diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/scheduler/CreatorRankingSnapshotScheduler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/scheduler/CreatorRankingSnapshotScheduler.kt new file mode 100644 index 00000000..e6220f08 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/scheduler/CreatorRankingSnapshotScheduler.kt @@ -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() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshService.kt new file mode 100644 index 00000000..4da51dcc --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshService.kt @@ -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.takeRankedBoundary(limit: Int): List { + if (size <= limit) return this + val boundaryScore = this[limit - 1].finalScore + return filter { it.finalScore >= boundaryScore } + } + + companion object { + private const val SNAPSHOT_LIMIT = 20 + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt new file mode 100644 index 00000000..3caf3960 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt @@ -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 = emptyList() + var startInclusiveUtc: LocalDateTime? = null + var endExclusiveUtc: LocalDateTime? = null + + override fun aggregateCandidates( + startInclusiveUtc: LocalDateTime, + endExclusiveUtc: LocalDateTime + ): List { + this.startInclusiveUtc = startInclusiveUtc + this.endExclusiveUtc = endExclusiveUtc + return candidates + } +} + +private class FakeCreatorRankingSnapshotPort : CreatorRankingSnapshotPort { + val snapshots = mutableListOf() + var aggregationStartAtUtc: LocalDateTime? = null + var aggregationEndAtUtc: LocalDateTime? = null + + override fun findSnapshotsByAggregationPeriod( + aggregationStartAtUtc: LocalDateTime, + aggregationEndAtUtc: LocalDateTime + ): List { + return snapshots.filter { + it.aggregationStartAtUtc == aggregationStartAtUtc && it.aggregationEndAtUtc == aggregationEndAtUtc + } + } + + override fun findLatestSnapshots(): List = snapshots + + override fun findPreviousCompletedSnapshots(): List = snapshots + + override fun replaceSnapshots( + aggregationStartAtUtc: LocalDateTime, + aggregationEndAtUtc: LocalDateTime, + newSnapshots: List + ) { + this.aggregationStartAtUtc = aggregationStartAtUtc + this.aggregationEndAtUtc = aggregationEndAtUtc + snapshots.removeIf { + it.aggregationStartAtUtc == aggregationStartAtUtc && it.aggregationEndAtUtc == aggregationEndAtUtc + } + snapshots.addAll(newSnapshots) + } +}