feat(content-ranking): 랭킹 스냅샷 제목 번역을 적용한다

This commit is contained in:
2026-09-09 23:23:19 +09:00
parent e5230c0950
commit 50409e41c0
3 changed files with 247 additions and 17 deletions
@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence 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.domain.AudioRankingType
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotPort import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotPort
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotRecord import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotRecord
@@ -9,25 +10,28 @@ import java.time.LocalDateTime
@Repository @Repository
class DefaultAudioRankingSnapshotPersistenceAdapter( class DefaultAudioRankingSnapshotPersistenceAdapter(
private val repository: AudioRankingSnapshotRepository private val repository: AudioRankingSnapshotRepository,
private val contentTranslationRepository: ContentTranslationRepository
) : AudioRankingSnapshotPort { ) : AudioRankingSnapshotPort {
override fun findLatestVisibleSnapshots( override fun findLatestVisibleSnapshots(
rankingType: AudioRankingType, rankingType: AudioRankingType,
nowUtc: LocalDateTime nowUtc: LocalDateTime,
locale: String
): List<AudioRankingSnapshotRecord> { ): List<AudioRankingSnapshotRecord> {
return repository.findLatestVisibleSnapshots(rankingType.name, nowUtc).map { it.toRecord() } return repository.findLatestVisibleSnapshots(rankingType.name, nowUtc).toRecords(locale)
} }
override fun findPreviousVisibleSnapshots( override fun findPreviousVisibleSnapshots(
rankingType: AudioRankingType, rankingType: AudioRankingType,
currentAggregationStartAtUtc: LocalDateTime, currentAggregationStartAtUtc: LocalDateTime,
nowUtc: LocalDateTime nowUtc: LocalDateTime,
locale: String
): List<AudioRankingSnapshotRecord> { ): List<AudioRankingSnapshotRecord> {
return repository.findPreviousVisibleSnapshots( return repository.findPreviousVisibleSnapshots(
rankingType = rankingType.name, rankingType = rankingType.name,
currentAggregationStartAtUtc = currentAggregationStartAtUtc, currentAggregationStartAtUtc = currentAggregationStartAtUtc,
nowUtc = nowUtc nowUtc = nowUtc
).map { it.toRecord() } ).toRecords(locale)
} }
@Transactional @Transactional
@@ -46,14 +50,25 @@ class DefaultAudioRankingSnapshotPersistenceAdapter(
repository.saveAll(newSnapshots.map { it.toEntity(visibleFromAtUtc) }) repository.saveAll(newSnapshots.map { it.toEntity(visibleFromAtUtc) })
} }
private fun AudioRankingSnapshot.toRecord(): AudioRankingSnapshotRecord { private fun List<AudioRankingSnapshot>.toRecords(locale: String): List<AudioRankingSnapshotRecord> {
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( return AudioRankingSnapshotRecord(
rankingType = rankingType, rankingType = rankingType,
aggregationStartAtUtc = aggregationStartAtUtc, aggregationStartAtUtc = aggregationStartAtUtc,
aggregationEndAtUtc = aggregationEndAtUtc, aggregationEndAtUtc = aggregationEndAtUtc,
visibleFromAtUtc = visibleFromAtUtc, visibleFromAtUtc = visibleFromAtUtc,
contentId = contentId, contentId = contentId,
title = title, title = displayTitle,
creatorMemberId = creatorMemberId, creatorMemberId = creatorMemberId,
creatorNickname = creatorNickname, creatorNickname = creatorNickname,
coverImageUrl = coverImageUrl, coverImageUrl = coverImageUrl,
@@ -1,8 +1,12 @@
package kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence package kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence
import kr.co.vividnext.sodalive.configs.QueryDslConfig 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.domain.AudioRankingType
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotRecord 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.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test 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.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import import org.springframework.context.annotation.Import
import java.time.LocalDateTime import java.time.LocalDateTime
import javax.persistence.EntityManager
import javax.persistence.EntityManagerFactory
@DataJpaTest( @DataJpaTest(
properties = [ properties = [
"spring.cache.type=none", "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) @Import(QueryDslConfig::class)
class DefaultAudioRankingSnapshotPersistenceAdapterTest @Autowired constructor( 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<AudioRankingSnapshotRecord>(),
adapter.findLatestVisibleSnapshots(
AudioRankingType.WEEKLY_POPULAR,
LocalDateTime.of(2026, 6, 8, 23, 59),
"en"
)
)
assertEquals(1L, statistics.prepareStatementCount)
assertEquals(
emptyList<AudioRankingSnapshotRecord>(),
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 @Test
@DisplayName("최신 visible 스냅샷만 랭킹 타입별 rank 순서로 조회한다") @DisplayName("최신 visible 스냅샷만 랭킹 타입별 rank 순서로 조회한다")
@@ -59,7 +209,8 @@ class DefaultAudioRankingSnapshotPersistenceAdapterTest @Autowired constructor(
val snapshots = adapter.findLatestVisibleSnapshots( val snapshots = adapter.findLatestVisibleSnapshots(
rankingType = AudioRankingType.WEEKLY_POPULAR, 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 }) assertEquals(listOf(3L, 2L), snapshots.map { it.contentId })
@@ -92,7 +243,8 @@ class DefaultAudioRankingSnapshotPersistenceAdapterTest @Autowired constructor(
val snapshots = adapter.findLatestVisibleSnapshots( val snapshots = adapter.findLatestVisibleSnapshots(
rankingType = AudioRankingType.WEEKLY_POPULAR, 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 }) assertEquals(listOf(1L), snapshots.map { it.contentId })
@@ -153,7 +305,9 @@ class DefaultAudioRankingSnapshotPersistenceAdapterTest @Autowired constructor(
aggregationEndAtUtc: LocalDateTime = LocalDateTime.of(2026, 6, 7, 15, 0), aggregationEndAtUtc: LocalDateTime = LocalDateTime.of(2026, 6, 7, 15, 0),
visibleFromAtUtc: LocalDateTime = LocalDateTime.of(2026, 6, 8, 0, 0), visibleFromAtUtc: LocalDateTime = LocalDateTime.of(2026, 6, 8, 0, 0),
rank: Int = 1, rank: Int = 1,
isAdult: Boolean = false isAdult: Boolean = false,
title: String = "audio-$contentId",
scoreBase: Double = 100.0
): AudioRankingSnapshot { ): AudioRankingSnapshot {
return AudioRankingSnapshot( return AudioRankingSnapshot(
rankingType = rankingType, rankingType = rankingType,
@@ -161,14 +315,31 @@ class DefaultAudioRankingSnapshotPersistenceAdapterTest @Autowired constructor(
aggregationEndAtUtc = aggregationEndAtUtc, aggregationEndAtUtc = aggregationEndAtUtc,
visibleFromAtUtc = visibleFromAtUtc, visibleFromAtUtc = visibleFromAtUtc,
contentId = contentId, contentId = contentId,
title = "audio-$contentId", title = title,
creatorMemberId = 100L + contentId, creatorMemberId = 100L + contentId,
creatorNickname = "creator-$contentId", creatorNickname = "creator-$contentId",
coverImageUrl = "cover-$contentId.png", coverImageUrl = "cover-$contentId.png",
releaseDate = LocalDateTime.of(2026, 6, 1, 0, 0), releaseDate = LocalDateTime.of(2026, 6, 1, 0, 0),
isAdult = isAdult, isAdult = isAdult,
rank = rank, 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 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<Number?> {
return listOf(
finalScore,
normalizedScore,
rawScore,
revenueCanAmount,
salesCount,
viewCount,
likeCount,
commentCount,
previousSalesCount,
previousViewCount,
previousLikeCount,
previousCommentCount,
salesGrowthRate,
viewGrowthRate,
likeGrowthRate,
commentGrowthRate,
contentGrowthScore,
boostMultiplier
)
}
} }
@@ -241,13 +241,15 @@ private class FakeAudioRankingSnapshotPort : AudioRankingSnapshotPort {
override fun findLatestVisibleSnapshots( override fun findLatestVisibleSnapshots(
rankingType: AudioRankingType, rankingType: AudioRankingType,
nowUtc: LocalDateTime nowUtc: LocalDateTime,
locale: String
): List<AudioRankingSnapshotRecord> = snapshots ): List<AudioRankingSnapshotRecord> = snapshots
override fun findPreviousVisibleSnapshots( override fun findPreviousVisibleSnapshots(
rankingType: AudioRankingType, rankingType: AudioRankingType,
currentAggregationStartAtUtc: LocalDateTime, currentAggregationStartAtUtc: LocalDateTime,
nowUtc: LocalDateTime nowUtc: LocalDateTime,
locale: String
): List<AudioRankingSnapshotRecord> = snapshots ): List<AudioRankingSnapshotRecord> = snapshots
override fun replaceSnapshots( override fun replaceSnapshots(