From 7ec19e3c8cc56cec2d7a76ad72f18fc69e22cc5e Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 24 Jun 2026 19:02:39 +0900 Subject: [PATCH] =?UTF-8?q?feat(content-ranking):=20=EB=9E=AD=ED=82=B9=20?= =?UTF-8?q?=EC=8A=A4=EB=83=85=EC=83=B7=20=EC=8A=A4=EC=BC=80=EC=A4=84?= =?UTF-8?q?=EB=9F=AC=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 --- .../AudioRankingSnapshotScheduler.kt | 59 ++++++++++++++++ .../AudioRankingSnapshotSchedulerTest.kt | 68 +++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/scheduler/AudioRankingSnapshotScheduler.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/scheduler/AudioRankingSnapshotSchedulerTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/scheduler/AudioRankingSnapshotScheduler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/scheduler/AudioRankingSnapshotScheduler.kt new file mode 100644 index 00000000..8adfafc7 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/scheduler/AudioRankingSnapshotScheduler.kt @@ -0,0 +1,59 @@ +package kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.scheduler + +import kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotJobService +import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType +import org.redisson.api.RedissonClient +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import java.util.concurrent.TimeUnit + +@Component +class AudioRankingSnapshotScheduler( + private val jobService: AudioRankingSnapshotJobService, + private val redissonClient: RedissonClient +) { + @Scheduled(cron = "0 0 2 * * MON", zone = "Asia/Seoul") + fun refreshWeeklyPopular() { + refresh(AudioRankingType.WEEKLY_POPULAR) + } + + @Scheduled(cron = "0 0 3 * * MON", zone = "Asia/Seoul") + fun refreshRising() { + refresh(AudioRankingType.RISING) + } + + @Scheduled(cron = "0 0 4 * * MON", zone = "Asia/Seoul") + fun refreshRevenue() { + refresh(AudioRankingType.REVENUE) + } + + @Scheduled(cron = "0 0 5 * * MON", zone = "Asia/Seoul") + fun refreshSalesCount() { + refresh(AudioRankingType.SALES_COUNT) + } + + @Scheduled(cron = "0 0 6 * * MON", zone = "Asia/Seoul") + fun refreshCommentCount() { + refresh(AudioRankingType.COMMENT_COUNT) + } + + @Scheduled(cron = "0 0 7 * * MON", zone = "Asia/Seoul") + fun refreshLikeCount() { + refresh(AudioRankingType.LIKE_COUNT) + } + + private fun refresh(type: AudioRankingType) { + val lockName = "lock:content-ranking-snapshot-refresh:$type" + val lock = redissonClient.getLock(lockName) + + try { + if (lock.tryLock(0, -1, TimeUnit.SECONDS)) { + jobService.refreshLastCompletedWeekByScheduledJob(type) + } + } finally { + if (lock.isHeldByCurrentThread) { + lock.unlock() + } + } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/scheduler/AudioRankingSnapshotSchedulerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/scheduler/AudioRankingSnapshotSchedulerTest.kt new file mode 100644 index 00000000..1c16cd46 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/scheduler/AudioRankingSnapshotSchedulerTest.kt @@ -0,0 +1,68 @@ +package kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.scheduler + +import kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotJobService +import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.redisson.api.RLock +import org.redisson.api.RedissonClient +import org.springframework.scheduling.annotation.Scheduled +import java.util.concurrent.TimeUnit + +class AudioRankingSnapshotSchedulerTest { + @Test + fun shouldHaveDistributedMondayKstCronByRankingType() { + assertSchedule("refreshWeeklyPopular", "0 0 2 * * MON") + assertSchedule("refreshRising", "0 0 3 * * MON") + assertSchedule("refreshRevenue", "0 0 4 * * MON") + assertSchedule("refreshSalesCount", "0 0 5 * * MON") + assertSchedule("refreshCommentCount", "0 0 6 * * MON") + assertSchedule("refreshLikeCount", "0 0 7 * * MON") + } + + @Test + fun shouldCallJobServiceOnlyWhenTypeLockAcquired() { + val jobService = Mockito.mock(AudioRankingSnapshotJobService::class.java) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val lock = Mockito.mock(RLock::class.java) + Mockito.`when`(redissonClient.getLock("lock:content-ranking-snapshot-refresh:REVENUE")).thenReturn(lock) + Mockito.`when`(lock.tryLock(0, -1, TimeUnit.SECONDS)).thenReturn(true) + Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true) + val scheduler = AudioRankingSnapshotScheduler(jobService, redissonClient) + + scheduler.refreshRevenue() + + Mockito.verify(redissonClient).getLock("lock:content-ranking-snapshot-refresh:REVENUE") + Mockito.verify(lock).tryLock(0, -1, TimeUnit.SECONDS) + Mockito.verify(jobService).refreshLastCompletedWeekByScheduledJob(AudioRankingType.REVENUE) + Mockito.verify(lock).unlock() + } + + @Test + fun shouldSkipJobServiceWhenTypeLockIsNotAcquired() { + val jobService = Mockito.mock(AudioRankingSnapshotJobService::class.java) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val lock = Mockito.mock(RLock::class.java) + Mockito.`when`(redissonClient.getLock("lock:content-ranking-snapshot-refresh:RISING")).thenReturn(lock) + Mockito.`when`(lock.tryLock(0, -1, TimeUnit.SECONDS)).thenReturn(false) + Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(false) + val scheduler = AudioRankingSnapshotScheduler(jobService, redissonClient) + + scheduler.refreshRising() + + Mockito.verify(redissonClient).getLock("lock:content-ranking-snapshot-refresh:RISING") + Mockito.verify(lock).tryLock(0, -1, TimeUnit.SECONDS) + Mockito.verify(jobService, Mockito.never()).refreshLastCompletedWeekByScheduledJob(AudioRankingType.RISING) + Mockito.verify(lock, Mockito.never()).unlock() + } + + private fun assertSchedule(methodName: String, cron: String) { + val scheduled = AudioRankingSnapshotScheduler::class.java + .getDeclaredMethod(methodName) + .getAnnotation(Scheduled::class.java) + + assertEquals(cron, scheduled.cron) + assertEquals("Asia/Seoul", scheduled.zone) + } +}