feat(recommend): 장르 기반 크리에이터 추천 조회를 추가한다
This commit is contained in:
@@ -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.HomeBannerRecommendationRecord
|
||||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeCheerCreatorRecommendationRecord
|
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.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.HomeLiveRecommendationRecord
|
||||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomePopularCommunityRecommendationRecord
|
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomePopularCommunityRecommendationRecord
|
||||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecentDebutCreatorRecord
|
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecentDebutCreatorRecord
|
||||||
@@ -741,6 +743,148 @@ class DefaultHomeRecommendationQueryRepository(
|
|||||||
.fetch()
|
.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(
|
private fun executeSnapshotQuery(
|
||||||
sql: String,
|
sql: String,
|
||||||
sectionType: RecommendedSectionType,
|
sectionType: RecommendedSectionType,
|
||||||
@@ -830,4 +974,9 @@ class DefaultHomeRecommendationQueryRepository(
|
|||||||
companion object {
|
companion object {
|
||||||
private const val LIVE_REPLAY_THEME = "다시듣기"
|
private const val LIVE_REPLAY_THEME = "다시듣기"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private data class GenreRecommendationTarget(
|
||||||
|
val id: Long,
|
||||||
|
val name: String
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.HomeBannerRecommendationRecord
|
||||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeCheerCreatorRecommendationRecord
|
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.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.HomeLiveRecommendationRecord
|
||||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomePopularCommunityRecommendationRecord
|
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomePopularCommunityRecommendationRecord
|
||||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeRecommendationQueryPort
|
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeRecommendationQueryPort
|
||||||
@@ -86,6 +87,23 @@ class HomeRecommendationQueryService(
|
|||||||
}.take(limit)
|
}.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 {
|
fun resolveAudioContentActivityType(theme: String): RecommendedActivityType {
|
||||||
return if (theme == LIVE_REPLAY_THEME) {
|
return if (theme == LIVE_REPLAY_THEME) {
|
||||||
RecommendedActivityType.LIVE_REPLAY
|
RecommendedActivityType.LIVE_REPLAY
|
||||||
@@ -107,6 +125,8 @@ class HomeRecommendationQueryService(
|
|||||||
private const val DEFAULT_AI_CHARACTER_LIMIT = 10
|
private const val DEFAULT_AI_CHARACTER_LIMIT = 10
|
||||||
private const val DEFAULT_CHEER_CREATOR_LIMIT = 8
|
private const val DEFAULT_CHEER_CREATOR_LIMIT = 8
|
||||||
private const val DEFAULT_POPULAR_COMMUNITY_LIMIT = 10
|
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 POPULAR_COMMUNITY_CANDIDATE_LIMIT = 20
|
||||||
private const val LIVE_REPLAY_THEME = "다시듣기"
|
private const val LIVE_REPLAY_THEME = "다시듣기"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,13 @@ interface HomeRecommendationQueryPort {
|
|||||||
communityIds: List<Long>,
|
communityIds: List<Long>,
|
||||||
includeAdultCommunities: Boolean
|
includeAdultCommunities: Boolean
|
||||||
): List<HomePopularCommunityRecommendationRecord>
|
): List<HomePopularCommunityRecommendationRecord>
|
||||||
|
|
||||||
|
fun findGenreCreatorRecommendations(
|
||||||
|
memberId: Long?,
|
||||||
|
includeAdultGenres: Boolean,
|
||||||
|
genreLimit: Int,
|
||||||
|
creatorLimit: Int
|
||||||
|
): List<HomeGenreCreatorRecommendationGroup>
|
||||||
}
|
}
|
||||||
|
|
||||||
data class HomeLiveRecommendationRecord(
|
data class HomeLiveRecommendationRecord(
|
||||||
@@ -119,3 +126,15 @@ data class HomePopularCommunityRecommendationRecord(
|
|||||||
val likeCount: Long,
|
val likeCount: Long,
|
||||||
val commentCount: 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?
|
||||||
|
)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
|
|||||||
import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTab
|
import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTab
|
||||||
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
|
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
|
||||||
import kr.co.vividnext.sodalive.event.Event
|
import kr.co.vividnext.sodalive.event.Event
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers
|
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
|
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
|
||||||
@@ -983,6 +984,223 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("장르 기반 크리에이터 추천은 조회 이력 콘텐츠 테마와 랜덤 보충 테마를 고르고 팔로우 크리에이터를 제외한다")
|
||||||
|
fun shouldFindGenreCreatorRecommendationsFromViewHistoryThemeWithFallbackAndFollowExclusion() {
|
||||||
|
val viewer = saveMember("genre-viewer", MemberRole.USER)
|
||||||
|
val followedCreator = saveMember("genre-followed", MemberRole.CREATOR)
|
||||||
|
val viewedCreator = saveMember("genre-viewed", MemberRole.CREATOR)
|
||||||
|
val fallbackCreator = saveMember("genre-fallback", MemberRole.CREATOR)
|
||||||
|
val viewedTheme = saveTheme("viewed-theme")
|
||||||
|
val fallbackTheme = saveTheme("fallback-theme")
|
||||||
|
val viewedContent = saveAudioContent(
|
||||||
|
viewedCreator,
|
||||||
|
LocalDateTime.of(2026, 5, 30, 10, 0),
|
||||||
|
isActive = true,
|
||||||
|
theme = viewedTheme
|
||||||
|
)
|
||||||
|
saveAudioContent(
|
||||||
|
followedCreator,
|
||||||
|
LocalDateTime.of(2026, 5, 30, 11, 0),
|
||||||
|
isActive = true,
|
||||||
|
theme = viewedTheme
|
||||||
|
)
|
||||||
|
saveAudioContent(
|
||||||
|
fallbackCreator,
|
||||||
|
LocalDateTime.of(2026, 5, 30, 12, 0),
|
||||||
|
isActive = true,
|
||||||
|
theme = fallbackTheme
|
||||||
|
)
|
||||||
|
saveFollowing(viewer, followedCreator, isActive = true)
|
||||||
|
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 = 2,
|
||||||
|
creatorLimit = 8
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(2, recommendations.size)
|
||||||
|
assertEquals(viewedTheme.id, recommendations.first().genreId)
|
||||||
|
assertEquals(viewedTheme.theme, recommendations.first().genreName)
|
||||||
|
assertEquals(false, recommendations.flatMap { it.creators }.any { it.creatorId == followedCreator.id })
|
||||||
|
assertEquals(true, recommendations.flatMap { it.creators }.any { it.creatorId == viewedCreator.id })
|
||||||
|
assertEquals(true, recommendations.flatMap { it.creators }.any { it.creatorId == fallbackCreator.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("장르 기반 크리에이터 추천은 series_genre가 아니라 content_theme 기준으로 그룹을 만든다")
|
||||||
|
fun shouldGroupGenreCreatorRecommendationsByContentThemeNotSeriesGenre() {
|
||||||
|
val creator = saveMember("theme-source-creator", MemberRole.CREATOR)
|
||||||
|
val theme = saveTheme("theme-source")
|
||||||
|
val unrelatedGenre = saveSeriesGenre("unrelated-genre", isAdult = false)
|
||||||
|
val content = saveAudioContent(creator, LocalDateTime.of(2026, 5, 30, 10, 0), isActive = true, theme = theme)
|
||||||
|
saveSeriesContent(saveSeries("unrelated-series", creator, isActive = true, genre = unrelatedGenre), content)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val recommendations = repository.findGenreCreatorRecommendations(
|
||||||
|
memberId = null,
|
||||||
|
includeAdultGenres = false,
|
||||||
|
genreLimit = 1,
|
||||||
|
creatorLimit = 8
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(theme.id, recommendations.single().genreId)
|
||||||
|
assertEquals(theme.theme, recommendations.single().genreName)
|
||||||
|
assertEquals(listOf(creator.id), recommendations.single().creators.map { it.creatorId })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("장르 기반 크리에이터 추천은 빈 테마 그룹을 제외하고 다른 테마로 보충한다")
|
||||||
|
fun shouldSkipEmptyThemeGroupsAndBackfillOtherThemes() {
|
||||||
|
val viewer = saveMember("empty-group-viewer", MemberRole.USER)
|
||||||
|
val followedCreator = saveMember("empty-group-followed", MemberRole.CREATOR)
|
||||||
|
val inactiveCreator = saveMember("empty-group-inactive", MemberRole.CREATOR, isActive = false)
|
||||||
|
val firstCreator = saveMember("empty-group-first", MemberRole.CREATOR)
|
||||||
|
val secondCreator = saveMember("empty-group-second", MemberRole.CREATOR)
|
||||||
|
val followedTheme = saveTheme("empty-followed-theme")
|
||||||
|
val inactiveTheme = saveTheme("empty-inactive-theme")
|
||||||
|
val firstTheme = saveTheme("empty-first-theme")
|
||||||
|
val secondTheme = saveTheme("empty-second-theme")
|
||||||
|
val followedContent = saveAudioContent(
|
||||||
|
followedCreator,
|
||||||
|
LocalDateTime.of(2026, 5, 30, 10, 0),
|
||||||
|
isActive = true,
|
||||||
|
theme = followedTheme
|
||||||
|
)
|
||||||
|
val inactiveContent = saveAudioContent(
|
||||||
|
inactiveCreator,
|
||||||
|
LocalDateTime.of(2026, 5, 30, 11, 0),
|
||||||
|
isActive = true,
|
||||||
|
theme = inactiveTheme
|
||||||
|
)
|
||||||
|
saveAudioContent(firstCreator, LocalDateTime.of(2026, 5, 30, 12, 0), isActive = true, theme = firstTheme)
|
||||||
|
saveAudioContent(secondCreator, LocalDateTime.of(2026, 5, 30, 13, 0), isActive = true, theme = secondTheme)
|
||||||
|
saveFollowing(viewer, followedCreator, isActive = true)
|
||||||
|
listOf(followedContent, inactiveContent).forEach { content ->
|
||||||
|
entityManager.persist(
|
||||||
|
CreatorContentViewHistory(
|
||||||
|
memberId = viewer.id!!,
|
||||||
|
contentId = content.id!!,
|
||||||
|
genreId = 999L,
|
||||||
|
viewedAt = LocalDateTime.of(2026, 5, 31, 10, 0)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val recommendations = repository.findGenreCreatorRecommendations(
|
||||||
|
memberId = viewer.id!!,
|
||||||
|
includeAdultGenres = false,
|
||||||
|
genreLimit = 2,
|
||||||
|
creatorLimit = 8
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(2, recommendations.size)
|
||||||
|
assertEquals(false, recommendations.any { it.creators.isEmpty() })
|
||||||
|
assertEquals(false, recommendations.any { it.genreId == followedTheme.id || it.genreId == inactiveTheme.id })
|
||||||
|
assertEquals(setOf(firstTheme.id, secondTheme.id), recommendations.map { it.genreId }.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("장르 기반 크리에이터 추천은 서비스 중복 제거용 후보 그룹을 genreLimit보다 많이 반환한다")
|
||||||
|
fun shouldReturnMoreCandidateGroupsThanFinalGenreLimitForServiceDeduplicationBackfill() {
|
||||||
|
val sharedCreator = saveMember("candidate-shared", MemberRole.CREATOR)
|
||||||
|
val backfillCreator = saveMember("candidate-backfill", MemberRole.CREATOR)
|
||||||
|
val firstTheme = saveTheme("candidate-first-theme")
|
||||||
|
val duplicateTheme = saveTheme("candidate-duplicate-theme")
|
||||||
|
val backfillTheme = saveTheme("candidate-backfill-theme")
|
||||||
|
saveAudioContent(sharedCreator, LocalDateTime.of(2026, 5, 30, 10, 0), isActive = true, theme = firstTheme)
|
||||||
|
saveAudioContent(sharedCreator, LocalDateTime.of(2026, 5, 30, 11, 0), isActive = true, theme = duplicateTheme)
|
||||||
|
saveAudioContent(backfillCreator, LocalDateTime.of(2026, 5, 30, 12, 0), isActive = true, theme = backfillTheme)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val recommendations = repository.findGenreCreatorRecommendations(
|
||||||
|
memberId = null,
|
||||||
|
includeAdultGenres = false,
|
||||||
|
genreLimit = 2,
|
||||||
|
creatorLimit = 8
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(3, recommendations.size)
|
||||||
|
assertEquals(
|
||||||
|
setOf(firstTheme.id, duplicateTheme.id, backfillTheme.id),
|
||||||
|
recommendations.map { it.genreId }.toSet()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("장르 기반 크리에이터 추천은 같은 크리에이터의 여러 콘텐츠가 장르별 limit을 중복 소모하지 않는다")
|
||||||
|
fun shouldDeduplicateCreatorsBeforeApplyingPerGenreLimit() {
|
||||||
|
val theme = saveTheme("duplicate-theme")
|
||||||
|
val duplicateCreator = saveMember("duplicate-creator", MemberRole.CREATOR)
|
||||||
|
val otherCreator = saveMember("other-creator", MemberRole.CREATOR)
|
||||||
|
repeat(3) { index ->
|
||||||
|
saveAudioContent(
|
||||||
|
duplicateCreator,
|
||||||
|
LocalDateTime.of(2026, 5, 30, 10, 0).plusMinutes(index.toLong()),
|
||||||
|
isActive = true,
|
||||||
|
theme = theme
|
||||||
|
)
|
||||||
|
}
|
||||||
|
saveAudioContent(otherCreator, LocalDateTime.of(2026, 5, 30, 12, 0), isActive = true, theme = theme)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val recommendations = repository.findGenreCreatorRecommendations(
|
||||||
|
memberId = null,
|
||||||
|
includeAdultGenres = false,
|
||||||
|
genreLimit = 1,
|
||||||
|
creatorLimit = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1, recommendations.size)
|
||||||
|
assertEquals(2, recommendations.single().creators.size)
|
||||||
|
assertEquals(
|
||||||
|
setOf(duplicateCreator.id, otherCreator.id),
|
||||||
|
recommendations.single().creators.map { it.creatorId }.toSet()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("장르 기반 크리에이터 추천은 성인 장르를 성인 노출 허용 회원에게만 포함한다")
|
||||||
|
fun shouldIncludeAdultGenreCreatorsOnlyWhenAdultGenresVisible() {
|
||||||
|
val adultCreator = saveMember("adult-genre-creator", MemberRole.CREATOR)
|
||||||
|
val adultTheme = saveTheme("adult-theme")
|
||||||
|
saveAudioContent(
|
||||||
|
adultCreator,
|
||||||
|
LocalDateTime.of(2026, 5, 30, 10, 0),
|
||||||
|
isActive = true,
|
||||||
|
theme = adultTheme,
|
||||||
|
isAdult = true
|
||||||
|
)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val hidden = repository.findGenreCreatorRecommendations(
|
||||||
|
memberId = null,
|
||||||
|
includeAdultGenres = false,
|
||||||
|
genreLimit = 5,
|
||||||
|
creatorLimit = 8
|
||||||
|
)
|
||||||
|
val visible = repository.findGenreCreatorRecommendations(
|
||||||
|
memberId = null,
|
||||||
|
includeAdultGenres = true,
|
||||||
|
genreLimit = 5,
|
||||||
|
creatorLimit = 8
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(false, hidden.any { it.genreId == adultTheme.id })
|
||||||
|
assertEquals(true, visible.any { it.genreId == adultTheme.id })
|
||||||
|
}
|
||||||
|
|
||||||
private fun saveMember(nickname: String, role: MemberRole, isActive: Boolean = true): Member {
|
private fun saveMember(nickname: String, role: MemberRole, isActive: Boolean = true): Member {
|
||||||
val member = Member(
|
val member = Member(
|
||||||
email = "$nickname@test.com",
|
email = "$nickname@test.com",
|
||||||
@@ -1096,19 +1314,16 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
creator: Member,
|
creator: Member,
|
||||||
releaseDate: LocalDateTime,
|
releaseDate: LocalDateTime,
|
||||||
isActive: Boolean,
|
isActive: Boolean,
|
||||||
themeName: String = "theme-${creator.nickname}-$releaseDate"
|
themeName: String = "theme-${creator.nickname}-$releaseDate",
|
||||||
|
theme: AudioContentTheme = saveTheme(themeName),
|
||||||
|
isAdult: Boolean = false
|
||||||
): AudioContent {
|
): AudioContent {
|
||||||
val theme = AudioContentTheme(
|
|
||||||
theme = themeName,
|
|
||||||
image = "theme-${creator.nickname}-$releaseDate.png"
|
|
||||||
)
|
|
||||||
entityManager.persist(theme)
|
|
||||||
|
|
||||||
val content = AudioContent(
|
val content = AudioContent(
|
||||||
title = "content-${creator.nickname}-$releaseDate",
|
title = "content-${creator.nickname}-$releaseDate",
|
||||||
detail = "detail",
|
detail = "detail",
|
||||||
languageCode = "ko",
|
languageCode = "ko",
|
||||||
releaseDate = releaseDate
|
releaseDate = releaseDate,
|
||||||
|
isAdult = isAdult
|
||||||
)
|
)
|
||||||
content.member = creator
|
content.member = creator
|
||||||
content.theme = theme
|
content.theme = theme
|
||||||
@@ -1117,6 +1332,16 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun saveTheme(name: String, isActive: Boolean = true): AudioContentTheme {
|
||||||
|
val theme = AudioContentTheme(
|
||||||
|
theme = name,
|
||||||
|
image = "$name.png",
|
||||||
|
isActive = isActive
|
||||||
|
)
|
||||||
|
entityManager.persist(theme)
|
||||||
|
return theme
|
||||||
|
}
|
||||||
|
|
||||||
private fun saveAudioContentComment(member: Member, content: AudioContent, isActive: Boolean): AudioContentComment {
|
private fun saveAudioContentComment(member: Member, content: AudioContent, isActive: Boolean): AudioContentComment {
|
||||||
val comment = AudioContentComment(comment = "comment", languageCode = "ko", isActive = isActive)
|
val comment = AudioContentComment(comment = "comment", languageCode = "ko", isActive = isActive)
|
||||||
comment.member = member
|
comment.member = member
|
||||||
@@ -1148,9 +1373,12 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
return event
|
return event
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveSeries(title: String, owner: Member, isActive: Boolean): Series {
|
private fun saveSeries(
|
||||||
val genre = SeriesGenre(genre = "genre-$title")
|
title: String,
|
||||||
entityManager.persist(genre)
|
owner: Member,
|
||||||
|
isActive: Boolean,
|
||||||
|
genre: SeriesGenre = saveSeriesGenre("genre-$title", isAdult = false)
|
||||||
|
): Series {
|
||||||
val series = Series(
|
val series = Series(
|
||||||
title = title,
|
title = title,
|
||||||
introduction = "introduction",
|
introduction = "introduction",
|
||||||
@@ -1163,6 +1391,20 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
return series
|
return series
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun saveSeriesGenre(name: String, isAdult: Boolean): SeriesGenre {
|
||||||
|
val genre = SeriesGenre(genre = name, isAdult = isAdult)
|
||||||
|
entityManager.persist(genre)
|
||||||
|
return genre
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveSeriesContent(series: Series, content: AudioContent): SeriesContent {
|
||||||
|
val seriesContent = SeriesContent()
|
||||||
|
seriesContent.series = series
|
||||||
|
seriesContent.content = content
|
||||||
|
entityManager.persist(seriesContent)
|
||||||
|
return seriesContent
|
||||||
|
}
|
||||||
|
|
||||||
private fun saveMainTab(title: String): AudioContentMainTab {
|
private fun saveMainTab(title: String): AudioContentMainTab {
|
||||||
val tab = AudioContentMainTab(title = title, isActive = true)
|
val tab = AudioContentMainTab(title = title, isActive = true)
|
||||||
entityManager.persist(tab)
|
entityManager.persist(tab)
|
||||||
|
|||||||
@@ -6,6 +6,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.HomeBannerRecommendationRecord
|
||||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeCheerCreatorRecommendationRecord
|
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.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.HomeLiveRecommendationRecord
|
||||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomePopularCommunityRecommendationRecord
|
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomePopularCommunityRecommendationRecord
|
||||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeRecommendationQueryPort
|
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeRecommendationQueryPort
|
||||||
@@ -279,6 +281,112 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
assertEquals(emptyList<HomePopularCommunityRecommendationRecord>(), service.findPopularCommunityRecommendations())
|
assertEquals(emptyList<HomePopularCommunityRecommendationRecord>(), service.findPopularCommunityRecommendations())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("장르 기반 크리에이터 추천은 기본 5개 장르와 장르별 8명을 조회하고 한 응답 안에서 크리에이터 중복을 제거한다")
|
||||||
|
fun shouldFindGenreCreatorRecommendationsWithDefaultLimitsAndCreatorUniqueness() {
|
||||||
|
port.genreCreatorRecommendations = listOf(
|
||||||
|
HomeGenreCreatorRecommendationGroup(
|
||||||
|
genreId = 1L,
|
||||||
|
genreName = "romance",
|
||||||
|
creators = listOf(
|
||||||
|
HomeGenreCreatorRecommendationRecord(
|
||||||
|
creatorId = 10L,
|
||||||
|
creatorNickname = "creator-10",
|
||||||
|
creatorProfileImage = null
|
||||||
|
),
|
||||||
|
HomeGenreCreatorRecommendationRecord(
|
||||||
|
creatorId = 11L,
|
||||||
|
creatorNickname = "creator-11",
|
||||||
|
creatorProfileImage = "11.png"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
HomeGenreCreatorRecommendationGroup(
|
||||||
|
genreId = 2L,
|
||||||
|
genreName = "fantasy",
|
||||||
|
creators = listOf(
|
||||||
|
HomeGenreCreatorRecommendationRecord(
|
||||||
|
creatorId = 10L,
|
||||||
|
creatorNickname = "creator-10",
|
||||||
|
creatorProfileImage = null
|
||||||
|
),
|
||||||
|
HomeGenreCreatorRecommendationRecord(
|
||||||
|
creatorId = 12L,
|
||||||
|
creatorNickname = "creator-12",
|
||||||
|
creatorProfileImage = "12.png"
|
||||||
|
),
|
||||||
|
HomeGenreCreatorRecommendationRecord(
|
||||||
|
creatorId = 13L,
|
||||||
|
creatorNickname = "creator-13",
|
||||||
|
creatorProfileImage = "13.png"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val recommendations = service.findGenreCreatorRecommendations(
|
||||||
|
memberId = 100L,
|
||||||
|
includeAdultGenres = true
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(100L, port.genreCreatorMemberId)
|
||||||
|
assertEquals(true, port.genreCreatorIncludeAdultGenres)
|
||||||
|
assertEquals(5, port.genreCreatorGenreLimit)
|
||||||
|
assertEquals(40, port.genreCreatorCreatorLimit)
|
||||||
|
assertEquals(listOf(10L, 11L), recommendations[0].creators.map { it.creatorId })
|
||||||
|
assertEquals(listOf(12L, 13L), recommendations[1].creators.map { it.creatorId })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("장르 기반 크리에이터 추천은 중복 제거 후 빈 그룹을 제외하고 뒤 후보로 보충한다")
|
||||||
|
fun shouldSkipEmptyGenreCreatorGroupsAfterCreatorDeduplication() {
|
||||||
|
port.genreCreatorRecommendations = listOf(
|
||||||
|
HomeGenreCreatorRecommendationGroup(
|
||||||
|
genreId = 1L,
|
||||||
|
genreName = "theme-1",
|
||||||
|
creators = listOf(
|
||||||
|
HomeGenreCreatorRecommendationRecord(
|
||||||
|
creatorId = 10L,
|
||||||
|
creatorNickname = "creator-10",
|
||||||
|
creatorProfileImage = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
HomeGenreCreatorRecommendationGroup(
|
||||||
|
genreId = 2L,
|
||||||
|
genreName = "theme-2",
|
||||||
|
creators = listOf(
|
||||||
|
HomeGenreCreatorRecommendationRecord(
|
||||||
|
creatorId = 10L,
|
||||||
|
creatorNickname = "creator-10",
|
||||||
|
creatorProfileImage = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
HomeGenreCreatorRecommendationGroup(
|
||||||
|
genreId = 3L,
|
||||||
|
genreName = "theme-3",
|
||||||
|
creators = listOf(
|
||||||
|
HomeGenreCreatorRecommendationRecord(
|
||||||
|
creatorId = 11L,
|
||||||
|
creatorNickname = "creator-11",
|
||||||
|
creatorProfileImage = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val recommendations = service.findGenreCreatorRecommendations(
|
||||||
|
memberId = 100L,
|
||||||
|
includeAdultGenres = false,
|
||||||
|
genreLimit = 2,
|
||||||
|
creatorLimit = 8
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf(1L, 3L), recommendations.map { it.genreId })
|
||||||
|
assertEquals(false, recommendations.any { it.creators.isEmpty() })
|
||||||
|
}
|
||||||
|
|
||||||
private class FakeHomeRecommendationQueryPort : HomeRecommendationQueryPort {
|
private class FakeHomeRecommendationQueryPort : HomeRecommendationQueryPort {
|
||||||
var liveLimit: Int? = null
|
var liveLimit: Int? = null
|
||||||
var bannerLimit: Int? = null
|
var bannerLimit: Int? = null
|
||||||
@@ -291,6 +399,10 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
var cheerCreatorDetailIds: List<Long> = emptyList()
|
var cheerCreatorDetailIds: List<Long> = emptyList()
|
||||||
var popularCommunityDetailIds: List<Long> = emptyList()
|
var popularCommunityDetailIds: List<Long> = emptyList()
|
||||||
var popularCommunityIncludeAdultCommunities: Boolean? = null
|
var popularCommunityIncludeAdultCommunities: Boolean? = null
|
||||||
|
var genreCreatorMemberId: Long? = null
|
||||||
|
var genreCreatorIncludeAdultGenres: Boolean? = null
|
||||||
|
var genreCreatorGenreLimit: Int? = null
|
||||||
|
var genreCreatorCreatorLimit: Int? = null
|
||||||
val liveRecommendations = listOf(
|
val liveRecommendations = listOf(
|
||||||
HomeLiveRecommendationRecord(
|
HomeLiveRecommendationRecord(
|
||||||
liveRoomId = 1L,
|
liveRoomId = 1L,
|
||||||
@@ -352,6 +464,7 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
var aiCharacterDetails: List<HomeAiCharacterRecommendationRecord> = emptyList()
|
var aiCharacterDetails: List<HomeAiCharacterRecommendationRecord> = emptyList()
|
||||||
var cheerCreatorDetails: List<HomeCheerCreatorRecommendationRecord> = emptyList()
|
var cheerCreatorDetails: List<HomeCheerCreatorRecommendationRecord> = emptyList()
|
||||||
var popularCommunityDetails: List<HomePopularCommunityRecommendationRecord> = emptyList()
|
var popularCommunityDetails: List<HomePopularCommunityRecommendationRecord> = emptyList()
|
||||||
|
var genreCreatorRecommendations: List<HomeGenreCreatorRecommendationGroup> = emptyList()
|
||||||
|
|
||||||
override fun findLiveRecommendations(limit: Int): List<HomeLiveRecommendationRecord> {
|
override fun findLiveRecommendations(limit: Int): List<HomeLiveRecommendationRecord> {
|
||||||
liveLimit = limit
|
liveLimit = limit
|
||||||
@@ -416,6 +529,19 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
popularCommunityIncludeAdultCommunities = includeAdultCommunities
|
popularCommunityIncludeAdultCommunities = includeAdultCommunities
|
||||||
return popularCommunityDetails
|
return popularCommunityDetails
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun findGenreCreatorRecommendations(
|
||||||
|
memberId: Long?,
|
||||||
|
includeAdultGenres: Boolean,
|
||||||
|
genreLimit: Int,
|
||||||
|
creatorLimit: Int
|
||||||
|
): List<HomeGenreCreatorRecommendationGroup> {
|
||||||
|
genreCreatorMemberId = memberId
|
||||||
|
genreCreatorIncludeAdultGenres = includeAdultGenres
|
||||||
|
genreCreatorGenreLimit = genreLimit
|
||||||
|
genreCreatorCreatorLimit = creatorLimit
|
||||||
|
return genreCreatorRecommendations
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user