Compare commits

...

23 Commits

Author SHA1 Message Date
2c44cb90ee test(creator-channel): 후원 탭 E2E 검증을 추가한다 2026-06-22 21:12:22 +09:00
02d5446888 docs(creator-channel): 후원 탭 Phase 2 기록을 갱신한다 2026-06-22 19:19:00 +09:00
8e76c2d640 feat(creator-channel): 후원 탭 legacy 랭킹 adapter를 추가한다 2026-06-22 19:18:27 +09:00
951f6789f0 feat(creator-channel): 후원 탭 repository를 추가한다 2026-06-22 19:17:56 +09:00
046ce700c7 feat(creator-channel): 후원 탭 조회 서비스를 구현한다 2026-06-22 19:17:45 +09:00
13b679d091 docs(creator-channel): 후원 탭 Phase 1 기록을 갱신한다 2026-06-22 18:00:51 +09:00
7e9e0aa320 feat(creator-channel): 후원 탭 endpoint를 추가한다 2026-06-22 18:00:16 +09:00
14f648cd10 feat(creator-channel): 후원 탭 응답 조립을 추가한다 2026-06-22 17:59:41 +09:00
34e05a577e feat(creator-channel): 후원 탭 조회 서비스 보호 동작을 추가한다 2026-06-22 17:59:09 +09:00
e516a7406f feat(creator-channel): 후원 탭 도메인 계약을 추가한다 2026-06-22 17:59:01 +09:00
b2fae3e081 docs(creator-channel): 후원 탭 API 계획을 기록한다 2026-06-22 16:31:54 +09:00
4ffd880440 docs(creator-channel): FanTalk 탭 Phase 5 기록을 갱신한다 2026-06-22 16:12:35 +09:00
45fafa9b00 test(creator-channel): FanTalk 탭 E2E 검증을 추가한다 2026-06-22 16:12:04 +09:00
bb44eaa8dd docs(creator-channel): FanTalk 탭 Phase 3과 4 기록을 갱신한다 2026-06-22 15:52:53 +09:00
408a342f17 feat(creator-channel): FanTalk 탭 repository를 추가한다 2026-06-22 15:52:03 +09:00
2848f07573 feat(creator-channel): FanTalk 탭 조회 서비스를 구현한다 2026-06-22 15:51:47 +09:00
e2a3aeefc2 docs(creator-channel): FanTalk 탭 Phase 2 기록을 갱신한다 2026-06-22 14:52:13 +09:00
0ebb686ce6 feat(creator-channel): FanTalk 탭 endpoint를 추가한다 2026-06-22 14:51:52 +09:00
90bf4c770c feat(creator-channel): FanTalk 탭 응답 조립을 추가한다 2026-06-22 14:51:44 +09:00
831c26c155 docs(creator-channel): FanTalk 탭 Phase 1 기록을 갱신한다 2026-06-22 14:26:57 +09:00
41937c7cce feat(creator-channel): FanTalk 탭 도메인 계약을 추가한다 2026-06-22 14:26:31 +09:00
dc9ee06bb8 docs(creator-channel): FanTalk 탭 API 계획을 기록한다 2026-06-22 13:40:12 +09:00
b1b6de8c3b fix(creator-channel): FanTalk 엔티티 data class 선언을 제거한다 2026-06-22 13:39:36 +09:00
38 changed files with 5132 additions and 1 deletions

View File

