fix(recommendation): 응원 크리에이터 후속 보완을 반영한다

This commit is contained in:
2026-07-31 14:22:59 +09:00
parent 5123494e0e
commit ed54483cab
8 changed files with 427 additions and 19 deletions

View File

@@ -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<Long>): BooleanExpression? {
return memberId?.let { Expressions.booleanTemplate("{0} <> {1}", creatorIdPath, it) }
}
private fun notActiveFollowedCreatorCondition(memberId: Long?, creatorIdPath: Expression<Long>): 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

View File

@@ -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

View File

@@ -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
)
)
}
}

View File

@@ -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())

View File

@@ -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() {

View File

@@ -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)

View File

@@ -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<List<RecommendationSnapshotRecord>> {
return submit<List<RecommendationSnapshotRecord>> {
service.refreshCheerCreatorIfMissing(offset = 0, limit = 16, nowUtc = nowUtc)
}
}
}
private class CapturingExecutor : Executor {
val taskSubmitted = CountDownLatch(1)
private val tasks = LinkedBlockingQueue<Runnable>()
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<RecommendationSnapshotRecord>()
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(

View File

@@ -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() {