feat(recommend): 홈 장르 추천 후보 조회를 보강한다
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user