feat(community): 게시물 댓글 조회 서비스를 추가한다

This commit is contained in:
2026-07-07 11:56:23 +09:00
parent cf8acacedf
commit 3bdf8353f6
3 changed files with 384 additions and 2 deletions

View File

@@ -2,15 +2,22 @@ package kr.co.vividnext.sodalive.v2.creator.channel.community.application
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.extensions.removeDeletedNicknamePrefix
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
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.v2.common.domain.toCdnUrl
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityComment
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityComments
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityPost
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityPostDetail
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicy
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityReplies
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityReply
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityTab
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCommentRecord
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityPostRecord
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityQueryPort
@@ -93,6 +100,115 @@ class CreatorChannelCommunityQueryService(
.map { it.toDomain(viewerId) }
}
fun getCommunityPostDetail(
postId: Long,
viewer: Member,
now: LocalDateTime = LocalDateTime.now()
): CreatorChannelCommunityPostDetail {
val queryPort = queryPortProvider.getObject()
val viewerId = viewer.id!!
val canViewAdultContent = memberContentPreferenceService.canViewAdultContent(viewer)
val postRecord = queryPort.findCommunityPost(postId, viewerId, canViewAdultContent)
?: throw SodaException(messageKey = INVALID_COMMUNITY_REQUEST_MESSAGE_KEY)
validateNotBlocked(queryPort, viewerId, postRecord.creatorId)
return CreatorChannelCommunityPostDetail(
post = postRecord.toDomain(viewerId),
comments = getCommunityComments(postId, viewer, page = 0, size = 20, now = now)
)
}
fun getCommunityComments(
postId: Long,
viewer: Member,
page: Int?,
size: Int?,
now: LocalDateTime = LocalDateTime.now()
): CreatorChannelCommunityComments {
val communityPage = queryPolicy.createPage(page, size)
val queryPort = queryPortProvider.getObject()
val viewerId = viewer.id!!
val canViewAdultContent = memberContentPreferenceService.canViewAdultContent(viewer)
val postRecord = queryPort.findCommunityPost(postId, viewerId, canViewAdultContent)
?: throw SodaException(messageKey = INVALID_COMMUNITY_REQUEST_MESSAGE_KEY)
validateNotBlocked(queryPort, viewerId, postRecord.creatorId)
if (!postRecord.isCommentAvailable) {
return CreatorChannelCommunityComments(
commentCount = 0,
comments = emptyList(),
page = communityPage,
hasNext = false
)
}
val fetchedComments = queryPort.findCommunityComments(
postId = postId,
viewerId = viewerId,
creatorId = postRecord.creatorId,
offset = communityPage.offset,
limit = communityPage.fetchLimit
)
val limitedComments = queryPolicy.limitItems(fetchedComments, communityPage)
val latestReplies = queryPort.findLatestRepliesByCommentIds(
commentIds = limitedComments.map { it.commentId },
viewerId = viewerId
).associateBy { it.parentCommentId }
return CreatorChannelCommunityComments(
commentCount = queryPort.countCommunityComments(
postId = postId,
viewerId = viewerId,
creatorId = postRecord.creatorId
),
comments = limitedComments.map { it.toCommentDomain(latestReplies[it.commentId]?.toReplyDomain()) },
page = communityPage,
hasNext = queryPolicy.hasNext(fetchedComments, communityPage)
)
}
fun getCommunityReplies(
commentId: Long,
viewer: Member,
page: Int?,
size: Int?,
now: LocalDateTime = LocalDateTime.now()
): CreatorChannelCommunityReplies {
val communityPage = queryPolicy.createPage(page, size)
val queryPort = queryPortProvider.getObject()
val viewerId = viewer.id!!
val canViewAdultContent = memberContentPreferenceService.canViewAdultContent(viewer)
val context = queryPort.findRootCommentContext(commentId, viewerId, canViewAdultContent)
?: throw SodaException(messageKey = INVALID_COMMUNITY_REQUEST_MESSAGE_KEY)
validateNotBlocked(queryPort, viewerId, context.creatorId)
if (!context.isCommentAvailable) {
return CreatorChannelCommunityReplies(
replyCount = 0,
replies = emptyList(),
page = communityPage,
hasNext = false
)
}
val fetchedReplies = queryPort.findCommunityReplies(
commentId = commentId,
viewerId = viewerId,
offset = communityPage.offset,
limit = communityPage.fetchLimit
)
return CreatorChannelCommunityReplies(
replyCount = queryPort.countCommunityReplies(commentId, viewerId),
replies = queryPolicy.limitItems(fetchedReplies, communityPage).map { it.toReplyDomain() },
page = communityPage,
hasNext = queryPolicy.hasNext(fetchedReplies, communityPage)
)
}
private fun validateCreatorRole(creator: CreatorChannelCommunityCreatorRecord) {
when (creator.role) {
MemberRole.CREATOR -> return
@@ -100,6 +216,12 @@ class CreatorChannelCommunityQueryService(
}
}
private fun validateNotBlocked(queryPort: CreatorChannelCommunityQueryPort, viewerId: Long, creatorId: Long) {
if (queryPort.existsBlockedBetween(viewerId, creatorId)) {
throw SodaException(messageKey = INVALID_COMMUNITY_REQUEST_MESSAGE_KEY)
}
}
private fun CreatorChannelCommunityPostRecord.toDomain(viewerId: Long): CreatorChannelCommunityPost {
val canAccessPaidContent = price <= 0 || viewerId == creatorId || existOrdered
return CreatorChannelCommunityPost(
@@ -126,6 +248,29 @@ class CreatorChannelCommunityQueryService(
)
}
private fun CreatorChannelCommunityCommentRecord.toCommentDomain(
latestReply: CreatorChannelCommunityReply?
): CreatorChannelCommunityComment {
return CreatorChannelCommunityComment(
commentId = commentId,
writerProfileImageUrl = writerProfilePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(),
writerNickname = writerNickname.removeDeletedNicknamePrefix(),
content = content,
createdAt = createdAt,
latestReply = latestReply
)
}
private fun CreatorChannelCommunityCommentRecord.toReplyDomain(): CreatorChannelCommunityReply {
return CreatorChannelCommunityReply(
commentId = commentId,
writerProfileImageUrl = writerProfilePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(),
writerNickname = writerNickname.removeDeletedNicknamePrefix(),
content = content,
createdAt = createdAt
)
}
private fun String?.toSignedAudioUrl(): String? {
if (isNullOrBlank()) return null
return audioContentCloudFront.generateSignedURL(this, AUDIO_SIGNED_URL_EXPIRATION_MILLIS)
@@ -135,5 +280,6 @@ class CreatorChannelCommunityQueryService(
companion object {
private const val AUDIO_SIGNED_URL_EXPIRATION_MILLIS = 1000L * 60 * 30
private const val INVALID_COMMUNITY_REQUEST_MESSAGE_KEY = "creator.community.invalid_request_retry"
}
}