Compare commits

...
36 Commits
Author SHA1 Message Date
klaus c073c48538 feat(gift): 선물함 상대 회원 정보를 추가한다 2026-10-01 20:49:28 +09:00
klaus 5494a49748 docs(gift): 관리자 선물 화면 구현 프롬프트를 기록한다 2026-10-01 19:02:15 +09:00
klaus 72e5612b1c docs(gift): 모바일 선물함 구현 프롬프트를 기록한다 2026-10-01 19:02:08 +09:00
klaus a95c91d31f feat(menu): 관리자 선물함 메뉴를 추가한다 2026-09-30 19:08:15 +09:00
klaus 15ce9aab41 feat(gift): 관리자 선물함 HTTP 조회를 추가한다 2026-09-30 18:41:57 +09:00
klaus b5a5d1318b feat(gift): 관리자 선물함 조회 서비스를 추가한다 2026-09-30 18:41:43 +09:00
klaus 059854c19f docs(gift): 관리자 선물함 클라이언트 계약을 기록한다 2026-09-30 18:41:25 +09:00
klaus 375be9e9a9 docs(gift): 관리자 선물함 조회 계획을 기록한다 2026-09-30 18:41:13 +09:00
klaus afe92829dc docs(gift): 관리자 선물함 조회 요구사항을 기록한다 2026-09-30 18:41:01 +09:00
klaus 22c6cd2d38 feat(gift): 선물 응답 시각을 UTC 문자열로 변환한다 2026-09-30 17:38:51 +09:00
klaus 2bda30e7e2 feat(gift): 카테고리별 신청번호를 발급한다 2026-09-30 17:38:24 +09:00
klaus f3f46c413c feat(gift): 신청번호 채번 저장 기준을 분리한다 2026-09-30 17:37:56 +09:00
klaus a6ba46e017 feat(gift): 선물 폼 카테고리 표시명을 조합한다 2026-09-30 17:37:30 +09:00
klaus 13a842a9a5 feat(gift): 선물 카테고리 운영 필드를 추가한다 2026-09-30 17:37:23 +09:00
klaus dda5de4d61 docs(gift): 선물 클라이언트 계약과 DDL을 갱신한다 2026-09-30 17:37:16 +09:00
klaus 38941d9fe9 docs(gift): 선물 요구사항 변경을 기록한다 2026-09-30 17:37:09 +09:00
klaus 631c985a24 docs(gift): 선물 클라이언트 API DTO를 보강한다 2026-09-30 14:40:40 +09:00
klaus cd949a4694 docs(gift): 선물 API 클라이언트 요약을 추가한다 2026-09-30 14:33:46 +09:00
klaus d360db6f08 feat(gift): 선물 관리자 API를 추가한다 2026-09-30 13:39:44 +09:00
klaus 6f9dcaa1e2 feat(gift): 선물 관리자 DTO를 추가한다 2026-09-30 13:39:24 +09:00
klaus 6a2ae38483 feat(gift): 선물 사용자 API를 추가한다 2026-09-30 13:39:04 +09:00
klaus 08347f9ab5 feat(gift): 선물 응답 DTO를 추가한다 2026-09-30 13:38:44 +09:00
klaus 35b16d7370 feat(gift): 선물 신청 요청 DTO를 추가한다 2026-09-30 13:38:24 +09:00
klaus 0f727c8b75 feat(gift): 선물 배송 스케줄러를 추가한다 2026-09-30 13:38:04 +09:00
klaus 6bf4a682bf feat(gift): 선물 관리자 서비스를 추가한다 2026-09-30 13:37:44 +09:00
klaus fcb1c1e8d1 feat(gift): 선물 조회 서비스를 추가한다 2026-09-30 13:37:26 +09:00
klaus 95b3b16897 feat(gift): 선물 신청 명령 서비스를 추가한다 2026-09-30 13:37:06 +09:00
klaus 71e7195414 feat(gift): 선물 푸시 서비스를 추가한다 2026-09-30 13:36:43 +09:00
klaus 4ac203be18 feat(gift): 선물 푸시 이벤트를 추가한다 2026-09-30 13:36:24 +09:00
klaus 57bb57ffb5 feat(gift): 선물 캔 결제 처리를 추가한다 2026-09-30 13:36:04 +09:00
klaus 760005c266 feat(gift): 신청번호 생성기를 추가한다 2026-09-30 13:35:47 +09:00
klaus 8ae722c8df feat(gift): 선물 배송과 리뷰 저장소를 추가한다 2026-09-30 13:35:31 +09:00
klaus 21d6e755e9 feat(gift): 선물 가격과 신청번호 저장소를 추가한다 2026-09-30 13:35:14 +09:00
klaus 3049184ea2 feat(gift): 선물 기본 영속 모델을 추가한다 2026-09-30 13:34:53 +09:00
klaus f3e6d40a4a feat(gift): 선물 도메인 상태값을 추가한다 2026-09-30 13:34:52 +09:00
klaus c02f5f7fa8 docs(gift): 크리에이터 선물하기 요구사항을 기록한다 2026-09-30 13:34:51 +09:00
57 changed files with 10133 additions and 9 deletions
@@ -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")
)
)
}
}
@@ -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
)
}
}
@@ -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
)
}
@@ -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
)
}
@@ -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()
@@ -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()
@@ -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?
}
@@ -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()
@@ -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?
}
@@ -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()
@@ -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>
}
@@ -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>
}
@@ -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()
@@ -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?
}
@@ -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()
@@ -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?
}
@@ -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 = "배송지 미입력 기한 초과"
}
}
@@ -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}")
}
}
@@ -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
)
}
@@ -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)
}
}
@@ -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 = "선물 전달 과정이 만족스러웠습니다."
)
}
}
@@ -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 = "피규어"
)
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
}
}