diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/CreatorChannelHomeQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/CreatorChannelHomeQueryRepository.kt new file mode 100644 index 00000000..0f9ff81a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/CreatorChannelHomeQueryRepository.kt @@ -0,0 +1,5 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence + +import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelHomeQueryPort + +interface CreatorChannelHomeQueryRepository : CreatorChannelHomeQueryPort diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt new file mode 100644 index 00000000..ce6278b8 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt @@ -0,0 +1,916 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.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.can.use.CanUsage +import kr.co.vividnext.sodalive.can.use.QUseCan.useCan +import kr.co.vividnext.sodalive.chat.character.QChatCharacter.chatCharacter +import kr.co.vividnext.sodalive.content.ContentType +import kr.co.vividnext.sodalive.content.QAudioContent.audioContent +import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series +import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent +import kr.co.vividnext.sodalive.explorer.profile.QCreatorCheers.creatorCheers +import kr.co.vividnext.sodalive.explorer.profile.channelDonation.QChannelDonationMessage.channelDonationMessage +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.extensions.removeDeletedNicknamePrefix +import kr.co.vividnext.sodalive.live.room.GenderRestriction +import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom +import kr.co.vividnext.sodalive.live.room.visit.QLiveRoomVisit.liveRoomVisit +import kr.co.vividnext.sodalive.member.Gender +import kr.co.vividnext.sodalive.member.MemberKind +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.QMember.member +import kr.co.vividnext.sodalive.member.block.QBlockMember +import kr.co.vividnext.sodalive.member.following.QCreatorFollowing.creatorFollowing +import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType +import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelActivityRecord +import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelAudioContentRecord +import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelCommunityPostRecord +import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelCreatorRecord +import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelDonationRecord +import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelFanTalkRecord +import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelFanTalkSummaryRecord +import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelLiveRecord +import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelScheduleRecord +import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelSeriesRecord +import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelSnsRecord +import org.springframework.stereotype.Repository +import java.time.Duration +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.temporal.ChronoUnit + +@Repository +class DefaultCreatorChannelHomeQueryRepository( + private val queryFactory: JPAQueryFactory +) : CreatorChannelHomeQueryRepository { + override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCreatorRecord? { + val creator = queryFactory + .select(member.id, member.nickname, member.profileImage, member.introduce, member.memberKind) + .from(member) + .where( + member.id.eq(creatorId), + member.role.eq(MemberRole.CREATOR), + member.isActive.isTrue + ) + .fetchFirst() ?: return null + + val following = viewerId?.let { + queryFactory + .select(creatorFollowing.isActive, creatorFollowing.isNotify) + .from(creatorFollowing) + .where( + creatorFollowing.member.id.eq(it), + creatorFollowing.creator.id.eq(creatorId), + creatorFollowing.isActive.isTrue + ) + .fetchFirst() + } + + val characterId = queryFactory + .select(chatCharacter.id) + .from(chatCharacter) + .where( + chatCharacter.creatorMember.id.eq(creatorId), + chatCharacter.isActive.isTrue + ) + .fetchFirst() + + return CreatorChannelCreatorRecord( + creatorId = creator.get(member.id)!!, + characterId = characterId, + nickname = creator.get(member.nickname)!!, + profileImagePath = creator.get(member.profileImage), + introduce = creator.get(member.introduce)!!, + followerCount = queryFactory + .select(creatorFollowing.id.count()) + .from(creatorFollowing) + .where( + creatorFollowing.creator.id.eq(creatorId), + creatorFollowing.isActive.isTrue, + creatorFollowing.member.isActive.isTrue + ) + .fetchOne() + ?.toInt() + ?: 0, + isAiChatAvailable = characterId != null, + isDmAvailable = creator.get(member.memberKind) != MemberKind.AI_CHARACTER, + isFollow = following?.get(creatorFollowing.isActive) ?: false, + isNotify = following?.get(creatorFollowing.isNotify) ?: false + ) + } + + override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean { + val blockMember = QBlockMember("creatorChannelBlockMember") + return queryFactory + .select(blockMember.id) + .from(blockMember) + .where( + blockMember.isActive.isTrue, + blockMember.member.id.eq(viewerId).and(blockMember.blockedMember.id.eq(creatorId)) + .or(blockMember.member.id.eq(creatorId).and(blockMember.blockedMember.id.eq(viewerId))) + ) + .fetchFirst() != null + } + + override fun findCurrentLive( + creatorId: Long, + now: LocalDateTime, + canViewAdultContent: Boolean, + viewerId: Long?, + isViewerCreator: Boolean, + effectiveViewerGender: Gender? + ): CreatorChannelLiveRecord? { + return queryFactory + .select( + Projections.constructor( + CreatorChannelLiveRecord::class.java, + liveRoom.id, + liveRoom.title, + liveRoom.coverImage, + liveRoom.beginDateTime, + liveRoom.price, + liveRoom.isAdult + ) + ) + .from(liveRoom) + .where( + liveRoom.member.id.eq(creatorId), + liveRoom.member.isActive.isTrue, + liveRoom.isActive.isTrue, + liveRoom.channelName.isNotNull, + liveRoom.channelName.isNotEmpty, + liveRoom.beginDateTime.loe(now), + adultLiveCondition(canViewAdultContent), + genderLiveCondition(viewerId, effectiveViewerGender), + creatorJoinLiveCondition(viewerId, isViewerCreator) + ) + .orderBy(liveRoom.beginDateTime.desc(), liveRoom.id.desc()) + .fetchFirst() + } + + override fun findLatestAudioContent( + creatorId: Long, + now: LocalDateTime, + canViewAdultContent: Boolean + ): CreatorChannelAudioContentRecord? { + val row = findAudioContentRows(creatorId, now, null, canViewAdultContent, 1).firstOrNull() ?: return null + return row.toAudioRecord( + firstContentId = firstAudioContentId(creatorId, now, canViewAdultContent), + seriesByContentId = audioSeriesByContentIds(listOf(itAudioId(row))) + ) + } + + override fun findChannelDonations( + creatorId: Long, + viewerId: Long?, + now: LocalDateTime, + limit: Int + ): List { + val kstZoneId = ZoneId.of("Asia/Seoul") + val utcZoneId = ZoneId.of("UTC") + val nowKst = now.atZone(utcZoneId).withZoneSameInstant(kstZoneId) + val start = nowKst.toLocalDate().withDayOfMonth(1).atStartOfDay(kstZoneId) + .withZoneSameInstant(utcZoneId) + .toLocalDateTime() + val end = nowKst.toLocalDate().withDayOfMonth(1).atStartOfDay(kstZoneId).plusMonths(1) + .withZoneSameInstant(utcZoneId) + .toLocalDateTime() + return queryFactory + .select( + Projections.constructor( + CreatorChannelDonationRecord::class.java, + channelDonationMessage.member.nickname, + channelDonationMessage.member.profileImage, + channelDonationMessage.can, + channelDonationMessage.additionalMessage.coalesce(""), + channelDonationMessage.createdAt + ) + ) + .from(channelDonationMessage) + .where( + channelDonationMessage.creator.id.eq(creatorId), + channelDonationMessage.createdAt.goe(start), + channelDonationMessage.createdAt.lt(end), + donationVisibilityCondition(creatorId, viewerId) + ) + .orderBy(channelDonationMessage.createdAt.desc(), channelDonationMessage.id.desc()) + .limit(limit.toLong()) + .fetch() + } + + override fun findCommunityPosts( + creatorId: Long, + viewerId: Long?, + isFixed: Boolean, + canViewAdultContent: Boolean, + limit: Int + ): List { + val posts = queryFactory + .select( + creatorCommunity.id, + creatorCommunity.member.id, + creatorCommunity.member.nickname, + creatorCommunity.member.profileImage, + creatorCommunity.imagePath, + creatorCommunity.audioPath, + creatorCommunity.content, + creatorCommunity.price, + creatorCommunity.createdAt, + creatorCommunity.fixedAt, + creatorCommunity.isFixed, + creatorCommunity.isCommentAvailable + ) + .from(creatorCommunity) + .where( + creatorCommunity.member.id.eq(creatorId), + creatorCommunity.member.isActive.isTrue, + visibleCommunityPostCondition(viewerId), + creatorCommunity.isFixed.eq(isFixed), + fixedNoticeCondition(isFixed), + adultCommunityCondition(canViewAdultContent) + ) + .orderBy( + if (isFixed) creatorCommunity.fixedAt.desc() else creatorCommunity.createdAt.desc(), + creatorCommunity.id.desc() + ) + .limit(limit.toLong()) + .fetch() + + val postIds = posts.map { it.get(creatorCommunity.id)!! } + val orderedPostIds = orderedCommunityPostIds(creatorId, viewerId, postIds) + val likeCounts = communityLikeCounts(postIds) + val commentCounts = communityCommentCounts( + postIds = posts.filter { it.get(creatorCommunity.isCommentAvailable)!! }.map { it.get(creatorCommunity.id)!! }, + viewerId = viewerId, + isContentCreator = viewerId == creatorId + ) + + return posts + .map { + val postId = it.get(creatorCommunity.id)!! + val postCreatorId = it.get(creatorCommunity.member.id)!! + val isFixedPost = it.get(creatorCommunity.isFixed)!! + val price = it.get(creatorCommunity.price)!! + val existOrdered = postId in orderedPostIds + val canAccessPaidContent = canAccessPaidCommunityContent( + price = price, + viewerId = viewerId, + creatorId = postCreatorId, + existOrdered = existOrdered + ) + CreatorChannelCommunityPostRecord( + postId = postId, + creatorId = postCreatorId, + creatorNickname = it.get(creatorCommunity.member.nickname)!!, + creatorProfilePath = it.get(creatorCommunity.member.profileImage), + imagePath = it.get(creatorCommunity.imagePath), + audioPath = if (canAccessPaidContent) it.get(creatorCommunity.audioPath) else null, + content = maskPaidCommunityContent( + content = it.get(creatorCommunity.content)!!, + canAccessPaidContent = canAccessPaidContent + ), + price = price, + date = if (isFixedPost) { + it.get(creatorCommunity.fixedAt) ?: it.get(creatorCommunity.createdAt)!! + } else { + it.get(creatorCommunity.createdAt)!! + }, + existOrdered = existOrdered, + likeCount = likeCounts[postId] ?: 0, + commentCount = commentCounts[postId] ?: 0 + ) + } + } + + override fun findSchedules( + creatorId: Long, + now: LocalDateTime, + canViewAdultContent: Boolean, + viewerId: Long?, + isViewerCreator: Boolean, + effectiveViewerGender: Gender?, + limit: Int + ): List { + val liveSchedules = queryFactory + .select( + Projections.constructor( + CreatorChannelScheduleRecord::class.java, + liveRoom.beginDateTime, + liveRoom.title, + Expressions.constant(CreatorActivityType.LIVE), + liveRoom.id, + liveRoom.isAdult + ) + ) + .from(liveRoom) + .where( + liveRoom.member.id.eq(creatorId), + liveRoom.member.isActive.isTrue, + liveRoom.isActive.isTrue, + liveRoom.channelName.isNull.or(liveRoom.channelName.isEmpty), + liveRoom.beginDateTime.gt(now), + adultLiveCondition(canViewAdultContent), + genderLiveCondition(viewerId, effectiveViewerGender), + creatorJoinLiveCondition(viewerId, isViewerCreator) + ) + .fetch() + + val audioSchedules = queryFactory + .select( + Projections.constructor( + CreatorChannelScheduleRecord::class.java, + audioContent.releaseDate, + audioContent.title, + Expressions.constant(CreatorActivityType.AUDIO), + audioContent.id, + audioContent.isAdult + ) + ) + .from(audioContent) + .where( + audioContent.member.id.eq(creatorId), + audioContent.member.isActive.isTrue, + audioContent.duration.isNotNull, + audioContent.releaseDate.isNotNull, + audioContent.releaseDate.gt(now), + adultAudioCondition(canViewAdultContent) + ) + .fetch() + + return (liveSchedules + audioSchedules) + .sortedWith(compareBy { it.scheduledAt }.thenBy { it.type.sortOrder }) + .take(limit) + } + + override fun findAudioContents( + creatorId: Long, + now: LocalDateTime, + latestAudioContentId: Long?, + canViewAdultContent: Boolean, + limit: Int + ): List { + val rows = findAudioContentRows(creatorId, now, latestAudioContentId, canViewAdultContent, limit) + val firstContentId = firstAudioContentId(creatorId, now, canViewAdultContent) + val seriesByContentId = audioSeriesByContentIds(rows.map { itAudioId(it) }) + return rows.map { it.toAudioRecord(firstContentId, seriesByContentId) } + } + + override fun findSeries( + creatorId: Long, + viewerId: Long?, + now: LocalDateTime, + canViewAdultContent: Boolean, + contentType: ContentType, + limit: Int + ): List { + val seriesRows = queryFactory + .select( + series.id, + series.title, + series.coverImage, + series.isOriginal + ) + .from(series) + .where( + series.member.id.eq(creatorId), + series.member.isActive.isTrue, + series.isActive.isTrue, + adultSeriesCondition(canViewAdultContent), + contentTypeSeriesCondition(canViewAdultContent, contentType), + notBlockedSeriesCreatorCondition(viewerId) + ) + .fetch() + + val seriesIds = seriesRows.map { it.get(series.id)!! } + val contentStats = seriesContentStats(seriesIds, now, canViewAdultContent) + val newSeriesIds = newSeriesIds(seriesIds, now, canViewAdultContent) + return seriesRows + .mapNotNull { seriesRow -> + contentStats[seriesRow.get(series.id)!!]?.let { seriesRow to it } + } + .sortedByDescending { it.second.latestPublishedAt } + .take(limit) + .map { (seriesRow, stats) -> + val seriesId = seriesRow.get(series.id)!! + CreatorChannelSeriesRecord( + seriesId = seriesId, + title = seriesRow.get(series.title)!!, + coverImagePath = seriesRow.get(series.coverImage), + numberOfContent = stats.contentCount, + isNew = seriesId in newSeriesIds, + isOriginal = seriesRow.get(series.isOriginal)!! + ) + } + } + + override fun findFanTalkSummary(creatorId: Long, viewerId: Long?): CreatorChannelFanTalkSummaryRecord { + val totalCount = queryFactory + .select(creatorCheers.id.count()) + .from(creatorCheers) + .where(fanTalkSummaryCondition(creatorId, viewerId)) + .fetchOne() + ?.toInt() + ?: 0 + + val latestTalk = queryFactory + .select( + Projections.constructor( + CreatorChannelFanTalkRecord::class.java, + creatorCheers.id, + creatorCheers.member.id, + creatorCheers.member.nickname, + creatorCheers.member.profileImage, + creatorCheers.cheers, + creatorCheers.languageCode, + creatorCheers.createdAt + ) + ) + .from(creatorCheers) + .where(fanTalkSummaryCondition(creatorId, viewerId)) + .orderBy(creatorCheers.createdAt.desc(), creatorCheers.id.desc()) + .limit(1) + .fetchFirst() + ?.let { it.copy(nickname = it.nickname.removeDeletedNicknamePrefix()) } + + return CreatorChannelFanTalkSummaryRecord( + totalCount = totalCount, + latestFanTalk = latestTalk + ) + } + + override fun findActivity(creatorId: Long, now: LocalDateTime): CreatorChannelActivityRecord { + val firstLiveAt = queryFactory + .select(liveRoom.beginDateTime.min()) + .from(liveRoom) + .where(liveRoom.member.id.eq(creatorId), liveRoom.channelName.isNotNull, liveRoom.beginDateTime.loe(now)) + .fetchFirst() + val firstAudioAt = firstAudioDebutAt(creatorId, now) + val debutDate = listOfNotNull(firstLiveAt, firstAudioAt).minOrNull() + + return CreatorChannelActivityRecord( + debutDate = debutDate, + dDay = debutDate?.let { "D+${ChronoUnit.DAYS.between(it.toLocalDate(), now.toLocalDate())}" }.orEmpty(), + liveCount = queryFactory + .select(liveRoom.id.count()) + .from(liveRoom) + .where(liveRoom.member.id.eq(creatorId), liveRoom.channelName.isNotNull) + .fetchOne() + ?: 0L, + liveDurationHours = liveDurationHours(creatorId), + liveContributorCount = queryFactory + .select(liveRoomVisit.member.id.count()) + .from(liveRoomVisit) + .innerJoin(liveRoomVisit.room, liveRoom) + .where(liveRoom.member.id.eq(creatorId), liveRoom.channelName.isNotNull) + .fetchOne() + ?: 0L, + audioContentCount = queryFactory + .select(audioContent.id.count()) + .from(audioContent) + .where( + audioContent.member.id.eq(creatorId), + audioContent.isActive.isTrue, + audioContent.releaseDate.isNotNull, + audioContent.releaseDate.loe(now) + ) + .fetchOne() + ?: 0L, + seriesCount = queryFactory + .select(series.id.count()) + .from(series) + .where(series.member.id.eq(creatorId), series.isActive.isTrue) + .fetchOne() + ?: 0L + ) + } + + override fun findSns(creatorId: Long): CreatorChannelSnsRecord { + return queryFactory + .select( + Projections.constructor( + CreatorChannelSnsRecord::class.java, + member.instagramUrl.coalesce(""), + member.fancimmUrl.coalesce(""), + member.xUrl.coalesce(""), + member.youtubeUrl.coalesce(""), + member.websiteUrl.coalesce("") + ) + ) + .from(member) + .where(member.id.eq(creatorId)) + .fetchFirst() + ?: CreatorChannelSnsRecord( + instagramUrl = "", + fancimmUrl = "", + xUrl = "", + youtubeUrl = "", + kakaoOpenChatUrl = "" + ) + } + + private fun findAudioContentRows( + creatorId: Long, + now: LocalDateTime, + excludedContentId: Long?, + canViewAdultContent: Boolean, + limit: Int + ) = queryFactory + .select( + audioContent.id, + audioContent.title, + audioContent.duration, + audioContent.coverImage, + audioContent.price, + audioContent.isAdult, + audioContent.isPointAvailable, + audioContent.releaseDate, + audioContent.createdAt + ) + .from(audioContent) + .where( + audioContent.member.id.eq(creatorId), + audioContent.member.isActive.isTrue, + audioContent.isActive.isTrue, + audioContent.duration.isNotNull, + audioContent.releaseDate.isNotNull, + audioContent.releaseDate.loe(now), + excludedContentId?.let { audioContent.id.ne(it) }, + adultAudioCondition(canViewAdultContent) + ) + .orderBy(audioContent.releaseDate.desc(), audioContent.id.desc()) + .limit(limit.toLong()) + .fetch() + + private fun itAudioId(row: com.querydsl.core.Tuple): Long = row.get(audioContent.id)!! + + private fun com.querydsl.core.Tuple.toAudioRecord( + firstContentId: Long?, + seriesByContentId: Map + ): CreatorChannelAudioContentRecord { + val audioContentId = get(audioContent.id)!! + val seriesSummary = seriesByContentId[audioContentId] + return CreatorChannelAudioContentRecord( + audioContentId = audioContentId, + title = get(audioContent.title)!!, + duration = get(audioContent.duration), + imagePath = get(audioContent.coverImage), + price = get(audioContent.price)!!, + isAdult = get(audioContent.isAdult)!!, + isPointAvailable = get(audioContent.isPointAvailable)!!, + isFirstContent = firstContentId == audioContentId, + publishedAt = get(audioContent.releaseDate)!!, + seriesName = seriesSummary?.title, + isOriginalSeries = seriesSummary?.isOriginal + ) + } + + private fun audioSeriesByContentIds(contentIds: List): Map { + if (contentIds.isEmpty()) return emptyMap() + return queryFactory + .select(seriesContent.content.id, series.title, series.isOriginal) + .from(seriesContent) + .innerJoin(seriesContent.series, series) + .where(seriesContent.content.id.`in`(contentIds)) + .fetch() + .associate { + it.get(seriesContent.content.id)!! to AudioSeriesSummary( + title = it.get(series.title)!!, + isOriginal = it.get(series.isOriginal)!! + ) + } + } + + private fun firstAudioContentId(creatorId: Long, now: LocalDateTime, canViewAdultContent: Boolean): Long? { + return queryFactory + .select(audioContent.id) + .from(audioContent) + .where( + audioContent.member.id.eq(creatorId), + audioContent.member.isActive.isTrue, + audioContent.isActive.isTrue, + audioContent.duration.isNotNull, + audioContent.releaseDate.isNotNull, + audioContent.releaseDate.loe(now), + adultAudioCondition(canViewAdultContent) + ) + .orderBy(audioContent.releaseDate.asc(), audioContent.id.asc()) + .fetchFirst() + } + + private fun orderedCommunityPostIds(creatorId: Long, viewerId: Long?, postIds: List): Set { + if (viewerId == null || postIds.isEmpty()) return emptySet() + if (viewerId == creatorId) return postIds.toSet() + return queryFactory + .select(useCan.communityPost.id) + .from(useCan) + .where( + useCan.member.id.eq(viewerId), + useCan.communityPost.id.`in`(postIds), + useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST), + useCan.isRefund.isFalse + ) + .fetch() + .toSet() + } + + private fun communityLikeCounts(postIds: List): Map { + if (postIds.isEmpty()) return emptyMap() + return queryFactory + .select(creatorCommunityLike.creatorCommunity.id, creatorCommunityLike.id.count()) + .from(creatorCommunityLike) + .where(creatorCommunityLike.creatorCommunity.id.`in`(postIds), creatorCommunityLike.isActive.isTrue) + .groupBy(creatorCommunityLike.creatorCommunity.id) + .fetch() + .associate { + it.get(creatorCommunityLike.creatorCommunity.id)!! to + (it.get(creatorCommunityLike.id.count())?.toInt() ?: 0) + } + } + + private fun communityCommentCounts(postIds: List, viewerId: Long?, isContentCreator: Boolean): Map { + if (postIds.isEmpty()) return emptyMap() + var where = creatorCommunityComment.creatorCommunity.id.`in`(postIds) + .and(creatorCommunityComment.isActive.isTrue) + .and(creatorCommunityComment.parent.isNull) + + if (viewerId != null) { + where = where + .and(creatorCommunityComment.member.id.notIn(blockedMemberIdSubQuery(viewerId))) + .and(creatorCommunityComment.member.id.notIn(blockingMemberIdSubQuery(viewerId))) + } + + if (!isContentCreator) { + where = where.and( + creatorCommunityComment.isSecret.isFalse.or( + viewerId?.let { creatorCommunityComment.member.id.eq(it) } + ?: creatorCommunityComment.isSecret.isFalse + ) + ) + } + + return queryFactory + .select(creatorCommunityComment.creatorCommunity.id, creatorCommunityComment.id.count()) + .from(creatorCommunityComment) + .where(where) + .groupBy(creatorCommunityComment.creatorCommunity.id) + .fetch() + .associate { + it.get(creatorCommunityComment.creatorCommunity.id)!! to + (it.get(creatorCommunityComment.id.count())?.toInt() ?: 0) + } + } + + private fun blockedMemberIdSubQuery(viewerId: Long) = QBlockMember("communityCommentViewerBlock").let { viewerBlock -> + queryFactory + .select(viewerBlock.blockedMember.id) + .from(viewerBlock) + .where(viewerBlock.member.id.eq(viewerId), viewerBlock.isActive.isTrue) + } + + private fun blockingMemberIdSubQuery(viewerId: Long) = QBlockMember("communityCommentWriterBlock").let { writerBlock -> + queryFactory + .select(writerBlock.member.id) + .from(writerBlock) + .where(writerBlock.blockedMember.id.eq(viewerId), writerBlock.isActive.isTrue) + } + + private fun canAccessPaidCommunityContent( + price: Int, + viewerId: Long?, + creatorId: Long, + existOrdered: Boolean + ): Boolean { + return price <= 0 || viewerId == creatorId || existOrdered + } + + private fun maskPaidCommunityContent(content: String, canAccessPaidContent: Boolean): String { + if (canAccessPaidContent) return content + val length = content.codePointCount(0, content.length) + val endIndex = if (length > 15) { + content.offsetByCodePoints(0, 15) + } else { + content.offsetByCodePoints(0, length / 2) + } + return content.substring(0, endIndex).plus("...") + } + + private fun firstAudioDebutAt(creatorId: Long, now: LocalDateTime): LocalDateTime? { + val firstThreeUploads = queryFactory + .select(audioContent.releaseDate, audioContent.createdAt) + .from(audioContent) + .where( + audioContent.member.id.eq(creatorId), + audioContent.duration.isNotNull + ) + .orderBy(audioContent.createdAt.asc(), audioContent.id.asc()) + .limit(3) + .fetch() + + val firstPublishedAt = firstThreeUploads + .mapNotNull { it.get(audioContent.releaseDate) } + .firstOrNull { !it.isAfter(now) } + if (firstPublishedAt != null) return firstPublishedAt + + val thirdUpload = firstThreeUploads.getOrNull(2) ?: return null + return if (thirdUpload.get(audioContent.releaseDate) == null) { + thirdUpload.get(audioContent.createdAt) + } else { + null + } + } + + private fun liveDurationHours(creatorId: Long): Long { + return queryFactory + .select(liveRoom.beginDateTime, liveRoom.updatedAt) + .from(liveRoom) + .where(liveRoom.member.id.eq(creatorId), liveRoom.channelName.isNotNull) + .fetch() + .sumOf { Duration.between(it.get(liveRoom.beginDateTime), it.get(liveRoom.updatedAt)).toSeconds() } / 3600 + } + + private fun adultLiveCondition(canViewAdultContent: Boolean): BooleanExpression? { + return if (canViewAdultContent) null else liveRoom.isAdult.isFalse + } + + private fun adultAudioCondition(canViewAdultContent: Boolean): BooleanExpression? { + return if (canViewAdultContent) null else audioContent.isAdult.isFalse + } + + private fun genderLiveCondition(viewerId: Long?, effectiveViewerGender: Gender?): BooleanExpression? { + if (effectiveViewerGender == null || effectiveViewerGender == Gender.NONE) return null + val genderCondition = when (effectiveViewerGender) { + Gender.MALE -> liveRoom.genderRestriction.`in`(GenderRestriction.ALL, GenderRestriction.MALE_ONLY) + Gender.FEMALE -> liveRoom.genderRestriction.`in`(GenderRestriction.ALL, GenderRestriction.FEMALE_ONLY) + Gender.NONE -> return null + } + return viewerId?.let { genderCondition.or(liveRoom.member.id.eq(it)) } ?: genderCondition + } + + private fun creatorJoinLiveCondition(viewerId: Long?, isViewerCreator: Boolean): BooleanExpression? { + if (!isViewerCreator || viewerId == null) return null + return liveRoom.isAvailableJoinCreator.isTrue.or(liveRoom.member.id.eq(viewerId)) + } + + private fun adultCommunityCondition(canViewAdultContent: Boolean): BooleanExpression? { + return if (canViewAdultContent) null else creatorCommunity.isAdult.isFalse + } + + private fun fixedNoticeCondition(isFixed: Boolean): BooleanExpression? { + return if (isFixed) creatorCommunity.fixedAt.isNotNull else null + } + + private fun visibleCommunityPostCondition(viewerId: Long?): BooleanExpression { + val activePost = creatorCommunity.isActive.isTrue + if (viewerId == null) return activePost + return activePost.or( + queryFactory + .select(useCan.id) + .from(useCan) + .where( + useCan.member.id.eq(viewerId), + useCan.communityPost.id.eq(creatorCommunity.id), + useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST), + useCan.isRefund.isFalse + ) + .exists() + ) + } + + private fun adultSeriesCondition(canViewAdultContent: Boolean): BooleanExpression? { + return if (canViewAdultContent) null else series.isAdult.isFalse + } + + private fun contentTypeSeriesCondition( + canViewAdultContent: Boolean, + contentType: ContentType + ): BooleanExpression? { + if (!canViewAdultContent || contentType == ContentType.ALL) return null + return series.member.isNull.or( + series.member.auth.gender.eq( + if (contentType == ContentType.MALE) 0 else 1 + ) + ) + } + + private fun notBlockedSeriesCreatorCondition(viewerId: Long?): BooleanExpression? { + if (viewerId == null) return null + val seriesCreatorBlock = QBlockMember("seriesCreatorBlockViewer") + return queryFactory + .select(seriesCreatorBlock.id) + .from(seriesCreatorBlock) + .where( + seriesCreatorBlock.isActive.isTrue, + seriesCreatorBlock.member.id.eq(series.member.id).and(seriesCreatorBlock.blockedMember.id.eq(viewerId)) + .or(seriesCreatorBlock.member.id.eq(viewerId).and(seriesCreatorBlock.blockedMember.id.eq(series.member.id))) + ) + .exists() + .not() + } + + private fun donationVisibilityCondition(creatorId: Long, viewerId: Long?): BooleanExpression? { + return if (viewerId == null) { + channelDonationMessage.isSecret.isFalse + } else if (viewerId == creatorId) { + null + } else { + channelDonationMessage.isSecret.isFalse.or(channelDonationMessage.member.id.eq(viewerId)) + } + } + + private fun seriesContentStats( + seriesIds: List, + now: LocalDateTime, + canViewAdultContent: Boolean + ): Map { + if (seriesIds.isEmpty()) return emptyMap() + val publishedAt = audioContent.releaseDate.coalesce(audioContent.createdAt) + return queryFactory + .select(seriesContent.series.id, seriesContent.id.count(), publishedAt.max()) + .from(seriesContent) + .innerJoin(seriesContent.content, audioContent) + .where( + seriesContent.series.id.`in`(seriesIds), + audioContent.isActive.isTrue, + audioContent.duration.isNotNull, + audioContent.releaseDate.isNotNull, + audioContent.releaseDate.loe(now), + adultAudioCondition(canViewAdultContent) + ) + .groupBy(seriesContent.series.id) + .fetch() + .associate { + it.get(seriesContent.series.id)!! to SeriesContentStats( + contentCount = it.get(seriesContent.id.count())?.toInt() ?: 0, + latestPublishedAt = it.get(publishedAt.max())!! + ) + } + } + + private fun newSeriesIds( + seriesIds: List, + now: LocalDateTime, + canViewAdultContent: Boolean + ): Set { + if (seriesIds.isEmpty()) return emptySet() + return queryFactory + .select(seriesContent.series.id) + .from(seriesContent) + .innerJoin(seriesContent.content, audioContent) + .where( + seriesContent.series.id.`in`(seriesIds), + audioContent.isActive.isTrue, + audioContent.duration.isNotNull, + audioContent.releaseDate.between(now.minusDays(7), now), + adultAudioCondition(canViewAdultContent) + ) + .fetch() + .toSet() + } + + private fun notBlockedFanTalkWriterCondition(viewerId: Long?): BooleanExpression? { + if (viewerId == null) return null + val viewerBlock = QBlockMember("viewerBlockFanTalkWriter") + val writerBlock = QBlockMember("writerBlockViewerFanTalk") + return creatorCheers.member.id.notIn( + queryFactory + .select(viewerBlock.blockedMember.id) + .from(viewerBlock) + .where(viewerBlock.member.id.eq(viewerId), viewerBlock.isActive.isTrue) + ).and( + creatorCheers.member.id.notIn( + queryFactory + .select(writerBlock.member.id) + .from(writerBlock) + .where(writerBlock.blockedMember.id.eq(viewerId), writerBlock.isActive.isTrue) + ) + ) + } + + private fun fanTalkSummaryCondition(creatorId: Long, viewerId: Long?): BooleanExpression { + return creatorCheers.creator.id.eq(creatorId) + .and(creatorCheers.isActive.isTrue) + .and(creatorCheers.parent.isNull) + .and(notBlockedFanTalkWriterCondition(viewerId)) + } + + private val CreatorActivityType.sortOrder: Int + get() = when (this) { + CreatorActivityType.LIVE -> 0 + else -> 1 + } + + private data class AudioSeriesSummary( + val title: String, + val isOriginal: Boolean + ) + + private data class SeriesContentStats( + val contentCount: Int, + val latestPublishedAt: LocalDateTime + ) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/port/out/CreatorChannelHomeQueryPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/port/out/CreatorChannelHomeQueryPort.kt new file mode 100644 index 00000000..39c8d8d1 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/port/out/CreatorChannelHomeQueryPort.kt @@ -0,0 +1,184 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.port.out + +import kr.co.vividnext.sodalive.content.ContentType +import kr.co.vividnext.sodalive.member.Gender +import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType +import java.time.LocalDateTime + +interface CreatorChannelHomeQueryPort { + fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCreatorRecord? + + fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean + + fun findCurrentLive( + creatorId: Long, + now: LocalDateTime, + canViewAdultContent: Boolean, + viewerId: Long?, + isViewerCreator: Boolean, + effectiveViewerGender: Gender? + ): CreatorChannelLiveRecord? + + fun findLatestAudioContent( + creatorId: Long, + now: LocalDateTime, + canViewAdultContent: Boolean + ): CreatorChannelAudioContentRecord? + + fun findChannelDonations( + creatorId: Long, + viewerId: Long?, + now: LocalDateTime, + limit: Int = 8 + ): List + + fun findCommunityPosts( + creatorId: Long, + viewerId: Long?, + isFixed: Boolean, + canViewAdultContent: Boolean, + limit: Int = 3 + ): List + + fun findSchedules( + creatorId: Long, + now: LocalDateTime, + canViewAdultContent: Boolean, + viewerId: Long?, + isViewerCreator: Boolean, + effectiveViewerGender: Gender?, + limit: Int = 3 + ): List + + fun findAudioContents( + creatorId: Long, + now: LocalDateTime, + latestAudioContentId: Long?, + canViewAdultContent: Boolean, + limit: Int = 9 + ): List + + fun findSeries( + creatorId: Long, + viewerId: Long?, + now: LocalDateTime, + canViewAdultContent: Boolean, + contentType: ContentType, + limit: Int = 8 + ): List + + fun findFanTalkSummary(creatorId: Long, viewerId: Long?): CreatorChannelFanTalkSummaryRecord + + fun findActivity(creatorId: Long, now: LocalDateTime): CreatorChannelActivityRecord + + fun findSns(creatorId: Long): CreatorChannelSnsRecord +} + +data class CreatorChannelCreatorRecord( + val creatorId: Long, + val characterId: Long?, + val nickname: String, + val profileImagePath: String?, + val introduce: String, + val followerCount: Int, + val isAiChatAvailable: Boolean, + val isDmAvailable: Boolean, + val isFollow: Boolean, + val isNotify: Boolean +) + +data class CreatorChannelLiveRecord( + val liveId: Long, + val title: String, + val coverImagePath: String?, + val beginDateTime: LocalDateTime, + val price: Int, + val isAdult: Boolean +) + +data class CreatorChannelAudioContentRecord( + val audioContentId: Long, + val title: String, + val duration: String?, + val imagePath: String?, + val price: Int, + val isAdult: Boolean, + val isPointAvailable: Boolean, + val isFirstContent: Boolean, + val publishedAt: LocalDateTime, + val seriesName: String?, + val isOriginalSeries: Boolean? +) + +data class CreatorChannelDonationRecord( + val nickname: String, + val profileImagePath: String?, + val can: Int, + val message: String, + val createdAt: LocalDateTime +) + +data class CreatorChannelScheduleRecord( + val scheduledAt: LocalDateTime, + val title: String, + val type: CreatorActivityType, + val targetId: Long, + val isAdult: Boolean +) + +data class CreatorChannelSeriesRecord( + val seriesId: Long, + val title: String, + val coverImagePath: String?, + val numberOfContent: Int, + val isNew: Boolean, + val isOriginal: Boolean +) + +data class CreatorChannelCommunityPostRecord( + val postId: Long, + val creatorId: Long, + val creatorNickname: String, + val creatorProfilePath: String?, + val imagePath: String?, + val audioPath: String?, + val content: String, + val price: Int, + val date: LocalDateTime, + val existOrdered: Boolean, + val likeCount: Int, + val commentCount: Int +) + +data class CreatorChannelFanTalkSummaryRecord( + val totalCount: Int, + val latestFanTalk: CreatorChannelFanTalkRecord? +) + +data class CreatorChannelFanTalkRecord( + val fanTalkId: Long, + val memberId: Long, + val nickname: String, + val profileImagePath: String?, + val content: String, + val languageCode: String?, + val createdAt: LocalDateTime +) + +data class CreatorChannelActivityRecord( + val debutDate: LocalDateTime?, + val dDay: String, + val liveCount: Long, + val liveDurationHours: Long, + val liveContributorCount: Long, + val audioContentCount: Long, + val seriesCount: Long +) + +data class CreatorChannelSnsRecord( + val instagramUrl: String, + val fancimmUrl: String, + val xUrl: String, + val youtubeUrl: String, + val kakaoOpenChatUrl: String +) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt new file mode 100644 index 00000000..aa166a16 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt @@ -0,0 +1,1367 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.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.use.CanUsage +import kr.co.vividnext.sodalive.can.use.UseCan +import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.ContentType +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.creator.admin.content.series.Series +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent +import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers +import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityComment +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLike +import kr.co.vividnext.sodalive.live.room.GenderRestriction +import kr.co.vividnext.sodalive.live.room.LiveRoom +import kr.co.vividnext.sodalive.live.room.visit.LiveRoomVisit +import kr.co.vividnext.sodalive.member.Gender +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberKind +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.auth.Auth +import kr.co.vividnext.sodalive.member.block.BlockMember +import kr.co.vividnext.sodalive.member.following.CreatorFollowing +import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import java.nio.file.Paths +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@DataJpaTest( + properties = [ + "spring.cache.type=none", + "spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE" + ] +) +@Import(QueryDslConfig::class) +class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor( + private val entityManager: EntityManager, + queryFactory: JPAQueryFactory +) { + private val repository = DefaultCreatorChannelHomeQueryRepository(queryFactory) + + @Test + @DisplayName("크리에이터 기본 정보는 활성 팔로워, AI 채팅, DM 가능 여부, 인증 회원 팔로우 상태를 조회한다") + fun shouldFindCreatorProfileWithRelationshipFlags() { + val viewer = saveMember("viewer", MemberRole.USER) + val creator = saveMember("creator", MemberRole.CREATOR) + val inactiveFollower = saveMember("inactive-follower", MemberRole.USER) + val activeFollower = saveMember("active-follower", MemberRole.USER) + saveFollowing(viewer, creator, isActive = true, isNotify = false) + saveFollowing(activeFollower, creator, isActive = true) + saveFollowing(inactiveFollower, creator, isActive = false) + val character = saveCharacter(creator, isActive = true) + + val aiCreator = saveMember("ai-creator", MemberRole.CREATOR, memberKind = MemberKind.AI_CHARACTER) + flushAndClear() + + val record = repository.findCreator(creator.id!!, viewer.id!!) + val aiRecord = repository.findCreator(aiCreator.id!!, viewer.id!!) + + assertNotNull(record) + assertEquals(2, record!!.followerCount) + assertEquals(character.id, record.characterId) + assertTrue(record.isAiChatAvailable) + assertTrue(record.isDmAvailable) + assertTrue(record.isFollow) + assertFalse(record.isNotify) + assertEquals(false, aiRecord!!.isDmAvailable) + assertEquals(null, aiRecord.characterId) + } + + @Test + @DisplayName("활성 ChatCharacter가 없으면 크리에이터 기본 정보의 characterId는 null이다") + fun shouldFindNullCharacterIdWithoutActiveChatCharacter() { + val creator = saveMember("inactive-character-creator", MemberRole.CREATOR) + saveCharacter(creator, isActive = false) + flushAndClear() + + val record = repository.findCreator(creator.id!!, viewerId = null) + + assertEquals(null, record!!.characterId) + assertFalse(record.isAiChatAvailable) + } + + @Test + @DisplayName("크리에이터 기본 정보는 활성 팔로워가 여러 명이어도 DB count 의미로 정확히 계산한다") + fun shouldCountMultipleActiveFollowersAccurately() { + val creator = saveMember("many-followers-creator", MemberRole.CREATOR) + repeat(5) { index -> + saveFollowing(saveMember("active-follower-$index", MemberRole.USER), creator, isActive = true) + } + saveFollowing(saveMember("inactive-follow-row", MemberRole.USER), creator, isActive = false) + saveFollowing(saveMember("inactive-member-follower", MemberRole.USER, isActive = false), creator, isActive = true) + flushAndClear() + + val record = repository.findCreator(creator.id!!, viewerId = null) + + assertEquals(5, record!!.followerCount) + } + + @Test + @DisplayName("홈 repository 조회는 Phase 3 projection/bulk 최적화 대상에서 entity 전체 fetch와 per-row helper를 사용하지 않는다") + fun shouldUseProjectionAndBulkQueriesForPhaseThreeOptimizedMethods() { + val source = Paths.get( + "src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/" + + "DefaultCreatorChannelHomeQueryRepository.kt" + ) + .toFile() + .readText() + + assertFalse(source.contains(".selectFrom(member)"), "findCreator/findSns must project required member columns") + assertFalse(source.contains(".selectFrom(liveRoom)"), "live queries in this repository must project required columns") + assertFalse( + source.contains(".selectFrom(audioContent)"), + "audio queries in this repository must project required columns" + ) + assertFalse(source.contains(".selectFrom(channelDonationMessage)"), "donation query must project required columns") + assertFalse(source.contains(".selectFrom(creatorCommunity)"), "community query must project required columns") + assertFalse(source.contains(".selectFrom(series)"), "series query must project required columns") + assertFalse(source.contains(".select(series)"), "series query must not fetch full Series entity") + assertFalse(source.contains(".selectFrom(creatorCheers)"), "fan talk latest query must project required columns") + assertFalse(source.contains(".fetch()\n .size"), "counts must use DB count instead of fetching ids") + assertFalse(source.contains("existsCommunityOrder("), "community orders must be bulk calculated") + assertFalse(source.contains("countCommunityLikes("), "community likes must be bulk calculated") + assertFalse(source.contains("countCommunityComments("), "community comments must be bulk calculated") + assertFalse(source.contains("publishedSeriesContents("), "series contents must be bulk calculated") + assertFalse(source.contains("hasNewSeriesContent("), "series new flags must be bulk calculated") + assertTrue( + source.contains( + """Projections.constructor( + CreatorChannelLiveRecord::class.java""" + ), + "findCurrentLive must use constructor projection for direct record mapping" + ) + assertTrue( + source.contains( + """Projections.constructor( + CreatorChannelScheduleRecord::class.java""" + ), + "findSchedules must use constructor projection for direct schedule record mapping" + ) + assertTrue( + source.contains( + """Projections.constructor( + CreatorChannelFanTalkRecord::class.java""" + ), + "findFanTalkSummary latest row must use constructor projection for direct record mapping" + ) + assertTrue( + source.contains( + """Projections.constructor( + CreatorChannelSnsRecord::class.java""" + ), + "findSns must use constructor projection for direct record mapping" + ) + } + + @Test + @DisplayName("비활성 팔로우는 알림 상태도 false로 조회한다") + fun shouldNotExposeNotifyForInactiveFollowing() { + val viewer = saveMember("inactive-follow-viewer", MemberRole.USER) + val creator = saveMember("inactive-follow-creator", MemberRole.CREATOR) + saveFollowing(viewer, creator, isActive = false, isNotify = true) + flushAndClear() + + val record = repository.findCreator(creator.id!!, viewer.id!!) + + assertFalse(record!!.isFollow) + assertFalse(record.isNotify) + } + + @Test + @DisplayName("회원과 크리에이터의 양방향 차단 관계를 조회한다") + fun shouldFindBlockedRelationshipBetweenMembers() { + val viewer = saveMember("blocked-viewer", MemberRole.USER) + val creator = saveMember("blocked-creator", MemberRole.CREATOR) + saveBlock(creator, viewer) + flushAndClear() + + assertTrue(repository.existsBlockedBetween(viewer.id!!, creator.id!!)) + } + + @Test + @DisplayName("현재 라이브와 예약 라이브/오디오 스케줄은 활성 상태, 성인 정책, 정렬을 DB에서 적용한다") + fun shouldFindCurrentLiveAndSchedules() { + val now = LocalDateTime.of(2026, 6, 12, 12, 0) + val creator = saveMember("schedule-creator", MemberRole.CREATOR) + val currentLive = saveLiveRoom(creator, now.minusMinutes(10), channelName = "current", isAdult = false) + saveLiveRoom(creator, now.minusMinutes(5), channelName = null, isAdult = false) + saveLiveRoom(creator, now.plusHours(2), channelName = null, isAdult = true) + saveLiveRoom(creator, now.plusMinutes(30), channelName = "future-current-live", isAdult = false) + val liveSchedule = saveLiveRoom(creator, now.plusHours(1), channelName = null, isAdult = false) + val audioSchedule = saveAudioContent(creator, now.plusHours(1), isAdult = false, theme = saveTheme("다시듣기")) + saveAudioContent(creator, now.plusHours(3), isAdult = true) + flushAndClear() + + val live = repository.findCurrentLive( + creator.id!!, + now, + canViewAdultContent = false, + viewerId = null, + isViewerCreator = false, + effectiveViewerGender = null + ) + val schedules = repository.findSchedules( + creator.id!!, + now, + canViewAdultContent = false, + viewerId = null, + isViewerCreator = false, + effectiveViewerGender = null, + limit = 10 + ) + + assertEquals(currentLive.id, live!!.liveId) + assertEquals(listOf(liveSchedule.id, audioSchedule.id), schedules.map { it.targetId }) + assertEquals(listOf(CreatorActivityType.LIVE, CreatorActivityType.AUDIO), schedules.map { it.type }) + assertEquals(false, schedules.any { it.isAdult }) + } + + @Test + @DisplayName("현재 라이브는 조회자 성별 제한과 크리에이터 입장 제한 정책을 반영한다") + fun shouldFindCurrentLiveWithViewerGenderAndCreatorJoinPolicy() { + val now = LocalDateTime.of(2026, 6, 12, 12, 0) + val creator = saveMember("current-live-policy-creator", MemberRole.CREATOR) + val viewerCreator = saveMember("current-live-policy-viewer", MemberRole.CREATOR) + saveLiveRoom( + creator, + now.minusMinutes(5), + channelName = "male-only-current", + isAdult = false, + genderRestriction = GenderRestriction.MALE_ONLY + ) + saveLiveRoom( + creator, + now.minusMinutes(10), + channelName = "creator-hidden-current", + isAdult = false, + isAvailableJoinCreator = false + ) + val visibleLive = saveLiveRoom( + creator, + now.minusMinutes(15), + channelName = "visible-current", + isAdult = false, + genderRestriction = GenderRestriction.ALL, + isAvailableJoinCreator = true + ) + flushAndClear() + + val live = repository.findCurrentLive( + creatorId = creator.id!!, + now = now, + canViewAdultContent = false, + viewerId = viewerCreator.id!!, + isViewerCreator = true, + effectiveViewerGender = Gender.FEMALE + ) + + assertEquals(visibleLive.id, live!!.liveId) + } + + @Test + @DisplayName("예약 라이브 스케줄은 조회자 성별 제한과 크리에이터 입장 제한 정책을 반영한다") + fun shouldFindLiveSchedulesWithViewerGenderAndCreatorJoinPolicy() { + val now = LocalDateTime.of(2026, 6, 12, 12, 0) + val creator = saveMember("schedule-live-policy-creator", MemberRole.CREATOR) + val viewerCreator = saveMember("schedule-live-policy-viewer", MemberRole.CREATOR) + saveLiveRoom( + creator, + now.plusMinutes(30), + channelName = null, + isAdult = false, + genderRestriction = GenderRestriction.MALE_ONLY + ) + saveLiveRoom( + creator, + now.plusMinutes(40), + channelName = null, + isAdult = false, + isAvailableJoinCreator = false + ) + val visibleLive = saveLiveRoom( + creator, + now.plusMinutes(50), + channelName = null, + isAdult = false, + genderRestriction = GenderRestriction.ALL, + isAvailableJoinCreator = true + ) + val audioSchedule = saveAudioContent(creator, now.plusHours(1), isAdult = false) + flushAndClear() + + val schedules = repository.findSchedules( + creatorId = creator.id!!, + now = now, + canViewAdultContent = false, + limit = 10, + viewerId = viewerCreator.id!!, + isViewerCreator = true, + effectiveViewerGender = Gender.FEMALE + ) + + assertEquals(listOf(visibleLive.id, audioSchedule.id), schedules.map { it.targetId }) + assertEquals(listOf(CreatorActivityType.LIVE, CreatorActivityType.AUDIO), schedules.map { it.type }) + } + + @Test + @DisplayName("예약 스케줄은 live/audio 후보 병합 후 시간순, 동일 시각 live 우선, limit을 적용한다") + fun shouldFindSchedulesWithMergedOrderingAndLimit() { + val now = LocalDateTime.of(2026, 6, 12, 12, 0) + val creator = saveMember("schedule-limit-creator", MemberRole.CREATOR) + val audioAtSameTime = saveAudioContent(creator, now.plusHours(1), isAdult = false) + val liveAtSameTime = saveLiveRoom(creator, now.plusHours(1), channelName = null, isAdult = false) + val earlierAudio = saveAudioContent(creator, now.plusMinutes(30), isAdult = false) + saveLiveRoom(creator, now.plusHours(2), channelName = null, isAdult = false) + flushAndClear() + + val schedules = repository.findSchedules( + creator.id!!, + now, + canViewAdultContent = false, + viewerId = null, + isViewerCreator = false, + effectiveViewerGender = null, + limit = 3 + ) + + assertEquals( + listOf(earlierAudio.id, liveAtSameTime.id, audioAtSameTime.id), + schedules.map { it.targetId } + ) + assertEquals( + listOf(CreatorActivityType.AUDIO, CreatorActivityType.LIVE, CreatorActivityType.AUDIO), + schedules.map { it.type } + ) + } + + @Test + @DisplayName("예약 오디오는 활성 상태와 무관하게 duration과 미래 releaseDate가 있으면 스케줄 후보로 조회한다") + fun shouldFindScheduledAudioByDurationAndFutureReleaseDateRegardlessOfActiveStatus() { + val now = LocalDateTime.of(2026, 6, 12, 12, 0) + val creator = saveMember("scheduled-audio-state-creator", MemberRole.CREATOR) + val scheduledAudio = saveAudioContent(creator, now.plusHours(1), isAdult = false) + scheduledAudio.isActive = false + val incompleteScheduledAudio = saveAudioContent(creator, now.plusMinutes(30), isAdult = false) + incompleteScheduledAudio.duration = null + flushAndClear() + + val schedules = repository.findSchedules( + creator.id!!, + now, + canViewAdultContent = false, + viewerId = null, + isViewerCreator = false, + effectiveViewerGender = null, + limit = 3 + ) + + assertEquals(listOf(scheduledAudio.id), schedules.map { it.targetId }) + assertEquals(listOf(CreatorActivityType.AUDIO), schedules.map { it.type }) + } + + @Test + @DisplayName("최신 오디오와 오디오 목록은 공개 콘텐츠만 최신순으로 조회하고 최신 오디오를 제외한다") + fun shouldFindLatestAudioAndAudioContents() { + val now = LocalDateTime.of(2026, 6, 12, 12, 0) + val creator = saveMember("audio-creator", MemberRole.CREATOR) + val first = saveAudioContent(creator, now.minusDays(3), isAdult = false, price = 100, isPointAvailable = true) + val middle = saveAudioContent(creator, now.minusDays(2), isAdult = false, price = 200) + val latest = saveAudioContent(creator, now.minusDays(1), isAdult = false, price = 300) + saveAudioContent(creator, now.plusDays(1), isAdult = false) + val series = saveSeries("original-series", creator, isOriginal = true) + saveSeriesContent(series, middle) + flushAndClear() + + val latestRecord = repository.findLatestAudioContent(creator.id!!, now, canViewAdultContent = false) + val records = repository.findAudioContents( + creator.id!!, + now, + latestAudioContentId = latestRecord!!.audioContentId, + canViewAdultContent = false, + limit = 9 + ) + + assertEquals(latest.id, latestRecord.audioContentId) + assertEquals(listOf(middle.id, first.id), records.map { it.audioContentId }) + assertEquals("original-series", records.first().seriesName) + assertEquals(true, records.first().isOriginalSeries) + assertEquals(true, records.last().isFirstContent) + assertEquals(100, records.last().price) + assertTrue(records.last().isPointAvailable) + } + + @Test + @DisplayName("오디오 목록은 releaseDate가 null인 콘텐츠를 제외한다") + fun shouldExcludeNullReleaseDateAudioContent() { + val now = LocalDateTime.of(2026, 6, 12, 12, 0) + val creator = saveMember("null-release-creator", MemberRole.CREATOR) + val nullRelease = saveAudioContent(creator, now.minusDays(2), isAdult = false) + nullRelease.releaseDate = null + val dated = saveAudioContent(creator, now.minusDays(1), isAdult = false) + flushAndClear() + + val records = repository.findAudioContents( + creator.id!!, + now, + latestAudioContentId = dated.id, + canViewAdultContent = false, + limit = 9 + ) + + assertEquals(emptyList(), records.map { it.audioContentId }) + } + + @Test + @DisplayName("첫 오디오 콘텐츠 여부는 조회자에게 보이는 공개 콘텐츠 기준으로 판정한다") + fun shouldMarkFirstAudioContentByVisibleAdultPolicy() { + val now = LocalDateTime.of(2026, 6, 12, 12, 0) + val creator = saveMember("visible-first-audio-creator", MemberRole.CREATOR) + saveAudioContent(creator, now.minusDays(3), isAdult = true) + val visibleFirst = saveAudioContent(creator, now.minusDays(2), isAdult = false) + val latest = saveAudioContent(creator, now.minusDays(1), isAdult = false) + flushAndClear() + + val records = repository.findAudioContents( + creator.id!!, + now, + latestAudioContentId = latest.id, + canViewAdultContent = false, + limit = 9 + ) + + assertEquals(listOf(visibleFirst.id), records.map { it.audioContentId }) + assertTrue(records.single().isFirstContent) + } + + @Test + @DisplayName("단독 오디오는 duration이 없는 미완성 콘텐츠를 최신/목록/첫 콘텐츠 판정에서 제외한다") + fun shouldExcludeStandaloneAudioWithoutDurationFromLatestListAndFirstContent() { + val now = LocalDateTime.of(2026, 6, 12, 12, 0) + val creator = saveMember("duration-audio-creator", MemberRole.CREATOR) + val incompleteFirst = saveAudioContent(creator, now.minusDays(4), isAdult = false) + incompleteFirst.duration = null + val completedFirst = saveAudioContent(creator, now.minusDays(3), isAdult = false) + val completedLatest = saveAudioContent(creator, now.minusDays(1), isAdult = false) + val incompleteLatest = saveAudioContent(creator, now.minusMinutes(30), isAdult = false) + incompleteLatest.duration = null + flushAndClear() + + val latest = repository.findLatestAudioContent(creator.id!!, now, canViewAdultContent = false) + val records = repository.findAudioContents(creator.id!!, now, latest!!.audioContentId, false, limit = 9) + + assertEquals(completedLatest.id, latest.audioContentId) + assertEquals(listOf(completedFirst.id), records.map { it.audioContentId }) + assertTrue(records.single().isFirstContent) + } + + @Test + @DisplayName("채널 후원, 공지, 커뮤니티, 팬 Talk는 기존 전체보기 의미에 맞는 요약을 조회한다") + fun shouldFindDonationsCommunitiesAndFanTalkSummary() { + val now = LocalDateTime.of(2026, 6, 12, 12, 0) + val creator = saveMember("community-creator", MemberRole.CREATOR) + val viewer = saveMember("community-viewer", MemberRole.USER) + val donor = saveMember("community-donor", MemberRole.USER) + val blockedWriter = saveMember("blocked-talk-writer", MemberRole.USER) + val donation = saveDonation(creator, donor, 300, now.minusDays(1)) + saveDonation(creator, donor, 100, now.minusMonths(1)) + val notice = saveCommunity(creator, isFixed = true, fixedAt = now.minusHours(1), price = 0) + saveCommunity(creator, isFixed = true, fixedAt = null, price = 0) + val post = saveCommunity(creator, isFixed = false, price = 100, imagePath = "community.png", audioPath = "community.mp3") + saveCommunityLike(viewer, post, isActive = true) + saveCommunityComment(viewer, post, isActive = true) + saveCommunityOrder(viewer, post, isRefund = false) + val latestTalk = saveCheers(viewer, creator, "latest", isActive = true, now.minusMinutes(1)) + saveCheers(blockedWriter, creator, "blocked", isActive = true, now) + saveBlock(viewer, blockedWriter) + flushAndClear() + + val donations = repository.findChannelDonations(creator.id!!, viewer.id!!, now, limit = 8) + val notices = repository.findCommunityPosts( + creator.id!!, + viewer.id!!, + isFixed = true, + canViewAdultContent = false, + limit = 3 + ) + val posts = repository.findCommunityPosts( + creator.id!!, + viewer.id!!, + isFixed = false, + canViewAdultContent = false, + limit = 3 + ) + val fanTalk = repository.findFanTalkSummary(creator.id!!, viewer.id!!) + + assertEquals(listOf(donation.can), donations.map { it.can }) + assertEquals(listOf(notice.id), notices.map { it.postId }) + assertEquals(listOf(post.id), posts.map { it.postId }) + assertEquals(1, posts.single().likeCount) + assertEquals(1, posts.single().commentCount) + assertTrue(posts.single().existOrdered) + assertEquals(1, fanTalk.totalCount) + assertEquals(latestTalk.id, fanTalk.latestFanTalk!!.fanTalkId) + } + + @Test + @DisplayName("팬 Talk 요약은 활성 최상위 글 전체 개수와 최신 1개만 조회한다") + fun shouldSummarizeFanTalkWithTotalCountAndLatestOnly() { + val creator = saveMember("fan-talk-summary-creator", MemberRole.CREATOR) + val viewer = saveMember("fan-talk-summary-viewer", MemberRole.USER) + val writer = saveMember("fan-talk-summary-writer", MemberRole.USER) + val older = saveCheers(writer, creator, "older", isActive = true, LocalDateTime.of(2026, 6, 12, 11, 0)) + val latest = saveCheers(writer, creator, "latest", isActive = true, LocalDateTime.of(2026, 6, 12, 12, 0)) + saveCheers(writer, creator, "inactive", isActive = false, LocalDateTime.of(2026, 6, 12, 13, 0)) + saveCheers(writer, creator, "reply", isActive = true, LocalDateTime.of(2026, 6, 12, 14, 0), parent = older) + flushAndClear() + + val summary = repository.findFanTalkSummary(creator.id!!, viewer.id!!) + + assertEquals(2, summary.totalCount) + assertEquals(latest.id, summary.latestFanTalk!!.fanTalkId) + assertEquals("latest", summary.latestFanTalk!!.content) + } + + @Test + @DisplayName("커뮤니티는 성인 정책과 작성자 본인의 구매 여부 의미를 반영한다") + fun shouldFilterAdultCommunityAndTreatCreatorAsOrdered() { + val creator = saveMember("adult-community-creator", MemberRole.CREATOR) + val visiblePost = saveCommunity(creator, isFixed = false, price = 100) + saveCommunity(creator, isFixed = false, price = 100, isAdult = true) + flushAndClear() + + val viewerPosts = repository.findCommunityPosts( + creator.id!!, + viewerId = creator.id!!, + isFixed = false, + canViewAdultContent = false, + limit = 3 + ) + + assertEquals(listOf(visiblePost.id), viewerPosts.map { it.postId }) + assertTrue(viewerPosts.single().existOrdered) + } + + @Test + @DisplayName("유료 커뮤니티는 비구매자에게 본문을 축약하고 오디오를 숨긴다") + fun shouldMaskPaidCommunityContentAndAudioForNonBuyer() { + val creator = saveMember("paid-community-creator", MemberRole.CREATOR) + val viewer = saveMember("paid-community-viewer", MemberRole.USER) + val content = "12345678901234567890" + saveCommunity( + creator, + isFixed = false, + price = 100, + audioPath = "paid-audio.mp3", + content = content + ) + flushAndClear() + + val posts = repository.findCommunityPosts( + creator.id!!, + viewerId = viewer.id!!, + isFixed = false, + canViewAdultContent = false, + limit = 3 + ) + + assertEquals("123456789012345...", posts.single().content) + assertEquals(null, posts.single().audioPath) + assertFalse(posts.single().existOrdered) + } + + @Test + @DisplayName("유료 커뮤니티는 구매자와 작성자에게 본문과 오디오를 노출한다") + fun shouldExposePaidCommunityContentAndAudioForBuyerAndCreator() { + val creator = saveMember("paid-community-owner", MemberRole.CREATOR) + val buyer = saveMember("paid-community-buyer", MemberRole.USER) + val post = saveCommunity( + creator, + isFixed = false, + price = 100, + audioPath = "paid-visible.mp3", + content = "paid full content" + ) + saveCommunityOrder(buyer, post, isRefund = false) + flushAndClear() + + val buyerPosts = repository.findCommunityPosts( + creator.id!!, + viewerId = buyer.id!!, + isFixed = false, + canViewAdultContent = false, + limit = 3 + ) + val creatorPosts = repository.findCommunityPosts( + creator.id!!, + viewerId = creator.id!!, + isFixed = false, + canViewAdultContent = false, + limit = 3 + ) + + assertEquals("paid full content", buyerPosts.single().content) + assertEquals("paid-visible.mp3", buyerPosts.single().audioPath) + assertTrue(buyerPosts.single().existOrdered) + assertEquals("paid full content", creatorPosts.single().content) + assertEquals("paid-visible.mp3", creatorPosts.single().audioPath) + assertTrue(creatorPosts.single().existOrdered) + } + + @Test + @DisplayName("구매한 유료 커뮤니티는 크리에이터가 삭제해도 구매자에게 조회된다") + fun shouldExposeDeletedPaidCommunityContentToBuyer() { + val creator = saveMember("deleted-paid-community-creator", MemberRole.CREATOR) + val buyer = saveMember("deleted-paid-community-buyer", MemberRole.USER) + val nonBuyer = saveMember("deleted-paid-community-non-buyer", MemberRole.USER) + val post = saveCommunity( + creator, + isFixed = false, + price = 100, + audioPath = "deleted-paid.mp3", + content = "deleted paid content", + isActive = false + ) + saveCommunityOrder(buyer, post, isRefund = false) + flushAndClear() + + val buyerPosts = repository.findCommunityPosts( + creator.id!!, + viewerId = buyer.id!!, + isFixed = false, + canViewAdultContent = false, + limit = 3 + ) + val nonBuyerPosts = repository.findCommunityPosts( + creator.id!!, + viewerId = nonBuyer.id!!, + isFixed = false, + canViewAdultContent = false, + limit = 3 + ) + + assertEquals(listOf(post.id), buyerPosts.map { it.postId }) + assertEquals("deleted paid content", buyerPosts.single().content) + assertEquals("deleted-paid.mp3", buyerPosts.single().audioPath) + assertTrue(buyerPosts.single().existOrdered) + assertEquals(emptyList(), nonBuyerPosts.map { it.postId }) + } + + @Test + @DisplayName("커뮤니티 댓글 수는 기존 목록처럼 보이는 최상위 댓글만 계산한다") + fun shouldCountVisibleRootCommunityCommentsOnly() { + val creator = saveMember("comment-count-creator", MemberRole.CREATOR) + val viewer = saveMember("comment-count-viewer", MemberRole.USER) + val blockedWriter = saveMember("comment-count-blocked", MemberRole.USER) + val blockingWriter = saveMember("comment-count-blocking", MemberRole.USER) + val secretWriter = saveMember("comment-count-secret", MemberRole.USER) + val post = saveCommunity(creator, isFixed = false, price = 0) + val visibleRoot = saveCommunityComment(viewer, post, isActive = true) + saveCommunityComment(viewer, post, isActive = true, parent = visibleRoot) + saveCommunityComment(viewer, post, isActive = false) + saveCommunityComment(blockedWriter, post, isActive = true) + saveCommunityComment(blockingWriter, post, isActive = true) + saveCommunityComment(secretWriter, post, isActive = true, isSecret = true) + saveBlock(viewer, blockedWriter) + saveBlock(blockingWriter, viewer) + flushAndClear() + + val posts = repository.findCommunityPosts( + creator.id!!, + viewerId = viewer.id!!, + isFixed = false, + canViewAdultContent = false, + limit = 3 + ) + + assertEquals(1, posts.single().commentCount) + } + + @Test + @DisplayName("커뮤니티 댓글 수는 댓글 불가 게시글이면 기존 목록처럼 0으로 계산한다") + fun shouldReturnZeroCommentCountWhenCommunityCommentUnavailable() { + val creator = saveMember("comment-unavailable-creator", MemberRole.CREATOR) + val viewer = saveMember("comment-unavailable-viewer", MemberRole.USER) + val post = saveCommunity(creator, isFixed = false, price = 0, isCommentAvailable = false) + saveCommunityComment(viewer, post, isActive = true) + flushAndClear() + + val posts = repository.findCommunityPosts( + creator.id!!, + viewerId = viewer.id!!, + isFixed = false, + canViewAdultContent = false, + limit = 3 + ) + + assertEquals(0, posts.single().commentCount) + } + + @Test + @DisplayName("채널 후원은 KST 기준 이번 달과 크리에이터의 비밀 후원 열람을 반영한다") + fun shouldFindKstMonthDonationsAndExposeSecretDonationToCreator() { + val now = LocalDateTime.of(2026, 5, 31, 15, 30) + val creator = saveMember("kst-donation-creator", MemberRole.CREATOR) + val donor = saveMember("kst-donation-donor", MemberRole.USER) + saveDonation(creator, donor, 300, LocalDateTime.of(2026, 5, 31, 15, 10), isSecret = true) + saveDonation(creator, donor, 100, LocalDateTime.of(2026, 5, 31, 14, 50), isSecret = false) + val thirdParty = saveMember("kst-donation-third-party", MemberRole.USER) + flushAndClear() + + val creatorView = repository.findChannelDonations(creator.id!!, creator.id!!, now, limit = 8) + val donorView = repository.findChannelDonations(creator.id!!, donor.id!!, now, limit = 8) + val thirdPartyView = repository.findChannelDonations(creator.id!!, thirdParty.id!!, now, limit = 8) + + assertEquals(listOf(300), creatorView.map { it.can }) + assertEquals(listOf(300), donorView.map { it.can }) + assertEquals(emptyList(), thirdPartyView.map { it.can }) + } + + @Test + @DisplayName("채널 후원 메시지는 추가 메시지만 반환하고 없으면 빈 문자열을 반환한다") + fun shouldReturnOnlyAdditionalChannelDonationMessage() { + val now = LocalDateTime.of(2026, 6, 12, 12, 0) + val creator = saveMember("message-donation-creator", MemberRole.CREATOR) + val donor = saveMember("message-donation-donor", MemberRole.USER) + saveDonation( + creator, + donor, + 1000, + now.minusMinutes(1), + isSecret = true, + additionalMessage = "응원합니다" + ) + saveDonation( + creator, + donor, + 3, + now.minusMinutes(2), + isSecret = false, + additionalMessage = null + ) + flushAndClear() + + val records = repository.findChannelDonations(creator.id!!, donor.id!!, now, limit = 8) + + assertEquals(listOf(1000, 3), records.map { it.can }) + assertEquals("응원합니다", records.first().message) + assertEquals("", records.last().message) + } + + @Test + @DisplayName("채널 후원 메시지는 요청 언어와 무관하게 추가 메시지를 그대로 반환한다") + fun shouldReturnAdditionalChannelDonationMessageWithoutRequestLanguage() { + val now = LocalDateTime.of(2026, 6, 12, 12, 0) + val creator = saveMember("i18n-donation-creator", MemberRole.CREATOR) + val donor = saveMember("i18n-donation-donor", MemberRole.USER) + saveDonation(creator, donor, 1000, now.minusMinutes(1), isSecret = true, additionalMessage = "cheer") + flushAndClear() + + val records = repository.findChannelDonations(creator.id!!, donor.id!!, now, limit = 8) + + assertEquals("cheer", records.single().message) + } + + @Test + @DisplayName("팬 Talk는 작성자가 조회자를 차단한 경우도 제외한다") + fun shouldExcludeFanTalkWhenWriterBlocksViewer() { + val creator = saveMember("fan-talk-creator", MemberRole.CREATOR) + val viewer = saveMember("fan-talk-viewer", MemberRole.USER) + val writer = saveMember("fan-talk-writer", MemberRole.USER) + saveCheers(writer, creator, "hidden", isActive = true, LocalDateTime.of(2026, 6, 12, 12, 0)) + saveBlock(writer, viewer) + flushAndClear() + + val summary = repository.findFanTalkSummary(creator.id!!, viewer.id!!) + + assertEquals(0, summary.totalCount) + assertEquals(null, summary.latestFanTalk) + } + + @Test + @DisplayName("시리즈는 소속 공개 콘텐츠 최신 공개 시각순으로 최대 8개를 조회한다") + fun shouldFindSeriesOrderedByLatestPublishedContent() { + val now = LocalDateTime.of(2026, 6, 12, 12, 0) + val creator = saveMember("series-creator", MemberRole.CREATOR) + val oldSeries = saveSeries("old-series", creator) + val newSeries = saveSeries("new-series", creator, isOriginal = true) + saveSeriesContent(oldSeries, saveAudioContent(creator, now.minusDays(3), isAdult = false)) + saveSeriesContent(newSeries, saveAudioContent(creator, now.minusDays(1), isAdult = false)) + flushAndClear() + + val records = repository.findSeries( + creator.id!!, + viewerId = null, + now, + canViewAdultContent = false, + contentType = ContentType.ALL, + limit = 8 + ) + + assertEquals(listOf(newSeries.id, oldSeries.id), records.map { it.seriesId }) + assertEquals(true, records.first().isOriginal) + assertEquals(1, records.first().numberOfContent) + } + + @Test + @DisplayName("시리즈는 성인 정책, 콘텐츠 타입, 차단, 신규 표시를 기존 목록 의미로 반영한다") + fun shouldFindSeriesWithVisibilityAndNewFlags() { + val now = LocalDateTime.of(2026, 6, 12, 12, 0) + val viewer = saveMember("series-viewer", MemberRole.USER) + val creator = saveMember("series-policy-creator", MemberRole.CREATOR) + saveAuth(creator, gender = 0) + val hiddenByContentTypeCreator = saveMember("female-series-creator", MemberRole.CREATOR) + saveAuth(hiddenByContentTypeCreator, gender = 1) + val blockedCreator = saveMember("blocked-series-creator", MemberRole.CREATOR) + saveBlock(viewer, blockedCreator) + + val visibleSeries = saveSeries("visible-series", creator) + saveSeriesContent(visibleSeries, saveAudioContent(creator, now.minusDays(2), isAdult = false)) + val newSeries = saveSeries("new-series-flag", creator) + saveSeriesContent(newSeries, saveAudioContent(creator, now.minusDays(1), isAdult = false)) + val durationMissingSeries = saveSeries("duration-missing-series", creator) + val durationMissingContent = saveAudioContent(creator, now.minusMinutes(10), isAdult = false) + durationMissingContent.duration = null + saveSeriesContent(durationMissingSeries, durationMissingContent) + val adultSeries = saveSeries("adult-series", creator, isAdult = true) + saveSeriesContent(adultSeries, saveAudioContent(creator, now.minusHours(1), isAdult = false)) + val adultContentSeries = saveSeries("adult-content-series", creator) + saveSeriesContent(adultContentSeries, saveAudioContent(creator, now.minusHours(2), isAdult = true)) + val contentTypeHiddenSeries = saveSeries("content-type-hidden", hiddenByContentTypeCreator) + saveSeriesContent( + contentTypeHiddenSeries, + saveAudioContent(hiddenByContentTypeCreator, now.minusMinutes(30), isAdult = false) + ) + val blockedSeries = saveSeries("blocked-series", blockedCreator) + saveSeriesContent(blockedSeries, saveAudioContent(blockedCreator, now.minusMinutes(20), isAdult = false)) + flushAndClear() + + val records = repository.findSeries( + creatorId = creator.id!!, + viewerId = viewer.id!!, + now = now, + canViewAdultContent = false, + contentType = ContentType.MALE, + limit = 8 + ) + val crossCreatorRecords = repository.findSeries( + creatorId = hiddenByContentTypeCreator.id!!, + viewerId = viewer.id!!, + now = now, + canViewAdultContent = true, + contentType = ContentType.MALE, + limit = 8 + ) + val blockedRecords = repository.findSeries( + creatorId = blockedCreator.id!!, + viewerId = viewer.id!!, + now = now, + canViewAdultContent = true, + contentType = ContentType.ALL, + limit = 8 + ) + + assertEquals(listOf(newSeries.id, visibleSeries.id), records.map { it.seriesId }) + assertTrue(records.first().isNew) + assertEquals(emptyList(), crossCreatorRecords.map { it.seriesId }) + assertEquals(emptyList(), blockedRecords.map { it.seriesId }) + } + + @Test + @DisplayName("시리즈 신규 표시는 기존 목록처럼 7일 전과 현재 시각 공개 콘텐츠를 포함한다") + fun shouldMarkSeriesNewAtSevenDayAndNowBoundaries() { + val now = LocalDateTime.of(2026, 6, 12, 12, 0) + val creator = saveMember("series-new-boundary-creator", MemberRole.CREATOR) + val sevenDaysAgoSeries = saveSeries("seven-days-ago-series", creator) + saveSeriesContent(sevenDaysAgoSeries, saveAudioContent(creator, now.minusDays(7), isAdult = false)) + val nowSeries = saveSeries("now-series", creator) + saveSeriesContent(nowSeries, saveAudioContent(creator, now, isAdult = false)) + flushAndClear() + + val records = repository.findSeries( + creatorId = creator.id!!, + viewerId = null, + now = now, + canViewAdultContent = false, + contentType = ContentType.ALL, + limit = 8 + ) + + assertEquals(listOf(nowSeries.id, sevenDaysAgoSeries.id), records.map { it.seriesId }) + assertTrue(records.all { it.isNew }) + } + + @Test + @DisplayName("releaseDate가 null인 오디오는 최신/목록/첫 콘텐츠 판정에서 제외한다") + fun shouldExcludeNullReleaseDateAudioFromLatestListAndFirstContent() { + val now = LocalDateTime.of(2026, 6, 12, 12, 0) + val creator = saveMember("null-release-order-creator", MemberRole.CREATOR) + val oldNullRelease = saveAudioContent(creator, now.minusDays(5), isAdult = false) + oldNullRelease.releaseDate = null + val newNullRelease = saveAudioContent(creator, now.minusDays(4), isAdult = false) + newNullRelease.releaseDate = null + val visibleFirst = saveAudioContent(creator, now.minusDays(3), isAdult = false) + val visibleLatest = saveAudioContent(creator, now.minusDays(1), isAdult = false) + flushAndClear() + updateCreatedAt("AudioContent", oldNullRelease.id!!, now.minusDays(3)) + updateCreatedAt("AudioContent", newNullRelease.id!!, now.minusDays(1)) + flushAndClear() + + val latest = repository.findLatestAudioContent(creator.id!!, now, canViewAdultContent = false) + val records = repository.findAudioContents(creator.id!!, now, latest!!.audioContentId, false, limit = 9) + + assertEquals(visibleLatest.id, latest.audioContentId) + assertEquals(listOf(visibleFirst.id), records.map { it.audioContentId }) + assertTrue(records.single().isFirstContent) + } + + @Test + @DisplayName("시리즈는 releaseDate가 null인 콘텐츠를 공개 콘텐츠로 집계하지 않는다") + fun shouldExcludeNullReleaseDateAudioFromSeriesContent() { + val now = LocalDateTime.of(2026, 6, 12, 12, 0) + val creator = saveMember("null-release-series-creator", MemberRole.CREATOR) + val hiddenSeries = saveSeries("null-release-series", creator) + val nullRelease = saveAudioContent(creator, now.minusDays(1), isAdult = false) + nullRelease.releaseDate = null + saveSeriesContent(hiddenSeries, nullRelease) + val visibleSeries = saveSeries("dated-release-series", creator) + saveSeriesContent(visibleSeries, saveAudioContent(creator, now.minusDays(2), isAdult = false)) + flushAndClear() + + val records = repository.findSeries( + creatorId = creator.id!!, + viewerId = null, + now = now, + canViewAdultContent = false, + contentType = ContentType.ALL, + limit = 8 + ) + + assertEquals(listOf(visibleSeries.id), records.map { it.seriesId }) + } + + @Test + @DisplayName("팬 Talk는 조회자 기준 차단 관계만 기존 목록 정책처럼 제외한다") + fun shouldFilterFanTalkByViewerBlockOnly() { + val creator = saveMember("viewer-block-talk-creator", MemberRole.CREATOR) + val viewer = saveMember("viewer-block-talk-viewer", MemberRole.USER) + val creatorBlockedWriter = saveMember("creator-blocked-talk-writer", MemberRole.USER) + val viewerBlockedWriter = saveMember("viewer-blocked-talk-writer", MemberRole.USER) + val writerBlockedViewer = saveMember("writer-blocked-talk-viewer", MemberRole.USER) + val visibleTalk = saveCheers( + creatorBlockedWriter, + creator, + "visible", + isActive = true, + LocalDateTime.of(2026, 6, 12, 12, 0) + ) + saveCheers(viewerBlockedWriter, creator, "viewer-hidden", isActive = true, LocalDateTime.of(2026, 6, 12, 12, 1)) + saveCheers(writerBlockedViewer, creator, "writer-hidden", isActive = true, LocalDateTime.of(2026, 6, 12, 12, 2)) + saveBlock(creator, creatorBlockedWriter) + saveBlock(viewer, viewerBlockedWriter) + saveBlock(writerBlockedViewer, viewer) + flushAndClear() + + val summary = repository.findFanTalkSummary(creator.id!!, viewer.id!!) + + assertEquals(1, summary.totalCount) + assertEquals(visibleTalk.id, summary.latestFanTalk!!.fanTalkId) + } + + @Test + @DisplayName("팬 Talk 작성자 닉네임은 삭제 회원 prefix를 제거해 반환한다") + fun shouldRemoveDeletedNicknamePrefixFromFanTalkWriter() { + val creator = saveMember("deleted-prefix-talk-creator", MemberRole.CREATOR) + val viewer = saveMember("deleted-prefix-talk-viewer", MemberRole.USER) + val writer = saveMember("deleted_fan", MemberRole.USER) + saveCheers(writer, creator, "visible", isActive = true, LocalDateTime.of(2026, 6, 12, 12, 0)) + flushAndClear() + + val summary = repository.findFanTalkSummary(creator.id!!, viewer.id!!) + + assertEquals("fan", summary.latestFanTalk!!.nickname) + } + + @Test + @DisplayName("소개, 활동, SNS는 기존 상세 API 의미와 같은 값으로 조회한다") + fun shouldFindIntroduceActivityAndSns() { + val now = LocalDateTime.of(2026, 6, 12, 12, 0) + val creator = saveMember("activity-creator", MemberRole.CREATOR) + creator.introduce = "creator introduce" + creator.instagramUrl = "instagram" + creator.fancimmUrl = "fancimm" + creator.xUrl = "x" + creator.youtubeUrl = "youtube" + creator.websiteUrl = "website" + val live = saveLiveRoom(creator, now.minusDays(4), channelName = "activity-live", isAdult = false) + saveAudioContent(creator, now.minusDays(3), isAdult = false) + saveAudioContent(creator, now.plusDays(1), isAdult = false) + saveLiveRoom(creator, now.minusDays(10), channelName = null, isAdult = false) + val futureLive = saveLiveRoom(creator, now.plusDays(1), channelName = "future-live", isAdult = false) + saveVisit(live, saveMember("visitor-1", MemberRole.USER)) + saveVisit(live, saveMember("visitor-2", MemberRole.USER)) + flushAndClear() + updateUpdatedAt("LiveRoom", live.id!!, now.minusDays(4).plusHours(2)) + updateUpdatedAt("LiveRoom", futureLive.id!!, now.plusDays(1).plusHours(1)) + flushAndClear() + + val creatorRecord = repository.findCreator(creator.id!!, viewerId = null) + val activity = repository.findActivity(creator.id!!, now) + val sns = repository.findSns(creator.id!!) + + assertEquals("creator introduce", creatorRecord!!.introduce) + assertEquals(now.minusDays(4), activity.debutDate) + assertEquals(2, activity.liveCount) + assertEquals(3, activity.liveDurationHours) + assertEquals(2, activity.liveContributorCount) + assertEquals(1, activity.audioContentCount) + assertEquals("instagram", sns.instagramUrl) + assertEquals("website", sns.kakaoOpenChatUrl) + } + + @Test + @DisplayName("데뷔일은 첫 두 오디오가 삭제되면 세 번째 오디오 공개 시각을 오디오 후보로 사용한다") + fun shouldUseThirdAudioReleaseDateForDebutWhenFirstTwoAudioContentsAreDeleted() { + val now = LocalDateTime.of(2026, 6, 12, 12, 0) + val creator = saveMember("debut-third-release-creator", MemberRole.CREATOR) + val first = saveAudioContent(creator, now.minusDays(10), isAdult = false) + val second = saveAudioContent(creator, now.minusDays(9), isAdult = false) + val third = saveAudioContent(creator, now.minusDays(8), isAdult = false) + first.isActive = false + first.releaseDate = null + second.isActive = false + second.releaseDate = null + flushAndClear() + updateCreatedAt("AudioContent", first.id!!, now.minusDays(10)) + updateCreatedAt("AudioContent", second.id!!, now.minusDays(9)) + updateCreatedAt("AudioContent", third.id!!, now.minusDays(8)) + flushAndClear() + + val activity = repository.findActivity(creator.id!!, now) + + assertEquals(now.minusDays(8), activity.debutDate) + } + + @Test + @DisplayName("데뷔일은 세 번째 오디오가 삭제되면 네 번째로 넘어가지 않고 세 번째 createdAt을 오디오 후보로 사용한다") + fun shouldUseThirdAudioCreatedAtForDebutWhenThirdAudioContentIsDeleted() { + val now = LocalDateTime.of(2026, 6, 12, 12, 0) + val creator = saveMember("debut-third-deleted-creator", MemberRole.CREATOR) + val first = saveAudioContent(creator, now.minusDays(10), isAdult = false) + val second = saveAudioContent(creator, now.minusDays(9), isAdult = false) + val third = saveAudioContent(creator, now.minusDays(8), isAdult = false) + val fourth = saveAudioContent(creator, now.minusDays(1), isAdult = false) + first.isActive = false + first.releaseDate = null + second.isActive = false + second.releaseDate = null + third.isActive = false + third.releaseDate = null + flushAndClear() + updateCreatedAt("AudioContent", first.id!!, now.minusDays(10)) + updateCreatedAt("AudioContent", second.id!!, now.minusDays(9)) + updateCreatedAt("AudioContent", third.id!!, now.minusDays(8)) + updateCreatedAt("AudioContent", fourth.id!!, now.minusDays(7)) + flushAndClear() + + val activity = repository.findActivity(creator.id!!, now) + + assertEquals(now.minusDays(8), activity.debutDate) + assertEquals(1, activity.audioContentCount) + } + + private fun saveMember( + nickname: String, + role: MemberRole, + isActive: Boolean = true, + memberKind: MemberKind = MemberKind.HUMAN + ): Member { + val member = Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + profileImage = "$nickname.png", + role = role, + memberKind = memberKind, + isActive = isActive + ) + entityManager.persist(member) + return member + } + + private fun saveFollowing( + member: Member, + creator: Member, + isActive: Boolean, + isNotify: Boolean = true + ): CreatorFollowing { + val following = CreatorFollowing(isNotify = isNotify, isActive = isActive) + following.member = member + following.creator = creator + entityManager.persist(following) + return following + } + + private fun saveCharacter(creator: Member, isActive: Boolean): ChatCharacter { + val character = ChatCharacter( + characterUUID = "${creator.nickname}-uuid", + name = creator.nickname, + description = "description", + systemPrompt = "system", + isActive = isActive + ) + character.creatorMember = creator + entityManager.persist(character) + return character + } + + private fun saveBlock(member: Member, blockedMember: Member): BlockMember { + val block = BlockMember(isActive = true) + block.member = member + block.blockedMember = blockedMember + entityManager.persist(block) + return block + } + + private fun saveLiveRoom( + creator: Member, + beginDateTime: LocalDateTime, + channelName: String?, + isAdult: Boolean, + isActive: Boolean = true, + genderRestriction: GenderRestriction = GenderRestriction.ALL, + isAvailableJoinCreator: Boolean = true + ): LiveRoom { + val liveRoom = LiveRoom( + title = "live-${creator.nickname}-$beginDateTime", + notice = "notice", + beginDateTime = beginDateTime, + numberOfPeople = 0, + coverImage = "live.png", + isAdult = isAdult, + price = 50, + isAvailableJoinCreator = isAvailableJoinCreator, + genderRestriction = genderRestriction + ) + liveRoom.member = creator + liveRoom.channelName = channelName + liveRoom.isActive = isActive + entityManager.persist(liveRoom) + return liveRoom + } + + private fun saveAudioContent( + creator: Member, + releaseDate: LocalDateTime, + isAdult: Boolean, + theme: AudioContentTheme = saveTheme("theme-${creator.nickname}-$releaseDate"), + price: Int = 0, + isPointAvailable: Boolean = false + ): AudioContent { + val content = AudioContent( + title = "audio-${creator.nickname}-$releaseDate", + detail = "detail", + languageCode = "ko", + releaseDate = releaseDate, + isAdult = isAdult, + price = price, + isPointAvailable = isPointAvailable + ) + content.member = creator + content.theme = theme + content.isActive = true + content.coverImage = "audio.png" + content.duration = "00:10:00" + entityManager.persist(content) + return content + } + + private fun saveTheme(name: String): AudioContentTheme { + val theme = AudioContentTheme(theme = name, image = "$name.png", isActive = true) + entityManager.persist(theme) + return theme + } + + private fun saveSeries( + title: String, + creator: Member, + isOriginal: Boolean = false, + isAdult: Boolean = false + ): Series { + val series = Series( + title = title, + introduction = "introduction", + languageCode = "ko", + isOriginal = isOriginal, + isAdult = isAdult, + isActive = true + ) + series.member = creator + series.genre = saveSeriesGenre(title) + series.coverImage = "$title.png" + entityManager.persist(series) + return series + } + + private fun saveSeriesGenre(name: String): SeriesGenre { + val genre = SeriesGenre(genre = "genre-$name", isAdult = false, isActive = true) + entityManager.persist(genre) + return genre + } + + private fun saveSeriesContent(series: Series, content: AudioContent): SeriesContent { + val seriesContent = SeriesContent() + seriesContent.series = series + seriesContent.content = content + entityManager.persist(seriesContent) + return seriesContent + } + + private fun saveDonation( + creator: Member, + donor: Member, + can: Int, + createdAt: LocalDateTime, + isSecret: Boolean = false, + additionalMessage: String? = "thanks" + ): ChannelDonationMessage { + val donation = ChannelDonationMessage(can = can, isSecret = isSecret, additionalMessage = additionalMessage) + donation.creator = creator + donation.member = donor + entityManager.persist(donation) + entityManager.flush() + updateCreatedAt("ChannelDonationMessage", donation.id!!, createdAt) + return donation + } + + private fun saveCommunity( + creator: Member, + isFixed: Boolean, + fixedAt: LocalDateTime? = null, + price: Int, + imagePath: String? = null, + audioPath: String? = null, + isAdult: Boolean = false, + isCommentAvailable: Boolean = true, + content: String = "community", + isActive: Boolean = true + ): CreatorCommunity { + val community = CreatorCommunity( + content = content, + price = price, + isCommentAvailable = isCommentAvailable, + isAdult = isAdult, + audioPath = audioPath, + imagePath = imagePath, + isActive = isActive, + isFixed = isFixed, + fixedAt = fixedAt + ) + community.member = creator + entityManager.persist(community) + return community + } + + private fun saveAuth(member: Member, gender: Int): Auth { + val auth = Auth( + name = member.nickname, + birth = "19900101", + uniqueCi = "${member.nickname}-ci", + di = "${member.nickname}-di", + gender = gender + ) + auth.member = member + entityManager.persist(auth) + return auth + } + + private fun saveCommunityLike(member: Member, community: CreatorCommunity, isActive: Boolean): CreatorCommunityLike { + val like = CreatorCommunityLike(isActive = isActive) + like.member = member + like.creatorCommunity = community + entityManager.persist(like) + return like + } + + private fun saveCommunityComment( + member: Member, + community: CreatorCommunity, + isActive: Boolean, + isSecret: Boolean = false, + parent: CreatorCommunityComment? = null + ): CreatorCommunityComment { + val comment = CreatorCommunityComment(comment = "comment", isSecret = isSecret, isActive = isActive) + comment.member = member + comment.creatorCommunity = community + comment.parent = parent + entityManager.persist(comment) + return comment + } + + private fun saveCommunityOrder(member: Member, community: CreatorCommunity, isRefund: Boolean): UseCan { + val useCan = UseCan(CanUsage.PAID_COMMUNITY_POST, community.price, rewardCan = 0, isRefund = isRefund) + useCan.member = member + useCan.communityPost = community + entityManager.persist(useCan) + return useCan + } + + private fun saveCheers( + member: Member, + creator: Member, + cheers: String, + isActive: Boolean, + createdAt: LocalDateTime, + parent: CreatorCheers? = null + ): CreatorCheers { + val creatorCheers = CreatorCheers(cheers = cheers, languageCode = "ko", isActive = isActive) + creatorCheers.member = member + creatorCheers.creator = creator + creatorCheers.parent = parent + entityManager.persist(creatorCheers) + entityManager.flush() + updateCreatedAt("CreatorCheers", creatorCheers.id!!, createdAt) + return creatorCheers + } + + private fun saveVisit(room: LiveRoom, member: Member): LiveRoomVisit { + val visit = LiveRoomVisit() + visit.room = room + visit.member = member + entityManager.persist(visit) + return visit + } + + private fun updateCreatedAt(entityName: String, id: Long, createdAt: LocalDateTime) { + entityManager.createQuery("update $entityName e set e.createdAt = :createdAt where e.id = :id") + .setParameter("createdAt", createdAt) + .setParameter("id", id) + .executeUpdate() + } + + private fun updateUpdatedAt(entityName: String, id: Long, updatedAt: LocalDateTime) { + entityManager.createQuery("update $entityName e set e.updatedAt = :updatedAt where e.id = :id") + .setParameter("updatedAt", updatedAt) + .setParameter("id", id) + .executeUpdate() + } + + private fun flushAndClear() { + entityManager.flush() + entityManager.clear() + } +}