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

@@ -43,7 +43,11 @@ class DefaultHomeRecommendationQueryRepository(
private val queryFactory: JPAQueryFactory, private val queryFactory: JPAQueryFactory,
private val entityManager: EntityManager private val entityManager: EntityManager
) : HomeRecommendationQueryRepository { ) : HomeRecommendationQueryRepository {
override fun findLiveRecommendations(limit: Int): List<HomeLiveRecommendationRecord> { override fun findLiveRecommendations(
offset: Int,
limit: Int,
includeAdultLives: Boolean
): List<HomeLiveRecommendationRecord> {
return queryFactory return queryFactory
.select( .select(
Projections.constructor( Projections.constructor(
@@ -64,9 +68,11 @@ class DefaultHomeRecommendationQueryRepository(
liveRoom.isActive.isTrue, liveRoom.isActive.isTrue,
liveRoom.channelName.isNotNull, liveRoom.channelName.isNotNull,
liveRoom.channelName.isNotEmpty, liveRoom.channelName.isNotEmpty,
includeAdultLiveCondition(includeAdultLives),
member.isActive.isTrue member.isActive.isTrue
) )
.orderBy(liveRoom.beginDateTime.desc(), liveRoom.id.desc()) .orderBy(liveRoom.beginDateTime.desc(), liveRoom.id.desc())
.offset(offset.toLong())
.limit(limit.toLong()) .limit(limit.toLong())
.fetch() .fetch()
} }
@@ -106,7 +112,10 @@ class DefaultHomeRecommendationQueryRepository(
.fetch() .fetch()
} }
override fun findRecentlyActiveCreators(limit: Int): List<RecentlyActiveCreatorRecord> { override fun findRecentlyActiveCreators(
limit: Int,
includeAdultActivities: Boolean
): List<RecentlyActiveCreatorRecord> {
val sql = """ val sql = """
select ranked.creator_id, select ranked.creator_id,
ranked.creator_nickname, ranked.creator_nickname,
@@ -133,6 +142,7 @@ class DefaultHomeRecommendationQueryRepository(
where lr.is_active = true where lr.is_active = true
and lr.channel_name is not null and lr.channel_name is not null
and lr.channel_name <> '' and lr.channel_name <> ''
and (:includeAdultActivities = true or lr.is_adult = false)
and m.is_active = true and m.is_active = true
union all union all
select m.id as creator_id, select m.id as creator_id,
@@ -147,6 +157,7 @@ class DefaultHomeRecommendationQueryRepository(
join content_theme act on act.id = ac.theme_id join content_theme act on act.id = ac.theme_id
where ac.is_active = true where ac.is_active = true
and ac.release_date is not null and ac.release_date is not null
and (:includeAdultActivities = true or ac.is_adult = false)
and m.is_active = true and m.is_active = true
union all union all
select m.id as creator_id, select m.id as creator_id,
@@ -159,6 +170,7 @@ class DefaultHomeRecommendationQueryRepository(
from creator_community cc from creator_community cc
join member m on m.id = cc.member_id join member m on m.id = cc.member_id
where cc.is_active = true where cc.is_active = true
and (:includeAdultActivities = true or cc.is_adult = false)
and m.is_active = true and m.is_active = true
) activities ) activities
) ranked ) ranked
@@ -169,6 +181,7 @@ class DefaultHomeRecommendationQueryRepository(
val query = entityManager.createNativeQuery(sql) val query = entityManager.createNativeQuery(sql)
.setParameter("liveReplayTheme", LIVE_REPLAY_THEME) .setParameter("liveReplayTheme", LIVE_REPLAY_THEME)
.setParameter("includeAdultActivities", includeAdultActivities)
.setParameter("limit", limit) .setParameter("limit", limit)
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
@@ -186,7 +199,12 @@ class DefaultHomeRecommendationQueryRepository(
} }
} }
override fun findRecentDebutCreators(now: LocalDateTime, limit: Int): List<RecentDebutCreatorRecord> { override fun findRecentDebutCreators(
now: LocalDateTime,
offset: Int,
limit: Int,
includeAdultContents: Boolean
): List<RecentDebutCreatorRecord> {
val sql = """ val sql = """
with creator_debut as ( with creator_debut as (
select debut_events.creator_id as creator_id, min(debut_events.debut_at) as debut_at select debut_events.creator_id as creator_id, min(debut_events.debut_at) as debut_at
@@ -196,6 +214,7 @@ class DefaultHomeRecommendationQueryRepository(
where ac.is_active = true where ac.is_active = true
and ac.release_date is not null and ac.release_date is not null
and ac.release_date <= :now and ac.release_date <= :now
and (:includeAdultContents = true or ac.is_adult = false)
union all union all
select lr.member_id as creator_id, lr.begin_date_time as debut_at select lr.member_id as creator_id, lr.begin_date_time as debut_at
from live_room lr from live_room lr
@@ -203,6 +222,7 @@ class DefaultHomeRecommendationQueryRepository(
and lr.channel_name is not null and lr.channel_name is not null
and lr.channel_name <> '' and lr.channel_name <> ''
and lr.begin_date_time <= :now and lr.begin_date_time <= :now
and (:includeAdultContents = true or lr.is_adult = false)
) debut_events ) debut_events
group by debut_events.creator_id group by debut_events.creator_id
), ),
@@ -223,6 +243,7 @@ class DefaultHomeRecommendationQueryRepository(
and ac.release_date is not null and ac.release_date is not null
and ac.release_date >= :window30Start and ac.release_date >= :window30Start
and ac.release_date <= :now and ac.release_date <= :now
and (:includeAdultContents = true or ac.is_adult = false)
union all union all
select lr.member_id as creator_id, lr.id as activity_id select lr.member_id as creator_id, lr.id as activity_id
from live_room lr from live_room lr
@@ -231,6 +252,7 @@ class DefaultHomeRecommendationQueryRepository(
and lr.channel_name <> '' and lr.channel_name <> ''
and lr.begin_date_time >= :window30Start and lr.begin_date_time >= :window30Start
and lr.begin_date_time <= :now and lr.begin_date_time <= :now
and (:includeAdultContents = true or lr.is_adult = false)
) activity ) activity
group by activity.creator_id group by activity.creator_id
), ),
@@ -290,7 +312,7 @@ class DefaultHomeRecommendationQueryRepository(
when cd.debut_at >= :boost30Start then ${RecommendationScoreSpec.NEW_BOOST_30_DAYS} when cd.debut_at >= :boost30Start then ${RecommendationScoreSpec.NEW_BOOST_30_DAYS}
else ${RecommendationScoreSpec.DEFAULT_NEW_BOOST} else ${RecommendationScoreSpec.DEFAULT_NEW_BOOST}
end) as score, end) as score,
rand() as random_tie_breaker m.id as random_tie_breaker
from member m from member m
join creator_debut cd on cd.creator_id = m.id join creator_debut cd on cd.creator_id = m.id
left join follow_stats fs on fs.creator_id = m.id left join follow_stats fs on fs.creator_id = m.id
@@ -301,10 +323,13 @@ class DefaultHomeRecommendationQueryRepository(
and cd.debut_at <= :now and cd.debut_at <= :now
order by score desc, random_tie_breaker asc order by score desc, random_tie_breaker asc
limit :limit limit :limit
offset :offset
""".trimIndent() """.trimIndent()
val query = entityManager.createNativeQuery(sql) val query = entityManager.createNativeQuery(sql)
.setRecommendationQueryParameters(now, limit) .setRecommendationQueryParameters(now, limit)
.setParameter("offset", offset)
.setParameter("includeAdultContents", includeAdultContents)
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
val rows = query.resultList as List<Array<Any?>> val rows = query.resultList as List<Array<Any?>>
@@ -321,7 +346,12 @@ class DefaultHomeRecommendationQueryRepository(
} }
} }
override fun findFirstAudioContents(now: LocalDateTime, limit: Int): List<HomeFirstAudioContentRecord> { override fun findFirstAudioContents(
now: LocalDateTime,
offset: Int,
limit: Int,
includeAdultContents: Boolean
): List<HomeFirstAudioContentRecord> {
val sql = """ val sql = """
with creator_debut as ( with creator_debut as (
select debut_events.creator_id as creator_id, min(debut_events.debut_at) as debut_at select debut_events.creator_id as creator_id, min(debut_events.debut_at) as debut_at
@@ -331,6 +361,7 @@ class DefaultHomeRecommendationQueryRepository(
where ac.is_active = true where ac.is_active = true
and ac.release_date is not null and ac.release_date is not null
and ac.release_date <= :now and ac.release_date <= :now
and (:includeAdultContents = true or ac.is_adult = false)
union all union all
select lr.member_id as creator_id, lr.begin_date_time as debut_at select lr.member_id as creator_id, lr.begin_date_time as debut_at
from live_room lr from live_room lr
@@ -338,6 +369,7 @@ class DefaultHomeRecommendationQueryRepository(
and lr.channel_name is not null and lr.channel_name is not null
and lr.channel_name <> '' and lr.channel_name <> ''
and lr.begin_date_time <= :now and lr.begin_date_time <= :now
and (:includeAdultContents = true or lr.is_adult = false)
) debut_events ) debut_events
group by debut_events.creator_id group by debut_events.creator_id
), ),
@@ -354,6 +386,7 @@ class DefaultHomeRecommendationQueryRepository(
) as upload_rank ) as upload_rank
from content ac from content ac
where ac.release_date is not null where ac.release_date is not null
and (:includeAdultContents = true or ac.is_adult = false)
), ),
eligible_contents as ( eligible_contents as (
select ranked_uploads.*, select ranked_uploads.*,
@@ -381,7 +414,7 @@ class DefaultHomeRecommendationQueryRepository(
when ec.release_date >= :boost30Start then 20 when ec.release_date >= :boost30Start then 20
else 0 else 0
end as recency_score, end as recency_score,
rand() as random_tie_breaker ec.content_id as random_tie_breaker
from eligible_contents ec from eligible_contents ec
join member m on m.id = ec.creator_id join member m on m.id = ec.creator_id
join creator_debut cd on cd.creator_id = ec.creator_id join creator_debut cd on cd.creator_id = ec.creator_id
@@ -392,11 +425,14 @@ class DefaultHomeRecommendationQueryRepository(
and ec.release_date >= :boost30Start and ec.release_date >= :boost30Start
order by recency_score desc, random_tie_breaker asc order by recency_score desc, random_tie_breaker asc
limit :limit limit :limit
offset :offset
""".trimIndent() """.trimIndent()
val query = entityManager.createNativeQuery(sql) val query = entityManager.createNativeQuery(sql)
.setParameter("now", now) .setParameter("now", now)
.setParameter("limit", limit) .setParameter("limit", limit)
.setParameter("offset", offset)
.setParameter("includeAdultContents", includeAdultContents)
.setParameter( .setParameter(
"boost30Start", "boost30Start",
now.toLocalDate().minusDays(RecommendationScoreSpec.NEW_BOOST_30_DAY_LIMIT).atStartOfDay() now.toLocalDate().minusDays(RecommendationScoreSpec.NEW_BOOST_30_DAY_LIMIT).atStartOfDay()
@@ -941,6 +977,10 @@ class DefaultHomeRecommendationQueryRepository(
return if (includeAdultCommunities) null else creatorCommunity.isAdult.isFalse return if (includeAdultCommunities) null else creatorCommunity.isAdult.isFalse
} }
private fun includeAdultLiveCondition(includeAdultLives: Boolean): BooleanExpression? {
return if (includeAdultLives) null else liveRoom.isAdult.isFalse
}
private fun javax.persistence.Query.setRecommendationQueryParameters( private fun javax.persistence.Query.setRecommendationQueryParameters(
now: LocalDateTime, now: LocalDateTime,
limit: Int limit: Int

View File

@@ -10,10 +10,12 @@ import java.time.LocalDateTime
class RecommendationSnapshotPersistenceAdapter( class RecommendationSnapshotPersistenceAdapter(
private val repository: RecommendationSnapshotRepository private val repository: RecommendationSnapshotRepository
) : RecommendationSnapshotPort { ) : RecommendationSnapshotPort {
override fun findLatestSnapshots(sectionType: RecommendedSectionType): List<RecommendationSnapshotRecord> { override fun findLatestSnapshots(
val snapshotAt = repository.findTopBySectionTypeOrderBySnapshotAtDesc(sectionType)?.snapshotAt ?: return emptyList() sectionType: RecommendedSectionType,
return repository.findAllBySectionTypeAndSnapshotAtOrderByScoreDescRandomTieBreakerAsc(sectionType, snapshotAt) offset: Int,
.map { it.toRecord() } limit: Int
): List<RecommendationSnapshotRecord> {
return repository.findLatestSnapshots(sectionType.name, offset, limit).map { it.toRecord() }
} }
override fun replaceSnapshots( override fun replaceSnapshots(

View File

@@ -2,14 +2,30 @@ package kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence
import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedSectionType import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedSectionType
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import java.time.LocalDateTime import java.time.LocalDateTime
interface RecommendationSnapshotRepository : JpaRepository<RecommendationSnapshot, Long> { interface RecommendationSnapshotRepository : JpaRepository<RecommendationSnapshot, Long> {
fun findTopBySectionTypeOrderBySnapshotAtDesc(sectionType: RecommendedSectionType): RecommendationSnapshot? @Query(
value = """
fun findAllBySectionTypeAndSnapshotAtOrderByScoreDescRandomTieBreakerAsc( select *
sectionType: RecommendedSectionType, from recommendation_snapshot rs
snapshotAt: LocalDateTime where rs.section_type = :sectionType
and rs.snapshot_at = (
select max(latest.snapshot_at)
from recommendation_snapshot latest
where latest.section_type = :sectionType
)
order by rs.score desc, rs.random_tie_breaker asc
limit :limit offset :offset
""",
nativeQuery = true
)
fun findLatestSnapshots(
@Param("sectionType") sectionType: String,
@Param("offset") offset: Int,
@Param("limit") limit: Int
): List<RecommendationSnapshot> ): List<RecommendationSnapshot>
fun deleteBySectionTypeAndSnapshotAt(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime) fun deleteBySectionTypeAndSnapshotAt(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime)

View File

@@ -77,7 +77,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
} }
flushAndClear() flushAndClear()
val lives = repository.findLiveRecommendations(limit = 20) val lives = repository.findLiveRecommendations(limit = 20, includeAdultLives = false)
assertEquals(20, lives.size) assertEquals(20, lives.size)
assertEquals(baseAt.plusDays(1).plusMinutes(20), lives.first().beginDateTime) 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 }) 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 @Test
@DisplayName("홈 배너는 활성 배너를 orders 오름차순 최대 20개로 조회하고 동일 orders는 랜덤 tie-breaker를 적용한다") @DisplayName("홈 배너는 활성 배너를 orders 오름차순 최대 20개로 조회하고 동일 orders는 랜덤 tie-breaker를 적용한다")
fun shouldFindActiveHomeBannersWithOrderLimitAndRandomTieBreaker() { fun shouldFindActiveHomeBannersWithOrderLimitAndRandomTieBreaker() {
@@ -251,6 +269,39 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
assertEquals(community.id, byCreatorId[communityCreator.id]!!.targetId) 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 @Test
@DisplayName("AI 캐릭터 스냅샷은 AI 발화 수와 중복 없는 활성 사용자 수를 집계하고 팔로우 증가량은 제외한다") @DisplayName("AI 캐릭터 스냅샷은 AI 발화 수와 중복 없는 활성 사용자 수를 집계하고 팔로우 증가량은 제외한다")
fun shouldFindAiCharacterSnapshotsWithoutFollowIncrease() { fun shouldFindAiCharacterSnapshotsWithoutFollowIncrease() {
@@ -786,6 +837,53 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
assertEquals(expectedLowScore, creators.last().score, 0.0001) 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 @Test
@DisplayName("최근 데뷔 크리에이터 동점은 랜덤 tie-breaker 오름차순으로 정렬한다") @DisplayName("최근 데뷔 크리에이터 동점은 랜덤 tie-breaker 오름차순으로 정렬한다")
fun shouldOrderRecentDebutCreatorsByRandomTieBreakerWhenScoresTie() { fun shouldOrderRecentDebutCreatorsByRandomTieBreakerWhenScoresTie() {
@@ -853,6 +951,46 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
assertEquals(true, contents.zipWithNext().all { it.first.recencyScore >= it.second.recencyScore }) 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 @Test
@DisplayName("AI 캐릭터 상세는 활성 캐릭터의 이름 소개 전체 AI 발화 수와 연결된 원작명만 조회한다") @DisplayName("AI 캐릭터 상세는 활성 캐릭터의 이름 소개 전체 AI 발화 수와 연결된 원작명만 조회한다")
fun shouldFindAiCharacterRecommendationDetailsWithOriginalWorkTitleOnlyWhenExists() { fun shouldFindAiCharacterRecommendationDetailsWithOriginalWorkTitleOnlyWhenExists() {
@@ -1440,13 +1578,18 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
return banner 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( val room = LiveRoom(
title = "live-${creator.nickname}-$beginDateTime", title = "live-${creator.nickname}-$beginDateTime",
notice = "notice", notice = "notice",
beginDateTime = beginDateTime, beginDateTime = beginDateTime,
numberOfPeople = 0, numberOfPeople = 0,
isAdult = false isAdult = isAdult
) )
room.member = creator room.member = creator
room.channelName = channelName room.channelName = channelName

View File

@@ -60,6 +60,23 @@ class RecommendationSnapshotPersistenceAdapterTest @Autowired constructor(
assertEquals(listOf(latestSnapshotAt, latestSnapshotAt, latestSnapshotAt), latestSnapshots.map { it.snapshotAt }) 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 @Test
fun shouldReplaceSnapshotsByDeletingSameSectionSnapshotAtOnly() { fun shouldReplaceSnapshotsByDeletingSameSectionSnapshotAtOnly() {
val oldSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59) val oldSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59)