docs(creator-channel): 커뮤니티 탭 Phase 1 기록을 갱신한다
This commit is contained in:
@@ -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` 확인.
|
||||
|
||||
Reference in New Issue
Block a user