docs(channel-donation): 후원 숨김 계약과 구현 검증 결과를 기록한다

This commit is contained in:
2026-11-05 02:21:28 +09:00
parent 4fbe4ced28
commit 6ab901fe0a
2 changed files with 739 additions and 0 deletions
@@ -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를 호출하고 두 조회 결과를 갱신해야 한다. 화면 클라이언트·운영 배포 완료를 의미하지 않는다.