From 6688bd71174b9957a1baf2d0df0c714c67288c3d Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 9 Sep 2026 23:16:33 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator-channel):=20=EC=98=A4=EB=94=94?= =?UTF-8?q?=EC=98=A4=20=EB=AA=A9=EB=A1=9D=20=EC=A0=9C=EB=AA=A9=20=EB=B2=88?= =?UTF-8?q?=EC=97=AD=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 --- ...faultCreatorChannelAudioQueryRepository.kt | 25 +++++++- ...tCreatorChannelAudioQueryRepositoryTest.kt | 64 +++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt index 9e96259e..19ec10f8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt @@ -12,6 +12,7 @@ import kr.co.vividnext.sodalive.content.order.QOrder.order import kr.co.vividnext.sodalive.content.series.translation.QSeriesTranslation import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme import kr.co.vividnext.sodalive.content.theme.translation.QContentThemeTranslation +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.member.QMember.member @@ -173,10 +174,11 @@ class DefaultCreatorChannelAudioQueryRepository( val rows = findAudioContentRows(creatorId, viewerId, themeId, now, canViewAdultContent, sort, offset, limit) val contentIds = rows.map { itAudioId(it) } val firstContentId = firstAudioContentId(creatorId, now, canViewAdultContent) + val titlesByContentId = audioTitlesByContentIds(contentIds, locale) val seriesByContentId = audioSeriesByContentIds(contentIds, locale) val orderStatesByContentId = orderStatesByContentIds(viewerId, contentIds, now) - return rows.map { it.toAudioRecord(firstContentId, seriesByContentId, orderStatesByContentId) } + return rows.map { it.toAudioRecord(firstContentId, titlesByContentId, seriesByContentId, orderStatesByContentId) } } private fun findAudioContentRows( @@ -307,6 +309,7 @@ class DefaultCreatorChannelAudioQueryRepository( private fun Tuple.toAudioRecord( firstContentId: Long?, + titlesByContentId: Map, seriesByContentId: Map, orderStatesByContentId: Map ): CreatorChannelAudioContentRecord { @@ -315,7 +318,7 @@ class DefaultCreatorChannelAudioQueryRepository( val orderState = orderStatesByContentId[audioContentId] return CreatorChannelAudioContentRecord( audioContentId = audioContentId, - title = get(audioContent.title)!!, + title = titlesByContentId[audioContentId] ?: get(audioContent.title)!!, duration = get(audioContent.duration), imagePath = get(audioContent.coverImage), price = get(audioContent.price)!!, @@ -329,6 +332,24 @@ class DefaultCreatorChannelAudioQueryRepository( ) } + private fun audioTitlesByContentIds(contentIds: List, locale: String): Map { + if (contentIds.isEmpty()) return emptyMap() + val contentTranslation = QContentTranslation("audioContentTranslation") + 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 firstAudioContentId( creatorId: Long, now: LocalDateTime, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepositoryTest.kt index 7cb2ba6e..13753396 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepositoryTest.kt @@ -10,6 +10,8 @@ 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.theme.translation.ContentThemeTranslation +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.member.Member @@ -204,6 +206,51 @@ class DefaultCreatorChannelAudioQueryRepositoryTest @Autowired constructor( assertFalse(firstPage.any { it.audioContentId == oldSelected.id && it.isFirstContent }) } + @Test + @DisplayName("목록 오디오 제목은 요청 locale 번역을 사용하고 공백 번역은 원문으로 fallback한다") + fun shouldFindAudioContentsWithTranslatedTitleFallback() { + val now = LocalDateTime.of(2026, 6, 19, 12, 0) + val viewer = saveMember("translated-title-viewer", MemberRole.USER) + val creator = saveMember("translated-title-creator", MemberRole.CREATOR) + val theme = saveTheme("수면") + val fallback = saveAudioContent(creator, now.minusDays(2), false, theme) + val translated = saveAudioContent(creator, now.minusDays(1), false, theme) + saveContentTranslation(translated, "en", "Translated Audio") + saveContentTranslation(translated, "ja", "翻訳オーディオ") + saveContentTranslation(fallback, "en", " ") + flushAndClear() + + val enRecords = repository.findAudioContents( + creator.id!!, + viewer.id!!, + null, + now, + false, + ContentSort.LATEST, + "en", + offset = 0, + limit = 10 + ) + val jaRecords = repository.findAudioContents( + creator.id!!, + viewer.id!!, + null, + now, + false, + ContentSort.LATEST, + "ja", + offset = 0, + limit = 10 + ) + + assertEquals(enRecords.map { it.audioContentId }, jaRecords.map { it.audioContentId }) + assertEquals(listOf(translated.id, fallback.id), enRecords.map { it.audioContentId }) + assertEquals("Translated Audio", enRecords.first().title) + assertEquals("翻訳オーディオ", jaRecords.first().title) + assertEquals(fallback.title, enRecords.last().title) + assertEquals(fallback.title, jaRecords.last().title) + } + @Test @DisplayName("목록은 가격순과 인기순 can 매출 정렬을 적용한다") fun shouldSortAudioContentsByPriceAndPopularRevenue() { @@ -391,6 +438,23 @@ class DefaultCreatorChannelAudioQueryRepositoryTest @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,