Compare commits

..

55 Commits

Author SHA1 Message Date
eb11168fa8 feat(home): 첫 오디오 콘텐츠 가격을 응답한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-01 23:31:44 +09:00
210d3dc618 feat(recommend): 첫 오디오 콘텐츠 가격을 조회한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-01 23:31:03 +09:00
1e08349477 feat(home): 인기 커뮤니티 게시글 가격을 응답한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-01 23:11:44 +09:00
fc00f3975e feat(recommend): 인기 커뮤니티 게시글 가격을 조회한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-01 23:11:31 +09:00
bd753a4710 test(home): 인기 커뮤니티 게시글 응답명을 검증한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-01 22:40:51 +09:00
1bed34425c feat(home): 인기 커뮤니티 게시글 응답 필드를 추가한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-01 22:40:29 +09:00
ae725e72c4 feat(recommend): 인기 커뮤니티 게시글 상세 필드를 조회한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-01 22:40:05 +09:00
1649d0b36e test(recommend): 홈 추천 신규 필드 테스트 픽스처를 보강한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-01 21:53:44 +09:00
8c82acacef fix(home): 홈 추천 포인트 응답 필드명을 고정한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-01 21:53:01 +09:00
c506f9c92b feat(home): AI 캐릭터 프로필 이미지를 응답한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-01 21:34:48 +09:00
86c1be1d67 feat(home): 첫 오디오 콘텐츠 포인트 사용 가능 여부를 응답한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-01 21:32:26 +09:00
1a1f67cc1a refactor(home): UTC 시간 포맷 변환을 재사용한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-01 19:01:23 +09:00
5970cf67a7 docs(home): 홈 추천 신규 테이블 SQL 설명을 보강한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-01 18:29:46 +09:00
1682c3ef8a docs(home): 홈 추천 Phase 7 산출물을 정리한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-01 17:57:42 +09:00
23cbdb4782 feat(recommend): 추천 스냅샷 성공 로그를 커밋 후 기록한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-01 17:57:16 +09:00
83159713de feat(recommend): 추천 팔로우 성공 로그를 커밋 후 기록한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-01 17:56:50 +09:00
8ce98012ad feat(recommend): 조회 이력 성공 로그를 커밋 후 기록한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-01 17:56:20 +09:00
c4213e93b6 feat(content): 콘텐츠 조회 이력 실패 로그를 남긴다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-01 17:55:53 +09:00
57235e5a39 feat(home): 홈 추천 조회 로그와 회원 컨텍스트를 전달한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-01 17:55:23 +09:00
61d79709c9 feat(recommend): 홈 추천 차단 필터를 확장한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-01 17:55:11 +09:00
96ce390e6f docs(home): 홈 추천 Phase 6 진행 상황을 정리한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-01 13:56:29 +09:00
3a32d1a37e feat(home): 홈 추천 조회 컨트롤러를 추가한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-01 13:55:53 +09:00
ed865a192f feat(recommend): 홈 추천 저장소 페이징 조건을 적용한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-01 13:55:17 +09:00
a7e06f278f feat(recommend): 홈 추천 전체보기 페이징 조회를 추가한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-01 13:54:40 +09:00
9fccf51e4a feat(home): 홈 추천 통합 facade를 추가한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-01 13:50:02 +09:00
c270e3c3af feat(home): 홈 추천 통합 응답 DTO를 추가한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-01 13:49:03 +09:00
a646283cef test(recommend): 팔로우 유니크 제약 테스트 픽스처를 정리한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-01 10:28:16 +09:00
a20f4d37ab docs(home): 추천 크리에이터 팔로우 요구사항을 정리한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-01 10:20:16 +09:00
c452f0070d feat(home): 추천 크리에이터 동시 팔로우 API를 추가한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-01 10:19:49 +09:00
431073ee14 feat(recommend): 추천 크리에이터 동시 팔로우 서비스를 추가한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-01 10:19:38 +09:00
13389cd558 docs(home): 메인 홈 추천 Phase 4 진행 상황을 정리한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-31 18:21:45 +09:00
472130b16b feat(recommend): 장르 기반 크리에이터 추천 조회를 추가한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-31 18:20:51 +09:00
e3b131268b feat(content): 콘텐츠 상세 조회 이력을 기록한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-31 18:20:07 +09:00
931d6a5fef feat(recommend): 콘텐츠 조회 이력 서비스를 추가한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-31 18:19:28 +09:00
e248a674c0 feat(recommend): 콘텐츠 조회 이력 저장 어댑터를 추가한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-31 18:18:50 +09:00
e1f0347ca5 feat(recommend): 콘텐츠 조회 이력 모델을 추가한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-31 18:18:23 +09:00
80f299eb1a docs(home): 본인 크리에이터 팔로우 제외 조건을 정리한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-31 16:55:58 +09:00
31aadd9e83 docs(home): 메인 홈 추천 Phase 3 계획을 정리한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-31 16:33:19 +09:00
f464f02567 feat(recommend): 홈 추천 조회 쿼리를 추가한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-31 16:32:51 +09:00
e545fd493e feat(recommend): 홈 추천 조회 서비스를 추가한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-31 16:32:43 +09:00
d195fea311 docs(home): 추천 스냅샷 점수 책임 경계를 정리한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-31 01:09:22 +09:00
6aada583c6 chore(opencode): 플러그인 잠금 버전을 갱신한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-31 00:58:47 +09:00
dfaacd068f feat(recommend): 추천 스냅샷 갱신 서비스를 추가한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-31 00:58:17 +09:00
7c85ec93fc feat(recommend): 홈 추천 스냅샷 집계 쿼리를 추가한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-31 00:57:46 +09:00
8b5872ce86 feat(recommend): 추천 스냅샷 저장소를 추가한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-31 00:57:15 +09:00
6499bacc8d feat(recommend): 추천 점수 산식 상수를 분리한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-31 00:56:59 +09:00
f8dd068fb1 docs(home): 메인 홈 추천 스냅샷 요구사항을 보강한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-31 00:56:45 +09:00
ef1580cbb8 docs(agent): 기본 구현체 명명 규칙을 문서화한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-30 20:10:26 +09:00
ec10bc1e58 docs(test): Redis 테스트 격리 규칙을 문서화한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-30 20:02:00 +09:00
f416d0169d test: embedded Redis 초기화를 명시 opt-in으로 분리한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-30 20:01:53 +09:00
8ba29a3719 feat(recommend): 추천 활동 공통 모델을 추가한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-30 17:45:30 +09:00
9a8172ab8b feat(recommend): 크리에이터 데뷔 판정 정책을 추가한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-30 17:45:06 +09:00
eecff03d4b feat(recommend): 홈 추천 점수 정책을 추가한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-30 17:44:59 +09:00
327471f4fc docs(home): 메인 홈 추천 API 구현 계획을 추가한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-30 17:44:51 +09:00
35b939b951 docs(home): 메인 홈 추천 API PRD를 추가한다
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-30 17:44:42 +09:00
5 changed files with 63 additions and 452 deletions

