feat(recommend): 홈 추천 저장소 페이징 조건을 적용한다
This commit is contained in:
@@ -43,7 +43,11 @@ class DefaultHomeRecommendationQueryRepository(
|
||||
private val queryFactory: JPAQueryFactory,
|
||||
private val entityManager: EntityManager
|
||||
) : HomeRecommendationQueryRepository {
|
||||
override fun findLiveRecommendations(limit: Int): List<HomeLiveRecommendationRecord> {
|
||||
override fun findLiveRecommendations(
|
||||
offset: Int,
|
||||
limit: Int,
|
||||
includeAdultLives: Boolean
|
||||
): List<HomeLiveRecommendationRecord> {
|
||||
return queryFactory
|
||||
.select(
|
||||
Projections.constructor(
|
||||
@@ -64,9 +68,11 @@ class DefaultHomeRecommendationQueryRepository(
|
||||
liveRoom.isActive.isTrue,
|
||||
liveRoom.channelName.isNotNull,
|
||||
liveRoom.channelName.isNotEmpty,
|
||||
includeAdultLiveCondition(includeAdultLives),
|
||||
member.isActive.isTrue
|
||||
)
|
||||
.orderBy(liveRoom.beginDateTime.desc(), liveRoom.id.desc())
|
||||
.offset(offset.toLong())
|
||||
.limit(limit.toLong())
|
||||
.fetch()
|
||||
}
|
||||
@@ -106,7 +112,10 @@ class DefaultHomeRecommendationQueryRepository(
|
||||
.fetch()
|
||||
}
|
||||
|
||||
override fun findRecentlyActiveCreators(limit: Int): List<RecentlyActiveCreatorRecord> {
|
||||
override fun findRecentlyActiveCreators(
|
||||
limit: Int,
|
||||
includeAdultActivities: Boolean
|
||||
): List<RecentlyActiveCreatorRecord> {
|
||||
val sql = """
|
||||
select ranked.creator_id,
|
||||
ranked.creator_nickname,
|
||||
@@ -133,6 +142,7 @@ class DefaultHomeRecommendationQueryRepository(
|
||||
where lr.is_active = true
|
||||
and lr.channel_name is not null
|
||||
and lr.channel_name <> ''
|
||||
and (:includeAdultActivities = true or lr.is_adult = false)
|
||||
and m.is_active = true
|
||||
union all
|
||||
select m.id as creator_id,
|
||||
@@ -147,6 +157,7 @@ class DefaultHomeRecommendationQueryRepository(
|
||||
join content_theme act on act.id = ac.theme_id
|
||||
where ac.is_active = true
|
||||
and ac.release_date is not null
|
||||
and (:includeAdultActivities = true or ac.is_adult = false)
|
||||
and m.is_active = true
|
||||
union all
|
||||
select m.id as creator_id,
|
||||
@@ -159,6 +170,7 @@ class DefaultHomeRecommendationQueryRepository(
|
||||
from creator_community cc
|
||||
join member m on m.id = cc.member_id
|
||||
where cc.is_active = true
|
||||
and (:includeAdultActivities = true or cc.is_adult = false)
|
||||
and m.is_active = true
|
||||
) activities
|
||||
) ranked
|
||||
@@ -169,6 +181,7 @@ class DefaultHomeRecommendationQueryRepository(
|
||||
|
||||
val query = entityManager.createNativeQuery(sql)
|
||||
.setParameter("liveReplayTheme", LIVE_REPLAY_THEME)
|
||||
.setParameter("includeAdultActivities", includeAdultActivities)
|
||||
.setParameter("limit", limit)
|
||||
|
||||
@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 = """
|
||||
with creator_debut as (
|
||||
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
|
||||
and ac.release_date is not null
|
||||
and ac.release_date <= :now
|
||||
and (:includeAdultContents = true or ac.is_adult = false)
|
||||
union all
|
||||
select lr.member_id as creator_id, lr.begin_date_time as debut_at
|
||||
from live_room lr
|
||||
@@ -203,6 +222,7 @@ class DefaultHomeRecommendationQueryRepository(
|
||||
and lr.channel_name is not null
|
||||
and lr.channel_name <> ''
|
||||
and lr.begin_date_time <= :now
|
||||
and (:includeAdultContents = true or lr.is_adult = false)
|
||||
) debut_events
|
||||
group by debut_events.creator_id
|
||||
),
|
||||
@@ -223,6 +243,7 @@ class DefaultHomeRecommendationQueryRepository(
|
||||
and ac.release_date is not null
|
||||
and ac.release_date >= :window30Start
|
||||
and ac.release_date <= :now
|
||||
and (:includeAdultContents = true or ac.is_adult = false)
|
||||
union all
|
||||
select lr.member_id as creator_id, lr.id as activity_id
|
||||
from live_room lr
|
||||
@@ -231,6 +252,7 @@ class DefaultHomeRecommendationQueryRepository(
|
||||
and lr.channel_name <> ''
|
||||
and lr.begin_date_time >= :window30Start
|
||||
and lr.begin_date_time <= :now
|
||||
and (:includeAdultContents = true or lr.is_adult = false)
|
||||
) activity
|
||||
group by activity.creator_id
|
||||
),
|
||||
@@ -290,7 +312,7 @@ class DefaultHomeRecommendationQueryRepository(
|
||||
when cd.debut_at >= :boost30Start then ${RecommendationScoreSpec.NEW_BOOST_30_DAYS}
|
||||
else ${RecommendationScoreSpec.DEFAULT_NEW_BOOST}
|
||||
end) as score,
|
||||
rand() as random_tie_breaker
|
||||
m.id as random_tie_breaker
|
||||
from member m
|
||||
join creator_debut cd on cd.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
|
||||
order by score desc, random_tie_breaker asc
|
||||
limit :limit
|
||||
offset :offset
|
||||
""".trimIndent()
|
||||
|
||||
val query = entityManager.createNativeQuery(sql)
|
||||
.setRecommendationQueryParameters(now, limit)
|
||||
.setParameter("offset", offset)
|
||||
.setParameter("includeAdultContents", includeAdultContents)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
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 = """
|
||||
with creator_debut as (
|
||||
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
|
||||
and ac.release_date is not null
|
||||
and ac.release_date <= :now
|
||||
and (:includeAdultContents = true or ac.is_adult = false)
|
||||
union all
|
||||
select lr.member_id as creator_id, lr.begin_date_time as debut_at
|
||||
from live_room lr
|
||||
@@ -338,6 +369,7 @@ class DefaultHomeRecommendationQueryRepository(
|
||||
and lr.channel_name is not null
|
||||
and lr.channel_name <> ''
|
||||
and lr.begin_date_time <= :now
|
||||
and (:includeAdultContents = true or lr.is_adult = false)
|
||||
) debut_events
|
||||
group by debut_events.creator_id
|
||||
),
|
||||
@@ -354,6 +386,7 @@ class DefaultHomeRecommendationQueryRepository(
|
||||
) as upload_rank
|
||||
from content ac
|
||||
where ac.release_date is not null
|
||||
and (:includeAdultContents = true or ac.is_adult = false)
|
||||
),
|
||||
eligible_contents as (
|
||||
select ranked_uploads.*,
|
||||
@@ -381,7 +414,7 @@ class DefaultHomeRecommendationQueryRepository(
|
||||
when ec.release_date >= :boost30Start then 20
|
||||
else 0
|
||||
end as recency_score,
|
||||
rand() as random_tie_breaker
|
||||
ec.content_id as random_tie_breaker
|
||||
from eligible_contents ec
|
||||
join member m on m.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
|
||||
order by recency_score desc, random_tie_breaker asc
|
||||
limit :limit
|
||||
offset :offset
|
||||
""".trimIndent()
|
||||
|
||||
val query = entityManager.createNativeQuery(sql)
|
||||
.setParameter("now", now)
|
||||
.setParameter("limit", limit)
|
||||
.setParameter("offset", offset)
|
||||
.setParameter("includeAdultContents", includeAdultContents)
|
||||
.setParameter(
|
||||
"boost30Start",
|
||||
now.toLocalDate().minusDays(RecommendationScoreSpec.NEW_BOOST_30_DAY_LIMIT).atStartOfDay()
|
||||
@@ -941,6 +977,10 @@ class DefaultHomeRecommendationQueryRepository(
|
||||
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(
|
||||
now: LocalDateTime,
|
||||
limit: Int
|
||||
|
||||
@@ -10,10 +10,12 @@ import java.time.LocalDateTime
|
||||
class RecommendationSnapshotPersistenceAdapter(
|
||||
private val repository: RecommendationSnapshotRepository
|
||||
) : RecommendationSnapshotPort {
|
||||
override fun findLatestSnapshots(sectionType: RecommendedSectionType): List<RecommendationSnapshotRecord> {
|
||||
val snapshotAt = repository.findTopBySectionTypeOrderBySnapshotAtDesc(sectionType)?.snapshotAt ?: return emptyList()
|
||||
return repository.findAllBySectionTypeAndSnapshotAtOrderByScoreDescRandomTieBreakerAsc(sectionType, snapshotAt)
|
||||
.map { it.toRecord() }
|
||||
override fun findLatestSnapshots(
|
||||
sectionType: RecommendedSectionType,
|
||||
offset: Int,
|
||||
limit: Int
|
||||
): List<RecommendationSnapshotRecord> {
|
||||
return repository.findLatestSnapshots(sectionType.name, offset, limit).map { it.toRecord() }
|
||||
}
|
||||
|
||||
override fun replaceSnapshots(
|
||||
|
||||
@@ -2,14 +2,30 @@ package kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedSectionType
|
||||
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
|
||||
|
||||
interface RecommendationSnapshotRepository : JpaRepository<RecommendationSnapshot, Long> {
|
||||
fun findTopBySectionTypeOrderBySnapshotAtDesc(sectionType: RecommendedSectionType): RecommendationSnapshot?
|
||||
|
||||
fun findAllBySectionTypeAndSnapshotAtOrderByScoreDescRandomTieBreakerAsc(
|
||||
sectionType: RecommendedSectionType,
|
||||
snapshotAt: LocalDateTime
|
||||
@Query(
|
||||
value = """
|
||||
select *
|
||||
from recommendation_snapshot rs
|
||||
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>
|
||||
|
||||
fun deleteBySectionTypeAndSnapshotAt(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user