From f34962b285ccec012c669cba554a17bcc32b6219 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 24 Jun 2026 16:23:18 +0900 Subject: [PATCH] =?UTF-8?q?feat(content-ranking):=20=EC=8A=A4=EB=83=85?= =?UTF-8?q?=EC=83=B7=20=EA=B8=B0=EB=B0=98=20=EB=9E=AD=ED=82=B9=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/AudioRankingQueryService.kt | 69 ++++++- .../AudioRankingQueryServiceTest.kt | 168 ++++++++++++++++++ 2 files changed, 234 insertions(+), 3 deletions(-) create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryServiceTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryService.kt index 144fcae2..209fe6bb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryService.kt @@ -1,17 +1,80 @@ 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.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.port.out.AudioRankingSnapshotPort +import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotRecord import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.ZoneOffset +import java.time.ZonedDateTime @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 { + 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( - showRankChange = false, + showRankChange = showRankChange, 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.visibleTo(canViewAdultContent: Boolean): List { + return if (canViewAdultContent) this else filter { !it.isAdult } + } + + private fun AudioRankingSnapshotRecord.toItem( + rank: Int, + showRankChange: Boolean, + previousRankByContentId: Map + ): 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 + } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryServiceTest.kt new file mode 100644 index 00000000..65ab6910 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryServiceTest.kt @@ -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(), 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 = emptyList() + var previousSnapshots: List = emptyList() + var nowUtc: LocalDateTime? = null + var currentAggregationStartAtUtc: LocalDateTime? = null + + override fun findLatestVisibleSnapshots( + rankingType: AudioRankingType, + nowUtc: LocalDateTime + ): List { + this.nowUtc = nowUtc + return latestSnapshots + } + + override fun findPreviousVisibleSnapshots( + rankingType: AudioRankingType, + currentAggregationStartAtUtc: LocalDateTime, + nowUtc: LocalDateTime + ): List { + this.currentAggregationStartAtUtc = currentAggregationStartAtUtc + return previousSnapshots + } + + override fun replaceSnapshots( + rankingType: AudioRankingType, + aggregationStartAtUtc: LocalDateTime, + aggregationEndAtUtc: LocalDateTime, + visibleFromAtUtc: LocalDateTime, + newSnapshots: List + ) = error("Query service test does not replace snapshots") +}