@@ -0,0 +1,594 @@
# 크리에이터 채널 FanTalk 탭 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}/fan-talks`로 크리에이터 채널 FanTalk 탭의 전체 FanTalk 개수와 페이징된 FanTalk 글 목록, 크리에이터 답글을 조회할 수 있게 한다.
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk` 조립 계층에 둔다. FanTalk 조회 service, page 정책, tab domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.creator.channel.fantalk` 하위에 두고 `v2.api.*`에 의존하지 않는다. 저장 엔티티는 legacy `CreatorCheers`를 그대로 사용하되, legacy timezone 기반 cheers 응답은 재사용하지 않고 V2 탭 전용 UTC 응답을 만든다.
**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}/fan-talks`
- 인증 정책: 인증 회원만 조회 가능. 비회원은 기존 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
- page 기준: 기존 크리에이터 채널 V2 탭 API와 동일한 0 기반 page index
- response:
- `fanTalkCount`: 조회자가 조회 가능한 최상위 FanTalk 전체 개수
- `fanTalks`: FanTalk 글 목록
- `page`: fallback 보정 후 실제 적용된 page index
- `size`: fallback 보정 후 실제 적용된 page size
- `hasNext`: 다음 page 존재 여부
- FanTalk item:
- `fanTalkId`, `writerId`, `writerNickname`, `writerProfileImageUrl`, `content`, `createdAtUtc`, `creatorReplies`
- creator reply item:
- `fanTalkId`, `writerId`, `writerNickname`, `writerProfileImageUrl`, `content`, `createdAtUtc`
- 저장 엔티티: `kr.co.vividnext.sodalive.explorer.profile.CreatorCheers`
- 최상위 FanTalk 기준: `creatorCheers.creator.id == creatorId`, `creatorCheers.isActive == true`, `creatorCheers.parent is null`
- 크리에이터 답글 기준: `creatorCheers.parent.id in parentFanTalkIds`, `creatorCheers.creator.id == creatorId`, `creatorCheers.member.id == creatorId`, `creatorCheers.isActive == true`
- 팬끼리 답글 작성은 현재 불가능하므로 응답 대상에 포함하지 않는다. 과거 데이터나 비정상 데이터로 크리에이터가 아닌 회원의 답글이 있어도 제외한다.
- 목록 정렬:
- 최상위 FanTalk: `createdAt desc`, `id desc`
- 크리에이터 답글: `createdAt asc`, `id asc`
- `fanTalkCount`는 최상위 FanTalk만 계산한다. 답글은 count에 포함하지 않는다.
- `hasNext``size + 1`개 조회 또는 동등한 방식으로 판단하고, 응답 목록에는 최대 `size`개만 내려준다.
- 차단 필터:
- 조회자와 FanTalk 작성자가 서로 차단 관계이면 해당 최상위 FanTalk는 목록과 count에서 제외한다.
- 차단으로 제외된 최상위 FanTalk의 답글도 응답에 포함하지 않는다.
- 조회자와 조회 대상 크리에이터 사이 차단 관계는 기존 크리에이터 채널 접근 정책과 동일하게 API 접근 자체를 거부한다.
- creator 검증:
- 조회 대상 회원이 없으면 `member.validation.user_not_found`
- 조회 대상 회원이 크리에이터가 아니면 `member.validation.creator_not_found`
- 조회자와 크리에이터 사이에 차단 관계가 있으면 기존 크리에이터 채널 접근 차단 오류
- `createdAtUtc``CreatorCheers.createdAt``kr.co.vividnext.sodalive.extensions.toUtcIso`로 변환한다.
- 프로필 이미지 URL은 `String?.toCdnUrl(cloudFrontHost)`를 사용하고, 없으면 기존 홈 API와 같은 `"$cloudFrontHost/profile/default-profile.png"`를 내려준다.
- 탈퇴 회원 닉네임 prefix 제거는 기존 legacy FanTalk 조회와 홈 FanTalk 요약 응답처럼 `removeDeletedNicknamePrefix()`를 적용한다.
- `languageCode`는 FanTalk 탭 응답에 포함하지 않는다.
- legacy `/profile/{id}/cheers` 공개 endpoint와 응답 스키마는 변경하지 않는다.
- 크리에이터 채널 홈 API의 `fanTalk.totalCount`, `fanTalk.latestFanTalk` 공개 응답 의미는 변경하지 않는다.
- DB schema, 운영 DDL, 마이그레이션은 포함하지 않는다.
---
## 1. 파일 구조 계획
### FanTalk 탭 신규 API 조립 계층
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkController.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacade.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/dto/CreatorChannelFanTalkTabResponse.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkControllerTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkEndToEndTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacadeTest.kt`
### FanTalk 도메인 조회 계층
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkTab.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicy.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/port/out/CreatorChannelFanTalkQueryPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/CreatorChannelFanTalkQueryRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicyTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepositoryTest.kt`
### 기존 파일 확인/재사용
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelPage.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CdnUrlExtensions.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRole.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorCheers.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt`
### 문서 산출물
- Create: `docs/20260622_크리에이터_채널_FanTalk_탭_API/plan-task.md`
- Verify: `docs/20260622_크리에이터_채널_FanTalk_탭_API/prd.md`
---
## 2. Response data class 초안
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/dto/CreatorChannelFanTalkTabResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다.
```kotlin
package kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.extensions.toUtcIso
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalk
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkReply
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkTab
data class CreatorChannelFanTalkTabResponse(
val fanTalkCount: Int,
val fanTalks: List<CreatorChannelFanTalkResponse>,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
) {
companion object {
fun from(tab: CreatorChannelFanTalkTab): CreatorChannelFanTalkTabResponse {
return CreatorChannelFanTalkTabResponse(
fanTalkCount = tab.fanTalkCount,
fanTalks = tab.fanTalks.map(CreatorChannelFanTalkResponse::from),
page = tab.page.page,
size = tab.page.size,
hasNext = tab.hasNext
)
}
}
}
data class CreatorChannelFanTalkResponse(
val fanTalkId: Long,
val writerId: Long,
val writerNickname: String,
val writerProfileImageUrl: String,
val content: String,
val createdAtUtc: String,
val creatorReplies: List<CreatorChannelFanTalkReplyResponse>
) {
companion object {
fun from(fanTalk: CreatorChannelFanTalk): CreatorChannelFanTalkResponse {
return CreatorChannelFanTalkResponse(
fanTalkId = fanTalk.fanTalkId,
writerId = fanTalk.writerId,
writerNickname = fanTalk.writerNickname,
writerProfileImageUrl = fanTalk.writerProfileImageUrl,
content = fanTalk.content,
createdAtUtc = fanTalk.createdAt.toUtcIso(),
creatorReplies = fanTalk.creatorReplies.map(CreatorChannelFanTalkReplyResponse::from)
)
}
}
}
data class CreatorChannelFanTalkReplyResponse(
val fanTalkId: Long,
val writerId: Long,
val writerNickname: String,
val writerProfileImageUrl: String,
val content: String,
val createdAtUtc: String
) {
companion object {
fun from(reply: CreatorChannelFanTalkReply): CreatorChannelFanTalkReplyResponse {
return CreatorChannelFanTalkReplyResponse(
fanTalkId = reply.fanTalkId,
writerId = reply.writerId,
writerNickname = reply.writerNickname,
writerProfileImageUrl = reply.writerProfileImageUrl,
content = reply.content,
createdAtUtc = reply.createdAt.toUtcIso()
)
}
}
}
```
---
## 3. Domain / Port 초안
구현 시 아래 형태를 기준으로 추가한다. API DTO가 domain model을 참조하지만 domain/port는 API DTO를 참조하지 않는다.
```kotlin
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
import java.time.LocalDateTime
data class CreatorChannelFanTalkTab(
val fanTalkCount: Int,
val fanTalks: List<CreatorChannelFanTalk>,
val page: CreatorChannelPage,
val hasNext: Boolean
)
data class CreatorChannelFanTalk(
val fanTalkId: Long,
val writerId: Long,
val writerNickname: String,
val writerProfileImageUrl: String,
val content: String,
val createdAt: LocalDateTime,
val creatorReplies: List<CreatorChannelFanTalkReply>
)
data class CreatorChannelFanTalkReply(
val fanTalkId: Long,
val writerId: Long,
val writerNickname: String,
val writerProfileImageUrl: String,
val content: String,
val createdAt: LocalDateTime
)
```
```kotlin
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out
import kr.co.vividnext.sodalive.member.MemberRole
import java.time.LocalDateTime
interface CreatorChannelFanTalkQueryPort {
fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelFanTalkCreatorRecord?
fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean
fun countFanTalks(creatorId: Long, viewerId: Long): Int
fun findFanTalks(
creatorId: Long,
viewerId: Long,
offset: Long,
limit: Int
): List<CreatorChannelFanTalkRecord>
fun findCreatorReplies(
creatorId: Long,
parentFanTalkIds: List<Long>
): List<CreatorChannelFanTalkReplyRecord>
}
data class CreatorChannelFanTalkCreatorRecord(
val creatorId: Long,
val role: MemberRole,
val nickname: String
)
data class CreatorChannelFanTalkRecord(
val fanTalkId: Long,
val writerId: Long,
val writerNickname: String,
val writerProfileImagePath: String?,
val content: String,
val createdAt: LocalDateTime
)
data class CreatorChannelFanTalkReplyRecord(
val fanTalkId: Long,
val parentFanTalkId: Long,
val writerId: Long,
val writerNickname: String,
val writerProfileImagePath: String?,
val content: String,
val createdAt: LocalDateTime
)
```
---
## 4. Query policy 초안
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicy.kt`에 아래 정책을 둔다.
```kotlin
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
import org.springframework.stereotype.Component
@Component
class CreatorChannelFanTalkQueryPolicy {
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
}
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
}
}
```
---
## 5. 구현 TASK
### Phase 1: FanTalk 도메인 모델과 페이징 정책
- [x] **Task 1.1: FanTalk 페이징 정책 테스트와 구현**
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicyTest.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicy.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelPage.kt`
- RED: `page`, `size` 보정과 `hasNext`, `limitItems` 동작 테스트를 먼저 작성한다.
- 테스트 케이스:
- `page == null`, `size == null`이면 `page=0`, `size=20`
- `page < 0`이면 `0`
- `size < 20`이면 `20`
- `size > 50`이면 `50`
- fetched size가 `size + 1`이면 `hasNext == true`
- fetched size가 `size` 이하이면 `hasNext == false`
- `limitItems`는 최대 `size`개만 반환
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicyTest`
- GREEN: `CreatorChannelFanTalkQueryPolicy``CreatorChannelCommunityQueryPolicy`와 같은 보정 규칙으로 최소 구현한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicyTest`
- REFACTOR: 상수와 메서드명이 커뮤니티/시리즈 탭 정책과 일관되는지 확인한다.
- [x] **Task 1.2: FanTalk domain model과 port 계약 추가**
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkTab.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/port/out/CreatorChannelFanTalkQueryPort.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicyTest.kt`
- RED: Task 1.1 테스트에 domain/port 타입 import를 추가해 타입 부재 컴파일 실패를 확인한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicyTest`
- GREEN: `CreatorChannelFanTalkTab`, `CreatorChannelFanTalk`, `CreatorChannelFanTalkReply`, `CreatorChannelFanTalkQueryPort`, record data class를 추가한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicyTest`
- REFACTOR: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain``port/out`에서 `v2.api` import가 없는지 확인한다.
- 확인 명령: `rg -n "v2\\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk`
### Phase 2: API 응답 DTO와 조립 계층
- [x] **Task 2.1: FanTalk 응답 DTO와 UTC 변환 테스트**
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/dto/CreatorChannelFanTalkTabResponse.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacadeTest.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt`
- RED: facade 테스트에서 domain tab을 response로 변환했을 때 필드명과 UTC 문자열이 PRD와 일치하는지 검증한다.
- 검증 값:
- `fanTalkCount`
- `fanTalks[0].writerId`
- `fanTalks[0].writerNickname`
- `fanTalks[0].writerProfileImageUrl`
- `fanTalks[0].content`
- `fanTalks[0].createdAtUtc`
- `fanTalks[0].creatorReplies[0].writerId`
- `page`
- `size`
- JSON 직렬화 필드명 `hasNext`
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest`
- GREEN: DTO를 추가하고 `createdAt.toUtcIso()`를 사용해 UTC ISO 문자열을 내려준다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest`
- REFACTOR: `languageCode`가 응답 DTO에 포함되지 않았는지 확인한다.
- 확인 명령: `rg -n "languageCode" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk`
- [x] **Task 2.2: FanTalk facade 추가**
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacade.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacadeTest.kt`
- RED: facade가 query service의 `getFanTalkTab(creatorId, viewer, page, size, now)` 결과를 `CreatorChannelFanTalkTabResponse`로 변환하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest`
- GREEN: `CreatorChannelFanTalkFacade``@Service`, `@Transactional(readOnly = true)`로 추가한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest`
- REFACTOR: facade가 API DTO와 domain query service 조립 외 책임을 갖지 않는지 확인한다.
- [x] **Task 2.3: FanTalk controller 추가**
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkController.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkControllerTest.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt`
- RED: MockMvc 테스트를 작성한다.
- `GET /api/v2/creator-channels/{creatorId}/fan-talks?page=1&size=20` 요청이 facade에 `creatorId`, `page=1`, `size=20`을 전달한다.
- 비회원 요청은 `common.error.bad_credentials` 계열 오류를 반환한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest`
- GREEN: `@RequestMapping("/api/v2/creator-channels")`, `@GetMapping("/{creatorId}/fan-talks")`, `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member")` 구조로 controller를 추가한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest`
- REFACTOR: controller가 `ApiResponse.ok(...)``requireMember` 외 응답 가공 책임을 갖지 않는지 확인한다.
### Phase 3: FanTalk 조회 서비스
- [x] **Task 3.1: query service의 creator 검증과 접근 차단 처리**
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryServiceTest.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt`
- RED: query service 테스트를 작성한다.
- creator가 없으면 `SodaException(messageKey = "member.validation.user_not_found")`
- creator role이 `MemberRole.CREATOR`가 아니면 `SodaException(messageKey = "member.validation.creator_not_found")`
- 조회자와 크리에이터 사이 차단 관계가 있으면 기존 채널 접근 차단 오류
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest`
- GREEN: `CreatorChannelFanTalkQueryService`를 추가하고 `findCreator`, `existsBlockedBetween`, role 검증을 구현한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest`
- REFACTOR: 에러 키와 차단 메시지 흐름이 커뮤니티/홈 query service와 같은지 확인한다.
- [x] **Task 3.2: query service의 page/count/list/reply 조립**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryServiceTest.kt`
- RED: query service 테스트를 추가한다.
- `page=-1`, `size=10` 요청 시 port에는 `offset=0`, `limit=21`이 전달되고 응답 `page=0`, `size=20`
- fetched FanTalk가 `size + 1`개이면 응답 목록은 `size`개이고 `hasNext=true`
- fetched FanTalk가 비어 있으면 `fanTalks=[]`, `hasNext=false`
- `countFanTalks` 결과가 `fanTalkCount`로 내려간다.
- `findCreatorReplies` 결과는 parent id 기준으로 각 FanTalk의 `creatorReplies`에 묶인다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest`
- GREEN: `CreatorChannelFanTalkQueryPolicy`로 page를 만들고, `countFanTalks`, `findFanTalks`, `findCreatorReplies`를 호출해 `CreatorChannelFanTalkTab`을 조립한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest`
- REFACTOR: reply 조회는 page에 포함된 parent FanTalk id만 대상으로 호출하는지 확인한다.
- [x] **Task 3.3: query service의 URL/닉네임 변환**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryServiceTest.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CdnUrlExtensions.kt`
- RED: query service 테스트를 추가한다.
- writer profile path가 있으면 CDN URL로 변환한다.
- writer profile path가 없으면 `"$cloudFrontHost/profile/default-profile.png"`를 내려준다.
- writer nickname은 `removeDeletedNicknamePrefix()` 결과를 내려준다.
- reply writer도 같은 URL/닉네임 변환을 적용한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest`
- GREEN: `String?.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl()``removeDeletedNicknamePrefix()`를 적용한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest`
- REFACTOR: default profile URL 생성 방식이 홈/커뮤니티 query service와 일관되는지 확인한다.
### Phase 4: QueryDSL repository
- [x] **Task 4.1: FanTalk repository 기본 creator/차단 조회**
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/CreatorChannelFanTalkQueryRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepository.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepositoryTest.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
- RED: repository 테스트를 작성한다.
- `findCreator`가 creator id, role, nickname을 조회한다.
- `existsBlockedBetween`가 양방향 활성 차단 관계를 감지한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest`
- GREEN: `JPAQueryFactory` 기반 repository를 추가하고 홈/커뮤니티 repository와 같은 creator/차단 조건을 구현한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest`
- REFACTOR: repository class 이름은 `Default...Repository` 접두사 규칙을 따른다.
- [x] **Task 4.2: 최상위 FanTalk count/list 조회**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepository.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepositoryTest.kt`
- RED: repository 테스트를 추가한다.
- `countFanTalks``creator.id`, `isActive=true`, `parent is null` 조건만 count한다.
- 비활성 FanTalk는 count/list에서 제외한다.
- 답글 FanTalk는 count/list에서 제외한다.
- 조회자와 작성자 사이 차단 관계가 있으면 count/list에서 제외한다.
- 목록 정렬은 `createdAt desc`, `id desc`다.
- `offset`, `limit`이 적용된다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest`
- GREEN: `countFanTalks`, `findFanTalks`를 구현한다. projection은 `CreatorChannelFanTalkRecord`를 사용한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest`
- REFACTOR: 홈 API의 `fanTalkSummaryCondition`과 조건 의미가 일치하는지 확인한다.
- [x] **Task 4.3: 크리에이터 답글 조회**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepository.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepositoryTest.kt`
- RED: repository 테스트를 추가한다.
- `findCreatorReplies`는 parent id 목록에 속한 활성 답글만 조회한다.
- 답글 작성자가 조회 대상 크리에이터인 데이터만 조회한다.
- 크리에이터가 아닌 회원의 답글은 제외한다.
- 비활성 답글은 제외한다.
- 답글 정렬은 `createdAt asc`, `id asc`다.
- `parentFanTalkIds`가 빈 목록이면 빈 목록을 반환하고 DB 조회 결과가 없어야 한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest`
- GREEN: `findCreatorReplies`를 구현한다. projection은 `CreatorChannelFanTalkReplyRecord`를 사용한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest`
- REFACTOR: reply 조회가 최상위 FanTalk page 결과 외 parent를 가져오지 않는지 확인한다.
### Phase 5: API 통합과 회귀 검증
- [x] **Task 5.1: FanTalk End-to-End 테스트**
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkEndToEndTest.kt`
- RED: E2E 테스트를 작성한다.
- 인증 회원이 `GET /api/v2/creator-channels/{creatorId}/fan-talks?page=0&size=20` 호출 시 200 OK
- 응답 JSON에 `fanTalkCount`, `fanTalks`, `page`, `size`, `hasNext`가 포함된다.
- 최상위 FanTalk의 `createdAtUtc`는 UTC ISO 문자열이다.
- 크리에이터 답글은 `creatorReplies`에 포함된다.
- 팬이 작성한 비정상 답글 데이터는 응답에 포함되지 않는다.
- page 범위를 벗어나면 빈 목록과 `hasNext=false`를 반환하되 count는 유지한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkEndToEndTest`
- GREEN: Phase 1~4 구현을 연결해 E2E 테스트를 통과시킨다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkEndToEndTest`
- REFACTOR: 테스트 데이터가 다른 크리에이터 채널 탭 테스트와 충돌하지 않도록 독립 fixture를 사용한다.
- [x] **Task 5.2: 패키지 의존 방향과 기존 API 회귀 확인**
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt`
- RED: 신규 테스트 추가는 없다. 이 task는 문서화된 구조 검증 task다.
- TDD 예외 사유: 패키지 의존 방향과 기존 endpoint 비변경 여부는 정적 검색과 기존 회귀 테스트가 더 직접적인 검증이다.
- 대체 검증 방법:
- `rg -n "v2\\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk`
- `rg -n "fan-talks|/profile/\\{id\\}/cheers|latestFanTalk" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/main/kotlin/kr/co/vividnext/sodalive/explorer`
- GREEN: `v2.creator.channel.fantalk` 하위에서 `v2.api.*` import 검색 결과가 0건인지 확인한다.
- 통과 확인:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkEndToEndTest`
- REFACTOR: 홈 API와 legacy cheers endpoint의 공개 응답 스키마를 변경한 파일 diff가 없는지 확인한다.
- [x] **Task 5.3: 전체 FanTalk 관련 테스트와 ktlint 검증**
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk`
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk`
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk`
- RED: 신규 테스트 추가는 없다. 이 task는 구현 완료 후 회귀 검증 task다.
- TDD 예외 사유: 전체 회귀와 ktlint는 구현 완료 상태를 검증하는 명령 실행 task다.
- 대체 검증 방법:
- `./gradlew test --tests "kr.co.vividnext.sodalive.v2.*creator.channel.fantalk*"`
- `./gradlew ktlintCheck`
- GREEN: 실패하는 FanTalk 관련 테스트나 ktlint 오류가 있으면 해당 task의 구현 단계로 돌아가 수정한다.
- 통과 확인:
- `./gradlew test --tests "kr.co.vividnext.sodalive.v2.*creator.channel.fantalk*"`
- `./gradlew ktlintCheck`
- REFACTOR: 필요한 경우 `./gradlew test`를 추가 실행하고 결과를 이 문서 하단 검증 기록에 누적한다.
---
## 6. 구현 시 주의사항
- 구현 전에 이 문서와 `docs/20260622_크리에이터_채널_FanTalk_탭_API/prd.md`가 같은 endpoint, page 기준, response field를 말하는지 다시 확인한다.
- 신규 공개 API 스키마 변경이 필요하면 구현 전에 PRD와 이 문서를 먼저 수정한다.
- `CreatorCheers` 엔티티 자체 구조는 변경하지 않는다.
- legacy `ExplorerQueryRepository.getCheersList`는 timezone 표시 문자열을 만들기 때문에 신규 V2 응답 DTO에 재사용하지 않는다.
- FanTalk 탭 query service는 홈 API query service에 의존하지 않는다.
- 홈 API의 `findFanTalkSummary`는 이번 작업에서 수정하지 않는 것을 기본으로 한다. 수정이 필요해지면 PRD와 이 문서를 먼저 갱신한다.
- controller/facade/DTO 조립 계층은 `v2.api.creator.channel.fantalk`에만 둔다.
- domain/application/port/repository 조회 계층은 `v2.creator.channel.fantalk`에만 둔다.
- 테스트 작성 시 Redis가 필요 없는 JPA/QueryDSL slice 테스트는 `@DataJpaTest(properties = ["spring.cache.type=none"])` 관례를 따른다.
- 테스트 완료 후 각 task 아래에 실행 명령과 결과를 한국어로 누적 기록한다.
---
## 7. 검증 기록
- 문서 생성 시점에는 구현 코드를 작성하지 않았으므로 신규 테스트는 실행하지 않았다.
- 문서 변경 검증으로 `./gradlew tasks --all`을 실행했다.
- sandbox 일반 실행은 Gradle wrapper가 `/Users/klaus/.gradle/wrapper/dists/gradle-8.1.1-bin/9wiye5v2saajue4irfo8ybqfp/gradle-8.1.1-bin.zip.lck`에 접근하지 못해 `Operation not permitted`로 실패했다.
- 권한 승인 후 같은 명령을 재실행했고 `BUILD SUCCESSFUL`을 확인했다.
- Phase 1 Task 1.1/1.2 구현 검증을 진행했다.
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicyTest` 실행 시 `CreatorChannelFanTalkQueryPolicy`, FanTalk domain model, FanTalk port record 미존재로 `compileTestKotlin` 실패를 확인했다.
- GREEN: FanTalk 페이징 정책, domain model, port 계약 추가 후 같은 명령을 재실행했고 `BUILD SUCCESSFUL`을 확인했다.
- 의존 방향 확인: `rg -n "v2\\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk` 결과 0건을 확인했다.
- `lsp_diagnostics`는 로컬에 `kotlin-lsp` 명령이 설치되어 있지 않아 실행할 수 없었다. Kotlin 컴파일은 위 Gradle 테스트 실행으로 확인했다.
- Phase 2 Task 2.1/2.2 구현 검증을 진행했다.
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest` 실행 시 FanTalk 응답 DTO, FanTalk facade, FanTalk query service 타입 미존재로 `compileTestKotlin` 실패를 확인했다.
- GREEN: FanTalk 응답 DTO, FanTalk facade, Phase 3 구현 전 facade 컴파일을 위한 `CreatorChannelFanTalkQueryService` 최소 shell 추가 후 같은 명령을 재실행했고 `BUILD SUCCESSFUL`을 확인했다.
- Phase 2 범위 준수: `CreatorChannelFanTalkQueryService`는 최종 public method signature만 두고 조회/검증/DB/port 구현은 추가하지 않았다.
- Phase 2 Task 2.3 구현 검증을 진행했다.
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest` 실행 시 `CreatorChannelFanTalkController` 미존재로 `compileTestKotlin` 실패를 확인했다.
- GREEN: FanTalk controller 추가 후 같은 명령을 재실행했고 `BUILD SUCCESSFUL`을 확인했다.
- `lsp_diagnostics`는 로컬에 `kotlin-lsp` 명령이 설치되어 있지 않아 실행할 수 없었다. Kotlin 컴파일은 위 Gradle 테스트 실행으로 확인했다.
- Phase 3 Task 3.1/3.2/3.3 구현 검증을 진행했다.
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest` 실행 시 `CreatorChannelFanTalkQueryService` 생성자 의존성 미구현으로 `compileTestKotlin` 실패를 확인했다.
- GREEN: FanTalk query service의 creator 검증, 접근 차단, page/count/list/reply 조립, CDN URL/default profile URL, 탈퇴 닉네임 prefix 제거 구현 후 같은 명령을 재실행했고 `BUILD SUCCESSFUL`을 확인했다.
- 회귀 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest``./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest` 실행 결과 모두 `BUILD SUCCESSFUL`을 확인했다.
- FanTalk 관련 전체 테스트 확인: `./gradlew test --tests "kr.co.vividnext.sodalive.v2.*creator.channel.fantalk*"` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 의존 방향 확인: `rg -n "v2\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk` 결과 0건을 확인했다.
- ktlint 확인: `./gradlew ktlintCheck` 최초 실행 시 신규 테스트의 긴 assertion 줄로 실패했고, 테스트 포맷만 수정한 뒤 재실행해 `BUILD SUCCESSFUL`을 확인했다.
- `lsp_diagnostics`는 로컬에 `kotlin-lsp` 명령이 설치되어 있지 않아 실행할 수 없었다. Kotlin 컴파일은 위 Gradle 테스트 실행으로 확인했다.
- Phase 4 Task 4.1/4.2/4.3 구현 검증을 진행했다.
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest` 실행 시 `DefaultCreatorChannelFanTalkQueryRepository` 미존재로 `compileTestKotlin` 실패를 확인했다.
- GREEN: FanTalk QueryDSL repository의 creator 조회, 양방향 차단 조회, 최상위 FanTalk count/list, 크리에이터 답글 조회 구현 후 같은 명령을 재실행했고 `BUILD SUCCESSFUL`을 확인했다.
- `lsp_diagnostics`는 로컬에 `kotlin-lsp` 명령이 설치되어 있지 않아 실행할 수 없었다. Kotlin 컴파일은 위 Gradle 테스트 실행으로 확인했다.
- 회귀 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- FanTalk 관련 전체 테스트 확인: `./gradlew test --tests "kr.co.vividnext.sodalive.v2.*creator.channel.fantalk*"` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 의존 방향 확인: `rg -n "v2\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk` 결과 0건을 확인했다.
- ktlint 확인: `./gradlew ktlintCheck` 최초 실행 시 신규 테스트의 긴 fixture 호출 줄로 실패했고, 테스트 포맷만 수정한 뒤 재실행해 `BUILD SUCCESSFUL`을 확인했다.
- Phase 4 코드 리뷰 및 재검증을 진행했다.
- 리뷰 범위: `DefaultCreatorChannelFanTalkQueryRepository`, `CreatorChannelFanTalkQueryRepository`, `CreatorChannelFanTalkQueryPort`, `DefaultCreatorChannelFanTalkQueryRepositoryTest`, `CreatorChannelFanTalkQueryService` 연동부를 PRD/plan의 Phase 4 요구사항과 대조했다.
- 리뷰 결과: creator/차단 조회, 최상위 FanTalk count/list 조건, 정렬, offset/limit, 크리에이터 답글 필터와 빈 parent 목록 처리에서 수정이 필요한 결함을 발견하지 않았다.
- 재검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 관련 회귀 재검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence.DefaultCreatorChannelFanTalkQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- FanTalk 전체 재검증: `./gradlew test --tests "kr.co.vividnext.sodalive.v2.*creator.channel.fantalk*"` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 의존 방향 재확인: `rg -n "v2\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk` 결과 0건을 확인했다.
- ktlint 재확인: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- Phase 5 Task 5.1 구현 검증을 진행했다.
- GREEN: `CreatorChannelFanTalkEndToEndTest`를 추가해 인증 회원의 FanTalk 탭 200 OK, 응답 필드, UTC ISO 문자열, 크리에이터 답글 포함, 팬 작성 답글 제외, 범위 밖 page의 빈 목록/count 유지 동작을 검증했다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkEndToEndTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- `lsp_diagnostics`는 로컬에 `kotlin-lsp` 명령이 설치되어 있지 않아 실행할 수 없었다. Kotlin 컴파일은 위 Gradle 테스트 실행으로 확인했다.
- Phase 5 Task 5.2 회귀 검증을 진행했다.
- 의존 방향 확인: `rg -n "v2\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk` 결과 0건을 확인했다.
- API 참조 확인: `rg -n "fan-talks|/profile/\{id\}/cheers|latestFanTalk" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/main/kotlin/kr/co/vividnext/sodalive/explorer` 실행 결과 신규 `fan-talks` controller 매핑과 기존 legacy cheers/home latestFanTalk 참조만 확인했다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkEndToEndTest` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- Phase 5 Task 5.3 전체 FanTalk 관련 테스트와 ktlint 검증을 진행했다.
- FanTalk 전체 테스트 확인: `./gradlew test --tests "kr.co.vividnext.sodalive.v2.*creator.channel.fantalk*"` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- ktlint 확인: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.

View File

@@ -0,0 +1,216 @@
# PRD: 크리에이터 채널 FanTalk 탭 API
## 1. Overview
크리에이터 채널의 FanTalk 탭에서 전체 FanTalk 개수와 FanTalk 글 목록을 페이징 조회하는 API를 제공한다.
---
## 2. Problem
- 크리에이터 채널 홈 API는 FanTalk 전체 개수와 최신 FanTalk 1건만 요약으로 제공한다.
- FanTalk 탭은 전체 개수, 페이징된 글 목록, 각 글에 달린 크리에이터 답글을 함께 표시해야 한다.
- legacy `/profile/{id}/cheers` API는 FanTalk를 조회하지만 날짜를 timezone 기반 표시 문자열로 내려주므로, V2 크리에이터 채널 탭 API에서 요구하는 UTC 기반 응답 계약과 맞지 않는다.
- FanTalk 엔티티는 legacy `CreatorCheers`를 사용하되, 신규 API 조립 계층과 도메인 조회 계층은 기존 V2 크리에이터 채널 탭 패턴처럼 분리해야 한다.
---
## 3. Goals
- 크리에이터 채널 FanTalk 탭 조회 API를 제공한다.
- API endpoint는 `GET /api/v2/creator-channels/{creatorId}/fan-talks`로 한다.
- 클라이언트에서 호출하는 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk` 하위 조립 계층에 둔다.
- FanTalk 목록, 전체 개수, 답글 조회, 페이징 보정, 차단 필터링 같은 조회 책임은 API 패키지 밖의 `kr.co.vividnext.sodalive.v2.creator.channel.fantalk` 도메인 조회 계층에 둔다.
- FanTalk 저장 엔티티는 기존 `kr.co.vividnext.sodalive.explorer.profile.CreatorCheers`를 사용한다.
- 응답에는 조회 가능한 전체 FanTalk 개수, FanTalk 글 목록, page, size, hasNext를 포함한다.
- FanTalk 글 item에는 글쓴이 닉네임, 글쓴이 ID, 글쓴이 프로필 이미지, 글쓴이가 쓴 글, 글 쓴 시간 UTC, 크리에이터가 쓴 답글 목록을 포함한다.
- 크리에이터 답글 item도 FanTalk 글과 동일한 작성자/본문/시간 필드 구조를 사용한다.
- 페이징 요청값은 기존 V2 크리에이터 채널 커뮤니티/시리즈 탭 API와 같은 보정 규칙을 따른다.
---
## 4. Non-Goals
- FanTalk 작성, 수정, 삭제 API는 포함하지 않는다.
- FanTalk 답글 작성, 수정, 삭제 API는 포함하지 않는다.
- 팬 회원 간 답글 작성/조회 기능은 포함하지 않는다. 현재 팬끼리 답글을 작성할 수 없으므로 FanTalk 탭 응답에서도 팬 간 답글을 고려하지 않는다.
- legacy `/profile/{id}/cheers` API의 공개 endpoint나 응답 스키마 변경은 포함하지 않는다.
- 크리에이터 채널 홈 API의 공개 응답 스키마 변경은 포함하지 않는다.
- DB schema, 운영 DDL, 마이그레이션은 포함하지 않는다.
- 앱 표시용 상대 시간 문구나 timezone 변환 문자열은 서버에서 새로 조합하지 않는다.
- 신고, 언어 감지, 푸시 알림 정책 변경은 포함하지 않는다.
---
## 5. Target Users
- 회원: 크리에이터 채널 FanTalk 탭에서 다른 팬들의 FanTalk 글과 크리에이터 답글을 탐색하는 사용자
- 앱 클라이언트: FanTalk 탭 구성에 필요한 전체 개수와 페이징 목록을 단일 API 응답으로 표시하려는 클라이언트
- 서버 개발자: 기존 `CreatorCheers` 저장 구조를 유지하면서 V2 조회 계층을 분리하려는 개발자
---
## 6. User Stories
- 사용자는 크리에이터 채널 FanTalk 탭에 들어가면 전체 FanTalk 개수를 확인하고 싶다.
- 사용자는 FanTalk 글을 최신순으로 추가 로딩하고 싶다.
- 사용자는 각 FanTalk 글에 크리에이터가 남긴 답글을 같은 화면에서 확인하고 싶다.
- 사용자는 글쓴이 닉네임, ID, 프로필 이미지, 본문, 작성 시간을 목록 item에서 바로 확인하고 싶다.
- 앱 클라이언트는 page, size, hasNext를 이용해 추가 로딩 상태를 안정적으로 제어하고 싶다.
- 서버 개발자는 API DTO가 도메인 조회 계층으로 새어 들어가지 않는 패키지 의존 방향을 유지하고 싶다.
---
## 7. Core Features
### Feature A. 크리에이터 채널 FanTalk 탭 조회 API
#### Requirements
- 신규 API는 크리에이터 채널 전용 V2 API로 작성한다.
- API endpoint는 `GET /api/v2/creator-channels/{creatorId}/fan-talks`로 한다.
- `creatorId`는 path variable로 받는다.
- FanTalk 추가 로딩을 위해 `page`, `size` query parameter를 받는다.
- `page`는 기존 V2 탭 API와 동일하게 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` 계열 오류를 반환한다.
- 조회자와 크리에이터 사이에 차단 관계가 있으면 기존 크리에이터 채널 접근 정책과 동일하게 접근 차단 오류를 반환한다.
- 조회 가능한 FanTalk가 없어도 전체 API는 성공 처리한다.
#### Edge Cases
- `page`가 0보다 작거나 `size`가 허용 범위를 벗어나도 400 오류를 반환하지 않고 실제 적용값으로 보정한다.
- 요청한 page 범위에 FanTalk가 없으면 `fanTalks`는 빈 배열, `hasNext``false`로 내려주되 `fanTalkCount`는 전체 개수를 유지한다.
- 조회자 본인이 크리에이터인 경우에도 같은 응답 스키마를 사용한다.
### Feature B. 응답 스키마
#### Requirements
- 응답 DTO는 구현 전에 명시하고 공개 API 계약으로 관리한다.
- 응답 최상위 DTO 이름은 `CreatorChannelFanTalkTabResponse`로 한다.
- 응답에는 다음 값을 포함한다.
- `fanTalkCount`: 조회자가 조회 가능한 전체 FanTalk 개수
- `fanTalks`: FanTalk 글 목록
- `page`: 현재 응답의 page index
- `size`: 현재 응답의 page size
- `hasNext`: 다음 page 존재 여부
- `fanTalkCount`는 최상위 FanTalk 글만 계산한다.
- `fanTalkCount`에는 현재 page에 포함되지 않은 FanTalk 글도 포함한다.
- `page`, `size`는 fallback 보정 이후 실제 적용된 값을 내려준다.
- `hasNext`는 같은 조건에서 다음 page에 노출할 FanTalk 글이 있으면 `true`로 내려준다.
- 응답 스키마 예시는 다음과 같다.
```kotlin
data class CreatorChannelFanTalkTabResponse(
val fanTalkCount: Int,
val fanTalks: List<CreatorChannelFanTalkResponse>,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
)
data class CreatorChannelFanTalkResponse(
val fanTalkId: Long,
val writerId: Long,
val writerNickname: String,
val writerProfileImageUrl: String,
val content: String,
val createdAtUtc: String,
val creatorReplies: List<CreatorChannelFanTalkReplyResponse>
)
data class CreatorChannelFanTalkReplyResponse(
val fanTalkId: Long,
val writerId: Long,
val writerNickname: String,
val writerProfileImageUrl: String,
val content: String,
val createdAtUtc: String
)
```
#### Edge Cases
- 조회 가능한 FanTalk가 없으면 `fanTalkCount``0`, `fanTalks`는 빈 배열, `hasNext``false`로 내려준다.
- FanTalk 글에 크리에이터 답글이 없으면 `creatorReplies`는 빈 배열로 내려준다.
- 작성자 프로필 이미지가 없으면 기존 V2 크리에이터 채널 API와 동일하게 기본 프로필 이미지 URL을 내려준다.
- 탈퇴 회원 닉네임 prefix 제거는 기존 legacy FanTalk 조회와 홈 FanTalk 요약 응답 정책을 따른다.
- `createdAtUtc``CreatorCheers.createdAt`을 UTC 기준 ISO-8601 문자열로 내려준다.
- Boolean 응답 필드는 현재 스키마에 없지만, 추후 추가 시 Jackson 직렬화 필드명을 명시해야 한다.
### Feature C. FanTalk 목록과 개수
#### Requirements
- 조회 대상은 지정한 `creatorId`의 FanTalk로 제한한다.
- 저장 엔티티는 `CreatorCheers`를 사용한다.
- 최상위 FanTalk 글은 `CreatorCheers.parent is null`인 활성 데이터로 정의한다.
- 활성 데이터는 `CreatorCheers.isActive == true`인 데이터로 정의한다.
- 목록은 최상위 FanTalk 글만 페이징한다.
- 목록 정렬은 최신순을 기본으로 하며 `createdAt desc`, `id desc`를 따른다.
- 전체 개수는 목록과 같은 creator, active, parent, 차단 필터 조건을 적용해 계산한다.
- 다음 page 존재 여부는 `size + 1`개를 조회하거나 동등한 방식으로 판단하되, 응답 목록에는 최대 `size`개만 내려준다.
- 글쓴이 ID는 `CreatorCheers.member.id`를 사용한다.
- 글쓴이 닉네임은 `CreatorCheers.member.nickname`을 사용하고 기존 삭제 회원 prefix 제거 정책을 적용한다.
- 글쓴이 프로필 이미지는 `CreatorCheers.member.profileImage`를 기존 CDN URL 조합 정책으로 변환한다.
- 글쓴이가 쓴 글은 `CreatorCheers.cheers`를 사용한다.
- 글 쓴 시간은 `CreatorCheers.createdAt`을 UTC 기준 ISO-8601 문자열로 변환한다.
- `languageCode`는 이번 FanTalk 탭 응답에 포함하지 않는다.
#### Edge Cases
- `CreatorCheers.createdAt`이 nullable 기반 엔티티 필드에서 온 경우에도 조회 결과 응답에는 null이 나오지 않아야 한다.
- FanTalk 작성자가 조회자와 차단 관계이면 해당 최상위 글은 목록과 개수에서 제외한다.
- 차단으로 제외된 최상위 글의 답글도 응답에 포함하지 않는다.
- 같은 작성자의 FanTalk가 여러 건 있어도 각각 별도 item으로 내려준다.
### Feature D. 크리에이터 답글 포함
#### Requirements
- 각 FanTalk 글에는 크리에이터가 쓴 활성 답글 목록을 `creatorReplies`로 포함한다.
- 답글은 `CreatorCheers.parent`가 해당 최상위 FanTalk 글인 데이터로 조회한다.
- 답글 작성자가 조회 대상 크리에이터인 데이터만 포함한다.
- 답글도 `CreatorCheers.isActive == true`인 데이터만 포함한다.
- 답글 item의 필드 구조는 최상위 FanTalk 글과 동일한 작성자 ID, 닉네임, 프로필 이미지, 본문, UTC 작성 시간을 사용한다.
- 답글 정렬은 오래된 답글부터 확인할 수 있도록 `createdAt asc`, `id asc`를 따른다.
- 현재 팬끼리 답글을 작성할 수 없으므로 크리에이터가 아닌 회원의 답글은 정상 응답 대상이 아니다.
- 과거 데이터나 비정상 데이터로 크리에이터가 아닌 회원의 답글이 존재하더라도 응답에 포함하지 않는다.
#### Edge Cases
- 크리에이터 답글이 여러 개면 모두 `creatorReplies`에 포함한다.
- 크리에이터가 작성했지만 비활성 처리된 답글은 포함하지 않는다.
- 답글 작성자인 크리에이터 프로필 이미지가 없으면 기본 프로필 이미지 URL을 내려준다.
- 답글 작성자인 크리에이터가 조회자와 차단 관계인 경우는 이미 채널 접근 차단 조건에서 처리된다.
### Feature E. V2 재사용 범위와 계층 분리
#### Requirements
- 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk` 하위에 둔다.
- FanTalk 조회 service, 순수 정책, domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.creator.channel.fantalk` 하위에 둔다.
- 도메인 조회 계층은 API response DTO를 import하지 않는다.
- 도메인 조회 계층은 API facade나 controller를 import하지 않는다.
- 의존 방향은 항상 `v2.api.creator.channel.fantalk -> v2.creator.channel.fantalk`이다.
- 페이징 값 보정과 `offset`, `fetchLimit` 계산은 기존 `CreatorChannelPage` 패턴을 재사용한다.
- 인증 회원 확인, creator role 검증, 채널 차단 접근 오류는 기존 V2 크리에이터 채널 탭 API와 같은 흐름을 따른다.
- 프로필 이미지 CDN URL 변환과 기본 프로필 이미지 URL은 기존 V2 크리에이터 채널 API 정책을 따른다.
- UTC ISO 변환은 기존 `toUtcIso` 확장 함수 또는 같은 의미의 기존 V2 변환 방식을 재사용한다.
- 기존 홈 API의 FanTalk 요약 조회 로직은 참고하되, 홈 도메인 repository에 신규 탭 페이징 책임을 추가하지 않는다.
- legacy `ExplorerQueryRepository.getCheersList`의 timezone 기반 날짜 포맷 응답은 신규 V2 API에서 재사용하지 않는다.
#### Edge Cases
- 신규 `fantalk` 도메인 패키지에서 `v2.api.*` import 검색 결과가 0건이어야 한다.
- 홈 API의 `fanTalk.totalCount`, `fanTalk.latestFanTalk` 공개 응답 의미는 변경하지 않는다.
- legacy FanTalk 작성/수정/삭제 기능은 기존 패키지에 남겨두고 이번 조회 계층 분리 대상에 포함하지 않는다.
---
## 8. Technical Constraints
- 빌드 도구는 Gradle Wrapper(`./gradlew`)를 사용한다.
- 언어/런타임은 Kotlin + Java 17을 따른다.
- 프레임워크는 Spring Boot 2.7.14를 따른다.
- 기존 Kotlin/Spring 스타일과 ktlint 규칙을 따른다.
- QueryDSL 조회는 기존 V2 크리에이터 채널 탭 repository 패턴을 따른다.
- 공개 API 스키마는 구현 중 임의 변경하지 않고, 변경이 필요하면 PRD와 구현 계획/TASK 문서를 먼저 갱신한다.
---
## 9. Decisions
- endpoint 이름은 `GET /api/v2/creator-channels/{creatorId}/fan-talks`로 확정한다.
- `page`는 기존 크리에이터 채널 V2 탭 API와 동일하게 0 기반 page index로 처리한다.

View File

@@ -0,0 +1,544 @@
# 크리에이터 채널 후원 탭 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}/donations`로 크리에이터 채널 후원 탭의 전체 채널 후원 개수, 후원 순위 Top 8, 페이징된 채널 후원 목록을 조회할 수 있게 한다.
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.donation` 조립 계층에 둔다. 후원 탭 조회 service, page/month 정책, tab domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.creator.channel.donation` 하위에 두고 `v2.api.*`에 의존하지 않는다. 채널 후원 목록은 기존 `ChannelDonationMessage`와 홈 API 후원 섹션 조건을 따르고, 후원 순위는 legacy `CreatorDonationRankingService`를 통해 `CreatorDonationRankingQueryRepository.getMemberDonationRanking` 결과와 동일하게 재사용한다.
**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}/donations`
- 인증 정책: 인증 회원만 조회 가능. 비회원은 기존 Security 흐름과 `requireMember` 정책으로 거부한다.
- request:
- path variable: `creatorId`
- query parameter: `page`, `required = false`, 기본값 `0`, `page < 0`이면 `0`으로 보정
- query parameter: `size`, `required = false`, 기본값 `20`, `size < 20`이면 `20`, `size > 50`이면 `50`으로 보정
- response:
- `donationCount`: 조회자가 조회 가능한 현재 KST 월 범위의 전체 채널 후원 개수
- `rankings`: 후원 순위 Top 8 목록
- `donations`: 채널 후원 목록
- `page`: 보정 후 실제 적용된 page index
- `size`: 보정 후 실제 적용된 page size
- `hasNext`: 다음 page 존재 여부
- channel donation item:
- `nickname`, `profileImageUrl`, `can`, `message`, `createdAtUtc`
- ranking item:
- `userId`, `nickname`, `profileImage`, `donationCan`
- 채널 후원 목록 기준:
- 저장 엔티티는 `kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage`를 사용한다.
- 기간은 홈 후원 섹션과 동일하게 현재 KST 월 시작 이상, 다음 달 KST 월 시작 미만을 UTC `LocalDateTime`으로 변환해 사용한다.
- 정렬은 `createdAt desc`, `id desc`를 따른다.
- `hasNext``size + 1`개 조회 또는 동등한 방식으로 판단하고, 응답 목록에는 최대 `size`개만 내려준다.
- 비공개 후원 노출:
- 조회자가 크리에이터 본인이면 해당 크리에이터의 비공개 후원까지 목록과 개수에 포함한다.
- 조회자가 크리에이터 본인이 아니면 공개 후원과 조회자 본인의 비공개 후원만 목록과 개수에 포함한다.
- 후원 순위 기준:
- `CreatorDonationRankingService.getMemberDonationRanking(...)`를 통해 legacy `CreatorDonationRankingQueryRepository.getMemberDonationRanking` 결과를 재사용한다.
- Top 8 조회는 `offset = 0`, `limit = 8`을 사용한다.
- 기간은 크리에이터의 `donationRankingPeriod`를 따르고, 값이 없으면 `DonationRankingPeriod.CUMULATIVE`를 사용한다.
- `DonationRankingPeriod.WEEKLY`이면 legacy service의 주간 범위를 그대로 사용한다.
- `DonationRankingPeriod.CUMULATIVE`이면 legacy service의 전체 누적 범위를 그대로 사용한다.
- 조회자가 크리에이터 본인이거나 크리에이터의 `isVisibleDonationRank``true`이면 `rankings`를 내려준다.
- 조회자가 크리에이터 본인이 아니고 크리에이터의 `isVisibleDonationRank``false`이면 `rankings`는 빈 배열이다.
- `rankings`가 빈 배열이어도 `donationCount`, `donations`, `page`, `size`, `hasNext`는 후원 목록 조건대로 조회한다.
- `donationCan`은 기존 프로필 정책과 동일하게 크리에이터 본인 조회 시 실제 값을 내려주고, 일반 회원 조회 시 `0`으로 내려준다.
- creator 검증:
- 조회 대상 회원이 없으면 `member.validation.user_not_found`
- 조회 대상 회원이 크리에이터가 아니면 `member.validation.creator_not_found`
- 조회자와 크리에이터 사이에 차단 관계가 있으면 기존 크리에이터 채널 접근 차단 오류
- `createdAtUtc``ChannelDonationMessage.createdAt``kr.co.vividnext.sodalive.extensions.toUtcIso`로 변환한다.
- 프로필 이미지 URL은 `String?.toCdnUrl(cloudFrontHost)`를 사용하고, 없으면 기존 홈 API와 같은 `"$cloudFrontHost/profile/default-profile.png"`를 내려준다.
- 후원자 닉네임은 `removeDeletedNicknamePrefix()`를 적용한다.
- 후원 메시지는 홈 API와 동일하게 `additionalMessage`가 없으면 빈 문자열로 내려준다. 레거시 `ChannelDonationService.buildMessage` 기본 문구 조합은 사용하지 않는다.
- legacy `/explorer/profile/channel-donation` 공개 endpoint와 응답 스키마는 변경하지 않는다.
- 크리에이터 채널 홈 API의 `channelDonations` 공개 응답 의미는 변경하지 않는다.
- DB schema, 운영 DDL, 마이그레이션은 포함하지 않는다.
---
## 1. 파일 구조 계획
### 후원 탭 신규 API 조립 계층
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationController.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacade.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/dto/CreatorChannelDonationTabResponse.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationControllerTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationEndToEndTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacadeTest.kt`
### 후원 도메인 조회 계층
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationTab.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicy.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationQueryPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationRankingPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/CreatorChannelDonationQueryRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/legacy/LegacyCreatorChannelDonationRankingAdapter.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicyTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepositoryTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/legacy/LegacyCreatorChannelDonationRankingAdapterTest.kt`
### 기존 파일 확인/재사용
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelPage.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CdnUrlExtensions.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessage.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorDonationRankingService.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorDonationRankingQueryRepository.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/adapter/in/web/CreatorChannelFanTalkController.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt`
### 문서 산출물
- Create: `docs/20260622_크리에이터_채널_후원_탭_API/plan-task.md`
- Verify: `docs/20260622_크리에이터_채널_후원_탭_API/prd.md`
---
## 2. Response data class 초안
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/dto/CreatorChannelDonationTabResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다.
```kotlin
package kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.extensions.toUtcIso
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonation
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationRanking
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationTab
data class CreatorChannelDonationTabResponse(
val donationCount: Int,
val rankings: List<MemberDonationRankingResponse>,
val donations: List<CreatorChannelDonationResponse>,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
) {
companion object {
fun from(tab: CreatorChannelDonationTab): CreatorChannelDonationTabResponse {
return CreatorChannelDonationTabResponse(
donationCount = tab.donationCount,
rankings = tab.rankings.map(MemberDonationRankingResponse::from),
donations = tab.donations.map(CreatorChannelDonationResponse::from),
page = tab.page.page,
size = tab.page.size,
hasNext = tab.hasNext
)
}
}
}
data class MemberDonationRankingResponse(
@JsonProperty("userId") val userId: Long,
@JsonProperty("nickname") val nickname: String,
@JsonProperty("profileImage") val profileImage: String,
@JsonProperty("donationCan") val donationCan: Int
) {
companion object {
fun from(ranking: CreatorChannelDonationRanking): MemberDonationRankingResponse {
return MemberDonationRankingResponse(
userId = ranking.userId,
nickname = ranking.nickname,
profileImage = ranking.profileImage,
donationCan = ranking.donationCan
)
}
}
}
data class CreatorChannelDonationResponse(
val nickname: String,
val profileImageUrl: String,
val can: Int,
val message: String,
val createdAtUtc: String
) {
companion object {
fun from(donation: CreatorChannelDonation): CreatorChannelDonationResponse {
return CreatorChannelDonationResponse(
nickname = donation.nickname,
profileImageUrl = donation.profileImageUrl,
can = donation.can,
message = donation.message,
createdAtUtc = donation.createdAt.toUtcIso()
)
}
}
}
```
---
## 3. Domain / Port 초안
구현 시 아래 형태를 기준으로 추가한다. API DTO가 domain model을 참조하지만 domain/port는 API DTO를 참조하지 않는다.
```kotlin
package kr.co.vividnext.sodalive.v2.creator.channel.donation.domain
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
import java.time.LocalDateTime
data class CreatorChannelDonationTab(
val donationCount: Int,
val rankings: List<CreatorChannelDonationRanking>,
val donations: List<CreatorChannelDonation>,
val page: CreatorChannelPage,
val hasNext: Boolean
)
data class CreatorChannelDonationRanking(
val userId: Long,
val nickname: String,
val profileImage: String,
val donationCan: Int
)
data class CreatorChannelDonation(
val nickname: String,
val profileImageUrl: String,
val can: Int,
val message: String,
val createdAt: LocalDateTime
)
```
```kotlin
package kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out
import kr.co.vividnext.sodalive.member.DonationRankingPeriod
import kr.co.vividnext.sodalive.member.MemberRole
import java.time.LocalDateTime
interface CreatorChannelDonationQueryPort {
fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelDonationCreatorRecord?
fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean
fun countChannelDonations(
creatorId: Long,
viewerId: Long,
now: LocalDateTime
): Int
fun findChannelDonations(
creatorId: Long,
viewerId: Long,
now: LocalDateTime,
offset: Long,
limit: Int
): List<CreatorChannelDonationRecord>
}
interface CreatorChannelDonationRankingPort {
fun findTopRankings(
creatorId: Long,
period: DonationRankingPeriod,
withDonationCan: Boolean
): List<CreatorChannelDonationRankingRecord>
}
data class CreatorChannelDonationCreatorRecord(
val creatorId: Long,
val role: MemberRole,
val nickname: String,
val isVisibleDonationRank: Boolean,
val donationRankingPeriod: DonationRankingPeriod?
)
data class CreatorChannelDonationRecord(
val nickname: String,
val profileImagePath: String?,
val can: Int,
val message: String,
val createdAt: LocalDateTime
)
data class CreatorChannelDonationRankingRecord(
val userId: Long,
val nickname: String,
val profileImage: String,
val donationCan: Int
)
```
---
## 4. 구현 Tasks
### Phase 1: 공개 계약과 순수 정책 추가
- [x] **Task 1.1: 후원 탭 domain model, port, page/month 정책 추가**
- 파일:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicyTest.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationTab.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicy.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationQueryPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationRankingPort.kt`
- RED: `CreatorChannelDonationQueryPolicyTest`를 먼저 작성한다.
- null page/size가 `0/20`, fetchLimit `21`로 보정되는지 검증한다.
- `page = -1`, `size = 10``0/20`으로 보정되는지 검증한다.
- `page = 2`, `size = 100``2/50`, offset `100`, fetchLimit `51`로 보정되는지 검증한다.
- fetched 21개에서 응답 item 20개와 `hasNext = true`가 계산되는지 검증한다.
- `now = 2026-06-22T03:00:00` 기준 KST 월 범위가 `2026-05-31T15:00:00` 이상, `2026-06-30T15:00:00` 미만 UTC로 계산되는지 검증한다.
- domain/port record가 PRD 필드를 보존하는지 생성 테스트로 검증한다.
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicyTest`
- Expected: 신규 클래스가 없어 컴파일 실패한다.
- GREEN: domain model, port, `CreatorChannelDonationQueryPolicy`를 최소 구현한다.
- `createPage(page, size)`는 기존 FanTalk 정책과 같은 보정값을 사용한다.
- `limitItems(fetched, page)``fetched.take(page.size)`를 반환한다.
- `hasNext(fetched, page)``fetched.size > page.size`를 반환한다.
- `currentKstMonthRange(now)`는 홈 후원 섹션과 동일한 KST 월 범위 UTC 변환을 반환한다.
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicyTest`
- Expected: `BUILD SUCCESSFUL`
- REFACTOR: 중복 상수와 월 범위 계산을 읽기 쉽게 정리하되 기존 `CreatorChannelPage`를 재사용한다.
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicyTest`
- Expected: `BUILD SUCCESSFUL`
- 실행 기록:
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicyTest` 실행, 신규 domain/port/policy 타입 부재로 `compileTestKotlin` 실패 확인.
- GREEN: 동일 명령 재실행, `BUILD SUCCESSFUL` 확인.
- [x] **Task 1.2: response DTO와 facade 매핑 추가**
- 파일:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacadeTest.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/dto/CreatorChannelDonationTabResponse.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacade.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt`
- RED: `CreatorChannelDonationFacadeTest`를 먼저 작성한다.
- `CreatorChannelDonationTabResponse.from(...)``donationCount`, `rankings`, `donations`, `page`, `size`, `hasNext`를 공개 필드로 매핑하는지 검증한다.
- `rankings[0]`의 JSON 필드가 `userId`, `nickname`, `profileImage`, `donationCan`인지 검증한다.
- `donations[0]`의 JSON 필드가 `nickname`, `profileImageUrl`, `can`, `message`, `createdAtUtc`인지 검증한다.
- `hasNext`가 JSON에서 `hasNext`로 직렬화되는지 검증한다.
- facade가 query service 결과를 `CreatorChannelDonationTabResponse`로 변환하는지 검증한다.
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacadeTest`
- Expected: DTO/facade가 없어 컴파일 실패한다.
- GREEN: DTO와 facade를 최소 구현한다.
- `CreatorChannelDonationFacade.getDonationTab(creatorId, viewer, page, size, now)`는 query service를 호출하고 `CreatorChannelDonationTabResponse.from(...)`을 반환한다.
- DTO의 `createdAtUtc` 변환은 기존 `toUtcIso`를 사용한다.
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacadeTest`
- Expected: `BUILD SUCCESSFUL`
- REFACTOR: DTO가 도메인 model만 import하고 persistence/legacy 타입을 import하지 않는지 확인한다.
- Run: `rg -n "adapter\\.out|explorer\\.profile" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation`
- Expected: 검색 결과 0건
- 실행 기록:
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacadeTest` 실행, DTO/facade/query service 경계 부재로 `compileTestKotlin` 실패 확인.
- GREEN: 동일 명령 재실행, `BUILD SUCCESSFUL` 확인.
- 보완: Phase 2 전 공개 endpoint가 내부 `UnsupportedOperationException`으로 실패하지 않도록 query service placeholder를 `SodaException(messageKey = "common.error.invalid_request")`로 고정하고 `CreatorChannelDonationQueryServiceTest`를 추가했다.
- 보완 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryServiceTest` 실행, RED에서 `UnsupportedOperationException` 실패 확인 후 GREEN에서 `BUILD SUCCESSFUL` 확인.
- REFACTOR: `rg -n "adapter\\.out|explorer\\.profile" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation` 실행, 검색 결과 0건 확인.
- [x] **Task 1.3: controller와 인증/API 계약 추가**
- 파일:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationControllerTest.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationController.kt`
- RED: `CreatorChannelDonationControllerTest`를 먼저 작성한다.
- 비회원 요청 `GET /api/v2/creator-channels/1/donations`는 401 또는 기존 테스트 보안 설정 기준 인증 실패로 거부되는지 검증한다.
- 인증 회원 요청은 `page`, `size`, `creatorId`, `viewer`를 facade에 전달하는지 검증한다.
- 성공 응답 JSON에 `data.donationCount`, `data.rankings[0].userId`, `data.donations[0].createdAtUtc`, `data.page`, `data.size`, `data.hasNext`가 포함되는지 검증한다.
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationControllerTest`
- Expected: controller가 없어 컴파일 실패한다.
- GREEN: controller를 최소 구현한다.
- `@RestController`, `@RequestMapping("/api/v2/creator-channels")`, `@GetMapping("/{creatorId}/donations")`를 사용한다.
- `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member")`로 회원을 받고, null이면 `SodaException(messageKey = "common.error.bad_credentials")`를 던진다.
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationControllerTest`
- Expected: `BUILD SUCCESSFUL`
- REFACTOR: 기존 FanTalk/커뮤니티 controller와 request mapping 스타일이 같은지 확인한다.
- Run: `rg -n "class CreatorChannelDonationController|/\\{creatorId\\}/donations" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation`
- Expected: controller class와 endpoint mapping 각 1건 확인
- 실행 기록:
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationControllerTest` 실행, controller 부재로 `compileTestKotlin` 실패 확인.
- GREEN: 동일 명령 재실행, `BUILD SUCCESSFUL` 확인.
- 보완: Phase 2 전 미완성 endpoint가 기본 운영 컨텍스트에 노출되지 않도록 `@ConditionalOnProperty(name = ["creator-channel.donation-tab.enabled"], havingValue = "true")`를 추가했다.
- 보완 검증: controller annotation 계약 테스트를 추가하고 RED에서 조건부 등록 annotation 부재 실패 확인 후 GREEN에서 `BUILD SUCCESSFUL` 확인.
- REFACTOR: `rg -n "class CreatorChannelDonationController|/\\{creatorId\\}/donations" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation` 실행, controller class와 endpoint mapping 각 1건 확인.
### Phase 2: 도메인 조회 서비스와 legacy ranking 재사용 추가
- [x] **Task 2.1: 후원 탭 query service 추가**
- 파일:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryServiceTest.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt`
- RED: fake `CreatorChannelDonationQueryPort`, fake `CreatorChannelDonationRankingPort`를 사용해 query service 테스트를 먼저 작성한다.
- creator가 없으면 `member.validation.user_not_found` 예외를 던지는지 검증한다.
- creator role이 `CREATOR`가 아니면 `member.validation.creator_not_found` 예외를 던지는지 검증한다.
- 조회자와 크리에이터 사이 차단 관계가 있으면 기존 차단 메시지 예외를 던지는지 검증한다.
- `page = -1`, `size = 10` 요청이 `offset = 0`, `limit = 21`로 port에 전달되고 응답은 size 20으로 잘리는지 검증한다.
- 조회자 본인이 크리에이터이면 `isVisibleDonationRank = false`여도 ranking port를 호출하고 `withDonationCan = true`가 전달되는지 검증한다.
- 조회자 본인이 아니고 `isVisibleDonationRank = true`, `donationRankingPeriod = WEEKLY`이면 ranking port에 `period = WEEKLY`, `withDonationCan = false`가 전달되고 `rankings`가 반환되는지 검증한다.
- 조회자 본인이 아니고 `isVisibleDonationRank = true`, `donationRankingPeriod = CUMULATIVE`이면 ranking port에 `period = CUMULATIVE`, `withDonationCan = false`가 전달되는지 검증한다.
- 조회자 본인이 아니고 `isVisibleDonationRank = true`, `donationRankingPeriod = null`이면 ranking port에 `period = CUMULATIVE`가 전달되는지 검증한다.
- 조회자 본인이 아니고 `isVisibleDonationRank = false`이면 ranking port를 호출하지 않고 `rankings`가 빈 배열인지 검증한다.
- 후원 순위가 비공개라 `rankings`가 빈 배열이어도 `donationCount`, `donations`, `page`, `size`, `hasNext`가 정상 조립되는지 검증한다.
- donation 작성자 닉네임의 삭제 prefix 제거, profileImagePath CDN 변환, 기본 프로필 이미지 fallback, null message의 빈 문자열 변환을 검증한다.
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryServiceTest`
- Expected: query service가 없어 컴파일 실패한다.
- GREEN: query service를 최소 구현한다.
- `ObjectProvider<CreatorChannelDonationQueryPort>` 패턴을 사용해 기존 FanTalk query service와 같은 순환 의존 회피 스타일을 따른다.
- `CreatorChannelDonationRankingPort`는 생성자 주입한다.
- `DonationRankingPeriod`는 creator record 값이 null이면 `DonationRankingPeriod.CUMULATIVE`로 보정한다.
- `isVisibleDonationRank`가 false이고 조회자가 크리에이터 본인이 아니면 ranking port를 호출하지 않는다.
- `isVisibleDonationRank`가 true이거나 조회자가 크리에이터 본인이면 ranking port를 호출하고 creator의 ranking period를 그대로 전달한다.
- `findChannelDonations(...)` 결과는 `limitItems` 적용 후 domain으로 변환한다.
- `hasNext`는 fetch 결과 크기로 계산한다.
- Phase 1 임시 보호장치를 함께 정리한다.
- `CreatorChannelDonationQueryService.getDonationTab(...)`의 placeholder `SodaException(messageKey = "common.error.invalid_request")`를 실제 구현으로 대체한다.
- placeholder 전용 `CreatorChannelDonationQueryServiceTest`는 실제 query service 동작 테스트로 교체하고, placeholder 오류 검증은 제거한다.
- `CreatorChannelDonationController``@ConditionalOnProperty(name = ["creator-channel.donation-tab.enabled"], havingValue = "true")`와 관련 import를 제거해 endpoint가 기본 Spring context에 등록되도록 한다.
- `CreatorChannelDonationControllerTest``@TestPropertySource(properties = ["creator-channel.donation-tab.enabled=true"])`와 conditional annotation 검증 테스트를 제거한다.
- 별도 feature flag rollout 정책을 유지하기로 결정한 경우에만 위 controller 조건부 등록을 남기고, 그 결정 사유와 활성화 설정 위치를 이 문서에 추가한다.
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryServiceTest`
- Expected: `BUILD SUCCESSFUL`
- REFACTOR: query service가 API DTO를 import하지 않는지 확인한다.
- Run: `rg -n "v2\\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation`
- Expected: 검색 결과 0건
- 실행 기록:
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryServiceTest` 실행, 새 fake port 기반 테스트가 기존 placeholder service 생성자/동작과 맞지 않아 `compileTestKotlin` 실패 확인. 같은 실행에서 당시 존재하던 Phase 2.2 repository 테스트의 미구현 repository 참조도 함께 컴파일 실패로 노출됨.
- GREEN 보정 전: 동일 명령 실행, service 구현 후 테스트 실행까지 진행됐고 차단 메시지 기대값이 실제 `explorer.creator.blocked_access` 한국어 템플릿과 달라 1건 실패 확인.
- GREEN: 동일 명령 재실행, `BUILD SUCCESSFUL` 확인.
- Controller regression: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationControllerTest` 실행, `BUILD SUCCESSFUL` 확인.
- REFACTOR: `rg -n "v2\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation` 실행, 검색 결과 0건 확인.
- REFACTOR: `rg -n "ConditionalOnProperty|creator-channel\.donation-tab\.enabled" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation` 실행, 검색 결과 0건 확인.
- [x] **Task 2.2: 채널 후원 QueryDSL repository 추가**
- 파일:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepositoryTest.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/CreatorChannelDonationQueryRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt`
- RED: `@DataJpaTest`로 repository 테스트를 먼저 작성한다.
- 활성 creator는 role, nickname, `isVisibleDonationRank`, `donationRankingPeriod`를 조회하고 비활성 회원은 조회하지 않는지 검증한다.
- 조회자와 크리에이터 사이 양방향 활성 차단만 차단 상태로 조회하는지 검증한다.
- 현재 KST 월 범위의 채널 후원만 count/list에 포함되는지 검증한다.
- 크리에이터 본인은 비공개 후원까지 count/list에 포함되는지 검증한다.
- 일반 조회자는 공개 후원과 본인의 비공개 후원만 count/list에 포함되는지 검증한다.
- 목록 정렬이 `createdAt desc`, `id desc`인지 검증한다.
- `offset`, `limit`이 적용되는지 검증한다.
- projection이 `selectFrom(channelDonationMessage)`가 아니라 필요한 컬럼 projection을 사용하는지 소스 문자열 또는 동작 테스트로 확인한다.
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultCreatorChannelDonationQueryRepositoryTest`
- Expected: repository가 없어 컴파일 실패한다.
- GREEN: QueryDSL repository를 최소 구현한다.
- `findCreator(...)`는 활성 회원만 조회하고 role이 USER인 회원도 record로 반환해 service에서 `creator_not_found`를 판단하게 한다.
- `existsBlockedBetween(...)`은 기존 홈/FanTalk repository의 차단 조건과 동일하게 구현한다.
- `countChannelDonations(...)``findChannelDonations(...)``CreatorChannelDonationQueryPolicy.currentKstMonthRange(now)` 결과와 같은 월 범위 조건을 적용한다.
- `donationVisibilityCondition(creatorId, viewerId)`는 홈 API의 조건과 동일하게 구현한다.
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultCreatorChannelDonationQueryRepositoryTest`
- Expected: `BUILD SUCCESSFUL`
- REFACTOR: 홈 repository의 기존 `findChannelDonations` 공개 동작이 변경되지 않았는지 관련 테스트를 실행한다.
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
- Expected: `BUILD SUCCESSFUL`
- 실행 기록:
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultCreatorChannelDonationQueryRepositoryTest` 실행, 신규 repository 부재로 `Unresolved reference: DefaultCreatorChannelDonationQueryRepository` 실패 확인.
- GREEN: 동일 명령 재실행, `BUILD SUCCESSFUL` 확인.
- 회귀: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest` 실행, `BUILD SUCCESSFUL` 확인.
- 보완: `ktlintCheck`에서 repository 테스트의 긴 `saveDonation(...)` 호출 1곳이 실패해 줄바꿈만 수정했다.
- 재검증: Phase 2 focused 테스트 묶음 재실행, `BUILD SUCCESSFUL` 확인.
- [x] **Task 2.3: legacy 후원 랭킹 adapter 추가**
- 파일:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/legacy/LegacyCreatorChannelDonationRankingAdapterTest.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/legacy/LegacyCreatorChannelDonationRankingAdapter.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorDonationRankingService.kt`
- RED: mock `CreatorDonationRankingService`를 사용해 adapter 테스트를 먼저 작성한다.
- `findTopRankings(creatorId = 1, period = CUMULATIVE, withDonationCan = false)` 호출 시 legacy service에 `offset = 0`, `limit = 8`, `withDonationCan = false`, 같은 period가 전달되는지 검증한다.
- `findTopRankings(creatorId = 1, period = WEEKLY, withDonationCan = true)` 호출 시 legacy service에 `offset = 0`, `limit = 8`, `withDonationCan = true`, `period = WEEKLY`가 전달되는지 검증한다.
- legacy `MemberDonationRankingResponse` 결과가 `CreatorChannelDonationRankingRecord`로 필드 손실 없이 변환되는지 검증한다.
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.legacy.LegacyCreatorChannelDonationRankingAdapterTest`
- Expected: adapter가 없어 컴파일 실패한다.
- GREEN: `CreatorChannelDonationRankingPort` 구현체를 최소 구현한다.
- `CreatorDonationRankingService.getMemberDonationRanking(creatorId, offset = 0, limit = 8, withDonationCan, period)`를 호출한다.
- 반환값의 `userId`, `nickname`, `profileImage`, `donationCan`을 record로 복사한다.
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.legacy.LegacyCreatorChannelDonationRankingAdapterTest`
- Expected: `BUILD SUCCESSFUL`
- REFACTOR: 랭킹 산식이나 기간 계산을 V2 코드에 중복 구현하지 않았는지 확인한다.
- Run: `rg -n "previousOrSame|SPIN_ROULETTE|CanUsage\\.DONATION|creator_donation_ranking" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation`
- Expected: 검색 결과 0건
- 실행 기록:
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.legacy.LegacyCreatorChannelDonationRankingAdapterTest` 실행, 신규 adapter 부재로 `Unresolved reference: LegacyCreatorChannelDonationRankingAdapter` 실패 확인.
- GREEN: 동일 명령 재실행, `BUILD SUCCESSFUL` 확인.
- REFACTOR: `rg -n "previousOrSame|SPIN_ROULETTE|CanUsage\.DONATION|creator_donation_ranking" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation` 실행, 검색 결과 0건 확인.
- 재검증: Phase 2 focused 테스트 묶음 재실행, `BUILD SUCCESSFUL` 확인.
### Phase 3: 통합 검증과 회귀 확인
- [x] **Task 3.1: 후원 탭 End-to-End 테스트 추가**
- 파일:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationEndToEndTest.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationController.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/legacy/LegacyCreatorChannelDonationRankingAdapter.kt`
- RED: `@SpringBootTest` + `MockMvc` 통합 테스트를 먼저 작성한다.
- 별도 `creator-channel.donation-tab.enabled` 테스트 property 없이 기본 Spring context에서 후원 탭 endpoint가 등록되는지 검증한다.
- controller-service-repository를 거쳐 후원 탭 API가 `donationCount`, `donations`, `page`, `size`, `hasNext`를 반환하는지 검증한다.
- `page` 범위 밖 요청은 빈 `donations`, 유지된 `donationCount`, `hasNext = false`를 반환하는지 검증한다.
- `page = -1`, `size = 100` 요청은 응답의 `page = 0`, `size = 50`으로 보정되는지 검증한다.
- 일반 조회자에게 크리에이터의 비공개 후원은 숨기고 조회자 본인의 비공개 후원은 노출하는지 검증한다.
- 일반 조회자가 `isVisibleDonationRank = false`인 크리에이터 채널을 조회하면 `rankings`는 빈 배열이고 `donationCount`, `donations`, `page`, `size`, `hasNext`는 정상 반환되는지 검증한다.
- 크리에이터 본인 조회 시 비공개 후원과 `donationCan` 값이 포함된 ranking이 내려오는지 검증한다.
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest`
- Expected: 통합 wiring 또는 신규 API가 없어 실패한다.
- GREEN: 누락된 Spring bean wiring, package scan, constructor 주입 문제를 최소 수정한다.
- 신규 repository/adapter/service/controller가 component scan 대상 package에 들어가야 한다.
- 테스트 데이터는 `ChannelDonationMessage`, `UseCan`, `UseCanCalculate` 등 기존 엔티티 저장 방식에 맞춰 생성한다.
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest`
- Expected: `BUILD SUCCESSFUL`
- REFACTOR: End-to-End 테스트 fixture helper 중복을 줄이되 테스트 의도를 흐리지 않는 범위에서만 정리한다.
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest`
- Expected: `BUILD SUCCESSFUL`
- 실행 기록:
- E2E: `CreatorChannelDonationEndToEndTest`를 추가한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest` 실행, 기존 Phase 2 wiring으로 `BUILD SUCCESSFUL` 확인.
- 검증 범위: 기본 Spring context endpoint 등록, controller-service-repository-legacy ranking 통합, page 범위 밖 응답, page/size 보정, 일반 조회자 비공개 후원/랭킹 숨김, 크리에이터 본인 비공개 후원 및 `donationCan` 노출을 확인.
- [x] **Task 3.2: 관련 테스트와 아키텍처 의존 방향 검증**
- 파일:
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation`
- Verify: `docs/20260622_크리에이터_채널_후원_탭_API/prd.md`
- Verify: `docs/20260622_크리에이터_채널_후원_탭_API/plan-task.md`
- RED: 이 task는 신규 실패 테스트 작성 대상이 아니라 구현 완료 후 회귀/아키텍처 검증 task다.
- TDD 예외 사유: 개별 동작 실패 테스트는 Task 1.1부터 Task 3.1까지 작성한다. 이 task는 전체 검증과 문서 상태 확인만 담당한다.
- 대체 검증 방법: 관련 단일 테스트 묶음, import 검색, ktlint를 실행한다.
- GREEN: 관련 테스트를 묶어서 실행한다.
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacadeTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultCreatorChannelDonationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.legacy.LegacyCreatorChannelDonationRankingAdapterTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest`
- Expected: `BUILD SUCCESSFUL`
- REFACTOR: 의존 방향과 포맷을 검증한다.
- Run: `rg -n "v2\\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation`
- Expected: 검색 결과 0건
- Run: `rg -n "class CreatorChannelDonationController|/\\{creatorId\\}/donations" src/main/kotlin/kr/co/vividnext/sodalive/v2`
- Expected: 후원 탭 controller와 endpoint mapping 각 1건 확인
- Run: `rg -n "ConditionalOnProperty|creator-channel\\.donation-tab\\.enabled" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation`
- Expected: 별도 feature flag rollout 정책을 유지하기로 문서화한 경우가 아니라면 검색 결과 0건
- Run: `./gradlew ktlintCheck`
- Expected: `BUILD SUCCESSFUL`
- 실행 기록:
- 관련 테스트 묶음: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacadeTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultCreatorChannelDonationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.legacy.LegacyCreatorChannelDonationRankingAdapterTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest` 실행, `BUILD SUCCESSFUL` 확인.
- 의존 방향: `rg -n "v2\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation` 실행, 검색 결과 0건 확인.
- endpoint mapping: `rg -n "class CreatorChannelDonationController|/\{creatorId\}/donations" src/main/kotlin/kr/co/vividnext/sodalive/v2` 실행, controller class와 endpoint mapping 각 1건 확인.
- feature flag: `rg -n "ConditionalOnProperty|creator-channel\.donation-tab\.enabled" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation` 실행, 검색 결과 0건 확인.
- format: `./gradlew ktlintCheck` 실행, `BUILD SUCCESSFUL` 확인.
---
## 5. 구현 순서
1. Phase 1에서 공개 계약, domain/port, page/month 정책, facade/controller를 먼저 고정한다.
2. Phase 2에서 query service, QueryDSL repository, legacy ranking adapter를 TDD로 추가한다.
3. Phase 3에서 End-to-End 테스트와 아키텍처/포맷 검증을 수행한다.
4. 각 task 완료 즉시 해당 체크박스를 `- [x]`로 변경하고, 실행한 명령과 결과를 task 아래에 한국어로 누적 기록한다.
---
## 6. 전체 검증 기록
- Phase 1 검증은 각 Task 실행 기록에 누적했다.
- Phase 2 검증은 각 Task 실행 기록에 누적했다.
- Phase 3 검증은 Task 3.1, Task 3.2 실행 기록에 누적했다. 단일 E2E, 관련 테스트 묶음, 의존 방향 검색, endpoint mapping 검색, feature flag 검색, `ktlintCheck` 모두 성공했다.

