feat(home-recommendation): 첫 오디오 제목 번역을 적용한다

This commit is contained in:
2026-09-09 23:21:49 +09:00
parent 14394b8263
commit 21f72f2827
3 changed files with 159 additions and 18 deletions
@@ -15,6 +15,7 @@ import kr.co.vividnext.sodalive.chat.room.QChatMessage.chatMessage
import kr.co.vividnext.sodalive.chat.room.QChatParticipant.chatParticipant
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
import kr.co.vividnext.sodalive.content.main.banner.QAudioContentBanner.audioContentBanner
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
import kr.co.vividnext.sodalive.event.QEvent.event
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommunity.creatorCommunity
@@ -50,7 +51,8 @@ import javax.persistence.EntityManager
@Repository
class DefaultHomeRecommendationQueryRepository(
private val queryFactory: JPAQueryFactory,
private val entityManager: EntityManager
private val entityManager: EntityManager,
private val contentTranslationRepository: ContentTranslationRepository
) : HomeRecommendationQueryRepository {
override fun findLiveRecommendations(
offset: Long,
@@ -367,7 +369,8 @@ class DefaultHomeRecommendationQueryRepository(
offset: Long,
limit: Int,
memberId: Long?,
includeAdultContents: Boolean
includeAdultContents: Boolean,
locale: String
): List<HomeFirstAudioContentRecord> {
val sql = """
with creator_debut as (
@@ -475,7 +478,7 @@ class DefaultHomeRecommendationQueryRepository(
@Suppress("UNCHECKED_CAST")
val rows = query.resultList as List<Array<Any?>>
return rows.map { row ->
val firstAudioContents = rows.map { row ->
HomeFirstAudioContentRecord(
contentId = (row[0] as Number).toLong(),
creatorId = (row[1] as Number).toLong(),
@@ -489,6 +492,19 @@ class DefaultHomeRecommendationQueryRepository(
isOriginalSeries = row[9].toNativeBoolean()
)
}
if (firstAudioContents.isEmpty()) return emptyList()
val translatedTitleByContentId = contentTranslationRepository
.findByContentIdInAndLocale(firstAudioContents.map { it.contentId }, locale)
.mapNotNull { translation ->
translation.renderedPayload.title
.takeIf { it.isNotBlank() }
?.let { translation.contentId to it }
}
.toMap()
return firstAudioContents.map { audio ->
translatedTitleByContentId[audio.contentId]?.let { audio.copy(title = it) } ?: audio
}
}
override fun findAiCharacterSnapshots(
@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.v2.api.home.application
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService
@@ -26,7 +27,11 @@ import java.time.LocalDateTime
class HomeRecommendationFacadeTest {
private val queryPort = FakeHomeRecommendationQueryPort()
private val queryService = HomeRecommendationQueryService(queryPort, EmptyRecommendationSnapshotPort())
private val queryService = HomeRecommendationQueryService(
queryPort,
EmptyRecommendationSnapshotPort(),
langContext = LangContext()
)
private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
private val facade = HomeRecommendationFacade(queryService, preferenceService, "https://cdn.test")
@@ -135,7 +140,8 @@ class HomeRecommendationFacadeTest {
offset: Long,
limit: Int,
memberId: Long?,
includeAdultContents: Boolean
includeAdultContents: Boolean,
locale: String
): List<HomeFirstAudioContentRecord> = firstAudioContents
override fun findAiCharacterSnapshots(
@@ -21,6 +21,9 @@ import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTab
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.content.translation.ContentTranslation
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
import kr.co.vividnext.sodalive.event.Event
@@ -63,9 +66,10 @@ import javax.persistence.Query
@Import(QueryDslConfig::class)
class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
private val entityManager: EntityManager,
queryFactory: JPAQueryFactory
queryFactory: JPAQueryFactory,
contentTranslationRepository: ContentTranslationRepository
) {
private val repository = DefaultHomeRecommendationQueryRepository(queryFactory, entityManager)
private val repository = DefaultHomeRecommendationQueryRepository(queryFactory, entityManager, contentTranslationRepository)
private val scorePolicy = RecommendationScorePolicy()
@Test
@@ -1565,12 +1569,76 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
).forEach { (content, createdAt) -> updateCreatedAt("AudioContent", content.id!!, createdAt) }
flushAndClear()
val contents = repository.findFirstAudioContents(now, limit = 10)
val contents = repository.findFirstAudioContents(now, limit = 10, locale = Lang.KO.code)
assertEquals(listOf(eligibleActive.id), contents.map { it.contentId })
assertEquals(12, contents.single().price)
}
@Test
@DisplayName("첫 오디오 native query는 빈 문자열, 공백, 누락, 다른 언어 번역을 원문으로 대체한다")
fun shouldTranslateFirstAudioContentsByRequestedLocaleWithFallback() {
val now = LocalDateTime.of(2026, 5, 31, 10, 0)
val translated = saveAudioContent(
saveMember("translated-first-audio", MemberRole.CREATOR),
now.minusDays(1),
isActive = true
)
val empty = saveAudioContent(
saveMember("empty-first-audio", MemberRole.CREATOR),
now.minusDays(2),
isActive = true
)
val whitespace = saveAudioContent(
saveMember("whitespace-first-audio", MemberRole.CREATOR),
now.minusDays(3),
isActive = true
)
val otherLocaleOnly = saveAudioContent(
saveMember("other-locale-first-audio", MemberRole.CREATOR),
now.minusDays(4),
isActive = true
)
val missing = saveAudioContent(
saveMember("missing-first-audio", MemberRole.CREATOR),
now.minusDays(5),
isActive = true
)
saveContentTranslation(translated, "en", "first-audio-en")
saveContentTranslation(translated, "ja", "first-audio-ja")
saveContentTranslation(empty, "en", "")
saveContentTranslation(whitespace, "en", " ")
saveContentTranslation(otherLocaleOnly, "en", "other-locale-first-audio-en")
flushAndClear()
val englishRecords = repository.findFirstAudioContents(now, limit = 10, locale = Lang.EN.code)
val japaneseRecords = repository.findFirstAudioContents(now, limit = 10, locale = Lang.JA.code)
assertEquals(englishRecords.map { it.contentId }, japaneseRecords.map { it.contentId })
assertEquals(
"first-audio-en",
englishRecords.single { it.contentId == translated.id }.title
)
assertEquals(empty.title, englishRecords.single { it.contentId == empty.id }.title)
assertEquals(whitespace.title, englishRecords.single { it.contentId == whitespace.id }.title)
assertEquals(
"other-locale-first-audio-en",
englishRecords.single { it.contentId == otherLocaleOnly.id }.title
)
assertEquals(missing.title, englishRecords.single { it.contentId == missing.id }.title)
assertEquals(
"first-audio-ja",
japaneseRecords.single { it.contentId == translated.id }.title
)
assertEquals(empty.title, japaneseRecords.single { it.contentId == empty.id }.title)
assertEquals(whitespace.title, japaneseRecords.single { it.contentId == whitespace.id }.title)
assertEquals(
otherLocaleOnly.title,
japaneseRecords.single { it.contentId == otherLocaleOnly.id }.title
)
assertEquals(missing.title, japaneseRecords.single { it.contentId == missing.id }.title)
}
@Test
@DisplayName("첫 오디오 콘텐츠는 예약/미공개 콘텐츠를 제외하고 releaseDate 최신성 점수순으로 조회한다")
fun shouldFindFirstAudioContentsOrderedByReleaseDateRecencyScoreExcludingScheduledContents() {
@@ -1585,7 +1653,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
updateCreatedAt("AudioContent", old.id!!, now.minusDays(20))
flushAndClear()
val contents = repository.findFirstAudioContents(now, limit = 10)
val contents = repository.findFirstAudioContents(now, limit = 10, locale = Lang.KO.code)
assertEquals(listOf(fresh.id, old.id), contents.map { it.contentId })
}
@@ -1602,8 +1670,20 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
val oldest = saveAudioContent(oldestCreator, now.minusDays(21), isActive = true, isAdult = false)
flushAndClear()
val page0 = repository.findFirstAudioContents(now, offset = 0L, limit = 2, includeAdultContents = false)
val page1 = repository.findFirstAudioContents(now, offset = 1L, limit = 2, includeAdultContents = false)
val page0 = repository.findFirstAudioContents(
now,
offset = 0L,
limit = 2,
includeAdultContents = false,
locale = Lang.KO.code
)
val page1 = repository.findFirstAudioContents(
now,
offset = 1L,
limit = 2,
includeAdultContents = false,
locale = Lang.KO.code
)
assertEquals(listOf(newest.id, oldest.id), page0.map { it.contentId })
assertEquals(listOf(oldest.id), page1.map { it.contentId })
@@ -1622,7 +1702,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
updateCreatedAt("AudioContent", content.id!!, now.minusDays(1))
flushAndClear()
val contents = repository.findFirstAudioContents(now, limit = 10)
val contents = repository.findFirstAudioContents(now, limit = 10, locale = Lang.KO.code)
assertEquals(false, contents.single().isAdult)
assertEquals(true, contents.single().isOriginalSeries)
@@ -1633,12 +1713,16 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
fun shouldMapNumericNativeBooleanFromFirstAudioContentRows() {
val mockEntityManager = Mockito.mock(EntityManager::class.java)
val mockQuery = Mockito.mock(Query::class.java)
val mockContentTranslationRepository = Mockito.mock(ContentTranslationRepository::class.java)
val repository = DefaultHomeRecommendationQueryRepository(
JPAQueryFactory(mockEntityManager),
mockEntityManager
mockEntityManager,
mockContentTranslationRepository
)
Mockito.`when`(mockEntityManager.createNativeQuery(anyString())).thenReturn(mockQuery)
Mockito.`when`(mockQuery.setParameter(anyString(), any())).thenReturn(mockQuery)
Mockito.`when`(mockContentTranslationRepository.findByContentIdInAndLocale(listOf(1L), Lang.KO.code))
.thenReturn(emptyList())
Mockito.`when`(mockQuery.resultList).thenReturn(
listOf(
arrayOf(
@@ -1661,7 +1745,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
offset = 0L,
limit = 1,
memberId = null,
includeAdultContents = false
includeAdultContents = false,
locale = Lang.KO.code
)
assertEquals(true, contents.single().isOriginalSeries)
@@ -1682,7 +1767,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
saveBlock(creatorBlockedViewer, viewer)
flushAndClear()
val contents = repository.findFirstAudioContents(now, limit = 10, memberId = viewer.id)
val contents = repository.findFirstAudioContents(now, limit = 10, memberId = viewer.id, locale = Lang.KO.code)
assertEquals(listOf(visibleContent.id), contents.map { it.contentId })
}
@@ -1699,9 +1784,27 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
val content3 = saveAudioContent(creator3, now.minusDays(5), isActive = true)
flushAndClear()
val page0 = repository.findFirstAudioContents(now, offset = 0L, limit = 1, includeAdultContents = false)
val page1 = repository.findFirstAudioContents(now, offset = 1L, limit = 1, includeAdultContents = false)
val page2 = repository.findFirstAudioContents(now, offset = 2L, limit = 1, includeAdultContents = false)
val page0 = repository.findFirstAudioContents(
now,
offset = 0L,
limit = 1,
includeAdultContents = false,
locale = Lang.KO.code
)
val page1 = repository.findFirstAudioContents(
now,
offset = 1L,
limit = 1,
includeAdultContents = false,
locale = Lang.KO.code
)
val page2 = repository.findFirstAudioContents(
now,
offset = 2L,
limit = 1,
includeAdultContents = false,
locale = Lang.KO.code
)
val pagedContentIds = (page0 + page1 + page2).map { it.contentId }
assertEquals(setOf(content1.id, content2.id, content3.id), pagedContentIds.toSet())
@@ -2621,6 +2724,22 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
return content
}
private fun saveContentTranslation(content: AudioContent, locale: String, title: String) {
val translation = ContentTranslation(
contentId = content.id!!,
locale = locale,
renderedPayload = ContentTranslationPayload(title = title, detail = "", tags = "")
)
entityManager.persist(translation)
entityManager.flush()
val payload = "{\"title\":\"$title\",\"detail\":\"\",\"tags\":\"\"}"
entityManager.createNativeQuery(
"update content_translation set rendered_payload = '$payload' format json where id = :id"
)
.setParameter("id", translation.id)
.executeUpdate()
}
private fun saveTheme(name: String, isActive: Boolean = true): AudioContentTheme {
val theme = AudioContentTheme(
theme = name,