docs(creator-channel): 커뮤니티 탭 Phase 1 기록을 갱신한다

This commit is contained in:
2026-06-21 19:23:58 +09:00
parent d249d9c257
commit 2ebe7afab7
2 changed files with 62 additions and 29 deletions

View File

@@ -25,7 +25,7 @@
- `size`: fallback 보정 후 실제 적용된 page size
- `hasNext`: 다음 page 존재 여부
- community post item:
- `postId`, `creatorId`, `creatorNickname`, `createdAtUtc`, `content`, `imageUrl`, `audioUrl`, `price`, `isCommentAvailable`, `likeCount`, `commentCount`, `isPinned`
- `postId`, `creatorId`, `creatorNickname`, `creatorProfileUrl`, `createdAtUtc`, `content`, `imageUrl`, `audioUrl`, `price`, `isCommentAvailable`, `existOrdered`, `likeCount`, `commentCount`, `isPinned`
- 공개 게시글 기준: `CreatorCommunity.isActive == true`, `CreatorCommunity.member.id == creatorId`, `CreatorCommunity.member.isActive == true`.
- 성인 콘텐츠는 조회자의 성인 콘텐츠 노출 정책이 false이면 목록과 count에서 제외한다.
- 성인 콘텐츠 필터는 구매 여부보다 우선한다. 조회자가 19금 게시글을 구매했거나 작성자여도 성인 콘텐츠 노출 정책이 false이면 제외한다.
@@ -35,14 +35,19 @@
- 일반 게시글 사이의 정렬은 `createdAt desc`, `id desc`다.
- 고정 게시글과 일반 게시글은 하나의 목록으로 페이징한다.
- `communityPostCount`는 고정 게시글과 일반 게시글을 모두 포함한 전체 개수다.
- `createdAtUtc`는 게시글 생성 시각을 UTC 기준 ISO-8601 문자열로 내려준다.
- `imageUrl``CreatorCommunity.imagePath``String?.toCdnUrl(cloudFrontHost)`로 변환한다. 경로가 없거나 blank이면 `null`다.
- `createdAtUtc`는 게시글 생성 시각을 UTC 기준 ISO-8601 문자열로 내려준다. 구현 전 재사용 가능한 `toUtcIso` 확장함수를 검색하고, public 확장함수가 있으면 신규 생성 없이 import해서 사용한다.
- 문서 작성 시점 확인 결과 `toUtcIso`는 일부 DTO의 private/internal 확장함수로만 존재하고, 공용 확장 파일인 `kr.co.vividnext.sodalive.extensions.LocalDateTimeExtensions.kt`에는 없다. 구현 시점에도 public 확장함수가 없으면 이 공용 확장 파일에 `fun LocalDateTime.toUtcIso(): String`을 추가하고 커뮤니티 DTO에서 import한다.
- `creatorProfileUrl``CreatorCommunity.member.profileImage``String?.toCdnUrl(cloudFrontHost)`로 변환하고, 없으면 기존 홈 API의 기본 프로필 이미지 URL을 내려준다.
- `existOrdered`는 조회자가 게시글 작성자이면 `true`, 조회자가 유효 구매 내역을 가지고 있으면 `true`, 그 외에는 `false`로 내려준다.
- `imageUrl``CreatorCommunity.imagePath`가 있고 이미지 접근 권한이 있을 때만 `String?.toCdnUrl(cloudFrontHost)`로 변환한다. 경로가 없거나 blank이면 `null`이다.
- legacy 커뮤니티 목록은 유료 미구매 게시글의 이미지를 노출했지만, 커뮤니티 탭 API는 유료 미구매 게시글의 `imageUrl``audioUrl`과 동일하게 `null`로 내려준다.
- 이미지는 signed URL을 생성하지 않고 기존 CDN URL 조합 정책만 사용한다.
- `audioUrl``CreatorCommunity.audioPath`가 있고 접근 권한이 있을 때만 `AudioContentCloudFront.generateSignedURL(resourcePath, 1000 * 60 * 30)` 결과를 내려준다.
- 오디오 접근 권한:
- 이미지/오디오 접근 권한:
- 무료 게시글이면 접근 가능
- 유료 게시글이고 조회자가 게시글 작성자이면 접근 가능
- 유료 게시글이고 조회자가 `CanUsage.PAID_COMMUNITY_POST`, `isRefund == false` 구매 내역을 가지면 접근 가능
- 그 외에는 `audioUrl == null`
- 그 외에는 `imageUrl == null`, `audioUrl == null`
- 유료 게시글 본문은 기존 홈 API/legacy 커뮤니티 정책과 같은 마스킹을 적용한다.
- 접근 가능하면 원문
- 접근 불가이고 길이가 15 code point 초과이면 앞 15 code point + `...`
@@ -87,6 +92,7 @@
### 기존 파일 확인/재사용
- 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`
- Modify/Verify: `src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/aws/cloudfront/AudioContentCloudFront.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunity.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/comment/CreatorCommunityCommentRepository.kt`
@@ -108,10 +114,9 @@
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.CreatorChannelCommunityPost
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityTab
import java.time.LocalDateTime
import java.time.ZoneOffset
data class CreatorChannelCommunityTabResponse(
val communityPostCount: Int,
@@ -138,6 +143,7 @@ data class CreatorChannelCommunityPostResponse(
val postId: Long,
val creatorId: Long,
val creatorNickname: String,
val creatorProfileUrl: String,
val createdAtUtc: String,
val content: String,
val imageUrl: String?,
@@ -145,6 +151,7 @@ data class CreatorChannelCommunityPostResponse(
val price: Int,
@JsonProperty("isCommentAvailable")
val isCommentAvailable: Boolean,
val existOrdered: Boolean,
val likeCount: Int,
val commentCount: Int,
@JsonProperty("isPinned")
@@ -156,12 +163,14 @@ data class CreatorChannelCommunityPostResponse(
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
@@ -169,10 +178,6 @@ data class CreatorChannelCommunityPostResponse(
}
}
}
private fun LocalDateTime.toUtcIso(): String {
return atOffset(ZoneOffset.UTC).toInstant().toString()
}
```
---
@@ -272,7 +277,7 @@ data class CreatorChannelCommunityPostRecord(
### Phase 1: 커뮤니티 도메인 계약과 순수 정책 추가
- [ ] **Task 1.1: `CreatorChannelCommunityQueryPolicy`와 domain/port 계약 테스트 작성**
- [x] **Task 1.1: `CreatorChannelCommunityQueryPolicy`와 domain/port 계약 테스트 작성**
- Files:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicyTest.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicy.kt`
@@ -294,6 +299,10 @@ data class CreatorChannelCommunityPostRecord(
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicyTest"`
- 기대 결과: `BUILD SUCCESSFUL`
- REFACTOR: 정책 클래스에는 DB, Spring MVC, API DTO 의존성을 넣지 않는다.
- 검증 기록:
- RED: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicyTest"` 실행 결과 `CreatorChannelCommunityQueryPolicy`, domain, port 미구현 심볼로 `compileTestKotlin` 실패를 확인했다.
- GREEN: 같은 명령 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 범위: Phase 1의 순수 정책, domain model, port 계약, 계약 테스트만 추가했고 DB/Spring MVC/API DTO 의존성은 넣지 않았다.
### Phase 2: QueryDSL repository 분리와 조회 정책 구현
@@ -316,6 +325,7 @@ data class CreatorChannelCommunityPostRecord(
- 차단 관계에 걸린 댓글 작성자의 댓글은 `commentCount`에서 제외된다.
- 유효 구매 내역은 `CanUsage.PAID_COMMUNITY_POST`, `UseCan.member.id == viewerId`, `UseCan.communityPost.id == postId`, `UseCan.isRefund == false`다.
- 같은 게시글에 구매 내역이 중복되어도 list item은 중복되지 않는다.
- 조회자가 게시글 작성자이면 구매 내역이 없어도 `existOrdered == true`다.
- RED 실행:
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorChannelCommunityQueryRepositoryTest"`
- 기대 결과: repository 미구현으로 컴파일 실패 또는 테스트 실패
@@ -344,6 +354,9 @@ data class CreatorChannelCommunityPostRecord(
- `MemberContentPreferenceService``isAdultVisibleByPolicy` 결과를 port의 `canViewAdultContent`로 전달한다.
- 이미지 path는 `toCdnUrl(cloudFrontHost)`로 변환하고 blank path는 `null`이다.
- 작성자 프로필 path가 없으면 기존 홈 API의 기본 프로필 URL 정책을 적용한다.
- 조회자가 게시글 작성자이면 구매 내역이 없어도 `existOrdered == true`로 조립한다.
- 무료 이미지, 구매한 유료 이미지, 작성자 본인 유료 이미지는 CDN URL을 사용하고 signed URL을 생성하지 않는다.
- 미구매 유료 이미지는 `imageUrl == null`이다.
- 무료 오디오, 구매한 유료 오디오, 작성자 본인 유료 오디오는 `AudioContentCloudFront.generateSignedURL(audioPath, 1000 * 60 * 30)` 결과를 사용한다.
- 미구매 유료 오디오는 signed URL을 생성하지 않고 `audioUrl == null`이다.
- 유료 미구매 본문은 policy의 마스킹 결과를 사용한다.
@@ -355,7 +368,7 @@ data class CreatorChannelCommunityPostRecord(
- GREEN 실행:
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest"`
- 기대 결과: `BUILD SUCCESSFUL`
- REFACTOR: signed URL 생성은 접근 가능한 오디오 path가 있을 때만 호출하고, service는 API DTO를 반환하지 않는다.
- REFACTOR: signed URL 생성은 접근 가능한 오디오 path가 있을 때만 호출하고, 이미지는 signed URL 생성 대상에서 제외한다. service는 API DTO를 반환하지 않는다.
### Phase 4: 홈 API 커뮤니티 조회 로직을 새 도메인으로 연결
@@ -400,11 +413,14 @@ data class CreatorChannelCommunityPostRecord(
- [ ] **Task 5.1: response DTO와 facade 테스트 작성**
- Files:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt`
- Modify/Verify: `src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityTabResponse.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt`
- RED: 아래 케이스를 테스트로 먼저 작성한다.
- facade는 `CreatorChannelCommunityQueryService.getCommunityTab` 결과를 `CreatorChannelCommunityTabResponse`로 변환한다.
- `createdAtUtc`는 UTC ISO-8601 문자열이다.
- `createdAtUtc` 변환은 재사용 가능한 `toUtcIso` 확장함수가 있으면 기존 확장함수를 import해서 사용하고, 없으면 `LocalDateTimeExtensions.kt`에 공용 확장함수를 추가해 사용한다.
- `creatorProfileUrl`, `existOrdered`가 응답에 포함된다.
- `imageUrl == null`, `audioUrl == null`이 그대로 응답된다.
- `@JsonProperty``isCommentAvailable`, `isPinned`, `hasNext` 필드명이 유지된다.
- RED 실행:
@@ -424,7 +440,7 @@ data class CreatorChannelCommunityPostRecord(
- 비회원 요청은 `401 Unauthorized`다.
- 인증 회원 요청은 `GET /api/v2/creator-channels/{creatorId}/community`를 호출하고 `creatorId`, `page`, `size`, `viewer`를 facade에 전달한다.
- `page=-1`, `size=100` 같은 값은 controller에서 거부하지 않고 facade로 전달한다.
- 성공 응답은 `success=true`, `data.communityPostCount`, `data.communityPosts[0].postId`, `data.communityPosts[0].isCommentAvailable`, `data.communityPosts[0].isPinned`, `data.page`, `data.size`, `data.hasNext`를 포함한다.
- 성공 응답은 `success=true`, `data.communityPostCount`, `data.communityPosts[0].postId`, `data.communityPosts[0].creatorProfileUrl`, `data.communityPosts[0].existOrdered`, `data.communityPosts[0].isCommentAvailable`, `data.communityPosts[0].isPinned`, `data.page`, `data.size`, `data.hasNext`를 포함한다.
- RED 실행:
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityControllerTest"`
- 기대 결과: controller 미구현으로 컴파일 실패 또는 테스트 실패
@@ -445,6 +461,7 @@ data class CreatorChannelCommunityPostRecord(
- `page=-1`, `size=10` 요청은 `page=0`, `size=20`으로 fallback된다.
- 성인 콘텐츠 노출 정책이 false인 조회자는 19금 게시글을 count와 list에서 받지 않는다.
- 성인 콘텐츠 노출 정책이 false인 조회자가 19금 게시글을 구매했더라도 count와 list에서 받지 않는다.
- 구매한 유료 게시글의 `imageUrl`은 CDN URL이고 signed URL이 아니며, 미구매 유료 게시글의 `imageUrl``null`이다.
- 구매한 유료 게시글의 `audioUrl`은 signed URL 형태이고, 미구매 유료 게시글의 `audioUrl``null`이다.
- 이미지가 없는 게시글의 `imageUrl``null`이다.
- RED 실행:
@@ -493,7 +510,7 @@ data class CreatorChannelCommunityPostRecord(
1. Phase 1에서 순수 정책과 domain/port 계약을 먼저 고정한다.
2. Phase 2에서 QueryDSL repository를 새 커뮤니티 도메인으로 분리한다.
3. Phase 3에서 service가 인증/성인/차단/URL/signed URL/마스킹 정책을 조립하게 한다.
3. Phase 3에서 service가 인증/성인/차단/CDN URL/오디오 signed URL/마스킹 정책을 조립하게 한다.
4. Phase 4에서 홈 API의 커뮤니티 조회를 새 도메인으로 연결하고 홈 repository의 커뮤니티 쿼리를 제거한다.
5. Phase 5에서 공개 API DTO/facade/controller를 추가한다.
6. Phase 6에서 커뮤니티 탭 E2E와 홈 API 회귀를 확인한다.
@@ -507,3 +524,4 @@ data class CreatorChannelCommunityPostRecord(
- 2026-06-21: 문서 작성 검증 - placeholder와 모호한 문구 검색 결과 0건.
- 2026-06-21: 문서 변경 whitespace 검증 - `git diff --check` 실행 결과 출력 없음, exit code 0.
- 2026-06-21: 문서 유지보수 규칙 검증 - sandbox 내부 `./gradlew tasks --all``~/.gradle` lock 파일 접근 제한으로 실패했고, 승인 실행으로 재시도한 `./gradlew tasks --all``BUILD SUCCESSFUL` 확인.
- 2026-06-21: Phase 1 Task 1.1 검증 - RED focused test는 미구현 심볼로 `compileTestKotlin` 실패, GREEN focused test는 `BUILD SUCCESSFUL` 확인.