From 3df5614b7a4a382a73c542d56362e225be00cbd7 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 1 Jun 2026 13:55:17 +0900 Subject: [PATCH] =?UTF-8?q?feat(recommend):=20=ED=99=88=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=20=EC=A0=80=EC=9E=A5=EC=86=8C=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=95=20=EC=A1=B0=EA=B1=B4=EC=9D=84=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...efaultHomeRecommendationQueryRepository.kt | 52 +++++- ...ecommendationSnapshotPersistenceAdapter.kt | 10 +- .../RecommendationSnapshotRepository.kt | 26 ++- ...ltHomeRecommendationQueryRepositoryTest.kt | 149 +++++++++++++++++- ...mendationSnapshotPersistenceAdapterTest.kt | 17 ++ 5 files changed, 236 insertions(+), 18 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt index b74d2ad3..36ac22d5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt @@ -43,7 +43,11 @@ class DefaultHomeRecommendationQueryRepository( private val queryFactory: JPAQueryFactory, private val entityManager: EntityManager ) : HomeRecommendationQueryRepository { - override fun findLiveRecommendations(limit: Int): List { + override fun findLiveRecommendations( + offset: Int, + limit: Int, + includeAdultLives: Boolean + ): List { 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 { + override fun findRecentlyActiveCreators( + limit: Int, + includeAdultActivities: Boolean + ): List { 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 { + override fun findRecentDebutCreators( + now: LocalDateTime, + offset: Int, + limit: Int, + includeAdultContents: Boolean + ): List { 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> @@ -321,7 +346,12 @@ class DefaultHomeRecommendationQueryRepository( } } - override fun findFirstAudioContents(now: LocalDateTime, limit: Int): List { + override fun findFirstAudioContents( + now: LocalDateTime, + offset: Int, + limit: Int, + includeAdultContents: Boolean + ): List { 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 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/RecommendationSnapshotPersistenceAdapter.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/RecommendationSnapshotPersistenceAdapter.kt index a469fcff..e58b7aaf 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/RecommendationSnapshotPersistenceAdapter.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/RecommendationSnapshotPersistenceAdapter.kt @@ -10,10 +10,12 @@ import java.time.LocalDateTime class RecommendationSnapshotPersistenceAdapter( private val repository: RecommendationSnapshotRepository ) : RecommendationSnapshotPort { - override fun findLatestSnapshots(sectionType: RecommendedSectionType): List { - 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 { + return repository.findLatestSnapshots(sectionType.name, offset, limit).map { it.toRecord() } } override fun replaceSnapshots( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/RecommendationSnapshotRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/RecommendationSnapshotRepository.kt index 361058fb..60038648 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/RecommendationSnapshotRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/RecommendationSnapshotRepository.kt @@ -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 { - 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 fun deleteBySectionTypeAndSnapshotAt(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt index b5d09448..33a1ac82 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt @@ -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 diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/RecommendationSnapshotPersistenceAdapterTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/RecommendationSnapshotPersistenceAdapterTest.kt index a84d470a..29ef71b4 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/RecommendationSnapshotPersistenceAdapterTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/RecommendationSnapshotPersistenceAdapterTest.kt @@ -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)