feat(community): 게시물 상세 응답 모델을 추가한다

This commit is contained in:
2026-07-07 11:55:33 +09:00
parent 19ada9e574
commit fe9adf35e7
4 changed files with 292 additions and 0 deletions

View File

@@ -0,0 +1,139 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.extensions.toUtcIso
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.CreatorChannelCommunityPostDetail
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityReplies
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityReply
data class CreatorChannelCommunityPostDetailResponse(
val postId: Long,
val creatorId: Long,
val creatorNickname: String,
val creatorProfileUrl: String,
val createdAtUtc: String,
val content: String,
val imageUrl: String?,
val audioUrl: String?,
val price: Int,
@JsonProperty("isCommentAvailable")
val isCommentAvailable: Boolean,
val existOrdered: Boolean,
val likeCount: Int,
val commentCount: Int,
@JsonProperty("isPinned")
val isPinned: Boolean,
@JsonProperty("isLiked")
val isLiked: Boolean,
val comments: CreatorChannelCommunityCommentsResponse
) {
companion object {
fun from(detail: CreatorChannelCommunityPostDetail): CreatorChannelCommunityPostDetailResponse {
val post = detail.post
return CreatorChannelCommunityPostDetailResponse(
postId = post.postId,
creatorId = post.creatorId,
creatorNickname = post.creatorNickname,
creatorProfileUrl = post.creatorProfileUrl,
createdAtUtc = post.createdAt.toUtcIso(),
content = post.content,
imageUrl = post.imageUrl,
audioUrl = post.audioUrl,
price = post.price,
isCommentAvailable = post.isCommentAvailable,
existOrdered = post.existOrdered,
likeCount = post.likeCount,
commentCount = post.commentCount,
isPinned = post.isPinned,
isLiked = post.isLiked,
comments = CreatorChannelCommunityCommentsResponse.from(detail.comments)
)
}
}
}
data class CreatorChannelCommunityCommentsResponse(
val commentCount: Int,
val comments: List<CreatorChannelCommunityCommentResponse>,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
) {
companion object {
fun from(comments: CreatorChannelCommunityComments): CreatorChannelCommunityCommentsResponse {
return CreatorChannelCommunityCommentsResponse(
commentCount = comments.commentCount,
comments = comments.comments.map(CreatorChannelCommunityCommentResponse::from),
page = comments.page.page,
size = comments.page.size,
hasNext = comments.hasNext
)
}
}
}
data class CreatorChannelCommunityCommentResponse(
val commentId: Long,
val writerProfileImageUrl: String,
val writerNickname: String,
val content: String,
val createdAtUtc: String,
val latestReply: CreatorChannelCommunityReplyResponse?
) {
companion object {
fun from(comment: CreatorChannelCommunityComment): CreatorChannelCommunityCommentResponse {
return CreatorChannelCommunityCommentResponse(
commentId = comment.commentId,
writerProfileImageUrl = comment.writerProfileImageUrl,
writerNickname = comment.writerNickname,
content = comment.content,
createdAtUtc = comment.createdAt.toUtcIso(),
latestReply = comment.latestReply?.let(CreatorChannelCommunityReplyResponse::from)
)
}
}
}
data class CreatorChannelCommunityRepliesResponse(
val replyCount: Int,
val replies: List<CreatorChannelCommunityReplyResponse>,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
) {
companion object {
fun from(replies: CreatorChannelCommunityReplies): CreatorChannelCommunityRepliesResponse {
return CreatorChannelCommunityRepliesResponse(
replyCount = replies.replyCount,
replies = replies.replies.map(CreatorChannelCommunityReplyResponse::from),
page = replies.page.page,
size = replies.page.size,
hasNext = replies.hasNext
)
}
}
}
data class CreatorChannelCommunityReplyResponse(
val commentId: Long,
val writerProfileImageUrl: String,
val writerNickname: String,
val content: String,
val createdAtUtc: String
) {
companion object {
fun from(reply: CreatorChannelCommunityReply): CreatorChannelCommunityReplyResponse {
return CreatorChannelCommunityReplyResponse(
commentId = reply.commentId,
writerProfileImageUrl = reply.writerProfileImageUrl,
writerNickname = reply.writerNickname,
content = reply.content,
createdAtUtc = reply.createdAt.toUtcIso()
)
}
}
}

View File

@@ -0,0 +1,40 @@
package kr.co.vividnext.sodalive.v2.creator.channel.community.domain
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
import java.time.LocalDateTime
data class CreatorChannelCommunityPostDetail(
val post: CreatorChannelCommunityPost,
val comments: CreatorChannelCommunityComments
)
data class CreatorChannelCommunityComments(
val commentCount: Int,
val comments: List<CreatorChannelCommunityComment>,
val page: CreatorChannelPage,
val hasNext: Boolean
)
data class CreatorChannelCommunityComment(
val commentId: Long,
val writerProfileImageUrl: String,
val writerNickname: String,
val content: String,
val createdAt: LocalDateTime,
val latestReply: CreatorChannelCommunityReply?
)
data class CreatorChannelCommunityReplies(
val replyCount: Int,
val replies: List<CreatorChannelCommunityReply>,
val page: CreatorChannelPage,
val hasNext: Boolean
)
data class CreatorChannelCommunityReply(
val commentId: Long,
val writerProfileImageUrl: String,
val writerNickname: String,
val content: String,
val createdAt: LocalDateTime
)

