From ec077d23f01e77974b37f3edd87950b6498eb60c Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 13 Feb 2026 15:46:54 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9D=B8=EA=B8=B0=20=EC=BA=90=EB=A6=AD?= =?UTF-8?q?=ED=84=B0=20=EB=B2=88=EC=97=AD=20=EC=A1=B0=ED=9A=8C=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/api/home/HomeService.kt | 37 +------------ .../controller/ChatCharacterController.kt | 4 +- .../character/service/ChatCharacterService.kt | 27 +++++---- .../service/PopularCharacterQuery.kt | 55 +++++++++++++++++++ 4 files changed, 74 insertions(+), 49 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt index 10ae9663..448cd110 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt @@ -1,7 +1,6 @@ package kr.co.vividnext.sodalive.api.home import kr.co.vividnext.sodalive.audition.AuditionService -import kr.co.vividnext.sodalive.chat.character.dto.Character import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository import kr.co.vividnext.sodalive.content.AudioContentMainItem @@ -145,7 +144,7 @@ class HomeService( ) // 인기 캐릭터 조회 - val translatedPopularCharacters = getTranslatedAiCharacterList(aiCharacterList = characterService.getPopularCharacters()) + val translatedPopularCharacters = characterService.getPopularCharacters(locale = langContext.lang.code) val currentDateTime = LocalDateTime.now() val startDate = currentDateTime @@ -473,38 +472,4 @@ class HomeService( return result.take(targetSize).shuffled() } - - /** - * AI 캐릭터 리스트의 이름/설명을 현재 언어(locale)에 맞춰 일괄 번역한다. - * - * 처리 절차: - * - characterId 목록을 추출하고, 요청 언어 코드로 aiCharacterTranslationRepository에서 - * 번역 데이터를 한 번에 조회한다. - * - 각 캐릭터에 대해 name과 description 모두 번역 값이 존재하고 비어있지 않을 때에만 - * 해당 필드를 교체한다. 둘 중 하나라도 없으면 원본 캐릭터를 그대로 유지한다. - * - * @param aiCharacterList 번역 대상 캐릭터 목록 - * @return 가능한 경우 name/description이 번역된 캐릭터 목록, 그 외는 원본 유지 - */ - private fun getTranslatedAiCharacterList(aiCharacterList: List): List { - val characterIds = aiCharacterList.map { it.characterId } - - return if (characterIds.isNotEmpty()) { - val translations = aiCharacterTranslationRepository - .findByCharacterIdInAndLocale(characterIds = characterIds, locale = langContext.lang.code) - .associateBy { it.characterId } - - aiCharacterList.map { character -> - val translatedName = translations[character.characterId]?.renderedPayload?.name - val translatedDesc = translations[character.characterId]?.renderedPayload?.description - if (translatedName.isNullOrBlank() || translatedDesc.isNullOrBlank()) { - character - } else { - character.copy(name = translatedName, description = translatedDesc) - } - } - } else { - aiCharacterList - } - } } 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 afa2bb37..9c81a93a 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 @@ -100,7 +100,7 @@ class ChatCharacterController( } // 인기 캐릭터 조회 - val popularCharacters = service.getPopularCharacters() + val popularCharacters = service.getPopularCharacters(locale = langContext.lang.code) // 최근 등록된 캐릭터 리스트 조회 val newCharacters = service.getRecentCharactersPage( @@ -138,7 +138,7 @@ class ChatCharacterController( CharacterMainResponse( banners = banners, recentCharacters = translatedRecentCharacters, - popularCharacters = getTranslatedAiCharacterList(popularCharacters), + popularCharacters = popularCharacters, newCharacters = getTranslatedAiCharacterList(newCharacters), recommendCharacters = getTranslatedAiCharacterList(recommendCharacters), curationSections = curationSections 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 47ac7565..23eb26ef 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 @@ -77,18 +77,23 @@ class ChatCharacterService( */ @Transactional(readOnly = true) @Cacheable( - cacheNames = ["popularCharacters_24h"], - key = "T(kr.co.vividnext.sodalive.chat.character.service.RankingWindowCalculator).now('popular-character').cacheKey" + cacheNames = ["popularCharacters_24h_locale"], + key = "T(kr.co.vividnext.sodalive.chat.character.service.RankingWindowCalculator)" + + ".now('popular-character').cacheKey + '-' + #locale" ) - fun getPopularCharacters(limit: Long = 20): List { + fun getPopularCharacters(locale: String, limit: Long = 20): List { val window = RankingWindowCalculator.now("popular-character") - val topIds = popularCharacterQuery.findPopularCharacterIds(window.windowStart, window.nextBoundary, limit) - val list = loadCharactersInOrder(topIds) + val results = popularCharacterQuery.findPopularCharactersWithTranslation( + window.windowStart, + window.nextBoundary, + limit, + locale + ) - val recentSet = if (list.isNotEmpty()) { + val recentSet = if (results.isNotEmpty()) { imageRepository .findCharacterIdsWithRecentImages( - list.map { it.id!! }, + results.map { it.id }, LocalDateTime.now().minusDays(3) ) .toSet() @@ -96,11 +101,11 @@ class ChatCharacterService( emptySet() } - return list.map { + return results.map { Character( - characterId = it.id!!, - name = it.name, - description = it.description, + characterId = it.id, + name = it.translatedPayload?.name.takeIf { name -> !name.isNullOrBlank() } ?: it.name, + description = it.translatedPayload?.description.takeIf { desc -> !desc.isNullOrBlank() } ?: it.description, imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}", new = recentSet.contains(it.id) ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/PopularCharacterQuery.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/PopularCharacterQuery.kt index ea542f78..94253a5b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/PopularCharacterQuery.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/PopularCharacterQuery.kt @@ -1,7 +1,10 @@ package kr.co.vividnext.sodalive.chat.character.service +import com.querydsl.core.types.Projections import com.querydsl.jpa.impl.JPAQueryFactory import kr.co.vividnext.sodalive.chat.character.QChatCharacter +import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRenderedPayload +import kr.co.vividnext.sodalive.chat.character.translate.QAiCharacterTranslation import kr.co.vividnext.sodalive.chat.room.ParticipantType import kr.co.vividnext.sodalive.chat.room.QChatMessage import kr.co.vividnext.sodalive.chat.room.QChatParticipant @@ -51,4 +54,56 @@ class PopularCharacterQuery( .limit(limit) .fetch() } + + data class PopularCharacterQueryResult( + val id: Long, + val name: String, + val description: String, + val imagePath: String?, + val translatedPayload: AiCharacterTranslationRenderedPayload? + ) + + fun findPopularCharactersWithTranslation( + windowStart: Instant, + endExclusive: Instant, + limit: Long, + locale: String + ): List { + val m = QChatMessage.chatMessage + val p = QChatParticipant.chatParticipant + val c = QChatCharacter.chatCharacter + val t = QAiCharacterTranslation.aiCharacterTranslation + + val start = LocalDateTime.ofInstant(windowStart, ZoneOffset.UTC) + val end = LocalDateTime.ofInstant(endExclusive, ZoneOffset.UTC) + + return queryFactory + .select( + Projections.constructor( + PopularCharacterQueryResult::class.java, + c.id, + c.name, + c.description, + c.imagePath, + t.renderedPayload + ) + ) + .from(m) + .join(p).on( + p.chatRoom.id.eq(m.chatRoom.id) + .and(p.participantType.eq(ParticipantType.CHARACTER)) + ) + .join(c).on(c.id.eq(p.character.id)) + .leftJoin(t).on(t.characterId.eq(c.id).and(t.locale.eq(locale))) + .where( + m.createdAt.goe(start) + .and(m.createdAt.lt(end)) + .and(m.isActive.isTrue) + .and(c.isActive.isTrue) + ) + .groupBy(c.id, c.name, c.description, c.imagePath, t.id, t.renderedPayload) + .orderBy(m.id.count().desc()) + .limit(limit) + .fetch() + } }