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 d690382a..b1ed4b2f 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 @@ -1,6 +1,11 @@ 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.CreatorRankingPeriodPolicy +import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScorePolicy +import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingSnapshotCandidate +import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingUtcRange +import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingAggregationPort import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingBlockPort import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotPort import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotRecord @@ -8,15 +13,20 @@ 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.ZonedDateTime @Service class CreatorRankingQueryService( private val snapshotPort: CreatorRankingSnapshotPort, private val blockPort: CreatorRankingBlockPort, + private val aggregationPort: CreatorRankingAggregationPort, + 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 { @@ -24,6 +34,17 @@ class CreatorRankingQueryService( return runCatching { val latestItems = snapshotPort.findLatestSnapshots().toRankedItems() if (latestItems.isEmpty()) { + if (snapshotPort.isSnapshotTableEmpty()) { + val fallbackItems = aggregateColdStartFallback().toRankedItems() + 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 @@ -69,6 +90,43 @@ class CreatorRankingQueryService( val blockedCreatorCount: Int ) + private fun aggregateColdStartFallback(): List { + val startedAt = System.currentTimeMillis() + val period = periodPolicy.resolveLastCompletedWeek(nowProvider()) + val utcRange = periodPolicy.toUtcRange(period) + 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 List.toRankedItems(): List { return groupBy { it.finalScore } .toSortedMap(compareByDescending { it }) @@ -89,6 +147,54 @@ 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( + aggregationStartAtUtc = utcRange.startInclusiveUtc, + aggregationEndAtUtc = utcRange.endExclusiveUtc, + creatorId = creatorId, + nickname = nickname, + profileImageUrl = profileImageUrl, + finalScore = calculatedFinalScore, + contentLiveScore = calculatedContentLiveScore, + engagementScore = calculatedEngagementScore, + supportScore = calculatedSupportScore, + fanLoyaltyScore = calculatedFanLoyaltyScore, + liveCanAmount = liveCanAmount, + contentPurchaseCanAmount = contentPurchaseCanAmount, + contentLikeCount = contentLikeCount, + contentCommentCount = contentCommentCount, + channelDonationCanAmount = channelDonationCanAmount, + channelDonationCount = channelDonationCount, + fanTalkCount = fanTalkCount, + finalFollowerCount = finalFollowerCount, + followIncrease = followIncrease + ) + } + private fun findBlockedCreatorIds(viewerMemberId: Long?, items: List): Set { if (viewerMemberId == null) { return emptySet() 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 309a067c..b5f34d29 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 @@ -2,6 +2,7 @@ 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.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 @@ -16,6 +17,8 @@ import org.junit.jupiter.api.extension.ExtendWith import org.springframework.boot.test.system.CapturedOutput import org.springframework.boot.test.system.OutputCaptureExtension import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZonedDateTime @ExtendWith(OutputCaptureExtension::class) class CreatorRankingQueryServiceTest { @@ -71,6 +74,90 @@ class CreatorRankingQueryServiceTest { 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() { + val snapshotPort = FakeCreatorRankingQuerySnapshotPort() + val aggregationPort = FakeCreatorRankingQueryAggregationPort() + snapshotPort.snapshotTableEmpty = true + aggregationPort.candidates = listOf( + candidate(creatorId = 1L, liveCanAmount = 100), + candidate(creatorId = 2L, liveCanAmount = 200) + ) + val service = service(snapshotPort = snapshotPort, aggregationPort = aggregationPort) + + 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) + } + + @Test + @DisplayName("최신 스냅샷이 없어도 과거 스냅샷 row가 있으면 cold-start fallback을 호출하지 않는다") + fun shouldNotUseColdStartFallbackWhenAnyHistoricalSnapshotExists() { + val snapshotPort = FakeCreatorRankingQuerySnapshotPort() + val aggregationPort = FakeCreatorRankingQueryAggregationPort() + snapshotPort.snapshotTableEmpty = false + aggregationPort.candidates = listOf(candidate(creatorId = 1L)) + val service = service(snapshotPort = snapshotPort, aggregationPort = aggregationPort) + + val result = service.getCreatorRankings(viewerMemberId = null) + + assertFalse(result.showRankChange) + assertTrue(result.items.isEmpty()) + assertEquals(0, aggregationPort.aggregateCallCount) + } + + @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() { @@ -213,17 +300,86 @@ class CreatorRankingQueryServiceTest { 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")) + } + private fun service( snapshotPort: CreatorRankingSnapshotPort = FakeCreatorRankingQuerySnapshotPort(), - blockPort: CreatorRankingBlockPort = FakeCreatorRankingBlockPort() + blockPort: CreatorRankingBlockPort = FakeCreatorRankingBlockPort(), + aggregationPort: CreatorRankingAggregationPort = FakeCreatorRankingQueryAggregationPort() ): CreatorRankingQueryService { return CreatorRankingQueryService( snapshotPort = snapshotPort, blockPort = blockPort, + aggregationPort = aggregationPort, + nowProvider = { + ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul")) + }, cloudFrontHost = "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 @@ -256,6 +412,7 @@ private class FakeCreatorRankingQuerySnapshotPort : CreatorRankingSnapshotPort { var latestSnapshots: List = emptyList() var previousSnapshots: List = emptyList() var latestFailure: RuntimeException? = null + var snapshotTableEmpty: Boolean = true override fun findSnapshotsByAggregationPeriod( aggregationStartAtUtc: LocalDateTime, @@ -269,6 +426,8 @@ private class FakeCreatorRankingQuerySnapshotPort : CreatorRankingSnapshotPort { override fun findPreviousCompletedSnapshots(): List = previousSnapshots + override fun isSnapshotTableEmpty(): Boolean = snapshotTableEmpty + override fun replaceSnapshots( aggregationStartAtUtc: LocalDateTime, aggregationEndAtUtc: LocalDateTime, @@ -276,6 +435,25 @@ 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