View File

@@ -0,0 +1,99 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.KotlinModule
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.CreatorChannelCommunityReplies
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityReply
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import java.time.LocalDateTime
class CreatorChannelCommunityPostDetailResponseTest {
@Test
@DisplayName("상세 응답 DTO는 is prefix boolean 필드명과 댓글/답글 구조를 유지한다")
fun shouldSerializeBooleanFieldsWithIsPrefixAndReuseCommentsResponse() {
val mapper = ObjectMapper().registerModule(KotlinModule.Builder().build())
val response = CreatorChannelCommunityPostDetailResponse.from(createDetail())
val replies = CreatorChannelCommunityRepliesResponse.from(createReplies())
val detailJson = mapper.readTree(mapper.writeValueAsString(response))
val repliesJson = mapper.readTree(mapper.writeValueAsString(replies))
assertTrue(detailJson["isCommentAvailable"].asBoolean())
assertTrue(detailJson["isPinned"].asBoolean())
assertTrue(detailJson["isLiked"].asBoolean())
assertTrue(detailJson["comments"]["hasNext"].asBoolean())
assertTrue(detailJson["comments"]["comments"][0].has("latestReply"))
assertTrue(detailJson["comments"]["comments"][0]["latestReply"].has("commentId"))
assertTrue(repliesJson["hasNext"].asBoolean())
assertFalse(repliesJson["replies"][0].has("latestReply"))
}
private fun createDetail(): CreatorChannelCommunityPostDetail {
val reply = CreatorChannelCommunityReply(
commentId = 2L,
writerProfileImageUrl = "https://cdn.test/reply.png",
writerNickname = "reply-writer",
content = "reply",
createdAt = LocalDateTime.of(2026, 7, 6, 3, 1)
)
return CreatorChannelCommunityPostDetail(
post = CreatorChannelCommunityPost(
postId = 1L,
creatorId = 10L,
creatorNickname = "creator",
creatorProfileUrl = "https://cdn.test/profile.png",
imageUrl = "https://cdn.test/image.png",
audioUrl = null,
content = "content",
price = 0,
createdAt = LocalDateTime.of(2026, 7, 6, 3, 0),
existOrdered = false,
isCommentAvailable = true,
likeCount = 1,
commentCount = 1,
isPinned = true,
isLiked = true
),
comments = CreatorChannelCommunityComments(
commentCount = 1,
comments = listOf(
CreatorChannelCommunityComment(
commentId = 1L,
writerProfileImageUrl = "https://cdn.test/comment.png",
writerNickname = "comment-writer",
content = "comment",
createdAt = LocalDateTime.of(2026, 7, 6, 3, 0),
latestReply = reply
)
),
page = CreatorChannelPage(0, 20),
hasNext = true
)
)
}
private fun createReplies(): CreatorChannelCommunityReplies {
return CreatorChannelCommunityReplies(
replyCount = 1,
replies = listOf(
CreatorChannelCommunityReply(
commentId = 2L,
writerProfileImageUrl = "https://cdn.test/reply.png",
writerNickname = "reply-writer",
content = "reply",
createdAt = LocalDateTime.of(2026, 7, 6, 3, 1)
)
),
page = CreatorChannelPage(0, 20),
hasNext = true
)
}
}

View File

@@ -41,6 +41,20 @@ class CreatorChannelCommunityQueryPolicyTest {
assertEquals(51, maximumPage.fetchLimit) assertEquals(51, maximumPage.fetchLimit)
} }
@Test
@DisplayName("커뮤니티 댓글도 커뮤니티 탭과 같은 page fallback 정책을 사용한다")
fun shouldUseSamePageFallbackForCommunityComments() {
val defaultPage = policy.createPage(page = null, size = null)
val boundedPage = policy.createPage(page = -1, size = 100)
assertEquals(0, defaultPage.page)
assertEquals(20, defaultPage.size)
assertEquals(21, defaultPage.fetchLimit)
assertEquals(0, boundedPage.page)
assertEquals(50, boundedPage.size)
assertEquals(51, boundedPage.fetchLimit)
}
@Test @Test
@DisplayName("커뮤니티 탭 목록 정책은 요청 size만 남기고 다음 페이지 여부를 계산한다") @DisplayName("커뮤니티 탭 목록 정책은 요청 size만 남기고 다음 페이지 여부를 계산한다")
fun shouldLimitItemsAndCalculateHasNext() { fun shouldLimitItemsAndCalculateHasNext() {