Compare commits

...
14 Commits
Author SHA1 Message Date
klaus 95d7568a25 docs(gift): 수취인 정보 전달 조건을 기록한다 2026-10-02 20:09:55 +09:00
klaus 5a09c85135 fix(test): embedded Redis 종료 누수를 방지한다 2026-10-02 20:09:04 +09:00
klaus 124a615a85 fix(gift): 배송지 미입력 만료 상태 범위를 확장한다 2026-10-02 20:08:05 +09:00
klaus 112ad755e5 fix(gift): 전달 완료 가능 액션의 수취인 조건을 반영한다 2026-10-02 20:07:06 +09:00
klaus 0bbb9e4fc7 fix(gift): 수취인 정보 없는 선물의 전달 완료를 차단한다 2026-10-02 20:06:17 +09:00
klaus c6b5f9c867 docs(gift): 받은 선물 상세 구현 프롬프트를 추가한다 2026-10-02 13:20:25 +09:00
klaus 978206438e docs(gift): 선물 상세 구현 프롬프트를 추가한다 2026-10-02 11:34:16 +09:00
klaus 31406e7f0a docs(gift): 사서함 노출 조건 문서를 갱신한다 2026-10-02 11:34:05 +09:00
klaus 77a66ee02c fix(gift): 전달 완료 전 사서함 주소를 노출한다 2026-10-02 11:33:55 +09:00
klaus 3fb822a096 docs(gift): 선물 받을 주소 요구사항을 기록한다 2026-10-01 22:58:41 +09:00
klaus 322b07d794 feat(gift): 보낸 선물 상세에 받을 주소를 노출한다 2026-10-01 22:58:11 +09:00
klaus 8a95dc171d feat(gift): 선물 받을 주소 관리자 API를 추가한다 2026-10-01 22:57:39 +09:00
klaus 33b6662cb4 feat(gift): 선물 받을 주소 관리자 서비스를 추가한다 2026-10-01 22:57:07 +09:00
klaus f66c89fb7f feat(gift): 선물 받을 주소 저장소를 추가한다 2026-10-01 22:56:59 +09:00
26 changed files with 1852 additions and 57 deletions
@@ -1,11 +1,12 @@
# 선물 관리자 페이지 구현 프롬프트 # 선물 관리자 페이지 구현 프롬프트
이 문서는 관리자 페이지의 선물 관련 3개 화면을 프론트엔드 구현 에이전트에게 전달하기 위한 프롬프트 모음이다. 이 문서는 관리자 페이지의 선물 관련 4개 화면을 프론트엔드 구현 에이전트에게 전달하기 위한 프롬프트 모음이다.
공통 전제: 공통 전제:
- 관리자 메뉴에는 parent 메뉴 `선물함 관리` 아래에 다음 3개 하위 메뉴가 있다. - 관리자 메뉴에는 parent 메뉴 `선물함 관리` 아래에 다음 4개 하위 메뉴가 있다.
- `선물함 리스트` → `/gift/list` - `선물함 리스트` → `/gift/list`
- `받을 주소` → `/gift/mailbox`
- `선물 카테고리` → `/gift/category` - `선물 카테고리` → `/gift/category`
- `선물 사이즈` → `/gift/size` - `선물 사이즈` → `/gift/size`
- 모든 API 응답은 기존 공통 envelope를 사용한다. 화면에서는 `response.data`를 실제 payload로 사용한다. - 모든 API 응답은 기존 공통 envelope를 사용한다. 화면에서는 `response.data`를 실제 payload로 사용한다.
@@ -237,7 +238,84 @@ type AdminGiftOperationStatusResponse = {
--- ---
## 2. 선물 카테고리 CRUD 페이지 프롬프트 ## 2. 받을 주소 설정 페이지 프롬프트
```text
관리자 선물 받을 주소 설정 페이지를 구현해줘.
목표:
- 관리자 메뉴 `선물함 관리 > 받을 주소`에서 팬이 선물을 보낼 전역 단일 주소를 조회하고 저장한다.
- 이 주소는 팬의 보낸 선물 상세 API에서 정상 진행 상태 중 전달 완료 전(`RECEIVED`, `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED`)일 때 `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 ```text
관리자 선물 카테고리 CRUD 페이지를 구현해줘. 관리자 선물 카테고리 CRUD 페이지를 구현해줘.
@@ -345,7 +423,7 @@ UX 요구사항:
--- ---
## 3. 선물 사이즈 가격 CRUD 페이지 프롬프트 ## 4. 선물 사이즈 가격 CRUD 페이지 프롬프트
```text ```text
관리자 선물 사이즈 가격 CRUD 페이지를 구현해줘. 관리자 선물 사이즈 가격 CRUD 페이지를 구현해줘.
@@ -464,7 +542,8 @@ type GiftSize = "SMALL" | "MEDIUM" | "LARGE";
1. API client 함수와 TypeScript 타입을 먼저 추가한다. 1. API client 함수와 TypeScript 타입을 먼저 추가한다.
2. `/gift/list` 선물함 리스트와 상세/상태변경 모달을 구현한다. 2. `/gift/list` 선물함 리스트와 상세/상태변경 모달을 구현한다.
3. `/gift/category` 카테고리 CRUD를 구현한다. 3. `/gift/mailbox` 받을 주소 설정 화면을 구현한다.
4. `/gift/size` 사이즈 가격 수정 화면을 구현한다. 4. `/gift/category` 카테고리 CRUD를 구현한다.
5. 기존 관리자 메뉴의 `선물함 관리` 하위 route와 연결되는지 확인한다. 5. `/gift/size` 사이즈 가격 수정 화면을 구현한다.
6. 각 페이지별 loading/empty/error/success 상태를 확인한다. 6. 기존 관리자 메뉴의 `선물함 관리` 하위 route와 연결되는지 확인한다.
7. 각 페이지별 loading/empty/error/success 상태를 확인한다.
@@ -187,6 +187,10 @@ data class GiftMailboxResponse(
val phoneNumber: String val phoneNumber: String
) )
// mailbox는 direction=SENT이고 status가 RECEIVED, TRACKING_REGISTERED,
// ARRIVED_AT_MAILBOX, INSPECTION_COMPLETED일 때만 값이 있다.
// DELIVERED, UNDELIVERABLE, CANCELED 또는 direction=RECEIVED이면 null이다.
data class GiftDeliveryInfoResponse( data class GiftDeliveryInfoResponse(
val canceledAt: String?, val canceledAt: String?,
val undeliverableAt: String?, val undeliverableAt: String?,
@@ -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 문자열을 앱 표시 형식으로 변환해 표시하는지 확인한다.
```
@@ -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를 호출하지 않는지 확인한다.
- 저장 성공 후 성공 토스트와 화면 갱신이 일어나는지 확인한다.
```
@@ -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 문자열을 앱 표시 형식으로 변환해 표시하는지 확인한다.
```
@@ -15,6 +15,16 @@ CREATE TABLE gift_category (
UNIQUE KEY uk_gift_category_receipt_code (receipt_code) UNIQUE KEY uk_gift_category_receipt_code (receipt_code)
) COMMENT='선물 카테고리'; ) 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 ( CREATE TABLE gift (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '내부 기본키', id BIGINT NOT NULL AUTO_INCREMENT COMMENT '내부 기본키',
application_no VARCHAR(40) NOT NULL COMMENT '신청번호', application_no VARCHAR(40) NOT NULL COMMENT '신청번호',
@@ -8,7 +8,7 @@
| 작성일 | 2026-09-29 | | 작성일 | 2026-09-29 |
| 요구사항 기준 | `docs/20260929_크리에이터_선물하기/prd.md` | | 요구사항 기준 | `docs/20260929_크리에이터_선물하기/prd.md` |
| API 기준 | `docs/20260929_크리에이터_선물하기/prd.md`의 `8. API 계약` | | API 기준 | `docs/20260929_크리에이터_선물하기/prd.md`의 `8. API 계약` |
| 현재 Phase | Phase 10 완료 | | 현재 Phase | Phase 12 구현 완료 |
| 현재 활성 Goal | 완료 | | 현재 활성 Goal | 완료 |
## 목표 ## 목표
@@ -1309,6 +1309,103 @@ P7은 FCM 확장 후 전체 상태 전이를 연결하고 최종 회귀로 종
- Gate 확인: Phase 9 focused suite, `./gradlew ktlintCheck`, `git diff --check` 모두 성공 - 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`, `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 통과를 확인한다.
### Phase 11 Gate
**Goal 실행 `P11-GATE`:** 관리자 설정과 사용자 상세 노출을 판정한다.
```bash
./gradlew test --tests "*GiftAdminServiceTest" --tests "*AdminGiftControllerTest" --tests "*GiftQueryServiceTest" --tests "*GiftControllerTest"
./gradlew ktlintCheck
```
**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` 실행 결과 통과.
- 남은 항목: 없음
## 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 ## Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 | | 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
@@ -1320,12 +1417,14 @@ P7은 FCM 확장 후 전체 상태 전이를 연결하고 최종 회귀로 종
| 2026-09-30 | `PLAN-DEC-005` | 확정 | 운영 반영용 DDL에는 선물 도메인 FK를 명시하고, 선물 신청 건에는 최종 결제 금액 스냅샷만 저장한다 | 사용자 결정: 제대로 가는 방향 선호, `basePriceCan`은 신청 건에 불필요 | `gift-schema.sql`, `Gift` | | 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-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-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 | 처리 계획 | | 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 제거, 공식 강제 종료 옵션 적용 후 전체 테스트 정상 종료 확인 |
## 최종 보고 형식 ## 최종 보고 형식
@@ -186,6 +186,7 @@ INSPECTION_COMPLETED -> UNDELIVERABLE
- 로그인 회원이 받는 크리에이터이면 보내는 팬의 닉네임만 알 수 있고, 팬이 신청 시 등록한 이름/휴대폰 번호/주소는 알 수 없다. - 로그인 회원이 받는 크리에이터이면 보내는 팬의 닉네임만 알 수 있고, 팬이 신청 시 등록한 이름/휴대폰 번호/주소는 알 수 없다.
- 로그인 회원이 보내는 팬이면 받는 크리에이터의 닉네임만 알 수 있고, 크리에이터가 입력한 이름/휴대폰 번호/주소는 알 수 없다. - 로그인 회원이 보내는 팬이면 받는 크리에이터의 닉네임만 알 수 있고, 크리에이터가 입력한 이름/휴대폰 번호/주소는 알 수 없다.
- 보내는 팬 상세에는 팬 본인이 신청 시 등록한 이름, 휴대폰 번호, 주소를 내려준다. - 보내는 팬 상세에는 팬 본인이 신청 시 등록한 이름, 휴대폰 번호, 주소를 내려준다.
- 보내는 팬 상세에는 관리자가 등록한 전역 선물 받을 주소를 `mailbox`로 내려준다. 단, 정상 진행 상태 중 `DELIVERED` 전 상태(`RECEIVED`, `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED`)에서만 내려준다.
- 받는 크리에이터 상세에는 크리에이터 본인이 입력한 받는 주소의 이름, 휴대폰 번호, 주소를 내려준다. - 받는 크리에이터 상세에는 크리에이터 본인이 입력한 받는 주소의 이름, 휴대폰 번호, 주소를 내려준다.
- 주소 형식은 `(우편번호) 주소, 상세주소` 문자열로 내려준다. - 주소 형식은 `(우편번호) 주소, 상세주소` 문자열로 내려준다.
- 로그인 회원이 알아야 하는 데이터가 아니거나 아직 입력되지 않은 데이터는 `null`로 내려준다. - 로그인 회원이 알아야 하는 데이터가 아니거나 아직 입력되지 않은 데이터는 `null`로 내려준다.
@@ -384,8 +385,8 @@ Response:
{ {
"applicationNo": "A-1002609300001", "applicationNo": "A-1002609300001",
"direction": "SENT", "direction": "SENT",
"status": "TRACKING_REGISTERED", "status": "RECEIVED",
"statusName": "발송 확인", "statusName": "접수 완료",
"giftInfo": { "giftInfo": {
"recipientCreatorNickname": "크리에이터닉네임", "recipientCreatorNickname": "크리에이터닉네임",
"senderNickname": null, "senderNickname": null,
@@ -393,7 +394,7 @@ Response:
"categoryName": "인형", "categoryName": "인형",
"applicationNo": "A-1002609300001", "applicationNo": "A-1002609300001",
"paidCan": 80, "paidCan": 80,
"tracking": "CJ대한통운_1234567890", "tracking": null,
"shippingRequestedAt": null "shippingRequestedAt": null
}, },
"senderInfo": { "senderInfo": {
@@ -403,11 +404,11 @@ Response:
}, },
"recipientAddress": null, "recipientAddress": null,
"mailbox": { "mailbox": {
"name": "소다라이브 사서함", "name": "소다라이브 선물 담당자",
"address": "구현 후 제공", "address": "(06234) 서울시 강남구 ..., 3층 선물 접수처",
"phoneNumber": "구현 후 제공" "phoneNumber": "01012345678"
}, },
"trackingRequired": false, "trackingRequired": true,
"recipientAddressRequired": false, "recipientAddressRequired": false,
"recipientAddressDeadlineAt": "2026-10-09T03:00:00Z", "recipientAddressDeadlineAt": "2026-10-09T03:00:00Z",
"delivery": { "delivery": {
@@ -446,6 +447,8 @@ Response:
} }
``` ```
`mailbox`는 관리자가 등록한 전역 단일 받을 주소다. 보낸 사람 관점(`direction=SENT`)이고 정상 진행 상태 중 `DELIVERED` 전 상태(`RECEIVED`, `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED`)일 때 내려준다. 받을 주소가 등록되지 않았거나, 다른 방향이거나, `DELIVERED`/`UNDELIVERABLE`/`CANCELED` 상태이면 `mailbox`는 `null`이다. 주소는 상세 조회 시점의 최신 관리자 설정을 사용한다.
받는 크리에이터 관점 응답 예시는 다음과 같다. 받는 크리에이터 관점 응답 예시는 다음과 같다.
```json ```json
@@ -514,7 +517,7 @@ Response:
`direction=SENT`이면 보내는 팬 관점의 상세 응답이다. `direction=SENT`이면 보내는 팬 관점의 상세 응답이다.
`giftInfo.recipientCreatorNickname`에는 받는 크리에이터 닉네임을 내려주고, `giftInfo.senderNickname`, `giftInfo.shippingRequestedAt`, `recipientAddress`는 `null`이다. `giftInfo.recipientCreatorNickname`에는 받는 크리에이터 닉네임을 내려주고, `giftInfo.senderNickname`, `giftInfo.shippingRequestedAt`, `recipientAddress`는 `null`이다.
`senderInfo`에는 팬 본인이 신청 시 등록한 이름, 휴대폰 번호, 주소를 내려준다. `senderInfo`에는 팬 본인이 신청 시 등록한 이름, 휴대폰 번호, 주소를 내려준다.
`mailbox`는 코드상에서 정한 사서함 이름, 주소, 연락처를 내려준다. 구현 후 값이 제공되면 이 문서를 갱신한다. `mailbox`는 관리자가 등록한 전역 단일 받을 주소의 이름, 주소, 연락처를 내려준다. 보낸 사람 관점의 정상 진행 상태 중 `DELIVERED` 전 상태에서만 값이 있고, 그 외에는 `null`이다.
`direction=SENT`이고 현재 상태가 `RECEIVED`이면 `trackingRequired=true`이며, 팬 클라이언트는 운송장 등록 CTA를 표시한다. `direction=SENT`이고 현재 상태가 `RECEIVED`이면 `trackingRequired=true`이며, 팬 클라이언트는 운송장 등록 CTA를 표시한다.
`direction=RECEIVED`이면 받는 크리에이터 관점의 상세 응답이다. `direction=RECEIVED`이면 받는 크리에이터 관점의 상세 응답이다.
`giftInfo.senderNickname`에는 발송인인 팬 닉네임을 내려주고, `giftInfo.recipientCreatorNickname`, `giftInfo.applicationNo`, `giftInfo.paidCan`, `giftInfo.tracking`, `senderInfo`, `mailbox`는 `null`이다. `giftInfo.senderNickname`에는 발송인인 팬 닉네임을 내려주고, `giftInfo.recipientCreatorNickname`, `giftInfo.applicationNo`, `giftInfo.paidCan`, `giftInfo.tracking`, `senderInfo`, `mailbox`는 `null`이다.
@@ -802,6 +805,40 @@ Response:
크리에이터가 배송지를 아직 입력하지 않은 경우에도 `recipientInfo.nickname`은 내려주고, `recipientInfo.name`, `recipientInfo.phoneNumber`, `recipientInfo.address`는 빈 문자열 `""`로 내려준다. 크리에이터가 배송지를 아직 입력하지 않은 경우에도 `recipientInfo.nickname`은 내려주고, `recipientInfo.name`, `recipientInfo.phoneNumber`, `recipientInfo.address`는 빈 문자열 `""`로 내려준다.
#### 8.13.3 관리자 선물 받을 주소 설정 API
선물 받을 주소는 전역 단일 설정이다. 팬은 정상 진행 상태 중 `DELIVERED` 전인 보낸 선물 상세에서 이 주소를 확인할 수 있다.
`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`는 현재 상태별로 다음 값만 내려준다.
| 현재 상태 | availableActions | | 현재 상태 | availableActions |
@@ -809,7 +846,8 @@ Response:
| `RECEIVED` | `[]` | | `RECEIVED` | `[]` |
| `TRACKING_REGISTERED` | `["ARRIVE_MAILBOX", "MARK_UNDELIVERABLE"]` | | `TRACKING_REGISTERED` | `["ARRIVE_MAILBOX", "MARK_UNDELIVERABLE"]` |
| `ARRIVED_AT_MAILBOX` | `["COMPLETE_INSPECTION", "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` | `[]` | | `DELIVERED` | `[]` |
| `UNDELIVERABLE` | `[]` | | `UNDELIVERABLE` | `[]` |
| `CANCELED` | `[]` | | `CANCELED` | `[]` |
@@ -821,7 +859,9 @@ Response:
| POST | `/api/v2/admin/gifts/{applicationNo}/arrive-mailbox` | `TRACKING_REGISTERED` | `ARRIVED_AT_MAILBOX` | 없음 | | 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}/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}/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: 전달불가 Request:
@@ -838,9 +878,10 @@ Response:
| 운송장 등록 기한 24시간 전 안내 | `RECEIVED`, 마감 24시간 전 | 상태 변경 없음 | 팬에게 안내 | | 운송장 등록 기한 24시간 전 안내 | `RECEIVED`, 마감 24시간 전 | 상태 변경 없음 | 팬에게 안내 |
| 운송장 미등록 자동취소 | `RECEIVED`, 신청 후 3일 초과 | `CANCELED`, 전액 환불 | 팬에게 자동취소 안내 | | 운송장 미등록 자동취소 | `RECEIVED`, 신청 후 3일 초과 | `CANCELED`, 전액 환불 | 팬에게 자동취소 안내 |
| 배송지 입력 기한 24시간 전 안내 | `TRACKING_REGISTERED` 이상, 배송지 미입력, 마감 24시간 전 | 상태 변경 없음 | 크리에이터에게 안내 | | 배송지 입력 기한 24시간 전 안내 | `TRACKING_REGISTERED` 이상, 배송지 미입력, 마감 24시간 전 | 상태 변경 없음 | 크리에이터에게 안내 |
| 배송지 미입력 전달불가 | 배송지 미입력, 운송장 등록 후 7일 초과 | `UNDELIVERABLE`, 사유 저장 | 팬에게 전달불가 안내. 크리에이터에게 추가 푸시 없음 | | 배송지 미입력 전달불가 | `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED` 중 하나이며 배송지 미입력, 운송장 등록 후 7일 초과 | `UNDELIVERABLE`, 사유 저장 | 팬에게 전달불가 안내. 크리에이터에게 추가 푸시 없음 |
스케줄러는 중복 실행되어도 같은 선물에 중복 환불 또는 중복 푸시가 발생하지 않아야 한다. 스케줄러는 중복 실행되어도 같은 선물에 중복 환불 또는 중복 푸시가 발생하지 않아야 한다.
배송지 입력 기한과 현재 시각이 같으면 아직 기한 초과가 아니며, `RECEIVED`, `DELIVERED`, `UNDELIVERABLE`, `CANCELED` 상태는 배송지 미입력 전달불가 대상에서 제외한다.
## 9. 푸시 알림 정책 ## 9. 푸시 알림 정책
@@ -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.AdminGiftCategoryResponse
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftDetailResponse 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.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.AdminGiftMarkUndeliverableRequest
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftOperationStatusResponse 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.AdminGiftSizePriceRequest
@@ -45,6 +47,14 @@ class AdminGiftController(
AdminGiftDetailResponse.from(queryService.getGiftDetail(applicationNo)) 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") @GetMapping("/gift-categories")
fun getCategories() = ApiResponse.ok(service.getCategories().map { AdminGiftCategoryResponse.from(it) }) fun getCategories() = ApiResponse.ok(service.getCategories().map { AdminGiftCategoryResponse.from(it) })
@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.v2.api.admin.gift.dto 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.GiftCategoryCommand
import kr.co.vividnext.sodalive.v2.gift.application.GiftMailboxCommand
import kr.co.vividnext.sodalive.v2.gift.application.GiftSizePriceCommand import kr.co.vividnext.sodalive.v2.gift.application.GiftSizePriceCommand
data class AdminGiftCategoryRequest( 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( data class AdminGiftMarkUndeliverableRequest(
val reason: String val reason: String
) )
@@ -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.AdminGiftMemberInfoResult
import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftProductInfoResult 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.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.GiftOperationStatusResult
import kr.co.vividnext.sodalive.v2.gift.application.GiftSizePriceResult import kr.co.vividnext.sodalive.v2.gift.application.GiftSizePriceResult
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus 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( data class AdminGiftListResponse(
val totalCount: Long, val totalCount: Long,
val items: List<AdminGiftListItemResponse>, val items: List<AdminGiftListItemResponse>,
@@ -84,4 +84,11 @@ class GiftDelivery(
@Column(name = "undeliverable_reason", length = 255) @Column(name = "undeliverable_reason", length = 255)
var undeliverableReason: String? = null var undeliverableReason: String? = null
) : BaseEntity() ) : BaseEntity() {
fun hasCompleteRecipientInformation() = listOf(
recipientName,
recipientPhoneNumber,
recipientZipCode,
recipientAddress
).none { it.isNullOrBlank() }
}
@@ -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
}
}
@@ -0,0 +1,5 @@
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
import org.springframework.data.jpa.repository.JpaRepository
interface GiftMailboxRepository : JpaRepository<GiftMailbox, Long>
@@ -80,7 +80,11 @@ interface GiftRepository : JpaRepository<Gift, Long> {
""" """
select g select g
from Gift 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 ( and exists (
select d.id select d.id
from GiftDelivery d from GiftDelivery d
@@ -90,7 +94,7 @@ interface GiftRepository : JpaRepository<Gift, Long> {
) )
""" """
) )
fun findTrackingRegisteredGiftsWithRecipientAddressDeadlineBefore( fun findGiftsWithRecipientAddressDeadlineBefore(
@Param("now") now: LocalDateTime @Param("now") now: LocalDateTime
): List<Gift> ): List<Gift>
@@ -51,7 +51,7 @@ class GiftScheduler(
@Transactional @Transactional
fun markExpiredRecipientAddressGiftsUndeliverable(now: LocalDateTime): Int { fun markExpiredRecipientAddressGiftsUndeliverable(now: LocalDateTime): Int {
val expiredGifts = giftRepository.findTrackingRegisteredGiftsWithRecipientAddressDeadlineBefore(now) val expiredGifts = giftRepository.findGiftsWithRecipientAddressDeadlineBefore(now)
expiredGifts.forEach { gift -> expiredGifts.forEach { gift ->
gift.status = GiftStatus.UNDELIVERABLE gift.status = GiftStatus.UNDELIVERABLE
val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id))) val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)))
@@ -137,7 +137,7 @@ class GiftAdminQueryService(
), ),
status = gift.status, status = gift.status,
statusName = gift.status.displayName, statusName = gift.status.displayName,
availableActions = gift.status.availableActions() availableActions = gift.status.availableActions(delivery)
) )
} }
@@ -153,13 +153,17 @@ class GiftAdminQueryService(
trackingNumber = context.delivery(this).trackingNumber, trackingNumber = context.delivery(this).trackingNumber,
status = status, status = status,
statusName = status.displayName, 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.TRACKING_REGISTERED -> listOf("ARRIVE_MAILBOX", "MARK_UNDELIVERABLE")
GiftStatus.ARRIVED_AT_MAILBOX -> listOf("COMPLETE_INSPECTION", "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() GiftStatus.RECEIVED, GiftStatus.DELIVERED, GiftStatus.UNDELIVERABLE, GiftStatus.CANCELED -> emptyList()
} }
@@ -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.GiftCategory
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository 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.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.GiftRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePrice 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.adapter.out.persistence.GiftSizePriceRepository
@@ -41,6 +43,14 @@ data class GiftSizePriceCommand(
val isActive: Boolean val isActive: Boolean
) )
data class GiftMailboxCommand(
val name: String,
val phoneNumber: String,
val zipCode: String,
val address: String,
val addressDetail: String?
)
data class GiftSizePriceResult( data class GiftSizePriceResult(
val sizeCode: GiftSize, val sizeCode: GiftSize,
val sizeName: String, val sizeName: String,
@@ -62,8 +72,31 @@ class GiftAdminService(
private val sizePriceRepository: GiftSizePriceRepository, private val sizePriceRepository: GiftSizePriceRepository,
private val giftRepository: GiftRepository, private val giftRepository: GiftRepository,
private val giftDeliveryRepository: GiftDeliveryRepository, private val giftDeliveryRepository: GiftDeliveryRepository,
private val giftMailboxRepository: GiftMailboxRepository,
private val giftPushService: GiftPushService 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) @Transactional(readOnly = true)
fun getCategories(): List<GiftCategoryResult> = categoryRepository.findAll().map { it.toResult() } fun getCategories(): List<GiftCategoryResult> = categoryRepository.findAll().map { it.toResult() }
@@ -170,6 +203,7 @@ class GiftAdminService(
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest() val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
if (gift.status != GiftStatus.INSPECTION_COMPLETED) throw invalidRequest() if (gift.status != GiftStatus.INSPECTION_COMPLETED) throw invalidRequest()
val delivery = giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)) ?: throw invalidRequest() val delivery = giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)) ?: throw invalidRequest()
if (!delivery.hasCompleteRecipientInformation()) throw invalidRequest()
gift.status = GiftStatus.DELIVERED gift.status = GiftStatus.DELIVERED
delivery.deliveredAt = now delivery.deliveredAt = now
@@ -198,6 +232,22 @@ class GiftAdminService(
if (command.salePriceCan <= 0 || command.salePriceCan > command.basePriceCan) throw invalidRequest() 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( private fun GiftCategory.toResult() = GiftCategoryResult(
categoryId = id ?: throw invalidRequest(), categoryId = id ?: throw invalidRequest(),
classificationNumber = classificationNumber, classificationNumber = classificationNumber,
@@ -217,6 +267,15 @@ class GiftAdminService(
isActive = isActive 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 fun invalidRequest() = SodaException(messageKey = "common.error.invalid_request")
private companion object { private companion object {
@@ -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.GiftCategoryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery 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.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.GiftRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository 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.GiftDirection
@@ -105,6 +107,7 @@ class GiftQueryService(
private val giftRepository: GiftRepository, private val giftRepository: GiftRepository,
private val deliveryRepository: GiftDeliveryRepository, private val deliveryRepository: GiftDeliveryRepository,
private val memberRepository: MemberRepository, private val memberRepository: MemberRepository,
private val giftMailboxRepository: GiftMailboxRepository,
@Value("\${cloud.aws.cloud-front.host}") @Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String private val cloudFrontHost: String
) { ) {
@@ -186,7 +189,7 @@ class GiftQueryService(
), ),
senderInfo = delivery.toSenderInfo().takeIf { direction == GiftDirection.SENT }, senderInfo = delivery.toSenderInfo().takeIf { direction == GiftDirection.SENT },
recipientAddress = recipientAddress, recipientAddress = recipientAddress,
mailbox = null, mailbox = mailboxFor(direction, gift.status),
trackingRequired = direction == GiftDirection.SENT && gift.status == GiftStatus.RECEIVED, trackingRequired = direction == GiftDirection.SENT && gift.status == GiftStatus.RECEIVED,
recipientAddressRequired = direction == GiftDirection.RECEIVED && recipientAddress == null && recipientAddressRequired = direction == GiftDirection.RECEIVED && recipientAddress == null &&
gift.status !in TERMINAL_STATUSES, gift.status !in TERMINAL_STATUSES,
@@ -247,6 +250,17 @@ class GiftQueryService(
return GiftAddressResult(name, phoneNumber, formatAddress(zipCode, address, recipientAddressDetail)) return GiftAddressResult(name, phoneNumber, formatAddress(zipCode, address, recipientAddressDetail))
} }
private fun mailboxFor(direction: GiftDirection, status: GiftStatus): GiftMailboxResult? {
if (direction != GiftDirection.SENT || status in TERMINAL_STATUSES) 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) { private fun GiftDelivery.tracking() = if (courierCompanyName != null && trackingNumber != null) {
"${courierCompanyName}_$trackingNumber" "${courierCompanyName}_$trackingNumber"
} else { } else {
@@ -18,9 +18,7 @@ class EmbeddedRedisInitializer : ApplicationContextInitializer<ConfigurableAppli
} }
private object EmbeddedRedisHolder { private object EmbeddedRedisHolder {
private var redisServer: RedisServer? = null
private var port: Int? = null private var port: Int? = null
private var shutdownHookRegistered = false
@Synchronized @Synchronized
fun start(): Int { fun start(): Int {
@@ -29,8 +27,9 @@ private object EmbeddedRedisHolder {
} }
val selectedPort = PortProvider.newEphemeralPortProvider().get() val selectedPort = PortProvider.newEphemeralPortProvider().get()
redisServer = RedisServer.newRedisServer() RedisServer.newRedisServer()
.port(selectedPort) .port(selectedPort)
.onShutdownForceStop(true)
.setting("bind 127.0.0.1") .setting("bind 127.0.0.1")
.setting("daemonize no") .setting("daemonize no")
.setting("appendonly no") .setting("appendonly no")
@@ -38,22 +37,6 @@ private object EmbeddedRedisHolder {
.also { it.start() } .also { it.start() }
port = selectedPort port = selectedPort
if (!shutdownHookRegistered) {
Runtime.getRuntime().addShutdownHook(
Thread {
stop()
}
)
shutdownHookRegistered = true
}
return selectedPort return selectedPort
} }
@Synchronized
fun stop() {
redisServer?.stop()
redisServer = null
port = null
}
} }
@@ -59,6 +59,54 @@ class AdminGiftControllerTest @Autowired constructor(
categoryRepository.deleteAll() 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 @Test
@DisplayName("관리자는 전체 선물함 목록을 필터링해 조회한다") @DisplayName("관리자는 전체 선물함 목록을 필터링해 조회한다")
fun shouldListAdminGifts() { fun shouldListAdminGifts() {
@@ -284,7 +332,7 @@ class AdminGiftControllerTest @Autowired constructor(
@Test @Test
@DisplayName("관리자는 검수완료 선물을 전달완료 처리한다") @DisplayName("관리자는 검수완료 선물을 전달완료 처리한다")
fun shouldCompleteGiftDelivery() { 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"))) mockMvc.perform(post("/api/v2/admin/gifts/$APPLICATION_NO/complete-delivery").with(user("admin").roles("ADMIN")))
.andExpect(status().isOk) .andExpect(status().isOk)
@@ -294,7 +342,17 @@ class AdminGiftControllerTest @Autowired constructor(
.andExpect(jsonPath("$.data.occurredAt").value(endsWith("Z"))) .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( val gift = giftRepository.save(
Gift( Gift(
applicationNo = APPLICATION_NO, applicationNo = APPLICATION_NO,
@@ -320,6 +378,10 @@ class AdminGiftControllerTest @Autowired constructor(
senderPhoneNumber = "01012345678", senderPhoneNumber = "01012345678",
senderZipCode = "06234", senderZipCode = "06234",
senderAddress = "서울시 강남구", senderAddress = "서울시 강남구",
recipientName = "김소다".takeIf { withRecipientAddress },
recipientPhoneNumber = "01098765432".takeIf { withRecipientAddress },
recipientZipCode = "04524".takeIf { withRecipientAddress },
recipientAddress = "서울시 중구".takeIf { withRecipientAddress },
trackingDeadlineAt = NOW.minusDays(1), trackingDeadlineAt = NOW.minusDays(1),
trackingRegisteredAt = NOW.minusHours(1) trackingRegisteredAt = NOW.minusHours(1)
) )
@@ -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.GiftCategoryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery 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.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.GiftRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePrice 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.adapter.out.persistence.GiftSizePriceRepository
@@ -60,6 +62,7 @@ class GiftControllerTest @Autowired constructor(
private val giftDeliveryRepository: GiftDeliveryRepository, private val giftDeliveryRepository: GiftDeliveryRepository,
private val sizePriceRepository: GiftSizePriceRepository, private val sizePriceRepository: GiftSizePriceRepository,
private val memberRepository: MemberRepository, private val memberRepository: MemberRepository,
private val giftMailboxRepository: GiftMailboxRepository,
private val objectMapper: ObjectMapper private val objectMapper: ObjectMapper
) { ) {
@MockBean @MockBean
@@ -190,6 +193,57 @@ class GiftControllerTest @Autowired constructor(
.andExpect(jsonPath("$.data.statusTimeline[1].occurredAt").value("2026-09-30T12:00:00Z")) .andExpect(jsonPath("$.data.statusTimeline[1].occurredAt").value("2026-09-30T12:00:00Z"))
} }
@Test
@DisplayName("발신자는 전달 완료 전 정상 진행 선물 상세에서 받을 주소를 조회한다")
fun shouldGetMailboxForSentNormalProgressGiftDetailBeforeDelivered() {
val sender = memberRepository.save(Member(password = "password", nickname = "fan"))
val recipient = memberRepository.save(Member(password = "password", nickname = "creator"))
giftMailboxRepository.save(
GiftMailbox(
name = "소다라이브 선물 담당자",
phoneNumber = "01012345678",
zipCode = "06234",
address = "서울시 강남구",
addressDetail = "3층 선물 접수처"
)
)
val statuses = listOf(
GiftStatus.RECEIVED,
GiftStatus.TRACKING_REGISTERED,
GiftStatus.ARRIVED_AT_MAILBOX,
GiftStatus.INSPECTION_COMPLETED
)
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 @Test
@DisplayName("팬은 POST /api/v2/gifts로 선물을 신청하고 접수 결과를 받는다") @DisplayName("팬은 POST /api/v2/gifts로 선물을 신청하고 접수 결과를 받는다")
fun shouldRegisterGift() { fun shouldRegisterGift() {
@@ -463,6 +517,18 @@ class GiftControllerTest @Autowired constructor(
isActive = isActive 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 { private companion object {
const val SENDER_ID = 1L const val SENDER_ID = 1L
const val RECIPIENT_ID = 2L const val RECIPIENT_ID = 2L
@@ -117,6 +117,29 @@ class GiftSchedulerTest @Autowired constructor(
assertEquals("배송지 미입력 기한 초과", delivery.undeliverableReason) 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 @Test
@DisplayName("배송지 입력 기한과 같은 발송 확인 선물은 전달 불가 처리하지 않는다") @DisplayName("배송지 입력 기한과 같은 발송 확인 선물은 전달 불가 처리하지 않는다")
fun shouldIgnoreTrackingRegisteredGiftWhoseRecipientAddressDeadlineIsNotPast() { fun shouldIgnoreTrackingRegisteredGiftWhoseRecipientAddressDeadlineIsNotPast() {
@@ -154,6 +177,46 @@ class GiftSchedulerTest @Autowired constructor(
assertEquals(null, delivery.undeliverableReason) 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 @Test
@DisplayName("배송지 미입력 전달 불가를 다시 실행해도 이미 전달 불가인 선물은 처리하지 않는다") @DisplayName("배송지 미입력 전달 불가를 다시 실행해도 이미 전달 불가인 선물은 처리하지 않는다")
fun shouldNotUpdateAlreadyUndeliverableGiftOnSecondRun() { fun shouldNotUpdateAlreadyUndeliverableGiftOnSecondRun() {
@@ -240,11 +303,12 @@ class GiftSchedulerTest @Autowired constructor(
status: GiftStatus, status: GiftStatus,
trackingDeadlineAt: LocalDateTime, trackingDeadlineAt: LocalDateTime,
recipientAddressDeadlineAt: LocalDateTime? = null, recipientAddressDeadlineAt: LocalDateTime? = null,
recipientAddress: String? = null recipientAddress: String? = null,
applicationNo: String = APPLICATION_NO
): Pair<Gift, GiftDelivery> { ): Pair<Gift, GiftDelivery> {
val gift = giftRepository.save( val gift = giftRepository.save(
Gift( Gift(
applicationNo = APPLICATION_NO, applicationNo = applicationNo,
senderMemberId = SENDER_MEMBER_ID, senderMemberId = SENDER_MEMBER_ID,
recipientMemberId = RECIPIENT_MEMBER_ID, recipientMemberId = RECIPIENT_MEMBER_ID,
status = status, status = status,
@@ -146,9 +146,13 @@ class GiftAdminQueryServiceTest @Autowired constructor(
service.getGiftDetail("A-100260930102").availableActions service.getGiftDetail("A-100260930102").availableActions
) )
assertEquals( assertEquals(
listOf("COMPLETE_DELIVERY", "MARK_UNDELIVERABLE"), listOf("MARK_UNDELIVERABLE"),
service.getGiftDetail("A-100260930103").availableActions service.getGiftDetail("A-100260930103").availableActions
) )
assertEquals(
listOf("MARK_UNDELIVERABLE"),
service.getGifts(GiftStatus.INSPECTION_COMPLETED, null, null, 0, 20).items.single().availableActions
)
assertEquals(emptyList<String>(), service.getGiftDetail("A-100260930104").availableActions) assertEquals(emptyList<String>(), service.getGiftDetail("A-100260930104").availableActions)
assertEquals(emptyList<String>(), service.getGiftDetail("A-100260930105").availableActions) assertEquals(emptyList<String>(), service.getGiftDetail("A-100260930105").availableActions)
assertEquals(emptyList<String>(), service.getGiftDetail("A-100260930106").availableActions) assertEquals(emptyList<String>(), service.getGiftDetail("A-100260930106").availableActions)
@@ -158,6 +162,33 @@ class GiftAdminQueryServiceTest @Autowired constructor(
assertEquals("common.error.invalid_request", exception.messageKey) 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( private fun service() = GiftAdminQueryService(
giftRepository = giftRepository, giftRepository = giftRepository,
deliveryRepository = deliveryRepository, deliveryRepository = deliveryRepository,
@@ -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.GiftCategoryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery 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.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.GiftRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository 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.GiftSize
@@ -15,6 +16,8 @@ import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test 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.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
@@ -33,7 +36,8 @@ class GiftAdminServiceTest @Autowired constructor(
private val categoryRepository: GiftCategoryRepository, private val categoryRepository: GiftCategoryRepository,
private val sizePriceRepository: GiftSizePriceRepository, private val sizePriceRepository: GiftSizePriceRepository,
private val giftRepository: GiftRepository, 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 giftPushService = Mockito.mock(GiftPushService::class.java)
private val service = GiftAdminService( private val service = GiftAdminService(
@@ -41,9 +45,61 @@ class GiftAdminServiceTest @Autowired constructor(
sizePriceRepository, sizePriceRepository,
giftRepository, giftRepository,
giftDeliveryRepository, giftDeliveryRepository,
giftMailboxRepository,
giftPushService 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 @Test
@DisplayName("카테고리를 등록, 수정, 논리삭제한다") @DisplayName("카테고리를 등록, 수정, 논리삭제한다")
fun shouldCreateUpdateAndDeactivateCategory() { fun shouldCreateUpdateAndDeactivateCategory() {
@@ -194,6 +250,7 @@ class GiftAdminServiceTest @Autowired constructor(
@DisplayName("운영자는 검수 완료 선물을 전달 완료 처리한다") @DisplayName("운영자는 검수 완료 선물을 전달 완료 처리한다")
fun shouldCompleteDeliveryForInspectionCompletedGift() { fun shouldCompleteDeliveryForInspectionCompletedGift() {
val (gift, delivery) = saveGift(GiftStatus.INSPECTION_COMPLETED) val (gift, delivery) = saveGift(GiftStatus.INSPECTION_COMPLETED)
populateRecipientInformation(delivery)
val result = service.completeDelivery(APPLICATION_NO, NOW) val result = service.completeDelivery(APPLICATION_NO, NOW)
@@ -208,7 +265,8 @@ class GiftAdminServiceTest @Autowired constructor(
@Test @Test
@DisplayName("검수 완료 상태가 아니면 운영 전달 완료 처리할 수 없다") @DisplayName("검수 완료 상태가 아니면 운영 전달 완료 처리할 수 없다")
fun shouldRejectDeliveryCompletionWhenGiftIsNotInspectionCompleted() { fun shouldRejectDeliveryCompletionWhenGiftIsNotInspectionCompleted() {
saveGift(GiftStatus.ARRIVED_AT_MAILBOX) val (_, delivery) = saveGift(GiftStatus.ARRIVED_AT_MAILBOX)
populateRecipientInformation(delivery)
val exception = assertThrows(SodaException::class.java) { val exception = assertThrows(SodaException::class.java) {
service.completeDelivery(APPLICATION_NO, NOW) service.completeDelivery(APPLICATION_NO, NOW)
@@ -217,6 +275,50 @@ class GiftAdminServiceTest @Autowired constructor(
assertEquals("common.error.invalid_request", exception.messageKey) 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<Gift, GiftDelivery> { private fun saveGift(status: GiftStatus): Pair<Gift, GiftDelivery> {
val gift = giftRepository.save( val gift = giftRepository.save(
Gift( Gift(
@@ -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.GiftCategoryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery 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.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.GiftRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePrice 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.adapter.out.persistence.GiftSizePriceRepository
@@ -39,7 +41,8 @@ class GiftQueryServiceTest @Autowired constructor(
private val sizePriceRepository: GiftSizePriceRepository, private val sizePriceRepository: GiftSizePriceRepository,
private val giftRepository: GiftRepository, private val giftRepository: GiftRepository,
private val deliveryRepository: GiftDeliveryRepository, private val deliveryRepository: GiftDeliveryRepository,
private val memberRepository: MemberRepository private val memberRepository: MemberRepository,
private val giftMailboxRepository: GiftMailboxRepository
) { ) {
@Test @Test
fun `폼 옵션은 활성 카테고리와 활성 사이즈 가격만 반환한다`() { fun `폼 옵션은 활성 카테고리와 활성 사이즈 가격만 반환한다`() {
@@ -230,6 +233,108 @@ class GiftQueryServiceTest @Autowired constructor(
assertNull(result.statusTimeline[1].occurredAt) assertNull(result.statusTimeline[1].occurredAt)
} }
@Test
@DisplayName("보낸 선물의 전달 완료 전 정상 진행 상세는 받을 주소를 반환한다")
fun shouldReturnMailboxForSentNormalProgressGiftDetailBeforeDelivered() {
val sender = saveMember("fan")
val recipient = saveMember("creator")
giftMailboxRepository.save(
GiftMailbox(
name = "소다라이브 선물 담당자",
phoneNumber = "01012345678",
zipCode = "06234",
address = "서울시 강남구",
addressDetail = "3층 선물 접수처"
)
)
val service = queryService()
val allowedStatuses = listOf(
GiftStatus.RECEIVED,
GiftStatus.TRACKING_REGISTERED,
GiftStatus.ARRIVED_AT_MAILBOX,
GiftStatus.INSPECTION_COMPLETED
)
allowedStatuses.forEachIndexed { index, status ->
val gift = saveGift(
applicationNo = "G-MAILBOX-ALLOWED-$index",
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = status
)
saveDelivery(gift).applyStatusTimes(status)
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("보낸 선물의 종료 상태와 받은 선물 상세는 받을 주소를 반환하지 않는다")
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")
val gift = saveGift(
applicationNo = "G-MAILBOX-EMPTY",
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = GiftStatus.INSPECTION_COMPLETED
)
saveDelivery(gift).applyStatusTimes(GiftStatus.INSPECTION_COMPLETED)
val result = queryService().getGiftDetail(sender.id!!, gift.applicationNo)
assertNull(result.mailbox)
}
@Test @Test
@DisplayName("수신자는 노출된 선물 상세에서 자신의 배송지와 발신자 닉네임만 조회한다") @DisplayName("수신자는 노출된 선물 상세에서 자신의 배송지와 발신자 닉네임만 조회한다")
fun shouldReturnReceivedGiftDetailWithoutSenderInformation() { fun shouldReturnReceivedGiftDetailWithoutSenderInformation() {
@@ -444,12 +549,25 @@ 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( private fun queryService() = GiftQueryService(
categoryRepository, categoryRepository,
sizePriceRepository, sizePriceRepository,
giftRepository, giftRepository,
deliveryRepository, deliveryRepository,
memberRepository, memberRepository,
giftMailboxRepository,
"https://cdn.test" "https://cdn.test"
) )