View File

@@ -0,0 +1,246 @@
# PRD: 크리에이터 채널 후원 탭 API
## 1. Overview
크리에이터 채널의 후원 탭에서 전체 채널 후원 개수, 후원 순위 Top 8, 채널 후원 목록을 페이징 조회하는 API를 제공한다.
---
## 2. Problem
- 크리에이터 채널 홈 API는 후원 섹션에 최신 채널 후원 일부만 제공한다.
- 후원 탭은 홈 요약보다 더 많은 채널 후원 목록을 추가 로딩해야 하고, 전체 채널 후원 개수와 후원 순위 Top 8을 함께 표시해야 한다.
- 레거시 채널 후원 목록 API는 `/explorer/profile/channel-donation`에 있고, V2 크리에이터 채널 탭 API의 패키지 분리 구조와 맞지 않는다.
- 후원 순위는 기존 레거시 `CreatorDonationRankingQueryRepository.getMemberDonationRanking` 결과와 동일해야 하므로 새 집계 기준을 임의로 만들면 안 된다.
- 레거시 프로필의 후원 순위는 크리에이터 설정에 따라 비공개, 주간 공개, 전체 공개가 가능하므로 후원 탭 API도 같은 공개 범위와 기간 정책을 따라야 한다.
- 신규 API는 기존 V2 크리에이터 채널 탭과 동일하게 공개 API 조립 계층과 도메인 조회 계층을 분리해야 한다.
---
## 3. Goals
- 크리에이터 채널 후원 탭 조회 API를 제공한다.
- API endpoint는 `GET /api/v2/creator-channels/{creatorId}/donations`로 한다.
- 클라이언트에서 호출하는 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.donation` 하위 조립 계층에 둔다.
- 후원 개수, 후원 순위, 후원 목록, 페이징 보정, 비공개 후원 노출 조건 같은 조회 책임은 API 패키지 밖의 `kr.co.vividnext.sodalive.v2.creator.channel.donation` 도메인 조회 계층에 둔다.
- 응답에는 조회 가능한 전체 채널 후원 개수, 후원 순위 Top 8, 채널 후원 목록, page, size, hasNext를 포함한다.
- 채널 후원 목록 item의 내용은 크리에이터 채널 홈 API의 `channelDonations` 섹션과 동일한 필드 의미를 사용한다.
- 후원 순위 Top 8 item은 기존 `MemberDonationRankingResponse`와 동일한 결과 리스트 구조를 사용한다.
- 페이징 요청값은 page 기본값 `0`, size 기본값 `20`, size 허용 범위 `20..50`으로 보정한다.
- V2 패키지에 있는 기존 크리에이터 채널 탭 패턴과 홈 후원 섹션 조회 로직 중 재사용 가능한 것을 확인하고 재사용한다.
---
## 4. Non-Goals
- 채널 후원 생성 API는 포함하지 않는다.
- 채널 후원 수정, 삭제, 환불 API는 포함하지 않는다.
- 후원 순위 산식, 포함 `CanUsage`, 정렬 기준 변경은 포함하지 않는다.
- 크리에이터의 후원 순위 노출 설정 변경 API는 포함하지 않는다.
- 레거시 `/explorer/profile/channel-donation` endpoint나 응답 스키마 변경은 포함하지 않는다.
- 크리에이터 채널 홈 API의 공개 응답 스키마 변경은 포함하지 않는다.
- DB schema, 운영 DDL, 마이그레이션은 포함하지 않는다.
- 후원 메시지 기본 문구 조합 정책을 새로 만들지 않는다.
---
## 5. Target Users
- 회원: 크리에이터 채널 후원 탭에서 다른 팬들의 채널 후원 내역과 후원 순위를 확인하는 사용자
- 크리에이터: 자신의 채널 후원 내역과 후원 순위를 확인하는 사용자
- 앱 클라이언트: 후원 탭 구성에 필요한 개수, 랭킹, 목록, 추가 로딩 상태를 단일 API 응답으로 표시하려는 클라이언트
- 서버 개발자: 레거시 후원 저장 구조와 랭킹 쿼리를 보존하면서 V2 조회 계층을 분리하려는 개발자
---
## 6. User Stories
- 사용자는 크리에이터 채널 후원 탭에 들어가면 전체 채널 후원 개수를 확인하고 싶다.
- 사용자는 해당 크리에이터의 후원 순위 Top 8을 확인하고 싶다.
- 사용자는 크리에이터가 후원 순위를 공개하지 않은 채널에서는 후원 순위 없이 채널 후원 목록만 확인한다.
- 크리에이터는 후원 순위를 공개하지 않은 경우에도 본인 채널에서 자신의 후원 순위를 확인하고 싶다.
- 사용자는 채널 후원 목록을 최신순으로 추가 로딩하고 싶다.
- 사용자는 후원자 닉네임, 프로필 이미지, 후원 캔 수, 메시지, 후원 시간을 목록 item에서 바로 확인하고 싶다.
- 앱 클라이언트는 page, size, hasNext를 이용해 추가 로딩 상태를 안정적으로 제어하고 싶다.
- 서버 개발자는 API DTO가 도메인 조회 계층으로 새어 들어가지 않는 패키지 의존 방향을 유지하고 싶다.
---
## 7. Core Features
### Feature A. 크리에이터 채널 후원 탭 조회 API
#### Requirements
- 신규 API는 크리에이터 채널 전용 V2 API로 작성한다.
- API endpoint는 `GET /api/v2/creator-channels/{creatorId}/donations`로 한다.
- `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 범위에 채널 후원이 없으면 `donations`는 빈 배열, `hasNext``false`로 내려주되 `donationCount`는 전체 개수를 유지한다.
- 조회자 본인이 크리에이터인 경우에도 같은 응답 스키마를 사용한다.
### Feature B. 응답 스키마
#### Requirements
- 응답 DTO는 구현 전에 명시하고 공개 API 계약으로 관리한다.
- 응답 최상위 DTO 이름은 `CreatorChannelDonationTabResponse`로 한다.
- 응답에는 다음 값을 포함한다.
- `donationCount`: 조회자가 조회 가능한 전체 채널 후원 개수
- `rankings`: 후원 순위 Top 8 목록
- `donations`: 채널 후원 목록
- `page`: 현재 응답의 page index
- `size`: 현재 응답의 page size
- `hasNext`: 다음 page 존재 여부
- `donationCount`는 현재 page에 포함되지 않은 채널 후원도 포함한다.
- `rankings`는 최대 8개만 내려준다.
- `rankings` item은 기존 `MemberDonationRankingResponse`와 동일하게 `userId`, `nickname`, `profileImage`, `donationCan`을 포함한다.
- `donations` item은 크리에이터 채널 홈 API의 `CreatorChannelDonationResponse`와 동일하게 `nickname`, `profileImageUrl`, `can`, `message`, `createdAtUtc`를 포함한다.
- `page`, `size`는 fallback 보정 이후 실제 적용된 값을 내려준다.
- `hasNext`는 같은 조건에서 다음 page에 노출할 채널 후원이 있으면 `true`로 내려준다.
- 응답 스키마 예시는 다음과 같다.
```kotlin
data class CreatorChannelDonationTabResponse(
val donationCount: Int,
val rankings: List<MemberDonationRankingResponse>,
val donations: List<CreatorChannelDonationResponse>,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
)
data class MemberDonationRankingResponse(
@JsonProperty("userId") val userId: Long,
@JsonProperty("nickname") val nickname: String,
@JsonProperty("profileImage") val profileImage: String,
@JsonProperty("donationCan") val donationCan: Int
)
data class CreatorChannelDonationResponse(
val nickname: String,
val profileImageUrl: String,
val can: Int,
val message: String,
val createdAtUtc: String
)
```
#### Edge Cases
- 조회 가능한 채널 후원이 없으면 `donationCount``0`, `donations`는 빈 배열, `hasNext``false`로 내려준다.
- 노출 가능한 후원 순위가 없으면 `rankings`는 빈 배열로 내려준다.
- 크리에이터가 후원 순위를 공개하지 않았고 조회자가 크리에이터 본인이 아니면 채널 후원 목록은 정상 조회하되 `rankings`만 빈 배열로 내려준다.
- 작성자 프로필 이미지가 없으면 기존 V2 크리에이터 채널 API와 동일하게 기본 프로필 이미지 URL을 내려준다.
- `createdAtUtc``ChannelDonationMessage.createdAt`을 UTC 기준 ISO-8601 문자열로 내려준다.
- Boolean 응답 필드는 Jackson 직렬화 필드명을 명시한다.
### Feature C. 전체 채널 후원 개수와 목록
#### Requirements
- 조회 대상은 지정한 `creatorId`의 채널 후원 메시지로 제한한다.
- 저장 엔티티는 기존 `ChannelDonationMessage`를 사용한다.
- 채널 후원 목록은 크리에이터 채널 홈 API의 후원 섹션과 동일하게 현재 KST 월 범위의 후원 메시지를 대상으로 한다.
- 현재 KST 월 범위는 `now`를 UTC로 받은 뒤 Asia/Seoul 기준 월 시작 이상, 다음 달 월 시작 미만으로 변환해 계산한다.
- 전체 채널 후원 개수는 목록과 같은 creator, 월 범위, 비공개 후원 노출 조건을 적용해 계산한다.
- 목록 정렬은 최신순을 기본으로 하며 `createdAt desc`, `id desc`를 따른다.
- 다음 page 존재 여부는 `size + 1`개를 조회하거나 동등한 방식으로 판단하되, 응답 목록에는 최대 `size`개만 내려준다.
- 후원자 닉네임은 `ChannelDonationMessage.member.nickname`을 사용하고 기존 삭제 회원 prefix 제거 정책을 적용한다.
- 후원자 프로필 이미지는 `ChannelDonationMessage.member.profileImage`를 기존 CDN URL 조합 정책으로 변환한다.
- 후원 캔 수는 `ChannelDonationMessage.can`을 사용한다.
- 후원 메시지는 크리에이터 채널 홈 API와 동일하게 `ChannelDonationMessage.additionalMessage`가 없으면 빈 문자열로 내려준다.
- 후원 시간은 `ChannelDonationMessage.createdAt`을 UTC 기준 ISO-8601 문자열로 변환한다.
#### Edge Cases
- 조회자가 크리에이터 본인이면 해당 크리에이터의 비공개 후원까지 목록과 개수에 포함한다.
- 조회자가 크리에이터 본인이 아니면 공개 후원과 조회자 본인의 비공개 후원만 목록과 개수에 포함한다.
- 비회원 조회는 허용하지 않으므로 비회원 기준 비공개 후원 필터는 별도로 만들지 않는다.
- 같은 회원이 여러 번 후원한 경우 목록에서는 각각 별도 item으로 내려준다.
### Feature D. 후원 순위 Top 8
#### Requirements
- 후원 순위는 기존 레거시 `CreatorDonationRankingQueryRepository.getMemberDonationRanking` 결과와 동일해야 한다.
- API 응답에는 Top 8만 내려준다.
- 호출 offset은 `0`, limit은 `8`을 사용한다.
- 순위 산식과 포함 후원 유형은 레거시 쿼리 기준을 따른다.
- `CanUsage.DONATION`
- `CanUsage.SPIN_ROULETTE`
- `CanUsage.LIVE`
- `CanUsage.CHANNEL_DONATION`
- 환불된 사용 내역은 제외한다.
- 비활성 회원은 제외한다.
- 정렬은 레거시 쿼리와 동일하게 `donationCan desc`, `member.id desc`를 따른다.
- 기간은 크리에이터의 `donationRankingPeriod` 설정을 따른다.
- `donationRankingPeriod`가 없으면 `DonationRankingPeriod.CUMULATIVE`를 사용한다.
- `DonationRankingPeriod.WEEKLY`는 기존 레거시 서비스의 주간 범위 계산을 따른다.
- `DonationRankingPeriod.CUMULATIVE`는 기존 레거시 서비스의 전체 누적 범위 계산을 따른다.
- 후원 순위 노출 정책은 기존 프로필 정책과 동일하게 유지한다.
- 조회자가 크리에이터 본인이거나 크리에이터의 `isVisibleDonationRank``true`이면 `rankings`를 내려준다.
- 조회자가 크리에이터 본인이 아니고 크리에이터의 `isVisibleDonationRank``false`이면 `rankings`는 빈 배열로 내려준다.
- 조회자가 크리에이터 본인이 아니고 크리에이터의 `isVisibleDonationRank``false`인 경우에도 `donationCount`, `donations`, `page`, `size`, `hasNext`는 후원 목록 조건대로 정상 조회한다.
- `donationCan` 노출 여부는 기존 프로필 정책과 동일하게 크리에이터 본인 조회 시 실제 값을 내려주고, 일반 회원 조회 시 `0`으로 내려준다.
#### Edge Cases
- 순위 대상 회원이 8명보다 적으면 있는 만큼만 내려준다.
- 같은 후원 캔 금액이면 레거시 쿼리와 동일하게 회원 ID 내림차순으로 정렬한다.
- 순위 조회 결과가 없어도 후원 탭 API는 성공 처리한다.
- 후원 순위 비공개로 `rankings`가 빈 배열인 경우와 실제 순위 결과가 없어 `rankings`가 빈 배열인 경우 모두 같은 응답 스키마를 사용한다.
### Feature E. V2 재사용 범위와 계층 분리
#### Requirements
- 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.donation` 하위에 둔다.
- 후원 탭 조회 service, 순수 정책, domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.creator.channel.donation` 하위에 둔다.
- 도메인 조회 계층은 API response DTO를 import하지 않는다.
- 도메인 조회 계층은 API facade나 controller를 import하지 않는다.
- 의존 방향은 항상 `v2.api.creator.channel.donation -> v2.creator.channel.donation`이다.
- 페이징 값 보정과 `offset`, `fetchLimit` 계산은 기존 `CreatorChannelPage` 패턴을 재사용한다.
- `page`, `size`, `hasNext`, `limitItems` 정책은 기존 FanTalk/커뮤니티/시리즈 탭의 query policy 패턴을 재사용한다.
- 인증 회원 확인, creator role 검증, 채널 차단 접근 오류는 기존 V2 크리에이터 채널 탭 API와 같은 흐름을 따른다.
- 프로필 이미지 CDN URL 변환과 기본 프로필 이미지 URL은 기존 V2 크리에이터 채널 API 정책을 따른다.
- UTC ISO 변환은 기존 `toUtcIso` 확장 함수 또는 같은 의미의 기존 V2 변환 방식을 재사용한다.
- 홈 API의 `findChannelDonations` 조회 조건과 응답 필드는 참고하되, 홈 도메인 repository에 후원 탭 페이징 책임을 추가하지 않는다.
- 후원 순위는 레거시 repository 또는 같은 쿼리 기준을 감싼 V2 port를 통해 재사용한다.
- 레거시 채널 후원 목록 API의 기본 메시지 조합(`buildMessage`)은 이번 V2 후원 탭 목록 응답에 재사용하지 않는다.
#### Edge Cases
- 신규 `donation` 도메인 패키지에서 `v2.api.*` import 검색 결과가 0건이어야 한다.
- 홈 API의 `channelDonations` 공개 응답 의미는 변경하지 않는다.
- legacy 후원 생성/목록 기능은 기존 패키지에 남겨두고 이번 조회 계층 분리 대상에 포함하지 않는다.
---
## 8. Technical Constraints
- 빌드 도구는 Gradle Wrapper(`./gradlew`)를 사용한다.
- 언어/런타임은 Kotlin + Java 17을 따른다.
- 프레임워크는 Spring Boot 2.7.14를 따른다.
- 기존 Kotlin/Spring 스타일과 ktlint 규칙을 따른다.
- QueryDSL 조회는 기존 V2 크리에이터 채널 탭 repository 패턴을 따른다.
- 공개 API 스키마는 구현 중 임의 변경하지 않고, 변경이 필요하면 PRD와 구현 계획/TASK 문서를 먼저 갱신한다.
---
## 9. Decisions
- endpoint는 `GET /api/v2/creator-channels/{creatorId}/donations`로 확정한다.
- page는 0 기반 page index로 처리한다.
- page 기본값은 `0`, size 기본값은 `20`으로 한다.
- page가 0 미만이면 `0`으로 보정한다.
- size가 20 미만이면 `20`, 50 초과이면 `50`으로 보정한다.
- 채널 후원 목록 item은 크리에이터 채널 홈 API의 `CreatorChannelDonationResponse`와 같은 필드 의미를 사용한다.
- 후원 순위 Top 8 item은 기존 `MemberDonationRankingResponse`와 같은 필드 의미를 사용한다.
- 후원 순위 산식은 `CreatorDonationRankingQueryRepository.getMemberDonationRanking` 기준을 변경하지 않는다.
- 후원 순위 공개 여부는 `isVisibleDonationRank`, 기간은 `donationRankingPeriod` 기준으로 판단한다.
- 채널 후원 목록과 개수의 기간은 홈 후원 섹션과 동일하게 현재 KST 월 범위로 한다.
---
## 10. Open Questions
- 없음. 구현 중 공개 응답 필드 추가나 기간 정책 변경이 필요하면 이 PRD를 먼저 갱신한다.

