feat(ranking): 주간 스냅샷 갱신을 추가한다

This commit is contained in:
2026-06-08 18:21:50 +09:00
parent 6891573dcc
commit 1b74e43706
3 changed files with 311 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
package kr.co.vividnext.sodalive.v2.ranking.adapter.out.scheduler
import kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshService
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
@Component
class CreatorRankingSnapshotScheduler(
private val refreshService: CreatorRankingSnapshotRefreshService
) {
@Scheduled(cron = "0 0 6 * * MON", zone = "Asia/Seoul")
fun refreshLastCompletedWeek() {
refreshService.refreshLastCompletedWeek()
}
}

View File

@@ -0,0 +1,102 @@
package kr.co.vividnext.sodalive.v2.ranking.application
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingPeriodPolicy
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScorePolicy
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingSnapshotCandidate
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingUtcRange
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingAggregationPort
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotPort
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotRecord
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.ZonedDateTime
@Service
class CreatorRankingSnapshotRefreshService(
private val aggregationPort: CreatorRankingAggregationPort,
private val snapshotPort: CreatorRankingSnapshotPort
) {
private val periodPolicy = CreatorRankingPeriodPolicy()
private val scorePolicy = CreatorRankingScorePolicy()
@Transactional
fun refreshLastCompletedWeek() {
refreshLastCompletedWeek(ZonedDateTime.now())
}
@Transactional
fun refreshLastCompletedWeek(now: ZonedDateTime) {
val period = periodPolicy.resolveLastCompletedWeek(now)
val utcRange = periodPolicy.toUtcRange(period)
val snapshots = aggregationPort.aggregateCandidates(
startInclusiveUtc = utcRange.startInclusiveUtc,
endExclusiveUtc = utcRange.endExclusiveUtc
).map { it.toSnapshotRecord(utcRange) }
.sortedByDescending { it.finalScore }
.takeRankedBoundary(limit = SNAPSHOT_LIMIT)
snapshotPort.replaceSnapshots(
aggregationStartAtUtc = utcRange.startInclusiveUtc,
aggregationEndAtUtc = utcRange.endExclusiveUtc,
newSnapshots = snapshots
)
}
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(
aggregationStartAtUtc = utcRange.startInclusiveUtc,
aggregationEndAtUtc = utcRange.endExclusiveUtc,
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
}
}

View File

