diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt index 31b00b67..df36d4bd 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt @@ -26,6 +26,7 @@ import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.QMember import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.block.QBlockMember +import kr.co.vividnext.sodalive.member.following.QCreatorFollowing import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScoreSpec import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType @@ -587,8 +588,7 @@ class DefaultHomeRecommendationQueryRepository( union all select lr.member_id as creator_id, lr.begin_date_time as debut_at from live_room lr - where lr.is_active = true - and lr.channel_name is not null + where lr.channel_name is not null and lr.channel_name <> '' and lr.begin_date_time <= :snapshotAt ) debut_events @@ -816,7 +816,13 @@ class DefaultHomeRecommendationQueryRepository( ) ) .from(member) - .where(member.isActive.isTrue, member.id.`in`(creatorIds), notBlockedCreatorCondition(memberId, member.id)) + .where( + member.isActive.isTrue, + member.id.`in`(creatorIds), + notViewerCondition(memberId, member.id), + notActiveFollowedCreatorCondition(memberId, member.id), + notBlockedCreatorCondition(memberId, member.id) + ) .fetch() } @@ -1278,6 +1284,24 @@ class DefaultHomeRecommendationQueryRepository( .notExists() } + private fun notViewerCondition(memberId: Long?, creatorIdPath: Expression): BooleanExpression? { + return memberId?.let { Expressions.booleanTemplate("{0} <> {1}", creatorIdPath, it) } + } + + private fun notActiveFollowedCreatorCondition(memberId: Long?, creatorIdPath: Expression): BooleanExpression? { + if (memberId == null) return null + val creatorFollowing = QCreatorFollowing("recommendationCreatorFollowing") + return JPAExpressions + .selectOne() + .from(creatorFollowing) + .where( + creatorFollowing.isActive.isTrue, + creatorFollowing.member.id.eq(memberId), + creatorFollowing.creator.id.eq(creatorIdPath) + ) + .notExists() + } + private fun orderedCommunityPostCondition(memberId: Long?): BooleanExpression { if (memberId == null) return Expressions.FALSE return JPAExpressions diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt index f1f2817c..ec980494 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt @@ -92,24 +92,38 @@ open class RecommendationSnapshotRefreshService( open fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)): Int { val startedAt = System.currentTimeMillis() val window = windowPolicy.previousKstSevenDayUtcWindow(nowUtc) - val snapshots = queryPort.findCheerCreatorSnapshots( - window.startUtc, - window.endExclusiveUtc, - CHEER_CREATOR_SNAPSHOT_LIMIT - ) - snapshotPort.replaceSnapshots(RecommendedSectionType.CHEER_CREATOR, window.snapshotAt, snapshots) - afterCommit { - log.info( - "event=cheer_creator_recommendation_snapshot_refresh_success " + - "snapshotAt={} windowStartUtc={} windowEndExclusiveUtc={} savedCount={} elapsedMs={}", + try { + val snapshots = queryPort.findCheerCreatorSnapshots( + window.startUtc, + window.endExclusiveUtc, + CHEER_CREATOR_SNAPSHOT_LIMIT + ) + snapshotPort.replaceSnapshots(RecommendedSectionType.CHEER_CREATOR, window.snapshotAt, snapshots) + afterCommit { + log.info( + "event=cheer_creator_recommendation_snapshot_refresh_success " + + "snapshotAt={} windowStartUtc={} windowEndExclusiveUtc={} savedCount={} elapsedMs={}", + window.snapshotAt, + window.startUtc, + window.endExclusiveUtc, + snapshots.size, + System.currentTimeMillis() - startedAt + ) + } + return snapshots.size + } catch (ex: Exception) { + log.warn( + "event=cheer_creator_recommendation_snapshot_refresh_failure " + + "snapshotAt={} windowStartUtc={} windowEndExclusiveUtc={} elapsedMs={} error={}", window.snapshotAt, window.startUtc, window.endExclusiveUtc, - snapshots.size, - System.currentTimeMillis() - startedAt + System.currentTimeMillis() - startedAt, + ex.message, + ex ) + throw ex } - return snapshots.size } @Transactional 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 7dfc1864..a32db47b 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 @@ -14,6 +14,7 @@ import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer import kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacade import kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshot import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService +import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicy import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull @@ -38,6 +39,7 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPat import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.transaction.annotation.Transactional import java.time.LocalDateTime +import java.time.ZoneOffset import javax.persistence.EntityManager @SpringBootTest @@ -524,6 +526,28 @@ class HomeRecommendationControllerTest @Autowired constructor( .andExpect(jsonPath("$.data.items[0].creatorId").value(character.creatorMember!!.id)) } + @Test + @DisplayName("메인 홈 통합 조회는 응원 크리에이터 item의 기존 3개 필드만 노출한다") + fun shouldKeepCheerCreatorItemSchemaOnHomeRecommendations() { + val creator = saveMember("cheer-api-creator", MemberRole.CREATOR).apply { + profileImage = "cheer-api-profile.png" + } + val snapshotAt = RecommendationSnapshotWindowPolicy() + .previousKstDayUtcWindow(LocalDateTime.now(ZoneOffset.UTC)) + .snapshotAt + saveCheerCreatorRecommendationSnapshot(creator.id!!, snapshotAt) + saveCheerCreatorRecommendationSnapshot(creator.id!!, snapshotAt.plusDays(1)) + entityManager.flush() + entityManager.clear() + + mockMvc.perform(get("/api/v2/home/recommendations")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.cheerCreators[0].creatorId").value(creator.id)) + .andExpect(jsonPath("$.data.cheerCreators[0].creatorNickname").value("cheer-api-creator")) + .andExpect(jsonPath("$.data.cheerCreators[0].creatorProfileImage").value("/cheer-api-profile.png")) + .andExpect(jsonPath("$.data.cheerCreators[0].length()").value(3)) + } + @Test @DisplayName("최근 활동 라이브 크리에이터는 creatorId와 라이브 상태별 targetId를 노출한다") fun shouldExposeNavigationIdsForRecentlyActiveLiveCreators() { @@ -619,4 +643,16 @@ class HomeRecommendationControllerTest @Autowired constructor( ) ) } + + private fun saveCheerCreatorRecommendationSnapshot(creatorId: Long, snapshotAt: LocalDateTime) { + entityManager.persist( + RecommendationSnapshot( + sectionType = RecommendedSectionType.CHEER_CREATOR, + targetId = creatorId, + score = 100.0, + snapshotAt = snapshotAt, + randomTieBreaker = 0.1 + ) + ) + } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponseTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponseTest.kt index 3d218302..ec6fda32 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponseTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponseTest.kt @@ -58,7 +58,13 @@ class HomeRecommendationResponseTest { ) ), genreCreators = emptyList(), - cheerCreators = emptyList(), + cheerCreators = listOf( + HomeCreatorItem( + creatorId = 15L, + creatorNickname = "cheer-creator", + creatorProfileImage = "https://cdn.test/profile/cheer.png" + ) + ), popularCommunityPosts = listOf( HomePopularCommunityPostItem( postId = 5L, @@ -108,6 +114,13 @@ class HomeRecommendationResponseTest { assertEquals("https://cdn.test/profile/character.png", json["aiCharacters"][0]["profileImage"].asText()) assertEquals(13L, json["aiCharacters"][0]["creatorId"].asLong()) assertEquals(true, json["aiCharacters"][1]["profileImage"].isNull) + assertEquals(15L, json["cheerCreators"][0]["creatorId"].asLong()) + assertEquals("cheer-creator", json["cheerCreators"][0]["creatorNickname"].asText()) + assertEquals("https://cdn.test/profile/cheer.png", json["cheerCreators"][0]["creatorProfileImage"].asText()) + assertEquals( + setOf("creatorId", "creatorNickname", "creatorProfileImage"), + json["cheerCreators"][0].fieldNames().asSequence().toSet() + ) assertEquals(5L, json["popularCommunityPosts"][0]["postId"].asLong()) assertEquals("https://cdn.test/community/image.png", json["popularCommunityPosts"][0]["imageUrl"].asText()) assertEquals("https://cdn.test/community/audio.mp3", json["popularCommunityPosts"][0]["audioUrl"].asText()) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt index 175dacd2..34a8005f 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt @@ -940,6 +940,100 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( assertEquals(expectedScore, snapshots.single().score, 0.0001) } + @Test + @DisplayName("최근 응원 스냅샷은 활동 점수가 없는 후보와 미래 데뷔 또는 비활성 크리에이터를 제외한다") + fun shouldExcludeIneligibleCheerCreatorSnapshotCandidates() { + val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0) + val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + val windowEndExclusive = snapshotAt.plusSeconds(1) + val donor = saveMember("cheer-boundary-donor", MemberRole.USER) + val zeroActivityCreator = saveMember("zero-activity-cheer", MemberRole.CREATOR) + val futureDebutCreator = saveMember("future-debut-cheer", MemberRole.CREATOR) + val inactiveCreator = saveMember("inactive-cheer-candidate", MemberRole.CREATOR, isActive = false) + val visibleCreator = saveMember("visible-cheer-candidate", MemberRole.CREATOR) + saveLiveRoom(zeroActivityCreator, LocalDateTime.of(2026, 5, 1, 0, 0), channelName = "zero-activity") + saveLiveRoom(futureDebutCreator, snapshotAt.plusSeconds(1), channelName = "future-debut") + saveLiveRoom(inactiveCreator, LocalDateTime.of(2026, 5, 1, 0, 0), channelName = "inactive-cheer") + saveLiveRoom(visibleCreator, LocalDateTime.of(2026, 5, 1, 0, 0), channelName = "visible-cheer") + listOf(futureDebutCreator, inactiveCreator, visibleCreator).forEach { creator -> + saveUseCanCalculate( + donor, + creator, + CanUsage.CHANNEL_DONATION, + 10, + UseCanCalculateStatus.RECEIVED, + false, + windowStart.plusDays(1) + ) + } + flushAndClear() + + val snapshots = repository.findCheerCreatorSnapshots(windowStart, windowEndExclusive, limit = 16) + + assertEquals(listOf(visibleCreator.id), snapshots.map { it.targetId }) + } + + @Test + @DisplayName("최근 응원 스냅샷은 채널명이 있는 종료 라이브를 데뷔 이력으로 인정한다") + fun shouldIncludeEndedLiveWithChannelNameAsCheerCreatorDebut() { + val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0) + val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + val windowEndExclusive = snapshotAt.plusSeconds(1) + val donor = saveMember("ended-live-cheer-donor", MemberRole.USER) + val endedLiveCreator = saveMember("ended-live-cheer", MemberRole.CREATOR) + val blankChannelCreator = saveMember("blank-ended-live-cheer", MemberRole.CREATOR) + saveLiveRoom( + endedLiveCreator, + LocalDateTime.of(2026, 5, 1, 0, 0), + channelName = "ended-live-cheer", + isActive = false + ) + saveLiveRoom( + blankChannelCreator, + LocalDateTime.of(2026, 5, 1, 0, 0), + channelName = "", + isActive = false + ) + val endedLiveCheer = saveCreatorCheers(donor, endedLiveCreator, isActive = true) + val blankChannelCheer = saveCreatorCheers(donor, blankChannelCreator, isActive = true) + updateCreatedAt("CreatorCheers", endedLiveCheer.id!!, windowStart.plusDays(1)) + updateCreatedAt("CreatorCheers", blankChannelCheer.id!!, windowStart.plusDays(1)) + flushAndClear() + + val snapshots = repository.findCheerCreatorSnapshots(windowStart, windowEndExclusive, limit = 16) + + assertEquals(listOf(endedLiveCreator.id), snapshots.map { it.targetId }) + } + + @Test + @DisplayName("최근 응원 스냅샷은 점수 내림차순 상위 16개만 반환한다") + fun shouldReturnTopSixteenCheerCreatorSnapshotsByScore() { + val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0) + val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + val windowEndExclusive = snapshotAt.plusSeconds(1) + val donor = saveMember("cheer-top-donor", MemberRole.USER) + val creators = (1..17).map { index -> + val creator = saveMember("cheer-top-$index", MemberRole.CREATOR) + saveLiveRoom(creator, LocalDateTime.of(2026, 5, 1, 0, 0), channelName = "cheer-top-$index") + saveUseCanCalculate( + donor, + creator, + CanUsage.CHANNEL_DONATION, + index, + UseCanCalculateStatus.RECEIVED, + false, + windowStart.plusDays(1) + ) + creator + } + flushAndClear() + + val snapshots = repository.findCheerCreatorSnapshots(windowStart, windowEndExclusive, limit = 16) + + assertEquals(16, snapshots.size) + assertEquals(creators.drop(1).reversed().map { it.id }, snapshots.map { it.targetId }) + } + @Test @DisplayName("인기 커뮤니티 스냅샷은 좋아요와 댓글 수를 distinct로 집계하고 follower-only 게시글은 제외한다") fun shouldFindPopularCommunitySnapshotsWithDistinctCounts() { @@ -1686,6 +1780,42 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( assertEquals(listOf(visibleCreator.id), details.map { it.creatorId }) } + @Test + @DisplayName("최근 응원 크리에이터 상세는 조회자 본인과 활성 팔로우 크리에이터를 제외한다") + fun shouldExcludeSelfAndActiveFollowedCreatorsFromCheerCreatorDetails() { + val viewer = saveMember("self-follow-cheer-viewer", MemberRole.CREATOR) + val activeFollowedCreator = saveMember("active-followed-cheer", MemberRole.CREATOR) + val inactiveFollowedCreator = saveMember("inactive-followed-cheer", MemberRole.CREATOR) + val visibleCreator = saveMember("unrelated-cheer", MemberRole.CREATOR) + saveFollowing(viewer, activeFollowedCreator, isActive = true) + saveFollowing(viewer, inactiveFollowedCreator, isActive = false) + flushAndClear() + + val details = repository.findCheerCreatorRecommendationDetails( + listOf(viewer.id!!, activeFollowedCreator.id!!, inactiveFollowedCreator.id!!, visibleCreator.id!!), + memberId = viewer.id + ) + + assertEquals(listOf(inactiveFollowedCreator.id, visibleCreator.id), details.map { it.creatorId }) + } + + @Test + @DisplayName("최근 응원 크리에이터 상세는 비회원 조회에서 회원별 필터를 적용하지 않는다") + fun shouldKeepAnonymousCheerCreatorDetailsWithoutMemberFilters() { + val viewer = saveMember("anonymous-filter-source", MemberRole.CREATOR) + val activeFollowedCreator = saveMember("anonymous-active-followed-cheer", MemberRole.CREATOR) + val visibleCreator = saveMember("anonymous-unrelated-cheer", MemberRole.CREATOR) + saveFollowing(viewer, activeFollowedCreator, isActive = true) + flushAndClear() + + val details = repository.findCheerCreatorRecommendationDetails( + listOf(viewer.id!!, activeFollowedCreator.id!!, visibleCreator.id!!), + memberId = null + ) + + assertEquals(listOf(viewer.id, activeFollowedCreator.id, visibleCreator.id), details.map { it.creatorId }) + } + @Test @DisplayName("최근 응원 크리에이터 상세는 빈 id 목록이면 빈 배열을 반환한다") fun shouldReturnEmptyCheerCreatorRecommendationDetailsWhenIdsAreEmpty() { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapterTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapterTest.kt index 041f5ec2..c7f9e9aa 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapterTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapterTest.kt @@ -108,6 +108,40 @@ class RecommendationSnapshotPersistenceAdapterTest @Autowired constructor( assertEquals(listOf(snapshotAt, snapshotAt), snapshots.map { it.snapshotAt }) } + @Test + fun shouldFindCheerCreatorSnapshotsWithScoreDescendingAndTieBreakerAscending() { + val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + repository.saveAll( + listOf( + snapshot( + RecommendedSectionType.CHEER_CREATOR, + targetId = 1L, + score = 100.0, + snapshotAt = snapshotAt, + randomTieBreaker = 0.9 + ), + snapshot( + RecommendedSectionType.CHEER_CREATOR, + targetId = 2L, + score = 200.0, + snapshotAt = snapshotAt, + randomTieBreaker = 0.8 + ), + snapshot( + RecommendedSectionType.CHEER_CREATOR, + targetId = 3L, + score = 100.0, + snapshotAt = snapshotAt, + randomTieBreaker = 0.1 + ) + ) + ) + + val snapshots = adapter.findSnapshots(RecommendedSectionType.CHEER_CREATOR, snapshotAt, offset = 0, limit = 10) + + assertEquals(listOf(2L, 3L, 1L), snapshots.map { it.targetId }) + } + @Test fun shouldCheckSnapshotExistenceByExactSnapshotAtIncludingEmptyMarker() { val staleSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackServiceTest.kt index 925ae2e2..1619ef09 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackServiceTest.kt @@ -13,6 +13,8 @@ import java.time.LocalDateTime import java.util.concurrent.CountDownLatch import java.util.concurrent.Executor import java.util.concurrent.Executors +import java.util.concurrent.Future +import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.TimeUnit class RecommendationSnapshotFallbackServiceTest { @@ -278,6 +280,80 @@ class RecommendationSnapshotFallbackServiceTest { assertEquals(listOf(99L), second.map { it.targetId }) } + @Test + @DisplayName("응원 크리에이터 fallback 동시 요청은 하나의 refresh future를 공유한다") + fun shouldShareSingleCheerCreatorRefreshFutureForConcurrentRequests() { + val existsSnapshotEntered = CountDownLatch(2) + val existsSnapshotReturned = CountDownLatch(2) + val snapshotPort = FakeRecommendationFallbackSnapshotPort( + existsSnapshotEntered = existsSnapshotEntered, + existsSnapshotReturned = existsSnapshotReturned + ) + val refreshService = FastCheerRefreshService(snapshotPort) + val redissonClient = Mockito.mock(RedissonClient::class.java) + val lock = Mockito.mock(RLock::class.java) + Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(lock) + Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true) + Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true) + val workerExecutor = CapturingExecutor() + val requestExecutor = Executors.newFixedThreadPool(2) + val service = RecommendationSnapshotFallbackService( + snapshotPort, + refreshService, + redissonClient, + workerExecutor, + homeWaitMillis = 1_000 + ) + val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0) + + try { + val first = requestExecutor.submitCheerRefresh(service, nowUtc) + val second = requestExecutor.submitCheerRefresh(service, nowUtc) + assertEquals(true, existsSnapshotEntered.await(1, TimeUnit.SECONDS)) + assertEquals(true, existsSnapshotReturned.await(1, TimeUnit.SECONDS)) + assertEquals(true, workerExecutor.taskSubmitted.await(1, TimeUnit.SECONDS)) + assertEquals(false, first.isDone) + assertEquals(false, second.isDone) + assertEquals(1, workerExecutor.taskCount) + + workerExecutor.runNext() + + assertEquals(listOf(99L), first.get(1, TimeUnit.SECONDS).map { it.targetId }) + assertEquals(listOf(99L), second.get(1, TimeUnit.SECONDS).map { it.targetId }) + assertEquals(1, refreshService.cheerRefreshCount) + } finally { + requestExecutor.shutdownNow() + } + } + + @Test + @DisplayName("응원 크리에이터 fallback은 lock 내부 double-check에서 최신 상태가 생기면 refresh를 생략한다") + fun shouldSkipCheerCreatorRefreshWhenSnapshotAppearsBeforeLockDoubleCheck() { + val snapshotPort = FakeRecommendationFallbackSnapshotPort() + 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) + val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59) + Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(lock) + Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenAnswer { + snapshotPort.replaceSnapshots( + RecommendedSectionType.CHEER_CREATOR, + snapshotAt, + listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 88L)) + ) + true + } + Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true) + val service = RecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor()) + + val snapshots = service.refreshCheerCreatorIfMissing(offset = 0, limit = 16, nowUtc = nowUtc) + + assertEquals(listOf(88L), snapshots.map { it.targetId }) + Mockito.verify(refreshService, Mockito.never()).refreshCheerCreatorSnapshots(nowUtc) + Mockito.verify(lock).unlock() + } + @Test @DisplayName("AI fallback이 오래 걸려도 응원 크리에이터 fallback은 같은 worker queue에서 대기하지 않는다") fun shouldRunCheerCreatorFallbackWithoutWaitingForBlockedAiFallback() { @@ -321,6 +397,32 @@ class RecommendationSnapshotFallbackServiceTest { } private fun directExecutor(): Executor = Executor { command -> command.run() } + + private fun java.util.concurrent.ExecutorService.submitCheerRefresh( + service: RecommendationSnapshotFallbackService, + nowUtc: LocalDateTime + ): Future> { + return submit> { + service.refreshCheerCreatorIfMissing(offset = 0, limit = 16, nowUtc = nowUtc) + } + } +} + +private class CapturingExecutor : Executor { + val taskSubmitted = CountDownLatch(1) + private val tasks = LinkedBlockingQueue() + + val taskCount: Int + get() = tasks.size + + override fun execute(command: Runnable) { + tasks.add(command) + taskSubmitted.countDown() + } + + fun runNext() { + tasks.poll(1, TimeUnit.SECONDS)!!.run() + } } private class BlockingCheerRefreshService( @@ -331,7 +433,10 @@ private class BlockingCheerRefreshService( snapshotPort, Mockito.mock(kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQueryPort::class.java) ) { + var cheerRefreshCount: Int = 0 + override fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime): Int { + cheerRefreshCount += 1 refreshStarted.countDown() allowRefreshComplete.await(1, TimeUnit.SECONDS) snapshotPort.replaceSnapshots( @@ -343,6 +448,25 @@ private class BlockingCheerRefreshService( } } +private class FastCheerRefreshService( + private val snapshotPort: RecommendationSnapshotPort +) : RecommendationSnapshotRefreshService( + snapshotPort, + Mockito.mock(kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQueryPort::class.java) +) { + var cheerRefreshCount: Int = 0 + + override fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime): Int { + cheerRefreshCount += 1 + snapshotPort.replaceSnapshots( + RecommendedSectionType.CHEER_CREATOR, + LocalDateTime.of(2026, 7, 9, 14, 59, 59), + listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 99L)) + ) + return 1 + } +} + private class BlockingAiAndFastCheerRefreshService( private val snapshotPort: RecommendationSnapshotPort, private val aiRefreshStarted: CountDownLatch, @@ -372,7 +496,10 @@ private class BlockingAiAndFastCheerRefreshService( } } -private class FakeRecommendationFallbackSnapshotPort : RecommendationSnapshotPort { +private class FakeRecommendationFallbackSnapshotPort( + private val existsSnapshotEntered: CountDownLatch? = null, + private val existsSnapshotReturned: CountDownLatch? = null +) : RecommendationSnapshotPort { private val snapshots = mutableListOf() override fun findLatestSnapshots( @@ -404,7 +531,12 @@ private class FakeRecommendationFallbackSnapshotPort : RecommendationSnapshotPor } override fun existsSnapshot(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime): Boolean { - return snapshots.any { it.sectionType == sectionType && it.snapshotAt == snapshotAt } + existsSnapshotEntered?.countDown() + existsSnapshotEntered?.await(1, TimeUnit.SECONDS) + val exists = snapshots.any { it.sectionType == sectionType && it.snapshotAt == snapshotAt } + existsSnapshotReturned?.countDown() + existsSnapshotReturned?.await(1, TimeUnit.SECONDS) + return exists } override fun replaceSnapshots( diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt index 9fad1533..e2b6c680 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt @@ -6,6 +6,8 @@ import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQue 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.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -197,6 +199,29 @@ class RecommendationSnapshotRefreshServiceTest { assertEquals(listOf(225L), snapshotPort.findLatestSnapshots(RecommendedSectionType.POPULAR_COMMUNITY).map { it.targetId }) } + @Test + @DisplayName("응원 크리에이터 스냅샷 갱신 실패는 섹션 event와 window를 로그로 남기고 예외를 전파한다") + fun shouldLogCheerCreatorRefreshFailureWithWindow(output: CapturedOutput) { + val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java) + val service = service(queryPort = queryPort) + val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0) + val windowStart = LocalDateTime.of(2026, 5, 22, 15, 0, 0) + val windowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0) + Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, windowEndExclusive, 16)) + .thenThrow(IllegalStateException("cheer refresh failed")) + + val exception = assertThrows(IllegalStateException::class.java) { + service.refreshCheerCreatorSnapshots(now) + } + + assertEquals("cheer refresh failed", exception.message) + assertTrue(output.out.contains("event=cheer_creator_recommendation_snapshot_refresh_failure")) + assertTrue(output.out.contains("snapshotAt=2026-05-29T14:59:59")) + assertTrue(output.out.contains("windowStartUtc=2026-05-22T15:00")) + assertTrue(output.out.contains("windowEndExclusiveUtc=2026-05-29T15:00")) + assertTrue(output.out.contains("error=cheer refresh failed")) + } + @Test @DisplayName("일 스냅샷 갱신은 AI 캐릭터 섹션 lock을 획득한 경우에만 AI refresh를 실행한다") fun shouldRefreshAiCharacterSectionOnlyWhenSectionLockAcquired() {