From 91f0d20a795fc78c5efe94a64d433ccb75879b0f Mon Sep 17 00:00:00 2001 From: Klaus Date: Sun, 12 Jul 2026 22:44:14 +0900 Subject: [PATCH] =?UTF-8?q?fix(content):=20=EC=98=A4=EB=94=94=EC=98=A4=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=20=EC=A1=B0=ED=9A=8C=20fallback=EC=9D=84=20?= =?UTF-8?q?=EC=84=B9=EC=85=98=EB=B3=84=EB=A1=9C=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AudioRecommendationQueryService.kt | 74 +++---- .../AudioRecommendationQueryServiceTest.kt | 187 +++++++++--------- 2 files changed, 122 insertions(+), 139 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt index b01a875f..5f72de82 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt @@ -7,12 +7,8 @@ import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommenda import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendations import kr.co.vividnext.sodalive.v2.content.recommendation.port.out.AudioRecommendationQueryPort import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType -import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord -import org.redisson.api.RedissonClient import org.springframework.stereotype.Service -import java.time.Duration -import java.time.LocalDate import java.time.LocalDateTime import java.time.ZoneId @@ -20,30 +16,31 @@ import java.time.ZoneId class AudioRecommendationQueryService( private val queryPort: AudioRecommendationQueryPort, private val memberContentPreferenceService: MemberContentPreferenceService, - private val snapshotPort: RecommendationSnapshotPort, - private val snapshotRefreshService: AudioRecommendationSnapshotRefreshService, - private val redissonClient: RedissonClient + private val snapshotFallbackService: AudioRecommendationSnapshotFallbackService ) { fun getRecommendations(member: Member?): AudioRecommendations { val now = LocalDateTime.now() + val fallbackNow = LocalDateTime.now(KST_ZONE) val canViewAdultContent = canViewAdultContent(member) val visibility = if (canViewAdultContent) AudioRecommendationVisibility.ALL else AudioRecommendationVisibility.SAFE val memberId = member?.id val newAndHotSectionType = newAndHotSectionType(visibility) - val newAndHotSnapshots = snapshotPort.findLatestSnapshots(newAndHotSectionType, limit = NEW_AND_HOT_HOME_LIMIT) - val mostCommentedSnapshots = snapshotPort.findLatestSnapshots( - mostCommentedSectionType(visibility), - limit = MOST_COMMENTED_AUDIO_LIMIT - ) - val recommendedSnapshots = snapshotPort.findLatestSnapshots( - recommendedAudioSectionType(visibility), - limit = RECOMMENDED_AUDIO_LIMIT - ) - val refreshedNewAndHotSnapshots = refreshMissingNewAndHotSnapshots( + val mostCommentedSectionType = mostCommentedSectionType(visibility) + val recommendedAudioSectionType = recommendedAudioSectionType(visibility) + val newAndHotSnapshots = findSnapshotsWithFallback( newAndHotSectionType, - newAndHotSnapshots, - offset = 0, - limit = NEW_AND_HOT_HOME_LIMIT + limit = NEW_AND_HOT_HOME_LIMIT, + now = fallbackNow + ) + val mostCommentedSnapshots = findSnapshotsWithFallback( + mostCommentedSectionType, + limit = MOST_COMMENTED_AUDIO_LIMIT, + now = fallbackNow + ) + val recommendedSnapshots = findSnapshotsWithFallback( + recommendedAudioSectionType, + limit = RECOMMENDED_AUDIO_LIMIT, + now = fallbackNow ) return AudioRecommendations( @@ -51,7 +48,7 @@ class AudioRecommendationQueryService( originalSeries = queryPort.findOriginalSeries(ORIGINAL_SERIES_LIMIT, memberId, canViewAdultContent, now), latestAudios = queryPort.findLatestAudios(LATEST_AUDIO_LIMIT, memberId, canViewAdultContent, now), newAndHotAudios = queryPort.findAudioCardsByIds( - refreshedNewAndHotSnapshots.map { it.targetId }, + newAndHotSnapshots.map { it.targetId }, memberId, canViewAdultContent, now @@ -74,14 +71,14 @@ class AudioRecommendationQueryService( fun findNewAndHotAudios(member: Member, offset: Long, limit: Int): List { val now = LocalDateTime.now() + val fallbackNow = LocalDateTime.now(KST_ZONE) val canViewAdultContent = canViewAdultContent(member) val visibility = if (canViewAdultContent) AudioRecommendationVisibility.ALL else AudioRecommendationVisibility.SAFE val sectionType = newAndHotSectionType(visibility) - val snapshots = snapshotPort.findLatestSnapshots(sectionType, offset, limit) - val refreshedSnapshots = refreshMissingNewAndHotSnapshots(sectionType, snapshots, offset, limit) + val snapshots = findSnapshotsWithFallback(sectionType, offset, limit, fallbackNow) return queryPort.findAudioCardsByIds( - refreshedSnapshots.map { it.targetId }, + snapshots.map { it.targetId }, member.id, canViewAdultContent, now @@ -113,29 +110,13 @@ class AudioRecommendationQueryService( } } - private fun refreshMissingNewAndHotSnapshots( + private fun findSnapshotsWithFallback( sectionType: RecommendedSectionType, - snapshots: List, - offset: Long, - limit: Int + offset: Long = 0, + limit: Int, + now: LocalDateTime ): List { - if (snapshots.isNotEmpty()) return snapshots - val today = LocalDate.now(KST_ZONE) - val marker = redissonClient.getBucket(newAndHotLazyRefreshMarkerKey(today)) - if (!marker.setIfAbsent(LAZY_REFRESH_ATTEMPTED_VALUE, LAZY_REFRESH_MARKER_TTL)) { - return snapshots - } - runCatching { - snapshotRefreshService.refreshDailySnapshots() - }.onFailure { ex -> - marker.delete() - throw ex - } - return snapshotPort.findLatestSnapshots(sectionType, offset, limit) - } - - private fun newAndHotLazyRefreshMarkerKey(date: LocalDate): String { - return "$LAZY_REFRESH_MARKER_KEY_PREFIX:$date" + return snapshotFallbackService.refreshIfMissing(sectionType, offset, limit, now) } private fun canViewAdultContent(member: Member?): Boolean { @@ -152,9 +133,6 @@ class AudioRecommendationQueryService( const val NEW_AND_HOT_HOME_LIMIT = 12 const val MOST_COMMENTED_AUDIO_LIMIT = 5 const val RECOMMENDED_AUDIO_LIMIT = 20 - private const val LAZY_REFRESH_MARKER_KEY_PREFIX = "audio-recommendation:new-and-hot:lazy-refresh-attempted" - private const val LAZY_REFRESH_ATTEMPTED_VALUE = "1" - private val LAZY_REFRESH_MARKER_TTL: Duration = Duration.ofDays(2) private val KST_ZONE: ZoneId = ZoneId.of("Asia/Seoul") } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt index ce8f0180..1f258021 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt @@ -7,30 +7,21 @@ import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationVisibility import kr.co.vividnext.sodalive.v2.content.recommendation.port.out.AudioRecommendationQueryPort import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType -import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.mockito.Mockito -import org.redisson.api.RBucket -import org.redisson.api.RedissonClient -import java.time.Duration import java.time.LocalDateTime class AudioRecommendationQueryServiceTest { private val queryPort = Mockito.mock(AudioRecommendationQueryPort::class.java) private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java) - private val snapshotPort = Mockito.mock(RecommendationSnapshotPort::class.java) - private val refreshService = Mockito.mock(AudioRecommendationSnapshotRefreshService::class.java) - private val redissonClient = Mockito.mock(RedissonClient::class.java) - private val lazyRefreshMarker = Mockito.mock(RBucket::class.java) as RBucket + private val fallbackService = Mockito.mock(AudioRecommendationSnapshotFallbackService::class.java) private val service = AudioRecommendationQueryService( queryPort, preferenceService, - snapshotPort, - refreshService, - redissonClient + fallbackService ) @Test @@ -40,8 +31,8 @@ class AudioRecommendationQueryServiceTest { } @Test - @DisplayName("조회 서비스는 SAFE 스냅샷을 lazy refresh 후 상세 섹션으로 조립한다") - fun shouldBuildRecommendationsFromSafeSnapshotsWithLazyRefresh() { + @DisplayName("조회 서비스는 SAFE 스냅샷 기반 3개 섹션을 fallback 경로로 조립한다") + fun shouldBuildRecommendationsFromSafeSnapshotsWithFallback() { val snapshot = RecommendationSnapshotRecord( sectionType = RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, targetId = 1L, @@ -49,39 +40,39 @@ class AudioRecommendationQueryServiceTest { snapshotAt = LocalDateTime.now(), randomTieBreaker = 1.0 ) - Mockito.doReturn(emptyList(), listOf(snapshot)) - .`when`(snapshotPort) - .findLatestSnapshots( - RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, - 0, - AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT - ) - Mockito.doReturn(emptyList()) - .`when`(snapshotPort) - .findLatestSnapshots( - RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, - 0, - AudioRecommendationQueryService.MOST_COMMENTED_AUDIO_LIMIT - ) - Mockito.doReturn(emptyList()) - .`when`(snapshotPort) - .findLatestSnapshots( - RecommendedSectionType.RECOMMENDED_AUDIO_SAFE, - 0, - AudioRecommendationQueryService.RECOMMENDED_AUDIO_LIMIT - ) - allowLazyRefreshOnce() + stubFallback( + RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, + 0, + AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT, + listOf(snapshot) + ) + stubFallback( + RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, + 0, + AudioRecommendationQueryService.MOST_COMMENTED_AUDIO_LIMIT, + emptyList() + ) + stubFallback( + RecommendedSectionType.RECOMMENDED_AUDIO_SAFE, + 0, + AudioRecommendationQueryService.RECOMMENDED_AUDIO_LIMIT, + emptyList() + ) val recommendations = service.getRecommendations(null) assertEquals(0, recommendations.mostCommentedAudios.size) - Mockito.verify(refreshService).refreshDailySnapshots() - Mockito.verify(snapshotPort, Mockito.times(1)).findLatestSnapshots( + verifyFallback( + RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, + 0, + AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT + ) + verifyFallback( RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, 0, AudioRecommendationQueryService.MOST_COMMENTED_AUDIO_LIMIT ) - Mockito.verify(snapshotPort, Mockito.times(1)).findLatestSnapshots( + verifyFallback( RecommendedSectionType.RECOMMENDED_AUDIO_SAFE, 0, AudioRecommendationQueryService.RECOMMENDED_AUDIO_LIMIT @@ -114,35 +105,34 @@ class AudioRecommendationQueryServiceTest { } @Test - @DisplayName("New & Hot lazy refresh는 보강 후에도 비어 있으면 같은 KST 날짜에 다시 실행하지 않는다") - fun shouldAttemptEmptyNewAndHotLazyRefreshOncePerKstDate() { - Mockito.doReturn(emptyList()) - .`when`(snapshotPort) - .findLatestSnapshots( - RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, - 0, - AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT - ) - Mockito.doReturn(emptyList()) - .`when`(snapshotPort) - .findLatestSnapshots( - RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, - 0, - AudioRecommendationQueryService.MOST_COMMENTED_AUDIO_LIMIT - ) - Mockito.doReturn(emptyList()) - .`when`(snapshotPort) - .findLatestSnapshots( - RecommendedSectionType.RECOMMENDED_AUDIO_SAFE, - 0, - AudioRecommendationQueryService.RECOMMENDED_AUDIO_LIMIT - ) - allowLazyRefreshOnce() + @DisplayName("Most Commented만 비어 있어도 해당 섹션 fallback을 독립적으로 호출한다") + fun shouldFallbackMostCommentedIndependentlyWhenNewAndHotExists() { + stubFallback( + RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, + 0, + AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT, + listOf(snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, 1L)) + ) + stubFallback( + RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, + 0, + AudioRecommendationQueryService.MOST_COMMENTED_AUDIO_LIMIT, + emptyList() + ) + stubFallback( + RecommendedSectionType.RECOMMENDED_AUDIO_SAFE, + 0, + AudioRecommendationQueryService.RECOMMENDED_AUDIO_LIMIT, + listOf(snapshot(RecommendedSectionType.RECOMMENDED_AUDIO_SAFE, 2L)) + ) - service.getRecommendations(null) service.getRecommendations(null) - Mockito.verify(refreshService, Mockito.times(1)).refreshDailySnapshots() + verifyFallback( + RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, + 0, + AudioRecommendationQueryService.MOST_COMMENTED_AUDIO_LIMIT + ) } @Test @@ -150,19 +140,18 @@ class AudioRecommendationQueryServiceTest { fun shouldUseStoredPreferenceForMemberAdultVisibility() { val member = member(id = 10L) Mockito.doReturn(true).`when`(preferenceService).canViewAdultContent(member) - Mockito.doReturn(listOf(snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 10L))) - .`when`(snapshotPort) - .findLatestSnapshots( - RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, - 0, - AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT - ) + stubFallback( + RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, + 0, + AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT, + listOf(snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 10L)) + ) service.getRecommendations(member) Mockito.verify(preferenceService).canViewAdultContent(member) Mockito.verify(preferenceService, Mockito.never()).initializeDefaultPreference(member) - Mockito.verify(snapshotPort).findLatestSnapshots( + verifyFallback( RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 0, AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT @@ -174,16 +163,16 @@ class AudioRecommendationQueryServiceTest { fun shouldKeepNewAndHotHomeLimitAtTwelve() { val member = member(id = 10L) Mockito.doReturn(true).`when`(preferenceService).canViewAdultContent(member) - Mockito.doReturn(listOf(snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 10L))).`when`(snapshotPort) - .findLatestSnapshots( - RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, - 0, - AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT - ) + stubFallback( + RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, + 0, + AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT, + listOf(snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 10L)) + ) service.getRecommendations(member) - Mockito.verify(snapshotPort).findLatestSnapshots( + verifyFallback( RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 0, AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT @@ -200,15 +189,19 @@ class AudioRecommendationQueryServiceTest { snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 5L) ) Mockito.doReturn(true).`when`(preferenceService).canViewAdultContent(member) - Mockito.doReturn(snapshots).`when`(snapshotPort) - .findLatestSnapshots(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 20L, 21) + stubFallback(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 20L, 21, snapshots) Mockito.doReturn(listOf(audioCard(3L), audioCard(4L), audioCard(5L))).`when`(queryPort) - .findAudioCardsByIds(eqValue(listOf(3L, 4L, 5L)), eqValue(member.id), eqValue(true), anyLocalDateTime()) + .findAudioCardsByIds( + eqValue(listOf(3L, 4L, 5L)), + eqValue(member.id), + eqValue(true), + anyLocalDateTime() + ) val result = service.findNewAndHotAudios(member, offset = 20L, limit = 21) assertEquals(listOf(3L, 4L, 5L), result.map { it.audioContentId }) - Mockito.verify(snapshotPort).findLatestSnapshots(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 20L, 21) + verifyFallback(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 20L, 21) } @Test @@ -280,15 +273,27 @@ class AudioRecommendationQueryServiceTest { ) } + private fun stubFallback( + sectionType: RecommendedSectionType, + offset: Long, + limit: Int, + snapshots: List + ) { + Mockito.doReturn(snapshots) + .`when`(fallbackService) + .refreshIfMissing(eqValue(sectionType), eqValue(offset), eqValue(limit), anyLocalDateTime()) + } + + private fun verifyFallback(sectionType: RecommendedSectionType, offset: Long, limit: Int) { + Mockito.verify(fallbackService).refreshIfMissing( + eqValue(sectionType), + eqValue(offset), + eqValue(limit), + anyLocalDateTime() + ) + } + private fun eqValue(value: T): T { return Mockito.eq(value) ?: value } - - private fun allowLazyRefreshOnce() { - Mockito.doReturn(lazyRefreshMarker).`when`(redissonClient).getBucket(Mockito.anyString()) - Mockito.doReturn(true, false).`when`(lazyRefreshMarker).setIfAbsent( - eqValue("1"), - eqValue(Duration.ofDays(2)) - ) - } }