feat(chat-character-image): 캐릭터 이미지

- 등록, 리스트, 상세, 트리거 단어 업데이트, 삭제 기능 추가
This commit is contained in:
2025-08-21 03:33:42 +09:00
parent ca27903e45
commit dd6849b840
9 changed files with 431 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
package kr.co.vividnext.sodalive.chat.character.image
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.CascadeType
import javax.persistence.Entity
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
import javax.persistence.OneToMany
@Entity
class CharacterImage(
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "character_id")
var chatCharacter: ChatCharacter,
// 이미지 경로 (S3 key)
var imagePath: String,
// 가격 (메시지/이미지 통합 단일가 - 요구사항 범위)
var price: Long = 0L,
// 성인 이미지 여부 (본인인증 필요)
var isAdult: Boolean = false,
// 갤러리/관리자 노출 순서 (낮을수록 먼저)
var sortOrder: Int = 0,
// 활성화 여부 (소프트 삭제)
var isActive: Boolean = true
) : BaseEntity() {
@OneToMany(mappedBy = "characterImage", cascade = [CascadeType.ALL], orphanRemoval = true)
var triggerMappings: MutableList<CharacterImageTriggerMapping> = mutableListOf()
}

View File

@@ -0,0 +1,16 @@
package kr.co.vividnext.sodalive.chat.character.image
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.stereotype.Repository
@Repository
interface CharacterImageRepository : JpaRepository<CharacterImage, Long> {
fun findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(characterId: Long): List<CharacterImage>
@Query(
"SELECT COALESCE(MAX(ci.sortOrder), 0) FROM CharacterImage ci " +
"WHERE ci.chatCharacter.id = :characterId AND ci.isActive = true"
)
fun findMaxSortOrderByCharacterId(characterId: Long): Int
}

View File

@@ -0,0 +1,99 @@
package kr.co.vividnext.sodalive.chat.character.image
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.common.SodaException
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class CharacterImageService(
private val characterRepository: ChatCharacterRepository,
private val imageRepository: CharacterImageRepository,
private val triggerTagRepository: CharacterImageTriggerRepository
) {
fun listActiveByCharacter(characterId: Long): List<CharacterImage> {
return imageRepository.findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(characterId)
}
fun getById(id: Long): CharacterImage =
imageRepository.findById(id).orElseThrow { SodaException("캐릭터 이미지를 찾을 수 없습니다: $id") }
@Transactional
fun registerImage(
characterId: Long,
imagePath: String,
price: Long,
isAdult: Boolean,
triggers: List<String>
): CharacterImage {
val character = characterRepository.findById(characterId)
.orElseThrow { SodaException("캐릭터를 찾을 수 없습니다: $characterId") }
if (!character.isActive) throw SodaException("비활성화된 캐릭터에는 이미지를 등록할 수 없습니다: $characterId")
val nextOrder = (imageRepository.findMaxSortOrderByCharacterId(characterId)) + 1
val entity = CharacterImage(
chatCharacter = character,
imagePath = imagePath,
price = price,
isAdult = isAdult,
sortOrder = nextOrder,
isActive = true
)
val saved = imageRepository.save(entity)
applyTriggers(saved, triggers)
return saved
}
/**
* 수정은 트리거만 가능
*/
@Transactional
fun updateTriggers(imageId: Long, triggers: List<String>): CharacterImage {
val image = getById(imageId)
if (!image.isActive) throw SodaException("비활성화된 이미지는 수정할 수 없습니다: $imageId")
applyTriggers(image, triggers)
return image
}
private fun applyTriggers(image: CharacterImage, triggers: List<String>) {
// 입력 트리거 정규화
val newWords = triggers.mapNotNull { it.trim().lowercase().takeIf { s -> s.isNotBlank() } }.distinct().toSet()
// 현재 매핑 단어 집합
val currentMappings = image.triggerMappings
val currentWords = currentMappings.map { it.tag.word }.toSet()
// 제거되어야 할 매핑(현재는 있지만 새 입력에는 없는 단어)
val toRemove = currentMappings.filter { it.tag.word !in newWords }
currentMappings.removeAll(toRemove)
// 추가되어야 할 단어(새 입력에는 있지만 현재는 없는 단어)
val toAdd = newWords.minus(currentWords)
toAdd.forEach { w ->
val tag = triggerTagRepository.findByWord(w) ?: triggerTagRepository.save(CharacterImageTrigger(word = w))
currentMappings.add(CharacterImageTriggerMapping(characterImage = image, tag = tag))
}
}
@Transactional
fun deleteImage(imageId: Long) {
val image = getById(imageId)
image.isActive = false
}
@Transactional
fun updateOrders(characterId: Long, ids: List<Long>): List<CharacterImage> {
// 동일 캐릭터 소속 검증 및 순서 재지정
val updated = mutableListOf<CharacterImage>()
ids.forEachIndexed { idx, id ->
val img = getById(id)
if (img.chatCharacter.id != characterId) throw SodaException("다른 캐릭터의 이미지가 포함되어 있습니다: $id")
if (!img.isActive) throw SodaException("비활성화된 이미지는 순서를 변경할 수 없습니다: $id")
img.sortOrder = idx + 1
updated.add(img)
}
return updated
}
}

View File

@@ -0,0 +1,24 @@
package kr.co.vividnext.sodalive.chat.character.image
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.FetchType
import javax.persistence.OneToMany
import javax.persistence.Table
import javax.persistence.UniqueConstraint
/**
* 캐릭터 이미지 트리거 "태그" 엔티티
* - word를 전역 고유로 관리하여 중복 단어 저장을 방지한다.
* - 이미지와의 연결은 CharacterImageTriggerMapping을 사용한다.
*/
@Entity
@Table(uniqueConstraints = [UniqueConstraint(columnNames = ["word"])])
class CharacterImageTrigger(
@Column(nullable = false)
var word: String
) : BaseEntity() {
@OneToMany(mappedBy = "tag", fetch = FetchType.LAZY)
var mappings: MutableList<CharacterImageTriggerMapping> = mutableListOf()
}

View File

@@ -0,0 +1,21 @@
package kr.co.vividnext.sodalive.chat.character.image
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.Entity
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
/**
* CharacterImage 와 CharacterImageTrigger(태그) 사이의 매핑 엔티티
*/
@Entity
class CharacterImageTriggerMapping(
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "character_image_id")
var characterImage: CharacterImage,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "tag_id")
var tag: CharacterImageTrigger
) : BaseEntity()

View File

@@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.chat.character.image
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
interface CharacterImageTriggerRepository : JpaRepository<CharacterImageTrigger, Long> {
fun findByWord(word: String): CharacterImageTrigger?
}