feat(recommend): 홈 추천 전체보기 페이징 조회를 추가한다

This commit is contained in:
2026-06-01 13:54:40 +09:00
parent f77bd7b8e2
commit 1f3a38a404
5 changed files with 111 additions and 29 deletions

View File

@@ -13,7 +13,6 @@ import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeRecommendationQueryPor
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecentDebutCreatorRecord import kr.co.vividnext.sodalive.v2.recommend.port.out.RecentDebutCreatorRecord
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecentlyActiveCreatorRecord import kr.co.vividnext.sodalive.v2.recommend.port.out.RecentlyActiveCreatorRecord
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotPort import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotPort
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotRecord
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime import java.time.LocalDateTime
@@ -24,36 +23,48 @@ class HomeRecommendationQueryService(
private val queryPort: HomeRecommendationQueryPort, private val queryPort: HomeRecommendationQueryPort,
private val snapshotPort: RecommendationSnapshotPort private val snapshotPort: RecommendationSnapshotPort
) { ) {
fun findLiveRecommendations(limit: Int = DEFAULT_LIVE_LIMIT): List<HomeLiveRecommendationRecord> { fun findLiveRecommendations(
return queryPort.findLiveRecommendations(limit) offset: Int = 0,
limit: Int = DEFAULT_LIVE_LIMIT,
includeAdultLives: Boolean = false
): List<HomeLiveRecommendationRecord> {
return queryPort.findLiveRecommendations(offset, limit, includeAdultLives)
} }
fun findHomeBanners(limit: Int = DEFAULT_BANNER_LIMIT): List<HomeBannerRecommendationRecord> { fun findHomeBanners(limit: Int = DEFAULT_BANNER_LIMIT): List<HomeBannerRecommendationRecord> {
return queryPort.findHomeBanners(limit) return queryPort.findHomeBanners(limit)
} }
fun findRecentlyActiveCreators(limit: Int = DEFAULT_ACTIVE_CREATOR_LIMIT): List<RecentlyActiveCreatorRecord> { fun findRecentlyActiveCreators(
return queryPort.findRecentlyActiveCreators(limit) limit: Int = DEFAULT_ACTIVE_CREATOR_LIMIT,
includeAdultActivities: Boolean = false
): List<RecentlyActiveCreatorRecord> {
return queryPort.findRecentlyActiveCreators(limit, includeAdultActivities)
} }
fun findRecentDebutCreators( fun findRecentDebutCreators(
now: LocalDateTime, now: LocalDateTime,
limit: Int = DEFAULT_RECENT_DEBUT_CREATOR_LIMIT offset: Int = 0,
limit: Int = DEFAULT_RECENT_DEBUT_CREATOR_LIMIT,
includeAdultContents: Boolean = false
): List<RecentDebutCreatorRecord> { ): List<RecentDebutCreatorRecord> {
return queryPort.findRecentDebutCreators(now, limit) return queryPort.findRecentDebutCreators(now, offset, limit, includeAdultContents)
} }
fun findFirstAudioContents( fun findFirstAudioContents(
now: LocalDateTime, now: LocalDateTime,
limit: Int = DEFAULT_FIRST_AUDIO_CONTENT_LIMIT offset: Int = 0,
limit: Int = DEFAULT_FIRST_AUDIO_CONTENT_LIMIT,
includeAdultContents: Boolean = false
): List<HomeFirstAudioContentRecord> { ): List<HomeFirstAudioContentRecord> {
return queryPort.findFirstAudioContents(now, limit) return queryPort.findFirstAudioContents(now, offset, limit, includeAdultContents)
} }
fun findAiCharacterRecommendations( fun findAiCharacterRecommendations(
offset: Int = 0,
limit: Int = DEFAULT_AI_CHARACTER_LIMIT limit: Int = DEFAULT_AI_CHARACTER_LIMIT
): List<HomeAiCharacterRecommendationRecord> { ): List<HomeAiCharacterRecommendationRecord> {
val snapshots = latestSnapshots(RecommendedSectionType.AI_CHARACTER, limit) val snapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.AI_CHARACTER, offset, limit)
val detailsById = queryPort.findAiCharacterRecommendationDetails(snapshots.map { it.targetId }) val detailsById = queryPort.findAiCharacterRecommendationDetails(snapshots.map { it.targetId })
.associateBy { it.characterId } .associateBy { it.characterId }
@@ -63,7 +74,7 @@ class HomeRecommendationQueryService(
fun findCheerCreatorRecommendations( fun findCheerCreatorRecommendations(
limit: Int = DEFAULT_CHEER_CREATOR_LIMIT limit: Int = DEFAULT_CHEER_CREATOR_LIMIT
): List<HomeCheerCreatorRecommendationRecord> { ): List<HomeCheerCreatorRecommendationRecord> {
val snapshots = latestSnapshots(RecommendedSectionType.CHEER_CREATOR, limit) val snapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR).take(limit)
val detailsById = queryPort.findCheerCreatorRecommendationDetails(snapshots.map { it.targetId }) val detailsById = queryPort.findCheerCreatorRecommendationDetails(snapshots.map { it.targetId })
.associateBy { it.creatorId } .associateBy { it.creatorId }
@@ -74,7 +85,8 @@ class HomeRecommendationQueryService(
limit: Int = DEFAULT_POPULAR_COMMUNITY_LIMIT, limit: Int = DEFAULT_POPULAR_COMMUNITY_LIMIT,
includeAdultCommunities: Boolean = false includeAdultCommunities: Boolean = false
): List<HomePopularCommunityRecommendationRecord> { ): List<HomePopularCommunityRecommendationRecord> {
val snapshots = latestSnapshots(RecommendedSectionType.POPULAR_COMMUNITY, POPULAR_COMMUNITY_CANDIDATE_LIMIT) val snapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.POPULAR_COMMUNITY)
.take(POPULAR_COMMUNITY_CANDIDATE_LIMIT)
val detailsById = queryPort.findPopularCommunityRecommendationDetails( val detailsById = queryPort.findPopularCommunityRecommendationDetails(
snapshots.map { it.targetId }, snapshots.map { it.targetId },
includeAdultCommunities includeAdultCommunities
@@ -112,10 +124,6 @@ class HomeRecommendationQueryService(
} }
} }
private fun latestSnapshots(sectionType: RecommendedSectionType, limit: Int): List<RecommendationSnapshotRecord> {
return snapshotPort.findLatestSnapshots(sectionType).take(limit)
}
companion object { companion object {
private const val DEFAULT_LIVE_LIMIT = 20 private const val DEFAULT_LIVE_LIMIT = 20
private const val DEFAULT_BANNER_LIMIT = 20 private const val DEFAULT_BANNER_LIMIT = 20

View File

@@ -4,15 +4,29 @@ import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedActivityType
import java.time.LocalDateTime import java.time.LocalDateTime
interface HomeRecommendationQueryPort { interface HomeRecommendationQueryPort {
fun findLiveRecommendations(limit: Int): List<HomeLiveRecommendationRecord> fun findLiveRecommendations(
offset: Int = 0,
limit: Int,
includeAdultLives: Boolean = false
): List<HomeLiveRecommendationRecord>
fun findHomeBanners(limit: Int): List<HomeBannerRecommendationRecord> fun findHomeBanners(limit: Int): List<HomeBannerRecommendationRecord>
fun findRecentlyActiveCreators(limit: Int): List<RecentlyActiveCreatorRecord> fun findRecentlyActiveCreators(limit: Int, includeAdultActivities: Boolean = false): List<RecentlyActiveCreatorRecord>
fun findRecentDebutCreators(now: LocalDateTime, limit: Int): List<RecentDebutCreatorRecord> fun findRecentDebutCreators(
now: LocalDateTime,
offset: Int = 0,
limit: Int,
includeAdultContents: Boolean = false
): List<RecentDebutCreatorRecord>
fun findFirstAudioContents(now: LocalDateTime, limit: Int): List<HomeFirstAudioContentRecord> fun findFirstAudioContents(
now: LocalDateTime,
offset: Int = 0,
limit: Int,
includeAdultContents: Boolean = false
): List<HomeFirstAudioContentRecord>
fun findAiCharacterSnapshots( fun findAiCharacterSnapshots(
windowStart: LocalDateTime, windowStart: LocalDateTime,

View File

@@ -4,7 +4,11 @@ import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedSectionType
import java.time.LocalDateTime import java.time.LocalDateTime
interface RecommendationSnapshotPort { interface RecommendationSnapshotPort {
fun findLatestSnapshots(sectionType: RecommendedSectionType): List<RecommendationSnapshotRecord> fun findLatestSnapshots(
sectionType: RecommendedSectionType,
offset: Int = 0,
limit: Int = Int.MAX_VALUE
): List<RecommendationSnapshotRecord>
fun replaceSnapshots( fun replaceSnapshots(
sectionType: RecommendedSectionType, sectionType: RecommendedSectionType,

View File

@@ -88,6 +88,17 @@ class HomeRecommendationQueryServiceTest {
val creators = service.findRecentlyActiveCreators() val creators = service.findRecentlyActiveCreators()
assertEquals(10, port.activeCreatorLimit) assertEquals(10, port.activeCreatorLimit)
assertEquals(false, port.activeCreatorIncludeAdultActivities)
assertEquals(port.activeCreators, creators)
}
@Test
@DisplayName("최근 활동 크리에이터는 성인 활동 노출 여부를 조회 포트에 위임한다")
fun shouldFindRecentlyActiveCreatorsWithAdultVisibilityPolicy() {
val creators = service.findRecentlyActiveCreators(limit = 8, includeAdultActivities = true)
assertEquals(8, port.activeCreatorLimit)
assertEquals(true, port.activeCreatorIncludeAdultActivities)
assertEquals(port.activeCreators, creators) assertEquals(port.activeCreators, creators)
} }
@@ -389,12 +400,19 @@ class HomeRecommendationQueryServiceTest {
private class FakeHomeRecommendationQueryPort : HomeRecommendationQueryPort { private class FakeHomeRecommendationQueryPort : HomeRecommendationQueryPort {
var liveLimit: Int? = null var liveLimit: Int? = null
var liveOffset: Int? = null
var liveIncludeAdultLives: Boolean? = null
var bannerLimit: Int? = null var bannerLimit: Int? = null
var activeCreatorLimit: Int? = null var activeCreatorLimit: Int? = null
var activeCreatorIncludeAdultActivities: Boolean? = null
var recentDebutNow: LocalDateTime? = null var recentDebutNow: LocalDateTime? = null
var recentDebutLimit: Int? = null var recentDebutLimit: Int? = null
var recentDebutOffset: Int? = null
var recentDebutIncludeAdultContents: Boolean? = null
var firstAudioNow: LocalDateTime? = null var firstAudioNow: LocalDateTime? = null
var firstAudioLimit: Int? = null var firstAudioLimit: Int? = null
var firstAudioOffset: Int? = null
var firstAudioIncludeAdultContents: Boolean? = null
var aiCharacterDetailIds: List<Long> = emptyList() var aiCharacterDetailIds: List<Long> = emptyList()
var cheerCreatorDetailIds: List<Long> = emptyList() var cheerCreatorDetailIds: List<Long> = emptyList()
var popularCommunityDetailIds: List<Long> = emptyList() var popularCommunityDetailIds: List<Long> = emptyList()
@@ -466,8 +484,14 @@ class HomeRecommendationQueryServiceTest {
var popularCommunityDetails: List<HomePopularCommunityRecommendationRecord> = emptyList() var popularCommunityDetails: List<HomePopularCommunityRecommendationRecord> = emptyList()
var genreCreatorRecommendations: List<HomeGenreCreatorRecommendationGroup> = emptyList() var genreCreatorRecommendations: List<HomeGenreCreatorRecommendationGroup> = emptyList()
override fun findLiveRecommendations(limit: Int): List<HomeLiveRecommendationRecord> { override fun findLiveRecommendations(
offset: Int,
limit: Int,
includeAdultLives: Boolean
): List<HomeLiveRecommendationRecord> {
liveOffset = offset
liveLimit = limit liveLimit = limit
liveIncludeAdultLives = includeAdultLives
return liveRecommendations return liveRecommendations
} }
@@ -476,20 +500,38 @@ class HomeRecommendationQueryServiceTest {
return banners return banners
} }
override fun findRecentlyActiveCreators(limit: Int): List<RecentlyActiveCreatorRecord> { override fun findRecentlyActiveCreators(
limit: Int,
includeAdultActivities: Boolean
): List<RecentlyActiveCreatorRecord> {
activeCreatorLimit = limit activeCreatorLimit = limit
activeCreatorIncludeAdultActivities = includeAdultActivities
return activeCreators return activeCreators
} }
override fun findRecentDebutCreators(now: LocalDateTime, limit: Int): List<RecentDebutCreatorRecord> { override fun findRecentDebutCreators(
now: LocalDateTime,
offset: Int,
limit: Int,
includeAdultContents: Boolean
): List<RecentDebutCreatorRecord> {
recentDebutNow = now recentDebutNow = now
recentDebutOffset = offset
recentDebutLimit = limit recentDebutLimit = limit
recentDebutIncludeAdultContents = includeAdultContents
return recentDebutCreators return recentDebutCreators
} }
override fun findFirstAudioContents(now: LocalDateTime, limit: Int): List<HomeFirstAudioContentRecord> { override fun findFirstAudioContents(
now: LocalDateTime,
offset: Int,
limit: Int,
includeAdultContents: Boolean
): List<HomeFirstAudioContentRecord> {
firstAudioNow = now firstAudioNow = now
firstAudioOffset = offset
firstAudioLimit = limit firstAudioLimit = limit
firstAudioIncludeAdultContents = includeAdultContents
return firstAudioContents return firstAudioContents
} }
@@ -548,14 +590,21 @@ class HomeRecommendationQueryServiceTest {
private class FakeHomeRecommendationSnapshotPort : RecommendationSnapshotPort { private class FakeHomeRecommendationSnapshotPort : RecommendationSnapshotPort {
private val snapshots = mutableListOf<RecommendationSnapshotRecord>() private val snapshots = mutableListOf<RecommendationSnapshotRecord>()
override fun findLatestSnapshots(sectionType: RecommendedSectionType): List<RecommendationSnapshotRecord> { override fun findLatestSnapshots(
sectionType: RecommendedSectionType,
offset: Int,
limit: Int
): List<RecommendationSnapshotRecord> {
val latestSnapshotAt = snapshots val latestSnapshotAt = snapshots
.filter { it.sectionType == sectionType } .filter { it.sectionType == sectionType }
.maxOfOrNull { it.snapshotAt } .maxOfOrNull { it.snapshotAt }
return snapshots val all = snapshots
.filter { it.sectionType == sectionType && it.snapshotAt == latestSnapshotAt } .filter { it.sectionType == sectionType && it.snapshotAt == latestSnapshotAt }
.sortedWith(compareByDescending<RecommendationSnapshotRecord> { it.score }.thenBy { it.randomTieBreaker }) .sortedWith(compareByDescending<RecommendationSnapshotRecord> { it.score }.thenBy { it.randomTieBreaker })
if (offset == 0 && limit == Int.MAX_VALUE) return all
return all.drop(offset).take(limit)
} }
override fun replaceSnapshots( override fun replaceSnapshots(

View File

@@ -176,14 +176,21 @@ class RecommendationSnapshotRefreshServiceTest {
private class FakeRecommendationSnapshotPort : RecommendationSnapshotPort { private class FakeRecommendationSnapshotPort : RecommendationSnapshotPort {
private val snapshots = mutableListOf<RecommendationSnapshotRecord>() private val snapshots = mutableListOf<RecommendationSnapshotRecord>()
override fun findLatestSnapshots(sectionType: RecommendedSectionType): List<RecommendationSnapshotRecord> { override fun findLatestSnapshots(
sectionType: RecommendedSectionType,
offset: Int,
limit: Int
): List<RecommendationSnapshotRecord> {
val latestSnapshotAt = snapshots val latestSnapshotAt = snapshots
.filter { it.sectionType == sectionType } .filter { it.sectionType == sectionType }
.maxOfOrNull { it.snapshotAt } .maxOfOrNull { it.snapshotAt }
return snapshots val all = snapshots
.filter { it.sectionType == sectionType && it.snapshotAt == latestSnapshotAt } .filter { it.sectionType == sectionType && it.snapshotAt == latestSnapshotAt }
.sortedWith(compareByDescending<RecommendationSnapshotRecord> { it.score }.thenBy { it.randomTieBreaker }) .sortedWith(compareByDescending<RecommendationSnapshotRecord> { it.score }.thenBy { it.randomTieBreaker })
if (offset == 0 && limit == Int.MAX_VALUE) return all
return all.drop(offset).take(limit)
} }
override fun replaceSnapshots( override fun replaceSnapshots(