diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotRefreshService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotRefreshService.kt new file mode 100644 index 00000000..78c3b583 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotRefreshService.kt @@ -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 { + 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.toSnapshotRecords( + type: AudioRankingType, + period: AudioRankingPeriod, + utcRange: AudioRankingUtcRange, + visibleFromAtUtc: java.time.LocalDateTime + ): List { + 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 { 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.withWeeklyPopularScores(): List { + 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.withRisingScores( + period: AudioRankingPeriod + ): List { + 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 + } +} 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 new file mode 100644 index 00000000..2225b8f6 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotRefreshServiceTest.kt @@ -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 = emptyList() + var risingCandidates: List = emptyList() + var revenueCandidates: List = emptyList() + var salesCountCandidates: List = emptyList() + var commentCountCandidates: List = emptyList() + var likeCountCandidates: List = emptyList() + var startInclusiveUtc: LocalDateTime? = null + var endExclusiveUtc: LocalDateTime? = null + var metricRankingType: AudioRankingType? = null + + override fun aggregateWeeklyPopularCandidates( + startInclusiveUtc: LocalDateTime, + endExclusiveUtc: LocalDateTime + ): List { + this.startInclusiveUtc = startInclusiveUtc + this.endExclusiveUtc = endExclusiveUtc + return weeklyCandidates + } + + override fun aggregateRisingCandidates( + startInclusiveUtc: LocalDateTime, + endExclusiveUtc: LocalDateTime + ): List { + this.startInclusiveUtc = startInclusiveUtc + this.endExclusiveUtc = endExclusiveUtc + return risingCandidates + } + + override fun aggregateRevenueCandidates( + startInclusiveUtc: LocalDateTime, + endExclusiveUtc: LocalDateTime + ): List { + this.startInclusiveUtc = startInclusiveUtc + this.endExclusiveUtc = endExclusiveUtc + metricRankingType = AudioRankingType.REVENUE + return revenueCandidates + } + + override fun aggregateSalesCountCandidates( + startInclusiveUtc: LocalDateTime, + endExclusiveUtc: LocalDateTime + ): List { + this.startInclusiveUtc = startInclusiveUtc + this.endExclusiveUtc = endExclusiveUtc + metricRankingType = AudioRankingType.SALES_COUNT + return salesCountCandidates + } + + override fun aggregateCommentCountCandidates( + startInclusiveUtc: LocalDateTime, + endExclusiveUtc: LocalDateTime + ): List { + this.startInclusiveUtc = startInclusiveUtc + this.endExclusiveUtc = endExclusiveUtc + metricRankingType = AudioRankingType.COMMENT_COUNT + return commentCountCandidates + } + + override fun aggregateLikeCountCandidates( + startInclusiveUtc: LocalDateTime, + endExclusiveUtc: LocalDateTime + ): List { + this.startInclusiveUtc = startInclusiveUtc + this.endExclusiveUtc = endExclusiveUtc + metricRankingType = AudioRankingType.LIKE_COUNT + return likeCountCandidates + } +} + +private class FakeAudioRankingSnapshotPort : AudioRankingSnapshotPort { + val snapshots = mutableListOf() + var rankingType: AudioRankingType? = null + var aggregationStartAtUtc: LocalDateTime? = null + var aggregationEndAtUtc: LocalDateTime? = null + var visibleFromAtUtc: LocalDateTime? = null + + override fun findLatestVisibleSnapshots( + rankingType: AudioRankingType, + nowUtc: LocalDateTime + ): List = snapshots + + override fun findPreviousVisibleSnapshots( + rankingType: AudioRankingType, + currentAggregationStartAtUtc: LocalDateTime, + nowUtc: LocalDateTime + ): List = snapshots + + override fun replaceSnapshots( + rankingType: AudioRankingType, + aggregationStartAtUtc: LocalDateTime, + aggregationEndAtUtc: LocalDateTime, + visibleFromAtUtc: LocalDateTime, + newSnapshots: List + ) { + this.rankingType = rankingType + this.aggregationStartAtUtc = aggregationStartAtUtc + this.aggregationEndAtUtc = aggregationEndAtUtc + this.visibleFromAtUtc = visibleFromAtUtc + snapshots.clear() + snapshots.addAll(newSnapshots) + } +}