diff --git a/docs/20260929_크리에이터_선물하기/gift-schema.sql b/docs/20260929_크리에이터_선물하기/gift-schema.sql new file mode 100644 index 00000000..f8302a87 --- /dev/null +++ b/docs/20260929_크리에이터_선물하기/gift-schema.sql @@ -0,0 +1,111 @@ +CREATE TABLE gift_category ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '내부 기본키', + category_code VARCHAR(50) NOT NULL COMMENT '내부 분류번호', + name VARCHAR(50) NOT NULL COMMENT '카테고리 표시명', + requires_damage_waiver TINYINT(1) NOT NULL COMMENT '파손면책 동의 필요 여부', + is_active TINYINT(1) NOT NULL COMMENT '활성 여부', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각', + PRIMARY KEY (id), + UNIQUE KEY uk_gift_category_code (category_code) +) COMMENT='선물 카테고리'; + +CREATE TABLE gift ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '내부 기본키', + application_no VARCHAR(20) NOT NULL COMMENT '신청번호', + sender_member_id BIGINT NOT NULL COMMENT '보내는 팬 회원번호', + recipient_member_id BIGINT NOT NULL COMMENT '받는 크리에이터 회원번호', + status VARCHAR(30) NOT NULL COMMENT '선물 상태', + size_code VARCHAR(20) NOT NULL COMMENT '선물 사이즈 코드', + category_id BIGINT NOT NULL COMMENT '신청 시 카테고리 ID', + category_name_snapshot VARCHAR(50) NOT NULL COMMENT '신청 시 카테고리명 스냅샷', + sale_price_can INT NOT NULL COMMENT '신청 시 결제 금액 스냅샷', + can_usage_id BIGINT NOT NULL COMMENT '캔 사용내역 ID', + sender_terms_agreed TINYINT(1) NOT NULL COMMENT '팬 이용약관 동의 여부', + sender_privacy_agreed TINYINT(1) NOT NULL COMMENT '팬 개인정보 동의 여부', + sender_terms_agreed_at TIMESTAMP NOT NULL COMMENT '팬 이용약관 동의 시각', + sender_privacy_agreed_at TIMESTAMP NOT NULL COMMENT '팬 개인정보 동의 시각', + recipient_terms_agreed TINYINT(1) NOT NULL COMMENT '크리에이터 이용약관 동의 여부', + recipient_privacy_agreed TINYINT(1) NOT NULL COMMENT '크리에이터 개인정보 동의 여부', + recipient_terms_agreed_at TIMESTAMP NULL COMMENT '크리에이터 이용약관 동의 시각', + recipient_privacy_agreed_at TIMESTAMP NULL COMMENT '크리에이터 개인정보 동의 시각', + damage_waiver_agreed TINYINT(1) NOT NULL COMMENT '파손면책 동의 여부', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각', + PRIMARY KEY (id), + UNIQUE KEY uk_gift_application_no (application_no), + CONSTRAINT fk_gift_sender_member FOREIGN KEY (sender_member_id) REFERENCES member (id), + CONSTRAINT fk_gift_recipient_member FOREIGN KEY (recipient_member_id) REFERENCES member (id), + CONSTRAINT fk_gift_category FOREIGN KEY (category_id) REFERENCES gift_category (id), + CONSTRAINT fk_gift_can_usage FOREIGN KEY (can_usage_id) REFERENCES use_can (id) +) COMMENT='선물 신청'; + +CREATE TABLE gift_delivery ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '내부 기본키', + gift_id BIGINT NOT NULL COMMENT '선물 ID', + sender_name VARCHAR(50) NOT NULL COMMENT '발신자 이름', + sender_phone_number VARCHAR(30) NOT NULL COMMENT '발신자 휴대폰 번호', + sender_zip_code VARCHAR(20) NOT NULL COMMENT '발신자 우편번호', + sender_address VARCHAR(255) NOT NULL COMMENT '발신자 주소', + sender_address_detail VARCHAR(255) NULL COMMENT '발신자 상세주소', + recipient_name VARCHAR(50) NULL COMMENT '수신자 이름', + recipient_phone_number VARCHAR(30) NULL COMMENT '수신자 휴대폰 번호', + recipient_zip_code VARCHAR(20) NULL COMMENT '수신자 우편번호', + recipient_address VARCHAR(255) NULL COMMENT '수신자 주소', + recipient_address_detail VARCHAR(255) NULL COMMENT '수신자 상세주소', + courier_company_name VARCHAR(50) NULL COMMENT '택배사 한글 표시명', + tracking_number VARCHAR(100) NULL COMMENT '운송장 번호', + tracking_deadline_at TIMESTAMP NOT NULL COMMENT '운송장 등록 기한', + recipient_address_deadline_at TIMESTAMP NULL COMMENT '수신자 배송지 입력 기한', + tracking_deadline_reminder_sent_at TIMESTAMP NULL COMMENT '운송장 등록 기한 안내 발송 시각', + recipient_address_deadline_reminder_sent_at TIMESTAMP NULL COMMENT '수신자 배송지 입력 기한 안내 발송 시각', + tracking_registered_at TIMESTAMP NULL COMMENT '운송장 등록 시각', + arrived_at_mailbox_at TIMESTAMP NULL COMMENT '사서함 도착 시각', + inspection_completed_at TIMESTAMP NULL COMMENT '검수완료 시각', + delivered_at TIMESTAMP NULL COMMENT '전달완료 시각', + canceled_at TIMESTAMP NULL COMMENT '취소 시각', + undeliverable_at TIMESTAMP NULL COMMENT '전달불가 시각', + undeliverable_reason VARCHAR(255) NULL COMMENT '전달불가 사유', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각', + PRIMARY KEY (id), + UNIQUE KEY uk_gift_delivery_gift_id (gift_id), + CONSTRAINT fk_gift_delivery_gift FOREIGN KEY (gift_id) REFERENCES gift (id) +) COMMENT='선물 배송 정보'; + +CREATE TABLE gift_size_price ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '내부 기본키', + size_code VARCHAR(20) NOT NULL COMMENT '선물 사이즈 코드', + base_price_can INT NOT NULL COMMENT '기본 금액', + sale_price_can INT NOT NULL COMMENT '결제 금액', + is_active TINYINT(1) NOT NULL COMMENT '활성 여부', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각', + PRIMARY KEY (id), + UNIQUE KEY uk_gift_size_price_size_code (size_code) +) COMMENT='선물 사이즈별 가격'; + +CREATE TABLE gift_review ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '내부 기본키', + gift_id BIGINT NOT NULL COMMENT '선물 ID', + sender_member_id BIGINT NOT NULL COMMENT '리뷰 작성 팬 회원번호', + rating INT NOT NULL COMMENT '별점', + keywords TEXT NULL COMMENT '리뷰 키워드 목록', + comment VARCHAR(255) NULL COMMENT '추가의견', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각', + PRIMARY KEY (id), + UNIQUE KEY uk_gift_review_gift_id (gift_id), + CONSTRAINT fk_gift_review_gift FOREIGN KEY (gift_id) REFERENCES gift (id), + CONSTRAINT fk_gift_review_sender_member FOREIGN KEY (sender_member_id) REFERENCES member (id) +) COMMENT='선물 리뷰'; + +CREATE TABLE gift_application_no_sequence ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '내부 기본키', + sequence_date VARCHAR(8) NOT NULL COMMENT '채번 기준일 yyyyMMdd', + last_sequence INT NOT NULL COMMENT '마지막 일련번호', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각', + PRIMARY KEY (id), + UNIQUE KEY uk_gift_application_no_sequence_date (sequence_date) +) COMMENT='선물 신청번호 날짜별 채번'; diff --git a/docs/20260929_크리에이터_선물하기/plan-task.md b/docs/20260929_크리에이터_선물하기/plan-task.md new file mode 100644 index 00000000..9075d095 --- /dev/null +++ b/docs/20260929_크리에이터_선물하기/plan-task.md @@ -0,0 +1,1178 @@ +# 크리에이터 선물하기 구현 계획 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +| 문서 항목 | 내용 | +|---|---| +| 상태 | 완료 | +| 작성일 | 2026-09-29 | +| 요구사항 기준 | `docs/20260929_크리에이터_선물하기/prd.md` | +| API 기준 | `docs/20260929_크리에이터_선물하기/prd.md`의 `8. API 계약` | +| 현재 Phase | Phase 7 완료 | +| 현재 활성 Goal | 완료 | + +## 목표 + +팬이 캔으로 크리에이터에게 실물 선물을 신청하고, 운송장 등록부터 크리에이터 배송지 입력, 운영 검수, 전달 완료/불가, 리뷰와 푸시까지 서버에서 추적할 수 있게 한다. + +## 아키텍처 + +신규 도메인은 `kr.co.vividnext.sodalive.v2.gift` 아래에 배치하고, v2 신규 도메인 관례대로 `domain`, `application`, `port`, `adapter` 패키지를 사용한다. JPA 엔티티와 repository는 `adapter.out.persistence`에 두고, 순수 enum/정책은 `domain`, 상태 전이와 캔 결제/환불 및 푸시 조합은 `application`에 둔다. 사용자 API는 `kr.co.vividnext.sodalive.v2.api.gift`, 관리자 API는 `kr.co.vividnext.sodalive.v2.api.admin.gift` 아래에 두되 controller는 v2 web adapter 관례에 맞춰 `adapter.in.web`, DTO는 `dto` 하위에 둔다. 공개 API DTO는 API 패키지에 두되 도메인/application service가 API DTO를 import하지 않도록 한다. + +## 기술 스택 + +- Kotlin + Java 17 +- Spring Boot 2.7.14 +- Spring MVC, Spring Security, Spring Data JPA, QueryDSL +- Gradle Wrapper, ktlint +- 테스트: 기존 JUnit/Spring Boot Test/MockMvc 패턴 + +## 현재 상태 + +| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 | +|---:|---|---:|---|---| +| 1 | 완료 | `4/4` | 완료 | 없음 | +| 2 | 완료 | `4/4` | 완료 | 없음 | +| 3 | 완료 | `4/4` | 완료 | 없음 | +| 4 | 완료 | `3/3` | 완료 | 없음 | +| 5 | 완료 | `3/3` | 완료 | 없음 | +| 6 | 완료 | `2/2` | 완료 | 없음 | +| 7 | 완료 | `3/3` | 완료 | 없음 | + +- 동시에 하나의 미완료 goal만 운용한다. +- 구현 중 요구사항이 바뀌면 이 문서의 체크박스와 범위를 먼저 갱신한 뒤 코드를 수정한다. +- 완료된 Task와 Progress 기록은 삭제하거나 덮어쓰지 않는다. + +## 범위 + +### 포함 + +- 선물 신청, 목록, 상세, 운송장 등록, 취소, 크리에이터 배송지 입력, 크리에이터 수령확인, 리뷰 작성 사용자 API +- 관리자 카테고리 API, 관리자 사이즈 가격 API, 운영 상태변경 API +- 운송장 미등록 자동취소, 배송지 미입력 전달불가, 기한 24시간 전 안내 스케줄러 +- 캔 사용내역 `선물하기` 추가와 선물 신청 내부 연결 +- 팬/크리에이터 대상 푸시 이벤트와 단일 딥링크 `GIFT_DETAIL` + `applicationNo` +- MySQL 운영 반영용 DDL 문서 + +### 제외 + +- 약관 본문 제공 API +- 외부 택배 조회, 송장 유효성 실시간 조회, 배송 추적 자동화 +- 외부 PG 에스크로, 커머스 플랫폼, provider별 택배사 코드 매핑 +- 선물 이미지, 첨부파일, 메시지 카드 +- 카테고리 hard delete +- 선물 사이즈 정의 추가/삭제 +- 캔 사용내역 화면에서 선물 상세로 이동하는 사용자 기능 + +## 파일 책임 지도 + +### 문서와 DDL + +- Create: `docs/20260929_크리에이터_선물하기/gift-schema.sql` — MySQL 운영 반영용 신규 테이블/인덱스 DDL. +- Modify: `docs/20260929_크리에이터_선물하기/plan-task.md` — 각 Task 실행 후 체크박스와 Progress 기록 누적. + +### 도메인 모델과 repository + +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/domain/GiftStatus.kt` — 선물 상태 enum. +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/domain/GiftSize.kt` — 선물 사이즈 enum. +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/Gift.kt` — 선물 신청 엔티티. +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftDelivery.kt` — 발신자/수신자 배송 정보와 상태 시각 엔티티. +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftCategory.kt` — 관리자 카테고리 엔티티. +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftSizePrice.kt` — 사이즈별 기본/판매 금액 설정 엔티티. +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftReview.kt` — 선물 리뷰 엔티티. +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftApplicationNoSequence.kt` — 날짜별 신청번호 채번 엔티티. +- Create: repository files under `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/`. + +### 도메인 service + +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftApplicationNoGenerator.kt` — `G{yyyyMMdd}{dailySequence6}` 발급과 동시성 재시도. +- Implemented in `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt` and `GiftCommandService.kt` — 사이즈 가격 조회와 신청 시점 최종 결제 금액 스냅샷. +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftCommandService.kt` — 신청, 운송장 등록, 취소, 배송지 입력, 수령확인, 리뷰 작성. +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt` — 폼 옵션, 목록, 상세 조회. +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminService.kt` — 관리자 카테고리/가격 설정과 운영 상태변경. +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftScheduler.kt` — 자동취소, 배송지 미입력 전달불가, 24시간 전 안내 작업. +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftPushService.kt` — 상태별 팬/크리에이터 푸시 이벤트 발행. +- Reuse: `kr.co.vividnext.sodalive.common.SodaException` — 선물 도메인 오류는 기존 공통 예외를 사용한다. + +### 사용자 API + +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftController.kt` — 사용자 API endpoint. +- Create: user request DTO files under `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/` — `GiftApplicationRequest.kt`, `GiftTrackingRegistrationRequest.kt`, `GiftRecipientAddressRegistrationRequest.kt`, `GiftReviewRequest.kt`. +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftResponse.kt` — 사용자 API response DTO. + +### 관리자 API + +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftController.kt` — 관리자 카테고리/가격/운영 상태변경 endpoint. +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/dto/AdminGiftRequest.kt` — 관리자 request DTO. +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/dto/AdminGiftResponse.kt` — 관리자 response DTO. + +### 기존 파일 변경 + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt` — `선물하기` 용도 추가. +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt` — 기존 `spendCan`/`refund` 재사용 가능 여부 확인 후 필요한 최소 연결 메서드 추가. +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt` — `GIFT_DETAIL` 딥링크와 선물 푸시 이벤트 타입 추가. +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationCategory.kt` — 선물 알림 카테고리 필요 시 `GIFT` 추가. +- Reuse: `FcmEvent.title`/`message` 기본 문구 — 선물 푸시는 별도 다국어 키 추가 없이 기존 FCM 기본 문구 경로를 사용한다. + +### 테스트 + +- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftApplicationNoGeneratorTest.kt` +- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminServiceTest.kt` +- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftCommandServiceTest.kt` +- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt` +- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftSchedulerTest.kt` +- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftPushServiceTest.kt` +- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt` +- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt` + +## Task TDD 작성 규칙 + +모든 구현 Task는 아래 순서를 지킨다. + +- [ ] **RED:** 가장 작은 실패 test를 작성한다. +- [ ] **RED 확인:** focused test를 실행해 요구 동작이 없어서 발생한 의도한 assertion 실패를 확인한다. +- [ ] **GREEN:** RED를 통과시키는 최소 구현을 작성한다. +- [ ] **GREEN 확인:** 같은 focused test를 다시 실행해 성공을 확인한다. +- [ ] **REFACTOR:** 새 동작을 바꾸지 않는 범위에서 이번 Task가 만든 중복만 정리하고, focused test·직접 영향 회귀·lint를 다시 실행해 실제 결과를 Progress에 기록한다. + +## Phase 1: 데이터 기반과 관리자 설정 API + +**Phase 결과:** 선물 도메인 테이블, 상태/사이즈 enum, 신청번호 채번, 관리자 카테고리/사이즈 가격 설정, 팬용 폼 옵션 조회가 준비된다. + +**선행조건:** 없음. + +**Phase 완료 조건:** `P1-T1`~`P1-T4`와 `P1-GATE` 완료, 검증 기록 누적. + +### 구현 항목 + +#### Task 1.1 DDL과 도메인 엔티티 생성 + +**Goal 실행 `P1-T1`:** PRD의 데이터 모델을 MySQL DDL과 JPA 엔티티로 만든다. + +- **시작 조건:** PRD `10. 데이터 모델 요구사항` 확인. +- **완료 증거:** DDL 문서, 엔티티/repository 파일, 엔티티 매핑 focused test 통과. +- **범위 밖:** 사용자 API, 결제, 푸시, 스케줄러. + +**Files:** + +- Create: `docs/20260929_크리에이터_선물하기/gift-schema.sql` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/domain/GiftStatus.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/domain/GiftSize.kt` +- Create: entity and repository files under `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftEntityMappingTest.kt` + +**Interfaces:** + +- Produces: `GiftStatus`, `GiftSize`, `Gift`, `GiftDelivery`, `GiftCategory`, `GiftSizePrice`, `GiftReview`. + +- [x] **RED:** 엔티티 저장/조회, `applicationNo` unique, `GiftReview.giftId` unique, 카테고리 `categoryCode` unique를 검증하는 실패 test를 작성한다. +- [x] **RED 확인:** `./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftEntityMappingTest"`를 실행해 신규 엔티티 미존재 또는 매핑 미구현 실패를 확인한다. +- [x] **GREEN:** 엔티티, repository, DDL을 최소 구현한다. DDL의 모든 컬럼과 테이블에는 MySQL `COMMENT`를 추가하고 `created_at`, `updated_at`은 문서 규칙을 따른다. +- [x] **GREEN 확인:** 같은 focused test가 통과하는지 확인한다. +- [x] **REFACTOR:** 엔티티별 책임을 유지하고, `./gradlew ktlintCheck`와 focused test 결과를 Progress에 기록한다. + +#### Task 1.2 신청번호 채번 구현 + +**Goal 실행 `P1-T2`:** `G{yyyyMMdd}{dailySequence6}` 신청번호를 동시 신청에서도 중복 없이 발급한다. + +- **시작 조건:** `P1-T1` 완료. +- **완료 증거:** 채번 단위/동시성 test 통과. +- **범위 밖:** 선물 신청 command 전체. + +**Files:** + +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftApplicationNoGenerator.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftApplicationNoSequenceRepository.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftApplicationNoGeneratorTest.kt` + +**Interfaces:** + +- Produces: `fun generate(now: LocalDateTime = LocalDateTime.now()): String`. + +- [x] **RED:** 같은 날짜 연속 발급이 `G20260929000001`, `G20260929000002`가 되고 병렬 발급 결과가 모두 unique인 실패 test를 작성한다. +- [x] **RED 확인:** `./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftApplicationNoGeneratorTest"`로 실패를 확인한다. +- [x] **GREEN:** DB unique 제약, 원자적 채번 또는 충돌 재시도 중 가장 단순한 방식으로 구현한다. +- [x] **GREEN 확인:** 같은 focused test 통과를 확인한다. +- [x] **REFACTOR:** 재시도 횟수와 실패 오류를 명확히 하고 focused test·`ktlintCheck` 결과를 기록한다. + +#### Task 1.3 관리자 카테고리와 사이즈 가격 API 구현 + +**Goal 실행 `P1-T3`:** 관리자가 카테고리와 사이즈별 `basePriceCan`/`salePriceCan`을 조회·수정할 수 있게 한다. + +- **시작 조건:** `P1-T1` 완료. +- **완료 증거:** 관리자 service/controller test 통과. +- **범위 밖:** 사용자 선물 신청, 폼 옵션 조회. + +**Files:** + +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminService.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftController.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/dto/AdminGiftRequest.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/dto/AdminGiftResponse.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminServiceTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt` + +**Interfaces:** + +- Produces: `GET/POST/PUT/DELETE /api/v2/admin/gift-categories`, `GET/PUT /api/v2/admin/gift-size-prices`. + +- [x] **RED:** 카테고리 등록/수정/논리삭제, 비활성 전환, `salePriceCan > basePriceCan` 거부 test를 작성한다. +- [x] **RED 확인:** `./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.GiftAdminServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.admin.gift.AdminGiftControllerTest"`로 실패를 확인한다. +- [x] **GREEN:** 관리자 service와 controller를 최소 구현한다. 삭제는 `isActive=false`만 수행한다. +- [x] **GREEN 확인:** 같은 focused test 통과를 확인한다. +- [x] **REFACTOR:** 관리자 DTO와 도메인 모델 import 방향을 점검하고 `ktlintCheck` 결과를 기록한다. + +#### Task 1.4 팬용 선물 신청 폼 옵션 조회 구현 + +**Goal 실행 `P1-T4`:** 팬이 선물 신청 페이지에서 활성 사이즈 가격과 활성 카테고리를 한 번에 조회한다. + +- **시작 조건:** `P1-T1`, `P1-T3` 완료. +- **완료 증거:** `GET /api/v2/gifts/form-options` contract test 통과. +- **범위 밖:** 선물 신청 mutation. + +**Files:** + +- Implemented in: `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftController.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftResponse.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt` + +**Interfaces:** + +- Produces: `GET /api/v2/gifts/form-options` with `sizes`, `categories`. + +- [x] **RED:** 비활성 카테고리 제외, `categoryCode` 미노출, `basePriceCan`/`salePriceCan` 응답을 검증하는 실패 test를 작성한다. +- [x] **RED 확인:** `./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.GiftQueryServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.gift.GiftControllerTest"`로 실패를 확인한다. +- [x] **GREEN:** 폼 옵션 조회 service/controller/response를 최소 구현한다. +- [x] **GREEN 확인:** 같은 focused test 통과를 확인한다. +- [x] **REFACTOR:** page/DTO 책임 경계를 정리하고 focused test·`ktlintCheck` 결과를 기록한다. + +### Phase 1 Gate + +**Goal 실행 `P1-GATE`:** 데이터 기반과 관리자 설정/폼 옵션 계약을 최종 판정한다. + +```bash +./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.*" --tests "kr.co.vividnext.sodalive.v2.api.admin.gift.*" --tests "kr.co.vividnext.sodalive.v2.api.gift.GiftControllerTest" +./gradlew ktlintCheck +./gradlew tasks --all +``` + +**Expected:** 모든 명령 exit 0. PRD `GIFT-001`, `GIFT-012`, `GIFT-013`이 구현 또는 명시적 후속 범위로 추적된다. + +**결과:** 완료. 2026-09-29 기준 Gate 명령 3개 모두 `BUILD SUCCESSFUL`. + +## Phase 2: 팬 선물 신청, 결제, 취소, 운송장 등록 + +**Phase 결과:** 팬이 선물을 신청해 캔을 결제하고, 운송장 등록 전 취소/환불하거나 운송장을 등록할 수 있다. + +**선행조건:** `P1-GATE` 완료. + +**Phase 완료 조건:** `P2-T1`~`P2-T4`와 `P2-GATE` 완료, 검증 기록 누적. + +### 구현 항목 + +#### Task 2.1 캔 사용내역 `선물하기` 연결 + +**Goal 실행 `P2-T1`:** 선물 신청에서 사용할 캔 차감/환불 연결점을 만든다. + +- **시작 조건:** `P1-GATE` 완료, 기존 `CanPaymentService`와 `CanUsage` 확인. +- **완료 증거:** 캔 차감/환불 focused test 통과. +- **범위 밖:** 선물 신청 API 전체. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/GiftCommandServiceTest.kt` + +**Interfaces:** + +- Produces: 선물 신청에서 호출할 캔 차감/환불 contract. 기존 `spendCan`/`refund` 재사용을 우선한다. + +- [x] **RED:** `CanUsage.GIFT` 또는 동등한 `선물하기` 사용내역이 생성되고 환불 시 1회만 환불되는 실패 test를 작성한다. +- [x] **RED 확인:** `./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.GiftCommandServiceTest"`로 실패를 확인한다. +- [x] **GREEN:** 기존 결제 service를 최대한 재사용해 최소 연결만 추가한다. +- [x] **GREEN 확인:** 같은 focused test 통과를 확인한다. +- [x] **REFACTOR:** 기존 결제 호출부 회귀를 확인하고 focused test·`./gradlew test --tests "kr.co.vividnext.sodalive.can.*"` 결과를 기록한다. + +#### Task 2.2 선물 보내기 등록 구현 + +**Goal 실행 `P2-T2`:** 팬이 약관/파손면책/발신자 정보를 입력해 선물을 신청하고 캔을 즉시 차감한다. + +- **시작 조건:** `P2-T1` 완료. +- **완료 증거:** service/controller 신청 test 통과. +- **범위 밖:** 운송장 등록, 목록/상세 조회. + +**Files:** + +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftCommandService.kt` +- Reuse: `kr.co.vividnext.sodalive.common.SodaException` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftApplicationRequest.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftController.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/GiftCommandServiceTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/GiftControllerTest.kt` + +**Interfaces:** + +- Produces: `POST /api/v2/gifts`. + +- [x] **RED:** 필수 발신자 정보, 팬 약관 2개, 조건부 `damageWaiverAgreed`, 비활성 카테고리, 최종 결제 금액 스냅샷, 고유 `applicationNo`, 캔 차감을 검증하는 실패 test를 작성한다. +- [x] **RED 확인:** `./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.GiftCommandServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.gift.GiftControllerTest"`로 실패를 확인한다. +- [x] **GREEN:** 신청 API와 service를 최소 구현한다. `trackingDeadlineAt`은 신청 시각 + 3일로 저장한다. +- [x] **GREEN 확인:** 같은 focused test 통과를 확인한다. +- [x] **REFACTOR:** validation 중복만 정리하고 focused test·`ktlintCheck` 결과를 기록한다. + +#### Task 2.3 선물 보내기 취소 구현 + +**Goal 실행 `P2-T3`:** 팬이 운송장 등록 전 선물 신청을 취소하고 전액 환불받을 수 있게 한다. + +- **시작 조건:** `P2-T2` 완료. +- **완료 증거:** 취소/환불 focused test 통과. +- **범위 밖:** 자동취소 스케줄러. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftCommandService.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftController.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/GiftCommandServiceTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/GiftControllerTest.kt` + +**Interfaces:** + +- Produces: `POST /api/v2/gifts/{applicationNo}/cancel`. + +- [x] **RED:** 보낸 팬만 취소 가능, `RECEIVED`에서만 가능, `TRACKING_REGISTERED` 이후 실패, 환불 1회만 발생하는 실패 test를 작성한다. +- [x] **RED 확인:** focused test 명령으로 실패를 확인한다. +- [x] **GREEN:** 취소 상태 전이와 환불을 최소 구현한다. +- [x] **GREEN 확인:** focused test 통과를 확인한다. +- [x] **REFACTOR:** 상태 전이 helper가 필요하면 이 Task 범위 안에서만 추출하고 회귀 결과를 기록한다. + +#### Task 2.4 운송장 등록 구현 + +**Goal 실행 `P2-T4`:** 팬이 택배사 한글 표시명과 운송장 번호를 등록해 상태를 `TRACKING_REGISTERED`로 바꾼다. + +- **시작 조건:** `P2-T2` 완료. +- **완료 증거:** 운송장 등록 service/controller test 통과. +- **범위 밖:** 크리에이터 배송지 입력, 푸시 실제 발송 검증. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftCommandService.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftController.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/GiftCommandServiceTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/GiftControllerTest.kt` + +**Interfaces:** + +- Produces: `POST /api/v2/gifts/{applicationNo}/tracking` with `courierCompanyName`, `trackingNumber`. + +- [x] **RED:** 보낸 팬만 등록 가능, `RECEIVED`에서만 가능, `courierCompanyName` 필수/50자 이하, 크리에이터 배송지 입력 기한 생성, 상태 전이를 검증하는 실패 test를 작성한다. +- [x] **RED 확인:** focused test 명령으로 실패를 확인한다. +- [x] **GREEN:** 운송장 등록과 `recipientAddressDeadlineAt = 등록 시각 + 7일` 저장을 구현한다. +- [x] **GREEN 확인:** focused test 통과를 확인한다. +- [x] **REFACTOR:** 받은 선물 노출 조건과 충돌하지 않도록 상태 predicate 이름을 정리하고 회귀 결과를 기록한다. + +### Phase 2 Gate + +**Goal 실행 `P2-GATE`:** 팬 신청/결제/취소/운송장 등록 흐름을 판정한다. + +```bash +./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.GiftCommandServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.gift.GiftControllerTest" +./gradlew ktlintCheck +``` + +**Expected:** `GIFT-002`~`GIFT-005`의 핵심 mutation이 통과하고, 운송장 등록 후 받은 선물 조회 가능 상태가 저장된다. + +## Phase 3: 선물함 조회와 크리에이터 배송지 입력 + +**Phase 결과:** 팬/크리에이터가 선물함 목록과 상세를 조회하고, 크리에이터가 배송지와 수령 약관을 등록할 수 있다. + +**선행조건:** `P2-GATE` 완료. + +### 구현 항목 + +#### Task 3.1 선물함 목록 조회 구현 + +**Goal 실행 `P3-T1`:** `type=ALL|SENT|RECEIVED` 목록과 `direction` 판정을 구현한다. + +- **Files:** Modify `GiftQueryService.kt`, `GiftController.kt`, `GiftResponse.kt`; Test `GiftQueryServiceTest.kt`, `GiftControllerTest.kt`. +- [x] **RED:** 보낸 선물은 신청 직후 노출, 받은 선물은 `TRACKING_REGISTERED` 이상부터 노출, `direction`, pagination 보정을 검증하는 실패 test를 작성한다. +- [x] **RED 확인:** `./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.GiftQueryServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.gift.GiftControllerTest"` 실패를 확인한다. +- [x] **GREEN:** 목록 query와 response를 구현한다. +- [x] **GREEN 확인:** focused test 통과를 확인한다. +- [x] **REFACTOR:** QueryDSL 조건을 중복 없이 정리하고 회귀 결과를 기록한다. + +#### Task 3.2 선물 상세 조회 구현 + +**Goal 실행 `P3-T2`:** 단일 상세 API가 로그인 회원 기준으로 보낸/받은 선물을 판정한다. + +- **Files:** Modify `GiftQueryService.kt`, `GiftController.kt`, `GiftResponse.kt`; Test `GiftQueryServiceTest.kt`, `GiftControllerTest.kt`. +- [x] **RED:** sender는 `SENT`, recipient는 노출 조건 만족 시 `RECEIVED`, 무관한 회원은 권한 오류 또는 미존재 오류를 반환하는 실패 test를 작성한다. +- [x] **RED:** `direction=SENT` 상세 응답이 `giftInfo.recipientCreatorNickname`, `giftInfo.sizeName`, `giftInfo.categoryName`, `giftInfo.applicationNo`, `giftInfo.paidCan`, `giftInfo.tracking`, `senderInfo.name`, `senderInfo.phoneNumber`, `senderInfo.address`, `mailbox`를 포함하는 실패 test를 작성한다. +- [x] **RED:** `direction=SENT` 상세 응답에서 `giftInfo.senderNickname`, `giftInfo.shippingRequestedAt`, `recipientAddress`가 `null`이고 크리에이터가 입력한 이름/휴대폰 번호/주소가 노출되지 않는 실패 test를 작성한다. +- [x] **RED:** `direction=SENT`이고 상태가 `RECEIVED`이면 `trackingRequired=true`, 그 외 상태이거나 `direction=RECEIVED`이면 `trackingRequired=false`인 실패 test를 작성한다. +- [x] **RED:** `direction=RECEIVED` 상세 응답이 `giftInfo.senderNickname`, `giftInfo.sizeName`, `giftInfo.categoryName`, `giftInfo.shippingRequestedAt`, `recipientAddress.name`, `recipientAddress.phoneNumber`, `recipientAddress.address`를 포함하는 실패 test를 작성한다. +- [x] **RED:** `direction=RECEIVED` 상세 응답에서 `giftInfo.recipientCreatorNickname`, `giftInfo.applicationNo`, `giftInfo.paidCan`, `giftInfo.tracking`, `senderInfo`, `mailbox`가 `null`이고 팬이 신청 시 등록한 이름/휴대폰 번호/주소가 노출되지 않는 실패 test를 작성한다. +- [x] **RED:** `direction=RECEIVED`이고 받는 주소가 미입력이며 종료 상태가 아니면 `recipientAddressRequired=true`, 주소 입력 완료 또는 종료 상태 또는 `direction=SENT`이면 `recipientAddressRequired=false`인 실패 test를 작성한다. +- [x] **RED:** 발신자/수신자 주소가 `(우편번호) 주소, 상세주소` 형식으로 조립되고, 로그인 회원이 알아야 하는 데이터가 아니거나 아직 입력되지 않은 데이터는 `null`인 실패 test를 작성한다. +- [x] **RED:** 상세 응답의 `statusTimeline`이 `RECEIVED`, `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED`, `DELIVERED` 순서로 내려오고 도달한 상태만 `occurredAt`을 가지며 미도달 상태는 `null`인 실패 test를 작성한다. +- [x] **RED:** `CANCELED`, `UNDELIVERABLE` 상세에서 정상 진행 `statusTimeline`은 유지되고 종료 정보는 각각 `delivery.canceledAt`, `delivery.undeliverableAt`, `delivery.undeliverableReason`으로 내려오는 실패 test를 작성한다. +- [x] **RED 확인:** focused test 실패를 확인한다. +- [x] **GREEN:** 상세 query와 권한 판정을 구현한다. +- [x] **GREEN 확인:** focused test 통과를 확인한다. +- [x] **REFACTOR:** 역할별 상세 응답 조립, 본인/상대방 개인정보 노출 경계, 상태 타임라인 조립 책임을 response mapper에서 명확히 하고 회귀 결과를 기록한다. + +#### Task 3.3 크리에이터 배송지 입력 구현 + +**Goal 실행 `P3-T3`:** 크리에이터가 배송지와 수령 약관 2개를 등록할 수 있게 한다. + +- **Files:** Modify `GiftCommandService.kt`, `GiftController.kt`, `GiftRecipientAddressRegistrationRequest.kt`; Test `GiftCommandServiceTest.kt`, `GiftControllerTest.kt`. +- [x] **RED:** 받는 크리에이터만 가능, 종료 상태 실패, 이름/휴대폰/우편번호/주소 필수, 수령 약관 2개 필수, 동의 시각 저장 실패 test를 작성한다. +- [x] **RED 확인:** focused test 실패를 확인한다. +- [x] **GREEN:** 배송지 입력 API를 구현한다. +- [x] **GREEN 확인:** focused test 통과를 확인한다. +- [x] **REFACTOR:** 발신/수신 주소 snapshot 책임을 `GiftDelivery` 안으로 정리하고 회귀 결과를 기록한다. + +#### Task 3.4 크리에이터 수령확인 구현 + +**Goal 실행 `P3-T4`:** 크리에이터가 `INSPECTION_COMPLETED` 선물을 `DELIVERED`로 완료 처리한다. + +- **Files:** Modify `GiftCommandService.kt`, `GiftController.kt`; Test `GiftCommandServiceTest.kt`, `GiftControllerTest.kt`. +- [x] **RED:** 받는 크리에이터만 가능, `INSPECTION_COMPLETED`에서만 가능, `deliveredAt` 저장, 팬 푸시 발행 요청을 검증하는 실패 test를 작성한다. +- [x] **RED 확인:** focused test 실패를 확인한다. +- [x] **GREEN:** 수령확인 상태 전이를 구현한다. +- [x] **GREEN 확인:** focused test 통과를 확인한다. +- [x] **REFACTOR:** 운영 전달완료와 공유 가능한 상태 전이 검증만 추출하고 회귀 결과를 기록한다. + +### Phase 3 Gate + +**Goal 실행 `P3-GATE`:** 조회와 크리에이터 배송지/수령 흐름을 판정한다. + +```bash +./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.GiftQueryServiceTest" --tests "kr.co.vividnext.sodalive.v2.gift.GiftCommandServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.gift.GiftControllerTest" +./gradlew ktlintCheck +``` + +**Expected:** `GIFT-007`과 상세/목록 권한 정책이 통과한다. + +## Phase 4: 자동 스케줄링 + +**Phase 결과:** 운송장 미등록과 배송지 미입력 기한 초과, 24시간 전 안내가 자동 처리된다. + +**선행조건:** `P2-GATE`, `P3-GATE` 완료. + +### 구현 항목 + +#### Task 4.1 운송장 미등록 자동취소 구현 + +**Goal 실행 `P4-T1`:** 신청 후 3일 초과 `RECEIVED` 선물을 `CANCELED`로 바꾸고 전액 환불한다. + +- **Files:** Create `GiftScheduler.kt`; Modify `GiftRepository.kt`, `GiftCommandService.kt`; Test `GiftSchedulerTest.kt`. +- [x] **RED:** 기한 초과 대상만 취소, 환불 1회, 중복 실행 idempotent 실패 test를 작성한다. +- [x] **RED 확인:** `./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.GiftSchedulerTest"` 실패를 확인한다. +- [x] **GREEN:** 자동취소 job service를 구현한다. +- [x] **GREEN 확인:** focused test 통과를 확인한다. +- [x] **REFACTOR:** scheduler와 command service의 상태 전이 중복을 정리하고 회귀 결과를 기록한다. + +#### Task 4.2 배송지 미입력 전달불가 구현 + +**Goal 실행 `P4-T2`:** 운송장 등록 후 7일 초과 배송지 미입력 선물을 `UNDELIVERABLE`로 바꾼다. + +- **Files:** Modify `GiftScheduler.kt`, `GiftRepository.kt`; Test `GiftSchedulerTest.kt`. +- [x] **RED:** 배송지 미입력 기한 초과만 대상, 사유 `배송지 미입력 기한 초과`, 중복 실행 idempotent 실패 test를 작성한다. +- [x] **RED 확인:** focused test 실패를 확인한다. +- [x] **GREEN:** 전달불가 job service를 구현한다. +- [x] **GREEN 확인:** focused test 통과를 확인한다. +- [x] **REFACTOR:** 대상 조회 query 이름과 상태 조건을 명확히 하고 회귀 결과를 기록한다. + +#### Task 4.3 24시간 전 안내 작업 구현 + +**Goal 실행 `P4-T3`:** 운송장 등록과 배송지 입력 마감 24시간 전 안내 푸시 작업을 구현한다. + +- **Files:** Modify `GiftScheduler.kt`, `GiftPushService.kt`; Test `GiftSchedulerTest.kt`, `GiftPushServiceTest.kt`. +- [x] **RED:** 마감 24시간 전 범위만 대상, 같은 선물 중복 안내 방지 실패 test를 작성한다. +- [x] **RED 확인:** focused test 실패를 확인한다. +- [x] **GREEN:** 안내 작업과 중복 방지 기준을 구현한다. 중복 방지는 별도 컬럼 또는 푸시 기록 조회 중 가장 단순한 방식을 사용한다. +- [x] **GREEN 확인:** focused test 통과를 확인한다. +- [x] **REFACTOR:** 작업별 batch size와 로그에 개인정보가 남지 않는지 확인하고 회귀 결과를 기록한다. + +### Phase 4 Gate + +**Goal 실행 `P4-GATE`:** 자동 처리와 idempotency를 판정한다. + +```bash +./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.GiftSchedulerTest" --tests "kr.co.vividnext.sodalive.v2.gift.GiftPushServiceTest" +./gradlew ktlintCheck +``` + +**Expected:** `GIFT-006`, `GIFT-008` 자동 처리와 중복 실행 방지가 통과한다. + +## Phase 5: 운영 상태변경 API + +**Phase 결과:** 운영자가 사서함 도착, 검수완료, 전달불가, 전달완료를 상태별 전용 API로 처리한다. + +**선행조건:** `P3-GATE` 완료. + +### 구현 항목 + +#### Task 5.1 사서함 도착과 검수완료 API 구현 + +**Goal 실행 `P5-T1`:** 운영자가 `TRACKING_REGISTERED -> ARRIVED_AT_MAILBOX -> INSPECTION_COMPLETED` 전이를 처리한다. + +- **Files:** Modify `GiftAdminService.kt`, `AdminGiftController.kt`; Test `GiftAdminServiceTest.kt`, `AdminGiftControllerTest.kt`. +- [x] **RED:** 허용 이전 상태 성공, 잘못된 이전 상태 실패, 상태 시각 저장, 팬/크리에이터 푸시 요청 실패 test를 작성한다. +- [x] **RED 확인:** focused test 실패를 확인한다. +- [x] **GREEN:** 두 운영 API를 구현한다. +- [x] **GREEN 확인:** focused test 통과를 확인한다. +- [x] **REFACTOR:** 상태 전이 검증 함수 중복을 정리하고 회귀 결과를 기록한다. + +#### Task 5.2 운영 전달불가 API 구현 + +**Goal 실행 `P5-T2`:** 운영자가 전달불가 사유를 등록하고 `UNDELIVERABLE`로 종료 처리한다. + +- **Files:** Modify `GiftAdminService.kt`, `AdminGiftController.kt`, `AdminGiftMarkUndeliverableRequest.kt`; Test `GiftAdminServiceTest.kt`, `AdminGiftControllerTest.kt`. +- [x] **RED:** 허용 상태 성공, 사유 필수, 종료 상태 실패, 팬/크리에이터 전달불가 푸시 요청 실패 test를 작성한다. +- [x] **RED 확인:** focused test 실패를 확인한다. +- [x] **GREEN:** 전달불가 API를 구현한다. +- [x] **GREEN 확인:** focused test 통과를 확인한다. +- [x] **REFACTOR:** 사유 길이와 개인정보 로그 금지를 확인하고 회귀 결과를 기록한다. + +#### Task 5.3 운영 전달완료 API 구현 + +**Goal 실행 `P5-T3`:** 운영자가 `INSPECTION_COMPLETED` 선물을 `DELIVERED`로 완료 처리한다. + +- **Files:** Modify `GiftAdminService.kt`, `AdminGiftController.kt`; Test `GiftAdminServiceTest.kt`, `AdminGiftControllerTest.kt`. +- [x] **RED:** `INSPECTION_COMPLETED`에서만 성공, 팬 전달완료 푸시, 운영 처리 시 크리에이터 배송완료 푸시 정책을 검증하는 실패 test를 작성한다. +- [x] **RED 확인:** focused test 실패를 확인한다. +- [x] **GREEN:** 운영 전달완료 API를 구현한다. +- [x] **GREEN 확인:** focused test 통과를 확인한다. +- [x] **REFACTOR:** 크리에이터 직접 수령확인과 운영 전달완료의 푸시 차이를 명확히 하고 회귀 결과를 기록한다. + +### Phase 5 Gate + +**Goal 실행 `P5-GATE`:** 운영 상태변경 API를 판정한다. + +```bash +./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.GiftAdminServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.admin.gift.AdminGiftControllerTest" +./gradlew ktlintCheck +``` + +**Expected:** `GIFT-009`의 상태별 전용 API와 금지 상태 전이가 통과한다. + +## Phase 6: 리뷰 작성 + +**Phase 결과:** 팬이 전달완료 선물에 별점, 여러 키워드, 추가의견 리뷰를 1회 작성한다. + +**선행조건:** `P3-GATE` 또는 `P5-GATE`에서 `DELIVERED` 전이 구현 완료. + +### 구현 항목 + +#### Task 6.1 리뷰 작성 service 구현 + +**Goal 실행 `P6-T1`:** 보낸 팬만 `DELIVERED` 선물에 리뷰를 1회 작성할 수 있게 한다. + +- **Files:** Modify `GiftCommandService.kt`, `GiftReviewRepository.kt`; Test `GiftCommandServiceTest.kt`. +- [x] **RED:** 보낸 팬만 가능, `DELIVERED`에서만 가능, 중복 실패, 별점 1~5, `keywords` 각 255자, `comment` 255자 실패 test를 작성한다. +- [x] **RED 확인:** focused test 실패를 확인한다. +- [x] **GREEN:** 리뷰 작성 service를 구현한다. +- [x] **GREEN 확인:** focused test 통과를 확인한다. +- [x] **REFACTOR:** keyword 저장 방식을 현재 DB 타입 기준으로 단순화하고 회귀 결과를 기록한다. + +#### Task 6.2 리뷰 작성 API 구현 + +**Goal 실행 `P6-T2`:** `POST /api/v2/gifts/{applicationNo}/review` 계약을 제공한다. + +- **Files:** Modify `GiftController.kt`, `GiftReviewRequest.kt`, `GiftResponse.kt`; Test `GiftControllerTest.kt`. +- [x] **RED:** request/response JSON에서 `keywords` 배열을 사용하고 단수 `keyword`를 받지 않는 실패 test를 작성한다. +- [x] **RED 확인:** focused test 실패를 확인한다. +- [x] **GREEN:** 리뷰 API를 구현한다. +- [x] **GREEN 확인:** focused test 통과를 확인한다. +- [x] **REFACTOR:** 상세 응답의 review 포함 여부를 PRD와 맞추고 회귀 결과를 기록한다. + +### Phase 6 Gate + +**Goal 실행 `P6-GATE`:** 리뷰 작성 요구사항을 판정한다. + +```bash +./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.GiftCommandServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.gift.GiftControllerTest" +./gradlew ktlintCheck +``` + +**Expected:** `GIFT-010`과 리뷰 API 계약이 통과한다. + +## Phase 7: 푸시, 딥링크, 최종 회귀 + +**Phase 결과:** 상태별 팬/크리에이터 푸시와 `GIFT_DETAIL` 딥링크가 모든 상태 전이에서 일관되게 발행된다. + +**선행조건:** `P2-GATE`~`P6-GATE` 완료. + +### 구현 항목 + +#### Task 7.1 FCM 이벤트와 딥링크 확장 + +**Goal 실행 `P7-T1`:** `FcmEvent`에 선물 푸시와 `GIFT_DETAIL` 딥링크를 추가한다. + +- **Files:** Modify `src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationCategory.kt`; Test `GiftPushServiceTest.kt`. +- [x] **RED:** `deepLinkValue=GIFT_DETAIL`, `deepLinkId=applicationNo`를 발행하는 실패 test를 작성한다. +- [x] **RED 확인:** focused test 실패를 확인한다. +- [x] **GREEN:** FCM enum과 gift push helper를 최소 확장한다. +- [x] **GREEN 확인:** focused test 통과를 확인한다. +- [x] **REFACTOR:** 기존 FCM 이벤트 회귀를 실행하고 결과를 기록한다. + +#### Task 7.2 상태별 팬/크리에이터 푸시 구현 + +**Goal 실행 `P7-T2`:** PRD `9. 푸시 알림 정책`의 수신자, 제목, 본문을 구현한다. + +- **Files:** Modify `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftPushService.kt`; Test `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftPushServiceTest.kt`. +- [x] **RED:** 팬 대상 5종, 크리에이터 대상 6종, 크리에이터 직접 수령확인 시 크리에이터 푸시 없음, 줄바꿈 본문 실패 test를 작성한다. +- [x] **RED 확인:** focused test 실패를 확인한다. +- [x] **GREEN:** 상태별 푸시 이벤트 생성을 구현한다. +- [x] **GREEN 확인:** focused test 통과를 확인한다. +- [x] **REFACTOR:** message key 이름을 정리하고 한국어/영어/일본어 기본값 조회 결과를 기록한다. + +#### Task 7.3 최종 통합 회귀 + +**Goal 실행 `P7-T3`:** 선물하기 전체 흐름과 공통 회귀를 검증한다. + +- **Files:** Modify `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/GiftCommandServiceTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftSchedulerTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftPushServiceTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt` only if final verification finds missing coverage; otherwise update this document's Progress section only. +- **TDD 예외 사유:** 구현을 추가하지 않고 전체 완성 상태를 검증하는 Gate 성격의 Task다. +- **대체 검증 방법:** 아래 명령과 수동 흐름 대조표를 Progress에 기록한다. +- [x] 사용자 흐름 대조표를 작성한다: 신청 → 취소, 신청 → 운송장 → 배송지 → 운영 검수 → 수령확인 → 리뷰, 신청 → 자동취소, 운송장 → 배송지 미입력 전달불가. +- [x] focused test 전체를 실행한다. +- [x] 직접 영향 회귀와 ktlint를 실행한다. +- [x] 전체 `test` 실행 여부를 판단한다. 공통 결제/FCM/스케줄러 경계를 변경했으므로 최종 Gate에서는 전체 `test`를 실행한다. + +### Phase 7 Gate + +**Goal 실행 `P7-GATE`:** 선물하기 릴리스 후보를 최종 판정한다. + +```bash +./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.*" --tests "kr.co.vividnext.sodalive.v2.api.gift.*" --tests "kr.co.vividnext.sodalive.v2.api.admin.gift.*" --tests "kr.co.vividnext.sodalive.fcm.*" +./gradlew ktlintCheck +./gradlew test +``` + +**Expected:** 모든 명령 exit 0. PRD `GIFT-001`~`GIFT-013`이 구현·검증·문서 추적으로 연결된다. + +## 실행 순서와 의존성 + +| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 | +|---:|---|---|---|---| +| 1 | `P1-T1` | 없음 | 아니요 | DDL/엔티티 경계 재확인 | +| 2 | `P1-T2` | `P1-T1` | 아니요 | 신청번호 채번 방식 재검토 | +| 3 | `P1-T3` | `P1-T1` | `P1-T2`와 일부 병행 가능 | 관리자 권한 패턴 확인 | +| 4 | `P1-T4` | `P1-T3` | 아니요 | 폼 옵션 계약 재확인 | +| 5 | `P1-GATE` | Phase 1 Task 전체 | 아니요 | 실패 소유 Task 수정 | +| 6 | `P2-T1` | `P1-GATE` | 아니요 | 기존 캔 결제 패턴 재확인 | +| 7 | `P2-T2` | `P2-T1` | 아니요 | 신청 validation 범위 재확인 | +| 8 | `P2-T3` | `P2-T2` | `P2-T4`와 일부 병행 가능 | 환불 idempotency 보강 | +| 9 | `P2-T4` | `P2-T2` | `P2-T3`와 일부 병행 가능 | 받은 선물 노출 조건 재확인 | +| 10 | `P2-GATE` | Phase 2 Task 전체 | 아니요 | 실패 소유 Task 수정 | +| 11 | `P3-T1` | `P2-GATE` | 아니요 | Query 조건 재확인 | +| 12 | `P3-T2` | `P3-T1` | 아니요 | 권한 정책 재확인 | +| 13 | `P3-T3` | `P2-T4` | `P3-T1` 이후 병행 가능 | 수령 약관 저장 보강 | +| 14 | `P3-T4` | `P5-T1` 또는 검수완료 fixture | 아니요 | 상태 전이 선행 구현 확인 | +| 15 | `P3-GATE` | Phase 3 Task 전체 | 아니요 | 실패 소유 Task 수정 | +| 16 | `P4-T1` | `P2-GATE` | `P4-T2`와 병행 가능 | 스케줄러 대상 query 보강 | +| 17 | `P4-T2` | `P3-T3` | `P4-T1`과 병행 가능 | 전달불가 상태 정책 재확인 | +| 18 | `P4-T3` | `P7-T1` 선행 권장 | 아니요 | 푸시 중복 방지 방식 확정 | +| 19 | `P4-GATE` | Phase 4 Task 전체 | 아니요 | 실패 소유 Task 수정 | +| 20 | `P5-T1` | `P2-T4` | 아니요 | 운영 권한 패턴 확인 | +| 21 | `P5-T2` | `P5-T1` | 아니요 | 전달불가 사유 정책 재확인 | +| 22 | `P5-T3` | `P5-T1` | 아니요 | 전달완료 푸시 정책 재확인 | +| 23 | `P5-GATE` | Phase 5 Task 전체 | 아니요 | 실패 소유 Task 수정 | +| 24 | `P6-T1` | `DELIVERED` fixture 가능 | `P6-T2` 전에는 아니요 | 리뷰 저장 방식 보강 | +| 25 | `P6-T2` | `P6-T1` | 아니요 | API 계약 재확인 | +| 26 | `P6-GATE` | Phase 6 Task 전체 | 아니요 | 실패 소유 Task 수정 | +| 27 | `P7-T1` | FCM 패턴 확인 | 아니요 | 기존 FCM 딥링크 빌더 확인 | +| 28 | `P7-T2` | `P7-T1` | 아니요 | 메시지 키 누락 보강 | +| 29 | `P7-T3` | 모든 구현 Task | 아니요 | 회귀 실패 소유 Task 생성 | +| 30 | `P7-GATE` | Phase 7 Task 전체 | 아니요 | 릴리스 차단 기록 | + +```text +P1-T1 → P1-T2/P1-T3 → P1-T4 → P1-GATE +P2-T1 → P2-T2 → P2-T3/P2-T4 → P2-GATE +P3-T1 → P3-T2 → P3-T3 → P3-T4 → P3-GATE +P4, P5, P6는 선행 상태 전이 완료 후 병행 가능 +P7은 FCM 확장 후 전체 상태 전이를 연결하고 최종 회귀로 종료 +``` + +## 변경 금지 항목 + +- PRD의 API path, request/response 필드, 상태 enum을 근거 없이 변경하지 않는다. +- 외부 택배사 코드, 배송조회 연동, 약관 본문 API를 추가하지 않는다. +- 카테고리 hard delete를 구현하지 않는다. +- 운송장 등록 후 팬 취소를 허용하지 않는다. +- `salePriceCan` 대신 `basePriceCan`으로 결제하지 않는다. +- `keyword` 단수 필드를 리뷰 API에 추가하지 않는다. +- `as any`, `@ts-ignore`, 타입 오류 우회, test skip/삭제로 Gate를 통과시키지 않는다. +- 개인정보 전체를 로그, 푸시 본문, 테스트 fixture에 불필요하게 남기지 않는다. +- 선물 상세 사용자 API에 상대방 이름, 휴대폰 번호, 우편번호, 주소, 상세주소를 노출하지 않는다. 팬에게는 본인이 신청 시 등록한 발신자 정보만, 크리에이터에게는 본인이 입력한 수신자 정보만 노출한다. + +## 의사결정 및 중단 규칙 + +- PRD와 구현 중 발견한 기존 코드 제약이 충돌하면 PRD Decision Log와 이 문서를 먼저 갱신한다. +- 관리자 권한명처럼 코드 확인으로 확정 가능한 값은 구현 Task에서 확인하고 문서에 실제 값을 기록한다. +- 캔 결제/환불 또는 푸시 이벤트에서 기존 public contract 변경이 필요하면 작업을 멈추고 사용자 확인을 받는다. +- 같은 차단 사유가 3회 반복되고 독립 작업도 불가능하면 해당 Goal을 `blocked`로 기록한다. +- 완료 증거와 Progress 기록까지 충족한 뒤에만 Goal을 완료 처리한다. + +## Progress + +기존 기록을 삭제하거나 덮어쓰지 않고 실제 실행 결과를 차수별로 누적한다. + +### 계획 작성 — 2026-09-29 + +- 상태: 완료 +- 무엇을: PRD 기반 goal 실행형 구현 계획 문서를 작성했다. +- 왜: 코드 구현 전 PRD와 구현 계획/TASK 문서를 모두 준비해야 하는 저장소 규칙을 충족하기 위해서다. +- 어떻게: + - `docs/20260929_크리에이터_선물하기/prd.md` 확인 + - `docs/sample/sample-plan-task.md` 확인 + - 기존 관리자 API, 캔 결제, FCM, 스케줄러 테스트 패턴 확인 +- 남은 항목: 구현 시작 전 사용자의 계획 검토/승인 +- 다음 행동: 사용자 승인 후 `P1-T1`부터 단일 Goal로 실행 + +### P1-T1 DDL과 도메인 엔티티 생성 — 2026-09-29 + +- 상태: 완료 +- 무엇을: 선물 상태/사이즈 enum, 선물·배송·카테고리·사이즈 가격·리뷰·신청번호 채번 JPA 엔티티와 repository, 운영 반영용 `gift-schema.sql`을 추가했다. +- 왜: PRD `10. 데이터 모델 요구사항`과 `P1-T1` 완료 증거를 충족하기 위해서다. +- 어떻게: + - RED test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftEntityMappingTest.kt` 작성 + - RED 확인: `./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.GiftEntityMappingTest"` 실행 결과 `GiftRepository`, `Gift`, `GiftStatus`, `GiftSize` 등 미구현 참조로 `compileTestKotlin` 실패 + - GREEN 구현: v2 관례에 맞춰 enum은 `domain`, JPA 엔티티/repository는 `adapter.out.persistence`에 배치 + - GREEN 확인: `./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftEntityMappingTest"` 실행 결과 `BUILD SUCCESSFUL` + - REFACTOR 확인: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL` +- 남은 항목: 없음 +- 다음 행동: `P1-T2` 신청번호 채번 구현 + +### P1-T2 신청번호 채번 구현 — 2026-09-29 + +- 상태: 완료 +- 무엇을: `G{yyyyMMdd}{dailySequence6}` 형식의 선물 신청번호 generator를 추가했다. +- 왜: PRD `6.3 신청번호 정책`의 Asia/Seoul 날짜 기준, 날짜별 6자리 sequence, 병렬 unique 보장 요구를 충족하기 위해서다. +- 어떻게: + - RED test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftApplicationNoGeneratorTest.kt` 작성 + - RED 확인: `./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftApplicationNoGeneratorTest"` 실행 결과 `GiftApplicationNoGenerator` 미구현 참조로 `compileTestKotlin` 실패 + - GREEN 구현: `GiftApplicationNoSequenceRepository.findBySequenceDateForUpdate`와 `TransactionTemplate`을 사용해 날짜별 sequence row를 갱신하고, 최초 row 생성 경합은 unique 제약 충돌 재시도로 처리 + - GREEN 확인: `./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftApplicationNoGeneratorTest"` 실행 결과 `BUILD SUCCESSFUL` + - REFACTOR 확인: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL` +- 남은 항목: 없음 +- 다음 행동: `P1-T3` 관리자 카테고리와 사이즈 가격 API 구현 + +### P1-T3 관리자 카테고리와 사이즈 가격 API 구현 — 2026-09-29 + +- 상태: 완료 +- 무엇을: 관리자 카테고리 등록/수정/조회/논리삭제 API와 사이즈 가격 조회/수정 API를 추가했다. +- 왜: PRD `GIFT-012`, `GIFT-013`의 관리자 선물 카테고리와 사이즈별 가격 설정 요구를 충족하기 위해서다. +- 어떻게: + - RED test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminServiceTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt` 작성 + - RED 확인: focused test 실행 결과 `GiftAdminService`, `GiftCategoryCommand`, `GiftSizePriceCommand` 미구현 참조로 `compileTestKotlin` 실패 + - GREEN 구현: `GiftAdminService`, 관리자 request/response DTO, `AdminGiftController`를 추가하고 카테고리 삭제는 `isActive=false`로 처리 + - GREEN 확인: `./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftAdminServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.in.web.AdminGiftControllerTest"` 실행 결과 `BUILD SUCCESSFUL` + - REFACTOR 확인: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL` +- 남은 항목: 없음 +- 다음 행동: `P1-T4` 팬용 선물 신청 폼 옵션 조회 구현 + +### P1-T4 팬용 선물 신청 폼 옵션 조회 구현 — 2026-09-29 + +- 상태: 완료 +- 무엇을: 팬용 `GET /api/v2/gifts/form-options` API와 조회 service/response DTO를 추가했다. +- 왜: PRD `GIFT-001`의 선물 신청 화면 옵션 조회 요구를 충족하기 위해서다. +- 어떻게: + - RED test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt` 작성 + - RED 확인: focused test 실행 결과 `GiftQueryService` 미구현 참조로 실패 확인 + - GREEN 구현: 활성 `GiftSizePrice`와 활성 `GiftCategory`만 조회하고, 사용자 응답에서는 `categoryCode`를 제외하도록 구현 + - GREEN 확인: `./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftQueryServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.gift.adapter.in.web.GiftControllerTest"` 실행 결과 `BUILD SUCCESSFUL` + - REFACTOR 확인: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL` +- 남은 항목: Phase 1 Gate +- 다음 행동: `P1-GATE` 실행 + +### P1-GATE 데이터 기반과 관리자 설정/폼 옵션 계약 판정 — 2026-09-29 + +- 상태: 완료 +- 무엇을: Phase 1의 도메인/관리자 API/폼 옵션 계약을 Gate 명령으로 검증했다. +- 왜: `P1-T1`~`P1-T4` 완료 후 Phase 2 진입 가능 여부를 판정하기 위해서다. +- 어떻게: + - `./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.*" --tests "kr.co.vividnext.sodalive.v2.api.admin.gift.*" --tests "kr.co.vividnext.sodalive.v2.api.gift.GiftControllerTest"` 실행 결과 `BUILD SUCCESSFUL` + - `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL` + - `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL` +- 남은 항목: 없음 +- 다음 행동: `P2-T1` 캔 사용내역 `선물하기` 연결 + +### P2-T1 캔 사용내역 `선물하기` 연결 — 2026-09-29 + +- 상태: 완료 +- 무엇을: 선물 신청에서 사용할 `CanUsage.GIFT`, 선물용 캔 차감/환불 연결 메서드, 캔 사용내역 표시명을 추가했다. +- 왜: 선물 신청 시 캔 사용내역을 `선물하기`로 남기고 취소/실패 시 한 번만 환불할 수 있어야 하기 때문이다. +- 어떻게: + - RED test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/GiftCommandServiceTest.kt` 작성 + - RED 확인: focused test 실행 결과 `CanUsage.GIFT`, `spendGiftCan`, `refundGiftCan` 미구현 참조로 실패 확인 + - GREEN 구현: 기존 `CanPaymentService.spendCan`/환불 로직을 재사용해 `spendGiftCan`, `refundGiftCan`을 추가 + - GREEN 확인: `./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.GiftCommandServiceTest"` 실행 결과 `BUILD SUCCESSFUL` + - REFACTOR 확인: `./gradlew test --tests "kr.co.vividnext.sodalive.can.*"`, `./gradlew ktlintCheck` 실행 결과 모두 `BUILD SUCCESSFUL` +- 남은 항목: 없음 +- 다음 행동: `P2-T2` 선물 보내기 등록 구현 + +### P2-T2 선물 보내기 등록 구현 — 2026-09-29 + +- 상태: 완료 +- 무엇을: `POST /api/v2/gifts` 선물 신청 API와 신청 service를 추가했다. +- 왜: 팬이 약관/파손면책/발신자 정보를 입력해 선물을 신청하고 즉시 캔을 차감해야 하기 때문이다. +- 어떻게: + - RED test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/GiftCommandServiceTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt` 작성 + - RED 확인: focused test 실행 결과 `GiftCommandService`, request/result DTO 미구현 참조로 실패 확인 + - GREEN 구현: 신청 검증, 신청번호 발급, `spendGiftCan` 차감, `Gift`/`GiftDelivery` 저장, 신청 응답 반환을 구현 + - GREEN 확인: `./gradlew test --rerun-tasks --tests "kr.co.vividnext.sodalive.v2.gift.GiftCommandServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.gift.adapter.in.web.GiftControllerTest"` 실행 결과 `BUILD SUCCESSFUL` + - REFACTOR 확인: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL` +- 남은 항목: 없음 +- 다음 행동: `P2-T3` 선물 보내기 취소 구현 + +### P2-T3 선물 보내기 취소 구현 — 2026-09-29 + +- 상태: 완료 +- 무엇을: `POST /api/v2/gifts/{applicationNo}/cancel` 취소 API와 취소 service를 추가했다. +- 왜: 팬이 운송장 등록 전 접수된 선물을 취소하고 전액 환불받을 수 있어야 하기 때문이다. +- 어떻게: + - RED test: `GiftCommandServiceTest`, `GiftControllerTest`에 발신자/상태 검증과 취소 응답 검증을 추가 + - RED 확인: 취소 결과 타입과 service method 미구현으로 focused test compile 실패 확인 + - GREEN 구현: 보낸 팬 + `RECEIVED` 상태만 `CANCELED`로 전이, `GiftDelivery.canceledAt` 저장, `refundGiftCan` 호출 + - GREEN 확인: `./gradlew test --rerun-tasks --tests "kr.co.vividnext.sodalive.v2.gift.GiftCommandServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.gift.adapter.in.web.GiftControllerTest"` 실행 결과 `BUILD SUCCESSFUL` + - REFACTOR 확인: `./gradlew ktlintCheck --rerun-tasks` 실행 결과 `BUILD SUCCESSFUL` +- 남은 항목: 없음 +- 다음 행동: `P2-T4` 운송장 등록 구현 + +### P2-T4 운송장 등록 구현 — 2026-09-29 + +- 상태: 완료 +- 무엇을: `POST /api/v2/gifts/{applicationNo}/tracking` 운송장 등록 API와 service를 추가했다. +- 왜: 팬이 접수된 선물에 택배사명과 운송장 번호를 등록해 크리에이터가 받은 선물로 조회할 수 있는 상태로 전이해야 하기 때문이다. +- 어떻게: + - RED test: `GiftCommandServiceTest`, `GiftControllerTest`에 발신자/상태/택배사명/운송장번호 검증과 응답 검증을 추가 + - RED 확인: 신규 command/result/request와 `registerTracking` 미구현으로 focused test compile 실패 확인 + - GREEN 구현: 보낸 팬 + `RECEIVED` 상태만 `TRACKING_REGISTERED`로 전이, `trackingRegisteredAt`, `recipientAddressDeadlineAt = now + 7일`, 택배사명/운송장 번호 저장 + - GREEN 확인: `./gradlew test --rerun-tasks --tests "kr.co.vividnext.sodalive.v2.gift.GiftCommandServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.gift.adapter.in.web.GiftControllerTest"` 실행 결과 `BUILD SUCCESSFUL` + - REFACTOR 확인: `./gradlew ktlintCheck --rerun-tasks` 실행 결과 `BUILD SUCCESSFUL` +- 남은 항목: 없음 +- 다음 행동: `P3-T1` 선물함 목록 조회 구현 + +### P2-GATE 팬 신청/결제/취소/운송장 등록 판정 — 2026-09-29 + +- 상태: 완료 +- 검증: + - `./gradlew test --rerun-tasks --tests "kr.co.vividnext.sodalive.v2.gift.GiftCommandServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.gift.adapter.in.web.GiftControllerTest"` 실행 결과 `BUILD SUCCESSFUL` + - `./gradlew ktlintCheck --rerun-tasks` 실행 결과 `BUILD SUCCESSFUL` +- 판정: `GIFT-002`~`GIFT-005` 핵심 mutation 통과, 운송장 등록 후 `TRACKING_REGISTERED` 상태와 받은 선물 노출 선행 상태 저장 확인 + +### P3-T1 선물함 목록 조회 구현 — 2026-09-29 + +- 상태: 완료 +- 무엇을: `GET /api/v2/gifts?type=ALL|SENT|RECEIVED&page=&size=` 목록 조회 API와 query service를 추가했다. +- 왜: 팬/크리에이터가 보낸 선물과 받은 선물을 역할별 `direction`으로 구분해 조회해야 하기 때문이다. +- 어떻게: + - RED test: `GiftQueryServiceTest`, `GiftControllerTest`에 보낸 선물 즉시 노출, 받은 선물 `TRACKING_REGISTERED` 이상 노출, `ALL` direction, page/size 보정, controller 응답 검증 추가 + - RED 확인: `GiftListType`, `getGifts` 미구현으로 focused test compile 실패 확인 + - GREEN 구현: `GiftListType`, `GiftDirection`, repository page 조회, `GiftQueryService.getGifts`, `GiftListResponse`, `GET /api/v2/gifts` 추가 + - GREEN 확인: `./gradlew test --rerun-tasks --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftQueryServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.gift.adapter.in.web.GiftControllerTest"` 실행 결과 `BUILD SUCCESSFUL` + - REFACTOR 확인: `./gradlew ktlintCheck --rerun-tasks` 실행 결과 `BUILD SUCCESSFUL` +- 남은 항목: 없음 +- 다음 행동: `P3-T2` 선물 상세 조회 구현 + +### P3-T2 선물 상세 조회 구현 — 2026-09-29 + +- 상태: 완료 +- 무엇을: `GET /api/v2/gifts/{applicationNo}` 상세 조회 API와 role 기반 상세 query를 추가했다. +- 왜: 보낸 팬과 받는 크리에이터가 같은 선물을 보더라도 노출 가능한 개인정보와 액션 플래그가 달라야 하기 때문이다. +- 어떻게: + - RED test: `GiftQueryServiceTest`, `GiftControllerTest`에 SENT/RECEIVED 권한, 개인정보 경계, 주소 조립, `trackingRequired`, `recipientAddressRequired`, 상태 타임라인, 종료 정보 검증 추가 + - RED 확인: `getGiftDetail` 미구현으로 focused test compile 실패 확인 + - GREEN 구현: 상세 권한 판정, 발신/수신 응답 분기, 주소 포맷, 상태 타임라인, `canceledAt`/`undeliverableAt` 종료 정보 반환 구현 + - GREEN 확인: `./gradlew test --rerun-tasks --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftQueryServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.gift.adapter.in.web.GiftControllerTest"` 실행 결과 `BUILD SUCCESSFUL` + - REFACTOR 확인: `./gradlew ktlintCheck --rerun-tasks` 실행 결과 `BUILD SUCCESSFUL` +- 남은 항목: 없음 +- 다음 행동: `P3-T3` 크리에이터 배송지 입력 구현 + +### P3-T3 크리에이터 배송지 입력 구현 — 2026-09-29 + +- 상태: 완료 +- 무엇을: `POST /api/v2/gifts/{applicationNo}/recipient-address` 배송지 입력 API와 service를 추가했다. +- 왜: 크리에이터가 운송장 등록 이후 수령 주소와 수령 약관 동의를 등록해야 하기 때문이다. +- 어떻게: + - RED test: `GiftCommandServiceTest`, `GiftControllerTest`에 수령자 권한, `RECEIVED`/종료 상태 거부, 필수 입력, 약관 2개 동의, controller 응답 검증 추가 + - RED 확인: 신규 command/request/result/service 미구현으로 focused test compile 실패 확인 + - GREEN 구현: 수령자 + 등록 가능 상태만 배송지 저장, `Gift.recipientTermsAgreedAt`/`recipientPrivacyAgreedAt`에 동의 시각 저장, 응답 반환 구현 + - GREEN 확인: `./gradlew test --rerun-tasks --tests "kr.co.vividnext.sodalive.v2.gift.GiftCommandServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.gift.adapter.in.web.GiftControllerTest"` 실행 결과 `BUILD SUCCESSFUL` + - REFACTOR 확인: `./gradlew ktlintCheck --rerun-tasks` 실행 결과 `BUILD SUCCESSFUL` +- 남은 항목: 없음 +- 다음 행동: `P3-T4` 크리에이터 수령확인 구현 + +### P3-T4 크리에이터 수령확인 구현 — 2026-09-29 + +- 상태: 완료 +- 무엇을: `POST /api/v2/gifts/{applicationNo}/delivery-complete` 수령확인 API와 service를 추가했다. +- 왜: 크리에이터가 검수 완료된 선물을 직접 전달 완료 처리할 수 있어야 하기 때문이다. +- 어떻게: + - RED test: `GiftCommandServiceTest`, `GiftControllerTest`에 수령자 권한, `INSPECTION_COMPLETED` 상태 제한, `deliveredAt` 저장, controller 응답 검증 추가 + - RED 확인: 신규 결과 타입과 `confirmDelivery` 미구현으로 focused test compile 실패 확인 + - GREEN 구현: 수령자 + `INSPECTION_COMPLETED` 상태만 `DELIVERED`로 전이, `GiftDelivery.deliveredAt` 저장, 응답 반환 구현 + - GREEN 확인: `./gradlew test --rerun-tasks --tests "kr.co.vividnext.sodalive.v2.gift.GiftCommandServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.gift.adapter.in.web.GiftControllerTest"` 실행 결과 `BUILD SUCCESSFUL` + - REFACTOR 확인: `./gradlew ktlintCheck --rerun-tasks` 실행 결과 `BUILD SUCCESSFUL` +- 남은 항목: 없음 +- 다음 행동: `P3-GATE` 조회와 크리에이터 배송지/수령 흐름 판정 + +### P3-GATE 조회와 크리에이터 배송지/수령 흐름 판정 — 2026-09-29 + +- 상태: 완료 +- 검증: + - `./gradlew test --rerun-tasks --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftQueryServiceTest" --tests "kr.co.vividnext.sodalive.v2.gift.GiftCommandServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.gift.adapter.in.web.GiftControllerTest"` 실행 결과 `BUILD SUCCESSFUL` + - `./gradlew ktlintCheck --rerun-tasks` 실행 결과 `BUILD SUCCESSFUL` +- 판정: `GIFT-007`과 상세/목록 권한 정책, 크리에이터 배송지 입력/수령확인 흐름 통과 +- 다음 행동: `P4-T1` 운송장 미등록 자동취소 구현 + +### P4-T1 운송장 미등록 자동취소 구현 — 2026-09-30 + +- 상태: 완료 +- 무엇을: 운송장 등록 기한이 지난 `RECEIVED` 선물을 자동 `CANCELED` 처리하고 전액 환불하는 scheduler를 추가했다. +- 왜: 팬이 신청한 뒤 운송장을 등록하지 않은 선물은 기한 초과 시 자동 취소되어야 하기 때문이다. +- 어떻게: + - RED test: `GiftSchedulerTest`에 기한 초과 대상, 기한 동일 제외, 상태 조건, 재실행 idempotent와 환불 1회 검증 추가 + - RED 확인: 신규 scheduler/service 미구현으로 focused test compile 실패 확인 + - GREEN 구현: `GiftScheduler.cancelExpiredTrackingRegistrationGifts`, `GiftRepository.findReceivedGiftsWithTrackingDeadlineBefore` 추가 + - GREEN 확인: `./gradlew test --rerun-tasks --tests "kr.co.vividnext.sodalive.v2.gift.adapter.out.scheduler.GiftSchedulerTest"` 실행 결과 `BUILD SUCCESSFUL` + - REFACTOR 확인: `./gradlew ktlintCheck --rerun-tasks` 실행 결과 `BUILD SUCCESSFUL` +- 남은 항목: 없음 +- 다음 행동: `P4-T2` 배송지 미입력 전달불가 구현 + +### P4-T2 배송지 미입력 전달불가 구현 — 2026-09-30 + +- 상태: 완료 +- 무엇을: 배송지 입력 기한이 지난 `TRACKING_REGISTERED` 선물을 자동 `UNDELIVERABLE` 처리하는 scheduler를 추가했다. +- 왜: 운송장 등록 후에도 크리에이터가 배송지를 입력하지 않으면 전달불가로 종료되어야 하기 때문이다. +- 어떻게: + - RED test: `GiftSchedulerTest`에 기한 초과 대상, 기한 동일 제외, 배송지 입력 완료 제외, 재실행 idempotent 검증 추가 + - RED 확인: `markExpiredRecipientAddressGiftsUndeliverable` 미구현으로 focused test compile 실패 확인 + - GREEN 구현: `GiftScheduler.markExpiredRecipientAddressGiftsUndeliverable`, `GiftRepository.findTrackingRegisteredGiftsWithRecipientAddressDeadlineBefore` 추가 + - GREEN 확인: `./gradlew test --rerun-tasks --tests "kr.co.vividnext.sodalive.v2.gift.adapter.out.scheduler.GiftSchedulerTest"` 실행 결과 `BUILD SUCCESSFUL` + - REFACTOR 확인: `./gradlew ktlintCheck --rerun-tasks` 실행 결과 `BUILD SUCCESSFUL` +- 남은 항목: 없음 +- 다음 행동: `P4-T3` 24시간 전 안내 작업 구현 + +### P4-T3 24시간 전 안내 작업 구현 — 2026-09-30 + +- 상태: 완료 +- 무엇을: 운송장 등록 기한과 배송지 입력 기한 24시간 전 안내 작업을 추가했다. +- 왜: 팬과 크리에이터가 자동취소/전달불가 전에 남은 작업을 처리할 수 있어야 하기 때문이다. +- 어떻게: + - RED test: `GiftSchedulerTest`에 24시간 이내 대상만 안내, 24시간 초과 제외, 배송지 입력 완료 제외, 재실행 중복 방지 검증 추가 + - RED 확인: `GiftPushService`, 안내 발송 시각 필드, `sendDeadlineReminderGifts` 미구현으로 focused test compile 실패 확인 + - GREEN 구현: `GiftScheduler.sendDeadlineReminderGifts`, 안내 대상 repository query, `GiftDelivery` 안내 발송 시각 컬럼, `GiftPushService` 추가 + - GREEN 확인: `./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftPushServiceTest" --tests "kr.co.vividnext.sodalive.v2.gift.adapter.out.scheduler.GiftSchedulerTest"` 실행 결과 `BUILD SUCCESSFUL` + - REFACTOR 확인: `gift-schema.sql`에 안내 발송 시각 컬럼 반영, `./gradlew ktlintCheck --rerun-tasks` 실행 결과 `BUILD SUCCESSFUL` +- 남은 항목: `GIFT_DETAIL` applicationNo 딥링크 타입 확장은 계획상 `P7-T1`에서 처리 +- 다음 행동: `P4-GATE` 자동 처리와 idempotency 판정 + +### P4-GATE 자동 처리와 idempotency 판정 — 2026-09-30 + +- 상태: 완료 +- 검증: + - `./gradlew test --rerun-tasks --tests "kr.co.vividnext.sodalive.v2.gift.adapter.out.scheduler.GiftSchedulerTest" --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftPushServiceTest"` 실행 결과 `BUILD SUCCESSFUL` + - `./gradlew ktlintCheck --rerun-tasks` 실행 결과 `BUILD SUCCESSFUL` +- 판정: `GIFT-006`, `GIFT-008` 자동 처리와 24시간 전 안내 중복 방지 통과 +- 다음 행동: `P5-T1` 사서함 도착과 검수완료 API 구현 + +### P5-T1 사서함 도착과 검수완료 API 구현 — 2026-09-30 + +- 상태: 완료 +- 무엇을: 운영자용 사서함 도착/검수완료 상태 전이 API와 상태별 푸시 요청을 추가했다. +- 왜: 운영자가 `TRACKING_REGISTERED -> ARRIVED_AT_MAILBOX -> INSPECTION_COMPLETED` 흐름을 전용 API로 처리해야 하기 때문이다. +- 어떻게: + - RED test: `GiftAdminServiceTest`, `AdminGiftControllerTest`에 허용 상태, 금지 상태, 상태 시각 저장, 푸시 호출 검증 추가 + - RED 확인: `arriveMailbox`, `completeInspection`, 운영 푸시 메서드 미구현으로 focused test compile 실패 확인 + - GREEN 구현: `GiftAdminService.arriveMailbox`, `GiftAdminService.completeInspection`, 관리자 API 2개, 운영 상태 응답 DTO, `GiftPushService` 운영 푸시 3개 추가 + - GREEN 확인: `./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftAdminServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.in.web.AdminGiftControllerTest"` 실행 결과 `BUILD SUCCESSFUL` + - REFACTOR 확인: `GiftPushServiceTest`에 운영 푸시 이벤트 본문 검증 추가, `./gradlew test --rerun-tasks --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftAdminServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.in.web.AdminGiftControllerTest" --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftPushServiceTest"` 실행 결과 `BUILD SUCCESSFUL`, `./gradlew ktlintCheck --rerun-tasks` 실행 결과 `BUILD SUCCESSFUL` +- 남은 항목: 없음 +- 다음 행동: `P5-T2` 운영 전달불가 API 구현 + +### P5-T2 운영 전달불가 API 구현 — 2026-09-30 + +- 상태: 완료 +- 무엇을: 운영자용 전달불가 상태 전이 API와 팬/크리에이터 전달불가 푸시 요청을 추가했다. +- 왜: 운영자가 배송/검수 흐름 중 전달 불가능한 선물을 사유와 함께 `UNDELIVERABLE`로 종료 처리해야 하기 때문이다. +- 어떻게: + - RED test: `GiftAdminServiceTest`, `AdminGiftControllerTest`, `GiftPushServiceTest`에 허용 상태, 사유 필수, 종료 상태 실패, 전달불가 푸시 호출/본문 검증 추가 + - RED 확인: `markUndeliverable`, `sendUndeliverableToSender`, `sendUndeliverableToRecipient` 미구현으로 focused test compile 실패 확인 + - GREEN 구현: `GiftAdminService.markUndeliverable`, `POST /api/v2/admin/gifts/{applicationNo}/mark-undeliverable`, `AdminGiftMarkUndeliverableRequest`, 전달불가 푸시 2개 추가 + - GREEN 확인: `./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftAdminServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.in.web.AdminGiftControllerTest" --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftPushServiceTest"` 실행 결과 `BUILD SUCCESSFUL` + - REFACTOR 확인: `AdminGiftControllerTest` fixture cleanup과 import 순서 정리 후 같은 focused test 실행 결과 `BUILD SUCCESSFUL`, `./gradlew ktlintCheck --rerun-tasks` 실행 결과 `BUILD SUCCESSFUL` +- 남은 항목: 없음 +- 다음 행동: `P5-T3` 운영 전달완료 API 구현 + +### P5-T3 운영 전달완료 API 구현 — 2026-09-30 + +- 상태: 완료 +- 무엇을: 운영자용 전달완료 상태 전이 API와 팬 전달완료 푸시 요청을 추가했다. +- 왜: 운영자가 검수 완료된 선물을 `DELIVERED`로 종료 처리하고 팬에게 전달 완료를 알려야 하기 때문이다. +- 어떻게: + - RED test: `GiftAdminServiceTest`, `AdminGiftControllerTest`, `GiftPushServiceTest`에 `INSPECTION_COMPLETED` 허용, 금지 상태, `deliveredAt` 저장, 팬 전달완료 푸시와 크리에이터 푸시 미발송 검증 추가 + - RED 확인: `completeDelivery`, `sendDeliveredToSender` 미구현으로 focused test compile 실패 확인 + - GREEN 구현: `GiftAdminService.completeDelivery`, `POST /api/v2/admin/gifts/{applicationNo}/complete-delivery`, 팬 전달완료 푸시 추가 + - GREEN 확인: `./gradlew test --rerun-tasks --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftAdminServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.in.web.AdminGiftControllerTest" --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftPushServiceTest"` 실행 결과 `BUILD SUCCESSFUL` + - REFACTOR 확인: `./gradlew ktlintCheck --rerun-tasks` 실행 결과 `BUILD SUCCESSFUL` +- 남은 항목: `P5-GATE` +- 다음 행동: `P5-GATE` 운영 상태변경 API 판정 + +### P5-GATE 운영 상태변경 API 판정 — 2026-09-30 + +- 상태: 완료 +- 검증: + - `./gradlew test --rerun-tasks --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftAdminServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.in.web.AdminGiftControllerTest"` 실행 결과 `BUILD SUCCESSFUL` + - `./gradlew ktlintCheck --rerun-tasks` 실행 결과 `BUILD SUCCESSFUL` +- 판정: `GIFT-009` 운영 상태별 전용 API와 금지 상태 전이 통과 +- 다음 행동: `P6-T1` 리뷰 작성 service 구현 + +### P6-T1 리뷰 작성 service 구현 — 2026-09-30 + +- 상태: 완료 +- 무엇을: 보낸 팬이 `DELIVERED` 선물에 별점, 여러 키워드, 추가의견 리뷰를 1회 작성하는 service를 추가했다. +- 왜: PRD `GIFT-010`의 보낸 팬/전달완료/중복 방지/입력 길이 제한 요구를 충족하기 위해서다. +- 어떻게: + - RED test: `GiftCommandServiceTest`에 보낸 팬 성공, 수신자 실패, 비전달완료 실패, 중복 실패, 별점 1~5, keyword/comment 255자 제한 검증 추가 + - RED 확인: `./gradlew test --rerun-tasks --tests "kr.co.vividnext.sodalive.v2.gift.GiftCommandServiceTest"` 실행 결과 `GiftReviewCommand`, `GiftReviewResult`, `writeReview`, `reviewRepository` 미구현으로 `compileTestKotlin` 실패 확인 + - GREEN 구현: `GiftCommandService.writeReview`, `GiftReviewCommand`, `GiftReviewResult`, `GiftReviewRepository` 주입을 추가하고 `GiftReview.keywords`에는 현재 DB `text` 타입 기준으로 comma-separated 문자열을 저장 + - GREEN 확인: `./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.GiftCommandServiceTest"` 실행 결과 `BUILD SUCCESSFUL` + - REFACTOR 확인: `./gradlew ktlintCheck --rerun-tasks` 실행 결과 `BUILD SUCCESSFUL`, 같은 focused test 재실행 결과 `BUILD SUCCESSFUL` +- 남은 항목: 없음 +- 다음 행동: `P6-T2` 리뷰 작성 API 구현 + +### P6-T2 리뷰 작성 API 구현 — 2026-09-30 + +- 상태: 완료 +- 무엇을: `POST /api/v2/gifts/{applicationNo}/review` 리뷰 작성 API와 request/response DTO를 추가했다. +- 왜: PRD `GIFT-010`의 리뷰 작성 API 계약에서 `keywords` 배열 요청/응답과 단수 `keyword` 미노출을 보장해야 하기 때문이다. +- 어떻게: + - RED test: `GiftControllerTest`에 리뷰 작성 성공 envelope, `keywords` 배열 응답, 단수 `keyword` 미노출 검증 추가 + - RED 확인: `./gradlew test --tests "kr.co.vividnext.sodalive.v2.api.gift.adapter.in.web.GiftControllerTest"` 실행 결과 `GiftReviewRequest` 미구현으로 `compileTestKotlin` 실패 확인 + - GREEN 구현: `GiftReviewRequest`, `GiftReviewResponse`, `GiftController.writeReview`를 추가하고 기존 `GiftCommandService.writeReview`에 연결 + - GREEN 확인: `./gradlew test --tests "kr.co.vividnext.sodalive.v2.api.gift.adapter.in.web.GiftControllerTest.shouldWriteReview"` 실행 결과 `BUILD SUCCESSFUL` + - REFACTOR 확인: `./gradlew test --tests "kr.co.vividnext.sodalive.v2.api.gift.adapter.in.web.GiftControllerTest"`, `./gradlew ktlintCheck --rerun-tasks` 실행 결과 모두 `BUILD SUCCESSFUL` +- 남은 항목: `P6-GATE` +- 다음 행동: `P6-GATE` 리뷰 작성 요구사항 판정 + +### P6-GATE 리뷰 작성 요구사항 판정 — 2026-09-30 + +- 상태: 완료 +- 검증: + - `./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.GiftCommandServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.gift.GiftControllerTest"` 실행 결과 `BUILD SUCCESSFUL` + - `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL` +- 판정: `GIFT-010` 리뷰 작성 service와 `POST /api/v2/gifts/{applicationNo}/review` API 계약 통과 +- 다음 행동: `P7-T1` FCM 이벤트와 딥링크 확장 + +### P7-T1 FCM 이벤트와 딥링크 확장 — 2026-09-30 + +- 상태: 완료 +- 무엇을: 선물 푸시에 `GIFT` 카테고리와 `GIFT_DETAIL` 딥링크를 추가하고 `applicationNo`를 딥링크 ID로 발행하게 했다. +- 왜: PRD의 모든 선물 푸시가 단일 `GIFT_DETAIL` + `applicationNo` 딥링크로 상세 화면에 진입해야 하기 때문이다. +- 어떻게: + - RED test: `GiftPushServiceTest`에 선물 푸시 이벤트의 `deepLinkValue=GIFT_DETAIL`, `deepLinkId=applicationNo`, `category=GIFT` 검증 추가 + - RED 확인: `./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftPushServiceTest"` 실행 결과 `PushNotificationCategory.GIFT`, `FcmDeepLinkValue.GIFT_DETAIL` 미구현으로 `compileTestKotlin` 실패 확인 + - GREEN 구현: `FcmDeepLinkValue.GIFT_DETAIL`, `PushNotificationCategory.GIFT` 추가, `FcmEvent`/`FcmService`의 `deepLinkId`를 문자열 신청번호도 받을 수 있게 최소 확장, `GiftPushService`에서 신청번호 딥링크 발행 + - GREEN 확인: `./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftPushServiceTest"` 실행 결과 `BUILD SUCCESSFUL` + - REFACTOR 확인: `./gradlew test --tests "kr.co.vividnext.sodalive.fcm.*" --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftPushServiceTest"`, `./gradlew ktlintCheck` 실행 결과 모두 `BUILD SUCCESSFUL` +- 남은 항목: 없음 +- 다음 행동: `P7-T2` 상태별 팬/크리에이터 푸시 구현 + +### DDL 날짜 컬럼 정책 변경 — 2026-09-30 + +- 상태: 완료 +- 무엇을: `gift-schema.sql`의 날짜 컬럼 타입을 `DATETIME`에서 `TIMESTAMP`로 변경하고, 모든 `created_at`/`updated_at`을 `NOT NULL DEFAULT CURRENT_TIMESTAMP`로 통일했다. +- 왜: 운영 반영 DDL에서 날짜/시각 컬럼 정책을 `TIMESTAMP` 기반으로 맞추고 필수 audit 컬럼은 DB 기본값을 갖도록 하기 위해서다. +- 어떻게: + - `updated_at`에는 `ON UPDATE CURRENT_TIMESTAMP`를 적용 + - 기한/상태 변경 시각 같은 비즈니스 날짜 컬럼은 앱에서 의미 있는 값을 넣어야 하므로 임의 `DEFAULT CURRENT_TIMESTAMP`는 추가하지 않음 + - 확인: `rg -n "DATETIME|created_at DATETIME|updated_at DATETIME|created_at TIMESTAMP NULL|updated_at TIMESTAMP NULL" "docs/20260929_크리에이터_선물하기/gift-schema.sql"` 실행 결과 잔여 항목 없음 +- 남은 항목: 없음 +- 다음 행동: `P7-T2` 상태별 팬/크리에이터 푸시 구현 + +### P7-T2 상태별 팬/크리에이터 푸시 구현 — 2026-09-30 + +- 상태: 완료 +- 무엇을: PRD 푸시 정책의 누락된 자동취소, 운송장 등록 완료, 크리에이터 배송완료 푸시를 추가하고 직접 수령확인 시 크리에이터 푸시가 없는 동작을 고정했다. +- 왜: 선물 상태 전이가 끝까지 진행될 때 팬/크리에이터가 각자 해야 할 작업과 결과를 푸시로 받아야 하기 때문이다. +- 어떻게: + - RED test: `GiftPushServiceTest`, `GiftCommandServiceTest`, `GiftSchedulerTest`, `GiftAdminServiceTest`에 누락 푸시 3종과 직접 수령확인 푸시 미발송 검증 추가 + - RED 확인: `./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftPushServiceTest" --tests "kr.co.vividnext.sodalive.v2.gift.GiftCommandServiceTest" --tests "kr.co.vividnext.sodalive.v2.gift.adapter.out.scheduler.GiftSchedulerTest" --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftAdminServiceTest"` 실행 결과 신규 푸시 메서드와 `GiftCommandService.giftPushService` 미구현으로 `compileTestKotlin` 실패 확인 + - GREEN 구현: `GiftPushService`에 `sendAutoCanceledToSender`, `sendTrackingRegisteredToRecipient`, `sendDeliveredToRecipient` 추가, 스케줄러/사용자 운송장 등록/운영 전달완료 호출부 연결 + - GREEN 확인: 같은 focused test 실행 결과 `BUILD SUCCESSFUL` + - REFACTOR 확인: 푸시 본문은 현재 구현 패턴대로 이벤트의 기본 `title`/`message`를 사용하므로 message key 추가는 하지 않음. `./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.*" --tests "kr.co.vividnext.sodalive.v2.api.admin.gift.*" --tests "kr.co.vividnext.sodalive.v2.api.gift.*"`, `./gradlew ktlintCheck` 실행 결과 모두 `BUILD SUCCESSFUL` +- 남은 항목: 없음 +- 다음 행동: `P7-T3` 최종 통합 회귀 + +### P7-T3 최종 통합 회귀 — 2026-09-30 + +- 상태: 완료 +- 사용자 흐름 대조표: + - 신청 → 취소: `registerGift`, `cancelGift`, 캔 환불 회귀 통과 + - 신청 → 운송장 → 배송지 → 운영 검수 → 수령확인 → 리뷰: 사용자/관리자 command, controller, query 회귀 통과 + - 신청 → 자동취소: 스케줄러 자동취소, 환불, 팬 자동취소 푸시 회귀 통과 + - 운송장 → 배송지 미입력 전달불가: 스케줄러 전달불가, idempotency 회귀 통과 +- 검증: + - focused/direct: `./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.*" --tests "kr.co.vividnext.sodalive.v2.api.admin.gift.*" --tests "kr.co.vividnext.sodalive.v2.api.gift.*"` 실행 결과 `BUILD SUCCESSFUL` + - lint: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL` + - full: `./gradlew test` 실행 결과 `BUILD SUCCESSFUL` +- 남은 항목: 없음 +- 다음 행동: `P7-GATE` 릴리스 후보 판정 + +### P7-GATE 선물하기 릴리스 후보 판정 — 2026-09-30 + +- 상태: 완료 +- 검증: + - `./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.*" --tests "kr.co.vividnext.sodalive.v2.api.admin.gift.*" --tests "kr.co.vividnext.sodalive.v2.api.gift.*"` 실행 결과 `BUILD SUCCESSFUL` + - `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL` + - `./gradlew test` 실행 결과 `BUILD SUCCESSFUL` +- 판정: PRD `GIFT-001`~`GIFT-013` 구현·검증·문서 추적 완료 +- 남은 항목: 없음 + +### DDL FK와 선물 금액 스냅샷 정리 — 2026-09-30 + +- 상태: 완료 +- 무엇을: 운영 반영용 `gift-schema.sql`에 FK를 추가하고, 선물 신청 건에서는 최종 결제 금액 스냅샷만 저장하도록 `gift.base_price_can`/`Gift.basePriceCan`을 제거했다. +- 왜: 기존 운영 관례가 FK 없음이 아니고, 신규 선물 도메인은 운영 반영 DDL에서 참조 무결성을 DB로 보장하는 편이 안전하기 때문이다. 신청 건에는 환불/상세/내역에 필요한 최종 결제 금액만 있으면 충분하다. +- 어떻게: + - RED test: `Gift` 생성 fixture와 저장 검증에서 `basePriceCan` 제거 후 production `Gift` 생성자가 아직 요구해 `compileTestKotlin` 실패 확인 + - GREEN 구현: `Gift` 엔티티와 `GiftCommandService`에서 기본 금액 스냅샷 제거, `gift-schema.sql`의 `gift.base_price_can` 제거 + - DDL 구현: `gift -> member/use_can/gift_category`, `gift_delivery -> gift`, `gift_review -> gift/member` FK 추가. `gift_category`는 FK 참조 전에 생성되도록 DDL 순서 조정 + - 검증: focused gift/API/scheduler/admin test 실행 결과 `BUILD SUCCESSFUL`, `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL` +- 남은 항목: 없음 + +## Decision Log + +| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 | +|---|---|---|---|---|---| +| 2026-09-29 | `PLAN-DEC-001` | 확정 | 신규 선물 도메인은 `kr.co.vividnext.sodalive.v2.gift`, 사용자 API는 `v2.api.gift`, 관리자 API는 `v2.api.admin.gift`에 둔다 | 기존 v2 API와 도메인 분리 패턴 | 전체 구현 | +| 2026-09-29 | `PLAN-DEC-002` | 확정 | 운영 DB DDL은 같은 작업 디렉터리의 `gift-schema.sql`로 작성한다 | 기존 docs 작업 디렉터리 SQL 문서 패턴 | `P1-T1` | +| 2026-09-29 | `PLAN-DEC-003` | 확정 | 최종 Gate에서는 전체 `./gradlew test`를 실행한다 | 캔 결제, FCM, 스케줄러 등 공통 경계를 변경함 | `P7-GATE` | +| 2026-09-29 | `PLAN-DEC-004` | 확정 | 신규 선물 구현은 v2 신규 도메인 관례대로 `domain`, `application`, `port`, `adapter` 구조를 사용한다 | 사용자 지시: v2 아래 구현된 관례만 확인하고 처리 | 전체 구현 | +| 2026-09-30 | `PLAN-DEC-005` | 확정 | 운영 반영용 DDL에는 선물 도메인 FK를 명시하고, 선물 신청 건에는 최종 결제 금액 스냅샷만 저장한다 | 사용자 결정: 제대로 가는 방향 선호, `basePriceCan`은 신청 건에 불필요 | `gift-schema.sql`, `Gift` | + +## 발견된 문제 + +| ID | 심각도 | 상태 | 발견 내용 | 영향 Goal | 처리 계획 | +|---|---|---|---|---|---| +| 없음 | Low | 해결 | 계획 작성 시점의 차단 문제 없음 | 없음 | 없음 | + +## 최종 보고 형식 + +```markdown +구현 결과: 완료한 Phase와 사용자 흐름을 한 문장으로 작성 + +- 변경: 주요 파일과 동작 요약 +- 결정: 중요한 Decision Log ID와 내용 +- 검증: + - 실행 명령 — 성공/실패와 핵심 수치 + - 수동 검증 — 성공/실패/불가 사유 +- 남은 항목: 외부 의존, 후속 범위 또는 없음 +- 문서: 갱신한 PRD/plan/review 링크 +``` diff --git a/docs/20260929_크리에이터_선물하기/prd.md b/docs/20260929_크리에이터_선물하기/prd.md new file mode 100644 index 00000000..672f854d --- /dev/null +++ b/docs/20260929_크리에이터_선물하기/prd.md @@ -0,0 +1,899 @@ +# PRD: 크리에이터 선물하기 + +## 문서 정보 + +| 항목 | 내용 | +|---|---| +| 문서 상태 | 구현 중 | +| 작성일 | 2026-09-29 | +| 최종 수정일 | 2026-09-29 | +| 대상 제품 | 크리에이터 선물하기 | +| 작성자·결정권자 | 사용자 | +| 관련 API Contract | 별도 문서 없음. 이 문서의 `8. API 계약`을 기준으로 사용 | +| 관련 구현 계획 | `docs/20260929_크리에이터_선물하기/plan-task.md` | +| 관련 review | 없음 | + +## 1. Overview + +팬이 내부 재화인 캔을 사용해 크리에이터에게 실물 선물을 보낼 수 있는 API를 제공한다. +팬은 선물 신청, 운송장 등록, 취소, 상세 조회와 리뷰 작성을 할 수 있고, 크리에이터는 운송장 등록 이후 받은 선물을 확인하고 배송지를 입력한 뒤 수령 확인을 할 수 있다. +관리자는 선물 카테고리와 사이즈별 가격을 관리하고, 운영자는 사서함 도착, 검수, 전달 완료/불가 상태를 처리한다. + +## 2. Problem Statement + +- 현재 팬이 크리에이터에게 실물 선물을 보내는 신청, 결제, 배송 상태, 검수 상태를 서버에서 일관되게 추적할 수 없다. +- 선물 가격은 사이즈별로 다르고 운영자가 변경해야 하므로 코드 상수만으로는 운영 요구를 충족할 수 없다. +- 운송장 미등록, 크리에이터 배송지 미입력, 검수 실패 같은 종료 상태와 환불/푸시 정책이 명확히 정의되어야 한다. +- 팬과 크리에이터가 서로 다른 시점에 약관과 개인정보 처리에 동의해야 하므로 신청 건 기준의 동의 스냅샷이 필요하다. + +문제를 해결했다는 판단은 선물 신청부터 전달 완료 또는 종료 상태까지 모든 상태 전이가 API/스케줄링/운영 API로 추적되고, 캔 사용내역과 푸시가 상태별 정책대로 처리되는 것으로 한다. + +## 3. Goals + +- 팬이 선물 사이즈, 카테고리, 발신자 정보, 약관 동의를 입력해 선물 보내기를 신청할 수 있다. +- 선물 신청 시 캔을 즉시 차감하고 내부적으로 선물 신청과 캔 사용내역을 연결한다. +- 팬은 운송장 등록 전 선물 신청을 취소할 수 있고, 3일 내 운송장을 등록하지 않으면 자동취소 및 전액 환불된다. +- 크리에이터는 팬이 운송장을 등록한 선물부터 받은 선물 목록과 상세를 조회하고 7일 이내 배송지와 수령 약관 동의를 등록할 수 있다. +- 팬과 크리에이터는 같은 선물함 목록 API에서 보낸/받은 내역을 함께 또는 개별 조회할 수 있다. +- 운영자는 상태별 전용 관리자 API로 사서함 도착, 검수완료, 전달불가, 전달완료를 처리할 수 있다. +- 선물 카테고리와 사이즈별 가격은 관리자 API로 관리할 수 있다. +- 상태별 팬/크리에이터 푸시 문구와 딥링크를 명확히 정의한다. + +## 4. Non-Goals + +- 약관 본문 제공 API는 포함하지 않는다. 약관 내용은 별도 API 또는 콘텐츠로 제공한다. +- 실물 택배사 API 연동, 송장 유효성 실시간 조회, 배송 추적 자동화는 포함하지 않는다. +- 선물 이미지, 첨부파일, 메시지 카드는 포함하지 않는다. +- 선물 카테고리 hard delete는 허용하지 않는다. +- 선물 사이즈 정의 자체를 관리자에서 추가/삭제하는 기능은 포함하지 않는다. +- 캔 사용내역 화면에서 사용자가 선물 신청 상세로 이동하는 기능은 포함하지 않는다. + +## 5. Target Users and Permissions + +| 사용자 | 목표 | 주요 작업 | 권한 | +|---|---|---|---| +| 팬 | 크리에이터에게 선물을 보낸다 | 사이즈/카테고리 조회, 신청, 운송장 등록, 취소, 보낸 내역 조회, 리뷰 작성 | 인증 회원 | +| 크리에이터 | 받은 선물을 확인하고 배송지를 입력한다 | 받은 내역 조회, 배송지 입력, 수령 확인 | 인증 회원 중 크리에이터 | +| 관리자 | 선물 운영 설정을 관리한다 | 카테고리, 사이즈 가격 관리 | 관리자 | +| 운영자 | 선물 검수/전달 상태를 처리한다 | 사서함 도착, 검수완료, 전달불가, 전달완료 처리 | 관리자 또는 운영 권한 | +| 스케줄러 | 기한 초과 건을 자동 처리한다 | 운송장 미등록 자동취소, 배송지 미입력 전달불가 | 시스템 | + +기본 권한 정책은 기존 인증 필요 API와 관리자 API 정책을 따른다. +팬과 크리에이터는 본인과 관련된 선물만 조회/변경할 수 있다. + +## 6. 핵심 정책 + +### 6.1 선물 상태 + +| 상태 코드 | 표시명 | 의미 | 종료 상태 | +|---|---|---|---| +| `RECEIVED` | 접수 완료 | 팬이 신청하고 캔 결제를 완료한 최초 상태 | 아니오 | +| `TRACKING_REGISTERED` | 발송 확인 | 팬이 택배사와 운송장 번호를 등록한 상태 | 아니오 | +| `ARRIVED_AT_MAILBOX` | 사서함 도착 | 운영자가 소다라이브 사서함 도착을 확인한 상태 | 아니오 | +| `INSPECTION_COMPLETED` | 검수완료 | 운영자가 선물 검수를 통과 처리한 상태 | 아니오 | +| `DELIVERED` | 전달완료 | 크리에이터 수령확인 또는 운영자 전달완료 처리 상태 | 예 | +| `UNDELIVERABLE` | 전달불가 | 검수 실패 또는 배송지 미입력 기한 초과로 전달할 수 없는 상태 | 예 | +| `CANCELED` | 신청 취소 | 운송장 등록 전 사용자 취소 또는 자동취소 상태 | 예 | + +상태 전이는 다음을 기본으로 한다. + +```text +RECEIVED -> TRACKING_REGISTERED -> ARRIVED_AT_MAILBOX -> INSPECTION_COMPLETED -> DELIVERED +RECEIVED -> CANCELED +TRACKING_REGISTERED -> UNDELIVERABLE +ARRIVED_AT_MAILBOX -> UNDELIVERABLE +INSPECTION_COMPLETED -> UNDELIVERABLE +``` + +### 6.2 기한 정책 + +- 팬은 선물 신청 후 3일 이내에 운송장 번호를 등록해야 한다. +- 운송장 등록 기한 24시간 전 팬에게 안내 푸시를 보낸다. +- 운송장 등록 기한을 넘긴 `RECEIVED` 선물은 스케줄러가 `CANCELED`로 변경하고 사용 캔을 전액 환불한다. +- 크리에이터는 팬이 운송장을 등록한 뒤 7일 이내에 배송지를 입력해야 한다. +- 배송지 입력 기한 24시간 전 크리에이터에게 안내 푸시를 보낸다. +- 배송지 입력 기한을 넘긴 선물은 스케줄러가 `UNDELIVERABLE`로 변경하고 전달 불가 사유를 `배송지 미입력 기한 초과`로 저장한다. + +### 6.3 신청번호 정책 + +- 신청번호 `applicationNo`는 내부 기본키와 별도로 발급한다. +- 형식은 `G{yyyyMMdd}{dailySequence6}`로 한다. +- `yyyyMMdd`는 Asia/Seoul 기준 신청일이다. +- `dailySequence6`는 해당 날짜 안에서 1부터 증가하는 6자리 숫자다. +- 예시는 `G20260929000001`이다. +- `applicationNo`는 unique이며 생성 후 변경하지 않는다. +- 동시에 여러 선물 신청이 발생해도 같은 `applicationNo`가 발급되지 않아야 한다. +- 구현은 DB unique 제약, 원자적 채번, 충돌 시 재시도 중 하나 이상의 방식으로 경합을 처리해야 한다. + +### 6.4 선물 사이즈와 가격 + +선물 사이즈는 고정 정책이므로 코드 enum으로 관리한다. + +| 코드 | 표시명 | 기준 | 초기 기본금액 | +|---|---|---|---:| +| `SMALL` | 소형 | 가로+세로+높이 100cm 이하, 무게 5kg 이하 | 100캔 | +| `MEDIUM` | 중형 | 가로+세로+높이 120cm 이하, 무게 15kg 이하 | 150캔 | +| `LARGE` | 대형 | 가로+세로+높이 160cm 이하, 무게 20kg 이하 | 200캔 | + +가격은 관리자 설정 DB에서 관리한다. + +- `basePriceCan`: 기본 금액. 클라이언트가 취소선 표시 등에 사용할 수 있다. +- `salePriceCan`: 실제 표시/결제 금액. 할인 금액이 아니라 최종 결제 금액이다. +- 할인 없음은 `salePriceCan == basePriceCan`으로 표현한다. +- 선물 신청 시 실제 차감 금액은 선택한 사이즈 가격 설정의 `salePriceCan`이다. +- 선물 신청 건에는 신청 시점의 최종 결제 금액인 `salePriceCan`만 스냅샷으로 저장한다. + +### 6.5 선물 카테고리 + +카테고리는 관리자 페이지에서 등록, 수정, 논리 삭제할 수 있다. + +| 필드 | 타입 | 제약 | 설명 | +|---|---|---|---| +| `categoryCode` | string | 50자 이하, unique | 내부 분류번호. 사용자에게 표시하지 않음 | +| `name` | string | 50자 이하 | 사용자 표시명 | +| `requiresDamageWaiver` | boolean | DDL은 `tinyint(1)` | 파손면책 동의 필요 여부 | +| `isActive` | boolean | DDL은 `tinyint(1)` | 활성 여부 | + +`requiresDamageWaiver == true`인 카테고리를 선택한 팬은 선물 신청 시 `damageWaiverAgreed=true`를 추가로 보내야 한다. + +### 6.6 약관 동의 + +약관 본문은 이번 API 범위 밖이다. API는 동의 여부만 확인하고 선물 신청 건에 동의 스냅샷을 저장한다. + +팬 신청 동의는 선물 보내기 등록 시점에 저장한다. + +| API 필드 | 표시명 | 저장 시각 | +|---|---|---| +| `senderTermsAgreed` | 상품 전달 이용약관 동의 | `senderTermsAgreedAt` | +| `senderPrivacyAgreed` | 상품 전달을 위한 개인정보 수집 및 이용 동의 | `senderPrivacyAgreedAt` | + +크리에이터 수령 동의는 배송지 입력 시점에 저장한다. + +| API 필드 | 표시명 | 저장 시각 | +|---|---|---| +| `recipientTermsAgreed` | 상품 전달 이용약관 동의 | `recipientTermsAgreedAt` | +| `recipientPrivacyAgreed` | 상품 전달을 위한 개인정보 수집 및 이용 동의 | `recipientPrivacyAgreedAt` | + +각 동의 필드가 `true`가 아니면 해당 API는 실패한다. + +### 6.7 결제와 환불 + +- 결제는 내부 재화인 캔으로 한다. +- 캔 사용내역에 `선물하기` 용도를 추가한다. +- 선물 보내기 등록 API 성공 시 즉시 `salePriceCan`만큼 캔을 차감한다. +- 선물 신청과 캔 사용내역은 내부적으로 연결되어야 한다. +- 사용자 캔 사용내역 화면에서 선물 신청으로 이동할 수 없어도 된다. +- 운송장 등록 전 사용자 취소 또는 3일 자동취소 시 사용 캔을 전액 환불한다. +- 운송장 등록 후에는 팬이 취소할 수 없다. + +### 6.8 목록 노출 + +- 선물함 목록 API는 `type=ALL|SENT|RECEIVED`를 지원한다. +- 기본값은 `ALL`이다. +- 응답 item에는 `direction=SENT|RECEIVED`를 포함한다. +- 보낸 선물은 신청 직후부터 팬에게 노출한다. +- 받은 선물은 팬이 운송장 번호를 등록해 상태가 `TRACKING_REGISTERED` 이상이 된 뒤 크리에이터에게 노출한다. +- 배송지 미입력 상태라면 받은 선물 item/detail에 `recipientAddressRequired=true`, `recipientAddressDeadlineAt`을 내려준다. +- 상세 조회는 팬과 크리에이터가 서로의 개인정보를 알 필요 없이 선물을 보내거나 받을 수 있게 설계한다. +- 상세 조회에서 상대방 정보는 닉네임만 노출한다. 로그인 회원 본인이 신청 또는 입력한 개인정보는 본인에게만 노출한다. +- 로그인 회원이 받는 크리에이터이면 보내는 팬의 닉네임만 알 수 있고, 팬이 신청 시 등록한 이름/휴대폰 번호/주소는 알 수 없다. +- 로그인 회원이 보내는 팬이면 받는 크리에이터의 닉네임만 알 수 있고, 크리에이터가 입력한 이름/휴대폰 번호/주소는 알 수 없다. +- 보내는 팬 상세에는 팬 본인이 신청 시 등록한 이름, 휴대폰 번호, 주소를 내려준다. +- 받는 크리에이터 상세에는 크리에이터 본인이 입력한 받는 주소의 이름, 휴대폰 번호, 주소를 내려준다. +- 주소 형식은 `(우편번호) 주소, 상세주소` 문자열로 내려준다. +- 로그인 회원이 알아야 하는 데이터가 아니거나 아직 입력되지 않은 데이터는 `null`로 내려준다. +- 클라이언트의 입력 필요 상태 판단은 `null` 값이 아니라 행동 필요 flag로 한다. +- 상세 조회는 `trackingRequired`와 `recipientAddressRequired`를 내려준다. +- `trackingRequired=true`는 `direction=SENT`인 팬이 아직 운송장 번호를 등록해야 하는 상태임을 의미한다. +- `recipientAddressRequired=true`는 `direction=RECEIVED`인 크리에이터가 아직 받는 주소를 입력해야 하는 상태임을 의미한다. +- 현재 로그인 회원이 처리할 일이 아니거나 이미 처리할 수 없는 상태이면 해당 flag는 `false`다. +- 상세 조회는 정상 진행 상태 5개(`RECEIVED`, `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED`, `DELIVERED`)의 날짜/시간을 순서대로 내려준다. +- `CANCELED`, `UNDELIVERABLE`은 정상 진행 타임라인 상태가 아니며, 각각 별도 종료 정보로 `canceledAt`, `undeliverableAt`, `undeliverableReason`을 내려준다. + +### 6.9 리뷰 + +- 리뷰는 선물을 보낸 팬만 작성할 수 있다. +- 리뷰는 `DELIVERED` 상태 이후 선물 건당 1회만 작성할 수 있다. +- 별점은 1~5점이다. +- 키워드는 여러 개 선택해 등록할 수 있다. +- 각 키워드는 최대 255자다. +- 추가의견은 최대 255자다. + +### 6.10 푸시와 딥링크 + +- 푸시 본문의 `\n`은 줄바꿈 문자로 처리한다. +- 푸시 터치 시 선물 상세 페이지로 이동하도록 단일 딥링크를 작성한다. +- 딥링크는 `deepLinkValue=GIFT_DETAIL`, `deepLinkId=applicationNo`를 사용한다. +- 보낸 선물/받은 선물 판정은 딥링크가 아니라 선물 상세 조회 API가 로그인 회원 기준으로 수행한다. +- 상세 조회 API는 로그인 회원이 `senderMemberId`이면 `direction=SENT`, `recipientMemberId`이고 받은 선물 노출 조건을 만족하면 `direction=RECEIVED`로 응답한다. +- 둘 다 아니거나 받은 선물 노출 조건을 만족하지 않으면 권한 오류 또는 미존재 오류를 반환한다. + +### 6.11 택배사 입력 정책 + +- 현재 외부 택배 조회, PG 에스크로, 커머스 플랫폼 연동 계획은 없다. +- 국가 단일 택배사 코드 표준이나 외부 provider별 코드를 이번 PRD의 기준으로 삼지 않는다. +- 클라이언트는 직접 입력이 아니라 별도 Dropdown으로 택배사 선택지를 제공한다. +- 운송장 등록 API는 선택된 택배사의 한글 표시명 `courierCompanyName`을 받는다. +- 서버는 `courierCompanyName`을 표시/확인용 스냅샷으로 그대로 저장한다. +- `courierCompanyName`은 필수이며 최대 50자로 제한한다. +- 외부 배송조회/PG/커머스 연동이 후속 범위로 추가되면 그때 `courierCompanyCode`와 provider별 매핑 정책을 별도 PRD에서 정의한다. +- 크리에이터가 수령확인 API로 `DELIVERED` 처리해도 팬에게는 전달 완료 푸시를 보낸다. +- 크리에이터가 직접 수령확인한 경우 크리에이터에게는 배송 완료/전달완료 푸시를 보내지 않는다. + +## 7. 기능 요구사항 + +| ID | 상태 | 요구사항 | 수용 기준 | 계획 연결 | +|---|---|---|---|---| +| `GIFT-001` | 확정 | 팬은 활성 카테고리와 사이즈별 가격을 조회할 수 있다 | 비활성 카테고리는 사용자 조회에 노출되지 않고, 사이즈 응답에는 `basePriceCan`, `salePriceCan`이 포함된다 | `P1` | +| `GIFT-002` | 확정 | 팬은 선물 보내기 등록 시 캔을 즉시 결제하고 고유 신청번호를 받는다 | 성공 시 상태는 `RECEIVED`, 캔 사용내역 `선물하기`가 생성되고 선물과 연결되며 동시 신청에도 중복 없는 `applicationNo`가 발급된다 | `P2` | +| `GIFT-003` | 확정 | 팬 신청 약관 2개와 조건부 파손면책 동의를 검증한다 | 필수 동의가 `true`가 아니면 신청이 실패한다 | `P2` | +| `GIFT-004` | 확정 | 팬은 운송장 등록 전 신청을 취소할 수 있다 | `RECEIVED` 상태에서만 취소되고 전액 환불된다 | `P2` | +| `GIFT-005` | 확정 | 팬은 3일 내 운송장을 등록해야 한다 | 등록 성공 시 상태는 `TRACKING_REGISTERED`, 크리에이터 받은 목록에 노출된다 | `P3` | +| `GIFT-006` | 확정 | 스케줄러는 운송장 미등록 건을 자동취소한다 | 기한 초과 `RECEIVED`가 `CANCELED`로 바뀌고 전액 환불된다 | `P4` | +| `GIFT-007` | 확정 | 크리에이터는 운송장 등록 후 받은 선물을 조회하고 배송지를 입력한다 | 배송지 입력 시 수령자 정보와 수령 약관 2개를 저장한다 | `P3` | +| `GIFT-008` | 확정 | 스케줄러는 배송지 미입력 기한 초과 건을 전달불가 처리한다 | 기한 초과 건이 `UNDELIVERABLE`로 바뀌고 사유가 저장된다 | `P4` | +| `GIFT-009` | 확정 | 운영자는 상태별 관리자 API로 선물 상태를 처리한다 | 잘못된 이전 상태에서 상태변경 API를 호출하면 실패한다 | `P5` | +| `GIFT-010` | 확정 | 팬은 `DELIVERED` 이후 리뷰를 1회 작성할 수 있다 | 중복 리뷰, 비전달완료 리뷰, 수신자 리뷰는 실패한다 | `P6` | +| `GIFT-011` | 확정 | 팬/크리에이터 대상 푸시를 상태별로 분리 발송한다 | 각 상태 전이에서 지정된 수신자에게만 지정 문구와 딥링크가 발송된다 | `P7` | +| `GIFT-012` | 확정 | 관리자는 카테고리를 등록/수정/논리삭제할 수 있다 | 삭제는 `isActive=false`만 수행한다 | `P1` | +| `GIFT-013` | 확정 | 관리자는 사이즈별 가격을 수정할 수 있다 | 새 신청에는 변경 가격이 적용되고 기존 신청 스냅샷은 바뀌지 않는다 | `P1` | + +## 8. API 계약 + +### 8.1 공통 규칙 + +- 인증: 기존 인증 필요 API와 동일한 인증 헤더를 사용한다. +- 관리자 API: 기존 관리자 인증/권한 정책을 따른다. +- 날짜/시간 응답: UTC ISO-8601 문자열을 사용한다. +- 페이징: `page` 기본값 `0`, `size` 기본값 `20`, 허용 범위 `20..50`으로 보정한다. +- 목록 응답은 `items`, `page`, `size`, `hasNext`를 포함한다. +- 오류 envelope와 메시지 키는 기존 공통 오류 정책을 따른다. + +### 8.2 선물 신청 폼 옵션 조회 + +`GET /api/v2/gifts/form-options` + +선물 신청 페이지 진입 시 필요한 팬용 사이즈와 카테고리 옵션을 한 번에 조회한다. + +Response: + +```json +{ + "sizes": [ + { + "sizeCode": "SMALL", + "name": "소형", + "maxTotalLengthCm": 100, + "maxWeightKg": 5, + "basePriceCan": 100, + "salePriceCan": 80 + } + ], + "categories": [ + { + "categoryId": 1, + "name": "인형", + "requiresDamageWaiver": true + } + ] +} +``` + +사용자 API는 `categoryCode`를 응답하지 않고 활성 카테고리만 내려준다. + +### 8.3 선물 보내기 등록 + +`POST /api/v2/gifts` + +Request: + +```json +{ + "recipientMemberId": 100, + "senderName": "홍길동", + "senderPhoneNumber": "01012345678", + "senderZipCode": "06234", + "senderAddress": "서울시 강남구 ...", + "senderAddressDetail": "101동 1001호", + "sizeCode": "SMALL", + "categoryId": 1, + "senderTermsAgreed": true, + "senderPrivacyAgreed": true, + "damageWaiverAgreed": true +} +``` + +Response: + +```json +{ + "applicationNo": "G20260929000001", + "status": "RECEIVED", + "statusName": "접수 완료", + "priceCan": 80, + "trackingDeadlineAt": "2026-10-02T03:00:00Z" +} +``` + +요구사항: + +- 수신자는 크리에이터 회원이어야 한다. +- 발신자 이름, 휴대폰 번호, 우편번호, 주소는 필수다. +- 선택한 카테고리가 비활성이면 실패한다. +- 선택한 카테고리가 `requiresDamageWaiver=true`이면 `damageWaiverAgreed=true`가 필수다. +- `senderTermsAgreed`와 `senderPrivacyAgreed`가 모두 `true`여야 한다. +- 성공 시 `salePriceCan`만큼 캔을 차감한다. + +### 8.4 선물함 목록 조회 + +`GET /api/v2/gifts?type=ALL&page=0&size=20` + +Request Query: + +| 이름 | 필수 | 기본값 | 설명 | +|---|---|---|---| +| `type` | 아니오 | `ALL` | `ALL`, `SENT`, `RECEIVED` | +| `page` | 아니오 | `0` | 0 기반 page index | +| `size` | 아니오 | `20` | 20..50 보정 | + +Response: + +```json +{ + "items": [ + { + "applicationNo": "G20260929000001", + "direction": "SENT", + "status": "TRACKING_REGISTERED", + "statusName": "발송 확인", + "counterpartMemberId": 100, + "counterpartName": "크리에이터닉네임", + "sizeName": "소형", + "categoryName": "인형", + "priceCan": 80, + "recipientAddressRequired": false, + "recipientAddressDeadlineAt": null, + "createdAt": "2026-09-29T03:00:00Z" + } + ], + "page": 0, + "size": 20, + "hasNext": false +} +``` + +받은 선물은 `TRACKING_REGISTERED` 이상 상태부터 노출한다. + +### 8.5 선물 상세 조회 + +`GET /api/v2/gifts/{applicationNo}` + +Response: + +```json +{ + "applicationNo": "G20260929000001", + "direction": "SENT", + "status": "TRACKING_REGISTERED", + "statusName": "발송 확인", + "giftInfo": { + "recipientCreatorNickname": "크리에이터닉네임", + "senderNickname": null, + "sizeName": "소형", + "categoryName": "인형", + "applicationNo": "G20260929000001", + "paidCan": 80, + "tracking": "CJ대한통운_1234567890", + "shippingRequestedAt": null + }, + "senderInfo": { + "name": "홍길동", + "phoneNumber": "01012345678", + "address": "(06234) 서울시 강남구 ..., 101동 1001호" + }, + "recipientAddress": null, + "mailbox": { + "name": "소다라이브 사서함", + "address": "구현 후 제공", + "phoneNumber": "구현 후 제공" + }, + "trackingRequired": false, + "recipientAddressRequired": false, + "recipientAddressDeadlineAt": "2026-10-09T03:00:00Z", + "delivery": { + "canceledAt": null, + "undeliverableAt": null, + "undeliverableReason": null + }, + "statusTimeline": [ + { + "status": "RECEIVED", + "statusName": "접수 완료", + "occurredAt": "2026-09-29T03:00:00Z" + }, + { + "status": "TRACKING_REGISTERED", + "statusName": "발송 확인", + "occurredAt": "2026-10-02T03:00:00Z" + }, + { + "status": "ARRIVED_AT_MAILBOX", + "statusName": "사서함 도착", + "occurredAt": null + }, + { + "status": "INSPECTION_COMPLETED", + "statusName": "검수완료", + "occurredAt": null + }, + { + "status": "DELIVERED", + "statusName": "전달완료", + "occurredAt": null + } + ], + "review": null +} +``` + +받는 크리에이터 관점 응답 예시는 다음과 같다. + +```json +{ + "applicationNo": "G20260929000001", + "direction": "RECEIVED", + "status": "INSPECTION_COMPLETED", + "statusName": "검수완료", + "giftInfo": { + "recipientCreatorNickname": null, + "senderNickname": "팬닉네임", + "sizeName": "소형", + "categoryName": "인형", + "applicationNo": null, + "paidCan": null, + "tracking": null, + "shippingRequestedAt": "2026-10-03T03:00:00Z" + }, + "senderInfo": null, + "recipientAddress": { + "name": "김소다", + "phoneNumber": "01098765432", + "address": "(04524) 서울시 중구 ..., 202호" + }, + "mailbox": null, + "trackingRequired": false, + "recipientAddressRequired": false, + "recipientAddressDeadlineAt": "2026-10-09T03:00:00Z", + "delivery": { + "canceledAt": null, + "undeliverableAt": null, + "undeliverableReason": null + }, + "statusTimeline": [ + { + "status": "RECEIVED", + "statusName": "접수 완료", + "occurredAt": "2026-09-29T03:00:00Z" + }, + { + "status": "TRACKING_REGISTERED", + "statusName": "발송 확인", + "occurredAt": "2026-10-02T03:00:00Z" + }, + { + "status": "ARRIVED_AT_MAILBOX", + "statusName": "사서함 도착", + "occurredAt": "2026-10-03T03:00:00Z" + }, + { + "status": "INSPECTION_COMPLETED", + "statusName": "검수완료", + "occurredAt": "2026-10-04T03:00:00Z" + }, + { + "status": "DELIVERED", + "statusName": "전달완료", + "occurredAt": null + } + ], + "review": null +} +``` + +팬과 크리에이터 모두 같은 상세 API를 사용하되 권한에 따라 본인 관련 선물만 조회할 수 있다. +`direction=SENT`이면 보내는 팬 관점의 상세 응답이다. +`giftInfo.recipientCreatorNickname`에는 받는 크리에이터 닉네임을 내려주고, `giftInfo.senderNickname`, `giftInfo.shippingRequestedAt`, `recipientAddress`는 `null`이다. +`senderInfo`에는 팬 본인이 신청 시 등록한 이름, 휴대폰 번호, 주소를 내려준다. +`mailbox`는 코드상에서 정한 사서함 이름, 주소, 연락처를 내려준다. 구현 후 값이 제공되면 이 문서를 갱신한다. +`direction=SENT`이고 현재 상태가 `RECEIVED`이면 `trackingRequired=true`이며, 팬 클라이언트는 운송장 등록 CTA를 표시한다. +`direction=RECEIVED`이면 받는 크리에이터 관점의 상세 응답이다. +`giftInfo.senderNickname`에는 발송인인 팬 닉네임을 내려주고, `giftInfo.recipientCreatorNickname`, `giftInfo.applicationNo`, `giftInfo.paidCan`, `giftInfo.tracking`, `senderInfo`, `mailbox`는 `null`이다. +`giftInfo.shippingRequestedAt`에는 배송신청일을 내려준다. +`recipientAddress`에는 크리에이터 본인이 입력한 이름, 휴대폰 번호, 주소를 내려준다. +`direction=RECEIVED`이고 받은 주소가 미입력이며 종료 상태가 아니면 `recipientAddressRequired=true`이며, 크리에이터 클라이언트는 받는 주소 입력 CTA를 표시한다. +주소는 발신자/수신자 모두 `(우편번호) 주소, 상세주소` 형식의 단일 문자열로 내려준다. +운송장 미입력, 받는 주소 미입력 상태의 상세 보완은 API 개발 후 후속으로 처리한다. +로그인 회원이 알아야 하는 데이터가 아니면 `null`로 내려준다. +정상 진행 5개 상태의 `statusTimeline`은 항상 같은 순서로 내려주며, 아직 도달하지 않은 상태의 `occurredAt`은 `null`이다. +`CANCELED`와 `UNDELIVERABLE` 상태에서도 `statusTimeline`은 도달한 정상 진행 상태의 시각을 유지하고, 종료 시각/사유는 `delivery.canceledAt`, `delivery.undeliverableAt`, `delivery.undeliverableReason`으로 확인한다. + +### 8.6 운송장 번호 등록 + +`POST /api/v2/gifts/{applicationNo}/tracking` + +Request: + +```json +{ + "courierCompanyName": "CJ대한통운", + "trackingNumber": "1234567890" +} +``` + +Response: + +```json +{ + "applicationNo": "G20260929000001", + "status": "TRACKING_REGISTERED", + "recipientAddressDeadlineAt": "2026-10-09T03:00:00Z" +} +``` + +요구사항: + +- 보낸 팬만 호출할 수 있다. +- `RECEIVED` 상태에서만 성공한다. +- 성공 시 크리에이터 받은 선물 목록에 노출된다. +- 성공 시 크리에이터에게 `팬 운송장 등록 완료` 푸시를 보낸다. + +### 8.7 선물 보내기 취소 + +`POST /api/v2/gifts/{applicationNo}/cancel` + +Request: 없음 + +Response: + +```json +{ + "applicationNo": "G20260929000001", + "status": "CANCELED", + "refundedCan": 80 +} +``` + +요구사항: + +- 보낸 팬만 호출할 수 있다. +- `RECEIVED` 상태에서만 성공한다. +- 성공 시 사용 캔을 전액 환불한다. + +### 8.8 크리에이터 배송지 입력 + +`POST /api/v2/gifts/{applicationNo}/recipient-address` + +Request: + +```json +{ + "recipientName": "김소다", + "recipientPhoneNumber": "01098765432", + "recipientZipCode": "04524", + "recipientAddress": "서울시 중구 ...", + "recipientAddressDetail": "202호", + "recipientTermsAgreed": true, + "recipientPrivacyAgreed": true +} +``` + +Response: + +```json +{ + "applicationNo": "G20260929000001", + "recipientAddressRegisteredAt": "2026-10-03T03:00:00Z" +} +``` + +요구사항: + +- 받는 크리에이터만 호출할 수 있다. +- `TRACKING_REGISTERED` 이상, 종료 상태가 아닌 선물에만 입력할 수 있다. +- 이름, 휴대폰 번호, 우편번호, 주소는 필수다. +- `recipientTermsAgreed`, `recipientPrivacyAgreed`가 모두 `true`여야 한다. + +### 8.9 크리에이터 수령확인 + +`POST /api/v2/gifts/{applicationNo}/delivery-complete` + +Request: 없음 + +Response: + +```json +{ + "applicationNo": "G20260929000001", + "status": "DELIVERED", + "deliveredAt": "2026-10-10T03:00:00Z" +} +``` + +요구사항: + +- 받는 크리에이터만 호출할 수 있다. +- `INSPECTION_COMPLETED` 상태에서만 성공한다. +- 성공 시 팬에게 전달 완료 푸시를 보낸다. +- 성공 시 크리에이터에게는 배송 완료/전달완료 푸시를 보내지 않는다. + +### 8.10 리뷰 작성 + +`POST /api/v2/gifts/{applicationNo}/review` + +Request: + +```json +{ + "rating": 5, + "keywords": ["배송이 빨라요", "안내가 친절해요"], + "comment": "선물 전달 과정이 만족스러웠습니다." +} +``` + +Response: + +```json +{ + "reviewId": 1, + "applicationNo": "G20260929000001", + "rating": 5, + "keywords": ["배송이 빨라요", "안내가 친절해요"], + "comment": "선물 전달 과정이 만족스러웠습니다.", + "createdAt": "2026-10-11T03:00:00Z" +} +``` + +요구사항: + +- 보낸 팬만 호출할 수 있다. +- `DELIVERED` 상태에서만 작성할 수 있다. +- 선물 건당 1회만 작성할 수 있다. +- `rating`은 1 이상 5 이하여야 한다. +- `keywords`는 배열이며, 각 keyword는 255자 이하여야 한다. +- `comment`는 255자 이하여야 한다. + +### 8.11 관리자 카테고리 API + +| Method | Path | 설명 | +|---|---|---| +| GET | `/api/v2/admin/gift-categories` | 카테고리 목록 조회 | +| POST | `/api/v2/admin/gift-categories` | 카테고리 등록 | +| PUT | `/api/v2/admin/gift-categories/{categoryId}` | 카테고리 수정 | +| DELETE | `/api/v2/admin/gift-categories/{categoryId}` | 카테고리 논리 삭제 | + +등록/수정 Request: + +```json +{ + "categoryCode": "DOLL", + "name": "인형", + "requiresDamageWaiver": true, + "isActive": true +} +``` + +삭제는 `isActive=false`로만 처리한다. + +### 8.12 관리자 사이즈 가격 API + +| Method | Path | 설명 | +|---|---|---| +| GET | `/api/v2/admin/gift-size-prices` | 사이즈별 가격 목록 조회 | +| PUT | `/api/v2/admin/gift-size-prices/{sizeCode}` | 사이즈별 가격 수정 | + +수정 Request: + +```json +{ + "basePriceCan": 100, + "salePriceCan": 80, + "isActive": true +} +``` + +`salePriceCan`은 0보다 커야 하고 `basePriceCan`보다 클 수 없다. + +### 8.13 운영 상태변경 API + +| Method | Path | 이전 상태 | 변경 상태 | 필수 입력 | +|---|---|---|---|---| +| POST | `/api/v2/admin/gifts/{applicationNo}/arrive-mailbox` | `TRACKING_REGISTERED` | `ARRIVED_AT_MAILBOX` | 없음 | +| POST | `/api/v2/admin/gifts/{applicationNo}/complete-inspection` | `ARRIVED_AT_MAILBOX` | `INSPECTION_COMPLETED` | 없음 | +| POST | `/api/v2/admin/gifts/{applicationNo}/mark-undeliverable` | `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED` | `UNDELIVERABLE` | `reason` | +| POST | `/api/v2/admin/gifts/{applicationNo}/complete-delivery` | `INSPECTION_COMPLETED` | `DELIVERED` | 없음 | + +전달불가 Request: + +```json +{ + "reason": "선물 접수 규격에 맞지 않습니다." +} +``` + +### 8.14 자동 스케줄링 작업 + +| 작업 | 대상 | 처리 | 푸시 | +|---|---|---|---| +| 운송장 등록 기한 24시간 전 안내 | `RECEIVED`, 마감 24시간 전 | 상태 변경 없음 | 팬에게 안내 | +| 운송장 미등록 자동취소 | `RECEIVED`, 신청 후 3일 초과 | `CANCELED`, 전액 환불 | 팬에게 자동취소 안내 | +| 배송지 입력 기한 24시간 전 안내 | `TRACKING_REGISTERED` 이상, 배송지 미입력, 마감 24시간 전 | 상태 변경 없음 | 크리에이터에게 안내 | +| 배송지 미입력 전달불가 | 배송지 미입력, 운송장 등록 후 7일 초과 | `UNDELIVERABLE`, 사유 저장 | 팬에게 전달불가 안내. 크리에이터에게 추가 푸시 없음 | + +스케줄러는 중복 실행되어도 같은 선물에 중복 환불 또는 중복 푸시가 발생하지 않아야 한다. + +## 9. 푸시 알림 정책 + +### 9.1 팬 대상 푸시 + +| 트리거 | 제목 | 본문 | 딥링크 | +|---|---|---|---| +| 선물 신청 후 자동 취소까지 24시간 남은 경우 | 운송장 번호를 입력해 주세요 | 24시간 이내에 운송장 번호를 등록해 주세요.\n기한 내 등록하지 않으면 신청이 자동 취소됩니다. | `GIFT_DETAIL`, `applicationNo` | +| 선물 자동 취소 | 선물 신청이 자동 취소됐어요 | 신청 후 3일 이내에 운송장 번호가 등록되지 않아 신청이 취소됐어요.\n사용한 캔은 모두 환불되었습니다. | `GIFT_DETAIL`, `applicationNo` | +| 사서함 도착 | 선물이 사서함에 도착했어요 | 보내주신 선물이 소다라이브 사서함에 도착했어요.\n안전하게 전달할 수 있도록 선물을 확인하고 있어요. | `GIFT_DETAIL`, `applicationNo` | +| 전달 완료 | 선물이 전달됐어요 | 보내주신 선물이 크리에이터에게 안전하게 전달됐어요. | `GIFT_DETAIL`, `applicationNo` | +| 전달 불가 | 선물을 전달할 수 없어요 | 도착한 선물이 선물 접수 규격에 맞지 않아 크리에이터에게 전달할 수 없어요.\n자세한 사유는 신청 상세에서 확인해 주세요. | `GIFT_DETAIL`, `applicationNo` | + +### 9.2 크리에이터 대상 푸시 + +| 트리거 | 제목 | 본문 | 딥링크 | +|---|---|---|---| +| 팬 운송장 등록 완료 | 팬이 보낸 선물이 있어요! | 7일 이내에 배송지를 입력해 주세요.\n기간이 지나면 선물을 받을 수 없어요 | `GIFT_DETAIL`, `applicationNo` | +| 배송지 입력 기한 24시간 전 | 배송지 입력 기한이 1일 남았어요 | 24시간 안에 배송지를 입력해 주세요.\n입력하지 않으면 선물은 반송되지 않고 폐기돼요 | `GIFT_DETAIL`, `applicationNo` | +| 사서함 도착 · 검수 중 | 선물이 사서함에 도착했어요 | 팬이 보낸 선물을 검수하고 있어요.\n검수가 끝나면 다시 알려드릴게요 | `GIFT_DETAIL`, `applicationNo` | +| 검수 완료 (통과) | 선물 검수가 완료됐어요 | 확인된 선물을 빠른 시일 내에 입력하신 배송지로 보내드릴게요. | `GIFT_DETAIL`, `applicationNo` | +| 검수 완료 (전달 불가) | 전달할 수 없는 선물이 있어요 | 검수 결과 선물 정책에 맞지 않아 해당 선물은 배송되지 않아요.\n자세한 사유를 확인해 주세요. | `GIFT_DETAIL`, `applicationNo` | +| 배송 완료 | 선물이 도착했어요! | 팬이 보낸 선물이 배송지에 도착했어요.\n지금 확인해 보세요. | `GIFT_DETAIL`, `applicationNo` | + +크리에이터가 직접 수령확인 API를 호출해 `DELIVERED`가 된 경우에는 크리에이터 대상 `배송 완료` 푸시를 보내지 않는다. + +## 10. 데이터 모델 요구사항 + +### 10.1 Gift + +| 필드 | 설명 | +|---|---| +| `id` | 내부 기본키 | +| `applicationNo` | 신청번호. 기본키와 별도이며 사용자/운영 식별자로 사용 | +| `senderMemberId` | 보내는 팬 회원번호 | +| `recipientMemberId` | 받는 크리에이터 회원번호 | +| `status` | 선물 상태 | +| `sizeCode` | 신청 시 선택한 사이즈 코드 | +| `categoryId` | 신청 시 선택한 카테고리 ID | +| `categoryNameSnapshot` | 신청 시 카테고리명 스냅샷 | +| `salePriceCan` | 신청 시 실제 결제 금액 스냅샷 | +| `canUsageId` | 캔 사용내역 연결 ID | +| `senderTermsAgreed`, `senderPrivacyAgreed` | 팬 동의 여부 | +| `senderTermsAgreedAt`, `senderPrivacyAgreedAt` | 팬 동의 시각 | +| `recipientTermsAgreed`, `recipientPrivacyAgreed` | 크리에이터 동의 여부 | +| `recipientTermsAgreedAt`, `recipientPrivacyAgreedAt` | 크리에이터 동의 시각 | +| `damageWaiverAgreed` | 팬 파손면책 동의 여부 | +| `createdAt`, `updatedAt` | 생성/수정 시각 | + +### 10.2 GiftDelivery + +| 필드 | 설명 | +|---|---| +| `giftId` | 선물 ID | +| `senderName`, `senderPhoneNumber` | 발신자 이름/휴대폰 번호 | +| `senderZipCode`, `senderAddress`, `senderAddressDetail` | 발신자 주소 | +| `recipientName`, `recipientPhoneNumber` | 수신자 이름/휴대폰 번호 | +| `recipientZipCode`, `recipientAddress`, `recipientAddressDetail` | 수신자 주소 | +| `courierCompanyName`, `trackingNumber` | 택배사 한글 표시명/운송장 번호 | +| `trackingDeadlineAt` | 운송장 등록 기한 | +| `recipientAddressDeadlineAt` | 크리에이터 배송지 입력 기한 | +| `trackingRegisteredAt` | 발송 확인 시각 | +| `arrivedAtMailboxAt` | 사서함 도착 시각 | +| `inspectionCompletedAt` | 검수완료 시각 | +| `deliveredAt` | 전달완료 시각 | +| `canceledAt` | 신청 취소 시각 | +| `undeliverableAt` | 전달불가 시각 | +| `undeliverableReason` | 전달불가 사유 | + +### 10.3 GiftCategory + +| 필드 | 설명 | +|---|---| +| `id` | 내부 기본키 | +| `categoryCode` | 50자 이하 내부 분류번호 | +| `name` | 50자 이하 카테고리명 | +| `requiresDamageWaiver` | 파손면책 동의 필요 여부. DDL은 `tinyint(1)` | +| `isActive` | 활성 여부. DDL은 `tinyint(1)` | +| `createdAt`, `updatedAt` | 생성/수정 시각 | + +### 10.4 GiftSizePrice + +| 필드 | 설명 | +|---|---| +| `id` | 내부 기본키 | +| `sizeCode` | `SMALL`, `MEDIUM`, `LARGE` | +| `basePriceCan` | 기본 금액 | +| `salePriceCan` | 실제 표시/결제 금액 | +| `isActive` | 활성 여부. DDL은 `tinyint(1)` | +| `createdAt`, `updatedAt` | 생성/수정 시각 | + +### 10.5 GiftReview + +| 필드 | 설명 | +|---|---| +| `id` | 내부 기본키 | +| `giftId` | 선물 ID. 선물당 1개 unique | +| `senderMemberId` | 리뷰 작성 팬 회원번호 | +| `rating` | 1~5점 | +| `keywords` | 선택한 리뷰 키워드 목록. 각 키워드는 최대 255자 | +| `comment` | 최대 255자 | +| `createdAt`, `updatedAt` | 생성/수정 시각 | + +## 11. 보안과 데이터 취급 + +- 발신자/수신자 이름, 휴대폰 번호, 주소, 우편번호는 개인정보로 취급한다. +- 운영 로그, 오류 로그, 푸시 본문에는 주소 전체와 전화번호 전체를 남기지 않는다. +- 상세 조회는 본인 관련 선물 또는 관리자/운영 권한에서만 허용한다. +- 상세 조회에서 상대방의 이름, 휴대폰 번호, 주소, 우편번호, 상세주소는 노출하지 않고 상대방 닉네임만 노출한다. +- 팬 상세에서는 팬 본인이 신청 시 등록한 발신자 정보와 사서함 정보만 노출하고, 크리에이터 배송지는 노출하지 않는다. +- 크리에이터 상세에서는 크리에이터 본인이 입력한 받는 주소만 노출하고, 팬이 신청 시 등록한 발신자 정보는 노출하지 않는다. +- 내부 운영 화면에서는 필요한 권한을 가진 사용자에게만 개인정보를 노출한다. +- 약관 동의 여부와 동의시각은 선물 신청 건 감사 근거로 보존한다. + +## 12. 테스트와 품질 요구사항 + +- 선물 신청은 캔 부족, 비활성 카테고리, 필수 약관 미동의, 파손면책 미동의, 최종 결제 금액 스냅샷을 테스트한다. +- 취소/자동취소는 `RECEIVED`에서만 가능하고 환불이 1회만 발생하는지 테스트한다. +- 운송장 등록은 받은 선물 목록 노출과 크리에이터 배송지 입력 기한 생성을 테스트한다. +- 배송지 입력은 크리에이터 권한, 필수 배송지, 수령 약관 2개를 테스트한다. +- 상태별 운영 API는 허용 이전 상태와 금지 이전 상태를 모두 테스트한다. +- 리뷰는 작성 권한, 상태, 중복 작성, 글자수 제한을 테스트한다. +- 푸시는 팬/크리에이터 수신자 분리와 크리에이터 수령확인 시 크리에이터 푸시 억제를 테스트한다. +- 스케줄러는 중복 실행 시 중복 환불/중복 푸시가 없는지 테스트한다. + +## 13. 성공 기준 + +- [ ] 팬이 선물 신청 시 약관/파손면책/캔 결제를 포함해 `RECEIVED` 선물을 만들 수 있다. +- [ ] 팬이 운송장 등록 전 직접 취소하거나 스케줄러가 자동취소하면 사용 캔이 전액 환불된다. +- [ ] 운송장 등록 후 크리에이터 받은 선물 목록에 노출되고 배송지 입력 CTA에 필요한 기한 정보가 응답된다. +- [ ] 크리에이터는 배송지와 수령 약관 2개를 등록할 수 있다. +- [ ] 운영자는 상태별 전용 API로 상태를 전이하고, 잘못된 상태 전이는 실패한다. +- [ ] 팬은 `DELIVERED` 이후 리뷰를 1회 작성할 수 있다. +- [ ] 관리자 카테고리 논리삭제와 사이즈별 가격 변경이 새 신청에만 반영된다. +- [ ] 팬/크리에이터 푸시가 지정된 문구, 수신자, 딥링크로 발송된다. + +## 14. Open Questions + +없음. 구현 중 관리자 권한명의 정확한 값이 필요하면 기존 코드 패턴 확인 후 이 PRD를 먼저 갱신한다. + +## 15. 요구사항 추적표 + +| 요구사항 | 계획 Phase | 자동 검증 | 수동 검증 | +|---|---:|---|---| +| `GIFT-001`, `GIFT-012`, `GIFT-013` | 1 | 관리자 설정 API 테스트 | 관리자 설정 조회/수정 확인 | +| `GIFT-002~005` | 2 | 신청/취소/운송장 API 테스트 | 팬 선물 신청 흐름 확인 | +| `GIFT-007~008` | 3 | 받은 목록/배송지/스케줄러 테스트 | 크리에이터 받은 선물 흐름 확인 | +| `GIFT-009` | 4 | 운영 상태변경 API 테스트 | 운영 상태 전이 확인 | +| `GIFT-010` | 5 | 리뷰 API 테스트 | 전달완료 후 리뷰 작성 확인 | +| `GIFT-011` | 6 | 푸시 이벤트 테스트 | 딥링크와 수신자 확인 | + +## 16. Decision Log + +| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항 | +|---|---|---|---|---|---| +| 2026-09-29 | `DEC-001` | 확정 | 1차 PRD는 사용자 API, 관리자 설정/API, 운영 상태변경, 자동 스케줄링, 푸시까지 포함한다 | 사용자 선택 A | 전체 | +| 2026-09-29 | `DEC-002` | 확정 | 크리에이터 배송지는 팬 운송장 등록 후 크리에이터가 7일 이내 입력한다 | 사용자 선택 A | `GIFT-005`, `GIFT-007`, `GIFT-008` | +| 2026-09-29 | `DEC-003` | 확정 | 팬 약관 동의는 별도 회원 약관 이력이 아니라 선물 신청 건에 스냅샷 저장한다 | 사용자 선택 A | `GIFT-002`, `GIFT-003` | +| 2026-09-29 | `DEC-004` | 확정 | 선물 신청 시 캔을 즉시 차감하고 취소/자동취소 시 전액 환불한다 | 사용자 선택 A | `GIFT-002`, `GIFT-004`, `GIFT-006` | +| 2026-09-29 | `DEC-005` | 확정 | 리뷰는 보낸 팬이 `DELIVERED` 이후 선물 건당 1회 작성한다 | 사용자 선택 B | `GIFT-010` | +| 2026-09-29 | `DEC-006` | 확정 | 크리에이터 수령확인 API는 `DELIVERED`로 변경하고 팬에게 전달 완료 푸시를 보낸다 | 사용자 정정 | `GIFT-009`, `GIFT-011` | +| 2026-09-29 | `DEC-007` | 확정 | 배송지 입력 기한 초과는 `UNDELIVERABLE`, 사유 `배송지 미입력 기한 초과`로 처리한다 | 사용자 선택 A | `GIFT-008` | +| 2026-09-29 | `DEC-008` | 확정 | 선물함 목록은 `type=ALL|SENT|RECEIVED` 단일 API로 제공한다 | 사용자 선택 B | `GIFT-007` | +| 2026-09-29 | `DEC-009` | 확정 | 팬 발신자 이름/휴대폰 번호/우편번호/주소를 모두 필수로 받는다 | 사용자 선택 A | `GIFT-002` | +| 2026-09-29 | `DEC-010` | 확정 | 가격은 `basePriceCan`과 실제 결제 금액인 `salePriceCan`으로 표현한다 | 사용자 정정 | `GIFT-001`, `GIFT-002`, `GIFT-013` | +| 2026-09-29 | `DEC-011` | 확정 | 크리에이터 배송지 입력 시에도 약관 2개 동의와 동의시각을 별도 저장한다 | 사용자 정정 | `GIFT-007` | +| 2026-09-29 | `DEC-012` | 확정 | 파손면책 동의 필요 카테고리는 팬 신청 시 `damageWaiverAgreed=true`를 요구한다 | 사용자 선택 A | `GIFT-003` |