From 12b9a912c04db2a1e74356ee60de693f7e0df30f Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 9 Sep 2026 23:17:18 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator-channel):=20=ED=99=88=20=EC=BD=98?= =?UTF-8?q?=ED=85=90=EC=B8=A0=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 --- ...efaultCreatorChannelHomeQueryRepository.kt | 91 +++++++++++--- ...ltCreatorChannelHomeQueryRepositoryTest.kt | 116 ++++++++++++++++++ 2 files changed, 190 insertions(+), 17 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt index 26aaef32..6d465e6f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt @@ -10,6 +10,8 @@ import kr.co.vividnext.sodalive.content.QAudioContent.audioContent import kr.co.vividnext.sodalive.content.order.OrderType import kr.co.vividnext.sodalive.content.order.QOrder.order import kr.co.vividnext.sodalive.content.pin.QPinContent.pinContent +import kr.co.vividnext.sodalive.content.series.translation.QSeriesTranslation +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.explorer.profile.QCreatorCheers.creatorCheers @@ -153,13 +155,15 @@ class DefaultCreatorChannelHomeQueryRepository( creatorId: Long, now: LocalDateTime, canViewAdultContent: Boolean, - viewerId: Long? + viewerId: Long?, + locale: String ): CreatorChannelAudioContentRecord? { val row = findAudioContentRows(creatorId, now, null, canViewAdultContent, 1).firstOrNull() ?: return null val contentId = itAudioId(row) return row.toAudioRecord( firstContentId = firstAudioContentId(creatorId, now, canViewAdultContent), - seriesByContentId = audioSeriesByContentIds(listOf(contentId)), + titlesByContentId = audioTitlesByContentIds(listOf(contentId), locale), + seriesByContentId = audioSeriesByContentIds(listOf(contentId), locale), orderStatesByContentId = orderStatesByContentIds(viewerId, listOf(contentId), now) ) } @@ -209,6 +213,7 @@ class DefaultCreatorChannelHomeQueryRepository( viewerId: Long?, isViewerCreator: Boolean, effectiveViewerGender: Gender?, + locale: String, limit: Int ): List { val liveSchedules = queryFactory @@ -235,18 +240,21 @@ class DefaultCreatorChannelHomeQueryRepository( ) .fetch() + val contentTranslation = QContentTranslation("homeScheduleContentTranslation") val audioSchedules = queryFactory .select( - Projections.constructor( - CreatorChannelScheduleRecord::class.java, - audioContent.releaseDate, - audioContent.title, - Expressions.constant(CreatorActivityType.AUDIO), - audioContent.id, - audioContent.isAdult - ) + audioContent.releaseDate, + audioContent.title, + contentTranslation.renderedPayload, + audioContent.id, + audioContent.isAdult ) .from(audioContent) + .leftJoin(contentTranslation) + .on( + contentTranslation.contentId.eq(audioContent.id), + contentTranslation.locale.eq(locale) + ) .where( audioContent.member.id.eq(creatorId), audioContent.member.isActive.isTrue, @@ -256,6 +264,17 @@ class DefaultCreatorChannelHomeQueryRepository( adultAudioCondition(canViewAdultContent) ) .fetch() + .map { + val originalTitle = it.get(audioContent.title)!! + val translatedTitle = it.get(contentTranslation.renderedPayload)?.title + CreatorChannelScheduleRecord( + scheduledAt = it.get(audioContent.releaseDate)!!, + title = translatedTitle.takeUnless(String?::isNullOrBlank) ?: originalTitle, + type = CreatorActivityType.AUDIO, + targetId = it.get(audioContent.id)!!, + isAdult = it.get(audioContent.isAdult)!! + ) + } return (liveSchedules + audioSchedules) .sortedWith(compareBy { it.scheduledAt }.thenBy { it.type.sortOrder }) @@ -268,14 +287,16 @@ class DefaultCreatorChannelHomeQueryRepository( latestAudioContentId: Long?, canViewAdultContent: Boolean, viewerId: Long?, + locale: String, limit: Int ): List { val rows = findAudioContentRows(creatorId, now, latestAudioContentId, canViewAdultContent, limit, pinFirst = true) val contentIds = rows.map { itAudioId(it) } val firstContentId = firstAudioContentId(creatorId, now, canViewAdultContent) - val seriesByContentId = audioSeriesByContentIds(contentIds) + 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) } } override fun findSeries( @@ -284,16 +305,24 @@ class DefaultCreatorChannelHomeQueryRepository( now: LocalDateTime, canViewAdultContent: Boolean, contentType: ContentType, + locale: String, limit: Int ): List { + val seriesTranslation = QSeriesTranslation("homeSeriesTranslation") val seriesRows = queryFactory .select( series.id, series.title, + seriesTranslation.renderedPayload, series.coverImage, series.isOriginal ) .from(series) + .leftJoin(seriesTranslation) + .on( + seriesTranslation.seriesId.eq(series.id), + seriesTranslation.locale.eq(locale) + ) .where( series.member.id.eq(creatorId), series.member.isActive.isTrue, @@ -317,7 +346,8 @@ class DefaultCreatorChannelHomeQueryRepository( val seriesId = seriesRow.get(series.id)!! CreatorChannelSeriesRecord( seriesId = seriesId, - title = seriesRow.get(series.title)!!, + title = seriesRow.get(seriesTranslation.renderedPayload)?.title.takeUnless(String?::isNullOrBlank) + ?: seriesRow.get(series.title)!!, coverImagePath = seriesRow.get(series.coverImage), numberOfContent = stats.contentCount, isNew = seriesId in newSeriesIds, @@ -493,6 +523,7 @@ class DefaultCreatorChannelHomeQueryRepository( private fun com.querydsl.core.Tuple.toAudioRecord( firstContentId: Long?, + titlesByContentId: Map, seriesByContentId: Map, orderStatesByContentId: Map ): CreatorChannelAudioContentRecord { @@ -501,7 +532,7 @@ class DefaultCreatorChannelHomeQueryRepository( 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)!!, @@ -515,6 +546,24 @@ class DefaultCreatorChannelHomeQueryRepository( ) } + private fun audioTitlesByContentIds(contentIds: List, locale: String): Map { + if (contentIds.isEmpty()) return emptyMap() + val contentTranslation = QContentTranslation("homeAudioContentTranslation") + 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 orderStatesByContentIds( viewerId: Long?, contentIds: List, @@ -542,17 +591,25 @@ class DefaultCreatorChannelHomeQueryRepository( } } - private fun audioSeriesByContentIds(contentIds: List): Map { + private fun audioSeriesByContentIds(contentIds: List, locale: String): Map { if (contentIds.isEmpty()) return emptyMap() + val seriesTranslation = QSeriesTranslation("homeAudioSeriesTranslation") return queryFactory - .select(seriesContent.content.id, series.title, series.isOriginal) + .select(seriesContent.content.id, series.title, seriesTranslation.renderedPayload, series.isOriginal) .from(seriesContent) .innerJoin(seriesContent.series, series) + .leftJoin(seriesTranslation) + .on( + seriesTranslation.seriesId.eq(series.id), + seriesTranslation.locale.eq(locale) + ) .where(seriesContent.content.id.`in`(contentIds)) .fetch() .associate { + val originalTitle = it.get(series.title)!! + val translatedTitle = it.get(seriesTranslation.renderedPayload)?.title it.get(seriesContent.content.id)!! to AudioSeriesSummary( - title = it.get(series.title)!!, + title = translatedTitle.takeUnless(String?::isNullOrBlank) ?: originalTitle, isOriginal = it.get(series.isOriginal)!! ) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt index 65b8dec6..9922dfec 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt @@ -9,7 +9,11 @@ import kr.co.vividnext.sodalive.content.ContentType import kr.co.vividnext.sodalive.content.order.Order import kr.co.vividnext.sodalive.content.order.OrderType import kr.co.vividnext.sodalive.content.pin.PinContent +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.explorer.profile.CreatorCheers @@ -514,6 +518,84 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor( assertTrue(records.last().isPointAvailable) } + @Test + @DisplayName("홈 오디오 제목과 시리즈명은 요청 locale 번역을 사용하고 공백 번역은 원문으로 fallback한다") + fun shouldFindHomeAudioContentsWithTranslatedTitleFallback() { + val now = LocalDateTime.of(2026, 6, 12, 12, 0) + val creator = saveMember("home-audio-i18n-creator", MemberRole.CREATOR) + val first = saveAudioContent(creator, now.minusDays(3), isAdult = false) + val listed = saveAudioContent(creator, now.minusDays(2), isAdult = false) + val latest = saveAudioContent(creator, now.minusDays(1), isAdult = false) + val series = saveSeries("home-origin-series", creator, isOriginal = true) + saveSeriesContent(series, listed) + saveContentTranslation(latest, "en", "Latest Audio") + saveContentTranslation(latest, "ja", "最新オーディオ") + saveContentTranslation(listed, "en", "Listed Audio") + saveContentTranslation(first, "en", " ") + saveSeriesTranslation(series, "en", "Home Series") + flushAndClear() + + val latestRecord = repository.findLatestAudioContent(creator.id!!, now, false, locale = "en") + val records = repository.findAudioContents( + creator.id!!, + now, + latestRecord!!.audioContentId, + false, + locale = "en", + limit = 9 + ) + val jaLatestRecord = repository.findLatestAudioContent(creator.id!!, now, false, locale = "ja") + + assertEquals("Latest Audio", latestRecord.title) + assertEquals("最新オーディオ", jaLatestRecord!!.title) + assertEquals(listOf(listed.id, first.id), records.map { it.audioContentId }) + assertEquals("Listed Audio", records.first().title) + assertEquals("Home Series", records.first().seriesName) + assertEquals(first.title, records.last().title) + } + + @Test + @DisplayName("홈 AUDIO 스케줄과 시리즈 제목은 요청 locale 번역을 사용하고 라이브 스케줄 제목은 유지한다") + fun shouldFindHomeSchedulesAndSeriesWithTranslatedTitleFallback() { + val now = LocalDateTime.of(2026, 6, 12, 12, 0) + val creator = saveMember("home-schedule-i18n-creator", MemberRole.CREATOR) + val liveSchedule = saveLiveRoom(creator, now.plusHours(1), channelName = null, isAdult = false) + val audioSchedule = saveAudioContent(creator, now.plusHours(2), isAdult = false) + val blankAudioSchedule = saveAudioContent(creator, now.plusHours(3), isAdult = false) + val series = saveSeries("home-origin-series-title", creator, isOriginal = true) + val blankSeries = saveSeries("home-blank-series-title", creator, isOriginal = true) + saveSeriesContent(series, saveAudioContent(creator, now.minusDays(1), isAdult = false)) + saveSeriesContent(blankSeries, saveAudioContent(creator, now.minusDays(2), isAdult = false)) + saveContentTranslation(audioSchedule, "en", "Scheduled Audio") + saveContentTranslation(audioSchedule, "ja", "予約オーディオ") + saveContentTranslation(blankAudioSchedule, "en", " ") + saveSeriesTranslation(series, "en", "Translated Home Series") + saveSeriesTranslation(series, "ja", "翻訳ホームシリーズ") + saveSeriesTranslation(blankSeries, "en", " ") + flushAndClear() + + val schedules = repository.findSchedules( + creator.id!!, + now, + canViewAdultContent = false, + viewerId = null, + isViewerCreator = false, + effectiveViewerGender = null, + locale = "en", + limit = 10 + ) + val seriesRecords = repository.findSeries(creator.id!!, null, now, false, ContentType.ALL, locale = "en", limit = 8) + val jaSeriesRecords = repository.findSeries(creator.id!!, null, now, false, ContentType.ALL, locale = "ja", limit = 8) + + assertEquals(listOf(liveSchedule.id, audioSchedule.id, blankAudioSchedule.id), schedules.map { it.targetId }) + assertEquals(liveSchedule.title, schedules.first().title) + assertEquals("Scheduled Audio", schedules[1].title) + assertEquals(blankAudioSchedule.title, schedules.last().title) + assertEquals("Translated Home Series", seriesRecords.first { it.seriesId == series.id }.title) + assertEquals(blankSeries.title, seriesRecords.first { it.seriesId == blankSeries.id }.title) + assertEquals("翻訳ホームシリーズ", jaSeriesRecords.first { it.seriesId == series.id }.title) + } + @Test @DisplayName("홈 오디오 목록은 최신 오디오를 제외하고 활성 고정 콘텐츠를 최근 고정순으로 먼저 조회한다") fun shouldSortHomeAudioContentsByActivePinBeforeReleaseDate() { @@ -1215,6 +1297,40 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor( return seriesContent } + private fun saveSeriesTranslation(series: Series, locale: String, title: String): SeriesTranslation { + val translation = SeriesTranslation( + seriesId = series.id!!, + locale = locale, + renderedPayload = SeriesTranslationPayload(title = title, introduction = "", keywords = emptyList()) + ) + entityManager.persist(translation) + entityManager.flush() + val payload = "{\"title\":\"$title\",\"introduction\":\"\",\"keywords\":[]}" + entityManager.createNativeQuery( + "update series_translation set rendered_payload = '$payload' format json where id = :id" + ) + .setParameter("id", translation.id) + .executeUpdate() + 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 savePinContent( creator: Member, content: AudioContent,