feat(home): 인기 커뮤니티 fallback을 홈 조회에 연결한다
This commit is contained in:
@@ -27,6 +27,7 @@ class HomeRecommendationQueryService(
|
|||||||
private val snapshotPort: RecommendationSnapshotPort,
|
private val snapshotPort: RecommendationSnapshotPort,
|
||||||
private val aiCharacterSnapshotFallbackService: AiCharacterSnapshotFallbackPort? = null,
|
private val aiCharacterSnapshotFallbackService: AiCharacterSnapshotFallbackPort? = null,
|
||||||
private val cheerCreatorSnapshotFallbackService: CheerCreatorSnapshotFallbackPort? = null,
|
private val cheerCreatorSnapshotFallbackService: CheerCreatorSnapshotFallbackPort? = null,
|
||||||
|
private val popularCommunitySnapshotFallbackService: PopularCommunitySnapshotFallbackPort? = null,
|
||||||
private val clock: Clock = Clock.systemUTC()
|
private val clock: Clock = Clock.systemUTC()
|
||||||
) {
|
) {
|
||||||
private val snapshotWindowPolicy = RecommendationSnapshotWindowPolicy()
|
private val snapshotWindowPolicy = RecommendationSnapshotWindowPolicy()
|
||||||
@@ -129,8 +130,7 @@ class HomeRecommendationQueryService(
|
|||||||
memberId: Long? = null,
|
memberId: Long? = null,
|
||||||
includeAdultCommunities: Boolean = false
|
includeAdultCommunities: Boolean = false
|
||||||
): List<HomePopularCommunityRecommendationRecord> {
|
): List<HomePopularCommunityRecommendationRecord> {
|
||||||
val snapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.POPULAR_COMMUNITY)
|
val snapshots = findPopularCommunitySnapshotsWithFallback()
|
||||||
.take(POPULAR_COMMUNITY_CANDIDATE_LIMIT)
|
|
||||||
val detailsById = queryPort.findPopularCommunityRecommendationDetails(
|
val detailsById = queryPort.findPopularCommunityRecommendationDetails(
|
||||||
snapshots.map { it.targetId },
|
snapshots.map { it.targetId },
|
||||||
memberId,
|
memberId,
|
||||||
@@ -144,6 +144,21 @@ class HomeRecommendationQueryService(
|
|||||||
}.take(limit)
|
}.take(limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun findPopularCommunitySnapshotsWithFallback(): List<RecommendationSnapshotRecord> {
|
||||||
|
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(
|
fun findGenreCreatorRecommendations(
|
||||||
memberId: Long?,
|
memberId: Long?,
|
||||||
includeAdultGenres: Boolean,
|
includeAdultGenres: Boolean,
|
||||||
|
|||||||
@@ -412,9 +412,10 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("인기 커뮤니티 추천은 최신 스냅샷 10개를 기준으로 크리에이터 중복을 제거하고 상세를 조립한다")
|
@DisplayName("인기 커뮤니티 추천은 대상일 스냅샷 10개를 기준으로 크리에이터 중복을 제거하고 상세를 조립한다")
|
||||||
fun shouldFindPopularCommunitiesFromLatestSnapshotsWithLimitDetailsAndCreatorUniqueness() {
|
fun shouldFindPopularCommunitiesFromTargetDaySnapshotsWithLimitDetailsAndCreatorUniqueness() {
|
||||||
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(
|
snapshotPort.replaceSnapshots(
|
||||||
RecommendedSectionType.POPULAR_COMMUNITY,
|
RecommendedSectionType.POPULAR_COMMUNITY,
|
||||||
snapshotAt,
|
snapshotAt,
|
||||||
@@ -487,7 +488,8 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
@DisplayName("인기 커뮤니티 추천은 상위 스냅샷 중복 제거 후 뒤 후보로 기본 10개를 채운다")
|
@DisplayName("인기 커뮤니티 추천은 상위 스냅샷 중복 제거 후 뒤 후보로 기본 10개를 채운다")
|
||||||
fun shouldBackfillPopularCommunitiesAfterRemovingDuplicateCreators() {
|
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(
|
snapshotPort.replaceSnapshots(
|
||||||
RecommendedSectionType.POPULAR_COMMUNITY,
|
RecommendedSectionType.POPULAR_COMMUNITY,
|
||||||
snapshotAt,
|
snapshotAt,
|
||||||
@@ -521,6 +523,77 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
assertEquals(communities.size, communities.map { it.creatorId }.toSet().size)
|
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<Long>(), port.popularCommunityDetailIds)
|
||||||
|
assertEquals(emptyList<HomePopularCommunityRecommendationRecord>(), communities)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("최신 스냅샷이 없으면 AI 캐릭터/최근 응원/인기 커뮤니티 추천은 빈 배열을 반환한다")
|
@DisplayName("최신 스냅샷이 없으면 AI 캐릭터/최근 응원/인기 커뮤니티 추천은 빈 배열을 반환한다")
|
||||||
fun shouldReturnEmptyListWhenLatestSnapshotsDoNotExist() {
|
fun shouldReturnEmptyListWhenLatestSnapshotsDoNotExist() {
|
||||||
@@ -969,7 +1042,7 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
|
|
||||||
override fun findPopularCommunitySnapshots(
|
override fun findPopularCommunitySnapshots(
|
||||||
windowStart: LocalDateTime,
|
windowStart: LocalDateTime,
|
||||||
snapshotAt: LocalDateTime,
|
windowEndExclusive: LocalDateTime,
|
||||||
limit: Int
|
limit: Int
|
||||||
): List<RecommendationSnapshotRecord> = emptyList()
|
): List<RecommendationSnapshotRecord> = emptyList()
|
||||||
|
|
||||||
@@ -1071,7 +1144,9 @@ private class FakeHomeRecommendationSnapshotPort : RecommendationSnapshotPort {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun supportsEmptySnapshotMarker(sectionType: RecommendedSectionType): Boolean {
|
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<RecommendationSnapshotRecord>
|
||||||
|
) : PopularCommunitySnapshotFallbackPort {
|
||||||
|
var offset: Long? = null
|
||||||
|
var limit: Int? = null
|
||||||
|
var nowUtc: LocalDateTime? = null
|
||||||
|
|
||||||
|
override fun refreshPopularCommunityIfMissing(
|
||||||
|
offset: Long,
|
||||||
|
limit: Int,
|
||||||
|
nowUtc: LocalDateTime
|
||||||
|
): List<RecommendationSnapshotRecord> {
|
||||||
|
this.offset = offset
|
||||||
|
this.limit = limit
|
||||||
|
this.nowUtc = nowUtc
|
||||||
|
return snapshots
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private class FakeAiCharacterSnapshotFallbackPort(
|
private class FakeAiCharacterSnapshotFallbackPort(
|
||||||
private val snapshots: List<RecommendationSnapshotRecord>
|
private val snapshots: List<RecommendationSnapshotRecord>
|
||||||
) : AiCharacterSnapshotFallbackPort {
|
) : AiCharacterSnapshotFallbackPort {
|
||||||
|
|||||||
Reference in New Issue
Block a user