feat(content-ranking): 랭킹 스냅샷 갱신 서비스를 추가한다

This commit is contained in:
2026-06-24 16:22:28 +09:00
parent ee32696c6c
commit 4e97364a14
2 changed files with 468 additions and 0 deletions

View File

@@ -0,0 +1,201 @@
package kr.co.vividnext.sodalive.v2.content.ranking.application
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingPeriod
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingPeriodPolicy
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingSchedulePolicy
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingScorePolicy
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingSnapshotCandidate
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingUtcRange
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingAggregationPort
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotPort
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotRecord
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.ZonedDateTime
import kotlin.math.max
@Service
class AudioRankingSnapshotRefreshService(
private val aggregationPort: AudioRankingAggregationPort,
private val snapshotPort: AudioRankingSnapshotPort
) {
private val periodPolicy = AudioRankingPeriodPolicy()
private val schedulePolicy = AudioRankingSchedulePolicy()
private val scorePolicy = AudioRankingScorePolicy()
@Transactional
fun refreshLastCompletedWeek(type: AudioRankingType, now: ZonedDateTime) {
val period = periodPolicy.resolveLastCompletedWeek(now)
val utcRange = periodPolicy.toUtcRange(period)
val visibleFromAtUtc = schedulePolicy.resolveVisibleFromAt(period.endExclusiveKst)
val candidates = resolveCandidates(type, utcRange)
val snapshots = candidates.toSnapshotRecords(type, period, utcRange, visibleFromAtUtc)
snapshotPort.replaceSnapshots(
rankingType = type,
aggregationStartAtUtc = utcRange.startInclusiveUtc,
aggregationEndAtUtc = utcRange.endExclusiveUtc,
visibleFromAtUtc = visibleFromAtUtc,
newSnapshots = snapshots
)
}
private fun resolveCandidates(
type: AudioRankingType,
utcRange: AudioRankingUtcRange
): List<AudioRankingSnapshotCandidate> {
return when (type) {
AudioRankingType.WEEKLY_POPULAR -> aggregationPort.aggregateWeeklyPopularCandidates(
utcRange.startInclusiveUtc,
utcRange.endExclusiveUtc
)
AudioRankingType.RISING -> aggregationPort.aggregateRisingCandidates(
utcRange.startInclusiveUtc,
utcRange.endExclusiveUtc
)
AudioRankingType.REVENUE -> aggregationPort.aggregateRevenueCandidates(
utcRange.startInclusiveUtc,
utcRange.endExclusiveUtc
)
AudioRankingType.SALES_COUNT -> aggregationPort.aggregateSalesCountCandidates(
utcRange.startInclusiveUtc,
utcRange.endExclusiveUtc
)
AudioRankingType.COMMENT_COUNT -> aggregationPort.aggregateCommentCountCandidates(
utcRange.startInclusiveUtc,
utcRange.endExclusiveUtc
)
AudioRankingType.LIKE_COUNT -> aggregationPort.aggregateLikeCountCandidates(
utcRange.startInclusiveUtc,
utcRange.endExclusiveUtc
)
}
}
private fun List<AudioRankingSnapshotCandidate>.toSnapshotRecords(
type: AudioRankingType,
period: AudioRankingPeriod,
utcRange: AudioRankingUtcRange,
visibleFromAtUtc: java.time.LocalDateTime
): List<AudioRankingSnapshotRecord> {
val scoredCandidates = when (type) {
AudioRankingType.WEEKLY_POPULAR -> withWeeklyPopularScores()
AudioRankingType.RISING -> withRisingScores(period)
AudioRankingType.REVENUE,
AudioRankingType.SALES_COUNT,
AudioRankingType.COMMENT_COUNT,
AudioRankingType.LIKE_COUNT -> this
}
val rankedRecords = scoredCandidates
.sortedWith(
compareByDescending<AudioRankingSnapshotCandidate> { it.finalScore }
.thenByDescending { it.releaseDate }
.thenByDescending { it.contentId }
)
.mapIndexed { index, candidate -> candidate.toSnapshotRecord(type, utcRange, visibleFromAtUtc, index + 1) }
val globalContentIds = rankedRecords.take(SNAPSHOT_LIMIT).map { it.contentId }.toSet()
val safeContentIds = rankedRecords.filter { !it.isAdult }.take(SNAPSHOT_LIMIT).map { it.contentId }.toSet()
val selectedContentIds = globalContentIds + safeContentIds
return rankedRecords.filter { it.contentId in selectedContentIds }
}
private fun List<AudioRankingSnapshotCandidate>.withWeeklyPopularScores(): List<AudioRankingSnapshotCandidate> {
val rawScores = associateWith { candidate ->
scorePolicy.calculateWeeklyPopularScore(
revenue = candidate.revenueCanAmount,
salesCount = candidate.salesCount,
viewCount = candidate.viewCount,
likeCount = candidate.likeCount,
commentCount = candidate.commentCount,
isPaid = candidate.isPaid
)
}
val paidMaxScore = rawScores.filterKeys { it.isPaid }.values.maxOrNull() ?: 0.0
val freeMaxScore = rawScores.filterKeys { !it.isPaid }.values.maxOrNull() ?: 0.0
return map { candidate ->
val rawScore = rawScores.getValue(candidate)
candidate.copy(
finalScore = scorePolicy.normalizeScore(
rawScore,
if (candidate.isPaid) paidMaxScore else freeMaxScore
)
)
}
}
private fun AudioRankingSnapshotCandidate.withRisingScore(period: AudioRankingPeriod): AudioRankingSnapshotCandidate {
return copy(
finalScore = scorePolicy.calculateRisingScore(
recentSalesCount = salesCount,
previousSalesCount = previousSalesCount,
recentViewCount = viewCount,
previousViewCount = previousViewCount,
recentLikeCount = likeCount,
previousLikeCount = previousLikeCount,
recentCommentCount = commentCount,
previousCommentCount = previousCommentCount,
releaseDate = releaseDate,
aggregationEndAt = period.endExclusiveKst,
isPaid = isPaid
)
)
}
private fun List<AudioRankingSnapshotCandidate>.withRisingScores(
period: AudioRankingPeriod
): List<AudioRankingSnapshotCandidate> {
val scoredCandidates = map { it.withRisingScore(period) }
val paidMaxScore = scoredCandidates.filter { it.isPaid }.maxOfOrNull { it.finalScore } ?: 0.0
val freeMaxScore = scoredCandidates.filter { !it.isPaid }.maxOfOrNull { it.finalScore } ?: 0.0
return scoredCandidates.map { candidate ->
candidate.copy(
finalScore = scorePolicy.normalizeScore(
candidate.finalScore,
if (candidate.isPaid) paidMaxScore else freeMaxScore
)
)
}
}
private fun AudioRankingSnapshotCandidate.toSnapshotRecord(
type: AudioRankingType,
utcRange: AudioRankingUtcRange,
visibleFromAtUtc: java.time.LocalDateTime,
rank: Int
): AudioRankingSnapshotRecord {
return AudioRankingSnapshotRecord(
rankingType = type,
aggregationStartAtUtc = utcRange.startInclusiveUtc,
aggregationEndAtUtc = utcRange.endExclusiveUtc,
visibleFromAtUtc = visibleFromAtUtc,
contentId = contentId,
title = title,
creatorMemberId = creatorMemberId,
creatorNickname = creatorNickname,
coverImageUrl = coverImageUrl,
releaseDate = releaseDate,
isAdult = isAdult,
rank = rank,
finalScore = max(finalScore, 0.0),
revenueCanAmount = revenueCanAmount,
salesCount = salesCount,
viewCount = viewCount,
likeCount = likeCount,
commentCount = commentCount,
previousSalesCount = previousSalesCount,
previousViewCount = previousViewCount,
previousLikeCount = previousLikeCount,
previousCommentCount = previousCommentCount
)
}
companion object {
private const val SNAPSHOT_LIMIT = 20
}
}