View File

@@ -826,201 +826,51 @@ class DefaultHomeRecommendationQueryRepository(
genreLimit: Int, genreLimit: Int,
creatorLimit: Int creatorLimit: Int
): List<HomeGenreCreatorRecommendationGroup> { ): List<HomeGenreCreatorRecommendationGroup> {
val groups = mutableListOf<HomeGenreCreatorRecommendationGroup>() val genres = findGenreRecommendationTargets(
val selectedGenreIds = mutableSetOf<Long>()
val viewedTargets = findViewedGenreRecommendationTargets(
memberId = memberId, memberId = memberId,
includeAdultGenres = includeAdultGenres, includeAdultGenres = includeAdultGenres,
targetLimit = genreLimit targetLimit = (genreLimit * creatorLimit).coerceAtLeast(genreLimit)
) )
viewedTargets.forEach { target -> return genres.asSequence().mapNotNull { genre ->
groups.add(toGenreCreatorRecommendationGroup(target, memberId, includeAdultGenres, creatorLimit)) val creators = findCreatorsByGenre(
selectedGenreIds.add(target.id) genreId = genre.id,
}
fillFallbackGenreCreatorGroups(
groups = groups,
selectedGenreIds = selectedGenreIds,
memberId = memberId, memberId = memberId,
includeAdultGenres = includeAdultGenres, includeAdultGenres = includeAdultGenres,
genreLimit = genreLimit,
creatorLimit = creatorLimit creatorLimit = creatorLimit
) )
creators.takeIf { it.isNotEmpty() }?.let {
return groups HomeGenreCreatorRecommendationGroup(
genreId = genre.id,
genreName = genre.name,
creators = it
)
}
}.toList()
} }
private fun findViewedGenreRecommendationTargets( private fun findGenreRecommendationTargets(
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 = """
select ct.id,
ct.theme as genre
from content_theme ct
join (
select distinct c.theme_id
from creator_content_view_history ccvh
join content c on c.id = ccvh.content_id
where ccvh.member_id = :memberId
) viewed on viewed.theme_id = ct.id
where ct.is_active = true
and ${eligibleGenreExistsSql()}
order by rand() 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,
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 = """ val sql = """
select selected.id, select selected.id,
selected.genre selected.genre
from ( from (
select ct.id, select ct.id,
ct.theme as genre, ct.theme as genre,
count(distinct m.id) as creator_count, case when viewed.theme_id is null then 1 else 0 end as source_rank,
rand() as random_tie_breaker rand() as random_tie_breaker
from content_theme ct from content_theme ct
join content c on c.theme_id = ct.id left join (
join member m on m.id = c.member_id 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 where ct.is_active = true
$excludedClause and exists (
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 ->
GenreRecommendationTarget(
id = (row[0] as Number).toLong(),
name = row[1] as String
)
}
}
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 select 1
from content c from content c
join member m on m.id = c.member_id join member m on m.id = c.member_id
@@ -1048,7 +898,25 @@ class DefaultHomeRecommendationQueryRepository(
) )
) )
) )
) selected
order by selected.source_rank asc, selected.random_tie_breaker asc
limit :targetLimit
""".trimIndent() """.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( private fun findCreatorsByGenre(
@@ -1262,7 +1130,6 @@ 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,38 +116,14 @@ 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 partialFallbackGroups = mutableListOf<HomeGenreCreatorRecommendationGroup>() val candidateLimit = genreLimit * creatorLimit
val selectedGroups = mutableListOf<HomeGenreCreatorRecommendationGroup>()
queryPort.findGenreCreatorRecommendations(memberId, includeAdultGenres, genreLimit, creatorLimit) return queryPort.findGenreCreatorRecommendations(memberId, includeAdultGenres, genreLimit, candidateLimit)
.forEach { group -> .map { group ->
if (selectedGroups.size >= genreLimit) return@forEach group.copy(creators = group.creators.filter { selectedCreatorIds.add(it.creatorId) }.take(creatorLimit))
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,8 +165,7 @@ 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,95 +1504,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
} }
@Test @Test
@DisplayName("조회 이력이 없으면 크리에이터 8명을 채울 수 있는 콘텐츠 테마 후보를 먼저 반환한다") @DisplayName("장르 기반 크리에이터 추천은 서비스 중복 제거용 후보 그룹을 genreLimit보다 많이 반환한다")
fun shouldRecommendFullRandomThemeCandidatesFirstWhenViewHistoryDoesNotExist() { fun shouldReturnMoreCandidateGroupsThanFinalGenreLimitForServiceDeduplicationBackfill() {
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(8, port.genreCreatorCreatorLimit) assertEquals(40, 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,150 +429,6 @@ 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