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