From b4555a4460d16f0edd68fcda421e2717ea499e9c Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 10 Jul 2026 08:58:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(home):=20=EC=9D=B8=EA=B8=B0=20=EC=BB=A4?= =?UTF-8?q?=EB=AE=A4=EB=8B=88=ED=8B=B0=20fallback=EC=9D=84=20=ED=99=88=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EC=97=90=20=EC=97=B0=EA=B2=B0=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../HomeRecommendationQueryService.kt | 19 +++- .../HomeRecommendationQueryServiceTest.kt | 106 +++++++++++++++++- 2 files changed, 117 insertions(+), 8 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 aed4b4c7..d57eb3fc 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 @@ -27,6 +27,7 @@ class HomeRecommendationQueryService( private val snapshotPort: RecommendationSnapshotPort, private val aiCharacterSnapshotFallbackService: AiCharacterSnapshotFallbackPort? = null, private val cheerCreatorSnapshotFallbackService: CheerCreatorSnapshotFallbackPort? = null, + private val popularCommunitySnapshotFallbackService: PopularCommunitySnapshotFallbackPort? = null, private val clock: Clock = Clock.systemUTC() ) { private val snapshotWindowPolicy = RecommendationSnapshotWindowPolicy() @@ -129,8 +130,7 @@ class HomeRecommendationQueryService( memberId: Long? = null, includeAdultCommunities: Boolean = false ): List { - val snapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.POPULAR_COMMUNITY) - .take(POPULAR_COMMUNITY_CANDIDATE_LIMIT) + val snapshots = findPopularCommunitySnapshotsWithFallback() val detailsById = queryPort.findPopularCommunityRecommendationDetails( snapshots.map { it.targetId }, memberId, @@ -144,6 +144,21 @@ class HomeRecommendationQueryService( }.take(limit) } + private fun findPopularCommunitySnapshotsWithFallback(): List { + val nowUtc = LocalDateTime.now(clock) + val snapshotAt = snapshotWindowPolicy.previousKstDayUtcWindow(nowUtc).snapshotAt + val snapshots = snapshotPort.findSnapshots( + RecommendedSectionType.POPULAR_COMMUNITY, + snapshotAt, + limit = POPULAR_COMMUNITY_CANDIDATE_LIMIT + ) + if (snapshots.isNotEmpty()) return snapshots + if (snapshotPort.existsSnapshot(RecommendedSectionType.POPULAR_COMMUNITY, snapshotAt)) return emptyList() + return popularCommunitySnapshotFallbackService + ?.refreshPopularCommunityIfMissing(offset = 0, limit = POPULAR_COMMUNITY_CANDIDATE_LIMIT, nowUtc = nowUtc) + .orEmpty() + } + fun findGenreCreatorRecommendations( memberId: Long?, includeAdultGenres: Boolean, 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 0f782bea..e2d4d5a8 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 @@ -412,9 +412,10 @@ class HomeRecommendationQueryServiceTest { } @Test - @DisplayName("인기 커뮤니티 추천은 최신 스냅샷 10개를 기준으로 크리에이터 중복을 제거하고 상세를 조립한다") - fun shouldFindPopularCommunitiesFromLatestSnapshotsWithLimitDetailsAndCreatorUniqueness() { - val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + @DisplayName("인기 커뮤니티 추천은 대상일 스냅샷 10개를 기준으로 크리에이터 중복을 제거하고 상세를 조립한다") + fun shouldFindPopularCommunitiesFromTargetDaySnapshotsWithLimitDetailsAndCreatorUniqueness() { + val service = HomeRecommendationQueryService(port, snapshotPort, clock = fixedClock()) + val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59) snapshotPort.replaceSnapshots( RecommendedSectionType.POPULAR_COMMUNITY, snapshotAt, @@ -487,7 +488,8 @@ class HomeRecommendationQueryServiceTest { @Test @DisplayName("인기 커뮤니티 추천은 상위 스냅샷 중복 제거 후 뒤 후보로 기본 10개를 채운다") fun shouldBackfillPopularCommunitiesAfterRemovingDuplicateCreators() { - val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + val service = HomeRecommendationQueryService(port, snapshotPort, clock = fixedClock()) + val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59) snapshotPort.replaceSnapshots( RecommendedSectionType.POPULAR_COMMUNITY, snapshotAt, @@ -521,6 +523,77 @@ class HomeRecommendationQueryServiceTest { assertEquals(communities.size, communities.map { it.creatorId }.toSet().size) } + @Test + @DisplayName("인기 커뮤니티 추천은 대상일 스냅샷이 없고 오래된 스냅샷만 있으면 fallback refresh를 호출한다") + fun shouldFallbackWhenOnlyStalePopularCommunitySnapshotsExist() { + val fallback = FakePopularCommunitySnapshotFallbackPort( + listOf(snapshot(RecommendedSectionType.POPULAR_COMMUNITY, 10L, 10.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59))) + ) + val service = HomeRecommendationQueryService( + port, + snapshotPort, + popularCommunitySnapshotFallbackService = fallback, + clock = fixedClock() + ) + val staleSnapshotAt = LocalDateTime.of(2026, 7, 8, 14, 59, 59) + snapshotPort.replaceSnapshots( + RecommendedSectionType.POPULAR_COMMUNITY, + staleSnapshotAt, + listOf(snapshot(RecommendedSectionType.POPULAR_COMMUNITY, 99L, 99.0, staleSnapshotAt)) + ) + port.popularCommunityDetails = listOf( + HomePopularCommunityRecommendationRecord( + communityId = 10L, + creatorId = 110L, + creatorNickname = "fallback-community-creator", + creatorProfileImage = null, + imagePath = null, + audioPath = null, + content = "content", + price = 0, + createdAt = LocalDateTime.of(2026, 7, 9, 1, 0), + likeCount = 0L, + commentCount = 0L, + existOrdered = false, + isLiked = false + ) + ) + + val communities = service.findPopularCommunityRecommendations(memberId = 105L) + + assertEquals(0L, fallback.offset) + assertEquals(20, fallback.limit) + assertEquals(LocalDateTime.of(2026, 7, 9, 21, 0), fallback.nowUtc) + assertEquals(listOf(10L), port.popularCommunityDetailIds) + assertEquals(listOf(10L), communities.map { it.communityId }) + } + + @Test + @DisplayName("인기 커뮤니티 추천은 빈 스냅샷 refresh 완료 marker가 있으면 fallback refresh를 반복하지 않는다") + fun shouldNotFallbackWhenEmptyPopularCommunitySnapshotCycleExists() { + val fallback = FakePopularCommunitySnapshotFallbackPort( + listOf(snapshot(RecommendedSectionType.POPULAR_COMMUNITY, 99L, 99.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59))) + ) + val service = HomeRecommendationQueryService( + port, + snapshotPort, + popularCommunitySnapshotFallbackService = fallback, + clock = fixedClock() + ) + snapshotPort.replaceSnapshots( + RecommendedSectionType.POPULAR_COMMUNITY, + LocalDateTime.of(2026, 7, 9, 14, 59, 59), + emptyList() + ) + + val communities = service.findPopularCommunityRecommendations(memberId = 105L) + + assertEquals(null, fallback.offset) + assertEquals(null, fallback.limit) + assertEquals(emptyList(), port.popularCommunityDetailIds) + assertEquals(emptyList(), communities) + } + @Test @DisplayName("최신 스냅샷이 없으면 AI 캐릭터/최근 응원/인기 커뮤니티 추천은 빈 배열을 반환한다") fun shouldReturnEmptyListWhenLatestSnapshotsDoNotExist() { @@ -969,7 +1042,7 @@ class HomeRecommendationQueryServiceTest { override fun findPopularCommunitySnapshots( windowStart: LocalDateTime, - snapshotAt: LocalDateTime, + windowEndExclusive: LocalDateTime, limit: Int ): List = emptyList() @@ -1071,7 +1144,9 @@ private class FakeHomeRecommendationSnapshotPort : RecommendationSnapshotPort { } private fun supportsEmptySnapshotMarker(sectionType: RecommendedSectionType): Boolean { - return sectionType == RecommendedSectionType.AI_CHARACTER || sectionType == RecommendedSectionType.CHEER_CREATOR + return sectionType == RecommendedSectionType.AI_CHARACTER || + sectionType == RecommendedSectionType.CHEER_CREATOR || + sectionType == RecommendedSectionType.POPULAR_COMMUNITY } } @@ -1098,6 +1173,25 @@ private class FakeCheerCreatorSnapshotFallbackPort( } } +private class FakePopularCommunitySnapshotFallbackPort( + private val snapshots: List +) : PopularCommunitySnapshotFallbackPort { + var offset: Long? = null + var limit: Int? = null + var nowUtc: LocalDateTime? = null + + override fun refreshPopularCommunityIfMissing( + offset: Long, + limit: Int, + nowUtc: LocalDateTime + ): List { + this.offset = offset + this.limit = limit + this.nowUtc = nowUtc + return snapshots + } +} + private class FakeAiCharacterSnapshotFallbackPort( private val snapshots: List ) : AiCharacterSnapshotFallbackPort {