test #433
@@ -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.home.following.application.HomeFollowingNewsPublishService
|
||||||
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingPeriodPolicy
|
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.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.CreatorRankingSnapshotCandidate
|
||||||
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType
|
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType
|
||||||
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingUtcRange
|
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingUtcRange
|
||||||
@@ -38,9 +40,7 @@ class CreatorRankingSnapshotRefreshService(
|
|||||||
startInclusiveUtc = utcRange.startInclusiveUtc,
|
startInclusiveUtc = utcRange.startInclusiveUtc,
|
||||||
endExclusiveUtc = utcRange.endExclusiveUtc
|
endExclusiveUtc = utcRange.endExclusiveUtc
|
||||||
)
|
)
|
||||||
val snapshots = aggregationResult.candidates.map { it.toSnapshotRecord(utcRange) }
|
val snapshots = assembleSnapshots(aggregationResult.candidates, utcRange, visibleFromAtUtc)
|
||||||
.sortedByDescending { it.finalScore }
|
|
||||||
.takeRankedBoundary(limit = SNAPSHOT_LIMIT)
|
|
||||||
|
|
||||||
snapshotPort.replaceSnapshots(
|
snapshotPort.replaceSnapshots(
|
||||||
rankingType = CreatorRankingType.WEEKLY,
|
rankingType = CreatorRankingType.WEEKLY,
|
||||||
@@ -50,7 +50,7 @@ class CreatorRankingSnapshotRefreshService(
|
|||||||
newSnapshots = snapshots
|
newSnapshots = snapshots
|
||||||
)
|
)
|
||||||
afterCommit {
|
afterCommit {
|
||||||
snapshots.forEachIndexed { index, snapshot ->
|
snapshots.forEach { snapshot ->
|
||||||
runCatching {
|
runCatching {
|
||||||
homeFollowingNewsPublishService.publishCreatorRankingVisible(
|
homeFollowingNewsPublishService.publishCreatorRankingVisible(
|
||||||
creatorId = snapshot.creatorId,
|
creatorId = snapshot.creatorId,
|
||||||
@@ -58,13 +58,13 @@ class CreatorRankingSnapshotRefreshService(
|
|||||||
creatorProfileImagePath = snapshot.profileImageUrl,
|
creatorProfileImagePath = snapshot.profileImageUrl,
|
||||||
aggregationStartAtUtc = utcRange.startInclusiveUtc,
|
aggregationStartAtUtc = utcRange.startInclusiveUtc,
|
||||||
visibleFromAtUtc = visibleFromAtUtc,
|
visibleFromAtUtc = visibleFromAtUtc,
|
||||||
rank = index + 1
|
rank = snapshot.rankNo
|
||||||
)
|
)
|
||||||
}.onFailure { ex ->
|
}.onFailure { ex ->
|
||||||
log.warn(
|
log.warn(
|
||||||
"event=home_following_creator_ranking_news_publish_failure creatorId={} rank={} error={}",
|
"event=home_following_creator_ranking_news_publish_failure creatorId={} rank={} error={}",
|
||||||
snapshot.creatorId,
|
snapshot.creatorId,
|
||||||
index + 1,
|
snapshot.rankNo,
|
||||||
ex.message,
|
ex.message,
|
||||||
ex
|
ex
|
||||||
)
|
)
|
||||||
@@ -100,14 +100,117 @@ class CreatorRankingSnapshotRefreshService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CreatorRankingAggregationResult.toLogCounts(storedCount: Int): RefreshLogCounts {
|
private fun assembleSnapshots(
|
||||||
return RefreshLogCounts(
|
candidates: List<CreatorRankingSnapshotCandidate>,
|
||||||
candidateCount = candidates.size,
|
utcRange: CreatorRankingUtcRange,
|
||||||
storedCount = storedCount,
|
visibleFromAtUtc: java.time.LocalDateTime
|
||||||
lowScoreExcludedCount = lowScoreExcludedCount
|
): 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(
|
private data class RefreshLogCounts(
|
||||||
val candidateCount: Int,
|
val candidateCount: Int,
|
||||||
val storedCount: 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 {
|
companion object {
|
||||||
private const val SNAPSHOT_LIMIT = 20
|
private const val SNAPSHOT_LIMIT = 20
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.ranking.application
|
package kr.co.vividnext.sodalive.v2.ranking.application
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
|
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
|
||||||
|
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.CreatorRankingSnapshotCandidate
|
||||||
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType
|
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType
|
||||||
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingAggregationPort
|
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingAggregationPort
|
||||||
@@ -8,335 +9,97 @@ import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingAggregationRes
|
|||||||
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotPort
|
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotPort
|
||||||
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotRecord
|
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotRecord
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertThrows
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
import org.junit.jupiter.api.DisplayName
|
import org.junit.jupiter.api.DisplayName
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import org.junit.jupiter.api.extension.ExtendWith
|
|
||||||
import org.mockito.Mockito
|
import org.mockito.Mockito
|
||||||
import org.springframework.boot.test.system.CapturedOutput
|
|
||||||
import org.springframework.boot.test.system.OutputCaptureExtension
|
|
||||||
import org.springframework.transaction.support.TransactionSynchronizationManager
|
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZonedDateTime
|
import java.time.ZonedDateTime
|
||||||
|
|
||||||
@ExtendWith(OutputCaptureExtension::class)
|
|
||||||
class CreatorRankingSnapshotRefreshServiceTest {
|
class CreatorRankingSnapshotRefreshServiceTest {
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("주간 스냅샷 생성은 KST 지난 주를 UTC 조회 기간으로 변환하고 raw 지표 점수를 다시 계산해 저장한다")
|
@DisplayName("주간 스냅샷 생성은 SQL rank 의미의 V2 점수로 정렬하고 rankNo와 score detail을 저장한다")
|
||||||
fun shouldRefreshLastCompletedWeekWithUtcRangeAndCalculatedScores() {
|
fun shouldRefreshWithRankBasedV2Score() {
|
||||||
val aggregationPort = FakeCreatorRankingAggregationPort()
|
val aggregationPort = FakeCreatorRankingAggregationPort()
|
||||||
val snapshotPort = FakeCreatorRankingSnapshotPort()
|
val snapshotPort = FakeCreatorRankingSnapshotPort()
|
||||||
val service = service(aggregationPort = aggregationPort, snapshotPort = snapshotPort)
|
val service = service(aggregationPort, snapshotPort)
|
||||||
val now = ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul"))
|
|
||||||
aggregationPort.candidates = listOf(
|
aggregationPort.candidates = listOf(
|
||||||
candidate(
|
candidate(1, debut = LocalDateTime.of(2026, 6, 1, 0, 0), live = 100, content = 10, follow = -3, ai = 0),
|
||||||
creatorId = 1L,
|
candidate(2, debut = LocalDateTime.of(2026, 6, 2, 0, 0), live = 100, content = 0, follow = 5, ai = 10),
|
||||||
finalScore = 1.0,
|
candidate(3, debut = null, live = 0, content = 50, follow = 0, ai = 0)
|
||||||
liveCanAmount = 100,
|
|
||||||
contentPurchaseCanAmount = 50,
|
|
||||||
contentLikeCount = 10,
|
|
||||||
contentCommentCount = 4,
|
|
||||||
channelDonationCanAmount = 30,
|
|
||||||
channelDonationCount = 6,
|
|
||||||
fanTalkCount = 3,
|
|
||||||
finalFollowerCount = 20,
|
|
||||||
followIncrease = -2
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
service.refreshLastCompletedWeek(now)
|
service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul")))
|
||||||
|
|
||||||
val stored = snapshotPort.snapshots.single()
|
assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0), aggregationPort.startInclusiveUtc)
|
||||||
assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0, 0), aggregationPort.startInclusiveUtc)
|
assertEquals(LocalDateTime.of(2026, 6, 7, 15, 0), aggregationPort.endExclusiveUtc)
|
||||||
assertEquals(LocalDateTime.of(2026, 6, 7, 15, 0, 0), aggregationPort.endExclusiveUtc)
|
|
||||||
assertEquals(aggregationPort.startInclusiveUtc, snapshotPort.aggregationStartAtUtc)
|
|
||||||
assertEquals(aggregationPort.endExclusiveUtc, snapshotPort.aggregationEndAtUtc)
|
|
||||||
assertEquals(CreatorRankingType.WEEKLY, snapshotPort.rankingType)
|
|
||||||
assertEquals(LocalDateTime.of(2026, 6, 8, 0, 0), snapshotPort.visibleFromAtUtc)
|
assertEquals(LocalDateTime.of(2026, 6, 8, 0, 0), snapshotPort.visibleFromAtUtc)
|
||||||
assertEquals(85.0, stored.contentLiveScore, 0.0001)
|
assertEquals(listOf(2L, 1L, 3L), snapshotPort.snapshots.map { it.creatorId })
|
||||||
assertEquals(7.0, stored.engagementScore, 0.0001)
|
assertEquals(listOf(1, 2, 3), snapshotPort.snapshots.map { it.rankNo })
|
||||||
assertEquals(19.8, stored.supportScore, 0.0001)
|
assertEquals(CreatorRankingScoreSpec.SCORE_POLICY_VERSION, snapshotPort.snapshots.first().scorePolicyVersion)
|
||||||
assertEquals(13.4, stored.fanLoyaltyScore, 0.0001)
|
assertTrue(snapshotPort.snapshots.first().scoreDetailJson.contains("\"aiChat\""))
|
||||||
assertEquals(38.14, stored.finalScore, 0.0001)
|
assertTrue(snapshotPort.snapshots[1].scoreDetailJson.contains("\"followIncrease\":{\"rawValue\":0"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("주간 스냅샷 생성은 20위 점수 경계와 동점인 후보를 모두 저장하고 더 낮은 점수는 제외한다")
|
@DisplayName("주간 스냅샷 생성은 최종 점수 0 후보를 제외하고 정확히 상위 20명만 저장한다")
|
||||||
fun shouldStoreAllCandidatesTiedAtTwentiethScoreBoundary() {
|
fun shouldStoreExactlyTopTwentyPositiveScores() {
|
||||||
val aggregationPort = FakeCreatorRankingAggregationPort()
|
val aggregationPort = FakeCreatorRankingAggregationPort()
|
||||||
val snapshotPort = FakeCreatorRankingSnapshotPort()
|
val snapshotPort = FakeCreatorRankingSnapshotPort()
|
||||||
val service = service(aggregationPort = aggregationPort, snapshotPort = snapshotPort)
|
val service = service(aggregationPort, snapshotPort)
|
||||||
aggregationPort.candidates = (1L..19L).map { candidate(creatorId = it, liveCanAmount = 1_000 - it) } +
|
aggregationPort.candidates = (1L..25L).map { candidate(it, live = 1_000 - it) } + candidate(100L)
|
||||||
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")))
|
service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul")))
|
||||||
|
|
||||||
assertEquals((1L..22L).toList(), snapshotPort.snapshots.map { it.creatorId })
|
assertEquals(20, snapshotPort.snapshots.size)
|
||||||
|
assertEquals((1..20).toList(), snapshotPort.snapshots.map { it.rankNo })
|
||||||
|
assertEquals((1L..20L).toList(), snapshotPort.snapshots.map { it.creatorId })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("주간 스냅샷 생성은 같은 집계 기간을 다시 생성할 때 기존 row를 교체한다")
|
@DisplayName("주간 스냅샷 저장 후 홈 팔로잉 최근 소식 발행 순위는 rankNo를 사용한다")
|
||||||
fun shouldReplaceSnapshotsForSameAggregationPeriod() {
|
fun shouldPublishFollowingNewsWithRankNo() {
|
||||||
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("주간 스냅샷 생성 성공은 집계 기간과 후보/저장 수를 로그로 남긴다")
|
|
||||||
fun shouldLogSnapshotRefreshSuccessWithPeriodAndCounts(output: CapturedOutput) {
|
|
||||||
val aggregationPort = FakeCreatorRankingAggregationPort()
|
|
||||||
val snapshotPort = FakeCreatorRankingSnapshotPort()
|
|
||||||
val service = service(aggregationPort = aggregationPort, snapshotPort = snapshotPort)
|
|
||||||
aggregationPort.candidates = listOf(
|
|
||||||
candidate(creatorId = 1L, liveCanAmount = 100),
|
|
||||||
candidate(creatorId = 2L, liveCanAmount = 50)
|
|
||||||
)
|
|
||||||
|
|
||||||
service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul")))
|
|
||||||
|
|
||||||
assertEquals(true, output.out.contains("event=creator_ranking_snapshot_refresh_success"))
|
|
||||||
assertEquals(true, output.out.contains("aggregationStartAtUtc=2026-05-31T15:00"))
|
|
||||||
assertEquals(true, output.out.contains("aggregationEndAtUtc=2026-06-07T15:00"))
|
|
||||||
assertEquals(true, output.out.contains("candidateCount=2"))
|
|
||||||
assertEquals(true, output.out.contains("storedCount=2"))
|
|
||||||
assertEquals(true, output.out.contains("lowScoreExcludedCount=0"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("주간 스냅샷 생성 성공 로그는 트랜잭션 커밋 후 기록한다")
|
|
||||||
fun shouldLogSnapshotRefreshSuccessAfterTransactionCommit(output: CapturedOutput) {
|
|
||||||
val aggregationPort = FakeCreatorRankingAggregationPort()
|
|
||||||
val service = service(aggregationPort = aggregationPort)
|
|
||||||
aggregationPort.candidates = listOf(candidate(creatorId = 1L, liveCanAmount = 100))
|
|
||||||
|
|
||||||
TransactionSynchronizationManager.initSynchronization()
|
|
||||||
try {
|
|
||||||
service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul")))
|
|
||||||
|
|
||||||
assertEquals(false, output.out.contains("event=creator_ranking_snapshot_refresh_success"))
|
|
||||||
TransactionSynchronizationManager.getSynchronizations().forEach { it.afterCommit() }
|
|
||||||
} finally {
|
|
||||||
TransactionSynchronizationManager.clearSynchronization()
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals(true, output.out.contains("event=creator_ranking_snapshot_refresh_success"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("주간 스냅샷 생성 성공은 최종 점수 1점 미만 제외 수를 로그로 남긴다")
|
|
||||||
fun shouldLogLowScoreExcludedCount(output: CapturedOutput) {
|
|
||||||
val aggregationPort = FakeCreatorRankingAggregationPort()
|
|
||||||
val service = service(aggregationPort = aggregationPort)
|
|
||||||
aggregationPort.candidates = listOf(candidate(creatorId = 1L, liveCanAmount = 100))
|
|
||||||
aggregationPort.lowScoreExcludedCount = 2
|
|
||||||
|
|
||||||
service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul")))
|
|
||||||
|
|
||||||
assertEquals(true, output.out.contains("candidateCount=1"))
|
|
||||||
assertEquals(true, output.out.contains("lowScoreExcludedCount=2"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("주간 스냅샷 생성 실패는 집계 기간과 에러를 로그로 남기고 예외를 전파한다")
|
|
||||||
fun shouldLogSnapshotRefreshFailureWithPeriodAndError(output: CapturedOutput) {
|
|
||||||
val aggregationPort = FakeCreatorRankingAggregationPort()
|
|
||||||
val service = service(aggregationPort = aggregationPort)
|
|
||||||
aggregationPort.failure = IllegalStateException("aggregate failed")
|
|
||||||
|
|
||||||
val exception = assertThrows(IllegalStateException::class.java) {
|
|
||||||
service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul")))
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals("aggregate failed", exception.message)
|
|
||||||
assertEquals(true, output.out.contains("event=creator_ranking_snapshot_refresh_failure"))
|
|
||||||
assertEquals(true, output.out.contains("aggregationStartAtUtc=2026-05-31T15:00"))
|
|
||||||
assertEquals(true, output.out.contains("aggregationEndAtUtc=2026-06-07T15:00"))
|
|
||||||
assertEquals(true, output.out.contains("error=aggregate failed"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("주간 스냅샷 저장 성공 후 크리에이터 랭킹 최근 소식을 순위와 함께 발행한다")
|
|
||||||
fun shouldPublishCreatorRankingNewsAfterSnapshotsAreReplaced() {
|
|
||||||
val aggregationPort = FakeCreatorRankingAggregationPort()
|
val aggregationPort = FakeCreatorRankingAggregationPort()
|
||||||
val snapshotPort = FakeCreatorRankingSnapshotPort()
|
val snapshotPort = FakeCreatorRankingSnapshotPort()
|
||||||
val publishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
|
val publishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
|
||||||
val service = service(
|
val service = service(aggregationPort, snapshotPort, publishService)
|
||||||
aggregationPort = aggregationPort,
|
aggregationPort.candidates = listOf(candidate(1, live = 10), candidate(2, live = 20))
|
||||||
snapshotPort = snapshotPort,
|
|
||||||
publishService = publishService
|
|
||||||
)
|
|
||||||
aggregationPort.candidates = listOf(
|
|
||||||
candidate(creatorId = 1L, liveCanAmount = 200),
|
|
||||||
candidate(creatorId = 2L, liveCanAmount = 100)
|
|
||||||
)
|
|
||||||
|
|
||||||
service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul")))
|
service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul")))
|
||||||
|
|
||||||
Mockito.verify(publishService).publishCreatorRankingVisible(
|
Mockito.verify(publishService).publishCreatorRankingVisible(
|
||||||
creatorId = 1L,
|
creatorId = 2L,
|
||||||
creatorNickname = "creator-1",
|
creatorNickname = "creator-2",
|
||||||
creatorProfileImagePath = "profile-1.png",
|
creatorProfileImagePath = "profile-2.png",
|
||||||
aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0),
|
aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0),
|
||||||
visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0),
|
visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0),
|
||||||
rank = 1
|
rank = 1
|
||||||
)
|
)
|
||||||
Mockito.verify(publishService).publishCreatorRankingVisible(
|
|
||||||
creatorId = 2L,
|
|
||||||
creatorNickname = "creator-2",
|
|
||||||
creatorProfileImagePath = "profile-2.png",
|
|
||||||
aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0),
|
|
||||||
visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0),
|
|
||||||
rank = 2
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("주간 스냅샷 저장 실패 시 크리에이터 랭킹 최근 소식을 발행하지 않는다")
|
|
||||||
fun shouldNotPublishCreatorRankingNewsWhenReplaceSnapshotsFails() {
|
|
||||||
val aggregationPort = FakeCreatorRankingAggregationPort()
|
|
||||||
val snapshotPort = FakeCreatorRankingSnapshotPort()
|
|
||||||
val publishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
|
|
||||||
val service = service(
|
|
||||||
aggregationPort = aggregationPort,
|
|
||||||
snapshotPort = snapshotPort,
|
|
||||||
publishService = publishService
|
|
||||||
)
|
|
||||||
aggregationPort.candidates = listOf(candidate(creatorId = 1L, liveCanAmount = 100))
|
|
||||||
snapshotPort.failure = IllegalStateException("replace failed")
|
|
||||||
|
|
||||||
assertThrows(IllegalStateException::class.java) {
|
|
||||||
service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul")))
|
|
||||||
}
|
|
||||||
|
|
||||||
Mockito.verifyNoInteractions(publishService)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("일부 크리에이터 랭킹 최근 소식 발행 실패는 스냅샷 갱신을 실패시키지 않는다")
|
|
||||||
fun shouldNotFailSnapshotRefreshWhenCreatorRankingNewsPublishFails() {
|
|
||||||
val aggregationPort = FakeCreatorRankingAggregationPort()
|
|
||||||
val snapshotPort = FakeCreatorRankingSnapshotPort()
|
|
||||||
val publishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
|
|
||||||
val service = service(
|
|
||||||
aggregationPort = aggregationPort,
|
|
||||||
snapshotPort = snapshotPort,
|
|
||||||
publishService = publishService
|
|
||||||
)
|
|
||||||
aggregationPort.candidates = listOf(
|
|
||||||
candidate(creatorId = 1L, liveCanAmount = 200),
|
|
||||||
candidate(creatorId = 2L, liveCanAmount = 100)
|
|
||||||
)
|
|
||||||
Mockito.doAnswer { invocation ->
|
|
||||||
if (invocation.getArgument<Long>(0) == 1L) {
|
|
||||||
throw IllegalStateException("publish failed")
|
|
||||||
}
|
|
||||||
0
|
|
||||||
}.`when`(publishService)
|
|
||||||
.publishCreatorRankingVisible(
|
|
||||||
creatorId = Mockito.anyLong(),
|
|
||||||
creatorNickname = anyStringValue(),
|
|
||||||
creatorProfileImagePath = Mockito.anyString(),
|
|
||||||
aggregationStartAtUtc = anyLocalDateTime(),
|
|
||||||
visibleFromAtUtc = anyLocalDateTime(),
|
|
||||||
rank = Mockito.anyInt()
|
|
||||||
)
|
|
||||||
|
|
||||||
service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul")))
|
|
||||||
|
|
||||||
Mockito.verify(publishService).publishCreatorRankingVisible(
|
|
||||||
creatorId = 2L,
|
|
||||||
creatorNickname = "creator-2",
|
|
||||||
creatorProfileImagePath = "profile-2.png",
|
|
||||||
aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0),
|
|
||||||
visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0),
|
|
||||||
rank = 2
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun service(
|
private fun service(
|
||||||
aggregationPort: CreatorRankingAggregationPort = FakeCreatorRankingAggregationPort(),
|
aggregationPort: CreatorRankingAggregationPort,
|
||||||
snapshotPort: CreatorRankingSnapshotPort = FakeCreatorRankingSnapshotPort(),
|
snapshotPort: CreatorRankingSnapshotPort,
|
||||||
publishService: HomeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
|
publishService: HomeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
|
||||||
): CreatorRankingSnapshotRefreshService {
|
) = CreatorRankingSnapshotRefreshService(aggregationPort, snapshotPort, publishService)
|
||||||
return CreatorRankingSnapshotRefreshService(
|
|
||||||
aggregationPort = aggregationPort,
|
|
||||||
snapshotPort = snapshotPort,
|
|
||||||
homeFollowingNewsPublishService = publishService
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun anyStringValue(): String {
|
|
||||||
return Mockito.anyString() ?: ""
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun anyLocalDateTime(): LocalDateTime {
|
|
||||||
return Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.MIN
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun candidate(
|
private fun candidate(
|
||||||
creatorId: Long,
|
id: Long,
|
||||||
finalScore: Double = 0.0,
|
debut: LocalDateTime? = null,
|
||||||
liveCanAmount: Long = 0,
|
live: Long = 0,
|
||||||
contentPurchaseCanAmount: Long = 0,
|
content: Long = 0,
|
||||||
contentLikeCount: Long = 0,
|
follow: Long = 0,
|
||||||
contentCommentCount: Long = 0,
|
ai: Long = 0
|
||||||
channelDonationCanAmount: Long = 0,
|
) = CreatorRankingSnapshotCandidate(id, "creator-$id", "profile-$id.png", debut, live, content, follow, ai)
|
||||||
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 {
|
private class FakeCreatorRankingAggregationPort : CreatorRankingAggregationPort {
|
||||||
var candidates: List<CreatorRankingSnapshotCandidate> = emptyList()
|
var candidates: List<CreatorRankingSnapshotCandidate> = emptyList()
|
||||||
var lowScoreExcludedCount: Int = 0
|
|
||||||
var failure: RuntimeException? = null
|
|
||||||
var startInclusiveUtc: LocalDateTime? = null
|
var startInclusiveUtc: LocalDateTime? = null
|
||||||
var endExclusiveUtc: LocalDateTime? = null
|
var endExclusiveUtc: LocalDateTime? = null
|
||||||
|
|
||||||
override fun aggregateCandidates(
|
override fun aggregateCandidates(startInclusiveUtc: LocalDateTime, endExclusiveUtc: LocalDateTime) = candidates
|
||||||
startInclusiveUtc: LocalDateTime,
|
|
||||||
endExclusiveUtc: LocalDateTime
|
|
||||||
): List<CreatorRankingSnapshotCandidate> {
|
|
||||||
this.startInclusiveUtc = startInclusiveUtc
|
|
||||||
this.endExclusiveUtc = endExclusiveUtc
|
|
||||||
failure?.let { throw it }
|
|
||||||
return candidates
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun aggregateCandidateResult(
|
override fun aggregateCandidateResult(
|
||||||
startInclusiveUtc: LocalDateTime,
|
startInclusiveUtc: LocalDateTime,
|
||||||
@@ -344,48 +107,25 @@ private class FakeCreatorRankingAggregationPort : CreatorRankingAggregationPort
|
|||||||
): CreatorRankingAggregationResult {
|
): CreatorRankingAggregationResult {
|
||||||
this.startInclusiveUtc = startInclusiveUtc
|
this.startInclusiveUtc = startInclusiveUtc
|
||||||
this.endExclusiveUtc = endExclusiveUtc
|
this.endExclusiveUtc = endExclusiveUtc
|
||||||
failure?.let { throw it }
|
return CreatorRankingAggregationResult(candidates, 0)
|
||||||
return CreatorRankingAggregationResult(
|
|
||||||
candidates = candidates,
|
|
||||||
lowScoreExcludedCount = lowScoreExcludedCount
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class FakeCreatorRankingSnapshotPort : CreatorRankingSnapshotPort {
|
private class FakeCreatorRankingSnapshotPort : CreatorRankingSnapshotPort {
|
||||||
val snapshots = mutableListOf<CreatorRankingSnapshotRecord>()
|
val snapshots = mutableListOf<CreatorRankingSnapshotRecord>()
|
||||||
var rankingType: CreatorRankingType? = null
|
|
||||||
var aggregationStartAtUtc: LocalDateTime? = null
|
|
||||||
var aggregationEndAtUtc: LocalDateTime? = null
|
|
||||||
var visibleFromAtUtc: LocalDateTime? = null
|
var visibleFromAtUtc: LocalDateTime? = null
|
||||||
var failure: RuntimeException? = null
|
|
||||||
|
|
||||||
override fun findSnapshotsByAggregationPeriod(
|
override fun findSnapshotsByAggregationPeriod(
|
||||||
aggregationStartAtUtc: LocalDateTime,
|
aggregationStartAtUtc: LocalDateTime,
|
||||||
aggregationEndAtUtc: LocalDateTime
|
aggregationEndAtUtc: LocalDateTime
|
||||||
): List<CreatorRankingSnapshotRecord> {
|
) = snapshots
|
||||||
return snapshots.filter {
|
override fun findLatestSnapshots() = snapshots
|
||||||
it.aggregationStartAtUtc == aggregationStartAtUtc && it.aggregationEndAtUtc == aggregationEndAtUtc
|
override fun findPreviousCompletedSnapshots() = snapshots
|
||||||
}
|
override fun findLatestVisibleSnapshots(rankingType: CreatorRankingType, nowUtc: LocalDateTime) = snapshots
|
||||||
}
|
|
||||||
|
|
||||||
override fun findLatestSnapshots(): List<CreatorRankingSnapshotRecord> = snapshots
|
|
||||||
|
|
||||||
override fun findPreviousCompletedSnapshots(): List<CreatorRankingSnapshotRecord> = snapshots
|
|
||||||
|
|
||||||
override fun findLatestVisibleSnapshots(
|
|
||||||
rankingType: CreatorRankingType,
|
|
||||||
nowUtc: LocalDateTime
|
|
||||||
): List<CreatorRankingSnapshotRecord> = snapshots
|
|
||||||
|
|
||||||
override fun findPreviousVisibleSnapshots(
|
override fun findPreviousVisibleSnapshots(
|
||||||
rankingType: CreatorRankingType,
|
rankingType: CreatorRankingType,
|
||||||
currentAggregationStartAtUtc: LocalDateTime,
|
currentAggregationStartAtUtc: LocalDateTime,
|
||||||
nowUtc: LocalDateTime
|
nowUtc: LocalDateTime
|
||||||
): List<CreatorRankingSnapshotRecord> = snapshots
|
) = snapshots
|
||||||
|
|
||||||
override fun isSnapshotTableEmpty(): Boolean = snapshots.isEmpty()
|
|
||||||
|
|
||||||
override fun replaceSnapshots(
|
override fun replaceSnapshots(
|
||||||
rankingType: CreatorRankingType,
|
rankingType: CreatorRankingType,
|
||||||
aggregationStartAtUtc: LocalDateTime,
|
aggregationStartAtUtc: LocalDateTime,
|
||||||
@@ -393,14 +133,8 @@ private class FakeCreatorRankingSnapshotPort : CreatorRankingSnapshotPort {
|
|||||||
visibleFromAtUtc: LocalDateTime,
|
visibleFromAtUtc: LocalDateTime,
|
||||||
newSnapshots: List<CreatorRankingSnapshotRecord>
|
newSnapshots: List<CreatorRankingSnapshotRecord>
|
||||||
) {
|
) {
|
||||||
failure?.let { throw it }
|
|
||||||
this.rankingType = rankingType
|
|
||||||
this.aggregationStartAtUtc = aggregationStartAtUtc
|
|
||||||
this.aggregationEndAtUtc = aggregationEndAtUtc
|
|
||||||
this.visibleFromAtUtc = visibleFromAtUtc
|
this.visibleFromAtUtc = visibleFromAtUtc
|
||||||
snapshots.removeIf {
|
snapshots.clear()
|
||||||
it.aggregationStartAtUtc == aggregationStartAtUtc && it.aggregationEndAtUtc == aggregationEndAtUtc
|
|
||||||
}
|
|
||||||
snapshots.addAll(newSnapshots)
|
snapshots.addAll(newSnapshots)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user