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,80 +826,52 @@ class DefaultHomeRecommendationQueryRepository(
genreLimit: Int,
creatorLimit: Int
): List<HomeGenreCreatorRecommendationGroup> {
val genres = findGenreRecommendationTargets(
val groups = mutableListOf<HomeGenreCreatorRecommendationGroup>()
val selectedGenreIds = mutableSetOf<Long>()
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<GenreRecommendationTarget> {
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<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 = """
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<Array<Any?>>
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
)
}