Compare commits

..

58 Commits

Author SHA1 Message Date
7606796fe3 feat(recommend): 홈 장르 추천 크리에이터 중복 보충을 개선한다 2026-06-04 17:23:03 +09:00
81f1bcc4ef feat(recommend): 홈 장르 추천 후보 조회를 보강한다 2026-06-04 17:22:23 +09:00
410814ef33 feat(recommend): 홈 장르 추천 조회 이력 표시를 추가한다 2026-06-04 17:22:08 +09:00
e5827d5018 feat(home): 첫 오디오 콘텐츠 가격을 응답한다 2026-06-01 23:31:44 +09:00
b99a406248 feat(recommend): 첫 오디오 콘텐츠 가격을 조회한다 2026-06-01 23:31:03 +09:00
3a17941ec6 feat(home): 인기 커뮤니티 게시글 가격을 응답한다 2026-06-01 23:11:44 +09:00
6d399c48ab feat(recommend): 인기 커뮤니티 게시글 가격을 조회한다 2026-06-01 23:11:31 +09:00
bc349d5881 test(home): 인기 커뮤니티 게시글 응답명을 검증한다 2026-06-01 22:40:51 +09:00
5d606a257e feat(home): 인기 커뮤니티 게시글 응답 필드를 추가한다 2026-06-01 22:40:29 +09:00
12b446c4ae feat(recommend): 인기 커뮤니티 게시글 상세 필드를 조회한다 2026-06-01 22:40:05 +09:00
6304c67cde test(recommend): 홈 추천 신규 필드 테스트 픽스처를 보강한다 2026-06-01 21:53:44 +09:00
7c0aa9245e fix(home): 홈 추천 포인트 응답 필드명을 고정한다 2026-06-01 21:53:01 +09:00
0fdfc48680 feat(home): AI 캐릭터 프로필 이미지를 응답한다 2026-06-01 21:34:48 +09:00
4f66b6abb9 feat(home): 첫 오디오 콘텐츠 포인트 사용 가능 여부를 응답한다 2026-06-01 21:32:26 +09:00
279053ce7b refactor(home): UTC 시간 포맷 변환을 재사용한다 2026-06-01 19:01:23 +09:00
d86fee0945 docs(home): 홈 추천 신규 테이블 SQL 설명을 보강한다 2026-06-01 18:29:46 +09:00
9f27d70910 docs(home): 홈 추천 Phase 7 산출물을 정리한다 2026-06-01 17:57:42 +09:00
85591c2a8b feat(recommend): 추천 스냅샷 성공 로그를 커밋 후 기록한다 2026-06-01 17:57:16 +09:00
bb96f07872 feat(recommend): 추천 팔로우 성공 로그를 커밋 후 기록한다 2026-06-01 17:56:50 +09:00
da387f43a0 feat(recommend): 조회 이력 성공 로그를 커밋 후 기록한다 2026-06-01 17:56:20 +09:00
7ad514dcc0 feat(content): 콘텐츠 조회 이력 실패 로그를 남긴다 2026-06-01 17:55:53 +09:00
1d7f55bbe7 feat(home): 홈 추천 조회 로그와 회원 컨텍스트를 전달한다 2026-06-01 17:55:23 +09:00
c681fb9a3f feat(recommend): 홈 추천 차단 필터를 확장한다 2026-06-01 17:55:11 +09:00
65f0ff7e72 docs(home): 홈 추천 Phase 6 진행 상황을 정리한다 2026-06-01 13:56:29 +09:00
fb0f22070f feat(home): 홈 추천 조회 컨트롤러를 추가한다 2026-06-01 13:55:53 +09:00
3df5614b7a feat(recommend): 홈 추천 저장소 페이징 조건을 적용한다 2026-06-01 13:55:17 +09:00
1f3a38a404 feat(recommend): 홈 추천 전체보기 페이징 조회를 추가한다 2026-06-01 13:54:40 +09:00
f77bd7b8e2 feat(home): 홈 추천 통합 facade를 추가한다 2026-06-01 13:50:02 +09:00
09cba1ffeb feat(home): 홈 추천 통합 응답 DTO를 추가한다 2026-06-01 13:49:03 +09:00
227a329ae1 test(recommend): 팔로우 유니크 제약 테스트 픽스처를 정리한다 2026-06-01 10:28:16 +09:00
9df7ba259b docs(home): 추천 크리에이터 팔로우 요구사항을 정리한다 2026-06-01 10:20:16 +09:00
cdff31422c feat(home): 추천 크리에이터 동시 팔로우 API를 추가한다 2026-06-01 10:19:49 +09:00
8300b1875c feat(recommend): 추천 크리에이터 동시 팔로우 서비스를 추가한다 2026-06-01 10:19:38 +09:00
82b2eb75d4 docs(home): 메인 홈 추천 Phase 4 진행 상황을 정리한다 2026-05-31 18:21:45 +09:00
5bea7cfb64 feat(recommend): 장르 기반 크리에이터 추천 조회를 추가한다 2026-05-31 18:20:51 +09:00
209d32da2f feat(content): 콘텐츠 상세 조회 이력을 기록한다 2026-05-31 18:20:07 +09:00
43179de810 feat(recommend): 콘텐츠 조회 이력 서비스를 추가한다 2026-05-31 18:19:28 +09:00
2ef8e8e489 feat(recommend): 콘텐츠 조회 이력 저장 어댑터를 추가한다 2026-05-31 18:18:50 +09:00
70832a10b9 feat(recommend): 콘텐츠 조회 이력 모델을 추가한다 2026-05-31 18:18:23 +09:00
24429abe38 docs(home): 본인 크리에이터 팔로우 제외 조건을 정리한다 2026-05-31 16:55:58 +09:00
5003588556 docs(home): 메인 홈 추천 Phase 3 계획을 정리한다 2026-05-31 16:33:19 +09:00
6652984056 feat(recommend): 홈 추천 조회 쿼리를 추가한다 2026-05-31 16:32:51 +09:00
14822f351b feat(recommend): 홈 추천 조회 서비스를 추가한다 2026-05-31 16:32:43 +09:00
3cd4e689dc docs(home): 추천 스냅샷 점수 책임 경계를 정리한다 2026-05-31 01:09:22 +09:00
bc68d1f227 chore(opencode): 플러그인 잠금 버전을 갱신한다 2026-05-31 00:58:47 +09:00
82d935e63f feat(recommend): 추천 스냅샷 갱신 서비스를 추가한다 2026-05-31 00:58:17 +09:00
58e59c5cb4 feat(recommend): 홈 추천 스냅샷 집계 쿼리를 추가한다 2026-05-31 00:57:46 +09:00
2edd486524 feat(recommend): 추천 스냅샷 저장소를 추가한다 2026-05-31 00:57:15 +09:00
a7e17fede2 feat(recommend): 추천 점수 산식 상수를 분리한다 2026-05-31 00:56:59 +09:00
602063863a docs(home): 메인 홈 추천 스냅샷 요구사항을 보강한다 2026-05-31 00:56:45 +09:00
029408039d docs(agent): 기본 구현체 명명 규칙을 문서화한다 2026-05-30 20:10:26 +09:00
fa828f71a0 docs(test): Redis 테스트 격리 규칙을 문서화한다 2026-05-30 20:02:00 +09:00
43304522e3 test: embedded Redis 초기화를 명시 opt-in으로 분리한다 2026-05-30 20:01:53 +09:00
1d1e062e1e feat(recommend): 추천 활동 공통 모델을 추가한다 2026-05-30 17:45:30 +09:00
c5b92d250e feat(recommend): 크리에이터 데뷔 판정 정책을 추가한다 2026-05-30 17:45:06 +09:00
07bbc75844 feat(recommend): 홈 추천 점수 정책을 추가한다 2026-05-30 17:44:59 +09:00
2324483c87 docs(home): 메인 홈 추천 API 구현 계획을 추가한다 2026-05-30 17:44:51 +09:00
502bf9639e docs(home): 메인 홈 추천 API PRD를 추가한다 2026-05-30 17:44:42 +09:00
5 changed files with 463 additions and 74 deletions

View File

@@ -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
) )
} }

View File

@@ -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 {

View File

@@ -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(

View File

@@ -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")

View File

@@ -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