feat(ranking): 주간 스냅샷 갱신을 추가한다
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user