인기 캐릭터 번역 조회 개선

This commit is contained in:
2026-02-13 15:46:54 +09:00
parent 01a1a05d77
commit ec077d23f0
4 changed files with 74 additions and 49 deletions

View File

@@ -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<Character>): List<Character> {
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
}
}
}

View File

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

View File

@@ -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<Character> {
fun getPopularCharacters(locale: String, limit: Long = 20): List<Character> {
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)
)

View File

@@ -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<PopularCharacterQueryResult> {
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()
}
}