From 81f1bcc4efd326f52c8e1399b2720b123c6d23f9 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 4 Jun 2026 17:22:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(recommend):=20=ED=99=88=20=EC=9E=A5?= =?UTF-8?q?=EB=A5=B4=20=EC=B6=94=EC=B2=9C=20=ED=9B=84=EB=B3=B4=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EB=A5=BC=20=EB=B3=B4=EA=B0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...efaultHomeRecommendationQueryRepository.kt | 261 +++++++++++++----- ...ltHomeRecommendationQueryRepositoryTest.kt | 91 +++++- 2 files changed, 286 insertions(+), 66 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 b5b2c9a6..8dcc693b 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 @@ -826,80 +826,52 @@ class DefaultHomeRecommendationQueryRepository( genreLimit: Int, creatorLimit: Int ): List { - val genres = findGenreRecommendationTargets( + val groups = mutableListOf() + val selectedGenreIds = mutableSetOf() + + val viewedTargets = findViewedGenreRecommendationTargets( memberId = memberId, includeAdultGenres = includeAdultGenres, - targetLimit = (genreLimit * creatorLimit).coerceAtLeast(genreLimit) + targetLimit = 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() + viewedTargets.forEach { target -> + groups.add(toGenreCreatorRecommendationGroup(target, memberId, includeAdultGenres, creatorLimit)) + selectedGenreIds.add(target.id) + } + + fillFallbackGenreCreatorGroups( + groups = groups, + selectedGenreIds = selectedGenreIds, + memberId = memberId, + includeAdultGenres = includeAdultGenres, + genreLimit = genreLimit, + creatorLimit = creatorLimit + ) + + return groups } - private fun findGenreRecommendationTargets( + private fun findViewedGenreRecommendationTargets( memberId: Long?, includeAdultGenres: Boolean, targetLimit: Int ): List { + if (memberId == null || targetLimit <= 0) return emptyList() + 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 - ) - and not exists ( - select 1 - from block_member bm - where :memberId is not null - and bm.is_active = true - and ( - (bm.member_id = :memberId and bm.blocked_member_id = m.id) - or (bm.member_id = m.id and bm.blocked_member_id = :memberId) - ) - ) - ) - ) selected - order by selected.source_rank asc, selected.random_tie_breaker asc + select ct.id, + ct.theme as genre + from content_theme ct + join ( + select distinct c.theme_id + from creator_content_view_history ccvh + join content c on c.id = ccvh.content_id + where ccvh.member_id = :memberId + ) viewed on viewed.theme_id = ct.id + where ct.is_active = true + and ${eligibleGenreExistsSql()} + order by rand() asc limit :targetLimit """.trimIndent() @@ -911,6 +883,114 @@ class DefaultHomeRecommendationQueryRepository( @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, + isViewed = true + ) + } + } + + private fun fillFallbackGenreCreatorGroups( + groups: MutableList, + selectedGenreIds: MutableSet, + memberId: Long?, + includeAdultGenres: Boolean, + genreLimit: Int, + creatorLimit: Int + ) { + val fullTargets = findFallbackGenreRecommendationTargets( + memberId = memberId, + includeAdultGenres = includeAdultGenres, + excludedGenreIds = selectedGenreIds, + targetLimit = (genreLimit * creatorLimit).coerceAtLeast(genreLimit), + minCreatorCount = creatorLimit + ) + + fullTargets.forEach { target -> + groups.add(toGenreCreatorRecommendationGroup(target, memberId, includeAdultGenres, creatorLimit)) + selectedGenreIds.add(target.id) + } + val partialTargets = findFallbackGenreRecommendationTargets( + memberId = memberId, + includeAdultGenres = includeAdultGenres, + excludedGenreIds = selectedGenreIds, + targetLimit = (genreLimit * creatorLimit).coerceAtLeast(genreLimit), + minCreatorCount = 1 + ) + + partialTargets.forEach { target -> + groups.add(toGenreCreatorRecommendationGroup(target, memberId, includeAdultGenres, creatorLimit)) + selectedGenreIds.add(target.id) + } + } + + private fun findFallbackGenreRecommendationTargets( + memberId: Long?, + includeAdultGenres: Boolean, + excludedGenreIds: Set, + targetLimit: Int, + minCreatorCount: Int + ): List { + if (targetLimit <= 0) return emptyList() + + val excludedClause = if (excludedGenreIds.isEmpty()) "" else "and ct.id not in (:excludedGenreIds)" + val sql = """ + select selected.id, + selected.genre + from ( + select ct.id, + ct.theme as genre, + count(distinct m.id) as creator_count, + rand() as random_tie_breaker + from content_theme ct + join content c on c.theme_id = ct.id + join member m on m.id = c.member_id + where ct.is_active = true + $excludedClause + 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 + ) + and not exists ( + select 1 + from block_member bm + where :memberId is not null + and bm.is_active = true + and ( + (bm.member_id = :memberId and bm.blocked_member_id = m.id) + or (bm.member_id = m.id and bm.blocked_member_id = :memberId) + ) + ) + group by ct.id, ct.theme + having count(distinct m.id) >= :minCreatorCount + ) selected + order by selected.random_tie_breaker asc + limit :targetLimit + """.trimIndent() + + val query = entityManager.createNativeQuery(sql) + .setParameter("memberId", memberId) + .setParameter("includeAdultGenres", includeAdultGenres) + .setParameter("targetLimit", targetLimit) + .setParameter("minCreatorCount", minCreatorCount) + + if (excludedGenreIds.isNotEmpty()) { + query.setParameter("excludedGenreIds", excludedGenreIds) + } + + @Suppress("UNCHECKED_CAST") + val rows = query.resultList as List> + return rows.map { row -> GenreRecommendationTarget( id = (row[0] as Number).toLong(), @@ -919,6 +999,58 @@ class DefaultHomeRecommendationQueryRepository( } } + private fun toGenreCreatorRecommendationGroup( + target: GenreRecommendationTarget, + memberId: Long?, + includeAdultGenres: Boolean, + creatorLimit: Int + ): HomeGenreCreatorRecommendationGroup { + return HomeGenreCreatorRecommendationGroup( + genreId = target.id, + genreName = target.name, + isViewedTheme = target.isViewed, + creators = findCreatorsByGenre( + genreId = target.id, + memberId = memberId, + includeAdultGenres = includeAdultGenres, + creatorLimit = creatorLimit + ) + ) + } + + private fun eligibleGenreExistsSql(): String { + return """ + 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 + ) + and not exists ( + select 1 + from block_member bm + where :memberId is not null + and bm.is_active = true + and ( + (bm.member_id = :memberId and bm.blocked_member_id = m.id) + or (bm.member_id = m.id and bm.blocked_member_id = :memberId) + ) + ) + ) + """.trimIndent() + } + private fun findCreatorsByGenre( genreId: Long, memberId: Long?, @@ -1130,6 +1262,7 @@ class DefaultHomeRecommendationQueryRepository( private data class GenreRecommendationTarget( val id: Long, - val name: String + val name: String, + val isViewed: Boolean = false ) } 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 5cd63a5e..00a63994 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 @@ -1504,8 +1504,95 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( } @Test - @DisplayName("장르 기반 크리에이터 추천은 서비스 중복 제거용 후보 그룹을 genreLimit보다 많이 반환한다") - fun shouldReturnMoreCandidateGroupsThanFinalGenreLimitForServiceDeduplicationBackfill() { + @DisplayName("조회 이력이 없으면 크리에이터 8명을 채울 수 있는 콘텐츠 테마 후보를 먼저 반환한다") + fun shouldRecommendFullRandomThemeCandidatesFirstWhenViewHistoryDoesNotExist() { + repeat(5) { themeIndex -> + val theme = saveTheme("no-history-underfilled-theme-$themeIndex") + saveAudioContent( + saveMember("no-history-underfilled-creator-$themeIndex", MemberRole.CREATOR), + LocalDateTime.of(2026, 5, 30, 10, 0).plusMinutes(themeIndex.toLong()), + isActive = true, + theme = theme + ) + } + repeat(5) { themeIndex -> + val theme = saveTheme("no-history-full-theme-$themeIndex") + repeat(8) { creatorIndex -> + saveAudioContent( + saveMember("no-history-full-$themeIndex-creator-$creatorIndex", MemberRole.CREATOR), + LocalDateTime.of(2026, 5, 30, 11, 0).plusMinutes((themeIndex * 10 + creatorIndex).toLong()), + isActive = true, + theme = theme + ) + } + } + flushAndClear() + + val recommendations = repository.findGenreCreatorRecommendations( + memberId = null, + includeAdultGenres = false, + genreLimit = 5, + creatorLimit = 8 + ) + + assertEquals(true, recommendations.size >= 5) + assertEquals(true, recommendations.take(5).all { it.genreName.startsWith("no-history-full-theme-") }) + assertEquals(true, recommendations.take(5).all { it.creators.size == 8 }) + } + + @Test + @DisplayName("조회 이력 콘텐츠 테마는 8명이 되지 않아도 후보에 포함하고 부족한 장르는 8명 보유 테마 후보로 채운다") + fun shouldKeepViewedThemeCandidateEvenWhenUnderfilledAndReturnFullRandomThemeCandidates() { + val viewer = saveMember("viewed-partial-viewer", MemberRole.USER) + val viewedTheme = saveTheme("viewed-partial-theme") + val viewedContent = saveAudioContent( + saveMember("viewed-partial-creator", MemberRole.CREATOR), + LocalDateTime.of(2026, 5, 30, 10, 0), + isActive = true, + theme = viewedTheme + ) + repeat(5) { themeIndex -> + val theme = saveTheme("viewed-fill-full-theme-$themeIndex") + repeat(8) { creatorIndex -> + saveAudioContent( + saveMember("viewed-fill-full-$themeIndex-creator-$creatorIndex", MemberRole.CREATOR), + LocalDateTime.of(2026, 5, 30, 11, 0).plusMinutes((themeIndex * 10 + creatorIndex).toLong()), + isActive = true, + theme = theme + ) + } + } + 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 = 5, + creatorLimit = 8 + ) + + assertEquals(true, recommendations.size >= 5) + assertEquals(true, recommendations.any { it.genreId == viewedTheme.id && it.creators.size == 1 }) + assertEquals( + true, + recommendations + .filter { it.genreId != viewedTheme.id } + .take(4) + .all { it.genreName.startsWith("viewed-fill-full-theme-") && it.creators.size == 8 } + ) + } + + @Test + @DisplayName("장르 기반 크리에이터 추천 repository는 service 보충용 후보 그룹을 genreLimit보다 많이 반환한다") + fun shouldReturnMoreCandidateGroupsThanFinalGenreLimitForServiceBackfill() { val sharedCreator = saveMember("candidate-shared", MemberRole.CREATOR) val backfillCreator = saveMember("candidate-backfill", MemberRole.CREATOR) val firstTheme = saveTheme("candidate-first-theme")