feat(creator): 채널 홈 조회 어댑터를 추가한다

This commit is contained in:
2026-06-13 17:57:04 +09:00
parent abc3e8e9aa
commit 3fd957a0d1
4 changed files with 2472 additions and 0 deletions

View File

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

View File

@@ -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<CreatorChannelDonationRecord> {
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<CreatorChannelCommunityPostRecord> {
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<CreatorChannelScheduleRecord> {
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<CreatorChannelScheduleRecord> { it.scheduledAt }.thenBy { it.type.sortOrder })
.take(limit)
}
override fun findAudioContents(
creatorId: Long,
now: LocalDateTime,
latestAudioContentId: Long?,
canViewAdultContent: Boolean,
limit: Int
): List<CreatorChannelAudioContentRecord> {
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<CreatorChannelSeriesRecord> {
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<Long, AudioSeriesSummary>
): 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<Long>): Map<Long, AudioSeriesSummary> {
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<Long>): Set<Long> {
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<Long>): Map<Long, Int> {
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<Long>, viewerId: Long?, isContentCreator: Boolean): Map<Long, Int> {
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<Long>,
now: LocalDateTime,
canViewAdultContent: Boolean
): Map<Long, SeriesContentStats> {
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<Long>,
now: LocalDateTime,
canViewAdultContent: Boolean
): Set<Long> {
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
)
}

View File

@@ -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<CreatorChannelDonationRecord>
fun findCommunityPosts(
creatorId: Long,
viewerId: Long?,
isFixed: Boolean,
canViewAdultContent: Boolean,
limit: Int = 3
): List<CreatorChannelCommunityPostRecord>
fun findSchedules(
creatorId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean,
viewerId: Long?,
isViewerCreator: Boolean,
effectiveViewerGender: Gender?,
limit: Int = 3
): List<CreatorChannelScheduleRecord>
fun findAudioContents(
creatorId: Long,
now: LocalDateTime,
latestAudioContentId: Long?,
canViewAdultContent: Boolean,
limit: Int = 9
): List<CreatorChannelAudioContentRecord>
fun findSeries(
creatorId: Long,
viewerId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean,
contentType: ContentType,
limit: Int = 8
): List<CreatorChannelSeriesRecord>
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
)