Compare commits
17 Commits
998dd10311
...
a96d9ddc76
| Author | SHA1 | Date | |
|---|---|---|---|
| a96d9ddc76 | |||
| ccfe3f79c7 | |||
| c04d72b04e | |||
| 3360477f75 | |||
| 0a6a689773 | |||
| e0e6b34d21 | |||
| bd4e865f2e | |||
| 45337663e5 | |||
| 014511668a | |||
| 6ab3c50c32 | |||
| 06e82f1bba | |||
| 0620e54cbd | |||
| 00695d5b33 | |||
| 078718c041 | |||
| 2ebe7afab7 | |||
| d249d9c257 | |||
| 94b5c70cc6 |
580
docs/20260621_크리에이터_채널_커뮤니티_탭_API/plan-task.md
Normal file
580
docs/20260621_크리에이터_채널_커뮤니티_탭_API/plan-task.md
Normal 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는 변경하지 않았다.
|
||||
254
docs/20260621_크리에이터_채널_커뮤니티_탭_API/prd.md
Normal file
254
docs/20260621_크리에이터_채널_커뮤니티_탭_API/prd.md
Normal 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`를 먼저 갱신한다.
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)!!
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 = "..."
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user