feat(recommend): 홈 추천 저장소 페이징 조건을 적용한다

This commit is contained in:
2026-06-01 13:55:17 +09:00
parent 1f3a38a404
commit 3df5614b7a
5 changed files with 236 additions and 18 deletions

View File

@@ -77,7 +77,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
}
flushAndClear()
val lives = repository.findLiveRecommendations(limit = 20)
val lives = repository.findLiveRecommendations(limit = 20, includeAdultLives = false)
assertEquals(20, lives.size)
assertEquals(baseAt.plusDays(1).plusMinutes(20), lives.first().beginDateTime)
@@ -87,6 +87,24 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
assertEquals(false, lives.any { it.creatorId == inactiveCreator.id })
}
@Test
@DisplayName("라이브 전체보기 조회는 offset/limit과 성인 노출 조건을 DB에서 적용한다")
fun shouldFindPagedLiveRecommendationsWithAdultFilter() {
val baseAt = LocalDateTime.of(2026, 5, 31, 10, 0)
val creator = saveMember("paged-live-creator", MemberRole.CREATOR)
val newest = saveLiveRoom(creator, baseAt.plusMinutes(3), channelName = "paged-live-newest", isAdult = false)
saveLiveRoom(creator, baseAt.plusMinutes(2), channelName = "paged-live-adult", isAdult = true)
val middle = saveLiveRoom(creator, baseAt.plusMinutes(1), channelName = "paged-live-middle", isAdult = false)
val oldest = saveLiveRoom(creator, baseAt, channelName = "paged-live-oldest", isAdult = false)
flushAndClear()
val page0 = repository.findLiveRecommendations(offset = 0, limit = 3, includeAdultLives = false)
val page1 = repository.findLiveRecommendations(offset = 2, limit = 3, includeAdultLives = false)
assertEquals(listOf(newest.id, middle.id, oldest.id), page0.map { it.liveRoomId })
assertEquals(listOf(oldest.id), page1.map { it.liveRoomId })
}
@Test
@DisplayName("홈 배너는 활성 배너를 orders 오름차순 최대 20개로 조회하고 동일 orders는 랜덤 tie-breaker를 적용한다")
fun shouldFindActiveHomeBannersWithOrderLimitAndRandomTieBreaker() {
@@ -251,6 +269,39 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
assertEquals(community.id, byCreatorId[communityCreator.id]!!.targetId)
}
@Test
@DisplayName("최근 활동 크리에이터는 성인 노출 정책이 꺼져 있으면 성인 라이브/오디오/커뮤니티 활동을 제외한다")
fun shouldExcludeAdultActivitiesFromRecentlyActiveCreatorsWhenAdultHidden() {
val baseAt = LocalDateTime.of(2026, 5, 31, 10, 0)
val normalLiveCreator = saveMember("activity-normal-live", MemberRole.CREATOR)
val adultLiveCreator = saveMember("activity-adult-live", MemberRole.CREATOR)
val adultAudioCreator = saveMember("activity-adult-audio", MemberRole.CREATOR)
val adultCommunityCreator = saveMember("activity-adult-community", MemberRole.CREATOR)
saveLiveRoom(normalLiveCreator, baseAt.plusMinutes(3), channelName = "normal-live", isAdult = false)
saveLiveRoom(adultLiveCreator, baseAt.plusMinutes(2), channelName = "adult-live", isAdult = true)
val adultAudio = saveAudioContent(adultAudioCreator, baseAt.plusMinutes(1), isActive = true, isAdult = true)
val adultCommunity = saveCommunity(adultCommunityCreator, isCommentAvailable = true, isAdult = true)
updateCreatedAt("CreatorCommunity", adultCommunity.id!!, baseAt)
flushAndClear()
val hiddenCreators = repository.findRecentlyActiveCreators(limit = 10, includeAdultActivities = false)
val visibleCreators = repository.findRecentlyActiveCreators(limit = 10, includeAdultActivities = true)
assertEquals(listOf(normalLiveCreator.id), hiddenCreators.map { it.creatorId })
assertEquals(
listOf(normalLiveCreator.id, adultLiveCreator.id, adultAudioCreator.id, adultCommunityCreator.id),
visibleCreators.map { it.creatorId }
)
assertEquals(null, visibleCreators[0].targetId)
assertEquals(null, visibleCreators[1].targetId)
assertEquals(adultAudio.id, visibleCreators[2].targetId)
assertEquals(adultCommunity.id, visibleCreators[3].targetId)
assertEquals(RecommendedActivityType.LIVE, visibleCreators[0].activityType)
assertEquals(RecommendedActivityType.LIVE, visibleCreators[1].activityType)
assertEquals(RecommendedActivityType.AUDIO, visibleCreators[2].activityType)
assertEquals(RecommendedActivityType.COMMUNITY, visibleCreators[3].activityType)
}
@Test
@DisplayName("AI 캐릭터 스냅샷은 AI 발화 수와 중복 없는 활성 사용자 수를 집계하고 팔로우 증가량은 제외한다")
fun shouldFindAiCharacterSnapshotsWithoutFollowIncrease() {
@@ -786,6 +837,53 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
assertEquals(expectedLowScore, creators.last().score, 0.0001)
}
@Test
@DisplayName("최근 데뷔 크리에이터 전체보기 조회는 offset/limit과 성인 콘텐츠 제외를 DB에서 적용한다")
fun shouldFindPagedRecentDebutCreatorsWithAdultFilter() {
val now = LocalDateTime.of(2026, 5, 31, 10, 0)
val normalNewest = saveMember("paged-debut-normal-newest", MemberRole.CREATOR)
val adultCreator = saveMember("paged-debut-adult", MemberRole.CREATOR)
val normalOldest = saveMember("paged-debut-normal-oldest", MemberRole.CREATOR)
val newestContent = saveAudioContent(normalNewest, now.minusDays(1), isActive = true, isAdult = false)
saveAudioContent(adultCreator, now.minusDays(2), isActive = true, isAdult = true)
saveAudioContent(normalOldest, now.minusDays(3), isActive = true, isAdult = false)
val fan = saveMember("paged-debut-fan", MemberRole.USER)
val following = saveFollowing(fan, normalNewest, isActive = true)
val comment = saveAudioContentComment(fan, newestContent, isActive = true)
val like = saveAudioContentLike(fan, newestContent, isActive = true)
updateCreatedAt("CreatorFollowing", following.id!!, now.minusHours(1))
updateCreatedAt("AudioContentComment", comment.id!!, now.minusHours(1))
updateCreatedAt("AudioContentLike", like.id!!, now.minusHours(1))
flushAndClear()
val page0 = repository.findRecentDebutCreators(now, offset = 0, limit = 2, includeAdultContents = false)
val page1 = repository.findRecentDebutCreators(now, offset = 1, limit = 2, includeAdultContents = false)
assertEquals(listOf(normalNewest.id, normalOldest.id), page0.map { it.creatorId })
assertEquals(listOf(normalOldest.id), page1.map { it.creatorId })
}
@Test
@DisplayName("최근 데뷔 크리에이터 전체보기 동점 후보는 page size 1에서도 안정적으로 겹치지 않는다")
fun shouldFindPagedRecentDebutCreatorsWithStableTieOrdering() {
val now = LocalDateTime.of(2026, 5, 31, 10, 0)
val creator1 = saveMember("stable-paged-debut-1", MemberRole.CREATOR)
val creator2 = saveMember("stable-paged-debut-2", MemberRole.CREATOR)
val creator3 = saveMember("stable-paged-debut-3", MemberRole.CREATOR)
saveAudioContent(creator1, now.minusDays(5), isActive = true)
saveAudioContent(creator2, now.minusDays(5), isActive = true)
saveAudioContent(creator3, now.minusDays(5), isActive = true)
flushAndClear()
val page0 = repository.findRecentDebutCreators(now, offset = 0, limit = 1, includeAdultContents = false)
val page1 = repository.findRecentDebutCreators(now, offset = 1, limit = 1, includeAdultContents = false)
val page2 = repository.findRecentDebutCreators(now, offset = 2, limit = 1, includeAdultContents = false)
val pagedCreatorIds = (page0 + page1 + page2).map { it.creatorId }
assertEquals(listOf(creator1.id, creator2.id, creator3.id), pagedCreatorIds)
assertEquals(pagedCreatorIds, pagedCreatorIds.distinct())
}
@Test
@DisplayName("최근 데뷔 크리에이터 동점은 랜덤 tie-breaker 오름차순으로 정렬한다")
fun shouldOrderRecentDebutCreatorsByRandomTieBreakerWhenScoresTie() {
@@ -853,6 +951,46 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
assertEquals(true, contents.zipWithNext().all { it.first.recencyScore >= it.second.recencyScore })
}
@Test
@DisplayName("첫 오디오 전체보기 조회는 offset/limit과 성인 콘텐츠 제외를 DB에서 적용한다")
fun shouldFindPagedFirstAudioContentsWithAdultFilter() {
val now = LocalDateTime.of(2026, 5, 31, 10, 0)
val newestCreator = saveMember("paged-first-audio-newest", MemberRole.CREATOR)
val adultCreator = saveMember("paged-first-audio-adult", MemberRole.CREATOR)
val oldestCreator = saveMember("paged-first-audio-oldest", MemberRole.CREATOR)
val newest = saveAudioContent(newestCreator, now.minusDays(1), isActive = true, isAdult = false)
saveAudioContent(adultCreator, now.minusDays(2), isActive = true, isAdult = true)
val oldest = saveAudioContent(oldestCreator, now.minusDays(21), isActive = true, isAdult = false)
flushAndClear()
val page0 = repository.findFirstAudioContents(now, offset = 0, limit = 2, includeAdultContents = false)
val page1 = repository.findFirstAudioContents(now, offset = 1, limit = 2, includeAdultContents = false)
assertEquals(listOf(newest.id, oldest.id), page0.map { it.contentId })
assertEquals(listOf(oldest.id), page1.map { it.contentId })
}
@Test
@DisplayName("첫 오디오 전체보기 동점 후보는 page size 1에서도 안정적으로 겹치지 않는다")
fun shouldFindPagedFirstAudioContentsWithStableTieOrdering() {
val now = LocalDateTime.of(2026, 5, 31, 10, 0)
val creator1 = saveMember("stable-paged-first-audio-1", MemberRole.CREATOR)
val creator2 = saveMember("stable-paged-first-audio-2", MemberRole.CREATOR)
val creator3 = saveMember("stable-paged-first-audio-3", MemberRole.CREATOR)
val content1 = saveAudioContent(creator1, now.minusDays(5), isActive = true)
val content2 = saveAudioContent(creator2, now.minusDays(5), isActive = true)
val content3 = saveAudioContent(creator3, now.minusDays(5), isActive = true)
flushAndClear()
val page0 = repository.findFirstAudioContents(now, offset = 0, limit = 1, includeAdultContents = false)
val page1 = repository.findFirstAudioContents(now, offset = 1, limit = 1, includeAdultContents = false)
val page2 = repository.findFirstAudioContents(now, offset = 2, limit = 1, includeAdultContents = false)
val pagedContentIds = (page0 + page1 + page2).map { it.contentId }
assertEquals(listOf(content1.id, content2.id, content3.id), pagedContentIds)
assertEquals(pagedContentIds, pagedContentIds.distinct())
}
@Test
@DisplayName("AI 캐릭터 상세는 활성 캐릭터의 이름 소개 전체 AI 발화 수와 연결된 원작명만 조회한다")
fun shouldFindAiCharacterRecommendationDetailsWithOriginalWorkTitleOnlyWhenExists() {
@@ -1440,13 +1578,18 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
return banner
}
private fun saveLiveRoom(creator: Member, beginDateTime: LocalDateTime, channelName: String?): LiveRoom {
private fun saveLiveRoom(
creator: Member,
beginDateTime: LocalDateTime,
channelName: String?,
isAdult: Boolean = false
): LiveRoom {
val room = LiveRoom(
title = "live-${creator.nickname}-$beginDateTime",
notice = "notice",
beginDateTime = beginDateTime,
numberOfPeople = 0,
isAdult = false
isAdult = isAdult
)
room.member = creator
room.channelName = channelName

View File

@@ -60,6 +60,23 @@ class RecommendationSnapshotPersistenceAdapterTest @Autowired constructor(
assertEquals(listOf(latestSnapshotAt, latestSnapshotAt, latestSnapshotAt), latestSnapshots.map { it.snapshotAt })
}
@Test
fun shouldFindLatestSnapshotsWithOffsetAndLimit() {
val latestSnapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
repository.saveAll(
listOf(
snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 1L, score = 400.0, snapshotAt = latestSnapshotAt),
snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 2L, score = 300.0, snapshotAt = latestSnapshotAt),
snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 3L, score = 200.0, snapshotAt = latestSnapshotAt),
snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 4L, score = 100.0, snapshotAt = latestSnapshotAt)
)
)
val snapshots = adapter.findLatestSnapshots(RecommendedSectionType.AI_CHARACTER, offset = 1, limit = 2)
assertEquals(listOf(2L, 3L), snapshots.map { it.targetId })
}
@Test
fun shouldReplaceSnapshotsByDeletingSameSectionSnapshotAtOnly() {
val oldSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59)