feat(recommend): 홈 추천 스냅샷 집계 쿼리를 추가한다
This commit is contained in:
@@ -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<RecommendationSnapshotRecord> {
|
||||||
|
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<RecommendationSnapshotRecord> {
|
||||||
|
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<RecommendationSnapshotRecord> {
|
||||||
|
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<RecommendationSnapshotRecord> {
|
||||||
|
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<Array<Any>>
|
||||||
|
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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<RecommendationSnapshotRecord>
|
||||||
|
|
||||||
|
fun findCheerCreatorSnapshots(
|
||||||
|
windowStart: LocalDateTime,
|
||||||
|
snapshotAt: LocalDateTime,
|
||||||
|
limit: Int
|
||||||
|
): List<RecommendationSnapshotRecord>
|
||||||
|
|
||||||
|
fun findPopularCommunitySnapshots(
|
||||||
|
windowStart: LocalDateTime,
|
||||||
|
snapshotAt: LocalDateTime,
|
||||||
|
limit: Int
|
||||||
|
): List<RecommendationSnapshotRecord>
|
||||||
|
}
|
||||||
@@ -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<Any>(), cheerSnapshots)
|
||||||
|
assertEquals(emptyList<Any>(), 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user