feat(home): 크리에이터 랭킹 갱신 산식을 적용한다

This commit is contained in:
2026-07-10 14:43:50 +09:00
parent 247569ee16
commit 1f944329f4
2 changed files with 162 additions and 381 deletions

View File

@@ -2,7 +2,9 @@ package kr.co.vividnext.sodalive.v2.ranking.application
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingPeriodPolicy
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScoreDetail
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScorePolicy
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScoreSpec
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingSnapshotCandidate
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingUtcRange
@@ -38,9 +40,7 @@ class CreatorRankingSnapshotRefreshService(
startInclusiveUtc = utcRange.startInclusiveUtc,
endExclusiveUtc = utcRange.endExclusiveUtc
)
val snapshots = aggregationResult.candidates.map { it.toSnapshotRecord(utcRange) }
.sortedByDescending { it.finalScore }
.takeRankedBoundary(limit = SNAPSHOT_LIMIT)
val snapshots = assembleSnapshots(aggregationResult.candidates, utcRange, visibleFromAtUtc)
snapshotPort.replaceSnapshots(
rankingType = CreatorRankingType.WEEKLY,
@@ -50,7 +50,7 @@ class CreatorRankingSnapshotRefreshService(
newSnapshots = snapshots
)
afterCommit {
snapshots.forEachIndexed { index, snapshot ->
snapshots.forEach { snapshot ->
runCatching {
homeFollowingNewsPublishService.publishCreatorRankingVisible(
creatorId = snapshot.creatorId,
@@ -58,13 +58,13 @@ class CreatorRankingSnapshotRefreshService(
creatorProfileImagePath = snapshot.profileImageUrl,
aggregationStartAtUtc = utcRange.startInclusiveUtc,
visibleFromAtUtc = visibleFromAtUtc,
rank = index + 1
rank = snapshot.rankNo
)
}.onFailure { ex ->
log.warn(
"event=home_following_creator_ranking_news_publish_failure creatorId={} rank={} error={}",
snapshot.creatorId,
index + 1,
snapshot.rankNo,
ex.message,
ex
)
@@ -100,14 +100,117 @@ class CreatorRankingSnapshotRefreshService(
}
}
private fun CreatorRankingAggregationResult.toLogCounts(storedCount: Int): RefreshLogCounts {
return RefreshLogCounts(
candidateCount = candidates.size,
storedCount = storedCount,
lowScoreExcludedCount = lowScoreExcludedCount
private fun assembleSnapshots(
candidates: List<CreatorRankingSnapshotCandidate>,
utcRange: CreatorRankingUtcRange,
visibleFromAtUtc: java.time.LocalDateTime
): List<CreatorRankingSnapshotRecord> {
val liveRanks = candidates.toSqlRanks { it.liveCanAmount }
val contentRanks = candidates.toSqlRanks { it.contentPurchaseCanAmount }
val followRanks = candidates.toSqlRanks { it.followIncrease.coerceAtLeast(0L) }
val aiChatRanks = candidates.toSqlRanks { it.aiChatCount }
return candidates.map { candidate ->
val live = metric(
candidate.liveCanAmount,
liveRanks.getValue(candidate.creatorId),
CreatorRankingScoreSpec.LIVE_REVENUE_WEIGHT
)
val content = metric(
candidate.contentPurchaseCanAmount,
contentRanks.getValue(candidate.creatorId),
CreatorRankingScoreSpec.CONTENT_REVENUE_WEIGHT
)
val followRaw = scorePolicy.normalizeFollowIncrease(candidate.followIncrease)
val follow = metric(
followRaw,
followRanks.getValue(candidate.creatorId),
CreatorRankingScoreSpec.FOLLOW_INCREASE_WEIGHT
)
val aiChat = metric(
candidate.aiChatCount,
aiChatRanks.getValue(candidate.creatorId),
CreatorRankingScoreSpec.AI_CHAT_WEIGHT
)
val finalScore = scorePolicy.calculateFinalScore(
live.rankScore,
content.rankScore,
follow.rankScore,
aiChat.rankScore
)
val detail = CreatorRankingScoreDetail(
metrics = CreatorRankingScoreDetail.Metrics(
liveRevenue = live,
contentRevenue = content,
followIncrease = follow,
aiChat = aiChat
),
tieBreakers = CreatorRankingScoreDetail.TieBreakers(
creatorDebutAt = candidate.creatorDebutAt,
creatorId = candidate.creatorId
)
)
ScoredCandidate(candidate, finalScore, detail.toJson())
}.filter { it.finalScore > 0.0 }
.sortedWith(
compareByDescending<ScoredCandidate> { it.finalScore }
.thenByDescending { it.candidate.creatorDebutAt ?: java.time.LocalDateTime.MIN }
.thenByDescending { it.candidate.creatorId }
)
.take(SNAPSHOT_LIMIT)
.mapIndexed { index, scored ->
CreatorRankingSnapshotRecord(
rankingType = CreatorRankingType.WEEKLY,
aggregationStartAtUtc = utcRange.startInclusiveUtc,
aggregationEndAtUtc = utcRange.endExclusiveUtc,
visibleFromAtUtc = visibleFromAtUtc,
creatorId = scored.candidate.creatorId,
nickname = scored.candidate.nickname,
profileImageUrl = scored.candidate.profileImageUrl,
rankNo = index + 1,
finalScore = scored.finalScore,
scorePolicyVersion = CreatorRankingScoreSpec.SCORE_POLICY_VERSION,
scoreDetailJson = scored.scoreDetailJson
)
}
}
private fun List<CreatorRankingSnapshotCandidate>.toSqlRanks(
metric: (CreatorRankingSnapshotCandidate) -> Long
): Map<Long, Int> {
var previousValue: Long? = null
var currentRank = 0
return sortedByDescending(metric).mapIndexed { index, candidate ->
val value = metric(candidate)
if (previousValue == null || previousValue != value) {
currentRank = index + 1
previousValue = value
}
candidate.creatorId to currentRank
}.toMap()
}
private fun metric(rawValue: Long, rank: Int, weight: Double): CreatorRankingScoreDetail.Metric {
val rankScore = scorePolicy.calculateRankScore(rawValue, rank)
return CreatorRankingScoreDetail.Metric(
rawValue = rawValue,
rank = rank,
rankScore = rankScore,
weight = weight,
weightedScore = rankScore * weight
)
}
private data class ScoredCandidate(
val candidate: CreatorRankingSnapshotCandidate,
val finalScore: Double,
val scoreDetailJson: String
)
private fun CreatorRankingAggregationResult.toLogCounts(storedCount: Int): RefreshLogCounts {
return RefreshLogCounts(candidates.size, storedCount, lowScoreExcludedCount)
}
private data class RefreshLogCounts(
val candidateCount: Int,
val storedCount: Int,
@@ -132,62 +235,6 @@ class CreatorRankingSnapshotRefreshService(
)
}
private fun CreatorRankingSnapshotCandidate.toSnapshotRecord(utcRange: CreatorRankingUtcRange): CreatorRankingSnapshotRecord {
val calculatedContentLiveScore = scorePolicy.calculateContentLiveScore(
liveCanAmount = liveCanAmount,
contentPurchaseCanAmount = contentPurchaseCanAmount
)
val calculatedEngagementScore = scorePolicy.calculateEngagementScore(
contentLikeCount = contentLikeCount,
contentCommentCount = contentCommentCount
)
val calculatedSupportScore = scorePolicy.calculateSupportScore(
channelDonationCanAmount = channelDonationCanAmount,
channelDonationCount = channelDonationCount,
fanTalkCount = fanTalkCount
)
val calculatedFanLoyaltyScore = scorePolicy.calculateFanLoyaltyScore(
finalFollowerCount = finalFollowerCount,
followIncrease = followIncrease
)
val calculatedFinalScore = scorePolicy.calculateFinalScore(
contentLiveScore = calculatedContentLiveScore,
engagementScore = calculatedEngagementScore,
supportScore = calculatedSupportScore,
fanLoyaltyScore = calculatedFanLoyaltyScore
)
return CreatorRankingSnapshotRecord(
rankingType = CreatorRankingType.WEEKLY,
aggregationStartAtUtc = utcRange.startInclusiveUtc,
aggregationEndAtUtc = utcRange.endExclusiveUtc,
visibleFromAtUtc = utcRange.endExclusiveUtc.plusHours(9),
creatorId = creatorId,
nickname = nickname,
profileImageUrl = profileImageUrl,
finalScore = calculatedFinalScore,
contentLiveScore = calculatedContentLiveScore,
engagementScore = calculatedEngagementScore,
supportScore = calculatedSupportScore,
fanLoyaltyScore = calculatedFanLoyaltyScore,
liveCanAmount = liveCanAmount,
contentPurchaseCanAmount = contentPurchaseCanAmount,
contentLikeCount = contentLikeCount,
contentCommentCount = contentCommentCount,
channelDonationCanAmount = channelDonationCanAmount,
channelDonationCount = channelDonationCount,
fanTalkCount = fanTalkCount,
finalFollowerCount = finalFollowerCount,
followIncrease = followIncrease
)
}
private fun List<CreatorRankingSnapshotRecord>.takeRankedBoundary(limit: Int): List<CreatorRankingSnapshotRecord> {
if (size <= limit) return this
val boundaryScore = this[limit - 1].finalScore
return filter { it.finalScore >= boundaryScore }
}
companion object {
private const val SNAPSHOT_LIMIT = 20
}