Compare commits
451
Commits
3fb822a096
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41314ef797 | ||
|
|
200dc6264a | ||
|
|
6f48aa156a | ||
|
|
c1000a6c68 | ||
|
|
b978f5bc8d | ||
|
|
3f660e81f9 | ||
|
|
c9b0bd13b5 | ||
|
|
92402419c5 | ||
|
|
c4fa2fa2f8 | ||
|
|
b4027fdee7 | ||
|
|
eb262b8d53 | ||
|
|
f5397df65e | ||
|
|
0c6aeb5c8a | ||
|
|
d8083e34ea | ||
|
|
8eeade49d1 | ||
|
|
b36764abff | ||
|
|
7844ea74d7 | ||
|
|
e04fa67d1d | ||
|
|
bd1ec54d36 | ||
|
|
4442fa6d91 | ||
|
|
795c25159f | ||
|
|
cdc7cb8612 | ||
|
|
efefef51f4 | ||
|
|
e2b9eb8bf3 | ||
|
|
1aa7a89f7b | ||
|
|
45aa26f1cb | ||
|
|
626f02b45e | ||
|
|
540c5cb317 | ||
|
|
3aa6a35adb | ||
|
|
81c0d2586c | ||
|
|
965bb068fc | ||
|
|
d082e0b745 | ||
|
|
c8b83272a3 | ||
|
|
1939fdcb33 | ||
|
|
f521a240c2 | ||
|
|
0c35624dfb | ||
|
|
5c24addf31 | ||
|
|
8671c8efc7 | ||
|
|
16c17f4bfa | ||
|
|
0e821fae1b | ||
|
|
6a10eff15f | ||
|
|
fea329e637 | ||
|
|
681e4a4036 | ||
|
|
c23f574162 | ||
|
|
c884d7d6c9 | ||
|
|
116e8cbca3 | ||
|
|
c8187ba147 | ||
|
|
676bd0b79e | ||
|
|
7522f06bf3 | ||
|
|
a9d2d1ab48 | ||
|
|
e0e371cdc9 | ||
|
|
b49344d0e9 | ||
|
|
5cc152307a | ||
|
|
1fd3d41d7e | ||
|
|
c1b9dd730d | ||
|
|
d40cd32c50 | ||
|
|
0289607fd9 | ||
|
|
1bec644372 | ||
|
|
f8a6d1b221 | ||
|
|
ec769a1307 | ||
|
|
8e4fb0d313 | ||
|
|
cc3a620642 | ||
|
|
be0884e974 | ||
|
|
34456395fd | ||
|
|
26ddeb9591 | ||
|
|
cd535a628c | ||
|
|
de32b537f4 | ||
|
|
9c271fc1f6 | ||
|
|
2ddbfbccd6 | ||
|
|
80786deb72 | ||
|
|
8ca2e185ac | ||
|
|
484711ad1b | ||
|
|
e80ceca0c5 | ||
|
|
33293a6533 | ||
|
|
f0c1d4e32a | ||
|
|
6cd319ec76 | ||
|
|
6557ec2aed | ||
|
|
f2f8a34319 | ||
|
|
c50ac6ed2c | ||
|
|
11b9c349d1 | ||
|
|
ef9f8d65e1 | ||
|
|
299f2100e9 | ||
|
|
fd5c794480 | ||
|
|
68197de095 | ||
|
|
587f3d6b58 | ||
|
|
9b6167d46d | ||
|
|
008ee3b4e5 | ||
|
|
3a57ad23bb | ||
|
|
729552335a | ||
|
|
02ae507c87 | ||
|
|
5818abf69d | ||
|
|
ee403915f0 | ||
|
|
1a660088de | ||
|
|
5196c80ca8 | ||
|
|
c9c09c2998 | ||
|
|
3ea33c4c7b | ||
|
|
451a1aa4f2 | ||
|
|
90555fd34f | ||
|
|
0dc430b098 | ||
|
|
1f2103c7fa | ||
|
|
062c17c51e | ||
|
|
de169b79a1 | ||
|
|
aa24de0a5a | ||
|
|
e5937d573a | ||
|
|
6da86e12bd | ||
|
|
9049022a74 | ||
|
|
7b6f3a7a5f | ||
|
|
53e9678efa | ||
|
|
e4f547fa92 | ||
|
|
b69756ef81 | ||
|
|
1a3a9149a2 | ||
|
|
ce120a6d5d | ||
|
|
08b5fd23ab | ||
|
|
eb18e2d009 | ||
|
|
a27852ed44 | ||
|
|
c7925c1706 | ||
|
|
be59bd7e89 | ||
|
|
51ce143fc2 | ||
|
|
89eb11f808 | ||
|
|
30d89987a4 | ||
|
|
7959d3e5ed | ||
|
|
1e29573ef7 | ||
|
|
cc2f533dc6 | ||
|
|
32b0c19f9d | ||
|
|
9af2d768e8 | ||
|
|
5677824cde | ||
|
|
e8f1bc09f9 | ||
|
|
d1a936d55b | ||
|
|
dc97eaa835 | ||
|
|
dcbe57806c | ||
|
|
b14438cc15 | ||
|
|
b27d3bd5c6 | ||
|
|
03ebc9cfe9 | ||
|
|
24841b9850 | ||
|
|
d35a3d1a8c | ||
|
|
60c4e0b528 | ||
|
|
84f33d1bc2 | ||
|
|
c4e1709b99 | ||
|
|
e7a5fd5819 | ||
|
|
4bde03643c | ||
|
|
1bc52b56af | ||
|
|
9c33fd93f7 | ||
|
|
3c087bc275 | ||
|
|
8ad13c289e | ||
|
|
7577f48a09 | ||
|
|
0251906964 | ||
|
|
2723a5f134 | ||
|
|
c3c60605fd | ||
|
|
238f704b22 | ||
|
|
5639d8ac8e | ||
|
|
9aac591591 | ||
|
|
ffa8e5aebb | ||
|
|
cbbfe014cc | ||
|
|
83028f7817 | ||
|
|
70d1795557 | ||
|
|
8c6c681424 | ||
|
|
50bc9f4ff3 | ||
|
|
f00ea03fad | ||
|
|
f22e7b9ad1 | ||
|
|
c7ec95f4bb | ||
|
|
229e7a8ccc | ||
|
|
3c616474ff | ||
|
|
56eb6b3ce3 | ||
|
|
545836d43c | ||
|
|
219f83dec0 | ||
|
|
a76a841238 | ||
|
|
c26680de84 | ||
|
|
8fffad9d3a | ||
|
|
f4f0f203a2 | ||
|
|
b7196f5a0c | ||
|
|
5d33a18890 | ||
|
|
96186a1a50 | ||
|
|
bc8bc479d1 | ||
|
|
47595b1291 | ||
|
|
01a88964df | ||
|
|
3a2b77379f | ||
|
|
dc4e5f75cd | ||
|
|
d0178d551c | ||
|
|
827333108d | ||
|
|
587b90bd27 | ||
|
|
4dc20c5e90 | ||
|
|
ac25782f2b | ||
|
|
20437d56e7 | ||
|
|
f0b412828a | ||
|
|
367faac5c3 | ||
|
|
84deaaa970 | ||
|
|
a2b39466c2 | ||
|
|
03586c4005 | ||
|
|
6ea69e1510 | ||
|
|
553c6dc539 | ||
|
|
6cc22f5b6d | ||
|
|
9103d67cc1 | ||
|
|
25083fb0e4 | ||
|
|
d2dc045255 | ||
|
|
b8621dfbb0 | ||
|
|
93633940dd | ||
|
|
b6f5325351 | ||
|
|
7c32c08f1f | ||
|
|
1d268da08d | ||
|
|
797666ae0d | ||
|
|
dcf470997e | ||
|
|
0974d1dbf8 | ||
|
|
12a35db6cd | ||
|
|
9abbb05ad8 | ||
|
|
1ecaf69b0b | ||
|
|
e334d1e5d9 | ||
|
|
b735e861d0 | ||
|
|
4eb433d372 | ||
|
|
2416ae61f3 | ||
|
|
01fb336985 | ||
|
|
b6af88a732 | ||
|
|
58a2a17d6d | ||
|
|
79f5a0f520 | ||
|
|
7f6c0f7f04 | ||
|
|
f658df4dca | ||
|
|
9d43b8e23a | ||
|
|
4270aef79b | ||
|
|
1c0dc82d44 | ||
|
|
c1e325aadf | ||
|
|
cec87da69d | ||
|
|
f68f24cb2c | ||
|
|
ed094347fc | ||
|
|
b8afdffbe1 | ||
|
|
f6ba79f31c | ||
|
|
5f3b1663d2 | ||
|
|
66e786b4bb | ||
|
|
f671114574 | ||
|
|
ce37060d94 | ||
|
|
7d19a4d184 | ||
|
|
22f28a2f8a | ||
|
|
ceef9ca979 | ||
|
|
efe8f4f939 | ||
|
|
ba692a1195 | ||
|
|
d732bad042 | ||
|
|
4c935c3bee | ||
|
|
c160dd791f | ||
|
|
23cd1b4601 | ||
|
|
031fc8ba1b | ||
|
|
c6853289ad | ||
|
|
2497bb69bc | ||
|
|
a58a67e0a2 | ||
|
|
4315fe12a5 | ||
|
|
42f10a8899 | ||
|
|
1e4b47f989 | ||
|
|
ff255dbfae | ||
|
|
dbe9b72feb | ||
|
|
95a714b391 | ||
|
|
28f58c7f56 | ||
|
|
8bd46d8f21 | ||
|
|
e1bb8e54ed | ||
|
|
1de705b063 | ||
|
|
f6926ad356 | ||
|
|
2cdbbb1b37 | ||
|
|
4dce8c8f03 | ||
|
|
97a5bace6f | ||
|
|
d4d51ec48f | ||
|
|
fb91398462 | ||
|
|
105dadd798 | ||
|
|
2abf2837d3 | ||
|
|
422aa67af6 | ||
|
|
7fffab6985 | ||
|
|
5a4be3d2c1 | ||
|
|
f39a7681db | ||
|
|
c60a7580ba | ||
|
|
97edb56edc | ||
|
|
6ebca8d22b | ||
|
|
95371ad934 | ||
|
|
2c176825fd | ||
|
|
fae7de48d3 | ||
|
|
b8230646a2 | ||
|
|
43279541dd | ||
|
|
b4791977c1 | ||
|
|
ef917ecc25 | ||
|
|
a93faad951 | ||
|
|
fd001d24d3 | ||
|
|
7aa5884797 | ||
|
|
5b237a1547 | ||
|
|
2e37990d87 | ||
|
|
dd07d724a8 | ||
|
|
03ce8618e7 | ||
|
|
db1a7a7fd6 | ||
|
|
36a82d7f53 | ||
|
|
3a34401113 | ||
|
|
9927268330 | ||
|
|
c45c97e29d | ||
|
|
c64a315226 | ||
|
|
a4cafca6ab | ||
|
|
46284a0660 | ||
|
|
05df86e15a | ||
|
|
8b433027e2 | ||
|
|
5bd4ff7610 | ||
|
|
d693c397ea | ||
|
|
1d8d1ec9a5 | ||
|
|
5e491f11ee | ||
|
|
7cedea06ac | ||
|
|
2e5f750e50 | ||
|
|
20289cad10 | ||
|
|
e0d64c31c7 | ||
|
|
8c1b95dc97 | ||
|
|
fb5641343e | ||
|
|
87765941eb | ||
|
|
1809862c16 | ||
|
|
300f784f7d | ||
|
|
67a045eae6 | ||
|
|
2a79903a28 | ||
|
|
d3222ce083 | ||
|
|
406a421742 | ||
|
|
10bf728faf | ||
|
|
607617747c | ||
|
|
f0a69eb1a2 | ||
|
|
6b307a6e17 | ||
|
|
08d08a934a | ||
|
|
c500c12668 | ||
|
|
62060adeba | ||
|
|
b2fc75edb8 | ||
|
|
a999dd2085 | ||
|
|
49f95ab100 | ||
|
|
1a84d5b30c | ||
|
|
3b65050632 | ||
|
|
d0df31674c | ||
|
|
1fe88402e2 | ||
|
|
67097696e6 | ||
|
|
8e7e77067a | ||
|
|
9899390b61 | ||
|
|
80c476a908 | ||
|
|
59da1d6e49 | ||
|
|
5aef7dac33 | ||
|
|
faf7aa06b6 | ||
|
|
38ef6e5583 | ||
|
|
c0b15b5d94 | ||
|
|
2cfc067ea1 | ||
|
|
a91db4f956 | ||
|
|
8a09780a02 | ||
|
|
45e8ec6505 | ||
|
|
4554b85914 | ||
|
|
8aa79c4a9c | ||
|
|
c8d3210b57 | ||
|
|
2282a49563 | ||
|
|
b82fdfb2c8 | ||
|
|
2d17eac199 | ||
|
|
e482bc3aad | ||
|
|
ec022b74d1 | ||
|
|
dc42c09ce3 | ||
|
|
046a34d2a4 | ||
|
|
9ff6ec1888 | ||
|
|
d2950106ec | ||
|
|
962f800d2e | ||
|
|
962107e507 | ||
|
|
039bd11963 | ||
|
|
5c250ea4ae | ||
|
|
e3405bcec6 | ||
|
|
0fd1c2235f | ||
|
|
b20c29b022 | ||
|
|
12d5dcd298 | ||
|
|
2c305dc6c6 | ||
|
|
62f76f7433 | ||
|
|
858ce524f9 | ||
|
|
3795fb4a40 | ||
|
|
0c01aeec50 | ||
|
|
892206744d | ||
|
|
9e2c1474db | ||
|
|
16328f73d9 | ||
|
|
e0d4f53cf4 | ||
|
|
e09a59c5b4 | ||
|
|
049e654535 | ||
|
|
c927dc4ecd | ||
|
|
fe4ecd0ad8 | ||
|
|
78d476fe80 | ||
|
|
a11c8465d5 | ||
|
|
366304a9b7 | ||
|
|
4356663688 | ||
|
|
26b55e6fcf | ||
|
|
0d743f7204 | ||
|
|
6cbe113b3e | ||
|
|
6409b69d6c | ||
|
|
c5164c76fc | ||
|
|
baade8e138 | ||
|
|
b848d6b4e0 | ||
|
|
d8139d2ab0 | ||
|
|
e96d8f7469 | ||
|
|
2acffd8afc | ||
|
|
3c8e72073c | ||
|
|
724d7a9d9b | ||
|
|
2da3b0db78 | ||
|
|
685ad7afaf | ||
|
|
264cf75964 | ||
|
|
c773dbc7b5 | ||
|
|
37cbc64f52 | ||
|
|
cb1dde17bb | ||
|
|
c29988acf4 | ||
|
|
eadbf56dae | ||
|
|
4b3b455135 | ||
|
|
e6ac177396 | ||
|
|
3d0e29003f | ||
|
|
78b9b00f77 | ||
|
|
0ee7faa551 | ||
|
|
e5fdced681 | ||
|
|
afb99fef64 | ||
|
|
7dfaa36024 | ||
|
|
0496f665aa | ||
|
|
0d19e1be74 | ||
|
|
4aff0111aa | ||
|
|
63b3ba2bb2 | ||
|
|
7444b41f60 | ||
|
|
8e90dbc8b6 | ||
|
|
9f70722521 | ||
|
|
52fae596fa | ||
|
|
ccb67957bc | ||
|
|
fb82538d0d | ||
|
|
72ee39612e | ||
|
|
51fd5408dc | ||
|
|
3fae40fbef | ||
|
|
0745890af0 | ||
|
|
4abe1730a7 | ||
|
|
626f0e6989 | ||
|
|
9f42d9d173 | ||
|
|
f90a93c4bc | ||
|
|
8000ad6c6a | ||
|
|
1f1f1bea1a | ||
|
|
d95460c7cd | ||
|
|
a3d93d4b08 | ||
|
|
07a92af982 | ||
|
|
f4618877d4 | ||
|
|
2b914fd222 | ||
|
|
109e42a5a3 | ||
|
|
fa515ad39c | ||
|
|
f09673a795 | ||
|
|
f71536c614 | ||
|
|
7bdddc7ae8 | ||
|
|
aa8926a624 | ||
|
|
be71e59be2 | ||
|
|
4d7753378f | ||
|
|
60257c4ef4 | ||
|
|
1e0b79bf62 | ||
|
|
6883434d0d | ||
|
|
eda2193e64 | ||
|
|
99bf829c88 | ||
|
|
5feafe1b48 | ||
|
|
c9292b7d04 | ||
|
|
ae7e1a91c1 | ||
|
|
3e1887e0d1 | ||
|
|
474646db47 | ||
|
|
56f7b6c449 | ||
|
|
76b2b5f7e3 | ||
|
|
e918d809eb | ||
|
|
7af059e543 | ||
|
|
897726e1ec | ||
|
|
8b98a2dd07 | ||
|
|
cca75420f0 | ||
|
|
86c627ed1d | ||
|
|
d55514e3a7 |
@@ -1,549 +0,0 @@
|
||||
# 선물 관리자 페이지 구현 프롬프트
|
||||
|
||||
이 문서는 관리자 페이지의 선물 관련 4개 화면을 프론트엔드 구현 에이전트에게 전달하기 위한 프롬프트 모음이다.
|
||||
|
||||
공통 전제:
|
||||
|
||||
- 관리자 메뉴에는 parent 메뉴 `선물함 관리` 아래에 다음 4개 하위 메뉴가 있다.
|
||||
- `선물함 리스트` → `/gift/list`
|
||||
- `받을 주소` → `/gift/mailbox`
|
||||
- `선물 카테고리` → `/gift/category`
|
||||
- `선물 사이즈` → `/gift/size`
|
||||
- 모든 API 응답은 기존 공통 envelope를 사용한다. 화면에서는 `response.data`를 실제 payload로 사용한다.
|
||||
- 인증/권한 처리는 기존 관리자 페이지의 API 클라이언트, 토큰 저장 방식, 에러 처리 방식을 따른다.
|
||||
- 새 디자인 시스템을 만들지 말고 기존 관리자 페이지의 테이블, 필터, 버튼, 모달, 폼, 토스트, 확인창 패턴을 재사용한다.
|
||||
- 화면 문구는 한국어로 작성한다.
|
||||
- API 실패 시 기존 관리자 페이지의 공통 에러 표시 방식을 따른다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 선물함 리스트 페이지 프롬프트
|
||||
|
||||
```text
|
||||
관리자 선물함 리스트 페이지를 구현해줘.
|
||||
|
||||
목표:
|
||||
- 관리자 메뉴 `선물함 관리 > 선물함 리스트`에서 전체 선물 신청 목록을 조회한다.
|
||||
- 상태, 신청번호, 닉네임으로 필터링할 수 있다.
|
||||
- 목록 row에서 선물 상세를 확인하고, 현재 상태에서 가능한 운영 액션을 실행할 수 있다.
|
||||
- 기존 관리자 페이지의 테이블/필터/페이지네이션/모달/확인창 스타일을 그대로 따른다.
|
||||
|
||||
라우트:
|
||||
- `/gift/list`
|
||||
|
||||
필수 화면 구성:
|
||||
- 상단 제목: `선물함 리스트`
|
||||
- 필터 영역:
|
||||
- 상태 select: 전체, 접수 완료, 발송 확인, 사서함 도착, 검수 완료, 전달 완료, 전달 불가, 신청 취소
|
||||
- 신청번호 검색 input: `applicationNo` 부분 검색
|
||||
- 닉네임 검색 input: 발송인 또는 수취인 닉네임 부분 검색
|
||||
- 검색 버튼, 초기화 버튼
|
||||
- 목록 테이블 컬럼:
|
||||
- 신청번호 `applicationNo`
|
||||
- 발송인 닉네임 `senderNickname`
|
||||
- 수취인 닉네임 `recipientNickname`
|
||||
- 사이즈 `sizeName` / `sizeCode`
|
||||
- 카테고리 `categoryName`
|
||||
- 분류번호 `classificationNumber`
|
||||
- 택배사 `courierCompanyName`
|
||||
- 운송장번호 `trackingNumber`
|
||||
- 상태 `statusName`
|
||||
- 액션 버튼
|
||||
- 페이지네이션:
|
||||
- 서버의 `page`, `size`, `totalCount`, `hasNext` 기준으로 기존 관리자 페이지 패턴에 맞춘다.
|
||||
|
||||
목록 조회 API:
|
||||
- Method: `GET`
|
||||
- URL: `/api/v2/admin/gifts`
|
||||
- Query:
|
||||
- `status?: GiftStatus`
|
||||
- `applicationNo?: string`
|
||||
- `nickname?: string`
|
||||
- `page?: number` 기본 `0`
|
||||
- `size?: number` 기본 `20`
|
||||
- 필터 조합:
|
||||
- 선택 필터는 AND 조건이다.
|
||||
- `nickname`은 발송인/수취인 닉네임 중 하나에 포함되면 매칭된다.
|
||||
|
||||
목록 조회 response `data`:
|
||||
```ts
|
||||
type AdminGiftListResponse = {
|
||||
totalCount: number;
|
||||
items: AdminGiftListItemResponse[];
|
||||
page: number;
|
||||
size: number;
|
||||
hasNext: boolean;
|
||||
};
|
||||
|
||||
type AdminGiftListItemResponse = {
|
||||
applicationNo: string;
|
||||
senderNickname: string;
|
||||
recipientNickname: string;
|
||||
sizeCode: string;
|
||||
sizeName: string;
|
||||
categoryName: string;
|
||||
classificationNumber: string;
|
||||
courierCompanyName: string | null;
|
||||
trackingNumber: string | null;
|
||||
status: GiftStatus;
|
||||
statusName: string;
|
||||
availableActions: AdminGiftAction[];
|
||||
};
|
||||
```
|
||||
|
||||
상태 enum:
|
||||
```ts
|
||||
type GiftStatus =
|
||||
| "RECEIVED"
|
||||
| "TRACKING_REGISTERED"
|
||||
| "ARRIVED_AT_MAILBOX"
|
||||
| "INSPECTION_COMPLETED"
|
||||
| "DELIVERED"
|
||||
| "UNDELIVERABLE"
|
||||
| "CANCELED";
|
||||
```
|
||||
|
||||
상태 표시명:
|
||||
- `RECEIVED`: `접수 완료`
|
||||
- `TRACKING_REGISTERED`: `발송 확인`
|
||||
- `ARRIVED_AT_MAILBOX`: `사서함 도착`
|
||||
- `INSPECTION_COMPLETED`: `검수 완료`
|
||||
- `DELIVERED`: `전달 완료`
|
||||
- `UNDELIVERABLE`: `전달 불가`
|
||||
- `CANCELED`: `신청 취소`
|
||||
|
||||
상세 조회 API:
|
||||
- Method: `GET`
|
||||
- URL: `/api/v2/admin/gifts/{applicationNo}`
|
||||
- 용도:
|
||||
- 목록 row 클릭 또는 `상세` 버튼 클릭 시 상세 모달/상세 패널을 연다.
|
||||
|
||||
상세 response `data`:
|
||||
```ts
|
||||
type AdminGiftDetailResponse = {
|
||||
applicationNo: string;
|
||||
senderInfo: AdminGiftMemberInfoResponse;
|
||||
recipientInfo: AdminGiftMemberInfoResponse;
|
||||
productInfo: AdminGiftProductInfoResponse;
|
||||
inboundDeliveryInfo: AdminGiftInboundDeliveryInfoResponse;
|
||||
status: GiftStatus;
|
||||
statusName: string;
|
||||
availableActions: AdminGiftAction[];
|
||||
};
|
||||
|
||||
type AdminGiftMemberInfoResponse = {
|
||||
nickname: string;
|
||||
name: string;
|
||||
phoneNumber: string;
|
||||
address: string;
|
||||
};
|
||||
|
||||
type AdminGiftProductInfoResponse = {
|
||||
sizeCode: string;
|
||||
sizeName: string;
|
||||
categoryName: string;
|
||||
classificationNumber: string;
|
||||
};
|
||||
|
||||
type AdminGiftInboundDeliveryInfoResponse = {
|
||||
courierCompanyName: string | null;
|
||||
trackingNumber: string | null;
|
||||
};
|
||||
```
|
||||
|
||||
상세 표시 요구사항:
|
||||
- 발송인 정보: 닉네임, 이름, 전화번호, 주소
|
||||
- 수취인 정보: 닉네임, 이름, 전화번호, 주소
|
||||
- 상품 정보: 사이즈, 카테고리, 분류번호
|
||||
- 입고 배송 정보: 택배사, 운송장번호
|
||||
- 현재 상태와 가능한 액션
|
||||
- 수취인이 배송지를 아직 입력하지 않은 경우 `recipientInfo.nickname`은 표시하고 `name`, `phoneNumber`, `address`는 빈 문자열로 온다. 화면에서는 `미입력`으로 표시해도 된다.
|
||||
|
||||
운영 액션 enum:
|
||||
```ts
|
||||
type AdminGiftAction =
|
||||
| "ARRIVE_MAILBOX"
|
||||
| "COMPLETE_INSPECTION"
|
||||
| "COMPLETE_DELIVERY"
|
||||
| "MARK_UNDELIVERABLE";
|
||||
```
|
||||
|
||||
상태별 `availableActions`:
|
||||
- `RECEIVED`: 없음
|
||||
- `TRACKING_REGISTERED`: `ARRIVE_MAILBOX`, `MARK_UNDELIVERABLE`
|
||||
- `ARRIVED_AT_MAILBOX`: `COMPLETE_INSPECTION`, `MARK_UNDELIVERABLE`
|
||||
- `INSPECTION_COMPLETED`: `COMPLETE_DELIVERY`, `MARK_UNDELIVERABLE`
|
||||
- `DELIVERED`: 없음
|
||||
- `UNDELIVERABLE`: 없음
|
||||
- `CANCELED`: 없음
|
||||
|
||||
액션 버튼 문구:
|
||||
- `ARRIVE_MAILBOX`: `사서함 도착 처리`
|
||||
- `COMPLETE_INSPECTION`: `검수 완료 처리`
|
||||
- `COMPLETE_DELIVERY`: `전달 완료 처리`
|
||||
- `MARK_UNDELIVERABLE`: `전달 불가 처리`
|
||||
|
||||
액션 API:
|
||||
- 사서함 도착 처리
|
||||
- Method: `POST`
|
||||
- URL: `/api/v2/admin/gifts/{applicationNo}/arrive-mailbox`
|
||||
- Body: 없음
|
||||
- 검수 완료 처리
|
||||
- Method: `POST`
|
||||
- URL: `/api/v2/admin/gifts/{applicationNo}/complete-inspection`
|
||||
- Body: 없음
|
||||
- 전달 완료 처리
|
||||
- Method: `POST`
|
||||
- URL: `/api/v2/admin/gifts/{applicationNo}/complete-delivery`
|
||||
- Body: 없음
|
||||
- 전달 불가 처리
|
||||
- Method: `POST`
|
||||
- URL: `/api/v2/admin/gifts/{applicationNo}/mark-undeliverable`
|
||||
- Body:
|
||||
```ts
|
||||
type AdminGiftMarkUndeliverableRequest = {
|
||||
reason: string;
|
||||
};
|
||||
```
|
||||
|
||||
액션 response `data`:
|
||||
```ts
|
||||
type AdminGiftOperationStatusResponse = {
|
||||
applicationNo: string;
|
||||
status: GiftStatus;
|
||||
statusName: string;
|
||||
occurredAt: string; // UTC ISO string, e.g. 2026-09-30T12:00:00Z
|
||||
};
|
||||
```
|
||||
|
||||
액션 UX:
|
||||
- 모든 상태 변경 액션은 확인창을 띄운다.
|
||||
- `MARK_UNDELIVERABLE`은 사유 입력 모달을 띄운다.
|
||||
- 전달 불가 사유는 공백만 입력할 수 없고 255자 이내로 제한한다.
|
||||
- 액션 성공 후:
|
||||
- 상세 모달이 열려 있으면 상세를 다시 조회한다.
|
||||
- 목록도 현재 필터/페이지 기준으로 다시 조회한다.
|
||||
- 성공 토스트를 표시한다.
|
||||
- 액션 실패 시 기존 관리자 페이지 에러 토스트/알림 패턴을 따른다.
|
||||
|
||||
빈 값 표시:
|
||||
- `courierCompanyName`, `trackingNumber`가 null이면 `-`로 표시한다.
|
||||
- 수취인 개인정보가 빈 문자열이면 `미입력`으로 표시한다.
|
||||
|
||||
테스트/검증:
|
||||
- API 클라이언트 함수 단위 테스트 또는 페이지 테스트에서 query parameter가 올바르게 전달되는지 확인한다.
|
||||
- `availableActions`에 따라 버튼 노출이 달라지는지 확인한다.
|
||||
- 전달 불가 사유 빈 값 validation을 확인한다.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 받을 주소 설정 페이지 프롬프트
|
||||
|
||||
```text
|
||||
관리자 선물 받을 주소 설정 페이지를 구현해줘.
|
||||
|
||||
목표:
|
||||
- 관리자 메뉴 `선물함 관리 > 받을 주소`에서 팬이 선물을 보낼 전역 단일 주소를 조회하고 저장한다.
|
||||
- 이 주소는 팬의 선물 상세 API에서 운송장 등록 전 상태일 때 `mailbox`로 노출된다.
|
||||
- 기존 관리자 페이지의 폼, 저장 버튼, 토스트, 에러 표시 패턴을 그대로 따른다.
|
||||
|
||||
라우트:
|
||||
- `/gift/mailbox`
|
||||
|
||||
필수 화면 구성:
|
||||
- 상단 제목: `받을 주소`
|
||||
- 설명 문구: `팬이 선물을 발송할 때 확인하는 받을 주소입니다.`
|
||||
- 입력 폼:
|
||||
- 받을 사람 이름 `name`
|
||||
- 연락처 `phoneNumber`
|
||||
- 우편번호 `zipCode`
|
||||
- 주소 `address`
|
||||
- 상세주소 `addressDetail`
|
||||
- 저장 버튼: `저장`
|
||||
|
||||
초기 조회 API:
|
||||
- Method: `GET`
|
||||
- URL: `/api/v2/admin/gift-mailbox`
|
||||
- response `data`:
|
||||
```ts
|
||||
type AdminGiftMailboxResponse = {
|
||||
name: string;
|
||||
address: string;
|
||||
phoneNumber: string;
|
||||
} | null;
|
||||
```
|
||||
- `data`가 null이면 빈 폼을 표시한다.
|
||||
|
||||
저장 API:
|
||||
- Method: `PUT`
|
||||
- URL: `/api/v2/admin/gift-mailbox`
|
||||
- Request:
|
||||
```ts
|
||||
type AdminGiftMailboxRequest = {
|
||||
name: string;
|
||||
phoneNumber: string;
|
||||
zipCode: string;
|
||||
address: string;
|
||||
addressDetail: string | null;
|
||||
};
|
||||
```
|
||||
- Response `data`:
|
||||
```ts
|
||||
type AdminGiftMailboxResponse = {
|
||||
name: string;
|
||||
address: string;
|
||||
phoneNumber: string;
|
||||
};
|
||||
```
|
||||
|
||||
Validation:
|
||||
- `name`, `phoneNumber`, `zipCode`, `address`는 필수다.
|
||||
- 공백만 입력할 수 없다.
|
||||
- 저장 실패 시 기존 관리자 페이지의 공통 에러 표시 방식을 따른다.
|
||||
|
||||
저장 성공 UX:
|
||||
- 성공 토스트를 표시한다.
|
||||
- 저장 API response 기준으로 화면 값을 갱신한다.
|
||||
- `address`는 서버가 조립한 `(우편번호) 주소, 상세주소` 형식으로 표시해도 되고, 입력 필드는 사용자가 입력한 값을 유지해도 된다.
|
||||
|
||||
테스트/검증:
|
||||
- 초기 조회 시 null이면 빈 폼을 표시하는지 확인한다.
|
||||
- 저장 시 PUT body가 정확히 전달되는지 확인한다.
|
||||
- 필수값 공백 validation을 확인한다.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 선물 카테고리 CRUD 페이지 프롬프트
|
||||
|
||||
```text
|
||||
관리자 선물 카테고리 CRUD 페이지를 구현해줘.
|
||||
|
||||
목표:
|
||||
- 관리자 메뉴 `선물함 관리 > 선물 카테고리`에서 선물 카테고리를 조회, 등록, 수정, 비활성화한다.
|
||||
- 기존 관리자 페이지의 CRUD 테이블, 등록/수정 모달, 삭제 확인창, 토스트 패턴을 그대로 따른다.
|
||||
|
||||
라우트:
|
||||
- `/gift/category`
|
||||
|
||||
필수 화면 구성:
|
||||
- 상단 제목: `선물 카테고리`
|
||||
- 상단 우측 `카테고리 등록` 버튼
|
||||
- 목록 테이블 컬럼:
|
||||
- 카테고리 ID `categoryId`
|
||||
- 분류번호 `classificationNumber`
|
||||
- 카테고리 코드 `categoryCode`
|
||||
- 카테고리명 `name`
|
||||
- 접수 코드 `receiptCode`
|
||||
- 대표 품목 `representativeItem`
|
||||
- 파손면책 동의 필요 여부 `requiresDamageWaiver`
|
||||
- 활성 여부 `isActive`
|
||||
- 관리 버튼: 수정, 비활성화
|
||||
- 등록/수정 폼 필드:
|
||||
- `classificationNumber`
|
||||
- `categoryCode`
|
||||
- `name`
|
||||
- `receiptCode`
|
||||
- `representativeItem`
|
||||
- `requiresDamageWaiver`
|
||||
- `isActive`
|
||||
|
||||
목록 조회 API:
|
||||
- Method: `GET`
|
||||
- URL: `/api/v2/admin/gift-categories`
|
||||
- Request: 없음
|
||||
- Response `data`:
|
||||
```ts
|
||||
type AdminGiftCategoryResponse = {
|
||||
categoryId: number;
|
||||
classificationNumber: string;
|
||||
categoryCode: string;
|
||||
name: string;
|
||||
receiptCode: string;
|
||||
representativeItem: string;
|
||||
requiresDamageWaiver: boolean;
|
||||
isActive: boolean;
|
||||
};
|
||||
```
|
||||
|
||||
등록 API:
|
||||
- Method: `POST`
|
||||
- URL: `/api/v2/admin/gift-categories`
|
||||
- Body:
|
||||
```ts
|
||||
type AdminGiftCategoryRequest = {
|
||||
classificationNumber: string;
|
||||
categoryCode: string;
|
||||
name: string;
|
||||
receiptCode: string;
|
||||
representativeItem: string;
|
||||
requiresDamageWaiver: boolean;
|
||||
isActive: boolean;
|
||||
};
|
||||
```
|
||||
- Response `data`: `AdminGiftCategoryResponse`
|
||||
|
||||
수정 API:
|
||||
- Method: `PUT`
|
||||
- URL: `/api/v2/admin/gift-categories/{categoryId}`
|
||||
- Body: `AdminGiftCategoryRequest`
|
||||
- Response `data`: `AdminGiftCategoryResponse`
|
||||
|
||||
비활성화 API:
|
||||
- Method: `DELETE`
|
||||
- URL: `/api/v2/admin/gift-categories/{categoryId}`
|
||||
- Body: 없음
|
||||
- Response `data`: `AdminGiftCategoryResponse`
|
||||
- 서버는 실제 삭제가 아니라 `isActive=false` 논리 삭제로 처리한다.
|
||||
|
||||
폼 validation:
|
||||
- `classificationNumber`: 필수, 숫자 3자리. 예: `100`
|
||||
- `categoryCode`: 필수, 50자 이하. 예: `DOLL`
|
||||
- `name`: 필수, 50자 이하. 예: `인형`
|
||||
- `receiptCode`: 필수, 영문 대문자 1~9자. 예: `A`
|
||||
- `representativeItem`: 필수, 100자 이하. 예: `피규어`
|
||||
- `requiresDamageWaiver`: boolean
|
||||
- `isActive`: boolean
|
||||
|
||||
UX 요구사항:
|
||||
- 등록 성공 후 목록을 다시 조회하고 모달을 닫는다.
|
||||
- 수정 성공 후 목록을 다시 조회하고 모달을 닫는다.
|
||||
- 비활성화 버튼은 확인창을 띄운 뒤 호출한다.
|
||||
- 이미 비활성화된 row는 비활성화 버튼을 disabled 처리한다.
|
||||
- boolean 값은 기존 관리자 페이지 표현에 맞춰 `예/아니오`, `활성/비활성` 또는 badge로 표시한다.
|
||||
- `categoryCode`는 수정 폼에 표시하되 서버 쪽 엔티티는 코드 변경을 실제로 반영하지 않을 수 있으므로, 가능하면 수정 시 읽기 전용으로 두고 나머지 운영 필드만 수정하도록 UX를 구성한다.
|
||||
|
||||
테스트/검증:
|
||||
- 목록 조회가 `GET /api/v2/admin/gift-categories`를 호출하는지 확인한다.
|
||||
- 등록/수정 body가 `AdminGiftCategoryRequest` 형태와 일치하는지 확인한다.
|
||||
- validation 실패 시 API를 호출하지 않고 폼 에러를 표시한다.
|
||||
- 비활성화 성공 후 해당 row의 `isActive=false`가 반영되는지 확인한다.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 선물 사이즈 가격 CRUD 페이지 프롬프트
|
||||
|
||||
```text
|
||||
관리자 선물 사이즈 가격 CRUD 페이지를 구현해줘.
|
||||
|
||||
목표:
|
||||
- 관리자 메뉴 `선물함 관리 > 선물 사이즈`에서 선물 사이즈별 기본가/판매가/활성 여부를 조회하고 수정한다.
|
||||
- 현재 API는 사이즈 가격의 목록 조회와 수정만 제공한다. 신규 생성과 물리 삭제 기능은 만들지 않는다.
|
||||
- 기존 관리자 페이지의 테이블, 수정 모달, 저장 확인/토스트 패턴을 그대로 따른다.
|
||||
|
||||
라우트:
|
||||
- `/gift/size`
|
||||
|
||||
필수 화면 구성:
|
||||
- 상단 제목: `선물 사이즈`
|
||||
- 목록 테이블 컬럼:
|
||||
- 사이즈 코드 `sizeCode`
|
||||
- 사이즈명 `name`
|
||||
- 기본가 `basePriceCan`
|
||||
- 판매가 `salePriceCan`
|
||||
- 활성 여부 `isActive`
|
||||
- 관리 버튼: 수정
|
||||
- 수정 폼 필드:
|
||||
- `sizeCode`: 읽기 전용
|
||||
- `name`: 읽기 전용
|
||||
- `basePriceCan`: number input
|
||||
- `salePriceCan`: number input
|
||||
- `isActive`: checkbox/switch
|
||||
|
||||
목록 조회 API:
|
||||
- Method: `GET`
|
||||
- URL: `/api/v2/admin/gift-size-prices`
|
||||
- Request: 없음
|
||||
- Response `data`:
|
||||
```ts
|
||||
type AdminGiftSizePriceResponse = {
|
||||
sizeCode: GiftSize;
|
||||
name: string;
|
||||
basePriceCan: number;
|
||||
salePriceCan: number;
|
||||
isActive: boolean;
|
||||
};
|
||||
```
|
||||
|
||||
수정 API:
|
||||
- Method: `PUT`
|
||||
- URL: `/api/v2/admin/gift-size-prices/{sizeCode}`
|
||||
- Body:
|
||||
```ts
|
||||
type AdminGiftSizePriceRequest = {
|
||||
basePriceCan: number;
|
||||
salePriceCan: number;
|
||||
isActive: boolean;
|
||||
};
|
||||
```
|
||||
- Response `data`: `AdminGiftSizePriceResponse`
|
||||
|
||||
사이즈 enum:
|
||||
```ts
|
||||
type GiftSize = "SMALL" | "MEDIUM" | "LARGE";
|
||||
```
|
||||
|
||||
서버 validation:
|
||||
- `salePriceCan`은 0보다 커야 한다.
|
||||
- `salePriceCan`은 `basePriceCan`보다 클 수 없다.
|
||||
|
||||
프론트 validation:
|
||||
- `basePriceCan`: 필수, 1 이상 정수
|
||||
- `salePriceCan`: 필수, 1 이상 정수
|
||||
- `salePriceCan <= basePriceCan`
|
||||
- validation 실패 시 API 호출을 막고 필드 에러를 표시한다.
|
||||
|
||||
UX 요구사항:
|
||||
- 가격은 숫자 입력이지만 테이블 표시에서는 기존 관리자 페이지의 숫자 포맷을 따른다.
|
||||
- 수정 버튼 클릭 시 현재 row 값을 폼 초기값으로 넣는다.
|
||||
- 저장 전 확인창은 기존 관리자 페이지 패턴이 있으면 따른다.
|
||||
- 저장 성공 후 목록을 다시 조회하고 모달을 닫는다.
|
||||
- 신규 생성/삭제 버튼은 만들지 않는다.
|
||||
|
||||
테스트/검증:
|
||||
- 목록 조회가 `GET /api/v2/admin/gift-size-prices`를 호출하는지 확인한다.
|
||||
- 수정 저장 시 `PUT /api/v2/admin/gift-size-prices/{sizeCode}`와 request body가 정확한지 확인한다.
|
||||
- `salePriceCan > basePriceCan`일 때 API를 호출하지 않고 validation 메시지를 표시하는지 확인한다.
|
||||
- 저장 성공 후 목록 refresh가 일어나는지 확인한다.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 구현 시 참고할 공통 타입
|
||||
|
||||
```ts
|
||||
type ApiResponse<T> = {
|
||||
success: boolean;
|
||||
data: T;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type GiftStatus =
|
||||
| "RECEIVED"
|
||||
| "TRACKING_REGISTERED"
|
||||
| "ARRIVED_AT_MAILBOX"
|
||||
| "INSPECTION_COMPLETED"
|
||||
| "DELIVERED"
|
||||
| "UNDELIVERABLE"
|
||||
| "CANCELED";
|
||||
|
||||
type AdminGiftAction =
|
||||
| "ARRIVE_MAILBOX"
|
||||
| "COMPLETE_INSPECTION"
|
||||
| "COMPLETE_DELIVERY"
|
||||
| "MARK_UNDELIVERABLE";
|
||||
|
||||
type GiftSize = "SMALL" | "MEDIUM" | "LARGE";
|
||||
```
|
||||
|
||||
## 구현 순서 추천
|
||||
|
||||
1. API client 함수와 TypeScript 타입을 먼저 추가한다.
|
||||
2. `/gift/list` 선물함 리스트와 상세/상태변경 모달을 구현한다.
|
||||
3. `/gift/mailbox` 받을 주소 설정 화면을 구현한다.
|
||||
4. `/gift/category` 카테고리 CRUD를 구현한다.
|
||||
5. `/gift/size` 사이즈 가격 수정 화면을 구현한다.
|
||||
6. 기존 관리자 메뉴의 `선물함 관리` 하위 route와 연결되는지 확인한다.
|
||||
7. 각 페이지별 loading/empty/error/success 상태를 확인한다.
|
||||
@@ -1,364 +0,0 @@
|
||||
# 선물하기 클라이언트 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`로 내려간다.
|
||||
@@ -1,274 +0,0 @@
|
||||
# 선물함 리스트 페이지 생성 프롬프트
|
||||
|
||||
이 문서는 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 배너 + 카드 리스트. 단, 실제 구현에서는 `선물 내역` 타이틀을 제거한다.
|
||||
- 받은 선물 상태별 카드는 `배송지 입력 완료/전달 예정`, `전달 완료`, `전달 불가`, `선물 검수/선물 확인 중`, `선물 검수 완료/배송 중` 패턴을 사용한다.
|
||||
@@ -1,128 +0,0 @@
|
||||
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_mailbox (
|
||||
id BIGINT NOT NULL COMMENT '전역 단일 설정 ID. 항상 1',
|
||||
name VARCHAR(50) NOT NULL COMMENT '받을 사람 이름',
|
||||
phone_number VARCHAR(30) NOT NULL COMMENT '받을 연락처',
|
||||
zip_code VARCHAR(20) NOT NULL COMMENT '받을 우편번호',
|
||||
address VARCHAR(255) NOT NULL COMMENT '받을 주소',
|
||||
address_detail VARCHAR(255) NULL COMMENT '받을 상세주소',
|
||||
PRIMARY KEY (id)
|
||||
) COMMENT='선물 받을 주소 전역 설정';
|
||||
|
||||
CREATE TABLE gift (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '내부 기본키',
|
||||
application_no VARCHAR(40) NOT NULL COMMENT '신청번호',
|
||||
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='선물 신청번호 날짜별 채번';
|
||||
@@ -1,263 +0,0 @@
|
||||
# 선물 보내기 페이지 생성 프롬프트
|
||||
|
||||
이 문서는 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,7 +120,6 @@ 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,8 +131,6 @@ 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")
|
||||
}
|
||||
@@ -162,18 +160,6 @@ 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(
|
||||
@@ -338,24 +324,6 @@ 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,6 +14,5 @@ enum class CanUsage {
|
||||
CHAT_MESSAGE_PURCHASE, // 메시지를 통한 구매(이미지 등 다양한 리소스에 공통 적용)
|
||||
CHARACTER_IMAGE_PURCHASE, // 캐릭터 이미지 단독 구매
|
||||
CHAT_QUOTA_PURCHASE, // 채팅 횟수(쿼터) 충전
|
||||
CHAT_ROOM_RESET, // 채팅방 초기화 결제(별도 구분)
|
||||
GIFT
|
||||
CHAT_ROOM_RESET // 채팅방 초기화 결제(별도 구분)
|
||||
}
|
||||
|
||||
@@ -25,8 +25,7 @@ enum class FcmDeepLinkValue(val value: String) {
|
||||
SERIES("series"),
|
||||
AUDITION("audition"),
|
||||
COMMUNITY("community"),
|
||||
CHAT("chat"),
|
||||
GIFT_DETAIL("gift-detail")
|
||||
CHAT("chat")
|
||||
}
|
||||
|
||||
class FcmEvent(
|
||||
@@ -51,7 +50,7 @@ class FcmEvent(
|
||||
val creatorId: Long? = null,
|
||||
val auditionId: Long? = null,
|
||||
val deepLinkValue: FcmDeepLinkValue? = null,
|
||||
val deepLinkId: Any? = null,
|
||||
val deepLinkId: Long? = 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: Any? = null,
|
||||
deepLinkId: Long? = null,
|
||||
deepLinkCommentPostId: Long? = null,
|
||||
chatType: String? = null
|
||||
) {
|
||||
@@ -118,7 +118,7 @@ class FcmService(
|
||||
|
||||
private fun createDeepLink(
|
||||
deepLinkValue: FcmDeepLinkValue?,
|
||||
deepLinkId: Any?,
|
||||
deepLinkId: Long?,
|
||||
deepLinkCommentPostId: Long?
|
||||
): String? {
|
||||
return buildDeepLink(serverEnv, deepLinkValue, deepLinkId, deepLinkCommentPostId)
|
||||
@@ -194,7 +194,7 @@ class FcmService(
|
||||
fun buildDeepLink(
|
||||
serverEnv: String,
|
||||
deepLinkValue: FcmDeepLinkValue?,
|
||||
deepLinkId: Any?,
|
||||
deepLinkId: Long?,
|
||||
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: Any? = null,
|
||||
deepLinkId: Long? = null,
|
||||
deepLinkCommentPostId: Long? = null,
|
||||
deepLink: String? = null,
|
||||
chatType: String? = null
|
||||
|
||||
@@ -6,7 +6,6 @@ enum class PushNotificationCategory(val code: String) {
|
||||
COMMUNITY("community"),
|
||||
MESSAGE("message"),
|
||||
AUDITION("audition"),
|
||||
GIFT("gift"),
|
||||
SYSTEM("system");
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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
|
||||
@@ -9,20 +8,6 @@ class MenuService(
|
||||
private val repository: MenuRepository
|
||||
) {
|
||||
fun getMenus(member: Member): List<GetMenuResponse> {
|
||||
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")
|
||||
)
|
||||
)
|
||||
return repository.getMenu(member.role)
|
||||
}
|
||||
}
|
||||
|
||||
-106
@@ -1,106 +0,0 @@
|
||||
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.AdminGiftMailboxRequest
|
||||
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftMailboxResponse
|
||||
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftMarkUndeliverableRequest
|
||||
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftOperationStatusResponse
|
||||
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftSizePriceRequest
|
||||
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-mailbox")
|
||||
fun getMailbox() = ApiResponse.ok(service.getMailbox()?.let { AdminGiftMailboxResponse.from(it) })
|
||||
|
||||
@PutMapping("/gift-mailbox")
|
||||
fun updateMailbox(@RequestBody request: AdminGiftMailboxRequest) = ApiResponse.ok(
|
||||
AdminGiftMailboxResponse.from(service.updateMailbox(request.toCommand()))
|
||||
)
|
||||
|
||||
@GetMapping("/gift-categories")
|
||||
fun getCategories() = ApiResponse.ok(service.getCategories().map { AdminGiftCategoryResponse.from(it) })
|
||||
|
||||
@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))
|
||||
)
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.gift.dto
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftCategoryCommand
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftMailboxCommand
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftSizePriceCommand
|
||||
|
||||
data class AdminGiftCategoryRequest(
|
||||
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 AdminGiftMailboxRequest(
|
||||
val name: String,
|
||||
val phoneNumber: String,
|
||||
val zipCode: String,
|
||||
val address: String,
|
||||
val addressDetail: String?
|
||||
) {
|
||||
fun toCommand() = GiftMailboxCommand(
|
||||
name = name,
|
||||
phoneNumber = phoneNumber,
|
||||
zipCode = zipCode,
|
||||
address = address,
|
||||
addressDetail = addressDetail
|
||||
)
|
||||
}
|
||||
|
||||
data class AdminGiftMarkUndeliverableRequest(
|
||||
val reason: String
|
||||
)
|
||||
@@ -1,204 +0,0 @@
|
||||
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.GiftMailboxResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftOperationStatusResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftSizePriceResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
|
||||
|
||||
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 AdminGiftMailboxResponse(
|
||||
val name: String,
|
||||
val address: String,
|
||||
val phoneNumber: String
|
||||
) {
|
||||
companion object {
|
||||
fun from(result: GiftMailboxResult) = AdminGiftMailboxResponse(
|
||||
name = result.name,
|
||||
address = result.address,
|
||||
phoneNumber = result.phoneNumber
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class AdminGiftListResponse(
|
||||
val totalCount: Long,
|
||||
val items: List<AdminGiftListItemResponse>,
|
||||
val page: Int,
|
||||
val size: Int,
|
||||
val hasNext: Boolean
|
||||
) {
|
||||
companion object {
|
||||
fun from(result: AdminGiftListResult) = AdminGiftListResponse(
|
||||
totalCount = result.totalCount,
|
||||
items = result.items.map { AdminGiftListItemResponse.from(it) },
|
||||
page = result.page,
|
||||
size = result.size,
|
||||
hasNext = result.hasNext
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class AdminGiftListItemResponse(
|
||||
val applicationNo: String,
|
||||
val senderNickname: String,
|
||||
val recipientNickname: String,
|
||||
val sizeCode: String,
|
||||
val sizeName: String,
|
||||
val categoryName: String,
|
||||
val classificationNumber: String,
|
||||
val courierCompanyName: String?,
|
||||
val trackingNumber: String?,
|
||||
val status: GiftStatus,
|
||||
val statusName: String,
|
||||
val availableActions: List<String>
|
||||
) {
|
||||
companion object {
|
||||
fun from(result: AdminGiftListItemResult) = AdminGiftListItemResponse(
|
||||
applicationNo = result.applicationNo,
|
||||
senderNickname = result.senderNickname,
|
||||
recipientNickname = result.recipientNickname,
|
||||
sizeCode = result.sizeCode,
|
||||
sizeName = result.sizeName,
|
||||
categoryName = result.categoryName,
|
||||
classificationNumber = result.classificationNumber,
|
||||
courierCompanyName = result.courierCompanyName,
|
||||
trackingNumber = result.trackingNumber,
|
||||
status = result.status,
|
||||
statusName = result.statusName,
|
||||
availableActions = result.availableActions
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class AdminGiftDetailResponse(
|
||||
val applicationNo: String,
|
||||
val senderInfo: AdminGiftMemberInfoResponse,
|
||||
val recipientInfo: AdminGiftMemberInfoResponse,
|
||||
val productInfo: AdminGiftProductInfoResponse,
|
||||
val inboundDeliveryInfo: AdminGiftInboundDeliveryInfoResponse,
|
||||
val status: GiftStatus,
|
||||
val statusName: String,
|
||||
val availableActions: List<String>
|
||||
) {
|
||||
companion object {
|
||||
fun from(result: AdminGiftDetailResult) = AdminGiftDetailResponse(
|
||||
applicationNo = result.applicationNo,
|
||||
senderInfo = AdminGiftMemberInfoResponse.from(result.senderInfo),
|
||||
recipientInfo = AdminGiftMemberInfoResponse.from(result.recipientInfo),
|
||||
productInfo = AdminGiftProductInfoResponse.from(result.productInfo),
|
||||
inboundDeliveryInfo = AdminGiftInboundDeliveryInfoResponse.from(result.inboundDeliveryInfo),
|
||||
status = result.status,
|
||||
statusName = result.statusName,
|
||||
availableActions = result.availableActions
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class AdminGiftMemberInfoResponse(
|
||||
val nickname: String,
|
||||
val name: String,
|
||||
val phoneNumber: String,
|
||||
val address: String
|
||||
) {
|
||||
companion object {
|
||||
fun from(result: AdminGiftMemberInfoResult) = AdminGiftMemberInfoResponse(
|
||||
nickname = result.nickname,
|
||||
name = result.name,
|
||||
phoneNumber = result.phoneNumber,
|
||||
address = result.address
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class AdminGiftProductInfoResponse(
|
||||
val sizeCode: String,
|
||||
val sizeName: String,
|
||||
val categoryName: String,
|
||||
val classificationNumber: String
|
||||
) {
|
||||
companion object {
|
||||
fun from(result: AdminGiftProductInfoResult) = AdminGiftProductInfoResponse(
|
||||
sizeCode = result.sizeCode,
|
||||
sizeName = result.sizeName,
|
||||
categoryName = result.categoryName,
|
||||
classificationNumber = result.classificationNumber
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class AdminGiftInboundDeliveryInfoResponse(
|
||||
val courierCompanyName: String?,
|
||||
val trackingNumber: String?
|
||||
) {
|
||||
companion object {
|
||||
fun from(result: AdminGiftInboundDeliveryInfoResult) = AdminGiftInboundDeliveryInfoResponse(
|
||||
courierCompanyName = result.courierCompanyName,
|
||||
trackingNumber = result.trackingNumber
|
||||
)
|
||||
}
|
||||
}
|
||||
-153
@@ -1,153 +0,0 @@
|
||||
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()
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.gift.dto
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftApplicationCommand
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
|
||||
|
||||
data class GiftApplicationRequest(
|
||||
val recipientMemberId: Long,
|
||||
val senderName: String,
|
||||
val senderPhoneNumber: String,
|
||||
val senderZipCode: String,
|
||||
val senderAddress: String,
|
||||
val senderAddressDetail: String?,
|
||||
val sizeCode: GiftSize,
|
||||
val categoryId: Long,
|
||||
val senderTermsAgreed: Boolean,
|
||||
val senderPrivacyAgreed: Boolean,
|
||||
val damageWaiverAgreed: Boolean
|
||||
) {
|
||||
fun toCommand() = GiftApplicationCommand(
|
||||
recipientMemberId = recipientMemberId,
|
||||
senderName = senderName,
|
||||
senderPhoneNumber = senderPhoneNumber,
|
||||
senderZipCode = senderZipCode,
|
||||
senderAddress = senderAddress,
|
||||
senderAddressDetail = senderAddressDetail,
|
||||
sizeCode = sizeCode,
|
||||
categoryId = categoryId,
|
||||
senderTermsAgreed = senderTermsAgreed,
|
||||
senderPrivacyAgreed = senderPrivacyAgreed,
|
||||
damageWaiverAgreed = damageWaiverAgreed
|
||||
)
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
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()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.gift.dto
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftReviewCommand
|
||||
|
||||
data class GiftReviewRequest(
|
||||
val rating: Int,
|
||||
val keywords: List<String>,
|
||||
val comment: String?
|
||||
) {
|
||||
fun toCommand() = GiftReviewCommand(
|
||||
rating = rating,
|
||||
keywords = keywords,
|
||||
comment = comment
|
||||
)
|
||||
}
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
|
||||
import java.time.LocalDateTime
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.EnumType
|
||||
import javax.persistence.Enumerated
|
||||
import javax.persistence.Table
|
||||
import javax.persistence.UniqueConstraint
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "gift",
|
||||
uniqueConstraints = [UniqueConstraint(name = "uk_gift_application_no", columnNames = ["application_no"])]
|
||||
)
|
||||
class Gift(
|
||||
@Column(name = "application_no", nullable = false, length = 20)
|
||||
val applicationNo: String,
|
||||
|
||||
@Column(name = "sender_member_id", nullable = false)
|
||||
val senderMemberId: Long,
|
||||
|
||||
@Column(name = "recipient_member_id", nullable = false)
|
||||
val recipientMemberId: Long,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 30)
|
||||
var status: GiftStatus,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "size_code", nullable = false, length = 20)
|
||||
val sizeCode: GiftSize,
|
||||
|
||||
@Column(name = "category_id", nullable = false)
|
||||
val categoryId: Long,
|
||||
|
||||
@Column(name = "category_name_snapshot", nullable = false, length = 50)
|
||||
val categoryNameSnapshot: String,
|
||||
|
||||
@Column(name = "sale_price_can", nullable = false)
|
||||
val salePriceCan: Int,
|
||||
|
||||
@Column(name = "can_usage_id", nullable = false)
|
||||
val canUsageId: Long,
|
||||
|
||||
@Column(name = "sender_terms_agreed", nullable = false)
|
||||
val senderTermsAgreed: Boolean,
|
||||
|
||||
@Column(name = "sender_privacy_agreed", nullable = false)
|
||||
val senderPrivacyAgreed: Boolean,
|
||||
|
||||
@Column(name = "sender_terms_agreed_at", nullable = false)
|
||||
val senderTermsAgreedAt: LocalDateTime,
|
||||
|
||||
@Column(name = "sender_privacy_agreed_at", nullable = false)
|
||||
val senderPrivacyAgreedAt: LocalDateTime,
|
||||
|
||||
@Column(name = "recipient_terms_agreed", nullable = false)
|
||||
var recipientTermsAgreed: Boolean = false,
|
||||
|
||||
@Column(name = "recipient_privacy_agreed", nullable = false)
|
||||
var recipientPrivacyAgreed: Boolean = false,
|
||||
|
||||
@Column(name = "recipient_terms_agreed_at")
|
||||
var recipientTermsAgreedAt: LocalDateTime? = null,
|
||||
|
||||
@Column(name = "recipient_privacy_agreed_at")
|
||||
var recipientPrivacyAgreedAt: LocalDateTime? = null,
|
||||
|
||||
@Column(name = "damage_waiver_agreed", nullable = false)
|
||||
val damageWaiverAgreed: Boolean = false
|
||||
) : BaseEntity()
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.Table
|
||||
import javax.persistence.UniqueConstraint
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "gift_application_no_sequence",
|
||||
uniqueConstraints = [
|
||||
UniqueConstraint(
|
||||
name = "uk_gift_application_no_sequence_category_date",
|
||||
columnNames = ["category_id", "sequence_date"]
|
||||
)
|
||||
]
|
||||
)
|
||||
class GiftApplicationNoSequence(
|
||||
@Column(name = "category_id", nullable = false)
|
||||
val categoryId: Long,
|
||||
|
||||
@Column(name = "sequence_date", nullable = false, length = 6)
|
||||
val sequenceDate: String,
|
||||
|
||||
@Column(name = "last_sequence", nullable = false)
|
||||
var lastSequence: Int
|
||||
) : BaseEntity()
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Lock
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import javax.persistence.LockModeType
|
||||
|
||||
interface GiftApplicationNoSequenceRepository : JpaRepository<GiftApplicationNoSequence, Long> {
|
||||
fun findByCategoryIdAndSequenceDate(categoryId: Long, sequenceDate: String): GiftApplicationNoSequence?
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query(
|
||||
"""
|
||||
select s
|
||||
from GiftApplicationNoSequence s
|
||||
where s.categoryId = :categoryId
|
||||
and s.sequenceDate = :sequenceDate
|
||||
"""
|
||||
)
|
||||
fun findByCategoryIdAndSequenceDateForUpdate(
|
||||
@Param("categoryId") categoryId: Long,
|
||||
@Param("sequenceDate") sequenceDate: String
|
||||
): GiftApplicationNoSequence?
|
||||
}
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.Table
|
||||
import javax.persistence.UniqueConstraint
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "gift_category",
|
||||
uniqueConstraints = [
|
||||
UniqueConstraint(name = "uk_gift_category_classification_number", columnNames = ["classification_number"]),
|
||||
UniqueConstraint(name = "uk_gift_category_code", columnNames = ["category_code"]),
|
||||
UniqueConstraint(name = "uk_gift_category_receipt_code", columnNames = ["receipt_code"])
|
||||
]
|
||||
)
|
||||
class GiftCategory(
|
||||
@Column(name = "classification_number", nullable = false, length = 3)
|
||||
var classificationNumber: String,
|
||||
|
||||
@Column(name = "category_code", nullable = false, length = 50)
|
||||
val categoryCode: String,
|
||||
|
||||
@Column(name = "name", nullable = false, length = 50)
|
||||
var name: String,
|
||||
|
||||
@Column(name = "receipt_code", nullable = false, length = 9)
|
||||
var receiptCode: String,
|
||||
|
||||
@Column(name = "representative_item", nullable = false, length = 100)
|
||||
var representativeItem: String,
|
||||
|
||||
@Column(name = "requires_damage_waiver", nullable = false)
|
||||
var requiresDamageWaiver: Boolean = false,
|
||||
|
||||
@Column(name = "is_active", nullable = false)
|
||||
var isActive: Boolean = true
|
||||
) : BaseEntity()
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface GiftCategoryRepository : JpaRepository<GiftCategory, Long> {
|
||||
fun findByCategoryCode(categoryCode: String): GiftCategory?
|
||||
}
|
||||
-87
@@ -1,87 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import java.time.LocalDateTime
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.Table
|
||||
import javax.persistence.UniqueConstraint
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "gift_delivery",
|
||||
uniqueConstraints = [UniqueConstraint(name = "uk_gift_delivery_gift_id", columnNames = ["gift_id"])]
|
||||
)
|
||||
class GiftDelivery(
|
||||
@Column(name = "gift_id", nullable = false)
|
||||
val giftId: Long,
|
||||
|
||||
@Column(name = "sender_name", nullable = false, length = 50)
|
||||
val senderName: String,
|
||||
|
||||
@Column(name = "sender_phone_number", nullable = false, length = 30)
|
||||
val senderPhoneNumber: String,
|
||||
|
||||
@Column(name = "sender_zip_code", nullable = false, length = 20)
|
||||
val senderZipCode: String,
|
||||
|
||||
@Column(name = "sender_address", nullable = false, length = 255)
|
||||
val senderAddress: String,
|
||||
|
||||
@Column(name = "sender_address_detail", length = 255)
|
||||
val senderAddressDetail: String? = null,
|
||||
|
||||
@Column(name = "recipient_name", length = 50)
|
||||
var recipientName: String? = null,
|
||||
|
||||
@Column(name = "recipient_phone_number", length = 30)
|
||||
var recipientPhoneNumber: String? = null,
|
||||
|
||||
@Column(name = "recipient_zip_code", length = 20)
|
||||
var recipientZipCode: String? = null,
|
||||
|
||||
@Column(name = "recipient_address", length = 255)
|
||||
var recipientAddress: String? = null,
|
||||
|
||||
@Column(name = "recipient_address_detail", length = 255)
|
||||
var recipientAddressDetail: String? = null,
|
||||
|
||||
@Column(name = "courier_company_name", length = 50)
|
||||
var courierCompanyName: String? = null,
|
||||
|
||||
@Column(name = "tracking_number", length = 100)
|
||||
var trackingNumber: String? = null,
|
||||
|
||||
@Column(name = "tracking_deadline_at", nullable = false)
|
||||
val trackingDeadlineAt: LocalDateTime,
|
||||
|
||||
@Column(name = "recipient_address_deadline_at")
|
||||
var recipientAddressDeadlineAt: LocalDateTime? = null,
|
||||
|
||||
@Column(name = "tracking_deadline_reminder_sent_at")
|
||||
var trackingDeadlineReminderSentAt: LocalDateTime? = null,
|
||||
|
||||
@Column(name = "recipient_address_deadline_reminder_sent_at")
|
||||
var recipientAddressDeadlineReminderSentAt: LocalDateTime? = null,
|
||||
|
||||
@Column(name = "tracking_registered_at")
|
||||
var trackingRegisteredAt: LocalDateTime? = null,
|
||||
|
||||
@Column(name = "arrived_at_mailbox_at")
|
||||
var arrivedAtMailboxAt: LocalDateTime? = null,
|
||||
|
||||
@Column(name = "inspection_completed_at")
|
||||
var inspectionCompletedAt: LocalDateTime? = null,
|
||||
|
||||
@Column(name = "delivered_at")
|
||||
var deliveredAt: LocalDateTime? = null,
|
||||
|
||||
@Column(name = "canceled_at")
|
||||
var canceledAt: LocalDateTime? = null,
|
||||
|
||||
@Column(name = "undeliverable_at")
|
||||
var undeliverableAt: LocalDateTime? = null,
|
||||
|
||||
@Column(name = "undeliverable_reason", length = 255)
|
||||
var undeliverableReason: String? = null
|
||||
) : BaseEntity()
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
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>
|
||||
}
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
|
||||
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.Id
|
||||
import javax.persistence.Table
|
||||
|
||||
@Entity
|
||||
@Table(name = "gift_mailbox")
|
||||
class GiftMailbox(
|
||||
@Id
|
||||
@Column(name = "id", nullable = false)
|
||||
val id: Long = SINGLETON_ID,
|
||||
|
||||
@Column(name = "name", nullable = false, length = 50)
|
||||
var name: String,
|
||||
|
||||
@Column(name = "phone_number", nullable = false, length = 30)
|
||||
var phoneNumber: String,
|
||||
|
||||
@Column(name = "zip_code", nullable = false, length = 20)
|
||||
var zipCode: String,
|
||||
|
||||
@Column(name = "address", nullable = false, length = 255)
|
||||
var address: String,
|
||||
|
||||
@Column(name = "address_detail", length = 255)
|
||||
var addressDetail: String?
|
||||
) {
|
||||
companion object {
|
||||
const val SINGLETON_ID = 1L
|
||||
}
|
||||
}
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface GiftMailboxRepository : JpaRepository<GiftMailbox, Long>
|
||||
-141
@@ -1,141 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import java.time.LocalDateTime
|
||||
|
||||
interface GiftRepository : JpaRepository<Gift, Long> {
|
||||
fun findByApplicationNo(applicationNo: String): Gift?
|
||||
|
||||
fun findBySenderMemberId(senderMemberId: Long, pageable: Pageable): Page<Gift>
|
||||
|
||||
fun findByRecipientMemberIdAndStatusIn(
|
||||
recipientMemberId: Long,
|
||||
statuses: Collection<GiftStatus>,
|
||||
pageable: Pageable
|
||||
): Page<Gift>
|
||||
|
||||
fun findBySenderMemberIdOrRecipientMemberIdAndStatusIn(
|
||||
senderMemberId: Long,
|
||||
recipientMemberId: Long,
|
||||
statuses: Collection<GiftStatus>,
|
||||
pageable: Pageable
|
||||
): Page<Gift>
|
||||
|
||||
@Query(
|
||||
value = """
|
||||
select g
|
||||
from Gift g
|
||||
join Member sender on sender.id = g.senderMemberId
|
||||
join Member recipient on recipient.id = g.recipientMemberId
|
||||
where (:status is null or g.status = :status)
|
||||
and (:applicationNo is null or g.applicationNo like concat('%', :applicationNo, '%'))
|
||||
and (
|
||||
:nickname is null
|
||||
or sender.nickname like concat('%', :nickname, '%')
|
||||
or recipient.nickname like concat('%', :nickname, '%')
|
||||
)
|
||||
""",
|
||||
countQuery = """
|
||||
select count(g)
|
||||
from Gift g
|
||||
join Member sender on sender.id = g.senderMemberId
|
||||
join Member recipient on recipient.id = g.recipientMemberId
|
||||
where (:status is null or g.status = :status)
|
||||
and (:applicationNo is null or g.applicationNo like concat('%', :applicationNo, '%'))
|
||||
and (
|
||||
:nickname is null
|
||||
or sender.nickname like concat('%', :nickname, '%')
|
||||
or recipient.nickname like concat('%', :nickname, '%')
|
||||
)
|
||||
"""
|
||||
)
|
||||
fun findAdminGifts(
|
||||
@Param("status") status: GiftStatus?,
|
||||
@Param("applicationNo") applicationNo: String?,
|
||||
@Param("nickname") nickname: String?,
|
||||
pageable: Pageable
|
||||
): Page<Gift>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
select g
|
||||
from Gift g
|
||||
where g.status = kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.RECEIVED
|
||||
and exists (
|
||||
select d.id
|
||||
from GiftDelivery d
|
||||
where d.giftId = g.id
|
||||
and d.trackingDeadlineAt < :now
|
||||
)
|
||||
"""
|
||||
)
|
||||
fun findReceivedGiftsWithTrackingDeadlineBefore(@Param("now") now: LocalDateTime): List<Gift>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
select g
|
||||
from Gift g
|
||||
where g.status = kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.TRACKING_REGISTERED
|
||||
and exists (
|
||||
select d.id
|
||||
from GiftDelivery d
|
||||
where d.giftId = g.id
|
||||
and d.recipientAddressDeadlineAt < :now
|
||||
and d.recipientAddress is null
|
||||
)
|
||||
"""
|
||||
)
|
||||
fun findTrackingRegisteredGiftsWithRecipientAddressDeadlineBefore(
|
||||
@Param("now") now: LocalDateTime
|
||||
): List<Gift>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
select g
|
||||
from Gift g
|
||||
where g.status = kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.RECEIVED
|
||||
and exists (
|
||||
select d.id
|
||||
from GiftDelivery d
|
||||
where d.giftId = g.id
|
||||
and d.trackingDeadlineAt > :now
|
||||
and d.trackingDeadlineAt <= :reminderUntil
|
||||
and d.trackingDeadlineReminderSentAt is null
|
||||
)
|
||||
"""
|
||||
)
|
||||
fun findReceivedGiftsWithTrackingDeadlineReminderDue(
|
||||
@Param("now") now: LocalDateTime,
|
||||
@Param("reminderUntil") reminderUntil: LocalDateTime
|
||||
): List<Gift>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
select g
|
||||
from Gift g
|
||||
where g.status in (
|
||||
kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.TRACKING_REGISTERED,
|
||||
kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.ARRIVED_AT_MAILBOX,
|
||||
kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.INSPECTION_COMPLETED
|
||||
)
|
||||
and exists (
|
||||
select d.id
|
||||
from GiftDelivery d
|
||||
where d.giftId = g.id
|
||||
and d.recipientAddressDeadlineAt > :now
|
||||
and d.recipientAddressDeadlineAt <= :reminderUntil
|
||||
and d.recipientAddress is null
|
||||
and d.recipientAddressDeadlineReminderSentAt is null
|
||||
)
|
||||
"""
|
||||
)
|
||||
fun findGiftsWithRecipientAddressDeadlineReminderDue(
|
||||
@Param("now") now: LocalDateTime,
|
||||
@Param("reminderUntil") reminderUntil: LocalDateTime
|
||||
): List<Gift>
|
||||
}
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.Table
|
||||
import javax.persistence.UniqueConstraint
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "gift_review",
|
||||
uniqueConstraints = [UniqueConstraint(name = "uk_gift_review_gift_id", columnNames = ["gift_id"])]
|
||||
)
|
||||
class GiftReview(
|
||||
@Column(name = "gift_id", nullable = false)
|
||||
val giftId: Long,
|
||||
|
||||
@Column(name = "sender_member_id", nullable = false)
|
||||
val senderMemberId: Long,
|
||||
|
||||
@Column(name = "rating", nullable = false)
|
||||
val rating: Int,
|
||||
|
||||
@Column(name = "keywords", columnDefinition = "text")
|
||||
var keywords: String? = null,
|
||||
|
||||
@Column(name = "comment", length = 255)
|
||||
var comment: String? = null
|
||||
) : BaseEntity()
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface GiftReviewRepository : JpaRepository<GiftReview, Long> {
|
||||
fun findByGiftId(giftId: Long): GiftReview?
|
||||
}
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.EnumType
|
||||
import javax.persistence.Enumerated
|
||||
import javax.persistence.Table
|
||||
import javax.persistence.UniqueConstraint
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "gift_size_price",
|
||||
uniqueConstraints = [UniqueConstraint(name = "uk_gift_size_price_size_code", columnNames = ["size_code"])]
|
||||
)
|
||||
class GiftSizePrice(
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "size_code", nullable = false, length = 20)
|
||||
val sizeCode: GiftSize,
|
||||
|
||||
@Column(name = "base_price_can", nullable = false)
|
||||
var basePriceCan: Int,
|
||||
|
||||
@Column(name = "sale_price_can", nullable = false)
|
||||
var salePriceCan: Int,
|
||||
|
||||
@Column(name = "is_active", nullable = false)
|
||||
var isActive: Boolean = true
|
||||
) : BaseEntity()
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface GiftSizePriceRepository : JpaRepository<GiftSizePrice, Long> {
|
||||
fun findBySizeCode(sizeCode: GiftSize): GiftSizePrice?
|
||||
}
|
||||
-101
@@ -1,101 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.adapter.out.scheduler
|
||||
|
||||
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftPushService
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
|
||||
import org.redisson.api.RedissonClient
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.LocalDateTime
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@Component
|
||||
class GiftScheduler(
|
||||
private val giftRepository: GiftRepository,
|
||||
private val giftDeliveryRepository: GiftDeliveryRepository,
|
||||
private val canPaymentService: CanPaymentService,
|
||||
private val giftPushService: GiftPushService,
|
||||
private val redissonClient: RedissonClient
|
||||
) {
|
||||
@Scheduled(cron = "0 0 * * * *", zone = "Asia/Seoul")
|
||||
@Transactional
|
||||
fun runExpiredTrackingRegistrationGiftCancellation() {
|
||||
val lock = redissonClient.getLock(EXPIRED_TRACKING_REGISTRATION_GIFTS_LOCK_KEY)
|
||||
|
||||
try {
|
||||
if (lock.tryLock(0, -1, TimeUnit.SECONDS)) {
|
||||
cancelExpiredTrackingRegistrationGifts(LocalDateTime.now())
|
||||
}
|
||||
} finally {
|
||||
if (lock.isHeldByCurrentThread) {
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun cancelExpiredTrackingRegistrationGifts(now: LocalDateTime): Int {
|
||||
val expiredGifts = giftRepository.findReceivedGiftsWithTrackingDeadlineBefore(now)
|
||||
expiredGifts.forEach { gift ->
|
||||
gift.status = GiftStatus.CANCELED
|
||||
requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id))).canceledAt = now
|
||||
canPaymentService.refundGiftCan(gift.senderMemberId, gift.canUsageId)
|
||||
giftPushService.sendAutoCanceledToSender(gift.applicationNo, gift.senderMemberId)
|
||||
}
|
||||
|
||||
return expiredGifts.size
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun markExpiredRecipientAddressGiftsUndeliverable(now: LocalDateTime): Int {
|
||||
val expiredGifts = giftRepository.findTrackingRegisteredGiftsWithRecipientAddressDeadlineBefore(now)
|
||||
expiredGifts.forEach { gift ->
|
||||
gift.status = GiftStatus.UNDELIVERABLE
|
||||
val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)))
|
||||
delivery.undeliverableAt = now
|
||||
delivery.undeliverableReason = EXPIRED_RECIPIENT_ADDRESS_REASON
|
||||
}
|
||||
|
||||
return expiredGifts.size
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun sendDeadlineReminderGifts(now: LocalDateTime): Int {
|
||||
val reminderUntil = now.plusHours(DEADLINE_REMINDER_HOURS)
|
||||
val trackingReminderCount = sendTrackingDeadlineReminders(now, reminderUntil)
|
||||
val recipientAddressReminderCount = sendRecipientAddressDeadlineReminders(now, reminderUntil)
|
||||
|
||||
return trackingReminderCount + recipientAddressReminderCount
|
||||
}
|
||||
|
||||
private fun sendTrackingDeadlineReminders(now: LocalDateTime, reminderUntil: LocalDateTime): Int {
|
||||
val gifts = giftRepository.findReceivedGiftsWithTrackingDeadlineReminderDue(now, reminderUntil)
|
||||
gifts.forEach { gift ->
|
||||
val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)))
|
||||
delivery.trackingDeadlineReminderSentAt = now
|
||||
giftPushService.sendTrackingDeadlineReminder(gift.applicationNo, gift.senderMemberId)
|
||||
}
|
||||
|
||||
return gifts.size
|
||||
}
|
||||
|
||||
private fun sendRecipientAddressDeadlineReminders(now: LocalDateTime, reminderUntil: LocalDateTime): Int {
|
||||
val gifts = giftRepository.findGiftsWithRecipientAddressDeadlineReminderDue(now, reminderUntil)
|
||||
gifts.forEach { gift ->
|
||||
val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)))
|
||||
delivery.recipientAddressDeadlineReminderSentAt = now
|
||||
giftPushService.sendRecipientAddressDeadlineReminder(gift.applicationNo, gift.recipientMemberId)
|
||||
}
|
||||
|
||||
return gifts.size
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DEADLINE_REMINDER_HOURS = 24L
|
||||
const val EXPIRED_TRACKING_REGISTRATION_GIFTS_LOCK_KEY = "lock:gift-expired-tracking-registration-cancellation"
|
||||
const val EXPIRED_RECIPIENT_ADDRESS_REASON = "배송지 미입력 기한 초과"
|
||||
}
|
||||
}
|
||||
-205
@@ -1,205 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
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.GiftMailbox
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailboxRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePrice
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository
|
||||
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 GiftMailboxCommand(
|
||||
val name: String,
|
||||
val phoneNumber: String,
|
||||
val zipCode: String,
|
||||
val address: String,
|
||||
val addressDetail: String?
|
||||
)
|
||||
|
||||
data class GiftSizePriceResult(
|
||||
val sizeCode: GiftSize,
|
||||
val sizeName: String,
|
||||
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 giftMailboxRepository: GiftMailboxRepository,
|
||||
private val giftPushService: GiftPushService
|
||||
) {
|
||||
@Transactional(readOnly = true)
|
||||
fun getMailbox(): GiftMailboxResult? = giftMailboxRepository.findByIdOrNull(GiftMailbox.SINGLETON_ID)?.toResult()
|
||||
|
||||
@Transactional
|
||||
fun updateMailbox(command: GiftMailboxCommand): GiftMailboxResult {
|
||||
validateMailbox(command)
|
||||
val mailbox = giftMailboxRepository.findByIdOrNull(GiftMailbox.SINGLETON_ID)
|
||||
?: GiftMailbox(
|
||||
name = command.name,
|
||||
phoneNumber = command.phoneNumber,
|
||||
zipCode = command.zipCode,
|
||||
address = command.address,
|
||||
addressDetail = command.addressDetail?.takeIf { it.isNotBlank() }
|
||||
)
|
||||
mailbox.name = command.name
|
||||
mailbox.phoneNumber = command.phoneNumber
|
||||
mailbox.zipCode = command.zipCode
|
||||
mailbox.address = command.address
|
||||
mailbox.addressDetail = command.addressDetail?.takeIf { it.isNotBlank() }
|
||||
return giftMailboxRepository.save(mailbox).toResult()
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getCategories(): List<GiftCategoryResult> = categoryRepository.findAll().map { it.toResult() }
|
||||
|
||||
@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 validateMailbox(command: GiftMailboxCommand) {
|
||||
if (
|
||||
command.name.isBlank() ||
|
||||
command.name.length > 50 ||
|
||||
command.phoneNumber.isBlank() ||
|
||||
command.phoneNumber.length > 30 ||
|
||||
command.zipCode.isBlank() ||
|
||||
command.zipCode.length > 20 ||
|
||||
command.address.isBlank() ||
|
||||
command.address.length > 255 ||
|
||||
(command.addressDetail?.length ?: 0) > 255
|
||||
) {
|
||||
throw invalidRequest()
|
||||
}
|
||||
}
|
||||
|
||||
private fun GiftCategory.toResult() = GiftCategoryResult(
|
||||
categoryId = id ?: throw invalidRequest(),
|
||||
classificationNumber = classificationNumber,
|
||||
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 GiftMailbox.toResult() = GiftMailboxResult(
|
||||
name = name,
|
||||
address = formatAddress(zipCode, address, addressDetail),
|
||||
phoneNumber = phoneNumber
|
||||
)
|
||||
|
||||
private fun formatAddress(zipCode: String, address: String, detail: String?) =
|
||||
"($zipCode) $address" + if (detail.isNullOrBlank()) "" else ", $detail"
|
||||
|
||||
private fun invalidRequest() = SodaException(messageKey = "common.error.invalid_request")
|
||||
|
||||
private companion object {
|
||||
val UNDELIVERABLE_ALLOWED_STATUSES = setOf(
|
||||
GiftStatus.TRACKING_REGISTERED,
|
||||
GiftStatus.ARRIVED_AT_MAILBOX,
|
||||
GiftStatus.INSPECTION_COMPLETED
|
||||
)
|
||||
val CLASSIFICATION_NUMBER_REGEX = Regex("\\d{3}")
|
||||
val RECEIPT_CODE_REGEX = Regex("[A-Z]{1,9}")
|
||||
}
|
||||
}
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,368 +0,0 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
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)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.application
|
||||
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailbox
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailboxRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftDirection
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftListType
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.data.domain.Sort
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
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,
|
||||
private val giftMailboxRepository: GiftMailboxRepository,
|
||||
@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 = mailboxFor(direction, gift.status),
|
||||
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 mailboxFor(direction: GiftDirection, status: GiftStatus): GiftMailboxResult? {
|
||||
if (direction != GiftDirection.SENT || status != GiftStatus.RECEIVED) return null
|
||||
return giftMailboxRepository.findByIdOrNull(GiftMailbox.SINGLETON_ID)?.let {
|
||||
GiftMailboxResult(
|
||||
name = it.name,
|
||||
address = formatAddress(it.zipCode, it.address, it.addressDetail),
|
||||
phoneNumber = it.phoneNumber
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun GiftDelivery.tracking() = if (courierCompanyName != null && trackingNumber != null) {
|
||||
"${courierCompanyName}_$trackingNumber"
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.domain
|
||||
|
||||
enum class GiftListType {
|
||||
ALL,
|
||||
SENT,
|
||||
RECEIVED
|
||||
}
|
||||
|
||||
enum class GiftDirection {
|
||||
SENT,
|
||||
RECEIVED
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
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("신청 취소")
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
-439
@@ -1,439 +0,0 @@
|
||||
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 shouldManageGiftMailbox() {
|
||||
mockMvc.perform(get("/api/v2/admin/gift-mailbox").with(user("admin").roles("ADMIN")))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data").isEmpty)
|
||||
|
||||
mockMvc.perform(
|
||||
put("/api/v2/admin/gift-mailbox")
|
||||
.with(user("admin").roles("ADMIN"))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(
|
||||
"""
|
||||
{
|
||||
"name":"소다라이브 선물 담당자",
|
||||
"phoneNumber":"01012345678",
|
||||
"zipCode":"06234",
|
||||
"address":"서울시 강남구",
|
||||
"addressDetail":"3층 선물 접수처"
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.name").value("소다라이브 선물 담당자"))
|
||||
.andExpect(jsonPath("$.data.phoneNumber").value("01012345678"))
|
||||
.andExpect(jsonPath("$.data.address").value("(06234) 서울시 강남구, 3층 선물 접수처"))
|
||||
|
||||
mockMvc.perform(get("/api/v2/admin/gift-mailbox").with(user("admin").roles("ADMIN")))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.name").value("소다라이브 선물 담당자"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("관리자는 공백 받을 주소를 저장할 수 없다")
|
||||
fun shouldRejectInvalidGiftMailbox() {
|
||||
mockMvc.perform(
|
||||
put("/api/v2/admin/gift-mailbox")
|
||||
.with(user("admin").roles("ADMIN"))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""{"name":" ","phoneNumber":"01012345678","zipCode":"06234","address":"서울시 강남구"}""")
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(false))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("관리자는 전체 선물함 목록을 필터링해 조회한다")
|
||||
fun shouldListAdminGifts() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
-517
@@ -1,517 +0,0 @@
|
||||
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.GiftMailbox
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailboxRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePrice
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository
|
||||
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 giftMailboxRepository: GiftMailboxRepository,
|
||||
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("발신자는 운송장 등록 전 선물 상세에서 받을 주소를 조회한다")
|
||||
fun shouldGetMailboxForSentReceivedGiftDetail() {
|
||||
val sender = memberRepository.save(Member(password = "password", nickname = "fan"))
|
||||
val recipient = memberRepository.save(Member(password = "password", nickname = "creator"))
|
||||
val gift = saveGift(
|
||||
applicationNo = "G-CONTROLLER-MAILBOX",
|
||||
senderMemberId = sender.id!!,
|
||||
recipientMemberId = recipient.id!!,
|
||||
status = GiftStatus.RECEIVED
|
||||
)
|
||||
giftDeliveryRepository.save(
|
||||
GiftDelivery(
|
||||
giftId = gift.id!!,
|
||||
senderName = "홍길동",
|
||||
senderPhoneNumber = "01012345678",
|
||||
senderZipCode = "06234",
|
||||
senderAddress = "서울시 강남구",
|
||||
trackingDeadlineAt = LocalDateTime.of(2026, 10, 2, 12, 0)
|
||||
)
|
||||
)
|
||||
giftMailboxRepository.save(
|
||||
GiftMailbox(
|
||||
name = "소다라이브 선물 담당자",
|
||||
phoneNumber = "01012345678",
|
||||
zipCode = "06234",
|
||||
address = "서울시 강남구",
|
||||
addressDetail = "3층 선물 접수처"
|
||||
)
|
||||
)
|
||||
|
||||
mockMvc.perform(
|
||||
get("/api/v2/gifts/{applicationNo}", gift.applicationNo)
|
||||
.with(user(MemberAdapter(sender)))
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.mailbox.name").value("소다라이브 선물 담당자"))
|
||||
.andExpect(jsonPath("$.data.mailbox.phoneNumber").value("01012345678"))
|
||||
.andExpect(jsonPath("$.data.mailbox.address").value("(06234) 서울시 강남구, 3층 선물 접수처"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("팬은 POST /api/v2/gifts로 선물을 신청하고 접수 결과를 받는다")
|
||||
fun shouldRegisterGift() {
|
||||
// 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"
|
||||
}
|
||||
}
|
||||
@@ -1,876 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift
|
||||
|
||||
import kr.co.vividnext.sodalive.can.CanRepository
|
||||
import kr.co.vividnext.sodalive.can.charge.Charge
|
||||
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
|
||||
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
|
||||
import kr.co.vividnext.sodalive.can.payment.Payment
|
||||
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
|
||||
import kr.co.vividnext.sodalive.can.use.CanUsage
|
||||
import kr.co.vividnext.sodalive.can.use.UseCan
|
||||
import kr.co.vividnext.sodalive.can.use.UseCanCalculate
|
||||
import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository
|
||||
import kr.co.vividnext.sodalive.can.use.UseCanRepository
|
||||
import kr.co.vividnext.sodalive.common.CountryContext
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftReview
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftReviewRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePrice
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftApplicationCommand
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftApplicationNoGenerator
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftCancellationResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftCommandService
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftDeliveryConfirmationResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftPushService
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftRecipientAddressRegistrationCommand
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftRecipientAddressRegistrationResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftReviewCommand
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftReviewResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftTrackingRegistrationCommand
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftTrackingRegistrationResult
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import org.junit.jupiter.params.provider.MethodSource
|
||||
import org.mockito.Mockito
|
||||
import java.time.LocalDateTime
|
||||
import java.util.Optional
|
||||
|
||||
class GiftCommandServiceTest {
|
||||
@Test
|
||||
@DisplayName("선물 캔 사용은 한 번만 환불할 수 있다")
|
||||
fun shouldSpendAndRefundGiftCanOnlyOnce() {
|
||||
// Given: 충전 캔을 보유한 선물 발신자와 결제 서비스를 준비한다.
|
||||
val member = Member(password = "password", nickname = "sender")
|
||||
member.id = MEMBER_ID
|
||||
member.pgChargeCan = INITIAL_CAN
|
||||
val charge = Charge(chargeCan = INITIAL_CAN, rewardCan = 0)
|
||||
charge.id = CHARGE_ID
|
||||
charge.member = member
|
||||
charge.payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.PG)
|
||||
val fixture = CanPaymentFixture(member, charge)
|
||||
|
||||
// When: 선물용 캔을 사용한 뒤 해당 사용 내역을 환불한다.
|
||||
val useCanId = fixture.service.spendGiftCan(
|
||||
memberId = MEMBER_ID,
|
||||
needCan = GIFT_PRICE_CAN,
|
||||
container = "web"
|
||||
)
|
||||
val afterSpendCan = member.getChargeCan("web") + member.getRewardCan("web")
|
||||
fixture.service.refundGiftCan(memberId = MEMBER_ID, useCanId = useCanId)
|
||||
val afterRefundCan = member.getChargeCan("web") + member.getRewardCan("web")
|
||||
|
||||
// Then: 발신자와 GIFT 용도가 저장되고, 잔액이 복원되며 재환불은 거부된다.
|
||||
assertEquals(CanUsage.GIFT, fixture.savedUseCan.canUsage)
|
||||
assertEquals(member, fixture.savedUseCan.member)
|
||||
assertEquals(INITIAL_CAN - GIFT_PRICE_CAN, afterSpendCan)
|
||||
assertEquals(INITIAL_CAN, afterRefundCan)
|
||||
assertEquals(true, fixture.savedUseCan.isRefund)
|
||||
assertThrows(SodaException::class.java) {
|
||||
fixture.service.refundGiftCan(memberId = MEMBER_ID, useCanId = useCanId)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("선물 신청은 가격과 약관 동의, 배송 기한을 스냅샷으로 저장하고 캔을 차감한다")
|
||||
fun shouldRegisterGiftWithPriceAgreementAndDeliverySnapshots() {
|
||||
// Given: 활성 선물 카테고리와 가격, 발신자 및 수신 크리에이터를 준비한다.
|
||||
val fixture = GiftApplicationFixture()
|
||||
|
||||
// When: 팬이 선물을 신청한다.
|
||||
val result = fixture.service.registerGift(fixture.sender, validGiftApplicationCommand())
|
||||
|
||||
// Then: 신청 정보와 배송 기한을 저장하고 판매가만큼 캔을 차감한다.
|
||||
assertEquals(APPLICATION_NO, result.applicationNo)
|
||||
assertEquals(GiftStatus.RECEIVED, result.status)
|
||||
assertEquals(GiftStatus.RECEIVED.displayName, result.statusName)
|
||||
assertEquals(SALE_PRICE_CAN, result.priceCan)
|
||||
assertEquals(NOW.plusDays(3), result.trackingDeadlineAt)
|
||||
assertEquals(SENDER_ID, fixture.savedGift.senderMemberId)
|
||||
assertEquals(RECIPIENT_ID, fixture.savedGift.recipientMemberId)
|
||||
assertEquals(GiftStatus.RECEIVED, fixture.savedGift.status)
|
||||
assertEquals(GiftSize.SMALL, fixture.savedGift.sizeCode)
|
||||
assertEquals(CATEGORY_ID, fixture.savedGift.categoryId)
|
||||
assertEquals("인형", fixture.savedGift.categoryNameSnapshot)
|
||||
assertEquals(SALE_PRICE_CAN, fixture.savedGift.salePriceCan)
|
||||
assertEquals(USE_CAN_ID, fixture.savedGift.canUsageId)
|
||||
assertEquals(true, fixture.savedGift.senderTermsAgreed)
|
||||
assertEquals(true, fixture.savedGift.senderPrivacyAgreed)
|
||||
assertEquals(NOW, fixture.savedGift.senderTermsAgreedAt)
|
||||
assertEquals(NOW, fixture.savedGift.senderPrivacyAgreedAt)
|
||||
assertEquals(true, fixture.savedGift.damageWaiverAgreed)
|
||||
assertEquals(GIFT_ID, fixture.savedDelivery.giftId)
|
||||
assertEquals("홍길동", fixture.savedDelivery.senderName)
|
||||
assertEquals("01012345678", fixture.savedDelivery.senderPhoneNumber)
|
||||
assertEquals("06234", fixture.savedDelivery.senderZipCode)
|
||||
assertEquals("서울시 강남구", fixture.savedDelivery.senderAddress)
|
||||
assertEquals("101동 1001호", fixture.savedDelivery.senderAddressDetail)
|
||||
assertEquals(NOW.plusDays(3), fixture.savedDelivery.trackingDeadlineAt)
|
||||
Mockito.verify(fixture.canPaymentService).spendGiftCan(SENDER_ID, SALE_PRICE_CAN, "aos")
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("보낸 팬은 운송장 등록 전 선물을 취소하고 전액 환불받는다")
|
||||
fun shouldCancelReceivedGiftAndRefundOnce() {
|
||||
// Given: 접수 상태의 선물과 발신자 배송 정보를 준비한다.
|
||||
val fixture = GiftApplicationFixture()
|
||||
fixture.prepareGiftForCancellation()
|
||||
|
||||
// When: 보낸 팬이 선물을 취소한다.
|
||||
val result = fixture.service.cancelGift(fixture.sender, APPLICATION_NO)
|
||||
|
||||
// Then: 선물과 배송 정보에 취소 시각을 저장하고 한 번만 환불한다.
|
||||
assertEquals(
|
||||
GiftCancellationResult(
|
||||
applicationNo = APPLICATION_NO,
|
||||
status = GiftStatus.CANCELED,
|
||||
statusName = GiftStatus.CANCELED.displayName,
|
||||
priceCan = SALE_PRICE_CAN,
|
||||
canceledAt = NOW
|
||||
),
|
||||
result
|
||||
)
|
||||
assertEquals(GiftStatus.CANCELED, fixture.cancellationGift.status)
|
||||
assertEquals(NOW, fixture.cancellationDelivery.canceledAt)
|
||||
Mockito.verify(fixture.canPaymentService).refundGiftCan(SENDER_ID, USE_CAN_ID)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("보낸 팬은 접수된 선물의 운송장을 등록하고 크리에이터 배송지 입력 기한을 생성한다")
|
||||
fun shouldRegisterTrackingForReceivedGift() {
|
||||
// Given: 접수 상태의 선물과 발신자 배송 정보를 준비한다.
|
||||
val fixture = GiftApplicationFixture()
|
||||
fixture.prepareGiftForTracking()
|
||||
|
||||
// When: 보낸 팬이 택배사와 운송장 번호를 등록한다.
|
||||
val result = fixture.service.registerTracking(
|
||||
fixture.sender,
|
||||
APPLICATION_NO,
|
||||
validGiftTrackingRegistrationCommand()
|
||||
)
|
||||
|
||||
// Then: 발송 확인 상태와 운송장 정보, 수신자 주소 입력 기한을 저장한다.
|
||||
assertEquals(
|
||||
GiftTrackingRegistrationResult(
|
||||
applicationNo = APPLICATION_NO,
|
||||
status = GiftStatus.TRACKING_REGISTERED,
|
||||
statusName = GiftStatus.TRACKING_REGISTERED.displayName,
|
||||
courierCompanyName = "CJ대한통운",
|
||||
trackingNumber = "1234567890",
|
||||
trackingRegisteredAt = NOW,
|
||||
recipientAddressDeadlineAt = NOW.plusDays(7)
|
||||
),
|
||||
result
|
||||
)
|
||||
assertEquals(GiftStatus.TRACKING_REGISTERED, fixture.trackingGift.status)
|
||||
assertEquals("CJ대한통운", fixture.trackingDelivery.courierCompanyName)
|
||||
assertEquals("1234567890", fixture.trackingDelivery.trackingNumber)
|
||||
assertEquals(NOW, fixture.trackingDelivery.trackingRegisteredAt)
|
||||
assertEquals(NOW.plusDays(7), fixture.trackingDelivery.recipientAddressDeadlineAt)
|
||||
Mockito.verify(fixture.giftPushService).sendTrackingRegisteredToRecipient(APPLICATION_NO, RECIPIENT_ID)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("recipientAddressRegistrableStatuses")
|
||||
@DisplayName("받는 크리에이터는 운송장 등록 이후 종료 전 상태에서 주소와 수령 동의를 등록한다")
|
||||
fun shouldRegisterRecipientAddressAndAgreements(status: GiftStatus) {
|
||||
// Given: 주소 입력이 허용된 선물과 받는 크리에이터를 준비한다.
|
||||
val fixture = GiftApplicationFixture()
|
||||
fixture.prepareGiftForRecipientAddress(status = status)
|
||||
|
||||
// When: 받는 크리에이터가 배송지와 수령 약관 동의를 등록한다.
|
||||
val result = fixture.service.registerRecipientAddress(
|
||||
fixture.creator,
|
||||
APPLICATION_NO,
|
||||
validGiftRecipientAddressRegistrationCommand()
|
||||
)
|
||||
|
||||
// Then: 주소 스냅샷과 두 동의 시각을 저장하고 현재 상태를 반환한다.
|
||||
assertEquals(
|
||||
GiftRecipientAddressRegistrationResult(
|
||||
applicationNo = APPLICATION_NO,
|
||||
status = status,
|
||||
statusName = status.displayName,
|
||||
recipientAddressRegisteredAt = NOW
|
||||
),
|
||||
result
|
||||
)
|
||||
assertEquals("김소다", fixture.recipientAddressDelivery.recipientName)
|
||||
assertEquals("01098765432", fixture.recipientAddressDelivery.recipientPhoneNumber)
|
||||
assertEquals("04524", fixture.recipientAddressDelivery.recipientZipCode)
|
||||
assertEquals("서울시 중구", fixture.recipientAddressDelivery.recipientAddress)
|
||||
assertEquals("202호", fixture.recipientAddressDelivery.recipientAddressDetail)
|
||||
assertEquals(true, fixture.recipientAddressGift.recipientTermsAgreed)
|
||||
assertEquals(true, fixture.recipientAddressGift.recipientPrivacyAgreed)
|
||||
assertEquals(NOW, fixture.recipientAddressGift.recipientTermsAgreedAt)
|
||||
assertEquals(NOW, fixture.recipientAddressGift.recipientPrivacyAgreedAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("받는 크리에이터는 검수 완료 선물의 수령을 확인하고 전달 완료 시각을 저장한다")
|
||||
fun shouldConfirmDeliveryForInspectionCompletedGift() {
|
||||
val fixture = GiftApplicationFixture()
|
||||
fixture.prepareGiftForDeliveryConfirmation()
|
||||
|
||||
val result = fixture.service.confirmDelivery(fixture.creator, APPLICATION_NO)
|
||||
|
||||
assertEquals(
|
||||
GiftDeliveryConfirmationResult(
|
||||
applicationNo = APPLICATION_NO,
|
||||
status = GiftStatus.DELIVERED,
|
||||
statusName = GiftStatus.DELIVERED.displayName,
|
||||
deliveredAt = NOW
|
||||
),
|
||||
result
|
||||
)
|
||||
assertEquals(GiftStatus.DELIVERED, fixture.deliveryConfirmationGift.status)
|
||||
assertEquals(NOW, fixture.deliveryConfirmationDelivery.deliveredAt)
|
||||
Mockito.verifyNoInteractions(fixture.giftPushService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("보낸 팬은 전달 완료 선물에 리뷰를 한 번 작성한다")
|
||||
fun shouldWriteReviewForDeliveredGiftBySender() {
|
||||
val fixture = GiftApplicationFixture()
|
||||
fixture.prepareGiftForReview()
|
||||
|
||||
val result = fixture.service.writeReview(fixture.sender, APPLICATION_NO, validGiftReviewCommand())
|
||||
|
||||
assertEquals(
|
||||
GiftReviewResult(
|
||||
reviewId = REVIEW_ID,
|
||||
applicationNo = APPLICATION_NO,
|
||||
rating = 5,
|
||||
keywords = listOf("배송이 빨라요", "안내가 친절해요"),
|
||||
comment = "선물 전달 과정이 만족스러웠습니다.",
|
||||
createdAt = NOW
|
||||
),
|
||||
result
|
||||
)
|
||||
assertEquals(GIFT_ID, fixture.savedReview.giftId)
|
||||
assertEquals(SENDER_ID, fixture.savedReview.senderMemberId)
|
||||
assertEquals(5, fixture.savedReview.rating)
|
||||
assertEquals("배송이 빨라요,안내가 친절해요", fixture.savedReview.keywords)
|
||||
assertEquals("선물 전달 과정이 만족스러웠습니다.", fixture.savedReview.comment)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("invalidGiftReviewScenarios")
|
||||
@DisplayName("리뷰 작성은 보낸 팬의 전달 완료 선물, 1회, 유효한 별점과 내용만 허용한다")
|
||||
fun shouldRejectInvalidGiftReview(scenario: InvalidGiftReviewScenario) {
|
||||
val fixture = GiftApplicationFixture()
|
||||
fixture.prepareGiftForReview(
|
||||
status = scenario.status,
|
||||
senderMemberId = scenario.senderMemberId,
|
||||
reviewExists = scenario.reviewExists
|
||||
)
|
||||
val requester = Member(password = "password", nickname = "requester").apply { id = scenario.requesterMemberId }
|
||||
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
fixture.service.writeReview(requester, APPLICATION_NO, scenario.command)
|
||||
}
|
||||
|
||||
assertEquals("common.error.invalid_request", exception.messageKey)
|
||||
Mockito.verify(fixture.reviewRepository, Mockito.never()).save(Mockito.any(GiftReview::class.java))
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("invalidGiftDeliveryConfirmationScenarios")
|
||||
@DisplayName("수령 확인은 받는 크리에이터의 검수 완료 선물에만 가능하고 배송 정보가 필요하다")
|
||||
fun shouldRejectInvalidGiftDeliveryConfirmation(scenario: InvalidGiftDeliveryConfirmationScenario) {
|
||||
val fixture = GiftApplicationFixture()
|
||||
fixture.prepareGiftForDeliveryConfirmation(
|
||||
status = scenario.status,
|
||||
recipientMemberId = scenario.recipientMemberId,
|
||||
deliveryExists = scenario.deliveryExists
|
||||
)
|
||||
val requester = Member(password = "password", nickname = "requester").apply { id = scenario.requesterMemberId }
|
||||
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
fixture.service.confirmDelivery(requester, APPLICATION_NO)
|
||||
}
|
||||
|
||||
assertEquals("common.error.invalid_request", exception.messageKey)
|
||||
assertEquals(scenario.status, fixture.deliveryConfirmationGift.status)
|
||||
assertEquals(null, fixture.deliveryConfirmationDelivery.deliveredAt)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("invalidGiftRecipientAddressRegistrationScenarios")
|
||||
@DisplayName("수령자 주소 등록은 받는 크리에이터, 허용 상태, 필수 주소와 두 동의가 필요하다")
|
||||
fun shouldRejectInvalidGiftRecipientAddressRegistration(scenario: InvalidGiftRecipientAddressRegistrationScenario) {
|
||||
// Given: 권한, 상태 또는 입력값이 유효하지 않은 선물을 준비한다.
|
||||
val fixture = GiftApplicationFixture()
|
||||
fixture.prepareGiftForRecipientAddress(
|
||||
status = scenario.status,
|
||||
recipientMemberId = scenario.recipientMemberId,
|
||||
deliveryExists = scenario.deliveryExists
|
||||
)
|
||||
val requester = Member(password = "password", nickname = "requester").apply { id = scenario.requesterMemberId }
|
||||
|
||||
// When: 배송지 등록을 요청한다.
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
fixture.service.registerRecipientAddress(requester, APPLICATION_NO, scenario.command)
|
||||
}
|
||||
|
||||
// Then: 공통 요청 오류를 반환하고 선물 상태와 수령 동의를 바꾸지 않는다.
|
||||
assertEquals("common.error.invalid_request", exception.messageKey)
|
||||
assertEquals(scenario.status, fixture.recipientAddressGift.status)
|
||||
assertEquals(false, fixture.recipientAddressGift.recipientTermsAgreed)
|
||||
assertEquals(false, fixture.recipientAddressGift.recipientPrivacyAgreed)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("invalidGiftTrackingRegistrationScenarios")
|
||||
@DisplayName("운송장 등록은 보낸 팬의 접수 상태에서 유효한 택배사와 운송장 번호가 있어야 한다")
|
||||
fun shouldRejectInvalidGiftTrackingRegistration(scenario: InvalidGiftTrackingRegistrationScenario) {
|
||||
// Given: 운송장 등록 권한, 상태, 입력값 또는 배송 정보가 유효하지 않은 선물을 준비한다.
|
||||
val fixture = GiftApplicationFixture()
|
||||
fixture.prepareGiftForTracking(scenario.status, scenario.senderMemberId, scenario.deliveryExists)
|
||||
val requester = Member(password = "password", nickname = "requester").apply { id = scenario.requesterMemberId }
|
||||
|
||||
// When: 운송장 등록을 요청한다.
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
fixture.service.registerTracking(requester, APPLICATION_NO, scenario.command)
|
||||
}
|
||||
|
||||
// Then: 공통 요청 오류를 반환하고 선물 상태를 바꾸지 않는다.
|
||||
assertEquals("common.error.invalid_request", exception.messageKey)
|
||||
assertEquals(scenario.status, fixture.trackingGift.status)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("invalidGiftCancellationScenarios")
|
||||
@DisplayName("선물 취소는 보낸 팬의 접수 상태에서만 가능하다")
|
||||
fun shouldRejectGiftCancellationForInvalidSenderOrStatus(scenario: InvalidGiftCancellationScenario) {
|
||||
// Given: 취소 권한 또는 상태가 유효하지 않은 선물을 준비한다.
|
||||
val fixture = GiftApplicationFixture()
|
||||
fixture.prepareGiftForCancellation(scenario.status, scenario.senderMemberId)
|
||||
val requester = Member(password = "password", nickname = "requester").apply { id = scenario.requesterMemberId }
|
||||
|
||||
// When: 취소를 요청한다.
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
fixture.service.cancelGift(requester, APPLICATION_NO)
|
||||
}
|
||||
|
||||
// Then: 환불하지 않고 공통 요청 오류를 반환한다.
|
||||
assertEquals("common.error.invalid_request", exception.messageKey)
|
||||
Mockito.verify(fixture.canPaymentService, Mockito.never()).refundGiftCan(
|
||||
Mockito.anyLong(),
|
||||
Mockito.anyLong()
|
||||
)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("invalidGiftApplicationScenarios")
|
||||
@DisplayName("선물 신청은 필수 발신자 정보, 동의, 활성 옵션 및 수신 크리에이터를 검증한다")
|
||||
fun shouldRejectInvalidGiftApplication(scenario: InvalidGiftApplicationScenario) {
|
||||
// Given: 하나의 신청 조건만 유효하지 않은 선물 신청을 준비한다.
|
||||
val fixture = GiftApplicationFixture()
|
||||
fixture.category.isActive = scenario.categoryActive
|
||||
fixture.category.requiresDamageWaiver = scenario.requiresDamageWaiver
|
||||
fixture.sizePrice.isActive = scenario.sizePriceActive
|
||||
fixture.setCreatorExists(scenario.creatorExists)
|
||||
|
||||
// When: 유효하지 않은 신청을 등록한다.
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
fixture.service.registerGift(fixture.sender, scenario.command)
|
||||
}
|
||||
|
||||
// Then: 캔을 차감하지 않고 공통 요청 오류를 반환한다.
|
||||
assertEquals("common.error.invalid_request", exception.messageKey)
|
||||
Mockito.verifyNoInteractions(fixture.canPaymentService)
|
||||
}
|
||||
|
||||
private class CanPaymentFixture(member: Member, charge: Charge) {
|
||||
private val canRepository = Mockito.mock(CanRepository::class.java)
|
||||
private val memberRepository = Mockito.mock(MemberRepository::class.java)
|
||||
private val chargeRepository = Mockito.mock(ChargeRepository::class.java)
|
||||
private val useCanRepository = Mockito.mock(UseCanRepository::class.java)
|
||||
private val useCanCalculateRepository = Mockito.mock(UseCanCalculateRepository::class.java)
|
||||
private val useCanCalculates = mutableListOf<UseCanCalculate>()
|
||||
lateinit var savedUseCan: UseCan
|
||||
private set
|
||||
|
||||
val service = CanPaymentService(
|
||||
repository = canRepository,
|
||||
memberRepository = memberRepository,
|
||||
chargeRepository = chargeRepository,
|
||||
useCanRepository = useCanRepository,
|
||||
useCanCalculateRepository = useCanCalculateRepository,
|
||||
messageSource = SodaMessageSource(),
|
||||
langContext = LangContext(),
|
||||
countryContext = CountryContext()
|
||||
)
|
||||
|
||||
init {
|
||||
Mockito.`when`(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.of(member))
|
||||
Mockito.`when`(
|
||||
chargeRepository.getOldestChargeWhereRewardCanGreaterThan0(0L, MEMBER_ID, "web")
|
||||
).thenReturn(null)
|
||||
Mockito.`when`(
|
||||
chargeRepository.getOldestChargeWhereChargeCanGreaterThan0(0L, MEMBER_ID, "web")
|
||||
).thenReturn(charge)
|
||||
Mockito.`when`(useCanRepository.save(Mockito.any(UseCan::class.java))).thenAnswer { invocation ->
|
||||
invocation.getArgument<UseCan>(0).apply {
|
||||
id = USE_CAN_ID
|
||||
savedUseCan = this
|
||||
}
|
||||
}
|
||||
Mockito.`when`(useCanRepository.findById(USE_CAN_ID)).thenAnswer { Optional.of(savedUseCan) }
|
||||
Mockito.`when`(useCanCalculateRepository.save(Mockito.any(UseCanCalculate::class.java))).thenAnswer {
|
||||
invocation ->
|
||||
invocation.getArgument<UseCanCalculate>(0).also(useCanCalculates::add)
|
||||
}
|
||||
Mockito.`when`(useCanCalculateRepository.findByUseCanIdAndStatus(USE_CAN_ID)).thenReturn(useCanCalculates)
|
||||
}
|
||||
}
|
||||
|
||||
private class GiftApplicationFixture {
|
||||
val giftRepository = Mockito.mock(GiftRepository::class.java)
|
||||
val deliveryRepository = Mockito.mock(GiftDeliveryRepository::class.java)
|
||||
val reviewRepository = Mockito.mock(GiftReviewRepository::class.java)
|
||||
val categoryRepository = Mockito.mock(GiftCategoryRepository::class.java)
|
||||
val sizePriceRepository = Mockito.mock(GiftSizePriceRepository::class.java)
|
||||
val memberRepository = Mockito.mock(MemberRepository::class.java)
|
||||
val applicationNoGenerator = Mockito.mock(GiftApplicationNoGenerator::class.java)
|
||||
val canPaymentService = Mockito.mock(CanPaymentService::class.java)
|
||||
val giftPushService = Mockito.mock(GiftPushService::class.java)
|
||||
val sender = Member(password = "password", nickname = "sender", container = "aos").apply { id = SENDER_ID }
|
||||
val creator = Member(password = "password", nickname = "creator").apply { id = RECIPIENT_ID }
|
||||
val category = GiftCategory(
|
||||
classificationNumber = "100",
|
||||
categoryCode = "DOLL",
|
||||
name = "인형",
|
||||
receiptCode = "A",
|
||||
representativeItem = "피규어",
|
||||
requiresDamageWaiver = false,
|
||||
isActive = true
|
||||
).apply { id = CATEGORY_ID }
|
||||
val sizePrice = GiftSizePrice(GiftSize.SMALL, BASE_PRICE_CAN, SALE_PRICE_CAN, true)
|
||||
lateinit var savedGift: Gift
|
||||
lateinit var savedDelivery: GiftDelivery
|
||||
lateinit var cancellationGift: Gift
|
||||
lateinit var cancellationDelivery: GiftDelivery
|
||||
lateinit var trackingGift: Gift
|
||||
lateinit var trackingDelivery: GiftDelivery
|
||||
lateinit var recipientAddressGift: Gift
|
||||
lateinit var recipientAddressDelivery: GiftDelivery
|
||||
lateinit var deliveryConfirmationGift: Gift
|
||||
lateinit var deliveryConfirmationDelivery: GiftDelivery
|
||||
lateinit var reviewGift: Gift
|
||||
lateinit var savedReview: GiftReview
|
||||
|
||||
val service = GiftCommandService(
|
||||
giftRepository = giftRepository,
|
||||
deliveryRepository = deliveryRepository,
|
||||
reviewRepository = reviewRepository,
|
||||
categoryRepository = categoryRepository,
|
||||
sizePriceRepository = sizePriceRepository,
|
||||
memberRepository = memberRepository,
|
||||
applicationNoGenerator = applicationNoGenerator,
|
||||
canPaymentService = canPaymentService,
|
||||
giftPushService = giftPushService,
|
||||
now = { NOW }
|
||||
)
|
||||
|
||||
init {
|
||||
setCreatorExists(true)
|
||||
Mockito.`when`(categoryRepository.findById(CATEGORY_ID)).thenReturn(Optional.of(category))
|
||||
Mockito.`when`(sizePriceRepository.findBySizeCode(GiftSize.SMALL)).thenReturn(sizePrice)
|
||||
Mockito.`when`(applicationNoGenerator.generate(category, NOW)).thenReturn(APPLICATION_NO)
|
||||
Mockito.`when`(canPaymentService.spendGiftCan(SENDER_ID, SALE_PRICE_CAN, "aos")).thenReturn(USE_CAN_ID)
|
||||
Mockito.`when`(giftRepository.save(Mockito.any(Gift::class.java))).thenAnswer { invocation ->
|
||||
invocation.getArgument<Gift>(0).apply {
|
||||
id = GIFT_ID
|
||||
savedGift = this
|
||||
}
|
||||
}
|
||||
Mockito.`when`(deliveryRepository.save(Mockito.any(GiftDelivery::class.java))).thenAnswer { invocation ->
|
||||
invocation.getArgument<GiftDelivery>(0).also { savedDelivery = it }
|
||||
}
|
||||
Mockito.`when`(reviewRepository.save(Mockito.any(GiftReview::class.java))).thenAnswer { invocation ->
|
||||
invocation.getArgument<GiftReview>(0).apply {
|
||||
id = REVIEW_ID
|
||||
createdAt = NOW
|
||||
savedReview = this
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setCreatorExists(exists: Boolean) {
|
||||
Mockito.`when`(memberRepository.findCreatorByIdOrNull(RECIPIENT_ID)).thenReturn(creator.takeIf { exists })
|
||||
}
|
||||
|
||||
fun prepareGiftForCancellation(
|
||||
status: GiftStatus = GiftStatus.RECEIVED,
|
||||
senderMemberId: Long = SENDER_ID
|
||||
) {
|
||||
cancellationGift = Gift(
|
||||
applicationNo = APPLICATION_NO,
|
||||
senderMemberId = senderMemberId,
|
||||
recipientMemberId = RECIPIENT_ID,
|
||||
status = status,
|
||||
sizeCode = GiftSize.SMALL,
|
||||
categoryId = CATEGORY_ID,
|
||||
categoryNameSnapshot = "인형",
|
||||
salePriceCan = SALE_PRICE_CAN,
|
||||
canUsageId = USE_CAN_ID,
|
||||
senderTermsAgreed = true,
|
||||
senderPrivacyAgreed = true,
|
||||
senderTermsAgreedAt = NOW,
|
||||
senderPrivacyAgreedAt = NOW,
|
||||
damageWaiverAgreed = true
|
||||
).apply { id = GIFT_ID }
|
||||
cancellationDelivery = GiftDelivery(
|
||||
giftId = GIFT_ID,
|
||||
senderName = "홍길동",
|
||||
senderPhoneNumber = "01012345678",
|
||||
senderZipCode = "06234",
|
||||
senderAddress = "서울시 강남구",
|
||||
trackingDeadlineAt = NOW.plusDays(3)
|
||||
)
|
||||
Mockito.`when`(giftRepository.findByApplicationNo(APPLICATION_NO)).thenReturn(cancellationGift)
|
||||
Mockito.`when`(deliveryRepository.findByGiftId(GIFT_ID)).thenReturn(cancellationDelivery)
|
||||
}
|
||||
|
||||
fun prepareGiftForTracking(
|
||||
status: GiftStatus = GiftStatus.RECEIVED,
|
||||
senderMemberId: Long = SENDER_ID,
|
||||
deliveryExists: Boolean = true
|
||||
) {
|
||||
trackingGift = Gift(
|
||||
applicationNo = APPLICATION_NO,
|
||||
senderMemberId = senderMemberId,
|
||||
recipientMemberId = RECIPIENT_ID,
|
||||
status = status,
|
||||
sizeCode = GiftSize.SMALL,
|
||||
categoryId = CATEGORY_ID,
|
||||
categoryNameSnapshot = "인형",
|
||||
salePriceCan = SALE_PRICE_CAN,
|
||||
canUsageId = USE_CAN_ID,
|
||||
senderTermsAgreed = true,
|
||||
senderPrivacyAgreed = true,
|
||||
senderTermsAgreedAt = NOW,
|
||||
senderPrivacyAgreedAt = NOW,
|
||||
damageWaiverAgreed = true
|
||||
).apply { id = GIFT_ID }
|
||||
trackingDelivery = GiftDelivery(
|
||||
giftId = GIFT_ID,
|
||||
senderName = "홍길동",
|
||||
senderPhoneNumber = "01012345678",
|
||||
senderZipCode = "06234",
|
||||
senderAddress = "서울시 강남구",
|
||||
trackingDeadlineAt = NOW.plusDays(3)
|
||||
)
|
||||
Mockito.`when`(giftRepository.findByApplicationNo(APPLICATION_NO)).thenReturn(trackingGift)
|
||||
Mockito.`when`(deliveryRepository.findByGiftId(GIFT_ID)).thenReturn(
|
||||
trackingDelivery.takeIf { deliveryExists }
|
||||
)
|
||||
}
|
||||
|
||||
fun prepareGiftForRecipientAddress(
|
||||
status: GiftStatus = GiftStatus.TRACKING_REGISTERED,
|
||||
recipientMemberId: Long = RECIPIENT_ID,
|
||||
deliveryExists: Boolean = true
|
||||
) {
|
||||
recipientAddressGift = Gift(
|
||||
applicationNo = APPLICATION_NO,
|
||||
senderMemberId = SENDER_ID,
|
||||
recipientMemberId = recipientMemberId,
|
||||
status = status,
|
||||
sizeCode = GiftSize.SMALL,
|
||||
categoryId = CATEGORY_ID,
|
||||
categoryNameSnapshot = "인형",
|
||||
salePriceCan = SALE_PRICE_CAN,
|
||||
canUsageId = USE_CAN_ID,
|
||||
senderTermsAgreed = true,
|
||||
senderPrivacyAgreed = true,
|
||||
senderTermsAgreedAt = NOW,
|
||||
senderPrivacyAgreedAt = NOW,
|
||||
damageWaiverAgreed = true
|
||||
).apply { id = GIFT_ID }
|
||||
recipientAddressDelivery = GiftDelivery(
|
||||
giftId = GIFT_ID,
|
||||
senderName = "홍길동",
|
||||
senderPhoneNumber = "01012345678",
|
||||
senderZipCode = "06234",
|
||||
senderAddress = "서울시 강남구",
|
||||
trackingDeadlineAt = NOW.plusDays(3)
|
||||
)
|
||||
Mockito.`when`(giftRepository.findByApplicationNo(APPLICATION_NO)).thenReturn(recipientAddressGift)
|
||||
Mockito.`when`(deliveryRepository.findByGiftId(GIFT_ID)).thenReturn(
|
||||
recipientAddressDelivery.takeIf { deliveryExists }
|
||||
)
|
||||
}
|
||||
|
||||
fun prepareGiftForDeliveryConfirmation(
|
||||
status: GiftStatus = GiftStatus.INSPECTION_COMPLETED,
|
||||
recipientMemberId: Long = RECIPIENT_ID,
|
||||
deliveryExists: Boolean = true
|
||||
) {
|
||||
deliveryConfirmationGift = Gift(
|
||||
applicationNo = APPLICATION_NO,
|
||||
senderMemberId = SENDER_ID,
|
||||
recipientMemberId = recipientMemberId,
|
||||
status = status,
|
||||
sizeCode = GiftSize.SMALL,
|
||||
categoryId = CATEGORY_ID,
|
||||
categoryNameSnapshot = "인형",
|
||||
salePriceCan = SALE_PRICE_CAN,
|
||||
canUsageId = USE_CAN_ID,
|
||||
senderTermsAgreed = true,
|
||||
senderPrivacyAgreed = true,
|
||||
senderTermsAgreedAt = NOW,
|
||||
senderPrivacyAgreedAt = NOW,
|
||||
damageWaiverAgreed = true
|
||||
).apply { id = GIFT_ID }
|
||||
deliveryConfirmationDelivery = GiftDelivery(
|
||||
giftId = GIFT_ID,
|
||||
senderName = "홍길동",
|
||||
senderPhoneNumber = "01012345678",
|
||||
senderZipCode = "06234",
|
||||
senderAddress = "서울시 강남구",
|
||||
trackingDeadlineAt = NOW.plusDays(3)
|
||||
)
|
||||
Mockito.`when`(giftRepository.findByApplicationNo(APPLICATION_NO)).thenReturn(deliveryConfirmationGift)
|
||||
Mockito.`when`(deliveryRepository.findByGiftId(GIFT_ID)).thenReturn(
|
||||
deliveryConfirmationDelivery.takeIf { deliveryExists }
|
||||
)
|
||||
}
|
||||
|
||||
fun prepareGiftForReview(
|
||||
status: GiftStatus = GiftStatus.DELIVERED,
|
||||
senderMemberId: Long = SENDER_ID,
|
||||
reviewExists: Boolean = false
|
||||
) {
|
||||
reviewGift = Gift(
|
||||
applicationNo = APPLICATION_NO,
|
||||
senderMemberId = senderMemberId,
|
||||
recipientMemberId = RECIPIENT_ID,
|
||||
status = status,
|
||||
sizeCode = GiftSize.SMALL,
|
||||
categoryId = CATEGORY_ID,
|
||||
categoryNameSnapshot = "인형",
|
||||
salePriceCan = SALE_PRICE_CAN,
|
||||
canUsageId = USE_CAN_ID,
|
||||
senderTermsAgreed = true,
|
||||
senderPrivacyAgreed = true,
|
||||
senderTermsAgreedAt = NOW,
|
||||
senderPrivacyAgreedAt = NOW,
|
||||
damageWaiverAgreed = true
|
||||
).apply { id = GIFT_ID }
|
||||
Mockito.`when`(giftRepository.findByApplicationNo(APPLICATION_NO)).thenReturn(reviewGift)
|
||||
Mockito.`when`(reviewRepository.findByGiftId(GIFT_ID)).thenReturn(
|
||||
GiftReview(GIFT_ID, SENDER_ID, 5).takeIf { reviewExists }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class InvalidGiftApplicationScenario(
|
||||
val command: GiftApplicationCommand,
|
||||
val categoryActive: Boolean = true,
|
||||
val requiresDamageWaiver: Boolean = false,
|
||||
val sizePriceActive: Boolean = true,
|
||||
val creatorExists: Boolean = true
|
||||
)
|
||||
|
||||
data class InvalidGiftCancellationScenario(
|
||||
val requesterMemberId: Long,
|
||||
val senderMemberId: Long,
|
||||
val status: GiftStatus
|
||||
)
|
||||
|
||||
data class InvalidGiftTrackingRegistrationScenario(
|
||||
val requesterMemberId: Long = SENDER_ID,
|
||||
val senderMemberId: Long = SENDER_ID,
|
||||
val status: GiftStatus = GiftStatus.RECEIVED,
|
||||
val command: GiftTrackingRegistrationCommand = validGiftTrackingRegistrationCommand(),
|
||||
val deliveryExists: Boolean = true
|
||||
)
|
||||
|
||||
data class InvalidGiftRecipientAddressRegistrationScenario(
|
||||
val requesterMemberId: Long = RECIPIENT_ID,
|
||||
val recipientMemberId: Long = RECIPIENT_ID,
|
||||
val status: GiftStatus = GiftStatus.TRACKING_REGISTERED,
|
||||
val command: GiftRecipientAddressRegistrationCommand = validGiftRecipientAddressRegistrationCommand(),
|
||||
val deliveryExists: Boolean = true
|
||||
)
|
||||
|
||||
data class InvalidGiftDeliveryConfirmationScenario(
|
||||
val requesterMemberId: Long = RECIPIENT_ID,
|
||||
val recipientMemberId: Long = RECIPIENT_ID,
|
||||
val status: GiftStatus = GiftStatus.INSPECTION_COMPLETED,
|
||||
val deliveryExists: Boolean = true
|
||||
)
|
||||
|
||||
data class InvalidGiftReviewScenario(
|
||||
val requesterMemberId: Long = SENDER_ID,
|
||||
val senderMemberId: Long = SENDER_ID,
|
||||
val status: GiftStatus = GiftStatus.DELIVERED,
|
||||
val command: GiftReviewCommand = validGiftReviewCommand(),
|
||||
val reviewExists: Boolean = false
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val MEMBER_ID = 1L
|
||||
const val CHARGE_ID = 10L
|
||||
const val USE_CAN_ID = 100L
|
||||
const val INITIAL_CAN = 100
|
||||
const val GIFT_PRICE_CAN = 30
|
||||
const val SENDER_ID = 2L
|
||||
const val OTHER_MEMBER_ID = 4L
|
||||
const val RECIPIENT_ID = 3L
|
||||
const val CATEGORY_ID = 4L
|
||||
const val GIFT_ID = 5L
|
||||
const val REVIEW_ID = 6L
|
||||
const val APPLICATION_NO = "G20260929000001"
|
||||
const val BASE_PRICE_CAN = 100
|
||||
const val SALE_PRICE_CAN = 80
|
||||
val NOW: LocalDateTime = LocalDateTime.of(2026, 9, 29, 12, 0)
|
||||
|
||||
@JvmStatic
|
||||
fun invalidGiftApplicationScenarios() = listOf(
|
||||
InvalidGiftApplicationScenario(validGiftApplicationCommand().copy(senderName = "")),
|
||||
InvalidGiftApplicationScenario(validGiftApplicationCommand().copy(senderPhoneNumber = "")),
|
||||
InvalidGiftApplicationScenario(validGiftApplicationCommand().copy(senderZipCode = "")),
|
||||
InvalidGiftApplicationScenario(validGiftApplicationCommand().copy(senderAddress = "")),
|
||||
InvalidGiftApplicationScenario(validGiftApplicationCommand().copy(senderTermsAgreed = false)),
|
||||
InvalidGiftApplicationScenario(validGiftApplicationCommand().copy(senderPrivacyAgreed = false)),
|
||||
InvalidGiftApplicationScenario(validGiftApplicationCommand(), categoryActive = false),
|
||||
InvalidGiftApplicationScenario(
|
||||
validGiftApplicationCommand().copy(damageWaiverAgreed = false),
|
||||
requiresDamageWaiver = true
|
||||
),
|
||||
InvalidGiftApplicationScenario(validGiftApplicationCommand(), sizePriceActive = false),
|
||||
InvalidGiftApplicationScenario(validGiftApplicationCommand(), creatorExists = false)
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
fun invalidGiftCancellationScenarios() = listOf(
|
||||
InvalidGiftCancellationScenario(OTHER_MEMBER_ID, SENDER_ID, GiftStatus.RECEIVED),
|
||||
InvalidGiftCancellationScenario(SENDER_ID, SENDER_ID, GiftStatus.TRACKING_REGISTERED)
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
fun invalidGiftTrackingRegistrationScenarios() = listOf(
|
||||
InvalidGiftTrackingRegistrationScenario(requesterMemberId = OTHER_MEMBER_ID),
|
||||
InvalidGiftTrackingRegistrationScenario(status = GiftStatus.TRACKING_REGISTERED),
|
||||
InvalidGiftTrackingRegistrationScenario(
|
||||
command = validGiftTrackingRegistrationCommand().copy(courierCompanyName = "")
|
||||
),
|
||||
InvalidGiftTrackingRegistrationScenario(
|
||||
command = validGiftTrackingRegistrationCommand().copy(courierCompanyName = "a".repeat(51))
|
||||
),
|
||||
InvalidGiftTrackingRegistrationScenario(
|
||||
command = validGiftTrackingRegistrationCommand().copy(trackingNumber = "")
|
||||
),
|
||||
InvalidGiftTrackingRegistrationScenario(deliveryExists = false)
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
fun recipientAddressRegistrableStatuses() = listOf(
|
||||
GiftStatus.TRACKING_REGISTERED,
|
||||
GiftStatus.ARRIVED_AT_MAILBOX,
|
||||
GiftStatus.INSPECTION_COMPLETED
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
fun invalidGiftRecipientAddressRegistrationScenarios() = listOf(
|
||||
InvalidGiftRecipientAddressRegistrationScenario(requesterMemberId = OTHER_MEMBER_ID),
|
||||
InvalidGiftRecipientAddressRegistrationScenario(status = GiftStatus.RECEIVED),
|
||||
InvalidGiftRecipientAddressRegistrationScenario(status = GiftStatus.DELIVERED),
|
||||
InvalidGiftRecipientAddressRegistrationScenario(status = GiftStatus.UNDELIVERABLE),
|
||||
InvalidGiftRecipientAddressRegistrationScenario(status = GiftStatus.CANCELED),
|
||||
InvalidGiftRecipientAddressRegistrationScenario(
|
||||
command = validGiftRecipientAddressRegistrationCommand().copy(name = "")
|
||||
),
|
||||
InvalidGiftRecipientAddressRegistrationScenario(
|
||||
command = validGiftRecipientAddressRegistrationCommand().copy(phoneNumber = "")
|
||||
),
|
||||
InvalidGiftRecipientAddressRegistrationScenario(
|
||||
command = validGiftRecipientAddressRegistrationCommand().copy(zipCode = "")
|
||||
),
|
||||
InvalidGiftRecipientAddressRegistrationScenario(
|
||||
command = validGiftRecipientAddressRegistrationCommand().copy(address = "")
|
||||
),
|
||||
InvalidGiftRecipientAddressRegistrationScenario(
|
||||
command = validGiftRecipientAddressRegistrationCommand().copy(recipientTermsAgreed = false)
|
||||
),
|
||||
InvalidGiftRecipientAddressRegistrationScenario(
|
||||
command = validGiftRecipientAddressRegistrationCommand().copy(recipientPrivacyAgreed = false)
|
||||
),
|
||||
InvalidGiftRecipientAddressRegistrationScenario(deliveryExists = false)
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
fun invalidGiftDeliveryConfirmationScenarios() = listOf(
|
||||
InvalidGiftDeliveryConfirmationScenario(requesterMemberId = OTHER_MEMBER_ID),
|
||||
InvalidGiftDeliveryConfirmationScenario(status = GiftStatus.TRACKING_REGISTERED),
|
||||
InvalidGiftDeliveryConfirmationScenario(status = GiftStatus.DELIVERED),
|
||||
InvalidGiftDeliveryConfirmationScenario(deliveryExists = false)
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
fun invalidGiftReviewScenarios() = listOf(
|
||||
InvalidGiftReviewScenario(requesterMemberId = OTHER_MEMBER_ID),
|
||||
InvalidGiftReviewScenario(status = GiftStatus.INSPECTION_COMPLETED),
|
||||
InvalidGiftReviewScenario(reviewExists = true),
|
||||
InvalidGiftReviewScenario(command = validGiftReviewCommand().copy(rating = 0)),
|
||||
InvalidGiftReviewScenario(command = validGiftReviewCommand().copy(rating = 6)),
|
||||
InvalidGiftReviewScenario(command = validGiftReviewCommand().copy(keywords = listOf("a".repeat(256)))),
|
||||
InvalidGiftReviewScenario(command = validGiftReviewCommand().copy(comment = "a".repeat(256)))
|
||||
)
|
||||
|
||||
fun validGiftApplicationCommand() = GiftApplicationCommand(
|
||||
recipientMemberId = RECIPIENT_ID,
|
||||
senderName = "홍길동",
|
||||
senderPhoneNumber = "01012345678",
|
||||
senderZipCode = "06234",
|
||||
senderAddress = "서울시 강남구",
|
||||
senderAddressDetail = "101동 1001호",
|
||||
sizeCode = GiftSize.SMALL,
|
||||
categoryId = CATEGORY_ID,
|
||||
senderTermsAgreed = true,
|
||||
senderPrivacyAgreed = true,
|
||||
damageWaiverAgreed = true
|
||||
)
|
||||
|
||||
fun validGiftTrackingRegistrationCommand() = GiftTrackingRegistrationCommand(
|
||||
courierCompanyName = "CJ대한통운",
|
||||
trackingNumber = "1234567890"
|
||||
)
|
||||
|
||||
fun validGiftRecipientAddressRegistrationCommand() = GiftRecipientAddressRegistrationCommand(
|
||||
name = "김소다",
|
||||
phoneNumber = "01098765432",
|
||||
zipCode = "04524",
|
||||
address = "서울시 중구",
|
||||
addressDetail = "202호",
|
||||
recipientTermsAgreed = true,
|
||||
recipientPrivacyAgreed = true
|
||||
)
|
||||
|
||||
fun validGiftReviewCommand() = GiftReviewCommand(
|
||||
rating = 5,
|
||||
keywords = listOf("배송이 빨라요", "안내가 친절해요"),
|
||||
comment = "선물 전달 과정이 만족스러웠습니다."
|
||||
)
|
||||
}
|
||||
}
|
||||
-156
@@ -1,156 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence
|
||||
|
||||
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||
import org.springframework.context.annotation.Import
|
||||
import org.springframework.dao.DataIntegrityViolationException
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@DataJpaTest(
|
||||
properties = [
|
||||
"spring.cache.type=none",
|
||||
"spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE"
|
||||
]
|
||||
)
|
||||
@Import(QueryDslConfig::class)
|
||||
@DisplayName("선물 엔티티 매핑 테스트")
|
||||
class GiftEntityMappingTest @Autowired constructor(
|
||||
private val giftRepository: GiftRepository,
|
||||
private val giftDeliveryRepository: GiftDeliveryRepository,
|
||||
private val giftCategoryRepository: GiftCategoryRepository,
|
||||
private val giftSizePriceRepository: GiftSizePriceRepository,
|
||||
private val giftReviewRepository: GiftReviewRepository,
|
||||
private val giftApplicationNoSequenceRepository: GiftApplicationNoSequenceRepository
|
||||
) {
|
||||
@Test
|
||||
@DisplayName("선물 엔티티와 배송 정보를 저장하고 조회한다")
|
||||
fun shouldSaveAndFindGiftWithDelivery() {
|
||||
val category = giftCategoryRepository.save(
|
||||
GiftCategory(
|
||||
classificationNumber = "100",
|
||||
categoryCode = "DOLL",
|
||||
name = "인형",
|
||||
receiptCode = "A",
|
||||
representativeItem = "피규어",
|
||||
requiresDamageWaiver = true
|
||||
)
|
||||
)
|
||||
giftSizePriceRepository.save(
|
||||
GiftSizePrice(
|
||||
sizeCode = GiftSize.SMALL,
|
||||
basePriceCan = 100,
|
||||
salePriceCan = 80
|
||||
)
|
||||
)
|
||||
val gift = giftRepository.save(
|
||||
Gift(
|
||||
applicationNo = "G20260929000001",
|
||||
senderMemberId = 1L,
|
||||
recipientMemberId = 2L,
|
||||
status = GiftStatus.RECEIVED,
|
||||
sizeCode = GiftSize.SMALL,
|
||||
categoryId = category.id!!,
|
||||
categoryNameSnapshot = category.name,
|
||||
salePriceCan = 80,
|
||||
canUsageId = 10L,
|
||||
senderTermsAgreed = true,
|
||||
senderPrivacyAgreed = true,
|
||||
senderTermsAgreedAt = LocalDateTime.of(2026, 9, 29, 12, 0),
|
||||
senderPrivacyAgreedAt = LocalDateTime.of(2026, 9, 29, 12, 0),
|
||||
damageWaiverAgreed = true
|
||||
)
|
||||
)
|
||||
|
||||
giftDeliveryRepository.save(
|
||||
GiftDelivery(
|
||||
giftId = gift.id!!,
|
||||
senderName = "홍길동",
|
||||
senderPhoneNumber = "01012345678",
|
||||
senderZipCode = "06234",
|
||||
senderAddress = "서울시 강남구",
|
||||
senderAddressDetail = "101동 1001호",
|
||||
trackingDeadlineAt = LocalDateTime.of(2026, 10, 2, 12, 0)
|
||||
)
|
||||
)
|
||||
|
||||
val saved = giftRepository.findByApplicationNo("G20260929000001")
|
||||
val delivery = giftDeliveryRepository.findByGiftId(gift.id!!)
|
||||
|
||||
assertNotNull(saved)
|
||||
assertEquals(GiftStatus.RECEIVED, saved!!.status)
|
||||
assertEquals("홍길동", delivery!!.senderName)
|
||||
assertNotNull(saved.createdAt)
|
||||
assertNotNull(saved.updatedAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("신청번호와 카테고리 unique 값과 리뷰 선물 ID는 중복 저장할 수 없다")
|
||||
fun shouldRejectDuplicateUniqueValues() {
|
||||
val category = giftCategoryRepository.save(giftCategory("100", "DOLL", "A"))
|
||||
val gift = giftRepository.save(gift(category.id!!, "G20260929000001"))
|
||||
giftRepository.saveAndFlush(gift(category.id!!, "G20260929000002"))
|
||||
giftReviewRepository.saveAndFlush(GiftReview(giftId = gift.id!!, senderMemberId = 1L, rating = 5))
|
||||
|
||||
assertThrows(DataIntegrityViolationException::class.java) {
|
||||
giftRepository.saveAndFlush(gift(category.id!!, "G20260929000001"))
|
||||
}
|
||||
assertThrows(DataIntegrityViolationException::class.java) {
|
||||
giftCategoryRepository.saveAndFlush(giftCategory("101", "DOLL", "B"))
|
||||
}
|
||||
assertThrows(DataIntegrityViolationException::class.java) {
|
||||
giftCategoryRepository.saveAndFlush(giftCategory("100", "TOY", "B"))
|
||||
}
|
||||
assertThrows(DataIntegrityViolationException::class.java) {
|
||||
giftCategoryRepository.saveAndFlush(giftCategory("101", "TOY", "A"))
|
||||
}
|
||||
assertThrows(DataIntegrityViolationException::class.java) {
|
||||
giftReviewRepository.saveAndFlush(GiftReview(giftId = gift.id!!, senderMemberId = 1L, rating = 4))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("카테고리와 날짜별 신청번호 채번 sequence를 저장한다")
|
||||
fun shouldSaveApplicationNoSequence() {
|
||||
val category = giftCategoryRepository.save(giftCategory("100", "DOLL", "A"))
|
||||
val sequence = giftApplicationNoSequenceRepository.save(
|
||||
GiftApplicationNoSequence(categoryId = category.id!!, sequenceDate = "260929", lastSequence = 1)
|
||||
)
|
||||
|
||||
assertEquals(category.id, sequence.categoryId)
|
||||
assertEquals("260929", sequence.sequenceDate)
|
||||
assertEquals(1, sequence.lastSequence)
|
||||
}
|
||||
|
||||
private fun gift(categoryId: Long, applicationNo: String) = Gift(
|
||||
applicationNo = applicationNo,
|
||||
senderMemberId = 1L,
|
||||
recipientMemberId = 2L,
|
||||
status = GiftStatus.RECEIVED,
|
||||
sizeCode = GiftSize.SMALL,
|
||||
categoryId = categoryId,
|
||||
categoryNameSnapshot = "인형",
|
||||
salePriceCan = 80,
|
||||
canUsageId = 10L,
|
||||
senderTermsAgreed = true,
|
||||
senderPrivacyAgreed = true,
|
||||
senderTermsAgreedAt = LocalDateTime.of(2026, 9, 29, 12, 0),
|
||||
senderPrivacyAgreedAt = LocalDateTime.of(2026, 9, 29, 12, 0),
|
||||
damageWaiverAgreed = true
|
||||
)
|
||||
|
||||
private fun giftCategory(classificationNumber: String, categoryCode: String, receiptCode: String) = GiftCategory(
|
||||
classificationNumber = classificationNumber,
|
||||
categoryCode = categoryCode,
|
||||
name = "인형",
|
||||
receiptCode = receiptCode,
|
||||
representativeItem = "피규어"
|
||||
)
|
||||
}
|
||||
-287
@@ -1,287 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.adapter.out.scheduler
|
||||
|
||||
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
|
||||
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.application.GiftPushService
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import org.redisson.api.RedissonClient
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||
import org.springframework.context.annotation.Import
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@DataJpaTest(
|
||||
properties = [
|
||||
"spring.cache.type=none",
|
||||
"spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE"
|
||||
]
|
||||
)
|
||||
@Import(QueryDslConfig::class)
|
||||
@DisplayName("선물 자동처리 스케줄러 테스트")
|
||||
class GiftSchedulerTest @Autowired constructor(
|
||||
private val giftRepository: GiftRepository,
|
||||
private val giftDeliveryRepository: GiftDeliveryRepository
|
||||
) {
|
||||
private val canPaymentService = Mockito.mock(CanPaymentService::class.java)
|
||||
private val giftPushService = Mockito.mock(GiftPushService::class.java)
|
||||
private val scheduler = GiftScheduler(
|
||||
giftRepository,
|
||||
giftDeliveryRepository,
|
||||
canPaymentService,
|
||||
giftPushService,
|
||||
Mockito.mock(RedissonClient::class.java)
|
||||
)
|
||||
|
||||
@Test
|
||||
@DisplayName("운송장 등록 기한이 지난 접수 선물을 취소하고 한 번 환불한다")
|
||||
fun shouldCancelOverdueReceivedGiftAndRefundOnce() {
|
||||
val now = LocalDateTime.of(2026, 10, 2, 12, 0)
|
||||
val (gift, delivery) = saveGift(GiftStatus.RECEIVED, now.minusSeconds(1))
|
||||
|
||||
val canceledCount = scheduler.cancelExpiredTrackingRegistrationGifts(now)
|
||||
|
||||
assertEquals(1, canceledCount)
|
||||
assertEquals(GiftStatus.CANCELED, gift.status)
|
||||
assertEquals(now, delivery.canceledAt)
|
||||
Mockito.verify(canPaymentService).refundGiftCan(SENDER_MEMBER_ID, CAN_USAGE_ID)
|
||||
Mockito.verify(giftPushService).sendAutoCanceledToSender(APPLICATION_NO, SENDER_MEMBER_ID)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("운송장 등록 기한과 같은 접수 선물은 취소하지 않는다")
|
||||
fun shouldIgnoreReceivedGiftWhoseTrackingDeadlineIsNotPast() {
|
||||
val now = LocalDateTime.of(2026, 10, 2, 12, 0)
|
||||
val (gift, delivery) = saveGift(GiftStatus.RECEIVED, now)
|
||||
|
||||
val canceledCount = scheduler.cancelExpiredTrackingRegistrationGifts(now)
|
||||
|
||||
assertEquals(0, canceledCount)
|
||||
assertEquals(GiftStatus.RECEIVED, gift.status)
|
||||
assertEquals(null, delivery.canceledAt)
|
||||
Mockito.verifyNoInteractions(canPaymentService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("운송장 등록 기한이 지나도 접수 상태가 아닌 선물은 취소하지 않는다")
|
||||
fun shouldIgnoreOverdueGiftThatIsNotReceived() {
|
||||
val now = LocalDateTime.of(2026, 10, 2, 12, 0)
|
||||
val (gift, delivery) = saveGift(GiftStatus.TRACKING_REGISTERED, now.minusSeconds(1))
|
||||
|
||||
val canceledCount = scheduler.cancelExpiredTrackingRegistrationGifts(now)
|
||||
|
||||
assertEquals(0, canceledCount)
|
||||
assertEquals(GiftStatus.TRACKING_REGISTERED, gift.status)
|
||||
assertEquals(null, delivery.canceledAt)
|
||||
Mockito.verifyNoInteractions(canPaymentService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("자동취소를 다시 실행해도 이미 취소된 선물은 환불하지 않는다")
|
||||
fun shouldNotRefundAlreadyCanceledGiftOnSecondRun() {
|
||||
val now = LocalDateTime.of(2026, 10, 2, 12, 0)
|
||||
saveGift(GiftStatus.RECEIVED, now.minusSeconds(1))
|
||||
|
||||
val firstCanceledCount = scheduler.cancelExpiredTrackingRegistrationGifts(now)
|
||||
val secondCanceledCount = scheduler.cancelExpiredTrackingRegistrationGifts(now)
|
||||
|
||||
assertEquals(1, firstCanceledCount)
|
||||
assertEquals(0, secondCanceledCount)
|
||||
Mockito.verify(canPaymentService, Mockito.times(1)).refundGiftCan(SENDER_MEMBER_ID, CAN_USAGE_ID)
|
||||
Mockito.verify(giftPushService, Mockito.times(1)).sendAutoCanceledToSender(APPLICATION_NO, SENDER_MEMBER_ID)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("배송지 입력 기한이 지난 발송 확인 선물을 전달 불가 처리한다")
|
||||
fun shouldMarkOverdueTrackingRegisteredGiftWithoutRecipientAddressUndeliverable() {
|
||||
val now = LocalDateTime.of(2026, 10, 9, 12, 0)
|
||||
val (gift, delivery) = saveGift(
|
||||
status = GiftStatus.TRACKING_REGISTERED,
|
||||
trackingDeadlineAt = now.minusDays(7),
|
||||
recipientAddressDeadlineAt = now.minusSeconds(1)
|
||||
)
|
||||
|
||||
val updatedCount = scheduler.markExpiredRecipientAddressGiftsUndeliverable(now)
|
||||
|
||||
assertEquals(1, updatedCount)
|
||||
assertEquals(GiftStatus.UNDELIVERABLE, gift.status)
|
||||
assertEquals(now, delivery.undeliverableAt)
|
||||
assertEquals("배송지 미입력 기한 초과", delivery.undeliverableReason)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("배송지 입력 기한과 같은 발송 확인 선물은 전달 불가 처리하지 않는다")
|
||||
fun shouldIgnoreTrackingRegisteredGiftWhoseRecipientAddressDeadlineIsNotPast() {
|
||||
val now = LocalDateTime.of(2026, 10, 9, 12, 0)
|
||||
val (gift, delivery) = saveGift(
|
||||
status = GiftStatus.TRACKING_REGISTERED,
|
||||
trackingDeadlineAt = now.minusDays(7),
|
||||
recipientAddressDeadlineAt = now
|
||||
)
|
||||
|
||||
val updatedCount = scheduler.markExpiredRecipientAddressGiftsUndeliverable(now)
|
||||
|
||||
assertEquals(0, updatedCount)
|
||||
assertEquals(GiftStatus.TRACKING_REGISTERED, gift.status)
|
||||
assertEquals(null, delivery.undeliverableAt)
|
||||
assertEquals(null, delivery.undeliverableReason)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("배송지가 입력된 선물은 배송지 입력 기한이 지나도 전달 불가 처리하지 않는다")
|
||||
fun shouldIgnoreGiftWithRecipientAddress() {
|
||||
val now = LocalDateTime.of(2026, 10, 9, 12, 0)
|
||||
val (gift, delivery) = saveGift(
|
||||
status = GiftStatus.TRACKING_REGISTERED,
|
||||
trackingDeadlineAt = now.minusDays(7),
|
||||
recipientAddressDeadlineAt = now.minusSeconds(1),
|
||||
recipientAddress = "서울시 송파구"
|
||||
)
|
||||
|
||||
val updatedCount = scheduler.markExpiredRecipientAddressGiftsUndeliverable(now)
|
||||
|
||||
assertEquals(0, updatedCount)
|
||||
assertEquals(GiftStatus.TRACKING_REGISTERED, gift.status)
|
||||
assertEquals(null, delivery.undeliverableAt)
|
||||
assertEquals(null, delivery.undeliverableReason)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("배송지 미입력 전달 불가를 다시 실행해도 이미 전달 불가인 선물은 처리하지 않는다")
|
||||
fun shouldNotUpdateAlreadyUndeliverableGiftOnSecondRun() {
|
||||
val now = LocalDateTime.of(2026, 10, 9, 12, 0)
|
||||
val (_, delivery) = saveGift(
|
||||
status = GiftStatus.TRACKING_REGISTERED,
|
||||
trackingDeadlineAt = now.minusDays(7),
|
||||
recipientAddressDeadlineAt = now.minusSeconds(1)
|
||||
)
|
||||
|
||||
val firstUpdatedCount = scheduler.markExpiredRecipientAddressGiftsUndeliverable(now)
|
||||
val secondUpdatedCount = scheduler.markExpiredRecipientAddressGiftsUndeliverable(now.plusMinutes(1))
|
||||
|
||||
assertEquals(1, firstUpdatedCount)
|
||||
assertEquals(0, secondUpdatedCount)
|
||||
assertEquals(now, delivery.undeliverableAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("운송장 등록 기한 24시간 전 접수 선물에 한 번만 안내한다")
|
||||
fun shouldSendTrackingDeadlineReminderOnce() {
|
||||
val now = LocalDateTime.of(2026, 10, 1, 12, 0)
|
||||
val (_, delivery) = saveGift(GiftStatus.RECEIVED, now.plusHours(24))
|
||||
|
||||
val firstSentCount = scheduler.sendDeadlineReminderGifts(now)
|
||||
val secondSentCount = scheduler.sendDeadlineReminderGifts(now.plusMinutes(1))
|
||||
|
||||
assertEquals(1, firstSentCount)
|
||||
assertEquals(0, secondSentCount)
|
||||
assertEquals(now, delivery.trackingDeadlineReminderSentAt)
|
||||
Mockito.verify(giftPushService, Mockito.times(1)).sendTrackingDeadlineReminder(APPLICATION_NO, SENDER_MEMBER_ID)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("운송장 등록 기한 24시간 초과 전 접수 선물은 안내하지 않는다")
|
||||
fun shouldIgnoreTrackingDeadlineReminderBeforeWindow() {
|
||||
val now = LocalDateTime.of(2026, 10, 1, 12, 0)
|
||||
saveGift(GiftStatus.RECEIVED, now.plusHours(24).plusSeconds(1))
|
||||
|
||||
val sentCount = scheduler.sendDeadlineReminderGifts(now)
|
||||
|
||||
assertEquals(0, sentCount)
|
||||
Mockito.verifyNoInteractions(giftPushService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("배송지 입력 기한 24시간 전 선물에 한 번만 안내한다")
|
||||
fun shouldSendRecipientAddressDeadlineReminderOnce() {
|
||||
val now = LocalDateTime.of(2026, 10, 8, 12, 0)
|
||||
val (_, delivery) = saveGift(
|
||||
status = GiftStatus.TRACKING_REGISTERED,
|
||||
trackingDeadlineAt = now.minusDays(6),
|
||||
recipientAddressDeadlineAt = now.plusHours(24)
|
||||
)
|
||||
|
||||
val firstSentCount = scheduler.sendDeadlineReminderGifts(now)
|
||||
val secondSentCount = scheduler.sendDeadlineReminderGifts(now.plusMinutes(1))
|
||||
|
||||
assertEquals(1, firstSentCount)
|
||||
assertEquals(0, secondSentCount)
|
||||
assertEquals(now, delivery.recipientAddressDeadlineReminderSentAt)
|
||||
Mockito.verify(giftPushService, Mockito.times(1))
|
||||
.sendRecipientAddressDeadlineReminder(APPLICATION_NO, RECIPIENT_MEMBER_ID)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("배송지가 입력된 선물은 배송지 입력 기한 안내를 보내지 않는다")
|
||||
fun shouldIgnoreRecipientAddressDeadlineReminderWhenAddressExists() {
|
||||
val now = LocalDateTime.of(2026, 10, 8, 12, 0)
|
||||
saveGift(
|
||||
status = GiftStatus.TRACKING_REGISTERED,
|
||||
trackingDeadlineAt = now.minusDays(6),
|
||||
recipientAddressDeadlineAt = now.plusHours(24),
|
||||
recipientAddress = "서울시 송파구"
|
||||
)
|
||||
|
||||
val sentCount = scheduler.sendDeadlineReminderGifts(now)
|
||||
|
||||
assertEquals(0, sentCount)
|
||||
Mockito.verifyNoInteractions(giftPushService)
|
||||
}
|
||||
|
||||
private fun saveGift(
|
||||
status: GiftStatus,
|
||||
trackingDeadlineAt: LocalDateTime,
|
||||
recipientAddressDeadlineAt: LocalDateTime? = null,
|
||||
recipientAddress: String? = null
|
||||
): Pair<Gift, GiftDelivery> {
|
||||
val gift = giftRepository.save(
|
||||
Gift(
|
||||
applicationNo = APPLICATION_NO,
|
||||
senderMemberId = SENDER_MEMBER_ID,
|
||||
recipientMemberId = RECIPIENT_MEMBER_ID,
|
||||
status = status,
|
||||
sizeCode = GiftSize.SMALL,
|
||||
categoryId = CATEGORY_ID,
|
||||
categoryNameSnapshot = "인형",
|
||||
salePriceCan = 80,
|
||||
canUsageId = CAN_USAGE_ID,
|
||||
senderTermsAgreed = true,
|
||||
senderPrivacyAgreed = true,
|
||||
senderTermsAgreedAt = APPLIED_AT,
|
||||
senderPrivacyAgreedAt = APPLIED_AT,
|
||||
damageWaiverAgreed = true
|
||||
)
|
||||
)
|
||||
val delivery = giftDeliveryRepository.save(
|
||||
GiftDelivery(
|
||||
giftId = requireNotNull(gift.id),
|
||||
senderName = "홍길동",
|
||||
senderPhoneNumber = "01012345678",
|
||||
senderZipCode = "06234",
|
||||
senderAddress = "서울시 강남구",
|
||||
recipientAddress = recipientAddress,
|
||||
trackingDeadlineAt = trackingDeadlineAt,
|
||||
recipientAddressDeadlineAt = recipientAddressDeadlineAt
|
||||
)
|
||||
)
|
||||
|
||||
return gift to delivery
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val APPLICATION_NO = "G20260929000001"
|
||||
const val SENDER_MEMBER_ID = 1L
|
||||
const val RECIPIENT_MEMBER_ID = 2L
|
||||
const val CATEGORY_ID = 3L
|
||||
const val CAN_USAGE_ID = 4L
|
||||
val APPLIED_AT: LocalDateTime = LocalDateTime.of(2026, 9, 29, 12, 0)
|
||||
}
|
||||
}
|
||||
-228
@@ -1,228 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
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.GiftMailboxRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
|
||||
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 giftMailboxRepository: GiftMailboxRepository
|
||||
) {
|
||||
private val giftPushService = Mockito.mock(GiftPushService::class.java)
|
||||
private val service = GiftAdminService(
|
||||
categoryRepository,
|
||||
sizePriceRepository,
|
||||
giftRepository,
|
||||
giftDeliveryRepository,
|
||||
giftMailboxRepository,
|
||||
giftPushService
|
||||
)
|
||||
|
||||
@Test
|
||||
@DisplayName("받을 주소는 미등록이면 null이고 저장 후 단일 설정으로 갱신된다")
|
||||
fun shouldUpsertGiftMailbox() {
|
||||
assertEquals(null, service.getMailbox())
|
||||
|
||||
val created = service.updateMailbox(
|
||||
GiftMailboxCommand(
|
||||
name = "소다라이브 선물 담당자",
|
||||
phoneNumber = "01012345678",
|
||||
zipCode = "06234",
|
||||
address = "서울시 강남구",
|
||||
addressDetail = "3층 선물 접수처"
|
||||
)
|
||||
)
|
||||
val updated = service.updateMailbox(
|
||||
GiftMailboxCommand(
|
||||
name = "소다라이브 운영팀",
|
||||
phoneNumber = "01087654321",
|
||||
zipCode = "04524",
|
||||
address = "서울시 중구",
|
||||
addressDetail = null
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals("소다라이브 선물 담당자", created.name)
|
||||
assertEquals("(06234) 서울시 강남구, 3층 선물 접수처", created.address)
|
||||
assertEquals("01012345678", created.phoneNumber)
|
||||
assertEquals("소다라이브 운영팀", updated.name)
|
||||
assertEquals("(04524) 서울시 중구", updated.address)
|
||||
assertEquals(1, giftMailboxRepository.count())
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("받을 주소 필수값이 공백이면 저장할 수 없다")
|
||||
fun shouldRejectInvalidGiftMailbox() {
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
service.updateMailbox(
|
||||
GiftMailboxCommand(
|
||||
name = " ",
|
||||
phoneNumber = "01012345678",
|
||||
zipCode = "06234",
|
||||
address = "서울시 강남구",
|
||||
addressDetail = null
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals("common.error.invalid_request", exception.messageKey)
|
||||
assertEquals(0, giftMailboxRepository.count())
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("카테고리를 등록, 수정, 논리삭제한다")
|
||||
fun shouldCreateUpdateAndDeactivateCategory() {
|
||||
val created = service.createCategory(
|
||||
GiftCategoryCommand(
|
||||
classificationNumber = "100",
|
||||
categoryCode = "DOLL",
|
||||
name = "인형",
|
||||
receiptCode = "A",
|
||||
representativeItem = "피규어",
|
||||
requiresDamageWaiver = true,
|
||||
isActive = true
|
||||
)
|
||||
)
|
||||
val updated = service.updateCategory(
|
||||
created.categoryId,
|
||||
GiftCategoryCommand(
|
||||
classificationNumber = "101",
|
||||
categoryCode = "DOLL",
|
||||
name = "큰 인형",
|
||||
receiptCode = "B",
|
||||
representativeItem = "대형 피규어",
|
||||
requiresDamageWaiver = false,
|
||||
isActive = true
|
||||
)
|
||||
)
|
||||
service.deleteCategory(created.categoryId)
|
||||
|
||||
val saved = categoryRepository.findById(created.categoryId).orElseThrow()
|
||||
assertEquals("101", updated.classificationNumber)
|
||||
assertEquals("DOLL", updated.categoryCode)
|
||||
assertEquals("큰 인형", updated.name)
|
||||
assertEquals("B", updated.receiptCode)
|
||||
assertEquals("대형 피규어", updated.representativeItem)
|
||||
assertFalse(updated.requiresDamageWaiver)
|
||||
assertFalse(saved.isActive)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("카테고리 분류번호와 접수코드 형식을 검증한다")
|
||||
fun shouldValidateCategoryClassificationNumberAndReceiptCode() {
|
||||
val invalidClassificationNumber = assertThrows(SodaException::class.java) {
|
||||
service.createCategory(categoryCommand(classificationNumber = "10A"))
|
||||
}
|
||||
val invalidReceiptCode = assertThrows(SodaException::class.java) {
|
||||
service.createCategory(categoryCommand(receiptCode = "A1"))
|
||||
}
|
||||
val blankRepresentativeItem = assertThrows(SodaException::class.java) {
|
||||
service.createCategory(categoryCommand(representativeItem = " "))
|
||||
}
|
||||
|
||||
assertEquals("common.error.invalid_request", invalidClassificationNumber.messageKey)
|
||||
assertEquals("common.error.invalid_request", invalidReceiptCode.messageKey)
|
||||
assertEquals("common.error.invalid_request", blankRepresentativeItem.messageKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("사이즈 가격을 수정하고 salePriceCan이 basePriceCan보다 크면 거부한다")
|
||||
fun shouldUpdateSizePriceAndRejectInvalidSalePrice() {
|
||||
val updated = service.updateSizePrice(
|
||||
GiftSize.SMALL,
|
||||
GiftSizePriceCommand(basePriceCan = 100, salePriceCan = 80, isActive = true)
|
||||
)
|
||||
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
service.updateSizePrice(
|
||||
GiftSize.SMALL,
|
||||
GiftSizePriceCommand(basePriceCan = 100, salePriceCan = 101, isActive = true)
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals(GiftSize.SMALL, updated.sizeCode)
|
||||
assertEquals(80, updated.salePriceCan)
|
||||
assertEquals("common.error.invalid_request", exception.messageKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("운영자는 발송 확인 선물을 사서함 도착 처리한다")
|
||||
fun shouldMarkTrackingRegisteredGiftArrivedAtMailbox() {
|
||||
val (gift, delivery) = saveGift(GiftStatus.TRACKING_REGISTERED)
|
||||
|
||||
val result = service.arriveMailbox(APPLICATION_NO, NOW)
|
||||
|
||||
assertEquals(GiftStatus.ARRIVED_AT_MAILBOX, result.status)
|
||||
assertEquals(GiftStatus.ARRIVED_AT_MAILBOX, gift.status)
|
||||
assertEquals(NOW, delivery.arrivedAtMailboxAt)
|
||||
Mockito.verify(giftPushService).sendArrivedAtMailboxToSender(APPLICATION_NO, SENDER_MEMBER_ID)
|
||||
Mockito.verify(giftPushService).sendArrivedAtMailboxToRecipient(APPLICATION_NO, RECIPIENT_MEMBER_ID)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("사서함 도착 상태가 아니면 검수 완료 처리할 수 없다")
|
||||
fun shouldRejectInspectionCompletionWhenGiftIsNotArrivedAtMailbox() {
|
||||
saveGift(GiftStatus.TRACKING_REGISTERED)
|
||||
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
service.completeInspection(APPLICATION_NO, NOW)
|
||||
}
|
||||
|
||||
assertEquals("common.error.invalid_request", exception.messageKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("운영자는 사서함 도착 선물을 검수 완료 처리한다")
|
||||
fun shouldCompleteInspectionForArrivedAtMailboxGift() {
|
||||
val (gift, delivery) = saveGift(GiftStatus.ARRIVED_AT_MAILBOX)
|
||||
|
||||
val result = service.completeInspection(APPLICATION_NO, NOW)
|
||||
|
||||
assertEquals(GiftStatus.INSPECTION_COMPLETED, result.status)
|
||||
assertEquals(GiftStatus.INSPECTION_COMPLETED, gift.status)
|
||||
assertEquals(NOW, delivery.inspectionCompletedAt)
|
||||
Mockito.verify(giftPushService).sendInspectionCompleted(APPLICATION_NO, RECIPIENT_MEMBER_ID)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("운영자는 진행 중 선물을 전달 불가 처리한다")
|
||||
fun shouldMarkActiveGiftUndeliverable() {
|
||||
val (gift, delivery) = saveGift(GiftStatus.INSPECTION_COMPLETED)
|
||||
|
||||
val result = service.markUndeliverable(APPLICATION_NO, UNDELIVERABLE_REASON, NOW)
|
||||
|
||||
assertEquals(GiftStatus.UNDELIVERABLE, result.status)
|
||||
assertEquals(GiftStatus.UNDELIVERABLE, gift.status)
|
||||
assertEquals(NOW, delivery.undeliverableAt)
|
||||
assertEquals(UNDELIVERABLE_REASON, delivery.undeliverableReason)
|
||||
Mockito.verify(giftPushService).sendUndeliverableToSender(APPLICATION_NO, SENDER_MEMBER_ID)
|
||||
Mockito.verify(giftPushService).sendUndeliverableToRecipient(APPLICATION_NO, RECIPIENT_MEMBER_ID)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("전달 불가 사유가 비어 있거나 종료 상태면 전달 불가 처리할 수 없다")
|
||||
fun shouldRejectUndeliverableWhenReasonBlankOrGiftTerminated() {
|
||||
saveGift(GiftStatus.DELIVERED)
|
||||
|
||||
val blankReasonException = assertThrows(SodaException::class.java) {
|
||||
service.markUndeliverable(APPLICATION_NO, " ", NOW)
|
||||
}
|
||||
val terminatedStatusException = assertThrows(SodaException::class.java) {
|
||||
service.markUndeliverable(APPLICATION_NO, UNDELIVERABLE_REASON, NOW)
|
||||
}
|
||||
|
||||
assertEquals("common.error.invalid_request", blankReasonException.messageKey)
|
||||
assertEquals("common.error.invalid_request", terminatedStatusException.messageKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("운영자는 검수 완료 선물을 전달 완료 처리한다")
|
||||
fun shouldCompleteDeliveryForInspectionCompletedGift() {
|
||||
val (gift, delivery) = saveGift(GiftStatus.INSPECTION_COMPLETED)
|
||||
|
||||
val result = service.completeDelivery(APPLICATION_NO, NOW)
|
||||
|
||||
assertEquals(GiftStatus.DELIVERED, result.status)
|
||||
assertEquals(GiftStatus.DELIVERED, gift.status)
|
||||
assertEquals(NOW, delivery.deliveredAt)
|
||||
Mockito.verify(giftPushService).sendDeliveredToSender(APPLICATION_NO, SENDER_MEMBER_ID)
|
||||
Mockito.verify(giftPushService).sendDeliveredToRecipient(APPLICATION_NO, RECIPIENT_MEMBER_ID)
|
||||
Mockito.verifyNoMoreInteractions(giftPushService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("검수 완료 상태가 아니면 운영 전달 완료 처리할 수 없다")
|
||||
fun shouldRejectDeliveryCompletionWhenGiftIsNotInspectionCompleted() {
|
||||
saveGift(GiftStatus.ARRIVED_AT_MAILBOX)
|
||||
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
service.completeDelivery(APPLICATION_NO, NOW)
|
||||
}
|
||||
|
||||
assertEquals("common.error.invalid_request", exception.messageKey)
|
||||
}
|
||||
|
||||
private fun saveGift(status: GiftStatus): Pair<Gift, GiftDelivery> {
|
||||
val gift = giftRepository.save(
|
||||
Gift(
|
||||
applicationNo = APPLICATION_NO,
|
||||
senderMemberId = SENDER_MEMBER_ID,
|
||||
recipientMemberId = RECIPIENT_MEMBER_ID,
|
||||
status = status,
|
||||
sizeCode = GiftSize.SMALL,
|
||||
categoryId = 1L,
|
||||
categoryNameSnapshot = "인형",
|
||||
salePriceCan = 80,
|
||||
canUsageId = 10L,
|
||||
senderTermsAgreed = true,
|
||||
senderPrivacyAgreed = true,
|
||||
senderTermsAgreedAt = NOW.minusDays(1),
|
||||
senderPrivacyAgreedAt = NOW.minusDays(1),
|
||||
damageWaiverAgreed = true
|
||||
)
|
||||
)
|
||||
val delivery = giftDeliveryRepository.save(
|
||||
GiftDelivery(
|
||||
giftId = requireNotNull(gift.id),
|
||||
senderName = "홍길동",
|
||||
senderPhoneNumber = "01012345678",
|
||||
senderZipCode = "06234",
|
||||
senderAddress = "서울시 강남구",
|
||||
trackingDeadlineAt = NOW.minusDays(1),
|
||||
trackingRegisteredAt = NOW.minusHours(1)
|
||||
)
|
||||
)
|
||||
|
||||
return gift to delivery
|
||||
}
|
||||
|
||||
private fun categoryCommand(
|
||||
classificationNumber: String = "100",
|
||||
categoryCode: String = "DOLL",
|
||||
name: String = "인형",
|
||||
receiptCode: String = "A",
|
||||
representativeItem: String = "피규어"
|
||||
) = GiftCategoryCommand(
|
||||
classificationNumber = classificationNumber,
|
||||
categoryCode = categoryCode,
|
||||
name = name,
|
||||
receiptCode = receiptCode,
|
||||
representativeItem = representativeItem,
|
||||
requiresDamageWaiver = true,
|
||||
isActive = true
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val APPLICATION_NO = "G20260929000001"
|
||||
const val SENDER_MEMBER_ID = 1L
|
||||
const val RECIPIENT_MEMBER_ID = 2L
|
||||
const val UNDELIVERABLE_REASON = "선물 접수 규격에 맞지 않습니다."
|
||||
val NOW: LocalDateTime = LocalDateTime.of(2026, 10, 3, 12, 0)
|
||||
}
|
||||
}
|
||||
-115
@@ -1,115 +0,0 @@
|
||||
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 = "피규어"
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,522 +0,0 @@
|
||||
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.GiftMailbox
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftMailboxRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftRepository
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePrice
|
||||
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository
|
||||
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,
|
||||
private val giftMailboxRepository: GiftMailboxRepository
|
||||
) {
|
||||
@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 shouldReturnMailboxOnlyForSentReceivedGiftDetail() {
|
||||
val sender = saveMember("fan")
|
||||
val recipient = saveMember("creator")
|
||||
val receivedGift = saveGift(
|
||||
applicationNo = "G-MAILBOX-RECEIVED",
|
||||
senderMemberId = sender.id!!,
|
||||
recipientMemberId = recipient.id!!,
|
||||
status = GiftStatus.RECEIVED
|
||||
)
|
||||
saveDelivery(receivedGift)
|
||||
val trackingGift = saveGift(
|
||||
applicationNo = "G-MAILBOX-TRACKING",
|
||||
senderMemberId = sender.id!!,
|
||||
recipientMemberId = recipient.id!!,
|
||||
status = GiftStatus.TRACKING_REGISTERED
|
||||
)
|
||||
saveDelivery(trackingGift).trackingRegisteredAt = NOW
|
||||
giftMailboxRepository.save(
|
||||
GiftMailbox(
|
||||
name = "소다라이브 선물 담당자",
|
||||
phoneNumber = "01012345678",
|
||||
zipCode = "06234",
|
||||
address = "서울시 강남구",
|
||||
addressDetail = "3층 선물 접수처"
|
||||
)
|
||||
)
|
||||
val service = queryService()
|
||||
|
||||
val receivedResult = service.getGiftDetail(sender.id!!, receivedGift.applicationNo)
|
||||
val trackingSenderResult = service.getGiftDetail(sender.id!!, trackingGift.applicationNo)
|
||||
val trackingRecipientResult = service.getGiftDetail(recipient.id!!, trackingGift.applicationNo)
|
||||
|
||||
assertEquals("소다라이브 선물 담당자", receivedResult.mailbox?.name)
|
||||
assertEquals("01012345678", receivedResult.mailbox?.phoneNumber)
|
||||
assertEquals("(06234) 서울시 강남구, 3층 선물 접수처", receivedResult.mailbox?.address)
|
||||
assertNull(trackingSenderResult.mailbox)
|
||||
assertNull(trackingRecipientResult.mailbox)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("받을 주소가 없으면 운송장 등록 전 상세도 mailbox가 null이다")
|
||||
fun shouldReturnNullMailboxWhenMailboxNotConfigured() {
|
||||
val sender = saveMember("fan")
|
||||
val recipient = saveMember("creator")
|
||||
val gift = saveGift(
|
||||
applicationNo = "G-MAILBOX-EMPTY",
|
||||
senderMemberId = sender.id!!,
|
||||
recipientMemberId = recipient.id!!,
|
||||
status = GiftStatus.RECEIVED
|
||||
)
|
||||
saveDelivery(gift)
|
||||
|
||||
val result = queryService().getGiftDetail(sender.id!!, gift.applicationNo)
|
||||
|
||||
assertNull(result.mailbox)
|
||||
}
|
||||
|
||||
@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,
|
||||
giftMailboxRepository,
|
||||
"https://cdn.test"
|
||||
)
|
||||
|
||||
private companion object {
|
||||
val NOW: LocalDateTime = LocalDateTime.of(2026, 9, 29, 12, 0)
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.v2.gift.domain
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class GiftStatusTest {
|
||||
@Test
|
||||
@DisplayName("선물 상태 표시명은 한글 띄어쓰기를 통일한다")
|
||||
fun shouldUseConsistentKoreanSpacingForDisplayNames() {
|
||||
assertEquals("접수 완료", GiftStatus.RECEIVED.displayName)
|
||||
assertEquals("발송 확인", GiftStatus.TRACKING_REGISTERED.displayName)
|
||||
assertEquals("사서함 도착", GiftStatus.ARRIVED_AT_MAILBOX.displayName)
|
||||
assertEquals("검수 완료", GiftStatus.INSPECTION_COMPLETED.displayName)
|
||||
assertEquals("전달 완료", GiftStatus.DELIVERED.displayName)
|
||||
assertEquals("전달 불가", GiftStatus.UNDELIVERABLE.displayName)
|
||||
assertEquals("신청 취소", GiftStatus.CANCELED.displayName)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user