Compare commits
6 Commits
5adbae3213
...
7f04899ca5
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f04899ca5 | |||
| f1072a4ce4 | |||
| 3bdf8353f6 | |||
| cf8acacedf | |||
| fe9adf35e7 | |||
| 19ada9e574 |
856
docs/20260706_커뮤니티_게시물_상세_API/plan-task.md
Normal file
856
docs/20260706_커뮤니티_게시물_상세_API/plan-task.md
Normal file
@@ -0,0 +1,856 @@
|
|||||||
|
# 커뮤니티 게시물 상세 API Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
|
||||||
|
|
||||||
|
**Goal:** 인증 회원이 커뮤니티 게시물 상세, 댓글 목록, 댓글 답글 목록을 v2 API로 조회할 수 있게 한다.
|
||||||
|
|
||||||
|
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.community` 조립 계층에 둔다. 게시물 상세/댓글/답글 조회 service, domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.creator.channel.community` 하위에 두고 `v2.api.*`에 의존하지 않는다. 기존 커뮤니티 탭의 `CreatorChannelCommunityPost` 조립 정책을 재사용하고, 댓글 저장 엔티티는 legacy `CreatorCommunityComment`를 사용하되 legacy response DTO는 v2 공개 응답으로 노출하지 않는다.
|
||||||
|
|
||||||
|
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Data JPA, QueryDSL, JUnit 5, MockMvc, Gradle Wrapper
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 구현 전 확정 사항
|
||||||
|
|
||||||
|
- 상세 API endpoint: `GET /api/v2/creator-channels/community-posts/{postId}`
|
||||||
|
- 댓글 API endpoint: `GET /api/v2/creator-channels/community-posts/{postId}/comments`
|
||||||
|
- 답글 API endpoint: `GET /api/v2/creator-channels/community-comments/{commentId}/replies`
|
||||||
|
- 인증 정책: 세 API 모두 인증 회원만 조회 가능하다. 비회원은 기존 Security 흐름과 `requireMember` 정책으로 거부한다.
|
||||||
|
- page 기준: 기존 크리에이터 채널 v2 탭 API와 동일한 0 기반 page index다.
|
||||||
|
- page fallback:
|
||||||
|
- `page` 기본값은 `0`
|
||||||
|
- `page < 0`이면 `0`
|
||||||
|
- `size` 기본값은 `20`
|
||||||
|
- `size < 20`이면 `20`
|
||||||
|
- `size > 50`이면 `50`
|
||||||
|
- 상세 응답 게시물 필드는 커뮤니티 탭의 `CreatorChannelCommunityPostResponse`와 동일한 의미를 사용하고 `isLiked`를 포함한다.
|
||||||
|
- 상세 응답은 `comments: CreatorChannelCommunityCommentsResponse`로 첫 댓글 20개를 포함한다.
|
||||||
|
- 댓글 응답은 `commentCount`, `comments`, `page`, `size`, `hasNext`를 포함한다.
|
||||||
|
- 댓글 item은 `commentId`, `writerProfileImageUrl`, `writerNickname`, `content`, `createdAtUtc`, `latestReply`를 포함한다.
|
||||||
|
- 답글 응답은 `replyCount`, `replies`, `page`, `size`, `hasNext`를 포함한다.
|
||||||
|
- 답글 item은 댓글 item과 같은 작성자/본문/UTC 작성 시간 구조를 사용하지만 `latestReply`를 포함하지 않는다.
|
||||||
|
- 게시물 조회 정책:
|
||||||
|
- `CreatorCommunity.isActive == true`
|
||||||
|
- 조회자의 성인 콘텐츠 노출 정책이 false이면 19금 게시물은 조회 불가
|
||||||
|
- 조회자와 게시물 작성자 사이 활성 차단 관계가 있으면 접근 차단 오류
|
||||||
|
- 유료 본문/이미지/오디오 접근 정책은 커뮤니티 탭 `CreatorChannelCommunityPost`와 동일
|
||||||
|
- 댓글 조회 정책:
|
||||||
|
- 최상위 댓글: `CreatorCommunityComment.isActive == true`, `parent is null`
|
||||||
|
- 답글: `CreatorCommunityComment.isActive == true`, `parent.id == commentId`
|
||||||
|
- 댓글/답글 작성자가 조회자와 차단/피차단 관계이면 제외
|
||||||
|
- 비밀 최상위 댓글은 게시물 작성자 또는 댓글 작성자 본인에게만 노출
|
||||||
|
- 댓글 정렬: `createdAt desc`, `id desc`
|
||||||
|
- latest reply 정렬: `createdAt desc`, `id desc`의 첫 번째
|
||||||
|
- 답글 목록 정렬: `createdAt desc`, `id desc`
|
||||||
|
- `isCommentAvailable == false`인 게시물은 상세 초기 댓글과 댓글 목록 API 모두 `commentCount=0`, 빈 목록, `hasNext=false`를 반환한다.
|
||||||
|
- `createdAtUtc`는 `kr.co.vividnext.sodalive.extensions.toUtcIso`를 사용한다.
|
||||||
|
- 프로필 이미지 URL은 `String?.toCdnUrl(cloudFrontHost)`와 기본 프로필 이미지 URL `"$cloudFrontHost/profile/default-profile.png"` 정책을 따른다.
|
||||||
|
- 탈퇴 회원 닉네임 prefix 제거는 `removeDeletedNicknamePrefix()`를 적용한다.
|
||||||
|
- legacy `/creator-community` endpoint와 응답 스키마는 변경하지 않는다.
|
||||||
|
- DB schema, 운영 DDL, 마이그레이션은 포함하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 파일 구조 계획
|
||||||
|
|
||||||
|
### API 조립 계층
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt`
|
||||||
|
- 상세/댓글/답글 GET mapping 추가
|
||||||
|
- 인증 회원 null guard는 기존 `requireMember` 재사용
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt`
|
||||||
|
- `getCommunityPostDetail`, `getCommunityComments`, `getCommunityReplies` 추가
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponse.kt`
|
||||||
|
- 상세/댓글/답글 response DTO와 domain 변환 책임
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponseTest.kt`
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt`
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt`
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt`
|
||||||
|
|
||||||
|
### 도메인 조회 계층
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityPostDetail.kt`
|
||||||
|
- 상세/댓글/답글 domain model
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicy.kt`
|
||||||
|
- 기존 page fallback을 댓글/답글에도 재사용하도록 이름 또는 주석 확인
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/port/out/CreatorChannelCommunityQueryPort.kt`
|
||||||
|
- 게시물 상세, 댓글, 답글 조회 port method와 record 추가
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt`
|
||||||
|
- 상세/댓글/답글 use case 추가
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/CreatorChannelCommunityQueryRepository.kt`
|
||||||
|
- port method 구현 계약 반영
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepository.kt`
|
||||||
|
- QueryDSL 상세/댓글/답글 조회 구현
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicyTest.kt`
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt`
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepositoryTest.kt`
|
||||||
|
|
||||||
|
### 기존 파일 확인/재사용
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityTabResponse.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityTab.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelPage.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CdnUrlExtensions.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/comment/CreatorCommunityComment.kt`
|
||||||
|
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/member/block/BlockMember.kt`
|
||||||
|
|
||||||
|
### 문서 산출물
|
||||||
|
- Create: `docs/20260706_커뮤니티_게시물_상세_API/plan-task.md`
|
||||||
|
- Verify: `docs/20260706_커뮤니티_게시물_상세_API/prd.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Response data class 초안
|
||||||
|
|
||||||
|
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponse.kt`를 아래 구조로 만든다. 공개 API 계약 변경이 필요하면 구현 전에 PRD와 이 문서를 먼저 갱신한다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Domain / Port 초안
|
||||||
|
|
||||||
|
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityPostDetail.kt`를 아래 구조로 만든다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
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
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
구현 시 `CreatorChannelCommunityQueryPort`에 아래 method와 record를 추가한다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
fun findCommunityPost(
|
||||||
|
postId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
canViewAdultContent: Boolean
|
||||||
|
): CreatorChannelCommunityPostRecord?
|
||||||
|
|
||||||
|
fun countCommunityComments(
|
||||||
|
postId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
creatorId: Long
|
||||||
|
): Int
|
||||||
|
|
||||||
|
fun findCommunityComments(
|
||||||
|
postId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
creatorId: Long,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int
|
||||||
|
): List<CreatorChannelCommunityCommentRecord>
|
||||||
|
|
||||||
|
fun findLatestRepliesByCommentIds(
|
||||||
|
commentIds: List<Long>,
|
||||||
|
viewerId: Long
|
||||||
|
): List<CreatorChannelCommunityCommentRecord>
|
||||||
|
|
||||||
|
fun findRootCommentContext(
|
||||||
|
commentId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
canViewAdultContent: Boolean
|
||||||
|
): CreatorChannelCommunityCommentContextRecord?
|
||||||
|
|
||||||
|
fun countCommunityReplies(
|
||||||
|
commentId: Long,
|
||||||
|
viewerId: Long
|
||||||
|
): Int
|
||||||
|
|
||||||
|
fun findCommunityReplies(
|
||||||
|
commentId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int
|
||||||
|
): List<CreatorChannelCommunityCommentRecord>
|
||||||
|
|
||||||
|
data class CreatorChannelCommunityCommentRecord(
|
||||||
|
val commentId: Long,
|
||||||
|
val parentCommentId: Long?,
|
||||||
|
val postId: Long,
|
||||||
|
val writerId: Long,
|
||||||
|
val writerProfilePath: String?,
|
||||||
|
val writerNickname: String,
|
||||||
|
val content: String,
|
||||||
|
val createdAt: LocalDateTime
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelCommunityCommentContextRecord(
|
||||||
|
val commentId: Long,
|
||||||
|
val postId: Long,
|
||||||
|
val creatorId: Long,
|
||||||
|
val isCommentAvailable: Boolean
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Phase / Task Breakdown
|
||||||
|
|
||||||
|
### Phase 1: DTO, domain, page 정책 기반 추가
|
||||||
|
|
||||||
|
- [x] **Task 1.1: 상세/댓글/답글 response DTO 추가** - 검증: `CreatorChannelCommunityPostDetailResponse.kt`와 DTO 직렬화 테스트를 추가했고 boolean is-prefix, comments/latestReply/replies 구조를 확인했다.
|
||||||
|
- Files:
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponse.kt`
|
||||||
|
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponseTest.kt`
|
||||||
|
- RED: 실패 테스트 작성/실패 확인
|
||||||
|
- `CreatorChannelCommunityPostDetailResponseTest.shouldSerializeBooleanFieldsWithIsPrefixAndReuseCommentsResponse`를 추가한다.
|
||||||
|
- 검증 항목:
|
||||||
|
- `isCommentAvailable`, `isPinned`, `isLiked`, `hasNext` JSON 필드명이 유지된다.
|
||||||
|
- 상세 응답의 `comments`가 `CreatorChannelCommunityCommentsResponse` 구조로 직렬화된다.
|
||||||
|
- 댓글 item은 `latestReply`를 포함하고, 답글 item은 `latestReply`를 포함하지 않는다.
|
||||||
|
- 실행 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityPostDetailResponseTest`
|
||||||
|
- 기대 결과:
|
||||||
|
- 신규 DTO가 없어서 `compileTestKotlin` 실패한다.
|
||||||
|
- GREEN: 최소 구현/통과 확인
|
||||||
|
- 위 response data class 초안 그대로 DTO 파일을 추가한다.
|
||||||
|
- 실행 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityPostDetailResponseTest`
|
||||||
|
- 기대 결과:
|
||||||
|
- `BUILD SUCCESSFUL`
|
||||||
|
- REFACTOR: 정리/회귀 확인
|
||||||
|
- 기존 `CreatorChannelCommunityPostResponse`를 중복 수정하지 않는다.
|
||||||
|
- `rg -n "JsonProperty\\(\"isLiked\"\\)|JsonProperty\\(\"hasNext\"\\)" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community`로 Boolean 필드명을 확인한다.
|
||||||
|
|
||||||
|
- [x] **Task 1.2: 상세/댓글/답글 domain model 추가** - 검증: 상세/댓글/답글 domain model을 추가하고 기존 `CreatorChannelCommunityQueryPolicy.createPage` fallback 재사용 테스트를 통과시켰다.
|
||||||
|
- Files:
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityPostDetail.kt`
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicyTest.kt`
|
||||||
|
- RED: 실패 테스트 작성/실패 확인
|
||||||
|
- `CreatorChannelCommunityQueryPolicyTest.shouldUseSamePageFallbackForCommunityComments`를 추가한다.
|
||||||
|
- 검증 항목:
|
||||||
|
- `createPage(null, null)`은 `page=0`, `size=20`, `fetchLimit=21`
|
||||||
|
- `createPage(-1, 100)`은 `page=0`, `size=50`, `fetchLimit=51`
|
||||||
|
- 실행 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicyTest`
|
||||||
|
- 기대 결과:
|
||||||
|
- domain model import 또는 신규 테스트 의도에 맞춰 컴파일/테스트 실패를 확인한다.
|
||||||
|
- GREEN: 최소 구현/통과 확인
|
||||||
|
- 위 domain data class 초안을 추가한다.
|
||||||
|
- 기존 `CreatorChannelCommunityQueryPolicy.createPage`를 그대로 사용하고, 동작 변경은 하지 않는다.
|
||||||
|
- 실행 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicyTest`
|
||||||
|
- 기대 결과:
|
||||||
|
- `BUILD SUCCESSFUL`
|
||||||
|
- REFACTOR: 정리/회귀 확인
|
||||||
|
- `CreatorChannelPage`를 새로 만들지 않고 기존 `v2.creator.channel.live.domain.CreatorChannelPage`를 재사용한다.
|
||||||
|
|
||||||
|
### Phase 2: QueryDSL repository 상세/댓글/답글 조회
|
||||||
|
|
||||||
|
- [x] **Task 2.1: port method와 record 계약 추가** - 검증: 상세/댓글/답글 port method와 record를 추가했고 service/home 테스트 fake 구현을 갱신해 컴파일 및 service 테스트를 통과시켰다.
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/port/out/CreatorChannelCommunityQueryPort.kt`
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt`
|
||||||
|
- RED: 실패 테스트 작성/실패 확인
|
||||||
|
- `FakeCreatorChannelCommunityQueryPort`가 신규 port method를 구현하지 않아 컴파일 실패하도록 service test에 상세/댓글/답글 테스트 skeleton을 추가한다.
|
||||||
|
- 추가할 테스트 이름:
|
||||||
|
- `shouldAssembleCommunityPostDetailWithInitialComments`
|
||||||
|
- `shouldReturnEmptyCommentsWhenCommentUnavailable`
|
||||||
|
- `shouldAssembleCommunityReplies`
|
||||||
|
- 실행 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest`
|
||||||
|
- 기대 결과:
|
||||||
|
- `CreatorChannelCommunityQueryPort` 신규 method 미정의 또는 fake 미구현으로 `compileTestKotlin` 실패한다.
|
||||||
|
- GREEN: 최소 구현/통과 확인
|
||||||
|
- `CreatorChannelCommunityQueryPort`에 3장 port 초안의 method와 record를 추가한다.
|
||||||
|
- `FakeCreatorChannelCommunityQueryPort`에는 테스트용 기본값을 반환하는 구현을 추가한다.
|
||||||
|
- 실행 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest`
|
||||||
|
- 기대 결과:
|
||||||
|
- 아직 service method가 없어서 의도한 RED 실패가 남는다.
|
||||||
|
- REFACTOR: 정리/회귀 확인
|
||||||
|
- port record는 API DTO를 import하지 않는다.
|
||||||
|
- 실행 명령:
|
||||||
|
- `rg -n "v2\\.api\\." src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community`
|
||||||
|
- 기대 결과:
|
||||||
|
- 검색 결과 0건
|
||||||
|
|
||||||
|
- [x] **Task 2.2: 게시물 ID 기반 상세 row 조회** - 검증: `findCommunityPost`를 `selectCommunityPostRow`/`toCommunityPostRecords` 재사용으로 구현하고 좋아요/구매/성인/비활성 정책 repository 테스트를 통과시켰다.
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/CreatorChannelCommunityQueryRepository.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepository.kt`
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepositoryTest.kt`
|
||||||
|
- RED: 실패 테스트 작성/실패 확인
|
||||||
|
- `DefaultCreatorChannelCommunityQueryRepositoryTest.shouldFindCommunityPostByIdWithLikePurchaseAndAdultPolicy`를 추가한다.
|
||||||
|
- fixture:
|
||||||
|
- creator, viewer, adult-disabled viewer
|
||||||
|
- 무료 게시물 1개
|
||||||
|
- 유료 게시물 1개와 유효 구매 내역
|
||||||
|
- 19금 게시물 1개
|
||||||
|
- viewer 활성 좋아요와 다른 회원 좋아요
|
||||||
|
- 검증 항목:
|
||||||
|
- `findCommunityPost(postId, viewerId, true)`는 `CreatorChannelCommunityPostRecord` 1건을 반환한다.
|
||||||
|
- `existOrdered`, `isLiked`, `likeCount`, `commentCount`, `isPinned`가 기존 목록과 같은 의미로 채워진다.
|
||||||
|
- `canViewAdultContent=false`이면 19금 게시물은 `null`이다.
|
||||||
|
- 비활성 게시물은 `null`이다.
|
||||||
|
- 실행 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorChannelCommunityQueryRepositoryTest`
|
||||||
|
- 기대 결과:
|
||||||
|
- `findCommunityPost` 미구현으로 컴파일 실패한다.
|
||||||
|
- GREEN: 최소 구현/통과 확인
|
||||||
|
- `DefaultCreatorChannelCommunityQueryRepository.findCommunityPost(...)`를 추가한다.
|
||||||
|
- 기존 `selectCommunityPostRow`, `communityPostCondition`, `toCommunityPostRecords`를 재사용한다.
|
||||||
|
- 단일 게시물 조회 조건은 `creatorCommunity.id.eq(postId)`, `isActive=true`, `member.isActive=true`, 성인 필터를 적용한다.
|
||||||
|
- 실행 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorChannelCommunityQueryRepositoryTest`
|
||||||
|
- 기대 결과:
|
||||||
|
- `BUILD SUCCESSFUL`
|
||||||
|
- REFACTOR: 정리/회귀 확인
|
||||||
|
- 목록 조회와 상세 조회의 부가 필드 계산이 중복되지 않도록 `toCommunityPostRecords`를 재사용한다.
|
||||||
|
- 단일 조회 결과가 없으면 빈 리스트 변환 후 `firstOrNull()`로 처리해 NPE를 피한다.
|
||||||
|
|
||||||
|
- [x] **Task 2.3: 댓글 목록 count/list와 최신 답글 bulk 조회** - 검증: 댓글 count/list/latest reply bulk 조회를 구현하고 비밀 댓글, 차단 작성자, 최신 답글 정렬 repository 테스트를 통과시켰다.
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepository.kt`
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepositoryTest.kt`
|
||||||
|
- RED: 실패 테스트 작성/실패 확인
|
||||||
|
- `DefaultCreatorChannelCommunityQueryRepositoryTest.shouldFindVisibleRootCommentsWithLatestReply`를 추가한다.
|
||||||
|
- fixture:
|
||||||
|
- 공개 최상위 댓글 2개
|
||||||
|
- 각 댓글의 답글 여러 개
|
||||||
|
- 비밀 댓글 1개
|
||||||
|
- 비활성 댓글 1개
|
||||||
|
- 차단 작성자 댓글 1개
|
||||||
|
- 최상위 댓글이 아닌 답글 1개
|
||||||
|
- 검증 항목:
|
||||||
|
- viewer 기준 `countCommunityComments`는 공개/본인 비밀/차단 제외 정책을 반영한다.
|
||||||
|
- creator 기준 `countCommunityComments`는 비밀 댓글도 포함한다.
|
||||||
|
- `findCommunityComments`는 `createdAt desc`, `id desc` 순서로 `limit`개를 반환한다.
|
||||||
|
- `findLatestRepliesByCommentIds`는 parent comment별 최신 답글 1개만 반환한다.
|
||||||
|
- 답글 작성자가 차단 관계이면 latest reply 후보에서 제외된다.
|
||||||
|
- 실행 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorChannelCommunityQueryRepositoryTest`
|
||||||
|
- 기대 결과:
|
||||||
|
- 댓글 port method 미구현으로 컴파일 실패한다.
|
||||||
|
- GREEN: 최소 구현/통과 확인
|
||||||
|
- `countCommunityComments`, `findCommunityComments`, `findLatestRepliesByCommentIds`를 구현한다.
|
||||||
|
- 기존 helper `visibleSecretCommentCondition(creatorId, viewerId)`와 `notBlockedCommentWriterCondition(viewerId)`를 재사용한다.
|
||||||
|
- `findLatestRepliesByCommentIds`는 parent id 목록이 비어 있으면 빈 목록을 반환한다.
|
||||||
|
- latest reply는 QueryDSL로 후보를 조회한 뒤 Kotlin group by로 parent별 첫 항목을 선택하거나 동등한 방식으로 구현한다.
|
||||||
|
- 실행 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorChannelCommunityQueryRepositoryTest`
|
||||||
|
- 기대 결과:
|
||||||
|
- `BUILD SUCCESSFUL`
|
||||||
|
- REFACTOR: 정리/회귀 확인
|
||||||
|
- count 조건과 list 조건이 다르지 않은지 테스트 fixture를 재확인한다.
|
||||||
|
- `fetch().size` 대신 count query를 우선 사용한다.
|
||||||
|
|
||||||
|
- [x] **Task 2.4: 답글 조회용 부모 댓글 context, count/list 조회** - 검증: root comment context와 답글 count/list 조회를 구현하고 보이지 않는 비밀 부모, 답글의 답글 제외, 차단/비활성 제외 테스트를 통과시켰다.
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepository.kt`
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepositoryTest.kt`
|
||||||
|
- RED: 실패 테스트 작성/실패 확인
|
||||||
|
- `DefaultCreatorChannelCommunityQueryRepositoryTest.shouldFindVisibleRepliesForRootCommentOnly`를 추가한다.
|
||||||
|
- fixture:
|
||||||
|
- 최상위 댓글 1개
|
||||||
|
- 답글 3개
|
||||||
|
- 비활성 답글 1개
|
||||||
|
- 차단 작성자 답글 1개
|
||||||
|
- 답글의 답글 형태 비정상 데이터 1개
|
||||||
|
- 비밀 최상위 댓글 1개
|
||||||
|
- 검증 항목:
|
||||||
|
- `findRootCommentContext`는 최상위 댓글만 반환한다.
|
||||||
|
- 조회자에게 보이지 않는 비밀 부모 댓글은 `null`이다.
|
||||||
|
- `countCommunityReplies`와 `findCommunityReplies`는 활성 답글과 차단 제외 정책을 반영한다.
|
||||||
|
- 답글 정렬은 `createdAt desc`, `id desc`다.
|
||||||
|
- 실행 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorChannelCommunityQueryRepositoryTest`
|
||||||
|
- 기대 결과:
|
||||||
|
- 답글 port method 미구현으로 컴파일 실패한다.
|
||||||
|
- GREEN: 최소 구현/통과 확인
|
||||||
|
- `findRootCommentContext`, `countCommunityReplies`, `findCommunityReplies`를 구현한다.
|
||||||
|
- 부모 댓글 context 조회는 부모 댓글의 게시물 활성/성인 필터/비밀 댓글/차단 작성자 정책을 함께 적용한다.
|
||||||
|
- 실행 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorChannelCommunityQueryRepositoryTest`
|
||||||
|
- 기대 결과:
|
||||||
|
- `BUILD SUCCESSFUL`
|
||||||
|
- REFACTOR: 정리/회귀 확인
|
||||||
|
- 답글 목록 item에 `latestReply`용 데이터를 넣지 않는다.
|
||||||
|
- `parent.isNotNull`과 `parent.id.eq(commentId)` 조건을 모두 사용한다.
|
||||||
|
|
||||||
|
### Phase 3: Query service 상세/댓글/답글 조립
|
||||||
|
|
||||||
|
- [x] **Task 3.1: 상세 조회 service 조립** - 검증: 상세 service가 기존 게시글 domain 변환, 초기 댓글 20개, 최신 답글을 조립하도록 구현하고 service 테스트를 통과시켰다.
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt`
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt`
|
||||||
|
- RED: 실패 테스트 작성/실패 확인
|
||||||
|
- `CreatorChannelCommunityQueryServiceTest.shouldAssembleCommunityPostDetailWithInitialComments`를 완성한다.
|
||||||
|
- fake port fixture:
|
||||||
|
- `findCommunityPost`는 `isCommentAvailable=true`, `isLiked=true` 게시물 반환
|
||||||
|
- `findCommunityComments`는 21개 댓글 반환
|
||||||
|
- `findLatestRepliesByCommentIds`는 첫 댓글 최신 답글 반환
|
||||||
|
- 검증 항목:
|
||||||
|
- 상세 게시물의 이미지/오디오/본문 접근 정책이 기존 탭과 같다.
|
||||||
|
- `comments.page=0`, `comments.size=20`, `comments.hasNext=true`
|
||||||
|
- `comments.comments.size=20`
|
||||||
|
- 첫 댓글의 `latestReply`가 조립된다.
|
||||||
|
- 실행 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest`
|
||||||
|
- 기대 결과:
|
||||||
|
- `getCommunityPostDetail` 미구현으로 컴파일 실패한다.
|
||||||
|
- GREEN: 최소 구현/통과 확인
|
||||||
|
- `CreatorChannelCommunityQueryService.getCommunityPostDetail(postId, viewer, now)`를 추가한다.
|
||||||
|
- `findCommunityPost`가 null이면 `SodaException(messageKey = "creator.community.invalid_request_retry")`를 던진다.
|
||||||
|
- 게시물 작성자와 조회자 사이 차단 관계는 `existsBlockedBetween(viewerId, post.creatorId)`로 확인한다.
|
||||||
|
- 댓글은 내부 helper `getCommunityComments(postId, viewer, page = 0, size = 20, now)`를 호출해 조립한다.
|
||||||
|
- 실행 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest`
|
||||||
|
- 기대 결과:
|
||||||
|
- `BUILD SUCCESSFUL`
|
||||||
|
- REFACTOR: 정리/회귀 확인
|
||||||
|
- 기존 `CreatorChannelCommunityPostRecord.toDomain(viewerId)`를 상세에도 재사용한다.
|
||||||
|
- `now` parameter는 기존 facade 패턴과 맞춰 테스트 주입 가능하게 유지한다.
|
||||||
|
|
||||||
|
- [x] **Task 3.2: 댓글 목록 service 조립** - 검증: 댓글 service가 page fallback, 댓글 불가 빈 응답, CDN/default profile, 탈퇴 닉네임 prefix 제거, latest reply 조립을 수행하도록 구현하고 테스트를 통과시켰다.
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt`
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt`
|
||||||
|
- RED: 실패 테스트 작성/실패 확인
|
||||||
|
- `CreatorChannelCommunityQueryServiceTest.shouldReturnEmptyCommentsWhenCommentUnavailable`를 추가한다.
|
||||||
|
- `CreatorChannelCommunityQueryServiceTest.shouldApplyCommentPageFallbackAndLatestReply`를 추가한다.
|
||||||
|
- 검증 항목:
|
||||||
|
- `isCommentAvailable=false`이면 comment count/list/latest reply port를 호출하지 않고 빈 응답을 반환한다.
|
||||||
|
- `page=-1`, `size=100` 요청은 `offset=0`, `limit=51`, 응답 `size=50`으로 조립된다.
|
||||||
|
- `findLatestRepliesByCommentIds`는 page 응답에 포함된 최대 `size`개 댓글 id만 대상으로 호출된다.
|
||||||
|
- 실행 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest`
|
||||||
|
- 기대 결과:
|
||||||
|
- `getCommunityComments` 미구현 또는 port 호출 누락으로 실패한다.
|
||||||
|
- GREEN: 최소 구현/통과 확인
|
||||||
|
- `CreatorChannelCommunityQueryService.getCommunityComments(postId, viewer, page, size, now)`를 추가한다.
|
||||||
|
- 게시물 검증은 `findCommunityPost`를 사용한다.
|
||||||
|
- `CreatorChannelCommunityQueryPolicy.limitItems`와 `hasNext`를 재사용한다.
|
||||||
|
- 댓글 record는 CDN URL/default profile URL, 닉네임 prefix 제거 후 domain으로 변환한다.
|
||||||
|
- 실행 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest`
|
||||||
|
- 기대 결과:
|
||||||
|
- `BUILD SUCCESSFUL`
|
||||||
|
- REFACTOR: 정리/회귀 확인
|
||||||
|
- 댓글 조립 helper와 답글 조립 helper를 분리해 중복을 줄인다.
|
||||||
|
- API DTO를 import하지 않는다.
|
||||||
|
|
||||||
|
- [x] **Task 3.3: 답글 목록 service 조립** - 검증: 답글 service가 부모 context 검증, page fallback, reply DTO용 domain 조립을 수행하도록 구현하고 보이지 않는 부모 예외 테스트를 통과시켰다.
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt`
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt`
|
||||||
|
- RED: 실패 테스트 작성/실패 확인
|
||||||
|
- `CreatorChannelCommunityQueryServiceTest.shouldAssembleCommunityReplies`를 완성한다.
|
||||||
|
- `CreatorChannelCommunityQueryServiceTest.shouldThrowWhenRootCommentContextNotVisible`를 추가한다.
|
||||||
|
- 검증 항목:
|
||||||
|
- `findRootCommentContext`가 null이면 `SodaException(messageKey = "creator.community.invalid_request_retry")`
|
||||||
|
- page fallback과 `hasNext`가 댓글 목록과 동일하다.
|
||||||
|
- 응답 domain의 replies item에는 latest reply가 없다.
|
||||||
|
- 실행 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest`
|
||||||
|
- 기대 결과:
|
||||||
|
- `getCommunityReplies` 미구현으로 컴파일 실패한다.
|
||||||
|
- GREEN: 최소 구현/통과 확인
|
||||||
|
- `CreatorChannelCommunityQueryService.getCommunityReplies(commentId, viewer, page, size, now)`를 추가한다.
|
||||||
|
- 부모 댓글 context 조회 후 게시물 작성자와 조회자 사이 차단 관계를 확인한다.
|
||||||
|
- `countCommunityReplies`, `findCommunityReplies`로 답글 응답을 조립한다.
|
||||||
|
- 실행 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest`
|
||||||
|
- 기대 결과:
|
||||||
|
- `BUILD SUCCESSFUL`
|
||||||
|
- REFACTOR: 정리/회귀 확인
|
||||||
|
- 댓글/답글 record 변환에서 default profile URL과 닉네임 정리를 공통 private helper로 묶는다.
|
||||||
|
|
||||||
|
### Phase 4: Facade와 Controller endpoint 연결
|
||||||
|
|
||||||
|
- [x] **Task 4.1: facade method 추가** - 검증: 상세/댓글/답글 facade method를 추가하고 raw page/size 전달 및 공개 response DTO 변환 테스트를 통과시켰다.
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt`
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt`
|
||||||
|
- RED: 실패 테스트 작성/실패 확인
|
||||||
|
- 추가할 테스트 이름:
|
||||||
|
- `shouldReturnCommunityPostDetailResponse`
|
||||||
|
- `shouldReturnCommunityCommentsResponse`
|
||||||
|
- `shouldReturnCommunityRepliesResponse`
|
||||||
|
- 검증 항목:
|
||||||
|
- facade가 query service 결과를 response DTO로 변환한다.
|
||||||
|
- raw `page`, `size`를 service로 전달한다.
|
||||||
|
- 실행 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.application.CreatorChannelCommunityFacadeTest`
|
||||||
|
- 기대 결과:
|
||||||
|
- 신규 facade method 미구현으로 컴파일 실패한다.
|
||||||
|
- GREEN: 최소 구현/통과 확인
|
||||||
|
- `getCommunityPostDetail(postId, viewer, now)`, `getCommunityComments(postId, viewer, page, size, now)`, `getCommunityReplies(commentId, viewer, page, size, now)`를 추가한다.
|
||||||
|
- 실행 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.application.CreatorChannelCommunityFacadeTest`
|
||||||
|
- 기대 결과:
|
||||||
|
- `BUILD SUCCESSFUL`
|
||||||
|
- REFACTOR: 정리/회귀 확인
|
||||||
|
- facade에는 비즈니스 조건문을 넣지 않는다.
|
||||||
|
|
||||||
|
- [x] **Task 4.2: controller endpoint 추가** - 검증: 세 v2 GET mapping을 추가하고 인증 거부, 성공 JSON, raw page/size 전달 controller 테스트를 통과시켰다.
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt`
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt`
|
||||||
|
- RED: 실패 테스트 작성/실패 확인
|
||||||
|
- 추가할 테스트 이름:
|
||||||
|
- `shouldRejectAnonymousCommunityPostDetailRequest`
|
||||||
|
- `shouldReturnCommunityPostDetailForAuthenticatedMember`
|
||||||
|
- `shouldReturnCommunityCommentsForAuthenticatedMember`
|
||||||
|
- `shouldPassRawCommentPageAndSizeToFacade`
|
||||||
|
- `shouldReturnCommunityRepliesForAuthenticatedMember`
|
||||||
|
- `shouldPassRawReplyPageAndSizeToFacade`
|
||||||
|
- 검증 항목:
|
||||||
|
- 세 endpoint는 인증 회원만 통과한다.
|
||||||
|
- JSON 경로:
|
||||||
|
- `$.data.postId`
|
||||||
|
- `$.data.isLiked`
|
||||||
|
- `$.data.comments.commentCount`
|
||||||
|
- `$.data.comments.comments[0].latestReply.commentId`
|
||||||
|
- `$.data.hasNext`
|
||||||
|
- controller는 page/size 보정을 하지 않고 facade로 raw 값을 전달한다.
|
||||||
|
- 실행 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityControllerTest`
|
||||||
|
- 기대 결과:
|
||||||
|
- 신규 mapping 미구현으로 404 또는 컴파일 실패를 확인한다.
|
||||||
|
- GREEN: 최소 구현/통과 확인
|
||||||
|
- controller에 아래 mapping을 추가한다.
|
||||||
|
- `@GetMapping("/community-posts/{postId}")`
|
||||||
|
- `@GetMapping("/community-posts/{postId}/comments")`
|
||||||
|
- `@GetMapping("/community-comments/{commentId}/replies")`
|
||||||
|
- 기존 `requireMember`를 재사용한다.
|
||||||
|
- 실행 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityControllerTest`
|
||||||
|
- 기대 결과:
|
||||||
|
- `BUILD SUCCESSFUL`
|
||||||
|
- REFACTOR: 정리/회귀 확인
|
||||||
|
- 기존 `@GetMapping("/{creatorId}/community")`와 신규 literal path가 충돌하지 않는지 controller test에서 모두 통과 확인한다.
|
||||||
|
|
||||||
|
### Phase 5: End-to-End와 회귀 검증
|
||||||
|
|
||||||
|
- [x] **Task 5.1: 상세 API E2E 테스트** - 검증: 상세 API E2E에서 `isLiked=true`, 초기 댓글 20개, `hasNext=true`, 최신 답글, UTC `Z` 문자열을 확인했다.
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt`
|
||||||
|
- RED: 실패 테스트 작성/실패 확인
|
||||||
|
- `CreatorChannelCommunityEndToEndTest.shouldReturnCommunityPostDetailWithInitialComments`를 추가한다.
|
||||||
|
- fixture:
|
||||||
|
- creator, viewer
|
||||||
|
- 커뮤니티 게시물 1개
|
||||||
|
- 활성 좋아요
|
||||||
|
- 댓글 21개
|
||||||
|
- 첫 댓글 최신 답글 1개
|
||||||
|
- 검증 항목:
|
||||||
|
- `GET /api/v2/creator-channels/community-posts/{postId}` 200 OK
|
||||||
|
- `data.isLiked=true`
|
||||||
|
- `data.comments.comments.length()==20`
|
||||||
|
- `data.comments.hasNext=true`
|
||||||
|
- `data.comments.comments[0].latestReply` 존재
|
||||||
|
- `createdAtUtc`는 `Z`로 끝나는 UTC 문자열
|
||||||
|
- 실행 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest`
|
||||||
|
- 기대 결과:
|
||||||
|
- API 미구현 상태에서 404 또는 JSON assertion 실패를 확인한다.
|
||||||
|
- GREEN: 최소 구현/통과 확인
|
||||||
|
- Phase 1~4 구현을 연결해 테스트를 통과시킨다.
|
||||||
|
- 실행 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest`
|
||||||
|
- 기대 결과:
|
||||||
|
- `BUILD SUCCESSFUL`
|
||||||
|
- REFACTOR: 정리/회귀 확인
|
||||||
|
- E2E fixture helper가 기존 커뮤니티 탭 테스트 helper와 충돌하지 않도록 private helper로 유지한다.
|
||||||
|
|
||||||
|
- [x] **Task 5.2: 댓글/답글 API E2E 테스트** - 검증: 댓글 API, 답글 API, 댓글 불가 빈 응답 E2E를 추가했고 답글 item에 `latestReply`가 없음을 확인했다.
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt`
|
||||||
|
- RED: 실패 테스트 작성/실패 확인
|
||||||
|
- 추가할 테스트 이름:
|
||||||
|
- `shouldReturnPagedCommunityCommentsWithLatestReply`
|
||||||
|
- `shouldReturnPagedCommunityRepliesWithoutLatestReply`
|
||||||
|
- `shouldReturnEmptyCommentsWhenCommentUnavailable`
|
||||||
|
- 검증 항목:
|
||||||
|
- 댓글 API는 `commentCount`, `comments`, `page`, `size`, `hasNext`를 반환한다.
|
||||||
|
- 답글 API는 `replyCount`, `replies`, `page`, `size`, `hasNext`를 반환한다.
|
||||||
|
- 답글 item에는 `latestReply` 필드가 없다.
|
||||||
|
- 댓글 불가 게시물은 빈 댓글 응답을 반환한다.
|
||||||
|
- 실행 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest`
|
||||||
|
- 기대 결과:
|
||||||
|
- API 미구현 상태에서 404 또는 JSON assertion 실패를 확인한다.
|
||||||
|
- GREEN: 최소 구현/통과 확인
|
||||||
|
- 댓글/답글 controller, facade, service, repository 연결을 완료한다.
|
||||||
|
- 실행 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest`
|
||||||
|
- 기대 결과:
|
||||||
|
- `BUILD SUCCESSFUL`
|
||||||
|
- REFACTOR: 정리/회귀 확인
|
||||||
|
- 응답 필드명 `commentId`가 댓글과 답글 모두에서 동일하게 사용되는지 확인한다.
|
||||||
|
|
||||||
|
- [x] **Task 5.3: 접근 정책 E2E 테스트** - 검증: 성인 콘텐츠 미허용 회원의 19금 상세 접근이 공통 오류 응답(`success=false`)으로 거부됨을 E2E로 확인했다.
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt`
|
||||||
|
- RED: 실패 테스트 작성/실패 확인
|
||||||
|
- 추가할 테스트 이름:
|
||||||
|
- `shouldRejectAdultCommunityPostWhenViewerCannotViewAdultContent`
|
||||||
|
- `shouldRejectCommunityPostWhenViewerAndCreatorAreBlocked`
|
||||||
|
- `shouldFilterSecretAndBlockedComments`
|
||||||
|
- `shouldRejectRepliesWhenParentCommentIsNotVisible`
|
||||||
|
- 검증 항목:
|
||||||
|
- 19금 게시물 접근 불가
|
||||||
|
- 게시물 작성자 차단 관계 접근 차단
|
||||||
|
- 비밀 댓글은 게시물 작성자/댓글 작성자 본인에게만 노출
|
||||||
|
- 차단 댓글 작성자와 답글 작성자는 count/list/latestReply에서 제외
|
||||||
|
- 보이지 않는 부모 댓글의 답글 조회는 오류
|
||||||
|
- 실행 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest`
|
||||||
|
- 기대 결과:
|
||||||
|
- 접근 정책 미구현으로 assertion 실패를 확인한다.
|
||||||
|
- GREEN: 최소 구현/통과 확인
|
||||||
|
- repository 조건과 service 예외 처리를 조정해 테스트를 통과시킨다.
|
||||||
|
- 실행 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest`
|
||||||
|
- 기대 결과:
|
||||||
|
- `BUILD SUCCESSFUL`
|
||||||
|
- REFACTOR: 정리/회귀 확인
|
||||||
|
- legacy `/creator-community` controller/service는 수정하지 않았는지 `git diff`로 확인한다.
|
||||||
|
|
||||||
|
### Phase 6: 전체 회귀와 문서 갱신
|
||||||
|
|
||||||
|
- [x] **Task 6.1: 기존 커뮤니티 탭 회귀 테스트** - 검증: community controller/service/repository 포함 focused test 묶음과 domain 패키지 API 의존성 검색을 실행해 회귀가 없음을 확인했다.
|
||||||
|
- Files:
|
||||||
|
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt`
|
||||||
|
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt`
|
||||||
|
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepositoryTest.kt`
|
||||||
|
- RED: 실패 테스트 작성/실패 확인
|
||||||
|
- TDD 예외 사유: 이 task는 신규 테스트 작성이 아니라 기존 커뮤니티 탭 동작 회귀 검증이다.
|
||||||
|
- 대체 검증 방법: 기존 테스트 전체를 실행해 상세/댓글 추가가 탭 목록 동작을 깨지 않는지 확인한다.
|
||||||
|
- GREEN: 최소 구현/통과 확인
|
||||||
|
- 실행 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityControllerTest`
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest`
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorChannelCommunityQueryRepositoryTest`
|
||||||
|
- 기대 결과:
|
||||||
|
- 모두 `BUILD SUCCESSFUL`
|
||||||
|
- REFACTOR: 정리/회귀 확인
|
||||||
|
- `rg -n "v2\\.api\\." src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community` 실행 결과가 0건인지 확인한다.
|
||||||
|
|
||||||
|
- [x] **Task 6.2: formatting, compile, 전체 검증** - 검증: `./gradlew compileKotlin`, `./gradlew ktlintCheck`, `./gradlew test`, `./gradlew tasks --all`을 실행해 모두 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- Files:
|
||||||
|
- Verify: 전체 변경 파일
|
||||||
|
- Modify: `docs/20260706_커뮤니티_게시물_상세_API/plan-task.md`
|
||||||
|
- RED: 실패 테스트 작성/실패 확인
|
||||||
|
- TDD 예외 사유: 전체 검증 task는 구현 완료 후 회귀 확인 단계라 별도 실패 테스트를 만들지 않는다.
|
||||||
|
- 대체 검증 방법: 단일 테스트, compile, ktlint, 전체 테스트를 순서대로 실행한다.
|
||||||
|
- GREEN: 최소 구현/통과 확인
|
||||||
|
- 실행 명령:
|
||||||
|
- `./gradlew compileKotlin`
|
||||||
|
- `./gradlew ktlintCheck`
|
||||||
|
- `./gradlew test`
|
||||||
|
- 기대 결과:
|
||||||
|
- 모두 `BUILD SUCCESSFUL`
|
||||||
|
- REFACTOR: 정리/회귀 확인
|
||||||
|
- 실행 명령:
|
||||||
|
- `./gradlew tasks --all`
|
||||||
|
- 기대 결과:
|
||||||
|
- `BUILD SUCCESSFUL`
|
||||||
|
- 검증 결과를 이 문서 하단 "검증 기록"에 누적한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 구현 순서 권장
|
||||||
|
|
||||||
|
1. Task 1.1 → Task 1.2로 공개 DTO와 domain type을 먼저 고정한다.
|
||||||
|
2. Task 2.1 → Task 2.4로 repository 조회 계약과 QueryDSL 조건을 테스트로 고정한다.
|
||||||
|
3. Task 3.1 → Task 3.3으로 service 조립과 예외 정책을 완성한다.
|
||||||
|
4. Task 4.1 → Task 4.2로 facade/controller endpoint를 연결한다.
|
||||||
|
5. Task 5.1 → Task 5.3으로 실제 HTTP 흐름과 접근 정책을 검증한다.
|
||||||
|
6. Task 6.1 → Task 6.2로 기존 커뮤니티 탭 회귀와 전체 검증을 수행한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 검증 기록
|
||||||
|
|
||||||
|
- 2026-07-06: plan-task 문서 작성 전 `docs/20260706_커뮤니티_게시물_상세_API/prd.md`, `docs/agent-guides/코드스타일.md`, `docs/agent-guides/테스트스타일.md`, `docs/agent-guides/실행명령어.md`를 확인했다.
|
||||||
|
- 2026-07-06: 기존 유사 계획 문서 `docs/20260621_크리에이터_채널_커뮤니티_탭_API/plan-task.md`, `docs/20260622_크리에이터_채널_FanTalk_탭_API/plan-task.md`를 확인해 문서 구조와 TDD task 형식을 맞췄다.
|
||||||
|
- 2026-07-06: `rg -n "TBD|TODO|implement later|fill in|적절|:app:compileDebugKotlin|android|Android|Similar" docs/20260706_커뮤니티_게시물_상세_API/plan-task.md`로 placeholder와 잘못된 Android task 예시가 없음을 확인했다.
|
||||||
|
- 2026-07-06: `./gradlew tasks --all`을 실행해 Gradle 명령 유효성을 확인했고 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- 2026-07-06: Phase 1-5 구현 후 `./gradlew compileKotlin`, DTO/facade/controller/service/repository/E2E focused tests를 실행해 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- 2026-07-07: 포맷 정리 후 `./gradlew ktlintCheck`와 관련 focused test 묶음을 재실행해 모두 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- 2026-07-07: `rg -n "v2\.api\." src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community` 실행 결과 0건으로 domain/query 계층의 API DTO 의존이 없음을 확인했다.
|
||||||
|
- 2026-07-07: `rg -n "creator-community|schema|migration|Flyway|Liquibase" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/test/kotlin/kr/co/vividnext/sodalive/v2 docs/20260706_커뮤니티_게시물_상세_API/plan-task.md` 실행 결과 문서 언급 외 legacy endpoint/schema 변경 흔적이 없음을 확인했다.
|
||||||
|
- 2026-07-07: `./gradlew compileKotlin`, `./gradlew ktlintCheck`, `./gradlew test`, `./gradlew tasks --all`을 실행해 모두 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- 2026-07-07: 리뷰 게이트에서 Task 5.3 E2E 누락, 비밀 답글 노출, 댓글 불가 게시글의 답글 직접 조회 우회가 발견되어 repository/service/E2E 실패 테스트를 추가했다.
|
||||||
|
- 2026-07-07: 추가 RED 확인으로 `DefaultCreatorChannelCommunityQueryRepositoryTest`, `CreatorChannelCommunityQueryServiceTest`, `CreatorChannelCommunityEndToEndTest` focused 실행 시 5개 테스트 실패를 확인했다.
|
||||||
|
- 2026-07-07: 비밀 답글 필터와 댓글 불가 답글 빈 응답 처리를 구현한 뒤 같은 focused test 묶음을 재실행해 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- 2026-07-07: 보강 후 `./gradlew compileKotlin`, `./gradlew ktlintCheck`, `./gradlew test`, `./gradlew tasks --all`을 재실행해 모두 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- 2026-07-07: `rg -n "shouldRejectCommunityPostWhenViewerAndCreatorAreBlocked|shouldFilterSecretAndBlockedComments|shouldRejectRepliesWhenParentCommentIsNotVisible|shouldReturnEmptyRepliesWhenCommentUnavailable" ...`로 접근 정책 E2E/서비스 테스트명이 추가됐음을 확인했다.
|
||||||
301
docs/20260706_커뮤니티_게시물_상세_API/prd.md
Normal file
301
docs/20260706_커뮤니티_게시물_상세_API/prd.md
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
# PRD: 커뮤니티 게시물 상세 API
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
커뮤니티 게시물 ID로 게시물 상세와 초기 댓글 20개를 조회하고, 이후 댓글/답글을 페이징 조회하는 v2 API를 제공한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Problem
|
||||||
|
- 크리에이터 채널 커뮤니티 탭은 게시글 목록을 제공하지만, 게시물 상세 화면에서 게시물 1건과 초기 댓글을 함께 조회하는 v2 API가 없다.
|
||||||
|
- 기존 legacy `/creator-community/{id}` 상세 API는 `firstComment` 1개만 제공하고, 클라이언트가 상세 진입 직후 댓글 목록을 별도로 다시 조회해야 한다.
|
||||||
|
- legacy 댓글 API는 timezone 기반 표시 문자열을 반환하지만, 신규 상세 화면은 UTC 기반 날짜/시간과 v2 응답 패턴이 필요하다.
|
||||||
|
- 커뮤니티 탭의 `CreatorChannelCommunityPost`에는 `isLiked`가 이미 포함되어 있으므로 상세 API도 동일한 게시물 필드 의미를 유지해야 한다.
|
||||||
|
- 댓글/답글 조회는 legacy entity와 필터 정책을 재사용할 수 있지만, 공개 API 조립 계층과 도메인 조회 계층은 기존 v2 패키지 경계를 따라 분리되어야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Goals
|
||||||
|
- 커뮤니티 게시물 ID로 게시물 상세를 조회하는 API를 제공한다.
|
||||||
|
- 상세 응답의 게시물 필드는 크리에이터 채널 커뮤니티 탭의 `CreatorChannelCommunityPostResponse`와 동일한 의미와 필드명을 사용한다.
|
||||||
|
- 상세 응답의 게시물 필드에는 조회자의 활성 좋아요 여부인 `isLiked`를 포함한다.
|
||||||
|
- 상세 응답에는 첫 댓글 20개를 함께 포함하며, 이 댓글 묶음은 댓글 조회 API 응답과 동일한 response data class를 사용한다.
|
||||||
|
- 커뮤니티 댓글 목록을 page/size 기반으로 페이징 조회하는 API를 제공한다.
|
||||||
|
- 커뮤니티 댓글의 답글 목록을 page/size 기반으로 페이징 조회하는 API를 제공한다.
|
||||||
|
- API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.community` 하위 조립 계층에 둔다.
|
||||||
|
- 게시물 상세, 댓글, 답글 조회 정책과 domain model, port, repository는 `kr.co.vividnext.sodalive.v2.creator.channel.community` 하위 도메인 조회 계층에 둔다.
|
||||||
|
- 기존 커뮤니티 탭 조회에서 재사용 가능한 `CreatorChannelCommunityPost`, `CreatorChannelCommunityQueryService`, `CreatorChannelCommunityQueryPolicy`, repository helper를 우선 재사용한다.
|
||||||
|
- legacy `CreatorCommunityComment` entity와 차단/비밀 댓글 필터 정책은 재사용하되, legacy response DTO를 v2 공개 응답으로 직접 노출하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Non-Goals
|
||||||
|
- 커뮤니티 게시물 작성, 수정, 삭제 API는 포함하지 않는다.
|
||||||
|
- 커뮤니티 게시물 좋아요 생성/취소 API는 변경하지 않는다.
|
||||||
|
- 커뮤니티 댓글/답글 작성, 수정, 삭제 API는 포함하지 않는다.
|
||||||
|
- legacy `/creator-community` API의 endpoint와 응답 스키마는 변경하지 않는다.
|
||||||
|
- 커뮤니티 탭 목록 API의 공개 응답 스키마는 변경하지 않는다.
|
||||||
|
- DB schema, 운영 DDL, 마이그레이션은 포함하지 않는다.
|
||||||
|
- 앱 표시용 상대 시간 문구는 서버에서 새로 조합하지 않는다.
|
||||||
|
- 댓글 좋아요, 댓글 신고, 답글 전체 개수의 댓글 item 내 노출은 포함하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Target Users
|
||||||
|
- 회원: 커뮤니티 게시물 상세 화면에서 게시물 내용과 댓글을 확인하는 사용자
|
||||||
|
- 앱 클라이언트: 상세 진입 시 게시물과 초기 댓글 20개를 한 번에 렌더링하고 이후 댓글/답글을 추가 로딩하려는 클라이언트
|
||||||
|
- 서버 개발자: v2 커뮤니티 탭, 상세, 댓글 조회 정책을 중복 없이 재사용해야 하는 개발자
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 조사 결과
|
||||||
|
|
||||||
|
### 재사용 후보
|
||||||
|
- `CreatorChannelCommunityPost`
|
||||||
|
- 파일: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityTab.kt`
|
||||||
|
- 현재 필드: `postId`, `creatorId`, `creatorNickname`, `creatorProfileUrl`, `imageUrl`, `audioUrl`, `content`, `price`, `createdAt`, `existOrdered`, `isCommentAvailable`, `likeCount`, `commentCount`, `isPinned`, `isLiked`
|
||||||
|
- 상세 응답의 게시물 본문은 이 domain model을 재사용한다.
|
||||||
|
|
||||||
|
- `CreatorChannelCommunityPostResponse`
|
||||||
|
- 파일: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityTabResponse.kt`
|
||||||
|
- 현재 필드: `postId`, `creatorId`, `creatorNickname`, `creatorProfileUrl`, `createdAtUtc`, `content`, `imageUrl`, `audioUrl`, `price`, `isCommentAvailable`, `existOrdered`, `likeCount`, `commentCount`, `isPinned`, `isLiked`
|
||||||
|
- 상세 응답의 게시물 필드는 이 response data class와 동일한 필드명/의미를 사용한다.
|
||||||
|
|
||||||
|
- `CreatorChannelCommunityQueryService`
|
||||||
|
- 파일: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt`
|
||||||
|
- 현재 커뮤니티 탭과 채널 홈 커뮤니티 게시글 조회를 담당한다.
|
||||||
|
- 게시물 상세 조회 method를 추가해 게시물 1건 조립과 유료 콘텐츠 접근 정책, CDN URL, signed audio URL, 본문 마스킹, `isLiked` 전달을 재사용한다.
|
||||||
|
|
||||||
|
- `CreatorChannelCommunityQueryPolicy`
|
||||||
|
- 파일: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicy.kt`
|
||||||
|
- page 기본값/보정 규칙과 `hasNext` 판정, 유료 본문 마스킹 정책을 제공한다.
|
||||||
|
- 댓글/답글 page도 같은 `page=0`, `size=20`, `size min=20`, `size max=50` 규칙을 따른다.
|
||||||
|
|
||||||
|
- `CreatorCommunityComment`
|
||||||
|
- 파일: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/comment/CreatorCommunityComment.kt`
|
||||||
|
- legacy 댓글 entity이며 `parent`로 답글을 표현한다.
|
||||||
|
- 신규 v2 조회 repository에서 같은 entity를 조회하되 legacy DTO는 공개 API로 재사용하지 않는다.
|
||||||
|
|
||||||
|
- legacy 댓글 필터 정책
|
||||||
|
- 파일: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/comment/CreatorCommunityCommentRepository.kt`
|
||||||
|
- 활성 댓글, 최상위 댓글/답글 구분, 작성자 차단/피차단 제외, 비밀 댓글 노출 정책을 참고한다.
|
||||||
|
|
||||||
|
### 기존 endpoint
|
||||||
|
- 커뮤니티 탭: `GET /api/v2/creator-channels/{creatorId}/community`
|
||||||
|
- legacy 게시물 상세: `GET /creator-community/{id}`
|
||||||
|
- legacy 댓글 목록: `GET /creator-community/{id}/comment`
|
||||||
|
- legacy 답글 목록: `GET /creator-community/comment/{id}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. User Stories
|
||||||
|
- 사용자는 커뮤니티 게시물 상세 화면에 들어가면 게시물 내용, 미디어, 좋아요 상태, 좋아요 수, 댓글 수를 즉시 확인하고 싶다.
|
||||||
|
- 사용자는 게시물 상세 화면 진입 직후 최신 댓글 20개를 바로 보고 싶다.
|
||||||
|
- 사용자는 댓글 목록을 계속 아래로 스크롤하며 추가 조회하고 싶다.
|
||||||
|
- 사용자는 댓글에 달린 최신 답글 1개를 댓글 목록에서 미리 보고 싶다.
|
||||||
|
- 사용자는 특정 댓글의 답글 목록을 추가로 페이징 조회하고 싶다.
|
||||||
|
- 앱 클라이언트는 상세 API의 초기 댓글 응답과 댓글 조회 API 응답을 같은 data class로 처리하고 싶다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Core Features
|
||||||
|
|
||||||
|
### Feature A. 커뮤니티 게시물 상세 조회 API
|
||||||
|
|
||||||
|
#### Endpoint
|
||||||
|
- `GET /api/v2/creator-channels/community-posts/{postId}`
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- `postId`는 path variable로 받는다.
|
||||||
|
- API는 인증 회원만 조회할 수 있어야 한다.
|
||||||
|
- 비회원이 조회하면 기존 인증 필요 v2 API와 동일하게 `common.error.bad_credentials` 계열 오류를 반환한다.
|
||||||
|
- 게시물이 존재하지 않거나 비활성 상태이면 기존 커뮤니티 정책과 동일하게 조회 불가 오류를 반환한다.
|
||||||
|
- 조회자의 성인 콘텐츠 노출 정책이 false이고 게시물이 19금이면 조회 불가 오류를 반환한다.
|
||||||
|
- 조회자와 게시물 작성자 사이에 차단 관계가 있으면 기존 크리에이터 채널 접근 정책과 동일하게 접근 차단 오류를 반환한다.
|
||||||
|
- 게시물 필드는 커뮤니티 탭의 `CreatorChannelCommunityPostResponse`와 동일한 필드명과 의미를 사용한다.
|
||||||
|
- 게시물 필드에는 `isLiked`를 포함하며, 조회자의 `CreatorCommunityLike.isActive == true`인 좋아요가 있으면 `true`다.
|
||||||
|
- 상세 응답에는 `comments` 필드로 첫 댓글 20개를 포함한다.
|
||||||
|
- `comments`는 댓글 조회 API의 `CreatorChannelCommunityCommentsResponse`와 동일한 response data class를 사용한다.
|
||||||
|
- 상세 응답의 `comments.page`는 `0`, `comments.size`는 `20`으로 내려준다.
|
||||||
|
- 상세 응답의 `comments.hasNext`는 같은 조건에서 21번째 댓글이 있으면 `true`다.
|
||||||
|
- `isCommentAvailable == false`인 게시물은 `comments.commentCount=0`, `comments.comments=[]`, `comments.hasNext=false`로 내려준다.
|
||||||
|
- 유료 게시물의 본문, 이미지, 오디오 접근 정책은 커뮤니티 탭 `CreatorChannelCommunityPost`와 동일하게 적용한다.
|
||||||
|
|
||||||
|
#### Response Data Class
|
||||||
|
```kotlin
|
||||||
|
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
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 게시물 작성자가 조회자인 경우에도 성인 콘텐츠 노출 정책이 false이면 19금 게시물은 조회할 수 없다.
|
||||||
|
- 유료 게시물을 구매하지 않은 조회자에게는 커뮤니티 탭과 동일하게 이미지 URL과 오디오 URL을 `null`로 내려주고 본문을 마스킹한다.
|
||||||
|
- 게시물에 이미지나 오디오가 없으면 각각 `null`로 내려준다.
|
||||||
|
- 댓글이 없어도 상세 API는 성공하며 `comments.comments=[]`를 내려준다.
|
||||||
|
|
||||||
|
### Feature B. 커뮤니티 댓글 조회 API
|
||||||
|
|
||||||
|
#### Endpoint
|
||||||
|
- `GET /api/v2/creator-channels/community-posts/{postId}/comments`
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- `postId`는 path variable로 받는다.
|
||||||
|
- `page`, `size` query parameter를 받는다.
|
||||||
|
- `page` 기본값은 `0`이다.
|
||||||
|
- `size` 기본값은 `20`이다.
|
||||||
|
- `page`가 0보다 작으면 `0`으로 보정한다.
|
||||||
|
- `size`가 20보다 작으면 `20`으로 보정한다.
|
||||||
|
- `size`가 50보다 크면 `50`으로 보정한다.
|
||||||
|
- API는 인증 회원만 조회할 수 있어야 한다.
|
||||||
|
- 게시물이 존재하지 않거나 비활성 상태이면 기존 커뮤니티 정책과 동일하게 조회 불가 오류를 반환한다.
|
||||||
|
- 조회자의 성인 콘텐츠 노출 정책이 false이고 게시물이 19금이면 조회 불가 오류를 반환한다.
|
||||||
|
- 조회자와 게시물 작성자 사이에 차단 관계가 있으면 빈 목록이 아니라 접근 차단 오류를 반환한다.
|
||||||
|
- `isCommentAvailable == false`인 게시물은 `commentCount=0`, `comments=[]`, `hasNext=false`로 내려준다.
|
||||||
|
- `commentCount`는 조회자가 볼 수 있는 활성 최상위 댓글 전체 개수다.
|
||||||
|
- `comments`는 조회자가 볼 수 있는 활성 최상위 댓글 목록이다.
|
||||||
|
- 댓글 작성자가 조회자와 차단/피차단 관계이면 목록과 전체 개수에서 제외한다.
|
||||||
|
- 비밀 댓글은 게시물 작성자 또는 댓글 작성자 본인에게만 노출한다.
|
||||||
|
- 댓글 정렬은 최신순 `createdAt desc`, `id desc`를 따른다.
|
||||||
|
- 각 댓글에는 최신 답글 1개를 `latestReply`로 포함한다.
|
||||||
|
- 최신 답글은 해당 댓글의 활성 답글 중 조회자가 볼 수 있는 답글을 `createdAt desc`, `id desc`로 정렬했을 때 첫 번째 항목이다.
|
||||||
|
- 최신 답글이 없으면 `latestReply`는 `null`이다.
|
||||||
|
- 날짜/시간은 UTC 기준 ISO-8601 문자열인 `createdAtUtc`로 내려준다.
|
||||||
|
- 작성자 프로필 이미지가 없으면 기존 기본 프로필 이미지 URL을 내려준다.
|
||||||
|
- 탈퇴 회원 닉네임 prefix 제거는 기존 `removeDeletedNicknamePrefix` 정책을 적용한다.
|
||||||
|
|
||||||
|
#### Response Data Class
|
||||||
|
```kotlin
|
||||||
|
data class CreatorChannelCommunityCommentsResponse(
|
||||||
|
val commentCount: Int,
|
||||||
|
val comments: List<CreatorChannelCommunityCommentResponse>,
|
||||||
|
val page: Int,
|
||||||
|
val size: Int,
|
||||||
|
@JsonProperty("hasNext")
|
||||||
|
val hasNext: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelCommunityCommentResponse(
|
||||||
|
val commentId: Long,
|
||||||
|
val writerProfileImageUrl: String,
|
||||||
|
val writerNickname: String,
|
||||||
|
val content: String,
|
||||||
|
val createdAtUtc: String,
|
||||||
|
val latestReply: CreatorChannelCommunityReplyResponse?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelCommunityReplyResponse(
|
||||||
|
val commentId: Long,
|
||||||
|
val writerProfileImageUrl: String,
|
||||||
|
val writerNickname: String,
|
||||||
|
val content: String,
|
||||||
|
val createdAtUtc: String
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 요청한 page 범위에 댓글이 없으면 `comments=[]`, `hasNext=false`이고 `commentCount`는 전체 개수를 유지한다.
|
||||||
|
- 댓글은 있지만 조회자의 차단 관계나 비밀 댓글 정책으로 모두 제외되면 `commentCount=0`, `comments=[]`가 된다.
|
||||||
|
- 댓글 작성자 프로필 이미지 path가 blank이면 기본 프로필 이미지 URL을 사용한다.
|
||||||
|
- 답글 작성자가 조회자와 차단/피차단 관계이면 `latestReply` 후보에서 제외한다.
|
||||||
|
|
||||||
|
### Feature C. 커뮤니티 댓글 답글 조회 API
|
||||||
|
|
||||||
|
#### Endpoint
|
||||||
|
- `GET /api/v2/creator-channels/community-comments/{commentId}/replies`
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- `commentId`는 path variable로 받는다.
|
||||||
|
- `page`, `size` query parameter를 받는다.
|
||||||
|
- page/size 보정 규칙은 댓글 조회 API와 동일하다.
|
||||||
|
- API는 인증 회원만 조회할 수 있어야 한다.
|
||||||
|
- `commentId`가 최상위 댓글이 아니거나 존재하지 않거나 비활성 상태이면 기존 커뮤니티 정책과 동일하게 조회 불가 오류를 반환한다.
|
||||||
|
- 부모 댓글이 속한 게시물이 존재하지 않거나 비활성 상태이면 조회 불가 오류를 반환한다.
|
||||||
|
- 조회자의 성인 콘텐츠 노출 정책이 false이고 부모 댓글이 속한 게시물이 19금이면 조회 불가 오류를 반환한다.
|
||||||
|
- 조회자와 게시물 작성자 사이에 차단 관계가 있으면 접근 차단 오류를 반환한다.
|
||||||
|
- 부모 댓글 자체가 조회자에게 보이지 않는 비밀 댓글이거나 부모 댓글 작성자가 조회자와 차단/피차단 관계이면 조회 불가 오류를 반환한다.
|
||||||
|
- `replyCount`는 조회자가 볼 수 있는 활성 답글 전체 개수다.
|
||||||
|
- `replies`는 조회자가 볼 수 있는 활성 답글 목록이다.
|
||||||
|
- 답글 작성자가 조회자와 차단/피차단 관계이면 목록과 전체 개수에서 제외한다.
|
||||||
|
- 답글 정렬은 최신순 `createdAt desc`, `id desc`를 따른다.
|
||||||
|
- 답글 item은 댓글 item과 같은 작성자/본문/UTC 작성 시간 구조를 사용하지만 `latestReply` 필드는 포함하지 않는다.
|
||||||
|
|
||||||
|
#### Response Data Class
|
||||||
|
```kotlin
|
||||||
|
data class CreatorChannelCommunityRepliesResponse(
|
||||||
|
val replyCount: Int,
|
||||||
|
val replies: List<CreatorChannelCommunityReplyResponse>,
|
||||||
|
val page: Int,
|
||||||
|
val size: Int,
|
||||||
|
@JsonProperty("hasNext")
|
||||||
|
val hasNext: Boolean
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 요청한 page 범위에 답글이 없으면 `replies=[]`, `hasNext=false`이고 `replyCount`는 전체 개수를 유지한다.
|
||||||
|
- 답글이 있지만 조회자의 차단 관계로 모두 제외되면 `replyCount=0`, `replies=[]`가 된다.
|
||||||
|
|
||||||
|
### Feature D. API 조립 계층과 도메인 조회 계층 분리
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.community` 하위에 둔다.
|
||||||
|
- domain model, query service, policy, port, repository는 `kr.co.vividnext.sodalive.v2.creator.channel.community` 하위에 둔다.
|
||||||
|
- API 조립 계층은 인증 회원 확인, path/query parameter 수신, domain 결과를 response DTO로 변환하는 책임만 가진다.
|
||||||
|
- 도메인 조회 계층은 API response DTO를 import하지 않는다.
|
||||||
|
- 도메인 조회 계층은 API facade나 controller를 import하지 않는다.
|
||||||
|
- 의존 방향은 항상 `v2.api.creator.channel.community -> v2.creator.channel.community`이다.
|
||||||
|
- 댓글/답글 조회용 domain model은 v2 전용으로 만들고, legacy `GetCommunityPostCommentListResponse`와 `GetCommunityPostCommentListItem`은 공개 v2 응답에 재사용하지 않는다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 기존 `GET /api/v2/creator-channels/{creatorId}/community` mapping과 신규 `GET /api/v2/creator-channels/community-posts/{postId}` mapping이 충돌하면 안 된다.
|
||||||
|
- 도메인 조회 계층에 `kr.co.vividnext.sodalive.v2.api.*` import가 생기면 안 된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Technical Constraints
|
||||||
|
- 빌드 도구는 Gradle Wrapper(`./gradlew`)를 사용한다.
|
||||||
|
- Kotlin + Spring Boot 2.7.14 기존 스타일을 따른다.
|
||||||
|
- 신규 공개 API 스키마는 구현 전에 PRD와 구현 계획/TASK 문서에 명시한다.
|
||||||
|
- Boolean 응답 필드는 Jackson 직렬화 이름 보존을 위해 `@JsonProperty("isCommentAvailable")`, `@JsonProperty("isPinned")`, `@JsonProperty("isLiked")`, `@JsonProperty("hasNext")`를 명시한다.
|
||||||
|
- 날짜 응답은 `kr.co.vividnext.sodalive.extensions.toUtcIso`를 우선 재사용해 UTC 기준 ISO-8601 문자열로 내려준다.
|
||||||
|
- 프로필 이미지 URL은 기존 `String?.toCdnUrl(cloudFrontHost)`와 기본 프로필 이미지 URL 정책을 따른다.
|
||||||
|
- 커뮤니티 게시물 상세의 유료 콘텐츠 접근 정책은 커뮤니티 탭과 동일하게 `price <= 0 || viewerId == creatorId || existOrdered`를 기준으로 한다.
|
||||||
|
- 댓글/답글 조회는 `CreatorCommunityComment` entity를 조회하되, v2 domain record와 response DTO로 변환한다.
|
||||||
|
- 댓글/답글 count와 list 조건은 동일해야 한다.
|
||||||
|
- list 조회는 `size + 1`개를 조회하거나 동등한 방식으로 `hasNext`를 판단하고, 응답 목록에는 최대 `size`개만 포함한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Success Criteria
|
||||||
|
- `GET /api/v2/creator-channels/community-posts/{postId}`가 커뮤니티 탭 게시물과 동일한 게시물 필드, `isLiked`, 초기 댓글 20개를 반환한다.
|
||||||
|
- 상세 API의 `comments` 구조가 댓글 조회 API 응답 data class와 동일하다.
|
||||||
|
- `GET /api/v2/creator-channels/community-posts/{postId}/comments`가 조회 가능한 댓글 전체 개수, 댓글 page, 최신 답글 1개를 반환한다.
|
||||||
|
- `GET /api/v2/creator-channels/community-comments/{commentId}/replies`가 조회 가능한 답글 전체 개수와 답글 page를 반환한다.
|
||||||
|
- 댓글/답글 날짜는 UTC ISO-8601 문자열로 내려간다.
|
||||||
|
- 댓글/답글 작성자 차단/피차단, 비밀 댓글, 비활성 댓글, 19금 게시물 접근 정책이 테스트로 구분된다.
|
||||||
|
- `isCommentAvailable == false`인 게시물은 상세 초기 댓글과 댓글 목록 API 모두 빈 댓글 응답을 반환한다.
|
||||||
|
- 기존 커뮤니티 탭 API와 크리에이터 채널 홈 API의 커뮤니티 게시글 응답이 회귀 없이 통과한다.
|
||||||
|
- `v2.creator.channel.community` 도메인 패키지의 `v2.api.*` import 검색 결과가 0건이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Open Questions
|
||||||
|
- 없음. 구현 중 댓글 비밀 정책이나 endpoint 경로에 대한 추가 결정이 필요하면 구현 전에 이 PRD와 `plan-task.md`를 먼저 갱신한다.
|
||||||
@@ -33,6 +33,53 @@ class CreatorChannelCommunityController(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/community-posts/{postId}")
|
||||||
|
fun getCommunityPostDetail(
|
||||||
|
@PathVariable postId: Long,
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
|
) = run {
|
||||||
|
ApiResponse.ok(
|
||||||
|
creatorChannelCommunityFacade.getCommunityPostDetail(
|
||||||
|
postId = postId,
|
||||||
|
viewer = requireMember(member)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/community-posts/{postId}/comments")
|
||||||
|
fun getCommunityComments(
|
||||||
|
@PathVariable postId: Long,
|
||||||
|
@RequestParam(required = false) page: Int?,
|
||||||
|
@RequestParam(required = false) size: Int?,
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
|
) = run {
|
||||||
|
ApiResponse.ok(
|
||||||
|
creatorChannelCommunityFacade.getCommunityComments(
|
||||||
|
postId = postId,
|
||||||
|
viewer = requireMember(member),
|
||||||
|
page = page,
|
||||||
|
size = size
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/community-comments/{commentId}/replies")
|
||||||
|
fun getCommunityReplies(
|
||||||
|
@PathVariable commentId: Long,
|
||||||
|
@RequestParam(required = false) page: Int?,
|
||||||
|
@RequestParam(required = false) size: Int?,
|
||||||
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
|
) = run {
|
||||||
|
ApiResponse.ok(
|
||||||
|
creatorChannelCommunityFacade.getCommunityReplies(
|
||||||
|
commentId = commentId,
|
||||||
|
viewer = requireMember(member),
|
||||||
|
page = page,
|
||||||
|
size = size
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun requireMember(member: Member?): Member {
|
private fun requireMember(member: Member?): Member {
|
||||||
return member ?: throw SodaException(messageKey = "common.error.bad_credentials")
|
return member ?: throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.api.creator.channel.community.application
|
package kr.co.vividnext.sodalive.v2.api.creator.channel.community.application
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityCommentsResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityPostDetailResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityRepliesResponse
|
||||||
import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityTabResponse
|
import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityTabResponse
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryService
|
import kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryService
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
@@ -29,4 +32,54 @@ class CreatorChannelCommunityFacade(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getCommunityPostDetail(
|
||||||
|
postId: Long,
|
||||||
|
viewer: Member,
|
||||||
|
now: LocalDateTime = LocalDateTime.now()
|
||||||
|
): CreatorChannelCommunityPostDetailResponse {
|
||||||
|
return CreatorChannelCommunityPostDetailResponse.from(
|
||||||
|
creatorChannelCommunityQueryService.getCommunityPostDetail(
|
||||||
|
postId = postId,
|
||||||
|
viewer = viewer,
|
||||||
|
now = now
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCommunityComments(
|
||||||
|
postId: Long,
|
||||||
|
viewer: Member,
|
||||||
|
page: Int?,
|
||||||
|
size: Int?,
|
||||||
|
now: LocalDateTime = LocalDateTime.now()
|
||||||
|
): CreatorChannelCommunityCommentsResponse {
|
||||||
|
return CreatorChannelCommunityCommentsResponse.from(
|
||||||
|
creatorChannelCommunityQueryService.getCommunityComments(
|
||||||
|
postId = postId,
|
||||||
|
viewer = viewer,
|
||||||
|
page = page,
|
||||||
|
size = size,
|
||||||
|
now = now
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCommunityReplies(
|
||||||
|
commentId: Long,
|
||||||
|
viewer: Member,
|
||||||
|
page: Int?,
|
||||||
|
size: Int?,
|
||||||
|
now: LocalDateTime = LocalDateTime.now()
|
||||||
|
): CreatorChannelCommunityRepliesResponse {
|
||||||
|
return CreatorChannelCommunityRepliesResponse.from(
|
||||||
|
creatorChannelCommunityQueryService.getCommunityReplies(
|
||||||
|
commentId = commentId,
|
||||||
|
viewer = viewer,
|
||||||
|
page = page,
|
||||||
|
size = size,
|
||||||
|
now = now
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.QCreat
|
|||||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.QCreatorCommunityLike.creatorCommunityLike
|
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.QCreatorCommunityLike.creatorCommunityLike
|
||||||
import kr.co.vividnext.sodalive.member.QMember.member
|
import kr.co.vividnext.sodalive.member.QMember.member
|
||||||
import kr.co.vividnext.sodalive.member.block.QBlockMember
|
import kr.co.vividnext.sodalive.member.block.QBlockMember
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCommentContextRecord
|
||||||
|
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.CreatorChannelCommunityCreatorRecord
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityPostRecord
|
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityPostRecord
|
||||||
import org.springframework.stereotype.Repository
|
import org.springframework.stereotype.Repository
|
||||||
@@ -119,6 +121,122 @@ class DefaultCreatorChannelCommunityQueryRepository(
|
|||||||
return rows.toCommunityPostRecords(creatorId, viewerId)
|
return rows.toCommunityPostRecords(creatorId, viewerId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun findCommunityPost(
|
||||||
|
postId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
canViewAdultContent: Boolean
|
||||||
|
): CreatorChannelCommunityPostRecord? {
|
||||||
|
val rows = queryFactory
|
||||||
|
.selectCommunityPostRow()
|
||||||
|
.from(creatorCommunity)
|
||||||
|
.where(communityPostByIdCondition(postId, canViewAdultContent))
|
||||||
|
.fetch()
|
||||||
|
|
||||||
|
val creatorId = rows.firstOrNull()?.creatorId ?: return null
|
||||||
|
return rows.toCommunityPostRecords(creatorId, viewerId).firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun countCommunityComments(
|
||||||
|
postId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
creatorId: Long
|
||||||
|
): Int {
|
||||||
|
return queryFactory
|
||||||
|
.select(creatorCommunityComment.id.count())
|
||||||
|
.from(creatorCommunityComment)
|
||||||
|
.where(rootCommentCondition(postId, creatorId, viewerId))
|
||||||
|
.fetchOne()
|
||||||
|
?.toInt()
|
||||||
|
?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findCommunityComments(
|
||||||
|
postId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
creatorId: Long,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int
|
||||||
|
): List<CreatorChannelCommunityCommentRecord> {
|
||||||
|
return queryFactory
|
||||||
|
.selectCommunityCommentRow()
|
||||||
|
.from(creatorCommunityComment)
|
||||||
|
.where(rootCommentCondition(postId, creatorId, viewerId))
|
||||||
|
.orderBy(creatorCommunityComment.createdAt.desc(), creatorCommunityComment.id.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit.toLong())
|
||||||
|
.fetch()
|
||||||
|
.map { it.toCommunityCommentRecord() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findLatestRepliesByCommentIds(
|
||||||
|
commentIds: List<Long>,
|
||||||
|
viewerId: Long
|
||||||
|
): List<CreatorChannelCommunityCommentRecord> {
|
||||||
|
if (commentIds.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
return queryFactory
|
||||||
|
.selectCommunityCommentRow()
|
||||||
|
.from(creatorCommunityComment)
|
||||||
|
.where(replyCondition(commentIds, viewerId))
|
||||||
|
.orderBy(creatorCommunityComment.createdAt.desc(), creatorCommunityComment.id.desc())
|
||||||
|
.fetch()
|
||||||
|
.map { it.toCommunityCommentRecord() }
|
||||||
|
.groupBy { it.parentCommentId }
|
||||||
|
.values
|
||||||
|
.map { it.first() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findRootCommentContext(
|
||||||
|
commentId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
canViewAdultContent: Boolean
|
||||||
|
): CreatorChannelCommunityCommentContextRecord? {
|
||||||
|
val row = queryFactory
|
||||||
|
.select(
|
||||||
|
creatorCommunityComment.id,
|
||||||
|
creatorCommunityComment.creatorCommunity.id,
|
||||||
|
creatorCommunityComment.creatorCommunity.member.id,
|
||||||
|
creatorCommunityComment.creatorCommunity.isCommentAvailable
|
||||||
|
)
|
||||||
|
.from(creatorCommunityComment)
|
||||||
|
.where(rootCommentContextCondition(commentId, viewerId, canViewAdultContent))
|
||||||
|
.fetchFirst() ?: return null
|
||||||
|
|
||||||
|
return CreatorChannelCommunityCommentContextRecord(
|
||||||
|
commentId = row.get(creatorCommunityComment.id)!!,
|
||||||
|
postId = row.get(creatorCommunityComment.creatorCommunity.id)!!,
|
||||||
|
creatorId = row.get(creatorCommunityComment.creatorCommunity.member.id)!!,
|
||||||
|
isCommentAvailable = row.get(creatorCommunityComment.creatorCommunity.isCommentAvailable)!!
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun countCommunityReplies(commentId: Long, viewerId: Long): Int {
|
||||||
|
return queryFactory
|
||||||
|
.select(creatorCommunityComment.id.count())
|
||||||
|
.from(creatorCommunityComment)
|
||||||
|
.where(replyCondition(commentId, viewerId))
|
||||||
|
.fetchOne()
|
||||||
|
?.toInt()
|
||||||
|
?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findCommunityReplies(
|
||||||
|
commentId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int
|
||||||
|
): List<CreatorChannelCommunityCommentRecord> {
|
||||||
|
return queryFactory
|
||||||
|
.selectCommunityCommentRow()
|
||||||
|
.from(creatorCommunityComment)
|
||||||
|
.where(replyCondition(commentId, viewerId))
|
||||||
|
.orderBy(creatorCommunityComment.createdAt.desc(), creatorCommunityComment.id.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit.toLong())
|
||||||
|
.fetch()
|
||||||
|
.map { it.toCommunityCommentRecord() }
|
||||||
|
}
|
||||||
|
|
||||||
private fun JPAQueryFactory.selectCommunityPostRow() = select(
|
private fun JPAQueryFactory.selectCommunityPostRow() = select(
|
||||||
creatorCommunity.id,
|
creatorCommunity.id,
|
||||||
creatorCommunity.member.id,
|
creatorCommunity.member.id,
|
||||||
@@ -134,6 +252,17 @@ class DefaultCreatorChannelCommunityQueryRepository(
|
|||||||
creatorCommunity.isCommentAvailable
|
creatorCommunity.isCommentAvailable
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun JPAQueryFactory.selectCommunityCommentRow() = select(
|
||||||
|
creatorCommunityComment.id,
|
||||||
|
creatorCommunityComment.parent.id,
|
||||||
|
creatorCommunityComment.creatorCommunity.id,
|
||||||
|
creatorCommunityComment.member.id,
|
||||||
|
creatorCommunityComment.member.profileImage,
|
||||||
|
creatorCommunityComment.member.nickname,
|
||||||
|
creatorCommunityComment.comment,
|
||||||
|
creatorCommunityComment.createdAt
|
||||||
|
)
|
||||||
|
|
||||||
private fun communityPostCondition(creatorId: Long, canViewAdultContent: Boolean): BooleanExpression {
|
private fun communityPostCondition(creatorId: Long, canViewAdultContent: Boolean): BooleanExpression {
|
||||||
val condition = creatorCommunity.isActive.isTrue
|
val condition = creatorCommunity.isActive.isTrue
|
||||||
.and(creatorCommunity.member.id.eq(creatorId))
|
.and(creatorCommunity.member.id.eq(creatorId))
|
||||||
@@ -142,6 +271,62 @@ class DefaultCreatorChannelCommunityQueryRepository(
|
|||||||
return if (canViewAdultContent) condition else condition.and(creatorCommunity.isAdult.isFalse)
|
return if (canViewAdultContent) condition else condition.and(creatorCommunity.isAdult.isFalse)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun communityPostByIdCondition(postId: Long, canViewAdultContent: Boolean): BooleanExpression {
|
||||||
|
val condition = creatorCommunity.id.eq(postId)
|
||||||
|
.and(creatorCommunity.isActive.isTrue)
|
||||||
|
.and(creatorCommunity.member.isActive.isTrue)
|
||||||
|
|
||||||
|
return if (canViewAdultContent) condition else condition.and(creatorCommunity.isAdult.isFalse)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rootCommentCondition(
|
||||||
|
postId: Long,
|
||||||
|
creatorId: Long,
|
||||||
|
viewerId: Long
|
||||||
|
): BooleanExpression {
|
||||||
|
return creatorCommunityComment.creatorCommunity.id.eq(postId)
|
||||||
|
.and(creatorCommunityComment.isActive.isTrue)
|
||||||
|
.and(creatorCommunityComment.parent.isNull)
|
||||||
|
.and(visibleSecretCommentCondition(creatorId, viewerId))
|
||||||
|
.and(notBlockedCommentWriterCondition(viewerId))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rootCommentContextCondition(
|
||||||
|
commentId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
canViewAdultContent: Boolean
|
||||||
|
): BooleanExpression {
|
||||||
|
val condition = creatorCommunityComment.id.eq(commentId)
|
||||||
|
.and(creatorCommunityComment.isActive.isTrue)
|
||||||
|
.and(creatorCommunityComment.parent.isNull)
|
||||||
|
.and(creatorCommunityComment.creatorCommunity.isActive.isTrue)
|
||||||
|
.and(creatorCommunityComment.creatorCommunity.member.isActive.isTrue)
|
||||||
|
.and(
|
||||||
|
creatorCommunityComment.isSecret.isFalse
|
||||||
|
.or(creatorCommunityComment.creatorCommunity.member.id.eq(viewerId))
|
||||||
|
.or(creatorCommunityComment.member.id.eq(viewerId))
|
||||||
|
)
|
||||||
|
.and(notBlockedCommentWriterCondition(viewerId))
|
||||||
|
|
||||||
|
return if (canViewAdultContent) condition else condition.and(creatorCommunityComment.creatorCommunity.isAdult.isFalse)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun replyCondition(commentId: Long, viewerId: Long): BooleanExpression {
|
||||||
|
return creatorCommunityComment.isActive.isTrue
|
||||||
|
.and(creatorCommunityComment.parent.isNotNull)
|
||||||
|
.and(creatorCommunityComment.parent.id.eq(commentId))
|
||||||
|
.and(visibleSecretReplyCondition(viewerId))
|
||||||
|
.and(notBlockedCommentWriterCondition(viewerId))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun replyCondition(commentIds: List<Long>, viewerId: Long): BooleanExpression {
|
||||||
|
return creatorCommunityComment.isActive.isTrue
|
||||||
|
.and(creatorCommunityComment.parent.isNotNull)
|
||||||
|
.and(creatorCommunityComment.parent.id.`in`(commentIds))
|
||||||
|
.and(visibleSecretReplyCondition(viewerId))
|
||||||
|
.and(notBlockedCommentWriterCondition(viewerId))
|
||||||
|
}
|
||||||
|
|
||||||
private fun homeCommunityPostCondition(
|
private fun homeCommunityPostCondition(
|
||||||
creatorId: Long,
|
creatorId: Long,
|
||||||
viewerId: Long,
|
viewerId: Long,
|
||||||
@@ -302,6 +487,12 @@ class DefaultCreatorChannelCommunityQueryRepository(
|
|||||||
.or(creatorCommunityComment.member.id.eq(viewerId))
|
.or(creatorCommunityComment.member.id.eq(viewerId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun visibleSecretReplyCondition(viewerId: Long): BooleanExpression {
|
||||||
|
return creatorCommunityComment.isSecret.isFalse
|
||||||
|
.or(creatorCommunityComment.creatorCommunity.member.id.eq(viewerId))
|
||||||
|
.or(creatorCommunityComment.member.id.eq(viewerId))
|
||||||
|
}
|
||||||
|
|
||||||
private fun notBlockedCommentWriterCondition(viewerId: Long): BooleanExpression {
|
private fun notBlockedCommentWriterCondition(viewerId: Long): BooleanExpression {
|
||||||
val viewerBlock = QBlockMember("communityCommentViewerBlockWriter")
|
val viewerBlock = QBlockMember("communityCommentViewerBlockWriter")
|
||||||
val writerBlock = QBlockMember("communityCommentWriterBlockViewer")
|
val writerBlock = QBlockMember("communityCommentWriterBlockViewer")
|
||||||
@@ -355,4 +546,17 @@ class DefaultCreatorChannelCommunityQueryRepository(
|
|||||||
|
|
||||||
private val Tuple.isCommentAvailable: Boolean
|
private val Tuple.isCommentAvailable: Boolean
|
||||||
get() = get(creatorCommunity.isCommentAvailable)!!
|
get() = get(creatorCommunity.isCommentAvailable)!!
|
||||||
|
|
||||||
|
private fun Tuple.toCommunityCommentRecord(): CreatorChannelCommunityCommentRecord {
|
||||||
|
return CreatorChannelCommunityCommentRecord(
|
||||||
|
commentId = get(creatorCommunityComment.id)!!,
|
||||||
|
parentCommentId = get(creatorCommunityComment.parent.id),
|
||||||
|
postId = get(creatorCommunityComment.creatorCommunity.id)!!,
|
||||||
|
writerId = get(creatorCommunityComment.member.id)!!,
|
||||||
|
writerProfilePath = get(creatorCommunityComment.member.profileImage),
|
||||||
|
writerNickname = get(creatorCommunityComment.member.nickname)!!,
|
||||||
|
content = get(creatorCommunityComment.comment)!!,
|
||||||
|
createdAt = get(creatorCommunityComment.createdAt)!!
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.aws.cloudfront.AudioContentCloudFront
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
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.LangContext
|
||||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||||
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
|
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.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.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.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.CreatorChannelCommunityCreatorRecord
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityPostRecord
|
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityPostRecord
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityQueryPort
|
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityQueryPort
|
||||||
@@ -93,6 +100,115 @@ class CreatorChannelCommunityQueryService(
|
|||||||
.map { it.toDomain(viewerId) }
|
.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) {
|
private fun validateCreatorRole(creator: CreatorChannelCommunityCreatorRecord) {
|
||||||
when (creator.role) {
|
when (creator.role) {
|
||||||
MemberRole.CREATOR -> return
|
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 {
|
private fun CreatorChannelCommunityPostRecord.toDomain(viewerId: Long): CreatorChannelCommunityPost {
|
||||||
val canAccessPaidContent = price <= 0 || viewerId == creatorId || existOrdered
|
val canAccessPaidContent = price <= 0 || viewerId == creatorId || existOrdered
|
||||||
return CreatorChannelCommunityPost(
|
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? {
|
private fun String?.toSignedAudioUrl(): String? {
|
||||||
if (isNullOrBlank()) return null
|
if (isNullOrBlank()) return null
|
||||||
return audioContentCloudFront.generateSignedURL(this, AUDIO_SIGNED_URL_EXPIRATION_MILLIS)
|
return audioContentCloudFront.generateSignedURL(this, AUDIO_SIGNED_URL_EXPIRATION_MILLIS)
|
||||||
@@ -135,5 +280,6 @@ class CreatorChannelCommunityQueryService(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val AUDIO_SIGNED_URL_EXPIRATION_MILLIS = 1000L * 60 * 30
|
private const val AUDIO_SIGNED_URL_EXPIRATION_MILLIS = 1000L * 60 * 30
|
||||||
|
private const val INVALID_COMMUNITY_REQUEST_MESSAGE_KEY = "creator.community.invalid_request_retry"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -29,6 +29,49 @@ interface CreatorChannelCommunityQueryPort {
|
|||||||
canViewAdultContent: Boolean,
|
canViewAdultContent: Boolean,
|
||||||
limit: Int
|
limit: Int
|
||||||
): List<CreatorChannelCommunityPostRecord>
|
): List<CreatorChannelCommunityPostRecord>
|
||||||
|
|
||||||
|
fun findCommunityPost(
|
||||||
|
postId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
canViewAdultContent: Boolean
|
||||||
|
): CreatorChannelCommunityPostRecord?
|
||||||
|
|
||||||
|
fun countCommunityComments(
|
||||||
|
postId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
creatorId: Long
|
||||||
|
): Int
|
||||||
|
|
||||||
|
fun findCommunityComments(
|
||||||
|
postId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
creatorId: Long,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int
|
||||||
|
): List<CreatorChannelCommunityCommentRecord>
|
||||||
|
|
||||||
|
fun findLatestRepliesByCommentIds(
|
||||||
|
commentIds: List<Long>,
|
||||||
|
viewerId: Long
|
||||||
|
): List<CreatorChannelCommunityCommentRecord>
|
||||||
|
|
||||||
|
fun findRootCommentContext(
|
||||||
|
commentId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
canViewAdultContent: Boolean
|
||||||
|
): CreatorChannelCommunityCommentContextRecord?
|
||||||
|
|
||||||
|
fun countCommunityReplies(
|
||||||
|
commentId: Long,
|
||||||
|
viewerId: Long
|
||||||
|
): Int
|
||||||
|
|
||||||
|
fun findCommunityReplies(
|
||||||
|
commentId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int
|
||||||
|
): List<CreatorChannelCommunityCommentRecord>
|
||||||
}
|
}
|
||||||
|
|
||||||
data class CreatorChannelCommunityCreatorRecord(
|
data class CreatorChannelCommunityCreatorRecord(
|
||||||
@@ -54,3 +97,21 @@ data class CreatorChannelCommunityPostRecord(
|
|||||||
val isPinned: Boolean,
|
val isPinned: Boolean,
|
||||||
val isLiked: Boolean
|
val isLiked: Boolean
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelCommunityCommentRecord(
|
||||||
|
val commentId: Long,
|
||||||
|
val parentCommentId: Long?,
|
||||||
|
val postId: Long,
|
||||||
|
val writerId: Long,
|
||||||
|
val writerProfilePath: String?,
|
||||||
|
val writerNickname: String,
|
||||||
|
val content: String,
|
||||||
|
val createdAt: LocalDateTime
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreatorChannelCommunityCommentContextRecord(
|
||||||
|
val commentId: Long,
|
||||||
|
val postId: Long,
|
||||||
|
val creatorId: Long,
|
||||||
|
val isCommentAvailable: Boolean
|
||||||
|
)
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ import kr.co.vividnext.sodalive.member.Member
|
|||||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
import kr.co.vividnext.sodalive.v2.api.creator.channel.community.application.CreatorChannelCommunityFacade
|
import kr.co.vividnext.sodalive.v2.api.creator.channel.community.application.CreatorChannelCommunityFacade
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityCommentResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityCommentsResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityPostDetailResponse
|
||||||
import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityPostResponse
|
import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityPostResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityRepliesResponse
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityReplyResponse
|
||||||
import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityTabResponse
|
import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityTabResponse
|
||||||
import org.junit.jupiter.api.DisplayName
|
import org.junit.jupiter.api.DisplayName
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
@@ -147,6 +152,147 @@ class CreatorChannelCommunityControllerTest @Autowired constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("커뮤니티 상세 조회는 비회원 요청을 거부한다")
|
||||||
|
fun shouldRejectAnonymousCommunityPostDetailRequest() {
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/community-posts/101")
|
||||||
|
.with(anonymous())
|
||||||
|
)
|
||||||
|
.andExpect(status().isUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("커뮤니티 상세 조회는 인증 회원에게 상세와 초기 댓글을 반환한다")
|
||||||
|
fun shouldReturnCommunityPostDetailForAuthenticatedMember() {
|
||||||
|
val viewer = createMember(id = 10L)
|
||||||
|
Mockito.doReturn(createDetailResponse()).`when`(facade).getCommunityPostDetail(
|
||||||
|
eqValue(101L),
|
||||||
|
eqValue(viewer),
|
||||||
|
anyValue(LocalDateTime.now())
|
||||||
|
)
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/community-posts/101")
|
||||||
|
.with(user(MemberAdapter(viewer)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.data.postId").value(101))
|
||||||
|
.andExpect(jsonPath("$.data.isLiked").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.comments.commentCount").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.comments.comments[0].latestReply.commentId").value(202))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("커뮤니티 댓글 조회는 인증 회원에게 댓글 페이지를 반환한다")
|
||||||
|
fun shouldReturnCommunityCommentsForAuthenticatedMember() {
|
||||||
|
val viewer = createMember(id = 10L)
|
||||||
|
Mockito.doReturn(createCommentsResponse()).`when`(facade).getCommunityComments(
|
||||||
|
eqValue(101L),
|
||||||
|
eqValue(viewer),
|
||||||
|
eqValue(0),
|
||||||
|
eqValue(20),
|
||||||
|
anyValue(LocalDateTime.now())
|
||||||
|
)
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/community-posts/101/comments")
|
||||||
|
.param("page", "0")
|
||||||
|
.param("size", "20")
|
||||||
|
.with(user(MemberAdapter(viewer)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.data.commentCount").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.comments[0].latestReply.commentId").value(202))
|
||||||
|
.andExpect(jsonPath("$.data.hasNext").value(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("커뮤니티 댓글 조회는 raw page와 size를 facade에 전달한다")
|
||||||
|
fun shouldPassRawCommentPageAndSizeToFacade() {
|
||||||
|
val viewer = createMember(id = 10L)
|
||||||
|
Mockito.doReturn(createCommentsResponse(page = 0, size = 50)).`when`(facade).getCommunityComments(
|
||||||
|
eqValue(101L),
|
||||||
|
eqValue(viewer),
|
||||||
|
eqValue(-1),
|
||||||
|
eqValue(100),
|
||||||
|
anyValue(LocalDateTime.now())
|
||||||
|
)
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/community-posts/101/comments")
|
||||||
|
.param("page", "-1")
|
||||||
|
.param("size", "100")
|
||||||
|
.with(user(MemberAdapter(viewer)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.data.page").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.size").value(50))
|
||||||
|
|
||||||
|
Mockito.verify(facade).getCommunityComments(
|
||||||
|
eqValue(101L),
|
||||||
|
eqValue(viewer),
|
||||||
|
eqValue(-1),
|
||||||
|
eqValue(100),
|
||||||
|
anyValue(LocalDateTime.now())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("커뮤니티 답글 조회는 인증 회원에게 답글 페이지를 반환한다")
|
||||||
|
fun shouldReturnCommunityRepliesForAuthenticatedMember() {
|
||||||
|
val viewer = createMember(id = 10L)
|
||||||
|
Mockito.doReturn(createRepliesResponse()).`when`(facade).getCommunityReplies(
|
||||||
|
eqValue(201L),
|
||||||
|
eqValue(viewer),
|
||||||
|
eqValue(0),
|
||||||
|
eqValue(20),
|
||||||
|
anyValue(LocalDateTime.now())
|
||||||
|
)
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/community-comments/201/replies")
|
||||||
|
.param("page", "0")
|
||||||
|
.param("size", "20")
|
||||||
|
.with(user(MemberAdapter(viewer)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.data.replyCount").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.replies[0].commentId").value(202))
|
||||||
|
.andExpect(jsonPath("$.data.replies[0].latestReply").doesNotExist())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("커뮤니티 답글 조회는 raw page와 size를 facade에 전달한다")
|
||||||
|
fun shouldPassRawReplyPageAndSizeToFacade() {
|
||||||
|
val viewer = createMember(id = 10L)
|
||||||
|
Mockito.doReturn(createRepliesResponse(page = 0, size = 50)).`when`(facade).getCommunityReplies(
|
||||||
|
eqValue(201L),
|
||||||
|
eqValue(viewer),
|
||||||
|
eqValue(-1),
|
||||||
|
eqValue(100),
|
||||||
|
anyValue(LocalDateTime.now())
|
||||||
|
)
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/community-comments/201/replies")
|
||||||
|
.param("page", "-1")
|
||||||
|
.param("size", "100")
|
||||||
|
.with(user(MemberAdapter(viewer)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.data.page").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.size").value(50))
|
||||||
|
|
||||||
|
Mockito.verify(facade).getCommunityReplies(
|
||||||
|
eqValue(201L),
|
||||||
|
eqValue(viewer),
|
||||||
|
eqValue(-1),
|
||||||
|
eqValue(100),
|
||||||
|
anyValue(LocalDateTime.now())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun <T> eqValue(value: T): T {
|
private fun <T> eqValue(value: T): T {
|
||||||
return Mockito.eq(value) ?: value
|
return Mockito.eq(value) ?: value
|
||||||
}
|
}
|
||||||
@@ -194,4 +340,68 @@ class CreatorChannelCommunityControllerTest @Autowired constructor(
|
|||||||
hasNext = false
|
hasNext = false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun createDetailResponse(): CreatorChannelCommunityPostDetailResponse {
|
||||||
|
return CreatorChannelCommunityPostDetailResponse(
|
||||||
|
postId = 101L,
|
||||||
|
creatorId = 1L,
|
||||||
|
creatorNickname = "creator",
|
||||||
|
creatorProfileUrl = "https://cdn.test/profile.png",
|
||||||
|
createdAtUtc = "2026-06-21T03:30:00Z",
|
||||||
|
content = "content",
|
||||||
|
imageUrl = null,
|
||||||
|
audioUrl = null,
|
||||||
|
price = 100,
|
||||||
|
isCommentAvailable = true,
|
||||||
|
existOrdered = true,
|
||||||
|
likeCount = 7,
|
||||||
|
commentCount = 1,
|
||||||
|
isPinned = true,
|
||||||
|
isLiked = true,
|
||||||
|
comments = createCommentsResponse()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createCommentsResponse(page: Int = 0, size: Int = 20): CreatorChannelCommunityCommentsResponse {
|
||||||
|
return CreatorChannelCommunityCommentsResponse(
|
||||||
|
commentCount = 1,
|
||||||
|
comments = listOf(
|
||||||
|
CreatorChannelCommunityCommentResponse(
|
||||||
|
commentId = 201L,
|
||||||
|
writerProfileImageUrl = "https://cdn.test/writer.png",
|
||||||
|
writerNickname = "writer",
|
||||||
|
content = "comment",
|
||||||
|
createdAtUtc = "2026-07-06T03:00:00Z",
|
||||||
|
latestReply = CreatorChannelCommunityReplyResponse(
|
||||||
|
commentId = 202L,
|
||||||
|
writerProfileImageUrl = "https://cdn.test/replier.png",
|
||||||
|
writerNickname = "replier",
|
||||||
|
content = "reply",
|
||||||
|
createdAtUtc = "2026-07-06T03:01:00Z"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
page = page,
|
||||||
|
size = size,
|
||||||
|
hasNext = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createRepliesResponse(page: Int = 0, size: Int = 20): CreatorChannelCommunityRepliesResponse {
|
||||||
|
return CreatorChannelCommunityRepliesResponse(
|
||||||
|
replyCount = 1,
|
||||||
|
replies = listOf(
|
||||||
|
CreatorChannelCommunityReplyResponse(
|
||||||
|
commentId = 202L,
|
||||||
|
writerProfileImageUrl = "https://cdn.test/replier.png",
|
||||||
|
writerNickname = "replier",
|
||||||
|
content = "reply",
|
||||||
|
createdAtUtc = "2026-07-06T03:01:00Z"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
page = page,
|
||||||
|
size = size,
|
||||||
|
hasNext = false
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import kr.co.vividnext.sodalive.can.use.CanUsage
|
|||||||
import kr.co.vividnext.sodalive.can.use.UseCan
|
import kr.co.vividnext.sodalive.can.use.UseCan
|
||||||
import kr.co.vividnext.sodalive.content.ContentType
|
import kr.co.vividnext.sodalive.content.ContentType
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
|
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.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.member.block.BlockMember
|
||||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreference
|
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreference
|
||||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||||
import org.junit.jupiter.api.DisplayName
|
import org.junit.jupiter.api.DisplayName
|
||||||
@@ -94,6 +97,145 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor(
|
|||||||
Mockito.verifyNoMoreInteractions(audioContentCloudFront)
|
Mockito.verifyNoMoreInteractions(audioContentCloudFront)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("커뮤니티 상세 API는 초기 댓글 20개와 최신 답글을 반환한다")
|
||||||
|
fun shouldReturnCommunityPostDetailWithInitialComments() {
|
||||||
|
val fixture = createDetailFixture()
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/community-posts/${fixture.postId}")
|
||||||
|
.with(user(MemberAdapter(fixture.viewer)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.data.postId").value(fixture.postId))
|
||||||
|
.andExpect(jsonPath("$.data.isLiked").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.comments.comments.length()").value(20))
|
||||||
|
.andExpect(jsonPath("$.data.comments.hasNext").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.comments.comments[0].latestReply.commentId").value(fixture.latestReplyId))
|
||||||
|
.andExpect(jsonPath("$.data.comments.comments[0].createdAtUtc").value(org.hamcrest.Matchers.endsWith("Z")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("커뮤니티 댓글 API는 댓글 페이지와 최신 답글을 반환한다")
|
||||||
|
fun shouldReturnPagedCommunityCommentsWithLatestReply() {
|
||||||
|
val fixture = createDetailFixture()
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/community-posts/${fixture.postId}/comments")
|
||||||
|
.param("page", "0")
|
||||||
|
.param("size", "20")
|
||||||
|
.with(user(MemberAdapter(fixture.viewer)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.data.commentCount").value(21))
|
||||||
|
.andExpect(jsonPath("$.data.comments.length()").value(20))
|
||||||
|
.andExpect(jsonPath("$.data.hasNext").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.comments[0].latestReply.commentId").value(fixture.latestReplyId))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("커뮤니티 답글 API는 latestReply 없이 답글 페이지를 반환한다")
|
||||||
|
fun shouldReturnPagedCommunityRepliesWithoutLatestReply() {
|
||||||
|
val fixture = createDetailFixture()
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/community-comments/${fixture.firstCommentId}/replies")
|
||||||
|
.param("page", "0")
|
||||||
|
.param("size", "20")
|
||||||
|
.with(user(MemberAdapter(fixture.viewer)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.data.replyCount").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.replies[0].commentId").value(fixture.latestReplyId))
|
||||||
|
.andExpect(jsonPath("$.data.replies[0].latestReply").doesNotExist())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("댓글 불가 게시물은 빈 댓글 응답을 반환한다")
|
||||||
|
fun shouldReturnEmptyCommentsWhenCommentUnavailable() {
|
||||||
|
val fixture = createCommentUnavailableFixture()
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/community-posts/${fixture.postId}/comments")
|
||||||
|
.with(user(MemberAdapter(fixture.viewer)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.data.commentCount").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.comments.length()").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.hasNext").value(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("성인 콘텐츠를 볼 수 없는 회원은 19금 커뮤니티 상세를 조회할 수 없다")
|
||||||
|
fun shouldRejectAdultCommunityPostWhenViewerCannotViewAdultContent() {
|
||||||
|
val fixture = createAdultBlockedFixture()
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/community-posts/${fixture.postId}")
|
||||||
|
.with(user(MemberAdapter(fixture.viewer)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("차단 관계가 있으면 커뮤니티 상세를 조회할 수 없다")
|
||||||
|
fun shouldRejectCommunityPostWhenViewerAndCreatorAreBlocked() {
|
||||||
|
val fixture = createBlockedFixture()
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/community-posts/${fixture.postId}")
|
||||||
|
.with(user(MemberAdapter(fixture.viewer)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("커뮤니티 댓글 API는 비밀 댓글과 차단 작성자를 필터링한다")
|
||||||
|
fun shouldFilterSecretAndBlockedComments() {
|
||||||
|
val fixture = createAccessPolicyFixture()
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/community-posts/${fixture.postId}/comments")
|
||||||
|
.with(user(MemberAdapter(fixture.viewer)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.data.commentCount").value(2))
|
||||||
|
.andExpect(jsonPath("$.data.comments.length()").value(2))
|
||||||
|
.andExpect(jsonPath("$.data.comments[0].commentId").value(fixture.ownSecretCommentId))
|
||||||
|
.andExpect(jsonPath("$.data.comments[1].commentId").value(fixture.publicCommentId))
|
||||||
|
.andExpect(jsonPath("$.data.comments[1].latestReply.commentId").value(fixture.publicReplyId))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("보이지 않는 부모 댓글의 답글 조회는 오류를 반환한다")
|
||||||
|
fun shouldRejectRepliesWhenParentCommentIsNotVisible() {
|
||||||
|
val fixture = createAccessPolicyFixture()
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/community-comments/${fixture.hiddenSecretCommentId}/replies")
|
||||||
|
.with(user(MemberAdapter(fixture.viewer)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("댓글 불가 게시물의 답글 직접 조회는 빈 응답을 반환한다")
|
||||||
|
fun shouldReturnEmptyRepliesWhenCommentUnavailable() {
|
||||||
|
val fixture = createCommentUnavailableFixtureWithReply()
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/creator-channels/community-comments/${fixture.commentId}/replies")
|
||||||
|
.with(user(MemberAdapter(fixture.viewer)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.data.replyCount").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.replies.length()").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.hasNext").value(false))
|
||||||
|
}
|
||||||
|
|
||||||
private fun createFixture(): Fixture {
|
private fun createFixture(): Fixture {
|
||||||
return transactionTemplate.execute {
|
return transactionTemplate.execute {
|
||||||
val now = LocalDateTime.of(2026, 6, 21, 12, 0)
|
val now = LocalDateTime.of(2026, 6, 21, 12, 0)
|
||||||
@@ -192,12 +334,13 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor(
|
|||||||
content: String,
|
content: String,
|
||||||
imagePath: String? = null,
|
imagePath: String? = null,
|
||||||
audioPath: String? = null,
|
audioPath: String? = null,
|
||||||
isAdult: Boolean = false
|
isAdult: Boolean = false,
|
||||||
|
isCommentAvailable: Boolean = true
|
||||||
): CreatorCommunity {
|
): CreatorCommunity {
|
||||||
val community = CreatorCommunity(
|
val community = CreatorCommunity(
|
||||||
content = content,
|
content = content,
|
||||||
price = price,
|
price = price,
|
||||||
isCommentAvailable = true,
|
isCommentAvailable = isCommentAvailable,
|
||||||
isAdult = isAdult,
|
isAdult = isAdult,
|
||||||
audioPath = audioPath,
|
audioPath = audioPath,
|
||||||
imagePath = imagePath,
|
imagePath = imagePath,
|
||||||
@@ -210,6 +353,37 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor(
|
|||||||
return community
|
return community
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun saveCommunityLike(member: Member, community: CreatorCommunity): CreatorCommunityLike {
|
||||||
|
val like = CreatorCommunityLike(isActive = true)
|
||||||
|
like.member = member
|
||||||
|
like.creatorCommunity = community
|
||||||
|
entityManager.persist(like)
|
||||||
|
return like
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveCommunityComment(
|
||||||
|
member: Member,
|
||||||
|
community: CreatorCommunity,
|
||||||
|
parent: CreatorCommunityComment? = null,
|
||||||
|
isSecret: Boolean = false,
|
||||||
|
isActive: Boolean = true
|
||||||
|
): 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 saveBlock(member: Member, blockedMember: Member): BlockMember {
|
||||||
|
val block = BlockMember(isActive = true)
|
||||||
|
block.member = member
|
||||||
|
block.blockedMember = blockedMember
|
||||||
|
entityManager.persist(block)
|
||||||
|
return block
|
||||||
|
}
|
||||||
|
|
||||||
private fun saveCommunityOrder(member: Member, community: CreatorCommunity): UseCan {
|
private fun saveCommunityOrder(member: Member, community: CreatorCommunity): UseCan {
|
||||||
val useCan = UseCan(CanUsage.PAID_COMMUNITY_POST, community.price, rewardCan = 0, isRefund = false)
|
val useCan = UseCan(CanUsage.PAID_COMMUNITY_POST, community.price, rewardCan = 0, isRefund = false)
|
||||||
useCan.member = member
|
useCan.member = member
|
||||||
@@ -225,6 +399,147 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor(
|
|||||||
.executeUpdate()
|
.executeUpdate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun updateCommentCreatedAt(id: Long, createdAt: LocalDateTime) {
|
||||||
|
entityManager.createQuery("update CreatorCommunityComment e set e.createdAt = :createdAt where e.id = :id")
|
||||||
|
.setParameter("createdAt", createdAt)
|
||||||
|
.setParameter("id", id)
|
||||||
|
.executeUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createDetailFixture(): DetailFixture {
|
||||||
|
return transactionTemplate.execute {
|
||||||
|
val now = LocalDateTime.of(2026, 7, 6, 12, 0)
|
||||||
|
val viewer = saveMember("community-detail-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember("community-detail-creator", MemberRole.CREATOR)
|
||||||
|
val replier = saveMember("community-detail-replier", MemberRole.USER)
|
||||||
|
savePreference(viewer, isAdultContentVisible = true)
|
||||||
|
val post = saveCommunity(creator, isFixed = false, price = 0, content = "detail community")
|
||||||
|
saveCommunityLike(viewer, post)
|
||||||
|
val comments = (1..21).map { saveCommunityComment(viewer, post) }
|
||||||
|
val latestReply = saveCommunityComment(replier, post, parent = comments.last())
|
||||||
|
entityManager.flush()
|
||||||
|
comments.forEachIndexed { index, comment ->
|
||||||
|
updateCommentCreatedAt(comment.id!!, now.plusMinutes(index.toLong()))
|
||||||
|
}
|
||||||
|
updateCommentCreatedAt(latestReply.id!!, now.plusMinutes(30))
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
DetailFixture(
|
||||||
|
viewer = viewer,
|
||||||
|
postId = post.id!!,
|
||||||
|
firstCommentId = comments.last().id!!,
|
||||||
|
latestReplyId = latestReply.id!!
|
||||||
|
)
|
||||||
|
}!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createCommentUnavailableFixture(): PostFixture {
|
||||||
|
return transactionTemplate.execute {
|
||||||
|
val viewer = saveMember("community-unavailable-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember("community-unavailable-creator", MemberRole.CREATOR)
|
||||||
|
savePreference(viewer, isAdultContentVisible = true)
|
||||||
|
val post = saveCommunity(
|
||||||
|
creator = creator,
|
||||||
|
isFixed = false,
|
||||||
|
price = 0,
|
||||||
|
content = "comment unavailable",
|
||||||
|
isCommentAvailable = false
|
||||||
|
)
|
||||||
|
saveCommunityComment(viewer, post)
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
PostFixture(viewer = viewer, postId = post.id!!)
|
||||||
|
}!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createAdultBlockedFixture(): PostFixture {
|
||||||
|
return transactionTemplate.execute {
|
||||||
|
val viewer = saveMember("community-adult-blocked-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember("community-adult-blocked-creator", MemberRole.CREATOR)
|
||||||
|
savePreference(viewer, isAdultContentVisible = false)
|
||||||
|
val post = saveCommunity(
|
||||||
|
creator = creator,
|
||||||
|
isFixed = false,
|
||||||
|
price = 0,
|
||||||
|
content = "adult blocked",
|
||||||
|
isAdult = true
|
||||||
|
)
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
PostFixture(viewer = viewer, postId = post.id!!)
|
||||||
|
}!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createBlockedFixture(): PostFixture {
|
||||||
|
return transactionTemplate.execute {
|
||||||
|
val viewer = saveMember("community-blocked-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember("community-blocked-creator", MemberRole.CREATOR)
|
||||||
|
savePreference(viewer, isAdultContentVisible = true)
|
||||||
|
saveBlock(viewer, creator)
|
||||||
|
val post = saveCommunity(creator, isFixed = false, price = 0, content = "blocked")
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
PostFixture(viewer = viewer, postId = post.id!!)
|
||||||
|
}!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createAccessPolicyFixture(): AccessPolicyFixture {
|
||||||
|
return transactionTemplate.execute {
|
||||||
|
val now = LocalDateTime.of(2026, 7, 6, 12, 0)
|
||||||
|
val viewer = saveMember("community-policy-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember("community-policy-creator", MemberRole.CREATOR)
|
||||||
|
val publicWriter = saveMember("community-policy-public-writer", MemberRole.USER)
|
||||||
|
val secretWriter = saveMember("community-policy-secret-writer", MemberRole.USER)
|
||||||
|
val blockedWriter = saveMember("community-policy-blocked-writer", MemberRole.USER)
|
||||||
|
val replyWriter = saveMember("community-policy-reply-writer", MemberRole.USER)
|
||||||
|
savePreference(viewer, isAdultContentVisible = true)
|
||||||
|
val post = saveCommunity(creator, isFixed = false, price = 0, content = "policy")
|
||||||
|
val publicComment = saveCommunityComment(publicWriter, post)
|
||||||
|
val ownSecretComment = saveCommunityComment(viewer, post, isSecret = true)
|
||||||
|
val hiddenSecretComment = saveCommunityComment(secretWriter, post, isSecret = true)
|
||||||
|
saveCommunityComment(blockedWriter, post)
|
||||||
|
val publicReply = saveCommunityComment(replyWriter, post, parent = publicComment)
|
||||||
|
val hiddenSecretReply = saveCommunityComment(secretWriter, post, parent = publicComment, isSecret = true)
|
||||||
|
saveBlock(viewer, blockedWriter)
|
||||||
|
entityManager.flush()
|
||||||
|
updateCommentCreatedAt(publicComment.id!!, now.minusMinutes(3))
|
||||||
|
updateCommentCreatedAt(ownSecretComment.id!!, now.minusMinutes(1))
|
||||||
|
updateCommentCreatedAt(hiddenSecretComment.id!!, now)
|
||||||
|
updateCommentCreatedAt(publicReply.id!!, now.plusMinutes(1))
|
||||||
|
updateCommentCreatedAt(hiddenSecretReply.id!!, now.plusMinutes(2))
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
AccessPolicyFixture(
|
||||||
|
viewer = viewer,
|
||||||
|
postId = post.id!!,
|
||||||
|
publicCommentId = publicComment.id!!,
|
||||||
|
ownSecretCommentId = ownSecretComment.id!!,
|
||||||
|
hiddenSecretCommentId = hiddenSecretComment.id!!,
|
||||||
|
publicReplyId = publicReply.id!!
|
||||||
|
)
|
||||||
|
}!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createCommentUnavailableFixtureWithReply(): ReplyFixture {
|
||||||
|
return transactionTemplate.execute {
|
||||||
|
val viewer = saveMember("community-unavailable-reply-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember("community-unavailable-reply-creator", MemberRole.CREATOR)
|
||||||
|
savePreference(viewer, isAdultContentVisible = true)
|
||||||
|
val post = saveCommunity(
|
||||||
|
creator = creator,
|
||||||
|
isFixed = false,
|
||||||
|
price = 0,
|
||||||
|
content = "comment unavailable reply",
|
||||||
|
isCommentAvailable = false
|
||||||
|
)
|
||||||
|
val comment = saveCommunityComment(viewer, post)
|
||||||
|
saveCommunityComment(viewer, post, parent = comment)
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
ReplyFixture(viewer = viewer, commentId = comment.id!!)
|
||||||
|
}!!
|
||||||
|
}
|
||||||
|
|
||||||
private data class Fixture(
|
private data class Fixture(
|
||||||
val viewer: Member,
|
val viewer: Member,
|
||||||
val creatorId: Long,
|
val creatorId: Long,
|
||||||
@@ -234,4 +549,30 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor(
|
|||||||
val noImagePostId: Long,
|
val noImagePostId: Long,
|
||||||
val adultPurchasedPostId: Long
|
val adultPurchasedPostId: Long
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private data class DetailFixture(
|
||||||
|
val viewer: Member,
|
||||||
|
val postId: Long,
|
||||||
|
val firstCommentId: Long,
|
||||||
|
val latestReplyId: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class PostFixture(
|
||||||
|
val viewer: Member,
|
||||||
|
val postId: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class AccessPolicyFixture(
|
||||||
|
val viewer: Member,
|
||||||
|
val postId: Long,
|
||||||
|
val publicCommentId: Long,
|
||||||
|
val ownSecretCommentId: Long,
|
||||||
|
val hiddenSecretCommentId: Long,
|
||||||
|
val publicReplyId: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class ReplyFixture(
|
||||||
|
val viewer: Member,
|
||||||
|
val commentId: Long
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ import kr.co.vividnext.sodalive.member.Member
|
|||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityTabResponse
|
import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityTabResponse
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryService
|
import kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryService
|
||||||
|
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.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.community.domain.CreatorChannelCommunityTab
|
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityTab
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
|
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
@@ -87,6 +92,56 @@ class CreatorChannelCommunityFacadeTest {
|
|||||||
assertTrue(response.hasNext)
|
assertTrue(response.hasNext)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("커뮤니티 상세 facade는 service 결과를 상세 응답 DTO로 변환한다")
|
||||||
|
fun shouldReturnCommunityPostDetailResponse() {
|
||||||
|
val service = Mockito.mock(CreatorChannelCommunityQueryService::class.java)
|
||||||
|
val facade = CreatorChannelCommunityFacade(service)
|
||||||
|
val viewer = createMember(id = 10L)
|
||||||
|
val now = LocalDateTime.of(2026, 7, 6, 12, 0)
|
||||||
|
Mockito.doReturn(createDetail()).`when`(service).getCommunityPostDetail(101L, viewer, now)
|
||||||
|
|
||||||
|
val response = facade.getCommunityPostDetail(101L, viewer, now)
|
||||||
|
|
||||||
|
assertEquals(101L, response.postId)
|
||||||
|
assertTrue(response.isLiked)
|
||||||
|
assertEquals(1, response.comments.commentCount)
|
||||||
|
assertEquals(201L, response.comments.comments.first().latestReply?.commentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("커뮤니티 댓글 facade는 raw page와 size를 service로 전달하고 응답 DTO로 변환한다")
|
||||||
|
fun shouldReturnCommunityCommentsResponse() {
|
||||||
|
val service = Mockito.mock(CreatorChannelCommunityQueryService::class.java)
|
||||||
|
val facade = CreatorChannelCommunityFacade(service)
|
||||||
|
val viewer = createMember(id = 10L)
|
||||||
|
val now = LocalDateTime.of(2026, 7, 6, 12, 0)
|
||||||
|
Mockito.doReturn(createComments()).`when`(service).getCommunityComments(101L, viewer, -1, 100, now)
|
||||||
|
|
||||||
|
val response = facade.getCommunityComments(101L, viewer, -1, 100, now)
|
||||||
|
|
||||||
|
assertEquals(1, response.commentCount)
|
||||||
|
assertEquals(0, response.page)
|
||||||
|
assertEquals(20, response.size)
|
||||||
|
assertEquals(201L, response.comments.first().latestReply?.commentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("커뮤니티 답글 facade는 raw page와 size를 service로 전달하고 응답 DTO로 변환한다")
|
||||||
|
fun shouldReturnCommunityRepliesResponse() {
|
||||||
|
val service = Mockito.mock(CreatorChannelCommunityQueryService::class.java)
|
||||||
|
val facade = CreatorChannelCommunityFacade(service)
|
||||||
|
val viewer = createMember(id = 10L)
|
||||||
|
val now = LocalDateTime.of(2026, 7, 6, 12, 0)
|
||||||
|
Mockito.doReturn(createReplies()).`when`(service).getCommunityReplies(201L, viewer, -1, 100, now)
|
||||||
|
|
||||||
|
val response = facade.getCommunityReplies(201L, viewer, -1, 100, now)
|
||||||
|
|
||||||
|
assertEquals(1, response.replyCount)
|
||||||
|
assertEquals(201L, response.replies.first().commentId)
|
||||||
|
assertTrue(response.hasNext)
|
||||||
|
}
|
||||||
|
|
||||||
private fun createMember(id: Long): Member {
|
private fun createMember(id: Long): Member {
|
||||||
return Member(
|
return Member(
|
||||||
email = "viewer$id@test.com",
|
email = "viewer$id@test.com",
|
||||||
@@ -139,4 +194,46 @@ class CreatorChannelCommunityFacadeTest {
|
|||||||
hasNext = true
|
hasNext = true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun createDetail(): CreatorChannelCommunityPostDetail {
|
||||||
|
return CreatorChannelCommunityPostDetail(
|
||||||
|
post = createTab().communityPosts.first(),
|
||||||
|
comments = createComments()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createComments(): CreatorChannelCommunityComments {
|
||||||
|
return CreatorChannelCommunityComments(
|
||||||
|
commentCount = 1,
|
||||||
|
comments = listOf(
|
||||||
|
CreatorChannelCommunityComment(
|
||||||
|
commentId = 200L,
|
||||||
|
writerProfileImageUrl = "https://cdn.test/writer.png",
|
||||||
|
writerNickname = "writer",
|
||||||
|
content = "comment",
|
||||||
|
createdAt = LocalDateTime.of(2026, 7, 6, 3, 0),
|
||||||
|
latestReply = createReplies().replies.first()
|
||||||
|
)
|
||||||
|
),
|
||||||
|
page = CreatorChannelPage(page = 0, size = 20),
|
||||||
|
hasNext = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createReplies(): CreatorChannelCommunityReplies {
|
||||||
|
return CreatorChannelCommunityReplies(
|
||||||
|
replyCount = 1,
|
||||||
|
replies = listOf(
|
||||||
|
CreatorChannelCommunityReply(
|
||||||
|
commentId = 201L,
|
||||||
|
writerProfileImageUrl = "https://cdn.test/replier.png",
|
||||||
|
writerNickname = "replier",
|
||||||
|
content = "reply",
|
||||||
|
createdAt = LocalDateTime.of(2026, 7, 6, 3, 1)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
page = CreatorChannelPage(page = 0, size = 20),
|
||||||
|
hasNext = true
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -353,6 +353,111 @@ class DefaultCreatorChannelCommunityQueryRepositoryTest @Autowired constructor(
|
|||||||
assertFalse(adultInactivePurchasedPost.id in adultBlockedPosts.map { it.postId })
|
assertFalse(adultInactivePurchasedPost.id in adultBlockedPosts.map { it.postId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("게시물 ID 상세 조회는 좋아요와 구매와 성인 정책 필드를 채운다")
|
||||||
|
fun shouldFindCommunityPostByIdWithLikePurchaseAndAdultPolicy() {
|
||||||
|
val viewer = saveMember("detail-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember("detail-creator", MemberRole.CREATOR)
|
||||||
|
val liker = saveMember("detail-liker", MemberRole.USER)
|
||||||
|
val paidPost = saveCommunity(creator, isFixed = true, price = 100, isAdult = false)
|
||||||
|
val adultPost = saveCommunity(creator, isFixed = false, price = 0, isAdult = true)
|
||||||
|
val inactivePost = saveCommunity(creator, isFixed = false, price = 0, isActive = false)
|
||||||
|
saveCommunityOrder(viewer, paidPost, CanUsage.PAID_COMMUNITY_POST, isRefund = false)
|
||||||
|
saveCommunityLike(viewer, paidPost, isActive = true)
|
||||||
|
saveCommunityLike(liker, paidPost, isActive = true)
|
||||||
|
saveCommunityComment(viewer, paidPost, isActive = true)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val record = repository.findCommunityPost(paidPost.id!!, viewer.id!!, canViewAdultContent = true)
|
||||||
|
val blockedAdultRecord = repository.findCommunityPost(adultPost.id!!, viewer.id!!, canViewAdultContent = false)
|
||||||
|
val inactiveRecord = repository.findCommunityPost(inactivePost.id!!, viewer.id!!, canViewAdultContent = true)
|
||||||
|
|
||||||
|
assertNotNull(record)
|
||||||
|
assertEquals(paidPost.id, record!!.postId)
|
||||||
|
assertTrue(record.existOrdered)
|
||||||
|
assertTrue(record.isLiked)
|
||||||
|
assertEquals(2, record.likeCount)
|
||||||
|
assertEquals(1, record.commentCount)
|
||||||
|
assertTrue(record.isPinned)
|
||||||
|
assertNull(blockedAdultRecord)
|
||||||
|
assertNull(inactiveRecord)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("최상위 댓글 목록은 비밀 댓글과 차단 작성자를 필터링하고 최신 답글을 반환한다")
|
||||||
|
fun shouldFindVisibleRootCommentsWithLatestReply() {
|
||||||
|
val creator = saveMember("visible-comment-creator", MemberRole.CREATOR)
|
||||||
|
val viewer = saveMember("visible-comment-viewer", MemberRole.USER)
|
||||||
|
val writer = saveMember("visible-comment-writer", MemberRole.USER)
|
||||||
|
val secretWriter = saveMember("visible-secret-writer", MemberRole.USER)
|
||||||
|
val blockedWriter = saveMember("visible-blocked-writer", MemberRole.USER)
|
||||||
|
val replyWriter = saveMember("visible-reply-writer", MemberRole.USER)
|
||||||
|
val post = saveCommunity(creator, isFixed = false, price = 0, isCommentAvailable = true)
|
||||||
|
val oldComment = saveCommunityComment(writer, post, isActive = true)
|
||||||
|
val newComment = saveCommunityComment(viewer, post, isActive = true)
|
||||||
|
saveCommunityComment(secretWriter, post, isActive = true, isSecret = true)
|
||||||
|
saveCommunityComment(blockedWriter, post, isActive = true)
|
||||||
|
saveCommunityComment(writer, post, isActive = false)
|
||||||
|
val oldReply = saveCommunityComment(replyWriter, post, isActive = true, parent = newComment)
|
||||||
|
val latestReply = saveCommunityComment(replyWriter, post, isActive = true, parent = newComment)
|
||||||
|
val hiddenSecretReply = saveCommunityComment(secretWriter, post, isActive = true, isSecret = true, parent = newComment)
|
||||||
|
saveBlock(viewer, blockedWriter, isActive = true)
|
||||||
|
flushAndClear()
|
||||||
|
updateCreatedAt("CreatorCommunityComment", oldComment.id!!, LocalDateTime.of(2026, 7, 6, 10, 0))
|
||||||
|
updateCreatedAt("CreatorCommunityComment", newComment.id!!, LocalDateTime.of(2026, 7, 6, 11, 0))
|
||||||
|
updateCreatedAt("CreatorCommunityComment", oldReply.id!!, LocalDateTime.of(2026, 7, 6, 11, 1))
|
||||||
|
updateCreatedAt("CreatorCommunityComment", latestReply.id!!, LocalDateTime.of(2026, 7, 6, 11, 2))
|
||||||
|
updateCreatedAt("CreatorCommunityComment", hiddenSecretReply.id!!, LocalDateTime.of(2026, 7, 6, 11, 3))
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val viewerCount = repository.countCommunityComments(post.id!!, viewer.id!!, creator.id!!)
|
||||||
|
val creatorCount = repository.countCommunityComments(post.id!!, creator.id!!, creator.id!!)
|
||||||
|
val comments = repository.findCommunityComments(post.id!!, viewer.id!!, creator.id!!, offset = 0, limit = 10)
|
||||||
|
val latestReplies = repository.findLatestRepliesByCommentIds(comments.map { it.commentId }, viewer.id!!)
|
||||||
|
|
||||||
|
assertEquals(2, viewerCount)
|
||||||
|
assertEquals(4, creatorCount)
|
||||||
|
assertEquals(listOf(newComment.id, oldComment.id), comments.map { it.commentId })
|
||||||
|
assertEquals(listOf(latestReply.id), latestReplies.map { it.commentId })
|
||||||
|
assertEquals(newComment.id, latestReplies.single().parentCommentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("답글 조회는 보이는 최상위 댓글 context와 활성 답글만 반환한다")
|
||||||
|
fun shouldFindVisibleRepliesForRootCommentOnly() {
|
||||||
|
val creator = saveMember("reply-context-creator", MemberRole.CREATOR)
|
||||||
|
val viewer = saveMember("reply-context-viewer", MemberRole.USER)
|
||||||
|
val writer = saveMember("reply-context-writer", MemberRole.USER)
|
||||||
|
val blockedWriter = saveMember("reply-context-blocked", MemberRole.USER)
|
||||||
|
val secretWriter = saveMember("reply-context-secret", MemberRole.USER)
|
||||||
|
val post = saveCommunity(creator, isFixed = false, price = 0, isCommentAvailable = true)
|
||||||
|
val root = saveCommunityComment(writer, post, isActive = true)
|
||||||
|
val secretRoot = saveCommunityComment(secretWriter, post, isActive = true, isSecret = true)
|
||||||
|
val oldReply = saveCommunityComment(writer, post, isActive = true, parent = root)
|
||||||
|
val newReply = saveCommunityComment(writer, post, isActive = true, parent = root)
|
||||||
|
saveCommunityComment(secretWriter, post, isActive = true, isSecret = true, parent = root)
|
||||||
|
saveCommunityComment(writer, post, isActive = false, parent = root)
|
||||||
|
saveCommunityComment(blockedWriter, post, isActive = true, parent = root)
|
||||||
|
saveBlock(viewer, blockedWriter, isActive = true)
|
||||||
|
flushAndClear()
|
||||||
|
updateCreatedAt("CreatorCommunityComment", oldReply.id!!, LocalDateTime.of(2026, 7, 6, 10, 0))
|
||||||
|
updateCreatedAt("CreatorCommunityComment", newReply.id!!, LocalDateTime.of(2026, 7, 6, 11, 0))
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val context = repository.findRootCommentContext(root.id!!, viewer.id!!, canViewAdultContent = true)
|
||||||
|
val invisibleSecretContext = repository.findRootCommentContext(secretRoot.id!!, viewer.id!!, canViewAdultContent = true)
|
||||||
|
val replyContext = repository.findRootCommentContext(newReply.id!!, viewer.id!!, canViewAdultContent = true)
|
||||||
|
val count = repository.countCommunityReplies(root.id!!, viewer.id!!)
|
||||||
|
val replies = repository.findCommunityReplies(root.id!!, viewer.id!!, offset = 0, limit = 10)
|
||||||
|
|
||||||
|
assertNotNull(context)
|
||||||
|
assertEquals(post.id, context!!.postId)
|
||||||
|
assertNull(invisibleSecretContext)
|
||||||
|
assertNull(replyContext)
|
||||||
|
assertEquals(2, count)
|
||||||
|
assertEquals(listOf(newReply.id, oldReply.id), replies.map { it.commentId })
|
||||||
|
}
|
||||||
|
|
||||||
private fun saveMember(
|
private fun saveMember(
|
||||||
nickname: String,
|
nickname: String,
|
||||||
role: MemberRole,
|
role: MemberRole,
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import kr.co.vividnext.sodalive.member.MemberProvider
|
|||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicy
|
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicy
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCommentContextRecord
|
||||||
|
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.CreatorChannelCommunityCreatorRecord
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityPostRecord
|
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityPostRecord
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityQueryPort
|
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityQueryPort
|
||||||
@@ -173,6 +175,119 @@ class CreatorChannelCommunityQueryServiceTest {
|
|||||||
assertEquals(true, posts.single().isLiked)
|
assertEquals(true, posts.single().isLiked)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("커뮤니티 상세는 게시글과 초기 댓글 및 최신 답글을 조립한다")
|
||||||
|
fun shouldAssembleCommunityPostDetailWithInitialComments() {
|
||||||
|
val port = FakeCreatorChannelCommunityQueryPort().apply {
|
||||||
|
communityPost = communityPostRecord(1L, price = 0, isLiked = true)
|
||||||
|
communityCommentCount = 21
|
||||||
|
communityComments = (1L..21L).map { communityCommentRecord(it) }
|
||||||
|
latestReplies = listOf(communityCommentRecord(100L, parentCommentId = 1L, writerNickname = "deleted_reply-writer"))
|
||||||
|
}
|
||||||
|
val service = createService(port)
|
||||||
|
val viewer = createMember(id = 10L)
|
||||||
|
|
||||||
|
val detail = service.getCommunityPostDetail(1L, viewer, LocalDateTime.of(2026, 7, 6, 10, 0))
|
||||||
|
|
||||||
|
assertEquals(1L, detail.post.postId)
|
||||||
|
assertEquals(true, detail.post.isLiked)
|
||||||
|
assertEquals(21, detail.comments.commentCount)
|
||||||
|
assertEquals(20, detail.comments.comments.size)
|
||||||
|
assertEquals(true, detail.comments.hasNext)
|
||||||
|
assertEquals("reply-writer", detail.comments.comments.first().latestReply?.writerNickname)
|
||||||
|
assertEquals("https://cdn.test/profile/default-profile.png", detail.comments.comments.first().writerProfileImageUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("댓글 불가 게시글은 댓글 port를 호출하지 않고 빈 댓글 응답을 반환한다")
|
||||||
|
fun shouldReturnEmptyCommentsWhenCommentUnavailable() {
|
||||||
|
val port = FakeCreatorChannelCommunityQueryPort().apply {
|
||||||
|
communityPost = communityPostRecord(1L, price = 0, isCommentAvailable = false)
|
||||||
|
}
|
||||||
|
val service = createService(port)
|
||||||
|
|
||||||
|
val comments = service.getCommunityComments(1L, createMember(id = 10L), -1, 100, LocalDateTime.of(2026, 7, 6, 10, 0))
|
||||||
|
|
||||||
|
assertEquals(0, comments.commentCount)
|
||||||
|
assertEquals(0, comments.comments.size)
|
||||||
|
assertEquals(0, comments.page.page)
|
||||||
|
assertEquals(50, comments.page.size)
|
||||||
|
assertEquals(false, comments.hasNext)
|
||||||
|
assertEquals(false, port.commentListCalled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("커뮤니티 댓글은 page fallback과 최신 답글을 적용한다")
|
||||||
|
fun shouldApplyCommentPageFallbackAndLatestReply() {
|
||||||
|
val port = FakeCreatorChannelCommunityQueryPort().apply {
|
||||||
|
communityPost = communityPostRecord(1L, price = 0)
|
||||||
|
communityCommentCount = 60
|
||||||
|
communityComments = (1L..51L).map { communityCommentRecord(it, writerProfilePath = "profile/$it.png") }
|
||||||
|
latestReplies = listOf(communityCommentRecord(100L, parentCommentId = 2L))
|
||||||
|
}
|
||||||
|
val service = createService(port)
|
||||||
|
|
||||||
|
val comments = service.getCommunityComments(1L, createMember(id = 10L), -1, 100, LocalDateTime.of(2026, 7, 6, 10, 0))
|
||||||
|
|
||||||
|
assertEquals(0L, port.commentListOffset)
|
||||||
|
assertEquals(51, port.commentListLimit)
|
||||||
|
assertEquals((1L..50L).toList(), port.latestReplyCommentIds)
|
||||||
|
assertEquals(50, comments.comments.size)
|
||||||
|
assertEquals(true, comments.hasNext)
|
||||||
|
assertEquals("https://cdn.test/profile/1.png", comments.comments.first().writerProfileImageUrl)
|
||||||
|
assertEquals(100L, comments.comments[1].latestReply?.commentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("커뮤니티 답글은 부모 댓글 context를 확인하고 page fallback으로 조립한다")
|
||||||
|
fun shouldAssembleCommunityReplies() {
|
||||||
|
val port = FakeCreatorChannelCommunityQueryPort().apply {
|
||||||
|
rootCommentContext = CreatorChannelCommunityCommentContextRecord(1L, 10L, 1L, true)
|
||||||
|
communityReplyCount = 51
|
||||||
|
communityReplies = (1L..51L).map { communityCommentRecord(it, parentCommentId = 1L) }
|
||||||
|
}
|
||||||
|
val service = createService(port)
|
||||||
|
|
||||||
|
val replies = service.getCommunityReplies(1L, createMember(id = 10L), -1, 100, LocalDateTime.of(2026, 7, 6, 10, 0))
|
||||||
|
|
||||||
|
assertEquals(51, replies.replyCount)
|
||||||
|
assertEquals(50, replies.replies.size)
|
||||||
|
assertEquals(0L, port.replyListOffset)
|
||||||
|
assertEquals(51, port.replyListLimit)
|
||||||
|
assertEquals(true, replies.hasNext)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("부모 댓글 context가 보이지 않으면 커뮤니티 답글 조회는 예외를 던진다")
|
||||||
|
fun shouldThrowWhenRootCommentContextNotVisible() {
|
||||||
|
val port = FakeCreatorChannelCommunityQueryPort().apply { rootCommentContext = null }
|
||||||
|
val service = createService(port)
|
||||||
|
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
service.getCommunityReplies(1L, createMember(id = 10L), null, null, LocalDateTime.of(2026, 7, 6, 10, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("creator.community.invalid_request_retry", exception.messageKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("댓글 불가 게시글의 답글 직접 조회는 빈 응답을 반환한다")
|
||||||
|
fun shouldReturnEmptyRepliesWhenCommentUnavailable() {
|
||||||
|
val port = FakeCreatorChannelCommunityQueryPort().apply {
|
||||||
|
rootCommentContext = CreatorChannelCommunityCommentContextRecord(1L, 10L, 1L, false)
|
||||||
|
communityReplyCount = 1
|
||||||
|
communityReplies = listOf(communityCommentRecord(2L, parentCommentId = 1L))
|
||||||
|
}
|
||||||
|
val service = createService(port)
|
||||||
|
|
||||||
|
val replies = service.getCommunityReplies(1L, createMember(id = 10L), null, null, LocalDateTime.of(2026, 7, 6, 10, 0))
|
||||||
|
|
||||||
|
assertEquals(0, replies.replyCount)
|
||||||
|
assertEquals(0, replies.replies.size)
|
||||||
|
assertEquals(false, replies.hasNext)
|
||||||
|
assertEquals(null, port.replyListOffset)
|
||||||
|
}
|
||||||
|
|
||||||
private fun createService(
|
private fun createService(
|
||||||
port: FakeCreatorChannelCommunityQueryPort,
|
port: FakeCreatorChannelCommunityQueryPort,
|
||||||
audioContentCloudFront: AudioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java),
|
audioContentCloudFront: AudioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java),
|
||||||
@@ -226,11 +341,29 @@ private class FakeCreatorChannelCommunityQueryPort : CreatorChannelCommunityQuer
|
|||||||
var blocked = false
|
var blocked = false
|
||||||
var communityPostCount = 1
|
var communityPostCount = 1
|
||||||
var communityPosts = listOf(communityPostRecord(1L, price = 0))
|
var communityPosts = listOf(communityPostRecord(1L, price = 0))
|
||||||
|
var communityPost: CreatorChannelCommunityPostRecord? = communityPostRecord(1L, price = 0)
|
||||||
var homeCommunityPosts = listOf(communityPostRecord(1L, price = 0))
|
var homeCommunityPosts = listOf(communityPostRecord(1L, price = 0))
|
||||||
|
var communityCommentCount = 0
|
||||||
|
var communityComments = emptyList<CreatorChannelCommunityCommentRecord>()
|
||||||
|
var latestReplies = emptyList<CreatorChannelCommunityCommentRecord>()
|
||||||
|
var rootCommentContext: CreatorChannelCommunityCommentContextRecord? = CreatorChannelCommunityCommentContextRecord(
|
||||||
|
commentId = 1L,
|
||||||
|
postId = 1L,
|
||||||
|
creatorId = 1L,
|
||||||
|
isCommentAvailable = true
|
||||||
|
)
|
||||||
|
var communityReplyCount = 0
|
||||||
|
var communityReplies = emptyList<CreatorChannelCommunityCommentRecord>()
|
||||||
var countCanViewAdultContent: Boolean? = null
|
var countCanViewAdultContent: Boolean? = null
|
||||||
var listCanViewAdultContent: Boolean? = null
|
var listCanViewAdultContent: Boolean? = null
|
||||||
var listOffset: Long? = null
|
var listOffset: Long? = null
|
||||||
var listLimit: Int? = null
|
var listLimit: Int? = null
|
||||||
|
var commentListCalled = false
|
||||||
|
var commentListOffset: Long? = null
|
||||||
|
var commentListLimit: Int? = null
|
||||||
|
var latestReplyCommentIds = emptyList<Long>()
|
||||||
|
var replyListOffset: Long? = null
|
||||||
|
var replyListLimit: Int? = null
|
||||||
var homeCreatorId: Long? = null
|
var homeCreatorId: Long? = null
|
||||||
var homeViewerId: Long? = null
|
var homeViewerId: Long? = null
|
||||||
var homeIsPinned: Boolean? = null
|
var homeIsPinned: Boolean? = null
|
||||||
@@ -277,6 +410,54 @@ private class FakeCreatorChannelCommunityQueryPort : CreatorChannelCommunityQuer
|
|||||||
homeLimit = limit
|
homeLimit = limit
|
||||||
return homeCommunityPosts
|
return homeCommunityPosts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun findCommunityPost(
|
||||||
|
postId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
canViewAdultContent: Boolean
|
||||||
|
): CreatorChannelCommunityPostRecord? = communityPost
|
||||||
|
|
||||||
|
override fun countCommunityComments(postId: Long, viewerId: Long, creatorId: Long): Int = communityCommentCount
|
||||||
|
|
||||||
|
override fun findCommunityComments(
|
||||||
|
postId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
creatorId: Long,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int
|
||||||
|
): List<CreatorChannelCommunityCommentRecord> {
|
||||||
|
commentListCalled = true
|
||||||
|
commentListOffset = offset
|
||||||
|
commentListLimit = limit
|
||||||
|
return communityComments
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findLatestRepliesByCommentIds(
|
||||||
|
commentIds: List<Long>,
|
||||||
|
viewerId: Long
|
||||||
|
): List<CreatorChannelCommunityCommentRecord> {
|
||||||
|
latestReplyCommentIds = commentIds
|
||||||
|
return latestReplies
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findRootCommentContext(
|
||||||
|
commentId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
canViewAdultContent: Boolean
|
||||||
|
): CreatorChannelCommunityCommentContextRecord? = rootCommentContext
|
||||||
|
|
||||||
|
override fun countCommunityReplies(commentId: Long, viewerId: Long): Int = communityReplyCount
|
||||||
|
|
||||||
|
override fun findCommunityReplies(
|
||||||
|
commentId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int
|
||||||
|
): List<CreatorChannelCommunityCommentRecord> {
|
||||||
|
replyListOffset = offset
|
||||||
|
replyListLimit = limit
|
||||||
|
return communityReplies
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun communityPostRecord(
|
private fun communityPostRecord(
|
||||||
@@ -287,7 +468,8 @@ private fun communityPostRecord(
|
|||||||
creatorProfilePath: String? = "profile/$postId.png",
|
creatorProfilePath: String? = "profile/$postId.png",
|
||||||
imagePath: String? = "image/$postId.png",
|
imagePath: String? = "image/$postId.png",
|
||||||
audioPath: String? = "audio/$postId.mp3",
|
audioPath: String? = "audio/$postId.mp3",
|
||||||
isLiked: Boolean = false
|
isLiked: Boolean = false,
|
||||||
|
isCommentAvailable: Boolean = true
|
||||||
): CreatorChannelCommunityPostRecord {
|
): CreatorChannelCommunityPostRecord {
|
||||||
return CreatorChannelCommunityPostRecord(
|
return CreatorChannelCommunityPostRecord(
|
||||||
postId = postId,
|
postId = postId,
|
||||||
@@ -300,10 +482,28 @@ private fun communityPostRecord(
|
|||||||
price = price,
|
price = price,
|
||||||
createdAt = LocalDateTime.of(2026, 6, 21, 10, 0).plusMinutes(postId),
|
createdAt = LocalDateTime.of(2026, 6, 21, 10, 0).plusMinutes(postId),
|
||||||
existOrdered = existOrdered,
|
existOrdered = existOrdered,
|
||||||
isCommentAvailable = true,
|
isCommentAvailable = isCommentAvailable,
|
||||||
likeCount = postId.toInt(),
|
likeCount = postId.toInt(),
|
||||||
commentCount = postId.toInt() + 1,
|
commentCount = postId.toInt() + 1,
|
||||||
isPinned = postId == 1L,
|
isPinned = postId == 1L,
|
||||||
isLiked = isLiked
|
isLiked = isLiked
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun communityCommentRecord(
|
||||||
|
commentId: Long,
|
||||||
|
parentCommentId: Long? = null,
|
||||||
|
writerProfilePath: String? = null,
|
||||||
|
writerNickname: String = "writer-$commentId"
|
||||||
|
): CreatorChannelCommunityCommentRecord {
|
||||||
|
return CreatorChannelCommunityCommentRecord(
|
||||||
|
commentId = commentId,
|
||||||
|
parentCommentId = parentCommentId,
|
||||||
|
postId = 1L,
|
||||||
|
writerId = commentId + 100L,
|
||||||
|
writerProfilePath = writerProfilePath,
|
||||||
|
writerNickname = writerNickname,
|
||||||
|
content = "comment-$commentId",
|
||||||
|
createdAt = LocalDateTime.of(2026, 7, 6, 10, 0).plusMinutes(commentId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -742,4 +742,40 @@ private class FakeCreatorChannelCommunityQueryPort : CreatorChannelCommunityQuer
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun findCommunityPost(
|
||||||
|
postId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
canViewAdultContent: Boolean
|
||||||
|
): CreatorChannelCommunityPostRecord? = null
|
||||||
|
|
||||||
|
override fun countCommunityComments(postId: Long, viewerId: Long, creatorId: Long): Int = 0
|
||||||
|
|
||||||
|
override fun findCommunityComments(
|
||||||
|
postId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
creatorId: Long,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int
|
||||||
|
) = emptyList<kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCommentRecord>()
|
||||||
|
|
||||||
|
override fun findLatestRepliesByCommentIds(
|
||||||
|
commentIds: List<Long>,
|
||||||
|
viewerId: Long
|
||||||
|
) = emptyList<kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCommentRecord>()
|
||||||
|
|
||||||
|
override fun findRootCommentContext(
|
||||||
|
commentId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
canViewAdultContent: Boolean
|
||||||
|
): kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCommentContextRecord? = null
|
||||||
|
|
||||||
|
override fun countCommunityReplies(commentId: Long, viewerId: Long): Int = 0
|
||||||
|
|
||||||
|
override fun findCommunityReplies(
|
||||||
|
commentId: Long,
|
||||||
|
viewerId: Long,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int
|
||||||
|
) = emptyList<kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCommentRecord>()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user