From cf29600ad3de6fd0fa4569146de24fec789959ef Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 24 Jun 2026 19:03:12 +0900 Subject: [PATCH] =?UTF-8?q?feat(content-ranking):=20=EB=9E=AD=ED=82=B9=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20fallback=EA=B3=BC=20=EC=B0=A8=EB=8B=A8=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=EB=A5=BC=20=EC=A0=81=EC=9A=A9=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/AudioRankingQueryService.kt | 51 +++++- .../AudioRankingQueryServiceTest.kt | 155 +++++++++++++++++- 2 files changed, 195 insertions(+), 11 deletions(-) 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 209fe6bb..76e1969b 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 @@ -5,10 +5,11 @@ import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreference 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.AudioRankingBlockPort import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotPort import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotRecord +import org.slf4j.LoggerFactory import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional import java.time.ZoneOffset import java.time.ZonedDateTime @@ -16,24 +17,27 @@ import java.time.ZonedDateTime class AudioRankingQueryService( private val snapshotPort: AudioRankingSnapshotPort, private val memberContentPreferenceService: MemberContentPreferenceService, + private val blockPort: AudioRankingBlockPort, + private val jobService: AudioRankingSnapshotJobService, private val nowProvider: () -> ZonedDateTime = { ZonedDateTime.now() } ) { - @Transactional(readOnly = true) + private val log = LoggerFactory.getLogger(javaClass) + fun getRankings(type: AudioRankingType, member: Member?): AudioRanking { val nowUtc = nowProvider().withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime() - val latestSnapshots = snapshotPort.findLatestVisibleSnapshots(type, nowUtc) + val latestSnapshots = 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) + val blockedCreatorMemberIds = blockedCreatorMemberIds(member, latestSnapshots + previousSnapshots) + val latestVisibleSnapshots = latestSnapshots.visibleTo(canViewAdultContent, blockedCreatorMemberIds).take(ITEM_LIMIT) + val previousRankByContentId = previousSnapshots.visibleTo(canViewAdultContent, blockedCreatorMemberIds) .take(ITEM_LIMIT) .mapIndexed { index, snapshot -> snapshot.contentId to index + 1 } .toMap() @@ -48,13 +52,44 @@ class AudioRankingQueryService( ) } + private fun findLatestVisibleSnapshots( + type: AudioRankingType, + nowUtc: java.time.LocalDateTime + ): List { + val latestSnapshots = snapshotPort.findLatestVisibleSnapshots(type, nowUtc) + if (latestSnapshots.isNotEmpty()) return latestSnapshots + + runCatching { jobService.refreshLastCompletedWeekByFallback(type) } + .onFailure { ex -> + log.warn( + "event=audio_ranking_query_fallback_failure rankingType={} error={}", + type, + ex.message, + ex + ) + } + return snapshotPort.findLatestVisibleSnapshots(type, nowUtc) + } + 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 blockedCreatorMemberIds(member: Member?, snapshots: List): Set { + val memberId = member?.id ?: return emptySet() + val creatorMemberIds = snapshots.map { it.creatorMemberId }.toSet() + if (creatorMemberIds.isEmpty()) return emptySet() + return blockPort.findBlockedCreatorMemberIds(memberId, creatorMemberIds) + } + + private fun List.visibleTo( + canViewAdultContent: Boolean, + blockedCreatorMemberIds: Set + ): List { + return filter { snapshot -> + (canViewAdultContent || !snapshot.isAdult) && snapshot.creatorMemberId !in blockedCreatorMemberIds + } } private fun AudioRankingSnapshotRecord.toItem( 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 index 65ab6910..fd90d266 100644 --- 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 @@ -3,18 +3,35 @@ 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.AudioRankingBlockPort 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.junit.jupiter.api.extension.ExtendWith import org.mockito.Mockito +import org.springframework.boot.test.system.CapturedOutput +import org.springframework.boot.test.system.OutputCaptureExtension +import org.springframework.transaction.annotation.Transactional import java.time.LocalDateTime import java.time.ZoneId import java.time.ZonedDateTime +@ExtendWith(OutputCaptureExtension::class) class AudioRankingQueryServiceTest { + @Test + fun shouldNotWrapGetRankingsInTransactionSoFallbackRequeryUsesFreshSnapshot() { + val method = AudioRankingQueryService::class.java.getDeclaredMethod( + "getRankings", + AudioRankingType::class.java, + Member::class.java + ) + + assertEquals(null, method.getAnnotation(Transactional::class.java)) + } + @Test fun shouldReturnLatestVisibleSnapshotsWithRankChangesAndNewFlags() { val snapshotPort = FakeAudioRankingQuerySnapshotPort() @@ -96,9 +113,113 @@ class AudioRankingQueryServiceTest { assertEquals(listOf(1L), result.items.map { it.contentId }) } + @Test + fun shouldFilterBlockedCreatorSnapshotsForMemberAndRecalculateRanks() { + val snapshotPort = FakeAudioRankingQuerySnapshotPort() + val blockPort = FakeAudioRankingBlockPort(blockedCreatorMemberIds = setOf(102L)) + val member = member(id = 7L) + snapshotPort.latestSnapshots = listOf( + snapshot(contentId = 1L, rank = 1, creatorMemberId = 101L), + snapshot(contentId = 2L, rank = 2, creatorMemberId = 102L), + snapshot(contentId = 3L, rank = 3, creatorMemberId = 103L) + ) + snapshotPort.previousSnapshots = listOf( + snapshot(contentId = 2L, rank = 1, creatorMemberId = 102L), + snapshot(contentId = 1L, rank = 2, creatorMemberId = 101L) + ) + + val result = service(snapshotPort, blockPort = blockPort).getRankings(AudioRankingType.REVENUE, member) + + assertEquals(listOf(1L, 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(7L, blockPort.memberId) + assertEquals(setOf(101L, 102L, 103L), blockPort.creatorMemberIds) + } + + @Test + fun shouldNotLookupBlockedCreatorsForAnonymousViewer() { + val snapshotPort = FakeAudioRankingQuerySnapshotPort() + val blockPort = FakeAudioRankingBlockPort(blockedCreatorMemberIds = setOf(101L)) + snapshotPort.latestSnapshots = listOf(snapshot(contentId = 1L, rank = 1, creatorMemberId = 101L)) + + val result = service(snapshotPort, blockPort = blockPort).getRankings(AudioRankingType.REVENUE, member = null) + + assertEquals(listOf(1L), result.items.map { it.contentId }) + assertEquals(0, blockPort.callCount) + } + + @Test + fun shouldRunFallbackAndRequeryWhenLatestVisibleSnapshotDoesNotExist() { + val snapshotPort = FakeAudioRankingQuerySnapshotPort() + val jobService = Mockito.mock(AudioRankingSnapshotJobService::class.java) + snapshotPort.latestSnapshotsByCall = listOf( + emptyList(), + listOf(snapshot(contentId = 1L, rank = 1)) + ) + + val result = service(snapshotPort, jobService = jobService).getRankings(AudioRankingType.LIKE_COUNT, member = null) + + assertEquals(listOf(1L), result.items.map { it.contentId }) + assertEquals(2, snapshotPort.latestCallCount) + Mockito.verify(jobService).refreshLastCompletedWeekByFallback(AudioRankingType.LIKE_COUNT) + } + + @Test + fun shouldReturnEmptyRankingWhenFallbackFails(output: CapturedOutput) { + val snapshotPort = FakeAudioRankingQuerySnapshotPort() + val jobService = Mockito.mock(AudioRankingSnapshotJobService::class.java) + Mockito.doThrow(IllegalStateException("aggregate failed")) + .`when`(jobService).refreshLastCompletedWeekByFallback(AudioRankingType.LIKE_COUNT) + + val result = service(snapshotPort, jobService = jobService).getRankings(AudioRankingType.LIKE_COUNT, member = null) + + assertFalse(result.showRankChange) + assertEquals(AudioRankingType.LIKE_COUNT, result.type) + assertEquals(emptyList(), result.items) + assertTrue(output.out.contains("event=audio_ranking_query_fallback_failure")) + assertTrue(output.out.contains("rankingType=LIKE_COUNT")) + assertTrue(output.out.contains("error=aggregate failed")) + } + + @Test + fun shouldNotRunFallbackWhenLatestVisibleSnapshotExists() { + val snapshotPort = FakeAudioRankingQuerySnapshotPort() + val jobService = Mockito.mock(AudioRankingSnapshotJobService::class.java) + snapshotPort.latestSnapshots = listOf(snapshot(contentId = 1L, rank = 1)) + + service(snapshotPort, jobService = jobService).getRankings(AudioRankingType.REVENUE, member = null) + + Mockito.verify(jobService, Mockito.never()).refreshLastCompletedWeekByFallback(AudioRankingType.REVENUE) + } + + @Test + fun shouldFilterPreviousOnlyBlockedCreatorWhenCalculatingRankChanges() { + val snapshotPort = FakeAudioRankingQuerySnapshotPort() + val blockPort = FakeAudioRankingBlockPort(blockedCreatorMemberIds = setOf(999L)) + val member = member(id = 7L) + snapshotPort.latestSnapshots = listOf( + snapshot(contentId = 1L, rank = 1, creatorMemberId = 101L), + snapshot(contentId = 2L, rank = 2, creatorMemberId = 102L) + ) + snapshotPort.previousSnapshots = listOf( + snapshot(contentId = 99L, rank = 1, creatorMemberId = 999L), + snapshot(contentId = 1L, rank = 2, creatorMemberId = 101L), + snapshot(contentId = 2L, rank = 3, creatorMemberId = 102L) + ) + + val result = service(snapshotPort, blockPort = blockPort).getRankings(AudioRankingType.REVENUE, member) + + assertEquals(setOf(101L, 102L, 999L), blockPort.creatorMemberIds) + assertEquals(listOf(0, 0), result.items.map { it.rankChange }) + assertEquals(listOf(false, false), result.items.map { it.isNew }) + } + private fun service( snapshotPort: FakeAudioRankingQuerySnapshotPort, - adultMember: Member? = null + adultMember: Member? = null, + blockPort: AudioRankingBlockPort = FakeAudioRankingBlockPort(), + jobService: AudioRankingSnapshotJobService = Mockito.mock(AudioRankingSnapshotJobService::class.java) ): AudioRankingQueryService { val memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java) if (adultMember != null) { @@ -107,15 +228,22 @@ class AudioRankingQueryServiceTest { return AudioRankingQueryService( snapshotPort = snapshotPort, memberContentPreferenceService = memberContentPreferenceService, + blockPort = blockPort, + jobService = jobService, nowProvider = { ZonedDateTime.of(2026, 6, 8, 9, 0, 0, 0, ZoneId.of("Asia/Seoul")) } ) } + private fun member(id: Long): Member { + return Member(password = "password", nickname = "member-$id").also { it.id = id } + } + private fun snapshot( contentId: Long, rank: Int, rankingType: AudioRankingType = AudioRankingType.REVENUE, - isAdult: Boolean = false + isAdult: Boolean = false, + creatorMemberId: Long = 100L + contentId ): AudioRankingSnapshotRecord { return AudioRankingSnapshotRecord( rankingType = rankingType, @@ -124,7 +252,7 @@ class AudioRankingQueryServiceTest { visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0), contentId = contentId, title = "audio-$contentId", - creatorMemberId = 100L + contentId, + creatorMemberId = creatorMemberId, creatorNickname = "creator-$contentId", coverImageUrl = "cover-$contentId.png", releaseDate = LocalDateTime.of(2026, 6, 1, 0, 0), @@ -137,15 +265,21 @@ class AudioRankingQueryServiceTest { private class FakeAudioRankingQuerySnapshotPort : AudioRankingSnapshotPort { var latestSnapshots: List = emptyList() + var latestSnapshotsByCall: List> = emptyList() var previousSnapshots: List = emptyList() var nowUtc: LocalDateTime? = null var currentAggregationStartAtUtc: LocalDateTime? = null + var latestCallCount: Int = 0 override fun findLatestVisibleSnapshots( rankingType: AudioRankingType, nowUtc: LocalDateTime ): List { this.nowUtc = nowUtc + latestCallCount += 1 + if (latestSnapshotsByCall.isNotEmpty()) { + return latestSnapshotsByCall.getOrElse(latestCallCount - 1) { latestSnapshotsByCall.last() } + } return latestSnapshots } @@ -166,3 +300,18 @@ private class FakeAudioRankingQuerySnapshotPort : AudioRankingSnapshotPort { newSnapshots: List ) = error("Query service test does not replace snapshots") } + +private class FakeAudioRankingBlockPort( + private val blockedCreatorMemberIds: Set = emptySet() +) : AudioRankingBlockPort { + var memberId: Long? = null + var creatorMemberIds: Set = emptySet() + var callCount: Int = 0 + + override fun findBlockedCreatorMemberIds(memberId: Long, creatorMemberIds: Set): Set { + callCount += 1 + this.memberId = memberId + this.creatorMemberIds = creatorMemberIds + return blockedCreatorMemberIds + } +}