Compare commits

...
3 Commits
9 changed files with 702 additions and 63 deletions
@@ -245,7 +245,7 @@ type AdminGiftOperationStatusResponse = {
목표:
- 관리자 메뉴 `선물함 관리 > 받을 주소`에서 팬이 선물을 보낼 전역 단일 주소를 조회하고 저장한다.
- 이 주소는 팬의 선물 상세 API에서 운송장 등록 전 상태일 때 `mailbox`로 노출된다.
- 이 주소는 팬의 보낸 선물 상세 API에서 정상 진행 상태 중 전달 완료 전(`RECEIVED`, `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED`)일 때 `mailbox`로 노출된다.
- 기존 관리자 페이지의 폼, 저장 버튼, 토스트, 에러 표시 패턴을 그대로 따른다.
라우트:
@@ -187,6 +187,10 @@ data class GiftMailboxResponse(
val phoneNumber: String
)
// mailbox는 direction=SENT이고 status가 RECEIVED, TRACKING_REGISTERED,
// ARRIVED_AT_MAILBOX, INSPECTION_COMPLETED일 때만 값이 있다.
// DELIVERED, UNDELIVERABLE, CANCELED 또는 direction=RECEIVED이면 null이다.
data class GiftDeliveryInfoResponse(
val canceledAt: String?,
val undeliverableAt: String?,
@@ -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를 호출하지 않는지 확인한다.
- 저장 성공 후 성공 토스트와 화면 갱신이 일어나는지 확인한다.
```
@@ -1311,7 +1311,7 @@ P7은 FCM 확장 후 전체 상태 전이를 연결하고 최종 회귀로 종
## Phase 11: 선물 받을 주소 설정
**Phase 결과:** 관리자는 전역 단일 선물 받을 주소를 등록/수정하고, 팬은 운송장 등록 전 상세에서 해당 주소를 확인할 수 있다.
**Phase 결과:** 관리자는 전역 단일 선물 받을 주소를 등록/수정하고, 팬은 보낸 선물의 전달 완료 전 정상 진행 상세에서 해당 주소를 확인할 수 있다.
**선행조건:** `P3-GATE`, `P10-GATE` 완료.
@@ -1331,11 +1331,11 @@ P7은 FCM 확장 후 전체 상태 전이를 연결하고 최종 회귀로 종
#### Task 11.2 선물 상세 받을 주소 노출 구현
**Goal 실행 `P11-T2`:** 보낸 선물의 운송장 등록 전 상세에만 받을 주소를 노출한다.
**Goal 실행 `P11-T2`:** 보낸 선물의 전달 완료 전 정상 진행 상세에만 받을 주소를 노출한다.
- **Files:** Modify `GiftQueryService.kt`; Test `GiftQueryServiceTest.kt`, `GiftControllerTest.kt`.
- [x] **RED:** `direction=SENT`이고 `status=RECEIVED`인 상세만 `mailbox`를 반환하는 실패 test를 작성한다.
- [x] **RED:** 받을 주소 미등록 또는 다른 상태/방향이면 `mailbox=null`인 실패 test를 작성한다.
- [x] **RED:** `direction=SENT`이고 `status`가 `RECEIVED`, `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED`인 상세에서 `mailbox`를 반환하는 실패 test를 작성한다.
- [x] **RED:** 받을 주소 미등록, 종료 상태(`DELIVERED`, `UNDELIVERABLE`, `CANCELED`), 또는 다른 방향이면 `mailbox=null`인 실패 test를 작성한다.
- [x] **RED 확인:** focused test 실패를 확인한다.
- [x] **GREEN:** `GiftQueryService`에서 받을 주소 repository를 조회해 기존 `GiftMailboxResult`로 매핑한다.
- [x] **GREEN 확인:** focused test 통과를 확인한다.
@@ -1351,6 +1351,17 @@ P7은 FCM 확장 후 전체 상태 전이를 연결하고 최종 회귀로 종
**Expected:** 받을 주소 미등록/등록/수정, 상세 `mailbox` 노출 조건, 기존 선물 상세 권한 정책이 통과한다.
### P11-T2 선물 상세 받을 주소 노출 조건 확장 — 2026-10-02
- 상태: 완료
- 무엇을: 보낸 선물 상세의 `mailbox` 노출 조건을 `RECEIVED` 단일 상태에서 정상 진행 상태 중 `DELIVERED` 전(`RECEIVED`, `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED`)으로 확장했다.
- 왜: 팬이 운송장을 등록한 뒤에도 전달 완료 전까지 관리자 전역 선물 받을 주소를 상세에서 확인할 수 있어야 하기 때문이다.
- 어떻게:
- RED 확인: `GiftQueryServiceTest`, `GiftControllerTest`에 정상 진행 상태/종료 상태/받은 선물 방향 검증을 추가하고 기존 구현에서 focused test 실패를 확인했다.
- GREEN 구현: `GiftQueryService.mailboxFor` 조건을 `direction=SENT`이고 종료 상태가 아닌 경우로 단순화했다.
- 검증: `./gradlew test --tests '*GiftQueryServiceTest' --tests '*GiftControllerTest'`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.gift.*' --tests 'kr.co.vividnext.sodalive.v2.api.gift.*'`, `./gradlew ktlintCheck`, `git diff --check` 실행 결과 통과.
- 남은 항목: 없음
## Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
@@ -186,6 +186,7 @@ INSPECTION_COMPLETED -> UNDELIVERABLE
- 로그인 회원이 받는 크리에이터이면 보내는 팬의 닉네임만 알 수 있고, 팬이 신청 시 등록한 이름/휴대폰 번호/주소는 알 수 없다.
- 로그인 회원이 보내는 팬이면 받는 크리에이터의 닉네임만 알 수 있고, 크리에이터가 입력한 이름/휴대폰 번호/주소는 알 수 없다.
- 보내는 팬 상세에는 팬 본인이 신청 시 등록한 이름, 휴대폰 번호, 주소를 내려준다.
- 보내는 팬 상세에는 관리자가 등록한 전역 선물 받을 주소를 `mailbox`로 내려준다. 단, 정상 진행 상태 중 `DELIVERED` 전 상태(`RECEIVED`, `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED`)에서만 내려준다.
- 받는 크리에이터 상세에는 크리에이터 본인이 입력한 받는 주소의 이름, 휴대폰 번호, 주소를 내려준다.
- 주소 형식은 `(우편번호) 주소, 상세주소` 문자열로 내려준다.
- 로그인 회원이 알아야 하는 데이터가 아니거나 아직 입력되지 않은 데이터는 `null`로 내려준다.
@@ -446,7 +447,7 @@ Response:
}
```
`mailbox`는 관리자가 등록한 전역 단일 받을 주소다. 보낸 사람 관점(`direction=SENT`)이고 상태가 운송장 등록 전인 `RECEIVED`일 때만 내려준다. 받을 주소가 등록되지 않았거나 다른 상태/방향이면 `mailbox`는 `null`이다. 주소는 상세 조회 시점의 최신 관리자 설정을 사용한다.
`mailbox`는 관리자가 등록한 전역 단일 받을 주소다. 보낸 사람 관점(`direction=SENT`)이고 정상 진행 상태 중 `DELIVERED` 전 상태(`RECEIVED`, `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED`)일 때 내려준다. 받을 주소가 등록되지 않았거나, 다른 방향이거나, `DELIVERED`/`UNDELIVERABLE`/`CANCELED` 상태이면 `mailbox`는 `null`이다. 주소는 상세 조회 시점의 최신 관리자 설정을 사용한다.
받는 크리에이터 관점 응답 예시는 다음과 같다.
@@ -516,7 +517,7 @@ Response:
`direction=SENT`이면 보내는 팬 관점의 상세 응답이다.
`giftInfo.recipientCreatorNickname`에는 받는 크리에이터 닉네임을 내려주고, `giftInfo.senderNickname`, `giftInfo.shippingRequestedAt`, `recipientAddress`는 `null`이다.
`senderInfo`에는 팬 본인이 신청 시 등록한 이름, 휴대폰 번호, 주소를 내려준다.
`mailbox`는 코드상에서 정한 사서함 이름, 주소, 연락처를 내려준다. 구현 후 값이 제공되면 이 문서를 갱신한다.
`mailbox`는 관리자가 등록한 전역 단일 받을 주소의 이름, 주소, 연락처를 내려준다. 보낸 사람 관점의 정상 진행 상태 중 `DELIVERED` 전 상태에서만 값이 있고, 그 외에는 `null`이다.
`direction=SENT`이고 현재 상태가 `RECEIVED`이면 `trackingRequired=true`이며, 팬 클라이언트는 운송장 등록 CTA를 표시한다.
`direction=RECEIVED`이면 받는 크리에이터 관점의 상세 응답이다.
`giftInfo.senderNickname`에는 발송인인 팬 닉네임을 내려주고, `giftInfo.recipientCreatorNickname`, `giftInfo.applicationNo`, `giftInfo.paidCan`, `giftInfo.tracking`, `senderInfo`, `mailbox`는 `null`이다.
@@ -806,7 +807,7 @@ Response:
#### 8.13.3 관리자 선물 받을 주소 설정 API
선물 받을 주소는 전역 단일 설정이다. 팬이 운송장을 등록하기 전에 보낸 선물 상세에서 이 주소를 확인하고 택배를 발송한다.
선물 받을 주소는 전역 단일 설정이다. 팬은 정상 진행 상태 중 `DELIVERED` 전인 보낸 선물 상세에서 이 주소를 확인할 수 있다.
`GET /api/v2/admin/gift-mailbox`
@@ -251,7 +251,7 @@ class GiftQueryService(
}
private fun mailboxFor(direction: GiftDirection, status: GiftStatus): GiftMailboxResult? {
if (direction != GiftDirection.SENT || status != GiftStatus.RECEIVED) return null
if (direction != GiftDirection.SENT || status in TERMINAL_STATUSES) return null
return giftMailboxRepository.findByIdOrNull(GiftMailbox.SINGLETON_ID)?.let {
GiftMailboxResult(
name = it.name,
@@ -194,15 +194,32 @@ class GiftControllerTest @Autowired constructor(
}
@Test
@DisplayName("발신자는 운송장 등록 전 선물 상세에서 받을 주소를 조회한다")
fun shouldGetMailboxForSentReceivedGiftDetail() {
@DisplayName("발신자는 전달 완료 전 정상 진행 선물 상세에서 받을 주소를 조회한다")
fun shouldGetMailboxForSentNormalProgressGiftDetailBeforeDelivered() {
val sender = memberRepository.save(Member(password = "password", nickname = "fan"))
val recipient = memberRepository.save(Member(password = "password", nickname = "creator"))
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-CONTROLLER-MAILBOX",
applicationNo = "G-C-MBOX-$index",
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = GiftStatus.RECEIVED
status = giftStatus
)
giftDeliveryRepository.save(
GiftDelivery(
@@ -213,16 +230,7 @@ class GiftControllerTest @Autowired constructor(
senderAddress = "서울시 강남구",
trackingDeadlineAt = LocalDateTime.of(2026, 10, 2, 12, 0)
)
)
giftMailboxRepository.save(
GiftMailbox(
name = "소다라이브 선물 담당자",
phoneNumber = "01012345678",
zipCode = "06234",
address = "서울시 강남구",
addressDetail = "3층 선물 접수처"
)
)
).applyStatusTimes(giftStatus)
mockMvc.perform(
get("/api/v2/gifts/{applicationNo}", gift.applicationNo)
@@ -234,6 +242,7 @@ class GiftControllerTest @Autowired constructor(
.andExpect(jsonPath("$.data.mailbox.phoneNumber").value("01012345678"))
.andExpect(jsonPath("$.data.mailbox.address").value("(06234) 서울시 강남구, 3층 선물 접수처"))
}
}
@Test
@DisplayName("팬은 POST /api/v2/gifts로 선물을 신청하고 접수 결과를 받는다")
@@ -508,6 +517,18 @@ class GiftControllerTest @Autowired constructor(
isActive = isActive
)
private fun GiftDelivery.applyStatusTimes(status: GiftStatus) {
if (status.ordinal >= GiftStatus.TRACKING_REGISTERED.ordinal) {
trackingRegisteredAt = LocalDateTime.of(2026, 9, 30, 12, 0)
}
if (status.ordinal >= GiftStatus.ARRIVED_AT_MAILBOX.ordinal) {
arrivedAtMailboxAt = LocalDateTime.of(2026, 10, 1, 12, 0)
}
if (status.ordinal >= GiftStatus.INSPECTION_COMPLETED.ordinal) {
inspectionCompletedAt = LocalDateTime.of(2026, 10, 2, 12, 0)
}
}
private companion object {
const val SENDER_ID = 1L
const val RECIPIENT_ID = 2L
@@ -234,24 +234,10 @@ class GiftQueryServiceTest @Autowired constructor(
}
@Test
@DisplayName("보낸 선물의 운송장 등록 전 상세만 받을 주소를 반환한다")
fun shouldReturnMailboxOnlyForSentReceivedGiftDetail() {
@DisplayName("보낸 선물의 전달 완료 전 정상 진행 상세는 받을 주소를 반환한다")
fun shouldReturnMailboxForSentNormalProgressGiftDetailBeforeDelivered() {
val sender = saveMember("fan")
val recipient = saveMember("creator")
val receivedGift = saveGift(
applicationNo = "G-MAILBOX-RECEIVED",
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = GiftStatus.RECEIVED
)
saveDelivery(receivedGift)
val trackingGift = saveGift(
applicationNo = "G-MAILBOX-TRACKING",
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = GiftStatus.TRACKING_REGISTERED
)
saveDelivery(trackingGift).trackingRegisteredAt = NOW
giftMailboxRepository.save(
GiftMailbox(
name = "소다라이브 선물 담당자",
@@ -262,20 +248,77 @@ class GiftQueryServiceTest @Autowired constructor(
)
)
val service = queryService()
val allowedStatuses = listOf(
GiftStatus.RECEIVED,
GiftStatus.TRACKING_REGISTERED,
GiftStatus.ARRIVED_AT_MAILBOX,
GiftStatus.INSPECTION_COMPLETED
)
val receivedResult = service.getGiftDetail(sender.id!!, receivedGift.applicationNo)
val trackingSenderResult = service.getGiftDetail(sender.id!!, trackingGift.applicationNo)
val trackingRecipientResult = service.getGiftDetail(recipient.id!!, trackingGift.applicationNo)
allowedStatuses.forEachIndexed { index, status ->
val gift = saveGift(
applicationNo = "G-MAILBOX-ALLOWED-$index",
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = status
)
saveDelivery(gift).applyStatusTimes(status)
assertEquals("소다라이브 선물 담당자", receivedResult.mailbox?.name)
assertEquals("01012345678", receivedResult.mailbox?.phoneNumber)
assertEquals("(06234) 서울시 강남구, 3층 선물 접수처", receivedResult.mailbox?.address)
assertNull(trackingSenderResult.mailbox)
assertNull(trackingRecipientResult.mailbox)
val result = service.getGiftDetail(sender.id!!, gift.applicationNo)
assertEquals("소다라이브 선물 담당자", result.mailbox?.name)
assertEquals("01012345678", result.mailbox?.phoneNumber)
assertEquals("(06234) 서울시 강남구, 3층 선물 접수처", result.mailbox?.address)
assertEquals(status == GiftStatus.RECEIVED, result.trackingRequired)
}
}
@Test
@DisplayName("받을 주소가 없으면 운송장 등록 전 상세도 mailbox가 null이다")
@DisplayName("보낸 선물의 종료 상태와 받은 선물 상세는 받을 주소를 반환하지 않는다")
fun shouldReturnNullMailboxForTerminalOrReceivedGiftDetail() {
val sender = saveMember("fan")
val recipient = saveMember("creator")
giftMailboxRepository.save(
GiftMailbox(
name = "소다라이브 선물 담당자",
phoneNumber = "01012345678",
zipCode = "06234",
address = "서울시 강남구",
addressDetail = "3층 선물 접수처"
)
)
val service = queryService()
val terminalStatuses = listOf(GiftStatus.DELIVERED, GiftStatus.UNDELIVERABLE, GiftStatus.CANCELED)
terminalStatuses.forEachIndexed { index, status ->
val gift = saveGift(
applicationNo = "G-MAILBOX-TERMINAL-$index",
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = status
)
saveDelivery(gift).applyStatusTimes(status)
val result = service.getGiftDetail(sender.id!!, gift.applicationNo)
assertNull(result.mailbox)
}
val receivedGift = saveGift(
applicationNo = "G-MAILBOX-RECIPIENT",
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = GiftStatus.TRACKING_REGISTERED
)
saveDelivery(receivedGift).applyStatusTimes(GiftStatus.TRACKING_REGISTERED)
val recipientResult = service.getGiftDetail(recipient.id!!, receivedGift.applicationNo)
assertNull(recipientResult.mailbox)
}
@Test
@DisplayName("받을 주소가 없으면 전달 완료 전 정상 진행 상세도 mailbox가 null이다")
fun shouldReturnNullMailboxWhenMailboxNotConfigured() {
val sender = saveMember("fan")
val recipient = saveMember("creator")
@@ -283,9 +326,9 @@ class GiftQueryServiceTest @Autowired constructor(
applicationNo = "G-MAILBOX-EMPTY",
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = GiftStatus.RECEIVED
status = GiftStatus.INSPECTION_COMPLETED
)
saveDelivery(gift)
saveDelivery(gift).applyStatusTimes(GiftStatus.INSPECTION_COMPLETED)
val result = queryService().getGiftDetail(sender.id!!, gift.applicationNo)
@@ -506,6 +549,18 @@ class GiftQueryServiceTest @Autowired constructor(
)
)
private fun GiftDelivery.applyStatusTimes(status: GiftStatus) {
if (status.ordinal >= GiftStatus.TRACKING_REGISTERED.ordinal) trackingRegisteredAt = NOW.plusHours(1)
if (status.ordinal >= GiftStatus.ARRIVED_AT_MAILBOX.ordinal) arrivedAtMailboxAt = NOW.plusHours(2)
if (status.ordinal >= GiftStatus.INSPECTION_COMPLETED.ordinal) inspectionCompletedAt = NOW.plusHours(3)
if (status == GiftStatus.DELIVERED) deliveredAt = NOW.plusHours(4)
if (status == GiftStatus.UNDELIVERABLE) {
undeliverableAt = NOW.plusHours(4)
undeliverableReason = "검수 실패"
}
if (status == GiftStatus.CANCELED) canceledAt = NOW.plusHours(1)
}
private fun queryService() = GiftQueryService(
categoryRepository,
sizePriceRepository,