test #426
@@ -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.CreatorRankingPeriodPolicy
|
||||||
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScorePolicy
|
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScorePolicy
|
||||||
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingSnapshotCandidate
|
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingSnapshotCandidate
|
||||||
|
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType
|
||||||
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingUtcRange
|
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.CreatorRankingAggregationPort
|
||||||
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingBlockPort
|
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.beans.factory.annotation.Value
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
import java.time.ZonedDateTime
|
import java.time.ZonedDateTime
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -34,10 +37,12 @@ class CreatorRankingQueryService(
|
|||||||
fun getCreatorRankings(viewerMemberId: Long?): CreatorRankingResult {
|
fun getCreatorRankings(viewerMemberId: Long?): CreatorRankingResult {
|
||||||
val startedAt = System.currentTimeMillis()
|
val startedAt = System.currentTimeMillis()
|
||||||
return runCatching {
|
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 (latestItems.isEmpty()) {
|
||||||
if (snapshotPort.isSnapshotTableEmpty()) {
|
if (snapshotPort.isSnapshotTableEmpty()) {
|
||||||
val fallbackItems = aggregateColdStartFallback().toRankedItems()
|
val fallbackItems = aggregateColdStartFallback(nowUtc).toRankedItems()
|
||||||
if (fallbackItems.isNotEmpty()) {
|
if (fallbackItems.isNotEmpty()) {
|
||||||
delegateColdStartSnapshotRefresh()
|
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 previousRankByCreatorId = previousItems.associate { it.creatorId to it.rank }
|
||||||
val showRankChange = previousRankByCreatorId.isNotEmpty()
|
val showRankChange = previousRankByCreatorId.isNotEmpty()
|
||||||
val blockedCreatorIds = findBlockedCreatorIds(viewerMemberId = viewerMemberId, items = latestItems)
|
val blockedCreatorIds = findBlockedCreatorIds(viewerMemberId = viewerMemberId, items = latestItems)
|
||||||
@@ -95,10 +104,14 @@ class CreatorRankingQueryService(
|
|||||||
val blockedCreatorCount: Int
|
val blockedCreatorCount: Int
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun aggregateColdStartFallback(): List<CreatorRankingSnapshotRecord> {
|
private fun aggregateColdStartFallback(nowUtc: LocalDateTime): List<CreatorRankingSnapshotRecord> {
|
||||||
val startedAt = System.currentTimeMillis()
|
val startedAt = System.currentTimeMillis()
|
||||||
val period = periodPolicy.resolveLastCompletedWeek(nowProvider())
|
val period = periodPolicy.resolveLastCompletedWeek(nowProvider())
|
||||||
val utcRange = periodPolicy.toUtcRange(period)
|
val utcRange = periodPolicy.toUtcRange(period)
|
||||||
|
val visibleFromAtUtc = periodPolicy.resolveVisibleFromAtUtc(period.endExclusiveKst)
|
||||||
|
if (visibleFromAtUtc > nowUtc) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
log.info(
|
log.info(
|
||||||
"event=creator_ranking_query_cold_start_fallback_attempt " +
|
"event=creator_ranking_query_cold_start_fallback_attempt " +
|
||||||
"aggregationStartAtUtc={} aggregationEndAtUtc={}",
|
"aggregationStartAtUtc={} aggregationEndAtUtc={}",
|
||||||
@@ -144,6 +157,10 @@ class CreatorRankingQueryService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun nowUtc(): LocalDateTime {
|
||||||
|
return nowProvider().withZoneSameInstant(UTC_ZONE).toLocalDateTime()
|
||||||
|
}
|
||||||
|
|
||||||
private fun List<CreatorRankingSnapshotRecord>.toRankedItems(): List<CreatorRankingItem> {
|
private fun List<CreatorRankingSnapshotRecord>.toRankedItems(): List<CreatorRankingItem> {
|
||||||
return groupBy { it.finalScore }
|
return groupBy { it.finalScore }
|
||||||
.toSortedMap(compareByDescending { it })
|
.toSortedMap(compareByDescending { it })
|
||||||
@@ -190,8 +207,10 @@ class CreatorRankingQueryService(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return CreatorRankingSnapshotRecord(
|
return CreatorRankingSnapshotRecord(
|
||||||
|
rankingType = CreatorRankingType.WEEKLY,
|
||||||
aggregationStartAtUtc = utcRange.startInclusiveUtc,
|
aggregationStartAtUtc = utcRange.startInclusiveUtc,
|
||||||
aggregationEndAtUtc = utcRange.endExclusiveUtc,
|
aggregationEndAtUtc = utcRange.endExclusiveUtc,
|
||||||
|
visibleFromAtUtc = utcRange.endExclusiveUtc.plusHours(9),
|
||||||
creatorId = creatorId,
|
creatorId = creatorId,
|
||||||
nickname = nickname,
|
nickname = nickname,
|
||||||
profileImageUrl = profileImageUrl,
|
profileImageUrl = profileImageUrl,
|
||||||
@@ -234,6 +253,7 @@ class CreatorRankingQueryService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
private val UTC_ZONE: ZoneId = ZoneId.of("UTC")
|
||||||
private const val RANKING_LIMIT = 20
|
private const val RANKING_LIMIT = 20
|
||||||
private const val MASKED_CREATOR_ID = 0L
|
private const val MASKED_CREATOR_ID = 0L
|
||||||
private const val MASKED_NICKNAME = ""
|
private const val MASKED_NICKNAME = ""
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import kr.co.vividnext.sodalive.member.MemberRole
|
|||||||
import kr.co.vividnext.sodalive.member.block.BlockMember
|
import kr.co.vividnext.sodalive.member.block.BlockMember
|
||||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
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.adapter.out.persistence.CreatorRankingSnapshot
|
||||||
|
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType
|
||||||
import org.junit.jupiter.api.DisplayName
|
import org.junit.jupiter.api.DisplayName
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import org.springframework.beans.factory.annotation.Autowired
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
@@ -126,8 +127,10 @@ class CreatorRankingControllerTest @Autowired constructor(
|
|||||||
) {
|
) {
|
||||||
entityManager.persist(
|
entityManager.persist(
|
||||||
CreatorRankingSnapshot(
|
CreatorRankingSnapshot(
|
||||||
|
rankingType = CreatorRankingType.WEEKLY,
|
||||||
aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0, 0),
|
aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0, 0),
|
||||||
aggregationEndAtUtc = LocalDateTime.of(2026, 6, 7, 15, 0, 0),
|
aggregationEndAtUtc = LocalDateTime.of(2026, 6, 7, 15, 0, 0),
|
||||||
|
visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0, 0),
|
||||||
creatorId = creatorId,
|
creatorId = creatorId,
|
||||||
nickname = nickname,
|
nickname = nickname,
|
||||||
profileImageUrl = profileImageUrl,
|
profileImageUrl = profileImageUrl,
|
||||||
|
|||||||
@@ -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.CreatorRankingItem
|
||||||
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingSnapshotCandidate
|
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingSnapshotCandidate
|
||||||
|
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType
|
||||||
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingAggregationPort
|
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.CreatorRankingBlockPort
|
||||||
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotPort
|
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 })
|
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
|
@Test
|
||||||
@DisplayName("동점 스냅샷은 같은 점수 구간 안에서만 섞이고 상위 20명만 반환한다")
|
@DisplayName("동점 스냅샷은 같은 점수 구간 안에서만 섞이고 상위 20명만 반환한다")
|
||||||
fun shouldRandomizeOnlyWithinTieGroupsAndLimitToTwentyItems() {
|
fun shouldRandomizeOnlyWithinTieGroupsAndLimitToTwentyItems() {
|
||||||
@@ -400,16 +449,15 @@ class CreatorRankingQueryServiceTest {
|
|||||||
snapshotPort: CreatorRankingSnapshotPort = FakeCreatorRankingQuerySnapshotPort(),
|
snapshotPort: CreatorRankingSnapshotPort = FakeCreatorRankingQuerySnapshotPort(),
|
||||||
blockPort: CreatorRankingBlockPort = FakeCreatorRankingBlockPort(),
|
blockPort: CreatorRankingBlockPort = FakeCreatorRankingBlockPort(),
|
||||||
aggregationPort: CreatorRankingAggregationPort = FakeCreatorRankingQueryAggregationPort(),
|
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 {
|
): CreatorRankingQueryService {
|
||||||
return CreatorRankingQueryService(
|
return CreatorRankingQueryService(
|
||||||
snapshotPort = snapshotPort,
|
snapshotPort = snapshotPort,
|
||||||
blockPort = blockPort,
|
blockPort = blockPort,
|
||||||
aggregationPort = aggregationPort,
|
aggregationPort = aggregationPort,
|
||||||
snapshotJobService = snapshotJobService,
|
snapshotJobService = snapshotJobService,
|
||||||
nowProvider = {
|
nowProvider = { now },
|
||||||
ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul"))
|
|
||||||
},
|
|
||||||
cloudFrontHost = "https://cdn.test"
|
cloudFrontHost = "https://cdn.test"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -444,8 +492,10 @@ class CreatorRankingQueryServiceTest {
|
|||||||
finalScore: Double
|
finalScore: Double
|
||||||
): CreatorRankingSnapshotRecord {
|
): CreatorRankingSnapshotRecord {
|
||||||
return CreatorRankingSnapshotRecord(
|
return CreatorRankingSnapshotRecord(
|
||||||
|
rankingType = CreatorRankingType.WEEKLY,
|
||||||
aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0, 0),
|
aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0, 0),
|
||||||
aggregationEndAtUtc = LocalDateTime.of(2026, 6, 7, 15, 0, 0),
|
aggregationEndAtUtc = LocalDateTime.of(2026, 6, 7, 15, 0, 0),
|
||||||
|
visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0, 0),
|
||||||
creatorId = creatorId,
|
creatorId = creatorId,
|
||||||
nickname = "creator-$creatorId",
|
nickname = "creator-$creatorId",
|
||||||
profileImageUrl = "profile-$creatorId.png",
|
profileImageUrl = "profile-$creatorId.png",
|
||||||
@@ -472,6 +522,11 @@ private class FakeCreatorRankingQuerySnapshotPort : CreatorRankingSnapshotPort {
|
|||||||
var previousSnapshots: List<CreatorRankingSnapshotRecord> = emptyList()
|
var previousSnapshots: List<CreatorRankingSnapshotRecord> = emptyList()
|
||||||
var latestFailure: RuntimeException? = null
|
var latestFailure: RuntimeException? = null
|
||||||
var snapshotTableEmpty: Boolean = true
|
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(
|
override fun findSnapshotsByAggregationPeriod(
|
||||||
aggregationStartAtUtc: LocalDateTime,
|
aggregationStartAtUtc: LocalDateTime,
|
||||||
@@ -485,11 +540,34 @@ private class FakeCreatorRankingQuerySnapshotPort : CreatorRankingSnapshotPort {
|
|||||||
|
|
||||||
override fun findPreviousCompletedSnapshots(): List<CreatorRankingSnapshotRecord> = previousSnapshots
|
override fun findPreviousCompletedSnapshots(): List<CreatorRankingSnapshotRecord> = previousSnapshots
|
||||||
|
|
||||||
|
override fun findLatestVisibleSnapshots(
|
||||||
|
rankingType: CreatorRankingType,
|
||||||
|
nowUtc: LocalDateTime
|
||||||
|
): List<CreatorRankingSnapshotRecord> {
|
||||||
|
latestFailure?.let { throw it }
|
||||||
|
latestRankingType = rankingType
|
||||||
|
latestNowUtc = nowUtc
|
||||||
|
return latestSnapshots
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findPreviousVisibleSnapshots(
|
||||||
|
rankingType: CreatorRankingType,
|
||||||
|
currentAggregationStartAtUtc: LocalDateTime,
|
||||||
|
nowUtc: LocalDateTime
|
||||||
|
): List<CreatorRankingSnapshotRecord> {
|
||||||
|
previousRankingType = rankingType
|
||||||
|
previousCurrentAggregationStartAtUtc = currentAggregationStartAtUtc
|
||||||
|
previousNowUtc = nowUtc
|
||||||
|
return previousSnapshots
|
||||||
|
}
|
||||||
|
|
||||||
override fun isSnapshotTableEmpty(): Boolean = snapshotTableEmpty
|
override fun isSnapshotTableEmpty(): Boolean = snapshotTableEmpty
|
||||||
|
|
||||||
override fun replaceSnapshots(
|
override fun replaceSnapshots(
|
||||||
|
rankingType: CreatorRankingType,
|
||||||
aggregationStartAtUtc: LocalDateTime,
|
aggregationStartAtUtc: LocalDateTime,
|
||||||
aggregationEndAtUtc: LocalDateTime,
|
aggregationEndAtUtc: LocalDateTime,
|
||||||
|
visibleFromAtUtc: LocalDateTime,
|
||||||
newSnapshots: List<CreatorRankingSnapshotRecord>
|
newSnapshots: List<CreatorRankingSnapshotRecord>
|
||||||
) = Unit
|
) = Unit
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user