diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/scheduler/RecommendationSnapshotScheduler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/scheduler/RecommendationSnapshotScheduler.kt new file mode 100644 index 00000000..8075ab44 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/scheduler/RecommendationSnapshotScheduler.kt @@ -0,0 +1,15 @@ +package kr.co.vividnext.sodalive.v2.recommend.adapter.out.scheduler + +import kr.co.vividnext.sodalive.v2.recommend.application.RecommendationSnapshotRefreshService +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component + +@Component +class RecommendationSnapshotScheduler( + private val refreshService: RecommendationSnapshotRefreshService +) { + @Scheduled(cron = "0 0 6 * * *", zone = "Asia/Seoul") + fun refreshDailySnapshots() { + refreshService.refreshDailySnapshots() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendationSnapshotRefreshService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendationSnapshotRefreshService.kt new file mode 100644 index 00000000..4c93ddfe --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendationSnapshotRefreshService.kt @@ -0,0 +1,64 @@ +package kr.co.vividnext.sodalive.v2.recommend.application + +import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedSectionType +import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeRecommendationQueryPort +import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotPort +import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotRecord +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.time.ZoneId + +@Service +class RecommendationSnapshotRefreshService( + private val snapshotPort: RecommendationSnapshotPort, + private val queryPort: HomeRecommendationQueryPort +) { + @Transactional(readOnly = true) + fun getLatestSnapshots(sectionType: RecommendedSectionType): List { + return snapshotPort.findLatestSnapshots(sectionType) + } + + @Transactional + fun refreshDailySnapshots() { + refreshDailySnapshots(LocalDateTime.now()) + } + + @Transactional + fun refreshDailySnapshots(now: LocalDateTime) { + val snapshotAt = now + .atZone(UTC_ZONE) + .withZoneSameInstant(KST_ZONE) + .toLocalDate() + .minusDays(1) + .atTime(23, 59, 59) + val windowStart = snapshotAt.toLocalDate().minusDays(6).atStartOfDay() + + replaceAiCharacterSnapshots(windowStart, snapshotAt) + replaceCheerCreatorSnapshots(windowStart, snapshotAt) + replacePopularCommunitySnapshots(windowStart, snapshotAt) + } + + private fun replaceAiCharacterSnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime) { + val snapshots = queryPort.findAiCharacterSnapshots(windowStart, snapshotAt, AI_CHARACTER_SNAPSHOT_LIMIT) + snapshotPort.replaceSnapshots(RecommendedSectionType.AI_CHARACTER, snapshotAt, snapshots) + } + + private fun replaceCheerCreatorSnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime) { + val snapshots = queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, CHEER_CREATOR_SNAPSHOT_LIMIT) + snapshotPort.replaceSnapshots(RecommendedSectionType.CHEER_CREATOR, snapshotAt, snapshots) + } + + private fun replacePopularCommunitySnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime) { + val snapshots = queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, POPULAR_COMMUNITY_SNAPSHOT_LIMIT) + snapshotPort.replaceSnapshots(RecommendedSectionType.POPULAR_COMMUNITY, snapshotAt, snapshots) + } + + companion object { + private const val AI_CHARACTER_SNAPSHOT_LIMIT = 20 + private const val CHEER_CREATOR_SNAPSHOT_LIMIT = 16 + private const val POPULAR_COMMUNITY_SNAPSHOT_LIMIT = 20 + private val UTC_ZONE: ZoneId = ZoneId.of("UTC") + private val KST_ZONE: ZoneId = ZoneId.of("Asia/Seoul") + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendationSnapshotRefreshServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendationSnapshotRefreshServiceTest.kt new file mode 100644 index 00000000..a3ce5ddf --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendationSnapshotRefreshServiceTest.kt @@ -0,0 +1,197 @@ +package kr.co.vividnext.sodalive.v2.recommend.application + +import kr.co.vividnext.sodalive.v2.recommend.adapter.out.scheduler.RecommendationSnapshotScheduler +import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedSectionType +import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeRecommendationQueryPort +import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotPort +import kr.co.vividnext.sodalive.v2.recommend.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.springframework.scheduling.annotation.Scheduled +import java.time.LocalDateTime + +class RecommendationSnapshotRefreshServiceTest { + @Test + @DisplayName("섹션의 최신 기준 시각 스냅샷만 반환하고 없으면 빈 배열을 반환한다") + fun shouldReadOnlyLatestSnapshotsOrEmptyList() { + val snapshotPort = FakeRecommendationSnapshotPort() + val service = service(snapshotPort = snapshotPort) + val oldSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59) + val latestSnapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + snapshotPort.replaceSnapshots( + RecommendedSectionType.AI_CHARACTER, + oldSnapshotAt, + listOf(snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 1L, score = 10.0, snapshotAt = oldSnapshotAt)) + ) + snapshotPort.replaceSnapshots( + RecommendedSectionType.AI_CHARACTER, + latestSnapshotAt, + listOf(snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 2L, score = 20.0, snapshotAt = latestSnapshotAt)) + ) + + val latestSnapshots = service.getLatestSnapshots(RecommendedSectionType.AI_CHARACTER) + val emptySnapshots = service.getLatestSnapshots(RecommendedSectionType.CHEER_CREATOR) + + assertEquals(listOf(2L), latestSnapshots.map { it.targetId }) + assertEquals(emptyList(), emptySnapshots) + } + + @Test + @DisplayName("일 스냅샷 갱신은 전날 23시 59분 59초 기준으로 DB 점수 계산 스냅샷을 저장한다") + fun shouldRefreshDailySnapshotsWithPreviousDayEndSnapshotAt() { + val snapshotPort = FakeRecommendationSnapshotPort() + val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java) + val service = service(snapshotPort = snapshotPort, queryPort = queryPort) + val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0) + val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0, 0) + + Mockito.`when`(queryPort.findAiCharacterSnapshots(windowStart, snapshotAt, 20)).thenReturn( + listOf( + RecommendationSnapshotRecord( + sectionType = RecommendedSectionType.AI_CHARACTER, + targetId = 11L, + score = 78.0, + snapshotAt = snapshotAt, + randomTieBreaker = 0.1 + ) + ) + ) + Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, 16)).thenReturn( + listOf( + RecommendationSnapshotRecord( + sectionType = RecommendedSectionType.CHEER_CREATOR, + targetId = 22L, + score = 792.22, + snapshotAt = snapshotAt, + randomTieBreaker = 0.2 + ) + ) + ) + Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn( + listOf( + RecommendationSnapshotRecord( + sectionType = RecommendedSectionType.POPULAR_COMMUNITY, + targetId = 33L, + score = 40.0, + snapshotAt = snapshotAt, + randomTieBreaker = 0.3 + ) + ) + ) + + service.refreshDailySnapshots(now) + + val aiSnapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.AI_CHARACTER) + val cheerSnapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR) + val communitySnapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.POPULAR_COMMUNITY) + + assertEquals(snapshotAt, aiSnapshots.single().snapshotAt) + assertEquals(11L, aiSnapshots.single().targetId) + assertEquals(78.0, aiSnapshots.single().score, 0.0001) + assertEquals(0.1, aiSnapshots.single().randomTieBreaker, 0.0001) + + assertEquals(22L, cheerSnapshots.single().targetId) + assertEquals(792.22, cheerSnapshots.single().score, 0.0001) + + assertEquals(33L, communitySnapshots.single().targetId) + assertEquals(40.0, communitySnapshots.single().score, 0.0001) + + Mockito.verify(queryPort).findAiCharacterSnapshots(windowStart, snapshotAt, 20) + Mockito.verify(queryPort).findCheerCreatorSnapshots(windowStart, snapshotAt, 16) + Mockito.verify(queryPort).findPopularCommunitySnapshots(windowStart, snapshotAt, 20) + } + + @Test + @DisplayName("일 스냅샷 갱신은 DB에서 최종 점수순으로 제한된 결과만 저장한다") + fun shouldStoreDbScoredSnapshotResultsWithoutServiceSideCandidateLimit() { + val snapshotPort = FakeRecommendationSnapshotPort() + val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java) + val service = service(snapshotPort = snapshotPort, queryPort = queryPort) + val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0) + val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0, 0) + + Mockito.`when`(queryPort.findAiCharacterSnapshots(windowStart, snapshotAt, 20)).thenReturn( + listOf(snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 25L, score = 25.0, snapshotAt = snapshotAt)) + ) + Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, 16)).thenReturn( + listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 120L, score = 120.0, snapshotAt = snapshotAt)) + ) + Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn( + listOf(snapshot(RecommendedSectionType.POPULAR_COMMUNITY, targetId = 225L, score = 225.0, snapshotAt = snapshotAt)) + ) + + service.refreshDailySnapshots(now) + + assertEquals(listOf(25L), snapshotPort.findLatestSnapshots(RecommendedSectionType.AI_CHARACTER).map { it.targetId }) + assertEquals(listOf(120L), snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR).map { it.targetId }) + assertEquals(listOf(225L), snapshotPort.findLatestSnapshots(RecommendedSectionType.POPULAR_COMMUNITY).map { it.targetId }) + } + + @Test + @DisplayName("추천 스냅샷 스케줄러는 매일 06:00 KST cron으로 갱신 서비스를 호출한다") + fun shouldScheduleDailySnapshotRefreshAtKstSix() { + val scheduled = RecommendationSnapshotScheduler::class.java + .getDeclaredMethod("refreshDailySnapshots") + .getAnnotation(Scheduled::class.java) + val service = Mockito.mock(RecommendationSnapshotRefreshService::class.java) + val scheduler = RecommendationSnapshotScheduler(service) + + scheduler.refreshDailySnapshots() + + assertEquals("0 0 6 * * *", scheduled.cron) + assertEquals("Asia/Seoul", scheduled.zone) + Mockito.verify(service).refreshDailySnapshots() + } + + private fun service( + snapshotPort: RecommendationSnapshotPort = FakeRecommendationSnapshotPort(), + queryPort: HomeRecommendationQueryPort = Mockito.mock(HomeRecommendationQueryPort::class.java) + ): RecommendationSnapshotRefreshService { + return RecommendationSnapshotRefreshService( + snapshotPort = snapshotPort, + queryPort = queryPort + ) + } + + private fun snapshot( + sectionType: RecommendedSectionType, + targetId: Long, + score: Double, + snapshotAt: LocalDateTime + ): RecommendationSnapshotRecord { + return RecommendationSnapshotRecord( + sectionType = sectionType, + targetId = targetId, + score = score, + snapshotAt = snapshotAt, + randomTieBreaker = 0.1 + ) + } +} + +private class FakeRecommendationSnapshotPort : RecommendationSnapshotPort { + private val snapshots = mutableListOf() + + override fun findLatestSnapshots(sectionType: RecommendedSectionType): List { + val latestSnapshotAt = snapshots + .filter { it.sectionType == sectionType } + .maxOfOrNull { it.snapshotAt } + + return snapshots + .filter { it.sectionType == sectionType && it.snapshotAt == latestSnapshotAt } + .sortedWith(compareByDescending { it.score }.thenBy { it.randomTieBreaker }) + } + + override fun replaceSnapshots( + sectionType: RecommendedSectionType, + snapshotAt: LocalDateTime, + newSnapshots: List + ) { + snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt } + snapshots.addAll(newSnapshots) + } +}