diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt index cfd16a89..9c129fd2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt @@ -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 { val sql = """ with creator_debut as ( @@ -475,7 +478,7 @@ class DefaultHomeRecommendationQueryRepository( @Suppress("UNCHECKED_CAST") val rows = query.resultList as List> - 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( diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt index 265b8e57..56ad10cf 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt @@ -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 = firstAudioContents override fun findAiCharacterSnapshots( diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt index 582b797c..56221dbf 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt @@ -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,