feat(recommend): 홈 장르 추천 후보 조회를 보강한다

This commit is contained in:
2026-06-04 17:22:23 +09:00
parent 410814ef33
commit 81f1bcc4ef
2 changed files with 286 additions and 66 deletions

View File

@@ -826,51 +826,201 @@ class DefaultHomeRecommendationQueryRepository(
genreLimit: Int, genreLimit: Int,
creatorLimit: Int creatorLimit: Int
): List<HomeGenreCreatorRecommendationGroup> { ): List<HomeGenreCreatorRecommendationGroup> {
val genres = findGenreRecommendationTargets( val groups = mutableListOf<HomeGenreCreatorRecommendationGroup>()
val selectedGenreIds = mutableSetOf<Long>()
val viewedTargets = findViewedGenreRecommendationTargets(
memberId = memberId, memberId = memberId,
includeAdultGenres = includeAdultGenres, includeAdultGenres = includeAdultGenres,
targetLimit = (genreLimit * creatorLimit).coerceAtLeast(genreLimit) targetLimit = genreLimit
) )
return genres.asSequence().mapNotNull { genre -> viewedTargets.forEach { target ->
val creators = findCreatorsByGenre( groups.add(toGenreCreatorRecommendationGroup(target, memberId, includeAdultGenres, creatorLimit))
genreId = genre.id, selectedGenreIds.add(target.id)
}
fillFallbackGenreCreatorGroups(
groups = groups,
selectedGenreIds = selectedGenreIds,
memberId = memberId, memberId = memberId,
includeAdultGenres = includeAdultGenres, includeAdultGenres = includeAdultGenres,
genreLimit = genreLimit,
creatorLimit = creatorLimit creatorLimit = creatorLimit
) )
creators.takeIf { it.isNotEmpty() }?.let {
HomeGenreCreatorRecommendationGroup( return groups
genreId = genre.id,
genreName = genre.name,
creators = it
)
}
}.toList()
} }
private fun findGenreRecommendationTargets( private fun findViewedGenreRecommendationTargets(
memberId: Long?, memberId: Long?,
includeAdultGenres: Boolean, includeAdultGenres: Boolean,
targetLimit: Int targetLimit: Int
): List<GenreRecommendationTarget> { ): List<GenreRecommendationTarget> {
if (memberId == null || targetLimit <= 0) return emptyList()
val sql = """
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()
val query = entityManager.createNativeQuery(sql)
.setParameter("memberId", memberId)
.setParameter("includeAdultGenres", includeAdultGenres)
.setParameter("targetLimit", targetLimit)
@Suppress("UNCHECKED_CAST")
val rows = query.resultList as List<Array<Any?>>
return rows.map { row ->
GenreRecommendationTarget(
id = (row[0] as Number).toLong(),
name = row[1] as String,
isViewed = true
)
}
}
private fun fillFallbackGenreCreatorGroups(
groups: MutableList<HomeGenreCreatorRecommendationGroup>,
selectedGenreIds: MutableSet<Long>,
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<Long>,
targetLimit: Int,
minCreatorCount: Int
): List<GenreRecommendationTarget> {
if (targetLimit <= 0) return emptyList()
val excludedClause = if (excludedGenreIds.isEmpty()) "" else "and ct.id not in (:excludedGenreIds)"
val sql = """ val sql = """
select selected.id, select selected.id,
selected.genre selected.genre
from ( from (
select ct.id, select ct.id,
ct.theme as genre, ct.theme as genre,
case when viewed.theme_id is null then 1 else 0 end as source_rank, count(distinct m.id) as creator_count,
rand() as random_tie_breaker rand() as random_tie_breaker
from content_theme ct from content_theme ct
left join ( join content c on c.theme_id = ct.id
select distinct c.theme_id join member m on m.id = c.member_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 where ct.is_active = true
and exists ( $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<Array<Any?>>
return rows.map { row ->
GenreRecommendationTarget(
id = (row[0] as Number).toLong(),
name = row[1] as String
)
}
}
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 select 1
from content c from content c
join member m on m.id = c.member_id join member m on m.id = c.member_id
@@ -898,25 +1048,7 @@ class DefaultHomeRecommendationQueryRepository(
) )
) )
) )
) selected
order by selected.source_rank asc, selected.random_tie_breaker asc
limit :targetLimit
""".trimIndent() """.trimIndent()
val query = entityManager.createNativeQuery(sql)
.setParameter("memberId", memberId)
.setParameter("includeAdultGenres", includeAdultGenres)
.setParameter("targetLimit", targetLimit)
@Suppress("UNCHECKED_CAST")
val rows = query.resultList as List<Array<Any?>>
return rows.map { row ->
GenreRecommendationTarget(
id = (row[0] as Number).toLong(),
name = row[1] as String
)
}
} }
private fun findCreatorsByGenre( private fun findCreatorsByGenre(
@@ -1130,6 +1262,7 @@ class DefaultHomeRecommendationQueryRepository(
private data class GenreRecommendationTarget( private data class GenreRecommendationTarget(
val id: Long, val id: Long,
val name: String val name: String,
val isViewed: Boolean = false
) )
} }

View File

@@ -1504,8 +1504,95 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
} }
@Test @Test
@DisplayName("장르 기반 크리에이터 추천은 서비스 중복 제거용 후보 그룹을 genreLimit보다 많이 반환한다") @DisplayName("조회 이력이 없으면 크리에이터 8명을 채울 수 있는 콘텐츠 테마 후보를 먼저 반환한다")
fun shouldReturnMoreCandidateGroupsThanFinalGenreLimitForServiceDeduplicationBackfill() { 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 sharedCreator = saveMember("candidate-shared", MemberRole.CREATOR)
val backfillCreator = saveMember("candidate-backfill", MemberRole.CREATOR) val backfillCreator = saveMember("candidate-backfill", MemberRole.CREATOR)
val firstTheme = saveTheme("candidate-first-theme") val firstTheme = saveTheme("candidate-first-theme")