From 247569ee1611851835269230132d6f3169cf8bb7 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 10 Jul 2026 14:43:11 +0900 Subject: [PATCH] =?UTF-8?q?feat(home):=20=ED=81=AC=EB=A6=AC=EC=97=90?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EB=9E=AD=ED=82=B9=20=EC=8A=A4=EB=83=85?= =?UTF-8?q?=EC=83=B7=20=EC=A0=80=EC=9E=A5=20=EA=B5=AC=EC=A1=B0=EB=A5=BC=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../out/persistence/CreatorRankingSnapshot.kt | 44 +- .../CreatorRankingSnapshotRepository.kt | 11 +- ...DefaultCreatorRankingSnapshotRepository.kt | 50 +- .../port/out/CreatorRankingSnapshotPort.kt | 18 +- ...ultCreatorRankingSnapshotRepositoryTest.kt | 487 ++++++++---------- 5 files changed, 258 insertions(+), 352 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshot.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshot.kt index 9968d035..3f798b31 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshot.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshot.kt @@ -34,45 +34,15 @@ class CreatorRankingSnapshot( @Column(name = "profile_image_url", updatable = false, length = 500) val profileImageUrl: String?, + @Column(name = "rank_no", nullable = false, updatable = false) + val rankNo: Int, + @Column(name = "final_score", nullable = false, updatable = false) val finalScore: Double, - @Column(name = "content_live_score", nullable = false, updatable = false) - val contentLiveScore: Double, + @Column(name = "score_policy_version", nullable = false, updatable = false, length = 80) + val scorePolicyVersion: String, - @Column(name = "engagement_score", nullable = false, updatable = false) - val engagementScore: Double, - - @Column(name = "support_score", nullable = false, updatable = false) - val supportScore: Double, - - @Column(name = "fan_loyalty_score", nullable = false, updatable = false) - val fanLoyaltyScore: Double, - - @Column(name = "live_can_amount", nullable = false, updatable = false) - val liveCanAmount: Long, - - @Column(name = "content_purchase_can_amount", nullable = false, updatable = false) - val contentPurchaseCanAmount: Long, - - @Column(name = "content_like_count", nullable = false, updatable = false) - val contentLikeCount: Long, - - @Column(name = "content_comment_count", nullable = false, updatable = false) - val contentCommentCount: Long, - - @Column(name = "channel_donation_can_amount", nullable = false, updatable = false) - val channelDonationCanAmount: Long, - - @Column(name = "channel_donation_count", nullable = false, updatable = false) - val channelDonationCount: Long, - - @Column(name = "fan_talk_count", nullable = false, updatable = false) - val fanTalkCount: Long, - - @Column(name = "final_follower_count", nullable = false, updatable = false) - val finalFollowerCount: Long, - - @Column(name = "follow_increase", nullable = false, updatable = false) - val followIncrease: Long + @Column(name = "score_detail_json", nullable = false, updatable = false, columnDefinition = "json") + val scoreDetailJson: String ) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotRepository.kt index 2cc1e9c0..530aaf47 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotRepository.kt @@ -7,7 +7,7 @@ import org.springframework.data.repository.query.Param import java.time.LocalDateTime interface CreatorRankingSnapshotRepository : JpaRepository { - fun findAllByAggregationStartAtUtcAndAggregationEndAtUtcOrderByFinalScoreDesc( + fun findAllByAggregationStartAtUtcAndAggregationEndAtUtcOrderByRankNoAsc( aggregationStartAtUtc: LocalDateTime, aggregationEndAtUtc: LocalDateTime ): List @@ -20,7 +20,7 @@ interface CreatorRankingSnapshotRepository : JpaRepository { - return repository.findAllByAggregationStartAtUtcAndAggregationEndAtUtcOrderByFinalScoreDesc( + return repository.findAllByAggregationStartAtUtcAndAggregationEndAtUtcOrderByRankNoAsc( aggregationStartAtUtc = aggregationStartAtUtc, aggregationEndAtUtc = aggregationEndAtUtc ).map { it.toRecord() } @@ -42,16 +42,12 @@ class DefaultCreatorRankingSnapshotRepository( nowUtc: LocalDateTime ): List { return repository.findPreviousVisibleSnapshots( - rankingType = rankingType.name, - currentAggregationStartAtUtc = currentAggregationStartAtUtc, - nowUtc = nowUtc + rankingType.name, + currentAggregationStartAtUtc, + nowUtc ).map { it.toRecord() } } - override fun isSnapshotTableEmpty(): Boolean { - return repository.count() == 0L - } - @Transactional override fun replaceSnapshots( rankingType: CreatorRankingType, @@ -61,9 +57,9 @@ class DefaultCreatorRankingSnapshotRepository( newSnapshots: List ) { repository.deleteByRankingTypeAndAggregationStartAtUtcAndAggregationEndAtUtc( - rankingType = rankingType, - aggregationStartAtUtc = aggregationStartAtUtc, - aggregationEndAtUtc = aggregationEndAtUtc + rankingType, + aggregationStartAtUtc, + aggregationEndAtUtc ) repository.saveAll(newSnapshots.map { it.toEntity(rankingType, visibleFromAtUtc) }) } @@ -77,20 +73,10 @@ class DefaultCreatorRankingSnapshotRepository( creatorId = creatorId, nickname = nickname, profileImageUrl = profileImageUrl, + rankNo = rankNo, finalScore = finalScore, - contentLiveScore = contentLiveScore, - engagementScore = engagementScore, - supportScore = supportScore, - fanLoyaltyScore = fanLoyaltyScore, - liveCanAmount = liveCanAmount, - contentPurchaseCanAmount = contentPurchaseCanAmount, - contentLikeCount = contentLikeCount, - contentCommentCount = contentCommentCount, - channelDonationCanAmount = channelDonationCanAmount, - channelDonationCount = channelDonationCount, - fanTalkCount = fanTalkCount, - finalFollowerCount = finalFollowerCount, - followIncrease = followIncrease + scorePolicyVersion = scorePolicyVersion, + scoreDetailJson = scoreDetailJson ) } @@ -106,20 +92,10 @@ class DefaultCreatorRankingSnapshotRepository( creatorId = creatorId, nickname = nickname, profileImageUrl = profileImageUrl, + rankNo = rankNo, finalScore = finalScore, - contentLiveScore = contentLiveScore, - engagementScore = engagementScore, - supportScore = supportScore, - fanLoyaltyScore = fanLoyaltyScore, - liveCanAmount = liveCanAmount, - contentPurchaseCanAmount = contentPurchaseCanAmount, - contentLikeCount = contentLikeCount, - contentCommentCount = contentCommentCount, - channelDonationCanAmount = channelDonationCanAmount, - channelDonationCount = channelDonationCount, - fanTalkCount = fanTalkCount, - finalFollowerCount = finalFollowerCount, - followIncrease = followIncrease + scorePolicyVersion = scorePolicyVersion, + scoreDetailJson = scoreDetailJson ) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingSnapshotPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingSnapshotPort.kt index 33904ee8..918cc2a1 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingSnapshotPort.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingSnapshotPort.kt @@ -24,8 +24,6 @@ interface CreatorRankingSnapshotPort { nowUtc: LocalDateTime ): List - fun isSnapshotTableEmpty(): Boolean - fun replaceSnapshots( rankingType: CreatorRankingType, aggregationStartAtUtc: LocalDateTime, @@ -43,18 +41,8 @@ data class CreatorRankingSnapshotRecord( val creatorId: Long, val nickname: String, val profileImageUrl: String?, + val rankNo: Int, val finalScore: Double, - val contentLiveScore: Double, - val engagementScore: Double, - val supportScore: Double, - val fanLoyaltyScore: Double, - val liveCanAmount: Long, - val contentPurchaseCanAmount: Long, - val contentLikeCount: Long, - val contentCommentCount: Long, - val channelDonationCanAmount: Long, - val channelDonationCount: Long, - val fanTalkCount: Long, - val finalFollowerCount: Long, - val followIncrease: Long + val scorePolicyVersion: String, + val scoreDetailJson: String ) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepositoryTest.kt index 8118cff9..1d3e1a95 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepositoryTest.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScoreSpec import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotRecord import org.junit.jupiter.api.Assertions.assertEquals @@ -14,345 +15,315 @@ import java.time.LocalDateTime @DataJpaTest( properties = [ "spring.cache.type=none", - "spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE" + "spring.datasource.url=jdbc:h2:mem:creator-ranking-snapshot;" + + "MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE" ] ) -@Import(QueryDslConfig::class) +@Import(QueryDslConfig::class, DefaultCreatorRankingSnapshotRepository::class) class DefaultCreatorRankingSnapshotRepositoryTest @Autowired constructor( + private val adapter: DefaultCreatorRankingSnapshotRepository, private val repository: CreatorRankingSnapshotRepository ) { - private val adapter = DefaultCreatorRankingSnapshotRepository(repository) - @Test - @DisplayName("같은 집계 기간의 스냅샷은 삭제 후 새 후보로 교체한다") - fun shouldReplaceSnapshotsByAggregationPeriod() { - val startAt = LocalDateTime.of(2026, 5, 31, 15, 0) - val endAt = LocalDateTime.of(2026, 6, 7, 15, 0) - val oldStartAt = startAt.minusWeeks(1) - val oldEndAt = endAt.minusWeeks(1) - repository.saveAll( - listOf( - snapshot(creatorId = 1L, aggregationStartAtUtc = oldStartAt, aggregationEndAtUtc = oldEndAt), - snapshot(creatorId = 2L, aggregationStartAtUtc = startAt, aggregationEndAtUtc = endAt) + @DisplayName("replaceSnapshots는 rankNo, finalScore, scorePolicyVersion, scoreDetailJson을 저장하고 매핑한다") + fun shouldReplaceSnapshotsAndMapV2SnapshotContractFields() { + adapter.replaceSnapshots( + rankingType = CreatorRankingType.WEEKLY, + aggregationStartAtUtc = START, + aggregationEndAtUtc = END, + visibleFromAtUtc = VISIBLE, + newSnapshots = listOf( + record( + creatorId = 1L, + rankNo = 2, + finalScore = 88.5, + scorePolicyVersion = CreatorRankingScoreSpec.SCORE_POLICY_VERSION, + scoreDetailJson = "{\"liveCanAmount\":10}" + ) ) ) - adapter.replaceSnapshots( - rankingType = CreatorRankingType.WEEKLY, - aggregationStartAtUtc = startAt, - aggregationEndAtUtc = endAt, - visibleFromAtUtc = endAt.plusHours(9), - newSnapshots = listOf(snapshotRecord(creatorId = 3L, aggregationStartAtUtc = startAt, aggregationEndAtUtc = endAt)) - ) - - val allSnapshots = repository.findAll() - assertEquals(listOf(1L, 3L), allSnapshots.map { it.creatorId }.sorted()) - assertEquals(listOf(3L), adapter.findLatestSnapshots().map { it.creatorId }) - } - - @Test - @DisplayName("스냅샷은 랭킹 타입과 공개 노출 시각을 저장하고 같은 타입/기간만 교체한다") - fun shouldPersistRankingTypeAndVisibleFromAtAndReplaceByTypeAndPeriod() { - val startAt = LocalDateTime.of(2026, 5, 31, 15, 0) - val endAt = LocalDateTime.of(2026, 6, 7, 15, 0) - val visibleFromAt = LocalDateTime.of(2026, 6, 8, 0, 0) - repository.save(snapshot(creatorId = 1L, aggregationStartAtUtc = startAt, aggregationEndAtUtc = endAt)) - - adapter.replaceSnapshots( - rankingType = CreatorRankingType.WEEKLY, - aggregationStartAtUtc = startAt, - aggregationEndAtUtc = endAt, - visibleFromAtUtc = visibleFromAt, - newSnapshots = listOf(snapshotRecord(creatorId = 2L, aggregationStartAtUtc = startAt, aggregationEndAtUtc = endAt)) - ) - val saved = repository.findAll().single() - assertEquals(CreatorRankingType.WEEKLY, saved.rankingType) - assertEquals(visibleFromAt, saved.visibleFromAtUtc) - assertEquals(2L, saved.creatorId) + assertEquals(2, saved.rankNo) + assertEquals(88.5, saved.finalScore, 0.0001) + assertEquals(CreatorRankingScoreSpec.SCORE_POLICY_VERSION, saved.scorePolicyVersion) + assertEquals("{\"liveCanAmount\":10}", saved.scoreDetailJson) + + val mapped = adapter.findSnapshotsByAggregationPeriod(START, END).single() + assertEquals(2, mapped.rankNo) + assertEquals(88.5, mapped.finalScore, 0.0001) + assertEquals(CreatorRankingScoreSpec.SCORE_POLICY_VERSION, mapped.scorePolicyVersion) + assertEquals("{\"liveCanAmount\":10}", mapped.scoreDetailJson) } @Test - @DisplayName("최신 완료 주차 스냅샷은 최신 종료 시각 기준으로 최종 점수 내림차순 조회한다") - fun shouldFindLatestSnapshotsByLatestAggregationEndAndFinalScoreDescending() { - val oldStartAt = LocalDateTime.of(2026, 5, 24, 15, 0) - val oldEndAt = LocalDateTime.of(2026, 5, 31, 15, 0) - val latestStartAt = LocalDateTime.of(2026, 5, 31, 15, 0) - val latestEndAt = LocalDateTime.of(2026, 6, 7, 15, 0) + @DisplayName("findLatestSnapshots는 최신 종료 시각 스냅샷을 rank_no 오름차순으로 조회한다") + fun shouldFindLatestSnapshotsOrderedByRankNoAsc() { repository.saveAll( listOf( - snapshot( + entity( creatorId = 1L, + rankNo = 1, finalScore = 999.0, - aggregationStartAtUtc = oldStartAt, - aggregationEndAtUtc = oldEndAt + aggregationStartAtUtc = PREVIOUS_START, + aggregationEndAtUtc = PREVIOUS_END ), - snapshot( - creatorId = 2L, - finalScore = 100.0, - aggregationStartAtUtc = latestStartAt, - aggregationEndAtUtc = latestEndAt - ), - snapshot( - creatorId = 3L, - finalScore = 300.0, - aggregationStartAtUtc = latestStartAt, - aggregationEndAtUtc = latestEndAt - ), - snapshot( - creatorId = 4L, - finalScore = 200.0, - aggregationStartAtUtc = latestStartAt, - aggregationEndAtUtc = latestEndAt - ) + entity(creatorId = 2L, rankNo = 3, finalScore = 300.0), + entity(creatorId = 3L, rankNo = 1, finalScore = 100.0), + entity(creatorId = 4L, rankNo = 2, finalScore = 500.0) ) ) val latestSnapshots = adapter.findLatestSnapshots() assertEquals(listOf(3L, 4L, 2L), latestSnapshots.map { it.creatorId }) - assertEquals(listOf(latestStartAt, latestStartAt, latestStartAt), latestSnapshots.map { it.aggregationStartAtUtc }) - assertEquals(listOf(latestEndAt, latestEndAt, latestEndAt), latestSnapshots.map { it.aggregationEndAtUtc }) + assertEquals(listOf(1, 2, 3), latestSnapshots.map { it.rankNo }) } @Test - @DisplayName("최신 공개 스냅샷은 visibleFromAt이 현재 시각 이하인 최신 노출 시각 기준으로 조회한다") - fun shouldFindLatestVisibleSnapshotsByVisibleFromAt() { - val previousStartAt = LocalDateTime.of(2026, 5, 24, 15, 0) - val previousEndAt = LocalDateTime.of(2026, 5, 31, 15, 0) - val latestStartAt = LocalDateTime.of(2026, 5, 31, 15, 0) - val latestEndAt = LocalDateTime.of(2026, 6, 7, 15, 0) + @DisplayName("findLatestVisibleSnapshots는 현재 공개 가능한 최신 visible_from_at 스냅샷을 rank_no 오름차순으로 조회한다") + fun shouldFindLatestVisibleSnapshotsOrderedByRankNoAsc() { repository.saveAll( listOf( - snapshot( - creatorId = 1L, - aggregationStartAtUtc = previousStartAt, - aggregationEndAtUtc = previousEndAt, - visibleFromAtUtc = LocalDateTime.of(2026, 6, 1, 0, 0), - finalScore = 100.0 - ), - snapshot( - creatorId = 2L, - aggregationStartAtUtc = latestStartAt, - aggregationEndAtUtc = latestEndAt, - visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0), - finalScore = 200.0 + entity(creatorId = 1L, rankNo = 1, finalScore = 999.0, visibleFromAtUtc = VISIBLE.plusDays(1)), + entity(creatorId = 2L, rankNo = 3, finalScore = 300.0, visibleFromAtUtc = VISIBLE), + entity(creatorId = 3L, rankNo = 1, finalScore = 100.0, visibleFromAtUtc = VISIBLE), + entity(creatorId = 4L, rankNo = 2, finalScore = 500.0, visibleFromAtUtc = VISIBLE), + entity( + creatorId = 5L, + rankNo = 1, + finalScore = 700.0, + visibleFromAtUtc = VISIBLE.minusDays(1) ) ) ) - val beforeVisible = adapter.findLatestVisibleSnapshots( - rankingType = CreatorRankingType.WEEKLY, - nowUtc = LocalDateTime.of(2026, 6, 7, 23, 59, 59) - ) - val afterVisible = adapter.findLatestVisibleSnapshots( - rankingType = CreatorRankingType.WEEKLY, - nowUtc = LocalDateTime.of(2026, 6, 8, 0, 0) - ) + val latestVisible = adapter.findLatestVisibleSnapshots(CreatorRankingType.WEEKLY, NOW) - assertEquals(listOf(1L), beforeVisible.map { it.creatorId }) - assertEquals(listOf(2L), afterVisible.map { it.creatorId }) + assertEquals(listOf(3L, 4L, 2L), latestVisible.map { it.creatorId }) + assertEquals(listOf(1, 2, 3), latestVisible.map { it.rankNo }) } @Test - @DisplayName("직전 공개 스냅샷은 현재 공개 스냅샷보다 이전 집계 시작 시각 중 최신 공개 기준으로 조회한다") - fun shouldFindPreviousVisibleSnapshotsBeforeCurrentVisibleSnapshot() { - val oldestStartAt = LocalDateTime.of(2026, 5, 17, 15, 0) - val previousStartAt = LocalDateTime.of(2026, 5, 24, 15, 0) - val latestStartAt = LocalDateTime.of(2026, 5, 31, 15, 0) + @DisplayName("findPreviousVisibleSnapshots는 현재 집계 시작 전 공개 스냅샷을 rank_no 오름차순으로 조회한다") + fun shouldFindPreviousVisibleSnapshotsOrderedByRankNoAsc() { repository.saveAll( listOf( - snapshot(creatorId = 1L, aggregationStartAtUtc = oldestStartAt, aggregationEndAtUtc = oldestStartAt.plusWeeks(1)), - snapshot( + entity( + creatorId = 1L, + rankNo = 1, + aggregationStartAtUtc = OLDEST_START, + aggregationEndAtUtc = OLDEST_END, + visibleFromAtUtc = VISIBLE.minusWeeks(1) + ), + entity( creatorId = 2L, - aggregationStartAtUtc = previousStartAt, - aggregationEndAtUtc = previousStartAt.plusWeeks(1), - finalScore = 300.0 - ), - snapshot( - creatorId = 3L, - aggregationStartAtUtc = previousStartAt, - aggregationEndAtUtc = previousStartAt.plusWeeks(1), - finalScore = 200.0 - ), - snapshot(creatorId = 4L, aggregationStartAtUtc = latestStartAt, aggregationEndAtUtc = latestStartAt.plusWeeks(1)) - ) - ) - - val previous = adapter.findPreviousVisibleSnapshots( - rankingType = CreatorRankingType.WEEKLY, - currentAggregationStartAtUtc = latestStartAt, - nowUtc = LocalDateTime.of(2026, 6, 8, 0, 0) - ) - - assertEquals(listOf(2L, 3L), previous.map { it.creatorId }) - } - - @Test - @DisplayName("직전 완료 주차 스냅샷은 최신 종료 시각보다 이전인 가장 큰 종료 시각 기준으로 조회한다") - fun shouldFindPreviousCompletedSnapshotsBeforeLatestPeriod() { - val oldestStartAt = LocalDateTime.of(2026, 5, 17, 15, 0) - val oldestEndAt = LocalDateTime.of(2026, 5, 24, 15, 0) - val previousStartAt = LocalDateTime.of(2026, 5, 24, 15, 0) - val previousEndAt = LocalDateTime.of(2026, 5, 31, 15, 0) - val latestStartAt = LocalDateTime.of(2026, 5, 31, 15, 0) - val latestEndAt = LocalDateTime.of(2026, 6, 7, 15, 0) - repository.saveAll( - listOf( - snapshot(creatorId = 1L, aggregationStartAtUtc = oldestStartAt, aggregationEndAtUtc = oldestEndAt), - snapshot( - creatorId = 2L, - finalScore = 200.0, - aggregationStartAtUtc = previousStartAt, - aggregationEndAtUtc = previousEndAt - ), - snapshot( - creatorId = 3L, + rankNo = 3, finalScore = 300.0, - aggregationStartAtUtc = previousStartAt, - aggregationEndAtUtc = previousEndAt + aggregationStartAtUtc = PREVIOUS_START, + aggregationEndAtUtc = PREVIOUS_END, + visibleFromAtUtc = VISIBLE.minusDays(1) ), - snapshot(creatorId = 4L, aggregationStartAtUtc = latestStartAt, aggregationEndAtUtc = latestEndAt) + entity( + creatorId = 3L, + rankNo = 1, + finalScore = 100.0, + aggregationStartAtUtc = PREVIOUS_START, + aggregationEndAtUtc = PREVIOUS_END, + visibleFromAtUtc = VISIBLE.minusDays(1) + ), + entity( + creatorId = 4L, + rankNo = 2, + finalScore = 500.0, + aggregationStartAtUtc = PREVIOUS_START, + aggregationEndAtUtc = PREVIOUS_END, + visibleFromAtUtc = VISIBLE.minusDays(1) + ), + entity( + creatorId = 5L, + rankNo = 1, + aggregationStartAtUtc = PREVIOUS_START, + aggregationEndAtUtc = PREVIOUS_END, + visibleFromAtUtc = VISIBLE.plusDays(1) + ), + entity( + creatorId = 6L, + rankNo = 1, + aggregationStartAtUtc = START, + aggregationEndAtUtc = END, + visibleFromAtUtc = VISIBLE + ) ) ) - val previousSnapshots = adapter.findPreviousCompletedSnapshots() + val previousVisible = adapter.findPreviousVisibleSnapshots(CreatorRankingType.WEEKLY, START, NOW) - assertEquals(listOf(3L, 2L), previousSnapshots.map { it.creatorId }) - assertEquals(listOf(previousEndAt, previousEndAt), previousSnapshots.map { it.aggregationEndAtUtc }) + assertEquals(listOf(3L, 4L, 2L), previousVisible.map { it.creatorId }) + assertEquals(listOf(1, 2, 3), previousVisible.map { it.rankNo }) } @Test - @DisplayName("요청한 집계 기간에 스냅샷이 없으면 이전 주차를 대신 반환하지 않는다") - fun shouldReturnEmptyWhenRequestedAggregationPeriodHasNoSnapshots() { - val previousStartAt = LocalDateTime.of(2026, 5, 24, 15, 0) - val previousEndAt = LocalDateTime.of(2026, 5, 31, 15, 0) - val requestedStartAt = LocalDateTime.of(2026, 5, 31, 15, 0) - val requestedEndAt = LocalDateTime.of(2026, 6, 7, 15, 0) - repository.save( - snapshot( - creatorId = 1L, - aggregationStartAtUtc = previousStartAt, - aggregationEndAtUtc = previousEndAt + @DisplayName("findPreviousCompletedSnapshots는 최신 완료 전 집계 종료 시각 스냅샷을 rank_no 오름차순으로 조회한다") + fun shouldFindPreviousCompletedSnapshotsOrderedByRankNoAsc() { + repository.saveAll( + listOf( + entity(creatorId = 1L, rankNo = 1, aggregationStartAtUtc = OLDEST_START, aggregationEndAtUtc = OLDEST_END), + entity( + creatorId = 2L, + rankNo = 3, + finalScore = 300.0, + aggregationStartAtUtc = PREVIOUS_START, + aggregationEndAtUtc = PREVIOUS_END + ), + entity( + creatorId = 3L, + rankNo = 1, + finalScore = 100.0, + aggregationStartAtUtc = PREVIOUS_START, + aggregationEndAtUtc = PREVIOUS_END + ), + entity( + creatorId = 4L, + rankNo = 2, + finalScore = 500.0, + aggregationStartAtUtc = PREVIOUS_START, + aggregationEndAtUtc = PREVIOUS_END + ), + entity(creatorId = 5L, rankNo = 1, aggregationStartAtUtc = START, aggregationEndAtUtc = END) ) ) - val snapshots = adapter.findSnapshotsByAggregationPeriod( - aggregationStartAtUtc = requestedStartAt, - aggregationEndAtUtc = requestedEndAt - ) + val previousCompleted = adapter.findPreviousCompletedSnapshots() - assertEquals(emptyList(), snapshots) + assertEquals(listOf(3L, 4L, 2L), previousCompleted.map { it.creatorId }) + assertEquals(listOf(1, 2, 3), previousCompleted.map { it.rankNo }) } @Test - @DisplayName("스냅샷 row가 하나도 없을 때만 테이블 완전 공백으로 판단한다") - fun shouldReturnTrueOnlyWhenSnapshotTableHasNoRows() { - assertEquals(true, adapter.isSnapshotTableEmpty()) - - repository.save( - snapshot( - creatorId = 1L, - aggregationStartAtUtc = LocalDateTime.of(2026, 5, 24, 15, 0), - aggregationEndAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0) + @DisplayName("findSnapshotsByAggregationPeriod는 요청 기간 스냅샷을 rank_no 오름차순으로 조회한다") + fun shouldFindSnapshotsByAggregationPeriodOrderedByRankNoAsc() { + repository.saveAll( + listOf( + entity( + creatorId = 1L, + rankNo = 1, + finalScore = 999.0, + aggregationStartAtUtc = PREVIOUS_START, + aggregationEndAtUtc = PREVIOUS_END + ), + entity(creatorId = 2L, rankNo = 3, finalScore = 300.0), + entity(creatorId = 3L, rankNo = 1, finalScore = 100.0), + entity(creatorId = 4L, rankNo = 2, finalScore = 500.0) ) ) - assertEquals(false, adapter.isSnapshotTableEmpty()) + val periodSnapshots = adapter.findSnapshotsByAggregationPeriod(START, END) + + assertEquals(listOf(3L, 4L, 2L), periodSnapshots.map { it.creatorId }) + assertEquals(listOf(1, 2, 3), periodSnapshots.map { it.rankNo }) } @Test - @DisplayName("20위 점수 경계 동점 후보는 저장소에서 누락 없이 저장하고 조회할 수 있다") - fun shouldPersistAllCandidatesTiedAtTwentiethScoreBoundary() { - val startAt = LocalDateTime.of(2026, 5, 31, 15, 0) - val endAt = LocalDateTime.of(2026, 6, 7, 15, 0) - val candidates = (1L..19L).map { creatorId -> - snapshotRecord( - creatorId = creatorId, - finalScore = 1000.0 - creatorId, - aggregationStartAtUtc = startAt, - aggregationEndAtUtc = endAt + @DisplayName("replaceSnapshots는 같은 rankingType과 집계 기간만 삭제하고 다른 기간은 유지한다") + fun shouldReplaceOnlySameTypeAndAggregationPeriod() { + repository.saveAll( + listOf( + entity( + creatorId = 1L, + rankNo = 1, + rankingType = CreatorRankingType.WEEKLY, + aggregationStartAtUtc = START, + aggregationEndAtUtc = END + ), + entity( + creatorId = 2L, + rankNo = 1, + rankingType = CreatorRankingType.WEEKLY, + aggregationStartAtUtc = PREVIOUS_START, + aggregationEndAtUtc = PREVIOUS_END + ), + entity( + creatorId = 3L, + rankNo = 1, + rankingType = CreatorRankingType.WEEKLY, + aggregationStartAtUtc = OLDEST_START, + aggregationEndAtUtc = OLDEST_END + ) ) - } + listOf( - snapshotRecord(creatorId = 20L, finalScore = 500.0, aggregationStartAtUtc = startAt, aggregationEndAtUtc = endAt), - snapshotRecord(creatorId = 21L, finalScore = 500.0, aggregationStartAtUtc = startAt, aggregationEndAtUtc = endAt), - snapshotRecord(creatorId = 22L, finalScore = 500.0, aggregationStartAtUtc = startAt, aggregationEndAtUtc = endAt) ) adapter.replaceSnapshots( rankingType = CreatorRankingType.WEEKLY, - aggregationStartAtUtc = startAt, - aggregationEndAtUtc = endAt, - visibleFromAtUtc = endAt.plusHours(9), - newSnapshots = candidates + aggregationStartAtUtc = START, + aggregationEndAtUtc = END, + visibleFromAtUtc = VISIBLE, + newSnapshots = listOf(record(creatorId = 4L, rankNo = 1)) ) - val latestSnapshots = adapter.findLatestSnapshots() - assertEquals(22, latestSnapshots.size) - assertEquals(setOf(20L, 21L, 22L), latestSnapshots.takeLast(3).map { it.creatorId }.toSet()) + assertEquals(listOf(2L, 3L, 4L), repository.findAll().map { it.creatorId }.sorted()) } - private fun snapshot( + private fun record( creatorId: Long, - finalScore: Double = 100.0, - aggregationStartAtUtc: LocalDateTime, - aggregationEndAtUtc: LocalDateTime, - visibleFromAtUtc: LocalDateTime = aggregationEndAtUtc.plusHours(9) - ): CreatorRankingSnapshot { - return CreatorRankingSnapshot( - rankingType = CreatorRankingType.WEEKLY, - aggregationStartAtUtc = aggregationStartAtUtc, - aggregationEndAtUtc = aggregationEndAtUtc, - visibleFromAtUtc = visibleFromAtUtc, - creatorId = creatorId, - nickname = "creator-$creatorId", - profileImageUrl = "profile-$creatorId.png", - finalScore = finalScore, - contentLiveScore = 10.0, - engagementScore = 20.0, - supportScore = 30.0, - fanLoyaltyScore = 40.0, - liveCanAmount = 100, - contentPurchaseCanAmount = 200, - contentLikeCount = 3, - contentCommentCount = 4, - channelDonationCanAmount = 500, - channelDonationCount = 6, - fanTalkCount = 7, - finalFollowerCount = 8, - followIncrease = -1 - ) - } - - private fun snapshotRecord( - creatorId: Long, - finalScore: Double = 100.0, - aggregationStartAtUtc: LocalDateTime, - aggregationEndAtUtc: LocalDateTime, - visibleFromAtUtc: LocalDateTime = aggregationEndAtUtc.plusHours(9) + rankNo: Int, + finalScore: Double = (100 - rankNo).toDouble(), + rankingType: CreatorRankingType = CreatorRankingType.WEEKLY, + aggregationStartAtUtc: LocalDateTime = START, + aggregationEndAtUtc: LocalDateTime = END, + visibleFromAtUtc: LocalDateTime = VISIBLE, + scorePolicyVersion: String = CreatorRankingScoreSpec.SCORE_POLICY_VERSION, + scoreDetailJson: String = "{}" ): CreatorRankingSnapshotRecord { return CreatorRankingSnapshotRecord( - rankingType = CreatorRankingType.WEEKLY, + rankingType = rankingType, aggregationStartAtUtc = aggregationStartAtUtc, aggregationEndAtUtc = aggregationEndAtUtc, visibleFromAtUtc = visibleFromAtUtc, creatorId = creatorId, nickname = "creator-$creatorId", profileImageUrl = "profile-$creatorId.png", + rankNo = rankNo, finalScore = finalScore, - contentLiveScore = 10.0, - engagementScore = 20.0, - supportScore = 30.0, - fanLoyaltyScore = 40.0, - liveCanAmount = 100, - contentPurchaseCanAmount = 200, - contentLikeCount = 3, - contentCommentCount = 4, - channelDonationCanAmount = 500, - channelDonationCount = 6, - fanTalkCount = 7, - finalFollowerCount = 8, - followIncrease = -1 + scorePolicyVersion = scorePolicyVersion, + scoreDetailJson = scoreDetailJson ) } + + private fun entity( + creatorId: Long, + rankNo: Int, + finalScore: Double = (100 - rankNo).toDouble(), + rankingType: CreatorRankingType = CreatorRankingType.WEEKLY, + aggregationStartAtUtc: LocalDateTime = START, + aggregationEndAtUtc: LocalDateTime = END, + visibleFromAtUtc: LocalDateTime = VISIBLE, + scorePolicyVersion: String = CreatorRankingScoreSpec.SCORE_POLICY_VERSION, + scoreDetailJson: String = "{}" + ): CreatorRankingSnapshot { + return CreatorRankingSnapshot( + rankingType = rankingType, + aggregationStartAtUtc = aggregationStartAtUtc, + aggregationEndAtUtc = aggregationEndAtUtc, + visibleFromAtUtc = visibleFromAtUtc, + creatorId = creatorId, + nickname = "creator-$creatorId", + profileImageUrl = "profile-$creatorId.png", + rankNo = rankNo, + finalScore = finalScore, + scorePolicyVersion = scorePolicyVersion, + scoreDetailJson = scoreDetailJson + ) + } + + companion object { + private val OLDEST_START = LocalDateTime.of(2026, 5, 17, 15, 0) + private val OLDEST_END = LocalDateTime.of(2026, 5, 24, 15, 0) + private val PREVIOUS_START = LocalDateTime.of(2026, 5, 24, 15, 0) + private val PREVIOUS_END = LocalDateTime.of(2026, 5, 31, 15, 0) + private val START = LocalDateTime.of(2026, 5, 31, 15, 0) + private val END = LocalDateTime.of(2026, 6, 7, 15, 0) + private val VISIBLE = LocalDateTime.of(2026, 6, 8, 0, 0) + private val NOW = LocalDateTime.of(2026, 6, 8, 1, 0) + } }