From 29db5c3fd0cdd44e349aaf00b895828e3b5a2ed4 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 8 Jun 2026 10:11:42 +0900 Subject: [PATCH] =?UTF-8?q?fix(recommend):=20=EC=9E=A5=EB=A5=B4=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=EC=97=90=EC=84=9C=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=EC=9E=90=EB=A5=BC=20=EC=A0=9C=EC=99=B8=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...efaultHomeRecommendationQueryRepository.kt | 3 + ...ltHomeRecommendationQueryRepositoryTest.kt | 102 ++++++++++++++++++ .../HomeRecommendationQueryServiceTest.kt | 29 +++++ 3 files changed, 134 insertions(+) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt index e6b3f876..08c2a96f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt @@ -933,6 +933,7 @@ class DefaultHomeRecommendationQueryRepository( and (:includeAdultGenres = true or c.is_adult = false) and m.is_active = true and m.role = 'CREATOR' + and (:memberId is null or m.id <> :memberId) and not exists ( select 1 from creator_following cf @@ -1009,6 +1010,7 @@ class DefaultHomeRecommendationQueryRepository( and (:includeAdultGenres = true or c.is_adult = false) and m.is_active = true and m.role = 'CREATOR' + and (:memberId is null or m.id <> :memberId) and not exists ( select 1 from creator_following cf @@ -1052,6 +1054,7 @@ class DefaultHomeRecommendationQueryRepository( and (:includeAdultGenres = true or c.is_adult = false) and m.is_active = true and m.role = 'CREATOR' + and (:memberId is null or m.id <> :memberId) and not exists ( select 1 from creator_following cf diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt index bded8864..ced84ece 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt @@ -1427,6 +1427,108 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( assertEquals(listOf(visibleCreator.id), recommendations.single().creators.map { it.creatorId }) } + @Test + @DisplayName("장르 기반 크리에이터 추천은 요청자 본인만 있는 장르를 후보에서 제외한다") + fun shouldExcludeRequesterOnlyGenreFromGenreCreatorRecommendations() { + val viewer = saveMember("self-only-viewer", MemberRole.CREATOR) + val fallbackCreator = saveMember("self-only-fallback", MemberRole.CREATOR) + val selfTheme = saveTheme("self-only-theme") + val fallbackTheme = saveTheme("self-only-fallback-theme") + val selfContent = saveAudioContent( + viewer, + LocalDateTime.of(2026, 5, 30, 10, 0), + isActive = true, + theme = selfTheme + ) + saveAudioContent( + fallbackCreator, + LocalDateTime.of(2026, 5, 30, 11, 0), + isActive = true, + theme = fallbackTheme + ) + entityManager.persist( + CreatorContentViewHistory( + memberId = viewer.id!!, + contentId = selfContent.id!!, + genreId = 999L, + viewedAt = LocalDateTime.of(2026, 5, 31, 10, 0) + ) + ) + flushAndClear() + + val recommendations = repository.findGenreCreatorRecommendations( + memberId = viewer.id!!, + includeAdultGenres = false, + genreLimit = 1, + creatorLimit = 8 + ) + + assertEquals(listOf(fallbackTheme.id), recommendations.map { it.genreId }) + assertEquals(listOf(fallbackCreator.id), recommendations.single().creators.map { it.creatorId }) + } + + @Test + @DisplayName("장르 기반 크리에이터 추천은 요청자 본인을 제외한 뒤 대체 크리에이터로 8명을 채운다") + fun shouldBackfillCreatorAfterExcludingRequesterFromGenreCreatorRecommendations() { + val viewer = saveMember("self-backfill-viewer", MemberRole.CREATOR) + val theme = saveTheme("self-backfill-theme") + saveAudioContent(viewer, LocalDateTime.of(2026, 5, 30, 10, 0), isActive = true, theme = theme) + val expectedCreators = (0..8).map { index -> + saveMember("self-backfill-creator-$index", MemberRole.CREATOR).also { creator -> + saveAudioContent( + creator, + LocalDateTime.of(2026, 5, 30, 11, 0).plusMinutes(index.toLong()), + isActive = true, + theme = theme + ) + } + } + flushAndClear() + + val recommendations = repository.findGenreCreatorRecommendations( + memberId = viewer.id!!, + includeAdultGenres = false, + genreLimit = 1, + creatorLimit = 8 + ) + val creatorIds = recommendations.single().creators.map { it.creatorId } + + assertEquals(8, creatorIds.size) + assertEquals(false, creatorIds.contains(viewer.id)) + assertEquals(true, creatorIds.all { it in expectedCreators.map { creator -> creator.id } }) + } + + @Test + @DisplayName("장르 기반 크리에이터 추천은 요청자 본인 제외 후 대체가 없으면 가능한 크리에이터만 응답한다") + fun shouldReturnAvailableCreatorsAfterExcludingRequesterFromGenreCreatorRecommendations() { + val viewer = saveMember("self-partial-viewer", MemberRole.CREATOR) + val theme = saveTheme("self-partial-theme") + saveAudioContent(viewer, LocalDateTime.of(2026, 5, 30, 10, 0), isActive = true, theme = theme) + val expectedCreators = (0 until 7).map { index -> + saveMember("self-partial-creator-$index", MemberRole.CREATOR).also { creator -> + saveAudioContent( + creator, + LocalDateTime.of(2026, 5, 30, 11, 0).plusMinutes(index.toLong()), + isActive = true, + theme = theme + ) + } + } + flushAndClear() + + val recommendations = repository.findGenreCreatorRecommendations( + memberId = viewer.id!!, + includeAdultGenres = false, + genreLimit = 1, + creatorLimit = 8 + ) + val creatorIds = recommendations.single().creators.map { it.creatorId } + + assertEquals(7, creatorIds.size) + assertEquals(false, creatorIds.contains(viewer.id)) + assertEquals(expectedCreators.map { it.id }.toSet(), creatorIds.toSet()) + } + @Test @DisplayName("장르 기반 크리에이터 추천은 series_genre가 아니라 content_theme 기준으로 그룹을 만든다") fun shouldGroupGenreCreatorRecommendationsByContentThemeNotSeriesGenre() { 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 5a2cb42b..9b11bdd1 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 @@ -526,6 +526,35 @@ class HomeRecommendationQueryServiceTest { assertEquals((101L..108L).toList(), recommendations[1].creators.map { it.creatorId }) } + @Test + @DisplayName("장르 기반 크리에이터 추천은 장르의 추천 가능 크리에이터가 8명 미만이면 가능한 만큼 응답한다") + fun shouldReturnAvailableCreatorsWhenGenreCreatorCountIsUnderLimit() { + val availableCreators = (1L..7L).map { creatorId -> + HomeGenreCreatorRecommendationRecord( + creatorId = creatorId, + creatorNickname = "available-$creatorId", + creatorProfileImage = null + ) + } + port.genreCreatorRecommendations = listOf( + HomeGenreCreatorRecommendationGroup( + genreId = 1L, + genreName = "partial-theme", + creators = availableCreators + ) + ) + + val recommendations = service.findGenreCreatorRecommendations( + memberId = 100L, + includeAdultGenres = false, + genreLimit = 5, + creatorLimit = 8 + ) + + assertEquals(listOf(1L), recommendations.map { it.genreId }) + assertEquals((1L..7L).toList(), recommendations.single().creators.map { it.creatorId }) + } + @Test @DisplayName("장르 기반 크리에이터 추천은 조회 이력 장르는 중복 제거 후 8명 미만이어도 유지한다") fun shouldKeepViewedThemeWhenCreatorDeduplicationMakesThemeUnderfilled() {