feat(recommend): 홈 추천 조회 쿼리를 추가한다
This commit is contained in:
@@ -1,16 +1,427 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence
|
package kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence
|
||||||
|
|
||||||
|
import com.querydsl.core.types.Projections
|
||||||
|
import com.querydsl.core.types.dsl.BooleanExpression
|
||||||
|
import com.querydsl.core.types.dsl.Expressions
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.QChatCharacter.chatCharacter
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.QOriginalWork
|
||||||
|
import kr.co.vividnext.sodalive.chat.room.ParticipantType
|
||||||
|
import kr.co.vividnext.sodalive.chat.room.QChatMessage.chatMessage
|
||||||
|
import kr.co.vividnext.sodalive.chat.room.QChatParticipant.chatParticipant
|
||||||
|
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
|
||||||
|
import kr.co.vividnext.sodalive.content.main.banner.QAudioContentBanner.audioContentBanner
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
|
||||||
|
import kr.co.vividnext.sodalive.event.QEvent.event
|
||||||
|
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommunity.creatorCommunity
|
||||||
|
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.QCreatorCommunityComment.creatorCommunityComment
|
||||||
|
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.QCreatorCommunityLike.creatorCommunityLike
|
||||||
|
import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom
|
||||||
|
import kr.co.vividnext.sodalive.member.QMember
|
||||||
|
import kr.co.vividnext.sodalive.member.QMember.member
|
||||||
import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendationScoreSpec
|
import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendationScoreSpec
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedActivityType
|
||||||
import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedSectionType
|
import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedSectionType
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeAiCharacterRecommendationRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeBannerRecommendationRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeCheerCreatorRecommendationRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeFirstAudioContentRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeLiveRecommendationRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomePopularCommunityRecommendationRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecentDebutCreatorRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecentlyActiveCreatorRecord
|
||||||
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotRecord
|
import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotRecord
|
||||||
import org.springframework.stereotype.Repository
|
import org.springframework.stereotype.Repository
|
||||||
|
import java.sql.Timestamp
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import javax.persistence.EntityManager
|
import javax.persistence.EntityManager
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
class DefaultHomeRecommendationQueryRepository(
|
class DefaultHomeRecommendationQueryRepository(
|
||||||
|
private val queryFactory: JPAQueryFactory,
|
||||||
private val entityManager: EntityManager
|
private val entityManager: EntityManager
|
||||||
) : HomeRecommendationQueryRepository {
|
) : HomeRecommendationQueryRepository {
|
||||||
|
override fun findLiveRecommendations(limit: Int): List<HomeLiveRecommendationRecord> {
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
Projections.constructor(
|
||||||
|
HomeLiveRecommendationRecord::class.java,
|
||||||
|
liveRoom.id,
|
||||||
|
member.id,
|
||||||
|
member.nickname,
|
||||||
|
member.profileImage,
|
||||||
|
liveRoom.title,
|
||||||
|
liveRoom.coverImage,
|
||||||
|
liveRoom.beginDateTime,
|
||||||
|
liveRoom.channelName
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(liveRoom)
|
||||||
|
.join(liveRoom.member, member)
|
||||||
|
.where(
|
||||||
|
liveRoom.isActive.isTrue,
|
||||||
|
liveRoom.channelName.isNotNull,
|
||||||
|
liveRoom.channelName.isNotEmpty,
|
||||||
|
member.isActive.isTrue
|
||||||
|
)
|
||||||
|
.orderBy(liveRoom.beginDateTime.desc(), liveRoom.id.desc())
|
||||||
|
.limit(limit.toLong())
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findHomeBanners(limit: Int): List<HomeBannerRecommendationRecord> {
|
||||||
|
val bannerCreator = QMember("bannerCreator")
|
||||||
|
val seriesOwner = QMember("seriesOwner")
|
||||||
|
val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')")
|
||||||
|
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
Projections.constructor(
|
||||||
|
HomeBannerRecommendationRecord::class.java,
|
||||||
|
audioContentBanner.id,
|
||||||
|
audioContentBanner.type.stringValue(),
|
||||||
|
audioContentBanner.thumbnailImage,
|
||||||
|
event.id,
|
||||||
|
bannerCreator.id,
|
||||||
|
series.id,
|
||||||
|
audioContentBanner.link,
|
||||||
|
audioContentBanner.orders,
|
||||||
|
randomTieBreaker
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(audioContentBanner)
|
||||||
|
.leftJoin(audioContentBanner.event, event)
|
||||||
|
.leftJoin(audioContentBanner.creator, bannerCreator)
|
||||||
|
.leftJoin(audioContentBanner.series, series)
|
||||||
|
.leftJoin(series.member, seriesOwner)
|
||||||
|
.where(
|
||||||
|
audioContentBanner.isActive.isTrue,
|
||||||
|
audioContentBanner.tab.isNull,
|
||||||
|
activeBannerTargetCondition(bannerCreator, seriesOwner)
|
||||||
|
)
|
||||||
|
.orderBy(audioContentBanner.orders.asc(), randomTieBreaker.asc())
|
||||||
|
.limit(limit.toLong())
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findRecentlyActiveCreators(limit: Int): List<RecentlyActiveCreatorRecord> {
|
||||||
|
val sql = """
|
||||||
|
select ranked.creator_id,
|
||||||
|
ranked.creator_nickname,
|
||||||
|
ranked.creator_profile_image,
|
||||||
|
ranked.activity_type,
|
||||||
|
ranked.activity_at,
|
||||||
|
ranked.target_id
|
||||||
|
from (
|
||||||
|
select activities.*,
|
||||||
|
row_number() over (
|
||||||
|
partition by activities.creator_id
|
||||||
|
order by activities.activity_at desc, activities.target_sort_id desc
|
||||||
|
) as creator_rank
|
||||||
|
from (
|
||||||
|
select m.id as creator_id,
|
||||||
|
m.nickname as creator_nickname,
|
||||||
|
m.profile_image as creator_profile_image,
|
||||||
|
'LIVE' as activity_type,
|
||||||
|
lr.begin_date_time as activity_at,
|
||||||
|
null as target_id,
|
||||||
|
lr.id as target_sort_id
|
||||||
|
from live_room lr
|
||||||
|
join member m on m.id = lr.member_id
|
||||||
|
where lr.is_active = true
|
||||||
|
and lr.channel_name is not null
|
||||||
|
and lr.channel_name <> ''
|
||||||
|
and m.is_active = true
|
||||||
|
union all
|
||||||
|
select m.id as creator_id,
|
||||||
|
m.nickname as creator_nickname,
|
||||||
|
m.profile_image as creator_profile_image,
|
||||||
|
case when act.theme = :liveReplayTheme then 'LIVE_REPLAY' else 'AUDIO' end as activity_type,
|
||||||
|
ac.release_date as activity_at,
|
||||||
|
ac.id as target_id,
|
||||||
|
ac.id as target_sort_id
|
||||||
|
from content ac
|
||||||
|
join member m on m.id = ac.member_id
|
||||||
|
join content_theme act on act.id = ac.theme_id
|
||||||
|
where ac.is_active = true
|
||||||
|
and ac.release_date is not null
|
||||||
|
and m.is_active = true
|
||||||
|
union all
|
||||||
|
select m.id as creator_id,
|
||||||
|
m.nickname as creator_nickname,
|
||||||
|
m.profile_image as creator_profile_image,
|
||||||
|
'COMMUNITY' as activity_type,
|
||||||
|
cc.created_at as activity_at,
|
||||||
|
cc.id as target_id,
|
||||||
|
cc.id as target_sort_id
|
||||||
|
from creator_community cc
|
||||||
|
join member m on m.id = cc.member_id
|
||||||
|
where cc.is_active = true
|
||||||
|
and m.is_active = true
|
||||||
|
) activities
|
||||||
|
) ranked
|
||||||
|
where ranked.creator_rank = 1
|
||||||
|
order by ranked.activity_at desc, ranked.target_sort_id desc
|
||||||
|
limit :limit
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val query = entityManager.createNativeQuery(sql)
|
||||||
|
.setParameter("liveReplayTheme", LIVE_REPLAY_THEME)
|
||||||
|
.setParameter("limit", limit)
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val rows = query.resultList as List<Array<Any?>>
|
||||||
|
|
||||||
|
return rows.map { row ->
|
||||||
|
RecentlyActiveCreatorRecord(
|
||||||
|
creatorId = (row[0] as Number).toLong(),
|
||||||
|
creatorNickname = row[1] as String,
|
||||||
|
creatorProfileImage = row[2] as String?,
|
||||||
|
activityType = RecommendedActivityType.valueOf(row[3] as String),
|
||||||
|
activityAt = toLocalDateTime(row[4]),
|
||||||
|
targetId = (row[5] as Number?)?.toLong()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findRecentDebutCreators(now: LocalDateTime, limit: Int): List<RecentDebutCreatorRecord> {
|
||||||
|
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 <= :now
|
||||||
|
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.channel_name <> ''
|
||||||
|
and lr.begin_date_time <= :now
|
||||||
|
) debut_events
|
||||||
|
group by debut_events.creator_id
|
||||||
|
),
|
||||||
|
follow_stats as (
|
||||||
|
select cf.creator_id as creator_id, count(distinct cf.id) as follow_increase
|
||||||
|
from creator_following cf
|
||||||
|
where cf.is_active = true
|
||||||
|
and cf.created_at >= :window7Start
|
||||||
|
and cf.created_at <= :now
|
||||||
|
group by cf.creator_id
|
||||||
|
),
|
||||||
|
content_stats as (
|
||||||
|
select activity.creator_id as creator_id, count(activity.activity_id) as content_activity_score
|
||||||
|
from (
|
||||||
|
select ac.member_id as creator_id, ac.id as activity_id
|
||||||
|
from content ac
|
||||||
|
where ac.is_active = true
|
||||||
|
and ac.release_date is not null
|
||||||
|
and ac.release_date >= :window30Start
|
||||||
|
and ac.release_date <= :now
|
||||||
|
union all
|
||||||
|
select lr.member_id as creator_id, lr.id as activity_id
|
||||||
|
from live_room lr
|
||||||
|
where lr.is_active = true
|
||||||
|
and lr.channel_name is not null
|
||||||
|
and lr.channel_name <> ''
|
||||||
|
and lr.begin_date_time >= :window30Start
|
||||||
|
and lr.begin_date_time <= :now
|
||||||
|
) activity
|
||||||
|
group by activity.creator_id
|
||||||
|
),
|
||||||
|
communication_stats as (
|
||||||
|
select communication.creator_id as creator_id, count(communication.activity_id) as communication_score
|
||||||
|
from (
|
||||||
|
select cc.member_id as creator_id, cc.id as activity_id
|
||||||
|
from creator_community cc
|
||||||
|
where cc.is_active = true
|
||||||
|
and cc.created_at >= :window7Start
|
||||||
|
and cc.created_at <= :now
|
||||||
|
union all
|
||||||
|
select cc.member_id as creator_id, ccc.id as activity_id
|
||||||
|
from creator_community_comment ccc
|
||||||
|
join creator_community cc on cc.id = ccc.creator_community_id
|
||||||
|
where ccc.is_active = true
|
||||||
|
and cc.is_active = true
|
||||||
|
and ccc.created_at >= :window7Start
|
||||||
|
and ccc.created_at <= :now
|
||||||
|
union all
|
||||||
|
select cc.member_id as creator_id, ccl.id as activity_id
|
||||||
|
from creator_community_like ccl
|
||||||
|
join creator_community cc on cc.id = ccl.creator_community_id
|
||||||
|
where ccl.is_active = true
|
||||||
|
and cc.is_active = true
|
||||||
|
and ccl.created_at >= :window7Start
|
||||||
|
and ccl.created_at <= :now
|
||||||
|
union all
|
||||||
|
select ac.member_id as creator_id, acc.id as activity_id
|
||||||
|
from content_comment acc
|
||||||
|
join content ac on ac.id = acc.content_id
|
||||||
|
where acc.is_active = true
|
||||||
|
and ac.is_active = true
|
||||||
|
and acc.created_at >= :window7Start
|
||||||
|
and acc.created_at <= :now
|
||||||
|
union all
|
||||||
|
select ac.member_id as creator_id, acl.id as activity_id
|
||||||
|
from content_like acl
|
||||||
|
join content ac on ac.id = acl.content_id
|
||||||
|
where acl.is_active = true
|
||||||
|
and ac.is_active = true
|
||||||
|
and acl.created_at >= :window7Start
|
||||||
|
and acl.created_at <= :now
|
||||||
|
) communication
|
||||||
|
group by communication.creator_id
|
||||||
|
)
|
||||||
|
select m.id as creator_id,
|
||||||
|
m.nickname as creator_nickname,
|
||||||
|
m.profile_image as creator_profile_image,
|
||||||
|
cd.debut_at as debut_at,
|
||||||
|
((coalesce(fs.follow_increase, 0) * ${RecommendationScoreSpec.DEBUT_FOLLOW_INCREASE_WEIGHT} +
|
||||||
|
coalesce(cs.content_activity_score, 0) * ${RecommendationScoreSpec.DEBUT_CONTENT_ACTIVITY_WEIGHT} +
|
||||||
|
coalesce(cms.communication_score, 0) * ${RecommendationScoreSpec.DEBUT_COMMUNICATION_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 follow_stats fs on fs.creator_id = m.id
|
||||||
|
left join content_stats cs on cs.creator_id = m.id
|
||||||
|
left join communication_stats cms on cms.creator_id = m.id
|
||||||
|
where m.is_active = true
|
||||||
|
and cd.debut_at >= :boost30Start
|
||||||
|
and cd.debut_at <= :now
|
||||||
|
order by score desc, random_tie_breaker asc
|
||||||
|
limit :limit
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val query = entityManager.createNativeQuery(sql)
|
||||||
|
.setRecommendationQueryParameters(now, limit)
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val rows = query.resultList as List<Array<Any?>>
|
||||||
|
|
||||||
|
return rows.map { row ->
|
||||||
|
RecentDebutCreatorRecord(
|
||||||
|
creatorId = (row[0] as Number).toLong(),
|
||||||
|
creatorNickname = row[1] as String,
|
||||||
|
creatorProfileImage = row[2] as String?,
|
||||||
|
debutAt = toLocalDateTime(row[3]),
|
||||||
|
score = (row[4] as Number).toDouble(),
|
||||||
|
randomTieBreaker = (row[5] as Number).toDouble()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findFirstAudioContents(now: LocalDateTime, limit: Int): List<HomeFirstAudioContentRecord> {
|
||||||
|
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 <= :now
|
||||||
|
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.channel_name <> ''
|
||||||
|
and lr.begin_date_time <= :now
|
||||||
|
) debut_events
|
||||||
|
group by debut_events.creator_id
|
||||||
|
),
|
||||||
|
ranked_uploads as (
|
||||||
|
select ac.id as content_id,
|
||||||
|
ac.member_id as creator_id,
|
||||||
|
ac.title as title,
|
||||||
|
ac.cover_image as cover_image,
|
||||||
|
ac.release_date as release_date,
|
||||||
|
ac.is_active as is_active,
|
||||||
|
row_number() over (
|
||||||
|
partition by ac.member_id
|
||||||
|
order by ac.created_at asc, ac.release_date asc, ac.id asc
|
||||||
|
) as upload_rank
|
||||||
|
from content ac
|
||||||
|
where ac.release_date is not null
|
||||||
|
),
|
||||||
|
eligible_contents as (
|
||||||
|
select ranked_uploads.*,
|
||||||
|
row_number() over (
|
||||||
|
partition by ranked_uploads.creator_id
|
||||||
|
order by ranked_uploads.upload_rank asc
|
||||||
|
) as active_rank
|
||||||
|
from ranked_uploads
|
||||||
|
where ranked_uploads.upload_rank <= 3
|
||||||
|
and ranked_uploads.is_active = true
|
||||||
|
and ranked_uploads.release_date <= :now
|
||||||
|
)
|
||||||
|
select ec.content_id as content_id,
|
||||||
|
m.id as creator_id,
|
||||||
|
m.nickname as creator_nickname,
|
||||||
|
m.profile_image as creator_profile_image,
|
||||||
|
ec.title as title,
|
||||||
|
ec.cover_image as cover_image,
|
||||||
|
ec.release_date as release_date,
|
||||||
|
case
|
||||||
|
when ec.release_date >= :recency3Start then 100
|
||||||
|
when ec.release_date >= :recency7Start then 80
|
||||||
|
when ec.release_date >= :recency14Start then 60
|
||||||
|
when ec.release_date >= :recency21Start then 40
|
||||||
|
when ec.release_date >= :boost30Start then 20
|
||||||
|
else 0
|
||||||
|
end as recency_score,
|
||||||
|
rand() as random_tie_breaker
|
||||||
|
from eligible_contents ec
|
||||||
|
join member m on m.id = ec.creator_id
|
||||||
|
join creator_debut cd on cd.creator_id = ec.creator_id
|
||||||
|
where ec.active_rank = 1
|
||||||
|
and m.is_active = true
|
||||||
|
and cd.debut_at >= :boost30Start
|
||||||
|
and cd.debut_at <= :now
|
||||||
|
and ec.release_date >= :boost30Start
|
||||||
|
order by recency_score desc, random_tie_breaker asc
|
||||||
|
limit :limit
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val query = entityManager.createNativeQuery(sql)
|
||||||
|
.setParameter("now", now)
|
||||||
|
.setParameter("limit", limit)
|
||||||
|
.setParameter(
|
||||||
|
"boost30Start",
|
||||||
|
now.toLocalDate().minusDays(RecommendationScoreSpec.NEW_BOOST_30_DAY_LIMIT).atStartOfDay()
|
||||||
|
)
|
||||||
|
.setParameter("recency3Start", now.toLocalDate().minusDays(3).atStartOfDay())
|
||||||
|
.setParameter("recency7Start", now.toLocalDate().minusDays(7).atStartOfDay())
|
||||||
|
.setParameter("recency14Start", now.toLocalDate().minusDays(14).atStartOfDay())
|
||||||
|
.setParameter("recency21Start", now.toLocalDate().minusDays(21).atStartOfDay())
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val rows = query.resultList as List<Array<Any?>>
|
||||||
|
|
||||||
|
return rows.map { row ->
|
||||||
|
HomeFirstAudioContentRecord(
|
||||||
|
contentId = (row[0] as Number).toLong(),
|
||||||
|
creatorId = (row[1] as Number).toLong(),
|
||||||
|
creatorNickname = row[2] as String,
|
||||||
|
creatorProfileImage = row[3] as String?,
|
||||||
|
title = row[4] as String,
|
||||||
|
coverImage = row[5] as String?,
|
||||||
|
releaseDate = toLocalDateTime(row[6]),
|
||||||
|
recencyScore = (row[7] as Number).toInt(),
|
||||||
|
randomTieBreaker = (row[8] as Number).toDouble()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun findAiCharacterSnapshots(
|
override fun findAiCharacterSnapshots(
|
||||||
windowStart: LocalDateTime,
|
windowStart: LocalDateTime,
|
||||||
snapshotAt: LocalDateTime,
|
snapshotAt: LocalDateTime,
|
||||||
@@ -102,6 +513,7 @@ class DefaultHomeRecommendationQueryRepository(
|
|||||||
from live_room lr
|
from live_room lr
|
||||||
where lr.is_active = true
|
where lr.is_active = true
|
||||||
and lr.channel_name is not null
|
and lr.channel_name is not null
|
||||||
|
and lr.channel_name <> ''
|
||||||
and lr.begin_date_time <= :snapshotAt
|
and lr.begin_date_time <= :snapshotAt
|
||||||
) debut_events
|
) debut_events
|
||||||
group by debut_events.creator_id
|
group by debut_events.creator_id
|
||||||
@@ -171,6 +583,7 @@ class DefaultHomeRecommendationQueryRepository(
|
|||||||
from live_room lr
|
from live_room lr
|
||||||
where lr.is_active = true
|
where lr.is_active = true
|
||||||
and lr.channel_name is not null
|
and lr.channel_name is not null
|
||||||
|
and lr.channel_name <> ''
|
||||||
and lr.begin_date_time <= :snapshotAt
|
and lr.begin_date_time <= :snapshotAt
|
||||||
) debut_events
|
) debut_events
|
||||||
group by debut_events.creator_id
|
group by debut_events.creator_id
|
||||||
@@ -227,6 +640,107 @@ class DefaultHomeRecommendationQueryRepository(
|
|||||||
return executeSnapshotQuery(sql, RecommendedSectionType.POPULAR_COMMUNITY, windowStart, snapshotAt, limit)
|
return executeSnapshotQuery(sql, RecommendedSectionType.POPULAR_COMMUNITY, windowStart, snapshotAt, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun findAiCharacterRecommendationDetails(
|
||||||
|
characterIds: List<Long>
|
||||||
|
): List<HomeAiCharacterRecommendationRecord> {
|
||||||
|
if (characterIds.isEmpty()) return emptyList()
|
||||||
|
val linkedOriginalWork = QOriginalWork("linkedOriginalWork")
|
||||||
|
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
Projections.constructor(
|
||||||
|
HomeAiCharacterRecommendationRecord::class.java,
|
||||||
|
chatCharacter.id,
|
||||||
|
chatCharacter.name,
|
||||||
|
chatCharacter.description,
|
||||||
|
chatMessage.id.count(),
|
||||||
|
linkedOriginalWork.title
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(chatCharacter)
|
||||||
|
.leftJoin(chatCharacter.originalWork, linkedOriginalWork).on(linkedOriginalWork.isDeleted.isFalse)
|
||||||
|
.leftJoin(chatParticipant).on(
|
||||||
|
chatParticipant.character.id.eq(chatCharacter.id),
|
||||||
|
chatParticipant.participantType.eq(ParticipantType.CHARACTER),
|
||||||
|
chatParticipant.isActive.isTrue
|
||||||
|
)
|
||||||
|
.leftJoin(chatMessage).on(
|
||||||
|
chatMessage.participant.id.eq(chatParticipant.id),
|
||||||
|
chatMessage.isActive.isTrue
|
||||||
|
)
|
||||||
|
.where(chatCharacter.isActive.isTrue, chatCharacter.id.`in`(characterIds))
|
||||||
|
.groupBy(chatCharacter.id, chatCharacter.name, chatCharacter.description, linkedOriginalWork.title)
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findCheerCreatorRecommendationDetails(
|
||||||
|
creatorIds: List<Long>
|
||||||
|
): List<HomeCheerCreatorRecommendationRecord> {
|
||||||
|
if (creatorIds.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
Projections.constructor(
|
||||||
|
HomeCheerCreatorRecommendationRecord::class.java,
|
||||||
|
member.id,
|
||||||
|
member.nickname,
|
||||||
|
member.profileImage
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(member)
|
||||||
|
.where(member.isActive.isTrue, member.id.`in`(creatorIds))
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findPopularCommunityRecommendationDetails(
|
||||||
|
communityIds: List<Long>,
|
||||||
|
includeAdultCommunities: Boolean
|
||||||
|
): List<HomePopularCommunityRecommendationRecord> {
|
||||||
|
if (communityIds.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
Projections.constructor(
|
||||||
|
HomePopularCommunityRecommendationRecord::class.java,
|
||||||
|
creatorCommunity.id,
|
||||||
|
member.id,
|
||||||
|
member.nickname,
|
||||||
|
member.profileImage,
|
||||||
|
creatorCommunity.content,
|
||||||
|
creatorCommunity.createdAt,
|
||||||
|
creatorCommunityLike.id.countDistinct(),
|
||||||
|
creatorCommunityComment.id.countDistinct()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(creatorCommunity)
|
||||||
|
.join(creatorCommunity.member, member)
|
||||||
|
.leftJoin(creatorCommunityLike).on(
|
||||||
|
creatorCommunityLike.creatorCommunity.id.eq(creatorCommunity.id),
|
||||||
|
creatorCommunityLike.isActive.isTrue
|
||||||
|
)
|
||||||
|
.leftJoin(creatorCommunityComment).on(
|
||||||
|
creatorCommunityComment.creatorCommunity.id.eq(creatorCommunity.id),
|
||||||
|
creatorCommunityComment.isActive.isTrue
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
creatorCommunity.isActive.isTrue,
|
||||||
|
member.isActive.isTrue,
|
||||||
|
creatorCommunity.price.eq(0),
|
||||||
|
creatorCommunity.isFixed.isFalse,
|
||||||
|
includeAdultCommunityCondition(includeAdultCommunities),
|
||||||
|
creatorCommunity.id.`in`(communityIds)
|
||||||
|
)
|
||||||
|
.groupBy(
|
||||||
|
creatorCommunity.id,
|
||||||
|
member.id,
|
||||||
|
member.nickname,
|
||||||
|
member.profileImage,
|
||||||
|
creatorCommunity.content,
|
||||||
|
creatorCommunity.createdAt
|
||||||
|
)
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
private fun executeSnapshotQuery(
|
private fun executeSnapshotQuery(
|
||||||
sql: String,
|
sql: String,
|
||||||
sectionType: RecommendedSectionType,
|
sectionType: RecommendedSectionType,
|
||||||
@@ -264,4 +778,56 @@ class DefaultHomeRecommendationQueryRepository(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun activeBannerTargetCondition(
|
||||||
|
bannerCreator: QMember,
|
||||||
|
seriesOwner: QMember
|
||||||
|
): BooleanExpression {
|
||||||
|
return audioContentBanner.type.eq(AudioContentBannerType.LINK)
|
||||||
|
.or(audioContentBanner.type.eq(AudioContentBannerType.EVENT).and(event.isActive.isTrue))
|
||||||
|
.or(audioContentBanner.type.eq(AudioContentBannerType.CREATOR).and(bannerCreator.isActive.isTrue))
|
||||||
|
.or(
|
||||||
|
audioContentBanner.type.eq(AudioContentBannerType.SERIES)
|
||||||
|
.and(series.isActive.isTrue)
|
||||||
|
.and(seriesOwner.isActive.isTrue)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun includeAdultCommunityCondition(includeAdultCommunities: Boolean): BooleanExpression? {
|
||||||
|
return if (includeAdultCommunities) null else creatorCommunity.isAdult.isFalse
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun javax.persistence.Query.setRecommendationQueryParameters(
|
||||||
|
now: LocalDateTime,
|
||||||
|
limit: Int
|
||||||
|
): javax.persistence.Query {
|
||||||
|
return setParameter("now", now)
|
||||||
|
.setParameter("window7Start", now.toLocalDate().minusDays(7).atStartOfDay())
|
||||||
|
.setParameter("window30Start", now.toLocalDate().minusDays(30).atStartOfDay())
|
||||||
|
.setParameter("limit", limit)
|
||||||
|
.setParameter(
|
||||||
|
"boost10Start",
|
||||||
|
now.toLocalDate().minusDays(RecommendationScoreSpec.NEW_BOOST_10_DAY_LIMIT).atStartOfDay()
|
||||||
|
)
|
||||||
|
.setParameter(
|
||||||
|
"boost20Start",
|
||||||
|
now.toLocalDate().minusDays(RecommendationScoreSpec.NEW_BOOST_20_DAY_LIMIT).atStartOfDay()
|
||||||
|
)
|
||||||
|
.setParameter(
|
||||||
|
"boost30Start",
|
||||||
|
now.toLocalDate().minusDays(RecommendationScoreSpec.NEW_BOOST_30_DAY_LIMIT).atStartOfDay()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toLocalDateTime(value: Any?): LocalDateTime {
|
||||||
|
return when (value) {
|
||||||
|
is LocalDateTime -> value
|
||||||
|
is Timestamp -> value.toLocalDateTime()
|
||||||
|
else -> error("Unsupported LocalDateTime value: $value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val LIVE_REPLAY_THEME = "다시듣기"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,43 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence
|
package kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence
|
||||||
|
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
|
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
|
||||||
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||||
import kr.co.vividnext.sodalive.can.use.CanUsage
|
import kr.co.vividnext.sodalive.can.use.CanUsage
|
||||||
import kr.co.vividnext.sodalive.can.use.UseCan
|
import kr.co.vividnext.sodalive.can.use.UseCan
|
||||||
import kr.co.vividnext.sodalive.can.use.UseCanCalculate
|
import kr.co.vividnext.sodalive.can.use.UseCanCalculate
|
||||||
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
|
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
|
||||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
||||||
import kr.co.vividnext.sodalive.chat.room.ChatMessage
|
import kr.co.vividnext.sodalive.chat.room.ChatMessage
|
||||||
import kr.co.vividnext.sodalive.chat.room.ChatParticipant
|
import kr.co.vividnext.sodalive.chat.room.ChatParticipant
|
||||||
import kr.co.vividnext.sodalive.chat.room.ChatRoom
|
import kr.co.vividnext.sodalive.chat.room.ChatRoom
|
||||||
import kr.co.vividnext.sodalive.chat.room.ParticipantType
|
import kr.co.vividnext.sodalive.chat.room.ParticipantType
|
||||||
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||||
import kr.co.vividnext.sodalive.content.AudioContent
|
import kr.co.vividnext.sodalive.content.AudioContent
|
||||||
|
import kr.co.vividnext.sodalive.content.comment.AudioContentComment
|
||||||
|
import kr.co.vividnext.sodalive.content.like.AudioContentLike
|
||||||
|
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner
|
||||||
|
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
|
||||||
|
import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTab
|
||||||
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
|
||||||
|
import kr.co.vividnext.sodalive.event.Event
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers
|
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.CreatorCommunity
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityComment
|
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.explorer.profile.creatorCommunity.like.CreatorCommunityLike
|
||||||
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
import kr.co.vividnext.sodalive.live.room.LiveRoom
|
import kr.co.vividnext.sodalive.live.room.LiveRoom
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
|
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.RecommendationScorePolicy
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedActivityType
|
||||||
import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedSectionType
|
import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedSectionType
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeAiCharacterRecommendationRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeCheerCreatorRecommendationRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommend.port.out.HomePopularCommunityRecommendationRecord
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.DisplayName
|
import org.junit.jupiter.api.DisplayName
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
@@ -40,11 +55,201 @@ import javax.persistence.EntityManager
|
|||||||
)
|
)
|
||||||
@Import(QueryDslConfig::class)
|
@Import(QueryDslConfig::class)
|
||||||
class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
||||||
private val entityManager: EntityManager
|
private val entityManager: EntityManager,
|
||||||
|
queryFactory: JPAQueryFactory
|
||||||
) {
|
) {
|
||||||
private val repository = DefaultHomeRecommendationQueryRepository(entityManager)
|
private val repository = DefaultHomeRecommendationQueryRepository(queryFactory, entityManager)
|
||||||
private val scorePolicy = RecommendationScorePolicy()
|
private val scorePolicy = RecommendationScorePolicy()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("라이브 추천은 활성 크리에이터의 진행 라이브를 최신순 최대 20개 조회한다")
|
||||||
|
fun shouldFindLatestLiveRecommendationsWithLimit() {
|
||||||
|
val baseAt = LocalDateTime.of(2026, 5, 31, 10, 0)
|
||||||
|
val activeCreator = saveMember("live-active", MemberRole.CREATOR)
|
||||||
|
val inactiveCreator = saveMember("live-inactive", MemberRole.CREATOR, isActive = false)
|
||||||
|
val oldLive = saveLiveRoom(activeCreator, baseAt.minusHours(1), channelName = "old-live")
|
||||||
|
val latestLive = saveLiveRoom(activeCreator, baseAt, channelName = "latest-live")
|
||||||
|
saveLiveRoom(activeCreator, baseAt.plusHours(1), channelName = null)
|
||||||
|
saveLiveRoom(inactiveCreator, baseAt.plusHours(2), channelName = "inactive-creator-live")
|
||||||
|
repeat(21) { index ->
|
||||||
|
saveLiveRoom(activeCreator, baseAt.plusDays(1).plusMinutes(index.toLong()), channelName = "limit-live-$index")
|
||||||
|
}
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val lives = repository.findLiveRecommendations(limit = 20)
|
||||||
|
|
||||||
|
assertEquals(20, lives.size)
|
||||||
|
assertEquals(baseAt.plusDays(1).plusMinutes(20), lives.first().beginDateTime)
|
||||||
|
assertEquals(baseAt.plusDays(1).plusMinutes(1), lives.last().beginDateTime)
|
||||||
|
assertEquals(false, lives.any { it.liveRoomId == oldLive.id })
|
||||||
|
assertEquals(false, lives.any { it.liveRoomId == latestLive.id })
|
||||||
|
assertEquals(false, lives.any { it.creatorId == inactiveCreator.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("홈 배너는 활성 배너를 orders 오름차순 최대 20개로 조회하고 동일 orders는 랜덤 tie-breaker를 적용한다")
|
||||||
|
fun shouldFindActiveHomeBannersWithOrderLimitAndRandomTieBreaker() {
|
||||||
|
val creator = saveMember("banner-creator", MemberRole.CREATOR)
|
||||||
|
val event = saveEvent("event-banner")
|
||||||
|
val laterBanner = saveBanner("later.png", AudioContentBannerType.CREATOR, orders = 2, isActive = true, creator = creator)
|
||||||
|
val sameOrderBanner1 = saveBanner(
|
||||||
|
"same-1.png",
|
||||||
|
AudioContentBannerType.LINK,
|
||||||
|
orders = 1,
|
||||||
|
isActive = true,
|
||||||
|
link = "https://same-1.test"
|
||||||
|
)
|
||||||
|
val sameOrderBanner2 = saveBanner(
|
||||||
|
"same-2.png",
|
||||||
|
AudioContentBannerType.EVENT,
|
||||||
|
orders = 1,
|
||||||
|
isActive = true,
|
||||||
|
event = event
|
||||||
|
)
|
||||||
|
saveBanner("inactive.png", AudioContentBannerType.LINK, orders = 0, isActive = false, link = "https://inactive.test")
|
||||||
|
repeat(20) { index ->
|
||||||
|
saveBanner(
|
||||||
|
"limit-$index.png",
|
||||||
|
AudioContentBannerType.LINK,
|
||||||
|
orders = 3 + index,
|
||||||
|
isActive = true,
|
||||||
|
link = "https://limit-$index.test"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val banners = repository.findHomeBanners(limit = 20)
|
||||||
|
|
||||||
|
assertEquals(20, banners.size)
|
||||||
|
assertEquals(listOf(1, 1, 2), banners.take(3).map { it.orders })
|
||||||
|
assertEquals(setOf(sameOrderBanner1.id, sameOrderBanner2.id), banners.take(2).map { it.bannerId }.toSet())
|
||||||
|
assertEquals(true, banners.take(2).zipWithNext().all { it.first.randomTieBreaker <= it.second.randomTieBreaker })
|
||||||
|
assertEquals(laterBanner.id, banners[2].bannerId)
|
||||||
|
assertEquals(creator.id, banners[2].creatorId)
|
||||||
|
assertEquals(event.id, banners.take(2).first { it.type == AudioContentBannerType.EVENT.name }.eventId)
|
||||||
|
assertEquals(false, banners.any { it.thumbnailImage == "inactive.png" })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("홈 배너는 기존 홈 배너처럼 탭 전용 배너를 제외한다")
|
||||||
|
fun shouldExcludeTabSpecificBannersFromHomeBanners() {
|
||||||
|
val tab = saveMainTab("tab-banner")
|
||||||
|
val homeBanner = saveBanner(
|
||||||
|
"home-banner.png",
|
||||||
|
AudioContentBannerType.LINK,
|
||||||
|
orders = 1,
|
||||||
|
isActive = true,
|
||||||
|
link = "https://home-banner.test"
|
||||||
|
)
|
||||||
|
saveBanner(
|
||||||
|
"tab-banner.png",
|
||||||
|
AudioContentBannerType.LINK,
|
||||||
|
orders = 2,
|
||||||
|
isActive = true,
|
||||||
|
tab = tab,
|
||||||
|
link = "https://tab-banner.test"
|
||||||
|
)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val banners = repository.findHomeBanners(limit = 20)
|
||||||
|
|
||||||
|
assertEquals(listOf(homeBanner.id), banners.map { it.bannerId })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("홈 배너는 비활성 대상 엔티티를 제외하고 LINK는 배너 자체 활성 상태만으로 조회한다")
|
||||||
|
fun shouldExcludeHomeBannersWithInactiveTargetsExceptLink() {
|
||||||
|
val activeCreator = saveMember("banner-active-creator", MemberRole.CREATOR)
|
||||||
|
val inactiveCreator = saveMember("banner-inactive-creator", MemberRole.CREATOR, isActive = false)
|
||||||
|
val inactiveSeriesOwner = saveMember("banner-inactive-series-owner", MemberRole.CREATOR, isActive = false)
|
||||||
|
val activeEvent = saveEvent("active-event-banner")
|
||||||
|
val inactiveEvent = saveEvent("inactive-event-banner", isActive = false)
|
||||||
|
val activeSeries = saveSeries("active-series-banner", activeCreator, isActive = true)
|
||||||
|
val inactiveSeries = saveSeries("inactive-series-banner", activeCreator, isActive = false)
|
||||||
|
val inactiveOwnerSeries = saveSeries("inactive-owner-series-banner", inactiveSeriesOwner, isActive = true)
|
||||||
|
val activeEventBanner = saveBanner(
|
||||||
|
"active-event.png",
|
||||||
|
AudioContentBannerType.EVENT,
|
||||||
|
orders = 1,
|
||||||
|
isActive = true,
|
||||||
|
event = activeEvent
|
||||||
|
)
|
||||||
|
val activeCreatorBanner = saveBanner(
|
||||||
|
"active-creator.png",
|
||||||
|
AudioContentBannerType.CREATOR,
|
||||||
|
orders = 2,
|
||||||
|
isActive = true,
|
||||||
|
creator = activeCreator
|
||||||
|
)
|
||||||
|
val activeSeriesBanner = saveBanner(
|
||||||
|
"active-series.png",
|
||||||
|
AudioContentBannerType.SERIES,
|
||||||
|
orders = 3,
|
||||||
|
isActive = true,
|
||||||
|
series = activeSeries
|
||||||
|
)
|
||||||
|
val linkBanner = saveBanner(
|
||||||
|
"link.png",
|
||||||
|
AudioContentBannerType.LINK,
|
||||||
|
orders = 4,
|
||||||
|
isActive = true,
|
||||||
|
link = "https://link.test"
|
||||||
|
)
|
||||||
|
saveBanner("inactive-event.png", AudioContentBannerType.EVENT, orders = 5, isActive = true, event = inactiveEvent)
|
||||||
|
saveBanner("inactive-creator.png", AudioContentBannerType.CREATOR, orders = 6, isActive = true, creator = inactiveCreator)
|
||||||
|
saveBanner("inactive-series.png", AudioContentBannerType.SERIES, orders = 7, isActive = true, series = inactiveSeries)
|
||||||
|
saveBanner(
|
||||||
|
"inactive-owner-series.png",
|
||||||
|
AudioContentBannerType.SERIES,
|
||||||
|
orders = 8,
|
||||||
|
isActive = true,
|
||||||
|
series = inactiveOwnerSeries
|
||||||
|
)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val banners = repository.findHomeBanners(limit = 20)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf(activeEventBanner.id, activeCreatorBanner.id, activeSeriesBanner.id, linkBanner.id),
|
||||||
|
banners.map { it.bannerId }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("최근 활동 크리에이터는 크리에이터당 최신 활동 1개를 LIVE/AUDIO/COMMUNITY/LIVE_REPLAY로 분류한다")
|
||||||
|
fun shouldFindOneLatestActivityPerCreatorWithActivityType() {
|
||||||
|
val baseAt = LocalDateTime.of(2026, 5, 31, 10, 0)
|
||||||
|
val liveCreator = saveMember("activity-live", MemberRole.CREATOR)
|
||||||
|
val audioCreator = saveMember("activity-audio", MemberRole.CREATOR)
|
||||||
|
val replayCreator = saveMember("activity-replay", MemberRole.CREATOR)
|
||||||
|
val communityCreator = saveMember("activity-community", MemberRole.CREATOR)
|
||||||
|
saveAudioContent(liveCreator, baseAt.minusDays(2), isActive = true)
|
||||||
|
saveLiveRoom(liveCreator, baseAt, channelName = "activity-live-channel")
|
||||||
|
val audio = saveAudioContent(audioCreator, baseAt.minusHours(1), isActive = true)
|
||||||
|
val replay = saveAudioContent(replayCreator, baseAt.minusHours(2), isActive = true, themeName = "다시듣기")
|
||||||
|
val community = saveCommunity(communityCreator, isCommentAvailable = true)
|
||||||
|
updateCreatedAt("CreatorCommunity", community.id!!, baseAt.minusHours(3))
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val creators = repository.findRecentlyActiveCreators(limit = 10)
|
||||||
|
val byCreatorId = creators.associateBy { it.creatorId }
|
||||||
|
|
||||||
|
assertEquals(4, creators.size)
|
||||||
|
assertEquals(
|
||||||
|
listOf(liveCreator.id, audioCreator.id, replayCreator.id, communityCreator.id),
|
||||||
|
creators.map { it.creatorId }
|
||||||
|
)
|
||||||
|
assertEquals(RecommendedActivityType.LIVE, byCreatorId[liveCreator.id]!!.activityType)
|
||||||
|
assertEquals(null, byCreatorId[liveCreator.id]!!.targetId)
|
||||||
|
assertEquals(baseAt, byCreatorId[liveCreator.id]!!.activityAt)
|
||||||
|
assertEquals(RecommendedActivityType.AUDIO, byCreatorId[audioCreator.id]!!.activityType)
|
||||||
|
assertEquals(audio.id, byCreatorId[audioCreator.id]!!.targetId)
|
||||||
|
assertEquals(RecommendedActivityType.LIVE_REPLAY, byCreatorId[replayCreator.id]!!.activityType)
|
||||||
|
assertEquals(replay.id, byCreatorId[replayCreator.id]!!.targetId)
|
||||||
|
assertEquals(RecommendedActivityType.COMMUNITY, byCreatorId[communityCreator.id]!!.activityType)
|
||||||
|
assertEquals(community.id, byCreatorId[communityCreator.id]!!.targetId)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("AI 캐릭터 스냅샷은 AI 발화 수와 중복 없는 활성 사용자 수를 집계하고 팔로우 증가량은 제외한다")
|
@DisplayName("AI 캐릭터 스냅샷은 AI 발화 수와 중복 없는 활성 사용자 수를 집계하고 팔로우 증가량은 제외한다")
|
||||||
fun shouldFindAiCharacterSnapshotsWithoutFollowIncrease() {
|
fun shouldFindAiCharacterSnapshotsWithoutFollowIncrease() {
|
||||||
@@ -358,6 +563,29 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
assertEquals(expectedCommentDisabledScore, snapshots[commentDisabledPost.id]!!.score, 0.0001)
|
assertEquals(expectedCommentDisabledScore, snapshots[commentDisabledPost.id]!!.score, 0.0001)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("인기 커뮤니티 스냅샷은 성인 게시글도 후보 점수 산정에 포함한다")
|
||||||
|
fun shouldIncludeAdultCommunitiesInPopularCommunitySnapshots() {
|
||||||
|
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0)
|
||||||
|
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||||
|
val creator = saveMember("adult-community-creator", MemberRole.CREATOR)
|
||||||
|
val member = saveMember("adult-community-member", MemberRole.USER)
|
||||||
|
saveLiveRoom(creator, LocalDateTime.of(2026, 5, 10, 12, 0), channelName = "adult-community-live")
|
||||||
|
val normalPost = saveCommunity(creator, isCommentAvailable = true, isAdult = false)
|
||||||
|
val adultPost = saveCommunity(creator, isCommentAvailable = true, isAdult = true)
|
||||||
|
val normalLike = saveCommunityLike(member, normalPost, isActive = true)
|
||||||
|
val adultLike = saveCommunityLike(member, adultPost, isActive = true)
|
||||||
|
updateCreatedAt("CreatorCommunity", normalPost.id!!, windowStart.plusDays(1))
|
||||||
|
updateCreatedAt("CreatorCommunity", adultPost.id!!, windowStart.plusDays(1))
|
||||||
|
updateCreatedAt("CreatorCommunityLike", normalLike.id!!, windowStart.plusDays(1))
|
||||||
|
updateCreatedAt("CreatorCommunityLike", adultLike.id!!, windowStart.plusDays(1))
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val snapshots = repository.findPopularCommunitySnapshots(windowStart, snapshotAt, limit = 10)
|
||||||
|
|
||||||
|
assertEquals(setOf(normalPost.id, adultPost.id), snapshots.map { it.targetId }.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("인기 커뮤니티 스냅샷은 DB에서 최종 점수를 계산한 뒤 정렬하고 limit을 적용한다")
|
@DisplayName("인기 커뮤니티 스냅샷은 DB에서 최종 점수를 계산한 뒤 정렬하고 limit을 적용한다")
|
||||||
fun shouldFindPopularCommunitySnapshotsWithDbScoreOrderAndLimit() {
|
fun shouldFindPopularCommunitySnapshotsWithDbScoreOrderAndLimit() {
|
||||||
@@ -468,18 +696,306 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
assertEquals(expectedCommunityScore, communitySnapshot.score, 0.0001)
|
assertEquals(expectedCommunityScore, communitySnapshot.score, 0.0001)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveMember(nickname: String, role: MemberRole): Member {
|
@Test
|
||||||
|
@DisplayName("최근 응원과 인기 커뮤니티 스냅샷 데뷔일은 빈 채널명 라이브를 제외한다")
|
||||||
|
fun shouldExcludeBlankChannelNameLiveFromSnapshotDebutAt() {
|
||||||
|
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0)
|
||||||
|
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||||
|
val blankLiveAt = LocalDateTime.of(2026, 5, 1, 12, 0)
|
||||||
|
val contentDebutAt = LocalDateTime.of(2026, 5, 20, 12, 0)
|
||||||
|
val creator = saveMember("blank-live-debut-creator", MemberRole.CREATOR)
|
||||||
|
val donor = saveMember("blank-live-debut-donor", MemberRole.USER)
|
||||||
|
saveLiveRoom(creator, blankLiveAt, channelName = "")
|
||||||
|
saveAudioContent(creator, contentDebutAt, 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("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(contentDebutAt, snapshotAt)
|
||||||
|
)
|
||||||
|
val expectedCommunityScore = scorePolicy.calculateCommunityScore(
|
||||||
|
likeCount = 1,
|
||||||
|
commentCount = 0,
|
||||||
|
followerCount = 0,
|
||||||
|
newBoost = scorePolicy.calculateCreatorNewBoost(contentDebutAt, snapshotAt)
|
||||||
|
)
|
||||||
|
assertEquals(expectedCheerScore, cheerSnapshot.score, 0.0001)
|
||||||
|
assertEquals(expectedCommunityScore, communitySnapshot.score, 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("최근 데뷔 크리에이터는 실제 데뷔일 30일 이내 후보를 PRD 산식 점수순으로 조회한다")
|
||||||
|
fun shouldFindRecentDebutCreatorsWithinThirtyDaysOrderedByScore() {
|
||||||
|
val now = LocalDateTime.of(2026, 5, 31, 10, 0)
|
||||||
|
val newHighScoreCreator = saveMember("new-high-debut", MemberRole.CREATOR)
|
||||||
|
val newLowScoreCreator = saveMember("new-low-debut", MemberRole.CREATOR)
|
||||||
|
val oldCreator = saveMember("old-debut", MemberRole.CREATOR)
|
||||||
|
val follower = saveMember("debut-follower", MemberRole.USER)
|
||||||
|
val commenter = saveMember("debut-commenter", MemberRole.USER)
|
||||||
|
val highContent = saveAudioContent(newHighScoreCreator, now.minusDays(20), isActive = true)
|
||||||
|
val lowContent = saveAudioContent(newLowScoreCreator, now.minusDays(5), isActive = true)
|
||||||
|
saveAudioContent(oldCreator, now.minusDays(31), isActive = true)
|
||||||
|
updateCreatedAt("AudioContent", highContent.id!!, now.minusDays(20))
|
||||||
|
updateCreatedAt("AudioContent", lowContent.id!!, now.minusDays(5))
|
||||||
|
val following = saveFollowing(follower, newHighScoreCreator, isActive = true)
|
||||||
|
val comment = saveAudioContentComment(commenter, highContent, isActive = true)
|
||||||
|
val like = saveAudioContentLike(commenter, highContent, isActive = true)
|
||||||
|
updateCreatedAt("CreatorFollowing", following.id!!, now.minusDays(1))
|
||||||
|
updateCreatedAt("AudioContentComment", comment.id!!, now.minusDays(1))
|
||||||
|
updateCreatedAt("AudioContentLike", like.id!!, now.minusDays(1))
|
||||||
|
updateCreatedAt("Member", newHighScoreCreator.id!!, now.minusDays(60))
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val creators = repository.findRecentDebutCreators(now, limit = 10)
|
||||||
|
|
||||||
|
val expectedHighScore = scorePolicy.calculateDebutCreatorScore(
|
||||||
|
followIncrease = 1,
|
||||||
|
contentActivityScore = 1,
|
||||||
|
communicationScore = 2,
|
||||||
|
newBoost = scorePolicy.calculateCreatorNewBoost(now.minusDays(20), now)
|
||||||
|
)
|
||||||
|
val expectedLowScore = scorePolicy.calculateDebutCreatorScore(
|
||||||
|
followIncrease = 0,
|
||||||
|
contentActivityScore = 1,
|
||||||
|
communicationScore = 0,
|
||||||
|
newBoost = scorePolicy.calculateCreatorNewBoost(now.minusDays(5), now)
|
||||||
|
)
|
||||||
|
assertEquals(listOf(newHighScoreCreator.id, newLowScoreCreator.id), creators.map { it.creatorId })
|
||||||
|
assertEquals(now.minusDays(20), creators.first().debutAt)
|
||||||
|
assertEquals(expectedHighScore, creators.first().score, 0.0001)
|
||||||
|
assertEquals(expectedLowScore, creators.last().score, 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("최근 데뷔 크리에이터 동점은 랜덤 tie-breaker 오름차순으로 정렬한다")
|
||||||
|
fun shouldOrderRecentDebutCreatorsByRandomTieBreakerWhenScoresTie() {
|
||||||
|
val now = LocalDateTime.of(2026, 5, 31, 10, 0)
|
||||||
|
val creator1 = saveMember("tie-debut-1", MemberRole.CREATOR)
|
||||||
|
val creator2 = saveMember("tie-debut-2", MemberRole.CREATOR)
|
||||||
|
saveAudioContent(creator1, now.minusDays(5), isActive = true)
|
||||||
|
saveAudioContent(creator2, now.minusDays(5), isActive = true)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val creators = repository.findRecentDebutCreators(now, limit = 10)
|
||||||
|
|
||||||
|
assertEquals(2, creators.size)
|
||||||
|
assertEquals(true, creators.zipWithNext().all { it.first.randomTieBreaker <= it.second.randomTieBreaker })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("첫 오디오 콘텐츠는 생성/공개일 기준 첫 3개 안의 활성 공개 콘텐츠만 조회하고 비활성 선행 콘텐츠 경계를 지킨다")
|
||||||
|
fun shouldFindFirstAudioContentsWithinFirstThreeUploadsAndInactiveBoundary() {
|
||||||
|
val now = LocalDateTime.of(2026, 5, 31, 10, 0)
|
||||||
|
val eligibleCreator = saveMember("first-audio-eligible", MemberRole.CREATOR)
|
||||||
|
val excludedCreator = saveMember("first-audio-excluded", MemberRole.CREATOR)
|
||||||
|
val eligibleInactive1 = saveAudioContent(eligibleCreator, now.minusDays(10), isActive = false)
|
||||||
|
val eligibleInactive2 = saveAudioContent(eligibleCreator, now.minusDays(9), isActive = false)
|
||||||
|
val eligibleActive = saveAudioContent(eligibleCreator, now.minusDays(2), isActive = true)
|
||||||
|
val excludedInactive1 = saveAudioContent(excludedCreator, now.minusDays(10), isActive = false)
|
||||||
|
val excludedInactive2 = saveAudioContent(excludedCreator, now.minusDays(9), isActive = false)
|
||||||
|
val excludedInactive3 = saveAudioContent(excludedCreator, now.minusDays(8), isActive = false)
|
||||||
|
val excludedActive = saveAudioContent(excludedCreator, now.minusDays(1), isActive = true)
|
||||||
|
listOf(
|
||||||
|
eligibleInactive1 to now.minusDays(10),
|
||||||
|
eligibleInactive2 to now.minusDays(9),
|
||||||
|
eligibleActive to now.minusDays(2),
|
||||||
|
excludedInactive1 to now.minusDays(10),
|
||||||
|
excludedInactive2 to now.minusDays(9),
|
||||||
|
excludedInactive3 to now.minusDays(8),
|
||||||
|
excludedActive to now.minusDays(1)
|
||||||
|
).forEach { (content, createdAt) -> updateCreatedAt("AudioContent", content.id!!, createdAt) }
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val contents = repository.findFirstAudioContents(now, limit = 10)
|
||||||
|
|
||||||
|
assertEquals(listOf(eligibleActive.id), contents.map { it.contentId })
|
||||||
|
assertEquals(100, contents.single().recencyScore)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("첫 오디오 콘텐츠는 예약/미공개 콘텐츠를 제외하고 releaseDate 최신성 점수순으로 조회한다")
|
||||||
|
fun shouldFindFirstAudioContentsOrderedByReleaseDateRecencyScoreExcludingScheduledContents() {
|
||||||
|
val now = LocalDateTime.of(2026, 5, 31, 10, 0)
|
||||||
|
val freshCreator = saveMember("fresh-first-audio", MemberRole.CREATOR)
|
||||||
|
val oldCreator = saveMember("old-first-audio", MemberRole.CREATOR)
|
||||||
|
val scheduledCreator = saveMember("scheduled-first-audio", MemberRole.CREATOR)
|
||||||
|
val fresh = saveAudioContent(freshCreator, now.minusDays(3), isActive = true)
|
||||||
|
val old = saveAudioContent(oldCreator, now.minusDays(21), isActive = true)
|
||||||
|
saveAudioContent(scheduledCreator, now.plusDays(1), isActive = true)
|
||||||
|
updateCreatedAt("AudioContent", fresh.id!!, now.minusDays(20))
|
||||||
|
updateCreatedAt("AudioContent", old.id!!, now.minusDays(20))
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val contents = repository.findFirstAudioContents(now, limit = 10)
|
||||||
|
|
||||||
|
assertEquals(listOf(fresh.id, old.id), contents.map { it.contentId })
|
||||||
|
assertEquals(listOf(100, 40), contents.map { it.recencyScore })
|
||||||
|
assertEquals(true, contents.zipWithNext().all { it.first.recencyScore >= it.second.recencyScore })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("AI 캐릭터 상세는 활성 캐릭터의 이름 소개 전체 AI 발화 수와 연결된 원작명만 조회한다")
|
||||||
|
fun shouldFindAiCharacterRecommendationDetailsWithOriginalWorkTitleOnlyWhenExists() {
|
||||||
|
val originalWork = saveOriginalWork("original-title")
|
||||||
|
val characterWithWork = saveCharacter("ai-detail-work", isActive = true, originalWork = originalWork)
|
||||||
|
val characterWithoutWork = saveCharacter("ai-detail-no-work", isActive = true)
|
||||||
|
val inactiveCharacter = saveCharacter("ai-detail-inactive", isActive = false)
|
||||||
|
val room = saveChatRoom("ai-detail-room")
|
||||||
|
val workParticipant = saveParticipant(room, ParticipantType.CHARACTER, character = characterWithWork)
|
||||||
|
val noWorkParticipant = saveParticipant(room, ParticipantType.CHARACTER, character = characterWithoutWork)
|
||||||
|
val inactiveParticipant = saveParticipant(room, ParticipantType.CHARACTER, character = inactiveCharacter)
|
||||||
|
saveMessage(room, workParticipant, "work-1", isActive = true)
|
||||||
|
saveMessage(room, workParticipant, "work-2", isActive = true)
|
||||||
|
saveMessage(room, workParticipant, "inactive-work", isActive = false)
|
||||||
|
saveMessage(room, noWorkParticipant, "no-work", isActive = true)
|
||||||
|
saveMessage(room, inactiveParticipant, "inactive-character", isActive = true)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val details = repository.findAiCharacterRecommendationDetails(
|
||||||
|
listOf(characterWithWork.id!!, characterWithoutWork.id!!, inactiveCharacter.id!!, 999L)
|
||||||
|
)
|
||||||
|
.associateBy { it.characterId }
|
||||||
|
|
||||||
|
assertEquals(setOf(characterWithWork.id, characterWithoutWork.id), details.keys)
|
||||||
|
assertEquals("ai-detail-work", details[characterWithWork.id]!!.name)
|
||||||
|
assertEquals("description", details[characterWithWork.id]!!.description)
|
||||||
|
assertEquals(2L, details[characterWithWork.id]!!.totalChatCount)
|
||||||
|
assertEquals("original-title", details[characterWithWork.id]!!.originalWorkTitle)
|
||||||
|
assertEquals(1L, details[characterWithoutWork.id]!!.totalChatCount)
|
||||||
|
assertEquals(null, details[characterWithoutWork.id]!!.originalWorkTitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("AI 캐릭터 상세는 빈 id 목록이면 빈 배열을 반환한다")
|
||||||
|
fun shouldReturnEmptyAiCharacterRecommendationDetailsWhenIdsAreEmpty() {
|
||||||
|
assertEquals(
|
||||||
|
emptyList<HomeAiCharacterRecommendationRecord>(),
|
||||||
|
repository.findAiCharacterRecommendationDetails(emptyList())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("최근 응원 크리에이터 상세는 활성 크리에이터의 닉네임과 프로필만 조회한다")
|
||||||
|
fun shouldFindCheerCreatorRecommendationDetailsForActiveCreatorsOnly() {
|
||||||
|
val activeCreator = saveMember("cheer-detail-active", MemberRole.CREATOR)
|
||||||
|
val inactiveCreator = saveMember("cheer-detail-inactive", MemberRole.CREATOR, isActive = false)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val details = repository.findCheerCreatorRecommendationDetails(listOf(activeCreator.id!!, inactiveCreator.id!!, 999L))
|
||||||
|
|
||||||
|
val detailById = details.associateBy { it.creatorId }
|
||||||
|
assertEquals(setOf(activeCreator.id), detailById.keys)
|
||||||
|
assertEquals("cheer-detail-active", detailById[activeCreator.id]!!.creatorNickname)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("최근 응원 크리에이터 상세는 빈 id 목록이면 빈 배열을 반환한다")
|
||||||
|
fun shouldReturnEmptyCheerCreatorRecommendationDetailsWhenIdsAreEmpty() {
|
||||||
|
assertEquals(
|
||||||
|
emptyList<HomeCheerCreatorRecommendationRecord>(),
|
||||||
|
repository.findCheerCreatorRecommendationDetails(emptyList())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("인기 커뮤니티 상세는 노출 가능 게시글만 좋아요/댓글 수와 크리에이터 정보로 조회한다")
|
||||||
|
fun shouldFindPopularCommunityRecommendationDetailsWithEligibilityAndCounts() {
|
||||||
|
val creator = saveMember("community-detail-creator", MemberRole.CREATOR)
|
||||||
|
val inactiveCreator = saveMember("community-detail-inactive-creator", MemberRole.CREATOR, isActive = false)
|
||||||
|
val member = saveMember("community-detail-member", MemberRole.USER)
|
||||||
|
val eligible = saveCommunity(creator, isCommentAvailable = true)
|
||||||
|
val paid = saveCommunity(creator, isCommentAvailable = true, price = 10)
|
||||||
|
val fixed = saveCommunity(creator, isCommentAvailable = true, isFixed = true)
|
||||||
|
val adult = saveCommunity(creator, isCommentAvailable = true, isAdult = true)
|
||||||
|
val inactivePost = saveCommunity(creator, isCommentAvailable = true, isActive = false)
|
||||||
|
val inactiveCreatorPost = saveCommunity(inactiveCreator, isCommentAvailable = true)
|
||||||
|
val like1 = saveCommunityLike(member, eligible, isActive = true)
|
||||||
|
val like2 = saveCommunityLike(member, eligible, isActive = true)
|
||||||
|
saveCommunityLike(member, eligible, isActive = false)
|
||||||
|
val comment1 = saveCommunityComment(member, eligible, isActive = true)
|
||||||
|
saveCommunityComment(member, eligible, isActive = false)
|
||||||
|
updateCreatedAt("CreatorCommunity", eligible.id!!, LocalDateTime.of(2026, 5, 29, 1, 0))
|
||||||
|
updateCreatedAt("CreatorCommunityLike", like1.id!!, LocalDateTime.of(2026, 5, 29, 2, 0))
|
||||||
|
updateCreatedAt("CreatorCommunityLike", like2.id!!, LocalDateTime.of(2026, 5, 29, 3, 0))
|
||||||
|
updateCreatedAt("CreatorCommunityComment", comment1.id!!, LocalDateTime.of(2026, 5, 29, 4, 0))
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val details = repository.findPopularCommunityRecommendationDetails(
|
||||||
|
listOf(eligible.id!!, paid.id!!, fixed.id!!, adult.id!!, inactivePost.id!!, inactiveCreatorPost.id!!, 999L),
|
||||||
|
includeAdultCommunities = false
|
||||||
|
)
|
||||||
|
|
||||||
|
val detailById = details.associateBy { it.communityId }
|
||||||
|
assertEquals(setOf(eligible.id), detailById.keys)
|
||||||
|
assertEquals("content", detailById[eligible.id]!!.content)
|
||||||
|
assertEquals(LocalDateTime.of(2026, 5, 29, 1, 0), detailById[eligible.id]!!.createdAt)
|
||||||
|
assertEquals(2L, detailById[eligible.id]!!.likeCount)
|
||||||
|
assertEquals(1L, detailById[eligible.id]!!.commentCount)
|
||||||
|
assertEquals(creator.id, detailById[eligible.id]!!.creatorId)
|
||||||
|
assertEquals("community-detail-creator", detailById[eligible.id]!!.creatorNickname)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("인기 커뮤니티 상세는 성인 노출 가능 회원에게 성인 게시글을 포함한다")
|
||||||
|
fun shouldFindAdultPopularCommunityDetailsWhenAdultVisible() {
|
||||||
|
val creator = saveMember("adult-visible-community-creator", MemberRole.CREATOR)
|
||||||
|
val member = saveMember("adult-visible-community-member", MemberRole.USER)
|
||||||
|
val adult = saveCommunity(creator, isCommentAvailable = true, isAdult = true)
|
||||||
|
val like = saveCommunityLike(member, adult, isActive = true)
|
||||||
|
updateCreatedAt("CreatorCommunity", adult.id!!, LocalDateTime.of(2026, 5, 29, 1, 0))
|
||||||
|
updateCreatedAt("CreatorCommunityLike", like.id!!, LocalDateTime.of(2026, 5, 29, 2, 0))
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val details = repository.findPopularCommunityRecommendationDetails(
|
||||||
|
listOf(adult.id!!),
|
||||||
|
includeAdultCommunities = true
|
||||||
|
)
|
||||||
|
|
||||||
|
val detailById = details.associateBy { it.communityId }
|
||||||
|
assertEquals(setOf(adult.id), detailById.keys)
|
||||||
|
assertEquals(1L, detailById[adult.id]!!.likeCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("인기 커뮤니티 상세는 빈 id 목록이면 빈 배열을 반환한다")
|
||||||
|
fun shouldReturnEmptyPopularCommunityRecommendationDetailsWhenIdsAreEmpty() {
|
||||||
|
assertEquals(
|
||||||
|
emptyList<HomePopularCommunityRecommendationRecord>(),
|
||||||
|
repository.findPopularCommunityRecommendationDetails(emptyList(), includeAdultCommunities = false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveMember(nickname: String, role: MemberRole, isActive: Boolean = true): Member {
|
||||||
val member = Member(
|
val member = Member(
|
||||||
email = "$nickname@test.com",
|
email = "$nickname@test.com",
|
||||||
password = "password",
|
password = "password",
|
||||||
nickname = nickname,
|
nickname = nickname,
|
||||||
role = role
|
role = role,
|
||||||
|
isActive = isActive
|
||||||
)
|
)
|
||||||
entityManager.persist(member)
|
entityManager.persist(member)
|
||||||
return member
|
return member
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveCharacter(name: String, isActive: Boolean): ChatCharacter {
|
private fun saveCharacter(name: String, isActive: Boolean, originalWork: OriginalWork? = null): ChatCharacter {
|
||||||
val character = ChatCharacter(
|
val character = ChatCharacter(
|
||||||
characterUUID = "$name-uuid",
|
characterUUID = "$name-uuid",
|
||||||
name = name,
|
name = name,
|
||||||
@@ -487,10 +1003,23 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
systemPrompt = "system",
|
systemPrompt = "system",
|
||||||
isActive = isActive
|
isActive = isActive
|
||||||
)
|
)
|
||||||
|
character.originalWork = originalWork
|
||||||
entityManager.persist(character)
|
entityManager.persist(character)
|
||||||
return character
|
return character
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun saveOriginalWork(title: String): OriginalWork {
|
||||||
|
val originalWork = OriginalWork(
|
||||||
|
title = title,
|
||||||
|
contentType = "webtoon",
|
||||||
|
category = "romance",
|
||||||
|
isAdult = false,
|
||||||
|
description = "description"
|
||||||
|
)
|
||||||
|
entityManager.persist(originalWork)
|
||||||
|
return originalWork
|
||||||
|
}
|
||||||
|
|
||||||
private fun saveChatRoom(sessionId: String): ChatRoom {
|
private fun saveChatRoom(sessionId: String): ChatRoom {
|
||||||
val room = ChatRoom(sessionId = sessionId, title = sessionId)
|
val room = ChatRoom(sessionId = sessionId, title = sessionId)
|
||||||
entityManager.persist(room)
|
entityManager.persist(room)
|
||||||
@@ -542,21 +1071,35 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
return cheers
|
return cheers
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveCommunity(creator: Member, isCommentAvailable: Boolean): CreatorCommunity {
|
private fun saveCommunity(
|
||||||
|
creator: Member,
|
||||||
|
isCommentAvailable: Boolean,
|
||||||
|
price: Int = 0,
|
||||||
|
isAdult: Boolean = false,
|
||||||
|
isActive: Boolean = true,
|
||||||
|
isFixed: Boolean = false
|
||||||
|
): CreatorCommunity {
|
||||||
val community = CreatorCommunity(
|
val community = CreatorCommunity(
|
||||||
content = "content",
|
content = "content",
|
||||||
price = 0,
|
price = price,
|
||||||
isCommentAvailable = isCommentAvailable,
|
isCommentAvailable = isCommentAvailable,
|
||||||
isAdult = false
|
isAdult = isAdult,
|
||||||
|
isActive = isActive,
|
||||||
|
isFixed = isFixed
|
||||||
)
|
)
|
||||||
community.member = creator
|
community.member = creator
|
||||||
entityManager.persist(community)
|
entityManager.persist(community)
|
||||||
return community
|
return community
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveAudioContent(creator: Member, releaseDate: LocalDateTime, isActive: Boolean): AudioContent {
|
private fun saveAudioContent(
|
||||||
|
creator: Member,
|
||||||
|
releaseDate: LocalDateTime,
|
||||||
|
isActive: Boolean,
|
||||||
|
themeName: String = "theme-${creator.nickname}-$releaseDate"
|
||||||
|
): AudioContent {
|
||||||
val theme = AudioContentTheme(
|
val theme = AudioContentTheme(
|
||||||
theme = "theme-${creator.nickname}-$releaseDate",
|
theme = themeName,
|
||||||
image = "theme-${creator.nickname}-$releaseDate.png"
|
image = "theme-${creator.nickname}-$releaseDate.png"
|
||||||
)
|
)
|
||||||
entityManager.persist(theme)
|
entityManager.persist(theme)
|
||||||
@@ -574,6 +1117,86 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun saveAudioContentComment(member: Member, content: AudioContent, isActive: Boolean): AudioContentComment {
|
||||||
|
val comment = AudioContentComment(comment = "comment", languageCode = "ko", isActive = isActive)
|
||||||
|
comment.member = member
|
||||||
|
comment.audioContent = content
|
||||||
|
entityManager.persist(comment)
|
||||||
|
return comment
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveAudioContentLike(member: Member, content: AudioContent, isActive: Boolean): AudioContentLike {
|
||||||
|
val like = AudioContentLike(memberId = member.id!!)
|
||||||
|
like.audioContent = content
|
||||||
|
like.isActive = isActive
|
||||||
|
entityManager.persist(like)
|
||||||
|
return like
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveEvent(title: String, isActive: Boolean = true): Event {
|
||||||
|
val event = Event(
|
||||||
|
thumbnailImage = "$title-thumbnail.png",
|
||||||
|
detailImage = "$title-detail.png",
|
||||||
|
popupImage = null,
|
||||||
|
link = "https://$title.test",
|
||||||
|
title = title,
|
||||||
|
startDate = LocalDateTime.of(2026, 5, 1, 0, 0),
|
||||||
|
endDate = LocalDateTime.of(2026, 6, 1, 0, 0),
|
||||||
|
isActive = isActive
|
||||||
|
)
|
||||||
|
entityManager.persist(event)
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveSeries(title: String, owner: Member, isActive: Boolean): Series {
|
||||||
|
val genre = SeriesGenre(genre = "genre-$title")
|
||||||
|
entityManager.persist(genre)
|
||||||
|
val series = Series(
|
||||||
|
title = title,
|
||||||
|
introduction = "introduction",
|
||||||
|
languageCode = "ko",
|
||||||
|
isActive = isActive
|
||||||
|
)
|
||||||
|
series.member = owner
|
||||||
|
series.genre = genre
|
||||||
|
entityManager.persist(series)
|
||||||
|
return series
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveMainTab(title: String): AudioContentMainTab {
|
||||||
|
val tab = AudioContentMainTab(title = title, isActive = true)
|
||||||
|
entityManager.persist(tab)
|
||||||
|
return tab
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveBanner(
|
||||||
|
thumbnailImage: String,
|
||||||
|
type: AudioContentBannerType,
|
||||||
|
orders: Int,
|
||||||
|
isActive: Boolean,
|
||||||
|
creator: Member? = null,
|
||||||
|
event: Event? = null,
|
||||||
|
series: Series? = null,
|
||||||
|
tab: AudioContentMainTab? = null,
|
||||||
|
link: String? = null
|
||||||
|
): AudioContentBanner {
|
||||||
|
val banner = AudioContentBanner(
|
||||||
|
thumbnailImage = thumbnailImage,
|
||||||
|
type = type,
|
||||||
|
lang = Lang.KO,
|
||||||
|
isAdult = false,
|
||||||
|
isActive = isActive,
|
||||||
|
orders = orders
|
||||||
|
)
|
||||||
|
banner.creator = creator
|
||||||
|
banner.event = event
|
||||||
|
banner.series = series
|
||||||
|
banner.tab = tab
|
||||||
|
banner.link = link
|
||||||
|
entityManager.persist(banner)
|
||||||
|
return banner
|
||||||
|
}
|
||||||
|
|
||||||
private fun saveLiveRoom(creator: Member, beginDateTime: LocalDateTime, channelName: String?): LiveRoom {
|
private fun saveLiveRoom(creator: Member, beginDateTime: LocalDateTime, channelName: String?): LiveRoom {
|
||||||
val room = LiveRoom(
|
val room = LiveRoom(
|
||||||
title = "live-${creator.nickname}-$beginDateTime",
|
title = "live-${creator.nickname}-$beginDateTime",
|
||||||
|
|||||||
Reference in New Issue
Block a user