feat(content-ranking): 스냅샷 기반 랭킹 조회를 추가한다
This commit is contained in:
@@ -1,17 +1,80 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.content.ranking.application
|
package kr.co.vividnext.sodalive.v2.content.ranking.application
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||||
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRanking
|
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRanking
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingItem
|
||||||
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
|
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotPort
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotRecord
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.time.ZonedDateTime
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class AudioRankingQueryService {
|
class AudioRankingQueryService(
|
||||||
|
private val snapshotPort: AudioRankingSnapshotPort,
|
||||||
|
private val memberContentPreferenceService: MemberContentPreferenceService,
|
||||||
|
private val nowProvider: () -> ZonedDateTime = { ZonedDateTime.now() }
|
||||||
|
) {
|
||||||
|
@Transactional(readOnly = true)
|
||||||
fun getRankings(type: AudioRankingType, member: Member?): AudioRanking {
|
fun getRankings(type: AudioRankingType, member: Member?): AudioRanking {
|
||||||
|
val nowUtc = nowProvider().withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime()
|
||||||
|
val latestSnapshots = snapshotPort.findLatestVisibleSnapshots(type, nowUtc)
|
||||||
|
if (latestSnapshots.isEmpty()) {
|
||||||
|
return AudioRanking(showRankChange = false, type = type, items = emptyList())
|
||||||
|
}
|
||||||
|
val canViewAdultContent = canViewAdultContent(member)
|
||||||
|
val latestVisibleSnapshots = latestSnapshots.visibleTo(canViewAdultContent).take(ITEM_LIMIT)
|
||||||
|
|
||||||
|
val previousSnapshots = snapshotPort.findPreviousVisibleSnapshots(
|
||||||
|
rankingType = type,
|
||||||
|
currentAggregationStartAtUtc = latestSnapshots.first().aggregationStartAtUtc,
|
||||||
|
nowUtc = nowUtc
|
||||||
|
)
|
||||||
|
val previousRankByContentId = previousSnapshots.visibleTo(canViewAdultContent)
|
||||||
|
.take(ITEM_LIMIT)
|
||||||
|
.mapIndexed { index, snapshot -> snapshot.contentId to index + 1 }
|
||||||
|
.toMap()
|
||||||
|
val showRankChange = previousRankByContentId.isNotEmpty()
|
||||||
|
|
||||||
return AudioRanking(
|
return AudioRanking(
|
||||||
showRankChange = false,
|
showRankChange = showRankChange,
|
||||||
type = type,
|
type = type,
|
||||||
items = emptyList()
|
items = latestVisibleSnapshots.mapIndexed { index, snapshot ->
|
||||||
|
snapshot.toItem(index + 1, showRankChange, previousRankByContentId)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun canViewAdultContent(member: Member?): Boolean {
|
||||||
|
if (member == null) return false
|
||||||
|
return memberContentPreferenceService.canViewAdultContent(member)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<AudioRankingSnapshotRecord>.visibleTo(canViewAdultContent: Boolean): List<AudioRankingSnapshotRecord> {
|
||||||
|
return if (canViewAdultContent) this else filter { !it.isAdult }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AudioRankingSnapshotRecord.toItem(
|
||||||
|
rank: Int,
|
||||||
|
showRankChange: Boolean,
|
||||||
|
previousRankByContentId: Map<Long, Int>
|
||||||
|
): AudioRankingItem {
|
||||||
|
val previousRank = previousRankByContentId[contentId]
|
||||||
|
return AudioRankingItem(
|
||||||
|
contentId = contentId,
|
||||||
|
title = title,
|
||||||
|
creatorNickname = creatorNickname,
|
||||||
|
rank = rank,
|
||||||
|
rankChange = if (showRankChange && previousRank != null) previousRank - rank else null,
|
||||||
|
isNew = showRankChange && previousRank == null,
|
||||||
|
coverImageUrl = coverImageUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ITEM_LIMIT = 20
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.content.ranking.application
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotPort
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotRecord
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZonedDateTime
|
||||||
|
|
||||||
|
class AudioRankingQueryServiceTest {
|
||||||
|
@Test
|
||||||
|
fun shouldReturnLatestVisibleSnapshotsWithRankChangesAndNewFlags() {
|
||||||
|
val snapshotPort = FakeAudioRankingQuerySnapshotPort()
|
||||||
|
snapshotPort.latestSnapshots = listOf(
|
||||||
|
snapshot(contentId = 2L, rank = 1),
|
||||||
|
snapshot(contentId = 1L, rank = 2),
|
||||||
|
snapshot(contentId = 3L, rank = 3)
|
||||||
|
)
|
||||||
|
snapshotPort.previousSnapshots = listOf(
|
||||||
|
snapshot(contentId = 1L, rank = 1),
|
||||||
|
snapshot(contentId = 2L, rank = 2)
|
||||||
|
)
|
||||||
|
val service = service(snapshotPort)
|
||||||
|
|
||||||
|
val result = service.getRankings(AudioRankingType.REVENUE, member = null)
|
||||||
|
|
||||||
|
assertTrue(result.showRankChange)
|
||||||
|
assertEquals(AudioRankingType.REVENUE, result.type)
|
||||||
|
assertEquals(listOf(2L, 1L, 3L), result.items.map { it.contentId })
|
||||||
|
assertEquals(listOf(1, 2, 3), result.items.map { it.rank })
|
||||||
|
assertEquals(listOf(1, -1, null), result.items.map { it.rankChange })
|
||||||
|
assertEquals(listOf(false, false, true), result.items.map { it.isNew })
|
||||||
|
assertEquals(LocalDateTime.of(2026, 6, 8, 0, 0), snapshotPort.nowUtc)
|
||||||
|
assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0), snapshotPort.currentAggregationStartAtUtc)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shouldHideRankChangesWhenPreviousSnapshotDoesNotExist() {
|
||||||
|
val snapshotPort = FakeAudioRankingQuerySnapshotPort()
|
||||||
|
snapshotPort.latestSnapshots = listOf(snapshot(contentId = 1L, rank = 1))
|
||||||
|
val service = service(snapshotPort)
|
||||||
|
|
||||||
|
val result = service.getRankings(AudioRankingType.WEEKLY_POPULAR, member = null)
|
||||||
|
|
||||||
|
assertFalse(result.showRankChange)
|
||||||
|
assertEquals(listOf(1L), result.items.map { it.contentId })
|
||||||
|
assertEquals(listOf(null), result.items.map { it.rankChange })
|
||||||
|
assertEquals(listOf(false), result.items.map { it.isNew })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shouldReturnEmptyRankingWhenLatestSnapshotDoesNotExist() {
|
||||||
|
val result = service(FakeAudioRankingQuerySnapshotPort()).getRankings(AudioRankingType.LIKE_COUNT, member = null)
|
||||||
|
|
||||||
|
assertFalse(result.showRankChange)
|
||||||
|
assertEquals(AudioRankingType.LIKE_COUNT, result.type)
|
||||||
|
assertEquals(emptyList<Any>(), result.items)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shouldFilterAdultSnapshotsForNonAdultViewerAndRecalculateRanks() {
|
||||||
|
val snapshotPort = FakeAudioRankingQuerySnapshotPort()
|
||||||
|
snapshotPort.latestSnapshots = listOf(
|
||||||
|
snapshot(contentId = 1L, rank = 1, isAdult = true),
|
||||||
|
snapshot(contentId = 2L, rank = 2),
|
||||||
|
snapshot(contentId = 3L, rank = 3)
|
||||||
|
)
|
||||||
|
snapshotPort.previousSnapshots = listOf(
|
||||||
|
snapshot(contentId = 1L, rank = 1, isAdult = true),
|
||||||
|
snapshot(contentId = 2L, rank = 2)
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = service(snapshotPort).getRankings(AudioRankingType.REVENUE, member = null)
|
||||||
|
|
||||||
|
assertEquals(listOf(2L, 3L), result.items.map { it.contentId })
|
||||||
|
assertEquals(listOf(1, 2), result.items.map { it.rank })
|
||||||
|
assertEquals(listOf(0, null), result.items.map { it.rankChange })
|
||||||
|
assertEquals(listOf(false, true), result.items.map { it.isNew })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shouldKeepAdultSnapshotsForAdultViewer() {
|
||||||
|
val snapshotPort = FakeAudioRankingQuerySnapshotPort()
|
||||||
|
val member = Mockito.mock(Member::class.java)
|
||||||
|
snapshotPort.latestSnapshots = listOf(snapshot(contentId = 1L, rank = 1, isAdult = true))
|
||||||
|
|
||||||
|
val result = service(snapshotPort, adultMember = member).getRankings(AudioRankingType.REVENUE, member)
|
||||||
|
|
||||||
|
assertEquals(listOf(1L), result.items.map { it.contentId })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun service(
|
||||||
|
snapshotPort: FakeAudioRankingQuerySnapshotPort,
|
||||||
|
adultMember: Member? = null
|
||||||
|
): AudioRankingQueryService {
|
||||||
|
val memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
|
||||||
|
if (adultMember != null) {
|
||||||
|
Mockito.doReturn(true).`when`(memberContentPreferenceService).canViewAdultContent(adultMember)
|
||||||
|
}
|
||||||
|
return AudioRankingQueryService(
|
||||||
|
snapshotPort = snapshotPort,
|
||||||
|
memberContentPreferenceService = memberContentPreferenceService,
|
||||||
|
nowProvider = { ZonedDateTime.of(2026, 6, 8, 9, 0, 0, 0, ZoneId.of("Asia/Seoul")) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun snapshot(
|
||||||
|
contentId: Long,
|
||||||
|
rank: Int,
|
||||||
|
rankingType: AudioRankingType = AudioRankingType.REVENUE,
|
||||||
|
isAdult: Boolean = false
|
||||||
|
): AudioRankingSnapshotRecord {
|
||||||
|
return AudioRankingSnapshotRecord(
|
||||||
|
rankingType = rankingType,
|
||||||
|
aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0),
|
||||||
|
aggregationEndAtUtc = LocalDateTime.of(2026, 6, 7, 15, 0),
|
||||||
|
visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0),
|
||||||
|
contentId = contentId,
|
||||||
|
title = "audio-$contentId",
|
||||||
|
creatorMemberId = 100L + contentId,
|
||||||
|
creatorNickname = "creator-$contentId",
|
||||||
|
coverImageUrl = "cover-$contentId.png",
|
||||||
|
releaseDate = LocalDateTime.of(2026, 6, 1, 0, 0),
|
||||||
|
isAdult = isAdult,
|
||||||
|
rank = rank,
|
||||||
|
finalScore = (100 - rank).toDouble()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeAudioRankingQuerySnapshotPort : AudioRankingSnapshotPort {
|
||||||
|
var latestSnapshots: List<AudioRankingSnapshotRecord> = emptyList()
|
||||||
|
var previousSnapshots: List<AudioRankingSnapshotRecord> = emptyList()
|
||||||
|
var nowUtc: LocalDateTime? = null
|
||||||
|
var currentAggregationStartAtUtc: LocalDateTime? = null
|
||||||
|
|
||||||
|
override fun findLatestVisibleSnapshots(
|
||||||
|
rankingType: AudioRankingType,
|
||||||
|
nowUtc: LocalDateTime
|
||||||
|
): List<AudioRankingSnapshotRecord> {
|
||||||
|
this.nowUtc = nowUtc
|
||||||
|
return latestSnapshots
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findPreviousVisibleSnapshots(
|
||||||
|
rankingType: AudioRankingType,
|
||||||
|
currentAggregationStartAtUtc: LocalDateTime,
|
||||||
|
nowUtc: LocalDateTime
|
||||||
|
): List<AudioRankingSnapshotRecord> {
|
||||||
|
this.currentAggregationStartAtUtc = currentAggregationStartAtUtc
|
||||||
|
return previousSnapshots
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun replaceSnapshots(
|
||||||
|
rankingType: AudioRankingType,
|
||||||
|
aggregationStartAtUtc: LocalDateTime,
|
||||||
|
aggregationEndAtUtc: LocalDateTime,
|
||||||
|
visibleFromAtUtc: LocalDateTime,
|
||||||
|
newSnapshots: List<AudioRankingSnapshotRecord>
|
||||||
|
) = error("Query service test does not replace snapshots")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user