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 new file mode 100644 index 00000000..0d9560b4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt @@ -0,0 +1,267 @@ +package kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence + +import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendationScoreSpec +import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedSectionType +import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotRecord +import org.springframework.stereotype.Repository +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@Repository +class DefaultHomeRecommendationQueryRepository( + private val entityManager: EntityManager +) : HomeRecommendationQueryRepository { + override fun findAiCharacterSnapshots( + windowStart: LocalDateTime, + snapshotAt: LocalDateTime, + limit: Int + ): List { + val sql = """ + select cc.id as target_id, + (((select count(cm.id) + from chat_message cm + join chat_participant cp on cp.id = cm.participant_id + where cp.character_id = cc.id + and cm.created_at >= :windowStart + and cm.created_at <= :snapshotAt + and cm.is_active = true + and cp.is_active = true + and cp.participant_type = 'CHARACTER') * ${RecommendationScoreSpec.AI_RECENT_CHAT_WEIGHT} + + (select count(distinct up.member_id) + from chat_message cm + join chat_participant up on up.id = cm.participant_id + join chat_participant cp on cp.chat_room_id = cm.chat_room_id + join member m on m.id = up.member_id + where cp.character_id = cc.id + and cm.created_at >= :windowStart + and cm.created_at <= :snapshotAt + and cm.is_active = true + and up.is_active = true + and cp.is_active = true + and up.participant_type = 'USER' + and cp.participant_type = 'CHARACTER' + and m.is_active = true) * ${RecommendationScoreSpec.AI_RECENT_ACTIVE_USER_WEIGHT}) * + case + when cc.created_at >= :boost10Start then ${RecommendationScoreSpec.NEW_BOOST_10_DAYS} + when cc.created_at >= :boost20Start then ${RecommendationScoreSpec.NEW_BOOST_20_DAYS} + when cc.created_at >= :boost30Start then ${RecommendationScoreSpec.NEW_BOOST_30_DAYS} + else ${RecommendationScoreSpec.DEFAULT_NEW_BOOST} + end) as score, + rand() as random_tie_breaker + from chat_character cc + where cc.is_active = true + and (exists ( + select 1 + from chat_message cm + join chat_participant cp on cp.id = cm.participant_id + where cp.character_id = cc.id + and cm.created_at >= :windowStart + and cm.created_at <= :snapshotAt + and cm.is_active = true + and cp.is_active = true + and cp.participant_type = 'CHARACTER' + ) or exists ( + select 1 + from chat_message cm + join chat_participant up on up.id = cm.participant_id + join chat_participant cp on cp.chat_room_id = cm.chat_room_id + join member m on m.id = up.member_id + where cp.character_id = cc.id + and cm.created_at >= :windowStart + and cm.created_at <= :snapshotAt + and cm.is_active = true + and up.is_active = true + and cp.is_active = true + and up.participant_type = 'USER' + and cp.participant_type = 'CHARACTER' + and m.is_active = true + )) + order by score desc, random_tie_breaker asc + limit :limit + """.trimIndent() + + return executeSnapshotQuery(sql, RecommendedSectionType.AI_CHARACTER, windowStart, snapshotAt, limit) + } + + override fun findCheerCreatorSnapshots( + windowStart: LocalDateTime, + snapshotAt: LocalDateTime, + limit: Int + ): List { + val sql = """ + with creator_debut as ( + select debut_events.creator_id as creator_id, min(debut_events.debut_at) as debut_at + from ( + select ac.member_id as creator_id, ac.release_date as debut_at + from content ac + where ac.is_active = true + and ac.release_date is not null + and ac.release_date <= :snapshotAt + union all + select lr.member_id as creator_id, lr.begin_date_time as debut_at + from live_room lr + where lr.is_active = true + and lr.channel_name is not null + and lr.begin_date_time <= :snapshotAt + ) debut_events + group by debut_events.creator_id + ), + donation_stats as ( + select ucc.recipient_creator_id as creator_id, + coalesce(sum(ucc.can), 0) as donation_amount, + count(ucc.id) as donation_count + from use_can_calculate ucc + join use_can uc on uc.id = ucc.use_can_id + where ucc.status = 'RECEIVED' + and uc.is_refund = false + and uc.can_usage = 'CHANNEL_DONATION' + and ucc.created_at >= :windowStart + and ucc.created_at <= :snapshotAt + group by ucc.recipient_creator_id + ), + fan_talk_stats as ( + select ch.creator_id as creator_id, count(ch.id) as fan_talk_count + from creator_cheers ch + where ch.is_active = true + and ch.created_at >= :windowStart + and ch.created_at <= :snapshotAt + group by ch.creator_id + ) + select m.id as target_id, + ((coalesce(ds.donation_amount, 0) * ${RecommendationScoreSpec.CHEER_DONATION_AMOUNT_WEIGHT} + + coalesce(fts.fan_talk_count, 0) * ${RecommendationScoreSpec.CHEER_FAN_TALK_WEIGHT} + + coalesce(ds.donation_count, 0) * ${RecommendationScoreSpec.CHEER_DONATION_COUNT_WEIGHT}) * + case + when cd.debut_at >= :boost10Start then ${RecommendationScoreSpec.NEW_BOOST_10_DAYS} + when cd.debut_at >= :boost20Start then ${RecommendationScoreSpec.NEW_BOOST_20_DAYS} + when cd.debut_at >= :boost30Start then ${RecommendationScoreSpec.NEW_BOOST_30_DAYS} + else ${RecommendationScoreSpec.DEFAULT_NEW_BOOST} + end) as score, + rand() as random_tie_breaker + from member m + join creator_debut cd on cd.creator_id = m.id + left join donation_stats ds on ds.creator_id = m.id + left join fan_talk_stats fts on fts.creator_id = m.id + where m.is_active = true + and cd.debut_at is not null + and (coalesce(ds.donation_count, 0) > 0 or coalesce(fts.fan_talk_count, 0) > 0) + order by score desc, random_tie_breaker asc + limit :limit + """.trimIndent() + + return executeSnapshotQuery(sql, RecommendedSectionType.CHEER_CREATOR, windowStart, snapshotAt, limit) + } + + override fun findPopularCommunitySnapshots( + windowStart: LocalDateTime, + snapshotAt: LocalDateTime, + limit: Int + ): List { + val sql = """ + with creator_debut as ( + select debut_events.creator_id as creator_id, min(debut_events.debut_at) as debut_at + from ( + select ac.member_id as creator_id, ac.release_date as debut_at + from content ac + where ac.is_active = true + and ac.release_date is not null + and ac.release_date <= :snapshotAt + union all + select lr.member_id as creator_id, lr.begin_date_time as debut_at + from live_room lr + where lr.is_active = true + and lr.channel_name is not null + and lr.begin_date_time <= :snapshotAt + ) debut_events + group by debut_events.creator_id + ), + like_stats as ( + select ccl.creator_community_id as community_id, count(distinct ccl.id) as like_count + from creator_community_like ccl + where ccl.is_active = true + and ccl.created_at >= :windowStart + and ccl.created_at <= :snapshotAt + group by ccl.creator_community_id + ), + comment_stats as ( + select ccc.creator_community_id as community_id, count(distinct ccc.id) as comment_count + from creator_community_comment ccc + where ccc.is_active = true + and ccc.created_at >= :windowStart + and ccc.created_at <= :snapshotAt + group by ccc.creator_community_id + ), + follower_stats as ( + select cf.creator_id as creator_id, count(distinct cf.id) as follower_count + from creator_following cf + where cf.is_active = true + group by cf.creator_id + ) + select cc.id as target_id, + ((coalesce(ls.like_count, 0) * ${RecommendationScoreSpec.COMMUNITY_LIKE_WEIGHT} + + (case when cc.is_comment_available = true then coalesce(cs.comment_count, 0) else 0 end) * ${RecommendationScoreSpec.COMMUNITY_COMMENT_WEIGHT} + + coalesce(fs.follower_count, 0) * ${RecommendationScoreSpec.COMMUNITY_FOLLOWER_WEIGHT}) * + case + when cd.debut_at >= :boost10Start then ${RecommendationScoreSpec.NEW_BOOST_10_DAYS} + when cd.debut_at >= :boost20Start then ${RecommendationScoreSpec.NEW_BOOST_20_DAYS} + when cd.debut_at >= :boost30Start then ${RecommendationScoreSpec.NEW_BOOST_30_DAYS} + else ${RecommendationScoreSpec.DEFAULT_NEW_BOOST} + end) as score, + rand() as random_tie_breaker + from creator_community cc + join member m on m.id = cc.member_id + join creator_debut cd on cd.creator_id = cc.member_id + left join like_stats ls on ls.community_id = cc.id + left join comment_stats cs on cs.community_id = cc.id + left join follower_stats fs on fs.creator_id = cc.member_id + where cc.is_active = true + and m.is_active = true + and cc.price = 0 + and cc.is_fixed = false + and cc.created_at <= :snapshotAt + and cd.debut_at is not null + order by score desc, random_tie_breaker asc + limit :limit + """.trimIndent() + + return executeSnapshotQuery(sql, RecommendedSectionType.POPULAR_COMMUNITY, windowStart, snapshotAt, limit) + } + + private fun executeSnapshotQuery( + sql: String, + sectionType: RecommendedSectionType, + windowStart: LocalDateTime, + snapshotAt: LocalDateTime, + limit: Int + ): List { + val query = entityManager.createNativeQuery(sql) + .setParameter("windowStart", windowStart) + .setParameter("snapshotAt", snapshotAt) + .setParameter("limit", limit) + .setParameter( + "boost10Start", + snapshotAt.toLocalDate().minusDays(RecommendationScoreSpec.NEW_BOOST_10_DAY_LIMIT).atStartOfDay() + ) + .setParameter( + "boost20Start", + snapshotAt.toLocalDate().minusDays(RecommendationScoreSpec.NEW_BOOST_20_DAY_LIMIT).atStartOfDay() + ) + .setParameter( + "boost30Start", + snapshotAt.toLocalDate().minusDays(RecommendationScoreSpec.NEW_BOOST_30_DAY_LIMIT).atStartOfDay() + ) + + @Suppress("UNCHECKED_CAST") + val rows = query.resultList as List> + + return rows.map { row -> + RecommendationSnapshotRecord( + sectionType = sectionType, + targetId = (row[0] as Number).toLong(), + score = (row[1] as Number).toDouble(), + snapshotAt = snapshotAt, + randomTieBreaker = (row[2] as Number).toDouble() + ) + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/HomeRecommendationQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/HomeRecommendationQueryRepository.kt new file mode 100644 index 00000000..0cea7a13 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/HomeRecommendationQueryRepository.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence + +import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeRecommendationQueryPort +import org.springframework.data.repository.NoRepositoryBean + +@NoRepositoryBean +interface HomeRecommendationQueryRepository : HomeRecommendationQueryPort diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/port/out/HomeRecommendationQueryPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/port/out/HomeRecommendationQueryPort.kt new file mode 100644 index 00000000..053998b6 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/port/out/HomeRecommendationQueryPort.kt @@ -0,0 +1,23 @@ +package kr.co.vividnext.sodalive.v2.recommend.port.out + +import java.time.LocalDateTime + +interface HomeRecommendationQueryPort { + fun findAiCharacterSnapshots( + windowStart: LocalDateTime, + snapshotAt: LocalDateTime, + limit: Int + ): List + + fun findCheerCreatorSnapshots( + windowStart: LocalDateTime, + snapshotAt: LocalDateTime, + limit: Int + ): List + + fun findPopularCommunitySnapshots( + windowStart: LocalDateTime, + snapshotAt: LocalDateTime, + limit: Int + ): List +} 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 new file mode 100644 index 00000000..a7d9e2f0 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt @@ -0,0 +1,626 @@ +package kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence + +import kr.co.vividnext.sodalive.can.payment.PaymentGateway +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.can.use.UseCan +import kr.co.vividnext.sodalive.can.use.UseCanCalculate +import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus +import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import kr.co.vividnext.sodalive.chat.room.ChatMessage +import kr.co.vividnext.sodalive.chat.room.ChatParticipant +import kr.co.vividnext.sodalive.chat.room.ChatRoom +import kr.co.vividnext.sodalive.chat.room.ParticipantType +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityComment +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLike +import kr.co.vividnext.sodalive.live.room.LiveRoom +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.following.CreatorFollowing +import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendationScorePolicy +import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedSectionType +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@DataJpaTest( + properties = [ + "spring.cache.type=none", + "spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE" + ] +) +@Import(QueryDslConfig::class) +class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( + private val entityManager: EntityManager +) { + private val repository = DefaultHomeRecommendationQueryRepository(entityManager) + private val scorePolicy = RecommendationScorePolicy() + + @Test + @DisplayName("AI 캐릭터 스냅샷은 AI 발화 수와 중복 없는 활성 사용자 수를 집계하고 팔로우 증가량은 제외한다") + fun shouldFindAiCharacterSnapshotsWithoutFollowIncrease() { + val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0) + val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + val user1 = saveMember("ai-user-1", MemberRole.USER) + val user2 = saveMember("ai-user-2", MemberRole.USER) + val character = saveCharacter("character-1", isActive = true) + val inactiveCharacter = saveCharacter("character-2", isActive = false) + val room = saveChatRoom("room-1") + val otherRoom = saveChatRoom("room-2") + val userParticipant1 = saveParticipant(room, ParticipantType.USER, member = user1) + val userParticipant2 = saveParticipant(room, ParticipantType.USER, member = user2) + val characterParticipant = saveParticipant(room, ParticipantType.CHARACTER, character = character) + val inactiveCharacterUser = saveParticipant(otherRoom, ParticipantType.USER, member = user1) + saveParticipant(otherRoom, ParticipantType.CHARACTER, character = inactiveCharacter) + + val message1 = saveMessage(room, userParticipant1, "message-1", isActive = true) + val message2 = saveMessage(room, userParticipant1, "message-2", isActive = true) + val message3 = saveMessage(room, userParticipant2, "message-3", isActive = true) + val characterMessage1 = saveMessage(room, characterParticipant, "character-message-1", isActive = true) + val characterMessage2 = saveMessage(room, characterParticipant, "character-message-2", isActive = true) + val inactiveMessage = saveMessage(room, characterParticipant, "message-4", isActive = false) + val oldMessage = saveMessage(room, characterParticipant, "message-5", isActive = true) + val inactiveCharacterMessage = saveMessage(otherRoom, inactiveCharacterUser, "message-6", isActive = true) + updateCreatedAt("ChatMessage", message1.id!!, windowStart.plusDays(1)) + updateCreatedAt("ChatMessage", message2.id!!, windowStart.plusDays(2)) + updateCreatedAt("ChatMessage", message3.id!!, snapshotAt) + updateCreatedAt("ChatMessage", characterMessage1.id!!, windowStart.plusDays(1)) + updateCreatedAt("ChatMessage", characterMessage2.id!!, snapshotAt) + updateCreatedAt("ChatMessage", inactiveMessage.id!!, windowStart.plusDays(3)) + updateCreatedAt("ChatMessage", oldMessage.id!!, windowStart.minusSeconds(1)) + updateCreatedAt("ChatMessage", inactiveCharacterMessage.id!!, windowStart.plusDays(1)) + updateCreatedAt("ChatCharacter", character.id!!, LocalDateTime.of(2026, 5, 20, 12, 0)) + flushAndClear() + + val snapshots = repository.findAiCharacterSnapshots(windowStart, snapshotAt, limit = 10) + + val expectedScore = scorePolicy.calculateAiChatScore( + recentChatCount = 2, + recentActiveUserCount = 2, + newBoost = scorePolicy.calculateAiCharacterNewBoost(LocalDateTime.of(2026, 5, 20, 12, 0), snapshotAt) + ) + assertEquals(1, snapshots.size) + assertEquals(RecommendedSectionType.AI_CHARACTER, snapshots.single().sectionType) + assertEquals(character.id, snapshots.single().targetId) + assertEquals(expectedScore, snapshots.single().score, 0.0001) + assertEquals(snapshotAt, snapshots.single().snapshotAt) + } + + @Test + @DisplayName("AI 캐릭터 스냅샷은 DB에서 최종 점수를 계산한 뒤 정렬하고 limit을 적용한다") + fun shouldFindAiCharacterSnapshotsWithDbScoreOrderAndLimit() { + val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0) + val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + val user = saveMember("ai-score-user", MemberRole.USER) + val oldHighActivityCharacter = saveCharacter("old-high-activity", isActive = true) + val newLowerActivityCharacter = saveCharacter("new-lower-activity", isActive = true) + val oldRoom = saveChatRoom("ai-score-old-room") + val newRoom = saveChatRoom("ai-score-new-room") + val oldUserParticipant = saveParticipant(oldRoom, ParticipantType.USER, member = user) + val oldCharacterParticipant = saveParticipant(oldRoom, ParticipantType.CHARACTER, character = oldHighActivityCharacter) + val newUserParticipant = saveParticipant(newRoom, ParticipantType.USER, member = user) + val newCharacterParticipant = saveParticipant(newRoom, ParticipantType.CHARACTER, character = newLowerActivityCharacter) + repeat(3) { index -> + updateCreatedAt( + "ChatMessage", + saveMessage(oldRoom, oldCharacterParticipant, "old-character-$index", true).id!!, + windowStart.plusDays(1) + ) + } + updateCreatedAt( + "ChatMessage", + saveMessage(oldRoom, oldUserParticipant, "old-user", true).id!!, + windowStart.plusDays(1) + ) + repeat(2) { index -> + updateCreatedAt( + "ChatMessage", + saveMessage(newRoom, newCharacterParticipant, "new-character-$index", true).id!!, + windowStart.plusDays(1) + ) + } + updateCreatedAt("ChatMessage", saveMessage(newRoom, newUserParticipant, "new-user", true).id!!, windowStart.plusDays(1)) + updateCreatedAt("ChatCharacter", oldHighActivityCharacter.id!!, LocalDateTime.of(2026, 4, 1, 0, 0)) + updateCreatedAt("ChatCharacter", newLowerActivityCharacter.id!!, LocalDateTime.of(2026, 5, 20, 0, 0)) + flushAndClear() + + val snapshots = repository.findAiCharacterSnapshots(windowStart, snapshotAt, limit = 1) + + val expectedScore = scorePolicy.calculateAiChatScore( + recentChatCount = 2, + recentActiveUserCount = 1, + newBoost = scorePolicy.calculateAiCharacterNewBoost(LocalDateTime.of(2026, 5, 20, 0, 0), snapshotAt) + ) + assertEquals(1, snapshots.size) + assertEquals(RecommendedSectionType.AI_CHARACTER, snapshots.single().sectionType) + assertEquals(newLowerActivityCharacter.id, snapshots.single().targetId) + assertEquals(expectedScore, snapshots.single().score, 0.0001) + assertEquals(snapshotAt, snapshots.single().snapshotAt) + } + + @Test + @DisplayName("최근 응원 스냅샷은 CHANNEL_DONATION 후원 금액과 후원 수만 집계한다") + fun shouldFindCheerCreatorSnapshotsWithChannelDonationOnly() { + val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0) + val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + val creator = saveMember("cheer-creator", MemberRole.CREATOR) + val donor = saveMember("cheer-donor", MemberRole.USER) + saveAudioContent(creator, LocalDateTime.of(2026, 5, 20, 12, 0), isActive = true) + saveLiveRoom(creator, LocalDateTime.of(2026, 5, 10, 12, 0), channelName = "cheer-channel") + + saveUseCanCalculate( + donor, + creator, + CanUsage.CHANNEL_DONATION, + can = 100, + status = UseCanCalculateStatus.RECEIVED, + isRefund = false, + createdAt = windowStart.plusDays(1) + ) + saveUseCanCalculate( + donor, + creator, + CanUsage.CHANNEL_DONATION, + can = 50, + status = UseCanCalculateStatus.RECEIVED, + isRefund = false, + createdAt = snapshotAt + ) + saveUseCanCalculate( + donor, + creator, + CanUsage.DONATION, + can = 1_000, + status = UseCanCalculateStatus.RECEIVED, + isRefund = false, + createdAt = windowStart.plusDays(1) + ) + saveUseCanCalculate( + donor, + creator, + CanUsage.SPIN_ROULETTE, + can = 1_000, + status = UseCanCalculateStatus.RECEIVED, + isRefund = false, + createdAt = windowStart.plusDays(1) + ) + saveUseCanCalculate( + donor, + creator, + CanUsage.LIVE, + can = 1_000, + status = UseCanCalculateStatus.RECEIVED, + isRefund = false, + createdAt = windowStart.plusDays(1) + ) + saveUseCanCalculate( + donor, + creator, + CanUsage.CHANNEL_DONATION, + can = 1_000, + status = UseCanCalculateStatus.REFUND, + isRefund = false, + createdAt = windowStart.plusDays(1) + ) + saveUseCanCalculate( + donor, + creator, + CanUsage.CHANNEL_DONATION, + can = 1_000, + status = UseCanCalculateStatus.RECEIVED, + isRefund = true, + createdAt = windowStart.plusDays(1) + ) + saveUseCanCalculate( + donor, + creator, + CanUsage.CHANNEL_DONATION, + can = 1_000, + status = UseCanCalculateStatus.RECEIVED, + isRefund = false, + createdAt = windowStart.minusSeconds(1) + ) + saveUseCanCalculate( + donor, + null, + CanUsage.CHANNEL_DONATION, + can = 1_000, + status = UseCanCalculateStatus.RECEIVED, + isRefund = false, + createdAt = windowStart.plusDays(1) + ) + val cheer1 = saveCreatorCheers(donor, creator, isActive = true) + val cheer2 = saveCreatorCheers(donor, creator, isActive = true) + val inactiveCheer = saveCreatorCheers(donor, creator, isActive = false) + updateCreatedAt("CreatorCheers", cheer1.id!!, windowStart.plusDays(1)) + updateCreatedAt("CreatorCheers", cheer2.id!!, snapshotAt) + updateCreatedAt("CreatorCheers", inactiveCheer.id!!, windowStart.plusDays(1)) + updateCreatedAt("Member", creator.id!!, LocalDateTime.of(2026, 5, 10, 12, 0)) + flushAndClear() + + val snapshots = repository.findCheerCreatorSnapshots(windowStart, snapshotAt, limit = 10) + + val expectedScore = scorePolicy.calculateCheerScore( + donationAmount = 150, + fanTalkCount = 2, + donationCount = 2, + newBoost = scorePolicy.calculateCreatorNewBoost(LocalDateTime.of(2026, 5, 10, 12, 0), snapshotAt) + ) + assertEquals(1, snapshots.size) + assertEquals(RecommendedSectionType.CHEER_CREATOR, snapshots.single().sectionType) + assertEquals(creator.id, snapshots.single().targetId) + assertEquals(expectedScore, snapshots.single().score, 0.0001) + } + + @Test + @DisplayName("최근 응원 스냅샷은 DB에서 최종 점수를 계산한 뒤 정렬하고 limit을 적용한다") + fun shouldFindCheerCreatorSnapshotsWithDbScoreOrderAndLimit() { + val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0) + val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + val donor = saveMember("cheer-score-donor", MemberRole.USER) + val oldCreator = saveMember("old-cheer-score-creator", MemberRole.CREATOR) + val newCreator = saveMember("new-cheer-score-creator", MemberRole.CREATOR) + saveLiveRoom(oldCreator, LocalDateTime.of(2026, 4, 1, 0, 0), channelName = "old-cheer") + saveLiveRoom(newCreator, LocalDateTime.of(2026, 5, 20, 0, 0), channelName = "new-cheer") + saveUseCanCalculate( + donor, + oldCreator, + CanUsage.CHANNEL_DONATION, + 2, + UseCanCalculateStatus.RECEIVED, + false, + windowStart.plusDays(1) + ) + saveUseCanCalculate( + donor, + newCreator, + CanUsage.CHANNEL_DONATION, + 2, + UseCanCalculateStatus.RECEIVED, + false, + windowStart.plusDays(1) + ) + flushAndClear() + + val snapshots = repository.findCheerCreatorSnapshots(windowStart, snapshotAt, limit = 1) + + val expectedScore = scorePolicy.calculateCheerScore( + donationAmount = 2, + fanTalkCount = 0, + donationCount = 1, + newBoost = scorePolicy.calculateCreatorNewBoost(LocalDateTime.of(2026, 5, 20, 0, 0), snapshotAt) + ) + assertEquals(1, snapshots.size) + assertEquals(RecommendedSectionType.CHEER_CREATOR, snapshots.single().sectionType) + assertEquals(newCreator.id, snapshots.single().targetId) + assertEquals(expectedScore, snapshots.single().score, 0.0001) + } + + @Test + @DisplayName("인기 커뮤니티 스냅샷은 좋아요 댓글 팔로워 수를 distinct로 집계하고 댓글 불가 게시글은 댓글 수 0으로 계산한다") + fun shouldFindPopularCommunitySnapshotsWithDistinctCounts() { + val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0) + val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + val creator = saveMember("community-creator", MemberRole.CREATOR) + val member1 = saveMember("community-member-1", MemberRole.USER) + val member2 = saveMember("community-member-2", MemberRole.USER) + saveAudioContent(creator, LocalDateTime.of(2026, 5, 5, 12, 0), isActive = true) + saveLiveRoom(creator, LocalDateTime.of(2026, 4, 1, 12, 0), channelName = "community-channel") + val post = saveCommunity(creator, isCommentAvailable = true) + val commentDisabledPost = saveCommunity(creator, isCommentAvailable = false) + val like1 = saveCommunityLike(member1, post, isActive = true) + val like2 = saveCommunityLike(member2, post, isActive = true) + val inactiveLike = saveCommunityLike(member1, post, isActive = false) + val comment1 = saveCommunityComment(member1, post, isActive = true) + val comment2 = saveCommunityComment(member2, post, isActive = true) + val inactiveComment = saveCommunityComment(member1, post, isActive = false) + saveFollowing(member1, creator, isActive = true) + saveFollowing(member2, creator, isActive = true) + saveFollowing(member1, creator, isActive = false) + val disabledComment = saveCommunityComment(member1, commentDisabledPost, isActive = true) + updateCreatedAt("CreatorCommunity", post.id!!, windowStart.plusDays(1)) + updateCreatedAt("CreatorCommunity", commentDisabledPost.id!!, windowStart.plusDays(1)) + updateCreatedAt("CreatorCommunityLike", like1.id!!, windowStart.plusDays(1)) + updateCreatedAt("CreatorCommunityLike", like2.id!!, snapshotAt) + updateCreatedAt("CreatorCommunityLike", inactiveLike.id!!, windowStart.plusDays(1)) + updateCreatedAt("CreatorCommunityComment", comment1.id!!, windowStart.plusDays(1)) + updateCreatedAt("CreatorCommunityComment", comment2.id!!, snapshotAt) + updateCreatedAt("CreatorCommunityComment", inactiveComment.id!!, windowStart.plusDays(1)) + updateCreatedAt("CreatorCommunityComment", disabledComment.id!!, windowStart.plusDays(1)) + updateCreatedAt("Member", creator.id!!, LocalDateTime.of(2026, 4, 1, 12, 0)) + flushAndClear() + + val snapshots = repository.findPopularCommunitySnapshots(windowStart, snapshotAt, limit = 10) + .associateBy { it.targetId } + + val expectedPostScore = scorePolicy.calculateCommunityScore( + likeCount = 2, + commentCount = 2, + followerCount = 2, + newBoost = scorePolicy.calculateCreatorNewBoost(LocalDateTime.of(2026, 4, 1, 12, 0), snapshotAt) + ) + val expectedCommentDisabledScore = scorePolicy.calculateCommunityScore( + likeCount = 0, + commentCount = 0, + followerCount = 2, + newBoost = scorePolicy.calculateCreatorNewBoost(LocalDateTime.of(2026, 4, 1, 12, 0), snapshotAt) + ) + assertEquals(expectedPostScore, snapshots[post.id]!!.score, 0.0001) + assertEquals(expectedCommentDisabledScore, snapshots[commentDisabledPost.id]!!.score, 0.0001) + } + + @Test + @DisplayName("인기 커뮤니티 스냅샷은 DB에서 최종 점수를 계산한 뒤 정렬하고 limit을 적용한다") + fun shouldFindPopularCommunitySnapshotsWithDbScoreOrderAndLimit() { + val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0) + val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + val liker = saveMember("community-score-liker", MemberRole.USER) + val oldCreator = saveMember("old-community-score-creator", MemberRole.CREATOR) + val newCreator = saveMember("new-community-score-creator", MemberRole.CREATOR) + saveLiveRoom(oldCreator, LocalDateTime.of(2026, 4, 1, 0, 0), channelName = "old-community") + saveLiveRoom(newCreator, LocalDateTime.of(2026, 5, 20, 0, 0), channelName = "new-community") + val oldPost = saveCommunity(oldCreator, isCommentAvailable = true) + val newPost = saveCommunity(newCreator, isCommentAvailable = true) + val oldLike = saveCommunityLike(liker, oldPost, isActive = true) + val newLike = saveCommunityLike(liker, newPost, isActive = true) + updateCreatedAt("CreatorCommunity", oldPost.id!!, windowStart.plusDays(1)) + updateCreatedAt("CreatorCommunity", newPost.id!!, windowStart.plusDays(1)) + updateCreatedAt("CreatorCommunityLike", oldLike.id!!, windowStart.plusDays(1)) + updateCreatedAt("CreatorCommunityLike", newLike.id!!, windowStart.plusDays(1)) + flushAndClear() + + val snapshots = repository.findPopularCommunitySnapshots(windowStart, snapshotAt, limit = 1) + + val expectedScore = scorePolicy.calculateCommunityScore( + likeCount = 1, + commentCount = 0, + followerCount = 0, + newBoost = scorePolicy.calculateCreatorNewBoost(LocalDateTime.of(2026, 5, 20, 0, 0), snapshotAt) + ) + assertEquals(1, snapshots.size) + assertEquals(RecommendedSectionType.POPULAR_COMMUNITY, snapshots.single().sectionType) + assertEquals(newPost.id, snapshots.single().targetId) + assertEquals(expectedScore, snapshots.single().score, 0.0001) + } + + @Test + @DisplayName("실제 데뷔일이 없는 크리에이터는 최근 응원과 인기 커뮤니티 스냅샷에서 제외한다") + fun shouldExcludeCreatorSnapshotsWithoutActualDebutAt() { + val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0) + val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + val creator = saveMember("creator-without-debut", MemberRole.CREATOR) + val donor = saveMember("donor-without-debut", MemberRole.USER) + val community = saveCommunity(creator, isCommentAvailable = true) + val like = saveCommunityLike(donor, community, isActive = true) + saveUseCanCalculate( + donor, + creator, + CanUsage.CHANNEL_DONATION, + can = 100, + status = UseCanCalculateStatus.RECEIVED, + isRefund = false, + createdAt = windowStart.plusDays(1) + ) + updateCreatedAt("CreatorCommunity", community.id!!, windowStart.plusDays(1)) + updateCreatedAt("CreatorCommunityLike", like.id!!, windowStart.plusDays(1)) + flushAndClear() + + val cheerSnapshots = repository.findCheerCreatorSnapshots(windowStart, snapshotAt, limit = 10) + val communitySnapshots = repository.findPopularCommunitySnapshots(windowStart, snapshotAt, limit = 10) + + assertEquals(emptyList(), cheerSnapshots) + assertEquals(emptyList(), communitySnapshots) + } + + @Test + @DisplayName("최근 응원과 인기 커뮤니티 스냅샷은 Member.createdAt이 아니라 실제 데뷔일을 사용한다") + fun shouldUseActualDebutAtInsteadOfMemberCreatedAtForCreatorSnapshots() { + val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0) + val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + val memberCreatedAt = LocalDateTime.of(2026, 1, 1, 0, 0) + val firstLiveAt = LocalDateTime.of(2026, 5, 10, 12, 0) + val firstContentAt = LocalDateTime.of(2026, 5, 20, 12, 0) + val creator = saveMember("actual-debut-creator", MemberRole.CREATOR) + val donor = saveMember("actual-debut-donor", MemberRole.USER) + saveLiveRoom(creator, firstLiveAt, channelName = "actual-debut-channel") + saveAudioContent(creator, firstContentAt, isActive = true) + saveUseCanCalculate( + donor, + creator, + CanUsage.CHANNEL_DONATION, + can = 100, + status = UseCanCalculateStatus.RECEIVED, + isRefund = false, + createdAt = windowStart.plusDays(1) + ) + val community = saveCommunity(creator, isCommentAvailable = true) + val like = saveCommunityLike(donor, community, isActive = true) + updateCreatedAt("Member", creator.id!!, memberCreatedAt) + updateCreatedAt("CreatorCommunity", community.id!!, windowStart.plusDays(1)) + updateCreatedAt("CreatorCommunityLike", like.id!!, windowStart.plusDays(1)) + flushAndClear() + + val cheerSnapshot = repository.findCheerCreatorSnapshots(windowStart, snapshotAt, limit = 10).single() + val communitySnapshot = repository.findPopularCommunitySnapshots(windowStart, snapshotAt, limit = 10).single() + + val expectedCheerScore = scorePolicy.calculateCheerScore( + donationAmount = 100, + fanTalkCount = 0, + donationCount = 1, + newBoost = scorePolicy.calculateCreatorNewBoost(firstLiveAt, snapshotAt) + ) + val expectedCommunityScore = scorePolicy.calculateCommunityScore( + likeCount = 1, + commentCount = 0, + followerCount = 0, + newBoost = scorePolicy.calculateCreatorNewBoost(firstLiveAt, snapshotAt) + ) + assertEquals(expectedCheerScore, cheerSnapshot.score, 0.0001) + assertEquals(expectedCommunityScore, communitySnapshot.score, 0.0001) + } + + private fun saveMember(nickname: String, role: MemberRole): Member { + val member = Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + role = role + ) + entityManager.persist(member) + return member + } + + private fun saveCharacter(name: String, isActive: Boolean): ChatCharacter { + val character = ChatCharacter( + characterUUID = "$name-uuid", + name = name, + description = "description", + systemPrompt = "system", + isActive = isActive + ) + entityManager.persist(character) + return character + } + + private fun saveChatRoom(sessionId: String): ChatRoom { + val room = ChatRoom(sessionId = sessionId, title = sessionId) + entityManager.persist(room) + return room + } + + private fun saveParticipant( + room: ChatRoom, + type: ParticipantType, + member: Member? = null, + character: ChatCharacter? = null + ): ChatParticipant { + val participant = ChatParticipant(chatRoom = room, participantType = type, member = member, character = character) + entityManager.persist(participant) + return participant + } + + private fun saveMessage(room: ChatRoom, participant: ChatParticipant, message: String, isActive: Boolean): ChatMessage { + val chatMessage = ChatMessage(message = message, chatRoom = room, participant = participant, isActive = isActive) + entityManager.persist(chatMessage) + return chatMessage + } + + private fun saveUseCanCalculate( + donor: Member, + creator: Member?, + usage: CanUsage, + can: Int, + status: UseCanCalculateStatus, + isRefund: Boolean, + createdAt: LocalDateTime + ) { + val useCan = UseCan(canUsage = usage, can = can, rewardCan = 0, isRefund = isRefund) + useCan.member = donor + entityManager.persist(useCan) + val calculate = UseCanCalculate(can = can, paymentGateway = PaymentGateway.PG, status = status) + calculate.useCan = useCan + calculate.recipientCreatorId = creator?.id + entityManager.persist(calculate) + entityManager.flush() + updateCreatedAt("UseCanCalculate", calculate.id!!, createdAt) + } + + private fun saveCreatorCheers(member: Member, creator: Member, isActive: Boolean): CreatorCheers { + val cheers = CreatorCheers(cheers = "cheers", languageCode = "ko", isActive = isActive) + cheers.member = member + cheers.creator = creator + entityManager.persist(cheers) + return cheers + } + + private fun saveCommunity(creator: Member, isCommentAvailable: Boolean): CreatorCommunity { + val community = CreatorCommunity( + content = "content", + price = 0, + isCommentAvailable = isCommentAvailable, + isAdult = false + ) + community.member = creator + entityManager.persist(community) + return community + } + + private fun saveAudioContent(creator: Member, releaseDate: LocalDateTime, isActive: Boolean): AudioContent { + val theme = AudioContentTheme( + theme = "theme-${creator.nickname}-$releaseDate", + image = "theme-${creator.nickname}-$releaseDate.png" + ) + entityManager.persist(theme) + + val content = AudioContent( + title = "content-${creator.nickname}-$releaseDate", + detail = "detail", + languageCode = "ko", + releaseDate = releaseDate + ) + content.member = creator + content.theme = theme + content.isActive = isActive + entityManager.persist(content) + return content + } + + private fun saveLiveRoom(creator: Member, beginDateTime: LocalDateTime, channelName: String?): LiveRoom { + val room = LiveRoom( + title = "live-${creator.nickname}-$beginDateTime", + notice = "notice", + beginDateTime = beginDateTime, + numberOfPeople = 0, + isAdult = false + ) + room.member = creator + room.channelName = channelName + entityManager.persist(room) + return room + } + + private fun saveCommunityLike(member: Member, community: CreatorCommunity, isActive: Boolean): CreatorCommunityLike { + val like = CreatorCommunityLike(isActive = isActive) + like.member = member + like.creatorCommunity = community + entityManager.persist(like) + return like + } + + private fun saveCommunityComment(member: Member, community: CreatorCommunity, isActive: Boolean): CreatorCommunityComment { + val comment = CreatorCommunityComment(comment = "comment", isActive = isActive) + comment.member = member + comment.creatorCommunity = community + entityManager.persist(comment) + return comment + } + + private fun saveFollowing(member: Member, creator: Member, isActive: Boolean): CreatorFollowing { + val following = CreatorFollowing(isActive = isActive) + following.member = member + following.creator = creator + entityManager.persist(following) + return following + } + + private fun updateCreatedAt(entityName: String, id: Long, createdAt: LocalDateTime) { + entityManager.createQuery("update $entityName e set e.createdAt = :createdAt where e.id = :id") + .setParameter("createdAt", createdAt) + .setParameter("id", id) + .executeUpdate() + } + + private fun flushAndClear() { + entityManager.flush() + entityManager.clear() + } +}