From 5bea7cfb648f6b2fd144a2e27816d1e806020c6a Mon Sep 17 00:00:00 2001 From: Klaus Date: Sun, 31 May 2026 18:20:51 +0900 Subject: [PATCH] =?UTF-8?q?feat(recommend):=20=EC=9E=A5=EB=A5=B4=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=ED=81=AC=EB=A6=AC=EC=97=90=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=B6=94=EC=B2=9C=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...efaultHomeRecommendationQueryRepository.kt | 149 ++++++++++ .../HomeRecommendationQueryService.kt | 20 ++ .../port/out/HomeRecommendationQueryPort.kt | 19 ++ ...ltHomeRecommendationQueryRepositoryTest.kt | 264 +++++++++++++++++- .../HomeRecommendationQueryServiceTest.kt | 126 +++++++++ 5 files changed, 567 insertions(+), 11 deletions(-) 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 98ffba73..b74d2ad3 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 @@ -26,6 +26,8 @@ import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeAiCharacterRecommendat import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeBannerRecommendationRecord import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeCheerCreatorRecommendationRecord import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeFirstAudioContentRecord +import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeGenreCreatorRecommendationGroup +import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeGenreCreatorRecommendationRecord import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeLiveRecommendationRecord import kr.co.vividnext.sodalive.v2.recommend.port.out.HomePopularCommunityRecommendationRecord import kr.co.vividnext.sodalive.v2.recommend.port.out.RecentDebutCreatorRecord @@ -741,6 +743,148 @@ class DefaultHomeRecommendationQueryRepository( .fetch() } + override fun findGenreCreatorRecommendations( + memberId: Long?, + includeAdultGenres: Boolean, + genreLimit: Int, + creatorLimit: Int + ): List { + val genres = findGenreRecommendationTargets( + memberId = memberId, + includeAdultGenres = includeAdultGenres, + targetLimit = (genreLimit * creatorLimit).coerceAtLeast(genreLimit) + ) + + return genres.asSequence().mapNotNull { genre -> + val creators = findCreatorsByGenre( + genreId = genre.id, + memberId = memberId, + includeAdultGenres = includeAdultGenres, + creatorLimit = creatorLimit + ) + creators.takeIf { it.isNotEmpty() }?.let { + HomeGenreCreatorRecommendationGroup( + genreId = genre.id, + genreName = genre.name, + creators = it + ) + } + }.toList() + } + + private fun findGenreRecommendationTargets( + memberId: Long?, + includeAdultGenres: Boolean, + targetLimit: Int + ): List { + val sql = """ + select selected.id, + selected.genre + from ( + select ct.id, + ct.theme as genre, + case when viewed.theme_id is null then 1 else 0 end as source_rank, + rand() as random_tie_breaker + from content_theme ct + left join ( + select distinct c.theme_id + from creator_content_view_history ccvh + join content c on c.id = ccvh.content_id + where (:memberId is not null and ccvh.member_id = :memberId) + ) viewed on viewed.theme_id = ct.id + where ct.is_active = true + and exists ( + select 1 + from content c + join member m on m.id = c.member_id + where c.theme_id = ct.id + and c.is_active = true + and (:includeAdultGenres = true or c.is_adult = false) + and m.is_active = true + and m.role = 'CREATOR' + and not exists ( + select 1 + from creator_following cf + where :memberId is not null + and cf.member_id = :memberId + and cf.creator_id = m.id + and cf.is_active = true + ) + ) + ) selected + order by selected.source_rank asc, selected.random_tie_breaker asc + limit :targetLimit + """.trimIndent() + + val query = entityManager.createNativeQuery(sql) + .setParameter("memberId", memberId) + .setParameter("includeAdultGenres", includeAdultGenres) + .setParameter("targetLimit", targetLimit) + + @Suppress("UNCHECKED_CAST") + val rows = query.resultList as List> + + return rows.map { row -> + GenreRecommendationTarget( + id = (row[0] as Number).toLong(), + name = row[1] as String + ) + } + } + + private fun findCreatorsByGenre( + genreId: Long, + memberId: Long?, + includeAdultGenres: Boolean, + creatorLimit: Int + ): List { + val sql = """ + select candidates.creator_id, + candidates.creator_nickname, + candidates.creator_profile_image + from ( + select m.id as creator_id, + m.nickname as creator_nickname, + m.profile_image as creator_profile_image + from content c + join member m on m.id = c.member_id + where c.theme_id = :genreId + and c.is_active = true + and (:includeAdultGenres = true or c.is_adult = false) + and m.is_active = true + and m.role = 'CREATOR' + and not exists ( + select 1 + from creator_following cf + where :memberId is not null + and cf.member_id = :memberId + and cf.creator_id = m.id + and cf.is_active = true + ) + group by m.id, m.nickname, m.profile_image + ) candidates + order by rand() asc + limit :creatorLimit + """.trimIndent() + + val query = entityManager.createNativeQuery(sql) + .setParameter("genreId", genreId) + .setParameter("memberId", memberId) + .setParameter("includeAdultGenres", includeAdultGenres) + .setParameter("creatorLimit", creatorLimit) + + @Suppress("UNCHECKED_CAST") + val rows = query.resultList as List> + + return rows.map { row -> + HomeGenreCreatorRecommendationRecord( + creatorId = (row[0] as Number).toLong(), + creatorNickname = row[1] as String, + creatorProfileImage = row[2] as String? + ) + } + } + private fun executeSnapshotQuery( sql: String, sectionType: RecommendedSectionType, @@ -830,4 +974,9 @@ class DefaultHomeRecommendationQueryRepository( companion object { private const val LIVE_REPLAY_THEME = "다시듣기" } + + private data class GenreRecommendationTarget( + val id: Long, + val name: String + ) } 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 2c8f73fd..c8aba58a 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 @@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeAiCharacterRecommendat import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeBannerRecommendationRecord import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeCheerCreatorRecommendationRecord import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeFirstAudioContentRecord +import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeGenreCreatorRecommendationGroup import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeLiveRecommendationRecord import kr.co.vividnext.sodalive.v2.recommend.port.out.HomePopularCommunityRecommendationRecord import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeRecommendationQueryPort @@ -86,6 +87,23 @@ class HomeRecommendationQueryService( }.take(limit) } + fun findGenreCreatorRecommendations( + memberId: Long?, + includeAdultGenres: Boolean, + genreLimit: Int = DEFAULT_GENRE_CREATOR_GENRE_LIMIT, + creatorLimit: Int = DEFAULT_GENRE_CREATOR_CREATOR_LIMIT + ): List { + val selectedCreatorIds = mutableSetOf() + val candidateLimit = genreLimit * creatorLimit + + return queryPort.findGenreCreatorRecommendations(memberId, includeAdultGenres, genreLimit, candidateLimit) + .map { group -> + group.copy(creators = group.creators.filter { selectedCreatorIds.add(it.creatorId) }.take(creatorLimit)) + } + .filter { it.creators.isNotEmpty() } + .take(genreLimit) + } + fun resolveAudioContentActivityType(theme: String): RecommendedActivityType { return if (theme == LIVE_REPLAY_THEME) { RecommendedActivityType.LIVE_REPLAY @@ -107,6 +125,8 @@ class HomeRecommendationQueryService( private const val DEFAULT_AI_CHARACTER_LIMIT = 10 private const val DEFAULT_CHEER_CREATOR_LIMIT = 8 private const val DEFAULT_POPULAR_COMMUNITY_LIMIT = 10 + private const val DEFAULT_GENRE_CREATOR_GENRE_LIMIT = 5 + private const val DEFAULT_GENRE_CREATOR_CREATOR_LIMIT = 8 private const val POPULAR_COMMUNITY_CANDIDATE_LIMIT = 20 private const val LIVE_REPLAY_THEME = "다시듣기" } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/port/out/HomeRecommendationQueryPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/port/out/HomeRecommendationQueryPort.kt index 64820de5..5a9946a0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/port/out/HomeRecommendationQueryPort.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/port/out/HomeRecommendationQueryPort.kt @@ -40,6 +40,13 @@ interface HomeRecommendationQueryPort { communityIds: List, includeAdultCommunities: Boolean ): List + + fun findGenreCreatorRecommendations( + memberId: Long?, + includeAdultGenres: Boolean, + genreLimit: Int, + creatorLimit: Int + ): List } data class HomeLiveRecommendationRecord( @@ -119,3 +126,15 @@ data class HomePopularCommunityRecommendationRecord( val likeCount: Long, val commentCount: Long ) + +data class HomeGenreCreatorRecommendationGroup( + val genreId: Long, + val genreName: String, + val creators: List +) + +data class HomeGenreCreatorRecommendationRecord( + val creatorId: Long, + val creatorNickname: String, + val creatorProfileImage: String? +) 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 2f27118e..dcabef17 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 @@ -22,6 +22,7 @@ import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTab import kr.co.vividnext.sodalive.content.theme.AudioContentTheme import kr.co.vividnext.sodalive.creator.admin.content.series.Series +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent import kr.co.vividnext.sodalive.event.Event import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity @@ -983,6 +984,223 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( ) } + @Test + @DisplayName("장르 기반 크리에이터 추천은 조회 이력 콘텐츠 테마와 랜덤 보충 테마를 고르고 팔로우 크리에이터를 제외한다") + fun shouldFindGenreCreatorRecommendationsFromViewHistoryThemeWithFallbackAndFollowExclusion() { + val viewer = saveMember("genre-viewer", MemberRole.USER) + val followedCreator = saveMember("genre-followed", MemberRole.CREATOR) + val viewedCreator = saveMember("genre-viewed", MemberRole.CREATOR) + val fallbackCreator = saveMember("genre-fallback", MemberRole.CREATOR) + val viewedTheme = saveTheme("viewed-theme") + val fallbackTheme = saveTheme("fallback-theme") + val viewedContent = saveAudioContent( + viewedCreator, + LocalDateTime.of(2026, 5, 30, 10, 0), + isActive = true, + theme = viewedTheme + ) + saveAudioContent( + followedCreator, + LocalDateTime.of(2026, 5, 30, 11, 0), + isActive = true, + theme = viewedTheme + ) + saveAudioContent( + fallbackCreator, + LocalDateTime.of(2026, 5, 30, 12, 0), + isActive = true, + theme = fallbackTheme + ) + saveFollowing(viewer, followedCreator, isActive = true) + entityManager.persist( + CreatorContentViewHistory( + memberId = viewer.id!!, + contentId = viewedContent.id!!, + genreId = 999L, + viewedAt = LocalDateTime.of(2026, 5, 31, 10, 0) + ) + ) + flushAndClear() + + val recommendations = repository.findGenreCreatorRecommendations( + memberId = viewer.id!!, + includeAdultGenres = false, + genreLimit = 2, + creatorLimit = 8 + ) + + assertEquals(2, recommendations.size) + assertEquals(viewedTheme.id, recommendations.first().genreId) + assertEquals(viewedTheme.theme, recommendations.first().genreName) + assertEquals(false, recommendations.flatMap { it.creators }.any { it.creatorId == followedCreator.id }) + assertEquals(true, recommendations.flatMap { it.creators }.any { it.creatorId == viewedCreator.id }) + assertEquals(true, recommendations.flatMap { it.creators }.any { it.creatorId == fallbackCreator.id }) + } + + @Test + @DisplayName("장르 기반 크리에이터 추천은 series_genre가 아니라 content_theme 기준으로 그룹을 만든다") + fun shouldGroupGenreCreatorRecommendationsByContentThemeNotSeriesGenre() { + val creator = saveMember("theme-source-creator", MemberRole.CREATOR) + val theme = saveTheme("theme-source") + val unrelatedGenre = saveSeriesGenre("unrelated-genre", isAdult = false) + val content = saveAudioContent(creator, LocalDateTime.of(2026, 5, 30, 10, 0), isActive = true, theme = theme) + saveSeriesContent(saveSeries("unrelated-series", creator, isActive = true, genre = unrelatedGenre), content) + flushAndClear() + + val recommendations = repository.findGenreCreatorRecommendations( + memberId = null, + includeAdultGenres = false, + genreLimit = 1, + creatorLimit = 8 + ) + + assertEquals(theme.id, recommendations.single().genreId) + assertEquals(theme.theme, recommendations.single().genreName) + assertEquals(listOf(creator.id), recommendations.single().creators.map { it.creatorId }) + } + + @Test + @DisplayName("장르 기반 크리에이터 추천은 빈 테마 그룹을 제외하고 다른 테마로 보충한다") + fun shouldSkipEmptyThemeGroupsAndBackfillOtherThemes() { + val viewer = saveMember("empty-group-viewer", MemberRole.USER) + val followedCreator = saveMember("empty-group-followed", MemberRole.CREATOR) + val inactiveCreator = saveMember("empty-group-inactive", MemberRole.CREATOR, isActive = false) + val firstCreator = saveMember("empty-group-first", MemberRole.CREATOR) + val secondCreator = saveMember("empty-group-second", MemberRole.CREATOR) + val followedTheme = saveTheme("empty-followed-theme") + val inactiveTheme = saveTheme("empty-inactive-theme") + val firstTheme = saveTheme("empty-first-theme") + val secondTheme = saveTheme("empty-second-theme") + val followedContent = saveAudioContent( + followedCreator, + LocalDateTime.of(2026, 5, 30, 10, 0), + isActive = true, + theme = followedTheme + ) + val inactiveContent = saveAudioContent( + inactiveCreator, + LocalDateTime.of(2026, 5, 30, 11, 0), + isActive = true, + theme = inactiveTheme + ) + saveAudioContent(firstCreator, LocalDateTime.of(2026, 5, 30, 12, 0), isActive = true, theme = firstTheme) + saveAudioContent(secondCreator, LocalDateTime.of(2026, 5, 30, 13, 0), isActive = true, theme = secondTheme) + saveFollowing(viewer, followedCreator, isActive = true) + listOf(followedContent, inactiveContent).forEach { content -> + entityManager.persist( + CreatorContentViewHistory( + memberId = viewer.id!!, + contentId = content.id!!, + genreId = 999L, + viewedAt = LocalDateTime.of(2026, 5, 31, 10, 0) + ) + ) + } + flushAndClear() + + val recommendations = repository.findGenreCreatorRecommendations( + memberId = viewer.id!!, + includeAdultGenres = false, + genreLimit = 2, + creatorLimit = 8 + ) + + assertEquals(2, recommendations.size) + assertEquals(false, recommendations.any { it.creators.isEmpty() }) + assertEquals(false, recommendations.any { it.genreId == followedTheme.id || it.genreId == inactiveTheme.id }) + assertEquals(setOf(firstTheme.id, secondTheme.id), recommendations.map { it.genreId }.toSet()) + } + + @Test + @DisplayName("장르 기반 크리에이터 추천은 서비스 중복 제거용 후보 그룹을 genreLimit보다 많이 반환한다") + fun shouldReturnMoreCandidateGroupsThanFinalGenreLimitForServiceDeduplicationBackfill() { + val sharedCreator = saveMember("candidate-shared", MemberRole.CREATOR) + val backfillCreator = saveMember("candidate-backfill", MemberRole.CREATOR) + val firstTheme = saveTheme("candidate-first-theme") + val duplicateTheme = saveTheme("candidate-duplicate-theme") + val backfillTheme = saveTheme("candidate-backfill-theme") + saveAudioContent(sharedCreator, LocalDateTime.of(2026, 5, 30, 10, 0), isActive = true, theme = firstTheme) + saveAudioContent(sharedCreator, LocalDateTime.of(2026, 5, 30, 11, 0), isActive = true, theme = duplicateTheme) + saveAudioContent(backfillCreator, LocalDateTime.of(2026, 5, 30, 12, 0), isActive = true, theme = backfillTheme) + flushAndClear() + + val recommendations = repository.findGenreCreatorRecommendations( + memberId = null, + includeAdultGenres = false, + genreLimit = 2, + creatorLimit = 8 + ) + + assertEquals(3, recommendations.size) + assertEquals( + setOf(firstTheme.id, duplicateTheme.id, backfillTheme.id), + recommendations.map { it.genreId }.toSet() + ) + } + + @Test + @DisplayName("장르 기반 크리에이터 추천은 같은 크리에이터의 여러 콘텐츠가 장르별 limit을 중복 소모하지 않는다") + fun shouldDeduplicateCreatorsBeforeApplyingPerGenreLimit() { + val theme = saveTheme("duplicate-theme") + val duplicateCreator = saveMember("duplicate-creator", MemberRole.CREATOR) + val otherCreator = saveMember("other-creator", MemberRole.CREATOR) + repeat(3) { index -> + saveAudioContent( + duplicateCreator, + LocalDateTime.of(2026, 5, 30, 10, 0).plusMinutes(index.toLong()), + isActive = true, + theme = theme + ) + } + saveAudioContent(otherCreator, LocalDateTime.of(2026, 5, 30, 12, 0), isActive = true, theme = theme) + flushAndClear() + + val recommendations = repository.findGenreCreatorRecommendations( + memberId = null, + includeAdultGenres = false, + genreLimit = 1, + creatorLimit = 2 + ) + + assertEquals(1, recommendations.size) + assertEquals(2, recommendations.single().creators.size) + assertEquals( + setOf(duplicateCreator.id, otherCreator.id), + recommendations.single().creators.map { it.creatorId }.toSet() + ) + } + + @Test + @DisplayName("장르 기반 크리에이터 추천은 성인 장르를 성인 노출 허용 회원에게만 포함한다") + fun shouldIncludeAdultGenreCreatorsOnlyWhenAdultGenresVisible() { + val adultCreator = saveMember("adult-genre-creator", MemberRole.CREATOR) + val adultTheme = saveTheme("adult-theme") + saveAudioContent( + adultCreator, + LocalDateTime.of(2026, 5, 30, 10, 0), + isActive = true, + theme = adultTheme, + isAdult = true + ) + flushAndClear() + + val hidden = repository.findGenreCreatorRecommendations( + memberId = null, + includeAdultGenres = false, + genreLimit = 5, + creatorLimit = 8 + ) + val visible = repository.findGenreCreatorRecommendations( + memberId = null, + includeAdultGenres = true, + genreLimit = 5, + creatorLimit = 8 + ) + + assertEquals(false, hidden.any { it.genreId == adultTheme.id }) + assertEquals(true, visible.any { it.genreId == adultTheme.id }) + } + private fun saveMember(nickname: String, role: MemberRole, isActive: Boolean = true): Member { val member = Member( email = "$nickname@test.com", @@ -1096,19 +1314,16 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( creator: Member, releaseDate: LocalDateTime, isActive: Boolean, - themeName: String = "theme-${creator.nickname}-$releaseDate" + themeName: String = "theme-${creator.nickname}-$releaseDate", + theme: AudioContentTheme = saveTheme(themeName), + isAdult: Boolean = false ): AudioContent { - val theme = AudioContentTheme( - theme = themeName, - image = "theme-${creator.nickname}-$releaseDate.png" - ) - entityManager.persist(theme) - val content = AudioContent( title = "content-${creator.nickname}-$releaseDate", detail = "detail", languageCode = "ko", - releaseDate = releaseDate + releaseDate = releaseDate, + isAdult = isAdult ) content.member = creator content.theme = theme @@ -1117,6 +1332,16 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( return content } + private fun saveTheme(name: String, isActive: Boolean = true): AudioContentTheme { + val theme = AudioContentTheme( + theme = name, + image = "$name.png", + isActive = isActive + ) + entityManager.persist(theme) + return theme + } + private fun saveAudioContentComment(member: Member, content: AudioContent, isActive: Boolean): AudioContentComment { val comment = AudioContentComment(comment = "comment", languageCode = "ko", isActive = isActive) comment.member = member @@ -1148,9 +1373,12 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( return event } - private fun saveSeries(title: String, owner: Member, isActive: Boolean): Series { - val genre = SeriesGenre(genre = "genre-$title") - entityManager.persist(genre) + private fun saveSeries( + title: String, + owner: Member, + isActive: Boolean, + genre: SeriesGenre = saveSeriesGenre("genre-$title", isAdult = false) + ): Series { val series = Series( title = title, introduction = "introduction", @@ -1163,6 +1391,20 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( return series } + private fun saveSeriesGenre(name: String, isAdult: Boolean): SeriesGenre { + val genre = SeriesGenre(genre = name, isAdult = isAdult) + entityManager.persist(genre) + return genre + } + + private fun saveSeriesContent(series: Series, content: AudioContent): SeriesContent { + val seriesContent = SeriesContent() + seriesContent.series = series + seriesContent.content = content + entityManager.persist(seriesContent) + return seriesContent + } + private fun saveMainTab(title: String): AudioContentMainTab { val tab = AudioContentMainTab(title = title, isActive = true) entityManager.persist(tab) 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 471dbdf1..9530fb30 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 @@ -6,6 +6,8 @@ import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeAiCharacterRecommendat import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeBannerRecommendationRecord import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeCheerCreatorRecommendationRecord import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeFirstAudioContentRecord +import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeGenreCreatorRecommendationGroup +import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeGenreCreatorRecommendationRecord import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeLiveRecommendationRecord import kr.co.vividnext.sodalive.v2.recommend.port.out.HomePopularCommunityRecommendationRecord import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeRecommendationQueryPort @@ -279,6 +281,112 @@ class HomeRecommendationQueryServiceTest { assertEquals(emptyList(), service.findPopularCommunityRecommendations()) } + @Test + @DisplayName("장르 기반 크리에이터 추천은 기본 5개 장르와 장르별 8명을 조회하고 한 응답 안에서 크리에이터 중복을 제거한다") + fun shouldFindGenreCreatorRecommendationsWithDefaultLimitsAndCreatorUniqueness() { + port.genreCreatorRecommendations = listOf( + HomeGenreCreatorRecommendationGroup( + genreId = 1L, + genreName = "romance", + creators = listOf( + HomeGenreCreatorRecommendationRecord( + creatorId = 10L, + creatorNickname = "creator-10", + creatorProfileImage = null + ), + HomeGenreCreatorRecommendationRecord( + creatorId = 11L, + creatorNickname = "creator-11", + creatorProfileImage = "11.png" + ) + ) + ), + HomeGenreCreatorRecommendationGroup( + genreId = 2L, + genreName = "fantasy", + creators = listOf( + HomeGenreCreatorRecommendationRecord( + creatorId = 10L, + creatorNickname = "creator-10", + creatorProfileImage = null + ), + HomeGenreCreatorRecommendationRecord( + creatorId = 12L, + creatorNickname = "creator-12", + creatorProfileImage = "12.png" + ), + HomeGenreCreatorRecommendationRecord( + creatorId = 13L, + creatorNickname = "creator-13", + creatorProfileImage = "13.png" + ) + ) + ) + ) + + val recommendations = service.findGenreCreatorRecommendations( + memberId = 100L, + includeAdultGenres = true + ) + + assertEquals(100L, port.genreCreatorMemberId) + assertEquals(true, port.genreCreatorIncludeAdultGenres) + assertEquals(5, port.genreCreatorGenreLimit) + assertEquals(40, port.genreCreatorCreatorLimit) + assertEquals(listOf(10L, 11L), recommendations[0].creators.map { it.creatorId }) + assertEquals(listOf(12L, 13L), recommendations[1].creators.map { it.creatorId }) + } + + @Test + @DisplayName("장르 기반 크리에이터 추천은 중복 제거 후 빈 그룹을 제외하고 뒤 후보로 보충한다") + fun shouldSkipEmptyGenreCreatorGroupsAfterCreatorDeduplication() { + port.genreCreatorRecommendations = listOf( + HomeGenreCreatorRecommendationGroup( + genreId = 1L, + genreName = "theme-1", + creators = listOf( + HomeGenreCreatorRecommendationRecord( + creatorId = 10L, + creatorNickname = "creator-10", + creatorProfileImage = null + ) + ) + ), + HomeGenreCreatorRecommendationGroup( + genreId = 2L, + genreName = "theme-2", + creators = listOf( + HomeGenreCreatorRecommendationRecord( + creatorId = 10L, + creatorNickname = "creator-10", + creatorProfileImage = null + ) + ) + ), + HomeGenreCreatorRecommendationGroup( + genreId = 3L, + genreName = "theme-3", + creators = listOf( + HomeGenreCreatorRecommendationRecord( + creatorId = 11L, + creatorNickname = "creator-11", + creatorProfileImage = null + ) + ) + ) + ) + + val recommendations = service.findGenreCreatorRecommendations( + memberId = 100L, + includeAdultGenres = false, + genreLimit = 2, + creatorLimit = 8 + ) + + assertEquals(listOf(1L, 3L), recommendations.map { it.genreId }) + assertEquals(false, recommendations.any { it.creators.isEmpty() }) + } + private class FakeHomeRecommendationQueryPort : HomeRecommendationQueryPort { var liveLimit: Int? = null var bannerLimit: Int? = null @@ -291,6 +399,10 @@ class HomeRecommendationQueryServiceTest { var cheerCreatorDetailIds: List = emptyList() var popularCommunityDetailIds: List = emptyList() var popularCommunityIncludeAdultCommunities: Boolean? = null + var genreCreatorMemberId: Long? = null + var genreCreatorIncludeAdultGenres: Boolean? = null + var genreCreatorGenreLimit: Int? = null + var genreCreatorCreatorLimit: Int? = null val liveRecommendations = listOf( HomeLiveRecommendationRecord( liveRoomId = 1L, @@ -352,6 +464,7 @@ class HomeRecommendationQueryServiceTest { var aiCharacterDetails: List = emptyList() var cheerCreatorDetails: List = emptyList() var popularCommunityDetails: List = emptyList() + var genreCreatorRecommendations: List = emptyList() override fun findLiveRecommendations(limit: Int): List { liveLimit = limit @@ -416,6 +529,19 @@ class HomeRecommendationQueryServiceTest { popularCommunityIncludeAdultCommunities = includeAdultCommunities return popularCommunityDetails } + + override fun findGenreCreatorRecommendations( + memberId: Long?, + includeAdultGenres: Boolean, + genreLimit: Int, + creatorLimit: Int + ): List { + genreCreatorMemberId = memberId + genreCreatorIncludeAdultGenres = includeAdultGenres + genreCreatorGenreLimit = genreLimit + genreCreatorCreatorLimit = creatorLimit + return genreCreatorRecommendations + } } }