From 79be172b932861b549940d37ec5f740d0d8c940e Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 24 Jun 2026 23:47:08 +0900 Subject: [PATCH] =?UTF-8?q?fix(content-ranking):=20=EA=B3=B5=EA=B0=9C=20?= =?UTF-8?q?=EC=A0=84=20=EB=9E=AD=ED=82=B9=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/CreatorRankingQueryService.kt | 28 +++++- .../api/home/CreatorRankingControllerTest.kt | 3 + .../CreatorRankingQueryServiceTest.kt | 86 ++++++++++++++++++- 3 files changed, 109 insertions(+), 8 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 27a2dc31..09ea36d5 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 @@ -5,6 +5,7 @@ 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 @@ -14,6 +15,8 @@ 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 @Service @@ -34,10 +37,12 @@ class CreatorRankingQueryService( fun getCreatorRankings(viewerMemberId: Long?): CreatorRankingResult { val startedAt = System.currentTimeMillis() return runCatching { - val latestItems = snapshotPort.findLatestSnapshots().toRankedItems() + val nowUtc = nowUtc() + val latestSnapshots = snapshotPort.findLatestVisibleSnapshots(CreatorRankingType.WEEKLY, nowUtc) + val latestItems = latestSnapshots.toRankedItems() if (latestItems.isEmpty()) { if (snapshotPort.isSnapshotTableEmpty()) { - val fallbackItems = aggregateColdStartFallback().toRankedItems() + val fallbackItems = aggregateColdStartFallback(nowUtc).toRankedItems() if (fallbackItems.isNotEmpty()) { delegateColdStartSnapshotRefresh() } @@ -56,7 +61,11 @@ class CreatorRankingQueryService( ) } - val previousItems = snapshotPort.findPreviousCompletedSnapshots().toRankedItems() + val previousItems = snapshotPort.findPreviousVisibleSnapshots( + rankingType = CreatorRankingType.WEEKLY, + currentAggregationStartAtUtc = latestSnapshots.first().aggregationStartAtUtc, + nowUtc = nowUtc + ).toRankedItems() val previousRankByCreatorId = previousItems.associate { it.creatorId to it.rank } val showRankChange = previousRankByCreatorId.isNotEmpty() val blockedCreatorIds = findBlockedCreatorIds(viewerMemberId = viewerMemberId, items = latestItems) @@ -95,10 +104,14 @@ class CreatorRankingQueryService( val blockedCreatorCount: Int ) - private fun aggregateColdStartFallback(): List { + 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={}", @@ -144,6 +157,10 @@ class CreatorRankingQueryService( } } + private fun nowUtc(): LocalDateTime { + return nowProvider().withZoneSameInstant(UTC_ZONE).toLocalDateTime() + } + private fun List.toRankedItems(): List { return groupBy { it.finalScore } .toSortedMap(compareByDescending { it }) @@ -190,8 +207,10 @@ class CreatorRankingQueryService( ) return CreatorRankingSnapshotRecord( + rankingType = CreatorRankingType.WEEKLY, aggregationStartAtUtc = utcRange.startInclusiveUtc, aggregationEndAtUtc = utcRange.endExclusiveUtc, + visibleFromAtUtc = utcRange.endExclusiveUtc.plusHours(9), creatorId = creatorId, nickname = nickname, profileImageUrl = profileImageUrl, @@ -234,6 +253,7 @@ class CreatorRankingQueryService( } companion object { + private val UTC_ZONE: ZoneId = ZoneId.of("UTC") private const val RANKING_LIMIT = 20 private const val MASKED_CREATOR_ID = 0L private const val MASKED_NICKNAME = "" diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/CreatorRankingControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/CreatorRankingControllerTest.kt index 34950b9b..f53f9eba 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/CreatorRankingControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/CreatorRankingControllerTest.kt @@ -7,6 +7,7 @@ import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.block.BlockMember import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer import kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.CreatorRankingSnapshot +import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired @@ -126,8 +127,10 @@ class CreatorRankingControllerTest @Autowired constructor( ) { entityManager.persist( CreatorRankingSnapshot( + 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), creatorId = creatorId, nickname = nickname, profileImageUrl = profileImageUrl, 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 61dceb60..f1cec7fb 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.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 @@ -236,6 +237,54 @@ class CreatorRankingQueryServiceTest { assertEquals(listOf(false, 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() { @@ -400,16 +449,15 @@ class CreatorRankingQueryServiceTest { snapshotPort: CreatorRankingSnapshotPort = FakeCreatorRankingQuerySnapshotPort(), blockPort: CreatorRankingBlockPort = FakeCreatorRankingBlockPort(), aggregationPort: CreatorRankingAggregationPort = FakeCreatorRankingQueryAggregationPort(), - snapshotJobService: CreatorRankingSnapshotJobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java) + 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 = { - ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul")) - }, + nowProvider = { now }, cloudFrontHost = "https://cdn.test" ) } @@ -444,8 +492,10 @@ class CreatorRankingQueryServiceTest { finalScore: Double ): 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), creatorId = creatorId, nickname = "creator-$creatorId", profileImageUrl = "profile-$creatorId.png", @@ -472,6 +522,11 @@ private class FakeCreatorRankingQuerySnapshotPort : CreatorRankingSnapshotPort { 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 override fun findSnapshotsByAggregationPeriod( aggregationStartAtUtc: LocalDateTime, @@ -485,11 +540,34 @@ private class FakeCreatorRankingQuerySnapshotPort : CreatorRankingSnapshotPort { override fun findPreviousCompletedSnapshots(): List = previousSnapshots + override fun findLatestVisibleSnapshots( + rankingType: CreatorRankingType, + nowUtc: LocalDateTime + ): List { + latestFailure?.let { throw it } + latestRankingType = rankingType + latestNowUtc = nowUtc + return latestSnapshots + } + + override fun findPreviousVisibleSnapshots( + rankingType: CreatorRankingType, + currentAggregationStartAtUtc: LocalDateTime, + nowUtc: LocalDateTime + ): List { + previousRankingType = rankingType + previousCurrentAggregationStartAtUtc = currentAggregationStartAtUtc + previousNowUtc = nowUtc + return previousSnapshots + } + override fun isSnapshotTableEmpty(): Boolean = snapshotTableEmpty override fun replaceSnapshots( + rankingType: CreatorRankingType, aggregationStartAtUtc: LocalDateTime, aggregationEndAtUtc: LocalDateTime, + visibleFromAtUtc: LocalDateTime, newSnapshots: List ) = Unit }