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?
)

View File

@@ -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.theme.AudioContentTheme
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.explorer.profile.CreatorCheers
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 {
val member = Member(
email = "$nickname@test.com",
@@ -1096,19 +1314,16 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
creator: Member,
releaseDate: LocalDateTime,
isActive: Boolean,
themeName: String = "theme-${creator.nickname}-$releaseDate"
themeName: String = "theme-${creator.nickname}-$releaseDate",
theme: AudioContentTheme = saveTheme(themeName),
isAdult: Boolean = false
): AudioContent {
val theme = AudioContentTheme(
theme = themeName,
image = "theme-${creator.nickname}-$releaseDate.png"
)
entityManager.persist(theme)
val content = AudioContent(
title = "content-${creator.nickname}-$releaseDate",
detail = "detail",
languageCode = "ko",
releaseDate = releaseDate
releaseDate = releaseDate,
isAdult = isAdult
)
content.member = creator
content.theme = theme
@@ -1117,6 +1332,16 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
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 {
val comment = AudioContentComment(comment = "comment", languageCode = "ko", isActive = isActive)
comment.member = member
@@ -1148,9 +1373,12 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
return event
}
private fun saveSeries(title: String, owner: Member, isActive: Boolean): Series {
val genre = SeriesGenre(genre = "genre-$title")
entityManager.persist(genre)
private fun saveSeries(
title: String,
owner: Member,
isActive: Boolean,
genre: SeriesGenre = saveSeriesGenre("genre-$title", isAdult = false)
): Series {
val series = Series(
title = title,
introduction = "introduction",
@@ -1163,6 +1391,20 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
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 {
val tab = AudioContentMainTab(title = title, isActive = true)
entityManager.persist(tab)

View File

@@ -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.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.HomeRecommendationQueryPort
@@ -279,6 +281,112 @@ class HomeRecommendationQueryServiceTest {
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 {
var liveLimit: Int? = null
var bannerLimit: Int? = null
@@ -291,6 +399,10 @@ class HomeRecommendationQueryServiceTest {
var cheerCreatorDetailIds: List<Long> = emptyList()
var popularCommunityDetailIds: List<Long> = emptyList()
var popularCommunityIncludeAdultCommunities: Boolean? = null
var genreCreatorMemberId: Long? = null
var genreCreatorIncludeAdultGenres: Boolean? = null
var genreCreatorGenreLimit: Int? = null
var genreCreatorCreatorLimit: Int? = null
val liveRecommendations = listOf(
HomeLiveRecommendationRecord(
liveRoomId = 1L,
@@ -352,6 +464,7 @@ class HomeRecommendationQueryServiceTest {
var aiCharacterDetails: List<HomeAiCharacterRecommendationRecord> = emptyList()
var cheerCreatorDetails: List<HomeCheerCreatorRecommendationRecord> = emptyList()
var popularCommunityDetails: List<HomePopularCommunityRecommendationRecord> = emptyList()
var genreCreatorRecommendations: List<HomeGenreCreatorRecommendationGroup> = emptyList()
override fun findLiveRecommendations(limit: Int): List<HomeLiveRecommendationRecord> {
liveLimit = limit
@@ -416,6 +529,19 @@ class HomeRecommendationQueryServiceTest {
popularCommunityIncludeAdultCommunities = includeAdultCommunities
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
}
}
}