feat(recommend): 장르 기반 크리에이터 추천 조회를 추가한다

This commit is contained in:
2026-05-31 18:20:51 +09:00
parent 209d32da2f
commit 5bea7cfb64
5 changed files with 567 additions and 11 deletions

View File

@@ -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<HomeGenreCreatorRecommendationGroup> {
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<GenreRecommendationTarget> {
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<Array<Any?>>
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<HomeGenreCreatorRecommendationRecord> {
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<Array<Any?>>
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
)
}

View File

@@ -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<HomeGenreCreatorRecommendationGroup> {
val selectedCreatorIds = mutableSetOf<Long>()
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 = "다시듣기"
}

View File

@@ -40,6 +40,13 @@ interface HomeRecommendationQueryPort {
communityIds: List<Long>,
includeAdultCommunities: Boolean
): List<HomePopularCommunityRecommendationRecord>
fun findGenreCreatorRecommendations(
memberId: Long?,
includeAdultGenres: Boolean,
genreLimit: Int,
creatorLimit: Int
): List<HomeGenreCreatorRecommendationGroup>
}
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<HomeGenreCreatorRecommendationRecord>
)
data class HomeGenreCreatorRecommendationRecord(
val creatorId: Long,
val creatorNickname: String,
val creatorProfileImage: String?
)