diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminController.kt new file mode 100644 index 0000000..aa6870f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminController.kt @@ -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> = + ApiResponse.ok(service.listAll()) + + @GetMapping("/{curationId}/characters") + fun listCharacters( + @PathVariable curationId: Long + ): ApiResponse> { + 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)) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminDto.kt new file mode 100644 index 0000000..bb46b03 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminDto.kt @@ -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 +) + +data class CharacterCurationAddCharacterRequest( + val characterId: Long +) + +data class CharacterCurationReorderCharactersRequest( + val characterIds: List +) + +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 +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminService.kt new file mode 100644 index 0000000..72b7690 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminService.kt @@ -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) { + 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) { + 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 { + return curationRepository.findAllByOrderBySortOrderAsc() + .map { CharacterCurationListItemResponse(it.id!!, it.title, it.isAdult, it.isActive) } + } + + @Transactional(readOnly = true) + fun listCharacters(curationId: Long): List { + val curation = curationRepository.findById(curationId) + .orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") } + val mappings = mappingRepository.findByCurationWithCharacterOrderBySortOrderAsc(curation) + return mappings.map { it.chatCharacter } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt index e7e8fb4..7e7deaa 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt @@ -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() + // 큐레이션 섹션 (활성화된 큐레이션 + 캐릭터) + 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( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/curation/CharacterCuration.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/curation/CharacterCuration.kt new file mode 100644 index 0000000..021933e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/curation/CharacterCuration.kt @@ -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 = 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() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/curation/CharacterCurationQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/curation/CharacterCurationQueryService.kt new file mode 100644 index 0000000..31db343 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/curation/CharacterCurationQueryService.kt @@ -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 + ) + + @Transactional(readOnly = true) + fun getActiveCurationsWithCharacters(): List { + val curations = curationRepository.findByIsActiveTrueOrderBySortOrderAsc() + if (curations.isEmpty()) return emptyList() + + // 매핑 + 캐릭터를 한 번에 조회(ch.isActive = true 필터 적용)하여 N+1 해소 + val mappings = mappingRepository + .findByCurationInWithActiveCharacterOrderByCurationIdAscAndSortOrderAsc(curations) + + val charactersByCurationId: Map> = mappings + .groupBy { it.curation.id!! } + .mapValues { (_, list) -> list.map { it.chatCharacter } } + + return curations.map { curation -> + val characters = charactersByCurationId[curation.id!!] ?: emptyList() + CurationAgg(curation, characters) + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/curation/repository/CharacterCurationMappingRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/curation/repository/CharacterCurationMappingRepository.kt new file mode 100644 index 0000000..db42281 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/curation/repository/CharacterCurationMappingRepository.kt @@ -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 { + fun findByCuration(curation: CharacterCuration): List + + @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 + ): List + + @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 +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/curation/repository/CharacterCurationRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/curation/repository/CharacterCurationRepository.kt new file mode 100644 index 0000000..ed658d1 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/curation/repository/CharacterCurationRepository.kt @@ -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 { + fun findByIsActiveTrueOrderBySortOrderAsc(): List + fun findAllByOrderBySortOrderAsc(): List + + @Query("SELECT MAX(c.sortOrder) FROM CharacterCuration c WHERE c.isActive = true") + fun findMaxSortOrder(): Int? +}