Compare commits
36
Commits
main
...
c073c48538
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c073c48538 | ||
|
|
5494a49748 | ||
|
|
72e5612b1c | ||
|
|
a95c91d31f | ||
|
|
15ce9aab41 | ||
|
|
b5a5d1318b | ||
|
|
059854c19f | ||
|
|
375be9e9a9 | ||
|
|
afe92829dc | ||
|
|
22c6cd2d38 | ||
|
|
2bda30e7e2 | ||
|
|
f3f46c413c | ||
|
|
a6ba46e017 | ||
|
|
13a842a9a5 | ||
|
|
dda5de4d61 | ||
|
|
38941d9fe9 | ||
|
|
631c985a24 | ||
|
|
cd949a4694 | ||
|
|
d360db6f08 | ||
|
|
6f9dcaa1e2 | ||
|
|
6a2ae38483 | ||
|
|
08347f9ab5 | ||
|
|
35b16d7370 | ||
|
|
0f727c8b75 | ||
|
|
6bf4a682bf | ||
|
|
fcb1c1e8d1 | ||
|
|
95b3b16897 | ||
|
|
71e7195414 | ||
|
|
4ac203be18 | ||
|
|
57bb57ffb5 | ||
|
|
760005c266 | ||
|
|
8ae722c8df | ||
|
|
21d6e755e9 | ||
|
|
3049184ea2 | ||
|
|
f3e6d40a4a | ||
|
|
c02f5f7fa8 |
@@ -0,0 +1,470 @@
|
||||
# 선물 관리자 페이지 구현 프롬프트
|
||||
|
||||
이 문서는 관리자 페이지의 선물 관련 3개 화면을 프론트엔드 구현 에이전트에게 전달하기 위한 프롬프트 모음이다.
|
||||
|
||||
공통 전제:
|
||||
|
||||
- 관리자 메뉴에는 parent 메뉴 `선물함 관리` 아래에 다음 3개 하위 메뉴가 있다.
|
||||
- `선물함 리스트` → `/gift/list`
|
||||
- `선물 카테고리` → `/gift/category`
|
||||
- `선물 사이즈` → `/gift/size`
|
||||
- 모든 API 응답은 기존 공통 envelope를 사용한다. 화면에서는 `response.data`를 실제 payload로 사용한다.
|
||||
- 인증/권한 처리는 기존 관리자 페이지의 API 클라이언트, 토큰 저장 방식, 에러 처리 방식을 따른다.
|
||||
- 새 디자인 시스템을 만들지 말고 기존 관리자 페이지의 테이블, 필터, 버튼, 모달, 폼, 토스트, 확인창 패턴을 재사용한다.
|
||||
- 화면 문구는 한국어로 작성한다.
|
||||
- API 실패 시 기존 관리자 페이지의 공통 에러 표시 방식을 따른다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 선물함 리스트 페이지 프롬프트
|
||||
|
||||
```text
|
||||
관리자 선물함 리스트 페이지를 구현해줘.
|
||||
|
||||
목표:
|
||||
- 관리자 메뉴 `선물함 관리 > 선물함 리스트`에서 전체 선물 신청 목록을 조회한다.
|
||||
- 상태, 신청번호, 닉네임으로 필터링할 수 있다.
|
||||
- 목록 row에서 선물 상세를 확인하고, 현재 상태에서 가능한 운영 액션을 실행할 수 있다.
|
||||
- 기존 관리자 페이지의 테이블/필터/페이지네이션/모달/확인창 스타일을 그대로 따른다.
|
||||
|
||||
라우트:
|
||||
- `/gift/list`
|
||||
|
||||
필수 화면 구성:
|
||||
- 상단 제목: `선물함 리스트`
|
||||
- 필터 영역:
|
||||
- 상태 select: 전체, 접수 완료, 발송 확인, 사서함 도착, 검수 완료, 전달 완료, 전달 불가, 신청 취소
|
||||
- 신청번호 검색 input: `applicationNo` 부분 검색
|
||||
- 닉네임 검색 input: 발송인 또는 수취인 닉네임 부분 검색
|
||||
- 검색 버튼, 초기화 버튼
|
||||
- 목록 테이블 컬럼:
|
||||
- 신청번호 `applicationNo`
|
||||
- 발송인 닉네임 `senderNickname`
|
||||
- 수취인 닉네임 `recipientNickname`
|
||||
- 사이즈 `sizeName` / `sizeCode`
|
||||
- 카테고리 `categoryName`
|
||||
- 분류번호 `classificationNumber`
|
||||
- 택배사 `courierCompanyName`
|
||||
- 운송장번호 `trackingNumber`
|
||||
- 상태 `statusName`
|
||||
- 액션 버튼
|
||||
- 페이지네이션:
|
||||
- 서버의 `page`, `size`, `totalCount`, `hasNext` 기준으로 기존 관리자 페이지 패턴에 맞춘다.
|
||||
|
||||
목록 조회 API:
|
||||
- Method: `GET`
|
||||
- URL: `/api/v2/admin/gifts`
|
||||
- Query:
|
||||
- `status?: GiftStatus`
|
||||
- `applicationNo?: string`
|
||||
- `nickname?: string`
|
||||
- `page?: number` 기본 `0`
|
||||
- `size?: number` 기본 `20`
|
||||
- 필터 조합:
|
||||
- 선택 필터는 AND 조건이다.
|
||||
- `nickname`은 발송인/수취인 닉네임 중 하나에 포함되면 매칭된다.
|
||||
|
||||
목록 조회 response `data`:
|
||||
```ts
|
||||
type AdminGiftListResponse = {
|
||||
totalCount: number;
|
||||
items: AdminGiftListItemResponse[];
|
||||
page: number;
|
||||
size: number;
|
||||
hasNext: boolean;
|
||||
};
|
||||
|
||||
type AdminGiftListItemResponse = {
|
||||
applicationNo: string;
|
||||
senderNickname: string;
|
||||
recipientNickname: string;
|
||||
sizeCode: string;
|
||||
sizeName: string;
|
||||
categoryName: string;
|
||||
classificationNumber: string;
|
||||
courierCompanyName: string | null;
|
||||
trackingNumber: string | null;
|
||||
status: GiftStatus;
|
||||
statusName: string;
|
||||
availableActions: AdminGiftAction[];
|
||||
};
|
||||
```
|
||||
|
||||
상태 enum:
|
||||
```ts
|
||||
type GiftStatus =
|
||||
| "RECEIVED"
|
||||
| "TRACKING_REGISTERED"
|
||||
| "ARRIVED_AT_MAILBOX"
|
||||
| "INSPECTION_COMPLETED"
|
||||
| "DELIVERED"
|
||||
| "UNDELIVERABLE"
|
||||
| "CANCELED";
|
||||
```
|
||||
|
||||
상태 표시명:
|
||||
- `RECEIVED`: `접수 완료`
|
||||
- `TRACKING_REGISTERED`: `발송 확인`
|
||||
- `ARRIVED_AT_MAILBOX`: `사서함 도착`
|
||||
- `INSPECTION_COMPLETED`: `검수 완료`
|
||||
- `DELIVERED`: `전달 완료`
|
||||
- `UNDELIVERABLE`: `전달 불가`
|
||||
- `CANCELED`: `신청 취소`
|
||||
|
||||
상세 조회 API:
|
||||
- Method: `GET`
|
||||
- URL: `/api/v2/admin/gifts/{applicationNo}`
|
||||
- 용도:
|
||||
- 목록 row 클릭 또는 `상세` 버튼 클릭 시 상세 모달/상세 패널을 연다.
|
||||
|
||||
상세 response `data`:
|
||||
```ts
|
||||
type AdminGiftDetailResponse = {
|
||||
applicationNo: string;
|
||||
senderInfo: AdminGiftMemberInfoResponse;
|
||||
recipientInfo: AdminGiftMemberInfoResponse;
|
||||
productInfo: AdminGiftProductInfoResponse;
|
||||
inboundDeliveryInfo: AdminGiftInboundDeliveryInfoResponse;
|
||||
status: GiftStatus;
|
||||
statusName: string;
|
||||
availableActions: AdminGiftAction[];
|
||||
};
|
||||
|
||||
type AdminGiftMemberInfoResponse = {
|
||||
nickname: string;
|
||||
name: string;
|
||||
phoneNumber: string;
|
||||
address: string;
|
||||
};
|
||||
|
||||
type AdminGiftProductInfoResponse = {
|
||||
sizeCode: string;
|
||||
sizeName: string;
|
||||
categoryName: string;
|
||||
classificationNumber: string;
|
||||
};
|
||||
|
||||
type AdminGiftInboundDeliveryInfoResponse = {
|
||||
courierCompanyName: string | null;
|
||||
trackingNumber: string | null;
|
||||
};
|
||||
```
|
||||
|
||||
상세 표시 요구사항:
|
||||
- 발송인 정보: 닉네임, 이름, 전화번호, 주소
|
||||
- 수취인 정보: 닉네임, 이름, 전화번호, 주소
|
||||
- 상품 정보: 사이즈, 카테고리, 분류번호
|
||||
- 입고 배송 정보: 택배사, 운송장번호
|
||||
- 현재 상태와 가능한 액션
|
||||
- 수취인이 배송지를 아직 입력하지 않은 경우 `recipientInfo.nickname`은 표시하고 `name`, `phoneNumber`, `address`는 빈 문자열로 온다. 화면에서는 `미입력`으로 표시해도 된다.
|
||||
|
||||
운영 액션 enum:
|
||||
```ts
|
||||
type AdminGiftAction =
|
||||
| "ARRIVE_MAILBOX"
|
||||
| "COMPLETE_INSPECTION"
|
||||
| "COMPLETE_DELIVERY"
|
||||
| "MARK_UNDELIVERABLE";
|
||||
```
|
||||
|
||||
상태별 `availableActions`:
|
||||
- `RECEIVED`: 없음
|
||||
- `TRACKING_REGISTERED`: `ARRIVE_MAILBOX`, `MARK_UNDELIVERABLE`
|
||||
- `ARRIVED_AT_MAILBOX`: `COMPLETE_INSPECTION`, `MARK_UNDELIVERABLE`
|
||||
- `INSPECTION_COMPLETED`: `COMPLETE_DELIVERY`, `MARK_UNDELIVERABLE`
|
||||
- `DELIVERED`: 없음
|
||||
- `UNDELIVERABLE`: 없음
|
||||
- `CANCELED`: 없음
|
||||
|
||||
액션 버튼 문구:
|
||||
- `ARRIVE_MAILBOX`: `사서함 도착 처리`
|
||||
- `COMPLETE_INSPECTION`: `검수 완료 처리`
|
||||
- `COMPLETE_DELIVERY`: `전달 완료 처리`
|
||||
- `MARK_UNDELIVERABLE`: `전달 불가 처리`
|
||||
|
||||
액션 API:
|
||||
- 사서함 도착 처리
|
||||
- Method: `POST`
|
||||
- URL: `/api/v2/admin/gifts/{applicationNo}/arrive-mailbox`
|
||||
- Body: 없음
|
||||
- 검수 완료 처리
|
||||
- Method: `POST`
|
||||
- URL: `/api/v2/admin/gifts/{applicationNo}/complete-inspection`
|
||||
- Body: 없음
|
||||
- 전달 완료 처리
|
||||
- Method: `POST`
|
||||
- URL: `/api/v2/admin/gifts/{applicationNo}/complete-delivery`
|
||||
- Body: 없음
|
||||
- 전달 불가 처리
|
||||
- Method: `POST`
|
||||
- URL: `/api/v2/admin/gifts/{applicationNo}/mark-undeliverable`
|
||||
- Body:
|
||||
```ts
|
||||
type AdminGiftMarkUndeliverableRequest = {
|
||||
reason: string;
|
||||
};
|
||||
```
|
||||
|
||||
액션 response `data`:
|
||||
```ts
|
||||
type AdminGiftOperationStatusResponse = {
|
||||
applicationNo: string;
|
||||
status: GiftStatus;
|
||||
statusName: string;
|
||||
occurredAt: string; // UTC ISO string, e.g. 2026-09-30T12:00:00Z
|
||||
};
|
||||
```
|
||||
|
||||
액션 UX:
|
||||
- 모든 상태 변경 액션은 확인창을 띄운다.
|
||||
- `MARK_UNDELIVERABLE`은 사유 입력 모달을 띄운다.
|
||||
- 전달 불가 사유는 공백만 입력할 수 없고 255자 이내로 제한한다.
|
||||
- 액션 성공 후:
|
||||
- 상세 모달이 열려 있으면 상세를 다시 조회한다.
|
||||
- 목록도 현재 필터/페이지 기준으로 다시 조회한다.
|
||||
- 성공 토스트를 표시한다.
|
||||
- 액션 실패 시 기존 관리자 페이지 에러 토스트/알림 패턴을 따른다.
|
||||
|
||||
빈 값 표시:
|
||||
- `courierCompanyName`, `trackingNumber`가 null이면 `-`로 표시한다.
|
||||
- 수취인 개인정보가 빈 문자열이면 `미입력`으로 표시한다.
|
||||
|
||||
테스트/검증:
|
||||
- API 클라이언트 함수 단위 테스트 또는 페이지 테스트에서 query parameter가 올바르게 전달되는지 확인한다.
|
||||
- `availableActions`에 따라 버튼 노출이 달라지는지 확인한다.
|
||||
- 전달 불가 사유 빈 값 validation을 확인한다.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 선물 카테고리 CRUD 페이지 프롬프트
|
||||
|
||||
```text
|
||||
관리자 선물 카테고리 CRUD 페이지를 구현해줘.
|
||||
|
||||
목표:
|
||||
- 관리자 메뉴 `선물함 관리 > 선물 카테고리`에서 선물 카테고리를 조회, 등록, 수정, 비활성화한다.
|
||||
- 기존 관리자 페이지의 CRUD 테이블, 등록/수정 모달, 삭제 확인창, 토스트 패턴을 그대로 따른다.
|
||||
|
||||
라우트:
|
||||
- `/gift/category`
|
||||
|
||||
필수 화면 구성:
|
||||
- 상단 제목: `선물 카테고리`
|
||||
- 상단 우측 `카테고리 등록` 버튼
|
||||
- 목록 테이블 컬럼:
|
||||
- 카테고리 ID `categoryId`
|
||||
- 분류번호 `classificationNumber`
|
||||
- 카테고리 코드 `categoryCode`
|
||||
- 카테고리명 `name`
|
||||
- 접수 코드 `receiptCode`
|
||||
- 대표 품목 `representativeItem`
|
||||
- 파손면책 동의 필요 여부 `requiresDamageWaiver`
|
||||
- 활성 여부 `isActive`
|
||||
- 관리 버튼: 수정, 비활성화
|
||||
- 등록/수정 폼 필드:
|
||||
- `classificationNumber`
|
||||
- `categoryCode`
|
||||
- `name`
|
||||
- `receiptCode`
|
||||
- `representativeItem`
|
||||
- `requiresDamageWaiver`
|
||||
- `isActive`
|
||||
|
||||
목록 조회 API:
|
||||
- Method: `GET`
|
||||
- URL: `/api/v2/admin/gift-categories`
|
||||
- Request: 없음
|
||||
- Response `data`:
|
||||
```ts
|
||||
type AdminGiftCategoryResponse = {
|
||||
categoryId: number;
|
||||
classificationNumber: string;
|
||||
categoryCode: string;
|
||||
name: string;
|
||||
receiptCode: string;
|
||||
representativeItem: string;
|
||||
requiresDamageWaiver: boolean;
|
||||
isActive: boolean;
|
||||
};
|
||||
```
|
||||
|
||||
등록 API:
|
||||
- Method: `POST`
|
||||
- URL: `/api/v2/admin/gift-categories`
|
||||
- Body:
|
||||
```ts
|
||||
type AdminGiftCategoryRequest = {
|
||||
classificationNumber: string;
|
||||
categoryCode: string;
|
||||
name: string;
|
||||
receiptCode: string;
|
||||
representativeItem: string;
|
||||
requiresDamageWaiver: boolean;
|
||||
isActive: boolean;
|
||||
};
|
||||
```
|
||||
- Response `data`: `AdminGiftCategoryResponse`
|
||||
|
||||
수정 API:
|
||||
- Method: `PUT`
|
||||
- URL: `/api/v2/admin/gift-categories/{categoryId}`
|
||||
- Body: `AdminGiftCategoryRequest`
|
||||
- Response `data`: `AdminGiftCategoryResponse`
|
||||
|
||||
비활성화 API:
|
||||
- Method: `DELETE`
|
||||
- URL: `/api/v2/admin/gift-categories/{categoryId}`
|
||||
- Body: 없음
|
||||
- Response `data`: `AdminGiftCategoryResponse`
|
||||
- 서버는 실제 삭제가 아니라 `isActive=false` 논리 삭제로 처리한다.
|
||||
|
||||
폼 validation:
|
||||
- `classificationNumber`: 필수, 숫자 3자리. 예: `100`
|
||||
- `categoryCode`: 필수, 50자 이하. 예: `DOLL`
|
||||
- `name`: 필수, 50자 이하. 예: `인형`
|
||||
- `receiptCode`: 필수, 영문 대문자 1~9자. 예: `A`
|
||||
- `representativeItem`: 필수, 100자 이하. 예: `피규어`
|
||||
- `requiresDamageWaiver`: boolean
|
||||
- `isActive`: boolean
|
||||
|
||||
UX 요구사항:
|
||||
- 등록 성공 후 목록을 다시 조회하고 모달을 닫는다.
|
||||
- 수정 성공 후 목록을 다시 조회하고 모달을 닫는다.
|
||||
- 비활성화 버튼은 확인창을 띄운 뒤 호출한다.
|
||||
- 이미 비활성화된 row는 비활성화 버튼을 disabled 처리한다.
|
||||
- boolean 값은 기존 관리자 페이지 표현에 맞춰 `예/아니오`, `활성/비활성` 또는 badge로 표시한다.
|
||||
- `categoryCode`는 수정 폼에 표시하되 서버 쪽 엔티티는 코드 변경을 실제로 반영하지 않을 수 있으므로, 가능하면 수정 시 읽기 전용으로 두고 나머지 운영 필드만 수정하도록 UX를 구성한다.
|
||||
|
||||
테스트/검증:
|
||||
- 목록 조회가 `GET /api/v2/admin/gift-categories`를 호출하는지 확인한다.
|
||||
- 등록/수정 body가 `AdminGiftCategoryRequest` 형태와 일치하는지 확인한다.
|
||||
- validation 실패 시 API를 호출하지 않고 폼 에러를 표시한다.
|
||||
- 비활성화 성공 후 해당 row의 `isActive=false`가 반영되는지 확인한다.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 선물 사이즈 가격 CRUD 페이지 프롬프트
|
||||
|
||||
```text
|
||||
관리자 선물 사이즈 가격 CRUD 페이지를 구현해줘.
|
||||
|
||||
목표:
|
||||
- 관리자 메뉴 `선물함 관리 > 선물 사이즈`에서 선물 사이즈별 기본가/판매가/활성 여부를 조회하고 수정한다.
|
||||
- 현재 API는 사이즈 가격의 목록 조회와 수정만 제공한다. 신규 생성과 물리 삭제 기능은 만들지 않는다.
|
||||
- 기존 관리자 페이지의 테이블, 수정 모달, 저장 확인/토스트 패턴을 그대로 따른다.
|
||||
|
||||
라우트:
|
||||
- `/gift/size`
|
||||
|
||||
필수 화면 구성:
|
||||
- 상단 제목: `선물 사이즈`
|
||||
- 목록 테이블 컬럼:
|
||||
- 사이즈 코드 `sizeCode`
|
||||
- 사이즈명 `name`
|
||||
- 기본가 `basePriceCan`
|
||||
- 판매가 `salePriceCan`
|
||||
- 활성 여부 `isActive`
|
||||
- 관리 버튼: 수정
|
||||
- 수정 폼 필드:
|
||||
- `sizeCode`: 읽기 전용
|
||||
- `name`: 읽기 전용
|
||||
- `basePriceCan`: number input
|
||||
- `salePriceCan`: number input
|
||||
- `isActive`: checkbox/switch
|
||||
|
||||
목록 조회 API:
|
||||
- Method: `GET`
|
||||
- URL: `/api/v2/admin/gift-size-prices`
|
||||
- Request: 없음
|
||||
- Response `data`:
|
||||
```ts
|
||||
type AdminGiftSizePriceResponse = {
|
||||
sizeCode: GiftSize;
|
||||
name: string;
|
||||
basePriceCan: number;
|
||||
salePriceCan: number;
|
||||
isActive: boolean;
|
||||
};
|
||||
```
|
||||
|
||||
수정 API:
|
||||
- Method: `PUT`
|
||||
- URL: `/api/v2/admin/gift-size-prices/{sizeCode}`
|
||||
- Body:
|
||||
```ts
|
||||
type AdminGiftSizePriceRequest = {
|
||||
basePriceCan: number;
|
||||
salePriceCan: number;
|
||||
isActive: boolean;
|
||||
};
|
||||
```
|
||||
- Response `data`: `AdminGiftSizePriceResponse`
|
||||
|
||||
사이즈 enum:
|
||||
```ts
|
||||
type GiftSize = "SMALL" | "MEDIUM" | "LARGE";
|
||||
```
|
||||
|
||||
서버 validation:
|
||||
- `salePriceCan`은 0보다 커야 한다.
|
||||
- `salePriceCan`은 `basePriceCan`보다 클 수 없다.
|
||||
|
||||
프론트 validation:
|
||||
- `basePriceCan`: 필수, 1 이상 정수
|
||||
- `salePriceCan`: 필수, 1 이상 정수
|
||||
- `salePriceCan <= basePriceCan`
|
||||
- validation 실패 시 API 호출을 막고 필드 에러를 표시한다.
|
||||
|
||||
UX 요구사항:
|
||||
- 가격은 숫자 입력이지만 테이블 표시에서는 기존 관리자 페이지의 숫자 포맷을 따른다.
|
||||
- 수정 버튼 클릭 시 현재 row 값을 폼 초기값으로 넣는다.
|
||||
- 저장 전 확인창은 기존 관리자 페이지 패턴이 있으면 따른다.
|
||||
- 저장 성공 후 목록을 다시 조회하고 모달을 닫는다.
|
||||
- 신규 생성/삭제 버튼은 만들지 않는다.
|
||||
|
||||
테스트/검증:
|
||||
- 목록 조회가 `GET /api/v2/admin/gift-size-prices`를 호출하는지 확인한다.
|
||||
- 수정 저장 시 `PUT /api/v2/admin/gift-size-prices/{sizeCode}`와 request body가 정확한지 확인한다.
|
||||
- `salePriceCan > basePriceCan`일 때 API를 호출하지 않고 validation 메시지를 표시하는지 확인한다.
|
||||
- 저장 성공 후 목록 refresh가 일어나는지 확인한다.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 구현 시 참고할 공통 타입
|
||||
|
||||
```ts
|
||||
type ApiResponse<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/category` 카테고리 CRUD를 구현한다.
|
||||
4. `/gift/size` 사이즈 가격 수정 화면을 구현한다.
|
||||
5. 기존 관리자 메뉴의 `선물함 관리` 하위 route와 연결되는지 확인한다.
|
||||
6. 각 페이지별 loading/empty/error/success 상태를 확인한다.
|
||||
@@ -0,0 +1,364 @@
|
||||
# 선물하기 클라이언트 API 요약
|
||||
|
||||
## 문서 목적
|
||||
|
||||
- 이 문서는 클라이언트 구현 프롬프트 작성용 빠른 참조다.
|
||||
- 상세 request/response 계약은 `prd.md`의 `8. API 계약`을 기준으로 한다.
|
||||
- 여기서는 사용자 역할별 URI, 역할, 핵심 request/response 필드만 요약한다.
|
||||
|
||||
## 문서 분리 결정
|
||||
|
||||
PRD에는 상세 API 계약, request/response 예시, 요구사항을 유지한다. 클라이언트 프롬프트용 역할별 인벤토리는 별도 문서로 둔다.
|
||||
|
||||
- 이유: PRD에 같은 URI 정보를 역할별로 다시 넣으면 상세 계약과 요약표를 동시에 갱신해야 한다.
|
||||
- 기준: PRD는 제품 요구사항과 API 계약의 원본, 이 문서는 클라이언트 구현을 위한 탐색용 색인이다.
|
||||
|
||||
## 공통/조회 API
|
||||
|
||||
| 사용자 | Method | URI | 역할 | Request | Response |
|
||||
|---|---:|---|---|---|---|
|
||||
| 비회원/회원 | GET | `/api/v2/gifts/form-options` | 선물 신청 폼에 필요한 활성 카테고리와 사이즈별 가격 옵션을 조회한다. | 없음 | `sizes[]`, `categories[]` |
|
||||
| 팬/크리에이터 | GET | `/api/v2/gifts?type=ALL&page=0&size=20` | 내 선물함 목록을 조회하고 `type=ALL|SENT|RECEIVED`로 전체/보낸/받은 선물을 필터링한다. | query: `type`, `page`, `size` | `items[]`, `page`, `size`, `hasNext` |
|
||||
| 팬/크리에이터 | GET | `/api/v2/gifts/{applicationNo}` | 선물 상세를 조회하며 로그인 회원이 팬인지 크리에이터인지에 따라 노출 정보와 CTA flag가 달라진다. | path: `applicationNo` | `applicationNo`, `direction`, `status`, `statusName`, `giftInfo`, `senderInfo`, `recipientAddress`, `mailbox`, `trackingRequired`, `recipientAddressRequired`, `delivery`, `statusTimeline`, `review` |
|
||||
|
||||
## 팬 API
|
||||
|
||||
| 사용자 | Method | URI | 역할 | Request | Response |
|
||||
|---|---:|---|---|---|---|
|
||||
| 팬 | POST | `/api/v2/gifts` | 크리에이터에게 선물을 신청하고 최종 결제 금액만큼 캔을 차감한다. | body: `recipientMemberId`, `senderName`, `senderPhoneNumber`, `senderZipCode`, `senderAddress`, `senderAddressDetail`, `sizeCode`, `categoryId`, `senderTermsAgreed`, `senderPrivacyAgreed`, `damageWaiverAgreed` | `applicationNo`, `status`, `statusName`, `priceCan`, `trackingDeadlineAt` |
|
||||
| 팬 | POST | `/api/v2/gifts/{applicationNo}/cancel` | 운송장 등록 전 선물 신청을 취소하고 전액 환불한다. | path: `applicationNo` | `applicationNo`, `status`, `statusName`, `priceCan`, `canceledAt` |
|
||||
| 팬 | POST | `/api/v2/gifts/{applicationNo}/tracking` | 발송 후 택배사와 운송장 번호를 등록한다. | path: `applicationNo`, body: `courierCompanyName`, `trackingNumber` | `applicationNo`, `status`, `statusName`, `courierCompanyName`, `trackingNumber`, `trackingRegisteredAt`, `recipientAddressDeadlineAt` |
|
||||
| 팬 | POST | `/api/v2/gifts/{applicationNo}/review` | 전달 완료된 선물에 리뷰를 작성한다. | path: `applicationNo`, body: `rating`, `keywords`, `comment` | `reviewId`, `applicationNo`, `rating`, `keywords`, `comment`, `createdAt` |
|
||||
|
||||
## 크리에이터 API
|
||||
|
||||
| 사용자 | Method | URI | 역할 | Request | Response |
|
||||
|---|---:|---|---|---|---|
|
||||
| 크리에이터 | POST | `/api/v2/gifts/{applicationNo}/recipient-address` | 운송장 등록 이후 배송지와 수령 약관 동의를 등록한다. | path: `applicationNo`, body: `recipientName`, `recipientPhoneNumber`, `recipientZipCode`, `recipientAddress`, `recipientAddressDetail`, `recipientTermsAgreed`, `recipientPrivacyAgreed` | `applicationNo`, `status`, `statusName`, `recipientAddressRegisteredAt` |
|
||||
| 크리에이터 | POST | `/api/v2/gifts/{applicationNo}/delivery-complete` | 검수 완료된 선물을 직접 수령 완료 처리한다. | path: `applicationNo` | `applicationNo`, `status`, `statusName`, `deliveredAt` |
|
||||
|
||||
## 관리자 API
|
||||
|
||||
| 사용자 | Method | URI | 역할 | Request | Response |
|
||||
|---|---:|---|---|---|---|
|
||||
| 관리자 | GET | `/api/v2/admin/gift-categories` | 선물 카테고리 목록을 조회한다. | 없음 | `AdminGiftCategoryResponse[]` |
|
||||
| 관리자 | GET | `/api/v2/admin/gifts` | 전체 선물함 목록을 조회하고 상태/신청번호/닉네임으로 검색한다. | query: `status`, `applicationNo`, `nickname`, `page`, `size` | `totalCount`, `items[]`, `page`, `size`, `hasNext` |
|
||||
| 관리자 | GET | `/api/v2/admin/gifts/{applicationNo}` | 선물 상세와 발송인/수취인 개인정보, 상품/배송/상태 정보를 조회한다. | path: `applicationNo` | `applicationNo`, `senderInfo`, `recipientInfo`, `productInfo`, `inboundDeliveryInfo`, `status`, `statusName`, `availableActions` |
|
||||
| 관리자 | POST | `/api/v2/admin/gift-categories` | 선물 카테고리를 등록한다. | body: `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive` | `categoryId`, `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive` |
|
||||
| 관리자 | PUT | `/api/v2/admin/gift-categories/{categoryId}` | 선물 카테고리를 수정한다. | path: `categoryId`, body: `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive` | `categoryId`, `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive` |
|
||||
| 관리자 | DELETE | `/api/v2/admin/gift-categories/{categoryId}` | 선물 카테고리를 비활성화한다. | path: `categoryId` | `categoryId`, `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive=false` |
|
||||
| 관리자 | GET | `/api/v2/admin/gift-size-prices` | 사이즈별 기본가와 판매가를 조회한다. | 없음 | `AdminGiftSizePriceResponse[]` |
|
||||
| 관리자 | PUT | `/api/v2/admin/gift-size-prices/{sizeCode}` | 사이즈별 기본가와 판매가를 수정한다. | path: `sizeCode`, body: `basePriceCan`, `salePriceCan`, `isActive` | `sizeCode`, `name`, `basePriceCan`, `salePriceCan`, `isActive` |
|
||||
| 관리자 | POST | `/api/v2/admin/gifts/{applicationNo}/arrive-mailbox` | 발송 확인 선물을 사서함 도착 처리한다. | path: `applicationNo` | `applicationNo`, `status`, `statusName`, `occurredAt` |
|
||||
| 관리자 | POST | `/api/v2/admin/gifts/{applicationNo}/complete-inspection` | 사서함 도착 선물을 검수 완료 처리한다. | path: `applicationNo` | `applicationNo`, `status`, `statusName`, `occurredAt` |
|
||||
| 관리자 | POST | `/api/v2/admin/gifts/{applicationNo}/mark-undeliverable` | 진행 중 선물을 전달 불가 처리하고 사유를 저장한다. | path: `applicationNo`, body: `reason` | `applicationNo`, `status`, `statusName`, `occurredAt` |
|
||||
| 관리자 | POST | `/api/v2/admin/gifts/{applicationNo}/complete-delivery` | 검수 완료 선물을 운영자가 전달 완료 처리한다. | path: `applicationNo` | `applicationNo`, `status`, `statusName`, `occurredAt` |
|
||||
|
||||
관리자 응답 DTO 요약:
|
||||
|
||||
- `AdminGiftCategoryResponse`: `categoryId`, `classificationNumber`, `categoryCode`, `name`, `receiptCode`, `representativeItem`, `requiresDamageWaiver`, `isActive`
|
||||
- `AdminGiftListResponse`: `totalCount`, `items`, `page`, `size`, `hasNext`
|
||||
- `AdminGiftListItemResponse`: `applicationNo`, `senderNickname`, `recipientNickname`, `sizeCode`, `sizeName`, `categoryName`, `classificationNumber`, `courierCompanyName`, `trackingNumber`, `status`, `statusName`, `availableActions`
|
||||
- `AdminGiftDetailResponse`: `applicationNo`, `senderInfo`, `recipientInfo`, `productInfo`, `inboundDeliveryInfo`, `status`, `statusName`, `availableActions`
|
||||
- `AdminGiftSizePriceResponse`: `sizeCode`, `name`, `basePriceCan`, `salePriceCan`, `isActive`
|
||||
- `AdminGiftOperationStatusResponse`: `applicationNo`, `status`, `statusName`, `occurredAt`
|
||||
|
||||
## Kotlin DTO 형태
|
||||
|
||||
아래는 JSON으로 변환되기 직전의 API request/response `data class` 형태다. `companion object` 변환 함수는 클라이언트 입력/출력 형태와 직접 관련이 없어 생략했다. 응답 시각 필드는 UTC ISO 문자열이다. 예: `2026-09-30T12:00:00Z`.
|
||||
|
||||
### 사용자 Request DTO
|
||||
|
||||
```kotlin
|
||||
data class GiftApplicationRequest(
|
||||
val recipientMemberId: Long,
|
||||
val senderName: String,
|
||||
val senderPhoneNumber: String,
|
||||
val senderZipCode: String,
|
||||
val senderAddress: String,
|
||||
val senderAddressDetail: String?,
|
||||
val sizeCode: GiftSize,
|
||||
val categoryId: Long,
|
||||
val senderTermsAgreed: Boolean,
|
||||
val senderPrivacyAgreed: Boolean,
|
||||
val damageWaiverAgreed: Boolean
|
||||
)
|
||||
|
||||
data class GiftTrackingRegistrationRequest(
|
||||
val courierCompanyName: String,
|
||||
val trackingNumber: String
|
||||
)
|
||||
|
||||
data class GiftRecipientAddressRegistrationRequest(
|
||||
val recipientName: String,
|
||||
val recipientPhoneNumber: String,
|
||||
val recipientZipCode: String,
|
||||
val recipientAddress: String,
|
||||
val recipientAddressDetail: String?,
|
||||
val recipientTermsAgreed: Boolean,
|
||||
val recipientPrivacyAgreed: Boolean
|
||||
)
|
||||
|
||||
data class GiftReviewRequest(
|
||||
val rating: Int,
|
||||
val keywords: List<String>,
|
||||
val comment: String?
|
||||
)
|
||||
```
|
||||
|
||||
### 사용자 Response DTO
|
||||
|
||||
```kotlin
|
||||
data class GiftFormOptionsResponse(
|
||||
val sizes: List<GiftSizeOptionResponse>,
|
||||
val categories: List<GiftCategoryOptionResponse>
|
||||
)
|
||||
|
||||
data class GiftSizeOptionResponse(
|
||||
val sizeCode: String,
|
||||
val name: String,
|
||||
val basePriceCan: Int,
|
||||
val salePriceCan: Int
|
||||
)
|
||||
|
||||
data class GiftCategoryOptionResponse(
|
||||
val categoryId: Long,
|
||||
val name: String,
|
||||
val requiresDamageWaiver: Boolean
|
||||
)
|
||||
|
||||
// name은 사용자 폼 옵션에서만 "분류명/대표품목" 형태로 조합된다.
|
||||
|
||||
data class GiftListResponse(
|
||||
val totalCount: Long,
|
||||
val items: List<GiftListItemResponse>,
|
||||
val page: Int,
|
||||
val size: Int,
|
||||
val hasNext: Boolean
|
||||
)
|
||||
|
||||
data class GiftListItemResponse(
|
||||
val applicationNo: String,
|
||||
val direction: String,
|
||||
val status: String,
|
||||
val statusName: String,
|
||||
val priceCan: Int,
|
||||
val categoryName: String,
|
||||
val sizeName: String,
|
||||
val createdAt: String?
|
||||
)
|
||||
|
||||
data class GiftDetailResponse(
|
||||
val applicationNo: String,
|
||||
val direction: String,
|
||||
val status: String,
|
||||
val statusName: String,
|
||||
val giftInfo: GiftDetailInfoResponse,
|
||||
val senderInfo: GiftAddressResponse?,
|
||||
val recipientAddress: GiftAddressResponse?,
|
||||
val mailbox: GiftMailboxResponse?,
|
||||
val trackingRequired: Boolean,
|
||||
val recipientAddressRequired: Boolean,
|
||||
val recipientAddressDeadlineAt: String?,
|
||||
val delivery: GiftDeliveryInfoResponse,
|
||||
val statusTimeline: List<GiftStatusTimelineResponse>
|
||||
)
|
||||
|
||||
data class GiftDetailInfoResponse(
|
||||
val recipientCreatorNickname: String?,
|
||||
val senderNickname: String?,
|
||||
val sizeName: String,
|
||||
val categoryName: String,
|
||||
val applicationNo: String?,
|
||||
val paidCan: Int?,
|
||||
val tracking: String?,
|
||||
val shippingRequestedAt: String?
|
||||
)
|
||||
|
||||
data class GiftAddressResponse(
|
||||
val name: String,
|
||||
val phoneNumber: String,
|
||||
val address: String
|
||||
)
|
||||
|
||||
data class GiftMailboxResponse(
|
||||
val name: String,
|
||||
val address: String,
|
||||
val phoneNumber: String
|
||||
)
|
||||
|
||||
data class GiftDeliveryInfoResponse(
|
||||
val canceledAt: String?,
|
||||
val undeliverableAt: String?,
|
||||
val undeliverableReason: String?
|
||||
)
|
||||
|
||||
data class GiftStatusTimelineResponse(
|
||||
val status: String,
|
||||
val statusName: String,
|
||||
val occurredAt: String?
|
||||
)
|
||||
|
||||
data class GiftApplicationResponse(
|
||||
val applicationNo: String,
|
||||
val status: String,
|
||||
val statusName: String,
|
||||
val priceCan: Int,
|
||||
val trackingDeadlineAt: String
|
||||
)
|
||||
|
||||
data class GiftCancellationResponse(
|
||||
val applicationNo: String,
|
||||
val status: String,
|
||||
val statusName: String,
|
||||
val priceCan: Int,
|
||||
val canceledAt: String
|
||||
)
|
||||
|
||||
data class GiftTrackingRegistrationResponse(
|
||||
val applicationNo: String,
|
||||
val status: String,
|
||||
val statusName: String,
|
||||
val courierCompanyName: String,
|
||||
val trackingNumber: String,
|
||||
val trackingRegisteredAt: String,
|
||||
val recipientAddressDeadlineAt: String
|
||||
)
|
||||
|
||||
data class GiftRecipientAddressRegistrationResponse(
|
||||
val applicationNo: String,
|
||||
val status: String,
|
||||
val statusName: String,
|
||||
val recipientAddressRegisteredAt: String
|
||||
)
|
||||
|
||||
data class GiftDeliveryConfirmationResponse(
|
||||
val applicationNo: String,
|
||||
val status: String,
|
||||
val statusName: String,
|
||||
val deliveredAt: String
|
||||
)
|
||||
|
||||
data class GiftReviewResponse(
|
||||
val reviewId: Long,
|
||||
val applicationNo: String,
|
||||
val rating: Int,
|
||||
val keywords: List<String>,
|
||||
val comment: String?,
|
||||
val createdAt: String?
|
||||
)
|
||||
```
|
||||
|
||||
### 관리자 Request DTO
|
||||
|
||||
```kotlin
|
||||
data class AdminGiftCategoryRequest(
|
||||
val classificationNumber: String,
|
||||
val categoryCode: String,
|
||||
val name: String,
|
||||
val receiptCode: String,
|
||||
val representativeItem: String,
|
||||
val requiresDamageWaiver: Boolean,
|
||||
val isActive: Boolean
|
||||
)
|
||||
|
||||
data class AdminGiftSizePriceRequest(
|
||||
val basePriceCan: Int,
|
||||
val salePriceCan: Int,
|
||||
val isActive: Boolean
|
||||
)
|
||||
|
||||
data class AdminGiftMarkUndeliverableRequest(
|
||||
val reason: String
|
||||
)
|
||||
```
|
||||
|
||||
### 관리자 Response DTO
|
||||
|
||||
```kotlin
|
||||
data class AdminGiftCategoryResponse(
|
||||
val categoryId: Long,
|
||||
val classificationNumber: String,
|
||||
val categoryCode: String,
|
||||
val name: String,
|
||||
val receiptCode: String,
|
||||
val representativeItem: String,
|
||||
val requiresDamageWaiver: Boolean,
|
||||
val isActive: Boolean
|
||||
)
|
||||
|
||||
data class AdminGiftListResponse(
|
||||
val totalCount: Long,
|
||||
val items: List<AdminGiftListItemResponse>,
|
||||
val page: Int,
|
||||
val size: Int,
|
||||
val hasNext: Boolean
|
||||
)
|
||||
|
||||
data class AdminGiftListItemResponse(
|
||||
val applicationNo: String,
|
||||
val senderNickname: String,
|
||||
val recipientNickname: String,
|
||||
val sizeCode: String,
|
||||
val sizeName: String,
|
||||
val categoryName: String,
|
||||
val classificationNumber: String,
|
||||
val courierCompanyName: String?,
|
||||
val trackingNumber: String?,
|
||||
val status: String,
|
||||
val statusName: String,
|
||||
val availableActions: List<String>
|
||||
)
|
||||
|
||||
data class AdminGiftDetailResponse(
|
||||
val applicationNo: String,
|
||||
val senderInfo: AdminGiftMemberInfoResponse,
|
||||
val recipientInfo: AdminGiftMemberInfoResponse,
|
||||
val productInfo: AdminGiftProductInfoResponse,
|
||||
val inboundDeliveryInfo: AdminGiftInboundDeliveryInfoResponse,
|
||||
val status: String,
|
||||
val statusName: String,
|
||||
val availableActions: List<String>
|
||||
)
|
||||
|
||||
data class AdminGiftMemberInfoResponse(
|
||||
val nickname: String,
|
||||
val name: String,
|
||||
val phoneNumber: String,
|
||||
val address: String
|
||||
)
|
||||
|
||||
data class AdminGiftProductInfoResponse(
|
||||
val sizeCode: String,
|
||||
val sizeName: String,
|
||||
val categoryName: String,
|
||||
val classificationNumber: String
|
||||
)
|
||||
|
||||
data class AdminGiftInboundDeliveryInfoResponse(
|
||||
val courierCompanyName: String?,
|
||||
val trackingNumber: String?
|
||||
)
|
||||
|
||||
data class AdminGiftSizePriceResponse(
|
||||
val sizeCode: String,
|
||||
val name: String,
|
||||
val basePriceCan: Int,
|
||||
val salePriceCan: Int,
|
||||
val isActive: Boolean
|
||||
)
|
||||
|
||||
data class AdminGiftOperationStatusResponse(
|
||||
val applicationNo: String,
|
||||
val status: GiftStatus,
|
||||
val statusName: String,
|
||||
val occurredAt: String
|
||||
)
|
||||
```
|
||||
|
||||
## 클라이언트 구현 참고
|
||||
|
||||
- 선물 푸시 딥링크는 `deepLinkValue=GIFT_DETAIL`, `deepLinkId=applicationNo`를 사용한다.
|
||||
- 딥링크 진입 후에는 `GET /api/v2/gifts/{applicationNo}`로 상세를 조회해 `direction`, `trackingRequired`, `recipientAddressRequired`를 기준으로 화면과 CTA를 결정한다.
|
||||
- 운송장 미등록 자동취소는 서버 스케줄러가 처리하며, 팬 클라이언트는 상세 조회의 `status=CANCELED`와 푸시를 기준으로 표시한다.
|
||||
- 모든 응답은 기존 `ApiResponse.ok(...)` envelope 안의 `data`로 내려간다.
|
||||
@@ -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,118 @@
|
||||
CREATE TABLE gift_category (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '내부 기본키',
|
||||
classification_number VARCHAR(3) NOT NULL COMMENT '신청번호에 사용하는 3자리 분류번호',
|
||||
category_code VARCHAR(50) NOT NULL COMMENT '내부 범용코드',
|
||||
name VARCHAR(50) NOT NULL COMMENT '분류명',
|
||||
receipt_code VARCHAR(9) NOT NULL COMMENT '신청번호에 사용하는 접수코드',
|
||||
representative_item VARCHAR(100) NOT NULL COMMENT '대표품목',
|
||||
requires_damage_waiver TINYINT(1) NOT NULL COMMENT '파손면책 동의 필요 여부',
|
||||
is_active TINYINT(1) NOT NULL COMMENT '활성 여부',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_gift_category_classification_number (classification_number),
|
||||
UNIQUE KEY uk_gift_category_code (category_code),
|
||||
UNIQUE KEY uk_gift_category_receipt_code (receipt_code)
|
||||
) COMMENT='선물 카테고리';
|
||||
|
||||
CREATE TABLE gift (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '내부 기본키',
|
||||
application_no VARCHAR(40) NOT NULL COMMENT '신청번호',
|
||||
sender_member_id BIGINT NOT NULL COMMENT '보내는 팬 회원번호',
|
||||
recipient_member_id BIGINT NOT NULL COMMENT '받는 크리에이터 회원번호',
|
||||
status VARCHAR(30) NOT NULL COMMENT '선물 상태',
|
||||
size_code VARCHAR(20) NOT NULL COMMENT '선물 사이즈 코드',
|
||||
category_id BIGINT NOT NULL COMMENT '신청 시 카테고리 ID',
|
||||
category_name_snapshot VARCHAR(50) NOT NULL COMMENT '신청 시 카테고리명 스냅샷',
|
||||
sale_price_can INT NOT NULL COMMENT '신청 시 결제 금액 스냅샷',
|
||||
can_usage_id BIGINT NOT NULL COMMENT '캔 사용내역 ID',
|
||||
sender_terms_agreed TINYINT(1) NOT NULL COMMENT '팬 이용약관 동의 여부',
|
||||
sender_privacy_agreed TINYINT(1) NOT NULL COMMENT '팬 개인정보 동의 여부',
|
||||
sender_terms_agreed_at TIMESTAMP NOT NULL COMMENT '팬 이용약관 동의 시각',
|
||||
sender_privacy_agreed_at TIMESTAMP NOT NULL COMMENT '팬 개인정보 동의 시각',
|
||||
recipient_terms_agreed TINYINT(1) NOT NULL COMMENT '크리에이터 이용약관 동의 여부',
|
||||
recipient_privacy_agreed TINYINT(1) NOT NULL COMMENT '크리에이터 개인정보 동의 여부',
|
||||
recipient_terms_agreed_at TIMESTAMP NULL COMMENT '크리에이터 이용약관 동의 시각',
|
||||
recipient_privacy_agreed_at TIMESTAMP NULL COMMENT '크리에이터 개인정보 동의 시각',
|
||||
damage_waiver_agreed TINYINT(1) NOT NULL COMMENT '파손면책 동의 여부',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_gift_application_no (application_no),
|
||||
CONSTRAINT fk_gift_sender_member FOREIGN KEY (sender_member_id) REFERENCES member (id),
|
||||
CONSTRAINT fk_gift_recipient_member FOREIGN KEY (recipient_member_id) REFERENCES member (id),
|
||||
CONSTRAINT fk_gift_category FOREIGN KEY (category_id) REFERENCES gift_category (id),
|
||||
CONSTRAINT fk_gift_can_usage FOREIGN KEY (can_usage_id) REFERENCES use_can (id)
|
||||
) COMMENT='선물 신청';
|
||||
|
||||
CREATE TABLE gift_delivery (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '내부 기본키',
|
||||
gift_id BIGINT NOT NULL COMMENT '선물 ID',
|
||||
sender_name VARCHAR(50) NOT NULL COMMENT '발신자 이름',
|
||||
sender_phone_number VARCHAR(30) NOT NULL COMMENT '발신자 휴대폰 번호',
|
||||
sender_zip_code VARCHAR(20) NOT NULL COMMENT '발신자 우편번호',
|
||||
sender_address VARCHAR(255) NOT NULL COMMENT '발신자 주소',
|
||||
sender_address_detail VARCHAR(255) NULL COMMENT '발신자 상세주소',
|
||||
recipient_name VARCHAR(50) NULL COMMENT '수신자 이름',
|
||||
recipient_phone_number VARCHAR(30) NULL COMMENT '수신자 휴대폰 번호',
|
||||
recipient_zip_code VARCHAR(20) NULL COMMENT '수신자 우편번호',
|
||||
recipient_address VARCHAR(255) NULL COMMENT '수신자 주소',
|
||||
recipient_address_detail VARCHAR(255) NULL COMMENT '수신자 상세주소',
|
||||
courier_company_name VARCHAR(50) NULL COMMENT '택배사 한글 표시명',
|
||||
tracking_number VARCHAR(100) NULL COMMENT '운송장 번호',
|
||||
tracking_deadline_at TIMESTAMP NOT NULL COMMENT '운송장 등록 기한',
|
||||
recipient_address_deadline_at TIMESTAMP NULL COMMENT '수신자 배송지 입력 기한',
|
||||
tracking_deadline_reminder_sent_at TIMESTAMP NULL COMMENT '운송장 등록 기한 안내 발송 시각',
|
||||
recipient_address_deadline_reminder_sent_at TIMESTAMP NULL COMMENT '수신자 배송지 입력 기한 안내 발송 시각',
|
||||
tracking_registered_at TIMESTAMP NULL COMMENT '운송장 등록 시각',
|
||||
arrived_at_mailbox_at TIMESTAMP NULL COMMENT '사서함 도착 시각',
|
||||
inspection_completed_at TIMESTAMP NULL COMMENT '검수완료 시각',
|
||||
delivered_at TIMESTAMP NULL COMMENT '전달완료 시각',
|
||||
canceled_at TIMESTAMP NULL COMMENT '취소 시각',
|
||||
undeliverable_at TIMESTAMP NULL COMMENT '전달불가 시각',
|
||||
undeliverable_reason VARCHAR(255) NULL COMMENT '전달불가 사유',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_gift_delivery_gift_id (gift_id),
|
||||
CONSTRAINT fk_gift_delivery_gift FOREIGN KEY (gift_id) REFERENCES gift (id)
|
||||
) COMMENT='선물 배송 정보';
|
||||
|
||||
CREATE TABLE gift_size_price (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '내부 기본키',
|
||||
size_code VARCHAR(20) NOT NULL COMMENT '선물 사이즈 코드',
|
||||
base_price_can INT NOT NULL COMMENT '기본 금액',
|
||||
sale_price_can INT NOT NULL COMMENT '결제 금액',
|
||||
is_active TINYINT(1) NOT NULL COMMENT '활성 여부',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_gift_size_price_size_code (size_code)
|
||||
) COMMENT='선물 사이즈별 가격';
|
||||
|
||||
CREATE TABLE gift_review (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '내부 기본키',
|
||||
gift_id BIGINT NOT NULL COMMENT '선물 ID',
|
||||
sender_member_id BIGINT NOT NULL COMMENT '리뷰 작성 팬 회원번호',
|
||||
rating INT NOT NULL COMMENT '별점',
|
||||
keywords TEXT NULL COMMENT '리뷰 키워드 목록',
|
||||
comment VARCHAR(255) NULL COMMENT '추가의견',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_gift_review_gift_id (gift_id),
|
||||
CONSTRAINT fk_gift_review_gift FOREIGN KEY (gift_id) REFERENCES gift (id),
|
||||
CONSTRAINT fk_gift_review_sender_member FOREIGN KEY (sender_member_id) REFERENCES member (id)
|
||||
) COMMENT='선물 리뷰';
|
||||
|
||||
CREATE TABLE gift_application_no_sequence (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '내부 기본키',
|
||||
category_id BIGINT NOT NULL COMMENT '채번 기준 카테고리 ID',
|
||||
sequence_date VARCHAR(6) NOT NULL COMMENT '채번 기준일 yyMMdd',
|
||||
last_sequence INT NOT NULL COMMENT '마지막 일련번호',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_gift_application_no_sequence_category_date (category_id, sequence_date),
|
||||
CONSTRAINT fk_gift_application_no_sequence_category FOREIGN KEY (category_id) REFERENCES gift_category (id)
|
||||
) 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는 각 플랫폼의 기존 컴포넌트로 대응한다.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -120,6 +120,7 @@ class CanService(
|
||||
|
||||
CanUsage.CHAT_QUOTA_PURCHASE -> "캐릭터 톡 이용권 구매"
|
||||
CanUsage.CHAT_ROOM_RESET -> "캐릭터 톡 초기화"
|
||||
CanUsage.GIFT -> "선물하기"
|
||||
}
|
||||
|
||||
val createdAt = it.createdAt
|
||||
|
||||
@@ -54,7 +54,7 @@ class CanPaymentService(
|
||||
communityPost: CreatorCommunity? = null,
|
||||
auditionApplicant: AuditionApplicant? = null,
|
||||
container: String
|
||||
) {
|
||||
): UseCan {
|
||||
val member = memberRepository.findByIdOrNull(id = memberId)
|
||||
?: throw SodaException(messageKey = "can.payment.invalid_request_retry")
|
||||
val useRewardCan = spendRewardCan(member, needCan, container)
|
||||
@@ -131,6 +131,8 @@ class CanPaymentService(
|
||||
useCan.member = member
|
||||
useCan.chatRoomId = chatRoomId
|
||||
useCan.characterId = characterId
|
||||
} else if (canUsage == CanUsage.GIFT) {
|
||||
useCan.member = member
|
||||
} else {
|
||||
throw SodaException(messageKey = "common.error.invalid_request")
|
||||
}
|
||||
@@ -160,6 +162,18 @@ class CanPaymentService(
|
||||
useCan,
|
||||
paymentGateway = PaymentGateway.APPLE_IAP
|
||||
)
|
||||
|
||||
return useCan
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun spendGiftCan(memberId: Long, needCan: Int, container: String): Long {
|
||||
return spendCan(
|
||||
memberId = memberId,
|
||||
needCan = needCan,
|
||||
canUsage = CanUsage.GIFT,
|
||||
container = container
|
||||
).id!!
|
||||
}
|
||||
|
||||
private fun setUseCanCalculate(
|
||||
@@ -324,6 +338,24 @@ class CanPaymentService(
|
||||
roomId = roomId,
|
||||
canUsage = CanUsage.LIVE
|
||||
) ?: throw SodaException(messageKey = "can.payment.invalid_reservation")
|
||||
refundUseCan(member, useCan)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun refundGiftCan(memberId: Long, useCanId: Long) {
|
||||
val member = memberRepository.findByIdOrNull(memberId)
|
||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||
val useCan = useCanRepository.findByIdOrNull(useCanId)
|
||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||
|
||||
if (useCan.member?.id != member.id || useCan.canUsage != CanUsage.GIFT || useCan.isRefund) {
|
||||
throw SodaException(messageKey = "common.error.invalid_request")
|
||||
}
|
||||
|
||||
refundUseCan(member, useCan)
|
||||
}
|
||||
|
||||
private fun refundUseCan(member: Member, useCan: UseCan) {
|
||||
useCan.isRefund = true
|
||||
|
||||
val useCanCalculates = useCanCalculateRepository.findByUseCanIdAndStatus(useCan.id!!)
|
||||
|
||||
@@ -14,5 +14,6 @@ enum class CanUsage {
|
||||
CHAT_MESSAGE_PURCHASE, // 메시지를 통한 구매(이미지 등 다양한 리소스에 공통 적용)
|
||||
CHARACTER_IMAGE_PURCHASE, // 캐릭터 이미지 단독 구매
|
||||
CHAT_QUOTA_PURCHASE, // 채팅 횟수(쿼터) 충전
|
||||
CHAT_ROOM_RESET // 채팅방 초기화 결제(별도 구분)
|
||||
CHAT_ROOM_RESET, // 채팅방 초기화 결제(별도 구분)
|
||||
GIFT
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ enum class FcmDeepLinkValue(val value: String) {
|
||||
SERIES("series"),
|
||||
AUDITION("audition"),
|
||||
COMMUNITY("community"),
|
||||
CHAT("chat")
|
||||
CHAT("chat"),
|
||||
GIFT_DETAIL("gift-detail")
|
||||
}
|
||||
|
||||
class FcmEvent(
|
||||
@@ -50,7 +51,7 @@ class FcmEvent(
|
||||
val creatorId: Long? = null,
|
||||
val auditionId: Long? = null,
|
||||
val deepLinkValue: FcmDeepLinkValue? = null,
|
||||
val deepLinkId: Long? = null,
|
||||
val deepLinkId: Any? = null,
|
||||
val deepLinkCommentPostId: Long? = null,
|
||||
val commentParentId: Long? = null,
|
||||
val myMemberId: Long? = null,
|
||||
|
||||
@@ -32,7 +32,7 @@ class FcmService(
|
||||
creatorId: Long? = null,
|
||||
auditionId: Long? = null,
|
||||
deepLinkValue: FcmDeepLinkValue? = null,
|
||||
deepLinkId: Long? = null,
|
||||
deepLinkId: Any? = null,
|
||||
deepLinkCommentPostId: Long? = null,
|
||||
chatType: String? = null
|
||||
) {
|
||||
@@ -118,7 +118,7 @@ class FcmService(
|
||||
|
||||
private fun createDeepLink(
|
||||
deepLinkValue: FcmDeepLinkValue?,
|
||||
deepLinkId: Long?,
|
||||
deepLinkId: Any?,
|
||||
deepLinkCommentPostId: Long?
|
||||
): String? {
|
||||
return buildDeepLink(serverEnv, deepLinkValue, deepLinkId, deepLinkCommentPostId)
|
||||
@@ -194,7 +194,7 @@ class FcmService(
|
||||
fun buildDeepLink(
|
||||
serverEnv: String,
|
||||
deepLinkValue: FcmDeepLinkValue?,
|
||||
deepLinkId: Long?,
|
||||
deepLinkId: Any?,
|
||||
deepLinkCommentPostId: Long? = null
|
||||
): String? {
|
||||
if (deepLinkValue == null || deepLinkId == null) {
|
||||
@@ -222,7 +222,7 @@ class FcmService(
|
||||
creatorId: Long? = null,
|
||||
auditionId: Long? = null,
|
||||
deepLinkValue: FcmDeepLinkValue? = null,
|
||||
deepLinkId: Long? = null,
|
||||
deepLinkId: Any? = null,
|
||||
deepLinkCommentPostId: Long? = null,
|
||||
deepLink: String? = null,
|
||||
chatType: String? = null
|
||||
|
||||
@@ -6,6 +6,7 @@ enum class PushNotificationCategory(val code: String) {
|
||||
COMMUNITY("community"),
|
||||
MESSAGE("message"),
|
||||
AUDITION("audition"),
|
||||
GIFT("gift"),
|
||||
SYSTEM("system");
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package kr.co.vividnext.sodalive.menu
|
||||
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
@Service
|
||||
@@ -8,6 +9,20 @@ class MenuService(
|
||||
private val repository: MenuRepository
|
||||
) {
|
||||
fun getMenus(member: Member): List<GetMenuResponse> {
|
||||
return repository.getMenu(member.role)
|
||||
val menus = repository.getMenu(member.role)
|
||||
if (member.role != MemberRole.ADMIN) return menus
|
||||
|
||||
return menus + giftManagementMenu
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val giftManagementMenu = GetMenuResponse(
|
||||
title = "선물함 관리",
|
||||
items = listOf(
|
||||
GetMenuResponse(title = "선물함 리스트", route = "/gift/list"),
|
||||
GetMenuResponse(title = "선물 카테고리", route = "/gift/category"),
|
||||
GetMenuResponse(title = "선물 사이즈", route = "/gift/size")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.`in`.web
|
||||
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftCategoryRequest
|
||||
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftCategoryResponse
|
||||
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftDetailResponse
|
||||
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftListResponse
|
||||
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftMarkUndeliverableRequest
|
||||
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftOperationStatusResponse
|
||||
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftSizePriceRequest
|
||||
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftSizePriceResponse
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftAdminQueryService
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftAdminService
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.PutMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v2/admin")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
class AdminGiftController(
|
||||
private val service: GiftAdminService,
|
||||
private val queryService: GiftAdminQueryService
|
||||
) {
|
||||
@GetMapping("/gifts")
|
||||
fun getGifts(
|
||||
@RequestParam(required = false) status: GiftStatus?,
|
||||
@RequestParam(required = false) applicationNo: String?,
|
||||
@RequestParam(required = false) nickname: String?,
|
||||
@RequestParam(defaultValue = "0") page: Int,
|
||||
@RequestParam(defaultValue = "20") size: Int
|
||||
) = ApiResponse.ok(AdminGiftListResponse.from(queryService.getGifts(status, applicationNo, nickname, page, size)))
|
||||
|
||||
@GetMapping("/gifts/{applicationNo}")
|
||||
fun getGiftDetail(@PathVariable applicationNo: String) = ApiResponse.ok(
|
||||
AdminGiftDetailResponse.from(queryService.getGiftDetail(applicationNo))
|
||||
)
|
||||
|
||||
@GetMapping("/gift-categories")
|
||||
fun getCategories() = ApiResponse.ok(service.getCategories().map { AdminGiftCategoryResponse.from(it) })
|
||||
|
||||
@PostMapping("/gift-categories")
|
||||
fun createCategory(@RequestBody request: AdminGiftCategoryRequest) = ApiResponse.ok(
|
||||
AdminGiftCategoryResponse.from(service.createCategory(request.toCommand()))
|
||||
)
|
||||
|
||||
@PutMapping("/gift-categories/{categoryId}")
|
||||
fun updateCategory(
|
||||
@PathVariable categoryId: Long,
|
||||
@RequestBody request: AdminGiftCategoryRequest
|
||||
) = ApiResponse.ok(AdminGiftCategoryResponse.from(service.updateCategory(categoryId, request.toCommand())))
|
||||
|
||||
@DeleteMapping("/gift-categories/{categoryId}")
|
||||
fun deleteCategory(@PathVariable categoryId: Long) = ApiResponse.ok(
|
||||
AdminGiftCategoryResponse.from(service.deleteCategory(categoryId))
|
||||
)
|
||||
|
||||
@GetMapping("/gift-size-prices")
|
||||
fun getSizePrices() = ApiResponse.ok(service.getSizePrices().map { AdminGiftSizePriceResponse.from(it) })
|
||||
|
||||
@PutMapping("/gift-size-prices/{sizeCode}")
|
||||
fun updateSizePrice(
|
||||
@PathVariable sizeCode: GiftSize,
|
||||
@RequestBody request: AdminGiftSizePriceRequest
|
||||
) = ApiResponse.ok(AdminGiftSizePriceResponse.from(service.updateSizePrice(sizeCode, request.toCommand())))
|
||||
|
||||
@PostMapping("/gifts/{applicationNo}/arrive-mailbox")
|
||||
fun arriveMailbox(@PathVariable applicationNo: String) = ApiResponse.ok(
|
||||
AdminGiftOperationStatusResponse.from(service.arriveMailbox(applicationNo))
|
||||
)
|
||||
|
||||
@PostMapping("/gifts/{applicationNo}/complete-inspection")
|
||||
fun completeInspection(@PathVariable applicationNo: String) = ApiResponse.ok(
|
||||
AdminGiftOperationStatusResponse.from(service.completeInspection(applicationNo))
|
||||
)
|
||||
|
||||
@PostMapping("/gifts/{applicationNo}/mark-undeliverable")
|
||||
fun markUndeliverable(
|
||||
@PathVariable applicationNo: String,
|
||||
@RequestBody request: AdminGiftMarkUndeliverableRequest
|
||||
) = ApiResponse.ok(AdminGiftOperationStatusResponse.from(service.markUndeliverable(applicationNo, request.reason)))
|
||||
|
||||
@PostMapping("/gifts/{applicationNo}/complete-delivery")
|
||||
fun completeDelivery(@PathVariable applicationNo: String) = ApiResponse.ok(
|
||||
AdminGiftOperationStatusResponse.from(service.completeDelivery(applicationNo))
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.gift.dto
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftCategoryCommand
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftSizePriceCommand
|
||||
|
||||
data class AdminGiftCategoryRequest(
|
||||
val classificationNumber: String,
|
||||
val categoryCode: String,
|
||||
val name: String,
|
||||
val receiptCode: String,
|
||||
val representativeItem: String,
|
||||
val requiresDamageWaiver: Boolean,
|
||||
val isActive: Boolean
|
||||
) {
|
||||
fun toCommand() = GiftCategoryCommand(
|
||||
classificationNumber = classificationNumber,
|
||||
categoryCode = categoryCode,
|
||||
name = name,
|
||||
receiptCode = receiptCode,
|
||||
representativeItem = representativeItem,
|
||||
requiresDamageWaiver = requiresDamageWaiver,
|
||||
isActive = isActive
|
||||
)
|
||||
}
|
||||
|
||||
data class AdminGiftSizePriceRequest(
|
||||
val basePriceCan: Int,
|
||||
val salePriceCan: Int,
|
||||
val isActive: Boolean
|
||||
) {
|
||||
fun toCommand() = GiftSizePriceCommand(
|
||||
basePriceCan = basePriceCan,
|
||||
salePriceCan = salePriceCan,
|
||||
isActive = isActive
|
||||
)
|
||||
}
|
||||
|
||||
data class AdminGiftMarkUndeliverableRequest(
|
||||
val reason: String
|
||||
)
|
||||
@@ -0,0 +1,189 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.gift.dto
|
||||
|
||||
import kr.co.vividnext.sodalive.extensions.toUtcIso
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftDetailResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftInboundDeliveryInfoResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftListItemResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftListResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftMemberInfoResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftProductInfoResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftCategoryResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftOperationStatusResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftSizePriceResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
|
||||
|
||||
data class AdminGiftCategoryResponse(
|
||||
val categoryId: Long,
|
||||
val classificationNumber: String,
|
||||
val categoryCode: String,
|
||||
val name: String,
|
||||
val receiptCode: String,
|
||||
val representativeItem: String,
|
||||
val requiresDamageWaiver: Boolean,
|
||||
val isActive: Boolean
|
||||
) {
|
||||
companion object {
|
||||
fun from(result: GiftCategoryResult) = AdminGiftCategoryResponse(
|
||||
categoryId = result.categoryId,
|
||||
classificationNumber = result.classificationNumber,
|
||||
categoryCode = result.categoryCode,
|
||||
name = result.name,
|
||||
receiptCode = result.receiptCode,
|
||||
representativeItem = result.representativeItem,
|
||||
requiresDamageWaiver = result.requiresDamageWaiver,
|
||||
isActive = result.isActive
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class AdminGiftSizePriceResponse(
|
||||
val sizeCode: String,
|
||||
val name: String,
|
||||
val basePriceCan: Int,
|
||||
val salePriceCan: Int,
|
||||
val isActive: Boolean
|
||||
) {
|
||||
companion object {
|
||||
fun from(result: GiftSizePriceResult) = AdminGiftSizePriceResponse(
|
||||
sizeCode = result.sizeCode.name,
|
||||
name = result.sizeName,
|
||||
basePriceCan = result.basePriceCan,
|
||||
salePriceCan = result.salePriceCan,
|
||||
isActive = result.isActive
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class AdminGiftOperationStatusResponse(
|
||||
val applicationNo: String,
|
||||
val status: GiftStatus,
|
||||
val statusName: String,
|
||||
val occurredAt: String
|
||||
) {
|
||||
companion object {
|
||||
fun from(result: GiftOperationStatusResult) = AdminGiftOperationStatusResponse(
|
||||
applicationNo = result.applicationNo,
|
||||
status = result.status,
|
||||
statusName = result.statusName,
|
||||
occurredAt = result.occurredAt.toUtcIso()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class AdminGiftListResponse(
|
||||
val totalCount: Long,
|
||||
val items: List<AdminGiftListItemResponse>,
|
||||
val page: Int,
|
||||
val size: Int,
|
||||
val hasNext: Boolean
|
||||
) {
|
||||
companion object {
|
||||
fun from(result: AdminGiftListResult) = AdminGiftListResponse(
|
||||
totalCount = result.totalCount,
|
||||
items = result.items.map { AdminGiftListItemResponse.from(it) },
|
||||
page = result.page,
|
||||
size = result.size,
|
||||
hasNext = result.hasNext
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class AdminGiftListItemResponse(
|
||||
val applicationNo: String,
|
||||
val senderNickname: String,
|
||||
val recipientNickname: String,
|
||||
val sizeCode: String,
|
||||
val sizeName: String,
|
||||
val categoryName: String,
|
||||
val classificationNumber: String,
|
||||
val courierCompanyName: String?,
|
||||
val trackingNumber: String?,
|
||||
val status: GiftStatus,
|
||||
val statusName: String,
|
||||
val availableActions: List<String>
|
||||
) {
|
||||
companion object {
|
||||
fun from(result: AdminGiftListItemResult) = AdminGiftListItemResponse(
|
||||
applicationNo = result.applicationNo,
|
||||
senderNickname = result.senderNickname,
|
||||
recipientNickname = result.recipientNickname,
|
||||
sizeCode = result.sizeCode,
|
||||
sizeName = result.sizeName,
|
||||
categoryName = result.categoryName,
|
||||
classificationNumber = result.classificationNumber,
|
||||
courierCompanyName = result.courierCompanyName,
|
||||
trackingNumber = result.trackingNumber,
|
||||
status = result.status,
|
||||
statusName = result.statusName,
|
||||
availableActions = result.availableActions
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class AdminGiftDetailResponse(
|
||||
val applicationNo: String,
|
||||
val senderInfo: AdminGiftMemberInfoResponse,
|
||||
val recipientInfo: AdminGiftMemberInfoResponse,
|
||||
val productInfo: AdminGiftProductInfoResponse,
|
||||
val inboundDeliveryInfo: AdminGiftInboundDeliveryInfoResponse,
|
||||
val status: GiftStatus,
|
||||
val statusName: String,
|
||||
val availableActions: List<String>
|
||||
) {
|
||||
companion object {
|
||||
fun from(result: AdminGiftDetailResult) = AdminGiftDetailResponse(
|
||||
applicationNo = result.applicationNo,
|
||||
senderInfo = AdminGiftMemberInfoResponse.from(result.senderInfo),
|
||||
recipientInfo = AdminGiftMemberInfoResponse.from(result.recipientInfo),
|
||||
productInfo = AdminGiftProductInfoResponse.from(result.productInfo),
|
||||
inboundDeliveryInfo = AdminGiftInboundDeliveryInfoResponse.from(result.inboundDeliveryInfo),
|
||||
status = result.status,
|
||||
statusName = result.statusName,
|
||||
availableActions = result.availableActions
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class AdminGiftMemberInfoResponse(
|
||||
val nickname: String,
|
||||
val name: String,
|
||||
val phoneNumber: String,
|
||||
val address: String
|
||||
) {
|
||||
companion object {
|
||||
fun from(result: AdminGiftMemberInfoResult) = AdminGiftMemberInfoResponse(
|
||||
nickname = result.nickname,
|
||||
name = result.name,
|
||||
phoneNumber = result.phoneNumber,
|
||||
address = result.address
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class AdminGiftProductInfoResponse(
|
||||
val sizeCode: String,
|
||||
val sizeName: String,
|
||||
val categoryName: String,
|
||||
val classificationNumber: String
|
||||
) {
|
||||
companion object {
|
||||
fun from(result: AdminGiftProductInfoResult) = AdminGiftProductInfoResponse(
|
||||
sizeCode = result.sizeCode,
|
||||
sizeName = result.sizeName,
|
||||
categoryName = result.categoryName,
|
||||
classificationNumber = result.classificationNumber
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class AdminGiftInboundDeliveryInfoResponse(
|
||||
val courierCompanyName: String?,
|
||||
val trackingNumber: String?
|
||||
) {
|
||||
companion object {
|
||||
fun from(result: AdminGiftInboundDeliveryInfoResult) = AdminGiftInboundDeliveryInfoResponse(
|
||||
courierCompanyName = result.courierCompanyName,
|
||||
trackingNumber = result.trackingNumber
|
||||
)
|
||||
}
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.gift.adapter.`in`.web
|
||||
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftApplicationRequest
|
||||
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftApplicationResponse
|
||||
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftCancellationResponse
|
||||
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftDeliveryConfirmationResponse
|
||||
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftDetailResponse
|
||||
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftFormOptionsResponse
|
||||
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftListResponse
|
||||
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftRecipientAddressRegistrationRequest
|
||||
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftRecipientAddressRegistrationResponse
|
||||
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftReviewRequest
|
||||
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftReviewResponse
|
||||
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftTrackingRegistrationRequest
|
||||
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftTrackingRegistrationResponse
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftCommandService
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftQueryService
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftListType
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v2/gifts")
|
||||
class GiftController(
|
||||
private val queryService: GiftQueryService,
|
||||
private val commandService: GiftCommandService
|
||||
) {
|
||||
@GetMapping("/form-options")
|
||||
fun getFormOptions() = ApiResponse.ok(GiftFormOptionsResponse.from(queryService.getFormOptions()))
|
||||
|
||||
@GetMapping
|
||||
fun getGifts(
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||
@RequestParam(defaultValue = "ALL") type: GiftListType,
|
||||
@RequestParam(defaultValue = "0") page: Int,
|
||||
@RequestParam(defaultValue = "20") size: Int
|
||||
) = ApiResponse.ok(
|
||||
GiftListResponse.from(
|
||||
queryService.getGifts(
|
||||
memberId = member?.id ?: throw SodaException(messageKey = "common.error.bad_credentials"),
|
||||
type = type,
|
||||
page = page,
|
||||
size = size
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@GetMapping("/{applicationNo}")
|
||||
fun getGiftDetail(
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||
@PathVariable applicationNo: String
|
||||
) = ApiResponse.ok(
|
||||
GiftDetailResponse.from(
|
||||
queryService.getGiftDetail(
|
||||
memberId = member?.id ?: throw SodaException(messageKey = "common.error.bad_credentials"),
|
||||
applicationNo = applicationNo
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@PostMapping
|
||||
fun registerGift(
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||
@RequestBody request: GiftApplicationRequest
|
||||
) = ApiResponse.ok(
|
||||
GiftApplicationResponse.from(
|
||||
commandService.registerGift(
|
||||
member = member ?: throw SodaException(messageKey = "common.error.bad_credentials"),
|
||||
command = request.toCommand()
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@PostMapping("/{applicationNo}/cancel")
|
||||
fun cancelGift(
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||
@PathVariable applicationNo: String
|
||||
) = ApiResponse.ok(
|
||||
GiftCancellationResponse.from(
|
||||
commandService.cancelGift(
|
||||
member = member ?: throw SodaException(messageKey = "common.error.bad_credentials"),
|
||||
applicationNo = applicationNo
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@PostMapping("/{applicationNo}/tracking")
|
||||
fun registerTracking(
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||
@PathVariable applicationNo: String,
|
||||
@RequestBody request: GiftTrackingRegistrationRequest
|
||||
) = ApiResponse.ok(
|
||||
GiftTrackingRegistrationResponse.from(
|
||||
commandService.registerTracking(
|
||||
member = member ?: throw SodaException(messageKey = "common.error.bad_credentials"),
|
||||
applicationNo = applicationNo,
|
||||
command = request.toCommand()
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@PostMapping("/{applicationNo}/recipient-address")
|
||||
fun registerRecipientAddress(
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||
@PathVariable applicationNo: String,
|
||||
@RequestBody request: GiftRecipientAddressRegistrationRequest
|
||||
) = ApiResponse.ok(
|
||||
GiftRecipientAddressRegistrationResponse.from(
|
||||
commandService.registerRecipientAddress(
|
||||
member = member ?: throw SodaException(messageKey = "common.error.bad_credentials"),
|
||||
applicationNo = applicationNo,
|
||||
command = request.toCommand()
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@PostMapping("/{applicationNo}/delivery-complete")
|
||||
fun confirmDelivery(
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||
@PathVariable applicationNo: String
|
||||
) = ApiResponse.ok(
|
||||
GiftDeliveryConfirmationResponse.from(
|
||||
commandService.confirmDelivery(
|
||||
member = member ?: throw SodaException(messageKey = "common.error.bad_credentials"),
|
||||
applicationNo = applicationNo
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@PostMapping("/{applicationNo}/review")
|
||||
fun writeReview(
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||
@PathVariable applicationNo: String,
|
||||
@RequestBody request: GiftReviewRequest
|
||||
) = ApiResponse.ok(
|
||||
GiftReviewResponse.from(
|
||||
commandService.writeReview(
|
||||
member = member ?: throw SodaException(messageKey = "common.error.bad_credentials"),
|
||||
applicationNo = applicationNo,
|
||||
command = request.toCommand()
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.gift.dto
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftApplicationCommand
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
|
||||
|
||||
data class GiftApplicationRequest(
|
||||
val recipientMemberId: Long,
|
||||
val senderName: String,
|
||||
val senderPhoneNumber: String,
|
||||
val senderZipCode: String,
|
||||
val senderAddress: String,
|
||||
val senderAddressDetail: String?,
|
||||
val sizeCode: GiftSize,
|
||||
val categoryId: Long,
|
||||
val senderTermsAgreed: Boolean,
|
||||
val senderPrivacyAgreed: Boolean,
|
||||
val damageWaiverAgreed: Boolean
|
||||
) {
|
||||
fun toCommand() = GiftApplicationCommand(
|
||||
recipientMemberId = recipientMemberId,
|
||||
senderName = senderName,
|
||||
senderPhoneNumber = senderPhoneNumber,
|
||||
senderZipCode = senderZipCode,
|
||||
senderAddress = senderAddress,
|
||||
senderAddressDetail = senderAddressDetail,
|
||||
sizeCode = sizeCode,
|
||||
categoryId = categoryId,
|
||||
senderTermsAgreed = senderTermsAgreed,
|
||||
senderPrivacyAgreed = senderPrivacyAgreed,
|
||||
damageWaiverAgreed = damageWaiverAgreed
|
||||
)
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.gift.dto
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftRecipientAddressRegistrationCommand
|
||||
|
||||
data class GiftRecipientAddressRegistrationRequest(
|
||||
val recipientName: String,
|
||||
val recipientPhoneNumber: String,
|
||||
val recipientZipCode: String,
|
||||
val recipientAddress: String,
|
||||
val recipientAddressDetail: String?,
|
||||
val recipientTermsAgreed: Boolean,
|
||||
val recipientPrivacyAgreed: Boolean
|
||||
) {
|
||||
fun toCommand() = GiftRecipientAddressRegistrationCommand(
|
||||
name = recipientName,
|
||||
phoneNumber = recipientPhoneNumber,
|
||||
zipCode = recipientZipCode,
|
||||
address = recipientAddress,
|
||||
addressDetail = recipientAddressDetail,
|
||||
recipientTermsAgreed = recipientTermsAgreed,
|
||||
recipientPrivacyAgreed = recipientPrivacyAgreed
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.gift.dto
|
||||
|
||||
import kr.co.vividnext.sodalive.extensions.toUtcIso
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftApplicationResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftCancellationResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftDeliveryConfirmationResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftDetailResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftFormOptionsResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftListResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftRecipientAddressRegistrationResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftReviewResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftTrackingRegistrationResult
|
||||
|
||||
data class GiftFormOptionsResponse(
|
||||
val sizes: List<GiftSizeOptionResponse>,
|
||||
val categories: List<GiftCategoryOptionResponse>
|
||||
) {
|
||||
companion object {
|
||||
fun from(result: GiftFormOptionsResult) = GiftFormOptionsResponse(
|
||||
sizes = result.sizes.map {
|
||||
GiftSizeOptionResponse(it.sizeCode.name, it.sizeName, it.basePriceCan, it.salePriceCan)
|
||||
},
|
||||
categories = result.categories.map {
|
||||
GiftCategoryOptionResponse(it.categoryId, it.name, it.requiresDamageWaiver)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class GiftSizeOptionResponse(
|
||||
val sizeCode: String,
|
||||
val name: String,
|
||||
val basePriceCan: Int,
|
||||
val salePriceCan: Int
|
||||
)
|
||||
|
||||
data class GiftCategoryOptionResponse(
|
||||
val categoryId: Long,
|
||||
val name: String,
|
||||
val requiresDamageWaiver: Boolean
|
||||
)
|
||||
|
||||
data class GiftListResponse(
|
||||
val totalCount: Long,
|
||||
val items: List<GiftListItemResponse>,
|
||||
val page: Int,
|
||||
val size: Int,
|
||||
val hasNext: Boolean
|
||||
) {
|
||||
companion object {
|
||||
fun from(result: GiftListResult) = GiftListResponse(
|
||||
totalCount = result.totalCount,
|
||||
items = result.items.map {
|
||||
GiftListItemResponse(
|
||||
applicationNo = it.applicationNo,
|
||||
direction = it.direction.name,
|
||||
status = it.status.name,
|
||||
statusName = it.statusName,
|
||||
priceCan = it.priceCan,
|
||||
categoryName = it.categoryName,
|
||||
sizeName = it.sizeName,
|
||||
counterpartMemberId = it.counterpartMemberId,
|
||||
counterpartNickname = it.counterpartNickname,
|
||||
counterpartProfileImageUrl = it.counterpartProfileImageUrl,
|
||||
createdAt = it.createdAt?.toUtcIso()
|
||||
)
|
||||
},
|
||||
page = result.page,
|
||||
size = result.size,
|
||||
hasNext = result.hasNext
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class GiftListItemResponse(
|
||||
val applicationNo: String,
|
||||
val direction: String,
|
||||
val status: String,
|
||||
val statusName: String,
|
||||
val priceCan: Int,
|
||||
val categoryName: String,
|
||||
val sizeName: String,
|
||||
val counterpartMemberId: Long,
|
||||
val counterpartNickname: String,
|
||||
val counterpartProfileImageUrl: String,
|
||||
val createdAt: String?
|
||||
)
|
||||
|
||||
data class GiftDetailResponse(
|
||||
val applicationNo: String,
|
||||
val direction: String,
|
||||
val status: String,
|
||||
val statusName: String,
|
||||
val giftInfo: GiftDetailInfoResponse,
|
||||
val senderInfo: GiftAddressResponse?,
|
||||
val recipientAddress: GiftAddressResponse?,
|
||||
val mailbox: GiftMailboxResponse?,
|
||||
val trackingRequired: Boolean,
|
||||
val recipientAddressRequired: Boolean,
|
||||
val recipientAddressDeadlineAt: String?,
|
||||
val delivery: GiftDeliveryInfoResponse,
|
||||
val statusTimeline: List<GiftStatusTimelineResponse>
|
||||
) {
|
||||
companion object {
|
||||
fun from(result: GiftDetailResult) = GiftDetailResponse(
|
||||
applicationNo = result.applicationNo,
|
||||
direction = result.direction.name,
|
||||
status = result.status.name,
|
||||
statusName = result.statusName,
|
||||
giftInfo = GiftDetailInfoResponse(
|
||||
recipientCreatorNickname = result.giftInfo.recipientCreatorNickname,
|
||||
senderNickname = result.giftInfo.senderNickname,
|
||||
sizeName = result.giftInfo.sizeName,
|
||||
categoryName = result.giftInfo.categoryName,
|
||||
applicationNo = result.giftInfo.applicationNo,
|
||||
paidCan = result.giftInfo.paidCan,
|
||||
tracking = result.giftInfo.tracking,
|
||||
shippingRequestedAt = result.giftInfo.shippingRequestedAt?.toUtcIso()
|
||||
),
|
||||
senderInfo = result.senderInfo?.let { GiftAddressResponse(it.name, it.phoneNumber, it.address) },
|
||||
recipientAddress = result.recipientAddress?.let { GiftAddressResponse(it.name, it.phoneNumber, it.address) },
|
||||
mailbox = result.mailbox?.let { GiftMailboxResponse(it.name, it.address, it.phoneNumber) },
|
||||
trackingRequired = result.trackingRequired,
|
||||
recipientAddressRequired = result.recipientAddressRequired,
|
||||
recipientAddressDeadlineAt = result.recipientAddressDeadlineAt?.toUtcIso(),
|
||||
delivery = GiftDeliveryInfoResponse(
|
||||
canceledAt = result.delivery.canceledAt?.toUtcIso(),
|
||||
undeliverableAt = result.delivery.undeliverableAt?.toUtcIso(),
|
||||
undeliverableReason = result.delivery.undeliverableReason
|
||||
),
|
||||
statusTimeline = result.statusTimeline.map {
|
||||
GiftStatusTimelineResponse(it.status.name, it.statusName, it.occurredAt?.toUtcIso())
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class GiftDetailInfoResponse(
|
||||
val recipientCreatorNickname: String?,
|
||||
val senderNickname: String?,
|
||||
val sizeName: String,
|
||||
val categoryName: String,
|
||||
val applicationNo: String?,
|
||||
val paidCan: Int?,
|
||||
val tracking: String?,
|
||||
val shippingRequestedAt: String?
|
||||
)
|
||||
|
||||
data class GiftAddressResponse(
|
||||
val name: String,
|
||||
val phoneNumber: String,
|
||||
val address: String
|
||||
)
|
||||
|
||||
data class GiftMailboxResponse(
|
||||
val name: String,
|
||||
val address: String,
|
||||
val phoneNumber: String
|
||||
)
|
||||
|
||||
data class GiftDeliveryInfoResponse(
|
||||
val canceledAt: String?,
|
||||
val undeliverableAt: String?,
|
||||
val undeliverableReason: String?
|
||||
)
|
||||
|
||||
data class GiftStatusTimelineResponse(
|
||||
val status: String,
|
||||
val statusName: String,
|
||||
val occurredAt: String?
|
||||
)
|
||||
|
||||
data class GiftApplicationResponse(
|
||||
val applicationNo: String,
|
||||
val status: String,
|
||||
val statusName: String,
|
||||
val priceCan: Int,
|
||||
val trackingDeadlineAt: String
|
||||
) {
|
||||
companion object {
|
||||
fun from(result: GiftApplicationResult) = GiftApplicationResponse(
|
||||
applicationNo = result.applicationNo,
|
||||
status = result.status.name,
|
||||
statusName = result.statusName,
|
||||
priceCan = result.priceCan,
|
||||
trackingDeadlineAt = result.trackingDeadlineAt.toUtcIso()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class GiftCancellationResponse(
|
||||
val applicationNo: String,
|
||||
val status: String,
|
||||
val statusName: String,
|
||||
val priceCan: Int,
|
||||
val canceledAt: String
|
||||
) {
|
||||
companion object {
|
||||
fun from(result: GiftCancellationResult) = GiftCancellationResponse(
|
||||
applicationNo = result.applicationNo,
|
||||
status = result.status.name,
|
||||
statusName = result.statusName,
|
||||
priceCan = result.priceCan,
|
||||
canceledAt = result.canceledAt.toUtcIso()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class GiftTrackingRegistrationResponse(
|
||||
val applicationNo: String,
|
||||
val status: String,
|
||||
val statusName: String,
|
||||
val courierCompanyName: String,
|
||||
val trackingNumber: String,
|
||||
val trackingRegisteredAt: String,
|
||||
val recipientAddressDeadlineAt: String
|
||||
) {
|
||||
companion object {
|
||||
fun from(result: GiftTrackingRegistrationResult) = GiftTrackingRegistrationResponse(
|
||||
applicationNo = result.applicationNo,
|
||||
status = result.status.name,
|
||||
statusName = result.statusName,
|
||||
courierCompanyName = result.courierCompanyName,
|
||||
trackingNumber = result.trackingNumber,
|
||||
trackingRegisteredAt = result.trackingRegisteredAt.toUtcIso(),
|
||||
recipientAddressDeadlineAt = result.recipientAddressDeadlineAt.toUtcIso()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class GiftRecipientAddressRegistrationResponse(
|
||||
val applicationNo: String,
|
||||
val status: String,
|
||||
val statusName: String,
|
||||
val recipientAddressRegisteredAt: String
|
||||
) {
|
||||
companion object {
|
||||
fun from(result: GiftRecipientAddressRegistrationResult) = GiftRecipientAddressRegistrationResponse(
|
||||
applicationNo = result.applicationNo,
|
||||
status = result.status.name,
|
||||
statusName = result.statusName,
|
||||
recipientAddressRegisteredAt = result.recipientAddressRegisteredAt.toUtcIso()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class GiftDeliveryConfirmationResponse(
|
||||
val applicationNo: String,
|
||||
val status: String,
|
||||
val statusName: String,
|
||||
val deliveredAt: String
|
||||
) {
|
||||
companion object {
|
||||
fun from(result: GiftDeliveryConfirmationResult) = GiftDeliveryConfirmationResponse(
|
||||
applicationNo = result.applicationNo,
|
||||
status = result.status.name,
|
||||
statusName = result.statusName,
|
||||
deliveredAt = result.deliveredAt.toUtcIso()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class GiftReviewResponse(
|
||||
val reviewId: Long,
|
||||
val applicationNo: String,
|
||||
val rating: Int,
|
||||
val keywords: List<String>,
|
||||
val comment: String?,
|
||||
val createdAt: String?
|
||||
) {
|
||||
companion object {
|
||||
fun from(result: GiftReviewResult) = GiftReviewResponse(
|
||||
reviewId = result.reviewId,
|
||||
applicationNo = result.applicationNo,
|
||||
rating = result.rating,
|
||||
keywords = result.keywords,
|
||||
comment = result.comment,
|
||||
createdAt = result.createdAt?.toUtcIso()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.gift.dto
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftReviewCommand
|
||||
|
||||
data class GiftReviewRequest(
|
||||
val rating: Int,
|
||||
val keywords: List<String>,
|
||||
val comment: String?
|
||||
) {
|
||||
fun toCommand() = GiftReviewCommand(
|
||||
rating = rating,
|
||||
keywords = keywords,
|
||||
comment = comment
|
||||
)
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.gift.dto
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftTrackingRegistrationCommand
|
||||
|
||||
data class GiftTrackingRegistrationRequest(
|
||||
val courierCompanyName: String,
|
||||
val trackingNumber: String
|
||||
) {
|
||||
fun toCommand() = GiftTrackingRegistrationCommand(
|
||||
courierCompanyName = courierCompanyName,
|
||||
trackingNumber = trackingNumber
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
|
||||
import java.time.LocalDateTime
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.EnumType
|
||||
import javax.persistence.Enumerated
|
||||
import javax.persistence.Table
|
||||
import javax.persistence.UniqueConstraint
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "gift",
|
||||
uniqueConstraints = [UniqueConstraint(name = "uk_gift_application_no", columnNames = ["application_no"])]
|
||||
)
|
||||
class Gift(
|
||||
@Column(name = "application_no", nullable = false, length = 20)
|
||||
val applicationNo: String,
|
||||
|
||||
@Column(name = "sender_member_id", nullable = false)
|
||||
val senderMemberId: Long,
|
||||
|
||||
@Column(name = "recipient_member_id", nullable = false)
|
||||
val recipientMemberId: Long,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 30)
|
||||
var status: GiftStatus,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "size_code", nullable = false, length = 20)
|
||||
val sizeCode: GiftSize,
|
||||
|
||||
@Column(name = "category_id", nullable = false)
|
||||
val categoryId: Long,
|
||||
|
||||
@Column(name = "category_name_snapshot", nullable = false, length = 50)
|
||||
val categoryNameSnapshot: String,
|
||||
|
||||
@Column(name = "sale_price_can", nullable = false)
|
||||
val salePriceCan: Int,
|
||||
|
||||
@Column(name = "can_usage_id", nullable = false)
|
||||
val canUsageId: Long,
|
||||
|
||||
@Column(name = "sender_terms_agreed", nullable = false)
|
||||
val senderTermsAgreed: Boolean,
|
||||
|
||||
@Column(name = "sender_privacy_agreed", nullable = false)
|
||||
val senderPrivacyAgreed: Boolean,
|
||||
|
||||
@Column(name = "sender_terms_agreed_at", nullable = false)
|
||||
val senderTermsAgreedAt: LocalDateTime,
|
||||
|
||||
@Column(name = "sender_privacy_agreed_at", nullable = false)
|
||||
val senderPrivacyAgreedAt: LocalDateTime,
|
||||
|
||||
@Column(name = "recipient_terms_agreed", nullable = false)
|
||||
var recipientTermsAgreed: Boolean = false,
|
||||
|
||||
@Column(name = "recipient_privacy_agreed", nullable = false)
|
||||
var recipientPrivacyAgreed: Boolean = false,
|
||||
|
||||
@Column(name = "recipient_terms_agreed_at")
|
||||
var recipientTermsAgreedAt: LocalDateTime? = null,
|
||||
|
||||
@Column(name = "recipient_privacy_agreed_at")
|
||||
var recipientPrivacyAgreedAt: LocalDateTime? = null,
|
||||
|
||||
@Column(name = "damage_waiver_agreed", nullable = false)
|
||||
val damageWaiverAgreed: Boolean = false
|
||||
) : BaseEntity()
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.Table
|
||||
import javax.persistence.UniqueConstraint
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "gift_application_no_sequence",
|
||||
uniqueConstraints = [
|
||||
UniqueConstraint(
|
||||
name = "uk_gift_application_no_sequence_category_date",
|
||||
columnNames = ["category_id", "sequence_date"]
|
||||
)
|
||||
]
|
||||
)
|
||||
class GiftApplicationNoSequence(
|
||||
@Column(name = "category_id", nullable = false)
|
||||
val categoryId: Long,
|
||||
|
||||
@Column(name = "sequence_date", nullable = false, length = 6)
|
||||
val sequenceDate: String,
|
||||
|
||||
@Column(name = "last_sequence", nullable = false)
|
||||
var lastSequence: Int
|
||||
) : BaseEntity()
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Lock
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import javax.persistence.LockModeType
|
||||
|
||||
interface GiftApplicationNoSequenceRepository : JpaRepository<GiftApplicationNoSequence, Long> {
|
||||
fun findByCategoryIdAndSequenceDate(categoryId: Long, sequenceDate: String): GiftApplicationNoSequence?
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query(
|
||||
"""
|
||||
select s
|
||||
from GiftApplicationNoSequence s
|
||||
where s.categoryId = :categoryId
|
||||
and s.sequenceDate = :sequenceDate
|
||||
"""
|
||||
)
|
||||
fun findByCategoryIdAndSequenceDateForUpdate(
|
||||
@Param("categoryId") categoryId: Long,
|
||||
@Param("sequenceDate") sequenceDate: String
|
||||
): GiftApplicationNoSequence?
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.Table
|
||||
import javax.persistence.UniqueConstraint
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "gift_category",
|
||||
uniqueConstraints = [
|
||||
UniqueConstraint(name = "uk_gift_category_classification_number", columnNames = ["classification_number"]),
|
||||
UniqueConstraint(name = "uk_gift_category_code", columnNames = ["category_code"]),
|
||||
UniqueConstraint(name = "uk_gift_category_receipt_code", columnNames = ["receipt_code"])
|
||||
]
|
||||
)
|
||||
class GiftCategory(
|
||||
@Column(name = "classification_number", nullable = false, length = 3)
|
||||
var classificationNumber: String,
|
||||
|
||||
@Column(name = "category_code", nullable = false, length = 50)
|
||||
val categoryCode: String,
|
||||
|
||||
@Column(name = "name", nullable = false, length = 50)
|
||||
var name: String,
|
||||
|
||||
@Column(name = "receipt_code", nullable = false, length = 9)
|
||||
var receiptCode: String,
|
||||
|
||||
@Column(name = "representative_item", nullable = false, length = 100)
|
||||
var representativeItem: String,
|
||||
|
||||
@Column(name = "requires_damage_waiver", nullable = false)
|
||||
var requiresDamageWaiver: Boolean = false,
|
||||
|
||||
@Column(name = "is_active", nullable = false)
|
||||
var isActive: Boolean = true
|
||||
) : BaseEntity()
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface GiftCategoryRepository : JpaRepository<GiftCategory, Long> {
|
||||
fun findByCategoryCode(categoryCode: String): GiftCategory?
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import java.time.LocalDateTime
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.Table
|
||||
import javax.persistence.UniqueConstraint
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "gift_delivery",
|
||||
uniqueConstraints = [UniqueConstraint(name = "uk_gift_delivery_gift_id", columnNames = ["gift_id"])]
|
||||
)
|
||||
class GiftDelivery(
|
||||
@Column(name = "gift_id", nullable = false)
|
||||
val giftId: Long,
|
||||
|
||||
@Column(name = "sender_name", nullable = false, length = 50)
|
||||
val senderName: String,
|
||||
|
||||
@Column(name = "sender_phone_number", nullable = false, length = 30)
|
||||
val senderPhoneNumber: String,
|
||||
|
||||
@Column(name = "sender_zip_code", nullable = false, length = 20)
|
||||
val senderZipCode: String,
|
||||
|
||||
@Column(name = "sender_address", nullable = false, length = 255)
|
||||
val senderAddress: String,
|
||||
|
||||
@Column(name = "sender_address_detail", length = 255)
|
||||
val senderAddressDetail: String? = null,
|
||||
|
||||
@Column(name = "recipient_name", length = 50)
|
||||
var recipientName: String? = null,
|
||||
|
||||
@Column(name = "recipient_phone_number", length = 30)
|
||||
var recipientPhoneNumber: String? = null,
|
||||
|
||||
@Column(name = "recipient_zip_code", length = 20)
|
||||
var recipientZipCode: String? = null,
|
||||
|
||||
@Column(name = "recipient_address", length = 255)
|
||||
var recipientAddress: String? = null,
|
||||
|
||||
@Column(name = "recipient_address_detail", length = 255)
|
||||
var recipientAddressDetail: String? = null,
|
||||
|
||||
@Column(name = "courier_company_name", length = 50)
|
||||
var courierCompanyName: String? = null,
|
||||
|
||||
@Column(name = "tracking_number", length = 100)
|
||||
var trackingNumber: String? = null,
|
||||
|
||||
@Column(name = "tracking_deadline_at", nullable = false)
|
||||
val trackingDeadlineAt: LocalDateTime,
|
||||
|
||||
@Column(name = "recipient_address_deadline_at")
|
||||
var recipientAddressDeadlineAt: LocalDateTime? = null,
|
||||
|
||||
@Column(name = "tracking_deadline_reminder_sent_at")
|
||||
var trackingDeadlineReminderSentAt: LocalDateTime? = null,
|
||||
|
||||
@Column(name = "recipient_address_deadline_reminder_sent_at")
|
||||
var recipientAddressDeadlineReminderSentAt: LocalDateTime? = null,
|
||||
|
||||
@Column(name = "tracking_registered_at")
|
||||
var trackingRegisteredAt: LocalDateTime? = null,
|
||||
|
||||
@Column(name = "arrived_at_mailbox_at")
|
||||
var arrivedAtMailboxAt: LocalDateTime? = null,
|
||||
|
||||
@Column(name = "inspection_completed_at")
|
||||
var inspectionCompletedAt: LocalDateTime? = null,
|
||||
|
||||
@Column(name = "delivered_at")
|
||||
var deliveredAt: LocalDateTime? = null,
|
||||
|
||||
@Column(name = "canceled_at")
|
||||
var canceledAt: LocalDateTime? = null,
|
||||
|
||||
@Column(name = "undeliverable_at")
|
||||
var undeliverableAt: LocalDateTime? = null,
|
||||
|
||||
@Column(name = "undeliverable_reason", length = 255)
|
||||
var undeliverableReason: String? = null
|
||||
) : BaseEntity()
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface GiftDeliveryRepository : JpaRepository<GiftDelivery, Long> {
|
||||
fun findByGiftId(giftId: Long): GiftDelivery?
|
||||
|
||||
fun findByGiftIdIn(giftIds: Collection<Long>): List<GiftDelivery>
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import java.time.LocalDateTime
|
||||
|
||||
interface GiftRepository : JpaRepository<Gift, Long> {
|
||||
fun findByApplicationNo(applicationNo: String): Gift?
|
||||
|
||||
fun findBySenderMemberId(senderMemberId: Long, pageable: Pageable): Page<Gift>
|
||||
|
||||
fun findByRecipientMemberIdAndStatusIn(
|
||||
recipientMemberId: Long,
|
||||
statuses: Collection<GiftStatus>,
|
||||
pageable: Pageable
|
||||
): Page<Gift>
|
||||
|
||||
fun findBySenderMemberIdOrRecipientMemberIdAndStatusIn(
|
||||
senderMemberId: Long,
|
||||
recipientMemberId: Long,
|
||||
statuses: Collection<GiftStatus>,
|
||||
pageable: Pageable
|
||||
): Page<Gift>
|
||||
|
||||
@Query(
|
||||
value = """
|
||||
select g
|
||||
from Gift g
|
||||
join Member sender on sender.id = g.senderMemberId
|
||||
join Member recipient on recipient.id = g.recipientMemberId
|
||||
where (:status is null or g.status = :status)
|
||||
and (:applicationNo is null or g.applicationNo like concat('%', :applicationNo, '%'))
|
||||
and (
|
||||
:nickname is null
|
||||
or sender.nickname like concat('%', :nickname, '%')
|
||||
or recipient.nickname like concat('%', :nickname, '%')
|
||||
)
|
||||
""",
|
||||
countQuery = """
|
||||
select count(g)
|
||||
from Gift g
|
||||
join Member sender on sender.id = g.senderMemberId
|
||||
join Member recipient on recipient.id = g.recipientMemberId
|
||||
where (:status is null or g.status = :status)
|
||||
and (:applicationNo is null or g.applicationNo like concat('%', :applicationNo, '%'))
|
||||
and (
|
||||
:nickname is null
|
||||
or sender.nickname like concat('%', :nickname, '%')
|
||||
or recipient.nickname like concat('%', :nickname, '%')
|
||||
)
|
||||
"""
|
||||
)
|
||||
fun findAdminGifts(
|
||||
@Param("status") status: GiftStatus?,
|
||||
@Param("applicationNo") applicationNo: String?,
|
||||
@Param("nickname") nickname: String?,
|
||||
pageable: Pageable
|
||||
): Page<Gift>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
select g
|
||||
from Gift g
|
||||
where g.status = kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.RECEIVED
|
||||
and exists (
|
||||
select d.id
|
||||
from GiftDelivery d
|
||||
where d.giftId = g.id
|
||||
and d.trackingDeadlineAt < :now
|
||||
)
|
||||
"""
|
||||
)
|
||||
fun findReceivedGiftsWithTrackingDeadlineBefore(@Param("now") now: LocalDateTime): List<Gift>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
select g
|
||||
from Gift g
|
||||
where g.status = kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.TRACKING_REGISTERED
|
||||
and exists (
|
||||
select d.id
|
||||
from GiftDelivery d
|
||||
where d.giftId = g.id
|
||||
and d.recipientAddressDeadlineAt < :now
|
||||
and d.recipientAddress is null
|
||||
)
|
||||
"""
|
||||
)
|
||||
fun findTrackingRegisteredGiftsWithRecipientAddressDeadlineBefore(
|
||||
@Param("now") now: LocalDateTime
|
||||
): List<Gift>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
select g
|
||||
from Gift g
|
||||
where g.status = kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.RECEIVED
|
||||
and exists (
|
||||
select d.id
|
||||
from GiftDelivery d
|
||||
where d.giftId = g.id
|
||||
and d.trackingDeadlineAt > :now
|
||||
and d.trackingDeadlineAt <= :reminderUntil
|
||||
and d.trackingDeadlineReminderSentAt is null
|
||||
)
|
||||
"""
|
||||
)
|
||||
fun findReceivedGiftsWithTrackingDeadlineReminderDue(
|
||||
@Param("now") now: LocalDateTime,
|
||||
@Param("reminderUntil") reminderUntil: LocalDateTime
|
||||
): List<Gift>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
select g
|
||||
from Gift g
|
||||
where g.status in (
|
||||
kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.TRACKING_REGISTERED,
|
||||
kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.ARRIVED_AT_MAILBOX,
|
||||
kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.INSPECTION_COMPLETED
|
||||
)
|
||||
and exists (
|
||||
select d.id
|
||||
from GiftDelivery d
|
||||
where d.giftId = g.id
|
||||
and d.recipientAddressDeadlineAt > :now
|
||||
and d.recipientAddressDeadlineAt <= :reminderUntil
|
||||
and d.recipientAddress is null
|
||||
and d.recipientAddressDeadlineReminderSentAt is null
|
||||
)
|
||||
"""
|
||||
)
|
||||
fun findGiftsWithRecipientAddressDeadlineReminderDue(
|
||||
@Param("now") now: LocalDateTime,
|
||||
@Param("reminderUntil") reminderUntil: LocalDateTime
|
||||
): List<Gift>
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.Table
|
||||
import javax.persistence.UniqueConstraint
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "gift_review",
|
||||
uniqueConstraints = [UniqueConstraint(name = "uk_gift_review_gift_id", columnNames = ["gift_id"])]
|
||||
)
|
||||
class GiftReview(
|
||||
@Column(name = "gift_id", nullable = false)
|
||||
val giftId: Long,
|
||||
|
||||
@Column(name = "sender_member_id", nullable = false)
|
||||
val senderMemberId: Long,
|
||||
|
||||
@Column(name = "rating", nullable = false)
|
||||
val rating: Int,
|
||||
|
||||
@Column(name = "keywords", columnDefinition = "text")
|
||||
var keywords: String? = null,
|
||||
|
||||
@Column(name = "comment", length = 255)
|
||||
var comment: String? = null
|
||||
) : BaseEntity()
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface GiftReviewRepository : JpaRepository<GiftReview, Long> {
|
||||
fun findByGiftId(giftId: Long): GiftReview?
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.EnumType
|
||||
import javax.persistence.Enumerated
|
||||
import javax.persistence.Table
|
||||
import javax.persistence.UniqueConstraint
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "gift_size_price",
|
||||
uniqueConstraints = [UniqueConstraint(name = "uk_gift_size_price_size_code", columnNames = ["size_code"])]
|
||||
)
|
||||
class GiftSizePrice(
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "size_code", nullable = false, length = 20)
|
||||
val sizeCode: GiftSize,
|
||||
|
||||
@Column(name = "base_price_can", nullable = false)
|
||||
var basePriceCan: Int,
|
||||
|
||||
@Column(name = "sale_price_can", nullable = false)
|
||||
var salePriceCan: Int,
|
||||
|
||||
@Column(name = "is_active", nullable = false)
|
||||
var isActive: Boolean = true
|
||||
) : BaseEntity()
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface GiftSizePriceRepository : JpaRepository<GiftSizePrice, Long> {
|
||||
fun findBySizeCode(sizeCode: GiftSize): GiftSizePrice?
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.adapter.out.scheduler
|
||||
|
||||
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftPushService
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
|
||||
import org.redisson.api.RedissonClient
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.LocalDateTime
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@Component
|
||||
class GiftScheduler(
|
||||
private val giftRepository: GiftRepository,
|
||||
private val giftDeliveryRepository: GiftDeliveryRepository,
|
||||
private val canPaymentService: CanPaymentService,
|
||||
private val giftPushService: GiftPushService,
|
||||
private val redissonClient: RedissonClient
|
||||
) {
|
||||
@Scheduled(cron = "0 0 * * * *", zone = "Asia/Seoul")
|
||||
@Transactional
|
||||
fun runExpiredTrackingRegistrationGiftCancellation() {
|
||||
val lock = redissonClient.getLock(EXPIRED_TRACKING_REGISTRATION_GIFTS_LOCK_KEY)
|
||||
|
||||
try {
|
||||
if (lock.tryLock(0, -1, TimeUnit.SECONDS)) {
|
||||
cancelExpiredTrackingRegistrationGifts(LocalDateTime.now())
|
||||
}
|
||||
} finally {
|
||||
if (lock.isHeldByCurrentThread) {
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun cancelExpiredTrackingRegistrationGifts(now: LocalDateTime): Int {
|
||||
val expiredGifts = giftRepository.findReceivedGiftsWithTrackingDeadlineBefore(now)
|
||||
expiredGifts.forEach { gift ->
|
||||
gift.status = GiftStatus.CANCELED
|
||||
requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id))).canceledAt = now
|
||||
canPaymentService.refundGiftCan(gift.senderMemberId, gift.canUsageId)
|
||||
giftPushService.sendAutoCanceledToSender(gift.applicationNo, gift.senderMemberId)
|
||||
}
|
||||
|
||||
return expiredGifts.size
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun markExpiredRecipientAddressGiftsUndeliverable(now: LocalDateTime): Int {
|
||||
val expiredGifts = giftRepository.findTrackingRegisteredGiftsWithRecipientAddressDeadlineBefore(now)
|
||||
expiredGifts.forEach { gift ->
|
||||
gift.status = GiftStatus.UNDELIVERABLE
|
||||
val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)))
|
||||
delivery.undeliverableAt = now
|
||||
delivery.undeliverableReason = EXPIRED_RECIPIENT_ADDRESS_REASON
|
||||
}
|
||||
|
||||
return expiredGifts.size
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun sendDeadlineReminderGifts(now: LocalDateTime): Int {
|
||||
val reminderUntil = now.plusHours(DEADLINE_REMINDER_HOURS)
|
||||
val trackingReminderCount = sendTrackingDeadlineReminders(now, reminderUntil)
|
||||
val recipientAddressReminderCount = sendRecipientAddressDeadlineReminders(now, reminderUntil)
|
||||
|
||||
return trackingReminderCount + recipientAddressReminderCount
|
||||
}
|
||||
|
||||
private fun sendTrackingDeadlineReminders(now: LocalDateTime, reminderUntil: LocalDateTime): Int {
|
||||
val gifts = giftRepository.findReceivedGiftsWithTrackingDeadlineReminderDue(now, reminderUntil)
|
||||
gifts.forEach { gift ->
|
||||
val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)))
|
||||
delivery.trackingDeadlineReminderSentAt = now
|
||||
giftPushService.sendTrackingDeadlineReminder(gift.applicationNo, gift.senderMemberId)
|
||||
}
|
||||
|
||||
return gifts.size
|
||||
}
|
||||
|
||||
private fun sendRecipientAddressDeadlineReminders(now: LocalDateTime, reminderUntil: LocalDateTime): Int {
|
||||
val gifts = giftRepository.findGiftsWithRecipientAddressDeadlineReminderDue(now, reminderUntil)
|
||||
gifts.forEach { gift ->
|
||||
val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)))
|
||||
delivery.recipientAddressDeadlineReminderSentAt = now
|
||||
giftPushService.sendRecipientAddressDeadlineReminder(gift.applicationNo, gift.recipientMemberId)
|
||||
}
|
||||
|
||||
return gifts.size
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DEADLINE_REMINDER_HOURS = 24L
|
||||
const val EXPIRED_TRACKING_REGISTRATION_GIFTS_LOCK_KEY = "lock:gift-expired-tracking-registration-cancellation"
|
||||
const val EXPIRED_RECIPIENT_ADDRESS_REASON = "배송지 미입력 기한 초과"
|
||||
}
|
||||
}
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.application
|
||||
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.data.domain.Sort
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
data class AdminGiftListResult(
|
||||
val totalCount: Long,
|
||||
val items: List<AdminGiftListItemResult>,
|
||||
val page: Int,
|
||||
val size: Int,
|
||||
val hasNext: Boolean
|
||||
)
|
||||
|
||||
data class AdminGiftListItemResult(
|
||||
val applicationNo: String,
|
||||
val senderNickname: String,
|
||||
val recipientNickname: String,
|
||||
val sizeCode: String,
|
||||
val sizeName: String,
|
||||
val categoryName: String,
|
||||
val classificationNumber: String,
|
||||
val courierCompanyName: String?,
|
||||
val trackingNumber: String?,
|
||||
val status: GiftStatus,
|
||||
val statusName: String,
|
||||
val availableActions: List<String>
|
||||
)
|
||||
|
||||
data class AdminGiftDetailResult(
|
||||
val applicationNo: String,
|
||||
val senderInfo: AdminGiftMemberInfoResult,
|
||||
val recipientInfo: AdminGiftMemberInfoResult,
|
||||
val productInfo: AdminGiftProductInfoResult,
|
||||
val inboundDeliveryInfo: AdminGiftInboundDeliveryInfoResult,
|
||||
val status: GiftStatus,
|
||||
val statusName: String,
|
||||
val availableActions: List<String>
|
||||
)
|
||||
|
||||
data class AdminGiftMemberInfoResult(
|
||||
val nickname: String,
|
||||
val name: String,
|
||||
val phoneNumber: String,
|
||||
val address: String
|
||||
)
|
||||
|
||||
data class AdminGiftProductInfoResult(
|
||||
val sizeCode: String,
|
||||
val sizeName: String,
|
||||
val categoryName: String,
|
||||
val classificationNumber: String
|
||||
)
|
||||
|
||||
data class AdminGiftInboundDeliveryInfoResult(
|
||||
val courierCompanyName: String?,
|
||||
val trackingNumber: String?
|
||||
)
|
||||
|
||||
@Service
|
||||
class GiftAdminQueryService(
|
||||
private val giftRepository: GiftRepository,
|
||||
private val deliveryRepository: GiftDeliveryRepository,
|
||||
private val categoryRepository: GiftCategoryRepository,
|
||||
private val memberRepository: MemberRepository
|
||||
) {
|
||||
@Transactional(readOnly = true)
|
||||
fun getGifts(
|
||||
status: GiftStatus?,
|
||||
applicationNo: String?,
|
||||
nickname: String?,
|
||||
page: Int,
|
||||
size: Int
|
||||
): AdminGiftListResult {
|
||||
val normalizedPage = page.coerceAtLeast(0)
|
||||
val normalizedSize = if (size <= 0) DEFAULT_PAGE_SIZE else size
|
||||
val gifts = giftRepository.findAdminGifts(
|
||||
status = status,
|
||||
applicationNo = applicationNo?.takeIf { it.isNotBlank() },
|
||||
nickname = nickname?.takeIf { it.isNotBlank() },
|
||||
pageable = PageRequest.of(normalizedPage, normalizedSize, Sort.by(Sort.Direction.DESC, "createdAt", "id"))
|
||||
)
|
||||
val context = buildContext(gifts.content)
|
||||
|
||||
return AdminGiftListResult(
|
||||
totalCount = gifts.totalElements,
|
||||
items = gifts.content.map { gift -> gift.toListItem(context) },
|
||||
page = normalizedPage,
|
||||
size = normalizedSize,
|
||||
hasNext = gifts.hasNext()
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getGiftDetail(applicationNo: String): AdminGiftDetailResult {
|
||||
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
|
||||
val context = buildContext(listOf(gift))
|
||||
val delivery = context.delivery(gift)
|
||||
val sender = context.sender(gift)
|
||||
val recipient = context.recipient(gift)
|
||||
val category = context.category(gift)
|
||||
|
||||
return AdminGiftDetailResult(
|
||||
applicationNo = gift.applicationNo,
|
||||
senderInfo = AdminGiftMemberInfoResult(
|
||||
nickname = sender.nickname,
|
||||
name = delivery.senderName,
|
||||
phoneNumber = delivery.senderPhoneNumber,
|
||||
address = formatAddress(delivery.senderZipCode, delivery.senderAddress, delivery.senderAddressDetail)
|
||||
),
|
||||
recipientInfo = AdminGiftMemberInfoResult(
|
||||
nickname = recipient.nickname,
|
||||
name = delivery.recipientName.orEmpty(),
|
||||
phoneNumber = delivery.recipientPhoneNumber.orEmpty(),
|
||||
address = delivery.recipientAddressResult()
|
||||
),
|
||||
productInfo = AdminGiftProductInfoResult(
|
||||
sizeCode = gift.sizeCode.name,
|
||||
sizeName = gift.sizeCode.displayName,
|
||||
categoryName = gift.categoryNameSnapshot,
|
||||
classificationNumber = category.classificationNumber
|
||||
),
|
||||
inboundDeliveryInfo = AdminGiftInboundDeliveryInfoResult(
|
||||
courierCompanyName = delivery.courierCompanyName,
|
||||
trackingNumber = delivery.trackingNumber
|
||||
),
|
||||
status = gift.status,
|
||||
statusName = gift.status.displayName,
|
||||
availableActions = gift.status.availableActions()
|
||||
)
|
||||
}
|
||||
|
||||
private fun Gift.toListItem(context: GiftAdminQueryContext) = AdminGiftListItemResult(
|
||||
applicationNo = applicationNo,
|
||||
senderNickname = context.sender(this).nickname,
|
||||
recipientNickname = context.recipient(this).nickname,
|
||||
sizeCode = sizeCode.name,
|
||||
sizeName = sizeCode.displayName,
|
||||
categoryName = categoryNameSnapshot,
|
||||
classificationNumber = context.category(this).classificationNumber,
|
||||
courierCompanyName = context.delivery(this).courierCompanyName,
|
||||
trackingNumber = context.delivery(this).trackingNumber,
|
||||
status = status,
|
||||
statusName = status.displayName,
|
||||
availableActions = status.availableActions()
|
||||
)
|
||||
|
||||
private fun GiftStatus.availableActions() = when (this) {
|
||||
GiftStatus.TRACKING_REGISTERED -> listOf("ARRIVE_MAILBOX", "MARK_UNDELIVERABLE")
|
||||
GiftStatus.ARRIVED_AT_MAILBOX -> listOf("COMPLETE_INSPECTION", "MARK_UNDELIVERABLE")
|
||||
GiftStatus.INSPECTION_COMPLETED -> listOf("COMPLETE_DELIVERY", "MARK_UNDELIVERABLE")
|
||||
GiftStatus.RECEIVED, GiftStatus.DELIVERED, GiftStatus.UNDELIVERABLE, GiftStatus.CANCELED -> emptyList()
|
||||
}
|
||||
|
||||
private fun GiftDelivery.recipientAddressResult(): String {
|
||||
val zipCode = recipientZipCode ?: return ""
|
||||
val address = recipientAddress ?: return ""
|
||||
return formatAddress(zipCode, address, recipientAddressDetail)
|
||||
}
|
||||
|
||||
private fun formatAddress(zipCode: String, address: String, detail: String?) =
|
||||
"($zipCode) $address" + if (detail.isNullOrBlank()) "" else ", $detail"
|
||||
|
||||
private fun invalidRequest() = SodaException(messageKey = "common.error.invalid_request")
|
||||
|
||||
private fun buildContext(gifts: List<Gift>): GiftAdminQueryContext {
|
||||
val giftIds = gifts.mapNotNull { it.id }
|
||||
val memberIds = gifts.flatMap { listOf(it.senderMemberId, it.recipientMemberId) }.toSet()
|
||||
val categoryIds = gifts.map { it.categoryId }.toSet()
|
||||
return GiftAdminQueryContext(
|
||||
deliveriesByGiftId = deliveryRepository.findByGiftIdIn(giftIds).associateBy { it.giftId },
|
||||
membersById = memberRepository.findAllById(memberIds).associateBy { it.id ?: throw invalidRequest() },
|
||||
categoriesById = categoryRepository.findAllById(categoryIds).associateBy { it.id ?: throw invalidRequest() }
|
||||
)
|
||||
}
|
||||
|
||||
private inner class GiftAdminQueryContext(
|
||||
private val deliveriesByGiftId: Map<Long, GiftDelivery>,
|
||||
private val membersById: Map<Long, Member>,
|
||||
private val categoriesById: Map<Long, GiftCategory>
|
||||
) {
|
||||
fun delivery(gift: Gift) = deliveriesByGiftId[gift.id] ?: throw invalidRequest()
|
||||
|
||||
fun sender(gift: Gift) = membersById[gift.senderMemberId] ?: throw invalidRequest()
|
||||
|
||||
fun recipient(gift: Gift) = membersById[gift.recipientMemberId] ?: throw invalidRequest()
|
||||
|
||||
fun category(gift: Gift) = categoriesById[gift.categoryId] ?: throw invalidRequest()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DEFAULT_PAGE_SIZE = 20
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.application
|
||||
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePrice
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.LocalDateTime
|
||||
|
||||
data class GiftCategoryCommand(
|
||||
val classificationNumber: String,
|
||||
val categoryCode: String,
|
||||
val name: String,
|
||||
val receiptCode: String,
|
||||
val representativeItem: String,
|
||||
val requiresDamageWaiver: Boolean,
|
||||
val isActive: Boolean
|
||||
)
|
||||
|
||||
data class GiftCategoryResult(
|
||||
val categoryId: Long,
|
||||
val classificationNumber: String,
|
||||
val categoryCode: String,
|
||||
val name: String,
|
||||
val receiptCode: String,
|
||||
val representativeItem: String,
|
||||
val requiresDamageWaiver: Boolean,
|
||||
val isActive: Boolean
|
||||
)
|
||||
|
||||
data class GiftSizePriceCommand(
|
||||
val basePriceCan: Int,
|
||||
val salePriceCan: Int,
|
||||
val isActive: Boolean
|
||||
)
|
||||
|
||||
data class GiftSizePriceResult(
|
||||
val sizeCode: GiftSize,
|
||||
val sizeName: String,
|
||||
val basePriceCan: Int,
|
||||
val salePriceCan: Int,
|
||||
val isActive: Boolean
|
||||
)
|
||||
|
||||
data class GiftOperationStatusResult(
|
||||
val applicationNo: String,
|
||||
val status: GiftStatus,
|
||||
val statusName: String,
|
||||
val occurredAt: LocalDateTime
|
||||
)
|
||||
|
||||
@Service
|
||||
class GiftAdminService(
|
||||
private val categoryRepository: GiftCategoryRepository,
|
||||
private val sizePriceRepository: GiftSizePriceRepository,
|
||||
private val giftRepository: GiftRepository,
|
||||
private val giftDeliveryRepository: GiftDeliveryRepository,
|
||||
private val giftPushService: GiftPushService
|
||||
) {
|
||||
@Transactional(readOnly = true)
|
||||
fun getCategories(): List<GiftCategoryResult> = categoryRepository.findAll().map { it.toResult() }
|
||||
|
||||
@Transactional
|
||||
fun createCategory(command: GiftCategoryCommand): GiftCategoryResult {
|
||||
validateCategory(command)
|
||||
return categoryRepository.save(
|
||||
GiftCategory(
|
||||
classificationNumber = command.classificationNumber,
|
||||
categoryCode = command.categoryCode,
|
||||
name = command.name,
|
||||
receiptCode = command.receiptCode,
|
||||
representativeItem = command.representativeItem,
|
||||
requiresDamageWaiver = command.requiresDamageWaiver,
|
||||
isActive = command.isActive
|
||||
)
|
||||
).toResult()
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun updateCategory(categoryId: Long, command: GiftCategoryCommand): GiftCategoryResult {
|
||||
validateCategory(command)
|
||||
val category = categoryRepository.findByIdOrNull(categoryId) ?: throw invalidRequest()
|
||||
category.classificationNumber = command.classificationNumber
|
||||
category.name = command.name
|
||||
category.receiptCode = command.receiptCode
|
||||
category.representativeItem = command.representativeItem
|
||||
category.requiresDamageWaiver = command.requiresDamageWaiver
|
||||
category.isActive = command.isActive
|
||||
return category.toResult()
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun deleteCategory(categoryId: Long): GiftCategoryResult {
|
||||
val category = categoryRepository.findByIdOrNull(categoryId) ?: throw invalidRequest()
|
||||
category.isActive = false
|
||||
return category.toResult()
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getSizePrices(): List<GiftSizePriceResult> = sizePriceRepository.findAll().map { it.toResult() }
|
||||
|
||||
@Transactional
|
||||
fun updateSizePrice(sizeCode: GiftSize, command: GiftSizePriceCommand): GiftSizePriceResult {
|
||||
validateSizePrice(command)
|
||||
val sizePrice = sizePriceRepository.findBySizeCode(sizeCode)
|
||||
?: GiftSizePrice(sizeCode = sizeCode, basePriceCan = command.basePriceCan, salePriceCan = command.salePriceCan)
|
||||
sizePrice.basePriceCan = command.basePriceCan
|
||||
sizePrice.salePriceCan = command.salePriceCan
|
||||
sizePrice.isActive = command.isActive
|
||||
return sizePriceRepository.save(sizePrice).toResult()
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun arriveMailbox(applicationNo: String, now: LocalDateTime = LocalDateTime.now()): GiftOperationStatusResult {
|
||||
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
|
||||
if (gift.status != GiftStatus.TRACKING_REGISTERED) throw invalidRequest()
|
||||
val delivery = giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)) ?: throw invalidRequest()
|
||||
|
||||
gift.status = GiftStatus.ARRIVED_AT_MAILBOX
|
||||
delivery.arrivedAtMailboxAt = now
|
||||
giftPushService.sendArrivedAtMailboxToSender(gift.applicationNo, gift.senderMemberId)
|
||||
giftPushService.sendArrivedAtMailboxToRecipient(gift.applicationNo, gift.recipientMemberId)
|
||||
|
||||
return GiftOperationStatusResult(gift.applicationNo, gift.status, gift.status.displayName, now)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun completeInspection(applicationNo: String, now: LocalDateTime = LocalDateTime.now()): GiftOperationStatusResult {
|
||||
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
|
||||
if (gift.status != GiftStatus.ARRIVED_AT_MAILBOX) throw invalidRequest()
|
||||
val delivery = giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)) ?: throw invalidRequest()
|
||||
|
||||
gift.status = GiftStatus.INSPECTION_COMPLETED
|
||||
delivery.inspectionCompletedAt = now
|
||||
giftPushService.sendInspectionCompleted(gift.applicationNo, gift.recipientMemberId)
|
||||
|
||||
return GiftOperationStatusResult(gift.applicationNo, gift.status, gift.status.displayName, now)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun markUndeliverable(
|
||||
applicationNo: String,
|
||||
reason: String,
|
||||
now: LocalDateTime = LocalDateTime.now()
|
||||
): GiftOperationStatusResult {
|
||||
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
|
||||
if (gift.status !in UNDELIVERABLE_ALLOWED_STATUSES || reason.isBlank() || reason.length > 255) {
|
||||
throw invalidRequest()
|
||||
}
|
||||
val delivery = giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)) ?: throw invalidRequest()
|
||||
|
||||
gift.status = GiftStatus.UNDELIVERABLE
|
||||
delivery.undeliverableAt = now
|
||||
delivery.undeliverableReason = reason
|
||||
giftPushService.sendUndeliverableToSender(gift.applicationNo, gift.senderMemberId)
|
||||
giftPushService.sendUndeliverableToRecipient(gift.applicationNo, gift.recipientMemberId)
|
||||
|
||||
return GiftOperationStatusResult(gift.applicationNo, gift.status, gift.status.displayName, now)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun completeDelivery(applicationNo: String, now: LocalDateTime = LocalDateTime.now()): GiftOperationStatusResult {
|
||||
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
|
||||
if (gift.status != GiftStatus.INSPECTION_COMPLETED) throw invalidRequest()
|
||||
val delivery = giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)) ?: throw invalidRequest()
|
||||
|
||||
gift.status = GiftStatus.DELIVERED
|
||||
delivery.deliveredAt = now
|
||||
giftPushService.sendDeliveredToSender(gift.applicationNo, gift.senderMemberId)
|
||||
giftPushService.sendDeliveredToRecipient(gift.applicationNo, gift.recipientMemberId)
|
||||
|
||||
return GiftOperationStatusResult(gift.applicationNo, gift.status, gift.status.displayName, now)
|
||||
}
|
||||
|
||||
private fun validateCategory(command: GiftCategoryCommand) {
|
||||
if (
|
||||
command.categoryCode.isBlank() ||
|
||||
command.categoryCode.length > 50 ||
|
||||
command.name.isBlank() ||
|
||||
command.name.length > 50 ||
|
||||
!CLASSIFICATION_NUMBER_REGEX.matches(command.classificationNumber) ||
|
||||
!RECEIPT_CODE_REGEX.matches(command.receiptCode) ||
|
||||
command.representativeItem.isBlank() ||
|
||||
command.representativeItem.length > 100
|
||||
) {
|
||||
throw invalidRequest()
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateSizePrice(command: GiftSizePriceCommand) {
|
||||
if (command.salePriceCan <= 0 || command.salePriceCan > command.basePriceCan) throw invalidRequest()
|
||||
}
|
||||
|
||||
private fun GiftCategory.toResult() = GiftCategoryResult(
|
||||
categoryId = id ?: throw invalidRequest(),
|
||||
classificationNumber = classificationNumber,
|
||||
categoryCode = categoryCode,
|
||||
name = name,
|
||||
receiptCode = receiptCode,
|
||||
representativeItem = representativeItem,
|
||||
requiresDamageWaiver = requiresDamageWaiver,
|
||||
isActive = isActive
|
||||
)
|
||||
|
||||
private fun GiftSizePrice.toResult() = GiftSizePriceResult(
|
||||
sizeCode = sizeCode,
|
||||
sizeName = sizeCode.displayName,
|
||||
basePriceCan = basePriceCan,
|
||||
salePriceCan = salePriceCan,
|
||||
isActive = isActive
|
||||
)
|
||||
|
||||
private fun invalidRequest() = SodaException(messageKey = "common.error.invalid_request")
|
||||
|
||||
private companion object {
|
||||
val UNDELIVERABLE_ALLOWED_STATUSES = setOf(
|
||||
GiftStatus.TRACKING_REGISTERED,
|
||||
GiftStatus.ARRIVED_AT_MAILBOX,
|
||||
GiftStatus.INSPECTION_COMPLETED
|
||||
)
|
||||
val CLASSIFICATION_NUMBER_REGEX = Regex("\\d{3}")
|
||||
val RECEIPT_CODE_REGEX = Regex("[A-Z]{1,9}")
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.application
|
||||
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftApplicationNoSequence
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftApplicationNoSequenceRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory
|
||||
import org.springframework.dao.DataIntegrityViolationException
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.PlatformTransactionManager
|
||||
import org.springframework.transaction.support.TransactionTemplate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@Service
|
||||
class GiftApplicationNoGenerator(
|
||||
private val repository: GiftApplicationNoSequenceRepository,
|
||||
transactionManager: PlatformTransactionManager
|
||||
) {
|
||||
private val transactionTemplate = TransactionTemplate(transactionManager)
|
||||
|
||||
fun generate(category: GiftCategory, now: LocalDateTime = LocalDateTime.now(SEOUL_ZONE)): String {
|
||||
val categoryId = category.id ?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||
val sequenceDate = now.format(DATE_FORMATTER)
|
||||
for (attempt in 1..MAX_RETRY_COUNT) {
|
||||
try {
|
||||
return transactionTemplate.execute {
|
||||
val sequence = repository.findByCategoryIdAndSequenceDateForUpdate(categoryId, sequenceDate)
|
||||
?: repository.saveAndFlush(
|
||||
GiftApplicationNoSequence(
|
||||
categoryId = categoryId,
|
||||
sequenceDate = sequenceDate,
|
||||
lastSequence = 0
|
||||
)
|
||||
)
|
||||
sequence.lastSequence += 1
|
||||
"${category.receiptCode}-${category.classificationNumber}$sequenceDate" +
|
||||
sequence.lastSequence.toString().padStart(4, '0')
|
||||
} ?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||
} catch (_: DataIntegrityViolationException) {
|
||||
if (attempt == MAX_RETRY_COUNT) throw SodaException(messageKey = "gift.application_no.generate_failed")
|
||||
}
|
||||
}
|
||||
throw SodaException(messageKey = "gift.application_no.generate_failed")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val SEOUL_ZONE = ZoneId.of("Asia/Seoul")
|
||||
private val DATE_FORMATTER = DateTimeFormatter.ofPattern("yyMMdd")
|
||||
private const val MAX_RETRY_COUNT = 3
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.application
|
||||
|
||||
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftReview
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftReviewRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.LocalDateTime
|
||||
|
||||
data class GiftApplicationCommand(
|
||||
val recipientMemberId: Long,
|
||||
val senderName: String,
|
||||
val senderPhoneNumber: String,
|
||||
val senderZipCode: String,
|
||||
val senderAddress: String,
|
||||
val senderAddressDetail: String?,
|
||||
val sizeCode: GiftSize,
|
||||
val categoryId: Long,
|
||||
val senderTermsAgreed: Boolean,
|
||||
val senderPrivacyAgreed: Boolean,
|
||||
val damageWaiverAgreed: Boolean
|
||||
)
|
||||
|
||||
data class GiftApplicationResult(
|
||||
val applicationNo: String,
|
||||
val status: GiftStatus,
|
||||
val statusName: String,
|
||||
val priceCan: Int,
|
||||
val trackingDeadlineAt: LocalDateTime
|
||||
)
|
||||
|
||||
data class GiftCancellationResult(
|
||||
val applicationNo: String,
|
||||
val status: GiftStatus,
|
||||
val statusName: String,
|
||||
val priceCan: Int,
|
||||
val canceledAt: LocalDateTime
|
||||
)
|
||||
|
||||
data class GiftTrackingRegistrationCommand(
|
||||
val courierCompanyName: String,
|
||||
val trackingNumber: String
|
||||
)
|
||||
|
||||
data class GiftTrackingRegistrationResult(
|
||||
val applicationNo: String,
|
||||
val status: GiftStatus,
|
||||
val statusName: String,
|
||||
val courierCompanyName: String,
|
||||
val trackingNumber: String,
|
||||
val trackingRegisteredAt: LocalDateTime,
|
||||
val recipientAddressDeadlineAt: LocalDateTime
|
||||
)
|
||||
|
||||
data class GiftRecipientAddressRegistrationCommand(
|
||||
val name: String,
|
||||
val phoneNumber: String,
|
||||
val zipCode: String,
|
||||
val address: String,
|
||||
val addressDetail: String?,
|
||||
val recipientTermsAgreed: Boolean,
|
||||
val recipientPrivacyAgreed: Boolean
|
||||
)
|
||||
|
||||
data class GiftRecipientAddressRegistrationResult(
|
||||
val applicationNo: String,
|
||||
val status: GiftStatus,
|
||||
val statusName: String,
|
||||
val recipientAddressRegisteredAt: LocalDateTime
|
||||
)
|
||||
|
||||
data class GiftDeliveryConfirmationResult(
|
||||
val applicationNo: String,
|
||||
val status: GiftStatus,
|
||||
val statusName: String,
|
||||
val deliveredAt: LocalDateTime
|
||||
)
|
||||
|
||||
data class GiftReviewCommand(
|
||||
val rating: Int,
|
||||
val keywords: List<String>,
|
||||
val comment: String?
|
||||
)
|
||||
|
||||
data class GiftReviewResult(
|
||||
val reviewId: Long,
|
||||
val applicationNo: String,
|
||||
val rating: Int,
|
||||
val keywords: List<String>,
|
||||
val comment: String?,
|
||||
val createdAt: LocalDateTime?
|
||||
)
|
||||
|
||||
@Service
|
||||
class GiftCommandService(
|
||||
private val giftRepository: GiftRepository,
|
||||
private val deliveryRepository: GiftDeliveryRepository,
|
||||
private val reviewRepository: GiftReviewRepository,
|
||||
private val categoryRepository: GiftCategoryRepository,
|
||||
private val sizePriceRepository: GiftSizePriceRepository,
|
||||
private val memberRepository: MemberRepository,
|
||||
private val applicationNoGenerator: GiftApplicationNoGenerator,
|
||||
private val canPaymentService: CanPaymentService,
|
||||
private val giftPushService: GiftPushService,
|
||||
private val now: () -> LocalDateTime = { LocalDateTime.now() }
|
||||
) {
|
||||
@Transactional
|
||||
fun registerGift(member: Member, command: GiftApplicationCommand): GiftApplicationResult {
|
||||
validate(command)
|
||||
val senderMemberId = member.id ?: throw invalidRequest()
|
||||
memberRepository.findCreatorByIdOrNull(command.recipientMemberId) ?: throw invalidRequest()
|
||||
val category = categoryRepository.findByIdOrNull(command.categoryId)?.takeIf { it.isActive } ?: throw invalidRequest()
|
||||
if (category.requiresDamageWaiver && !command.damageWaiverAgreed) throw invalidRequest()
|
||||
val sizePrice = sizePriceRepository.findBySizeCode(command.sizeCode)?.takeIf { it.isActive } ?: throw invalidRequest()
|
||||
val appliedAt = now()
|
||||
val trackingDeadlineAt = appliedAt.plusDays(3)
|
||||
val applicationNo = applicationNoGenerator.generate(category, appliedAt)
|
||||
val canUsageId = canPaymentService.spendGiftCan(
|
||||
senderMemberId,
|
||||
sizePrice.salePriceCan,
|
||||
member.container.ifBlank { "web" }
|
||||
)
|
||||
val gift = giftRepository.save(
|
||||
Gift(
|
||||
applicationNo = applicationNo,
|
||||
senderMemberId = senderMemberId,
|
||||
recipientMemberId = command.recipientMemberId,
|
||||
status = GiftStatus.RECEIVED,
|
||||
sizeCode = command.sizeCode,
|
||||
categoryId = category.id ?: throw invalidRequest(),
|
||||
categoryNameSnapshot = category.name,
|
||||
salePriceCan = sizePrice.salePriceCan,
|
||||
canUsageId = canUsageId,
|
||||
senderTermsAgreed = command.senderTermsAgreed,
|
||||
senderPrivacyAgreed = command.senderPrivacyAgreed,
|
||||
senderTermsAgreedAt = appliedAt,
|
||||
senderPrivacyAgreedAt = appliedAt,
|
||||
damageWaiverAgreed = command.damageWaiverAgreed
|
||||
)
|
||||
)
|
||||
deliveryRepository.save(
|
||||
GiftDelivery(
|
||||
giftId = gift.id ?: throw invalidRequest(),
|
||||
senderName = command.senderName,
|
||||
senderPhoneNumber = command.senderPhoneNumber,
|
||||
senderZipCode = command.senderZipCode,
|
||||
senderAddress = command.senderAddress,
|
||||
senderAddressDetail = command.senderAddressDetail,
|
||||
trackingDeadlineAt = trackingDeadlineAt
|
||||
)
|
||||
)
|
||||
return GiftApplicationResult(
|
||||
applicationNo = applicationNo,
|
||||
status = GiftStatus.RECEIVED,
|
||||
statusName = GiftStatus.RECEIVED.displayName,
|
||||
priceCan = sizePrice.salePriceCan,
|
||||
trackingDeadlineAt = trackingDeadlineAt
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun writeReview(member: Member, applicationNo: String, command: GiftReviewCommand): GiftReviewResult {
|
||||
val senderMemberId = member.id ?: throw invalidRequest()
|
||||
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
|
||||
val giftId = gift.id ?: throw invalidRequest()
|
||||
if (
|
||||
gift.senderMemberId != senderMemberId ||
|
||||
gift.status != GiftStatus.DELIVERED ||
|
||||
reviewRepository.findByGiftId(giftId) != null
|
||||
) {
|
||||
throw invalidRequest()
|
||||
}
|
||||
validate(command)
|
||||
|
||||
val review = reviewRepository.save(
|
||||
GiftReview(
|
||||
giftId = giftId,
|
||||
senderMemberId = senderMemberId,
|
||||
rating = command.rating,
|
||||
keywords = command.keywords.joinToString(","),
|
||||
comment = command.comment
|
||||
)
|
||||
)
|
||||
|
||||
return GiftReviewResult(
|
||||
reviewId = review.id ?: throw invalidRequest(),
|
||||
applicationNo = gift.applicationNo,
|
||||
rating = review.rating,
|
||||
keywords = review.keywords?.takeIf { it.isNotBlank() }?.split(",") ?: emptyList(),
|
||||
comment = review.comment,
|
||||
createdAt = review.createdAt
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun cancelGift(member: Member, applicationNo: String): GiftCancellationResult {
|
||||
val senderMemberId = member.id ?: throw invalidRequest()
|
||||
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
|
||||
if (gift.senderMemberId != senderMemberId || gift.status != GiftStatus.RECEIVED) throw invalidRequest()
|
||||
|
||||
val canceledAt = now()
|
||||
gift.status = GiftStatus.CANCELED
|
||||
deliveryRepository.findByGiftId(gift.id ?: throw invalidRequest())?.canceledAt = canceledAt
|
||||
canPaymentService.refundGiftCan(senderMemberId, gift.canUsageId)
|
||||
|
||||
return GiftCancellationResult(
|
||||
applicationNo = gift.applicationNo,
|
||||
status = gift.status,
|
||||
statusName = gift.status.displayName,
|
||||
priceCan = gift.salePriceCan,
|
||||
canceledAt = canceledAt
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun registerTracking(
|
||||
member: Member,
|
||||
applicationNo: String,
|
||||
command: GiftTrackingRegistrationCommand
|
||||
): GiftTrackingRegistrationResult {
|
||||
val senderMemberId = member.id ?: throw invalidRequest()
|
||||
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
|
||||
if (gift.senderMemberId != senderMemberId || gift.status != GiftStatus.RECEIVED) throw invalidRequest()
|
||||
validate(command)
|
||||
val delivery = deliveryRepository.findByGiftId(gift.id ?: throw invalidRequest()) ?: throw invalidRequest()
|
||||
val trackingRegisteredAt = now()
|
||||
val recipientAddressDeadlineAt = trackingRegisteredAt.plusDays(7)
|
||||
|
||||
gift.status = GiftStatus.TRACKING_REGISTERED
|
||||
delivery.courierCompanyName = command.courierCompanyName
|
||||
delivery.trackingNumber = command.trackingNumber
|
||||
delivery.trackingRegisteredAt = trackingRegisteredAt
|
||||
delivery.recipientAddressDeadlineAt = recipientAddressDeadlineAt
|
||||
giftPushService.sendTrackingRegisteredToRecipient(gift.applicationNo, gift.recipientMemberId)
|
||||
|
||||
return GiftTrackingRegistrationResult(
|
||||
applicationNo = gift.applicationNo,
|
||||
status = gift.status,
|
||||
statusName = gift.status.displayName,
|
||||
courierCompanyName = command.courierCompanyName,
|
||||
trackingNumber = command.trackingNumber,
|
||||
trackingRegisteredAt = trackingRegisteredAt,
|
||||
recipientAddressDeadlineAt = recipientAddressDeadlineAt
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun registerRecipientAddress(
|
||||
member: Member,
|
||||
applicationNo: String,
|
||||
command: GiftRecipientAddressRegistrationCommand
|
||||
): GiftRecipientAddressRegistrationResult {
|
||||
val recipientMemberId = member.id ?: throw invalidRequest()
|
||||
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
|
||||
if (gift.recipientMemberId != recipientMemberId || gift.status !in RECIPIENT_ADDRESS_REGISTRABLE_STATUSES) {
|
||||
throw invalidRequest()
|
||||
}
|
||||
validate(command)
|
||||
val delivery = deliveryRepository.findByGiftId(gift.id ?: throw invalidRequest()) ?: throw invalidRequest()
|
||||
val registeredAt = now()
|
||||
|
||||
delivery.recipientName = command.name
|
||||
delivery.recipientPhoneNumber = command.phoneNumber
|
||||
delivery.recipientZipCode = command.zipCode
|
||||
delivery.recipientAddress = command.address
|
||||
delivery.recipientAddressDetail = command.addressDetail
|
||||
gift.recipientTermsAgreed = command.recipientTermsAgreed
|
||||
gift.recipientPrivacyAgreed = command.recipientPrivacyAgreed
|
||||
gift.recipientTermsAgreedAt = registeredAt
|
||||
gift.recipientPrivacyAgreedAt = registeredAt
|
||||
|
||||
return GiftRecipientAddressRegistrationResult(
|
||||
applicationNo = gift.applicationNo,
|
||||
status = gift.status,
|
||||
statusName = gift.status.displayName,
|
||||
recipientAddressRegisteredAt = registeredAt
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun confirmDelivery(member: Member, applicationNo: String): GiftDeliveryConfirmationResult {
|
||||
val recipientMemberId = member.id ?: throw invalidRequest()
|
||||
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
|
||||
if (gift.recipientMemberId != recipientMemberId || gift.status != GiftStatus.INSPECTION_COMPLETED) {
|
||||
throw invalidRequest()
|
||||
}
|
||||
val delivery = deliveryRepository.findByGiftId(gift.id ?: throw invalidRequest()) ?: throw invalidRequest()
|
||||
val deliveredAt = now()
|
||||
|
||||
gift.status = GiftStatus.DELIVERED
|
||||
delivery.deliveredAt = deliveredAt
|
||||
|
||||
return GiftDeliveryConfirmationResult(
|
||||
applicationNo = gift.applicationNo,
|
||||
status = gift.status,
|
||||
statusName = gift.status.displayName,
|
||||
deliveredAt = deliveredAt
|
||||
)
|
||||
}
|
||||
|
||||
private fun validate(command: GiftApplicationCommand) {
|
||||
if (
|
||||
command.senderName.isBlank() ||
|
||||
command.senderPhoneNumber.isBlank() ||
|
||||
command.senderZipCode.isBlank() ||
|
||||
command.senderAddress.isBlank() ||
|
||||
!command.senderTermsAgreed ||
|
||||
!command.senderPrivacyAgreed
|
||||
) {
|
||||
throw invalidRequest()
|
||||
}
|
||||
}
|
||||
|
||||
private fun validate(command: GiftTrackingRegistrationCommand) {
|
||||
if (
|
||||
command.courierCompanyName.isBlank() ||
|
||||
command.courierCompanyName.length > 50 ||
|
||||
command.trackingNumber.isBlank()
|
||||
) {
|
||||
throw invalidRequest()
|
||||
}
|
||||
}
|
||||
|
||||
private fun validate(command: GiftRecipientAddressRegistrationCommand) {
|
||||
if (
|
||||
command.name.isBlank() ||
|
||||
command.phoneNumber.isBlank() ||
|
||||
command.zipCode.isBlank() ||
|
||||
command.address.isBlank() ||
|
||||
!command.recipientTermsAgreed ||
|
||||
!command.recipientPrivacyAgreed
|
||||
) {
|
||||
throw invalidRequest()
|
||||
}
|
||||
}
|
||||
|
||||
private fun validate(command: GiftReviewCommand) {
|
||||
if (
|
||||
command.rating !in 1..5 ||
|
||||
command.keywords.any { it.length > 255 } ||
|
||||
(command.comment?.length ?: 0) > 255
|
||||
) {
|
||||
throw invalidRequest()
|
||||
}
|
||||
}
|
||||
|
||||
private fun invalidRequest() = SodaException(messageKey = "common.error.invalid_request")
|
||||
|
||||
private companion object {
|
||||
val RECIPIENT_ADDRESS_REGISTRABLE_STATUSES = setOf(
|
||||
GiftStatus.TRACKING_REGISTERED,
|
||||
GiftStatus.ARRIVED_AT_MAILBOX,
|
||||
GiftStatus.INSPECTION_COMPLETED
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.application
|
||||
|
||||
import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue
|
||||
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
||||
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
||||
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
@Service
|
||||
class GiftPushService(
|
||||
private val applicationEventPublisher: ApplicationEventPublisher
|
||||
) {
|
||||
fun sendTrackingDeadlineReminder(applicationNo: String, memberId: Long) {
|
||||
publishGiftPush(
|
||||
memberId = memberId,
|
||||
applicationNo = applicationNo,
|
||||
title = "운송장 번호를 입력해 주세요",
|
||||
message = "24시간 이내에 운송장 번호를 등록해 주세요.\n기한 내 등록하지 않으면 신청이 자동 취소됩니다."
|
||||
)
|
||||
}
|
||||
|
||||
fun sendRecipientAddressDeadlineReminder(applicationNo: String, memberId: Long) {
|
||||
publishGiftPush(
|
||||
memberId = memberId,
|
||||
applicationNo = applicationNo,
|
||||
title = "배송지 입력 기한이 1일 남았어요",
|
||||
message = "24시간 안에 배송지를 입력해 주세요.\n입력하지 않으면 선물은 반송되지 않고 폐기돼요"
|
||||
)
|
||||
}
|
||||
|
||||
fun sendAutoCanceledToSender(applicationNo: String, memberId: Long) {
|
||||
publishGiftPush(
|
||||
memberId = memberId,
|
||||
applicationNo = applicationNo,
|
||||
title = "선물 신청이 자동 취소됐어요",
|
||||
message = "신청 후 3일 이내에 운송장 번호가 등록되지 않아 신청이 취소됐어요.\n사용한 캔은 모두 환불되었습니다."
|
||||
)
|
||||
}
|
||||
|
||||
fun sendTrackingRegisteredToRecipient(applicationNo: String, memberId: Long) {
|
||||
publishGiftPush(
|
||||
memberId = memberId,
|
||||
applicationNo = applicationNo,
|
||||
title = "팬이 보낸 선물이 있어요!",
|
||||
message = "7일 이내에 배송지를 입력해 주세요.\n기간이 지나면 선물을 받을 수 없어요"
|
||||
)
|
||||
}
|
||||
|
||||
fun sendArrivedAtMailboxToSender(applicationNo: String, memberId: Long) {
|
||||
publishGiftPush(
|
||||
memberId = memberId,
|
||||
applicationNo = applicationNo,
|
||||
title = "선물이 사서함에 도착했어요",
|
||||
message = "보내주신 선물이 소다라이브 사서함에 도착했어요.\n안전하게 전달할 수 있도록 선물을 확인하고 있어요."
|
||||
)
|
||||
}
|
||||
|
||||
fun sendArrivedAtMailboxToRecipient(applicationNo: String, memberId: Long) {
|
||||
publishGiftPush(
|
||||
memberId = memberId,
|
||||
applicationNo = applicationNo,
|
||||
title = "선물이 사서함에 도착했어요",
|
||||
message = "팬이 보낸 선물을 검수하고 있어요.\n검수가 끝나면 다시 알려드릴게요"
|
||||
)
|
||||
}
|
||||
|
||||
fun sendInspectionCompleted(applicationNo: String, memberId: Long) {
|
||||
publishGiftPush(
|
||||
memberId = memberId,
|
||||
applicationNo = applicationNo,
|
||||
title = "선물 검수가 완료됐어요",
|
||||
message = "확인된 선물을 빠른 시일 내에 입력하신 배송지로 보내드릴게요."
|
||||
)
|
||||
}
|
||||
|
||||
fun sendUndeliverableToSender(applicationNo: String, memberId: Long) {
|
||||
publishGiftPush(
|
||||
memberId = memberId,
|
||||
applicationNo = applicationNo,
|
||||
title = "선물을 전달할 수 없어요",
|
||||
message = "도착한 선물이 선물 접수 규격에 맞지 않아 크리에이터에게 전달할 수 없어요.\n자세한 사유는 신청 상세에서 확인해 주세요."
|
||||
)
|
||||
}
|
||||
|
||||
fun sendUndeliverableToRecipient(applicationNo: String, memberId: Long) {
|
||||
publishGiftPush(
|
||||
memberId = memberId,
|
||||
applicationNo = applicationNo,
|
||||
title = "전달할 수 없는 선물이 있어요",
|
||||
message = "검수 결과 선물 정책에 맞지 않아 해당 선물은 배송되지 않아요.\n자세한 사유를 확인해 주세요."
|
||||
)
|
||||
}
|
||||
|
||||
fun sendDeliveredToSender(applicationNo: String, memberId: Long) {
|
||||
publishGiftPush(
|
||||
memberId = memberId,
|
||||
applicationNo = applicationNo,
|
||||
title = "선물이 전달됐어요",
|
||||
message = "보내주신 선물이 크리에이터에게 안전하게 전달됐어요."
|
||||
)
|
||||
}
|
||||
|
||||
fun sendDeliveredToRecipient(applicationNo: String, memberId: Long) {
|
||||
publishGiftPush(
|
||||
memberId = memberId,
|
||||
applicationNo = applicationNo,
|
||||
title = "선물이 도착했어요!",
|
||||
message = "팬이 보낸 선물이 배송지에 도착했어요.\n지금 확인해 보세요."
|
||||
)
|
||||
}
|
||||
|
||||
private fun publishGiftPush(memberId: Long, applicationNo: String, title: String, message: String) {
|
||||
applicationEventPublisher.publishEvent(
|
||||
FcmEvent(
|
||||
type = FcmEventType.INDIVIDUAL,
|
||||
category = PushNotificationCategory.GIFT,
|
||||
title = title,
|
||||
message = message,
|
||||
deepLinkValue = FcmDeepLinkValue.GIFT_DETAIL,
|
||||
deepLinkId = applicationNo,
|
||||
recipients = listOf(memberId)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
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.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
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.LocalDateTime
|
||||
|
||||
data class GiftFormOptionsResult(
|
||||
val sizes: List<GiftSizePriceResult>,
|
||||
val categories: List<GiftCategoryResult>
|
||||
)
|
||||
|
||||
data class GiftListResult(
|
||||
val totalCount: Long,
|
||||
val items: List<GiftListItemResult>,
|
||||
val page: Int,
|
||||
val size: Int,
|
||||
val hasNext: Boolean
|
||||
)
|
||||
|
||||
data class GiftListItemResult(
|
||||
val applicationNo: String,
|
||||
val direction: GiftDirection,
|
||||
val status: GiftStatus,
|
||||
val statusName: String,
|
||||
val priceCan: Int,
|
||||
val categoryName: String,
|
||||
val sizeName: String,
|
||||
val counterpartMemberId: Long,
|
||||
val counterpartNickname: String,
|
||||
val counterpartProfileImageUrl: String,
|
||||
val createdAt: LocalDateTime?
|
||||
)
|
||||
|
||||
data class GiftDetailResult(
|
||||
val applicationNo: String,
|
||||
val direction: GiftDirection,
|
||||
val status: GiftStatus,
|
||||
val statusName: String,
|
||||
val giftInfo: GiftDetailInfoResult,
|
||||
val senderInfo: GiftAddressResult?,
|
||||
val recipientAddress: GiftAddressResult?,
|
||||
val mailbox: GiftMailboxResult?,
|
||||
val trackingRequired: Boolean,
|
||||
val recipientAddressRequired: Boolean,
|
||||
val recipientAddressDeadlineAt: LocalDateTime?,
|
||||
val delivery: GiftDeliveryInfoResult,
|
||||
val statusTimeline: List<GiftStatusTimelineResult>
|
||||
)
|
||||
|
||||
data class GiftDetailInfoResult(
|
||||
val recipientCreatorNickname: String?,
|
||||
val senderNickname: String?,
|
||||
val sizeName: String,
|
||||
val categoryName: String,
|
||||
val applicationNo: String?,
|
||||
val paidCan: Int?,
|
||||
val tracking: String?,
|
||||
val shippingRequestedAt: LocalDateTime?
|
||||
)
|
||||
|
||||
data class GiftAddressResult(
|
||||
val name: String,
|
||||
val phoneNumber: String,
|
||||
val address: String
|
||||
)
|
||||
|
||||
data class GiftMailboxResult(
|
||||
val name: String,
|
||||
val address: String,
|
||||
val phoneNumber: String
|
||||
)
|
||||
|
||||
data class GiftDeliveryInfoResult(
|
||||
val canceledAt: LocalDateTime?,
|
||||
val undeliverableAt: LocalDateTime?,
|
||||
val undeliverableReason: String?
|
||||
)
|
||||
|
||||
data class GiftStatusTimelineResult(
|
||||
val status: GiftStatus,
|
||||
val statusName: String,
|
||||
val occurredAt: LocalDateTime?
|
||||
)
|
||||
|
||||
@Service
|
||||
class GiftQueryService(
|
||||
private val categoryRepository: GiftCategoryRepository,
|
||||
private val sizePriceRepository: GiftSizePriceRepository,
|
||||
private val giftRepository: GiftRepository,
|
||||
private val deliveryRepository: GiftDeliveryRepository,
|
||||
private val memberRepository: MemberRepository,
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val cloudFrontHost: String
|
||||
) {
|
||||
@Transactional(readOnly = true)
|
||||
fun getFormOptions() = GiftFormOptionsResult(
|
||||
sizes = sizePriceRepository.findAll().filter { it.isActive }.map {
|
||||
GiftSizePriceResult(it.sizeCode, it.sizeCode.displayName, it.basePriceCan, it.salePriceCan, it.isActive)
|
||||
},
|
||||
categories = categoryRepository.findAll().filter { it.isActive }.map {
|
||||
GiftCategoryResult(
|
||||
categoryId = it.id!!,
|
||||
classificationNumber = it.classificationNumber,
|
||||
categoryCode = it.categoryCode,
|
||||
name = "${it.name}/${it.representativeItem}",
|
||||
receiptCode = it.receiptCode,
|
||||
representativeItem = it.representativeItem,
|
||||
requiresDamageWaiver = it.requiresDamageWaiver,
|
||||
isActive = it.isActive
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getGifts(memberId: Long, type: GiftListType, page: Int, size: Int): GiftListResult {
|
||||
val normalizedPage = page.coerceAtLeast(0)
|
||||
val normalizedSize = if (size <= 0) DEFAULT_PAGE_SIZE else size
|
||||
val pageable = PageRequest.of(normalizedPage, normalizedSize, Sort.by(Sort.Direction.DESC, "createdAt", "id"))
|
||||
val gifts = when (type) {
|
||||
GiftListType.SENT -> giftRepository.findBySenderMemberId(memberId, pageable)
|
||||
GiftListType.RECEIVED -> giftRepository.findByRecipientMemberIdAndStatusIn(
|
||||
memberId,
|
||||
RECIPIENT_VISIBLE_STATUSES,
|
||||
pageable
|
||||
)
|
||||
GiftListType.ALL -> giftRepository.findBySenderMemberIdOrRecipientMemberIdAndStatusIn(
|
||||
memberId,
|
||||
memberId,
|
||||
RECIPIENT_VISIBLE_STATUSES,
|
||||
pageable
|
||||
)
|
||||
}
|
||||
|
||||
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, membersById) },
|
||||
page = normalizedPage,
|
||||
size = normalizedSize,
|
||||
hasNext = gifts.hasNext()
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getGiftDetail(memberId: Long, applicationNo: String): GiftDetailResult {
|
||||
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
|
||||
val delivery = deliveryRepository.findByGiftId(gift.id ?: throw invalidRequest()) ?: throw invalidRequest()
|
||||
val direction = gift.directionFor(memberId)
|
||||
val sender = memberRepository.findByIdOrNull(gift.senderMemberId) ?: throw invalidRequest()
|
||||
val recipient = memberRepository.findByIdOrNull(gift.recipientMemberId) ?: throw invalidRequest()
|
||||
val recipientAddress = if (direction == GiftDirection.RECEIVED) delivery.toRecipientAddressResult() else null
|
||||
|
||||
return GiftDetailResult(
|
||||
applicationNo = gift.applicationNo,
|
||||
direction = direction,
|
||||
status = gift.status,
|
||||
statusName = gift.status.displayName,
|
||||
giftInfo = GiftDetailInfoResult(
|
||||
recipientCreatorNickname = recipient.nickname.takeIf { direction == GiftDirection.SENT },
|
||||
senderNickname = sender.nickname.takeIf { direction == GiftDirection.RECEIVED },
|
||||
sizeName = gift.sizeCode.displayName,
|
||||
categoryName = gift.categoryNameSnapshot,
|
||||
applicationNo = gift.applicationNo.takeIf { direction == GiftDirection.SENT },
|
||||
paidCan = gift.salePriceCan.takeIf { direction == GiftDirection.SENT },
|
||||
tracking = delivery.tracking().takeIf { direction == GiftDirection.SENT },
|
||||
shippingRequestedAt = gift.createdAt.takeIf { direction == GiftDirection.RECEIVED }
|
||||
),
|
||||
senderInfo = delivery.toSenderInfo().takeIf { direction == GiftDirection.SENT },
|
||||
recipientAddress = recipientAddress,
|
||||
mailbox = null,
|
||||
trackingRequired = direction == GiftDirection.SENT && gift.status == GiftStatus.RECEIVED,
|
||||
recipientAddressRequired = direction == GiftDirection.RECEIVED && recipientAddress == null &&
|
||||
gift.status !in TERMINAL_STATUSES,
|
||||
recipientAddressDeadlineAt = delivery.recipientAddressDeadlineAt,
|
||||
delivery = GiftDeliveryInfoResult(
|
||||
canceledAt = delivery.canceledAt,
|
||||
undeliverableAt = delivery.undeliverableAt,
|
||||
undeliverableReason = delivery.undeliverableReason
|
||||
),
|
||||
statusTimeline = gift.toStatusTimeline(delivery)
|
||||
)
|
||||
}
|
||||
|
||||
private fun Gift.toGiftListItem(memberId: Long, 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
|
||||
recipientMemberId == memberId && status in RECIPIENT_VISIBLE_STATUSES -> GiftDirection.RECEIVED
|
||||
else -> throw invalidRequest()
|
||||
}
|
||||
|
||||
private fun GiftDelivery.toSenderInfo() = GiftAddressResult(
|
||||
name = senderName,
|
||||
phoneNumber = senderPhoneNumber,
|
||||
address = formatAddress(senderZipCode, senderAddress, senderAddressDetail)
|
||||
)
|
||||
|
||||
private fun GiftDelivery.toRecipientAddressResult(): GiftAddressResult? {
|
||||
val name = recipientName ?: return null
|
||||
val phoneNumber = recipientPhoneNumber ?: return null
|
||||
val zipCode = recipientZipCode ?: return null
|
||||
val address = recipientAddress ?: return null
|
||||
|
||||
return GiftAddressResult(name, phoneNumber, formatAddress(zipCode, address, recipientAddressDetail))
|
||||
}
|
||||
|
||||
private fun GiftDelivery.tracking() = if (courierCompanyName != null && trackingNumber != null) {
|
||||
"${courierCompanyName}_$trackingNumber"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
private fun Gift.toStatusTimeline(delivery: GiftDelivery) = NORMAL_PROGRESS_STATUSES.map { timelineStatus ->
|
||||
GiftStatusTimelineResult(
|
||||
status = timelineStatus,
|
||||
statusName = timelineStatus.displayName,
|
||||
occurredAt = when (timelineStatus) {
|
||||
GiftStatus.RECEIVED -> createdAt
|
||||
GiftStatus.TRACKING_REGISTERED -> delivery.trackingRegisteredAt
|
||||
GiftStatus.ARRIVED_AT_MAILBOX -> delivery.arrivedAtMailboxAt
|
||||
GiftStatus.INSPECTION_COMPLETED -> delivery.inspectionCompletedAt
|
||||
GiftStatus.DELIVERED -> delivery.deliveredAt
|
||||
GiftStatus.UNDELIVERABLE, GiftStatus.CANCELED -> null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun formatAddress(zipCode: String, address: String, detail: String?) =
|
||||
"($zipCode) $address" + if (detail.isNullOrBlank()) "" else ", $detail"
|
||||
|
||||
private fun invalidRequest() = SodaException(messageKey = "common.error.invalid_request")
|
||||
|
||||
private companion object {
|
||||
const val DEFAULT_PAGE_SIZE = 20
|
||||
val RECIPIENT_VISIBLE_STATUSES = setOf(
|
||||
GiftStatus.TRACKING_REGISTERED,
|
||||
GiftStatus.ARRIVED_AT_MAILBOX,
|
||||
GiftStatus.INSPECTION_COMPLETED,
|
||||
GiftStatus.DELIVERED,
|
||||
GiftStatus.UNDELIVERABLE
|
||||
)
|
||||
val NORMAL_PROGRESS_STATUSES = listOf(
|
||||
GiftStatus.RECEIVED,
|
||||
GiftStatus.TRACKING_REGISTERED,
|
||||
GiftStatus.ARRIVED_AT_MAILBOX,
|
||||
GiftStatus.INSPECTION_COMPLETED,
|
||||
GiftStatus.DELIVERED
|
||||
)
|
||||
val TERMINAL_STATUSES = setOf(GiftStatus.DELIVERED, GiftStatus.UNDELIVERABLE, GiftStatus.CANCELED)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.domain
|
||||
|
||||
enum class GiftListType {
|
||||
ALL,
|
||||
SENT,
|
||||
RECEIVED
|
||||
}
|
||||
|
||||
enum class GiftDirection {
|
||||
SENT,
|
||||
RECEIVED
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.domain
|
||||
|
||||
enum class GiftSize(
|
||||
val displayName: String,
|
||||
val maxTotalLengthCm: Int,
|
||||
val maxWeightKg: Int,
|
||||
val defaultPriceCan: Int
|
||||
) {
|
||||
SMALL("소형", 100, 5, 100),
|
||||
MEDIUM("중형", 120, 15, 150),
|
||||
LARGE("대형", 160, 20, 200)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.domain
|
||||
|
||||
enum class GiftStatus(val displayName: String) {
|
||||
RECEIVED("접수 완료"),
|
||||
TRACKING_REGISTERED("발송 확인"),
|
||||
ARRIVED_AT_MAILBOX("사서함 도착"),
|
||||
INSPECTION_COMPLETED("검수 완료"),
|
||||
DELIVERED("전달 완료"),
|
||||
UNDELIVERABLE("전달 불가"),
|
||||
CANCELED("신청 취소")
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package kr.co.vividnext.sodalive.menu
|
||||
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
|
||||
class MenuServiceTest {
|
||||
private val repository = Mockito.mock(MenuRepository::class.java)
|
||||
private val service = MenuService(repository)
|
||||
|
||||
@Test
|
||||
@DisplayName("관리자 메뉴 마지막에 선물함 관리 메뉴를 추가한다")
|
||||
fun shouldAppendGiftMenuForAdmin() {
|
||||
Mockito.`when`(repository.getMenu(MemberRole.ADMIN)).thenReturn(
|
||||
listOf(GetMenuResponse(title = "회원 관리", route = "/member/list"))
|
||||
)
|
||||
|
||||
val menus = service.getMenus(member(MemberRole.ADMIN))
|
||||
|
||||
assertEquals("회원 관리", menus[0].title)
|
||||
val giftMenu = menus.last()
|
||||
assertEquals("선물함 관리", giftMenu.title)
|
||||
assertNull(giftMenu.route)
|
||||
assertEquals(
|
||||
listOf("선물함 리스트", "선물 카테고리", "선물 사이즈"),
|
||||
giftMenu.items?.map { it.title }
|
||||
)
|
||||
assertEquals(
|
||||
listOf("/gift/list", "/gift/category", "/gift/size"),
|
||||
giftMenu.items?.map { it.route }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("관리자가 아니면 선물함 관리 메뉴를 추가하지 않는다")
|
||||
fun shouldNotAppendGiftMenuForNonAdmin() {
|
||||
Mockito.`when`(repository.getMenu(MemberRole.CONTENT_MANAGER)).thenReturn(
|
||||
listOf(GetMenuResponse(title = "콘텐츠 리스트", route = "/content/list"))
|
||||
)
|
||||
|
||||
val menus = service.getMenus(member(MemberRole.CONTENT_MANAGER))
|
||||
|
||||
assertEquals(1, menus.size)
|
||||
assertEquals("콘텐츠 리스트", menus[0].title)
|
||||
}
|
||||
|
||||
private fun member(role: MemberRole) = Member(
|
||||
email = "${role.name.lowercase()}@test.com",
|
||||
password = "password",
|
||||
nickname = role.name.lowercase(),
|
||||
role = role
|
||||
)
|
||||
}
|
||||
+391
@@ -0,0 +1,391 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.`in`.web
|
||||
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePrice
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
|
||||
import org.hamcrest.Matchers.endsWith
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
|
||||
import org.springframework.test.annotation.DirtiesContext
|
||||
import org.springframework.test.context.ContextConfiguration
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@SpringBootTest(
|
||||
properties = [
|
||||
"cloud.aws.cloud-front.host=https://cdn.test",
|
||||
"spring.datasource.url=jdbc:h2:mem:gift-admin-controller;MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;" +
|
||||
"DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"
|
||||
]
|
||||
)
|
||||
@AutoConfigureMockMvc
|
||||
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
|
||||
@DisplayName("선물 관리자 API 테스트")
|
||||
class AdminGiftControllerTest @Autowired constructor(
|
||||
private val mockMvc: MockMvc,
|
||||
private val categoryRepository: GiftCategoryRepository,
|
||||
private val sizePriceRepository: GiftSizePriceRepository,
|
||||
private val giftRepository: GiftRepository,
|
||||
private val giftDeliveryRepository: GiftDeliveryRepository,
|
||||
private val memberRepository: MemberRepository
|
||||
) {
|
||||
@BeforeEach
|
||||
fun deleteGiftFixtures() {
|
||||
giftDeliveryRepository.deleteAll()
|
||||
giftRepository.deleteAll()
|
||||
categoryRepository.deleteAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("관리자는 전체 선물함 목록을 필터링해 조회한다")
|
||||
fun shouldListAdminGifts() {
|
||||
val (gift, _) = saveAdminGift(
|
||||
applicationNo = "A-1002609300001",
|
||||
status = GiftStatus.TRACKING_REGISTERED,
|
||||
senderNickname = "admin-fan-alpha",
|
||||
recipientNickname = "admin-creator-alpha",
|
||||
withTracking = true
|
||||
)
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/admin/gifts")
|
||||
.with(user("admin").roles("ADMIN"))
|
||||
.param("status", "TRACKING_REGISTERED")
|
||||
.param("applicationNo", "0001")
|
||||
.param("nickname", "alpha")
|
||||
.param("page", "0")
|
||||
.param("size", "20")
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.totalCount").value(1))
|
||||
.andExpect(jsonPath("$.data.items[0].applicationNo").value(gift.applicationNo))
|
||||
.andExpect(jsonPath("$.data.items[0].senderNickname").value("admin-fan-alpha"))
|
||||
.andExpect(jsonPath("$.data.items[0].recipientNickname").value("admin-creator-alpha"))
|
||||
.andExpect(jsonPath("$.data.items[0].sizeCode").value("SMALL"))
|
||||
.andExpect(jsonPath("$.data.items[0].sizeName").value("소형"))
|
||||
.andExpect(jsonPath("$.data.items[0].categoryName").value("인형"))
|
||||
.andExpect(jsonPath("$.data.items[0].classificationNumber").value("100"))
|
||||
.andExpect(jsonPath("$.data.items[0].courierCompanyName").value("CJ대한통운"))
|
||||
.andExpect(jsonPath("$.data.items[0].trackingNumber").value("1234567890"))
|
||||
.andExpect(jsonPath("$.data.items[0].status").value("TRACKING_REGISTERED"))
|
||||
.andExpect(jsonPath("$.data.items[0].statusName").value("발송 확인"))
|
||||
.andExpect(jsonPath("$.data.items[0].availableActions[0]").value("ARRIVE_MAILBOX"))
|
||||
.andExpect(jsonPath("$.data.items[0].availableActions[1]").value("MARK_UNDELIVERABLE"))
|
||||
.andExpect(jsonPath("$.data.page").value(0))
|
||||
.andExpect(jsonPath("$.data.size").value(20))
|
||||
.andExpect(jsonPath("$.data.hasNext").value(false))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("관리자는 선물 상세를 조회하고 미입력 수취인 정보는 빈 문자열로 받는다")
|
||||
fun shouldGetAdminGiftDetail() {
|
||||
saveAdminGift(
|
||||
applicationNo = "A-1002609300002",
|
||||
status = GiftStatus.ARRIVED_AT_MAILBOX,
|
||||
senderNickname = "admin-fan-beta",
|
||||
recipientNickname = "admin-creator-beta",
|
||||
withTracking = true
|
||||
)
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/admin/gifts/A-1002609300002")
|
||||
.with(user("admin").roles("ADMIN"))
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.applicationNo").value("A-1002609300002"))
|
||||
.andExpect(jsonPath("$.data.senderInfo.nickname").value("admin-fan-beta"))
|
||||
.andExpect(jsonPath("$.data.senderInfo.name").value("홍길동"))
|
||||
.andExpect(jsonPath("$.data.senderInfo.phoneNumber").value("01012345678"))
|
||||
.andExpect(jsonPath("$.data.senderInfo.address").value("(06234) 서울시 강남구, 101동 1001호"))
|
||||
.andExpect(jsonPath("$.data.recipientInfo.nickname").value("admin-creator-beta"))
|
||||
.andExpect(jsonPath("$.data.recipientInfo.name").value(""))
|
||||
.andExpect(jsonPath("$.data.recipientInfo.phoneNumber").value(""))
|
||||
.andExpect(jsonPath("$.data.recipientInfo.address").value(""))
|
||||
.andExpect(jsonPath("$.data.productInfo.sizeCode").value("SMALL"))
|
||||
.andExpect(jsonPath("$.data.productInfo.sizeName").value("소형"))
|
||||
.andExpect(jsonPath("$.data.productInfo.categoryName").value("인형"))
|
||||
.andExpect(jsonPath("$.data.productInfo.classificationNumber").value("100"))
|
||||
.andExpect(jsonPath("$.data.inboundDeliveryInfo.courierCompanyName").value("CJ대한통운"))
|
||||
.andExpect(jsonPath("$.data.inboundDeliveryInfo.trackingNumber").value("1234567890"))
|
||||
.andExpect(jsonPath("$.data.status").value("ARRIVED_AT_MAILBOX"))
|
||||
.andExpect(jsonPath("$.data.statusName").value("사서함 도착"))
|
||||
.andExpect(jsonPath("$.data.availableActions[0]").value("COMPLETE_INSPECTION"))
|
||||
.andExpect(jsonPath("$.data.availableActions[1]").value("MARK_UNDELIVERABLE"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("관리자는 카테고리를 등록, 조회, 수정, 논리삭제한다")
|
||||
fun shouldManageGiftCategories() {
|
||||
mockMvc.perform(
|
||||
post("/api/v2/admin/gift-categories")
|
||||
.with(user("admin").roles("ADMIN"))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(
|
||||
"""
|
||||
{
|
||||
"classificationNumber":"100",
|
||||
"categoryCode":"DOLL",
|
||||
"name":"인형",
|
||||
"receiptCode":"A",
|
||||
"representativeItem":"피규어",
|
||||
"requiresDamageWaiver":true,
|
||||
"isActive":true
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.classificationNumber").value("100"))
|
||||
.andExpect(jsonPath("$.data.categoryCode").value("DOLL"))
|
||||
.andExpect(jsonPath("$.data.receiptCode").value("A"))
|
||||
.andExpect(jsonPath("$.data.representativeItem").value("피규어"))
|
||||
|
||||
val categoryId = categoryRepository.findByCategoryCode("DOLL")!!.id!!
|
||||
|
||||
mockMvc.perform(
|
||||
put("/api/v2/admin/gift-categories/$categoryId")
|
||||
.with(user("admin").roles("ADMIN"))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(
|
||||
"""
|
||||
{
|
||||
"classificationNumber":"101",
|
||||
"categoryCode":"DOLL",
|
||||
"name":"큰 인형",
|
||||
"receiptCode":"B",
|
||||
"representativeItem":"대형 피규어",
|
||||
"requiresDamageWaiver":false,
|
||||
"isActive":true
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.classificationNumber").value("101"))
|
||||
.andExpect(jsonPath("$.data.name").value("큰 인형"))
|
||||
.andExpect(jsonPath("$.data.receiptCode").value("B"))
|
||||
.andExpect(jsonPath("$.data.representativeItem").value("대형 피규어"))
|
||||
|
||||
mockMvc.perform(get("/api/v2/admin/gift-categories").with(user("admin").roles("ADMIN")))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data[0].name").value("큰 인형"))
|
||||
|
||||
mockMvc.perform(delete("/api/v2/admin/gift-categories/$categoryId").with(user("admin").roles("ADMIN")))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.isActive").value(false))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("관리자는 사이즈 가격을 조회하고 수정하며 잘못된 가격을 거부한다")
|
||||
fun shouldManageGiftSizePrices() {
|
||||
sizePriceRepository.save(GiftSizePrice(sizeCode = GiftSize.SMALL, basePriceCan = 100, salePriceCan = 100))
|
||||
|
||||
mockMvc.perform(get("/api/v2/admin/gift-size-prices").with(user("admin").roles("ADMIN")))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data[0].sizeCode").value("SMALL"))
|
||||
|
||||
mockMvc.perform(
|
||||
put("/api/v2/admin/gift-size-prices/SMALL")
|
||||
.with(user("admin").roles("ADMIN"))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"basePriceCan":100,"salePriceCan":80,"isActive":true}""")
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.salePriceCan").value(80))
|
||||
|
||||
mockMvc.perform(
|
||||
put("/api/v2/admin/gift-size-prices/SMALL")
|
||||
.with(user("admin").roles("ADMIN"))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"basePriceCan":100,"salePriceCan":101,"isActive":true}""")
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("관리자는 사서함 도착과 검수완료를 처리한다")
|
||||
fun shouldUpdateGiftOperationStatuses() {
|
||||
saveGift(GiftStatus.TRACKING_REGISTERED)
|
||||
|
||||
mockMvc.perform(post("/api/v2/admin/gifts/$APPLICATION_NO/arrive-mailbox").with(user("admin").roles("ADMIN")))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.applicationNo").value(APPLICATION_NO))
|
||||
.andExpect(jsonPath("$.data.status").value("ARRIVED_AT_MAILBOX"))
|
||||
.andExpect(jsonPath("$.data.occurredAt").value(endsWith("Z")))
|
||||
|
||||
mockMvc.perform(post("/api/v2/admin/gifts/$APPLICATION_NO/complete-inspection").with(user("admin").roles("ADMIN")))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.status").value("INSPECTION_COMPLETED"))
|
||||
.andExpect(jsonPath("$.data.occurredAt").value(endsWith("Z")))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("관리자는 전달불가 사유를 등록하고 전달불가 처리한다")
|
||||
fun shouldMarkGiftUndeliverable() {
|
||||
saveGift(GiftStatus.INSPECTION_COMPLETED)
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v2/admin/gifts/$APPLICATION_NO/mark-undeliverable")
|
||||
.with(user("admin").roles("ADMIN"))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"reason":"선물 접수 규격에 맞지 않습니다."}""")
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.applicationNo").value(APPLICATION_NO))
|
||||
.andExpect(jsonPath("$.data.status").value("UNDELIVERABLE"))
|
||||
.andExpect(jsonPath("$.data.occurredAt").value(endsWith("Z")))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("관리자는 전달불가 사유 없이 전달불가 처리할 수 없다")
|
||||
fun shouldRejectUndeliverableWithoutReason() {
|
||||
saveGift(GiftStatus.INSPECTION_COMPLETED)
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v2/admin/gifts/$APPLICATION_NO/mark-undeliverable")
|
||||
.with(user("admin").roles("ADMIN"))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"reason":" "}""")
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("관리자는 검수완료 선물을 전달완료 처리한다")
|
||||
fun shouldCompleteGiftDelivery() {
|
||||
saveGift(GiftStatus.INSPECTION_COMPLETED)
|
||||
|
||||
mockMvc.perform(post("/api/v2/admin/gifts/$APPLICATION_NO/complete-delivery").with(user("admin").roles("ADMIN")))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.applicationNo").value(APPLICATION_NO))
|
||||
.andExpect(jsonPath("$.data.status").value("DELIVERED"))
|
||||
.andExpect(jsonPath("$.data.occurredAt").value(endsWith("Z")))
|
||||
}
|
||||
|
||||
private fun saveGift(status: GiftStatus) {
|
||||
val gift = giftRepository.save(
|
||||
Gift(
|
||||
applicationNo = APPLICATION_NO,
|
||||
senderMemberId = SENDER_MEMBER_ID,
|
||||
recipientMemberId = RECIPIENT_MEMBER_ID,
|
||||
status = status,
|
||||
sizeCode = GiftSize.SMALL,
|
||||
categoryId = 1L,
|
||||
categoryNameSnapshot = "인형",
|
||||
salePriceCan = 80,
|
||||
canUsageId = 10L,
|
||||
senderTermsAgreed = true,
|
||||
senderPrivacyAgreed = true,
|
||||
senderTermsAgreedAt = NOW.minusDays(1),
|
||||
senderPrivacyAgreedAt = NOW.minusDays(1),
|
||||
damageWaiverAgreed = true
|
||||
)
|
||||
)
|
||||
giftDeliveryRepository.save(
|
||||
GiftDelivery(
|
||||
giftId = requireNotNull(gift.id),
|
||||
senderName = "홍길동",
|
||||
senderPhoneNumber = "01012345678",
|
||||
senderZipCode = "06234",
|
||||
senderAddress = "서울시 강남구",
|
||||
trackingDeadlineAt = NOW.minusDays(1),
|
||||
trackingRegisteredAt = NOW.minusHours(1)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun saveAdminGift(
|
||||
applicationNo: String,
|
||||
status: GiftStatus,
|
||||
senderNickname: String,
|
||||
recipientNickname: String,
|
||||
withTracking: Boolean
|
||||
): Pair<Gift, GiftDelivery> {
|
||||
val sender = memberRepository.save(Member(password = "password", nickname = senderNickname))
|
||||
val recipient = memberRepository.save(Member(password = "password", nickname = recipientNickname))
|
||||
val category = categoryRepository.save(
|
||||
GiftCategory(
|
||||
classificationNumber = "100",
|
||||
categoryCode = "ADMIN_${applicationNo.takeLast(4)}",
|
||||
name = "인형",
|
||||
receiptCode = "A",
|
||||
representativeItem = "피규어"
|
||||
)
|
||||
)
|
||||
val gift = giftRepository.save(
|
||||
Gift(
|
||||
applicationNo = applicationNo,
|
||||
senderMemberId = sender.id!!,
|
||||
recipientMemberId = recipient.id!!,
|
||||
status = status,
|
||||
sizeCode = GiftSize.SMALL,
|
||||
categoryId = category.id!!,
|
||||
categoryNameSnapshot = category.name,
|
||||
salePriceCan = 80,
|
||||
canUsageId = 10L,
|
||||
senderTermsAgreed = true,
|
||||
senderPrivacyAgreed = true,
|
||||
senderTermsAgreedAt = NOW.minusDays(1),
|
||||
senderPrivacyAgreedAt = NOW.minusDays(1),
|
||||
damageWaiverAgreed = true
|
||||
)
|
||||
)
|
||||
val delivery = giftDeliveryRepository.save(
|
||||
GiftDelivery(
|
||||
giftId = requireNotNull(gift.id),
|
||||
senderName = "홍길동",
|
||||
senderPhoneNumber = "01012345678",
|
||||
senderZipCode = "06234",
|
||||
senderAddress = "서울시 강남구",
|
||||
senderAddressDetail = "101동 1001호",
|
||||
trackingDeadlineAt = NOW.minusDays(1)
|
||||
).apply {
|
||||
if (withTracking) {
|
||||
courierCompanyName = "CJ대한통운"
|
||||
trackingNumber = "1234567890"
|
||||
trackingRegisteredAt = NOW.minusHours(1)
|
||||
}
|
||||
}
|
||||
)
|
||||
return gift to delivery
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val APPLICATION_NO = "G20260929000001"
|
||||
const val SENDER_MEMBER_ID = 1L
|
||||
const val RECIPIENT_MEMBER_ID = 2L
|
||||
val NOW: LocalDateTime = LocalDateTime.of(2026, 10, 3, 12, 0)
|
||||
}
|
||||
}
|
||||
+472
@@ -0,0 +1,472 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.gift.adapter.`in`.web
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftApplicationRequest
|
||||
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftRecipientAddressRegistrationRequest
|
||||
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftReviewRequest
|
||||
import kr.co.vividnext.sodalive.v2.api.gift.dto.GiftTrackingRegistrationRequest
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePrice
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftApplicationResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftCancellationResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftCommandService
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftDeliveryConfirmationResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftRecipientAddressRegistrationResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftReviewResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftTrackingRegistrationResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
|
||||
import org.hamcrest.Matchers.endsWith
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.boot.test.mock.mockito.MockBean
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
|
||||
import org.springframework.test.context.ContextConfiguration
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@SpringBootTest(
|
||||
properties = [
|
||||
"spring.datasource.url=" +
|
||||
"jdbc:h2:mem:gift-controller;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE",
|
||||
"cloud.aws.cloud-front.host=https://cdn.test"
|
||||
]
|
||||
)
|
||||
@AutoConfigureMockMvc
|
||||
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||
class GiftControllerTest @Autowired constructor(
|
||||
private val mockMvc: MockMvc,
|
||||
private val categoryRepository: GiftCategoryRepository,
|
||||
private val giftRepository: GiftRepository,
|
||||
private val giftDeliveryRepository: GiftDeliveryRepository,
|
||||
private val sizePriceRepository: GiftSizePriceRepository,
|
||||
private val memberRepository: MemberRepository,
|
||||
private val objectMapper: ObjectMapper
|
||||
) {
|
||||
@MockBean
|
||||
private lateinit var commandService: GiftCommandService
|
||||
|
||||
@Test
|
||||
fun `팬은 활성 폼 옵션을 조회하고 categoryCode를 받지 않는다`() {
|
||||
categoryRepository.save(giftCategory("100", "DOLL", "A", "인형", "피규어", true, true))
|
||||
categoryRepository.save(giftCategory("200", "FOOD", "B", "간식", "쿠키", false, false))
|
||||
sizePriceRepository.save(GiftSizePrice(GiftSize.SMALL, 100, 80, true))
|
||||
|
||||
mockMvc.perform(get("/api/v2/gifts/form-options").with(user("fan").roles("USER")))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.sizes[0].sizeCode").value("SMALL"))
|
||||
.andExpect(jsonPath("$.data.sizes[0].name").value("소형"))
|
||||
.andExpect(jsonPath("$.data.sizes[0].basePriceCan").value(100))
|
||||
.andExpect(jsonPath("$.data.sizes[0].salePriceCan").value(80))
|
||||
.andExpect(jsonPath("$.data.categories[0].name").value("인형/피규어"))
|
||||
.andExpect(jsonPath("$.data.categories[0].categoryCode").doesNotExist())
|
||||
.andExpect(jsonPath("$.data.categories[0].classificationNumber").doesNotExist())
|
||||
.andExpect(jsonPath("$.data.categories[0].receiptCode").doesNotExist())
|
||||
.andExpect(jsonPath("$.data.categories[0].representativeItem").doesNotExist())
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("회원은 선물함 목록에서 보낸 선물과 노출 가능한 받은 선물을 페이징 응답으로 조회한다")
|
||||
fun shouldListGifts() {
|
||||
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 = counterpart.id!!,
|
||||
recipientMemberId = member.id!!,
|
||||
status = GiftStatus.TRACKING_REGISTERED
|
||||
)
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/gifts")
|
||||
.param("type", "ALL")
|
||||
.param("page", "-1")
|
||||
.param("size", "0")
|
||||
.with(user(MemberAdapter(member)))
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.totalCount").value(2))
|
||||
.andExpect(jsonPath("$.data.items.length()").value(2))
|
||||
.andExpect(jsonPath("$.data.page").value(0))
|
||||
.andExpect(jsonPath("$.data.size").value(20))
|
||||
.andExpect(jsonPath("$.data.hasNext").value(false))
|
||||
.andExpect(jsonPath("$.data.items[0].applicationNo").value("G-CONTROLLER-VISIBLE"))
|
||||
.andExpect(jsonPath("$.data.items[0].direction").value("RECEIVED"))
|
||||
.andExpect(jsonPath("$.data.items[0].status").value("TRACKING_REGISTERED"))
|
||||
.andExpect(jsonPath("$.data.items[0].statusName").value("발송 확인"))
|
||||
.andExpect(jsonPath("$.data.items[0].priceCan").value(80))
|
||||
.andExpect(jsonPath("$.data.items[0].categoryName").value("인형"))
|
||||
.andExpect(jsonPath("$.data.items[0].sizeName").value("소형"))
|
||||
.andExpect(jsonPath("$.data.items[0].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")))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("발신자는 인증 정보로 선물 상세와 자신의 발신자 정보를 조회한다")
|
||||
fun shouldGetSentGiftDetail() {
|
||||
val sender = memberRepository.save(Member(password = "password", nickname = "fan"))
|
||||
val recipient = memberRepository.save(Member(password = "password", nickname = "creator"))
|
||||
val gift = saveGift(
|
||||
applicationNo = "G-CONTROLLER-DETAIL",
|
||||
senderMemberId = sender.id!!,
|
||||
recipientMemberId = recipient.id!!,
|
||||
status = GiftStatus.TRACKING_REGISTERED
|
||||
)
|
||||
giftDeliveryRepository.save(
|
||||
GiftDelivery(
|
||||
giftId = gift.id!!,
|
||||
senderName = "홍길동",
|
||||
senderPhoneNumber = "01012345678",
|
||||
senderZipCode = "06234",
|
||||
senderAddress = "서울시 강남구",
|
||||
senderAddressDetail = "101동 1001호",
|
||||
trackingDeadlineAt = LocalDateTime.of(2026, 10, 2, 12, 0)
|
||||
).apply {
|
||||
courierCompanyName = "CJ대한통운"
|
||||
trackingNumber = "1234567890"
|
||||
trackingRegisteredAt = LocalDateTime.of(2026, 9, 30, 12, 0)
|
||||
}
|
||||
)
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/gifts/{applicationNo}", gift.applicationNo)
|
||||
.with(user(MemberAdapter(sender)))
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.applicationNo").value("G-CONTROLLER-DETAIL"))
|
||||
.andExpect(jsonPath("$.data.direction").value("SENT"))
|
||||
.andExpect(jsonPath("$.data.status").value("TRACKING_REGISTERED"))
|
||||
.andExpect(jsonPath("$.data.giftInfo.recipientCreatorNickname").value("creator"))
|
||||
.andExpect(jsonPath("$.data.giftInfo.applicationNo").value("G-CONTROLLER-DETAIL"))
|
||||
.andExpect(jsonPath("$.data.giftInfo.paidCan").value(80))
|
||||
.andExpect(jsonPath("$.data.giftInfo.tracking").value("CJ대한통운_1234567890"))
|
||||
.andExpect(jsonPath("$.data.senderInfo.name").value("홍길동"))
|
||||
.andExpect(jsonPath("$.data.senderInfo.address").value("(06234) 서울시 강남구, 101동 1001호"))
|
||||
.andExpect(jsonPath("$.data.recipientAddress").isEmpty)
|
||||
.andExpect(jsonPath("$.data.trackingRequired").value(false))
|
||||
.andExpect(jsonPath("$.data.statusTimeline[0].status").value("RECEIVED"))
|
||||
.andExpect(jsonPath("$.data.recipientAddressDeadlineAt").isEmpty)
|
||||
.andExpect(jsonPath("$.data.giftInfo.shippingRequestedAt").isEmpty)
|
||||
.andExpect(jsonPath("$.data.delivery.canceledAt").isEmpty)
|
||||
.andExpect(jsonPath("$.data.delivery.undeliverableAt").isEmpty)
|
||||
.andExpect(jsonPath("$.data.statusTimeline[1].occurredAt").value("2026-09-30T12:00:00Z"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("팬은 POST /api/v2/gifts로 선물을 신청하고 접수 결과를 받는다")
|
||||
fun shouldRegisterGift() {
|
||||
// Given: 로그인한 팬과 선물 신청 요청을 준비한다.
|
||||
val member = Member(password = "password", nickname = "fan").apply { id = SENDER_ID }
|
||||
val request = GiftApplicationRequest(
|
||||
recipientMemberId = RECIPIENT_ID,
|
||||
senderName = "홍길동",
|
||||
senderPhoneNumber = "01012345678",
|
||||
senderZipCode = "06234",
|
||||
senderAddress = "서울시 강남구",
|
||||
senderAddressDetail = "101동 1001호",
|
||||
sizeCode = GiftSize.SMALL,
|
||||
categoryId = CATEGORY_ID,
|
||||
senderTermsAgreed = true,
|
||||
senderPrivacyAgreed = true,
|
||||
damageWaiverAgreed = true
|
||||
)
|
||||
val deadline = LocalDateTime.of(2026, 10, 2, 12, 0)
|
||||
Mockito.`when`(
|
||||
commandService.registerGift(member, request.toCommand())
|
||||
).thenReturn(
|
||||
GiftApplicationResult(
|
||||
applicationNo = "G20260929000001",
|
||||
status = GiftStatus.RECEIVED,
|
||||
statusName = "접수 완료",
|
||||
priceCan = 80,
|
||||
trackingDeadlineAt = deadline
|
||||
)
|
||||
)
|
||||
|
||||
// When: 팬이 선물 신청 API를 호출한다.
|
||||
mockMvc.perform(
|
||||
post("/api/v2/gifts")
|
||||
.with(user(MemberAdapter(member)))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request))
|
||||
)
|
||||
// Then: 신청번호, 접수 상태, 판매가와 운송장 등록 기한을 반환한다.
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.applicationNo").value("G20260929000001"))
|
||||
.andExpect(jsonPath("$.data.status").value("RECEIVED"))
|
||||
.andExpect(jsonPath("$.data.statusName").value("접수 완료"))
|
||||
.andExpect(jsonPath("$.data.priceCan").value(80))
|
||||
.andExpect(jsonPath("$.data.trackingDeadlineAt").value("2026-10-02T12:00:00Z"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("팬은 POST /api/v2/gifts/{applicationNo}/cancel로 접수된 선물을 취소하고 환불 결과를 받는다")
|
||||
fun shouldCancelGift() {
|
||||
// Given: 취소할 접수 선물과 로그인한 발신자를 준비한다.
|
||||
val member = Member(password = "password", nickname = "fan").apply { id = SENDER_ID }
|
||||
val canceledAt = LocalDateTime.of(2026, 9, 29, 12, 0)
|
||||
Mockito.`when`(commandService.cancelGift(member, APPLICATION_NO)).thenReturn(
|
||||
GiftCancellationResult(
|
||||
applicationNo = APPLICATION_NO,
|
||||
status = GiftStatus.CANCELED,
|
||||
statusName = "신청 취소",
|
||||
priceCan = 80,
|
||||
canceledAt = canceledAt
|
||||
)
|
||||
)
|
||||
|
||||
// When: 발신자 인증으로 취소 API를 호출한다.
|
||||
mockMvc.perform(
|
||||
post("/api/v2/gifts/{applicationNo}/cancel", APPLICATION_NO)
|
||||
.with(user(MemberAdapter(member)))
|
||||
)
|
||||
// Then: 취소 상태, 환불 금액과 취소 시각을 반환한다.
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.applicationNo").value(APPLICATION_NO))
|
||||
.andExpect(jsonPath("$.data.status").value("CANCELED"))
|
||||
.andExpect(jsonPath("$.data.statusName").value("신청 취소"))
|
||||
.andExpect(jsonPath("$.data.priceCan").value(80))
|
||||
.andExpect(jsonPath("$.data.canceledAt").value("2026-09-29T12:00:00Z"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("팬은 POST /api/v2/gifts/{applicationNo}/tracking으로 운송장을 등록하고 발송 확인 결과를 받는다")
|
||||
fun shouldRegisterTracking() {
|
||||
// Given: 운송장 등록 요청과 로그인한 발신자를 준비한다.
|
||||
val member = Member(password = "password", nickname = "fan").apply { id = SENDER_ID }
|
||||
val request = GiftTrackingRegistrationRequest(
|
||||
courierCompanyName = "CJ대한통운",
|
||||
trackingNumber = "1234567890"
|
||||
)
|
||||
val trackingRegisteredAt = LocalDateTime.of(2026, 9, 29, 12, 0)
|
||||
val recipientAddressDeadlineAt = trackingRegisteredAt.plusDays(7)
|
||||
Mockito.`when`(commandService.registerTracking(member, APPLICATION_NO, request.toCommand())).thenReturn(
|
||||
GiftTrackingRegistrationResult(
|
||||
applicationNo = APPLICATION_NO,
|
||||
status = GiftStatus.TRACKING_REGISTERED,
|
||||
statusName = "발송 확인",
|
||||
courierCompanyName = "CJ대한통운",
|
||||
trackingNumber = "1234567890",
|
||||
trackingRegisteredAt = trackingRegisteredAt,
|
||||
recipientAddressDeadlineAt = recipientAddressDeadlineAt
|
||||
)
|
||||
)
|
||||
|
||||
// When: 발신자 인증으로 운송장 등록 API를 호출한다.
|
||||
mockMvc.perform(
|
||||
post("/api/v2/gifts/{applicationNo}/tracking", APPLICATION_NO)
|
||||
.with(user(MemberAdapter(member)))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request))
|
||||
)
|
||||
// Then: 신청번호, 발송 확인 상태와 운송장 및 수신자 주소 입력 기한을 반환한다.
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.applicationNo").value(APPLICATION_NO))
|
||||
.andExpect(jsonPath("$.data.status").value("TRACKING_REGISTERED"))
|
||||
.andExpect(jsonPath("$.data.statusName").value("발송 확인"))
|
||||
.andExpect(jsonPath("$.data.courierCompanyName").value("CJ대한통운"))
|
||||
.andExpect(jsonPath("$.data.trackingNumber").value("1234567890"))
|
||||
.andExpect(jsonPath("$.data.trackingRegisteredAt").value("2026-09-29T12:00:00Z"))
|
||||
.andExpect(jsonPath("$.data.recipientAddressDeadlineAt").value("2026-10-06T12:00:00Z"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("받는 크리에이터는 POST recipient-address로 주소 등록 성공 envelope를 받는다")
|
||||
fun shouldRegisterRecipientAddress() {
|
||||
// Given: 로그인한 받는 크리에이터와 주소 등록 요청을 준비한다.
|
||||
val member = Member(password = "password", nickname = "creator").apply { id = RECIPIENT_ID }
|
||||
val request = GiftRecipientAddressRegistrationRequest(
|
||||
recipientName = "김소다",
|
||||
recipientPhoneNumber = "01098765432",
|
||||
recipientZipCode = "04524",
|
||||
recipientAddress = "서울시 중구",
|
||||
recipientAddressDetail = "202호",
|
||||
recipientTermsAgreed = true,
|
||||
recipientPrivacyAgreed = true
|
||||
)
|
||||
val registeredAt = LocalDateTime.of(2026, 9, 29, 12, 0)
|
||||
Mockito.`when`(
|
||||
commandService.registerRecipientAddress(member, APPLICATION_NO, request.toCommand())
|
||||
).thenReturn(
|
||||
GiftRecipientAddressRegistrationResult(
|
||||
applicationNo = APPLICATION_NO,
|
||||
status = GiftStatus.TRACKING_REGISTERED,
|
||||
statusName = "발송 확인",
|
||||
recipientAddressRegisteredAt = registeredAt
|
||||
)
|
||||
)
|
||||
|
||||
// When: 받는 크리에이터 인증으로 주소 등록 API를 호출한다.
|
||||
mockMvc.perform(
|
||||
post("/api/v2/gifts/{applicationNo}/recipient-address", APPLICATION_NO)
|
||||
.with(user(MemberAdapter(member)))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request))
|
||||
)
|
||||
// Then: 성공 envelope에 신청번호, 상태와 주소 등록 시각을 반환한다.
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.applicationNo").value(APPLICATION_NO))
|
||||
.andExpect(jsonPath("$.data.status").value("TRACKING_REGISTERED"))
|
||||
.andExpect(jsonPath("$.data.statusName").value("발송 확인"))
|
||||
.andExpect(jsonPath("$.data.recipientAddressRegisteredAt").value("2026-09-29T12:00:00Z"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("받는 크리에이터는 POST delivery-complete로 수령 확인 성공 envelope를 받는다")
|
||||
fun shouldConfirmDelivery() {
|
||||
val member = Member(password = "password", nickname = "creator").apply { id = RECIPIENT_ID }
|
||||
val deliveredAt = LocalDateTime.of(2026, 9, 29, 12, 0)
|
||||
Mockito.`when`(commandService.confirmDelivery(member, APPLICATION_NO)).thenReturn(
|
||||
GiftDeliveryConfirmationResult(
|
||||
applicationNo = APPLICATION_NO,
|
||||
status = GiftStatus.DELIVERED,
|
||||
statusName = "전달완료",
|
||||
deliveredAt = deliveredAt
|
||||
)
|
||||
)
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v2/gifts/{applicationNo}/delivery-complete", APPLICATION_NO)
|
||||
.with(user(MemberAdapter(member)))
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.applicationNo").value(APPLICATION_NO))
|
||||
.andExpect(jsonPath("$.data.status").value("DELIVERED"))
|
||||
.andExpect(jsonPath("$.data.statusName").value("전달완료"))
|
||||
.andExpect(jsonPath("$.data.deliveredAt").value("2026-09-29T12:00:00Z"))
|
||||
|
||||
Mockito.verify(commandService).confirmDelivery(member, APPLICATION_NO)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("팬은 POST review로 리뷰를 작성하고 keywords 배열 응답을 받는다")
|
||||
fun shouldWriteReview() {
|
||||
val member = Member(password = "password", nickname = "fan").apply { id = SENDER_ID }
|
||||
val request = GiftReviewRequest(
|
||||
rating = 5,
|
||||
keywords = listOf("배송이 빨라요", "안내가 친절해요"),
|
||||
comment = "선물 전달 과정이 만족스러웠습니다."
|
||||
)
|
||||
val createdAt = LocalDateTime.of(2026, 10, 11, 12, 0)
|
||||
Mockito.`when`(commandService.writeReview(member, APPLICATION_NO, request.toCommand())).thenReturn(
|
||||
GiftReviewResult(
|
||||
reviewId = 1L,
|
||||
applicationNo = APPLICATION_NO,
|
||||
rating = 5,
|
||||
keywords = listOf("배송이 빨라요", "안내가 친절해요"),
|
||||
comment = "선물 전달 과정이 만족스러웠습니다.",
|
||||
createdAt = createdAt
|
||||
)
|
||||
)
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v2/gifts/{applicationNo}/review", APPLICATION_NO)
|
||||
.with(user(MemberAdapter(member)))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request))
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.reviewId").value(1L))
|
||||
.andExpect(jsonPath("$.data.applicationNo").value(APPLICATION_NO))
|
||||
.andExpect(jsonPath("$.data.rating").value(5))
|
||||
.andExpect(jsonPath("$.data.keywords[0]").value("배송이 빨라요"))
|
||||
.andExpect(jsonPath("$.data.keywords[1]").value("안내가 친절해요"))
|
||||
.andExpect(jsonPath("$.data.keyword").doesNotExist())
|
||||
.andExpect(jsonPath("$.data.comment").value("선물 전달 과정이 만족스러웠습니다."))
|
||||
.andExpect(jsonPath("$.data.createdAt").value("2026-10-11T12:00:00Z"))
|
||||
|
||||
Mockito.verify(commandService).writeReview(member, APPLICATION_NO, request.toCommand())
|
||||
}
|
||||
|
||||
private fun saveGift(
|
||||
applicationNo: String,
|
||||
senderMemberId: Long,
|
||||
recipientMemberId: Long,
|
||||
status: GiftStatus
|
||||
) = giftRepository.save(
|
||||
Gift(
|
||||
applicationNo = applicationNo,
|
||||
senderMemberId = senderMemberId,
|
||||
recipientMemberId = recipientMemberId,
|
||||
status = status,
|
||||
sizeCode = GiftSize.SMALL,
|
||||
categoryId = CATEGORY_ID,
|
||||
categoryNameSnapshot = "인형",
|
||||
salePriceCan = 80,
|
||||
canUsageId = 1L,
|
||||
senderTermsAgreed = true,
|
||||
senderPrivacyAgreed = true,
|
||||
senderTermsAgreedAt = LocalDateTime.of(2026, 9, 29, 12, 0),
|
||||
senderPrivacyAgreedAt = LocalDateTime.of(2026, 9, 29, 12, 0)
|
||||
)
|
||||
)
|
||||
|
||||
private fun giftCategory(
|
||||
classificationNumber: String,
|
||||
categoryCode: String,
|
||||
receiptCode: String,
|
||||
name: String,
|
||||
representativeItem: String,
|
||||
requiresDamageWaiver: Boolean,
|
||||
isActive: Boolean
|
||||
) = GiftCategory(
|
||||
classificationNumber = classificationNumber,
|
||||
categoryCode = categoryCode,
|
||||
name = name,
|
||||
receiptCode = receiptCode,
|
||||
representativeItem = representativeItem,
|
||||
requiresDamageWaiver = requiresDamageWaiver,
|
||||
isActive = isActive
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val SENDER_ID = 1L
|
||||
const val RECIPIENT_ID = 2L
|
||||
const val CATEGORY_ID = 3L
|
||||
const val APPLICATION_NO = "G20260929000001"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,876 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift
|
||||
|
||||
import kr.co.vividnext.sodalive.can.CanRepository
|
||||
import kr.co.vividnext.sodalive.can.charge.Charge
|
||||
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
|
||||
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
|
||||
import kr.co.vividnext.sodalive.can.payment.Payment
|
||||
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
|
||||
import kr.co.vividnext.sodalive.can.use.CanUsage
|
||||
import kr.co.vividnext.sodalive.can.use.UseCan
|
||||
import kr.co.vividnext.sodalive.can.use.UseCanCalculate
|
||||
import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository
|
||||
import kr.co.vividnext.sodalive.can.use.UseCanRepository
|
||||
import kr.co.vividnext.sodalive.common.CountryContext
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftReview
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftReviewRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePrice
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftApplicationCommand
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftApplicationNoGenerator
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftCancellationResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftCommandService
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftDeliveryConfirmationResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftPushService
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftRecipientAddressRegistrationCommand
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftRecipientAddressRegistrationResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftReviewCommand
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftReviewResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftTrackingRegistrationCommand
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftTrackingRegistrationResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import org.junit.jupiter.params.provider.MethodSource
|
||||
import org.mockito.Mockito
|
||||
import java.time.LocalDateTime
|
||||
import java.util.Optional
|
||||
|
||||
class GiftCommandServiceTest {
|
||||
@Test
|
||||
@DisplayName("선물 캔 사용은 한 번만 환불할 수 있다")
|
||||
fun shouldSpendAndRefundGiftCanOnlyOnce() {
|
||||
// Given: 충전 캔을 보유한 선물 발신자와 결제 서비스를 준비한다.
|
||||
val member = Member(password = "password", nickname = "sender")
|
||||
member.id = MEMBER_ID
|
||||
member.pgChargeCan = INITIAL_CAN
|
||||
val charge = Charge(chargeCan = INITIAL_CAN, rewardCan = 0)
|
||||
charge.id = CHARGE_ID
|
||||
charge.member = member
|
||||
charge.payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.PG)
|
||||
val fixture = CanPaymentFixture(member, charge)
|
||||
|
||||
// When: 선물용 캔을 사용한 뒤 해당 사용 내역을 환불한다.
|
||||
val useCanId = fixture.service.spendGiftCan(
|
||||
memberId = MEMBER_ID,
|
||||
needCan = GIFT_PRICE_CAN,
|
||||
container = "web"
|
||||
)
|
||||
val afterSpendCan = member.getChargeCan("web") + member.getRewardCan("web")
|
||||
fixture.service.refundGiftCan(memberId = MEMBER_ID, useCanId = useCanId)
|
||||
val afterRefundCan = member.getChargeCan("web") + member.getRewardCan("web")
|
||||
|
||||
// Then: 발신자와 GIFT 용도가 저장되고, 잔액이 복원되며 재환불은 거부된다.
|
||||
assertEquals(CanUsage.GIFT, fixture.savedUseCan.canUsage)
|
||||
assertEquals(member, fixture.savedUseCan.member)
|
||||
assertEquals(INITIAL_CAN - GIFT_PRICE_CAN, afterSpendCan)
|
||||
assertEquals(INITIAL_CAN, afterRefundCan)
|
||||
assertEquals(true, fixture.savedUseCan.isRefund)
|
||||
assertThrows(SodaException::class.java) {
|
||||
fixture.service.refundGiftCan(memberId = MEMBER_ID, useCanId = useCanId)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("선물 신청은 가격과 약관 동의, 배송 기한을 스냅샷으로 저장하고 캔을 차감한다")
|
||||
fun shouldRegisterGiftWithPriceAgreementAndDeliverySnapshots() {
|
||||
// Given: 활성 선물 카테고리와 가격, 발신자 및 수신 크리에이터를 준비한다.
|
||||
val fixture = GiftApplicationFixture()
|
||||
|
||||
// When: 팬이 선물을 신청한다.
|
||||
val result = fixture.service.registerGift(fixture.sender, validGiftApplicationCommand())
|
||||
|
||||
// Then: 신청 정보와 배송 기한을 저장하고 판매가만큼 캔을 차감한다.
|
||||
assertEquals(APPLICATION_NO, result.applicationNo)
|
||||
assertEquals(GiftStatus.RECEIVED, result.status)
|
||||
assertEquals(GiftStatus.RECEIVED.displayName, result.statusName)
|
||||
assertEquals(SALE_PRICE_CAN, result.priceCan)
|
||||
assertEquals(NOW.plusDays(3), result.trackingDeadlineAt)
|
||||
assertEquals(SENDER_ID, fixture.savedGift.senderMemberId)
|
||||
assertEquals(RECIPIENT_ID, fixture.savedGift.recipientMemberId)
|
||||
assertEquals(GiftStatus.RECEIVED, fixture.savedGift.status)
|
||||
assertEquals(GiftSize.SMALL, fixture.savedGift.sizeCode)
|
||||
assertEquals(CATEGORY_ID, fixture.savedGift.categoryId)
|
||||
assertEquals("인형", fixture.savedGift.categoryNameSnapshot)
|
||||
assertEquals(SALE_PRICE_CAN, fixture.savedGift.salePriceCan)
|
||||
assertEquals(USE_CAN_ID, fixture.savedGift.canUsageId)
|
||||
assertEquals(true, fixture.savedGift.senderTermsAgreed)
|
||||
assertEquals(true, fixture.savedGift.senderPrivacyAgreed)
|
||||
assertEquals(NOW, fixture.savedGift.senderTermsAgreedAt)
|
||||
assertEquals(NOW, fixture.savedGift.senderPrivacyAgreedAt)
|
||||
assertEquals(true, fixture.savedGift.damageWaiverAgreed)
|
||||
assertEquals(GIFT_ID, fixture.savedDelivery.giftId)
|
||||
assertEquals("홍길동", fixture.savedDelivery.senderName)
|
||||
assertEquals("01012345678", fixture.savedDelivery.senderPhoneNumber)
|
||||
assertEquals("06234", fixture.savedDelivery.senderZipCode)
|
||||
assertEquals("서울시 강남구", fixture.savedDelivery.senderAddress)
|
||||
assertEquals("101동 1001호", fixture.savedDelivery.senderAddressDetail)
|
||||
assertEquals(NOW.plusDays(3), fixture.savedDelivery.trackingDeadlineAt)
|
||||
Mockito.verify(fixture.canPaymentService).spendGiftCan(SENDER_ID, SALE_PRICE_CAN, "aos")
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("보낸 팬은 운송장 등록 전 선물을 취소하고 전액 환불받는다")
|
||||
fun shouldCancelReceivedGiftAndRefundOnce() {
|
||||
// Given: 접수 상태의 선물과 발신자 배송 정보를 준비한다.
|
||||
val fixture = GiftApplicationFixture()
|
||||
fixture.prepareGiftForCancellation()
|
||||
|
||||
// When: 보낸 팬이 선물을 취소한다.
|
||||
val result = fixture.service.cancelGift(fixture.sender, APPLICATION_NO)
|
||||
|
||||
// Then: 선물과 배송 정보에 취소 시각을 저장하고 한 번만 환불한다.
|
||||
assertEquals(
|
||||
GiftCancellationResult(
|
||||
applicationNo = APPLICATION_NO,
|
||||
status = GiftStatus.CANCELED,
|
||||
statusName = GiftStatus.CANCELED.displayName,
|
||||
priceCan = SALE_PRICE_CAN,
|
||||
canceledAt = NOW
|
||||
),
|
||||
result
|
||||
)
|
||||
assertEquals(GiftStatus.CANCELED, fixture.cancellationGift.status)
|
||||
assertEquals(NOW, fixture.cancellationDelivery.canceledAt)
|
||||
Mockito.verify(fixture.canPaymentService).refundGiftCan(SENDER_ID, USE_CAN_ID)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("보낸 팬은 접수된 선물의 운송장을 등록하고 크리에이터 배송지 입력 기한을 생성한다")
|
||||
fun shouldRegisterTrackingForReceivedGift() {
|
||||
// Given: 접수 상태의 선물과 발신자 배송 정보를 준비한다.
|
||||
val fixture = GiftApplicationFixture()
|
||||
fixture.prepareGiftForTracking()
|
||||
|
||||
// When: 보낸 팬이 택배사와 운송장 번호를 등록한다.
|
||||
val result = fixture.service.registerTracking(
|
||||
fixture.sender,
|
||||
APPLICATION_NO,
|
||||
validGiftTrackingRegistrationCommand()
|
||||
)
|
||||
|
||||
// Then: 발송 확인 상태와 운송장 정보, 수신자 주소 입력 기한을 저장한다.
|
||||
assertEquals(
|
||||
GiftTrackingRegistrationResult(
|
||||
applicationNo = APPLICATION_NO,
|
||||
status = GiftStatus.TRACKING_REGISTERED,
|
||||
statusName = GiftStatus.TRACKING_REGISTERED.displayName,
|
||||
courierCompanyName = "CJ대한통운",
|
||||
trackingNumber = "1234567890",
|
||||
trackingRegisteredAt = NOW,
|
||||
recipientAddressDeadlineAt = NOW.plusDays(7)
|
||||
),
|
||||
result
|
||||
)
|
||||
assertEquals(GiftStatus.TRACKING_REGISTERED, fixture.trackingGift.status)
|
||||
assertEquals("CJ대한통운", fixture.trackingDelivery.courierCompanyName)
|
||||
assertEquals("1234567890", fixture.trackingDelivery.trackingNumber)
|
||||
assertEquals(NOW, fixture.trackingDelivery.trackingRegisteredAt)
|
||||
assertEquals(NOW.plusDays(7), fixture.trackingDelivery.recipientAddressDeadlineAt)
|
||||
Mockito.verify(fixture.giftPushService).sendTrackingRegisteredToRecipient(APPLICATION_NO, RECIPIENT_ID)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("recipientAddressRegistrableStatuses")
|
||||
@DisplayName("받는 크리에이터는 운송장 등록 이후 종료 전 상태에서 주소와 수령 동의를 등록한다")
|
||||
fun shouldRegisterRecipientAddressAndAgreements(status: GiftStatus) {
|
||||
// Given: 주소 입력이 허용된 선물과 받는 크리에이터를 준비한다.
|
||||
val fixture = GiftApplicationFixture()
|
||||
fixture.prepareGiftForRecipientAddress(status = status)
|
||||
|
||||
// When: 받는 크리에이터가 배송지와 수령 약관 동의를 등록한다.
|
||||
val result = fixture.service.registerRecipientAddress(
|
||||
fixture.creator,
|
||||
APPLICATION_NO,
|
||||
validGiftRecipientAddressRegistrationCommand()
|
||||
)
|
||||
|
||||
// Then: 주소 스냅샷과 두 동의 시각을 저장하고 현재 상태를 반환한다.
|
||||
assertEquals(
|
||||
GiftRecipientAddressRegistrationResult(
|
||||
applicationNo = APPLICATION_NO,
|
||||
status = status,
|
||||
statusName = status.displayName,
|
||||
recipientAddressRegisteredAt = NOW
|
||||
),
|
||||
result
|
||||
)
|
||||
assertEquals("김소다", fixture.recipientAddressDelivery.recipientName)
|
||||
assertEquals("01098765432", fixture.recipientAddressDelivery.recipientPhoneNumber)
|
||||
assertEquals("04524", fixture.recipientAddressDelivery.recipientZipCode)
|
||||
assertEquals("서울시 중구", fixture.recipientAddressDelivery.recipientAddress)
|
||||
assertEquals("202호", fixture.recipientAddressDelivery.recipientAddressDetail)
|
||||
assertEquals(true, fixture.recipientAddressGift.recipientTermsAgreed)
|
||||
assertEquals(true, fixture.recipientAddressGift.recipientPrivacyAgreed)
|
||||
assertEquals(NOW, fixture.recipientAddressGift.recipientTermsAgreedAt)
|
||||
assertEquals(NOW, fixture.recipientAddressGift.recipientPrivacyAgreedAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("받는 크리에이터는 검수 완료 선물의 수령을 확인하고 전달 완료 시각을 저장한다")
|
||||
fun shouldConfirmDeliveryForInspectionCompletedGift() {
|
||||
val fixture = GiftApplicationFixture()
|
||||
fixture.prepareGiftForDeliveryConfirmation()
|
||||
|
||||
val result = fixture.service.confirmDelivery(fixture.creator, APPLICATION_NO)
|
||||
|
||||
assertEquals(
|
||||
GiftDeliveryConfirmationResult(
|
||||
applicationNo = APPLICATION_NO,
|
||||
status = GiftStatus.DELIVERED,
|
||||
statusName = GiftStatus.DELIVERED.displayName,
|
||||
deliveredAt = NOW
|
||||
),
|
||||
result
|
||||
)
|
||||
assertEquals(GiftStatus.DELIVERED, fixture.deliveryConfirmationGift.status)
|
||||
assertEquals(NOW, fixture.deliveryConfirmationDelivery.deliveredAt)
|
||||
Mockito.verifyNoInteractions(fixture.giftPushService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("보낸 팬은 전달 완료 선물에 리뷰를 한 번 작성한다")
|
||||
fun shouldWriteReviewForDeliveredGiftBySender() {
|
||||
val fixture = GiftApplicationFixture()
|
||||
fixture.prepareGiftForReview()
|
||||
|
||||
val result = fixture.service.writeReview(fixture.sender, APPLICATION_NO, validGiftReviewCommand())
|
||||
|
||||
assertEquals(
|
||||
GiftReviewResult(
|
||||
reviewId = REVIEW_ID,
|
||||
applicationNo = APPLICATION_NO,
|
||||
rating = 5,
|
||||
keywords = listOf("배송이 빨라요", "안내가 친절해요"),
|
||||
comment = "선물 전달 과정이 만족스러웠습니다.",
|
||||
createdAt = NOW
|
||||
),
|
||||
result
|
||||
)
|
||||
assertEquals(GIFT_ID, fixture.savedReview.giftId)
|
||||
assertEquals(SENDER_ID, fixture.savedReview.senderMemberId)
|
||||
assertEquals(5, fixture.savedReview.rating)
|
||||
assertEquals("배송이 빨라요,안내가 친절해요", fixture.savedReview.keywords)
|
||||
assertEquals("선물 전달 과정이 만족스러웠습니다.", fixture.savedReview.comment)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("invalidGiftReviewScenarios")
|
||||
@DisplayName("리뷰 작성은 보낸 팬의 전달 완료 선물, 1회, 유효한 별점과 내용만 허용한다")
|
||||
fun shouldRejectInvalidGiftReview(scenario: InvalidGiftReviewScenario) {
|
||||
val fixture = GiftApplicationFixture()
|
||||
fixture.prepareGiftForReview(
|
||||
status = scenario.status,
|
||||
senderMemberId = scenario.senderMemberId,
|
||||
reviewExists = scenario.reviewExists
|
||||
)
|
||||
val requester = Member(password = "password", nickname = "requester").apply { id = scenario.requesterMemberId }
|
||||
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
fixture.service.writeReview(requester, APPLICATION_NO, scenario.command)
|
||||
}
|
||||
|
||||
assertEquals("common.error.invalid_request", exception.messageKey)
|
||||
Mockito.verify(fixture.reviewRepository, Mockito.never()).save(Mockito.any(GiftReview::class.java))
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("invalidGiftDeliveryConfirmationScenarios")
|
||||
@DisplayName("수령 확인은 받는 크리에이터의 검수 완료 선물에만 가능하고 배송 정보가 필요하다")
|
||||
fun shouldRejectInvalidGiftDeliveryConfirmation(scenario: InvalidGiftDeliveryConfirmationScenario) {
|
||||
val fixture = GiftApplicationFixture()
|
||||
fixture.prepareGiftForDeliveryConfirmation(
|
||||
status = scenario.status,
|
||||
recipientMemberId = scenario.recipientMemberId,
|
||||
deliveryExists = scenario.deliveryExists
|
||||
)
|
||||
val requester = Member(password = "password", nickname = "requester").apply { id = scenario.requesterMemberId }
|
||||
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
fixture.service.confirmDelivery(requester, APPLICATION_NO)
|
||||
}
|
||||
|
||||
assertEquals("common.error.invalid_request", exception.messageKey)
|
||||
assertEquals(scenario.status, fixture.deliveryConfirmationGift.status)
|
||||
assertEquals(null, fixture.deliveryConfirmationDelivery.deliveredAt)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("invalidGiftRecipientAddressRegistrationScenarios")
|
||||
@DisplayName("수령자 주소 등록은 받는 크리에이터, 허용 상태, 필수 주소와 두 동의가 필요하다")
|
||||
fun shouldRejectInvalidGiftRecipientAddressRegistration(scenario: InvalidGiftRecipientAddressRegistrationScenario) {
|
||||
// Given: 권한, 상태 또는 입력값이 유효하지 않은 선물을 준비한다.
|
||||
val fixture = GiftApplicationFixture()
|
||||
fixture.prepareGiftForRecipientAddress(
|
||||
status = scenario.status,
|
||||
recipientMemberId = scenario.recipientMemberId,
|
||||
deliveryExists = scenario.deliveryExists
|
||||
)
|
||||
val requester = Member(password = "password", nickname = "requester").apply { id = scenario.requesterMemberId }
|
||||
|
||||
// When: 배송지 등록을 요청한다.
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
fixture.service.registerRecipientAddress(requester, APPLICATION_NO, scenario.command)
|
||||
}
|
||||
|
||||
// Then: 공통 요청 오류를 반환하고 선물 상태와 수령 동의를 바꾸지 않는다.
|
||||
assertEquals("common.error.invalid_request", exception.messageKey)
|
||||
assertEquals(scenario.status, fixture.recipientAddressGift.status)
|
||||
assertEquals(false, fixture.recipientAddressGift.recipientTermsAgreed)
|
||||
assertEquals(false, fixture.recipientAddressGift.recipientPrivacyAgreed)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("invalidGiftTrackingRegistrationScenarios")
|
||||
@DisplayName("운송장 등록은 보낸 팬의 접수 상태에서 유효한 택배사와 운송장 번호가 있어야 한다")
|
||||
fun shouldRejectInvalidGiftTrackingRegistration(scenario: InvalidGiftTrackingRegistrationScenario) {
|
||||
// Given: 운송장 등록 권한, 상태, 입력값 또는 배송 정보가 유효하지 않은 선물을 준비한다.
|
||||
val fixture = GiftApplicationFixture()
|
||||
fixture.prepareGiftForTracking(scenario.status, scenario.senderMemberId, scenario.deliveryExists)
|
||||
val requester = Member(password = "password", nickname = "requester").apply { id = scenario.requesterMemberId }
|
||||
|
||||
// When: 운송장 등록을 요청한다.
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
fixture.service.registerTracking(requester, APPLICATION_NO, scenario.command)
|
||||
}
|
||||
|
||||
// Then: 공통 요청 오류를 반환하고 선물 상태를 바꾸지 않는다.
|
||||
assertEquals("common.error.invalid_request", exception.messageKey)
|
||||
assertEquals(scenario.status, fixture.trackingGift.status)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("invalidGiftCancellationScenarios")
|
||||
@DisplayName("선물 취소는 보낸 팬의 접수 상태에서만 가능하다")
|
||||
fun shouldRejectGiftCancellationForInvalidSenderOrStatus(scenario: InvalidGiftCancellationScenario) {
|
||||
// Given: 취소 권한 또는 상태가 유효하지 않은 선물을 준비한다.
|
||||
val fixture = GiftApplicationFixture()
|
||||
fixture.prepareGiftForCancellation(scenario.status, scenario.senderMemberId)
|
||||
val requester = Member(password = "password", nickname = "requester").apply { id = scenario.requesterMemberId }
|
||||
|
||||
// When: 취소를 요청한다.
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
fixture.service.cancelGift(requester, APPLICATION_NO)
|
||||
}
|
||||
|
||||
// Then: 환불하지 않고 공통 요청 오류를 반환한다.
|
||||
assertEquals("common.error.invalid_request", exception.messageKey)
|
||||
Mockito.verify(fixture.canPaymentService, Mockito.never()).refundGiftCan(
|
||||
Mockito.anyLong(),
|
||||
Mockito.anyLong()
|
||||
)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("invalidGiftApplicationScenarios")
|
||||
@DisplayName("선물 신청은 필수 발신자 정보, 동의, 활성 옵션 및 수신 크리에이터를 검증한다")
|
||||
fun shouldRejectInvalidGiftApplication(scenario: InvalidGiftApplicationScenario) {
|
||||
// Given: 하나의 신청 조건만 유효하지 않은 선물 신청을 준비한다.
|
||||
val fixture = GiftApplicationFixture()
|
||||
fixture.category.isActive = scenario.categoryActive
|
||||
fixture.category.requiresDamageWaiver = scenario.requiresDamageWaiver
|
||||
fixture.sizePrice.isActive = scenario.sizePriceActive
|
||||
fixture.setCreatorExists(scenario.creatorExists)
|
||||
|
||||
// When: 유효하지 않은 신청을 등록한다.
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
fixture.service.registerGift(fixture.sender, scenario.command)
|
||||
}
|
||||
|
||||
// Then: 캔을 차감하지 않고 공통 요청 오류를 반환한다.
|
||||
assertEquals("common.error.invalid_request", exception.messageKey)
|
||||
Mockito.verifyNoInteractions(fixture.canPaymentService)
|
||||
}
|
||||
|
||||
private class CanPaymentFixture(member: Member, charge: Charge) {
|
||||
private val canRepository = Mockito.mock(CanRepository::class.java)
|
||||
private val memberRepository = Mockito.mock(MemberRepository::class.java)
|
||||
private val chargeRepository = Mockito.mock(ChargeRepository::class.java)
|
||||
private val useCanRepository = Mockito.mock(UseCanRepository::class.java)
|
||||
private val useCanCalculateRepository = Mockito.mock(UseCanCalculateRepository::class.java)
|
||||
private val useCanCalculates = mutableListOf<UseCanCalculate>()
|
||||
lateinit var savedUseCan: UseCan
|
||||
private set
|
||||
|
||||
val service = CanPaymentService(
|
||||
repository = canRepository,
|
||||
memberRepository = memberRepository,
|
||||
chargeRepository = chargeRepository,
|
||||
useCanRepository = useCanRepository,
|
||||
useCanCalculateRepository = useCanCalculateRepository,
|
||||
messageSource = SodaMessageSource(),
|
||||
langContext = LangContext(),
|
||||
countryContext = CountryContext()
|
||||
)
|
||||
|
||||
init {
|
||||
Mockito.`when`(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.of(member))
|
||||
Mockito.`when`(
|
||||
chargeRepository.getOldestChargeWhereRewardCanGreaterThan0(0L, MEMBER_ID, "web")
|
||||
).thenReturn(null)
|
||||
Mockito.`when`(
|
||||
chargeRepository.getOldestChargeWhereChargeCanGreaterThan0(0L, MEMBER_ID, "web")
|
||||
).thenReturn(charge)
|
||||
Mockito.`when`(useCanRepository.save(Mockito.any(UseCan::class.java))).thenAnswer { invocation ->
|
||||
invocation.getArgument<UseCan>(0).apply {
|
||||
id = USE_CAN_ID
|
||||
savedUseCan = this
|
||||
}
|
||||
}
|
||||
Mockito.`when`(useCanRepository.findById(USE_CAN_ID)).thenAnswer { Optional.of(savedUseCan) }
|
||||
Mockito.`when`(useCanCalculateRepository.save(Mockito.any(UseCanCalculate::class.java))).thenAnswer {
|
||||
invocation ->
|
||||
invocation.getArgument<UseCanCalculate>(0).also(useCanCalculates::add)
|
||||
}
|
||||
Mockito.`when`(useCanCalculateRepository.findByUseCanIdAndStatus(USE_CAN_ID)).thenReturn(useCanCalculates)
|
||||
}
|
||||
}
|
||||
|
||||
private class GiftApplicationFixture {
|
||||
val giftRepository = Mockito.mock(GiftRepository::class.java)
|
||||
val deliveryRepository = Mockito.mock(GiftDeliveryRepository::class.java)
|
||||
val reviewRepository = Mockito.mock(GiftReviewRepository::class.java)
|
||||
val categoryRepository = Mockito.mock(GiftCategoryRepository::class.java)
|
||||
val sizePriceRepository = Mockito.mock(GiftSizePriceRepository::class.java)
|
||||
val memberRepository = Mockito.mock(MemberRepository::class.java)
|
||||
val applicationNoGenerator = Mockito.mock(GiftApplicationNoGenerator::class.java)
|
||||
val canPaymentService = Mockito.mock(CanPaymentService::class.java)
|
||||
val giftPushService = Mockito.mock(GiftPushService::class.java)
|
||||
val sender = Member(password = "password", nickname = "sender", container = "aos").apply { id = SENDER_ID }
|
||||
val creator = Member(password = "password", nickname = "creator").apply { id = RECIPIENT_ID }
|
||||
val category = GiftCategory(
|
||||
classificationNumber = "100",
|
||||
categoryCode = "DOLL",
|
||||
name = "인형",
|
||||
receiptCode = "A",
|
||||
representativeItem = "피규어",
|
||||
requiresDamageWaiver = false,
|
||||
isActive = true
|
||||
).apply { id = CATEGORY_ID }
|
||||
val sizePrice = GiftSizePrice(GiftSize.SMALL, BASE_PRICE_CAN, SALE_PRICE_CAN, true)
|
||||
lateinit var savedGift: Gift
|
||||
lateinit var savedDelivery: GiftDelivery
|
||||
lateinit var cancellationGift: Gift
|
||||
lateinit var cancellationDelivery: GiftDelivery
|
||||
lateinit var trackingGift: Gift
|
||||
lateinit var trackingDelivery: GiftDelivery
|
||||
lateinit var recipientAddressGift: Gift
|
||||
lateinit var recipientAddressDelivery: GiftDelivery
|
||||
lateinit var deliveryConfirmationGift: Gift
|
||||
lateinit var deliveryConfirmationDelivery: GiftDelivery
|
||||
lateinit var reviewGift: Gift
|
||||
lateinit var savedReview: GiftReview
|
||||
|
||||
val service = GiftCommandService(
|
||||
giftRepository = giftRepository,
|
||||
deliveryRepository = deliveryRepository,
|
||||
reviewRepository = reviewRepository,
|
||||
categoryRepository = categoryRepository,
|
||||
sizePriceRepository = sizePriceRepository,
|
||||
memberRepository = memberRepository,
|
||||
applicationNoGenerator = applicationNoGenerator,
|
||||
canPaymentService = canPaymentService,
|
||||
giftPushService = giftPushService,
|
||||
now = { NOW }
|
||||
)
|
||||
|
||||
init {
|
||||
setCreatorExists(true)
|
||||
Mockito.`when`(categoryRepository.findById(CATEGORY_ID)).thenReturn(Optional.of(category))
|
||||
Mockito.`when`(sizePriceRepository.findBySizeCode(GiftSize.SMALL)).thenReturn(sizePrice)
|
||||
Mockito.`when`(applicationNoGenerator.generate(category, NOW)).thenReturn(APPLICATION_NO)
|
||||
Mockito.`when`(canPaymentService.spendGiftCan(SENDER_ID, SALE_PRICE_CAN, "aos")).thenReturn(USE_CAN_ID)
|
||||
Mockito.`when`(giftRepository.save(Mockito.any(Gift::class.java))).thenAnswer { invocation ->
|
||||
invocation.getArgument<Gift>(0).apply {
|
||||
id = GIFT_ID
|
||||
savedGift = this
|
||||
}
|
||||
}
|
||||
Mockito.`when`(deliveryRepository.save(Mockito.any(GiftDelivery::class.java))).thenAnswer { invocation ->
|
||||
invocation.getArgument<GiftDelivery>(0).also { savedDelivery = it }
|
||||
}
|
||||
Mockito.`when`(reviewRepository.save(Mockito.any(GiftReview::class.java))).thenAnswer { invocation ->
|
||||
invocation.getArgument<GiftReview>(0).apply {
|
||||
id = REVIEW_ID
|
||||
createdAt = NOW
|
||||
savedReview = this
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setCreatorExists(exists: Boolean) {
|
||||
Mockito.`when`(memberRepository.findCreatorByIdOrNull(RECIPIENT_ID)).thenReturn(creator.takeIf { exists })
|
||||
}
|
||||
|
||||
fun prepareGiftForCancellation(
|
||||
status: GiftStatus = GiftStatus.RECEIVED,
|
||||
senderMemberId: Long = SENDER_ID
|
||||
) {
|
||||
cancellationGift = Gift(
|
||||
applicationNo = APPLICATION_NO,
|
||||
senderMemberId = senderMemberId,
|
||||
recipientMemberId = RECIPIENT_ID,
|
||||
status = status,
|
||||
sizeCode = GiftSize.SMALL,
|
||||
categoryId = CATEGORY_ID,
|
||||
categoryNameSnapshot = "인형",
|
||||
salePriceCan = SALE_PRICE_CAN,
|
||||
canUsageId = USE_CAN_ID,
|
||||
senderTermsAgreed = true,
|
||||
senderPrivacyAgreed = true,
|
||||
senderTermsAgreedAt = NOW,
|
||||
senderPrivacyAgreedAt = NOW,
|
||||
damageWaiverAgreed = true
|
||||
).apply { id = GIFT_ID }
|
||||
cancellationDelivery = GiftDelivery(
|
||||
giftId = GIFT_ID,
|
||||
senderName = "홍길동",
|
||||
senderPhoneNumber = "01012345678",
|
||||
senderZipCode = "06234",
|
||||
senderAddress = "서울시 강남구",
|
||||
trackingDeadlineAt = NOW.plusDays(3)
|
||||
)
|
||||
Mockito.`when`(giftRepository.findByApplicationNo(APPLICATION_NO)).thenReturn(cancellationGift)
|
||||
Mockito.`when`(deliveryRepository.findByGiftId(GIFT_ID)).thenReturn(cancellationDelivery)
|
||||
}
|
||||
|
||||
fun prepareGiftForTracking(
|
||||
status: GiftStatus = GiftStatus.RECEIVED,
|
||||
senderMemberId: Long = SENDER_ID,
|
||||
deliveryExists: Boolean = true
|
||||
) {
|
||||
trackingGift = Gift(
|
||||
applicationNo = APPLICATION_NO,
|
||||
senderMemberId = senderMemberId,
|
||||
recipientMemberId = RECIPIENT_ID,
|
||||
status = status,
|
||||
sizeCode = GiftSize.SMALL,
|
||||
categoryId = CATEGORY_ID,
|
||||
categoryNameSnapshot = "인형",
|
||||
salePriceCan = SALE_PRICE_CAN,
|
||||
canUsageId = USE_CAN_ID,
|
||||
senderTermsAgreed = true,
|
||||
senderPrivacyAgreed = true,
|
||||
senderTermsAgreedAt = NOW,
|
||||
senderPrivacyAgreedAt = NOW,
|
||||
damageWaiverAgreed = true
|
||||
).apply { id = GIFT_ID }
|
||||
trackingDelivery = GiftDelivery(
|
||||
giftId = GIFT_ID,
|
||||
senderName = "홍길동",
|
||||
senderPhoneNumber = "01012345678",
|
||||
senderZipCode = "06234",
|
||||
senderAddress = "서울시 강남구",
|
||||
trackingDeadlineAt = NOW.plusDays(3)
|
||||
)
|
||||
Mockito.`when`(giftRepository.findByApplicationNo(APPLICATION_NO)).thenReturn(trackingGift)
|
||||
Mockito.`when`(deliveryRepository.findByGiftId(GIFT_ID)).thenReturn(
|
||||
trackingDelivery.takeIf { deliveryExists }
|
||||
)
|
||||
}
|
||||
|
||||
fun prepareGiftForRecipientAddress(
|
||||
status: GiftStatus = GiftStatus.TRACKING_REGISTERED,
|
||||
recipientMemberId: Long = RECIPIENT_ID,
|
||||
deliveryExists: Boolean = true
|
||||
) {
|
||||
recipientAddressGift = Gift(
|
||||
applicationNo = APPLICATION_NO,
|
||||
senderMemberId = SENDER_ID,
|
||||
recipientMemberId = recipientMemberId,
|
||||
status = status,
|
||||
sizeCode = GiftSize.SMALL,
|
||||
categoryId = CATEGORY_ID,
|
||||
categoryNameSnapshot = "인형",
|
||||
salePriceCan = SALE_PRICE_CAN,
|
||||
canUsageId = USE_CAN_ID,
|
||||
senderTermsAgreed = true,
|
||||
senderPrivacyAgreed = true,
|
||||
senderTermsAgreedAt = NOW,
|
||||
senderPrivacyAgreedAt = NOW,
|
||||
damageWaiverAgreed = true
|
||||
).apply { id = GIFT_ID }
|
||||
recipientAddressDelivery = GiftDelivery(
|
||||
giftId = GIFT_ID,
|
||||
senderName = "홍길동",
|
||||
senderPhoneNumber = "01012345678",
|
||||
senderZipCode = "06234",
|
||||
senderAddress = "서울시 강남구",
|
||||
trackingDeadlineAt = NOW.plusDays(3)
|
||||
)
|
||||
Mockito.`when`(giftRepository.findByApplicationNo(APPLICATION_NO)).thenReturn(recipientAddressGift)
|
||||
Mockito.`when`(deliveryRepository.findByGiftId(GIFT_ID)).thenReturn(
|
||||
recipientAddressDelivery.takeIf { deliveryExists }
|
||||
)
|
||||
}
|
||||
|
||||
fun prepareGiftForDeliveryConfirmation(
|
||||
status: GiftStatus = GiftStatus.INSPECTION_COMPLETED,
|
||||
recipientMemberId: Long = RECIPIENT_ID,
|
||||
deliveryExists: Boolean = true
|
||||
) {
|
||||
deliveryConfirmationGift = Gift(
|
||||
applicationNo = APPLICATION_NO,
|
||||
senderMemberId = SENDER_ID,
|
||||
recipientMemberId = recipientMemberId,
|
||||
status = status,
|
||||
sizeCode = GiftSize.SMALL,
|
||||
categoryId = CATEGORY_ID,
|
||||
categoryNameSnapshot = "인형",
|
||||
salePriceCan = SALE_PRICE_CAN,
|
||||
canUsageId = USE_CAN_ID,
|
||||
senderTermsAgreed = true,
|
||||
senderPrivacyAgreed = true,
|
||||
senderTermsAgreedAt = NOW,
|
||||
senderPrivacyAgreedAt = NOW,
|
||||
damageWaiverAgreed = true
|
||||
).apply { id = GIFT_ID }
|
||||
deliveryConfirmationDelivery = GiftDelivery(
|
||||
giftId = GIFT_ID,
|
||||
senderName = "홍길동",
|
||||
senderPhoneNumber = "01012345678",
|
||||
senderZipCode = "06234",
|
||||
senderAddress = "서울시 강남구",
|
||||
trackingDeadlineAt = NOW.plusDays(3)
|
||||
)
|
||||
Mockito.`when`(giftRepository.findByApplicationNo(APPLICATION_NO)).thenReturn(deliveryConfirmationGift)
|
||||
Mockito.`when`(deliveryRepository.findByGiftId(GIFT_ID)).thenReturn(
|
||||
deliveryConfirmationDelivery.takeIf { deliveryExists }
|
||||
)
|
||||
}
|
||||
|
||||
fun prepareGiftForReview(
|
||||
status: GiftStatus = GiftStatus.DELIVERED,
|
||||
senderMemberId: Long = SENDER_ID,
|
||||
reviewExists: Boolean = false
|
||||
) {
|
||||
reviewGift = Gift(
|
||||
applicationNo = APPLICATION_NO,
|
||||
senderMemberId = senderMemberId,
|
||||
recipientMemberId = RECIPIENT_ID,
|
||||
status = status,
|
||||
sizeCode = GiftSize.SMALL,
|
||||
categoryId = CATEGORY_ID,
|
||||
categoryNameSnapshot = "인형",
|
||||
salePriceCan = SALE_PRICE_CAN,
|
||||
canUsageId = USE_CAN_ID,
|
||||
senderTermsAgreed = true,
|
||||
senderPrivacyAgreed = true,
|
||||
senderTermsAgreedAt = NOW,
|
||||
senderPrivacyAgreedAt = NOW,
|
||||
damageWaiverAgreed = true
|
||||
).apply { id = GIFT_ID }
|
||||
Mockito.`when`(giftRepository.findByApplicationNo(APPLICATION_NO)).thenReturn(reviewGift)
|
||||
Mockito.`when`(reviewRepository.findByGiftId(GIFT_ID)).thenReturn(
|
||||
GiftReview(GIFT_ID, SENDER_ID, 5).takeIf { reviewExists }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class InvalidGiftApplicationScenario(
|
||||
val command: GiftApplicationCommand,
|
||||
val categoryActive: Boolean = true,
|
||||
val requiresDamageWaiver: Boolean = false,
|
||||
val sizePriceActive: Boolean = true,
|
||||
val creatorExists: Boolean = true
|
||||
)
|
||||
|
||||
data class InvalidGiftCancellationScenario(
|
||||
val requesterMemberId: Long,
|
||||
val senderMemberId: Long,
|
||||
val status: GiftStatus
|
||||
)
|
||||
|
||||
data class InvalidGiftTrackingRegistrationScenario(
|
||||
val requesterMemberId: Long = SENDER_ID,
|
||||
val senderMemberId: Long = SENDER_ID,
|
||||
val status: GiftStatus = GiftStatus.RECEIVED,
|
||||
val command: GiftTrackingRegistrationCommand = validGiftTrackingRegistrationCommand(),
|
||||
val deliveryExists: Boolean = true
|
||||
)
|
||||
|
||||
data class InvalidGiftRecipientAddressRegistrationScenario(
|
||||
val requesterMemberId: Long = RECIPIENT_ID,
|
||||
val recipientMemberId: Long = RECIPIENT_ID,
|
||||
val status: GiftStatus = GiftStatus.TRACKING_REGISTERED,
|
||||
val command: GiftRecipientAddressRegistrationCommand = validGiftRecipientAddressRegistrationCommand(),
|
||||
val deliveryExists: Boolean = true
|
||||
)
|
||||
|
||||
data class InvalidGiftDeliveryConfirmationScenario(
|
||||
val requesterMemberId: Long = RECIPIENT_ID,
|
||||
val recipientMemberId: Long = RECIPIENT_ID,
|
||||
val status: GiftStatus = GiftStatus.INSPECTION_COMPLETED,
|
||||
val deliveryExists: Boolean = true
|
||||
)
|
||||
|
||||
data class InvalidGiftReviewScenario(
|
||||
val requesterMemberId: Long = SENDER_ID,
|
||||
val senderMemberId: Long = SENDER_ID,
|
||||
val status: GiftStatus = GiftStatus.DELIVERED,
|
||||
val command: GiftReviewCommand = validGiftReviewCommand(),
|
||||
val reviewExists: Boolean = false
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val MEMBER_ID = 1L
|
||||
const val CHARGE_ID = 10L
|
||||
const val USE_CAN_ID = 100L
|
||||
const val INITIAL_CAN = 100
|
||||
const val GIFT_PRICE_CAN = 30
|
||||
const val SENDER_ID = 2L
|
||||
const val OTHER_MEMBER_ID = 4L
|
||||
const val RECIPIENT_ID = 3L
|
||||
const val CATEGORY_ID = 4L
|
||||
const val GIFT_ID = 5L
|
||||
const val REVIEW_ID = 6L
|
||||
const val APPLICATION_NO = "G20260929000001"
|
||||
const val BASE_PRICE_CAN = 100
|
||||
const val SALE_PRICE_CAN = 80
|
||||
val NOW: LocalDateTime = LocalDateTime.of(2026, 9, 29, 12, 0)
|
||||
|
||||
@JvmStatic
|
||||
fun invalidGiftApplicationScenarios() = listOf(
|
||||
InvalidGiftApplicationScenario(validGiftApplicationCommand().copy(senderName = "")),
|
||||
InvalidGiftApplicationScenario(validGiftApplicationCommand().copy(senderPhoneNumber = "")),
|
||||
InvalidGiftApplicationScenario(validGiftApplicationCommand().copy(senderZipCode = "")),
|
||||
InvalidGiftApplicationScenario(validGiftApplicationCommand().copy(senderAddress = "")),
|
||||
InvalidGiftApplicationScenario(validGiftApplicationCommand().copy(senderTermsAgreed = false)),
|
||||
InvalidGiftApplicationScenario(validGiftApplicationCommand().copy(senderPrivacyAgreed = false)),
|
||||
InvalidGiftApplicationScenario(validGiftApplicationCommand(), categoryActive = false),
|
||||
InvalidGiftApplicationScenario(
|
||||
validGiftApplicationCommand().copy(damageWaiverAgreed = false),
|
||||
requiresDamageWaiver = true
|
||||
),
|
||||
InvalidGiftApplicationScenario(validGiftApplicationCommand(), sizePriceActive = false),
|
||||
InvalidGiftApplicationScenario(validGiftApplicationCommand(), creatorExists = false)
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
fun invalidGiftCancellationScenarios() = listOf(
|
||||
InvalidGiftCancellationScenario(OTHER_MEMBER_ID, SENDER_ID, GiftStatus.RECEIVED),
|
||||
InvalidGiftCancellationScenario(SENDER_ID, SENDER_ID, GiftStatus.TRACKING_REGISTERED)
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
fun invalidGiftTrackingRegistrationScenarios() = listOf(
|
||||
InvalidGiftTrackingRegistrationScenario(requesterMemberId = OTHER_MEMBER_ID),
|
||||
InvalidGiftTrackingRegistrationScenario(status = GiftStatus.TRACKING_REGISTERED),
|
||||
InvalidGiftTrackingRegistrationScenario(
|
||||
command = validGiftTrackingRegistrationCommand().copy(courierCompanyName = "")
|
||||
),
|
||||
InvalidGiftTrackingRegistrationScenario(
|
||||
command = validGiftTrackingRegistrationCommand().copy(courierCompanyName = "a".repeat(51))
|
||||
),
|
||||
InvalidGiftTrackingRegistrationScenario(
|
||||
command = validGiftTrackingRegistrationCommand().copy(trackingNumber = "")
|
||||
),
|
||||
InvalidGiftTrackingRegistrationScenario(deliveryExists = false)
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
fun recipientAddressRegistrableStatuses() = listOf(
|
||||
GiftStatus.TRACKING_REGISTERED,
|
||||
GiftStatus.ARRIVED_AT_MAILBOX,
|
||||
GiftStatus.INSPECTION_COMPLETED
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
fun invalidGiftRecipientAddressRegistrationScenarios() = listOf(
|
||||
InvalidGiftRecipientAddressRegistrationScenario(requesterMemberId = OTHER_MEMBER_ID),
|
||||
InvalidGiftRecipientAddressRegistrationScenario(status = GiftStatus.RECEIVED),
|
||||
InvalidGiftRecipientAddressRegistrationScenario(status = GiftStatus.DELIVERED),
|
||||
InvalidGiftRecipientAddressRegistrationScenario(status = GiftStatus.UNDELIVERABLE),
|
||||
InvalidGiftRecipientAddressRegistrationScenario(status = GiftStatus.CANCELED),
|
||||
InvalidGiftRecipientAddressRegistrationScenario(
|
||||
command = validGiftRecipientAddressRegistrationCommand().copy(name = "")
|
||||
),
|
||||
InvalidGiftRecipientAddressRegistrationScenario(
|
||||
command = validGiftRecipientAddressRegistrationCommand().copy(phoneNumber = "")
|
||||
),
|
||||
InvalidGiftRecipientAddressRegistrationScenario(
|
||||
command = validGiftRecipientAddressRegistrationCommand().copy(zipCode = "")
|
||||
),
|
||||
InvalidGiftRecipientAddressRegistrationScenario(
|
||||
command = validGiftRecipientAddressRegistrationCommand().copy(address = "")
|
||||
),
|
||||
InvalidGiftRecipientAddressRegistrationScenario(
|
||||
command = validGiftRecipientAddressRegistrationCommand().copy(recipientTermsAgreed = false)
|
||||
),
|
||||
InvalidGiftRecipientAddressRegistrationScenario(
|
||||
command = validGiftRecipientAddressRegistrationCommand().copy(recipientPrivacyAgreed = false)
|
||||
),
|
||||
InvalidGiftRecipientAddressRegistrationScenario(deliveryExists = false)
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
fun invalidGiftDeliveryConfirmationScenarios() = listOf(
|
||||
InvalidGiftDeliveryConfirmationScenario(requesterMemberId = OTHER_MEMBER_ID),
|
||||
InvalidGiftDeliveryConfirmationScenario(status = GiftStatus.TRACKING_REGISTERED),
|
||||
InvalidGiftDeliveryConfirmationScenario(status = GiftStatus.DELIVERED),
|
||||
InvalidGiftDeliveryConfirmationScenario(deliveryExists = false)
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
fun invalidGiftReviewScenarios() = listOf(
|
||||
InvalidGiftReviewScenario(requesterMemberId = OTHER_MEMBER_ID),
|
||||
InvalidGiftReviewScenario(status = GiftStatus.INSPECTION_COMPLETED),
|
||||
InvalidGiftReviewScenario(reviewExists = true),
|
||||
InvalidGiftReviewScenario(command = validGiftReviewCommand().copy(rating = 0)),
|
||||
InvalidGiftReviewScenario(command = validGiftReviewCommand().copy(rating = 6)),
|
||||
InvalidGiftReviewScenario(command = validGiftReviewCommand().copy(keywords = listOf("a".repeat(256)))),
|
||||
InvalidGiftReviewScenario(command = validGiftReviewCommand().copy(comment = "a".repeat(256)))
|
||||
)
|
||||
|
||||
fun validGiftApplicationCommand() = GiftApplicationCommand(
|
||||
recipientMemberId = RECIPIENT_ID,
|
||||
senderName = "홍길동",
|
||||
senderPhoneNumber = "01012345678",
|
||||
senderZipCode = "06234",
|
||||
senderAddress = "서울시 강남구",
|
||||
senderAddressDetail = "101동 1001호",
|
||||
sizeCode = GiftSize.SMALL,
|
||||
categoryId = CATEGORY_ID,
|
||||
senderTermsAgreed = true,
|
||||
senderPrivacyAgreed = true,
|
||||
damageWaiverAgreed = true
|
||||
)
|
||||
|
||||
fun validGiftTrackingRegistrationCommand() = GiftTrackingRegistrationCommand(
|
||||
courierCompanyName = "CJ대한통운",
|
||||
trackingNumber = "1234567890"
|
||||
)
|
||||
|
||||
fun validGiftRecipientAddressRegistrationCommand() = GiftRecipientAddressRegistrationCommand(
|
||||
name = "김소다",
|
||||
phoneNumber = "01098765432",
|
||||
zipCode = "04524",
|
||||
address = "서울시 중구",
|
||||
addressDetail = "202호",
|
||||
recipientTermsAgreed = true,
|
||||
recipientPrivacyAgreed = true
|
||||
)
|
||||
|
||||
fun validGiftReviewCommand() = GiftReviewCommand(
|
||||
rating = 5,
|
||||
keywords = listOf("배송이 빨라요", "안내가 친절해요"),
|
||||
comment = "선물 전달 과정이 만족스러웠습니다."
|
||||
)
|
||||
}
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
|
||||
|
||||
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||
import org.springframework.context.annotation.Import
|
||||
import org.springframework.dao.DataIntegrityViolationException
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@DataJpaTest(
|
||||
properties = [
|
||||
"spring.cache.type=none",
|
||||
"spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE"
|
||||
]
|
||||
)
|
||||
@Import(QueryDslConfig::class)
|
||||
@DisplayName("선물 엔티티 매핑 테스트")
|
||||
class GiftEntityMappingTest @Autowired constructor(
|
||||
private val giftRepository: GiftRepository,
|
||||
private val giftDeliveryRepository: GiftDeliveryRepository,
|
||||
private val giftCategoryRepository: GiftCategoryRepository,
|
||||
private val giftSizePriceRepository: GiftSizePriceRepository,
|
||||
private val giftReviewRepository: GiftReviewRepository,
|
||||
private val giftApplicationNoSequenceRepository: GiftApplicationNoSequenceRepository
|
||||
) {
|
||||
@Test
|
||||
@DisplayName("선물 엔티티와 배송 정보를 저장하고 조회한다")
|
||||
fun shouldSaveAndFindGiftWithDelivery() {
|
||||
val category = giftCategoryRepository.save(
|
||||
GiftCategory(
|
||||
classificationNumber = "100",
|
||||
categoryCode = "DOLL",
|
||||
name = "인형",
|
||||
receiptCode = "A",
|
||||
representativeItem = "피규어",
|
||||
requiresDamageWaiver = true
|
||||
)
|
||||
)
|
||||
giftSizePriceRepository.save(
|
||||
GiftSizePrice(
|
||||
sizeCode = GiftSize.SMALL,
|
||||
basePriceCan = 100,
|
||||
salePriceCan = 80
|
||||
)
|
||||
)
|
||||
val gift = giftRepository.save(
|
||||
Gift(
|
||||
applicationNo = "G20260929000001",
|
||||
senderMemberId = 1L,
|
||||
recipientMemberId = 2L,
|
||||
status = GiftStatus.RECEIVED,
|
||||
sizeCode = GiftSize.SMALL,
|
||||
categoryId = category.id!!,
|
||||
categoryNameSnapshot = category.name,
|
||||
salePriceCan = 80,
|
||||
canUsageId = 10L,
|
||||
senderTermsAgreed = true,
|
||||
senderPrivacyAgreed = true,
|
||||
senderTermsAgreedAt = LocalDateTime.of(2026, 9, 29, 12, 0),
|
||||
senderPrivacyAgreedAt = LocalDateTime.of(2026, 9, 29, 12, 0),
|
||||
damageWaiverAgreed = true
|
||||
)
|
||||
)
|
||||
|
||||
giftDeliveryRepository.save(
|
||||
GiftDelivery(
|
||||
giftId = gift.id!!,
|
||||
senderName = "홍길동",
|
||||
senderPhoneNumber = "01012345678",
|
||||
senderZipCode = "06234",
|
||||
senderAddress = "서울시 강남구",
|
||||
senderAddressDetail = "101동 1001호",
|
||||
trackingDeadlineAt = LocalDateTime.of(2026, 10, 2, 12, 0)
|
||||
)
|
||||
)
|
||||
|
||||
val saved = giftRepository.findByApplicationNo("G20260929000001")
|
||||
val delivery = giftDeliveryRepository.findByGiftId(gift.id!!)
|
||||
|
||||
assertNotNull(saved)
|
||||
assertEquals(GiftStatus.RECEIVED, saved!!.status)
|
||||
assertEquals("홍길동", delivery!!.senderName)
|
||||
assertNotNull(saved.createdAt)
|
||||
assertNotNull(saved.updatedAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("신청번호와 카테고리 unique 값과 리뷰 선물 ID는 중복 저장할 수 없다")
|
||||
fun shouldRejectDuplicateUniqueValues() {
|
||||
val category = giftCategoryRepository.save(giftCategory("100", "DOLL", "A"))
|
||||
val gift = giftRepository.save(gift(category.id!!, "G20260929000001"))
|
||||
giftRepository.saveAndFlush(gift(category.id!!, "G20260929000002"))
|
||||
giftReviewRepository.saveAndFlush(GiftReview(giftId = gift.id!!, senderMemberId = 1L, rating = 5))
|
||||
|
||||
assertThrows(DataIntegrityViolationException::class.java) {
|
||||
giftRepository.saveAndFlush(gift(category.id!!, "G20260929000001"))
|
||||
}
|
||||
assertThrows(DataIntegrityViolationException::class.java) {
|
||||
giftCategoryRepository.saveAndFlush(giftCategory("101", "DOLL", "B"))
|
||||
}
|
||||
assertThrows(DataIntegrityViolationException::class.java) {
|
||||
giftCategoryRepository.saveAndFlush(giftCategory("100", "TOY", "B"))
|
||||
}
|
||||
assertThrows(DataIntegrityViolationException::class.java) {
|
||||
giftCategoryRepository.saveAndFlush(giftCategory("101", "TOY", "A"))
|
||||
}
|
||||
assertThrows(DataIntegrityViolationException::class.java) {
|
||||
giftReviewRepository.saveAndFlush(GiftReview(giftId = gift.id!!, senderMemberId = 1L, rating = 4))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("카테고리와 날짜별 신청번호 채번 sequence를 저장한다")
|
||||
fun shouldSaveApplicationNoSequence() {
|
||||
val category = giftCategoryRepository.save(giftCategory("100", "DOLL", "A"))
|
||||
val sequence = giftApplicationNoSequenceRepository.save(
|
||||
GiftApplicationNoSequence(categoryId = category.id!!, sequenceDate = "260929", lastSequence = 1)
|
||||
)
|
||||
|
||||
assertEquals(category.id, sequence.categoryId)
|
||||
assertEquals("260929", sequence.sequenceDate)
|
||||
assertEquals(1, sequence.lastSequence)
|
||||
}
|
||||
|
||||
private fun gift(categoryId: Long, applicationNo: String) = Gift(
|
||||
applicationNo = applicationNo,
|
||||
senderMemberId = 1L,
|
||||
recipientMemberId = 2L,
|
||||
status = GiftStatus.RECEIVED,
|
||||
sizeCode = GiftSize.SMALL,
|
||||
categoryId = categoryId,
|
||||
categoryNameSnapshot = "인형",
|
||||
salePriceCan = 80,
|
||||
canUsageId = 10L,
|
||||
senderTermsAgreed = true,
|
||||
senderPrivacyAgreed = true,
|
||||
senderTermsAgreedAt = LocalDateTime.of(2026, 9, 29, 12, 0),
|
||||
senderPrivacyAgreedAt = LocalDateTime.of(2026, 9, 29, 12, 0),
|
||||
damageWaiverAgreed = true
|
||||
)
|
||||
|
||||
private fun giftCategory(classificationNumber: String, categoryCode: String, receiptCode: String) = GiftCategory(
|
||||
classificationNumber = classificationNumber,
|
||||
categoryCode = categoryCode,
|
||||
name = "인형",
|
||||
receiptCode = receiptCode,
|
||||
representativeItem = "피규어"
|
||||
)
|
||||
}
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.adapter.out.scheduler
|
||||
|
||||
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
|
||||
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftPushService
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.redisson.api.RedissonClient
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||
import org.springframework.context.annotation.Import
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@DataJpaTest(
|
||||
properties = [
|
||||
"spring.cache.type=none",
|
||||
"spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE"
|
||||
]
|
||||
)
|
||||
@Import(QueryDslConfig::class)
|
||||
@DisplayName("선물 자동처리 스케줄러 테스트")
|
||||
class GiftSchedulerTest @Autowired constructor(
|
||||
private val giftRepository: GiftRepository,
|
||||
private val giftDeliveryRepository: GiftDeliveryRepository
|
||||
) {
|
||||
private val canPaymentService = Mockito.mock(CanPaymentService::class.java)
|
||||
private val giftPushService = Mockito.mock(GiftPushService::class.java)
|
||||
private val scheduler = GiftScheduler(
|
||||
giftRepository,
|
||||
giftDeliveryRepository,
|
||||
canPaymentService,
|
||||
giftPushService,
|
||||
Mockito.mock(RedissonClient::class.java)
|
||||
)
|
||||
|
||||
@Test
|
||||
@DisplayName("운송장 등록 기한이 지난 접수 선물을 취소하고 한 번 환불한다")
|
||||
fun shouldCancelOverdueReceivedGiftAndRefundOnce() {
|
||||
val now = LocalDateTime.of(2026, 10, 2, 12, 0)
|
||||
val (gift, delivery) = saveGift(GiftStatus.RECEIVED, now.minusSeconds(1))
|
||||
|
||||
val canceledCount = scheduler.cancelExpiredTrackingRegistrationGifts(now)
|
||||
|
||||
assertEquals(1, canceledCount)
|
||||
assertEquals(GiftStatus.CANCELED, gift.status)
|
||||
assertEquals(now, delivery.canceledAt)
|
||||
Mockito.verify(canPaymentService).refundGiftCan(SENDER_MEMBER_ID, CAN_USAGE_ID)
|
||||
Mockito.verify(giftPushService).sendAutoCanceledToSender(APPLICATION_NO, SENDER_MEMBER_ID)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("운송장 등록 기한과 같은 접수 선물은 취소하지 않는다")
|
||||
fun shouldIgnoreReceivedGiftWhoseTrackingDeadlineIsNotPast() {
|
||||
val now = LocalDateTime.of(2026, 10, 2, 12, 0)
|
||||
val (gift, delivery) = saveGift(GiftStatus.RECEIVED, now)
|
||||
|
||||
val canceledCount = scheduler.cancelExpiredTrackingRegistrationGifts(now)
|
||||
|
||||
assertEquals(0, canceledCount)
|
||||
assertEquals(GiftStatus.RECEIVED, gift.status)
|
||||
assertEquals(null, delivery.canceledAt)
|
||||
Mockito.verifyNoInteractions(canPaymentService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("운송장 등록 기한이 지나도 접수 상태가 아닌 선물은 취소하지 않는다")
|
||||
fun shouldIgnoreOverdueGiftThatIsNotReceived() {
|
||||
val now = LocalDateTime.of(2026, 10, 2, 12, 0)
|
||||
val (gift, delivery) = saveGift(GiftStatus.TRACKING_REGISTERED, now.minusSeconds(1))
|
||||
|
||||
val canceledCount = scheduler.cancelExpiredTrackingRegistrationGifts(now)
|
||||
|
||||
assertEquals(0, canceledCount)
|
||||
assertEquals(GiftStatus.TRACKING_REGISTERED, gift.status)
|
||||
assertEquals(null, delivery.canceledAt)
|
||||
Mockito.verifyNoInteractions(canPaymentService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("자동취소를 다시 실행해도 이미 취소된 선물은 환불하지 않는다")
|
||||
fun shouldNotRefundAlreadyCanceledGiftOnSecondRun() {
|
||||
val now = LocalDateTime.of(2026, 10, 2, 12, 0)
|
||||
saveGift(GiftStatus.RECEIVED, now.minusSeconds(1))
|
||||
|
||||
val firstCanceledCount = scheduler.cancelExpiredTrackingRegistrationGifts(now)
|
||||
val secondCanceledCount = scheduler.cancelExpiredTrackingRegistrationGifts(now)
|
||||
|
||||
assertEquals(1, firstCanceledCount)
|
||||
assertEquals(0, secondCanceledCount)
|
||||
Mockito.verify(canPaymentService, Mockito.times(1)).refundGiftCan(SENDER_MEMBER_ID, CAN_USAGE_ID)
|
||||
Mockito.verify(giftPushService, Mockito.times(1)).sendAutoCanceledToSender(APPLICATION_NO, SENDER_MEMBER_ID)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("배송지 입력 기한이 지난 발송 확인 선물을 전달 불가 처리한다")
|
||||
fun shouldMarkOverdueTrackingRegisteredGiftWithoutRecipientAddressUndeliverable() {
|
||||
val now = LocalDateTime.of(2026, 10, 9, 12, 0)
|
||||
val (gift, delivery) = saveGift(
|
||||
status = GiftStatus.TRACKING_REGISTERED,
|
||||
trackingDeadlineAt = now.minusDays(7),
|
||||
recipientAddressDeadlineAt = now.minusSeconds(1)
|
||||
)
|
||||
|
||||
val updatedCount = scheduler.markExpiredRecipientAddressGiftsUndeliverable(now)
|
||||
|
||||
assertEquals(1, updatedCount)
|
||||
assertEquals(GiftStatus.UNDELIVERABLE, gift.status)
|
||||
assertEquals(now, delivery.undeliverableAt)
|
||||
assertEquals("배송지 미입력 기한 초과", delivery.undeliverableReason)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("배송지 입력 기한과 같은 발송 확인 선물은 전달 불가 처리하지 않는다")
|
||||
fun shouldIgnoreTrackingRegisteredGiftWhoseRecipientAddressDeadlineIsNotPast() {
|
||||
val now = LocalDateTime.of(2026, 10, 9, 12, 0)
|
||||
val (gift, delivery) = saveGift(
|
||||
status = GiftStatus.TRACKING_REGISTERED,
|
||||
trackingDeadlineAt = now.minusDays(7),
|
||||
recipientAddressDeadlineAt = now
|
||||
)
|
||||
|
||||
val updatedCount = scheduler.markExpiredRecipientAddressGiftsUndeliverable(now)
|
||||
|
||||
assertEquals(0, updatedCount)
|
||||
assertEquals(GiftStatus.TRACKING_REGISTERED, gift.status)
|
||||
assertEquals(null, delivery.undeliverableAt)
|
||||
assertEquals(null, delivery.undeliverableReason)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("배송지가 입력된 선물은 배송지 입력 기한이 지나도 전달 불가 처리하지 않는다")
|
||||
fun shouldIgnoreGiftWithRecipientAddress() {
|
||||
val now = LocalDateTime.of(2026, 10, 9, 12, 0)
|
||||
val (gift, delivery) = saveGift(
|
||||
status = GiftStatus.TRACKING_REGISTERED,
|
||||
trackingDeadlineAt = now.minusDays(7),
|
||||
recipientAddressDeadlineAt = now.minusSeconds(1),
|
||||
recipientAddress = "서울시 송파구"
|
||||
)
|
||||
|
||||
val updatedCount = scheduler.markExpiredRecipientAddressGiftsUndeliverable(now)
|
||||
|
||||
assertEquals(0, updatedCount)
|
||||
assertEquals(GiftStatus.TRACKING_REGISTERED, gift.status)
|
||||
assertEquals(null, delivery.undeliverableAt)
|
||||
assertEquals(null, delivery.undeliverableReason)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("배송지 미입력 전달 불가를 다시 실행해도 이미 전달 불가인 선물은 처리하지 않는다")
|
||||
fun shouldNotUpdateAlreadyUndeliverableGiftOnSecondRun() {
|
||||
val now = LocalDateTime.of(2026, 10, 9, 12, 0)
|
||||
val (_, delivery) = saveGift(
|
||||
status = GiftStatus.TRACKING_REGISTERED,
|
||||
trackingDeadlineAt = now.minusDays(7),
|
||||
recipientAddressDeadlineAt = now.minusSeconds(1)
|
||||
)
|
||||
|
||||
val firstUpdatedCount = scheduler.markExpiredRecipientAddressGiftsUndeliverable(now)
|
||||
val secondUpdatedCount = scheduler.markExpiredRecipientAddressGiftsUndeliverable(now.plusMinutes(1))
|
||||
|
||||
assertEquals(1, firstUpdatedCount)
|
||||
assertEquals(0, secondUpdatedCount)
|
||||
assertEquals(now, delivery.undeliverableAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("운송장 등록 기한 24시간 전 접수 선물에 한 번만 안내한다")
|
||||
fun shouldSendTrackingDeadlineReminderOnce() {
|
||||
val now = LocalDateTime.of(2026, 10, 1, 12, 0)
|
||||
val (_, delivery) = saveGift(GiftStatus.RECEIVED, now.plusHours(24))
|
||||
|
||||
val firstSentCount = scheduler.sendDeadlineReminderGifts(now)
|
||||
val secondSentCount = scheduler.sendDeadlineReminderGifts(now.plusMinutes(1))
|
||||
|
||||
assertEquals(1, firstSentCount)
|
||||
assertEquals(0, secondSentCount)
|
||||
assertEquals(now, delivery.trackingDeadlineReminderSentAt)
|
||||
Mockito.verify(giftPushService, Mockito.times(1)).sendTrackingDeadlineReminder(APPLICATION_NO, SENDER_MEMBER_ID)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("운송장 등록 기한 24시간 초과 전 접수 선물은 안내하지 않는다")
|
||||
fun shouldIgnoreTrackingDeadlineReminderBeforeWindow() {
|
||||
val now = LocalDateTime.of(2026, 10, 1, 12, 0)
|
||||
saveGift(GiftStatus.RECEIVED, now.plusHours(24).plusSeconds(1))
|
||||
|
||||
val sentCount = scheduler.sendDeadlineReminderGifts(now)
|
||||
|
||||
assertEquals(0, sentCount)
|
||||
Mockito.verifyNoInteractions(giftPushService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("배송지 입력 기한 24시간 전 선물에 한 번만 안내한다")
|
||||
fun shouldSendRecipientAddressDeadlineReminderOnce() {
|
||||
val now = LocalDateTime.of(2026, 10, 8, 12, 0)
|
||||
val (_, delivery) = saveGift(
|
||||
status = GiftStatus.TRACKING_REGISTERED,
|
||||
trackingDeadlineAt = now.minusDays(6),
|
||||
recipientAddressDeadlineAt = now.plusHours(24)
|
||||
)
|
||||
|
||||
val firstSentCount = scheduler.sendDeadlineReminderGifts(now)
|
||||
val secondSentCount = scheduler.sendDeadlineReminderGifts(now.plusMinutes(1))
|
||||
|
||||
assertEquals(1, firstSentCount)
|
||||
assertEquals(0, secondSentCount)
|
||||
assertEquals(now, delivery.recipientAddressDeadlineReminderSentAt)
|
||||
Mockito.verify(giftPushService, Mockito.times(1))
|
||||
.sendRecipientAddressDeadlineReminder(APPLICATION_NO, RECIPIENT_MEMBER_ID)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("배송지가 입력된 선물은 배송지 입력 기한 안내를 보내지 않는다")
|
||||
fun shouldIgnoreRecipientAddressDeadlineReminderWhenAddressExists() {
|
||||
val now = LocalDateTime.of(2026, 10, 8, 12, 0)
|
||||
saveGift(
|
||||
status = GiftStatus.TRACKING_REGISTERED,
|
||||
trackingDeadlineAt = now.minusDays(6),
|
||||
recipientAddressDeadlineAt = now.plusHours(24),
|
||||
recipientAddress = "서울시 송파구"
|
||||
)
|
||||
|
||||
val sentCount = scheduler.sendDeadlineReminderGifts(now)
|
||||
|
||||
assertEquals(0, sentCount)
|
||||
Mockito.verifyNoInteractions(giftPushService)
|
||||
}
|
||||
|
||||
private fun saveGift(
|
||||
status: GiftStatus,
|
||||
trackingDeadlineAt: LocalDateTime,
|
||||
recipientAddressDeadlineAt: LocalDateTime? = null,
|
||||
recipientAddress: String? = null
|
||||
): Pair<Gift, GiftDelivery> {
|
||||
val gift = giftRepository.save(
|
||||
Gift(
|
||||
applicationNo = APPLICATION_NO,
|
||||
senderMemberId = SENDER_MEMBER_ID,
|
||||
recipientMemberId = RECIPIENT_MEMBER_ID,
|
||||
status = status,
|
||||
sizeCode = GiftSize.SMALL,
|
||||
categoryId = CATEGORY_ID,
|
||||
categoryNameSnapshot = "인형",
|
||||
salePriceCan = 80,
|
||||
canUsageId = CAN_USAGE_ID,
|
||||
senderTermsAgreed = true,
|
||||
senderPrivacyAgreed = true,
|
||||
senderTermsAgreedAt = APPLIED_AT,
|
||||
senderPrivacyAgreedAt = APPLIED_AT,
|
||||
damageWaiverAgreed = true
|
||||
)
|
||||
)
|
||||
val delivery = giftDeliveryRepository.save(
|
||||
GiftDelivery(
|
||||
giftId = requireNotNull(gift.id),
|
||||
senderName = "홍길동",
|
||||
senderPhoneNumber = "01012345678",
|
||||
senderZipCode = "06234",
|
||||
senderAddress = "서울시 강남구",
|
||||
recipientAddress = recipientAddress,
|
||||
trackingDeadlineAt = trackingDeadlineAt,
|
||||
recipientAddressDeadlineAt = recipientAddressDeadlineAt
|
||||
)
|
||||
)
|
||||
|
||||
return gift to delivery
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val APPLICATION_NO = "G20260929000001"
|
||||
const val SENDER_MEMBER_ID = 1L
|
||||
const val RECIPIENT_MEMBER_ID = 2L
|
||||
const val CATEGORY_ID = 3L
|
||||
const val CAN_USAGE_ID = 4L
|
||||
val APPLIED_AT: LocalDateTime = LocalDateTime.of(2026, 9, 29, 12, 0)
|
||||
}
|
||||
}
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.application
|
||||
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||
import org.springframework.context.annotation.Import
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@DataJpaTest(
|
||||
properties = [
|
||||
"spring.cache.type=none",
|
||||
"spring.datasource.url=jdbc:h2:mem:gift-admin-query;MODE=MySQL;NON_KEYWORDS=VALUE"
|
||||
]
|
||||
)
|
||||
@Import(QueryDslConfig::class)
|
||||
@DisplayName("관리자 선물함 조회 서비스 테스트")
|
||||
class GiftAdminQueryServiceTest @Autowired constructor(
|
||||
private val giftRepository: GiftRepository,
|
||||
private val deliveryRepository: GiftDeliveryRepository,
|
||||
private val categoryRepository: GiftCategoryRepository,
|
||||
private val memberRepository: MemberRepository
|
||||
) {
|
||||
@Test
|
||||
@DisplayName("관리자는 선물함 목록을 필터링하고 가능한 액션을 조회한다")
|
||||
fun shouldListAdminGiftsWithFiltersAndAvailableActions() {
|
||||
val sender = saveMember("fan-alpha")
|
||||
val recipient = saveMember("creator-alpha")
|
||||
val otherSender = saveMember("fan-beta")
|
||||
val otherRecipient = saveMember("creator-beta")
|
||||
val category = saveCategory()
|
||||
saveGift(
|
||||
applicationNo = "A-1002609300001",
|
||||
sender = sender,
|
||||
recipient = recipient,
|
||||
category = category,
|
||||
status = GiftStatus.TRACKING_REGISTERED,
|
||||
withTracking = true
|
||||
)
|
||||
saveGift(
|
||||
applicationNo = "A-1002609300002",
|
||||
sender = otherSender,
|
||||
recipient = otherRecipient,
|
||||
category = category,
|
||||
status = GiftStatus.RECEIVED
|
||||
)
|
||||
|
||||
val result = service().getGifts(
|
||||
status = GiftStatus.TRACKING_REGISTERED,
|
||||
applicationNo = "0001",
|
||||
nickname = "alpha",
|
||||
page = 0,
|
||||
size = 20
|
||||
)
|
||||
|
||||
assertEquals(1L, result.totalCount)
|
||||
assertEquals(1, result.items.size)
|
||||
assertEquals(0, result.page)
|
||||
assertEquals(20, result.size)
|
||||
assertEquals(false, result.hasNext)
|
||||
val item = result.items.single()
|
||||
assertEquals("A-1002609300001", item.applicationNo)
|
||||
assertEquals("fan-alpha", item.senderNickname)
|
||||
assertEquals("creator-alpha", item.recipientNickname)
|
||||
assertEquals("SMALL", item.sizeCode)
|
||||
assertEquals("소형", item.sizeName)
|
||||
assertEquals("인형", item.categoryName)
|
||||
assertEquals("100", item.classificationNumber)
|
||||
assertEquals("CJ대한통운", item.courierCompanyName)
|
||||
assertEquals("1234567890", item.trackingNumber)
|
||||
assertEquals(GiftStatus.TRACKING_REGISTERED, item.status)
|
||||
assertEquals("발송 확인", item.statusName)
|
||||
assertEquals(listOf("ARRIVE_MAILBOX", "MARK_UNDELIVERABLE"), item.availableActions)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("관리자는 선물 상세에서 개인정보와 상품 정보를 조회하고 미입력 수취인 정보는 빈 문자열로 받는다")
|
||||
fun shouldGetAdminGiftDetailWithPersonalInformation() {
|
||||
val sender = saveMember("fan-alpha")
|
||||
val recipient = saveMember("creator-alpha")
|
||||
val category = saveCategory()
|
||||
saveGift(
|
||||
applicationNo = "A-1002609300001",
|
||||
sender = sender,
|
||||
recipient = recipient,
|
||||
category = category,
|
||||
status = GiftStatus.ARRIVED_AT_MAILBOX,
|
||||
withTracking = true
|
||||
)
|
||||
|
||||
val result = service().getGiftDetail("A-1002609300001")
|
||||
|
||||
assertEquals("A-1002609300001", result.applicationNo)
|
||||
assertEquals("fan-alpha", result.senderInfo.nickname)
|
||||
assertEquals("홍길동", result.senderInfo.name)
|
||||
assertEquals("01012345678", result.senderInfo.phoneNumber)
|
||||
assertEquals("(06234) 서울시 강남구, 101동 1001호", result.senderInfo.address)
|
||||
assertEquals("creator-alpha", result.recipientInfo.nickname)
|
||||
assertEquals("", result.recipientInfo.name)
|
||||
assertEquals("", result.recipientInfo.phoneNumber)
|
||||
assertEquals("", result.recipientInfo.address)
|
||||
assertEquals("SMALL", result.productInfo.sizeCode)
|
||||
assertEquals("소형", result.productInfo.sizeName)
|
||||
assertEquals("인형", result.productInfo.categoryName)
|
||||
assertEquals("100", result.productInfo.classificationNumber)
|
||||
assertEquals("CJ대한통운", result.inboundDeliveryInfo.courierCompanyName)
|
||||
assertEquals("1234567890", result.inboundDeliveryInfo.trackingNumber)
|
||||
assertEquals(GiftStatus.ARRIVED_AT_MAILBOX, result.status)
|
||||
assertEquals("사서함 도착", result.statusName)
|
||||
assertEquals(listOf("COMPLETE_INSPECTION", "MARK_UNDELIVERABLE"), result.availableActions)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("관리자 선물 상세는 상태별 가능한 액션을 반환하고 없는 신청번호를 거부한다")
|
||||
fun shouldReturnAvailableActionsByStatusAndRejectUnknownApplicationNo() {
|
||||
val sender = saveMember("fan-alpha")
|
||||
val recipient = saveMember("creator-alpha")
|
||||
val category = saveCategory()
|
||||
GiftStatus.values().forEachIndexed { index, status ->
|
||||
saveGift("A-10026093010$index", sender, recipient, category, status)
|
||||
}
|
||||
|
||||
val service = service()
|
||||
|
||||
assertEquals(emptyList<String>(), service.getGiftDetail("A-100260930100").availableActions)
|
||||
assertEquals(
|
||||
listOf("ARRIVE_MAILBOX", "MARK_UNDELIVERABLE"),
|
||||
service.getGiftDetail("A-100260930101").availableActions
|
||||
)
|
||||
assertEquals(
|
||||
listOf("COMPLETE_INSPECTION", "MARK_UNDELIVERABLE"),
|
||||
service.getGiftDetail("A-100260930102").availableActions
|
||||
)
|
||||
assertEquals(
|
||||
listOf("COMPLETE_DELIVERY", "MARK_UNDELIVERABLE"),
|
||||
service.getGiftDetail("A-100260930103").availableActions
|
||||
)
|
||||
assertEquals(emptyList<String>(), service.getGiftDetail("A-100260930104").availableActions)
|
||||
assertEquals(emptyList<String>(), service.getGiftDetail("A-100260930105").availableActions)
|
||||
assertEquals(emptyList<String>(), service.getGiftDetail("A-100260930106").availableActions)
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
service.getGiftDetail("UNKNOWN")
|
||||
}
|
||||
assertEquals("common.error.invalid_request", exception.messageKey)
|
||||
}
|
||||
|
||||
private fun service() = GiftAdminQueryService(
|
||||
giftRepository = giftRepository,
|
||||
deliveryRepository = deliveryRepository,
|
||||
categoryRepository = categoryRepository,
|
||||
memberRepository = memberRepository
|
||||
)
|
||||
|
||||
private fun saveMember(nickname: String) = memberRepository.save(Member(password = "password", nickname = nickname))
|
||||
|
||||
private fun saveCategory() = categoryRepository.save(
|
||||
GiftCategory(
|
||||
classificationNumber = "100",
|
||||
categoryCode = "DOLL",
|
||||
name = "인형",
|
||||
receiptCode = "A",
|
||||
representativeItem = "피규어"
|
||||
)
|
||||
)
|
||||
|
||||
private fun saveGift(
|
||||
applicationNo: String,
|
||||
sender: Member,
|
||||
recipient: Member,
|
||||
category: GiftCategory,
|
||||
status: GiftStatus,
|
||||
withTracking: Boolean = false
|
||||
): Gift {
|
||||
val gift = giftRepository.save(
|
||||
Gift(
|
||||
applicationNo = applicationNo,
|
||||
senderMemberId = sender.id!!,
|
||||
recipientMemberId = recipient.id!!,
|
||||
status = status,
|
||||
sizeCode = GiftSize.SMALL,
|
||||
categoryId = category.id!!,
|
||||
categoryNameSnapshot = category.name,
|
||||
salePriceCan = 80,
|
||||
canUsageId = 10L,
|
||||
senderTermsAgreed = true,
|
||||
senderPrivacyAgreed = true,
|
||||
senderTermsAgreedAt = NOW,
|
||||
senderPrivacyAgreedAt = NOW
|
||||
)
|
||||
)
|
||||
deliveryRepository.save(
|
||||
GiftDelivery(
|
||||
giftId = gift.id!!,
|
||||
senderName = "홍길동",
|
||||
senderPhoneNumber = "01012345678",
|
||||
senderZipCode = "06234",
|
||||
senderAddress = "서울시 강남구",
|
||||
senderAddressDetail = "101동 1001호",
|
||||
trackingDeadlineAt = NOW.plusDays(3)
|
||||
).apply {
|
||||
if (withTracking) {
|
||||
courierCompanyName = "CJ대한통운"
|
||||
trackingNumber = "1234567890"
|
||||
trackingRegisteredAt = NOW.plusHours(1)
|
||||
}
|
||||
}
|
||||
)
|
||||
return gift
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val NOW: LocalDateTime = LocalDateTime.of(2026, 9, 30, 12, 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.application
|
||||
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||
import org.springframework.context.annotation.Import
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@DataJpaTest(
|
||||
properties = [
|
||||
"spring.cache.type=none",
|
||||
"spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE"
|
||||
]
|
||||
)
|
||||
@Import(QueryDslConfig::class)
|
||||
@DisplayName("선물 관리자 서비스 테스트")
|
||||
class GiftAdminServiceTest @Autowired constructor(
|
||||
private val categoryRepository: GiftCategoryRepository,
|
||||
private val sizePriceRepository: GiftSizePriceRepository,
|
||||
private val giftRepository: GiftRepository,
|
||||
private val giftDeliveryRepository: GiftDeliveryRepository
|
||||
) {
|
||||
private val giftPushService = Mockito.mock(GiftPushService::class.java)
|
||||
private val service = GiftAdminService(
|
||||
categoryRepository,
|
||||
sizePriceRepository,
|
||||
giftRepository,
|
||||
giftDeliveryRepository,
|
||||
giftPushService
|
||||
)
|
||||
|
||||
@Test
|
||||
@DisplayName("카테고리를 등록, 수정, 논리삭제한다")
|
||||
fun shouldCreateUpdateAndDeactivateCategory() {
|
||||
val created = service.createCategory(
|
||||
GiftCategoryCommand(
|
||||
classificationNumber = "100",
|
||||
categoryCode = "DOLL",
|
||||
name = "인형",
|
||||
receiptCode = "A",
|
||||
representativeItem = "피규어",
|
||||
requiresDamageWaiver = true,
|
||||
isActive = true
|
||||
)
|
||||
)
|
||||
val updated = service.updateCategory(
|
||||
created.categoryId,
|
||||
GiftCategoryCommand(
|
||||
classificationNumber = "101",
|
||||
categoryCode = "DOLL",
|
||||
name = "큰 인형",
|
||||
receiptCode = "B",
|
||||
representativeItem = "대형 피규어",
|
||||
requiresDamageWaiver = false,
|
||||
isActive = true
|
||||
)
|
||||
)
|
||||
service.deleteCategory(created.categoryId)
|
||||
|
||||
val saved = categoryRepository.findById(created.categoryId).orElseThrow()
|
||||
assertEquals("101", updated.classificationNumber)
|
||||
assertEquals("DOLL", updated.categoryCode)
|
||||
assertEquals("큰 인형", updated.name)
|
||||
assertEquals("B", updated.receiptCode)
|
||||
assertEquals("대형 피규어", updated.representativeItem)
|
||||
assertFalse(updated.requiresDamageWaiver)
|
||||
assertFalse(saved.isActive)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("카테고리 분류번호와 접수코드 형식을 검증한다")
|
||||
fun shouldValidateCategoryClassificationNumberAndReceiptCode() {
|
||||
val invalidClassificationNumber = assertThrows(SodaException::class.java) {
|
||||
service.createCategory(categoryCommand(classificationNumber = "10A"))
|
||||
}
|
||||
val invalidReceiptCode = assertThrows(SodaException::class.java) {
|
||||
service.createCategory(categoryCommand(receiptCode = "A1"))
|
||||
}
|
||||
val blankRepresentativeItem = assertThrows(SodaException::class.java) {
|
||||
service.createCategory(categoryCommand(representativeItem = " "))
|
||||
}
|
||||
|
||||
assertEquals("common.error.invalid_request", invalidClassificationNumber.messageKey)
|
||||
assertEquals("common.error.invalid_request", invalidReceiptCode.messageKey)
|
||||
assertEquals("common.error.invalid_request", blankRepresentativeItem.messageKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("사이즈 가격을 수정하고 salePriceCan이 basePriceCan보다 크면 거부한다")
|
||||
fun shouldUpdateSizePriceAndRejectInvalidSalePrice() {
|
||||
val updated = service.updateSizePrice(
|
||||
GiftSize.SMALL,
|
||||
GiftSizePriceCommand(basePriceCan = 100, salePriceCan = 80, isActive = true)
|
||||
)
|
||||
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
service.updateSizePrice(
|
||||
GiftSize.SMALL,
|
||||
GiftSizePriceCommand(basePriceCan = 100, salePriceCan = 101, isActive = true)
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals(GiftSize.SMALL, updated.sizeCode)
|
||||
assertEquals(80, updated.salePriceCan)
|
||||
assertEquals("common.error.invalid_request", exception.messageKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("운영자는 발송 확인 선물을 사서함 도착 처리한다")
|
||||
fun shouldMarkTrackingRegisteredGiftArrivedAtMailbox() {
|
||||
val (gift, delivery) = saveGift(GiftStatus.TRACKING_REGISTERED)
|
||||
|
||||
val result = service.arriveMailbox(APPLICATION_NO, NOW)
|
||||
|
||||
assertEquals(GiftStatus.ARRIVED_AT_MAILBOX, result.status)
|
||||
assertEquals(GiftStatus.ARRIVED_AT_MAILBOX, gift.status)
|
||||
assertEquals(NOW, delivery.arrivedAtMailboxAt)
|
||||
Mockito.verify(giftPushService).sendArrivedAtMailboxToSender(APPLICATION_NO, SENDER_MEMBER_ID)
|
||||
Mockito.verify(giftPushService).sendArrivedAtMailboxToRecipient(APPLICATION_NO, RECIPIENT_MEMBER_ID)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("사서함 도착 상태가 아니면 검수 완료 처리할 수 없다")
|
||||
fun shouldRejectInspectionCompletionWhenGiftIsNotArrivedAtMailbox() {
|
||||
saveGift(GiftStatus.TRACKING_REGISTERED)
|
||||
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
service.completeInspection(APPLICATION_NO, NOW)
|
||||
}
|
||||
|
||||
assertEquals("common.error.invalid_request", exception.messageKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("운영자는 사서함 도착 선물을 검수 완료 처리한다")
|
||||
fun shouldCompleteInspectionForArrivedAtMailboxGift() {
|
||||
val (gift, delivery) = saveGift(GiftStatus.ARRIVED_AT_MAILBOX)
|
||||
|
||||
val result = service.completeInspection(APPLICATION_NO, NOW)
|
||||
|
||||
assertEquals(GiftStatus.INSPECTION_COMPLETED, result.status)
|
||||
assertEquals(GiftStatus.INSPECTION_COMPLETED, gift.status)
|
||||
assertEquals(NOW, delivery.inspectionCompletedAt)
|
||||
Mockito.verify(giftPushService).sendInspectionCompleted(APPLICATION_NO, RECIPIENT_MEMBER_ID)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("운영자는 진행 중 선물을 전달 불가 처리한다")
|
||||
fun shouldMarkActiveGiftUndeliverable() {
|
||||
val (gift, delivery) = saveGift(GiftStatus.INSPECTION_COMPLETED)
|
||||
|
||||
val result = service.markUndeliverable(APPLICATION_NO, UNDELIVERABLE_REASON, NOW)
|
||||
|
||||
assertEquals(GiftStatus.UNDELIVERABLE, result.status)
|
||||
assertEquals(GiftStatus.UNDELIVERABLE, gift.status)
|
||||
assertEquals(NOW, delivery.undeliverableAt)
|
||||
assertEquals(UNDELIVERABLE_REASON, delivery.undeliverableReason)
|
||||
Mockito.verify(giftPushService).sendUndeliverableToSender(APPLICATION_NO, SENDER_MEMBER_ID)
|
||||
Mockito.verify(giftPushService).sendUndeliverableToRecipient(APPLICATION_NO, RECIPIENT_MEMBER_ID)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("전달 불가 사유가 비어 있거나 종료 상태면 전달 불가 처리할 수 없다")
|
||||
fun shouldRejectUndeliverableWhenReasonBlankOrGiftTerminated() {
|
||||
saveGift(GiftStatus.DELIVERED)
|
||||
|
||||
val blankReasonException = assertThrows(SodaException::class.java) {
|
||||
service.markUndeliverable(APPLICATION_NO, " ", NOW)
|
||||
}
|
||||
val terminatedStatusException = assertThrows(SodaException::class.java) {
|
||||
service.markUndeliverable(APPLICATION_NO, UNDELIVERABLE_REASON, NOW)
|
||||
}
|
||||
|
||||
assertEquals("common.error.invalid_request", blankReasonException.messageKey)
|
||||
assertEquals("common.error.invalid_request", terminatedStatusException.messageKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("운영자는 검수 완료 선물을 전달 완료 처리한다")
|
||||
fun shouldCompleteDeliveryForInspectionCompletedGift() {
|
||||
val (gift, delivery) = saveGift(GiftStatus.INSPECTION_COMPLETED)
|
||||
|
||||
val result = service.completeDelivery(APPLICATION_NO, NOW)
|
||||
|
||||
assertEquals(GiftStatus.DELIVERED, result.status)
|
||||
assertEquals(GiftStatus.DELIVERED, gift.status)
|
||||
assertEquals(NOW, delivery.deliveredAt)
|
||||
Mockito.verify(giftPushService).sendDeliveredToSender(APPLICATION_NO, SENDER_MEMBER_ID)
|
||||
Mockito.verify(giftPushService).sendDeliveredToRecipient(APPLICATION_NO, RECIPIENT_MEMBER_ID)
|
||||
Mockito.verifyNoMoreInteractions(giftPushService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("검수 완료 상태가 아니면 운영 전달 완료 처리할 수 없다")
|
||||
fun shouldRejectDeliveryCompletionWhenGiftIsNotInspectionCompleted() {
|
||||
saveGift(GiftStatus.ARRIVED_AT_MAILBOX)
|
||||
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
service.completeDelivery(APPLICATION_NO, NOW)
|
||||
}
|
||||
|
||||
assertEquals("common.error.invalid_request", exception.messageKey)
|
||||
}
|
||||
|
||||
private fun saveGift(status: GiftStatus): Pair<Gift, GiftDelivery> {
|
||||
val gift = giftRepository.save(
|
||||
Gift(
|
||||
applicationNo = APPLICATION_NO,
|
||||
senderMemberId = SENDER_MEMBER_ID,
|
||||
recipientMemberId = RECIPIENT_MEMBER_ID,
|
||||
status = status,
|
||||
sizeCode = GiftSize.SMALL,
|
||||
categoryId = 1L,
|
||||
categoryNameSnapshot = "인형",
|
||||
salePriceCan = 80,
|
||||
canUsageId = 10L,
|
||||
senderTermsAgreed = true,
|
||||
senderPrivacyAgreed = true,
|
||||
senderTermsAgreedAt = NOW.minusDays(1),
|
||||
senderPrivacyAgreedAt = NOW.minusDays(1),
|
||||
damageWaiverAgreed = true
|
||||
)
|
||||
)
|
||||
val delivery = giftDeliveryRepository.save(
|
||||
GiftDelivery(
|
||||
giftId = requireNotNull(gift.id),
|
||||
senderName = "홍길동",
|
||||
senderPhoneNumber = "01012345678",
|
||||
senderZipCode = "06234",
|
||||
senderAddress = "서울시 강남구",
|
||||
trackingDeadlineAt = NOW.minusDays(1),
|
||||
trackingRegisteredAt = NOW.minusHours(1)
|
||||
)
|
||||
)
|
||||
|
||||
return gift to delivery
|
||||
}
|
||||
|
||||
private fun categoryCommand(
|
||||
classificationNumber: String = "100",
|
||||
categoryCode: String = "DOLL",
|
||||
name: String = "인형",
|
||||
receiptCode: String = "A",
|
||||
representativeItem: String = "피규어"
|
||||
) = GiftCategoryCommand(
|
||||
classificationNumber = classificationNumber,
|
||||
categoryCode = categoryCode,
|
||||
name = name,
|
||||
receiptCode = receiptCode,
|
||||
representativeItem = representativeItem,
|
||||
requiresDamageWaiver = true,
|
||||
isActive = true
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val APPLICATION_NO = "G20260929000001"
|
||||
const val SENDER_MEMBER_ID = 1L
|
||||
const val RECIPIENT_MEMBER_ID = 2L
|
||||
const val UNDELIVERABLE_REASON = "선물 접수 규격에 맞지 않습니다."
|
||||
val NOW: LocalDateTime = LocalDateTime.of(2026, 10, 3, 12, 0)
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.application
|
||||
|
||||
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftApplicationNoSequence
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftApplicationNoSequenceRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||
import org.springframework.context.annotation.Import
|
||||
import org.springframework.transaction.PlatformTransactionManager
|
||||
import java.time.LocalDateTime
|
||||
import java.util.concurrent.Callable
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
@DataJpaTest(
|
||||
properties = [
|
||||
"spring.cache.type=none",
|
||||
"spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE"
|
||||
]
|
||||
)
|
||||
@Import(QueryDslConfig::class)
|
||||
@DisplayName("선물 신청번호 채번 테스트")
|
||||
class GiftApplicationNoGeneratorTest @Autowired constructor(
|
||||
private val repository: GiftApplicationNoSequenceRepository,
|
||||
private val categoryRepository: GiftCategoryRepository,
|
||||
private val transactionManager: PlatformTransactionManager
|
||||
) {
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
repository.deleteAll()
|
||||
categoryRepository.deleteAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("같은 카테고리와 날짜 신청번호를 순서대로 발급한다")
|
||||
fun shouldGenerateSequentialApplicationNoByCategoryAndDate() {
|
||||
val generator = GiftApplicationNoGenerator(repository, transactionManager)
|
||||
val now = LocalDateTime.of(2026, 9, 29, 12, 0)
|
||||
val category = saveCategory("100", "DOLL", "A")
|
||||
|
||||
val first = generator.generate(category, now)
|
||||
val second = generator.generate(category, now)
|
||||
|
||||
assertEquals("A-1002609290001", first)
|
||||
assertEquals("A-1002609290002", second)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("카테고리와 날짜가 다르면 sequence를 따로 발급한다")
|
||||
fun shouldGenerateApplicationNoByCategoryAndDateIndependently() {
|
||||
val generator = GiftApplicationNoGenerator(repository, transactionManager)
|
||||
val doll = saveCategory("100", "DOLL", "A")
|
||||
val food = saveCategory("200", "FOOD", "C")
|
||||
|
||||
val dollFirst = generator.generate(doll, LocalDateTime.of(2026, 9, 29, 12, 0))
|
||||
val foodFirst = generator.generate(food, LocalDateTime.of(2026, 9, 29, 12, 0))
|
||||
val dollNextDay = generator.generate(doll, LocalDateTime.of(2026, 9, 30, 12, 0))
|
||||
|
||||
assertEquals("A-1002609290001", dollFirst)
|
||||
assertEquals("C-2002609290001", foodFirst)
|
||||
assertEquals("A-1002609300001", dollNextDay)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("sequence가 9999를 초과하면 자리수를 늘린다")
|
||||
fun shouldExpandSequenceDigitsAfter9999() {
|
||||
val generator = GiftApplicationNoGenerator(repository, transactionManager)
|
||||
val category = saveCategory("100", "DOLL", "A")
|
||||
val now = LocalDateTime.of(2026, 9, 30, 12, 0)
|
||||
repository.save(GiftApplicationNoSequence(categoryId = category.id!!, sequenceDate = "260930", lastSequence = 9999))
|
||||
|
||||
val applicationNo = generator.generate(category, now)
|
||||
|
||||
assertEquals("A-10026093010000", applicationNo)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("병렬 발급 결과는 모두 중복되지 않는다")
|
||||
fun shouldGenerateUniqueApplicationNoConcurrently() {
|
||||
val generator = GiftApplicationNoGenerator(repository, transactionManager)
|
||||
val now = LocalDateTime.of(2026, 9, 29, 12, 0)
|
||||
val category = saveCategory("100", "DOLL", "A")
|
||||
val executor = Executors.newFixedThreadPool(4)
|
||||
|
||||
val applicationNos = try {
|
||||
executor.invokeAll(
|
||||
(1..10).map {
|
||||
Callable { generator.generate(category, now) }
|
||||
}
|
||||
).map { it.get() }
|
||||
} finally {
|
||||
executor.shutdownNow()
|
||||
}
|
||||
|
||||
assertEquals(10, applicationNos.toSet().size)
|
||||
assertEquals("A-1002609290001", applicationNos.minOrNull())
|
||||
assertEquals("A-1002609290010", applicationNos.maxOrNull())
|
||||
}
|
||||
|
||||
private fun saveCategory(classificationNumber: String, categoryCode: String, receiptCode: String) =
|
||||
categoryRepository.save(
|
||||
GiftCategory(
|
||||
classificationNumber = classificationNumber,
|
||||
categoryCode = categoryCode,
|
||||
name = "인형",
|
||||
receiptCode = receiptCode,
|
||||
representativeItem = "피규어"
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.application
|
||||
|
||||
import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue
|
||||
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
||||
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
||||
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.ArgumentCaptor
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
|
||||
class GiftPushServiceTest {
|
||||
private val eventPublisher = Mockito.mock(ApplicationEventPublisher::class.java)
|
||||
private val service = GiftPushService(eventPublisher)
|
||||
|
||||
@Test
|
||||
fun shouldPublishTrackingDeadlineReminderToSender() {
|
||||
service.sendTrackingDeadlineReminder(APPLICATION_NO, MEMBER_ID)
|
||||
|
||||
val event = capturePublishedEvent()
|
||||
assertGiftEvent(event)
|
||||
assertEquals("운송장 번호를 입력해 주세요", event.title)
|
||||
assertEquals("24시간 이내에 운송장 번호를 등록해 주세요.\n기한 내 등록하지 않으면 신청이 자동 취소됩니다.", event.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPublishRecipientAddressDeadlineReminderToRecipient() {
|
||||
service.sendRecipientAddressDeadlineReminder(APPLICATION_NO, MEMBER_ID)
|
||||
|
||||
val event = capturePublishedEvent()
|
||||
assertGiftEvent(event)
|
||||
assertEquals("배송지 입력 기한이 1일 남았어요", event.title)
|
||||
assertEquals("24시간 안에 배송지를 입력해 주세요.\n입력하지 않으면 선물은 반송되지 않고 폐기돼요", event.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPublishAutoCanceledToSender() {
|
||||
service.sendAutoCanceledToSender(APPLICATION_NO, MEMBER_ID)
|
||||
|
||||
val event = capturePublishedEvent()
|
||||
assertGiftEvent(event)
|
||||
assertEquals("선물 신청이 자동 취소됐어요", event.title)
|
||||
assertEquals("신청 후 3일 이내에 운송장 번호가 등록되지 않아 신청이 취소됐어요.\n사용한 캔은 모두 환불되었습니다.", event.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPublishTrackingRegisteredToRecipient() {
|
||||
service.sendTrackingRegisteredToRecipient(APPLICATION_NO, MEMBER_ID)
|
||||
|
||||
val event = capturePublishedEvent()
|
||||
assertGiftEvent(event)
|
||||
assertEquals("팬이 보낸 선물이 있어요!", event.title)
|
||||
assertEquals("7일 이내에 배송지를 입력해 주세요.\n기간이 지나면 선물을 받을 수 없어요", event.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPublishArrivedAtMailboxToSender() {
|
||||
service.sendArrivedAtMailboxToSender(APPLICATION_NO, MEMBER_ID)
|
||||
|
||||
val event = capturePublishedEvent()
|
||||
assertGiftEvent(event)
|
||||
assertEquals("선물이 사서함에 도착했어요", event.title)
|
||||
assertEquals("보내주신 선물이 소다라이브 사서함에 도착했어요.\n안전하게 전달할 수 있도록 선물을 확인하고 있어요.", event.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPublishArrivedAtMailboxToRecipient() {
|
||||
service.sendArrivedAtMailboxToRecipient(APPLICATION_NO, MEMBER_ID)
|
||||
|
||||
val event = capturePublishedEvent()
|
||||
assertGiftEvent(event)
|
||||
assertEquals("선물이 사서함에 도착했어요", event.title)
|
||||
assertEquals("팬이 보낸 선물을 검수하고 있어요.\n검수가 끝나면 다시 알려드릴게요", event.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPublishInspectionCompletedToRecipient() {
|
||||
service.sendInspectionCompleted(APPLICATION_NO, MEMBER_ID)
|
||||
|
||||
val event = capturePublishedEvent()
|
||||
assertGiftEvent(event)
|
||||
assertEquals("선물 검수가 완료됐어요", event.title)
|
||||
assertEquals("확인된 선물을 빠른 시일 내에 입력하신 배송지로 보내드릴게요.", event.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPublishUndeliverableToSender() {
|
||||
service.sendUndeliverableToSender(APPLICATION_NO, MEMBER_ID)
|
||||
|
||||
val event = capturePublishedEvent()
|
||||
assertGiftEvent(event)
|
||||
assertEquals("선물을 전달할 수 없어요", event.title)
|
||||
assertEquals("도착한 선물이 선물 접수 규격에 맞지 않아 크리에이터에게 전달할 수 없어요.\n자세한 사유는 신청 상세에서 확인해 주세요.", event.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPublishUndeliverableToRecipient() {
|
||||
service.sendUndeliverableToRecipient(APPLICATION_NO, MEMBER_ID)
|
||||
|
||||
val event = capturePublishedEvent()
|
||||
assertGiftEvent(event)
|
||||
assertEquals("전달할 수 없는 선물이 있어요", event.title)
|
||||
assertEquals("검수 결과 선물 정책에 맞지 않아 해당 선물은 배송되지 않아요.\n자세한 사유를 확인해 주세요.", event.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPublishDeliveredToSender() {
|
||||
service.sendDeliveredToSender(APPLICATION_NO, MEMBER_ID)
|
||||
|
||||
val event = capturePublishedEvent()
|
||||
assertGiftEvent(event)
|
||||
assertEquals("선물이 전달됐어요", event.title)
|
||||
assertEquals("보내주신 선물이 크리에이터에게 안전하게 전달됐어요.", event.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldPublishDeliveredToRecipient() {
|
||||
service.sendDeliveredToRecipient(APPLICATION_NO, MEMBER_ID)
|
||||
|
||||
val event = capturePublishedEvent()
|
||||
assertGiftEvent(event)
|
||||
assertEquals("선물이 도착했어요!", event.title)
|
||||
assertEquals("팬이 보낸 선물이 배송지에 도착했어요.\n지금 확인해 보세요.", event.message)
|
||||
}
|
||||
|
||||
private fun assertGiftEvent(event: FcmEvent) {
|
||||
assertEquals(FcmEventType.INDIVIDUAL, event.type)
|
||||
assertEquals(PushNotificationCategory.GIFT, event.category)
|
||||
assertEquals(FcmDeepLinkValue.GIFT_DETAIL, event.deepLinkValue)
|
||||
assertEquals(APPLICATION_NO, event.deepLinkId)
|
||||
assertEquals(listOf(MEMBER_ID), event.recipients)
|
||||
}
|
||||
|
||||
private fun capturePublishedEvent(): FcmEvent {
|
||||
val captor = ArgumentCaptor.forClass(FcmEvent::class.java)
|
||||
Mockito.verify(eventPublisher).publishEvent(captor.capture())
|
||||
return captor.value
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val APPLICATION_NO = "G20260929000001"
|
||||
const val MEMBER_ID = 1L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.application
|
||||
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePrice
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftDirection
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftListType
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||
import org.springframework.context.annotation.Import
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@DataJpaTest(
|
||||
properties = [
|
||||
"spring.cache.type=none",
|
||||
"spring.datasource.url=jdbc:h2:mem:gift-query;MODE=MySQL;NON_KEYWORDS=VALUE"
|
||||
]
|
||||
)
|
||||
@Import(QueryDslConfig::class)
|
||||
class GiftQueryServiceTest @Autowired constructor(
|
||||
private val categoryRepository: GiftCategoryRepository,
|
||||
private val sizePriceRepository: GiftSizePriceRepository,
|
||||
private val giftRepository: GiftRepository,
|
||||
private val deliveryRepository: GiftDeliveryRepository,
|
||||
private val memberRepository: MemberRepository
|
||||
) {
|
||||
@Test
|
||||
fun `폼 옵션은 활성 카테고리와 활성 사이즈 가격만 반환한다`() {
|
||||
val activeCategory = categoryRepository.save(giftCategory("100", "DOLL", "A", "인형", "피규어", true, true))
|
||||
categoryRepository.save(giftCategory("200", "FOOD", "B", "간식", "쿠키", false, false))
|
||||
sizePriceRepository.save(GiftSizePrice(GiftSize.SMALL, 100, 80, true))
|
||||
sizePriceRepository.save(GiftSizePrice(GiftSize.MEDIUM, 150, 120, false))
|
||||
|
||||
val result = queryService().getFormOptions()
|
||||
|
||||
assertEquals(listOf(GiftSize.SMALL), result.sizes.map { it.sizeCode })
|
||||
assertEquals(listOf(100), result.sizes.map { it.basePriceCan })
|
||||
assertEquals(listOf(80), result.sizes.map { it.salePriceCan })
|
||||
assertEquals(listOf(activeCategory.id), result.categories.map { it.categoryId })
|
||||
assertEquals("인형/피규어", result.categories.single().name)
|
||||
assertEquals(true, result.categories.single().requiresDamageWaiver)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("보낸 선물은 접수 직후 선물함에 노출된다")
|
||||
fun shouldListReceivedGiftForSender() {
|
||||
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 = sender.id!!,
|
||||
type = GiftListType.SENT,
|
||||
page = 0,
|
||||
size = 20
|
||||
)
|
||||
|
||||
assertEquals(1L, result.totalCount)
|
||||
assertEquals(1, result.items.size)
|
||||
assertEquals("G-SENT-RECEIVED", result.items.single().applicationNo)
|
||||
assertEquals(GiftDirection.SENT, result.items.single().direction)
|
||||
assertEquals(GiftStatus.RECEIVED, result.items.single().status)
|
||||
assertEquals(GiftStatus.RECEIVED.displayName, result.items.single().statusName)
|
||||
assertEquals(80, result.items.single().priceCan)
|
||||
assertEquals("인형", result.items.single().categoryName)
|
||||
assertEquals("소형", result.items.single().sizeName)
|
||||
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() {
|
||||
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 = visibleSender.id!!,
|
||||
recipientMemberId = recipient.id!!,
|
||||
status = GiftStatus.TRACKING_REGISTERED
|
||||
)
|
||||
|
||||
val result = queryService().getGifts(
|
||||
memberId = recipient.id!!,
|
||||
type = GiftListType.RECEIVED,
|
||||
page = 0,
|
||||
size = 20
|
||||
)
|
||||
|
||||
assertEquals(1L, result.totalCount)
|
||||
assertEquals(listOf("G-RECEIVED-VISIBLE"), result.items.map { it.applicationNo })
|
||||
assertEquals(GiftDirection.RECEIVED, result.items.single().direction)
|
||||
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() {
|
||||
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 = fan.id!!,
|
||||
recipientMemberId = member.id!!,
|
||||
status = GiftStatus.ARRIVED_AT_MAILBOX
|
||||
)
|
||||
saveGift("G-ALL-HIDDEN", senderMemberId = hiddenFan.id!!, recipientMemberId = member.id!!, status = GiftStatus.RECEIVED)
|
||||
|
||||
val result = queryService().getGifts(
|
||||
memberId = member.id!!,
|
||||
type = GiftListType.ALL,
|
||||
page = 0,
|
||||
size = 20
|
||||
)
|
||||
|
||||
assertEquals(2L, result.totalCount)
|
||||
assertEquals(
|
||||
mapOf("G-ALL-SENT" to GiftDirection.SENT, "G-ALL-RECEIVED" to GiftDirection.RECEIVED),
|
||||
result.items.associate { it.applicationNo to it.direction }
|
||||
)
|
||||
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() {
|
||||
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 = sender.id!!,
|
||||
type = GiftListType.SENT,
|
||||
page = -1,
|
||||
size = 0
|
||||
)
|
||||
|
||||
assertEquals(0, result.page)
|
||||
assertEquals(20, result.size)
|
||||
assertEquals(false, result.hasNext)
|
||||
assertEquals(listOf("G-PAGING"), result.items.map { it.applicationNo })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("발신자는 자신의 선물 상세와 발신자 정보만 조회한다")
|
||||
fun shouldReturnSentGiftDetailWithoutRecipientAddress() {
|
||||
val sender = saveMember("fan")
|
||||
val recipient = saveMember("creator")
|
||||
val gift = saveGift(
|
||||
applicationNo = "G-SENT-DETAIL",
|
||||
senderMemberId = sender.id!!,
|
||||
recipientMemberId = recipient.id!!,
|
||||
status = GiftStatus.RECEIVED
|
||||
)
|
||||
val delivery = saveDelivery(gift)
|
||||
delivery.recipientName = "수신자"
|
||||
delivery.recipientPhoneNumber = "01098765432"
|
||||
delivery.recipientZipCode = "04524"
|
||||
delivery.recipientAddress = "서울시 중구"
|
||||
delivery.recipientAddressDetail = "202호"
|
||||
|
||||
val result = queryService().getGiftDetail(sender.id!!, gift.applicationNo)
|
||||
|
||||
assertEquals(GiftDirection.SENT, result.direction)
|
||||
assertEquals(recipient.nickname, result.giftInfo.recipientCreatorNickname)
|
||||
assertNull(result.giftInfo.senderNickname)
|
||||
assertEquals("소형", result.giftInfo.sizeName)
|
||||
assertEquals("인형", result.giftInfo.categoryName)
|
||||
assertEquals(gift.applicationNo, result.giftInfo.applicationNo)
|
||||
assertEquals(80, result.giftInfo.paidCan)
|
||||
assertNull(result.giftInfo.tracking)
|
||||
assertNull(result.giftInfo.shippingRequestedAt)
|
||||
assertEquals("발신자", result.senderInfo?.name)
|
||||
assertEquals("01012345678", result.senderInfo?.phoneNumber)
|
||||
assertEquals("(06234) 서울시 강남구, 101동 1001호", result.senderInfo?.address)
|
||||
assertNull(result.recipientAddress)
|
||||
assertNull(result.mailbox)
|
||||
assertEquals(true, result.trackingRequired)
|
||||
assertEquals(false, result.recipientAddressRequired)
|
||||
assertEquals(
|
||||
listOf(
|
||||
GiftStatus.RECEIVED,
|
||||
GiftStatus.TRACKING_REGISTERED,
|
||||
GiftStatus.ARRIVED_AT_MAILBOX,
|
||||
GiftStatus.INSPECTION_COMPLETED,
|
||||
GiftStatus.DELIVERED
|
||||
),
|
||||
result.statusTimeline.map { it.status }
|
||||
)
|
||||
assertNotNull(result.statusTimeline[0].occurredAt)
|
||||
assertNull(result.statusTimeline[1].occurredAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("수신자는 노출된 선물 상세에서 자신의 배송지와 발신자 닉네임만 조회한다")
|
||||
fun shouldReturnReceivedGiftDetailWithoutSenderInformation() {
|
||||
val sender = saveMember("fan")
|
||||
val recipient = saveMember("creator")
|
||||
val gift = saveGift(
|
||||
applicationNo = "G-RECEIVED-DETAIL",
|
||||
senderMemberId = sender.id!!,
|
||||
recipientMemberId = recipient.id!!,
|
||||
status = GiftStatus.TRACKING_REGISTERED
|
||||
)
|
||||
saveDelivery(gift).apply {
|
||||
trackingRegisteredAt = NOW.plusHours(1)
|
||||
recipientName = "수신자"
|
||||
recipientPhoneNumber = "01098765432"
|
||||
recipientZipCode = "04524"
|
||||
recipientAddress = "서울시 중구"
|
||||
recipientAddressDetail = "202호"
|
||||
}
|
||||
|
||||
val result = queryService().getGiftDetail(recipient.id!!, gift.applicationNo)
|
||||
|
||||
assertEquals(GiftDirection.RECEIVED, result.direction)
|
||||
assertNull(result.giftInfo.recipientCreatorNickname)
|
||||
assertEquals(sender.nickname, result.giftInfo.senderNickname)
|
||||
assertEquals("소형", result.giftInfo.sizeName)
|
||||
assertEquals("인형", result.giftInfo.categoryName)
|
||||
assertNull(result.giftInfo.applicationNo)
|
||||
assertNull(result.giftInfo.paidCan)
|
||||
assertNull(result.giftInfo.tracking)
|
||||
assertNotNull(result.giftInfo.shippingRequestedAt)
|
||||
assertNull(result.senderInfo)
|
||||
assertEquals("수신자", result.recipientAddress?.name)
|
||||
assertEquals("01098765432", result.recipientAddress?.phoneNumber)
|
||||
assertEquals("(04524) 서울시 중구, 202호", result.recipientAddress?.address)
|
||||
assertNull(result.mailbox)
|
||||
assertEquals(false, result.trackingRequired)
|
||||
assertEquals(false, result.recipientAddressRequired)
|
||||
assertNotNull(result.statusTimeline[0].occurredAt)
|
||||
assertEquals(NOW.plusHours(1), result.statusTimeline[1].occurredAt)
|
||||
assertNull(result.statusTimeline[2].occurredAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("상세 조회는 수신자 노출 조건과 관계없는 회원 및 배송 정보 누락을 거부한다")
|
||||
fun shouldRejectHiddenOrUnrelatedGiftDetailAndMissingDelivery() {
|
||||
val sender = saveMember("fan")
|
||||
val recipient = saveMember("creator")
|
||||
val other = saveMember("other")
|
||||
val hiddenGift = saveGift(
|
||||
applicationNo = "G-HIDDEN-DETAIL",
|
||||
senderMemberId = sender.id!!,
|
||||
recipientMemberId = recipient.id!!,
|
||||
status = GiftStatus.RECEIVED
|
||||
)
|
||||
saveDelivery(hiddenGift)
|
||||
val visibleGift = saveGift(
|
||||
applicationNo = "G-VISIBLE-DETAIL",
|
||||
senderMemberId = sender.id!!,
|
||||
recipientMemberId = recipient.id!!,
|
||||
status = GiftStatus.TRACKING_REGISTERED
|
||||
)
|
||||
saveDelivery(visibleGift)
|
||||
val missingDeliveryGift = saveGift(
|
||||
applicationNo = "G-MISSING-DELIVERY",
|
||||
senderMemberId = sender.id!!,
|
||||
recipientMemberId = recipient.id!!,
|
||||
status = GiftStatus.RECEIVED
|
||||
)
|
||||
val service = queryService()
|
||||
|
||||
val hiddenException = assertThrows(SodaException::class.java) {
|
||||
service.getGiftDetail(recipient.id!!, hiddenGift.applicationNo)
|
||||
}
|
||||
val unrelatedException = assertThrows(SodaException::class.java) {
|
||||
service.getGiftDetail(other.id!!, visibleGift.applicationNo)
|
||||
}
|
||||
val missingDeliveryException = assertThrows(SodaException::class.java) {
|
||||
service.getGiftDetail(sender.id!!, missingDeliveryGift.applicationNo)
|
||||
}
|
||||
|
||||
assertEquals("common.error.invalid_request", hiddenException.messageKey)
|
||||
assertEquals("common.error.invalid_request", unrelatedException.messageKey)
|
||||
assertEquals("common.error.invalid_request", missingDeliveryException.messageKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("수신자 배송지 입력 필요 여부는 미입력인 진행 상태에서만 참이다")
|
||||
fun shouldRequireRecipientAddressOnlyForNonTerminalReceivedGift() {
|
||||
val sender = saveMember("fan")
|
||||
val recipient = saveMember("creator")
|
||||
val pendingGift = saveGift(
|
||||
applicationNo = "G-ADDRESS-PENDING",
|
||||
senderMemberId = sender.id!!,
|
||||
recipientMemberId = recipient.id!!,
|
||||
status = GiftStatus.TRACKING_REGISTERED
|
||||
)
|
||||
saveDelivery(pendingGift).trackingRegisteredAt = NOW
|
||||
val terminalGift = saveGift(
|
||||
applicationNo = "G-ADDRESS-TERMINAL",
|
||||
senderMemberId = sender.id!!,
|
||||
recipientMemberId = recipient.id!!,
|
||||
status = GiftStatus.UNDELIVERABLE
|
||||
)
|
||||
saveDelivery(terminalGift).apply {
|
||||
trackingRegisteredAt = NOW
|
||||
undeliverableAt = NOW.plusHours(1)
|
||||
undeliverableReason = "배송지 미입력 기한 초과"
|
||||
}
|
||||
val service = queryService()
|
||||
|
||||
val pendingResult = service.getGiftDetail(recipient.id!!, pendingGift.applicationNo)
|
||||
val terminalResult = service.getGiftDetail(recipient.id!!, terminalGift.applicationNo)
|
||||
|
||||
assertEquals(true, pendingResult.recipientAddressRequired)
|
||||
assertNull(pendingResult.recipientAddress)
|
||||
assertEquals(false, terminalResult.recipientAddressRequired)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("취소와 전달 불가 상세는 정상 진행 타임라인과 종료 정보를 함께 반환한다")
|
||||
fun shouldReturnTerminalInformationWithNormalProgressTimeline() {
|
||||
val sender = saveMember("fan")
|
||||
val recipient = saveMember("creator")
|
||||
val canceledGift = saveGift(
|
||||
applicationNo = "G-CANCELED-DETAIL",
|
||||
senderMemberId = sender.id!!,
|
||||
recipientMemberId = recipient.id!!,
|
||||
status = GiftStatus.CANCELED
|
||||
)
|
||||
saveDelivery(canceledGift).canceledAt = NOW.plusHours(1)
|
||||
val undeliverableGift = saveGift(
|
||||
applicationNo = "G-UNDELIVERABLE-X",
|
||||
senderMemberId = sender.id!!,
|
||||
recipientMemberId = recipient.id!!,
|
||||
status = GiftStatus.UNDELIVERABLE
|
||||
)
|
||||
saveDelivery(undeliverableGift).apply {
|
||||
trackingRegisteredAt = NOW.plusHours(1)
|
||||
arrivedAtMailboxAt = NOW.plusHours(2)
|
||||
undeliverableAt = NOW.plusHours(3)
|
||||
undeliverableReason = "검수 실패"
|
||||
}
|
||||
val service = queryService()
|
||||
|
||||
val canceledResult = service.getGiftDetail(sender.id!!, canceledGift.applicationNo)
|
||||
val undeliverableResult = service.getGiftDetail(sender.id!!, undeliverableGift.applicationNo)
|
||||
|
||||
assertEquals(NOW.plusHours(1), canceledResult.delivery.canceledAt)
|
||||
assertNull(canceledResult.statusTimeline[1].occurredAt)
|
||||
assertEquals(NOW.plusHours(1), undeliverableResult.statusTimeline[1].occurredAt)
|
||||
assertEquals(NOW.plusHours(2), undeliverableResult.statusTimeline[2].occurredAt)
|
||||
assertNull(undeliverableResult.statusTimeline[3].occurredAt)
|
||||
assertEquals(NOW.plusHours(3), undeliverableResult.delivery.undeliverableAt)
|
||||
assertEquals("검수 실패", undeliverableResult.delivery.undeliverableReason)
|
||||
}
|
||||
|
||||
private fun saveGift(
|
||||
applicationNo: String,
|
||||
senderMemberId: Long,
|
||||
recipientMemberId: Long,
|
||||
status: GiftStatus
|
||||
) = giftRepository.save(
|
||||
Gift(
|
||||
applicationNo = applicationNo,
|
||||
senderMemberId = senderMemberId,
|
||||
recipientMemberId = recipientMemberId,
|
||||
status = status,
|
||||
sizeCode = GiftSize.SMALL,
|
||||
categoryId = 1L,
|
||||
categoryNameSnapshot = "인형",
|
||||
salePriceCan = 80,
|
||||
canUsageId = 1L,
|
||||
senderTermsAgreed = true,
|
||||
senderPrivacyAgreed = true,
|
||||
senderTermsAgreedAt = NOW,
|
||||
senderPrivacyAgreedAt = NOW
|
||||
)
|
||||
)
|
||||
|
||||
private fun saveMember(nickname: String, profileImage: String? = null) = memberRepository.save(
|
||||
Member(password = "password", nickname = nickname, profileImage = profileImage)
|
||||
)
|
||||
|
||||
private fun giftCategory(
|
||||
classificationNumber: String,
|
||||
categoryCode: String,
|
||||
receiptCode: String,
|
||||
name: String,
|
||||
representativeItem: String,
|
||||
requiresDamageWaiver: Boolean,
|
||||
isActive: Boolean
|
||||
) = GiftCategory(
|
||||
classificationNumber = classificationNumber,
|
||||
categoryCode = categoryCode,
|
||||
name = name,
|
||||
receiptCode = receiptCode,
|
||||
representativeItem = representativeItem,
|
||||
requiresDamageWaiver = requiresDamageWaiver,
|
||||
isActive = isActive
|
||||
)
|
||||
|
||||
private fun saveDelivery(gift: Gift) = deliveryRepository.save(
|
||||
GiftDelivery(
|
||||
giftId = gift.id!!,
|
||||
senderName = "발신자",
|
||||
senderPhoneNumber = "01012345678",
|
||||
senderZipCode = "06234",
|
||||
senderAddress = "서울시 강남구",
|
||||
senderAddressDetail = "101동 1001호",
|
||||
trackingDeadlineAt = NOW.plusDays(3)
|
||||
)
|
||||
)
|
||||
|
||||
private fun queryService() = GiftQueryService(
|
||||
categoryRepository,
|
||||
sizePriceRepository,
|
||||
giftRepository,
|
||||
deliveryRepository,
|
||||
memberRepository,
|
||||
"https://cdn.test"
|
||||
)
|
||||
|
||||
private companion object {
|
||||
val NOW: LocalDateTime = LocalDateTime.of(2026, 9, 29, 12, 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.domain
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class GiftStatusTest {
|
||||
@Test
|
||||
@DisplayName("선물 상태 표시명은 한글 띄어쓰기를 통일한다")
|
||||
fun shouldUseConsistentKoreanSpacingForDisplayNames() {
|
||||
assertEquals("접수 완료", GiftStatus.RECEIVED.displayName)
|
||||
assertEquals("발송 확인", GiftStatus.TRACKING_REGISTERED.displayName)
|
||||
assertEquals("사서함 도착", GiftStatus.ARRIVED_AT_MAILBOX.displayName)
|
||||
assertEquals("검수 완료", GiftStatus.INSPECTION_COMPLETED.displayName)
|
||||
assertEquals("전달 완료", GiftStatus.DELIVERED.displayName)
|
||||
assertEquals("전달 불가", GiftStatus.UNDELIVERABLE.displayName)
|
||||
assertEquals("신청 취소", GiftStatus.CANCELED.displayName)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user