44 KiB
크리에이터 채널 후원 탭 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으로 보정
- path variable:
- response:
donationCount: 조회자가 조회 가능한 현재 KST 월 범위의 전체 채널 후원 개수rankings: 후원 순위 Top 8 목록donations: 채널 후원 목록page: 보정 후 실제 적용된 page indexsize: 보정 후 실제 적용된 page sizehasNext: 다음 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(...)를 통해 legacyCreatorDonationRankingQueryRepository.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와 이 문서를 갱신한다.
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를 참조하지 않는다.
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
)
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
- Create:
- RED:
CreatorChannelDonationQueryPolicyTest를 먼저 작성한다.- null page/size가
0/20, fetchLimit21로 보정되는지 검증한다. page = -1,size = 10이0/20으로 보정되는지 검증한다.page = 2,size = 100이2/50, offset100, fetchLimit51로 보정되는지 검증한다.- 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: 신규 클래스가 없어 컴파일 실패한다.
- null page/size가
- 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
- Run:
- 실행 기록:
- RED:
./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicyTest실행, 신규 domain/port/policy 타입 부재로compileTestKotlin실패 확인. - GREEN: 동일 명령 재실행,
BUILD SUCCESSFUL확인.
- RED:
- 파일:
-
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
- Create:
- 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건
- Run:
- 실행 기록:
- 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건 확인.
- RED:
- 파일:
-
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
- Create:
- 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건 확인
- Run:
- 실행 기록:
- 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건 확인.
- RED:
- 파일:
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
- Create:
- RED: fake
CreatorChannelDonationQueryPort, fakeCreatorChannelDonationRankingPort를 사용해 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가 없어 컴파일 실패한다.
- creator가 없으면
- 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(...)의 placeholderSodaException(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건
- Run:
- 실행 기록:
- 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건 확인.
- RED:
- 파일:
-
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
- Create:
- 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가 없어 컴파일 실패한다.
- 활성 creator는 role, nickname,
- 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
- Run:
- 실행 기록:
- 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확인.
- RED:
- 파일:
-
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
- Create:
- 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건
- Run:
- 실행 기록:
- 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확인.
- RED:
- 파일:
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
- Create:
- 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
- Run:
- 실행 기록:
- 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노출을 확인.
- E2E:
- 파일:
-
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
- Verify:
- 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
- Run:
- 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
- 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실행,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. 구현 순서
- Phase 1에서 공개 계약, domain/port, page/month 정책, facade/controller를 먼저 고정한다.
- Phase 2에서 query service, QueryDSL repository, legacy ranking adapter를 TDD로 추가한다.
- Phase 3에서 End-to-End 테스트와 아키텍처/포맷 검증을 수행한다.
- 각 task 완료 즉시 해당 체크박스를
- [x]로 변경하고, 실행한 명령과 결과를 task 아래에 한국어로 누적 기록한다.
6. 전체 검증 기록
- Phase 1 검증은 각 Task 실행 기록에 누적했다.
- Phase 2 검증은 각 Task 실행 기록에 누적했다.
- Phase 3 검증은 Task 3.1, Task 3.2 실행 기록에 누적했다. 단일 E2E, 관련 테스트 묶음, 의존 방향 검색, endpoint mapping 검색, feature flag 검색,
ktlintCheck모두 성공했다.