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 d86ac8d..3957e08 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 @@ -74,6 +74,13 @@ class ChatCharacterController( size = 50 ).content + // 추천 캐릭터 조회 + // 최근 대화한 캐릭터를 제외한 랜덤 20개 조회 + // Controller에서는 호출만 + // 세부로직은 추후에 변경될 수 있으므로 Service에 별도로 생성 + val excludeIds = recentCharacters.map { it.characterId } + val recommendCharacters = service.getRecommendCharacters(excludeIds, 20) + // 큐레이션 섹션 (활성화된 큐레이션 + 캐릭터) val curationSections = curationQueryService.getActiveCurationsWithCharacters() .map { agg -> @@ -98,6 +105,7 @@ class ChatCharacterController( recentCharacters = recentCharacters, popularCharacters = popularCharacters, newCharacters = newCharacters, + recommendCharacters = recommendCharacters, curationSections = curationSections ) ) @@ -193,4 +201,23 @@ class ChatCharacterController( ) ) } + + /** + * 추천 캐릭터 새로고침 API + * - 최근 대화한 캐릭터를 제외하고 랜덤 20개 반환 + * - 비회원 또는 본인인증되지 않은 경우: 최근 대화 목록 없음 → 전체 활성 캐릭터 중 랜덤 20개 + */ + @GetMapping("/recommend") + fun getRecommendCharacters( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + val recent = if (member == null || member.auth == null) { + emptyList() + } else { + chatRoomService + .listMyChatRooms(member, 0, 50) // 최근 기록은 최대 50개까지만 제외 대상으로 고려 + .map { it.characterId } + } + ApiResponse.ok(service.getRecommendCharacters(recent, 20)) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterHomeResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterHomeResponse.kt index e54ba93..6b848d9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterHomeResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterHomeResponse.kt @@ -7,6 +7,7 @@ data class CharacterMainResponse( val recentCharacters: List, val popularCharacters: List, val newCharacters: List, + val recommendCharacters: List, val curationSections: List ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt index eb9bc4d..e35bf6f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt @@ -74,5 +74,29 @@ interface ChatCharacterRepository : JpaRepository { pageable: Pageable ): List + /** + * 활성 캐릭터 무작위 조회 + */ + @Query( + """ + SELECT c FROM ChatCharacter c + WHERE c.isActive = true + ORDER BY function('RAND') + """ + ) + fun findRandomActive(pageable: Pageable): List + + /** + * 제외할 캐릭터를 뺀 활성 캐릭터 무작위 조회 + */ + @Query( + """ + SELECT c FROM ChatCharacter c + WHERE c.isActive = true AND c.id NOT IN :excludeIds + ORDER BY function('RAND') + """ + ) + fun findRandomActiveExcluding(@Param("excludeIds") excludeIds: List, pageable: Pageable): List + fun findByIdInAndIsActiveTrue(ids: List): List } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt index 1c23587..68d06aa 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt @@ -38,6 +38,24 @@ class ChatCharacterService( @Value("\${cloud.aws.cloud-front.host}") private val imageHost: String ) { + @Transactional(readOnly = true) + fun getRecommendCharacters(excludeCharacterIds: List = emptyList(), limit: Int = 20): List { + val safeLimit = if (limit <= 0) 20 else if (limit > 50) 50 else limit + val chars = if (excludeCharacterIds.isNotEmpty()) { + chatCharacterRepository.findRandomActiveExcluding(excludeCharacterIds, PageRequest.of(0, safeLimit)) + } else { + chatCharacterRepository.findRandomActive(PageRequest.of(0, safeLimit)) + } + return chars.map { + Character( + characterId = it.id!!, + name = it.name, + description = it.description, + imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}" + ) + } + } + /** * UTC 20:00 경계 기준 지난 윈도우의 메시지 수 상위 캐릭터 조회 * Spring Cache(@Cacheable) + 동적 키 + 고정 TTL(24h) 사용