Compare commits
58 Commits
eb11168fa8
...
7606796fe3
| Author | SHA1 | Date | |
|---|---|---|---|
| 7606796fe3 | |||
| 81f1bcc4ef | |||
| 410814ef33 | |||
| e5827d5018 | |||
| b99a406248 | |||
| 3a17941ec6 | |||
| 6d399c48ab | |||
| bc349d5881 | |||
| 5d606a257e | |||
| 12b446c4ae | |||
| 6304c67cde | |||
| 7c0aa9245e | |||
| 0fdfc48680 | |||
| 4f66b6abb9 | |||
| 279053ce7b | |||
| d86fee0945 | |||
| 9f27d70910 | |||
| 85591c2a8b | |||
| bb96f07872 | |||
| da387f43a0 | |||
| 7ad514dcc0 | |||
| 1d7f55bbe7 | |||
| c681fb9a3f | |||
| 65f0ff7e72 | |||
| fb0f22070f | |||
| 3df5614b7a | |||
| 1f3a38a404 | |||
| f77bd7b8e2 | |||
| 09cba1ffeb | |||
| 227a329ae1 | |||
| 9df7ba259b | |||
| cdff31422c | |||
| 8300b1875c | |||
| 82b2eb75d4 | |||
| 5bea7cfb64 | |||
| 209d32da2f | |||
| 43179de810 | |||
| 2ef8e8e489 | |||
| 70832a10b9 | |||
| 24429abe38 | |||
| 5003588556 | |||
| 6652984056 | |||
| 14822f351b | |||
| 3cd4e689dc | |||
| bc68d1f227 | |||
| 82d935e63f | |||
| 58e59c5cb4 | |||
| 2edd486524 | |||
| a7e17fede2 | |||
| 602063863a | |||
| 029408039d | |||
| fa828f71a0 | |||
| 43304522e3 | |||
| 1d1e062e1e | |||
| c5b92d250e | |||
| 07bbc75844 | |||
| 2324483c87 | |||
| 502bf9639e |
@@ -826,80 +826,52 @@ class DefaultHomeRecommendationQueryRepository(
|
|||||||
genreLimit: Int,
|
genreLimit: Int,
|
||||||
creatorLimit: Int
|
creatorLimit: Int
|
||||||
): List<HomeGenreCreatorRecommendationGroup> {
|
): List<HomeGenreCreatorRecommendationGroup> {
|
||||||
val genres = findGenreRecommendationTargets(
|
val groups = mutableListOf<HomeGenreCreatorRecommendationGroup>()
|
||||||
|
val selectedGenreIds = mutableSetOf<Long>()
|
||||||
|
|
||||||
|
val viewedTargets = findViewedGenreRecommendationTargets(
|
||||||
memberId = memberId,
|
memberId = memberId,
|
||||||
includeAdultGenres = includeAdultGenres,
|
includeAdultGenres = includeAdultGenres,
|
||||||
targetLimit = (genreLimit * creatorLimit).coerceAtLeast(genreLimit)
|
targetLimit = genreLimit
|
||||||
)
|
)
|
||||||
|
|
||||||
return genres.asSequence().mapNotNull { genre ->
|
viewedTargets.forEach { target ->
|
||||||
val creators = findCreatorsByGenre(
|
groups.add(toGenreCreatorRecommendationGroup(target, memberId, includeAdultGenres, creatorLimit))
|
||||||
genreId = genre.id,
|
selectedGenreIds.add(target.id)
|
||||||
memberId = memberId,
|
}
|
||||||
includeAdultGenres = includeAdultGenres,
|
|
||||||
creatorLimit = creatorLimit
|
fillFallbackGenreCreatorGroups(
|
||||||
)
|
groups = groups,
|
||||||
creators.takeIf { it.isNotEmpty() }?.let {
|
selectedGenreIds = selectedGenreIds,
|
||||||
HomeGenreCreatorRecommendationGroup(
|
memberId = memberId,
|
||||||
genreId = genre.id,
|
includeAdultGenres = includeAdultGenres,
|
||||||
genreName = genre.name,
|
genreLimit = genreLimit,
|
||||||
creators = it
|
creatorLimit = creatorLimit
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}.toList()
|
return groups
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun findGenreRecommendationTargets(
|
private fun findViewedGenreRecommendationTargets(
|
||||||
memberId: Long?,
|
memberId: Long?,
|
||||||
includeAdultGenres: Boolean,
|
includeAdultGenres: Boolean,
|
||||||
targetLimit: Int
|
targetLimit: Int
|
||||||
): List<GenreRecommendationTarget> {
|
): List<GenreRecommendationTarget> {
|
||||||
|
if (memberId == null || targetLimit <= 0) return emptyList()
|
||||||
|
|
||||||
val sql = """
|
val sql = """
|
||||||
select selected.id,
|
select ct.id,
|
||||||
selected.genre
|
ct.theme as genre
|
||||||
from (
|
from content_theme ct
|
||||||
select ct.id,
|
join (
|
||||||
ct.theme as genre,
|
select distinct c.theme_id
|
||||||
case when viewed.theme_id is null then 1 else 0 end as source_rank,
|
from creator_content_view_history ccvh
|
||||||
rand() as random_tie_breaker
|
join content c on c.id = ccvh.content_id
|
||||||
from content_theme ct
|
where ccvh.member_id = :memberId
|
||||||
left join (
|
) viewed on viewed.theme_id = ct.id
|
||||||
select distinct c.theme_id
|
where ct.is_active = true
|
||||||
from creator_content_view_history ccvh
|
and ${eligibleGenreExistsSql()}
|
||||||
join content c on c.id = ccvh.content_id
|
order by rand() asc
|
||||||
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
|
|
||||||
limit :targetLimit
|
limit :targetLimit
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
@@ -911,6 +883,114 @@ class DefaultHomeRecommendationQueryRepository(
|
|||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val rows = query.resultList as List<Array<Any?>>
|
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 ->
|
return rows.map { row ->
|
||||||
GenreRecommendationTarget(
|
GenreRecommendationTarget(
|
||||||
id = (row[0] as Number).toLong(),
|
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(
|
private fun findCreatorsByGenre(
|
||||||
genreId: Long,
|
genreId: Long,
|
||||||
memberId: Long?,
|
memberId: Long?,
|
||||||
@@ -1130,6 +1262,7 @@ class DefaultHomeRecommendationQueryRepository(
|
|||||||
|
|
||||||
private data class GenreRecommendationTarget(
|
private data class GenreRecommendationTarget(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
val name: String
|
val name: String,
|
||||||
|
val isViewed: Boolean = false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,14 +116,38 @@ class HomeRecommendationQueryService(
|
|||||||
creatorLimit: Int = DEFAULT_GENRE_CREATOR_CREATOR_LIMIT
|
creatorLimit: Int = DEFAULT_GENRE_CREATOR_CREATOR_LIMIT
|
||||||
): List<HomeGenreCreatorRecommendationGroup> {
|
): List<HomeGenreCreatorRecommendationGroup> {
|
||||||
val selectedCreatorIds = mutableSetOf<Long>()
|
val selectedCreatorIds = mutableSetOf<Long>()
|
||||||
val candidateLimit = genreLimit * creatorLimit
|
val partialFallbackGroups = mutableListOf<HomeGenreCreatorRecommendationGroup>()
|
||||||
|
val selectedGroups = mutableListOf<HomeGenreCreatorRecommendationGroup>()
|
||||||
|
|
||||||
return queryPort.findGenreCreatorRecommendations(memberId, includeAdultGenres, genreLimit, candidateLimit)
|
queryPort.findGenreCreatorRecommendations(memberId, includeAdultGenres, genreLimit, creatorLimit)
|
||||||
.map { group ->
|
.forEach { group ->
|
||||||
group.copy(creators = group.creators.filter { selectedCreatorIds.add(it.creatorId) }.take(creatorLimit))
|
if (selectedGroups.size >= genreLimit) return@forEach
|
||||||
|
|
||||||
|
val creators = group.creators.filter { it.creatorId !in selectedCreatorIds }.take(creatorLimit)
|
||||||
|
if (creators.isEmpty()) return@forEach
|
||||||
|
|
||||||
|
val deduplicatedGroup = group.copy(creators = creators)
|
||||||
|
if (group.isViewedTheme || creators.size == creatorLimit) {
|
||||||
|
selectedGroups.add(deduplicatedGroup)
|
||||||
|
selectedCreatorIds.addAll(creators.map { it.creatorId })
|
||||||
|
} else {
|
||||||
|
partialFallbackGroups.add(group)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.filter { it.creators.isNotEmpty() }
|
|
||||||
.take(genreLimit)
|
if (selectedGroups.size < genreLimit) {
|
||||||
|
partialFallbackGroups.forEach { group ->
|
||||||
|
if (selectedGroups.size >= genreLimit) return@forEach
|
||||||
|
|
||||||
|
val creators = group.creators.filter { it.creatorId !in selectedCreatorIds }.take(creatorLimit)
|
||||||
|
if (creators.isEmpty()) return@forEach
|
||||||
|
|
||||||
|
selectedGroups.add(group.copy(creators = creators))
|
||||||
|
selectedCreatorIds.addAll(creators.map { it.creatorId })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectedGroups.take(genreLimit)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun resolveAudioContentActivityType(theme: String): RecommendedActivityType {
|
fun resolveAudioContentActivityType(theme: String): RecommendedActivityType {
|
||||||
|
|||||||
@@ -165,7 +165,8 @@ data class HomePopularCommunityRecommendationRecord(
|
|||||||
data class HomeGenreCreatorRecommendationGroup(
|
data class HomeGenreCreatorRecommendationGroup(
|
||||||
val genreId: Long,
|
val genreId: Long,
|
||||||
val genreName: String,
|
val genreName: String,
|
||||||
val creators: List<HomeGenreCreatorRecommendationRecord>
|
val creators: List<HomeGenreCreatorRecommendationRecord>,
|
||||||
|
val isViewedTheme: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
data class HomeGenreCreatorRecommendationRecord(
|
data class HomeGenreCreatorRecommendationRecord(
|
||||||
|
|||||||
@@ -1504,8 +1504,95 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("장르 기반 크리에이터 추천은 서비스 중복 제거용 후보 그룹을 genreLimit보다 많이 반환한다")
|
@DisplayName("조회 이력이 없으면 크리에이터 8명을 채울 수 있는 콘텐츠 테마 후보를 먼저 반환한다")
|
||||||
fun shouldReturnMoreCandidateGroupsThanFinalGenreLimitForServiceDeduplicationBackfill() {
|
fun shouldRecommendFullRandomThemeCandidatesFirstWhenViewHistoryDoesNotExist() {
|
||||||
|
repeat(5) { themeIndex ->
|
||||||
|
val theme = saveTheme("no-history-underfilled-theme-$themeIndex")
|
||||||
|
saveAudioContent(
|
||||||
|
saveMember("no-history-underfilled-creator-$themeIndex", MemberRole.CREATOR),
|
||||||
|
LocalDateTime.of(2026, 5, 30, 10, 0).plusMinutes(themeIndex.toLong()),
|
||||||
|
isActive = true,
|
||||||
|
theme = theme
|
||||||
|
)
|
||||||
|
}
|
||||||
|
repeat(5) { themeIndex ->
|
||||||
|
val theme = saveTheme("no-history-full-theme-$themeIndex")
|
||||||
|
repeat(8) { creatorIndex ->
|
||||||
|
saveAudioContent(
|
||||||
|
saveMember("no-history-full-$themeIndex-creator-$creatorIndex", MemberRole.CREATOR),
|
||||||
|
LocalDateTime.of(2026, 5, 30, 11, 0).plusMinutes((themeIndex * 10 + creatorIndex).toLong()),
|
||||||
|
isActive = true,
|
||||||
|
theme = theme
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val recommendations = repository.findGenreCreatorRecommendations(
|
||||||
|
memberId = null,
|
||||||
|
includeAdultGenres = false,
|
||||||
|
genreLimit = 5,
|
||||||
|
creatorLimit = 8
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(true, recommendations.size >= 5)
|
||||||
|
assertEquals(true, recommendations.take(5).all { it.genreName.startsWith("no-history-full-theme-") })
|
||||||
|
assertEquals(true, recommendations.take(5).all { it.creators.size == 8 })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("조회 이력 콘텐츠 테마는 8명이 되지 않아도 후보에 포함하고 부족한 장르는 8명 보유 테마 후보로 채운다")
|
||||||
|
fun shouldKeepViewedThemeCandidateEvenWhenUnderfilledAndReturnFullRandomThemeCandidates() {
|
||||||
|
val viewer = saveMember("viewed-partial-viewer", MemberRole.USER)
|
||||||
|
val viewedTheme = saveTheme("viewed-partial-theme")
|
||||||
|
val viewedContent = saveAudioContent(
|
||||||
|
saveMember("viewed-partial-creator", MemberRole.CREATOR),
|
||||||
|
LocalDateTime.of(2026, 5, 30, 10, 0),
|
||||||
|
isActive = true,
|
||||||
|
theme = viewedTheme
|
||||||
|
)
|
||||||
|
repeat(5) { themeIndex ->
|
||||||
|
val theme = saveTheme("viewed-fill-full-theme-$themeIndex")
|
||||||
|
repeat(8) { creatorIndex ->
|
||||||
|
saveAudioContent(
|
||||||
|
saveMember("viewed-fill-full-$themeIndex-creator-$creatorIndex", MemberRole.CREATOR),
|
||||||
|
LocalDateTime.of(2026, 5, 30, 11, 0).plusMinutes((themeIndex * 10 + creatorIndex).toLong()),
|
||||||
|
isActive = true,
|
||||||
|
theme = theme
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 = 5,
|
||||||
|
creatorLimit = 8
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(true, recommendations.size >= 5)
|
||||||
|
assertEquals(true, recommendations.any { it.genreId == viewedTheme.id && it.creators.size == 1 })
|
||||||
|
assertEquals(
|
||||||
|
true,
|
||||||
|
recommendations
|
||||||
|
.filter { it.genreId != viewedTheme.id }
|
||||||
|
.take(4)
|
||||||
|
.all { it.genreName.startsWith("viewed-fill-full-theme-") && it.creators.size == 8 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("장르 기반 크리에이터 추천 repository는 service 보충용 후보 그룹을 genreLimit보다 많이 반환한다")
|
||||||
|
fun shouldReturnMoreCandidateGroupsThanFinalGenreLimitForServiceBackfill() {
|
||||||
val sharedCreator = saveMember("candidate-shared", MemberRole.CREATOR)
|
val sharedCreator = saveMember("candidate-shared", MemberRole.CREATOR)
|
||||||
val backfillCreator = saveMember("candidate-backfill", MemberRole.CREATOR)
|
val backfillCreator = saveMember("candidate-backfill", MemberRole.CREATOR)
|
||||||
val firstTheme = saveTheme("candidate-first-theme")
|
val firstTheme = saveTheme("candidate-first-theme")
|
||||||
|
|||||||
@@ -374,7 +374,7 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
assertEquals(100L, port.genreCreatorMemberId)
|
assertEquals(100L, port.genreCreatorMemberId)
|
||||||
assertEquals(true, port.genreCreatorIncludeAdultGenres)
|
assertEquals(true, port.genreCreatorIncludeAdultGenres)
|
||||||
assertEquals(5, port.genreCreatorGenreLimit)
|
assertEquals(5, port.genreCreatorGenreLimit)
|
||||||
assertEquals(40, port.genreCreatorCreatorLimit)
|
assertEquals(8, port.genreCreatorCreatorLimit)
|
||||||
assertEquals(listOf(10L, 11L), recommendations[0].creators.map { it.creatorId })
|
assertEquals(listOf(10L, 11L), recommendations[0].creators.map { it.creatorId })
|
||||||
assertEquals(listOf(12L, 13L), recommendations[1].creators.map { it.creatorId })
|
assertEquals(listOf(12L, 13L), recommendations[1].creators.map { it.creatorId })
|
||||||
}
|
}
|
||||||
@@ -429,6 +429,150 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
assertEquals(false, recommendations.any { it.creators.isEmpty() })
|
assertEquals(false, recommendations.any { it.creators.isEmpty() })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("장르 기반 크리에이터 추천은 중복 제거 후 랜덤 보충 장르가 8명이 되지 않으면 뒤 후보로 대체한다")
|
||||||
|
fun shouldBackfillRandomThemesWhenCreatorDeduplicationMakesThemeUnderfilled() {
|
||||||
|
val sharedCreators = (1L..8L).map { creatorId ->
|
||||||
|
HomeGenreCreatorRecommendationRecord(
|
||||||
|
creatorId = creatorId,
|
||||||
|
creatorNickname = "shared-$creatorId",
|
||||||
|
creatorProfileImage = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val uniqueCreators = (101L..108L).map { creatorId ->
|
||||||
|
HomeGenreCreatorRecommendationRecord(
|
||||||
|
creatorId = creatorId,
|
||||||
|
creatorNickname = "unique-$creatorId",
|
||||||
|
creatorProfileImage = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
port.genreCreatorRecommendations = listOf(
|
||||||
|
HomeGenreCreatorRecommendationGroup(
|
||||||
|
genreId = 1L,
|
||||||
|
genreName = "first-random-theme",
|
||||||
|
creators = sharedCreators
|
||||||
|
),
|
||||||
|
HomeGenreCreatorRecommendationGroup(
|
||||||
|
genreId = 2L,
|
||||||
|
genreName = "underfilled-after-dedup-theme",
|
||||||
|
creators = sharedCreators
|
||||||
|
),
|
||||||
|
HomeGenreCreatorRecommendationGroup(
|
||||||
|
genreId = 3L,
|
||||||
|
genreName = "backfill-random-theme",
|
||||||
|
creators = uniqueCreators
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val recommendations = service.findGenreCreatorRecommendations(
|
||||||
|
memberId = null,
|
||||||
|
includeAdultGenres = false,
|
||||||
|
genreLimit = 2,
|
||||||
|
creatorLimit = 8
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf(1L, 3L), recommendations.map { it.genreId })
|
||||||
|
assertEquals(true, recommendations.all { it.creators.size == 8 })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("장르 기반 크리에이터 추천은 보류된 부분 후보의 크리에이터를 뒤 충분 후보에서 중복 제거하지 않는다")
|
||||||
|
fun shouldNotConsumeCreatorsFromDeferredPartialFallbackCandidates() {
|
||||||
|
val firstCreators = (1L..8L).map { creatorId ->
|
||||||
|
HomeGenreCreatorRecommendationRecord(
|
||||||
|
creatorId = creatorId,
|
||||||
|
creatorNickname = "first-$creatorId",
|
||||||
|
creatorProfileImage = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val partialUniqueCreator = HomeGenreCreatorRecommendationRecord(
|
||||||
|
creatorId = 101L,
|
||||||
|
creatorNickname = "partial-unique",
|
||||||
|
creatorProfileImage = null
|
||||||
|
)
|
||||||
|
val fullBackfillCreators = (101L..108L).map { creatorId ->
|
||||||
|
HomeGenreCreatorRecommendationRecord(
|
||||||
|
creatorId = creatorId,
|
||||||
|
creatorNickname = "full-$creatorId",
|
||||||
|
creatorProfileImage = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
port.genreCreatorRecommendations = listOf(
|
||||||
|
HomeGenreCreatorRecommendationGroup(
|
||||||
|
genreId = 1L,
|
||||||
|
genreName = "first-random-theme",
|
||||||
|
creators = firstCreators
|
||||||
|
),
|
||||||
|
HomeGenreCreatorRecommendationGroup(
|
||||||
|
genreId = 2L,
|
||||||
|
genreName = "deferred-partial-theme",
|
||||||
|
creators = firstCreators.take(7) + partialUniqueCreator
|
||||||
|
),
|
||||||
|
HomeGenreCreatorRecommendationGroup(
|
||||||
|
genreId = 3L,
|
||||||
|
genreName = "full-backfill-theme",
|
||||||
|
creators = fullBackfillCreators
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val recommendations = service.findGenreCreatorRecommendations(
|
||||||
|
memberId = null,
|
||||||
|
includeAdultGenres = false,
|
||||||
|
genreLimit = 2,
|
||||||
|
creatorLimit = 8
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf(1L, 3L), recommendations.map { it.genreId })
|
||||||
|
assertEquals((101L..108L).toList(), recommendations[1].creators.map { it.creatorId })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("장르 기반 크리에이터 추천은 조회 이력 장르는 중복 제거 후 8명 미만이어도 유지한다")
|
||||||
|
fun shouldKeepViewedThemeWhenCreatorDeduplicationMakesThemeUnderfilled() {
|
||||||
|
val sharedCreators = (1L..8L).map { creatorId ->
|
||||||
|
HomeGenreCreatorRecommendationRecord(
|
||||||
|
creatorId = creatorId,
|
||||||
|
creatorNickname = "shared-$creatorId",
|
||||||
|
creatorProfileImage = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val uniqueCreators = (101L..108L).map { creatorId ->
|
||||||
|
HomeGenreCreatorRecommendationRecord(
|
||||||
|
creatorId = creatorId,
|
||||||
|
creatorNickname = "unique-$creatorId",
|
||||||
|
creatorProfileImage = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
port.genreCreatorRecommendations = listOf(
|
||||||
|
HomeGenreCreatorRecommendationGroup(
|
||||||
|
genreId = 1L,
|
||||||
|
genreName = "first-random-theme",
|
||||||
|
creators = sharedCreators
|
||||||
|
),
|
||||||
|
HomeGenreCreatorRecommendationGroup(
|
||||||
|
genreId = 2L,
|
||||||
|
genreName = "viewed-theme",
|
||||||
|
creators = sharedCreators + uniqueCreators.first(),
|
||||||
|
isViewedTheme = true
|
||||||
|
),
|
||||||
|
HomeGenreCreatorRecommendationGroup(
|
||||||
|
genreId = 3L,
|
||||||
|
genreName = "backfill-random-theme",
|
||||||
|
creators = uniqueCreators
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val recommendations = service.findGenreCreatorRecommendations(
|
||||||
|
memberId = 100L,
|
||||||
|
includeAdultGenres = false,
|
||||||
|
genreLimit = 2,
|
||||||
|
creatorLimit = 8
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf(1L, 2L), recommendations.map { it.genreId })
|
||||||
|
assertEquals(1, recommendations[1].creators.size)
|
||||||
|
}
|
||||||
|
|
||||||
private class FakeHomeRecommendationQueryPort : HomeRecommendationQueryPort {
|
private class FakeHomeRecommendationQueryPort : HomeRecommendationQueryPort {
|
||||||
var liveLimit: Int? = null
|
var liveLimit: Int? = null
|
||||||
var liveOffset: Int? = null
|
var liveOffset: Int? = null
|
||||||
|
|||||||
Reference in New Issue
Block a user