feat(ranking): 크리에이터 랭킹 조회 서비스를 추가한다
This commit is contained in:
@@ -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<CreatorRankingSnapshotRecord>.toRankedItems(): List<CreatorRankingItem> {
|
||||||
|
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<CreatorRankingItem>): Set<Long> {
|
||||||
|
if (viewerMemberId == null) {
|
||||||
|
return emptySet()
|
||||||
|
}
|
||||||
|
return blockPort.findBlockedCreatorIds(
|
||||||
|
memberId = viewerMemberId,
|
||||||
|
creatorIds = items.map { it.creatorId }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CreatorRankingItem.maskIfBlocked(blockedCreatorIds: Set<Long>): 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<CreatorRankingItem>
|
||||||
|
)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.ranking.port.out
|
||||||
|
|
||||||
|
interface CreatorRankingBlockPort {
|
||||||
|
fun findBlockedCreatorIds(memberId: Long, creatorIds: Collection<Long>): Set<Long>
|
||||||
|
}
|
||||||
@@ -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.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.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.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertFalse
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
import org.junit.jupiter.api.Assertions.assertNull
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
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 java.time.LocalDateTime
|
||||||
|
|
||||||
class CreatorRankingQueryServiceTest {
|
class CreatorRankingQueryServiceTest {
|
||||||
@Test
|
@Test
|
||||||
@@ -49,4 +53,197 @@ class CreatorRankingQueryServiceTest {
|
|||||||
assertEquals(-1, fallenItem.rankChange)
|
assertEquals(-1, fallenItem.rankChange)
|
||||||
assertFalse(fallenItem.isNew)
|
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<CreatorRankingSnapshotRecord> = emptyList()
|
||||||
|
var previousSnapshots: List<CreatorRankingSnapshotRecord> = emptyList()
|
||||||
|
|
||||||
|
override fun findSnapshotsByAggregationPeriod(
|
||||||
|
aggregationStartAtUtc: LocalDateTime,
|
||||||
|
aggregationEndAtUtc: LocalDateTime
|
||||||
|
): List<CreatorRankingSnapshotRecord> = emptyList()
|
||||||
|
|
||||||
|
override fun findLatestSnapshots(): List<CreatorRankingSnapshotRecord> = latestSnapshots
|
||||||
|
|
||||||
|
override fun findPreviousCompletedSnapshots(): List<CreatorRankingSnapshotRecord> = previousSnapshots
|
||||||
|
|
||||||
|
override fun replaceSnapshots(
|
||||||
|
aggregationStartAtUtc: LocalDateTime,
|
||||||
|
aggregationEndAtUtc: LocalDateTime,
|
||||||
|
newSnapshots: List<CreatorRankingSnapshotRecord>
|
||||||
|
) = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeCreatorRankingBlockPort : CreatorRankingBlockPort {
|
||||||
|
var blockedCreatorIds: Set<Long> = emptySet()
|
||||||
|
var memberId: Long? = null
|
||||||
|
var creatorIds: Set<Long> = emptySet()
|
||||||
|
|
||||||
|
override fun findBlockedCreatorIds(memberId: Long, creatorIds: Collection<Long>): Set<Long> {
|
||||||
|
this.memberId = memberId
|
||||||
|
this.creatorIds = creatorIds.toSet()
|
||||||
|
return blockedCreatorIds
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user