Compare commits

...

17 Commits

Author SHA1 Message Date
a96d9ddc76 docs(creator-channel): 커뮤니티 탭 Phase 7 기록을 갱신한다 2026-06-22 01:44:12 +09:00
ccfe3f79c7 docs(creator-channel): 커뮤니티 탭 Phase 6 기록을 갱신한다 2026-06-22 01:08:31 +09:00
c04d72b04e test(creator-channel): 커뮤니티 탭 E2E 검증을 추가한다 2026-06-22 01:08:21 +09:00
3360477f75 docs(creator-channel): 커뮤니티 탭 Phase 5 기록을 갱신한다 2026-06-22 00:03:11 +09:00
0a6a689773 feat(creator-channel): 커뮤니티 탭 endpoint를 추가한다 2026-06-22 00:02:14 +09:00
e0e6b34d21 feat(creator-channel): 커뮤니티 탭 응답 조립을 추가한다 2026-06-22 00:01:45 +09:00
bd4e865f2e docs(creator-channel): 커뮤니티 탭 Phase 4 기록을 갱신한다 2026-06-21 23:20:55 +09:00
45337663e5 test(creator-channel): 홈 커뮤니티 서비스 연결을 검증한다 2026-06-21 23:20:36 +09:00
014511668a refactor(creator-channel): 홈 repository 커뮤니티 조회 책임을 제거한다 2026-06-21 23:19:52 +09:00
6ab3c50c32 feat(creator-channel): 홈 커뮤니티 조회를 공용 서비스로 연결한다 2026-06-21 23:19:37 +09:00
06e82f1bba docs(creator-channel): 커뮤니티 탭 Phase 3 기록을 갱신한다 2026-06-21 22:15:59 +09:00
0620e54cbd feat(creator-channel): 커뮤니티 탭 조회 서비스를 추가한다 2026-06-21 22:15:37 +09:00
00695d5b33 docs(creator-channel): 커뮤니티 탭 Phase 2 기록을 갱신한다 2026-06-21 20:45:10 +09:00
078718c041 feat(creator-channel): 커뮤니티 탭 repository를 추가한다 2026-06-21 20:44:24 +09:00
2ebe7afab7 docs(creator-channel): 커뮤니티 탭 Phase 1 기록을 갱신한다 2026-06-21 19:23:58 +09:00
d249d9c257 feat(creator-channel): 커뮤니티 탭 조회 계약을 추가한다 2026-06-21 19:23:32 +09:00
94b5c70cc6 docs(creator-channel): 커뮤니티 탭 API 계획을 기록한다 2026-06-21 18:29:56 +09:00
27 changed files with 3226 additions and 625 deletions

View File

@@ -0,0 +1,580 @@
# 크리에이터 채널 커뮤니티 탭 API Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
**Goal:** 인증 회원이 `GET /api/v2/creator-channels/{creatorId}/community`로 크리에이터 채널 커뮤니티 탭의 조회 가능한 전체 게시글 개수와 페이징된 게시글 목록을 조회할 수 있게 한다.
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.community` 조립 계층에 둔다. 커뮤니티 게시글 조회 service, page/content masking 정책, tab domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.creator.channel.community` 하위에 두고 `v2.api.*`에 의존하지 않는다. 홈 API는 홈 repository에 커뮤니티 조회 쿼리를 직접 두지 않고, 분리된 커뮤니티 조회 도메인의 홈 요약 조회 메서드를 호출해 기존 `notices`, `communities` 응답 계약을 유지한다.
**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/{creatorId}/community`
- 인증 정책: 인증 회원만 조회 가능. 비회원은 기존 Security 흐름과 `requireMember` 정책으로 거부한다.
- request:
- path variable: `creatorId`
- query parameter: `page`, `required = false`, 기본값 `0`, `page < 0`이면 `0`으로 fallback
- query parameter: `size`, `required = false`, 기본값 `20`, `size < 20`이면 `20`, `size > 50`이면 `50`으로 fallback
- response:
- `communityPostCount`: 조회자가 조회 가능한 커뮤니티 게시글 전체 개수
- `communityPosts`: 커뮤니티 게시글 목록
- `page`: fallback 보정 후 실제 적용된 page index
- `size`: fallback 보정 후 실제 적용된 page size
- `hasNext`: 다음 page 존재 여부
- community post item:
- `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이면 제외한다.
- 목록 정렬:
- 고정 게시글을 먼저 노출한다.
- 고정 게시글 사이의 정렬은 `fixedAt desc`, `id desc`다.
- 일반 게시글 사이의 정렬은 `createdAt desc`, `id desc`다.
- 고정 게시글과 일반 게시글은 하나의 목록으로 페이징한다.
- `communityPostCount`는 고정 게시글과 일반 게시글을 모두 포함한 전체 개수다.
- `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` 구매 내역을 가지면 접근 가능
- 그 외에는 `imageUrl == null`, `audioUrl == null`
- 유료 게시글 본문은 기존 홈 API/legacy 커뮤니티 정책과 같은 마스킹을 적용한다.
- 접근 가능하면 원문
- 접근 불가이고 길이가 15 code point 초과이면 앞 15 code point + `...`
- 접근 불가이고 길이가 15 code point 이하이면 앞 절반 code point + `...`
- `commentCount``isCommentAvailable == false`이면 `0`이다.
- `commentCount`는 활성 최상위 댓글만 세고, 기존 커뮤니티 댓글의 차단 관계와 비밀 댓글 노출 정책을 적용한다.
- `likeCount`는 활성 좋아요 수만 센다.
- legacy `/creator-community` 공개 endpoint는 변경하지 않는다.
- 홈 API 공개 응답 스키마는 변경하지 않는다.
---
## 1. 파일 구조 계획
### 커뮤니티 탭 신규 API 조립 계층
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityTabResponse.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt`
### 커뮤니티 도메인 조회 계층
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityTab.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicy.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/port/out/CreatorChannelCommunityQueryPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/CreatorChannelCommunityQueryRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicyTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepositoryTest.kt`
### 홈 API 커뮤니티 조회 분리 대상
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.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`
- 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`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/like/CreatorCommunityLikeRepository.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCan.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt`
### 문서 산출물
- Create: `docs/20260621_크리에이터_채널_커뮤니티_탭_API/plan-task.md`
- Verify: `docs/20260621_크리에이터_채널_커뮤니티_탭_API/prd.md`
---
## 2. Response data class 초안
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityTabResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 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.CreatorChannelCommunityPost
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityTab
data class CreatorChannelCommunityTabResponse(
val communityPostCount: Int,
val communityPosts: List<CreatorChannelCommunityPostResponse>,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
) {
companion object {
fun from(tab: CreatorChannelCommunityTab): CreatorChannelCommunityTabResponse {
return CreatorChannelCommunityTabResponse(
communityPostCount = tab.communityPostCount,
communityPosts = tab.communityPosts.map(CreatorChannelCommunityPostResponse::from),
page = tab.page.page,
size = tab.page.size,
hasNext = tab.hasNext
)
}
}
}
data class CreatorChannelCommunityPostResponse(
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
) {
companion object {
fun from(post: CreatorChannelCommunityPost): CreatorChannelCommunityPostResponse {
return 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
)
}
}
}
```
---
## 3. Domain / Port 초안
구현 시 아래 형태를 기준으로 추가한다. API DTO가 domain model을 참조하지만 domain/port는 API DTO를 참조하지 않는다.
```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 CreatorChannelCommunityTab(
val communityPostCount: Int,
val communityPosts: List<CreatorChannelCommunityPost>,
val page: CreatorChannelPage,
val hasNext: Boolean
)
data class CreatorChannelCommunityPost(
val postId: Long,
val creatorId: Long,
val creatorNickname: String,
val creatorProfileUrl: String,
val imageUrl: String?,
val audioUrl: String?,
val content: String,
val price: Int,
val createdAt: LocalDateTime,
val existOrdered: Boolean,
val isCommentAvailable: Boolean,
val likeCount: Int,
val commentCount: Int,
val isPinned: Boolean
)
```
```kotlin
package kr.co.vividnext.sodalive.v2.creator.channel.community.port.out
import kr.co.vividnext.sodalive.member.MemberRole
import java.time.LocalDateTime
interface CreatorChannelCommunityQueryPort {
fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCommunityCreatorRecord?
fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean
fun countCommunityPosts(
creatorId: Long,
viewerId: Long,
canViewAdultContent: Boolean
): Int
fun findCommunityPosts(
creatorId: Long,
viewerId: Long,
canViewAdultContent: Boolean,
offset: Long,
limit: Int
): List<CreatorChannelCommunityPostRecord>
fun findHomeCommunityPosts(
creatorId: Long,
viewerId: Long,
isPinned: Boolean,
canViewAdultContent: Boolean,
limit: Int
): List<CreatorChannelCommunityPostRecord>
}
data class CreatorChannelCommunityCreatorRecord(
val creatorId: Long,
val role: MemberRole,
val nickname: String
)
data class CreatorChannelCommunityPostRecord(
val postId: Long,
val creatorId: Long,
val creatorNickname: String,
val creatorProfilePath: String?,
val imagePath: String?,
val audioPath: String?,
val content: String,
val price: Int,
val createdAt: LocalDateTime,
val existOrdered: Boolean,
val isCommentAvailable: Boolean,
val likeCount: Int,
val commentCount: Int,
val isPinned: Boolean
)
```
---
## 4. 작업 계획
### Phase 1: 커뮤니티 도메인 계약과 순수 정책 추가
- [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`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityTab.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/port/out/CreatorChannelCommunityQueryPort.kt`
- RED: 아래 케이스를 테스트로 먼저 작성한다.
- `page = null`, `size = null`이면 `page=0`, `size=20`, `offset=0`, `fetchLimit=21`이다.
- `page = -1`, `size = 10`이면 `page=0`, `size=20`, `fetchLimit=21`이다.
- `page = 2`, `size = 100`이면 `page=2`, `size=50`, `offset=100`, `fetchLimit=51`이다.
- `limitItems``size`만큼만 남기고 `hasNext``fetched.size > size`로 계산한다.
- 유료 본문 마스킹은 15 code point 초과면 앞 15자 + `...`, 15자 이하면 앞 절반 + `...`로 계산한다.
- 무료 게시글, 작성자 본인, 구매자는 유료 본문 원문을 볼 수 있다.
- domain model과 port record가 Phase 1 계약 필드를 유지한다.
- RED 실행:
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicyTest"`
- 기대 결과: `CreatorChannelCommunityQueryPolicy`, domain, port 미구현으로 컴파일 실패 또는 테스트 실패
- GREEN: `CreatorChannelPage`를 재사용해 page 정책을 만들고, `maskPaidContent(content, price, isCreatorSelf, existOrdered)` 순수 함수를 추가한다.
- GREEN 실행:
- `./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 분리와 조회 정책 구현
- [x] **Task 2.1: 커뮤니티 repository의 creator/차단/count/list 조회 테스트 작성**
- Files:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepositoryTest.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/CreatorChannelCommunityQueryRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepository.kt`
- RED: `@DataJpaTest(properties = ["spring.cache.type=none", "spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE"])`, `@Import(QueryDslConfig::class)` 패턴으로 아래 케이스를 작성한다.
- 활성 creator는 `findCreator`로 조회되고 비활성 creator는 `null`이다.
- viewer와 creator 사이 양방향 활성 차단 관계는 `existsBlockedBetween`에서 `true`다.
- `countCommunityPosts`는 creator의 활성 게시글만 세고 다른 creator, 비활성 게시글은 제외한다.
- `canViewAdultContent=false`이면 19금 게시글은 count와 list에서 제외된다.
- `canViewAdultContent=false`이고 viewer가 19금 게시글을 구매했어도 count와 list에서 제외된다.
- list는 고정 게시글을 먼저 반환하고, 고정 게시글은 `fixedAt desc`, 일반 게시글은 `createdAt desc` 순서를 따른다.
- `offset`, `limit`으로 하나의 통합 목록을 페이징한다.
- `likeCount`는 활성 좋아요만 센다.
- `isCommentAvailable=false`인 게시글의 `commentCount``0`이다.
- `commentCount`는 활성 최상위 댓글만 세고, 비밀 댓글은 작성자 본인 또는 콘텐츠 creator에게만 포함한다.
- 차단 관계에 걸린 댓글 작성자의 댓글은 `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 미구현으로 컴파일 실패 또는 테스트 실패
- GREEN: 기존 `DefaultCreatorChannelHomeQueryRepository.findCommunityPosts`, `orderedCommunityPostIds`, `communityLikeCounts`, `communityCommentCounts`, 차단 sub query, adult condition을 커뮤니티 repository로 옮기되 탭용 통합 정렬과 count를 추가한다.
- GREEN 구현 기준:
- tab list where는 `isActive == true`, `member.id == creatorId`, `member.isActive == true`, adult condition을 먼저 적용한다.
- 구매 내역 exists/join은 접근 권한 계산에만 사용하고 adult condition을 우회하지 않는다.
- 정렬은 `isFixed desc`, `fixedAt desc nullsLast`, `createdAt desc`, `id desc`를 사용한다.
- home summary 조회는 `findHomeCommunityPosts(creatorId, viewerId, isPinned, canViewAdultContent, limit)`로 제공하고, 기존 홈과 동일하게 고정글/일반글을 분리 조회한다.
- GREEN 실행:
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorChannelCommunityQueryRepositoryTest"`
- 기대 결과: `BUILD SUCCESSFUL`
- REFACTOR: `v2.creator.channel.community.adapter.out.persistence``v2.api.*`를 import하지 않는다.
- 검증 기록:
- RED: focused test 실행 결과 `DefaultCreatorChannelCommunityQueryRepository` 미구현으로 `compileTestKotlin` 실패를 확인했다.
- GREEN: repository 구현 추가 후 focused test 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 계약 보정: block fixture와 구현을 양방향 활성 차단 정책에 맞춘 뒤 focused test 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- Review follow-up RED: raw `createdAt`, 같은 `fixedAt` 고정글 `id desc`, 홈 구매자 비활성 게시글 비노출 테스트 추가 후 focused test 2건 실패를 확인했다.
- Review follow-up GREEN: repository 보정 후 focused test 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 전체 테스트: `./gradlew --no-daemon test` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- ktlint: `./gradlew --no-daemon ktlintCheck``DefaultCreatorChannelCommunityQueryRepository.kt` 1개 줄에서 처음 실패했고, formatting 후 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 범위: Phase 2 repository/test 파일만 추가했고 service/API/home refactor는 건드리지 않았다.
### Phase 3: 커뮤니티 조회 service 구현
- [x] **Task 3.1: `CreatorChannelCommunityQueryService` 단위 테스트 작성**
- Files:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt`
- RED: fake `CreatorChannelCommunityQueryPort`, mock `MemberContentPreferenceService`, mock `AudioContentCloudFront`, `LangContext`, `SodaMessageSource`를 사용해 아래 케이스를 작성한다.
- 요청 page/size fallback 결과를 port의 `offset`, `limit`에 전달하고 `hasNext`와 응답 목록 size를 조립한다.
- creator가 없으면 `member.validation.user_not_found` 예외를 던진다.
- creator role이 `CREATOR`가 아니면 `member.validation.creator_not_found` 예외를 던진다.
- 차단 관계가 있으면 기존 `explorer.creator.blocked_access` 메시지 예외를 던진다.
- `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의 마스킹 결과를 사용한다.
- `findHomeCommunityPosts`는 탭 전체 검증 없이 받은 `viewerId`, `canViewAdultContent`, `isPinned`, `limit`로 홈 요약 목록을 조립한다.
- RED 실행:
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest"`
- 기대 결과: service 미구현으로 컴파일 실패 또는 테스트 실패
- GREEN: `getCommunityTab(creatorId, viewer, page, size, now)``findHomeCommunityPosts(creatorId, viewerId, isPinned, canViewAdultContent, limit)`를 구현한다.
- GREEN 실행:
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest"`
- 기대 결과: `BUILD SUCCESSFUL`
- REFACTOR: signed URL 생성은 접근 가능한 오디오 path가 있을 때만 호출하고, 이미지는 signed URL 생성 대상에서 제외한다. service는 API DTO를 반환하지 않는다.
- 검증 기록:
- RED: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest"` 실행 결과 `CreatorChannelCommunityQueryService` 미구현 심볼로 `compileTestKotlin` 실패를 확인했다.
- GREEN: service 구현 추가 후 같은 focused test 실행 중 Phase 1 마스킹 정책 기대값(`15 code point 이하이면 앞 절반 + ...`)과 테스트 기대값 불일치 1건을 확인했고, 테스트 기대값을 정책에 맞춘 뒤 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 범위: Phase 3 service/test 파일만 추가했고 API DTO/controller/facade와 홈 API 연결은 건드리지 않았다.
### Phase 4: 홈 API 커뮤니티 조회 로직을 새 도메인으로 연결
- [x] **Task 4.1: 홈 service 회귀 테스트를 새 커뮤니티 service 의존으로 갱신**
- Files:
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHome.kt`
- RED: 기존 홈 service 테스트에서 home query port의 `findCommunityPosts` stub 대신 `CreatorChannelCommunityQueryService.findHomeCommunityPosts` 결과를 사용하도록 테스트를 먼저 바꾼다.
- `notices``isPinned=true`, `limit=3`으로 조회한다.
- `communities``isPinned=false`, `limit=3`으로 조회한다.
- 홈 응답의 커뮤니티 필드명은 유지하되, 커뮤니티 도메인 정책에 맞춰 유료 미구매 게시글의 `imageUrl`/`audioUrl``null`이고 `dateUtc`는 게시글 작성 시각(`createdAt`) 기준이다.
- RED 실행:
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest"`
- 기대 결과: service 생성자/호출부 미구현으로 컴파일 실패 또는 테스트 실패
- GREEN: `CreatorChannelHomeQueryService``CreatorChannelCommunityQueryService`를 주입하고, 기존 `queryPort.findCommunityPosts` 호출 2곳을 새 community service 호출로 교체한다.
- GREEN 실행:
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest"`
- 기대 결과: `BUILD SUCCESSFUL`
- REFACTOR: 홈 domain의 기존 `CreatorChannelCommunityPost` data class를 제거하고, 홈의 `notices`, `communities` 타입은 `kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityPost`를 사용한다. 홈 API response DTO 변환 결과가 바뀌지 않는지 테스트로 확인한다.
- 검증 기록:
- RED: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest"` 실행 결과 `communityQueryService` 생성자 파라미터 미구현, 홈/커뮤니티 post domain 타입 불일치, 기존 home port `findCommunityPosts` 잔존으로 `compileTestKotlin` 실패를 확인했다.
- GREEN: 홈 service가 `CreatorChannelCommunityQueryService.findHomeCommunityPosts``isPinned=true/false`, `limit=3`으로 호출하도록 교체하고, 홈 domain/DTO/test fixture를 커뮤니티 domain post 기준으로 보정한 뒤 같은 focused test `BUILD SUCCESSFUL`을 확인했다.
- 홈 API 회귀: 유료 미구매 홈 커뮤니티 게시글의 `imageUrl`/`audioUrl == null`, 고정글 `dateUtc == createdAt` 응답을 `CreatorChannelHomeControllerTest`, `CreatorChannelHomeFacadeTest`에 고정했고, 포함 회귀 focused test 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- [x] **Task 4.2: 홈 port/repository에서 커뮤니티 조회 책임 제거**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
- RED: 홈 repository 테스트에서 커뮤니티 게시글 조회 전용 테스트가 있으면 동일한 케이스가 `DefaultCreatorChannelCommunityQueryRepositoryTest`로 이동되어야 함을 먼저 확인한다.
- RED 실행:
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest"`
- 기대 결과: 기존 home port method 제거 전에는 테스트/컴파일이 아직 기존 구조를 기대해 실패할 수 있다.
- GREEN:
- `CreatorChannelHomeQueryPort.findCommunityPosts``CreatorChannelCommunityPostRecord`를 제거한다.
- `DefaultCreatorChannelHomeQueryRepository.findCommunityPosts`, `orderedCommunityPostIds`, `communityLikeCounts`, `communityCommentCounts`, 커뮤니티 전용 차단 sub query, `canAccessPaidCommunityContent`, `maskPaidCommunityContent`, `adultCommunityCondition`, `fixedNoticeCondition`, `visibleCommunityPostCondition` 중 홈 repository에서 더 이상 쓰지 않는 커뮤니티 전용 helper를 제거한다.
- 같은 로직은 `DefaultCreatorChannelCommunityQueryRepository`에만 남긴다.
- GREEN 실행:
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest"`
- 기대 결과: `BUILD SUCCESSFUL`
- REFACTOR: 홈 repository에서 `creatorCommunity`, `creatorCommunityLike`, `creatorCommunityComment`, `useCan` import가 더 이상 필요 없으면 제거한다. 다른 홈 조회에서 쓰는 import는 유지한다.
- 검증 기록:
- GREEN: `CreatorChannelHomeQueryPort.findCommunityPosts`, home 전용 `CreatorChannelCommunityPostRecord`, `DefaultCreatorChannelHomeQueryRepository.findCommunityPosts`와 커뮤니티 전용 helper/import 및 home repository의 직접 커뮤니티 조회 테스트를 제거했다.
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest"` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- `rg -n "CreatorChannelHomeQueryPort\.findCommunityPosts|CreatorChannelCommunityPostRecord|findCommunityPosts\(" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence` 결과 home port/repository의 커뮤니티 조회 책임 잔존 0건을 확인했다.
### Phase 5: 커뮤니티 탭 API 조립 계층 추가
- [x] **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 실행:
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.application.CreatorChannelCommunityFacadeTest"`
- 기대 결과: DTO/facade 미구현으로 컴파일 실패 또는 테스트 실패
- GREEN: PRD와 이 문서의 response data class 초안을 기준으로 DTO와 facade를 구현한다.
- GREEN 실행:
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.application.CreatorChannelCommunityFacadeTest"`
- 기대 결과: `BUILD SUCCESSFUL`
- REFACTOR: DTO는 공개 API 필드 변환만 담당하고, 구매/성인/정렬 정책을 포함하지 않는다.
- 검증 기록:
- RED: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.application.CreatorChannelCommunityFacadeTest"` 실행 결과 `CreatorChannelCommunityTabResponse`, `CreatorChannelCommunityFacade` 미구현 심볼로 `compileTestKotlin` 실패를 확인했다.
- GREEN: DTO/facade와 공용 `LocalDateTime.toUtcIso()` 확장함수를 추가한 뒤 같은 focused test 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 범위: 공개 API 응답 DTO 변환과 facade 위임만 추가했고 구매/성인/정렬 정책은 DTO에 넣지 않았다.
- [x] **Task 5.2: controller 테스트와 endpoint 구현**
- Files:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt`
- RED: `@WebMvcTest(CreatorChannelCommunityController::class)`와 기존 시리즈/오디오 controller test의 `TestSecurityConfig` 패턴으로 아래 케이스를 작성한다.
- 비회원 요청은 `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].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 미구현으로 컴파일 실패 또는 테스트 실패
- GREEN: `@RestController`, `@RequestMapping("/api/v2/creator-channels")`, `@GetMapping("/{creatorId}/community")`, `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member")` 패턴으로 구현한다.
- GREEN 실행:
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityControllerTest"`
- 기대 결과: `BUILD SUCCESSFUL`
- REFACTOR: 인증 null guard는 기존 탭 controller와 같은 `requireMember` private 함수로 둔다.
- 검증 기록:
- RED: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityControllerTest"` 실행 결과 `CreatorChannelCommunityController` 미구현 심볼로 `compileTestKotlin` 실패를 확인했다.
- GREEN: `GET /api/v2/creator-channels/{creatorId}/community` controller 구현 후 같은 focused test 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- Phase 5 focused 회귀: facade/controller focused tests 동시 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- ktlint: `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 의존 방향: `rg -n "kr\.co\.vividnext\.sodalive\.v2\.api\." src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community` 실행 결과 출력 없음으로 domain/query 계층의 API 의존 0건을 확인했다.
- 코드 리뷰 및 fresh 검증: controller는 기존 v2 탭 API와 같은 인증/`requireMember` 패턴으로 facade에 `creatorId`, `viewer`, raw `page`, raw `size`만 전달하고, facade/DTO는 query service 결과를 공개 응답 DTO로 변환만 하는 것을 확인했다. `LocalDateTime.toUtcIso()` 공용 확장함수는 기존 v2 DTO private 확장함수와 동일한 UTC offset 직렬화 방식임을 확인했다. `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.*"`, `./gradlew --no-daemon ktlintCheck`, `./gradlew --no-daemon test`, `git diff --check` 모두 `BUILD SUCCESSFUL` 또는 출력 없음으로 통과했고, 커뮤니티 domain/query 계층의 `v2.api.*` import 검색도 출력 없음으로 확인했다.
### Phase 6: E2E와 회귀 검증
- [x] **Task 6.1: 커뮤니티 탭 end-to-end 테스트 작성**
- Files:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt`
- RED: `@SpringBootTest`, `@AutoConfigureMockMvc`, `@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])`, `TransactionTemplate` 패턴으로 아래 케이스를 작성한다.
- controller-service-repository를 거쳐 전체 응답 필드를 반환한다.
- 고정 게시글이 일반 게시글보다 먼저 반환된다.
- `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 실행:
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest"`
- 기대 결과: API 미구현 또는 fixture 미연결로 실패
- GREEN: 필요한 fixture helper를 테스트 내부에 추가하고, `@MockBean AudioContentCloudFront`로 signed URL 결과를 `https://signed.test/community-audio`처럼 고정한다. E2E 테스트에서 실제 CloudFront private key 파일을 요구하지 않게 한다.
- GREEN 실행:
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest"`
- 기대 결과: `BUILD SUCCESSFUL`
- REFACTOR: E2E fixture는 테스트 내부 helper로 유지하고 운영 코드에 테스트 전용 분기를 넣지 않는다.
- 검증 기록:
- RED/GREEN: `CreatorChannelCommunityEndToEndTest`를 추가한 뒤 `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest"` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. 별도 production 수정 없이 즉시 GREEN이었으며, Phase 1-5 구현이 이미 endpoint 동작을 충족했기 때문으로 확인했다.
- 범위: `@SpringBootTest`, `@AutoConfigureMockMvc`, `EmbeddedRedisInitializer`, `TransactionTemplate`, `@MockBean AudioContentCloudFront` 패턴으로 controller-service-repository 실제 경로를 검증했다. 고정글 우선 정렬, `page=-1`/`size=10` fallback, 성인 콘텐츠 비노출, 구매/미구매 유료 이미지·오디오 접근, 이미지 없는 게시글 `imageUrl == null`을 E2E 응답으로 고정했고 운영 코드는 변경하지 않았다. 리뷰 후 기존 v2 E2E와 같은 shared H2 datasource를 사용하도록 보정하고, 미구매 오디오 signed URL 생성이 발생하면 실패하도록 `AudioContentCloudFront` interaction 검증을 추가한 뒤 focused E2E와 ktlint를 재실행해 `BUILD SUCCESSFUL`을 확인했다.
- [x] **Task 6.2: 홈 API 회귀와 의존 방향 검증**
- Files:
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt`
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt`
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt`
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
- RED: 홈 API 응답 스키마가 변경되지 않아야 하므로 기존 테스트가 실패하면 변경 원인을 확인한다.
- 검증 실행:
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.home.*" --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.*"`
- 기대 결과: `BUILD SUCCESSFUL`
- 의존 방향 검색:
- `rg -n "kr\\.co\\.vividnext\\.sodalive\\.v2\\.api\\." src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community`
- 기대 결과: 검색 결과 0건
- REFACTOR: 홈 API response DTO의 필드명, `dateUtc`, `existOrdered`, `likeCount`, `commentCount` 의미가 바뀌지 않도록 API DTO 변경을 피한다.
- 검증 기록:
- 홈 회귀: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.home.*" --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.*"` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 의존 방향: `rg -n "kr\.co\.vividnext\.sodalive\.v2\.api\." src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community` 실행 결과 출력 없음으로 community domain/query 계층의 API 의존 0건을 확인했다.
- 코드 리뷰 및 fresh 검증: 신규 E2E가 Phase 6 범위인 controller-service-repository 실제 경로, page/size fallback, 고정글 우선 정렬, 성인 콘텐츠 비노출, 구매/미구매 유료 미디어 접근, 홈 API 회귀, 의존 방향을 검증하는지 확인했고 추가 코드 이슈는 발견하지 않았다. `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest"`, `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.home.*" --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.*"`, `./gradlew --no-daemon ktlintCheck`, `./gradlew --no-daemon test`는 모두 `BUILD SUCCESSFUL`; `git diff --check`는 출력 없음, 커뮤니티 domain/query 계층의 `v2.api.*` import 검색도 출력 없음으로 확인했다.
### Phase 7: 전체 검증과 문서 갱신
- [x] **Task 7.1: 전체 테스트와 ktlint 검증**
- Files:
- Verify: `docs/20260621_크리에이터_채널_커뮤니티_탭_API/plan-task.md`
- 검증 실행:
- `./gradlew --no-daemon test`
- 기대 결과: `BUILD SUCCESSFUL`
- `./gradlew --no-daemon ktlintCheck`
- 기대 결과: `BUILD SUCCESSFUL`
- 문서 검증:
- 각 완료 task의 체크박스를 `- [x]`로 갱신한다.
- 각 task 아래에 무엇을, 왜, 어떻게 검증했는지, 실행 명령과 결과를 한국어로 누적 기록한다.
- 전체 검증 결과는 아래 `전체 검증 기록` 섹션에 누적한다.
- REFACTOR: 검증 실패가 구현 범위 변경을 요구하면 먼저 이 문서의 task를 갱신한 뒤 코드를 수정한다.
- 검증 기록:
- 전체 테스트: `./gradlew --no-daemon test` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- ktlint: `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 범위: Phase 7은 검증과 문서 갱신만 수행했고 production/test code는 변경하지 않았다.
---
## 5. 구현 순서 요약
1. Phase 1에서 순수 정책과 domain/port 계약을 먼저 고정한다.
2. Phase 2에서 QueryDSL repository를 새 커뮤니티 도메인으로 분리한다.
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 회귀를 확인한다.
7. Phase 7에서 전체 테스트, ktlint, 의존 방향 검색 결과를 누적 기록한다.
---
## 6. 전체 검증 기록
- 구현 전 문서 작성 단계에서는 코드 검증을 수행하지 않는다. 구현 단계에서 각 task 완료 즉시 실행 명령과 결과를 이 섹션에 누적한다.
- 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` 확인.
- 2026-06-21: Phase 2 Task 2.1 검증 - focused repository test는 RED에서 미구현 repository로 `compileTestKotlin` 실패, GREEN과 양방향 활성 차단 계약 보정 후 `BUILD SUCCESSFUL` 확인. Review follow-up에서 raw `createdAt`, 같은 `fixedAt` 고정글 `id desc`, 홈 구매자 비활성 게시글 비노출 테스트 추가 후 2건 실패를 확인했고 repository 보정 후 focused test `BUILD SUCCESSFUL` 확인. `./gradlew --no-daemon test``BUILD SUCCESSFUL` 확인. `./gradlew --no-daemon ktlintCheck`는 formatting 후 `BUILD SUCCESSFUL`, `rg -n "kr\.co\.vividnext\.sodalive\.v2\.api\." src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community`는 출력 없음 확인.
- 2026-06-21: Phase 3 Task 3.1 검증 - RED focused service test는 `CreatorChannelCommunityQueryService` 미구현 심볼로 `compileTestKotlin` 실패, GREEN focused service test는 `BUILD SUCCESSFUL` 확인. `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.*"`, `./gradlew --no-daemon test`, `./gradlew --no-daemon ktlintCheck` 모두 `BUILD SUCCESSFUL` 확인.
- 2026-06-21: Phase 4 Task 4.1 검증 - RED focused home service test는 `communityQueryService` 생성자 파라미터 미구현, 홈/커뮤니티 post domain 타입 불일치, 기존 home port `findCommunityPosts` 잔존으로 `compileTestKotlin` 실패 확인. GREEN focused home service test는 `BUILD SUCCESSFUL` 확인. 리뷰 후 홈 커뮤니티 정책을 유료 미구매 `imageUrl`/`audioUrl == null`, `dateUtc == createdAt`으로 명시하고 테스트에 고정했다.
- 2026-06-21: Phase 4 Task 4.2 검증 - focused home repository test는 `BUILD SUCCESSFUL` 확인. 홈/커뮤니티 회귀 focused test(`CreatorChannelHomeControllerTest`, `CreatorChannelHomeFacadeTest`, `CreatorChannelCommunityQueryServiceTest`, `DefaultCreatorChannelCommunityQueryRepositoryTest`)는 `BUILD SUCCESSFUL` 확인. `./gradlew --no-daemon ktlintCheck`는 import 정렬로 1회 실패 후 `./gradlew --no-daemon ktlintFormat` 적용 및 재실행 결과 `BUILD SUCCESSFUL` 확인. `./gradlew --no-daemon test` 전체 테스트 `BUILD SUCCESSFUL` 확인.
- 2026-06-21: Phase 5 Task 5.1 검증 - RED focused facade test는 `CreatorChannelCommunityTabResponse`, `CreatorChannelCommunityFacade` 미구현 심볼로 `compileTestKotlin` 실패 확인. GREEN focused facade test는 `BUILD SUCCESSFUL` 확인.
- 2026-06-21: Phase 5 Task 5.2 검증 - RED focused controller test는 `CreatorChannelCommunityController` 미구현 심볼로 `compileTestKotlin` 실패 확인. GREEN focused controller test는 `BUILD SUCCESSFUL` 확인. Phase 5 facade/controller focused tests 동시 실행, `./gradlew --no-daemon ktlintCheck`, `./gradlew --no-daemon test`는 모두 `BUILD SUCCESSFUL` 확인. 커뮤니티 domain/query 계층의 `v2.api.*` import 검색은 출력 없음 확인.
- 2026-06-21: Phase 5 코드 리뷰 및 fresh 검증 - controller/facade/DTO 구현이 Phase 5 범위인 인증 사용자 전달, raw page/size 전달, query service 위임, 공개 응답 변환에 머무는지 확인했고 추가 코드 이슈는 발견하지 않았다. `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.*"`, `./gradlew --no-daemon ktlintCheck`, `./gradlew --no-daemon test`는 모두 `BUILD SUCCESSFUL`; `git diff --check``rg -n "kr\.co\.vividnext\.sodalive\.v2\.api\." src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community`는 출력 없음 확인.
- 2026-06-22: Phase 6 Task 6.1/6.2 검증 - 커뮤니티 탭 E2E focused test, 홈 API 회귀 focused test, `./gradlew --no-daemon ktlintCheck`, `./gradlew --no-daemon test`는 모두 `BUILD SUCCESSFUL` 확인. `git diff --check`와 커뮤니티 domain/query 계층의 `v2.api.*` import 검색은 출력 없음 확인. 리뷰 후 shared H2 datasource와 오디오 signed URL interaction 검증을 보정했고, focused E2E와 ktlint 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 2026-06-22: Phase 6 코드 리뷰 및 fresh 검증 - 신규 E2E와 Phase 6 문서 기록을 재검토했고 추가 코드 이슈는 발견하지 않았다. focused 커뮤니티 E2E, 홈 API 회귀, `ktlintCheck`, 전체 테스트는 모두 `BUILD SUCCESSFUL`; `git diff --check`와 community domain/query 계층의 API 의존 검색은 출력 없음으로 확인했다.
- 2026-06-22: Phase 7 Task 7.1 검증 - `./gradlew --no-daemon test` 실행 결과 `BUILD SUCCESSFUL`, `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`을 확인했다. Phase 7은 전체 검증과 문서 갱신만 수행했고 production/test code는 변경하지 않았다.

