Merge pull request 'Test' (#467) from test into main

Reviewed-on: #467
This commit was merged in pull request #467.
This commit is contained in:
2026-11-06 12:15:58 +00:00
56 changed files with 3686 additions and 153 deletions
+1
View File
@@ -196,6 +196,7 @@ single-context 구성으로 루트 `GLOSSARY.md`와 `docs/adr/`를 사용한다.
- 필요한 문서/도구/참조가 없으면 어느 검토 부분이 미검증인지 보고한다. 공유 checklist 결손을 조용히 무시하거나 global checklist를 만들거나 설치해 full sign-off로 바꾸지 않는다.
- 기존 PRD/plan 경로와 project guides가 변경 범위·테스트·검증 기준이다. 이미 완료된 분석을 다른 skill workflow로 반복하지 않는다.
- 구현 시 focused test부터 시작하고 프로젝트의 TDD, 보안 및 사람 승인 경계를 지킨다. 실패한 테스트를 삭제/완화하지 않는다.
- 에이전트는 구현 중인 기능과 직접 영향받는 범위만 각각 테스트한다. 전체 테스트는 사용자가 실행하며, 에이전트는 실행 명령과 미검증 범위를 안내한다. `test` 무필터 실행 및 전체 테스트를 포함하는 `check`/`build`/`clean build`도 자동 실행하지 않는다. 상세 기준은 `docs/agent-guides/실행명령어.md`를 따른다.
- 새 session은 갱신된 `AGENTS.md`를 안정적으로 다시 읽을 수 있도록 시작한다. skill 단계는 논리적 routing guidance이며 runtime 자동 invocation 성공을 보장하지 않는다.
- worktree는 필요성을 확인하되 자동 생성하지 않는다. branch 종료/merge는 실제 해당 작업일 때만 다루며 commit/push/merge/delete는 명시적 승인 없이는 하지 않는다.
- 사용자가 이미 명확하게 승인한 범위는 다시 묻지 않는다. 승인 경계 밖의 새로운 결정이나 scope 확장만 확인한다.
@@ -0,0 +1,390 @@
# 크리에이터 채널 비밀 후원 익명 조회 구현 계획
| 문서 항목 | 내용 |
|---|---|
| 상태 | Phase 1 및 `P2-T1`·`P2-GATE` 완료. 전체 테스트는 사용자 실행 |
| 작성일 | 2026-11-04 |
| 요구사항 기준 | [prd.md](prd.md) |
| API 기준 | [prd.md §8](prd.md#8-api-계약) |
| 현재 Phase | Phase 2: 응답 isSecret UI 표시 의미 변경 |
| 현재 활성 Goal | 없음 |
| 다음 Goal | 없음 |
> 실행 준비 기준: goal별로 한 번에 하나씩 실행하며, 각 Task의 명령과 체크박스는 실행 후에만 완료 처리한다. 구현 방법은 본 계획의 TDD 순서를 따른다.
**실행 방식:** 사용자 지시에 따라 worktree 없이 현재 `test` 브랜치에서 Goal을 순차 실행한다. 커밋·푸시·배포는 하지 않는다.
**Architecture:** persistence record는 실제 `isSecret`을 유지한다. 서비스의 기존 `toDomain` mapper가 조회자별 `isMasked`로 표시 필드와 응답 domain의 `isSecret`을 정한다. DTO 필드명·타입과 기존 이미지 URL 조합은 바꾸지 않는다.
**Spec:** [prd.md](prd.md), 특히 §7~§10.
**Review Focus:**
- 공개 행은 다른 인증 viewer에게도 원본 표시: `P1-T1`, `P1-T2` 공개 후원 E2E assertion.
- 비밀 행은 USER 및 본인이 아닌 CREATOR role도 익명: 양 Task의 role별 HTTP E2E.
- null/empty 메시지, localized nickname, 마스킹 이미지 URL과 원본/default-profile 회귀: 양 Task의 mapper/service 검증.
- 월 경계·정렬·limit/page의 비밀 행 포함: repository와 HTTP E2E의 경계·초과 건수 검증.
- 응답 `isSecret`은 조회자별 마스킹 Boolean, DB/record는 원래 비밀 Boolean이다. 양 endpoint 역할별 JSON과 저장값 불변을 검증하고 랭킹은 유지한다.
## 목표
크리에이터 채널 홈과 후원 탭에서 현재 KST 월 비밀 후원을 유지하며, 크리에이터와 후원자 외의 사용자에게는 식별 정보 대신 익명 필드를 반환한다.
## 현재 상태
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|---:|---|---:|---|---|
| 1 | 범위 한정 완료 | `2/2` | 없음 | 전체 suite는 600000ms timeout으로 결과 미확정. 전체 suite 통과로 간주하지 않음 |
| 2 | 완료 | `1/1` | 없음 | 관련 196개 회귀 및 최종 HTTP 55개 통과. 전체 테스트는 사용자 실행 |
- 구현 단계의 실행 체크박스는 실제 테스트 및 검토 증거가 확보된 뒤에만 완료 처리한다.
- 미완료 Goal은 동시에 하나만 실행한다.
- 파일명과 URL 구성 계약 및 설정된 CDN 이미지 접근은 확인했다. 전체 프로젝트 suite는 timeout으로 미완료 상태이며 범위 한정 Gate 성공과 분리한다.
## 범위
### 포함
- 홈 및 후원 탭의 현재 KST 월 비밀 후원 조회, DB 비밀 설정 보존, 조회자별 UI flag `isSecret` 및 서비스 계층 마스킹.
- 양쪽 DTO 직렬화와 repository, service, controller, facade 및 HTTP MockMvc E2E 검증.
- 기존 접근 제어, 정렬, count/page, 랭킹, 날짜·금액 계약 보존.
### 제외
- 후원 생성, 수정, 취소, DB/DDL, 공개 후원 표시 변경.
- 랭킹 변경 또는 전체 익명성 보장.
- 신규 dependency, 공통 masking abstraction, port signature 변경(실제 필요가 발견된 경우 별도 결정 전까지 금지).
- 새 config, fallback, 공통 helper architecture 또는 외부 번역 서비스.
## 기술적 제약
- Kotlin, Spring Boot 2.7.14, Java 17, Gradle Wrapper, QueryDSL 및 기존 `SodaMessageSource`/CDN URL 변환을 사용한다.
- 이번 변경에서 공개 API 필드를 추가하지 않는다. 내부 `memberId`, 현재 채널 주인 전용 `donationId` 및 숨김 정책은 유지한다.
- persistence에서 비밀 row를 유지하되 서비스가 response domain을 만들기 전 익명화한다. 두 계층 모두 수정과 테스트를 하나의 end-to-end Task로 수행한다.
- 각 Task의 `isSecret` 추가는 해당 탭의 port record, domain model, QueryDSL constructor projection 순서와 해당 테스트 fixture constructor를 함께 갱신한다.
- 로컬 변환 함수 `toDomain`이 이미 있으므로 기존 함수가 있는 service 안에서 viewer/creator/lang 판정을 수행한다. 새 공유 추상화는 만들지 않는다.
- 신규 로직은 먼저 요구 동작을 검증하는 assertion 실패를 관찰한 뒤 구현한다. 필드 추가로 발생한 컴파일 오류는 의도한 RED 증거가 아니다.
- focused test와 직접 영향 package 회귀를 우선한다. 전체 테스트·빌드는 이 계획의 미래 Gate에서만 실행하며, 실제 수행한 결과만 Progress에 기록한다.
- 마스킹 대상 비밀 후원의 이미지 URL은 기존 `defaultProfileImageUrl()`처럼 `cloud.aws.cloud-front.host`에 `/profile/anonymous-profile.png`를 직접 붙여 만든다. 임의 사용자 제공 경로에 `toCdnUrl`을 적용하지 않는다. 테스트 host `https://cdn.test`의 정확한 기대 URL은 `https://cdn.test/profile/anonymous-profile.png`다.
- 익명 URL은 마스킹 대상 비밀 donation item에만 적용한다. 공개 및 creator/donor 본인의 원본 profile image, 기존 `default-profile.png` fallback은 그대로 유지한다. 테스트용 host/URL은 실제 운영 자산 접근 검증을 대신하지 않는다.
### Phase 1: 홈과 후원 탭 응답에서 비밀 후원 마스킹
**Phase 결과:** 두 API가 비밀 후원을 count/list에 포함하고 권한별 원본 또는 익명 표시를 반환한다.
**선행조건:** PRD `SDA-001`~`SDA-007` 및 §8 계약 확인. 파일명과 URL 구성 계약이 확정되어 있어 이미지 제공 대기 없이 RED와 GREEN을 진행할 수 있다. 운영 CDN 접근 가능 여부는 최종 Gate에서 검증한다.
**Phase 완료 조건:** `P1-T1`, `P1-T2` 및 `P1-GATE`의 증거 충족. 운영 CDN에서 이미지에 접근할 수 없으면 Gate는 blocked 상태다.
### 구현 항목
- [x] **Task 1.1: 홈 후원 목록 조회와 익명화**
**Goal 실행 `P1-T1`:** 홈 API는 현재 KST 월 비밀 후원을 포함하고, 그 비밀 행만 크리에이터·해당 후원자는 원본, 다른 조회자는 익명 표시를 받으며 공개 행은 기존 원본 표시를 유지한다.
- **시작 조건:** 없음. PRD `SDA-001`, `SDA-003`~`SDA-007`과 §8.1~§8.3 확인. 이미지 URL 계약은 확정되어 있다.
- **완료 증거:** 홈 DTO `isSecret`/응답 계약 및 서비스 마스킹 테스트, repository query 행 검증, 실제 HTTP E2E와 정확한 이미지 URL 및 원본/default-profile 회귀 검증 기록.
- **범위 밖:** 후원 탭 변경, 랭킹 변경, 실제 이미지 자산 생성.
**Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHome.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHomeQueryPolicyTest.kt` (관련 동작이 있으면 수정)
**Interfaces:**
- Consumes: 기존 `CreatorChannelHomeQueryPort.findChannelDonations(creatorId, viewerId, now, limit, lang)`, `SodaMessageSource.getMessage(key, lang)` 및 `cloud.aws.cloud-front.host`.
- Produces: `CreatorChannelDonationRecord.isSecret: Boolean`, home domain donation의 `isSecret: Boolean`, response JSON `channelDonations[].isSecret`.
- Port method signature를 유지한다. record의 새 `isSecret`을 QueryDSL projection과 모든 constructor fixture에 추가한다. Service mapper signature는 `CreatorChannelDonationRecord.toDomain(creatorId: Long, viewerId: Long, lang: Lang): CreatorChannelDonation`으로 둔다.
- [x] **RED:** E2E에서 `isSecret` 누락 assertion 실패를 production 변경 전에 확인하고 viewer role 및 공개/비밀 행 cases를 추가했다.
- [x] **RED 확인:** JSON path `$.data.channelDonations[0].isSecret` 누락 assertion 실패 확인.
- [x] **RED 추가 검증:** role, 현지화, 원본/default/익명 이미지 URL 및 Boolean 응답 검증 cases 작성.
- [x] **GREEN:** repository filter 제거, persisted `isSecret` 전달 및 서비스 마스킹을 구현했다. 공개/본인 원본과 기존 default-profile 동작을 보존했다.
- [x] **GREEN 확인:** 홈 package 89 tests 통과. 이후 홈 E2E 18 + service 23 및 `ktlintCheck` 통과.
- [x] **REFACTOR:** KST 월 경계 회귀를 2026년 12월 고정 검증으로 보강했고, 테스트 lint 위반을 수정했다. 역할/언어/월 경계/최신 8건 및 민감 필드 경계를 검증했다.
**Task 1.1 검증 명령:**
```bash
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest'
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest'
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.application.CreatorChannelHomeFacadeTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest'
```
**Expected:** 각 명령 exit code 0. response의 **마스킹 대상 비밀 donation item만** 원본 nickname/message/profile path가 없어야 한다. 공개/본인 후원과 ranking identity는 response에 기존대로 남는다. 구현 시 실행할 명령이며 현재 검증 결과가 아니다.
**Task 1.1 Progress:** 완료. 세부 RED/GREEN 및 2026년 12월 월 경계 수정 증거는 Progress의 `P1-T1 구현 및 검토`와 후속 수정 기록을 참조한다.
- [x] **Task 1.2: 후원 탭 목록과 count 익명화**
**Goal 실행 `P1-T2`:** 후원 탭은 현재 월 비밀 후원을 목록·count에 포함하고, 비밀 row만 권한별로 마스킹하며 공개 row의 기존 원본 표시를 유지한다.
- **시작 조건:** `P1-T1` 완료. 별도 domain mapper를 사용하되 `P1-T1`에서 등록한 i18n key를 재사용한다.
- **완료 증거:** donation DTO의 `isSecret`, query count/list, service masking, E2E의 viewer 역할별 응답과 pagination 회귀 기록.
- **범위 밖:** 기존 랭킹 쿼리, 랭킹 결과 익명화 또는 랭킹 합계 변경.
**Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationQueryPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationTab.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/dto/CreatorChannelDonationTabResponse.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/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/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationControllerTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacadeTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationEndToEndTest.kt`
**Interfaces:**
- Consumes: 기존 `CreatorChannelDonationQueryPort.countChannelDonations/findChannelDonations`, `SodaMessageSource.getMessage(key, lang)`, 그리고 `P1-T1`이 추가하는 `explorer.channel_donation.anonymous.nickname`, `explorer.channel_donation.anonymous.message`.
- Produces: `CreatorChannelDonationRecord.isSecret: Boolean`, donation domain item의 `isSecret: Boolean`, `donations[].isSecret` JSON 필드.
- Port method signature를 유지한다. record constructor projection와 모든 test fixture를 갱신한다. mapper signature는 `CreatorChannelDonationRecord.toDomain(creatorId: Long, viewerId: Long, lang: Lang): CreatorChannelDonation`이다.
- [x] **RED:** E2E의 `isSecret` JSON assertion을 추가하고 production 변경 전 field 누락 실패를 확인했다. USER/별도 CREATOR role, 공개·본인·타인 비밀 및 out-of-range page cases를 추가했다.
- [x] **RED 확인:** `$.data.donations[0].isSecret` 누락 assertion 실패 확인.
- [x] **RED 추가 검증:** donor/creator 원본, 기본·익명 이미지, 타 역할 마스킹을 위한 검증 cases 작성.
- [x] **GREEN:** secret filter만 제거하고 count/list, paging 및 KST 계약을 유지했다. persisted `isSecret`과 권한별 마스킹을 구현했으며 ranking은 변경하지 않았다.
- [x] **GREEN 확인:** donation package 63 tests 및 `ktlintCheck` 통과.
- [x] **REFACTOR:** mixed 25행 pagination, 전체 count, 월 경계, 역할/언어/메시지 및 기존 ranking 동작을 검증했다.
**Task 1.2 검증 명령:**
```bash
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryServiceTest'
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultCreatorChannelDonationQueryRepositoryTest'
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationControllerTest' --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.CreatorChannelDonationEndToEndTest'
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicyTest'
```
**Expected:** 구현 시 각 명령 exit code 0. 타인 비밀 항목만 익명화되고 공개/본인 항목 및 랭킹은 유지된다. 100/200/300 fixture의 count는 3, 최신순은 300/200/100이다. 같은 3건을 조회할 때 모든 viewer의 count는 같고, 범위 밖 page는 빈 목록과 count 3을 반환한다. can/createdAtUtc는 모든 역할에서 저장값과 정확히 일치한다.
**Task 1.2 Progress:** 완료. 상세 결과는 Progress의 `P1-T2 구현 및 검토`와 통합 Gate 기록을 참조한다.
### Phase 1 Gate
**Goal 실행 `P1-GATE`:** 두 조회 endpoint의 계약, 권한 마스킹 및 직접 영향 회귀를 범위 한정으로 검증한다. 전체 프로젝트 suite는 timeout으로 미완료이며 성공으로 간주하지 않는다.
- **시작 조건:** `P1-T1`, `P1-T2` 구현 완료. 파일명과 URL 계약은 확정되어 있으며 실제 운영 CDN 이미지 접근 가능 여부를 검증한다.
- **완료 증거:** focused 패키지 테스트, `ktlintCheck`, `bootJar`, SpringBootTest MockMvc 경로 및 설정된 CDN의 실제 이미지 응답 결과를 Progress에 기록한다. 전체 suite는 별도 제한 사항이다.
- **범위 밖:** 랭킹 수정, 테스트 삭제·완화, 전체 테스트를 무조건 실행하는 것.
- **TDD 예외 사유:** Gate는 제품 로직을 바꾸지 않고 기존 자동 테스트와 제공된 자산 URL을 검증한다.
- **대체 검증 방법:** focused/package test, MockMvc 응답 field와 설정된 이미지 URL 접근 결과를 대조해 기록한다. MockMvc는 live socket/server 검증이 아니다. 코드 수정이 필요하면 owning Task로 되돌린다.
```bash
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest'
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.*' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.*'
./gradlew ktlintCheck
./gradlew bootJar
```
위 명령은 구현 후 실행할 미래 Gate 검증이며 `bootJar`는 전체 test suite를 암묵 실행하지 않는다. 전체 회귀는 광범위 공통 코드/인증/보안 변경, 여러 domain 변경, release/final 전체 상태 증거 요구, targeted tests만으로 영향 범위를 판단할 수 없는 실패 또는 사용자 명시 요청 때만 별도로 실행한다. 기존 message group에 두 i18n key를 추가한 사실만으로 전체 회귀를 요구하지 않는다. 생략 시 근거와 focused/package 회귀 결과를 기록한다.
```bash
./gradlew test
```
**Gate Expected:** E2E와 해당 패키지 테스트·lint·`bootJar`의 실제 성공 결과를 확인한다. 정확한 URL `"$cloudFrontHost/profile/anonymous-profile.png"`가 마스킹 대상 donation item의 `profileImageUrl`이 되고 CDN에서 실제 접근 가능하면 범위 한정 Gate를 완료한다. 전체 suite 결과는 별도로 기록한다.
**수동 검증:**
- [x] SpringBootTest/H2/MockMvc 요청으로 creator, donor, USER, 별도 CREATOR role을 검증했다. 별도 live server HTTP 요청은 실행하지 않았다.
- [x] 타인 응답에서 마스킹 대상 secret item의 원문 부재, public/own donation 보존, 목록 DTO의 신규 ID 부재를 검증했다.
- [x] 설정된 CDN URL의 접근 결과는 HTTP 200, `image/png`, 19,331 bytes다. 업로드 작업은 하지 않았다.
- [x] 랭킹의 기존 식별 정보 및 secret contribution 집계와 전역 익명성이 아니라는 제한을 유지했다.
## 실행 순서와 의존성
| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 |
|---:|---|---|---|---|
| 1 | `P1-T1` | 없음 | 아니요 | RED/GREEN 테스트의 실패 위치 판정 |
| 2 | `P1-T2` | `P1-T1` 완료 | 아니요 | donation 변경과 직접 테스트에서 실패 위치 판정 |
| 3 | `P1-GATE` | `P1-T1`, `P1-T2` 완료 | 아니요 | 운영 CDN 접근 실패 시 blocker 유지 |
```text
P1-T1 → P1-T2 → P1-GATE 범위 한정 완료
```
## 변경 금지 항목
- Query의 secret filtering을 제거하는 시점에 service masking과 E2E 증거를 함께 완성한다. raw 비밀 필드를 공개하는 중간 배포를 하지 않는다.
- 월 경계, 홈 limit 8, donation default/page size, 최신순 tie-breaker, 인증·block·creator validation을 바꾸지 않는다.
- 기존 랭킹 query/DTO를 수정하지 않는다.
- 익명 이미지 URL은 `"$cloudFrontHost/profile/anonymous-profile.png"`로 확정되었고 설정된 CDN에서 접근 확인했다. 업로드는 수행하지 않았다. 공개/본인 profile 및 기존 default-profile fallback은 변경하지 않는다.
- 신규 memberId/donationId를 노출하거나 테스트를 삭제·skip·완화하지 않는다.
- [`docs/20260622_크리에이터_채널_후원_탭_API/prd.md`](../20260622_크리에이터_채널_후원_탭_API/prd.md)는 편집하지 않는다.
## 의사결정 및 중단 규칙
- `P1-T1`과 `P1-T2`는 확정된 파일명·URL 계약으로 구현 완료했다. 설정된 CDN 접근 검증을 마쳤다. 전체 suite timeout은 범위 한정 Gate의 성공과 구분한다.
- EN/JA는 사용자 승인 문구가 아닌 작성자 기술 초안이다. implementation review에서 승인/수정 확인을 기록하며 사용자 확정 문구로 간주하지 않는다.
- 회귀가 발견되면 해당 Task의 새 회귀 수정 체크박스와 증거를 계획에 추가한 뒤 수정한다. 성공을 추정하지 않는다.
### Phase 2: 응답 isSecret을 익명 UI 표시 여부로 변경
**결과:** 두 탭에서 익명 표시가 필요한 타인의 비밀 후원만 `isSecret=true`를 반환한다. Phase 1 완료 기록은 당시 저장값 전달 계약의 이력으로 보존하며 현재 의미는 이 Phase가 우선한다.
- [x] **Task 2.1: 조회자별 UI flag 전달**
**Goal `P2-T1`:** 기존 마스킹 판정과 응답 flag를 일치시키되 DB 비밀 설정과 나머지 응답·권한 정책을 유지한다.
- 시작 조건: PRD `DEC-008` 승인. 현재 `test` 브랜치에서 진행, worktree·commit·push 없음.
- 완료 증거: 기존 HTTP 역할/언어 매트릭스 RED→GREEN, 두 기능 회귀 성공, DB 비밀값 불변과 owner 전용 donationId/숨김 회귀 통과.
- 범위 밖: DB/record/쿼리 수정, 추가 응답 필드, 숨김·랭킹·인증·시각 정책 변경.
**Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.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/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationEndToEndTest.kt`
**Interface:** 로컬 mapper에서 응답 domain 생성 인자 `isSecret = isMasked`를 사용한다. 입력 record의 실제 비밀값·기존 mapper signature 및 DTO signature는 유지한다.
- [x] **RED:** 기존 두 HTTP E2E 및 service 테스트에서 내 채널/본인 비밀 후원의 flag 기대값을 false로, 타인 비밀은 true로 변경했다. 공개·타인 CREATOR·언어·내용·이미지·금액·시각·donationId assertion은 유지했다.
- [x] **RED 확인:** 두 E2E 메서드 실행은 컴파일 성공 후 `:test` 단계에서 600000ms timeout으로 assertion 결과를 얻지 못했다. 두 service의 역할별 메서드만 실행해 22개 중 8개에서 expected false / actual true 실패를 확인했다. HTTP 검증은 환경 지연 원인을 조사한 뒤 별도로 완료한다.
- [x] **GREEN:** 두 서비스의 반환 `isSecret` 값만 `isMasked`로 바꿨다. 테스트는 fixture/id/can으로 원본 공개·비밀 row를 선택하며 소유자의 응답 flag로 원본 비밀 여부를 추정하지 않는다.
- [x] **GREEN 확인:** 두 service 46개, 관련 패키지 196개 및 최종 HTTP E2E 55개가 통과했다. 조회 후 별도 트랜잭션 clear/재조회로 DB 비밀값 보존을 확인했다.
- [x] **REFACTOR:** 추가 추상화 없이 기능별 회귀·lint 성공과 독립 검토 승인을 기록했다. repository record의 실제 비밀값 assertion은 변경하지 않았다.
**Goal `P2-GATE`:** 두 기능의 새 flag 계약 및 직접 영향 회귀를 판정한다.
- 시작 조건: `P2-T1` 구현 및 검증 완료.
- 완료 증거: 아래 명령 성공과 독립 검토. 전체 테스트는 사용자가 `./gradlew test`로 실행한다.
- TDD 예외 사유: 검증 전용 Goal이며 제품 코드를 추가하지 않는다.
- 대체 검증: 실제 SpringBootTest/H2/MockMvc 요청 결과와 DB 재조회 assertion, 코드 리뷰 대조.
```bash
./gradlew --no-daemon -Pkotlin.compiler.execution.strategy=in-process test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.*' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.*' ktlintCheck
```
실행 순서: `P2-T1` → `P2-GATE`. 이번 변경에서는 전체 테스트를 자동 실행하지 않는다.
- [x] `P2-GATE`: 관련 기능 회귀, 실제 HTTP/MockMvc 응답, DB 불변, ktlint 및 독립 검토 통과.
## Progress
### 응답 isSecret 의미 변경 커밋 전 재검증: 2026-11-05
- 사용자 `/commit-with-validation` 요청에 따라 기존 구현 변경을 커밋한다. 구현 단계의 커밋 미실행 기록은 당시 이력으로 보존한다.
- 실행 명령: `./gradlew --no-daemon -Pkotlin.compiler.execution.strategy=in-process test --tests '*CreatorChannelHomeQueryServiceTest' --tests '*CreatorChannelDonationQueryServiceTest' --tests '*CreatorChannelHomeEndToEndTest' --tests '*CreatorChannelDonationEndToEndTest' ktlintCheck --console=plain`.
- 결과: exit 0, `BUILD SUCCESSFUL`, 2분 27초. 네 클래스 101개 테스트, 실패·오류·skip 0. `ktlintCheck`는 `UP-TO-DATE`로 통과했다.
- `git diff --check` 통과. 전체 테스트는 실행하지 않았으며 사용자 실행 명령은 `./gradlew test`다.
### P2-GATE 완료 증거
- 관련 service 두 클래스 46개 및 `ktlintCheck` 성공. 이어 네 home/donation 패키지를 위 명령으로 검증했다 (`--tests` 패턴 앞에 `*` 사용, `--info --console=plain` 추가). 15개 클래스·196개 테스트, 실패·오류·skip 0, exit 0, 1분 32초. 기존 숨김·owner donationId·정산·랭킹 회귀 포함.
- 독립 정적 리뷰 APPROVE: production 변경은 두 반환 할당뿐이며, 저장값과 익명 내용·기존 권한/필드 구조 보존을 확인했다.
- 부모 최종 실행: `./gradlew --no-daemon -Pkotlin.compiler.execution.strategy=in-process test --tests '*CreatorChannelHomeEndToEndTest' --tests '*CreatorChannelDonationEndToEndTest' ktlintCheck bootJar --console=plain`. exit 0, 1분 45초. home 18 + donation 37 = 55개, 실패·오류·skip 0. `bootJar` 성공, lint 직전 성공 결과 재사용.
- Surface: RANDOM_PORT/TestRestTemplate/실제 JWT 요청과 MockMvc 역할·언어 매트릭스를 구분해 검증했다. 동일 비밀행의 owner/donor GET은 원문과 false, outsider GET은 익명과 true다. 기존 owner-only donationId, 숨김 후 최신 목록 보충 및 DB 원본 불변 assertion 통과.
- 초기 HTTP timeout은 재현되지 않았다. 진단 실행의 donation/home Spring 시작은 각각 약 25.7초/11.7초였으며, 소유 Test Executor 스레드 덤프는 repository 초기화 중 RUNNABLE 상태였고 이후 정상 종료했다. 과거 timeout 원인은 미확정이며 설정을 변경하지 않았다.
- 범위 밖 관찰: H2의 background scheduler에서 `FOR UPDATE SKIP LOCKED` 문법 오류 로그가 있었으나 관련 테스트 실패는 없었다. 원인을 이번 변경이나 timeout으로 단정하거나 수정하지 않았다.
- Kotlin LSP 미설치/도구 부재로 LSP 통과 주장은 하지 않는다. 관련 Kotlin 컴파일과 lint로 검증했다. 전체 테스트·운영 데이터/CDN 재검증·커밋·푸시는 수행하지 않았다. 전체 테스트 사용자 명령: `./gradlew test`.
### P2-T1 RED 및 최소 변경
- HTTP RED 명령: `./gradlew --no-daemon -Pkotlin.compiler.execution.strategy=in-process test --tests '*CreatorChannelHomeEndToEndTest.shouldMaskOnlyOtherMembersSecretDonationsThroughHttp*' --tests '*CreatorChannelDonationEndToEndTest.shouldMaskOnlyOtherMembersSecretDonationsThroughHttp*'`. 컴파일 성공 후 `:test`에서 10분 timeout; RED assertion 증거로 간주하지 않는다.
- 대체 RED 명령: `./gradlew --no-daemon -Pkotlin.compiler.execution.strategy=in-process test --tests '*CreatorChannelHomeQueryServiceTest.shouldMaskSecretDonationOnlyForOtherViewers*' --tests '*CreatorChannelDonationQueryServiceTest.shouldMaskSecretDonationOnlyForOtherViewers*'`. 55초, 22개 중 8개 expected false / actual true 실패. 본인 및 채널 주인의 저장된 비밀 후원에서 기대와 다름을 확인했다.
- 그 뒤 production은 두 서비스의 `isSecret = isSecret`을 `isSecret = isMasked`로 바꾼 2줄만 수정했다. repository/record/DB 및 기존 donationId 판정은 변경하지 않았다.
- 전체 테스트는 실행하지 않는다. 기존 기능별 HTTP/서비스 테스트와 직접 영향 package 회귀만 실행하며, 전체 검증은 사용자 명령 `./gradlew test`로 분리한다.
### 커밋 전 재검증: 2026-11-04
- 사용자 `/commit-with-validation` 요청으로 기존 변경의 커밋을 진행한다. 이전 구현 단계의 커밋 미실행 기록은 당시 상태로 보존한다.
- 실행: `./gradlew --no-daemon -Pkotlin.compiler.execution.strategy=in-process test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.*' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.*' ktlintCheck`
- 결과: exit 0, `BUILD SUCCESSFUL` 1분 56초. XML 13개에서 home 89개와 donation 63개, 총 152개 테스트의 실패·오류·skip 0을 확인했다. lint는 `UP-TO-DATE`이며 `git diff --check`도 통과했다.
- 이번 검증에서 전체 테스트, `bootJar`, live server 및 CDN 접근은 재실행하지 않았다. 기존 전체 suite timeout 이력은 유지하며, 최신 실행 지침에 따라 전체 테스트는 사용자가 `./gradlew test`로 실행한다.
### 현재 완료 상태: 2026-11-04
- 상태: `P1-T1`, `P1-T2` 구현 및 독립 리뷰 승인 완료. `P1-GATE`는 아래 범위 한정 검증 완료. 전체 프로젝트 suite는 timeout으로 결과 미확정이며 전체 suite 통과 또는 전체 release 검증 완료로 표시하지 않는다.
- 통합 검증: `./gradlew --no-daemon -Pkotlin.compiler.execution.strategy=in-process test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.*' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.*' ktlintCheck bootJar` exit 0, `BUILD SUCCESSFUL` 49s. 총 152 tests (home 89, donation 63), failures/errors/skipped 0. 출력 artifact: `build/libs/sodalive-0.0.1-SNAPSHOT.jar`.
- 이미지 접근: `CLOUD_FRONT_HOST/profile/anonymous-profile.png` 요청은 host를 출력하거나 기록하지 않고 HTTP 200, `image/png`, 19,331 bytes를 반환했다. 자산 업로드는 하지 않았다.
- Surface 검증은 SpringBootTest/H2/MockMvc의 실제 endpoint 요청 경로다. 별도 live server/curl 요청은 하지 않았다.
- 전체 프로젝트 `./gradlew --no-daemon -Pkotlin.compiler.execution.strategy=in-process test`는 `:test`에서 600000ms tool timeout에 도달했다. 최종 결과가 없고 실패 assertion도 출력되지 않았으므로 성공/실패 어느 쪽으로도 판정하지 않는다. 이후 Gradle/Test/Redis 프로세스가 남지 않았음을 확인했다.
- 독립 reviewer는 P1-T2와 전체 feature를 승인했으며 blocker가 없었다. 홈의 2026년 12월 월 경계 결함 수정은 T1 Progress 기록에 보존되어 있다.
- Kotlin LSP 진단은 도구 부재로 수행할 수 없었다. home은 11개 변경 파일, donation은 executor에서 진단 도구를 사용할 수 없었다. 컴파일·lint 성공은 LSP 진단 성공으로 간주하지 않는다.
- EN/JA 번역은 기존 문서 결정대로 기술 초안이며 제품 승인 문구로 간주하지 않는다. 커밋, worktree, push, 배포, live application server 실행은 하지 않았다.
### 이전 실행 기록
### 문서 작성: 2026-11-04
- 상태: 계획 작성 완료, 구현 미착수
- 무엇을: 요구사항과 구현 경로를 확인하고 PRD 및 goal 실행 계획을 작성했다.
- 왜: 구현 전 요구사항과 테스트 가능한 완료 기준을 고정하기 위해서다.
- 어떻게:
- 샘플 `docs/sample/sample-prd.md`, `docs/sample/sample-plan-task.md` 및 `docs/agent-guides/문서유지보수.md`/`작업절차.md`를 읽었다. 과거 기준 문서는 [`docs/20260622_크리에이터_채널_후원_탭_API/prd.md`](../20260622_크리에이터_채널_후원_탭_API/prd.md)다.
- Kotlin source 및 두 기존 `SpringBootTest` MockMvc E2E 파일을 읽고 현재 persistence filter, service mapper, DTO 필드, test fixture 구조를 확인했다.
- 문서 전용 작업이므로 Gradle, 테스트, 빌드, 서버 요청은 실행하지 않았다.
- 남은 항목: 실제 코드 변경 및 모든 구현 체크박스, 외부 의존 `EXT-001` 이미지 제공/검증.
- 다음 행동: `P1-T1`의 첫 RED 테스트를 작성한다. 구현 착수 시 실제 자산 제공 여부를 다시 확인한다.
### 문서 정합성 검증: 2026-11-04
- 두 문서를 직접 읽고 인터뷰 결정 및 현재 source/test 구조와 대조했다. 읽기 전용 독립 검토 결과는 APPROVE이며 차단 지적은 없었다.
- Node 정적 검사로 문서의 Kotlin 경로 23개와 로컬 링크 8개의 존재, trailing whitespace 없음, 끝 개행, 구현 체크박스 미완료 상태를 확인했다.
- 두 Markdown 파일에 `lsp_diagnostics`를 시도했으나 `.md` 서버 미설정으로 진단할 수 없었다. 문서 간 대조와 정적 검사를 대체 근거로 사용했다.
- 문서 전용 변경이므로 프로젝트 문서 유지보수 규칙에 따라 Gradle 테스트·빌드·API 실행 검증은 생략했다. 위 미래 구현 검증 명령의 성공을 의미하지 않는다.
### 이미지 계약 정정: 2026-11-04
- 기존 Progress 및 `ISSUE-001` 기록은 작성 당시의 filename/path 미제공 상태를 나타내는 역사 기록이다. 이후 사용자 입력으로 filename/path 계약이 제공되어 해당 blocker 상태는 현재 유효하지 않다.
- 파일명은 `anonymous-profile.png`이며 URL은 기존 `defaultProfileImageUrl()`과 동일한 직접 host/path 조합인 `"$cloudFrontHost/profile/anonymous-profile.png"`로 확정했다. 테스트 host `https://cdn.test`의 기대 URL은 `https://cdn.test/profile/anonymous-profile.png`다.
- 이 계약은 마스킹 대상 비밀 donation item에만 적용한다. 공개 및 본인 원본 profile, 기존 default-profile fallback은 그대로 둔다. 운영 파일 업로드와 실제 CDN 접근 가능성은 검증하지 않았으며 `P1-GATE`에 남는다.
- 실행 체크박스는 모두 미완료이며 테스트, 빌드, API 요청, CDN 접근 검증은 수행하지 않았다.
### P1-T1 구현 및 검토: 2026-11-04
- 완료: 홈 repository의 비밀행 제외 제거, persisted `isSecret` 전달, ID 기반 마스킹, KO/EN/JA 문구와 익명 프로필 URL을 구현했다.
- RED: 기존 E2E에서 `No value at JSON path "$.data.channelDonations[0].isSecret"` assertion 실패를 먼저 확인했다. 확장 테스트에서도 기존 필터의 비밀행 누락을 확인했다.
- GREEN: `./gradlew --no-daemon -Pkotlin.compiler.execution.strategy=in-process test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.*'` 성공. 89개, 실패·오류·skip 0 (E2E 18, repository 38, service 23, policy 5, controller 3, facade 2).
- 독립 확인: 부모 세션에서 홈 E2E를 같은 옵션으로 재실행하여 성공했다. 변경 파일 11개를 읽고 검토했다.
- 리뷰 수정: 테스트의 UTC 월초 `plusMonths` 계산이 12월에 잘못되는 문제를 고정 assertion으로 재현하고 KST 기준 월 이동으로 수정했다. 추가된 테스트 2파일의 ktlint 포맷 위반도 수정했다.
- 수정 후 `test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest' ktlintCheck` (위 공통 Gradle 옵션) 성공. E2E 18 + service 23, 실패·skip 0. 새 읽기 전용 검토에서 이전 P2 지적 ADDRESSED 및 새 결함 없음 확인.
- Kotlin LSP 미설치로 11개 파일 진단 불가. Gradle Kotlin 컴파일 및 ktlint 결과를 기록하며 LSP 통과로 간주하지 않는다.
- 실제 API surface는 SpringBootTest/H2/MockMvc 전체 요청 경로로 확인했다. 운영 CDN 접근은 아직 미검증이며 Gate에서 별도로 판정한다.
### P1-GATE 이미지 사전 검증: 2026-11-04
- 환경의 `CLOUD_FRONT_HOST`가 설정되어 있음을 확인하고, host 값을 출력하지 않은 채 `curl --silent --show-error --max-time 20 --output /dev/null --write-out 'HTTP_STATUS=%{http_code}\nCONTENT_TYPE=%{content_type}\nSIZE_DOWNLOAD=%{size_download}\n' "$CLOUD_FRONT_HOST/profile/anonymous-profile.png"`를 실행했다.
- 결과: HTTP 200, Content-Type `image/png`, 다운로드 크기 19,331바이트. 설정된 CDN의 실제 익명 이미지 접근 가능성을 확인했다. 자산 업로드·수정은 하지 않았다.
- 이미지 접근 조건만 충족했으며, `P1-T2`와 통합 회귀·리뷰가 끝나기 전 전체 Gate는 완료하지 않는다.
### P1-T2 구현 및 통합 Gate: 2026-11-04
- T2 RED: production 변경 전 MockMvc E2E 5개 중 1개가 `$.data.donations[0].isSecret` 누락 assertion으로 실패했다. T2 GREEN: donation 패키지 63 tests와 `ktlintCheck` 통과. 역할별 마스킹, count/page, KST 경계, KO/EN/JA 및 legacy ranking 회귀를 검증했다. E2E 21, repository 14, service 17, policy 5, controller 2, facade 2, legacy ranking adapter 2개가 통과했다. 결과 XML과 HTML은 `build/test-results/test/`, `build/reports/tests/test/`에 있다.
- 통합 명령은 위 현재 완료 상태에 기록한 home/donation 4 package 패턴, `ktlintCheck`, `bootJar`를 함께 실행했으며 152/152 tests, lint 및 jar 성공으로 끝났다. jar 경로는 `build/libs/sodalive-0.0.1-SNAPSHOT.jar`다.
- CDN 이미지 확인은 HTTP 200, `image/png`, 19,331 bytes였다. host는 로그에 기록하지 않았다. 자산 업로드는 하지 않았다.
- 독립 reviewer는 P1-T2와 전체 feature를 승인했고 blocker가 없었다.
- 전체 프로젝트 suite 추가 실행은 `:test` 단계에서 600000ms timeout에 도달해 최종 결과를 얻지 못했다. 실패 assertion 출력은 없었지만 suite 통과로 간주하지 않는다. 프로세스 확인에서 남은 Gradle/Test/Redis 프로세스는 없었다.
- LSP 진단은 home 11개 파일 및 donation에서 사용 가능한 도구가 없어 완료되지 않았다. Kotlin 컴파일과 ktlint 결과만 검증 근거다. 실제 live server 요청은 없으며 surface QA는 MockMvc 경로로 한정한다.
- EN/JA는 기존 기술 초안 상태를 유지하며 제품 승인으로 변경하지 않는다. commit/worktree/push/deploy는 없었다.
## Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|---|---|---|---|---|---|
| 2026-11-04 | `DEC-001` | 확정 | 홈 및 후원 탭을 각각 저장소·service·DTO·E2E까지 완료하는 bounded Task로 나눈다. | 서로 다른 domain port와 DTO, 독립 E2E가 존재 | `P1-T1`, `P1-T2` |
| 2026-11-04 | `DEC-002` | 확정 | 기존 local mapper와 CDN 변환을 사용하고 공용 masking abstraction 및 port signature 변경을 만들지 않는다. | 현재 service 구현 방식과 최소 변경 | `P1-T1`, `P1-T2` |
| 2026-11-04 | `DEC-003` | 확정 (과거 기록, `DEC-004`로 정정) | 익명 이미지 자산은 P0 외부 의존이며 경로 검증 전 Gate를 통과시키지 않는다. | 사용자 제공 자산 필요 | `EXT-001`, `P1-GATE` |
| 2026-11-04 | `DEC-004` | 확정 | 사용자가 제공한 파일명은 `anonymous-profile.png`; 기존 default-profile 방식대로 `cloud.aws.cloud-front.host`에 `/profile/anonymous-profile.png`를 직접 붙인다. URL 계약은 확정됐지만 운영 CDN 접근은 미검증이다. | 사용자 입력과 두 query service의 기존 defaultProfileImageUrl 근거 | `P1-T1`, `P1-T2`, `P1-GATE`, `ISSUE-001` |
| 2026-11-04 | `DEC-005` | 확정 | 익명 이미지는 마스킹 대상 비밀 donation item에만 쓰고 공개/본인 원본 profile과 기존 default-profile fallback은 보존한다. | 기존 표시 정책 유지 | `P1-T1`, `P1-T2` |
## 발견된 문제
| ID | 심각도 | 상태 | 발견 내용 | 영향 Goal | 처리 계획 |
|---|---|---|---|---|---|
| `ISSUE-001` | Blocker | 해결 | 파일명·URL 계약 및 설정된 CDN 접근 검증을 완료했다. 별도의 전체 suite timeout은 제품 버그로 분류하지 않고 현재 검증 한계로 기록한다. | `P1-GATE` | 추가 조치 없음. 전체 suite 결과는 미확정이며 통과로 주장하지 않는다. |
@@ -0,0 +1,184 @@
# 크리에이터 채널 비밀 후원 익명 조회 PRD
## 문서 정보
| 항목 | 내용 |
|---|---|
| 문서 상태 | 응답 isSecret UI 표시 의미 변경 및 관련 기능 검증 완료 (`P2-GATE`) |
| 작성일 | 2026-11-04 |
| 최종 수정일 | 2026-11-04 |
| 대상 제품 | 크리에이터 채널 비밀 후원 익명 조회 |
| 작성자·결정권자 | 제품 요청 기준 |
| 관련 API Contract | 이 문서 §8 |
| 관련 구현 계획 | [plan-task.md](plan-task.md) |
| 관련 review | 없음 |
## 1. Overview
크리에이터 채널 홈과 후원 탭에서 비밀 후원 행은 그대로 보이되, 크리에이터와 후원자 본인 외의 조회자에게 후원자의 식별 정보와 메시지를 숨긴다. 응답 `isSecret`은 현재 조회자에게 익명 UI로 표시해야 하는지를 나타낸다. 마스킹 대상 비밀 후원 이미지 URL은 기존 `default-profile`과 같은 방식으로 `cloud.aws.cloud-front.host`에 `/profile/anonymous-profile.png`를 붙여 구성한다. 최초 구현 검증 이력은 보존하고, 후속 의미 변경은 `P2-T1`에서 별도로 검증한다.
## 2. Problem Statement
현재 두 조회 경로는 비밀 후원을 일반 조회자에게 결과에서 제거한다. 그 결과 후원 건수와 목록의 의미가 조회자에 따라 달라지고, 사용자는 비밀 후원이 있었는지 확인할 수 없다. 해결 후에는 건수와 행은 유지하면서 권한에 맞게 표시 필드만 가려야 한다.
## 3. Goals
### 3.1 제품 목표
- 홈의 `channelDonations`와 후원 탭 `donations`에 현재 KST 월의 비밀 후원을 포함한다.
- 비밀 후원에 한해 크리에이터 본인 또는 해당 후원자 본인에게 원래 닉네임, 메시지, 프로필 이미지를 보여 준다. 공개 후원은 기존처럼 모든 인증 조회자에게 원본을 보여 준다.
- 비밀 후원의 나머지 조회자에게 확정된 익명 표시 문구와 `"$cloudFrontHost/profile/anonymous-profile.png"` 이미지를 보여 준다. 공개 후원과 크리에이터·후원자 본인의 프로필 이미지 및 기존 default-profile fallback은 변경하지 않는다.
- 두 응답의 `isSecret`은 현재 조회자에게 익명 후원 UI로 표시해야 하는지를 나타낸다. DB의 실제 비밀 여부는 변경하지 않는다.
### 3.2 UX 목표
- 익명화된 후원도 목록의 건수와 정렬을 유지해 숨겨진 행으로 오해되지 않게 한다.
- 메시지가 null 또는 빈 문자열이어도 비공개 문구를 표시한다.
## 4. Non-Goals
- 후원 작성, 수정, 취소, DB 스키마 또는 DDL 변경.
- 공개 후원의 표시 변경.
- 랭킹의 노출·집계·익명성 변경. 기존 랭킹 식별 정보와 비밀 후원 집계는 유지한다.
- 플랫폼 전체에서 후원자를 알아볼 수 없게 하는 익명성 보장. 이 변경은 두 목록의 특정 필드만 가린다.
- 인증, 차단, 크리에이터 검증, API route, pagination 정책 변경.
- 기존 `20260622` 문서 수정. 본 문서는 목록의 비밀 후원 노출, 건수 및 `isSecret` 표시 범위에 한해 후속 기준이며 그 외 역사적 결정은 대체하지 않는다.
## 5. Target Users and Permissions
| 사용자 | 조회 결과 |
|---|---|
| 채널 크리에이터 (`viewerId == creatorId`) | 공개 및 비밀 후원 모두 원본 필드 |
| 후원자 본인 (`viewerId == donor.memberId`) | 자신의 공개 및 비밀 후원 원본 필드 |
| 그 외 인증 조회자 | 공개 후원은 원본 필드, 비밀 후원은 행을 유지하고 닉네임·메시지·프로필 이미지를 익명 표시 |
- 기존 인증 및 크리에이터 조회 권한을 그대로 적용한다.
- 기존 차단 확인과 크리에이터 역할 검증을 변경하지 않는다.
- 이번 후속 변경은 새 `memberId`나 식별 필드를 추가하지 않으며, 현재 구현의 채널 주인 전용 `donationId` 반환 정책을 유지한다.
## 6. 핵심 사용자 흐름
1. 인증 사용자가 크리에이터 채널 홈 또는 후원 탭을 조회한다.
2. 서버는 기존 권한·차단·크리에이터 검증을 수행하고 현재 KST 월 후원 목록을 가져온다.
3. 비밀 후원은 크리에이터와 해당 후원자만 원본 필드를 받고, 그 외 조회자는 익명화된 값을 받는다. 공개 후원은 기존대로 인증 조회자에게 원본 필드를 제공한다.
4. 클라이언트는 응답 `isSecret` 하나로 익명 후원 UI를 선택한다. 기존 정렬·건수·pagination은 유지한다.
## 7. 기능 요구사항
요구사항과 파일명·URL 구성 계약은 확정됐고 구현됐다. 설정된 CDN에서 익명 이미지 URL의 실제 접근도 확인됐다.
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|---|---|---|---|---|
| `SDA-001` | 확정 | 홈 `channelDonations`에 현재 KST 월의 공개·비밀 후원을 포함한다. | 현재 월 경계 포함, 이전·다음 월 제외, 전체 후보에서 최신 최대 8건, `createdAt`/id 내림차순. 홈 count 필드는 추가하지 않는다. | §8.1~§8.2, `P1-T1` |
| `SDA-002` | 확정 | 후원 탭 `donations`와 `donationCount`에 현재 KST 월의 공개·비밀 후원을 포함한다. | count가 공개/비밀 전체와 일치하고 기존 페이지 경계를 유지 | §8.1~§8.2, `P1-T2` |
| `SDA-003` | 확정 | 비밀 후원은 본인 또는 크리에이터에게 원본 필드를 반환하고, 공개 후원은 기존처럼 모든 인증 조회자에게 원본을 반환한다. | 비밀의 경우 `viewerId == creatorId` 또는 `viewerId == donor.memberId`; 공개 행은 인증 viewer 모두 nickname/message/profile 원본 | §8.3, `P1-T1`, `P1-T2` |
| `SDA-004` | 확정 | 비밀 후원의 나머지 조회자에게 해당 행 필드만 익명화한다. | 닉네임 `익명`, 메시지 `크리에이터에게만 보여요`, 프로필 URL은 `"$cloudFrontHost/profile/anonymous-profile.png"` | §8.3, `P1-T1`, `P1-T2`, `EXT-001` |
| `SDA-005` | 정정 확정 | 응답 `isSecret`은 현재 조회자에게 익명 처리된 항목일 때만 true다. | 공개·본인 후원·내 채널 후원은 false, 다른 채널의 타인 비밀 후원은 true. DB 원본은 보존 | §8.1~§8.2, `P2-T1` |
| `SDA-006` | 확정 | 익명 닉네임과 메시지 문구를 요청 언어에 맞춰 반환한다. | `Lang.KO/EN/JA`와 기존 `SodaMessageSource` 사용, 두 key 모두 언어별 값, KO 문구 정확 일치 | §8.3, `P1-T1`, `P1-T2` |
| `SDA-007` | 완료 | 익명 프로필 이미지 파일명은 `anonymous-profile.png`; URL은 `"$cloudFrontHost/profile/anonymous-profile.png"`로 구성한다. | 설정된 CDN에서 HTTP 200, `image/png`, 19,331 bytes를 확인했다. | `EXT-001`, `P1-GATE` |
## 8. API 계약
### 8.1 공통 동작
- 기존 인증, block 검사, creator 검증과 응답 envelope를 유지한다.
- 현재 KST 월 시작은 inclusive, 다음 KST 월 시작은 exclusive이며 저장·비교 시간 변환 규칙은 기존 구현을 유지한다.
- 최신순은 `createdAt DESC, donation.id DESC`다.
- 응답 `isSecret = 저장된 isSecret && viewerId != creatorId && viewerId != donorId`다. 공개 후원·본인이 한 비밀 후원·내 채널의 비밀 후원은 false, 다른 채널에서 타인이 한 비밀 후원만 true다.
- 응답값은 실제 비밀 설정을 설명하지 않고 익명 UI 표시 여부만 나타낸다. DB와 repository record의 `isSecret`은 저장값 그대로 유지한다. `isMyDonation` 등 추가 필드는 만들지 않는다.
- `can`과 `createdAtUtc`는 실제 값 그대로 반환한다.
- 새 donor `memberId`나 추가 식별 필드를 만들지 않는다. 후속 기능으로 도입된 채널 주인 전용 `donationId` 노출 및 후원 숨김 정책은 이번 변경에서 그대로 유지한다.
### 8.2 Endpoint 계약
| 조회 대상 | 응답 `isSecret` | 표시 정보 |
|---|---|---|
| 공개 후원 | false | 원본 |
| 내가 한 비밀 후원 | false | 원본 |
| 내 채널의 비밀 후원 | false | 원본 |
| 다른 채널에서 타인이 한 비밀 후원 | true | 익명 처리 |
| Method 및 경로 | 응답 필드 | 목록 및 page 규칙 |
|---|---|---|
| `GET /api/v2/creator-channels/{creatorId}/home` | `data.channelDonations[]`에 `nickname`, `profileImageUrl`, `can`, `message`, `createdAtUtc`, `isSecret` | 현재 월 전체 대상 중 최신 최대 8건 |
| `GET /api/v2/creator-channels/{creatorId}/donations` | `data.donations[]`에 같은 후원 필드와 `isSecret`, `data.donationCount` | 기본 page `0`, 기본 size `20`, size `20..50`, hasNext 기존 규칙 |
### 8.3 익명화 및 현지화
마스킹 대상 비밀 후원의 `profileImageUrl`은 `cloud.aws.cloud-front.host`를 사용하는 기존 `defaultProfileImageUrl()`과 동일한 직접 문자열 조합으로 `"$cloudFrontHost/profile/anonymous-profile.png"`를 만든다. 이는 임의 사용자 제공 경로에 `toCdnUrl`을 적용하는 계약이 아니다. 기존 구현 근거는 `CreatorChannelHomeQueryService.kt:258`과 `CreatorChannelDonationQueryService.kt:114`의 `defaultProfileImageUrl(): String = "$cloudFrontHost/profile/default-profile.png"`이다. `https://cdn.test`를 host로 쓰는 테스트 기대 URL은 `https://cdn.test/profile/anonymous-profile.png`다.
이 익명 URL은 **마스킹 대상 비밀 후원 행에만** 사용한다. 공개 후원과 크리에이터·해당 후원자 본인의 원본 프로필 이미지 및 기존 `default-profile.png` fallback은 그대로 유지한다. 설정된 CDN에서 해당 경로의 이미지 접근을 확인했다. 이 검증은 업로드 주체나 별도 운영 배포 상태를 확인한 것은 아니다.
기존 `SodaMessageSource`에서 다음 key를 `Lang.KO`, `Lang.EN`, `Lang.JA`별로 제공한다.
| Key | KO | EN 기술 초안 | JA 기술 초안 |
|---|---|---|---|
| `explorer.channel_donation.anonymous.nickname` | `익명` | `Anonymous` | `匿名` |
| `explorer.channel_donation.anonymous.message` | `크리에이터에게만 보여요` | `Only visible to the creator` | `クリエイターにのみ表示されます` |
EN/JA 문구는 사용자가 명시 승인한 문구가 아니며 작성자의 기술 초안이다. 구현 review에서 제품 문구 확인 대상으로 고정하고 `SodaMessageSource` 외부 번역 서비스를 도입하지 않는다. null 또는 빈 원본 메시지에도 익명 조회자는 위 문구를 받는다.
### 8.4 랭킹 제한
랭킹 query·응답·가시성·합계를 변경하지 않는다. 랭킹의 `userId`, 닉네임, 프로필은 기존 규칙대로 노출될 수 있고 집계에는 비밀 후원 기여분이 포함된다. 따라서 이 기능만으로 전체 서비스 차원의 후원자 익명성이 보장되지 않는다.
## 9. 외부 의존
| ID | 우선순위 | 제공 필요 계약 | 담당 주체 | 구현 영향 | 재개 조건 |
|---|---:|---|---|---|---|
| `EXT-001` | 완료 | 파일명 `anonymous-profile.png`; URL 계약 `"$cloudFrontHost/profile/anonymous-profile.png"` (`cloud.aws.cloud-front.host` 사용) | 사용자/제품 자산 제공자 | 익명 조회자의 이미지 필드. 설정된 CDN에서 접근 확인 | HTTP 200, `image/png`, 19,331 bytes 확인. 업로드 작업은 수행하지 않음 |
테스트 host `https://cdn.test`의 정확한 기대 URL은 `https://cdn.test/profile/anonymous-profile.png`다. 신규 config, fallback, 공통 helper 또는 임의 사용자 경로용 `toCdnUrl`은 추가하지 않는다. 설정된 CDN 접근 검증은 통과했다. 저장소 필터를 없애 raw secret 필드를 반환하는 코드를 먼저 배포해서는 안 된다.
## 10. 보안과 데이터 취급
- 익명화는 응답 DTO 이전 서버 서비스 변환에서 수행한다. 익명 뷰어에게 raw 비밀 필드를 보내지 않는다.
- 저장소가 비밀 행을 조회하도록 바뀌어도 원본 값이 API 출력·로그·오류에 노출되지 않게 서비스 경계를 검증한다.
- 이 변경으로 donor ID를 새로 반환하지 않는다.
- 랭킹의 기존 식별 정보가 목록 마스킹을 우회해 보일 수 있음을 문서화하며 전역 익명성을 약속하지 않는다.
## 11. 성공 기준과 추적
### 11.1 수용 기준
- [x] 홈 `channelDonations`는 전체 현재 월 공개·비밀 후보 중 최신 8건을 유지하며 새 home count 필드를 추가하지 않는다 (`SDA-001`).
- [x] 후원 탭 공개/비밀 모두 건수·페이지·hasNext에 포함된다 (`SDA-002`).
- [x] 비밀 후원은 크리에이터와 해당 후원자 본인만 원본을 보고 다른 사용자는 익명 문구와 이미지 자산을 본다. 공개 후원은 모두 원본을 본다 (`SDA-003`, `SDA-004`).
- [x] 최초 구현에서 저장된 비밀 상태를 Boolean으로 직렬화했다 (과거 기준, `DEC-008`로 의미 변경).
- [x] 현재 조회자에게 익명 처리된 항목만 응답 `isSecret=true`이며 원본 반환 항목은 false다. 조회 후에도 DB의 비밀 설정은 동일하다 (`SDA-005`, `P2-T1`).
- [x] `can`, `createdAtUtc`, 기존 인증·차단·크리에이터 오류 및 랭킹 동작이 유지된다.
- [x] URL 계약과 설정된 CDN 접근을 검증했다. HTTP 200, `image/png`, 19,331 bytes (`SDA-007`).
- [x] HTTP MockMvc E2E로 홈과 후원 탭의 익명/본인/크리에이터 응답을 검증했다. 별도 live application server 요청은 수행하지 않았다.
범위 한정 feature gate는 package tests, `ktlintCheck`, `bootJar`, CDN 접근 검증으로 완료했다. 전체 프로젝트 test suite는 제한 시간 timeout으로 완료되지 않았으며 전체 suite 통과를 뜻하지 않는다.
### 11.2 추적표
| 요구사항 | 구현 계획 | 자동 검증 |
|---|---|---|
| `SDA-001`, `SDA-003`, `SDA-005`, `SDA-006` | `P1-T1` | 홈 repository/service/controller 및 `CreatorChannelHomeEndToEndTest` |
| `SDA-002`~`SDA-006` | `P1-T2` | donation repository/service/controller 및 `CreatorChannelDonationEndToEndTest` |
| `SDA-007` | `P1-T1`, `P1-T2`, `P1-GATE` | 두 Task는 확정 URL 계약과 기존 이미지 회귀를 검증하고, Gate는 운영 CDN 접근 가능 여부를 검증 |
| `SDA-005` 정정 | `P2-T1`, `P2-GATE` | 홈·후원 service/HTTP E2E의 역할별 UI flag, DB 원본 및 기존 donationId/숨김 회귀 |
## 12. 과거 문서와의 관계
[`docs/20260622_크리에이터_채널_후원_탭_API/prd.md`](../20260622_크리에이터_채널_후원_탭_API/prd.md)의 문서는 작성 당시의 역사적 기록으로 남긴다. 본 PRD는 홈 및 후원 탭에서 비밀 후원을 제거하던 기존 visibility 동작, 그에 따른 목록 건수, 응답 `isSecret` 부재에 대해서만 후속 기준을 정한다. 랭킹을 비롯한 본 문서에 명시하지 않은 기존 결정은 바꾸지 않는다.
## 13. Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 |
|---|---|---|---|---|---|
| 2026-11-04 | `DEC-001` | 확정 | 두 목록은 전체 현재 월 비밀 후원을 포함하고 표시 필드만 권한별 마스킹한다. | 요청에서 확정된 제품 동작 | `SDA-001`~`SDA-006`, §8 |
| 2026-11-04 | `DEC-002` | 확정 | `isSecret`은 persisted 비밀 상태를 보여 주며 모든 사용자에게 제공한다. | 요청에서 확정된 API 동작 | `SDA-005` |
| 2026-11-04 | `DEC-003` | 확정 | 랭킹은 변경하지 않고 전역 익명성을 보장하지 않는다. | 요청에서 확정된 범위 | §8.4 |
| 2026-11-04 | `DEC-004` | 외부 의존 (과거 기록, `DEC-006`으로 정정) | 익명 프로필 이미지는 자산 경로를 받기 전까지 운영 구현 및 완료를 차단한다. | 실제 이미지 제공 필요 | `EXT-001`, `P1-GATE` |
| 2026-11-04 | `DEC-005` | 기술 초안 | EN/JA 문구는 작성자 초안으로 구현 review 때 제품 확인한다. | 한국어만 정확 문구 승인됨 | `SDA-006` |
| 2026-11-04 | `DEC-006` | 확정 | 사용자가 제공한 익명 이미지 파일명은 `anonymous-profile.png`; 기존 `defaultProfileImageUrl()`과 동일하게 `cloud.aws.cloud-front.host`를 사용해 `"$cloudFrontHost/profile/anonymous-profile.png"`로 직접 구성한다. | 파일명 및 default-profile 구성 방식 사용자 확정. 운영 CDN 접근성은 별도 미검증 | `SDA-007`, `EXT-001`, §8.3 |
| 2026-11-04 | `DEC-007` | 확정 | 익명 이미지는 마스킹 대상 비밀 후원 행에만 적용하고 공개/본인 원본 이미지와 기존 default-profile fallback은 보존한다. | 기존 표시 정책 유지 | `SDA-003`, `SDA-004`, §8.3 |
| 2026-11-04 | `DEC-008` | 정정 확정 | `DEC-002`의 저장값 전달 계약을 대체한다. 응답 isSecret은 익명 UI 표시 여부이며 공개·본인·내 채널은 false, 타 채널 타인 비밀 후원만 true다. DB 저장값, 원본/익명 내용, 기존 donationId와 숨김 정책은 유지한다. | UI에서 실제 비밀 여부 구분이 필요 없다는 사용자 확정 및 수정 요청 | `SDA-005`, §8.1, `P2-T1` |
## 14. 변경 관리
요구사항 변경은 Decision Log, 이 PRD, 이 문서 안 API 계약, [plan-task.md](plan-task.md) 순서로 반영한다. 구현 시작 전에는 외부 의존 경계를 다시 확인하고, 진행 체크박스 및 검증 결과를 실제 증거에 따라 누적한다.
@@ -0,0 +1,2 @@
ALTER TABLE channel_donation_message
ADD COLUMN is_hidden TINYINT(1) NOT NULL DEFAULT 0 COMMENT '채널 후원 표시 숨김 여부';
@@ -0,0 +1,577 @@
# 채널 후원 메시지 숨김 구현 계획
| 문서 항목 | 내용 |
|---|---|
| 상태 | 서버 구현·기능 범위 검증·최종 리뷰 완료 |
| 작성일 | 2026-11-05 |
| 요구사항 기준 | [prd.md](prd.md) |
| API 기준 | [prd.md §8](prd.md#8-제안-api-경계-author-technical-draft) |
| 현재 Phase | 전체 Phase 완료 |
| 현재 활성 Goal | 없음 |
| 다음 Goal | 없음. 운영 DDL/배포·프론트엔드는 별도 작업 |
## 목표와 실행 경계
**목표:** 소유 CREATOR가 채널 후원 항목을 soft-hide하면 모든 사용자에게 V2 채널 홈과 후원 탭에서 보이지 않고, 원본 후원과 재무 영향은 보존된다.
사용자의 "문서대로 진행" 요청으로 PRD `DEC-006`의 기술안과 아래 계획 실행을 승인했다. 과거 기록의 초안/미착수 표시는 해당 기록 시점의 상태이며 현재 상태는 상단 표를 따른다. 동시에 하나의 미완료 Goal만 실행하며, Phase 중간 결과를 개별 배포하지 않는다. 배포 시 DDL을 선반영하고 숨김 API와 두 조회 필터를 함께 배포한다. 서버 검증과 프론트엔드 연동 완료는 구분하며, 운영 DDL 실행·배포는 별도 승인 대상이다.
## 현재 상태
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|---:|---|---:|---|---|
| 1 | 완료 | `2/2` | 없음 | Gate 및 독립 리뷰 통과 |
| 2 | 완료 | `2/2` | 없음 | Gate 및 독립 리뷰 통과 |
| 3 | 완료 | `1/1` 및 `P3-R1` | 없음 | 최종 리뷰 승인, 운영 적용 별도 |
## 범위
### 포함
- `ChannelDonationMessage.isHidden` soft-hide 필드 및 운영 반영용 DDL 파일 추가 Task.
- 소유 CREATOR 한 명의 단일 donation 숨김 use case와 bodyless DELETE API.
- 홈과 탭 목록의 local hidden filter, 탭 count/pagination 및 홈 최대 8건 refill.
- 두 V2 응답에 owner-only `donationId` 추가 및 회귀 assertion 조정.
- HTTP/repository 통합 테스트로 권한, 데이터 보존, 기존 랭킹/재무 효과 및 legacy API 불변 검증.
### 제외
- 운영 DB DDL 실행, 배포 및 공유 인프라 변경. 코드·테스트·DDL 문서 작성은 사용자 승인으로 포함한다.
- 복원, hard delete, audit metadata/reason/history, bulk hide, 실시간 push.
- legacy/financial/ranking 전역 query 필터, Hibernate 전역 soft-delete annotation.
- frontend 파일, 화면/UX 디자인.
## 기술 초안과 경계
- 현행 엔티티 `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessage.kt`는 `BaseEntity` ID/시간만 상속한다. 권고 변경은 `isHidden: Boolean = false`와 운영 MySQL DDL `is_hidden TINYINT(1) NOT NULL DEFAULT 0 COMMENT '채널 후원 표시 숨김 여부'`다. 구현 전 DDL 위치/운영 schema 적용 절차를 확인한다.
- donation 탭 list/count query의 현재 predicate는 creator 및 KST 월 범위다. `isHidden=false`를 두 query 모두에 적용한다. 홈 query도 local `isHidden=false` 필터를 적용하고 기존 8건 한도를 숨김 제외 후 적용한다.
- 양 query에 공유 도메인 전역 filter를 만들지 않는다. 숨김 predicate는 공개 후원 탭 및 홈 전용이다. 랭킹 port/adapter, 정산, can usage와 `explorer.profile.channelDonation` legacy query에 추가하지 않는다.
- visible 행은 기존 `isSecret` 값과 masking을 보존한다. hidden은 공개/비밀 모두 제외한다.
- owner-only `donationId` nullable/omitted JSON 정책을 검증한다. donor `memberId`는 응답하지 않는다. 기존 E2E에서 전 역할 donation ID 부재를 단정한 부분은 소유자 케이스만 ID 기대값으로 바꾸고 비소유 케이스의 부재 assertion을 유지한다.
- API 초안은 `DELETE /api/v2/creator-channels/{creatorId}/donations/{donationId}`, body 없음, `ApiResponse.ok` data 없음이다. 인증/도메인 거부는 기존 `SodaException`, i18n, 공통 handler를 재사용하며 HTTP 200 `success=false` 관례와 충돌하는 generic REST 403/404는 만들지 않는다.
- `actor.role == CREATOR`, `actor.id == creatorId`, donation ID와 creator ID의 동일 소속을 모두 검증한다. 존재하지 않음 또는 다른 채널의 대상도 변화 없이 거부하고 존재 여부 정보 노출은 기존 domain 오류 패턴으로 제한한다.
- 숨김 재요청은 이미 `isHidden=true`이면 성공한다. 원본 저장값을 덮어쓰지 않고 can/refund/ranking/settlement/usage 경계를 호출하지 않는다.
## 검증 명령 정책
- 미래 구현에서는 각 Task 아래 명시된 실제 테스트 클래스만 `--tests`로 지정한다. 아래 RED/GREEN 명령은 향후 실행 명령이며 현재 실행하지 않는다.
- Phase Gate도 관련 donation/home/legacy HTTP 및 repository 테스트 클래스만 `--tests`로 지정한다. 무필터 테스트를 실행하지 않는다.
- 프로젝트 전체 테스트는 사용자가 직접 `./gradlew test`로 실행한다. `check`, `build`, `clean build`도 자동 실행하지 않는다. 미래 빌드/포맷 확인은 필요한 경우 `./gradlew bootJar`, `./gradlew ktlintCheck`만 사용한다.
- 미래 구현 전용 명령 예시는 실행 사실이나 성공 기록이 아니다. 테스트 결과, SQL 운영 적용, H2 통합 근거를 실행 후 실제로 기록한다.
## Review Focus
- 소유자가 아닌 CREATOR, donor, USER, ADMIN 및 anonymous 요청의 권한/상태 변경 없음: Task 1.2 HTTP 통합 검증.
- 경로의 creator와 실제 donation 소속 불일치 또는 ID 미존재: Task 1.2의 forged ID HTTP 검증.
- hidden row가 count, offset/page 결과에 남거나 페이지를 덜 채우지 않는지: Task 2.1 repository/HTTP 검증.
- home hidden 후보가 최신 8건을 줄이지 않고 visible 최대 8건으로 refill하는지: Task 2.2 home repository/HTTP 검증.
- 기록·랭킹·정산·can 사용 및 legacy 출력 불변: `P3-GATE`. owner-only ID와 donor ID 비노출: Task 2.1/2.2 통합 검증.
### Phase 1: 저장과 소유자 전용 숨김 API
**Phase 결과:** 소유 CREATOR가 한 donation을 기록 보존형으로 숨기며, 권한 실패/중복 요청도 안전하다.
**선행조건:** 별도 명시 구현 요청. PRD 제품 결정을 유지하고 §7~§8 기술 초안의 실제 패턴과 DDL 반영 절차를 재확인한다.
**Phase 완료 조건:** `P1-T1`, `P1-T2`, `P1-GATE` 완료 증거 기록.
#### Task 1.1: 숨김 저장 모델과 persistence port
- [x] **Task 1.1 완료:** 아래 TDD 단계와 완료 증거를 충족한다.
**Goal 실행 `P1-T1`:** donation record에 기본 비숨김 상태를 저장하고 포트가 donation을 채널 범위로 숨길 수 있게 한다.
- **시작 조건:** 구현 요청 및 `ChannelDonationMessage.kt`, `BaseEntity.kt`, schema/DDL 관례 재확인.
- **완료 증거:** entity 매핑, MySQL DDL 파일, repository integration에서 기본 false/true 저장, 동일 채널 한정 및 원본 보존이 확인됨.
- **범위 밖:** API wiring, 조회 filter 및 금융 동작 변경.
**Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessage.kt`
- Create: `docs/20261105_채널후원메시지숨김/channel_donation_message_is_hidden_ddl.sql` (작성 완료, 운영 미적용)
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/HideCreatorChannelDonationPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultHideCreatorChannelDonationRepository.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultHideCreatorChannelDonationRepositoryTest.kt`
**Interfaces:**
- Consumes: existing `ChannelDonationMessage.id`, `creator.id`, mutable JPA entity and project DB mappings.
- Produces: entity `isHidden: Boolean = false`; `HideCreatorChannelDonationPort.hideOwnedDonation(creatorId: Long, donationId: Long): Boolean`, true when matching donation exists and is already/now hidden, false otherwise. Repository update must condition by both IDs and leave other fields untouched.
- [x] **RED:** `shouldHideOnlyDonationOwnedByCreatorAndRetainOriginalValues`에서 기본 false와 숨김 후 can/message/isSecret/member/creator/id 보존을 검증한다.
- [x] **RED 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultHideCreatorChannelDonationRepositoryTest'`를 실행해 새 필드/동작 누락으로 assertion 실패를 확인한다. 컴파일/fixture 오류는 RED 증거가 아니다.
- [x] **GREEN:** boolean field, port, 채널+donation 제한 update 및 DDL을 최소 구현한다.
- [x] **GREEN 확인:** 같은 focused 명령을 실행해 H2 DB 왕복과 다른 channel ID 무변경이 통과하는지 확인한다.
- [x] **REFACTOR:** 이번 Task의 중복만 정리하고 같은 focused test 및 `./gradlew ktlintCheck`를 실행해 결과를 기록한다.
**P1-T1 실제 실행 기록: 2026-11-05 (상위 리뷰 전, Task 최종 완료 미체크)**
- 범위: 이 Task의 entity/port/repository/test/DDL과 이 기록만 변경한다. `P1-T2`, 조회 filter, API 및 금융 경계는 시작하지 않는다.
- 테스트 작성 후 컴파일 가능한 최소 bootstrap으로 `isHidden=false` 매핑, port와 항상 false를 반환하는 repository stub을 추가했다. 숨김 동작 구현 전 assertion RED를 확인했다.
- RED 명령: Java 17(`liberica-17.0.15+10`)에서 `./gradlew --no-daemon -Pkotlin.compiler.execution.strategy=in-process test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultHideCreatorChannelDonationRepositoryTest'`. 최초 120초 실행은 `compileKotlin` 도중 도구 timeout으로 종료되어 RED 근거로 사용하지 않았다. 동일 명령을 600초 제한으로 재실행해 exit 1, 6건 중 assertion 실패 3건, errors/skip 0을 확인했다.
- RED 실패: `shouldHideOnlyDonationOwnedByCreatorAndRetainOriginalValues(false/true)`의 52행과 `shouldSucceedWhenHidingAgainWithoutChangingOriginalValues`의 108행에서 `expected: <true> but was: <false>`. 컴파일 및 H2 fixture 생성은 성공했다.
- MySQL 멱등성 근거: Connector/J의 [`useAffectedRows` 설명](https://github.com/mysql/mysql-connector-j/blob/release/8.x/src/main/resources/com/mysql/cj/LocalizedErrorMessages.properties)은 `CLIENT_FOUND_ROWS`에 따라 matched/affected count가 달라짐을 명시한다. update는 두 ID 및 `isHidden=false`로 제한하고 true만 설정한다. 따라서 최초 변경은 양 설정 모두 1건이고 반복 숨김은 양 설정 모두 0건이며, 같은 두 ID의 존재 확인으로 true를 반환한다. H2에서도 반복 요청이 0건 update 경로를 통과하므로 H2의 무변경 update count를 MySQL 근거로 사용하지 않는다.
- 트랜잭션: repository의 쓰기 메서드에 `@Transactional`을 두어 독립 포트 호출도 update/존재 조회를 한 트랜잭션에서 수행한다. 후속 service의 트랜잭션이 있으면 참여하며 service/API 구현은 이 Task에 추가하지 않는다.
- 임시 경로: `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/channel-hide.aeTJdH/p1-t1.SxYFSF`. 각 shell 실행에 이 경로의 `TMPDIR`, `NODE_COMPILE_CACHE`, `BUN_RUNTIME_TRANSPILER_CACHE_PATH`를 지정한다. 운영 SQL은 실행하지 않는다.
- GREEN 명령: 위 RED와 동일한 focused 명령을 구현 후 실행해 exit 0, 신규 6건 통과(failures/errors/skip 0)를 확인했다. `compileKotlin`, `compileJava`, `compileTestKotlin`도 성공했다. 실제 DB는 캐시를 끈 `@DataJpaTest`의 H2이며 `QueryDslConfig`와 실제 repository bean을 import했다. mock은 사용하지 않았다.
- DB 동작 증거: 신규 기본 false, 공개/비밀 각각 선택한 행만 true, 같은 채널의 다른 행 및 다른 채널 행은 false, wrong creator/missing ID는 false 반환, 반복 숨김 true 반환을 확인했다. 매번 flush/clear/find로 DB에서 다시 읽어 `id/can/additionalMessage/isSecret/member/creator/createdAt`을 고정 기대값과 정확히 비교했다. 선택 항목 테스트에서 3행, 나머지 거부/반복 테스트에서 1행이 유지됐다. 생성 시각은 기존 DDL의 초 정밀도에 맞는 고정값 `2026-11-05T00:00:00`을 사용했고 비교값 절삭이나 허용오차는 사용하지 않았다.
- SQL 관찰: 테스트 XML의 Hibernate 출력에서 hide는 `UPDATE channel_donation_message SET is_hidden=? WHERE id=? AND creator_id=? AND is_hidden=?`만 실행하고, 반복 숨김과 거부는 같은 두 ID로 `SELECT 1`을 수행했다. hide SQL에 원본 필드 update, DELETE 또는 전역 filter는 없었다. fixture 준비의 createdAt update와 hide update를 구분했다.
- 회귀/포맷 명령: `./gradlew --no-daemon -Pkotlin.compiler.execution.strategy=in-process test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultHideCreatorChannelDonationRepositoryTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultCreatorChannelDonationQueryRepositoryTest' ktlintCheck` 실행, exit 0. XML 기준 신규 6건 + 기존 query 14건 = 20건, failures/errors/skip 모두 0. `ktlintMainSourceSetCheck`, `ktlintTestSourceSetCheck`, Kotlin script 검사 및 `ktlintCheck` 성공. 추가 리팩터링은 필요하지 않아 범위를 확장하지 않았다.
- 검증 한계: MySQL 서버에서 DDL 및 repository를 실행하지 않았고 운영 SQL은 미적용이다. MySQL matched/affected 차이는 위 Connector/J 근거와 조건부 update로 처리했다. `lsp_diagnostics` 도구가 없어 컴파일/ktlint로 대체했으며 LSP 성공을 주장하지 않는다. 기존 무관 코드의 `!!`/unchecked-cast/unused-parameter 컴파일 경고와 Gradle 9 비호환 deprecation 안내는 수정하지 않았다.
- 중단 상태: P1-T1 실행 substep만 체크하고 Task 1.1 최종 완료는 상위 리뷰 대기로 남긴다. API/조회 filter는 미구현이므로 실제 화면 숨김 완료로 보고하지 않는다. 전체 테스트·`check`/`build`/`clean build`·`bootJar`·리뷰어·커밋·stage·push는 실행하지 않았다. 전체 테스트는 사용자가 `./gradlew test`로 별도 실행한다.
- 변경 범위/공백: read-only git status/diff에서 P1-T1 지정 파일과 이 계획의 실행 기록만 변경했고, 원래 untracked인 PRD는 수정하지 않았다. tracked diff 및 신규 5개 파일(entity 외 port/repository/test/DDL/계획)의 `git diff --no-index --check /dev/null <file>`를 검사해 모두 exit 0·출력 없음이었다. 프로세스 종료와 임시 루트의 열린 파일 없음, 빈 디렉터리임을 확인했으며 기존 상위 Gradle daemon은 종료하지 않는다.
#### Task 1.2: use case와 API 권한 경계
선행 검증: P1-T1 독립 리뷰에서 명세/품질 PASS. 상위에서 신규 repository 테스트를 재실행해 6건, failures/errors/skip 0, BUILD SUCCESSFUL을 확인했다. 저장 단계만 완료이며 조회 숨김은 아직 미구현이다.
- [x] **Task 1.2 완료:** 아래 TDD 단계와 완료 증거를 충족한다.
**Goal 실행 `P1-T2`:** 소유 CREATOR 전용 bodyless DELETE endpoint가 대상 항목을 멱등 숨김 처리하고 도메인 오류를 기존 envelope로 반환한다.
- **시작 조건:** `P1-T1` 완료. 현재 `CreatorChannelDonationController`, 기존 authentication/member role 및 `SodaExceptionHandler`의 실제 오류 패턴을 재확인한다.
- **완료 증거:** 서비스/HTTP 테스트가 성공, role 및 owner/channel mismatch 거부, hidden duplicate 성공, response data 없음, 금융 효과 불변을 확인함.
- **범위 밖:** 조회 filter, DTO ID 및 frontend 연동.
**Files:**
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/HideCreatorChannelDonationService.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/HideCreatorChannelDonationServiceTest.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationController.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacade.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationControllerTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationEndToEndTest.kt`
- Fixture: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacadeTest.kt` (생성자 주입 인자 변경만 반영)
**Interfaces:**
- Consumes: `HideCreatorChannelDonationPort.hideOwnedDonation(creatorId, donationId)` from `P1-T1`; authenticated `Member`; existing `ApiResponse`/`SodaException` handler.
- Produces: `HideCreatorChannelDonationService.hide(creatorId: Long, donationId: Long, actor: Member): Unit`; `DELETE /api/v2/creator-channels/{creatorId}/donations/{donationId}` bodyless, success `ApiResponse.ok` with no data.
- [x] **RED:** `shouldAllowOnlyOwningCreatorToHideDonationThroughHttp`와 역할별 denial, forged creator/donation ID, not-found, repeated hide 통합 cases를 작성한다. 승인된 역할만 성공하며 실패는 모든 원본/금융 fixture를 유지해야 한다.
- [x] **RED 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest'`를 실행해 endpoint 미구현 또는 정책 assertion 실패를 확인한다.
- [x] **GREEN:** service/facade/controller를 구현하고 기존 error key/handler로 role, owner, 같은 channel 소속을 검증한다. 같은 소유자의 중복 요청은 성공 처리하되 금융 변경은 하지 않는다.
- [x] **GREEN 확인:** 같은 focused 명령으로 소유 성공, donor/other creator/user/admin 거부, forged ID 거부, 중복 성공, HTTP-200 domain error envelope와 금융/원본 불변을 확인한다.
- [x] **REFACTOR:** 이번 Task의 중복만 정리하고 focused controller/E2E 테스트 및 `./gradlew ktlintCheck` 결과를 기록한다.
**P1-T2 실제 실행 기록: 2026-11-05 (상위 리뷰 대기, Task 최종 완료 미체크)**
- 승인 범위: `P1-T2` 및 `P1-GATE`까지만 실행한다. `P1-T1` entity/port/repository/DDL은 보존하며 P2 조회 filter/응답 ID는 시작하지 않는다. 기존 Facade 직접 생성 fixture도 주입 변경에 맞춘다.
- 경계 판단: 인증은 기존 `SecurityConfig`/`TokenProvider`/`requireMember`를 그대로 사용한다. 외부 입력인 두 경로 ID는 service의 CREATOR role/actor ID 확인 후 기존 채널 범위 port에 전달한다. role/owner 거부는 `common.error.access_denied`, 대상 소속 불일치/미존재는 같은 `common.error.invalid_request`로 처리해 존재 여부를 구별하지 않는다. 기존 read-only Facade의 hide 진입점은 명시적인 쓰기 트랜잭션으로 재정의한다.
- 임시 루트: 부모 경로를 `ls -ld`로 확인하고 `mktemp -d`로 `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/channel-hide.aeTJdH/p1-t2.jkumZp`를 만들었다. shell 실행마다 해당 루트의 `TMPDIR`, `NODE_COMPILE_CACHE`, `BUN_RUNTIME_TRANSPILER_CACHE_PATH`를 지정한다. Java는 `17.0.15`다.
- RED 명령: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest'`, 도구 timeout 600000ms. 정상 컴파일 후 exit 1, 30건 중 failures 8, errors/skip 0, 3분 11초. 신규 인증 DELETE 8건은 endpoint 미구현으로 183행에서 `expected: <200 OK> but was: <404 NOT_FOUND>` assertion 실패했다. 기존 21건 및 신규 anonymous 401 검증 1건은 통과했다.
- RED 실제 HTTP: `RANDOM_PORT`의 Tomcat `50634`에서 실제 JWT와 `TestRestTemplate`로 body 없는 DELETE를 전송했다. 예: `/api/v2/creator-channels/90/donations/104` 소유 CREATOR는 `404` 및 `status:404,error:Not Found`, `/api/v2/creator-channels/131/donations/162` anonymous는 `401` 및 `status:401,error:Unauthorized`를 반환했다. JWT는 출력하지 않았다. `@DirtiesContext`의 Tomcat 종료 로그를 확인했다.
- 구현 후 첫 동일 E2E 실행은 exit 1, 30건 중 failures 8, errors/skip 0, 4분 35초였다. 실제 owner 응답은 이미 `200 {"success":true,"message":null,"data":null,"errorProperty":null}`이고 domain 거부도 `200 success=false`였지만 신규 테스트가 data 키 자체의 생략을 잘못 가정했다. `ApiResponse.kt`의 nullable 필드와 기존 `ApiResponse.ok<Unit>()` 사용을 확인해 공통 envelope를 변경하지 않고 신규 assertion을 `data`의 정확한 JSON null 검증으로 정정했다. 이는 payload가 없다는 계약이며 `{}`/Unit 객체는 허용하지 않는다. 기존 테스트는 완화하지 않았다.
- GREEN 및 포맷 1차 명령: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.application.HideCreatorChannelDonationServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationControllerTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacadeTest' ktlintCheck`. XML 기준 E2E 30 + 실제 DB service 9 + controller 4 + facade fixture 회귀 2 = 45건, failures/errors/skip 모두 0. 명령 전체는 신규 service test의 `listOf` 인자 줄바꿈 ktlint 위반 5건으로 exit 1(1분 21초)이며, 해당 신규 코드 포맷만 수정하고 Gate에서 다시 확인한다.
- 최종 GREEN: 아래 P1-GATE 명령에서 E2E 30건, service 9건, controller 4건, facade fixture 2건이 다시 통과했다. GET 동작/응답 ID 부재를 검증하던 기존 테스트 21건은 그대로 유지했다. `HideCreatorChannelDonationServiceTest`는 실제 Spring bean/기존 repository/H2를 사용하고 외부 mock 없이 검증한다. USER/ADMIN은 자기 ID와 경로가 같아도 거부하며, 비소유 USER/ADMIN/CREATOR는 대상 미존재 여부보다 먼저 access_denied를 반환한다.
- 실제 socket HTTP 최종 증거: 기존 E2E의 `RANDOM_PORT` Tomcat `50765`, 실제 `TokenProvider` 발급 JWT, 기존 운영 인증 체인을 사용했다. body 없는 DELETE 총 13요청을 실행했고 JWT는 로그에 출력하지 않았다. 공개 `/api/v2/creator-channels/90/donations/104`, 비밀 `/api/v2/creator-channels/95/donations/109` 및 중복 `/api/v2/creator-channels/1/donations/2` 2회는 `200 {"success":true,"message":null,"data":null,"errorProperty":null}`였다. donor/outsider/other CREATOR/ADMIN 및 경로 소유권 위조는 `200 success=false,message=권한이 없습니다.,data=null`; 다른 채널 `/api/v2/creator-channels/106/donations/145`와 미존재 `/api/v2/creator-channels/106/donations/9223372036854775807`는 동일한 `200 success=false,message=잘못된 요청입니다.,data=null`이었다. 이미 숨긴 항목의 donor 요청도 거부됐다. anonymous `/api/v2/creator-channels/131/donations/162`는 실제 인증 체인에서 `401` 및 `status:401,error:Unauthorized`였다.
- DB 보존 증거: 각 HTTP fixture는 해당 채널 3행과 다른 채널 1행, 실제 `UseCan` 1행(`can=450,rewardCan=50,isRefund=false`) 및 `UseCanCalculate` 1행(`can=500,status=RECEIVED`)을 생성해 commit했다. JDBC로 전체 컬럼을 요청 전후 다시 읽어 donation의 `is_hidden`만 제외한 원본/연결/시각과 재무 두 테이블의 모든 컬럼·행 수가 정확히 동일함을 확인했다. 별도 transaction/clear/find로 선택 ID만 hidden=true이고 나머지는 false인 것도 확인했다. 거부는 전 행 false, 중복은 첫 요청 이후 같은 상태였다. 고정 donation 생성 시각은 `2026-11-05T00:00:00`이며 비교값 절삭/허용오차를 사용하지 않았다.
- 정리 영수증: 각 socket 테스트의 발급 token은 해당 member ID만 `@AfterEach`에서 제거했다. `@DirtiesContext`로 `50765` Tomcat 및 E2E/service Hikari 종료 로그를 확인했다. 이번 실행들의 Test worker PID `73880,75780,76195,76844`를 `ps`로 조회해 잔존 프로세스가 없었고 `lsof +D`는 열린 파일 없이 종료했다. 임시 루트는 0 entries였으며 symlink 해석 후 부모 allowlist, 현재 UID 소유권과 빈 상태를 확인해 `rmdir`로 `p1-t2.jkumZp`만 삭제했다. shared Gradle/Kotlin daemon은 종료하지 않았다.
- 한계/중단: LSP 도구가 없어 실제 Kotlin/Java 컴파일 및 ktlint로 대체했다. MySQL/운영 DDL 적용, 전체 테스트, `check`/`build`/`clean build`/`bootJar`, dependency audit, reviewer/subagent, commit/stage/push는 수행하지 않았다. 전체 테스트는 사용자 실행 명령 `./gradlew test`다. P2 filter/ID는 미구현이므로 두 V2 목록은 아직 hidden 항목을 제외하지 않는다. P3의 실제 legacy/ranking/정산 조회 불변 최종 Gate는 수행하지 않았으며 이번 증거는 원본·재무 record 보존까지다. Task 1.2와 Phase 최종 완료 판정은 상위 리뷰에 남기고 여기서 중단한다.
#### Phase 1 Gate
**Goal 실행 `P1-GATE`:** 숨김 저장 및 owner-only API 권한/불변 조건을 확인한다.
- **시작 조건:** `P1-T1`, `P1-T2` 완료.
- **완료 증거:** 두 focused 테스트 통과, entity/repository 데이터 보존 및 E2E 권한/멱등성 증거 기록.
- **범위 밖:** 전체 suite, 조회 필터 및 legacy query 수정.
```bash
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultHideCreatorChannelDonationRepositoryTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.application.HideCreatorChannelDonationServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationControllerTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest'
./gradlew ktlintCheck
```
**Expected:** 구현 후 각 명령 exit 0, 실패/skip 0. DB 원본 및 can/정산 관련 fixture 불변. 현재 실행하지 않음.
**P1-GATE 실제 실행 기록: 2026-11-05 (실행 통과, 최종 완료는 상위 리뷰 대기)**
- [x] 위 Gate의 4개 클래스와 주입 변경 직접 영향 범위인 `CreatorChannelDonationFacadeTest`를 함께 focused 실행했다.
- [x] 같은 invocation에서 `ktlintCheck`를 실행해 main/test/Kotlin script 검사 성공을 확인했다.
- [x] Phase 1 최종 완료 판정: 독립 리뷰 명세/품질 PASS. 상위에서 실제 HTTP를 포함한 E2E 30건을 재실행해 failures/errors/skip 0, BUILD SUCCESSFUL(26초)을 확인했다. 로그에서 Tomcat·DB 풀 종료도 확인했다.
실제 명령:
```bash
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultHideCreatorChannelDonationRepositoryTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.application.HideCreatorChannelDonationServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationControllerTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacadeTest' ktlintCheck
```
- 결과: exit 0, `BUILD SUCCESSFUL in 1m 9s`. XML 기준 repository 6 + service 9 + controller 4 + E2E 30 = Gate 49건, 추가 facade 2건으로 총 51건이며 failures/errors/skipped는 전부 0이다. 앞선 실행에서 변경된 production Kotlin/Java와 test Kotlin 컴파일이 성공했고 최종 실행의 test Kotlin 컴파일도 성공했다. 기존 무관 컴파일 경고와 Gradle deprecation 안내는 범위 밖으로 유지한다.
- 증거 경로: `build/test-results/test/TEST-kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest.xml`의 HTTP EVIDENCE 13행과 각 클래스 XML의 testsuite 집계를 직접 확인했다. 실제 HTTP/DB/정리 내용은 바로 위 P1-T2 기록에 누적했다. 전체 테스트 통과나 화면 숨김 완료를 뜻하지 않는다.
### Phase 2: V2 조회 필터, count 및 owner-only ID
**Phase 결과:** 홈/탭은 hidden item을 모든 사용자에게 제외하고 count/page/refill 및 ID 공개 범위를 올바르게 유지한다.
**선행조건:** `P1-GATE` 완료. `P1-T2` 성공 이후 API 조회 필드 노출과 기존 JSON assertion을 갱신한다.
**Phase 완료 조건:** `P2-T1`, `P2-T2`, `P2-GATE` 완료 증거 기록.
#### Task 2.1: donation 탭 filter, count, pagination, ID
- [x] **Task 2.1 완료:** 아래 TDD 단계와 완료 증거를 충족한다.
**Goal 실행 `P2-T1`:** donation 탭은 hidden 제외 count/list를 동일 조건으로 제공하고 owner에게만 donationId를 제공한다.
- **시작 조건:** Phase 1 Gate 완료. 현재 query/service/domain/response 및 기존 E2E ID 부재 assertion 확인.
- **완료 증거:** repository와 HTTP 통합에서 역할별 hidden 제외, count/page/hasNext, visible 공개/비밀 masking 유지, owner ID 및 non-owner 부재가 검증됨.
- **범위 밖:** 홈 목록 filter 및 legacy/ranking query 변경.
**Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationQueryPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationTab.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/dto/CreatorChannelDonationTabResponse.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/application/CreatorChannelDonationQueryServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationEndToEndTest.kt`
- Fixture/contract: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicyTest.kt`
- Fixture/contract: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacadeTest.kt`
- Fixture/contract: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationControllerTest.kt`
**Interfaces:**
- Consumes: `ChannelDonationMessage.isHidden`; actor/owner context available to V2 response assembly. Existing `countChannelDonations` and `findChannelDonations` page API.
- Produces: donation tab item response `donationId: Long?` serialized only for owning CREATOR; count/list share hidden predicate before pagination. No donor member ID.
- [x] **RED:** `shouldExcludeHiddenRowsFromDonationTabCountAndPagesForEveryViewer` 및 `shouldExposeDonationIdOnlyToOwningCreator`를 추가한다. mixed 25 rows, hidden within first page, all-hidden empty, and owner/non-owner JSON boundary cases를 포함한다.
- [x] **RED 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultCreatorChannelDonationQueryRepositoryTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest'`를 실행해 hidden filter 및 owner ID 부재/노출 assertion 실패를 확인한다.
- [x] **GREEN:** count와 목록에 동일 local predicate를 적용하고 pagination 전에 제외한다. response mapping에서 소유 CREATOR에만 persisted ID를 제공한다.
- [x] **GREEN 확인:** 같은 focused 명령을 실행해 viewer 전원 hidden 제외, `donationCount`, page/hasNext, 공개/비밀 masking 및 ID JSON을 검증한다.
- [x] **REFACTOR:** 현재 ID 없음만 가정하던 owner assertion은 owner-only ID 기대값으로 수정하고 non-owner 부재 assertion은 보존한다. focused donation query/service/E2E 및 `./gradlew ktlintCheck` 결과 기록.
**P2-T1 실행 증거: 2026-11-05 (최종 Task 판정은 상위 리뷰 대기)**
- RED: 위 두 클래스 명령, 정상 컴파일 후 48건 중 assertion 실패 7건, errors/skip 0, `BUILD FAILED in 36s`. repository는 hidden 혼합 count `22 != 25`, all-hidden `0 != 2`; 실제 socket GET은 count `22 != 28`, owner ID `[3,2,1] != [null,null,null]`, 기존 언어별 owner JSON은 `donationId` 누락으로 실패했다. 최초 fixture의 숨김 플래그 변경이 managed createdAt을 덮어쓰는 문제를 바로잡아 persist 전에 플래그를 설정하고 RED를 재확인했다. 중간 생성자 컴파일 오류는 RED 근거에서 제외했다.
- GREEN: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultCreatorChannelDonationQueryRepositoryTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryServiceTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicyTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.*' ktlintCheck`, exit 0, `BUILD SUCCESSFUL in 1m`. repository 16 + service 20 + policy 5 + E2E 32 + controller 4 + facade 2 = 79건, failures/errors/skip 0; Kotlin/Java/test 컴파일 및 ktlint 통과.
- 검증: 공개·비밀 25행 중 최신 3행 숨김 후 22행을 20+2로 조회, HTTP 두 건 DELETE 후 정확히 20행/hasNext=false, 전부 숨김 후 count=0/empty/false를 owner/donor/outsider/other CREATOR/ADMIN 모두 확인했다. record ID는 실제 저장 행의 ID와 비교하며 서비스는 같은 ID의 USER/ADMIN에도 ID를 주지 않는다. 기존 월 경계·정렬·다국어 masking assertion과 non-owner 필드 부재를 유지했다.
- 기존 RANDOM_PORT/TestRestTemplate/JWT helper를 GET에도 재사용했다. 토큰을 출력하지 않고 기존 token 삭제와 DirtiesContext 종료를 유지했다. 홈/P3/legacy/재무 query와 P1 production은 수정하지 않았다. LSP 도구가 없어 컴파일/ktlint로 검증한다.
#### Task 2.2: 홈 filter, refill, owner-only ID
- [x] **Task 2.2 완료:** 아래 TDD 단계와 완료 증거를 충족한다.
**Goal 실행 `P2-T2`:** 채널 홈은 hidden donation을 제외한 최신 visible 최대 8건을 제공하고 소유 CREATOR에게만 donationId를 제공한다.
- **시작 조건:** `P2-T1` 완료. 홈 query/service/domain/response와 현재 KST 월/최대 8개 테스트 확인.
- **완료 증거:** repository 및 HTTP 통합으로 hidden 전체 제외, hidden 섞인 최신 8개 refill, owner ID 범위 및 visible `isSecret` 표시 유지.
- **범위 밖:** donation tab count, home 새 count 필드, legacy API 변경.
**Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHome.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt`
- Fixture/contract: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt`
- Fixture/contract: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt`
- Fixture/contract: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt`
**Interfaces:**
- Consumes: `ChannelDonationMessage.isHidden`; existing home `findChannelDonations(creatorId, viewerId, now, limit, lang)` and owner context in response assembly.
- Produces: `channelDonations[]` hidden-excluded, maximum 8 visible rows after filter; `donationId: Long?` only for owning CREATOR, no donor member ID.
- [x] **RED:** `shouldRefillHomeWithLatestEightVisibleDonationsAfterHiddenRowsAreExcluded` 및 `shouldExposeHomeDonationIdOnlyToOwningCreator`를 작성한다. hidden newest candidates, tied timestamps, public/secret visible rows, all-hidden empty를 포함한다.
- [x] **RED 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest'`로 숨김 누락 또는 owner ID assertion 실패를 확인한다.
- [x] **GREEN:** home query의 channel/month 조건과 함께 `isHidden=false`를 적용하고 기존 정렬 뒤 최대 8개를 조회한다. 소유자 응답에만 ID를 전달한다.
- [x] **GREEN 확인:** 같은 focused 명령으로 모든 viewer의 hidden 부재, visible 최대 8개 refill, 기존 월 경계/정렬/secret masking 및 ID 응답 범위를 검증한다.
- [x] **REFACTOR:** owner-only ID 회귀를 유지하며 현재 donation ID 부재만 강제하던 assertion을 owner/non-owner 구분 검증으로 갱신한다. focused home tests 및 `./gradlew ktlintCheck` 결과 기록.
**P2-T2 실행 증거: 2026-11-05 (최종 Task 판정은 상위 리뷰 대기)**
- RED: 위 두 클래스에 실제 socket home GET을 추가한 `CreatorChannelDonationEndToEndTest`까지 focused 실행. 정상 컴파일 후 93건 중 assertion 실패 8건, errors/skip 0, `BUILD FAILED in 51s`. repository는 visible 기대 `[9,8,7,6,5,4,3,2]` 대신 hidden 포함 `[12,11,10,9,8,7,6,5]`, all-hidden empty 실패; socket home은 hidden 원본 포함 `[300,200,100,12,11,10,9,8]`, owner ID `[179,178,177]` 대신 null 세 건. 기존 다국어 owner field-set assertion 3건도 ID 누락으로 실패했다.
- GREEN: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest' ktlintCheck`, exit 0, `BUILD SUCCESSFUL in 1m 31s`. home repository 40 + service 26 + policy 5 + E2E 18 + controller 3 + facade 2 + socket donation E2E 35 = 129건, failures/errors/skip 0; 컴파일 및 ktlint 통과.
- 검증: 최신 숨김 공개·비밀 후보 3건을 제외하고 동시각 ID 내림차순 visible 8건을 모든 5역할에 보충했다. owner가 홈에서 얻은 실제 ID로 공개/비밀 각각 DELETE한 뒤 홈 8건 refill 및 탭 count=8/hasNext=false, 두 JSON item 배열의 완전 일치를 검증했다. 전부 숨김 후 모든 역할의 홈은 빈 배열이다. 3행 별도 fixture에서도 양 조회면 persisted ID가 정확히 같고 선택한 비밀 행을 숨긴 뒤 모든 역할에서 두 목록이 일치한다.
- 새 월 의존 fixture는 현재 UTC 시각으로 계산한 KST 월 범위를 사용한다. 기존 P1 고정 fixture/원본 snapshot은 유지했다. 홈 `isViewerCreator`의 role-only live 접근 정책은 변경하거나 소유권에 재사용하지 않았다. 양 도메인에 소유자 ID만 전달하며 API property NON_NULL로 non-owner 키 자체를 생략한다. home E2E에도 DirtiesContext 종료를 명시했다.
#### Phase 2 Gate
**Goal 실행 `P2-GATE`:** 양 V2 조회 표면에서 숨김, page/count/refill과 owner ID를 통합 확인한다.
- **시작 조건:** `P2-T1`, `P2-T2` 완료.
- **완료 증거:** 아래 focused test, `ktlintCheck`, `bootJar` 결과. HTTP integration에서 creator/donor/USER/other CREATOR/ADMIN별 숨김 부재 확인.
- **범위 밖:** 전체 test suite 및 frontend UI 구현.
```bash
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultCreatorChannelDonationQueryRepositoryTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest'
./gradlew ktlintCheck
./gradlew bootJar
```
**Expected:** 구현 후 지정 focused tests, lint, jar 성공. hidden 공개/비밀 item은 어떤 role에도 없고 count/page/home fill 모두 visible 기준이다. donor ID는 추가되지 않는다. 현재 실행하지 않음.
**P2-GATE 실행 증거: 2026-11-05 (실행 통과·상위 리뷰 대기)**
- [x] 지정 Gate 4개 클래스 109건 및 직접 영향받는 service/policy/controller/facade 67건, 합계 176건 통과. failures/errors/skipped 모두 0.
- [x] 같은 invocation에서 `ktlintCheck`, `bootJar` 성공, exit 0, `BUILD SUCCESSFUL in 1m 14s`. bootJar는 실제 생성했고 lint는 직전 GREEN 결과와 동일 입력의 UP-TO-DATE 판정이다. 마지막 fixture UTC 조정 후 test Kotlin 재컴파일도 성공했다.
- [x] 기존 socket E2E에서 두 V2 GET → owner ID로 DELETE → 두 GET을 실제 인증 체인으로 확인했다. 모든 역할의 hidden 부재·count/page/refill·non-owner ID 키 생략·donor memberId 부재를 검증했다.
- [x] Phase 2 Task 및 Gate 최종 승인: 독립 리뷰에서 명세/품질 PASS. 상위가 홈·후원 E2E 53건을 재실행해 실패/오류/skip 0과 BUILD SUCCESSFUL(40초), 실제 HTTP 및 종료 로그를 확인했다.
실제 최종 명령 (Java 17, timeout 600000ms):
```bash
./gradlew test \
--tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultCreatorChannelDonationQueryRepositoryTest' \
--tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest' \
--tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest' \
--tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest' \
--tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryServiceTest' \
--tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicyTest' \
--tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationControllerTest' \
--tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacadeTest' \
--tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest' \
--tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.domain.CreatorChannelHomeQueryPolicyTest' \
--tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest' \
--tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.application.CreatorChannelHomeFacadeTest' \
ktlintCheck bootJar
```
실제 HTTP 응답 증거는 `build/test-results/test/TEST-kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest.xml`의 `HTTP EVIDENCE`에 있다. Tomcat port는 `51039`, worker PID는 `84091`이다. 아래는 실제 응답의 관련 필드만 발췌한 것이며 token/header는 기록하지 않았다.
| 요청 | HTTP | 실제 응답 관련 필드 |
|---|---:|---|
| owner GET `/api/v2/creator-channels/120/home` 및 `/donations` | 200 | 양 목록 `donationId=[179,178,177]`; 탭 `donationCount=3` |
| owner DELETE `/api/v2/creator-channels/120/donations/178` | 200 | `{"success":true,"message":null,"data":null,"errorProperty":null}` |
| 같은 두 GET 재요청 | 200 | 양 목록 `donationId=[179,177]`, `can=[300,100]`; 탭 `donationCount=2,hasNext=false` |
| owner 홈 GET → 공개 ID 12 DELETE → 두 GET (`creatorId=1`) | 200 | 홈 `can=[9,8,7,6,5,4,3,2]` → `[9,7,6,5,4,3,2,1]`; 두 배열 동일, 탭 count=8 |
| owner 홈 GET → 비밀 ID 29 DELETE → 두 GET (`creatorId=6`) | 200 | 홈 `can=[9,8,7,6,5,4,3,2]` → `[8,7,6,5,4,3,2,1]`; 두 배열 동일, 탭 count=8 |
- donor/outsider/other CREATOR/ADMIN도 같은 hidden 제외 목록을 받으며 non-owner JSON에는 `donationId` 키가 없었다. 채널 소유자와 같은 ID의 USER/ADMIN은 service matrix로도 ID 부재를 확인했다. 신규 로그와 최종 XML에 JWT 패턴 검색 결과 0건이며, 토큰은 기존 AfterEach에서 발급 member ID별 삭제했다.
- 변경 검토: production diff는 두 query의 local predicate/ID projection, 두 port record/domain/service/DTO뿐이다. P1 저장·숨김 API, 공통 직렬화, 랭킹/정산/legacy query와 의존성·설정은 변경하지 않았다. `git diff --check` 통과. PRD는 수정하지 않았다.
- 정리 영수증: 부모 경로를 먼저 확인하고 생성한 전용 루트 `channel-hide.aeTJdH/p2.1mgEdJ`만 사용했다. Tomcat 51039와 두 E2E Hikari 종료 로그를 확인했고 PID 84091·Gradle Test Executor·redis-server 및 해당 port listener 잔존 없음, `lsof +D` 열린 파일 없음이었다. UID 501 소유권·canonical path·빈 상태를 확인해 자체 로그 5개와 전용 루트만 삭제했다. 부모와 기존 `p1-t1.SxYFSF`, shared Gradle/Kotlin daemon PID 53466/73431은 보존했다. 최종 XML/HTML 테스트 결과는 `build/`에 남겼다.
- 검증 한계: LSP 도구가 없어 Kotlin/Java 컴파일 및 ktlint로 대체했다. 전체 테스트·`check`/`build`/`clean build`, P3 settlement/legacy 회귀, MySQL/운영 DDL 적용, subagent/reviewer, commit/stage/push는 수행하지 않았다. 전체 테스트는 사용자가 `./gradlew test`로 실행한다. 기존 deprecation/unchecked-cast 경고는 범위 밖이다.
**Phase 2의 정확한 변경 파일 (22개, 기존 P1 변경은 별도 보존):**
```text
src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt
src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationQueryPort.kt
src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationTab.kt
src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt
src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/dto/CreatorChannelDonationTabResponse.kt
src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt
src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt
src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHome.kt
src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt
src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt
src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepositoryTest.kt
src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryServiceTest.kt
src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicyTest.kt
src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationEndToEndTest.kt
src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationControllerTest.kt
src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacadeTest.kt
src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt
src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt
src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt
src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt
src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt
docs/20261105_채널후원메시지숨김/plan-task.md
```
### Phase 3: 보존, legacy 및 최종 회귀 증거
**Phase 결과:** 숨김이 표시 조정에 한정되고 원본/재무/legacy 동작을 바꾸지 않았다는 증거가 남는다.
**선행조건:** `P2-GATE` 완료.
**Phase 완료 조건:** `P3-GATE` 증거 기록.
#### Task 3.1: 재무 불변과 legacy 경계 Gate
- [x] **Task 3.1 완료:** 아래 회귀 검증과 완료 증거를 충족했다. 최종 리뷰의 P3-R1 보정 및 재리뷰도 통과했다.
**Goal 실행 `P3-GATE`:** 실제 persistence/HTTP 증거로 DB 보존, 랭킹/정산/can 사용 불변 및 legacy 목록의 기존 출력 유지를 확인한다.
- **시작 조건:** Phase 2 완료. 기존 legacy 채널 후원 query/controller 및 랭킹 adapter의 실제 경로를 구현 전 다시 확인한다.
- **완료 증거:** 통합 테스트에서 숨김 전후 원본 행과 금융 record 동일, 랭킹 동일, legacy 결과에서 같은 donation 표시, 문서에 global hide가 아님을 확인.
- **범위 밖:** legacy API를 숨김 처리하거나 재무 query를 변경하는 것.
- **TDD 예외 사유:** Gate 자체는 별도 production behavior를 추가하지 않고 앞선 integration test의 회귀 범위를 판정한다. 필요한 실패 assertion은 소유 Task에 추가 후 재검증한다.
- **대체 검증 방법:** repository/HTTP integration test, DB flush/clear/reload, legacy endpoint response 대조 및 focused lint/build를 기록한다.
**Files:**
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationEndToEndTest.kt`
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationControllerTest.kt` (기존 mock 기반 controller 회귀이며 DB 보존/조회 증거를 대체하지 않음)
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/legacy/LegacyCreatorChannelDonationRankingAdapterTest.kt`
- [x] 실제 persistence를 사용하는 `CreatorChannelDonationEndToEndTest`에서 숨김 API 성공 후 legacy GET을 호출해 동일 행이 유지되는지 확인한다. mock 응답만으로 판정하지 않는다.
- [x] entity를 DB에서 재조회해 ID/can/message/isSecret/createdAt/member/creator 값이 숨김 전후 동일한지 확인한다.
- [x] `UseCan`, `UseCanCalculate` 및 실제 ranking/정산 조회 결과가 숨김 전후 바뀌지 않는지 확인한다. mock 위임 테스트만으로 불변을 주장하지 않는다.
- [x] 관련 donation/home/legacy 클래스만 `--tests`로 실행하고 `./gradlew ktlintCheck`, `./gradlew bootJar` 결과를 기록한다.
```bash
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest' --tests 'kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationControllerTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.legacy.LegacyCreatorChannelDonationRankingAdapterTest'
```
**Expected:** 지정 테스트 exit 0, legacy 실제 조회와 재무 원본/집계 결과가 숨김 전후 동일. 현재 실행하지 않음.
**P3-GATE 실행 증거: 2026-11-05 (실행 통과, Task 최종 승인은 상위 전체 변경 리뷰 대기)**
- 변경은 위 E2E 파일과 이 기록뿐이며 production/PRD/P1·P2 기존 테스트는 보존했다. `shouldPreserveLegacyRankingAndSettlementAfterHidingDonationThroughHttp(false/true)` 2건을 추가하고 기존 HTTP/JWT·snapshot·hidden 재조회 helper를 재사용했다. Gate의 승인된 TDD 예외에 따라 production을 인위적으로 되돌리지 않았다.
- 금융 기여 연결: 채널별 메시지는 선택할 500캔 한 건뿐이고, 동일 donor/creator·비밀 여부·시각의 `UseCan(can=450,rewardCan=50,isRefund=false,CHANNEL_DONATION)` 및 `UseCanCalculate(can=500,status=RECEIVED)` 한 건을 commit했다. 별도 채널의 400캔 행도 보존한다. DB flush/clear/reload로 금액·연결·상태를 대조했으며 P1의 독립 500캔 fixture를 보존 증거로 재사용하지 않았다.
- 월/격리: 현재 UTC로 구한 KST 월 시작에 2시간을 더한 초 정밀도 입력을 사용했다. 공개 `2026-10-31T17:00:00Z`, 비밀 `2026-10-31T17:00:01Z`는 KST 11월이다. 날짜 설정 후 baseline을 읽었다. 정산은 실제 주입된 `AdminChannelDonationCalculateQueryRepository.getChannelDonationByCreatorTotal` 및 `getChannelDonationByCreator`를 동일 초의 inclusive 범위로 전후 호출해 다른 fixture와 격리했다. 날짜 포맷 함수/alias/mock은 사용하지 않았다.
- 신규 2건 우선 명령: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest.shouldPreserveLegacyRankingAndSettlementAfterHidingDonationThroughHttp' ktlintCheck`. exit 0, `BUILD SUCCESSFUL in 31s`, tests=2, failures/errors/skipped=0. test Kotlin 컴파일과 변경 test source ktlint 검사를 실제 실행했다.
최종 Gate 명령 (Java 17.0.15, timeout 600000ms, 표준 wrapper, 강제 재실행 없음):
```bash
./gradlew test \
--tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest' \
--tests 'kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationControllerTest' \
--tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.legacy.LegacyCreatorChannelDonationRankingAdapterTest' \
--tests 'kr.co.vividnext.sodalive.admin.calculate.channelDonation.AdminChannelDonationCalculateQueryRepositoryTest' \
ktlintCheck bootJar
```
- 결과: exit 0, `BUILD SUCCESSFUL in 33s`. 지정 Gate는 E2E 37 + legacy controller 2 + ranking adapter 2 = 41건, 직접 호출한 정산 repository 회귀 2건을 더해 총 43건, failures/errors/skipped 모두 0. 기존 E2E 35건도 전부 유지·통과했다. `ktlintCheck`와 `bootJar`는 동일 입력의 UP-TO-DATE 성공이며 기존 `build/libs/sodalive-0.0.1-SNAPSHOT.jar`(141 MiB)를 확인했다. 새 production 재컴파일/새 jar 생성으로 표현하지 않는다.
- 실제 socket 증거: Tomcat `51284`, worker PID `88424`, 실제 JWT 인증 체인의 GET → owner 응답 ID로 bodyless DELETE → GET 순서다. 공개 `creatorId=125,donationId=181`, 비밀 `creatorId=130,donationId=183` 모두 DELETE `200 {"success":true,"message":null,"data":null,"errorProperty":null}`. 양 경우 탭 `donationCount=1→0,donations=[]/hasNext=false`, 홈 `channelDonations` 1건→빈 배열을 확인했다.
- legacy 증거: owner GET `/explorer/profile/channel-donation?creatorId=125&page=0&size=20` 및 creatorId=130의 **전체 JSON envelope**가 전후 정확히 동일했다. `totalCount=1`, 같은 ID 181/183·500캔·공개/비밀 플래그·원문/생성 시각이 남는다. 이 기능은 두 V2 표시 목록만 숨기며 global hide가 아니다.
- 랭킹 증거: 탭 owner 응답 전체 `rankings`가 전후 동일하고 donor 126/131의 `donationCan=500→500`이었다. DELETE 후 해당 fixture의 정확한 Redis ranking key만 제거하고 제거 성공을 확인해, stale cache가 아닌 실제 legacy query 재조회로 검증했다.
- 정산 증거: 공개·비밀 모두 실제 total/count 및 creator별 query 결과 전체와 `toResponseItem()` 전체가 전후 동일했다. 각각 `count=1,totalCan=500,krw=50000,fee=3300,settlementAmount=39695,withholdingTax=1310,depositAmount=38385`. 0건/0원 불변을 통과 근거로 쓰지 않았다.
- DB 증거: JDBC 전체 컬럼 snapshot에서 donation 2행(자기 채널 1+다른 채널 1)은 `is_hidden` 외 모든 값, `UseCan` 1행과 `UseCanCalculate` 1행은 모든 컬럼·행 수가 동일했다. 별도 transaction/clear/find로 선택 ID만 hidden=true임을 확인했다. 행 삭제나 비교 정밀도 완화는 없다.
- 증거 파일: `build/test-results/test/TEST-kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest.xml`의 `HTTP EVIDENCE`, `SETTLEMENT EVIDENCE`, testsuite 집계 및 나머지 3개 클래스 XML. JWT 패턴 검색 결과는 0건이며 토큰은 기존 AfterEach에서 발급 member ID별 삭제했다.
- 정리: 확인한 부모 아래 `mktemp -d`로 생성한 `channel-hide.aeTJdH/p3.OBzvWM`의 TMPDIR/NODE_COMPILE_CACHE/BUN_RUNTIME_TRANSPILER_CACHE_PATH를 모든 shell에 지정했다. 두 실행의 Tomcat `51262/51284` 및 Hikari 종료 로그, worker PID `88133/88424`·Test Executor·redis-server·port listener 잔존 없음, `lsof +D` 열린 파일 없음과 0 entries를 확인했다. canonical 부모 allowlist·현재 UID 소유권·symlink 아님을 검사한 뒤 `rmdir`로 해당 빈 루트만 삭제했다. shared Gradle/Kotlin daemon PID `53466/73431`과 부모/다른 Task 임시 경로는 보존했다.
- 한계/인계: LSP 도구가 없어 test Kotlin 컴파일·ktlint로 대체했다. MySQL/운영 DDL, 전체 테스트·`check`/`build`/`clean build`, subagent/reviewer, commit/stage/push는 미실행이다. 기존 Gradle deprecation 안내는 유지했다. 전체 테스트의 사용자 명령은 `./gradlew test`이며, 상위의 broad scoped donation/home 회귀와 최종 전체 변경 리뷰·Task 승인에 넘긴다.
## Phase Gate와 전체 테스트 정책
### 회귀 수정 `P3-R1`: MySQL 자동 수정 시각 보존
- [x] **Task P3-R1 완료:** 숨김 UPDATE가 기존 `updated_at`을 명시적으로 유지함을 회귀 검사하고 최종 재리뷰를 통과했다.
- **목표:** MySQL의 자동 갱신 시각 설정에서도 숨김 상태 외 원본 컬럼을 바꾸지 않는다.
- **시작 조건:** 최종 Oracle 리뷰에서 기존 DDL의 `updated_at ... ON UPDATE CURRENT_TIMESTAMP`와 현재 UPDATE의 시각 자기 대입 누락을 확인했다.
- **Files:** Modify `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultHideCreatorChannelDonationRepository.kt`; Test `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultHideCreatorChannelDonationRepositoryTest.kt`.
- **범위 밖:** 기존 테이블 DDL 수정, 시각 초기화, 공통 시간 변환 변경, snapshot assertion 완화.
- [x] **RED:** 실제 생성 UPDATE SQL에 `updated_at=updated_at` 보존식이 없는 실패를 확인한다.
- [x] **GREEN:** QueryDSL UPDATE에 기존 `updatedAt` 자기 대입만 추가한다.
- [x] **GREEN 확인:** repository 회귀와 실제 HTTP 원본 snapshot 검사 통과, 생성 SQL 보존식 확인.
- [x] **REFACTOR 검증:** 추가 리팩터링 없이 이번 변경 범위의 lint/컴파일 및 `bootJar` 결과를 기록한다.
- [x] **최종 재리뷰:** Oracle가 P3-R1을 ADDRESSED로 판정하고 전체 변경을 승인했다. 새로운 명세·품질·보안 차단 사항은 없다.
- **검증 명령:** `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultHideCreatorChannelDonationRepositoryTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest' ktlintCheck bootJar`.
- **검증 한계:** H2의 Hibernate 생성 스키마에는 기존 MySQL 자동 시각 설정이 없으므로 기존 green만으로 MySQL 원본 시각 보존을 보장하지 않는다. 실제 MySQL 실행 여부는 결과에 별도로 기록한다.
**P3-R1 실행 증거: 2026-11-05 (RED/GREEN·검증 완료, 최종 재리뷰 대기)**
- 기존 DDL `docs/plan-task/20260223_channel_donation_message_ddl.sql`의 `updated_at TIMESTAMP ... ON UPDATE CURRENT_TIMESTAMP`를 확인했다. production 변경은 `.set(channelDonationMessage.updatedAt, channelDonationMessage.updatedAt)` 한 줄뿐이다. 기존 HTTP 전체 컬럼 snapshot·다른 행·멱등성 검사는 변경하지 않았다.
- 신규 `shouldPreserveUpdatedAtWithSelfAssignmentInGeneratedHideSql`은 테스트 내부 `TestConfiguration`/`HibernatePropertiesCustomizer`/`StatementInspector`로 실제 repository가 실행하는 SQL을 수집한다. fixture flush/clear/reload 후 수집값을 비우고 숨김 UPDATE의 자기 대입을 검사하며, DB에서 다시 읽은 `updatedAt`도 정확히 비교한다. 소스 문자열·mock 검증 또는 H2의 MySQL 자동 갱신 모방은 없다.
- RED 명령: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultHideCreatorChannelDonationRepositoryTest.shouldPreserveUpdatedAtWithSelfAssignmentInGeneratedHideSql'`. Java 17.0.15, timeout 600000ms, exit 1, `BUILD FAILED in 14s`, tests=1/failures=1/errors=0/skipped=0. 정상 컴파일 후 60행의 실제 SQL assertion이 `update channel_donation_message set is_hidden=? where id=? and creator_id=? and is_hidden=? ==> expected: <true> but was: <false>`로 실패했다.
- GREEN: 위 두 클래스 지정 검증 명령을 그대로 실행했다(timeout 600000ms, 표준 daemon·캐시 사용, 강제 재실행 없음). exit 0, `BUILD SUCCESSFUL in 49s`, repository 7 + HTTP E2E 37 = 44건, failures/errors/skipped 모두 0. production/test Kotlin 컴파일, main/test ktlint 검사 및 `bootJar`가 실제 실행됐고 Kotlin script 검사는 UP-TO-DATE 성공이다. 새 jar `build/libs/sodalive-0.0.1-SNAPSHOT.jar`는 141 MiB다.
- 실제 GREEN SQL(공백만 정리): `update channel_donation_message set is_hidden=?, updated_at=updated_at where id=? and creator_id=? and is_hidden=?`. 증거는 `build/test-results/test/TEST-kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultHideCreatorChannelDonationRepositoryTest.xml`의 신규 testcase 및 Hibernate 출력이다.
- 실제 HTTP: Tomcat `51419`에서 JWT 인증 DELETE 성공·중복 성공, 권한/ID 거부 및 anonymous 401을 확인했다. 공개/비밀 `/api/v2/creator-channels/125/donations/181`, `/api/v2/creator-channels/130/donations/183`은 모두 `200 success=true,data=null`이며 원본/재무 전체 컬럼 snapshot, legacy 전체 JSON, 랭킹 500캔 및 정산 39,695원·입금 38,385원 전후 동일 검사가 통과했다. 증거는 같은 디렉터리의 `TEST-kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest.xml`이다.
- 정리: 부모를 확인한 뒤 만든 전용 루트 `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/channel-hide.aeTJdH/p3-r1.tm8TPt`에 shell별 TMPDIR/NODE_COMPILE_CACHE/BUN_RUNTIME_TRANSPILER_CACHE_PATH를 지정했다. worker PID `91431/91649` 종료, Tomcat·Hikari 종료 로그, port 51419 listener·Test Executor·redis-server 잔존 없음, `lsof +D` 열린 파일 없음과 0 entries를 확인했다. canonical 부모·현재 UID·symlink 아님을 확인하고 `rmdir`로 전용 루트만 삭제했다. shared Gradle/Kotlin daemon PID `53466/73431`, 부모와 다른 작업 경로는 보존했다.
- 한계: 실제 MySQL/운영 DDL, 전체 테스트, subagent/reviewer, commit/stage/push는 실행하지 않았다. LSP 도구는 미제공이며 Kotlin 컴파일·ktlint 결과만 보고한다. 기존 Gradle deprecation 안내는 유지했다. 전체 테스트의 사용자 실행 명령은 `./gradlew test`다. Task P3-R1 및 최종 재리뷰 체크는 상위 판정까지 미완료로 남긴다.
모든 Phase Gate는 구현 경로를 직접 검증하는 focused feature test만 실행한다. 무필터 `test` 또는 전체 suite를 포함하는 `check`/`build`/`clean build`를 실행하지 않는다. 전체 테스트는 사용자 실행 항목이며 명령은 `./gradlew test`다. Phase Gate의 성공은 전체 suite 성공을 뜻하지 않는다.
## 프론트엔드 후속 범위
프론트엔드 작업은 이 backend 저장소 범위 밖이다. 별도 작업에서 소유 CREATOR에게만 전달되는 `donationId`를 사용해 숨김 API를 호출하고, 성공 뒤 홈과 탭 데이터를 다시 조회해야 한다. 프론트엔드 파일 경로, UI 디자인, 추가 확인 dialog/interaction을 이 계획에서 정하지 않는다.
## 실행 순서와 의존성
| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 |
|---:|---|---|---|---|
| 1 | `P1-T1` | 별도 명시 요청 | 아니요 | DDL/schema 근거 확인 |
| 2 | `P1-T2` | `P1-T1` | 아니요 | 기존 auth/error 관례 재확인 |
| 3 | `P1-GATE` | `P1-T1`, `P1-T2` | 아니요 | 실패 소유 Task에 회귀 추가 |
| 4 | `P2-T1` | `P1-GATE` | 아니요 | count/list 동일 predicate 확인 |
| 5 | `P2-T2` | `P2-T1` | 아니요 | 홈 fill 및 owner ID 검증 보강 |
| 6 | `P2-GATE` | `P2-T1`, `P2-T2` | 아니요 | 실패 소유 Task에 회귀 추가 |
| 7 | `P3-GATE` | `P2-GATE` | 아니요 | legacy/financial 경계 확인 |
```text
P1-T1 -> P1-T2 -> P1-GATE -> P2-T1 -> P2-T2 -> P2-GATE -> P3-GATE
```
## 변경 금지와 중단 조건
- 별도 구현 요청 전에는 이 계획의 체크박스를 시작하거나 코드를 수정하지 않는다.
- soft-hide 행과 원본 값을 보존한다. delete SQL, 필드 초기화, 숨김 metadata/history를 추가하지 않는다.
- `isHidden` 조건을 global entity filter, legacy list, ranking, settlement 또는 can use query에 적용하지 않는다.
- 권한을 UI에만 맡기지 않는다. 인증, CREATOR role, channel ownership, donation-channel match를 서버에서 검증한다.
- 상태 오류를 generic HTTP 403/404로 바꾸지 않고 기존 `SodaException`/handler 응답 관례를 확인한다.
- 사용자 결정과 기술 제안을 합치지 않는다. 구현 단계에서 제안 변경이 필요하면 구현 전에 PRD/plan Decision Log를 먼저 갱신한다.
## Progress 및 문서 검증 기록
### 최종 완료 판정: 2026-11-05
- P3-R1 수정 후 상위에서 아래 「최종 기능 범위 재검증」과 동일한 명령을 다시 실행했다. `BUILD SUCCESSFUL in 1m 7s`, XML 19개 클래스 총 **208건**, failures/errors/skipped 모두 0이다. repository 회귀 1건이 추가됐으며 기존 207건도 통과했다.
- `ktlintCheck`·`bootJar`는 수정 후 생성 산출물의 UP-TO-DATE 성공이다. 직전 P3-R1 실행에서는 production/test 컴파일·lint·jar 생성이 실제 수행됐다. 전체 suite를 실행한 것은 아니다.
- 최초 최종 리뷰에서 MySQL 자동 `updated_at` 갱신 위험을 발견했다. 보존식 1줄과 실제 생성 SQL RED/GREEN을 추가했고, scoped 재리뷰에서 **ADDRESSED / 전체 변경 승인**을 받았다. 실제 MySQL 실행 한계는 유지한다.
- 최종 테스트 worker PID 92754 잔존 없음과 임시 루트 열린 파일 없음을 확인했다. 테스트/HTTP/SQL 증거는 `build/test-results/test/`, 빌드 산출물은 `build/libs/sodalive-0.0.1-SNAPSHOT.jar`에 남긴다.
- 최종 정리 영수증: 소유권·symlink 아님·빈 상태를 확인해 남은 `p1-t1.SxYFSF`와 부모 `channel-hide.aeTJdH`를 `rmdir`로 제거했다. 임시 notepad도 제거했고 셸에서 세 경로 부재를 확인했다(exit 0). 작업용 서버/worker는 종료됐으며 공유 캐시·기존 daemon은 건드리지 않았다. 최종 tracked 및 신규 파일 공백 검사도 exit 0이었다.
- 현재 변경은 서버 소스·관련 테스트·본 기능 문서/DDL에 한정한다. 커밋·push·운영 SQL·배포는 하지 않았다. 전체 테스트 사용자 명령은 `./gradlew test`다.
- 프론트엔드 연동은 범위 밖이며 서버 구현 완료와 화면 클라이언트 반영 완료를 구분한다. 기존 프로필 API에서는 숨긴 행이 계속 보일 수 있다.
### 최종 기능 범위 재검증: 2026-11-05
- 상위 세션에서 아래 명령을 직접 실행했다. exit 0, `BUILD SUCCESSFUL in 1m 5s`, XML 19개 클래스 합계 207건, failures/errors/skipped 모두 0이다. 이는 변경 기능과 직접 영향 회귀이며 전체 suite가 아니다.
- `ktlintCheck` 및 `bootJar`는 동일 입력의 UP-TO-DATE 성공이다. production jar는 Phase 2에서 생성했고 이후 production 변경은 없다.
- 실제 socket HTTP에서 공개/비밀 500캔 각각 DELETE 성공, V2 목록/건수 감소, legacy 전체 JSON 유지, cache 제거 후 랭킹 500캔 유지, 실제 정산 39,695원·입금액 38,385원 유지 로그를 다시 확인했다.
- 최종 worker PID 89370의 Tomcat·Hikari 종료 로그와 `ps -p 89370` 잔존 없음, `git diff --check` exit 0을 확인했다. 전체 변경 Oracle 리뷰는 별도로 대기한다.
```bash
./gradlew test \
--tests 'kr.co.vividnext.sodalive.v2.creator.channel.donation.*' \
--tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.donation.*' \
--tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.*' \
--tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.*' \
--tests 'kr.co.vividnext.sodalive.explorer.profile.channelDonation.*' \
--tests 'kr.co.vividnext.sodalive.admin.calculate.channelDonation.AdminChannelDonationCalculateQueryRepositoryTest' \
ktlintCheck bootJar --console=plain
```
- 전체 테스트 `./gradlew test`는 사용자 실행 항목이다. 운영 MySQL/DDL 실행·배포·프론트엔드 연동은 수행하지 않았다. `lsp_diagnostics` 미제공으로 Kotlin 컴파일/ktlint를 사용했으며 LSP 검증을 주장하지 않는다.
### 구현 착수: 2026-11-05
- 사용자 "문서대로 진행" 승인으로 기술 초안과 미래 구현 계획의 실행을 시작한다. 문서 전용 제한/미요청 문구는 과거 작성 시점 기록이며, 현재 제품 코드·테스트·DDL 문서 변경을 허용한다. 실제 운영 DDL 실행과 배포는 하지 않는다.
- 현재 `test` 브랜치에서 작업한다. 기존 source 변경이 없고 별도 worktree를 자동 생성하지 않는다. 검증은 프로젝트 기준의 focused test, HTTP 경로, lint 및 bootJar를 사용한다.
- 제공된 도구에 `lsp_diagnostics`가 없어 Kotlin 컴파일·ktlint로 대체하며 LSP 검증 성공을 주장하지 않는다.
### 문서 작성: 2026-11-05
- 상태: PRD와 미래 계획 작성 완료. 현재 Goal 없음, 구현/테스트 미착수.
- 무엇을: 사용자 인터뷰 결정과 현행 donation/home V2 query, DTO, controller, tests를 근거로 지정 문서 두 개를 작성한다.
- 어떻게: 코드 스타일/테스트/작업 절차/문서 유지보수/실행 명령어 지침, 두 sample 문서, 기존 비밀 후원 PRD/계획, `ChannelDonationMessage`, donation/home repository, response DTO 및 donation HTTP E2E test를 읽었다.
- 문서 전용이므로 Gradle, API 호출, SQL 실행 및 runtime/test/build 검증은 하지 않았다. 향후 계획의 명령은 실행 결과가 아니다.
- 최초 초안에서는 콘텐츠 일관성 및 공백 검사가 검증 대기였으며, 후속 결과는 아래 문서 검증 기록에 누적한다.
- 남은 조건: 사용자의 별도 명시 구현 요청. frontend 작업은 backend와 별도 범위.
### 문서 검증: 2026-11-05
- 상태: 문서 작성·검토 완료. 제품 구현은 미착수이며 활성 구현 Goal은 없다.
- 무엇을/왜: 인터뷰 결정, PRD 요구사항, Goal·파일·검증 명령의 연결을 대조하고 기술 초안이 사용자 확정사항으로 오인되지 않는지 확인했다.
- 내용 검증: 두 문서 전체 읽기, 실제 source/test 경로 대조, 요구사항 추적표와 상대 링크 확인. 날짜는 `date '+%Y-%m-%d %Z'`의 `2026-11-05 KST`에 맞췄다.
- 독립 검토: 최초 검토 에이전트는 지원되지 않는 모델로 시작 실패했다. 다른 실행 경로의 검토자는 읽기 전용 builder check 후 중요한 미해결 질문·모순·실행 차단 결함이 없다고 판정했다.
- 공백 검사: 두 문서 각각 `GIT_MASTER=1 git diff --no-index --check /dev/null "docs/20261105_채널후원메시지숨김/prd.md"`, `GIT_MASTER=1 git diff --no-index --check /dev/null "docs/20261105_채널후원메시지숨김/plan-task.md"` 실행, 각각 exit 0·출력 없음. untracked 문서도 검사 대상에 포함했다.
- 변경 범위: `GIT_MASTER=1 git status --short --untracked-files=all`로 이 작업의 신규 문서 두 개만 확인했다. 제품 코드·테스트·설정·SQL은 변경하지 않았다.
- 미실행: 문서 전용 변경이므로 테스트·빌드·Gradle·API·SQL 실행과 런타임/운영 DB 검증은 생략했다. 실제 기능이 동작한다는 완료 주장은 하지 않는다.
- 다음 조건: 기술안 검토 및 별도의 구현 요청. 전체 테스트 명령 `./gradlew test`는 향후 구현 후 사용자가 직접 실행한다.
## Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 |
|---|---|---|---|---|---|
| 2026-11-05 | `PLAN-DEC-001` | 사용자 확정 | 후원 항목 하나 soft-hide, 원본/재무 보존, 두 V2 목록만 숨김. | 사용자 인터뷰 | `CDH-001`~`CDH-008` |
| 2026-11-05 | `PLAN-DEC-002` | 작성자 기술 초안 | 단일 boolean, 로컬 query filter, owner-only ID, bodyless DELETE API, 멱등 재요청. | 현행 패키지 및 query 경계 | `CDH-009`, `P1-T1`~`P2-T2` |
## 최종 문서 전용 확인
- [x] 두 문서 간 ID/범위/기술 제안 정합성 및 trailing whitespace를 확인했다.
- [x] 변경 파일이 이 디렉터리의 `prd.md`와 `plan-task.md` 두 개뿐인지 확인했다.
- 문서 전용 변경이므로 프로젝트 지침에 따라 Gradle 테스트/빌드는 실행하지 않는다. 런타임 동작은 미검증이다.
## 커밋 전 검증: 2026-11-05
- 사용자 `/commit-with-validation` 요청으로 변경 파일과 전역 커밋 절차 및 저장소 메시지 규칙을 확인했다.
- Java 17.0.15에서 위 「최종 기능 범위 재검증」의 명령을 동일하게 실행했다. `BUILD SUCCESSFUL in 2s`, exit 0이며 test·ktlintCheck·bootJar를 포함한 19개 task 모두 UP-TO-DATE였다. 테스트를 새로 실행하거나 jar를 새로 생성한 결과로 표현하지 않는다.
- 현재 XML 19개 클래스의 208건, failures/errors/skipped 모두 0을 확인했다. `git diff --check`도 통과했다.
- 전체 테스트와 운영 MySQL/DDL 적용은 수행하지 않았다. 전체 테스트 사용자 명령은 `./gradlew test`이며, 배포 전 DDL 선반영 조건은 유지한다.
@@ -0,0 +1,162 @@
# 채널 후원 메시지 항목 숨김 PRD
## 문서 정보
| 항목 | 내용 |
|---|---|
| 문서 상태 | 서버 구현·기능 범위 검증·최종 리뷰 완료. 운영 DDL/프론트엔드 연동 미수행 |
| 작성일 | 2026-11-05 |
| 최종 수정일 | 2026-11-05 |
| 대상 제품 | 크리에이터 채널 후원 메시지 숨김 |
| 작성자·결정권자 | 요구사항은 사용자 인터뷰로 확정, 기술안은 문서 작성자 제안 |
| 관련 API Contract | 이 문서 §8, `DEC-006`으로 구현 승인 |
| 관련 구현 계획 | [plan-task.md](plan-task.md) |
| 관련 review | 없음 |
## 1. 개요
크리에이터는 자신의 채널에 표시되는 단일 후원 항목을 숨길 수 있다. 숨김은 원본 후원 기록을 보존하는 표시 조정이며, 채널 홈의 `channelDonations`와 후원 탭의 `donations`에서만 항목을 제외한다. 최초에는 문서만 작성했으며, 이후 사용자의 "문서대로 진행" 승인(`DEC-006`)에 따라 구현했다. 현재 실행 상태와 검증 결과는 [plan-task.md](plan-task.md)를 기준으로 한다.
## 2. 문제와 목표
채널에 표시된 후원 메시지가 부적절하거나 더 이상 게시하기 적절하지 않을 수 있지만, 이를 제거하면서 후원 및 정산 이력을 변경해서는 안 된다. 크리에이터가 해당 후원 항목을 숨기면 모든 조회자에게 두 V2 채널 표시 위치에서 보이지 않고 원본 기록과 재무 효과는 그대로 남아야 한다.
## 3. 목표와 범위 밖
### 목표
- 채널의 소유 CREATOR만 해당 채널의 후원 항목 하나를 숨길 수 있다.
- 숨긴 항목은 채널 소유자와 후원자를 포함한 모든 조회자에게 두 V2 표시 목록에서 제외된다.
- `donationCount`는 숨기지 않은 후원 항목만 센다.
- 데이터베이스 원본 값, 기여도 랭킹, 정산 및 사용한 can은 그대로 보존된다.
### 범위 밖
- 복원, 물리 삭제, 후원 취소, 환불, can 사용 취소 또는 정산 역분개.
- legacy 프로필/후원 목록 API의 숨김 처리. 해당 경로는 같은 원본 기록을 계속 노출할 수 있다. 이 기능은 전역 삭제나 전체 서비스 비노출을 약속하지 않는다.
- 관리자 추가 권한, 후원자 권한, 다른 크리에이터 권한, 일반 회원 권한.
- 숨김 사유, 수행자/시각 이력, UI 이력, 일괄 API, 실시간 push.
- 프론트엔드 구현 및 화면 디자인. 별도 프론트엔드 작업은 소유자 전용 ID로 숨김 API를 호출하고 두 표시 경로를 다시 조회해야 한다.
## 4. 사용자와 권한
| 주체 | 숨김 가능 여부 | 두 V2 목록의 숨긴 항목 |
|---|---:|---|
| 해당 채널의 소유 CREATOR | 가능 | 제외 |
| 후원자 본인 | 불가 | 제외 |
| 다른 CREATOR | 불가 | 제외 |
| 일반 회원 | 불가 | 제외 |
| 관리자 | 이 요청으로 추가 권한 없음 | 제외 |
서버는 인증 회원, `CREATOR` role, 요청 경로의 `creatorId`와 인증 회원의 채널 소유권, 후원 항목과 동일 채널 소속을 검증한다. 존재하지 않는 항목 또는 다른 채널의 ID를 안전하게 거부하고 어떠한 기록도 변경하지 않는다. 현재 `SodaException` 및 공통 오류 handler를 따른다. 프로젝트 관례상 도메인 실패는 HTTP 200 응답의 `success=false` envelope일 수 있으며, 별도의 REST 403/404 계약을 만들지 않는다.
## 5. 핵심 흐름 (기술 제안 포함)
1. 소유 CREATOR가 채널 홈 또는 후원 탭에서 항목을 선택한다.
2. 프론트엔드는 소유자에게만 응답되는 안정적인 `donationId`로 숨김 요청을 보낸다.
3. 서버가 인증, role, 채널 소유권 및 후원 소속을 확인한 뒤 해당 항목의 숨김 상태만 기록한다.
4. 같은 소유자가 이미 숨긴 항목을 다시 요청해도 성공하는 멱등 동작을 제공한다.
5. 프론트엔드는 홈과 후원 탭을 다시 조회한다. 숨긴 항목은 양쪽에서 보이지 않으며 count도 감소한다.
## 6. 제품 요구사항
| ID | 상태 | 요구사항 | 수용 기준 |
|---|---|---|---|
| `CDH-001` | 확정 | 소유 CREATOR는 채널 후원 한 항목을 숨긴다. | ID로 단일 항목을 지정하며 소유권과 채널 소속이 모두 일치할 때만 성공한다. |
| `CDH-002` | 확정 | 숨김 상태는 soft-hide다. | DB 행, ID, can, 메시지, donor/creator 연결, 생성 시각, `isSecret`이 보존된다. |
| `CDH-003` | 확정 | 숨긴 항목은 모든 사용자에게 숨긴다. | V2 홈 `channelDonations` 및 V2 후원 탭 `donations`에서 creator와 donor 본인 포함 전원에게 제외된다. |
| `CDH-004` | 확정 | 후원 탭의 `donationCount`는 보이는 항목 수다. | 숨김 항목은 count에서 빠지며 pagination 및 `hasNext`도 같은 숨김 제외 조건을 쓴다. |
| `CDH-005` | 확정 | 랭킹과 재무 효과는 보존한다. | 기존 랭킹, 정산, 원본 can 사용 및 후원 기록의 값/행이 달라지지 않는다. |
| `CDH-006` | 확정 | 권한은 소유 CREATOR에 한정한다. | donor, 타 CREATOR, USER, ADMIN 요청은 거부되며 기록 변화가 없다. |
| `CDH-007` | 확정 | 숨김은 지정된 두 V2 표시 경로만 바꾼다. | legacy 프로필/후원 목록은 변경하지 않으며 같은 행을 계속 반환할 수 있음을 명시한다. |
| `CDH-008` | 확정 | 현재 공개/비밀 후원 표시 정책을 유지한다. | 숨기지 않은 공개 및 비밀 항목은 현재 `isSecret` 마스킹 규칙으로 계속 표시된다. |
| `CDH-009` | 작성자 기술 초안 | 숨김 재요청은 안전하게 성공하도록 제안한다. | 같은 소유자의 중복 숨김 요청은 성공하며 금융 효과를 재적용하지 않는다. |
## 7. 기술 상태와 근거
다음 기술 선택은 최초 **AUTHOR TECHNICAL DRAFT**로 작성했고 `DEC-006`으로 구현 승인됐다. 아래 제안 표현은 작성 당시의 결정 근거이며 현재 구현 계약으로 사용한다.
- 변경 전 엔티티 `ChannelDonationMessage`는 `BaseEntity`의 ID/시간을 상속하며 숨김 필드가 없었다. 구현은 boolean `isHidden=false`를 추가하고 MySQL의 `is_hidden TINYINT(1) NOT NULL DEFAULT 0 COMMENT '채널 후원 표시 숨김 여부'`를 사용한다. [운영 반영용 DDL](channel_donation_message_is_hidden_ddl.sql)을 작성했으며 운영 DB에는 실행하지 않았다.
- 기존 donation 탭 QueryDSL은 채널과 KST 월 조건으로 count와 page 조회를 나눠 수행하고, 홈 QueryDSL은 같은 기간의 최신 8건을 조회한다. 숨김 조건은 각 조회 경로의 `offset/limit` 적용 전에 둔다. 홈은 기존 limit 8을 유지해 숨긴 항목 다음의 표시 가능 항목으로 최대 8개를 채운다.
- donation 탭의 count와 list가 동일한 숨김 제외 predicate를 사용한다. legacy query, 랭킹/정산/금융 query에는 전역 filter를 추가하지 않는다. Hibernate 전역 `@Where`/`@SQLDelete`는 사용하지 않는다.
- 현재 홈/탭 후원 응답은 후원 항목의 ID와 donor member ID를 노출하지 않으며 기존 테스트도 ID 부재를 검증한다. 숨김 동작을 위해 안정적인 `donationId`를 소유 CREATOR 응답에만 nullable/생략 방식으로 추가하는 방안을 제안한다. 일반 조회자, donor 및 다른 사용자의 JSON에는 ID가 없어야 하며 donor `memberId`는 누구에게도 추가 노출하지 않는다. 기존 owner ID 부재 assertion은 owner-only ID 노출 검증으로 갱신하고 non-owner 부재 검증은 유지한다.
- 제안 endpoint는 bodyless `DELETE /api/v2/creator-channels/{creatorId}/donations/{donationId}`이며 `ApiResponse.ok` envelope에 data 없이 성공을 반환한다. 기존 인증 주입, `SodaException`, message key/i18n 및 handler를 재사용한다. generic REST 403/404를 새로 도입하지 않는다.
- 공개 후원 및 비밀 후원의 기존 `isSecret` 표시/masking은 visible row에만 그대로 적용한다. 숨긴 항목은 공개/비밀 구분 없이 전체 제외한다.
## 8. 제안 API 경계 (AUTHOR TECHNICAL DRAFT)
| Method / path | 입력 | 성공 | 실패 및 제한 |
|---|---|---|---|
| `DELETE /api/v2/creator-channels/{creatorId}/donations/{donationId}` | 인증 사용자, 경로 ID, body 없음 | 기존 `ApiResponse.ok` envelope, data 없음 | 현재 `SodaException`/handler 및 현행 HTTP-200 `success=false` 도메인 오류 패턴. 비소유/불일치/미존재는 상태 변경 없이 거부 |
제안된 application 경계는 재사용 패키지 `kr.co.vividnext.sodalive.v2.creator.channel.donation`의 `HideCreatorChannelDonationService.hide(creatorId: Long, donationId: Long, actor: Member)`다. 출력 port는 `HideCreatorChannelDonationPort.hideOwnedDonation(creatorId: Long, donationId: Long): Boolean`로 채널에 속하는 항목만 원자적으로 숨긴다. 소유자 role/identity 검증은 service 경계에서 수행한다. adapter 패키지 `kr.co.vividnext.sodalive.v2.api.creator.channel.donation`은 controller/facade와 공개 응답의 소유자 전용 `donationId`를 조립한다. 구체 파일과 인터페이스는 plan의 기술 초안에 적는다.
숨김 행 재요청은 이미 숨김 상태면 성공으로 끝낸다. 첫 요청과 재요청 모두 can 사용, 환불, 랭킹, 정산을 호출하거나 수정하지 않는다. 소유권·같은 채널 소속·항목 존재 여부가 일치하지 않으면 성공 처리하지 않는다.
## 9. 비기능, 보안과 데이터
- 사용자 제공 ID는 신뢰하지 않는다. 인증 회원, `CREATOR` role, 채널 소유자 ID 및 donation의 channel ID를 서버에서 대조한다.
- 다른 채널/존재하지 않는 donation에 대한 거부가 데이터 존재 여부를 불필요하게 노출하지 않도록 기존 도메인 오류 패턴을 검토한다.
- `isHidden` 외에 `hiddenAt`, `hiddenBy`, reason, history를 추가하지 않는다.
- 변경 범위 밖의 구형 API가 원본을 노출할 수 있다는 제한을 제품/운영 설명에서 숨기지 않는다.
- 테스트는 repository/HTTP 통합 근거를 포함하고, 기존 테스트 관례인 JUnit 5, `@SpringBootTest`, 실제 H2/MockMvc 경로를 따른다.
## 10. 성공 기준과 추적
- [x] `CDH-001`~`CDH-009`의 미래 구현 테스트와 계획 Goal이 연결되어 있다. 구현 및 테스트 통과를 뜻하지 않는다.
- [x] 역할별 권한, 다른 채널 및 존재하지 않는 ID, 숨김 중복 요청을 HTTP 통합으로 확인했다.
- [x] 모든 viewer role의 홈/탭 결과에서 숨긴 공개/비밀 행이 제외된다.
- [x] count, page, `hasNext`, 홈 최신 8개 보충 결과가 숨김 predicate와 일치한다.
- [x] 원본 행과 값, ranking, settlement, can 사용 내역의 불변을 H2/실제 HTTP로 확인했다. MySQL 자동 수정 시각 보존은 생성 SQL 자기 대입 회귀로 확인했으며 운영 MySQL 실행은 미검증이다.
- [x] legacy 출력이 변경되지 않고 그 제한을 문서화했다.
- [x] donor member ID는 추가 노출되지 않으며 donation ID는 소유 CREATOR에만 응답된다.
| 요구사항 | 계획 Goal | 주요 완료 증거 |
|---|---|---|
| `CDH-001`, `CDH-006` | `P1-T1`, `P1-T2`, `P1-GATE` | 소유 CREATOR 성공, 비소유/위조 ID/미존재 거부 |
| `CDH-002`, `CDH-005`, `CDH-007` | `P1-T1`, `P3-GATE` | DB 재조회, 실제 랭킹/정산 및 legacy 조회 전후 비교 |
| `CDH-003`, `CDH-004`, `CDH-008` | `P2-T1`, `P2-T2`, `P2-GATE` | 역할별 숨김 제외, count/page/refill, visible 비밀 후원 마스킹 |
| `CDH-009` (기술 초안) | `P1-T1`, `P1-T2` | 동일 소유자의 중복 요청 성공 및 추가 변경 없음 |
| 소유자 전용 ID (기술 초안) | `P2-T1`, `P2-T2` | 양 응답의 동일 persisted ID, 비소유자 JSON의 ID 부재 |
## 11. 인터뷰 기록과 미결 사항
사용자 인터뷰 결정: 단일 항목 soft-hide, 소유 CREATOR만 권한 보유, 모든 viewer에게 두 V2 목록에서 제외, 복원 없음, donationCount 제외, 랭킹/정산/can 사용 보존, legacy API 불변 및 전역 삭제가 아님.
점수: Goal 1.00, Scope 1.00, Constraints 0.90, Success 0.85, Context 0.85. 기존 코드베이스 가중치 `.30/.20/.15/.20/.15` 적용 모호성은 **0.0675 (약 0.07)**다. 제품 질문은 모두 해결되었다. 기술안은 사용자 결정으로 승격하지 않는다.
| 분류 | 상태 | 항목 |
|---|---|---|
| 제품 질문 | 없음 | 구현 범위·권한·보존 정책·조회 범위는 인터뷰로 확정 |
| 기술 제안 | `DEC-006` 승인 | §7~§9의 필드, predicate, ID 노출 및 endpoint 계약을 구현 기준으로 확정 |
| 외부 의존 | 프론트엔드 연동 필요 | 실제 화면의 숨김 조작과 양쪽 데이터 갱신은 별도 프론트엔드 작업. 이 계획의 backend 완료와 화면 기능 완료를 구분 |
## 12. Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 |
|---|---|---|---|---|
| 2026-11-05 | `DEC-001` | 사용자 확정 | 단일 채널 후원 항목을 soft-hide하고 원본 기록/값을 보존한다. | 사용자 인터뷰 |
| 2026-11-05 | `DEC-002` | 사용자 확정 | 소유 CREATOR만 숨김할 수 있고, 숨긴 항목은 두 V2 목록에서 모든 viewer에게 제외한다. | 사용자 인터뷰 |
| 2026-11-05 | `DEC-003` | 사용자 확정 | donationCount에서 제외하되 랭킹, 정산, can 사용은 그대로 둔다. | 사용자 인터뷰 |
| 2026-11-05 | `DEC-004` | 사용자 확정 | legacy 프로필/후원 목록, 복원 및 기타 global hide는 범위 밖이다. | 사용자 인터뷰 |
| 2026-11-05 | `DEC-005` | 작성자 기술 초안 | `isHidden` 필드, 로컬 query filter, owner-only ID, DELETE route, 멱등 재요청을 제안한다. | 기존 코드 경계와 최소 구현 검토. 사용자 승인 아님 |
## 13. 기준 문서와 변경 관리
[`docs/20261104_크리에이터채널_비밀후원_익명조회/prd.md`](../20261104_크리에이터채널_비밀후원_익명조회/prd.md)는 현재 visible row의 비밀 여부 마스킹 기준이다. 본 문서는 기존 `isSecret` 마스킹 정책을 변경하지 않고, 숨김 상태의 두 V2 query 제외 및 `donationCount`에 한해 후속 요구를 더한다. 과거 PRD의 목록 DTO ID 비노출 요구는 **owner-only `donationId` 기술안이 승인될 경우에만** 소유 CREATOR 응답 범위에서 좁게 대체한다. 현재 공개 계약을 이미 변경한 것은 아니다. donor member ID 부재 및 모든 non-owner의 ID 부재 요구는 유지한다. 과거 문서는 수정하지 않는다.
구현 착수 전 별도의 명시적 요청이 필요하다. 구현 선택을 확정하면 이 PRD의 기술 제안을 승인된 계약과 구분해 Decision Log에 반영하고 계획을 동기화한다.
### 구현 승인: 2026-11-05
사용자의 "문서대로 진행" 요청으로 이 문서의 기술 제안과 계획을 구현 기준으로 승인했다. 위 초안/미요청 표기는 작성 당시 이력이며 현재는 `isHidden`, 소유 CREATOR 전용 `donationId`, bodyless DELETE 및 멱등 재요청을 구현한다. 프론트엔드·운영 DDL 실행·배포는 포함하지 않는다.
| 날짜 | ID | 상태 | 결정 | 근거 |
|---|---|---|---|---|
| 2026-11-05 | `DEC-006` | 사용자 승인 | `DEC-005` 기술 초안을 본 구현의 계약으로 사용한다. | "문서대로 진행" |
### 서버 구현 완료: 2026-11-05
- 관련 기능 19개 클래스 208건이 실패/오류/skip 없이 통과했다. 실제 JWT socket HTTP, 원본 DB·랭킹·정산·legacy 보존과 SQL 시각 보존을 검증했고 최종 리뷰가 승인됐다.
- `ktlintCheck`, `bootJar` 성공. 전체 suite는 미실행이며 사용자가 `./gradlew test`로 검증한다. 상세 명령·실패 이력·최종 증거는 [plan-task.md](plan-task.md)에 있다.
- [DDL](channel_donation_message_is_hidden_ddl.sql)은 작성만 했으며 배포 전에 반영해야 한다. 프론트엔드는 소유자 전용 `donationId`로 DELETE를 호출하고 두 조회 결과를 갱신해야 한다. 화면 클라이언트·운영 배포 완료를 의미하지 않는다.
@@ -0,0 +1,110 @@
# 국가 강제 지정 테스트 fixture 격리 구현 계획
> 실행: subagent-driven-development. 기존 승인 범위를 구현하며 추가 인터뷰 없이 진행한다.
## 현재 상태와 목표
- 작성일: 2026-11-06
- 상태: 구현 완료 (기능 범위 검증 완료, 전체 테스트는 사용자 실행)
- 요구사항: [prd.md](prd.md)
- 활성 Goal: 없음 (P1-T1, P1-GATE 완료)
- 목표: 운영 국가 ID 추가 시 일반 회원 fixture의 목록을 수정하지 않아도 되고, 기존 독립된 정책 검증은 유지된다.
- 기술: Kotlin 1.6.21, JDK 17, JUnit 5, Spring Boot 2.7.14, Gradle Wrapper.
- 시작 조건: 사용자 수정 승인, 관련 테스트/가이드 읽기 완료.
- 범위 밖: production, API, DB schema, dependency, 사용자 기존 변경, 전체 테스트 자동 실행, commit/push.
### Phase 1: 목록 복제 없는 fixture
- [x] **Task 1.1: 공통 fixture 판별과 세 호출부 전환 (P1-T1)**
**Objective:** 세 테스트가 강제 지정 ID 목록을 복사하지 않고 일반 회원을 준비한다.
**Files:**
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/support/CountryMemberFixtures.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/support/CountryMemberFixturesTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceIntegrationTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt`
- Verify unchanged: `src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceServiceTest.kt`
**Interface:** test-only `internal fun Member.hasForcedCountryMapping(): Boolean`. 기존 resolver에 KR/JP를 각각 전달해 요청 국가가 유지되는지 확인하며, 어느 한 결과라도 다르면 true. 예상 응답 계산에는 사용 금지.
- [x] **Baseline:** 기존 service/integration 테스트와 아래 두 HTTP 메서드가 수정 전 통과하는지 확인한다.
- [x] **RED:** helper와 리터럴 기대값의 parameterized test를 작성한다. 기존 통합 테스트의 복제 목록 동작을 helper로 옮긴 상태에서 17958이 제외되지 않는 실패를 재현한다.
- [x] **RED 확인:** `CountryMemberFixturesTest`에서 17958의 expected true / actual false를 확인한다.
- [x] **GREEN:** helper를 resolver 기반 판별로 바꾸고 16/17958/29721은 true, 100은 false인지 확인한다.
- [x] **GREEN 확인:** helper focused test 통과.
- [x] **REFACTOR:** 세 호출부 조건을 helper로 교체하고 목록 복사본을 제거한다. 기존 저장 루프와 정책/응답 assertions는 보존한다.
**완료 증거:** 다음 세 시나리오 PASS 및 아래 명령 결과 기록.
| 시나리오 | PASS 조건 | 실제 검증 표면 |
|---|---|---|
| 일반 회원 | 100은 강제 지정 아님, 기존 US/JP/기본값 처리 보존 | helper JUnit + H2 integration |
| 강제 지정/누락 경계 | 16/17958/29721 제외, 헤더보다 강제 지정 우선 | helper JUnit + 기존 service policy assertions |
| 인접 HTTP 회귀 | 홈/오디오의 기존 8개 국가·인증 조합별 상태/스키마/이벤트 ID assertion 통과 | 기존 Spring MockMvc 요청과 실제 H2 persistence |
```bash
./gradlew test --tests 'kr.co.vividnext.sodalive.support.CountryMemberFixturesTest' --console=plain
./gradlew test --tests 'kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest' --tests 'kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest' --console=plain
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest.shouldApplyEventBannerDetailPolicyForHomeAggregate' --tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest.shouldApplyEventBannerDetailPolicyAndKeepAudioResponseSchema' --console=plain
```
**P1-T1 실행 결과 (2026-11-06):**
- 모든 실제 Gradle 실행은 `JAVA_HOME`을 Liberica 17.0.15로 지정하고 `--no-daemon --console=plain`을 사용했다. Baseline은 기존 두 클래스와 위 HTTP 두 메서드를 한 번에 선택해 74건 통과했다(service 36, integration 22, 홈 8, 오디오 8).
- RED/GREEN 명령은 `./gradlew --no-daemon test --tests 'kr.co.vividnext.sodalive.support.CountryMemberFixturesTest' --console=plain`이다. 기존 통합 테스트의 복제 목록 동작에서는 4건 중 17958만 expected true / actual false로 실패했고(exit 1), KR/JP resolver probe 전환 후 4건 모두 통과했다(exit 0).
- 최종 focused 회귀는 baseline의 네 `--tests` 선택자에 helper를 추가해 78건 통과했다(실패/오류/skip 0). 실제 H2 persistence와 홈/오디오 MockMvc의 기존 국가·인증별 상태/스키마/이벤트 ID assertion을 유지한 채 검증했다. 세 시나리오 모두 PASS다.
- `compileTestKotlin`이 변경 후 실제 실행되어 통과했다. `./gradlew --no-daemon ktlintTestSourceSetCheck -PinternalKtlintGitFilter=... --console=plain`은 변경한 다섯 Kotlin 파일을 줄바꿈으로 지정해 통과했다(빈 위반 보고서). LSP 도구가 없어 `lsp_diagnostics`는 미실행이다.
- production 전체 diff가 시작 시점과 같고 resolver/service test/PRD의 SHA-256도 보존됐다. 저장 재시도 루프와 기존 assertion은 변경하지 않았다. 작업 소유 Gradle/Kotlin/test JVM 및 Redis 잔류 프로세스는 없었다. 전체 테스트, build/check, 독립 리뷰, 임시 증거 삭제는 실행하지 않았고 P1-GATE는 변경하지 않았다. 전체 테스트는 사용자가 `./gradlew test`로 실행한다.
실제 전체 명령·결과·RED 상세: `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/country-fixture.zPNREy/implementation-report.md`.
로그와 단계별 원본 JUnit XML: 같은 경로의 `implementation.igrAaj/{baseline,red,green,focused}-test-results/` 및 `*.log`. Baseline과 최종 회귀에서 Redis 종료 중 Lettuce 재연결 경고가 있었고, Gradle deprecation 경고도 보존했다. 해당 경고로 인한 테스트 실패는 없었다.
- [x] **Phase Gate (P1-GATE): 검증과 독립 리뷰**
- 시작 조건: P1-T1 검증 통과.
- 완료 증거: 변경 test Kotlin compile 및 scoped lint, diff 검사, 독립 리뷰 승인, 소유 임시 자원 정리.
- TDD 예외 사유: Gate는 읽기 전용 검증이다. 기존 테스트/XML과 코드 diff로 판정한다.
- LSP 도구는 현재 미등록이므로 실행 불가를 기록하고 Kotlin test compilation/scoped lint로 대체한다.
- 전체 테스트는 미실행이며 사용자 명령은 `./gradlew test`이다. 전체 성공으로 표시하지 않는다.
- 종료 조건: 세 시나리오와 focused tests 통과, 독립 reviewer 승인, 범위 밖 변경 없음, 검증 기록과 QA 자원 정리 완료 시 즉시 종료한다.
## 검증 기록
- 시작 시 production resolver에 사용자 수정 `45459L`이 있었다. 이번 작업은 해당 파일을 변경하지 않는다.
- 기본 macOS Java 검색은 실패했지만 `$JAVA_HOME/bin/java -version`은 Liberica 17.0.15로 성공했다.
### 부모 세션 재검증 (2026-11-06)
아래 명령으로 캐시된 test 결과를 사용하지 않고 같은 78건을 직접 재실행했다.
```bash
./gradlew --no-daemon test --rerun \
--tests 'kr.co.vividnext.sodalive.support.CountryMemberFixturesTest' \
--tests 'kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest' \
--tests 'kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest' \
--tests 'kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest.shouldApplyEventBannerDetailPolicyForHomeAggregate' \
--tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest.shouldApplyEventBannerDetailPolicyAndKeepAudioResponseSchema' \
--console=plain
```
- 결과: exit 0, `BUILD SUCCESSFUL in 2m 30s`, test task 실제 실행. 원본 `build/test-results/test/TEST-*.xml`의 4/36/22/8/8건 합계 78건, 실패/오류/skip 모두 0을 직접 확인했다.
- 홈/오디오 두 메서드의 MockMvc 요청과 H2 persistence가 실제 실행됐다. 운영 서버 호출이나 외부 DB 접근은 없다.
- 변경된 Kotlin 다섯 파일을 모두 읽고 diff를 대조했다. 기존 assertions와 저장 루프는 그대로이고 세 조건만 공통 helper를 사용한다.
- 운영 resolver SHA-256은 시작과 동일한 `5b562826c7cef1da40406c49c5105a534afb39a64fb915ea2a4ca9ac44dab9df`다.
- `git diff --check` 통과. 테스트 후 Gradle/test/Kotlin JVM과 Redis 프로세스 검색 결과 0건.
- scoped lint 재현 명령(다섯 변경 파일 전체 포함):
```bash
./gradlew --no-daemon ktlintTestSourceSetCheck \
-PinternalKtlintGitFilter=$'src/test/kotlin/kr/co/vividnext/sodalive/support/CountryMemberFixtures.kt\nsrc/test/kotlin/kr/co/vividnext/sodalive/support/CountryMemberFixturesTest.kt\nsrc/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceIntegrationTest.kt\nsrc/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt\nsrc/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt' \
--console=plain
```
- 위 lint는 구현 세션에서 exit 0, `BUILD SUCCESSFUL in 1m 11s`; 원본 로그와 빈 위반 보고서를 부모가 확인했다.
- 기존 종료 시 Lettuce 재연결 경고와 Gradle deprecation 경고가 재현됐다. 테스트 실패는 없으며 이번 범위 밖 코드는 수정하지 않았다.
- 전체 테스트와 LSP는 미실행이다. 전체 테스트 사용자 실행 명령: `./gradlew test`.
### 독립 리뷰 (2026-11-06)
- [fixture 격리 리뷰](reviews/fixture격리-review.md): 명세 준수 및 코드 품질 승인. 차단 사항/수정 권고 없음.
- reviewer가 코드/diff와 원본 RED/GREEN/XML/lint 로그를 직접 대조했다. 부모의 fresh 78건 재실행 결과도 위에 기록했다.
- P1-T1에 적힌 임시 증거 경로는 실행 당시 기록이다. 소유 임시 파일 정리 후에는 위 재현 명령·결과와 리뷰 문서를 영구 기록으로 사용한다. 마지막 JUnit XML/HTML은 `build/test-results/test/`와 `build/reports/tests/test/`에 있으며 다음 Gradle 실행으로 갱신될 수 있다.
- QA 정리 완료: Gradle/test/Kotlin JVM 및 Redis 프로세스 0건을 확인한 뒤 이번 작업의 `country-fixture.zPNREy` 디렉터리(하위 implementation/review 포함)와 임시 notepad만 삭제했다. 두 경로의 부재를 `test ! -e`로 재확인했다. 공유 cache와 작업 트리는 변경하지 않았으며 commit/push는 하지 않았다.
- Gate 판정: 세 시나리오와 focused 78건, scoped lint, 독립 리뷰, 사용자 변경 보존 및 임시 자원 정리 완료. LSP와 전체 suite 미실행 범위는 위와 같다.
@@ -0,0 +1,33 @@
# 국가 강제 지정 테스트 fixture 격리 PRD
## 문서 정보
- 상태: 구현 완료
- 작성일: 2026-11-06
- 결정권자: 사용자. 기존 리뷰의 권장 방향으로 수정 승인.
- 구현 계획: [plan-task.md](plan-task.md)
## 문제와 목표
일반 회원 fixture를 만드는 세 테스트가 운영 국가 강제 지정 ID 목록을 복사한다.
복사본 누락으로 일반 회원이 강제 지정 회원으로 선택될 수 있으며, 운영 목록 추가가 관련 없는 테스트 수정으로 이어진다.
목표는 세 fixture가 운영 ID를 중복 관리하지 않고 일반 회원을 선택하면서 독립된 정책/응답 기대값을 유지하는 것이다.
## 요구사항
| ID | 상태 | 요구사항 | 수용 기준 |
|---|---|---|---|
| COUNTRY-FIXTURE-001 | 확정 | 운영 ID 목록 복제 제거 | 세 fixture가 공통 test-only 판별 함수를 사용하고 목록 복사본이 없다. |
| COUNTRY-FIXTURE-002 | 확정 | 일반 회원과 강제 지정 회원 구분 | 일반 회원 100은 허용하고 KR 16/17958 및 JP 29721은 제외한다. 17958은 기존 통합 테스트 복사본 누락 회귀 사례다. |
| COUNTRY-FIXTURE-003 | 확정 | 독립된 검증 유지 | 기존 강제 지정 우선순위, 정규화, 기본값 및 홈/오디오 응답 assertion을 변경하지 않는다. |
| COUNTRY-FIXTURE-004 | 확정 | 운영 정책 보존 | production 파일/API/DB/config 변경이 없고 사용자의 기존 JP 45459 추가를 보존한다. |
## 구현 결정과 범위 밖
- test-only `Member.hasForcedCountryMapping()`은 서로 다른 KR/JP 헤더에 대해 기존 resolver가 헤더를 그대로 반환하는지 판별한다. 어느 하나라도 다르면 일반 회원 후보에서 제외한다.
- 이 함수는 fixture 선택용이다. 국가 정책과 응답의 기대값 계산에는 사용하지 않는다.
- 기존 세 후보 생성 반복문과 persistence 경로는 유지한다. 신규 dependency, 설정, production 추상화는 만들지 않는다.
- 개별 운영 ID 전체를 테스트에 열거하지 않는다. 회귀 입력 ID는 대표 사례이지 동기화할 운영 목록이 아니다.
- 전체 테스트는 저장소 규칙에 따라 사용자가 실행한다. 이번 작업은 focused policy 및 직접 영향받는 HTTP 테스트를 실행한다.
## 완료 기준
- [x] 세 fixture의 운영 목록 복사본 제거.
- [x] 누락 ID 회귀 RED/GREEN과 일반/강제 회원 판별 검증.
- [x] 기존 policy/DB 및 홈/오디오 MockMvc 검증 통과.
- [x] production 보존, 독립 리뷰, 검증 한계 기록 및 소유 QA 자원 정리.
@@ -0,0 +1,21 @@
# 국가 fixture 격리 리뷰
## 리뷰 정보
- 일자: 2026-11-06
- 범위: P1-T1의 test-only Kotlin 5개 파일과 기존 정책/HTTP 검증 보존.
- 기준: [PRD](../prd.md), [구현 계획](../plan-task.md), working tree diff.
- 리뷰어: 별도 read-only reviewer (`ses_e4f9ca68effegn7SKxWexCfjMl`).
- 판정: 명세 준수 승인, 코드 품질 승인. 확정 발견 사항 없음, 회귀 수정 Task 전환 항목 없음.
## 근거
- 세 호출부에서 운영 ID 복사본을 제거했다. 저장·재시도·index·flush/clear와 기존 assertion은 변경하지 않았다.
- test-only helper는 KR/JP 요청값 유지 여부로 강제 지정을 판별한다. 일반 회원 100, KR 16, JP 29721, 기존 복사본 누락 17958을 독립 리터럴 기대값으로 검증한다.
- helper 결과는 일반 회원 fixture 선택에만 쓰인다. 국가 우선순위·정규화·기본값 및 HTTP 응답의 기대값은 resolver에서 계산하지 않는다.
- production diff는 시작 시점과 동일한 사용자의 JP 45459 추가뿐이다. Resolver SHA-256 `5b562826c7cef1da40406c49c5105a534afb39a64fb915ea2a4ca9ac44dab9df` 일치를 직접 확인했다.
- 원본 XML/log에서 baseline 74 PASS, RED 4 중 17958만 expected true / actual false 실패, GREEN 4 PASS, 최종 78 PASS 및 scoped lint 5파일 PASS를 대조했다.
- 부모도 동일 78건을 `test --rerun`으로 재실행하여 PASS를 확인했다. 상세 명령과 결과는 구현 계획에 보존한다.
## 한계
- reviewer는 읽기 전용으로 검토했으며 테스트를 중복 실행하지 않았다.
- LSP 도구는 미등록이다. 실제 Kotlin test compilation과 scoped lint를 확인했다.
- 전체 suite는 미실행이다. 사용자 실행 명령은 `./gradlew test`.
@@ -0,0 +1,88 @@
# 이벤트 수정 누락 필드 보존 구현 계획
## 목표와 상태
- 작성일: 2026-11-06
- 요구사항: [prd.md](prd.md)
- 상태: 기능 범위 구현 및 검증 완료
- 현재 활성 Goal: 없음
- 기술: Kotlin 1.6.21, Java 17, Spring Boot 2.7.14, JUnit 5, MockMvc, H2.
- 구조: 기존 두 서비스의 대입 조건에 null 가드만 추가한다. API 시그니처와 명시값 처리 방식은 바꾸지 않는다.
- 실행: subagent-driven-development. 기존 작업 디렉터리를 사용하고 worktree는 생성하지 않는다.
### Phase 1: 두 API의 누락 값 보존
- [x] **Task 1.1: 요청 누락 회귀 테스트와 최소 수정 (P1-T1)**
**Objective:** 두 이벤트 수정 API가 누락된 link/isAdult를 보존하면서 명시값은 기존대로 저장한다.
- 시작 조건: EVENT-001~005 확정, 기존 코드·테스트 확인 완료.
- 완료 증거: RED assertion 실패, GREEN 통합 테스트 통과, DB 재조회 검증.
- 범위 밖: 다른 필드 처리 변경, 공개 스키마 변경, 운영 데이터 접근.
**Files:**
- `src/main/kotlin/kr/co/vividnext/sodalive/event/EventService.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerService.kt`
- `src/test/kotlin/kr/co/vividnext/sodalive/event/EventControllerIntegrationTest.kt`
- `src/test/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerControllerIntegrationTest.kt`
- [x] **RED:** 기존 통합 테스트에 관리자 인증 PUT 요청과 DB 재조회 assertion을 추가한다.
- [x] **RED 확인:** 아래 focused 명령으로 기존 link/isAdult가 null로 바뀌어 실패하는지 확인한다.
- [x] **GREEN:** 두 서비스의 link/isAdult 대입 조건에 각 인자 `!= null` 가드를 추가한다.
- [x] **GREEN 확인:** 동일 focused 테스트에서 모든 시나리오가 통과한다.
- [x] **REFACTOR:** 불필요한 리팩터링 없이 기존 테스트를 포함한 직접 영향 회귀를 확인한다.
```bash
export TMPDIR=/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/event-omission.qeHSia/implementer.ZukrpN
export NODE_COMPILE_CACHE=$TMPDIR/node-compile-cache BUN_RUNTIME_TRANSPILER_CACHE_PATH=$TMPDIR/bun-transpiler
./gradlew test --no-daemon --tests 'kr.co.vividnext.sodalive.event.EventControllerIntegrationTest' --tests 'kr.co.vividnext.sodalive.admin.event.banner.AdminEventBannerControllerIntegrationTest'
```
**P1-T1 검증 결과 (2026-11-06):**
- RED: production 변경 전 위 명령 exit 1, 2분 23초. 23개 중 10개 assertion 실패, error/skip 0. 일반 클래스 12개 중 5개, 관리자 클래스 11개 중 5개 실패했다. 각 API의 제목만 수정한 true/false/null 3개 사례에서 기존 링크 대신 null이 저장됐으며 true/false도 null이 됐다. 빈 문자열/공백 링크 2개 사례에서는 링크의 기존 처리 방식은 맞았지만 생략한 isAdult가 true 대신 null이 됐다. 기존 테스트 9개와 명시값 변경 4개는 통과했다.
- GREEN: 네 비교문에만 `link != null &&`, `isAdult != null &&`를 추가한 뒤 동일 명령 exit 0, 3분 35초. 일반 12개와 관리자 11개, 총 23개 모두 통과하고 failure/error/skip은 0이었다. 기존 테스트를 삭제·변경하지 않았고 리팩터링하지 않았다.
- S1~S3는 실제 컨트롤러·보안·서비스와 기존 S3 mock을 사용했다. 모든 요청의 HTTP 200 및 `success=true`를 확인한 뒤 별도 repository 호출로 H2를 재조회했다. 제목만 수정하는 사례는 이미지 3종·isPopup·기간·lang·isActive까지 Event 스냅샷으로 완전 비교했다. 명시값은 Boolean을 반대 값에서 true/false로 각각 변경했고, 빈 문자열/공백 링크는 일반 API의 원문 저장과 관리자 API의 null 저장을 확인했다. 시각은 `2099-01-01T00:00:00`, `2099-01-02T23:59:59`로 고정했다.
- 임시 RED/GREEN 보고서: `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/event-omission.qeHSia/implementer.ZukrpN/p1-t1-red-green.md`. GREEN XML에서도 각 클래스의 test/failure/error/skip 수를 확인했다. LSP 진단 도구가 제공되지 않아 실행하지 못했으며 Gradle의 production/test Kotlin 컴파일이 성공했다. 변경 파일의 컴파일 경고는 없고, 기존 다른 파일의 불필요한 `!!`·unchecked cast·미사용 인자 및 Gradle deprecation 경고는 남아 있다.
- 한계: MockMvc/H2 통합 검증이며 실제 네트워크·운영 MySQL 검증은 아니다. 전체 테스트는 미실행이며 사용자 실행 명령은 `./gradlew test`이다. QA·변경 범위 lint·빌드·리뷰·임시 리소스 정리는 상위 에이전트의 P1-GATE에 남겼다. 커밋·스테이징·푸시와 운영 앱/서비스 실행은 하지 않았다.
## 시나리오 계약
| 시나리오 | PASS 조건 | 검증 표면 |
|---|---|---|
| S1 누락 보존 | 제목만 수정해도 link와 isAdult=true/false/null 및 다른 생략 필드가 DB 재조회에서 유지된다. | 두 실제 컨트롤러의 MockMvc PUT + H2 |
| S2 명시값 반영 | 새 링크, isAdult=true/false가 저장되고 응답 success=true이다. | 두 컨트롤러 + H2 |
| S3 빈값 호환 | 빈 문자열·공백 link는 일반 API에서 원문, 관리자 API에서 null로 저장된다. isAdult 생략값은 유지된다. | 두 컨트롤러 + H2 |
| S4 인접 회귀 | 기존 이벤트 조회·언어·생성 테스트가 통과한다. | 기존 focused 테스트 |
- [x] **Phase Gate (P1-GATE): 검증 및 리뷰**
- 시작 조건: P1-T1 완료.
- 완료 증거: focused 회귀, `./gradlew bootJar`, 변경 diff 리뷰, 검증 한계 기록, 임시 리소스 정리.
- 범위 밖: 전체 테스트 자동 실행, 관련 없는 lint 수정, 커밋/푸시.
- TDD 예외 사유: Gate는 구현이 아닌 검증 단계이다.
- 대체 검증: 실제 HTTP QA가 가능한 로컬 테스트 환경이면 endpoint 응답과 DB 결과를 확인한다. 불가하면 MockMvc와 실제 네트워크 검증을 구분해 기록한다.
- LSP 도구가 제공되지 않으면 그 사실을 명시하고 Kotlin 컴파일과 변경 범위 lint를 대체 증거로 남긴다.
```bash
./gradlew test --tests 'kr.co.vividnext.sodalive.event.*' --tests 'kr.co.vividnext.sodalive.admin.event.banner.*'
./gradlew bootJar
```
## 종료 조건
S1~S4의 관찰 가능한 동작과 focused 테스트가 통과하고, 빌드·변경 리뷰·임시 리소스 정리가 완료되면 즉시 종료한다. 전체 테스트는 저장소 규칙에 따라 사용자가 `./gradlew test`로 실행하며, 미검증 범위를 명시한다.
## 검증 기록
- 초기 조사: 두 서비스에서 link/isAdult만 누락 null을 대입하고, 기존 세 테스트 클래스에 update 검증이 없음을 확인했다.
- 계획 에이전트 후속 호출은 timeout으로 종료됐다. 기존 조사와 승인된 요구사항으로 위 최소 변경을 확정했다.
### P1-GATE 검증 결과 (2026-11-06)
- 실제 HTTP QA: 임시 `EventOmissionHttpQaTest`를 별도 Gradle init script로 테스트 소스에 연결했다. `./gradlew --no-daemon --console=plain -I "$TMPDIR/http-qa.init.gradle" test --tests 'kr.co.vividnext.sodalive.event.EventOmissionHttpQaTest'` exit 0. RANDOM_PORT 서버와 TestRestTemplate, 실제 TokenProvider 관리자 JWT, H2·embedded Redis를 사용했다. MockMvc 요청으로 대체하지 않았다.
- 실제 네트워크: 두 경로 각각 기존 성인 여부 true/false/null에서 제목만 수정, 링크만 수정, isAdult=true, isAdult=false, 빈 링크, 공백 링크를 차례로 전송했다. 총 36개 PUT 모두 HTTP 200, `{"success":true,"message":"수정되었습니다.","data":{},"errorProperty":null}` 응답이며, 6개 조합 모두 DB 재조회 assertion을 통과했다. 제목 외 필드 보존·독립 필드 수정·빈값 호환을 확인했다.
- 1차 Gate: `./gradlew --no-daemon --console=plain test --tests 'kr.co.vividnext.sodalive.event.*' --tests 'kr.co.vividnext.sodalive.admin.event.banner.*' ktlintCheck bootJar --continue` exit 0, 1분 30초. 저장소 39개 및 앞선 임시 QA 컴파일 클래스 2개가 실행됐다. `ktlintCheck` 실제 실행 통과, `bootJar` 생성 및 `build/libs/sodalive-0.0.1-SNAPSHOT.jar` 확인.
- 임시 QA 컴파일 클래스가 일반 실행에도 남은 것을 확인해 해당 소유 class 파일만 제거했다. 이후 임시 init script 없이 아래 명령으로 저장소 테스트만 재실행했다.
```bash
./gradlew --no-daemon --console=plain test --rerun --tests 'kr.co.vividnext.sodalive.event.*' --tests 'kr.co.vividnext.sodalive.admin.event.banner.*' ktlintCheck bootJar --continue
```
- 최종 결과: exit 0, 2분 57초. XML 5개에서 일반 컨트롤러 12 + 관리자 컨트롤러 11 + 언어 통합 7 + 저장소 5 + 서비스 4 = **39개**, 실패·오류·skip 모두 0. XML 시각은 `2026-11-06T04:14:52`~`04:15:19`. 임시 QA class는 최종 결과에 없다. Kotlin test 컴파일 및 테스트는 실제 실행됐고, 동일 입력의 lint·bootJar는 직전 통과 결과를 재사용한 UP-TO-DATE였다.
- 독립 리뷰: `bg_8220e32c`가 PRD EVENT-001~005, 실제 diff, 연결 코드와 테스트, HTTP/DB 원본을 대조해 **APPROVE**. 차단 사항 없음. [리뷰 기록](reviews/event-omission-review.md).
- 진단: LSP 도구가 제공되지 않아 미실행이며 Kotlin 컴파일·ktlintCheck로 대체했다. `git diff --check` 통과. 기존 무관 코드 경고와 테스트 context 종료 경고는 이번 수정 범위 밖이다.
- 검증 한계: 전체 테스트와 운영 MySQL·실제 관리자 UI는 미검증이다. 전체 검증은 사용자가 `./gradlew test`로 실행한다. 운영 데이터 복원·접속 및 커밋·푸시는 하지 않았다.
- 정리: Gradle wrapper/daemon/test worker·embedded Redis 프로세스가 남아 있지 않음을 확인했다. 이번 작업 소유 `event-omission.qeHSia` 루트와 두 자식 루트, 임시 HTTP QA 소스·init script·로그를 삭제했고, 루트·임시 class·임시 XML 부재 검사 `CLEANUP_PASS`를 확인했다. 앞서 기록된 임시 보고서 경로는 실행 당시 경로이며 현재는 위 문서와 리뷰에 요약 근거를 보존한다. 공유 캐시·기존 worktree·운영 리소스는 건드리지 않았다.
@@ -0,0 +1,27 @@
# 이벤트 수정 누락 필드 보존 PRD
## 문서 정보
- 작성일: 2026-11-06
- 상태: 구현 및 기능 범위 검증 완료
- 결정권자: 사용자. 두 API 모두 파라미터 미전송 시 기존 값을 유지하도록 명시 승인.
- 구현 계획: [plan-task.md](plan-task.md)
## 문제와 목표
`PUT /event`, `PUT /admin/event/banner`에서 `link`, `isAdult`를 생략하면 기존 값이 null로 덮어써진다. 부분 수정 시 생략된 값을 보존하고, 명시적으로 전달한 값은 기존 방식으로 반영한다.
## 요구사항과 수용 기준
| ID | 상태 | 요구사항 | 수용 기준 |
|---|---|---|---|
| EVENT-001 | 확정 | 두 API의 link 생략은 변경하지 않는다. | 제목만 수정한 뒤 DB 재조회에서 기존 링크가 유지된다. |
| EVENT-002 | 확정 | 두 API의 isAdult 생략은 변경하지 않는다. | 기존 true, false, null 각각이 유지된다. |
| EVENT-003 | 확정 | 명시한 값의 기존 동작을 유지한다. | 새 링크 및 isAdult=true/false가 저장된다. |
| EVENT-004 | 확정 | 빈 링크의 기존 API별 동작을 유지한다. | /event는 빈 문자열·공백을 그대로 저장하고 관리자 API는 null로 저장한다. |
| EVENT-005 | 확정 | 다른 생략 필드와 API 계약을 유지한다. | 이미지·isPopup·기간·lang·isActive는 제목만 수정해도 유지되고 응답 성공 형식과 ADMIN 권한은 변하지 않는다. |
## 범위 밖
- 새 삭제 파라미터, JSON PATCH, DTO·스키마 변경, 서비스 통합/리팩터링.
- 기존 데이터 복원, 관리자 프런트엔드 변경, 운영 DB 접근, 날짜 처리 변경.
- isAdult를 null로 초기화하는 새 기능. 생략은 이제 보존이며 true/false 전송은 기존대로 지원한다.
## 검증 경계
기존 SpringBootTest/MockMvc와 H2 재조회로 요청 바인딩부터 저장까지 검증한다. S3는 기존 mock을 유지한다. 실제 관리자 UI 및 운영 MySQL 검증과 전체 테스트는 별도이며, 전체 테스트 실행 명령은 `./gradlew test`이다.
@@ -0,0 +1,29 @@
# 이벤트 수정 누락 필드 보존 리뷰
## 리뷰 정보
- 일자: 2026-11-06
- 대상: 현재 working tree의 서비스 2개·통합 테스트 2개, P1-T1/P1-GATE
- 기준: [PRD](../prd.md), [구현 계획](../plan-task.md)
- 리뷰어: 독립 검토 에이전트 `bg_8220e32c` 및 상위 에이전트 실행 검증
- 상태: 판정 완료, APPROVE
## 검토 근거
- `EventService.update`, `AdminEventBannerService.update`의 production 변경은 null 가드 네 곳뿐이다.
- 두 컨트롤러의 선택 파라미터, ADMIN 권한, 응답 구조, Event 엔티티·저장소를 대조했다.
- 기존 통합 테스트에 추가한 14개 사례는 실제 컨트롤러와 서비스 및 별도 H2 재조회로 누락·명시값·빈값 처리를 확인한다.
- nullable Boolean 세 상태 및 다른 생략 필드는 Event 스냅샷으로 비교한다. 명시한 false도 별도 값으로 반영한다.
- 빈 링크는 기존대로 일반 API에서 원문을 저장하고 관리자 API에서 null로 저장한다.
## 실행 증거
| 검증 | 결과 |
|---|---|
| TDD RED | 구현자 기록: 23개 중 의도한 assertion 실패 10개. 리뷰어가 RED 원본을 별도로 재실행하지는 않았다. |
| focused GREEN | 리뷰어가 원본 XML의 일반 12개·관리자 11개, 실패·오류·skip 0을 확인했다. |
| 실제 HTTP/DB | 부모가 실제 관리자 JWT로 36개 PUT·6개 DB 상태 조합을 실행했고 리뷰어가 QA 소스와 XML을 대조했다. 모두 PASS. |
| 최종 인접 회귀 | 부모가 임시 QA class 제거 후 저장소 5개 클래스 39개를 재실행, 실패·오류·skip 0 확인. |
| 빌드·정적 검사 | 부모의 Kotlin 컴파일, ktlintCheck, bootJar, diff 공백 검사 통과. |
## 판정
확정 발견 사항 없음. EVENT-001~005에 부합하고 API 스키마·권한 변경이나 범위 확장이 없다. 독립 리뷰의 명세 준수 및 코드 품질 판정은 APPROVE다.
전체 테스트, 운영 MySQL, 실제 관리자 UI, LSP는 미검증이다. 전체 테스트는 사용자 실행 항목이며 위 검증을 전체 성공으로 표현하지 않는다.
+3 -4
View File
@@ -20,10 +20,9 @@
- 각 task에는 TDD 절차를 명시한다. 기본 형식은 `RED: 실패 테스트 작성/실패 확인`, `GREEN: 최소 구현/통과 확인`, `REFACTOR: 정리/회귀 확인`을 포함한다.
- 테스트 작성이 현실적으로 불가능한 task는 `TDD 예외 사유`와 `대체 검증 방법`을 task에 명시한다.
- 각 phase 또는 task에는 실행 명령, 기대 결과, 수동 확인 항목 등 검증 기준을 함께 작성한다.
- 각 task의 검증 기준에는 focused test와 직접 영향받는 회귀 명령을 우선 명시한다. 전체 회귀는 광범위한 공통 코드 변경,
여러 domain/phase 변경, release/final Gate에서 전체 상태 증거를 별도로 요구하는 경우, targeted test만으로 영향 범위를 판단할
수 없는 경우 또는 사용자 요청일 때만 포함한다.
- 계획에서 전체 회귀를 조건부로 두면 실행 조건, 생략 시 기록할 근거와 대체 focused/영향 범위 회귀 명령을 함께 명시한다.
- 각 Task와 Gate의 에이전트 검증 명령은 구현 기능의 focused test와 직접 영향받는 기능별 회귀로 한정한다. 전체 테스트는 사용자 실행 항목으로 분리한다.
- 공통 코드·여러 domain/phase 변경·release/final Gate를 자동 전체 테스트 실행 조건으로 작성하지 않는다. `check`/`build`/`clean build`의 간접 전체 테스트도 피하고 필요한 빌드는 `bootJar`로 명시한다.
- 계획과 결과 보고에 기능별 실행 명령·결과, 전체 테스트 미실행 사실 및 사용자 실행 명령 `./gradlew test`를 남긴다. 기능 범위 완료와 사용자 전체 검증 상태를 구분하며 기존 timeout·실패 이력은 보존한다.
- 구현 완료 즉시 해당 task 체크박스를 `- [x]`로 갱신한다.
- 작업 도중 범위가 변경되면 계획 문서 체크리스트를 먼저 업데이트한 뒤 구현한다.
- 결과 보고 시 개별 task 검증 기록(무엇/왜/어떻게, 실행 명령, 결과)은 해당 task 아래에 한국어로 남긴다.
+20 -12
View File
@@ -3,25 +3,33 @@
## 실행 기준
- 아래 명령은 저장소 루트(`/Users/klaus/Develop/sodalive/Server/sodalive`)에서 실행한다.
- 변경 범위에 맞는 최소 명령으로 검증하고, 결과는 계획 문서 하단 검증 기록에 남긴다.
- 검증은 focused test → 영향받는 package/feature 회귀 → 전체 회귀 순서로 범위를 넓힌다.
- 전체 회귀 테스트(`./gradlew test`, `./gradlew check`, 전체 `build`)는 실행 시간이 길므로 매 Task나 일반적인 작은 변경에서
관성적으로 실행하지 않는다.
- 공통 인증·보안·예외·설정·serialization처럼 영향 범위가 넓은 코드 변경, 여러 domain/phase에 걸친 변경, release/final Gate에서
전체 상태 증거를 별도로 요구하는 경우, targeted test만으로 영향 범위를 판단할 수 없는 실패 또는 사용자 명시 요청일 때만
전체 회귀를 실행한다.
- 전체 회귀를 생략하면 실행하지 않은 사실, 생략 근거와 대신 실행한 focused/영향 범위 회귀 명령을 계획 문서에 기록한다.
- 에이전트는 구현 중인 기능의 focused test와 직접 영향받는 package/feature 회귀만 각각 실행한다. `--tests`로 대상 클래스·메서드·패키지를 명시한다.
- 전체 테스트는 10분 실행 제한으로 완료되지 않는 경우가 많으므로 사용자가 직접 실행한다. 에이전트는 무필터 `./gradlew test`를 자동 실행하거나 timeout 후 재시도하지 않는다.
- 공통 코드 변경, 여러 domain/phase 변경, release/final Gate라도 자동으로 전체 테스트로 확대하지 않는다. 영향받는 기능을 식별해 각각 검증하고 확인하지 못한 범위를 보고한다.
- `./gradlew check`, `./gradlew build`, `./gradlew clean build`도 전체 테스트를 포함하므로 에이전트가 자동 실행하지 않는다. 빌드 산출물 검증이 필요하면 `./gradlew bootJar`를 사용하고 기능별 테스트와 구분해 기록한다.
- 계획 문서와 최종 보고에는 실행한 기능별 테스트 명령·결과, 전체 테스트 미실행 사실, 사용자 실행 명령 `./gradlew test`를 남긴다. 기능별 성공을 전체 테스트 성공으로 표현하지 않는다.
- 전체 테스트의 사용자 실행 대기는 기능 범위 완료를 막지 않는다. 기능별 테스트 실패나 미검증 위험은 별도로 명시하며 실패를 무시하거나 테스트를 완화하지 않는다.
## Build/Lint/Test
아래는 사용 가능한 명령 목록이며 모든 변경에서 전부 실행하라는 의미가 아니다.
에이전트는 변경 범위에 필요한 명령만 선택한다. 아래 `--tests`는 기존 홈 기능의 예시이며 실제 작업 대상에 맞게 바꾼다.
```bash
./gradlew tasks --all
./gradlew bootRun
./gradlew build
./gradlew clean build
./gradlew test
./gradlew check
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.*'
./gradlew bootJar
./gradlew ktlintCheck
./gradlew ktlintFormat
```
사용자가 직접 실행하는 전체 검증 명령:
```bash
./gradlew test
./gradlew check
./gradlew build
./gradlew clean build
```
위 명령은 목적에 맞게 하나를 선택한다. 전체 테스트 확인만 필요하면 `./gradlew test`로 충분하다.
+3 -3
View File
@@ -29,8 +29,8 @@
전에는 goal이나 Phase Gate를 완료 처리하지 않는다.
- 변경 중: 완료된 Task와 기존 검증 기록은 되돌리거나 삭제하지 않는다. 후속 리뷰에서 발견한 회귀는 별도 Task/Goal로 누적한다.
- 변경 후: 변경 범위의 focused test를 우선 실행하고, 공유 경계와 직접 영향받는 package/feature 회귀까지만 단계적으로 확장한다.
- 변경 후: 전체 회귀 테스트는 공통 인증·보안·예외·설정 등 광범위한 변경, 여러 domain/phase 변경, release/final Gate에서 전체
상태 증거를 별도로 요구하는 경우, targeted test만으로 영향 범위를 판단할 수 없는 실패 또는 사용자 명시 요청일 때만 실행한다.
- 변경 후: 전체 회귀 테스트를 실행하지 않으면 생략 사실과 근거, 대신 실행한 focused/영향 범위 회귀 명령을 검증 기록에 남긴다.
- 변경 후: 전체 테스트는 사용자가 직접 수행한다. 공통 코드·여러 domain/phase 변경이나 release/final Gate에서도 에이전트는 관련 기능을 각각 검증하며 전체 테스트로 자동 확대하지 않는다.
- 변경 후: 전체 테스트를 포함하는 무필터 `test`, `check`, `build`, `clean build`를 자동 실행하지 않는다. 필요한 빌드 검증은 `bootJar`로 분리한다.
- 변경 후: 검증 기록과 최종 보고에 실행한 기능별 명령·결과, 미검증 범위와 사용자용 전체 테스트 명령 `./gradlew test`를 남긴다. 전체 테스트 사용자 실행 대기만으로 기능 범위 완료를 막거나 전체 성공으로 표시하지 않는다.
- 변경 후: 각 task의 검증 결과는 해당 task 아래에 무엇을, 왜, 어떻게 검증했는지, 실행 명령과 결과를 한국어로 누적 기록한다.
- 변경 후: 여러 task/phase에 걸친 회귀 검증, 전체 빌드/포맷 검증, 문서 변경 범위 확인처럼 전체에 해당하는 검증은 계획 문서 하단의 검증 기록에 누적한다.
+3 -4
View File
@@ -50,10 +50,9 @@
- 계약: 제공되지 않은 endpoint, DTO, enum, 오류 status/key와 validation 상한을 추정하지 않는다.
- backend 구현 전 UI 확인이 필요하면 제공 계약 기반 explicit mock mode를 사용하고 실제 404 자동 fallback·production mock을 금지하며 mock/server 완료 증거를 분리한다.
- 구현: 모든 구현 Task는 아래 `RED → GREEN → REFACTOR` 순서를 체크박스에 명시하고, 가장 작은 실패 test에서 시작해 최소 구현으로 통과시킨다.
- 검증: focused test에서 시작해 영향받는 package/feature 회귀로 확장한다. 전체 회귀는 공통 경계 변경, 여러 domain/phase 변경,
release/final Gate에서 전체 상태 증거를 별도로 요구하는 경우, targeted test만으로 영향 범위를 판단할 수 없는 실패 또는 사용자
명시 요청이 있을 때만 실행한다.
- 전체 회귀를 생략하면 생략 사실·근거와 대신 실행한 focused/영향 범위 회귀 명령을 Progress에 기록한다.
- 검증: 에이전트는 구현 기능의 focused test와 직접 영향받는 package/feature 회귀만 `--tests`로 지정해 각각 실행한다. 공통 경계·여러 domain/phase 변경과 release/final Gate도 전체 테스트 자동 실행 사유가 아니다.
- 전체 테스트는 사용자가 직접 실행한다. 에이전트는 무필터 `test` 및 전체 테스트를 포함하는 `check`/`build`/`clean build`를 실행하지 않으며, 필요한 빌드 검증은 `bootJar`로 분리한다.
- Progress와 최종 보고에 실행한 기능별 명령·결과, 전체 테스트 미실행 및 미검증 범위, 사용자 실행 명령 `./gradlew test`를 기록한다. 전체 테스트 사용자 실행 대기는 기능 범위 완료와 별도 상태로 관리한다.
## Task TDD 작성 규칙
@@ -167,7 +167,7 @@ class AdminEventBannerService(
)
}
if (event.link != link) {
if (link != null && event.link != link) {
event.link = if (link.isNullOrBlank()) null else link
}
@@ -179,7 +179,7 @@ class AdminEventBannerService(
event.isPopup = isPopup
}
if (isAdult != event.isAdult) {
if (isAdult != null && isAdult != event.isAdult) {
event.isAdult = isAdult
}
@@ -228,7 +228,7 @@ class EventService(
)
}
if (event.link != link) {
if (link != null && event.link != link) {
event.link = link
}
@@ -240,7 +240,7 @@ class EventService(
event.isPopup = isPopup
}
if (isAdult != event.isAdult) {
if (isAdult != null && isAdult != event.isAdult) {
event.isAdult = isAdult
}
@@ -15,6 +15,9 @@ class ChannelDonationMessage(
@Column(columnDefinition = "TEXT")
var additionalMessage: String? = null
) : BaseEntity() {
@Column(nullable = false)
var isHidden: Boolean = false
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
var member: Member? = null
@@ -1921,6 +1921,16 @@ class SodaMessageSource {
Lang.KO to "%s캔을 비밀후원하셨습니다.",
Lang.EN to "You secretly sponsored %s cans.",
Lang.JA to "%sCANをシークレット支援しました。"
),
"explorer.channel_donation.anonymous.nickname" to mapOf(
Lang.KO to "익명",
Lang.EN to "Anonymous",
Lang.JA to "匿名"
),
"explorer.channel_donation.anonymous.message" to mapOf(
Lang.KO to "크리에이터에게만 보여요",
Lang.EN to "Only visible to the creator",
Lang.JA to "クリエイターにのみ表示されます"
)
)
@@ -3,7 +3,7 @@ package kr.co.vividnext.sodalive.member.contentpreference
import kr.co.vividnext.sodalive.member.Member
private val FORCED_KR_MEMBER_IDS = setOf(16L, 17L, 17958L, 44144L)
private val FORCED_JP_MEMBER_IDS = setOf(29721L, 32050L, 37543L, 40850L)
private val FORCED_JP_MEMBER_IDS = setOf(29721L, 32050L, 37543L, 40850L, 45459L)
fun resolveCountryCodeWithForcedMapping(member: Member?, requestCountryCode: String?): String {
val memberId = member?.id
@@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacade
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
@@ -18,6 +19,16 @@ class CreatorChannelDonationController(
private val creatorChannelDonationFacade: CreatorChannelDonationFacade,
private val langContext: LangContext
) {
@DeleteMapping("/{creatorId}/donations/{donationId}")
fun hideDonation(
@PathVariable creatorId: Long,
@PathVariable donationId: Long,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
): ApiResponse<Unit> {
creatorChannelDonationFacade.hide(creatorId, donationId, requireMember(member))
return ApiResponse.ok<Unit>()
}
@GetMapping("/{creatorId}/donations")
fun getDonationTab(
@PathVariable creatorId: Long,
@@ -4,6 +4,7 @@ import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto.CreatorChannelDonationTabResponse
import kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryService
import kr.co.vividnext.sodalive.v2.creator.channel.donation.application.HideCreatorChannelDonationService
import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@@ -13,8 +14,14 @@ import java.time.LocalDateTime
@Transactional(readOnly = true)
class CreatorChannelDonationFacade(
private val creatorChannelDonationQueryService: CreatorChannelDonationQueryService,
private val creatorLocalizedNameService: CreatorLocalizedNameService
private val creatorLocalizedNameService: CreatorLocalizedNameService,
private val hideCreatorChannelDonationService: HideCreatorChannelDonationService
) {
@Transactional
fun hide(creatorId: Long, donationId: Long, actor: Member) {
hideCreatorChannelDonationService.hide(creatorId, donationId, actor)
}
fun getDonationTab(
creatorId: Long,
viewer: Member,
@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.extensions.toUtcIso
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonation
@@ -48,20 +49,26 @@ data class MemberDonationRankingResponse(
}
data class CreatorChannelDonationResponse(
@JsonInclude(JsonInclude.Include.NON_NULL)
val donationId: Long?,
val nickname: String,
val profileImageUrl: String,
val can: Int,
val message: String,
val createdAtUtc: String
val createdAtUtc: String,
@JsonProperty("isSecret")
val isSecret: Boolean
) {
companion object {
fun from(donation: CreatorChannelDonation): CreatorChannelDonationResponse {
return CreatorChannelDonationResponse(
donationId = donation.donationId,
nickname = donation.nickname,
profileImageUrl = donation.profileImageUrl,
can = donation.can,
message = donation.message,
createdAtUtc = donation.createdAt.toUtcIso()
createdAtUtc = donation.createdAt.toUtcIso(),
isSecret = donation.isSecret
)
}
}
@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.home.dto
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.v2.api.creator.channel.common.dto.CreatorChannelAudioContentResponse
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
@@ -109,20 +110,26 @@ data class CreatorChannelLiveResponse(
}
data class CreatorChannelDonationResponse(
@JsonInclude(JsonInclude.Include.NON_NULL)
val donationId: Long?,
val nickname: String,
val profileImageUrl: String,
val can: Int,
val message: String,
val createdAtUtc: String
val createdAtUtc: String,
@JsonProperty("isSecret")
val isSecret: Boolean
) {
companion object {
fun from(donation: CreatorChannelDonation): CreatorChannelDonationResponse {
return CreatorChannelDonationResponse(
donationId = donation.donationId,
nickname = donation.nickname,
profileImageUrl = donation.profileImageUrl,
can = donation.can,
message = donation.message,
createdAtUtc = donation.createdAt.toUtcIso()
createdAtUtc = donation.createdAt.toUtcIso(),
isSecret = donation.isSecret
)
}
}
@@ -68,7 +68,7 @@ class DefaultCreatorChannelDonationQueryRepository(
return queryFactory
.select(channelDonationMessage.id.count())
.from(channelDonationMessage)
.where(channelDonationCondition(creatorId, viewerId, now))
.where(channelDonationCondition(creatorId, now))
.fetchOne()
?.toInt()
?: 0
@@ -87,19 +87,21 @@ class DefaultCreatorChannelDonationQueryRepository(
.select(
Projections.constructor(
CreatorChannelDonationRecord::class.java,
channelDonationMessage.id,
channelDonationMessage.member.id,
donorName.displayName,
channelDonationMessage.member.profileImage,
channelDonationMessage.can,
channelDonationMessage.additionalMessage,
channelDonationMessage.createdAt,
channelDonationMessage.isSecret,
donorName.name.id.isNotNull
)
)
.from(channelDonationMessage)
.join(channelDonationMessage.member, member)
.leftJoin(donorName.name).on(donorName.condition)
.where(channelDonationCondition(creatorId, viewerId, now))
.where(channelDonationCondition(creatorId, now))
.orderBy(channelDonationMessage.createdAt.desc(), channelDonationMessage.id.desc())
.offset(offset)
.limit(limit.toLong())
@@ -108,22 +110,12 @@ class DefaultCreatorChannelDonationQueryRepository(
private fun channelDonationCondition(
creatorId: Long,
viewerId: Long,
now: LocalDateTime
): BooleanExpression {
val monthRange = queryPolicy.currentKstMonthRange(now)
return channelDonationMessage.creator.id.eq(creatorId)
.and(channelDonationMessage.isHidden.isFalse)
.and(channelDonationMessage.createdAt.goe(monthRange.startInclusiveUtc))
.and(channelDonationMessage.createdAt.lt(monthRange.endExclusiveUtc))
.and(donationVisibilityCondition(creatorId, viewerId))
}
private fun donationVisibilityCondition(creatorId: Long, viewerId: Long): BooleanExpression {
return if (creatorId == viewerId) {
channelDonationMessage.id.isNotNull
} else {
channelDonationMessage.isSecret.isFalse
.or(channelDonationMessage.member.id.eq(viewerId))
}
}
}
@@ -0,0 +1,30 @@
package kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.explorer.profile.channelDonation.QChannelDonationMessage.channelDonationMessage
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.HideCreatorChannelDonationPort
import org.springframework.stereotype.Repository
import org.springframework.transaction.annotation.Transactional
@Repository
class DefaultHideCreatorChannelDonationRepository(
private val queryFactory: JPAQueryFactory
) : HideCreatorChannelDonationPort {
@Transactional
override fun hideOwnedDonation(creatorId: Long, donationId: Long): Boolean {
val ownedDonation = channelDonationMessage.id.eq(donationId)
.and(channelDonationMessage.creator.id.eq(creatorId))
val updatedCount = queryFactory
.update(channelDonationMessage)
.set(channelDonationMessage.isHidden, true)
.set(channelDonationMessage.updatedAt, channelDonationMessage.updatedAt)
.where(ownedDonation, channelDonationMessage.isHidden.isFalse)
.execute()
return updatedCount > 0 || queryFactory
.selectOne()
.from(channelDonationMessage)
.where(ownedDonation)
.fetchFirst() != null
}
}
@@ -68,7 +68,7 @@ class CreatorChannelDonationQueryService(
return CreatorChannelDonationTab(
donationCount = queryPort.countChannelDonations(creatorId, viewerId, now),
rankings = findRankings(creator, viewerId),
donations = queryPolicy.limitItems(fetchedDonations, donationPage).map { it.toDomain() },
donations = queryPolicy.limitItems(fetchedDonations, donationPage).map { it.toDomain(creatorId, viewer, lang) },
page = donationPage,
hasNext = queryPolicy.hasNext(fetchedDonations, donationPage)
)
@@ -95,14 +95,32 @@ class CreatorChannelDonationQueryService(
).map { it.toDomain() }
}
private fun CreatorChannelDonationRecord.toDomain() = CreatorChannelDonation(
memberId = memberId,
nickname = if (isLocalizedName) nickname else nickname.removeDeletedNicknamePrefix(),
profileImageUrl = profileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(),
can = can,
message = message.orEmpty(),
createdAt = createdAt
)
private fun CreatorChannelDonationRecord.toDomain(creatorId: Long, viewer: Member, lang: Lang): CreatorChannelDonation {
val viewerId = viewer.id!!
val isMasked = isSecret && viewerId != creatorId && viewerId != memberId
return CreatorChannelDonation(
donationId = donationId.takeIf { viewer.role == MemberRole.CREATOR && viewerId == creatorId },
memberId = memberId,
nickname = when {
isMasked -> messageSource.getMessage("explorer.channel_donation.anonymous.nickname", lang).orEmpty()
isLocalizedName -> nickname
else -> nickname.removeDeletedNicknamePrefix()
},
profileImageUrl = if (isMasked) {
"$cloudFrontHost/profile/anonymous-profile.png"
} else {
profileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl()
},
can = can,
message = if (isMasked) {
messageSource.getMessage("explorer.channel_donation.anonymous.message", lang).orEmpty()
} else {
message.orEmpty()
},
createdAt = createdAt,
isSecret = isMasked
)
}
private fun CreatorChannelDonationRankingRecord.toDomain() = CreatorChannelDonationRanking(
userId = userId,
@@ -0,0 +1,23 @@
package kr.co.vividnext.sodalive.v2.creator.channel.donation.application
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.HideCreatorChannelDonationPort
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class HideCreatorChannelDonationService(
private val hideCreatorChannelDonationPort: HideCreatorChannelDonationPort
) {
@Transactional
fun hide(creatorId: Long, donationId: Long, actor: Member) {
if (actor.role != MemberRole.CREATOR || actor.id != creatorId) {
throw SodaException(messageKey = "common.error.access_denied")
}
if (!hideCreatorChannelDonationPort.hideOwnedDonation(creatorId, donationId)) {
throw SodaException(messageKey = "common.error.invalid_request")
}
}
}
@@ -19,10 +19,12 @@ data class CreatorChannelDonationRanking(
)
data class CreatorChannelDonation(
val donationId: Long?,
val memberId: Long,
val nickname: String,
val profileImageUrl: String,
val can: Int,
val message: String,
val createdAt: LocalDateTime
val createdAt: LocalDateTime,
val isSecret: Boolean
)
@@ -35,11 +35,13 @@ data class CreatorChannelDonationCreatorRecord(
)
data class CreatorChannelDonationRecord(
val donationId: Long,
val memberId: Long,
val nickname: String,
val profileImagePath: String?,
val can: Int,
val message: String?,
val createdAt: LocalDateTime,
val isSecret: Boolean,
val isLocalizedName: Boolean = false
)
@@ -0,0 +1,5 @@
package kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out
interface HideCreatorChannelDonationPort {
fun hideOwnedDonation(creatorId: Long, donationId: Long): Boolean
}
@@ -195,12 +195,14 @@ class DefaultCreatorChannelHomeQueryRepository(
.select(
Projections.constructor(
CreatorChannelDonationRecord::class.java,
channelDonationMessage.id,
channelDonationMessage.member.id,
donorName.displayName,
channelDonationMessage.member.profileImage,
channelDonationMessage.can,
channelDonationMessage.additionalMessage.coalesce(""),
channelDonationMessage.createdAt,
channelDonationMessage.isSecret,
donorName.name.id.isNotNull
)
)
@@ -209,9 +211,9 @@ class DefaultCreatorChannelHomeQueryRepository(
.leftJoin(donorName.name).on(donorName.condition)
.where(
channelDonationMessage.creator.id.eq(creatorId),
channelDonationMessage.isHidden.isFalse,
channelDonationMessage.createdAt.goe(start),
channelDonationMessage.createdAt.lt(end),
donationVisibilityCondition(creatorId, viewerId)
channelDonationMessage.createdAt.lt(end)
)
.orderBy(channelDonationMessage.createdAt.desc(), channelDonationMessage.id.desc())
.limit(limit.toLong())
@@ -737,16 +739,6 @@ class DefaultCreatorChannelHomeQueryRepository(
.not()
}
private fun donationVisibilityCondition(creatorId: Long, viewerId: Long?): BooleanExpression? {
return if (viewerId == null) {
channelDonationMessage.isSecret.isFalse
} else if (viewerId == creatorId) {
null
} else {
channelDonationMessage.isSecret.isFalse.or(channelDonationMessage.member.id.eq(viewerId))
}
}
private fun seriesContentStats(
seriesIds: List<Long>,
now: LocalDateTime,
@@ -102,7 +102,8 @@ class CreatorChannelHomeQueryService(
effectiveViewerGender = effectiveViewerGender
)?.toDomain(),
latestAudioContent = latestAudioContent,
channelDonations = queryPort.findChannelDonations(creatorId, viewerId, now, lang = lang).map { it.toDomain() },
channelDonations = queryPort.findChannelDonations(creatorId, viewerId, now, lang = lang)
.map { it.toDomain(creatorId, viewer, lang) },
notices = communityQueryService.findHomeCommunityPosts(
creatorId = creatorId,
viewerId = viewerId,
@@ -196,14 +197,32 @@ class CreatorChannelHomeQueryService(
isRented = isRented
)
private fun CreatorChannelDonationRecord.toDomain() = CreatorChannelDonation(
memberId = memberId,
nickname = if (isLocalizedName) nickname else nickname.removeDeletedNicknamePrefix(),
profileImageUrl = profileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(),
can = can,
message = message,
createdAt = createdAt
)
private fun CreatorChannelDonationRecord.toDomain(creatorId: Long, viewer: Member, lang: Lang): CreatorChannelDonation {
val viewerId = viewer.id!!
val isMasked = isSecret && viewerId != creatorId && viewerId != memberId
return CreatorChannelDonation(
donationId = donationId.takeIf { viewer.role == MemberRole.CREATOR && viewerId == creatorId },
memberId = memberId,
nickname = when {
isMasked -> messageSource.getMessage("explorer.channel_donation.anonymous.nickname", lang).orEmpty()
isLocalizedName -> nickname
else -> nickname.removeDeletedNicknamePrefix()
},
profileImageUrl = if (isMasked) {
"$cloudFrontHost/profile/anonymous-profile.png"
} else {
profileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl()
},
can = can,
message = if (isMasked) {
messageSource.getMessage("explorer.channel_donation.anonymous.message", lang).orEmpty()
} else {
message
},
createdAt = createdAt,
isSecret = isMasked
)
}
private fun CreatorChannelScheduleRecord.toDomain() = CreatorChannelSchedule(
scheduledAt = scheduledAt,
@@ -43,12 +43,14 @@ data class CreatorChannelLive(
)
data class CreatorChannelDonation(
val donationId: Long?,
val memberId: Long,
val nickname: String,
val profileImageUrl: String,
val can: Int,
val message: String,
val createdAt: LocalDateTime
val createdAt: LocalDateTime,
val isSecret: Boolean
)
data class CreatorChannelSchedule(
@@ -114,12 +114,14 @@ data class CreatorChannelAudioContentRecord(
)
data class CreatorChannelDonationRecord(
val donationId: Long,
val memberId: Long,
val nickname: String,
val profileImagePath: String?,
val can: Int,
val message: String,
val createdAt: LocalDateTime,
val isSecret: Boolean,
val isLocalizedName: Boolean = false
)
@@ -11,6 +11,9 @@ import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.NullSource
import org.junit.jupiter.params.provider.ValueSource
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
@@ -24,6 +27,7 @@ import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import java.io.InputStream
@@ -117,6 +121,72 @@ class AdminEventBannerControllerIntegrationTest @Autowired constructor(
.andExpect(jsonPath("$.data[*].lang").value(containsInAnyOrder("KO", "EN", "JA")))
}
@ParameterizedTest
@NullSource
@ValueSource(booleans = [true, false])
@DisplayName("관리자 이벤트 제목만 수정하면 생략한 링크와 성인 여부 등 나머지 필드를 보존한다")
fun shouldPreserveOmittedFieldsWhenUpdatingOnlyTitle(isAdult: Boolean?) {
val event = saveEventForUpdate(isAdult)
mockMvc.perform(updateRequest(event).param("title", "updated"))
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
assertEquals(event.copy(title = "updated"), eventRepository.findById(requireNotNull(event.id)).orElseThrow())
}
@ParameterizedTest
@ValueSource(booleans = [true, false])
@DisplayName("관리자 이벤트 수정은 명시한 링크와 성인 여부를 저장한다")
fun shouldPersistExplicitLinkAndAdultFlag(isAdult: Boolean) {
val event = saveEventForUpdate(!isAdult)
mockMvc.perform(
updateRequest(event)
.param("link", "https://event.test/updated")
.param("isAdult", isAdult.toString())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
assertEquals(
event.copy(link = "https://event.test/updated", isAdult = isAdult),
eventRepository.findById(requireNotNull(event.id)).orElseThrow()
)
}
@ParameterizedTest
@ValueSource(strings = ["", " "])
@DisplayName("관리자 이벤트 수정은 빈 링크를 null로 저장하고 생략한 성인 여부를 보존한다")
fun shouldClearBlankLinkWithoutChangingOmittedAdultFlag(link: String) {
val event = saveEventForUpdate()
mockMvc.perform(updateRequest(event).param("link", link))
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
assertEquals(event.copy(link = null), eventRepository.findById(requireNotNull(event.id)).orElseThrow())
}
private fun updateRequest(event: Event) =
put("/admin/event/banner")
.param("id", event.id.toString())
.with(user("admin").roles("ADMIN"))
private fun saveEventForUpdate(isAdult: Boolean? = true): Event {
return eventRepository.saveAndFlush(
activeEvent("original", Lang.EN).apply {
popupImage = "original-popup.png"
link = "https://event.test/original"
this.isAdult = isAdult
isPopup = true
isActive = false
startDate = LocalDateTime.of(2099, 1, 1, 0, 0, 0)
endDate = LocalDateTime.of(2099, 1, 2, 23, 59, 59)
}
)
}
private fun createRequest() =
multipart("/admin/event/banner")
.file(MockMultipartFile("thumbnail", "thumbnail.png", "image/png", "thumbnail".toByteArray()))
@@ -5,19 +5,25 @@ import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.NullSource
import org.junit.jupiter.params.provider.ValueSource
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.test.annotation.DirtiesContext
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import java.time.LocalDateTime
@@ -108,6 +114,70 @@ class EventControllerIntegrationTest @Autowired constructor(
.andExpect(status().isUnauthorized)
}
@ParameterizedTest
@NullSource
@ValueSource(booleans = [true, false])
@DisplayName("이벤트 제목만 수정하면 생략한 링크와 성인 여부 등 나머지 필드를 보존한다")
fun shouldPreserveOmittedFieldsWhenUpdatingOnlyTitle(isAdult: Boolean?) {
val event = saveEventForUpdate(isAdult)
mockMvc.perform(updateRequest(event).param("title", "updated"))
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
assertEquals(event.copy(title = "updated"), eventRepository.findById(requireNotNull(event.id)).orElseThrow())
}
@ParameterizedTest
@ValueSource(booleans = [true, false])
@DisplayName("이벤트 수정은 명시한 링크와 성인 여부를 저장한다")
fun shouldPersistExplicitLinkAndAdultFlag(isAdult: Boolean) {
val event = saveEventForUpdate(!isAdult)
mockMvc.perform(
updateRequest(event)
.param("link", "https://event.test/updated")
.param("isAdult", isAdult.toString())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
assertEquals(
event.copy(link = "https://event.test/updated", isAdult = isAdult),
eventRepository.findById(requireNotNull(event.id)).orElseThrow()
)
}
@ParameterizedTest
@ValueSource(strings = ["", " "])
@DisplayName("이벤트 수정은 빈 링크 원문을 저장하고 생략한 성인 여부를 보존한다")
fun shouldPersistBlankLinkWithoutChangingOmittedAdultFlag(link: String) {
val event = saveEventForUpdate()
mockMvc.perform(updateRequest(event).param("link", link))
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
assertEquals(event.copy(link = link), eventRepository.findById(requireNotNull(event.id)).orElseThrow())
}
private fun updateRequest(event: Event) =
put("/event")
.param("id", event.id.toString())
.with(user("admin").roles("ADMIN"))
private fun saveEventForUpdate(isAdult: Boolean? = true): Event {
return eventRepository.saveAndFlush(
saveControllerEvent("original", Lang.EN).apply {
link = "https://event.test/original"
this.isAdult = isAdult
isActive = false
startDate = LocalDateTime.of(2099, 1, 1, 0, 0, 0)
endDate = LocalDateTime.of(2099, 1, 2, 23, 59, 59)
}
)
}
private fun saveControllerEvent(seed: String, lang: Lang): Event {
return eventRepository.saveAndFlush(
Event(
@@ -7,6 +7,7 @@ import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.auth.Auth
import kr.co.vividnext.sodalive.member.auth.AuthRepository
import kr.co.vividnext.sodalive.support.hasForcedCountryMapping
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
@@ -30,10 +31,6 @@ class MemberContentPreferenceIntegrationTest @Autowired constructor(
private val preferenceRepository: MemberContentPreferenceRepository,
private val entityManager: EntityManager
) {
companion object {
private val FORCED_MEMBER_IDS = setOf(16L, 17L, 29721L, 32050L, 37543L, 40850L)
}
private lateinit var service: MemberContentPreferenceService
private lateinit var countryContext: CountryContext
@@ -273,7 +270,7 @@ class MemberContentPreferenceIntegrationTest @Autowired constructor(
var index = 0
while (true) {
val candidate = saveMember("$seed-$index")
if (!FORCED_MEMBER_IDS.contains(candidate.id)) {
if (!candidate.hasForcedCountryMapping()) {
return candidate
}
index++
@@ -0,0 +1,10 @@
package kr.co.vividnext.sodalive.support
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.resolveCountryCodeWithForcedMapping
// Fixture selection only: two distinct headers detect forced countries without copying member IDs.
internal fun Member.hasForcedCountryMapping(): Boolean {
return resolveCountryCodeWithForcedMapping(this, "KR") != "KR" ||
resolveCountryCodeWithForcedMapping(this, "JP") != "JP"
}
@@ -0,0 +1,22 @@
package kr.co.vividnext.sodalive.support
import kr.co.vividnext.sodalive.member.Member
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
class CountryMemberFixturesTest {
@ParameterizedTest
@CsvSource("16, true", "17958, true", "29721, true", "100, false")
@DisplayName("일반 회원 fixture는 강제 국가 매핑 회원과 기존 목록에서 누락된 회원을 제외한다")
fun shouldIdentifyForcedCountryMappingsForFixtureSelection(memberId: Long, expected: Boolean) {
val member = Member(
email = "member$memberId@test.com",
password = "password",
nickname = "member$memberId"
).apply { id = memberId }
assertEquals(expected, member.hasForcedCountryMapping())
}
}
@@ -19,6 +19,7 @@ import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.auth.Auth
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreference
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.support.hasForcedCountryMapping
import kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshot
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
import org.junit.jupiter.api.DisplayName
@@ -84,7 +85,7 @@ class AudioRecommendationEndToEndTest @Autowired constructor(
} else {
val role = if (viewerType == "ADMIN") MemberRole.ADMIN else MemberRole.USER
var candidate = saveMember("audio-event-viewer", role)
while (candidate.id in setOf(16L, 17L, 17958L, 44144L, 29721L, 32050L, 37543L, 40850L)) {
while (candidate.hasForcedCountryMapping()) {
candidate = saveMember("audio-event-viewer-${candidate.id}", role)
}
candidate
@@ -28,6 +28,7 @@ import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequ
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.HttpStatusEntryPoint
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
@@ -83,6 +84,31 @@ class CreatorChannelDonationControllerTest @Autowired constructor(
.andExpect(status().isUnauthorized)
}
@Test
@DisplayName("후원 숨김 DELETE는 body 없이 경로 ID와 인증 회원을 전달하고 data 없는 성공을 반환한다")
fun shouldHideDonationWithoutRequestBodyOrResponseData() {
val creator = createMember(id = 1L).apply { role = MemberRole.CREATOR }
mockMvc.perform(
delete("/api/v2/creator-channels/1/donations/7")
.with(user(MemberAdapter(creator)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data").doesNotExist())
Mockito.verify(facade).hide(1L, 7L, creator)
}
@Test
@DisplayName("후원 숨김 DELETE는 비회원 요청을 거부한다")
fun shouldRejectAnonymousHideDonationRequest() {
mockMvc.perform(delete("/api/v2/creator-channels/1/donations/7").with(anonymous()))
.andExpect(status().isUnauthorized)
Mockito.verifyNoInteractions(facade)
}
@Test
@DisplayName("크리에이터 채널 후원 탭 조회는 query parameter를 facade에 전달하고 성공 응답을 반환한다")
fun shouldReturnCreatorChannelDonationTabForAuthenticatedMember() {
@@ -116,6 +142,9 @@ class CreatorChannelDonationControllerTest @Autowired constructor(
.andExpect(jsonPath("$.data.donations[0].can").value(50))
.andExpect(jsonPath("$.data.donations[0].message").value("thanks"))
.andExpect(jsonPath("$.data.donations[0].createdAtUtc").value("2026-06-21T03:30:00Z"))
.andExpect(jsonPath("$.data.donations[0].isSecret").value(true))
.andExpect(jsonPath("$.data.donations[0].memberId").doesNotExist())
.andExpect(jsonPath("$.data.donations[0].id").doesNotExist())
.andExpect(jsonPath("$.data.page").value(1))
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.hasNext").value(false))
@@ -163,11 +192,13 @@ class CreatorChannelDonationControllerTest @Autowired constructor(
),
donations = listOf(
CreatorChannelDonationResponse(
donationId = null,
nickname = "donor",
profileImageUrl = "https://cdn.test/donor.png",
can = 50,
message = "thanks",
createdAtUtc = "2026-06-21T03:30:00Z"
createdAtUtc = "2026-06-21T03:30:00Z",
isSecret = true
)
),
page = page,
@@ -1,23 +1,46 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.`in`.web
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.admin.calculate.channelDonation.AdminChannelDonationCalculateQueryRepository
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.can.use.UseCan
import kr.co.vividnext.sodalive.can.use.UseCanCalculate
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.jwt.TokenProvider
import kr.co.vividnext.sodalive.member.DonationRankingPeriod
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.token.MemberTokenRepository
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicy
import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorLocalizedNameEntity
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.junit.jupiter.params.provider.ValueSource
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.client.TestRestTemplate
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.test.annotation.DirtiesContext
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
@@ -25,9 +48,11 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPat
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.transaction.support.TransactionTemplate
import java.time.LocalDateTime
import java.time.ZoneOffset
import javax.persistence.EntityManager
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = [
"cloud.aws.cloud-front.host=https://cdn.test",
"spring.cache.type=none",
@@ -36,11 +61,279 @@ import javax.persistence.EntityManager
)
@AutoConfigureMockMvc
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
class CreatorChannelDonationEndToEndTest @Autowired constructor(
private val mockMvc: MockMvc,
private val entityManager: EntityManager,
private val transactionTemplate: TransactionTemplate
private val transactionTemplate: TransactionTemplate,
private val objectMapper: ObjectMapper,
private val restTemplate: TestRestTemplate,
private val tokenProvider: TokenProvider,
private val tokenRepository: MemberTokenRepository,
private val jdbcTemplate: JdbcTemplate,
private val settlementRepository: AdminChannelDonationCalculateQueryRepository,
private val redisTemplate: RedisTemplate<String, Any>
) {
private val tokenMemberIds = mutableSetOf<Long>()
@AfterEach
fun removeIssuedTokens() {
tokenMemberIds.forEach { tokenRepository.deleteById(it) }
}
@ParameterizedTest
@ValueSource(booleans = [false, true])
@DisplayName("소유 CREATOR의 실제 HTTP DELETE는 공개·비밀 한 건만 숨기고 원본과 재무 기록을 보존한다")
fun shouldAllowOnlyOwningCreatorToHideDonationThroughHttp(isSecret: Boolean) {
val fixture = createHideFixture()
val before = readHideRecords(fixture)
val donationId = fixture.donationIds[if (isSecret) 1 else 0]
deleteThroughHttp(fixture.creator.id!!, donationId, fixture.creator, true)
assertEquals(before, readHideRecords(fixture))
assertHiddenDonations(fixture, setOf(donationId))
}
@ParameterizedTest
@ValueSource(booleans = [false, true])
@DisplayName("실제 HTTP 숨김은 단일 공개·비밀 후원의 legacy 응답과 양수 랭킹·정산을 그대로 보존한다")
fun shouldPreserveLegacyRankingAndSettlementAfterHidingDonationThroughHttp(isSecret: Boolean) {
val createdAt = CreatorChannelDonationQueryPolicy()
.currentKstMonthRange(LocalDateTime.now(ZoneOffset.UTC)).startInclusiveUtc
.plusHours(2).plusSeconds(if (isSecret) 1 else 0)
val fixture = transactionTemplate.execute {
val prefix = "preservation-$isSecret"
val creator = saveMember("$prefix-creator", MemberRole.CREATOR)
val donor = saveMember("$prefix-donor", MemberRole.USER)
val outsider = saveMember("$prefix-outsider", MemberRole.USER)
val otherCreator = saveMember("$prefix-other-creator", MemberRole.CREATOR)
val admin = saveMember("$prefix-admin", MemberRole.ADMIN)
val donation = saveDonation(creator, donor, 500, createdAt, isSecret, "preserved contribution")
val otherDonation = saveDonation(otherCreator, donor, 400, createdAt, false, "other channel original")
val usage = saveRankingDonation(donor, creator, can = 450, rewardCan = 50, isSecret = isSecret)
entityManager.flush()
updateCreatedAt("UseCan", usage.id!!, createdAt)
updateCreatedAt("UseCanCalculate", usage.useCanCalculates.single().id!!, createdAt)
entityManager.clear()
val stored = entityManager.find(ChannelDonationMessage::class.java, donation.id!!)
val storedUsage = entityManager.find(UseCan::class.java, usage.id!!)
val calculation = storedUsage.useCanCalculates.single()
assertEquals(stored.can, storedUsage.can + storedUsage.rewardCan)
assertEquals(stored.member!!.id, storedUsage.member!!.id)
assertEquals(stored.isSecret, storedUsage.isSecret)
assertEquals(createdAt, stored.createdAt)
assertEquals(stored.createdAt, storedUsage.createdAt)
assertEquals(stored.can, calculation.can)
assertEquals(stored.creator!!.id, calculation.recipientCreatorId)
assertEquals(UseCanCalculateStatus.RECEIVED, calculation.status)
assertEquals(CanUsage.CHANNEL_DONATION, storedUsage.canUsage)
assertFalse(storedUsage.isRefund)
HideFixture(creator, donor, outsider, otherCreator, admin, listOf(donation.id!!), otherDonation.id!!)
}!!
val before = readHideRecords(fixture)
val path = "/api/v2/creator-channels/${fixture.creator.id}"
val legacyPath = "/explorer/profile/channel-donation?creatorId=${fixture.creator.id}&page=0&size=20"
val tabBefore = requestThroughHttp("$path/donations?page=0&size=20", HttpMethod.GET, fixture.creator)["data"]
val selected = tabBefore["donations"].single()
val donationId = selected["donationId"].asLong()
assertEquals(fixture.donationIds.single(), donationId)
assertEquals(500, selected["can"].asInt())
assertFalse(selected["isSecret"].asBoolean())
assertEquals(1, tabBefore["donationCount"].asInt())
assertEquals(
tabBefore["donations"],
requestThroughHttp("$path/home", HttpMethod.GET, fixture.creator)["data"]["channelDonations"]
)
val legacyBefore = requestThroughHttp(legacyPath, HttpMethod.GET, fixture.creator)
assertTrue(legacyBefore["success"].asBoolean())
assertEquals(1, legacyBefore["data"]["totalCount"].asInt())
assertEquals(donationId, legacyBefore["data"]["items"].single()["id"].asLong())
val rankingsBefore = tabBefore["rankings"]
assertEquals(fixture.donor.id, rankingsBefore.single()["userId"].asLong())
assertEquals(500, rankingsBefore.single()["donationCan"].asInt())
val totalBefore = settlementRepository.getChannelDonationByCreatorTotal(createdAt, createdAt)
val settlementBefore = settlementRepository.getChannelDonationByCreator(createdAt, createdAt, 0, 20)
val settlement = settlementBefore.single().toResponseItem()
assertEquals(fixture.creator.nickname, settlement.creator)
assertEquals(1L, totalBefore.count)
assertEquals(500, totalBefore.totalCan)
assertEquals(1, settlement.count)
assertEquals(500, settlement.totalCan)
assertEquals(39695, settlement.settlementAmount)
assertEquals(38385, settlement.depositAmount)
println("SETTLEMENT EVIDENCE before creatorId=${fixture.creator.id} at=$createdAt total=$totalBefore item=$settlement")
deleteThroughHttp(fixture.creator.id!!, donationId, fixture.creator, true)
assertTrue(redisTemplate.delete("creator_donation_ranking_v2:${fixture.creator.id}:CUMULATIVE:0:8:true"))
val tabAfter = requestThroughHttp("$path/donations?page=0&size=20", HttpMethod.GET, fixture.creator)["data"]
assertEquals(0, tabAfter["donationCount"].asInt())
assertTrue(tabAfter["donations"].isEmpty)
assertFalse(tabAfter["hasNext"].asBoolean())
assertTrue(requestThroughHttp("$path/home", HttpMethod.GET, fixture.creator)["data"]["channelDonations"].isEmpty)
assertEquals(rankingsBefore, tabAfter["rankings"])
assertEquals(legacyBefore, requestThroughHttp(legacyPath, HttpMethod.GET, fixture.creator))
val totalAfter = settlementRepository.getChannelDonationByCreatorTotal(createdAt, createdAt)
val settlementAfter = settlementRepository.getChannelDonationByCreator(createdAt, createdAt, 0, 20)
assertEquals(totalBefore, totalAfter)
assertEquals(settlementBefore, settlementAfter)
assertEquals(settlement, settlementAfter.single().toResponseItem())
println(
"SETTLEMENT EVIDENCE after creatorId=${fixture.creator.id} at=$createdAt " +
"total=$totalAfter item=${settlementAfter.single().toResponseItem()}"
)
assertEquals(before, readHideRecords(fixture))
assertHiddenDonations(fixture, setOf(donationId))
}
@ParameterizedTest
@ValueSource(strings = ["donor", "outsider", "otherCreator", "admin"])
@DisplayName("후원자·일반 회원·다른 CREATOR·ADMIN의 실제 HTTP DELETE는 기록 변경 없이 거부된다")
fun shouldRejectNonOwnersThroughHttpWithoutChangingRecords(actorType: String) {
val fixture = createHideFixture()
val before = readHideRecords(fixture)
val actor = when (actorType) {
"donor" -> fixture.donor
"outsider" -> fixture.outsider
"otherCreator" -> fixture.otherCreator
else -> fixture.admin
}
deleteThroughHttp(fixture.creator.id!!, fixture.donationIds.first(), actor, false, "권한이 없습니다.")
assertEquals(before, readHideRecords(fixture))
assertHiddenDonations(fixture, emptySet())
}
@Test
@DisplayName("실제 HTTP DELETE는 경로 소유권 위조·다른 채널 후원·미존재 ID를 동일한 원본을 유지하며 거부한다")
fun shouldRejectMismatchedOrMissingIdsThroughHttp() {
val fixture = createHideFixture()
val before = readHideRecords(fixture)
val creatorId = fixture.creator.id!!
val requests = listOf(
Triple(fixture.otherCreator.id!!, fixture.donationIds.first(), "권한이 없습니다."),
Triple(creatorId, fixture.otherDonationId, "잘못된 요청입니다."),
Triple(creatorId, Long.MAX_VALUE, "잘못된 요청입니다.")
)
requests.forEach { (pathCreatorId, donationId, message) ->
deleteThroughHttp(pathCreatorId, donationId, fixture.creator, false, message)
assertEquals(before, readHideRecords(fixture))
assertHiddenDonations(fixture, emptySet())
}
}
@Test
@DisplayName("실제 HTTP 중복 숨김은 성공하고 이미 숨긴 항목의 비소유자 요청은 거부하며 재무를 보존한다")
fun shouldSucceedWhenHidingAgainThroughHttpWithoutChangingRecords() {
val fixture = createHideFixture()
val before = readHideRecords(fixture)
val donationId = fixture.donationIds[1]
repeat(2) {
deleteThroughHttp(fixture.creator.id!!, donationId, fixture.creator, true)
assertEquals(before, readHideRecords(fixture))
assertHiddenDonations(fixture, setOf(donationId))
}
deleteThroughHttp(fixture.creator.id!!, donationId, fixture.donor, false, "권한이 없습니다.")
assertEquals(before, readHideRecords(fixture))
assertHiddenDonations(fixture, setOf(donationId))
}
@Test
@DisplayName("인증 없는 실제 HTTP DELETE는 운영 인증 체인에서 401로 거부되고 기록을 보존한다")
fun shouldRejectUnauthenticatedDeleteThroughRealSecurityChain() {
val fixture = createHideFixture()
val before = readHideRecords(fixture)
val path = "/api/v2/creator-channels/${fixture.creator.id}/donations/${fixture.donationIds.first()}"
val response = restTemplate.exchange(path, HttpMethod.DELETE, HttpEntity<Any>(HttpHeaders()), String::class.java)
println("HTTP EVIDENCE DELETE $path actor=anonymous ${response.statusCodeValue}: ${response.body}")
assertEquals(HttpStatus.UNAUTHORIZED, response.statusCode)
assertEquals(before, readHideRecords(fixture))
assertHiddenDonations(fixture, emptySet())
}
private fun deleteThroughHttp(
creatorId: Long,
donationId: Long,
actor: Member,
success: Boolean,
message: String? = null
) {
val path = "/api/v2/creator-channels/$creatorId/donations/$donationId"
val body = requestThroughHttp(path, HttpMethod.DELETE, actor)
assertEquals(success, body["success"].asBoolean(), body.toString())
assertTrue(body["data"].isNull, body.toString())
if (message != null) assertEquals(message, body["message"].asText())
}
private fun requestThroughHttp(path: String, method: HttpMethod, actor: Member): JsonNode {
val principal = MemberAdapter(actor)
val token = tokenProvider.createToken(
UsernamePasswordAuthenticationToken(principal, "", principal.authorities),
actor.id!!
)
tokenMemberIds.add(actor.id!!)
val headers = HttpHeaders().apply {
setBearerAuth(token)
set(HttpHeaders.ACCEPT_LANGUAGE, "ko")
}
val response = restTemplate.exchange(path, method, HttpEntity<Any>(headers), String::class.java)
println("HTTP EVIDENCE $method $path actor=${actor.role} ${response.statusCodeValue}: ${response.body}")
assertEquals(HttpStatus.OK, response.statusCode, response.body)
return objectMapper.readTree(response.body)
}
private fun createHideFixture(): HideFixture = transactionTemplate.execute {
val creator = saveMember("hide-http-creator", MemberRole.CREATOR)
val donor = saveMember("hide-http-donor", MemberRole.USER)
val outsider = saveMember("hide-http-outsider", MemberRole.USER)
val otherCreator = saveMember("hide-http-other-creator", MemberRole.CREATOR)
val admin = saveMember("hide-http-admin", MemberRole.ADMIN)
val createdAt = LocalDateTime.of(2026, 11, 5, 0, 0)
val donations = listOf(
saveDonation(creator, donor, 100, createdAt, false, "public original"),
saveDonation(creator, donor, 200, createdAt, true, "secret original"),
saveDonation(creator, donor, 300, createdAt, false, "untouched original")
)
val otherDonation = saveDonation(otherCreator, donor, 400, createdAt, true, "other channel original")
saveRankingDonation(donor, creator, can = 450, rewardCan = 50)
entityManager.flush()
entityManager.clear()
HideFixture(creator, donor, outsider, otherCreator, admin, donations.map { it.id!! }, otherDonation.id!!)
}!!
private fun readHideRecords(fixture: HideFixture): List<List<Map<String, Any?>>> {
val donations = jdbcTemplate.queryForList(
"select * from channel_donation_message where creator_id in (?, ?) order by id",
fixture.creator.id,
fixture.otherCreator.id
).map { row -> row.filterKeys { !it.equals("is_hidden", ignoreCase = true) } }
val usages = jdbcTemplate.queryForList("select * from use_can where member_id = ? order by id", fixture.donor.id)
val calculations = jdbcTemplate.queryForList(
"select c.* from use_can_calculate c join use_can u on u.id = c.use_can_id where u.member_id = ? order by c.id",
fixture.donor.id
)
assertEquals(fixture.donationIds.size + 1, donations.size)
assertEquals(1, usages.size)
assertEquals(1, calculations.size)
return listOf(donations, usages, calculations)
}
private fun assertHiddenDonations(fixture: HideFixture, hiddenIds: Set<Long>) {
transactionTemplate.executeWithoutResult {
entityManager.clear()
(fixture.donationIds + fixture.otherDonationId).forEach { donationId ->
val stored = requireNotNull(entityManager.find(ChannelDonationMessage::class.java, donationId))
assertEquals(donationId in hiddenIds, stored.isHidden)
}
}
}
@Test
@DisplayName("후원 탭 API는 controller-service-repository를 거쳐 후원 목록과 랭킹을 반환한다")
fun shouldReturnDonationTabThroughControllerServiceRepositoryAndLegacyRanking() {
@@ -54,22 +347,28 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor(
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.donationCount").value(2))
.andExpect(jsonPath("$.data.donationCount").value(3))
.andExpect(jsonPath("$.data.rankings.length()").value(1))
.andExpect(jsonPath("$.data.rankings[0].userId").value(fixture.viewer.id!!))
.andExpect(jsonPath("$.data.rankings[0].nickname").value(fixture.viewer.nickname))
.andExpect(jsonPath("$.data.rankings[0].profileImage").value("https://cdn.test/${fixture.viewer.profileImage}"))
.andExpect(jsonPath("$.data.rankings[0].donationCan").value(0))
.andExpect(jsonPath("$.data.donations.length()").value(2))
.andExpect(jsonPath("$.data.donations[0].nickname").value("donation-e2e-success-viewer"))
.andExpect(jsonPath("$.data.donations[0].profileImageUrl").value("https://cdn.test/donation-e2e-success-viewer.png"))
.andExpect(jsonPath("$.data.donations[0].can").value(200))
.andExpect(jsonPath("$.data.donations[0].message").value("own secret"))
.andExpect(jsonPath("$.data.donations.length()").value(3))
.andExpect(jsonPath("$.data.donations[0].isSecret").value(true))
.andExpect(jsonPath("$.data.donations[0].nickname").value("익명"))
.andExpect(jsonPath("$.data.donations[0].profileImageUrl").value("https://cdn.test/profile/anonymous-profile.png"))
.andExpect(jsonPath("$.data.donations[0].can").value(300))
.andExpect(jsonPath("$.data.donations[0].message").value("크리에이터에게만 보여요"))
.andExpect(jsonPath("$.data.donations[0].createdAtUtc").exists())
.andExpect(jsonPath("$.data.donations[1].nickname").value("donation-e2e-success-other"))
.andExpect(jsonPath("$.data.donations[1].can").value(100))
.andExpect(jsonPath("$.data.donations[1].message").value("public"))
.andExpect(jsonPath("$.data.donations[?(@.message == 'hidden')]").isEmpty)
.andExpect(jsonPath("$.data.donations[1].isSecret").value(false))
.andExpect(jsonPath("$.data.donations[1].nickname").value("donation-e2e-success-viewer"))
.andExpect(jsonPath("$.data.donations[1].profileImageUrl").value("https://cdn.test/donation-e2e-success-viewer.png"))
.andExpect(jsonPath("$.data.donations[1].can").value(200))
.andExpect(jsonPath("$.data.donations[1].message").value("own secret"))
.andExpect(jsonPath("$.data.donations[2].isSecret").value(false))
.andExpect(jsonPath("$.data.donations[2].nickname").value("donation-e2e-success-other"))
.andExpect(jsonPath("$.data.donations[2].can").value(100))
.andExpect(jsonPath("$.data.donations[2].message").value("public"))
.andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.hasNext").value(false))
@@ -88,7 +387,7 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor(
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.donationCount").value(2))
.andExpect(jsonPath("$.data.donationCount").value(3))
.andExpect(jsonPath("$.data.donations.length()").value(0))
.andExpect(jsonPath("$.data.page").value(1))
.andExpect(jsonPath("$.data.size").value(20))
@@ -110,7 +409,8 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor(
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(50))
.andExpect(jsonPath("$.data.donations.length()").value(2))
.andExpect(jsonPath("$.data.donationCount").value(3))
.andExpect(jsonPath("$.data.donations.length()").value(3))
}
@Test
@@ -125,8 +425,8 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor(
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.rankings.length()").value(0))
.andExpect(jsonPath("$.data.donationCount").value(2))
.andExpect(jsonPath("$.data.donations.length()").value(2))
.andExpect(jsonPath("$.data.donationCount").value(3))
.andExpect(jsonPath("$.data.donations.length()").value(3))
.andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.hasNext").value(false))
@@ -151,6 +451,354 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor(
.andExpect(jsonPath("$.data.donations[?(@.message == 'hidden')]").isNotEmpty)
}
@ParameterizedTest(name = "{0}, {1}")
@CsvSource(
"creator, ko", "donor, ko", "defaultDonor, ko", "outsider, ko", "otherCreator, ko",
"creator, en", "donor, en", "defaultDonor, en", "outsider, en", "otherCreator, en",
"creator, ja", "donor, ja", "defaultDonor, ja", "outsider, ja", "otherCreator, ja"
)
@DisplayName("후원 탭은 비밀 후원을 ID로 마스킹하며 요청 언어와 원본 표시 및 저장값을 보존한다")
fun shouldMaskOnlyOtherMembersSecretDonationsThroughHttp(viewerRole: String, language: String) {
val fixture = transactionTemplate.execute {
val creator = saveMember("secret-tab-creator", MemberRole.CREATOR)
val donor = saveMember("deleted_secret-tab-donor", MemberRole.CREATOR)
val defaultDonor = saveMember("secret-tab-default-donor", MemberRole.USER).apply { profileImage = null }
val viewers = mapOf(
"creator" to creator,
"donor" to donor,
"defaultDonor" to defaultDonor,
"outsider" to saveMember("secret-tab-outsider", MemberRole.USER),
"otherCreator" to saveMember("secret-tab-other-creator", MemberRole.CREATOR)
)
listOf(Lang.EN, Lang.JA).forEach { lang ->
entityManager.persist(CreatorLocalizedNameEntity(donor.id!!, lang, "deleted_localized-${lang.code}"))
}
val start = CreatorChannelDonationQueryPolicy().currentKstMonthRange(LocalDateTime.now()).startInclusiveUtc
val rows = listOf(
saveDonation(creator, donor, 100, start.plusMinutes(1), additionalMessage = "public thanks"),
saveDonation(creator, defaultDonor, 200, start.plusMinutes(2), additionalMessage = null),
saveDonation(creator, donor, 300, start.plusMinutes(3), true, "private thanks"),
saveDonation(creator, donor, 400, start.plusMinutes(4), true, null),
saveDonation(creator, donor, 500, start.plusMinutes(5), true, ""),
saveDonation(creator, defaultDonor, 600, start.plusMinutes(6), true, "other private thanks")
)
entityManager.flush()
entityManager.clear()
val persistedRows = rows.map { entityManager.find(ChannelDonationMessage::class.java, it.id!!) }
persistedRows.forEach { row -> assertEquals(start.plusMinutes((row.can / 100).toLong()), row.createdAt) }
DonationFixture(creator.id!!, donor.id!!, viewers.getValue(viewerRole), persistedRows)
}!!
val result = mockMvc.perform(
get("/api/v2/creator-channels/${fixture.creatorId}/donations")
.header("Accept-Language", language)
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.donationCount").value(6))
.andExpect(jsonPath("$.data.hasNext").value(false))
.andReturn()
val donations = objectMapper.readTree(result.response.contentAsByteArray)["data"]["donations"]
assertEquals(listOf(600, 500, 400, 300, 200, 100), donations.map { it["can"].asInt() })
val anonymousNickname = mapOf("ko" to "익명", "en" to "Anonymous", "ja" to "匿名").getValue(language)
val anonymousMessage = mapOf(
"ko" to "크리에이터에게만 보여요",
"en" to "Only visible to the creator",
"ja" to "クリエイターにのみ表示されます"
).getValue(language)
fixture.donations.sortedByDescending { it.can }.zip(donations).forEach { (stored, item) ->
val isOwner = fixture.viewer.role == MemberRole.CREATOR && fixture.viewer.id == fixture.creatorId
assertEquals(
setOf("nickname", "profileImageUrl", "can", "message", "createdAtUtc", "isSecret") +
if (isOwner) setOf("donationId") else emptySet(),
item.fieldNames().asSequence().toSet()
)
if (isOwner) assertEquals(stored.id, item["donationId"]?.asLong())
assertFalse(item.has("memberId"))
assertTrue(item["isSecret"].isBoolean)
assertEquals(stored.can, item["can"].asInt())
assertEquals(
requireNotNull(stored.createdAt).atOffset(ZoneOffset.UTC).toInstant().toString(),
item["createdAtUtc"].asText()
)
val masked = stored.isSecret && fixture.viewer.id != fixture.creatorId && fixture.viewer.id != stored.member?.id
assertEquals(masked, item["isSecret"].asBoolean())
if (masked) {
assertEquals(anonymousNickname, item["nickname"].asText())
assertEquals(anonymousMessage, item["message"].asText())
assertEquals("https://cdn.test/profile/anonymous-profile.png", item["profileImageUrl"].asText())
assertFalse(item.toString().contains("secret-tab-"))
assertFalse(item.toString().contains("localized-"))
assertFalse(item.toString().contains("private thanks"))
} else {
val isDonor = stored.member?.id == fixture.donorId
val nickname = if (isDonor) {
if (language == "ko") "secret-tab-donor" else "deleted_localized-$language"
} else {
"secret-tab-default-donor"
}
val profile = if (isDonor) "deleted_secret-tab-donor.png" else "profile/default-profile.png"
assertEquals(nickname, item["nickname"].asText())
assertEquals(stored.additionalMessage.orEmpty(), item["message"].asText())
assertEquals("https://cdn.test/$profile", item["profileImageUrl"].asText())
}
}
transactionTemplate.execute {
entityManager.clear()
fixture.donations.forEach { stored ->
assertEquals(stored.isSecret, entityManager.find(ChannelDonationMessage::class.java, stored.id).isSecret)
}
}
}
@Test
@DisplayName("실제 HTTP 후원 탭은 숨김 제외 후 20건과 hasNext를 채우며 전부 숨기면 0건을 반환한다")
fun shouldExcludeHiddenRowsFromDonationTabCountAndPagesForEveryViewerThroughHttp() {
val fixture = createHideFixture()
val rows = transactionTemplate.execute {
val start = CreatorChannelDonationQueryPolicy()
.currentKstMonthRange(LocalDateTime.now(ZoneOffset.UTC)).startInclusiveUtc
fixture.donationIds.forEach { id ->
entityManager.find(ChannelDonationMessage::class.java, id).isHidden = true
}
val donations = (1..25).map { index ->
saveDonation(
fixture.creator,
fixture.donor,
index,
start.plusHours(1),
index % 2 == 1,
"row$index",
isHidden = index >= 23
)
}
entityManager.flush()
entityManager.clear()
donations
}!!
val viewers = listOf(fixture.creator, fixture.donor, fixture.outsider, fixture.otherCreator, fixture.admin)
val path = "/api/v2/creator-channels/${fixture.creator.id}/donations"
viewers.forEach { viewer ->
listOf((22 downTo 3).toList(), listOf(2, 1), emptyList()).forEachIndexed { page, expected ->
val body = requestThroughHttp("$path?page=$page&size=20", HttpMethod.GET, viewer)
assertTrue(body["success"].asBoolean())
val data = body["data"]
assertEquals(22, data["donationCount"].asInt())
assertEquals(page, data["page"].asInt())
assertEquals(20, data["size"].asInt())
assertEquals(page == 0, data["hasNext"].asBoolean())
assertEquals(expected, data["donations"].map { it["can"].asInt() })
data["donations"].forEach { item ->
assertFalse(item.has("memberId"))
if (viewer.id == fixture.creator.id) {
assertEquals(rows[item["can"].asInt() - 1].id, item["donationId"]?.asLong())
} else {
assertFalse(item.has("donationId"))
}
}
}
}
rows.take(2).forEach { deleteThroughHttp(fixture.creator.id!!, it.id!!, fixture.creator, true) }
viewers.forEach { viewer ->
val data = requestThroughHttp("$path?size=20", HttpMethod.GET, viewer)["data"]
assertEquals(20, data["donationCount"].asInt())
assertEquals((22 downTo 3).toList(), data["donations"].map { it["can"].asInt() })
assertFalse(data["hasNext"].asBoolean())
}
transactionTemplate.executeWithoutResult {
rows.forEach { entityManager.find(ChannelDonationMessage::class.java, it.id!!).isHidden = true }
}
viewers.forEach { viewer ->
val data = requestThroughHttp(path, HttpMethod.GET, viewer)["data"]
assertEquals(0, data["donationCount"].asInt())
assertTrue(data["donations"].isEmpty)
assertFalse(data["hasNext"].asBoolean())
}
}
@Test
@DisplayName("실제 HTTP에서 소유 CREATOR만 영속 후원 ID를 얻어 숨길 수 있다")
fun shouldExposeDonationIdOnlyToOwningCreator() {
val fixture = createHideFixture()
transactionTemplate.executeWithoutResult {
val start = CreatorChannelDonationQueryPolicy()
.currentKstMonthRange(LocalDateTime.now(ZoneOffset.UTC)).startInclusiveUtc
fixture.donationIds.forEachIndexed { index, id ->
updateCreatedAt("ChannelDonationMessage", id, start.plusMinutes(index.toLong()))
}
}
val path = "/api/v2/creator-channels/${fixture.creator.id}/donations"
val data = requestThroughHttp(path, HttpMethod.GET, fixture.creator)["data"]
assertEquals(fixture.donationIds.reversed(), data["donations"].map { it["donationId"]?.asLong() })
val donationId = data["donations"][0]["donationId"].asLong()
deleteThroughHttp(fixture.creator.id!!, donationId, fixture.creator, true)
val after = requestThroughHttp(path, HttpMethod.GET, fixture.creator)["data"]
assertEquals(2, after["donationCount"].asInt())
assertEquals(fixture.donationIds.dropLast(1).reversed(), after["donations"].map { it["donationId"].asLong() })
}
@Test
@DisplayName("실제 홈 HTTP 응답도 소유 CREATOR에만 탭과 동일한 영속 후원 ID를 제공한다")
fun shouldExposeHomeDonationIdOnlyToOwningCreator() {
val fixture = createHideFixture()
transactionTemplate.executeWithoutResult {
val start = CreatorChannelDonationQueryPolicy()
.currentKstMonthRange(LocalDateTime.now(ZoneOffset.UTC)).startInclusiveUtc
fixture.donationIds.forEachIndexed { index, id ->
updateCreatedAt("ChannelDonationMessage", id, start.plusMinutes(index.toLong()))
}
}
val path = "/api/v2/creator-channels/${fixture.creator.id}"
val home = requestThroughHttp("$path/home", HttpMethod.GET, fixture.creator)["data"]["channelDonations"]
val tab = requestThroughHttp("$path/donations", HttpMethod.GET, fixture.creator)["data"]["donations"]
assertEquals(fixture.donationIds.reversed(), home.map { it["donationId"]?.asLong() })
assertEquals(tab, home)
val donationId = home[1]["donationId"].asLong()
deleteThroughHttp(fixture.creator.id!!, donationId, fixture.creator, true)
listOf(fixture.creator, fixture.donor, fixture.outsider, fixture.otherCreator, fixture.admin).forEach { viewer ->
val homeAfter = requestThroughHttp("$path/home", HttpMethod.GET, viewer)["data"]["channelDonations"]
val tabAfter = requestThroughHttp("$path/donations", HttpMethod.GET, viewer)["data"]
assertEquals(listOf(300, 100), homeAfter.map { it["can"].asInt() })
assertEquals(tabAfter["donations"], homeAfter)
assertEquals(2, tabAfter["donationCount"].asInt())
homeAfter.forEach { item ->
assertFalse(item.has("memberId"))
if (viewer.id == fixture.creator.id) {
assertTrue(item.has("donationId"))
} else {
assertFalse(item.has("donationId"))
}
}
}
}
@ParameterizedTest
@ValueSource(booleans = [false, true])
@DisplayName("실제 HTTP 홈은 숨김 제외 후 8건을 보충하고 공개·비밀 DELETE를 모든 역할에 반영한다")
fun shouldRefillHomeWithLatestEightVisibleDonationsAfterHiddenRowsAreExcludedThroughHttp(isSecret: Boolean) {
val fixture = createHideFixture()
val start = CreatorChannelDonationQueryPolicy()
.currentKstMonthRange(LocalDateTime.now(ZoneOffset.UTC)).startInclusiveUtc
val rows = transactionTemplate.execute {
fixture.donationIds.forEach { entityManager.find(ChannelDonationMessage::class.java, it).isHidden = true }
val donations = (1..12).map { index ->
saveDonation(
fixture.creator,
fixture.donor,
index,
start.plusHours((index / 2).toLong()),
index % 2 == 1,
"visible$index",
isHidden = index >= 10
)
}
entityManager.flush()
entityManager.clear()
donations
}!!
val path = "/api/v2/creator-channels/${fixture.creator.id}"
val viewers = listOf(fixture.creator, fixture.donor, fixture.outsider, fixture.otherCreator, fixture.admin)
viewers.forEach { viewer ->
val data = requestThroughHttp("$path/home", HttpMethod.GET, viewer)["data"]
assertFalse(data.has("donationCount"))
val items = data["channelDonations"]
assertEquals((9 downTo 2).toList(), items.map { it["can"].asInt() })
items.forEach { item ->
val can = item["can"].asInt()
val secret = can % 2 == 1
assertEquals(
start.plusHours((can / 2).toLong()).atOffset(ZoneOffset.UTC).toInstant().toString(),
item["createdAtUtc"].asText()
)
assertFalse(item.has("memberId"))
if (viewer.id == fixture.creator.id) {
assertEquals(rows[can - 1].id, item["donationId"]?.asLong())
} else {
assertFalse(item.has("donationId"))
}
val masked = secret && viewer.id != fixture.creator.id && viewer.id != fixture.donor.id
assertEquals(masked, item["isSecret"].asBoolean())
assertEquals(if (masked) "익명" else fixture.donor.nickname, item["nickname"].asText())
assertEquals(if (masked) "크리에이터에게만 보여요" else "visible$can", item["message"].asText())
}
}
val ownerHome = requestThroughHttp("$path/home", HttpMethod.GET, fixture.creator)["data"]["channelDonations"]
val selectedCan = if (isSecret) 9 else 8
val selected = ownerHome.first { it["can"].asInt() == selectedCan }
assertFalse(selected["isSecret"].asBoolean())
assertEquals(selectedCan, selected["can"].asInt())
assertEquals(rows[selectedCan - 1].id, selected["donationId"].asLong())
deleteThroughHttp(fixture.creator.id!!, selected["donationId"].asLong(), fixture.creator, true)
val expected = if (isSecret) (8 downTo 1).toList() else listOf(9, 7, 6, 5, 4, 3, 2, 1)
viewers.forEach { viewer ->
val home = requestThroughHttp("$path/home", HttpMethod.GET, viewer)["data"]["channelDonations"]
val tab = requestThroughHttp("$path/donations", HttpMethod.GET, viewer)["data"]
assertEquals(expected, home.map { it["can"].asInt() })
assertEquals(8, tab["donationCount"].asInt())
assertFalse(tab["hasNext"].asBoolean())
assertEquals(tab["donations"], home)
}
transactionTemplate.executeWithoutResult {
rows.forEach { entityManager.find(ChannelDonationMessage::class.java, it.id!!).isHidden = true }
}
viewers.forEach { viewer ->
val home = requestThroughHttp("$path/home", HttpMethod.GET, viewer)["data"]["channelDonations"]
assertTrue(home.isEmpty)
}
}
@Test
@DisplayName("후원 탭은 KST 월 공개·비밀 전체를 최신순으로 페이지화하고 범위 밖에서도 count를 유지한다")
fun shouldPaginateAllCurrentMonthDonationsThroughHttp() {
val range = CreatorChannelDonationQueryPolicy().currentKstMonthRange(LocalDateTime.now())
val start = range.startInclusiveUtc
val end = range.endExclusiveUtc
val fixture = transactionTemplate.execute {
val creator = saveMember("page-tab-creator", MemberRole.CREATOR)
val donor = saveMember("page-tab-donor", MemberRole.USER)
val viewer = saveMember("page-tab-viewer", MemberRole.USER)
saveDonation(creator, donor, 900, start.minusSeconds(1), true, "previous month")
saveDonation(creator, donor, 901, start, true, "start")
saveDonation(creator, donor, 902, end, true, "next month")
saveDonation(creator, donor, 903, end.minusSeconds(1), true, "last second")
(1..23).forEach { index ->
saveDonation(creator, donor, index, start.plusHours((index / 2).toLong()), index % 2 == 1, "row$index")
}
entityManager.flush()
entityManager.clear()
val rows = entityManager.createQuery(
"select d from ChannelDonationMessage d where d.creator.id = :creatorId",
ChannelDonationMessage::class.java
).setParameter("creatorId", creator.id).resultList
DonationFixture(creator.id!!, donor.id!!, viewer, rows)
}!!
val persisted = fixture.donations.associateBy { it.can }
val expectedPages = listOf(listOf(903) + (23 downTo 5), listOf(4, 3, 2, 1, 901), emptyList())
expectedPages.forEachIndexed { page, expectedCans ->
val result = mockMvc.perform(
get("/api/v2/creator-channels/${fixture.creatorId}/donations")
.param("page", page.toString())
.param("size", "20")
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.donationCount").value(25))
.andExpect(jsonPath("$.data.page").value(page))
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.hasNext").value(page == 0))
.andReturn()
val items = objectMapper.readTree(result.response.contentAsByteArray)["data"]["donations"]
assertEquals(expectedCans, items.map { it["can"].asInt() })
items.forEach { item ->
val stored = persisted.getValue(item["can"].asInt())
assertEquals(stored.isSecret, item["isSecret"].asBoolean())
assertEquals(
requireNotNull(stored.createdAt).atOffset(ZoneOffset.UTC).toInstant().toString(),
item["createdAtUtc"].asText()
)
}
}
}
private fun createFixture(prefix: String, isVisibleDonationRank: Boolean): Fixture {
return transactionTemplate.execute {
val monthStart = CreatorChannelDonationQueryPolicy()
@@ -204,9 +852,11 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor(
can: Int,
createdAt: LocalDateTime,
isSecret: Boolean = false,
additionalMessage: String
additionalMessage: String?,
isHidden: Boolean = false
): ChannelDonationMessage {
val donation = ChannelDonationMessage(can = can, isSecret = isSecret, additionalMessage = additionalMessage)
donation.isHidden = isHidden
donation.creator = creator
donation.member = donor
entityManager.persist(donation)
@@ -215,8 +865,14 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor(
return donation
}
private fun saveRankingDonation(donor: Member, creator: Member, can: Int, rewardCan: Int) {
val useCan = UseCan(CanUsage.CHANNEL_DONATION, can = can, rewardCan = rewardCan, isRefund = false)
private fun saveRankingDonation(
donor: Member,
creator: Member,
can: Int,
rewardCan: Int,
isSecret: Boolean = false
): UseCan {
val useCan = UseCan(CanUsage.CHANNEL_DONATION, can = can, rewardCan = rewardCan, isRefund = false, isSecret = isSecret)
useCan.member = donor
entityManager.persist(useCan)
@@ -228,6 +884,7 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor(
calculate.useCan = useCan
calculate.recipientCreatorId = creator.id
entityManager.persist(calculate)
return useCan
}
private fun updateCreatedAt(entityName: String, id: Long, createdAt: LocalDateTime) {
@@ -242,4 +899,21 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor(
val creator: Member,
val creatorId: Long
)
private data class HideFixture(
val creator: Member,
val donor: Member,
val outsider: Member,
val otherCreator: Member,
val admin: Member,
val donationIds: List<Long>,
val otherDonationId: Long
)
private data class DonationFixture(
val creatorId: Long,
val donorId: Long,
val viewer: Member,
val donations: List<ChannelDonationMessage>
)
}
@@ -7,6 +7,7 @@ import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto.CreatorChannelDonationTabResponse
import kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryService
import kr.co.vividnext.sodalive.v2.creator.channel.donation.application.HideCreatorChannelDonationService
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
@@ -36,6 +37,7 @@ class CreatorChannelDonationFacadeTest {
assertEquals(50, response.donations.first().can)
assertEquals("thanks", response.donations.first().message)
assertEquals("2026-06-21T03:30:00Z", response.donations.first().createdAtUtc)
assertTrue(response.donations.first().isSecret)
assertEquals(1, response.page)
assertEquals(20, response.size)
assertTrue(response.hasNext)
@@ -51,6 +53,12 @@ class CreatorChannelDonationFacadeTest {
assertEquals(50, json["donations"][0]["can"].asInt())
assertEquals("thanks", json["donations"][0]["message"].asText())
assertEquals("2026-06-21T03:30:00Z", json["donations"][0]["createdAtUtc"].asText())
assertTrue(json["donations"][0]["isSecret"].isBoolean)
assertTrue(json["donations"][0]["isSecret"].asBoolean())
assertEquals(
setOf("nickname", "profileImageUrl", "can", "message", "createdAtUtc", "isSecret"),
json["donations"][0].fieldNames().asSequence().toSet()
)
assertTrue(json["hasNext"].asBoolean())
assertFalse(json.has("languageCode"))
}
@@ -61,7 +69,8 @@ class CreatorChannelDonationFacadeTest {
val service = Mockito.mock(CreatorChannelDonationQueryService::class.java)
val facade = CreatorChannelDonationFacade(
service,
Mockito.mock(CreatorLocalizedNameService::class.java)
Mockito.mock(CreatorLocalizedNameService::class.java),
Mockito.mock(HideCreatorChannelDonationService::class.java)
)
val viewer = createMember(id = 10L)
val now = LocalDateTime.of(2026, 6, 21, 12, 0)
@@ -86,6 +95,7 @@ class CreatorChannelDonationFacadeTest {
assertEquals(3, response.donationCount)
assertEquals(10L, response.rankings.first().userId)
assertEquals("https://cdn.test/donor.png", response.donations.first().profileImageUrl)
assertTrue(response.donations.first().isSecret)
assertEquals(1, response.page)
assertEquals(20, response.size)
assertTrue(response.hasNext)
@@ -113,12 +123,14 @@ class CreatorChannelDonationFacadeTest {
),
donations = listOf(
CreatorChannelDonation(
donationId = null,
memberId = 10L,
nickname = "donor",
profileImageUrl = "https://cdn.test/donor.png",
can = 50,
message = "thanks",
createdAt = LocalDateTime.of(2026, 6, 21, 3, 30)
createdAt = LocalDateTime.of(2026, 6, 21, 3, 30),
isSecret = true
)
),
page = CreatorChannelPage(page = 1, size = 20),
@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.home
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.content.AudioContent
@@ -11,6 +12,7 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers
import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.live.room.visit.LiveRoomVisit
@@ -19,14 +21,21 @@ import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslation
import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorLocalizedNameEntity
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.test.annotation.DirtiesContext
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
@@ -46,10 +55,12 @@ import javax.persistence.EntityManager
)
@AutoConfigureMockMvc
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
class CreatorChannelHomeEndToEndTest @Autowired constructor(
private val mockMvc: MockMvc,
private val entityManager: EntityManager,
private val transactionTemplate: TransactionTemplate
private val transactionTemplate: TransactionTemplate,
private val objectMapper: ObjectMapper
) {
@MockBean
private lateinit var countryContext: CountryContext
@@ -67,6 +78,7 @@ class CreatorChannelHomeEndToEndTest @Autowired constructor(
.andExpect(jsonPath("$.data.currentLive.liveId").value(fixture.currentLiveId))
.andExpect(jsonPath("$.data.latestAudioContent.audioContentId").value(fixture.latestAudioId))
.andExpect(jsonPath("$.data.channelDonations[0].can").value(500))
.andExpect(jsonPath("$.data.channelDonations[0].isSecret").value(false))
.andExpect(jsonPath("$.data.notices[0].postId").value(fixture.noticeId))
.andExpect(jsonPath("$.data.notices[0].isPinned").value(true))
.andExpect(jsonPath("$.data.notices[0].isCommentAvailable").value(true))
@@ -106,6 +118,173 @@ class CreatorChannelHomeEndToEndTest @Autowired constructor(
.andExpect(jsonPath("$.data.communities[0].content").value("translated community"))
}
@ParameterizedTest(name = "{0}, {1}")
@CsvSource(
"creator, ko", "donor, ko", "defaultDonor, ko", "outsider, ko", "otherCreator, ko",
"creator, en", "donor, en", "defaultDonor, en", "outsider, en", "otherCreator, en",
"creator, ja", "donor, ja", "defaultDonor, ja", "outsider, ja", "otherCreator, ja"
)
@DisplayName("홈 비밀 후원은 본인과 채널 주인만 원본을 보고 타인은 요청 언어로 익명 표시한다")
fun shouldMaskOnlyOtherMembersSecretDonationsThroughHttp(viewerRole: String, language: String) {
val fixture = transactionTemplate.execute {
val creator = saveMember("secret-home-creator", MemberRole.CREATOR)
val donor = saveMember("deleted_secret-home-donor", MemberRole.CREATOR)
val defaultDonor = saveMember("secret-home-default-donor", MemberRole.USER).apply { profileImage = null }
val viewers = mapOf(
"creator" to creator,
"donor" to donor,
"defaultDonor" to defaultDonor,
"outsider" to saveMember("secret-home-outsider", MemberRole.USER),
"otherCreator" to saveMember("secret-home-other-creator", MemberRole.CREATOR)
)
listOf(Lang.EN, Lang.JA).forEach { lang ->
entityManager.persist(CreatorLocalizedNameEntity(donor.id!!, lang, "deleted_localized-${lang.code}"))
}
val start = currentKstMonthStartUtc()
val rows = listOf(
saveDonation(creator, donor, start.plusMinutes(1), 100, false, "public thanks"),
saveDonation(creator, defaultDonor, start.plusMinutes(2), 200, false, null),
saveDonation(creator, donor, start.plusMinutes(3), 300, true, "private thanks"),
saveDonation(creator, donor, start.plusMinutes(4), 400, true, null),
saveDonation(creator, donor, start.plusMinutes(5), 500, true, ""),
saveDonation(creator, defaultDonor, start.plusMinutes(6), 600, true, "other private thanks")
)
entityManager.flush()
entityManager.clear()
val persistedRows = rows.map { entityManager.find(ChannelDonationMessage::class.java, it.id!!) }
persistedRows.forEach { row -> assertEquals(start.plusMinutes((row.can / 100).toLong()), row.createdAt) }
DonationFixture(creator.id!!, donor.id!!, viewers.getValue(viewerRole), persistedRows)
} ?: throw IllegalStateException("donation fixture creation failed")
val result = mockMvc.perform(
get("/api/v2/creator-channels/${fixture.creatorId}/home")
.header("Accept-Language", language)
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.donationCount").doesNotExist())
.andReturn()
val donations = objectMapper.readTree(result.response.contentAsByteArray)["data"]["channelDonations"]
assertEquals(listOf(600, 500, 400, 300, 200, 100), donations.map { it["can"].asInt() })
val anonymousNickname = mapOf("ko" to "익명", "en" to "Anonymous", "ja" to "匿名").getValue(language)
val anonymousMessage = mapOf(
"ko" to "크리에이터에게만 보여요",
"en" to "Only visible to the creator",
"ja" to "クリエイターにのみ表示されます"
).getValue(language)
fixture.donations.sortedByDescending { it.can }.zip(donations).forEach { (stored, item) ->
assertTrue(item.path("isSecret").isBoolean)
assertEquals(stored.can, item["can"].asInt())
assertEquals(
requireNotNull(stored.createdAt).atOffset(ZoneOffset.UTC).toInstant().toString(),
item["createdAtUtc"].asText()
)
assertEquals(
setOf("nickname", "profileImageUrl", "can", "message", "createdAtUtc", "isSecret") +
if (viewerRole == "creator") setOf("donationId") else emptySet(),
item.fieldNames().asSequence().toSet()
)
if (viewerRole == "creator") assertEquals(stored.id, item["donationId"]?.asLong())
assertFalse(item.has("memberId"))
val masked = stored.isSecret && fixture.viewer.id != fixture.creatorId && fixture.viewer.id != stored.member?.id
assertEquals(masked, item["isSecret"].asBoolean())
if (masked) {
assertEquals(anonymousNickname, item["nickname"].asText())
assertEquals(anonymousMessage, item["message"].asText())
assertEquals("https://cdn.test/profile/anonymous-profile.png", item["profileImageUrl"].asText())
assertFalse(item.toString().contains("secret-home-donor"))
assertFalse(item.toString().contains("localized-"))
assertFalse(item.toString().contains("private thanks"))
} else {
val isDonor = stored.member?.id == fixture.donorId
val nickname = if (isDonor) {
if (language == "ko") "secret-home-donor" else "deleted_localized-$language"
} else {
"secret-home-default-donor"
}
val profile = if (isDonor) "deleted_secret-home-donor.png" else "profile/default-profile.png"
assertEquals(nickname, item["nickname"].asText())
assertEquals(stored.additionalMessage.orEmpty(), item["message"].asText())
assertEquals("https://cdn.test/$profile", item["profileImageUrl"].asText())
}
}
transactionTemplate.execute {
entityManager.clear()
fixture.donations.forEach { stored ->
assertEquals(stored.isSecret, entityManager.find(ChannelDonationMessage::class.java, stored.id).isSecret)
}
}
}
@Test
@DisplayName("홈은 KST 월 시작을 포함하고 다음 월을 제외하며 공개·비밀 전체에서 최신 8건을 반환한다")
fun shouldKeepKstMonthBoundsAndLatestEightMixedDonationsThroughHttp() {
fun nextMonthStartUtc(monthStartUtc: LocalDateTime): LocalDateTime =
monthStartUtc.plusHours(9).plusMonths(1).minusHours(9)
assertEquals(
LocalDateTime.of(2026, 12, 31, 15, 0),
nextMonthStartUtc(LocalDateTime.of(2026, 11, 30, 15, 0))
)
val start = currentKstMonthStartUtc()
val end = nextMonthStartUtc(start)
val members = transactionTemplate.execute {
val creator = saveMember("month-home-creator", MemberRole.CREATOR)
val donor = saveMember("month-home-donor", MemberRole.USER)
val viewer = saveMember("month-home-viewer", MemberRole.USER)
saveDonation(creator, donor, start.minusSeconds(1), 900, true)
saveDonation(creator, donor, start, 901, true)
saveDonation(creator, donor, end, 902, true)
saveDonation(creator, donor, end.minusSeconds(1), 903, false)
Triple(creator, donor, viewer)
} ?: throw IllegalStateException("month fixture creation failed")
val boundaryResult = mockMvc.perform(
get("/api/v2/creator-channels/${members.first.id}/home").with(user(MemberAdapter(members.third)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.channelDonations[0].createdAtUtc").value("${end.minusSeconds(1)}Z"))
.andExpect(jsonPath("$.data.channelDonations[1].createdAtUtc").value("$start:00Z"))
.andExpect(jsonPath("$.data.channelDonations[1].isSecret").value(true))
.andReturn()
val boundaryItems = objectMapper.readTree(boundaryResult.response.contentAsByteArray)["data"]["channelDonations"]
assertEquals(listOf(903, 901), boundaryItems.map { it["can"].asInt() })
transactionTemplate.execute {
val creator = entityManager.find(Member::class.java, members.first.id)
val donor = entityManager.find(Member::class.java, members.second.id)
(1..10).forEach { index ->
saveDonation(creator, donor, start.plusHours((index / 2).toLong()), index, index % 2 == 1)
}
entityManager.flush()
entityManager.clear()
}
val result = mockMvc.perform(
get("/api/v2/creator-channels/${members.first.id}/home").with(user(MemberAdapter(members.third)))
)
.andExpect(status().isOk)
.andReturn()
val items = objectMapper.readTree(result.response.contentAsByteArray)["data"]["channelDonations"]
assertEquals(listOf(903, 10, 9, 8, 7, 6, 5, 4), items.map { it["can"].asInt() })
assertEquals(listOf(false, false, true, false, true, false, true, false), items.map { it["isSecret"].asBoolean() })
val persisted = transactionTemplate.execute {
entityManager.createQuery(
"select d from ChannelDonationMessage d where d.creator.id = :creatorId order by d.createdAt desc, d.id desc",
ChannelDonationMessage::class.java
).setParameter("creatorId", members.first.id).resultList.associateBy { it.can }
}!!
items.forEach { item ->
val stored = persisted.getValue(item["can"].asInt())
assertEquals(
requireNotNull(stored.createdAt).atOffset(ZoneOffset.UTC).toInstant().toString(),
item["createdAtUtc"].asText()
)
}
}
private fun currentKstMonthStartUtc(): LocalDateTime =
LocalDateTime.now(ZoneOffset.UTC).plusHours(9).toLocalDate().withDayOfMonth(1).atStartOfDay().minusHours(9)
private fun createFixture(): Fixture {
return transactionTemplate.execute {
val now = LocalDateTime.now(ZoneOffset.UTC)
@@ -231,8 +410,15 @@ class CreatorChannelHomeEndToEndTest @Autowired constructor(
return order
}
private fun saveDonation(creator: Member, donor: Member, createdAt: LocalDateTime): ChannelDonationMessage {
val donation = ChannelDonationMessage(can = 500, isSecret = false, additionalMessage = "e2e thanks")
private fun saveDonation(
creator: Member,
donor: Member,
createdAt: LocalDateTime,
can: Int = 500,
isSecret: Boolean = false,
message: String? = "e2e thanks"
): ChannelDonationMessage {
val donation = ChannelDonationMessage(can = can, isSecret = isSecret, additionalMessage = message)
donation.creator = creator
donation.member = donor
entityManager.persist(donation)
@@ -301,6 +487,13 @@ class CreatorChannelHomeEndToEndTest @Autowired constructor(
.executeUpdate()
}
private data class DonationFixture(
val creatorId: Long,
val donorId: Long,
val viewer: Member,
val donations: List<ChannelDonationMessage>
)
private data class Fixture(
val viewer: Member,
val creatorId: Long,
@@ -139,7 +139,7 @@ class CreatorChannelHomeControllerTest @Autowired constructor(
.andExpect(jsonPath("$.data.schedules[0].isAdult").doesNotExist())
.andExpect(jsonPath("$.data.channelDonations[0].donationId").doesNotExist())
.andExpect(jsonPath("$.data.channelDonations[0].memberId").doesNotExist())
.andExpect(jsonPath("$.data.channelDonations[0].isSecret").doesNotExist())
.andExpect(jsonPath("$.data.channelDonations[0].isSecret").value(true))
.andExpect(jsonPath("$.data.notices[0].dateUtc").value("2026-06-12T04:00:00Z"))
.andExpect(jsonPath("$.data.notices[0].imageUrl").doesNotExist())
.andExpect(jsonPath("$.data.notices[0].audioUrl").doesNotExist())
@@ -254,12 +254,14 @@ class CreatorChannelHomeControllerTest @Autowired constructor(
),
channelDonations = listOf(
CreatorChannelDonation(
donationId = null,
memberId = 20L,
nickname = "fan",
profileImageUrl = "fan.png",
can = 50,
message = "thanks",
createdAt = LocalDateTime.of(2026, 6, 12, 2, 0)
createdAt = LocalDateTime.of(2026, 6, 12, 2, 0),
isSecret = true
)
),
notices = listOf(post),
@@ -64,6 +64,7 @@ class CreatorChannelHomeFacadeTest {
assertTrue(response.latestAudioContent?.isOwned == true)
assertFalse(response.latestAudioContent?.isRented == true)
assertEquals("thanks", response.channelDonations.first().message)
assertTrue(response.channelDonations.first().isSecret)
assertEquals(301L, response.notices.first().postId)
assertEquals("2026-06-12T04:00:00Z", response.notices.first().dateUtc)
assertNull(response.notices.first().imageUrl)
@@ -185,12 +186,14 @@ class CreatorChannelHomeFacadeTest {
),
channelDonations = listOf(
CreatorChannelDonation(
donationId = null,
memberId = 20L,
nickname = "fan",
profileImageUrl = "fan.png",
can = 50,
message = "thanks",
createdAt = LocalDateTime.of(2026, 6, 12, 2, 0)
createdAt = LocalDateTime.of(2026, 6, 12, 2, 0),
isSecret = true
)
),
notices = listOf(post),
@@ -19,6 +19,7 @@ import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreference
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.support.hasForcedCountryMapping
import kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacade
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityDisplayContent
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
@@ -421,7 +422,7 @@ class HomeRecommendationControllerTest @Autowired constructor(
} else {
val role = if (viewerType == "ADMIN") MemberRole.ADMIN else MemberRole.USER
var candidate = saveMember("home-event-viewer", role)
while (candidate.id in setOf(16L, 17L, 17958L, 44144L, 29721L, 32050L, 37543L, 40850L)) {
while (candidate.hasForcedCountryMapping()) {
candidate = saveMember("home-event-viewer-${candidate.id}", role)
}
candidate
@@ -8,6 +8,7 @@ import kr.co.vividnext.sodalive.member.DonationRankingPeriod
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.block.BlockMember
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicy
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
@@ -15,6 +16,8 @@ import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
@@ -78,33 +81,59 @@ class DefaultCreatorChannelDonationQueryRepositoryTest @Autowired constructor(
assertFalse(repository.existsBlockedBetween(viewer.id!!, otherCreator.id!!))
}
@Test
@DisplayName("크리에이터 본인은 현재 KST 월 범위의 공개/비공개 채널 후원을 모두 조회한다")
fun shouldCountAndFindAllCurrentMonthDonationsForCreatorSelf() {
val now = LocalDateTime.of(2026, 6, 22, 3, 0)
@ParameterizedTest(name = "{0}, month={1}")
@CsvSource(
"creator, 6",
"donor, 6",
"outsider, 6",
"otherCreator, 6",
"creator, 12",
"donor, 12",
"outsider, 12",
"otherCreator, 12"
)
@DisplayName("모든 조회자는 KST 월 시작 포함·다음 월 제외 조건으로 공개·비밀 count와 목록을 동일하게 조회한다")
fun shouldCountAndFindAllCurrentMonthDonationsForEveryViewer(viewerRole: String, month: Int) {
val now = LocalDateTime.of(2026, month, 22, 3, 0)
val range = CreatorChannelDonationQueryPolicy().currentKstMonthRange(now)
val start = LocalDateTime.of(2026, month - 1, if (month == 6) 31 else 30, 15, 0)
val end = LocalDateTime.of(2026, month, if (month == 6) 30 else 31, 15, 0)
assertEquals(start, range.startInclusiveUtc)
assertEquals(end, range.endExclusiveUtc)
val creator = saveMember("donation-self-creator", MemberRole.CREATOR)
val donor = saveMember("donation-self-donor", MemberRole.USER, profileImage = "self-donor.png")
val monthStartCreatedAt = LocalDateTime.of(2026, 5, 31, 15, 0)
val monthStart = saveDonation(creator, donor, 100, monthStartCreatedAt, additionalMessage = null)
val secret = saveDonation(creator, donor, 200, LocalDateTime.of(2026, 6, 22, 2, 0), isSecret = true)
saveDonation(creator, donor, 300, LocalDateTime.of(2026, 5, 31, 14, 59, 59))
saveDonation(creator, donor, 400, LocalDateTime.of(2026, 6, 30, 15, 0))
val viewers = mapOf(
"creator" to creator,
"donor" to donor,
"outsider" to saveMember("donation-boundary-outsider", MemberRole.USER),
"otherCreator" to saveMember("donation-boundary-other-creator", MemberRole.CREATOR)
)
val viewerId = viewers.getValue(viewerRole).id!!
saveDonation(creator, donor, 100, start, isSecret = true, additionalMessage = null)
saveDonation(creator, donor, 200, now.minusHours(1))
saveDonation(creator, donor, 300, end.minusSeconds(1), isSecret = true)
saveDonation(creator, donor, 400, start.minusSeconds(1), isSecret = true)
saveDonation(creator, donor, 500, end, isSecret = true)
val otherChannel = saveMember("donation-boundary-other-channel", MemberRole.CREATOR)
saveDonation(otherChannel, donor, 600, now, isSecret = true)
flushAndClear()
val count = repository.countChannelDonations(creator.id!!, creator.id!!, now)
val records = repository.findChannelDonations(creator.id!!, creator.id!!, now, offset = 0, limit = 10, lang = Lang.KO)
val count = repository.countChannelDonations(creator.id!!, viewerId, now)
val records = repository.findChannelDonations(creator.id!!, viewerId, now, offset = 0, limit = 10, lang = Lang.KO)
assertEquals(2, count)
assertEquals(listOf(secret.can, monthStart.can), records.map { it.can })
assertEquals(3, count)
assertEquals(count, records.size)
assertEquals(listOf(300, 200, 100), records.map { it.can })
assertEquals(listOf(true, false, true), records.map { it.isSecret })
assertEquals(donor.nickname, records.last().nickname)
assertEquals(donor.profileImage, records.last().profileImagePath)
assertNull(records.last().message)
assertEquals(monthStartCreatedAt, records.last().createdAt)
assertEquals(listOf(end.minusSeconds(1), now.minusHours(1), start), records.map { it.createdAt })
}
@Test
@DisplayName("일반 조회자는 현재 KST 월 범위의 공개 후원과 본인 비공개 후원만 조회한다")
fun shouldCountAndFindOnlyVisibleCurrentMonthDonationsForViewer() {
@DisplayName("일반 조회자도 공개 후원과 본인·타인 비밀 후원을 모두 조회한다")
fun shouldCountAndFindAllCurrentMonthDonationsForViewer() {
val now = LocalDateTime.of(2026, 6, 22, 12, 0)
val creator = saveMember("donation-visible-creator", MemberRole.CREATOR)
val viewer = saveMember("donation-visible-viewer", MemberRole.USER)
@@ -118,15 +147,23 @@ class DefaultCreatorChannelDonationQueryRepositoryTest @Autowired constructor(
isSecret = true,
additionalMessage = "own secret"
)
saveDonation(creator, otherDonor, 300, now.minusHours(1), isSecret = true, additionalMessage = "hidden")
val otherSecretDonation = saveDonation(
creator,
otherDonor,
300,
now.minusHours(1),
isSecret = true,
additionalMessage = "hidden"
)
flushAndClear()
val count = repository.countChannelDonations(creator.id!!, viewer.id!!, now)
val records = repository.findChannelDonations(creator.id!!, viewer.id!!, now, offset = 0, limit = 10, lang = Lang.KO)
assertEquals(2, count)
assertEquals(listOf(ownSecretDonation.can, publicDonation.can), records.map { it.can })
assertEquals(listOf("own secret", "public"), records.map { it.message })
assertEquals(3, count)
assertEquals(listOf(otherSecretDonation.can, ownSecretDonation.can, publicDonation.can), records.map { it.can })
assertEquals(listOf(true, true, false), records.map { it.isSecret })
assertEquals(listOf("hidden", "own secret", "public"), records.map { it.message })
}
@Test
@@ -138,7 +175,7 @@ class DefaultCreatorChannelDonationQueryRepositoryTest @Autowired constructor(
val donor = saveMember("donation-order-donor", MemberRole.USER)
val sameCreatedAt = now.minusHours(1)
val first = saveDonation(creator, donor, 100, sameCreatedAt, additionalMessage = "first")
val second = saveDonation(creator, donor, 200, sameCreatedAt, additionalMessage = "second")
val second = saveDonation(creator, donor, 200, sameCreatedAt, isSecret = true, additionalMessage = "second")
saveDonation(creator, donor, 300, sameCreatedAt.plusMinutes(1), additionalMessage = "newest")
flushAndClear()
@@ -147,6 +184,94 @@ class DefaultCreatorChannelDonationQueryRepositoryTest @Autowired constructor(
assertEquals(listOf(second.can, first.can), records.map { it.can })
}
@Test
@DisplayName("공개·비밀 23건의 실제 QueryDSL 페이지 합계와 count는 모든 조회자에서 일치한다")
fun shouldKeepCountAndMixedPagesIdenticalForEveryViewer() {
val now = LocalDateTime.of(2026, 12, 22, 12, 0)
val start = CreatorChannelDonationQueryPolicy().currentKstMonthRange(now).startInclusiveUtc
val creator = saveMember("donation-page-creator", MemberRole.CREATOR)
val donor = saveMember("donation-page-donor", MemberRole.USER)
val viewers = listOf(
creator,
donor,
saveMember("donation-page-outsider", MemberRole.USER),
saveMember("donation-page-other-creator", MemberRole.CREATOR)
)
(1..23).forEach { index ->
saveDonation(creator, donor, index, start.plusHours((index / 2).toLong()), isSecret = index % 2 == 1)
}
flushAndClear()
viewers.forEach { viewer ->
val count = repository.countChannelDonations(creator.id!!, viewer.id!!, now)
val first = repository.findChannelDonations(creator.id!!, viewer.id!!, now, 0, 20, Lang.KO)
val second = repository.findChannelDonations(creator.id!!, viewer.id!!, now, 20, 20, Lang.KO)
val outOfRange = repository.findChannelDonations(creator.id!!, viewer.id!!, now, 40, 20, Lang.KO)
assertEquals(23, count)
assertEquals((23 downTo 4).toList(), first.map { it.can })
assertEquals(listOf(3, 2, 1), second.map { it.can })
assertTrue(outOfRange.isEmpty())
assertEquals(count, first.size + second.size)
(first + second).forEach { record ->
assertEquals(record.can % 2 == 1, record.isSecret)
assertEquals(start.plusHours((record.can / 2).toLong()), record.createdAt)
}
}
}
@Test
@DisplayName("숨긴 공개·비밀 후원은 모든 역할의 count와 페이지 경계에서 제외된다")
fun shouldExcludeHiddenRowsFromDonationTabCountAndPagesForEveryViewer() {
val now = LocalDateTime.of(2026, 12, 22, 12, 0)
val creator = saveMember("hidden-page-creator", MemberRole.CREATOR)
val donor = saveMember("hidden-page-donor", MemberRole.USER)
val viewers = listOf(
creator,
donor,
saveMember("hidden-page-outsider", MemberRole.USER),
saveMember("hidden-page-other-creator", MemberRole.CREATOR),
saveMember("hidden-page-admin", MemberRole.ADMIN)
)
val rows = (1..25).map { index ->
saveDonation(creator, donor, index, now, isSecret = index % 2 == 1, isHidden = index >= 23)
}
flushAndClear()
viewers.forEach { viewer ->
assertEquals(22, repository.countChannelDonations(creator.id!!, viewer.id!!, now))
val first = repository.findChannelDonations(creator.id!!, viewer.id!!, now, 0, 20, Lang.KO)
val second = repository.findChannelDonations(creator.id!!, viewer.id!!, now, 20, 20, Lang.KO)
assertEquals((22 downTo 3).toList(), first.map { it.can })
assertEquals(listOf(2, 1), second.map { it.can })
assertEquals(rows.take(22).reversed().map { it.id }, (first + second).map { it.donationId })
}
rows.take(2).forEach { row ->
entityManager.find(ChannelDonationMessage::class.java, row.id!!).isHidden = true
}
flushAndClear()
assertEquals(20, repository.countChannelDonations(creator.id!!, donor.id!!, now))
assertEquals(20, repository.findChannelDonations(creator.id!!, donor.id!!, now, 0, 21, Lang.KO).size)
assertTrue(repository.findChannelDonations(creator.id!!, donor.id!!, now, 20, 21, Lang.KO).isEmpty())
}
@Test
@DisplayName("모든 후원이 숨김이면 모든 역할의 count는 0이고 목록은 비어 있다")
fun shouldReturnZeroCountAndEmptyPagesWhenAllDonationsAreHidden() {
val now = LocalDateTime.of(2026, 12, 22, 12, 0)
val creator = saveMember("all-hidden-creator", MemberRole.CREATOR)
val donor = saveMember("all-hidden-donor", MemberRole.USER)
listOf(false, true).forEach { secret ->
saveDonation(creator, donor, 100, now, isSecret = secret, isHidden = true)
}
flushAndClear()
listOf(creator, donor).forEach { viewer ->
assertEquals(0, repository.countChannelDonations(creator.id!!, viewer.id!!, now))
assertTrue(repository.findChannelDonations(creator.id!!, viewer.id!!, now, 0, 21, Lang.KO).isEmpty())
}
}
@Test
@DisplayName("후원 탭 repository 목록 조회는 entity 전체 fetch 없이 필요한 컬럼 projection만 사용한다")
fun shouldUseProjectionForDonationList() {
@@ -203,9 +328,11 @@ class DefaultCreatorChannelDonationQueryRepositoryTest @Autowired constructor(
can: Int,
createdAt: LocalDateTime,
isSecret: Boolean = false,
additionalMessage: String? = "thanks"
additionalMessage: String? = "thanks",
isHidden: Boolean = false
): ChannelDonationMessage {
val donation = ChannelDonationMessage(can = can, isSecret = isSecret, additionalMessage = additionalMessage)
donation.isHidden = isHidden
donation.creator = creator
donation.member = donor
entityManager.persist(donation)
@@ -0,0 +1,200 @@
package kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.HideCreatorChannelDonationPort
import org.hibernate.resource.jdbc.spi.StatementInspector
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.autoconfigure.orm.jpa.HibernatePropertiesCustomizer
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Import
import java.time.LocalDateTime
import javax.persistence.EntityManager
@DataJpaTest(properties = ["spring.cache.type=none"])
@Import(
QueryDslConfig::class,
DefaultHideCreatorChannelDonationRepository::class,
DefaultHideCreatorChannelDonationRepositoryTest.SqlCaptureConfiguration::class
)
class DefaultHideCreatorChannelDonationRepositoryTest @Autowired constructor(
private val entityManager: EntityManager,
private val repository: HideCreatorChannelDonationPort,
private val sqlCapture: SqlCaptureConfiguration
) {
@Test
@DisplayName("새 후원은 기본 비숨김 상태로 저장된다")
fun shouldPersistDonationAsVisibleByDefault() {
val creator = saveMember("hide-default-creator", MemberRole.CREATOR)
val donor = saveMember("hide-default-donor", MemberRole.USER)
val donationId = saveDonation(creator, donor)
val donation = reloadDonation(donationId)
assertFalse(donation.isHidden)
assertOriginalValues(donation, donationId, creator.id!!, donor.id!!, false)
}
@Test
@DisplayName("숨김 UPDATE는 MySQL 자동 갱신을 막도록 수정 시각을 자기 대입한다")
fun shouldPreserveUpdatedAtWithSelfAssignmentInGeneratedHideSql() {
val creator = saveMember("hide-sql-creator", MemberRole.CREATOR)
val donor = saveMember("hide-sql-donor", MemberRole.USER)
val donationId = saveDonation(creator, donor)
val updatedAt = requireNotNull(reloadDonation(donationId).updatedAt)
sqlCapture.statements.clear()
assertTrue(repository.hideOwnedDonation(creator.id!!, donationId))
val updateSql = sqlCapture.statements.single { it.startsWith("update channel_donation_message ", ignoreCase = true) }
assertTrue(Regex("""(?i)\bupdated_at\s*=\s*(?:\w+\.)?updated_at\b""").containsMatchIn(updateSql), updateSql)
val donation = reloadDonation(donationId)
assertTrue(donation.isHidden)
assertEquals(updatedAt, donation.updatedAt)
}
@ParameterizedTest
@ValueSource(booleans = [false, true])
@DisplayName("소유 채널의 선택한 공개·비밀 후원 한 건만 숨기고 원본 값을 보존한다")
fun shouldHideOnlyDonationOwnedByCreatorAndRetainOriginalValues(isSecret: Boolean) {
val creator = saveMember("hide-owned-creator", MemberRole.CREATOR)
val otherCreator = saveMember("hide-other-creator", MemberRole.CREATOR)
val donor = saveMember("hide-owned-donor", MemberRole.USER)
val donationId = saveDonation(creator, donor, isSecret)
val sameChannelDonationId = saveDonation(creator, donor)
val otherChannelDonationId = saveDonation(otherCreator, donor)
assertFalse(reloadDonation(donationId).isHidden)
assertTrue(repository.hideOwnedDonation(creator.id!!, donationId))
val donation = reloadDonation(donationId)
assertTrue(donation.isHidden)
assertOriginalValues(donation, donationId, creator.id!!, donor.id!!, isSecret)
val sameChannelDonation = reloadDonation(sameChannelDonationId)
assertFalse(sameChannelDonation.isHidden)
assertOriginalValues(sameChannelDonation, sameChannelDonationId, creator.id!!, donor.id!!, false)
val otherChannelDonation = reloadDonation(otherChannelDonationId)
assertFalse(otherChannelDonation.isHidden)
assertOriginalValues(otherChannelDonation, otherChannelDonationId, otherCreator.id!!, donor.id!!, false)
assertEquals(3L, donationCount())
}
@Test
@DisplayName("다른 채널의 후원은 숨기지 않고 실패를 반환한다")
fun shouldRejectDonationFromAnotherCreatorWithoutChangingIt() {
val creator = saveMember("hide-reject-creator", MemberRole.CREATOR)
val otherCreator = saveMember("hide-reject-other", MemberRole.CREATOR)
val donor = saveMember("hide-reject-donor", MemberRole.USER)
val donationId = saveDonation(creator, donor, isSecret = true)
reloadDonation(donationId)
assertFalse(repository.hideOwnedDonation(otherCreator.id!!, donationId))
val donation = reloadDonation(donationId)
assertFalse(donation.isHidden)
assertOriginalValues(donation, donationId, creator.id!!, donor.id!!, true)
assertEquals(1L, donationCount())
}
@Test
@DisplayName("존재하지 않는 후원 ID는 기존 후원을 바꾸지 않고 실패를 반환한다")
fun shouldRejectMissingDonationWithoutChangingExistingDonation() {
val creator = saveMember("hide-missing-creator", MemberRole.CREATOR)
val donor = saveMember("hide-missing-donor", MemberRole.USER)
val donationId = saveDonation(creator, donor)
reloadDonation(donationId)
assertFalse(repository.hideOwnedDonation(creator.id!!, Long.MAX_VALUE))
val donation = reloadDonation(donationId)
assertFalse(donation.isHidden)
assertOriginalValues(donation, donationId, creator.id!!, donor.id!!, false)
assertEquals(1L, donationCount())
}
@Test
@DisplayName("같은 후원의 반복 숨김은 성공하며 다른 채널 요청은 거부하고 원본 값을 보존한다")
fun shouldSucceedWhenHidingAgainWithoutChangingOriginalValues() {
val creator = saveMember("hide-repeat-creator", MemberRole.CREATOR)
val otherCreator = saveMember("hide-repeat-other", MemberRole.CREATOR)
val donor = saveMember("hide-repeat-donor", MemberRole.USER)
val donationId = saveDonation(creator, donor, isSecret = true)
reloadDonation(donationId)
assertTrue(repository.hideOwnedDonation(creator.id!!, donationId))
assertTrue(reloadDonation(donationId).isHidden)
assertTrue(repository.hideOwnedDonation(creator.id!!, donationId))
assertFalse(repository.hideOwnedDonation(otherCreator.id!!, donationId))
val donation = reloadDonation(donationId)
assertTrue(donation.isHidden)
assertOriginalValues(donation, donationId, creator.id!!, donor.id!!, true)
assertEquals(1L, donationCount())
}
private fun saveMember(nickname: String, role: MemberRole): Member {
val member = Member(email = "$nickname@test.com", password = "password", nickname = nickname, role = role)
entityManager.persist(member)
return member
}
private fun saveDonation(creator: Member, donor: Member, isSecret: Boolean = false): Long {
val donation = ChannelDonationMessage(can = 120, isSecret = isSecret, additionalMessage = "original message")
donation.creator = creator
donation.member = donor
entityManager.persist(donation)
donation.createdAt = LocalDateTime.of(2026, 11, 5, 0, 0)
return requireNotNull(donation.id)
}
private fun reloadDonation(donationId: Long): ChannelDonationMessage {
entityManager.flush()
entityManager.clear()
return requireNotNull(entityManager.find(ChannelDonationMessage::class.java, donationId))
}
private fun assertOriginalValues(
donation: ChannelDonationMessage,
donationId: Long,
creatorId: Long,
donorId: Long,
isSecret: Boolean
) {
assertEquals(donationId, donation.id)
assertEquals(120, donation.can)
assertEquals("original message", donation.additionalMessage)
assertEquals(isSecret, donation.isSecret)
assertEquals(creatorId, donation.creator?.id)
assertEquals(donorId, donation.member?.id)
assertEquals(LocalDateTime.of(2026, 11, 5, 0, 0), donation.createdAt)
}
private fun donationCount(): Long {
return entityManager.createQuery("select count(d) from ChannelDonationMessage d", java.lang.Long::class.java)
.singleResult.toLong()
}
@TestConfiguration
class SqlCaptureConfiguration : HibernatePropertiesCustomizer, StatementInspector {
val statements = mutableListOf<String>()
override fun customize(hibernateProperties: MutableMap<String, Any>) {
hibernateProperties["hibernate.session_factory.statement_inspector"] = this
}
override fun inspect(sql: String): String {
statements.add(sql)
return sql
}
}
}
@@ -17,6 +17,8 @@ import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.mockito.Mockito
import org.springframework.beans.factory.ObjectProvider
import java.time.LocalDateTime
@@ -154,6 +156,58 @@ class CreatorChannelDonationQueryServiceTest {
assertEquals("thanks", tab.donations[1].message)
}
@ParameterizedTest
@CsvSource(
"1, CREATOR, true",
"20, USER, true",
"10, USER, true",
"30, CREATOR, true",
"1, USER, true",
"1, ADMIN, true",
"40, ADMIN, true",
"1, CREATOR, false",
"20, USER, false",
"10, USER, false",
"30, CREATOR, false"
)
@DisplayName("후원 mapper는 비밀 여부와 ID로만 마스킹하고 원본 이름·프로필·null 메시지 계약을 유지한다")
fun shouldMaskSecretDonationOnlyForOtherViewers(viewerId: Long, role: MemberRole, isSecret: Boolean) {
listOf(false, true).forEach { isLocalized ->
listOf(null, "profile/donor.png").forEach { profilePath ->
val messages = listOf(null, "", "private thanks")
val port = FakeDonationQueryPort(
donations = messages.map { message ->
createDonationRecord(
memberId = 20L,
nickname = "deleted_donor",
profileImagePath = profilePath,
message = message,
isSecret = isSecret,
isLocalizedName = isLocalized
)
},
donationCount = messages.size
)
val tab = createService(port).getDonationTab(CREATOR_ID, createMember(viewerId, role), 0, 20, NOW, Lang.KO)
assertEquals(3, tab.donationCount)
assertEquals(3, tab.donations.size)
val masked = isSecret && viewerId != CREATOR_ID && viewerId != 20L
tab.donations.zip(messages).forEach { (donation, message) ->
val originalNickname = if (isLocalized) "deleted_donor" else "donor"
assertEquals(if (masked) "익명" else originalNickname, donation.nickname)
assertEquals(if (masked) "크리에이터에게만 보여요" else message.orEmpty(), donation.message)
val profile = if (masked) "profile/anonymous-profile.png" else profilePath ?: "profile/default-profile.png"
assertEquals("https://cdn.test/$profile", donation.profileImageUrl)
assertEquals(masked, donation.isSecret)
assertEquals(100, donation.can)
assertEquals(NOW, donation.createdAt)
assertEquals(if (role == MemberRole.CREATOR && viewerId == CREATOR_ID) 101L else null, donation.donationId)
}
}
}
}
@Test
@DisplayName("조회자가 크리에이터 본인이면 순위 공개 여부와 무관하게 donationCan 포함 랭킹을 조회한다")
fun shouldFetchRankingsWithDonationCanForCreatorViewer() {
@@ -280,19 +334,25 @@ class CreatorChannelDonationQueryServiceTest {
}
private fun createDonationRecord(
memberId: Long = 20L,
nickname: String = "donor",
profileImagePath: String? = "profile/donor.png",
can: Int = 100,
message: String? = "thanks",
createdAt: LocalDateTime = NOW
createdAt: LocalDateTime = NOW,
isSecret: Boolean = false,
isLocalizedName: Boolean = false
): CreatorChannelDonationRecord {
return CreatorChannelDonationRecord(
memberId = VIEWER_ID,
donationId = 101L,
memberId = memberId,
nickname = nickname,
profileImagePath = profileImagePath,
can = can,
message = message,
createdAt = createdAt
createdAt = createdAt,
isSecret = isSecret,
isLocalizedName = isLocalizedName
)
}
@@ -305,12 +365,12 @@ class CreatorChannelDonationQueryServiceTest {
)
}
private fun createMember(id: Long): Member {
private fun createMember(id: Long, role: MemberRole = MemberRole.USER): Member {
return Member(
email = "viewer$id@test.com",
password = "password",
nickname = "viewer$id",
role = MemberRole.USER
role = role
).apply { this.id = id }
}
@@ -0,0 +1,159 @@
package kr.co.vividnext.sodalive.v2.creator.channel.donation.application
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.EnumSource
import org.junit.jupiter.params.provider.ValueSource
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.annotation.DirtiesContext
import org.springframework.test.context.ContextConfiguration
import org.springframework.transaction.support.TransactionTemplate
import java.time.LocalDateTime
import javax.persistence.EntityManager
@SpringBootTest(
properties = [
"spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:hide-donation-service;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE"
]
)
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
class HideCreatorChannelDonationServiceTest @Autowired constructor(
private val service: HideCreatorChannelDonationService,
private val entityManager: EntityManager,
private val transactionTemplate: TransactionTemplate
) {
@ParameterizedTest
@ValueSource(booleans = [false, true])
@DisplayName("서비스는 소유 CREATOR의 공개·비밀 후원을 숨기고 DB 원본을 보존한다")
fun shouldHideOwnedPublicAndSecretDonationAndPreserveOriginalValues(isSecret: Boolean) {
val fixture = createFixture(isSecret = isSecret)
val before = originalValues(fixture.donationId)
service.hide(fixture.creator.id!!, fixture.donationId, fixture.creator)
assertEquals(before, originalValues(fixture.donationId))
assertHidden(fixture.donationId, true)
}
@ParameterizedTest
@EnumSource(value = MemberRole::class, names = ["USER", "ADMIN"])
@DisplayName("서비스는 채널 ID가 같아도 CREATOR가 아닌 actor를 거부한다")
fun shouldRejectNonCreatorEvenWhenActorIdMatchesChannel(role: MemberRole) {
val fixture = createFixture(role = role)
val before = originalValues(fixture.donationId)
val exception = assertThrows(SodaException::class.java) {
service.hide(fixture.creator.id!!, fixture.donationId, fixture.creator)
}
assertEquals("common.error.access_denied", exception.messageKey)
assertEquals(before, originalValues(fixture.donationId))
assertHidden(fixture.donationId, false)
}
@ParameterizedTest
@EnumSource(value = MemberRole::class, names = ["USER", "ADMIN", "CREATOR"])
@DisplayName("서비스는 비소유 actor를 대상 존재 여부 확인보다 먼저 거부한다")
fun shouldRejectNonOwnerBeforeCheckingDonationExistence(role: MemberRole) {
val fixture = createFixture()
val actor = transactionTemplate.execute { saveMember("hide-service-outsider", role) }!!
val before = originalValues(fixture.donationId)
listOf(fixture.donationId, Long.MAX_VALUE).forEach { donationId ->
val exception = assertThrows(SodaException::class.java) {
service.hide(fixture.creator.id!!, donationId, actor)
}
assertEquals("common.error.access_denied", exception.messageKey)
}
assertEquals(before, originalValues(fixture.donationId))
assertHidden(fixture.donationId, false)
}
@Test
@DisplayName("서비스는 소속이 다른 후원과 미존재 ID를 같은 오류로 거부한다")
fun shouldRejectOtherChannelAndMissingDonationWithSameError() {
val fixture = createFixture()
val otherCreator = transactionTemplate.execute { saveMember("hide-service-other", MemberRole.CREATOR) }!!
val before = originalValues(fixture.donationId)
listOf(otherCreator to fixture.donationId, fixture.creator to Long.MAX_VALUE).forEach { (actor, donationId) ->
val exception = assertThrows(SodaException::class.java) {
service.hide(actor.id!!, donationId, actor)
}
assertEquals("common.error.invalid_request", exception.messageKey)
}
assertEquals(before, originalValues(fixture.donationId))
assertHidden(fixture.donationId, false)
}
@Test
@DisplayName("서비스의 중복 숨김은 성공하고 원본을 보존한다")
fun shouldSucceedWhenHidingAgainWithoutChangingOriginalValues() {
val fixture = createFixture(isSecret = true)
val before = originalValues(fixture.donationId)
repeat(2) {
service.hide(fixture.creator.id!!, fixture.donationId, fixture.creator)
assertEquals(before, originalValues(fixture.donationId))
assertHidden(fixture.donationId, true)
}
}
private fun createFixture(role: MemberRole = MemberRole.CREATOR, isSecret: Boolean = false): Fixture {
return transactionTemplate.execute {
val creator = saveMember("hide-service-creator", role)
val donor = saveMember("hide-service-donor", MemberRole.USER)
val donation = ChannelDonationMessage(120, isSecret, "service original").apply {
this.creator = creator
member = donor
}
entityManager.persist(donation)
donation.createdAt = LocalDateTime.of(2026, 11, 5, 0, 0)
entityManager.flush()
entityManager.clear()
Fixture(creator, donation.id!!)
}!!
}
private fun saveMember(nickname: String, role: MemberRole): Member {
return Member(email = "$nickname@test.com", password = "password", nickname = nickname, role = role)
.also { entityManager.persist(it) }
}
private fun originalValues(donationId: Long): List<Any?> = transactionTemplate.execute {
entityManager.clear()
val donation = requireNotNull(entityManager.find(ChannelDonationMessage::class.java, donationId))
listOf(
donation.id,
donation.can,
donation.additionalMessage,
donation.isSecret,
donation.creator?.id,
donation.member?.id,
donation.createdAt
)
}!!
private fun assertHidden(donationId: Long, hidden: Boolean) {
transactionTemplate.executeWithoutResult {
entityManager.clear()
val donation = requireNotNull(entityManager.find(ChannelDonationMessage::class.java, donationId))
assertEquals(hidden, donation.isHidden)
}
}
private data class Fixture(val creator: Member, val donationId: Long)
}
@@ -78,12 +78,14 @@ class CreatorChannelDonationQueryPolicyTest {
donationCan = 100
)
val donation = CreatorChannelDonation(
donationId = 101L,
memberId = 10L,
nickname = "donor",
profileImageUrl = "https://cdn.test/donor.png",
can = 50,
message = "thanks",
createdAt = createdAt
createdAt = createdAt,
isSecret = true
)
val tab = CreatorChannelDonationTab(
donationCount = 1,
@@ -100,12 +102,14 @@ class CreatorChannelDonationQueryPolicyTest {
donationRankingPeriod = DonationRankingPeriod.CUMULATIVE
)
val donationRecord = CreatorChannelDonationRecord(
donationId = 101L,
memberId = 10L,
nickname = "donor",
profileImagePath = null,
can = 50,
message = "thanks",
createdAt = createdAt
createdAt = createdAt,
isSecret = true
)
val rankingRecord = CreatorChannelDonationRankingRecord(
userId = 10L,
@@ -122,6 +126,8 @@ class CreatorChannelDonationQueryPolicyTest {
assertEquals(MemberRole.CREATOR, creatorRecord.role)
assertEquals(DonationRankingPeriod.CUMULATIVE, creatorRecord.donationRankingPeriod)
assertNull(donationRecord.profileImagePath)
assertTrue(donationRecord.isSecret)
assertTrue(tab.donations.first().isSecret)
assertEquals(100, rankingRecord.donationCan)
}
}
@@ -779,13 +779,16 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
}
@Test
@DisplayName("채널 후원은 KST 기준 이번 달과 크리에이터의 비밀 후원 열람을 반영한다")
fun shouldFindKstMonthDonationsAndExposeSecretDonationToCreator() {
@DisplayName("채널 후원은 KST 기준 이번 달 공개·비밀 후원을 조회자와 무관하게 포함한다")
fun shouldFindAllKstMonthDonationsForEveryViewer() {
val now = LocalDateTime.of(2026, 5, 31, 15, 30)
val creator = saveMember("kst-donation-creator", MemberRole.CREATOR)
val donor = saveMember("kst-donation-donor", MemberRole.USER)
saveDonation(creator, donor, 300, LocalDateTime.of(2026, 5, 31, 15, 10), isSecret = true)
saveDonation(creator, donor, 100, LocalDateTime.of(2026, 5, 31, 14, 50), isSecret = false)
saveDonation(creator, donor, 200, LocalDateTime.of(2026, 5, 31, 15, 0), isSecret = true)
saveDonation(creator, donor, 400, LocalDateTime.of(2026, 6, 30, 14, 59, 59), isSecret = true)
saveDonation(creator, donor, 500, LocalDateTime.of(2026, 6, 30, 15, 0), isSecret = true)
val thirdParty = saveMember("kst-donation-third-party", MemberRole.USER)
flushAndClear()
@@ -793,9 +796,78 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
val donorView = repository.findChannelDonations(creator.id!!, donor.id!!, now, limit = 8, lang = Lang.KO)
val thirdPartyView = repository.findChannelDonations(creator.id!!, thirdParty.id!!, now, limit = 8, lang = Lang.KO)
assertEquals(listOf(300), creatorView.map { it.can })
assertEquals(listOf(300), donorView.map { it.can })
assertEquals(emptyList<Int>(), thirdPartyView.map { it.can })
assertEquals(listOf(400, 300, 200), creatorView.map { it.can })
assertEquals(listOf(400, 300, 200), donorView.map { it.can })
assertEquals(listOf(400, 300, 200), thirdPartyView.map { it.can })
assertEquals(listOf(true, true, true), thirdPartyView.map { it.isSecret })
assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0), thirdPartyView.last().createdAt)
}
@Test
@DisplayName("홈 후원은 공개·비밀 후보 전체 중 생성 시각과 id 내림차순으로 최신 8건을 조회한다")
fun shouldFindLatestEightAcrossPublicAndSecretDonations() {
val now = LocalDateTime.of(2026, 6, 12, 12, 0)
val creator = saveMember("mixed-home-creator", MemberRole.CREATOR)
val donor = saveMember("mixed-home-donor", MemberRole.USER)
val viewer = saveMember("mixed-home-viewer", MemberRole.USER)
(1..10).forEach { index ->
saveDonation(creator, donor, index, now.minusMinutes((index / 2).toLong()), isSecret = index % 2 == 1)
}
flushAndClear()
val records = repository.findChannelDonations(creator.id!!, viewer.id!!, now, limit = 8, lang = Lang.KO)
assertEquals(listOf(1, 3, 2, 5, 4, 7, 6, 9), records.map { it.can })
assertEquals(listOf(true, true, false, true, false, true, false, true), records.map { it.isSecret })
}
@Test
@DisplayName("홈은 최신 숨김 공개·비밀 후보를 제외한 뒤 동시각 ID순으로 visible 8건을 보충한다")
fun shouldRefillHomeWithLatestEightVisibleDonationsAfterHiddenRowsAreExcluded() {
val now = LocalDateTime.of(2026, 6, 12, 12, 0)
val creator = saveMember("hidden-home-creator", MemberRole.CREATOR)
val donor = saveMember("hidden-home-donor", MemberRole.USER)
val viewers = listOf(
creator,
donor,
saveMember("hidden-home-outsider", MemberRole.USER),
saveMember("hidden-home-other-creator", MemberRole.CREATOR),
saveMember("hidden-home-admin", MemberRole.ADMIN)
)
val rows = (1..12).map { index ->
saveDonation(
creator,
donor,
index,
now.plusMinutes((index / 2).toLong()),
isSecret = index % 2 == 1,
isHidden = index >= 10
)
}
flushAndClear()
viewers.forEach { viewer ->
val records = repository.findChannelDonations(creator.id!!, viewer.id!!, now, limit = 8, lang = Lang.KO)
assertEquals((9 downTo 2).toList(), records.map { it.can })
assertEquals(rows.take(9).drop(1).reversed().map { it.id }, records.map { it.donationId })
assertEquals(listOf(true, false, true, false, true, false, true, false), records.map { it.isSecret })
}
}
@Test
@DisplayName("모든 후원이 숨김이면 홈 후원은 빈 목록이다")
fun shouldReturnEmptyHomeDonationsWhenAllRowsAreHidden() {
val now = LocalDateTime.of(2026, 6, 12, 12, 0)
val creator = saveMember("all-hidden-home-creator", MemberRole.CREATOR)
val donor = saveMember("all-hidden-home-donor", MemberRole.USER)
listOf(false, true).forEach { secret ->
saveDonation(creator, donor, 100, now, isSecret = secret, isHidden = true)
}
flushAndClear()
listOf(creator, donor).forEach { viewer ->
assertTrue(repository.findChannelDonations(creator.id!!, viewer.id!!, now, limit = 8, lang = Lang.KO).isEmpty())
}
}
@Test
@@ -1353,9 +1425,11 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
can: Int,
createdAt: LocalDateTime,
isSecret: Boolean = false,
additionalMessage: String? = "thanks"
additionalMessage: String? = "thanks",
isHidden: Boolean = false
): ChannelDonationMessage {
val donation = ChannelDonationMessage(can = can, isSecret = isSecret, additionalMessage = additionalMessage)
donation.isHidden = isHidden
donation.creator = creator
donation.member = donor
entityManager.persist(donation)
@@ -54,6 +54,8 @@ import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.mockito.Mockito
import org.springframework.beans.factory.ObjectProvider
import java.time.LocalDateTime
@@ -196,6 +198,53 @@ class CreatorChannelHomeQueryServiceTest {
assertEquals("donor", home.channelDonations.first().nickname)
}
@ParameterizedTest
@CsvSource(
"1, CREATOR, true",
"20, USER, true",
"10, USER, true",
"30, CREATOR, true",
"1, USER, true",
"1, ADMIN, true",
"40, ADMIN, true",
"1, CREATOR, false",
"20, USER, false",
"10, USER, false",
"30, CREATOR, false"
)
@DisplayName("홈 mapper는 비밀 여부와 조회자 ID로만 익명화하며 원본 이름과 프로필 기본값을 보존한다")
fun shouldMaskSecretDonationOnlyForOtherViewers(viewerId: Long, role: MemberRole, isSecret: Boolean) {
listOf(false, true).forEach { isLocalized ->
listOf(null, "profile/fan.png").forEach { profilePath ->
val port = FakeCreatorChannelHomeQueryPort(donorId = 20L).apply {
channelDonationNickname = "deleted_donor"
channelDonationIsSecret = isSecret
channelDonationIsLocalizedName = isLocalized
channelDonationProfilePath = profilePath
}
val service = createService(port)
val home = service.getHome(
1L,
createMember(viewerId, role = role),
LocalDateTime.of(2026, 6, 13, 10, 0),
Lang.KO
)
val donation = home.channelDonations.single()
val masked = isSecret && viewerId != 1L && viewerId != 20L
val expectedNickname = if (isLocalized) "deleted_donor" else "donor"
assertEquals(if (masked) "익명" else expectedNickname, donation.nickname)
assertEquals(if (masked) "크리에이터에게만 보여요" else "thanks", donation.message)
val originalProfile = profilePath ?: "profile/default-profile.png"
val expectedProfile = if (masked) "profile/anonymous-profile.png" else originalProfile
assertEquals("https://cdn.test/$expectedProfile", donation.profileImageUrl)
assertEquals(masked, donation.isSecret)
assertEquals(30, donation.can)
assertEquals(LocalDateTime.of(2026, 6, 13, 8, 0), donation.createdAt)
assertEquals(if (role == MemberRole.CREATOR && viewerId == 1L) 101L else null, donation.donationId)
}
}
}
@Test
@DisplayName("creatorId에 해당하는 회원이 없으면 user_not_found 예외를 던진다")
fun shouldThrowUserNotFoundWhenCreatorMemberDoesNotExist() {
@@ -350,7 +399,9 @@ class CreatorChannelHomeQueryServiceTest {
assertEquals("thanks", json["channelDonations"][0]["message"].asText())
assertFalse(json["channelDonations"][0].has("donation" + "Id"))
assertFalse(json["channelDonations"][0].has("member" + "Id"))
assertFalse(json["channelDonations"][0].has("is" + "Secret"))
assertTrue(json["channelDonations"][0]["isSecret"].isBoolean)
assertFalse(json["channelDonations"][0]["isSecret"].asBoolean())
assertFalse(json["channelDonations"][0].has("secret"))
}
private fun createHome(): CreatorChannelHome {
@@ -408,12 +459,14 @@ class CreatorChannelHomeQueryServiceTest {
),
channelDonations = listOf(
CreatorChannelDonation(
donationId = null,
memberId = 20L,
nickname = "fan",
profileImageUrl = "fan.png",
can = 50,
message = "thanks",
createdAt = LocalDateTime.of(2026, 6, 12, 2, 0)
createdAt = LocalDateTime.of(2026, 6, 12, 2, 0),
isSecret = false
)
),
notices = listOf(post),
@@ -564,7 +617,7 @@ class CreatorChannelHomeQueryServiceTest {
}
}
private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort {
private class FakeCreatorChannelHomeQueryPort(private val donorId: Long = 20L) : CreatorChannelHomeQueryPort {
var creator: CreatorChannelCreatorRecord? = CreatorChannelCreatorRecord(
creatorId = 1L,
role = MemberRole.CREATOR,
@@ -593,6 +646,9 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort {
var audioContentsLocale: String? = null
var seriesLocale: String? = null
var channelDonationNickname: String = "fan"
var channelDonationIsSecret: Boolean = false
var channelDonationIsLocalizedName: Boolean = false
var channelDonationProfilePath: String? = "profile/fan.png"
override fun findCreator(creatorId: Long, viewerId: Long?, lang: Lang): CreatorChannelCreatorRecord? = creator
@@ -639,12 +695,15 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort {
lang: Lang
): List<CreatorChannelDonationRecord> = listOf(
CreatorChannelDonationRecord(
memberId = 20L,
donationId = 101L,
memberId = donorId,
nickname = channelDonationNickname,
profileImagePath = "profile/fan.png",
profileImagePath = channelDonationProfilePath,
can = 30,
message = "thanks",
createdAt = LocalDateTime.of(2026, 6, 13, 8, 0)
createdAt = LocalDateTime.of(2026, 6, 13, 8, 0),
isSecret = channelDonationIsSecret,
isLocalizedName = channelDonationIsLocalizedName
)
)