Compare commits
17
Commits
a95c91d31f
...
test
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95d7568a25 | ||
|
|
5a09c85135 | ||
|
|
124a615a85 | ||
|
|
112ad755e5 | ||
|
|
0bbb9e4fc7 | ||
|
|
c6b5f9c867 | ||
|
|
978206438e | ||
|
|
31406e7f0a | ||
|
|
77a66ee02c | ||
|
|
3fb822a096 | ||
|
|
322b07d794 | ||
|
|
8a95dc171d | ||
|
|
33b6662cb4 | ||
|
|
f66c89fb7f | ||
|
|
c073c48538 | ||
|
|
5494a49748 | ||
|
|
72e5612b1c |
@@ -0,0 +1,549 @@
|
||||
# 선물 관리자 페이지 구현 프롬프트
|
||||
|
||||
이 문서는 관리자 페이지의 선물 관련 4개 화면을 프론트엔드 구현 에이전트에게 전달하기 위한 프롬프트 모음이다.
|
||||
|
||||
공통 전제:
|
||||
|
||||
- 관리자 메뉴에는 parent 메뉴 `선물함 관리` 아래에 다음 4개 하위 메뉴가 있다.
|
||||
- `선물함 리스트` → `/gift/list`
|
||||
- `받을 주소` → `/gift/mailbox`
|
||||
- `선물 카테고리` → `/gift/category`
|
||||
- `선물 사이즈` → `/gift/size`
|
||||
- 모든 API 응답은 기존 공통 envelope를 사용한다. 화면에서는 `response.data`를 실제 payload로 사용한다.
|
||||
- 인증/권한 처리는 기존 관리자 페이지의 API 클라이언트, 토큰 저장 방식, 에러 처리 방식을 따른다.
|
||||
- 새 디자인 시스템을 만들지 말고 기존 관리자 페이지의 테이블, 필터, 버튼, 모달, 폼, 토스트, 확인창 패턴을 재사용한다.
|
||||
- 화면 문구는 한국어로 작성한다.
|
||||
- API 실패 시 기존 관리자 페이지의 공통 에러 표시 방식을 따른다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 선물함 리스트 페이지 프롬프트
|
||||
|
||||
```text
|
||||
관리자 선물함 리스트 페이지를 구현해줘.
|
||||
|
||||
목표:
|
||||
- 관리자 메뉴 `선물함 관리 > 선물함 리스트`에서 전체 선물 신청 목록을 조회한다.
|
||||
- 상태, 신청번호, 닉네임으로 필터링할 수 있다.
|
||||
- 목록 row에서 선물 상세를 확인하고, 현재 상태에서 가능한 운영 액션을 실행할 수 있다.
|
||||
- 기존 관리자 페이지의 테이블/필터/페이지네이션/모달/확인창 스타일을 그대로 따른다.
|
||||
|
||||
라우트:
|
||||
- `/gift/list`
|
||||
|
||||
필수 화면 구성:
|
||||
- 상단 제목: `선물함 리스트`
|
||||
- 필터 영역:
|
||||
- 상태 select: 전체, 접수 완료, 발송 확인, 사서함 도착, 검수 완료, 전달 완료, 전달 불가, 신청 취소
|
||||
- 신청번호 검색 input: `applicationNo` 부분 검색
|
||||
- 닉네임 검색 input: 발송인 또는 수취인 닉네임 부분 검색
|
||||
- 검색 버튼, 초기화 버튼
|
||||
- 목록 테이블 컬럼:
|
||||
- 신청번호 `applicationNo`
|
||||
- 발송인 닉네임 `senderNickname`
|
||||
- 수취인 닉네임 `recipientNickname`
|
||||
- 사이즈 `sizeName` / `sizeCode`
|
||||
- 카테고리 `categoryName`
|
||||
- 분류번호 `classificationNumber`
|
||||
- 택배사 `courierCompanyName`
|
||||
- 운송장번호 `trackingNumber`
|
||||
- 상태 `statusName`
|
||||
- 액션 버튼
|
||||
- 페이지네이션:
|
||||
- 서버의 `page`, `size`, `totalCount`, `hasNext` 기준으로 기존 관리자 페이지 패턴에 맞춘다.
|
||||
|
||||
목록 조회 API:
|
||||
- Method: `GET`
|
||||
- URL: `/api/v2/admin/gifts`
|
||||
- Query:
|
||||
- `status?: GiftStatus`
|
||||
- `applicationNo?: string`
|
||||
- `nickname?: string`
|
||||
- `page?: number` 기본 `0`
|
||||
- `size?: number` 기본 `20`
|
||||
- 필터 조합:
|
||||
- 선택 필터는 AND 조건이다.
|
||||
- `nickname`은 발송인/수취인 닉네임 중 하나에 포함되면 매칭된다.
|
||||
|
||||
목록 조회 response `data`:
|
||||
```ts
|
||||
type AdminGiftListResponse = {
|
||||
totalCount: number;
|
||||
items: AdminGiftListItemResponse[];
|
||||
page: number;
|
||||
size: number;
|
||||
hasNext: boolean;
|
||||
};
|
||||
|
||||
type AdminGiftListItemResponse = {
|
||||
applicationNo: string;
|
||||
senderNickname: string;
|
||||
recipientNickname: string;
|
||||
sizeCode: string;
|
||||
sizeName: string;
|
||||
categoryName: string;
|
||||
classificationNumber: string;
|
||||
courierCompanyName: string | null;
|
||||
trackingNumber: string | null;
|
||||
status: GiftStatus;
|
||||
statusName: string;
|
||||
availableActions: AdminGiftAction[];
|
||||
};
|
||||
```
|
||||
|
||||
상태 enum:
|
||||
```ts
|
||||
type GiftStatus =
|
||||
| "RECEIVED"
|
||||
| "TRACKING_REGISTERED"
|
||||
| "ARRIVED_AT_MAILBOX"
|
||||
| "INSPECTION_COMPLETED"
|
||||
| "DELIVERED"
|
||||
| "UNDELIVERABLE"
|
||||
| "CANCELED";
|
||||
```
|
||||
|
||||
상태 표시명:
|
||||
- `RECEIVED`: `접수 완료`
|
||||
- `TRACKING_REGISTERED`: `발송 확인`
|
||||
- `ARRIVED_AT_MAILBOX`: `사서함 도착`
|
||||
- `INSPECTION_COMPLETED`: `검수 완료`
|
||||
- `DELIVERED`: `전달 완료`
|
||||
- `UNDELIVERABLE`: `전달 불가`
|
||||
- `CANCELED`: `신청 취소`
|
||||
|
||||
상세 조회 API:
|
||||
- Method: `GET`
|
||||
- URL: `/api/v2/admin/gifts/{applicationNo}`
|
||||
- 용도:
|
||||
- 목록 row 클릭 또는 `상세` 버튼 클릭 시 상세 모달/상세 패널을 연다.
|
||||
|
||||
상세 response `data`:
|
||||
```ts
|
||||
type AdminGiftDetailResponse = {
|
||||
applicationNo: string;
|
||||
senderInfo: AdminGiftMemberInfoResponse;
|
||||
recipientInfo: AdminGiftMemberInfoResponse;
|
||||
productInfo: AdminGiftProductInfoResponse;
|
||||
inboundDeliveryInfo: AdminGiftInboundDeliveryInfoResponse;
|
||||
status: GiftStatus;
|
||||
statusName: string;
|
||||
availableActions: AdminGiftAction[];
|
||||
};
|
||||
|
||||
type AdminGiftMemberInfoResponse = {
|
||||
nickname: string;
|
||||
name: string;
|
||||
phoneNumber: string;
|
||||
address: string;
|
||||
};
|
||||
|
||||
type AdminGiftProductInfoResponse = {
|
||||
sizeCode: string;
|
||||
sizeName: string;
|
||||
categoryName: string;
|
||||
classificationNumber: string;
|
||||
};
|
||||
|
||||
type AdminGiftInboundDeliveryInfoResponse = {
|
||||
courierCompanyName: string | null;
|
||||
trackingNumber: string | null;
|
||||
};
|
||||
```
|
||||
|
||||
상세 표시 요구사항:
|
||||
- 발송인 정보: 닉네임, 이름, 전화번호, 주소
|
||||
- 수취인 정보: 닉네임, 이름, 전화번호, 주소
|
||||
- 상품 정보: 사이즈, 카테고리, 분류번호
|
||||
- 입고 배송 정보: 택배사, 운송장번호
|
||||
- 현재 상태와 가능한 액션
|
||||
- 수취인이 배송지를 아직 입력하지 않은 경우 `recipientInfo.nickname`은 표시하고 `name`, `phoneNumber`, `address`는 빈 문자열로 온다. 화면에서는 `미입력`으로 표시해도 된다.
|
||||
|
||||
운영 액션 enum:
|
||||
```ts
|
||||
type AdminGiftAction =
|
||||
| "ARRIVE_MAILBOX"
|
||||
| "COMPLETE_INSPECTION"
|
||||
| "COMPLETE_DELIVERY"
|
||||
| "MARK_UNDELIVERABLE";
|
||||
```
|
||||
|
||||
상태별 `availableActions`:
|
||||
- `RECEIVED`: 없음
|
||||
- `TRACKING_REGISTERED`: `ARRIVE_MAILBOX`, `MARK_UNDELIVERABLE`
|
||||
- `ARRIVED_AT_MAILBOX`: `COMPLETE_INSPECTION`, `MARK_UNDELIVERABLE`
|
||||
- `INSPECTION_COMPLETED`: `COMPLETE_DELIVERY`, `MARK_UNDELIVERABLE`
|
||||
- `DELIVERED`: 없음
|
||||
- `UNDELIVERABLE`: 없음
|
||||
- `CANCELED`: 없음
|
||||
|
||||
액션 버튼 문구:
|
||||
- `ARRIVE_MAILBOX`: `사서함 도착 처리`
|
||||
- `COMPLETE_INSPECTION`: `검수 완료 처리`
|
||||
- `COMPLETE_DELIVERY`: `전달 완료 처리`
|
||||
- `MARK_UNDELIVERABLE`: `전달 불가 처리`
|
||||
|
||||
액션 API:
|
||||
- 사서함 도착 처리
|
||||
- Method: `POST`
|
||||
- URL: `/api/v2/admin/gifts/{applicationNo}/arrive-mailbox`
|
||||
- Body: 없음
|
||||
- 검수 완료 처리
|
||||
- Method: `POST`
|
||||
- URL: `/api/v2/admin/gifts/{applicationNo}/complete-inspection`
|
||||
- Body: 없음
|
||||
- 전달 완료 처리
|
||||
- Method: `POST`
|
||||
- URL: `/api/v2/admin/gifts/{applicationNo}/complete-delivery`
|
||||
- Body: 없음
|
||||
- 전달 불가 처리
|
||||
- Method: `POST`
|
||||
- URL: `/api/v2/admin/gifts/{applicationNo}/mark-undeliverable`
|
||||
- Body:
|
||||
```ts
|
||||
type AdminGiftMarkUndeliverableRequest = {
|
||||
reason: string;
|
||||
};
|
||||
```
|
||||
|
||||
액션 response `data`:
|
||||
```ts
|
||||
type AdminGiftOperationStatusResponse = {
|
||||
applicationNo: string;
|
||||
status: GiftStatus;
|
||||
statusName: string;
|
||||
occurredAt: string; // UTC ISO string, e.g. 2026-09-30T12:00:00Z
|
||||
};
|
||||
```
|
||||
|
||||
액션 UX:
|
||||
- 모든 상태 변경 액션은 확인창을 띄운다.
|
||||
- `MARK_UNDELIVERABLE`은 사유 입력 모달을 띄운다.
|
||||
- 전달 불가 사유는 공백만 입력할 수 없고 255자 이내로 제한한다.
|
||||
- 액션 성공 후:
|
||||
- 상세 모달이 열려 있으면 상세를 다시 조회한다.
|
||||
- 목록도 현재 필터/페이지 기준으로 다시 조회한다.
|
||||
- 성공 토스트를 표시한다.
|
||||
- 액션 실패 시 기존 관리자 페이지 에러 토스트/알림 패턴을 따른다.
|
||||
|
||||
빈 값 표시:
|
||||
- `courierCompanyName`, `trackingNumber`가 null이면 `-`로 표시한다.
|
||||
- 수취인 개인정보가 빈 문자열이면 `미입력`으로 표시한다.
|
||||
|
||||
테스트/검증:
|
||||
- API 클라이언트 함수 단위 테스트 또는 페이지 테스트에서 query parameter가 올바르게 전달되는지 확인한다.
|
||||
- `availableActions`에 따라 버튼 노출이 달라지는지 확인한다.
|
||||
- 전달 불가 사유 빈 값 validation을 확인한다.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 받을 주소 설정 페이지 프롬프트
|
||||
|
||||
```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
|
||||
관리자 선물 카테고리 CRUD 페이지를 구현해줘.
|
||||
|
||||
목표:
|
||||
- 관리자 메뉴 `선물함 관리 > 선물 카테고리`에서 선물 카테고리를 조회, 등록, 수정, 비활성화한다.
|
||||
- 기존 관리자 페이지의 CRUD 테이블, 등록/수정 모달, 삭제 확인창, 토스트 패턴을 그대로 따른다.
|
||||
|
||||
라우트:
|
||||
- `/gift/category`
|
||||
|
||||
필수 화면 구성:
|
||||
- 상단 제목: `선물 카테고리`
|
||||
- 상단 우측 `카테고리 등록` 버튼
|
||||
- 목록 테이블 컬럼:
|
||||
- 카테고리 ID `categoryId`
|
||||
- 분류번호 `classificationNumber`
|
||||
- 카테고리 코드 `categoryCode`
|
||||
- 카테고리명 `name`
|
||||
- 접수 코드 `receiptCode`
|
||||
- 대표 품목 `representativeItem`
|
||||
- 파손면책 동의 필요 여부 `requiresDamageWaiver`
|
||||
- 활성 여부 `isActive`
|
||||
- 관리 버튼: 수정, 비활성화
|
||||
- 등록/수정 폼 필드:
|
||||
- `classificationNumber`
|
||||
- `categoryCode`
|
||||
- `name`
|
||||
- `receiptCode`
|
||||
- `representativeItem`
|
||||
- `requiresDamageWaiver`
|
||||
- `isActive`
|
||||
|
||||
목록 조회 API:
|
||||
- Method: `GET`
|
||||
- URL: `/api/v2/admin/gift-categories`
|
||||
- Request: 없음
|
||||
- Response `data`:
|
||||
```ts
|
||||
type AdminGiftCategoryResponse = {
|
||||
categoryId: number;
|
||||
classificationNumber: string;
|
||||
categoryCode: string;
|
||||
name: string;
|
||||
receiptCode: string;
|
||||
representativeItem: string;
|
||||
requiresDamageWaiver: boolean;
|
||||
isActive: boolean;
|
||||
};
|
||||
```
|
||||
|
||||
등록 API:
|
||||
- Method: `POST`
|
||||
- URL: `/api/v2/admin/gift-categories`
|
||||
- Body:
|
||||
```ts
|
||||
type AdminGiftCategoryRequest = {
|
||||
classificationNumber: string;
|
||||
categoryCode: string;
|
||||
name: string;
|
||||
receiptCode: string;
|
||||
representativeItem: string;
|
||||
requiresDamageWaiver: boolean;
|
||||
isActive: boolean;
|
||||
};
|
||||
```
|
||||
- Response `data`: `AdminGiftCategoryResponse`
|
||||
|
||||
수정 API:
|
||||
- Method: `PUT`
|
||||
- URL: `/api/v2/admin/gift-categories/{categoryId}`
|
||||
- Body: `AdminGiftCategoryRequest`
|
||||
- Response `data`: `AdminGiftCategoryResponse`
|
||||
|
||||
비활성화 API:
|
||||
- Method: `DELETE`
|
||||
- URL: `/api/v2/admin/gift-categories/{categoryId}`
|
||||
- Body: 없음
|
||||
- Response `data`: `AdminGiftCategoryResponse`
|
||||
- 서버는 실제 삭제가 아니라 `isActive=false` 논리 삭제로 처리한다.
|
||||
|
||||
폼 validation:
|
||||
- `classificationNumber`: 필수, 숫자 3자리. 예: `100`
|
||||
- `categoryCode`: 필수, 50자 이하. 예: `DOLL`
|
||||
- `name`: 필수, 50자 이하. 예: `인형`
|
||||
- `receiptCode`: 필수, 영문 대문자 1~9자. 예: `A`
|
||||
- `representativeItem`: 필수, 100자 이하. 예: `피규어`
|
||||
- `requiresDamageWaiver`: boolean
|
||||
- `isActive`: boolean
|
||||
|
||||
UX 요구사항:
|
||||
- 등록 성공 후 목록을 다시 조회하고 모달을 닫는다.
|
||||
- 수정 성공 후 목록을 다시 조회하고 모달을 닫는다.
|
||||
- 비활성화 버튼은 확인창을 띄운 뒤 호출한다.
|
||||
- 이미 비활성화된 row는 비활성화 버튼을 disabled 처리한다.
|
||||
- boolean 값은 기존 관리자 페이지 표현에 맞춰 `예/아니오`, `활성/비활성` 또는 badge로 표시한다.
|
||||
- `categoryCode`는 수정 폼에 표시하되 서버 쪽 엔티티는 코드 변경을 실제로 반영하지 않을 수 있으므로, 가능하면 수정 시 읽기 전용으로 두고 나머지 운영 필드만 수정하도록 UX를 구성한다.
|
||||
|
||||
테스트/검증:
|
||||
- 목록 조회가 `GET /api/v2/admin/gift-categories`를 호출하는지 확인한다.
|
||||
- 등록/수정 body가 `AdminGiftCategoryRequest` 형태와 일치하는지 확인한다.
|
||||
- validation 실패 시 API를 호출하지 않고 폼 에러를 표시한다.
|
||||
- 비활성화 성공 후 해당 row의 `isActive=false`가 반영되는지 확인한다.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 선물 사이즈 가격 CRUD 페이지 프롬프트
|
||||
|
||||
```text
|
||||
관리자 선물 사이즈 가격 CRUD 페이지를 구현해줘.
|
||||
|
||||
목표:
|
||||
- 관리자 메뉴 `선물함 관리 > 선물 사이즈`에서 선물 사이즈별 기본가/판매가/활성 여부를 조회하고 수정한다.
|
||||
- 현재 API는 사이즈 가격의 목록 조회와 수정만 제공한다. 신규 생성과 물리 삭제 기능은 만들지 않는다.
|
||||
- 기존 관리자 페이지의 테이블, 수정 모달, 저장 확인/토스트 패턴을 그대로 따른다.
|
||||
|
||||
라우트:
|
||||
- `/gift/size`
|
||||
|
||||
필수 화면 구성:
|
||||
- 상단 제목: `선물 사이즈`
|
||||
- 목록 테이블 컬럼:
|
||||
- 사이즈 코드 `sizeCode`
|
||||
- 사이즈명 `name`
|
||||
- 기본가 `basePriceCan`
|
||||
- 판매가 `salePriceCan`
|
||||
- 활성 여부 `isActive`
|
||||
- 관리 버튼: 수정
|
||||
- 수정 폼 필드:
|
||||
- `sizeCode`: 읽기 전용
|
||||
- `name`: 읽기 전용
|
||||
- `basePriceCan`: number input
|
||||
- `salePriceCan`: number input
|
||||
- `isActive`: checkbox/switch
|
||||
|
||||
목록 조회 API:
|
||||
- Method: `GET`
|
||||
- URL: `/api/v2/admin/gift-size-prices`
|
||||
- Request: 없음
|
||||
- Response `data`:
|
||||
```ts
|
||||
type AdminGiftSizePriceResponse = {
|
||||
sizeCode: GiftSize;
|
||||
name: string;
|
||||
basePriceCan: number;
|
||||
salePriceCan: number;
|
||||
isActive: boolean;
|
||||
};
|
||||
```
|
||||
|
||||
수정 API:
|
||||
- Method: `PUT`
|
||||
- URL: `/api/v2/admin/gift-size-prices/{sizeCode}`
|
||||
- Body:
|
||||
```ts
|
||||
type AdminGiftSizePriceRequest = {
|
||||
basePriceCan: number;
|
||||
salePriceCan: number;
|
||||
isActive: boolean;
|
||||
};
|
||||
```
|
||||
- Response `data`: `AdminGiftSizePriceResponse`
|
||||
|
||||
사이즈 enum:
|
||||
```ts
|
||||
type GiftSize = "SMALL" | "MEDIUM" | "LARGE";
|
||||
```
|
||||
|
||||
서버 validation:
|
||||
- `salePriceCan`은 0보다 커야 한다.
|
||||
- `salePriceCan`은 `basePriceCan`보다 클 수 없다.
|
||||
|
||||
프론트 validation:
|
||||
- `basePriceCan`: 필수, 1 이상 정수
|
||||
- `salePriceCan`: 필수, 1 이상 정수
|
||||
- `salePriceCan <= basePriceCan`
|
||||
- validation 실패 시 API 호출을 막고 필드 에러를 표시한다.
|
||||
|
||||
UX 요구사항:
|
||||
- 가격은 숫자 입력이지만 테이블 표시에서는 기존 관리자 페이지의 숫자 포맷을 따른다.
|
||||
- 수정 버튼 클릭 시 현재 row 값을 폼 초기값으로 넣는다.
|
||||
- 저장 전 확인창은 기존 관리자 페이지 패턴이 있으면 따른다.
|
||||
- 저장 성공 후 목록을 다시 조회하고 모달을 닫는다.
|
||||
- 신규 생성/삭제 버튼은 만들지 않는다.
|
||||
|
||||
테스트/검증:
|
||||
- 목록 조회가 `GET /api/v2/admin/gift-size-prices`를 호출하는지 확인한다.
|
||||
- 수정 저장 시 `PUT /api/v2/admin/gift-size-prices/{sizeCode}`와 request body가 정확한지 확인한다.
|
||||
- `salePriceCan > basePriceCan`일 때 API를 호출하지 않고 validation 메시지를 표시하는지 확인한다.
|
||||
- 저장 성공 후 목록 refresh가 일어나는지 확인한다.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 구현 시 참고할 공통 타입
|
||||
|
||||
```ts
|
||||
type ApiResponse<T> = {
|
||||
success: boolean;
|
||||
data: T;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type GiftStatus =
|
||||
| "RECEIVED"
|
||||
| "TRACKING_REGISTERED"
|
||||
| "ARRIVED_AT_MAILBOX"
|
||||
| "INSPECTION_COMPLETED"
|
||||
| "DELIVERED"
|
||||
| "UNDELIVERABLE"
|
||||
| "CANCELED";
|
||||
|
||||
type AdminGiftAction =
|
||||
| "ARRIVE_MAILBOX"
|
||||
| "COMPLETE_INSPECTION"
|
||||
| "COMPLETE_DELIVERY"
|
||||
| "MARK_UNDELIVERABLE";
|
||||
|
||||
type GiftSize = "SMALL" | "MEDIUM" | "LARGE";
|
||||
```
|
||||
|
||||
## 구현 순서 추천
|
||||
|
||||
1. API client 함수와 TypeScript 타입을 먼저 추가한다.
|
||||
2. `/gift/list` 선물함 리스트와 상세/상태변경 모달을 구현한다.
|
||||
3. `/gift/mailbox` 받을 주소 설정 화면을 구현한다.
|
||||
4. `/gift/category` 카테고리 CRUD를 구현한다.
|
||||
5. `/gift/size` 사이즈 가격 수정 화면을 구현한다.
|
||||
6. 기존 관리자 메뉴의 `선물함 관리` 하위 route와 연결되는지 확인한다.
|
||||
7. 각 페이지별 loading/empty/error/success 상태를 확인한다.
|
||||
@@ -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,274 @@
|
||||
# 선물함 리스트 페이지 생성 프롬프트
|
||||
|
||||
이 문서는 Android/iOS 앱의 `선물함` 리스트 페이지를 생성할 때 사용하는 프롬프트다.
|
||||
|
||||
참고 Figma:
|
||||
|
||||
- 팬 모드, 신청한 내역이 있을 때: `2481:19382`
|
||||
- 팬 모드, 신청한 내역이 없을 때: `2520:35587`
|
||||
- 크리에이터 모드, 받은 선물 내역이 없을 때: `2531:36283`
|
||||
- 크리에이터 모드, 받은 선물 내역이 있을 때: `2531:36194`
|
||||
- 크리에이터 모드, 받은 선물 상태별 카드 표시: `2531:36237`
|
||||
|
||||
전제:
|
||||
|
||||
- Android/iOS 각각의 기존 디자인 시스템, 컴포넌트, API client, 상태 관리, toast/dialog, navigation 패턴을 재사용한다.
|
||||
- Figma의 visual structure를 기준으로 구현하되, 아래 사용자 요구사항을 Figma보다 우선한다.
|
||||
- 상단 뒤로가기 내비게이션 영역은 고정한다.
|
||||
- 상단 뒤로가기 내비게이션 영역을 제외한 전체 콘텐츠 영역은 스크롤되어야 한다.
|
||||
- `신청 내역`, `선물 내역` 섹션 타이틀은 표시하지 않는다.
|
||||
- 리스트는 신청일/취소일 최신순으로 보여야 한다.
|
||||
- request/response 설명은 모바일 공통 JSON 기준이다.
|
||||
|
||||
---
|
||||
|
||||
## 모바일 앱 구현 프롬프트
|
||||
|
||||
```text
|
||||
Android/iOS 앱의 선물함 리스트 페이지를 Figma 기준으로 생성하고 API 연동을 구현해줘.
|
||||
|
||||
Figma는 두 관점의 상태를 보여준다.
|
||||
- 보낸 선물 관점: 사용자가 본인이 신청한 선물 내역을 확인하는 상태다. Figma `2481:19382`, `2520:35587`를 기준으로 한다.
|
||||
- 받은 선물 관점: 사용자가 본인이 받을 선물 내역을 확인하는 상태다. Figma `2531:36283`, `2531:36194`, `2531:36237`를 기준으로 한다.
|
||||
- 실제 선물함 API는 두 관점을 나누어 호출하지 않고 `type=ALL`로 한 번 호출한다.
|
||||
- 응답 item의 `direction` 값이 `SENT`이면 보낸 선물 카드로, `RECEIVED`이면 받은 선물 카드로 렌더링한다.
|
||||
- 각 카드에는 상대방 프로필 이미지와 닉네임을 표시한다.
|
||||
- `direction=SENT`: 상대방은 선물을 받는 크리에이터다.
|
||||
- `direction=RECEIVED`: 상대방은 선물을 보낸 팬이다.
|
||||
|
||||
중요 요구사항:
|
||||
- 상단 뒤로가기 내비게이션은 고정한다.
|
||||
- 상단 내비게이션 아래의 전체 콘텐츠만 스크롤되게 한다.
|
||||
- Figma에 보이는 `신청 내역`, `선물 내역` 섹션 타이틀은 실제 구현에서 표시하지 않는다.
|
||||
- 선물함 리스트는 신청일/취소일 최신순으로 정렬한다.
|
||||
- 현재 API 응답만으로 취소일 기준 정렬/표시는 완전하지 않으므로, API가 취소일을 내려주지 않으면 서버 응답 순서를 우선 사용하고 취소 건 날짜 라벨은 `createdAt` 기준으로 표시한다. 취소일 표시가 필수이면 API 보강이 필요하다.
|
||||
|
||||
API 호출:
|
||||
- 선물함 리스트는 항상 `GET /api/v2/gifts?type=ALL&page=0&size=20`로 조회한다.
|
||||
- `SENT`, `RECEIVED`로 따로 호출하지 않는다.
|
||||
- `items[].direction`으로 보낸 선물/받은 선물 UI를 결정한다.
|
||||
- 페이지 진입 시 첫 페이지를 조회한다.
|
||||
- `hasNext=true`이면 스크롤 하단에서 다음 page를 추가 조회한다.
|
||||
- pull-to-refresh가 기존 앱에 있으면 page를 0으로 초기화하고 다시 조회한다.
|
||||
|
||||
공통 레이아웃:
|
||||
1. 상단 고정 내비게이션
|
||||
- 타이틀: `선물함`
|
||||
- 뒤로가기 버튼은 기존 앱 패턴을 사용한다.
|
||||
- 이 영역은 스크롤되지 않는다.
|
||||
|
||||
2. 스크롤 콘텐츠
|
||||
- nav 아래부터 시작한다.
|
||||
- 안내 배너, 빈 상태, 리스트, 하단 여백 또는 하단 문의 버튼까지 스크롤 영역에 포함한다.
|
||||
- iOS safe area와 Android navigation bar 여백은 기존 앱 패턴을 따른다.
|
||||
|
||||
보낸 선물 UI:
|
||||
- `direction=SENT`인 item에 적용한다.
|
||||
- 사용자가 신청한 선물 내역을 보여준다.
|
||||
- 보낸 선물만 있는 상태는 Figma `2481:19382`를 따른다.
|
||||
- 상단 안내 배너를 표시한다.
|
||||
- 제목: `사서함을 통해 안전하게 전달됩니다`
|
||||
- 본문: `크리에이터 및 팬의 주소는 공개되지 않습니다. 선물은 소다라이브 사서함에 도착한 뒤 크리에이터에게 전달됩니다.`
|
||||
- `신청 내역` 섹션 타이틀은 표시하지 않는다.
|
||||
- 카드 구성:
|
||||
- 상단 왼쪽: `statusName`
|
||||
- 상단 오른쪽: 날짜 라벨
|
||||
- 기본: `{createdAt} 신청`
|
||||
- 상태가 `CANCELED`이고 취소일 필드가 있으면 `{canceledAt} 취소`
|
||||
- 현재 리스트 API에는 `canceledAt`이 없으므로 기존 API만 사용할 때는 `{createdAt} 취소` 또는 `{createdAt} 신청` 중 제품 정책에 맞춰 하나로 통일한다.
|
||||
- 프로필 row 내부: `counterpartProfileImageUrl`, `counterpartNickname`을 사용해 받는 크리에이터 프로필을 표시한다.
|
||||
- 프로필 row 아래 보조 문구: `sizeName · categoryName`
|
||||
- 프로필 이미지가 없으면 기존 앱의 기본 프로필 이미지를 표시한다.
|
||||
- 우측 chevron
|
||||
- 탭 시 선물 상세 페이지로 이동하며 `applicationNo`를 전달한다.
|
||||
- 리스트가 비어 있고 보낸 선물 관점으로 진입한 화면이면 Figma `2520:35587`를 따른다.
|
||||
- 상단 안내 배너만 표시한다.
|
||||
- 별도 empty title/body를 추가하지 않는다.
|
||||
|
||||
받은 선물 UI:
|
||||
- `direction=RECEIVED`인 item에 적용한다.
|
||||
- 사용자가 받을 선물 내역을 보여준다.
|
||||
- 리스트가 비어 있고 받은 선물 관점으로 진입한 화면이면 Figma `2531:36283`를 따른다.
|
||||
- 가운데 empty title: `전달 예정인 선물이 없어요`
|
||||
- empty body: `베타 기간에는 팬이 선물을 보내는 기능만 제공돼요. 더 다양해진 선물 기능으로 곧 다시 만나요!`
|
||||
- 버튼: `의견 남기기`
|
||||
- 의견 남기기 동작은 기존 앱의 문의/피드백 이동 패턴을 사용한다.
|
||||
- 받은 선물이 있는 상태는 Figma `2531:36194`, `2531:36237`를 따른다.
|
||||
- 상단 안내 배너를 표시한다.
|
||||
- 제목: `배송지 입력 기간 안내`
|
||||
- 본문: `알림을 받은 날부터 7일 이내에 배송지를 입력해 주세요. 기한 내 입력하지 않으면 선물이 반송됩니다.`
|
||||
- `선물 내역` 섹션 타이틀은 표시하지 않는다.
|
||||
- 배송지 입력이 필요한 선물이 있으면 파란 안내 배너를 리스트 상단에 표시한다.
|
||||
- 제목: `팬이 보낸 선물이 있어요!`
|
||||
- 본문: `선물이 늦지 않게 전달될 수 있도록 배송지를 입력해 주세요.`
|
||||
- 보조 문구: `*n일 이내로 입력하지 않으면 선물이 사라져요.`
|
||||
- 현재 리스트 API에는 배송지 입력 마감일이 없으므로 정확한 n일 계산이 필요하면 API 보강이 필요하다.
|
||||
- 배너 탭 시 배송지 입력이 필요한 첫 번째 선물 상세로 이동한다.
|
||||
- 카드 구성:
|
||||
- 상단 왼쪽 프로필 row 내부: `counterpartProfileImageUrl`, `counterpartNickname`을 사용해 보낸 팬 프로필을 표시한다.
|
||||
- 상단 오른쪽: `{createdAt} 신청`
|
||||
- 본문 메인: 상태 표시 문구
|
||||
- 본문 서브: 상태 보조 문구가 필요한 경우만 표시
|
||||
- 우측 chevron
|
||||
- 탭 시 선물 상세 페이지로 이동하며 `applicationNo`를 전달한다.
|
||||
- 리스트 하단에는 Figma처럼 `문의하기` 하단 버튼을 둘 수 있다. 기존 앱 정책상 고정 버튼이면 하단 safe area를 포함하고, 스크롤 콘텐츠 내부 버튼이면 콘텐츠 하단에 둔다.
|
||||
|
||||
상태 표시 매핑:
|
||||
|
||||
보낸 선물 카드:
|
||||
| API status | 표시 문구 |
|
||||
|---|---|
|
||||
| `RECEIVED` | `운송장 등록 필요` |
|
||||
| `TRACKING_REGISTERED` | `발송 확인` |
|
||||
| `ARRIVED_AT_MAILBOX` | `사서함 도착` |
|
||||
| `INSPECTION_COMPLETED` | `선물 검수 완료` |
|
||||
| `DELIVERED` | `전달 완료` |
|
||||
| `UNDELIVERABLE` | `전달 불가` |
|
||||
| `CANCELED` | `신청 취소` |
|
||||
|
||||
받은 선물 카드:
|
||||
| API status | 메인 문구 | 서브 문구 |
|
||||
|---|---|---|
|
||||
| `TRACKING_REGISTERED` | `배송지 입력 필요` | `전달 예정` |
|
||||
| `ARRIVED_AT_MAILBOX` | `선물 검수` | `선물 확인 중` |
|
||||
| `INSPECTION_COMPLETED` | `선물 검수 완료` | `배송 중` |
|
||||
| `DELIVERED` | `전달 완료` | 없음 |
|
||||
| `UNDELIVERABLE` | `전달 불가` | 없음 |
|
||||
|
||||
API 명세:
|
||||
|
||||
선물함 리스트 조회 API:
|
||||
- Method: `GET`
|
||||
- URL: `/api/v2/gifts`
|
||||
- 인증: 로그인 필요
|
||||
- Query:
|
||||
- `type`: `ALL`, `SENT`, `RECEIVED`
|
||||
- `page`: 0부터 시작
|
||||
- `size`: 페이지 크기. 기본 20
|
||||
- 이 페이지에서는 `type=ALL`만 사용한다.
|
||||
- Response envelope 예시:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"totalCount": 1,
|
||||
"items": [],
|
||||
"page": 0,
|
||||
"size": 20,
|
||||
"hasNext": false
|
||||
},
|
||||
"message": ""
|
||||
}
|
||||
```
|
||||
|
||||
Response `data` 예시:
|
||||
```json
|
||||
{
|
||||
"totalCount": 2,
|
||||
"items": [
|
||||
{
|
||||
"applicationNo": "A-1002609300001",
|
||||
"direction": "SENT",
|
||||
"status": "RECEIVED",
|
||||
"statusName": "접수 완료",
|
||||
"priceCan": 80,
|
||||
"categoryName": "아크릴/스탠드",
|
||||
"sizeName": "소형",
|
||||
"counterpartMemberId": 200,
|
||||
"counterpartNickname": "달빛수집가",
|
||||
"counterpartProfileImageUrl": "https://example.com/profile.png",
|
||||
"createdAt": "2026-09-18T03:00:00Z"
|
||||
}
|
||||
],
|
||||
"page": 0,
|
||||
"size": 20,
|
||||
"hasNext": false
|
||||
}
|
||||
```
|
||||
|
||||
Response `data` 필드:
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
|---|---|---|
|
||||
| `totalCount` | number | 전체 개수 |
|
||||
| `items` | array | 선물함 리스트 |
|
||||
| `items[].applicationNo` | string | 선물 신청번호. 상세 이동 ID |
|
||||
| `items[].direction` | string | `SENT` 또는 `RECEIVED` |
|
||||
| `items[].status` | string | 선물 상태 코드 |
|
||||
| `items[].statusName` | string | 서버 상태 표시명 |
|
||||
| `items[].priceCan` | number | 사용된 캔 |
|
||||
| `items[].categoryName` | string | 카테고리명 |
|
||||
| `items[].sizeName` | string | 사이즈명 |
|
||||
| `items[].counterpartMemberId` | number | 상대방 회원 ID. `SENT`에서는 받는 크리에이터, `RECEIVED`에서는 보낸 팬 |
|
||||
| `items[].counterpartNickname` | string | 상대방 닉네임 |
|
||||
| `items[].counterpartProfileImageUrl` | string 또는 null | 상대방 프로필 이미지 URL. 없으면 기본 프로필 이미지 표시 |
|
||||
| `items[].createdAt` | string 또는 null | 신청일. UTC ISO 문자열 |
|
||||
| `page` | number | 현재 페이지 |
|
||||
| `size` | number | 페이지 크기 |
|
||||
| `hasNext` | boolean | 다음 페이지 존재 여부 |
|
||||
|
||||
정렬:
|
||||
- 현재 서버 API는 `createdAt desc, id desc`로 정렬한다.
|
||||
- 요구사항은 신청일/취소일 최신순이다.
|
||||
- 취소일 최신순까지 정확히 맞추려면 리스트 API가 `canceledAt` 또는 `displayedAt` 같은 정렬 기준 시각을 내려주고 그 기준으로 정렬해야 한다.
|
||||
- API 보강 전에는 서버 응답 순서를 그대로 사용하고, 클라이언트에서 임의 재정렬하지 않는다.
|
||||
|
||||
상대방 프로필 표시 규칙:
|
||||
- `counterpartNickname`은 카드의 프로필 row 이름으로 표시한다.
|
||||
- `counterpartProfileImageUrl`이 있으면 해당 이미지를 원형 프로필 이미지로 표시한다.
|
||||
- `counterpartProfileImageUrl`이 `null`이거나 빈 값이면 기존 앱의 기본 프로필 이미지를 표시한다.
|
||||
- `direction=SENT`에서는 받는 크리에이터 정보로 해석한다.
|
||||
- `direction=RECEIVED`에서는 보낸 팬 정보로 해석한다.
|
||||
|
||||
현재 API만으로 부족한 Figma 표시값:
|
||||
- 취소일 `canceledAt`
|
||||
- 배송지 입력 마감일 또는 남은 일수
|
||||
|
||||
추가 API 보강이 가능하면 리스트 item에 아래 필드를 추가해달라고 요청한다.
|
||||
```json
|
||||
{
|
||||
"canceledAt": "2026-09-18T03:00:00Z",
|
||||
"recipientAddressDeadlineAt": "2026-09-25T03:00:00Z",
|
||||
"sortAt": "2026-09-18T03:00:00Z"
|
||||
}
|
||||
```
|
||||
- `canceledAt`: 취소 상태의 취소일 표시용
|
||||
- `recipientAddressDeadlineAt`: 크리에이터 배송지 입력 안내 배너의 남은 일수 계산용
|
||||
- `sortAt`: 신청/취소 최신순 정렬 기준. 취소 건은 취소일, 그 외에는 신청일
|
||||
|
||||
로딩/에러/빈 상태:
|
||||
- 최초 로딩 중에는 기존 앱 리스트 스켈레톤 또는 loading 패턴을 사용한다.
|
||||
- 실패 시 기존 앱 toast/dialog와 재시도 버튼을 사용한다.
|
||||
- 빈 상태는 진입 관점에 따라 다르게 표시한다.
|
||||
- 보낸 선물 관점으로 진입한 화면이면 안내 배너만 남긴다.
|
||||
- 받은 선물 관점으로 진입한 화면이면 중앙 empty UI와 `의견 남기기` 버튼을 표시한다.
|
||||
|
||||
테스트/검증:
|
||||
- 선물함 진입 시 `type=ALL`로 API를 한 번 호출하는지 확인한다.
|
||||
- `direction=SENT` item은 보낸 선물 카드 UI로 표시되는지 확인한다.
|
||||
- `direction=RECEIVED` item은 받은 선물 카드 UI로 표시되는지 확인한다.
|
||||
- 각 item의 `counterpartNickname`, `counterpartProfileImageUrl`이 프로필 row에 표시되는지 확인한다.
|
||||
- `counterpartProfileImageUrl=null`이면 기본 프로필 이미지가 표시되는지 확인한다.
|
||||
- 상단 내비게이션은 고정되고 아래 콘텐츠만 스크롤되는지 확인한다.
|
||||
- `신청 내역`, `선물 내역` 텍스트가 실제 화면에 노출되지 않는지 확인한다.
|
||||
- 리스트 item 탭 시 `applicationNo`로 상세 화면 이동이 되는지 확인한다.
|
||||
- 빈 상태가 진입 관점에 맞는 Figma 기준으로 표시되는지 확인한다.
|
||||
- `hasNext=true`일 때 다음 page를 추가 조회하는지 확인한다.
|
||||
- API 실패 시 입력/스크롤 상태를 깨지 않고 오류와 재시도를 제공하는지 확인한다.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 요약
|
||||
|
||||
| 목적 | Method | URL | Query | Response data |
|
||||
|---|---|---|---|---|
|
||||
| 선물함 리스트 조회 | GET | `/api/v2/gifts` | `type=ALL`, `page`, `size` | 선물 리스트, 페이지 정보 |
|
||||
|
||||
## Figma 반영 메모
|
||||
|
||||
- 보낸 선물 리스트 있음: 안내 배너 + 카드 리스트. 단, 실제 구현에서는 `신청 내역` 타이틀을 제거한다.
|
||||
- 보낸 선물 관점 empty: 안내 배너만 표시한다.
|
||||
- 받은 선물 관점 empty: 중앙 empty UI와 `의견 남기기` 버튼을 표시한다.
|
||||
- 받은 선물 리스트 있음: 안내 배너 + 배송지 입력 CTA 배너 + 카드 리스트. 단, 실제 구현에서는 `선물 내역` 타이틀을 제거한다.
|
||||
- 받은 선물 상태별 카드는 `배송지 입력 완료/전달 예정`, `전달 완료`, `전달 불가`, `선물 검수/선물 확인 중`, `선물 검수 완료/배송 중` 패턴을 사용한다.
|
||||
@@ -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)
|
||||
) COMMENT='선물 카테고리';
|
||||
|
||||
CREATE TABLE gift_mailbox (
|
||||
id BIGINT NOT NULL COMMENT '전역 단일 설정 ID. 항상 1',
|
||||
name VARCHAR(50) NOT NULL COMMENT '받을 사람 이름',
|
||||
phone_number VARCHAR(30) NOT NULL COMMENT '받을 연락처',
|
||||
zip_code VARCHAR(20) NOT NULL COMMENT '받을 우편번호',
|
||||
address VARCHAR(255) NOT NULL COMMENT '받을 주소',
|
||||
address_detail VARCHAR(255) NULL COMMENT '받을 상세주소',
|
||||
PRIMARY KEY (id)
|
||||
) COMMENT='선물 받을 주소 전역 설정';
|
||||
|
||||
CREATE TABLE gift (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '내부 기본키',
|
||||
application_no VARCHAR(40) NOT NULL COMMENT '신청번호',
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
# 선물 보내기 페이지 생성 프롬프트
|
||||
|
||||
이 문서는 Android/iOS 앱의 `선물 보내기` 페이지를 생성하거나 기존 생성 UI에 API 연동을 붙일 때 사용하는 프롬프트다.
|
||||
|
||||
참고 Figma:
|
||||
|
||||
- 빈 입력 상태: `2481:18904`
|
||||
- 입력 완료 및 CTA 활성화 상태: `2481:18957`
|
||||
|
||||
전제:
|
||||
|
||||
- 크리에이터 닉네임과 `memberId`를 받아 `선물 보내기` 화면으로 이동하는 흐름은 이미 생성되어 있다.
|
||||
- 새 진입 흐름을 만들지 않는다.
|
||||
- Figma에 있는 UI 구조와 문구를 유지한다.
|
||||
- Android/iOS 각각의 기존 디자인 시스템, 컴포넌트, API client, 상태 관리, toast/dialog, navigation 패턴을 재사용한다.
|
||||
- 모든 API 응답은 공통 envelope를 사용하며 실제 payload는 `data`에 있다.
|
||||
- request/response 설명은 모바일 공통 JSON 기준이다.
|
||||
|
||||
---
|
||||
|
||||
## 모바일 앱 구현 프롬프트
|
||||
|
||||
```text
|
||||
Android/iOS 앱의 선물 보내기 페이지를 Figma 기준으로 생성하고 API 연동을 구현해줘.
|
||||
|
||||
중요 전제:
|
||||
- 크리에이터 닉네임과 memberId를 받아 이 화면으로 이동하는 기능은 이미 구현되어 있다.
|
||||
- 이 작업에서는 이동 경로를 새로 만들지 말고, 전달받은 값만 화면과 API request에 연결한다.
|
||||
- 전달받은 크리에이터 닉네임은 `받는 크리에이터` 영역에 표시한다.
|
||||
- 전달받은 memberId는 선물 신청 API의 `recipientMemberId`로 보낸다.
|
||||
|
||||
Figma 참고 상태:
|
||||
- 빈 입력 상태: node `2481:18904`
|
||||
- 입력 완료 및 최하단 CTA 활성화 상태: node `2481:18957`
|
||||
|
||||
화면 구조:
|
||||
|
||||
1. 상단
|
||||
- 타이틀: `선물 보내기`
|
||||
- 뒤로가기 버튼은 기존 앱 패턴을 사용한다.
|
||||
|
||||
2. 안내 배너
|
||||
- 제목: `선물 보내기 베타 서비스 안내`
|
||||
- 본문: `베타 기간 동안은 일부 기능만 제공됩니다. 크리에이터에게 마음을 잘 전할 수 있도록 더 넓어진 선물 보내기로 곧 다시 만나요!`
|
||||
|
||||
3. 받는 크리에이터
|
||||
- 이전 화면에서 전달받은 크리에이터 닉네임을 표시한다.
|
||||
- Figma의 프로필 row 형태를 유지한다.
|
||||
- memberId는 화면 표시용이 아니라 API request용으로만 사용한다.
|
||||
|
||||
4. 선물 사이즈
|
||||
- 화면 진입 시 `GET /api/v2/gifts/form-options`를 호출해 `sizes`를 가져온다.
|
||||
- 조회된 `sizes`를 사이즈 카드 목록에 바인딩한다.
|
||||
- 카드에는 `name`, 사이즈 설명, 캔 가격을 표시한다.
|
||||
- 서버 응답에는 사이즈 설명 필드가 없으므로 설명 문구는 앱에서 코드별로 매핑한다.
|
||||
- `SMALL`: `세 변의 합 100cm 이하 · 5kg 이하`
|
||||
- `MEDIUM`, `LARGE`: 정책 문구가 앱에 이미 있으면 기존 문구를 사용하고, 없으면 이름과 가격만 표시한다.
|
||||
- 가격은 `salePriceCan`을 표시하고 submit 금액도 `salePriceCan`을 사용한다.
|
||||
- `basePriceCan`과 `salePriceCan`이 다르면 기존 앱 할인/정가 표시 패턴이 있을 때만 정가를 함께 보여준다.
|
||||
- 기본 선택은 서버가 내려준 첫 번째 사이즈로 둔다.
|
||||
|
||||
5. 카테고리
|
||||
- 화면 진입 시 같은 `GET /api/v2/gifts/form-options` 응답의 `categories`를 사용한다.
|
||||
- Figma의 select field를 유지한다.
|
||||
- 빈 상태 문구: `카테고리 선택`
|
||||
- 선택 후에는 선택한 카테고리 `name`을 표시한다.
|
||||
- 카테고리 선택 UI는 기존 Android/iOS 앱 패턴에 맞는 bottom sheet, picker, dialog 중 이미 쓰는 방식을 사용한다.
|
||||
- `requiresDamageWaiver=true`인 카테고리를 선택한 경우에만 카테고리 아래에 파손 면책 동의 row를 표시한다.
|
||||
- 파손 면책 동의 문구: `파손 및 분실 면책 사항에 동의합니다. (필수)`
|
||||
- `requiresDamageWaiver=false` 카테고리를 선택하면 파손 면책 동의 row를 숨기고 `damageWaiverAgreed=false`로 초기화한다.
|
||||
|
||||
6. 보내는 사람
|
||||
- 이름 input → `senderName`
|
||||
- 휴대폰 번호 input → `senderPhoneNumber`
|
||||
- 우편번호 input → `senderZipCode`
|
||||
- 주소 input → `senderAddress`
|
||||
- 상세 주소 input → `senderAddressDetail`
|
||||
- `우편번호 검색` 버튼은 기존 주소 검색 기능과 연결한다.
|
||||
- 주소 검색 결과로 우편번호와 기본 주소를 채운다.
|
||||
- 기본 주소는 사용자가 직접 수정하지 못하게 하는 기존 패턴이 있으면 그 패턴을 따른다.
|
||||
|
||||
7. 이용 약관 동의
|
||||
- Figma의 발송 규정 요약 박스와 체크박스 2개를 유지한다.
|
||||
- `소다라이브 크리에이터 상품 전달 이용약관에 동의합니다. (필수)` → `senderTermsAgreed`
|
||||
- `상품 전달을 위한 개인정보 수집·이용에 동의합니다. (필수)` → `senderPrivacyAgreed`
|
||||
|
||||
8. 하단 CTA
|
||||
- 빈 상태 또는 필수값 누락 상태는 Figma 빈 상태처럼 비활성 버튼을 표시한다.
|
||||
- 활성 조건을 모두 만족하면 Figma 활성 상태처럼 soda 색상 CTA로 바꾼다.
|
||||
- 활성 CTA 문구는 선택된 사이즈의 `salePriceCan`을 사용해 `{salePriceCan}캔으로 선물 보내기`로 표시한다.
|
||||
- submit 중에는 중복 탭을 막고 loading 상태를 표시한다.
|
||||
|
||||
화면 진입 시 처리:
|
||||
- 이미 전달받은 `creatorNickname`과 `memberId`를 읽는다.
|
||||
- 둘 중 하나라도 없으면 기존 앱의 오류 처리 또는 뒤로가기 패턴을 따른다.
|
||||
- 즉시 `GET /api/v2/gifts/form-options`를 호출한다.
|
||||
- 로딩 중에는 기존 화면 스켈레톤/로딩 패턴을 사용한다.
|
||||
- 조회 실패 시 toast/dialog와 재시도 동작을 기존 패턴으로 제공한다.
|
||||
- `sizes` 또는 `categories`가 비어 있으면 선물 신청을 막고 CTA를 비활성화한다.
|
||||
|
||||
CTA 활성 조건:
|
||||
- `memberId`가 있다.
|
||||
- 사이즈가 선택되어 있다.
|
||||
- 카테고리가 선택되어 있다.
|
||||
- 이름이 입력되어 있다.
|
||||
- 휴대폰 번호가 입력되어 있다.
|
||||
- 우편번호가 입력되어 있다.
|
||||
- 주소가 입력되어 있다.
|
||||
- 이용약관 동의가 true다.
|
||||
- 개인정보 수집·이용 동의가 true다.
|
||||
- 선택한 카테고리의 `requiresDamageWaiver=true`이면 파손 면책 동의가 true다.
|
||||
|
||||
검증 실패 처리:
|
||||
- CTA는 기본적으로 위 조건을 만족할 때만 활성화한다.
|
||||
- 그래도 submit 시점에 한 번 더 validation 한다.
|
||||
- 누락된 필드는 기존 앱의 input error, toast, dialog 중 현재 화면 패턴에 맞춰 안내한다.
|
||||
- validation 실패 시 `POST /api/v2/gifts`를 호출하지 않는다.
|
||||
|
||||
폼 옵션 조회 API:
|
||||
- Method: `GET`
|
||||
- URL: `/api/v2/gifts/form-options`
|
||||
- Request: 없음
|
||||
- Response envelope 예시:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"sizes": [],
|
||||
"categories": []
|
||||
},
|
||||
"message": ""
|
||||
}
|
||||
```
|
||||
- Response `data` 예시:
|
||||
```json
|
||||
{
|
||||
"sizes": [
|
||||
{
|
||||
"sizeCode": "SMALL",
|
||||
"name": "소형",
|
||||
"basePriceCan": 100,
|
||||
"salePriceCan": 80
|
||||
}
|
||||
],
|
||||
"categories": [
|
||||
{
|
||||
"categoryId": 1,
|
||||
"name": "아크릴",
|
||||
"requiresDamageWaiver": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
폼 옵션 필드:
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
|---|---|---|
|
||||
| `sizes` | array | 선택 가능한 선물 사이즈 목록 |
|
||||
| `sizes[].sizeCode` | string | 사이즈 코드. `SMALL`, `MEDIUM`, `LARGE` |
|
||||
| `sizes[].name` | string | 사이즈 표시명 |
|
||||
| `sizes[].basePriceCan` | number | 기본 가격, 단위는 캔 |
|
||||
| `sizes[].salePriceCan` | number | 실제 결제 가격, 단위는 캔 |
|
||||
| `categories` | array | 선택 가능한 활성 카테고리 목록 |
|
||||
| `categories[].categoryId` | number | 카테고리 ID |
|
||||
| `categories[].name` | string | 카테고리 표시명 |
|
||||
| `categories[].requiresDamageWaiver` | boolean | 파손 면책 동의 필요 여부 |
|
||||
|
||||
선물 신청 접수 API:
|
||||
- Method: `POST`
|
||||
- URL: `/api/v2/gifts`
|
||||
- 인증: 로그인 회원 필요. 기존 앱 인증 토큰/세션 처리 방식을 사용한다.
|
||||
- Request body 예시:
|
||||
```json
|
||||
{
|
||||
"recipientMemberId": 100,
|
||||
"senderName": "김소다",
|
||||
"senderPhoneNumber": "01000000000",
|
||||
"senderZipCode": "12345",
|
||||
"senderAddress": "서울시 강남구 ...",
|
||||
"senderAddressDetail": "123동 456호",
|
||||
"sizeCode": "SMALL",
|
||||
"categoryId": 1,
|
||||
"senderTermsAgreed": true,
|
||||
"senderPrivacyAgreed": true,
|
||||
"damageWaiverAgreed": true
|
||||
}
|
||||
```
|
||||
|
||||
Request body 필드:
|
||||
|
||||
| 필드 | 타입 | 필수 | 설명 |
|
||||
|---|---|---|---|
|
||||
| `recipientMemberId` | number | Y | 화면 진입 시 전달받은 memberId |
|
||||
| `senderName` | string | Y | 보내는 사람 이름 |
|
||||
| `senderPhoneNumber` | string | Y | 보내는 사람 휴대폰 번호 |
|
||||
| `senderZipCode` | string | Y | 보내는 사람 우편번호 |
|
||||
| `senderAddress` | string | Y | 보내는 사람 기본 주소 |
|
||||
| `senderAddressDetail` | string 또는 null | N | 보내는 사람 상세 주소 |
|
||||
| `sizeCode` | string | Y | 선택한 사이즈 코드 |
|
||||
| `categoryId` | number | Y | 선택한 카테고리 ID |
|
||||
| `senderTermsAgreed` | boolean | Y | 이용약관 동의 여부. 반드시 `true` |
|
||||
| `senderPrivacyAgreed` | boolean | Y | 개인정보 수집·이용 동의 여부. 반드시 `true` |
|
||||
| `damageWaiverAgreed` | boolean | Y | 파손 면책 동의 여부. 필요한 카테고리에서는 `true`, 필요 없는 카테고리에서는 `false` |
|
||||
|
||||
- Response `data` 예시:
|
||||
```json
|
||||
{
|
||||
"applicationNo": "A-1002609300001",
|
||||
"status": "RECEIVED",
|
||||
"statusName": "접수 완료",
|
||||
"priceCan": 80,
|
||||
"trackingDeadlineAt": "2026-10-02T03:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
Response `data` 필드:
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
|---|---|---|
|
||||
| `applicationNo` | string | 선물 신청번호 |
|
||||
| `status` | string | 신청 상태 코드 |
|
||||
| `statusName` | string | 신청 상태 표시명 |
|
||||
| `priceCan` | number | 실제 차감된 캔 금액 |
|
||||
| `trackingDeadlineAt` | string | 운송장 등록 기한. UTC ISO 문자열 |
|
||||
|
||||
성공 처리:
|
||||
- 신청 성공 시 기존 앱의 완료 dialog 또는 완료 화면 패턴을 사용한다.
|
||||
- 최소 표시값은 `applicationNo`, `statusName`, `priceCan`, `trackingDeadlineAt`이다.
|
||||
- 성공 이후 이동 CTA가 필요하면 기존 선물함 또는 선물 상세 이동 패턴을 사용한다.
|
||||
|
||||
실패 처리:
|
||||
- 서버 오류 메시지가 있으면 기존 앱 에러 노출 방식으로 표시한다.
|
||||
- 잔액 부족, 유효하지 않은 카테고리, 파손 면책 미동의, 약관 미동의 등의 오류는 서버 메시지를 우선 사용한다.
|
||||
- 실패 시 입력값은 유지한다.
|
||||
|
||||
테스트/검증:
|
||||
- 화면 진입 시 `GET /api/v2/gifts/form-options`가 호출되는지 확인한다.
|
||||
- 전달받은 크리에이터 닉네임이 받는 크리에이터 영역에 표시되는지 확인한다.
|
||||
- 전달받은 memberId가 `POST /api/v2/gifts`의 `recipientMemberId`로 들어가는지 확인한다.
|
||||
- 빈 상태에서는 CTA가 비활성화되는지 확인한다.
|
||||
- 필수 입력과 필수 동의를 모두 완료하면 CTA가 활성화되고 `{salePriceCan}캔으로 선물 보내기`가 표시되는지 확인한다.
|
||||
- `requiresDamageWaiver=true` 카테고리 선택 시 파손 면책 동의 row가 표시되고 동의 전에는 CTA가 비활성인지 확인한다.
|
||||
- `requiresDamageWaiver=false` 카테고리 선택 시 파손 면책 동의 row가 숨겨지고 request의 `damageWaiverAgreed`가 false인지 확인한다.
|
||||
- submit 성공 시 요청 body와 성공 처리 값을 확인한다.
|
||||
- submit 실패 시 입력값이 유지되고 기존 앱 방식으로 오류가 표시되는지 확인한다.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 요약
|
||||
|
||||
| 목적 | Method | URL | Request | Response data |
|
||||
|---|---|---|---|---|
|
||||
| 폼 옵션 조회 | GET | `/api/v2/gifts/form-options` | 없음 | 사이즈 목록, 카테고리 목록 |
|
||||
| 선물 신청 접수 | POST | `/api/v2/gifts` | 선물 신청 JSON body | 신청번호, 상태, 차감 금액, 운송장 등록 기한 |
|
||||
|
||||
## Figma 반영 메모
|
||||
|
||||
- 빈 상태 Figma는 카테고리 미선택, 보내는 사람 정보 미입력, 약관 미동의, 하단 CTA 비활성 상태다.
|
||||
- 활성 상태 Figma는 크리에이터 닉네임 표시, 카테고리 선택, 파손 면책 동의 노출, 보내는 사람 정보 입력, 약관 동의, 하단 CTA 활성 상태다.
|
||||
- Figma의 하단 버튼 텍스트는 캔 금액이 포함된 형태이므로 API의 `salePriceCan`으로 동적으로 표시한다.
|
||||
- Figma의 select, checkbox, radio, bottom action bar, safe area는 각 플랫폼의 기존 컴포넌트로 대응한다.
|
||||
@@ -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 | 완료 |
|
||||
|
||||
## 목표
|
||||
@@ -1309,6 +1309,103 @@ P7은 FCM 확장 후 전체 상태 전이를 연결하고 최종 회귀로 종
|
||||
- Gate 확인: Phase 9 focused suite, `./gradlew ktlintCheck`, `git diff --check` 모두 성공
|
||||
- 남은 항목: 없음
|
||||
|
||||
## Phase 11: 선물 받을 주소 설정
|
||||
|
||||
**Phase 결과:** 관리자는 전역 단일 선물 받을 주소를 등록/수정하고, 팬은 보낸 선물의 전달 완료 전 정상 진행 상세에서 해당 주소를 확인할 수 있다.
|
||||
|
||||
**선행조건:** `P3-GATE`, `P10-GATE` 완료.
|
||||
|
||||
### 구현 항목
|
||||
|
||||
#### Task 11.1 받을 주소 관리자 설정 API 구현
|
||||
|
||||
**Goal 실행 `P11-T1`:** 전역 단일 받을 주소 조회/저장 API를 구현한다.
|
||||
|
||||
- **Files:** Create `GiftMailbox.kt`, `GiftMailboxRepository.kt`; Modify `GiftAdminService.kt`, `AdminGiftController.kt`, `AdminGiftRequest.kt`, `AdminGiftResponse.kt`, `gift-schema.sql`; Test `GiftAdminServiceTest.kt`, `AdminGiftControllerTest.kt`.
|
||||
- [x] **RED:** 설정 미등록 시 `GET /api/v2/admin/gift-mailbox`가 `data=null`을 반환하는 실패 test를 작성한다.
|
||||
- [x] **RED:** `PUT /api/v2/admin/gift-mailbox`가 전역 단일 주소를 생성하고, 재호출 시 같은 row를 갱신하는 실패 test를 작성한다.
|
||||
- [x] **RED:** 이름/연락처/우편번호/주소 공백 입력을 거부하는 실패 test를 작성한다.
|
||||
- [x] **RED 확인:** focused test 실패를 확인한다.
|
||||
- [x] **GREEN:** 단일 row persistence, 관리자 service, request/response DTO, controller endpoint를 구현한다.
|
||||
- [x] **GREEN 확인:** focused test 통과를 확인한다.
|
||||
|
||||
#### Task 11.2 선물 상세 받을 주소 노출 구현
|
||||
|
||||
**Goal 실행 `P11-T2`:** 보낸 선물의 전달 완료 전 정상 진행 상세에만 받을 주소를 노출한다.
|
||||
|
||||
- **Files:** Modify `GiftQueryService.kt`; Test `GiftQueryServiceTest.kt`, `GiftControllerTest.kt`.
|
||||
- [x] **RED:** `direction=SENT`이고 `status`가 `RECEIVED`, `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
|
||||
|
||||
| 날짜 | 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-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 제거, 공식 강제 종료 옵션 적용 후 전체 테스트 정상 종료 확인 |
|
||||
|
||||
## 최종 보고 형식
|
||||
|
||||
|
||||
@@ -186,6 +186,7 @@ INSPECTION_COMPLETED -> UNDELIVERABLE
|
||||
- 로그인 회원이 받는 크리에이터이면 보내는 팬의 닉네임만 알 수 있고, 팬이 신청 시 등록한 이름/휴대폰 번호/주소는 알 수 없다.
|
||||
- 로그인 회원이 보내는 팬이면 받는 크리에이터의 닉네임만 알 수 있고, 크리에이터가 입력한 이름/휴대폰 번호/주소는 알 수 없다.
|
||||
- 보내는 팬 상세에는 팬 본인이 신청 시 등록한 이름, 휴대폰 번호, 주소를 내려준다.
|
||||
- 보내는 팬 상세에는 관리자가 등록한 전역 선물 받을 주소를 `mailbox`로 내려준다. 단, 정상 진행 상태 중 `DELIVERED` 전 상태(`RECEIVED`, `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED`)에서만 내려준다.
|
||||
- 받는 크리에이터 상세에는 크리에이터 본인이 입력한 받는 주소의 이름, 휴대폰 번호, 주소를 내려준다.
|
||||
- 주소 형식은 `(우편번호) 주소, 상세주소` 문자열로 내려준다.
|
||||
- 로그인 회원이 알아야 하는 데이터가 아니거나 아직 입력되지 않은 데이터는 `null`로 내려준다.
|
||||
@@ -384,8 +385,8 @@ Response:
|
||||
{
|
||||
"applicationNo": "A-1002609300001",
|
||||
"direction": "SENT",
|
||||
"status": "TRACKING_REGISTERED",
|
||||
"statusName": "발송 확인",
|
||||
"status": "RECEIVED",
|
||||
"statusName": "접수 완료",
|
||||
"giftInfo": {
|
||||
"recipientCreatorNickname": "크리에이터닉네임",
|
||||
"senderNickname": null,
|
||||
@@ -393,7 +394,7 @@ Response:
|
||||
"categoryName": "인형",
|
||||
"applicationNo": "A-1002609300001",
|
||||
"paidCan": 80,
|
||||
"tracking": "CJ대한통운_1234567890",
|
||||
"tracking": null,
|
||||
"shippingRequestedAt": null
|
||||
},
|
||||
"senderInfo": {
|
||||
@@ -403,11 +404,11 @@ Response:
|
||||
},
|
||||
"recipientAddress": null,
|
||||
"mailbox": {
|
||||
"name": "소다라이브 사서함",
|
||||
"address": "구현 후 제공",
|
||||
"phoneNumber": "구현 후 제공"
|
||||
"name": "소다라이브 선물 담당자",
|
||||
"address": "(06234) 서울시 강남구 ..., 3층 선물 접수처",
|
||||
"phoneNumber": "01012345678"
|
||||
},
|
||||
"trackingRequired": false,
|
||||
"trackingRequired": true,
|
||||
"recipientAddressRequired": false,
|
||||
"recipientAddressDeadlineAt": "2026-10-09T03:00:00Z",
|
||||
"delivery": {
|
||||
@@ -446,6 +447,8 @@ Response:
|
||||
}
|
||||
```
|
||||
|
||||
`mailbox`는 관리자가 등록한 전역 단일 받을 주소다. 보낸 사람 관점(`direction=SENT`)이고 정상 진행 상태 중 `DELIVERED` 전 상태(`RECEIVED`, `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED`)일 때 내려준다. 받을 주소가 등록되지 않았거나, 다른 방향이거나, `DELIVERED`/`UNDELIVERABLE`/`CANCELED` 상태이면 `mailbox`는 `null`이다. 주소는 상세 조회 시점의 최신 관리자 설정을 사용한다.
|
||||
|
||||
받는 크리에이터 관점 응답 예시는 다음과 같다.
|
||||
|
||||
```json
|
||||
@@ -514,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`이다.
|
||||
@@ -802,6 +805,40 @@ Response:
|
||||
|
||||
크리에이터가 배송지를 아직 입력하지 않은 경우에도 `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 |
|
||||
@@ -809,7 +846,8 @@ Response:
|
||||
| `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` | `[]` |
|
||||
@@ -821,7 +859,9 @@ Response:
|
||||
| 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:
|
||||
|
||||
@@ -838,9 +878,10 @@ Response:
|
||||
| 운송장 등록 기한 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. 푸시 알림 정책
|
||||
|
||||
|
||||
+10
@@ -5,6 +5,8 @@ import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftCategoryRequest
|
||||
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftCategoryResponse
|
||||
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftDetailResponse
|
||||
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftListResponse
|
||||
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftMailboxRequest
|
||||
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftMailboxResponse
|
||||
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftMarkUndeliverableRequest
|
||||
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftOperationStatusResponse
|
||||
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftSizePriceRequest
|
||||
@@ -45,6 +47,14 @@ class AdminGiftController(
|
||||
AdminGiftDetailResponse.from(queryService.getGiftDetail(applicationNo))
|
||||
)
|
||||
|
||||
@GetMapping("/gift-mailbox")
|
||||
fun getMailbox() = ApiResponse.ok(service.getMailbox()?.let { AdminGiftMailboxResponse.from(it) })
|
||||
|
||||
@PutMapping("/gift-mailbox")
|
||||
fun updateMailbox(@RequestBody request: AdminGiftMailboxRequest) = ApiResponse.ok(
|
||||
AdminGiftMailboxResponse.from(service.updateMailbox(request.toCommand()))
|
||||
)
|
||||
|
||||
@GetMapping("/gift-categories")
|
||||
fun getCategories() = ApiResponse.ok(service.getCategories().map { AdminGiftCategoryResponse.from(it) })
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.gift.dto
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftCategoryCommand
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftMailboxCommand
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftSizePriceCommand
|
||||
|
||||
data class AdminGiftCategoryRequest(
|
||||
@@ -35,6 +36,22 @@ data class AdminGiftSizePriceRequest(
|
||||
)
|
||||
}
|
||||
|
||||
data class AdminGiftMailboxRequest(
|
||||
val name: String,
|
||||
val phoneNumber: String,
|
||||
val zipCode: String,
|
||||
val address: String,
|
||||
val addressDetail: String?
|
||||
) {
|
||||
fun toCommand() = GiftMailboxCommand(
|
||||
name = name,
|
||||
phoneNumber = phoneNumber,
|
||||
zipCode = zipCode,
|
||||
address = address,
|
||||
addressDetail = addressDetail
|
||||
)
|
||||
}
|
||||
|
||||
data class AdminGiftMarkUndeliverableRequest(
|
||||
val reason: String
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftListResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftMemberInfoResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftProductInfoResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftCategoryResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftMailboxResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftOperationStatusResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftSizePriceResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
|
||||
@@ -70,6 +71,20 @@ data class AdminGiftOperationStatusResponse(
|
||||
}
|
||||
}
|
||||
|
||||
data class AdminGiftMailboxResponse(
|
||||
val name: String,
|
||||
val address: String,
|
||||
val phoneNumber: String
|
||||
) {
|
||||
companion object {
|
||||
fun from(result: GiftMailboxResult) = AdminGiftMailboxResponse(
|
||||
name = result.name,
|
||||
address = result.address,
|
||||
phoneNumber = result.phoneNumber
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class AdminGiftListResponse(
|
||||
val totalCount: Long,
|
||||
val items: List<AdminGiftListItemResponse>,
|
||||
|
||||
@@ -59,6 +59,9 @@ data class GiftListResponse(
|
||||
priceCan = it.priceCan,
|
||||
categoryName = it.categoryName,
|
||||
sizeName = it.sizeName,
|
||||
counterpartMemberId = it.counterpartMemberId,
|
||||
counterpartNickname = it.counterpartNickname,
|
||||
counterpartProfileImageUrl = it.counterpartProfileImageUrl,
|
||||
createdAt = it.createdAt?.toUtcIso()
|
||||
)
|
||||
},
|
||||
@@ -77,6 +80,9 @@ data class GiftListItemResponse(
|
||||
val priceCan: Int,
|
||||
val categoryName: String,
|
||||
val sizeName: String,
|
||||
val counterpartMemberId: Long,
|
||||
val counterpartNickname: String,
|
||||
val counterpartProfileImageUrl: String,
|
||||
val createdAt: String?
|
||||
)
|
||||
|
||||
|
||||
+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() }
|
||||
}
|
||||
|
||||
+33
@@ -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
|
||||
}
|
||||
}
|
||||
+5
@@ -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>
|
||||
+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()
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailbox
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailboxRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePrice
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository
|
||||
@@ -41,6 +43,14 @@ data class GiftSizePriceCommand(
|
||||
val isActive: Boolean
|
||||
)
|
||||
|
||||
data class GiftMailboxCommand(
|
||||
val name: String,
|
||||
val phoneNumber: String,
|
||||
val zipCode: String,
|
||||
val address: String,
|
||||
val addressDetail: String?
|
||||
)
|
||||
|
||||
data class GiftSizePriceResult(
|
||||
val sizeCode: GiftSize,
|
||||
val sizeName: String,
|
||||
@@ -62,8 +72,31 @@ class GiftAdminService(
|
||||
private val sizePriceRepository: GiftSizePriceRepository,
|
||||
private val giftRepository: GiftRepository,
|
||||
private val giftDeliveryRepository: GiftDeliveryRepository,
|
||||
private val giftMailboxRepository: GiftMailboxRepository,
|
||||
private val giftPushService: GiftPushService
|
||||
) {
|
||||
@Transactional(readOnly = true)
|
||||
fun getMailbox(): GiftMailboxResult? = giftMailboxRepository.findByIdOrNull(GiftMailbox.SINGLETON_ID)?.toResult()
|
||||
|
||||
@Transactional
|
||||
fun updateMailbox(command: GiftMailboxCommand): GiftMailboxResult {
|
||||
validateMailbox(command)
|
||||
val mailbox = giftMailboxRepository.findByIdOrNull(GiftMailbox.SINGLETON_ID)
|
||||
?: GiftMailbox(
|
||||
name = command.name,
|
||||
phoneNumber = command.phoneNumber,
|
||||
zipCode = command.zipCode,
|
||||
address = command.address,
|
||||
addressDetail = command.addressDetail?.takeIf { it.isNotBlank() }
|
||||
)
|
||||
mailbox.name = command.name
|
||||
mailbox.phoneNumber = command.phoneNumber
|
||||
mailbox.zipCode = command.zipCode
|
||||
mailbox.address = command.address
|
||||
mailbox.addressDetail = command.addressDetail?.takeIf { it.isNotBlank() }
|
||||
return giftMailboxRepository.save(mailbox).toResult()
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getCategories(): List<GiftCategoryResult> = categoryRepository.findAll().map { it.toResult() }
|
||||
|
||||
@@ -170,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
|
||||
@@ -198,6 +232,22 @@ class GiftAdminService(
|
||||
if (command.salePriceCan <= 0 || command.salePriceCan > command.basePriceCan) throw invalidRequest()
|
||||
}
|
||||
|
||||
private fun validateMailbox(command: GiftMailboxCommand) {
|
||||
if (
|
||||
command.name.isBlank() ||
|
||||
command.name.length > 50 ||
|
||||
command.phoneNumber.isBlank() ||
|
||||
command.phoneNumber.length > 30 ||
|
||||
command.zipCode.isBlank() ||
|
||||
command.zipCode.length > 20 ||
|
||||
command.address.isBlank() ||
|
||||
command.address.length > 255 ||
|
||||
(command.addressDetail?.length ?: 0) > 255
|
||||
) {
|
||||
throw invalidRequest()
|
||||
}
|
||||
}
|
||||
|
||||
private fun GiftCategory.toResult() = GiftCategoryResult(
|
||||
categoryId = id ?: throw invalidRequest(),
|
||||
classificationNumber = classificationNumber,
|
||||
@@ -217,6 +267,15 @@ class GiftAdminService(
|
||||
isActive = isActive
|
||||
)
|
||||
|
||||
private fun GiftMailbox.toResult() = GiftMailboxResult(
|
||||
name = name,
|
||||
address = formatAddress(zipCode, address, addressDetail),
|
||||
phoneNumber = phoneNumber
|
||||
)
|
||||
|
||||
private fun formatAddress(zipCode: String, address: String, detail: String?) =
|
||||
"($zipCode) $address" + if (detail.isNullOrBlank()) "" else ", $detail"
|
||||
|
||||
private fun invalidRequest() = SodaException(messageKey = "common.error.invalid_request")
|
||||
|
||||
private companion object {
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.application
|
||||
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailbox
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailboxRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftDirection
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftListType
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.data.domain.Sort
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
@@ -39,6 +43,9 @@ data class GiftListItemResult(
|
||||
val priceCan: Int,
|
||||
val categoryName: String,
|
||||
val sizeName: String,
|
||||
val counterpartMemberId: Long,
|
||||
val counterpartNickname: String,
|
||||
val counterpartProfileImageUrl: String,
|
||||
val createdAt: LocalDateTime?
|
||||
)
|
||||
|
||||
@@ -99,7 +106,10 @@ class GiftQueryService(
|
||||
private val sizePriceRepository: GiftSizePriceRepository,
|
||||
private val giftRepository: GiftRepository,
|
||||
private val deliveryRepository: GiftDeliveryRepository,
|
||||
private val memberRepository: MemberRepository
|
||||
private val memberRepository: MemberRepository,
|
||||
private val giftMailboxRepository: GiftMailboxRepository,
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val cloudFrontHost: String
|
||||
) {
|
||||
@Transactional(readOnly = true)
|
||||
fun getFormOptions() = GiftFormOptionsResult(
|
||||
@@ -140,9 +150,13 @@ class GiftQueryService(
|
||||
)
|
||||
}
|
||||
|
||||
val membersById = memberRepository.findAllById(
|
||||
gifts.content.flatMap { listOf(it.senderMemberId, it.recipientMemberId) }.toSet()
|
||||
).associateBy { it.id ?: throw invalidRequest() }
|
||||
|
||||
return GiftListResult(
|
||||
totalCount = gifts.totalElements,
|
||||
items = gifts.content.map { it.toGiftListItem(memberId) },
|
||||
items = gifts.content.map { it.toGiftListItem(memberId, membersById) },
|
||||
page = normalizedPage,
|
||||
size = normalizedSize,
|
||||
hasNext = gifts.hasNext()
|
||||
@@ -175,7 +189,7 @@ class GiftQueryService(
|
||||
),
|
||||
senderInfo = delivery.toSenderInfo().takeIf { direction == GiftDirection.SENT },
|
||||
recipientAddress = recipientAddress,
|
||||
mailbox = null,
|
||||
mailbox = mailboxFor(direction, gift.status),
|
||||
trackingRequired = direction == GiftDirection.SENT && gift.status == GiftStatus.RECEIVED,
|
||||
recipientAddressRequired = direction == GiftDirection.RECEIVED && recipientAddress == null &&
|
||||
gift.status !in TERMINAL_STATUSES,
|
||||
@@ -189,16 +203,31 @@ class GiftQueryService(
|
||||
)
|
||||
}
|
||||
|
||||
private fun Gift.toGiftListItem(memberId: Long) = GiftListItemResult(
|
||||
applicationNo = applicationNo,
|
||||
direction = if (senderMemberId == memberId) GiftDirection.SENT else GiftDirection.RECEIVED,
|
||||
status = status,
|
||||
statusName = status.displayName,
|
||||
priceCan = salePriceCan,
|
||||
categoryName = categoryNameSnapshot,
|
||||
sizeName = sizeCode.displayName,
|
||||
createdAt = createdAt
|
||||
)
|
||||
private fun Gift.toGiftListItem(memberId: Long, membersById: Map<Long, Member>): GiftListItemResult {
|
||||
val direction = if (senderMemberId == memberId) GiftDirection.SENT else GiftDirection.RECEIVED
|
||||
val counterpartId = if (direction == GiftDirection.SENT) recipientMemberId else senderMemberId
|
||||
val counterpart = membersById[counterpartId] ?: throw invalidRequest()
|
||||
|
||||
return GiftListItemResult(
|
||||
applicationNo = applicationNo,
|
||||
direction = direction,
|
||||
status = status,
|
||||
statusName = status.displayName,
|
||||
priceCan = salePriceCan,
|
||||
categoryName = categoryNameSnapshot,
|
||||
sizeName = sizeCode.displayName,
|
||||
counterpartMemberId = counterpartId,
|
||||
counterpartNickname = counterpart.nickname,
|
||||
counterpartProfileImageUrl = counterpart.profileImageUrl(),
|
||||
createdAt = createdAt
|
||||
)
|
||||
}
|
||||
|
||||
private fun Member.profileImageUrl() = if (profileImage.isNullOrBlank()) {
|
||||
"$cloudFrontHost/profile/default-profile.png"
|
||||
} else {
|
||||
"$cloudFrontHost/$profileImage"
|
||||
}
|
||||
|
||||
private fun Gift.directionFor(memberId: Long) = when {
|
||||
senderMemberId == memberId -> GiftDirection.SENT
|
||||
@@ -221,6 +250,17 @@ class GiftQueryService(
|
||||
return GiftAddressResult(name, phoneNumber, formatAddress(zipCode, address, recipientAddressDetail))
|
||||
}
|
||||
|
||||
private fun mailboxFor(direction: GiftDirection, status: GiftStatus): GiftMailboxResult? {
|
||||
if (direction != GiftDirection.SENT || status 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) {
|
||||
"${courierCompanyName}_$trackingNumber"
|
||||
} else {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+64
-2
@@ -59,6 +59,54 @@ class AdminGiftControllerTest @Autowired constructor(
|
||||
categoryRepository.deleteAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("관리자는 받을 주소를 조회하고 저장한다")
|
||||
fun shouldManageGiftMailbox() {
|
||||
mockMvc.perform(get("/api/v2/admin/gift-mailbox").with(user("admin").roles("ADMIN")))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data").isEmpty)
|
||||
|
||||
mockMvc.perform(
|
||||
put("/api/v2/admin/gift-mailbox")
|
||||
.with(user("admin").roles("ADMIN"))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(
|
||||
"""
|
||||
{
|
||||
"name":"소다라이브 선물 담당자",
|
||||
"phoneNumber":"01012345678",
|
||||
"zipCode":"06234",
|
||||
"address":"서울시 강남구",
|
||||
"addressDetail":"3층 선물 접수처"
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.name").value("소다라이브 선물 담당자"))
|
||||
.andExpect(jsonPath("$.data.phoneNumber").value("01012345678"))
|
||||
.andExpect(jsonPath("$.data.address").value("(06234) 서울시 강남구, 3층 선물 접수처"))
|
||||
|
||||
mockMvc.perform(get("/api/v2/admin/gift-mailbox").with(user("admin").roles("ADMIN")))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.name").value("소다라이브 선물 담당자"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("관리자는 공백 받을 주소를 저장할 수 없다")
|
||||
fun shouldRejectInvalidGiftMailbox() {
|
||||
mockMvc.perform(
|
||||
put("/api/v2/admin/gift-mailbox")
|
||||
.with(user("admin").roles("ADMIN"))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"name":" ","phoneNumber":"01012345678","zipCode":"06234","address":"서울시 강남구"}""")
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("관리자는 전체 선물함 목록을 필터링해 조회한다")
|
||||
fun shouldListAdminGifts() {
|
||||
@@ -284,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)
|
||||
@@ -294,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,
|
||||
@@ -320,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)
|
||||
)
|
||||
|
||||
+90
-6
@@ -14,6 +14,8 @@ import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailbox
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailboxRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePrice
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository
|
||||
@@ -46,7 +48,9 @@ import java.time.LocalDateTime
|
||||
|
||||
@SpringBootTest(
|
||||
properties = [
|
||||
"spring.datasource.url=jdbc:h2:mem:gift-controller;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"
|
||||
"spring.datasource.url=" +
|
||||
"jdbc:h2:mem:gift-controller;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE",
|
||||
"cloud.aws.cloud-front.host=https://cdn.test"
|
||||
]
|
||||
)
|
||||
@AutoConfigureMockMvc
|
||||
@@ -58,6 +62,7 @@ class GiftControllerTest @Autowired constructor(
|
||||
private val giftDeliveryRepository: GiftDeliveryRepository,
|
||||
private val sizePriceRepository: GiftSizePriceRepository,
|
||||
private val memberRepository: MemberRepository,
|
||||
private val giftMailboxRepository: GiftMailboxRepository,
|
||||
private val objectMapper: ObjectMapper
|
||||
) {
|
||||
@MockBean
|
||||
@@ -86,13 +91,26 @@ class GiftControllerTest @Autowired constructor(
|
||||
@Test
|
||||
@DisplayName("회원은 선물함 목록에서 보낸 선물과 노출 가능한 받은 선물을 페이징 응답으로 조회한다")
|
||||
fun shouldListGifts() {
|
||||
val member = Member(password = "password", nickname = "member").apply { id = 10L }
|
||||
saveGift("G-CONTROLLER-SENT", senderMemberId = 10L, recipientMemberId = 20L, status = GiftStatus.RECEIVED)
|
||||
saveGift("G-CONTROLLER-HIDDEN", senderMemberId = 20L, recipientMemberId = 10L, status = GiftStatus.RECEIVED)
|
||||
val member = memberRepository.save(Member(password = "password", nickname = "member"))
|
||||
val counterpart = memberRepository.save(
|
||||
Member(password = "password", nickname = "counterpart", profileImage = "profile/counterpart.png")
|
||||
)
|
||||
saveGift(
|
||||
"G-CONTROLLER-SENT",
|
||||
senderMemberId = member.id!!,
|
||||
recipientMemberId = counterpart.id!!,
|
||||
status = GiftStatus.RECEIVED
|
||||
)
|
||||
saveGift(
|
||||
"G-CONTROLLER-HIDDEN",
|
||||
senderMemberId = counterpart.id!!,
|
||||
recipientMemberId = member.id!!,
|
||||
status = GiftStatus.RECEIVED
|
||||
)
|
||||
saveGift(
|
||||
"G-CONTROLLER-VISIBLE",
|
||||
senderMemberId = 20L,
|
||||
recipientMemberId = 10L,
|
||||
senderMemberId = counterpart.id!!,
|
||||
recipientMemberId = member.id!!,
|
||||
status = GiftStatus.TRACKING_REGISTERED
|
||||
)
|
||||
|
||||
@@ -117,6 +135,9 @@ class GiftControllerTest @Autowired constructor(
|
||||
.andExpect(jsonPath("$.data.items[0].priceCan").value(80))
|
||||
.andExpect(jsonPath("$.data.items[0].categoryName").value("인형"))
|
||||
.andExpect(jsonPath("$.data.items[0].sizeName").value("소형"))
|
||||
.andExpect(jsonPath("$.data.items[0].counterpartMemberId").value(counterpart.id!!))
|
||||
.andExpect(jsonPath("$.data.items[0].counterpartNickname").value("counterpart"))
|
||||
.andExpect(jsonPath("$.data.items[0].counterpartProfileImageUrl").value("https://cdn.test/profile/counterpart.png"))
|
||||
.andExpect(jsonPath("$.data.items[0].createdAt").value(endsWith("Z")))
|
||||
}
|
||||
|
||||
@@ -172,6 +193,57 @@ class GiftControllerTest @Autowired constructor(
|
||||
.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
|
||||
@DisplayName("팬은 POST /api/v2/gifts로 선물을 신청하고 접수 결과를 받는다")
|
||||
fun shouldRegisterGift() {
|
||||
@@ -445,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
|
||||
|
||||
+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,
|
||||
|
||||
+104
-2
@@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailboxRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
|
||||
@@ -15,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
|
||||
@@ -33,7 +36,8 @@ class GiftAdminServiceTest @Autowired constructor(
|
||||
private val categoryRepository: GiftCategoryRepository,
|
||||
private val sizePriceRepository: GiftSizePriceRepository,
|
||||
private val giftRepository: GiftRepository,
|
||||
private val giftDeliveryRepository: GiftDeliveryRepository
|
||||
private val giftDeliveryRepository: GiftDeliveryRepository,
|
||||
private val giftMailboxRepository: GiftMailboxRepository
|
||||
) {
|
||||
private val giftPushService = Mockito.mock(GiftPushService::class.java)
|
||||
private val service = GiftAdminService(
|
||||
@@ -41,9 +45,61 @@ class GiftAdminServiceTest @Autowired constructor(
|
||||
sizePriceRepository,
|
||||
giftRepository,
|
||||
giftDeliveryRepository,
|
||||
giftMailboxRepository,
|
||||
giftPushService
|
||||
)
|
||||
|
||||
@Test
|
||||
@DisplayName("받을 주소는 미등록이면 null이고 저장 후 단일 설정으로 갱신된다")
|
||||
fun shouldUpsertGiftMailbox() {
|
||||
assertEquals(null, service.getMailbox())
|
||||
|
||||
val created = service.updateMailbox(
|
||||
GiftMailboxCommand(
|
||||
name = "소다라이브 선물 담당자",
|
||||
phoneNumber = "01012345678",
|
||||
zipCode = "06234",
|
||||
address = "서울시 강남구",
|
||||
addressDetail = "3층 선물 접수처"
|
||||
)
|
||||
)
|
||||
val updated = service.updateMailbox(
|
||||
GiftMailboxCommand(
|
||||
name = "소다라이브 운영팀",
|
||||
phoneNumber = "01087654321",
|
||||
zipCode = "04524",
|
||||
address = "서울시 중구",
|
||||
addressDetail = null
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals("소다라이브 선물 담당자", created.name)
|
||||
assertEquals("(06234) 서울시 강남구, 3층 선물 접수처", created.address)
|
||||
assertEquals("01012345678", created.phoneNumber)
|
||||
assertEquals("소다라이브 운영팀", updated.name)
|
||||
assertEquals("(04524) 서울시 중구", updated.address)
|
||||
assertEquals(1, giftMailboxRepository.count())
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("받을 주소 필수값이 공백이면 저장할 수 없다")
|
||||
fun shouldRejectInvalidGiftMailbox() {
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
service.updateMailbox(
|
||||
GiftMailboxCommand(
|
||||
name = " ",
|
||||
phoneNumber = "01012345678",
|
||||
zipCode = "06234",
|
||||
address = "서울시 강남구",
|
||||
addressDetail = null
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals("common.error.invalid_request", exception.messageKey)
|
||||
assertEquals(0, giftMailboxRepository.count())
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("카테고리를 등록, 수정, 논리삭제한다")
|
||||
fun shouldCreateUpdateAndDeactivateCategory() {
|
||||
@@ -194,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)
|
||||
|
||||
@@ -208,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)
|
||||
@@ -217,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(
|
||||
|
||||
+168
-17
@@ -9,6 +9,8 @@ import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailbox
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailboxRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePrice
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository
|
||||
@@ -39,7 +41,8 @@ class GiftQueryServiceTest @Autowired constructor(
|
||||
private val sizePriceRepository: GiftSizePriceRepository,
|
||||
private val giftRepository: GiftRepository,
|
||||
private val deliveryRepository: GiftDeliveryRepository,
|
||||
private val memberRepository: MemberRepository
|
||||
private val memberRepository: MemberRepository,
|
||||
private val giftMailboxRepository: GiftMailboxRepository
|
||||
) {
|
||||
@Test
|
||||
fun `폼 옵션은 활성 카테고리와 활성 사이즈 가격만 반환한다`() {
|
||||
@@ -61,10 +64,17 @@ class GiftQueryServiceTest @Autowired constructor(
|
||||
@Test
|
||||
@DisplayName("보낸 선물은 접수 직후 선물함에 노출된다")
|
||||
fun shouldListReceivedGiftForSender() {
|
||||
saveGift("G-SENT-RECEIVED", senderMemberId = MEMBER_ID, recipientMemberId = 2L, status = GiftStatus.RECEIVED)
|
||||
val sender = saveMember("fan")
|
||||
val recipient = saveMember("creator", "profile/creator.png")
|
||||
saveGift(
|
||||
"G-SENT-RECEIVED",
|
||||
senderMemberId = sender.id!!,
|
||||
recipientMemberId = recipient.id!!,
|
||||
status = GiftStatus.RECEIVED
|
||||
)
|
||||
|
||||
val result = queryService().getGifts(
|
||||
memberId = MEMBER_ID,
|
||||
memberId = sender.id!!,
|
||||
type = GiftListType.SENT,
|
||||
page = 0,
|
||||
size = 20
|
||||
@@ -79,22 +89,33 @@ class GiftQueryServiceTest @Autowired constructor(
|
||||
assertEquals(80, result.items.single().priceCan)
|
||||
assertEquals("인형", result.items.single().categoryName)
|
||||
assertEquals("소형", result.items.single().sizeName)
|
||||
assertEquals(recipient.id, result.items.single().counterpartMemberId)
|
||||
assertEquals("creator", result.items.single().counterpartNickname)
|
||||
assertEquals("https://cdn.test/profile/creator.png", result.items.single().counterpartProfileImageUrl)
|
||||
assertNotNull(result.items.single().createdAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("받은 선물은 운송장 등록 이후 상태만 선물함에 노출된다")
|
||||
fun shouldExcludeReceivedOnlyGiftAndListVisibleGiftForRecipient() {
|
||||
saveGift("G-RECEIVED-HIDDEN", senderMemberId = 2L, recipientMemberId = MEMBER_ID, status = GiftStatus.RECEIVED)
|
||||
val recipient = saveMember("creator")
|
||||
val hiddenSender = saveMember("hidden-fan")
|
||||
val visibleSender = saveMember("visible-fan")
|
||||
saveGift(
|
||||
"G-RECEIVED-HIDDEN",
|
||||
senderMemberId = hiddenSender.id!!,
|
||||
recipientMemberId = recipient.id!!,
|
||||
status = GiftStatus.RECEIVED
|
||||
)
|
||||
saveGift(
|
||||
"G-RECEIVED-VISIBLE",
|
||||
senderMemberId = 3L,
|
||||
recipientMemberId = MEMBER_ID,
|
||||
senderMemberId = visibleSender.id!!,
|
||||
recipientMemberId = recipient.id!!,
|
||||
status = GiftStatus.TRACKING_REGISTERED
|
||||
)
|
||||
|
||||
val result = queryService().getGifts(
|
||||
memberId = MEMBER_ID,
|
||||
memberId = recipient.id!!,
|
||||
type = GiftListType.RECEIVED,
|
||||
page = 0,
|
||||
size = 20
|
||||
@@ -103,22 +124,29 @@ class GiftQueryServiceTest @Autowired constructor(
|
||||
assertEquals(1L, result.totalCount)
|
||||
assertEquals(listOf("G-RECEIVED-VISIBLE"), result.items.map { it.applicationNo })
|
||||
assertEquals(GiftDirection.RECEIVED, result.items.single().direction)
|
||||
assertEquals(visibleSender.id, result.items.single().counterpartMemberId)
|
||||
assertEquals("visible-fan", result.items.single().counterpartNickname)
|
||||
assertEquals("https://cdn.test/profile/default-profile.png", result.items.single().counterpartProfileImageUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("전체 선물함은 보낸 선물과 노출 가능한 받은 선물을 방향과 함께 반환한다")
|
||||
fun shouldListSentAndVisibleReceivedGiftsWithDirectionsForAll() {
|
||||
saveGift("G-ALL-SENT", senderMemberId = MEMBER_ID, recipientMemberId = 2L, status = GiftStatus.CANCELED)
|
||||
val member = saveMember("member")
|
||||
val creator = saveMember("creator")
|
||||
val fan = saveMember("fan")
|
||||
val hiddenFan = saveMember("hidden-fan")
|
||||
saveGift("G-ALL-SENT", senderMemberId = member.id!!, recipientMemberId = creator.id!!, status = GiftStatus.CANCELED)
|
||||
saveGift(
|
||||
"G-ALL-RECEIVED",
|
||||
senderMemberId = 3L,
|
||||
recipientMemberId = MEMBER_ID,
|
||||
senderMemberId = fan.id!!,
|
||||
recipientMemberId = member.id!!,
|
||||
status = GiftStatus.ARRIVED_AT_MAILBOX
|
||||
)
|
||||
saveGift("G-ALL-HIDDEN", senderMemberId = 4L, recipientMemberId = MEMBER_ID, status = GiftStatus.RECEIVED)
|
||||
saveGift("G-ALL-HIDDEN", senderMemberId = hiddenFan.id!!, recipientMemberId = member.id!!, status = GiftStatus.RECEIVED)
|
||||
|
||||
val result = queryService().getGifts(
|
||||
memberId = MEMBER_ID,
|
||||
memberId = member.id!!,
|
||||
type = GiftListType.ALL,
|
||||
page = 0,
|
||||
size = 20
|
||||
@@ -129,15 +157,21 @@ class GiftQueryServiceTest @Autowired constructor(
|
||||
mapOf("G-ALL-SENT" to GiftDirection.SENT, "G-ALL-RECEIVED" to GiftDirection.RECEIVED),
|
||||
result.items.associate { it.applicationNo to it.direction }
|
||||
)
|
||||
assertEquals(
|
||||
mapOf("G-ALL-SENT" to creator.id, "G-ALL-RECEIVED" to fan.id),
|
||||
result.items.associate { it.applicationNo to it.counterpartMemberId }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("선물함 page와 size는 음수 또는 0이면 기본값으로 보정된다")
|
||||
fun shouldNormalizeGiftListPageAndSize() {
|
||||
saveGift("G-PAGING", senderMemberId = MEMBER_ID, recipientMemberId = 2L, status = GiftStatus.RECEIVED)
|
||||
val sender = saveMember("fan")
|
||||
val recipient = saveMember("creator")
|
||||
saveGift("G-PAGING", senderMemberId = sender.id!!, recipientMemberId = recipient.id!!, status = GiftStatus.RECEIVED)
|
||||
|
||||
val result = queryService().getGifts(
|
||||
memberId = MEMBER_ID,
|
||||
memberId = sender.id!!,
|
||||
type = GiftListType.SENT,
|
||||
page = -1,
|
||||
size = 0
|
||||
@@ -199,6 +233,108 @@ class GiftQueryServiceTest @Autowired constructor(
|
||||
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
|
||||
@DisplayName("수신자는 노출된 선물 상세에서 자신의 배송지와 발신자 닉네임만 조회한다")
|
||||
fun shouldReturnReceivedGiftDetailWithoutSenderInformation() {
|
||||
@@ -379,7 +515,9 @@ class GiftQueryServiceTest @Autowired constructor(
|
||||
)
|
||||
)
|
||||
|
||||
private fun saveMember(nickname: String) = memberRepository.save(Member(password = "password", nickname = nickname))
|
||||
private fun saveMember(nickname: String, profileImage: String? = null) = memberRepository.save(
|
||||
Member(password = "password", nickname = nickname, profileImage = profileImage)
|
||||
)
|
||||
|
||||
private fun giftCategory(
|
||||
classificationNumber: String,
|
||||
@@ -411,16 +549,29 @@ 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,
|
||||
giftRepository,
|
||||
deliveryRepository,
|
||||
memberRepository
|
||||
memberRepository,
|
||||
giftMailboxRepository,
|
||||
"https://cdn.test"
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val MEMBER_ID = 1L
|
||||
val NOW: LocalDateTime = LocalDateTime.of(2026, 9, 29, 12, 0)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user