View File

@@ -0,0 +1,254 @@
# PRD: 크리에이터 채널 커뮤니티 탭 API
## 1. Overview
크리에이터 채널의 커뮤니티 탭에서 조회자가 볼 수 있는 커뮤니티 게시글 전체 개수와 게시글 목록을 페이징 조회하는 API를 제공한다.
---
## 2. Problem
- 크리에이터 채널 홈 API는 커뮤니티 게시글 일부를 홈 화면 요약용으로 조회하지만, 커뮤니티 탭은 전체 개수와 페이징 목록이 필요하다.
- 기존 홈 API의 커뮤니티 조회 로직이 `home` 도메인 repository 안에 포함되어 있어, 커뮤니티 탭 API에서 그대로 재사용하려면 홈 도메인에 의존하게 된다.
- 커뮤니티 게시글 조회 로직은 홈 화면과 커뮤니티 탭에서 모두 쓰일 수 있으므로, 하나의 커뮤니티 조회 도메인으로 분리되어야 한다.
- 조회자의 성인 콘텐츠 노출 정책이 false이면 19금 커뮤니티 게시글은 전체 개수와 목록에서 모두 제외되어야 한다.
- legacy `/creator-community` 목록 조회는 구매 내역 조건과 성인 필터 조건이 섞일 수 있으므로, `isAdult=false` 조회에서 구매한 19금 게시글이 개수나 목록에 포함되지 않도록 새 v2 조회 정책에서 명확히 보장해야 한다.
- legacy 커뮤니티 목록은 유료 게시글을 구매하지 않은 조회자에게도 게시글 이미지를 노출했지만, 커뮤니티 탭 API는 유료 미구매 게시글의 이미지도 오디오와 동일하게 `null`로 내려줘야 한다.
---
## 3. Goals
- 크리에이터 채널 커뮤니티 탭 조회 API를 제공한다.
- API endpoint는 `GET /api/v2/creator-channels/{creatorId}/community`로 한다.
- 클라이언트에서 호출하는 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.community` 하위 조립 계층에 둔다.
- 커뮤니티 게시글 목록, 전체 개수, 구매 여부, 좋아요 수, 댓글 수, 성인 콘텐츠 노출, 유료 이미지/오디오 접근 정책은 API 패키지 밖 커뮤니티 도메인 패키지에 둔다.
- 크리에이터 채널 홈 API와 커뮤니티 탭 API는 동일한 커뮤니티 조회 도메인을 사용한다.
- 응답에는 조회 가능한 커뮤니티 게시글 전체 개수, 게시글 목록, page, size, hasNext를 포함한다.
- 게시글 목록 item에는 게시글 id, 크리에이터 id, 크리에이터 닉네임, 크리에이터 프로필 이미지 URL, 작성 시간 UTC, 게시글 본문, 이미지 URL, 오디오 URL, 가격, 댓글 쓰기 가능 여부, 구매 여부, 좋아요 개수, 댓글 개수, pin 여부를 포함한다.
- 유료 게시글의 이미지와 오디오 콘텐츠는 조회자가 구매했거나 게시글 작성자인 경우에만 내려준다.
- 유료 게시글을 구매하지 않은 조회자에게는 이미지 URL과 오디오 콘텐츠 URL을 `null`로 내려준다.
- 이미지가 없는 게시글은 `imageUrl``null`로 내려준다.
- 조회자의 성인 콘텐츠 노출 정책이 false이면 19금 게시글은 전체 개수와 목록에서 제외한다.
- 페이징 요청값은 기존 오디오/시리즈 탭 API와 같은 보정 규칙을 따른다.
---
## 4. Non-Goals
- 커뮤니티 게시글 작성, 수정, 삭제 API는 포함하지 않는다.
- 커뮤니티 게시글 구매 API는 포함하지 않는다.
- 커뮤니티 댓글 작성, 수정, 삭제, 목록 조회 API는 포함하지 않는다.
- 커뮤니티 좋아요 생성/취소 API는 포함하지 않는다.
- legacy `/creator-community` API의 공개 endpoint 변경은 포함하지 않는다.
- 크리에이터 채널 홈 API의 공개 응답 스키마 변경은 포함하지 않는다.
- 홈 API의 커뮤니티 노출 개수나 홈 화면 구성 정책 변경은 포함하지 않는다.
- DB schema, 운영 DDL, 마이그레이션은 포함하지 않는다.
- 앱 표시용 상대 시간 문구는 서버에서 새로 조합하지 않는다.
---
## 5. Target Users
- 회원: 크리에이터 채널 커뮤니티 탭에서 크리에이터의 커뮤니티 게시글을 탐색하는 사용자
- 앱 클라이언트: 커뮤니티 탭 구성에 필요한 전체 개수와 게시글 목록을 단일 API 응답으로 표시하려는 클라이언트
- 서버 개발자: 홈 API와 커뮤니티 탭 API에서 커뮤니티 조회 정책을 중복 없이 재사용하려는 개발자
---
## 6. User Stories
- 사용자는 크리에이터 채널 커뮤니티 탭에 들어가면 자신이 조회 가능한 게시글 전체 개수를 확인하고 싶다.
- 사용자는 커뮤니티 게시글을 최신순으로 추가 로딩하고 싶다.
- 성인 콘텐츠 노출이 꺼진 사용자는 19금 게시글이 개수와 목록에 포함되지 않기를 원한다.
- 사용자는 이미지가 없는 게시글도 정상적으로 목록에서 확인하고 싶다.
- 사용자는 구매한 유료 게시글의 오디오 콘텐츠를 재생할 수 있어야 한다.
- 구매하지 않은 사용자는 유료 게시글의 이미지 URL과 오디오 콘텐츠 URL을 받지 않아야 한다.
- 앱 클라이언트는 크리에이터 프로필 이미지, 댓글 작성 가능 여부, 구매 여부, 좋아요 개수, 댓글 개수, pin 여부를 게시글 item에서 바로 확인하고 싶다.
- 서버 개발자는 홈 API와 커뮤니티 탭 API가 동일한 커뮤니티 조회 도메인을 사용한다는 것을 패키지 의존 방향으로 확인하고 싶다.
---
## 7. Core Features
### Feature A. 크리에이터 채널 커뮤니티 탭 조회 API
#### Requirements
- 신규 API는 크리에이터 채널 전용 v2 API로 작성한다.
- API endpoint는 `GET /api/v2/creator-channels/{creatorId}/community`로 한다.
- `creatorId`는 path variable로 받는다.
- 커뮤니티 게시글 추가 로딩을 위해 `page`, `size` query parameter를 받는다.
- `page`는 0부터 시작하는 page index로 처리한다.
- `page`를 보내지 않으면 기본값 `0`을 사용한다.
- `size`를 보내지 않으면 기본값 `20`을 사용한다.
- `page`가 0보다 작으면 `0`으로 보정한다.
- `size`가 20보다 작으면 `20`으로 보정한다.
- `size`가 50보다 크면 `50`으로 보정한다.
- API는 인증 회원만 조회할 수 있어야 한다.
- 비회원이 조회하면 기존 인증 필요 API와 동일하게 `common.error.bad_credentials` 계열 오류를 반환한다.
- 조회 대상 회원이 존재하지 않으면 기존 정책과 동일하게 `member.validation.user_not_found` 계열 오류를 반환한다.
- 조회 대상 회원이 크리에이터가 아니면 기존 정책과 동일하게 `member.validation.creator_not_found` 계열 오류를 반환한다.
- 조회자와 크리에이터 사이에 차단 관계가 있으면 기존 크리에이터 채널 접근 정책과 동일하게 접근 차단 오류를 반환한다.
- 공개된 커뮤니티 게시글이 없어도 전체 API는 성공 처리한다.
#### Edge Cases
- 조회자 본인이 크리에이터인 경우에도 같은 응답 스키마를 사용한다.
- `page`가 0보다 작거나 `size`가 허용 범위를 벗어나도 400 오류를 반환하지 않고 실제 적용값으로 보정한다.
- 요청한 page 범위에 게시글이 없으면 `communityPosts`는 빈 배열, `hasNext``false`로 내려주되 `communityPostCount`는 전체 개수를 유지한다.
### Feature B. 응답 스키마
#### Requirements
- 응답 DTO는 구현 전에 명시하고 공개 API 계약으로 관리한다.
- 응답 최상위 DTO 이름은 `CreatorChannelCommunityTabResponse`로 한다.
- 응답에는 다음 값을 포함한다.
- `communityPostCount`: 조회자가 조회 가능한 커뮤니티 게시글 전체 개수
- `communityPosts`: 커뮤니티 게시글 목록
- `page`: 현재 응답의 page index
- `size`: 현재 응답의 page size
- `hasNext`: 다음 page 존재 여부
- `communityPostCount`는 목록 조회와 같은 공개 여부, 작성자, 성인 콘텐츠 노출, 차단 정책을 적용해 계산한다.
- `communityPostCount`에는 현재 page에 포함되지 않은 게시글도 포함한다.
- `communityPostCount`는 pinned 게시글과 일반 게시글을 모두 포함한 전체 개수다.
- `page`, `size`는 fallback 보정 이후 실제 적용된 값을 내려준다.
- `hasNext`는 같은 조건에서 다음 page에 노출할 게시글이 있으면 `true`로 내려준다.
- 응답 스키마 예시는 다음과 같다.
```kotlin
data class CreatorChannelCommunityTabResponse(
val communityPostCount: Int,
val communityPosts: List<CreatorChannelCommunityPostResponse>,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
)
data class CreatorChannelCommunityPostResponse(
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
)
```
#### Edge Cases
- 조회 가능한 커뮤니티 게시글이 없으면 `communityPostCount``0`, `communityPosts`는 빈 배열, `hasNext``false`로 내려준다.
- 이미지가 없는 게시글은 `imageUrl``null`로 내려준다.
- 유료 게시글을 구매하지 않았고 게시글 작성자도 아닌 조회자에게는 이미지가 있는 게시글이어도 `imageUrl``null`로 내려준다.
- 오디오가 없는 게시글은 `audioUrl``null`로 내려준다.
- `isCommentAvailable == false`인 게시글의 `commentCount`는 기존 커뮤니티 목록 정책과 동일하게 `0`으로 내려준다.
- Boolean 응답 필드는 Jackson 직렬화 시 `commentAvailable`, `pinned`로 바뀌지 않고 `isCommentAvailable`, `isPinned`로 내려가야 한다.
### Feature C. 커뮤니티 게시글 목록과 개수
#### Requirements
- 조회 대상은 지정한 `creatorId`가 작성한 커뮤니티 게시글로 제한한다.
- 활성 게시글만 조회한다.
- 조회자의 성인 콘텐츠 노출 정책이 false이면 19금 게시글은 목록에서 제외한다.
- 조회자의 성인 콘텐츠 노출 정책이 false이면 19금 게시글은 `communityPostCount`에서도 제외한다.
- 성인 콘텐츠 필터는 구매 여부보다 우선 적용한다.
- 조회자가 19금 게시글을 구매했더라도 성인 콘텐츠 노출 정책이 false이면 해당 게시글은 목록과 전체 개수에 포함하지 않는다.
- 목록은 pinned 게시글을 먼저 노출하고, 그 다음 일반 게시글을 노출한다.
- pinned 게시글 사이의 정렬은 `fixedAt desc`, `id desc`를 따른다.
- 일반 게시글 사이의 정렬은 `createdAt desc`, `id desc`를 따른다.
- 목록은 `page`, `size` 기준으로 페이징 조회한다.
- 다음 page 존재 여부는 `size + 1`개를 조회하거나 동등한 방식으로 판단하되, 응답 목록에는 최대 `size`개만 내려준다.
- `createdAtUtc`는 게시글 생성 시간을 UTC 기준 ISO-8601 문자열로 내려준다.
- `creatorProfileUrl`은 크리에이터 프로필 이미지 path가 있으면 기존 CDN URL 조합 정책으로 내려주고, 없으면 기본 프로필 이미지 URL을 내려준다.
- `existOrdered`는 조회자가 게시글 작성자이면 `true`, 조회자가 유효 구매 내역을 가지고 있으면 `true`, 그 외에는 `false`로 내려준다.
- `imageUrl`은 커뮤니티 게시글 이미지 path가 있고 조회자가 해당 게시글의 유료 미디어에 접근할 수 있을 때만 기존 CDN URL 조합 정책으로 내려준다.
- `likeCount`는 활성 좋아요 수를 기준으로 계산한다.
- `commentCount`는 조회자가 볼 수 있는 활성 최상위 댓글 수를 기준으로 계산한다.
- 댓글 수 계산에는 기존 커뮤니티 댓글의 차단 관계와 비밀 댓글 노출 정책을 적용한다.
#### Edge Cases
- pinned 게시글과 일반 게시글이 섞여 있어도 전체 목록은 하나의 페이징 결과로 내려준다.
- pinned 게시글 개수가 page size를 초과하면 첫 page는 pinned 게시글만 포함될 수 있다.
- 게시글 작성자가 조회자인 경우에도 성인 콘텐츠 노출 정책이 false이면 19금 게시글은 제외한다.
- 좋아요나 댓글이 없는 게시글은 `likeCount`, `commentCount``0`으로 내려준다.
### Feature D. 유료 이미지와 오디오 콘텐츠 접근 정책
#### Requirements
- 커뮤니티 게시글에 이미지 path가 없으면 `imageUrl``null`이다.
- 커뮤니티 게시글에 오디오 path가 없으면 `audioUrl``null`이다.
- 무료 게시글에 이미지 path가 있으면 CDN URL을 내려준다.
- 무료 게시글에 오디오 path가 있으면 signed URL을 내려준다.
- 유료 게시글에 이미지 path가 있고 조회자가 해당 게시글을 구매했으면 CDN URL을 내려준다.
- 유료 게시글에 오디오 path가 있고 조회자가 해당 게시글을 구매했으면 signed URL을 내려준다.
- 유료 게시글에 이미지 path가 있고 조회자가 게시글 작성자이면 CDN URL을 내려준다.
- 유료 게시글에 오디오 path가 있고 조회자가 게시글 작성자이면 signed URL을 내려준다.
- 유료 게시글에 이미지 path가 있지만 조회자가 구매하지 않았고 게시글 작성자도 아니면 `imageUrl``null`이다.
- 유료 게시글에 오디오 path가 있지만 조회자가 구매하지 않았고 게시글 작성자도 아니면 `audioUrl``null`이다.
- 이 이미지 제한 정책은 legacy `/creator-community` 목록의 기존 이미지 노출 동작과 다르며, 커뮤니티 탭 API에서는 오디오 접근 정책과 동일하게 적용한다.
- 이미지 URL은 signed URL로 만들지 않고 기존 CDN URL 조합 정책만 사용한다.
- 오디오 signed URL 생성은 기존 `AudioContentCloudFront.generateSignedURL` 방식을 재사용한다.
- 오디오 signed URL 만료 시간은 legacy 커뮤니티 목록 정책과 동일하게 30분을 기본으로 한다.
- 유료 게시글 본문은 기존 크리에이터 채널 홈 API의 유료 커뮤니티 본문 마스킹 정책을 따른다.
- 유료 게시글 이미지/오디오 접근 여부는 `CanUsage.PAID_COMMUNITY_POST`의 유효 구매 내역을 기준으로 판단한다.
- 환불된 구매 내역은 접근 가능 구매로 보지 않는다.
#### Edge Cases
- 조회자가 구매했더라도 성인 콘텐츠 노출 정책이 false인 19금 게시글은 목록에 포함되지 않으므로 이미지 URL과 오디오 signed URL도 내려주지 않는다.
- 구매 내역이 중복으로 있어도 응답 item은 게시글 1개로 중복 없이 내려준다.
- 이미지 path가 blank이면 `imageUrl``null`로 내려준다.
- 오디오 signed URL 생성 대상 path가 blank이면 `audioUrl``null`로 내려준다.
### Feature E. 커뮤니티 조회 도메인 분리
#### Requirements
- 커뮤니티 탭 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.community` 하위에 둔다.
- 커뮤니티 게시글 조회 service, 순수 정책, domain model, port, repository는 `kr.co.vividnext.sodalive.v2.creator.channel.community` 하위에 둔다.
- 도메인 조회 계층은 API response DTO를 import하지 않는다.
- 도메인 조회 계층은 API facade나 controller를 import하지 않는다.
- 의존 방향은 항상 `v2.api.creator.channel.community -> v2.creator.channel.community`이다.
- 크리에이터 채널 홈 API는 홈 도메인 내부에 커뮤니티 조회 쿼리를 직접 보유하지 않고, 분리된 커뮤니티 조회 도메인을 사용한다.
- 홈 API의 공개 응답 필드명과 필드 의미는 변경하지 않는다.
- 홈 API의 커뮤니티 요약 조회 limit와 notice 조회 정책은 기존 동작을 유지한다.
- legacy `kr.co.vividnext.sodalive.explorer.profile.creatorCommunity` 쓰기/상세/댓글/좋아요/구매 기능은 이번 분리 대상에 포함하지 않는다.
#### Edge Cases
- 홈 API와 커뮤니티 탭 API가 같은 domain model을 사용하더라도 각 API response DTO는 각 API 패키지에서 따로 소유한다.
- 커뮤니티 도메인 분리 과정에서 기존 홈 API controller mapping과 신규 커뮤니티 탭 controller mapping이 충돌하면 안 된다.
- 도메인 분리 후 `v2.creator.channel.community` 하위에서 `v2.api.*` import 검색 결과가 0건이어야 한다.
---
## 8. Technical Constraints
- 빌드 도구는 Gradle Wrapper(`./gradlew`)를 사용한다.
- Kotlin + Spring Boot 2.7.14 기존 스타일을 따른다.
- 신규 공개 API 스키마는 구현 전에 PRD와 구현 계획/TASK 문서에 명시한다.
- 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.community` 하위에 둔다.
- API 조립 계층은 HTTP 계약과 공개 응답 변환만 담당한다.
- 도메인 조회 코드는 `kr.co.vividnext.sodalive.v2.creator.channel.community` 하위에 둔다.
- 도메인 패키지는 `kr.co.vividnext.sodalive.v2.api.*` 패키지에 의존하지 않는다.
- 기존 크리에이터 채널 홈/라이브/오디오/시리즈 API의 인증, 예외, 성인 콘텐츠 노출, 차단 관계 정책을 재사용한다.
- 성인 콘텐츠 노출 여부는 기존 v2 탭 API와 동일하게 `MemberContentPreferenceService``isAdultVisibleByPolicy`를 기준으로 계산한다.
- 페이징 응답은 기존 오디오/시리즈 탭 API와 같은 `page`, `size`, `hasNext` 패턴을 따른다.
- 이미지 URL은 기존 `String?.toCdnUrl(cloudFrontHost)` 방식과 같은 CDN URL 조합 정책을 따른다.
- 오디오 URL은 콘텐츠 CloudFront signed URL 생성 정책을 따른다.
- `createdAtUtc` 변환은 기존에 재사용 가능한 `toUtcIso` 확장함수가 있으면 신규 private 확장함수를 만들지 않고 기존 확장함수를 사용한다.
- 날짜 응답은 UTC 기준 ISO-8601 문자열로 내려준다.
---
## 9. Metrics
- 커뮤니티 탭 API 성공/실패 건수
- 커뮤니티 탭 API 응답 시간
- 커뮤니티 탭 추가 로딩 요청 건수
- 성인 콘텐츠 노출 정책이 false인 조회에서 19금 게시글이 개수와 목록에 포함되지 않는 테스트 통과 여부
- 유료 게시글 이미지 CDN URL/null 처리와 오디오 signed URL/null 처리 테스트 통과 여부
- 홈 API 커뮤니티 요약 조회 회귀 테스트 통과 여부
- `v2.creator.channel.community` 도메인 패키지의 `v2.api.*` import 검색 결과 0건 여부
---
## 10. Open Questions
- 없음. 구현 중 새 정책 결정이 필요하면 구현 전에 이 PRD와 `plan-task.md`를 먼저 갱신한다.

View File

@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.extensions
import java.time.Duration
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZoneOffset
private val DEFAULT_KST_ZONE_ID: ZoneId = ZoneId.of("Asia/Seoul")
private val UTC_ZONE_ID: ZoneId = ZoneId.of("UTC")
@@ -26,3 +27,7 @@ fun LocalDateTime.convertToUtc(timeZone: ZoneId = DEFAULT_KST_ZONE_ID): LocalDat
.withZoneSameInstant(UTC_ZONE_ID)
.toLocalDateTime()
}
fun LocalDateTime.toUtcIso(): String {
return atOffset(ZoneOffset.UTC).toInstant().toString()
}

View File

@@ -0,0 +1,39 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.`in`.web
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.creator.channel.community.application.CreatorChannelCommunityFacade
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/v2/creator-channels")
class CreatorChannelCommunityController(
private val creatorChannelCommunityFacade: CreatorChannelCommunityFacade
) {
@GetMapping("/{creatorId}/community")
fun getCommunityTab(
@PathVariable creatorId: Long,
@RequestParam(required = false) page: Int?,
@RequestParam(required = false) size: Int?,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(
creatorChannelCommunityFacade.getCommunityTab(
creatorId = creatorId,
viewer = requireMember(member),
page = page,
size = size
)
)
}
private fun requireMember(member: Member?): Member {
return member ?: throw SodaException(messageKey = "common.error.bad_credentials")
}
}

View File

@@ -0,0 +1,32 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.community.application
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityTabResponse
import kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryService
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
@Service
@Transactional(readOnly = true)
class CreatorChannelCommunityFacade(
private val creatorChannelCommunityQueryService: CreatorChannelCommunityQueryService
) {
fun getCommunityTab(
creatorId: Long,
viewer: Member,
page: Int?,
size: Int?,
now: LocalDateTime = LocalDateTime.now()
): CreatorChannelCommunityTabResponse {
return CreatorChannelCommunityTabResponse.from(
creatorChannelCommunityQueryService.getCommunityTab(
creatorId = creatorId,
viewer = viewer,
page = page,
size = size,
now = now
)
)
}
}

View File

@@ -0,0 +1,67 @@
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
data class CreatorChannelCommunityTabResponse(
val communityPostCount: Int,
val communityPosts: List<CreatorChannelCommunityPostResponse>,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
) {
companion object {
fun from(tab: CreatorChannelCommunityTab): CreatorChannelCommunityTabResponse {
return CreatorChannelCommunityTabResponse(
communityPostCount = tab.communityPostCount,
communityPosts = tab.communityPosts.map(CreatorChannelCommunityPostResponse::from),
page = tab.page.page,
size = tab.page.size,
hasNext = tab.hasNext
)
}
}
}
data class CreatorChannelCommunityPostResponse(
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
) {
companion object {
fun from(post: CreatorChannelCommunityPost): CreatorChannelCommunityPostResponse {
return 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
)
}
}
}

View File

@@ -3,8 +3,8 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.home.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.v2.api.creator.channel.common.dto.CreatorChannelAudioContentResponse
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityPost
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelActivity
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCommunityPost
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCreator
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelDonation
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelFanTalk
@@ -195,7 +195,7 @@ data class CreatorChannelCommunityPostResponse(
audioUrl = post.audioUrl,
content = post.content,
price = post.price,
dateUtc = post.date.toUtcIso(),
dateUtc = post.createdAt.toUtcIso(),
existOrdered = post.existOrdered,
likeCount = post.likeCount,
commentCount = post.commentCount

View File

@@ -0,0 +1,5 @@
package kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityQueryPort
interface CreatorChannelCommunityQueryRepository : CreatorChannelCommunityQueryPort

View File

@@ -0,0 +1,340 @@
package kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence
import com.querydsl.core.Tuple
import com.querydsl.core.types.OrderSpecifier
import com.querydsl.core.types.dsl.BooleanExpression
import com.querydsl.core.types.dsl.CaseBuilder
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.can.use.QUseCan.useCan
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommunity.creatorCommunity
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.QCreatorCommunityComment.creatorCommunityComment
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.block.QBlockMember
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 org.springframework.stereotype.Repository
@Repository
class DefaultCreatorChannelCommunityQueryRepository(
private val queryFactory: JPAQueryFactory
) : CreatorChannelCommunityQueryRepository {
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCommunityCreatorRecord? {
val creator = queryFactory
.select(member.id, member.role, member.nickname)
.from(member)
.where(
member.id.eq(creatorId),
member.isActive.isTrue
)
.fetchFirst() ?: return null
return CreatorChannelCommunityCreatorRecord(
creatorId = creator.get(member.id)!!,
role = creator.get(member.role)!!,
nickname = creator.get(member.nickname)!!
)
}
override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean {
val blockMember = QBlockMember("creatorChannelCommunityBlockMember")
return queryFactory
.select(blockMember.id)
.from(blockMember)
.where(
blockMember.isActive.isTrue,
blockMember.member.id.eq(viewerId).and(blockMember.blockedMember.id.eq(creatorId))
.or(blockMember.member.id.eq(creatorId).and(blockMember.blockedMember.id.eq(viewerId)))
)
.fetchFirst() != null
}
override fun countCommunityPosts(
creatorId: Long,
viewerId: Long,
canViewAdultContent: Boolean
): Int {
return queryFactory
.select(creatorCommunity.id.count())
.from(creatorCommunity)
.where(communityPostCondition(creatorId, canViewAdultContent))
.fetchOne()
?.toInt()
?: 0
}
override fun findCommunityPosts(
creatorId: Long,
viewerId: Long,
canViewAdultContent: Boolean,
offset: Long,
limit: Int
): List<CreatorChannelCommunityPostRecord> {
val rows = queryFactory
.selectCommunityPostRow()
.from(creatorCommunity)
.where(communityPostCondition(creatorId, canViewAdultContent))
.orderBy(
CaseBuilder()
.`when`(creatorCommunity.isFixed.isTrue)
.then(1)
.otherwise(0)
.desc(),
creatorCommunity.fixedAt.desc().nullsLast(),
CaseBuilder()
.`when`(creatorCommunity.isFixed.isTrue)
.then(creatorCommunity.id)
.otherwise(0L)
.desc(),
creatorCommunity.createdAt.desc(),
creatorCommunity.id.desc()
)
.offset(offset)
.limit(limit.toLong())
.fetch()
return rows.toCommunityPostRecords(creatorId, viewerId)
}
override fun findHomeCommunityPosts(
creatorId: Long,
viewerId: Long,
isPinned: Boolean,
canViewAdultContent: Boolean,
limit: Int
): List<CreatorChannelCommunityPostRecord> {
val rows = queryFactory
.selectCommunityPostRow()
.from(creatorCommunity)
.where(
homeCommunityPostCondition(creatorId, viewerId, canViewAdultContent),
creatorCommunity.isFixed.eq(isPinned),
pinnedPostCondition(isPinned)
)
.orderBy(*homeCommunityPostOrder(isPinned))
.limit(limit.toLong())
.fetch()
return rows.toCommunityPostRecords(creatorId, viewerId)
}
private fun JPAQueryFactory.selectCommunityPostRow() = select(
creatorCommunity.id,
creatorCommunity.member.id,
creatorCommunity.member.nickname,
creatorCommunity.member.profileImage,
creatorCommunity.imagePath,
creatorCommunity.audioPath,
creatorCommunity.content,
creatorCommunity.price,
creatorCommunity.createdAt,
creatorCommunity.fixedAt,
creatorCommunity.isFixed,
creatorCommunity.isCommentAvailable
)
private fun communityPostCondition(creatorId: Long, canViewAdultContent: Boolean): BooleanExpression {
val condition = creatorCommunity.isActive.isTrue
.and(creatorCommunity.member.id.eq(creatorId))
.and(creatorCommunity.member.isActive.isTrue)
return if (canViewAdultContent) condition else condition.and(creatorCommunity.isAdult.isFalse)
}
private fun homeCommunityPostCondition(
creatorId: Long,
viewerId: Long,
canViewAdultContent: Boolean
): BooleanExpression {
val condition = creatorCommunity.member.id.eq(creatorId)
.and(creatorCommunity.member.isActive.isTrue)
.and(
creatorCommunity.isActive.isTrue.or(
queryFactory
.select(useCan.id)
.from(useCan)
.where(
useCan.member.id.eq(viewerId),
useCan.communityPost.id.eq(creatorCommunity.id),
useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST),
useCan.isRefund.isFalse
)
.exists()
)
)
return if (canViewAdultContent) condition else condition.and(creatorCommunity.isAdult.isFalse)
}
private fun pinnedPostCondition(isPinned: Boolean): BooleanExpression? {
return if (isPinned) creatorCommunity.fixedAt.isNotNull else null
}
private fun homeCommunityPostOrder(isPinned: Boolean): Array<OrderSpecifier<*>> {
return if (isPinned) {
arrayOf(creatorCommunity.fixedAt.desc(), creatorCommunity.id.desc())
} else {
arrayOf(creatorCommunity.createdAt.desc(), creatorCommunity.id.desc())
}
}
private fun List<Tuple>.toCommunityPostRecords(
creatorId: Long,
viewerId: Long
): List<CreatorChannelCommunityPostRecord> {
val postIds = map { it.postId }
val orderedPostIds = orderedCommunityPostIds(creatorId, viewerId, postIds)
val likeCounts = communityLikeCounts(postIds)
val commentAvailablePostIds = filter { it.isCommentAvailable }.map { it.postId }
val commentCounts = communityCommentCounts(commentAvailablePostIds, creatorId, viewerId)
return map { row ->
val postId = row.postId
val isPinned = row.isPinned
CreatorChannelCommunityPostRecord(
postId = postId,
creatorId = row.creatorId,
creatorNickname = row.creatorNickname,
creatorProfilePath = row.creatorProfilePath,
imagePath = row.imagePath,
audioPath = row.audioPath,
content = row.content,
price = row.price,
createdAt = row.createdAt,
existOrdered = postId in orderedPostIds,
isCommentAvailable = row.isCommentAvailable,
likeCount = likeCounts[postId] ?: 0,
commentCount = commentCounts[postId] ?: 0,
isPinned = isPinned
)
}
}
private fun orderedCommunityPostIds(
creatorId: Long,
viewerId: Long,
postIds: List<Long>
): Set<Long> {
if (postIds.isEmpty()) return emptySet()
if (creatorId == viewerId) return postIds.toSet()
return queryFactory
.select(useCan.communityPost.id)
.distinct()
.from(useCan)
.where(
useCan.member.id.eq(viewerId),
useCan.communityPost.id.`in`(postIds),
useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST),
useCan.isRefund.isFalse
)
.fetch()
.toSet()
}
private fun communityLikeCounts(postIds: List<Long>): Map<Long, Int> {
if (postIds.isEmpty()) return emptyMap()
return queryFactory
.select(creatorCommunityLike.creatorCommunity.id, creatorCommunityLike.id.count())
.from(creatorCommunityLike)
.where(
creatorCommunityLike.creatorCommunity.id.`in`(postIds),
creatorCommunityLike.isActive.isTrue
)
.groupBy(creatorCommunityLike.creatorCommunity.id)
.fetch()
.associate {
it.get(creatorCommunityLike.creatorCommunity.id)!! to (it.get(creatorCommunityLike.id.count())?.toInt() ?: 0)
}
}
private fun communityCommentCounts(
postIds: List<Long>,
creatorId: Long,
viewerId: Long
): Map<Long, Int> {
if (postIds.isEmpty()) return emptyMap()
return queryFactory
.select(creatorCommunityComment.creatorCommunity.id, creatorCommunityComment.id.count())
.from(creatorCommunityComment)
.where(
creatorCommunityComment.creatorCommunity.id.`in`(postIds),
creatorCommunityComment.isActive.isTrue,
creatorCommunityComment.parent.isNull,
visibleSecretCommentCondition(creatorId, viewerId),
notBlockedCommentWriterCondition(viewerId)
)
.groupBy(creatorCommunityComment.creatorCommunity.id)
.fetch()
.associate {
it.get(creatorCommunityComment.creatorCommunity.id)!! to
(it.get(creatorCommunityComment.id.count())?.toInt() ?: 0)
}
}
private fun visibleSecretCommentCondition(creatorId: Long, viewerId: Long): BooleanExpression {
return creatorCommunityComment.isSecret.isFalse
.or(
creatorCommunityComment.creatorCommunity.member.id.eq(creatorId)
.and(creatorCommunityComment.creatorCommunity.member.id.eq(viewerId))
)
.or(creatorCommunityComment.member.id.eq(viewerId))
}
private fun notBlockedCommentWriterCondition(viewerId: Long): BooleanExpression {
val viewerBlock = QBlockMember("communityCommentViewerBlockWriter")
val writerBlock = QBlockMember("communityCommentWriterBlockViewer")
return creatorCommunityComment.member.id.notIn(
queryFactory
.select(viewerBlock.blockedMember.id)
.from(viewerBlock)
.where(viewerBlock.member.id.eq(viewerId), viewerBlock.isActive.isTrue)
).and(
creatorCommunityComment.member.id.notIn(
queryFactory
.select(writerBlock.member.id)
.from(writerBlock)
.where(writerBlock.blockedMember.id.eq(viewerId), writerBlock.isActive.isTrue)
)
)
}
private val Tuple.postId: Long
get() = get(creatorCommunity.id)!!
private val Tuple.creatorId: Long
get() = get(creatorCommunity.member.id)!!
private val Tuple.creatorNickname: String
get() = get(creatorCommunity.member.nickname)!!
private val Tuple.creatorProfilePath: String?
get() = get(creatorCommunity.member.profileImage)
private val Tuple.imagePath: String?
get() = get(creatorCommunity.imagePath)
private val Tuple.audioPath: String?
get() = get(creatorCommunity.audioPath)
private val Tuple.content: String
get() = get(creatorCommunity.content)!!
private val Tuple.price: Int
get() = get(creatorCommunity.price)!!
private val Tuple.createdAt
get() = get(creatorCommunity.createdAt)!!
private val Tuple.fixedAt
get() = get(creatorCommunity.fixedAt)
private val Tuple.isPinned: Boolean
get() = get(creatorCommunity.isFixed)!!
private val Tuple.isCommentAvailable: Boolean
get() = get(creatorCommunity.isCommentAvailable)!!
}

View File

@@ -0,0 +1,140 @@
package kr.co.vividnext.sodalive.v2.creator.channel.community.application
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityPost
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicy
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityTab
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityPostRecord
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityQueryPort
import org.springframework.beans.factory.ObjectProvider
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
@Service
@Transactional(readOnly = true)
class CreatorChannelCommunityQueryService(
private val queryPortProvider: ObjectProvider<CreatorChannelCommunityQueryPort>,
private val queryPolicy: CreatorChannelCommunityQueryPolicy,
private val memberContentPreferenceService: MemberContentPreferenceService,
private val audioContentCloudFront: AudioContentCloudFront,
private val messageSource: SodaMessageSource,
private val langContext: LangContext,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) {
fun getCommunityTab(
creatorId: Long,
viewer: Member,
page: Int?,
size: Int?,
now: LocalDateTime = LocalDateTime.now()
): CreatorChannelCommunityTab {
val communityPage = queryPolicy.createPage(page, size)
val queryPort = queryPortProvider.getObject()
val viewerId = viewer.id!!
val creator = queryPort.findCreator(creatorId, viewerId)
?: throw SodaException(messageKey = "member.validation.user_not_found")
if (queryPort.existsBlockedBetween(viewerId, creatorId)) {
val messageTemplate = messageSource
.getMessage("explorer.creator.blocked_access", langContext.lang)
.orEmpty()
throw SodaException(message = String.format(messageTemplate, creator.nickname))
}
validateCreatorRole(creator)
val preference = memberContentPreferenceService.getStoredPreference(viewer)
val canViewAdultContent = isAdultVisibleByPolicy(viewer, preference.isAdultContentVisible)
val fetchedPosts = queryPort.findCommunityPosts(
creatorId = creatorId,
viewerId = viewerId,
canViewAdultContent = canViewAdultContent,
offset = communityPage.offset,
limit = communityPage.fetchLimit
)
return CreatorChannelCommunityTab(
communityPostCount = queryPort.countCommunityPosts(
creatorId = creatorId,
viewerId = viewerId,
canViewAdultContent = canViewAdultContent
),
communityPosts = queryPolicy.limitItems(fetchedPosts, communityPage).map { it.toDomain(viewerId) },
page = communityPage,
hasNext = queryPolicy.hasNext(fetchedPosts, communityPage)
)
}
fun findHomeCommunityPosts(
creatorId: Long,
viewerId: Long,
isPinned: Boolean,
canViewAdultContent: Boolean,
limit: Int
): List<CreatorChannelCommunityPost> {
return queryPortProvider.getObject()
.findHomeCommunityPosts(
creatorId = creatorId,
viewerId = viewerId,
isPinned = isPinned,
canViewAdultContent = canViewAdultContent,
limit = limit
)
.map { it.toDomain(viewerId) }
}
private fun validateCreatorRole(creator: CreatorChannelCommunityCreatorRecord) {
when (creator.role) {
MemberRole.CREATOR -> return
else -> throw SodaException(messageKey = "member.validation.creator_not_found")
}
}
private fun CreatorChannelCommunityPostRecord.toDomain(viewerId: Long): CreatorChannelCommunityPost {
val canAccessPaidContent = price <= 0 || viewerId == creatorId || existOrdered
return CreatorChannelCommunityPost(
postId = postId,
creatorId = creatorId,
creatorNickname = creatorNickname,
creatorProfileUrl = creatorProfilePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(),
imageUrl = if (canAccessPaidContent) imagePath.toCdnUrl(cloudFrontHost) else null,
audioUrl = if (canAccessPaidContent) audioPath.toSignedAudioUrl() else null,
content = queryPolicy.maskPaidContent(
content = content,
price = price,
isCreatorSelf = viewerId == creatorId,
existOrdered = existOrdered
),
price = price,
createdAt = createdAt,
existOrdered = existOrdered || viewerId == creatorId,
isCommentAvailable = isCommentAvailable,
likeCount = likeCount,
commentCount = commentCount,
isPinned = isPinned
)
}
private fun String?.toSignedAudioUrl(): String? {
if (isNullOrBlank()) return null
return audioContentCloudFront.generateSignedURL(this, AUDIO_SIGNED_URL_EXPIRATION_MILLIS)
}
private fun defaultProfileImageUrl(): String = "$cloudFrontHost/profile/default-profile.png"
companion object {
private const val AUDIO_SIGNED_URL_EXPIRATION_MILLIS = 1000L * 60 * 30
}
}

View File

@@ -0,0 +1,52 @@
package kr.co.vividnext.sodalive.v2.creator.channel.community.domain
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
import org.springframework.stereotype.Component
@Component
class CreatorChannelCommunityQueryPolicy {
fun createPage(page: Int?, size: Int?): CreatorChannelPage {
return CreatorChannelPage(
page = page?.coerceAtLeast(MIN_PAGE) ?: DEFAULT_PAGE,
size = size?.coerceIn(MIN_PAGE_SIZE, MAX_PAGE_SIZE) ?: DEFAULT_PAGE_SIZE
)
}
fun <T> limitItems(fetched: List<T>, page: CreatorChannelPage): List<T> {
return fetched.take(page.size)
}
fun hasNext(fetched: List<*>, page: CreatorChannelPage): Boolean {
return fetched.size > page.size
}
fun maskPaidContent(
content: String,
price: Int,
isCreatorSelf: Boolean,
existOrdered: Boolean
): String {
if (price <= 0 || isCreatorSelf || existOrdered) {
return content
}
val codePointCount = content.codePointCount(0, content.length)
val visibleCodePointCount = if (codePointCount > PAID_CONTENT_PREVIEW_CODE_POINTS) {
PAID_CONTENT_PREVIEW_CODE_POINTS
} else {
codePointCount / 2
}
val endIndex = content.offsetByCodePoints(0, visibleCodePointCount)
return content.substring(0, endIndex) + PAID_CONTENT_MASK_SUFFIX
}
companion object {
private const val DEFAULT_PAGE = 0
private const val DEFAULT_PAGE_SIZE = 20
private const val MIN_PAGE = 0
private const val MIN_PAGE_SIZE = 20
private const val MAX_PAGE_SIZE = 50
private const val PAID_CONTENT_PREVIEW_CODE_POINTS = 15
private const val PAID_CONTENT_MASK_SUFFIX = "..."
}
}

View File

@@ -0,0 +1,28 @@
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 CreatorChannelCommunityTab(
val communityPostCount: Int,
val communityPosts: List<CreatorChannelCommunityPost>,
val page: CreatorChannelPage,
val hasNext: Boolean
)
data class CreatorChannelCommunityPost(
val postId: Long,
val creatorId: Long,
val creatorNickname: String,
val creatorProfileUrl: String,
val imageUrl: String?,
val audioUrl: String?,
val content: String,
val price: Int,
val createdAt: LocalDateTime,
val existOrdered: Boolean,
val isCommentAvailable: Boolean,
val likeCount: Int,
val commentCount: Int,
val isPinned: Boolean
)

View File

@@ -0,0 +1,55 @@
package kr.co.vividnext.sodalive.v2.creator.channel.community.port.out
import kr.co.vividnext.sodalive.member.MemberRole
import java.time.LocalDateTime
interface CreatorChannelCommunityQueryPort {
fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCommunityCreatorRecord?
fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean
fun countCommunityPosts(
creatorId: Long,
viewerId: Long,
canViewAdultContent: Boolean
): Int
fun findCommunityPosts(
creatorId: Long,
viewerId: Long,
canViewAdultContent: Boolean,
offset: Long,
limit: Int
): List<CreatorChannelCommunityPostRecord>
fun findHomeCommunityPosts(
creatorId: Long,
viewerId: Long,
isPinned: Boolean,
canViewAdultContent: Boolean,
limit: Int
): List<CreatorChannelCommunityPostRecord>
}
data class CreatorChannelCommunityCreatorRecord(
val creatorId: Long,
val role: MemberRole,
val nickname: String
)
data class CreatorChannelCommunityPostRecord(
val postId: Long,
val creatorId: Long,
val creatorNickname: String,
val creatorProfilePath: String?,
val imagePath: String?,
val audioPath: String?,
val content: String,
val price: Int,
val createdAt: LocalDateTime,
val existOrdered: Boolean,
val isCommentAvailable: Boolean,
val likeCount: Int,
val commentCount: Int,
val isPinned: Boolean
)

View File

@@ -4,8 +4,6 @@ import com.querydsl.core.types.Projections
import com.querydsl.core.types.dsl.BooleanExpression
import com.querydsl.core.types.dsl.Expressions
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.can.use.QUseCan.useCan
import kr.co.vividnext.sodalive.chat.character.QChatCharacter.chatCharacter
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
@@ -15,9 +13,6 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
import kr.co.vividnext.sodalive.explorer.profile.QCreatorCheers.creatorCheers
import kr.co.vividnext.sodalive.explorer.profile.channelDonation.QChannelDonationMessage.channelDonationMessage
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommunity.creatorCommunity
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.QCreatorCommunityComment.creatorCommunityComment
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.QCreatorCommunityLike.creatorCommunityLike
import kr.co.vividnext.sodalive.extensions.removeDeletedNicknamePrefix
import kr.co.vividnext.sodalive.live.room.GenderRestriction
import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom
@@ -30,7 +25,6 @@ import kr.co.vividnext.sodalive.member.following.QCreatorFollowing.creatorFollow
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelActivityRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelAudioContentRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelCommunityPostRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelDonationRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelFanTalkRecord
@@ -207,90 +201,6 @@ class DefaultCreatorChannelHomeQueryRepository(
.fetch()
}
override fun findCommunityPosts(
creatorId: Long,
viewerId: Long?,
isFixed: Boolean,
canViewAdultContent: Boolean,
limit: Int
): List<CreatorChannelCommunityPostRecord> {
val posts = queryFactory
.select(
creatorCommunity.id,
creatorCommunity.member.id,
creatorCommunity.member.nickname,
creatorCommunity.member.profileImage,
creatorCommunity.imagePath,
creatorCommunity.audioPath,
creatorCommunity.content,
creatorCommunity.price,
creatorCommunity.createdAt,
creatorCommunity.fixedAt,
creatorCommunity.isFixed,
creatorCommunity.isCommentAvailable
)
.from(creatorCommunity)
.where(
creatorCommunity.member.id.eq(creatorId),
creatorCommunity.member.isActive.isTrue,
visibleCommunityPostCondition(viewerId),
creatorCommunity.isFixed.eq(isFixed),
fixedNoticeCondition(isFixed),
adultCommunityCondition(canViewAdultContent)
)
.orderBy(
if (isFixed) creatorCommunity.fixedAt.desc() else creatorCommunity.createdAt.desc(),
creatorCommunity.id.desc()
)
.limit(limit.toLong())
.fetch()
val postIds = posts.map { it.get(creatorCommunity.id)!! }
val orderedPostIds = orderedCommunityPostIds(creatorId, viewerId, postIds)
val likeCounts = communityLikeCounts(postIds)
val commentCounts = communityCommentCounts(
postIds = posts.filter { it.get(creatorCommunity.isCommentAvailable)!! }.map { it.get(creatorCommunity.id)!! },
viewerId = viewerId,
isContentCreator = viewerId == creatorId
)
return posts
.map {
val postId = it.get(creatorCommunity.id)!!
val postCreatorId = it.get(creatorCommunity.member.id)!!
val isFixedPost = it.get(creatorCommunity.isFixed)!!
val price = it.get(creatorCommunity.price)!!
val existOrdered = postId in orderedPostIds
val canAccessPaidContent = canAccessPaidCommunityContent(
price = price,
viewerId = viewerId,
creatorId = postCreatorId,
existOrdered = existOrdered
)
CreatorChannelCommunityPostRecord(
postId = postId,
creatorId = postCreatorId,
creatorNickname = it.get(creatorCommunity.member.nickname)!!,
creatorProfilePath = it.get(creatorCommunity.member.profileImage),
imagePath = it.get(creatorCommunity.imagePath),
audioPath = if (canAccessPaidContent) it.get(creatorCommunity.audioPath) else null,
content = maskPaidCommunityContent(
content = it.get(creatorCommunity.content)!!,
canAccessPaidContent = canAccessPaidContent
),
price = price,
date = if (isFixedPost) {
it.get(creatorCommunity.fixedAt) ?: it.get(creatorCommunity.createdAt)!!
} else {
it.get(creatorCommunity.createdAt)!!
},
existOrdered = existOrdered,
likeCount = likeCounts[postId] ?: 0,
commentCount = commentCounts[postId] ?: 0
)
}
}
override fun findSchedules(
creatorId: Long,
now: LocalDateTime,
@@ -639,103 +549,6 @@ class DefaultCreatorChannelHomeQueryRepository(
.fetchFirst()
}
private fun orderedCommunityPostIds(creatorId: Long, viewerId: Long?, postIds: List<Long>): Set<Long> {
if (viewerId == null || postIds.isEmpty()) return emptySet()
if (viewerId == creatorId) return postIds.toSet()
return queryFactory
.select(useCan.communityPost.id)
.from(useCan)
.where(
useCan.member.id.eq(viewerId),
useCan.communityPost.id.`in`(postIds),
useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST),
useCan.isRefund.isFalse
)
.fetch()
.toSet()
}
private fun communityLikeCounts(postIds: List<Long>): Map<Long, Int> {
if (postIds.isEmpty()) return emptyMap()
return queryFactory
.select(creatorCommunityLike.creatorCommunity.id, creatorCommunityLike.id.count())
.from(creatorCommunityLike)
.where(creatorCommunityLike.creatorCommunity.id.`in`(postIds), creatorCommunityLike.isActive.isTrue)
.groupBy(creatorCommunityLike.creatorCommunity.id)
.fetch()
.associate {
it.get(creatorCommunityLike.creatorCommunity.id)!! to
(it.get(creatorCommunityLike.id.count())?.toInt() ?: 0)
}
}
private fun communityCommentCounts(postIds: List<Long>, viewerId: Long?, isContentCreator: Boolean): Map<Long, Int> {
if (postIds.isEmpty()) return emptyMap()
var where = creatorCommunityComment.creatorCommunity.id.`in`(postIds)
.and(creatorCommunityComment.isActive.isTrue)
.and(creatorCommunityComment.parent.isNull)
if (viewerId != null) {
where = where
.and(creatorCommunityComment.member.id.notIn(blockedMemberIdSubQuery(viewerId)))
.and(creatorCommunityComment.member.id.notIn(blockingMemberIdSubQuery(viewerId)))
}
if (!isContentCreator) {
where = where.and(
creatorCommunityComment.isSecret.isFalse.or(
viewerId?.let { creatorCommunityComment.member.id.eq(it) }
?: creatorCommunityComment.isSecret.isFalse
)
)
}
return queryFactory
.select(creatorCommunityComment.creatorCommunity.id, creatorCommunityComment.id.count())
.from(creatorCommunityComment)
.where(where)
.groupBy(creatorCommunityComment.creatorCommunity.id)
.fetch()
.associate {
it.get(creatorCommunityComment.creatorCommunity.id)!! to
(it.get(creatorCommunityComment.id.count())?.toInt() ?: 0)
}
}
private fun blockedMemberIdSubQuery(viewerId: Long) = QBlockMember("communityCommentViewerBlock").let { viewerBlock ->
queryFactory
.select(viewerBlock.blockedMember.id)
.from(viewerBlock)
.where(viewerBlock.member.id.eq(viewerId), viewerBlock.isActive.isTrue)
}
private fun blockingMemberIdSubQuery(viewerId: Long) = QBlockMember("communityCommentWriterBlock").let { writerBlock ->
queryFactory
.select(writerBlock.member.id)
.from(writerBlock)
.where(writerBlock.blockedMember.id.eq(viewerId), writerBlock.isActive.isTrue)
}
private fun canAccessPaidCommunityContent(
price: Int,
viewerId: Long?,
creatorId: Long,
existOrdered: Boolean
): Boolean {
return price <= 0 || viewerId == creatorId || existOrdered
}
private fun maskPaidCommunityContent(content: String, canAccessPaidContent: Boolean): String {
if (canAccessPaidContent) return content
val length = content.codePointCount(0, content.length)
val endIndex = if (length > 15) {
content.offsetByCodePoints(0, 15)
} else {
content.offsetByCodePoints(0, length / 2)
}
return content.substring(0, endIndex).plus("...")
}
private fun firstAudioDebutAt(creatorId: Long, now: LocalDateTime): LocalDateTime? {
val firstThreeUploads = queryFactory
.select(audioContent.releaseDate, audioContent.createdAt)
@@ -793,31 +606,6 @@ class DefaultCreatorChannelHomeQueryRepository(
return liveRoom.isAvailableJoinCreator.isTrue.or(liveRoom.member.id.eq(viewerId))
}
private fun adultCommunityCondition(canViewAdultContent: Boolean): BooleanExpression? {
return if (canViewAdultContent) null else creatorCommunity.isAdult.isFalse
}
private fun fixedNoticeCondition(isFixed: Boolean): BooleanExpression? {
return if (isFixed) creatorCommunity.fixedAt.isNotNull else null
}
private fun visibleCommunityPostCondition(viewerId: Long?): BooleanExpression {
val activePost = creatorCommunity.isActive.isTrue
if (viewerId == null) return activePost
return activePost.or(
queryFactory
.select(useCan.id)
.from(useCan)
.where(
useCan.member.id.eq(viewerId),
useCan.communityPost.id.eq(creatorCommunity.id),
useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST),
useCan.isRefund.isFalse
)
.exists()
)
}
private fun adultSeriesCondition(canViewAdultContent: Boolean): BooleanExpression? {
return if (canViewAdultContent) null else series.isAdult.isFalse
}

View File

@@ -10,8 +10,8 @@ import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreference
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
import kr.co.vividnext.sodalive.v2.creator.channel.common.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryService
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelActivity
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCommunityPost
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCreator
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelDonation
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelFanTalk
@@ -24,7 +24,6 @@ import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelSer
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelSns
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelActivityRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelAudioContentRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelCommunityPostRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelDonationRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelFanTalkRecord
@@ -43,6 +42,7 @@ import java.time.LocalDateTime
@Transactional(readOnly = true)
class CreatorChannelHomeQueryService(
private val queryPort: CreatorChannelHomeQueryPort,
private val communityQueryService: CreatorChannelCommunityQueryService,
private val queryPolicy: CreatorChannelHomeQueryPolicy,
private val memberContentPreferenceService: MemberContentPreferenceService,
private val messageSource: SodaMessageSource,
@@ -98,12 +98,13 @@ class CreatorChannelHomeQueryService(
)?.toDomain(),
latestAudioContent = latestAudioContent,
channelDonations = queryPort.findChannelDonations(creatorId, viewerId, now).map { it.toDomain() },
notices = queryPort.findCommunityPosts(
notices = communityQueryService.findHomeCommunityPosts(
creatorId = creatorId,
viewerId = viewerId,
isFixed = true,
canViewAdultContent = canViewAdultContent
).map { it.toDomain() },
isPinned = true,
canViewAdultContent = canViewAdultContent,
limit = 3
),
schedules = queryPolicy.limitSchedules(
queryPort.findSchedules(
creatorId = creatorId,
@@ -124,12 +125,13 @@ class CreatorChannelHomeQueryService(
canViewAdultContent = canViewAdultContent,
contentType = preference.contentType
).map { it.toDomain() },
communities = queryPort.findCommunityPosts(
communities = communityQueryService.findHomeCommunityPosts(
creatorId = creatorId,
viewerId = viewerId,
isFixed = false,
canViewAdultContent = canViewAdultContent
).map { it.toDomain() },
isPinned = false,
canViewAdultContent = canViewAdultContent,
limit = 3
),
fanTalk = queryPort.findFanTalkSummary(creatorId, viewerId).toDomain(),
introduce = creator.introduce,
activity = queryPort.findActivity(creatorId, now).toDomain(),
@@ -210,21 +212,6 @@ class CreatorChannelHomeQueryService(
isOriginal = isOriginal
)
private fun CreatorChannelCommunityPostRecord.toDomain() = CreatorChannelCommunityPost(
postId = postId,
creatorId = creatorId,
creatorNickname = creatorNickname,
creatorProfileUrl = creatorProfilePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(),
imageUrl = imagePath.toCdnUrl(cloudFrontHost),
audioUrl = audioPath.toCdnUrl(cloudFrontHost),
content = content,
price = price,
date = date,
existOrdered = existOrdered,
likeCount = likeCount,
commentCount = commentCount
)
private fun CreatorChannelFanTalkSummaryRecord.toDomain() = CreatorChannelFanTalkSummary(
totalCount = totalCount,
latestFanTalk = latestFanTalk?.toDomain()

View File

@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.creator.channel.home.domain
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.creator.channel.common.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityPost
import java.time.LocalDateTime
data class CreatorChannelHome(
@@ -66,21 +67,6 @@ data class CreatorChannelSeries(
val isOriginal: Boolean
)
data class CreatorChannelCommunityPost(
val postId: Long,
val creatorId: Long,
val creatorNickname: String,
val creatorProfileUrl: String,
val imageUrl: String?,
val audioUrl: String?,
val content: String,
val price: Int,
val date: LocalDateTime,
val existOrdered: Boolean,
val likeCount: Int,
val commentCount: Int
)
data class CreatorChannelFanTalkSummary(
val totalCount: Int,
val latestFanTalk: CreatorChannelFanTalk?

View File

@@ -34,14 +34,6 @@ interface CreatorChannelHomeQueryPort {
limit: Int = 8
): List<CreatorChannelDonationRecord>
fun findCommunityPosts(
creatorId: Long,
viewerId: Long?,
isFixed: Boolean,
canViewAdultContent: Boolean,
limit: Int = 3
): List<CreatorChannelCommunityPostRecord>
fun findSchedules(
creatorId: Long,
now: LocalDateTime,
@@ -140,21 +132,6 @@ data class CreatorChannelSeriesRecord(
val isOriginal: Boolean
)
data class CreatorChannelCommunityPostRecord(
val postId: Long,
val creatorId: Long,
val creatorNickname: String,
val creatorProfilePath: String?,
val imagePath: String?,
val audioPath: String?,
val content: String,
val price: Int,
val date: LocalDateTime,
val existOrdered: Boolean,
val likeCount: Int,
val commentCount: Int
)
data class CreatorChannelFanTalkSummaryRecord(
val totalCount: Int,
val latestFanTalk: CreatorChannelFanTalkRecord?

View File

@@ -0,0 +1,195 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.`in`.web
import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
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.dto.CreatorChannelCommunityPostResponse
import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityTabResponse
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Import
import org.springframework.http.HttpStatus
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.HttpStatusEntryPoint
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import java.time.LocalDateTime
import javax.servlet.http.HttpServletResponse
@WebMvcTest(CreatorChannelCommunityController::class)
@Import(CreatorChannelCommunityControllerTest.TestSecurityConfig::class)
class CreatorChannelCommunityControllerTest @Autowired constructor(
private val mockMvc: MockMvc
) {
@MockBean
private lateinit var facade: CreatorChannelCommunityFacade
@MockBean
private lateinit var countryContext: CountryContext
@MockBean
private lateinit var langContext: LangContext
@MockBean
private lateinit var sodaMessageSource: SodaMessageSource
@TestConfiguration
class TestSecurityConfig {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
return http
.csrf().disable()
.authorizeRequests()
.anyRequest().authenticated()
.and()
.exceptionHandling()
.authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
.accessDeniedHandler { _, response, _ -> response.sendError(HttpServletResponse.SC_FORBIDDEN) }
.and()
.build()
}
}
@Test
@DisplayName("크리에이터 채널 커뮤니티 탭 조회는 비회원 요청을 거부한다")
fun shouldRejectAnonymousCreatorChannelCommunityRequest() {
mockMvc.perform(
get("/api/v2/creator-channels/1/community")
.with(anonymous())
)
.andExpect(status().isUnauthorized)
}
@Test
@DisplayName("크리에이터 채널 커뮤니티 탭 조회는 query parameter를 facade에 전달하고 성공 응답을 반환한다")
fun shouldReturnCreatorChannelCommunityTabForAuthenticatedMember() {
val viewer = createMember(id = 10L)
Mockito.doReturn(createResponse(page = 1, size = 20)).`when`(facade).getCommunityTab(
eqValue(1L),
eqValue(viewer),
eqValue(1),
eqValue(20),
anyValue(LocalDateTime.now())
)
mockMvc.perform(
get("/api/v2/creator-channels/1/community")
.param("page", "1")
.param("size", "20")
.with(user(MemberAdapter(viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.communityPostCount").value(2))
.andExpect(jsonPath("$.data.communityPosts").isArray)
.andExpect(jsonPath("$.data.communityPosts[0].postId").value(101))
.andExpect(jsonPath("$.data.communityPosts[0].creatorProfileUrl").value("https://cdn.test/profile.png"))
.andExpect(jsonPath("$.data.communityPosts[0].existOrdered").value(true))
.andExpect(jsonPath("$.data.communityPosts[0].isCommentAvailable").value(true))
.andExpect(jsonPath("$.data.communityPosts[0].isPinned").value(true))
.andExpect(jsonPath("$.data.page").value(1))
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.hasNext").value(false))
Mockito.verify(facade).getCommunityTab(
eqValue(1L),
eqValue(viewer),
eqValue(1),
eqValue(20),
anyValue(LocalDateTime.now())
)
}
@Test
@DisplayName("크리에이터 채널 커뮤니티 탭 조회는 page와 size를 controller에서 보정하지 않고 facade에 전달한다")
fun shouldPassRawPageAndSizeToFacade() {
val viewer = createMember(id = 10L)
Mockito.doReturn(createResponse(page = 0, size = 50)).`when`(facade).getCommunityTab(
eqValue(1L),
eqValue(viewer),
eqValue(-1),
eqValue(100),
anyValue(LocalDateTime.now())
)
mockMvc.perform(
get("/api/v2/creator-channels/1/community")
.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).getCommunityTab(
eqValue(1L),
eqValue(viewer),
eqValue(-1),
eqValue(100),
anyValue(LocalDateTime.now())
)
}
private fun <T> eqValue(value: T): T {
return Mockito.eq(value) ?: value
}
private fun <T> anyValue(fallback: T): T {
return Mockito.any<T>() ?: fallback
}
private fun createMember(id: Long): Member {
return Member(
email = "viewer$id@test.com",
password = "password",
nickname = "viewer$id",
role = MemberRole.USER
).apply { this.id = id }
}
private fun createResponse(
page: Int = 0,
size: Int = 20
): CreatorChannelCommunityTabResponse {
return CreatorChannelCommunityTabResponse(
communityPostCount = 2,
communityPosts = listOf(
CreatorChannelCommunityPostResponse(
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 = 3,
isPinned = true
)
),
page = page,
size = size,
hasNext = false
)
}
}

View File

@@ -0,0 +1,237 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.`in`.web
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.can.use.UseCan
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreference
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.transaction.support.TransactionTemplate
import java.time.LocalDateTime
import javax.persistence.EntityManager
@SpringBootTest(
properties = [
"cloud.aws.cloud-front.host=https://cdn.test",
"spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:creator-channel-live-e2e;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE"
]
)
@AutoConfigureMockMvc
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
class CreatorChannelCommunityEndToEndTest @Autowired constructor(
private val mockMvc: MockMvc,
private val entityManager: EntityManager,
private val transactionTemplate: TransactionTemplate
) {
@MockBean
private lateinit var audioContentCloudFront: AudioContentCloudFront
@Test
@DisplayName("커뮤니티 탭 API는 E2E로 정렬, fallback, 성인 필터, 유료 미디어 접근 정책을 반환한다")
fun shouldReturnCommunityTabThroughControllerServiceAndRepository() {
val fixture = createFixture()
Mockito.doReturn("https://signed.test/community-audio")
.`when`(audioContentCloudFront)
.generateSignedURL("community/purchased.mp3", 1000L * 60 * 30)
mockMvc.perform(
get("/api/v2/creator-channels/${fixture.creatorId}/community")
.param("page", "-1")
.param("size", "10")
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.communityPostCount").value(4))
.andExpect(jsonPath("$.data.communityPosts.length()").value(4))
.andExpect(jsonPath("$.data.communityPosts[0].postId").value(fixture.pinnedPostId))
.andExpect(jsonPath("$.data.communityPosts[0].isPinned").value(true))
.andExpect(jsonPath("$.data.communityPosts[0].creatorId").value(fixture.creatorId))
.andExpect(jsonPath("$.data.communityPosts[0].creatorNickname").value("community-e2e-creator"))
.andExpect(jsonPath("$.data.communityPosts[0].creatorProfileUrl").value("https://cdn.test/community-e2e-creator.png"))
.andExpect(jsonPath("$.data.communityPosts[0].createdAtUtc").exists())
.andExpect(jsonPath("$.data.communityPosts[0].content").value("pinned community"))
.andExpect(jsonPath("$.data.communityPosts[0].price").value(0))
.andExpect(jsonPath("$.data.communityPosts[0].isCommentAvailable").value(true))
.andExpect(jsonPath("$.data.communityPosts[0].existOrdered").value(false))
.andExpect(jsonPath("$.data.communityPosts[0].likeCount").value(0))
.andExpect(jsonPath("$.data.communityPosts[0].commentCount").value(0))
.andExpect(jsonPath("$.data.communityPosts[1].postId").value(fixture.purchasedPaidPostId))
.andExpect(jsonPath("$.data.communityPosts[1].imageUrl").value("https://cdn.test/community/purchased.png"))
.andExpect(jsonPath("$.data.communityPosts[1].audioUrl").value("https://signed.test/community-audio"))
.andExpect(jsonPath("$.data.communityPosts[1].existOrdered").value(true))
.andExpect(jsonPath("$.data.communityPosts[2].postId").value(fixture.unpurchasedPaidPostId))
.andExpect(jsonPath("$.data.communityPosts[2].imageUrl").doesNotExist())
.andExpect(jsonPath("$.data.communityPosts[2].audioUrl").doesNotExist())
.andExpect(jsonPath("$.data.communityPosts[2].existOrdered").value(false))
.andExpect(jsonPath("$.data.communityPosts[3].postId").value(fixture.noImagePostId))
.andExpect(jsonPath("$.data.communityPosts[3].imageUrl").doesNotExist())
.andExpect(jsonPath("$.data.communityPosts[?(@.postId == ${fixture.adultPurchasedPostId})]").isEmpty)
.andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.hasNext").value(false))
Mockito.verify(audioContentCloudFront)
.generateSignedURL("community/purchased.mp3", 1000L * 60 * 30)
Mockito.verifyNoMoreInteractions(audioContentCloudFront)
}
private fun createFixture(): Fixture {
return transactionTemplate.execute {
val now = LocalDateTime.of(2026, 6, 21, 12, 0)
val viewer = saveMember("community-e2e-viewer", MemberRole.USER)
val creator = saveMember("community-e2e-creator", MemberRole.CREATOR)
savePreference(viewer, isAdultContentVisible = false)
val pinned = saveCommunity(
creator = creator,
isFixed = true,
fixedAt = now,
price = 0,
content = "pinned community",
imagePath = "community/pinned.png"
)
val purchasedPaid = saveCommunity(
creator = creator,
isFixed = false,
price = 100,
content = "purchased paid community",
imagePath = "community/purchased.png",
audioPath = "community/purchased.mp3"
)
val unpurchasedPaid = saveCommunity(
creator = creator,
isFixed = false,
price = 100,
content = "unpurchased paid community",
imagePath = "community/unpurchased.png",
audioPath = "community/unpurchased.mp3"
)
val noImage = saveCommunity(
creator = creator,
isFixed = false,
price = 0,
content = "no image community"
)
val adultPurchased = saveCommunity(
creator = creator,
isFixed = false,
price = 100,
content = "adult purchased community",
imagePath = "community/adult.png",
audioPath = "community/adult.mp3",
isAdult = true
)
saveCommunityOrder(viewer, purchasedPaid)
saveCommunityOrder(viewer, adultPurchased)
entityManager.flush()
updateCreatedAt(pinned.id!!, now.minusHours(4))
updateCreatedAt(purchasedPaid.id!!, now.minusHours(1))
updateCreatedAt(unpurchasedPaid.id!!, now.minusHours(2))
updateCreatedAt(noImage.id!!, now.minusHours(3))
updateCreatedAt(adultPurchased.id!!, now.minusMinutes(30))
entityManager.flush()
entityManager.clear()
Fixture(
viewer = viewer,
creatorId = creator.id!!,
pinnedPostId = pinned.id!!,
purchasedPaidPostId = purchasedPaid.id!!,
unpurchasedPaidPostId = unpurchasedPaid.id!!,
noImagePostId = noImage.id!!,
adultPurchasedPostId = adultPurchased.id!!
)
}!!
}
private fun saveMember(nickname: String, role: MemberRole): Member {
val member = Member(
email = "$nickname@test.com",
password = "password",
nickname = nickname,
profileImage = "$nickname.png",
role = role
)
entityManager.persist(member)
return member
}
private fun savePreference(member: Member, isAdultContentVisible: Boolean): MemberContentPreference {
val preference = MemberContentPreference(
isAdultContentVisible = isAdultContentVisible,
contentType = ContentType.ALL
)
preference.member = member
entityManager.persist(preference)
return preference
}
private fun saveCommunity(
creator: Member,
isFixed: Boolean,
fixedAt: LocalDateTime? = null,
price: Int,
content: String,
imagePath: String? = null,
audioPath: String? = null,
isAdult: Boolean = false
): CreatorCommunity {
val community = CreatorCommunity(
content = content,
price = price,
isCommentAvailable = true,
isAdult = isAdult,
audioPath = audioPath,
imagePath = imagePath,
isActive = true,
isFixed = isFixed,
fixedAt = fixedAt
)
community.member = creator
entityManager.persist(community)
return community
}
private fun saveCommunityOrder(member: Member, community: CreatorCommunity): UseCan {
val useCan = UseCan(CanUsage.PAID_COMMUNITY_POST, community.price, rewardCan = 0, isRefund = false)
useCan.member = member
useCan.communityPost = community
entityManager.persist(useCan)
return useCan
}
private fun updateCreatedAt(id: Long, createdAt: LocalDateTime) {
entityManager.createQuery("update CreatorCommunity e set e.createdAt = :createdAt where e.id = :id")
.setParameter("createdAt", createdAt)
.setParameter("id", id)
.executeUpdate()
}
private data class Fixture(
val viewer: Member,
val creatorId: Long,
val pinnedPostId: Long,
val purchasedPaidPostId: Long,
val unpurchasedPaidPostId: Long,
val noImagePostId: Long,
val adultPurchasedPostId: Long
)
}

View File

@@ -0,0 +1,138 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.community.application
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.KotlinModule
import kr.co.vividnext.sodalive.member.Member
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.creator.channel.community.application.CreatorChannelCommunityQueryService
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityPost
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityTab
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import java.time.LocalDateTime
class CreatorChannelCommunityFacadeTest {
@Test
@DisplayName("커뮤니티 탭 응답 DTO는 domain tab 값을 공개 응답 필드로 그대로 매핑한다")
fun shouldMapCommunityTabDomainToPublicResponse() {
val response = CreatorChannelCommunityTabResponse.from(createTab())
assertEquals(2, response.communityPostCount)
assertEquals(101L, response.communityPosts.first().postId)
assertEquals(1L, response.communityPosts.first().creatorId)
assertEquals("creator", response.communityPosts.first().creatorNickname)
assertEquals("https://cdn.test/profile.png", response.communityPosts.first().creatorProfileUrl)
assertEquals("2026-06-21T03:30:00Z", response.communityPosts.first().createdAtUtc)
assertEquals("paid content", response.communityPosts.first().content)
assertEquals("https://cdn.test/image.png", response.communityPosts.first().imageUrl)
assertEquals("https://signed.test/audio", response.communityPosts.first().audioUrl)
assertEquals(100, response.communityPosts.first().price)
assertTrue(response.communityPosts.first().isCommentAvailable)
assertTrue(response.communityPosts.first().existOrdered)
assertEquals(7, response.communityPosts.first().likeCount)
assertEquals(3, response.communityPosts.first().commentCount)
assertTrue(response.communityPosts.first().isPinned)
assertNull(response.communityPosts.last().imageUrl)
assertNull(response.communityPosts.last().audioUrl)
assertEquals(1, response.page)
assertEquals(20, response.size)
assertTrue(response.hasNext)
val mapper = ObjectMapper().registerModule(KotlinModule.Builder().build())
val json = mapper.readTree(mapper.writeValueAsString(response))
assertTrue(json["hasNext"].asBoolean())
assertTrue(json["communityPosts"][0]["isCommentAvailable"].asBoolean())
assertTrue(json["communityPosts"][0]["isPinned"].asBoolean())
}
@Test
@DisplayName("커뮤니티 탭 facade는 query service 결과를 공개 응답 DTO로 변환한다")
fun shouldMapCommunityTabQueryResultToPublicResponse() {
val service = Mockito.mock(CreatorChannelCommunityQueryService::class.java)
val facade = CreatorChannelCommunityFacade(service)
val viewer = createMember(id = 10L)
val now = LocalDateTime.of(2026, 6, 21, 12, 0)
Mockito.doReturn(createTab()).`when`(service).getCommunityTab(
creatorId = 1L,
viewer = viewer,
page = -1,
size = 100,
now = now
)
val response = facade.getCommunityTab(
creatorId = 1L,
viewer = viewer,
page = -1,
size = 100,
now = now
)
assertEquals(2, response.communityPostCount)
assertEquals(101L, response.communityPosts.first().postId)
assertEquals("https://cdn.test/profile.png", response.communityPosts.first().creatorProfileUrl)
assertTrue(response.communityPosts.first().existOrdered)
assertFalse(response.communityPosts.last().isCommentAvailable)
assertEquals(1, response.page)
assertEquals(20, response.size)
assertTrue(response.hasNext)
}
private fun createMember(id: Long): Member {
return Member(
email = "viewer$id@test.com",
password = "password",
nickname = "viewer$id",
role = MemberRole.USER
).apply { this.id = id }
}
private fun createTab(): CreatorChannelCommunityTab {
return CreatorChannelCommunityTab(
communityPostCount = 2,
communityPosts = listOf(
CreatorChannelCommunityPost(
postId = 101L,
creatorId = 1L,
creatorNickname = "creator",
creatorProfileUrl = "https://cdn.test/profile.png",
imageUrl = "https://cdn.test/image.png",
audioUrl = "https://signed.test/audio",
content = "paid content",
price = 100,
createdAt = LocalDateTime.of(2026, 6, 21, 3, 30),
existOrdered = true,
isCommentAvailable = true,
likeCount = 7,
commentCount = 3,
isPinned = true
),
CreatorChannelCommunityPost(
postId = 102L,
creatorId = 1L,
creatorNickname = "creator",
creatorProfileUrl = "https://cdn.test/profile.png",
imageUrl = null,
audioUrl = null,
content = "masked...",
price = 50,
createdAt = LocalDateTime.of(2026, 6, 21, 3, 0),
existOrdered = false,
isCommentAvailable = false,
likeCount = 1,
commentCount = 0,
isPinned = false
)
),
page = CreatorChannelPage(page = 1, size = 20),
hasNext = true
)
}
}

View File

@@ -10,8 +10,8 @@ import kr.co.vividnext.sodalive.v2.api.creator.channel.home.application.CreatorC
import kr.co.vividnext.sodalive.v2.api.creator.channel.home.dto.CreatorChannelHomeResponse
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.creator.channel.common.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityPost
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelActivity
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCommunityPost
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCreator
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelDonation
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelFanTalk
@@ -132,6 +132,9 @@ class CreatorChannelHomeControllerTest @Autowired constructor(
.andExpect(jsonPath("$.data.channelDonations[0].donationId").doesNotExist())
.andExpect(jsonPath("$.data.channelDonations[0].memberId").doesNotExist())
.andExpect(jsonPath("$.data.channelDonations[0].isSecret").doesNotExist())
.andExpect(jsonPath("$.data.notices[0].dateUtc").value("2026-06-12T04:00:00Z"))
.andExpect(jsonPath("$.data.notices[0].imageUrl").doesNotExist())
.andExpect(jsonPath("$.data.notices[0].audioUrl").doesNotExist())
.andExpect(jsonPath("$.data.series[0].isNew").value(true))
.andExpect(jsonPath("$.data.series[0].isOriginal").value(true))
@@ -159,14 +162,16 @@ class CreatorChannelHomeControllerTest @Autowired constructor(
creatorId = 1L,
creatorNickname = "creator",
creatorProfileUrl = "profile.png",
imageUrl = "image.png",
audioUrl = "audio.mp3",
imageUrl = null,
audioUrl = null,
content = "notice",
price = 10,
date = LocalDateTime.of(2026, 6, 12, 4, 0),
createdAt = LocalDateTime.of(2026, 6, 12, 4, 0),
existOrdered = true,
isCommentAvailable = true,
likeCount = 2,
commentCount = 3
commentCount = 3,
isPinned = true
)
return CreatorChannelHome(

View File

@@ -4,9 +4,9 @@ import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.creator.channel.common.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityPost
import kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryService
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelActivity
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCommunityPost
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCreator
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelDonation
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelFanTalk
@@ -18,6 +18,7 @@ import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelSer
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelSns
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
@@ -58,6 +59,8 @@ class CreatorChannelHomeFacadeTest {
assertFalse(response.latestAudioContent?.isRented == true)
assertEquals("thanks", response.channelDonations.first().message)
assertEquals(301L, response.notices.first().postId)
assertEquals("2026-06-12T04:00:00Z", response.notices.first().dateUtc)
assertNull(response.notices.first().imageUrl)
assertEquals(501L, response.schedules.first().targetId)
assertEquals(202L, response.audioContents.first().audioContentId)
assertFalse(response.audioContents.first().isOwned)
@@ -89,14 +92,16 @@ class CreatorChannelHomeFacadeTest {
creatorId = 1L,
creatorNickname = "creator",
creatorProfileUrl = "profile.png",
imageUrl = "image.png",
audioUrl = "audio.mp3",
imageUrl = null,
audioUrl = null,
content = "notice",
price = 10,
date = LocalDateTime.of(2026, 6, 12, 4, 0),
createdAt = LocalDateTime.of(2026, 6, 12, 4, 0),
existOrdered = true,
isCommentAvailable = true,
likeCount = 2,
commentCount = 3
commentCount = 3,
isPinned = true
)
return CreatorChannelHome(

View File

@@ -0,0 +1,445 @@
package kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.can.use.UseCan
import kr.co.vividnext.sodalive.configs.QueryDslConfig
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.MemberKind
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.block.BlockMember
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityPostRecord
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import java.time.LocalDateTime
import javax.persistence.EntityManager
@DataJpaTest(
properties = [
"spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE"
]
)
@Import(QueryDslConfig::class)
class DefaultCreatorChannelCommunityQueryRepositoryTest @Autowired constructor(
private val entityManager: EntityManager,
queryFactory: JPAQueryFactory
) {
private val repository = DefaultCreatorChannelCommunityQueryRepository(queryFactory)
@Test
@DisplayName("활성 크리에이터는 조회되고 비활성 크리에이터는 null이다")
fun shouldFindOnlyActiveCreator() {
val viewer = saveMember("creator-lookup-viewer", MemberRole.USER)
val activeCreator = saveMember("active-creator", MemberRole.CREATOR)
val inactiveCreator = saveMember("inactive-creator", MemberRole.CREATOR, isActive = false)
flushAndClear()
val activeRecord = repository.findCreator(activeCreator.id!!, viewer.id!!)
val inactiveRecord = repository.findCreator(inactiveCreator.id!!, viewer.id!!)
assertNotNull(activeRecord)
assertEquals(activeCreator.id, activeRecord!!.creatorId)
assertEquals(MemberRole.CREATOR, activeRecord.role)
assertEquals(activeCreator.nickname, activeRecord.nickname)
assertNull(inactiveRecord)
}
@Test
@DisplayName("조회자와 크리에이터 사이 양방향 활성 차단은 차단 상태로 조회된다")
fun shouldFindActiveBlockInBothDirections() {
val viewer = saveMember("block-viewer", MemberRole.USER)
val creator = saveMember("block-creator", MemberRole.CREATOR)
val otherCreator = saveMember("not-blocked-creator", MemberRole.CREATOR)
saveBlock(viewer, creator, isActive = true)
saveBlock(otherCreator, viewer, isActive = false)
flushAndClear()
assertTrue(repository.existsBlockedBetween(viewer.id!!, creator.id!!))
assertTrue(repository.existsBlockedBetween(creator.id!!, viewer.id!!))
assertFalse(repository.existsBlockedBetween(viewer.id!!, otherCreator.id!!))
}
@Test
@DisplayName("게시글 수는 대상 크리에이터의 활성 게시글만 세고 성인 콘텐츠 정책을 우선 적용한다")
fun shouldCountOnlyVisibleActiveCreatorPostsWithAdultFilter() {
val viewer = saveMember("count-viewer", MemberRole.USER)
val creator = saveMember("count-creator", MemberRole.CREATOR)
val otherCreator = saveMember("other-count-creator", MemberRole.CREATOR)
saveCommunity(creator, isFixed = false, price = 0, isAdult = false)
val adultPost = saveCommunity(creator, isFixed = false, price = 100, isAdult = true)
saveCommunity(creator, isFixed = false, price = 0, isActive = false)
saveCommunity(otherCreator, isFixed = false, price = 0, isAdult = false)
saveCommunityOrder(viewer, adultPost, CanUsage.PAID_COMMUNITY_POST, isRefund = false)
flushAndClear()
assertEquals(1, repository.countCommunityPosts(creator.id!!, viewer.id!!, canViewAdultContent = false))
assertEquals(2, repository.countCommunityPosts(creator.id!!, viewer.id!!, canViewAdultContent = true))
val visiblePosts: List<CreatorChannelCommunityPostRecord> = repository.findCommunityPosts(
creatorId = creator.id!!,
viewerId = viewer.id!!,
canViewAdultContent = false,
offset = 0,
limit = 10
)
val visiblePostIds = visiblePosts.map { it.postId }
assertFalse(adultPost.id in visiblePostIds)
}
@Test
@DisplayName("통합 목록은 고정글 우선 정렬 후 일반글 정렬을 적용하고 offset과 limit으로 페이징한다")
fun shouldFindUnifiedPagedPostsWithPinnedFirstOrdering() {
val viewer = saveMember("ordering-viewer", MemberRole.USER)
val creator = saveMember("ordering-creator", MemberRole.CREATOR)
val now = LocalDateTime.of(2026, 6, 21, 12, 0)
val oldPinned = saveCommunity(creator, isFixed = true, fixedAt = now.minusHours(2), price = 0)
val olderCreatedSameFixedPinned = saveCommunity(creator, isFixed = true, fixedAt = now.minusHours(1), price = 0)
val newerCreatedSameFixedPinned = saveCommunity(creator, isFixed = true, fixedAt = now.minusHours(1), price = 0)
val oldNormal = saveCommunity(creator, isFixed = false, price = 0)
val middleNormal = saveCommunity(creator, isFixed = false, price = 0)
val newNormal = saveCommunity(creator, isFixed = false, price = 0)
flushAndClear()
updateCreatedAt("CreatorCommunity", oldPinned.id!!, now.minusDays(10))
updateCreatedAt("CreatorCommunity", olderCreatedSameFixedPinned.id!!, now.minusDays(1))
updateCreatedAt("CreatorCommunity", newerCreatedSameFixedPinned.id!!, now.minusDays(5))
updateCreatedAt("CreatorCommunity", oldNormal.id!!, now.minusDays(3))
updateCreatedAt("CreatorCommunity", middleNormal.id!!, now.minusDays(2))
updateCreatedAt("CreatorCommunity", newNormal.id!!, now.minusDays(1))
flushAndClear()
val firstPage: List<CreatorChannelCommunityPostRecord> = repository.findCommunityPosts(
creatorId = creator.id!!,
viewerId = viewer.id!!,
canViewAdultContent = true,
offset = 0,
limit = 3
)
val secondPage: List<CreatorChannelCommunityPostRecord> = repository.findCommunityPosts(
creatorId = creator.id!!,
viewerId = viewer.id!!,
canViewAdultContent = true,
offset = 3,
limit = 2
)
assertEquals(
listOf(newerCreatedSameFixedPinned.id, olderCreatedSameFixedPinned.id, oldPinned.id),
firstPage.map { it.postId }
)
assertEquals(listOf(newNormal.id, middleNormal.id), secondPage.map { it.postId })
assertTrue(firstPage[0].isPinned)
assertEquals(now.minusDays(5), firstPage[0].createdAt)
}
@Test
@DisplayName("좋아요는 활성 좋아요만 세고 댓글 불가 게시글의 댓글 수는 0이다")
fun shouldCountActiveLikesAndZeroCommentsWhenUnavailable() {
val viewer = saveMember("likes-viewer", MemberRole.USER)
val creator = saveMember("likes-creator", MemberRole.CREATOR)
val activeLiker = saveMember("active-liker", MemberRole.USER)
val inactiveLiker = saveMember("inactive-liker", MemberRole.USER)
val commenter = saveMember("unavailable-commenter", MemberRole.USER)
val post = saveCommunity(creator, isFixed = false, price = 0, isCommentAvailable = false)
saveCommunityLike(activeLiker, post, isActive = true)
saveCommunityLike(inactiveLiker, post, isActive = false)
saveCommunityComment(commenter, post, isActive = true)
flushAndClear()
val record = repository.findCommunityPosts(
creatorId = creator.id!!,
viewerId = viewer.id!!,
canViewAdultContent = true,
offset = 0,
limit = 10
).single()
assertEquals(1, record.likeCount)
assertEquals(0, record.commentCount)
assertFalse(record.isCommentAvailable)
}
@Test
@DisplayName("댓글 수는 활성 최상위 댓글만 세고 비밀 댓글과 차단 작성자 정책을 적용한다")
fun shouldCountVisibleActiveRootCommentsOnly() {
val creator = saveMember("comment-creator", MemberRole.CREATOR)
val viewer = saveMember("comment-viewer", MemberRole.USER)
val secretWriter = saveMember("secret-writer", MemberRole.USER)
val blockedWriter = saveMember("blocked-writer", MemberRole.USER)
val post = saveCommunity(creator, isFixed = false, price = 0, isCommentAvailable = true)
val publicRoot = saveCommunityComment(viewer, post, isActive = true)
saveCommunityComment(viewer, post, isActive = true, parent = publicRoot)
saveCommunityComment(viewer, post, isActive = false)
saveCommunityComment(secretWriter, post, isActive = true, isSecret = true)
saveCommunityComment(blockedWriter, post, isActive = true)
saveBlock(viewer, blockedWriter, isActive = true)
flushAndClear()
val viewerRecord = repository.findCommunityPosts(
creatorId = creator.id!!,
viewerId = viewer.id!!,
canViewAdultContent = true,
offset = 0,
limit = 10
).single()
val writerRecord = repository.findCommunityPosts(
creatorId = creator.id!!,
viewerId = secretWriter.id!!,
canViewAdultContent = true,
offset = 0,
limit = 10
).single()
val creatorRecord = repository.findCommunityPosts(
creatorId = creator.id!!,
viewerId = creator.id!!,
canViewAdultContent = true,
offset = 0,
limit = 10
).single()
assertEquals(1, viewerRecord.commentCount)
assertEquals(3, writerRecord.commentCount)
assertEquals(3, creatorRecord.commentCount)
}
@Test
@DisplayName("유효 구매 내역만 구매 상태로 인정하고 중복 구매는 목록 행을 중복시키지 않는다")
fun shouldUseValidPurchasesWithoutDuplicatingListItems() {
val viewer = saveMember("purchase-viewer", MemberRole.USER)
val creator = saveMember("purchase-creator", MemberRole.CREATOR)
val otherViewer = saveMember("purchase-other-viewer", MemberRole.USER)
val validPost = saveCommunity(creator, isFixed = false, price = 100, imagePath = "valid.png", audioPath = "valid.mp3")
val wrongUsagePost = saveCommunity(creator, isFixed = false, price = 100, imagePath = "wrong-usage.png")
val refundedPost = saveCommunity(creator, isFixed = false, price = 100, imagePath = "refunded.png")
val otherViewerPost = saveCommunity(creator, isFixed = false, price = 100, imagePath = "other-viewer.png")
saveCommunityOrder(viewer, validPost, CanUsage.PAID_COMMUNITY_POST, isRefund = false)
saveCommunityOrder(viewer, validPost, CanUsage.PAID_COMMUNITY_POST, isRefund = false)
saveCommunityOrder(viewer, wrongUsagePost, CanUsage.DONATION, isRefund = false)
saveCommunityOrder(viewer, refundedPost, CanUsage.PAID_COMMUNITY_POST, isRefund = true)
saveCommunityOrder(otherViewer, otherViewerPost, CanUsage.PAID_COMMUNITY_POST, isRefund = false)
flushAndClear()
val records: List<CreatorChannelCommunityPostRecord> = repository.findCommunityPosts(
creatorId = creator.id!!,
viewerId = viewer.id!!,
canViewAdultContent = true,
offset = 0,
limit = 10
)
val recordsById = records.associateBy { it.postId }
assertEquals(records.map { it.postId }.distinct(), records.map { it.postId })
assertTrue(recordsById.getValue(validPost.id!!).existOrdered)
assertEquals("valid.mp3", recordsById.getValue(validPost.id!!).audioPath)
assertFalse(recordsById.getValue(wrongUsagePost.id!!).existOrdered)
assertFalse(recordsById.getValue(refundedPost.id!!).existOrdered)
assertFalse(recordsById.getValue(otherViewerPost.id!!).existOrdered)
}
@Test
@DisplayName("크리에이터 본인은 구매 내역이 없어도 구매 상태로 조회된다")
fun shouldMarkCreatorOwnPostAsOrdered() {
val creator = saveMember("self-order-creator", MemberRole.CREATOR)
saveCommunity(creator, isFixed = false, price = 100, imagePath = "self.png", audioPath = "self.mp3")
flushAndClear()
val record = repository.findCommunityPosts(
creatorId = creator.id!!,
viewerId = creator.id!!,
canViewAdultContent = true,
offset = 0,
limit = 10
).single()
assertTrue(record.existOrdered)
assertEquals("self.mp3", record.audioPath)
}
@Test
@DisplayName("홈 커뮤니티 요약은 고정글과 일반글을 분리해 조회한다")
fun shouldFindHomeCommunityPostsByPinnedFlag() {
val viewer = saveMember("home-summary-viewer", MemberRole.USER)
val creator = saveMember("home-summary-creator", MemberRole.CREATOR)
val now = LocalDateTime.of(2026, 6, 21, 12, 0)
val pinned = saveCommunity(creator, isFixed = true, fixedAt = now.minusHours(1), price = 0)
val normal = saveCommunity(creator, isFixed = false, price = 0)
val adultPinned = saveCommunity(creator, isFixed = true, fixedAt = now, price = 0, isAdult = true)
flushAndClear()
updateCreatedAt("CreatorCommunity", normal.id!!, now.minusDays(1))
flushAndClear()
val pinnedPosts: List<CreatorChannelCommunityPostRecord> = repository.findHomeCommunityPosts(
creatorId = creator.id!!,
viewerId = viewer.id!!,
isPinned = true,
canViewAdultContent = false,
limit = 3
)
val normalPosts: List<CreatorChannelCommunityPostRecord> = repository.findHomeCommunityPosts(
creatorId = creator.id!!,
viewerId = viewer.id!!,
isPinned = false,
canViewAdultContent = false,
limit = 3
)
assertEquals(listOf(pinned.id), pinnedPosts.map { it.postId })
assertEquals(listOf(normal.id), normalPosts.map { it.postId })
assertFalse(adultPinned.id in pinnedPosts.map { it.postId })
assertTrue(pinnedPosts.single().isPinned)
assertFalse(normalPosts.single().isPinned)
}
@Test
@DisplayName("홈 커뮤니티 요약은 구매한 비활성 유료글을 포함하되 성인 콘텐츠 정책을 우선 적용한다")
fun shouldFindPurchasedInactiveHomeCommunityPostsWithAdultFilter() {
val viewer = saveMember("home-purchased-viewer", MemberRole.USER)
val creator = saveMember("home-purchased-creator", MemberRole.CREATOR)
val inactivePurchasedPost = saveCommunity(creator, isFixed = false, price = 100, isActive = false)
val adultInactivePurchasedPost = saveCommunity(
creator = creator,
isFixed = false,
price = 100,
isAdult = true,
isActive = false
)
saveCommunityOrder(viewer, inactivePurchasedPost, CanUsage.PAID_COMMUNITY_POST, isRefund = false)
saveCommunityOrder(viewer, adultInactivePurchasedPost, CanUsage.PAID_COMMUNITY_POST, isRefund = false)
flushAndClear()
val adultAllowedPosts: List<CreatorChannelCommunityPostRecord> = repository.findHomeCommunityPosts(
creatorId = creator.id!!,
viewerId = viewer.id!!,
isPinned = false,
canViewAdultContent = true,
limit = 10
)
val adultBlockedPosts: List<CreatorChannelCommunityPostRecord> = repository.findHomeCommunityPosts(
creatorId = creator.id!!,
viewerId = viewer.id!!,
isPinned = false,
canViewAdultContent = false,
limit = 10
)
assertTrue(inactivePurchasedPost.id in adultAllowedPosts.map { it.postId })
assertTrue(adultInactivePurchasedPost.id in adultAllowedPosts.map { it.postId })
assertTrue(inactivePurchasedPost.id in adultBlockedPosts.map { it.postId })
assertFalse(adultInactivePurchasedPost.id in adultBlockedPosts.map { it.postId })
}
private fun saveMember(
nickname: String,
role: MemberRole,
isActive: Boolean = true,
memberKind: MemberKind = MemberKind.HUMAN
): Member {
val member = Member(
email = "$nickname@test.com",
password = "password",
nickname = nickname,
profileImage = "$nickname.png",
role = role,
memberKind = memberKind,
isActive = isActive
)
entityManager.persist(member)
return member
}
private fun saveBlock(member: Member, blockedMember: Member, isActive: Boolean): BlockMember {
val block = BlockMember(isActive = isActive)
block.member = member
block.blockedMember = blockedMember
entityManager.persist(block)
return block
}
private fun saveCommunity(
creator: Member,
isFixed: Boolean,
fixedAt: LocalDateTime? = null,
price: Int,
imagePath: String? = null,
audioPath: String? = null,
isAdult: Boolean = false,
isCommentAvailable: Boolean = true,
content: String = "community",
isActive: Boolean = true
): CreatorCommunity {
val community = CreatorCommunity(
content = content,
price = price,
isCommentAvailable = isCommentAvailable,
isAdult = isAdult,
audioPath = audioPath,
imagePath = imagePath,
isActive = isActive,
isFixed = isFixed,
fixedAt = fixedAt
)
community.member = creator
entityManager.persist(community)
return community
}
private fun saveCommunityLike(member: Member, community: CreatorCommunity, isActive: Boolean): CreatorCommunityLike {
val like = CreatorCommunityLike(isActive = isActive)
like.member = member
like.creatorCommunity = community
entityManager.persist(like)
return like
}
private fun saveCommunityComment(
member: Member,
community: CreatorCommunity,
isActive: Boolean,
isSecret: Boolean = false,
parent: CreatorCommunityComment? = null
): 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 saveCommunityOrder(
member: Member,
community: CreatorCommunity,
canUsage: CanUsage,
isRefund: Boolean
): UseCan {
val useCan = UseCan(canUsage, community.price, rewardCan = 0, isRefund = isRefund)
useCan.member = member
useCan.communityPost = community
entityManager.persist(useCan)
return useCan
}
private fun updateCreatedAt(entityName: String, id: Long, createdAt: LocalDateTime) {
entityManager.createQuery("update $entityName e set e.createdAt = :createdAt where e.id = :id")
.setParameter("createdAt", createdAt)
.setParameter("id", id)
.executeUpdate()
}
private fun flushAndClear() {
entityManager.flush()
entityManager.clear()
}
}

View File

@@ -0,0 +1,314 @@
package kr.co.vividnext.sodalive.v2.creator.channel.community.application
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberProvider
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicy
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityPostRecord
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityQueryPort
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.beans.factory.ObjectProvider
import java.time.LocalDateTime
class CreatorChannelCommunityQueryServiceTest {
@Test
@DisplayName("커뮤니티 탭 서비스는 요청 fallback과 조회 컨텍스트를 port에 전달하고 탭을 조립한다")
fun shouldResolveRequestFallbacksAndAssembleCommunityTab() {
val port = FakeCreatorChannelCommunityQueryPort().apply {
communityPostCount = 60
communityPosts = (1L..51L).map { communityPostRecord(it, price = 0) }
}
val audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java)
Mockito.`when`(audioContentCloudFront.generateSignedURL("audio/1.mp3", 1000 * 60 * 30))
.thenReturn("https://signed.test/audio/1.mp3")
val service = createService(port, audioContentCloudFront, canViewAdultContent = false)
val viewer = createMember(id = 10L)
val now = LocalDateTime.of(2026, 6, 21, 10, 0)
val tab = service.getCommunityTab(
creatorId = 1L,
viewer = viewer,
page = -1,
size = 100,
now = now
)
assertEquals(60, tab.communityPostCount)
assertEquals(0, tab.page.page)
assertEquals(50, tab.page.size)
assertEquals(0L, port.listOffset)
assertEquals(51, port.listLimit)
assertEquals(false, port.listCanViewAdultContent)
assertEquals(false, port.countCanViewAdultContent)
assertEquals(50, tab.communityPosts.size)
assertTrue(tab.hasNext)
assertEquals("https://cdn.test/profile/1.png", tab.communityPosts.first().creatorProfileUrl)
assertEquals("https://cdn.test/image/1.png", tab.communityPosts.first().imageUrl)
assertEquals("https://signed.test/audio/1.mp3", tab.communityPosts.first().audioUrl)
}
@Test
@DisplayName("creatorId에 해당하는 회원이 없으면 user_not_found 예외를 던진다")
fun shouldThrowUserNotFoundWhenCreatorMemberDoesNotExist() {
val port = FakeCreatorChannelCommunityQueryPort().apply { creator = null }
val service = createService(port)
val viewer = createMember(id = 10L)
val exception = assertThrows(SodaException::class.java) {
service.getCommunityTab(1L, viewer, null, null, LocalDateTime.of(2026, 6, 21, 10, 0))
}
assertEquals("member.validation.user_not_found", exception.messageKey)
}
@Test
@DisplayName("대상 회원 role이 CREATOR가 아니면 creator_not_found 예외를 던진다")
fun shouldThrowCreatorNotFoundWhenMemberIsNotCreator() {
val port = FakeCreatorChannelCommunityQueryPort().apply { creator = creator?.copy(role = MemberRole.USER) }
val service = createService(port)
val viewer = createMember(id = 10L)
val exception = assertThrows(SodaException::class.java) {
service.getCommunityTab(1L, viewer, null, null, LocalDateTime.of(2026, 6, 21, 10, 0))
}
assertEquals("member.validation.creator_not_found", exception.messageKey)
}
@Test
@DisplayName("차단 관계가 있으면 기존 차단 메시지 예외를 던진다")
fun shouldThrowBlockedAccessWhenViewerAndTargetAreBlocked() {
val port = FakeCreatorChannelCommunityQueryPort().apply { blocked = true }
val service = createService(port)
val viewer = createMember(id = 10L)
val exception = assertThrows(SodaException::class.java) {
service.getCommunityTab(1L, viewer, null, null, LocalDateTime.of(2026, 6, 21, 10, 0))
}
assertNull(exception.messageKey)
assertEquals("Channel access is restricted at creator's request.", exception.message)
}
@Test
@DisplayName("커뮤니티 게시글은 접근 권한에 따라 이미지와 오디오와 본문을 조립한다")
fun shouldAssembleCommunityPostAssetsByAccessPolicy() {
val port = FakeCreatorChannelCommunityQueryPort().apply {
communityPosts = listOf(
communityPostRecord(1L, price = 0, existOrdered = false),
communityPostRecord(2L, price = 100, existOrdered = true),
communityPostRecord(3L, price = 100, existOrdered = false),
communityPostRecord(4L, creatorId = 10L, price = 100, existOrdered = false, creatorProfilePath = null),
communityPostRecord(5L, price = 0, imagePath = " ", audioPath = null)
)
}
val audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java)
Mockito.`when`(audioContentCloudFront.generateSignedURL("audio/1.mp3", 1000 * 60 * 30))
.thenReturn("https://signed.test/audio/1.mp3")
Mockito.`when`(audioContentCloudFront.generateSignedURL("audio/2.mp3", 1000 * 60 * 30))
.thenReturn("https://signed.test/audio/2.mp3")
Mockito.`when`(audioContentCloudFront.generateSignedURL("audio/4.mp3", 1000 * 60 * 30))
.thenReturn("https://signed.test/audio/4.mp3")
val service = createService(port, audioContentCloudFront)
val viewer = createMember(id = 10L)
val posts = service.getCommunityTab(1L, viewer, null, null, LocalDateTime.of(2026, 6, 21, 10, 0))
.communityPosts
assertEquals("https://cdn.test/image/1.png", posts[0].imageUrl)
assertEquals("https://signed.test/audio/1.mp3", posts[0].audioUrl)
assertEquals("content-1", posts[0].content)
assertEquals("https://cdn.test/image/2.png", posts[1].imageUrl)
assertEquals("https://signed.test/audio/2.mp3", posts[1].audioUrl)
assertNull(posts[2].imageUrl)
assertNull(posts[2].audioUrl)
assertEquals("cont...", posts[2].content)
assertEquals("https://cdn.test/image/4.png", posts[3].imageUrl)
assertEquals("https://signed.test/audio/4.mp3", posts[3].audioUrl)
assertEquals("https://cdn.test/profile/default-profile.png", posts[3].creatorProfileUrl)
assertEquals(true, posts[3].existOrdered)
assertNull(posts[4].imageUrl)
assertNull(posts[4].audioUrl)
Mockito.verify(audioContentCloudFront, Mockito.never()).generateSignedURL("audio/3.mp3", 1000 * 60 * 30)
}
@Test
@DisplayName("홈 커뮤니티 요약 조회는 탭 전체 검증 없이 받은 조건으로 목록을 조립한다")
fun shouldAssembleHomeCommunityPostsWithoutTabValidation() {
val port = FakeCreatorChannelCommunityQueryPort().apply {
creator = null
blocked = true
homeCommunityPosts = listOf(communityPostRecord(1L, price = 0))
}
val service = createService(port)
val posts = service.findHomeCommunityPosts(
creatorId = 1L,
viewerId = 10L,
isPinned = true,
canViewAdultContent = false,
limit = 3
)
assertEquals(1, posts.size)
assertEquals(1L, port.homeCreatorId)
assertEquals(10L, port.homeViewerId)
assertEquals(true, port.homeIsPinned)
assertEquals(false, port.homeCanViewAdultContent)
assertEquals(3, port.homeLimit)
}
private fun createService(
port: FakeCreatorChannelCommunityQueryPort,
audioContentCloudFront: AudioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java),
canViewAdultContent: Boolean = true
): CreatorChannelCommunityQueryService {
val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
Mockito.`when`(
preferenceService.getStoredPreference(Mockito.any(Member::class.java) ?: createMember(id = 0L))
).thenReturn(
ViewerContentPreference(
countryCode = "US",
isAdultContentVisible = canViewAdultContent,
contentType = ContentType.ALL,
isAdult = canViewAdultContent
)
)
val langContext = LangContext()
langContext.setLang(Lang.EN)
return CreatorChannelCommunityQueryService(
queryPortProvider = FixedCreatorChannelCommunityQueryPortProvider(port),
queryPolicy = CreatorChannelCommunityQueryPolicy(),
memberContentPreferenceService = preferenceService,
audioContentCloudFront = audioContentCloudFront,
messageSource = SodaMessageSource(),
langContext = langContext,
cloudFrontHost = "https://cdn.test"
)
}
private fun createMember(id: Long): Member {
return Member(
email = "member$id@test.com",
password = "password",
nickname = "member$id",
provider = MemberProvider.EMAIL
).apply { this.id = id }
}
}
private class FixedCreatorChannelCommunityQueryPortProvider(
private val port: CreatorChannelCommunityQueryPort
) : ObjectProvider<CreatorChannelCommunityQueryPort> {
override fun getObject(vararg args: Any?): CreatorChannelCommunityQueryPort = port
override fun getIfAvailable(): CreatorChannelCommunityQueryPort = port
override fun getIfUnique(): CreatorChannelCommunityQueryPort = port
override fun getObject(): CreatorChannelCommunityQueryPort = port
}
private class FakeCreatorChannelCommunityQueryPort : CreatorChannelCommunityQueryPort {
var creator: CreatorChannelCommunityCreatorRecord? = CreatorChannelCommunityCreatorRecord(
creatorId = 1L,
role = MemberRole.CREATOR,
nickname = "creator"
)
var blocked = false
var communityPostCount = 1
var communityPosts = listOf(communityPostRecord(1L, price = 0))
var homeCommunityPosts = listOf(communityPostRecord(1L, price = 0))
var countCanViewAdultContent: Boolean? = null
var listCanViewAdultContent: Boolean? = null
var listOffset: Long? = null
var listLimit: Int? = null
var homeCreatorId: Long? = null
var homeViewerId: Long? = null
var homeIsPinned: Boolean? = null
var homeCanViewAdultContent: Boolean? = null
var homeLimit: Int? = null
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCommunityCreatorRecord? = creator
override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean = blocked
override fun countCommunityPosts(
creatorId: Long,
viewerId: Long,
canViewAdultContent: Boolean
): Int {
countCanViewAdultContent = canViewAdultContent
return communityPostCount
}
override fun findCommunityPosts(
creatorId: Long,
viewerId: Long,
canViewAdultContent: Boolean,
offset: Long,
limit: Int
): List<CreatorChannelCommunityPostRecord> {
listCanViewAdultContent = canViewAdultContent
listOffset = offset
listLimit = limit
return communityPosts
}
override fun findHomeCommunityPosts(
creatorId: Long,
viewerId: Long,
isPinned: Boolean,
canViewAdultContent: Boolean,
limit: Int
): List<CreatorChannelCommunityPostRecord> {
homeCreatorId = creatorId
homeViewerId = viewerId
homeIsPinned = isPinned
homeCanViewAdultContent = canViewAdultContent
homeLimit = limit
return homeCommunityPosts
}
}
private fun communityPostRecord(
postId: Long,
creatorId: Long = 1L,
price: Int,
existOrdered: Boolean = false,
creatorProfilePath: String? = "profile/$postId.png",
imagePath: String? = "image/$postId.png",
audioPath: String? = "audio/$postId.mp3"
): CreatorChannelCommunityPostRecord {
return CreatorChannelCommunityPostRecord(
postId = postId,
creatorId = creatorId,
creatorNickname = "creator-$creatorId",
creatorProfilePath = creatorProfilePath,
imagePath = imagePath,
audioPath = audioPath,
content = "content-$postId",
price = price,
createdAt = LocalDateTime.of(2026, 6, 21, 10, 0).plusMinutes(postId),
existOrdered = existOrdered,
isCommentAvailable = true,
likeCount = postId.toInt(),
commentCount = postId.toInt() + 1,
isPinned = postId == 1L
)
}

View File

@@ -0,0 +1,164 @@
package kr.co.vividnext.sodalive.v2.creator.channel.community.domain
import kr.co.vividnext.sodalive.member.MemberRole
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 org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import java.time.LocalDateTime
class CreatorChannelCommunityQueryPolicyTest {
private val policy = CreatorChannelCommunityQueryPolicy()
@Test
@DisplayName("커뮤니티 탭 page 정책은 null 요청을 기본값으로 fallback하고 fetch limit을 계산한다")
fun shouldFallbackNullPageAndSizeForCommunityTab() {
val page = policy.createPage(page = null, size = null)
assertEquals(0, page.page)
assertEquals(20, page.size)
assertEquals(0L, page.offset)
assertEquals(21, page.fetchLimit)
}
@Test
@DisplayName("커뮤니티 탭 page 정책은 최소/최대 범위로 fallback하고 fetch limit을 계산한다")
fun shouldFallbackPageAndSizeForCommunityTab() {
val minimumPage = policy.createPage(page = -1, size = 10)
val maximumPage = policy.createPage(page = 2, size = 100)
assertEquals(0, minimumPage.page)
assertEquals(20, minimumPage.size)
assertEquals(0L, minimumPage.offset)
assertEquals(21, minimumPage.fetchLimit)
assertEquals(2, maximumPage.page)
assertEquals(50, maximumPage.size)
assertEquals(100L, maximumPage.offset)
assertEquals(51, maximumPage.fetchLimit)
}
@Test
@DisplayName("커뮤니티 탭 목록 정책은 요청 size만 남기고 다음 페이지 여부를 계산한다")
fun shouldLimitItemsAndCalculateHasNext() {
val page = policy.createPage(page = 0, size = 20)
val fetched = (1..21).toList()
val items = policy.limitItems(fetched, page)
assertEquals((1..20).toList(), items)
assertTrue(policy.hasNext(fetched, page))
assertFalse(policy.hasNext((1..20).toList(), page))
}
@Test
@DisplayName("유료 커뮤니티 본문은 접근 권한이 없으면 code point 기준으로 마스킹한다")
fun shouldMaskPaidContentWhenViewerCannotAccess() {
assertEquals(
"123456789012345...",
policy.maskPaidContent(
content = "1234567890123456",
price = 100,
isCreatorSelf = false,
existOrdered = false
)
)
assertEquals(
"1234567...",
policy.maskPaidContent(
content = "123456789012345",
price = 100,
isCreatorSelf = false,
existOrdered = false
)
)
assertEquals(
"가나다라마바사아자차카타파하🙂...",
policy.maskPaidContent(
content = "가나다라마바사아자차카타파하🙂끝",
price = 100,
isCreatorSelf = false,
existOrdered = false
)
)
}
@Test
@DisplayName("무료 게시글, 작성자 본인, 구매자는 유료 본문 원문을 볼 수 있다")
fun shouldReturnOriginalContentWhenViewerCanAccess() {
assertEquals(
"free content",
policy.maskPaidContent("free content", price = 0, isCreatorSelf = false, existOrdered = false)
)
assertEquals(
"creator content",
policy.maskPaidContent("creator content", price = 100, isCreatorSelf = true, existOrdered = false)
)
assertEquals(
"ordered content",
policy.maskPaidContent("ordered content", price = 100, isCreatorSelf = false, existOrdered = true)
)
}
@Test
@DisplayName("커뮤니티 탭 domain model과 port record는 Phase 1 계약 필드를 유지한다")
fun shouldKeepDomainAndPortContract() {
val createdAt = LocalDateTime.of(2026, 6, 21, 10, 0)
val page = policy.createPage(page = 0, size = 20)
val tab = CreatorChannelCommunityTab(
communityPostCount = 1,
communityPosts = listOf(
CreatorChannelCommunityPost(
postId = 10L,
creatorId = 1L,
creatorNickname = "creator",
creatorProfileUrl = "https://cdn.test/profile.png",
imageUrl = null,
audioUrl = null,
content = "content",
price = 100,
createdAt = createdAt,
existOrdered = false,
isCommentAvailable = true,
likeCount = 2,
commentCount = 3,
isPinned = true
)
),
page = page,
hasNext = false
)
val creatorRecord = CreatorChannelCommunityCreatorRecord(
creatorId = 1L,
role = MemberRole.CREATOR,
nickname = "creator"
)
val postRecord = CreatorChannelCommunityPostRecord(
postId = 10L,
creatorId = 1L,
creatorNickname = "creator",
creatorProfilePath = null,
imagePath = null,
audioPath = null,
content = "content",
price = 100,
createdAt = createdAt,
existOrdered = false,
isCommentAvailable = true,
likeCount = 2,
commentCount = 3,
isPinned = true
)
assertEquals(1, tab.communityPostCount)
assertEquals("creator", tab.communityPosts.first().creatorNickname)
assertEquals(page, tab.page)
assertFalse(tab.hasNext)
assertEquals(MemberRole.CREATOR, creatorRecord.role)
assertNull(postRecord.imagePath)
assertTrue(postRecord.isPinned)
}
}

View File

@@ -2,8 +2,6 @@ package kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.can.use.UseCan
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.content.AudioContent
@@ -15,9 +13,6 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers
import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage
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.live.room.GenderRestriction
import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.live.room.visit.LiveRoomVisit
@@ -143,14 +138,10 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
"audio queries in this repository must project required columns"
)
assertFalse(source.contains(".selectFrom(channelDonationMessage)"), "donation query must project required columns")
assertFalse(source.contains(".selectFrom(creatorCommunity)"), "community query must project required columns")
assertFalse(source.contains(".selectFrom(series)"), "series query must project required columns")
assertFalse(source.contains(".select(series)"), "series query must not fetch full Series entity")
assertFalse(source.contains(".selectFrom(creatorCheers)"), "fan talk latest query must project required columns")
assertFalse(source.contains(".fetch()\n .size"), "counts must use DB count instead of fetching ids")
assertFalse(source.contains("existsCommunityOrder("), "community orders must be bulk calculated")
assertFalse(source.contains("countCommunityLikes("), "community likes must be bulk calculated")
assertFalse(source.contains("countCommunityComments("), "community comments must be bulk calculated")
assertFalse(source.contains("publishedSeriesContents("), "series contents must be bulk calculated")
assertFalse(source.contains("hasNewSeriesContent("), "series new flags must be bulk calculated")
assertTrue(
@@ -214,17 +205,6 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
saveOrder(viewer, creator, latestAudio, OrderType.KEEP)
saveOrder(viewer, creator, listAudio, OrderType.RENTAL, endDate = now.plusDays(1))
val donation = saveDonation(creator, donor, 500, now.minusHours(3), additionalMessage = "integrated thanks")
val notice = saveCommunity(creator, isFixed = true, fixedAt = now.minusHours(4), price = 0)
val community = saveCommunity(
creator,
isFixed = false,
price = 100,
imagePath = "community.png",
audioPath = "community.mp3"
)
saveCommunityOrder(viewer, community, isRefund = false)
saveCommunityLike(viewer, community, isActive = true)
saveCommunityComment(viewer, community, isActive = true)
val fanTalk = saveCheers(fan, creator, "integrated fan talk", isActive = true, now.minusMinutes(30))
saveVisit(currentLive, viewer)
flushAndClear()
@@ -247,7 +227,6 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
viewerId = viewer.id!!
)
val donations = repository.findChannelDonations(creator.id!!, viewer.id!!, now, limit = 8)
val notices = repository.findCommunityPosts(creator.id!!, viewer.id!!, isFixed = true, false, limit = 3)
val schedules = repository.findSchedules(
creator.id!!,
now,
@@ -266,7 +245,6 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
limit = 9
)
val seriesRecords = repository.findSeries(creator.id!!, viewer.id!!, now, false, ContentType.ALL, limit = 8)
val communities = repository.findCommunityPosts(creator.id!!, viewer.id!!, isFixed = false, false, limit = 3)
val fanTalkSummary = repository.findFanTalkSummary(creator.id!!, viewer.id!!)
val activity = repository.findActivity(creator.id!!, now)
val sns = repository.findSns(creator.id!!)
@@ -279,7 +257,6 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
assertFalse(latestAudioRecord.isRented)
assertEquals(listOf(donation.can), donations.map { it.can })
assertEquals("integrated thanks", donations.single().message)
assertEquals(listOf(notice.id), notices.map { it.postId })
assertEquals(listOf(liveSchedule.id, audioSchedule.id), schedules.map { it.targetId })
assertEquals(listOf(CreatorActivityType.LIVE, CreatorActivityType.AUDIO), schedules.map { it.type })
assertEquals(listOf(listAudio.id, firstAudio.id), audioContents.map { it.audioContentId })
@@ -287,10 +264,6 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
assertEquals(listOf(true, false), audioContents.map { it.isRented })
assertEquals(listOf(series.id), seriesRecords.map { it.seriesId })
assertEquals(true, seriesRecords.single().isOriginal)
assertEquals(listOf(community.id), communities.map { it.postId })
assertEquals(1, communities.single().likeCount)
assertEquals(1, communities.single().commentCount)
assertTrue(communities.single().existOrdered)
assertEquals(1, fanTalkSummary.totalCount)
assertEquals(fanTalk.id, fanTalkSummary.latestFanTalk!!.fanTalkId)
assertEquals(now.minusDays(3), activity.debutDate)
@@ -642,54 +615,6 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
assertTrue(records.single().isFirstContent)
}
@Test
@DisplayName("채널 후원, 공지, 커뮤니티, 팬 Talk는 기존 전체보기 의미에 맞는 요약을 조회한다")
fun shouldFindDonationsCommunitiesAndFanTalkSummary() {
val now = LocalDateTime.of(2026, 6, 12, 12, 0)
val creator = saveMember("community-creator", MemberRole.CREATOR)
val viewer = saveMember("community-viewer", MemberRole.USER)
val donor = saveMember("community-donor", MemberRole.USER)
val blockedWriter = saveMember("blocked-talk-writer", MemberRole.USER)
val donation = saveDonation(creator, donor, 300, now.minusDays(1))
saveDonation(creator, donor, 100, now.minusMonths(1))
val notice = saveCommunity(creator, isFixed = true, fixedAt = now.minusHours(1), price = 0)
saveCommunity(creator, isFixed = true, fixedAt = null, price = 0)
val post = saveCommunity(creator, isFixed = false, price = 100, imagePath = "community.png", audioPath = "community.mp3")
saveCommunityLike(viewer, post, isActive = true)
saveCommunityComment(viewer, post, isActive = true)
saveCommunityOrder(viewer, post, isRefund = false)
val latestTalk = saveCheers(viewer, creator, "latest", isActive = true, now.minusMinutes(1))
saveCheers(blockedWriter, creator, "blocked", isActive = true, now)
saveBlock(viewer, blockedWriter)
flushAndClear()
val donations = repository.findChannelDonations(creator.id!!, viewer.id!!, now, limit = 8)
val notices = repository.findCommunityPosts(
creator.id!!,
viewer.id!!,
isFixed = true,
canViewAdultContent = false,
limit = 3
)
val posts = repository.findCommunityPosts(
creator.id!!,
viewer.id!!,
isFixed = false,
canViewAdultContent = false,
limit = 3
)
val fanTalk = repository.findFanTalkSummary(creator.id!!, viewer.id!!)
assertEquals(listOf(donation.can), donations.map { it.can })
assertEquals(listOf(notice.id), notices.map { it.postId })
assertEquals(listOf(post.id), posts.map { it.postId })
assertEquals(1, posts.single().likeCount)
assertEquals(1, posts.single().commentCount)
assertTrue(posts.single().existOrdered)
assertEquals(1, fanTalk.totalCount)
assertEquals(latestTalk.id, fanTalk.latestFanTalk!!.fanTalkId)
}
@Test
@DisplayName("팬 Talk 요약은 활성 최상위 글 전체 개수와 최신 1개만 조회한다")
fun shouldSummarizeFanTalkWithTotalCountAndLatestOnly() {
@@ -709,181 +634,6 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
assertEquals("latest", summary.latestFanTalk!!.content)
}
@Test
@DisplayName("커뮤니티는 성인 정책과 작성자 본인의 구매 여부 의미를 반영한다")
fun shouldFilterAdultCommunityAndTreatCreatorAsOrdered() {
val creator = saveMember("adult-community-creator", MemberRole.CREATOR)
val visiblePost = saveCommunity(creator, isFixed = false, price = 100)
saveCommunity(creator, isFixed = false, price = 100, isAdult = true)
flushAndClear()
val viewerPosts = repository.findCommunityPosts(
creator.id!!,
viewerId = creator.id!!,
isFixed = false,
canViewAdultContent = false,
limit = 3
)
assertEquals(listOf(visiblePost.id), viewerPosts.map { it.postId })
assertTrue(viewerPosts.single().existOrdered)
}
@Test
@DisplayName("유료 커뮤니티는 비구매자에게 본문을 축약하고 오디오를 숨긴다")
fun shouldMaskPaidCommunityContentAndAudioForNonBuyer() {
val creator = saveMember("paid-community-creator", MemberRole.CREATOR)
val viewer = saveMember("paid-community-viewer", MemberRole.USER)
val content = "12345678901234567890"
saveCommunity(
creator,
isFixed = false,
price = 100,
audioPath = "paid-audio.mp3",
content = content
)
flushAndClear()
val posts = repository.findCommunityPosts(
creator.id!!,
viewerId = viewer.id!!,
isFixed = false,
canViewAdultContent = false,
limit = 3
)
assertEquals("123456789012345...", posts.single().content)
assertEquals(null, posts.single().audioPath)
assertFalse(posts.single().existOrdered)
}
@Test
@DisplayName("유료 커뮤니티는 구매자와 작성자에게 본문과 오디오를 노출한다")
fun shouldExposePaidCommunityContentAndAudioForBuyerAndCreator() {
val creator = saveMember("paid-community-owner", MemberRole.CREATOR)
val buyer = saveMember("paid-community-buyer", MemberRole.USER)
val post = saveCommunity(
creator,
isFixed = false,
price = 100,
audioPath = "paid-visible.mp3",
content = "paid full content"
)
saveCommunityOrder(buyer, post, isRefund = false)
flushAndClear()
val buyerPosts = repository.findCommunityPosts(
creator.id!!,
viewerId = buyer.id!!,
isFixed = false,
canViewAdultContent = false,
limit = 3
)
val creatorPosts = repository.findCommunityPosts(
creator.id!!,
viewerId = creator.id!!,
isFixed = false,
canViewAdultContent = false,
limit = 3
)
assertEquals("paid full content", buyerPosts.single().content)
assertEquals("paid-visible.mp3", buyerPosts.single().audioPath)
assertTrue(buyerPosts.single().existOrdered)
assertEquals("paid full content", creatorPosts.single().content)
assertEquals("paid-visible.mp3", creatorPosts.single().audioPath)
assertTrue(creatorPosts.single().existOrdered)
}
@Test
@DisplayName("구매한 유료 커뮤니티는 크리에이터가 삭제해도 구매자에게 조회된다")
fun shouldExposeDeletedPaidCommunityContentToBuyer() {
val creator = saveMember("deleted-paid-community-creator", MemberRole.CREATOR)
val buyer = saveMember("deleted-paid-community-buyer", MemberRole.USER)
val nonBuyer = saveMember("deleted-paid-community-non-buyer", MemberRole.USER)
val post = saveCommunity(
creator,
isFixed = false,
price = 100,
audioPath = "deleted-paid.mp3",
content = "deleted paid content",
isActive = false
)
saveCommunityOrder(buyer, post, isRefund = false)
flushAndClear()
val buyerPosts = repository.findCommunityPosts(
creator.id!!,
viewerId = buyer.id!!,
isFixed = false,
canViewAdultContent = false,
limit = 3
)
val nonBuyerPosts = repository.findCommunityPosts(
creator.id!!,
viewerId = nonBuyer.id!!,
isFixed = false,
canViewAdultContent = false,
limit = 3
)
assertEquals(listOf(post.id), buyerPosts.map { it.postId })
assertEquals("deleted paid content", buyerPosts.single().content)
assertEquals("deleted-paid.mp3", buyerPosts.single().audioPath)
assertTrue(buyerPosts.single().existOrdered)
assertEquals(emptyList<Long>(), nonBuyerPosts.map { it.postId })
}
@Test
@DisplayName("커뮤니티 댓글 수는 기존 목록처럼 보이는 최상위 댓글만 계산한다")
fun shouldCountVisibleRootCommunityCommentsOnly() {
val creator = saveMember("comment-count-creator", MemberRole.CREATOR)
val viewer = saveMember("comment-count-viewer", MemberRole.USER)
val blockedWriter = saveMember("comment-count-blocked", MemberRole.USER)
val blockingWriter = saveMember("comment-count-blocking", MemberRole.USER)
val secretWriter = saveMember("comment-count-secret", MemberRole.USER)
val post = saveCommunity(creator, isFixed = false, price = 0)
val visibleRoot = saveCommunityComment(viewer, post, isActive = true)
saveCommunityComment(viewer, post, isActive = true, parent = visibleRoot)
saveCommunityComment(viewer, post, isActive = false)
saveCommunityComment(blockedWriter, post, isActive = true)
saveCommunityComment(blockingWriter, post, isActive = true)
saveCommunityComment(secretWriter, post, isActive = true, isSecret = true)
saveBlock(viewer, blockedWriter)
saveBlock(blockingWriter, viewer)
flushAndClear()
val posts = repository.findCommunityPosts(
creator.id!!,
viewerId = viewer.id!!,
isFixed = false,
canViewAdultContent = false,
limit = 3
)
assertEquals(1, posts.single().commentCount)
}
@Test
@DisplayName("커뮤니티 댓글 수는 댓글 불가 게시글이면 기존 목록처럼 0으로 계산한다")
fun shouldReturnZeroCommentCountWhenCommunityCommentUnavailable() {
val creator = saveMember("comment-unavailable-creator", MemberRole.CREATOR)
val viewer = saveMember("comment-unavailable-viewer", MemberRole.USER)
val post = saveCommunity(creator, isFixed = false, price = 0, isCommentAvailable = false)
saveCommunityComment(viewer, post, isActive = true)
flushAndClear()
val posts = repository.findCommunityPosts(
creator.id!!,
viewerId = viewer.id!!,
isFixed = false,
canViewAdultContent = false,
limit = 3
)
assertEquals(0, posts.single().commentCount)
}
@Test
@DisplayName("채널 후원은 KST 기준 이번 달과 크리에이터의 비밀 후원 열람을 반영한다")
fun shouldFindKstMonthDonationsAndExposeSecretDonationToCreator() {
@@ -1421,34 +1171,6 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
return donation
}
private fun saveCommunity(
creator: Member,
isFixed: Boolean,
fixedAt: LocalDateTime? = null,
price: Int,
imagePath: String? = null,
audioPath: String? = null,
isAdult: Boolean = false,
isCommentAvailable: Boolean = true,
content: String = "community",
isActive: Boolean = true
): CreatorCommunity {
val community = CreatorCommunity(
content = content,
price = price,
isCommentAvailable = isCommentAvailable,
isAdult = isAdult,
audioPath = audioPath,
imagePath = imagePath,
isActive = isActive,
isFixed = isFixed,
fixedAt = fixedAt
)
community.member = creator
entityManager.persist(community)
return community
}
private fun saveAuth(member: Member, gender: Int): Auth {
val auth = Auth(
name = member.nickname,
@@ -1462,37 +1184,6 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
return auth
}
private fun saveCommunityLike(member: Member, community: CreatorCommunity, isActive: Boolean): CreatorCommunityLike {
val like = CreatorCommunityLike(isActive = isActive)
like.member = member
like.creatorCommunity = community
entityManager.persist(like)
return like
}
private fun saveCommunityComment(
member: Member,
community: CreatorCommunity,
isActive: Boolean,
isSecret: Boolean = false,
parent: CreatorCommunityComment? = null
): 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 saveCommunityOrder(member: Member, community: CreatorCommunity, isRefund: Boolean): UseCan {
val useCan = UseCan(CanUsage.PAID_COMMUNITY_POST, community.price, rewardCan = 0, isRefund = isRefund)
useCan.member = member
useCan.communityPost = community
entityManager.persist(useCan)
return useCan
}
private fun saveOrder(
member: Member,
creator: Member,

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.v2.creator.channel.home.application
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.i18n.Lang
@@ -16,8 +17,13 @@ import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
import kr.co.vividnext.sodalive.v2.api.creator.channel.home.dto.CreatorChannelHomeResponse
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.creator.channel.common.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryService
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityPost
import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicy
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityPostRecord
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityQueryPort
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelActivity
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCommunityPost
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelCreator
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelDonation
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelFanTalk
@@ -30,7 +36,6 @@ import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelSer
import kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelSns
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelActivityRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelAudioContentRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelCommunityPostRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelDonationRecord
import kr.co.vividnext.sodalive.v2.creator.channel.home.port.out.CreatorChannelFanTalkRecord
@@ -49,6 +54,7 @@ import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.beans.factory.ObjectProvider
import java.time.LocalDateTime
class CreatorChannelHomeQueryServiceTest {
@@ -58,7 +64,8 @@ class CreatorChannelHomeQueryServiceTest {
@DisplayName("크리에이터 채널 홈 서비스는 모든 섹션을 조립하고 최종 정책을 적용한다")
fun shouldAssembleCreatorChannelHomeWithFinalPolicies() {
val port = FakeCreatorChannelHomeQueryPort()
val service = createService(port, canViewAdultContent = false)
val communityPort = FakeCreatorChannelCommunityQueryPort()
val service = createService(port, communityPort, canViewAdultContent = false)
val viewer = createMember(id = 10L, gender = Gender.FEMALE, authGender = 1)
val now = LocalDateTime.of(2026, 6, 13, 10, 0)
@@ -76,7 +83,13 @@ class CreatorChannelHomeQueryServiceTest {
assertEquals(listOf(402L, 401L, 404L), home.schedules.map { it.targetId })
assertFalse(home.schedules.any { it.isAdult })
assertEquals("https://cdn.test/profile/fan.png", home.channelDonations.first().profileImageUrl)
assertEquals("https://cdn.test/community.png", home.notices.first().imageUrl)
assertNull(home.notices.first().imageUrl)
assertNull(home.notices.first().audioUrl)
assertEquals(listOf(1L, 1L), communityPort.homeCreatorIds)
assertEquals(listOf(10L, 10L), communityPort.homeViewerIds)
assertEquals(listOf(true, false), communityPort.homeIsPinnedValues)
assertEquals(listOf(false, false), communityPort.homeCanViewAdultContentValues)
assertEquals(listOf(3, 3), communityPort.homeLimits)
assertEquals("https://cdn.test/series.png", home.series.first().coverImageUrl)
assertEquals("introduce", home.introduce)
assertEquals(Gender.MALE, port.currentLiveEffectiveViewerGender)
@@ -272,10 +285,12 @@ class CreatorChannelHomeQueryServiceTest {
audioUrl = "audio.mp3",
content = "notice",
price = 10,
date = LocalDateTime.of(2026, 6, 12, 4, 0),
createdAt = LocalDateTime.of(2026, 6, 12, 4, 0),
existOrdered = true,
isCommentAvailable = true,
likeCount = 2,
commentCount = 3
commentCount = 3,
isPinned = true
)
return CreatorChannelHome(
@@ -392,6 +407,7 @@ class CreatorChannelHomeQueryServiceTest {
private fun createService(
port: FakeCreatorChannelHomeQueryPort,
communityPort: FakeCreatorChannelCommunityQueryPort = FakeCreatorChannelCommunityQueryPort(),
canViewAdultContent: Boolean = true
): CreatorChannelHomeQueryService {
val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
@@ -408,8 +424,20 @@ class CreatorChannelHomeQueryServiceTest {
val messageSource = SodaMessageSource()
val langContext = LangContext()
langContext.setLang(Lang.KO)
val audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java)
Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong()))
.thenAnswer { invocation -> "https://signed.test/${invocation.getArgument<String>(0)}" }
return CreatorChannelHomeQueryService(
queryPort = port,
communityQueryService = CreatorChannelCommunityQueryService(
queryPortProvider = FixedCreatorChannelCommunityQueryPortProvider(communityPort),
queryPolicy = CreatorChannelCommunityQueryPolicy(),
memberContentPreferenceService = preferenceService,
audioContentCloudFront = audioContentCloudFront,
messageSource = messageSource,
langContext = langContext,
cloudFrontHost = "https://cdn.test"
),
queryPolicy = CreatorChannelHomeQueryPolicy(),
memberContentPreferenceService = preferenceService,
messageSource = messageSource,
@@ -517,29 +545,6 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort {
)
)
override fun findCommunityPosts(
creatorId: Long,
viewerId: Long?,
isFixed: Boolean,
canViewAdultContent: Boolean,
limit: Int
): List<CreatorChannelCommunityPostRecord> = listOf(
CreatorChannelCommunityPostRecord(
postId = if (isFixed) 301L else 302L,
creatorId = creatorId,
creatorNickname = "creator",
creatorProfilePath = "profile/creator.png",
imagePath = "community.png",
audioPath = "community.mp3",
content = if (isFixed) "notice" else "community",
price = 0,
date = LocalDateTime.of(2026, 6, 13, 7, 0),
existOrdered = false,
likeCount = 3,
commentCount = 4
)
)
override fun findSchedules(
creatorId: Long,
now: LocalDateTime,
@@ -666,3 +671,70 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort {
)
}
}
private class FixedCreatorChannelCommunityQueryPortProvider(
private val port: CreatorChannelCommunityQueryPort
) : ObjectProvider<CreatorChannelCommunityQueryPort> {
override fun getObject(vararg args: Any?): CreatorChannelCommunityQueryPort = port
override fun getIfAvailable(): CreatorChannelCommunityQueryPort = port
override fun getIfUnique(): CreatorChannelCommunityQueryPort = port
override fun getObject(): CreatorChannelCommunityQueryPort = port
}
private class FakeCreatorChannelCommunityQueryPort : CreatorChannelCommunityQueryPort {
val homeCreatorIds = mutableListOf<Long>()
val homeViewerIds = mutableListOf<Long>()
val homeIsPinnedValues = mutableListOf<Boolean>()
val homeCanViewAdultContentValues = mutableListOf<Boolean>()
val homeLimits = mutableListOf<Int>()
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCommunityCreatorRecord? {
return CreatorChannelCommunityCreatorRecord(creatorId = creatorId, role = MemberRole.CREATOR, nickname = "creator")
}
override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean = false
override fun countCommunityPosts(creatorId: Long, viewerId: Long, canViewAdultContent: Boolean): Int = 0
override fun findCommunityPosts(
creatorId: Long,
viewerId: Long,
canViewAdultContent: Boolean,
offset: Long,
limit: Int
): List<CreatorChannelCommunityPostRecord> = emptyList()
override fun findHomeCommunityPosts(
creatorId: Long,
viewerId: Long,
isPinned: Boolean,
canViewAdultContent: Boolean,
limit: Int
): List<CreatorChannelCommunityPostRecord> {
homeCreatorIds += creatorId
homeViewerIds += viewerId
homeIsPinnedValues += isPinned
homeCanViewAdultContentValues += canViewAdultContent
homeLimits += limit
return listOf(
CreatorChannelCommunityPostRecord(
postId = if (isPinned) 301L else 302L,
creatorId = creatorId,
creatorNickname = "creator",
creatorProfilePath = "profile/creator.png",
imagePath = if (isPinned) "image/301.png" else "image/302.png",
audioPath = if (isPinned) "audio/301.mp3" else "audio/302.mp3",
content = if (isPinned) "notice" else "community",
price = 100,
createdAt = LocalDateTime.of(2026, 6, 13, 7, 0),
existOrdered = false,
isCommentAvailable = true,
likeCount = 3,
commentCount = 4,
isPinned = isPinned
)
)
}
}