Compare commits
6
Commits
978206438e
...
test
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95d7568a25 | ||
|
|
5a09c85135 | ||
|
|
124a615a85 | ||
|
|
112ad755e5 | ||
|
|
0bbb9e4fc7 | ||
|
|
c6b5f9c867 |
@@ -1,6 +1,6 @@
|
||||
# 선물 상세 및 운송장 등록 페이지 생성 프롬프트
|
||||
# 보낸 선물 상세 및 운송장 등록 페이지 생성 프롬프트
|
||||
|
||||
이 문서는 Android/iOS 앱의 `선물함` 리스트에서 진입하는 선물 상세 페이지와 운송장 등록 페이지를 생성할 때 사용하는 프롬프트다.
|
||||
이 문서는 Android/iOS 앱의 `선물함` 리스트에서 보낸 선물 상세 페이지와 운송장 등록 페이지를 생성할 때 사용하는 프롬프트다.
|
||||
|
||||
참고 Figma:
|
||||
|
||||
|
||||
@@ -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 문자열을 앱 표시 형식으로 변환해 표시하는지 확인한다.
|
||||
```
|
||||
@@ -8,7 +8,7 @@
|
||||
| 작성일 | 2026-09-29 |
|
||||
| 요구사항 기준 | `docs/20260929_크리에이터_선물하기/prd.md` |
|
||||
| API 기준 | `docs/20260929_크리에이터_선물하기/prd.md`의 `8. API 계약` |
|
||||
| 현재 Phase | Phase 10 완료 |
|
||||
| 현재 Phase | Phase 12 구현 완료 |
|
||||
| 현재 활성 Goal | 완료 |
|
||||
|
||||
## 목표
|
||||
@@ -1362,6 +1362,50 @@ P7은 FCM 확장 후 전체 상태 전이를 연결하고 최종 회귀로 종
|
||||
- 검증: `./gradlew test --tests '*GiftQueryServiceTest' --tests '*GiftControllerTest'`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.gift.*' --tests 'kr.co.vividnext.sodalive.v2.api.gift.*'`, `./gradlew ktlintCheck`, `git diff --check` 실행 결과 통과.
|
||||
- 남은 항목: 없음
|
||||
|
||||
## Phase 12: 수취인 정보 전달 완료 불변조건 보강
|
||||
|
||||
**Phase 결과:** 수취인 필수 정보가 없는 선물은 전달 완료할 수 없고, 배송지 입력 기한이 지난 진행 중 선물은 관리자 진행 상태와 무관하게 전달 불가로 종료된다.
|
||||
|
||||
**선행조건:** `P4-T2`, `P5-T3` 완료.
|
||||
|
||||
### Task 12.1 전달 완료 수취인 정보 필수화
|
||||
|
||||
**Goal 실행 `P12-T1`:** `COMPLETE_DELIVERY`는 `INSPECTION_COMPLETED` 상태이며 수취인 이름, 휴대폰 번호, 우편번호, 주소가 모두 입력된 경우에만 허용한다.
|
||||
|
||||
- **Files:** Modify `GiftDelivery.kt`, `GiftAdminService.kt`, `GiftAdminQueryService.kt`; Test `GiftAdminServiceTest.kt`, `GiftAdminQueryServiceTest.kt`, `AdminGiftControllerTest.kt`.
|
||||
- [x] **RED:** 수취인 필수 정보가 없는 `INSPECTION_COMPLETED` 선물의 전달 완료 요청이 거부되고 상태/전달 완료 시각/푸시가 바뀌지 않는 실패 test를 작성한다.
|
||||
- [x] **RED:** 수취인 필수 정보가 불완전하면 관리자 목록/상세의 `availableActions`에서 `COMPLETE_DELIVERY`가 제외되는 실패 test를 작성한다.
|
||||
- [x] **RED 확인:** focused test가 기존 구현에서 의도한 assertion으로 실패하는지 확인한다.
|
||||
- [x] **GREEN:** 수취인 정보 완비 조건을 공통화하고 command와 관리자 action 계산에 적용한다.
|
||||
- [x] **GREEN 확인:** 같은 focused test 통과를 확인한다.
|
||||
|
||||
### Task 12.2 배송지 미입력 기한 초과 상태 범위 보강
|
||||
|
||||
**Goal 실행 `P12-T2`:** 배송지 입력 기한이 지난 주소 미입력 선물을 `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED`에서 `UNDELIVERABLE`로 종료한다.
|
||||
|
||||
- **Files:** Modify `GiftRepository.kt`; Test `GiftSchedulerTest.kt`.
|
||||
- [x] **RED:** 세 상태 모두 기한 초과 + `recipientAddress=null`이면 전달 불가가 되는 실패 test를 작성한다.
|
||||
- [x] **RED:** 기한 동일/미도래, 주소 입력 완료, 대상 외 상태는 변경되지 않는 회귀 test를 유지한다.
|
||||
- [x] **RED 확인:** `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED` case가 기존 query에서 실패하는지 확인한다.
|
||||
- [x] **GREEN:** 기존 만료 조회 query의 상태 조건만 세 상태로 확장한다.
|
||||
- [x] **GREEN 확인:** scheduler focused test 통과를 확인한다.
|
||||
|
||||
### Phase 12 Gate
|
||||
|
||||
- [x] `GiftAdminServiceTest`, `GiftAdminQueryServiceTest`, `AdminGiftControllerTest`, `GiftSchedulerTest`의 변경 관련 test 통과.
|
||||
- [x] 전체 `./gradlew --no-daemon test` 통과(8분 33초), 종료 후 embedded Redis 및 Gradle test worker 잔존 없음.
|
||||
- [x] `ktlintCheck`, `build -x test`, `git diff --check` 통과.
|
||||
- [x] `AdminGiftControllerTest`에서 주소 미입력 실패와 주소 입력 완료 성공 HTTP 계약 확인.
|
||||
|
||||
**구현 결과:** 수취인 필수 정보 완비 조건을 `GiftDelivery.hasCompleteRecipientInformation()`으로 공통화해 전달 완료 command와 관리자 action 계산에 적용했다. 배송지 입력 기한 초과 조회는 주소 입력 가능 상태 3개로 확장했다.
|
||||
|
||||
### Task 12.3 Embedded Redis 종료 누수 수정
|
||||
|
||||
- [x] `com.github.codemonstur:embedded-redis:1.4.3`의 `RedisInstance.stop()`과 shutdown hook 등록 동작을 확인했다.
|
||||
- [x] initializer의 중복 JVM shutdown hook을 제거했다.
|
||||
- [x] 공식 `onShutdownForceStop(true)` 옵션으로 native Redis 프로세스 종료가 무기한 `waitFor()`에 걸리지 않도록 변경했다.
|
||||
- [x] 후속 요청에 따라 전체 `./gradlew --no-daemon test`를 실행해 정상 종료를 확인했다.
|
||||
|
||||
## Decision Log
|
||||
|
||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|
||||
@@ -1373,12 +1417,14 @@ P7은 FCM 확장 후 전체 상태 전이를 연결하고 최종 회귀로 종
|
||||
| 2026-09-30 | `PLAN-DEC-005` | 확정 | 운영 반영용 DDL에는 선물 도메인 FK를 명시하고, 선물 신청 건에는 최종 결제 금액 스냅샷만 저장한다 | 사용자 결정: 제대로 가는 방향 선호, `basePriceCan`은 신청 건에 불필요 | `gift-schema.sql`, `Gift` |
|
||||
| 2026-09-30 | `PLAN-DEC-006` | 확정 | 선물 API response datetime은 전역 Jackson 설정이 아니라 DTO response boundary에서 UTC ISO 문자열로 변환한다 | 선물 API만의 공개 계약 보강이며 전역 변경은 기존 API 영향이 큼 | `P8-T1`, `client-api-summary.md` |
|
||||
| 2026-09-30 | `PLAN-DEC-007` | 확정 | 기존 `categoryCode`는 내부 범용코드로 유지하고 분류번호/접수코드/대표품목을 추가하며, 신청번호는 카테고리+날짜별 sequence로 발급한다 | 사용자 확정: 카테고리 필드 의미와 사용자 폼 옵션 표시 규칙 분리 | `P9-T1`~`P9-T3` |
|
||||
| 2026-10-02 | `PLAN-DEC-008` | 확정 | `COMPLETE_DELIVERY`는 수취인 필수 정보가 모두 입력된 경우에만 허용하고, 배송지 미입력 기한 초과는 주소 입력 가능 상태 3개 전체에서 `UNDELIVERABLE`로 종료한다 | 배송지 없는 선물의 전달 완료와 관리자 상태 진행 후 만료 누락 방지 | `P12-T1`, `P12-T2` |
|
||||
|
||||
## 발견된 문제
|
||||
|
||||
| ID | 심각도 | 상태 | 발견 내용 | 영향 Goal | 처리 계획 |
|
||||
|---|---|---|---|---|---|
|
||||
| 없음 | Low | 해결 | 계획 작성 시점의 차단 문제 없음 | 없음 | 없음 |
|
||||
| `GIFT-BUG-001` | High | 해결 | 수취인 정보가 없는 `INSPECTION_COMPLETED` 선물이 전달 완료될 수 있고, 관리자 상태 진행 후 배송지 미입력 기한 초과 대상에서 누락된다 | `P12-T1`, `P12-T2` | command/action 불변조건과 만료 조회 상태 범위를 테스트 우선으로 보강 완료 |
|
||||
| `TEST-INFRA-001` | Medium | 해결 | 테스트 timeout 후 embedded Redis native 프로세스와 Gradle worker가 남아 후속 테스트 실행을 방해했다 | `P12-T3` | 잔존 프로세스 정리, 중복 hook 제거, 공식 강제 종료 옵션 적용 후 전체 테스트 정상 종료 확인 |
|
||||
|
||||
## 최종 보고 형식
|
||||
|
||||
|
||||
@@ -846,7 +846,8 @@ Response는 저장된 받을 주소를 `name`, `address`, `phoneNumber`로 내
|
||||
| `RECEIVED` | `[]` |
|
||||
| `TRACKING_REGISTERED` | `["ARRIVE_MAILBOX", "MARK_UNDELIVERABLE"]` |
|
||||
| `ARRIVED_AT_MAILBOX` | `["COMPLETE_INSPECTION", "MARK_UNDELIVERABLE"]` |
|
||||
| `INSPECTION_COMPLETED` | `["COMPLETE_DELIVERY", "MARK_UNDELIVERABLE"]` |
|
||||
| `INSPECTION_COMPLETED`, 수취인 정보 입력 완료 | `["COMPLETE_DELIVERY", "MARK_UNDELIVERABLE"]` |
|
||||
| `INSPECTION_COMPLETED`, 수취인 정보 미입력/불완전 | `["MARK_UNDELIVERABLE"]` |
|
||||
| `DELIVERED` | `[]` |
|
||||
| `UNDELIVERABLE` | `[]` |
|
||||
| `CANCELED` | `[]` |
|
||||
@@ -858,7 +859,9 @@ Response는 저장된 받을 주소를 `name`, `address`, `phoneNumber`로 내
|
||||
| POST | `/api/v2/admin/gifts/{applicationNo}/arrive-mailbox` | `TRACKING_REGISTERED` | `ARRIVED_AT_MAILBOX` | 없음 |
|
||||
| POST | `/api/v2/admin/gifts/{applicationNo}/complete-inspection` | `ARRIVED_AT_MAILBOX` | `INSPECTION_COMPLETED` | 없음 |
|
||||
| POST | `/api/v2/admin/gifts/{applicationNo}/mark-undeliverable` | `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED` | `UNDELIVERABLE` | `reason` |
|
||||
| POST | `/api/v2/admin/gifts/{applicationNo}/complete-delivery` | `INSPECTION_COMPLETED` | `DELIVERED` | 없음 |
|
||||
| POST | `/api/v2/admin/gifts/{applicationNo}/complete-delivery` | `INSPECTION_COMPLETED` | `DELIVERED` | 저장된 수취인 이름, 휴대폰 번호, 우편번호, 주소 |
|
||||
|
||||
`complete-delivery`는 `recipientName`, `recipientPhoneNumber`, `recipientZipCode`, `recipientAddress`가 모두 null 또는 공백이 아닐 때만 성공한다. `recipientAddressDetail`은 선택 입력이다. 조건을 만족하지 않으면 상태, 전달 완료 시각, 푸시를 변경하지 않고 요청을 거부한다.
|
||||
|
||||
전달불가 Request:
|
||||
|
||||
@@ -875,9 +878,10 @@ Response는 저장된 받을 주소를 `name`, `address`, `phoneNumber`로 내
|
||||
| 운송장 등록 기한 24시간 전 안내 | `RECEIVED`, 마감 24시간 전 | 상태 변경 없음 | 팬에게 안내 |
|
||||
| 운송장 미등록 자동취소 | `RECEIVED`, 신청 후 3일 초과 | `CANCELED`, 전액 환불 | 팬에게 자동취소 안내 |
|
||||
| 배송지 입력 기한 24시간 전 안내 | `TRACKING_REGISTERED` 이상, 배송지 미입력, 마감 24시간 전 | 상태 변경 없음 | 크리에이터에게 안내 |
|
||||
| 배송지 미입력 전달불가 | 배송지 미입력, 운송장 등록 후 7일 초과 | `UNDELIVERABLE`, 사유 저장 | 팬에게 전달불가 안내. 크리에이터에게 추가 푸시 없음 |
|
||||
| 배송지 미입력 전달불가 | `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED` 중 하나이며 배송지 미입력, 운송장 등록 후 7일 초과 | `UNDELIVERABLE`, 사유 저장 | 팬에게 전달불가 안내. 크리에이터에게 추가 푸시 없음 |
|
||||
|
||||
스케줄러는 중복 실행되어도 같은 선물에 중복 환불 또는 중복 푸시가 발생하지 않아야 한다.
|
||||
배송지 입력 기한과 현재 시각이 같으면 아직 기한 초과가 아니며, `RECEIVED`, `DELIVERED`, `UNDELIVERABLE`, `CANCELED` 상태는 배송지 미입력 전달불가 대상에서 제외한다.
|
||||
|
||||
## 9. 푸시 알림 정책
|
||||
|
||||
|
||||
+8
-1
@@ -84,4 +84,11 @@ class GiftDelivery(
|
||||
|
||||
@Column(name = "undeliverable_reason", length = 255)
|
||||
var undeliverableReason: String? = null
|
||||
) : BaseEntity()
|
||||
) : BaseEntity() {
|
||||
fun hasCompleteRecipientInformation() = listOf(
|
||||
recipientName,
|
||||
recipientPhoneNumber,
|
||||
recipientZipCode,
|
||||
recipientAddress
|
||||
).none { it.isNullOrBlank() }
|
||||
}
|
||||
|
||||
+6
-2
@@ -80,7 +80,11 @@ interface GiftRepository : JpaRepository<Gift, Long> {
|
||||
"""
|
||||
select g
|
||||
from Gift g
|
||||
where g.status = kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.TRACKING_REGISTERED
|
||||
where g.status in (
|
||||
kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.TRACKING_REGISTERED,
|
||||
kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.ARRIVED_AT_MAILBOX,
|
||||
kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.INSPECTION_COMPLETED
|
||||
)
|
||||
and exists (
|
||||
select d.id
|
||||
from GiftDelivery d
|
||||
@@ -90,7 +94,7 @@ interface GiftRepository : JpaRepository<Gift, Long> {
|
||||
)
|
||||
"""
|
||||
)
|
||||
fun findTrackingRegisteredGiftsWithRecipientAddressDeadlineBefore(
|
||||
fun findGiftsWithRecipientAddressDeadlineBefore(
|
||||
@Param("now") now: LocalDateTime
|
||||
): List<Gift>
|
||||
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@ class GiftScheduler(
|
||||
|
||||
@Transactional
|
||||
fun markExpiredRecipientAddressGiftsUndeliverable(now: LocalDateTime): Int {
|
||||
val expiredGifts = giftRepository.findTrackingRegisteredGiftsWithRecipientAddressDeadlineBefore(now)
|
||||
val expiredGifts = giftRepository.findGiftsWithRecipientAddressDeadlineBefore(now)
|
||||
expiredGifts.forEach { gift ->
|
||||
gift.status = GiftStatus.UNDELIVERABLE
|
||||
val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)))
|
||||
|
||||
+8
-4
@@ -137,7 +137,7 @@ class GiftAdminQueryService(
|
||||
),
|
||||
status = gift.status,
|
||||
statusName = gift.status.displayName,
|
||||
availableActions = gift.status.availableActions()
|
||||
availableActions = gift.status.availableActions(delivery)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -153,13 +153,17 @@ class GiftAdminQueryService(
|
||||
trackingNumber = context.delivery(this).trackingNumber,
|
||||
status = status,
|
||||
statusName = status.displayName,
|
||||
availableActions = status.availableActions()
|
||||
availableActions = status.availableActions(context.delivery(this))
|
||||
)
|
||||
|
||||
private fun GiftStatus.availableActions() = when (this) {
|
||||
private fun GiftStatus.availableActions(delivery: GiftDelivery) = when (this) {
|
||||
GiftStatus.TRACKING_REGISTERED -> listOf("ARRIVE_MAILBOX", "MARK_UNDELIVERABLE")
|
||||
GiftStatus.ARRIVED_AT_MAILBOX -> listOf("COMPLETE_INSPECTION", "MARK_UNDELIVERABLE")
|
||||
GiftStatus.INSPECTION_COMPLETED -> listOf("COMPLETE_DELIVERY", "MARK_UNDELIVERABLE")
|
||||
GiftStatus.INSPECTION_COMPLETED -> if (delivery.hasCompleteRecipientInformation()) {
|
||||
listOf("COMPLETE_DELIVERY", "MARK_UNDELIVERABLE")
|
||||
} else {
|
||||
listOf("MARK_UNDELIVERABLE")
|
||||
}
|
||||
GiftStatus.RECEIVED, GiftStatus.DELIVERED, GiftStatus.UNDELIVERABLE, GiftStatus.CANCELED -> emptyList()
|
||||
}
|
||||
|
||||
|
||||
@@ -203,6 +203,7 @@ class GiftAdminService(
|
||||
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
|
||||
if (gift.status != GiftStatus.INSPECTION_COMPLETED) throw invalidRequest()
|
||||
val delivery = giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)) ?: throw invalidRequest()
|
||||
if (!delivery.hasCompleteRecipientInformation()) throw invalidRequest()
|
||||
|
||||
gift.status = GiftStatus.DELIVERED
|
||||
delivery.deliveredAt = now
|
||||
|
||||
@@ -18,9 +18,7 @@ class EmbeddedRedisInitializer : ApplicationContextInitializer<ConfigurableAppli
|
||||
}
|
||||
|
||||
private object EmbeddedRedisHolder {
|
||||
private var redisServer: RedisServer? = null
|
||||
private var port: Int? = null
|
||||
private var shutdownHookRegistered = false
|
||||
|
||||
@Synchronized
|
||||
fun start(): Int {
|
||||
@@ -29,8 +27,9 @@ private object EmbeddedRedisHolder {
|
||||
}
|
||||
|
||||
val selectedPort = PortProvider.newEphemeralPortProvider().get()
|
||||
redisServer = RedisServer.newRedisServer()
|
||||
RedisServer.newRedisServer()
|
||||
.port(selectedPort)
|
||||
.onShutdownForceStop(true)
|
||||
.setting("bind 127.0.0.1")
|
||||
.setting("daemonize no")
|
||||
.setting("appendonly no")
|
||||
@@ -38,22 +37,6 @@ private object EmbeddedRedisHolder {
|
||||
.also { it.start() }
|
||||
port = selectedPort
|
||||
|
||||
if (!shutdownHookRegistered) {
|
||||
Runtime.getRuntime().addShutdownHook(
|
||||
Thread {
|
||||
stop()
|
||||
}
|
||||
)
|
||||
shutdownHookRegistered = true
|
||||
}
|
||||
|
||||
return selectedPort
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stop() {
|
||||
redisServer?.stop()
|
||||
redisServer = null
|
||||
port = null
|
||||
}
|
||||
}
|
||||
|
||||
+16
-2
@@ -332,7 +332,7 @@ class AdminGiftControllerTest @Autowired constructor(
|
||||
@Test
|
||||
@DisplayName("관리자는 검수완료 선물을 전달완료 처리한다")
|
||||
fun shouldCompleteGiftDelivery() {
|
||||
saveGift(GiftStatus.INSPECTION_COMPLETED)
|
||||
saveGift(GiftStatus.INSPECTION_COMPLETED, withRecipientAddress = true)
|
||||
|
||||
mockMvc.perform(post("/api/v2/admin/gifts/$APPLICATION_NO/complete-delivery").with(user("admin").roles("ADMIN")))
|
||||
.andExpect(status().isOk)
|
||||
@@ -342,7 +342,17 @@ class AdminGiftControllerTest @Autowired constructor(
|
||||
.andExpect(jsonPath("$.data.occurredAt").value(endsWith("Z")))
|
||||
}
|
||||
|
||||
private fun saveGift(status: GiftStatus) {
|
||||
@Test
|
||||
@DisplayName("관리자는 수취인 정보가 없는 검수완료 선물을 전달완료 처리할 수 없다")
|
||||
fun shouldRejectGiftDeliveryWithoutRecipientAddress() {
|
||||
saveGift(GiftStatus.INSPECTION_COMPLETED)
|
||||
|
||||
mockMvc.perform(post("/api/v2/admin/gifts/$APPLICATION_NO/complete-delivery").with(user("admin").roles("ADMIN")))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
}
|
||||
|
||||
private fun saveGift(status: GiftStatus, withRecipientAddress: Boolean = false) {
|
||||
val gift = giftRepository.save(
|
||||
Gift(
|
||||
applicationNo = APPLICATION_NO,
|
||||
@@ -368,6 +378,10 @@ class AdminGiftControllerTest @Autowired constructor(
|
||||
senderPhoneNumber = "01012345678",
|
||||
senderZipCode = "06234",
|
||||
senderAddress = "서울시 강남구",
|
||||
recipientName = "김소다".takeIf { withRecipientAddress },
|
||||
recipientPhoneNumber = "01098765432".takeIf { withRecipientAddress },
|
||||
recipientZipCode = "04524".takeIf { withRecipientAddress },
|
||||
recipientAddress = "서울시 중구".takeIf { withRecipientAddress },
|
||||
trackingDeadlineAt = NOW.minusDays(1),
|
||||
trackingRegisteredAt = NOW.minusHours(1)
|
||||
)
|
||||
|
||||
+66
-2
@@ -117,6 +117,29 @@ class GiftSchedulerTest @Autowired constructor(
|
||||
assertEquals("배송지 미입력 기한 초과", delivery.undeliverableReason)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("배송지 입력 기한이 지난 진행 중 선물은 현재 상태와 무관하게 전달 불가 처리한다")
|
||||
fun shouldMarkOverdueInProgressGiftsWithoutRecipientAddressUndeliverable() {
|
||||
val now = LocalDateTime.of(2026, 10, 9, 12, 0)
|
||||
val gifts = listOf(GiftStatus.ARRIVED_AT_MAILBOX, GiftStatus.INSPECTION_COMPLETED).mapIndexed { index, status ->
|
||||
saveGift(
|
||||
status = status,
|
||||
trackingDeadlineAt = now.minusDays(7),
|
||||
recipientAddressDeadlineAt = now.minusSeconds(1),
|
||||
applicationNo = "$APPLICATION_NO-$index"
|
||||
)
|
||||
}
|
||||
|
||||
val updatedCount = scheduler.markExpiredRecipientAddressGiftsUndeliverable(now)
|
||||
|
||||
assertEquals(2, updatedCount)
|
||||
gifts.forEach { (gift, delivery) ->
|
||||
assertEquals(GiftStatus.UNDELIVERABLE, gift.status)
|
||||
assertEquals(now, delivery.undeliverableAt)
|
||||
assertEquals("배송지 미입력 기한 초과", delivery.undeliverableReason)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("배송지 입력 기한과 같은 발송 확인 선물은 전달 불가 처리하지 않는다")
|
||||
fun shouldIgnoreTrackingRegisteredGiftWhoseRecipientAddressDeadlineIsNotPast() {
|
||||
@@ -154,6 +177,46 @@ class GiftSchedulerTest @Autowired constructor(
|
||||
assertEquals(null, delivery.undeliverableReason)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("미래 또는 미설정 기한과 대상 외 상태는 배송지 미입력 전달 불가 처리하지 않는다")
|
||||
fun shouldIgnoreUnexpiredAndOutOfScopeGiftsWithoutRecipientAddress() {
|
||||
val now = LocalDateTime.of(2026, 10, 9, 12, 0)
|
||||
val unexpiredGifts = listOf(
|
||||
saveGift(
|
||||
GiftStatus.ARRIVED_AT_MAILBOX,
|
||||
now.minusDays(7),
|
||||
now.plusSeconds(1),
|
||||
applicationNo = "A-1002609302001"
|
||||
),
|
||||
saveGift(
|
||||
GiftStatus.INSPECTION_COMPLETED,
|
||||
now.minusDays(7),
|
||||
applicationNo = "A-1002609302002"
|
||||
)
|
||||
)
|
||||
val terminalGifts = listOf(
|
||||
GiftStatus.RECEIVED,
|
||||
GiftStatus.DELIVERED,
|
||||
GiftStatus.UNDELIVERABLE,
|
||||
GiftStatus.CANCELED
|
||||
).mapIndexed { index, status ->
|
||||
saveGift(
|
||||
status,
|
||||
now.minusDays(7),
|
||||
now.minusSeconds(1),
|
||||
applicationNo = "A-100260930201$index"
|
||||
)
|
||||
}
|
||||
|
||||
val updatedCount = scheduler.markExpiredRecipientAddressGiftsUndeliverable(now)
|
||||
|
||||
assertEquals(0, updatedCount)
|
||||
(unexpiredGifts + terminalGifts).forEach { (_, delivery) ->
|
||||
assertEquals(null, delivery.undeliverableAt)
|
||||
assertEquals(null, delivery.undeliverableReason)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("배송지 미입력 전달 불가를 다시 실행해도 이미 전달 불가인 선물은 처리하지 않는다")
|
||||
fun shouldNotUpdateAlreadyUndeliverableGiftOnSecondRun() {
|
||||
@@ -240,11 +303,12 @@ class GiftSchedulerTest @Autowired constructor(
|
||||
status: GiftStatus,
|
||||
trackingDeadlineAt: LocalDateTime,
|
||||
recipientAddressDeadlineAt: LocalDateTime? = null,
|
||||
recipientAddress: String? = null
|
||||
recipientAddress: String? = null,
|
||||
applicationNo: String = APPLICATION_NO
|
||||
): Pair<Gift, GiftDelivery> {
|
||||
val gift = giftRepository.save(
|
||||
Gift(
|
||||
applicationNo = APPLICATION_NO,
|
||||
applicationNo = applicationNo,
|
||||
senderMemberId = SENDER_MEMBER_ID,
|
||||
recipientMemberId = RECIPIENT_MEMBER_ID,
|
||||
status = status,
|
||||
|
||||
+32
-1
@@ -146,9 +146,13 @@ class GiftAdminQueryServiceTest @Autowired constructor(
|
||||
service.getGiftDetail("A-100260930102").availableActions
|
||||
)
|
||||
assertEquals(
|
||||
listOf("COMPLETE_DELIVERY", "MARK_UNDELIVERABLE"),
|
||||
listOf("MARK_UNDELIVERABLE"),
|
||||
service.getGiftDetail("A-100260930103").availableActions
|
||||
)
|
||||
assertEquals(
|
||||
listOf("MARK_UNDELIVERABLE"),
|
||||
service.getGifts(GiftStatus.INSPECTION_COMPLETED, null, null, 0, 20).items.single().availableActions
|
||||
)
|
||||
assertEquals(emptyList<String>(), service.getGiftDetail("A-100260930104").availableActions)
|
||||
assertEquals(emptyList<String>(), service.getGiftDetail("A-100260930105").availableActions)
|
||||
assertEquals(emptyList<String>(), service.getGiftDetail("A-100260930106").availableActions)
|
||||
@@ -158,6 +162,33 @@ class GiftAdminQueryServiceTest @Autowired constructor(
|
||||
assertEquals("common.error.invalid_request", exception.messageKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("수취인 정보가 입력된 검수 완료 선물만 전달 완료 액션을 반환한다")
|
||||
fun shouldReturnCompleteDeliveryActionOnlyWithRecipientAddress() {
|
||||
val sender = saveMember("fan-alpha")
|
||||
val recipient = saveMember("creator-alpha")
|
||||
val category = saveCategory()
|
||||
val gift = saveGift(
|
||||
applicationNo = "A-1002609300001",
|
||||
sender = sender,
|
||||
recipient = recipient,
|
||||
category = category,
|
||||
status = GiftStatus.INSPECTION_COMPLETED
|
||||
)
|
||||
requireNotNull(deliveryRepository.findByGiftId(requireNotNull(gift.id))).apply {
|
||||
recipientName = "김소다"
|
||||
recipientPhoneNumber = "01098765432"
|
||||
recipientZipCode = "04524"
|
||||
recipientAddress = "서울시 중구"
|
||||
}
|
||||
|
||||
val detail = service().getGiftDetail(gift.applicationNo)
|
||||
val listItem = service().getGifts(GiftStatus.INSPECTION_COMPLETED, null, null, 0, 20).items.single()
|
||||
|
||||
assertEquals(listOf("COMPLETE_DELIVERY", "MARK_UNDELIVERABLE"), detail.availableActions)
|
||||
assertEquals(listOf("COMPLETE_DELIVERY", "MARK_UNDELIVERABLE"), listItem.availableActions)
|
||||
}
|
||||
|
||||
private fun service() = GiftAdminQueryService(
|
||||
giftRepository = giftRepository,
|
||||
deliveryRepository = deliveryRepository,
|
||||
|
||||
+49
-1
@@ -16,6 +16,8 @@ import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import org.junit.jupiter.params.provider.ValueSource
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||
@@ -248,6 +250,7 @@ class GiftAdminServiceTest @Autowired constructor(
|
||||
@DisplayName("운영자는 검수 완료 선물을 전달 완료 처리한다")
|
||||
fun shouldCompleteDeliveryForInspectionCompletedGift() {
|
||||
val (gift, delivery) = saveGift(GiftStatus.INSPECTION_COMPLETED)
|
||||
populateRecipientInformation(delivery)
|
||||
|
||||
val result = service.completeDelivery(APPLICATION_NO, NOW)
|
||||
|
||||
@@ -262,7 +265,8 @@ class GiftAdminServiceTest @Autowired constructor(
|
||||
@Test
|
||||
@DisplayName("검수 완료 상태가 아니면 운영 전달 완료 처리할 수 없다")
|
||||
fun shouldRejectDeliveryCompletionWhenGiftIsNotInspectionCompleted() {
|
||||
saveGift(GiftStatus.ARRIVED_AT_MAILBOX)
|
||||
val (_, delivery) = saveGift(GiftStatus.ARRIVED_AT_MAILBOX)
|
||||
populateRecipientInformation(delivery)
|
||||
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
service.completeDelivery(APPLICATION_NO, NOW)
|
||||
@@ -271,6 +275,50 @@ class GiftAdminServiceTest @Autowired constructor(
|
||||
assertEquals("common.error.invalid_request", exception.messageKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("수취인 정보가 없으면 운영 전달 완료 처리할 수 없다")
|
||||
fun shouldRejectDeliveryCompletionWithoutRecipientAddress() {
|
||||
val (gift, delivery) = saveGift(GiftStatus.INSPECTION_COMPLETED)
|
||||
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
service.completeDelivery(APPLICATION_NO, NOW)
|
||||
}
|
||||
|
||||
assertEquals("common.error.invalid_request", exception.messageKey)
|
||||
assertEquals(GiftStatus.INSPECTION_COMPLETED, gift.status)
|
||||
assertEquals(null, delivery.deliveredAt)
|
||||
Mockito.verifyNoInteractions(giftPushService)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = ["name", "phoneNumber", "zipCode", "address"])
|
||||
@DisplayName("수취인 필수 정보에 공백이 있으면 운영 전달 완료 처리할 수 없다")
|
||||
fun shouldRejectDeliveryCompletionWithBlankRecipientInformation(blankField: String) {
|
||||
val (gift, delivery) = saveGift(GiftStatus.INSPECTION_COMPLETED)
|
||||
populateRecipientInformation(delivery)
|
||||
when (blankField) {
|
||||
"name" -> delivery.recipientName = " "
|
||||
"phoneNumber" -> delivery.recipientPhoneNumber = " "
|
||||
"zipCode" -> delivery.recipientZipCode = " "
|
||||
"address" -> delivery.recipientAddress = " "
|
||||
}
|
||||
|
||||
assertThrows(SodaException::class.java) {
|
||||
service.completeDelivery(APPLICATION_NO, NOW)
|
||||
}
|
||||
|
||||
assertEquals(GiftStatus.INSPECTION_COMPLETED, gift.status)
|
||||
assertEquals(null, delivery.deliveredAt)
|
||||
Mockito.verifyNoInteractions(giftPushService)
|
||||
}
|
||||
|
||||
private fun populateRecipientInformation(delivery: GiftDelivery) {
|
||||
delivery.recipientName = "김소다"
|
||||
delivery.recipientPhoneNumber = "01098765432"
|
||||
delivery.recipientZipCode = "04524"
|
||||
delivery.recipientAddress = "서울시 중구"
|
||||
}
|
||||
|
||||
private fun saveGift(status: GiftStatus): Pair<Gift, GiftDelivery> {
|
||||
val gift = giftRepository.save(
|
||||
Gift(
|
||||
|
||||
Reference in New Issue
Block a user