View File

@@ -10,7 +10,7 @@ import javax.persistence.ManyToOne
import javax.persistence.OneToMany
@Entity
data class CreatorCheers(
class CreatorCheers(
@Column(columnDefinition = "TEXT", nullable = false)
var cheers: String,
var languageCode: String?,

View File

@@ -0,0 +1,39 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.donation.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.donation.application.CreatorChannelDonationFacade
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 CreatorChannelDonationController(
private val creatorChannelDonationFacade: CreatorChannelDonationFacade
) {
@GetMapping("/{creatorId}/donations")
fun getDonationTab(
@PathVariable creatorId: Long,
@RequestParam(required = false) page: Int?,
@RequestParam(required = false) size: Int?,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(
creatorChannelDonationFacade.getDonationTab(
creatorId = creatorId,
viewer = requireMember(member),
page = page,
size = size
)
)
}
private fun requireMember(member: Member?): Member {
return member ?: throw SodaException(messageKey = "common.error.bad_credentials")
}
}

View File

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

View File

@@ -0,0 +1,68 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.extensions.toUtcIso
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonation
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationRanking
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationTab
data class CreatorChannelDonationTabResponse(
val donationCount: Int,
val rankings: List<MemberDonationRankingResponse>,
val donations: List<CreatorChannelDonationResponse>,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
) {
companion object {
fun from(tab: CreatorChannelDonationTab): CreatorChannelDonationTabResponse {
return CreatorChannelDonationTabResponse(
donationCount = tab.donationCount,
rankings = tab.rankings.map(MemberDonationRankingResponse::from),
donations = tab.donations.map(CreatorChannelDonationResponse::from),
page = tab.page.page,
size = tab.page.size,
hasNext = tab.hasNext
)
}
}
}
data class MemberDonationRankingResponse(
@JsonProperty("userId") val userId: Long,
@JsonProperty("nickname") val nickname: String,
@JsonProperty("profileImage") val profileImage: String,
@JsonProperty("donationCan") val donationCan: Int
) {
companion object {
fun from(ranking: CreatorChannelDonationRanking): MemberDonationRankingResponse {
return MemberDonationRankingResponse(
userId = ranking.userId,
nickname = ranking.nickname,
profileImage = ranking.profileImage,
donationCan = ranking.donationCan
)
}
}
}
data class CreatorChannelDonationResponse(
val nickname: String,
val profileImageUrl: String,
val can: Int,
val message: String,
val createdAtUtc: String
) {
companion object {
fun from(donation: CreatorChannelDonation): CreatorChannelDonationResponse {
return CreatorChannelDonationResponse(
nickname = donation.nickname,
profileImageUrl = donation.profileImageUrl,
can = donation.can,
message = donation.message,
createdAtUtc = donation.createdAt.toUtcIso()
)
}
}
}

View File

@@ -0,0 +1,39 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.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.fantalk.application.CreatorChannelFanTalkFacade
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 CreatorChannelFanTalkController(
private val creatorChannelFanTalkFacade: CreatorChannelFanTalkFacade
) {
@GetMapping("/{creatorId}/fan-talks")
fun getFanTalkTab(
@PathVariable creatorId: Long,
@RequestParam(required = false) page: Int?,
@RequestParam(required = false) size: Int?,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(
creatorChannelFanTalkFacade.getFanTalkTab(
creatorId = creatorId,
viewer = requireMember(member),
page = page,
size = size
)
)
}
private fun requireMember(member: Member?): Member {
return member ?: throw SodaException(messageKey = "common.error.bad_credentials")
}
}

View File

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

View File

@@ -0,0 +1,74 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.extensions.toUtcIso
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalk
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkReply
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkTab
data class CreatorChannelFanTalkTabResponse(
val fanTalkCount: Int,
val fanTalks: List<CreatorChannelFanTalkResponse>,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
) {
companion object {
fun from(tab: CreatorChannelFanTalkTab): CreatorChannelFanTalkTabResponse {
return CreatorChannelFanTalkTabResponse(
fanTalkCount = tab.fanTalkCount,
fanTalks = tab.fanTalks.map(CreatorChannelFanTalkResponse::from),
page = tab.page.page,
size = tab.page.size,
hasNext = tab.hasNext
)
}
}
}
data class CreatorChannelFanTalkResponse(
val fanTalkId: Long,
val writerId: Long,
val writerNickname: String,
val writerProfileImageUrl: String,
val content: String,
val createdAtUtc: String,
val creatorReplies: List<CreatorChannelFanTalkReplyResponse>
) {
companion object {
fun from(fanTalk: CreatorChannelFanTalk): CreatorChannelFanTalkResponse {
return CreatorChannelFanTalkResponse(
fanTalkId = fanTalk.fanTalkId,
writerId = fanTalk.writerId,
writerNickname = fanTalk.writerNickname,
writerProfileImageUrl = fanTalk.writerProfileImageUrl,
content = fanTalk.content,
createdAtUtc = fanTalk.createdAt.toUtcIso(),
creatorReplies = fanTalk.creatorReplies.map(CreatorChannelFanTalkReplyResponse::from)
)
}
}
}
data class CreatorChannelFanTalkReplyResponse(
val fanTalkId: Long,
val writerId: Long,
val writerNickname: String,
val writerProfileImageUrl: String,
val content: String,
val createdAtUtc: String
) {
companion object {
fun from(reply: CreatorChannelFanTalkReply): CreatorChannelFanTalkReplyResponse {
return CreatorChannelFanTalkReplyResponse(
fanTalkId = reply.fanTalkId,
writerId = reply.writerId,
writerNickname = reply.writerNickname,
writerProfileImageUrl = reply.writerProfileImageUrl,
content = reply.content,
createdAtUtc = reply.createdAt.toUtcIso()
)
}
}
}

View File

@@ -0,0 +1,33 @@
package kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.legacy
import kr.co.vividnext.sodalive.explorer.profile.CreatorDonationRankingService
import kr.co.vividnext.sodalive.member.DonationRankingPeriod
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationRankingPort
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationRankingRecord
import org.springframework.stereotype.Component
@Component
class LegacyCreatorChannelDonationRankingAdapter(
private val creatorDonationRankingService: CreatorDonationRankingService
) : CreatorChannelDonationRankingPort {
override fun findTopRankings(
creatorId: Long,
period: DonationRankingPeriod,
withDonationCan: Boolean
): List<CreatorChannelDonationRankingRecord> {
return creatorDonationRankingService.getMemberDonationRanking(
creatorId = creatorId,
offset = 0L,
limit = 8L,
withDonationCan = withDonationCan,
period = period
).map {
CreatorChannelDonationRankingRecord(
userId = it.userId,
nickname = it.nickname,
profileImage = it.profileImage,
donationCan = it.donationCan
)
}
}
}

View File

@@ -0,0 +1,5 @@
package kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationQueryPort
interface CreatorChannelDonationQueryRepository : CreatorChannelDonationQueryPort

View File

@@ -0,0 +1,119 @@
package kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence
import com.querydsl.core.types.Projections
import com.querydsl.core.types.dsl.BooleanExpression
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.explorer.profile.channelDonation.QChannelDonationMessage.channelDonationMessage
import kr.co.vividnext.sodalive.member.QMember.member
import kr.co.vividnext.sodalive.member.block.QBlockMember
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicy
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationRecord
import org.springframework.stereotype.Repository
import java.time.LocalDateTime
@Repository
class DefaultCreatorChannelDonationQueryRepository(
private val queryFactory: JPAQueryFactory
) : CreatorChannelDonationQueryRepository {
private val queryPolicy = CreatorChannelDonationQueryPolicy()
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelDonationCreatorRecord? {
val creator = queryFactory
.select(
member.id,
member.role,
member.nickname,
member.isVisibleDonationRank,
member.donationRankingPeriod
)
.from(member)
.where(
member.id.eq(creatorId),
member.isActive.isTrue
)
.fetchFirst() ?: return null
return CreatorChannelDonationCreatorRecord(
creatorId = creator.get(member.id)!!,
role = creator.get(member.role)!!,
nickname = creator.get(member.nickname)!!,
isVisibleDonationRank = creator.get(member.isVisibleDonationRank)!!,
donationRankingPeriod = creator.get(member.donationRankingPeriod)
)
}
override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean {
val blockMember = QBlockMember("creatorChannelDonationBlockMember")
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 countChannelDonations(
creatorId: Long,
viewerId: Long,
now: LocalDateTime
): Int {
return queryFactory
.select(channelDonationMessage.id.count())
.from(channelDonationMessage)
.where(channelDonationCondition(creatorId, viewerId, now))
.fetchOne()
?.toInt()
?: 0
}
override fun findChannelDonations(
creatorId: Long,
viewerId: Long,
now: LocalDateTime,
offset: Long,
limit: Int
): List<CreatorChannelDonationRecord> {
return queryFactory
.select(
Projections.constructor(
CreatorChannelDonationRecord::class.java,
channelDonationMessage.member.nickname,
channelDonationMessage.member.profileImage,
channelDonationMessage.can,
channelDonationMessage.additionalMessage,
channelDonationMessage.createdAt
)
)
.from(channelDonationMessage)
.where(channelDonationCondition(creatorId, viewerId, now))
.orderBy(channelDonationMessage.createdAt.desc(), channelDonationMessage.id.desc())
.offset(offset)
.limit(limit.toLong())
.fetch()
}
private fun channelDonationCondition(
creatorId: Long,
viewerId: Long,
now: LocalDateTime
): BooleanExpression {
val monthRange = queryPolicy.currentKstMonthRange(now)
return channelDonationMessage.creator.id.eq(creatorId)
.and(channelDonationMessage.createdAt.goe(monthRange.startInclusiveUtc))
.and(channelDonationMessage.createdAt.lt(monthRange.endExclusiveUtc))
.and(donationVisibilityCondition(creatorId, viewerId))
}
private fun donationVisibilityCondition(creatorId: Long, viewerId: Long): BooleanExpression {
return if (creatorId == viewerId) {
channelDonationMessage.id.isNotNull
} else {
channelDonationMessage.isSecret.isFalse
.or(channelDonationMessage.member.id.eq(viewerId))
}
}
}

View File

@@ -0,0 +1,113 @@
package kr.co.vividnext.sodalive.v2.creator.channel.donation.application
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.extensions.removeDeletedNicknamePrefix
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.DonationRankingPeriod
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonation
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicy
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationRanking
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationTab
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationQueryPort
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationRankingPort
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationRankingRecord
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationRecord
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 CreatorChannelDonationQueryService(
private val queryPortProvider: ObjectProvider<CreatorChannelDonationQueryPort>,
private val rankingPort: CreatorChannelDonationRankingPort,
private val queryPolicy: CreatorChannelDonationQueryPolicy,
private val messageSource: SodaMessageSource,
private val langContext: LangContext,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) {
fun getDonationTab(
creatorId: Long,
viewer: Member,
page: Int?,
size: Int?,
now: LocalDateTime
): CreatorChannelDonationTab {
val donationPage = 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 fetchedDonations = queryPort.findChannelDonations(
creatorId = creatorId,
viewerId = viewerId,
now = now,
offset = donationPage.offset,
limit = donationPage.fetchLimit
)
return CreatorChannelDonationTab(
donationCount = queryPort.countChannelDonations(creatorId, viewerId, now),
rankings = findRankings(creator, viewerId),
donations = queryPolicy.limitItems(fetchedDonations, donationPage).map { it.toDomain() },
page = donationPage,
hasNext = queryPolicy.hasNext(fetchedDonations, donationPage)
)
}
private fun validateCreatorRole(creator: CreatorChannelDonationCreatorRecord) {
when (creator.role) {
MemberRole.CREATOR -> return
else -> throw SodaException(messageKey = "member.validation.creator_not_found")
}
}
private fun findRankings(
creator: CreatorChannelDonationCreatorRecord,
viewerId: Long
): List<CreatorChannelDonationRanking> {
val isViewerCreator = viewerId == creator.creatorId
if (!isViewerCreator && !creator.isVisibleDonationRank) return emptyList()
return rankingPort.findTopRankings(
creatorId = creator.creatorId,
period = creator.donationRankingPeriod ?: DonationRankingPeriod.CUMULATIVE,
withDonationCan = isViewerCreator
).map { it.toDomain() }
}
private fun CreatorChannelDonationRecord.toDomain() = CreatorChannelDonation(
nickname = nickname.removeDeletedNicknamePrefix(),
profileImageUrl = profileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(),
can = can,
message = message.orEmpty(),
createdAt = createdAt
)
private fun CreatorChannelDonationRankingRecord.toDomain() = CreatorChannelDonationRanking(
userId = userId,
nickname = nickname,
profileImage = profileImage,
donationCan = donationCan
)
private fun defaultProfileImageUrl(): String = "$cloudFrontHost/profile/default-profile.png"
}

View File

@@ -0,0 +1,54 @@
package kr.co.vividnext.sodalive.v2.creator.channel.donation.domain
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
import org.springframework.stereotype.Component
import java.time.LocalDateTime
import java.time.ZoneId
@Component
class CreatorChannelDonationQueryPolicy {
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 currentKstMonthRange(now: LocalDateTime): CreatorChannelDonationMonthRange {
val nowKst = now.atZone(UTC_ZONE_ID).withZoneSameInstant(KST_ZONE_ID)
val start = nowKst.toLocalDate().withDayOfMonth(1).atStartOfDay(KST_ZONE_ID)
.withZoneSameInstant(UTC_ZONE_ID)
.toLocalDateTime()
val end = nowKst.toLocalDate().withDayOfMonth(1).atStartOfDay(KST_ZONE_ID).plusMonths(1)
.withZoneSameInstant(UTC_ZONE_ID)
.toLocalDateTime()
return CreatorChannelDonationMonthRange(
startInclusiveUtc = start,
endExclusiveUtc = end
)
}
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 val KST_ZONE_ID: ZoneId = ZoneId.of("Asia/Seoul")
private val UTC_ZONE_ID: ZoneId = ZoneId.of("UTC")
}
}
data class CreatorChannelDonationMonthRange(
val startInclusiveUtc: LocalDateTime,
val endExclusiveUtc: LocalDateTime
)

View File

@@ -0,0 +1,27 @@
package kr.co.vividnext.sodalive.v2.creator.channel.donation.domain
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
import java.time.LocalDateTime
data class CreatorChannelDonationTab(
val donationCount: Int,
val rankings: List<CreatorChannelDonationRanking>,
val donations: List<CreatorChannelDonation>,
val page: CreatorChannelPage,
val hasNext: Boolean
)
data class CreatorChannelDonationRanking(
val userId: Long,
val nickname: String,
val profileImage: String,
val donationCan: Int
)
data class CreatorChannelDonation(
val nickname: String,
val profileImageUrl: String,
val can: Int,
val message: String,
val createdAt: LocalDateTime
)

View File

@@ -0,0 +1,41 @@
package kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out
import kr.co.vividnext.sodalive.member.DonationRankingPeriod
import kr.co.vividnext.sodalive.member.MemberRole
import java.time.LocalDateTime
interface CreatorChannelDonationQueryPort {
fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelDonationCreatorRecord?
fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean
fun countChannelDonations(
creatorId: Long,
viewerId: Long,
now: LocalDateTime
): Int
fun findChannelDonations(
creatorId: Long,
viewerId: Long,
now: LocalDateTime,
offset: Long,
limit: Int
): List<CreatorChannelDonationRecord>
}
data class CreatorChannelDonationCreatorRecord(
val creatorId: Long,
val role: MemberRole,
val nickname: String,
val isVisibleDonationRank: Boolean,
val donationRankingPeriod: DonationRankingPeriod?
)
data class CreatorChannelDonationRecord(
val nickname: String,
val profileImagePath: String?,
val can: Int,
val message: String?,
val createdAt: LocalDateTime
)

View File

@@ -0,0 +1,18 @@
package kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out
import kr.co.vividnext.sodalive.member.DonationRankingPeriod
interface CreatorChannelDonationRankingPort {
fun findTopRankings(
creatorId: Long,
period: DonationRankingPeriod,
withDonationCan: Boolean
): List<CreatorChannelDonationRankingRecord>
}
data class CreatorChannelDonationRankingRecord(
val userId: Long,
val nickname: String,
val profileImage: String,
val donationCan: Int
)

View File

@@ -0,0 +1,5 @@
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkQueryPort
interface CreatorChannelFanTalkQueryRepository : CreatorChannelFanTalkQueryPort

View File

@@ -0,0 +1,138 @@
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence
import com.querydsl.core.types.Projections
import com.querydsl.core.types.dsl.BooleanExpression
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.explorer.profile.QCreatorCheers.creatorCheers
import kr.co.vividnext.sodalive.member.QMember.member
import kr.co.vividnext.sodalive.member.block.QBlockMember
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkRecord
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkReplyRecord
import org.springframework.stereotype.Repository
@Repository
class DefaultCreatorChannelFanTalkQueryRepository(
private val queryFactory: JPAQueryFactory
) : CreatorChannelFanTalkQueryRepository {
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelFanTalkCreatorRecord? {
val creator = queryFactory
.select(member.id, member.role, member.nickname)
.from(member)
.where(
member.id.eq(creatorId),
member.isActive.isTrue
)
.fetchFirst() ?: return null
return CreatorChannelFanTalkCreatorRecord(
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("creatorChannelFanTalkBlockMember")
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 countFanTalks(creatorId: Long, viewerId: Long): Int {
return queryFactory
.select(creatorCheers.id.count())
.from(creatorCheers)
.where(fanTalkCondition(creatorId, viewerId))
.fetchOne()
?.toInt()
?: 0
}
override fun findFanTalks(
creatorId: Long,
viewerId: Long,
offset: Long,
limit: Int
): List<CreatorChannelFanTalkRecord> {
return queryFactory
.select(
Projections.constructor(
CreatorChannelFanTalkRecord::class.java,
creatorCheers.id,
creatorCheers.member.id,
creatorCheers.member.nickname,
creatorCheers.member.profileImage,
creatorCheers.cheers,
creatorCheers.createdAt
)
)
.from(creatorCheers)
.where(fanTalkCondition(creatorId, viewerId))
.orderBy(creatorCheers.createdAt.desc(), creatorCheers.id.desc())
.offset(offset)
.limit(limit.toLong())
.fetch()
}
override fun findCreatorReplies(
creatorId: Long,
parentFanTalkIds: List<Long>
): List<CreatorChannelFanTalkReplyRecord> {
if (parentFanTalkIds.isEmpty()) return emptyList()
return queryFactory
.select(
Projections.constructor(
CreatorChannelFanTalkReplyRecord::class.java,
creatorCheers.id,
creatorCheers.parent.id,
creatorCheers.member.id,
creatorCheers.member.nickname,
creatorCheers.member.profileImage,
creatorCheers.cheers,
creatorCheers.createdAt
)
)
.from(creatorCheers)
.where(
creatorCheers.creator.id.eq(creatorId),
creatorCheers.member.id.eq(creatorId),
creatorCheers.isActive.isTrue,
creatorCheers.parent.id.`in`(parentFanTalkIds)
)
.orderBy(creatorCheers.createdAt.asc(), creatorCheers.id.asc())
.fetch()
}
private fun fanTalkCondition(creatorId: Long, viewerId: Long): BooleanExpression {
return creatorCheers.creator.id.eq(creatorId)
.and(creatorCheers.isActive.isTrue)
.and(creatorCheers.parent.isNull)
.and(notBlockedFanTalkWriterCondition(viewerId))
}
private fun notBlockedFanTalkWriterCondition(viewerId: Long): BooleanExpression {
val viewerBlock = QBlockMember("viewerBlockFanTalkTabWriter")
val writerBlock = QBlockMember("writerBlockFanTalkTabViewer")
return creatorCheers.member.id.notIn(
queryFactory
.select(viewerBlock.blockedMember.id)
.from(viewerBlock)
.where(viewerBlock.member.id.eq(viewerId), viewerBlock.isActive.isTrue)
).and(
creatorCheers.member.id.notIn(
queryFactory
.select(writerBlock.member.id)
.from(writerBlock)
.where(writerBlock.blockedMember.id.eq(viewerId), writerBlock.isActive.isTrue)
)
)
}
}

View File

@@ -0,0 +1,116 @@
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.extensions.removeDeletedNicknamePrefix
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalk
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicy
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkReply
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkTab
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkQueryPort
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkRecord
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkReplyRecord
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 CreatorChannelFanTalkQueryService(
private val queryPortProvider: ObjectProvider<CreatorChannelFanTalkQueryPort>,
private val queryPolicy: CreatorChannelFanTalkQueryPolicy,
private val messageSource: SodaMessageSource,
private val langContext: LangContext,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) {
fun getFanTalkTab(
creatorId: Long,
viewer: Member,
page: Int?,
size: Int?,
now: LocalDateTime = LocalDateTime.now()
): CreatorChannelFanTalkTab {
val fanTalkPage = 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 fetchedFanTalks = queryPort.findFanTalks(
creatorId = creatorId,
viewerId = viewerId,
offset = fanTalkPage.offset,
limit = fanTalkPage.fetchLimit
)
val fanTalkRecords = queryPolicy.limitItems(fetchedFanTalks, fanTalkPage)
val repliesByParentId = findRepliesByParentId(queryPort, creatorId, fanTalkRecords)
return CreatorChannelFanTalkTab(
fanTalkCount = queryPort.countFanTalks(creatorId, viewerId),
fanTalks = fanTalkRecords.map { it.toDomain(repliesByParentId[it.fanTalkId].orEmpty()) },
page = fanTalkPage,
hasNext = queryPolicy.hasNext(fetchedFanTalks, fanTalkPage)
)
}
private fun validateCreatorRole(creator: CreatorChannelFanTalkCreatorRecord) {
when (creator.role) {
MemberRole.CREATOR -> return
else -> throw SodaException(messageKey = "member.validation.creator_not_found")
}
}
private fun findRepliesByParentId(
queryPort: CreatorChannelFanTalkQueryPort,
creatorId: Long,
fanTalkRecords: List<CreatorChannelFanTalkRecord>
): Map<Long, List<CreatorChannelFanTalkReply>> {
val parentFanTalkIds = fanTalkRecords.map { it.fanTalkId }
if (parentFanTalkIds.isEmpty()) return emptyMap()
return queryPort.findCreatorReplies(creatorId, parentFanTalkIds)
.groupBy(
keySelector = { it.parentFanTalkId },
valueTransform = { it.toDomain() }
)
}
private fun CreatorChannelFanTalkRecord.toDomain(
creatorReplies: List<CreatorChannelFanTalkReply>
) = CreatorChannelFanTalk(
fanTalkId = fanTalkId,
writerId = writerId,
writerNickname = writerNickname.removeDeletedNicknamePrefix(),
writerProfileImageUrl = writerProfileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(),
content = content,
createdAt = createdAt,
creatorReplies = creatorReplies
)
private fun CreatorChannelFanTalkReplyRecord.toDomain() = CreatorChannelFanTalkReply(
fanTalkId = fanTalkId,
writerId = writerId,
writerNickname = writerNickname.removeDeletedNicknamePrefix(),
writerProfileImageUrl = writerProfileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(),
content = content,
createdAt = createdAt
)
private fun defaultProfileImageUrl(): String = "$cloudFrontHost/profile/default-profile.png"
}

View File

@@ -0,0 +1,30 @@
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
import org.springframework.stereotype.Component
@Component
class CreatorChannelFanTalkQueryPolicy {
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
}
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
}
}

View File

@@ -0,0 +1,30 @@
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
import java.time.LocalDateTime
data class CreatorChannelFanTalkTab(
val fanTalkCount: Int,
val fanTalks: List<CreatorChannelFanTalk>,
val page: CreatorChannelPage,
val hasNext: Boolean
)
data class CreatorChannelFanTalk(
val fanTalkId: Long,
val writerId: Long,
val writerNickname: String,
val writerProfileImageUrl: String,
val content: String,
val createdAt: LocalDateTime,
val creatorReplies: List<CreatorChannelFanTalkReply>
)
data class CreatorChannelFanTalkReply(
val fanTalkId: Long,
val writerId: Long,
val writerNickname: String,
val writerProfileImageUrl: String,
val content: String,
val createdAt: LocalDateTime
)

View File

@@ -0,0 +1,49 @@
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out
import kr.co.vividnext.sodalive.member.MemberRole
import java.time.LocalDateTime
interface CreatorChannelFanTalkQueryPort {
fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelFanTalkCreatorRecord?
fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean
fun countFanTalks(creatorId: Long, viewerId: Long): Int
fun findFanTalks(
creatorId: Long,
viewerId: Long,
offset: Long,
limit: Int
): List<CreatorChannelFanTalkRecord>
fun findCreatorReplies(
creatorId: Long,
parentFanTalkIds: List<Long>
): List<CreatorChannelFanTalkReplyRecord>
}
data class CreatorChannelFanTalkCreatorRecord(
val creatorId: Long,
val role: MemberRole,
val nickname: String
)
data class CreatorChannelFanTalkRecord(
val fanTalkId: Long,
val writerId: Long,
val writerNickname: String,
val writerProfileImagePath: String?,
val content: String,
val createdAt: LocalDateTime
)
data class CreatorChannelFanTalkReplyRecord(
val fanTalkId: Long,
val parentFanTalkId: Long,
val writerId: Long,
val writerNickname: String,
val writerProfileImagePath: String?,
val content: String,
val createdAt: LocalDateTime
)

View File

@@ -0,0 +1,169 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.donation.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.donation.application.CreatorChannelDonationFacade
import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto.CreatorChannelDonationResponse
import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto.CreatorChannelDonationTabResponse
import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto.MemberDonationRankingResponse
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(CreatorChannelDonationController::class)
@Import(CreatorChannelDonationControllerTest.TestSecurityConfig::class)
class CreatorChannelDonationControllerTest @Autowired constructor(
private val mockMvc: MockMvc
) {
@MockBean
private lateinit var facade: CreatorChannelDonationFacade
@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 shouldRejectAnonymousCreatorChannelDonationRequest() {
mockMvc.perform(
get("/api/v2/creator-channels/1/donations")
.with(anonymous())
)
.andExpect(status().isUnauthorized)
}
@Test
@DisplayName("크리에이터 채널 후원 탭 조회는 query parameter를 facade에 전달하고 성공 응답을 반환한다")
fun shouldReturnCreatorChannelDonationTabForAuthenticatedMember() {
val viewer = createMember(id = 10L)
Mockito.doReturn(createResponse(page = 1, size = 20)).`when`(facade).getDonationTab(
eqValue(1L),
eqValue(viewer),
eqValue(1),
eqValue(20),
anyValue(LocalDateTime.now())
)
mockMvc.perform(
get("/api/v2/creator-channels/1/donations")
.param("page", "1")
.param("size", "20")
.with(user(MemberAdapter(viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.donationCount").value(3))
.andExpect(jsonPath("$.data.rankings").isArray)
.andExpect(jsonPath("$.data.rankings[0].userId").value(10))
.andExpect(jsonPath("$.data.rankings[0].nickname").value("fan"))
.andExpect(jsonPath("$.data.rankings[0].profileImage").value("https://cdn.test/fan.png"))
.andExpect(jsonPath("$.data.rankings[0].donationCan").value(100))
.andExpect(jsonPath("$.data.donations").isArray)
.andExpect(jsonPath("$.data.donations[0].nickname").value("donor"))
.andExpect(jsonPath("$.data.donations[0].profileImageUrl").value("https://cdn.test/donor.png"))
.andExpect(jsonPath("$.data.donations[0].can").value(50))
.andExpect(jsonPath("$.data.donations[0].message").value("thanks"))
.andExpect(jsonPath("$.data.donations[0].createdAtUtc").value("2026-06-21T03:30:00Z"))
.andExpect(jsonPath("$.data.page").value(1))
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.hasNext").value(false))
Mockito.verify(facade).getDonationTab(
eqValue(1L),
eqValue(viewer),
eqValue(1),
eqValue(20),
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
): CreatorChannelDonationTabResponse {
return CreatorChannelDonationTabResponse(
donationCount = 3,
rankings = listOf(
MemberDonationRankingResponse(
userId = 10L,
nickname = "fan",
profileImage = "https://cdn.test/fan.png",
donationCan = 100
)
),
donations = listOf(
CreatorChannelDonationResponse(
nickname = "donor",
profileImageUrl = "https://cdn.test/donor.png",
can = 50,
message = "thanks",
createdAtUtc = "2026-06-21T03:30:00Z"
)
),
page = page,
size = size,
hasNext = false
)
}
}

View File

@@ -0,0 +1,245 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.`in`.web
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.can.use.UseCan
import kr.co.vividnext.sodalive.can.use.UseCanCalculate
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage
import kr.co.vividnext.sodalive.member.DonationRankingPeriod
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.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicy
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.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
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-donation-e2e;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE"
]
)
@AutoConfigureMockMvc
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
class CreatorChannelDonationEndToEndTest @Autowired constructor(
private val mockMvc: MockMvc,
private val entityManager: EntityManager,
private val transactionTemplate: TransactionTemplate
) {
@Test
@DisplayName("후원 탭 API는 controller-service-repository를 거쳐 후원 목록과 랭킹을 반환한다")
fun shouldReturnDonationTabThroughControllerServiceRepositoryAndLegacyRanking() {
val fixture = createFixture("donation-e2e-success", isVisibleDonationRank = true)
mockMvc.perform(
get("/api/v2/creator-channels/${fixture.creatorId}/donations")
.param("page", "0")
.param("size", "20")
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.donationCount").value(2))
.andExpect(jsonPath("$.data.rankings.length()").value(1))
.andExpect(jsonPath("$.data.rankings[0].userId").value(fixture.viewer.id!!))
.andExpect(jsonPath("$.data.rankings[0].nickname").value(fixture.viewer.nickname))
.andExpect(jsonPath("$.data.rankings[0].profileImage").value("https://cdn.test/${fixture.viewer.profileImage}"))
.andExpect(jsonPath("$.data.rankings[0].donationCan").value(0))
.andExpect(jsonPath("$.data.donations.length()").value(2))
.andExpect(jsonPath("$.data.donations[0].nickname").value("donation-e2e-success-viewer"))
.andExpect(jsonPath("$.data.donations[0].profileImageUrl").value("https://cdn.test/donation-e2e-success-viewer.png"))
.andExpect(jsonPath("$.data.donations[0].can").value(200))
.andExpect(jsonPath("$.data.donations[0].message").value("own secret"))
.andExpect(jsonPath("$.data.donations[0].createdAtUtc").exists())
.andExpect(jsonPath("$.data.donations[1].nickname").value("donation-e2e-success-other"))
.andExpect(jsonPath("$.data.donations[1].can").value(100))
.andExpect(jsonPath("$.data.donations[1].message").value("public"))
.andExpect(jsonPath("$.data.donations[?(@.message == 'hidden')]").isEmpty)
.andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.hasNext").value(false))
}
@Test
@DisplayName("후원 탭 API는 page 범위 밖 요청에 빈 목록과 유지된 count를 반환한다")
fun shouldReturnEmptyDonationsAndKeepCountForOutOfRangePage() {
val fixture = createFixture("donation-e2e-out-of-range", isVisibleDonationRank = true)
mockMvc.perform(
get("/api/v2/creator-channels/${fixture.creatorId}/donations")
.param("page", "1")
.param("size", "20")
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.donationCount").value(2))
.andExpect(jsonPath("$.data.donations.length()").value(0))
.andExpect(jsonPath("$.data.page").value(1))
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.hasNext").value(false))
}
@Test
@DisplayName("후원 탭 API는 page와 size를 정책 범위로 보정한다")
fun shouldClampPageAndSize() {
val fixture = createFixture("donation-e2e-clamp", isVisibleDonationRank = true)
mockMvc.perform(
get("/api/v2/creator-channels/${fixture.creatorId}/donations")
.param("page", "-1")
.param("size", "100")
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(50))
.andExpect(jsonPath("$.data.donations.length()").value(2))
}
@Test
@DisplayName("후원 랭킹 비공개 크리에이터는 일반 조회자에게 빈 랭킹과 정상 후원 목록을 반환한다")
fun shouldReturnEmptyRankingsAndDonationTabForHiddenRankingCreator() {
val fixture = createFixture("donation-e2e-hidden-ranking", isVisibleDonationRank = false)
mockMvc.perform(
get("/api/v2/creator-channels/${fixture.creatorId}/donations")
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.rankings.length()").value(0))
.andExpect(jsonPath("$.data.donationCount").value(2))
.andExpect(jsonPath("$.data.donations.length()").value(2))
.andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.hasNext").value(false))
}
@Test
@DisplayName("크리에이터 본인 조회는 비공개 후원과 실제 donationCan 랭킹을 포함한다")
fun shouldReturnPrivateDonationsAndDonationCanForCreatorSelf() {
val fixture = createFixture("donation-e2e-self", isVisibleDonationRank = false)
mockMvc.perform(
get("/api/v2/creator-channels/${fixture.creatorId}/donations")
.with(user(MemberAdapter(fixture.creator)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.donationCount").value(3))
.andExpect(jsonPath("$.data.rankings.length()").value(1))
.andExpect(jsonPath("$.data.rankings[0].userId").value(fixture.viewer.id!!))
.andExpect(jsonPath("$.data.rankings[0].donationCan").value(500))
.andExpect(jsonPath("$.data.donations.length()").value(3))
.andExpect(jsonPath("$.data.donations[?(@.message == 'hidden')]").isNotEmpty)
}
private fun createFixture(prefix: String, isVisibleDonationRank: Boolean): Fixture {
return transactionTemplate.execute {
val monthStart = CreatorChannelDonationQueryPolicy()
.currentKstMonthRange(LocalDateTime.now())
.startInclusiveUtc
val now = monthStart.plusDays(10)
val viewer = saveMember("$prefix-viewer", MemberRole.USER)
val creator = saveMember(
"$prefix-creator",
MemberRole.CREATOR,
isVisibleDonationRank = isVisibleDonationRank
)
val otherDonor = saveMember("$prefix-other", MemberRole.USER)
saveDonation(creator, otherDonor, 100, now.minusHours(3), additionalMessage = "public")
saveDonation(creator, viewer, 200, now.minusHours(2), isSecret = true, additionalMessage = "own secret")
saveDonation(creator, otherDonor, 300, now.minusHours(1), isSecret = true, additionalMessage = "hidden")
saveRankingDonation(viewer, creator, can = 450, rewardCan = 50)
entityManager.flush()
entityManager.clear()
Fixture(
viewer = viewer,
creator = creator,
creatorId = creator.id!!
)
}!!
}
private fun saveMember(
nickname: String,
role: MemberRole,
isVisibleDonationRank: Boolean = true
): Member {
val member = Member(
email = "$nickname@test.com",
password = "password",
nickname = nickname,
profileImage = "$nickname.png",
role = role,
isVisibleDonationRank = isVisibleDonationRank,
donationRankingPeriod = DonationRankingPeriod.CUMULATIVE
)
entityManager.persist(member)
return member
}
private fun saveDonation(
creator: Member,
donor: Member,
can: Int,
createdAt: LocalDateTime,
isSecret: Boolean = false,
additionalMessage: String
): ChannelDonationMessage {
val donation = ChannelDonationMessage(can = can, isSecret = isSecret, additionalMessage = additionalMessage)
donation.creator = creator
donation.member = donor
entityManager.persist(donation)
entityManager.flush()
updateCreatedAt("ChannelDonationMessage", donation.id!!, createdAt)
return donation
}
private fun saveRankingDonation(donor: Member, creator: Member, can: Int, rewardCan: Int) {
val useCan = UseCan(CanUsage.CHANNEL_DONATION, can = can, rewardCan = rewardCan, isRefund = false)
useCan.member = donor
entityManager.persist(useCan)
val calculate = UseCanCalculate(
can = can + rewardCan,
paymentGateway = PaymentGateway.PG,
status = UseCanCalculateStatus.RECEIVED
)
calculate.useCan = useCan
calculate.recipientCreatorId = creator.id
entityManager.persist(calculate)
}
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 data class Fixture(
val viewer: Member,
val creator: Member,
val creatorId: Long
)
}

View File

@@ -0,0 +1,120 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.donation.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.donation.dto.CreatorChannelDonationTabResponse
import kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryService
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonation
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationRanking
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationTab
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.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import java.time.LocalDateTime
class CreatorChannelDonationFacadeTest {
@Test
@DisplayName("후원 탭 응답 DTO는 domain tab 값을 공개 응답 필드와 UTC 문자열로 매핑한다")
fun shouldMapDonationTabDomainToPublicResponse() {
val response = CreatorChannelDonationTabResponse.from(createTab())
assertEquals(3, response.donationCount)
assertEquals(10L, response.rankings.first().userId)
assertEquals("fan", response.rankings.first().nickname)
assertEquals("https://cdn.test/fan.png", response.rankings.first().profileImage)
assertEquals(100, response.rankings.first().donationCan)
assertEquals("donor", response.donations.first().nickname)
assertEquals("https://cdn.test/donor.png", response.donations.first().profileImageUrl)
assertEquals(50, response.donations.first().can)
assertEquals("thanks", response.donations.first().message)
assertEquals("2026-06-21T03:30:00Z", response.donations.first().createdAtUtc)
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))
assertEquals(10L, json["rankings"][0]["userId"].asLong())
assertEquals("fan", json["rankings"][0]["nickname"].asText())
assertEquals("https://cdn.test/fan.png", json["rankings"][0]["profileImage"].asText())
assertEquals(100, json["rankings"][0]["donationCan"].asInt())
assertEquals("donor", json["donations"][0]["nickname"].asText())
assertEquals("https://cdn.test/donor.png", json["donations"][0]["profileImageUrl"].asText())
assertEquals(50, json["donations"][0]["can"].asInt())
assertEquals("thanks", json["donations"][0]["message"].asText())
assertEquals("2026-06-21T03:30:00Z", json["donations"][0]["createdAtUtc"].asText())
assertTrue(json["hasNext"].asBoolean())
assertFalse(json.has("languageCode"))
}
@Test
@DisplayName("후원 탭 facade는 query service 결과를 공개 응답 DTO로 변환한다")
fun shouldMapDonationTabQueryResultToPublicResponse() {
val service = Mockito.mock(CreatorChannelDonationQueryService::class.java)
val facade = CreatorChannelDonationFacade(service)
val viewer = createMember(id = 10L)
val now = LocalDateTime.of(2026, 6, 21, 12, 0)
Mockito.doReturn(createTab()).`when`(service).getDonationTab(
creatorId = 1L,
viewer = viewer,
page = -1,
size = 100,
now = now
)
val response = facade.getDonationTab(
creatorId = 1L,
viewer = viewer,
page = -1,
size = 100,
now = now
)
assertEquals(3, response.donationCount)
assertEquals(10L, response.rankings.first().userId)
assertEquals("https://cdn.test/donor.png", response.donations.first().profileImageUrl)
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(): CreatorChannelDonationTab {
return CreatorChannelDonationTab(
donationCount = 3,
rankings = listOf(
CreatorChannelDonationRanking(
userId = 10L,
nickname = "fan",
profileImage = "https://cdn.test/fan.png",
donationCan = 100
)
),
donations = listOf(
CreatorChannelDonation(
nickname = "donor",
profileImageUrl = "https://cdn.test/donor.png",
can = 50,
message = "thanks",
createdAt = LocalDateTime.of(2026, 6, 21, 3, 30)
)
),
page = CreatorChannelPage(page = 1, size = 20),
hasNext = true
)
}
}

View File

@@ -0,0 +1,166 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.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.fantalk.application.CreatorChannelFanTalkFacade
import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkReplyResponse
import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkResponse
import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkTabResponse
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(CreatorChannelFanTalkController::class)
@Import(CreatorChannelFanTalkControllerTest.TestSecurityConfig::class)
class CreatorChannelFanTalkControllerTest @Autowired constructor(
private val mockMvc: MockMvc
) {
@MockBean
private lateinit var facade: CreatorChannelFanTalkFacade
@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("크리에이터 채널 FanTalk 탭 조회는 비회원 요청을 거부한다")
fun shouldRejectAnonymousCreatorChannelFanTalkRequest() {
mockMvc.perform(
get("/api/v2/creator-channels/1/fan-talks")
.with(anonymous())
)
.andExpect(status().isUnauthorized)
}
@Test
@DisplayName("크리에이터 채널 FanTalk 탭 조회는 query parameter를 facade에 전달하고 성공 응답을 반환한다")
fun shouldReturnCreatorChannelFanTalkTabForAuthenticatedMember() {
val viewer = createMember(id = 10L)
Mockito.doReturn(createResponse(page = 1, size = 20)).`when`(facade).getFanTalkTab(
eqValue(1L),
eqValue(viewer),
eqValue(1),
eqValue(20),
anyValue(LocalDateTime.now())
)
mockMvc.perform(
get("/api/v2/creator-channels/1/fan-talks")
.param("page", "1")
.param("size", "20")
.with(user(MemberAdapter(viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.fanTalkCount").value(2))
.andExpect(jsonPath("$.data.fanTalks").isArray)
.andExpect(jsonPath("$.data.fanTalks[0].fanTalkId").value(101))
.andExpect(jsonPath("$.data.fanTalks[0].writerId").value(10))
.andExpect(jsonPath("$.data.fanTalks[0].writerProfileImageUrl").value("https://cdn.test/fan.png"))
.andExpect(jsonPath("$.data.fanTalks[0].creatorReplies[0].fanTalkId").value(201))
.andExpect(jsonPath("$.data.page").value(1))
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.hasNext").value(false))
Mockito.verify(facade).getFanTalkTab(
eqValue(1L),
eqValue(viewer),
eqValue(1),
eqValue(20),
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
): CreatorChannelFanTalkTabResponse {
return CreatorChannelFanTalkTabResponse(
fanTalkCount = 2,
fanTalks = listOf(
CreatorChannelFanTalkResponse(
fanTalkId = 101L,
writerId = 10L,
writerNickname = "fan",
writerProfileImageUrl = "https://cdn.test/fan.png",
content = "fan talk",
createdAtUtc = "2026-06-21T03:30:00Z",
creatorReplies = listOf(
CreatorChannelFanTalkReplyResponse(
fanTalkId = 201L,
writerId = 1L,
writerNickname = "creator",
writerProfileImageUrl = "https://cdn.test/creator.png",
content = "creator reply",
createdAtUtc = "2026-06-21T03:35:00Z"
)
)
)
),
page = page,
size = size,
hasNext = false
)
}
}

View File

@@ -0,0 +1,182 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.`in`.web
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers
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.support.EmbeddedRedisInitializer
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.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
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-fantalk-e2e;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE"
]
)
@AutoConfigureMockMvc
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
class CreatorChannelFanTalkEndToEndTest @Autowired constructor(
private val mockMvc: MockMvc,
private val entityManager: EntityManager,
private val transactionTemplate: TransactionTemplate
) {
@Test
@DisplayName("FanTalk 탭 API는 controller-service-repository를 거쳐 글과 크리에이터 답글을 반환한다")
fun shouldReturnFanTalkTabThroughControllerServiceAndRepository() {
val fixture = createFixture("fantalk-e2e-success")
mockMvc.perform(
get("/api/v2/creator-channels/${fixture.creatorId}/fan-talks")
.param("page", "0")
.param("size", "20")
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.fanTalkCount").value(2))
.andExpect(jsonPath("$.data.fanTalks.length()").value(2))
.andExpect(jsonPath("$.data.fanTalks[0].fanTalkId").value(fixture.newerFanTalkId))
.andExpect(jsonPath("$.data.fanTalks[0].writerId").value(fixture.writerId))
.andExpect(jsonPath("$.data.fanTalks[0].writerNickname").value("fan-writer"))
.andExpect(jsonPath("$.data.fanTalks[0].writerProfileImageUrl").value("https://cdn.test/fan-writer.png"))
.andExpect(jsonPath("$.data.fanTalks[0].content").value("newer fan talk"))
.andExpect(jsonPath("$.data.fanTalks[0].createdAtUtc").value("2026-06-22T12:00:00Z"))
.andExpect(jsonPath("$.data.fanTalks[0].creatorReplies.length()").value(1))
.andExpect(jsonPath("$.data.fanTalks[0].creatorReplies[0].fanTalkId").value(fixture.creatorReplyId))
.andExpect(jsonPath("$.data.fanTalks[0].creatorReplies[0].writerId").value(fixture.creatorId))
.andExpect(jsonPath("$.data.fanTalks[0].creatorReplies[0].writerNickname").value("creator"))
.andExpect(
jsonPath("$.data.fanTalks[0].creatorReplies[0].writerProfileImageUrl")
.value("https://cdn.test/creator.png")
)
.andExpect(jsonPath("$.data.fanTalks[0].creatorReplies[0].content").value("creator reply"))
.andExpect(jsonPath("$.data.fanTalks[0].creatorReplies[0].createdAtUtc").value("2026-06-22T12:05:00Z"))
.andExpect(jsonPath("$.data.fanTalks[0].creatorReplies[?(@.fanTalkId == ${fixture.fanReplyId})]").isEmpty)
.andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.hasNext").value(false))
}
@Test
@DisplayName("FanTalk 탭 API는 page 범위 밖 요청에 빈 목록과 유지된 count를 반환한다")
fun shouldReturnEmptyListAndKeepCountForOutOfRangePage() {
val fixture = createFixture("fantalk-e2e-out-of-range")
mockMvc.perform(
get("/api/v2/creator-channels/${fixture.creatorId}/fan-talks")
.param("page", "1")
.param("size", "20")
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.fanTalkCount").value(2))
.andExpect(jsonPath("$.data.fanTalks.length()").value(0))
.andExpect(jsonPath("$.data.page").value(1))
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.hasNext").value(false))
}
private fun createFixture(prefix: String): Fixture {
return transactionTemplate.execute {
val now = LocalDateTime.of(2026, 6, 22, 12, 0)
val viewer = saveMember("$prefix-viewer", MemberRole.USER)
val creator = saveMember("$prefix-creator", MemberRole.CREATOR, nickname = "creator", profileImage = "creator.png")
val writer = saveMember("$prefix-writer", MemberRole.USER, nickname = "fan-writer", profileImage = "fan-writer.png")
val newerFanTalk = saveCheers(writer, creator, "newer fan talk", isActive = true, createdAt = now)
saveCheers(writer, creator, "older fan talk", isActive = true, createdAt = now.minusHours(1))
val creatorReply = saveCheers(
creator,
creator,
"creator reply",
isActive = true,
createdAt = now.plusMinutes(5),
parent = newerFanTalk
)
val fanReply = saveCheers(
writer,
creator,
"fan reply should be excluded",
isActive = true,
createdAt = now.plusMinutes(10),
parent = newerFanTalk
)
entityManager.flush()
entityManager.clear()
Fixture(
viewer = viewer,
creatorId = creator.id!!,
writerId = writer.id!!,
newerFanTalkId = newerFanTalk.id!!,
creatorReplyId = creatorReply.id!!,
fanReplyId = fanReply.id!!
)
}!!
}
private fun saveMember(
emailPrefix: String,
role: MemberRole,
nickname: String = emailPrefix,
profileImage: String? = "$emailPrefix.png"
): Member {
val member = Member(
email = "$emailPrefix@test.com",
password = "password",
nickname = nickname,
profileImage = profileImage,
role = role
)
entityManager.persist(member)
return member
}
private fun saveCheers(
member: Member,
creator: Member,
cheers: String,
isActive: Boolean,
createdAt: LocalDateTime,
parent: CreatorCheers? = null
): CreatorCheers {
val creatorCheers = CreatorCheers(cheers = cheers, languageCode = "ko", isActive = isActive)
creatorCheers.member = member
creatorCheers.creator = creator
creatorCheers.parent = parent
entityManager.persist(creatorCheers)
entityManager.flush()
updateCreatedAt(creatorCheers.id!!, createdAt)
return creatorCheers
}
private fun updateCreatedAt(id: Long, createdAt: LocalDateTime) {
entityManager.createQuery("update CreatorCheers 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 writerId: Long,
val newerFanTalkId: Long,
val creatorReplyId: Long,
val fanReplyId: Long
)
}

View File

@@ -0,0 +1,118 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.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.fantalk.dto.CreatorChannelFanTalkTabResponse
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryService
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalk
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkReply
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkTab
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.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import java.time.LocalDateTime
class CreatorChannelFanTalkFacadeTest {
@Test
@DisplayName("FanTalk 탭 응답 DTO는 domain tab 값을 공개 응답 필드와 UTC 문자열로 매핑한다")
fun shouldMapFanTalkTabDomainToPublicResponse() {
val response = CreatorChannelFanTalkTabResponse.from(createTab())
assertEquals(2, response.fanTalkCount)
assertEquals(101L, response.fanTalks.first().fanTalkId)
assertEquals(10L, response.fanTalks.first().writerId)
assertEquals("fan", response.fanTalks.first().writerNickname)
assertEquals("https://cdn.test/fan.png", response.fanTalks.first().writerProfileImageUrl)
assertEquals("fan talk", response.fanTalks.first().content)
assertEquals("2026-06-21T03:30:00Z", response.fanTalks.first().createdAtUtc)
assertEquals(201L, response.fanTalks.first().creatorReplies.first().fanTalkId)
assertEquals(1L, response.fanTalks.first().creatorReplies.first().writerId)
assertEquals("creator", response.fanTalks.first().creatorReplies.first().writerNickname)
assertEquals("https://cdn.test/creator.png", response.fanTalks.first().creatorReplies.first().writerProfileImageUrl)
assertEquals("creator reply", response.fanTalks.first().creatorReplies.first().content)
assertEquals("2026-06-21T03:35:00Z", response.fanTalks.first().creatorReplies.first().createdAtUtc)
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())
assertFalse(json.has("languageCode"))
}
@Test
@DisplayName("FanTalk 탭 facade는 query service 결과를 공개 응답 DTO로 변환한다")
fun shouldMapFanTalkTabQueryResultToPublicResponse() {
val service = Mockito.mock(CreatorChannelFanTalkQueryService::class.java)
val facade = CreatorChannelFanTalkFacade(service)
val viewer = createMember(id = 10L)
val now = LocalDateTime.of(2026, 6, 21, 12, 0)
Mockito.doReturn(createTab()).`when`(service).getFanTalkTab(
creatorId = 1L,
viewer = viewer,
page = -1,
size = 100,
now = now
)
val response = facade.getFanTalkTab(
creatorId = 1L,
viewer = viewer,
page = -1,
size = 100,
now = now
)
assertEquals(2, response.fanTalkCount)
assertEquals(101L, response.fanTalks.first().fanTalkId)
assertEquals("https://cdn.test/fan.png", response.fanTalks.first().writerProfileImageUrl)
assertEquals(201L, response.fanTalks.first().creatorReplies.first().fanTalkId)
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(): CreatorChannelFanTalkTab {
return CreatorChannelFanTalkTab(
fanTalkCount = 2,
fanTalks = listOf(
CreatorChannelFanTalk(
fanTalkId = 101L,
writerId = 10L,
writerNickname = "fan",
writerProfileImageUrl = "https://cdn.test/fan.png",
content = "fan talk",
createdAt = LocalDateTime.of(2026, 6, 21, 3, 30),
creatorReplies = listOf(
CreatorChannelFanTalkReply(
fanTalkId = 201L,
writerId = 1L,
writerNickname = "creator",
writerProfileImageUrl = "https://cdn.test/creator.png",
content = "creator reply",
createdAt = LocalDateTime.of(2026, 6, 21, 3, 35)
)
)
)
),
page = CreatorChannelPage(page = 1, size = 20),
hasNext = true
)
}
}

View File

@@ -0,0 +1,87 @@
package kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.legacy
import kr.co.vividnext.sodalive.explorer.MemberDonationRankingResponse
import kr.co.vividnext.sodalive.explorer.profile.CreatorDonationRankingService
import kr.co.vividnext.sodalive.member.DonationRankingPeriod
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
class LegacyCreatorChannelDonationRankingAdapterTest {
private val creatorDonationRankingService = Mockito.mock(CreatorDonationRankingService::class.java)
private val adapter = LegacyCreatorChannelDonationRankingAdapter(creatorDonationRankingService)
@Test
@DisplayName("누적 기간 후원 랭킹 Top 8을 legacy service에 위임한다")
fun shouldDelegateCumulativeRankingRequestToLegacyService() {
Mockito.`when`(
creatorDonationRankingService.getMemberDonationRanking(
creatorId = 10L,
offset = 0L,
limit = 8L,
withDonationCan = false,
period = DonationRankingPeriod.CUMULATIVE
)
).thenReturn(emptyList())
val rankings = adapter.findTopRankings(
creatorId = 10L,
period = DonationRankingPeriod.CUMULATIVE,
withDonationCan = false
)
assertEquals(emptyList<Any>(), rankings)
Mockito.verify(creatorDonationRankingService).getMemberDonationRanking(
creatorId = 10L,
offset = 0L,
limit = 8L,
withDonationCan = false,
period = DonationRankingPeriod.CUMULATIVE
)
Mockito.verifyNoMoreInteractions(creatorDonationRankingService)
}
@Test
@DisplayName("주간 기간 후원 랭킹 Top 8을 legacy service에 위임하고 필드를 그대로 매핑한다")
fun shouldDelegateWeeklyRankingRequestAndMapFields() {
Mockito.`when`(
creatorDonationRankingService.getMemberDonationRanking(
creatorId = 20L,
offset = 0L,
limit = 8L,
withDonationCan = true,
period = DonationRankingPeriod.WEEKLY
)
).thenReturn(
listOf(
MemberDonationRankingResponse(
userId = 30L,
nickname = "donor",
profileImage = "https://cdn.test/profile.png",
donationCan = 1234
)
)
)
val rankings = adapter.findTopRankings(
creatorId = 20L,
period = DonationRankingPeriod.WEEKLY,
withDonationCan = true
)
assertEquals(1, rankings.size)
assertEquals(30L, rankings[0].userId)
assertEquals("donor", rankings[0].nickname)
assertEquals("https://cdn.test/profile.png", rankings[0].profileImage)
assertEquals(1234, rankings[0].donationCan)
Mockito.verify(creatorDonationRankingService).getMemberDonationRanking(
creatorId = 20L,
offset = 0L,
limit = 8L,
withDonationCan = true,
period = DonationRankingPeriod.WEEKLY
)
Mockito.verifyNoMoreInteractions(creatorDonationRankingService)
}
}

View File

@@ -0,0 +1,227 @@
package kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage
import kr.co.vividnext.sodalive.member.DonationRankingPeriod
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.block.BlockMember
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.nio.file.Paths
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 DefaultCreatorChannelDonationQueryRepositoryTest @Autowired constructor(
private val entityManager: EntityManager,
queryFactory: JPAQueryFactory
) {
private val repository = DefaultCreatorChannelDonationQueryRepository(queryFactory)
@Test
@DisplayName("활성 회원은 후원 랭킹 설정과 role을 조회하고 비활성 회원은 조회하지 않는다")
fun shouldFindOnlyActiveCreatorWithDonationRankingSettings() {
val viewer = saveMember("donation-lookup-viewer", MemberRole.USER)
val creator = saveMember(
"donation-active-creator",
MemberRole.CREATOR,
isVisibleDonationRank = false,
donationRankingPeriod = DonationRankingPeriod.WEEKLY
)
val inactiveCreator = saveMember("donation-inactive-creator", MemberRole.CREATOR, isActive = false)
val nonCreator = saveMember("donation-non-creator", MemberRole.USER)
flushAndClear()
val creatorRecord = repository.findCreator(creator.id!!, viewer.id!!)
val inactiveRecord = repository.findCreator(inactiveCreator.id!!, viewer.id!!)
val nonCreatorRecord = repository.findCreator(nonCreator.id!!, viewer.id!!)
assertNotNull(creatorRecord)
assertEquals(creator.id, creatorRecord!!.creatorId)
assertEquals(MemberRole.CREATOR, creatorRecord.role)
assertEquals(creator.nickname, creatorRecord.nickname)
assertFalse(creatorRecord.isVisibleDonationRank)
assertEquals(DonationRankingPeriod.WEEKLY, creatorRecord.donationRankingPeriod)
assertNull(inactiveRecord)
assertEquals(MemberRole.USER, nonCreatorRecord!!.role)
}
@Test
@DisplayName("조회자와 크리에이터 사이 양방향 활성 차단만 차단 상태로 조회한다")
fun shouldFindActiveBlockInBothDirections() {
val viewer = saveMember("donation-block-viewer", MemberRole.USER)
val creator = saveMember("donation-block-creator", MemberRole.CREATOR)
val otherCreator = saveMember("donation-other-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("크리에이터 본인은 현재 KST 월 범위의 공개/비공개 채널 후원을 모두 조회한다")
fun shouldCountAndFindAllCurrentMonthDonationsForCreatorSelf() {
val now = LocalDateTime.of(2026, 6, 22, 3, 0)
val creator = saveMember("donation-self-creator", MemberRole.CREATOR)
val donor = saveMember("donation-self-donor", MemberRole.USER, profileImage = "self-donor.png")
val monthStartCreatedAt = LocalDateTime.of(2026, 5, 31, 15, 0)
val monthStart = saveDonation(creator, donor, 100, monthStartCreatedAt, additionalMessage = null)
val secret = saveDonation(creator, donor, 200, LocalDateTime.of(2026, 6, 22, 2, 0), isSecret = true)
saveDonation(creator, donor, 300, LocalDateTime.of(2026, 5, 31, 14, 59, 59))
saveDonation(creator, donor, 400, LocalDateTime.of(2026, 6, 30, 15, 0))
flushAndClear()
val count = repository.countChannelDonations(creator.id!!, creator.id!!, now)
val records = repository.findChannelDonations(creator.id!!, creator.id!!, now, offset = 0, limit = 10)
assertEquals(2, count)
assertEquals(listOf(secret.can, monthStart.can), records.map { it.can })
assertEquals(donor.nickname, records.last().nickname)
assertEquals(donor.profileImage, records.last().profileImagePath)
assertNull(records.last().message)
assertEquals(monthStartCreatedAt, records.last().createdAt)
}
@Test
@DisplayName("일반 조회자는 현재 KST 월 범위의 공개 후원과 본인 비공개 후원만 조회한다")
fun shouldCountAndFindOnlyVisibleCurrentMonthDonationsForViewer() {
val now = LocalDateTime.of(2026, 6, 22, 12, 0)
val creator = saveMember("donation-visible-creator", MemberRole.CREATOR)
val viewer = saveMember("donation-visible-viewer", MemberRole.USER)
val otherDonor = saveMember("donation-visible-other", MemberRole.USER)
val publicDonation = saveDonation(creator, otherDonor, 100, now.minusHours(3), additionalMessage = "public")
val ownSecretDonation = saveDonation(
creator,
viewer,
200,
now.minusHours(2),
isSecret = true,
additionalMessage = "own secret"
)
saveDonation(creator, otherDonor, 300, now.minusHours(1), isSecret = true, additionalMessage = "hidden")
flushAndClear()
val count = repository.countChannelDonations(creator.id!!, viewer.id!!, now)
val records = repository.findChannelDonations(creator.id!!, viewer.id!!, now, offset = 0, limit = 10)
assertEquals(2, count)
assertEquals(listOf(ownSecretDonation.can, publicDonation.can), records.map { it.can })
assertEquals(listOf("own secret", "public"), records.map { it.message })
}
@Test
@DisplayName("채널 후원 목록은 createdAt desc, id desc로 정렬하고 offset/limit을 적용한다")
fun shouldOrderByCreatedAtAndIdDescWithOffsetAndLimit() {
val now = LocalDateTime.of(2026, 6, 22, 12, 0)
val creator = saveMember("donation-order-creator", MemberRole.CREATOR)
val viewer = saveMember("donation-order-viewer", MemberRole.USER)
val donor = saveMember("donation-order-donor", MemberRole.USER)
val sameCreatedAt = now.minusHours(1)
val first = saveDonation(creator, donor, 100, sameCreatedAt, additionalMessage = "first")
val second = saveDonation(creator, donor, 200, sameCreatedAt, additionalMessage = "second")
saveDonation(creator, donor, 300, sameCreatedAt.plusMinutes(1), additionalMessage = "newest")
flushAndClear()
val records = repository.findChannelDonations(creator.id!!, viewer.id!!, now, offset = 1, limit = 2)
assertEquals(listOf(second.can, first.can), records.map { it.can })
}
@Test
@DisplayName("후원 탭 repository 목록 조회는 entity 전체 fetch 없이 필요한 컬럼 projection만 사용한다")
fun shouldUseProjectionForDonationList() {
val source = Paths.get(
"src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/" +
"DefaultCreatorChannelDonationQueryRepository.kt"
)
.toFile()
.readText()
assertFalse(source.contains(".selectFrom(channelDonationMessage)"), "donation list must not fetch entity rows")
assertTrue(
source.contains(
"""Projections.constructor(
CreatorChannelDonationRecord::class.java"""
),
"donation list must use constructor projection for direct record mapping"
)
}
private fun saveMember(
nickname: String,
role: MemberRole,
isActive: Boolean = true,
profileImage: String? = "$nickname.png",
isVisibleDonationRank: Boolean = true,
donationRankingPeriod: DonationRankingPeriod? = DonationRankingPeriod.CUMULATIVE
): Member {
val member = Member(
email = "$nickname@test.com",
password = "password",
nickname = nickname,
profileImage = profileImage,
role = role,
isVisibleDonationRank = isVisibleDonationRank,
donationRankingPeriod = donationRankingPeriod,
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 saveDonation(
creator: Member,
donor: Member,
can: Int,
createdAt: LocalDateTime,
isSecret: Boolean = false,
additionalMessage: String? = "thanks"
): ChannelDonationMessage {
val donation = ChannelDonationMessage(can = can, isSecret = isSecret, additionalMessage = additionalMessage)
donation.creator = creator
donation.member = donor
entityManager.persist(donation)
entityManager.flush()
updateCreatedAt("ChannelDonationMessage", donation.id!!, createdAt)
return donation
}
private fun updateCreatedAt(entityName: String, id: Long, createdAt: LocalDateTime) {
entityManager.createQuery("update $entityName e set e.createdAt = :createdAt where e.id = :id")
.setParameter("createdAt", createdAt)
.setParameter("id", id)
.executeUpdate()
}
private fun flushAndClear() {
entityManager.flush()
entityManager.clear()
}
}

View File

@@ -0,0 +1,406 @@
package kr.co.vividnext.sodalive.v2.creator.channel.donation.application
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.DonationRankingPeriod
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicy
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationQueryPort
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationRankingPort
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationRankingRecord
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationRecord
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertThrows
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 CreatorChannelDonationQueryServiceTest {
@Test
@DisplayName("조회 대상 회원이 없으면 user_not_found 예외를 던진다")
fun shouldThrowUserNotFoundWhenCreatorMissing() {
val queryPort = FakeDonationQueryPort(creator = null)
val service = createService(queryPort = queryPort)
val exception = assertThrows(SodaException::class.java) {
service.getDonationTab(
creatorId = CREATOR_ID,
viewer = createMember(VIEWER_ID),
page = 0,
size = 20,
now = NOW
)
}
assertEquals("member.validation.user_not_found", exception.messageKey)
}
@Test
@DisplayName("조회 대상 회원이 크리에이터가 아니면 creator_not_found 예외를 던진다")
fun shouldThrowCreatorNotFoundWhenMemberIsNotCreator() {
val queryPort = FakeDonationQueryPort(
creator = createCreator(role = MemberRole.USER)
)
val service = createService(queryPort = queryPort)
val exception = assertThrows(SodaException::class.java) {
service.getDonationTab(
creatorId = CREATOR_ID,
viewer = createMember(VIEWER_ID),
page = 0,
size = 20,
now = NOW
)
}
assertEquals("member.validation.creator_not_found", exception.messageKey)
}
@Test
@DisplayName("조회자와 크리에이터 사이 차단 관계가 있으면 차단 메시지 예외를 던진다")
fun shouldThrowBlockedAccessMessageWhenBlocked() {
val queryPort = FakeDonationQueryPort(blocked = true)
val service = createService(queryPort = queryPort)
val exception = assertThrows(SodaException::class.java) {
service.getDonationTab(
creatorId = CREATOR_ID,
viewer = createMember(VIEWER_ID),
page = 0,
size = 20,
now = NOW
)
}
assertEquals("creator-nickname님의 요청으로 채널 접근이 제한됩니다.", exception.message)
}
@Test
@DisplayName("페이지 보정값으로 목록을 조회하고 응답 목록과 hasNext를 조립한다")
fun shouldUseResolvedPageForDonationQueryAndLimitResponseItems() {
val queryPort = FakeDonationQueryPort(
donations = (1..21).map {
createDonationRecord(nickname = "donor$it", message = "message$it")
},
donationCount = 30
)
val service = createService(queryPort = queryPort)
val tab = service.getDonationTab(
creatorId = CREATOR_ID,
viewer = createMember(VIEWER_ID),
page = -1,
size = 10,
now = NOW
)
assertEquals(0L, queryPort.lastFindDonationRequest?.offset)
assertEquals(21, queryPort.lastFindDonationRequest?.limit)
assertEquals(CREATOR_ID, queryPort.lastCountDonationRequest?.creatorId)
assertEquals(VIEWER_ID, queryPort.lastCountDonationRequest?.viewerId)
assertEquals(NOW, queryPort.lastCountDonationRequest?.now)
assertEquals(30, tab.donationCount)
assertEquals(20, tab.donations.size)
assertEquals("donor1", tab.donations.first().nickname)
assertEquals("message1", tab.donations.first().message)
assertEquals(0, tab.page.page)
assertEquals(20, tab.page.size)
assertEquals(true, tab.hasNext)
}
@Test
@DisplayName("후원 목록은 닉네임, 프로필 이미지, 메시지를 도메인 응답 값으로 변환한다")
fun shouldMapDonationRecordsToDomainValues() {
val queryPort = FakeDonationQueryPort(
donations = listOf(
createDonationRecord(
nickname = "deleted_donor",
profileImagePath = "profile/donor.png",
message = null
),
createDonationRecord(
nickname = "default-image-donor",
profileImagePath = null,
message = "thanks"
)
)
)
val service = createService(queryPort = queryPort)
val tab = service.getDonationTab(
creatorId = CREATOR_ID,
viewer = createMember(VIEWER_ID),
page = 0,
size = 20,
now = NOW
)
assertEquals("donor", tab.donations[0].nickname)
assertEquals("https://cdn.test/profile/donor.png", tab.donations[0].profileImageUrl)
assertEquals("", tab.donations[0].message)
assertEquals("default-image-donor", tab.donations[1].nickname)
assertEquals("https://cdn.test/profile/default-profile.png", tab.donations[1].profileImageUrl)
assertEquals("thanks", tab.donations[1].message)
}
@Test
@DisplayName("조회자가 크리에이터 본인이면 순위 공개 여부와 무관하게 donationCan 포함 랭킹을 조회한다")
fun shouldFetchRankingsWithDonationCanForCreatorViewer() {
val queryPort = FakeDonationQueryPort(
creator = createCreator(isVisibleDonationRank = false, donationRankingPeriod = DonationRankingPeriod.WEEKLY)
)
val rankingPort = FakeDonationRankingPort()
val service = createService(queryPort = queryPort, rankingPort = rankingPort)
val tab = service.getDonationTab(
creatorId = CREATOR_ID,
viewer = createMember(CREATOR_ID),
page = 0,
size = 20,
now = NOW
)
assertEquals(RankingRequest(CREATOR_ID, DonationRankingPeriod.WEEKLY, true), rankingPort.requests.single())
assertEquals(createRankingRecord(), rankingPort.records.single())
assertEquals(tab.rankings.single().userId, rankingPort.records.single().userId)
assertEquals(tab.rankings.single().nickname, rankingPort.records.single().nickname)
assertEquals(tab.rankings.single().profileImage, rankingPort.records.single().profileImage)
assertEquals(tab.rankings.single().donationCan, rankingPort.records.single().donationCan)
}
@Test
@DisplayName("일반 조회자는 공개 랭킹을 크리에이터 설정 기간과 donationCan 제외 조건으로 조회한다")
fun shouldFetchVisibleRankingsForNonCreatorViewerWithConfiguredPeriod() {
val weeklyRankingPort = FakeDonationRankingPort()
createService(
queryPort = FakeDonationQueryPort(
creator = createCreator(
isVisibleDonationRank = true,
donationRankingPeriod = DonationRankingPeriod.WEEKLY
)
),
rankingPort = weeklyRankingPort
).getDonationTab(CREATOR_ID, createMember(VIEWER_ID), 0, 20, NOW)
val cumulativeRankingPort = FakeDonationRankingPort()
createService(
queryPort = FakeDonationQueryPort(
creator = createCreator(
isVisibleDonationRank = true,
donationRankingPeriod = DonationRankingPeriod.CUMULATIVE
)
),
rankingPort = cumulativeRankingPort
).getDonationTab(CREATOR_ID, createMember(VIEWER_ID), 0, 20, NOW)
assertEquals(RankingRequest(CREATOR_ID, DonationRankingPeriod.WEEKLY, false), weeklyRankingPort.requests.single())
assertEquals(RankingRequest(CREATOR_ID, DonationRankingPeriod.CUMULATIVE, false), cumulativeRankingPort.requests.single())
}
@Test
@DisplayName("크리에이터 랭킹 기간이 없으면 누적 랭킹으로 조회한다")
fun shouldUseCumulativeRankingPeriodWhenCreatorPeriodIsNull() {
val rankingPort = FakeDonationRankingPort()
val service = createService(
queryPort = FakeDonationQueryPort(
creator = createCreator(isVisibleDonationRank = true, donationRankingPeriod = null)
),
rankingPort = rankingPort
)
service.getDonationTab(CREATOR_ID, createMember(VIEWER_ID), 0, 20, NOW)
assertEquals(RankingRequest(CREATOR_ID, DonationRankingPeriod.CUMULATIVE, false), rankingPort.requests.single())
}
@Test
@DisplayName("일반 조회자에게 랭킹이 비공개이면 랭킹 조회 없이 후원 탭 본문을 조립한다")
fun shouldSkipRankingsWhenHiddenFromNonCreatorViewer() {
val queryPort = FakeDonationQueryPort(
creator = createCreator(isVisibleDonationRank = false),
donationCount = 2,
donations = listOf(
createDonationRecord(nickname = "donor1"),
createDonationRecord(nickname = "donor2")
)
)
val rankingPort = FakeDonationRankingPort()
val service = createService(queryPort = queryPort, rankingPort = rankingPort)
val tab = service.getDonationTab(CREATOR_ID, createMember(VIEWER_ID), 0, 20, NOW)
assertEquals(emptyList<RankingRequest>(), rankingPort.requests)
assertEquals(emptyList<Any>(), tab.rankings)
assertEquals(2, tab.donationCount)
assertEquals(2, tab.donations.size)
assertEquals(0, tab.page.page)
assertEquals(20, tab.page.size)
assertFalse(tab.hasNext)
}
private fun createService(
queryPort: FakeDonationQueryPort = FakeDonationQueryPort(),
rankingPort: FakeDonationRankingPort = FakeDonationRankingPort()
): CreatorChannelDonationQueryService {
val provider = Mockito.mock(ObjectProvider::class.java) as ObjectProvider<CreatorChannelDonationQueryPort>
Mockito.doReturn(queryPort).`when`(provider).getObject()
return CreatorChannelDonationQueryService(
queryPortProvider = provider,
rankingPort = rankingPort,
queryPolicy = CreatorChannelDonationQueryPolicy(),
messageSource = SodaMessageSource(),
langContext = LangContext(),
cloudFrontHost = "https://cdn.test"
)
}
private fun createCreator(
role: MemberRole = MemberRole.CREATOR,
isVisibleDonationRank: Boolean = true,
donationRankingPeriod: DonationRankingPeriod? = DonationRankingPeriod.CUMULATIVE
): CreatorChannelDonationCreatorRecord {
return CreatorChannelDonationCreatorRecord(
creatorId = CREATOR_ID,
role = role,
nickname = "creator-nickname",
isVisibleDonationRank = isVisibleDonationRank,
donationRankingPeriod = donationRankingPeriod
)
}
private fun createDonationRecord(
nickname: String = "donor",
profileImagePath: String? = "profile/donor.png",
can: Int = 100,
message: String? = "thanks",
createdAt: LocalDateTime = NOW
): CreatorChannelDonationRecord {
return CreatorChannelDonationRecord(
nickname = nickname,
profileImagePath = profileImagePath,
can = can,
message = message,
createdAt = createdAt
)
}
private fun createRankingRecord(): CreatorChannelDonationRankingRecord {
return CreatorChannelDonationRankingRecord(
userId = VIEWER_ID,
nickname = "fan",
profileImage = "https://cdn.test/fan.png",
donationCan = 300
)
}
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 class FakeDonationQueryPort(
private val creator: CreatorChannelDonationCreatorRecord? = defaultCreator(),
private val blocked: Boolean = false,
private val donationCount: Int = 0,
private val donations: List<CreatorChannelDonationRecord> = emptyList()
) : CreatorChannelDonationQueryPort {
var lastCountDonationRequest: CountDonationRequest? = null
private set
var lastFindDonationRequest: FindDonationRequest? = null
private set
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelDonationCreatorRecord? {
return creator
}
override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean {
return blocked
}
override fun countChannelDonations(creatorId: Long, viewerId: Long, now: LocalDateTime): Int {
lastCountDonationRequest = CountDonationRequest(creatorId, viewerId, now)
return donationCount
}
override fun findChannelDonations(
creatorId: Long,
viewerId: Long,
now: LocalDateTime,
offset: Long,
limit: Int
): List<CreatorChannelDonationRecord> {
lastFindDonationRequest = FindDonationRequest(creatorId, viewerId, now, offset, limit)
return donations
}
}
private class FakeDonationRankingPort(
val records: List<CreatorChannelDonationRankingRecord> = listOf(defaultRankingRecord())
) : CreatorChannelDonationRankingPort {
val requests = mutableListOf<RankingRequest>()
override fun findTopRankings(
creatorId: Long,
period: DonationRankingPeriod,
withDonationCan: Boolean
): List<CreatorChannelDonationRankingRecord> {
requests += RankingRequest(creatorId, period, withDonationCan)
return records
}
}
private data class CountDonationRequest(
val creatorId: Long,
val viewerId: Long,
val now: LocalDateTime
)
private data class FindDonationRequest(
val creatorId: Long,
val viewerId: Long,
val now: LocalDateTime,
val offset: Long,
val limit: Int
)
private data class RankingRequest(
val creatorId: Long,
val period: DonationRankingPeriod,
val withDonationCan: Boolean
)
companion object {
private const val CREATOR_ID = 1L
private const val VIEWER_ID = 10L
private val NOW: LocalDateTime = LocalDateTime.of(2026, 6, 22, 3, 0)
private fun defaultCreator(): CreatorChannelDonationCreatorRecord {
return CreatorChannelDonationCreatorRecord(
creatorId = CREATOR_ID,
role = MemberRole.CREATOR,
nickname = "creator-nickname",
isVisibleDonationRank = true,
donationRankingPeriod = DonationRankingPeriod.CUMULATIVE
)
}
private fun defaultRankingRecord(): CreatorChannelDonationRankingRecord {
return CreatorChannelDonationRankingRecord(
userId = VIEWER_ID,
nickname = "fan",
profileImage = "https://cdn.test/fan.png",
donationCan = 300
)
}
}
}

View File

@@ -0,0 +1,125 @@
package kr.co.vividnext.sodalive.v2.creator.channel.donation.domain
import kr.co.vividnext.sodalive.member.DonationRankingPeriod
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationRankingRecord
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationRecord
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 CreatorChannelDonationQueryPolicyTest {
private val policy = CreatorChannelDonationQueryPolicy()
@Test
@DisplayName("후원 탭 page 정책은 null 요청을 기본값으로 fallback하고 fetch limit을 계산한다")
fun shouldFallbackNullPageAndSizeForDonationTab() {
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 shouldFallbackPageAndSizeForDonationTab() {
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))
assertFalse(policy.hasNext(emptyList<Int>(), page))
}
@Test
@DisplayName("후원 탭 월 범위 정책은 현재 UTC 시각 기준 KST 월 시작과 다음 월 시작을 UTC로 계산한다")
fun shouldCalculateCurrentKstMonthRangeAsUtc() {
val range = policy.currentKstMonthRange(LocalDateTime.of(2026, 6, 22, 3, 0))
assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0), range.startInclusiveUtc)
assertEquals(LocalDateTime.of(2026, 6, 30, 15, 0), range.endExclusiveUtc)
}
@Test
@DisplayName("후원 탭 domain model과 port record는 Phase 1 계약 필드를 유지한다")
fun shouldKeepDomainAndPortContract() {
val createdAt = LocalDateTime.of(2026, 6, 22, 10, 0)
val page = policy.createPage(page = 0, size = 20)
val ranking = CreatorChannelDonationRanking(
userId = 10L,
nickname = "fan",
profileImage = "https://cdn.test/fan.png",
donationCan = 100
)
val donation = CreatorChannelDonation(
nickname = "donor",
profileImageUrl = "https://cdn.test/donor.png",
can = 50,
message = "thanks",
createdAt = createdAt
)
val tab = CreatorChannelDonationTab(
donationCount = 1,
rankings = listOf(ranking),
donations = listOf(donation),
page = page,
hasNext = false
)
val creatorRecord = CreatorChannelDonationCreatorRecord(
creatorId = 1L,
role = MemberRole.CREATOR,
nickname = "creator",
isVisibleDonationRank = true,
donationRankingPeriod = DonationRankingPeriod.CUMULATIVE
)
val donationRecord = CreatorChannelDonationRecord(
nickname = "donor",
profileImagePath = null,
can = 50,
message = "thanks",
createdAt = createdAt
)
val rankingRecord = CreatorChannelDonationRankingRecord(
userId = 10L,
nickname = "fan",
profileImage = "https://cdn.test/fan.png",
donationCan = 100
)
assertEquals(1, tab.donationCount)
assertEquals(ranking, tab.rankings.first())
assertEquals(donation, tab.donations.first())
assertEquals(page, tab.page)
assertFalse(tab.hasNext)
assertEquals(MemberRole.CREATOR, creatorRecord.role)
assertEquals(DonationRankingPeriod.CUMULATIVE, creatorRecord.donationRankingPeriod)
assertNull(donationRecord.profileImagePath)
assertEquals(100, rankingRecord.donationCan)
}
}

View File

@@ -0,0 +1,227 @@
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.block.BlockMember
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 DefaultCreatorChannelFanTalkQueryRepositoryTest @Autowired constructor(
private val entityManager: EntityManager,
queryFactory: JPAQueryFactory
) {
private val repository = DefaultCreatorChannelFanTalkQueryRepository(queryFactory)
@Test
@DisplayName("활성 회원은 role과 닉네임을 조회하고 비활성 회원은 조회하지 않는다")
fun shouldFindOnlyActiveCreator() {
val viewer = saveMember("creator-lookup-viewer", MemberRole.USER)
val activeCreator = saveMember("active-fantalk-creator", MemberRole.CREATOR)
val inactiveCreator = saveMember("inactive-fantalk-creator", MemberRole.CREATOR, isActive = false)
val nonCreator = saveMember("fantalk-non-creator", MemberRole.USER)
flushAndClear()
val activeRecord = repository.findCreator(activeCreator.id!!, viewer.id!!)
val inactiveRecord = repository.findCreator(inactiveCreator.id!!, viewer.id!!)
val nonCreatorRecord = repository.findCreator(nonCreator.id!!, viewer.id!!)
assertNotNull(activeRecord)
assertEquals(activeCreator.id, activeRecord!!.creatorId)
assertEquals(MemberRole.CREATOR, activeRecord.role)
assertEquals(activeCreator.nickname, activeRecord.nickname)
assertNull(inactiveRecord)
assertEquals(MemberRole.USER, nonCreatorRecord!!.role)
}
@Test
@DisplayName("조회자와 크리에이터 사이 양방향 활성 차단만 차단 상태로 조회한다")
fun shouldFindActiveBlockInBothDirections() {
val viewer = saveMember("fantalk-block-viewer", MemberRole.USER)
val creator = saveMember("fantalk-block-creator", MemberRole.CREATOR)
val otherCreator = saveMember("fantalk-other-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("최상위 FanTalk 수와 목록은 활성 루트 글만 세고 작성자 차단을 제외한다")
fun shouldCountAndFindOnlyVisibleTopLevelFanTalks() {
val viewer = saveMember("fantalk-list-viewer", MemberRole.USER)
val creator = saveMember("fantalk-list-creator", MemberRole.CREATOR)
val otherCreator = saveMember("fantalk-list-other-creator", MemberRole.CREATOR)
val visibleWriter = saveMember("visible-writer", MemberRole.USER, profileImage = "visible.png")
val blockedWriter = saveMember("blocked-writer", MemberRole.USER)
val writerBlockingViewer = saveMember("writer-blocking-viewer", MemberRole.USER)
val now = LocalDateTime.of(2026, 6, 22, 12, 0)
val older = saveCheers(visibleWriter, creator, "older", isActive = true, createdAt = now.minusHours(2))
val newer = saveCheers(visibleWriter, creator, "newer", isActive = true, createdAt = now.minusHours(1))
saveCheers(visibleWriter, creator, "inactive", isActive = false, createdAt = now)
saveCheers(visibleWriter, otherCreator, "other creator", isActive = true, createdAt = now)
saveCheers(visibleWriter, creator, "reply", isActive = true, createdAt = now, parent = older)
saveCheers(blockedWriter, creator, "viewer blocked", isActive = true, createdAt = now.plusHours(1))
saveCheers(writerBlockingViewer, creator, "writer blocked", isActive = true, createdAt = now.plusHours(2))
saveBlock(viewer, blockedWriter, isActive = true)
saveBlock(writerBlockingViewer, viewer, isActive = true)
flushAndClear()
val count = repository.countFanTalks(creator.id!!, viewer.id!!)
val firstPage = repository.findFanTalks(creator.id!!, viewer.id!!, offset = 0, limit = 1)
val secondPage = repository.findFanTalks(creator.id!!, viewer.id!!, offset = 1, limit = 2)
assertEquals(2, count)
assertEquals(listOf(newer.id), firstPage.map { it.fanTalkId })
assertEquals(listOf(older.id), secondPage.map { it.fanTalkId })
assertEquals(visibleWriter.id, firstPage.first().writerId)
assertEquals(visibleWriter.nickname, firstPage.first().writerNickname)
assertEquals(visibleWriter.profileImage, firstPage.first().writerProfileImagePath)
assertEquals("newer", firstPage.first().content)
}
@Test
@DisplayName("최상위 FanTalk 목록은 createdAt desc, id desc 순서로 정렬한다")
fun shouldOrderFanTalksByCreatedAtDescAndIdDesc() {
val viewer = saveMember("fantalk-order-viewer", MemberRole.USER)
val creator = saveMember("fantalk-order-creator", MemberRole.CREATOR)
val writer = saveMember("fantalk-order-writer", MemberRole.USER)
val sameCreatedAt = LocalDateTime.of(2026, 6, 22, 12, 0)
val first = saveCheers(writer, creator, "first", isActive = true, createdAt = sameCreatedAt)
val second = saveCheers(writer, creator, "second", isActive = true, createdAt = sameCreatedAt)
val newest = saveCheers(writer, creator, "newest", isActive = true, createdAt = sameCreatedAt.plusMinutes(1))
flushAndClear()
val records = repository.findFanTalks(creator.id!!, viewer.id!!, offset = 0, limit = 10)
assertEquals(listOf(newest.id, second.id, first.id), records.map { it.fanTalkId })
}
@Test
@DisplayName("크리에이터 답글은 지정한 부모의 활성 크리에이터 작성 답글만 오래된 순으로 조회한다")
fun shouldFindOnlyActiveCreatorRepliesForRequestedParents() {
val creator = saveMember("reply-creator", MemberRole.CREATOR)
val writer = saveMember("reply-writer", MemberRole.USER)
val otherCreator = saveMember("reply-other-creator", MemberRole.CREATOR)
val now = LocalDateTime.of(2026, 6, 22, 12, 0)
val parent = saveCheers(writer, creator, "parent", isActive = true, createdAt = now.minusHours(3))
val otherParent = saveCheers(writer, creator, "other parent", isActive = true, createdAt = now.minusHours(2))
val newerReply = saveCheers(creator, creator, "newer reply", isActive = true, createdAt = now, parent = parent)
val olderReply = saveCheers(
creator,
creator,
"older reply",
isActive = true,
createdAt = now.minusMinutes(1),
parent = parent
)
saveCheers(writer, creator, "fan reply", isActive = true, createdAt = now.plusMinutes(1), parent = parent)
saveCheers(creator, creator, "inactive reply", isActive = false, createdAt = now.plusMinutes(2), parent = parent)
saveCheers(
creator,
otherCreator,
"other creator reply",
isActive = true,
createdAt = now.plusMinutes(3),
parent = parent
)
saveCheers(
creator,
creator,
"other parent reply",
isActive = true,
createdAt = now.plusMinutes(4),
parent = otherParent
)
flushAndClear()
val replies = repository.findCreatorReplies(creator.id!!, listOf(parent.id!!))
val emptyReplies = repository.findCreatorReplies(creator.id!!, emptyList())
assertEquals(listOf(olderReply.id, newerReply.id), replies.map { it.fanTalkId })
assertEquals(parent.id, replies.first().parentFanTalkId)
assertEquals(creator.id, replies.first().writerId)
assertEquals(creator.nickname, replies.first().writerNickname)
assertEquals(creator.profileImage, replies.first().writerProfileImagePath)
assertEquals("older reply", replies.first().content)
assertTrue(emptyReplies.isEmpty())
}
private fun saveMember(
nickname: String,
role: MemberRole,
isActive: Boolean = true,
profileImage: String? = "$nickname.png"
): Member {
val member = Member(
email = "$nickname@test.com",
password = "password",
nickname = nickname,
profileImage = profileImage,
role = role,
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 saveCheers(
member: Member,
creator: Member,
cheers: String,
isActive: Boolean,
createdAt: LocalDateTime,
parent: CreatorCheers? = null
): CreatorCheers {
val creatorCheers = CreatorCheers(cheers = cheers, languageCode = "ko", isActive = isActive)
creatorCheers.member = member
creatorCheers.creator = creator
creatorCheers.parent = parent
entityManager.persist(creatorCheers)
entityManager.flush()
updateCreatedAt("CreatorCheers", creatorCheers.id!!, createdAt)
return creatorCheers
}
private fun updateCreatedAt(entityName: String, id: Long, createdAt: LocalDateTime) {
entityManager.createQuery("update $entityName e set e.createdAt = :createdAt where e.id = :id")
.setParameter("createdAt", createdAt)
.setParameter("id", id)
.executeUpdate()
}
private fun flushAndClear() {
entityManager.flush()
entityManager.clear()
}
}

View File

@@ -0,0 +1,277 @@
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application
import kr.co.vividnext.sodalive.common.SodaException
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.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicy
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkQueryPort
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkRecord
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkReplyRecord
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.springframework.beans.factory.ObjectProvider
import java.time.LocalDateTime
class CreatorChannelFanTalkQueryServiceTest {
@Test
@DisplayName("creatorId에 해당하는 회원이 없으면 user_not_found 예외를 던진다")
fun shouldThrowUserNotFoundWhenCreatorMemberDoesNotExist() {
val port = FakeCreatorChannelFanTalkQueryPort().apply { creator = null }
val service = createService(port)
val viewer = createMember(id = 10L)
val exception = assertThrows(SodaException::class.java) {
service.getFanTalkTab(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 = FakeCreatorChannelFanTalkQueryPort().apply { creator = creator?.copy(role = MemberRole.USER) }
val service = createService(port)
val viewer = createMember(id = 10L)
val exception = assertThrows(SodaException::class.java) {
service.getFanTalkTab(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 = FakeCreatorChannelFanTalkQueryPort().apply { blocked = true }
val service = createService(port)
val viewer = createMember(id = 10L)
val exception = assertThrows(SodaException::class.java) {
service.getFanTalkTab(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("FanTalk 탭 서비스는 요청 fallback과 조회 컨텍스트를 port에 전달하고 탭을 조립한다")
fun shouldResolveRequestFallbacksAndAssembleFanTalkTab() {
val port = FakeCreatorChannelFanTalkQueryPort().apply {
fanTalkCount = 60
fanTalks = (1L..21L).map { fanTalkRecord(it) }
creatorReplies = listOf(
fanTalkReplyRecord(fanTalkId = 101L, parentFanTalkId = 1L),
fanTalkReplyRecord(fanTalkId = 102L, parentFanTalkId = 2L),
fanTalkReplyRecord(fanTalkId = 103L, parentFanTalkId = 21L)
)
}
val service = createService(port)
val viewer = createMember(id = 10L)
val tab = service.getFanTalkTab(
creatorId = 1L,
viewer = viewer,
page = -1,
size = 10,
now = LocalDateTime.of(2026, 6, 21, 10, 0)
)
assertEquals(60, tab.fanTalkCount)
assertEquals(0, tab.page.page)
assertEquals(20, tab.page.size)
assertEquals(0L, port.listOffset)
assertEquals(21, port.listLimit)
assertEquals(20, tab.fanTalks.size)
assertTrue(tab.hasNext)
assertEquals(
(1L..20L).toList(),
port.replyParentFanTalkIds
)
assertEquals(101L, tab.fanTalks[0].creatorReplies.single().fanTalkId)
assertEquals(102L, tab.fanTalks[1].creatorReplies.single().fanTalkId)
assertEquals(emptyList<Long>(), tab.fanTalks[19].creatorReplies.map { it.fanTalkId })
}
@Test
@DisplayName("FanTalk 목록이 비어 있으면 답글 조회 없이 빈 목록과 hasNext=false를 반환한다")
fun shouldReturnEmptyFanTalksWithoutFindingReplies() {
val port = FakeCreatorChannelFanTalkQueryPort().apply {
fanTalkCount = 5
fanTalks = emptyList()
}
val service = createService(port)
val viewer = createMember(id = 10L)
val tab = service.getFanTalkTab(1L, viewer, 3, 20, LocalDateTime.of(2026, 6, 21, 10, 0))
assertEquals(5, tab.fanTalkCount)
assertEquals(emptyList<Long>(), tab.fanTalks.map { it.fanTalkId })
assertEquals(false, tab.hasNext)
assertNull(port.replyParentFanTalkIds)
}
@Test
@DisplayName("FanTalk과 creator reply 작성자의 프로필 URL과 탈퇴 닉네임 prefix를 변환한다")
fun shouldConvertWriterProfileUrlsAndDeletedNicknamePrefixes() {
val port = FakeCreatorChannelFanTalkQueryPort().apply {
fanTalks = listOf(
fanTalkRecord(
fanTalkId = 1L,
writerNickname = "deleted_fan",
writerProfileImagePath = "profile/fan.png"
),
fanTalkRecord(
fanTalkId = 2L,
writerNickname = "normal",
writerProfileImagePath = null
)
)
creatorReplies = listOf(
fanTalkReplyRecord(
fanTalkId = 101L,
parentFanTalkId = 1L,
writerNickname = "deleted_creator",
writerProfileImagePath = "https://images.test/creator.png"
),
fanTalkReplyRecord(
fanTalkId = 102L,
parentFanTalkId = 2L,
writerNickname = "creator",
writerProfileImagePath = " "
)
)
}
val service = createService(port)
val viewer = createMember(id = 10L)
val fanTalks = service.getFanTalkTab(1L, viewer, null, null, LocalDateTime.of(2026, 6, 21, 10, 0))
.fanTalks
assertEquals("fan", fanTalks[0].writerNickname)
assertEquals("https://cdn.test/profile/fan.png", fanTalks[0].writerProfileImageUrl)
assertEquals("creator", fanTalks[0].creatorReplies.single().writerNickname)
assertEquals("https://images.test/creator.png", fanTalks[0].creatorReplies.single().writerProfileImageUrl)
assertEquals("normal", fanTalks[1].writerNickname)
assertEquals("https://cdn.test/profile/default-profile.png", fanTalks[1].writerProfileImageUrl)
assertEquals("https://cdn.test/profile/default-profile.png", fanTalks[1].creatorReplies.single().writerProfileImageUrl)
}
private fun createService(port: FakeCreatorChannelFanTalkQueryPort): CreatorChannelFanTalkQueryService {
val langContext = LangContext()
langContext.setLang(Lang.EN)
return CreatorChannelFanTalkQueryService(
queryPortProvider = FixedCreatorChannelFanTalkQueryPortProvider(port),
queryPolicy = CreatorChannelFanTalkQueryPolicy(),
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 FixedCreatorChannelFanTalkQueryPortProvider(
private val port: CreatorChannelFanTalkQueryPort
) : ObjectProvider<CreatorChannelFanTalkQueryPort> {
override fun getObject(vararg args: Any?): CreatorChannelFanTalkQueryPort = port
override fun getIfAvailable(): CreatorChannelFanTalkQueryPort = port
override fun getIfUnique(): CreatorChannelFanTalkQueryPort = port
override fun getObject(): CreatorChannelFanTalkQueryPort = port
}
private class FakeCreatorChannelFanTalkQueryPort : CreatorChannelFanTalkQueryPort {
var creator: CreatorChannelFanTalkCreatorRecord? = CreatorChannelFanTalkCreatorRecord(
creatorId = 1L,
role = MemberRole.CREATOR,
nickname = "creator"
)
var blocked = false
var fanTalkCount = 1
var fanTalks = listOf(fanTalkRecord(1L))
var creatorReplies = emptyList<CreatorChannelFanTalkReplyRecord>()
var listOffset: Long? = null
var listLimit: Int? = null
var replyParentFanTalkIds: List<Long>? = null
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelFanTalkCreatorRecord? = creator
override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean = blocked
override fun countFanTalks(creatorId: Long, viewerId: Long): Int = fanTalkCount
override fun findFanTalks(
creatorId: Long,
viewerId: Long,
offset: Long,
limit: Int
): List<CreatorChannelFanTalkRecord> {
listOffset = offset
listLimit = limit
return fanTalks
}
override fun findCreatorReplies(
creatorId: Long,
parentFanTalkIds: List<Long>
): List<CreatorChannelFanTalkReplyRecord> {
replyParentFanTalkIds = parentFanTalkIds
return creatorReplies
}
}
private fun fanTalkRecord(
fanTalkId: Long,
writerId: Long = 10L + fanTalkId,
writerNickname: String = "fan-$fanTalkId",
writerProfileImagePath: String? = "profile/$fanTalkId.png"
): CreatorChannelFanTalkRecord {
return CreatorChannelFanTalkRecord(
fanTalkId = fanTalkId,
writerId = writerId,
writerNickname = writerNickname,
writerProfileImagePath = writerProfileImagePath,
content = "content-$fanTalkId",
createdAt = LocalDateTime.of(2026, 6, 21, 10, 0).plusMinutes(fanTalkId)
)
}
private fun fanTalkReplyRecord(
fanTalkId: Long,
parentFanTalkId: Long,
writerId: Long = 1L,
writerNickname: String = "creator",
writerProfileImagePath: String? = "profile/creator.png"
): CreatorChannelFanTalkReplyRecord {
return CreatorChannelFanTalkReplyRecord(
fanTalkId = fanTalkId,
parentFanTalkId = parentFanTalkId,
writerId = writerId,
writerNickname = writerNickname,
writerProfileImagePath = writerProfileImagePath,
content = "reply-$fanTalkId",
createdAt = LocalDateTime.of(2026, 6, 21, 11, 0).plusMinutes(fanTalkId)
)
}

View File

@@ -0,0 +1,120 @@
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkRecord
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkReplyRecord
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 CreatorChannelFanTalkQueryPolicyTest {
private val policy = CreatorChannelFanTalkQueryPolicy()
@Test
@DisplayName("FanTalk 탭 page 정책은 null 요청을 기본값으로 fallback하고 fetch limit을 계산한다")
fun shouldFallbackNullPageAndSizeForFanTalkTab() {
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("FanTalk 탭 page 정책은 최소/최대 범위로 fallback하고 fetch limit을 계산한다")
fun shouldFallbackPageAndSizeForFanTalkTab() {
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("FanTalk 탭 목록 정책은 요청 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))
assertFalse(policy.hasNext(emptyList<Int>(), page))
}
@Test
@DisplayName("FanTalk 탭 domain model과 port record는 Phase 1 계약 필드를 유지한다")
fun shouldKeepDomainAndPortContract() {
val createdAt = LocalDateTime.of(2026, 6, 22, 10, 0)
val page = policy.createPage(page = 0, size = 20)
val reply = CreatorChannelFanTalkReply(
fanTalkId = 11L,
writerId = 1L,
writerNickname = "creator",
writerProfileImageUrl = "https://cdn.test/creator.png",
content = "reply",
createdAt = createdAt.plusMinutes(1)
)
val tab = CreatorChannelFanTalkTab(
fanTalkCount = 1,
fanTalks = listOf(
CreatorChannelFanTalk(
fanTalkId = 10L,
writerId = 2L,
writerNickname = "fan",
writerProfileImageUrl = "https://cdn.test/fan.png",
content = "fan talk",
createdAt = createdAt,
creatorReplies = listOf(reply)
)
),
page = page,
hasNext = false
)
val creatorRecord = CreatorChannelFanTalkCreatorRecord(
creatorId = 1L,
role = MemberRole.CREATOR,
nickname = "creator"
)
val fanTalkRecord = CreatorChannelFanTalkRecord(
fanTalkId = 10L,
writerId = 2L,
writerNickname = "fan",
writerProfileImagePath = null,
content = "fan talk",
createdAt = createdAt
)
val replyRecord = CreatorChannelFanTalkReplyRecord(
fanTalkId = 11L,
parentFanTalkId = 10L,
writerId = 1L,
writerNickname = "creator",
writerProfileImagePath = null,
content = "reply",
createdAt = createdAt.plusMinutes(1)
)
assertEquals(1, tab.fanTalkCount)
assertEquals("fan", tab.fanTalks.first().writerNickname)
assertEquals(reply, tab.fanTalks.first().creatorReplies.first())
assertEquals(page, tab.page)
assertFalse(tab.hasNext)
assertEquals(MemberRole.CREATOR, creatorRecord.role)
assertNull(fanTalkRecord.writerProfileImagePath)
assertEquals(10L, replyRecord.parentFanTalkId)
}
}