From be726f0aac1e4701d0f4bdf33b460fb2e7add14a Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 8 Jun 2026 22:17:54 +0900 Subject: [PATCH] =?UTF-8?q?feat(ranking):=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?=EC=84=9C=EB=B9=84=EC=8A=A4=EB=A5=BC=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/CreatorRankingQueryService.kt | 92 ++++++++ .../port/out/CreatorRankingBlockPort.kt | 5 + .../CreatorRankingQueryServiceTest.kt | 197 ++++++++++++++++++ 3 files changed, 294 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingBlockPort.kt 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 new file mode 100644 index 00000000..f4372d34 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt @@ -0,0 +1,92 @@ +package kr.co.vividnext.sodalive.v2.ranking.application + +import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingItem +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.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class CreatorRankingQueryService( + private val snapshotPort: CreatorRankingSnapshotPort, + private val blockPort: CreatorRankingBlockPort, + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) { + @Transactional(readOnly = true) + fun getCreatorRankings(viewerMemberId: Long?): CreatorRankingResult { + val latestItems = snapshotPort.findLatestSnapshots().toRankedItems() + if (latestItems.isEmpty()) { + return CreatorRankingResult(showRankChange = false, items = emptyList()) + } + + val previousItems = snapshotPort.findPreviousCompletedSnapshots().toRankedItems() + val previousRankByCreatorId = previousItems.associate { it.creatorId to it.rank } + val showRankChange = previousRankByCreatorId.isNotEmpty() + val blockedCreatorIds = findBlockedCreatorIds(viewerMemberId = viewerMemberId, items = latestItems) + val items = latestItems.map { item -> + val previousRank = previousRankByCreatorId[item.creatorId] + item.copy( + rankChange = if (showRankChange && previousRank != null) previousRank - item.rank else null, + isNew = showRankChange && previousRank == null + ).maskIfBlocked(blockedCreatorIds) + } + + return CreatorRankingResult(showRankChange = showRankChange, items = items) + } + + private fun List.toRankedItems(): List { + return groupBy { it.finalScore } + .toSortedMap(compareByDescending { it }) + .values + .flatMap { it.shuffled() } + .take(RANKING_LIMIT) + .mapIndexed { index, snapshot -> snapshot.toItem(rank = index + 1) } + } + + private fun CreatorRankingSnapshotRecord.toItem(rank: Int): CreatorRankingItem { + return CreatorRankingItem( + rank = rank, + rankChange = null, + isNew = false, + creatorId = creatorId, + nickname = nickname, + profileImageUrl = profileImageUrl + ) + } + + private fun findBlockedCreatorIds(viewerMemberId: Long?, items: List): Set { + 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 + } + return copy( + creatorId = MASKED_CREATOR_ID, + nickname = MASKED_NICKNAME, + profileImageUrl = "$cloudFrontHost/$DEFAULT_PROFILE_IMAGE_PATH" + ) + } + + companion object { + private const val RANKING_LIMIT = 20 + private const val MASKED_CREATOR_ID = 0L + private const val MASKED_NICKNAME = "" + private const val DEFAULT_PROFILE_IMAGE_PATH = "profile/default-profile.png" + } +} + +data class CreatorRankingResult( + val showRankChange: Boolean, + val items: List +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingBlockPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingBlockPort.kt new file mode 100644 index 00000000..a44d7dd8 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingBlockPort.kt @@ -0,0 +1,5 @@ +package kr.co.vividnext.sodalive.v2.ranking.port.out + +interface CreatorRankingBlockPort { + fun findBlockedCreatorIds(memberId: Long, creatorIds: Collection): Set +} 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 16d3d2b4..8ad9758f 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,12 +2,16 @@ 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.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.assertTrue import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +import java.time.LocalDateTime class CreatorRankingQueryServiceTest { @Test @@ -49,4 +53,197 @@ class CreatorRankingQueryServiceTest { assertEquals(-1, fallenItem.rankChange) assertFalse(fallenItem.isNew) } + + @Test + @DisplayName("최신 완료 주차 스냅샷이 없으면 순위 변화 비노출과 빈 목록을 반환한다") + fun shouldReturnEmptyResultWhenLatestSnapshotsDoNotExist() { + val snapshotPort = FakeCreatorRankingQuerySnapshotPort() + val service = service(snapshotPort = snapshotPort) + + val result = service.getCreatorRankings(viewerMemberId = null) + + assertFalse(result.showRankChange) + assertTrue(result.items.isEmpty()) + } + + @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) + + 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 }) + } + + @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() { + 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) + ) + 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("profile-1.png", result.items.single().profileImageUrl) + } + + private fun service( + snapshotPort: CreatorRankingSnapshotPort = FakeCreatorRankingQuerySnapshotPort(), + blockPort: CreatorRankingBlockPort = FakeCreatorRankingBlockPort() + ): CreatorRankingQueryService { + return CreatorRankingQueryService( + snapshotPort = snapshotPort, + blockPort = blockPort, + cloudFrontHost = "https://cdn.test" + ) + } + + private fun snapshot( + creatorId: Long, + finalScore: Double + ): CreatorRankingSnapshotRecord { + return CreatorRankingSnapshotRecord( + aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0, 0), + aggregationEndAtUtc = LocalDateTime.of(2026, 6, 7, 15, 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 + ) + } +} + +private class FakeCreatorRankingQuerySnapshotPort : CreatorRankingSnapshotPort { + var latestSnapshots: List = emptyList() + var previousSnapshots: List = emptyList() + + override fun findSnapshotsByAggregationPeriod( + aggregationStartAtUtc: LocalDateTime, + aggregationEndAtUtc: LocalDateTime + ): List = emptyList() + + override fun findLatestSnapshots(): List = latestSnapshots + + override fun findPreviousCompletedSnapshots(): List = previousSnapshots + + override fun replaceSnapshots( + aggregationStartAtUtc: LocalDateTime, + aggregationEndAtUtc: LocalDateTime, + newSnapshots: List + ) = Unit +} + +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 + } }