From 665298405676f380773035032dc09fad4262a03a Mon Sep 17 00:00:00 2001 From: Klaus Date: Sun, 31 May 2026 16:32:51 +0900 Subject: [PATCH] =?UTF-8?q?feat(recommend):=20=ED=99=88=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=20=EC=A1=B0=ED=9A=8C=20=EC=BF=BC=EB=A6=AC=EB=A5=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...efaultHomeRecommendationQueryRepository.kt | 566 +++++++++++++++ ...ltHomeRecommendationQueryRepositoryTest.kt | 643 +++++++++++++++++- 2 files changed, 1199 insertions(+), 10 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt index 0d9560b4..98ffba73 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt @@ -1,16 +1,427 @@ 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.RecommendedActivityType import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedSectionType +import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeAiCharacterRecommendationRecord +import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeBannerRecommendationRecord +import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeCheerCreatorRecommendationRecord +import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeFirstAudioContentRecord +import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeLiveRecommendationRecord +import kr.co.vividnext.sodalive.v2.recommend.port.out.HomePopularCommunityRecommendationRecord +import kr.co.vividnext.sodalive.v2.recommend.port.out.RecentDebutCreatorRecord +import kr.co.vividnext.sodalive.v2.recommend.port.out.RecentlyActiveCreatorRecord import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotRecord import org.springframework.stereotype.Repository +import java.sql.Timestamp import java.time.LocalDateTime import javax.persistence.EntityManager @Repository class DefaultHomeRecommendationQueryRepository( + private val queryFactory: JPAQueryFactory, private val entityManager: EntityManager ) : HomeRecommendationQueryRepository { + override fun findLiveRecommendations(limit: Int): List { + 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 { + 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 { + 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> + + 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 { + 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> + + 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 { + 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> + + 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( windowStart: LocalDateTime, snapshotAt: LocalDateTime, @@ -102,6 +513,7 @@ class DefaultHomeRecommendationQueryRepository( 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 <= :snapshotAt ) debut_events group by debut_events.creator_id @@ -171,6 +583,7 @@ class DefaultHomeRecommendationQueryRepository( 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 <= :snapshotAt ) debut_events group by debut_events.creator_id @@ -227,6 +640,107 @@ class DefaultHomeRecommendationQueryRepository( return executeSnapshotQuery(sql, RecommendedSectionType.POPULAR_COMMUNITY, windowStart, snapshotAt, limit) } + override fun findAiCharacterRecommendationDetails( + characterIds: List + ): List { + 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 + ): List { + 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, + includeAdultCommunities: Boolean + ): List { + 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( sql: String, 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 = "다시듣기" + } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt index a7d9e2f0..2f27118e 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt @@ -1,28 +1,43 @@ 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.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.original.OriginalWork 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.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.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.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.i18n.Lang 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.RecommendedActivityType import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedSectionType +import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeAiCharacterRecommendationRecord +import kr.co.vividnext.sodalive.v2.recommend.port.out.HomeCheerCreatorRecommendationRecord +import kr.co.vividnext.sodalive.v2.recommend.port.out.HomePopularCommunityRecommendationRecord import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test @@ -40,11 +55,201 @@ import javax.persistence.EntityManager ) @Import(QueryDslConfig::class) 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() + @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 @DisplayName("AI 캐릭터 스냅샷은 AI 발화 수와 중복 없는 활성 사용자 수를 집계하고 팔로우 증가량은 제외한다") fun shouldFindAiCharacterSnapshotsWithoutFollowIncrease() { @@ -358,6 +563,29 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( 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 @DisplayName("인기 커뮤니티 스냅샷은 DB에서 최종 점수를 계산한 뒤 정렬하고 limit을 적용한다") fun shouldFindPopularCommunitySnapshotsWithDbScoreOrderAndLimit() { @@ -468,18 +696,306 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( 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(), + 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(), + 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(), + repository.findPopularCommunityRecommendationDetails(emptyList(), includeAdultCommunities = false) + ) + } + + private fun saveMember(nickname: String, role: MemberRole, isActive: Boolean = true): Member { val member = Member( email = "$nickname@test.com", password = "password", nickname = nickname, - role = role + role = role, + isActive = isActive ) entityManager.persist(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( characterUUID = "$name-uuid", name = name, @@ -487,10 +1003,23 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( systemPrompt = "system", isActive = isActive ) + character.originalWork = originalWork entityManager.persist(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 { val room = ChatRoom(sessionId = sessionId, title = sessionId) entityManager.persist(room) @@ -542,21 +1071,35 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( 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( content = "content", - price = 0, + price = price, isCommentAvailable = isCommentAvailable, - isAdult = false + isAdult = isAdult, + isActive = isActive, + isFixed = isFixed ) community.member = creator entityManager.persist(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( - theme = "theme-${creator.nickname}-$releaseDate", + theme = themeName, image = "theme-${creator.nickname}-$releaseDate.png" ) entityManager.persist(theme) @@ -574,6 +1117,86 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( 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 { val room = LiveRoom( title = "live-${creator.nickname}-$beginDateTime",