From 7d0cf0a82a864b0a2d66c96aa86e51407211ccb6 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 10 Jul 2026 06:07:20 +0900 Subject: [PATCH] =?UTF-8?q?feat(home):=20=EC=9D=91=EC=9B=90=20=ED=81=AC?= =?UTF-8?q?=EB=A6=AC=EC=97=90=EC=9D=B4=ED=84=B0=20fallback=EC=9D=84=20?= =?UTF-8?q?=ED=99=88=20=EC=A1=B0=ED=9A=8C=EC=97=90=20=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../HomeRecommendationQueryService.kt | 26 ++- .../HomeRecommendationQueryServiceTest.kt | 155 +++++++++++++++++- 2 files changed, 175 insertions(+), 6 deletions(-) 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 e7aac2c3..aed4b4c7 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 @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.v2.recommendation.application import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType +import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicy import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeAiCharacterRecommendationRecord import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeBannerRecommendationRecord @@ -13,8 +14,10 @@ import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQue import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentDebutCreatorRecord import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentlyActiveCreatorRecord import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort +import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.time.Clock import java.time.LocalDateTime @Service @@ -22,8 +25,12 @@ import java.time.LocalDateTime class HomeRecommendationQueryService( private val queryPort: HomeRecommendationQueryPort, private val snapshotPort: RecommendationSnapshotPort, - private val aiCharacterSnapshotFallbackService: AiCharacterSnapshotFallbackPort? = null + private val aiCharacterSnapshotFallbackService: AiCharacterSnapshotFallbackPort? = null, + private val cheerCreatorSnapshotFallbackService: CheerCreatorSnapshotFallbackPort? = null, + private val clock: Clock = Clock.systemUTC() ) { + private val snapshotWindowPolicy = RecommendationSnapshotWindowPolicy() + fun findLiveRecommendations( offset: Long = 0, limit: Int = DEFAULT_LIVE_LIMIT, @@ -95,13 +102,28 @@ class HomeRecommendationQueryService( limit: Int = DEFAULT_CHEER_CREATOR_LIMIT, memberId: Long? = null ): List { - val snapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR).take(CHEER_CREATOR_CANDIDATE_LIMIT) + val snapshots = findCheerCreatorSnapshotsWithFallback() val detailsById = queryPort.findCheerCreatorRecommendationDetails(snapshots.map { it.targetId }, memberId) .associateBy { it.creatorId } return snapshots.mapNotNull { detailsById[it.targetId] }.take(limit) } + private fun findCheerCreatorSnapshotsWithFallback(): List { + val nowUtc = LocalDateTime.now(clock) + val snapshotAt = snapshotWindowPolicy.previousKstDayUtcWindow(nowUtc).snapshotAt + val snapshots = snapshotPort.findSnapshots( + RecommendedSectionType.CHEER_CREATOR, + snapshotAt, + limit = CHEER_CREATOR_CANDIDATE_LIMIT + ) + if (snapshots.isNotEmpty()) return snapshots + if (snapshotPort.existsSnapshot(RecommendedSectionType.CHEER_CREATOR, snapshotAt)) return emptyList() + return cheerCreatorSnapshotFallbackService + ?.refreshCheerCreatorIfMissing(offset = 0, limit = CHEER_CREATOR_CANDIDATE_LIMIT, nowUtc = nowUtc) + .orEmpty() + } + fun findPopularCommunityRecommendations( limit: Int = DEFAULT_POPULAR_COMMUNITY_LIMIT, memberId: Long? = null, 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 f027fb17..0f782bea 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 @@ -18,7 +18,10 @@ import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapsho import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +import java.time.Clock +import java.time.Instant import java.time.LocalDateTime +import java.time.ZoneOffset class HomeRecommendationQueryServiceTest { private val port = FakeHomeRecommendationQueryPort() @@ -280,7 +283,14 @@ class HomeRecommendationQueryServiceTest { @Test @DisplayName("최근 응원 크리에이터 추천은 최신 스냅샷 8명을 기준으로 닉네임과 프로필을 조립한다") fun shouldFindCheerCreatorsFromLatestSnapshotsWithLimitAndDetails() { - val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + val fallback = FakeCheerCreatorSnapshotFallbackPort(emptyList()) + val service = HomeRecommendationQueryService( + port, + snapshotPort, + cheerCreatorSnapshotFallbackService = fallback, + clock = fixedClock() + ) + val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59) snapshotPort.replaceSnapshots( RecommendedSectionType.CHEER_CREATOR, snapshotAt, @@ -303,12 +313,104 @@ class HomeRecommendationQueryServiceTest { val creators = service.findCheerCreatorRecommendations(memberId = 104L) + assertEquals(null, fallback.offset) + assertEquals(null, fallback.limit) assertEquals((1L..9L).toList(), port.cheerCreatorDetailIds) assertEquals(104L, port.cheerCreatorMemberId) assertEquals(listOf(1L, 2L), creators.map { it.creatorId }) assertEquals(listOf("creator-1", "creator-2"), creators.map { it.creatorNickname }) } + @Test + @DisplayName("최근 응원 크리에이터 추천은 최신 스냅샷이 없으면 fallback refresh 후 스냅샷 상세를 조립한다") + fun shouldFindCheerCreatorsAfterFallbackWhenLatestSnapshotsDoNotExist() { + val fallback = FakeCheerCreatorSnapshotFallbackPort( + listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 10L, 10.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59))) + ) + val service = HomeRecommendationQueryService( + port, + snapshotPort, + cheerCreatorSnapshotFallbackService = fallback, + clock = fixedClock() + ) + port.cheerCreatorDetails = listOf( + HomeCheerCreatorRecommendationRecord( + creatorId = 10L, + creatorNickname = "fallback-creator", + creatorProfileImage = null + ) + ) + + val creators = service.findCheerCreatorRecommendations(memberId = 104L) + + assertEquals(0L, fallback.offset) + assertEquals(16, fallback.limit) + assertEquals(LocalDateTime.of(2026, 7, 9, 21, 0), fallback.nowUtc) + assertEquals(listOf(10L), port.cheerCreatorDetailIds) + assertEquals(listOf(10L), creators.map { it.creatorId }) + } + + @Test + @DisplayName("최근 응원 크리에이터 추천은 오래된 스냅샷만 있으면 fallback refresh를 호출한다") + fun shouldFallbackWhenOnlyStaleCheerCreatorSnapshotsExist() { + val fallback = FakeCheerCreatorSnapshotFallbackPort( + listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 10L, 10.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59))) + ) + val service = HomeRecommendationQueryService( + port, + snapshotPort, + cheerCreatorSnapshotFallbackService = fallback, + clock = fixedClock() + ) + val staleSnapshotAt = LocalDateTime.of(2026, 7, 8, 14, 59, 59) + snapshotPort.replaceSnapshots( + RecommendedSectionType.CHEER_CREATOR, + staleSnapshotAt, + listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 99L, 99.0, staleSnapshotAt)) + ) + port.cheerCreatorDetails = listOf( + HomeCheerCreatorRecommendationRecord( + creatorId = 10L, + creatorNickname = "fallback-creator", + creatorProfileImage = null + ) + ) + + val creators = service.findCheerCreatorRecommendations(memberId = 104L) + + assertEquals(0L, fallback.offset) + assertEquals(16, fallback.limit) + assertEquals(LocalDateTime.of(2026, 7, 9, 21, 0), fallback.nowUtc) + assertEquals(listOf(10L), port.cheerCreatorDetailIds) + assertEquals(listOf(10L), creators.map { it.creatorId }) + } + + @Test + @DisplayName("최근 응원 크리에이터 추천은 빈 스냅샷 refresh 완료 marker가 있으면 fallback refresh를 반복하지 않는다") + fun shouldNotFallbackWhenEmptyCheerCreatorSnapshotCycleExists() { + val fallback = FakeCheerCreatorSnapshotFallbackPort( + listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 99L, 99.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59))) + ) + val service = HomeRecommendationQueryService( + port, + snapshotPort, + cheerCreatorSnapshotFallbackService = fallback, + clock = fixedClock() + ) + snapshotPort.replaceSnapshots( + RecommendedSectionType.CHEER_CREATOR, + LocalDateTime.of(2026, 7, 9, 14, 59, 59), + emptyList() + ) + + val creators = service.findCheerCreatorRecommendations(memberId = 104L) + + assertEquals(null, fallback.offset) + assertEquals(null, fallback.limit) + assertEquals(emptyList(), port.cheerCreatorDetailIds) + assertEquals(emptyList(), creators) + } + @Test @DisplayName("인기 커뮤니티 추천은 최신 스냅샷 10개를 기준으로 크리에이터 중복을 제거하고 상세를 조립한다") fun shouldFindPopularCommunitiesFromLatestSnapshotsWithLimitDetailsAndCreatorUniqueness() { @@ -861,7 +963,7 @@ class HomeRecommendationQueryServiceTest { override fun findCheerCreatorSnapshots( windowStart: LocalDateTime, - snapshotAt: LocalDateTime, + windowEndExclusive: LocalDateTime, limit: Int ): List = emptyList() @@ -931,17 +1033,35 @@ private class FakeHomeRecommendationSnapshotPort : RecommendationSnapshotPort { return all.drop(offset.toInt()).take(limit) } + override fun findSnapshots( + sectionType: RecommendedSectionType, + snapshotAt: LocalDateTime, + offset: Long, + limit: Int + ): List { + val all = snapshots + .filter { it.sectionType == sectionType && it.snapshotAt == snapshotAt && 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 existsSnapshot(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime): Boolean { + return snapshots.any { it.sectionType == sectionType && it.snapshotAt == snapshotAt } + } + override fun replaceSnapshots( sectionType: RecommendedSectionType, snapshotAt: LocalDateTime, newSnapshots: List ) { - if (newSnapshots.isEmpty() && sectionType == RecommendedSectionType.AI_CHARACTER) { - snapshots.removeIf { it.sectionType == sectionType } + if (newSnapshots.isEmpty() && supportsEmptySnapshotMarker(sectionType)) { + snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt } snapshots.add(snapshot(sectionType, targetId = 0L, score = 0.0, snapshotAt = snapshotAt)) return } @@ -949,6 +1069,33 @@ private class FakeHomeRecommendationSnapshotPort : RecommendationSnapshotPort { snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt } snapshots.addAll(newSnapshots) } + + private fun supportsEmptySnapshotMarker(sectionType: RecommendedSectionType): Boolean { + return sectionType == RecommendedSectionType.AI_CHARACTER || sectionType == RecommendedSectionType.CHEER_CREATOR + } +} + +private fun fixedClock(): Clock { + return Clock.fixed(Instant.parse("2026-07-09T21:00:00Z"), ZoneOffset.UTC) +} + +private class FakeCheerCreatorSnapshotFallbackPort( + private val snapshots: List +) : CheerCreatorSnapshotFallbackPort { + var offset: Long? = null + var limit: Int? = null + var nowUtc: LocalDateTime? = null + + override fun refreshCheerCreatorIfMissing( + offset: Long, + limit: Int, + nowUtc: LocalDateTime + ): List { + this.offset = offset + this.limit = limit + this.nowUtc = nowUtc + return snapshots + } } private class FakeAiCharacterSnapshotFallbackPort(