feat(recommend): 홈 추천 조회 서비스를 추가한다
This commit is contained in:
@@ -1,8 +1,91 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.recommend.application
|
package kr.co.vividnext.sodalive.v2.recommend.application
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedActivityType
|
import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedActivityType
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedSectionType
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeAiCharacterRecommendationRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeBannerRecommendationRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeCheerCreatorRecommendationRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeFirstAudioContentRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeLiveRecommendationRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomePopularCommunityRecommendationRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeRecommendationQueryPort
|
||||||
|
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.RecommendationSnapshotPort
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotRecord
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
class HomeRecommendationQueryService(
|
||||||
|
private val queryPort: HomeRecommendationQueryPort,
|
||||||
|
private val snapshotPort: RecommendationSnapshotPort
|
||||||
|
) {
|
||||||
|
fun findLiveRecommendations(limit: Int = DEFAULT_LIVE_LIMIT): List<HomeLiveRecommendationRecord> {
|
||||||
|
return queryPort.findLiveRecommendations(limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun findHomeBanners(limit: Int = DEFAULT_BANNER_LIMIT): List<HomeBannerRecommendationRecord> {
|
||||||
|
return queryPort.findHomeBanners(limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun findRecentlyActiveCreators(limit: Int = DEFAULT_ACTIVE_CREATOR_LIMIT): List<RecentlyActiveCreatorRecord> {
|
||||||
|
return queryPort.findRecentlyActiveCreators(limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun findRecentDebutCreators(
|
||||||
|
now: LocalDateTime,
|
||||||
|
limit: Int = DEFAULT_RECENT_DEBUT_CREATOR_LIMIT
|
||||||
|
): List<RecentDebutCreatorRecord> {
|
||||||
|
return queryPort.findRecentDebutCreators(now, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun findFirstAudioContents(
|
||||||
|
now: LocalDateTime,
|
||||||
|
limit: Int = DEFAULT_FIRST_AUDIO_CONTENT_LIMIT
|
||||||
|
): List<HomeFirstAudioContentRecord> {
|
||||||
|
return queryPort.findFirstAudioContents(now, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun findAiCharacterRecommendations(
|
||||||
|
limit: Int = DEFAULT_AI_CHARACTER_LIMIT
|
||||||
|
): List<HomeAiCharacterRecommendationRecord> {
|
||||||
|
val snapshots = latestSnapshots(RecommendedSectionType.AI_CHARACTER, limit)
|
||||||
|
val detailsById = queryPort.findAiCharacterRecommendationDetails(snapshots.map { it.targetId })
|
||||||
|
.associateBy { it.characterId }
|
||||||
|
|
||||||
|
return snapshots.mapNotNull { detailsById[it.targetId] }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun findCheerCreatorRecommendations(
|
||||||
|
limit: Int = DEFAULT_CHEER_CREATOR_LIMIT
|
||||||
|
): List<HomeCheerCreatorRecommendationRecord> {
|
||||||
|
val snapshots = latestSnapshots(RecommendedSectionType.CHEER_CREATOR, limit)
|
||||||
|
val detailsById = queryPort.findCheerCreatorRecommendationDetails(snapshots.map { it.targetId })
|
||||||
|
.associateBy { it.creatorId }
|
||||||
|
|
||||||
|
return snapshots.mapNotNull { detailsById[it.targetId] }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun findPopularCommunityRecommendations(
|
||||||
|
limit: Int = DEFAULT_POPULAR_COMMUNITY_LIMIT,
|
||||||
|
includeAdultCommunities: Boolean = false
|
||||||
|
): List<HomePopularCommunityRecommendationRecord> {
|
||||||
|
val snapshots = latestSnapshots(RecommendedSectionType.POPULAR_COMMUNITY, POPULAR_COMMUNITY_CANDIDATE_LIMIT)
|
||||||
|
val detailsById = queryPort.findPopularCommunityRecommendationDetails(
|
||||||
|
snapshots.map { it.targetId },
|
||||||
|
includeAdultCommunities
|
||||||
|
)
|
||||||
|
.associateBy { it.communityId }
|
||||||
|
val selectedCreatorIds = mutableSetOf<Long>()
|
||||||
|
|
||||||
|
return snapshots.mapNotNull { snapshot ->
|
||||||
|
detailsById[snapshot.targetId]?.takeIf { selectedCreatorIds.add(it.creatorId) }
|
||||||
|
}.take(limit)
|
||||||
|
}
|
||||||
|
|
||||||
class HomeRecommendationQueryService {
|
|
||||||
fun resolveAudioContentActivityType(theme: String): RecommendedActivityType {
|
fun resolveAudioContentActivityType(theme: String): RecommendedActivityType {
|
||||||
return if (theme == LIVE_REPLAY_THEME) {
|
return if (theme == LIVE_REPLAY_THEME) {
|
||||||
RecommendedActivityType.LIVE_REPLAY
|
RecommendedActivityType.LIVE_REPLAY
|
||||||
@@ -11,7 +94,20 @@ 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_BANNER_LIMIT = 20
|
||||||
|
private const val DEFAULT_ACTIVE_CREATOR_LIMIT = 10
|
||||||
|
private const val DEFAULT_RECENT_DEBUT_CREATOR_LIMIT = 10
|
||||||
|
private const val DEFAULT_FIRST_AUDIO_CONTENT_LIMIT = 10
|
||||||
|
private const val DEFAULT_AI_CHARACTER_LIMIT = 10
|
||||||
|
private const val DEFAULT_CHEER_CREATOR_LIMIT = 8
|
||||||
|
private const val DEFAULT_POPULAR_COMMUNITY_LIMIT = 10
|
||||||
|
private const val POPULAR_COMMUNITY_CANDIDATE_LIMIT = 20
|
||||||
private const val LIVE_REPLAY_THEME = "다시듣기"
|
private const val LIVE_REPLAY_THEME = "다시듣기"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.recommend.port.out
|
package kr.co.vividnext.sodalive.v2.recommend.port.out
|
||||||
|
|
||||||
|
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 findHomeBanners(limit: Int): List<HomeBannerRecommendationRecord>
|
||||||
|
|
||||||
|
fun findRecentlyActiveCreators(limit: Int): List<RecentlyActiveCreatorRecord>
|
||||||
|
|
||||||
|
fun findRecentDebutCreators(now: LocalDateTime, limit: Int): List<RecentDebutCreatorRecord>
|
||||||
|
|
||||||
|
fun findFirstAudioContents(now: LocalDateTime, limit: Int): List<HomeFirstAudioContentRecord>
|
||||||
|
|
||||||
fun findAiCharacterSnapshots(
|
fun findAiCharacterSnapshots(
|
||||||
windowStart: LocalDateTime,
|
windowStart: LocalDateTime,
|
||||||
snapshotAt: LocalDateTime,
|
snapshotAt: LocalDateTime,
|
||||||
@@ -20,4 +31,91 @@ interface HomeRecommendationQueryPort {
|
|||||||
snapshotAt: LocalDateTime,
|
snapshotAt: LocalDateTime,
|
||||||
limit: Int
|
limit: Int
|
||||||
): List<RecommendationSnapshotRecord>
|
): List<RecommendationSnapshotRecord>
|
||||||
|
|
||||||
|
fun findAiCharacterRecommendationDetails(characterIds: List<Long>): List<HomeAiCharacterRecommendationRecord>
|
||||||
|
|
||||||
|
fun findCheerCreatorRecommendationDetails(creatorIds: List<Long>): List<HomeCheerCreatorRecommendationRecord>
|
||||||
|
|
||||||
|
fun findPopularCommunityRecommendationDetails(
|
||||||
|
communityIds: List<Long>,
|
||||||
|
includeAdultCommunities: Boolean
|
||||||
|
): List<HomePopularCommunityRecommendationRecord>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class HomeLiveRecommendationRecord(
|
||||||
|
val liveRoomId: Long,
|
||||||
|
val creatorId: Long,
|
||||||
|
val creatorNickname: String,
|
||||||
|
val creatorProfileImage: String?,
|
||||||
|
val title: String,
|
||||||
|
val coverImage: String?,
|
||||||
|
val beginDateTime: LocalDateTime,
|
||||||
|
val channelName: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class HomeBannerRecommendationRecord(
|
||||||
|
val bannerId: Long,
|
||||||
|
val type: String,
|
||||||
|
val thumbnailImage: String,
|
||||||
|
val eventId: Long?,
|
||||||
|
val creatorId: Long?,
|
||||||
|
val seriesId: Long?,
|
||||||
|
val link: String?,
|
||||||
|
val orders: Int,
|
||||||
|
val randomTieBreaker: Double
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RecentlyActiveCreatorRecord(
|
||||||
|
val creatorId: Long,
|
||||||
|
val creatorNickname: String,
|
||||||
|
val creatorProfileImage: String?,
|
||||||
|
val activityType: RecommendedActivityType,
|
||||||
|
val activityAt: LocalDateTime,
|
||||||
|
val targetId: Long?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RecentDebutCreatorRecord(
|
||||||
|
val creatorId: Long,
|
||||||
|
val creatorNickname: String,
|
||||||
|
val creatorProfileImage: String?,
|
||||||
|
val debutAt: LocalDateTime,
|
||||||
|
val score: Double,
|
||||||
|
val randomTieBreaker: Double
|
||||||
|
)
|
||||||
|
|
||||||
|
data class HomeFirstAudioContentRecord(
|
||||||
|
val contentId: Long,
|
||||||
|
val creatorId: Long,
|
||||||
|
val creatorNickname: String,
|
||||||
|
val creatorProfileImage: String?,
|
||||||
|
val title: String,
|
||||||
|
val coverImage: String?,
|
||||||
|
val releaseDate: LocalDateTime,
|
||||||
|
val recencyScore: Int,
|
||||||
|
val randomTieBreaker: Double
|
||||||
|
)
|
||||||
|
|
||||||
|
data class HomeAiCharacterRecommendationRecord(
|
||||||
|
val characterId: Long,
|
||||||
|
val name: String,
|
||||||
|
val description: String,
|
||||||
|
val totalChatCount: Long,
|
||||||
|
val originalWorkTitle: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class HomeCheerCreatorRecommendationRecord(
|
||||||
|
val creatorId: Long,
|
||||||
|
val creatorNickname: String,
|
||||||
|
val creatorProfileImage: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class HomePopularCommunityRecommendationRecord(
|
||||||
|
val communityId: Long,
|
||||||
|
val creatorId: Long,
|
||||||
|
val creatorNickname: String,
|
||||||
|
val creatorProfileImage: String?,
|
||||||
|
val content: String,
|
||||||
|
val createdAt: LocalDateTime,
|
||||||
|
val likeCount: Long,
|
||||||
|
val commentCount: Long
|
||||||
|
)
|
||||||
|
|||||||
@@ -2,12 +2,26 @@ package kr.co.vividnext.sodalive.v2.recommend.application
|
|||||||
|
|
||||||
import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedActivityType
|
import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedActivityType
|
||||||
import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedSectionType
|
import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedSectionType
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeAiCharacterRecommendationRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeBannerRecommendationRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeCheerCreatorRecommendationRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeFirstAudioContentRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeLiveRecommendationRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomePopularCommunityRecommendationRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeRecommendationQueryPort
|
||||||
|
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.RecommendationSnapshotPort
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotRecord
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.DisplayName
|
import org.junit.jupiter.api.DisplayName
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
class HomeRecommendationQueryServiceTest {
|
class HomeRecommendationQueryServiceTest {
|
||||||
private val service = HomeRecommendationQueryService()
|
private val port = FakeHomeRecommendationQueryPort()
|
||||||
|
private val snapshotPort = FakeHomeRecommendationSnapshotPort()
|
||||||
|
private val service = HomeRecommendationQueryService(port, snapshotPort)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("다시듣기 테마 콘텐츠는 AUDIO가 아니라 LIVE_REPLAY 활동으로 분류한다")
|
@DisplayName("다시듣기 테마 콘텐츠는 AUDIO가 아니라 LIVE_REPLAY 활동으로 분류한다")
|
||||||
@@ -47,4 +61,398 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
assertEquals("CHEER_CREATOR", RecommendedSectionType.CHEER_CREATOR.code)
|
assertEquals("CHEER_CREATOR", RecommendedSectionType.CHEER_CREATOR.code)
|
||||||
assertEquals("POPULAR_COMMUNITY", RecommendedSectionType.POPULAR_COMMUNITY.code)
|
assertEquals("POPULAR_COMMUNITY", RecommendedSectionType.POPULAR_COMMUNITY.code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("홈 라이브 추천은 기본 20개를 최신순 조회 포트에 위임한다")
|
||||||
|
fun shouldFindLatestLiveRecommendationsWithDefaultLimit() {
|
||||||
|
val recommendations = service.findLiveRecommendations()
|
||||||
|
|
||||||
|
assertEquals(20, port.liveLimit)
|
||||||
|
assertEquals(port.liveRecommendations, recommendations)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("홈 배너 추천은 기본 20개를 활성 배너 조회 포트에 위임한다")
|
||||||
|
fun shouldFindHomeBannersWithDefaultLimit() {
|
||||||
|
val banners = service.findHomeBanners()
|
||||||
|
|
||||||
|
assertEquals(20, port.bannerLimit)
|
||||||
|
assertEquals(port.banners, banners)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("최근 활동 크리에이터는 기본 10개를 최신 활동 조회 포트에 위임한다")
|
||||||
|
fun shouldFindRecentlyActiveCreatorsWithDefaultLimit() {
|
||||||
|
val creators = service.findRecentlyActiveCreators()
|
||||||
|
|
||||||
|
assertEquals(10, port.activeCreatorLimit)
|
||||||
|
assertEquals(port.activeCreators, creators)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("최근 데뷔 크리에이터는 기본 10개를 기준 시각과 함께 조회 포트에 위임한다")
|
||||||
|
fun shouldFindRecentDebutCreatorsWithDefaultLimitAndNow() {
|
||||||
|
val now = LocalDateTime.of(2026, 5, 31, 10, 0)
|
||||||
|
|
||||||
|
val creators = service.findRecentDebutCreators(now)
|
||||||
|
|
||||||
|
assertEquals(now, port.recentDebutNow)
|
||||||
|
assertEquals(10, port.recentDebutLimit)
|
||||||
|
assertEquals(port.recentDebutCreators, creators)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("첫 오디오 콘텐츠는 기본 10개를 기준 시각과 함께 조회 포트에 위임한다")
|
||||||
|
fun shouldFindFirstAudioContentsWithDefaultLimitAndNow() {
|
||||||
|
val now = LocalDateTime.of(2026, 5, 31, 10, 0)
|
||||||
|
|
||||||
|
val contents = service.findFirstAudioContents(now)
|
||||||
|
|
||||||
|
assertEquals(now, port.firstAudioNow)
|
||||||
|
assertEquals(10, port.firstAudioLimit)
|
||||||
|
assertEquals(port.firstAudioContents, contents)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("AI 캐릭터 추천은 최신 스냅샷 10개를 기준으로 순서를 유지해 상세를 조립한다")
|
||||||
|
fun shouldFindAiCharactersFromLatestSnapshotsWithLimitAndDetails() {
|
||||||
|
val oldSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59)
|
||||||
|
val latestSnapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||||
|
snapshotPort.replaceSnapshots(
|
||||||
|
RecommendedSectionType.AI_CHARACTER,
|
||||||
|
oldSnapshotAt,
|
||||||
|
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 99L, 999.0, oldSnapshotAt))
|
||||||
|
)
|
||||||
|
snapshotPort.replaceSnapshots(
|
||||||
|
RecommendedSectionType.AI_CHARACTER,
|
||||||
|
latestSnapshotAt,
|
||||||
|
(1L..12L).map { targetId ->
|
||||||
|
snapshot(RecommendedSectionType.AI_CHARACTER, targetId, 100.0 - targetId, latestSnapshotAt)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
port.aiCharacterDetails = listOf(
|
||||||
|
HomeAiCharacterRecommendationRecord(
|
||||||
|
characterId = 1L,
|
||||||
|
name = "character-1",
|
||||||
|
description = "description-1",
|
||||||
|
totalChatCount = 3L,
|
||||||
|
originalWorkTitle = "original-work"
|
||||||
|
),
|
||||||
|
HomeAiCharacterRecommendationRecord(
|
||||||
|
characterId = 2L,
|
||||||
|
name = "character-2",
|
||||||
|
description = "description-2",
|
||||||
|
totalChatCount = 0L,
|
||||||
|
originalWorkTitle = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val characters = service.findAiCharacterRecommendations()
|
||||||
|
|
||||||
|
assertEquals((1L..10L).toList(), port.aiCharacterDetailIds)
|
||||||
|
assertEquals(listOf(1L, 2L), characters.map { it.characterId })
|
||||||
|
assertEquals("original-work", characters.first().originalWorkTitle)
|
||||||
|
assertEquals(null, characters.last().originalWorkTitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("최근 응원 크리에이터 추천은 최신 스냅샷 8명을 기준으로 닉네임과 프로필을 조립한다")
|
||||||
|
fun shouldFindCheerCreatorsFromLatestSnapshotsWithLimitAndDetails() {
|
||||||
|
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||||
|
snapshotPort.replaceSnapshots(
|
||||||
|
RecommendedSectionType.CHEER_CREATOR,
|
||||||
|
snapshotAt,
|
||||||
|
(1L..9L).map { targetId ->
|
||||||
|
snapshot(RecommendedSectionType.CHEER_CREATOR, targetId, 100.0 - targetId, snapshotAt)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
port.cheerCreatorDetails = listOf(
|
||||||
|
HomeCheerCreatorRecommendationRecord(
|
||||||
|
creatorId = 1L,
|
||||||
|
creatorNickname = "creator-1",
|
||||||
|
creatorProfileImage = "profile-1.png"
|
||||||
|
),
|
||||||
|
HomeCheerCreatorRecommendationRecord(
|
||||||
|
creatorId = 2L,
|
||||||
|
creatorNickname = "creator-2",
|
||||||
|
creatorProfileImage = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val creators = service.findCheerCreatorRecommendations()
|
||||||
|
|
||||||
|
assertEquals((1L..8L).toList(), port.cheerCreatorDetailIds)
|
||||||
|
assertEquals(listOf(1L, 2L), creators.map { it.creatorId })
|
||||||
|
assertEquals(listOf("creator-1", "creator-2"), creators.map { it.creatorNickname })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("인기 커뮤니티 추천은 최신 스냅샷 10개를 기준으로 크리에이터 중복을 제거하고 상세를 조립한다")
|
||||||
|
fun shouldFindPopularCommunitiesFromLatestSnapshotsWithLimitDetailsAndCreatorUniqueness() {
|
||||||
|
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||||
|
snapshotPort.replaceSnapshots(
|
||||||
|
RecommendedSectionType.POPULAR_COMMUNITY,
|
||||||
|
snapshotAt,
|
||||||
|
(1L..11L).map { targetId ->
|
||||||
|
snapshot(RecommendedSectionType.POPULAR_COMMUNITY, targetId, 100.0 - targetId, snapshotAt)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
port.popularCommunityDetails = listOf(
|
||||||
|
HomePopularCommunityRecommendationRecord(
|
||||||
|
communityId = 1L,
|
||||||
|
creatorId = 10L,
|
||||||
|
creatorNickname = "creator-10",
|
||||||
|
creatorProfileImage = "profile-10.png",
|
||||||
|
content = "content-1",
|
||||||
|
createdAt = LocalDateTime.of(2026, 5, 29, 1, 0),
|
||||||
|
likeCount = 3L,
|
||||||
|
commentCount = 2L
|
||||||
|
),
|
||||||
|
HomePopularCommunityRecommendationRecord(
|
||||||
|
communityId = 2L,
|
||||||
|
creatorId = 10L,
|
||||||
|
creatorNickname = "creator-10",
|
||||||
|
creatorProfileImage = "profile-10.png",
|
||||||
|
content = "content-2",
|
||||||
|
createdAt = LocalDateTime.of(2026, 5, 29, 2, 0),
|
||||||
|
likeCount = 1L,
|
||||||
|
commentCount = 1L
|
||||||
|
),
|
||||||
|
HomePopularCommunityRecommendationRecord(
|
||||||
|
communityId = 3L,
|
||||||
|
creatorId = 11L,
|
||||||
|
creatorNickname = "creator-11",
|
||||||
|
creatorProfileImage = null,
|
||||||
|
content = "content-3",
|
||||||
|
createdAt = LocalDateTime.of(2026, 5, 29, 3, 0),
|
||||||
|
likeCount = 0L,
|
||||||
|
commentCount = 0L
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val communities = service.findPopularCommunityRecommendations(includeAdultCommunities = true)
|
||||||
|
|
||||||
|
assertEquals((1L..11L).toList(), port.popularCommunityDetailIds)
|
||||||
|
assertEquals(true, port.popularCommunityIncludeAdultCommunities)
|
||||||
|
assertEquals(listOf(1L, 3L), communities.map { it.communityId })
|
||||||
|
assertEquals(listOf(10L, 11L), communities.map { it.creatorId })
|
||||||
|
assertEquals(LocalDateTime.of(2026, 5, 29, 1, 0), communities.first().createdAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("인기 커뮤니티 추천은 상위 스냅샷 중복 제거 후 뒤 후보로 기본 10개를 채운다")
|
||||||
|
fun shouldBackfillPopularCommunitiesAfterRemovingDuplicateCreators() {
|
||||||
|
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||||
|
snapshotPort.replaceSnapshots(
|
||||||
|
RecommendedSectionType.POPULAR_COMMUNITY,
|
||||||
|
snapshotAt,
|
||||||
|
(1L..20L).map { targetId ->
|
||||||
|
snapshot(RecommendedSectionType.POPULAR_COMMUNITY, targetId, 100.0 - targetId, snapshotAt)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
port.popularCommunityDetails = (1L..20L).map { communityId ->
|
||||||
|
HomePopularCommunityRecommendationRecord(
|
||||||
|
communityId = communityId,
|
||||||
|
creatorId = if (communityId <= 10L) 1L else communityId,
|
||||||
|
creatorNickname = "creator-$communityId",
|
||||||
|
creatorProfileImage = null,
|
||||||
|
content = "content-$communityId",
|
||||||
|
createdAt = LocalDateTime.of(2026, 5, 29, 1, 0).plusMinutes(communityId),
|
||||||
|
likeCount = 0L,
|
||||||
|
commentCount = 0L
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val communities = service.findPopularCommunityRecommendations()
|
||||||
|
|
||||||
|
assertEquals(20, port.popularCommunityDetailIds.size)
|
||||||
|
assertEquals(10, communities.size)
|
||||||
|
assertEquals(listOf(1L) + (11L..19L).toList(), communities.map { it.communityId })
|
||||||
|
assertEquals(communities.size, communities.map { it.creatorId }.toSet().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("최신 스냅샷이 없으면 AI 캐릭터/최근 응원/인기 커뮤니티 추천은 빈 배열을 반환한다")
|
||||||
|
fun shouldReturnEmptyListWhenLatestSnapshotsDoNotExist() {
|
||||||
|
assertEquals(emptyList<HomeAiCharacterRecommendationRecord>(), service.findAiCharacterRecommendations())
|
||||||
|
assertEquals(emptyList<HomeCheerCreatorRecommendationRecord>(), service.findCheerCreatorRecommendations())
|
||||||
|
assertEquals(emptyList<HomePopularCommunityRecommendationRecord>(), service.findPopularCommunityRecommendations())
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeHomeRecommendationQueryPort : HomeRecommendationQueryPort {
|
||||||
|
var liveLimit: Int? = null
|
||||||
|
var bannerLimit: Int? = null
|
||||||
|
var activeCreatorLimit: Int? = null
|
||||||
|
var recentDebutNow: LocalDateTime? = null
|
||||||
|
var recentDebutLimit: Int? = null
|
||||||
|
var firstAudioNow: LocalDateTime? = null
|
||||||
|
var firstAudioLimit: Int? = null
|
||||||
|
var aiCharacterDetailIds: List<Long> = emptyList()
|
||||||
|
var cheerCreatorDetailIds: List<Long> = emptyList()
|
||||||
|
var popularCommunityDetailIds: List<Long> = emptyList()
|
||||||
|
var popularCommunityIncludeAdultCommunities: Boolean? = null
|
||||||
|
val liveRecommendations = listOf(
|
||||||
|
HomeLiveRecommendationRecord(
|
||||||
|
liveRoomId = 1L,
|
||||||
|
creatorId = 10L,
|
||||||
|
creatorNickname = "creator",
|
||||||
|
creatorProfileImage = "profile.png",
|
||||||
|
title = "live",
|
||||||
|
coverImage = "cover.png",
|
||||||
|
beginDateTime = LocalDateTime.of(2026, 5, 31, 10, 0),
|
||||||
|
channelName = "channel"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val banners = listOf(
|
||||||
|
HomeBannerRecommendationRecord(
|
||||||
|
bannerId = 2L,
|
||||||
|
type = "LINK",
|
||||||
|
thumbnailImage = "banner.png",
|
||||||
|
eventId = null,
|
||||||
|
creatorId = null,
|
||||||
|
seriesId = null,
|
||||||
|
link = "https://example.com",
|
||||||
|
orders = 1,
|
||||||
|
randomTieBreaker = 0.1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val activeCreators = listOf(
|
||||||
|
RecentlyActiveCreatorRecord(
|
||||||
|
creatorId = 10L,
|
||||||
|
creatorNickname = "creator",
|
||||||
|
creatorProfileImage = "profile.png",
|
||||||
|
activityType = RecommendedActivityType.LIVE,
|
||||||
|
activityAt = LocalDateTime.of(2026, 5, 31, 10, 0),
|
||||||
|
targetId = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val recentDebutCreators = listOf(
|
||||||
|
RecentDebutCreatorRecord(
|
||||||
|
creatorId = 11L,
|
||||||
|
creatorNickname = "debut-creator",
|
||||||
|
creatorProfileImage = "debut-profile.png",
|
||||||
|
debutAt = LocalDateTime.of(2026, 5, 20, 10, 0),
|
||||||
|
score = 1.2,
|
||||||
|
randomTieBreaker = 0.2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val firstAudioContents = listOf(
|
||||||
|
HomeFirstAudioContentRecord(
|
||||||
|
contentId = 21L,
|
||||||
|
creatorId = 11L,
|
||||||
|
creatorNickname = "debut-creator",
|
||||||
|
creatorProfileImage = "debut-profile.png",
|
||||||
|
title = "first-audio",
|
||||||
|
coverImage = "first-audio.png",
|
||||||
|
releaseDate = LocalDateTime.of(2026, 5, 30, 10, 0),
|
||||||
|
recencyScore = 100,
|
||||||
|
randomTieBreaker = 0.3
|
||||||
|
)
|
||||||
|
)
|
||||||
|
var aiCharacterDetails: List<HomeAiCharacterRecommendationRecord> = emptyList()
|
||||||
|
var cheerCreatorDetails: List<HomeCheerCreatorRecommendationRecord> = emptyList()
|
||||||
|
var popularCommunityDetails: List<HomePopularCommunityRecommendationRecord> = emptyList()
|
||||||
|
|
||||||
|
override fun findLiveRecommendations(limit: Int): List<HomeLiveRecommendationRecord> {
|
||||||
|
liveLimit = limit
|
||||||
|
return liveRecommendations
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findHomeBanners(limit: Int): List<HomeBannerRecommendationRecord> {
|
||||||
|
bannerLimit = limit
|
||||||
|
return banners
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findRecentlyActiveCreators(limit: Int): List<RecentlyActiveCreatorRecord> {
|
||||||
|
activeCreatorLimit = limit
|
||||||
|
return activeCreators
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findRecentDebutCreators(now: LocalDateTime, limit: Int): List<RecentDebutCreatorRecord> {
|
||||||
|
recentDebutNow = now
|
||||||
|
recentDebutLimit = limit
|
||||||
|
return recentDebutCreators
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findFirstAudioContents(now: LocalDateTime, limit: Int): List<HomeFirstAudioContentRecord> {
|
||||||
|
firstAudioNow = now
|
||||||
|
firstAudioLimit = limit
|
||||||
|
return firstAudioContents
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findAiCharacterSnapshots(
|
||||||
|
windowStart: LocalDateTime,
|
||||||
|
snapshotAt: LocalDateTime,
|
||||||
|
limit: Int
|
||||||
|
): List<RecommendationSnapshotRecord> = emptyList()
|
||||||
|
|
||||||
|
override fun findCheerCreatorSnapshots(
|
||||||
|
windowStart: LocalDateTime,
|
||||||
|
snapshotAt: LocalDateTime,
|
||||||
|
limit: Int
|
||||||
|
): List<RecommendationSnapshotRecord> = emptyList()
|
||||||
|
|
||||||
|
override fun findPopularCommunitySnapshots(
|
||||||
|
windowStart: LocalDateTime,
|
||||||
|
snapshotAt: LocalDateTime,
|
||||||
|
limit: Int
|
||||||
|
): List<RecommendationSnapshotRecord> = emptyList()
|
||||||
|
|
||||||
|
override fun findAiCharacterRecommendationDetails(characterIds: List<Long>): List<HomeAiCharacterRecommendationRecord> {
|
||||||
|
aiCharacterDetailIds = characterIds
|
||||||
|
return aiCharacterDetails
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findCheerCreatorRecommendationDetails(creatorIds: List<Long>): List<HomeCheerCreatorRecommendationRecord> {
|
||||||
|
cheerCreatorDetailIds = creatorIds
|
||||||
|
return cheerCreatorDetails
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findPopularCommunityRecommendationDetails(
|
||||||
|
communityIds: List<Long>,
|
||||||
|
includeAdultCommunities: Boolean
|
||||||
|
): List<HomePopularCommunityRecommendationRecord> {
|
||||||
|
popularCommunityDetailIds = communityIds
|
||||||
|
popularCommunityIncludeAdultCommunities = includeAdultCommunities
|
||||||
|
return popularCommunityDetails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeHomeRecommendationSnapshotPort : RecommendationSnapshotPort {
|
||||||
|
private val snapshots = mutableListOf<RecommendationSnapshotRecord>()
|
||||||
|
|
||||||
|
override fun findLatestSnapshots(sectionType: RecommendedSectionType): List<RecommendationSnapshotRecord> {
|
||||||
|
val latestSnapshotAt = snapshots
|
||||||
|
.filter { it.sectionType == sectionType }
|
||||||
|
.maxOfOrNull { it.snapshotAt }
|
||||||
|
|
||||||
|
return snapshots
|
||||||
|
.filter { it.sectionType == sectionType && it.snapshotAt == latestSnapshotAt }
|
||||||
|
.sortedWith(compareByDescending<RecommendationSnapshotRecord> { it.score }.thenBy { it.randomTieBreaker })
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun replaceSnapshots(
|
||||||
|
sectionType: RecommendedSectionType,
|
||||||
|
snapshotAt: LocalDateTime,
|
||||||
|
newSnapshots: List<RecommendationSnapshotRecord>
|
||||||
|
) {
|
||||||
|
snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
|
||||||
|
snapshots.addAll(newSnapshots)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun snapshot(
|
||||||
|
sectionType: RecommendedSectionType,
|
||||||
|
targetId: Long,
|
||||||
|
score: Double,
|
||||||
|
snapshotAt: LocalDateTime
|
||||||
|
): RecommendationSnapshotRecord {
|
||||||
|
return RecommendationSnapshotRecord(
|
||||||
|
sectionType = sectionType,
|
||||||
|
targetId = targetId,
|
||||||
|
score = score,
|
||||||
|
snapshotAt = snapshotAt,
|
||||||
|
randomTieBreaker = targetId.toDouble() / 100
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user