From 263202f0f59aa4173119664a82de91c4ff71bca5 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 10 Jul 2026 14:45:42 +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=A1=B0=ED=9A=8C=20?= =?UTF-8?q?fallback=EC=9D=84=20=EC=9E=AC=EC=A1=B0=ED=9A=8C=EB=A1=9C=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 --- .../application/CreatorRankingQueryService.kt | 170 +----- .../CreatorRankingQueryServiceTest.kt | 554 ++---------------- 2 files changed, 75 insertions(+), 649 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt index 09ea36d5..986df8a8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt @@ -2,19 +2,13 @@ package kr.co.vividnext.sodalive.v2.ranking.application import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingItem -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.CreatorRankingType -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.CreatorRankingBlockPort import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotPort import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotRecord import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional import java.time.LocalDateTime import java.time.ZoneId import java.time.ZonedDateTime @@ -23,42 +17,21 @@ import java.time.ZonedDateTime class CreatorRankingQueryService( private val snapshotPort: CreatorRankingSnapshotPort, private val blockPort: CreatorRankingBlockPort, - private val aggregationPort: CreatorRankingAggregationPort, private val snapshotJobService: CreatorRankingSnapshotJobService, private val nowProvider: () -> ZonedDateTime = { ZonedDateTime.now() }, @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String ) { private val log = LoggerFactory.getLogger(javaClass) - private val periodPolicy = CreatorRankingPeriodPolicy() - private val scorePolicy = CreatorRankingScorePolicy() - @Transactional(readOnly = true) fun getCreatorRankings(viewerMemberId: Long?): CreatorRankingResult { val startedAt = System.currentTimeMillis() return runCatching { val nowUtc = nowUtc() - val latestSnapshots = snapshotPort.findLatestVisibleSnapshots(CreatorRankingType.WEEKLY, nowUtc) + val latestSnapshots = findLatestVisibleSnapshots(nowUtc) val latestItems = latestSnapshots.toRankedItems() if (latestItems.isEmpty()) { - if (snapshotPort.isSnapshotTableEmpty()) { - val fallbackItems = aggregateColdStartFallback(nowUtc).toRankedItems() - if (fallbackItems.isNotEmpty()) { - delegateColdStartSnapshotRefresh() - } - val blockedCreatorIds = findBlockedCreatorIds(viewerMemberId = viewerMemberId, items = fallbackItems) - return@runCatching QueryLogResult( - result = CreatorRankingResult( - showRankChange = false, - items = fallbackItems.map { it.maskIfBlocked(blockedCreatorIds) } - ), - blockedCreatorCount = blockedCreatorIds.size - ) - } - return@runCatching QueryLogResult( - result = CreatorRankingResult(showRankChange = false, items = emptyList()), - blockedCreatorCount = 0 - ) + return@runCatching QueryLogResult(CreatorRankingResult(showRankChange = false, items = emptyList()), 0) } val previousItems = snapshotPort.findPreviousVisibleSnapshots( @@ -77,10 +50,7 @@ class CreatorRankingQueryService( ).maskIfBlocked(blockedCreatorIds) } - QueryLogResult( - result = CreatorRankingResult(showRankChange = showRankChange, items = items), - blockedCreatorCount = blockedCreatorIds.size - ) + QueryLogResult(CreatorRankingResult(showRankChange = showRankChange, items = items), blockedCreatorIds.size) }.onSuccess { logResult -> log.info( "event=creator_ranking_query_success showRankChange={} itemCount={} blockedCreatorCount={} elapsedMs={}", @@ -99,75 +69,30 @@ class CreatorRankingQueryService( }.getOrThrow().result } + private fun findLatestVisibleSnapshots(nowUtc: LocalDateTime): List { + val latestSnapshots = snapshotPort.findLatestVisibleSnapshots(CreatorRankingType.WEEKLY, nowUtc) + if (latestSnapshots.isNotEmpty()) return latestSnapshots + + runCatching { snapshotJobService.refreshLastCompletedWeekByFallback() } + .onFailure { ex -> + log.warn("event=creator_ranking_query_fallback_failure error={}", ex.message, ex) + } + return snapshotPort.findLatestVisibleSnapshots(CreatorRankingType.WEEKLY, nowUtc) + } + private data class QueryLogResult( val result: CreatorRankingResult, val blockedCreatorCount: Int ) - private fun aggregateColdStartFallback(nowUtc: LocalDateTime): List { - val startedAt = System.currentTimeMillis() - val period = periodPolicy.resolveLastCompletedWeek(nowProvider()) - val utcRange = periodPolicy.toUtcRange(period) - val visibleFromAtUtc = periodPolicy.resolveVisibleFromAtUtc(period.endExclusiveKst) - if (visibleFromAtUtc > nowUtc) { - return emptyList() - } - log.info( - "event=creator_ranking_query_cold_start_fallback_attempt " + - "aggregationStartAtUtc={} aggregationEndAtUtc={}", - utcRange.startInclusiveUtc, - utcRange.endExclusiveUtc - ) - return runCatching { - aggregationPort.aggregateCandidates( - startInclusiveUtc = utcRange.startInclusiveUtc, - endExclusiveUtc = utcRange.endExclusiveUtc - ).map { it.toSnapshotRecord(utcRange) } - }.onSuccess { snapshots -> - log.info( - "event=creator_ranking_query_cold_start_fallback_success " + - "aggregationStartAtUtc={} aggregationEndAtUtc={} itemCount={} elapsedMs={}", - utcRange.startInclusiveUtc, - utcRange.endExclusiveUtc, - snapshots.size.coerceAtMost(RANKING_LIMIT), - System.currentTimeMillis() - startedAt - ) - }.onFailure { ex -> - log.warn( - "event=creator_ranking_query_cold_start_fallback_failure " + - "aggregationStartAtUtc={} aggregationEndAtUtc={} elapsedMs={} error={}", - utcRange.startInclusiveUtc, - utcRange.endExclusiveUtc, - System.currentTimeMillis() - startedAt, - ex.message, - ex - ) - }.getOrThrow() - } - - private fun delegateColdStartSnapshotRefresh() { - runCatching { - snapshotJobService.ensureLastCompletedWeekSnapshotForColdStart() - }.onFailure { ex -> - log.warn( - "event=creator_ranking_query_cold_start_snapshot_refresh_failure error={}", - ex.message, - ex - ) - } - } - private fun nowUtc(): LocalDateTime { return nowProvider().withZoneSameInstant(UTC_ZONE).toLocalDateTime() } private fun List.toRankedItems(): List { - return groupBy { it.finalScore } - .toSortedMap(compareByDescending { it }) - .values - .flatMap { it.shuffled() } + return sortedBy { it.rankNo } .take(RANKING_LIMIT) - .mapIndexed { index, snapshot -> snapshot.toItem(rank = index + 1) } + .map { snapshot -> snapshot.toItem(rank = snapshot.rankNo) } } private fun CreatorRankingSnapshotRecord.toItem(rank: Int): CreatorRankingItem { @@ -181,70 +106,13 @@ class CreatorRankingQueryService( ) } - 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 findBlockedCreatorIds(viewerMemberId: Long?, items: List): Set { - if (viewerMemberId == null) { - return emptySet() - } - return blockPort.findBlockedCreatorIds( - memberId = viewerMemberId, - creatorIds = items.map { it.creatorId } - ) + if (viewerMemberId == null) return emptySet() + return blockPort.findBlockedCreatorIds(memberId = viewerMemberId, creatorIds = items.map { it.creatorId }) } private fun CreatorRankingItem.maskIfBlocked(blockedCreatorIds: Set): CreatorRankingItem { - if (!blockedCreatorIds.contains(creatorId)) { - return this - } + if (!blockedCreatorIds.contains(creatorId)) return this return copy( creatorId = MASKED_CREATOR_ID, nickname = MASKED_NICKNAME, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt index f1cec7fb..0f06c6ee 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt @@ -1,568 +1,152 @@ package kr.co.vividnext.sodalive.v2.ranking.application -import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingItem -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.port.out.CreatorRankingAggregationPort import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingBlockPort 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.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertNull -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.Test -import org.junit.jupiter.api.extension.ExtendWith import org.mockito.Mockito -import org.springframework.boot.test.system.CapturedOutput -import org.springframework.boot.test.system.OutputCaptureExtension +import org.springframework.transaction.annotation.Transactional import java.time.LocalDateTime import java.time.ZoneId import java.time.ZonedDateTime -@ExtendWith(OutputCaptureExtension::class) class CreatorRankingQueryServiceTest { @Test - @DisplayName("스냅샷 후보와 조회 item 내부 모델은 순위 변화와 신규 진입 값을 담을 수 있다") - fun shouldCreateRankingDomainModelsForLaterQueryService() { - val candidate = CreatorRankingSnapshotCandidate( - creatorId = 1L, - nickname = "creator", - profileImageUrl = "profile.png", - finalScore = 100.0, - 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 - ) - val item = CreatorRankingItem( - rank = 1, - rankChange = null, - isNew = true, - creatorId = candidate.creatorId, - nickname = candidate.nickname, - profileImageUrl = candidate.profileImageUrl - ) - val fallenItem = item.copy(rank = 2, rankChange = -1, isNew = false) + @DisplayName("fallback refresh 후 재조회가 같은 read-only transaction에 묶이지 않도록 조회 메서드에는 Transactional이 없다") + fun shouldNotWrapQueryMethodWithTransactional() { + val method = CreatorRankingQueryService::class.java.getDeclaredMethod("getCreatorRankings", java.lang.Long::class.java) - assertEquals(1L, candidate.creatorId) - assertEquals(100.0, candidate.finalScore, 0.0001) - assertNull(item.rankChange) - assertTrue(item.isNew) - assertEquals(-1, fallenItem.rankChange) - assertFalse(fallenItem.isNew) + assertFalse(method.isAnnotationPresent(Transactional::class.java)) } @Test - @DisplayName("최신 완료 주차 스냅샷이 없으면 순위 변화 비노출과 빈 목록을 반환한다") - fun shouldReturnEmptyResultWhenLatestSnapshotsDoNotExist() { + @DisplayName("최신 공개 스냅샷이 없으면 fallback refresh 후 재조회한 스냅샷을 응답한다") + fun shouldRefreshByFallbackAndRequeryWhenLatestSnapshotsEmpty() { val snapshotPort = FakeCreatorRankingQuerySnapshotPort() - val service = service(snapshotPort = snapshotPort) + val jobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java) + snapshotPort.latestResponses = ArrayDeque(listOf(emptyList(), listOf(snapshot(1, rankNo = 1)))) + val service = service(snapshotPort = snapshotPort, snapshotJobService = jobService) val result = service.getCreatorRankings(viewerMemberId = null) + Mockito.verify(jobService).refreshLastCompletedWeekByFallback() + assertEquals(2, snapshotPort.latestCallCount) assertFalse(result.showRankChange) - assertTrue(result.items.isEmpty()) - } - - @Test - @DisplayName("최신 스냅샷이 있으면 cold-start fallback 집계를 호출하지 않는다") - fun shouldNotUseColdStartFallbackWhenLatestSnapshotsExist() { - val snapshotPort = FakeCreatorRankingQuerySnapshotPort() - val aggregationPort = FakeCreatorRankingQueryAggregationPort() - snapshotPort.latestSnapshots = listOf(snapshot(creatorId = 1L, finalScore = 100.0)) - snapshotPort.snapshotTableEmpty = true - aggregationPort.candidates = listOf(candidate(creatorId = 2L)) - val service = service(snapshotPort = snapshotPort, aggregationPort = aggregationPort) - - val result = service.getCreatorRankings(viewerMemberId = null) - assertEquals(listOf(1L), result.items.map { it.creatorId }) - assertEquals(0, aggregationPort.aggregateCallCount) } @Test - @DisplayName("최신 스냅샷이 없고 스냅샷 테이블이 완전히 비어 있으면 cold-start fallback을 반환한다") - fun shouldUseColdStartFallbackOnlyWhenSnapshotTableIsEmpty() { + @DisplayName("fallback 실패 또는 재조회 결과 없음은 빈 목록 성공으로 반환한다") + fun shouldReturnEmptyWhenFallbackFailsAndRequeryStillEmpty() { val snapshotPort = FakeCreatorRankingQuerySnapshotPort() - val aggregationPort = FakeCreatorRankingQueryAggregationPort() - val snapshotJobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java) - snapshotPort.snapshotTableEmpty = true - aggregationPort.candidates = listOf( - candidate(creatorId = 1L, liveCanAmount = 100), - candidate(creatorId = 2L, liveCanAmount = 200) - ) - val service = service( - snapshotPort = snapshotPort, - aggregationPort = aggregationPort, - snapshotJobService = snapshotJobService - ) - - val result = service.getCreatorRankings(viewerMemberId = null) - - assertFalse(result.showRankChange) - assertEquals(listOf(2L, 1L), result.items.map { it.creatorId }) - assertEquals(listOf(1, 2), result.items.map { it.rank }) - assertTrue(result.items.all { it.rankChange == null }) - assertTrue(result.items.none { it.isNew }) - assertEquals(1, aggregationPort.aggregateCallCount) - assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0), aggregationPort.startInclusiveUtc) - assertEquals(LocalDateTime.of(2026, 6, 7, 15, 0), aggregationPort.endExclusiveUtc) - Mockito.verify(snapshotJobService).ensureLastCompletedWeekSnapshotForColdStart() - } - - @Test - @DisplayName("cold-start fallback 후보가 없으면 스냅샷 생성 위임을 호출하지 않는다") - fun shouldNotDelegateColdStartSnapshotRefreshWhenFallbackIsEmpty() { - val snapshotPort = FakeCreatorRankingQuerySnapshotPort() - val aggregationPort = FakeCreatorRankingQueryAggregationPort() - val snapshotJobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java) - snapshotPort.snapshotTableEmpty = true - val service = service( - snapshotPort = snapshotPort, - aggregationPort = aggregationPort, - snapshotJobService = snapshotJobService - ) + val jobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java) + Mockito.doThrow(IllegalStateException("fallback failed")).`when`(jobService).refreshLastCompletedWeekByFallback() + val service = service(snapshotPort = snapshotPort, snapshotJobService = jobService) val result = service.getCreatorRankings(viewerMemberId = null) assertFalse(result.showRankChange) assertTrue(result.items.isEmpty()) - Mockito.verify(snapshotJobService, Mockito.never()).ensureLastCompletedWeekSnapshotForColdStart() + assertEquals(2, snapshotPort.latestCallCount) } @Test - @DisplayName("최신 스냅샷이 없어도 과거 스냅샷 row가 있으면 cold-start fallback을 호출하지 않는다") - fun shouldNotUseColdStartFallbackWhenAnyHistoricalSnapshotExists() { + @DisplayName("최신 공개 스냅샷이 있으면 fallback 없이 rankNo 기준 rank/rankChange/isNew를 계산한다") + fun shouldUseRankNoForRankChangeWithoutFallback() { val snapshotPort = FakeCreatorRankingQuerySnapshotPort() - val aggregationPort = FakeCreatorRankingQueryAggregationPort() - val snapshotJobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java) - snapshotPort.snapshotTableEmpty = false - aggregationPort.candidates = listOf(candidate(creatorId = 1L)) - val service = service( - snapshotPort = snapshotPort, - aggregationPort = aggregationPort, - snapshotJobService = snapshotJobService + val jobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java) + snapshotPort.latestResponses = ArrayDeque( + listOf(listOf(snapshot(2, rankNo = 1), snapshot(1, rankNo = 2), snapshot(3, rankNo = 3))) ) + snapshotPort.previousSnapshots = listOf(snapshot(1, rankNo = 1), snapshot(2, rankNo = 2)) + val service = service(snapshotPort = snapshotPort, snapshotJobService = jobService) val result = service.getCreatorRankings(viewerMemberId = null) - assertFalse(result.showRankChange) - assertTrue(result.items.isEmpty()) - assertEquals(0, aggregationPort.aggregateCallCount) - Mockito.verify(snapshotJobService, Mockito.never()).ensureLastCompletedWeekSnapshotForColdStart() - } - - @Test - @DisplayName("cold-start fallback도 차단 관계가 있으면 크리에이터 식별 정보만 마스킹한다") - fun shouldMaskBlockedCreatorIdentityInColdStartFallback() { - val snapshotPort = FakeCreatorRankingQuerySnapshotPort() - val aggregationPort = FakeCreatorRankingQueryAggregationPort() - val blockPort = FakeCreatorRankingBlockPort() - snapshotPort.snapshotTableEmpty = true - aggregationPort.candidates = listOf( - candidate(creatorId = 1L, liveCanAmount = 200), - candidate(creatorId = 2L, liveCanAmount = 100) - ) - blockPort.blockedCreatorIds = setOf(1L) - val service = service( - snapshotPort = snapshotPort, - blockPort = blockPort, - aggregationPort = aggregationPort - ) - - val result = service.getCreatorRankings(viewerMemberId = 99L) - - assertEquals(99L, blockPort.memberId) - assertEquals(setOf(1L, 2L), blockPort.creatorIds) - assertEquals(0L, result.items.first().creatorId) - assertEquals("", result.items.first().nickname) - assertEquals("https://cdn.test/profile/default-profile.png", result.items.first().profileImageUrl) - assertEquals(2L, result.items[1].creatorId) - } - - @Test - @DisplayName("직전 완료 주차 스냅샷이 없으면 순위 변화 없이 최신 스냅샷 상위 20명을 반환한다") - fun shouldReturnLatestTopTwentyWithoutRankChangeWhenPreviousSnapshotsDoNotExist() { - val snapshotPort = FakeCreatorRankingQuerySnapshotPort() - snapshotPort.latestSnapshots = (1L..21L).map { creatorId -> - snapshot(creatorId = creatorId, finalScore = (100 - creatorId).toDouble()) - } - val service = service(snapshotPort = snapshotPort) - - val result = service.getCreatorRankings(viewerMemberId = null) - - assertFalse(result.showRankChange) - assertEquals(20, result.items.size) - assertEquals((1..20).toList(), result.items.map { it.rank }) - assertEquals((1L..20L).toList(), result.items.map { it.creatorId }) - assertTrue(result.items.all { it.rankChange == null }) - assertTrue(result.items.none { it.isNew }) - } - - @Test - @DisplayName("직전 완료 주차 스냅샷이 있으면 현재 순위와 비교해 순위 변화와 신규 진입을 계산한다") - fun shouldCalculateRankChangeAndNewEntryFromPreviousSnapshots() { - val snapshotPort = FakeCreatorRankingQuerySnapshotPort() - snapshotPort.latestSnapshots = listOf( - snapshot(creatorId = 2L, finalScore = 300.0), - snapshot(creatorId = 1L, finalScore = 200.0), - snapshot(creatorId = 3L, finalScore = 100.0), - snapshot(creatorId = 4L, finalScore = 50.0) - ) - snapshotPort.previousSnapshots = listOf( - snapshot(creatorId = 1L, finalScore = 400.0), - snapshot(creatorId = 2L, finalScore = 300.0), - snapshot(creatorId = 3L, finalScore = 100.0) - ) - val service = service(snapshotPort = snapshotPort) - - val result = service.getCreatorRankings(viewerMemberId = null) - + Mockito.verify(jobService, Mockito.never()).refreshLastCompletedWeekByFallback() assertTrue(result.showRankChange) - assertEquals(listOf(2L, 1L, 3L, 4L), result.items.map { it.creatorId }) - assertEquals(listOf(1, 2, 3, 4), result.items.map { it.rank }) - assertEquals(listOf(1, -1, 0, null), result.items.map { it.rankChange }) - assertEquals(listOf(false, false, false, true), result.items.map { it.isNew }) + assertEquals(listOf(2L, 1L, 3L), result.items.map { it.creatorId }) + assertEquals(listOf(1, 2, 3), result.items.map { it.rank }) + assertEquals(listOf(1, -1, null), result.items.map { it.rankChange }) + assertEquals(listOf(false, false, true), result.items.map { it.isNew }) } @Test - @DisplayName("조회 서비스는 현재 UTC 시각 기준 최신 공개 스냅샷과 직전 공개 스냅샷으로 순위 변화를 계산한다") - fun shouldUseLatestVisibleSnapshotsAndPreviousVisibleSnapshots() { - val snapshotPort = FakeCreatorRankingQuerySnapshotPort() - val now = ZonedDateTime.of(2026, 6, 8, 9, 0, 0, 0, ZoneId.of("Asia/Seoul")) - snapshotPort.latestSnapshots = listOf( - snapshot(creatorId = 2L, finalScore = 300.0), - snapshot(creatorId = 1L, finalScore = 200.0) - ) - snapshotPort.previousSnapshots = listOf( - snapshot(creatorId = 1L, finalScore = 400.0), - snapshot(creatorId = 2L, finalScore = 100.0) - ) - val service = service(snapshotPort = snapshotPort, now = now) - - val result = service.getCreatorRankings(viewerMemberId = null) - - assertEquals(CreatorRankingType.WEEKLY, snapshotPort.latestRankingType) - assertEquals(LocalDateTime.of(2026, 6, 8, 0, 0), snapshotPort.latestNowUtc) - assertEquals(CreatorRankingType.WEEKLY, snapshotPort.previousRankingType) - assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0), snapshotPort.previousCurrentAggregationStartAtUtc) - assertEquals(LocalDateTime.of(2026, 6, 8, 0, 0), snapshotPort.previousNowUtc) - assertEquals(listOf(1, -1), result.items.map { it.rankChange }) - } - - @Test - @DisplayName("cold-start fallback은 공개 노출 시각 전이면 원천 집계와 스냅샷 생성 위임을 실행하지 않는다") - fun shouldNotUseColdStartFallbackBeforeVisibleFromAt() { - val snapshotPort = FakeCreatorRankingQuerySnapshotPort() - val aggregationPort = FakeCreatorRankingQueryAggregationPort() - val snapshotJobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java) - snapshotPort.snapshotTableEmpty = true - aggregationPort.candidates = listOf(candidate(creatorId = 1L)) - val service = service( - snapshotPort = snapshotPort, - aggregationPort = aggregationPort, - snapshotJobService = snapshotJobService, - now = ZonedDateTime.of(2026, 6, 8, 8, 59, 59, 0, ZoneId.of("Asia/Seoul")) - ) - - val result = service.getCreatorRankings(viewerMemberId = null) - - assertFalse(result.showRankChange) - assertTrue(result.items.isEmpty()) - assertEquals(0, aggregationPort.aggregateCallCount) - Mockito.verify(snapshotJobService, Mockito.never()).ensureLastCompletedWeekSnapshotForColdStart() - } - - @Test - @DisplayName("동점 스냅샷은 같은 점수 구간 안에서만 섞이고 상위 20명만 반환한다") - fun shouldRandomizeOnlyWithinTieGroupsAndLimitToTwentyItems() { - val snapshotPort = FakeCreatorRankingQuerySnapshotPort() - snapshotPort.latestSnapshots = listOf( - snapshot(creatorId = 1L, finalScore = 300.0), - snapshot(creatorId = 2L, finalScore = 200.0), - snapshot(creatorId = 3L, finalScore = 200.0) - ) + (4L..22L).map { creatorId -> - snapshot(creatorId = creatorId, finalScore = (100 - creatorId).toDouble()) - } - val service = service(snapshotPort = snapshotPort) - - val result = service.getCreatorRankings(viewerMemberId = null) - - assertEquals(20, result.items.size) - assertEquals(1L, result.items.first().creatorId) - assertEquals(setOf(2L, 3L), result.items.drop(1).take(2).map { it.creatorId }.toSet()) - assertEquals((1..20).toList(), result.items.map { it.rank }) - } - - @Test - @DisplayName("차단 관계가 있으면 순위 row는 유지하고 크리에이터 식별 정보만 마스킹한다") - fun shouldMaskBlockedCreatorIdentityWithoutRemovingRankingRow() { + @DisplayName("차단 관계가 있으면 row를 제거하지 않고 식별 정보만 마스킹한다") + fun shouldMaskBlockedCreatorWithoutRemovingRow() { val snapshotPort = FakeCreatorRankingQuerySnapshotPort() val blockPort = FakeCreatorRankingBlockPort() - snapshotPort.latestSnapshots = listOf( - snapshot(creatorId = 1L, finalScore = 300.0), - snapshot(creatorId = 2L, finalScore = 200.0) - ) - snapshotPort.previousSnapshots = listOf( - snapshot(creatorId = 1L, finalScore = 300.0), - snapshot(creatorId = 2L, finalScore = 200.0) - ) + snapshotPort.latestResponses = ArrayDeque(listOf(listOf(snapshot(1, rankNo = 1), snapshot(2, rankNo = 2)))) + snapshotPort.previousSnapshots = listOf(snapshot(1, rankNo = 1), snapshot(2, rankNo = 2)) blockPort.blockedCreatorIds = setOf(2L) val service = service(snapshotPort = snapshotPort, blockPort = blockPort) val result = service.getCreatorRankings(viewerMemberId = 99L) assertEquals(listOf(1, 2), result.items.map { it.rank }) - assertEquals(99L, blockPort.memberId) - assertEquals(setOf(1L, 2L), blockPort.creatorIds) - assertEquals(2, result.items.size) assertEquals(0L, result.items[1].creatorId) assertEquals("", result.items[1].nickname) assertEquals("https://cdn.test/profile/default-profile.png", result.items[1].profileImageUrl) - assertEquals(0, result.items[1].rankChange) - assertFalse(result.items[1].isNew) - } - - @Test - @DisplayName("비회원 조회는 차단 관계를 조회하지 않고 원본 랭킹을 반환한다") - fun shouldNotLookupBlocksForAnonymousViewer() { - val snapshotPort = FakeCreatorRankingQuerySnapshotPort() - val blockPort = FakeCreatorRankingBlockPort() - snapshotPort.latestSnapshots = listOf(snapshot(creatorId = 1L, finalScore = 100.0)) - val service = service(snapshotPort = snapshotPort, blockPort = blockPort) - - val result = service.getCreatorRankings(viewerMemberId = null) - - assertNull(blockPort.memberId) - assertEquals(1L, result.items.single().creatorId) - assertEquals("creator-1", result.items.single().nickname) - assertEquals("https://cdn.test/profile-1.png", result.items.single().profileImageUrl) - } - - @Test - @DisplayName("크리에이터 랭킹 조회 성공은 순위 변화 노출 여부와 반환 수를 로그로 남긴다") - fun shouldLogCreatorRankingQuerySuccessWithResultCounts(output: CapturedOutput) { - val snapshotPort = FakeCreatorRankingQuerySnapshotPort() - snapshotPort.latestSnapshots = listOf(snapshot(creatorId = 1L, finalScore = 100.0)) - val service = service(snapshotPort = snapshotPort) - - service.getCreatorRankings(viewerMemberId = null) - - assertTrue(output.out.contains("event=creator_ranking_query_success")) - assertTrue(output.out.contains("showRankChange=false")) - assertTrue(output.out.contains("itemCount=1")) - assertTrue(output.out.contains("blockedCreatorCount=0")) - } - - @Test - @DisplayName("크리에이터 랭킹 조회 실패는 에러를 로그로 남기고 예외를 전파한다") - fun shouldLogCreatorRankingQueryFailureWithError(output: CapturedOutput) { - val snapshotPort = FakeCreatorRankingQuerySnapshotPort() - snapshotPort.latestFailure = IllegalStateException("latest snapshots failed") - val service = service(snapshotPort = snapshotPort) - - val exception = assertThrows(IllegalStateException::class.java) { - service.getCreatorRankings(viewerMemberId = 99L) - } - - assertEquals("latest snapshots failed", exception.message) - assertTrue(output.out.contains("event=creator_ranking_query_failure")) - assertTrue(output.out.contains("error=latest snapshots failed")) - } - - @Test - @DisplayName("cold-start fallback 성공은 기간과 반환 수를 로그로 남긴다") - fun shouldLogColdStartFallbackSuccessWithPeriodAndCount(output: CapturedOutput) { - val snapshotPort = FakeCreatorRankingQuerySnapshotPort() - val aggregationPort = FakeCreatorRankingQueryAggregationPort() - snapshotPort.snapshotTableEmpty = true - aggregationPort.candidates = listOf(candidate(creatorId = 1L)) - val service = service(snapshotPort = snapshotPort, aggregationPort = aggregationPort) - - service.getCreatorRankings(viewerMemberId = null) - - assertTrue(output.out.contains("event=creator_ranking_query_cold_start_fallback_attempt")) - assertTrue(output.out.contains("event=creator_ranking_query_cold_start_fallback_success")) - assertTrue(output.out.contains("aggregationStartAtUtc=2026-05-31T15:00")) - assertTrue(output.out.contains("aggregationEndAtUtc=2026-06-07T15:00")) - assertTrue(output.out.contains("itemCount=1")) - } - - @Test - @DisplayName("cold-start fallback 실패는 기간과 에러를 로그로 남기고 예외를 전파한다") - fun shouldLogColdStartFallbackFailureWithError(output: CapturedOutput) { - val snapshotPort = FakeCreatorRankingQuerySnapshotPort() - val aggregationPort = FakeCreatorRankingQueryAggregationPort() - snapshotPort.snapshotTableEmpty = true - aggregationPort.failure = IllegalStateException("fallback failed") - val service = service(snapshotPort = snapshotPort, aggregationPort = aggregationPort) - - val exception = assertThrows(IllegalStateException::class.java) { - service.getCreatorRankings(viewerMemberId = null) - } - - assertEquals("fallback failed", exception.message) - assertTrue(output.out.contains("event=creator_ranking_query_cold_start_fallback_attempt")) - assertTrue(output.out.contains("event=creator_ranking_query_cold_start_fallback_failure")) - assertTrue(output.out.contains("aggregationStartAtUtc=2026-05-31T15:00")) - assertTrue(output.out.contains("aggregationEndAtUtc=2026-06-07T15:00")) - assertTrue(output.out.contains("error=fallback failed")) - } - - @Test - @DisplayName("cold-start 스냅샷 생성 위임 실패는 fallback 응답을 깨지 않고 로그로 남긴다") - fun shouldKeepFallbackResponseWhenColdStartSnapshotRefreshDelegationFails(output: CapturedOutput) { - val snapshotPort = FakeCreatorRankingQuerySnapshotPort() - val aggregationPort = FakeCreatorRankingQueryAggregationPort() - val snapshotJobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java) - snapshotPort.snapshotTableEmpty = true - aggregationPort.candidates = listOf(candidate(creatorId = 1L)) - Mockito.doThrow(IllegalStateException("cold-start refresh failed")) - .`when`(snapshotJobService).ensureLastCompletedWeekSnapshotForColdStart() - val service = service( - snapshotPort = snapshotPort, - aggregationPort = aggregationPort, - snapshotJobService = snapshotJobService - ) - - val result = service.getCreatorRankings(viewerMemberId = null) - - assertFalse(result.showRankChange) - assertEquals(listOf(1L), result.items.map { it.creatorId }) - assertTrue(output.out.contains("event=creator_ranking_query_cold_start_snapshot_refresh_failure")) - assertTrue(output.out.contains("error=cold-start refresh failed")) } private fun service( snapshotPort: CreatorRankingSnapshotPort = FakeCreatorRankingQuerySnapshotPort(), blockPort: CreatorRankingBlockPort = FakeCreatorRankingBlockPort(), - aggregationPort: CreatorRankingAggregationPort = FakeCreatorRankingQueryAggregationPort(), snapshotJobService: CreatorRankingSnapshotJobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java), now: ZonedDateTime = ZonedDateTime.of(2026, 6, 8, 9, 0, 0, 0, ZoneId.of("Asia/Seoul")) - ): CreatorRankingQueryService { - return CreatorRankingQueryService( - snapshotPort = snapshotPort, - blockPort = blockPort, - aggregationPort = aggregationPort, - snapshotJobService = snapshotJobService, - nowProvider = { now }, - cloudFrontHost = "https://cdn.test" - ) - } + ) = CreatorRankingQueryService(snapshotPort, blockPort, snapshotJobService, { now }, "https://cdn.test") - private fun candidate( - creatorId: Long, - liveCanAmount: Long = 100 - ): CreatorRankingSnapshotCandidate { - return CreatorRankingSnapshotCandidate( - creatorId = creatorId, - nickname = "creator-$creatorId", - profileImageUrl = "profile-$creatorId.png", - finalScore = 0.0, - contentLiveScore = 0.0, - engagementScore = 0.0, - supportScore = 0.0, - fanLoyaltyScore = 0.0, - liveCanAmount = liveCanAmount, - contentPurchaseCanAmount = 0, - contentLikeCount = 0, - contentCommentCount = 0, - channelDonationCanAmount = 0, - channelDonationCount = 0, - fanTalkCount = 0, - finalFollowerCount = 0, - followIncrease = 0 - ) - } - - private fun snapshot( - creatorId: Long, - finalScore: Double - ): CreatorRankingSnapshotRecord { + private fun snapshot(creatorId: Long, rankNo: Int): CreatorRankingSnapshotRecord { return CreatorRankingSnapshotRecord( rankingType = CreatorRankingType.WEEKLY, - aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0, 0), - aggregationEndAtUtc = LocalDateTime.of(2026, 6, 7, 15, 0, 0), - visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0, 0), + aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0), + aggregationEndAtUtc = LocalDateTime.of(2026, 6, 7, 15, 0), + visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0), creatorId = creatorId, nickname = "creator-$creatorId", profileImageUrl = "profile-$creatorId.png", - finalScore = finalScore, - contentLiveScore = 0.0, - engagementScore = 0.0, - supportScore = 0.0, - fanLoyaltyScore = 0.0, - liveCanAmount = 0, - contentPurchaseCanAmount = 0, - contentLikeCount = 0, - contentCommentCount = 0, - channelDonationCanAmount = 0, - channelDonationCount = 0, - fanTalkCount = 0, - finalFollowerCount = 0, - followIncrease = 0 + rankNo = rankNo, + finalScore = (100 - rankNo).toDouble(), + scorePolicyVersion = "CREATOR_WEEKLY_POPULARITY_V2", + scoreDetailJson = "{}" ) } } private class FakeCreatorRankingQuerySnapshotPort : CreatorRankingSnapshotPort { - var latestSnapshots: List = emptyList() + var latestResponses: ArrayDeque> = ArrayDeque(listOf(emptyList())) var previousSnapshots: List = emptyList() - var latestFailure: RuntimeException? = null - var snapshotTableEmpty: Boolean = true - var latestRankingType: CreatorRankingType? = null - var latestNowUtc: LocalDateTime? = null - var previousRankingType: CreatorRankingType? = null - var previousCurrentAggregationStartAtUtc: LocalDateTime? = null - var previousNowUtc: LocalDateTime? = null - + var latestCallCount = 0 override fun findSnapshotsByAggregationPeriod( aggregationStartAtUtc: LocalDateTime, aggregationEndAtUtc: LocalDateTime - ): List = emptyList() + ) = emptyList() - override fun findLatestSnapshots(): List { - latestFailure?.let { throw it } - return latestSnapshots - } + override fun findLatestSnapshots() = latestResponses.firstOrNull() ?: emptyList() - override fun findPreviousCompletedSnapshots(): List = previousSnapshots + override fun findPreviousCompletedSnapshots() = previousSnapshots override fun findLatestVisibleSnapshots( rankingType: CreatorRankingType, nowUtc: LocalDateTime ): List { - latestFailure?.let { throw it } - latestRankingType = rankingType - latestNowUtc = nowUtc - return latestSnapshots + latestCallCount++ + return if (latestResponses.size > 1) { + latestResponses.removeFirst() + } else { + latestResponses.firstOrNull() ?: emptyList() + } } - override fun findPreviousVisibleSnapshots( rankingType: CreatorRankingType, currentAggregationStartAtUtc: LocalDateTime, nowUtc: LocalDateTime - ): List { - previousRankingType = rankingType - previousCurrentAggregationStartAtUtc = currentAggregationStartAtUtc - previousNowUtc = nowUtc - return previousSnapshots - } - - override fun isSnapshotTableEmpty(): Boolean = snapshotTableEmpty - + ) = previousSnapshots override fun replaceSnapshots( rankingType: CreatorRankingType, aggregationStartAtUtc: LocalDateTime, @@ -572,33 +156,7 @@ private class FakeCreatorRankingQuerySnapshotPort : CreatorRankingSnapshotPort { ) = Unit } -private class FakeCreatorRankingQueryAggregationPort : CreatorRankingAggregationPort { - var candidates: List = emptyList() - var failure: RuntimeException? = null - var aggregateCallCount = 0 - var startInclusiveUtc: LocalDateTime? = null - var endExclusiveUtc: LocalDateTime? = null - - override fun aggregateCandidates( - startInclusiveUtc: LocalDateTime, - endExclusiveUtc: LocalDateTime - ): List { - aggregateCallCount++ - this.startInclusiveUtc = startInclusiveUtc - this.endExclusiveUtc = endExclusiveUtc - failure?.let { throw it } - return candidates - } -} - private class FakeCreatorRankingBlockPort : CreatorRankingBlockPort { var blockedCreatorIds: Set = emptySet() - var memberId: Long? = null - var creatorIds: Set = emptySet() - - override fun findBlockedCreatorIds(memberId: Long, creatorIds: Collection): Set { - this.memberId = memberId - this.creatorIds = creatorIds.toSet() - return blockedCreatorIds - } + override fun findBlockedCreatorIds(memberId: Long, creatorIds: Collection) = blockedCreatorIds }