feat(character-curation): 캐릭터 큐레이션 도메인/관리 API 추가 및 메인 화면 통합

- CharacterCuration/CharacterCurationMapping 엔티티 추가
- 리포지토리/서비스(조회·관리) 구현
- 관리자 컨트롤러에 등록/수정/삭제/정렬/캐릭터 추가·삭제·정렬 API 추가
- 앱 메인 API에 큐레이션 섹션 노출
- 정렬/소프트 삭제/활성 캐릭터 필터링 규칙 적용
This commit is contained in:
2025-08-28 17:39:53 +09:00
parent a58de0cf92
commit 6767afdd35
8 changed files with 392 additions and 2 deletions

View File

@@ -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(

View File

@@ -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()

View File

@@ -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)
}
}
}

View File

@@ -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>
}

View File

@@ -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?
}