feat(creator): 채널 홈 조회 서비스를 추가한다

This commit is contained in:
2026-06-13 18:52:10 +09:00
parent 951bd1b2d1
commit ec68d827a6
3 changed files with 682 additions and 0 deletions

View File

@@ -0,0 +1,265 @@
package kr.co.vividnext.sodalive.v2.creator.channel.application
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Gender
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelActivity
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelCommunityPost
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelCreator
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelDonation
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalk
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalkSummary
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHome
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicy
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelLive
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSchedule
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSeries
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSns
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.CreatorChannelHomeQueryPort
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.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
@Service
@Transactional(readOnly = true)
class CreatorChannelHomeQueryService(
private val queryPort: CreatorChannelHomeQueryPort,
private val queryPolicy: CreatorChannelHomeQueryPolicy,
private val memberContentPreferenceService: MemberContentPreferenceService,
private val messageSource: SodaMessageSource,
private val langContext: LangContext,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) {
fun getHome(
creatorId: Long,
viewer: Member,
now: LocalDateTime = LocalDateTime.now()
): CreatorChannelHome {
val viewerId = viewer.id!!
val creator = queryPort.findCreator(creatorId, viewerId)
?: throw SodaException(messageKey = "member.validation.user_not_found")
if (queryPort.existsBlockedBetween(viewerId, creatorId)) {
val messageTemplate = messageSource
.getMessage("explorer.creator.blocked_access", langContext.lang)
.orEmpty()
throw SodaException(message = String.format(messageTemplate, creator.nickname))
}
validateCreatorRole(creator)
val preference = memberContentPreferenceService.getStoredPreference(viewer)
val canViewAdultContent = isAdultVisibleByPolicy(viewer, preference.isAdultContentVisible)
val isViewerCreator = viewerId == creatorId
val effectiveViewerGender = viewer.effectiveGender()
val latestAudioContent = queryPort
.findLatestAudioContent(creatorId, now, canViewAdultContent)
?.toDomain()
val audioContents = queryPolicy.excludeLatestAudioContent(
queryPort.findAudioContents(
creatorId = creatorId,
now = now,
latestAudioContentId = latestAudioContent?.audioContentId,
canViewAdultContent = canViewAdultContent
).map { it.toDomain() },
latestAudioContent?.audioContentId
)
return CreatorChannelHome(
creator = creator.toDomain(),
currentLive = queryPort.findCurrentLive(
creatorId = creatorId,
now = now,
canViewAdultContent = canViewAdultContent,
viewerId = viewerId,
isViewerCreator = isViewerCreator,
effectiveViewerGender = effectiveViewerGender
)?.toDomain(),
latestAudioContent = latestAudioContent,
channelDonations = queryPort.findChannelDonations(creatorId, viewerId, now).map { it.toDomain() },
notices = queryPort.findCommunityPosts(
creatorId = creatorId,
viewerId = viewerId,
isFixed = true,
canViewAdultContent = canViewAdultContent
).map { it.toDomain() },
schedules = queryPolicy.limitSchedules(
queryPort.findSchedules(
creatorId = creatorId,
now = now,
canViewAdultContent = canViewAdultContent,
viewerId = viewerId,
isViewerCreator = isViewerCreator,
effectiveViewerGender = effectiveViewerGender
).map { it.toDomain() },
now,
canViewAdultContent
),
audioContents = audioContents,
series = queryPort.findSeries(
creatorId = creatorId,
viewerId = viewerId,
now = now,
canViewAdultContent = canViewAdultContent,
contentType = preference.contentType
).map { it.toDomain() },
communities = queryPort.findCommunityPosts(
creatorId = creatorId,
viewerId = viewerId,
isFixed = false,
canViewAdultContent = canViewAdultContent
).map { it.toDomain() },
fanTalk = queryPort.findFanTalkSummary(creatorId, viewerId).toDomain(),
introduce = creator.introduce,
activity = queryPort.findActivity(creatorId, now).toDomain(),
sns = queryPort.findSns(creatorId).toDomain()
)
}
private fun validateCreatorRole(creator: CreatorChannelCreatorRecord) {
when (creator.role) {
MemberRole.CREATOR -> return
else -> throw SodaException(messageKey = "member.validation.creator_not_found")
}
}
private fun Member.effectiveGender(): Gender {
auth?.let { return if (it.gender == 1) Gender.MALE else Gender.FEMALE }
return gender
}
private fun CreatorChannelCreatorRecord.toDomain() = CreatorChannelCreator(
creatorId = creatorId,
characterId = characterId,
nickname = nickname,
profileImageUrl = profileImagePath.toCdnUrl() ?: defaultProfileImageUrl(),
followerCount = followerCount,
isAiChatAvailable = isAiChatAvailable,
isDmAvailable = isDmAvailable,
isFollow = isFollow,
isNotify = isNotify
)
private fun CreatorChannelLiveRecord.toDomain() = CreatorChannelLive(
liveId = liveId,
title = title,
coverImageUrl = coverImagePath.toCdnUrl(),
beginDateTime = beginDateTime,
price = price,
isAdult = isAdult
)
private fun CreatorChannelAudioContentRecord.toDomain() = CreatorChannelAudioContent(
audioContentId = audioContentId,
title = title,
duration = duration,
imageUrl = imagePath.toCdnUrl(),
price = price,
isAdult = isAdult,
isPointAvailable = isPointAvailable,
isFirstContent = isFirstContent,
publishedAt = publishedAt,
seriesName = seriesName,
isOriginalSeries = isOriginalSeries
)
private fun CreatorChannelDonationRecord.toDomain() = CreatorChannelDonation(
nickname = nickname,
profileImageUrl = profileImagePath.toCdnUrl() ?: defaultProfileImageUrl(),
can = can,
message = message,
createdAt = createdAt
)
private fun CreatorChannelScheduleRecord.toDomain() = CreatorChannelSchedule(
scheduledAt = scheduledAt,
title = title,
type = type,
targetId = targetId,
isAdult = isAdult
)
private fun CreatorChannelSeriesRecord.toDomain() = CreatorChannelSeries(
seriesId = seriesId,
title = title,
coverImageUrl = coverImagePath.toCdnUrl().orEmpty(),
numberOfContent = numberOfContent,
isNew = isNew,
isOriginal = isOriginal
)
private fun CreatorChannelCommunityPostRecord.toDomain() = CreatorChannelCommunityPost(
postId = postId,
creatorId = creatorId,
creatorNickname = creatorNickname,
creatorProfileUrl = creatorProfilePath.toCdnUrl() ?: defaultProfileImageUrl(),
imageUrl = imagePath.toCdnUrl(),
audioUrl = audioPath.toCdnUrl(),
content = content,
price = price,
date = date,
existOrdered = existOrdered,
likeCount = likeCount,
commentCount = commentCount
)
private fun CreatorChannelFanTalkSummaryRecord.toDomain() = CreatorChannelFanTalkSummary(
totalCount = totalCount,
latestFanTalk = latestFanTalk?.toDomain()
)
private fun CreatorChannelFanTalkRecord.toDomain() = CreatorChannelFanTalk(
fanTalkId = fanTalkId,
memberId = memberId,
nickname = nickname,
profileImageUrl = profileImagePath.toCdnUrl() ?: defaultProfileImageUrl(),
content = content,
languageCode = languageCode,
createdAt = createdAt
)
private fun CreatorChannelActivityRecord.toDomain() = CreatorChannelActivity(
debutDate = debutDate,
dDay = dDay,
liveCount = liveCount,
liveDurationHours = liveDurationHours,
liveContributorCount = liveContributorCount,
audioContentCount = audioContentCount,
seriesCount = seriesCount
)
private fun CreatorChannelSnsRecord.toDomain() = CreatorChannelSns(
instagramUrl = instagramUrl,
fancimmUrl = fancimmUrl,
xUrl = xUrl,
youtubeUrl = youtubeUrl,
kakaoOpenChatUrl = kakaoOpenChatUrl
)
private fun String?.toCdnUrl(): String? {
if (isNullOrBlank()) return null
if (startsWith("https://") || startsWith("http://")) return this
return "$cloudFrontHost/$this"
}
private fun defaultProfileImageUrl(): String = "$cloudFrontHost/profile/default-profile.png"
}

View File

@@ -1,8 +1,10 @@
package kr.co.vividnext.sodalive.v2.creator.channel.domain
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import org.springframework.stereotype.Component
import java.time.LocalDateTime
@Component
class CreatorChannelHomeQueryPolicy {
fun limitSchedules(
schedules: List<CreatorChannelSchedule>,