From cc7d28108aa714608b11e8bec462e687c068f5ec Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 10 Jul 2026 03:22:07 +0900 Subject: [PATCH] =?UTF-8?q?feat(home):=20AI=20=EC=BA=90=EB=A6=AD=ED=84=B0?= =?UTF-8?q?=20=EC=8A=A4=EB=83=85=EC=83=B7=20fallback=EC=9D=84=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AiCharacterSnapshotFallbackService.kt | 137 +++++++ .../HomeRecommendationQueryService.kt | 17 +- .../home/HomeRecommendationControllerTest.kt | 2 +- .../AiCharacterSnapshotFallbackServiceTest.kt | 351 ++++++++++++++++++ .../HomeRecommendationQueryServiceTest.kt | 119 +++++- 5 files changed, 621 insertions(+), 5 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/AiCharacterSnapshotFallbackService.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/AiCharacterSnapshotFallbackServiceTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/AiCharacterSnapshotFallbackService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/AiCharacterSnapshotFallbackService.kt new file mode 100644 index 00000000..cc53b9e0 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/AiCharacterSnapshotFallbackService.kt @@ -0,0 +1,137 @@ +package kr.co.vividnext.sodalive.v2.recommendation.application + +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.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.TransactionDefinition +import org.springframework.transaction.support.TransactionTemplate +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.util.concurrent.CompletableFuture +import java.util.concurrent.Executor +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import java.util.concurrent.atomic.AtomicReference +import javax.annotation.PreDestroy + +@Service +class AiCharacterSnapshotFallbackService( + private val snapshotPort: RecommendationSnapshotPort, + private val refreshService: RecommendationSnapshotRefreshService, + private val redissonClient: RedissonClient, + executor: Executor? = null, + private val homeWaitMillis: Long = HOME_WAIT_MILLIS, + transactionManager: PlatformTransactionManager? = null +) : AiCharacterSnapshotFallbackPort { + private val log = LoggerFactory.getLogger(javaClass) + private val ownedExecutor: ExecutorService? = if (executor == null) Executors.newSingleThreadExecutor() else null + private val workerExecutor: Executor = executor ?: ownedExecutor!! + private val refreshFuture = AtomicReference?>() + private val readTransactionTemplate = transactionManager?.let { + TransactionTemplate(it).also { template -> + template.propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW + template.isReadOnly = true + } + } + + override fun refreshIfMissing( + offset: Long, + limit: Int, + nowUtc: LocalDateTime + ): List { + val existing = findLatestSnapshots(offset, limit) + if (existing.isNotEmpty()) return existing + if (hasLatestSnapshot()) return findLatestSnapshots(offset, limit) + + val future = getOrStartRefresh(nowUtc) + return try { + future.get(homeWaitMillis, TimeUnit.MILLISECONDS) + findLatestSnapshots(offset, limit) + } catch (ex: TimeoutException) { + log.warn("event=ai_character_snapshot_fallback_timeout homeWaitMs={}", homeWaitMillis) + emptyList() + } catch (ex: InterruptedException) { + Thread.currentThread().interrupt() + log.warn("event=ai_character_snapshot_fallback_failure error={}", ex.message, ex) + emptyList() + } catch (ex: Exception) { + log.warn("event=ai_character_snapshot_fallback_failure error={}", ex.message, ex) + emptyList() + } + } + + private fun findLatestSnapshots(offset: Long, limit: Int): List { + return readSnapshotPort { + snapshotPort.findLatestSnapshots(RecommendedSectionType.AI_CHARACTER, offset, limit) + } + } + + private fun hasLatestSnapshot(): Boolean { + return readSnapshotPort { + snapshotPort.existsLatestSnapshot(RecommendedSectionType.AI_CHARACTER) + } + } + + private fun readSnapshotPort(action: () -> T): T { + val template = readTransactionTemplate ?: return action() + return template.execute { action() } ?: action() + } + + @Synchronized + private fun getOrStartRefresh(nowUtc: LocalDateTime): CompletableFuture { + refreshFuture.get()?.let { return it } + + val newFuture = CompletableFuture.runAsync({ refreshInWorker(nowUtc) }, workerExecutor) + refreshFuture.set(newFuture) + newFuture.whenComplete { _, _ -> refreshFuture.compareAndSet(newFuture, null) } + return newFuture + } + + private fun refreshInWorker(nowUtc: LocalDateTime) { + val lock = redissonClient.getLock(LOCK_KEY) + try { + if (!lock.tryLock(LOCK_WAIT_MILLIS, -1, TimeUnit.MILLISECONDS)) { + log.info("event=ai_character_snapshot_fallback_lock_missed lockKey={} lockWaitMs={}", LOCK_KEY, LOCK_WAIT_MILLIS) + return + } + + log.info("event=ai_character_snapshot_fallback_lock_acquired lockKey={}", LOCK_KEY) + if (hasLatestSnapshot()) return + + log.info("event=ai_character_snapshot_fallback_refresh_start lockKey={}", LOCK_KEY) + val refreshedCount = refreshService.refreshAiCharacterSnapshots(nowUtc) + log.info("event=ai_character_snapshot_fallback_refresh_success refreshedCount={}", refreshedCount) + } catch (ex: Exception) { + log.warn("event=ai_character_snapshot_fallback_refresh_failure error={}", ex.message, ex) + } finally { + if (lock.isHeldByCurrentThread) { + lock.unlock() + } + } + } + + @PreDestroy + fun shutdown() { + ownedExecutor?.shutdown() + } + + companion object { + const val LOCK_KEY = "lock:recommendation-snapshot-refresh:AI_CHARACTER" + private const val LOCK_WAIT_MILLIS = 300L + private const val HOME_WAIT_MILLIS = 1_500L + } +} + +interface AiCharacterSnapshotFallbackPort { + fun refreshIfMissing( + offset: Long, + limit: Int, + nowUtc: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) + ): List +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt index 9af9848a..e7aac2c3 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt @@ -21,7 +21,8 @@ import java.time.LocalDateTime @Transactional(readOnly = true) class HomeRecommendationQueryService( private val queryPort: HomeRecommendationQueryPort, - private val snapshotPort: RecommendationSnapshotPort + private val snapshotPort: RecommendationSnapshotPort, + private val aiCharacterSnapshotFallbackService: AiCharacterSnapshotFallbackPort? = null ) { fun findLiveRecommendations( offset: Long = 0, @@ -71,13 +72,25 @@ class HomeRecommendationQueryService( offset: Long = 0, limit: Int = DEFAULT_AI_CHARACTER_LIMIT ): List { - val snapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.AI_CHARACTER, offset, limit) + val snapshots = findAiCharacterSnapshotsWithFallback(offset, limit) val detailsById = queryPort.findAiCharacterRecommendationDetails(snapshots.map { it.targetId }) .associateBy { it.characterId } return snapshots.mapNotNull { detailsById[it.targetId] } } + private fun findAiCharacterSnapshotsWithFallback( + offset: Long, + limit: Int + ) = snapshotPort.findLatestSnapshots(RecommendedSectionType.AI_CHARACTER, offset, limit) + .ifEmpty { + if (snapshotPort.existsLatestSnapshot(RecommendedSectionType.AI_CHARACTER)) { + emptyList() + } else { + aiCharacterSnapshotFallbackService?.refreshIfMissing(offset, limit).orEmpty() + } + } + fun findCheerCreatorRecommendations( limit: Int = DEFAULT_CHEER_CREATOR_LIMIT, memberId: Long? = null diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt index 910ce84a..e13d1ed3 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt @@ -590,7 +590,7 @@ class HomeRecommendationControllerTest @Autowired constructor( sectionType = RecommendedSectionType.AI_CHARACTER, targetId = characterId, score = 100.0, - snapshotAt = LocalDateTime.of(2026, 6, 1, 23, 59, 59), + snapshotAt = LocalDateTime.of(2026, 12, 31, 23, 59, 59), randomTieBreaker = 0.1 ) ) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/AiCharacterSnapshotFallbackServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/AiCharacterSnapshotFallbackServiceTest.kt new file mode 100644 index 00000000..6b860320 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/AiCharacterSnapshotFallbackServiceTest.kt @@ -0,0 +1,351 @@ +package kr.co.vividnext.sodalive.v2.recommendation.application + +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.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mockito +import org.redisson.api.RLock +import org.redisson.api.RedissonClient +import org.springframework.boot.test.system.CapturedOutput +import org.springframework.boot.test.system.OutputCaptureExtension +import org.springframework.transaction.TransactionDefinition +import org.springframework.transaction.support.AbstractPlatformTransactionManager +import org.springframework.transaction.support.DefaultTransactionStatus +import java.time.LocalDateTime +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executor +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +@ExtendWith(OutputCaptureExtension::class) +class AiCharacterSnapshotFallbackServiceTest { + @Test + @DisplayName("AI fallback은 최신 스냅샷이 이미 있으면 lock과 refresh를 실행하지 않고 즉시 반환한다") + fun shouldReturnExistingSnapshotsWithoutLockOrRefresh() { + val snapshotPort = FakeFallbackSnapshotPort() + val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59) + snapshotPort.replaceSnapshots(RecommendedSectionType.AI_CHARACTER, snapshotAt, listOf(snapshot(10L))) + val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val service = AiCharacterSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor()) + + val snapshots = service.refreshIfMissing(offset = 0, limit = 20) + + assertEquals(listOf(10L), snapshots.map { it.targetId }) + Mockito.verifyNoInteractions(redissonClient) + Mockito.verifyNoInteractions(refreshService) + } + + @Test + @DisplayName("AI fallback은 worker thread 안에서 section lock을 잡고 공통 AI refresh 후 최신 스냅샷을 다시 조회한다") + fun shouldRefreshMissingAiSnapshotsWithSectionLock() { + val snapshotPort = FakeFallbackSnapshotPort() + val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val lock = Mockito.mock(RLock::class.java) + Mockito.`when`(redissonClient.getLock(AiCharacterSnapshotFallbackService.LOCK_KEY)).thenReturn(lock) + Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true) + Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true) + val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0) + Mockito.doAnswer { + snapshotPort.replaceSnapshots( + RecommendedSectionType.AI_CHARACTER, + LocalDateTime.of(2026, 7, 9, 14, 59, 59), + listOf(snapshot(1L)) + ) + null + }.`when`(refreshService).refreshAiCharacterSnapshots(nowUtc) + val service = AiCharacterSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor()) + + val snapshots = service.refreshIfMissing(offset = 0, limit = 20, nowUtc = nowUtc) + + assertEquals(listOf(1L), snapshots.map { it.targetId }) + Mockito.verify(redissonClient).getLock(AiCharacterSnapshotFallbackService.LOCK_KEY) + Mockito.verify(lock).tryLock(300, -1, TimeUnit.MILLISECONDS) + Mockito.verify(refreshService).refreshAiCharacterSnapshots(nowUtc) + Mockito.verify(lock).unlock() + } + + @Test + @DisplayName("AI fallback의 snapshot 조회는 호출자 read transaction과 분리된 새 read transaction에서 실행한다") + fun shouldReadSnapshotsInRequiresNewReadOnlyTransactions() { + val snapshotPort = FakeFallbackSnapshotPort() + val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val lock = Mockito.mock(RLock::class.java) + val transactionManager = RecordingTransactionManager() + Mockito.`when`(redissonClient.getLock(AiCharacterSnapshotFallbackService.LOCK_KEY)).thenReturn(lock) + Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true) + Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true) + val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0) + Mockito.doAnswer { + snapshotPort.replaceSnapshots( + RecommendedSectionType.AI_CHARACTER, + LocalDateTime.of(2026, 7, 9, 14, 59, 59), + listOf(snapshot(1L)) + ) + null + }.`when`(refreshService).refreshAiCharacterSnapshots(nowUtc) + val service = AiCharacterSnapshotFallbackService( + snapshotPort = snapshotPort, + refreshService = refreshService, + redissonClient = redissonClient, + executor = directExecutor(), + transactionManager = transactionManager + ) + + val snapshots = service.refreshIfMissing(offset = 0, limit = 20, nowUtc = nowUtc) + + assertEquals(listOf(1L), snapshots.map { it.targetId }) + assertEquals( + listOf( + TransactionDefinition.PROPAGATION_REQUIRES_NEW, + TransactionDefinition.PROPAGATION_REQUIRES_NEW, + TransactionDefinition.PROPAGATION_REQUIRES_NEW, + TransactionDefinition.PROPAGATION_REQUIRES_NEW + ), + transactionManager.propagationBehaviors + ) + assertEquals(listOf(true, true, true, true), transactionManager.readOnlyFlags) + } + + @Test + @DisplayName("AI fallback은 lock 획득 실패 시 refresh를 중복 실행하지 않는다") + fun shouldSkipRefreshWhenSectionLockIsNotAcquired() { + val snapshotPort = FakeFallbackSnapshotPort() + val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val lock = Mockito.mock(RLock::class.java) + Mockito.`when`(redissonClient.getLock(AiCharacterSnapshotFallbackService.LOCK_KEY)).thenReturn(lock) + Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(false) + Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(false) + val service = AiCharacterSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor()) + + val snapshots = service.refreshIfMissing(offset = 0, limit = 20) + + assertEquals(emptyList(), snapshots) + Mockito.verifyNoInteractions(refreshService) + Mockito.verify(lock, Mockito.never()).unlock() + } + + @Test + @DisplayName("AI fallback은 빈 스냅샷 refresh 완료 marker가 있으면 refresh를 반복하지 않는다") + fun shouldSkipRefreshWhenEmptySnapshotMarkerExists() { + val snapshotPort = FakeFallbackSnapshotPort() + val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59) + snapshotPort.replaceSnapshots(RecommendedSectionType.AI_CHARACTER, snapshotAt, emptyList()) + val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val service = AiCharacterSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor()) + + val snapshots = service.refreshIfMissing(offset = 0, limit = 20) + + assertEquals(emptyList(), snapshots) + Mockito.verifyNoInteractions(redissonClient) + Mockito.verifyNoInteractions(refreshService) + } + + @Test + @DisplayName("AI fallback은 홈 대기 timeout 시 현재 요청은 빈 배열을 반환하고 worker는 취소하지 않아 다음 요청이 저장 결과를 읽는다") + fun shouldReturnEmptyOnTimeoutAndKeepWorkerRunning() { + val snapshotPort = FakeFallbackSnapshotPort() + val refreshStarted = CountDownLatch(1) + val allowRefreshComplete = CountDownLatch(1) + val refreshService = BlockingRefreshService(snapshotPort, refreshStarted, allowRefreshComplete) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val lock = Mockito.mock(RLock::class.java) + Mockito.`when`(redissonClient.getLock(AiCharacterSnapshotFallbackService.LOCK_KEY)).thenReturn(lock) + Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true) + Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true) + val executor = Executors.newSingleThreadExecutor() + val service = AiCharacterSnapshotFallbackService( + snapshotPort, + refreshService, + redissonClient, + executor, + homeWaitMillis = 50 + ) + + val first = service.refreshIfMissing(offset = 0, limit = 20, nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)) + assertEquals(true, refreshStarted.await(1, TimeUnit.SECONDS)) + allowRefreshComplete.countDown() + executor.shutdown() + assertEquals(true, executor.awaitTermination(1, TimeUnit.SECONDS)) + val second = service.refreshIfMissing(offset = 0, limit = 20) + + assertEquals(emptyList(), first) + assertEquals(listOf(99L), second.map { it.targetId }) + } + + @Test + @DisplayName("AI fallback은 refresh 실패 시 예외를 던지지 않고 빈 배열과 실패 로그를 반환한다") + fun shouldReturnEmptyAndLogWhenRefreshFails(output: CapturedOutput) { + val snapshotPort = FakeFallbackSnapshotPort() + val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val lock = Mockito.mock(RLock::class.java) + val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0) + Mockito.`when`(redissonClient.getLock(AiCharacterSnapshotFallbackService.LOCK_KEY)).thenReturn(lock) + Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true) + Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true) + Mockito.doThrow(IllegalStateException("boom")).`when`(refreshService).refreshAiCharacterSnapshots(nowUtc) + val service = AiCharacterSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor()) + + val snapshots = service.refreshIfMissing(offset = 0, limit = 20, nowUtc = nowUtc) + + assertEquals(emptyList(), snapshots) + assertEquals(true, output.out.contains("event=ai_character_snapshot_fallback_refresh_failure")) + Mockito.verify(lock).unlock() + } + + @Test + @DisplayName("AI fallback은 최신 스냅샷이 pre-check에서 감지되면 lock과 refresh를 실행하지 않는다") + fun shouldSkipLockAndRefreshWhenLatestSnapshotAppearsBeforeWorkerStarts() { + val snapshotPort = FakeFallbackSnapshotPort().apply { + replaceOnSecondRead = listOf(snapshot(77L)) + } + val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val service = AiCharacterSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor()) + + val snapshots = service.refreshIfMissing(offset = 0, limit = 20) + + assertEquals(listOf(77L), snapshots.map { it.targetId }) + Mockito.verifyNoInteractions(redissonClient) + Mockito.verifyNoInteractions(refreshService) + } + + @Test + @DisplayName("AI fallback은 lock 획득 후 최신 스냅샷을 재확인해 이미 생성됐으면 중복 refresh하지 않는다") + fun shouldDoubleCheckSnapshotsAfterLockBeforeRefresh() { + val snapshotPort = FakeFallbackSnapshotPort().apply { + replaceOnThirdRead = listOf(snapshot(88L)) + } + val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val lock = Mockito.mock(RLock::class.java) + Mockito.`when`(redissonClient.getLock(AiCharacterSnapshotFallbackService.LOCK_KEY)).thenReturn(lock) + Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true) + Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true) + val service = AiCharacterSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor()) + + val snapshots = service.refreshIfMissing(offset = 0, limit = 20) + + assertEquals(listOf(88L), snapshots.map { it.targetId }) + Mockito.verifyNoInteractions(refreshService) + Mockito.verify(lock).unlock() + } + + private fun directExecutor(): Executor = Executor { command -> command.run() } +} + +private class FakeFallbackSnapshotPort : RecommendationSnapshotPort { + private val snapshots = mutableListOf() + var replaceOnSecondRead: List? = null + var replaceOnThirdRead: List? = null + private var readCount = 0 + + override fun findLatestSnapshots( + sectionType: RecommendedSectionType, + offset: Long, + limit: Int + ): List { + advanceReadScenario() + val latestSnapshotAt = snapshots.filter { it.sectionType == sectionType }.maxOfOrNull { it.snapshotAt } + return snapshots + .filter { it.sectionType == sectionType && it.snapshotAt == latestSnapshotAt && it.targetId != 0L } + .drop(offset.toInt()) + .take(limit) + } + + override fun existsLatestSnapshot(sectionType: RecommendedSectionType): Boolean { + advanceReadScenario() + return snapshots.any { it.sectionType == sectionType } + } + + private fun advanceReadScenario() { + readCount += 1 + if (readCount == 2) { + replaceOnSecondRead?.let { + replaceSnapshots(RecommendedSectionType.AI_CHARACTER, LocalDateTime.of(2026, 7, 9, 14, 59, 59), it) + replaceOnSecondRead = null + } + } + if (readCount == 3) { + replaceOnThirdRead?.let { + replaceSnapshots(RecommendedSectionType.AI_CHARACTER, LocalDateTime.of(2026, 7, 9, 14, 59, 59), it) + replaceOnThirdRead = null + } + } + } + + override fun replaceSnapshots( + sectionType: RecommendedSectionType, + snapshotAt: LocalDateTime, + newSnapshots: List + ) { + if (newSnapshots.isEmpty() && sectionType == RecommendedSectionType.AI_CHARACTER) { + snapshots.removeIf { it.sectionType == sectionType } + snapshots.add(snapshot(0L, snapshotAt)) + return + } + + snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt } + snapshots.addAll(newSnapshots) + } +} + +private class BlockingRefreshService( + private val snapshotPort: RecommendationSnapshotPort, + private val refreshStarted: CountDownLatch, + private val allowRefreshComplete: CountDownLatch +) : RecommendationSnapshotRefreshService( + snapshotPort, + Mockito.mock(kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQueryPort::class.java) +) { + override fun refreshAiCharacterSnapshots(nowUtc: LocalDateTime): Int { + refreshStarted.countDown() + allowRefreshComplete.await(1, TimeUnit.SECONDS) + snapshotPort.replaceSnapshots( + RecommendedSectionType.AI_CHARACTER, + LocalDateTime.of(2026, 7, 9, 14, 59, 59), + listOf(snapshot(99L)) + ) + return 1 + } +} + +private fun snapshot( + targetId: Long, + snapshotAt: LocalDateTime = LocalDateTime.of(2026, 7, 9, 14, 59, 59) +): RecommendationSnapshotRecord { + return RecommendationSnapshotRecord( + sectionType = RecommendedSectionType.AI_CHARACTER, + targetId = targetId, + score = 1.0, + snapshotAt = snapshotAt, + randomTieBreaker = 1.0 + ) +} + +private class RecordingTransactionManager : AbstractPlatformTransactionManager() { + val propagationBehaviors = mutableListOf() + val readOnlyFlags = mutableListOf() + + override fun doGetTransaction(): Any = Any() + + override fun doBegin(transaction: Any, definition: TransactionDefinition) { + propagationBehaviors.add(definition.propagationBehavior) + readOnlyFlags.add(definition.isReadOnly) + } + + override fun doCommit(status: DefaultTransactionStatus) { + } + + override fun doRollback(status: DefaultTransactionStatus) { + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt index 6e6e32d9..f027fb17 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt @@ -143,6 +143,8 @@ class HomeRecommendationQueryServiceTest { @Test @DisplayName("AI 캐릭터 추천은 최신 스냅샷 20개를 기준으로 순서를 유지해 상세를 조립한다") fun shouldFindAiCharactersFromLatestSnapshotsWithLimitAndDetails() { + val fallback = FakeAiCharacterSnapshotFallbackPort(emptyList()) + val service = HomeRecommendationQueryService(port, snapshotPort, fallback) val oldSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59) val latestSnapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) snapshotPort.replaceSnapshots( @@ -180,6 +182,8 @@ class HomeRecommendationQueryServiceTest { val characters = service.findAiCharacterRecommendations() + assertEquals(null, fallback.offset) + assertEquals(null, fallback.limit) assertEquals((1L..20L).toList(), port.aiCharacterDetailIds) assertEquals(listOf(1L, 2L), characters.map { it.characterId }) assertEquals(listOf(101L, 102L), characters.map { it.creatorId }) @@ -189,6 +193,90 @@ class HomeRecommendationQueryServiceTest { assertEquals(null, characters.last().originalWorkTitle) } + @Test + @DisplayName("AI 캐릭터 추천은 최신 스냅샷이 없으면 fallback refresh 후 스냅샷 상세를 조립한다") + fun shouldFindAiCharactersAfterFallbackWhenLatestSnapshotsDoNotExist() { + val fallback = FakeAiCharacterSnapshotFallbackPort( + listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 10L, 10.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59))) + ) + val service = HomeRecommendationQueryService(port, snapshotPort, fallback) + port.aiCharacterDetails = listOf( + HomeAiCharacterRecommendationRecord( + characterId = 10L, + creatorId = 110L, + name = "fallback-character", + description = "description", + profileImage = null, + totalChatCount = 1L, + originalWorkTitle = null + ) + ) + + val characters = service.findAiCharacterRecommendations() + + assertEquals(0L, fallback.offset) + assertEquals(20, fallback.limit) + assertEquals(listOf(10L), port.aiCharacterDetailIds) + assertEquals(listOf(10L), characters.map { it.characterId }) + } + + @Test + @DisplayName("AI 캐릭터 추천은 fallback 후에도 스냅샷이 없으면 빈 배열을 반환한다") + fun shouldReturnEmptyAiCharactersWhenFallbackDoesNotCreateSnapshots() { + val fallback = FakeAiCharacterSnapshotFallbackPort(emptyList()) + val service = HomeRecommendationQueryService(port, snapshotPort, fallback) + + val characters = service.findAiCharacterRecommendations() + + assertEquals(0L, fallback.offset) + assertEquals(20, fallback.limit) + assertEquals(emptyList(), port.aiCharacterDetailIds) + assertEquals(emptyList(), characters) + } + + @Test + @DisplayName("AI 캐릭터 추천은 빈 스냅샷 refresh 완료 marker가 있으면 fallback refresh를 반복하지 않는다") + fun shouldNotFallbackWhenEmptyAiSnapshotCycleExists() { + val fallback = FakeAiCharacterSnapshotFallbackPort( + listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 99L, 99.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59))) + ) + val service = HomeRecommendationQueryService(port, snapshotPort, fallback) + snapshotPort.replaceSnapshots( + RecommendedSectionType.AI_CHARACTER, + LocalDateTime.of(2026, 7, 9, 14, 59, 59), + emptyList() + ) + + val characters = service.findAiCharacterRecommendations() + + assertEquals(null, fallback.offset) + assertEquals(null, fallback.limit) + assertEquals(emptyList(), port.aiCharacterDetailIds) + assertEquals(emptyList(), characters) + } + + @Test + @DisplayName("AI 캐릭터 추천은 최신 스냅샷이 있으면 요청 페이지가 비어도 fallback refresh를 호출하지 않는다") + fun shouldNotFallbackWhenLatestAiSnapshotsExistButRequestedPageIsEmpty() { + val fallback = FakeAiCharacterSnapshotFallbackPort( + listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 99L, 99.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59))) + ) + val service = HomeRecommendationQueryService(port, snapshotPort, fallback) + val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59) + snapshotPort.replaceSnapshots( + RecommendedSectionType.AI_CHARACTER, + snapshotAt, + (1L..20L).map { targetId -> snapshot(RecommendedSectionType.AI_CHARACTER, targetId, 100.0 - targetId, snapshotAt) } + ) + + val characters = service.findAiCharacterRecommendations(offset = 20, limit = 20) + + assertEquals(null, fallback.offset) + assertEquals(null, fallback.limit) + assertEquals(emptyList(), port.aiCharacterDetailIds) + assertEquals(emptyList(), characters) + } + @Test @DisplayName("최근 응원 크리에이터 추천은 최신 스냅샷 8명을 기준으로 닉네임과 프로필을 조립한다") fun shouldFindCheerCreatorsFromLatestSnapshotsWithLimitAndDetails() { @@ -767,7 +855,7 @@ class HomeRecommendationQueryServiceTest { override fun findAiCharacterSnapshots( windowStart: LocalDateTime, - snapshotAt: LocalDateTime, + windowEndExclusive: LocalDateTime, limit: Int ): List = emptyList() @@ -836,23 +924,50 @@ private class FakeHomeRecommendationSnapshotPort : RecommendationSnapshotPort { .maxOfOrNull { it.snapshotAt } val all = snapshots - .filter { it.sectionType == sectionType && it.snapshotAt == latestSnapshotAt } + .filter { it.sectionType == sectionType && it.snapshotAt == latestSnapshotAt && it.targetId != 0L } .sortedWith(compareByDescending { it.score }.thenBy { it.randomTieBreaker }) if (offset == 0L && limit == Int.MAX_VALUE) return all return all.drop(offset.toInt()).take(limit) } + override fun existsLatestSnapshot(sectionType: RecommendedSectionType): Boolean { + return snapshots.any { it.sectionType == sectionType } + } + override fun replaceSnapshots( sectionType: RecommendedSectionType, snapshotAt: LocalDateTime, newSnapshots: List ) { + if (newSnapshots.isEmpty() && sectionType == RecommendedSectionType.AI_CHARACTER) { + snapshots.removeIf { it.sectionType == sectionType } + snapshots.add(snapshot(sectionType, targetId = 0L, score = 0.0, snapshotAt = snapshotAt)) + return + } + snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt } snapshots.addAll(newSnapshots) } } +private class FakeAiCharacterSnapshotFallbackPort( + private val snapshots: List +) : AiCharacterSnapshotFallbackPort { + var offset: Long? = null + var limit: Int? = null + + override fun refreshIfMissing( + offset: Long, + limit: Int, + nowUtc: LocalDateTime + ): List { + this.offset = offset + this.limit = limit + return snapshots + } +} + private fun snapshot( sectionType: RecommendedSectionType, targetId: Long,