diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationSnapshotRefreshService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationSnapshotRefreshService.kt new file mode 100644 index 00000000..c6bae747 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationSnapshotRefreshService.kt @@ -0,0 +1,136 @@ +package kr.co.vividnext.sodalive.v2.audio.recommendation.application + +import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendationVisibility +import kr.co.vividnext.sodalive.v2.audio.recommendation.port.out.AudioRecommendationQueryPort +import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType +import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZonedDateTime + +@Service +class AudioRecommendationSnapshotRefreshService( + private val snapshotPort: RecommendationSnapshotPort, + private val queryPort: AudioRecommendationQueryPort +) { + private val log = LoggerFactory.getLogger(javaClass) + + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun refreshDailySnapshots() { + refreshDailySnapshots(ZonedDateTime.now(KST_ZONE)) + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun refreshDailySnapshots(now: LocalDateTime) { + refreshDailySnapshots(now.atZone(KST_ZONE)) + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun refreshDailySnapshots(now: ZonedDateTime) { + val startedAt = System.currentTimeMillis() + val snapshotAt = snapshotAt(now) + val newAndHotWindowStart = windowStart(snapshotAt, days = 3) + val mostCommentedWindowStart = windowStart(snapshotAt, days = 7) + val recommendedWindowStart = mostCommentedWindowStart + + runCatching { + replaceNewAndHotSnapshots(newAndHotWindowStart, snapshotAt, AudioRecommendationVisibility.SAFE) + replaceNewAndHotSnapshots(newAndHotWindowStart, snapshotAt, AudioRecommendationVisibility.ALL) + replaceMostCommentedSnapshots(mostCommentedWindowStart, snapshotAt, AudioRecommendationVisibility.SAFE) + replaceMostCommentedSnapshots(mostCommentedWindowStart, snapshotAt, AudioRecommendationVisibility.ALL) + replaceRecommendedAudioSnapshots(recommendedWindowStart, snapshotAt, AudioRecommendationVisibility.SAFE) + replaceRecommendedAudioSnapshots(recommendedWindowStart, snapshotAt, AudioRecommendationVisibility.ALL) + }.onSuccess { + log.info( + "event=audio_recommendation_snapshot_refresh_success snapshotAt={} elapsedMs={}", + snapshotAt, + System.currentTimeMillis() - startedAt + ) + }.onFailure { ex -> + log.warn( + "event=audio_recommendation_snapshot_refresh_failure snapshotAt={} elapsedMs={} error={}", + snapshotAt, + System.currentTimeMillis() - startedAt, + ex.message, + ex + ) + throw ex + } + } + + private fun replaceNewAndHotSnapshots( + windowStart: LocalDateTime, + snapshotAt: LocalDateTime, + visibility: AudioRecommendationVisibility + ) { + val sectionType = visibility.newAndHotSectionType() + val snapshots = queryPort.findNewAndHotSnapshots(windowStart, snapshotAt, visibility, NEW_AND_HOT_LIMIT) + snapshotPort.replaceSnapshots(sectionType, snapshotAt, snapshots) + } + + private fun replaceMostCommentedSnapshots( + windowStart: LocalDateTime, + snapshotAt: LocalDateTime, + visibility: AudioRecommendationVisibility + ) { + val sectionType = visibility.mostCommentedSectionType() + val snapshots = queryPort.findMostCommentedSnapshots(windowStart, snapshotAt, visibility, MOST_COMMENTED_LIMIT) + snapshotPort.replaceSnapshots(sectionType, snapshotAt, snapshots) + } + + private fun replaceRecommendedAudioSnapshots( + windowStart: LocalDateTime, + snapshotAt: LocalDateTime, + visibility: AudioRecommendationVisibility + ) { + val sectionType = visibility.recommendedAudioSectionType() + val snapshots = queryPort.findRecommendedAudioSnapshots(windowStart, snapshotAt, visibility, RECOMMENDED_AUDIO_LIMIT) + snapshotPort.replaceSnapshots(sectionType, snapshotAt, snapshots) + } + + private fun snapshotAt(now: ZonedDateTime): LocalDateTime { + val nowKst = now + .withZoneSameInstant(KST_ZONE) + return nowKst.toLocalDate() + .minusDays(1) + .atTime(23, 59, 59) + } + + private fun windowStart(snapshotAt: LocalDateTime, days: Long): LocalDateTime { + return snapshotAt.toLocalDate() + .minusDays(days - 1) + .atStartOfDay() + } + + private fun AudioRecommendationVisibility.newAndHotSectionType(): RecommendedSectionType { + return when (this) { + AudioRecommendationVisibility.SAFE -> RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE + AudioRecommendationVisibility.ALL -> RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL + } + } + + private fun AudioRecommendationVisibility.mostCommentedSectionType(): RecommendedSectionType { + return when (this) { + AudioRecommendationVisibility.SAFE -> RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE + AudioRecommendationVisibility.ALL -> RecommendedSectionType.MOST_COMMENTED_AUDIO_ALL + } + } + + private fun AudioRecommendationVisibility.recommendedAudioSectionType(): RecommendedSectionType { + return when (this) { + AudioRecommendationVisibility.SAFE -> RecommendedSectionType.RECOMMENDED_AUDIO_SAFE + AudioRecommendationVisibility.ALL -> RecommendedSectionType.RECOMMENDED_AUDIO_ALL + } + } + + companion object { + const val NEW_AND_HOT_LIMIT = 12 + const val MOST_COMMENTED_LIMIT = 5 + const val RECOMMENDED_AUDIO_LIMIT = 10 + private val KST_ZONE: ZoneId = ZoneId.of("Asia/Seoul") + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationSnapshotRefreshServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationSnapshotRefreshServiceTest.kt new file mode 100644 index 00000000..338128b8 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationSnapshotRefreshServiceTest.kt @@ -0,0 +1,84 @@ +package kr.co.vividnext.sodalive.v2.audio.recommendation.application + +import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendationVisibility +import kr.co.vividnext.sodalive.v2.audio.recommendation.port.out.AudioRecommendationQueryPort +import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType +import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZonedDateTime + +class AudioRecommendationSnapshotRefreshServiceTest { + private val snapshotPort = Mockito.mock(RecommendationSnapshotPort::class.java) + private val queryPort = Mockito.mock(AudioRecommendationQueryPort::class.java) + private val service = AudioRecommendationSnapshotRefreshService(snapshotPort, queryPort) + + @Test + @DisplayName("일 배치는 KST 전날 23:59:59 기준으로 여섯 오디오 스냅샷을 교체한다") + fun shouldRefreshAllAudioSnapshotsWithKstPreviousDaySnapshotAt() { + val now = LocalDateTime.of(2026, 6, 24, 0, 0) + val snapshotAt = LocalDateTime.of(2026, 6, 23, 23, 59, 59) + val newAndHotWindowStart = LocalDateTime.of(2026, 6, 21, 0, 0) + val mostCommentedWindowStart = LocalDateTime.of(2026, 6, 17, 0, 0) + + service.refreshDailySnapshots(now) + + Mockito.verify(queryPort).findNewAndHotSnapshots( + newAndHotWindowStart, + snapshotAt, + AudioRecommendationVisibility.SAFE, + AudioRecommendationSnapshotRefreshService.NEW_AND_HOT_LIMIT + ) + Mockito.verify(queryPort).findMostCommentedSnapshots( + mostCommentedWindowStart, + snapshotAt, + AudioRecommendationVisibility.ALL, + AudioRecommendationSnapshotRefreshService.MOST_COMMENTED_LIMIT + ) + Mockito.verify(queryPort).findRecommendedAudioSnapshots( + mostCommentedWindowStart, + snapshotAt, + AudioRecommendationVisibility.SAFE, + AudioRecommendationSnapshotRefreshService.RECOMMENDED_AUDIO_LIMIT + ) + Mockito.verify(snapshotPort).replaceSnapshots(RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, snapshotAt, emptyList()) + Mockito.verify(snapshotPort).replaceSnapshots(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, snapshotAt, emptyList()) + Mockito.verify(snapshotPort).replaceSnapshots(RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, snapshotAt, emptyList()) + Mockito.verify(snapshotPort).replaceSnapshots(RecommendedSectionType.MOST_COMMENTED_AUDIO_ALL, snapshotAt, emptyList()) + Mockito.verify(snapshotPort).replaceSnapshots(RecommendedSectionType.RECOMMENDED_AUDIO_SAFE, snapshotAt, emptyList()) + Mockito.verify(snapshotPort).replaceSnapshots(RecommendedSectionType.RECOMMENDED_AUDIO_ALL, snapshotAt, emptyList()) + } + + @Test + @DisplayName("일 배치는 ZonedDateTime 입력의 zone과 무관하게 KST 날짜 경계 기준으로 스냅샷 시각을 계산한다") + fun shouldRefreshSnapshotsByKstBoundaryFromZonedDateTime() { + val now = ZonedDateTime.of(2026, 6, 24, 0, 0, 0, 0, ZoneId.of("Asia/Seoul")) + val snapshotAt = LocalDateTime.of(2026, 6, 23, 23, 59, 59) + val newAndHotWindowStart = LocalDateTime.of(2026, 6, 21, 0, 0) + val mostCommentedWindowStart = LocalDateTime.of(2026, 6, 17, 0, 0) + + service.refreshDailySnapshots(now) + + Mockito.verify(queryPort).findNewAndHotSnapshots( + newAndHotWindowStart, + snapshotAt, + AudioRecommendationVisibility.SAFE, + AudioRecommendationSnapshotRefreshService.NEW_AND_HOT_LIMIT + ) + Mockito.verify(queryPort).findMostCommentedSnapshots( + mostCommentedWindowStart, + snapshotAt, + AudioRecommendationVisibility.ALL, + AudioRecommendationSnapshotRefreshService.MOST_COMMENTED_LIMIT + ) + Mockito.verify(queryPort).findRecommendedAudioSnapshots( + mostCommentedWindowStart, + snapshotAt, + AudioRecommendationVisibility.SAFE, + AudioRecommendationSnapshotRefreshService.RECOMMENDED_AUDIO_LIMIT + ) + } +}