# 크리에이터 채널 후원 탭 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, val donations: List, 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, val donations: List, 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 } interface CreatorChannelDonationRankingPort { fun findTopRankings( creatorId: Long, period: DonationRankingPeriod, withDonationCan: Boolean ): List } 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` 패턴을 사용해 기존 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` 모두 성공했다.