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
}
}