diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryService.kt index 750326f8..645e5777 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryService.kt @@ -116,14 +116,38 @@ class HomeRecommendationQueryService( creatorLimit: Int = DEFAULT_GENRE_CREATOR_CREATOR_LIMIT ): List { val selectedCreatorIds = mutableSetOf() - val candidateLimit = genreLimit * creatorLimit + val partialFallbackGroups = mutableListOf() + val selectedGroups = mutableListOf() - return queryPort.findGenreCreatorRecommendations(memberId, includeAdultGenres, genreLimit, candidateLimit) - .map { group -> - group.copy(creators = group.creators.filter { selectedCreatorIds.add(it.creatorId) }.take(creatorLimit)) + queryPort.findGenreCreatorRecommendations(memberId, includeAdultGenres, genreLimit, creatorLimit) + .forEach { group -> + if (selectedGroups.size >= genreLimit) return@forEach + + val creators = group.creators.filter { it.creatorId !in selectedCreatorIds }.take(creatorLimit) + if (creators.isEmpty()) return@forEach + + val deduplicatedGroup = group.copy(creators = creators) + if (group.isViewedTheme || creators.size == creatorLimit) { + selectedGroups.add(deduplicatedGroup) + selectedCreatorIds.addAll(creators.map { it.creatorId }) + } else { + partialFallbackGroups.add(group) + } } - .filter { it.creators.isNotEmpty() } - .take(genreLimit) + + if (selectedGroups.size < genreLimit) { + partialFallbackGroups.forEach { group -> + if (selectedGroups.size >= genreLimit) return@forEach + + val creators = group.creators.filter { it.creatorId !in selectedCreatorIds }.take(creatorLimit) + if (creators.isEmpty()) return@forEach + + selectedGroups.add(group.copy(creators = creators)) + selectedCreatorIds.addAll(creators.map { it.creatorId }) + } + } + + return selectedGroups.take(genreLimit) } fun resolveAudioContentActivityType(theme: String): RecommendedActivityType { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryServiceTest.kt index 96237145..aac8f053 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryServiceTest.kt @@ -374,7 +374,7 @@ class HomeRecommendationQueryServiceTest { assertEquals(100L, port.genreCreatorMemberId) assertEquals(true, port.genreCreatorIncludeAdultGenres) assertEquals(5, port.genreCreatorGenreLimit) - assertEquals(40, port.genreCreatorCreatorLimit) + assertEquals(8, port.genreCreatorCreatorLimit) assertEquals(listOf(10L, 11L), recommendations[0].creators.map { it.creatorId }) assertEquals(listOf(12L, 13L), recommendations[1].creators.map { it.creatorId }) } @@ -429,6 +429,150 @@ class HomeRecommendationQueryServiceTest { assertEquals(false, recommendations.any { it.creators.isEmpty() }) } + @Test + @DisplayName("장르 기반 크리에이터 추천은 중복 제거 후 랜덤 보충 장르가 8명이 되지 않으면 뒤 후보로 대체한다") + fun shouldBackfillRandomThemesWhenCreatorDeduplicationMakesThemeUnderfilled() { + val sharedCreators = (1L..8L).map { creatorId -> + HomeGenreCreatorRecommendationRecord( + creatorId = creatorId, + creatorNickname = "shared-$creatorId", + creatorProfileImage = null + ) + } + val uniqueCreators = (101L..108L).map { creatorId -> + HomeGenreCreatorRecommendationRecord( + creatorId = creatorId, + creatorNickname = "unique-$creatorId", + creatorProfileImage = null + ) + } + port.genreCreatorRecommendations = listOf( + HomeGenreCreatorRecommendationGroup( + genreId = 1L, + genreName = "first-random-theme", + creators = sharedCreators + ), + HomeGenreCreatorRecommendationGroup( + genreId = 2L, + genreName = "underfilled-after-dedup-theme", + creators = sharedCreators + ), + HomeGenreCreatorRecommendationGroup( + genreId = 3L, + genreName = "backfill-random-theme", + creators = uniqueCreators + ) + ) + + val recommendations = service.findGenreCreatorRecommendations( + memberId = null, + includeAdultGenres = false, + genreLimit = 2, + creatorLimit = 8 + ) + + assertEquals(listOf(1L, 3L), recommendations.map { it.genreId }) + assertEquals(true, recommendations.all { it.creators.size == 8 }) + } + + @Test + @DisplayName("장르 기반 크리에이터 추천은 보류된 부분 후보의 크리에이터를 뒤 충분 후보에서 중복 제거하지 않는다") + fun shouldNotConsumeCreatorsFromDeferredPartialFallbackCandidates() { + val firstCreators = (1L..8L).map { creatorId -> + HomeGenreCreatorRecommendationRecord( + creatorId = creatorId, + creatorNickname = "first-$creatorId", + creatorProfileImage = null + ) + } + val partialUniqueCreator = HomeGenreCreatorRecommendationRecord( + creatorId = 101L, + creatorNickname = "partial-unique", + creatorProfileImage = null + ) + val fullBackfillCreators = (101L..108L).map { creatorId -> + HomeGenreCreatorRecommendationRecord( + creatorId = creatorId, + creatorNickname = "full-$creatorId", + creatorProfileImage = null + ) + } + port.genreCreatorRecommendations = listOf( + HomeGenreCreatorRecommendationGroup( + genreId = 1L, + genreName = "first-random-theme", + creators = firstCreators + ), + HomeGenreCreatorRecommendationGroup( + genreId = 2L, + genreName = "deferred-partial-theme", + creators = firstCreators.take(7) + partialUniqueCreator + ), + HomeGenreCreatorRecommendationGroup( + genreId = 3L, + genreName = "full-backfill-theme", + creators = fullBackfillCreators + ) + ) + + val recommendations = service.findGenreCreatorRecommendations( + memberId = null, + includeAdultGenres = false, + genreLimit = 2, + creatorLimit = 8 + ) + + assertEquals(listOf(1L, 3L), recommendations.map { it.genreId }) + assertEquals((101L..108L).toList(), recommendations[1].creators.map { it.creatorId }) + } + + @Test + @DisplayName("장르 기반 크리에이터 추천은 조회 이력 장르는 중복 제거 후 8명 미만이어도 유지한다") + fun shouldKeepViewedThemeWhenCreatorDeduplicationMakesThemeUnderfilled() { + val sharedCreators = (1L..8L).map { creatorId -> + HomeGenreCreatorRecommendationRecord( + creatorId = creatorId, + creatorNickname = "shared-$creatorId", + creatorProfileImage = null + ) + } + val uniqueCreators = (101L..108L).map { creatorId -> + HomeGenreCreatorRecommendationRecord( + creatorId = creatorId, + creatorNickname = "unique-$creatorId", + creatorProfileImage = null + ) + } + port.genreCreatorRecommendations = listOf( + HomeGenreCreatorRecommendationGroup( + genreId = 1L, + genreName = "first-random-theme", + creators = sharedCreators + ), + HomeGenreCreatorRecommendationGroup( + genreId = 2L, + genreName = "viewed-theme", + creators = sharedCreators + uniqueCreators.first(), + isViewedTheme = true + ), + HomeGenreCreatorRecommendationGroup( + genreId = 3L, + genreName = "backfill-random-theme", + creators = uniqueCreators + ) + ) + + val recommendations = service.findGenreCreatorRecommendations( + memberId = 100L, + includeAdultGenres = false, + genreLimit = 2, + creatorLimit = 8 + ) + + assertEquals(listOf(1L, 2L), recommendations.map { it.genreId }) + assertEquals(1, recommendations[1].creators.size) + } + private class FakeHomeRecommendationQueryPort : HomeRecommendationQueryPort { var liveLimit: Int? = null var liveOffset: Int? = null