캐릭터 챗봇 #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 chatRoomService: ChatRoomService, | ||||
|     private val characterCommentService: CharacterCommentService, | ||||
|     private val curationQueryService: kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationQueryService, | ||||
|  | ||||
|     @Value("\${cloud.aws.cloud-front.host}") | ||||
|     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( | ||||
|   | ||||
| @@ -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? | ||||
| } | ||||
		Reference in New Issue
	
	Block a user