From 50409e41c0c469529572f5d77033c3cb23d67d22 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 9 Sep 2026 23:23:19 +0900 Subject: [PATCH] =?UTF-8?q?feat(content-ranking):=20=EB=9E=AD=ED=82=B9=20?= =?UTF-8?q?=EC=8A=A4=EB=83=85=EC=83=B7=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 --- ...tAudioRankingSnapshotPersistenceAdapter.kt | 29 ++- ...ioRankingSnapshotPersistenceAdapterTest.kt | 229 +++++++++++++++++- .../AudioRankingSnapshotRefreshServiceTest.kt | 6 +- 3 files changed, 247 insertions(+), 17 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapter.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapter.kt index aa42851d..698e5b78 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapter.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapter.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence +import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotPort import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotRecord @@ -9,25 +10,28 @@ import java.time.LocalDateTime @Repository class DefaultAudioRankingSnapshotPersistenceAdapter( - private val repository: AudioRankingSnapshotRepository + private val repository: AudioRankingSnapshotRepository, + private val contentTranslationRepository: ContentTranslationRepository ) : AudioRankingSnapshotPort { override fun findLatestVisibleSnapshots( rankingType: AudioRankingType, - nowUtc: LocalDateTime + nowUtc: LocalDateTime, + locale: String ): List { - return repository.findLatestVisibleSnapshots(rankingType.name, nowUtc).map { it.toRecord() } + return repository.findLatestVisibleSnapshots(rankingType.name, nowUtc).toRecords(locale) } override fun findPreviousVisibleSnapshots( rankingType: AudioRankingType, currentAggregationStartAtUtc: LocalDateTime, - nowUtc: LocalDateTime + nowUtc: LocalDateTime, + locale: String ): List { return repository.findPreviousVisibleSnapshots( rankingType = rankingType.name, currentAggregationStartAtUtc = currentAggregationStartAtUtc, nowUtc = nowUtc - ).map { it.toRecord() } + ).toRecords(locale) } @Transactional @@ -46,14 +50,25 @@ class DefaultAudioRankingSnapshotPersistenceAdapter( repository.saveAll(newSnapshots.map { it.toEntity(visibleFromAtUtc) }) } - private fun AudioRankingSnapshot.toRecord(): AudioRankingSnapshotRecord { + private fun List.toRecords(locale: String): List { + if (isEmpty()) return emptyList() + val translatedTitleByContentId = contentTranslationRepository.findByContentIdInAndLocale( + map { it.contentId }, + locale + ).mapNotNull { translation -> + translation.renderedPayload.title.takeIf { it.isNotBlank() }?.let { translation.contentId to it } + }.toMap() + return map { snapshot -> snapshot.toRecord(translatedTitleByContentId[snapshot.contentId] ?: snapshot.title) } + } + + private fun AudioRankingSnapshot.toRecord(displayTitle: String = title): AudioRankingSnapshotRecord { return AudioRankingSnapshotRecord( rankingType = rankingType, aggregationStartAtUtc = aggregationStartAtUtc, aggregationEndAtUtc = aggregationEndAtUtc, visibleFromAtUtc = visibleFromAtUtc, contentId = contentId, - title = title, + title = displayTitle, creatorMemberId = creatorMemberId, creatorNickname = creatorNickname, coverImageUrl = coverImageUrl, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapterTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapterTest.kt index 1e7a57af..d5f4f013 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapterTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapterTest.kt @@ -1,8 +1,12 @@ package kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence import kr.co.vividnext.sodalive.configs.QueryDslConfig +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.v2.content.ranking.domain.AudioRankingType import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotRecord +import org.hibernate.SessionFactory import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test @@ -10,18 +14,164 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import org.springframework.context.annotation.Import import java.time.LocalDateTime +import javax.persistence.EntityManager +import javax.persistence.EntityManagerFactory @DataJpaTest( properties = [ "spring.cache.type=none", - "spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE" + "spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE", + "spring.jpa.properties.hibernate.generate_statistics=true" ] ) @Import(QueryDslConfig::class) class DefaultAudioRankingSnapshotPersistenceAdapterTest @Autowired constructor( - private val repository: AudioRankingSnapshotRepository + private val repository: AudioRankingSnapshotRepository, + private val contentTranslationRepository: ContentTranslationRepository, + private val entityManager: EntityManager, + private val entityManagerFactory: EntityManagerFactory ) { - private val adapter = DefaultAudioRankingSnapshotPersistenceAdapter(repository) + private val adapter = DefaultAudioRankingSnapshotPersistenceAdapter(repository, contentTranslationRepository) + + @Test + @DisplayName("SQL NULL 번역 payload는 snapshot 제목으로 fallback한다") + fun shouldFallbackToSnapshotTitleWhenPersistedTranslationPayloadIsSqlNull() { + val snapshot = repository.save(snapshot(contentId = 1L, title = "snapshot-title", scoreBase = 10.0)) + val translationId = saveContentTranslation(snapshot.contentId, "en", "en-title") + entityManager.createNativeQuery("update content_translation set rendered_payload = null where id = :id") + .setParameter("id", translationId) + .executeUpdate() + entityManager.flush() + entityManager.clear() + + val records = adapter.findLatestVisibleSnapshots( + AudioRankingType.WEEKLY_POPULAR, + LocalDateTime.of(2026, 6, 8, 23, 59), + "en" + ) + + assertEquals(listOf("snapshot-title"), records.map { it.title }) + } + + @Test + @DisplayName("빈 최신과 이전 스냅샷은 번역 조회 없이 빈 목록을 반환한다") + fun shouldSkipTranslationLookupForEmptyLatestAndPreviousSnapshots() { + val statistics = entityManagerFactory.unwrap(SessionFactory::class.java).statistics + statistics.clear() + + assertEquals( + emptyList(), + adapter.findLatestVisibleSnapshots( + AudioRankingType.WEEKLY_POPULAR, + LocalDateTime.of(2026, 6, 8, 23, 59), + "en" + ) + ) + assertEquals(1L, statistics.prepareStatementCount) + assertEquals( + emptyList(), + adapter.findPreviousVisibleSnapshots( + AudioRankingType.WEEKLY_POPULAR, + LocalDateTime.of(2026, 5, 31, 15, 0), + LocalDateTime.of(2026, 6, 8, 23, 59), + "en" + ) + ) + assertEquals(2L, statistics.prepareStatementCount) + } + + @Test + @DisplayName("랭킹 스냅샷은 요청 locale 번역을 일괄 조회하고 snapshot 제목으로 fallback한다") + fun shouldTranslateSnapshotTitlesInBatchesWithSnapshotFallback() { + val latestStartAt = LocalDateTime.of(2026, 5, 31, 15, 0) + val latestEndAt = LocalDateTime.of(2026, 6, 7, 15, 0) + val previousStartAt = latestStartAt.minusWeeks(1) + val previousEndAt = latestEndAt.minusWeeks(1) + val contentIds = (1L..5L).toList() + val latestSnapshots = contentIds.mapIndexed { index, contentId -> + snapshot( + contentId = contentId, + aggregationStartAtUtc = latestStartAt, + aggregationEndAtUtc = latestEndAt, + rank = index + 1, + scoreBase = 10.0 + index + ) + } + val previousSnapshots = contentIds.mapIndexed { index, contentId -> + snapshot( + contentId = contentId, + aggregationStartAtUtc = previousStartAt, + aggregationEndAtUtc = previousEndAt, + visibleFromAtUtc = previousEndAt.plusHours(9), + rank = index + 1, + title = "previous-audio-$contentId", + scoreBase = 20.0 + index + ) + } + repository.saveAll(latestSnapshots + previousSnapshots) + saveContentTranslation(1L, "en", "en-title-1") + saveContentTranslation(1L, "ja", "ja-title-1") + saveContentTranslation(2L, "en", "") + saveContentTranslation(2L, "ja", "ja-title-2") + saveContentTranslation(3L, "en", " ") + saveContentTranslation(3L, "ja", "ja-title-3") + saveContentTranslation(4L, "ja", "ja-title-4") + entityManager.flush() + entityManager.clear() + val statistics = entityManagerFactory.unwrap(SessionFactory::class.java).statistics + statistics.clear() + + val nowUtc = latestEndAt.plusDays(1).plusHours(8) + val english = adapter.findLatestVisibleSnapshots(AudioRankingType.WEEKLY_POPULAR, nowUtc, "en") + val japanese = adapter.findLatestVisibleSnapshots(AudioRankingType.WEEKLY_POPULAR, nowUtc, "ja") + val englishAgain = adapter.findLatestVisibleSnapshots(AudioRankingType.WEEKLY_POPULAR, nowUtc, "en") + val previousEnglish = adapter.findPreviousVisibleSnapshots( + AudioRankingType.WEEKLY_POPULAR, + latestStartAt, + nowUtc, + "en" + ) + val previousJapanese = adapter.findPreviousVisibleSnapshots( + AudioRankingType.WEEKLY_POPULAR, + latestStartAt, + nowUtc, + "ja" + ) + val previousEnglishAgain = adapter.findPreviousVisibleSnapshots( + AudioRankingType.WEEKLY_POPULAR, + latestStartAt, + nowUtc, + "en" + ) + + assertEquals(listOf("en-title-1", "audio-2", "audio-3", "audio-4", "audio-5"), english.map { it.title }) + assertEquals(listOf("ja-title-1", "ja-title-2", "ja-title-3", "ja-title-4", "audio-5"), japanese.map { it.title }) + assertEquals(english.map { it.withoutTitle() }, japanese.map { it.withoutTitle() }) + assertEquals(english.map { it.withoutTitle() }, englishAgain.map { it.withoutTitle() }) + assertEquals( + listOf("en-title-1", "previous-audio-2", "previous-audio-3", "previous-audio-4", "previous-audio-5"), + previousEnglish.map { it.title } + ) + assertEquals( + listOf("ja-title-1", "ja-title-2", "ja-title-3", "ja-title-4", "previous-audio-5"), + previousJapanese.map { it.title } + ) + assertEquals(previousEnglish.map { it.withoutTitle() }, previousJapanese.map { it.withoutTitle() }) + assertEquals(previousEnglish.map { it.withoutTitle() }, previousEnglishAgain.map { it.withoutTitle() }) + assertEquals(12L, statistics.prepareStatementCount) + + val persistedSnapshots = repository.findAll().sortedBy { it.aggregationStartAtUtc }.groupBy { it.aggregationStartAtUtc } + assertEquals(latestSnapshots.map { it.title }, persistedSnapshots.getValue(latestStartAt).map { it.title }) + assertEquals( + latestSnapshots.map { it.scoreValues() }, + persistedSnapshots.getValue(latestStartAt).map { it.scoreValues() } + ) + assertEquals(previousSnapshots.map { it.title }, persistedSnapshots.getValue(previousStartAt).map { it.title }) + assertEquals( + previousSnapshots.map { it.scoreValues() }, + persistedSnapshots.getValue(previousStartAt).map { it.scoreValues() } + ) + } @Test @DisplayName("최신 visible 스냅샷만 랭킹 타입별 rank 순서로 조회한다") @@ -59,7 +209,8 @@ class DefaultAudioRankingSnapshotPersistenceAdapterTest @Autowired constructor( val snapshots = adapter.findLatestVisibleSnapshots( rankingType = AudioRankingType.WEEKLY_POPULAR, - nowUtc = LocalDateTime.of(2026, 6, 8, 23, 59) + nowUtc = LocalDateTime.of(2026, 6, 8, 23, 59), + locale = "ko" ) assertEquals(listOf(3L, 2L), snapshots.map { it.contentId }) @@ -92,7 +243,8 @@ class DefaultAudioRankingSnapshotPersistenceAdapterTest @Autowired constructor( val snapshots = adapter.findLatestVisibleSnapshots( rankingType = AudioRankingType.WEEKLY_POPULAR, - nowUtc = LocalDateTime.of(2026, 6, 7, 23, 59) + nowUtc = LocalDateTime.of(2026, 6, 7, 23, 59), + locale = "ko" ) assertEquals(listOf(1L), snapshots.map { it.contentId }) @@ -153,7 +305,9 @@ class DefaultAudioRankingSnapshotPersistenceAdapterTest @Autowired constructor( aggregationEndAtUtc: LocalDateTime = LocalDateTime.of(2026, 6, 7, 15, 0), visibleFromAtUtc: LocalDateTime = LocalDateTime.of(2026, 6, 8, 0, 0), rank: Int = 1, - isAdult: Boolean = false + isAdult: Boolean = false, + title: String = "audio-$contentId", + scoreBase: Double = 100.0 ): AudioRankingSnapshot { return AudioRankingSnapshot( rankingType = rankingType, @@ -161,14 +315,31 @@ class DefaultAudioRankingSnapshotPersistenceAdapterTest @Autowired constructor( aggregationEndAtUtc = aggregationEndAtUtc, visibleFromAtUtc = visibleFromAtUtc, contentId = contentId, - title = "audio-$contentId", + title = title, creatorMemberId = 100L + contentId, creatorNickname = "creator-$contentId", coverImageUrl = "cover-$contentId.png", releaseDate = LocalDateTime.of(2026, 6, 1, 0, 0), isAdult = isAdult, rank = rank, - finalScore = 100.0 + finalScore = scoreBase, + normalizedScore = scoreBase + 0.1, + rawScore = scoreBase + 0.2, + revenueCanAmount = scoreBase.toLong() * 10, + salesCount = scoreBase.toLong() * 20, + viewCount = scoreBase.toLong() * 30, + likeCount = scoreBase.toLong() * 40, + commentCount = scoreBase.toLong() * 50, + previousSalesCount = scoreBase.toLong() * 2, + previousViewCount = scoreBase.toLong() * 3, + previousLikeCount = scoreBase.toLong() * 4, + previousCommentCount = scoreBase.toLong() * 5, + salesGrowthRate = scoreBase + 0.3, + viewGrowthRate = scoreBase + 0.4, + likeGrowthRate = scoreBase + 0.5, + commentGrowthRate = scoreBase + 0.6, + contentGrowthScore = scoreBase + 0.7, + boostMultiplier = scoreBase + 0.8 ) } @@ -195,4 +366,46 @@ class DefaultAudioRankingSnapshotPersistenceAdapterTest @Autowired constructor( finalScore = 100.0 ) } + + private fun saveContentTranslation(contentId: Long, locale: String, title: String): Long { + val translation = ContentTranslation( + contentId = contentId, + 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.id!! + } + + private fun AudioRankingSnapshotRecord.withoutTitle(): AudioRankingSnapshotRecord = copy(title = "") + + private fun AudioRankingSnapshot.scoreValues(): List { + return listOf( + finalScore, + normalizedScore, + rawScore, + revenueCanAmount, + salesCount, + viewCount, + likeCount, + commentCount, + previousSalesCount, + previousViewCount, + previousLikeCount, + previousCommentCount, + salesGrowthRate, + viewGrowthRate, + likeGrowthRate, + commentGrowthRate, + contentGrowthScore, + boostMultiplier + ) + } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotRefreshServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotRefreshServiceTest.kt index 2225b8f6..cb0cf334 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotRefreshServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotRefreshServiceTest.kt @@ -241,13 +241,15 @@ private class FakeAudioRankingSnapshotPort : AudioRankingSnapshotPort { override fun findLatestVisibleSnapshots( rankingType: AudioRankingType, - nowUtc: LocalDateTime + nowUtc: LocalDateTime, + locale: String ): List = snapshots override fun findPreviousVisibleSnapshots( rankingType: AudioRankingType, currentAggregationStartAtUtc: LocalDateTime, - nowUtc: LocalDateTime + nowUtc: LocalDateTime, + locale: String ): List = snapshots override fun replaceSnapshots(