From c02f5f7fa8fd4da07f37b068539fcf43995863ee Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 30 Sep 2026 13:34:51 +0900 Subject: [PATCH 01/53] =?UTF-8?q?docs(gift):=20=ED=81=AC=EB=A6=AC=EC=97=90?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=84=A0=EB=AC=BC=ED=95=98=EA=B8=B0=20?= =?UTF-8?q?=EC=9A=94=EA=B5=AC=EC=82=AC=ED=95=AD=EC=9D=84=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gift-schema.sql | 111 ++ .../20260929_크리에이터_선물하기/plan-task.md | 1178 +++++++++++++++++ docs/20260929_크리에이터_선물하기/prd.md | 899 +++++++++++++ 3 files changed, 2188 insertions(+) create mode 100644 docs/20260929_크리에이터_선물하기/gift-schema.sql create mode 100644 docs/20260929_크리에이터_선물하기/plan-task.md create mode 100644 docs/20260929_크리에이터_선물하기/prd.md 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` | From f3e6d40a4af7925455ea230a856632f131619a24 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 30 Sep 2026 13:34:52 +0900 Subject: [PATCH 02/53] =?UTF-8?q?feat(gift):=20=EC=84=A0=EB=AC=BC=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=83=81=ED=83=9C=EA=B0=92?= =?UTF-8?q?=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/v2/gift/domain/GiftListType.kt | 12 ++++++++++++ .../sodalive/v2/gift/domain/GiftSize.kt | 12 ++++++++++++ .../sodalive/v2/gift/domain/GiftStatus.kt | 11 +++++++++++ .../sodalive/v2/gift/domain/GiftStatusTest.kt | 19 +++++++++++++++++++ 4 files changed, 54 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/domain/GiftListType.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/domain/GiftSize.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/domain/GiftStatus.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/domain/GiftStatusTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/domain/GiftListType.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/domain/GiftListType.kt new file mode 100644 index 00000000..a6a1ac13 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/domain/GiftListType.kt @@ -0,0 +1,12 @@ +package kr.co.vividnext.sodalive.v2.gift.domain + +enum class GiftListType { + ALL, + SENT, + RECEIVED +} + +enum class GiftDirection { + SENT, + RECEIVED +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/domain/GiftSize.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/domain/GiftSize.kt new file mode 100644 index 00000000..0b385c22 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/domain/GiftSize.kt @@ -0,0 +1,12 @@ +package kr.co.vividnext.sodalive.v2.gift.domain + +enum class GiftSize( + val displayName: String, + val maxTotalLengthCm: Int, + val maxWeightKg: Int, + val defaultPriceCan: Int +) { + SMALL("소형", 100, 5, 100), + MEDIUM("중형", 120, 15, 150), + LARGE("대형", 160, 20, 200) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/domain/GiftStatus.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/domain/GiftStatus.kt new file mode 100644 index 00000000..5ea6f6a5 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/domain/GiftStatus.kt @@ -0,0 +1,11 @@ +package kr.co.vividnext.sodalive.v2.gift.domain + +enum class GiftStatus(val displayName: String) { + RECEIVED("접수 완료"), + TRACKING_REGISTERED("발송 확인"), + ARRIVED_AT_MAILBOX("사서함 도착"), + INSPECTION_COMPLETED("검수 완료"), + DELIVERED("전달 완료"), + UNDELIVERABLE("전달 불가"), + CANCELED("신청 취소") +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/domain/GiftStatusTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/domain/GiftStatusTest.kt new file mode 100644 index 00000000..88896f55 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/domain/GiftStatusTest.kt @@ -0,0 +1,19 @@ +package kr.co.vividnext.sodalive.v2.gift.domain + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +class GiftStatusTest { + @Test + @DisplayName("선물 상태 표시명은 한글 띄어쓰기를 통일한다") + fun shouldUseConsistentKoreanSpacingForDisplayNames() { + assertEquals("접수 완료", GiftStatus.RECEIVED.displayName) + assertEquals("발송 확인", GiftStatus.TRACKING_REGISTERED.displayName) + assertEquals("사서함 도착", GiftStatus.ARRIVED_AT_MAILBOX.displayName) + assertEquals("검수 완료", GiftStatus.INSPECTION_COMPLETED.displayName) + assertEquals("전달 완료", GiftStatus.DELIVERED.displayName) + assertEquals("전달 불가", GiftStatus.UNDELIVERABLE.displayName) + assertEquals("신청 취소", GiftStatus.CANCELED.displayName) + } +} From 3049184ea2ff34fab8e0553edb2611889054b775 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 30 Sep 2026 13:34:53 +0900 Subject: [PATCH 03/53] =?UTF-8?q?feat(gift):=20=EC=84=A0=EB=AC=BC=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20=EC=98=81=EC=86=8D=20=EB=AA=A8=EB=8D=B8?= =?UTF-8?q?=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../v2/gift/adapter/out/persistence/Gift.kt | 75 +++++++++++++ .../adapter/out/persistence/GiftCategory.kt | 26 +++++ .../out/persistence/GiftCategoryRepository.kt | 7 ++ .../adapter/out/persistence/GiftRepository.kt | 106 ++++++++++++++++++ 4 files changed, 214 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/Gift.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftCategory.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftCategoryRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftRepository.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/Gift.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/Gift.kt new file mode 100644 index 00000000..555dffb4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/Gift.kt @@ -0,0 +1,75 @@ +package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize +import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus +import java.time.LocalDateTime +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.EnumType +import javax.persistence.Enumerated +import javax.persistence.Table +import javax.persistence.UniqueConstraint + +@Entity +@Table( + name = "gift", + uniqueConstraints = [UniqueConstraint(name = "uk_gift_application_no", columnNames = ["application_no"])] +) +class Gift( + @Column(name = "application_no", nullable = false, length = 20) + val applicationNo: String, + + @Column(name = "sender_member_id", nullable = false) + val senderMemberId: Long, + + @Column(name = "recipient_member_id", nullable = false) + val recipientMemberId: Long, + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 30) + var status: GiftStatus, + + @Enumerated(EnumType.STRING) + @Column(name = "size_code", nullable = false, length = 20) + val sizeCode: GiftSize, + + @Column(name = "category_id", nullable = false) + val categoryId: Long, + + @Column(name = "category_name_snapshot", nullable = false, length = 50) + val categoryNameSnapshot: String, + + @Column(name = "sale_price_can", nullable = false) + val salePriceCan: Int, + + @Column(name = "can_usage_id", nullable = false) + val canUsageId: Long, + + @Column(name = "sender_terms_agreed", nullable = false) + val senderTermsAgreed: Boolean, + + @Column(name = "sender_privacy_agreed", nullable = false) + val senderPrivacyAgreed: Boolean, + + @Column(name = "sender_terms_agreed_at", nullable = false) + val senderTermsAgreedAt: LocalDateTime, + + @Column(name = "sender_privacy_agreed_at", nullable = false) + val senderPrivacyAgreedAt: LocalDateTime, + + @Column(name = "recipient_terms_agreed", nullable = false) + var recipientTermsAgreed: Boolean = false, + + @Column(name = "recipient_privacy_agreed", nullable = false) + var recipientPrivacyAgreed: Boolean = false, + + @Column(name = "recipient_terms_agreed_at") + var recipientTermsAgreedAt: LocalDateTime? = null, + + @Column(name = "recipient_privacy_agreed_at") + var recipientPrivacyAgreedAt: LocalDateTime? = null, + + @Column(name = "damage_waiver_agreed", nullable = false) + val damageWaiverAgreed: Boolean = false +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftCategory.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftCategory.kt new file mode 100644 index 00000000..8974588f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftCategory.kt @@ -0,0 +1,26 @@ +package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.Table +import javax.persistence.UniqueConstraint + +@Entity +@Table( + name = "gift_category", + uniqueConstraints = [UniqueConstraint(name = "uk_gift_category_code", columnNames = ["category_code"])] +) +class GiftCategory( + @Column(name = "category_code", nullable = false, length = 50) + val categoryCode: String, + + @Column(name = "name", nullable = false, length = 50) + var name: String, + + @Column(name = "requires_damage_waiver", nullable = false) + var requiresDamageWaiver: Boolean = false, + + @Column(name = "is_active", nullable = false) + var isActive: Boolean = true +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftCategoryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftCategoryRepository.kt new file mode 100644 index 00000000..7eeae705 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftCategoryRepository.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence + +import org.springframework.data.jpa.repository.JpaRepository + +interface GiftCategoryRepository : JpaRepository { + fun findByCategoryCode(categoryCode: String): GiftCategory? +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftRepository.kt new file mode 100644 index 00000000..1c055939 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftRepository.kt @@ -0,0 +1,106 @@ +package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence + +import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import java.time.LocalDateTime + +interface GiftRepository : JpaRepository { + fun findByApplicationNo(applicationNo: String): Gift? + + fun findBySenderMemberId(senderMemberId: Long, pageable: Pageable): Page + + fun findByRecipientMemberIdAndStatusIn( + recipientMemberId: Long, + statuses: Collection, + pageable: Pageable + ): Page + + fun findBySenderMemberIdOrRecipientMemberIdAndStatusIn( + senderMemberId: Long, + recipientMemberId: Long, + statuses: Collection, + pageable: Pageable + ): Page + + @Query( + """ + select g + from Gift g + where g.status = kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.RECEIVED + and exists ( + select d.id + from GiftDelivery d + where d.giftId = g.id + and d.trackingDeadlineAt < :now + ) + """ + ) + fun findReceivedGiftsWithTrackingDeadlineBefore(@Param("now") now: LocalDateTime): List + + @Query( + """ + select g + from Gift g + where g.status = kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.TRACKING_REGISTERED + and exists ( + select d.id + from GiftDelivery d + where d.giftId = g.id + and d.recipientAddressDeadlineAt < :now + and d.recipientAddress is null + ) + """ + ) + fun findTrackingRegisteredGiftsWithRecipientAddressDeadlineBefore( + @Param("now") now: LocalDateTime + ): List + + @Query( + """ + select g + from Gift g + where g.status = kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.RECEIVED + and exists ( + select d.id + from GiftDelivery d + where d.giftId = g.id + and d.trackingDeadlineAt > :now + and d.trackingDeadlineAt <= :reminderUntil + and d.trackingDeadlineReminderSentAt is null + ) + """ + ) + fun findReceivedGiftsWithTrackingDeadlineReminderDue( + @Param("now") now: LocalDateTime, + @Param("reminderUntil") reminderUntil: LocalDateTime + ): List + + @Query( + """ + select g + from Gift g + where g.status in ( + kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.TRACKING_REGISTERED, + kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.ARRIVED_AT_MAILBOX, + kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.INSPECTION_COMPLETED + ) + and exists ( + select d.id + from GiftDelivery d + where d.giftId = g.id + and d.recipientAddressDeadlineAt > :now + and d.recipientAddressDeadlineAt <= :reminderUntil + and d.recipientAddress is null + and d.recipientAddressDeadlineReminderSentAt is null + ) + """ + ) + fun findGiftsWithRecipientAddressDeadlineReminderDue( + @Param("now") now: LocalDateTime, + @Param("reminderUntil") reminderUntil: LocalDateTime + ): List +} From 21d6e755e9db3ad64a7fddea8ea146cf4e5abd85 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 30 Sep 2026 13:35:14 +0900 Subject: [PATCH 04/53] =?UTF-8?q?feat(gift):=20=EC=84=A0=EB=AC=BC=20?= =?UTF-8?q?=EA=B0=80=EA=B2=A9=EA=B3=BC=20=EC=8B=A0=EC=B2=AD=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=A0=80=EC=9E=A5=EC=86=8C=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistence/GiftApplicationNoSequence.kt | 20 +++++++++++++ .../GiftApplicationNoSequenceRepository.kt | 15 ++++++++++ .../adapter/out/persistence/GiftSizePrice.kt | 30 +++++++++++++++++++ .../persistence/GiftSizePriceRepository.kt | 8 +++++ 4 files changed, 73 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftApplicationNoSequence.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftApplicationNoSequenceRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftSizePrice.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftSizePriceRepository.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftApplicationNoSequence.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftApplicationNoSequence.kt new file mode 100644 index 00000000..988d1cd7 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftApplicationNoSequence.kt @@ -0,0 +1,20 @@ +package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.Table +import javax.persistence.UniqueConstraint + +@Entity +@Table( + name = "gift_application_no_sequence", + uniqueConstraints = [UniqueConstraint(name = "uk_gift_application_no_sequence_date", columnNames = ["sequence_date"])] +) +class GiftApplicationNoSequence( + @Column(name = "sequence_date", nullable = false, length = 8) + val sequenceDate: String, + + @Column(name = "last_sequence", nullable = false) + var lastSequence: Int +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftApplicationNoSequenceRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftApplicationNoSequenceRepository.kt new file mode 100644 index 00000000..b557d321 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftApplicationNoSequenceRepository.kt @@ -0,0 +1,15 @@ +package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Lock +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import javax.persistence.LockModeType + +interface GiftApplicationNoSequenceRepository : JpaRepository { + fun findBySequenceDate(sequenceDate: String): GiftApplicationNoSequence? + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select s from GiftApplicationNoSequence s where s.sequenceDate = :sequenceDate") + fun findBySequenceDateForUpdate(@Param("sequenceDate") sequenceDate: String): GiftApplicationNoSequence? +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftSizePrice.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftSizePrice.kt new file mode 100644 index 00000000..6f5c934c --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftSizePrice.kt @@ -0,0 +1,30 @@ +package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.EnumType +import javax.persistence.Enumerated +import javax.persistence.Table +import javax.persistence.UniqueConstraint + +@Entity +@Table( + name = "gift_size_price", + uniqueConstraints = [UniqueConstraint(name = "uk_gift_size_price_size_code", columnNames = ["size_code"])] +) +class GiftSizePrice( + @Enumerated(EnumType.STRING) + @Column(name = "size_code", nullable = false, length = 20) + val sizeCode: GiftSize, + + @Column(name = "base_price_can", nullable = false) + var basePriceCan: Int, + + @Column(name = "sale_price_can", nullable = false) + var salePriceCan: Int, + + @Column(name = "is_active", nullable = false) + var isActive: Boolean = true +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftSizePriceRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftSizePriceRepository.kt new file mode 100644 index 00000000..19495ab1 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftSizePriceRepository.kt @@ -0,0 +1,8 @@ +package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence + +import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize +import org.springframework.data.jpa.repository.JpaRepository + +interface GiftSizePriceRepository : JpaRepository { + fun findBySizeCode(sizeCode: GiftSize): GiftSizePrice? +} From 8ae722c8dfe124b85626ae4c845630318d88dca5 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 30 Sep 2026 13:35:31 +0900 Subject: [PATCH 05/53] =?UTF-8?q?feat(gift):=20=EC=84=A0=EB=AC=BC=20?= =?UTF-8?q?=EB=B0=B0=EC=86=A1=EA=B3=BC=20=EB=A6=AC=EB=B7=B0=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/out/persistence/GiftDelivery.kt | 87 +++++++++++ .../out/persistence/GiftDeliveryRepository.kt | 7 + .../adapter/out/persistence/GiftReview.kt | 29 ++++ .../out/persistence/GiftReviewRepository.kt | 7 + .../out/persistence/GiftEntityMappingTest.kt | 137 ++++++++++++++++++ 5 files changed, 267 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftDelivery.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftDeliveryRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftReview.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftReviewRepository.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftEntityMappingTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftDelivery.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftDelivery.kt new file mode 100644 index 00000000..747e4a6b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftDelivery.kt @@ -0,0 +1,87 @@ +package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence + +import kr.co.vividnext.sodalive.common.BaseEntity +import java.time.LocalDateTime +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.Table +import javax.persistence.UniqueConstraint + +@Entity +@Table( + name = "gift_delivery", + uniqueConstraints = [UniqueConstraint(name = "uk_gift_delivery_gift_id", columnNames = ["gift_id"])] +) +class GiftDelivery( + @Column(name = "gift_id", nullable = false) + val giftId: Long, + + @Column(name = "sender_name", nullable = false, length = 50) + val senderName: String, + + @Column(name = "sender_phone_number", nullable = false, length = 30) + val senderPhoneNumber: String, + + @Column(name = "sender_zip_code", nullable = false, length = 20) + val senderZipCode: String, + + @Column(name = "sender_address", nullable = false, length = 255) + val senderAddress: String, + + @Column(name = "sender_address_detail", length = 255) + val senderAddressDetail: String? = null, + + @Column(name = "recipient_name", length = 50) + var recipientName: String? = null, + + @Column(name = "recipient_phone_number", length = 30) + var recipientPhoneNumber: String? = null, + + @Column(name = "recipient_zip_code", length = 20) + var recipientZipCode: String? = null, + + @Column(name = "recipient_address", length = 255) + var recipientAddress: String? = null, + + @Column(name = "recipient_address_detail", length = 255) + var recipientAddressDetail: String? = null, + + @Column(name = "courier_company_name", length = 50) + var courierCompanyName: String? = null, + + @Column(name = "tracking_number", length = 100) + var trackingNumber: String? = null, + + @Column(name = "tracking_deadline_at", nullable = false) + val trackingDeadlineAt: LocalDateTime, + + @Column(name = "recipient_address_deadline_at") + var recipientAddressDeadlineAt: LocalDateTime? = null, + + @Column(name = "tracking_deadline_reminder_sent_at") + var trackingDeadlineReminderSentAt: LocalDateTime? = null, + + @Column(name = "recipient_address_deadline_reminder_sent_at") + var recipientAddressDeadlineReminderSentAt: LocalDateTime? = null, + + @Column(name = "tracking_registered_at") + var trackingRegisteredAt: LocalDateTime? = null, + + @Column(name = "arrived_at_mailbox_at") + var arrivedAtMailboxAt: LocalDateTime? = null, + + @Column(name = "inspection_completed_at") + var inspectionCompletedAt: LocalDateTime? = null, + + @Column(name = "delivered_at") + var deliveredAt: LocalDateTime? = null, + + @Column(name = "canceled_at") + var canceledAt: LocalDateTime? = null, + + @Column(name = "undeliverable_at") + var undeliverableAt: LocalDateTime? = null, + + @Column(name = "undeliverable_reason", length = 255) + var undeliverableReason: String? = null +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftDeliveryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftDeliveryRepository.kt new file mode 100644 index 00000000..63e2b543 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftDeliveryRepository.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence + +import org.springframework.data.jpa.repository.JpaRepository + +interface GiftDeliveryRepository : JpaRepository { + fun findByGiftId(giftId: Long): GiftDelivery? +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftReview.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftReview.kt new file mode 100644 index 00000000..bde52bce --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftReview.kt @@ -0,0 +1,29 @@ +package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.Table +import javax.persistence.UniqueConstraint + +@Entity +@Table( + name = "gift_review", + uniqueConstraints = [UniqueConstraint(name = "uk_gift_review_gift_id", columnNames = ["gift_id"])] +) +class GiftReview( + @Column(name = "gift_id", nullable = false) + val giftId: Long, + + @Column(name = "sender_member_id", nullable = false) + val senderMemberId: Long, + + @Column(name = "rating", nullable = false) + val rating: Int, + + @Column(name = "keywords", columnDefinition = "text") + var keywords: String? = null, + + @Column(name = "comment", length = 255) + var comment: String? = null +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftReviewRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftReviewRepository.kt new file mode 100644 index 00000000..2998a748 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftReviewRepository.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence + +import org.springframework.data.jpa.repository.JpaRepository + +interface GiftReviewRepository : JpaRepository { + fun findByGiftId(giftId: Long): GiftReview? +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftEntityMappingTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftEntityMappingTest.kt new file mode 100644 index 00000000..9a758734 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftEntityMappingTest.kt @@ -0,0 +1,137 @@ +package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence + +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize +import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import org.springframework.dao.DataIntegrityViolationException +import java.time.LocalDateTime + +@DataJpaTest( + properties = [ + "spring.cache.type=none", + "spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE" + ] +) +@Import(QueryDslConfig::class) +@DisplayName("선물 엔티티 매핑 테스트") +class GiftEntityMappingTest @Autowired constructor( + private val giftRepository: GiftRepository, + private val giftDeliveryRepository: GiftDeliveryRepository, + private val giftCategoryRepository: GiftCategoryRepository, + private val giftSizePriceRepository: GiftSizePriceRepository, + private val giftReviewRepository: GiftReviewRepository, + private val giftApplicationNoSequenceRepository: GiftApplicationNoSequenceRepository +) { + @Test + @DisplayName("선물 엔티티와 배송 정보를 저장하고 조회한다") + fun shouldSaveAndFindGiftWithDelivery() { + val category = giftCategoryRepository.save( + GiftCategory( + categoryCode = "DOLL", + name = "인형", + requiresDamageWaiver = true + ) + ) + giftSizePriceRepository.save( + GiftSizePrice( + sizeCode = GiftSize.SMALL, + basePriceCan = 100, + salePriceCan = 80 + ) + ) + val gift = giftRepository.save( + Gift( + applicationNo = "G20260929000001", + senderMemberId = 1L, + recipientMemberId = 2L, + status = GiftStatus.RECEIVED, + sizeCode = GiftSize.SMALL, + categoryId = category.id!!, + categoryNameSnapshot = category.name, + salePriceCan = 80, + canUsageId = 10L, + senderTermsAgreed = true, + senderPrivacyAgreed = true, + senderTermsAgreedAt = LocalDateTime.of(2026, 9, 29, 12, 0), + senderPrivacyAgreedAt = LocalDateTime.of(2026, 9, 29, 12, 0), + damageWaiverAgreed = true + ) + ) + + giftDeliveryRepository.save( + GiftDelivery( + giftId = gift.id!!, + senderName = "홍길동", + senderPhoneNumber = "01012345678", + senderZipCode = "06234", + senderAddress = "서울시 강남구", + senderAddressDetail = "101동 1001호", + trackingDeadlineAt = LocalDateTime.of(2026, 10, 2, 12, 0) + ) + ) + + val saved = giftRepository.findByApplicationNo("G20260929000001") + val delivery = giftDeliveryRepository.findByGiftId(gift.id!!) + + assertNotNull(saved) + assertEquals(GiftStatus.RECEIVED, saved!!.status) + assertEquals("홍길동", delivery!!.senderName) + assertNotNull(saved.createdAt) + assertNotNull(saved.updatedAt) + } + + @Test + @DisplayName("신청번호와 카테고리 코드와 리뷰 선물 ID는 중복 저장할 수 없다") + fun shouldRejectDuplicateUniqueValues() { + val category = giftCategoryRepository.save(GiftCategory(categoryCode = "DOLL", name = "인형")) + val gift = giftRepository.save(gift(category.id!!, "G20260929000001")) + giftRepository.saveAndFlush(gift(category.id!!, "G20260929000002")) + giftReviewRepository.saveAndFlush(GiftReview(giftId = gift.id!!, senderMemberId = 1L, rating = 5)) + + assertThrows(DataIntegrityViolationException::class.java) { + giftRepository.saveAndFlush(gift(category.id!!, "G20260929000001")) + } + assertThrows(DataIntegrityViolationException::class.java) { + giftCategoryRepository.saveAndFlush(GiftCategory(categoryCode = "DOLL", name = "중복")) + } + assertThrows(DataIntegrityViolationException::class.java) { + giftReviewRepository.saveAndFlush(GiftReview(giftId = gift.id!!, senderMemberId = 1L, rating = 4)) + } + } + + @Test + @DisplayName("날짜별 신청번호 채번 sequence를 저장한다") + fun shouldSaveApplicationNoSequence() { + val sequence = giftApplicationNoSequenceRepository.save( + GiftApplicationNoSequence(sequenceDate = "20260929", lastSequence = 1) + ) + + assertEquals("20260929", sequence.sequenceDate) + assertEquals(1, sequence.lastSequence) + } + + private fun gift(categoryId: Long, applicationNo: String) = Gift( + applicationNo = applicationNo, + senderMemberId = 1L, + recipientMemberId = 2L, + status = GiftStatus.RECEIVED, + sizeCode = GiftSize.SMALL, + categoryId = categoryId, + categoryNameSnapshot = "인형", + salePriceCan = 80, + canUsageId = 10L, + senderTermsAgreed = true, + senderPrivacyAgreed = true, + senderTermsAgreedAt = LocalDateTime.of(2026, 9, 29, 12, 0), + senderPrivacyAgreedAt = LocalDateTime.of(2026, 9, 29, 12, 0), + damageWaiverAgreed = true + ) +} From 760005c26684cb04b23429f71780dcafcca61098 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 30 Sep 2026 13:35:47 +0900 Subject: [PATCH 06/53] =?UTF-8?q?feat(gift):=20=EC=8B=A0=EC=B2=AD=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=83=9D=EC=84=B1=EA=B8=B0=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/GiftApplicationNoGenerator.kt | 43 ++++++++++++ .../GiftApplicationNoGeneratorTest.kt | 68 +++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftApplicationNoGenerator.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftApplicationNoGeneratorTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftApplicationNoGenerator.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftApplicationNoGenerator.kt new file mode 100644 index 00000000..450cc499 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftApplicationNoGenerator.kt @@ -0,0 +1,43 @@ +package kr.co.vividnext.sodalive.v2.gift.application + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftApplicationNoSequence +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftApplicationNoSequenceRepository +import org.springframework.dao.DataIntegrityViolationException +import org.springframework.stereotype.Service +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.support.TransactionTemplate +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +@Service +class GiftApplicationNoGenerator( + private val repository: GiftApplicationNoSequenceRepository, + transactionManager: PlatformTransactionManager +) { + private val transactionTemplate = TransactionTemplate(transactionManager) + + fun generate(now: LocalDateTime = LocalDateTime.now(SEOUL_ZONE)): String { + val sequenceDate = now.format(DATE_FORMATTER) + for (attempt in 1..MAX_RETRY_COUNT) { + try { + return transactionTemplate.execute { + val sequence = repository.findBySequenceDateForUpdate(sequenceDate) + ?: repository.saveAndFlush(GiftApplicationNoSequence(sequenceDate = sequenceDate, lastSequence = 0)) + sequence.lastSequence += 1 + "G$sequenceDate${sequence.lastSequence.toString().padStart(6, '0')}" + } ?: throw SodaException(messageKey = "common.error.invalid_request") + } catch (_: DataIntegrityViolationException) { + if (attempt == MAX_RETRY_COUNT) throw SodaException(messageKey = "gift.application_no.generate_failed") + } + } + throw SodaException(messageKey = "gift.application_no.generate_failed") + } + + companion object { + private val SEOUL_ZONE = ZoneId.of("Asia/Seoul") + private val DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd") + private const val MAX_RETRY_COUNT = 3 + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftApplicationNoGeneratorTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftApplicationNoGeneratorTest.kt new file mode 100644 index 00000000..64996948 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftApplicationNoGeneratorTest.kt @@ -0,0 +1,68 @@ +package kr.co.vividnext.sodalive.v2.gift.application + +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftApplicationNoSequenceRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import org.springframework.transaction.PlatformTransactionManager +import java.time.LocalDateTime +import java.util.concurrent.Callable +import java.util.concurrent.Executors + +@DataJpaTest( + properties = [ + "spring.cache.type=none", + "spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE" + ] +) +@Import(QueryDslConfig::class) +@DisplayName("선물 신청번호 채번 테스트") +class GiftApplicationNoGeneratorTest @Autowired constructor( + private val repository: GiftApplicationNoSequenceRepository, + private val transactionManager: PlatformTransactionManager +) { + @BeforeEach + fun setUp() { + repository.deleteAll() + } + + @Test + @DisplayName("같은 날짜 신청번호를 순서대로 발급한다") + fun shouldGenerateSequentialApplicationNoByDate() { + val generator = GiftApplicationNoGenerator(repository, transactionManager) + val now = LocalDateTime.of(2026, 9, 29, 12, 0) + + val first = generator.generate(now) + val second = generator.generate(now) + + assertEquals("G20260929000001", first) + assertEquals("G20260929000002", second) + } + + @Test + @DisplayName("병렬 발급 결과는 모두 중복되지 않는다") + fun shouldGenerateUniqueApplicationNoConcurrently() { + val generator = GiftApplicationNoGenerator(repository, transactionManager) + val now = LocalDateTime.of(2026, 9, 29, 12, 0) + val executor = Executors.newFixedThreadPool(4) + + val applicationNos = try { + executor.invokeAll( + (1..10).map { + Callable { generator.generate(now) } + } + ).map { it.get() } + } finally { + executor.shutdownNow() + } + + assertEquals(10, applicationNos.toSet().size) + assertEquals("G20260929000001", applicationNos.minOrNull()) + assertEquals("G20260929000010", applicationNos.maxOrNull()) + } +} From 57bb57ffb53b248d3a02661aacabd8d95418e3e7 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 30 Sep 2026 13:36:04 +0900 Subject: [PATCH 07/53] =?UTF-8?q?feat(gift):=20=EC=84=A0=EB=AC=BC=20?= =?UTF-8?q?=EC=BA=94=20=EA=B2=B0=EC=A0=9C=20=EC=B2=98=EB=A6=AC=EB=A5=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../co/vividnext/sodalive/can/CanService.kt | 1 + .../sodalive/can/payment/CanPaymentService.kt | 34 ++++++++++++++++++- .../co/vividnext/sodalive/can/use/CanUsage.kt | 3 +- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt index b24964b9..d51443a3 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt @@ -120,6 +120,7 @@ class CanService( CanUsage.CHAT_QUOTA_PURCHASE -> "캐릭터 톡 이용권 구매" CanUsage.CHAT_ROOM_RESET -> "캐릭터 톡 초기화" + CanUsage.GIFT -> "선물하기" } val createdAt = it.createdAt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt index d5de9e6d..dc3d61ee 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt @@ -54,7 +54,7 @@ class CanPaymentService( communityPost: CreatorCommunity? = null, auditionApplicant: AuditionApplicant? = null, container: String - ) { + ): UseCan { val member = memberRepository.findByIdOrNull(id = memberId) ?: throw SodaException(messageKey = "can.payment.invalid_request_retry") val useRewardCan = spendRewardCan(member, needCan, container) @@ -131,6 +131,8 @@ class CanPaymentService( useCan.member = member useCan.chatRoomId = chatRoomId useCan.characterId = characterId + } else if (canUsage == CanUsage.GIFT) { + useCan.member = member } else { throw SodaException(messageKey = "common.error.invalid_request") } @@ -160,6 +162,18 @@ class CanPaymentService( useCan, paymentGateway = PaymentGateway.APPLE_IAP ) + + return useCan + } + + @Transactional + fun spendGiftCan(memberId: Long, needCan: Int, container: String): Long { + return spendCan( + memberId = memberId, + needCan = needCan, + canUsage = CanUsage.GIFT, + container = container + ).id!! } private fun setUseCanCalculate( @@ -324,6 +338,24 @@ class CanPaymentService( roomId = roomId, canUsage = CanUsage.LIVE ) ?: throw SodaException(messageKey = "can.payment.invalid_reservation") + refundUseCan(member, useCan) + } + + @Transactional + fun refundGiftCan(memberId: Long, useCanId: Long) { + val member = memberRepository.findByIdOrNull(memberId) + ?: throw SodaException(messageKey = "common.error.invalid_request") + val useCan = useCanRepository.findByIdOrNull(useCanId) + ?: throw SodaException(messageKey = "common.error.invalid_request") + + if (useCan.member?.id != member.id || useCan.canUsage != CanUsage.GIFT || useCan.isRefund) { + throw SodaException(messageKey = "common.error.invalid_request") + } + + refundUseCan(member, useCan) + } + + private fun refundUseCan(member: Member, useCan: UseCan) { useCan.isRefund = true val useCanCalculates = useCanCalculateRepository.findByUseCanIdAndStatus(useCan.id!!) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt index 32259413..a98a7c9c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt @@ -14,5 +14,6 @@ enum class CanUsage { CHAT_MESSAGE_PURCHASE, // 메시지를 통한 구매(이미지 등 다양한 리소스에 공통 적용) CHARACTER_IMAGE_PURCHASE, // 캐릭터 이미지 단독 구매 CHAT_QUOTA_PURCHASE, // 채팅 횟수(쿼터) 충전 - CHAT_ROOM_RESET // 채팅방 초기화 결제(별도 구분) + CHAT_ROOM_RESET, // 채팅방 초기화 결제(별도 구분) + GIFT } From 4ac203be1805a78236a10c4661776bb2db38097d Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 30 Sep 2026 13:36:24 +0900 Subject: [PATCH 08/53] =?UTF-8?q?feat(gift):=20=EC=84=A0=EB=AC=BC=20?= =?UTF-8?q?=ED=91=B8=EC=8B=9C=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=EB=A5=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt | 5 +++-- .../kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt | 8 ++++---- .../sodalive/fcm/notification/PushNotificationCategory.kt | 1 + 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt index 51c69c77..537d3227 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt @@ -25,7 +25,8 @@ enum class FcmDeepLinkValue(val value: String) { SERIES("series"), AUDITION("audition"), COMMUNITY("community"), - CHAT("chat") + CHAT("chat"), + GIFT_DETAIL("gift-detail") } class FcmEvent( @@ -50,7 +51,7 @@ class FcmEvent( val creatorId: Long? = null, val auditionId: Long? = null, val deepLinkValue: FcmDeepLinkValue? = null, - val deepLinkId: Long? = null, + val deepLinkId: Any? = null, val deepLinkCommentPostId: Long? = null, val commentParentId: Long? = null, val myMemberId: Long? = null, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt index cd02bdce..e0ba0bda 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt @@ -32,7 +32,7 @@ class FcmService( creatorId: Long? = null, auditionId: Long? = null, deepLinkValue: FcmDeepLinkValue? = null, - deepLinkId: Long? = null, + deepLinkId: Any? = null, deepLinkCommentPostId: Long? = null, chatType: String? = null ) { @@ -118,7 +118,7 @@ class FcmService( private fun createDeepLink( deepLinkValue: FcmDeepLinkValue?, - deepLinkId: Long?, + deepLinkId: Any?, deepLinkCommentPostId: Long? ): String? { return buildDeepLink(serverEnv, deepLinkValue, deepLinkId, deepLinkCommentPostId) @@ -194,7 +194,7 @@ class FcmService( fun buildDeepLink( serverEnv: String, deepLinkValue: FcmDeepLinkValue?, - deepLinkId: Long?, + deepLinkId: Any?, deepLinkCommentPostId: Long? = null ): String? { if (deepLinkValue == null || deepLinkId == null) { @@ -222,7 +222,7 @@ class FcmService( creatorId: Long? = null, auditionId: Long? = null, deepLinkValue: FcmDeepLinkValue? = null, - deepLinkId: Long? = null, + deepLinkId: Any? = null, deepLinkCommentPostId: Long? = null, deepLink: String? = null, chatType: String? = null diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationCategory.kt b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationCategory.kt index 72b1a4b0..64deded3 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationCategory.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationCategory.kt @@ -6,6 +6,7 @@ enum class PushNotificationCategory(val code: String) { COMMUNITY("community"), MESSAGE("message"), AUDITION("audition"), + GIFT("gift"), SYSTEM("system"); companion object { From 71e7195414ce5876ce15c3fd485dfe1dc32f38ab Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 30 Sep 2026 13:36:43 +0900 Subject: [PATCH 09/53] =?UTF-8?q?feat(gift):=20=EC=84=A0=EB=AC=BC=20?= =?UTF-8?q?=ED=91=B8=EC=8B=9C=20=EC=84=9C=EB=B9=84=EC=8A=A4=EB=A5=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../v2/gift/application/GiftPushService.kt | 126 +++++++++++++++ .../gift/application/GiftPushServiceTest.kt | 145 ++++++++++++++++++ 2 files changed, 271 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftPushService.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftPushServiceTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftPushService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftPushService.kt new file mode 100644 index 00000000..ef976e69 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftPushService.kt @@ -0,0 +1,126 @@ +package kr.co.vividnext.sodalive.v2.gift.application + +import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue +import kr.co.vividnext.sodalive.fcm.FcmEvent +import kr.co.vividnext.sodalive.fcm.FcmEventType +import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Service + +@Service +class GiftPushService( + private val applicationEventPublisher: ApplicationEventPublisher +) { + fun sendTrackingDeadlineReminder(applicationNo: String, memberId: Long) { + publishGiftPush( + memberId = memberId, + applicationNo = applicationNo, + title = "운송장 번호를 입력해 주세요", + message = "24시간 이내에 운송장 번호를 등록해 주세요.\n기한 내 등록하지 않으면 신청이 자동 취소됩니다." + ) + } + + fun sendRecipientAddressDeadlineReminder(applicationNo: String, memberId: Long) { + publishGiftPush( + memberId = memberId, + applicationNo = applicationNo, + title = "배송지 입력 기한이 1일 남았어요", + message = "24시간 안에 배송지를 입력해 주세요.\n입력하지 않으면 선물은 반송되지 않고 폐기돼요" + ) + } + + fun sendAutoCanceledToSender(applicationNo: String, memberId: Long) { + publishGiftPush( + memberId = memberId, + applicationNo = applicationNo, + title = "선물 신청이 자동 취소됐어요", + message = "신청 후 3일 이내에 운송장 번호가 등록되지 않아 신청이 취소됐어요.\n사용한 캔은 모두 환불되었습니다." + ) + } + + fun sendTrackingRegisteredToRecipient(applicationNo: String, memberId: Long) { + publishGiftPush( + memberId = memberId, + applicationNo = applicationNo, + title = "팬이 보낸 선물이 있어요!", + message = "7일 이내에 배송지를 입력해 주세요.\n기간이 지나면 선물을 받을 수 없어요" + ) + } + + fun sendArrivedAtMailboxToSender(applicationNo: String, memberId: Long) { + publishGiftPush( + memberId = memberId, + applicationNo = applicationNo, + title = "선물이 사서함에 도착했어요", + message = "보내주신 선물이 소다라이브 사서함에 도착했어요.\n안전하게 전달할 수 있도록 선물을 확인하고 있어요." + ) + } + + fun sendArrivedAtMailboxToRecipient(applicationNo: String, memberId: Long) { + publishGiftPush( + memberId = memberId, + applicationNo = applicationNo, + title = "선물이 사서함에 도착했어요", + message = "팬이 보낸 선물을 검수하고 있어요.\n검수가 끝나면 다시 알려드릴게요" + ) + } + + fun sendInspectionCompleted(applicationNo: String, memberId: Long) { + publishGiftPush( + memberId = memberId, + applicationNo = applicationNo, + title = "선물 검수가 완료됐어요", + message = "확인된 선물을 빠른 시일 내에 입력하신 배송지로 보내드릴게요." + ) + } + + fun sendUndeliverableToSender(applicationNo: String, memberId: Long) { + publishGiftPush( + memberId = memberId, + applicationNo = applicationNo, + title = "선물을 전달할 수 없어요", + message = "도착한 선물이 선물 접수 규격에 맞지 않아 크리에이터에게 전달할 수 없어요.\n자세한 사유는 신청 상세에서 확인해 주세요." + ) + } + + fun sendUndeliverableToRecipient(applicationNo: String, memberId: Long) { + publishGiftPush( + memberId = memberId, + applicationNo = applicationNo, + title = "전달할 수 없는 선물이 있어요", + message = "검수 결과 선물 정책에 맞지 않아 해당 선물은 배송되지 않아요.\n자세한 사유를 확인해 주세요." + ) + } + + fun sendDeliveredToSender(applicationNo: String, memberId: Long) { + publishGiftPush( + memberId = memberId, + applicationNo = applicationNo, + title = "선물이 전달됐어요", + message = "보내주신 선물이 크리에이터에게 안전하게 전달됐어요." + ) + } + + fun sendDeliveredToRecipient(applicationNo: String, memberId: Long) { + publishGiftPush( + memberId = memberId, + applicationNo = applicationNo, + title = "선물이 도착했어요!", + message = "팬이 보낸 선물이 배송지에 도착했어요.\n지금 확인해 보세요." + ) + } + + private fun publishGiftPush(memberId: Long, applicationNo: String, title: String, message: String) { + applicationEventPublisher.publishEvent( + FcmEvent( + type = FcmEventType.INDIVIDUAL, + category = PushNotificationCategory.GIFT, + title = title, + message = message, + deepLinkValue = FcmDeepLinkValue.GIFT_DETAIL, + deepLinkId = applicationNo, + recipients = listOf(memberId) + ) + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftPushServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftPushServiceTest.kt new file mode 100644 index 00000000..915a161c --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftPushServiceTest.kt @@ -0,0 +1,145 @@ +package kr.co.vividnext.sodalive.v2.gift.application + +import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue +import kr.co.vividnext.sodalive.fcm.FcmEvent +import kr.co.vividnext.sodalive.fcm.FcmEventType +import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.Mockito +import org.springframework.context.ApplicationEventPublisher + +class GiftPushServiceTest { + private val eventPublisher = Mockito.mock(ApplicationEventPublisher::class.java) + private val service = GiftPushService(eventPublisher) + + @Test + fun shouldPublishTrackingDeadlineReminderToSender() { + service.sendTrackingDeadlineReminder(APPLICATION_NO, MEMBER_ID) + + val event = capturePublishedEvent() + assertGiftEvent(event) + assertEquals("운송장 번호를 입력해 주세요", event.title) + assertEquals("24시간 이내에 운송장 번호를 등록해 주세요.\n기한 내 등록하지 않으면 신청이 자동 취소됩니다.", event.message) + } + + @Test + fun shouldPublishRecipientAddressDeadlineReminderToRecipient() { + service.sendRecipientAddressDeadlineReminder(APPLICATION_NO, MEMBER_ID) + + val event = capturePublishedEvent() + assertGiftEvent(event) + assertEquals("배송지 입력 기한이 1일 남았어요", event.title) + assertEquals("24시간 안에 배송지를 입력해 주세요.\n입력하지 않으면 선물은 반송되지 않고 폐기돼요", event.message) + } + + @Test + fun shouldPublishAutoCanceledToSender() { + service.sendAutoCanceledToSender(APPLICATION_NO, MEMBER_ID) + + val event = capturePublishedEvent() + assertGiftEvent(event) + assertEquals("선물 신청이 자동 취소됐어요", event.title) + assertEquals("신청 후 3일 이내에 운송장 번호가 등록되지 않아 신청이 취소됐어요.\n사용한 캔은 모두 환불되었습니다.", event.message) + } + + @Test + fun shouldPublishTrackingRegisteredToRecipient() { + service.sendTrackingRegisteredToRecipient(APPLICATION_NO, MEMBER_ID) + + val event = capturePublishedEvent() + assertGiftEvent(event) + assertEquals("팬이 보낸 선물이 있어요!", event.title) + assertEquals("7일 이내에 배송지를 입력해 주세요.\n기간이 지나면 선물을 받을 수 없어요", event.message) + } + + @Test + fun shouldPublishArrivedAtMailboxToSender() { + service.sendArrivedAtMailboxToSender(APPLICATION_NO, MEMBER_ID) + + val event = capturePublishedEvent() + assertGiftEvent(event) + assertEquals("선물이 사서함에 도착했어요", event.title) + assertEquals("보내주신 선물이 소다라이브 사서함에 도착했어요.\n안전하게 전달할 수 있도록 선물을 확인하고 있어요.", event.message) + } + + @Test + fun shouldPublishArrivedAtMailboxToRecipient() { + service.sendArrivedAtMailboxToRecipient(APPLICATION_NO, MEMBER_ID) + + val event = capturePublishedEvent() + assertGiftEvent(event) + assertEquals("선물이 사서함에 도착했어요", event.title) + assertEquals("팬이 보낸 선물을 검수하고 있어요.\n검수가 끝나면 다시 알려드릴게요", event.message) + } + + @Test + fun shouldPublishInspectionCompletedToRecipient() { + service.sendInspectionCompleted(APPLICATION_NO, MEMBER_ID) + + val event = capturePublishedEvent() + assertGiftEvent(event) + assertEquals("선물 검수가 완료됐어요", event.title) + assertEquals("확인된 선물을 빠른 시일 내에 입력하신 배송지로 보내드릴게요.", event.message) + } + + @Test + fun shouldPublishUndeliverableToSender() { + service.sendUndeliverableToSender(APPLICATION_NO, MEMBER_ID) + + val event = capturePublishedEvent() + assertGiftEvent(event) + assertEquals("선물을 전달할 수 없어요", event.title) + assertEquals("도착한 선물이 선물 접수 규격에 맞지 않아 크리에이터에게 전달할 수 없어요.\n자세한 사유는 신청 상세에서 확인해 주세요.", event.message) + } + + @Test + fun shouldPublishUndeliverableToRecipient() { + service.sendUndeliverableToRecipient(APPLICATION_NO, MEMBER_ID) + + val event = capturePublishedEvent() + assertGiftEvent(event) + assertEquals("전달할 수 없는 선물이 있어요", event.title) + assertEquals("검수 결과 선물 정책에 맞지 않아 해당 선물은 배송되지 않아요.\n자세한 사유를 확인해 주세요.", event.message) + } + + @Test + fun shouldPublishDeliveredToSender() { + service.sendDeliveredToSender(APPLICATION_NO, MEMBER_ID) + + val event = capturePublishedEvent() + assertGiftEvent(event) + assertEquals("선물이 전달됐어요", event.title) + assertEquals("보내주신 선물이 크리에이터에게 안전하게 전달됐어요.", event.message) + } + + @Test + fun shouldPublishDeliveredToRecipient() { + service.sendDeliveredToRecipient(APPLICATION_NO, MEMBER_ID) + + val event = capturePublishedEvent() + assertGiftEvent(event) + assertEquals("선물이 도착했어요!", event.title) + assertEquals("팬이 보낸 선물이 배송지에 도착했어요.\n지금 확인해 보세요.", event.message) + } + + private fun assertGiftEvent(event: FcmEvent) { + assertEquals(FcmEventType.INDIVIDUAL, event.type) + assertEquals(PushNotificationCategory.GIFT, event.category) + assertEquals(FcmDeepLinkValue.GIFT_DETAIL, event.deepLinkValue) + assertEquals(APPLICATION_NO, event.deepLinkId) + assertEquals(listOf(MEMBER_ID), event.recipients) + } + + private fun capturePublishedEvent(): FcmEvent { + val captor = ArgumentCaptor.forClass(FcmEvent::class.java) + Mockito.verify(eventPublisher).publishEvent(captor.capture()) + return captor.value + } + + private companion object { + const val APPLICATION_NO = "G20260929000001" + const val MEMBER_ID = 1L + } +} From 95b3b16897d0370efd0eddc359ef5d4cc596267b Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 30 Sep 2026 13:37:06 +0900 Subject: [PATCH 10/53] =?UTF-8?q?feat(gift):=20=EC=84=A0=EB=AC=BC=20?= =?UTF-8?q?=EC=8B=A0=EC=B2=AD=20=EB=AA=85=EB=A0=B9=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../v2/gift/application/GiftCommandService.kt | 368 ++++++++ .../v2/gift/GiftCommandServiceTest.kt | 868 ++++++++++++++++++ 2 files changed, 1236 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftCommandService.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/GiftCommandServiceTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftCommandService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftCommandService.kt new file mode 100644 index 00000000..348539ce --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftCommandService.kt @@ -0,0 +1,368 @@ +package kr.co.vividnext.sodalive.v2.gift.application + +import kr.co.vividnext.sodalive.can.payment.CanPaymentService +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftReview +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftReviewRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository +import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize +import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +data class GiftApplicationCommand( + val recipientMemberId: Long, + val senderName: String, + val senderPhoneNumber: String, + val senderZipCode: String, + val senderAddress: String, + val senderAddressDetail: String?, + val sizeCode: GiftSize, + val categoryId: Long, + val senderTermsAgreed: Boolean, + val senderPrivacyAgreed: Boolean, + val damageWaiverAgreed: Boolean +) + +data class GiftApplicationResult( + val applicationNo: String, + val status: GiftStatus, + val statusName: String, + val priceCan: Int, + val trackingDeadlineAt: LocalDateTime +) + +data class GiftCancellationResult( + val applicationNo: String, + val status: GiftStatus, + val statusName: String, + val priceCan: Int, + val canceledAt: LocalDateTime +) + +data class GiftTrackingRegistrationCommand( + val courierCompanyName: String, + val trackingNumber: String +) + +data class GiftTrackingRegistrationResult( + val applicationNo: String, + val status: GiftStatus, + val statusName: String, + val courierCompanyName: String, + val trackingNumber: String, + val trackingRegisteredAt: LocalDateTime, + val recipientAddressDeadlineAt: LocalDateTime +) + +data class GiftRecipientAddressRegistrationCommand( + val name: String, + val phoneNumber: String, + val zipCode: String, + val address: String, + val addressDetail: String?, + val recipientTermsAgreed: Boolean, + val recipientPrivacyAgreed: Boolean +) + +data class GiftRecipientAddressRegistrationResult( + val applicationNo: String, + val status: GiftStatus, + val statusName: String, + val recipientAddressRegisteredAt: LocalDateTime +) + +data class GiftDeliveryConfirmationResult( + val applicationNo: String, + val status: GiftStatus, + val statusName: String, + val deliveredAt: LocalDateTime +) + +data class GiftReviewCommand( + val rating: Int, + val keywords: List, + val comment: String? +) + +data class GiftReviewResult( + val reviewId: Long, + val applicationNo: String, + val rating: Int, + val keywords: List, + val comment: String?, + val createdAt: LocalDateTime? +) + +@Service +class GiftCommandService( + private val giftRepository: GiftRepository, + private val deliveryRepository: GiftDeliveryRepository, + private val reviewRepository: GiftReviewRepository, + private val categoryRepository: GiftCategoryRepository, + private val sizePriceRepository: GiftSizePriceRepository, + private val memberRepository: MemberRepository, + private val applicationNoGenerator: GiftApplicationNoGenerator, + private val canPaymentService: CanPaymentService, + private val giftPushService: GiftPushService, + private val now: () -> LocalDateTime = { LocalDateTime.now() } +) { + @Transactional + fun registerGift(member: Member, command: GiftApplicationCommand): GiftApplicationResult { + validate(command) + val senderMemberId = member.id ?: throw invalidRequest() + memberRepository.findCreatorByIdOrNull(command.recipientMemberId) ?: throw invalidRequest() + val category = categoryRepository.findByIdOrNull(command.categoryId)?.takeIf { it.isActive } ?: throw invalidRequest() + if (category.requiresDamageWaiver && !command.damageWaiverAgreed) throw invalidRequest() + val sizePrice = sizePriceRepository.findBySizeCode(command.sizeCode)?.takeIf { it.isActive } ?: throw invalidRequest() + val appliedAt = now() + val trackingDeadlineAt = appliedAt.plusDays(3) + val applicationNo = applicationNoGenerator.generate(appliedAt) + val canUsageId = canPaymentService.spendGiftCan( + senderMemberId, + sizePrice.salePriceCan, + member.container.ifBlank { "web" } + ) + val gift = giftRepository.save( + Gift( + applicationNo = applicationNo, + senderMemberId = senderMemberId, + recipientMemberId = command.recipientMemberId, + status = GiftStatus.RECEIVED, + sizeCode = command.sizeCode, + categoryId = category.id ?: throw invalidRequest(), + categoryNameSnapshot = category.name, + salePriceCan = sizePrice.salePriceCan, + canUsageId = canUsageId, + senderTermsAgreed = command.senderTermsAgreed, + senderPrivacyAgreed = command.senderPrivacyAgreed, + senderTermsAgreedAt = appliedAt, + senderPrivacyAgreedAt = appliedAt, + damageWaiverAgreed = command.damageWaiverAgreed + ) + ) + deliveryRepository.save( + GiftDelivery( + giftId = gift.id ?: throw invalidRequest(), + senderName = command.senderName, + senderPhoneNumber = command.senderPhoneNumber, + senderZipCode = command.senderZipCode, + senderAddress = command.senderAddress, + senderAddressDetail = command.senderAddressDetail, + trackingDeadlineAt = trackingDeadlineAt + ) + ) + return GiftApplicationResult( + applicationNo = applicationNo, + status = GiftStatus.RECEIVED, + statusName = GiftStatus.RECEIVED.displayName, + priceCan = sizePrice.salePriceCan, + trackingDeadlineAt = trackingDeadlineAt + ) + } + + @Transactional + fun writeReview(member: Member, applicationNo: String, command: GiftReviewCommand): GiftReviewResult { + val senderMemberId = member.id ?: throw invalidRequest() + val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest() + val giftId = gift.id ?: throw invalidRequest() + if ( + gift.senderMemberId != senderMemberId || + gift.status != GiftStatus.DELIVERED || + reviewRepository.findByGiftId(giftId) != null + ) { + throw invalidRequest() + } + validate(command) + + val review = reviewRepository.save( + GiftReview( + giftId = giftId, + senderMemberId = senderMemberId, + rating = command.rating, + keywords = command.keywords.joinToString(","), + comment = command.comment + ) + ) + + return GiftReviewResult( + reviewId = review.id ?: throw invalidRequest(), + applicationNo = gift.applicationNo, + rating = review.rating, + keywords = review.keywords?.takeIf { it.isNotBlank() }?.split(",") ?: emptyList(), + comment = review.comment, + createdAt = review.createdAt + ) + } + + @Transactional + fun cancelGift(member: Member, applicationNo: String): GiftCancellationResult { + val senderMemberId = member.id ?: throw invalidRequest() + val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest() + if (gift.senderMemberId != senderMemberId || gift.status != GiftStatus.RECEIVED) throw invalidRequest() + + val canceledAt = now() + gift.status = GiftStatus.CANCELED + deliveryRepository.findByGiftId(gift.id ?: throw invalidRequest())?.canceledAt = canceledAt + canPaymentService.refundGiftCan(senderMemberId, gift.canUsageId) + + return GiftCancellationResult( + applicationNo = gift.applicationNo, + status = gift.status, + statusName = gift.status.displayName, + priceCan = gift.salePriceCan, + canceledAt = canceledAt + ) + } + + @Transactional + fun registerTracking( + member: Member, + applicationNo: String, + command: GiftTrackingRegistrationCommand + ): GiftTrackingRegistrationResult { + val senderMemberId = member.id ?: throw invalidRequest() + val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest() + if (gift.senderMemberId != senderMemberId || gift.status != GiftStatus.RECEIVED) throw invalidRequest() + validate(command) + val delivery = deliveryRepository.findByGiftId(gift.id ?: throw invalidRequest()) ?: throw invalidRequest() + val trackingRegisteredAt = now() + val recipientAddressDeadlineAt = trackingRegisteredAt.plusDays(7) + + gift.status = GiftStatus.TRACKING_REGISTERED + delivery.courierCompanyName = command.courierCompanyName + delivery.trackingNumber = command.trackingNumber + delivery.trackingRegisteredAt = trackingRegisteredAt + delivery.recipientAddressDeadlineAt = recipientAddressDeadlineAt + giftPushService.sendTrackingRegisteredToRecipient(gift.applicationNo, gift.recipientMemberId) + + return GiftTrackingRegistrationResult( + applicationNo = gift.applicationNo, + status = gift.status, + statusName = gift.status.displayName, + courierCompanyName = command.courierCompanyName, + trackingNumber = command.trackingNumber, + trackingRegisteredAt = trackingRegisteredAt, + recipientAddressDeadlineAt = recipientAddressDeadlineAt + ) + } + + @Transactional + fun registerRecipientAddress( + member: Member, + applicationNo: String, + command: GiftRecipientAddressRegistrationCommand + ): GiftRecipientAddressRegistrationResult { + val recipientMemberId = member.id ?: throw invalidRequest() + val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest() + if (gift.recipientMemberId != recipientMemberId || gift.status !in RECIPIENT_ADDRESS_REGISTRABLE_STATUSES) { + throw invalidRequest() + } + validate(command) + val delivery = deliveryRepository.findByGiftId(gift.id ?: throw invalidRequest()) ?: throw invalidRequest() + val registeredAt = now() + + delivery.recipientName = command.name + delivery.recipientPhoneNumber = command.phoneNumber + delivery.recipientZipCode = command.zipCode + delivery.recipientAddress = command.address + delivery.recipientAddressDetail = command.addressDetail + gift.recipientTermsAgreed = command.recipientTermsAgreed + gift.recipientPrivacyAgreed = command.recipientPrivacyAgreed + gift.recipientTermsAgreedAt = registeredAt + gift.recipientPrivacyAgreedAt = registeredAt + + return GiftRecipientAddressRegistrationResult( + applicationNo = gift.applicationNo, + status = gift.status, + statusName = gift.status.displayName, + recipientAddressRegisteredAt = registeredAt + ) + } + + @Transactional + fun confirmDelivery(member: Member, applicationNo: String): GiftDeliveryConfirmationResult { + val recipientMemberId = member.id ?: throw invalidRequest() + val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest() + if (gift.recipientMemberId != recipientMemberId || gift.status != GiftStatus.INSPECTION_COMPLETED) { + throw invalidRequest() + } + val delivery = deliveryRepository.findByGiftId(gift.id ?: throw invalidRequest()) ?: throw invalidRequest() + val deliveredAt = now() + + gift.status = GiftStatus.DELIVERED + delivery.deliveredAt = deliveredAt + + return GiftDeliveryConfirmationResult( + applicationNo = gift.applicationNo, + status = gift.status, + statusName = gift.status.displayName, + deliveredAt = deliveredAt + ) + } + + private fun validate(command: GiftApplicationCommand) { + if ( + command.senderName.isBlank() || + command.senderPhoneNumber.isBlank() || + command.senderZipCode.isBlank() || + command.senderAddress.isBlank() || + !command.senderTermsAgreed || + !command.senderPrivacyAgreed + ) { + throw invalidRequest() + } + } + + private fun validate(command: GiftTrackingRegistrationCommand) { + if ( + command.courierCompanyName.isBlank() || + command.courierCompanyName.length > 50 || + command.trackingNumber.isBlank() + ) { + throw invalidRequest() + } + } + + private fun validate(command: GiftRecipientAddressRegistrationCommand) { + if ( + command.name.isBlank() || + command.phoneNumber.isBlank() || + command.zipCode.isBlank() || + command.address.isBlank() || + !command.recipientTermsAgreed || + !command.recipientPrivacyAgreed + ) { + throw invalidRequest() + } + } + + private fun validate(command: GiftReviewCommand) { + if ( + command.rating !in 1..5 || + command.keywords.any { it.length > 255 } || + (command.comment?.length ?: 0) > 255 + ) { + throw invalidRequest() + } + } + + private fun invalidRequest() = SodaException(messageKey = "common.error.invalid_request") + + private companion object { + val RECIPIENT_ADDRESS_REGISTRABLE_STATUSES = setOf( + GiftStatus.TRACKING_REGISTERED, + GiftStatus.ARRIVED_AT_MAILBOX, + GiftStatus.INSPECTION_COMPLETED + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/GiftCommandServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/GiftCommandServiceTest.kt new file mode 100644 index 00000000..be038d71 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/GiftCommandServiceTest.kt @@ -0,0 +1,868 @@ +package kr.co.vividnext.sodalive.v2.gift + +import kr.co.vividnext.sodalive.can.CanRepository +import kr.co.vividnext.sodalive.can.charge.Charge +import kr.co.vividnext.sodalive.can.charge.ChargeRepository +import kr.co.vividnext.sodalive.can.payment.CanPaymentService +import kr.co.vividnext.sodalive.can.payment.Payment +import kr.co.vividnext.sodalive.can.payment.PaymentGateway +import kr.co.vividnext.sodalive.can.payment.PaymentStatus +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.can.use.UseCan +import kr.co.vividnext.sodalive.can.use.UseCanCalculate +import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository +import kr.co.vividnext.sodalive.can.use.UseCanRepository +import kr.co.vividnext.sodalive.common.CountryContext +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftReview +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftReviewRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePrice +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository +import kr.co.vividnext.sodalive.v2.gift.application.GiftApplicationCommand +import kr.co.vividnext.sodalive.v2.gift.application.GiftApplicationNoGenerator +import kr.co.vividnext.sodalive.v2.gift.application.GiftCancellationResult +import kr.co.vividnext.sodalive.v2.gift.application.GiftCommandService +import kr.co.vividnext.sodalive.v2.gift.application.GiftDeliveryConfirmationResult +import kr.co.vividnext.sodalive.v2.gift.application.GiftPushService +import kr.co.vividnext.sodalive.v2.gift.application.GiftRecipientAddressRegistrationCommand +import kr.co.vividnext.sodalive.v2.gift.application.GiftRecipientAddressRegistrationResult +import kr.co.vividnext.sodalive.v2.gift.application.GiftReviewCommand +import kr.co.vividnext.sodalive.v2.gift.application.GiftReviewResult +import kr.co.vividnext.sodalive.v2.gift.application.GiftTrackingRegistrationCommand +import kr.co.vividnext.sodalive.v2.gift.application.GiftTrackingRegistrationResult +import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize +import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import org.mockito.Mockito +import java.time.LocalDateTime +import java.util.Optional + +class GiftCommandServiceTest { + @Test + @DisplayName("선물 캔 사용은 한 번만 환불할 수 있다") + fun shouldSpendAndRefundGiftCanOnlyOnce() { + // Given: 충전 캔을 보유한 선물 발신자와 결제 서비스를 준비한다. + val member = Member(password = "password", nickname = "sender") + member.id = MEMBER_ID + member.pgChargeCan = INITIAL_CAN + val charge = Charge(chargeCan = INITIAL_CAN, rewardCan = 0) + charge.id = CHARGE_ID + charge.member = member + charge.payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.PG) + val fixture = CanPaymentFixture(member, charge) + + // When: 선물용 캔을 사용한 뒤 해당 사용 내역을 환불한다. + val useCanId = fixture.service.spendGiftCan( + memberId = MEMBER_ID, + needCan = GIFT_PRICE_CAN, + container = "web" + ) + val afterSpendCan = member.getChargeCan("web") + member.getRewardCan("web") + fixture.service.refundGiftCan(memberId = MEMBER_ID, useCanId = useCanId) + val afterRefundCan = member.getChargeCan("web") + member.getRewardCan("web") + + // Then: 발신자와 GIFT 용도가 저장되고, 잔액이 복원되며 재환불은 거부된다. + assertEquals(CanUsage.GIFT, fixture.savedUseCan.canUsage) + assertEquals(member, fixture.savedUseCan.member) + assertEquals(INITIAL_CAN - GIFT_PRICE_CAN, afterSpendCan) + assertEquals(INITIAL_CAN, afterRefundCan) + assertEquals(true, fixture.savedUseCan.isRefund) + assertThrows(SodaException::class.java) { + fixture.service.refundGiftCan(memberId = MEMBER_ID, useCanId = useCanId) + } + } + + @Test + @DisplayName("선물 신청은 가격과 약관 동의, 배송 기한을 스냅샷으로 저장하고 캔을 차감한다") + fun shouldRegisterGiftWithPriceAgreementAndDeliverySnapshots() { + // Given: 활성 선물 카테고리와 가격, 발신자 및 수신 크리에이터를 준비한다. + val fixture = GiftApplicationFixture() + + // When: 팬이 선물을 신청한다. + val result = fixture.service.registerGift(fixture.sender, validGiftApplicationCommand()) + + // Then: 신청 정보와 배송 기한을 저장하고 판매가만큼 캔을 차감한다. + assertEquals(APPLICATION_NO, result.applicationNo) + assertEquals(GiftStatus.RECEIVED, result.status) + assertEquals(GiftStatus.RECEIVED.displayName, result.statusName) + assertEquals(SALE_PRICE_CAN, result.priceCan) + assertEquals(NOW.plusDays(3), result.trackingDeadlineAt) + assertEquals(SENDER_ID, fixture.savedGift.senderMemberId) + assertEquals(RECIPIENT_ID, fixture.savedGift.recipientMemberId) + assertEquals(GiftStatus.RECEIVED, fixture.savedGift.status) + assertEquals(GiftSize.SMALL, fixture.savedGift.sizeCode) + assertEquals(CATEGORY_ID, fixture.savedGift.categoryId) + assertEquals("인형", fixture.savedGift.categoryNameSnapshot) + assertEquals(SALE_PRICE_CAN, fixture.savedGift.salePriceCan) + assertEquals(USE_CAN_ID, fixture.savedGift.canUsageId) + assertEquals(true, fixture.savedGift.senderTermsAgreed) + assertEquals(true, fixture.savedGift.senderPrivacyAgreed) + assertEquals(NOW, fixture.savedGift.senderTermsAgreedAt) + assertEquals(NOW, fixture.savedGift.senderPrivacyAgreedAt) + assertEquals(true, fixture.savedGift.damageWaiverAgreed) + assertEquals(GIFT_ID, fixture.savedDelivery.giftId) + assertEquals("홍길동", fixture.savedDelivery.senderName) + assertEquals("01012345678", fixture.savedDelivery.senderPhoneNumber) + assertEquals("06234", fixture.savedDelivery.senderZipCode) + assertEquals("서울시 강남구", fixture.savedDelivery.senderAddress) + assertEquals("101동 1001호", fixture.savedDelivery.senderAddressDetail) + assertEquals(NOW.plusDays(3), fixture.savedDelivery.trackingDeadlineAt) + Mockito.verify(fixture.canPaymentService).spendGiftCan(SENDER_ID, SALE_PRICE_CAN, "aos") + } + + @Test + @DisplayName("보낸 팬은 운송장 등록 전 선물을 취소하고 전액 환불받는다") + fun shouldCancelReceivedGiftAndRefundOnce() { + // Given: 접수 상태의 선물과 발신자 배송 정보를 준비한다. + val fixture = GiftApplicationFixture() + fixture.prepareGiftForCancellation() + + // When: 보낸 팬이 선물을 취소한다. + val result = fixture.service.cancelGift(fixture.sender, APPLICATION_NO) + + // Then: 선물과 배송 정보에 취소 시각을 저장하고 한 번만 환불한다. + assertEquals( + GiftCancellationResult( + applicationNo = APPLICATION_NO, + status = GiftStatus.CANCELED, + statusName = GiftStatus.CANCELED.displayName, + priceCan = SALE_PRICE_CAN, + canceledAt = NOW + ), + result + ) + assertEquals(GiftStatus.CANCELED, fixture.cancellationGift.status) + assertEquals(NOW, fixture.cancellationDelivery.canceledAt) + Mockito.verify(fixture.canPaymentService).refundGiftCan(SENDER_ID, USE_CAN_ID) + } + + @Test + @DisplayName("보낸 팬은 접수된 선물의 운송장을 등록하고 크리에이터 배송지 입력 기한을 생성한다") + fun shouldRegisterTrackingForReceivedGift() { + // Given: 접수 상태의 선물과 발신자 배송 정보를 준비한다. + val fixture = GiftApplicationFixture() + fixture.prepareGiftForTracking() + + // When: 보낸 팬이 택배사와 운송장 번호를 등록한다. + val result = fixture.service.registerTracking( + fixture.sender, + APPLICATION_NO, + validGiftTrackingRegistrationCommand() + ) + + // Then: 발송 확인 상태와 운송장 정보, 수신자 주소 입력 기한을 저장한다. + assertEquals( + GiftTrackingRegistrationResult( + applicationNo = APPLICATION_NO, + status = GiftStatus.TRACKING_REGISTERED, + statusName = GiftStatus.TRACKING_REGISTERED.displayName, + courierCompanyName = "CJ대한통운", + trackingNumber = "1234567890", + trackingRegisteredAt = NOW, + recipientAddressDeadlineAt = NOW.plusDays(7) + ), + result + ) + assertEquals(GiftStatus.TRACKING_REGISTERED, fixture.trackingGift.status) + assertEquals("CJ대한통운", fixture.trackingDelivery.courierCompanyName) + assertEquals("1234567890", fixture.trackingDelivery.trackingNumber) + assertEquals(NOW, fixture.trackingDelivery.trackingRegisteredAt) + assertEquals(NOW.plusDays(7), fixture.trackingDelivery.recipientAddressDeadlineAt) + Mockito.verify(fixture.giftPushService).sendTrackingRegisteredToRecipient(APPLICATION_NO, RECIPIENT_ID) + } + + @ParameterizedTest + @MethodSource("recipientAddressRegistrableStatuses") + @DisplayName("받는 크리에이터는 운송장 등록 이후 종료 전 상태에서 주소와 수령 동의를 등록한다") + fun shouldRegisterRecipientAddressAndAgreements(status: GiftStatus) { + // Given: 주소 입력이 허용된 선물과 받는 크리에이터를 준비한다. + val fixture = GiftApplicationFixture() + fixture.prepareGiftForRecipientAddress(status = status) + + // When: 받는 크리에이터가 배송지와 수령 약관 동의를 등록한다. + val result = fixture.service.registerRecipientAddress( + fixture.creator, + APPLICATION_NO, + validGiftRecipientAddressRegistrationCommand() + ) + + // Then: 주소 스냅샷과 두 동의 시각을 저장하고 현재 상태를 반환한다. + assertEquals( + GiftRecipientAddressRegistrationResult( + applicationNo = APPLICATION_NO, + status = status, + statusName = status.displayName, + recipientAddressRegisteredAt = NOW + ), + result + ) + assertEquals("김소다", fixture.recipientAddressDelivery.recipientName) + assertEquals("01098765432", fixture.recipientAddressDelivery.recipientPhoneNumber) + assertEquals("04524", fixture.recipientAddressDelivery.recipientZipCode) + assertEquals("서울시 중구", fixture.recipientAddressDelivery.recipientAddress) + assertEquals("202호", fixture.recipientAddressDelivery.recipientAddressDetail) + assertEquals(true, fixture.recipientAddressGift.recipientTermsAgreed) + assertEquals(true, fixture.recipientAddressGift.recipientPrivacyAgreed) + assertEquals(NOW, fixture.recipientAddressGift.recipientTermsAgreedAt) + assertEquals(NOW, fixture.recipientAddressGift.recipientPrivacyAgreedAt) + } + + @Test + @DisplayName("받는 크리에이터는 검수 완료 선물의 수령을 확인하고 전달 완료 시각을 저장한다") + fun shouldConfirmDeliveryForInspectionCompletedGift() { + val fixture = GiftApplicationFixture() + fixture.prepareGiftForDeliveryConfirmation() + + val result = fixture.service.confirmDelivery(fixture.creator, APPLICATION_NO) + + assertEquals( + GiftDeliveryConfirmationResult( + applicationNo = APPLICATION_NO, + status = GiftStatus.DELIVERED, + statusName = GiftStatus.DELIVERED.displayName, + deliveredAt = NOW + ), + result + ) + assertEquals(GiftStatus.DELIVERED, fixture.deliveryConfirmationGift.status) + assertEquals(NOW, fixture.deliveryConfirmationDelivery.deliveredAt) + Mockito.verifyNoInteractions(fixture.giftPushService) + } + + @Test + @DisplayName("보낸 팬은 전달 완료 선물에 리뷰를 한 번 작성한다") + fun shouldWriteReviewForDeliveredGiftBySender() { + val fixture = GiftApplicationFixture() + fixture.prepareGiftForReview() + + val result = fixture.service.writeReview(fixture.sender, APPLICATION_NO, validGiftReviewCommand()) + + assertEquals( + GiftReviewResult( + reviewId = REVIEW_ID, + applicationNo = APPLICATION_NO, + rating = 5, + keywords = listOf("배송이 빨라요", "안내가 친절해요"), + comment = "선물 전달 과정이 만족스러웠습니다.", + createdAt = NOW + ), + result + ) + assertEquals(GIFT_ID, fixture.savedReview.giftId) + assertEquals(SENDER_ID, fixture.savedReview.senderMemberId) + assertEquals(5, fixture.savedReview.rating) + assertEquals("배송이 빨라요,안내가 친절해요", fixture.savedReview.keywords) + assertEquals("선물 전달 과정이 만족스러웠습니다.", fixture.savedReview.comment) + } + + @ParameterizedTest + @MethodSource("invalidGiftReviewScenarios") + @DisplayName("리뷰 작성은 보낸 팬의 전달 완료 선물, 1회, 유효한 별점과 내용만 허용한다") + fun shouldRejectInvalidGiftReview(scenario: InvalidGiftReviewScenario) { + val fixture = GiftApplicationFixture() + fixture.prepareGiftForReview( + status = scenario.status, + senderMemberId = scenario.senderMemberId, + reviewExists = scenario.reviewExists + ) + val requester = Member(password = "password", nickname = "requester").apply { id = scenario.requesterMemberId } + + val exception = assertThrows(SodaException::class.java) { + fixture.service.writeReview(requester, APPLICATION_NO, scenario.command) + } + + assertEquals("common.error.invalid_request", exception.messageKey) + Mockito.verify(fixture.reviewRepository, Mockito.never()).save(Mockito.any(GiftReview::class.java)) + } + + @ParameterizedTest + @MethodSource("invalidGiftDeliveryConfirmationScenarios") + @DisplayName("수령 확인은 받는 크리에이터의 검수 완료 선물에만 가능하고 배송 정보가 필요하다") + fun shouldRejectInvalidGiftDeliveryConfirmation(scenario: InvalidGiftDeliveryConfirmationScenario) { + val fixture = GiftApplicationFixture() + fixture.prepareGiftForDeliveryConfirmation( + status = scenario.status, + recipientMemberId = scenario.recipientMemberId, + deliveryExists = scenario.deliveryExists + ) + val requester = Member(password = "password", nickname = "requester").apply { id = scenario.requesterMemberId } + + val exception = assertThrows(SodaException::class.java) { + fixture.service.confirmDelivery(requester, APPLICATION_NO) + } + + assertEquals("common.error.invalid_request", exception.messageKey) + assertEquals(scenario.status, fixture.deliveryConfirmationGift.status) + assertEquals(null, fixture.deliveryConfirmationDelivery.deliveredAt) + } + + @ParameterizedTest + @MethodSource("invalidGiftRecipientAddressRegistrationScenarios") + @DisplayName("수령자 주소 등록은 받는 크리에이터, 허용 상태, 필수 주소와 두 동의가 필요하다") + fun shouldRejectInvalidGiftRecipientAddressRegistration(scenario: InvalidGiftRecipientAddressRegistrationScenario) { + // Given: 권한, 상태 또는 입력값이 유효하지 않은 선물을 준비한다. + val fixture = GiftApplicationFixture() + fixture.prepareGiftForRecipientAddress( + status = scenario.status, + recipientMemberId = scenario.recipientMemberId, + deliveryExists = scenario.deliveryExists + ) + val requester = Member(password = "password", nickname = "requester").apply { id = scenario.requesterMemberId } + + // When: 배송지 등록을 요청한다. + val exception = assertThrows(SodaException::class.java) { + fixture.service.registerRecipientAddress(requester, APPLICATION_NO, scenario.command) + } + + // Then: 공통 요청 오류를 반환하고 선물 상태와 수령 동의를 바꾸지 않는다. + assertEquals("common.error.invalid_request", exception.messageKey) + assertEquals(scenario.status, fixture.recipientAddressGift.status) + assertEquals(false, fixture.recipientAddressGift.recipientTermsAgreed) + assertEquals(false, fixture.recipientAddressGift.recipientPrivacyAgreed) + } + + @ParameterizedTest + @MethodSource("invalidGiftTrackingRegistrationScenarios") + @DisplayName("운송장 등록은 보낸 팬의 접수 상태에서 유효한 택배사와 운송장 번호가 있어야 한다") + fun shouldRejectInvalidGiftTrackingRegistration(scenario: InvalidGiftTrackingRegistrationScenario) { + // Given: 운송장 등록 권한, 상태, 입력값 또는 배송 정보가 유효하지 않은 선물을 준비한다. + val fixture = GiftApplicationFixture() + fixture.prepareGiftForTracking(scenario.status, scenario.senderMemberId, scenario.deliveryExists) + val requester = Member(password = "password", nickname = "requester").apply { id = scenario.requesterMemberId } + + // When: 운송장 등록을 요청한다. + val exception = assertThrows(SodaException::class.java) { + fixture.service.registerTracking(requester, APPLICATION_NO, scenario.command) + } + + // Then: 공통 요청 오류를 반환하고 선물 상태를 바꾸지 않는다. + assertEquals("common.error.invalid_request", exception.messageKey) + assertEquals(scenario.status, fixture.trackingGift.status) + } + + @ParameterizedTest + @MethodSource("invalidGiftCancellationScenarios") + @DisplayName("선물 취소는 보낸 팬의 접수 상태에서만 가능하다") + fun shouldRejectGiftCancellationForInvalidSenderOrStatus(scenario: InvalidGiftCancellationScenario) { + // Given: 취소 권한 또는 상태가 유효하지 않은 선물을 준비한다. + val fixture = GiftApplicationFixture() + fixture.prepareGiftForCancellation(scenario.status, scenario.senderMemberId) + val requester = Member(password = "password", nickname = "requester").apply { id = scenario.requesterMemberId } + + // When: 취소를 요청한다. + val exception = assertThrows(SodaException::class.java) { + fixture.service.cancelGift(requester, APPLICATION_NO) + } + + // Then: 환불하지 않고 공통 요청 오류를 반환한다. + assertEquals("common.error.invalid_request", exception.messageKey) + Mockito.verify(fixture.canPaymentService, Mockito.never()).refundGiftCan( + Mockito.anyLong(), + Mockito.anyLong() + ) + } + + @ParameterizedTest + @MethodSource("invalidGiftApplicationScenarios") + @DisplayName("선물 신청은 필수 발신자 정보, 동의, 활성 옵션 및 수신 크리에이터를 검증한다") + fun shouldRejectInvalidGiftApplication(scenario: InvalidGiftApplicationScenario) { + // Given: 하나의 신청 조건만 유효하지 않은 선물 신청을 준비한다. + val fixture = GiftApplicationFixture() + fixture.category.isActive = scenario.categoryActive + fixture.category.requiresDamageWaiver = scenario.requiresDamageWaiver + fixture.sizePrice.isActive = scenario.sizePriceActive + fixture.setCreatorExists(scenario.creatorExists) + + // When: 유효하지 않은 신청을 등록한다. + val exception = assertThrows(SodaException::class.java) { + fixture.service.registerGift(fixture.sender, scenario.command) + } + + // Then: 캔을 차감하지 않고 공통 요청 오류를 반환한다. + assertEquals("common.error.invalid_request", exception.messageKey) + Mockito.verifyNoInteractions(fixture.canPaymentService) + } + + private class CanPaymentFixture(member: Member, charge: Charge) { + private val canRepository = Mockito.mock(CanRepository::class.java) + private val memberRepository = Mockito.mock(MemberRepository::class.java) + private val chargeRepository = Mockito.mock(ChargeRepository::class.java) + private val useCanRepository = Mockito.mock(UseCanRepository::class.java) + private val useCanCalculateRepository = Mockito.mock(UseCanCalculateRepository::class.java) + private val useCanCalculates = mutableListOf() + lateinit var savedUseCan: UseCan + private set + + val service = CanPaymentService( + repository = canRepository, + memberRepository = memberRepository, + chargeRepository = chargeRepository, + useCanRepository = useCanRepository, + useCanCalculateRepository = useCanCalculateRepository, + messageSource = SodaMessageSource(), + langContext = LangContext(), + countryContext = CountryContext() + ) + + init { + Mockito.`when`(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.of(member)) + Mockito.`when`( + chargeRepository.getOldestChargeWhereRewardCanGreaterThan0(0L, MEMBER_ID, "web") + ).thenReturn(null) + Mockito.`when`( + chargeRepository.getOldestChargeWhereChargeCanGreaterThan0(0L, MEMBER_ID, "web") + ).thenReturn(charge) + Mockito.`when`(useCanRepository.save(Mockito.any(UseCan::class.java))).thenAnswer { invocation -> + invocation.getArgument(0).apply { + id = USE_CAN_ID + savedUseCan = this + } + } + Mockito.`when`(useCanRepository.findById(USE_CAN_ID)).thenAnswer { Optional.of(savedUseCan) } + Mockito.`when`(useCanCalculateRepository.save(Mockito.any(UseCanCalculate::class.java))).thenAnswer { + invocation -> + invocation.getArgument(0).also(useCanCalculates::add) + } + Mockito.`when`(useCanCalculateRepository.findByUseCanIdAndStatus(USE_CAN_ID)).thenReturn(useCanCalculates) + } + } + + private class GiftApplicationFixture { + val giftRepository = Mockito.mock(GiftRepository::class.java) + val deliveryRepository = Mockito.mock(GiftDeliveryRepository::class.java) + val reviewRepository = Mockito.mock(GiftReviewRepository::class.java) + val categoryRepository = Mockito.mock(GiftCategoryRepository::class.java) + val sizePriceRepository = Mockito.mock(GiftSizePriceRepository::class.java) + val memberRepository = Mockito.mock(MemberRepository::class.java) + val applicationNoGenerator = Mockito.mock(GiftApplicationNoGenerator::class.java) + val canPaymentService = Mockito.mock(CanPaymentService::class.java) + val giftPushService = Mockito.mock(GiftPushService::class.java) + val sender = Member(password = "password", nickname = "sender", container = "aos").apply { id = SENDER_ID } + val creator = Member(password = "password", nickname = "creator").apply { id = RECIPIENT_ID } + val category = GiftCategory("DOLL", "인형", false, true).apply { id = CATEGORY_ID } + val sizePrice = GiftSizePrice(GiftSize.SMALL, BASE_PRICE_CAN, SALE_PRICE_CAN, true) + lateinit var savedGift: Gift + lateinit var savedDelivery: GiftDelivery + lateinit var cancellationGift: Gift + lateinit var cancellationDelivery: GiftDelivery + lateinit var trackingGift: Gift + lateinit var trackingDelivery: GiftDelivery + lateinit var recipientAddressGift: Gift + lateinit var recipientAddressDelivery: GiftDelivery + lateinit var deliveryConfirmationGift: Gift + lateinit var deliveryConfirmationDelivery: GiftDelivery + lateinit var reviewGift: Gift + lateinit var savedReview: GiftReview + + val service = GiftCommandService( + giftRepository = giftRepository, + deliveryRepository = deliveryRepository, + reviewRepository = reviewRepository, + categoryRepository = categoryRepository, + sizePriceRepository = sizePriceRepository, + memberRepository = memberRepository, + applicationNoGenerator = applicationNoGenerator, + canPaymentService = canPaymentService, + giftPushService = giftPushService, + now = { NOW } + ) + + init { + setCreatorExists(true) + Mockito.`when`(categoryRepository.findById(CATEGORY_ID)).thenReturn(Optional.of(category)) + Mockito.`when`(sizePriceRepository.findBySizeCode(GiftSize.SMALL)).thenReturn(sizePrice) + Mockito.`when`(applicationNoGenerator.generate(NOW)).thenReturn(APPLICATION_NO) + Mockito.`when`(canPaymentService.spendGiftCan(SENDER_ID, SALE_PRICE_CAN, "aos")).thenReturn(USE_CAN_ID) + Mockito.`when`(giftRepository.save(Mockito.any(Gift::class.java))).thenAnswer { invocation -> + invocation.getArgument(0).apply { + id = GIFT_ID + savedGift = this + } + } + Mockito.`when`(deliveryRepository.save(Mockito.any(GiftDelivery::class.java))).thenAnswer { invocation -> + invocation.getArgument(0).also { savedDelivery = it } + } + Mockito.`when`(reviewRepository.save(Mockito.any(GiftReview::class.java))).thenAnswer { invocation -> + invocation.getArgument(0).apply { + id = REVIEW_ID + createdAt = NOW + savedReview = this + } + } + } + + fun setCreatorExists(exists: Boolean) { + Mockito.`when`(memberRepository.findCreatorByIdOrNull(RECIPIENT_ID)).thenReturn(creator.takeIf { exists }) + } + + fun prepareGiftForCancellation( + status: GiftStatus = GiftStatus.RECEIVED, + senderMemberId: Long = SENDER_ID + ) { + cancellationGift = Gift( + applicationNo = APPLICATION_NO, + senderMemberId = senderMemberId, + recipientMemberId = RECIPIENT_ID, + status = status, + sizeCode = GiftSize.SMALL, + categoryId = CATEGORY_ID, + categoryNameSnapshot = "인형", + salePriceCan = SALE_PRICE_CAN, + canUsageId = USE_CAN_ID, + senderTermsAgreed = true, + senderPrivacyAgreed = true, + senderTermsAgreedAt = NOW, + senderPrivacyAgreedAt = NOW, + damageWaiverAgreed = true + ).apply { id = GIFT_ID } + cancellationDelivery = GiftDelivery( + giftId = GIFT_ID, + senderName = "홍길동", + senderPhoneNumber = "01012345678", + senderZipCode = "06234", + senderAddress = "서울시 강남구", + trackingDeadlineAt = NOW.plusDays(3) + ) + Mockito.`when`(giftRepository.findByApplicationNo(APPLICATION_NO)).thenReturn(cancellationGift) + Mockito.`when`(deliveryRepository.findByGiftId(GIFT_ID)).thenReturn(cancellationDelivery) + } + + fun prepareGiftForTracking( + status: GiftStatus = GiftStatus.RECEIVED, + senderMemberId: Long = SENDER_ID, + deliveryExists: Boolean = true + ) { + trackingGift = Gift( + applicationNo = APPLICATION_NO, + senderMemberId = senderMemberId, + recipientMemberId = RECIPIENT_ID, + status = status, + sizeCode = GiftSize.SMALL, + categoryId = CATEGORY_ID, + categoryNameSnapshot = "인형", + salePriceCan = SALE_PRICE_CAN, + canUsageId = USE_CAN_ID, + senderTermsAgreed = true, + senderPrivacyAgreed = true, + senderTermsAgreedAt = NOW, + senderPrivacyAgreedAt = NOW, + damageWaiverAgreed = true + ).apply { id = GIFT_ID } + trackingDelivery = GiftDelivery( + giftId = GIFT_ID, + senderName = "홍길동", + senderPhoneNumber = "01012345678", + senderZipCode = "06234", + senderAddress = "서울시 강남구", + trackingDeadlineAt = NOW.plusDays(3) + ) + Mockito.`when`(giftRepository.findByApplicationNo(APPLICATION_NO)).thenReturn(trackingGift) + Mockito.`when`(deliveryRepository.findByGiftId(GIFT_ID)).thenReturn( + trackingDelivery.takeIf { deliveryExists } + ) + } + + fun prepareGiftForRecipientAddress( + status: GiftStatus = GiftStatus.TRACKING_REGISTERED, + recipientMemberId: Long = RECIPIENT_ID, + deliveryExists: Boolean = true + ) { + recipientAddressGift = Gift( + applicationNo = APPLICATION_NO, + senderMemberId = SENDER_ID, + recipientMemberId = recipientMemberId, + status = status, + sizeCode = GiftSize.SMALL, + categoryId = CATEGORY_ID, + categoryNameSnapshot = "인형", + salePriceCan = SALE_PRICE_CAN, + canUsageId = USE_CAN_ID, + senderTermsAgreed = true, + senderPrivacyAgreed = true, + senderTermsAgreedAt = NOW, + senderPrivacyAgreedAt = NOW, + damageWaiverAgreed = true + ).apply { id = GIFT_ID } + recipientAddressDelivery = GiftDelivery( + giftId = GIFT_ID, + senderName = "홍길동", + senderPhoneNumber = "01012345678", + senderZipCode = "06234", + senderAddress = "서울시 강남구", + trackingDeadlineAt = NOW.plusDays(3) + ) + Mockito.`when`(giftRepository.findByApplicationNo(APPLICATION_NO)).thenReturn(recipientAddressGift) + Mockito.`when`(deliveryRepository.findByGiftId(GIFT_ID)).thenReturn( + recipientAddressDelivery.takeIf { deliveryExists } + ) + } + + fun prepareGiftForDeliveryConfirmation( + status: GiftStatus = GiftStatus.INSPECTION_COMPLETED, + recipientMemberId: Long = RECIPIENT_ID, + deliveryExists: Boolean = true + ) { + deliveryConfirmationGift = Gift( + applicationNo = APPLICATION_NO, + senderMemberId = SENDER_ID, + recipientMemberId = recipientMemberId, + status = status, + sizeCode = GiftSize.SMALL, + categoryId = CATEGORY_ID, + categoryNameSnapshot = "인형", + salePriceCan = SALE_PRICE_CAN, + canUsageId = USE_CAN_ID, + senderTermsAgreed = true, + senderPrivacyAgreed = true, + senderTermsAgreedAt = NOW, + senderPrivacyAgreedAt = NOW, + damageWaiverAgreed = true + ).apply { id = GIFT_ID } + deliveryConfirmationDelivery = GiftDelivery( + giftId = GIFT_ID, + senderName = "홍길동", + senderPhoneNumber = "01012345678", + senderZipCode = "06234", + senderAddress = "서울시 강남구", + trackingDeadlineAt = NOW.plusDays(3) + ) + Mockito.`when`(giftRepository.findByApplicationNo(APPLICATION_NO)).thenReturn(deliveryConfirmationGift) + Mockito.`when`(deliveryRepository.findByGiftId(GIFT_ID)).thenReturn( + deliveryConfirmationDelivery.takeIf { deliveryExists } + ) + } + + fun prepareGiftForReview( + status: GiftStatus = GiftStatus.DELIVERED, + senderMemberId: Long = SENDER_ID, + reviewExists: Boolean = false + ) { + reviewGift = Gift( + applicationNo = APPLICATION_NO, + senderMemberId = senderMemberId, + recipientMemberId = RECIPIENT_ID, + status = status, + sizeCode = GiftSize.SMALL, + categoryId = CATEGORY_ID, + categoryNameSnapshot = "인형", + salePriceCan = SALE_PRICE_CAN, + canUsageId = USE_CAN_ID, + senderTermsAgreed = true, + senderPrivacyAgreed = true, + senderTermsAgreedAt = NOW, + senderPrivacyAgreedAt = NOW, + damageWaiverAgreed = true + ).apply { id = GIFT_ID } + Mockito.`when`(giftRepository.findByApplicationNo(APPLICATION_NO)).thenReturn(reviewGift) + Mockito.`when`(reviewRepository.findByGiftId(GIFT_ID)).thenReturn( + GiftReview(GIFT_ID, SENDER_ID, 5).takeIf { reviewExists } + ) + } + } + + data class InvalidGiftApplicationScenario( + val command: GiftApplicationCommand, + val categoryActive: Boolean = true, + val requiresDamageWaiver: Boolean = false, + val sizePriceActive: Boolean = true, + val creatorExists: Boolean = true + ) + + data class InvalidGiftCancellationScenario( + val requesterMemberId: Long, + val senderMemberId: Long, + val status: GiftStatus + ) + + data class InvalidGiftTrackingRegistrationScenario( + val requesterMemberId: Long = SENDER_ID, + val senderMemberId: Long = SENDER_ID, + val status: GiftStatus = GiftStatus.RECEIVED, + val command: GiftTrackingRegistrationCommand = validGiftTrackingRegistrationCommand(), + val deliveryExists: Boolean = true + ) + + data class InvalidGiftRecipientAddressRegistrationScenario( + val requesterMemberId: Long = RECIPIENT_ID, + val recipientMemberId: Long = RECIPIENT_ID, + val status: GiftStatus = GiftStatus.TRACKING_REGISTERED, + val command: GiftRecipientAddressRegistrationCommand = validGiftRecipientAddressRegistrationCommand(), + val deliveryExists: Boolean = true + ) + + data class InvalidGiftDeliveryConfirmationScenario( + val requesterMemberId: Long = RECIPIENT_ID, + val recipientMemberId: Long = RECIPIENT_ID, + val status: GiftStatus = GiftStatus.INSPECTION_COMPLETED, + val deliveryExists: Boolean = true + ) + + data class InvalidGiftReviewScenario( + val requesterMemberId: Long = SENDER_ID, + val senderMemberId: Long = SENDER_ID, + val status: GiftStatus = GiftStatus.DELIVERED, + val command: GiftReviewCommand = validGiftReviewCommand(), + val reviewExists: Boolean = false + ) + + private companion object { + const val MEMBER_ID = 1L + const val CHARGE_ID = 10L + const val USE_CAN_ID = 100L + const val INITIAL_CAN = 100 + const val GIFT_PRICE_CAN = 30 + const val SENDER_ID = 2L + const val OTHER_MEMBER_ID = 4L + const val RECIPIENT_ID = 3L + const val CATEGORY_ID = 4L + const val GIFT_ID = 5L + const val REVIEW_ID = 6L + const val APPLICATION_NO = "G20260929000001" + const val BASE_PRICE_CAN = 100 + const val SALE_PRICE_CAN = 80 + val NOW: LocalDateTime = LocalDateTime.of(2026, 9, 29, 12, 0) + + @JvmStatic + fun invalidGiftApplicationScenarios() = listOf( + InvalidGiftApplicationScenario(validGiftApplicationCommand().copy(senderName = "")), + InvalidGiftApplicationScenario(validGiftApplicationCommand().copy(senderPhoneNumber = "")), + InvalidGiftApplicationScenario(validGiftApplicationCommand().copy(senderZipCode = "")), + InvalidGiftApplicationScenario(validGiftApplicationCommand().copy(senderAddress = "")), + InvalidGiftApplicationScenario(validGiftApplicationCommand().copy(senderTermsAgreed = false)), + InvalidGiftApplicationScenario(validGiftApplicationCommand().copy(senderPrivacyAgreed = false)), + InvalidGiftApplicationScenario(validGiftApplicationCommand(), categoryActive = false), + InvalidGiftApplicationScenario( + validGiftApplicationCommand().copy(damageWaiverAgreed = false), + requiresDamageWaiver = true + ), + InvalidGiftApplicationScenario(validGiftApplicationCommand(), sizePriceActive = false), + InvalidGiftApplicationScenario(validGiftApplicationCommand(), creatorExists = false) + ) + + @JvmStatic + fun invalidGiftCancellationScenarios() = listOf( + InvalidGiftCancellationScenario(OTHER_MEMBER_ID, SENDER_ID, GiftStatus.RECEIVED), + InvalidGiftCancellationScenario(SENDER_ID, SENDER_ID, GiftStatus.TRACKING_REGISTERED) + ) + + @JvmStatic + fun invalidGiftTrackingRegistrationScenarios() = listOf( + InvalidGiftTrackingRegistrationScenario(requesterMemberId = OTHER_MEMBER_ID), + InvalidGiftTrackingRegistrationScenario(status = GiftStatus.TRACKING_REGISTERED), + InvalidGiftTrackingRegistrationScenario( + command = validGiftTrackingRegistrationCommand().copy(courierCompanyName = "") + ), + InvalidGiftTrackingRegistrationScenario( + command = validGiftTrackingRegistrationCommand().copy(courierCompanyName = "a".repeat(51)) + ), + InvalidGiftTrackingRegistrationScenario( + command = validGiftTrackingRegistrationCommand().copy(trackingNumber = "") + ), + InvalidGiftTrackingRegistrationScenario(deliveryExists = false) + ) + + @JvmStatic + fun recipientAddressRegistrableStatuses() = listOf( + GiftStatus.TRACKING_REGISTERED, + GiftStatus.ARRIVED_AT_MAILBOX, + GiftStatus.INSPECTION_COMPLETED + ) + + @JvmStatic + fun invalidGiftRecipientAddressRegistrationScenarios() = listOf( + InvalidGiftRecipientAddressRegistrationScenario(requesterMemberId = OTHER_MEMBER_ID), + InvalidGiftRecipientAddressRegistrationScenario(status = GiftStatus.RECEIVED), + InvalidGiftRecipientAddressRegistrationScenario(status = GiftStatus.DELIVERED), + InvalidGiftRecipientAddressRegistrationScenario(status = GiftStatus.UNDELIVERABLE), + InvalidGiftRecipientAddressRegistrationScenario(status = GiftStatus.CANCELED), + InvalidGiftRecipientAddressRegistrationScenario( + command = validGiftRecipientAddressRegistrationCommand().copy(name = "") + ), + InvalidGiftRecipientAddressRegistrationScenario( + command = validGiftRecipientAddressRegistrationCommand().copy(phoneNumber = "") + ), + InvalidGiftRecipientAddressRegistrationScenario( + command = validGiftRecipientAddressRegistrationCommand().copy(zipCode = "") + ), + InvalidGiftRecipientAddressRegistrationScenario( + command = validGiftRecipientAddressRegistrationCommand().copy(address = "") + ), + InvalidGiftRecipientAddressRegistrationScenario( + command = validGiftRecipientAddressRegistrationCommand().copy(recipientTermsAgreed = false) + ), + InvalidGiftRecipientAddressRegistrationScenario( + command = validGiftRecipientAddressRegistrationCommand().copy(recipientPrivacyAgreed = false) + ), + InvalidGiftRecipientAddressRegistrationScenario(deliveryExists = false) + ) + + @JvmStatic + fun invalidGiftDeliveryConfirmationScenarios() = listOf( + InvalidGiftDeliveryConfirmationScenario(requesterMemberId = OTHER_MEMBER_ID), + InvalidGiftDeliveryConfirmationScenario(status = GiftStatus.TRACKING_REGISTERED), + InvalidGiftDeliveryConfirmationScenario(status = GiftStatus.DELIVERED), + InvalidGiftDeliveryConfirmationScenario(deliveryExists = false) + ) + + @JvmStatic + fun invalidGiftReviewScenarios() = listOf( + InvalidGiftReviewScenario(requesterMemberId = OTHER_MEMBER_ID), + InvalidGiftReviewScenario(status = GiftStatus.INSPECTION_COMPLETED), + InvalidGiftReviewScenario(reviewExists = true), + InvalidGiftReviewScenario(command = validGiftReviewCommand().copy(rating = 0)), + InvalidGiftReviewScenario(command = validGiftReviewCommand().copy(rating = 6)), + InvalidGiftReviewScenario(command = validGiftReviewCommand().copy(keywords = listOf("a".repeat(256)))), + InvalidGiftReviewScenario(command = validGiftReviewCommand().copy(comment = "a".repeat(256))) + ) + + fun validGiftApplicationCommand() = GiftApplicationCommand( + recipientMemberId = RECIPIENT_ID, + senderName = "홍길동", + senderPhoneNumber = "01012345678", + senderZipCode = "06234", + senderAddress = "서울시 강남구", + senderAddressDetail = "101동 1001호", + sizeCode = GiftSize.SMALL, + categoryId = CATEGORY_ID, + senderTermsAgreed = true, + senderPrivacyAgreed = true, + damageWaiverAgreed = true + ) + + fun validGiftTrackingRegistrationCommand() = GiftTrackingRegistrationCommand( + courierCompanyName = "CJ대한통운", + trackingNumber = "1234567890" + ) + + fun validGiftRecipientAddressRegistrationCommand() = GiftRecipientAddressRegistrationCommand( + name = "김소다", + phoneNumber = "01098765432", + zipCode = "04524", + address = "서울시 중구", + addressDetail = "202호", + recipientTermsAgreed = true, + recipientPrivacyAgreed = true + ) + + fun validGiftReviewCommand() = GiftReviewCommand( + rating = 5, + keywords = listOf("배송이 빨라요", "안내가 친절해요"), + comment = "선물 전달 과정이 만족스러웠습니다." + ) + } +} From fcb1c1e8d1f5f25ae6d1c37745f790fb6590cdae Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 30 Sep 2026 13:37:26 +0900 Subject: [PATCH 11/53] =?UTF-8?q?feat(gift):=20=EC=84=A0=EB=AC=BC=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=84=9C=EB=B9=84=EC=8A=A4=EB=A5=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../v2/gift/application/GiftQueryService.kt | 259 +++++++++++ .../gift/application/GiftQueryServiceTest.kt | 408 ++++++++++++++++++ 2 files changed, 667 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt new file mode 100644 index 00000000..503c4c37 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt @@ -0,0 +1,259 @@ +package kr.co.vividnext.sodalive.v2.gift.application + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository +import kr.co.vividnext.sodalive.v2.gift.domain.GiftDirection +import kr.co.vividnext.sodalive.v2.gift.domain.GiftListType +import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +data class GiftFormOptionsResult( + val sizes: List, + val categories: List +) + +data class GiftListResult( + val totalCount: Long, + val items: List, + val page: Int, + val size: Int, + val hasNext: Boolean +) + +data class GiftListItemResult( + val applicationNo: String, + val direction: GiftDirection, + val status: GiftStatus, + val statusName: String, + val priceCan: Int, + val categoryName: String, + val sizeName: String, + val createdAt: LocalDateTime? +) + +data class GiftDetailResult( + val applicationNo: String, + val direction: GiftDirection, + val status: GiftStatus, + val statusName: String, + val giftInfo: GiftDetailInfoResult, + val senderInfo: GiftAddressResult?, + val recipientAddress: GiftAddressResult?, + val mailbox: GiftMailboxResult?, + val trackingRequired: Boolean, + val recipientAddressRequired: Boolean, + val recipientAddressDeadlineAt: LocalDateTime?, + val delivery: GiftDeliveryInfoResult, + val statusTimeline: List +) + +data class GiftDetailInfoResult( + val recipientCreatorNickname: String?, + val senderNickname: String?, + val sizeName: String, + val categoryName: String, + val applicationNo: String?, + val paidCan: Int?, + val tracking: String?, + val shippingRequestedAt: LocalDateTime? +) + +data class GiftAddressResult( + val name: String, + val phoneNumber: String, + val address: String +) + +data class GiftMailboxResult( + val name: String, + val address: String, + val phoneNumber: String +) + +data class GiftDeliveryInfoResult( + val canceledAt: LocalDateTime?, + val undeliverableAt: LocalDateTime?, + val undeliverableReason: String? +) + +data class GiftStatusTimelineResult( + val status: GiftStatus, + val statusName: String, + val occurredAt: LocalDateTime? +) + +@Service +class GiftQueryService( + private val categoryRepository: GiftCategoryRepository, + private val sizePriceRepository: GiftSizePriceRepository, + private val giftRepository: GiftRepository, + private val deliveryRepository: GiftDeliveryRepository, + private val memberRepository: MemberRepository +) { + @Transactional(readOnly = true) + fun getFormOptions() = GiftFormOptionsResult( + sizes = sizePriceRepository.findAll().filter { it.isActive }.map { + GiftSizePriceResult(it.sizeCode, it.sizeCode.displayName, it.basePriceCan, it.salePriceCan, it.isActive) + }, + categories = categoryRepository.findAll().filter { it.isActive }.map { + GiftCategoryResult(it.id!!, it.categoryCode, it.name, it.requiresDamageWaiver, it.isActive) + } + ) + + @Transactional(readOnly = true) + fun getGifts(memberId: Long, type: GiftListType, page: Int, size: Int): GiftListResult { + val normalizedPage = page.coerceAtLeast(0) + val normalizedSize = if (size <= 0) DEFAULT_PAGE_SIZE else size + val pageable = PageRequest.of(normalizedPage, normalizedSize, Sort.by(Sort.Direction.DESC, "createdAt", "id")) + val gifts = when (type) { + GiftListType.SENT -> giftRepository.findBySenderMemberId(memberId, pageable) + GiftListType.RECEIVED -> giftRepository.findByRecipientMemberIdAndStatusIn( + memberId, + RECIPIENT_VISIBLE_STATUSES, + pageable + ) + GiftListType.ALL -> giftRepository.findBySenderMemberIdOrRecipientMemberIdAndStatusIn( + memberId, + memberId, + RECIPIENT_VISIBLE_STATUSES, + pageable + ) + } + + return GiftListResult( + totalCount = gifts.totalElements, + items = gifts.content.map { it.toGiftListItem(memberId) }, + page = normalizedPage, + size = normalizedSize, + hasNext = gifts.hasNext() + ) + } + + @Transactional(readOnly = true) + fun getGiftDetail(memberId: Long, applicationNo: String): GiftDetailResult { + val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest() + val delivery = deliveryRepository.findByGiftId(gift.id ?: throw invalidRequest()) ?: throw invalidRequest() + val direction = gift.directionFor(memberId) + val sender = memberRepository.findByIdOrNull(gift.senderMemberId) ?: throw invalidRequest() + val recipient = memberRepository.findByIdOrNull(gift.recipientMemberId) ?: throw invalidRequest() + val recipientAddress = if (direction == GiftDirection.RECEIVED) delivery.toRecipientAddressResult() else null + + return GiftDetailResult( + applicationNo = gift.applicationNo, + direction = direction, + status = gift.status, + statusName = gift.status.displayName, + giftInfo = GiftDetailInfoResult( + recipientCreatorNickname = recipient.nickname.takeIf { direction == GiftDirection.SENT }, + senderNickname = sender.nickname.takeIf { direction == GiftDirection.RECEIVED }, + sizeName = gift.sizeCode.displayName, + categoryName = gift.categoryNameSnapshot, + applicationNo = gift.applicationNo.takeIf { direction == GiftDirection.SENT }, + paidCan = gift.salePriceCan.takeIf { direction == GiftDirection.SENT }, + tracking = delivery.tracking().takeIf { direction == GiftDirection.SENT }, + shippingRequestedAt = gift.createdAt.takeIf { direction == GiftDirection.RECEIVED } + ), + senderInfo = delivery.toSenderInfo().takeIf { direction == GiftDirection.SENT }, + recipientAddress = recipientAddress, + mailbox = null, + trackingRequired = direction == GiftDirection.SENT && gift.status == GiftStatus.RECEIVED, + recipientAddressRequired = direction == GiftDirection.RECEIVED && recipientAddress == null && + gift.status !in TERMINAL_STATUSES, + recipientAddressDeadlineAt = delivery.recipientAddressDeadlineAt, + delivery = GiftDeliveryInfoResult( + canceledAt = delivery.canceledAt, + undeliverableAt = delivery.undeliverableAt, + undeliverableReason = delivery.undeliverableReason + ), + statusTimeline = gift.toStatusTimeline(delivery) + ) + } + + private fun Gift.toGiftListItem(memberId: Long) = GiftListItemResult( + applicationNo = applicationNo, + direction = if (senderMemberId == memberId) GiftDirection.SENT else GiftDirection.RECEIVED, + status = status, + statusName = status.displayName, + priceCan = salePriceCan, + categoryName = categoryNameSnapshot, + sizeName = sizeCode.displayName, + createdAt = createdAt + ) + + private fun Gift.directionFor(memberId: Long) = when { + senderMemberId == memberId -> GiftDirection.SENT + recipientMemberId == memberId && status in RECIPIENT_VISIBLE_STATUSES -> GiftDirection.RECEIVED + else -> throw invalidRequest() + } + + private fun GiftDelivery.toSenderInfo() = GiftAddressResult( + name = senderName, + phoneNumber = senderPhoneNumber, + address = formatAddress(senderZipCode, senderAddress, senderAddressDetail) + ) + + private fun GiftDelivery.toRecipientAddressResult(): GiftAddressResult? { + val name = recipientName ?: return null + val phoneNumber = recipientPhoneNumber ?: return null + val zipCode = recipientZipCode ?: return null + val address = recipientAddress ?: return null + + return GiftAddressResult(name, phoneNumber, formatAddress(zipCode, address, recipientAddressDetail)) + } + + private fun GiftDelivery.tracking() = if (courierCompanyName != null && trackingNumber != null) { + "${courierCompanyName}_$trackingNumber" + } else { + null + } + + private fun Gift.toStatusTimeline(delivery: GiftDelivery) = NORMAL_PROGRESS_STATUSES.map { timelineStatus -> + GiftStatusTimelineResult( + status = timelineStatus, + statusName = timelineStatus.displayName, + occurredAt = when (timelineStatus) { + GiftStatus.RECEIVED -> createdAt + GiftStatus.TRACKING_REGISTERED -> delivery.trackingRegisteredAt + GiftStatus.ARRIVED_AT_MAILBOX -> delivery.arrivedAtMailboxAt + GiftStatus.INSPECTION_COMPLETED -> delivery.inspectionCompletedAt + GiftStatus.DELIVERED -> delivery.deliveredAt + GiftStatus.UNDELIVERABLE, GiftStatus.CANCELED -> null + } + ) + } + + private fun formatAddress(zipCode: String, address: String, detail: String?) = + "($zipCode) $address" + if (detail.isNullOrBlank()) "" else ", $detail" + + private fun invalidRequest() = SodaException(messageKey = "common.error.invalid_request") + + private companion object { + const val DEFAULT_PAGE_SIZE = 20 + val RECIPIENT_VISIBLE_STATUSES = setOf( + GiftStatus.TRACKING_REGISTERED, + GiftStatus.ARRIVED_AT_MAILBOX, + GiftStatus.INSPECTION_COMPLETED, + GiftStatus.DELIVERED, + GiftStatus.UNDELIVERABLE + ) + val NORMAL_PROGRESS_STATUSES = listOf( + GiftStatus.RECEIVED, + GiftStatus.TRACKING_REGISTERED, + GiftStatus.ARRIVED_AT_MAILBOX, + GiftStatus.INSPECTION_COMPLETED, + GiftStatus.DELIVERED + ) + val TERMINAL_STATUSES = setOf(GiftStatus.DELIVERED, GiftStatus.UNDELIVERABLE, GiftStatus.CANCELED) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt new file mode 100644 index 00000000..ce55aae2 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt @@ -0,0 +1,408 @@ +package kr.co.vividnext.sodalive.v2.gift.application + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePrice +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository +import kr.co.vividnext.sodalive.v2.gift.domain.GiftDirection +import kr.co.vividnext.sodalive.v2.gift.domain.GiftListType +import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize +import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import java.time.LocalDateTime + +@DataJpaTest( + properties = [ + "spring.cache.type=none", + "spring.datasource.url=jdbc:h2:mem:gift-query;MODE=MySQL;NON_KEYWORDS=VALUE" + ] +) +@Import(QueryDslConfig::class) +class GiftQueryServiceTest @Autowired constructor( + private val categoryRepository: GiftCategoryRepository, + private val sizePriceRepository: GiftSizePriceRepository, + private val giftRepository: GiftRepository, + private val deliveryRepository: GiftDeliveryRepository, + private val memberRepository: MemberRepository +) { + @Test + fun `폼 옵션은 활성 카테고리와 활성 사이즈 가격만 반환한다`() { + val activeCategory = categoryRepository.save(GiftCategory("DOLL", "인형", true, true)) + categoryRepository.save(GiftCategory("FOOD", "간식", false, false)) + sizePriceRepository.save(GiftSizePrice(GiftSize.SMALL, 100, 80, true)) + sizePriceRepository.save(GiftSizePrice(GiftSize.MEDIUM, 150, 120, false)) + + val result = queryService().getFormOptions() + + assertEquals(listOf(GiftSize.SMALL), result.sizes.map { it.sizeCode }) + assertEquals(listOf(100), result.sizes.map { it.basePriceCan }) + assertEquals(listOf(80), result.sizes.map { it.salePriceCan }) + assertEquals(listOf(activeCategory.id), result.categories.map { it.categoryId }) + assertEquals("인형", result.categories.single().name) + assertEquals(true, result.categories.single().requiresDamageWaiver) + } + + @Test + @DisplayName("보낸 선물은 접수 직후 선물함에 노출된다") + fun shouldListReceivedGiftForSender() { + saveGift("G-SENT-RECEIVED", senderMemberId = MEMBER_ID, recipientMemberId = 2L, status = GiftStatus.RECEIVED) + + val result = queryService().getGifts( + memberId = MEMBER_ID, + type = GiftListType.SENT, + page = 0, + size = 20 + ) + + assertEquals(1L, result.totalCount) + assertEquals(1, result.items.size) + assertEquals("G-SENT-RECEIVED", result.items.single().applicationNo) + assertEquals(GiftDirection.SENT, result.items.single().direction) + assertEquals(GiftStatus.RECEIVED, result.items.single().status) + assertEquals(GiftStatus.RECEIVED.displayName, result.items.single().statusName) + assertEquals(80, result.items.single().priceCan) + assertEquals("인형", result.items.single().categoryName) + assertEquals("소형", result.items.single().sizeName) + assertNotNull(result.items.single().createdAt) + } + + @Test + @DisplayName("받은 선물은 운송장 등록 이후 상태만 선물함에 노출된다") + fun shouldExcludeReceivedOnlyGiftAndListVisibleGiftForRecipient() { + saveGift("G-RECEIVED-HIDDEN", senderMemberId = 2L, recipientMemberId = MEMBER_ID, status = GiftStatus.RECEIVED) + saveGift( + "G-RECEIVED-VISIBLE", + senderMemberId = 3L, + recipientMemberId = MEMBER_ID, + status = GiftStatus.TRACKING_REGISTERED + ) + + val result = queryService().getGifts( + memberId = MEMBER_ID, + type = GiftListType.RECEIVED, + page = 0, + size = 20 + ) + + assertEquals(1L, result.totalCount) + assertEquals(listOf("G-RECEIVED-VISIBLE"), result.items.map { it.applicationNo }) + assertEquals(GiftDirection.RECEIVED, result.items.single().direction) + } + + @Test + @DisplayName("전체 선물함은 보낸 선물과 노출 가능한 받은 선물을 방향과 함께 반환한다") + fun shouldListSentAndVisibleReceivedGiftsWithDirectionsForAll() { + saveGift("G-ALL-SENT", senderMemberId = MEMBER_ID, recipientMemberId = 2L, status = GiftStatus.CANCELED) + saveGift( + "G-ALL-RECEIVED", + senderMemberId = 3L, + recipientMemberId = MEMBER_ID, + status = GiftStatus.ARRIVED_AT_MAILBOX + ) + saveGift("G-ALL-HIDDEN", senderMemberId = 4L, recipientMemberId = MEMBER_ID, status = GiftStatus.RECEIVED) + + val result = queryService().getGifts( + memberId = MEMBER_ID, + type = GiftListType.ALL, + page = 0, + size = 20 + ) + + assertEquals(2L, result.totalCount) + assertEquals( + mapOf("G-ALL-SENT" to GiftDirection.SENT, "G-ALL-RECEIVED" to GiftDirection.RECEIVED), + result.items.associate { it.applicationNo to it.direction } + ) + } + + @Test + @DisplayName("선물함 page와 size는 음수 또는 0이면 기본값으로 보정된다") + fun shouldNormalizeGiftListPageAndSize() { + saveGift("G-PAGING", senderMemberId = MEMBER_ID, recipientMemberId = 2L, status = GiftStatus.RECEIVED) + + val result = queryService().getGifts( + memberId = MEMBER_ID, + type = GiftListType.SENT, + page = -1, + size = 0 + ) + + assertEquals(0, result.page) + assertEquals(20, result.size) + assertEquals(false, result.hasNext) + assertEquals(listOf("G-PAGING"), result.items.map { it.applicationNo }) + } + + @Test + @DisplayName("발신자는 자신의 선물 상세와 발신자 정보만 조회한다") + fun shouldReturnSentGiftDetailWithoutRecipientAddress() { + val sender = saveMember("fan") + val recipient = saveMember("creator") + val gift = saveGift( + applicationNo = "G-SENT-DETAIL", + senderMemberId = sender.id!!, + recipientMemberId = recipient.id!!, + status = GiftStatus.RECEIVED + ) + val delivery = saveDelivery(gift) + delivery.recipientName = "수신자" + delivery.recipientPhoneNumber = "01098765432" + delivery.recipientZipCode = "04524" + delivery.recipientAddress = "서울시 중구" + delivery.recipientAddressDetail = "202호" + + val result = queryService().getGiftDetail(sender.id!!, gift.applicationNo) + + assertEquals(GiftDirection.SENT, result.direction) + assertEquals(recipient.nickname, result.giftInfo.recipientCreatorNickname) + assertNull(result.giftInfo.senderNickname) + assertEquals("소형", result.giftInfo.sizeName) + assertEquals("인형", result.giftInfo.categoryName) + assertEquals(gift.applicationNo, result.giftInfo.applicationNo) + assertEquals(80, result.giftInfo.paidCan) + assertNull(result.giftInfo.tracking) + assertNull(result.giftInfo.shippingRequestedAt) + assertEquals("발신자", result.senderInfo?.name) + assertEquals("01012345678", result.senderInfo?.phoneNumber) + assertEquals("(06234) 서울시 강남구, 101동 1001호", result.senderInfo?.address) + assertNull(result.recipientAddress) + assertNull(result.mailbox) + assertEquals(true, result.trackingRequired) + assertEquals(false, result.recipientAddressRequired) + assertEquals( + listOf( + GiftStatus.RECEIVED, + GiftStatus.TRACKING_REGISTERED, + GiftStatus.ARRIVED_AT_MAILBOX, + GiftStatus.INSPECTION_COMPLETED, + GiftStatus.DELIVERED + ), + result.statusTimeline.map { it.status } + ) + assertNotNull(result.statusTimeline[0].occurredAt) + assertNull(result.statusTimeline[1].occurredAt) + } + + @Test + @DisplayName("수신자는 노출된 선물 상세에서 자신의 배송지와 발신자 닉네임만 조회한다") + fun shouldReturnReceivedGiftDetailWithoutSenderInformation() { + val sender = saveMember("fan") + val recipient = saveMember("creator") + val gift = saveGift( + applicationNo = "G-RECEIVED-DETAIL", + senderMemberId = sender.id!!, + recipientMemberId = recipient.id!!, + status = GiftStatus.TRACKING_REGISTERED + ) + saveDelivery(gift).apply { + trackingRegisteredAt = NOW.plusHours(1) + recipientName = "수신자" + recipientPhoneNumber = "01098765432" + recipientZipCode = "04524" + recipientAddress = "서울시 중구" + recipientAddressDetail = "202호" + } + + val result = queryService().getGiftDetail(recipient.id!!, gift.applicationNo) + + assertEquals(GiftDirection.RECEIVED, result.direction) + assertNull(result.giftInfo.recipientCreatorNickname) + assertEquals(sender.nickname, result.giftInfo.senderNickname) + assertEquals("소형", result.giftInfo.sizeName) + assertEquals("인형", result.giftInfo.categoryName) + assertNull(result.giftInfo.applicationNo) + assertNull(result.giftInfo.paidCan) + assertNull(result.giftInfo.tracking) + assertNotNull(result.giftInfo.shippingRequestedAt) + assertNull(result.senderInfo) + assertEquals("수신자", result.recipientAddress?.name) + assertEquals("01098765432", result.recipientAddress?.phoneNumber) + assertEquals("(04524) 서울시 중구, 202호", result.recipientAddress?.address) + assertNull(result.mailbox) + assertEquals(false, result.trackingRequired) + assertEquals(false, result.recipientAddressRequired) + assertNotNull(result.statusTimeline[0].occurredAt) + assertEquals(NOW.plusHours(1), result.statusTimeline[1].occurredAt) + assertNull(result.statusTimeline[2].occurredAt) + } + + @Test + @DisplayName("상세 조회는 수신자 노출 조건과 관계없는 회원 및 배송 정보 누락을 거부한다") + fun shouldRejectHiddenOrUnrelatedGiftDetailAndMissingDelivery() { + val sender = saveMember("fan") + val recipient = saveMember("creator") + val other = saveMember("other") + val hiddenGift = saveGift( + applicationNo = "G-HIDDEN-DETAIL", + senderMemberId = sender.id!!, + recipientMemberId = recipient.id!!, + status = GiftStatus.RECEIVED + ) + saveDelivery(hiddenGift) + val visibleGift = saveGift( + applicationNo = "G-VISIBLE-DETAIL", + senderMemberId = sender.id!!, + recipientMemberId = recipient.id!!, + status = GiftStatus.TRACKING_REGISTERED + ) + saveDelivery(visibleGift) + val missingDeliveryGift = saveGift( + applicationNo = "G-MISSING-DELIVERY", + senderMemberId = sender.id!!, + recipientMemberId = recipient.id!!, + status = GiftStatus.RECEIVED + ) + val service = queryService() + + val hiddenException = assertThrows(SodaException::class.java) { + service.getGiftDetail(recipient.id!!, hiddenGift.applicationNo) + } + val unrelatedException = assertThrows(SodaException::class.java) { + service.getGiftDetail(other.id!!, visibleGift.applicationNo) + } + val missingDeliveryException = assertThrows(SodaException::class.java) { + service.getGiftDetail(sender.id!!, missingDeliveryGift.applicationNo) + } + + assertEquals("common.error.invalid_request", hiddenException.messageKey) + assertEquals("common.error.invalid_request", unrelatedException.messageKey) + assertEquals("common.error.invalid_request", missingDeliveryException.messageKey) + } + + @Test + @DisplayName("수신자 배송지 입력 필요 여부는 미입력인 진행 상태에서만 참이다") + fun shouldRequireRecipientAddressOnlyForNonTerminalReceivedGift() { + val sender = saveMember("fan") + val recipient = saveMember("creator") + val pendingGift = saveGift( + applicationNo = "G-ADDRESS-PENDING", + senderMemberId = sender.id!!, + recipientMemberId = recipient.id!!, + status = GiftStatus.TRACKING_REGISTERED + ) + saveDelivery(pendingGift).trackingRegisteredAt = NOW + val terminalGift = saveGift( + applicationNo = "G-ADDRESS-TERMINAL", + senderMemberId = sender.id!!, + recipientMemberId = recipient.id!!, + status = GiftStatus.UNDELIVERABLE + ) + saveDelivery(terminalGift).apply { + trackingRegisteredAt = NOW + undeliverableAt = NOW.plusHours(1) + undeliverableReason = "배송지 미입력 기한 초과" + } + val service = queryService() + + val pendingResult = service.getGiftDetail(recipient.id!!, pendingGift.applicationNo) + val terminalResult = service.getGiftDetail(recipient.id!!, terminalGift.applicationNo) + + assertEquals(true, pendingResult.recipientAddressRequired) + assertNull(pendingResult.recipientAddress) + assertEquals(false, terminalResult.recipientAddressRequired) + } + + @Test + @DisplayName("취소와 전달 불가 상세는 정상 진행 타임라인과 종료 정보를 함께 반환한다") + fun shouldReturnTerminalInformationWithNormalProgressTimeline() { + val sender = saveMember("fan") + val recipient = saveMember("creator") + val canceledGift = saveGift( + applicationNo = "G-CANCELED-DETAIL", + senderMemberId = sender.id!!, + recipientMemberId = recipient.id!!, + status = GiftStatus.CANCELED + ) + saveDelivery(canceledGift).canceledAt = NOW.plusHours(1) + val undeliverableGift = saveGift( + applicationNo = "G-UNDELIVERABLE-X", + senderMemberId = sender.id!!, + recipientMemberId = recipient.id!!, + status = GiftStatus.UNDELIVERABLE + ) + saveDelivery(undeliverableGift).apply { + trackingRegisteredAt = NOW.plusHours(1) + arrivedAtMailboxAt = NOW.plusHours(2) + undeliverableAt = NOW.plusHours(3) + undeliverableReason = "검수 실패" + } + val service = queryService() + + val canceledResult = service.getGiftDetail(sender.id!!, canceledGift.applicationNo) + val undeliverableResult = service.getGiftDetail(sender.id!!, undeliverableGift.applicationNo) + + assertEquals(NOW.plusHours(1), canceledResult.delivery.canceledAt) + assertNull(canceledResult.statusTimeline[1].occurredAt) + assertEquals(NOW.plusHours(1), undeliverableResult.statusTimeline[1].occurredAt) + assertEquals(NOW.plusHours(2), undeliverableResult.statusTimeline[2].occurredAt) + assertNull(undeliverableResult.statusTimeline[3].occurredAt) + assertEquals(NOW.plusHours(3), undeliverableResult.delivery.undeliverableAt) + assertEquals("검수 실패", undeliverableResult.delivery.undeliverableReason) + } + + private fun saveGift( + applicationNo: String, + senderMemberId: Long, + recipientMemberId: Long, + status: GiftStatus + ) = giftRepository.save( + Gift( + applicationNo = applicationNo, + senderMemberId = senderMemberId, + recipientMemberId = recipientMemberId, + status = status, + sizeCode = GiftSize.SMALL, + categoryId = 1L, + categoryNameSnapshot = "인형", + salePriceCan = 80, + canUsageId = 1L, + senderTermsAgreed = true, + senderPrivacyAgreed = true, + senderTermsAgreedAt = NOW, + senderPrivacyAgreedAt = NOW + ) + ) + + private fun saveMember(nickname: String) = memberRepository.save(Member(password = "password", nickname = nickname)) + + private fun saveDelivery(gift: Gift) = deliveryRepository.save( + GiftDelivery( + giftId = gift.id!!, + senderName = "발신자", + senderPhoneNumber = "01012345678", + senderZipCode = "06234", + senderAddress = "서울시 강남구", + senderAddressDetail = "101동 1001호", + trackingDeadlineAt = NOW.plusDays(3) + ) + ) + + private fun queryService() = GiftQueryService( + categoryRepository, + sizePriceRepository, + giftRepository, + deliveryRepository, + memberRepository + ) + + private companion object { + const val MEMBER_ID = 1L + val NOW: LocalDateTime = LocalDateTime.of(2026, 9, 29, 12, 0) + } +} From 6bf4a682bf3010796d2c71d76f77bd0d6b8f9385 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 30 Sep 2026 13:37:44 +0900 Subject: [PATCH 12/53] =?UTF-8?q?feat(gift):=20=EC=84=A0=EB=AC=BC=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=84=9C=EB=B9=84=EC=8A=A4?= =?UTF-8?q?=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../v2/gift/application/GiftAdminService.kt | 210 +++++++++++++++++ .../gift/application/GiftAdminServiceTest.kt | 223 ++++++++++++++++++ 2 files changed, 433 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminService.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminServiceTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminService.kt new file mode 100644 index 00000000..c61bcc2d --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminService.kt @@ -0,0 +1,210 @@ +package kr.co.vividnext.sodalive.v2.gift.application + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePrice +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository +import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize +import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +data class GiftCategoryCommand( + val categoryCode: String, + val name: String, + val requiresDamageWaiver: Boolean, + val isActive: Boolean +) + +data class GiftCategoryResult( + val categoryId: Long, + val categoryCode: String, + val name: String, + val requiresDamageWaiver: Boolean, + val isActive: Boolean +) + +data class GiftSizePriceCommand( + val basePriceCan: Int, + val salePriceCan: Int, + val isActive: Boolean +) + +data class GiftSizePriceResult( + val sizeCode: GiftSize, + val sizeName: String, + val basePriceCan: Int, + val salePriceCan: Int, + val isActive: Boolean +) + +data class GiftOperationStatusResult( + val applicationNo: String, + val status: GiftStatus, + val statusName: String, + val occurredAt: LocalDateTime +) + +@Service +class GiftAdminService( + private val categoryRepository: GiftCategoryRepository, + private val sizePriceRepository: GiftSizePriceRepository, + private val giftRepository: GiftRepository, + private val giftDeliveryRepository: GiftDeliveryRepository, + private val giftPushService: GiftPushService +) { + @Transactional(readOnly = true) + fun getCategories(): List = categoryRepository.findAll().map { it.toResult() } + + @Transactional + fun createCategory(command: GiftCategoryCommand): GiftCategoryResult { + validateCategory(command) + return categoryRepository.save( + GiftCategory( + categoryCode = command.categoryCode, + name = command.name, + requiresDamageWaiver = command.requiresDamageWaiver, + isActive = command.isActive + ) + ).toResult() + } + + @Transactional + fun updateCategory(categoryId: Long, command: GiftCategoryCommand): GiftCategoryResult { + validateCategory(command) + val category = categoryRepository.findByIdOrNull(categoryId) ?: throw invalidRequest() + category.name = command.name + category.requiresDamageWaiver = command.requiresDamageWaiver + category.isActive = command.isActive + return category.toResult() + } + + @Transactional + fun deleteCategory(categoryId: Long): GiftCategoryResult { + val category = categoryRepository.findByIdOrNull(categoryId) ?: throw invalidRequest() + category.isActive = false + return category.toResult() + } + + @Transactional(readOnly = true) + fun getSizePrices(): List = sizePriceRepository.findAll().map { it.toResult() } + + @Transactional + fun updateSizePrice(sizeCode: GiftSize, command: GiftSizePriceCommand): GiftSizePriceResult { + validateSizePrice(command) + val sizePrice = sizePriceRepository.findBySizeCode(sizeCode) + ?: GiftSizePrice(sizeCode = sizeCode, basePriceCan = command.basePriceCan, salePriceCan = command.salePriceCan) + sizePrice.basePriceCan = command.basePriceCan + sizePrice.salePriceCan = command.salePriceCan + sizePrice.isActive = command.isActive + return sizePriceRepository.save(sizePrice).toResult() + } + + @Transactional + fun arriveMailbox(applicationNo: String, now: LocalDateTime = LocalDateTime.now()): GiftOperationStatusResult { + val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest() + if (gift.status != GiftStatus.TRACKING_REGISTERED) throw invalidRequest() + val delivery = giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)) ?: throw invalidRequest() + + gift.status = GiftStatus.ARRIVED_AT_MAILBOX + delivery.arrivedAtMailboxAt = now + giftPushService.sendArrivedAtMailboxToSender(gift.applicationNo, gift.senderMemberId) + giftPushService.sendArrivedAtMailboxToRecipient(gift.applicationNo, gift.recipientMemberId) + + return GiftOperationStatusResult(gift.applicationNo, gift.status, gift.status.displayName, now) + } + + @Transactional + fun completeInspection(applicationNo: String, now: LocalDateTime = LocalDateTime.now()): GiftOperationStatusResult { + val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest() + if (gift.status != GiftStatus.ARRIVED_AT_MAILBOX) throw invalidRequest() + val delivery = giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)) ?: throw invalidRequest() + + gift.status = GiftStatus.INSPECTION_COMPLETED + delivery.inspectionCompletedAt = now + giftPushService.sendInspectionCompleted(gift.applicationNo, gift.recipientMemberId) + + return GiftOperationStatusResult(gift.applicationNo, gift.status, gift.status.displayName, now) + } + + @Transactional + fun markUndeliverable( + applicationNo: String, + reason: String, + now: LocalDateTime = LocalDateTime.now() + ): GiftOperationStatusResult { + val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest() + if (gift.status !in UNDELIVERABLE_ALLOWED_STATUSES || reason.isBlank() || reason.length > 255) { + throw invalidRequest() + } + val delivery = giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)) ?: throw invalidRequest() + + gift.status = GiftStatus.UNDELIVERABLE + delivery.undeliverableAt = now + delivery.undeliverableReason = reason + giftPushService.sendUndeliverableToSender(gift.applicationNo, gift.senderMemberId) + giftPushService.sendUndeliverableToRecipient(gift.applicationNo, gift.recipientMemberId) + + return GiftOperationStatusResult(gift.applicationNo, gift.status, gift.status.displayName, now) + } + + @Transactional + fun completeDelivery(applicationNo: String, now: LocalDateTime = LocalDateTime.now()): GiftOperationStatusResult { + val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest() + if (gift.status != GiftStatus.INSPECTION_COMPLETED) throw invalidRequest() + val delivery = giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)) ?: throw invalidRequest() + + gift.status = GiftStatus.DELIVERED + delivery.deliveredAt = now + giftPushService.sendDeliveredToSender(gift.applicationNo, gift.senderMemberId) + giftPushService.sendDeliveredToRecipient(gift.applicationNo, gift.recipientMemberId) + + return GiftOperationStatusResult(gift.applicationNo, gift.status, gift.status.displayName, now) + } + + private fun validateCategory(command: GiftCategoryCommand) { + if ( + command.categoryCode.isBlank() || + command.categoryCode.length > 50 || + command.name.isBlank() || + command.name.length > 50 + ) { + throw invalidRequest() + } + } + + private fun validateSizePrice(command: GiftSizePriceCommand) { + if (command.salePriceCan <= 0 || command.salePriceCan > command.basePriceCan) throw invalidRequest() + } + + private fun GiftCategory.toResult() = GiftCategoryResult( + categoryId = id ?: throw invalidRequest(), + categoryCode = categoryCode, + name = name, + requiresDamageWaiver = requiresDamageWaiver, + isActive = isActive + ) + + private fun GiftSizePrice.toResult() = GiftSizePriceResult( + sizeCode = sizeCode, + sizeName = sizeCode.displayName, + basePriceCan = basePriceCan, + salePriceCan = salePriceCan, + isActive = isActive + ) + + private fun invalidRequest() = SodaException(messageKey = "common.error.invalid_request") + + private companion object { + val UNDELIVERABLE_ALLOWED_STATUSES = setOf( + GiftStatus.TRACKING_REGISTERED, + GiftStatus.ARRIVED_AT_MAILBOX, + GiftStatus.INSPECTION_COMPLETED + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminServiceTest.kt new file mode 100644 index 00000000..4b3be603 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminServiceTest.kt @@ -0,0 +1,223 @@ +package kr.co.vividnext.sodalive.v2.gift.application + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository +import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize +import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import java.time.LocalDateTime + +@DataJpaTest( + properties = [ + "spring.cache.type=none", + "spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE" + ] +) +@Import(QueryDslConfig::class) +@DisplayName("선물 관리자 서비스 테스트") +class GiftAdminServiceTest @Autowired constructor( + private val categoryRepository: GiftCategoryRepository, + private val sizePriceRepository: GiftSizePriceRepository, + private val giftRepository: GiftRepository, + private val giftDeliveryRepository: GiftDeliveryRepository +) { + private val giftPushService = Mockito.mock(GiftPushService::class.java) + private val service = GiftAdminService( + categoryRepository, + sizePriceRepository, + giftRepository, + giftDeliveryRepository, + giftPushService + ) + + @Test + @DisplayName("카테고리를 등록, 수정, 논리삭제한다") + fun shouldCreateUpdateAndDeactivateCategory() { + val created = service.createCategory( + GiftCategoryCommand(categoryCode = "DOLL", name = "인형", requiresDamageWaiver = true, isActive = true) + ) + val updated = service.updateCategory( + created.categoryId, + GiftCategoryCommand(categoryCode = "DOLL", name = "큰 인형", requiresDamageWaiver = false, isActive = true) + ) + service.deleteCategory(created.categoryId) + + val saved = categoryRepository.findById(created.categoryId).orElseThrow() + assertEquals("큰 인형", updated.name) + assertFalse(updated.requiresDamageWaiver) + assertFalse(saved.isActive) + } + + @Test + @DisplayName("사이즈 가격을 수정하고 salePriceCan이 basePriceCan보다 크면 거부한다") + fun shouldUpdateSizePriceAndRejectInvalidSalePrice() { + val updated = service.updateSizePrice( + GiftSize.SMALL, + GiftSizePriceCommand(basePriceCan = 100, salePriceCan = 80, isActive = true) + ) + + val exception = assertThrows(SodaException::class.java) { + service.updateSizePrice( + GiftSize.SMALL, + GiftSizePriceCommand(basePriceCan = 100, salePriceCan = 101, isActive = true) + ) + } + + assertEquals(GiftSize.SMALL, updated.sizeCode) + assertEquals(80, updated.salePriceCan) + assertEquals("common.error.invalid_request", exception.messageKey) + } + + @Test + @DisplayName("운영자는 발송 확인 선물을 사서함 도착 처리한다") + fun shouldMarkTrackingRegisteredGiftArrivedAtMailbox() { + val (gift, delivery) = saveGift(GiftStatus.TRACKING_REGISTERED) + + val result = service.arriveMailbox(APPLICATION_NO, NOW) + + assertEquals(GiftStatus.ARRIVED_AT_MAILBOX, result.status) + assertEquals(GiftStatus.ARRIVED_AT_MAILBOX, gift.status) + assertEquals(NOW, delivery.arrivedAtMailboxAt) + Mockito.verify(giftPushService).sendArrivedAtMailboxToSender(APPLICATION_NO, SENDER_MEMBER_ID) + Mockito.verify(giftPushService).sendArrivedAtMailboxToRecipient(APPLICATION_NO, RECIPIENT_MEMBER_ID) + } + + @Test + @DisplayName("사서함 도착 상태가 아니면 검수 완료 처리할 수 없다") + fun shouldRejectInspectionCompletionWhenGiftIsNotArrivedAtMailbox() { + saveGift(GiftStatus.TRACKING_REGISTERED) + + val exception = assertThrows(SodaException::class.java) { + service.completeInspection(APPLICATION_NO, NOW) + } + + assertEquals("common.error.invalid_request", exception.messageKey) + } + + @Test + @DisplayName("운영자는 사서함 도착 선물을 검수 완료 처리한다") + fun shouldCompleteInspectionForArrivedAtMailboxGift() { + val (gift, delivery) = saveGift(GiftStatus.ARRIVED_AT_MAILBOX) + + val result = service.completeInspection(APPLICATION_NO, NOW) + + assertEquals(GiftStatus.INSPECTION_COMPLETED, result.status) + assertEquals(GiftStatus.INSPECTION_COMPLETED, gift.status) + assertEquals(NOW, delivery.inspectionCompletedAt) + Mockito.verify(giftPushService).sendInspectionCompleted(APPLICATION_NO, RECIPIENT_MEMBER_ID) + } + + @Test + @DisplayName("운영자는 진행 중 선물을 전달 불가 처리한다") + fun shouldMarkActiveGiftUndeliverable() { + val (gift, delivery) = saveGift(GiftStatus.INSPECTION_COMPLETED) + + val result = service.markUndeliverable(APPLICATION_NO, UNDELIVERABLE_REASON, NOW) + + assertEquals(GiftStatus.UNDELIVERABLE, result.status) + assertEquals(GiftStatus.UNDELIVERABLE, gift.status) + assertEquals(NOW, delivery.undeliverableAt) + assertEquals(UNDELIVERABLE_REASON, delivery.undeliverableReason) + Mockito.verify(giftPushService).sendUndeliverableToSender(APPLICATION_NO, SENDER_MEMBER_ID) + Mockito.verify(giftPushService).sendUndeliverableToRecipient(APPLICATION_NO, RECIPIENT_MEMBER_ID) + } + + @Test + @DisplayName("전달 불가 사유가 비어 있거나 종료 상태면 전달 불가 처리할 수 없다") + fun shouldRejectUndeliverableWhenReasonBlankOrGiftTerminated() { + saveGift(GiftStatus.DELIVERED) + + val blankReasonException = assertThrows(SodaException::class.java) { + service.markUndeliverable(APPLICATION_NO, " ", NOW) + } + val terminatedStatusException = assertThrows(SodaException::class.java) { + service.markUndeliverable(APPLICATION_NO, UNDELIVERABLE_REASON, NOW) + } + + assertEquals("common.error.invalid_request", blankReasonException.messageKey) + assertEquals("common.error.invalid_request", terminatedStatusException.messageKey) + } + + @Test + @DisplayName("운영자는 검수 완료 선물을 전달 완료 처리한다") + fun shouldCompleteDeliveryForInspectionCompletedGift() { + val (gift, delivery) = saveGift(GiftStatus.INSPECTION_COMPLETED) + + val result = service.completeDelivery(APPLICATION_NO, NOW) + + assertEquals(GiftStatus.DELIVERED, result.status) + assertEquals(GiftStatus.DELIVERED, gift.status) + assertEquals(NOW, delivery.deliveredAt) + Mockito.verify(giftPushService).sendDeliveredToSender(APPLICATION_NO, SENDER_MEMBER_ID) + Mockito.verify(giftPushService).sendDeliveredToRecipient(APPLICATION_NO, RECIPIENT_MEMBER_ID) + Mockito.verifyNoMoreInteractions(giftPushService) + } + + @Test + @DisplayName("검수 완료 상태가 아니면 운영 전달 완료 처리할 수 없다") + fun shouldRejectDeliveryCompletionWhenGiftIsNotInspectionCompleted() { + saveGift(GiftStatus.ARRIVED_AT_MAILBOX) + + val exception = assertThrows(SodaException::class.java) { + service.completeDelivery(APPLICATION_NO, NOW) + } + + assertEquals("common.error.invalid_request", exception.messageKey) + } + + private fun saveGift(status: GiftStatus): Pair { + val gift = giftRepository.save( + Gift( + applicationNo = APPLICATION_NO, + senderMemberId = SENDER_MEMBER_ID, + recipientMemberId = RECIPIENT_MEMBER_ID, + status = status, + sizeCode = GiftSize.SMALL, + categoryId = 1L, + categoryNameSnapshot = "인형", + salePriceCan = 80, + canUsageId = 10L, + senderTermsAgreed = true, + senderPrivacyAgreed = true, + senderTermsAgreedAt = NOW.minusDays(1), + senderPrivacyAgreedAt = NOW.minusDays(1), + damageWaiverAgreed = true + ) + ) + val delivery = giftDeliveryRepository.save( + GiftDelivery( + giftId = requireNotNull(gift.id), + senderName = "홍길동", + senderPhoneNumber = "01012345678", + senderZipCode = "06234", + senderAddress = "서울시 강남구", + trackingDeadlineAt = NOW.minusDays(1), + trackingRegisteredAt = NOW.minusHours(1) + ) + ) + + return gift to delivery + } + + private companion object { + const val APPLICATION_NO = "G20260929000001" + const val SENDER_MEMBER_ID = 1L + const val RECIPIENT_MEMBER_ID = 2L + const val UNDELIVERABLE_REASON = "선물 접수 규격에 맞지 않습니다." + val NOW: LocalDateTime = LocalDateTime.of(2026, 10, 3, 12, 0) + } +} From 0f727c8b759f31b691f68a89a4e103f5e1dc9f2b Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 30 Sep 2026 13:38:04 +0900 Subject: [PATCH 13/53] =?UTF-8?q?feat(gift):=20=EC=84=A0=EB=AC=BC=20?= =?UTF-8?q?=EB=B0=B0=EC=86=A1=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC?= =?UTF-8?q?=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/out/scheduler/GiftScheduler.kt | 101 ++++++ .../out/scheduler/GiftSchedulerTest.kt | 287 ++++++++++++++++++ 2 files changed, 388 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftScheduler.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftSchedulerTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftScheduler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftScheduler.kt new file mode 100644 index 00000000..5ede6cbf --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftScheduler.kt @@ -0,0 +1,101 @@ +package kr.co.vividnext.sodalive.v2.gift.adapter.out.scheduler + +import kr.co.vividnext.sodalive.can.payment.CanPaymentService +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository +import kr.co.vividnext.sodalive.v2.gift.application.GiftPushService +import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus +import org.redisson.api.RedissonClient +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.util.concurrent.TimeUnit + +@Component +class GiftScheduler( + private val giftRepository: GiftRepository, + private val giftDeliveryRepository: GiftDeliveryRepository, + private val canPaymentService: CanPaymentService, + private val giftPushService: GiftPushService, + private val redissonClient: RedissonClient +) { + @Scheduled(cron = "0 0 * * * *", zone = "Asia/Seoul") + @Transactional + fun runExpiredTrackingRegistrationGiftCancellation() { + val lock = redissonClient.getLock(EXPIRED_TRACKING_REGISTRATION_GIFTS_LOCK_KEY) + + try { + if (lock.tryLock(0, -1, TimeUnit.SECONDS)) { + cancelExpiredTrackingRegistrationGifts(LocalDateTime.now()) + } + } finally { + if (lock.isHeldByCurrentThread) { + lock.unlock() + } + } + } + + @Transactional + fun cancelExpiredTrackingRegistrationGifts(now: LocalDateTime): Int { + val expiredGifts = giftRepository.findReceivedGiftsWithTrackingDeadlineBefore(now) + expiredGifts.forEach { gift -> + gift.status = GiftStatus.CANCELED + requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id))).canceledAt = now + canPaymentService.refundGiftCan(gift.senderMemberId, gift.canUsageId) + giftPushService.sendAutoCanceledToSender(gift.applicationNo, gift.senderMemberId) + } + + return expiredGifts.size + } + + @Transactional + fun markExpiredRecipientAddressGiftsUndeliverable(now: LocalDateTime): Int { + val expiredGifts = giftRepository.findTrackingRegisteredGiftsWithRecipientAddressDeadlineBefore(now) + expiredGifts.forEach { gift -> + gift.status = GiftStatus.UNDELIVERABLE + val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id))) + delivery.undeliverableAt = now + delivery.undeliverableReason = EXPIRED_RECIPIENT_ADDRESS_REASON + } + + return expiredGifts.size + } + + @Transactional + fun sendDeadlineReminderGifts(now: LocalDateTime): Int { + val reminderUntil = now.plusHours(DEADLINE_REMINDER_HOURS) + val trackingReminderCount = sendTrackingDeadlineReminders(now, reminderUntil) + val recipientAddressReminderCount = sendRecipientAddressDeadlineReminders(now, reminderUntil) + + return trackingReminderCount + recipientAddressReminderCount + } + + private fun sendTrackingDeadlineReminders(now: LocalDateTime, reminderUntil: LocalDateTime): Int { + val gifts = giftRepository.findReceivedGiftsWithTrackingDeadlineReminderDue(now, reminderUntil) + gifts.forEach { gift -> + val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id))) + delivery.trackingDeadlineReminderSentAt = now + giftPushService.sendTrackingDeadlineReminder(gift.applicationNo, gift.senderMemberId) + } + + return gifts.size + } + + private fun sendRecipientAddressDeadlineReminders(now: LocalDateTime, reminderUntil: LocalDateTime): Int { + val gifts = giftRepository.findGiftsWithRecipientAddressDeadlineReminderDue(now, reminderUntil) + gifts.forEach { gift -> + val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id))) + delivery.recipientAddressDeadlineReminderSentAt = now + giftPushService.sendRecipientAddressDeadlineReminder(gift.applicationNo, gift.recipientMemberId) + } + + return gifts.size + } + + private companion object { + const val DEADLINE_REMINDER_HOURS = 24L + const val EXPIRED_TRACKING_REGISTRATION_GIFTS_LOCK_KEY = "lock:gift-expired-tracking-registration-cancellation" + const val EXPIRED_RECIPIENT_ADDRESS_REASON = "배송지 미입력 기한 초과" + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftSchedulerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftSchedulerTest.kt new file mode 100644 index 00000000..8cef5474 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftSchedulerTest.kt @@ -0,0 +1,287 @@ +package kr.co.vividnext.sodalive.v2.gift.adapter.out.scheduler + +import kr.co.vividnext.sodalive.can.payment.CanPaymentService +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository +import kr.co.vividnext.sodalive.v2.gift.application.GiftPushService +import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize +import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.redisson.api.RedissonClient +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import java.time.LocalDateTime + +@DataJpaTest( + properties = [ + "spring.cache.type=none", + "spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE" + ] +) +@Import(QueryDslConfig::class) +@DisplayName("선물 자동처리 스케줄러 테스트") +class GiftSchedulerTest @Autowired constructor( + private val giftRepository: GiftRepository, + private val giftDeliveryRepository: GiftDeliveryRepository +) { + private val canPaymentService = Mockito.mock(CanPaymentService::class.java) + private val giftPushService = Mockito.mock(GiftPushService::class.java) + private val scheduler = GiftScheduler( + giftRepository, + giftDeliveryRepository, + canPaymentService, + giftPushService, + Mockito.mock(RedissonClient::class.java) + ) + + @Test + @DisplayName("운송장 등록 기한이 지난 접수 선물을 취소하고 한 번 환불한다") + fun shouldCancelOverdueReceivedGiftAndRefundOnce() { + val now = LocalDateTime.of(2026, 10, 2, 12, 0) + val (gift, delivery) = saveGift(GiftStatus.RECEIVED, now.minusSeconds(1)) + + val canceledCount = scheduler.cancelExpiredTrackingRegistrationGifts(now) + + assertEquals(1, canceledCount) + assertEquals(GiftStatus.CANCELED, gift.status) + assertEquals(now, delivery.canceledAt) + Mockito.verify(canPaymentService).refundGiftCan(SENDER_MEMBER_ID, CAN_USAGE_ID) + Mockito.verify(giftPushService).sendAutoCanceledToSender(APPLICATION_NO, SENDER_MEMBER_ID) + } + + @Test + @DisplayName("운송장 등록 기한과 같은 접수 선물은 취소하지 않는다") + fun shouldIgnoreReceivedGiftWhoseTrackingDeadlineIsNotPast() { + val now = LocalDateTime.of(2026, 10, 2, 12, 0) + val (gift, delivery) = saveGift(GiftStatus.RECEIVED, now) + + val canceledCount = scheduler.cancelExpiredTrackingRegistrationGifts(now) + + assertEquals(0, canceledCount) + assertEquals(GiftStatus.RECEIVED, gift.status) + assertEquals(null, delivery.canceledAt) + Mockito.verifyNoInteractions(canPaymentService) + } + + @Test + @DisplayName("운송장 등록 기한이 지나도 접수 상태가 아닌 선물은 취소하지 않는다") + fun shouldIgnoreOverdueGiftThatIsNotReceived() { + val now = LocalDateTime.of(2026, 10, 2, 12, 0) + val (gift, delivery) = saveGift(GiftStatus.TRACKING_REGISTERED, now.minusSeconds(1)) + + val canceledCount = scheduler.cancelExpiredTrackingRegistrationGifts(now) + + assertEquals(0, canceledCount) + assertEquals(GiftStatus.TRACKING_REGISTERED, gift.status) + assertEquals(null, delivery.canceledAt) + Mockito.verifyNoInteractions(canPaymentService) + } + + @Test + @DisplayName("자동취소를 다시 실행해도 이미 취소된 선물은 환불하지 않는다") + fun shouldNotRefundAlreadyCanceledGiftOnSecondRun() { + val now = LocalDateTime.of(2026, 10, 2, 12, 0) + saveGift(GiftStatus.RECEIVED, now.minusSeconds(1)) + + val firstCanceledCount = scheduler.cancelExpiredTrackingRegistrationGifts(now) + val secondCanceledCount = scheduler.cancelExpiredTrackingRegistrationGifts(now) + + assertEquals(1, firstCanceledCount) + assertEquals(0, secondCanceledCount) + Mockito.verify(canPaymentService, Mockito.times(1)).refundGiftCan(SENDER_MEMBER_ID, CAN_USAGE_ID) + Mockito.verify(giftPushService, Mockito.times(1)).sendAutoCanceledToSender(APPLICATION_NO, SENDER_MEMBER_ID) + } + + @Test + @DisplayName("배송지 입력 기한이 지난 발송 확인 선물을 전달 불가 처리한다") + fun shouldMarkOverdueTrackingRegisteredGiftWithoutRecipientAddressUndeliverable() { + val now = LocalDateTime.of(2026, 10, 9, 12, 0) + val (gift, delivery) = saveGift( + status = GiftStatus.TRACKING_REGISTERED, + trackingDeadlineAt = now.minusDays(7), + recipientAddressDeadlineAt = now.minusSeconds(1) + ) + + val updatedCount = scheduler.markExpiredRecipientAddressGiftsUndeliverable(now) + + assertEquals(1, updatedCount) + assertEquals(GiftStatus.UNDELIVERABLE, gift.status) + assertEquals(now, delivery.undeliverableAt) + assertEquals("배송지 미입력 기한 초과", delivery.undeliverableReason) + } + + @Test + @DisplayName("배송지 입력 기한과 같은 발송 확인 선물은 전달 불가 처리하지 않는다") + fun shouldIgnoreTrackingRegisteredGiftWhoseRecipientAddressDeadlineIsNotPast() { + val now = LocalDateTime.of(2026, 10, 9, 12, 0) + val (gift, delivery) = saveGift( + status = GiftStatus.TRACKING_REGISTERED, + trackingDeadlineAt = now.minusDays(7), + recipientAddressDeadlineAt = now + ) + + val updatedCount = scheduler.markExpiredRecipientAddressGiftsUndeliverable(now) + + assertEquals(0, updatedCount) + assertEquals(GiftStatus.TRACKING_REGISTERED, gift.status) + assertEquals(null, delivery.undeliverableAt) + assertEquals(null, delivery.undeliverableReason) + } + + @Test + @DisplayName("배송지가 입력된 선물은 배송지 입력 기한이 지나도 전달 불가 처리하지 않는다") + fun shouldIgnoreGiftWithRecipientAddress() { + val now = LocalDateTime.of(2026, 10, 9, 12, 0) + val (gift, delivery) = saveGift( + status = GiftStatus.TRACKING_REGISTERED, + trackingDeadlineAt = now.minusDays(7), + recipientAddressDeadlineAt = now.minusSeconds(1), + recipientAddress = "서울시 송파구" + ) + + val updatedCount = scheduler.markExpiredRecipientAddressGiftsUndeliverable(now) + + assertEquals(0, updatedCount) + assertEquals(GiftStatus.TRACKING_REGISTERED, gift.status) + assertEquals(null, delivery.undeliverableAt) + assertEquals(null, delivery.undeliverableReason) + } + + @Test + @DisplayName("배송지 미입력 전달 불가를 다시 실행해도 이미 전달 불가인 선물은 처리하지 않는다") + fun shouldNotUpdateAlreadyUndeliverableGiftOnSecondRun() { + val now = LocalDateTime.of(2026, 10, 9, 12, 0) + val (_, delivery) = saveGift( + status = GiftStatus.TRACKING_REGISTERED, + trackingDeadlineAt = now.minusDays(7), + recipientAddressDeadlineAt = now.minusSeconds(1) + ) + + val firstUpdatedCount = scheduler.markExpiredRecipientAddressGiftsUndeliverable(now) + val secondUpdatedCount = scheduler.markExpiredRecipientAddressGiftsUndeliverable(now.plusMinutes(1)) + + assertEquals(1, firstUpdatedCount) + assertEquals(0, secondUpdatedCount) + assertEquals(now, delivery.undeliverableAt) + } + + @Test + @DisplayName("운송장 등록 기한 24시간 전 접수 선물에 한 번만 안내한다") + fun shouldSendTrackingDeadlineReminderOnce() { + val now = LocalDateTime.of(2026, 10, 1, 12, 0) + val (_, delivery) = saveGift(GiftStatus.RECEIVED, now.plusHours(24)) + + val firstSentCount = scheduler.sendDeadlineReminderGifts(now) + val secondSentCount = scheduler.sendDeadlineReminderGifts(now.plusMinutes(1)) + + assertEquals(1, firstSentCount) + assertEquals(0, secondSentCount) + assertEquals(now, delivery.trackingDeadlineReminderSentAt) + Mockito.verify(giftPushService, Mockito.times(1)).sendTrackingDeadlineReminder(APPLICATION_NO, SENDER_MEMBER_ID) + } + + @Test + @DisplayName("운송장 등록 기한 24시간 초과 전 접수 선물은 안내하지 않는다") + fun shouldIgnoreTrackingDeadlineReminderBeforeWindow() { + val now = LocalDateTime.of(2026, 10, 1, 12, 0) + saveGift(GiftStatus.RECEIVED, now.plusHours(24).plusSeconds(1)) + + val sentCount = scheduler.sendDeadlineReminderGifts(now) + + assertEquals(0, sentCount) + Mockito.verifyNoInteractions(giftPushService) + } + + @Test + @DisplayName("배송지 입력 기한 24시간 전 선물에 한 번만 안내한다") + fun shouldSendRecipientAddressDeadlineReminderOnce() { + val now = LocalDateTime.of(2026, 10, 8, 12, 0) + val (_, delivery) = saveGift( + status = GiftStatus.TRACKING_REGISTERED, + trackingDeadlineAt = now.minusDays(6), + recipientAddressDeadlineAt = now.plusHours(24) + ) + + val firstSentCount = scheduler.sendDeadlineReminderGifts(now) + val secondSentCount = scheduler.sendDeadlineReminderGifts(now.plusMinutes(1)) + + assertEquals(1, firstSentCount) + assertEquals(0, secondSentCount) + assertEquals(now, delivery.recipientAddressDeadlineReminderSentAt) + Mockito.verify(giftPushService, Mockito.times(1)) + .sendRecipientAddressDeadlineReminder(APPLICATION_NO, RECIPIENT_MEMBER_ID) + } + + @Test + @DisplayName("배송지가 입력된 선물은 배송지 입력 기한 안내를 보내지 않는다") + fun shouldIgnoreRecipientAddressDeadlineReminderWhenAddressExists() { + val now = LocalDateTime.of(2026, 10, 8, 12, 0) + saveGift( + status = GiftStatus.TRACKING_REGISTERED, + trackingDeadlineAt = now.minusDays(6), + recipientAddressDeadlineAt = now.plusHours(24), + recipientAddress = "서울시 송파구" + ) + + val sentCount = scheduler.sendDeadlineReminderGifts(now) + + assertEquals(0, sentCount) + Mockito.verifyNoInteractions(giftPushService) + } + + private fun saveGift( + status: GiftStatus, + trackingDeadlineAt: LocalDateTime, + recipientAddressDeadlineAt: LocalDateTime? = null, + recipientAddress: String? = null + ): Pair { + val gift = giftRepository.save( + Gift( + applicationNo = APPLICATION_NO, + senderMemberId = SENDER_MEMBER_ID, + recipientMemberId = RECIPIENT_MEMBER_ID, + status = status, + sizeCode = GiftSize.SMALL, + categoryId = CATEGORY_ID, + categoryNameSnapshot = "인형", + salePriceCan = 80, + canUsageId = CAN_USAGE_ID, + senderTermsAgreed = true, + senderPrivacyAgreed = true, + senderTermsAgreedAt = APPLIED_AT, + senderPrivacyAgreedAt = APPLIED_AT, + damageWaiverAgreed = true + ) + ) + val delivery = giftDeliveryRepository.save( + GiftDelivery( + giftId = requireNotNull(gift.id), + senderName = "홍길동", + senderPhoneNumber = "01012345678", + senderZipCode = "06234", + senderAddress = "서울시 강남구", + recipientAddress = recipientAddress, + trackingDeadlineAt = trackingDeadlineAt, + recipientAddressDeadlineAt = recipientAddressDeadlineAt + ) + ) + + return gift to delivery + } + + private companion object { + const val APPLICATION_NO = "G20260929000001" + const val SENDER_MEMBER_ID = 1L + const val RECIPIENT_MEMBER_ID = 2L + const val CATEGORY_ID = 3L + const val CAN_USAGE_ID = 4L + val APPLIED_AT: LocalDateTime = LocalDateTime.of(2026, 9, 29, 12, 0) + } +} From 35b16d737018ba7a93d11a537deae04b97842acf Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 30 Sep 2026 13:38:24 +0900 Subject: [PATCH 14/53] =?UTF-8?q?feat(gift):=20=EC=84=A0=EB=AC=BC=20?= =?UTF-8?q?=EC=8B=A0=EC=B2=AD=20=EC=9A=94=EC=B2=AD=20DTO=EB=A5=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../v2/api/gift/dto/GiftApplicationRequest.kt | 32 +++++++++++++++++++ ...GiftRecipientAddressRegistrationRequest.kt | 23 +++++++++++++ .../v2/api/gift/dto/GiftReviewRequest.kt | 15 +++++++++ .../dto/GiftTrackingRegistrationRequest.kt | 13 ++++++++ 4 files changed, 83 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftApplicationRequest.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftRecipientAddressRegistrationRequest.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftReviewRequest.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftTrackingRegistrationRequest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftApplicationRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftApplicationRequest.kt new file mode 100644 index 00000000..fbccf62e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftApplicationRequest.kt @@ -0,0 +1,32 @@ +package kr.co.vividnext.sodalive.v2.api.gift.dto + +import kr.co.vividnext.sodalive.v2.gift.application.GiftApplicationCommand +import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize + +data class GiftApplicationRequest( + val recipientMemberId: Long, + val senderName: String, + val senderPhoneNumber: String, + val senderZipCode: String, + val senderAddress: String, + val senderAddressDetail: String?, + val sizeCode: GiftSize, + val categoryId: Long, + val senderTermsAgreed: Boolean, + val senderPrivacyAgreed: Boolean, + val damageWaiverAgreed: Boolean +) { + fun toCommand() = GiftApplicationCommand( + recipientMemberId = recipientMemberId, + senderName = senderName, + senderPhoneNumber = senderPhoneNumber, + senderZipCode = senderZipCode, + senderAddress = senderAddress, + senderAddressDetail = senderAddressDetail, + sizeCode = sizeCode, + categoryId = categoryId, + senderTermsAgreed = senderTermsAgreed, + senderPrivacyAgreed = senderPrivacyAgreed, + damageWaiverAgreed = damageWaiverAgreed + ) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftRecipientAddressRegistrationRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftRecipientAddressRegistrationRequest.kt new file mode 100644 index 00000000..8617032e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftRecipientAddressRegistrationRequest.kt @@ -0,0 +1,23 @@ +package kr.co.vividnext.sodalive.v2.api.gift.dto + +import kr.co.vividnext.sodalive.v2.gift.application.GiftRecipientAddressRegistrationCommand + +data class GiftRecipientAddressRegistrationRequest( + val recipientName: String, + val recipientPhoneNumber: String, + val recipientZipCode: String, + val recipientAddress: String, + val recipientAddressDetail: String?, + val recipientTermsAgreed: Boolean, + val recipientPrivacyAgreed: Boolean +) { + fun toCommand() = GiftRecipientAddressRegistrationCommand( + name = recipientName, + phoneNumber = recipientPhoneNumber, + zipCode = recipientZipCode, + address = recipientAddress, + addressDetail = recipientAddressDetail, + recipientTermsAgreed = recipientTermsAgreed, + recipientPrivacyAgreed = recipientPrivacyAgreed + ) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftReviewRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftReviewRequest.kt new file mode 100644 index 00000000..9825e279 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftReviewRequest.kt @@ -0,0 +1,15 @@ +package kr.co.vividnext.sodalive.v2.api.gift.dto + +import kr.co.vividnext.sodalive.v2.gift.application.GiftReviewCommand + +data class GiftReviewRequest( + val rating: Int, + val keywords: List, + val comment: String? +) { + fun toCommand() = GiftReviewCommand( + rating = rating, + keywords = keywords, + comment = comment + ) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftTrackingRegistrationRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftTrackingRegistrationRequest.kt new file mode 100644 index 00000000..59db2ecd --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftTrackingRegistrationRequest.kt @@ -0,0 +1,13 @@ +package kr.co.vividnext.sodalive.v2.api.gift.dto + +import kr.co.vividnext.sodalive.v2.gift.application.GiftTrackingRegistrationCommand + +data class GiftTrackingRegistrationRequest( + val courierCompanyName: String, + val trackingNumber: String +) { + fun toCommand() = GiftTrackingRegistrationCommand( + courierCompanyName = courierCompanyName, + trackingNumber = trackingNumber + ) +} From 08347f9ab5c3af9d0ff2ddaef1ef9dabca3b2b3a Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 30 Sep 2026 13:38:44 +0900 Subject: [PATCH 15/53] =?UTF-8?q?feat(gift):=20=EC=84=A0=EB=AC=BC=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20DTO=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/v2/api/gift/dto/GiftResponse.kt | 275 ++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftResponse.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftResponse.kt new file mode 100644 index 00000000..c3e15825 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftResponse.kt @@ -0,0 +1,275 @@ +package kr.co.vividnext.sodalive.v2.api.gift.dto + +import kr.co.vividnext.sodalive.v2.gift.application.GiftApplicationResult +import kr.co.vividnext.sodalive.v2.gift.application.GiftCancellationResult +import kr.co.vividnext.sodalive.v2.gift.application.GiftDeliveryConfirmationResult +import kr.co.vividnext.sodalive.v2.gift.application.GiftDetailResult +import kr.co.vividnext.sodalive.v2.gift.application.GiftFormOptionsResult +import kr.co.vividnext.sodalive.v2.gift.application.GiftListResult +import kr.co.vividnext.sodalive.v2.gift.application.GiftRecipientAddressRegistrationResult +import kr.co.vividnext.sodalive.v2.gift.application.GiftReviewResult +import kr.co.vividnext.sodalive.v2.gift.application.GiftTrackingRegistrationResult +import java.time.LocalDateTime + +data class GiftFormOptionsResponse( + val sizes: List, + val categories: List +) { + companion object { + fun from(result: GiftFormOptionsResult) = GiftFormOptionsResponse( + sizes = result.sizes.map { + GiftSizeOptionResponse(it.sizeCode.name, it.sizeName, it.basePriceCan, it.salePriceCan) + }, + categories = result.categories.map { + GiftCategoryOptionResponse(it.categoryId, it.name, it.requiresDamageWaiver) + } + ) + } +} + +data class GiftSizeOptionResponse( + val sizeCode: String, + val name: String, + val basePriceCan: Int, + val salePriceCan: Int +) + +data class GiftCategoryOptionResponse( + val categoryId: Long, + val name: String, + val requiresDamageWaiver: Boolean +) + +data class GiftListResponse( + val totalCount: Long, + val items: List, + val page: Int, + val size: Int, + val hasNext: Boolean +) { + companion object { + fun from(result: GiftListResult) = GiftListResponse( + totalCount = result.totalCount, + items = result.items.map { + GiftListItemResponse( + applicationNo = it.applicationNo, + direction = it.direction.name, + status = it.status.name, + statusName = it.statusName, + priceCan = it.priceCan, + categoryName = it.categoryName, + sizeName = it.sizeName, + createdAt = it.createdAt + ) + }, + page = result.page, + size = result.size, + hasNext = result.hasNext + ) + } +} + +data class GiftListItemResponse( + val applicationNo: String, + val direction: String, + val status: String, + val statusName: String, + val priceCan: Int, + val categoryName: String, + val sizeName: String, + val createdAt: LocalDateTime? +) + +data class GiftDetailResponse( + val applicationNo: String, + val direction: String, + val status: String, + val statusName: String, + val giftInfo: GiftDetailInfoResponse, + val senderInfo: GiftAddressResponse?, + val recipientAddress: GiftAddressResponse?, + val mailbox: GiftMailboxResponse?, + val trackingRequired: Boolean, + val recipientAddressRequired: Boolean, + val recipientAddressDeadlineAt: LocalDateTime?, + val delivery: GiftDeliveryInfoResponse, + val statusTimeline: List +) { + companion object { + fun from(result: GiftDetailResult) = GiftDetailResponse( + applicationNo = result.applicationNo, + direction = result.direction.name, + status = result.status.name, + statusName = result.statusName, + giftInfo = GiftDetailInfoResponse( + recipientCreatorNickname = result.giftInfo.recipientCreatorNickname, + senderNickname = result.giftInfo.senderNickname, + sizeName = result.giftInfo.sizeName, + categoryName = result.giftInfo.categoryName, + applicationNo = result.giftInfo.applicationNo, + paidCan = result.giftInfo.paidCan, + tracking = result.giftInfo.tracking, + shippingRequestedAt = result.giftInfo.shippingRequestedAt + ), + senderInfo = result.senderInfo?.let { GiftAddressResponse(it.name, it.phoneNumber, it.address) }, + recipientAddress = result.recipientAddress?.let { GiftAddressResponse(it.name, it.phoneNumber, it.address) }, + mailbox = result.mailbox?.let { GiftMailboxResponse(it.name, it.address, it.phoneNumber) }, + trackingRequired = result.trackingRequired, + recipientAddressRequired = result.recipientAddressRequired, + recipientAddressDeadlineAt = result.recipientAddressDeadlineAt, + delivery = GiftDeliveryInfoResponse( + canceledAt = result.delivery.canceledAt, + undeliverableAt = result.delivery.undeliverableAt, + undeliverableReason = result.delivery.undeliverableReason + ), + statusTimeline = result.statusTimeline.map { + GiftStatusTimelineResponse(it.status.name, it.statusName, it.occurredAt) + } + ) + } +} + +data class GiftDetailInfoResponse( + val recipientCreatorNickname: String?, + val senderNickname: String?, + val sizeName: String, + val categoryName: String, + val applicationNo: String?, + val paidCan: Int?, + val tracking: String?, + val shippingRequestedAt: LocalDateTime? +) + +data class GiftAddressResponse( + val name: String, + val phoneNumber: String, + val address: String +) + +data class GiftMailboxResponse( + val name: String, + val address: String, + val phoneNumber: String +) + +data class GiftDeliveryInfoResponse( + val canceledAt: LocalDateTime?, + val undeliverableAt: LocalDateTime?, + val undeliverableReason: String? +) + +data class GiftStatusTimelineResponse( + val status: String, + val statusName: String, + val occurredAt: LocalDateTime? +) + +data class GiftApplicationResponse( + val applicationNo: String, + val status: String, + val statusName: String, + val priceCan: Int, + val trackingDeadlineAt: LocalDateTime +) { + companion object { + fun from(result: GiftApplicationResult) = GiftApplicationResponse( + applicationNo = result.applicationNo, + status = result.status.name, + statusName = result.statusName, + priceCan = result.priceCan, + trackingDeadlineAt = result.trackingDeadlineAt + ) + } +} + +data class GiftCancellationResponse( + val applicationNo: String, + val status: String, + val statusName: String, + val priceCan: Int, + val canceledAt: LocalDateTime +) { + companion object { + fun from(result: GiftCancellationResult) = GiftCancellationResponse( + applicationNo = result.applicationNo, + status = result.status.name, + statusName = result.statusName, + priceCan = result.priceCan, + canceledAt = result.canceledAt + ) + } +} + +data class GiftTrackingRegistrationResponse( + val applicationNo: String, + val status: String, + val statusName: String, + val courierCompanyName: String, + val trackingNumber: String, + val trackingRegisteredAt: LocalDateTime, + val recipientAddressDeadlineAt: LocalDateTime +) { + companion object { + fun from(result: GiftTrackingRegistrationResult) = GiftTrackingRegistrationResponse( + applicationNo = result.applicationNo, + status = result.status.name, + statusName = result.statusName, + courierCompanyName = result.courierCompanyName, + trackingNumber = result.trackingNumber, + trackingRegisteredAt = result.trackingRegisteredAt, + recipientAddressDeadlineAt = result.recipientAddressDeadlineAt + ) + } +} + +data class GiftRecipientAddressRegistrationResponse( + val applicationNo: String, + val status: String, + val statusName: String, + val recipientAddressRegisteredAt: LocalDateTime +) { + companion object { + fun from(result: GiftRecipientAddressRegistrationResult) = GiftRecipientAddressRegistrationResponse( + applicationNo = result.applicationNo, + status = result.status.name, + statusName = result.statusName, + recipientAddressRegisteredAt = result.recipientAddressRegisteredAt + ) + } +} + +data class GiftDeliveryConfirmationResponse( + val applicationNo: String, + val status: String, + val statusName: String, + val deliveredAt: LocalDateTime +) { + companion object { + fun from(result: GiftDeliveryConfirmationResult) = GiftDeliveryConfirmationResponse( + applicationNo = result.applicationNo, + status = result.status.name, + statusName = result.statusName, + deliveredAt = result.deliveredAt + ) + } +} + +data class GiftReviewResponse( + val reviewId: Long, + val applicationNo: String, + val rating: Int, + val keywords: List, + val comment: String?, + val createdAt: LocalDateTime? +) { + companion object { + fun from(result: GiftReviewResult) = GiftReviewResponse( + reviewId = result.reviewId, + applicationNo = result.applicationNo, + rating = result.rating, + keywords = result.keywords, + comment = result.comment, + createdAt = result.createdAt + ) + } +} From 6a2ae3848366bdb9b4c43bc60f6e35e08d64ffe9 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 30 Sep 2026 13:39:04 +0900 Subject: [PATCH 16/53] =?UTF-8?q?feat(gift):=20=EC=84=A0=EB=AC=BC=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20API=EB=A5=BC=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/gift/adapter/in/web/GiftController.kt | 153 +++++++ .../gift/adapter/in/web/GiftControllerTest.kt | 428 ++++++++++++++++++ 2 files changed, 581 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftController.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftController.kt new file mode 100644 index 00000000..17826e91 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftController.kt @@ -0,0 +1,153 @@ +package kr.co.vividnext.sodalive.v2.api.gift.adapter.`in`.web + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftApplicationRequest +import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftApplicationResponse +import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftCancellationResponse +import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftDeliveryConfirmationResponse +import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftDetailResponse +import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftFormOptionsResponse +import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftListResponse +import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftRecipientAddressRegistrationRequest +import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftRecipientAddressRegistrationResponse +import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftReviewRequest +import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftReviewResponse +import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftTrackingRegistrationRequest +import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftTrackingRegistrationResponse +import kr.co.vividnext.sodalive.v2.gift.application.GiftCommandService +import kr.co.vividnext.sodalive.v2.gift.application.GiftQueryService +import kr.co.vividnext.sodalive.v2.gift.domain.GiftListType +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v2/gifts") +class GiftController( + private val queryService: GiftQueryService, + private val commandService: GiftCommandService +) { + @GetMapping("/form-options") + fun getFormOptions() = ApiResponse.ok(GiftFormOptionsResponse.from(queryService.getFormOptions())) + + @GetMapping + fun getGifts( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @RequestParam(defaultValue = "ALL") type: GiftListType, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "20") size: Int + ) = ApiResponse.ok( + GiftListResponse.from( + queryService.getGifts( + memberId = member?.id ?: throw SodaException(messageKey = "common.error.bad_credentials"), + type = type, + page = page, + size = size + ) + ) + ) + + @GetMapping("/{applicationNo}") + fun getGiftDetail( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @PathVariable applicationNo: String + ) = ApiResponse.ok( + GiftDetailResponse.from( + queryService.getGiftDetail( + memberId = member?.id ?: throw SodaException(messageKey = "common.error.bad_credentials"), + applicationNo = applicationNo + ) + ) + ) + + @PostMapping + fun registerGift( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @RequestBody request: GiftApplicationRequest + ) = ApiResponse.ok( + GiftApplicationResponse.from( + commandService.registerGift( + member = member ?: throw SodaException(messageKey = "common.error.bad_credentials"), + command = request.toCommand() + ) + ) + ) + + @PostMapping("/{applicationNo}/cancel") + fun cancelGift( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @PathVariable applicationNo: String + ) = ApiResponse.ok( + GiftCancellationResponse.from( + commandService.cancelGift( + member = member ?: throw SodaException(messageKey = "common.error.bad_credentials"), + applicationNo = applicationNo + ) + ) + ) + + @PostMapping("/{applicationNo}/tracking") + fun registerTracking( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @PathVariable applicationNo: String, + @RequestBody request: GiftTrackingRegistrationRequest + ) = ApiResponse.ok( + GiftTrackingRegistrationResponse.from( + commandService.registerTracking( + member = member ?: throw SodaException(messageKey = "common.error.bad_credentials"), + applicationNo = applicationNo, + command = request.toCommand() + ) + ) + ) + + @PostMapping("/{applicationNo}/recipient-address") + fun registerRecipientAddress( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @PathVariable applicationNo: String, + @RequestBody request: GiftRecipientAddressRegistrationRequest + ) = ApiResponse.ok( + GiftRecipientAddressRegistrationResponse.from( + commandService.registerRecipientAddress( + member = member ?: throw SodaException(messageKey = "common.error.bad_credentials"), + applicationNo = applicationNo, + command = request.toCommand() + ) + ) + ) + + @PostMapping("/{applicationNo}/delivery-complete") + fun confirmDelivery( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @PathVariable applicationNo: String + ) = ApiResponse.ok( + GiftDeliveryConfirmationResponse.from( + commandService.confirmDelivery( + member = member ?: throw SodaException(messageKey = "common.error.bad_credentials"), + applicationNo = applicationNo + ) + ) + ) + + @PostMapping("/{applicationNo}/review") + fun writeReview( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @PathVariable applicationNo: String, + @RequestBody request: GiftReviewRequest + ) = ApiResponse.ok( + GiftReviewResponse.from( + commandService.writeReview( + member = member ?: throw SodaException(messageKey = "common.error.bad_credentials"), + applicationNo = applicationNo, + command = request.toCommand() + ) + ) + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt new file mode 100644 index 00000000..effe8e04 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt @@ -0,0 +1,428 @@ +package kr.co.vividnext.sodalive.v2.api.gift.adapter.`in`.web + +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftApplicationRequest +import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftRecipientAddressRegistrationRequest +import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftReviewRequest +import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftTrackingRegistrationRequest +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePrice +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository +import kr.co.vividnext.sodalive.v2.gift.application.GiftApplicationResult +import kr.co.vividnext.sodalive.v2.gift.application.GiftCancellationResult +import kr.co.vividnext.sodalive.v2.gift.application.GiftCommandService +import kr.co.vividnext.sodalive.v2.gift.application.GiftDeliveryConfirmationResult +import kr.co.vividnext.sodalive.v2.gift.application.GiftRecipientAddressRegistrationResult +import kr.co.vividnext.sodalive.v2.gift.application.GiftReviewResult +import kr.co.vividnext.sodalive.v2.gift.application.GiftTrackingRegistrationResult +import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize +import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.http.MediaType +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.time.LocalDateTime + +@SpringBootTest( + properties = [ + "spring.datasource.url=jdbc:h2:mem:gift-controller;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE" + ] +) +@AutoConfigureMockMvc +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class GiftControllerTest @Autowired constructor( + private val mockMvc: MockMvc, + private val categoryRepository: GiftCategoryRepository, + private val giftRepository: GiftRepository, + private val giftDeliveryRepository: GiftDeliveryRepository, + private val sizePriceRepository: GiftSizePriceRepository, + private val memberRepository: MemberRepository, + private val objectMapper: ObjectMapper +) { + @MockBean + private lateinit var commandService: GiftCommandService + + @Test + fun `팬은 활성 폼 옵션을 조회하고 categoryCode를 받지 않는다`() { + categoryRepository.save(GiftCategory("DOLL", "인형", true, true)) + categoryRepository.save(GiftCategory("FOOD", "간식", false, false)) + sizePriceRepository.save(GiftSizePrice(GiftSize.SMALL, 100, 80, true)) + + mockMvc.perform(get("/api/v2/gifts/form-options").with(user("fan").roles("USER"))) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.sizes[0].sizeCode").value("SMALL")) + .andExpect(jsonPath("$.data.sizes[0].name").value("소형")) + .andExpect(jsonPath("$.data.sizes[0].basePriceCan").value(100)) + .andExpect(jsonPath("$.data.sizes[0].salePriceCan").value(80)) + .andExpect(jsonPath("$.data.categories[0].name").value("인형")) + .andExpect(jsonPath("$.data.categories[0].categoryCode").doesNotExist()) + } + + @Test + @DisplayName("회원은 선물함 목록에서 보낸 선물과 노출 가능한 받은 선물을 페이징 응답으로 조회한다") + fun shouldListGifts() { + val member = Member(password = "password", nickname = "member").apply { id = 10L } + saveGift("G-CONTROLLER-SENT", senderMemberId = 10L, recipientMemberId = 20L, status = GiftStatus.RECEIVED) + saveGift("G-CONTROLLER-HIDDEN", senderMemberId = 20L, recipientMemberId = 10L, status = GiftStatus.RECEIVED) + saveGift( + "G-CONTROLLER-VISIBLE", + senderMemberId = 20L, + recipientMemberId = 10L, + status = GiftStatus.TRACKING_REGISTERED + ) + + mockMvc.perform( + get("/api/v2/gifts") + .param("type", "ALL") + .param("page", "-1") + .param("size", "0") + .with(user(MemberAdapter(member))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.totalCount").value(2)) + .andExpect(jsonPath("$.data.items.length()").value(2)) + .andExpect(jsonPath("$.data.page").value(0)) + .andExpect(jsonPath("$.data.size").value(20)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + .andExpect(jsonPath("$.data.items[0].applicationNo").value("G-CONTROLLER-VISIBLE")) + .andExpect(jsonPath("$.data.items[0].direction").value("RECEIVED")) + .andExpect(jsonPath("$.data.items[0].status").value("TRACKING_REGISTERED")) + .andExpect(jsonPath("$.data.items[0].statusName").value("발송 확인")) + .andExpect(jsonPath("$.data.items[0].priceCan").value(80)) + .andExpect(jsonPath("$.data.items[0].categoryName").value("인형")) + .andExpect(jsonPath("$.data.items[0].sizeName").value("소형")) + .andExpect(jsonPath("$.data.items[0].createdAt").exists()) + } + + @Test + @DisplayName("발신자는 인증 정보로 선물 상세와 자신의 발신자 정보를 조회한다") + fun shouldGetSentGiftDetail() { + val sender = memberRepository.save(Member(password = "password", nickname = "fan")) + val recipient = memberRepository.save(Member(password = "password", nickname = "creator")) + val gift = saveGift( + applicationNo = "G-CONTROLLER-DETAIL", + senderMemberId = sender.id!!, + recipientMemberId = recipient.id!!, + status = GiftStatus.TRACKING_REGISTERED + ) + giftDeliveryRepository.save( + GiftDelivery( + giftId = gift.id!!, + senderName = "홍길동", + senderPhoneNumber = "01012345678", + senderZipCode = "06234", + senderAddress = "서울시 강남구", + senderAddressDetail = "101동 1001호", + trackingDeadlineAt = LocalDateTime.of(2026, 10, 2, 12, 0) + ).apply { + courierCompanyName = "CJ대한통운" + trackingNumber = "1234567890" + trackingRegisteredAt = LocalDateTime.of(2026, 9, 30, 12, 0) + } + ) + + mockMvc.perform( + get("/api/v2/gifts/{applicationNo}", gift.applicationNo) + .with(user(MemberAdapter(sender))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.applicationNo").value("G-CONTROLLER-DETAIL")) + .andExpect(jsonPath("$.data.direction").value("SENT")) + .andExpect(jsonPath("$.data.status").value("TRACKING_REGISTERED")) + .andExpect(jsonPath("$.data.giftInfo.recipientCreatorNickname").value("creator")) + .andExpect(jsonPath("$.data.giftInfo.applicationNo").value("G-CONTROLLER-DETAIL")) + .andExpect(jsonPath("$.data.giftInfo.paidCan").value(80)) + .andExpect(jsonPath("$.data.giftInfo.tracking").value("CJ대한통운_1234567890")) + .andExpect(jsonPath("$.data.senderInfo.name").value("홍길동")) + .andExpect(jsonPath("$.data.senderInfo.address").value("(06234) 서울시 강남구, 101동 1001호")) + .andExpect(jsonPath("$.data.recipientAddress").isEmpty) + .andExpect(jsonPath("$.data.trackingRequired").value(false)) + .andExpect(jsonPath("$.data.statusTimeline[0].status").value("RECEIVED")) + .andExpect(jsonPath("$.data.statusTimeline[1].occurredAt").value("2026-09-30T12:00:00")) + } + + @Test + @DisplayName("팬은 POST /api/v2/gifts로 선물을 신청하고 접수 결과를 받는다") + fun shouldRegisterGift() { + // Given: 로그인한 팬과 선물 신청 요청을 준비한다. + val member = Member(password = "password", nickname = "fan").apply { id = SENDER_ID } + val request = GiftApplicationRequest( + recipientMemberId = RECIPIENT_ID, + senderName = "홍길동", + senderPhoneNumber = "01012345678", + senderZipCode = "06234", + senderAddress = "서울시 강남구", + senderAddressDetail = "101동 1001호", + sizeCode = GiftSize.SMALL, + categoryId = CATEGORY_ID, + senderTermsAgreed = true, + senderPrivacyAgreed = true, + damageWaiverAgreed = true + ) + val deadline = LocalDateTime.of(2026, 10, 2, 12, 0) + Mockito.`when`( + commandService.registerGift(member, request.toCommand()) + ).thenReturn( + GiftApplicationResult( + applicationNo = "G20260929000001", + status = GiftStatus.RECEIVED, + statusName = "접수 완료", + priceCan = 80, + trackingDeadlineAt = deadline + ) + ) + + // When: 팬이 선물 신청 API를 호출한다. + mockMvc.perform( + post("/api/v2/gifts") + .with(user(MemberAdapter(member))) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + // Then: 신청번호, 접수 상태, 판매가와 운송장 등록 기한을 반환한다. + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.applicationNo").value("G20260929000001")) + .andExpect(jsonPath("$.data.status").value("RECEIVED")) + .andExpect(jsonPath("$.data.statusName").value("접수 완료")) + .andExpect(jsonPath("$.data.priceCan").value(80)) + .andExpect(jsonPath("$.data.trackingDeadlineAt").value("2026-10-02T12:00:00")) + } + + @Test + @DisplayName("팬은 POST /api/v2/gifts/{applicationNo}/cancel로 접수된 선물을 취소하고 환불 결과를 받는다") + fun shouldCancelGift() { + // Given: 취소할 접수 선물과 로그인한 발신자를 준비한다. + val member = Member(password = "password", nickname = "fan").apply { id = SENDER_ID } + val canceledAt = LocalDateTime.of(2026, 9, 29, 12, 0) + Mockito.`when`(commandService.cancelGift(member, APPLICATION_NO)).thenReturn( + GiftCancellationResult( + applicationNo = APPLICATION_NO, + status = GiftStatus.CANCELED, + statusName = "신청 취소", + priceCan = 80, + canceledAt = canceledAt + ) + ) + + // When: 발신자 인증으로 취소 API를 호출한다. + mockMvc.perform( + post("/api/v2/gifts/{applicationNo}/cancel", APPLICATION_NO) + .with(user(MemberAdapter(member))) + ) + // Then: 취소 상태, 환불 금액과 취소 시각을 반환한다. + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.applicationNo").value(APPLICATION_NO)) + .andExpect(jsonPath("$.data.status").value("CANCELED")) + .andExpect(jsonPath("$.data.statusName").value("신청 취소")) + .andExpect(jsonPath("$.data.priceCan").value(80)) + .andExpect(jsonPath("$.data.canceledAt").value("2026-09-29T12:00:00")) + } + + @Test + @DisplayName("팬은 POST /api/v2/gifts/{applicationNo}/tracking으로 운송장을 등록하고 발송 확인 결과를 받는다") + fun shouldRegisterTracking() { + // Given: 운송장 등록 요청과 로그인한 발신자를 준비한다. + val member = Member(password = "password", nickname = "fan").apply { id = SENDER_ID } + val request = GiftTrackingRegistrationRequest( + courierCompanyName = "CJ대한통운", + trackingNumber = "1234567890" + ) + val trackingRegisteredAt = LocalDateTime.of(2026, 9, 29, 12, 0) + val recipientAddressDeadlineAt = trackingRegisteredAt.plusDays(7) + Mockito.`when`(commandService.registerTracking(member, APPLICATION_NO, request.toCommand())).thenReturn( + GiftTrackingRegistrationResult( + applicationNo = APPLICATION_NO, + status = GiftStatus.TRACKING_REGISTERED, + statusName = "발송 확인", + courierCompanyName = "CJ대한통운", + trackingNumber = "1234567890", + trackingRegisteredAt = trackingRegisteredAt, + recipientAddressDeadlineAt = recipientAddressDeadlineAt + ) + ) + + // When: 발신자 인증으로 운송장 등록 API를 호출한다. + mockMvc.perform( + post("/api/v2/gifts/{applicationNo}/tracking", APPLICATION_NO) + .with(user(MemberAdapter(member))) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + // Then: 신청번호, 발송 확인 상태와 운송장 및 수신자 주소 입력 기한을 반환한다. + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.applicationNo").value(APPLICATION_NO)) + .andExpect(jsonPath("$.data.status").value("TRACKING_REGISTERED")) + .andExpect(jsonPath("$.data.statusName").value("발송 확인")) + .andExpect(jsonPath("$.data.courierCompanyName").value("CJ대한통운")) + .andExpect(jsonPath("$.data.trackingNumber").value("1234567890")) + .andExpect(jsonPath("$.data.trackingRegisteredAt").value("2026-09-29T12:00:00")) + .andExpect(jsonPath("$.data.recipientAddressDeadlineAt").value("2026-10-06T12:00:00")) + } + + @Test + @DisplayName("받는 크리에이터는 POST recipient-address로 주소 등록 성공 envelope를 받는다") + fun shouldRegisterRecipientAddress() { + // Given: 로그인한 받는 크리에이터와 주소 등록 요청을 준비한다. + val member = Member(password = "password", nickname = "creator").apply { id = RECIPIENT_ID } + val request = GiftRecipientAddressRegistrationRequest( + recipientName = "김소다", + recipientPhoneNumber = "01098765432", + recipientZipCode = "04524", + recipientAddress = "서울시 중구", + recipientAddressDetail = "202호", + recipientTermsAgreed = true, + recipientPrivacyAgreed = true + ) + val registeredAt = LocalDateTime.of(2026, 9, 29, 12, 0) + Mockito.`when`( + commandService.registerRecipientAddress(member, APPLICATION_NO, request.toCommand()) + ).thenReturn( + GiftRecipientAddressRegistrationResult( + applicationNo = APPLICATION_NO, + status = GiftStatus.TRACKING_REGISTERED, + statusName = "발송 확인", + recipientAddressRegisteredAt = registeredAt + ) + ) + + // When: 받는 크리에이터 인증으로 주소 등록 API를 호출한다. + mockMvc.perform( + post("/api/v2/gifts/{applicationNo}/recipient-address", APPLICATION_NO) + .with(user(MemberAdapter(member))) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + // Then: 성공 envelope에 신청번호, 상태와 주소 등록 시각을 반환한다. + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.applicationNo").value(APPLICATION_NO)) + .andExpect(jsonPath("$.data.status").value("TRACKING_REGISTERED")) + .andExpect(jsonPath("$.data.statusName").value("발송 확인")) + .andExpect(jsonPath("$.data.recipientAddressRegisteredAt").value("2026-09-29T12:00:00")) + } + + @Test + @DisplayName("받는 크리에이터는 POST delivery-complete로 수령 확인 성공 envelope를 받는다") + fun shouldConfirmDelivery() { + val member = Member(password = "password", nickname = "creator").apply { id = RECIPIENT_ID } + val deliveredAt = LocalDateTime.of(2026, 9, 29, 12, 0) + Mockito.`when`(commandService.confirmDelivery(member, APPLICATION_NO)).thenReturn( + GiftDeliveryConfirmationResult( + applicationNo = APPLICATION_NO, + status = GiftStatus.DELIVERED, + statusName = "전달완료", + deliveredAt = deliveredAt + ) + ) + + mockMvc.perform( + post("/api/v2/gifts/{applicationNo}/delivery-complete", APPLICATION_NO) + .with(user(MemberAdapter(member))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.applicationNo").value(APPLICATION_NO)) + .andExpect(jsonPath("$.data.status").value("DELIVERED")) + .andExpect(jsonPath("$.data.statusName").value("전달완료")) + .andExpect(jsonPath("$.data.deliveredAt").value("2026-09-29T12:00:00")) + + Mockito.verify(commandService).confirmDelivery(member, APPLICATION_NO) + } + + @Test + @DisplayName("팬은 POST review로 리뷰를 작성하고 keywords 배열 응답을 받는다") + fun shouldWriteReview() { + val member = Member(password = "password", nickname = "fan").apply { id = SENDER_ID } + val request = GiftReviewRequest( + rating = 5, + keywords = listOf("배송이 빨라요", "안내가 친절해요"), + comment = "선물 전달 과정이 만족스러웠습니다." + ) + val createdAt = LocalDateTime.of(2026, 10, 11, 12, 0) + Mockito.`when`(commandService.writeReview(member, APPLICATION_NO, request.toCommand())).thenReturn( + GiftReviewResult( + reviewId = 1L, + applicationNo = APPLICATION_NO, + rating = 5, + keywords = listOf("배송이 빨라요", "안내가 친절해요"), + comment = "선물 전달 과정이 만족스러웠습니다.", + createdAt = createdAt + ) + ) + + mockMvc.perform( + post("/api/v2/gifts/{applicationNo}/review", APPLICATION_NO) + .with(user(MemberAdapter(member))) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.reviewId").value(1L)) + .andExpect(jsonPath("$.data.applicationNo").value(APPLICATION_NO)) + .andExpect(jsonPath("$.data.rating").value(5)) + .andExpect(jsonPath("$.data.keywords[0]").value("배송이 빨라요")) + .andExpect(jsonPath("$.data.keywords[1]").value("안내가 친절해요")) + .andExpect(jsonPath("$.data.keyword").doesNotExist()) + .andExpect(jsonPath("$.data.comment").value("선물 전달 과정이 만족스러웠습니다.")) + .andExpect(jsonPath("$.data.createdAt").value("2026-10-11T12:00:00")) + + Mockito.verify(commandService).writeReview(member, APPLICATION_NO, request.toCommand()) + } + + private fun saveGift( + applicationNo: String, + senderMemberId: Long, + recipientMemberId: Long, + status: GiftStatus + ) = giftRepository.save( + Gift( + applicationNo = applicationNo, + senderMemberId = senderMemberId, + recipientMemberId = recipientMemberId, + status = status, + sizeCode = GiftSize.SMALL, + categoryId = CATEGORY_ID, + categoryNameSnapshot = "인형", + salePriceCan = 80, + canUsageId = 1L, + senderTermsAgreed = true, + senderPrivacyAgreed = true, + senderTermsAgreedAt = LocalDateTime.of(2026, 9, 29, 12, 0), + senderPrivacyAgreedAt = LocalDateTime.of(2026, 9, 29, 12, 0) + ) + ) + + private companion object { + const val SENDER_ID = 1L + const val RECIPIENT_ID = 2L + const val CATEGORY_ID = 3L + const val APPLICATION_NO = "G20260929000001" + } +} From 6f9dcaa1e2b9d013ef81ffed8ad8699fca3e3794 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 30 Sep 2026 13:39:24 +0900 Subject: [PATCH 17/53] =?UTF-8?q?feat(gift):=20=EC=84=A0=EB=AC=BC=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20DTO=EB=A5=BC=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../v2/api/admin/gift/dto/AdminGiftRequest.kt | 34 +++++++++++ .../api/admin/gift/dto/AdminGiftResponse.kt | 59 +++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/dto/AdminGiftRequest.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/dto/AdminGiftResponse.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/dto/AdminGiftRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/dto/AdminGiftRequest.kt new file mode 100644 index 00000000..0c1121a9 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/dto/AdminGiftRequest.kt @@ -0,0 +1,34 @@ +package kr.co.vividnext.sodalive.v2.api.admin.gift.dto + +import kr.co.vividnext.sodalive.v2.gift.application.GiftCategoryCommand +import kr.co.vividnext.sodalive.v2.gift.application.GiftSizePriceCommand + +data class AdminGiftCategoryRequest( + val categoryCode: String, + val name: String, + val requiresDamageWaiver: Boolean, + val isActive: Boolean +) { + fun toCommand() = GiftCategoryCommand( + categoryCode = categoryCode, + name = name, + requiresDamageWaiver = requiresDamageWaiver, + isActive = isActive + ) +} + +data class AdminGiftSizePriceRequest( + val basePriceCan: Int, + val salePriceCan: Int, + val isActive: Boolean +) { + fun toCommand() = GiftSizePriceCommand( + basePriceCan = basePriceCan, + salePriceCan = salePriceCan, + isActive = isActive + ) +} + +data class AdminGiftMarkUndeliverableRequest( + val reason: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/dto/AdminGiftResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/dto/AdminGiftResponse.kt new file mode 100644 index 00000000..58f698a1 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/dto/AdminGiftResponse.kt @@ -0,0 +1,59 @@ +package kr.co.vividnext.sodalive.v2.api.admin.gift.dto + +import kr.co.vividnext.sodalive.v2.gift.application.GiftCategoryResult +import kr.co.vividnext.sodalive.v2.gift.application.GiftOperationStatusResult +import kr.co.vividnext.sodalive.v2.gift.application.GiftSizePriceResult +import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus +import java.time.LocalDateTime + +data class AdminGiftCategoryResponse( + val categoryId: Long, + val categoryCode: String, + val name: String, + val requiresDamageWaiver: Boolean, + val isActive: Boolean +) { + companion object { + fun from(result: GiftCategoryResult) = AdminGiftCategoryResponse( + categoryId = result.categoryId, + categoryCode = result.categoryCode, + name = result.name, + requiresDamageWaiver = result.requiresDamageWaiver, + isActive = result.isActive + ) + } +} + +data class AdminGiftSizePriceResponse( + val sizeCode: String, + val name: String, + val basePriceCan: Int, + val salePriceCan: Int, + val isActive: Boolean +) { + companion object { + fun from(result: GiftSizePriceResult) = AdminGiftSizePriceResponse( + sizeCode = result.sizeCode.name, + name = result.sizeName, + basePriceCan = result.basePriceCan, + salePriceCan = result.salePriceCan, + isActive = result.isActive + ) + } +} + +data class AdminGiftOperationStatusResponse( + val applicationNo: String, + val status: GiftStatus, + val statusName: String, + val occurredAt: LocalDateTime +) { + companion object { + fun from(result: GiftOperationStatusResult) = AdminGiftOperationStatusResponse( + applicationNo = result.applicationNo, + status = result.status, + statusName = result.statusName, + occurredAt = result.occurredAt + ) + } +} From d360db6f08b9f29fccda0bbd38e34bd1630a3af0 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 30 Sep 2026 13:39:44 +0900 Subject: [PATCH 18/53] =?UTF-8?q?feat(gift):=20=EC=84=A0=EB=AC=BC=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20API=EB=A5=BC=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/in/web/AdminGiftController.kt | 74 ++++++ .../adapter/in/web/AdminGiftControllerTest.kt | 224 ++++++++++++++++++ 2 files changed, 298 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftController.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftController.kt new file mode 100644 index 00000000..3de06d87 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftController.kt @@ -0,0 +1,74 @@ +package kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.`in`.web + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftCategoryRequest +import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftCategoryResponse +import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftMarkUndeliverableRequest +import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftOperationStatusResponse +import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftSizePriceRequest +import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftSizePriceResponse +import kr.co.vividnext.sodalive.v2.gift.application.GiftAdminService +import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v2/admin") +@PreAuthorize("hasRole('ADMIN')") +class AdminGiftController(private val service: GiftAdminService) { + @GetMapping("/gift-categories") + fun getCategories() = ApiResponse.ok(service.getCategories().map { AdminGiftCategoryResponse.from(it) }) + + @PostMapping("/gift-categories") + fun createCategory(@RequestBody request: AdminGiftCategoryRequest) = ApiResponse.ok( + AdminGiftCategoryResponse.from(service.createCategory(request.toCommand())) + ) + + @PutMapping("/gift-categories/{categoryId}") + fun updateCategory( + @PathVariable categoryId: Long, + @RequestBody request: AdminGiftCategoryRequest + ) = ApiResponse.ok(AdminGiftCategoryResponse.from(service.updateCategory(categoryId, request.toCommand()))) + + @DeleteMapping("/gift-categories/{categoryId}") + fun deleteCategory(@PathVariable categoryId: Long) = ApiResponse.ok( + AdminGiftCategoryResponse.from(service.deleteCategory(categoryId)) + ) + + @GetMapping("/gift-size-prices") + fun getSizePrices() = ApiResponse.ok(service.getSizePrices().map { AdminGiftSizePriceResponse.from(it) }) + + @PutMapping("/gift-size-prices/{sizeCode}") + fun updateSizePrice( + @PathVariable sizeCode: GiftSize, + @RequestBody request: AdminGiftSizePriceRequest + ) = ApiResponse.ok(AdminGiftSizePriceResponse.from(service.updateSizePrice(sizeCode, request.toCommand()))) + + @PostMapping("/gifts/{applicationNo}/arrive-mailbox") + fun arriveMailbox(@PathVariable applicationNo: String) = ApiResponse.ok( + AdminGiftOperationStatusResponse.from(service.arriveMailbox(applicationNo)) + ) + + @PostMapping("/gifts/{applicationNo}/complete-inspection") + fun completeInspection(@PathVariable applicationNo: String) = ApiResponse.ok( + AdminGiftOperationStatusResponse.from(service.completeInspection(applicationNo)) + ) + + @PostMapping("/gifts/{applicationNo}/mark-undeliverable") + fun markUndeliverable( + @PathVariable applicationNo: String, + @RequestBody request: AdminGiftMarkUndeliverableRequest + ) = ApiResponse.ok(AdminGiftOperationStatusResponse.from(service.markUndeliverable(applicationNo, request.reason))) + + @PostMapping("/gifts/{applicationNo}/complete-delivery") + fun completeDelivery(@PathVariable applicationNo: String) = ApiResponse.ok( + AdminGiftOperationStatusResponse.from(service.completeDelivery(applicationNo)) + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt new file mode 100644 index 00000000..38bd8172 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt @@ -0,0 +1,224 @@ +package kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.`in`.web + +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePrice +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository +import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize +import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user +import org.springframework.test.annotation.DirtiesContext +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.time.LocalDateTime + +@SpringBootTest( + properties = [ + "cloud.aws.cloud-front.host=https://cdn.test", + "spring.datasource.url=jdbc:h2:mem:gift-admin-controller;MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;" + + "DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE" + ] +) +@AutoConfigureMockMvc +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@DisplayName("선물 관리자 API 테스트") +class AdminGiftControllerTest @Autowired constructor( + private val mockMvc: MockMvc, + private val categoryRepository: GiftCategoryRepository, + private val sizePriceRepository: GiftSizePriceRepository, + private val giftRepository: GiftRepository, + private val giftDeliveryRepository: GiftDeliveryRepository +) { + @BeforeEach + fun deleteGiftFixtures() { + giftDeliveryRepository.deleteAll() + giftRepository.deleteAll() + } + + @Test + @DisplayName("관리자는 카테고리를 등록, 조회, 수정, 논리삭제한다") + fun shouldManageGiftCategories() { + mockMvc.perform( + post("/api/v2/admin/gift-categories") + .with(user("admin").roles("ADMIN")) + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + {"categoryCode":"DOLL","name":"인형","requiresDamageWaiver":true,"isActive":true} + """.trimIndent() + ) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.categoryCode").value("DOLL")) + + val categoryId = categoryRepository.findByCategoryCode("DOLL")!!.id!! + + mockMvc.perform( + put("/api/v2/admin/gift-categories/$categoryId") + .with(user("admin").roles("ADMIN")) + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + {"categoryCode":"DOLL","name":"큰 인형","requiresDamageWaiver":false,"isActive":true} + """.trimIndent() + ) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.name").value("큰 인형")) + + mockMvc.perform(get("/api/v2/admin/gift-categories").with(user("admin").roles("ADMIN"))) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data[0].name").value("큰 인형")) + + mockMvc.perform(delete("/api/v2/admin/gift-categories/$categoryId").with(user("admin").roles("ADMIN"))) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.isActive").value(false)) + } + + @Test + @DisplayName("관리자는 사이즈 가격을 조회하고 수정하며 잘못된 가격을 거부한다") + fun shouldManageGiftSizePrices() { + sizePriceRepository.save(GiftSizePrice(sizeCode = GiftSize.SMALL, basePriceCan = 100, salePriceCan = 100)) + + mockMvc.perform(get("/api/v2/admin/gift-size-prices").with(user("admin").roles("ADMIN"))) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data[0].sizeCode").value("SMALL")) + + mockMvc.perform( + put("/api/v2/admin/gift-size-prices/SMALL") + .with(user("admin").roles("ADMIN")) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"basePriceCan":100,"salePriceCan":80,"isActive":true}""") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.salePriceCan").value(80)) + + mockMvc.perform( + put("/api/v2/admin/gift-size-prices/SMALL") + .with(user("admin").roles("ADMIN")) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"basePriceCan":100,"salePriceCan":101,"isActive":true}""") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(false)) + } + + @Test + @DisplayName("관리자는 사서함 도착과 검수완료를 처리한다") + fun shouldUpdateGiftOperationStatuses() { + saveGift(GiftStatus.TRACKING_REGISTERED) + + mockMvc.perform(post("/api/v2/admin/gifts/$APPLICATION_NO/arrive-mailbox").with(user("admin").roles("ADMIN"))) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.applicationNo").value(APPLICATION_NO)) + .andExpect(jsonPath("$.data.status").value("ARRIVED_AT_MAILBOX")) + + mockMvc.perform(post("/api/v2/admin/gifts/$APPLICATION_NO/complete-inspection").with(user("admin").roles("ADMIN"))) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.status").value("INSPECTION_COMPLETED")) + } + + @Test + @DisplayName("관리자는 전달불가 사유를 등록하고 전달불가 처리한다") + fun shouldMarkGiftUndeliverable() { + saveGift(GiftStatus.INSPECTION_COMPLETED) + + mockMvc.perform( + post("/api/v2/admin/gifts/$APPLICATION_NO/mark-undeliverable") + .with(user("admin").roles("ADMIN")) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"reason":"선물 접수 규격에 맞지 않습니다."}""") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.applicationNo").value(APPLICATION_NO)) + .andExpect(jsonPath("$.data.status").value("UNDELIVERABLE")) + } + + @Test + @DisplayName("관리자는 전달불가 사유 없이 전달불가 처리할 수 없다") + fun shouldRejectUndeliverableWithoutReason() { + saveGift(GiftStatus.INSPECTION_COMPLETED) + + mockMvc.perform( + post("/api/v2/admin/gifts/$APPLICATION_NO/mark-undeliverable") + .with(user("admin").roles("ADMIN")) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"reason":" "}""") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(false)) + } + + @Test + @DisplayName("관리자는 검수완료 선물을 전달완료 처리한다") + fun shouldCompleteGiftDelivery() { + saveGift(GiftStatus.INSPECTION_COMPLETED) + + mockMvc.perform(post("/api/v2/admin/gifts/$APPLICATION_NO/complete-delivery").with(user("admin").roles("ADMIN"))) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.applicationNo").value(APPLICATION_NO)) + .andExpect(jsonPath("$.data.status").value("DELIVERED")) + } + + private fun saveGift(status: GiftStatus) { + val gift = giftRepository.save( + Gift( + applicationNo = APPLICATION_NO, + senderMemberId = SENDER_MEMBER_ID, + recipientMemberId = RECIPIENT_MEMBER_ID, + status = status, + sizeCode = GiftSize.SMALL, + categoryId = 1L, + categoryNameSnapshot = "인형", + salePriceCan = 80, + canUsageId = 10L, + senderTermsAgreed = true, + senderPrivacyAgreed = true, + senderTermsAgreedAt = NOW.minusDays(1), + senderPrivacyAgreedAt = NOW.minusDays(1), + damageWaiverAgreed = true + ) + ) + giftDeliveryRepository.save( + GiftDelivery( + giftId = requireNotNull(gift.id), + senderName = "홍길동", + senderPhoneNumber = "01012345678", + senderZipCode = "06234", + senderAddress = "서울시 강남구", + trackingDeadlineAt = NOW.minusDays(1), + trackingRegisteredAt = NOW.minusHours(1) + ) + ) + } + + private companion object { + const val APPLICATION_NO = "G20260929000001" + const val SENDER_MEMBER_ID = 1L + const val RECIPIENT_MEMBER_ID = 2L + val NOW: LocalDateTime = LocalDateTime.of(2026, 10, 3, 12, 0) + } +} From cd949a469406b686c57571c5a7b6b1892596cbda Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 30 Sep 2026 14:33:46 +0900 Subject: [PATCH 19/53] =?UTF-8?q?docs(gift):=20=EC=84=A0=EB=AC=BC=20API=20?= =?UTF-8?q?=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20=EC=9A=94?= =?UTF-8?q?=EC=95=BD=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../client-api-summary.md | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 docs/20260929_크리에이터_선물하기/client-api-summary.md diff --git a/docs/20260929_크리에이터_선물하기/client-api-summary.md b/docs/20260929_크리에이터_선물하기/client-api-summary.md new file mode 100644 index 00000000..2e22f448 --- /dev/null +++ b/docs/20260929_크리에이터_선물하기/client-api-summary.md @@ -0,0 +1,66 @@ +# 선물하기 클라이언트 API 요약 + +## 문서 목적 + +- 이 문서는 클라이언트 구현 프롬프트 작성용 빠른 참조다. +- 상세 request/response 계약은 `prd.md`의 `8. API 계약`을 기준으로 한다. +- 여기서는 사용자 역할별 URI, 역할, 핵심 request/response 필드만 요약한다. + +## 문서 분리 결정 + +PRD에는 상세 API 계약, request/response 예시, 요구사항을 유지한다. 클라이언트 프롬프트용 역할별 인벤토리는 별도 문서로 둔다. + +- 이유: PRD에 같은 URI 정보를 역할별로 다시 넣으면 상세 계약과 요약표를 동시에 갱신해야 한다. +- 기준: PRD는 제품 요구사항과 API 계약의 원본, 이 문서는 클라이언트 구현을 위한 탐색용 색인이다. + +## 공통/조회 API + +| 사용자 | Method | URI | 역할 | Request | Response | +|---|---:|---|---|---|---| +| 비회원/회원 | GET | `/api/v2/gifts/form-options` | 선물 신청 폼에 필요한 활성 카테고리와 사이즈별 가격 옵션을 조회한다. | 없음 | `sizes[]`, `categories[]` | +| 팬/크리에이터 | GET | `/api/v2/gifts?type=ALL&page=0&size=20` | 내 선물함 목록을 조회하고 `type=ALL|SENT|RECEIVED`로 전체/보낸/받은 선물을 필터링한다. | query: `type`, `page`, `size` | `items[]`, `page`, `size`, `hasNext` | +| 팬/크리에이터 | GET | `/api/v2/gifts/{applicationNo}` | 선물 상세를 조회하며 로그인 회원이 팬인지 크리에이터인지에 따라 노출 정보와 CTA flag가 달라진다. | path: `applicationNo` | `applicationNo`, `direction`, `status`, `statusName`, `giftInfo`, `senderInfo`, `recipientAddress`, `mailbox`, `trackingRequired`, `recipientAddressRequired`, `delivery`, `statusTimeline`, `review` | + +## 팬 API + +| 사용자 | Method | URI | 역할 | Request | Response | +|---|---:|---|---|---|---| +| 팬 | POST | `/api/v2/gifts` | 크리에이터에게 선물을 신청하고 최종 결제 금액만큼 캔을 차감한다. | body: `recipientMemberId`, `senderName`, `senderPhoneNumber`, `senderZipCode`, `senderAddress`, `senderAddressDetail`, `sizeCode`, `categoryId`, `senderTermsAgreed`, `senderPrivacyAgreed`, `damageWaiverAgreed` | `applicationNo`, `status`, `statusName`, `priceCan`, `trackingDeadlineAt` | +| 팬 | POST | `/api/v2/gifts/{applicationNo}/cancel` | 운송장 등록 전 선물 신청을 취소하고 전액 환불한다. | path: `applicationNo` | `applicationNo`, `status`, `refundedCan` | +| 팬 | POST | `/api/v2/gifts/{applicationNo}/tracking` | 발송 후 택배사와 운송장 번호를 등록한다. | path: `applicationNo`, body: `courierCompanyName`, `trackingNumber` | `applicationNo`, `status`, `recipientAddressDeadlineAt` | +| 팬 | POST | `/api/v2/gifts/{applicationNo}/review` | 전달 완료된 선물에 리뷰를 작성한다. | path: `applicationNo`, body: `rating`, `keywords`, `comment` | `reviewId`, `applicationNo`, `rating`, `keywords`, `comment`, `createdAt` | + +## 크리에이터 API + +| 사용자 | Method | URI | 역할 | Request | Response | +|---|---:|---|---|---|---| +| 크리에이터 | POST | `/api/v2/gifts/{applicationNo}/recipient-address` | 운송장 등록 이후 배송지와 수령 약관 동의를 등록한다. | path: `applicationNo`, body: `recipientName`, `recipientPhoneNumber`, `recipientZipCode`, `recipientAddress`, `recipientAddressDetail`, `recipientTermsAgreed`, `recipientPrivacyAgreed` | `applicationNo`, `recipientAddressRegisteredAt` | +| 크리에이터 | POST | `/api/v2/gifts/{applicationNo}/delivery-complete` | 검수 완료된 선물을 직접 수령 완료 처리한다. | path: `applicationNo` | `applicationNo`, `status`, `deliveredAt` | + +## 관리자 API + +| 사용자 | Method | URI | 역할 | Request | Response | +|---|---:|---|---|---|---| +| 관리자 | GET | `/api/v2/admin/gift-categories` | 선물 카테고리 목록을 조회한다. | 없음 | `AdminGiftCategoryResponse[]` | +| 관리자 | POST | `/api/v2/admin/gift-categories` | 선물 카테고리를 등록한다. | body: `categoryCode`, `name`, `requiresDamageWaiver`, `isActive` | `categoryId`, `categoryCode`, `name`, `requiresDamageWaiver`, `isActive` | +| 관리자 | PUT | `/api/v2/admin/gift-categories/{categoryId}` | 선물 카테고리를 수정한다. | path: `categoryId`, body: `categoryCode`, `name`, `requiresDamageWaiver`, `isActive` | `categoryId`, `categoryCode`, `name`, `requiresDamageWaiver`, `isActive` | +| 관리자 | DELETE | `/api/v2/admin/gift-categories/{categoryId}` | 선물 카테고리를 비활성화한다. | path: `categoryId` | `categoryId`, `categoryCode`, `name`, `requiresDamageWaiver`, `isActive=false` | +| 관리자 | GET | `/api/v2/admin/gift-size-prices` | 사이즈별 기본가와 판매가를 조회한다. | 없음 | `AdminGiftSizePriceResponse[]` | +| 관리자 | PUT | `/api/v2/admin/gift-size-prices/{sizeCode}` | 사이즈별 기본가와 판매가를 수정한다. | path: `sizeCode`, body: `basePriceCan`, `salePriceCan`, `isActive` | `sizeCode`, `name`, `basePriceCan`, `salePriceCan`, `isActive` | +| 관리자 | POST | `/api/v2/admin/gifts/{applicationNo}/arrive-mailbox` | 발송 확인 선물을 사서함 도착 처리한다. | path: `applicationNo` | `applicationNo`, `status`, `statusName`, `occurredAt` | +| 관리자 | POST | `/api/v2/admin/gifts/{applicationNo}/complete-inspection` | 사서함 도착 선물을 검수 완료 처리한다. | path: `applicationNo` | `applicationNo`, `status`, `statusName`, `occurredAt` | +| 관리자 | POST | `/api/v2/admin/gifts/{applicationNo}/mark-undeliverable` | 진행 중 선물을 전달 불가 처리하고 사유를 저장한다. | path: `applicationNo`, body: `reason` | `applicationNo`, `status`, `statusName`, `occurredAt` | +| 관리자 | POST | `/api/v2/admin/gifts/{applicationNo}/complete-delivery` | 검수 완료 선물을 운영자가 전달 완료 처리한다. | path: `applicationNo` | `applicationNo`, `status`, `statusName`, `occurredAt` | + +관리자 응답 DTO 요약: + +- `AdminGiftCategoryResponse`: `categoryId`, `categoryCode`, `name`, `requiresDamageWaiver`, `isActive` +- `AdminGiftSizePriceResponse`: `sizeCode`, `name`, `basePriceCan`, `salePriceCan`, `isActive` +- `AdminGiftOperationStatusResponse`: `applicationNo`, `status`, `statusName`, `occurredAt` + +## 클라이언트 구현 참고 + +- 선물 푸시 딥링크는 `deepLinkValue=GIFT_DETAIL`, `deepLinkId=applicationNo`를 사용한다. +- 딥링크 진입 후에는 `GET /api/v2/gifts/{applicationNo}`로 상세를 조회해 `direction`, `trackingRequired`, `recipientAddressRequired`를 기준으로 화면과 CTA를 결정한다. +- 운송장 미등록 자동취소는 서버 스케줄러가 처리하며, 팬 클라이언트는 상세 조회의 `status=CANCELED`와 푸시를 기준으로 표시한다. +- 모든 응답은 기존 `ApiResponse.ok(...)` envelope 안의 `data`로 내려간다. From 631c985a2453a45eec226d4445b0e0bf391a1916 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 30 Sep 2026 14:40:40 +0900 Subject: [PATCH 20/53] =?UTF-8?q?docs(gift):=20=EC=84=A0=EB=AC=BC=20?= =?UTF-8?q?=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20API=20DTO?= =?UTF-8?q?=EB=A5=BC=20=EB=B3=B4=EA=B0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../client-api-summary.md | 240 +++++++++++++++++- 1 file changed, 236 insertions(+), 4 deletions(-) diff --git a/docs/20260929_크리에이터_선물하기/client-api-summary.md b/docs/20260929_크리에이터_선물하기/client-api-summary.md index 2e22f448..be779547 100644 --- a/docs/20260929_크리에이터_선물하기/client-api-summary.md +++ b/docs/20260929_크리에이터_선물하기/client-api-summary.md @@ -26,16 +26,16 @@ PRD에는 상세 API 계약, request/response 예시, 요구사항을 유지한 | 사용자 | Method | URI | 역할 | Request | Response | |---|---:|---|---|---|---| | 팬 | POST | `/api/v2/gifts` | 크리에이터에게 선물을 신청하고 최종 결제 금액만큼 캔을 차감한다. | body: `recipientMemberId`, `senderName`, `senderPhoneNumber`, `senderZipCode`, `senderAddress`, `senderAddressDetail`, `sizeCode`, `categoryId`, `senderTermsAgreed`, `senderPrivacyAgreed`, `damageWaiverAgreed` | `applicationNo`, `status`, `statusName`, `priceCan`, `trackingDeadlineAt` | -| 팬 | POST | `/api/v2/gifts/{applicationNo}/cancel` | 운송장 등록 전 선물 신청을 취소하고 전액 환불한다. | path: `applicationNo` | `applicationNo`, `status`, `refundedCan` | -| 팬 | POST | `/api/v2/gifts/{applicationNo}/tracking` | 발송 후 택배사와 운송장 번호를 등록한다. | path: `applicationNo`, body: `courierCompanyName`, `trackingNumber` | `applicationNo`, `status`, `recipientAddressDeadlineAt` | +| 팬 | POST | `/api/v2/gifts/{applicationNo}/cancel` | 운송장 등록 전 선물 신청을 취소하고 전액 환불한다. | path: `applicationNo` | `applicationNo`, `status`, `statusName`, `priceCan`, `canceledAt` | +| 팬 | POST | `/api/v2/gifts/{applicationNo}/tracking` | 발송 후 택배사와 운송장 번호를 등록한다. | path: `applicationNo`, body: `courierCompanyName`, `trackingNumber` | `applicationNo`, `status`, `statusName`, `courierCompanyName`, `trackingNumber`, `trackingRegisteredAt`, `recipientAddressDeadlineAt` | | 팬 | POST | `/api/v2/gifts/{applicationNo}/review` | 전달 완료된 선물에 리뷰를 작성한다. | path: `applicationNo`, body: `rating`, `keywords`, `comment` | `reviewId`, `applicationNo`, `rating`, `keywords`, `comment`, `createdAt` | ## 크리에이터 API | 사용자 | Method | URI | 역할 | Request | Response | |---|---:|---|---|---|---| -| 크리에이터 | POST | `/api/v2/gifts/{applicationNo}/recipient-address` | 운송장 등록 이후 배송지와 수령 약관 동의를 등록한다. | path: `applicationNo`, body: `recipientName`, `recipientPhoneNumber`, `recipientZipCode`, `recipientAddress`, `recipientAddressDetail`, `recipientTermsAgreed`, `recipientPrivacyAgreed` | `applicationNo`, `recipientAddressRegisteredAt` | -| 크리에이터 | POST | `/api/v2/gifts/{applicationNo}/delivery-complete` | 검수 완료된 선물을 직접 수령 완료 처리한다. | path: `applicationNo` | `applicationNo`, `status`, `deliveredAt` | +| 크리에이터 | POST | `/api/v2/gifts/{applicationNo}/recipient-address` | 운송장 등록 이후 배송지와 수령 약관 동의를 등록한다. | path: `applicationNo`, body: `recipientName`, `recipientPhoneNumber`, `recipientZipCode`, `recipientAddress`, `recipientAddressDetail`, `recipientTermsAgreed`, `recipientPrivacyAgreed` | `applicationNo`, `status`, `statusName`, `recipientAddressRegisteredAt` | +| 크리에이터 | POST | `/api/v2/gifts/{applicationNo}/delivery-complete` | 검수 완료된 선물을 직접 수령 완료 처리한다. | path: `applicationNo` | `applicationNo`, `status`, `statusName`, `deliveredAt` | ## 관리자 API @@ -58,6 +58,238 @@ PRD에는 상세 API 계약, request/response 예시, 요구사항을 유지한 - `AdminGiftSizePriceResponse`: `sizeCode`, `name`, `basePriceCan`, `salePriceCan`, `isActive` - `AdminGiftOperationStatusResponse`: `applicationNo`, `status`, `statusName`, `occurredAt` +## Kotlin DTO 형태 + +아래는 JSON으로 변환되기 직전의 API request/response `data class` 형태다. `companion object` 변환 함수는 클라이언트 입력/출력 형태와 직접 관련이 없어 생략했다. + +### 사용자 Request DTO + +```kotlin +data class GiftApplicationRequest( + val recipientMemberId: Long, + val senderName: String, + val senderPhoneNumber: String, + val senderZipCode: String, + val senderAddress: String, + val senderAddressDetail: String?, + val sizeCode: GiftSize, + val categoryId: Long, + val senderTermsAgreed: Boolean, + val senderPrivacyAgreed: Boolean, + val damageWaiverAgreed: Boolean +) + +data class GiftTrackingRegistrationRequest( + val courierCompanyName: String, + val trackingNumber: String +) + +data class GiftRecipientAddressRegistrationRequest( + val recipientName: String, + val recipientPhoneNumber: String, + val recipientZipCode: String, + val recipientAddress: String, + val recipientAddressDetail: String?, + val recipientTermsAgreed: Boolean, + val recipientPrivacyAgreed: Boolean +) + +data class GiftReviewRequest( + val rating: Int, + val keywords: List, + val comment: String? +) +``` + +### 사용자 Response DTO + +```kotlin +data class GiftFormOptionsResponse( + val sizes: List, + val categories: List +) + +data class GiftSizeOptionResponse( + val sizeCode: String, + val name: String, + val basePriceCan: Int, + val salePriceCan: Int +) + +data class GiftCategoryOptionResponse( + val categoryId: Long, + val name: String, + val requiresDamageWaiver: Boolean +) + +data class GiftListResponse( + val totalCount: Long, + val items: List, + val page: Int, + val size: Int, + val hasNext: Boolean +) + +data class GiftListItemResponse( + val applicationNo: String, + val direction: String, + val status: String, + val statusName: String, + val priceCan: Int, + val categoryName: String, + val sizeName: String, + val createdAt: LocalDateTime? +) + +data class GiftDetailResponse( + val applicationNo: String, + val direction: String, + val status: String, + val statusName: String, + val giftInfo: GiftDetailInfoResponse, + val senderInfo: GiftAddressResponse?, + val recipientAddress: GiftAddressResponse?, + val mailbox: GiftMailboxResponse?, + val trackingRequired: Boolean, + val recipientAddressRequired: Boolean, + val recipientAddressDeadlineAt: LocalDateTime?, + val delivery: GiftDeliveryInfoResponse, + val statusTimeline: List +) + +data class GiftDetailInfoResponse( + val recipientCreatorNickname: String?, + val senderNickname: String?, + val sizeName: String, + val categoryName: String, + val applicationNo: String?, + val paidCan: Int?, + val tracking: String?, + val shippingRequestedAt: LocalDateTime? +) + +data class GiftAddressResponse( + val name: String, + val phoneNumber: String, + val address: String +) + +data class GiftMailboxResponse( + val name: String, + val address: String, + val phoneNumber: String +) + +data class GiftDeliveryInfoResponse( + val canceledAt: LocalDateTime?, + val undeliverableAt: LocalDateTime?, + val undeliverableReason: String? +) + +data class GiftStatusTimelineResponse( + val status: String, + val statusName: String, + val occurredAt: LocalDateTime? +) + +data class GiftApplicationResponse( + val applicationNo: String, + val status: String, + val statusName: String, + val priceCan: Int, + val trackingDeadlineAt: LocalDateTime +) + +data class GiftCancellationResponse( + val applicationNo: String, + val status: String, + val statusName: String, + val priceCan: Int, + val canceledAt: LocalDateTime +) + +data class GiftTrackingRegistrationResponse( + val applicationNo: String, + val status: String, + val statusName: String, + val courierCompanyName: String, + val trackingNumber: String, + val trackingRegisteredAt: LocalDateTime, + val recipientAddressDeadlineAt: LocalDateTime +) + +data class GiftRecipientAddressRegistrationResponse( + val applicationNo: String, + val status: String, + val statusName: String, + val recipientAddressRegisteredAt: LocalDateTime +) + +data class GiftDeliveryConfirmationResponse( + val applicationNo: String, + val status: String, + val statusName: String, + val deliveredAt: LocalDateTime +) + +data class GiftReviewResponse( + val reviewId: Long, + val applicationNo: String, + val rating: Int, + val keywords: List, + val comment: String?, + val createdAt: LocalDateTime? +) +``` + +### 관리자 Request DTO + +```kotlin +data class AdminGiftCategoryRequest( + val categoryCode: String, + val name: String, + val requiresDamageWaiver: Boolean, + val isActive: Boolean +) + +data class AdminGiftSizePriceRequest( + val basePriceCan: Int, + val salePriceCan: Int, + val isActive: Boolean +) + +data class AdminGiftMarkUndeliverableRequest( + val reason: String +) +``` + +### 관리자 Response DTO + +```kotlin +data class AdminGiftCategoryResponse( + val categoryId: Long, + val categoryCode: String, + val name: String, + val requiresDamageWaiver: Boolean, + val isActive: Boolean +) + +data class AdminGiftSizePriceResponse( + val sizeCode: String, + val name: String, + val basePriceCan: Int, + val salePriceCan: Int, + val isActive: Boolean +) + +data class AdminGiftOperationStatusResponse( + val applicationNo: String, + val status: GiftStatus, + val statusName: String, + val occurredAt: LocalDateTime +) +``` + ## 클라이언트 구현 참고 - 선물 푸시 딥링크는 `deepLinkValue=GIFT_DETAIL`, `deepLinkId=applicationNo`를 사용한다. From 38941d9fe9b0220fc494a24bbc98d39911523f8b Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 30 Sep 2026 17:37:09 +0900 Subject: [PATCH 21/53] =?UTF-8?q?docs(gift):=20=EC=84=A0=EB=AC=BC=20?= =?UTF-8?q?=EC=9A=94=EA=B5=AC=EC=82=AC=ED=95=AD=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EC=9D=84=20=EA=B8=B0=EB=A1=9D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20260929_크리에이터_선물하기/plan-task.md | 116 +++++++++++++++++- docs/20260929_크리에이터_선물하기/prd.md | 65 ++++++---- 2 files changed, 159 insertions(+), 22 deletions(-) diff --git a/docs/20260929_크리에이터_선물하기/plan-task.md b/docs/20260929_크리에이터_선물하기/plan-task.md index 9075d095..6b73c835 100644 --- a/docs/20260929_크리에이터_선물하기/plan-task.md +++ b/docs/20260929_크리에이터_선물하기/plan-task.md @@ -8,7 +8,7 @@ | 작성일 | 2026-09-29 | | 요구사항 기준 | `docs/20260929_크리에이터_선물하기/prd.md` | | API 기준 | `docs/20260929_크리에이터_선물하기/prd.md`의 `8. API 계약` | -| 현재 Phase | Phase 7 완료 | +| 현재 Phase | Phase 9 완료 | | 현재 활성 Goal | 완료 | ## 목표 @@ -38,6 +38,8 @@ | 5 | 완료 | `3/3` | 완료 | 없음 | | 6 | 완료 | `2/2` | 완료 | 없음 | | 7 | 완료 | `3/3` | 완료 | 없음 | +| 8 | 완료 | `1/1` | 완료 | 없음 | +| 9 | 완료 | `3/3` | 완료 | 없음 | - 동시에 하나의 미완료 goal만 운용한다. - 구현 중 요구사항이 바뀌면 이 문서의 체크박스와 범위를 먼저 갱신한 뒤 코드를 수정한다. @@ -655,6 +657,57 @@ **Expected:** 모든 명령 exit 0. PRD `GIFT-001`~`GIFT-013`이 구현·검증·문서 추적으로 연결된다. +## Phase 9: 카테고리 확장과 신청번호 정책 변경 + +**Phase 결과:** 카테고리는 분류번호, 내부 범용코드, 분류명, 접수코드, 대표품목, 파손면책 조건을 관리하고, 신청번호는 카테고리+날짜별 sequence로 발급된다. + +**선행조건:** Phase 8 완료, 사용자 확정 요구사항 반영. + +**Phase 완료 조건:** `P9-T1`~`P9-T3`와 `P9-GATE` 완료, 검증 기록 누적. + +### 구현 항목 + +#### Task 9.1 문서와 DDL 계약 갱신 + +**Goal 실행 `P9-T1`:** PRD, 구현 계획, DDL에 확정된 카테고리 필드와 신청번호 정책을 반영한다. + +- **Files:** Modify `prd.md`, `plan-task.md`, `gift-schema.sql`. +- [x] **RED:** 문서/DDL의 기존 `G{yyyyMMdd}{dailySequence6}`와 기존 카테고리 필드 계약을 확인한다. +- [x] **GREEN:** `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive`와 새 신청번호 형식을 반영한다. +- [x] **검증:** 세 문서가 같은 제약과 응답 정책을 설명하는지 대조한다. + +#### Task 9.2 카테고리 persistence/API 계약 변경 + +**Goal 실행 `P9-T2`:** 관리자 API는 새 카테고리 필드를 각각 관리하고, 사용자 폼 옵션은 `분류명/대표품목` 조합 name만 내려준다. + +- **Files:** Modify `GiftCategory.kt`, `GiftAdminService.kt`, `AdminGiftRequest.kt`, `AdminGiftResponse.kt`, `GiftQueryService.kt`, `GiftResponse.kt` if needed. +- **Tests:** Modify `GiftEntityMappingTest.kt`, `GiftAdminServiceTest.kt`, `GiftQueryServiceTest.kt`, `AdminGiftControllerTest.kt`, `GiftControllerTest.kt`. +- [x] **RED:** 새 필드 저장/조회, 관리자 request/response 분리, 사용자 폼 옵션 name 조합, validation 실패 테스트를 작성하고 실패를 확인한다. +- [x] **GREEN:** 엔티티, DTO, service mapping, validation을 최소 구현한다. +- [x] **검증:** 카테고리 관련 focused test를 실행한다. + +#### Task 9.3 신청번호 채번 정책 변경 + +**Goal 실행 `P9-T3`:** `receiptCode-classificationNumberYYMMDDSequenceNo` 형식과 카테고리+날짜별 sequence를 구현한다. + +- **Files:** Modify `GiftApplicationNoSequence.kt`, `GiftApplicationNoSequenceRepository.kt`, `GiftApplicationNoGenerator.kt`, `GiftCommandService.kt`. +- **Tests:** Modify `GiftApplicationNoGeneratorTest.kt`, `GiftCommandServiceTest.kt`, `GiftEntityMappingTest.kt` if needed. +- [x] **RED:** 형식, 카테고리별 독립 sequence, 날짜별 독립 sequence, `9999` 초과, 병렬 unique 테스트를 작성하고 실패를 확인한다. +- [x] **GREEN:** 기존 transaction/locking 방식을 유지해 최소 구현한다. +- [x] **검증:** 신청번호 focused test와 직접 영향 테스트를 실행한다. + +### Phase 9 Gate + +```bash +./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftApplicationNoGeneratorTest" --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftAdminServiceTest" --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftQueryServiceTest" --tests "kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftEntityMappingTest" --tests "kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.in.web.AdminGiftControllerTest" --tests "kr.co.vividnext.sodalive.v2.api.gift.adapter.in.web.GiftControllerTest" +./gradlew ktlintCheck +git diff --check +``` + +**Expected:** 모든 명령 exit 0. 기존 datetime UTC ISO 문자열 변경은 유지된다. + +**결과:** 완료. 2026-09-30 기준 Phase 9 Gate 명령 3개 모두 성공. + ## 실행 순서와 의존성 | 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 | @@ -1147,6 +1200,65 @@ P7은 FCM 확장 후 전체 상태 전이를 연결하고 최종 회귀로 종 - 검증: focused gift/API/scheduler/admin test 실행 결과 `BUILD SUCCESSFUL`, `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL` - 남은 항목: 없음 +## Phase 8: 선물 응답 시각 UTC ISO 문자열화 + +**Phase 결과:** 선물 사용자/관리자 API가 모든 응답 시각을 클라이언트가 안전하게 해석할 수 있는 UTC ISO 문자열로 반환한다. + +**선행조건:** `P7-GATE` 완료와 PRD `DEC-013` 확정. + +**Phase 완료 조건:** `P8-T1` 완료와 focused/controller 회귀, gift 회귀, lint 검증 기록 누적. + +### 구현 항목 + +#### Task 8.1 선물 response datetime 계약 수정 + +**Goal 실행 `P8-T1`:** `GiftResponse.kt`와 `AdminGiftResponse.kt`의 응답 시각 필드를 UTC ISO `String`으로 변환한다. + +- **시작 조건:** PRD `DEC-013` 확인. +- **완료 증거:** RED/GREEN/REFACTOR 체크박스, focused controller test, gift 회귀 test, `ktlintCheck` 통과. +- **범위 밖:** 전역 Jackson 설정 변경, 선물 외 API datetime 마이그레이션, DB 저장 timezone 변경. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftResponse.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/dto/AdminGiftResponse.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt` + +**Interfaces:** + +- Produces: 선물 사용자/관리자 API의 모든 populated datetime JSON 값은 `2026-09-30T12:00:00Z` 같은 UTC ISO 문자열이다. + +- [x] **RED:** controller test의 선물 날짜/시간 응답 기대값을 `Z` 포함 UTC ISO 문자열로 변경한다. +- [x] **RED 확인:** `./gradlew test --tests "kr.co.vividnext.sodalive.v2.api.gift.adapter.in.web.GiftControllerTest" --tests "kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.in.web.AdminGiftControllerTest"`를 실행해 기존 `LocalDateTime` 직렬화가 `Z`를 포함하지 않아 실패함을 확인한다. +- [x] **GREEN:** response DTO의 `LocalDateTime` 필드를 `String`, `LocalDateTime?` 필드를 `String?`로 바꾸고 기존 `LocalDateTime.toUtcIso()` extension으로 변환한다. +- [x] **GREEN 확인:** 같은 focused controller test가 통과하는지 확인한다. +- [x] **REFACTOR:** 불필요한 import를 제거하고 `./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.*"`, `./gradlew ktlintCheck`, `git diff --check` 결과를 Progress에 기록한다. + +### P8-T1 선물 response datetime 계약 수정 — 2026-09-30 + +- 상태: 완료 +- 무엇을: 선물 사용자/관리자 response DTO의 날짜/시간 필드를 `String`으로 바꾸고 `toUtcIso()`로 변환했다. 클라이언트 요약 문서도 UTC ISO 문자열 계약으로 동기화했다. +- 왜: Jackson 기본 `LocalDateTime` 직렬화는 `2026-09-30T12:00:00`처럼 offset이 없어 클라이언트가 UTC로 안전하게 해석할 수 없기 때문이다. +- 어떻게: + - RED test: controller test 기대값을 `Z` 포함 문자열로 변경 후 focused test 실행 결과 11개 assertion 실패 확인. + - GREEN 구현: `GiftResponse.kt`, `AdminGiftResponse.kt`에서 응답 시각 필드를 `String`/`String?`로 변경하고 기존 `LocalDateTime.toUtcIso()` extension 재사용. + - 검증: focused controller test 실행 결과 `BUILD SUCCESSFUL`, `./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.*"` 실행 결과 `BUILD SUCCESSFUL`, `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`, `git diff --check` 출력 없음, `rg -n -- "LocalDateTime"` 대상 response DTO/클라이언트 요약 문서 출력 없음, `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`. +- 남은 항목: 없음 + +### Phase 9 카테고리 확장과 신청번호 정책 변경 — 2026-09-30 + +- 상태: 완료 +- 무엇을: 카테고리에 분류번호, 접수코드, 대표품목을 추가하고 사용자 폼 옵션의 카테고리명은 `분류명/대표품목`으로 조합했다. 신청번호는 `receiptCode-classificationNumberYYMMDDSequenceNo` 형식으로 변경하고 sequence는 카테고리+날짜별로 분리했다. +- 왜: `categoryCode`는 내부 범용코드로 유지하고, 운영용 분류번호/접수코드/대표품목을 별도 관리해야 하기 때문이다. +- 어떻게: + - RED 확인 1: 카테고리 추가 필드 테스트 작성 후 `compileTestKotlin`에서 `classificationNumber`, `receiptCode`, `representativeItem` 미구현 실패 확인 + - GREEN 확인 1: 카테고리 focused suite 실행 결과 `BUILD SUCCESSFUL` + - RED 확인 2: 신청번호 generator 테스트 작성 후 기존 `generate(now)`와 sequence entity가 새 시그니처/필드를 지원하지 않아 `compileTestKotlin` 실패 확인 + - GREEN 확인 2: 신청번호 focused suite 실행 결과 `BUILD SUCCESSFUL` + - Gate 확인: Phase 9 focused suite, `./gradlew ktlintCheck`, `git diff --check` 모두 성공 +- 남은 항목: 없음 + ## Decision Log | 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 | @@ -1156,6 +1268,8 @@ P7은 FCM 확장 후 전체 상태 전이를 연결하고 최종 회귀로 종 | 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` | +| 2026-09-30 | `PLAN-DEC-006` | 확정 | 선물 API response datetime은 전역 Jackson 설정이 아니라 DTO response boundary에서 UTC ISO 문자열로 변환한다 | 선물 API만의 공개 계약 보강이며 전역 변경은 기존 API 영향이 큼 | `P8-T1`, `client-api-summary.md` | +| 2026-09-30 | `PLAN-DEC-007` | 확정 | 기존 `categoryCode`는 내부 범용코드로 유지하고 분류번호/접수코드/대표품목을 추가하며, 신청번호는 카테고리+날짜별 sequence로 발급한다 | 사용자 확정: 카테고리 필드 의미와 사용자 폼 옵션 표시 규칙 분리 | `P9-T1`~`P9-T3` | ## 발견된 문제 diff --git a/docs/20260929_크리에이터_선물하기/prd.md b/docs/20260929_크리에이터_선물하기/prd.md index 672f854d..6a559c83 100644 --- a/docs/20260929_크리에이터_선물하기/prd.md +++ b/docs/20260929_크리에이터_선물하기/prd.md @@ -6,7 +6,7 @@ |---|---| | 문서 상태 | 구현 중 | | 작성일 | 2026-09-29 | -| 최종 수정일 | 2026-09-29 | +| 최종 수정일 | 2026-09-30 | | 대상 제품 | 크리에이터 선물하기 | | 작성자·결정권자 | 사용자 | | 관련 API Contract | 별도 문서 없음. 이 문서의 `8. API 계약`을 기준으로 사용 | @@ -25,6 +25,7 @@ - 선물 가격은 사이즈별로 다르고 운영자가 변경해야 하므로 코드 상수만으로는 운영 요구를 충족할 수 없다. - 운송장 미등록, 크리에이터 배송지 미입력, 검수 실패 같은 종료 상태와 환불/푸시 정책이 명확히 정의되어야 한다. - 팬과 크리에이터가 서로 다른 시점에 약관과 개인정보 처리에 동의해야 하므로 신청 건 기준의 동의 스냅샷이 필요하다. +- 클라이언트가 날짜/시간 응답을 안정적으로 해석할 수 있도록 선물 API의 응답 시각은 timezone 없는 `LocalDateTime` JSON이 아니라 UTC ISO 문자열이어야 한다. 문제를 해결했다는 판단은 선물 신청부터 전달 완료 또는 종료 상태까지 모든 상태 전이가 API/스케줄링/운영 API로 추적되고, 캔 사용내역과 푸시가 상태별 정책대로 처리되는 것으로 한다. @@ -97,10 +98,12 @@ INSPECTION_COMPLETED -> UNDELIVERABLE ### 6.3 신청번호 정책 - 신청번호 `applicationNo`는 내부 기본키와 별도로 발급한다. -- 형식은 `G{yyyyMMdd}{dailySequence6}`로 한다. -- `yyyyMMdd`는 Asia/Seoul 기준 신청일이다. -- `dailySequence6`는 해당 날짜 안에서 1부터 증가하는 6자리 숫자다. -- 예시는 `G20260929000001`이다. +- 형식은 `{receiptCode}-{classificationNumber}{YYMMDD}{SequenceNo}`로 한다. +- `receiptCode`는 선택한 카테고리의 접수코드이고, `classificationNumber`는 선택한 카테고리의 분류번호다. +- `YYMMDD`는 Asia/Seoul 기준 신청일이다. +- `SequenceNo`는 선택한 카테고리와 신청일 조합 안에서 1부터 증가한다. +- `SequenceNo`는 최소 4자리로 왼쪽을 0으로 채우고, `9999`를 초과하면 자리수를 늘린다. +- 예시는 `A-1002609300001`, `C-20026093010000`이다. - `applicationNo`는 unique이며 생성 후 변경하지 않는다. - 동시에 여러 선물 신청이 발생해도 같은 `applicationNo`가 발급되지 않아야 한다. - 구현은 DB unique 제약, 원자적 채번, 충돌 시 재시도 중 하나 이상의 방식으로 경합을 처리해야 한다. @@ -129,12 +132,16 @@ INSPECTION_COMPLETED -> UNDELIVERABLE | 필드 | 타입 | 제약 | 설명 | |---|---|---|---| -| `categoryCode` | string | 50자 이하, unique | 내부 분류번호. 사용자에게 표시하지 않음 | -| `name` | string | 50자 이하 | 사용자 표시명 | +| `classificationNumber` | string | 숫자 3자리, unique | 신청번호에 사용하는 분류번호. 사용자에게 별도 필드로 표시하지 않음 | +| `categoryCode` | string | 50자 이하, unique | 내부 범용코드. 사용자에게 표시하지 않음 | +| `name` | string | 50자 이하 | 분류명 | +| `receiptCode` | string | 영문 대문자 1~9자, unique | 신청번호에 사용하는 접수코드 | +| `representativeItem` | string | 100자 이하 | 대표품목. 사용자 폼 옵션의 `name` 조합에 사용 | | `requiresDamageWaiver` | boolean | DDL은 `tinyint(1)` | 파손면책 동의 필요 여부 | | `isActive` | boolean | DDL은 `tinyint(1)` | 활성 여부 | `requiresDamageWaiver == true`인 카테고리를 선택한 팬은 선물 신청 시 `damageWaiverAgreed=true`를 추가로 보내야 한다. +사용자 선물 신청 폼 옵션에서는 카테고리 응답의 `name`을 `분류명/대표품목` 형태로 조합해 내려준다. 관리자 API에서는 `name`과 `representativeItem`을 각각 내려주고 각각 수정할 수 있어야 한다. ### 6.6 약관 동의 @@ -189,6 +196,8 @@ INSPECTION_COMPLETED -> UNDELIVERABLE - 현재 로그인 회원이 처리할 일이 아니거나 이미 처리할 수 없는 상태이면 해당 flag는 `false`다. - 상세 조회는 정상 진행 상태 5개(`RECEIVED`, `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED`, `DELIVERED`)의 날짜/시간을 순서대로 내려준다. - `CANCELED`, `UNDELIVERABLE`은 정상 진행 타임라인 상태가 아니며, 각각 별도 종료 정보로 `canceledAt`, `undeliverableAt`, `undeliverableReason`을 내려준다. +- 선물 사용자/관리자 API의 모든 응답 시각 필드는 UTC ISO 문자열로 내려준다. 예: `2026-09-30T12:00:00Z`. +- 응답 시각 필드는 `Z` 또는 offset 없이 내려가는 `LocalDateTime` 기본 직렬화 형식을 사용하지 않는다. ### 6.9 리뷰 @@ -272,14 +281,14 @@ Response: "categories": [ { "categoryId": 1, - "name": "인형", + "name": "인형/피규어", "requiresDamageWaiver": true } ] } ``` -사용자 API는 `categoryCode`를 응답하지 않고 활성 카테고리만 내려준다. +사용자 API는 `categoryCode`, `classificationNumber`, `receiptCode`, `representativeItem`을 별도 필드로 응답하지 않고 활성 카테고리만 내려준다. ### 8.3 선물 보내기 등록 @@ -307,7 +316,7 @@ Response: ```json { - "applicationNo": "G20260929000001", + "applicationNo": "A-1002609300001", "status": "RECEIVED", "statusName": "접수 완료", "priceCan": 80, @@ -342,7 +351,7 @@ Response: { "items": [ { - "applicationNo": "G20260929000001", + "applicationNo": "A-1002609300001", "direction": "SENT", "status": "TRACKING_REGISTERED", "statusName": "발송 확인", @@ -372,7 +381,7 @@ Response: ```json { - "applicationNo": "G20260929000001", + "applicationNo": "A-1002609300001", "direction": "SENT", "status": "TRACKING_REGISTERED", "statusName": "발송 확인", @@ -381,7 +390,7 @@ Response: "senderNickname": null, "sizeName": "소형", "categoryName": "인형", - "applicationNo": "G20260929000001", + "applicationNo": "A-1002609300001", "paidCan": 80, "tracking": "CJ대한통운_1234567890", "shippingRequestedAt": null @@ -440,7 +449,7 @@ Response: ```json { - "applicationNo": "G20260929000001", + "applicationNo": "A-1002609300001", "direction": "RECEIVED", "status": "INSPECTION_COMPLETED", "statusName": "검수완료", @@ -534,7 +543,7 @@ Response: ```json { - "applicationNo": "G20260929000001", + "applicationNo": "A-1002609300001", "status": "TRACKING_REGISTERED", "recipientAddressDeadlineAt": "2026-10-09T03:00:00Z" } @@ -557,7 +566,7 @@ Response: ```json { - "applicationNo": "G20260929000001", + "applicationNo": "A-1002609300001", "status": "CANCELED", "refundedCan": 80 } @@ -591,7 +600,7 @@ Response: ```json { - "applicationNo": "G20260929000001", + "applicationNo": "A-1002609300001", "recipientAddressRegisteredAt": "2026-10-03T03:00:00Z" } ``` @@ -613,7 +622,7 @@ Response: ```json { - "applicationNo": "G20260929000001", + "applicationNo": "A-1002609300001", "status": "DELIVERED", "deliveredAt": "2026-10-10T03:00:00Z" } @@ -645,7 +654,7 @@ Response: ```json { "reviewId": 1, - "applicationNo": "G20260929000001", + "applicationNo": "A-1002609300001", "rating": 5, "keywords": ["배송이 빨라요", "안내가 친절해요"], "comment": "선물 전달 과정이 만족스러웠습니다.", @@ -675,8 +684,11 @@ Response: ```json { + "classificationNumber": "100", "categoryCode": "DOLL", "name": "인형", + "receiptCode": "A", + "representativeItem": "피규어", "requiresDamageWaiver": true, "isActive": true } @@ -804,8 +816,11 @@ Response: | 필드 | 설명 | |---|---| | `id` | 내부 기본키 | -| `categoryCode` | 50자 이하 내부 분류번호 | -| `name` | 50자 이하 카테고리명 | +| `classificationNumber` | 숫자 3자리 unique 분류번호 | +| `categoryCode` | 50자 이하 unique 내부 범용코드 | +| `name` | 50자 이하 분류명 | +| `receiptCode` | 영문 대문자 1~9자 unique 접수코드 | +| `representativeItem` | 100자 이하 대표품목 | | `requiresDamageWaiver` | 파손면책 동의 필요 여부. DDL은 `tinyint(1)` | | `isActive` | 활성 여부. DDL은 `tinyint(1)` | | `createdAt`, `updatedAt` | 생성/수정 시각 | @@ -854,6 +869,9 @@ Response: - 리뷰는 작성 권한, 상태, 중복 작성, 글자수 제한을 테스트한다. - 푸시는 팬/크리에이터 수신자 분리와 크리에이터 수령확인 시 크리에이터 푸시 억제를 테스트한다. - 스케줄러는 중복 실행 시 중복 환불/중복 푸시가 없는지 테스트한다. +- 선물 사용자/관리자 API response DTO의 날짜/시간 필드는 UTC ISO 문자열(`Z` 포함)로 직렬화되는지 controller test로 고정한다. +- 카테고리 `classificationNumber`, `receiptCode`, `representativeItem`의 관리자 등록/수정/조회와 사용자 폼 옵션의 `분류명/대표품목` 조합을 테스트한다. +- 신청번호는 카테고리+날짜별 sequence, 4자리 padding, `9999` 초과 자리수 증가, 병렬 unique를 테스트한다. ## 13. 성공 기준 @@ -865,6 +883,8 @@ Response: - [ ] 팬은 `DELIVERED` 이후 리뷰를 1회 작성할 수 있다. - [ ] 관리자 카테고리 논리삭제와 사이즈별 가격 변경이 새 신청에만 반영된다. - [ ] 팬/크리에이터 푸시가 지정된 문구, 수신자, 딥링크로 발송된다. +- [ ] 선물 사용자/관리자 API의 날짜/시간 응답을 클라이언트가 UTC ISO 문자열로 해석할 수 있다. +- [ ] 카테고리 추가 필드와 새 신청번호 정책이 관리자 API, 사용자 폼 옵션, 선물 신청에 반영된다. ## 14. Open Questions @@ -880,6 +900,7 @@ Response: | `GIFT-009` | 4 | 운영 상태변경 API 테스트 | 운영 상태 전이 확인 | | `GIFT-010` | 5 | 리뷰 API 테스트 | 전달완료 후 리뷰 작성 확인 | | `GIFT-011` | 6 | 푸시 이벤트 테스트 | 딥링크와 수신자 확인 | +| `GIFT-014` | 8 | 선물 사용자/관리자 controller 날짜 응답 테스트 | UTC ISO 문자열 확인 | ## 16. Decision Log @@ -897,3 +918,5 @@ Response: | 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` | +| 2026-09-30 | `DEC-013` | 확정 | 선물 사용자/관리자 API의 response datetime 필드는 `LocalDateTime` 기본 JSON 대신 UTC ISO 문자열로 내려준다 | 클라이언트가 offset 없는 `LocalDateTime` 문자열을 안전하게 해석하지 못함 | `GIFT-014` | +| 2026-09-30 | `DEC-014` | 확정 | 카테고리는 분류번호, 내부 범용 카테고리 코드, 분류명, 접수코드, 대표품목, 파손면책 조건을 관리하고 신청번호는 `receiptCode-classificationNumberYYMMDDSequenceNo`로 발급한다 | 관리자 운영 필드와 사용자 표시 요구가 분리됨 | `GIFT-001`, `GIFT-002`, `GIFT-012` | From dda5de4d61ffbd847bb541b6eaa326ad194dedf0 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 30 Sep 2026 17:37:16 +0900 Subject: [PATCH 22/53] =?UTF-8?q?docs(gift):=20=EC=84=A0=EB=AC=BC=20?= =?UTF-8?q?=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=EA=B3=BC=20DDL=EC=9D=84=20=EA=B0=B1=EC=8B=A0=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../client-api-summary.md | 46 +++++++++++-------- .../gift-schema.sql | 19 +++++--- 2 files changed, 40 insertions(+), 25 deletions(-) diff --git a/docs/20260929_크리에이터_선물하기/client-api-summary.md b/docs/20260929_크리에이터_선물하기/client-api-summary.md index be779547..c78f353e 100644 --- a/docs/20260929_크리에이터_선물하기/client-api-summary.md +++ b/docs/20260929_크리에이터_선물하기/client-api-summary.md @@ -42,9 +42,9 @@ PRD에는 상세 API 계약, request/response 예시, 요구사항을 유지한 | 사용자 | Method | URI | 역할 | Request | Response | |---|---:|---|---|---|---| | 관리자 | GET | `/api/v2/admin/gift-categories` | 선물 카테고리 목록을 조회한다. | 없음 | `AdminGiftCategoryResponse[]` | -| 관리자 | POST | `/api/v2/admin/gift-categories` | 선물 카테고리를 등록한다. | body: `categoryCode`, `name`, `requiresDamageWaiver`, `isActive` | `categoryId`, `categoryCode`, `name`, `requiresDamageWaiver`, `isActive` | -| 관리자 | PUT | `/api/v2/admin/gift-categories/{categoryId}` | 선물 카테고리를 수정한다. | path: `categoryId`, body: `categoryCode`, `name`, `requiresDamageWaiver`, `isActive` | `categoryId`, `categoryCode`, `name`, `requiresDamageWaiver`, `isActive` | -| 관리자 | DELETE | `/api/v2/admin/gift-categories/{categoryId}` | 선물 카테고리를 비활성화한다. | path: `categoryId` | `categoryId`, `categoryCode`, `name`, `requiresDamageWaiver`, `isActive=false` | +| 관리자 | POST | `/api/v2/admin/gift-categories` | 선물 카테고리를 등록한다. | body: `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive` | `categoryId`, `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive` | +| 관리자 | PUT | `/api/v2/admin/gift-categories/{categoryId}` | 선물 카테고리를 수정한다. | path: `categoryId`, body: `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive` | `categoryId`, `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive` | +| 관리자 | DELETE | `/api/v2/admin/gift-categories/{categoryId}` | 선물 카테고리를 비활성화한다. | path: `categoryId` | `categoryId`, `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive=false` | | 관리자 | GET | `/api/v2/admin/gift-size-prices` | 사이즈별 기본가와 판매가를 조회한다. | 없음 | `AdminGiftSizePriceResponse[]` | | 관리자 | PUT | `/api/v2/admin/gift-size-prices/{sizeCode}` | 사이즈별 기본가와 판매가를 수정한다. | path: `sizeCode`, body: `basePriceCan`, `salePriceCan`, `isActive` | `sizeCode`, `name`, `basePriceCan`, `salePriceCan`, `isActive` | | 관리자 | POST | `/api/v2/admin/gifts/{applicationNo}/arrive-mailbox` | 발송 확인 선물을 사서함 도착 처리한다. | path: `applicationNo` | `applicationNo`, `status`, `statusName`, `occurredAt` | @@ -54,13 +54,13 @@ PRD에는 상세 API 계약, request/response 예시, 요구사항을 유지한 관리자 응답 DTO 요약: -- `AdminGiftCategoryResponse`: `categoryId`, `categoryCode`, `name`, `requiresDamageWaiver`, `isActive` +- `AdminGiftCategoryResponse`: `categoryId`, `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive` - `AdminGiftSizePriceResponse`: `sizeCode`, `name`, `basePriceCan`, `salePriceCan`, `isActive` - `AdminGiftOperationStatusResponse`: `applicationNo`, `status`, `statusName`, `occurredAt` ## Kotlin DTO 형태 -아래는 JSON으로 변환되기 직전의 API request/response `data class` 형태다. `companion object` 변환 함수는 클라이언트 입력/출력 형태와 직접 관련이 없어 생략했다. +아래는 JSON으로 변환되기 직전의 API request/response `data class` 형태다. `companion object` 변환 함수는 클라이언트 입력/출력 형태와 직접 관련이 없어 생략했다. 응답 시각 필드는 UTC ISO 문자열이다. 예: `2026-09-30T12:00:00Z`. ### 사용자 Request DTO @@ -122,6 +122,8 @@ data class GiftCategoryOptionResponse( val requiresDamageWaiver: Boolean ) +// name은 사용자 폼 옵션에서만 "분류명/대표품목" 형태로 조합된다. + data class GiftListResponse( val totalCount: Long, val items: List, @@ -138,7 +140,7 @@ data class GiftListItemResponse( val priceCan: Int, val categoryName: String, val sizeName: String, - val createdAt: LocalDateTime? + val createdAt: String? ) data class GiftDetailResponse( @@ -152,7 +154,7 @@ data class GiftDetailResponse( val mailbox: GiftMailboxResponse?, val trackingRequired: Boolean, val recipientAddressRequired: Boolean, - val recipientAddressDeadlineAt: LocalDateTime?, + val recipientAddressDeadlineAt: String?, val delivery: GiftDeliveryInfoResponse, val statusTimeline: List ) @@ -165,7 +167,7 @@ data class GiftDetailInfoResponse( val applicationNo: String?, val paidCan: Int?, val tracking: String?, - val shippingRequestedAt: LocalDateTime? + val shippingRequestedAt: String? ) data class GiftAddressResponse( @@ -181,15 +183,15 @@ data class GiftMailboxResponse( ) data class GiftDeliveryInfoResponse( - val canceledAt: LocalDateTime?, - val undeliverableAt: LocalDateTime?, + val canceledAt: String?, + val undeliverableAt: String?, val undeliverableReason: String? ) data class GiftStatusTimelineResponse( val status: String, val statusName: String, - val occurredAt: LocalDateTime? + val occurredAt: String? ) data class GiftApplicationResponse( @@ -197,7 +199,7 @@ data class GiftApplicationResponse( val status: String, val statusName: String, val priceCan: Int, - val trackingDeadlineAt: LocalDateTime + val trackingDeadlineAt: String ) data class GiftCancellationResponse( @@ -205,7 +207,7 @@ data class GiftCancellationResponse( val status: String, val statusName: String, val priceCan: Int, - val canceledAt: LocalDateTime + val canceledAt: String ) data class GiftTrackingRegistrationResponse( @@ -214,22 +216,22 @@ data class GiftTrackingRegistrationResponse( val statusName: String, val courierCompanyName: String, val trackingNumber: String, - val trackingRegisteredAt: LocalDateTime, - val recipientAddressDeadlineAt: LocalDateTime + val trackingRegisteredAt: String, + val recipientAddressDeadlineAt: String ) data class GiftRecipientAddressRegistrationResponse( val applicationNo: String, val status: String, val statusName: String, - val recipientAddressRegisteredAt: LocalDateTime + val recipientAddressRegisteredAt: String ) data class GiftDeliveryConfirmationResponse( val applicationNo: String, val status: String, val statusName: String, - val deliveredAt: LocalDateTime + val deliveredAt: String ) data class GiftReviewResponse( @@ -238,7 +240,7 @@ data class GiftReviewResponse( val rating: Int, val keywords: List, val comment: String?, - val createdAt: LocalDateTime? + val createdAt: String? ) ``` @@ -246,8 +248,11 @@ data class GiftReviewResponse( ```kotlin data class AdminGiftCategoryRequest( + val classificationNumber: String, val categoryCode: String, val name: String, + val receiptCode: String, + val representativeItem: String, val requiresDamageWaiver: Boolean, val isActive: Boolean ) @@ -268,8 +273,11 @@ data class AdminGiftMarkUndeliverableRequest( ```kotlin data class AdminGiftCategoryResponse( val categoryId: Long, + val classificationNumber: String, val categoryCode: String, val name: String, + val receiptCode: String, + val representativeItem: String, val requiresDamageWaiver: Boolean, val isActive: Boolean ) @@ -286,7 +294,7 @@ data class AdminGiftOperationStatusResponse( val applicationNo: String, val status: GiftStatus, val statusName: String, - val occurredAt: LocalDateTime + val occurredAt: String ) ``` diff --git a/docs/20260929_크리에이터_선물하기/gift-schema.sql b/docs/20260929_크리에이터_선물하기/gift-schema.sql index f8302a87..7e969356 100644 --- a/docs/20260929_크리에이터_선물하기/gift-schema.sql +++ b/docs/20260929_크리에이터_선물하기/gift-schema.sql @@ -1,18 +1,23 @@ CREATE TABLE gift_category ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '내부 기본키', - category_code VARCHAR(50) NOT NULL COMMENT '내부 분류번호', - name VARCHAR(50) NOT NULL COMMENT '카테고리 표시명', + classification_number VARCHAR(3) NOT NULL COMMENT '신청번호에 사용하는 3자리 분류번호', + category_code VARCHAR(50) NOT NULL COMMENT '내부 범용코드', + name VARCHAR(50) NOT NULL COMMENT '분류명', + receipt_code VARCHAR(9) NOT NULL COMMENT '신청번호에 사용하는 접수코드', + representative_item VARCHAR(100) 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) + UNIQUE KEY uk_gift_category_classification_number (classification_number), + UNIQUE KEY uk_gift_category_code (category_code), + UNIQUE KEY uk_gift_category_receipt_code (receipt_code) ) COMMENT='선물 카테고리'; CREATE TABLE gift ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '내부 기본키', - application_no VARCHAR(20) NOT NULL COMMENT '신청번호', + application_no VARCHAR(40) NOT NULL COMMENT '신청번호', sender_member_id BIGINT NOT NULL COMMENT '보내는 팬 회원번호', recipient_member_id BIGINT NOT NULL COMMENT '받는 크리에이터 회원번호', status VARCHAR(30) NOT NULL COMMENT '선물 상태', @@ -102,10 +107,12 @@ CREATE TABLE gift_review ( CREATE TABLE gift_application_no_sequence ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '내부 기본키', - sequence_date VARCHAR(8) NOT NULL COMMENT '채번 기준일 yyyyMMdd', + category_id BIGINT NOT NULL COMMENT '채번 기준 카테고리 ID', + sequence_date VARCHAR(6) NOT NULL COMMENT '채번 기준일 yyMMdd', 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) + UNIQUE KEY uk_gift_application_no_sequence_category_date (category_id, sequence_date), + CONSTRAINT fk_gift_application_no_sequence_category FOREIGN KEY (category_id) REFERENCES gift_category (id) ) COMMENT='선물 신청번호 날짜별 채번'; From 13a842a9a5ac32fc5d9f40d2062e25e1a80afa8a Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 30 Sep 2026 17:37:23 +0900 Subject: [PATCH 23/53] =?UTF-8?q?feat(gift):=20=EC=84=A0=EB=AC=BC=20?= =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EC=9A=B4=EC=98=81=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../v2/api/admin/gift/dto/AdminGiftRequest.kt | 6 ++ .../adapter/out/persistence/GiftCategory.kt | 15 ++++- .../v2/gift/application/GiftAdminService.kt | 23 +++++++- .../gift/application/GiftAdminServiceTest.kt | 58 ++++++++++++++++++- 4 files changed, 98 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/dto/AdminGiftRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/dto/AdminGiftRequest.kt index 0c1121a9..a53b906f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/dto/AdminGiftRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/dto/AdminGiftRequest.kt @@ -4,14 +4,20 @@ import kr.co.vividnext.sodalive.v2.gift.application.GiftCategoryCommand import kr.co.vividnext.sodalive.v2.gift.application.GiftSizePriceCommand data class AdminGiftCategoryRequest( + val classificationNumber: String, val categoryCode: String, val name: String, + val receiptCode: String, + val representativeItem: String, val requiresDamageWaiver: Boolean, val isActive: Boolean ) { fun toCommand() = GiftCategoryCommand( + classificationNumber = classificationNumber, categoryCode = categoryCode, name = name, + receiptCode = receiptCode, + representativeItem = representativeItem, requiresDamageWaiver = requiresDamageWaiver, isActive = isActive ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftCategory.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftCategory.kt index 8974588f..807b17ec 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftCategory.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftCategory.kt @@ -9,15 +9,28 @@ import javax.persistence.UniqueConstraint @Entity @Table( name = "gift_category", - uniqueConstraints = [UniqueConstraint(name = "uk_gift_category_code", columnNames = ["category_code"])] + uniqueConstraints = [ + UniqueConstraint(name = "uk_gift_category_classification_number", columnNames = ["classification_number"]), + UniqueConstraint(name = "uk_gift_category_code", columnNames = ["category_code"]), + UniqueConstraint(name = "uk_gift_category_receipt_code", columnNames = ["receipt_code"]) + ] ) class GiftCategory( + @Column(name = "classification_number", nullable = false, length = 3) + var classificationNumber: String, + @Column(name = "category_code", nullable = false, length = 50) val categoryCode: String, @Column(name = "name", nullable = false, length = 50) var name: String, + @Column(name = "receipt_code", nullable = false, length = 9) + var receiptCode: String, + + @Column(name = "representative_item", nullable = false, length = 100) + var representativeItem: String, + @Column(name = "requires_damage_waiver", nullable = false) var requiresDamageWaiver: Boolean = false, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminService.kt index c61bcc2d..9ba9ae97 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminService.kt @@ -15,16 +15,22 @@ import org.springframework.transaction.annotation.Transactional import java.time.LocalDateTime data class GiftCategoryCommand( + val classificationNumber: String, val categoryCode: String, val name: String, + val receiptCode: String, + val representativeItem: String, val requiresDamageWaiver: Boolean, val isActive: Boolean ) data class GiftCategoryResult( val categoryId: Long, + val classificationNumber: String, val categoryCode: String, val name: String, + val receiptCode: String, + val representativeItem: String, val requiresDamageWaiver: Boolean, val isActive: Boolean ) @@ -66,8 +72,11 @@ class GiftAdminService( validateCategory(command) return categoryRepository.save( GiftCategory( + classificationNumber = command.classificationNumber, categoryCode = command.categoryCode, name = command.name, + receiptCode = command.receiptCode, + representativeItem = command.representativeItem, requiresDamageWaiver = command.requiresDamageWaiver, isActive = command.isActive ) @@ -78,7 +87,10 @@ class GiftAdminService( fun updateCategory(categoryId: Long, command: GiftCategoryCommand): GiftCategoryResult { validateCategory(command) val category = categoryRepository.findByIdOrNull(categoryId) ?: throw invalidRequest() + category.classificationNumber = command.classificationNumber category.name = command.name + category.receiptCode = command.receiptCode + category.representativeItem = command.representativeItem category.requiresDamageWaiver = command.requiresDamageWaiver category.isActive = command.isActive return category.toResult() @@ -172,7 +184,11 @@ class GiftAdminService( command.categoryCode.isBlank() || command.categoryCode.length > 50 || command.name.isBlank() || - command.name.length > 50 + command.name.length > 50 || + !CLASSIFICATION_NUMBER_REGEX.matches(command.classificationNumber) || + !RECEIPT_CODE_REGEX.matches(command.receiptCode) || + command.representativeItem.isBlank() || + command.representativeItem.length > 100 ) { throw invalidRequest() } @@ -184,8 +200,11 @@ class GiftAdminService( private fun GiftCategory.toResult() = GiftCategoryResult( categoryId = id ?: throw invalidRequest(), + classificationNumber = classificationNumber, categoryCode = categoryCode, name = name, + receiptCode = receiptCode, + representativeItem = representativeItem, requiresDamageWaiver = requiresDamageWaiver, isActive = isActive ) @@ -206,5 +225,7 @@ class GiftAdminService( GiftStatus.ARRIVED_AT_MAILBOX, GiftStatus.INSPECTION_COMPLETED ) + val CLASSIFICATION_NUMBER_REGEX = Regex("\\d{3}") + val RECEIPT_CODE_REGEX = Regex("[A-Z]{1,9}") } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminServiceTest.kt index 4b3be603..88c14e74 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminServiceTest.kt @@ -48,20 +48,58 @@ class GiftAdminServiceTest @Autowired constructor( @DisplayName("카테고리를 등록, 수정, 논리삭제한다") fun shouldCreateUpdateAndDeactivateCategory() { val created = service.createCategory( - GiftCategoryCommand(categoryCode = "DOLL", name = "인형", requiresDamageWaiver = true, isActive = true) + GiftCategoryCommand( + classificationNumber = "100", + categoryCode = "DOLL", + name = "인형", + receiptCode = "A", + representativeItem = "피규어", + requiresDamageWaiver = true, + isActive = true + ) ) val updated = service.updateCategory( created.categoryId, - GiftCategoryCommand(categoryCode = "DOLL", name = "큰 인형", requiresDamageWaiver = false, isActive = true) + GiftCategoryCommand( + classificationNumber = "101", + categoryCode = "DOLL", + name = "큰 인형", + receiptCode = "B", + representativeItem = "대형 피규어", + requiresDamageWaiver = false, + isActive = true + ) ) service.deleteCategory(created.categoryId) val saved = categoryRepository.findById(created.categoryId).orElseThrow() + assertEquals("101", updated.classificationNumber) + assertEquals("DOLL", updated.categoryCode) assertEquals("큰 인형", updated.name) + assertEquals("B", updated.receiptCode) + assertEquals("대형 피규어", updated.representativeItem) assertFalse(updated.requiresDamageWaiver) assertFalse(saved.isActive) } + @Test + @DisplayName("카테고리 분류번호와 접수코드 형식을 검증한다") + fun shouldValidateCategoryClassificationNumberAndReceiptCode() { + val invalidClassificationNumber = assertThrows(SodaException::class.java) { + service.createCategory(categoryCommand(classificationNumber = "10A")) + } + val invalidReceiptCode = assertThrows(SodaException::class.java) { + service.createCategory(categoryCommand(receiptCode = "A1")) + } + val blankRepresentativeItem = assertThrows(SodaException::class.java) { + service.createCategory(categoryCommand(representativeItem = " ")) + } + + assertEquals("common.error.invalid_request", invalidClassificationNumber.messageKey) + assertEquals("common.error.invalid_request", invalidReceiptCode.messageKey) + assertEquals("common.error.invalid_request", blankRepresentativeItem.messageKey) + } + @Test @DisplayName("사이즈 가격을 수정하고 salePriceCan이 basePriceCan보다 크면 거부한다") fun shouldUpdateSizePriceAndRejectInvalidSalePrice() { @@ -213,6 +251,22 @@ class GiftAdminServiceTest @Autowired constructor( return gift to delivery } + private fun categoryCommand( + classificationNumber: String = "100", + categoryCode: String = "DOLL", + name: String = "인형", + receiptCode: String = "A", + representativeItem: String = "피규어" + ) = GiftCategoryCommand( + classificationNumber = classificationNumber, + categoryCode = categoryCode, + name = name, + receiptCode = receiptCode, + representativeItem = representativeItem, + requiresDamageWaiver = true, + isActive = true + ) + private companion object { const val APPLICATION_NO = "G20260929000001" const val SENDER_MEMBER_ID = 1L From a6ba46e0177da90a64201bbd21ca17c2f7b2ee74 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 30 Sep 2026 17:37:30 +0900 Subject: [PATCH 24/53] =?UTF-8?q?feat(gift):=20=EC=84=A0=EB=AC=BC=20?= =?UTF-8?q?=ED=8F=BC=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=EB=AA=85=EC=9D=84=20=EC=A1=B0=ED=95=A9=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../v2/gift/application/GiftQueryService.kt | 11 ++++++++- .../gift/application/GiftQueryServiceTest.kt | 24 ++++++++++++++++--- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt index 503c4c37..f1c06608 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt @@ -107,7 +107,16 @@ class GiftQueryService( GiftSizePriceResult(it.sizeCode, it.sizeCode.displayName, it.basePriceCan, it.salePriceCan, it.isActive) }, categories = categoryRepository.findAll().filter { it.isActive }.map { - GiftCategoryResult(it.id!!, it.categoryCode, it.name, it.requiresDamageWaiver, it.isActive) + GiftCategoryResult( + categoryId = it.id!!, + classificationNumber = it.classificationNumber, + categoryCode = it.categoryCode, + name = "${it.name}/${it.representativeItem}", + receiptCode = it.receiptCode, + representativeItem = it.representativeItem, + requiresDamageWaiver = it.requiresDamageWaiver, + isActive = it.isActive + ) } ) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt index ce55aae2..0da24ecb 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt @@ -43,8 +43,8 @@ class GiftQueryServiceTest @Autowired constructor( ) { @Test fun `폼 옵션은 활성 카테고리와 활성 사이즈 가격만 반환한다`() { - val activeCategory = categoryRepository.save(GiftCategory("DOLL", "인형", true, true)) - categoryRepository.save(GiftCategory("FOOD", "간식", false, false)) + val activeCategory = categoryRepository.save(giftCategory("100", "DOLL", "A", "인형", "피규어", true, true)) + categoryRepository.save(giftCategory("200", "FOOD", "B", "간식", "쿠키", false, false)) sizePriceRepository.save(GiftSizePrice(GiftSize.SMALL, 100, 80, true)) sizePriceRepository.save(GiftSizePrice(GiftSize.MEDIUM, 150, 120, false)) @@ -54,7 +54,7 @@ class GiftQueryServiceTest @Autowired constructor( assertEquals(listOf(100), result.sizes.map { it.basePriceCan }) assertEquals(listOf(80), result.sizes.map { it.salePriceCan }) assertEquals(listOf(activeCategory.id), result.categories.map { it.categoryId }) - assertEquals("인형", result.categories.single().name) + assertEquals("인형/피규어", result.categories.single().name) assertEquals(true, result.categories.single().requiresDamageWaiver) } @@ -381,6 +381,24 @@ class GiftQueryServiceTest @Autowired constructor( private fun saveMember(nickname: String) = memberRepository.save(Member(password = "password", nickname = nickname)) + private fun giftCategory( + classificationNumber: String, + categoryCode: String, + receiptCode: String, + name: String, + representativeItem: String, + requiresDamageWaiver: Boolean, + isActive: Boolean + ) = GiftCategory( + classificationNumber = classificationNumber, + categoryCode = categoryCode, + name = name, + receiptCode = receiptCode, + representativeItem = representativeItem, + requiresDamageWaiver = requiresDamageWaiver, + isActive = isActive + ) + private fun saveDelivery(gift: Gift) = deliveryRepository.save( GiftDelivery( giftId = gift.id!!, From f3f46c413c5fc2f8546aa1254dd57a6ec2a8adfb Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 30 Sep 2026 17:37:56 +0900 Subject: [PATCH 25/53] =?UTF-8?q?feat(gift):=20=EC=8B=A0=EC=B2=AD=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=B1=84=EB=B2=88=20=EC=A0=80=EC=9E=A5=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=EC=9D=84=20=EB=B6=84=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistence/GiftApplicationNoSequence.kt | 12 +++++-- .../GiftApplicationNoSequenceRepository.kt | 16 ++++++++-- .../out/persistence/GiftEntityMappingTest.kt | 31 +++++++++++++++---- 3 files changed, 48 insertions(+), 11 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftApplicationNoSequence.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftApplicationNoSequence.kt index 988d1cd7..73307433 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftApplicationNoSequence.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftApplicationNoSequence.kt @@ -9,10 +9,18 @@ import javax.persistence.UniqueConstraint @Entity @Table( name = "gift_application_no_sequence", - uniqueConstraints = [UniqueConstraint(name = "uk_gift_application_no_sequence_date", columnNames = ["sequence_date"])] + uniqueConstraints = [ + UniqueConstraint( + name = "uk_gift_application_no_sequence_category_date", + columnNames = ["category_id", "sequence_date"] + ) + ] ) class GiftApplicationNoSequence( - @Column(name = "sequence_date", nullable = false, length = 8) + @Column(name = "category_id", nullable = false) + val categoryId: Long, + + @Column(name = "sequence_date", nullable = false, length = 6) val sequenceDate: String, @Column(name = "last_sequence", nullable = false) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftApplicationNoSequenceRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftApplicationNoSequenceRepository.kt index b557d321..5a938dd4 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftApplicationNoSequenceRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftApplicationNoSequenceRepository.kt @@ -7,9 +7,19 @@ import org.springframework.data.repository.query.Param import javax.persistence.LockModeType interface GiftApplicationNoSequenceRepository : JpaRepository { - fun findBySequenceDate(sequenceDate: String): GiftApplicationNoSequence? + fun findByCategoryIdAndSequenceDate(categoryId: Long, sequenceDate: String): GiftApplicationNoSequence? @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("select s from GiftApplicationNoSequence s where s.sequenceDate = :sequenceDate") - fun findBySequenceDateForUpdate(@Param("sequenceDate") sequenceDate: String): GiftApplicationNoSequence? + @Query( + """ + select s + from GiftApplicationNoSequence s + where s.categoryId = :categoryId + and s.sequenceDate = :sequenceDate + """ + ) + fun findByCategoryIdAndSequenceDateForUpdate( + @Param("categoryId") categoryId: Long, + @Param("sequenceDate") sequenceDate: String + ): GiftApplicationNoSequence? } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftEntityMappingTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftEntityMappingTest.kt index 9a758734..cf7e4057 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftEntityMappingTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftEntityMappingTest.kt @@ -35,8 +35,11 @@ class GiftEntityMappingTest @Autowired constructor( fun shouldSaveAndFindGiftWithDelivery() { val category = giftCategoryRepository.save( GiftCategory( + classificationNumber = "100", categoryCode = "DOLL", name = "인형", + receiptCode = "A", + representativeItem = "피규어", requiresDamageWaiver = true ) ) @@ -89,9 +92,9 @@ class GiftEntityMappingTest @Autowired constructor( } @Test - @DisplayName("신청번호와 카테고리 코드와 리뷰 선물 ID는 중복 저장할 수 없다") + @DisplayName("신청번호와 카테고리 unique 값과 리뷰 선물 ID는 중복 저장할 수 없다") fun shouldRejectDuplicateUniqueValues() { - val category = giftCategoryRepository.save(GiftCategory(categoryCode = "DOLL", name = "인형")) + val category = giftCategoryRepository.save(giftCategory("100", "DOLL", "A")) val gift = giftRepository.save(gift(category.id!!, "G20260929000001")) giftRepository.saveAndFlush(gift(category.id!!, "G20260929000002")) giftReviewRepository.saveAndFlush(GiftReview(giftId = gift.id!!, senderMemberId = 1L, rating = 5)) @@ -100,7 +103,13 @@ class GiftEntityMappingTest @Autowired constructor( giftRepository.saveAndFlush(gift(category.id!!, "G20260929000001")) } assertThrows(DataIntegrityViolationException::class.java) { - giftCategoryRepository.saveAndFlush(GiftCategory(categoryCode = "DOLL", name = "중복")) + giftCategoryRepository.saveAndFlush(giftCategory("101", "DOLL", "B")) + } + assertThrows(DataIntegrityViolationException::class.java) { + giftCategoryRepository.saveAndFlush(giftCategory("100", "TOY", "B")) + } + assertThrows(DataIntegrityViolationException::class.java) { + giftCategoryRepository.saveAndFlush(giftCategory("101", "TOY", "A")) } assertThrows(DataIntegrityViolationException::class.java) { giftReviewRepository.saveAndFlush(GiftReview(giftId = gift.id!!, senderMemberId = 1L, rating = 4)) @@ -108,13 +117,15 @@ class GiftEntityMappingTest @Autowired constructor( } @Test - @DisplayName("날짜별 신청번호 채번 sequence를 저장한다") + @DisplayName("카테고리와 날짜별 신청번호 채번 sequence를 저장한다") fun shouldSaveApplicationNoSequence() { + val category = giftCategoryRepository.save(giftCategory("100", "DOLL", "A")) val sequence = giftApplicationNoSequenceRepository.save( - GiftApplicationNoSequence(sequenceDate = "20260929", lastSequence = 1) + GiftApplicationNoSequence(categoryId = category.id!!, sequenceDate = "260929", lastSequence = 1) ) - assertEquals("20260929", sequence.sequenceDate) + assertEquals(category.id, sequence.categoryId) + assertEquals("260929", sequence.sequenceDate) assertEquals(1, sequence.lastSequence) } @@ -134,4 +145,12 @@ class GiftEntityMappingTest @Autowired constructor( senderPrivacyAgreedAt = LocalDateTime.of(2026, 9, 29, 12, 0), damageWaiverAgreed = true ) + + private fun giftCategory(classificationNumber: String, categoryCode: String, receiptCode: String) = GiftCategory( + classificationNumber = classificationNumber, + categoryCode = categoryCode, + name = "인형", + receiptCode = receiptCode, + representativeItem = "피규어" + ) } From 2bda30e7e2f6df60d5c9c9bc073d8d5172230e74 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 30 Sep 2026 17:38:24 +0900 Subject: [PATCH 26/53] =?UTF-8?q?feat(gift):=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=EB=B3=84=20=EC=8B=A0=EC=B2=AD=EB=B2=88=ED=98=B8?= =?UTF-8?q?=EB=A5=BC=20=EB=B0=9C=EA=B8=89=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/GiftApplicationNoGenerator.kt | 19 ++++-- .../v2/gift/application/GiftCommandService.kt | 2 +- .../v2/gift/GiftCommandServiceTest.kt | 12 +++- .../GiftApplicationNoGeneratorTest.kt | 65 ++++++++++++++++--- 4 files changed, 81 insertions(+), 17 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftApplicationNoGenerator.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftApplicationNoGenerator.kt index 450cc499..f060ad0d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftApplicationNoGenerator.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftApplicationNoGenerator.kt @@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.v2.gift.application import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftApplicationNoSequence import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftApplicationNoSequenceRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory import org.springframework.dao.DataIntegrityViolationException import org.springframework.stereotype.Service import org.springframework.transaction.PlatformTransactionManager @@ -18,15 +19,23 @@ class GiftApplicationNoGenerator( ) { private val transactionTemplate = TransactionTemplate(transactionManager) - fun generate(now: LocalDateTime = LocalDateTime.now(SEOUL_ZONE)): String { + fun generate(category: GiftCategory, now: LocalDateTime = LocalDateTime.now(SEOUL_ZONE)): String { + val categoryId = category.id ?: throw SodaException(messageKey = "common.error.invalid_request") val sequenceDate = now.format(DATE_FORMATTER) for (attempt in 1..MAX_RETRY_COUNT) { try { return transactionTemplate.execute { - val sequence = repository.findBySequenceDateForUpdate(sequenceDate) - ?: repository.saveAndFlush(GiftApplicationNoSequence(sequenceDate = sequenceDate, lastSequence = 0)) + val sequence = repository.findByCategoryIdAndSequenceDateForUpdate(categoryId, sequenceDate) + ?: repository.saveAndFlush( + GiftApplicationNoSequence( + categoryId = categoryId, + sequenceDate = sequenceDate, + lastSequence = 0 + ) + ) sequence.lastSequence += 1 - "G$sequenceDate${sequence.lastSequence.toString().padStart(6, '0')}" + "${category.receiptCode}-${category.classificationNumber}$sequenceDate" + + sequence.lastSequence.toString().padStart(4, '0') } ?: throw SodaException(messageKey = "common.error.invalid_request") } catch (_: DataIntegrityViolationException) { if (attempt == MAX_RETRY_COUNT) throw SodaException(messageKey = "gift.application_no.generate_failed") @@ -37,7 +46,7 @@ class GiftApplicationNoGenerator( companion object { private val SEOUL_ZONE = ZoneId.of("Asia/Seoul") - private val DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd") + private val DATE_FORMATTER = DateTimeFormatter.ofPattern("yyMMdd") private const val MAX_RETRY_COUNT = 3 } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftCommandService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftCommandService.kt index 348539ce..a22cd12c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftCommandService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftCommandService.kt @@ -126,7 +126,7 @@ class GiftCommandService( val sizePrice = sizePriceRepository.findBySizeCode(command.sizeCode)?.takeIf { it.isActive } ?: throw invalidRequest() val appliedAt = now() val trackingDeadlineAt = appliedAt.plusDays(3) - val applicationNo = applicationNoGenerator.generate(appliedAt) + val applicationNo = applicationNoGenerator.generate(category, appliedAt) val canUsageId = canPaymentService.spendGiftCan( senderMemberId, sizePrice.salePriceCan, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/GiftCommandServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/GiftCommandServiceTest.kt index be038d71..4c9e5e17 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/GiftCommandServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/GiftCommandServiceTest.kt @@ -454,7 +454,15 @@ class GiftCommandServiceTest { val giftPushService = Mockito.mock(GiftPushService::class.java) val sender = Member(password = "password", nickname = "sender", container = "aos").apply { id = SENDER_ID } val creator = Member(password = "password", nickname = "creator").apply { id = RECIPIENT_ID } - val category = GiftCategory("DOLL", "인형", false, true).apply { id = CATEGORY_ID } + val category = GiftCategory( + classificationNumber = "100", + categoryCode = "DOLL", + name = "인형", + receiptCode = "A", + representativeItem = "피규어", + requiresDamageWaiver = false, + isActive = true + ).apply { id = CATEGORY_ID } val sizePrice = GiftSizePrice(GiftSize.SMALL, BASE_PRICE_CAN, SALE_PRICE_CAN, true) lateinit var savedGift: Gift lateinit var savedDelivery: GiftDelivery @@ -486,7 +494,7 @@ class GiftCommandServiceTest { setCreatorExists(true) Mockito.`when`(categoryRepository.findById(CATEGORY_ID)).thenReturn(Optional.of(category)) Mockito.`when`(sizePriceRepository.findBySizeCode(GiftSize.SMALL)).thenReturn(sizePrice) - Mockito.`when`(applicationNoGenerator.generate(NOW)).thenReturn(APPLICATION_NO) + Mockito.`when`(applicationNoGenerator.generate(category, NOW)).thenReturn(APPLICATION_NO) Mockito.`when`(canPaymentService.spendGiftCan(SENDER_ID, SALE_PRICE_CAN, "aos")).thenReturn(USE_CAN_ID) Mockito.`when`(giftRepository.save(Mockito.any(Gift::class.java))).thenAnswer { invocation -> invocation.getArgument(0).apply { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftApplicationNoGeneratorTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftApplicationNoGeneratorTest.kt index 64996948..6d62f1a6 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftApplicationNoGeneratorTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftApplicationNoGeneratorTest.kt @@ -1,7 +1,10 @@ package kr.co.vividnext.sodalive.v2.gift.application import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftApplicationNoSequence import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftApplicationNoSequenceRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName @@ -24,24 +27,56 @@ import java.util.concurrent.Executors @DisplayName("선물 신청번호 채번 테스트") class GiftApplicationNoGeneratorTest @Autowired constructor( private val repository: GiftApplicationNoSequenceRepository, + private val categoryRepository: GiftCategoryRepository, private val transactionManager: PlatformTransactionManager ) { @BeforeEach fun setUp() { repository.deleteAll() + categoryRepository.deleteAll() } @Test - @DisplayName("같은 날짜 신청번호를 순서대로 발급한다") - fun shouldGenerateSequentialApplicationNoByDate() { + @DisplayName("같은 카테고리와 날짜 신청번호를 순서대로 발급한다") + fun shouldGenerateSequentialApplicationNoByCategoryAndDate() { val generator = GiftApplicationNoGenerator(repository, transactionManager) val now = LocalDateTime.of(2026, 9, 29, 12, 0) + val category = saveCategory("100", "DOLL", "A") - val first = generator.generate(now) - val second = generator.generate(now) + val first = generator.generate(category, now) + val second = generator.generate(category, now) - assertEquals("G20260929000001", first) - assertEquals("G20260929000002", second) + assertEquals("A-1002609290001", first) + assertEquals("A-1002609290002", second) + } + + @Test + @DisplayName("카테고리와 날짜가 다르면 sequence를 따로 발급한다") + fun shouldGenerateApplicationNoByCategoryAndDateIndependently() { + val generator = GiftApplicationNoGenerator(repository, transactionManager) + val doll = saveCategory("100", "DOLL", "A") + val food = saveCategory("200", "FOOD", "C") + + val dollFirst = generator.generate(doll, LocalDateTime.of(2026, 9, 29, 12, 0)) + val foodFirst = generator.generate(food, LocalDateTime.of(2026, 9, 29, 12, 0)) + val dollNextDay = generator.generate(doll, LocalDateTime.of(2026, 9, 30, 12, 0)) + + assertEquals("A-1002609290001", dollFirst) + assertEquals("C-2002609290001", foodFirst) + assertEquals("A-1002609300001", dollNextDay) + } + + @Test + @DisplayName("sequence가 9999를 초과하면 자리수를 늘린다") + fun shouldExpandSequenceDigitsAfter9999() { + val generator = GiftApplicationNoGenerator(repository, transactionManager) + val category = saveCategory("100", "DOLL", "A") + val now = LocalDateTime.of(2026, 9, 30, 12, 0) + repository.save(GiftApplicationNoSequence(categoryId = category.id!!, sequenceDate = "260930", lastSequence = 9999)) + + val applicationNo = generator.generate(category, now) + + assertEquals("A-10026093010000", applicationNo) } @Test @@ -49,12 +84,13 @@ class GiftApplicationNoGeneratorTest @Autowired constructor( fun shouldGenerateUniqueApplicationNoConcurrently() { val generator = GiftApplicationNoGenerator(repository, transactionManager) val now = LocalDateTime.of(2026, 9, 29, 12, 0) + val category = saveCategory("100", "DOLL", "A") val executor = Executors.newFixedThreadPool(4) val applicationNos = try { executor.invokeAll( (1..10).map { - Callable { generator.generate(now) } + Callable { generator.generate(category, now) } } ).map { it.get() } } finally { @@ -62,7 +98,18 @@ class GiftApplicationNoGeneratorTest @Autowired constructor( } assertEquals(10, applicationNos.toSet().size) - assertEquals("G20260929000001", applicationNos.minOrNull()) - assertEquals("G20260929000010", applicationNos.maxOrNull()) + assertEquals("A-1002609290001", applicationNos.minOrNull()) + assertEquals("A-1002609290010", applicationNos.maxOrNull()) } + + private fun saveCategory(classificationNumber: String, categoryCode: String, receiptCode: String) = + categoryRepository.save( + GiftCategory( + classificationNumber = classificationNumber, + categoryCode = categoryCode, + name = "인형", + receiptCode = receiptCode, + representativeItem = "피규어" + ) + ) } From 22c6cd2d38fd7dd6c6e39a52c90943ff26370e3a Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 30 Sep 2026 17:38:51 +0900 Subject: [PATCH 27/53] =?UTF-8?q?feat(gift):=20=EC=84=A0=EB=AC=BC=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EC=8B=9C=EA=B0=81=EC=9D=84=20UTC=20?= =?UTF-8?q?=EB=AC=B8=EC=9E=90=EC=97=B4=EB=A1=9C=20=EB=B3=80=ED=99=98?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/admin/gift/dto/AdminGiftResponse.kt | 12 +++-- .../sodalive/v2/api/gift/dto/GiftResponse.kt | 54 +++++++++---------- .../adapter/in/web/AdminGiftControllerTest.kt | 31 ++++++++++- .../gift/adapter/in/web/GiftControllerTest.kt | 50 ++++++++++++----- 4 files changed, 103 insertions(+), 44 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/dto/AdminGiftResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/dto/AdminGiftResponse.kt index 58f698a1..6754d5be 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/dto/AdminGiftResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/dto/AdminGiftResponse.kt @@ -1,23 +1,29 @@ package kr.co.vividnext.sodalive.v2.api.admin.gift.dto +import kr.co.vividnext.sodalive.extensions.toUtcIso import kr.co.vividnext.sodalive.v2.gift.application.GiftCategoryResult import kr.co.vividnext.sodalive.v2.gift.application.GiftOperationStatusResult import kr.co.vividnext.sodalive.v2.gift.application.GiftSizePriceResult import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus -import java.time.LocalDateTime data class AdminGiftCategoryResponse( val categoryId: Long, + val classificationNumber: String, val categoryCode: String, val name: String, + val receiptCode: String, + val representativeItem: String, val requiresDamageWaiver: Boolean, val isActive: Boolean ) { companion object { fun from(result: GiftCategoryResult) = AdminGiftCategoryResponse( categoryId = result.categoryId, + classificationNumber = result.classificationNumber, categoryCode = result.categoryCode, name = result.name, + receiptCode = result.receiptCode, + representativeItem = result.representativeItem, requiresDamageWaiver = result.requiresDamageWaiver, isActive = result.isActive ) @@ -46,14 +52,14 @@ data class AdminGiftOperationStatusResponse( val applicationNo: String, val status: GiftStatus, val statusName: String, - val occurredAt: LocalDateTime + val occurredAt: String ) { companion object { fun from(result: GiftOperationStatusResult) = AdminGiftOperationStatusResponse( applicationNo = result.applicationNo, status = result.status, statusName = result.statusName, - occurredAt = result.occurredAt + occurredAt = result.occurredAt.toUtcIso() ) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftResponse.kt index c3e15825..5c9883fc 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftResponse.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.api.gift.dto +import kr.co.vividnext.sodalive.extensions.toUtcIso import kr.co.vividnext.sodalive.v2.gift.application.GiftApplicationResult import kr.co.vividnext.sodalive.v2.gift.application.GiftCancellationResult import kr.co.vividnext.sodalive.v2.gift.application.GiftDeliveryConfirmationResult @@ -9,7 +10,6 @@ import kr.co.vividnext.sodalive.v2.gift.application.GiftListResult import kr.co.vividnext.sodalive.v2.gift.application.GiftRecipientAddressRegistrationResult import kr.co.vividnext.sodalive.v2.gift.application.GiftReviewResult import kr.co.vividnext.sodalive.v2.gift.application.GiftTrackingRegistrationResult -import java.time.LocalDateTime data class GiftFormOptionsResponse( val sizes: List, @@ -59,7 +59,7 @@ data class GiftListResponse( priceCan = it.priceCan, categoryName = it.categoryName, sizeName = it.sizeName, - createdAt = it.createdAt + createdAt = it.createdAt?.toUtcIso() ) }, page = result.page, @@ -77,7 +77,7 @@ data class GiftListItemResponse( val priceCan: Int, val categoryName: String, val sizeName: String, - val createdAt: LocalDateTime? + val createdAt: String? ) data class GiftDetailResponse( @@ -91,7 +91,7 @@ data class GiftDetailResponse( val mailbox: GiftMailboxResponse?, val trackingRequired: Boolean, val recipientAddressRequired: Boolean, - val recipientAddressDeadlineAt: LocalDateTime?, + val recipientAddressDeadlineAt: String?, val delivery: GiftDeliveryInfoResponse, val statusTimeline: List ) { @@ -109,21 +109,21 @@ data class GiftDetailResponse( applicationNo = result.giftInfo.applicationNo, paidCan = result.giftInfo.paidCan, tracking = result.giftInfo.tracking, - shippingRequestedAt = result.giftInfo.shippingRequestedAt + shippingRequestedAt = result.giftInfo.shippingRequestedAt?.toUtcIso() ), senderInfo = result.senderInfo?.let { GiftAddressResponse(it.name, it.phoneNumber, it.address) }, recipientAddress = result.recipientAddress?.let { GiftAddressResponse(it.name, it.phoneNumber, it.address) }, mailbox = result.mailbox?.let { GiftMailboxResponse(it.name, it.address, it.phoneNumber) }, trackingRequired = result.trackingRequired, recipientAddressRequired = result.recipientAddressRequired, - recipientAddressDeadlineAt = result.recipientAddressDeadlineAt, + recipientAddressDeadlineAt = result.recipientAddressDeadlineAt?.toUtcIso(), delivery = GiftDeliveryInfoResponse( - canceledAt = result.delivery.canceledAt, - undeliverableAt = result.delivery.undeliverableAt, + canceledAt = result.delivery.canceledAt?.toUtcIso(), + undeliverableAt = result.delivery.undeliverableAt?.toUtcIso(), undeliverableReason = result.delivery.undeliverableReason ), statusTimeline = result.statusTimeline.map { - GiftStatusTimelineResponse(it.status.name, it.statusName, it.occurredAt) + GiftStatusTimelineResponse(it.status.name, it.statusName, it.occurredAt?.toUtcIso()) } ) } @@ -137,7 +137,7 @@ data class GiftDetailInfoResponse( val applicationNo: String?, val paidCan: Int?, val tracking: String?, - val shippingRequestedAt: LocalDateTime? + val shippingRequestedAt: String? ) data class GiftAddressResponse( @@ -153,15 +153,15 @@ data class GiftMailboxResponse( ) data class GiftDeliveryInfoResponse( - val canceledAt: LocalDateTime?, - val undeliverableAt: LocalDateTime?, + val canceledAt: String?, + val undeliverableAt: String?, val undeliverableReason: String? ) data class GiftStatusTimelineResponse( val status: String, val statusName: String, - val occurredAt: LocalDateTime? + val occurredAt: String? ) data class GiftApplicationResponse( @@ -169,7 +169,7 @@ data class GiftApplicationResponse( val status: String, val statusName: String, val priceCan: Int, - val trackingDeadlineAt: LocalDateTime + val trackingDeadlineAt: String ) { companion object { fun from(result: GiftApplicationResult) = GiftApplicationResponse( @@ -177,7 +177,7 @@ data class GiftApplicationResponse( status = result.status.name, statusName = result.statusName, priceCan = result.priceCan, - trackingDeadlineAt = result.trackingDeadlineAt + trackingDeadlineAt = result.trackingDeadlineAt.toUtcIso() ) } } @@ -187,7 +187,7 @@ data class GiftCancellationResponse( val status: String, val statusName: String, val priceCan: Int, - val canceledAt: LocalDateTime + val canceledAt: String ) { companion object { fun from(result: GiftCancellationResult) = GiftCancellationResponse( @@ -195,7 +195,7 @@ data class GiftCancellationResponse( status = result.status.name, statusName = result.statusName, priceCan = result.priceCan, - canceledAt = result.canceledAt + canceledAt = result.canceledAt.toUtcIso() ) } } @@ -206,8 +206,8 @@ data class GiftTrackingRegistrationResponse( val statusName: String, val courierCompanyName: String, val trackingNumber: String, - val trackingRegisteredAt: LocalDateTime, - val recipientAddressDeadlineAt: LocalDateTime + val trackingRegisteredAt: String, + val recipientAddressDeadlineAt: String ) { companion object { fun from(result: GiftTrackingRegistrationResult) = GiftTrackingRegistrationResponse( @@ -216,8 +216,8 @@ data class GiftTrackingRegistrationResponse( statusName = result.statusName, courierCompanyName = result.courierCompanyName, trackingNumber = result.trackingNumber, - trackingRegisteredAt = result.trackingRegisteredAt, - recipientAddressDeadlineAt = result.recipientAddressDeadlineAt + trackingRegisteredAt = result.trackingRegisteredAt.toUtcIso(), + recipientAddressDeadlineAt = result.recipientAddressDeadlineAt.toUtcIso() ) } } @@ -226,14 +226,14 @@ data class GiftRecipientAddressRegistrationResponse( val applicationNo: String, val status: String, val statusName: String, - val recipientAddressRegisteredAt: LocalDateTime + val recipientAddressRegisteredAt: String ) { companion object { fun from(result: GiftRecipientAddressRegistrationResult) = GiftRecipientAddressRegistrationResponse( applicationNo = result.applicationNo, status = result.status.name, statusName = result.statusName, - recipientAddressRegisteredAt = result.recipientAddressRegisteredAt + recipientAddressRegisteredAt = result.recipientAddressRegisteredAt.toUtcIso() ) } } @@ -242,14 +242,14 @@ data class GiftDeliveryConfirmationResponse( val applicationNo: String, val status: String, val statusName: String, - val deliveredAt: LocalDateTime + val deliveredAt: String ) { companion object { fun from(result: GiftDeliveryConfirmationResult) = GiftDeliveryConfirmationResponse( applicationNo = result.applicationNo, status = result.status.name, statusName = result.statusName, - deliveredAt = result.deliveredAt + deliveredAt = result.deliveredAt.toUtcIso() ) } } @@ -260,7 +260,7 @@ data class GiftReviewResponse( val rating: Int, val keywords: List, val comment: String?, - val createdAt: LocalDateTime? + val createdAt: String? ) { companion object { fun from(result: GiftReviewResult) = GiftReviewResponse( @@ -269,7 +269,7 @@ data class GiftReviewResponse( rating = result.rating, keywords = result.keywords, comment = result.comment, - createdAt = result.createdAt + createdAt = result.createdAt?.toUtcIso() ) } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt index 38bd8172..369ac8a2 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt @@ -10,6 +10,7 @@ import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePrice import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus +import org.hamcrest.Matchers.endsWith import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test @@ -62,13 +63,24 @@ class AdminGiftControllerTest @Autowired constructor( .contentType(MediaType.APPLICATION_JSON) .content( """ - {"categoryCode":"DOLL","name":"인형","requiresDamageWaiver":true,"isActive":true} + { + "classificationNumber":"100", + "categoryCode":"DOLL", + "name":"인형", + "receiptCode":"A", + "representativeItem":"피규어", + "requiresDamageWaiver":true, + "isActive":true + } """.trimIndent() ) ) .andExpect(status().isOk) .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.classificationNumber").value("100")) .andExpect(jsonPath("$.data.categoryCode").value("DOLL")) + .andExpect(jsonPath("$.data.receiptCode").value("A")) + .andExpect(jsonPath("$.data.representativeItem").value("피규어")) val categoryId = categoryRepository.findByCategoryCode("DOLL")!!.id!! @@ -78,12 +90,23 @@ class AdminGiftControllerTest @Autowired constructor( .contentType(MediaType.APPLICATION_JSON) .content( """ - {"categoryCode":"DOLL","name":"큰 인형","requiresDamageWaiver":false,"isActive":true} + { + "classificationNumber":"101", + "categoryCode":"DOLL", + "name":"큰 인형", + "receiptCode":"B", + "representativeItem":"대형 피규어", + "requiresDamageWaiver":false, + "isActive":true + } """.trimIndent() ) ) .andExpect(status().isOk) + .andExpect(jsonPath("$.data.classificationNumber").value("101")) .andExpect(jsonPath("$.data.name").value("큰 인형")) + .andExpect(jsonPath("$.data.receiptCode").value("B")) + .andExpect(jsonPath("$.data.representativeItem").value("대형 피규어")) mockMvc.perform(get("/api/v2/admin/gift-categories").with(user("admin").roles("ADMIN"))) .andExpect(status().isOk) @@ -132,11 +155,13 @@ class AdminGiftControllerTest @Autowired constructor( .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.data.applicationNo").value(APPLICATION_NO)) .andExpect(jsonPath("$.data.status").value("ARRIVED_AT_MAILBOX")) + .andExpect(jsonPath("$.data.occurredAt").value(endsWith("Z"))) mockMvc.perform(post("/api/v2/admin/gifts/$APPLICATION_NO/complete-inspection").with(user("admin").roles("ADMIN"))) .andExpect(status().isOk) .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.data.status").value("INSPECTION_COMPLETED")) + .andExpect(jsonPath("$.data.occurredAt").value(endsWith("Z"))) } @Test @@ -154,6 +179,7 @@ class AdminGiftControllerTest @Autowired constructor( .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.data.applicationNo").value(APPLICATION_NO)) .andExpect(jsonPath("$.data.status").value("UNDELIVERABLE")) + .andExpect(jsonPath("$.data.occurredAt").value(endsWith("Z"))) } @Test @@ -181,6 +207,7 @@ class AdminGiftControllerTest @Autowired constructor( .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.data.applicationNo").value(APPLICATION_NO)) .andExpect(jsonPath("$.data.status").value("DELIVERED")) + .andExpect(jsonPath("$.data.occurredAt").value(endsWith("Z"))) } private fun saveGift(status: GiftStatus) { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt index effe8e04..8a195ba9 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt @@ -26,6 +26,7 @@ import kr.co.vividnext.sodalive.v2.gift.application.GiftReviewResult import kr.co.vividnext.sodalive.v2.gift.application.GiftTrackingRegistrationResult import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus +import org.hamcrest.Matchers.endsWith import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.mockito.Mockito @@ -64,8 +65,8 @@ class GiftControllerTest @Autowired constructor( @Test fun `팬은 활성 폼 옵션을 조회하고 categoryCode를 받지 않는다`() { - categoryRepository.save(GiftCategory("DOLL", "인형", true, true)) - categoryRepository.save(GiftCategory("FOOD", "간식", false, false)) + categoryRepository.save(giftCategory("100", "DOLL", "A", "인형", "피규어", true, true)) + categoryRepository.save(giftCategory("200", "FOOD", "B", "간식", "쿠키", false, false)) sizePriceRepository.save(GiftSizePrice(GiftSize.SMALL, 100, 80, true)) mockMvc.perform(get("/api/v2/gifts/form-options").with(user("fan").roles("USER"))) @@ -75,8 +76,11 @@ class GiftControllerTest @Autowired constructor( .andExpect(jsonPath("$.data.sizes[0].name").value("소형")) .andExpect(jsonPath("$.data.sizes[0].basePriceCan").value(100)) .andExpect(jsonPath("$.data.sizes[0].salePriceCan").value(80)) - .andExpect(jsonPath("$.data.categories[0].name").value("인형")) + .andExpect(jsonPath("$.data.categories[0].name").value("인형/피규어")) .andExpect(jsonPath("$.data.categories[0].categoryCode").doesNotExist()) + .andExpect(jsonPath("$.data.categories[0].classificationNumber").doesNotExist()) + .andExpect(jsonPath("$.data.categories[0].receiptCode").doesNotExist()) + .andExpect(jsonPath("$.data.categories[0].representativeItem").doesNotExist()) } @Test @@ -113,7 +117,7 @@ class GiftControllerTest @Autowired constructor( .andExpect(jsonPath("$.data.items[0].priceCan").value(80)) .andExpect(jsonPath("$.data.items[0].categoryName").value("인형")) .andExpect(jsonPath("$.data.items[0].sizeName").value("소형")) - .andExpect(jsonPath("$.data.items[0].createdAt").exists()) + .andExpect(jsonPath("$.data.items[0].createdAt").value(endsWith("Z"))) } @Test @@ -161,7 +165,11 @@ class GiftControllerTest @Autowired constructor( .andExpect(jsonPath("$.data.recipientAddress").isEmpty) .andExpect(jsonPath("$.data.trackingRequired").value(false)) .andExpect(jsonPath("$.data.statusTimeline[0].status").value("RECEIVED")) - .andExpect(jsonPath("$.data.statusTimeline[1].occurredAt").value("2026-09-30T12:00:00")) + .andExpect(jsonPath("$.data.recipientAddressDeadlineAt").isEmpty) + .andExpect(jsonPath("$.data.giftInfo.shippingRequestedAt").isEmpty) + .andExpect(jsonPath("$.data.delivery.canceledAt").isEmpty) + .andExpect(jsonPath("$.data.delivery.undeliverableAt").isEmpty) + .andExpect(jsonPath("$.data.statusTimeline[1].occurredAt").value("2026-09-30T12:00:00Z")) } @Test @@ -209,7 +217,7 @@ class GiftControllerTest @Autowired constructor( .andExpect(jsonPath("$.data.status").value("RECEIVED")) .andExpect(jsonPath("$.data.statusName").value("접수 완료")) .andExpect(jsonPath("$.data.priceCan").value(80)) - .andExpect(jsonPath("$.data.trackingDeadlineAt").value("2026-10-02T12:00:00")) + .andExpect(jsonPath("$.data.trackingDeadlineAt").value("2026-10-02T12:00:00Z")) } @Test @@ -240,7 +248,7 @@ class GiftControllerTest @Autowired constructor( .andExpect(jsonPath("$.data.status").value("CANCELED")) .andExpect(jsonPath("$.data.statusName").value("신청 취소")) .andExpect(jsonPath("$.data.priceCan").value(80)) - .andExpect(jsonPath("$.data.canceledAt").value("2026-09-29T12:00:00")) + .andExpect(jsonPath("$.data.canceledAt").value("2026-09-29T12:00:00Z")) } @Test @@ -281,8 +289,8 @@ class GiftControllerTest @Autowired constructor( .andExpect(jsonPath("$.data.statusName").value("발송 확인")) .andExpect(jsonPath("$.data.courierCompanyName").value("CJ대한통운")) .andExpect(jsonPath("$.data.trackingNumber").value("1234567890")) - .andExpect(jsonPath("$.data.trackingRegisteredAt").value("2026-09-29T12:00:00")) - .andExpect(jsonPath("$.data.recipientAddressDeadlineAt").value("2026-10-06T12:00:00")) + .andExpect(jsonPath("$.data.trackingRegisteredAt").value("2026-09-29T12:00:00Z")) + .andExpect(jsonPath("$.data.recipientAddressDeadlineAt").value("2026-10-06T12:00:00Z")) } @Test @@ -324,7 +332,7 @@ class GiftControllerTest @Autowired constructor( .andExpect(jsonPath("$.data.applicationNo").value(APPLICATION_NO)) .andExpect(jsonPath("$.data.status").value("TRACKING_REGISTERED")) .andExpect(jsonPath("$.data.statusName").value("발송 확인")) - .andExpect(jsonPath("$.data.recipientAddressRegisteredAt").value("2026-09-29T12:00:00")) + .andExpect(jsonPath("$.data.recipientAddressRegisteredAt").value("2026-09-29T12:00:00Z")) } @Test @@ -350,7 +358,7 @@ class GiftControllerTest @Autowired constructor( .andExpect(jsonPath("$.data.applicationNo").value(APPLICATION_NO)) .andExpect(jsonPath("$.data.status").value("DELIVERED")) .andExpect(jsonPath("$.data.statusName").value("전달완료")) - .andExpect(jsonPath("$.data.deliveredAt").value("2026-09-29T12:00:00")) + .andExpect(jsonPath("$.data.deliveredAt").value("2026-09-29T12:00:00Z")) Mockito.verify(commandService).confirmDelivery(member, APPLICATION_NO) } @@ -391,7 +399,7 @@ class GiftControllerTest @Autowired constructor( .andExpect(jsonPath("$.data.keywords[1]").value("안내가 친절해요")) .andExpect(jsonPath("$.data.keyword").doesNotExist()) .andExpect(jsonPath("$.data.comment").value("선물 전달 과정이 만족스러웠습니다.")) - .andExpect(jsonPath("$.data.createdAt").value("2026-10-11T12:00:00")) + .andExpect(jsonPath("$.data.createdAt").value("2026-10-11T12:00:00Z")) Mockito.verify(commandService).writeReview(member, APPLICATION_NO, request.toCommand()) } @@ -419,6 +427,24 @@ class GiftControllerTest @Autowired constructor( ) ) + private fun giftCategory( + classificationNumber: String, + categoryCode: String, + receiptCode: String, + name: String, + representativeItem: String, + requiresDamageWaiver: Boolean, + isActive: Boolean + ) = GiftCategory( + classificationNumber = classificationNumber, + categoryCode = categoryCode, + name = name, + receiptCode = receiptCode, + representativeItem = representativeItem, + requiresDamageWaiver = requiresDamageWaiver, + isActive = isActive + ) + private companion object { const val SENDER_ID = 1L const val RECIPIENT_ID = 2L From afe92829dc9a9dde73c7818ab462e9c41ae2b925 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 30 Sep 2026 18:41:01 +0900 Subject: [PATCH 28/53] =?UTF-8?q?docs(gift):=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EC=84=A0=EB=AC=BC=ED=95=A8=20=EC=A1=B0=ED=9A=8C=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=EC=82=AC=ED=95=AD=EC=9D=84=20=EA=B8=B0=EB=A1=9D?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260929_크리에이터_선물하기/prd.md | 103 ++++++++++++++++++++++- 1 file changed, 101 insertions(+), 2 deletions(-) diff --git a/docs/20260929_크리에이터_선물하기/prd.md b/docs/20260929_크리에이터_선물하기/prd.md index 6a559c83..1d148d04 100644 --- a/docs/20260929_크리에이터_선물하기/prd.md +++ b/docs/20260929_크리에이터_선물하기/prd.md @@ -246,6 +246,7 @@ INSPECTION_COMPLETED -> UNDELIVERABLE | `GIFT-011` | 확정 | 팬/크리에이터 대상 푸시를 상태별로 분리 발송한다 | 각 상태 전이에서 지정된 수신자에게만 지정 문구와 딥링크가 발송된다 | `P7` | | `GIFT-012` | 확정 | 관리자는 카테고리를 등록/수정/논리삭제할 수 있다 | 삭제는 `isActive=false`만 수행한다 | `P1` | | `GIFT-013` | 확정 | 관리자는 사이즈별 가격을 수정할 수 있다 | 새 신청에는 변경 가격이 적용되고 기존 신청 스냅샷은 바뀌지 않는다 | `P1` | +| `GIFT-015` | 확정 | 관리자는 전체 선물함 목록과 상세를 조회하고 현재 상태에서 가능한 운영 액션을 확인할 수 있다 | 목록은 상태/신청번호/닉네임 검색과 페이징을 지원하고, 상세는 발송인/수취인 개인정보와 상품/배송/상태 정보를 내려준다 | `P10` | ## 8. API 계약 @@ -715,7 +716,105 @@ Response: `salePriceCan`은 0보다 커야 하고 `basePriceCan`보다 클 수 없다. -### 8.13 운영 상태변경 API +### 8.13 관리자 선물함 조회 API + +관리자 페이지는 전체 선물함을 목록과 상세로 조회한다. 상태 변경은 `8.14 운영 상태변경 API`의 기존 전용 endpoint를 사용한다. + +#### 8.13.1 관리자 선물함 목록 조회 + +`GET /api/v2/admin/gifts?status=TRACKING_REGISTERED&applicationNo=A-100&nickname=fan&page=0&size=20` + +Query: + +| 이름 | 필수 | 설명 | +|---|---|---| +| `status` | 아니오 | 선물 상태. 지정하면 해당 상태만 조회 | +| `applicationNo` | 아니오 | 신청번호 부분 검색 | +| `nickname` | 아니오 | 발송인 또는 수취인 닉네임 부분 검색 | +| `page` | 아니오 | 0 기반 page index. 기본값 `0` | +| `size` | 아니오 | page size. 기본값 `20` | + +필터는 AND로 조합한다. `nickname`은 발송인 닉네임 또는 수취인 닉네임 중 하나에 포함되면 매칭한다. + +Response: + +```json +{ + "totalCount": 1, + "items": [ + { + "applicationNo": "A-1002609300001", + "senderNickname": "팬닉네임", + "recipientNickname": "크리에이터닉네임", + "sizeCode": "SMALL", + "sizeName": "소형", + "categoryName": "인형", + "classificationNumber": "100", + "courierCompanyName": "CJ대한통운", + "trackingNumber": "1234567890", + "status": "TRACKING_REGISTERED", + "statusName": "발송 확인", + "availableActions": ["ARRIVE_MAILBOX", "MARK_UNDELIVERABLE"] + } + ], + "page": 0, + "size": 20, + "hasNext": false +} +``` + +#### 8.13.2 관리자 선물 상세 조회 + +`GET /api/v2/admin/gifts/{applicationNo}` + +Response: + +```json +{ + "applicationNo": "A-1002609300001", + "senderInfo": { + "nickname": "팬닉네임", + "name": "홍길동", + "phoneNumber": "01012345678", + "address": "(06234) 서울시 강남구 ..., 101동 1001호" + }, + "recipientInfo": { + "nickname": "크리에이터닉네임", + "name": "김소다", + "phoneNumber": "01098765432", + "address": "(04524) 서울시 중구 ..., 202호" + }, + "productInfo": { + "sizeCode": "SMALL", + "sizeName": "소형", + "categoryName": "인형", + "classificationNumber": "100" + }, + "inboundDeliveryInfo": { + "courierCompanyName": "CJ대한통운", + "trackingNumber": "1234567890" + }, + "status": "TRACKING_REGISTERED", + "statusName": "발송 확인", + "availableActions": ["ARRIVE_MAILBOX", "MARK_UNDELIVERABLE"] +} +``` + +크리에이터가 배송지를 아직 입력하지 않은 경우에도 `recipientInfo.nickname`은 내려주고, `recipientInfo.name`, `recipientInfo.phoneNumber`, `recipientInfo.address`는 빈 문자열 `""`로 내려준다. + +`availableActions`는 현재 상태별로 다음 값만 내려준다. + +| 현재 상태 | availableActions | +|---|---| +| `RECEIVED` | `[]` | +| `TRACKING_REGISTERED` | `["ARRIVE_MAILBOX", "MARK_UNDELIVERABLE"]` | +| `ARRIVED_AT_MAILBOX` | `["COMPLETE_INSPECTION", "MARK_UNDELIVERABLE"]` | +| `INSPECTION_COMPLETED` | `["COMPLETE_DELIVERY", "MARK_UNDELIVERABLE"]` | +| `DELIVERED` | `[]` | +| `UNDELIVERABLE` | `[]` | +| `CANCELED` | `[]` | + +### 8.14 운영 상태변경 API | Method | Path | 이전 상태 | 변경 상태 | 필수 입력 | |---|---|---|---|---| @@ -732,7 +831,7 @@ Response: } ``` -### 8.14 자동 스케줄링 작업 +### 8.15 자동 스케줄링 작업 | 작업 | 대상 | 처리 | 푸시 | |---|---|---|---| From 375be9e9a9dd1273de9ed943af134a3b01aa8d8f Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 30 Sep 2026 18:41:13 +0900 Subject: [PATCH 29/53] =?UTF-8?q?docs(gift):=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EC=84=A0=EB=AC=BC=ED=95=A8=20=EC=A1=B0=ED=9A=8C=20=EA=B3=84?= =?UTF-8?q?=ED=9A=8D=EC=9D=84=20=EA=B8=B0=EB=A1=9D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20260929_크리에이터_선물하기/plan-task.md | 52 ++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/docs/20260929_크리에이터_선물하기/plan-task.md b/docs/20260929_크리에이터_선물하기/plan-task.md index 6b73c835..21c0a614 100644 --- a/docs/20260929_크리에이터_선물하기/plan-task.md +++ b/docs/20260929_크리에이터_선물하기/plan-task.md @@ -8,7 +8,7 @@ | 작성일 | 2026-09-29 | | 요구사항 기준 | `docs/20260929_크리에이터_선물하기/prd.md` | | API 기준 | `docs/20260929_크리에이터_선물하기/prd.md`의 `8. API 계약` | -| 현재 Phase | Phase 9 완료 | +| 현재 Phase | Phase 10 완료 | | 현재 활성 Goal | 완료 | ## 목표 @@ -40,6 +40,7 @@ | 7 | 완료 | `3/3` | 완료 | 없음 | | 8 | 완료 | `1/1` | 완료 | 없음 | | 9 | 완료 | `3/3` | 완료 | 없음 | +| 10 | 완료 | `3/3` | 완료 | 관리자 선물함 목록/상세 API 추가 | - 동시에 하나의 미완료 goal만 운용한다. - 구현 중 요구사항이 바뀌면 이 문서의 체크박스와 범위를 먼저 갱신한 뒤 코드를 수정한다. @@ -708,6 +709,55 @@ git diff --check **결과:** 완료. 2026-09-30 기준 Phase 9 Gate 명령 3개 모두 성공. +## Phase 10: 관리자 선물함 목록과 상세 조회 + +**Phase 결과:** 관리자가 전체 선물 신청을 목록/상세로 조회하고, 현재 상태에서 가능한 운영 액션을 확인할 수 있다. + +**선행조건:** Phase 9 완료, 사용자 확정 요구사항 반영. + +**Phase 완료 조건:** `P10-T1`~`P10-T3`와 `P10-GATE` 완료, 검증 기록 누적. + +### 구현 항목 + +#### Task 10.1 문서 계약 갱신 + +**Goal 실행 `P10-T1`:** PRD, 구현 계획, 클라이언트 요약에 관리자 선물함 목록/상세 API 계약을 추가한다. + +- **Files:** Modify `prd.md`, `plan-task.md`, `client-api-summary.md`. +- [x] **RED:** 기존 관리자 API 문서에 전체 선물함 목록/상세 조회 계약이 없음을 확인한다. +- [x] **GREEN:** `GET /api/v2/admin/gifts`, `GET /api/v2/admin/gifts/{applicationNo}`와 `availableActions` 정책을 문서화한다. +- [x] **검증:** 세 문서가 같은 필터, 응답 필드, 개인정보 미입력 표현을 설명하는지 대조한다. + +#### Task 10.2 관리자 선물함 query service 구현 + +**Goal 실행 `P10-T2`:** 관리자 선물함 목록/상세 조회 service와 repository query를 구현한다. + +- **Files:** Create `GiftAdminQueryService.kt`; Modify `GiftRepository.kt`, `GiftDeliveryRepository.kt`. +- **Tests:** Create `GiftAdminQueryServiceTest.kt`. +- [x] **RED:** 필터, 페이징, 목록/상세 필드, 수취인 배송지 미입력 빈 문자열, 상태별 `availableActions` 테스트를 작성하고 실패를 확인한다. +- [x] **GREEN:** 조회 service와 repository query를 최소 구현한다. +- [x] **검증:** `GiftAdminQueryServiceTest`를 실행한다. + +#### Task 10.3 관리자 선물함 HTTP API 구현 + +**Goal 실행 `P10-T3`:** 관리자 controller와 response DTO에 목록/상세 endpoint를 연결한다. + +- **Files:** Modify `AdminGiftController.kt`, `AdminGiftResponse.kt`. +- **Tests:** Modify `AdminGiftControllerTest.kt`. +- [x] **RED:** MockMvc로 목록/상세 JSON 계약과 권한/오류를 테스트하고 실패를 확인한다. +- [x] **GREEN:** 두 GET endpoint와 response DTO를 최소 구현한다. +- [x] **검증:** `AdminGiftControllerTest`, `GiftAdminQueryServiceTest`를 실행한다. + +### Phase 10 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.*" +./gradlew ktlintCheck +git diff --check +``` + +**결과:** 완료. 2026-09-30 기준 focused 테스트, `ktlintCheck`, `git diff --check` 성공. + ## 실행 순서와 의존성 | 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 | From 059854c19ffb74739b1050b95e7a60fb64417954 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 30 Sep 2026 18:41:25 +0900 Subject: [PATCH 30/53] =?UTF-8?q?docs(gift):=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EC=84=A0=EB=AC=BC=ED=95=A8=20=ED=81=B4=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EC=96=B8=ED=8A=B8=20=EA=B3=84=EC=95=BD=EC=9D=84=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../client-api-summary.md | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/docs/20260929_크리에이터_선물하기/client-api-summary.md b/docs/20260929_크리에이터_선물하기/client-api-summary.md index c78f353e..2031c594 100644 --- a/docs/20260929_크리에이터_선물하기/client-api-summary.md +++ b/docs/20260929_크리에이터_선물하기/client-api-summary.md @@ -42,6 +42,8 @@ PRD에는 상세 API 계약, request/response 예시, 요구사항을 유지한 | 사용자 | Method | URI | 역할 | Request | Response | |---|---:|---|---|---|---| | 관리자 | GET | `/api/v2/admin/gift-categories` | 선물 카테고리 목록을 조회한다. | 없음 | `AdminGiftCategoryResponse[]` | +| 관리자 | GET | `/api/v2/admin/gifts` | 전체 선물함 목록을 조회하고 상태/신청번호/닉네임으로 검색한다. | query: `status`, `applicationNo`, `nickname`, `page`, `size` | `totalCount`, `items[]`, `page`, `size`, `hasNext` | +| 관리자 | GET | `/api/v2/admin/gifts/{applicationNo}` | 선물 상세와 발송인/수취인 개인정보, 상품/배송/상태 정보를 조회한다. | path: `applicationNo` | `applicationNo`, `senderInfo`, `recipientInfo`, `productInfo`, `inboundDeliveryInfo`, `status`, `statusName`, `availableActions` | | 관리자 | POST | `/api/v2/admin/gift-categories` | 선물 카테고리를 등록한다. | body: `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive` | `categoryId`, `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive` | | 관리자 | PUT | `/api/v2/admin/gift-categories/{categoryId}` | 선물 카테고리를 수정한다. | path: `categoryId`, body: `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive` | `categoryId`, `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive` | | 관리자 | DELETE | `/api/v2/admin/gift-categories/{categoryId}` | 선물 카테고리를 비활성화한다. | path: `categoryId` | `categoryId`, `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive=false` | @@ -55,6 +57,9 @@ PRD에는 상세 API 계약, request/response 예시, 요구사항을 유지한 관리자 응답 DTO 요약: - `AdminGiftCategoryResponse`: `categoryId`, `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive` +- `AdminGiftListResponse`: `totalCount`, `items`, `page`, `size`, `hasNext` +- `AdminGiftListItemResponse`: `applicationNo`, `senderNickname`, `recipientNickname`, `sizeCode`, `sizeName`, `categoryName`, `classificationNumber`, `courierCompanyName`, `trackingNumber`, `status`, `statusName`, `availableActions` +- `AdminGiftDetailResponse`: `applicationNo`, `senderInfo`, `recipientInfo`, `productInfo`, `inboundDeliveryInfo`, `status`, `statusName`, `availableActions` - `AdminGiftSizePriceResponse`: `sizeCode`, `name`, `basePriceCan`, `salePriceCan`, `isActive` - `AdminGiftOperationStatusResponse`: `applicationNo`, `status`, `statusName`, `occurredAt` @@ -282,6 +287,59 @@ data class AdminGiftCategoryResponse( val isActive: Boolean ) +data class AdminGiftListResponse( + val totalCount: Long, + val items: List, + val page: Int, + val size: Int, + val hasNext: Boolean +) + +data class AdminGiftListItemResponse( + val applicationNo: String, + val senderNickname: String, + val recipientNickname: String, + val sizeCode: String, + val sizeName: String, + val categoryName: String, + val classificationNumber: String, + val courierCompanyName: String?, + val trackingNumber: String?, + val status: String, + val statusName: String, + val availableActions: List +) + +data class AdminGiftDetailResponse( + val applicationNo: String, + val senderInfo: AdminGiftMemberInfoResponse, + val recipientInfo: AdminGiftMemberInfoResponse, + val productInfo: AdminGiftProductInfoResponse, + val inboundDeliveryInfo: AdminGiftInboundDeliveryInfoResponse, + val status: String, + val statusName: String, + val availableActions: List +) + +data class AdminGiftMemberInfoResponse( + val nickname: String, + val name: String, + val phoneNumber: String, + val address: String +) + +data class AdminGiftProductInfoResponse( + val sizeCode: String, + val sizeName: String, + val categoryName: String, + val classificationNumber: String +) + +data class AdminGiftInboundDeliveryInfoResponse( + val courierCompanyName: String?, + val trackingNumber: String? +) + data class AdminGiftSizePriceResponse( val sizeCode: String, val name: String, From b5a5d1318b21d08df6ae37484e3b7f3d61811962 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 30 Sep 2026 18:41:43 +0900 Subject: [PATCH 31/53] =?UTF-8?q?feat(gift):=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EC=84=A0=EB=AC=BC=ED=95=A8=20=EC=A1=B0=ED=9A=8C=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../out/persistence/GiftDeliveryRepository.kt | 2 + .../adapter/out/persistence/GiftRepository.kt | 35 +++ .../gift/application/GiftAdminQueryService.kt | 205 ++++++++++++++++ .../application/GiftAdminQueryServiceTest.kt | 228 ++++++++++++++++++ 4 files changed, 470 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminQueryService.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminQueryServiceTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftDeliveryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftDeliveryRepository.kt index 63e2b543..2a4c3487 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftDeliveryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftDeliveryRepository.kt @@ -4,4 +4,6 @@ import org.springframework.data.jpa.repository.JpaRepository interface GiftDeliveryRepository : JpaRepository { fun findByGiftId(giftId: Long): GiftDelivery? + + fun findByGiftIdIn(giftIds: Collection): List } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftRepository.kt index 1c055939..d689862b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftRepository.kt @@ -26,6 +26,41 @@ interface GiftRepository : JpaRepository { pageable: Pageable ): Page + @Query( + value = """ + select g + from Gift g + join Member sender on sender.id = g.senderMemberId + join Member recipient on recipient.id = g.recipientMemberId + where (:status is null or g.status = :status) + and (:applicationNo is null or g.applicationNo like concat('%', :applicationNo, '%')) + and ( + :nickname is null + or sender.nickname like concat('%', :nickname, '%') + or recipient.nickname like concat('%', :nickname, '%') + ) + """, + countQuery = """ + select count(g) + from Gift g + join Member sender on sender.id = g.senderMemberId + join Member recipient on recipient.id = g.recipientMemberId + where (:status is null or g.status = :status) + and (:applicationNo is null or g.applicationNo like concat('%', :applicationNo, '%')) + and ( + :nickname is null + or sender.nickname like concat('%', :nickname, '%') + or recipient.nickname like concat('%', :nickname, '%') + ) + """ + ) + fun findAdminGifts( + @Param("status") status: GiftStatus?, + @Param("applicationNo") applicationNo: String?, + @Param("nickname") nickname: String?, + pageable: Pageable + ): Page + @Query( """ select g diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminQueryService.kt new file mode 100644 index 00000000..13cb9d06 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminQueryService.kt @@ -0,0 +1,205 @@ +package kr.co.vividnext.sodalive.v2.gift.application + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository +import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +data class AdminGiftListResult( + val totalCount: Long, + val items: List, + val page: Int, + val size: Int, + val hasNext: Boolean +) + +data class AdminGiftListItemResult( + val applicationNo: String, + val senderNickname: String, + val recipientNickname: String, + val sizeCode: String, + val sizeName: String, + val categoryName: String, + val classificationNumber: String, + val courierCompanyName: String?, + val trackingNumber: String?, + val status: GiftStatus, + val statusName: String, + val availableActions: List +) + +data class AdminGiftDetailResult( + val applicationNo: String, + val senderInfo: AdminGiftMemberInfoResult, + val recipientInfo: AdminGiftMemberInfoResult, + val productInfo: AdminGiftProductInfoResult, + val inboundDeliveryInfo: AdminGiftInboundDeliveryInfoResult, + val status: GiftStatus, + val statusName: String, + val availableActions: List +) + +data class AdminGiftMemberInfoResult( + val nickname: String, + val name: String, + val phoneNumber: String, + val address: String +) + +data class AdminGiftProductInfoResult( + val sizeCode: String, + val sizeName: String, + val categoryName: String, + val classificationNumber: String +) + +data class AdminGiftInboundDeliveryInfoResult( + val courierCompanyName: String?, + val trackingNumber: String? +) + +@Service +class GiftAdminQueryService( + private val giftRepository: GiftRepository, + private val deliveryRepository: GiftDeliveryRepository, + private val categoryRepository: GiftCategoryRepository, + private val memberRepository: MemberRepository +) { + @Transactional(readOnly = true) + fun getGifts( + status: GiftStatus?, + applicationNo: String?, + nickname: String?, + page: Int, + size: Int + ): AdminGiftListResult { + val normalizedPage = page.coerceAtLeast(0) + val normalizedSize = if (size <= 0) DEFAULT_PAGE_SIZE else size + val gifts = giftRepository.findAdminGifts( + status = status, + applicationNo = applicationNo?.takeIf { it.isNotBlank() }, + nickname = nickname?.takeIf { it.isNotBlank() }, + pageable = PageRequest.of(normalizedPage, normalizedSize, Sort.by(Sort.Direction.DESC, "createdAt", "id")) + ) + val context = buildContext(gifts.content) + + return AdminGiftListResult( + totalCount = gifts.totalElements, + items = gifts.content.map { gift -> gift.toListItem(context) }, + page = normalizedPage, + size = normalizedSize, + hasNext = gifts.hasNext() + ) + } + + @Transactional(readOnly = true) + fun getGiftDetail(applicationNo: String): AdminGiftDetailResult { + val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest() + val context = buildContext(listOf(gift)) + val delivery = context.delivery(gift) + val sender = context.sender(gift) + val recipient = context.recipient(gift) + val category = context.category(gift) + + return AdminGiftDetailResult( + applicationNo = gift.applicationNo, + senderInfo = AdminGiftMemberInfoResult( + nickname = sender.nickname, + name = delivery.senderName, + phoneNumber = delivery.senderPhoneNumber, + address = formatAddress(delivery.senderZipCode, delivery.senderAddress, delivery.senderAddressDetail) + ), + recipientInfo = AdminGiftMemberInfoResult( + nickname = recipient.nickname, + name = delivery.recipientName.orEmpty(), + phoneNumber = delivery.recipientPhoneNumber.orEmpty(), + address = delivery.recipientAddressResult() + ), + productInfo = AdminGiftProductInfoResult( + sizeCode = gift.sizeCode.name, + sizeName = gift.sizeCode.displayName, + categoryName = gift.categoryNameSnapshot, + classificationNumber = category.classificationNumber + ), + inboundDeliveryInfo = AdminGiftInboundDeliveryInfoResult( + courierCompanyName = delivery.courierCompanyName, + trackingNumber = delivery.trackingNumber + ), + status = gift.status, + statusName = gift.status.displayName, + availableActions = gift.status.availableActions() + ) + } + + private fun Gift.toListItem(context: GiftAdminQueryContext) = AdminGiftListItemResult( + applicationNo = applicationNo, + senderNickname = context.sender(this).nickname, + recipientNickname = context.recipient(this).nickname, + sizeCode = sizeCode.name, + sizeName = sizeCode.displayName, + categoryName = categoryNameSnapshot, + classificationNumber = context.category(this).classificationNumber, + courierCompanyName = context.delivery(this).courierCompanyName, + trackingNumber = context.delivery(this).trackingNumber, + status = status, + statusName = status.displayName, + availableActions = status.availableActions() + ) + + private fun GiftStatus.availableActions() = when (this) { + GiftStatus.TRACKING_REGISTERED -> listOf("ARRIVE_MAILBOX", "MARK_UNDELIVERABLE") + GiftStatus.ARRIVED_AT_MAILBOX -> listOf("COMPLETE_INSPECTION", "MARK_UNDELIVERABLE") + GiftStatus.INSPECTION_COMPLETED -> listOf("COMPLETE_DELIVERY", "MARK_UNDELIVERABLE") + GiftStatus.RECEIVED, GiftStatus.DELIVERED, GiftStatus.UNDELIVERABLE, GiftStatus.CANCELED -> emptyList() + } + + private fun GiftDelivery.recipientAddressResult(): String { + val zipCode = recipientZipCode ?: return "" + val address = recipientAddress ?: return "" + return formatAddress(zipCode, address, recipientAddressDetail) + } + + private fun formatAddress(zipCode: String, address: String, detail: String?) = + "($zipCode) $address" + if (detail.isNullOrBlank()) "" else ", $detail" + + private fun invalidRequest() = SodaException(messageKey = "common.error.invalid_request") + + private fun buildContext(gifts: List): GiftAdminQueryContext { + val giftIds = gifts.mapNotNull { it.id } + val memberIds = gifts.flatMap { listOf(it.senderMemberId, it.recipientMemberId) }.toSet() + val categoryIds = gifts.map { it.categoryId }.toSet() + return GiftAdminQueryContext( + deliveriesByGiftId = deliveryRepository.findByGiftIdIn(giftIds).associateBy { it.giftId }, + membersById = memberRepository.findAllById(memberIds).associateBy { it.id ?: throw invalidRequest() }, + categoriesById = categoryRepository.findAllById(categoryIds).associateBy { it.id ?: throw invalidRequest() } + ) + } + + private inner class GiftAdminQueryContext( + private val deliveriesByGiftId: Map, + private val membersById: Map, + private val categoriesById: Map + ) { + fun delivery(gift: Gift) = deliveriesByGiftId[gift.id] ?: throw invalidRequest() + + fun sender(gift: Gift) = membersById[gift.senderMemberId] ?: throw invalidRequest() + + fun recipient(gift: Gift) = membersById[gift.recipientMemberId] ?: throw invalidRequest() + + fun category(gift: Gift) = categoriesById[gift.categoryId] ?: throw invalidRequest() + } + + private companion object { + const val DEFAULT_PAGE_SIZE = 20 + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminQueryServiceTest.kt new file mode 100644 index 00000000..a2c50924 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminQueryServiceTest.kt @@ -0,0 +1,228 @@ +package kr.co.vividnext.sodalive.v2.gift.application + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository +import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize +import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import java.time.LocalDateTime + +@DataJpaTest( + properties = [ + "spring.cache.type=none", + "spring.datasource.url=jdbc:h2:mem:gift-admin-query;MODE=MySQL;NON_KEYWORDS=VALUE" + ] +) +@Import(QueryDslConfig::class) +@DisplayName("관리자 선물함 조회 서비스 테스트") +class GiftAdminQueryServiceTest @Autowired constructor( + private val giftRepository: GiftRepository, + private val deliveryRepository: GiftDeliveryRepository, + private val categoryRepository: GiftCategoryRepository, + private val memberRepository: MemberRepository +) { + @Test + @DisplayName("관리자는 선물함 목록을 필터링하고 가능한 액션을 조회한다") + fun shouldListAdminGiftsWithFiltersAndAvailableActions() { + val sender = saveMember("fan-alpha") + val recipient = saveMember("creator-alpha") + val otherSender = saveMember("fan-beta") + val otherRecipient = saveMember("creator-beta") + val category = saveCategory() + saveGift( + applicationNo = "A-1002609300001", + sender = sender, + recipient = recipient, + category = category, + status = GiftStatus.TRACKING_REGISTERED, + withTracking = true + ) + saveGift( + applicationNo = "A-1002609300002", + sender = otherSender, + recipient = otherRecipient, + category = category, + status = GiftStatus.RECEIVED + ) + + val result = service().getGifts( + status = GiftStatus.TRACKING_REGISTERED, + applicationNo = "0001", + nickname = "alpha", + page = 0, + size = 20 + ) + + assertEquals(1L, result.totalCount) + assertEquals(1, result.items.size) + assertEquals(0, result.page) + assertEquals(20, result.size) + assertEquals(false, result.hasNext) + val item = result.items.single() + assertEquals("A-1002609300001", item.applicationNo) + assertEquals("fan-alpha", item.senderNickname) + assertEquals("creator-alpha", item.recipientNickname) + assertEquals("SMALL", item.sizeCode) + assertEquals("소형", item.sizeName) + assertEquals("인형", item.categoryName) + assertEquals("100", item.classificationNumber) + assertEquals("CJ대한통운", item.courierCompanyName) + assertEquals("1234567890", item.trackingNumber) + assertEquals(GiftStatus.TRACKING_REGISTERED, item.status) + assertEquals("발송 확인", item.statusName) + assertEquals(listOf("ARRIVE_MAILBOX", "MARK_UNDELIVERABLE"), item.availableActions) + } + + @Test + @DisplayName("관리자는 선물 상세에서 개인정보와 상품 정보를 조회하고 미입력 수취인 정보는 빈 문자열로 받는다") + fun shouldGetAdminGiftDetailWithPersonalInformation() { + val sender = saveMember("fan-alpha") + val recipient = saveMember("creator-alpha") + val category = saveCategory() + saveGift( + applicationNo = "A-1002609300001", + sender = sender, + recipient = recipient, + category = category, + status = GiftStatus.ARRIVED_AT_MAILBOX, + withTracking = true + ) + + val result = service().getGiftDetail("A-1002609300001") + + assertEquals("A-1002609300001", result.applicationNo) + assertEquals("fan-alpha", result.senderInfo.nickname) + assertEquals("홍길동", result.senderInfo.name) + assertEquals("01012345678", result.senderInfo.phoneNumber) + assertEquals("(06234) 서울시 강남구, 101동 1001호", result.senderInfo.address) + assertEquals("creator-alpha", result.recipientInfo.nickname) + assertEquals("", result.recipientInfo.name) + assertEquals("", result.recipientInfo.phoneNumber) + assertEquals("", result.recipientInfo.address) + assertEquals("SMALL", result.productInfo.sizeCode) + assertEquals("소형", result.productInfo.sizeName) + assertEquals("인형", result.productInfo.categoryName) + assertEquals("100", result.productInfo.classificationNumber) + assertEquals("CJ대한통운", result.inboundDeliveryInfo.courierCompanyName) + assertEquals("1234567890", result.inboundDeliveryInfo.trackingNumber) + assertEquals(GiftStatus.ARRIVED_AT_MAILBOX, result.status) + assertEquals("사서함 도착", result.statusName) + assertEquals(listOf("COMPLETE_INSPECTION", "MARK_UNDELIVERABLE"), result.availableActions) + } + + @Test + @DisplayName("관리자 선물 상세는 상태별 가능한 액션을 반환하고 없는 신청번호를 거부한다") + fun shouldReturnAvailableActionsByStatusAndRejectUnknownApplicationNo() { + val sender = saveMember("fan-alpha") + val recipient = saveMember("creator-alpha") + val category = saveCategory() + GiftStatus.values().forEachIndexed { index, status -> + saveGift("A-10026093010$index", sender, recipient, category, status) + } + + val service = service() + + assertEquals(emptyList(), service.getGiftDetail("A-100260930100").availableActions) + assertEquals( + listOf("ARRIVE_MAILBOX", "MARK_UNDELIVERABLE"), + service.getGiftDetail("A-100260930101").availableActions + ) + assertEquals( + listOf("COMPLETE_INSPECTION", "MARK_UNDELIVERABLE"), + service.getGiftDetail("A-100260930102").availableActions + ) + assertEquals( + listOf("COMPLETE_DELIVERY", "MARK_UNDELIVERABLE"), + service.getGiftDetail("A-100260930103").availableActions + ) + assertEquals(emptyList(), service.getGiftDetail("A-100260930104").availableActions) + assertEquals(emptyList(), service.getGiftDetail("A-100260930105").availableActions) + assertEquals(emptyList(), service.getGiftDetail("A-100260930106").availableActions) + val exception = assertThrows(SodaException::class.java) { + service.getGiftDetail("UNKNOWN") + } + assertEquals("common.error.invalid_request", exception.messageKey) + } + + private fun service() = GiftAdminQueryService( + giftRepository = giftRepository, + deliveryRepository = deliveryRepository, + categoryRepository = categoryRepository, + memberRepository = memberRepository + ) + + private fun saveMember(nickname: String) = memberRepository.save(Member(password = "password", nickname = nickname)) + + private fun saveCategory() = categoryRepository.save( + GiftCategory( + classificationNumber = "100", + categoryCode = "DOLL", + name = "인형", + receiptCode = "A", + representativeItem = "피규어" + ) + ) + + private fun saveGift( + applicationNo: String, + sender: Member, + recipient: Member, + category: GiftCategory, + status: GiftStatus, + withTracking: Boolean = false + ): Gift { + val gift = giftRepository.save( + Gift( + applicationNo = applicationNo, + senderMemberId = sender.id!!, + recipientMemberId = recipient.id!!, + status = status, + sizeCode = GiftSize.SMALL, + categoryId = category.id!!, + categoryNameSnapshot = category.name, + salePriceCan = 80, + canUsageId = 10L, + senderTermsAgreed = true, + senderPrivacyAgreed = true, + senderTermsAgreedAt = NOW, + senderPrivacyAgreedAt = NOW + ) + ) + deliveryRepository.save( + GiftDelivery( + giftId = gift.id!!, + senderName = "홍길동", + senderPhoneNumber = "01012345678", + senderZipCode = "06234", + senderAddress = "서울시 강남구", + senderAddressDetail = "101동 1001호", + trackingDeadlineAt = NOW.plusDays(3) + ).apply { + if (withTracking) { + courierCompanyName = "CJ대한통운" + trackingNumber = "1234567890" + trackingRegisteredAt = NOW.plusHours(1) + } + } + ) + return gift + } + + private companion object { + val NOW: LocalDateTime = LocalDateTime.of(2026, 9, 30, 12, 0) + } +} From 15ce9aab41b21f97459c6da2e178a224bc33ec60 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 30 Sep 2026 18:41:57 +0900 Subject: [PATCH 32/53] =?UTF-8?q?feat(gift):=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EC=84=A0=EB=AC=BC=ED=95=A8=20HTTP=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/in/web/AdminGiftController.kt | 24 ++- .../api/admin/gift/dto/AdminGiftResponse.kt | 124 +++++++++++++++ .../adapter/in/web/AdminGiftControllerTest.kt | 142 +++++++++++++++++- 3 files changed, 288 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftController.kt index 3de06d87..564b91d5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftController.kt @@ -3,12 +3,16 @@ package kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.`in`.web import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftCategoryRequest import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftCategoryResponse +import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftDetailResponse +import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftListResponse import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftMarkUndeliverableRequest import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftOperationStatusResponse import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftSizePriceRequest import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftSizePriceResponse +import kr.co.vividnext.sodalive.v2.gift.application.GiftAdminQueryService import kr.co.vividnext.sodalive.v2.gift.application.GiftAdminService import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize +import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus import org.springframework.security.access.prepost.PreAuthorize import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping @@ -17,12 +21,30 @@ import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/api/v2/admin") @PreAuthorize("hasRole('ADMIN')") -class AdminGiftController(private val service: GiftAdminService) { +class AdminGiftController( + private val service: GiftAdminService, + private val queryService: GiftAdminQueryService +) { + @GetMapping("/gifts") + fun getGifts( + @RequestParam(required = false) status: GiftStatus?, + @RequestParam(required = false) applicationNo: String?, + @RequestParam(required = false) nickname: String?, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "20") size: Int + ) = ApiResponse.ok(AdminGiftListResponse.from(queryService.getGifts(status, applicationNo, nickname, page, size))) + + @GetMapping("/gifts/{applicationNo}") + fun getGiftDetail(@PathVariable applicationNo: String) = ApiResponse.ok( + AdminGiftDetailResponse.from(queryService.getGiftDetail(applicationNo)) + ) + @GetMapping("/gift-categories") fun getCategories() = ApiResponse.ok(service.getCategories().map { AdminGiftCategoryResponse.from(it) }) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/dto/AdminGiftResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/dto/AdminGiftResponse.kt index 6754d5be..32c9a606 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/dto/AdminGiftResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/dto/AdminGiftResponse.kt @@ -1,6 +1,12 @@ package kr.co.vividnext.sodalive.v2.api.admin.gift.dto import kr.co.vividnext.sodalive.extensions.toUtcIso +import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftDetailResult +import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftInboundDeliveryInfoResult +import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftListItemResult +import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftListResult +import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftMemberInfoResult +import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftProductInfoResult import kr.co.vividnext.sodalive.v2.gift.application.GiftCategoryResult import kr.co.vividnext.sodalive.v2.gift.application.GiftOperationStatusResult import kr.co.vividnext.sodalive.v2.gift.application.GiftSizePriceResult @@ -63,3 +69,121 @@ data class AdminGiftOperationStatusResponse( ) } } + +data class AdminGiftListResponse( + val totalCount: Long, + val items: List, + val page: Int, + val size: Int, + val hasNext: Boolean +) { + companion object { + fun from(result: AdminGiftListResult) = AdminGiftListResponse( + totalCount = result.totalCount, + items = result.items.map { AdminGiftListItemResponse.from(it) }, + page = result.page, + size = result.size, + hasNext = result.hasNext + ) + } +} + +data class AdminGiftListItemResponse( + val applicationNo: String, + val senderNickname: String, + val recipientNickname: String, + val sizeCode: String, + val sizeName: String, + val categoryName: String, + val classificationNumber: String, + val courierCompanyName: String?, + val trackingNumber: String?, + val status: GiftStatus, + val statusName: String, + val availableActions: List +) { + companion object { + fun from(result: AdminGiftListItemResult) = AdminGiftListItemResponse( + applicationNo = result.applicationNo, + senderNickname = result.senderNickname, + recipientNickname = result.recipientNickname, + sizeCode = result.sizeCode, + sizeName = result.sizeName, + categoryName = result.categoryName, + classificationNumber = result.classificationNumber, + courierCompanyName = result.courierCompanyName, + trackingNumber = result.trackingNumber, + status = result.status, + statusName = result.statusName, + availableActions = result.availableActions + ) + } +} + +data class AdminGiftDetailResponse( + val applicationNo: String, + val senderInfo: AdminGiftMemberInfoResponse, + val recipientInfo: AdminGiftMemberInfoResponse, + val productInfo: AdminGiftProductInfoResponse, + val inboundDeliveryInfo: AdminGiftInboundDeliveryInfoResponse, + val status: GiftStatus, + val statusName: String, + val availableActions: List +) { + companion object { + fun from(result: AdminGiftDetailResult) = AdminGiftDetailResponse( + applicationNo = result.applicationNo, + senderInfo = AdminGiftMemberInfoResponse.from(result.senderInfo), + recipientInfo = AdminGiftMemberInfoResponse.from(result.recipientInfo), + productInfo = AdminGiftProductInfoResponse.from(result.productInfo), + inboundDeliveryInfo = AdminGiftInboundDeliveryInfoResponse.from(result.inboundDeliveryInfo), + status = result.status, + statusName = result.statusName, + availableActions = result.availableActions + ) + } +} + +data class AdminGiftMemberInfoResponse( + val nickname: String, + val name: String, + val phoneNumber: String, + val address: String +) { + companion object { + fun from(result: AdminGiftMemberInfoResult) = AdminGiftMemberInfoResponse( + nickname = result.nickname, + name = result.name, + phoneNumber = result.phoneNumber, + address = result.address + ) + } +} + +data class AdminGiftProductInfoResponse( + val sizeCode: String, + val sizeName: String, + val categoryName: String, + val classificationNumber: String +) { + companion object { + fun from(result: AdminGiftProductInfoResult) = AdminGiftProductInfoResponse( + sizeCode = result.sizeCode, + sizeName = result.sizeName, + categoryName = result.categoryName, + classificationNumber = result.classificationNumber + ) + } +} + +data class AdminGiftInboundDeliveryInfoResponse( + val courierCompanyName: String?, + val trackingNumber: String? +) { + companion object { + fun from(result: AdminGiftInboundDeliveryInfoResult) = AdminGiftInboundDeliveryInfoResponse( + courierCompanyName = result.courierCompanyName, + trackingNumber = result.trackingNumber + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt index 369ac8a2..db7dcb0e 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt @@ -1,7 +1,10 @@ package kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.`in`.web +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository @@ -46,12 +49,93 @@ class AdminGiftControllerTest @Autowired constructor( private val categoryRepository: GiftCategoryRepository, private val sizePriceRepository: GiftSizePriceRepository, private val giftRepository: GiftRepository, - private val giftDeliveryRepository: GiftDeliveryRepository + private val giftDeliveryRepository: GiftDeliveryRepository, + private val memberRepository: MemberRepository ) { @BeforeEach fun deleteGiftFixtures() { giftDeliveryRepository.deleteAll() giftRepository.deleteAll() + categoryRepository.deleteAll() + } + + @Test + @DisplayName("관리자는 전체 선물함 목록을 필터링해 조회한다") + fun shouldListAdminGifts() { + val (gift, _) = saveAdminGift( + applicationNo = "A-1002609300001", + status = GiftStatus.TRACKING_REGISTERED, + senderNickname = "admin-fan-alpha", + recipientNickname = "admin-creator-alpha", + withTracking = true + ) + + mockMvc.perform( + get("/api/v2/admin/gifts") + .with(user("admin").roles("ADMIN")) + .param("status", "TRACKING_REGISTERED") + .param("applicationNo", "0001") + .param("nickname", "alpha") + .param("page", "0") + .param("size", "20") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.totalCount").value(1)) + .andExpect(jsonPath("$.data.items[0].applicationNo").value(gift.applicationNo)) + .andExpect(jsonPath("$.data.items[0].senderNickname").value("admin-fan-alpha")) + .andExpect(jsonPath("$.data.items[0].recipientNickname").value("admin-creator-alpha")) + .andExpect(jsonPath("$.data.items[0].sizeCode").value("SMALL")) + .andExpect(jsonPath("$.data.items[0].sizeName").value("소형")) + .andExpect(jsonPath("$.data.items[0].categoryName").value("인형")) + .andExpect(jsonPath("$.data.items[0].classificationNumber").value("100")) + .andExpect(jsonPath("$.data.items[0].courierCompanyName").value("CJ대한통운")) + .andExpect(jsonPath("$.data.items[0].trackingNumber").value("1234567890")) + .andExpect(jsonPath("$.data.items[0].status").value("TRACKING_REGISTERED")) + .andExpect(jsonPath("$.data.items[0].statusName").value("발송 확인")) + .andExpect(jsonPath("$.data.items[0].availableActions[0]").value("ARRIVE_MAILBOX")) + .andExpect(jsonPath("$.data.items[0].availableActions[1]").value("MARK_UNDELIVERABLE")) + .andExpect(jsonPath("$.data.page").value(0)) + .andExpect(jsonPath("$.data.size").value(20)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + } + + @Test + @DisplayName("관리자는 선물 상세를 조회하고 미입력 수취인 정보는 빈 문자열로 받는다") + fun shouldGetAdminGiftDetail() { + saveAdminGift( + applicationNo = "A-1002609300002", + status = GiftStatus.ARRIVED_AT_MAILBOX, + senderNickname = "admin-fan-beta", + recipientNickname = "admin-creator-beta", + withTracking = true + ) + + mockMvc.perform( + get("/api/v2/admin/gifts/A-1002609300002") + .with(user("admin").roles("ADMIN")) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.applicationNo").value("A-1002609300002")) + .andExpect(jsonPath("$.data.senderInfo.nickname").value("admin-fan-beta")) + .andExpect(jsonPath("$.data.senderInfo.name").value("홍길동")) + .andExpect(jsonPath("$.data.senderInfo.phoneNumber").value("01012345678")) + .andExpect(jsonPath("$.data.senderInfo.address").value("(06234) 서울시 강남구, 101동 1001호")) + .andExpect(jsonPath("$.data.recipientInfo.nickname").value("admin-creator-beta")) + .andExpect(jsonPath("$.data.recipientInfo.name").value("")) + .andExpect(jsonPath("$.data.recipientInfo.phoneNumber").value("")) + .andExpect(jsonPath("$.data.recipientInfo.address").value("")) + .andExpect(jsonPath("$.data.productInfo.sizeCode").value("SMALL")) + .andExpect(jsonPath("$.data.productInfo.sizeName").value("소형")) + .andExpect(jsonPath("$.data.productInfo.categoryName").value("인형")) + .andExpect(jsonPath("$.data.productInfo.classificationNumber").value("100")) + .andExpect(jsonPath("$.data.inboundDeliveryInfo.courierCompanyName").value("CJ대한통운")) + .andExpect(jsonPath("$.data.inboundDeliveryInfo.trackingNumber").value("1234567890")) + .andExpect(jsonPath("$.data.status").value("ARRIVED_AT_MAILBOX")) + .andExpect(jsonPath("$.data.statusName").value("사서함 도착")) + .andExpect(jsonPath("$.data.availableActions[0]").value("COMPLETE_INSPECTION")) + .andExpect(jsonPath("$.data.availableActions[1]").value("MARK_UNDELIVERABLE")) } @Test @@ -242,6 +326,62 @@ class AdminGiftControllerTest @Autowired constructor( ) } + private fun saveAdminGift( + applicationNo: String, + status: GiftStatus, + senderNickname: String, + recipientNickname: String, + withTracking: Boolean + ): Pair { + val sender = memberRepository.save(Member(password = "password", nickname = senderNickname)) + val recipient = memberRepository.save(Member(password = "password", nickname = recipientNickname)) + val category = categoryRepository.save( + GiftCategory( + classificationNumber = "100", + categoryCode = "ADMIN_${applicationNo.takeLast(4)}", + name = "인형", + receiptCode = "A", + representativeItem = "피규어" + ) + ) + val gift = giftRepository.save( + Gift( + applicationNo = applicationNo, + senderMemberId = sender.id!!, + recipientMemberId = recipient.id!!, + status = status, + sizeCode = GiftSize.SMALL, + categoryId = category.id!!, + categoryNameSnapshot = category.name, + salePriceCan = 80, + canUsageId = 10L, + senderTermsAgreed = true, + senderPrivacyAgreed = true, + senderTermsAgreedAt = NOW.minusDays(1), + senderPrivacyAgreedAt = NOW.minusDays(1), + damageWaiverAgreed = true + ) + ) + val delivery = giftDeliveryRepository.save( + GiftDelivery( + giftId = requireNotNull(gift.id), + senderName = "홍길동", + senderPhoneNumber = "01012345678", + senderZipCode = "06234", + senderAddress = "서울시 강남구", + senderAddressDetail = "101동 1001호", + trackingDeadlineAt = NOW.minusDays(1) + ).apply { + if (withTracking) { + courierCompanyName = "CJ대한통운" + trackingNumber = "1234567890" + trackingRegisteredAt = NOW.minusHours(1) + } + } + ) + return gift to delivery + } + private companion object { const val APPLICATION_NO = "G20260929000001" const val SENDER_MEMBER_ID = 1L From a95c91d31fd12a10cce8444780274031e465217d Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 30 Sep 2026 19:08:15 +0900 Subject: [PATCH 33/53] =?UTF-8?q?feat(menu):=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EC=84=A0=EB=AC=BC=ED=95=A8=20=EB=A9=94=EB=89=B4=EB=A5=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../co/vividnext/sodalive/menu/MenuService.kt | 17 +++++- .../sodalive/menu/MenuServiceTest.kt | 57 +++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/menu/MenuServiceTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuService.kt index 957b7bb8..77455f1f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuService.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.menu import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole import org.springframework.stereotype.Service @Service @@ -8,6 +9,20 @@ class MenuService( private val repository: MenuRepository ) { fun getMenus(member: Member): List { - return repository.getMenu(member.role) + val menus = repository.getMenu(member.role) + if (member.role != MemberRole.ADMIN) return menus + + return menus + giftManagementMenu + } + + private companion object { + val giftManagementMenu = GetMenuResponse( + title = "선물함 관리", + items = listOf( + GetMenuResponse(title = "선물함 리스트", route = "/gift/list"), + GetMenuResponse(title = "선물 카테고리", route = "/gift/category"), + GetMenuResponse(title = "선물 사이즈", route = "/gift/size") + ) + ) } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/menu/MenuServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/menu/MenuServiceTest.kt new file mode 100644 index 00000000..8f143b43 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/menu/MenuServiceTest.kt @@ -0,0 +1,57 @@ +package kr.co.vividnext.sodalive.menu + +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito + +class MenuServiceTest { + private val repository = Mockito.mock(MenuRepository::class.java) + private val service = MenuService(repository) + + @Test + @DisplayName("관리자 메뉴 마지막에 선물함 관리 메뉴를 추가한다") + fun shouldAppendGiftMenuForAdmin() { + Mockito.`when`(repository.getMenu(MemberRole.ADMIN)).thenReturn( + listOf(GetMenuResponse(title = "회원 관리", route = "/member/list")) + ) + + val menus = service.getMenus(member(MemberRole.ADMIN)) + + assertEquals("회원 관리", menus[0].title) + val giftMenu = menus.last() + assertEquals("선물함 관리", giftMenu.title) + assertNull(giftMenu.route) + assertEquals( + listOf("선물함 리스트", "선물 카테고리", "선물 사이즈"), + giftMenu.items?.map { it.title } + ) + assertEquals( + listOf("/gift/list", "/gift/category", "/gift/size"), + giftMenu.items?.map { it.route } + ) + } + + @Test + @DisplayName("관리자가 아니면 선물함 관리 메뉴를 추가하지 않는다") + fun shouldNotAppendGiftMenuForNonAdmin() { + Mockito.`when`(repository.getMenu(MemberRole.CONTENT_MANAGER)).thenReturn( + listOf(GetMenuResponse(title = "콘텐츠 리스트", route = "/content/list")) + ) + + val menus = service.getMenus(member(MemberRole.CONTENT_MANAGER)) + + assertEquals(1, menus.size) + assertEquals("콘텐츠 리스트", menus[0].title) + } + + private fun member(role: MemberRole) = Member( + email = "${role.name.lowercase()}@test.com", + password = "password", + nickname = role.name.lowercase(), + role = role + ) +} From 72e5612b1cff679e2dee79c41a6402077a9cb43a Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 1 Oct 2026 19:02:08 +0900 Subject: [PATCH 34/53] =?UTF-8?q?docs(gift):=20=EB=AA=A8=EB=B0=94=EC=9D=BC?= =?UTF-8?q?=20=EC=84=A0=EB=AC=BC=ED=95=A8=20=EA=B5=AC=ED=98=84=20=ED=94=84?= =?UTF-8?q?=EB=A1=AC=ED=94=84=ED=8A=B8=EB=A5=BC=20=EA=B8=B0=EB=A1=9D?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gift-list-page-prompt.md | 274 ++++++++++++++++++ .../gift-send-page-prompt.md | 263 +++++++++++++++++ 2 files changed, 537 insertions(+) create mode 100644 docs/20260929_크리에이터_선물하기/gift-list-page-prompt.md create mode 100644 docs/20260929_크리에이터_선물하기/gift-send-page-prompt.md diff --git a/docs/20260929_크리에이터_선물하기/gift-list-page-prompt.md b/docs/20260929_크리에이터_선물하기/gift-list-page-prompt.md new file mode 100644 index 00000000..f0c70456 --- /dev/null +++ b/docs/20260929_크리에이터_선물하기/gift-list-page-prompt.md @@ -0,0 +1,274 @@ +# 선물함 리스트 페이지 생성 프롬프트 + +이 문서는 Android/iOS 앱의 `선물함` 리스트 페이지를 생성할 때 사용하는 프롬프트다. + +참고 Figma: + +- 팬 모드, 신청한 내역이 있을 때: `2481:19382` +- 팬 모드, 신청한 내역이 없을 때: `2520:35587` +- 크리에이터 모드, 받은 선물 내역이 없을 때: `2531:36283` +- 크리에이터 모드, 받은 선물 내역이 있을 때: `2531:36194` +- 크리에이터 모드, 받은 선물 상태별 카드 표시: `2531:36237` + +전제: + +- Android/iOS 각각의 기존 디자인 시스템, 컴포넌트, API client, 상태 관리, toast/dialog, navigation 패턴을 재사용한다. +- Figma의 visual structure를 기준으로 구현하되, 아래 사용자 요구사항을 Figma보다 우선한다. +- 상단 뒤로가기 내비게이션 영역은 고정한다. +- 상단 뒤로가기 내비게이션 영역을 제외한 전체 콘텐츠 영역은 스크롤되어야 한다. +- `신청 내역`, `선물 내역` 섹션 타이틀은 표시하지 않는다. +- 리스트는 신청일/취소일 최신순으로 보여야 한다. +- request/response 설명은 모바일 공통 JSON 기준이다. + +--- + +## 모바일 앱 구현 프롬프트 + +```text +Android/iOS 앱의 선물함 리스트 페이지를 Figma 기준으로 생성하고 API 연동을 구현해줘. + +Figma는 두 관점의 상태를 보여준다. +- 보낸 선물 관점: 사용자가 본인이 신청한 선물 내역을 확인하는 상태다. Figma `2481:19382`, `2520:35587`를 기준으로 한다. +- 받은 선물 관점: 사용자가 본인이 받을 선물 내역을 확인하는 상태다. Figma `2531:36283`, `2531:36194`, `2531:36237`를 기준으로 한다. +- 실제 선물함 API는 두 관점을 나누어 호출하지 않고 `type=ALL`로 한 번 호출한다. +- 응답 item의 `direction` 값이 `SENT`이면 보낸 선물 카드로, `RECEIVED`이면 받은 선물 카드로 렌더링한다. +- 각 카드에는 상대방 프로필 이미지와 닉네임을 표시한다. + - `direction=SENT`: 상대방은 선물을 받는 크리에이터다. + - `direction=RECEIVED`: 상대방은 선물을 보낸 팬이다. + +중요 요구사항: +- 상단 뒤로가기 내비게이션은 고정한다. +- 상단 내비게이션 아래의 전체 콘텐츠만 스크롤되게 한다. +- Figma에 보이는 `신청 내역`, `선물 내역` 섹션 타이틀은 실제 구현에서 표시하지 않는다. +- 선물함 리스트는 신청일/취소일 최신순으로 정렬한다. +- 현재 API 응답만으로 취소일 기준 정렬/표시는 완전하지 않으므로, API가 취소일을 내려주지 않으면 서버 응답 순서를 우선 사용하고 취소 건 날짜 라벨은 `createdAt` 기준으로 표시한다. 취소일 표시가 필수이면 API 보강이 필요하다. + +API 호출: +- 선물함 리스트는 항상 `GET /api/v2/gifts?type=ALL&page=0&size=20`로 조회한다. +- `SENT`, `RECEIVED`로 따로 호출하지 않는다. +- `items[].direction`으로 보낸 선물/받은 선물 UI를 결정한다. +- 페이지 진입 시 첫 페이지를 조회한다. +- `hasNext=true`이면 스크롤 하단에서 다음 page를 추가 조회한다. +- pull-to-refresh가 기존 앱에 있으면 page를 0으로 초기화하고 다시 조회한다. + +공통 레이아웃: +1. 상단 고정 내비게이션 + - 타이틀: `선물함` + - 뒤로가기 버튼은 기존 앱 패턴을 사용한다. + - 이 영역은 스크롤되지 않는다. + +2. 스크롤 콘텐츠 + - nav 아래부터 시작한다. + - 안내 배너, 빈 상태, 리스트, 하단 여백 또는 하단 문의 버튼까지 스크롤 영역에 포함한다. + - iOS safe area와 Android navigation bar 여백은 기존 앱 패턴을 따른다. + +보낸 선물 UI: +- `direction=SENT`인 item에 적용한다. +- 사용자가 신청한 선물 내역을 보여준다. +- 보낸 선물만 있는 상태는 Figma `2481:19382`를 따른다. +- 상단 안내 배너를 표시한다. + - 제목: `사서함을 통해 안전하게 전달됩니다` + - 본문: `크리에이터 및 팬의 주소는 공개되지 않습니다. 선물은 소다라이브 사서함에 도착한 뒤 크리에이터에게 전달됩니다.` +- `신청 내역` 섹션 타이틀은 표시하지 않는다. +- 카드 구성: + - 상단 왼쪽: `statusName` + - 상단 오른쪽: 날짜 라벨 + - 기본: `{createdAt} 신청` + - 상태가 `CANCELED`이고 취소일 필드가 있으면 `{canceledAt} 취소` + - 현재 리스트 API에는 `canceledAt`이 없으므로 기존 API만 사용할 때는 `{createdAt} 취소` 또는 `{createdAt} 신청` 중 제품 정책에 맞춰 하나로 통일한다. + - 프로필 row 내부: `counterpartProfileImageUrl`, `counterpartNickname`을 사용해 받는 크리에이터 프로필을 표시한다. + - 프로필 row 아래 보조 문구: `sizeName · categoryName` + - 프로필 이미지가 없으면 기존 앱의 기본 프로필 이미지를 표시한다. + - 우측 chevron + - 탭 시 선물 상세 페이지로 이동하며 `applicationNo`를 전달한다. +- 리스트가 비어 있고 보낸 선물 관점으로 진입한 화면이면 Figma `2520:35587`를 따른다. + - 상단 안내 배너만 표시한다. + - 별도 empty title/body를 추가하지 않는다. + +받은 선물 UI: +- `direction=RECEIVED`인 item에 적용한다. +- 사용자가 받을 선물 내역을 보여준다. +- 리스트가 비어 있고 받은 선물 관점으로 진입한 화면이면 Figma `2531:36283`를 따른다. + - 가운데 empty title: `전달 예정인 선물이 없어요` + - empty body: `베타 기간에는 팬이 선물을 보내는 기능만 제공돼요. 더 다양해진 선물 기능으로 곧 다시 만나요!` + - 버튼: `의견 남기기` + - 의견 남기기 동작은 기존 앱의 문의/피드백 이동 패턴을 사용한다. +- 받은 선물이 있는 상태는 Figma `2531:36194`, `2531:36237`를 따른다. +- 상단 안내 배너를 표시한다. + - 제목: `배송지 입력 기간 안내` + - 본문: `알림을 받은 날부터 7일 이내에 배송지를 입력해 주세요. 기한 내 입력하지 않으면 선물이 반송됩니다.` +- `선물 내역` 섹션 타이틀은 표시하지 않는다. +- 배송지 입력이 필요한 선물이 있으면 파란 안내 배너를 리스트 상단에 표시한다. + - 제목: `팬이 보낸 선물이 있어요!` + - 본문: `선물이 늦지 않게 전달될 수 있도록 배송지를 입력해 주세요.` + - 보조 문구: `*n일 이내로 입력하지 않으면 선물이 사라져요.` + - 현재 리스트 API에는 배송지 입력 마감일이 없으므로 정확한 n일 계산이 필요하면 API 보강이 필요하다. + - 배너 탭 시 배송지 입력이 필요한 첫 번째 선물 상세로 이동한다. +- 카드 구성: + - 상단 왼쪽 프로필 row 내부: `counterpartProfileImageUrl`, `counterpartNickname`을 사용해 보낸 팬 프로필을 표시한다. + - 상단 오른쪽: `{createdAt} 신청` + - 본문 메인: 상태 표시 문구 + - 본문 서브: 상태 보조 문구가 필요한 경우만 표시 + - 우측 chevron + - 탭 시 선물 상세 페이지로 이동하며 `applicationNo`를 전달한다. +- 리스트 하단에는 Figma처럼 `문의하기` 하단 버튼을 둘 수 있다. 기존 앱 정책상 고정 버튼이면 하단 safe area를 포함하고, 스크롤 콘텐츠 내부 버튼이면 콘텐츠 하단에 둔다. + +상태 표시 매핑: + +보낸 선물 카드: +| API status | 표시 문구 | +|---|---| +| `RECEIVED` | `운송장 등록 필요` | +| `TRACKING_REGISTERED` | `발송 확인` | +| `ARRIVED_AT_MAILBOX` | `사서함 도착` | +| `INSPECTION_COMPLETED` | `선물 검수 완료` | +| `DELIVERED` | `전달 완료` | +| `UNDELIVERABLE` | `전달 불가` | +| `CANCELED` | `신청 취소` | + +받은 선물 카드: +| API status | 메인 문구 | 서브 문구 | +|---|---|---| +| `TRACKING_REGISTERED` | `배송지 입력 필요` | `전달 예정` | +| `ARRIVED_AT_MAILBOX` | `선물 검수` | `선물 확인 중` | +| `INSPECTION_COMPLETED` | `선물 검수 완료` | `배송 중` | +| `DELIVERED` | `전달 완료` | 없음 | +| `UNDELIVERABLE` | `전달 불가` | 없음 | + +API 명세: + +선물함 리스트 조회 API: +- Method: `GET` +- URL: `/api/v2/gifts` +- 인증: 로그인 필요 +- Query: + - `type`: `ALL`, `SENT`, `RECEIVED` + - `page`: 0부터 시작 + - `size`: 페이지 크기. 기본 20 +- 이 페이지에서는 `type=ALL`만 사용한다. +- Response envelope 예시: +```json +{ + "success": true, + "data": { + "totalCount": 1, + "items": [], + "page": 0, + "size": 20, + "hasNext": false + }, + "message": "" +} +``` + +Response `data` 예시: +```json +{ + "totalCount": 2, + "items": [ + { + "applicationNo": "A-1002609300001", + "direction": "SENT", + "status": "RECEIVED", + "statusName": "접수 완료", + "priceCan": 80, + "categoryName": "아크릴/스탠드", + "sizeName": "소형", + "counterpartMemberId": 200, + "counterpartNickname": "달빛수집가", + "counterpartProfileImageUrl": "https://example.com/profile.png", + "createdAt": "2026-09-18T03:00:00Z" + } + ], + "page": 0, + "size": 20, + "hasNext": false +} +``` + +Response `data` 필드: + +| 필드 | 타입 | 설명 | +|---|---|---| +| `totalCount` | number | 전체 개수 | +| `items` | array | 선물함 리스트 | +| `items[].applicationNo` | string | 선물 신청번호. 상세 이동 ID | +| `items[].direction` | string | `SENT` 또는 `RECEIVED` | +| `items[].status` | string | 선물 상태 코드 | +| `items[].statusName` | string | 서버 상태 표시명 | +| `items[].priceCan` | number | 사용된 캔 | +| `items[].categoryName` | string | 카테고리명 | +| `items[].sizeName` | string | 사이즈명 | +| `items[].counterpartMemberId` | number | 상대방 회원 ID. `SENT`에서는 받는 크리에이터, `RECEIVED`에서는 보낸 팬 | +| `items[].counterpartNickname` | string | 상대방 닉네임 | +| `items[].counterpartProfileImageUrl` | string 또는 null | 상대방 프로필 이미지 URL. 없으면 기본 프로필 이미지 표시 | +| `items[].createdAt` | string 또는 null | 신청일. UTC ISO 문자열 | +| `page` | number | 현재 페이지 | +| `size` | number | 페이지 크기 | +| `hasNext` | boolean | 다음 페이지 존재 여부 | + +정렬: +- 현재 서버 API는 `createdAt desc, id desc`로 정렬한다. +- 요구사항은 신청일/취소일 최신순이다. +- 취소일 최신순까지 정확히 맞추려면 리스트 API가 `canceledAt` 또는 `displayedAt` 같은 정렬 기준 시각을 내려주고 그 기준으로 정렬해야 한다. +- API 보강 전에는 서버 응답 순서를 그대로 사용하고, 클라이언트에서 임의 재정렬하지 않는다. + +상대방 프로필 표시 규칙: +- `counterpartNickname`은 카드의 프로필 row 이름으로 표시한다. +- `counterpartProfileImageUrl`이 있으면 해당 이미지를 원형 프로필 이미지로 표시한다. +- `counterpartProfileImageUrl`이 `null`이거나 빈 값이면 기존 앱의 기본 프로필 이미지를 표시한다. +- `direction=SENT`에서는 받는 크리에이터 정보로 해석한다. +- `direction=RECEIVED`에서는 보낸 팬 정보로 해석한다. + +현재 API만으로 부족한 Figma 표시값: +- 취소일 `canceledAt` +- 배송지 입력 마감일 또는 남은 일수 + +추가 API 보강이 가능하면 리스트 item에 아래 필드를 추가해달라고 요청한다. +```json +{ + "canceledAt": "2026-09-18T03:00:00Z", + "recipientAddressDeadlineAt": "2026-09-25T03:00:00Z", + "sortAt": "2026-09-18T03:00:00Z" +} +``` +- `canceledAt`: 취소 상태의 취소일 표시용 +- `recipientAddressDeadlineAt`: 크리에이터 배송지 입력 안내 배너의 남은 일수 계산용 +- `sortAt`: 신청/취소 최신순 정렬 기준. 취소 건은 취소일, 그 외에는 신청일 + +로딩/에러/빈 상태: +- 최초 로딩 중에는 기존 앱 리스트 스켈레톤 또는 loading 패턴을 사용한다. +- 실패 시 기존 앱 toast/dialog와 재시도 버튼을 사용한다. +- 빈 상태는 진입 관점에 따라 다르게 표시한다. +- 보낸 선물 관점으로 진입한 화면이면 안내 배너만 남긴다. +- 받은 선물 관점으로 진입한 화면이면 중앙 empty UI와 `의견 남기기` 버튼을 표시한다. + +테스트/검증: +- 선물함 진입 시 `type=ALL`로 API를 한 번 호출하는지 확인한다. +- `direction=SENT` item은 보낸 선물 카드 UI로 표시되는지 확인한다. +- `direction=RECEIVED` item은 받은 선물 카드 UI로 표시되는지 확인한다. +- 각 item의 `counterpartNickname`, `counterpartProfileImageUrl`이 프로필 row에 표시되는지 확인한다. +- `counterpartProfileImageUrl=null`이면 기본 프로필 이미지가 표시되는지 확인한다. +- 상단 내비게이션은 고정되고 아래 콘텐츠만 스크롤되는지 확인한다. +- `신청 내역`, `선물 내역` 텍스트가 실제 화면에 노출되지 않는지 확인한다. +- 리스트 item 탭 시 `applicationNo`로 상세 화면 이동이 되는지 확인한다. +- 빈 상태가 진입 관점에 맞는 Figma 기준으로 표시되는지 확인한다. +- `hasNext=true`일 때 다음 page를 추가 조회하는지 확인한다. +- API 실패 시 입력/스크롤 상태를 깨지 않고 오류와 재시도를 제공하는지 확인한다. +``` + +--- + +## API 요약 + +| 목적 | Method | URL | Query | Response data | +|---|---|---|---|---| +| 선물함 리스트 조회 | GET | `/api/v2/gifts` | `type=ALL`, `page`, `size` | 선물 리스트, 페이지 정보 | + +## Figma 반영 메모 + +- 보낸 선물 리스트 있음: 안내 배너 + 카드 리스트. 단, 실제 구현에서는 `신청 내역` 타이틀을 제거한다. +- 보낸 선물 관점 empty: 안내 배너만 표시한다. +- 받은 선물 관점 empty: 중앙 empty UI와 `의견 남기기` 버튼을 표시한다. +- 받은 선물 리스트 있음: 안내 배너 + 배송지 입력 CTA 배너 + 카드 리스트. 단, 실제 구현에서는 `선물 내역` 타이틀을 제거한다. +- 받은 선물 상태별 카드는 `배송지 입력 완료/전달 예정`, `전달 완료`, `전달 불가`, `선물 검수/선물 확인 중`, `선물 검수 완료/배송 중` 패턴을 사용한다. diff --git a/docs/20260929_크리에이터_선물하기/gift-send-page-prompt.md b/docs/20260929_크리에이터_선물하기/gift-send-page-prompt.md new file mode 100644 index 00000000..65391ab6 --- /dev/null +++ b/docs/20260929_크리에이터_선물하기/gift-send-page-prompt.md @@ -0,0 +1,263 @@ +# 선물 보내기 페이지 생성 프롬프트 + +이 문서는 Android/iOS 앱의 `선물 보내기` 페이지를 생성하거나 기존 생성 UI에 API 연동을 붙일 때 사용하는 프롬프트다. + +참고 Figma: + +- 빈 입력 상태: `2481:18904` +- 입력 완료 및 CTA 활성화 상태: `2481:18957` + +전제: + +- 크리에이터 닉네임과 `memberId`를 받아 `선물 보내기` 화면으로 이동하는 흐름은 이미 생성되어 있다. +- 새 진입 흐름을 만들지 않는다. +- Figma에 있는 UI 구조와 문구를 유지한다. +- Android/iOS 각각의 기존 디자인 시스템, 컴포넌트, API client, 상태 관리, toast/dialog, navigation 패턴을 재사용한다. +- 모든 API 응답은 공통 envelope를 사용하며 실제 payload는 `data`에 있다. +- request/response 설명은 모바일 공통 JSON 기준이다. + +--- + +## 모바일 앱 구현 프롬프트 + +```text +Android/iOS 앱의 선물 보내기 페이지를 Figma 기준으로 생성하고 API 연동을 구현해줘. + +중요 전제: +- 크리에이터 닉네임과 memberId를 받아 이 화면으로 이동하는 기능은 이미 구현되어 있다. +- 이 작업에서는 이동 경로를 새로 만들지 말고, 전달받은 값만 화면과 API request에 연결한다. +- 전달받은 크리에이터 닉네임은 `받는 크리에이터` 영역에 표시한다. +- 전달받은 memberId는 선물 신청 API의 `recipientMemberId`로 보낸다. + +Figma 참고 상태: +- 빈 입력 상태: node `2481:18904` +- 입력 완료 및 최하단 CTA 활성화 상태: node `2481:18957` + +화면 구조: + +1. 상단 + - 타이틀: `선물 보내기` + - 뒤로가기 버튼은 기존 앱 패턴을 사용한다. + +2. 안내 배너 + - 제목: `선물 보내기 베타 서비스 안내` + - 본문: `베타 기간 동안은 일부 기능만 제공됩니다. 크리에이터에게 마음을 잘 전할 수 있도록 더 넓어진 선물 보내기로 곧 다시 만나요!` + +3. 받는 크리에이터 + - 이전 화면에서 전달받은 크리에이터 닉네임을 표시한다. + - Figma의 프로필 row 형태를 유지한다. + - memberId는 화면 표시용이 아니라 API request용으로만 사용한다. + +4. 선물 사이즈 + - 화면 진입 시 `GET /api/v2/gifts/form-options`를 호출해 `sizes`를 가져온다. + - 조회된 `sizes`를 사이즈 카드 목록에 바인딩한다. + - 카드에는 `name`, 사이즈 설명, 캔 가격을 표시한다. + - 서버 응답에는 사이즈 설명 필드가 없으므로 설명 문구는 앱에서 코드별로 매핑한다. + - `SMALL`: `세 변의 합 100cm 이하 · 5kg 이하` + - `MEDIUM`, `LARGE`: 정책 문구가 앱에 이미 있으면 기존 문구를 사용하고, 없으면 이름과 가격만 표시한다. + - 가격은 `salePriceCan`을 표시하고 submit 금액도 `salePriceCan`을 사용한다. + - `basePriceCan`과 `salePriceCan`이 다르면 기존 앱 할인/정가 표시 패턴이 있을 때만 정가를 함께 보여준다. + - 기본 선택은 서버가 내려준 첫 번째 사이즈로 둔다. + +5. 카테고리 + - 화면 진입 시 같은 `GET /api/v2/gifts/form-options` 응답의 `categories`를 사용한다. + - Figma의 select field를 유지한다. + - 빈 상태 문구: `카테고리 선택` + - 선택 후에는 선택한 카테고리 `name`을 표시한다. + - 카테고리 선택 UI는 기존 Android/iOS 앱 패턴에 맞는 bottom sheet, picker, dialog 중 이미 쓰는 방식을 사용한다. + - `requiresDamageWaiver=true`인 카테고리를 선택한 경우에만 카테고리 아래에 파손 면책 동의 row를 표시한다. + - 파손 면책 동의 문구: `파손 및 분실 면책 사항에 동의합니다. (필수)` + - `requiresDamageWaiver=false` 카테고리를 선택하면 파손 면책 동의 row를 숨기고 `damageWaiverAgreed=false`로 초기화한다. + +6. 보내는 사람 + - 이름 input → `senderName` + - 휴대폰 번호 input → `senderPhoneNumber` + - 우편번호 input → `senderZipCode` + - 주소 input → `senderAddress` + - 상세 주소 input → `senderAddressDetail` + - `우편번호 검색` 버튼은 기존 주소 검색 기능과 연결한다. + - 주소 검색 결과로 우편번호와 기본 주소를 채운다. + - 기본 주소는 사용자가 직접 수정하지 못하게 하는 기존 패턴이 있으면 그 패턴을 따른다. + +7. 이용 약관 동의 + - Figma의 발송 규정 요약 박스와 체크박스 2개를 유지한다. + - `소다라이브 크리에이터 상품 전달 이용약관에 동의합니다. (필수)` → `senderTermsAgreed` + - `상품 전달을 위한 개인정보 수집·이용에 동의합니다. (필수)` → `senderPrivacyAgreed` + +8. 하단 CTA + - 빈 상태 또는 필수값 누락 상태는 Figma 빈 상태처럼 비활성 버튼을 표시한다. + - 활성 조건을 모두 만족하면 Figma 활성 상태처럼 soda 색상 CTA로 바꾼다. + - 활성 CTA 문구는 선택된 사이즈의 `salePriceCan`을 사용해 `{salePriceCan}캔으로 선물 보내기`로 표시한다. + - submit 중에는 중복 탭을 막고 loading 상태를 표시한다. + +화면 진입 시 처리: +- 이미 전달받은 `creatorNickname`과 `memberId`를 읽는다. +- 둘 중 하나라도 없으면 기존 앱의 오류 처리 또는 뒤로가기 패턴을 따른다. +- 즉시 `GET /api/v2/gifts/form-options`를 호출한다. +- 로딩 중에는 기존 화면 스켈레톤/로딩 패턴을 사용한다. +- 조회 실패 시 toast/dialog와 재시도 동작을 기존 패턴으로 제공한다. +- `sizes` 또는 `categories`가 비어 있으면 선물 신청을 막고 CTA를 비활성화한다. + +CTA 활성 조건: +- `memberId`가 있다. +- 사이즈가 선택되어 있다. +- 카테고리가 선택되어 있다. +- 이름이 입력되어 있다. +- 휴대폰 번호가 입력되어 있다. +- 우편번호가 입력되어 있다. +- 주소가 입력되어 있다. +- 이용약관 동의가 true다. +- 개인정보 수집·이용 동의가 true다. +- 선택한 카테고리의 `requiresDamageWaiver=true`이면 파손 면책 동의가 true다. + +검증 실패 처리: +- CTA는 기본적으로 위 조건을 만족할 때만 활성화한다. +- 그래도 submit 시점에 한 번 더 validation 한다. +- 누락된 필드는 기존 앱의 input error, toast, dialog 중 현재 화면 패턴에 맞춰 안내한다. +- validation 실패 시 `POST /api/v2/gifts`를 호출하지 않는다. + +폼 옵션 조회 API: +- Method: `GET` +- URL: `/api/v2/gifts/form-options` +- Request: 없음 +- Response envelope 예시: +```json +{ + "success": true, + "data": { + "sizes": [], + "categories": [] + }, + "message": "" +} +``` +- Response `data` 예시: +```json +{ + "sizes": [ + { + "sizeCode": "SMALL", + "name": "소형", + "basePriceCan": 100, + "salePriceCan": 80 + } + ], + "categories": [ + { + "categoryId": 1, + "name": "아크릴", + "requiresDamageWaiver": true + } + ] +} +``` + +폼 옵션 필드: + +| 필드 | 타입 | 설명 | +|---|---|---| +| `sizes` | array | 선택 가능한 선물 사이즈 목록 | +| `sizes[].sizeCode` | string | 사이즈 코드. `SMALL`, `MEDIUM`, `LARGE` | +| `sizes[].name` | string | 사이즈 표시명 | +| `sizes[].basePriceCan` | number | 기본 가격, 단위는 캔 | +| `sizes[].salePriceCan` | number | 실제 결제 가격, 단위는 캔 | +| `categories` | array | 선택 가능한 활성 카테고리 목록 | +| `categories[].categoryId` | number | 카테고리 ID | +| `categories[].name` | string | 카테고리 표시명 | +| `categories[].requiresDamageWaiver` | boolean | 파손 면책 동의 필요 여부 | + +선물 신청 접수 API: +- Method: `POST` +- URL: `/api/v2/gifts` +- 인증: 로그인 회원 필요. 기존 앱 인증 토큰/세션 처리 방식을 사용한다. +- Request body 예시: +```json +{ + "recipientMemberId": 100, + "senderName": "김소다", + "senderPhoneNumber": "01000000000", + "senderZipCode": "12345", + "senderAddress": "서울시 강남구 ...", + "senderAddressDetail": "123동 456호", + "sizeCode": "SMALL", + "categoryId": 1, + "senderTermsAgreed": true, + "senderPrivacyAgreed": true, + "damageWaiverAgreed": true +} +``` + +Request body 필드: + +| 필드 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `recipientMemberId` | number | Y | 화면 진입 시 전달받은 memberId | +| `senderName` | string | Y | 보내는 사람 이름 | +| `senderPhoneNumber` | string | Y | 보내는 사람 휴대폰 번호 | +| `senderZipCode` | string | Y | 보내는 사람 우편번호 | +| `senderAddress` | string | Y | 보내는 사람 기본 주소 | +| `senderAddressDetail` | string 또는 null | N | 보내는 사람 상세 주소 | +| `sizeCode` | string | Y | 선택한 사이즈 코드 | +| `categoryId` | number | Y | 선택한 카테고리 ID | +| `senderTermsAgreed` | boolean | Y | 이용약관 동의 여부. 반드시 `true` | +| `senderPrivacyAgreed` | boolean | Y | 개인정보 수집·이용 동의 여부. 반드시 `true` | +| `damageWaiverAgreed` | boolean | Y | 파손 면책 동의 여부. 필요한 카테고리에서는 `true`, 필요 없는 카테고리에서는 `false` | + +- Response `data` 예시: +```json +{ + "applicationNo": "A-1002609300001", + "status": "RECEIVED", + "statusName": "접수 완료", + "priceCan": 80, + "trackingDeadlineAt": "2026-10-02T03:00:00Z" +} +``` + +Response `data` 필드: + +| 필드 | 타입 | 설명 | +|---|---|---| +| `applicationNo` | string | 선물 신청번호 | +| `status` | string | 신청 상태 코드 | +| `statusName` | string | 신청 상태 표시명 | +| `priceCan` | number | 실제 차감된 캔 금액 | +| `trackingDeadlineAt` | string | 운송장 등록 기한. UTC ISO 문자열 | + +성공 처리: +- 신청 성공 시 기존 앱의 완료 dialog 또는 완료 화면 패턴을 사용한다. +- 최소 표시값은 `applicationNo`, `statusName`, `priceCan`, `trackingDeadlineAt`이다. +- 성공 이후 이동 CTA가 필요하면 기존 선물함 또는 선물 상세 이동 패턴을 사용한다. + +실패 처리: +- 서버 오류 메시지가 있으면 기존 앱 에러 노출 방식으로 표시한다. +- 잔액 부족, 유효하지 않은 카테고리, 파손 면책 미동의, 약관 미동의 등의 오류는 서버 메시지를 우선 사용한다. +- 실패 시 입력값은 유지한다. + +테스트/검증: +- 화면 진입 시 `GET /api/v2/gifts/form-options`가 호출되는지 확인한다. +- 전달받은 크리에이터 닉네임이 받는 크리에이터 영역에 표시되는지 확인한다. +- 전달받은 memberId가 `POST /api/v2/gifts`의 `recipientMemberId`로 들어가는지 확인한다. +- 빈 상태에서는 CTA가 비활성화되는지 확인한다. +- 필수 입력과 필수 동의를 모두 완료하면 CTA가 활성화되고 `{salePriceCan}캔으로 선물 보내기`가 표시되는지 확인한다. +- `requiresDamageWaiver=true` 카테고리 선택 시 파손 면책 동의 row가 표시되고 동의 전에는 CTA가 비활성인지 확인한다. +- `requiresDamageWaiver=false` 카테고리 선택 시 파손 면책 동의 row가 숨겨지고 request의 `damageWaiverAgreed`가 false인지 확인한다. +- submit 성공 시 요청 body와 성공 처리 값을 확인한다. +- submit 실패 시 입력값이 유지되고 기존 앱 방식으로 오류가 표시되는지 확인한다. +``` + +--- + +## API 요약 + +| 목적 | Method | URL | Request | Response data | +|---|---|---|---|---| +| 폼 옵션 조회 | GET | `/api/v2/gifts/form-options` | 없음 | 사이즈 목록, 카테고리 목록 | +| 선물 신청 접수 | POST | `/api/v2/gifts` | 선물 신청 JSON body | 신청번호, 상태, 차감 금액, 운송장 등록 기한 | + +## Figma 반영 메모 + +- 빈 상태 Figma는 카테고리 미선택, 보내는 사람 정보 미입력, 약관 미동의, 하단 CTA 비활성 상태다. +- 활성 상태 Figma는 크리에이터 닉네임 표시, 카테고리 선택, 파손 면책 동의 노출, 보내는 사람 정보 입력, 약관 동의, 하단 CTA 활성 상태다. +- Figma의 하단 버튼 텍스트는 캔 금액이 포함된 형태이므로 API의 `salePriceCan`으로 동적으로 표시한다. +- Figma의 select, checkbox, radio, bottom action bar, safe area는 각 플랫폼의 기존 컴포넌트로 대응한다. From 5494a4974897f44d9d5784d27bd9790baff663f0 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 1 Oct 2026 19:02:15 +0900 Subject: [PATCH 35/53] =?UTF-8?q?docs(gift):=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EC=84=A0=EB=AC=BC=20=ED=99=94=EB=A9=B4=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20=ED=94=84=EB=A1=AC=ED=94=84=ED=8A=B8=EB=A5=BC=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin-page-prompts.md | 470 ++++++++++++++++++ 1 file changed, 470 insertions(+) create mode 100644 docs/20260929_크리에이터_선물하기/admin-page-prompts.md diff --git a/docs/20260929_크리에이터_선물하기/admin-page-prompts.md b/docs/20260929_크리에이터_선물하기/admin-page-prompts.md new file mode 100644 index 00000000..22312c5d --- /dev/null +++ b/docs/20260929_크리에이터_선물하기/admin-page-prompts.md @@ -0,0 +1,470 @@ +# 선물 관리자 페이지 구현 프롬프트 + +이 문서는 관리자 페이지의 선물 관련 3개 화면을 프론트엔드 구현 에이전트에게 전달하기 위한 프롬프트 모음이다. + +공통 전제: + +- 관리자 메뉴에는 parent 메뉴 `선물함 관리` 아래에 다음 3개 하위 메뉴가 있다. + - `선물함 리스트` → `/gift/list` + - `선물 카테고리` → `/gift/category` + - `선물 사이즈` → `/gift/size` +- 모든 API 응답은 기존 공통 envelope를 사용한다. 화면에서는 `response.data`를 실제 payload로 사용한다. +- 인증/권한 처리는 기존 관리자 페이지의 API 클라이언트, 토큰 저장 방식, 에러 처리 방식을 따른다. +- 새 디자인 시스템을 만들지 말고 기존 관리자 페이지의 테이블, 필터, 버튼, 모달, 폼, 토스트, 확인창 패턴을 재사용한다. +- 화면 문구는 한국어로 작성한다. +- API 실패 시 기존 관리자 페이지의 공통 에러 표시 방식을 따른다. + +--- + +## 1. 선물함 리스트 페이지 프롬프트 + +```text +관리자 선물함 리스트 페이지를 구현해줘. + +목표: +- 관리자 메뉴 `선물함 관리 > 선물함 리스트`에서 전체 선물 신청 목록을 조회한다. +- 상태, 신청번호, 닉네임으로 필터링할 수 있다. +- 목록 row에서 선물 상세를 확인하고, 현재 상태에서 가능한 운영 액션을 실행할 수 있다. +- 기존 관리자 페이지의 테이블/필터/페이지네이션/모달/확인창 스타일을 그대로 따른다. + +라우트: +- `/gift/list` + +필수 화면 구성: +- 상단 제목: `선물함 리스트` +- 필터 영역: + - 상태 select: 전체, 접수 완료, 발송 확인, 사서함 도착, 검수 완료, 전달 완료, 전달 불가, 신청 취소 + - 신청번호 검색 input: `applicationNo` 부분 검색 + - 닉네임 검색 input: 발송인 또는 수취인 닉네임 부분 검색 + - 검색 버튼, 초기화 버튼 +- 목록 테이블 컬럼: + - 신청번호 `applicationNo` + - 발송인 닉네임 `senderNickname` + - 수취인 닉네임 `recipientNickname` + - 사이즈 `sizeName` / `sizeCode` + - 카테고리 `categoryName` + - 분류번호 `classificationNumber` + - 택배사 `courierCompanyName` + - 운송장번호 `trackingNumber` + - 상태 `statusName` + - 액션 버튼 +- 페이지네이션: + - 서버의 `page`, `size`, `totalCount`, `hasNext` 기준으로 기존 관리자 페이지 패턴에 맞춘다. + +목록 조회 API: +- Method: `GET` +- URL: `/api/v2/admin/gifts` +- Query: + - `status?: GiftStatus` + - `applicationNo?: string` + - `nickname?: string` + - `page?: number` 기본 `0` + - `size?: number` 기본 `20` +- 필터 조합: + - 선택 필터는 AND 조건이다. + - `nickname`은 발송인/수취인 닉네임 중 하나에 포함되면 매칭된다. + +목록 조회 response `data`: +```ts +type AdminGiftListResponse = { + totalCount: number; + items: AdminGiftListItemResponse[]; + page: number; + size: number; + hasNext: boolean; +}; + +type AdminGiftListItemResponse = { + applicationNo: string; + senderNickname: string; + recipientNickname: string; + sizeCode: string; + sizeName: string; + categoryName: string; + classificationNumber: string; + courierCompanyName: string | null; + trackingNumber: string | null; + status: GiftStatus; + statusName: string; + availableActions: AdminGiftAction[]; +}; +``` + +상태 enum: +```ts +type GiftStatus = + | "RECEIVED" + | "TRACKING_REGISTERED" + | "ARRIVED_AT_MAILBOX" + | "INSPECTION_COMPLETED" + | "DELIVERED" + | "UNDELIVERABLE" + | "CANCELED"; +``` + +상태 표시명: +- `RECEIVED`: `접수 완료` +- `TRACKING_REGISTERED`: `발송 확인` +- `ARRIVED_AT_MAILBOX`: `사서함 도착` +- `INSPECTION_COMPLETED`: `검수 완료` +- `DELIVERED`: `전달 완료` +- `UNDELIVERABLE`: `전달 불가` +- `CANCELED`: `신청 취소` + +상세 조회 API: +- Method: `GET` +- URL: `/api/v2/admin/gifts/{applicationNo}` +- 용도: + - 목록 row 클릭 또는 `상세` 버튼 클릭 시 상세 모달/상세 패널을 연다. + +상세 response `data`: +```ts +type AdminGiftDetailResponse = { + applicationNo: string; + senderInfo: AdminGiftMemberInfoResponse; + recipientInfo: AdminGiftMemberInfoResponse; + productInfo: AdminGiftProductInfoResponse; + inboundDeliveryInfo: AdminGiftInboundDeliveryInfoResponse; + status: GiftStatus; + statusName: string; + availableActions: AdminGiftAction[]; +}; + +type AdminGiftMemberInfoResponse = { + nickname: string; + name: string; + phoneNumber: string; + address: string; +}; + +type AdminGiftProductInfoResponse = { + sizeCode: string; + sizeName: string; + categoryName: string; + classificationNumber: string; +}; + +type AdminGiftInboundDeliveryInfoResponse = { + courierCompanyName: string | null; + trackingNumber: string | null; +}; +``` + +상세 표시 요구사항: +- 발송인 정보: 닉네임, 이름, 전화번호, 주소 +- 수취인 정보: 닉네임, 이름, 전화번호, 주소 +- 상품 정보: 사이즈, 카테고리, 분류번호 +- 입고 배송 정보: 택배사, 운송장번호 +- 현재 상태와 가능한 액션 +- 수취인이 배송지를 아직 입력하지 않은 경우 `recipientInfo.nickname`은 표시하고 `name`, `phoneNumber`, `address`는 빈 문자열로 온다. 화면에서는 `미입력`으로 표시해도 된다. + +운영 액션 enum: +```ts +type AdminGiftAction = + | "ARRIVE_MAILBOX" + | "COMPLETE_INSPECTION" + | "COMPLETE_DELIVERY" + | "MARK_UNDELIVERABLE"; +``` + +상태별 `availableActions`: +- `RECEIVED`: 없음 +- `TRACKING_REGISTERED`: `ARRIVE_MAILBOX`, `MARK_UNDELIVERABLE` +- `ARRIVED_AT_MAILBOX`: `COMPLETE_INSPECTION`, `MARK_UNDELIVERABLE` +- `INSPECTION_COMPLETED`: `COMPLETE_DELIVERY`, `MARK_UNDELIVERABLE` +- `DELIVERED`: 없음 +- `UNDELIVERABLE`: 없음 +- `CANCELED`: 없음 + +액션 버튼 문구: +- `ARRIVE_MAILBOX`: `사서함 도착 처리` +- `COMPLETE_INSPECTION`: `검수 완료 처리` +- `COMPLETE_DELIVERY`: `전달 완료 처리` +- `MARK_UNDELIVERABLE`: `전달 불가 처리` + +액션 API: +- 사서함 도착 처리 + - Method: `POST` + - URL: `/api/v2/admin/gifts/{applicationNo}/arrive-mailbox` + - Body: 없음 +- 검수 완료 처리 + - Method: `POST` + - URL: `/api/v2/admin/gifts/{applicationNo}/complete-inspection` + - Body: 없음 +- 전달 완료 처리 + - Method: `POST` + - URL: `/api/v2/admin/gifts/{applicationNo}/complete-delivery` + - Body: 없음 +- 전달 불가 처리 + - Method: `POST` + - URL: `/api/v2/admin/gifts/{applicationNo}/mark-undeliverable` + - Body: +```ts +type AdminGiftMarkUndeliverableRequest = { + reason: string; +}; +``` + +액션 response `data`: +```ts +type AdminGiftOperationStatusResponse = { + applicationNo: string; + status: GiftStatus; + statusName: string; + occurredAt: string; // UTC ISO string, e.g. 2026-09-30T12:00:00Z +}; +``` + +액션 UX: +- 모든 상태 변경 액션은 확인창을 띄운다. +- `MARK_UNDELIVERABLE`은 사유 입력 모달을 띄운다. +- 전달 불가 사유는 공백만 입력할 수 없고 255자 이내로 제한한다. +- 액션 성공 후: + - 상세 모달이 열려 있으면 상세를 다시 조회한다. + - 목록도 현재 필터/페이지 기준으로 다시 조회한다. + - 성공 토스트를 표시한다. +- 액션 실패 시 기존 관리자 페이지 에러 토스트/알림 패턴을 따른다. + +빈 값 표시: +- `courierCompanyName`, `trackingNumber`가 null이면 `-`로 표시한다. +- 수취인 개인정보가 빈 문자열이면 `미입력`으로 표시한다. + +테스트/검증: +- API 클라이언트 함수 단위 테스트 또는 페이지 테스트에서 query parameter가 올바르게 전달되는지 확인한다. +- `availableActions`에 따라 버튼 노출이 달라지는지 확인한다. +- 전달 불가 사유 빈 값 validation을 확인한다. +``` + +--- + +## 2. 선물 카테고리 CRUD 페이지 프롬프트 + +```text +관리자 선물 카테고리 CRUD 페이지를 구현해줘. + +목표: +- 관리자 메뉴 `선물함 관리 > 선물 카테고리`에서 선물 카테고리를 조회, 등록, 수정, 비활성화한다. +- 기존 관리자 페이지의 CRUD 테이블, 등록/수정 모달, 삭제 확인창, 토스트 패턴을 그대로 따른다. + +라우트: +- `/gift/category` + +필수 화면 구성: +- 상단 제목: `선물 카테고리` +- 상단 우측 `카테고리 등록` 버튼 +- 목록 테이블 컬럼: + - 카테고리 ID `categoryId` + - 분류번호 `classificationNumber` + - 카테고리 코드 `categoryCode` + - 카테고리명 `name` + - 접수 코드 `receiptCode` + - 대표 품목 `representativeItem` + - 파손면책 동의 필요 여부 `requiresDamageWaiver` + - 활성 여부 `isActive` + - 관리 버튼: 수정, 비활성화 +- 등록/수정 폼 필드: + - `classificationNumber` + - `categoryCode` + - `name` + - `receiptCode` + - `representativeItem` + - `requiresDamageWaiver` + - `isActive` + +목록 조회 API: +- Method: `GET` +- URL: `/api/v2/admin/gift-categories` +- Request: 없음 +- Response `data`: +```ts +type AdminGiftCategoryResponse = { + categoryId: number; + classificationNumber: string; + categoryCode: string; + name: string; + receiptCode: string; + representativeItem: string; + requiresDamageWaiver: boolean; + isActive: boolean; +}; +``` + +등록 API: +- Method: `POST` +- URL: `/api/v2/admin/gift-categories` +- Body: +```ts +type AdminGiftCategoryRequest = { + classificationNumber: string; + categoryCode: string; + name: string; + receiptCode: string; + representativeItem: string; + requiresDamageWaiver: boolean; + isActive: boolean; +}; +``` +- Response `data`: `AdminGiftCategoryResponse` + +수정 API: +- Method: `PUT` +- URL: `/api/v2/admin/gift-categories/{categoryId}` +- Body: `AdminGiftCategoryRequest` +- Response `data`: `AdminGiftCategoryResponse` + +비활성화 API: +- Method: `DELETE` +- URL: `/api/v2/admin/gift-categories/{categoryId}` +- Body: 없음 +- Response `data`: `AdminGiftCategoryResponse` +- 서버는 실제 삭제가 아니라 `isActive=false` 논리 삭제로 처리한다. + +폼 validation: +- `classificationNumber`: 필수, 숫자 3자리. 예: `100` +- `categoryCode`: 필수, 50자 이하. 예: `DOLL` +- `name`: 필수, 50자 이하. 예: `인형` +- `receiptCode`: 필수, 영문 대문자 1~9자. 예: `A` +- `representativeItem`: 필수, 100자 이하. 예: `피규어` +- `requiresDamageWaiver`: boolean +- `isActive`: boolean + +UX 요구사항: +- 등록 성공 후 목록을 다시 조회하고 모달을 닫는다. +- 수정 성공 후 목록을 다시 조회하고 모달을 닫는다. +- 비활성화 버튼은 확인창을 띄운 뒤 호출한다. +- 이미 비활성화된 row는 비활성화 버튼을 disabled 처리한다. +- boolean 값은 기존 관리자 페이지 표현에 맞춰 `예/아니오`, `활성/비활성` 또는 badge로 표시한다. +- `categoryCode`는 수정 폼에 표시하되 서버 쪽 엔티티는 코드 변경을 실제로 반영하지 않을 수 있으므로, 가능하면 수정 시 읽기 전용으로 두고 나머지 운영 필드만 수정하도록 UX를 구성한다. + +테스트/검증: +- 목록 조회가 `GET /api/v2/admin/gift-categories`를 호출하는지 확인한다. +- 등록/수정 body가 `AdminGiftCategoryRequest` 형태와 일치하는지 확인한다. +- validation 실패 시 API를 호출하지 않고 폼 에러를 표시한다. +- 비활성화 성공 후 해당 row의 `isActive=false`가 반영되는지 확인한다. +``` + +--- + +## 3. 선물 사이즈 가격 CRUD 페이지 프롬프트 + +```text +관리자 선물 사이즈 가격 CRUD 페이지를 구현해줘. + +목표: +- 관리자 메뉴 `선물함 관리 > 선물 사이즈`에서 선물 사이즈별 기본가/판매가/활성 여부를 조회하고 수정한다. +- 현재 API는 사이즈 가격의 목록 조회와 수정만 제공한다. 신규 생성과 물리 삭제 기능은 만들지 않는다. +- 기존 관리자 페이지의 테이블, 수정 모달, 저장 확인/토스트 패턴을 그대로 따른다. + +라우트: +- `/gift/size` + +필수 화면 구성: +- 상단 제목: `선물 사이즈` +- 목록 테이블 컬럼: + - 사이즈 코드 `sizeCode` + - 사이즈명 `name` + - 기본가 `basePriceCan` + - 판매가 `salePriceCan` + - 활성 여부 `isActive` + - 관리 버튼: 수정 +- 수정 폼 필드: + - `sizeCode`: 읽기 전용 + - `name`: 읽기 전용 + - `basePriceCan`: number input + - `salePriceCan`: number input + - `isActive`: checkbox/switch + +목록 조회 API: +- Method: `GET` +- URL: `/api/v2/admin/gift-size-prices` +- Request: 없음 +- Response `data`: +```ts +type AdminGiftSizePriceResponse = { + sizeCode: GiftSize; + name: string; + basePriceCan: number; + salePriceCan: number; + isActive: boolean; +}; +``` + +수정 API: +- Method: `PUT` +- URL: `/api/v2/admin/gift-size-prices/{sizeCode}` +- Body: +```ts +type AdminGiftSizePriceRequest = { + basePriceCan: number; + salePriceCan: number; + isActive: boolean; +}; +``` +- Response `data`: `AdminGiftSizePriceResponse` + +사이즈 enum: +```ts +type GiftSize = "SMALL" | "MEDIUM" | "LARGE"; +``` + +서버 validation: +- `salePriceCan`은 0보다 커야 한다. +- `salePriceCan`은 `basePriceCan`보다 클 수 없다. + +프론트 validation: +- `basePriceCan`: 필수, 1 이상 정수 +- `salePriceCan`: 필수, 1 이상 정수 +- `salePriceCan <= basePriceCan` +- validation 실패 시 API 호출을 막고 필드 에러를 표시한다. + +UX 요구사항: +- 가격은 숫자 입력이지만 테이블 표시에서는 기존 관리자 페이지의 숫자 포맷을 따른다. +- 수정 버튼 클릭 시 현재 row 값을 폼 초기값으로 넣는다. +- 저장 전 확인창은 기존 관리자 페이지 패턴이 있으면 따른다. +- 저장 성공 후 목록을 다시 조회하고 모달을 닫는다. +- 신규 생성/삭제 버튼은 만들지 않는다. + +테스트/검증: +- 목록 조회가 `GET /api/v2/admin/gift-size-prices`를 호출하는지 확인한다. +- 수정 저장 시 `PUT /api/v2/admin/gift-size-prices/{sizeCode}`와 request body가 정확한지 확인한다. +- `salePriceCan > basePriceCan`일 때 API를 호출하지 않고 validation 메시지를 표시하는지 확인한다. +- 저장 성공 후 목록 refresh가 일어나는지 확인한다. +``` + +--- + +## 구현 시 참고할 공통 타입 + +```ts +type ApiResponse = { + success: boolean; + data: T; + message?: string; +}; + +type GiftStatus = + | "RECEIVED" + | "TRACKING_REGISTERED" + | "ARRIVED_AT_MAILBOX" + | "INSPECTION_COMPLETED" + | "DELIVERED" + | "UNDELIVERABLE" + | "CANCELED"; + +type AdminGiftAction = + | "ARRIVE_MAILBOX" + | "COMPLETE_INSPECTION" + | "COMPLETE_DELIVERY" + | "MARK_UNDELIVERABLE"; + +type GiftSize = "SMALL" | "MEDIUM" | "LARGE"; +``` + +## 구현 순서 추천 + +1. API client 함수와 TypeScript 타입을 먼저 추가한다. +2. `/gift/list` 선물함 리스트와 상세/상태변경 모달을 구현한다. +3. `/gift/category` 카테고리 CRUD를 구현한다. +4. `/gift/size` 사이즈 가격 수정 화면을 구현한다. +5. 기존 관리자 메뉴의 `선물함 관리` 하위 route와 연결되는지 확인한다. +6. 각 페이지별 loading/empty/error/success 상태를 확인한다. From c073c4853809454b3e103ff21c86d464fd58c6f1 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 1 Oct 2026 20:49:28 +0900 Subject: [PATCH 36/53] =?UTF-8?q?feat(gift):=20=EC=84=A0=EB=AC=BC=ED=95=A8?= =?UTF-8?q?=20=EC=83=81=EB=8C=80=20=ED=9A=8C=EC=9B=90=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/v2/api/gift/dto/GiftResponse.kt | 6 ++ .../v2/gift/application/GiftQueryService.kt | 50 ++++++++++---- .../gift/adapter/in/web/GiftControllerTest.kt | 30 +++++++-- .../gift/application/GiftQueryServiceTest.kt | 65 ++++++++++++++----- 4 files changed, 117 insertions(+), 34 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftResponse.kt index 5c9883fc..59496eb8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftResponse.kt @@ -59,6 +59,9 @@ data class GiftListResponse( priceCan = it.priceCan, categoryName = it.categoryName, sizeName = it.sizeName, + counterpartMemberId = it.counterpartMemberId, + counterpartNickname = it.counterpartNickname, + counterpartProfileImageUrl = it.counterpartProfileImageUrl, createdAt = it.createdAt?.toUtcIso() ) }, @@ -77,6 +80,9 @@ data class GiftListItemResponse( val priceCan: Int, val categoryName: String, val sizeName: String, + val counterpartMemberId: Long, + val counterpartNickname: String, + val counterpartProfileImageUrl: String, val createdAt: String? ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt index f1c06608..f84b6890 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.v2.gift.application import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRepository import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository @@ -11,6 +12,7 @@ import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRep import kr.co.vividnext.sodalive.v2.gift.domain.GiftDirection import kr.co.vividnext.sodalive.v2.gift.domain.GiftListType import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus +import org.springframework.beans.factory.annotation.Value import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Sort import org.springframework.data.repository.findByIdOrNull @@ -39,6 +41,9 @@ data class GiftListItemResult( val priceCan: Int, val categoryName: String, val sizeName: String, + val counterpartMemberId: Long, + val counterpartNickname: String, + val counterpartProfileImageUrl: String, val createdAt: LocalDateTime? ) @@ -99,7 +104,9 @@ class GiftQueryService( private val sizePriceRepository: GiftSizePriceRepository, private val giftRepository: GiftRepository, private val deliveryRepository: GiftDeliveryRepository, - private val memberRepository: MemberRepository + private val memberRepository: MemberRepository, + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String ) { @Transactional(readOnly = true) fun getFormOptions() = GiftFormOptionsResult( @@ -140,9 +147,13 @@ class GiftQueryService( ) } + val membersById = memberRepository.findAllById( + gifts.content.flatMap { listOf(it.senderMemberId, it.recipientMemberId) }.toSet() + ).associateBy { it.id ?: throw invalidRequest() } + return GiftListResult( totalCount = gifts.totalElements, - items = gifts.content.map { it.toGiftListItem(memberId) }, + items = gifts.content.map { it.toGiftListItem(memberId, membersById) }, page = normalizedPage, size = normalizedSize, hasNext = gifts.hasNext() @@ -189,16 +200,31 @@ class GiftQueryService( ) } - private fun Gift.toGiftListItem(memberId: Long) = GiftListItemResult( - applicationNo = applicationNo, - direction = if (senderMemberId == memberId) GiftDirection.SENT else GiftDirection.RECEIVED, - status = status, - statusName = status.displayName, - priceCan = salePriceCan, - categoryName = categoryNameSnapshot, - sizeName = sizeCode.displayName, - createdAt = createdAt - ) + private fun Gift.toGiftListItem(memberId: Long, membersById: Map): GiftListItemResult { + val direction = if (senderMemberId == memberId) GiftDirection.SENT else GiftDirection.RECEIVED + val counterpartId = if (direction == GiftDirection.SENT) recipientMemberId else senderMemberId + val counterpart = membersById[counterpartId] ?: throw invalidRequest() + + return GiftListItemResult( + applicationNo = applicationNo, + direction = direction, + status = status, + statusName = status.displayName, + priceCan = salePriceCan, + categoryName = categoryNameSnapshot, + sizeName = sizeCode.displayName, + counterpartMemberId = counterpartId, + counterpartNickname = counterpart.nickname, + counterpartProfileImageUrl = counterpart.profileImageUrl(), + createdAt = createdAt + ) + } + + private fun Member.profileImageUrl() = if (profileImage.isNullOrBlank()) { + "$cloudFrontHost/profile/default-profile.png" + } else { + "$cloudFrontHost/$profileImage" + } private fun Gift.directionFor(memberId: Long) = when { senderMemberId == memberId -> GiftDirection.SENT diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt index 8a195ba9..9bc22f37 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt @@ -46,7 +46,9 @@ import java.time.LocalDateTime @SpringBootTest( properties = [ - "spring.datasource.url=jdbc:h2:mem:gift-controller;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE" + "spring.datasource.url=" + + "jdbc:h2:mem:gift-controller;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE", + "cloud.aws.cloud-front.host=https://cdn.test" ] ) @AutoConfigureMockMvc @@ -86,13 +88,26 @@ class GiftControllerTest @Autowired constructor( @Test @DisplayName("회원은 선물함 목록에서 보낸 선물과 노출 가능한 받은 선물을 페이징 응답으로 조회한다") fun shouldListGifts() { - val member = Member(password = "password", nickname = "member").apply { id = 10L } - saveGift("G-CONTROLLER-SENT", senderMemberId = 10L, recipientMemberId = 20L, status = GiftStatus.RECEIVED) - saveGift("G-CONTROLLER-HIDDEN", senderMemberId = 20L, recipientMemberId = 10L, status = GiftStatus.RECEIVED) + val member = memberRepository.save(Member(password = "password", nickname = "member")) + val counterpart = memberRepository.save( + Member(password = "password", nickname = "counterpart", profileImage = "profile/counterpart.png") + ) + saveGift( + "G-CONTROLLER-SENT", + senderMemberId = member.id!!, + recipientMemberId = counterpart.id!!, + status = GiftStatus.RECEIVED + ) + saveGift( + "G-CONTROLLER-HIDDEN", + senderMemberId = counterpart.id!!, + recipientMemberId = member.id!!, + status = GiftStatus.RECEIVED + ) saveGift( "G-CONTROLLER-VISIBLE", - senderMemberId = 20L, - recipientMemberId = 10L, + senderMemberId = counterpart.id!!, + recipientMemberId = member.id!!, status = GiftStatus.TRACKING_REGISTERED ) @@ -117,6 +132,9 @@ class GiftControllerTest @Autowired constructor( .andExpect(jsonPath("$.data.items[0].priceCan").value(80)) .andExpect(jsonPath("$.data.items[0].categoryName").value("인형")) .andExpect(jsonPath("$.data.items[0].sizeName").value("소형")) + .andExpect(jsonPath("$.data.items[0].counterpartMemberId").value(counterpart.id!!)) + .andExpect(jsonPath("$.data.items[0].counterpartNickname").value("counterpart")) + .andExpect(jsonPath("$.data.items[0].counterpartProfileImageUrl").value("https://cdn.test/profile/counterpart.png")) .andExpect(jsonPath("$.data.items[0].createdAt").value(endsWith("Z"))) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt index 0da24ecb..2e4cb51b 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt @@ -61,10 +61,17 @@ class GiftQueryServiceTest @Autowired constructor( @Test @DisplayName("보낸 선물은 접수 직후 선물함에 노출된다") fun shouldListReceivedGiftForSender() { - saveGift("G-SENT-RECEIVED", senderMemberId = MEMBER_ID, recipientMemberId = 2L, status = GiftStatus.RECEIVED) + val sender = saveMember("fan") + val recipient = saveMember("creator", "profile/creator.png") + saveGift( + "G-SENT-RECEIVED", + senderMemberId = sender.id!!, + recipientMemberId = recipient.id!!, + status = GiftStatus.RECEIVED + ) val result = queryService().getGifts( - memberId = MEMBER_ID, + memberId = sender.id!!, type = GiftListType.SENT, page = 0, size = 20 @@ -79,22 +86,33 @@ class GiftQueryServiceTest @Autowired constructor( assertEquals(80, result.items.single().priceCan) assertEquals("인형", result.items.single().categoryName) assertEquals("소형", result.items.single().sizeName) + assertEquals(recipient.id, result.items.single().counterpartMemberId) + assertEquals("creator", result.items.single().counterpartNickname) + assertEquals("https://cdn.test/profile/creator.png", result.items.single().counterpartProfileImageUrl) assertNotNull(result.items.single().createdAt) } @Test @DisplayName("받은 선물은 운송장 등록 이후 상태만 선물함에 노출된다") fun shouldExcludeReceivedOnlyGiftAndListVisibleGiftForRecipient() { - saveGift("G-RECEIVED-HIDDEN", senderMemberId = 2L, recipientMemberId = MEMBER_ID, status = GiftStatus.RECEIVED) + val recipient = saveMember("creator") + val hiddenSender = saveMember("hidden-fan") + val visibleSender = saveMember("visible-fan") + saveGift( + "G-RECEIVED-HIDDEN", + senderMemberId = hiddenSender.id!!, + recipientMemberId = recipient.id!!, + status = GiftStatus.RECEIVED + ) saveGift( "G-RECEIVED-VISIBLE", - senderMemberId = 3L, - recipientMemberId = MEMBER_ID, + senderMemberId = visibleSender.id!!, + recipientMemberId = recipient.id!!, status = GiftStatus.TRACKING_REGISTERED ) val result = queryService().getGifts( - memberId = MEMBER_ID, + memberId = recipient.id!!, type = GiftListType.RECEIVED, page = 0, size = 20 @@ -103,22 +121,29 @@ class GiftQueryServiceTest @Autowired constructor( assertEquals(1L, result.totalCount) assertEquals(listOf("G-RECEIVED-VISIBLE"), result.items.map { it.applicationNo }) assertEquals(GiftDirection.RECEIVED, result.items.single().direction) + assertEquals(visibleSender.id, result.items.single().counterpartMemberId) + assertEquals("visible-fan", result.items.single().counterpartNickname) + assertEquals("https://cdn.test/profile/default-profile.png", result.items.single().counterpartProfileImageUrl) } @Test @DisplayName("전체 선물함은 보낸 선물과 노출 가능한 받은 선물을 방향과 함께 반환한다") fun shouldListSentAndVisibleReceivedGiftsWithDirectionsForAll() { - saveGift("G-ALL-SENT", senderMemberId = MEMBER_ID, recipientMemberId = 2L, status = GiftStatus.CANCELED) + val member = saveMember("member") + val creator = saveMember("creator") + val fan = saveMember("fan") + val hiddenFan = saveMember("hidden-fan") + saveGift("G-ALL-SENT", senderMemberId = member.id!!, recipientMemberId = creator.id!!, status = GiftStatus.CANCELED) saveGift( "G-ALL-RECEIVED", - senderMemberId = 3L, - recipientMemberId = MEMBER_ID, + senderMemberId = fan.id!!, + recipientMemberId = member.id!!, status = GiftStatus.ARRIVED_AT_MAILBOX ) - saveGift("G-ALL-HIDDEN", senderMemberId = 4L, recipientMemberId = MEMBER_ID, status = GiftStatus.RECEIVED) + saveGift("G-ALL-HIDDEN", senderMemberId = hiddenFan.id!!, recipientMemberId = member.id!!, status = GiftStatus.RECEIVED) val result = queryService().getGifts( - memberId = MEMBER_ID, + memberId = member.id!!, type = GiftListType.ALL, page = 0, size = 20 @@ -129,15 +154,21 @@ class GiftQueryServiceTest @Autowired constructor( mapOf("G-ALL-SENT" to GiftDirection.SENT, "G-ALL-RECEIVED" to GiftDirection.RECEIVED), result.items.associate { it.applicationNo to it.direction } ) + assertEquals( + mapOf("G-ALL-SENT" to creator.id, "G-ALL-RECEIVED" to fan.id), + result.items.associate { it.applicationNo to it.counterpartMemberId } + ) } @Test @DisplayName("선물함 page와 size는 음수 또는 0이면 기본값으로 보정된다") fun shouldNormalizeGiftListPageAndSize() { - saveGift("G-PAGING", senderMemberId = MEMBER_ID, recipientMemberId = 2L, status = GiftStatus.RECEIVED) + val sender = saveMember("fan") + val recipient = saveMember("creator") + saveGift("G-PAGING", senderMemberId = sender.id!!, recipientMemberId = recipient.id!!, status = GiftStatus.RECEIVED) val result = queryService().getGifts( - memberId = MEMBER_ID, + memberId = sender.id!!, type = GiftListType.SENT, page = -1, size = 0 @@ -379,7 +410,9 @@ class GiftQueryServiceTest @Autowired constructor( ) ) - private fun saveMember(nickname: String) = memberRepository.save(Member(password = "password", nickname = nickname)) + private fun saveMember(nickname: String, profileImage: String? = null) = memberRepository.save( + Member(password = "password", nickname = nickname, profileImage = profileImage) + ) private fun giftCategory( classificationNumber: String, @@ -416,11 +449,11 @@ class GiftQueryServiceTest @Autowired constructor( sizePriceRepository, giftRepository, deliveryRepository, - memberRepository + memberRepository, + "https://cdn.test" ) private companion object { - const val MEMBER_ID = 1L val NOW: LocalDateTime = LocalDateTime.of(2026, 9, 29, 12, 0) } } From f66c89fb7f139387ffbc29278995e8d4cbcfa8b5 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 1 Oct 2026 22:56:59 +0900 Subject: [PATCH 37/53] =?UTF-8?q?feat(gift):=20=EC=84=A0=EB=AC=BC=20?= =?UTF-8?q?=EB=B0=9B=EC=9D=84=20=EC=A3=BC=EC=86=8C=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EC=86=8C=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gift-schema.sql | 10 ++++++ .../adapter/out/persistence/GiftMailbox.kt | 33 +++++++++++++++++++ .../out/persistence/GiftMailboxRepository.kt | 5 +++ 3 files changed, 48 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftMailbox.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftMailboxRepository.kt diff --git a/docs/20260929_크리에이터_선물하기/gift-schema.sql b/docs/20260929_크리에이터_선물하기/gift-schema.sql index 7e969356..81dbfff3 100644 --- a/docs/20260929_크리에이터_선물하기/gift-schema.sql +++ b/docs/20260929_크리에이터_선물하기/gift-schema.sql @@ -15,6 +15,16 @@ CREATE TABLE gift_category ( UNIQUE KEY uk_gift_category_receipt_code (receipt_code) ) COMMENT='선물 카테고리'; +CREATE TABLE gift_mailbox ( + id BIGINT NOT NULL COMMENT '전역 단일 설정 ID. 항상 1', + name VARCHAR(50) NOT NULL COMMENT '받을 사람 이름', + phone_number VARCHAR(30) NOT NULL COMMENT '받을 연락처', + zip_code VARCHAR(20) NOT NULL COMMENT '받을 우편번호', + address VARCHAR(255) NOT NULL COMMENT '받을 주소', + address_detail VARCHAR(255) NULL COMMENT '받을 상세주소', + PRIMARY KEY (id) +) COMMENT='선물 받을 주소 전역 설정'; + CREATE TABLE gift ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '내부 기본키', application_no VARCHAR(40) NOT NULL COMMENT '신청번호', diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftMailbox.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftMailbox.kt new file mode 100644 index 00000000..88e46a3e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftMailbox.kt @@ -0,0 +1,33 @@ +package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence + +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.Id +import javax.persistence.Table + +@Entity +@Table(name = "gift_mailbox") +class GiftMailbox( + @Id + @Column(name = "id", nullable = false) + val id: Long = SINGLETON_ID, + + @Column(name = "name", nullable = false, length = 50) + var name: String, + + @Column(name = "phone_number", nullable = false, length = 30) + var phoneNumber: String, + + @Column(name = "zip_code", nullable = false, length = 20) + var zipCode: String, + + @Column(name = "address", nullable = false, length = 255) + var address: String, + + @Column(name = "address_detail", length = 255) + var addressDetail: String? +) { + companion object { + const val SINGLETON_ID = 1L + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftMailboxRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftMailboxRepository.kt new file mode 100644 index 00000000..914cadfe --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftMailboxRepository.kt @@ -0,0 +1,5 @@ +package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence + +import org.springframework.data.jpa.repository.JpaRepository + +interface GiftMailboxRepository : JpaRepository From 33b6662cb48fd47684b9340fc2d99b54d73c1f8b Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 1 Oct 2026 22:57:07 +0900 Subject: [PATCH 38/53] =?UTF-8?q?feat(gift):=20=EC=84=A0=EB=AC=BC=20?= =?UTF-8?q?=EB=B0=9B=EC=9D=84=20=EC=A3=BC=EC=86=8C=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EC=84=9C=EB=B9=84=EC=8A=A4=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../v2/gift/application/GiftAdminService.kt | 58 +++++++++++++++++++ .../gift/application/GiftAdminServiceTest.kt | 56 +++++++++++++++++- 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminService.kt index 9ba9ae97..a3569f59 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminService.kt @@ -4,6 +4,8 @@ import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailbox +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailboxRepository import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePrice import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository @@ -41,6 +43,14 @@ data class GiftSizePriceCommand( val isActive: Boolean ) +data class GiftMailboxCommand( + val name: String, + val phoneNumber: String, + val zipCode: String, + val address: String, + val addressDetail: String? +) + data class GiftSizePriceResult( val sizeCode: GiftSize, val sizeName: String, @@ -62,8 +72,31 @@ class GiftAdminService( private val sizePriceRepository: GiftSizePriceRepository, private val giftRepository: GiftRepository, private val giftDeliveryRepository: GiftDeliveryRepository, + private val giftMailboxRepository: GiftMailboxRepository, private val giftPushService: GiftPushService ) { + @Transactional(readOnly = true) + fun getMailbox(): GiftMailboxResult? = giftMailboxRepository.findByIdOrNull(GiftMailbox.SINGLETON_ID)?.toResult() + + @Transactional + fun updateMailbox(command: GiftMailboxCommand): GiftMailboxResult { + validateMailbox(command) + val mailbox = giftMailboxRepository.findByIdOrNull(GiftMailbox.SINGLETON_ID) + ?: GiftMailbox( + name = command.name, + phoneNumber = command.phoneNumber, + zipCode = command.zipCode, + address = command.address, + addressDetail = command.addressDetail?.takeIf { it.isNotBlank() } + ) + mailbox.name = command.name + mailbox.phoneNumber = command.phoneNumber + mailbox.zipCode = command.zipCode + mailbox.address = command.address + mailbox.addressDetail = command.addressDetail?.takeIf { it.isNotBlank() } + return giftMailboxRepository.save(mailbox).toResult() + } + @Transactional(readOnly = true) fun getCategories(): List = categoryRepository.findAll().map { it.toResult() } @@ -198,6 +231,22 @@ class GiftAdminService( if (command.salePriceCan <= 0 || command.salePriceCan > command.basePriceCan) throw invalidRequest() } + private fun validateMailbox(command: GiftMailboxCommand) { + if ( + command.name.isBlank() || + command.name.length > 50 || + command.phoneNumber.isBlank() || + command.phoneNumber.length > 30 || + command.zipCode.isBlank() || + command.zipCode.length > 20 || + command.address.isBlank() || + command.address.length > 255 || + (command.addressDetail?.length ?: 0) > 255 + ) { + throw invalidRequest() + } + } + private fun GiftCategory.toResult() = GiftCategoryResult( categoryId = id ?: throw invalidRequest(), classificationNumber = classificationNumber, @@ -217,6 +266,15 @@ class GiftAdminService( isActive = isActive ) + private fun GiftMailbox.toResult() = GiftMailboxResult( + name = name, + address = formatAddress(zipCode, address, addressDetail), + phoneNumber = phoneNumber + ) + + private fun formatAddress(zipCode: String, address: String, detail: String?) = + "($zipCode) $address" + if (detail.isNullOrBlank()) "" else ", $detail" + private fun invalidRequest() = SodaException(messageKey = "common.error.invalid_request") private companion object { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminServiceTest.kt index 88c14e74..b003dda6 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminServiceTest.kt @@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailboxRepository import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize @@ -33,7 +34,8 @@ class GiftAdminServiceTest @Autowired constructor( private val categoryRepository: GiftCategoryRepository, private val sizePriceRepository: GiftSizePriceRepository, private val giftRepository: GiftRepository, - private val giftDeliveryRepository: GiftDeliveryRepository + private val giftDeliveryRepository: GiftDeliveryRepository, + private val giftMailboxRepository: GiftMailboxRepository ) { private val giftPushService = Mockito.mock(GiftPushService::class.java) private val service = GiftAdminService( @@ -41,9 +43,61 @@ class GiftAdminServiceTest @Autowired constructor( sizePriceRepository, giftRepository, giftDeliveryRepository, + giftMailboxRepository, giftPushService ) + @Test + @DisplayName("받을 주소는 미등록이면 null이고 저장 후 단일 설정으로 갱신된다") + fun shouldUpsertGiftMailbox() { + assertEquals(null, service.getMailbox()) + + val created = service.updateMailbox( + GiftMailboxCommand( + name = "소다라이브 선물 담당자", + phoneNumber = "01012345678", + zipCode = "06234", + address = "서울시 강남구", + addressDetail = "3층 선물 접수처" + ) + ) + val updated = service.updateMailbox( + GiftMailboxCommand( + name = "소다라이브 운영팀", + phoneNumber = "01087654321", + zipCode = "04524", + address = "서울시 중구", + addressDetail = null + ) + ) + + assertEquals("소다라이브 선물 담당자", created.name) + assertEquals("(06234) 서울시 강남구, 3층 선물 접수처", created.address) + assertEquals("01012345678", created.phoneNumber) + assertEquals("소다라이브 운영팀", updated.name) + assertEquals("(04524) 서울시 중구", updated.address) + assertEquals(1, giftMailboxRepository.count()) + } + + @Test + @DisplayName("받을 주소 필수값이 공백이면 저장할 수 없다") + fun shouldRejectInvalidGiftMailbox() { + val exception = assertThrows(SodaException::class.java) { + service.updateMailbox( + GiftMailboxCommand( + name = " ", + phoneNumber = "01012345678", + zipCode = "06234", + address = "서울시 강남구", + addressDetail = null + ) + ) + } + + assertEquals("common.error.invalid_request", exception.messageKey) + assertEquals(0, giftMailboxRepository.count()) + } + @Test @DisplayName("카테고리를 등록, 수정, 논리삭제한다") fun shouldCreateUpdateAndDeactivateCategory() { From 8a95dc171d8b7e2662e06d46b7025b56f9dd9ec1 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 1 Oct 2026 22:57:39 +0900 Subject: [PATCH 39/53] =?UTF-8?q?feat(gift):=20=EC=84=A0=EB=AC=BC=20?= =?UTF-8?q?=EB=B0=9B=EC=9D=84=20=EC=A3=BC=EC=86=8C=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20API=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/in/web/AdminGiftController.kt | 10 ++++ .../v2/api/admin/gift/dto/AdminGiftRequest.kt | 17 +++++++ .../api/admin/gift/dto/AdminGiftResponse.kt | 15 ++++++ .../adapter/in/web/AdminGiftControllerTest.kt | 48 +++++++++++++++++++ 4 files changed, 90 insertions(+) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftController.kt index 564b91d5..be9c88d1 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftController.kt @@ -5,6 +5,8 @@ import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftCategoryRequest import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftCategoryResponse import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftDetailResponse import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftListResponse +import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftMailboxRequest +import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftMailboxResponse import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftMarkUndeliverableRequest import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftOperationStatusResponse import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftSizePriceRequest @@ -45,6 +47,14 @@ class AdminGiftController( AdminGiftDetailResponse.from(queryService.getGiftDetail(applicationNo)) ) + @GetMapping("/gift-mailbox") + fun getMailbox() = ApiResponse.ok(service.getMailbox()?.let { AdminGiftMailboxResponse.from(it) }) + + @PutMapping("/gift-mailbox") + fun updateMailbox(@RequestBody request: AdminGiftMailboxRequest) = ApiResponse.ok( + AdminGiftMailboxResponse.from(service.updateMailbox(request.toCommand())) + ) + @GetMapping("/gift-categories") fun getCategories() = ApiResponse.ok(service.getCategories().map { AdminGiftCategoryResponse.from(it) }) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/dto/AdminGiftRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/dto/AdminGiftRequest.kt index a53b906f..842dd9d7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/dto/AdminGiftRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/dto/AdminGiftRequest.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.v2.api.admin.gift.dto import kr.co.vividnext.sodalive.v2.gift.application.GiftCategoryCommand +import kr.co.vividnext.sodalive.v2.gift.application.GiftMailboxCommand import kr.co.vividnext.sodalive.v2.gift.application.GiftSizePriceCommand data class AdminGiftCategoryRequest( @@ -35,6 +36,22 @@ data class AdminGiftSizePriceRequest( ) } +data class AdminGiftMailboxRequest( + val name: String, + val phoneNumber: String, + val zipCode: String, + val address: String, + val addressDetail: String? +) { + fun toCommand() = GiftMailboxCommand( + name = name, + phoneNumber = phoneNumber, + zipCode = zipCode, + address = address, + addressDetail = addressDetail + ) +} + data class AdminGiftMarkUndeliverableRequest( val reason: String ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/dto/AdminGiftResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/dto/AdminGiftResponse.kt index 32c9a606..9edfdcae 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/dto/AdminGiftResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/dto/AdminGiftResponse.kt @@ -8,6 +8,7 @@ import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftListResult import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftMemberInfoResult import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftProductInfoResult import kr.co.vividnext.sodalive.v2.gift.application.GiftCategoryResult +import kr.co.vividnext.sodalive.v2.gift.application.GiftMailboxResult import kr.co.vividnext.sodalive.v2.gift.application.GiftOperationStatusResult import kr.co.vividnext.sodalive.v2.gift.application.GiftSizePriceResult import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus @@ -70,6 +71,20 @@ data class AdminGiftOperationStatusResponse( } } +data class AdminGiftMailboxResponse( + val name: String, + val address: String, + val phoneNumber: String +) { + companion object { + fun from(result: GiftMailboxResult) = AdminGiftMailboxResponse( + name = result.name, + address = result.address, + phoneNumber = result.phoneNumber + ) + } +} + data class AdminGiftListResponse( val totalCount: Long, val items: List, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt index db7dcb0e..da1787e5 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt @@ -59,6 +59,54 @@ class AdminGiftControllerTest @Autowired constructor( categoryRepository.deleteAll() } + @Test + @DisplayName("관리자는 받을 주소를 조회하고 저장한다") + fun shouldManageGiftMailbox() { + mockMvc.perform(get("/api/v2/admin/gift-mailbox").with(user("admin").roles("ADMIN"))) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").isEmpty) + + mockMvc.perform( + put("/api/v2/admin/gift-mailbox") + .with(user("admin").roles("ADMIN")) + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + { + "name":"소다라이브 선물 담당자", + "phoneNumber":"01012345678", + "zipCode":"06234", + "address":"서울시 강남구", + "addressDetail":"3층 선물 접수처" + } + """.trimIndent() + ) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.name").value("소다라이브 선물 담당자")) + .andExpect(jsonPath("$.data.phoneNumber").value("01012345678")) + .andExpect(jsonPath("$.data.address").value("(06234) 서울시 강남구, 3층 선물 접수처")) + + mockMvc.perform(get("/api/v2/admin/gift-mailbox").with(user("admin").roles("ADMIN"))) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.name").value("소다라이브 선물 담당자")) + } + + @Test + @DisplayName("관리자는 공백 받을 주소를 저장할 수 없다") + fun shouldRejectInvalidGiftMailbox() { + mockMvc.perform( + put("/api/v2/admin/gift-mailbox") + .with(user("admin").roles("ADMIN")) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"name":" ","phoneNumber":"01012345678","zipCode":"06234","address":"서울시 강남구"}""") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(false)) + } + @Test @DisplayName("관리자는 전체 선물함 목록을 필터링해 조회한다") fun shouldListAdminGifts() { From 322b07d794e40323aaf038a57fd7d57bb355e7d9 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 1 Oct 2026 22:58:11 +0900 Subject: [PATCH 40/53] =?UTF-8?q?feat(gift):=20=EB=B3=B4=EB=82=B8=20?= =?UTF-8?q?=EC=84=A0=EB=AC=BC=20=EC=83=81=EC=84=B8=EC=97=90=20=EB=B0=9B?= =?UTF-8?q?=EC=9D=84=20=EC=A3=BC=EC=86=8C=EB=A5=BC=20=EB=85=B8=EC=B6=9C?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../v2/gift/application/GiftQueryService.kt | 16 ++++- .../gift/adapter/in/web/GiftControllerTest.kt | 45 +++++++++++++ .../gift/application/GiftQueryServiceTest.kt | 65 ++++++++++++++++++- 3 files changed, 124 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt index f84b6890..8bd5c35b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt @@ -7,6 +7,8 @@ import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailbox +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailboxRepository import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository import kr.co.vividnext.sodalive.v2.gift.domain.GiftDirection @@ -105,6 +107,7 @@ class GiftQueryService( private val giftRepository: GiftRepository, private val deliveryRepository: GiftDeliveryRepository, private val memberRepository: MemberRepository, + private val giftMailboxRepository: GiftMailboxRepository, @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String ) { @@ -186,7 +189,7 @@ class GiftQueryService( ), senderInfo = delivery.toSenderInfo().takeIf { direction == GiftDirection.SENT }, recipientAddress = recipientAddress, - mailbox = null, + mailbox = mailboxFor(direction, gift.status), trackingRequired = direction == GiftDirection.SENT && gift.status == GiftStatus.RECEIVED, recipientAddressRequired = direction == GiftDirection.RECEIVED && recipientAddress == null && gift.status !in TERMINAL_STATUSES, @@ -247,6 +250,17 @@ class GiftQueryService( return GiftAddressResult(name, phoneNumber, formatAddress(zipCode, address, recipientAddressDetail)) } + private fun mailboxFor(direction: GiftDirection, status: GiftStatus): GiftMailboxResult? { + if (direction != GiftDirection.SENT || status != GiftStatus.RECEIVED) return null + return giftMailboxRepository.findByIdOrNull(GiftMailbox.SINGLETON_ID)?.let { + GiftMailboxResult( + name = it.name, + address = formatAddress(it.zipCode, it.address, it.addressDetail), + phoneNumber = it.phoneNumber + ) + } + } + private fun GiftDelivery.tracking() = if (courierCompanyName != null && trackingNumber != null) { "${courierCompanyName}_$trackingNumber" } else { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt index 9bc22f37..e159ca08 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt @@ -14,6 +14,8 @@ import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailbox +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailboxRepository import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePrice import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository @@ -60,6 +62,7 @@ class GiftControllerTest @Autowired constructor( private val giftDeliveryRepository: GiftDeliveryRepository, private val sizePriceRepository: GiftSizePriceRepository, private val memberRepository: MemberRepository, + private val giftMailboxRepository: GiftMailboxRepository, private val objectMapper: ObjectMapper ) { @MockBean @@ -190,6 +193,48 @@ class GiftControllerTest @Autowired constructor( .andExpect(jsonPath("$.data.statusTimeline[1].occurredAt").value("2026-09-30T12:00:00Z")) } + @Test + @DisplayName("발신자는 운송장 등록 전 선물 상세에서 받을 주소를 조회한다") + fun shouldGetMailboxForSentReceivedGiftDetail() { + val sender = memberRepository.save(Member(password = "password", nickname = "fan")) + val recipient = memberRepository.save(Member(password = "password", nickname = "creator")) + val gift = saveGift( + applicationNo = "G-CONTROLLER-MAILBOX", + senderMemberId = sender.id!!, + recipientMemberId = recipient.id!!, + status = GiftStatus.RECEIVED + ) + giftDeliveryRepository.save( + GiftDelivery( + giftId = gift.id!!, + senderName = "홍길동", + senderPhoneNumber = "01012345678", + senderZipCode = "06234", + senderAddress = "서울시 강남구", + trackingDeadlineAt = LocalDateTime.of(2026, 10, 2, 12, 0) + ) + ) + giftMailboxRepository.save( + GiftMailbox( + name = "소다라이브 선물 담당자", + phoneNumber = "01012345678", + zipCode = "06234", + address = "서울시 강남구", + addressDetail = "3층 선물 접수처" + ) + ) + + mockMvc.perform( + get("/api/v2/gifts/{applicationNo}", gift.applicationNo) + .with(user(MemberAdapter(sender))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.mailbox.name").value("소다라이브 선물 담당자")) + .andExpect(jsonPath("$.data.mailbox.phoneNumber").value("01012345678")) + .andExpect(jsonPath("$.data.mailbox.address").value("(06234) 서울시 강남구, 3층 선물 접수처")) + } + @Test @DisplayName("팬은 POST /api/v2/gifts로 선물을 신청하고 접수 결과를 받는다") fun shouldRegisterGift() { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt index 2e4cb51b..c44e8c7b 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt @@ -9,6 +9,8 @@ import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailbox +import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailboxRepository import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePrice import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository @@ -39,7 +41,8 @@ class GiftQueryServiceTest @Autowired constructor( private val sizePriceRepository: GiftSizePriceRepository, private val giftRepository: GiftRepository, private val deliveryRepository: GiftDeliveryRepository, - private val memberRepository: MemberRepository + private val memberRepository: MemberRepository, + private val giftMailboxRepository: GiftMailboxRepository ) { @Test fun `폼 옵션은 활성 카테고리와 활성 사이즈 가격만 반환한다`() { @@ -230,6 +233,65 @@ class GiftQueryServiceTest @Autowired constructor( assertNull(result.statusTimeline[1].occurredAt) } + @Test + @DisplayName("보낸 선물의 운송장 등록 전 상세만 받을 주소를 반환한다") + fun shouldReturnMailboxOnlyForSentReceivedGiftDetail() { + val sender = saveMember("fan") + val recipient = saveMember("creator") + val receivedGift = saveGift( + applicationNo = "G-MAILBOX-RECEIVED", + senderMemberId = sender.id!!, + recipientMemberId = recipient.id!!, + status = GiftStatus.RECEIVED + ) + saveDelivery(receivedGift) + val trackingGift = saveGift( + applicationNo = "G-MAILBOX-TRACKING", + senderMemberId = sender.id!!, + recipientMemberId = recipient.id!!, + status = GiftStatus.TRACKING_REGISTERED + ) + saveDelivery(trackingGift).trackingRegisteredAt = NOW + giftMailboxRepository.save( + GiftMailbox( + name = "소다라이브 선물 담당자", + phoneNumber = "01012345678", + zipCode = "06234", + address = "서울시 강남구", + addressDetail = "3층 선물 접수처" + ) + ) + val service = queryService() + + val receivedResult = service.getGiftDetail(sender.id!!, receivedGift.applicationNo) + val trackingSenderResult = service.getGiftDetail(sender.id!!, trackingGift.applicationNo) + val trackingRecipientResult = service.getGiftDetail(recipient.id!!, trackingGift.applicationNo) + + assertEquals("소다라이브 선물 담당자", receivedResult.mailbox?.name) + assertEquals("01012345678", receivedResult.mailbox?.phoneNumber) + assertEquals("(06234) 서울시 강남구, 3층 선물 접수처", receivedResult.mailbox?.address) + assertNull(trackingSenderResult.mailbox) + assertNull(trackingRecipientResult.mailbox) + } + + @Test + @DisplayName("받을 주소가 없으면 운송장 등록 전 상세도 mailbox가 null이다") + fun shouldReturnNullMailboxWhenMailboxNotConfigured() { + val sender = saveMember("fan") + val recipient = saveMember("creator") + val gift = saveGift( + applicationNo = "G-MAILBOX-EMPTY", + senderMemberId = sender.id!!, + recipientMemberId = recipient.id!!, + status = GiftStatus.RECEIVED + ) + saveDelivery(gift) + + val result = queryService().getGiftDetail(sender.id!!, gift.applicationNo) + + assertNull(result.mailbox) + } + @Test @DisplayName("수신자는 노출된 선물 상세에서 자신의 배송지와 발신자 닉네임만 조회한다") fun shouldReturnReceivedGiftDetailWithoutSenderInformation() { @@ -450,6 +512,7 @@ class GiftQueryServiceTest @Autowired constructor( giftRepository, deliveryRepository, memberRepository, + giftMailboxRepository, "https://cdn.test" ) From 3fb822a0961c747405722ea8cbebf0e712bb0aa0 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 1 Oct 2026 22:58:41 +0900 Subject: [PATCH 41/53] =?UTF-8?q?docs(gift):=20=EC=84=A0=EB=AC=BC=20?= =?UTF-8?q?=EB=B0=9B=EC=9D=84=20=EC=A3=BC=EC=86=8C=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=EC=82=AC=ED=95=AD=EC=9D=84=20=EA=B8=B0=EB=A1=9D=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin-page-prompts.md | 95 +++++++++++++++++-- .../20260929_크리에이터_선물하기/plan-task.md | 42 ++++++++ docs/20260929_크리에이터_선물하기/prd.md | 50 ++++++++-- 3 files changed, 172 insertions(+), 15 deletions(-) diff --git a/docs/20260929_크리에이터_선물하기/admin-page-prompts.md b/docs/20260929_크리에이터_선물하기/admin-page-prompts.md index 22312c5d..9953be7e 100644 --- a/docs/20260929_크리에이터_선물하기/admin-page-prompts.md +++ b/docs/20260929_크리에이터_선물하기/admin-page-prompts.md @@ -1,11 +1,12 @@ # 선물 관리자 페이지 구현 프롬프트 -이 문서는 관리자 페이지의 선물 관련 3개 화면을 프론트엔드 구현 에이전트에게 전달하기 위한 프롬프트 모음이다. +이 문서는 관리자 페이지의 선물 관련 4개 화면을 프론트엔드 구현 에이전트에게 전달하기 위한 프롬프트 모음이다. 공통 전제: -- 관리자 메뉴에는 parent 메뉴 `선물함 관리` 아래에 다음 3개 하위 메뉴가 있다. +- 관리자 메뉴에는 parent 메뉴 `선물함 관리` 아래에 다음 4개 하위 메뉴가 있다. - `선물함 리스트` → `/gift/list` + - `받을 주소` → `/gift/mailbox` - `선물 카테고리` → `/gift/category` - `선물 사이즈` → `/gift/size` - 모든 API 응답은 기존 공통 envelope를 사용한다. 화면에서는 `response.data`를 실제 payload로 사용한다. @@ -237,7 +238,84 @@ type AdminGiftOperationStatusResponse = { --- -## 2. 선물 카테고리 CRUD 페이지 프롬프트 +## 2. 받을 주소 설정 페이지 프롬프트 + +```text +관리자 선물 받을 주소 설정 페이지를 구현해줘. + +목표: +- 관리자 메뉴 `선물함 관리 > 받을 주소`에서 팬이 선물을 보낼 전역 단일 주소를 조회하고 저장한다. +- 이 주소는 팬의 선물 상세 API에서 운송장 등록 전 상태일 때 `mailbox`로 노출된다. +- 기존 관리자 페이지의 폼, 저장 버튼, 토스트, 에러 표시 패턴을 그대로 따른다. + +라우트: +- `/gift/mailbox` + +필수 화면 구성: +- 상단 제목: `받을 주소` +- 설명 문구: `팬이 선물을 발송할 때 확인하는 받을 주소입니다.` +- 입력 폼: + - 받을 사람 이름 `name` + - 연락처 `phoneNumber` + - 우편번호 `zipCode` + - 주소 `address` + - 상세주소 `addressDetail` +- 저장 버튼: `저장` + +초기 조회 API: +- Method: `GET` +- URL: `/api/v2/admin/gift-mailbox` +- response `data`: +```ts +type AdminGiftMailboxResponse = { + name: string; + address: string; + phoneNumber: string; +} | null; +``` +- `data`가 null이면 빈 폼을 표시한다. + +저장 API: +- Method: `PUT` +- URL: `/api/v2/admin/gift-mailbox` +- Request: +```ts +type AdminGiftMailboxRequest = { + name: string; + phoneNumber: string; + zipCode: string; + address: string; + addressDetail: string | null; +}; +``` +- Response `data`: +```ts +type AdminGiftMailboxResponse = { + name: string; + address: string; + phoneNumber: string; +}; +``` + +Validation: +- `name`, `phoneNumber`, `zipCode`, `address`는 필수다. +- 공백만 입력할 수 없다. +- 저장 실패 시 기존 관리자 페이지의 공통 에러 표시 방식을 따른다. + +저장 성공 UX: +- 성공 토스트를 표시한다. +- 저장 API response 기준으로 화면 값을 갱신한다. +- `address`는 서버가 조립한 `(우편번호) 주소, 상세주소` 형식으로 표시해도 되고, 입력 필드는 사용자가 입력한 값을 유지해도 된다. + +테스트/검증: +- 초기 조회 시 null이면 빈 폼을 표시하는지 확인한다. +- 저장 시 PUT body가 정확히 전달되는지 확인한다. +- 필수값 공백 validation을 확인한다. +``` + +--- + +## 3. 선물 카테고리 CRUD 페이지 프롬프트 ```text 관리자 선물 카테고리 CRUD 페이지를 구현해줘. @@ -345,7 +423,7 @@ UX 요구사항: --- -## 3. 선물 사이즈 가격 CRUD 페이지 프롬프트 +## 4. 선물 사이즈 가격 CRUD 페이지 프롬프트 ```text 관리자 선물 사이즈 가격 CRUD 페이지를 구현해줘. @@ -464,7 +542,8 @@ type GiftSize = "SMALL" | "MEDIUM" | "LARGE"; 1. API client 함수와 TypeScript 타입을 먼저 추가한다. 2. `/gift/list` 선물함 리스트와 상세/상태변경 모달을 구현한다. -3. `/gift/category` 카테고리 CRUD를 구현한다. -4. `/gift/size` 사이즈 가격 수정 화면을 구현한다. -5. 기존 관리자 메뉴의 `선물함 관리` 하위 route와 연결되는지 확인한다. -6. 각 페이지별 loading/empty/error/success 상태를 확인한다. +3. `/gift/mailbox` 받을 주소 설정 화면을 구현한다. +4. `/gift/category` 카테고리 CRUD를 구현한다. +5. `/gift/size` 사이즈 가격 수정 화면을 구현한다. +6. 기존 관리자 메뉴의 `선물함 관리` 하위 route와 연결되는지 확인한다. +7. 각 페이지별 loading/empty/error/success 상태를 확인한다. diff --git a/docs/20260929_크리에이터_선물하기/plan-task.md b/docs/20260929_크리에이터_선물하기/plan-task.md index 21c0a614..9b77549c 100644 --- a/docs/20260929_크리에이터_선물하기/plan-task.md +++ b/docs/20260929_크리에이터_선물하기/plan-task.md @@ -1309,6 +1309,48 @@ P7은 FCM 확장 후 전체 상태 전이를 연결하고 최종 회귀로 종 - Gate 확인: Phase 9 focused suite, `./gradlew ktlintCheck`, `git diff --check` 모두 성공 - 남은 항목: 없음 +## Phase 11: 선물 받을 주소 설정 + +**Phase 결과:** 관리자는 전역 단일 선물 받을 주소를 등록/수정하고, 팬은 운송장 등록 전 상세에서 해당 주소를 확인할 수 있다. + +**선행조건:** `P3-GATE`, `P10-GATE` 완료. + +### 구현 항목 + +#### Task 11.1 받을 주소 관리자 설정 API 구현 + +**Goal 실행 `P11-T1`:** 전역 단일 받을 주소 조회/저장 API를 구현한다. + +- **Files:** Create `GiftMailbox.kt`, `GiftMailboxRepository.kt`; Modify `GiftAdminService.kt`, `AdminGiftController.kt`, `AdminGiftRequest.kt`, `AdminGiftResponse.kt`, `gift-schema.sql`; Test `GiftAdminServiceTest.kt`, `AdminGiftControllerTest.kt`. +- [x] **RED:** 설정 미등록 시 `GET /api/v2/admin/gift-mailbox`가 `data=null`을 반환하는 실패 test를 작성한다. +- [x] **RED:** `PUT /api/v2/admin/gift-mailbox`가 전역 단일 주소를 생성하고, 재호출 시 같은 row를 갱신하는 실패 test를 작성한다. +- [x] **RED:** 이름/연락처/우편번호/주소 공백 입력을 거부하는 실패 test를 작성한다. +- [x] **RED 확인:** focused test 실패를 확인한다. +- [x] **GREEN:** 단일 row persistence, 관리자 service, request/response DTO, controller endpoint를 구현한다. +- [x] **GREEN 확인:** focused test 통과를 확인한다. + +#### Task 11.2 선물 상세 받을 주소 노출 구현 + +**Goal 실행 `P11-T2`:** 보낸 선물의 운송장 등록 전 상세에만 받을 주소를 노출한다. + +- **Files:** Modify `GiftQueryService.kt`; Test `GiftQueryServiceTest.kt`, `GiftControllerTest.kt`. +- [x] **RED:** `direction=SENT`이고 `status=RECEIVED`인 상세만 `mailbox`를 반환하는 실패 test를 작성한다. +- [x] **RED:** 받을 주소 미등록 또는 다른 상태/방향이면 `mailbox=null`인 실패 test를 작성한다. +- [x] **RED 확인:** focused test 실패를 확인한다. +- [x] **GREEN:** `GiftQueryService`에서 받을 주소 repository를 조회해 기존 `GiftMailboxResult`로 매핑한다. +- [x] **GREEN 확인:** focused test 통과를 확인한다. + +### Phase 11 Gate + +**Goal 실행 `P11-GATE`:** 관리자 설정과 사용자 상세 노출을 판정한다. + +```bash +./gradlew test --tests "*GiftAdminServiceTest" --tests "*AdminGiftControllerTest" --tests "*GiftQueryServiceTest" --tests "*GiftControllerTest" +./gradlew ktlintCheck +``` + +**Expected:** 받을 주소 미등록/등록/수정, 상세 `mailbox` 노출 조건, 기존 선물 상세 권한 정책이 통과한다. + ## Decision Log | 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 | diff --git a/docs/20260929_크리에이터_선물하기/prd.md b/docs/20260929_크리에이터_선물하기/prd.md index 1d148d04..500753bf 100644 --- a/docs/20260929_크리에이터_선물하기/prd.md +++ b/docs/20260929_크리에이터_선물하기/prd.md @@ -384,8 +384,8 @@ Response: { "applicationNo": "A-1002609300001", "direction": "SENT", - "status": "TRACKING_REGISTERED", - "statusName": "발송 확인", + "status": "RECEIVED", + "statusName": "접수 완료", "giftInfo": { "recipientCreatorNickname": "크리에이터닉네임", "senderNickname": null, @@ -393,7 +393,7 @@ Response: "categoryName": "인형", "applicationNo": "A-1002609300001", "paidCan": 80, - "tracking": "CJ대한통운_1234567890", + "tracking": null, "shippingRequestedAt": null }, "senderInfo": { @@ -403,11 +403,11 @@ Response: }, "recipientAddress": null, "mailbox": { - "name": "소다라이브 사서함", - "address": "구현 후 제공", - "phoneNumber": "구현 후 제공" + "name": "소다라이브 선물 담당자", + "address": "(06234) 서울시 강남구 ..., 3층 선물 접수처", + "phoneNumber": "01012345678" }, - "trackingRequired": false, + "trackingRequired": true, "recipientAddressRequired": false, "recipientAddressDeadlineAt": "2026-10-09T03:00:00Z", "delivery": { @@ -446,6 +446,8 @@ Response: } ``` +`mailbox`는 관리자가 등록한 전역 단일 받을 주소다. 보낸 사람 관점(`direction=SENT`)이고 상태가 운송장 등록 전인 `RECEIVED`일 때만 내려준다. 받을 주소가 등록되지 않았거나 다른 상태/방향이면 `mailbox`는 `null`이다. 주소는 상세 조회 시점의 최신 관리자 설정을 사용한다. + 받는 크리에이터 관점 응답 예시는 다음과 같다. ```json @@ -802,6 +804,40 @@ Response: 크리에이터가 배송지를 아직 입력하지 않은 경우에도 `recipientInfo.nickname`은 내려주고, `recipientInfo.name`, `recipientInfo.phoneNumber`, `recipientInfo.address`는 빈 문자열 `""`로 내려준다. +#### 8.13.3 관리자 선물 받을 주소 설정 API + +선물 받을 주소는 전역 단일 설정이다. 팬이 운송장을 등록하기 전에 보낸 선물 상세에서 이 주소를 확인하고 택배를 발송한다. + +`GET /api/v2/admin/gift-mailbox` + +Response: + +```json +{ + "name": "소다라이브 선물 담당자", + "address": "(06234) 서울시 강남구 ..., 3층 선물 접수처", + "phoneNumber": "01012345678" +} +``` + +설정이 없으면 공통 envelope의 `data`는 `null`이다. + +`PUT /api/v2/admin/gift-mailbox` + +Request: + +```json +{ + "name": "소다라이브 선물 담당자", + "phoneNumber": "01012345678", + "zipCode": "06234", + "address": "서울시 강남구 ...", + "addressDetail": "3층 선물 접수처" +} +``` + +Response는 저장된 받을 주소를 `name`, `address`, `phoneNumber`로 내려준다. `address`는 기존 주소 응답과 동일하게 `(우편번호) 주소, 상세주소` 형식이다. `name`, `phoneNumber`, `zipCode`, `address`는 필수이며 공백만 입력할 수 없다. `addressDetail`은 선택이다. + `availableActions`는 현재 상태별로 다음 값만 내려준다. | 현재 상태 | availableActions | From 77a66ee02ccfbe9b92b1683e871fdf2a26169595 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 2 Oct 2026 11:33:55 +0900 Subject: [PATCH 42/53] =?UTF-8?q?fix(gift):=20=EC=A0=84=EB=8B=AC=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EC=A0=84=20=EC=82=AC=EC=84=9C=ED=95=A8=20?= =?UTF-8?q?=EC=A3=BC=EC=86=8C=EB=A5=BC=20=EB=85=B8=EC=B6=9C=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../v2/gift/application/GiftQueryService.kt | 2 +- .../gift/adapter/in/web/GiftControllerTest.kt | 75 +++++++----- .../gift/application/GiftQueryServiceTest.kt | 109 +++++++++++++----- 3 files changed, 131 insertions(+), 55 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt index 8bd5c35b..8f9ae340 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt @@ -251,7 +251,7 @@ class GiftQueryService( } private fun mailboxFor(direction: GiftDirection, status: GiftStatus): GiftMailboxResult? { - if (direction != GiftDirection.SENT || status != GiftStatus.RECEIVED) return null + if (direction != GiftDirection.SENT || status in TERMINAL_STATUSES) return null return giftMailboxRepository.findByIdOrNull(GiftMailbox.SINGLETON_ID)?.let { GiftMailboxResult( name = it.name, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt index e159ca08..b84856da 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt @@ -194,26 +194,10 @@ class GiftControllerTest @Autowired constructor( } @Test - @DisplayName("발신자는 운송장 등록 전 선물 상세에서 받을 주소를 조회한다") - fun shouldGetMailboxForSentReceivedGiftDetail() { + @DisplayName("발신자는 전달 완료 전 정상 진행 선물 상세에서 받을 주소를 조회한다") + fun shouldGetMailboxForSentNormalProgressGiftDetailBeforeDelivered() { val sender = memberRepository.save(Member(password = "password", nickname = "fan")) val recipient = memberRepository.save(Member(password = "password", nickname = "creator")) - val gift = saveGift( - applicationNo = "G-CONTROLLER-MAILBOX", - senderMemberId = sender.id!!, - recipientMemberId = recipient.id!!, - status = GiftStatus.RECEIVED - ) - giftDeliveryRepository.save( - GiftDelivery( - giftId = gift.id!!, - senderName = "홍길동", - senderPhoneNumber = "01012345678", - senderZipCode = "06234", - senderAddress = "서울시 강남구", - trackingDeadlineAt = LocalDateTime.of(2026, 10, 2, 12, 0) - ) - ) giftMailboxRepository.save( GiftMailbox( name = "소다라이브 선물 담당자", @@ -223,16 +207,41 @@ class GiftControllerTest @Autowired constructor( addressDetail = "3층 선물 접수처" ) ) - - mockMvc.perform( - get("/api/v2/gifts/{applicationNo}", gift.applicationNo) - .with(user(MemberAdapter(sender))) + val statuses = listOf( + GiftStatus.RECEIVED, + GiftStatus.TRACKING_REGISTERED, + GiftStatus.ARRIVED_AT_MAILBOX, + GiftStatus.INSPECTION_COMPLETED ) - .andExpect(status().isOk) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.mailbox.name").value("소다라이브 선물 담당자")) - .andExpect(jsonPath("$.data.mailbox.phoneNumber").value("01012345678")) - .andExpect(jsonPath("$.data.mailbox.address").value("(06234) 서울시 강남구, 3층 선물 접수처")) + + statuses.forEachIndexed { index, giftStatus -> + val gift = saveGift( + applicationNo = "G-C-MBOX-$index", + senderMemberId = sender.id!!, + recipientMemberId = recipient.id!!, + status = giftStatus + ) + giftDeliveryRepository.save( + GiftDelivery( + giftId = gift.id!!, + senderName = "홍길동", + senderPhoneNumber = "01012345678", + senderZipCode = "06234", + senderAddress = "서울시 강남구", + trackingDeadlineAt = LocalDateTime.of(2026, 10, 2, 12, 0) + ) + ).applyStatusTimes(giftStatus) + + mockMvc.perform( + get("/api/v2/gifts/{applicationNo}", gift.applicationNo) + .with(user(MemberAdapter(sender))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.mailbox.name").value("소다라이브 선물 담당자")) + .andExpect(jsonPath("$.data.mailbox.phoneNumber").value("01012345678")) + .andExpect(jsonPath("$.data.mailbox.address").value("(06234) 서울시 강남구, 3층 선물 접수처")) + } } @Test @@ -508,6 +517,18 @@ class GiftControllerTest @Autowired constructor( isActive = isActive ) + private fun GiftDelivery.applyStatusTimes(status: GiftStatus) { + if (status.ordinal >= GiftStatus.TRACKING_REGISTERED.ordinal) { + trackingRegisteredAt = LocalDateTime.of(2026, 9, 30, 12, 0) + } + if (status.ordinal >= GiftStatus.ARRIVED_AT_MAILBOX.ordinal) { + arrivedAtMailboxAt = LocalDateTime.of(2026, 10, 1, 12, 0) + } + if (status.ordinal >= GiftStatus.INSPECTION_COMPLETED.ordinal) { + inspectionCompletedAt = LocalDateTime.of(2026, 10, 2, 12, 0) + } + } + private companion object { const val SENDER_ID = 1L const val RECIPIENT_ID = 2L diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt index c44e8c7b..6eef5e99 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt @@ -234,24 +234,10 @@ class GiftQueryServiceTest @Autowired constructor( } @Test - @DisplayName("보낸 선물의 운송장 등록 전 상세만 받을 주소를 반환한다") - fun shouldReturnMailboxOnlyForSentReceivedGiftDetail() { + @DisplayName("보낸 선물의 전달 완료 전 정상 진행 상세는 받을 주소를 반환한다") + fun shouldReturnMailboxForSentNormalProgressGiftDetailBeforeDelivered() { val sender = saveMember("fan") val recipient = saveMember("creator") - val receivedGift = saveGift( - applicationNo = "G-MAILBOX-RECEIVED", - senderMemberId = sender.id!!, - recipientMemberId = recipient.id!!, - status = GiftStatus.RECEIVED - ) - saveDelivery(receivedGift) - val trackingGift = saveGift( - applicationNo = "G-MAILBOX-TRACKING", - senderMemberId = sender.id!!, - recipientMemberId = recipient.id!!, - status = GiftStatus.TRACKING_REGISTERED - ) - saveDelivery(trackingGift).trackingRegisteredAt = NOW giftMailboxRepository.save( GiftMailbox( name = "소다라이브 선물 담당자", @@ -262,20 +248,77 @@ class GiftQueryServiceTest @Autowired constructor( ) ) val service = queryService() + val allowedStatuses = listOf( + GiftStatus.RECEIVED, + GiftStatus.TRACKING_REGISTERED, + GiftStatus.ARRIVED_AT_MAILBOX, + GiftStatus.INSPECTION_COMPLETED + ) - val receivedResult = service.getGiftDetail(sender.id!!, receivedGift.applicationNo) - val trackingSenderResult = service.getGiftDetail(sender.id!!, trackingGift.applicationNo) - val trackingRecipientResult = service.getGiftDetail(recipient.id!!, trackingGift.applicationNo) + allowedStatuses.forEachIndexed { index, status -> + val gift = saveGift( + applicationNo = "G-MAILBOX-ALLOWED-$index", + senderMemberId = sender.id!!, + recipientMemberId = recipient.id!!, + status = status + ) + saveDelivery(gift).applyStatusTimes(status) - assertEquals("소다라이브 선물 담당자", receivedResult.mailbox?.name) - assertEquals("01012345678", receivedResult.mailbox?.phoneNumber) - assertEquals("(06234) 서울시 강남구, 3층 선물 접수처", receivedResult.mailbox?.address) - assertNull(trackingSenderResult.mailbox) - assertNull(trackingRecipientResult.mailbox) + val result = service.getGiftDetail(sender.id!!, gift.applicationNo) + + assertEquals("소다라이브 선물 담당자", result.mailbox?.name) + assertEquals("01012345678", result.mailbox?.phoneNumber) + assertEquals("(06234) 서울시 강남구, 3층 선물 접수처", result.mailbox?.address) + assertEquals(status == GiftStatus.RECEIVED, result.trackingRequired) + } } @Test - @DisplayName("받을 주소가 없으면 운송장 등록 전 상세도 mailbox가 null이다") + @DisplayName("보낸 선물의 종료 상태와 받은 선물 상세는 받을 주소를 반환하지 않는다") + fun shouldReturnNullMailboxForTerminalOrReceivedGiftDetail() { + val sender = saveMember("fan") + val recipient = saveMember("creator") + giftMailboxRepository.save( + GiftMailbox( + name = "소다라이브 선물 담당자", + phoneNumber = "01012345678", + zipCode = "06234", + address = "서울시 강남구", + addressDetail = "3층 선물 접수처" + ) + ) + val service = queryService() + val terminalStatuses = listOf(GiftStatus.DELIVERED, GiftStatus.UNDELIVERABLE, GiftStatus.CANCELED) + + terminalStatuses.forEachIndexed { index, status -> + val gift = saveGift( + applicationNo = "G-MAILBOX-TERMINAL-$index", + senderMemberId = sender.id!!, + recipientMemberId = recipient.id!!, + status = status + ) + saveDelivery(gift).applyStatusTimes(status) + + val result = service.getGiftDetail(sender.id!!, gift.applicationNo) + + assertNull(result.mailbox) + } + + val receivedGift = saveGift( + applicationNo = "G-MAILBOX-RECIPIENT", + senderMemberId = sender.id!!, + recipientMemberId = recipient.id!!, + status = GiftStatus.TRACKING_REGISTERED + ) + saveDelivery(receivedGift).applyStatusTimes(GiftStatus.TRACKING_REGISTERED) + + val recipientResult = service.getGiftDetail(recipient.id!!, receivedGift.applicationNo) + + assertNull(recipientResult.mailbox) + } + + @Test + @DisplayName("받을 주소가 없으면 전달 완료 전 정상 진행 상세도 mailbox가 null이다") fun shouldReturnNullMailboxWhenMailboxNotConfigured() { val sender = saveMember("fan") val recipient = saveMember("creator") @@ -283,9 +326,9 @@ class GiftQueryServiceTest @Autowired constructor( applicationNo = "G-MAILBOX-EMPTY", senderMemberId = sender.id!!, recipientMemberId = recipient.id!!, - status = GiftStatus.RECEIVED + status = GiftStatus.INSPECTION_COMPLETED ) - saveDelivery(gift) + saveDelivery(gift).applyStatusTimes(GiftStatus.INSPECTION_COMPLETED) val result = queryService().getGiftDetail(sender.id!!, gift.applicationNo) @@ -506,6 +549,18 @@ class GiftQueryServiceTest @Autowired constructor( ) ) + private fun GiftDelivery.applyStatusTimes(status: GiftStatus) { + if (status.ordinal >= GiftStatus.TRACKING_REGISTERED.ordinal) trackingRegisteredAt = NOW.plusHours(1) + if (status.ordinal >= GiftStatus.ARRIVED_AT_MAILBOX.ordinal) arrivedAtMailboxAt = NOW.plusHours(2) + if (status.ordinal >= GiftStatus.INSPECTION_COMPLETED.ordinal) inspectionCompletedAt = NOW.plusHours(3) + if (status == GiftStatus.DELIVERED) deliveredAt = NOW.plusHours(4) + if (status == GiftStatus.UNDELIVERABLE) { + undeliverableAt = NOW.plusHours(4) + undeliverableReason = "검수 실패" + } + if (status == GiftStatus.CANCELED) canceledAt = NOW.plusHours(1) + } + private fun queryService() = GiftQueryService( categoryRepository, sizePriceRepository, From 31406e7f0a84f0334cf9bc499b7b0d25565f5fa1 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 2 Oct 2026 11:34:05 +0900 Subject: [PATCH 43/53] =?UTF-8?q?docs(gift):=20=EC=82=AC=EC=84=9C=ED=95=A8?= =?UTF-8?q?=20=EB=85=B8=EC=B6=9C=20=EC=A1=B0=EA=B1=B4=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=EB=A5=BC=20=EA=B0=B1=EC=8B=A0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../client-api-summary.md | 4 ++++ .../20260929_크리에이터_선물하기/plan-task.md | 19 +++++++++++++++---- docs/20260929_크리에이터_선물하기/prd.md | 7 ++++--- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/docs/20260929_크리에이터_선물하기/client-api-summary.md b/docs/20260929_크리에이터_선물하기/client-api-summary.md index 2031c594..ca271522 100644 --- a/docs/20260929_크리에이터_선물하기/client-api-summary.md +++ b/docs/20260929_크리에이터_선물하기/client-api-summary.md @@ -187,6 +187,10 @@ data class GiftMailboxResponse( val phoneNumber: String ) +// mailbox는 direction=SENT이고 status가 RECEIVED, TRACKING_REGISTERED, +// ARRIVED_AT_MAILBOX, INSPECTION_COMPLETED일 때만 값이 있다. +// DELIVERED, UNDELIVERABLE, CANCELED 또는 direction=RECEIVED이면 null이다. + data class GiftDeliveryInfoResponse( val canceledAt: String?, val undeliverableAt: String?, diff --git a/docs/20260929_크리에이터_선물하기/plan-task.md b/docs/20260929_크리에이터_선물하기/plan-task.md index 9b77549c..d0682ed6 100644 --- a/docs/20260929_크리에이터_선물하기/plan-task.md +++ b/docs/20260929_크리에이터_선물하기/plan-task.md @@ -1311,7 +1311,7 @@ P7은 FCM 확장 후 전체 상태 전이를 연결하고 최종 회귀로 종 ## Phase 11: 선물 받을 주소 설정 -**Phase 결과:** 관리자는 전역 단일 선물 받을 주소를 등록/수정하고, 팬은 운송장 등록 전 상세에서 해당 주소를 확인할 수 있다. +**Phase 결과:** 관리자는 전역 단일 선물 받을 주소를 등록/수정하고, 팬은 보낸 선물의 전달 완료 전 정상 진행 상세에서 해당 주소를 확인할 수 있다. **선행조건:** `P3-GATE`, `P10-GATE` 완료. @@ -1331,11 +1331,11 @@ P7은 FCM 확장 후 전체 상태 전이를 연결하고 최종 회귀로 종 #### Task 11.2 선물 상세 받을 주소 노출 구현 -**Goal 실행 `P11-T2`:** 보낸 선물의 운송장 등록 전 상세에만 받을 주소를 노출한다. +**Goal 실행 `P11-T2`:** 보낸 선물의 전달 완료 전 정상 진행 상세에만 받을 주소를 노출한다. - **Files:** Modify `GiftQueryService.kt`; Test `GiftQueryServiceTest.kt`, `GiftControllerTest.kt`. -- [x] **RED:** `direction=SENT`이고 `status=RECEIVED`인 상세만 `mailbox`를 반환하는 실패 test를 작성한다. -- [x] **RED:** 받을 주소 미등록 또는 다른 상태/방향이면 `mailbox=null`인 실패 test를 작성한다. +- [x] **RED:** `direction=SENT`이고 `status`가 `RECEIVED`, `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED`인 상세에서 `mailbox`를 반환하는 실패 test를 작성한다. +- [x] **RED:** 받을 주소 미등록, 종료 상태(`DELIVERED`, `UNDELIVERABLE`, `CANCELED`), 또는 다른 방향이면 `mailbox=null`인 실패 test를 작성한다. - [x] **RED 확인:** focused test 실패를 확인한다. - [x] **GREEN:** `GiftQueryService`에서 받을 주소 repository를 조회해 기존 `GiftMailboxResult`로 매핑한다. - [x] **GREEN 확인:** focused test 통과를 확인한다. @@ -1351,6 +1351,17 @@ P7은 FCM 확장 후 전체 상태 전이를 연결하고 최종 회귀로 종 **Expected:** 받을 주소 미등록/등록/수정, 상세 `mailbox` 노출 조건, 기존 선물 상세 권한 정책이 통과한다. +### P11-T2 선물 상세 받을 주소 노출 조건 확장 — 2026-10-02 + +- 상태: 완료 +- 무엇을: 보낸 선물 상세의 `mailbox` 노출 조건을 `RECEIVED` 단일 상태에서 정상 진행 상태 중 `DELIVERED` 전(`RECEIVED`, `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED`)으로 확장했다. +- 왜: 팬이 운송장을 등록한 뒤에도 전달 완료 전까지 관리자 전역 선물 받을 주소를 상세에서 확인할 수 있어야 하기 때문이다. +- 어떻게: + - RED 확인: `GiftQueryServiceTest`, `GiftControllerTest`에 정상 진행 상태/종료 상태/받은 선물 방향 검증을 추가하고 기존 구현에서 focused test 실패를 확인했다. + - GREEN 구현: `GiftQueryService.mailboxFor` 조건을 `direction=SENT`이고 종료 상태가 아닌 경우로 단순화했다. + - 검증: `./gradlew test --tests '*GiftQueryServiceTest' --tests '*GiftControllerTest'`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.gift.*' --tests 'kr.co.vividnext.sodalive.v2.api.gift.*'`, `./gradlew ktlintCheck`, `git diff --check` 실행 결과 통과. +- 남은 항목: 없음 + ## Decision Log | 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 | diff --git a/docs/20260929_크리에이터_선물하기/prd.md b/docs/20260929_크리에이터_선물하기/prd.md index 500753bf..624672ce 100644 --- a/docs/20260929_크리에이터_선물하기/prd.md +++ b/docs/20260929_크리에이터_선물하기/prd.md @@ -186,6 +186,7 @@ INSPECTION_COMPLETED -> UNDELIVERABLE - 로그인 회원이 받는 크리에이터이면 보내는 팬의 닉네임만 알 수 있고, 팬이 신청 시 등록한 이름/휴대폰 번호/주소는 알 수 없다. - 로그인 회원이 보내는 팬이면 받는 크리에이터의 닉네임만 알 수 있고, 크리에이터가 입력한 이름/휴대폰 번호/주소는 알 수 없다. - 보내는 팬 상세에는 팬 본인이 신청 시 등록한 이름, 휴대폰 번호, 주소를 내려준다. +- 보내는 팬 상세에는 관리자가 등록한 전역 선물 받을 주소를 `mailbox`로 내려준다. 단, 정상 진행 상태 중 `DELIVERED` 전 상태(`RECEIVED`, `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED`)에서만 내려준다. - 받는 크리에이터 상세에는 크리에이터 본인이 입력한 받는 주소의 이름, 휴대폰 번호, 주소를 내려준다. - 주소 형식은 `(우편번호) 주소, 상세주소` 문자열로 내려준다. - 로그인 회원이 알아야 하는 데이터가 아니거나 아직 입력되지 않은 데이터는 `null`로 내려준다. @@ -446,7 +447,7 @@ Response: } ``` -`mailbox`는 관리자가 등록한 전역 단일 받을 주소다. 보낸 사람 관점(`direction=SENT`)이고 상태가 운송장 등록 전인 `RECEIVED`일 때만 내려준다. 받을 주소가 등록되지 않았거나 다른 상태/방향이면 `mailbox`는 `null`이다. 주소는 상세 조회 시점의 최신 관리자 설정을 사용한다. +`mailbox`는 관리자가 등록한 전역 단일 받을 주소다. 보낸 사람 관점(`direction=SENT`)이고 정상 진행 상태 중 `DELIVERED` 전 상태(`RECEIVED`, `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED`)일 때 내려준다. 받을 주소가 등록되지 않았거나, 다른 방향이거나, `DELIVERED`/`UNDELIVERABLE`/`CANCELED` 상태이면 `mailbox`는 `null`이다. 주소는 상세 조회 시점의 최신 관리자 설정을 사용한다. 받는 크리에이터 관점 응답 예시는 다음과 같다. @@ -516,7 +517,7 @@ Response: `direction=SENT`이면 보내는 팬 관점의 상세 응답이다. `giftInfo.recipientCreatorNickname`에는 받는 크리에이터 닉네임을 내려주고, `giftInfo.senderNickname`, `giftInfo.shippingRequestedAt`, `recipientAddress`는 `null`이다. `senderInfo`에는 팬 본인이 신청 시 등록한 이름, 휴대폰 번호, 주소를 내려준다. -`mailbox`는 코드상에서 정한 사서함 이름, 주소, 연락처를 내려준다. 구현 후 값이 제공되면 이 문서를 갱신한다. +`mailbox`는 관리자가 등록한 전역 단일 받을 주소의 이름, 주소, 연락처를 내려준다. 보낸 사람 관점의 정상 진행 상태 중 `DELIVERED` 전 상태에서만 값이 있고, 그 외에는 `null`이다. `direction=SENT`이고 현재 상태가 `RECEIVED`이면 `trackingRequired=true`이며, 팬 클라이언트는 운송장 등록 CTA를 표시한다. `direction=RECEIVED`이면 받는 크리에이터 관점의 상세 응답이다. `giftInfo.senderNickname`에는 발송인인 팬 닉네임을 내려주고, `giftInfo.recipientCreatorNickname`, `giftInfo.applicationNo`, `giftInfo.paidCan`, `giftInfo.tracking`, `senderInfo`, `mailbox`는 `null`이다. @@ -806,7 +807,7 @@ Response: #### 8.13.3 관리자 선물 받을 주소 설정 API -선물 받을 주소는 전역 단일 설정이다. 팬이 운송장을 등록하기 전에 보낸 선물 상세에서 이 주소를 확인하고 택배를 발송한다. +선물 받을 주소는 전역 단일 설정이다. 팬은 정상 진행 상태 중 `DELIVERED` 전인 보낸 선물 상세에서 이 주소를 확인할 수 있다. `GET /api/v2/admin/gift-mailbox` From 978206438e5f9702785c2d93c3f2799be60bccb8 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 2 Oct 2026 11:34:16 +0900 Subject: [PATCH 44/53] =?UTF-8?q?docs(gift):=20=EC=84=A0=EB=AC=BC=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EA=B5=AC=ED=98=84=20=ED=94=84=EB=A1=AC?= =?UTF-8?q?=ED=94=84=ED=8A=B8=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin-page-prompts.md | 2 +- .../gift-detail-tracking-page-prompt.md | 433 ++++++++++++++++++ .../gift-mailbox-admin-page-prompt.md | 114 +++++ 3 files changed, 548 insertions(+), 1 deletion(-) create mode 100644 docs/20260929_크리에이터_선물하기/gift-detail-tracking-page-prompt.md create mode 100644 docs/20260929_크리에이터_선물하기/gift-mailbox-admin-page-prompt.md diff --git a/docs/20260929_크리에이터_선물하기/admin-page-prompts.md b/docs/20260929_크리에이터_선물하기/admin-page-prompts.md index 9953be7e..cc2f1b66 100644 --- a/docs/20260929_크리에이터_선물하기/admin-page-prompts.md +++ b/docs/20260929_크리에이터_선물하기/admin-page-prompts.md @@ -245,7 +245,7 @@ type AdminGiftOperationStatusResponse = { 목표: - 관리자 메뉴 `선물함 관리 > 받을 주소`에서 팬이 선물을 보낼 전역 단일 주소를 조회하고 저장한다. -- 이 주소는 팬의 선물 상세 API에서 운송장 등록 전 상태일 때 `mailbox`로 노출된다. +- 이 주소는 팬의 보낸 선물 상세 API에서 정상 진행 상태 중 전달 완료 전(`RECEIVED`, `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED`)일 때 `mailbox`로 노출된다. - 기존 관리자 페이지의 폼, 저장 버튼, 토스트, 에러 표시 패턴을 그대로 따른다. 라우트: diff --git a/docs/20260929_크리에이터_선물하기/gift-detail-tracking-page-prompt.md b/docs/20260929_크리에이터_선물하기/gift-detail-tracking-page-prompt.md new file mode 100644 index 00000000..127baf12 --- /dev/null +++ b/docs/20260929_크리에이터_선물하기/gift-detail-tracking-page-prompt.md @@ -0,0 +1,433 @@ +# 선물 상세 및 운송장 등록 페이지 생성 프롬프트 + +이 문서는 Android/iOS 앱의 `선물함` 리스트에서 진입하는 선물 상세 페이지와 운송장 등록 페이지를 생성할 때 사용하는 프롬프트다. + +참고 Figma: + +- 선물 보내기 신청 상세 운송장 등록 전 UI: `2481:19011` +- 선물 보내기 신청 상세 운송장 등록 후 UI: `2481:19152` +- 선물 보내기 신청 상세 전달 완료 UI: `2481:19320` +- 전달 불가 UI: `2481:19456` +- 취소 버튼 다이얼로그 UI: `2481:19500` +- 취소 완료 페이지 UI: `2481:19521` +- 운송장 등록 UI: `2481:19066` +- 운송장 정보를 채우고 난 후 UI: `2481:19109` + +전제: + +- 선물함 리스트 item 탭 시 `applicationNo`를 전달받아 상세 페이지로 이동한다. +- 새 디자인 시스템을 만들지 말고 Android/iOS 각각의 기존 디자인 시스템, 컴포넌트, API client, 상태 관리, toast/dialog, navigation 패턴을 재사용한다. +- 모든 API 응답은 공통 envelope를 사용하며 실제 payload는 `response.data`다. +- Figma의 진행 상태는 4단계처럼 보이지만 실제 구현은 서버의 `statusTimeline` 5개 상태를 모두 사용한다. +- 운송장 등록의 택배사 선택지는 서버 API가 없으므로 앱 로컬 상수로 관리한다. +- 취소 확인 팝업은 앱의 `v2 dialog` 컴포넌트를 기본으로 사용한다. + +--- + +## 모바일 앱 구현 프롬프트 + +```text +Android/iOS 앱의 선물 상세 페이지와 운송장 등록 페이지를 Figma 기준으로 생성하고 API 연동을 구현해줘. + +범위: +- 선물함 리스트에서 `applicationNo`를 받아 선물 상세 페이지로 이동한다. +- 상세 페이지는 `GET /api/v2/gifts/{applicationNo}` 응답으로 렌더링한다. +- `trackingRequired=true`인 보낸 선물 상세에서는 운송장 등록 CTA를 표시하고 운송장 등록 페이지로 이동한다. +- 운송장 등록 페이지는 같은 상세 API를 조회해 신청 요약과 보낼 주소를 표시한 뒤 `POST /api/v2/gifts/{applicationNo}/tracking`으로 운송장 정보를 등록한다. + +중요 전제: +- Figma의 visual structure와 문구를 우선 따르되, 상태 개수와 데이터 노출은 API 계약을 우선한다. +- 상세 진행 단계는 Figma처럼 4단계로 고정하지 말고 API `statusTimeline`의 5개 정상 진행 상태를 모두 렌더링한다. +- 택배사 목록은 API로 조회하지 않는다. 앱 로컬 상수로 관리하고 선택한 한글 표시명을 `courierCompanyName`으로 전송한다. +- 운송장 등록 완료 후 수정 기능은 만들지 않는다. + +상세 페이지 진입: +- 선물함 리스트 item에서 받은 `applicationNo`로 진입한다. +- 화면 진입 시 `GET /api/v2/gifts/{applicationNo}`를 호출한다. +- 조회 결과 `status=CANCELED`이면 일반 신청 상세 화면을 렌더링하지 말고 취소 완료 페이지 UI를 보여준다. +- 로딩 중에는 기존 앱 상세 화면 로딩/스켈레톤 패턴을 사용한다. +- 실패 시 기존 앱 toast/dialog와 재시도 패턴을 사용한다. + +상세 페이지 공통 레이아웃: +1. 상단 고정 내비게이션 + - 타이틀: `신청 상세` + - 뒤로가기 버튼은 기존 앱 패턴을 사용한다. +2. 스크롤 콘텐츠 + - 배송 상태 hero + - 진행 상태 progress + - 상태별 action button 영역 + - 선물 정보 + - 필요한 경우 보내는 사람, 받는 사서함, 사유 섹션 +3. 하단 safe area + - 기존 앱 패턴을 따른다. + +상태별 hero 문구: +| status | chip | title | body | +|---|---|---|---| +| `RECEIVED` | 없음 또는 `접수 완료` | `선물 신청 완료!` | `선물이 정상적으로 접수되었어요!` | +| `TRACKING_REGISTERED` | `발송 확인` | `사서함으로 이동 중이에요` | 없음 | +| `ARRIVED_AT_MAILBOX` | `사서함 도착` | `선물이 사서함에 도착했어요` | 없음 | +| `INSPECTION_COMPLETED` | `검수 완료` | `선물 검수를 완료했어요` | 없음 | +| `DELIVERED` | `전달 완료` | `크리에이터에게 선물을 전달했어요` | 없음 | +| `UNDELIVERABLE` | `전달 불가` | `전달할 수 없는 품목입니다` | `보내주신 선물은 SODALIVE 선물 정책에 따라 크리에이터에게 전달할 수 없는 품목으로 확인되었습니다.` | +| `CANCELED` | `신청 취소` | `선물 신청이 취소되었어요` | `사용한 캔은 환불 처리됩니다.` | + +진행 상태 progress: +- `statusTimeline` 배열을 그대로 사용한다. +- 항상 아래 5개 단계를 이 순서로 표시한다. + 1. `RECEIVED`: `신청 접수` + 2. `TRACKING_REGISTERED`: `발송 확인` + 3. `ARRIVED_AT_MAILBOX`: `사서함 도착` + 4. `INSPECTION_COMPLETED`: `검수 완료` + 5. `DELIVERED`: `전달 완료` +- `occurredAt`이 있으면 해당 단계는 완료 색상으로 표시하고 `MM.DD`를 표시한다. +- `occurredAt`이 null이면 비활성 색상으로 표시하고 날짜는 표시하지 않는다. +- `UNDELIVERABLE`, `CANCELED`는 정상 진행 단계가 아니므로 progress step으로 추가하지 않는다. +- 종료 상태에서도 `statusTimeline`에 있는 정상 진행 이력은 그대로 표시한다. + +상세 페이지 섹션 표시 규칙: +- `선물 정보`는 항상 표시한다. +- `direction=SENT`이면 크리에이터명은 `giftInfo.recipientCreatorNickname`을 사용한다. +- `direction=RECEIVED`이면 보낸 팬명은 `giftInfo.senderNickname`을 사용한다. +- `senderInfo`가 있으면 `보내는 사람` 섹션을 표시한다. +- `mailbox`가 있으면 `받는 사서함` 또는 `보내실 주소` 섹션을 표시한다. +- `recipientAddress`가 있으면 `받는 사람` 또는 `배송지` 섹션을 표시한다. +- `delivery.undeliverableReason`이 있으면 `사유` 섹션을 표시한다. + +선물 정보 표시 필드: +- 크리에이터 또는 보낸 팬 닉네임 +- 선물 사이즈: `giftInfo.sizeName` +- 카테고리: `giftInfo.categoryName` +- 신청번호: `applicationNo` 또는 `giftInfo.applicationNo` +- 캔: `giftInfo.paidCan`이 null이 아닐 때만 표시 +- 운송장 번호: `giftInfo.tracking`이 null이 아닐 때 표시 +- 검수일: `statusTimeline`에서 `INSPECTION_COMPLETED.occurredAt`이 있으면 표시 +- 전달 완료일: `statusTimeline`에서 `DELIVERED.occurredAt`이 있으면 표시 +- 취소일: `delivery.canceledAt`이 있으면 표시 + +받는 사서함/보내실 주소 표시: +- `mailbox.name`을 사서함/받는 분으로 표시한다. +- `mailbox.address`를 주소로 표시한다. +- `mailbox.phoneNumber`를 연락처로 표시한다. +- `mailbox`는 관리자가 등록한 전역 단일 주소다. 상대방 주소가 아니다. +- `mailbox`는 `direction=SENT`이고 `status`가 `RECEIVED`, `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED`일 때 내려온다. +- `DELIVERED`, `UNDELIVERABLE`, `CANCELED` 또는 `direction=RECEIVED`에서는 `mailbox=null`이다. +- `mailbox=null`이면 해당 섹션을 표시하지 않는다. + +보내는 사람 표시: +- `senderInfo.name` +- `senderInfo.phoneNumber` +- `senderInfo.address` +- 이 정보는 보낸 팬 본인이 신청 시 입력한 정보다. + +전달 불가 표시: +- `status=UNDELIVERABLE`이면 Figma `2481:19456` 패턴을 따른다. +- 사유 섹션의 제목은 `delivery.undeliverableReason`을 사용한다. +- `delivery.undeliverableReason`이 null이면 사유 섹션 제목은 `전달 불가`로 표시한다. +- 안내 문구는 `해당 선물은 정책에 따라 폐기되며 반송되지 않습니다.`를 사용한다. + +상세 페이지 action: +- `trackingRequired=true`이면 하단 주요 CTA `운송장 작성하기`를 표시하고 운송장 등록 페이지로 이동한다. +- `direction=SENT && status=RECEIVED`이면 보조 action `신청 취소`를 표시할 수 있다. +- 신청 취소 버튼을 누르면 즉시 API를 호출하지 말고 `v2 dialog` 확인 팝업을 먼저 표시한다. +- `status=DELIVERED && direction=SENT`이면 `리뷰 남기기` 버튼을 표시하고 기존 리뷰 작성 화면/플로우로 이동한다. +- `문의하기`, `선물 정책 확인` 버튼은 기존 앱에 해당 이동 경로가 있으면 연결하고, 없으면 기존 정책에 맞춰 숨기거나 비활성 처리한다. + +신청 취소 확인 다이얼로그: +- Figma `2481:19500`을 참고한다. +- 앱의 기본 `v2 dialog` 컴포넌트를 사용한다. 새 dialog 컴포넌트를 만들지 않는다. +- 제목: `신청 취소` +- 본문: + - `{크리에이터}에게 보내는 선물을 취소할까요?` + - `선물 보내기 규정에 따라 선물 취소 및 사용한 캔이 모두 환불됩니다.` +- 왼쪽 action: `나가기` + - dialog만 닫고 상세/운송장 등록 화면 상태를 유지한다. +- 오른쪽 destructive action: `취소하기` + - `POST /api/v2/gifts/{applicationNo}/cancel`을 호출한다. + - 호출 중에는 중복 탭을 막고 loading 상태를 표시한다. + - 실패 시 dialog를 닫지 말고 기존 앱 에러 toast/dialog 패턴으로 안내한다. + - 성공 시 취소 완료 페이지로 replace navigation 한다. + +취소 완료 페이지: +- Figma `2481:19521`을 참고한다. +- 취소 API 성공 직후뿐 아니라 상세 페이지 접근 시 `GET /api/v2/gifts/{applicationNo}` 응답이 `status=CANCELED`인 경우에도 이 화면을 보여준다. +- 상단 타이틀: `취소 완료` +- hero chip: `취소 완료` +- hero title: `선물 신청이 취소되었어요` +- hero body: `{크리에이터}에게 보내는 선물 신청({applicationNo})이 취소되었습니다. 취소한 신청은 되돌릴 수 없으며, 다시 보내려면 새로 신청해 주세요.` +- 섹션 제목: `취소 상세` +- 취소 상세 카드 표시 필드: + - 크리에이터: `giftInfo.recipientCreatorNickname` + - 선물 사이즈: `giftInfo.sizeName` + - 카테고리: `giftInfo.categoryName` + - 신청번호: `applicationNo` + - 신청일: `statusTimeline`의 `RECEIVED.occurredAt` + - 취소일: 취소 API response의 `canceledAt` 또는 상세 API 조회 시 `delivery.canceledAt` + - 환불 캔: 취소 API response의 `priceCan` 또는 상세 API의 `giftInfo.paidCan` +- 하단 또는 카드 아래에 `문의하기` 버튼을 표시한다. +- 뒤로가기 동작은 취소 전 상세/운송장 등록 화면으로 돌아가지 않게 한다. 기존 앱 navigation 정책에 맞춰 선물함 리스트 또는 이전 안전 화면으로 이동한다. + +운송장 등록 페이지 진입: +- 상세 페이지에서 `trackingRequired=true`일 때만 진입시킨다. +- 진입 시 `GET /api/v2/gifts/{applicationNo}`를 다시 조회한다. +- 조회 결과에서 `trackingRequired=false`이면 이미 등록 불가 상태이므로 기존 오류 처리 후 상세 페이지로 돌아간다. + +운송장 등록 페이지 레이아웃: +1. 상단 고정 내비게이션 + - 타이틀: `운송장 등록` +2. 신청 건 요약 카드 + - `applicationNo` + - 크리에이터 닉네임: `giftInfo.recipientCreatorNickname` + - `giftInfo.sizeName · giftInfo.categoryName · 신청일` + - 신청일은 `statusTimeline`의 `RECEIVED.occurredAt`을 `YYYY.MM.DD`로 표시한다. +3. 보내실 주소 + - `mailbox`를 사용한다. + - `mailbox=null`이면 운송장 등록을 막고 `받을 주소가 등록되어 있지 않습니다.` 오류를 표시한다. +4. 운송장 정보 + - 택배사 select + - 운송장 번호 input +5. 등록 안내 + - Figma의 안내 문구를 유지한다. +6. 하단 action bar + - 왼쪽: `신청 취소` + - 오른쪽: `등록 완료` + +택배사 로컬 상수: +- 서버에서 택배사 옵션을 조회하지 않는다. +- 앱에 아래 로컬 옵션을 둔다. 이미 앱 공통 택배사 상수가 있으면 그것을 우선 사용한다. +```ts +const giftCourierCompanyNames = [ + "CJ대한통운", + "우체국택배", + "GS25 편의점택배", + "CU 편의점택배", + "한진택배", + "롯데택배", + "로젠택배", + "경동택배", + "대신택배", + "일양로지스", + "천일택배", + "합동택배", + "건영택배", + "농협택배" +]; +``` +- 선택 UI는 기존 앱의 bottom sheet, picker, dialog 중 이미 쓰는 방식을 사용한다. +- API에는 선택된 한글 표시명을 그대로 `courierCompanyName`으로 보낸다. + +운송장 등록 CTA 활성 조건: +- 택배사를 선택했다. +- 운송장 번호를 입력했다. +- 운송장 번호는 공백만 입력할 수 없다. +- submit 중에는 중복 탭을 막고 loading 상태를 표시한다. +- 비활성 상태는 Figma `2481:19066`, 활성 상태는 Figma `2481:19109`를 따른다. + +운송장 등록 성공 처리: +- `POST /api/v2/gifts/{applicationNo}/tracking` 성공 후 성공 toast를 표시한다. +- 상세 페이지로 돌아가거나 replace navigation으로 상세 페이지를 다시 연다. +- 상세 페이지는 `GET /api/v2/gifts/{applicationNo}`를 다시 호출해 `TRACKING_REGISTERED` 상태를 표시한다. + +API 명세: + +선물 상세 조회: +- Method: `GET` +- URL: `/api/v2/gifts/{applicationNo}` +- 인증: 로그인 필요 +- Response envelope 예시: +```json +{ + "success": true, + "data": { + "applicationNo": "A-1002609300001", + "direction": "SENT", + "status": "RECEIVED", + "statusName": "접수 완료", + "giftInfo": { + "recipientCreatorNickname": "달빛수집가", + "senderNickname": null, + "sizeName": "소형", + "categoryName": "잡화", + "applicationNo": "A-1002609300001", + "paidCan": 100, + "tracking": null, + "shippingRequestedAt": null + }, + "senderInfo": { + "name": "김소다", + "phoneNumber": "01012345678", + "address": "(04030) 서울특별시 마포구 양화로 000, 3층" + }, + "recipientAddress": null, + "mailbox": { + "name": "소다라이브 사서함", + "address": "(06174) 서울특별시 강남구 테헤란로108길 8, 유민빌딩 4층", + "phoneNumber": "02-2055-1477" + }, + "trackingRequired": true, + "recipientAddressRequired": false, + "recipientAddressDeadlineAt": null, + "delivery": { + "canceledAt": null, + "undeliverableAt": null, + "undeliverableReason": null + }, + "statusTimeline": [ + { + "status": "RECEIVED", + "statusName": "접수 완료", + "occurredAt": "2026-09-18T03:00:00Z" + }, + { + "status": "TRACKING_REGISTERED", + "statusName": "발송 확인", + "occurredAt": null + }, + { + "status": "ARRIVED_AT_MAILBOX", + "statusName": "사서함 도착", + "occurredAt": null + }, + { + "status": "INSPECTION_COMPLETED", + "statusName": "검수완료", + "occurredAt": null + }, + { + "status": "DELIVERED", + "statusName": "전달완료", + "occurredAt": null + } + ] + }, + "message": "" +} +``` + +Response `data` 필드: + +| 필드 | 타입 | 설명 | +|---|---|---| +| `applicationNo` | string | 선물 신청번호 | +| `direction` | string | `SENT` 또는 `RECEIVED` | +| `status` | string | 선물 상태 코드 | +| `statusName` | string | 서버 상태 표시명 | +| `giftInfo` | object | 선물 기본 정보 | +| `giftInfo.recipientCreatorNickname` | string 또는 null | 보낸 선물 관점에서 받는 크리에이터 닉네임 | +| `giftInfo.senderNickname` | string 또는 null | 받은 선물 관점에서 보낸 팬 닉네임 | +| `giftInfo.sizeName` | string | 선물 사이즈명 | +| `giftInfo.categoryName` | string | 카테고리명 | +| `giftInfo.applicationNo` | string 또는 null | 보낸 선물 관점 신청번호 | +| `giftInfo.paidCan` | number 또는 null | 보낸 선물 관점 사용 캔 | +| `giftInfo.tracking` | string 또는 null | 운송장 표시 문자열. 예: `한진택배_123456789012` | +| `giftInfo.shippingRequestedAt` | string 또는 null | 받은 선물 관점 배송신청일. UTC ISO 문자열 | +| `senderInfo` | object 또는 null | 보낸 팬 본인이 신청 시 입력한 보내는 사람 정보 | +| `senderInfo.name` | string | 이름 | +| `senderInfo.phoneNumber` | string | 휴대폰 번호 | +| `senderInfo.address` | string | `(우편번호) 주소, 상세주소` 형식 주소 | +| `recipientAddress` | object 또는 null | 받는 크리에이터 본인이 입력한 배송지 | +| `mailbox` | object 또는 null | 관리자가 등록한 전역 선물 받을 주소. 보낸 선물의 `DELIVERED` 전 정상 진행 상태에서만 값이 있다 | +| `mailbox.name` | string | 사서함/받는 분 이름 | +| `mailbox.address` | string | `(우편번호) 주소, 상세주소` 형식 주소 | +| `mailbox.phoneNumber` | string | 연락처 | +| `trackingRequired` | boolean | 보낸 팬이 운송장 등록을 해야 하는 상태인지 여부 | +| `recipientAddressRequired` | boolean | 받는 크리에이터가 배송지를 입력해야 하는 상태인지 여부 | +| `recipientAddressDeadlineAt` | string 또는 null | 배송지 입력 마감일. UTC ISO 문자열 | +| `delivery.canceledAt` | string 또는 null | 취소일. UTC ISO 문자열 | +| `delivery.undeliverableAt` | string 또는 null | 전달 불가 처리일. UTC ISO 문자열 | +| `delivery.undeliverableReason` | string 또는 null | 전달 불가 사유 | +| `statusTimeline` | array | 정상 진행 5단계 타임라인 | +| `statusTimeline[].status` | string | 단계 상태 코드 | +| `statusTimeline[].statusName` | string | 단계 표시명 | +| `statusTimeline[].occurredAt` | string 또는 null | 해당 단계 도달 시각. UTC ISO 문자열 | + +운송장 등록: +- Method: `POST` +- URL: `/api/v2/gifts/{applicationNo}/tracking` +- 인증: 로그인 필요 +- Request: +```json +{ + "courierCompanyName": "한진택배", + "trackingNumber": "123456789012" +} +``` +- Response envelope 예시: +```json +{ + "success": true, + "data": { + "applicationNo": "A-1002609300001", + "status": "TRACKING_REGISTERED", + "statusName": "발송 확인", + "courierCompanyName": "한진택배", + "trackingNumber": "123456789012", + "trackingRegisteredAt": "2026-09-19T03:00:00Z", + "recipientAddressDeadlineAt": "2026-09-26T03:00:00Z" + }, + "message": "" +} +``` + +운송장 등록 response `data` 필드: + +| 필드 | 타입 | 설명 | +|---|---|---| +| `applicationNo` | string | 선물 신청번호 | +| `status` | string | 등록 후 상태. `TRACKING_REGISTERED` | +| `statusName` | string | 서버 상태 표시명 | +| `courierCompanyName` | string | 선택한 택배사 한글명 | +| `trackingNumber` | string | 운송장 번호 | +| `trackingRegisteredAt` | string | 운송장 등록일. UTC ISO 문자열 | +| `recipientAddressDeadlineAt` | string | 크리에이터 배송지 입력 마감일. UTC ISO 문자열 | + +선물 보내기 취소: +- Method: `POST` +- URL: `/api/v2/gifts/{applicationNo}/cancel` +- Request: 없음 +- 용도: `direction=SENT && status=RECEIVED`에서만 `신청 취소` action에 사용한다. +- Response envelope 예시: +```json +{ + "success": true, + "data": { + "applicationNo": "A-1002609300001", + "status": "CANCELED", + "statusName": "신청 취소", + "priceCan": 100, + "canceledAt": "2026-09-18T04:00:00Z" + }, + "message": "" +} +``` + +선물 보내기 취소 response `data` 필드: + +| 필드 | 타입 | 설명 | +|---|---|---| +| `applicationNo` | string | 선물 신청번호 | +| `status` | string | 취소 후 상태. `CANCELED` | +| `statusName` | string | 서버 상태 표시명 | +| `priceCan` | number | 환불되는 캔 수량 | +| `canceledAt` | string | 취소일. UTC ISO 문자열 | + +테스트/검증: +- 리스트 item 탭 시 `applicationNo`로 상세 페이지가 열리는지 확인한다. +- 상세 진입 시 `GET /api/v2/gifts/{applicationNo}`가 호출되는지 확인한다. +- 상세 조회 결과 `status=CANCELED`이면 일반 상세가 아니라 취소 완료 페이지가 표시되는지 확인한다. +- `statusTimeline` 5개 단계가 모두 표시되는지 확인한다. +- Figma처럼 4단계로 하드코딩하지 않았는지 확인한다. +- `trackingRequired=true`이면 `운송장 작성하기`가 표시되는지 확인한다. +- 신청 취소 버튼 탭 시 `v2 dialog` 확인 팝업이 뜨고 즉시 API를 호출하지 않는지 확인한다. +- 취소 확인 dialog에서 `나가기`를 누르면 dialog만 닫히는지 확인한다. +- 취소 확인 dialog에서 `취소하기`를 누르면 `POST /api/v2/gifts/{applicationNo}/cancel`을 호출하는지 확인한다. +- 취소 성공 후 취소 완료 페이지가 표시되고 취소 전 상세/등록 화면으로 돌아가지 않는지 확인한다. +- `mailbox`가 있을 때만 사서함 주소 섹션이 표시되는지 확인한다. +- `direction=SENT`의 `RECEIVED`, `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED` 상태에서 `mailbox`가 표시되는지 확인한다. +- `DELIVERED`, `UNDELIVERABLE`, `CANCELED`, `direction=RECEIVED`에서는 `mailbox`가 표시되지 않는지 확인한다. +- `UNDELIVERABLE`이면 전달 불가 hero와 사유 섹션이 표시되는지 확인한다. +- 운송장 등록 페이지에서 택배사/운송장 번호 누락 시 API를 호출하지 않는지 확인한다. +- 운송장 등록 시 선택한 택배사 한글명과 운송장 번호가 request body로 전송되는지 확인한다. +- 운송장 등록 성공 후 상세 페이지가 재조회되어 `TRACKING_REGISTERED` 상태로 보이는지 확인한다. +- 모든 날짜는 UTC ISO 문자열을 앱 표시 형식으로 변환해 표시하는지 확인한다. +``` diff --git a/docs/20260929_크리에이터_선물하기/gift-mailbox-admin-page-prompt.md b/docs/20260929_크리에이터_선물하기/gift-mailbox-admin-page-prompt.md new file mode 100644 index 00000000..cdd47627 --- /dev/null +++ b/docs/20260929_크리에이터_선물하기/gift-mailbox-admin-page-prompt.md @@ -0,0 +1,114 @@ +# 선물 받을 주소 관리자 페이지 구현 프롬프트 + +이 문서는 관리자 페이지에서 전역 선물 받을 주소를 등록/수정하는 화면을 구현할 때 사용하는 프롬프트다. + +## 전제 + +- 여기서 말하는 `선물 받을 주소`는 선물 상대방 주소가 아니다. +- 관리자가 등록하는 전역 단일 주소다. +- 팬이 보낸 선물 상세를 정상 진행 상태 중 전달 완료 전(`RECEIVED`, `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED`)에 조회할 때, 이 주소가 `mailbox`로 노출된다. +- 새 디자인 시스템을 만들지 말고 기존 관리자 페이지의 폼, 버튼, 토스트, 에러 표시 패턴을 재사용한다. + +## 구현 프롬프트 + +```text +관리자 선물 받을 주소 설정 페이지를 구현해줘. + +목표: +- 관리자 메뉴 `선물함 관리 > 받을 주소`에서 팬이 선물을 보낼 전역 단일 주소를 조회하고 저장한다. +- 이 주소는 상대방 주소가 아니라 관리자가 등록하는 운영 주소다. +- 팬의 선물 상세 API에서는 `direction=SENT`이고 `status`가 `RECEIVED`, `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED`일 때 이 주소가 `mailbox`로 노출된다. +- 기존 관리자 페이지의 폼, 저장 버튼, 토스트, 에러 표시 패턴을 그대로 따른다. + +라우트: +- `/gift/mailbox` + +메뉴: +- parent: `선물함 관리` +- label: `받을 주소` +- path: `/gift/mailbox` + +필수 화면 구성: +- 상단 제목: `받을 주소` +- 설명 문구: `팬이 선물을 발송할 때 확인하는 받을 주소입니다.` +- 입력 폼: + - 받을 사람 이름 `name` + - 연락처 `phoneNumber` + - 우편번호 `zipCode` + - 주소 `address` + - 상세주소 `addressDetail` +- 저장 버튼: `저장` + +초기 조회 API: +- Method: `GET` +- URL: `/api/v2/admin/gift-mailbox` +- 모든 API 응답은 기존 공통 envelope를 사용한다. +- 화면에서는 `response.data`를 실제 payload로 사용한다. +- response `data`: +```ts +type AdminGiftMailboxResponse = { + name: string; + address: string; + phoneNumber: string; +} | null; +``` +- `data`가 null이면 아직 등록된 받을 주소가 없는 상태다. +- null이면 빈 폼을 표시한다. + +저장 API: +- Method: `PUT` +- URL: `/api/v2/admin/gift-mailbox` +- Request: +```ts +type AdminGiftMailboxRequest = { + name: string; + phoneNumber: string; + zipCode: string; + address: string; + addressDetail: string | null; +}; +``` +- Response `data`: +```ts +type AdminGiftMailboxResponse = { + name: string; + address: string; + phoneNumber: string; +}; +``` + +동작 규칙: +- 받을 주소는 전역 단일 설정이다. +- 등록과 수정은 같은 `PUT /api/v2/admin/gift-mailbox` API를 사용한다. +- 최초 저장이면 생성처럼 동작한다. +- 이미 저장된 값이 있으면 기존 주소를 수정한다. +- 별도 ID, 목록, 삭제 기능은 만들지 않는다. + +폼 validation: +- `name`, `phoneNumber`, `zipCode`, `address`는 필수다. +- 필수값은 공백만 입력할 수 없다. +- `addressDetail`은 선택이다. +- validation 실패 시 API를 호출하지 않고 기존 관리자 페이지의 필드 에러 표시 방식을 따른다. + +저장 성공 UX: +- 성공 토스트를 표시한다. +- 저장 API response 기준으로 화면 값을 갱신한다. +- response의 `address`는 서버가 조립한 `(우편번호) 주소, 상세주소` 형식이다. +- 입력 필드는 사용자가 입력한 `zipCode`, `address`, `addressDetail` 값을 유지해도 된다. + +빈 상태 UX: +- 조회 결과 `data=null`이면 빈 폼을 보여준다. +- 필요하면 안내 문구 `등록된 받을 주소가 없습니다. 주소를 입력하고 저장해주세요.`를 표시한다. + +에러 처리: +- API 실패 시 기존 관리자 페이지의 공통 에러 토스트/알림 패턴을 따른다. +- 인증/권한 처리는 기존 관리자 API client와 토큰 처리 방식을 따른다. + +테스트/검증: +- `/gift/mailbox` route가 선물함 관리 메뉴에서 접근되는지 확인한다. +- 초기 조회 시 `GET /api/v2/admin/gift-mailbox`를 호출하는지 확인한다. +- 조회 결과가 null이면 빈 폼을 표시하는지 확인한다. +- 저장 시 `PUT /api/v2/admin/gift-mailbox`와 request body가 정확한지 확인한다. +- 필수값 공백 validation 시 API를 호출하지 않는지 확인한다. +- 저장 성공 후 성공 토스트와 화면 갱신이 일어나는지 확인한다. +``` From c6b5f9c86787a51e5dc0a74f47419a6200746e69 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 2 Oct 2026 13:20:25 +0900 Subject: [PATCH 45/53] =?UTF-8?q?docs(gift):=20=EB=B0=9B=EC=9D=80=20?= =?UTF-8?q?=EC=84=A0=EB=AC=BC=20=EC=83=81=EC=84=B8=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=ED=94=84=EB=A1=AC=ED=94=84=ED=8A=B8=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gift-detail-tracking-page-prompt.md | 4 +- .../gift-received-detail-page-prompt.md | 421 ++++++++++++++++++ 2 files changed, 423 insertions(+), 2 deletions(-) create mode 100644 docs/20260929_크리에이터_선물하기/gift-received-detail-page-prompt.md diff --git a/docs/20260929_크리에이터_선물하기/gift-detail-tracking-page-prompt.md b/docs/20260929_크리에이터_선물하기/gift-detail-tracking-page-prompt.md index 127baf12..d3e5546f 100644 --- a/docs/20260929_크리에이터_선물하기/gift-detail-tracking-page-prompt.md +++ b/docs/20260929_크리에이터_선물하기/gift-detail-tracking-page-prompt.md @@ -1,6 +1,6 @@ -# 선물 상세 및 운송장 등록 페이지 생성 프롬프트 +# 보낸 선물 상세 및 운송장 등록 페이지 생성 프롬프트 -이 문서는 Android/iOS 앱의 `선물함` 리스트에서 진입하는 선물 상세 페이지와 운송장 등록 페이지를 생성할 때 사용하는 프롬프트다. +이 문서는 Android/iOS 앱의 `선물함` 리스트에서 보낸 선물 상세 페이지와 운송장 등록 페이지를 생성할 때 사용하는 프롬프트다. 참고 Figma: diff --git a/docs/20260929_크리에이터_선물하기/gift-received-detail-page-prompt.md b/docs/20260929_크리에이터_선물하기/gift-received-detail-page-prompt.md new file mode 100644 index 00000000..deff17c7 --- /dev/null +++ b/docs/20260929_크리에이터_선물하기/gift-received-detail-page-prompt.md @@ -0,0 +1,421 @@ +# 받은 선물 상세 및 배송지 입력 페이지 생성 프롬프트 + +이 문서는 Android/iOS 앱의 `선물함` 리스트에서 받은 선물 상세 페이지와 배송지 입력 UI를 생성할 때 사용하는 프롬프트다. + +참고 Figma: + +- 배송지 입력 전 상세 UI: `2531:36290` +- 배송지 입력 완료 후 상세 UI: `2531:36399` +- 선물 전달 완료시 상세 UI: `2531:36330` +- 선물 전달 불가시 상세 UI: `2531:36422` + +전제: + +- 선물함 리스트 item 탭 시 `applicationNo`를 전달받아 상세 페이지로 이동한다. +- 새 디자인 시스템을 만들지 말고 Android/iOS 각각의 기존 디자인 시스템, 컴포넌트, API client, 상태 관리, toast/dialog, navigation 패턴을 재사용한다. +- 모든 API 응답은 공통 envelope를 사용하며 실제 payload는 `response.data`다. +- 이 프롬프트는 받는 크리에이터 관점(`direction=RECEIVED`)만 다룬다. +- API 예시는 TypeScript 타입이 아니라 JSON으로 작성한다. + +--- + +## 모바일 앱 구현 프롬프트 + +```text +Android/iOS 앱의 받은 선물 상세 페이지와 배송지 입력 UI를 Figma 기준으로 생성하고 API 연동을 구현해줘. + +범위: +- 선물함 리스트에서 `applicationNo`를 받아 받은 선물 상세 페이지로 이동한다. +- 상세 페이지는 `GET /api/v2/gifts/{applicationNo}` 응답으로 렌더링한다. +- `direction=RECEIVED && recipientAddressRequired=true`이면 배송지 입력 UI를 표시한다. +- 배송지 입력은 `POST /api/v2/gifts/{applicationNo}/recipient-address`로 연결한다. +- 배송지 입력 완료 후에는 상세를 다시 조회해 배송지 입력 완료 상태 UI를 표시한다. + +중요 전제: +- Figma의 visual structure와 문구를 우선 따르되, 데이터 노출과 CTA 조건은 API 계약을 우선한다. +- `direction=RECEIVED` 화면에서 `senderInfo`, `mailbox`, `giftInfo.applicationNo`, `giftInfo.paidCan`, `giftInfo.tracking`은 표시하지 않는다. +- `recipientAddress`가 null이면 받는 주소 섹션 자체를 표시하지 않는다. 전달 완료 후 배송지 정보가 삭제될 예정이므로 빈 섹션, skeleton, `-` 값도 표시하지 않는다. +- 전달 불가 화면의 `문의하기`는 기존 앱의 카카오 채널 문의 연결과 동일하게 연결한다. +- 전달 불가 화면의 `선물 정책 확인`은 버튼/action만 만들어두고 실제 외부 페이지 연결은 하지 않는다. 임의 URL을 연결하지 않는다. + +상세 페이지 진입: +- 화면 진입 시 `GET /api/v2/gifts/{applicationNo}`를 호출한다. +- 조회 결과 `direction`이 `RECEIVED`가 아니면 이 화면을 렌더링하지 말고 기존 앱의 잘못된 접근/상세 재라우팅 정책을 따른다. +- 로딩 중에는 기존 앱 상세 화면 로딩/스켈레톤 패턴을 사용한다. +- 실패 시 기존 앱 toast/dialog와 재시도 패턴을 사용한다. + +배송지 입력 전 UI: +- 적용 조건: `direction=RECEIVED && recipientAddressRequired=true`. +- Figma `2531:36290`을 따른다. +- 상단 타이틀: `선물 받기`. +- 개인정보 이용 안내 배너를 표시한다. + - 제목: `개인정보 이용 안내` + - 본문: `크리에이터 및 팬의 주소는 공개되지 않습니다. 입력한 배송지는 선물 전달 및 필요한 사고 처리에서 사용되며, 전달 완료 후 파기됩니다.` +- `전달 예정 선물` 섹션을 표시한다. + - 발송인: `giftInfo.senderNickname` + - 사이즈: `giftInfo.sizeName` + - 카테고리: `giftInfo.categoryName` +- `배송지 입력` 섹션을 표시한다. + - 이름 input: `recipientName` + - 휴대폰 번호 input: `recipientPhoneNumber` + - 우편번호 input: `recipientZipCode` + - 우편번호 검색 버튼: 기존 페이지와 동일하게 Kakao 우편번호 서비스를 사용한다. + - 주소 input: `recipientAddress` + - 상세 주소 input: `recipientAddressDetail` +- `이용 약관 동의` 섹션을 표시한다. + - `소다라이브 크리에이터 상품 전달 이용약관 및 전달 가능 제한 품목을 확인하고 동의합니다.(필수)` + - `상품 전달을 위해 수령인 성명·연락처·주소 등 필요한 개인정보를 일시적으로 수집·이용하는 것에 동의합니다.(필수)` + - 두 항목 모두 체크해야 제출 가능하다. + - 약관 상세 링크는 앱에 기존 약관/webview 경로가 있으면 연결하고, 없으면 새 페이지를 만들지 않는다. +- 하단 주요 CTA: `선물 받기`. +- CTA 활성 조건: + - 이름, 휴대폰 번호, 우편번호, 주소가 공백이 아니다. + - 상세 주소는 선택 입력이다. + - 약관 2개가 모두 체크됐다. + - submit 중에는 중복 탭을 막고 loading 상태를 표시한다. + +배송지 입력 API 연결: +- CTA 탭 시 `POST /api/v2/gifts/{applicationNo}/recipient-address`를 호출한다. +- request body에는 화면 입력값과 약관 동의 boolean을 그대로 전송한다. +- 성공 시 성공 toast를 표시하고 상세 페이지를 다시 조회하거나 replace navigation으로 상세를 다시 연다. +- 실패 시 기존 앱 form error/toast/dialog 패턴을 따른다. 실패 상태에서 입력값은 유지한다. + +주소 검색 구현: +- 기존 앱/페이지에 Kakao 우편번호 서비스 래퍼가 있으면 반드시 재사용한다. +- 신규 연결이 필요하면 Kakao 우편번호 서비스 공식 가이드 `https://postcode.map.kakao.com/guide`를 따른다. +- SDK script는 공식 가이드의 `//t1.kakaocdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js`를 사용한다. 임의 수정본이나 별도 주소 검색 API를 만들지 않는다. +- Android/iOS WebView 또는 모바일웹 환경에서는 `window.open` 팝업이 막힐 수 있으므로, 기존 페이지와 같은 방식이 없다면 Kakao 가이드의 `embed()` 기반 레이어/페이지 삽입 방식을 우선 검토한다. +- 검색 결과 선택 시: + - `data.zonecode`를 `recipientZipCode`에 넣는다. + - `data.userSelectedType === "R"`이면 `data.roadAddress`를, 그 외에는 `data.jibunAddress`를 `recipientAddress`에 넣는다. + - 주소 선택 후 `recipientAddressDetail` 입력으로 포커스를 이동한다. +- 상세 주소는 Kakao 결과에서 채우지 않고 사용자가 직접 입력하게 한다. +- 주소 검색 실패/취소 시 입력 중이던 이름, 휴대폰 번호, 약관 체크 상태를 초기화하지 않는다. + +배송지 입력 완료 후 상세 UI: +- 적용 조건: `direction=RECEIVED && recipientAddressRequired=false && status`가 `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED` 중 하나이며 `recipientAddress`가 있다. +- Figma `2531:36399`를 따른다. +- 상단 타이틀: `선물 상세`. +- hero title: `배송지 입력 완료!` +- hero body: `빠른 시일 내로 선물을 전달 드릴게요.` +- `문의하기` 버튼을 표시하고 기존 카카오 채널 문의로 연결한다. +- `선물 정보` 섹션을 표시한다. + - 발송인: `giftInfo.senderNickname` + - 사이즈: `giftInfo.sizeName` + - 카테고리: `giftInfo.categoryName` + - 배송 신청일: `giftInfo.shippingRequestedAt` +- `받는 주소` 섹션은 `recipientAddress`가 있을 때만 표시한다. + - 이름: `recipientAddress.name` + - 휴대폰 번호: `recipientAddress.phoneNumber` + - 주소: `recipientAddress.address` + +선물 전달 완료 상세 UI: +- 적용 조건: `direction=RECEIVED && status=DELIVERED`. +- Figma `2531:36330`을 따른다. +- 상단 타이틀: `선물 상세`. +- hero title: `선물 전달 완료!` +- hero body: `팬이 보낸 선물이 도착했어요. 선물이 도착하지 않은 경우, 문의해 주세요.` +- `문의하기` 버튼을 표시하고 기존 카카오 채널 문의로 연결한다. +- `선물 정보` 섹션을 표시한다. + - 발송인: `giftInfo.senderNickname` + - 사이즈: `giftInfo.sizeName` + - 카테고리: `giftInfo.categoryName` + - 배송 신청일: `giftInfo.shippingRequestedAt` + - 배송 완료일: `statusTimeline`에서 `DELIVERED.occurredAt` +- `받는 주소` 섹션은 `recipientAddress`가 있을 때만 표시한다. + - 섹션 보조 문구: `배송 완료 3일 후 해당 정보는 사라져요` + - 이름: `recipientAddress.name` + - 휴대폰 번호: `recipientAddress.phoneNumber` + - 주소: `recipientAddress.address` +- `recipientAddress=null`이면 `받는 주소` 섹션 전체를 숨긴다. + +선물 전달 불가 상세 UI: +- 적용 조건: `direction=RECEIVED && status=UNDELIVERABLE`. +- Figma `2531:36422`를 따른다. +- 상단 타이틀: `선물 상세`. +- chip: `전달 불가` +- hero title: `전달할 수 없는 품목입니다` +- hero body: `해당 선물은 SODALIVE 선물 정책에 따라 크리에이터에게 전달할 수 없는 품목으로 확인되었습니다.` +- action button: + - `문의하기`: 기존 카카오 채널 문의로 연결한다. + - `선물 정책 확인`: action handler만 연결하고 실제 외부 페이지/URL은 연결하지 않는다. 기존 앱 정책상 no-op이 금지되어 있으면 `준비 중입니다.` toast만 표시한다. +- `사유` 섹션을 표시한다. + - 사유 제목: `delivery.undeliverableReason`이 있으면 그 값을 사용한다. + - `delivery.undeliverableReason`이 null이면 `전달 불가`로 표시한다. + - 안내 문구: `해당 선물은 정책에 따라 폐기되며 반송되지 않습니다.` +- 전달 불가 상태에서는 배송지 입력 UI를 표시하지 않는다. +- `recipientAddress`가 응답에 있더라도 전달 불가 Figma에는 받는 주소 섹션이 없으므로 표시하지 않는다. + +날짜 표시: +- API의 모든 날짜는 UTC ISO 문자열이다. +- 앱 공통 날짜 formatter가 있으면 재사용한다. +- Figma처럼 상세 필드 날짜는 `YYYY.MM.DD`로 표시한다. +- 값이 null이면 해당 row를 숨긴다. + +API 명세: + +선물 상세 조회: +- Method: `GET` +- URL: `/api/v2/gifts/{applicationNo}` +- 인증: 로그인 필요 +- Response envelope 예시 - 배송지 입력 전: +```json +{ + "success": true, + "data": { + "applicationNo": "A-1002609300001", + "direction": "RECEIVED", + "status": "TRACKING_REGISTERED", + "statusName": "발송 확인", + "giftInfo": { + "recipientCreatorNickname": null, + "senderNickname": "팬닉네임이들어가는부분", + "sizeName": "소형", + "categoryName": "신규 전자굿즈", + "applicationNo": null, + "paidCan": null, + "tracking": null, + "shippingRequestedAt": "2026-10-12T03:00:00Z" + }, + "senderInfo": null, + "recipientAddress": null, + "mailbox": null, + "trackingRequired": false, + "recipientAddressRequired": true, + "recipientAddressDeadlineAt": "2026-10-19T03:00:00Z", + "delivery": { + "canceledAt": null, + "undeliverableAt": null, + "undeliverableReason": null + }, + "statusTimeline": [ + { + "status": "RECEIVED", + "statusName": "접수 완료", + "occurredAt": "2026-10-11T03:00:00Z" + }, + { + "status": "TRACKING_REGISTERED", + "statusName": "발송 확인", + "occurredAt": "2026-10-12T03:00:00Z" + }, + { + "status": "ARRIVED_AT_MAILBOX", + "statusName": "사서함 도착", + "occurredAt": null + }, + { + "status": "INSPECTION_COMPLETED", + "statusName": "검수완료", + "occurredAt": null + }, + { + "status": "DELIVERED", + "statusName": "전달완료", + "occurredAt": null + } + ] + }, + "message": "" +} +``` + +Response envelope 예시 - 배송지 입력 완료/전달 완료: +```json +{ + "success": true, + "data": { + "applicationNo": "A-1002609300001", + "direction": "RECEIVED", + "status": "DELIVERED", + "statusName": "전달완료", + "giftInfo": { + "recipientCreatorNickname": null, + "senderNickname": "팬닉네임이들어가는부분", + "sizeName": "소형", + "categoryName": "신규 전자굿즈", + "applicationNo": null, + "paidCan": null, + "tracking": null, + "shippingRequestedAt": "2026-10-12T03:00:00Z" + }, + "senderInfo": null, + "recipientAddress": { + "name": "크리에이터이름", + "phoneNumber": "01012345678", + "address": "(04030) 서울특별시 마포구 양화로 000, 3층" + }, + "mailbox": null, + "trackingRequired": false, + "recipientAddressRequired": false, + "recipientAddressDeadlineAt": "2026-10-19T03:00:00Z", + "delivery": { + "canceledAt": null, + "undeliverableAt": null, + "undeliverableReason": null + }, + "statusTimeline": [ + { + "status": "RECEIVED", + "statusName": "접수 완료", + "occurredAt": "2026-10-11T03:00:00Z" + }, + { + "status": "TRACKING_REGISTERED", + "statusName": "발송 확인", + "occurredAt": "2026-10-12T03:00:00Z" + }, + { + "status": "ARRIVED_AT_MAILBOX", + "statusName": "사서함 도착", + "occurredAt": "2026-10-14T03:00:00Z" + }, + { + "status": "INSPECTION_COMPLETED", + "statusName": "검수완료", + "occurredAt": "2026-10-16T03:00:00Z" + }, + { + "status": "DELIVERED", + "statusName": "전달완료", + "occurredAt": "2026-10-22T03:00:00Z" + } + ] + }, + "message": "" +} +``` + +Response envelope 예시 - 전달 불가: +```json +{ + "success": true, + "data": { + "applicationNo": "A-1002609300001", + "direction": "RECEIVED", + "status": "UNDELIVERABLE", + "statusName": "전달불가", + "giftInfo": { + "recipientCreatorNickname": null, + "senderNickname": "팬닉네임이들어가는부분", + "sizeName": "소형", + "categoryName": "신규 전자굿즈", + "applicationNo": null, + "paidCan": null, + "tracking": null, + "shippingRequestedAt": "2026-10-12T03:00:00Z" + }, + "senderInfo": null, + "recipientAddress": null, + "mailbox": null, + "trackingRequired": false, + "recipientAddressRequired": false, + "recipientAddressDeadlineAt": "2026-10-19T03:00:00Z", + "delivery": { + "canceledAt": null, + "undeliverableAt": "2026-10-16T03:00:00Z", + "undeliverableReason": "음식물 또는 보관이 어려운 품목" + }, + "statusTimeline": [ + { + "status": "RECEIVED", + "statusName": "접수 완료", + "occurredAt": "2026-10-11T03:00:00Z" + }, + { + "status": "TRACKING_REGISTERED", + "statusName": "발송 확인", + "occurredAt": "2026-10-12T03:00:00Z" + }, + { + "status": "ARRIVED_AT_MAILBOX", + "statusName": "사서함 도착", + "occurredAt": "2026-10-14T03:00:00Z" + }, + { + "status": "INSPECTION_COMPLETED", + "statusName": "검수완료", + "occurredAt": null + }, + { + "status": "DELIVERED", + "statusName": "전달완료", + "occurredAt": null + } + ] + }, + "message": "" +} +``` + +선물 상세 response `data` 필드: + +| 필드 | 설명 | +|---|---| +| `applicationNo` | 선물 신청번호. 화면 진입 key로 사용한다. | +| `direction` | 이 프롬프트에서는 `RECEIVED`만 렌더링한다. | +| `status` | `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED`, `DELIVERED`, `UNDELIVERABLE` 등을 상태별 UI 분기에 사용한다. | +| `giftInfo.senderNickname` | 받은 선물 관점의 발송인 팬 닉네임. | +| `giftInfo.sizeName` | 선물 사이즈명. | +| `giftInfo.categoryName` | 카테고리명. | +| `giftInfo.shippingRequestedAt` | 배송 신청일. UTC ISO 문자열. | +| `recipientAddress` | 크리에이터 본인이 입력한 배송지. null이면 받는 주소 섹션을 숨긴다. | +| `recipientAddressRequired` | 배송지 입력 UI 표시 여부. | +| `recipientAddressDeadlineAt` | 배송지 입력 마감일. 필요하면 안내 문구에 사용한다. | +| `delivery.undeliverableReason` | 전달 불가 사유. | +| `statusTimeline` | 배송 완료일 등 상태별 날짜를 찾는 데 사용한다. | + +배송지 입력: +- Method: `POST` +- URL: `/api/v2/gifts/{applicationNo}/recipient-address` +- 인증: 로그인 필요 +- Request: +```json +{ + "recipientName": "크리에이터이름", + "recipientPhoneNumber": "01012345678", + "recipientZipCode": "04030", + "recipientAddress": "서울특별시 마포구 양화로 000", + "recipientAddressDetail": "3층", + "recipientTermsAgreed": true, + "recipientPrivacyAgreed": true +} +``` +- Response envelope 예시: +```json +{ + "success": true, + "data": { + "applicationNo": "A-1002609300001", + "status": "TRACKING_REGISTERED", + "statusName": "발송 확인", + "recipientAddressRegisteredAt": "2026-10-13T03:00:00Z" + }, + "message": "" +} +``` + +배송지 입력 response `data` 필드: + +| 필드 | 설명 | +|---|---| +| `applicationNo` | 선물 신청번호 | +| `status` | 등록 후 상태. 일반적으로 기존 진행 상태 유지 | +| `statusName` | 서버 상태 표시명 | +| `recipientAddressRegisteredAt` | 배송지 입력 완료 시각. UTC ISO 문자열 | + +테스트/검증: +- 리스트 item 탭 시 `applicationNo`로 받은 선물 상세 페이지가 열리는지 확인한다. +- 상세 진입 시 `GET /api/v2/gifts/{applicationNo}`가 호출되는지 확인한다. +- `direction=RECEIVED && recipientAddressRequired=true`이면 배송지 입력 UI가 표시되는지 확인한다. +- 배송지 입력 필수값 또는 약관 동의가 누락되면 `POST /recipient-address`를 호출하지 않는지 확인한다. +- 배송지 입력 submit 시 request body가 API 명세와 일치하는지 확인한다. +- 배송지 입력 성공 후 상세가 재조회되고 배송지 입력 완료 UI가 표시되는지 확인한다. +- 배송지 입력 완료/전달 완료 상태에서 `recipientAddress`가 있으면 받는 주소 섹션이 표시되는지 확인한다. +- `recipientAddress=null`이면 받는 주소 섹션 자체가 표시되지 않는지 확인한다. +- `status=DELIVERED`이면 전달 완료 UI와 배송 완료일이 표시되는지 확인한다. +- `status=UNDELIVERABLE`이면 전달 불가 UI, 사유, 폐기/반송 불가 안내가 표시되는지 확인한다. +- 전달 불가 화면의 `문의하기`는 기존 카카오 채널 문의로 연결되는지 확인한다. +- 전달 불가 화면의 `선물 정책 확인`은 버튼 action만 있고 실제 외부 URL을 열지 않는지 확인한다. +- 모든 날짜는 UTC ISO 문자열을 앱 표시 형식으로 변환해 표시하는지 확인한다. +``` From 0bbb9e4fc74868bf063088b1fe7c29249915e570 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 2 Oct 2026 20:06:17 +0900 Subject: [PATCH 46/53] =?UTF-8?q?fix(gift):=20=EC=88=98=EC=B7=A8=EC=9D=B8?= =?UTF-8?q?=20=EC=A0=95=EB=B3=B4=20=EC=97=86=EB=8A=94=20=EC=84=A0=EB=AC=BC?= =?UTF-8?q?=EC=9D=98=20=EC=A0=84=EB=8B=AC=20=EC=99=84=EB=A3=8C=EB=A5=BC=20?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/out/persistence/GiftDelivery.kt | 9 +++- .../v2/gift/application/GiftAdminService.kt | 1 + .../adapter/in/web/AdminGiftControllerTest.kt | 18 ++++++- .../gift/application/GiftAdminServiceTest.kt | 50 ++++++++++++++++++- 4 files changed, 74 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftDelivery.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftDelivery.kt index 747e4a6b..748e1757 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftDelivery.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftDelivery.kt @@ -84,4 +84,11 @@ class GiftDelivery( @Column(name = "undeliverable_reason", length = 255) var undeliverableReason: String? = null -) : BaseEntity() +) : BaseEntity() { + fun hasCompleteRecipientInformation() = listOf( + recipientName, + recipientPhoneNumber, + recipientZipCode, + recipientAddress + ).none { it.isNullOrBlank() } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminService.kt index a3569f59..a4ba308d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminService.kt @@ -203,6 +203,7 @@ class GiftAdminService( val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest() if (gift.status != GiftStatus.INSPECTION_COMPLETED) throw invalidRequest() val delivery = giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)) ?: throw invalidRequest() + if (!delivery.hasCompleteRecipientInformation()) throw invalidRequest() gift.status = GiftStatus.DELIVERED delivery.deliveredAt = now diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt index da1787e5..98064c95 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt @@ -332,7 +332,7 @@ class AdminGiftControllerTest @Autowired constructor( @Test @DisplayName("관리자는 검수완료 선물을 전달완료 처리한다") fun shouldCompleteGiftDelivery() { - saveGift(GiftStatus.INSPECTION_COMPLETED) + saveGift(GiftStatus.INSPECTION_COMPLETED, withRecipientAddress = true) mockMvc.perform(post("/api/v2/admin/gifts/$APPLICATION_NO/complete-delivery").with(user("admin").roles("ADMIN"))) .andExpect(status().isOk) @@ -342,7 +342,17 @@ class AdminGiftControllerTest @Autowired constructor( .andExpect(jsonPath("$.data.occurredAt").value(endsWith("Z"))) } - private fun saveGift(status: GiftStatus) { + @Test + @DisplayName("관리자는 수취인 정보가 없는 검수완료 선물을 전달완료 처리할 수 없다") + fun shouldRejectGiftDeliveryWithoutRecipientAddress() { + saveGift(GiftStatus.INSPECTION_COMPLETED) + + mockMvc.perform(post("/api/v2/admin/gifts/$APPLICATION_NO/complete-delivery").with(user("admin").roles("ADMIN"))) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(false)) + } + + private fun saveGift(status: GiftStatus, withRecipientAddress: Boolean = false) { val gift = giftRepository.save( Gift( applicationNo = APPLICATION_NO, @@ -368,6 +378,10 @@ class AdminGiftControllerTest @Autowired constructor( senderPhoneNumber = "01012345678", senderZipCode = "06234", senderAddress = "서울시 강남구", + recipientName = "김소다".takeIf { withRecipientAddress }, + recipientPhoneNumber = "01098765432".takeIf { withRecipientAddress }, + recipientZipCode = "04524".takeIf { withRecipientAddress }, + recipientAddress = "서울시 중구".takeIf { withRecipientAddress }, trackingDeadlineAt = NOW.minusDays(1), trackingRegisteredAt = NOW.minusHours(1) ) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminServiceTest.kt index b003dda6..86610855 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminServiceTest.kt @@ -16,6 +16,8 @@ import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource import org.mockito.Mockito import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest @@ -248,6 +250,7 @@ class GiftAdminServiceTest @Autowired constructor( @DisplayName("운영자는 검수 완료 선물을 전달 완료 처리한다") fun shouldCompleteDeliveryForInspectionCompletedGift() { val (gift, delivery) = saveGift(GiftStatus.INSPECTION_COMPLETED) + populateRecipientInformation(delivery) val result = service.completeDelivery(APPLICATION_NO, NOW) @@ -262,7 +265,8 @@ class GiftAdminServiceTest @Autowired constructor( @Test @DisplayName("검수 완료 상태가 아니면 운영 전달 완료 처리할 수 없다") fun shouldRejectDeliveryCompletionWhenGiftIsNotInspectionCompleted() { - saveGift(GiftStatus.ARRIVED_AT_MAILBOX) + val (_, delivery) = saveGift(GiftStatus.ARRIVED_AT_MAILBOX) + populateRecipientInformation(delivery) val exception = assertThrows(SodaException::class.java) { service.completeDelivery(APPLICATION_NO, NOW) @@ -271,6 +275,50 @@ class GiftAdminServiceTest @Autowired constructor( assertEquals("common.error.invalid_request", exception.messageKey) } + @Test + @DisplayName("수취인 정보가 없으면 운영 전달 완료 처리할 수 없다") + fun shouldRejectDeliveryCompletionWithoutRecipientAddress() { + val (gift, delivery) = saveGift(GiftStatus.INSPECTION_COMPLETED) + + val exception = assertThrows(SodaException::class.java) { + service.completeDelivery(APPLICATION_NO, NOW) + } + + assertEquals("common.error.invalid_request", exception.messageKey) + assertEquals(GiftStatus.INSPECTION_COMPLETED, gift.status) + assertEquals(null, delivery.deliveredAt) + Mockito.verifyNoInteractions(giftPushService) + } + + @ParameterizedTest + @ValueSource(strings = ["name", "phoneNumber", "zipCode", "address"]) + @DisplayName("수취인 필수 정보에 공백이 있으면 운영 전달 완료 처리할 수 없다") + fun shouldRejectDeliveryCompletionWithBlankRecipientInformation(blankField: String) { + val (gift, delivery) = saveGift(GiftStatus.INSPECTION_COMPLETED) + populateRecipientInformation(delivery) + when (blankField) { + "name" -> delivery.recipientName = " " + "phoneNumber" -> delivery.recipientPhoneNumber = " " + "zipCode" -> delivery.recipientZipCode = " " + "address" -> delivery.recipientAddress = " " + } + + assertThrows(SodaException::class.java) { + service.completeDelivery(APPLICATION_NO, NOW) + } + + assertEquals(GiftStatus.INSPECTION_COMPLETED, gift.status) + assertEquals(null, delivery.deliveredAt) + Mockito.verifyNoInteractions(giftPushService) + } + + private fun populateRecipientInformation(delivery: GiftDelivery) { + delivery.recipientName = "김소다" + delivery.recipientPhoneNumber = "01098765432" + delivery.recipientZipCode = "04524" + delivery.recipientAddress = "서울시 중구" + } + private fun saveGift(status: GiftStatus): Pair { val gift = giftRepository.save( Gift( From 112ad755e5d0a09e46c6adb54e678bc0df4164db Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 2 Oct 2026 20:07:06 +0900 Subject: [PATCH 47/53] =?UTF-8?q?fix(gift):=20=EC=A0=84=EB=8B=AC=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EA=B0=80=EB=8A=A5=20=EC=95=A1=EC=85=98?= =?UTF-8?q?=EC=9D=98=20=EC=88=98=EC=B7=A8=EC=9D=B8=20=EC=A1=B0=EA=B1=B4?= =?UTF-8?q?=EC=9D=84=20=EB=B0=98=EC=98=81=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gift/application/GiftAdminQueryService.kt | 12 ++++--- .../application/GiftAdminQueryServiceTest.kt | 33 ++++++++++++++++++- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminQueryService.kt index 13cb9d06..f9996146 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminQueryService.kt @@ -137,7 +137,7 @@ class GiftAdminQueryService( ), status = gift.status, statusName = gift.status.displayName, - availableActions = gift.status.availableActions() + availableActions = gift.status.availableActions(delivery) ) } @@ -153,13 +153,17 @@ class GiftAdminQueryService( trackingNumber = context.delivery(this).trackingNumber, status = status, statusName = status.displayName, - availableActions = status.availableActions() + availableActions = status.availableActions(context.delivery(this)) ) - private fun GiftStatus.availableActions() = when (this) { + private fun GiftStatus.availableActions(delivery: GiftDelivery) = when (this) { GiftStatus.TRACKING_REGISTERED -> listOf("ARRIVE_MAILBOX", "MARK_UNDELIVERABLE") GiftStatus.ARRIVED_AT_MAILBOX -> listOf("COMPLETE_INSPECTION", "MARK_UNDELIVERABLE") - GiftStatus.INSPECTION_COMPLETED -> listOf("COMPLETE_DELIVERY", "MARK_UNDELIVERABLE") + GiftStatus.INSPECTION_COMPLETED -> if (delivery.hasCompleteRecipientInformation()) { + listOf("COMPLETE_DELIVERY", "MARK_UNDELIVERABLE") + } else { + listOf("MARK_UNDELIVERABLE") + } GiftStatus.RECEIVED, GiftStatus.DELIVERED, GiftStatus.UNDELIVERABLE, GiftStatus.CANCELED -> emptyList() } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminQueryServiceTest.kt index a2c50924..1af001b9 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminQueryServiceTest.kt @@ -146,9 +146,13 @@ class GiftAdminQueryServiceTest @Autowired constructor( service.getGiftDetail("A-100260930102").availableActions ) assertEquals( - listOf("COMPLETE_DELIVERY", "MARK_UNDELIVERABLE"), + listOf("MARK_UNDELIVERABLE"), service.getGiftDetail("A-100260930103").availableActions ) + assertEquals( + listOf("MARK_UNDELIVERABLE"), + service.getGifts(GiftStatus.INSPECTION_COMPLETED, null, null, 0, 20).items.single().availableActions + ) assertEquals(emptyList(), service.getGiftDetail("A-100260930104").availableActions) assertEquals(emptyList(), service.getGiftDetail("A-100260930105").availableActions) assertEquals(emptyList(), service.getGiftDetail("A-100260930106").availableActions) @@ -158,6 +162,33 @@ class GiftAdminQueryServiceTest @Autowired constructor( assertEquals("common.error.invalid_request", exception.messageKey) } + @Test + @DisplayName("수취인 정보가 입력된 검수 완료 선물만 전달 완료 액션을 반환한다") + fun shouldReturnCompleteDeliveryActionOnlyWithRecipientAddress() { + val sender = saveMember("fan-alpha") + val recipient = saveMember("creator-alpha") + val category = saveCategory() + val gift = saveGift( + applicationNo = "A-1002609300001", + sender = sender, + recipient = recipient, + category = category, + status = GiftStatus.INSPECTION_COMPLETED + ) + requireNotNull(deliveryRepository.findByGiftId(requireNotNull(gift.id))).apply { + recipientName = "김소다" + recipientPhoneNumber = "01098765432" + recipientZipCode = "04524" + recipientAddress = "서울시 중구" + } + + val detail = service().getGiftDetail(gift.applicationNo) + val listItem = service().getGifts(GiftStatus.INSPECTION_COMPLETED, null, null, 0, 20).items.single() + + assertEquals(listOf("COMPLETE_DELIVERY", "MARK_UNDELIVERABLE"), detail.availableActions) + assertEquals(listOf("COMPLETE_DELIVERY", "MARK_UNDELIVERABLE"), listItem.availableActions) + } + private fun service() = GiftAdminQueryService( giftRepository = giftRepository, deliveryRepository = deliveryRepository, From 124a615a85e44824fa9e57bfdee23b4836611d68 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 2 Oct 2026 20:08:05 +0900 Subject: [PATCH 48/53] =?UTF-8?q?fix(gift):=20=EB=B0=B0=EC=86=A1=EC=A7=80?= =?UTF-8?q?=20=EB=AF=B8=EC=9E=85=EB=A0=A5=20=EB=A7=8C=EB=A3=8C=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EB=B2=94=EC=9C=84=EB=A5=BC=20=ED=99=95=EC=9E=A5?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/out/persistence/GiftRepository.kt | 8 ++- .../adapter/out/scheduler/GiftScheduler.kt | 2 +- .../out/scheduler/GiftSchedulerTest.kt | 68 ++++++++++++++++++- 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftRepository.kt index d689862b..376fafde 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftRepository.kt @@ -80,7 +80,11 @@ interface GiftRepository : JpaRepository { """ select g from Gift g - where g.status = kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.TRACKING_REGISTERED + where g.status in ( + kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.TRACKING_REGISTERED, + kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.ARRIVED_AT_MAILBOX, + kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.INSPECTION_COMPLETED + ) and exists ( select d.id from GiftDelivery d @@ -90,7 +94,7 @@ interface GiftRepository : JpaRepository { ) """ ) - fun findTrackingRegisteredGiftsWithRecipientAddressDeadlineBefore( + fun findGiftsWithRecipientAddressDeadlineBefore( @Param("now") now: LocalDateTime ): List diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftScheduler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftScheduler.kt index 5ede6cbf..3d7b57c5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftScheduler.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftScheduler.kt @@ -51,7 +51,7 @@ class GiftScheduler( @Transactional fun markExpiredRecipientAddressGiftsUndeliverable(now: LocalDateTime): Int { - val expiredGifts = giftRepository.findTrackingRegisteredGiftsWithRecipientAddressDeadlineBefore(now) + val expiredGifts = giftRepository.findGiftsWithRecipientAddressDeadlineBefore(now) expiredGifts.forEach { gift -> gift.status = GiftStatus.UNDELIVERABLE val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id))) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftSchedulerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftSchedulerTest.kt index 8cef5474..72cf16ad 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftSchedulerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftSchedulerTest.kt @@ -117,6 +117,29 @@ class GiftSchedulerTest @Autowired constructor( assertEquals("배송지 미입력 기한 초과", delivery.undeliverableReason) } + @Test + @DisplayName("배송지 입력 기한이 지난 진행 중 선물은 현재 상태와 무관하게 전달 불가 처리한다") + fun shouldMarkOverdueInProgressGiftsWithoutRecipientAddressUndeliverable() { + val now = LocalDateTime.of(2026, 10, 9, 12, 0) + val gifts = listOf(GiftStatus.ARRIVED_AT_MAILBOX, GiftStatus.INSPECTION_COMPLETED).mapIndexed { index, status -> + saveGift( + status = status, + trackingDeadlineAt = now.minusDays(7), + recipientAddressDeadlineAt = now.minusSeconds(1), + applicationNo = "$APPLICATION_NO-$index" + ) + } + + val updatedCount = scheduler.markExpiredRecipientAddressGiftsUndeliverable(now) + + assertEquals(2, updatedCount) + gifts.forEach { (gift, delivery) -> + assertEquals(GiftStatus.UNDELIVERABLE, gift.status) + assertEquals(now, delivery.undeliverableAt) + assertEquals("배송지 미입력 기한 초과", delivery.undeliverableReason) + } + } + @Test @DisplayName("배송지 입력 기한과 같은 발송 확인 선물은 전달 불가 처리하지 않는다") fun shouldIgnoreTrackingRegisteredGiftWhoseRecipientAddressDeadlineIsNotPast() { @@ -154,6 +177,46 @@ class GiftSchedulerTest @Autowired constructor( assertEquals(null, delivery.undeliverableReason) } + @Test + @DisplayName("미래 또는 미설정 기한과 대상 외 상태는 배송지 미입력 전달 불가 처리하지 않는다") + fun shouldIgnoreUnexpiredAndOutOfScopeGiftsWithoutRecipientAddress() { + val now = LocalDateTime.of(2026, 10, 9, 12, 0) + val unexpiredGifts = listOf( + saveGift( + GiftStatus.ARRIVED_AT_MAILBOX, + now.minusDays(7), + now.plusSeconds(1), + applicationNo = "A-1002609302001" + ), + saveGift( + GiftStatus.INSPECTION_COMPLETED, + now.minusDays(7), + applicationNo = "A-1002609302002" + ) + ) + val terminalGifts = listOf( + GiftStatus.RECEIVED, + GiftStatus.DELIVERED, + GiftStatus.UNDELIVERABLE, + GiftStatus.CANCELED + ).mapIndexed { index, status -> + saveGift( + status, + now.minusDays(7), + now.minusSeconds(1), + applicationNo = "A-100260930201$index" + ) + } + + val updatedCount = scheduler.markExpiredRecipientAddressGiftsUndeliverable(now) + + assertEquals(0, updatedCount) + (unexpiredGifts + terminalGifts).forEach { (_, delivery) -> + assertEquals(null, delivery.undeliverableAt) + assertEquals(null, delivery.undeliverableReason) + } + } + @Test @DisplayName("배송지 미입력 전달 불가를 다시 실행해도 이미 전달 불가인 선물은 처리하지 않는다") fun shouldNotUpdateAlreadyUndeliverableGiftOnSecondRun() { @@ -240,11 +303,12 @@ class GiftSchedulerTest @Autowired constructor( status: GiftStatus, trackingDeadlineAt: LocalDateTime, recipientAddressDeadlineAt: LocalDateTime? = null, - recipientAddress: String? = null + recipientAddress: String? = null, + applicationNo: String = APPLICATION_NO ): Pair { val gift = giftRepository.save( Gift( - applicationNo = APPLICATION_NO, + applicationNo = applicationNo, senderMemberId = SENDER_MEMBER_ID, recipientMemberId = RECIPIENT_MEMBER_ID, status = status, From 5a09c8513517f979daf1619b7d47aa2cb6ad5e7c Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 2 Oct 2026 20:09:04 +0900 Subject: [PATCH 49/53] =?UTF-8?q?fix(test):=20embedded=20Redis=20=EC=A2=85?= =?UTF-8?q?=EB=A3=8C=20=EB=88=84=EC=88=98=EB=A5=BC=20=EB=B0=A9=EC=A7=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../support/EmbeddedRedisInitializer.kt | 21 ++----------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/support/EmbeddedRedisInitializer.kt b/src/test/kotlin/kr/co/vividnext/sodalive/support/EmbeddedRedisInitializer.kt index d13fc92a..ddd3e84c 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/support/EmbeddedRedisInitializer.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/support/EmbeddedRedisInitializer.kt @@ -18,9 +18,7 @@ class EmbeddedRedisInitializer : ApplicationContextInitializer Date: Fri, 2 Oct 2026 20:09:55 +0900 Subject: [PATCH 50/53] =?UTF-8?q?docs(gift):=20=EC=88=98=EC=B7=A8=EC=9D=B8?= =?UTF-8?q?=20=EC=A0=95=EB=B3=B4=20=EC=A0=84=EB=8B=AC=20=EC=A1=B0=EA=B1=B4?= =?UTF-8?q?=EC=9D=84=20=EA=B8=B0=EB=A1=9D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20260929_크리에이터_선물하기/plan-task.md | 50 ++++++++++++++++++- docs/20260929_크리에이터_선물하기/prd.md | 10 ++-- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/docs/20260929_크리에이터_선물하기/plan-task.md b/docs/20260929_크리에이터_선물하기/plan-task.md index d0682ed6..1aa30fbf 100644 --- a/docs/20260929_크리에이터_선물하기/plan-task.md +++ b/docs/20260929_크리에이터_선물하기/plan-task.md @@ -8,7 +8,7 @@ | 작성일 | 2026-09-29 | | 요구사항 기준 | `docs/20260929_크리에이터_선물하기/prd.md` | | API 기준 | `docs/20260929_크리에이터_선물하기/prd.md`의 `8. API 계약` | -| 현재 Phase | Phase 10 완료 | +| 현재 Phase | Phase 12 구현 완료 | | 현재 활성 Goal | 완료 | ## 목표 @@ -1362,6 +1362,50 @@ P7은 FCM 확장 후 전체 상태 전이를 연결하고 최종 회귀로 종 - 검증: `./gradlew test --tests '*GiftQueryServiceTest' --tests '*GiftControllerTest'`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.gift.*' --tests 'kr.co.vividnext.sodalive.v2.api.gift.*'`, `./gradlew ktlintCheck`, `git diff --check` 실행 결과 통과. - 남은 항목: 없음 +## Phase 12: 수취인 정보 전달 완료 불변조건 보강 + +**Phase 결과:** 수취인 필수 정보가 없는 선물은 전달 완료할 수 없고, 배송지 입력 기한이 지난 진행 중 선물은 관리자 진행 상태와 무관하게 전달 불가로 종료된다. + +**선행조건:** `P4-T2`, `P5-T3` 완료. + +### Task 12.1 전달 완료 수취인 정보 필수화 + +**Goal 실행 `P12-T1`:** `COMPLETE_DELIVERY`는 `INSPECTION_COMPLETED` 상태이며 수취인 이름, 휴대폰 번호, 우편번호, 주소가 모두 입력된 경우에만 허용한다. + +- **Files:** Modify `GiftDelivery.kt`, `GiftAdminService.kt`, `GiftAdminQueryService.kt`; Test `GiftAdminServiceTest.kt`, `GiftAdminQueryServiceTest.kt`, `AdminGiftControllerTest.kt`. +- [x] **RED:** 수취인 필수 정보가 없는 `INSPECTION_COMPLETED` 선물의 전달 완료 요청이 거부되고 상태/전달 완료 시각/푸시가 바뀌지 않는 실패 test를 작성한다. +- [x] **RED:** 수취인 필수 정보가 불완전하면 관리자 목록/상세의 `availableActions`에서 `COMPLETE_DELIVERY`가 제외되는 실패 test를 작성한다. +- [x] **RED 확인:** focused test가 기존 구현에서 의도한 assertion으로 실패하는지 확인한다. +- [x] **GREEN:** 수취인 정보 완비 조건을 공통화하고 command와 관리자 action 계산에 적용한다. +- [x] **GREEN 확인:** 같은 focused test 통과를 확인한다. + +### Task 12.2 배송지 미입력 기한 초과 상태 범위 보강 + +**Goal 실행 `P12-T2`:** 배송지 입력 기한이 지난 주소 미입력 선물을 `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED`에서 `UNDELIVERABLE`로 종료한다. + +- **Files:** Modify `GiftRepository.kt`; Test `GiftSchedulerTest.kt`. +- [x] **RED:** 세 상태 모두 기한 초과 + `recipientAddress=null`이면 전달 불가가 되는 실패 test를 작성한다. +- [x] **RED:** 기한 동일/미도래, 주소 입력 완료, 대상 외 상태는 변경되지 않는 회귀 test를 유지한다. +- [x] **RED 확인:** `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED` case가 기존 query에서 실패하는지 확인한다. +- [x] **GREEN:** 기존 만료 조회 query의 상태 조건만 세 상태로 확장한다. +- [x] **GREEN 확인:** scheduler focused test 통과를 확인한다. + +### Phase 12 Gate + +- [x] `GiftAdminServiceTest`, `GiftAdminQueryServiceTest`, `AdminGiftControllerTest`, `GiftSchedulerTest`의 변경 관련 test 통과. +- [x] 전체 `./gradlew --no-daemon test` 통과(8분 33초), 종료 후 embedded Redis 및 Gradle test worker 잔존 없음. +- [x] `ktlintCheck`, `build -x test`, `git diff --check` 통과. +- [x] `AdminGiftControllerTest`에서 주소 미입력 실패와 주소 입력 완료 성공 HTTP 계약 확인. + +**구현 결과:** 수취인 필수 정보 완비 조건을 `GiftDelivery.hasCompleteRecipientInformation()`으로 공통화해 전달 완료 command와 관리자 action 계산에 적용했다. 배송지 입력 기한 초과 조회는 주소 입력 가능 상태 3개로 확장했다. + +### Task 12.3 Embedded Redis 종료 누수 수정 + +- [x] `com.github.codemonstur:embedded-redis:1.4.3`의 `RedisInstance.stop()`과 shutdown hook 등록 동작을 확인했다. +- [x] initializer의 중복 JVM shutdown hook을 제거했다. +- [x] 공식 `onShutdownForceStop(true)` 옵션으로 native Redis 프로세스 종료가 무기한 `waitFor()`에 걸리지 않도록 변경했다. +- [x] 후속 요청에 따라 전체 `./gradlew --no-daemon test`를 실행해 정상 종료를 확인했다. + ## Decision Log | 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 | @@ -1373,12 +1417,14 @@ P7은 FCM 확장 후 전체 상태 전이를 연결하고 최종 회귀로 종 | 2026-09-30 | `PLAN-DEC-005` | 확정 | 운영 반영용 DDL에는 선물 도메인 FK를 명시하고, 선물 신청 건에는 최종 결제 금액 스냅샷만 저장한다 | 사용자 결정: 제대로 가는 방향 선호, `basePriceCan`은 신청 건에 불필요 | `gift-schema.sql`, `Gift` | | 2026-09-30 | `PLAN-DEC-006` | 확정 | 선물 API response datetime은 전역 Jackson 설정이 아니라 DTO response boundary에서 UTC ISO 문자열로 변환한다 | 선물 API만의 공개 계약 보강이며 전역 변경은 기존 API 영향이 큼 | `P8-T1`, `client-api-summary.md` | | 2026-09-30 | `PLAN-DEC-007` | 확정 | 기존 `categoryCode`는 내부 범용코드로 유지하고 분류번호/접수코드/대표품목을 추가하며, 신청번호는 카테고리+날짜별 sequence로 발급한다 | 사용자 확정: 카테고리 필드 의미와 사용자 폼 옵션 표시 규칙 분리 | `P9-T1`~`P9-T3` | +| 2026-10-02 | `PLAN-DEC-008` | 확정 | `COMPLETE_DELIVERY`는 수취인 필수 정보가 모두 입력된 경우에만 허용하고, 배송지 미입력 기한 초과는 주소 입력 가능 상태 3개 전체에서 `UNDELIVERABLE`로 종료한다 | 배송지 없는 선물의 전달 완료와 관리자 상태 진행 후 만료 누락 방지 | `P12-T1`, `P12-T2` | ## 발견된 문제 | ID | 심각도 | 상태 | 발견 내용 | 영향 Goal | 처리 계획 | |---|---|---|---|---|---| -| 없음 | Low | 해결 | 계획 작성 시점의 차단 문제 없음 | 없음 | 없음 | +| `GIFT-BUG-001` | High | 해결 | 수취인 정보가 없는 `INSPECTION_COMPLETED` 선물이 전달 완료될 수 있고, 관리자 상태 진행 후 배송지 미입력 기한 초과 대상에서 누락된다 | `P12-T1`, `P12-T2` | command/action 불변조건과 만료 조회 상태 범위를 테스트 우선으로 보강 완료 | +| `TEST-INFRA-001` | Medium | 해결 | 테스트 timeout 후 embedded Redis native 프로세스와 Gradle worker가 남아 후속 테스트 실행을 방해했다 | `P12-T3` | 잔존 프로세스 정리, 중복 hook 제거, 공식 강제 종료 옵션 적용 후 전체 테스트 정상 종료 확인 | ## 최종 보고 형식 diff --git a/docs/20260929_크리에이터_선물하기/prd.md b/docs/20260929_크리에이터_선물하기/prd.md index 624672ce..1457aca8 100644 --- a/docs/20260929_크리에이터_선물하기/prd.md +++ b/docs/20260929_크리에이터_선물하기/prd.md @@ -846,7 +846,8 @@ Response는 저장된 받을 주소를 `name`, `address`, `phoneNumber`로 내 | `RECEIVED` | `[]` | | `TRACKING_REGISTERED` | `["ARRIVE_MAILBOX", "MARK_UNDELIVERABLE"]` | | `ARRIVED_AT_MAILBOX` | `["COMPLETE_INSPECTION", "MARK_UNDELIVERABLE"]` | -| `INSPECTION_COMPLETED` | `["COMPLETE_DELIVERY", "MARK_UNDELIVERABLE"]` | +| `INSPECTION_COMPLETED`, 수취인 정보 입력 완료 | `["COMPLETE_DELIVERY", "MARK_UNDELIVERABLE"]` | +| `INSPECTION_COMPLETED`, 수취인 정보 미입력/불완전 | `["MARK_UNDELIVERABLE"]` | | `DELIVERED` | `[]` | | `UNDELIVERABLE` | `[]` | | `CANCELED` | `[]` | @@ -858,7 +859,9 @@ Response는 저장된 받을 주소를 `name`, `address`, `phoneNumber`로 내 | 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` | 없음 | +| POST | `/api/v2/admin/gifts/{applicationNo}/complete-delivery` | `INSPECTION_COMPLETED` | `DELIVERED` | 저장된 수취인 이름, 휴대폰 번호, 우편번호, 주소 | + +`complete-delivery`는 `recipientName`, `recipientPhoneNumber`, `recipientZipCode`, `recipientAddress`가 모두 null 또는 공백이 아닐 때만 성공한다. `recipientAddressDetail`은 선택 입력이다. 조건을 만족하지 않으면 상태, 전달 완료 시각, 푸시를 변경하지 않고 요청을 거부한다. 전달불가 Request: @@ -875,9 +878,10 @@ Response는 저장된 받을 주소를 `name`, `address`, `phoneNumber`로 내 | 운송장 등록 기한 24시간 전 안내 | `RECEIVED`, 마감 24시간 전 | 상태 변경 없음 | 팬에게 안내 | | 운송장 미등록 자동취소 | `RECEIVED`, 신청 후 3일 초과 | `CANCELED`, 전액 환불 | 팬에게 자동취소 안내 | | 배송지 입력 기한 24시간 전 안내 | `TRACKING_REGISTERED` 이상, 배송지 미입력, 마감 24시간 전 | 상태 변경 없음 | 크리에이터에게 안내 | -| 배송지 미입력 전달불가 | 배송지 미입력, 운송장 등록 후 7일 초과 | `UNDELIVERABLE`, 사유 저장 | 팬에게 전달불가 안내. 크리에이터에게 추가 푸시 없음 | +| 배송지 미입력 전달불가 | `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED` 중 하나이며 배송지 미입력, 운송장 등록 후 7일 초과 | `UNDELIVERABLE`, 사유 저장 | 팬에게 전달불가 안내. 크리에이터에게 추가 푸시 없음 | 스케줄러는 중복 실행되어도 같은 선물에 중복 환불 또는 중복 푸시가 발생하지 않아야 한다. +배송지 입력 기한과 현재 시각이 같으면 아직 기한 초과가 아니며, `RECEIVED`, `DELIVERED`, `UNDELIVERABLE`, `CANCELED` 상태는 배송지 미입력 전달불가 대상에서 제외한다. ## 9. 푸시 알림 정책 From 5d8e71df83207d4d3b79601225ced9e702e8f684 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 8 Oct 2026 17:38:50 +0900 Subject: [PATCH 51/53] =?UTF-8?q?docs(gift):=20=EC=84=A0=EB=AC=BC=ED=95=A8?= =?UTF-8?q?=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EA=B3=84=EC=95=BD=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=EB=A5=BC=20=EA=B8=B0=EB=A1=9D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260929_크리에이터_선물하기/client-api-summary.md | 6 ++++++ docs/20260929_크리에이터_선물하기/prd.md | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/20260929_크리에이터_선물하기/client-api-summary.md b/docs/20260929_크리에이터_선물하기/client-api-summary.md index ca271522..9f7e371c 100644 --- a/docs/20260929_크리에이터_선물하기/client-api-summary.md +++ b/docs/20260929_크리에이터_선물하기/client-api-summary.md @@ -145,6 +145,12 @@ data class GiftListItemResponse( val priceCan: Int, val categoryName: String, val sizeName: String, + val counterpartMemberId: Long, + val counterpartNickname: String, + val counterpartProfileImageUrl: String, + val recipientAddressRegistered: Boolean, + val recipientAddressDeadlineAt: String?, + val canceledAt: String?, val createdAt: String? ) diff --git a/docs/20260929_크리에이터_선물하기/prd.md b/docs/20260929_크리에이터_선물하기/prd.md index 1457aca8..6ca8bb46 100644 --- a/docs/20260929_크리에이터_선물하기/prd.md +++ b/docs/20260929_크리에이터_선물하기/prd.md @@ -362,8 +362,9 @@ Response: "sizeName": "소형", "categoryName": "인형", "priceCan": 80, - "recipientAddressRequired": false, + "recipientAddressRegistered": false, "recipientAddressDeadlineAt": null, + "canceledAt": null, "createdAt": "2026-09-29T03:00:00Z" } ], @@ -374,6 +375,9 @@ Response: ``` 받은 선물은 `TRACKING_REGISTERED` 이상 상태부터 노출한다. +`recipientAddressRegistered`는 크리에이터 배송지 필수 정보가 모두 입력되었는지 여부다. +`recipientAddressDeadlineAt`은 `direction=RECEIVED`이고 배송지가 아직 입력되지 않은 경우에만 배송지 입력 기한 UTC ISO 문자열로 내려주며, 배송지 입력 완료 후에는 `null`로 내려준다. +`canceledAt`은 취소 상태의 취소일 표시용 UTC ISO 문자열이며, 취소되지 않은 선물은 `null`이다. ### 8.5 선물 상세 조회 From f5f58e44f83cc7a092bae790754708640f3e12db Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 8 Oct 2026 17:38:56 +0900 Subject: [PATCH 52/53] =?UTF-8?q?docs(gift):=20iOS=20=EC=84=A0=EB=AC=BC?= =?UTF-8?q?=ED=95=A8=20=EA=B5=AC=ED=98=84=20=ED=94=84=EB=A1=AC=ED=94=84?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EB=B3=B4=EA=B0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gift-list-page-prompt.md | 26 +- .../ios-gift-feature-implementation-prompt.md | 596 ++++++++++++++++++ .../20260929_크리에이터_선물하기/plan-task.md | 24 + 3 files changed, 631 insertions(+), 15 deletions(-) create mode 100644 docs/20260929_크리에이터_선물하기/ios-gift-feature-implementation-prompt.md diff --git a/docs/20260929_크리에이터_선물하기/gift-list-page-prompt.md b/docs/20260929_크리에이터_선물하기/gift-list-page-prompt.md index f0c70456..77f0e0f9 100644 --- a/docs/20260929_크리에이터_선물하기/gift-list-page-prompt.md +++ b/docs/20260929_크리에이터_선물하기/gift-list-page-prompt.md @@ -177,6 +177,9 @@ Response `data` 예시: "counterpartMemberId": 200, "counterpartNickname": "달빛수집가", "counterpartProfileImageUrl": "https://example.com/profile.png", + "recipientAddressRegistered": false, + "recipientAddressDeadlineAt": null, + "canceledAt": null, "createdAt": "2026-09-18T03:00:00Z" } ], @@ -202,6 +205,9 @@ Response `data` 필드: | `items[].counterpartMemberId` | number | 상대방 회원 ID. `SENT`에서는 받는 크리에이터, `RECEIVED`에서는 보낸 팬 | | `items[].counterpartNickname` | string | 상대방 닉네임 | | `items[].counterpartProfileImageUrl` | string 또는 null | 상대방 프로필 이미지 URL. 없으면 기본 프로필 이미지 표시 | +| `items[].recipientAddressRegistered` | boolean | 크리에이터 배송지 필수 정보가 모두 입력되었는지 여부 | +| `items[].recipientAddressDeadlineAt` | string 또는 null | `direction=RECEIVED`이고 배송지가 아직 입력되지 않은 경우의 배송지 입력 기한. 배송지 입력 완료 후에는 null | +| `items[].canceledAt` | string 또는 null | 취소 상태의 취소일. 취소되지 않은 선물은 null | | `items[].createdAt` | string 또는 null | 신청일. UTC ISO 문자열 | | `page` | number | 현재 페이지 | | `size` | number | 페이지 크기 | @@ -220,21 +226,11 @@ Response `data` 필드: - `direction=SENT`에서는 받는 크리에이터 정보로 해석한다. - `direction=RECEIVED`에서는 보낸 팬 정보로 해석한다. -현재 API만으로 부족한 Figma 표시값: -- 취소일 `canceledAt` -- 배송지 입력 마감일 또는 남은 일수 - -추가 API 보강이 가능하면 리스트 item에 아래 필드를 추가해달라고 요청한다. -```json -{ - "canceledAt": "2026-09-18T03:00:00Z", - "recipientAddressDeadlineAt": "2026-09-25T03:00:00Z", - "sortAt": "2026-09-18T03:00:00Z" -} -``` -- `canceledAt`: 취소 상태의 취소일 표시용 -- `recipientAddressDeadlineAt`: 크리에이터 배송지 입력 안내 배너의 남은 일수 계산용 -- `sortAt`: 신청/취소 최신순 정렬 기준. 취소 건은 취소일, 그 외에는 신청일 +배송지/취소 표시 규칙: +- 배송지 입력 완료 여부는 `recipientAddressRegistered`로 판단한다. +- 배송지 입력 남은 기간은 `recipientAddressDeadlineAt` 기준으로 앱에서 계산한다. +- 배송지가 이미 입력되었으면 `recipientAddressDeadlineAt`은 null이다. +- 취소 상태의 날짜 라벨은 `canceledAt`이 있으면 `{canceledAt} 취소`로 표시하고, 그 외에는 `{createdAt} 신청`으로 표시한다. 로딩/에러/빈 상태: - 최초 로딩 중에는 기존 앱 리스트 스켈레톤 또는 loading 패턴을 사용한다. diff --git a/docs/20260929_크리에이터_선물하기/ios-gift-feature-implementation-prompt.md b/docs/20260929_크리에이터_선물하기/ios-gift-feature-implementation-prompt.md new file mode 100644 index 00000000..538a0812 --- /dev/null +++ b/docs/20260929_크리에이터_선물하기/ios-gift-feature-implementation-prompt.md @@ -0,0 +1,596 @@ +# iOS 선물하기 전체 기능 단일 구현 프롬프트 + +이 문서는 iOS 앱에서 크리에이터 선물하기 사용자 기능을 한 번에 구현할 때 사용하는 단일 프롬프트다. 기존 분할 문서의 사용자 화면 범위를 합치고, Figma UI 확인 결과와 신청 완료 후 상세 이동 정책을 반영했다. + +관리자 화면은 iOS 앱 범위가 아니므로 제외한다. + +## 참고 문서 + +- `docs/20260929_크리에이터_선물하기/prd.md` +- `docs/20260929_크리에이터_선물하기/client-api-summary.md` +- `docs/20260929_크리에이터_선물하기/gift-send-page-prompt.md` +- `docs/20260929_크리에이터_선물하기/gift-list-page-prompt.md` +- `docs/20260929_크리에이터_선물하기/gift-detail-tracking-page-prompt.md` +- `docs/20260929_크리에이터_선물하기/gift-received-detail-page-prompt.md` + +## Figma 확인 노드 + +- 선물 보내기: 빈 입력 `2481:18904`, 입력 완료/CTA 활성 `2481:18957` +- 선물함 리스트: 보낸 내역 있음 `2481:19382`, 보낸 내역 없음 `2520:35587`, 받은 내역 없음 `2531:36283`, 받은 내역 있음 `2531:36194`, 받은 상태별 카드 `2531:36237` +- 보낸 선물 상세/운송장: 등록 전 `2481:19011`, 등록 후 `2481:19152`, 전달 완료 `2481:19320`, 전달 불가 `2481:19456`, 취소 다이얼로그 `2481:19500`, 취소 완료 `2481:19521`, 운송장 입력 전 `2481:19066`, 운송장 입력 완료 `2481:19109` +- 받은 선물 상세/배송지: 배송지 입력 전 `2531:36290`, 배송지 입력 완료 `2531:36399`, 전달 완료 `2531:36330`, 전달 불가 `2531:36422` + +Figma 재확인 메모: + +- 모든 화면은 iPhone 13 기준 402px 폭, black/dark surface 기반이다. +- 상단 status bar와 nav/top 영역은 고정하고, 본문은 `scroll area`로 분리한다. +- 주요 섹션은 14pt 좌우 여백, 카드/입력/배너는 rounded dark surface, 섹션 사이 divider를 사용한다. +- 선물 보내기 화면은 안내 배너, 받는 크리에이터, 선물 사이즈, 카테고리, 보내는 사람, 약관 동의, 하단 single CTA 구조다. +- 선물함 리스트는 안내 배너와 104pt 높이 카드 리스트 구조다. 실제 구현에서는 Figma에 있는 `신청 내역`, `선물 내역` 섹션 타이틀을 표시하지 않는다. +- 보낸 상세는 상태 hero, 사서함/등록 안내, 선물 정보, 하단 action bar를 중심으로 한다. +- 받은 상세는 상태 hero와 문의 버튼, 선물 정보, 받는 주소 카드 구조를 상태별로 바꾼다. + +--- + +## iOS 구현 프롬프트 + +```text +iOS 앱의 크리에이터 선물하기 사용자 기능 전체를 Figma와 API 계약 기준으로 구현해줘. + +범위: +- 선물 보내기 신청 화면 +- 선물함 리스트 화면 +- 선물 상세 화면 +- 보낸 선물의 운송장 등록 화면 +- 보낸 선물의 신청 취소 확인/취소 완료 화면 +- 받은 선물의 배송지 입력 UI +- 받은 선물의 배송지 입력 완료/전달 완료/전달 불가 상세 UI +- 푸시/딥링크 진입 시 선물 상세 이동 + +범위 밖: +- 관리자 화면 +- 새 디자인 시스템 생성 +- 외부 택배 조회/운송장 실시간 검증 +- 약관 본문 제공 API 신규 구현 +- 선물 이미지, 첨부파일, 메시지 카드 +- 임의 URL 연결 + +공통 전제: +- iOS 기존 디자인 시스템, 컴포넌트, API client, 인증, 상태 관리, toast/dialog, navigation, loading/error 패턴을 재사용한다. +- Figma visual structure와 문구를 우선 따르되, 데이터 노출과 CTA 조건은 API 계약을 우선한다. +- 모든 API 응답은 공통 envelope를 사용하며 실제 payload는 `response.data` 또는 기존 iOS API client의 data unwrap 결과다. +- 모든 날짜는 UTC ISO 문자열이다. 앱 공통 formatter가 있으면 재사용하고, 상세 필드 날짜는 Figma처럼 `YYYY.MM.DD`로 표시한다. +- 개인정보 노출 경계를 반드시 지킨다. 로그인 회원이 알아야 하는 본인 정보만 표시하고, 상대방의 이름/휴대폰/주소는 표시하지 않는다. +- Figma 기준으로 상단 navigation은 고정하고, navigation 아래 본문만 스크롤한다. +- iOS safe area, keyboard avoidance, bottom action bar는 기존 앱 패턴을 따른다. +- 새 route/deep link가 필요하면 기존 navigation/deep link 등록 방식에 맞춘다. + +가장 중요한 이동 정책: +- 선물 보내기 신청 `POST /api/v2/gifts` 성공 시 완료 dialog에서 멈추지 말고, 성공 응답의 `applicationNo`로 선물 상세 페이지로 이동한다. +- 신청 완료 직후 이동 대상은 `GET /api/v2/gifts/{applicationNo}` 기반 상세 페이지다. +- 신청 성공 후 뒤로가기로 입력 폼에 다시 돌아와 중복 신청하지 않도록 기존 iOS navigation 정책에 맞춰 replace 또는 stack 정리를 적용한다. +- 선물함 리스트 item 탭, 푸시 딥링크, 신청 완료 후 이동은 모두 같은 상세 화면으로 들어가고, 상세 API의 `direction`, `trackingRequired`, `recipientAddressRequired`, `status`로 UI를 분기한다. + +보낸 사람/받는 사람 상세 차이: +- 같은 `GET /api/v2/gifts/{applicationNo}` 상세 API를 사용하지만 `direction`에 따라 상세 페이지를 다르게 표시한다. +- `direction=SENT`는 보낸 팬 관점이다. + - 받는 크리에이터 닉네임은 `giftInfo.recipientCreatorNickname`으로 표시한다. + - 팬 본인이 신청 시 입력한 발신자 정보 `senderInfo`를 표시할 수 있다. + - 관리자 전역 사서함 `mailbox`가 있으면 `보내실 주소` 또는 `받는 사서함` 섹션에 표시한다. + - 크리에이터가 입력한 배송지 `recipientAddress`는 표시하지 않는다. + - `trackingRequired=true`이면 운송장 등록 CTA를 표시한다. + - `status=RECEIVED`이면 신청 취소 action을 제공할 수 있다. +- `direction=RECEIVED`는 받는 크리에이터 관점이다. + - 보낸 팬 닉네임은 `giftInfo.senderNickname`으로 표시한다. + - 팬이 신청 시 입력한 이름/휴대폰/주소 `senderInfo`는 표시하지 않는다. + - `mailbox`, `giftInfo.applicationNo`, `giftInfo.paidCan`, `giftInfo.tracking`은 표시하지 않는다. + - 크리에이터 본인이 입력한 배송지 `recipientAddress`가 있을 때만 받는 주소 섹션을 표시한다. + - `recipientAddressRequired=true`이면 배송지 입력 UI를 표시한다. + +1. 선물 보내기 화면 + +Figma: +- 빈 입력 상태: `2481:18904` +- 입력 완료/CTA 활성 상태: `2481:18957` + +진입 전제: +- 크리에이터 닉네임과 `memberId`를 받아 이 화면으로 이동하는 기존 흐름은 이미 있다. +- 이 작업에서 새 진입 흐름을 만들지 않는다. +- 전달받은 크리에이터 닉네임은 `받는 크리에이터` 영역에 표시한다. +- 전달받은 `memberId`는 `POST /api/v2/gifts`의 `recipientMemberId`로 보낸다. +- 둘 중 하나라도 없으면 기존 앱의 오류 처리 또는 안전한 뒤로가기 패턴을 따른다. + +화면 구조: +- 상단 타이틀: `선물 보내기` +- 안내 배너 + - 제목: `선물 보내기 베타 서비스 안내` + - 본문: `베타 기간 동안은 일부 기능만 제공됩니다. 크리에이터에게 마음을 잘 전할 수 있도록 더 넓어진 선물 보내기로 곧 다시 만나요!` +- 받는 크리에이터: Figma 프로필 row 형태로 전달받은 닉네임 표시 +- 선물 사이즈: `GET /api/v2/gifts/form-options`의 `sizes` 바인딩 +- 카테고리: 같은 응답의 `categories` 바인딩 +- 보내는 사람 입력: 이름, 휴대폰 번호, 우편번호, 주소, 상세 주소 +- 이용 약관 동의: 발송 규정 요약 박스와 체크박스 2개 +- 하단 CTA: `{salePriceCan}캔으로 선물 보내기` + +폼 옵션 조회: +- 화면 진입 시 `GET /api/v2/gifts/form-options` 호출 +- `sizes` 기본 선택은 서버가 내려준 첫 번째 항목 +- 가격 표시는 `salePriceCan`을 사용하고 신청 금액도 `salePriceCan`을 사용 +- `basePriceCan != salePriceCan`이면 기존 앱 할인/정가 표시 패턴이 있을 때만 정가를 함께 표시 +- 사이즈 설명은 API에 없으므로 앱 로컬 매핑 사용 + - `SMALL`: `세 변의 합 100cm 이하 · 5kg 이하` + - `MEDIUM`: `세 변의 합 120cm 이하 · 15kg 이하` + - `LARGE`: `세 변의 합 160cm 이하 · 20kg 이하` +- 카테고리 select는 기존 iOS picker/bottom sheet/dialog 패턴 중 앱에서 이미 쓰는 방식을 사용 +- `requiresDamageWaiver=true` 카테고리 선택 시에만 파손 면책 동의 row 표시 +- `requiresDamageWaiver=false` 선택 시 파손 면책 동의 row를 숨기고 `damageWaiverAgreed=false`로 초기화 + +입력 필드 매핑: +- 이름 input -> `senderName` +- 휴대폰 번호 input -> `senderPhoneNumber` +- 우편번호 input -> `senderZipCode` +- 주소 input -> `senderAddress` +- 상세 주소 input -> `senderAddressDetail` +- 우편번호 검색은 기존 주소 검색 기능을 재사용한다. + +동의 필드: +- `소다라이브 크리에이터 상품 전달 이용약관에 동의합니다. (필수)` -> `senderTermsAgreed` +- `상품 전달을 위한 개인정보 수집·이용에 동의합니다. (필수)` -> `senderPrivacyAgreed` +- `파손 및 분실 면책 사항에 동의합니다. (필수)` -> `damageWaiverAgreed`, 필요한 카테고리에서만 표시/필수 +- 각 약관 문구의 `(필수)` 부분은 터치 가능한 링크로 처리한다. +- `(필수)` 터치 시 약관 Notion 웹페이지로 이동한다. +- sender 약관 2개와 recipient 약관 2개는 모두 같은 Notion 페이지로 이동하면 된다. +- Notion 페이지 URL은 실제 구현 시 입력받도록 처리하고, 이 프롬프트에서 임의 URL을 하드코딩하지 않는다. + +CTA 활성 조건: +- `memberId` 있음 +- 사이즈 선택됨 +- 카테고리 선택됨 +- 이름, 휴대폰 번호, 우편번호, 주소 입력됨 +- 이용약관 동의 true +- 개인정보 동의 true +- 선택 카테고리의 `requiresDamageWaiver=true`이면 파손 면책 동의 true +- submit 중에는 중복 탭 방지와 loading 표시 +- validation 실패 시 `POST /api/v2/gifts`를 호출하지 않는다. + +선물 신청 API: +- Method: `POST` +- URL: `/api/v2/gifts` +- Request: +```json +{ + "recipientMemberId": 100, + "senderName": "김소다", + "senderPhoneNumber": "01000000000", + "senderZipCode": "12345", + "senderAddress": "서울시 강남구 ...", + "senderAddressDetail": "123동 456호", + "sizeCode": "SMALL", + "categoryId": 1, + "senderTermsAgreed": true, + "senderPrivacyAgreed": true, + "damageWaiverAgreed": true +} +``` +- Response data: +```json +{ + "applicationNo": "A-1002609300001", + "status": "RECEIVED", + "statusName": "접수 완료", + "priceCan": 80, + "trackingDeadlineAt": "2026-10-02T03:00:00Z" +} +``` +- 성공 시 `applicationNo`로 선물 상세 페이지로 이동한다. +- 실패 시 서버 메시지를 기존 앱 오류 노출 방식으로 표시하고 입력값은 유지한다. + +2. 선물함 리스트 화면 + +Figma: +- 보낸 내역 있음: `2481:19382` +- 보낸 내역 없음: `2520:35587` +- 받은 내역 없음: `2531:36283` +- 받은 내역 있음: `2531:36194` +- 받은 상태별 카드: `2531:36237` + +공통 요구사항: +- 상단 타이틀: `선물함` +- 상단 뒤로가기 navigation은 고정 +- navigation 아래 콘텐츠만 스크롤 +- Figma에 보이는 `신청 내역`, `선물 내역` 섹션 타이틀은 표시하지 않는다. +- 리스트는 서버 응답 순서를 우선 사용한다. 현재 API만으로 취소일 기준 재정렬을 임의 구현하지 않는다. +- item 탭 시 `applicationNo`를 전달해 선물 상세 페이지로 이동한다. + +API: +- Method: `GET` +- URL: `/api/v2/gifts?type=ALL&page=0&size=20` +- 이 화면에서는 `SENT`, `RECEIVED`를 따로 호출하지 않고 `type=ALL`을 사용한다. +- `items[].direction`으로 보낸/받은 카드 UI를 결정한다. +- `hasNext=true`이면 스크롤 하단에서 다음 page를 추가 조회한다. +- 기존 앱에 pull-to-refresh가 있으면 page 0부터 다시 조회한다. + +Response data 예시: +```json +{ + "totalCount": 2, + "items": [ + { + "applicationNo": "A-1002609300001", + "direction": "SENT", + "status": "RECEIVED", + "statusName": "접수 완료", + "priceCan": 80, + "categoryName": "아크릴/스탠드", + "sizeName": "소형", + "counterpartMemberId": 200, + "counterpartNickname": "달빛수집가", + "counterpartProfileImageUrl": "https://example.com/profile.png", + "recipientAddressRegistered": false, + "recipientAddressDeadlineAt": null, + "canceledAt": null, + "createdAt": "2026-09-18T03:00:00Z" + } + ], + "page": 0, + "size": 20, + "hasNext": false +} +``` + +보낸 선물 카드 (`direction=SENT`): +- 상대방은 선물을 받는 크리에이터다. +- 상단 안내 배너 표시 + - 제목: `사서함을 통해 안전하게 전달됩니다` + - 본문: `크리에이터 및 팬의 주소는 공개되지 않습니다. 선물은 소다라이브 사서함에 도착한 뒤 크리에이터에게 전달됩니다.` +- 카드에는 `statusName`, 날짜 라벨, 크리에이터 프로필/닉네임, `sizeName · categoryName`, chevron 표시 +- 날짜 라벨은 기본 `{createdAt} 신청`; 취소일 필드가 없으면 임의 취소일 계산 금지 +- `status=CANCELED`이고 `canceledAt`이 있으면 날짜 라벨은 `{canceledAt} 취소`로 표시한다. +- 보낸 선물 empty는 안내 배너만 표시하고 별도 empty title/body를 추가하지 않는다. + +받은 선물 카드 (`direction=RECEIVED`): +- 상대방은 선물을 보낸 팬이다. +- 받은 선물 empty + - 제목: `전달 예정인 선물이 없어요` + - 본문: `베타 기간에는 팬이 선물을 보내는 기능만 제공돼요. 더 다양해진 선물 기능으로 곧 다시 만나요!` + - 버튼: `의견 남기기`, 기존 문의/피드백 이동 패턴 사용 +- 받은 선물 있음 상단 안내 배너 + - 제목: `배송지 입력 기간 안내` + - 본문: `알림을 받은 날부터 7일 이내에 배송지를 입력해 주세요. 기한 내 입력하지 않으면 선물이 반송됩니다.` +- 배송지 입력이 필요한 선물이 있으면 리스트 상단에 파란 안내 배너 표시 + - 제목: `팬이 보낸 선물이 있어요!` + - 본문: `선물이 늦지 않게 전달될 수 있도록 배송지를 입력해 주세요.` + - 보조 문구는 `recipientAddressRegistered=false`이고 `recipientAddressDeadlineAt`이 있으면 남은 일수로 표시한다. + - 배송지가 이미 입력되어 `recipientAddressRegistered=true`이면 `recipientAddressDeadlineAt`은 null로 온다. + - 배너 탭 시 배송지 입력이 필요한 첫 번째 선물 상세로 이동 +- 카드에는 팬 프로필/닉네임, `{createdAt} 신청`, 상태 메인/서브 문구, chevron 표시 +- 하단 `문의하기` 버튼은 기존 앱 정책에 맞춰 고정 또는 스크롤 내부 버튼으로 둔다. + +상태 표시 매핑: +- 보낸 선물 카드 + - `RECEIVED`: `운송장 등록 필요` + - `TRACKING_REGISTERED`: `발송 확인` + - `ARRIVED_AT_MAILBOX`: `사서함 도착` + - `INSPECTION_COMPLETED`: `선물 검수 완료` + - `DELIVERED`: `전달 완료` + - `UNDELIVERABLE`: `전달 불가` + - `CANCELED`: `신청 취소` +- 받은 선물 카드 + - `TRACKING_REGISTERED`: main `배송지 입력 필요`, sub `전달 예정` + - `ARRIVED_AT_MAILBOX`: main `선물 검수`, sub `선물 확인 중` + - `INSPECTION_COMPLETED`: main `선물 검수 완료`, sub `배송 중` + - `DELIVERED`: main `전달 완료`, sub 없음 + - `UNDELIVERABLE`: main `전달 불가`, sub 없음 + +3. 공통 선물 상세 화면 + +API: +- Method: `GET` +- URL: `/api/v2/gifts/{applicationNo}` +- 리스트 item 탭, 신청 완료 후 이동, 푸시 딥링크 진입 모두 이 API로 상세를 조회한다. + +Response data 핵심 필드: +```json +{ + "applicationNo": "A-1002609300001", + "direction": "SENT", + "status": "RECEIVED", + "statusName": "접수 완료", + "giftInfo": { + "recipientCreatorNickname": "달빛수집가", + "senderNickname": null, + "sizeName": "소형", + "categoryName": "잡화", + "applicationNo": "A-1002609300001", + "paidCan": 100, + "tracking": null, + "shippingRequestedAt": null + }, + "senderInfo": { + "name": "김소다", + "phoneNumber": "01012345678", + "address": "(04030) 서울특별시 마포구 양화로 000, 3층" + }, + "recipientAddress": null, + "mailbox": { + "name": "소다라이브 사서함", + "address": "(06174) 서울특별시 강남구 테헤란로108길 8, 유민빌딩 4층", + "phoneNumber": "02-2055-1477" + }, + "trackingRequired": true, + "recipientAddressRequired": false, + "recipientAddressDeadlineAt": null, + "delivery": { + "canceledAt": null, + "undeliverableAt": null, + "undeliverableReason": null + }, + "statusTimeline": [] +} +``` + +진입 처리: +- 화면 진입 시 상세 API 호출 +- 로딩 중에는 기존 상세 로딩/스켈레톤 패턴 사용 +- 실패 시 기존 toast/dialog와 재시도 패턴 사용 +- `direction`이 현재 화면 분기와 맞지 않아도 별도 화면을 만들지 말고 같은 상세 화면 안에서 `direction` 기준으로 렌더링한다. + +진행 상태 progress: +- Figma의 진행 단계가 4단계처럼 보여도 4단계로 하드코딩하지 않는다. +- API `statusTimeline`의 정상 진행 5개 상태를 이 순서로 표시한다. + 1. `RECEIVED`: `신청 접수` + 2. `TRACKING_REGISTERED`: `발송 확인` + 3. `ARRIVED_AT_MAILBOX`: `사서함 도착` + 4. `INSPECTION_COMPLETED`: `검수 완료` + 5. `DELIVERED`: `전달 완료` +- `occurredAt`이 있으면 완료 색상과 `MM.DD` 표시 +- `occurredAt=null`이면 비활성 색상, 날짜 미표시 +- `UNDELIVERABLE`, `CANCELED`는 progress step으로 추가하지 않는다. + +4. 보낸 선물 상세와 운송장 등록 + +Figma: +- 상세 운송장 등록 전 `2481:19011` +- 상세 운송장 등록 후 `2481:19152` +- 상세 전달 완료 `2481:19320` +- 전달 불가 `2481:19456` +- 취소 다이얼로그 `2481:19500` +- 취소 완료 `2481:19521` +- 운송장 등록 입력 전 `2481:19066` +- 운송장 등록 입력 완료 `2481:19109` + +적용 조건: +- `direction=SENT` + +상단 타이틀: +- 일반 상세: `신청 상세` +- 취소 완료 페이지: `취소 완료` +- 운송장 등록: `운송장 등록` + +보낸 상세 hero 문구: +- `RECEIVED`: title `선물 신청 완료!`, body `선물이 정상적으로 접수되었어요!` +- `TRACKING_REGISTERED`: chip `발송 확인`, title `사서함으로 이동 중이에요` +- `ARRIVED_AT_MAILBOX`: chip `사서함 도착`, title `선물이 사서함에 도착했어요` +- `INSPECTION_COMPLETED`: chip `검수 완료`, title `선물 검수를 완료했어요` +- `DELIVERED`: chip `전달 완료`, title `크리에이터에게 선물을 전달했어요` +- `UNDELIVERABLE`: chip `전달 불가`, title `전달할 수 없는 품목입니다`, body `보내주신 선물은 SODALIVE 선물 정책에 따라 크리에이터에게 전달할 수 없는 품목으로 확인되었습니다.` +- `CANCELED`: 일반 상세 대신 취소 완료 UI 표시 + +보낸 상세 섹션: +- 선물 정보는 항상 표시 + - 크리에이터: `giftInfo.recipientCreatorNickname` + - 사이즈: `giftInfo.sizeName` + - 카테고리: `giftInfo.categoryName` + - 신청번호: `applicationNo` 또는 `giftInfo.applicationNo` + - 캔: `giftInfo.paidCan`이 null이 아닐 때 표시 + - 운송장: `giftInfo.tracking`이 null이 아닐 때 표시 + - 검수일/전달 완료일은 `statusTimeline`에서 찾는다. +- `senderInfo`가 있으면 보내는 사람 섹션 표시 +- `mailbox`가 있으면 보내실 주소/받는 사서함 섹션 표시 +- `delivery.undeliverableReason`이 있으면 사유 섹션 표시 +- `recipientAddress`는 보낸 팬 상세에 표시하지 않는다. + +보낸 상세 action: +- `trackingRequired=true`이면 주요 CTA `운송장 작성하기` 표시, 운송장 등록 화면으로 이동 +- `direction=SENT && status=RECEIVED`이면 보조 action `신청 취소` 표시 가능 +- `status=DELIVERED && direction=SENT`이면 기존 리뷰 작성 화면/플로우가 있으면 `리뷰 남기기` 연결 +- `문의하기`, `선물 정책 확인`은 기존 이동 경로가 있으면 연결하고, 없으면 숨기거나 기존 앱 정책에 맞춰 처리 + +신청 취소: +- `신청 취소` 탭 시 즉시 API 호출 금지, 기존 `v2 dialog` 확인 팝업 표시 +- 제목: `신청 취소` +- 본문: + - `{크리에이터}에게 보내는 선물을 취소할까요?` + - `선물 보내기 규정에 따라 선물 취소 및 사용한 캔이 모두 환불됩니다.` +- 왼쪽 action: `나가기`, dialog만 닫음 +- 오른쪽 destructive action: `취소하기`, `POST /api/v2/gifts/{applicationNo}/cancel` 호출 +- 실패 시 dialog를 닫지 말고 기존 에러 패턴 표시 +- 성공 시 취소 완료 페이지로 replace navigation + +취소 완료 페이지: +- 상세 API 응답이 `status=CANCELED`인 경우에도 표시 +- hero title: `선물 신청이 취소되었어요` +- hero body: `{크리에이터}에게 보내는 선물 신청({applicationNo})이 취소되었습니다. 취소한 신청은 되돌릴 수 없으며, 다시 보내려면 새로 신청해 주세요.` +- 취소 상세: 크리에이터, 사이즈, 카테고리, 신청번호, 신청일, 취소일, 환불 캔 +- 뒤로가기는 취소 전 상세/등록 화면으로 돌아가지 않게 한다. + +운송장 등록 화면: +- 상세에서 `trackingRequired=true`일 때만 진입 +- 진입 시 상세 API를 다시 조회 +- 조회 결과 `trackingRequired=false`이면 기존 오류 처리 후 상세로 복귀 +- 신청 건 요약 카드: `applicationNo`, 크리에이터 닉네임, `sizeName · categoryName · 신청일` +- 보내실 주소: `mailbox` 사용. `mailbox=null`이면 등록을 막고 `받을 주소가 등록되어 있지 않습니다.` 오류 표시 +- 운송장 정보: 택배사 select, 운송장 번호 input +- 등록 안내: Figma 안내 문구 유지 +- 하단 action bar: 왼쪽 `신청 취소`, 오른쪽 `등록 완료` + +택배사 로컬 상수: +```swift +let giftCourierCompanyNames = [ + "CJ대한통운", + "우체국택배", + "GS25 편의점택배", + "CU 편의점택배", + "한진택배", + "롯데택배", + "로젠택배", + "경동택배", + "대신택배", + "일양로지스", + "천일택배", + "합동택배", + "건영택배", + "농협택배" +] +``` +- 이미 앱 공통 택배사 상수가 있으면 그것을 우선 사용 +- API에는 선택한 한글 표시명을 그대로 `courierCompanyName`으로 보낸다. + +운송장 등록 CTA 활성 조건: +- 택배사 선택됨 +- 운송장 번호가 공백이 아님 +- submit 중 중복 탭 방지 + +운송장 등록 API: +- Method: `POST` +- URL: `/api/v2/gifts/{applicationNo}/tracking` +- Request: +```json +{ + "courierCompanyName": "한진택배", + "trackingNumber": "123456789012" +} +``` +- 성공 시 성공 toast 표시 후 상세 페이지로 돌아가거나 replace navigation으로 상세를 다시 열고, 상세 API를 재조회해 `TRACKING_REGISTERED` 상태를 표시한다. + +5. 받은 선물 상세와 배송지 입력 + +Figma: +- 배송지 입력 전 `2531:36290` +- 배송지 입력 완료 `2531:36399` +- 전달 완료 `2531:36330` +- 전달 불가 `2531:36422` + +적용 조건: +- `direction=RECEIVED` + +받은 상세 개인정보 규칙: +- `senderInfo`, `mailbox`, `giftInfo.applicationNo`, `giftInfo.paidCan`, `giftInfo.tracking`은 표시하지 않는다. +- `recipientAddress=null`이면 받는 주소 섹션 전체를 숨긴다. 빈 섹션, skeleton, `-` 값도 표시하지 않는다. +- 전달 불가 화면에서는 `recipientAddress`가 응답에 있어도 받는 주소 섹션을 표시하지 않는다. + +배송지 입력 전 UI: +- 적용 조건: `direction=RECEIVED && recipientAddressRequired=true` +- 상단 타이틀: `선물 받기` +- 개인정보 이용 안내 배너 + - 제목: `개인정보 이용 안내` + - 본문: `크리에이터 및 팬의 주소는 공개되지 않습니다. 입력한 배송지는 선물 전달 및 필요한 사고 처리에서 사용되며, 전달 완료 후 파기됩니다.` +- 전달 예정 선물 섹션 + - 발송인: `giftInfo.senderNickname` + - 사이즈: `giftInfo.sizeName` + - 카테고리: `giftInfo.categoryName` +- 배송지 입력 섹션 + - 이름 `recipientName` + - 휴대폰 번호 `recipientPhoneNumber` + - 우편번호 `recipientZipCode` + - 주소 `recipientAddress` + - 상세 주소 `recipientAddressDetail` +- 우편번호 검색은 기존 앱 주소 검색/Kakao 우편번호 래퍼를 재사용 +- 이용 약관 동의 + - `소다라이브 크리에이터 상품 전달 이용약관 및 전달 가능 제한 품목을 확인하고 동의합니다.(필수)` -> `recipientTermsAgreed` + - `상품 전달을 위해 수령인 성명·연락처·주소 등 필요한 개인정보를 일시적으로 수집·이용하는 것에 동의합니다.(필수)` -> `recipientPrivacyAgreed` + - 각 약관 문구의 `(필수)` 부분은 터치 가능한 링크로 처리한다. + - `(필수)` 터치 시 약관 Notion 웹페이지로 이동한다. + - sender 약관 2개와 recipient 약관 2개는 모두 같은 Notion 페이지로 이동하면 된다. + - Notion 페이지 URL은 실제 구현 시 입력받도록 처리하고, 이 프롬프트에서 임의 URL을 하드코딩하지 않는다. +- 하단 CTA: `선물 받기` +- CTA 활성 조건: 이름, 휴대폰, 우편번호, 주소 입력됨 + 약관 2개 동의됨 + +배송지 입력 API: +- Method: `POST` +- URL: `/api/v2/gifts/{applicationNo}/recipient-address` +- Request: +```json +{ + "recipientName": "크리에이터이름", + "recipientPhoneNumber": "01012345678", + "recipientZipCode": "04030", + "recipientAddress": "서울특별시 마포구 양화로 000", + "recipientAddressDetail": "3층", + "recipientTermsAgreed": true, + "recipientPrivacyAgreed": true +} +``` +- 성공 시 성공 toast 표시 후 상세 API를 다시 조회하거나 replace navigation으로 상세를 다시 열어 배송지 입력 완료 UI를 표시한다. +- 실패 시 기존 form error/toast/dialog 패턴을 따르고 입력값은 유지한다. + +배송지 입력 완료 상세: +- 적용 조건: `direction=RECEIVED && recipientAddressRequired=false && status`가 `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED` 중 하나이며 `recipientAddress`가 있음 +- 상단 타이틀: `선물 상세` +- hero title: `배송지 입력 완료!` +- hero body: `빠른 시일 내로 선물을 전달 드릴게요.` +- `문의하기` 버튼은 기존 카카오 채널 문의로 연결 +- 선물 정보: 발송인, 사이즈, 카테고리, 배송 신청일 +- 받는 주소: `recipientAddress.name`, `recipientAddress.phoneNumber`, `recipientAddress.address` + +전달 완료 상세: +- 적용 조건: `direction=RECEIVED && status=DELIVERED` +- hero title: `선물 전달 완료!` +- hero body: `팬이 보낸 선물이 도착했어요. 선물이 도착하지 않은 경우, 문의해 주세요.` +- 선물 정보: 발송인, 사이즈, 카테고리, 배송 신청일, 배송 완료일 +- 받는 주소가 있으면 표시하고 보조 문구 `배송 완료 3일 후 해당 정보는 사라져요` 표시 + +전달 불가 상세: +- 적용 조건: `direction=RECEIVED && status=UNDELIVERABLE` +- chip: `전달 불가` +- hero title: `전달할 수 없는 품목입니다` +- hero body: `해당 선물은 SODALIVE 선물 정책에 따라 크리에이터에게 전달할 수 없는 품목으로 확인되었습니다.` +- `문의하기`: 기존 카카오 채널 문의로 연결 +- `선물 정책 확인`: 실제 외부 URL을 임의 연결하지 않는다. 기존 앱 정책상 no-op 금지이면 `준비 중입니다.` toast만 표시 +- 사유 섹션 + - 제목: `delivery.undeliverableReason`이 있으면 그 값, 없으면 `전달 불가` + - 안내 문구: `해당 선물은 정책에 따라 폐기되며 반송되지 않습니다.` +- 배송지 입력 UI는 표시하지 않는다. + +6. 푸시/딥링크 + +- 선물 푸시 딥링크는 `deepLinkValue=GIFT_DETAIL`, `deepLinkId=applicationNo`를 사용한다. +- 딥링크 진입 시 바로 특정 보낸/받은 화면을 결정하지 말고, `GET /api/v2/gifts/{applicationNo}`를 호출한 뒤 응답의 `direction`, `trackingRequired`, `recipientAddressRequired`, `status`로 상세 UI를 결정한다. +- 권한 오류 또는 미존재 오류는 기존 앱 딥링크 오류 처리 패턴을 따른다. + +7. API 요약 + +- `GET /api/v2/gifts/form-options`: 선물 신청 폼 옵션 조회 +- `POST /api/v2/gifts`: 선물 신청 접수 +- `GET /api/v2/gifts?type=ALL&page=0&size=20`: 선물함 리스트 조회 +- `GET /api/v2/gifts/{applicationNo}`: 선물 상세 조회 +- `POST /api/v2/gifts/{applicationNo}/cancel`: 보낸 선물 신청 취소 +- `POST /api/v2/gifts/{applicationNo}/tracking`: 보낸 선물 운송장 등록 +- `POST /api/v2/gifts/{applicationNo}/recipient-address`: 받은 선물 배송지 입력 +- `POST /api/v2/gifts/{applicationNo}/review`: 전달 완료 후 팬 리뷰 작성, 기존 리뷰 플로우가 있을 때 연결 + +8. 검증 체크리스트 + +- 선물 보내기 진입 시 전달받은 크리에이터 닉네임과 `memberId`가 각각 화면/API에 연결되는지 확인한다. +- 화면 진입 시 `GET /api/v2/gifts/form-options`가 호출되는지 확인한다. +- 필수 입력/동의가 누락되면 신청 API를 호출하지 않는지 확인한다. +- `requiresDamageWaiver=true` 카테고리에서만 파손 면책 동의가 표시되고 필수인지 확인한다. +- 선물 신청 성공 후 `applicationNo`로 상세 페이지로 이동하는지 확인한다. +- 신청 완료 후 뒤로가기로 입력 폼에 돌아와 중복 신청하지 않는지 확인한다. +- 선물함 리스트는 `type=ALL`로 한 번 조회하고 `direction`으로 카드 UI를 나누는지 확인한다. +- Figma의 `신청 내역`, `선물 내역` 섹션 타이틀이 실제 화면에 노출되지 않는지 확인한다. +- 리스트 item 탭, 푸시 딥링크, 신청 완료 이동이 모두 같은 상세 조회 플로우를 사용하는지 확인한다. +- `direction=SENT` 상세에서 팬 본인 발신자 정보와 사서함만 표시하고, 크리에이터 배송지는 표시하지 않는지 확인한다. +- `direction=RECEIVED` 상세에서 팬 닉네임만 표시하고 팬의 이름/휴대폰/주소는 표시하지 않는지 확인한다. +- `direction=RECEIVED` 상세에서 `mailbox`, `giftInfo.paidCan`, `giftInfo.tracking`이 표시되지 않는지 확인한다. +- 상세 progress가 `statusTimeline` 5단계를 사용하고 Figma처럼 4단계로 하드코딩되지 않았는지 확인한다. +- `trackingRequired=true`일 때만 운송장 등록 CTA가 표시되는지 확인한다. +- 운송장 등록 성공 후 상세를 재조회해 `TRACKING_REGISTERED` 상태를 표시하는지 확인한다. +- 신청 취소는 확인 dialog 이후에만 API를 호출하고, 성공 시 취소 완료 페이지로 replace 되는지 확인한다. +- `recipientAddressRequired=true`일 때 받은 선물 배송지 입력 UI가 표시되는지 확인한다. +- 배송지 입력 성공 후 상세를 재조회해 배송지 입력 완료 UI가 표시되는지 확인한다. +- `recipientAddress=null`이면 받는 주소 섹션을 통째로 숨기는지 확인한다. +- `status=UNDELIVERABLE`이면 전달 불가 UI와 사유/폐기 안내를 표시하고 배송지 입력 UI를 숨기는지 확인한다. +- 모든 날짜는 UTC ISO 문자열을 앱 표시 형식으로 변환하는지 확인한다. +- API 실패 시 기존 앱 오류 표시 방식을 사용하고 입력값/스크롤 상태를 불필요하게 초기화하지 않는지 확인한다. +``` diff --git a/docs/20260929_크리에이터_선물하기/plan-task.md b/docs/20260929_크리에이터_선물하기/plan-task.md index 1aa30fbf..0800680d 100644 --- a/docs/20260929_크리에이터_선물하기/plan-task.md +++ b/docs/20260929_크리에이터_선물하기/plan-task.md @@ -1406,6 +1406,30 @@ P7은 FCM 확장 후 전체 상태 전이를 연결하고 최종 회귀로 종 - [x] 공식 `onShutdownForceStop(true)` 옵션으로 native Redis 프로세스 종료가 무기한 `waitFor()`에 걸리지 않도록 변경했다. - [x] 후속 요청에 따라 전체 `./gradlew --no-daemon test`를 실행해 정상 종료를 확인했다. +## Phase 13: 선물함 리스트 표시 보강 + +**Phase 결과:** 선물함 리스트 item이 배송지 입력 여부, 배송지 입력 기한, 취소일을 내려줘 iOS가 리스트에서 배송지 CTA와 취소일을 표시할 수 있다. + +**선행조건:** Phase 12 완료, 사용자 확정 요구사항 반영. + +### Task 13.1 문서 계약 갱신 + +**Goal 실행 `P13-T1`:** PRD, 클라이언트 요약, 모바일 프롬프트에 리스트 item 추가 필드 3개를 반영한다. + +- **Files:** Modify `prd.md`, `client-api-summary.md`, `gift-list-page-prompt.md`, `ios-gift-feature-implementation-prompt.md`, `plan-task.md`. +- [x] **RED:** 기존 리스트 응답 계약에 `recipientAddressRegistered`, `canceledAt`이 없고 배송지 deadline 규칙이 불명확함을 확인한다. +- [x] **GREEN:** `recipientAddressRegistered`, `recipientAddressDeadlineAt`, `canceledAt`을 문서화한다. 배송지 입력 완료 후 `recipientAddressDeadlineAt=null`, 취소일은 `createdAt` 대입이 아니라 `canceledAt` 별도 필드로 내려주는 정책을 명시한다. + +### Task 13.2 리스트 응답 필드 구현 + +**Goal 실행 `P13-T2`:** 사용자 선물함 리스트 응답에 추가 필드 3개를 내려준다. + +- **Files:** Modify `GiftQueryService.kt`, `GiftResponse.kt`. +- **Tests:** Modify `GiftQueryServiceTest.kt`, `GiftControllerTest.kt`. +- [x] **RED:** 배송지 미입력/입력 완료/취소 상태의 리스트 응답 필드 실패 테스트를 작성하고 실패를 확인했다. +- [x] **GREEN:** 기존 `GiftDelivery` 정보를 재사용해 `recipientAddressRegistered`, `recipientAddressDeadlineAt`, `canceledAt`을 매핑했다. +- [x] **검증:** focused 테스트와 `ktlintCheck`, `git diff --check`를 실행했다. + ## Decision Log | 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 | From c899ed7e21f90e79bed68ddfce7008a05beda988 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 8 Oct 2026 17:39:02 +0900 Subject: [PATCH 53/53] =?UTF-8?q?feat(gift):=20=EC=84=A0=EB=AC=BC=ED=95=A8?= =?UTF-8?q?=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EB=B0=B0=EC=86=A1=EC=A7=80=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EB=A5=BC=20=EC=9D=91=EB=8B=B5=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/v2/api/gift/dto/GiftResponse.kt | 6 + .../v2/gift/application/GiftQueryService.kt | 19 +++- .../gift/adapter/in/web/GiftControllerTest.kt | 30 ++++- .../gift/application/GiftQueryServiceTest.kt | 105 +++++++++++++++--- 4 files changed, 139 insertions(+), 21 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftResponse.kt index 59496eb8..96b9bf46 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftResponse.kt @@ -62,6 +62,9 @@ data class GiftListResponse( counterpartMemberId = it.counterpartMemberId, counterpartNickname = it.counterpartNickname, counterpartProfileImageUrl = it.counterpartProfileImageUrl, + recipientAddressRegistered = it.recipientAddressRegistered, + recipientAddressDeadlineAt = it.recipientAddressDeadlineAt?.toUtcIso(), + canceledAt = it.canceledAt?.toUtcIso(), createdAt = it.createdAt?.toUtcIso() ) }, @@ -83,6 +86,9 @@ data class GiftListItemResponse( val counterpartMemberId: Long, val counterpartNickname: String, val counterpartProfileImageUrl: String, + val recipientAddressRegistered: Boolean, + val recipientAddressDeadlineAt: String?, + val canceledAt: String?, val createdAt: String? ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt index 8f9ae340..8d56c415 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt @@ -46,6 +46,9 @@ data class GiftListItemResult( val counterpartMemberId: Long, val counterpartNickname: String, val counterpartProfileImageUrl: String, + val recipientAddressRegistered: Boolean, + val recipientAddressDeadlineAt: LocalDateTime?, + val canceledAt: LocalDateTime?, val createdAt: LocalDateTime? ) @@ -153,10 +156,13 @@ class GiftQueryService( val membersById = memberRepository.findAllById( gifts.content.flatMap { listOf(it.senderMemberId, it.recipientMemberId) }.toSet() ).associateBy { it.id ?: throw invalidRequest() } + val deliveriesByGiftId = deliveryRepository.findByGiftIdIn( + gifts.content.mapNotNull { it.id } + ).associateBy { it.giftId } return GiftListResult( totalCount = gifts.totalElements, - items = gifts.content.map { it.toGiftListItem(memberId, membersById) }, + items = gifts.content.map { it.toGiftListItem(memberId, membersById, deliveriesByGiftId) }, page = normalizedPage, size = normalizedSize, hasNext = gifts.hasNext() @@ -203,10 +209,16 @@ class GiftQueryService( ) } - private fun Gift.toGiftListItem(memberId: Long, membersById: Map): GiftListItemResult { + private fun Gift.toGiftListItem( + memberId: Long, + membersById: Map, + deliveriesByGiftId: Map + ): GiftListItemResult { val direction = if (senderMemberId == memberId) GiftDirection.SENT else GiftDirection.RECEIVED val counterpartId = if (direction == GiftDirection.SENT) recipientMemberId else senderMemberId val counterpart = membersById[counterpartId] ?: throw invalidRequest() + val delivery = deliveriesByGiftId[id] ?: throw invalidRequest() + val recipientAddressRegistered = delivery.hasCompleteRecipientInformation() return GiftListItemResult( applicationNo = applicationNo, @@ -219,6 +231,9 @@ class GiftQueryService( counterpartMemberId = counterpartId, counterpartNickname = counterpart.nickname, counterpartProfileImageUrl = counterpart.profileImageUrl(), + recipientAddressRegistered = recipientAddressRegistered, + recipientAddressDeadlineAt = delivery.recipientAddressDeadlineAt.takeUnless { recipientAddressRegistered }, + canceledAt = delivery.canceledAt, createdAt = createdAt ) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt index b84856da..2deec4e5 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt @@ -100,7 +100,18 @@ class GiftControllerTest @Autowired constructor( senderMemberId = member.id!!, recipientMemberId = counterpart.id!!, status = GiftStatus.RECEIVED - ) + ).also { gift -> + giftDeliveryRepository.save( + GiftDelivery( + giftId = gift.id!!, + senderName = "홍길동", + senderPhoneNumber = "01012345678", + senderZipCode = "06234", + senderAddress = "서울시 강남구", + trackingDeadlineAt = LocalDateTime.of(2026, 10, 2, 12, 0) + ) + ) + } saveGift( "G-CONTROLLER-HIDDEN", senderMemberId = counterpart.id!!, @@ -112,7 +123,19 @@ class GiftControllerTest @Autowired constructor( senderMemberId = counterpart.id!!, recipientMemberId = member.id!!, status = GiftStatus.TRACKING_REGISTERED - ) + ).also { gift -> + giftDeliveryRepository.save( + GiftDelivery( + giftId = gift.id!!, + senderName = "홍길동", + senderPhoneNumber = "01012345678", + senderZipCode = "06234", + senderAddress = "서울시 강남구", + trackingDeadlineAt = LocalDateTime.of(2026, 10, 2, 12, 0), + recipientAddressDeadlineAt = LocalDateTime.of(2026, 10, 7, 12, 0) + ) + ) + } mockMvc.perform( get("/api/v2/gifts") @@ -138,6 +161,9 @@ class GiftControllerTest @Autowired constructor( .andExpect(jsonPath("$.data.items[0].counterpartMemberId").value(counterpart.id!!)) .andExpect(jsonPath("$.data.items[0].counterpartNickname").value("counterpart")) .andExpect(jsonPath("$.data.items[0].counterpartProfileImageUrl").value("https://cdn.test/profile/counterpart.png")) + .andExpect(jsonPath("$.data.items[0].recipientAddressRegistered").value(false)) + .andExpect(jsonPath("$.data.items[0].recipientAddressDeadlineAt").value("2026-10-07T12:00:00Z")) + .andExpect(jsonPath("$.data.items[0].canceledAt").isEmpty) .andExpect(jsonPath("$.data.items[0].createdAt").value(endsWith("Z"))) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt index 6eef5e99..8ad5cd41 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt @@ -66,11 +66,13 @@ class GiftQueryServiceTest @Autowired constructor( fun shouldListReceivedGiftForSender() { val sender = saveMember("fan") val recipient = saveMember("creator", "profile/creator.png") - saveGift( - "G-SENT-RECEIVED", - senderMemberId = sender.id!!, - recipientMemberId = recipient.id!!, - status = GiftStatus.RECEIVED + saveDelivery( + saveGift( + "G-SENT-RECEIVED", + senderMemberId = sender.id!!, + recipientMemberId = recipient.id!!, + status = GiftStatus.RECEIVED + ) ) val result = queryService().getGifts( @@ -107,11 +109,13 @@ class GiftQueryServiceTest @Autowired constructor( recipientMemberId = recipient.id!!, status = GiftStatus.RECEIVED ) - saveGift( - "G-RECEIVED-VISIBLE", - senderMemberId = visibleSender.id!!, - recipientMemberId = recipient.id!!, - status = GiftStatus.TRACKING_REGISTERED + saveDelivery( + saveGift( + "G-RECEIVED-VISIBLE", + senderMemberId = visibleSender.id!!, + recipientMemberId = recipient.id!!, + status = GiftStatus.TRACKING_REGISTERED + ) ) val result = queryService().getGifts( @@ -136,12 +140,21 @@ class GiftQueryServiceTest @Autowired constructor( val creator = saveMember("creator") val fan = saveMember("fan") val hiddenFan = saveMember("hidden-fan") - saveGift("G-ALL-SENT", senderMemberId = member.id!!, recipientMemberId = creator.id!!, status = GiftStatus.CANCELED) - saveGift( - "G-ALL-RECEIVED", - senderMemberId = fan.id!!, - recipientMemberId = member.id!!, - status = GiftStatus.ARRIVED_AT_MAILBOX + saveDelivery( + saveGift( + "G-ALL-SENT", + senderMemberId = member.id!!, + recipientMemberId = creator.id!!, + status = GiftStatus.CANCELED + ) + ) + saveDelivery( + saveGift( + "G-ALL-RECEIVED", + senderMemberId = fan.id!!, + recipientMemberId = member.id!!, + status = GiftStatus.ARRIVED_AT_MAILBOX + ) ) saveGift("G-ALL-HIDDEN", senderMemberId = hiddenFan.id!!, recipientMemberId = member.id!!, status = GiftStatus.RECEIVED) @@ -163,12 +176,70 @@ class GiftQueryServiceTest @Autowired constructor( ) } + @Test + @DisplayName("선물함 목록은 배송지 입력 여부와 기한, 취소일을 반환한다") + fun shouldListRecipientAddressAndCancellationFields() { + val member = saveMember("creator") + val pendingSender = saveMember("pending-fan") + val completedSender = saveMember("completed-fan") + val canceledRecipient = saveMember("canceled-creator") + val pendingGift = saveGift( + "G-LIST-PENDING", + senderMemberId = pendingSender.id!!, + recipientMemberId = member.id!!, + status = GiftStatus.TRACKING_REGISTERED + ) + saveDelivery(pendingGift).apply { + trackingRegisteredAt = NOW.plusHours(1) + recipientAddressDeadlineAt = NOW.plusDays(7) + } + val completedGift = saveGift( + "G-LIST-COMPLETE", + senderMemberId = completedSender.id!!, + recipientMemberId = member.id!!, + status = GiftStatus.ARRIVED_AT_MAILBOX + ) + saveDelivery(completedGift).apply { + trackingRegisteredAt = NOW.plusHours(1) + arrivedAtMailboxAt = NOW.plusHours(2) + recipientAddressDeadlineAt = NOW.plusDays(7) + recipientName = "수신자" + recipientPhoneNumber = "01098765432" + recipientZipCode = "04524" + recipientAddress = "서울시 중구" + } + val canceledGift = saveGift( + "G-LIST-CANCELED", + senderMemberId = member.id!!, + recipientMemberId = canceledRecipient.id!!, + status = GiftStatus.CANCELED + ) + saveDelivery(canceledGift).canceledAt = NOW.plusHours(3) + + val result = queryService().getGifts( + memberId = member.id!!, + type = GiftListType.ALL, + page = 0, + size = 20 + ) + + val items = result.items.associateBy { it.applicationNo } + assertEquals(false, items.getValue("G-LIST-PENDING").recipientAddressRegistered) + assertEquals(NOW.plusDays(7), items.getValue("G-LIST-PENDING").recipientAddressDeadlineAt) + assertNull(items.getValue("G-LIST-PENDING").canceledAt) + assertEquals(true, items.getValue("G-LIST-COMPLETE").recipientAddressRegistered) + assertNull(items.getValue("G-LIST-COMPLETE").recipientAddressDeadlineAt) + assertEquals(NOW.plusHours(3), items.getValue("G-LIST-CANCELED").canceledAt) + } + @Test @DisplayName("선물함 page와 size는 음수 또는 0이면 기본값으로 보정된다") fun shouldNormalizeGiftListPageAndSize() { val sender = saveMember("fan") val recipient = saveMember("creator") - saveGift("G-PAGING", senderMemberId = sender.id!!, recipientMemberId = recipient.id!!, status = GiftStatus.RECEIVED) + saveDelivery( + saveGift("G-PAGING", senderMemberId = sender.id!!, recipientMemberId = recipient.id!!, status = GiftStatus.RECEIVED) + ) val result = queryService().getGifts( memberId = sender.id!!,