@@ -0,0 +1,194 @@
package kr.co.vividnext.sodalive.v2.ranking.application
import kr.co.vividnext.sodalive.v2.ranking.adapter.out.scheduler.CreatorRankingSnapshotScheduler
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingSnapshotCandidate
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingAggregationPort
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotPort
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotRecord
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.scheduling.annotation.Scheduled
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZonedDateTime
class CreatorRankingSnapshotRefreshServiceTest {
@Test
@DisplayName("주간 스냅샷 생성은 KST 지난 주를 UTC 조회 기간으로 변환하고 raw 지표 점수를 다시 계산해 저장한다")
fun shouldRefreshLastCompletedWeekWithUtcRangeAndCalculatedScores() {
val aggregationPort = FakeCreatorRankingAggregationPort()
val snapshotPort = FakeCreatorRankingSnapshotPort()
val service = service(aggregationPort = aggregationPort, snapshotPort = snapshotPort)
val now = ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul"))
aggregationPort.candidates = listOf(
candidate(
creatorId = 1L,
finalScore = 1.0,
liveCanAmount = 100,
contentPurchaseCanAmount = 50,
contentLikeCount = 10,
contentCommentCount = 4,
channelDonationCanAmount = 30,
channelDonationCount = 6,
fanTalkCount = 3,
finalFollowerCount = 20,
followIncrease = -2
)
)
service.refreshLastCompletedWeek(now)
val stored = snapshotPort.snapshots.single()
assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0, 0), aggregationPort.startInclusiveUtc)
assertEquals(LocalDateTime.of(2026, 6, 7, 15, 0, 0), aggregationPort.endExclusiveUtc)
assertEquals(aggregationPort.startInclusiveUtc, snapshotPort.aggregationStartAtUtc)
assertEquals(aggregationPort.endExclusiveUtc, snapshotPort.aggregationEndAtUtc)
assertEquals(85.0, stored.contentLiveScore, 0.0001)
assertEquals(7.0, stored.engagementScore, 0.0001)
assertEquals(19.8, stored.supportScore, 0.0001)
assertEquals(13.4, stored.fanLoyaltyScore, 0.0001)
assertEquals(38.14, stored.finalScore, 0.0001)
}
@Test
@DisplayName("주간 스냅샷 생성은 20위 점수 경계와 동점인 후보를 모두 저장하고 더 낮은 점수는 제외한다")
fun shouldStoreAllCandidatesTiedAtTwentiethScoreBoundary() {
val aggregationPort = FakeCreatorRankingAggregationPort()
val snapshotPort = FakeCreatorRankingSnapshotPort()
val service = service(aggregationPort = aggregationPort, snapshotPort = snapshotPort)
aggregationPort.candidates = (1L..19L).map { candidate(creatorId = it, liveCanAmount = 1_000 - it) } +
candidate(creatorId = 20L, liveCanAmount = 500) +
candidate(creatorId = 21L, liveCanAmount = 500) +
candidate(creatorId = 22L, liveCanAmount = 500) +
candidate(creatorId = 23L, liveCanAmount = 499)
service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul")))
assertEquals((1L..22L).toList(), snapshotPort.snapshots.map { it.creatorId })
}
@Test
@DisplayName("주간 스냅샷 생성은 같은 집계 기간을 다시 생성할 때 기존 row를 교체한다")
fun shouldReplaceSnapshotsForSameAggregationPeriod() {
val aggregationPort = FakeCreatorRankingAggregationPort()
val snapshotPort = FakeCreatorRankingSnapshotPort()
val service = service(aggregationPort = aggregationPort, snapshotPort = snapshotPort)
val now = ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul"))
aggregationPort.candidates = listOf(candidate(creatorId = 1L, liveCanAmount = 100))
service.refreshLastCompletedWeek(now)
aggregationPort.candidates = listOf(candidate(creatorId = 2L, liveCanAmount = 200))
service.refreshLastCompletedWeek(now)
assertEquals(listOf(2L), snapshotPort.snapshots.map { it.creatorId })
}
@Test
@DisplayName("주간 스냅샷 스케줄러는 매주 월요일 06:00 KST cron으로 갱신 서비스를 호출한다")
fun shouldScheduleWeeklySnapshotRefreshAtKstMondaySix() {
val scheduled = CreatorRankingSnapshotScheduler::class.java
.getDeclaredMethod("refreshLastCompletedWeek")
.getAnnotation(Scheduled::class.java)
val service = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
val scheduler = CreatorRankingSnapshotScheduler(service)
scheduler.refreshLastCompletedWeek()
assertEquals("0 0 6 * * MON", scheduled.cron)
assertEquals("Asia/Seoul", scheduled.zone)
Mockito.verify(service).refreshLastCompletedWeek()
}
private fun service(
aggregationPort: CreatorRankingAggregationPort = FakeCreatorRankingAggregationPort(),
snapshotPort: CreatorRankingSnapshotPort = FakeCreatorRankingSnapshotPort()
): CreatorRankingSnapshotRefreshService {
return CreatorRankingSnapshotRefreshService(
aggregationPort = aggregationPort,
snapshotPort = snapshotPort
)
}
private fun candidate(
creatorId: Long,
finalScore: Double = 0.0,
liveCanAmount: Long = 0,
contentPurchaseCanAmount: Long = 0,
contentLikeCount: Long = 0,
contentCommentCount: Long = 0,
channelDonationCanAmount: Long = 0,
channelDonationCount: Long = 0,
fanTalkCount: Long = 0,
finalFollowerCount: Long = 0,
followIncrease: Long = 0
): CreatorRankingSnapshotCandidate {
return CreatorRankingSnapshotCandidate(
creatorId = creatorId,
nickname = "creator-$creatorId",
profileImageUrl = "profile-$creatorId.png",
finalScore = finalScore,
contentLiveScore = 0.0,
engagementScore = 0.0,
supportScore = 0.0,
fanLoyaltyScore = 0.0,
liveCanAmount = liveCanAmount,
contentPurchaseCanAmount = contentPurchaseCanAmount,
contentLikeCount = contentLikeCount,
contentCommentCount = contentCommentCount,
channelDonationCanAmount = channelDonationCanAmount,
channelDonationCount = channelDonationCount,
fanTalkCount = fanTalkCount,
finalFollowerCount = finalFollowerCount,
followIncrease = followIncrease
)
}
}
private class FakeCreatorRankingAggregationPort : CreatorRankingAggregationPort {
var candidates: List<CreatorRankingSnapshotCandidate> = emptyList()
var startInclusiveUtc: LocalDateTime? = null
var endExclusiveUtc: LocalDateTime? = null
override fun aggregateCandidates(
startInclusiveUtc: LocalDateTime,
endExclusiveUtc: LocalDateTime
): List<CreatorRankingSnapshotCandidate> {
this.startInclusiveUtc = startInclusiveUtc
this.endExclusiveUtc = endExclusiveUtc
return candidates
}
}
private class FakeCreatorRankingSnapshotPort : CreatorRankingSnapshotPort {
val snapshots = mutableListOf<CreatorRankingSnapshotRecord>()
var aggregationStartAtUtc: LocalDateTime? = null
var aggregationEndAtUtc: LocalDateTime? = null
override fun findSnapshotsByAggregationPeriod(
aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime
): List<CreatorRankingSnapshotRecord> {
return snapshots.filter {
it.aggregationStartAtUtc == aggregationStartAtUtc && it.aggregationEndAtUtc == aggregationEndAtUtc
}
}
override fun findLatestSnapshots(): List<CreatorRankingSnapshotRecord> = snapshots
override fun findPreviousCompletedSnapshots(): List<CreatorRankingSnapshotRecord> = snapshots
override fun replaceSnapshots(
aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime,
newSnapshots: List<CreatorRankingSnapshotRecord>
) {
this.aggregationStartAtUtc = aggregationStartAtUtc
this.aggregationEndAtUtc = aggregationEndAtUtc
snapshots.removeIf {
it.aggregationStartAtUtc == aggregationStartAtUtc && it.aggregationEndAtUtc == aggregationEndAtUtc
}
snapshots.addAll(newSnapshots)
}
}