docs(creator-channel): 후원 탭 API 계획을 기록한다

This commit is contained in:
2026-06-22 16:31:54 +09:00
parent 4ffd880440
commit b2fae3e081
2 changed files with 719 additions and 0 deletions

View File

@@ -0,0 +1,481 @@
# 크리에이터 채널 후원 탭 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`를 사용한다.
- 조회자가 크리에이터 본인이거나 크리에이터의 `isVisibleDonationRank``true`이면 `rankings`를 내려준다.
- 조회자가 크리에이터 본인이 아니고 크리에이터의 `isVisibleDonationRank``false`이면 `rankings`는 빈 배열이다.
- `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: 공개 계약과 순수 정책 추가
- [ ] **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`
- [ ] **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건
- [ ] **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건 확인
### Phase 2: 도메인 조회 서비스와 legacy ranking 재사용 추가
- [ ] **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으로 잘리는지 검증한다.
- 조회자 본인이 크리에이터이면 ranking port에 `withDonationCan = true`가 전달되는지 검증한다.
- 조회자 본인이 아니고 `isVisibleDonationRank = true`이면 ranking port에 `withDonationCan = false`가 전달되고 `rankings`가 반환되는지 검증한다.
- 조회자 본인이 아니고 `isVisibleDonationRank = false`이면 ranking port를 호출하지 않고 `rankings`가 빈 배열인지 검증한다.
- 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`로 보정한다.
- `findChannelDonations(...)` 결과는 `limitItems` 적용 후 domain으로 변환한다.
- `hasNext`는 fetch 결과 크기로 계산한다.
- 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건
- [ ] **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`
- [ ] **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가 전달되는지 검증한다.
- 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건
### Phase 3: 통합 검증과 회귀 확인
- [ ] **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` 통합 테스트를 먼저 작성한다.
- controller-service-repository를 거쳐 후원 탭 API가 `donationCount`, `donations`, `page`, `size`, `hasNext`를 반환하는지 검증한다.
- `page` 범위 밖 요청은 빈 `donations`, 유지된 `donationCount`, `hasNext = false`를 반환하는지 검증한다.
- `page = -1`, `size = 100` 요청은 응답의 `page = 0`, `size = 50`으로 보정되는지 검증한다.
- 일반 조회자에게 크리에이터의 비공개 후원은 숨기고 조회자 본인의 비공개 후원은 노출하는지 검증한다.
- 크리에이터 본인 조회 시 비공개 후원과 `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`
- [ ] **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: `./gradlew ktlintCheck`
- Expected: `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. 전체 검증 기록
- 아직 구현 전이므로 검증 기록 없음.

View File

@@ -0,0 +1,238 @@
# PRD: 크리에이터 채널 후원 탭 API
## 1. Overview
크리에이터 채널의 후원 탭에서 전체 채널 후원 개수, 후원 순위 Top 8, 채널 후원 목록을 페이징 조회하는 API를 제공한다.
---
## 2. Problem
- 크리에이터 채널 홈 API는 후원 섹션에 최신 채널 후원 일부만 제공한다.
- 후원 탭은 홈 요약보다 더 많은 채널 후원 목록을 추가 로딩해야 하고, 전체 채널 후원 개수와 후원 순위 Top 8을 함께 표시해야 한다.
- 레거시 채널 후원 목록 API는 `/explorer/profile/channel-donation`에 있고, V2 크리에이터 채널 탭 API의 패키지 분리 구조와 맞지 않는다.
- 후원 순위는 기존 레거시 `CreatorDonationRankingQueryRepository.getMemberDonationRanking` 결과와 동일해야 하므로 새 집계 기준을 임의로 만들면 안 된다.
- 신규 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`는 빈 배열로 내려준다.
- 작성자 프로필 이미지가 없으면 기존 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`는 기존 레거시 서비스의 주간 범위 계산을 따른다.
- 후원 순위 노출 정책은 기존 프로필 정책과 동일하게 유지한다.
- 조회자가 크리에이터 본인이거나 크리에이터의 `isVisibleDonationRank``true`이면 `rankings`를 내려준다.
- 조회자가 크리에이터 본인이 아니고 크리에이터의 `isVisibleDonationRank``false`이면 `rankings`는 빈 배열로 내려준다.
- `donationCan` 노출 여부는 기존 프로필 정책과 동일하게 크리에이터 본인 조회 시 실제 값을 내려주고, 일반 회원 조회 시 `0`으로 내려준다.
#### Edge Cases
- 순위 대상 회원이 8명보다 적으면 있는 만큼만 내려준다.
- 같은 후원 캔 금액이면 레거시 쿼리와 동일하게 회원 ID 내림차순으로 정렬한다.
- 순위 조회 결과가 없어도 후원 탭 API는 성공 처리한다.
### 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` 기준을 변경하지 않는다.
- 채널 후원 목록과 개수의 기간은 홈 후원 섹션과 동일하게 현재 KST 월 범위로 한다.
---
## 10. Open Questions
- 없음. 구현 중 공개 응답 필드 추가나 기간 정책 변경이 필요하면 이 PRD를 먼저 갱신한다.