fix(recommendation): 응원 크리에이터 후속 보완을 반영한다
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -92,6 +92,7 @@ open class RecommendationSnapshotRefreshService(
|
||||
open fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)): Int {
|
||||
val startedAt = System.currentTimeMillis()
|
||||
val window = windowPolicy.previousKstSevenDayUtcWindow(nowUtc)
|
||||
try {
|
||||
val snapshots = queryPort.findCheerCreatorSnapshots(
|
||||
window.startUtc,
|
||||
window.endExclusiveUtc,
|
||||
@@ -110,6 +111,19 @@ open class RecommendationSnapshotRefreshService(
|
||||
)
|
||||
}
|
||||
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,
|
||||
System.currentTimeMillis() - startedAt,
|
||||
ex.message,
|
||||
ex
|
||||
)
|
||||
throw ex
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user