View File

@@ -0,0 +1,267 @@
package kr.co.vividnext.sodalive.v2.content.ranking.application
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingSnapshotCandidate
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingAggregationPort
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotPort
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotRecord
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZonedDateTime
class AudioRankingSnapshotRefreshServiceTest {
@Test
fun shouldStoreTopTwentyByScoreReleaseDateAndContentId() {
val aggregationPort = FakeAudioRankingAggregationPort()
val snapshotPort = FakeAudioRankingSnapshotPort()
val service = service(aggregationPort = aggregationPort, snapshotPort = snapshotPort)
aggregationPort.weeklyCandidates = (1L..18L).map { contentId ->
candidate(contentId = contentId, salesCount = 100 - contentId, releaseDate = LocalDateTime.of(2026, 6, 1, 0, 0))
} + listOf(
candidate(contentId = 19L, salesCount = 10, releaseDate = LocalDateTime.of(2026, 6, 2, 0, 0)),
candidate(contentId = 20L, salesCount = 10, releaseDate = LocalDateTime.of(2026, 6, 3, 0, 0)),
candidate(contentId = 21L, salesCount = 10, releaseDate = LocalDateTime.of(2026, 6, 3, 0, 0)),
candidate(contentId = 22L, salesCount = 1, releaseDate = LocalDateTime.of(2026, 6, 4, 0, 0))
)
service.refreshLastCompletedWeek(AudioRankingType.WEEKLY_POPULAR, now())
assertEquals(20, snapshotPort.snapshots.size)
assertEquals(listOf(21L, 20L), snapshotPort.snapshots.takeLast(2).map { it.contentId })
assertEquals((1..20).toList(), snapshotPort.snapshots.map { it.rank })
}
@Test
fun shouldUseLastCompletedWeekUtcRangeAndVisibleFromAt() {
val aggregationPort = FakeAudioRankingAggregationPort()
val snapshotPort = FakeAudioRankingSnapshotPort()
val service = service(aggregationPort = aggregationPort, snapshotPort = snapshotPort)
aggregationPort.risingCandidates = listOf(candidate(contentId = 1L, viewCount = 20, previousViewCount = 10))
service.refreshLastCompletedWeek(AudioRankingType.RISING, now())
assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0), aggregationPort.startInclusiveUtc)
assertEquals(LocalDateTime.of(2026, 6, 7, 15, 0), aggregationPort.endExclusiveUtc)
assertEquals(AudioRankingType.RISING, snapshotPort.rankingType)
assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0), snapshotPort.aggregationStartAtUtc)
assertEquals(LocalDateTime.of(2026, 6, 7, 15, 0), snapshotPort.aggregationEndAtUtc)
assertEquals(LocalDateTime.of(2026, 6, 8, 0, 0), snapshotPort.visibleFromAtUtc)
assertEquals(LocalDateTime.of(2026, 6, 8, 0, 0), snapshotPort.snapshots.single().visibleFromAtUtc)
}
@Test
fun shouldNormalizeRisingScoresByPaidAndFreeGroups() {
val aggregationPort = FakeAudioRankingAggregationPort()
val snapshotPort = FakeAudioRankingSnapshotPort()
val service = service(aggregationPort = aggregationPort, snapshotPort = snapshotPort)
aggregationPort.risingCandidates = listOf(
candidate(contentId = 1L, salesCount = 6, previousSalesCount = 3),
candidate(contentId = 2L, salesCount = 3, previousSalesCount = 3),
candidate(contentId = 3L, viewCount = 20, previousViewCount = 10),
candidate(contentId = 4L, viewCount = 10, previousViewCount = 10)
)
service.refreshLastCompletedWeek(AudioRankingType.RISING, now())
val scoresByContentId = snapshotPort.snapshots.associate { it.contentId to it.finalScore }
assertEquals(100.0, scoresByContentId.getValue(1L), 0.0001)
assertEquals(0.0, scoresByContentId.getValue(2L), 0.0001)
assertEquals(100.0, scoresByContentId.getValue(3L), 0.0001)
assertEquals(0.0, scoresByContentId.getValue(4L), 0.0001)
}
@Test
fun shouldUseAggregationPortForMetricRankingTypes() {
val aggregationPort = FakeAudioRankingAggregationPort()
val snapshotPort = FakeAudioRankingSnapshotPort()
val service = service(aggregationPort = aggregationPort, snapshotPort = snapshotPort)
aggregationPort.revenueCandidates = listOf(candidate(contentId = 1L, finalScore = 10.0))
service.refreshLastCompletedWeek(AudioRankingType.REVENUE, now())
assertEquals(AudioRankingType.REVENUE, aggregationPort.metricRankingType)
assertEquals(listOf(1L), snapshotPort.snapshots.map { it.contentId })
}
@Test
fun shouldSortMetricTieScoresByReleaseDateAndContentId() {
val aggregationPort = FakeAudioRankingAggregationPort()
val snapshotPort = FakeAudioRankingSnapshotPort()
val service = service(aggregationPort = aggregationPort, snapshotPort = snapshotPort)
aggregationPort.revenueCandidates = listOf(
candidate(contentId = 1L, finalScore = 10.0, releaseDate = LocalDateTime.of(2026, 6, 1, 0, 0)),
candidate(contentId = 2L, finalScore = 10.0, releaseDate = LocalDateTime.of(2026, 6, 2, 0, 0)),
candidate(contentId = 3L, finalScore = 10.0, releaseDate = LocalDateTime.of(2026, 6, 2, 0, 0))
)
service.refreshLastCompletedWeek(AudioRankingType.REVENUE, now())
assertEquals(listOf(3L, 2L, 1L), snapshotPort.snapshots.map { it.contentId })
}
@Test
fun shouldStoreGlobalTopTwentyAndSafeTopTwentyCandidates() {
val aggregationPort = FakeAudioRankingAggregationPort()
val snapshotPort = FakeAudioRankingSnapshotPort()
val service = service(aggregationPort = aggregationPort, snapshotPort = snapshotPort)
aggregationPort.revenueCandidates = (1L..20L).map { contentId ->
candidate(contentId = contentId, finalScore = (100 - contentId).toDouble(), isAdult = true)
} + (21L..40L).map { contentId ->
candidate(contentId = contentId, finalScore = (100 - contentId).toDouble(), isAdult = false)
}
service.refreshLastCompletedWeek(AudioRankingType.REVENUE, now())
assertEquals(40, snapshotPort.snapshots.size)
assertEquals((1L..20L).toList(), snapshotPort.snapshots.take(20).map { it.contentId })
assertEquals((21L..40L).toList(), snapshotPort.snapshots.drop(20).map { it.contentId })
assertEquals((1..40).toList(), snapshotPort.snapshots.map { it.rank })
}
private fun service(
aggregationPort: AudioRankingAggregationPort = FakeAudioRankingAggregationPort(),
snapshotPort: AudioRankingSnapshotPort = FakeAudioRankingSnapshotPort()
): AudioRankingSnapshotRefreshService {
return AudioRankingSnapshotRefreshService(
aggregationPort = aggregationPort,
snapshotPort = snapshotPort
)
}
private fun now(): ZonedDateTime {
return ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul"))
}
private fun candidate(
contentId: Long,
finalScore: Double = 0.0,
salesCount: Long = 0,
previousSalesCount: Long = 0,
viewCount: Long = 0,
previousViewCount: Long = 0,
releaseDate: LocalDateTime = LocalDateTime.of(2026, 6, 1, 0, 0),
isAdult: Boolean = false
): AudioRankingSnapshotCandidate {
return AudioRankingSnapshotCandidate(
contentId = contentId,
title = "audio-$contentId",
creatorMemberId = 100L + contentId,
creatorNickname = "creator-$contentId",
coverImageUrl = "cover-$contentId.png",
releaseDate = releaseDate,
isAdult = isAdult,
isPaid = salesCount > 0,
finalScore = finalScore,
salesCount = salesCount,
previousSalesCount = previousSalesCount,
viewCount = viewCount,
previousViewCount = previousViewCount
)
}
}
private class FakeAudioRankingAggregationPort : AudioRankingAggregationPort {
var weeklyCandidates: List<AudioRankingSnapshotCandidate> = emptyList()
var risingCandidates: List<AudioRankingSnapshotCandidate> = emptyList()
var revenueCandidates: List<AudioRankingSnapshotCandidate> = emptyList()
var salesCountCandidates: List<AudioRankingSnapshotCandidate> = emptyList()
var commentCountCandidates: List<AudioRankingSnapshotCandidate> = emptyList()
var likeCountCandidates: List<AudioRankingSnapshotCandidate> = emptyList()
var startInclusiveUtc: LocalDateTime? = null
var endExclusiveUtc: LocalDateTime? = null
var metricRankingType: AudioRankingType? = null
override fun aggregateWeeklyPopularCandidates(
startInclusiveUtc: LocalDateTime,
endExclusiveUtc: LocalDateTime
): List<AudioRankingSnapshotCandidate> {
this.startInclusiveUtc = startInclusiveUtc
this.endExclusiveUtc = endExclusiveUtc
return weeklyCandidates
}
override fun aggregateRisingCandidates(
startInclusiveUtc: LocalDateTime,
endExclusiveUtc: LocalDateTime
): List<AudioRankingSnapshotCandidate> {
this.startInclusiveUtc = startInclusiveUtc
this.endExclusiveUtc = endExclusiveUtc
return risingCandidates
}
override fun aggregateRevenueCandidates(
startInclusiveUtc: LocalDateTime,
endExclusiveUtc: LocalDateTime
): List<AudioRankingSnapshotCandidate> {
this.startInclusiveUtc = startInclusiveUtc
this.endExclusiveUtc = endExclusiveUtc
metricRankingType = AudioRankingType.REVENUE
return revenueCandidates
}
override fun aggregateSalesCountCandidates(
startInclusiveUtc: LocalDateTime,
endExclusiveUtc: LocalDateTime
): List<AudioRankingSnapshotCandidate> {
this.startInclusiveUtc = startInclusiveUtc
this.endExclusiveUtc = endExclusiveUtc
metricRankingType = AudioRankingType.SALES_COUNT
return salesCountCandidates
}
override fun aggregateCommentCountCandidates(
startInclusiveUtc: LocalDateTime,
endExclusiveUtc: LocalDateTime
): List<AudioRankingSnapshotCandidate> {
this.startInclusiveUtc = startInclusiveUtc
this.endExclusiveUtc = endExclusiveUtc
metricRankingType = AudioRankingType.COMMENT_COUNT
return commentCountCandidates
}
override fun aggregateLikeCountCandidates(
startInclusiveUtc: LocalDateTime,
endExclusiveUtc: LocalDateTime
): List<AudioRankingSnapshotCandidate> {
this.startInclusiveUtc = startInclusiveUtc
this.endExclusiveUtc = endExclusiveUtc
metricRankingType = AudioRankingType.LIKE_COUNT
return likeCountCandidates
}
}
private class FakeAudioRankingSnapshotPort : AudioRankingSnapshotPort {
val snapshots = mutableListOf<AudioRankingSnapshotRecord>()
var rankingType: AudioRankingType? = null
var aggregationStartAtUtc: LocalDateTime? = null
var aggregationEndAtUtc: LocalDateTime? = null
var visibleFromAtUtc: LocalDateTime? = null
override fun findLatestVisibleSnapshots(
rankingType: AudioRankingType,
nowUtc: LocalDateTime
): List<AudioRankingSnapshotRecord> = snapshots
override fun findPreviousVisibleSnapshots(
rankingType: AudioRankingType,
currentAggregationStartAtUtc: LocalDateTime,
nowUtc: LocalDateTime
): List<AudioRankingSnapshotRecord> = snapshots
override fun replaceSnapshots(
rankingType: AudioRankingType,
aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime,
visibleFromAtUtc: LocalDateTime,
newSnapshots: List<AudioRankingSnapshotRecord>
) {
this.rankingType = rankingType
this.aggregationStartAtUtc = aggregationStartAtUtc
this.aggregationEndAtUtc = aggregationEndAtUtc
this.visibleFromAtUtc = visibleFromAtUtc
snapshots.clear()
snapshots.addAll(newSnapshots)
}
}