From ee3dfc40d9af8756e3e17ce22e294816a04a5100 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 9 Sep 2026 23:18:44 +0900 Subject: [PATCH] =?UTF-8?q?feat(content-all):=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A0=9C=EB=AA=A9=20=EB=B2=88=EC=97=AD?= =?UTF-8?q?=EC=9D=84=20=EC=A0=81=EC=9A=A9=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DefaultMainContentAllQueryRepository.kt | 23 +++++- ...efaultMainContentAllQueryRepositoryTest.kt | 77 +++++++++++++++++-- 2 files changed, 91 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt index 484bd4ec..219d58f2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt @@ -12,6 +12,7 @@ import kr.co.vividnext.sodalive.content.order.QOrder import kr.co.vividnext.sodalive.content.series.translation.QSeriesTranslation import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme +import kr.co.vividnext.sodalive.content.translation.QContentTranslation import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek @@ -58,6 +59,7 @@ class DefaultMainContentAllQueryRepository( sort: ContentSort, offset: Long, limit: Int, + locale: String, onlyPaid: Boolean, onlyFree: Boolean, onlyPointAvailable: Boolean @@ -69,13 +71,14 @@ class DefaultMainContentAllQueryRepository( val creatorIds = rows.map { it.get(audioContent.member.id)!! }.distinct() val firstContentIdByCreatorId = firstAudioContentIds(creatorIds, now, canViewAdultContent) val originalSeriesByContentId = originalSeriesFlags(contentIds) + val titlesByContentId = audioTitlesByContentIds(contentIds, locale) return rows.map { row -> val contentId = row.get(audioContent.id)!! val creatorId = row.get(audioContent.member.id)!! MainContentAllAudio( audioContentId = contentId, - title = row.get(audioContent.title)!!, + title = titlesByContentId[contentId] ?: row.get(audioContent.title)!!, imageUrl = row.get(audioContent.coverImage).toCdnUrl(cloudFrontHost), price = row.get(audioContent.price)!!, isAdult = row.get(audioContent.isAdult)!!, @@ -343,6 +346,24 @@ class DefaultMainContentAllQueryRepository( .associate { it.get(seriesContent.content.id)!! to it.get(series.isOriginal)!! } } + private fun audioTitlesByContentIds(contentIds: List, locale: String): Map { + if (contentIds.isEmpty()) return emptyMap() + val contentTranslation = QContentTranslation("mainContentAllContentTranslation") + return queryFactory + .select(contentTranslation.contentId, contentTranslation.renderedPayload) + .from(contentTranslation) + .where( + contentTranslation.contentId.`in`(contentIds), + contentTranslation.locale.eq(locale) + ) + .fetch() + .mapNotNull { + val title = it.get(contentTranslation.renderedPayload)?.title.takeUnless(String?::isNullOrBlank) + title?.let { translatedTitle -> it.get(contentTranslation.contentId)!! to translatedTitle } + } + .toMap() + } + private fun audioCondition( memberId: Long?, canViewAdultContent: Boolean, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt index f6f95220..1a57db2a 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt @@ -9,6 +9,8 @@ import kr.co.vividnext.sodalive.content.order.OrderType import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslation import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationPayload 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.creator.admin.content.series.Series import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek @@ -62,10 +64,28 @@ class DefaultMainContentAllQueryRepositoryTest @Autowired constructor( saveBlock(viewer, blockedCreator) flushAndClear() - val visible = repository.findAudios(viewer.id, canViewAdultContent = false, now, ContentSort.LATEST, 0, 20) - val paidAudios = repository.findAudios(viewer.id, false, now, ContentSort.LATEST, 0, 20, onlyPaid = true) - val freeAudios = repository.findAudios(null, false, now, ContentSort.LATEST, 0, 20, onlyFree = true) - val pointAudios = repository.findAudios(null, false, now, ContentSort.LATEST, 0, 20, onlyPointAvailable = true) + val visible = repository.findAudios(viewer.id, canViewAdultContent = false, now, ContentSort.LATEST, 0, 20, locale = "ko") + val paidAudios = repository.findAudios( + viewer.id, + false, + now, + ContentSort.LATEST, + 0, + 20, + locale = "ko", + onlyPaid = true + ) + val freeAudios = repository.findAudios(null, false, now, ContentSort.LATEST, 0, 20, locale = "ko", onlyFree = true) + val pointAudios = repository.findAudios( + null, + false, + now, + ContentSort.LATEST, + 0, + 20, + locale = "ko", + onlyPointAvailable = true + ) assertEquals(3, repository.countAudios(viewer.id, canViewAdultContent = false, now)) assertEquals(1, repository.countAudios(viewer.id, false, now, onlyPaid = true)) @@ -95,9 +115,9 @@ class DefaultMainContentAllQueryRepositoryTest @Autowired constructor( saveOrder(buyer, creator, middle, can = 1000, isActive = false) flushAndClear() - val priceHigh = repository.findAudios(null, false, now, ContentSort.PRICE_HIGH, 0, 20) - val priceLow = repository.findAudios(null, false, now, ContentSort.PRICE_LOW, 0, 20) - val popular = repository.findAudios(null, false, now, ContentSort.POPULAR, 0, 20) + val priceHigh = repository.findAudios(null, false, now, ContentSort.PRICE_HIGH, 0, 20, locale = "ko") + val priceLow = repository.findAudios(null, false, now, ContentSort.PRICE_LOW, 0, 20, locale = "ko") + val popular = repository.findAudios(null, false, now, ContentSort.POPULAR, 0, 20, locale = "ko") assertEquals(listOf(high.id, middle.id, low.id, first.id), priceHigh.map { it.audioContentId }) assertEquals(listOf(low.id, first.id, middle.id, high.id), priceLow.map { it.audioContentId }) @@ -118,11 +138,35 @@ class DefaultMainContentAllQueryRepositoryTest @Autowired constructor( val sameDateLowPrice = saveAudioContent(creator, theme, now.minusDays(1), isAdult = false, price = 100) flushAndClear() - val latest = repository.findAudios(null, false, now, ContentSort.LATEST, 0, 20) + val latest = repository.findAudios(null, false, now, ContentSort.LATEST, 0, 20, locale = "ko") assertEquals(listOf(sameDateLowPrice.id, sameDateHighPrice.id), latest.map { it.audioContentId }) } + @Test + @DisplayName("오디오 제목은 locale 번역값을 사용하고 blank 번역은 원문으로 fallback한다") + fun shouldFindAudiosWithTranslatedTitleFallback() { + val now = LocalDateTime.of(2026, 6, 25, 12, 0) + val creator = saveMember("audio-translation-creator", MemberRole.CREATOR) + val theme = saveTheme("audio-translation-theme") + val fallback = saveAudioContent(creator, theme, now.minusDays(2), isAdult = false, price = 100) + val translated = saveAudioContent(creator, theme, now.minusDays(1), isAdult = false, price = 100) + saveContentTranslation(translated, "en", "Translated Main Audio") + saveContentTranslation(translated, "ja", "翻訳メインオーディオ") + saveContentTranslation(fallback, "en", " ") + flushAndClear() + + val enRecords = repository.findAudios(null, false, now, ContentSort.LATEST, 0, 20, locale = "en") + val jaRecords = repository.findAudios(null, false, now, ContentSort.LATEST, 0, 20, locale = "ja") + + assertEquals(enRecords.map { it.audioContentId }, jaRecords.map { it.audioContentId }) + assertEquals(listOf(translated.id, fallback.id), enRecords.map { it.audioContentId }) + assertEquals("Translated Main Audio", enRecords.first().title) + assertEquals("翻訳メインオーディオ", jaRecords.first().title) + assertEquals(fallback.title, enRecords.last().title) + assertEquals(fallback.title, jaRecords.last().title) + } + @Test @DisplayName("시리즈는 활성 creator, 성인 노출 정책, 차단 관계, 요일과 오리지널 필터를 반영한다") fun shouldFindSeriesWithVisibilityDayOfWeekAndOriginalFilters() { @@ -363,6 +407,23 @@ class DefaultMainContentAllQueryRepositoryTest @Autowired constructor( return translation } + private fun saveContentTranslation(content: AudioContent, locale: String, title: String): ContentTranslation { + 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() + return translation + } + private fun saveOrder( member: Member, creator: Member,