캐릭터 챗봇 #338
|
@ -0,0 +1,76 @@
|
||||||
|
package kr.co.vividnext.sodalive.admin.chat.character.curation
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/chat/character/curation")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
class CharacterCurationAdminController(
|
||||||
|
private val service: CharacterCurationAdminService,
|
||||||
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
|
private val imageHost: String
|
||||||
|
) {
|
||||||
|
@GetMapping("/list")
|
||||||
|
fun listAll(): ApiResponse<List<CharacterCurationListItemResponse>> =
|
||||||
|
ApiResponse.ok(service.listAll())
|
||||||
|
|
||||||
|
@GetMapping("/{curationId}/characters")
|
||||||
|
fun listCharacters(
|
||||||
|
@PathVariable curationId: Long
|
||||||
|
): ApiResponse<List<CharacterCurationCharacterItemResponse>> {
|
||||||
|
val characters = service.listCharacters(curationId)
|
||||||
|
val items = characters.map {
|
||||||
|
CharacterCurationCharacterItemResponse(
|
||||||
|
id = it.id!!,
|
||||||
|
name = it.name,
|
||||||
|
description = it.description,
|
||||||
|
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return ApiResponse.ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/register")
|
||||||
|
fun register(@RequestBody request: CharacterCurationRegisterRequest) =
|
||||||
|
ApiResponse.ok(service.register(request).id)
|
||||||
|
|
||||||
|
@PutMapping("/update")
|
||||||
|
fun update(@RequestBody request: CharacterCurationUpdateRequest) =
|
||||||
|
ApiResponse.ok(service.update(request).id)
|
||||||
|
|
||||||
|
@DeleteMapping("/{curationId}")
|
||||||
|
fun delete(@PathVariable curationId: Long) =
|
||||||
|
ApiResponse.ok(service.softDelete(curationId))
|
||||||
|
|
||||||
|
@PutMapping("/reorder")
|
||||||
|
fun reorder(@RequestBody request: CharacterCurationOrderUpdateRequest) =
|
||||||
|
ApiResponse.ok(service.reorder(request.ids))
|
||||||
|
|
||||||
|
@PostMapping("/{curationId}/characters")
|
||||||
|
fun addCharacter(
|
||||||
|
@PathVariable curationId: Long,
|
||||||
|
@RequestBody request: CharacterCurationAddCharacterRequest
|
||||||
|
) = ApiResponse.ok(service.addCharacter(curationId, request.characterId))
|
||||||
|
|
||||||
|
@DeleteMapping("/{curationId}/characters/{characterId}")
|
||||||
|
fun removeCharacter(
|
||||||
|
@PathVariable curationId: Long,
|
||||||
|
@PathVariable characterId: Long
|
||||||
|
) = ApiResponse.ok(service.removeCharacter(curationId, characterId))
|
||||||
|
|
||||||
|
@PutMapping("/{curationId}/characters/reorder")
|
||||||
|
fun reorderCharacters(
|
||||||
|
@PathVariable curationId: Long,
|
||||||
|
@RequestBody request: CharacterCurationReorderCharactersRequest
|
||||||
|
) = ApiResponse.ok(service.reorderCharacters(curationId, request.characterIds))
|
||||||
|
}
|
|
@ -0,0 +1,44 @@
|
||||||
|
package kr.co.vividnext.sodalive.admin.chat.character.curation
|
||||||
|
|
||||||
|
data class CharacterCurationRegisterRequest(
|
||||||
|
val title: String,
|
||||||
|
val isAdult: Boolean = false,
|
||||||
|
val isActive: Boolean = true
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CharacterCurationUpdateRequest(
|
||||||
|
val id: Long,
|
||||||
|
val title: String? = null,
|
||||||
|
val isAdult: Boolean? = null,
|
||||||
|
val isActive: Boolean? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CharacterCurationOrderUpdateRequest(
|
||||||
|
val ids: List<Long>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CharacterCurationAddCharacterRequest(
|
||||||
|
val characterId: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CharacterCurationReorderCharactersRequest(
|
||||||
|
val characterIds: List<Long>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CharacterCurationListItemResponse(
|
||||||
|
val id: Long,
|
||||||
|
val title: String,
|
||||||
|
val isAdult: Boolean,
|
||||||
|
val isActive: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
// 관리자 큐레이션 상세 - 캐릭터 리스트 항목 응답 DTO
|
||||||
|
// id, name, description, 이미지 URL
|
||||||
|
// 이미지 URL은 컨트롤러에서 cloud-front host + imagePath로 구성
|
||||||
|
|
||||||
|
data class CharacterCurationCharacterItemResponse(
|
||||||
|
val id: Long,
|
||||||
|
val name: String,
|
||||||
|
val description: String,
|
||||||
|
val imageUrl: String
|
||||||
|
)
|
|
@ -0,0 +1,123 @@
|
||||||
|
package kr.co.vividnext.sodalive.admin.chat.character.curation
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.curation.CharacterCuration
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationMapping
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.curation.repository.CharacterCurationMappingRepository
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.curation.repository.CharacterCurationRepository
|
||||||
|
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 CharacterCurationAdminService(
|
||||||
|
private val curationRepository: CharacterCurationRepository,
|
||||||
|
private val mappingRepository: CharacterCurationMappingRepository,
|
||||||
|
private val characterRepository: ChatCharacterRepository
|
||||||
|
) {
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun register(request: CharacterCurationRegisterRequest): CharacterCuration {
|
||||||
|
val sortOrder = (curationRepository.findMaxSortOrder() ?: 0) + 1
|
||||||
|
val curation = CharacterCuration(
|
||||||
|
title = request.title,
|
||||||
|
isAdult = request.isAdult,
|
||||||
|
isActive = request.isActive,
|
||||||
|
sortOrder = sortOrder
|
||||||
|
)
|
||||||
|
return curationRepository.save(curation)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun update(request: CharacterCurationUpdateRequest): CharacterCuration {
|
||||||
|
val curation = curationRepository.findById(request.id)
|
||||||
|
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: ${request.id}") }
|
||||||
|
|
||||||
|
request.title?.let { curation.title = it }
|
||||||
|
request.isAdult?.let { curation.isAdult = it }
|
||||||
|
request.isActive?.let { curation.isActive = it }
|
||||||
|
|
||||||
|
return curationRepository.save(curation)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun softDelete(curationId: Long) {
|
||||||
|
val curation = curationRepository.findById(curationId)
|
||||||
|
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
|
||||||
|
curation.isActive = false
|
||||||
|
curationRepository.save(curation)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun reorder(ids: List<Long>) {
|
||||||
|
ids.forEachIndexed { index, id ->
|
||||||
|
val curation = curationRepository.findById(id)
|
||||||
|
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $id") }
|
||||||
|
curation.sortOrder = index + 1
|
||||||
|
curationRepository.save(curation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun addCharacter(curationId: Long, characterId: Long) {
|
||||||
|
val curation = curationRepository.findById(curationId)
|
||||||
|
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
|
||||||
|
if (!curation.isActive) throw SodaException("비활성화된 큐레이션입니다: $curationId")
|
||||||
|
|
||||||
|
val character = characterRepository.findById(characterId)
|
||||||
|
.orElseThrow { SodaException("캐릭터를 찾을 수 없습니다: $characterId") }
|
||||||
|
if (!character.isActive) throw SodaException("비활성화된 캐릭터는 추가할 수 없습니다: $characterId")
|
||||||
|
|
||||||
|
val existing = mappingRepository.findByCuration(curation)
|
||||||
|
.firstOrNull { it.chatCharacter.id == characterId }
|
||||||
|
if (existing != null) return // 이미 존재하면 무시
|
||||||
|
|
||||||
|
val nextOrder = (mappingRepository.findByCuration(curation).maxOfOrNull { it.sortOrder } ?: 0) + 1
|
||||||
|
val mapping = CharacterCurationMapping(
|
||||||
|
curation = curation,
|
||||||
|
chatCharacter = character,
|
||||||
|
sortOrder = nextOrder
|
||||||
|
)
|
||||||
|
mappingRepository.save(mapping)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun removeCharacter(curationId: Long, characterId: Long) {
|
||||||
|
val curation = curationRepository.findById(curationId)
|
||||||
|
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
|
||||||
|
val mappings = mappingRepository.findByCuration(curation)
|
||||||
|
val target = mappings.firstOrNull { it.chatCharacter.id == characterId }
|
||||||
|
?: throw SodaException("매핑을 찾을 수 없습니다: curation=$curationId, character=$characterId")
|
||||||
|
mappingRepository.delete(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun reorderCharacters(curationId: Long, characterIds: List<Long>) {
|
||||||
|
val curation = curationRepository.findById(curationId)
|
||||||
|
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
|
||||||
|
val mappings = mappingRepository.findByCuration(curation)
|
||||||
|
val mappingByCharacterId = mappings.associateBy { it.chatCharacter.id }
|
||||||
|
|
||||||
|
characterIds.forEachIndexed { index, cid ->
|
||||||
|
val mapping = mappingByCharacterId[cid]
|
||||||
|
?: throw SodaException("큐레이션에 포함되지 않은 캐릭터입니다: $cid")
|
||||||
|
mapping.sortOrder = index + 1
|
||||||
|
mappingRepository.save(mapping)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun listAll(): List<CharacterCurationListItemResponse> {
|
||||||
|
return curationRepository.findAllByOrderBySortOrderAsc()
|
||||||
|
.map { CharacterCurationListItemResponse(it.id!!, it.title, it.isAdult, it.isActive) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun listCharacters(curationId: Long): List<ChatCharacter> {
|
||||||
|
val curation = curationRepository.findById(curationId)
|
||||||
|
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
|
||||||
|
val mappings = mappingRepository.findByCurationWithCharacterOrderBySortOrderAsc(curation)
|
||||||
|
return mappings.map { it.chatCharacter }
|
||||||
|
}
|
||||||
|
}
|
|
@ -31,6 +31,7 @@ class ChatCharacterController(
|
||||||
private val bannerService: ChatCharacterBannerService,
|
private val bannerService: ChatCharacterBannerService,
|
||||||
private val chatRoomService: ChatRoomService,
|
private val chatRoomService: ChatRoomService,
|
||||||
private val characterCommentService: CharacterCommentService,
|
private val characterCommentService: CharacterCommentService,
|
||||||
|
private val curationQueryService: kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationQueryService,
|
||||||
|
|
||||||
@Value("\${cloud.aws.cloud-front.host}")
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
private val imageHost: String
|
private val imageHost: String
|
||||||
|
@ -85,8 +86,22 @@ class ChatCharacterController(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 큐레이션 섹션 (현재는 빈 리스트)
|
// 큐레이션 섹션 (활성화된 큐레이션 + 캐릭터)
|
||||||
val curationSections = emptyList<CurationSection>()
|
val curationSections = curationQueryService.getActiveCurationsWithCharacters()
|
||||||
|
.map { agg ->
|
||||||
|
CurationSection(
|
||||||
|
characterCurationId = agg.curation.id!!,
|
||||||
|
title = agg.curation.title,
|
||||||
|
characters = agg.characters.map {
|
||||||
|
Character(
|
||||||
|
characterId = it.id!!,
|
||||||
|
name = it.name,
|
||||||
|
description = it.description,
|
||||||
|
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 응답 생성
|
// 응답 생성
|
||||||
ApiResponse.ok(
|
ApiResponse.ok(
|
||||||
|
|
|
@ -0,0 +1,47 @@
|
||||||
|
package kr.co.vividnext.sodalive.chat.character.curation
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||||
|
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||||
|
import javax.persistence.CascadeType
|
||||||
|
import javax.persistence.Column
|
||||||
|
import javax.persistence.Entity
|
||||||
|
import javax.persistence.FetchType
|
||||||
|
import javax.persistence.JoinColumn
|
||||||
|
import javax.persistence.ManyToOne
|
||||||
|
import javax.persistence.OneToMany
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
class CharacterCuration(
|
||||||
|
@Column(nullable = false)
|
||||||
|
var title: String,
|
||||||
|
|
||||||
|
// 19금 여부
|
||||||
|
@Column(nullable = false)
|
||||||
|
var isAdult: Boolean = false,
|
||||||
|
|
||||||
|
// 활성화 여부 (소프트 삭제)
|
||||||
|
@Column(nullable = false)
|
||||||
|
var isActive: Boolean = true,
|
||||||
|
|
||||||
|
// 정렬 순서 (낮을수록 먼저)
|
||||||
|
@Column(nullable = false)
|
||||||
|
var sortOrder: Int = 0
|
||||||
|
) : BaseEntity() {
|
||||||
|
@OneToMany(mappedBy = "curation", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
|
||||||
|
var characterMappings: MutableList<CharacterCurationMapping> = mutableListOf()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
class CharacterCurationMapping(
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "curation_id")
|
||||||
|
var curation: CharacterCuration,
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "character_id")
|
||||||
|
var chatCharacter: ChatCharacter,
|
||||||
|
|
||||||
|
// 정렬 순서 (낮을수록 먼저)
|
||||||
|
@Column(nullable = false)
|
||||||
|
var sortOrder: Int = 0
|
||||||
|
) : BaseEntity()
|
|
@ -0,0 +1,37 @@
|
||||||
|
package kr.co.vividnext.sodalive.chat.character.curation
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.curation.repository.CharacterCurationMappingRepository
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.curation.repository.CharacterCurationRepository
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class CharacterCurationQueryService(
|
||||||
|
private val curationRepository: CharacterCurationRepository,
|
||||||
|
private val mappingRepository: CharacterCurationMappingRepository
|
||||||
|
) {
|
||||||
|
data class CurationAgg(
|
||||||
|
val curation: CharacterCuration,
|
||||||
|
val characters: List<ChatCharacter>
|
||||||
|
)
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun getActiveCurationsWithCharacters(): List<CurationAgg> {
|
||||||
|
val curations = curationRepository.findByIsActiveTrueOrderBySortOrderAsc()
|
||||||
|
if (curations.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
// 매핑 + 캐릭터를 한 번에 조회(ch.isActive = true 필터 적용)하여 N+1 해소
|
||||||
|
val mappings = mappingRepository
|
||||||
|
.findByCurationInWithActiveCharacterOrderByCurationIdAscAndSortOrderAsc(curations)
|
||||||
|
|
||||||
|
val charactersByCurationId: Map<Long, List<ChatCharacter>> = mappings
|
||||||
|
.groupBy { it.curation.id!! }
|
||||||
|
.mapValues { (_, list) -> list.map { it.chatCharacter } }
|
||||||
|
|
||||||
|
return curations.map { curation ->
|
||||||
|
val characters = charactersByCurationId[curation.id!!] ?: emptyList()
|
||||||
|
CurationAgg(curation, characters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
package kr.co.vividnext.sodalive.chat.character.curation.repository
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.curation.CharacterCuration
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationMapping
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
import org.springframework.data.jpa.repository.Query
|
||||||
|
import org.springframework.data.repository.query.Param
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
interface CharacterCurationMappingRepository : JpaRepository<CharacterCurationMapping, Long> {
|
||||||
|
fun findByCuration(curation: CharacterCuration): List<CharacterCurationMapping>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"select m from CharacterCurationMapping m " +
|
||||||
|
"join fetch m.chatCharacter ch " +
|
||||||
|
"where m.curation in :curations and ch.isActive = true " +
|
||||||
|
"order by m.curation.id asc, m.sortOrder asc"
|
||||||
|
)
|
||||||
|
fun findByCurationInWithActiveCharacterOrderByCurationIdAscAndSortOrderAsc(
|
||||||
|
@Param("curations") curations: List<CharacterCuration>
|
||||||
|
): List<CharacterCurationMapping>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"select m from CharacterCurationMapping m " +
|
||||||
|
"join fetch m.chatCharacter ch " +
|
||||||
|
"where m.curation = :curation " +
|
||||||
|
"order by m.sortOrder asc"
|
||||||
|
)
|
||||||
|
fun findByCurationWithCharacterOrderBySortOrderAsc(
|
||||||
|
@Param("curation") curation: CharacterCuration
|
||||||
|
): List<CharacterCurationMapping>
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
package kr.co.vividnext.sodalive.chat.character.curation.repository
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.curation.CharacterCuration
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
import org.springframework.data.jpa.repository.Query
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
interface CharacterCurationRepository : JpaRepository<CharacterCuration, Long> {
|
||||||
|
fun findByIsActiveTrueOrderBySortOrderAsc(): List<CharacterCuration>
|
||||||
|
fun findAllByOrderBySortOrderAsc(): List<CharacterCuration>
|
||||||
|
|
||||||
|
@Query("SELECT MAX(c.sortOrder) FROM CharacterCuration c WHERE c.isActive = true")
|
||||||
|
fun findMaxSortOrder(): Int?
|
||||||
|
}
|
Loading…
Reference in New Issue