feat(recommend): 홈 추천 스냅샷 집계 쿼리를 추가한다

This commit is contained in:
2026-05-31 00:57:46 +09:00
parent 2edd486524
commit 58e59c5cb4
4 changed files with 923 additions and 0 deletions

View File

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

View File

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

View File

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