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 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

View File

@@ -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(

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 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)