Merge pull request 'Test' (#459) from test into main

Reviewed-on: #459
This commit was merged in pull request #459.
This commit is contained in:
2026-10-22 11:21:33 +00:00
13 changed files with 1035 additions and 39 deletions
@@ -0,0 +1,120 @@
# 기존 관리자 선물함에 신청 취소 기능 추가 프롬프트
## 목적과 사용 범위
- 이 문서는 이미 존재하는 관리자 선물함에 기능 하나를 추가하기 위한 전달용 프롬프트다.
- 아래 확정된 API Request/Response 계약을 기준으로 타입, API client, 화면 액션과 테스트를 구현한다.
- [기존 전체 페이지 프롬프트](admin-page-prompts.md)는 수정하거나 다시 실행하지 않는다.
- 계약 기준: [PRD 8.14.1 관리자 선물 신청 취소](prd.md#8141-관리자-선물-신청-취소).
## 복사해서 전달할 증분 구현 프롬프트
아래 블록 전체를 기존 프론트엔드 저장소의 구현 담당자에게 전달한다.
````markdown
# 기존 관리자 선물함에 신청 취소 액션만 추가
## 1. 기존 코드 확인
이미 구현된 관리자 선물함 목록/상세에 `신청 취소` 기능만 증분으로 추가해줘.
페이지를 새로 만들거나 기존 전체 페이지 구현 프롬프트를 재실행하지 마.
- 먼저 기존 프론트엔드 코드를 읽고 목록, 상세, 액션 타입, 액션 dispatch 위치를 확인한다.
- API 인증/권한, 공통 API client, 응답 envelope 해제 방식, 확인창, 토스트, 오류 처리를 확인한다.
- 기존 상태 변경 액션의 pending 처리와 목록/상세 재조회 방식을 그대로 사용한다.
- 변경할 실제 파일과 재사용할 기존 패턴을 먼저 짧게 정리한다.
- 아래 API 계약을 기준으로 구현한다. 기존 타입이나 호출부가 계약과 다르면 차이를 보고하고 이 기능의 변경 범위 안에서 맞춘다.
## 2. 유지할 기존 화면과 액션
- 기존 `/gift/list` 라우트, 메뉴, 레이아웃, 테이블 컬럼, 필터, 페이지네이션을 보존한다.
- 기존 상세 모달/패널과 액션 표시·실행 프레임워크를 그대로 확장한다.
- `ARRIVE_MAILBOX`, `COMPLETE_INSPECTION`, `COMPLETE_DELIVERY`, `MARK_UNDELIVERABLE`을 보존한다.
- 기존 전달 불가 사유 입력/검증 및 각 액션의 확인창·성공·실패 동작을 변경하지 않는다.
- `COMPLETE_DELIVERY`의 수취인 필수 정보 조건부 허용을 유지한다.
- 모든 기존 액션도 서버 `availableActions`를 따르며 로컬 상태 매핑으로 대체하지 않는다.
- 받을 주소, 카테고리, 사이즈 설정 화면은 수정하지 않는다.
## 3. 최소 타입/dispatch 확장과 버튼 노출
- 기존 `AdminGiftAction` 타입에 `CANCEL`을 추가하되 기존 값을 삭제하거나 재정의하지 않는다.
- 기존 액션 라벨/핸들러 dispatch에 `CANCEL`과 `신청 취소`만 추가한다.
- 목록 row와 열린 상세 모두 기존 액션 렌더링/실행 경로에 연결한다.
- 각 화면의 서버 응답 `availableActions.includes("CANCEL")`일 때만 취소 버튼을 표시한다.
- API 계약상 `RECEIVED`에서만 `CANCEL`을 제공한다. 다른 상태에서는 제공하지 않는다.
- `status === "RECEIVED"`만으로 취소 권한을 만들거나 버튼을 강제로 추가하지 않는다.
- 목록/상세 중 한쪽의 로컬 값을 다른 쪽의 권한 판단에 대신 사용하지 않는다.
- 권한의 최종 판단은 서버가 하며, 관리자가 발신자 본인일 필요는 없다.
## 4. 확정 API 계약
액션 조회:
- 목록: `GET /api/v2/admin/gifts`, `data.items[].availableActions`를 사용한다.
- 상세: `GET /api/v2/admin/gifts/{applicationNo}`, `data.availableActions`를 사용한다.
- 접수 완료 응답: `status="RECEIVED"`, `statusName="접수 완료"`, `availableActions=["CANCEL"]`.
- 취소 후 응답: `status="CANCELED"`, `statusName="신청 취소"`, `availableActions=[]`.
취소 요청:
- Method: `POST`
- URL: `/api/v2/admin/gifts/{applicationNo}/cancel`
- 권한: `ADMIN` 전용. 기존 관리자 인증/권한 경로를 재사용한다.
- Request body: 없음. `{}`, 취소 사유, 환불액 등을 보내지 않는다.
- 서버 전이: `RECEIVED`에서만 `CANCELED`로 변경한다.
- 환불: 원 발신자에게 사용 캔 전액을 리워드캔으로 환불한다.
- 관리자의 잔액으로 환불하는 기능이 아니다. 프론트에서 환불/잔액 변경을 직접 처리하지 않는다.
- 성공 응답: 공통 envelope의 `success=true`와 아래 `data`이며 기존 client의 envelope 처리 방식에 맞춘다. HTTP 성공 코드만으로 업무 성공을 판단하지 않는다.
```json
{
"applicationNo": "A-1002609300001",
"status": "CANCELED",
"statusName": "신청 취소",
"occurredAt": "2026-09-30T12:00:00Z"
}
```
`occurredAt`은 서버의 `canceledAt`과 같은 UTC ISO `Z` 문자열이다.
기존 `AdminGiftOperationStatusResponse`를 재사용하고 로컬 현재 시각으로 값을 만들지 않는다.
## 5. 확인과 요청 중 처리
- 기존 확인창 컴포넌트를 사용하며 대상 `applicationNo`를 명확히 표시한다.
- 확인 문구 예: `신청번호 {applicationNo}의 선물 신청을 취소하시겠습니까?`
- 환불 안내: `사용한 캔 전액이 발신자에게 리워드캔으로 환불됩니다.`
- 확인창을 닫거나 취소하면 API를 호출하지 않는다. 명시적으로 확정한 경우에만 호출한다.
- 확정한 신청번호로 body 없는 요청을 한 번만 보낸다.
- pending 동안 확인 버튼과 같은 신청의 목록/상세 취소 버튼을 비활성화해 중복 제출을 막는다.
- 기존 client의 mutation 재시도 정책도 확인하고 취소 요청을 무조건 자동 재시도하지 않는다.
- 키보드 포커스/확인창 접근성과 pending 표시는 기존 관리자 패턴을 유지한다.
## 6. 성공과 실패
- 서버 성공 확인 후 기존 성공 토스트를 표시한다. 응답 전에 성공으로 처리하지 않는다.
- 현재 필터와 현재 페이지/페이지 크기를 유지한 채 목록을 다시 조회한다.
- 상세가 열려 있으면 해당 상세도 다시 조회해 서버의 현재 상태와 액션을 반영한다.
- 취소된 항목의 서버 상태는 `CANCELED`, `statusName`은 `신청 취소`, `availableActions`는 `[]`다.
- 현재 필터에 맞지 않아 목록에서 사라져도 필터를 초기화하거나 첫 페이지로 임의 이동하지 않는다.
- 실패는 기존 공통 오류 토스트/알림을 사용하며 별도 오류 스키마나 HTTP 정책을 만들지 않는다.
- 서버가 상태 변경/잘못된 요청을 알리거나 통신 실패로 처리 결과가 불명확하면
기존 패턴에 따라 목록과 열린 상세의 서버 현재 상태를 재조회한다. mutation은 재시도하지 않는다.
- 실패 시 로컬 상태를 가짜 `CANCELED`로 바꾸거나 성공 토스트/환불 완료를 표시하지 않는다.
## 7. 구현 후 집중 검증
확정 계약의 성공·실패 응답 fixture와 기존 API client mock을 테스트 범위에서 사용해 화면 및 요청 동작을 검증한다. 실행 코드에 자동 fallback이나 가짜 성공 처리를 추가하지 않는다. 실제 API 연동 검증 결과는 단위/컴포넌트 테스트 결과와 구분해 보고한다.
- 목록과 상세 각각 `RECEIVED` + 서버 `CANCEL`이면 버튼이 나오고 기존 dispatch로 실행되는지 확인한다.
- `RECEIVED`여도 `CANCEL`이 없으면 버튼이 없고, 다른 모든 상태에는 `CANCEL`이 없는지 확인한다.
- 확인창에 신청번호/발신자 전액 리워드캔 환불이 표시되고 확인을 닫으면 API 호출이 0회인지 확인한다.
- 확정 시 정확한 URL로 body 없이 1회만 요청하며 연속 클릭과 목록/상세 중복 실행이 막히는지 확인한다.
- 성공 후 필터/페이지가 유지되고 목록과 열린 상세가 갱신되며 취소 건의 액션이 없어지는지 확인한다.
- 실패 시 공통 오류를 표시하고 가짜 취소/성공이 없으며 필요한 현재 상태 재조회가 수행되는지 확인한다.
- 기존 운영 액션과 `COMPLETE_DELIVERY`의 수취인 정보 조건부 노출이 그대로인지 회귀 확인한다.
- 기존 프론트엔드 테스트 도구로 위 변경 범위만 검증하고 실행 결과/미검증 항목을 보고한다.
## 8. 범위 밖
취소 사유 필드, 취소 전용 푸시 흐름, 새 페이지/라우트, 새 디자인 시스템은 추가하지 않는다.
백엔드 구현, 사용자 직접 취소/자동취소 변경, 기존 페이지 전면 재작성도 이 작업에 포함하지 않는다.
````
@@ -44,6 +44,7 @@ PRD에는 상세 API 계약, 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/gifts/{applicationNo}/cancel` | 운송장 등록 전 `RECEIVED` 선물을 취소하고 발신자에게 전액 리워드캔을 환불한다. | path: `applicationNo`, body 없음 | `AdminGiftOperationStatusResponse`: `applicationNo`, `status`, `statusName`, `occurredAt` (`canceledAt` UTC ISO `Z`) |
| 관리자 | 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` |
@@ -63,6 +64,12 @@ PRD에는 상세 API 계약, request/response 예시, 요구사항을 유지한
- `AdminGiftSizePriceResponse`: `sizeCode`, `name`, `basePriceCan`, `salePriceCan`, `isActive`
- `AdminGiftOperationStatusResponse`: `applicationNo`, `status`, `statusName`, `occurredAt`
관리자 취소는 기존 `hasRole('ADMIN')` 권한을 따르며 관리자가 발신자가 아니어도 `RECEIVED`에서 취소할 수 있다. 같은 트랜잭션에서 `GiftDelivery.canceledAt`을 기록하고 기존 `refundGiftCan`으로 발신자에게 원 결제액 전액을 리워드캔으로 환불한다. 존재하지 않는 신청번호나 다른 상태는 기존 공통 invalid request이며 기존 `ApiResponse` envelope를 사용한다. 기존 사용자 취소 응답은 별도 계약이며 바뀌지 않는다. HTTP 성공 코드만으로 업무 성공을 판단하지 않고 envelope의 `success`를 확인한다.
관리자 선물함의 action: `RECEIVED`는 목록과 상세 모두 `availableActions=["CANCEL"]`; 그 외 상태는 기존 매핑을 유지한다. 특히 `INSPECTION_COMPLETED`의 `COMPLETE_DELIVERY`는 수취인 정보 완비 조건부 노출이다.
기존 관리자 화면에 취소 기능만 추가하는 작업은 [관리자 취소 기능 추가 프롬프트](admin-gift-cancel-addition-prompt.md)를 사용한다. 전체 화면 구현용 `admin-page-prompts.md`는 변경하지 않는다.
## Kotlin DTO 형태
아래는 JSON으로 변환되기 직전의 API request/response `data class` 형태다. `companion object` 변환 함수는 클라이언트 입력/출력 형태와 직접 관련이 없어 생략했다. 응답 시각 필드는 UTC ISO 문자열이다. 예: `2026-09-30T12:00:00Z`.
@@ -4,16 +4,16 @@
| 문서 항목 | 내용 |
|---|---|
| 상태 | 완료 |
| 상태 | Phase 14 완료, 전체 테스트 사용자 요청으로 제외 |
| 작성일 | 2026-09-29 |
| 요구사항 기준 | `docs/20260929_크리에이터_선물하기/prd.md` |
| API 기준 | `docs/20260929_크리에이터_선물하기/prd.md`의 `8. API 계약` |
| 현재 Phase | Phase 12 구현 완료 |
| 현재 활성 Goal | 완료 |
| 현재 Phase | Phase 14 관리자 선물 취소 |
| 현재 활성 Goal | 없음, `P14-T4` 시각 정밀도 회귀 수정 완료 |
## 목표
팬이 캔으로 크리에이터에게 실물 선물을 신청하고, 운송장 등록부터 크리에이터 배송지 입력, 운영 검수, 전달 완료/불가, 리뷰와 푸시까지 서버에서 추적할 수 있게 한다.
팬이 캔으로 크리에이터에게 실물 선물을 신청하고, 운송장 등록부터 크리에이터 배송지 입력, 운영 검수, 전달 완료/불가, 리뷰와 푸시까지 서버에서 추적한다. Phase 14는 `RECEIVED` 선물의 ADMIN 취소·환불과 목록/상세 액션을 제공한다.
## 아키텍처
@@ -41,6 +41,7 @@
| 8 | 완료 | `1/1` | 완료 | 없음 |
| 9 | 완료 | `3/3` | 완료 | 없음 |
| 10 | 완료 | `3/3` | 완료 | 관리자 선물함 목록/상세 API 추가 |
| 14 | 완료 | `4/4` | 완료 | 전체 테스트 제외, 운영 MySQL/배포/별도 프론트엔드 검증 범위 밖 |
- 동시에 하나의 미완료 goal만 운용한다.
- 구현 중 요구사항이 바뀌면 이 문서의 체크박스와 범위를 먼저 갱신한 뒤 코드를 수정한다.
@@ -52,6 +53,7 @@
- 선물 신청, 목록, 상세, 운송장 등록, 취소, 크리에이터 배송지 입력, 크리에이터 수령확인, 리뷰 작성 사용자 API
- 관리자 카테고리 API, 관리자 사이즈 가격 API, 운영 상태변경 API
- Phase 14 관리자 신청 취소·환불과 목록/상세 `CANCEL` 액션, 별도 관리자 기능 추가 프롬프트.
- 운송장 미등록 자동취소, 배송지 미입력 전달불가, 기한 24시간 전 안내 스케줄러
- 캔 사용내역 `선물하기` 추가와 선물 신청 내부 연결
- 팬/크리에이터 대상 푸시 이벤트와 단일 딥링크 `GIFT_DETAIL` + `applicationNo`
@@ -1430,6 +1432,199 @@ P7은 FCM 확장 후 전체 상태 전이를 연결하고 최종 회귀로 종
- [x] **GREEN:** 기존 `GiftDelivery` 정보를 재사용해 `recipientAddressRegistered`, `recipientAddressDeadlineAt`, `canceledAt`을 매핑했다.
- [x] **검증:** focused 테스트와 `ktlintCheck`, `git diff --check`를 실행했다.
### Phase 14 문서 초안 — 2026-10-22
- 상태: 문서 초안 작성 완료, 최종 검증 대기
- 무엇을: PRD, 구현 계획, 클라이언트 요약에 ADMIN 선물 취소 계약을 추가하고 기존 화면에 취소 기능만 추가하는 프롬프트를 별도 문서로 분리한다. 전체 화면 구현용 `admin-page-prompts.md`는 보존한다.
- 왜: 승인된 `RECEIVED` 전용 취소 범위를 구현과 분리해 먼저 문서화하기 위해서다.
- 어떻게:
- 문서 변경만 수행했다. 코드/테스트/설정/DDL은 수정하지 않았다.
- 초안 작성 시점에는 문서 검증을 완료하지 않았다. 최종 검증 결과는 후속 기록에 누적한다.
- 남은 항목: 최종 문서 검증, 사용자 구현 승인, 이후 구현 및 검증
- 다음 행동: 문서 검증 후 P14-T1 체크를 갱신하고, 구현 승인은 별도로 받는다.
## Phase 14: 관리자 선물 취소
**Phase 결과:** 관리자가 `RECEIVED` 선물만 취소하고 발신자에게 전액 리워드캔을 환불하며, 목록/상세에서 `CANCEL`을 선택할 수 있다. 서버 구현과 관련 검증을 완료했다.
**선행조건:** Phase 13 완료, PRD `DEC-015` 및 `GIFT-016`~`GIFT-017` 확정.
**2026-10-22 실행 승인:** 사용자의 "문서대로 진행" 요청으로 `P14-T2`부터 재개한다. 현재 서버 저장소에서 취소 API·환불·액션 응답을 구현하며 별도 프론트엔드 화면 구현은 포함하지 않는다.
**Phase 완료 조건:** `P14-T1`~`P14-T3` 및 `P14-GATE` 완료. 전체 테스트는 후속 사용자 요청으로 제외하고 관련 회귀·실제 HTTP·리뷰·테스트 제외 빌드로 검증했다.
### 문서 작업
#### Task 14.1 관리자 취소 계약 문서화
**Goal 실행 `P14-T1`:** 승인된 관리자 취소 범위와 계획 상태를 PRD, 계획, 클라이언트 요약, 별도 기능 추가 프롬프트에 일치시킨다.
- **시작 조건:** 관리자 취소가 `RECEIVED` 전용이며 목록/상세 액션으로 제공된다는 사용자 승인.
- **완료 증거:** 네 문서의 endpoint, 액션, 상태/환불/응답 계약 일치, 기존 `admin-page-prompts.md` 무변경 및 문서 검증 기록.
- **범위 밖:** 서버/프론트엔드 구현, 테스트 실행, mock/fallback, 새 오류/푸시/스키마.
- **TDD 예외 사유:** 문서만 수정하며 production 동작을 변경하지 않는다.
- **대체 검증 방법:** 네 문서 간 계약 대조, `./gradlew tasks --all`, `git diff --check`, 변경 파일 범위 확인을 수행하고 결과를 누적한다.
**Files:**
- Modify: `docs/20260929_크리에이터_선물하기/prd.md`, `docs/20260929_크리에이터_선물하기/plan-task.md`, `docs/20260929_크리에이터_선물하기/client-api-summary.md`.
- Create: `docs/20260929_크리에이터_선물하기/admin-gift-cancel-addition-prompt.md` — 새 화면을 만들지 않고 기존 화면에 취소 기능만 추가하는 프롬프트.
- [x] 네 문서의 계약과 기존 전체 화면 프롬프트 무변경을 대조하고 검증 명령의 실제 결과를 기록한다.
### P14-T1 최종 문서 검증 — 2026-10-22
- 상태: 문서 반영 및 검증 완료. `P14-T2`, `P14-T3`, `P14-GATE`는 미착수다.
- 무엇을: PRD/계획/API 요약에 관리자 취소와 목록·상세 `CANCEL` 계약을 반영하고, 기존 화면의 기능 추가만 지시하는 `admin-gift-cancel-addition-prompt.md`를 신규 작성했다.
- 왜: 운송장 등록 전 취소 정책을 명시하고, 기존 전체 페이지 구현 프롬프트를 변경하지 말라는 사용자 요청을 반영하기 위해서다.
- 어떻게: 네 문서의 `RECEIVED` 전용 취소, 발신자 리워드캔 전액 환불, body 없는 POST, 기존 관리자 응답 네 필드와 UTC 시각, 목록/상세 액션 및 미구현 표시를 대조했다. 신규 프롬프트 전체와 문서 링크도 확인했다.
- 검증: `./gradlew tasks --all` exit 0, `git diff --check` exit 0, 신규 프롬프트의 `git diff --no-index --check /dev/null docs/20260929_크리에이터_선물하기/admin-gift-cancel-addition-prompt.md` exit 0, 기존 `admin-page-prompts.md`의 `git diff --exit-code HEAD -- docs/20260929_크리에이터_선물하기/admin-page-prompts.md` exit 0.
- 변경 범위: 기존 문서 3개 수정과 기능 추가 프롬프트 1개 생성만 확인했다. 소스/테스트/설정/DDL과 기존 완료 Task·검증 이력은 변경하지 않았다.
- 미실행: 문서만 변경했으므로 focused/전체 테스트, lint, 애플리케이션 빌드와 브라우저 QA는 실행하지 않았다. 명령 확인은 기능 동작 검증을 의미하지 않는다.
- 다음 행동: 사용자 구현 승인 후 `P14-T2`의 첫 미완료 RED 단계부터 시작한다.
### 구현 작업 (사용자 승인 후 완료)
#### Task 14.2 관리자 취소 상태 전이와 환불 구현
**Goal 실행 `P14-T2`:** 기존 관리자 권한으로 `RECEIVED` 선물만 취소하고 발신자에게 전액 리워드캔 환불을 원자적으로 처리한다.
- **시작 조건:** `P14-T1` 검증과 사용자 구현 승인.
- **완료 증거:** service/controller focused test에서 상태, 환불, 권한, 공통 오류와 응답 계약 통과.
- **범위 밖:** 새로운 환불 체계/스키마/의존성, 취소 사유/감사 테이블, 취소 전용 푸시, 사용자 취소/자동취소 정책 변경, 전면 리팩터링.
**Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminService.kt` — `fun cancelGift(applicationNo: String, now: LocalDateTime = LocalDateTime.now()): GiftOperationStatusResult` 계약을 기존 관리자 operation method에 맞춰 추가한다.
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftController.kt` — body 없는 `POST /api/v2/admin/gifts/{applicationNo}/cancel`, 기존 `AdminGiftOperationStatusResponse`와 `ApiResponse.ok(...)` 사용.
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminServiceTest.kt`.
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt`.
- 조건부 확인/수정: `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftRepository.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftCommandService.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftScheduler.kt` — 경합 테스트가 입증한 상태 전이/환불 중복 원인에 한해 공통 경계를 최소 수정한다. 기존 사용자/자동취소 정책은 변경하지 않는다.
- [x] **RED:** service 테스트에서 `RECEIVED` 관리자 취소의 `CANCELED`, `GiftDelivery.canceledAt`, 원 결제액 전액 `refundGiftCan` 1회 호출을 검증한다. 발신자가 아닌 관리자도 가능하고, 존재하지 않거나 다른 상태면 기존 `common.error.invalid_request`이며 상태/환불은 변경되지 않는지 검증한다. 환불 실패 rollback 및 중복 환불 방지도 검증한다.
- [x] **RED:** `AdminGiftControllerTest`에 실제 Spring 트랜잭션을 통과하는 관리자 취소 통합 테스트를 추가한다. ADMIN 성공과 비관리자/미인증 거부, 모든 비허용 상태 거부, 응답 네 필드와 UTC 시각, 발신자의 실제 리워드캔/환불 내역 복원 및 관리자 잔액 불변을 검증한다. 사용자 취소·자동취소·운송장 등록·다른 관리자 취소와의 동시 요청, 환불 예외 시 DB 상태/취소 시각/환불 내역 롤백도 검증한다. mock 호출 횟수만으로 실제 환불/롤백/경합 검증을 대체하지 않는다.
- [x] **RED 확인:** `./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftAdminServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.in.web.AdminGiftControllerTest"` 실행. 새 assertion 또는 취소 메서드 미구현으로 실패해야 하며 설정/fixture 오류는 수정한 뒤 다시 확인한다.
- [x] **GREEN:** 기존 transaction, 예외, 캔 환불 경로와 `GiftOperationStatusResult`를 재사용해 최소 구현한다. 신규 push는 발행하지 않는다.
- [x] **GREEN 확인:** 같은 service focused test와 `./gradlew test --tests "kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.in.web.AdminGiftControllerTest"` 실행. 정상 envelope, 응답 필드 및 UTC `occurredAt=canceledAt`, 기존 ADMIN 권한 거부를 확인한다.
- [x] **REFACTOR:** 이번 변경으로 생긴 중복만 정리하고 아래 focused test와 `./gradlew ktlintCheck`를 실행해 실제 결과를 기록한다.
#### P14-T2 구현·검증 기록 — 2026-10-22
- 무엇을: ADMIN 취소 endpoint와 `GiftAdminService.cancelGift`를 추가하고 사용자 취소·운송장 등록·자동취소의 최초 선물 조회에 DB 쓰기 잠금을 적용했다. 기한 안내가 취소 시각을 덮어쓰는 경합도 RED로 확인해 동일 잠금을 적용했다. 기존 분산 job lock과 상태/환불 정책은 유지했다.
- RED: 기존 focused 92개 통과 후 신규 취소 HTTP 404 실패, service 메서드 미구현 실패, 잠금 없는 핵심 경합 7개 실패, 안내 경합 2개 실패를 각각 확인했다.
- GREEN: `./gradlew --no-daemon test --tests '*GiftAdminServiceTest' --tests '*AdminGiftControllerTest' --tests '*GiftCommandServiceTest' --tests '*GiftSchedulerTest' ktlintCheck` exit 0, 119개 테스트(25/30/50/14) 실패·오류·skip 0. 담당자 외 독립 재실행도 `test --rerun`으로 exit 0(48초)을 확인했다.
- 실제 DB 확인: 80캔 사용(충전 60 + 리워드 20)이 발신자 리워드 80으로 환불되고 관리자 잔액은 불변이다. 실제 환불/flush 후 예외 발생 시 상태·취소 시각·잔액·환불 내역이 모두 롤백된다. 9개 동시 요청 순서에서 최종 상태와 환불 1회 불변조건을 확인했다.
- 리뷰: 독립 T2 스펙/코드 리뷰 APPROVE, 차단 사항 없음. 커밋 `44bec81a`.
- 후속 사용자 요청: `44bec81a` 커밋은 취소하고 변경 파일을 작업 트리에 보존했다. 이후 커밋은 사용자가 확인 후 직접 수행하며 에이전트는 커밋하지 않는다.
- 검증 경계: H2/Spring 트랜잭션과 MockMvc 검증이다. 실제 socket HTTP는 후속 T3/Gate에서 수행하며 MySQL 실서버는 사용하지 않았다. LSP 도구가 없어 Kotlin 컴파일과 ktlint로 대체했다. 기존 타 스케줄러의 H2 `SKIP LOCKED` 오류 로그와 Gradle deprecation 경고는 별도 관찰로 남긴다.
#### Task 14.3 관리자 목록/상세 취소 액션 구현
**Goal 실행 `P14-T3`:** 관리자 목록과 상세에서 `RECEIVED`만 `CANCEL` 액션을 노출한다.
- **시작 조건:** `P14-T2` 완료.
- **완료 증거:** query service 테스트에서 모든 상태 매핑과 기존 `COMPLETE_DELIVERY` 수취인 정보 조건부 노출 통과.
- **범위 밖:** 다른 상태 액션 재설계, 관리자 UI 구현.
**Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminQueryService.kt`.
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminQueryServiceTest.kt`.
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt` — 목록/상세 JSON 액션 및 취소 후 `CANCELED`/빈 액션 재조회 검증.
- [x] **RED:** 목록과 상세 각각 `RECEIVED -> ["CANCEL"]`이며 다른 상태의 기존 액션이 그대로이고 `INSPECTION_COMPLETED`의 `COMPLETE_DELIVERY` 조건이 유지되는 테스트를 작성한다.
- [x] **RED 확인:** `./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftAdminQueryServiceTest"` 실행. 신규 assertion 실패를 확인한다.
- [x] **GREEN:** 두 응답의 공통 상태별 action 계산만 필요한 범위에서 수정한다.
- [x] **GREEN 확인:** `./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftAdminQueryServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.in.web.AdminGiftControllerTest"` 실행. 목록/상세 JSON 매핑, 취소 후 빈 액션과 기존 조건부 액션 통과를 확인한다.
- [x] **REFACTOR:** 이번 Task가 만든 중복만 정리하고 focused test와 `./gradlew ktlintCheck` 결과를 기록한다.
#### P14-T3 구현·검증 기록 — 2026-10-22
- 무엇을: 공통 `GiftStatus.availableActions`의 `RECEIVED` 분기에 `CANCEL`만 추가했다. 기존 모든 상태 매핑과 수취인 정보 조건을 보존했다.
- RED: 기존 34개 통과 후 신규 상세/실제 HTTP 목록 액션 assertion 2개만 실패하는 것을 확인했다. RANDOM_PORT fixture의 request context 오류는 수정 후 유효 RED를 재확인했으며 기능 실패로 계산하지 않았다.
- GREEN: `./gradlew --no-daemon test --tests '*GiftAdminQueryServiceTest' --tests '*AdminGiftControllerTest' ktlintCheck` exit 0, 35개(query 4 + controller 31) 실패·오류·skip 0. 기존 9개 경합·롤백 테스트도 포함됐다.
- 실제 HTTP: RANDOM_PORT Tomcat과 실제 TokenProvider JWT를 사용해 익명 401, 모든 비ADMIN 역할의 공통 실패 응답, ADMIN 목록/상세의 `RECEIVED/[CANCEL]`, body 없는 취소 성공, 반복 거부, 재조회 `CANCELED/[]`를 확인했다. 원 발신자 충전 40/리워드 0에서 리워드 80으로 환불되며 관리자 잔액과 환불 1회 조건을 실제 DB로 검증했다.
- 증거: `AdminGiftControllerTest.shouldCancelReceivedGiftThroughRealJwtHttpAndRequeryActions`의 `GIFT_HTTP` 출력에서 취소 성공 `success=true`, `status=CANCELED`, `statusName=신청 취소`, UTC `occurredAt` 네 필드를 확인했다. GET 증거는 개인정보를 제거하고 JWT는 출력하지 않았다.
- 리뷰: T2/T3 전체 코드·테스트 최종 리뷰 APPROVE, 차단 사항 없음.
### Phase 14 Gate
**Goal 실행 `P14-GATE`:** 승인된 구현의 취소, query action 및 경합 회귀를 판정한다.
- **시작 조건:** `P14-T2`, `P14-T3` 완료.
- **완료 증거:** focused test, 직접 영향 회귀, lint 통과 및 사용자 흐름 기록.
- **범위 밖:** 실패와 무관한 구현, 전체 선물 도메인 리팩터링.
```bash
./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftAdminServiceTest" --tests "kr.co.vividnext.sodalive.v2.gift.application.GiftAdminQueryServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.in.web.AdminGiftControllerTest"
./gradlew test --tests "kr.co.vividnext.sodalive.v2.gift.GiftCommandServiceTest" --tests "kr.co.vividnext.sodalive.v2.gift.adapter.out.scheduler.GiftSchedulerTest"
./gradlew ktlintCheck
```
**Expected:** focused 명령과 lint가 exit 0. 취소와 운송장 등록, 사용자 취소, 자동취소, 다른 관리자 취소가 경합해도 허용 상태 전이와 환불 1회 불변조건이 유지되고, 환불 실패 시 취소 상태가 커밋되지 않는다. 기존 테스트 구조 안에서 검증하며 새 의존성이나 스키마는 추가하지 않는다.
- [x] 위 focused test와 직접 영향 회귀, lint 결과를 기록한다.
- [x] 관리자 권한, 발신자 전액 환불, 목록/상세의 `CANCEL` 노출과 취소 후 빈 액션, 자동취소 전용 푸시 미발행을 검증한다.
- [x] 문서의 구현 예정 표시를 실제 검증된 상태로 갱신한다. 전체 회귀는 공통 경계 변경 또는 focused test로 영향 범위를 판단할 수 없는 경우에만 실행하고, 생략 시 근거와 대체 명령을 기록한다.
#### P14-GATE 최종 판정 — 2026-10-22
- 판정: 사용자 요청에 따른 전체 테스트 제외 범위에서 완료. 기능·실제 HTTP·권한·환불·롤백·경합 검증과 독립 최종 리뷰를 통과했다.
- 최종 빌드: `./gradlew --no-daemon build -x test ktlintCheck` exit 0, `BUILD SUCCESSFUL in 12s`. 최신 소스/테스트 컴파일 및 lint 산출물이 up-to-date이며 전체 build 작업이 성공했다.
- 전체 테스트: `./gradlew --no-daemon test --rerun ktlintCheck build` 1차는 도구의 600초 제한으로 중단됐다. 제한을 늘린 2차는 사용자가 중단했다. 이후 사용자가 전체 테스트를 제외하고 마무리하도록 지시해 재실행하지 않았다. 전체 테스트 성공/실패를 확정하지 않는다.
- 대체 증거: T2 focused 119개(독립 재실행 포함), T3 focused 35개(일부 중복), 실제 JWT socket HTTP 시나리오 및 최종 코드 리뷰 APPROVE. 테스트 삭제·skip·완화는 추가하지 않았다.
- 한계: LSP 도구가 없어 Kotlin 컴파일·ktlint로 대체했다. H2 검증이며 실제 MySQL REPEATABLE_READ/다중 인스턴스/운영 배포와 별도 프론트엔드 화면 QA는 수행하지 않았다. 기존 타 스케줄러 H2 문법 경고와 Gradle deprecation 경고는 수정 범위 밖이다.
- 프롬프트: 사용자 요청대로 서버 구현/준비 여부를 선행조건으로 두지 않고 확정 Request/Response와 테스트 fixture를 기준으로 프론트엔드 병행 구현을 안내한다. 기존 `admin-page-prompts.md`는 그대로 보존한다.
- 커밋: 생성했던 커밋은 사용자 요청으로 취소했다. 변경은 모두 unstaged/untracked 작업 트리에 유지하며 추가 커밋/푸시는 하지 않는다.
- 문서 검증: `./gradlew --no-daemon tasks --all` exit 0, `git diff --check` 통과. 기존 전체 프롬프트 비교와 index 비교는 각각 exit 0, HEAD는 작업 전 `45b97088`이다. `build/libs/sodalive-0.0.1-SNAPSHOT.jar` 산출물을 확인했다.
- 검증 기록 정정: P14-T1의 신규 파일 `git diff --no-index --check /dev/null ...` exit 0 표기는 부정확했다. 최종 확인에서는 공백 오류 출력 없이 신규 파일 차이로 exit 1이다. 기존 추적 파일 `git diff --check`는 exit 0이며, 이를 테스트/빌드 실패와 혼동하지 않는다.
- 자원 정리: 테스트 worker/Redis 프로세스 잔류 없음과 작업 전용 디렉터리에 열린 파일 없음 확인 후 `gift-p14.xbpOuF`, `gift-p14-impl.d9YMmw`, `gift-p14-t3.Hx06BJ` 및 임시 진행 노트를 삭제하고 네 경로의 부재를 확인했다. 검증 요약은 이 문서에 보존했으며 공유 Gradle 캐시와 build 산출물은 삭제하지 않았다.
### Phase 14 커밋 전 검증 — 2026-10-22
- 승인: 사용자가 `/commit-with-validation`으로 변경을 적절히 나누어 커밋하도록 후속 요청했다. 이전 커밋 보류 기록 이후의 새 승인으로 적용한다.
- 검증: `./gradlew --no-daemon test --rerun --tests '*GiftAdminServiceTest' --tests '*GiftAdminQueryServiceTest' --tests '*AdminGiftControllerTest' --tests '*GiftCommandServiceTest' --tests '*GiftSchedulerTest' ktlintCheck` exit 0, `BUILD SUCCESSFUL in 58s`.
- 결과: 관련 5개 클래스 124개 테스트에서 실패·오류·skip 0. lint는 UP-TO-DATE이며 `git diff --check`도 통과했다.
- 범위: 관리자 취소와 직접 영향받는 사용자 명령·스케줄러 회귀를 재실행했다. 기존 사용자 요청에 따라 전체 테스트는 제외했고 운영 MySQL 검증은 수행하지 않았다.
### Phase 14 후속 회귀 수정
#### Task 14.4 관리자 취소 시각 정밀도 회귀 수정
**Goal 실행 `P14-T4`:** 관리자 취소의 DB 재조회 `canceledAt`과 응답 `occurredAt`을 OS/JDK 시계 정밀도와 무관하게 일치시킨다.
- **시작 조건:** Jenkins의 `AdminGiftControllerTest` 458/526행 실패 보고 및 정밀도 정규화 수정 승인. H2 2.1.214 `timestamp`에 나노초 입력 시 마이크로초 반올림을 별도 JDBC로 재현했다.
- **정책:** 운영 DDL `canceled_at TIMESTAMP`에 맞춰 초 단위 절삭 후 저장·응답에 같은 값을 사용한다. 테스트 비교를 완화하거나 전역 `toUtcIso`, DDL, 사용자/자동취소 동작을 변경하지 않는다.
- **Files:** Modify `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminService.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminServiceTest.kt`; Verify `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt`; Update 기존 `prd.md`, `plan-task.md`.
- **완료 증거:** 고정 나노초·초 경계 입력의 DB flush/clear 재조회 테스트, 기존 실패 두 HTTP 테스트를 포함한 focused test, lint/테스트 제외 빌드, 변경 리뷰 통과.
- **범위 밖:** 전체 테스트(기존 사용자 제외 요청 유지), 커밋/푸시, 운영 DB/CI 설정 변경.
- [x] **RED:** `0`, `123456789`, `999999999` 나노초 입력을 사용해 초 단위 기대값과 DB 재조회/응답의 완전 일치를 검증하고 기존 코드의 실패를 확인한다.
- [x] **GREEN:** 관리자 취소 메서드에서 취소 시각을 한 번 초 단위로 정규화하고 저장·응답에 사용한다.
- [x] **REFACTOR/검증:** `./gradlew --no-daemon test --tests '*GiftAdminServiceTest' --tests '*AdminGiftControllerTest' ktlintCheck` 및 `./gradlew --no-daemon build -x test`를 실행한다. 기존 458/526행 assertion과 실제 JWT HTTP 검증은 유지한다.
#### P14-T4 진행 기록 — 2026-10-22
- RED: 신규 parameterized DB roundtrip 테스트 3개 중 2개 실패(exit 1, 42초). `23:59:59.123456789`는 DB에서 `.123457`로, `23:59:59.999999999`는 다음 날 `00:00`으로 반올림되어 응답과 달랐다. 나노초 0 입력은 통과했다.
- GREEN: 관리자 취소의 `now`를 `ChronoUnit.SECONDS`로 한 번 절삭해 저장과 결과에 같은 값을 사용했다. `./gradlew --no-daemon test --tests '*GiftAdminServiceTest' --tests '*AdminGiftControllerTest' ktlintCheck` exit 0(1분 26초), service 28 + controller 31 = 59개 실패·오류·skip 0.
- 실제 HTTP: `shouldCancelReceivedGiftThroughRealJwtHttpAndRequeryActions`에서 body 없는 POST가 `success=true`, `status=CANCELED`, `occurredAt=2026-10-22T19:05:41Z`를 반환하고 DB 재조회값과 정확히 일치했다. Jenkins에서 실패한 기존 두 assertion과 환불·경합 검증은 변경하지 않았다.
- 빌드/문서: `./gradlew --no-daemon build -x test tasks --all` exit 0(14초). 전체 테스트는 기존 사용자 제외 요청을 유지해 실행하지 않았다. 커밋/푸시 없음.
- 한계: Jenkins 재실행과 실제 운영 DB 검증은 하지 않았다. CI 원본 expected/actual은 제공되지 않았으며 로컬 고정 나노초 입력으로 해당 실패 메커니즘을 재현·검증했다. LSP 도구가 없어 Kotlin 컴파일·ktlint로 대체했다.
- 최종 판정: 독립 변경 리뷰 APPROVE, 차단 사항 없음. 기존 HTTP 458/526행의 완전 일치 검증과 전역 날짜 변환/DDL/환불 로직이 변경되지 않았음을 확인했다. `git diff --check` 통과, 후속 커밋 없음.
### Phase 14 실행 순서와 의존성
| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 |
|---:|---|---|---|---|
| 31 | `P14-T1` | 관리자 취소 승인 범위 문서화 | 아니요 | 문서 검증 |
| 32 | `P14-T2` | `P14-T1` 검증 및 사용자 구현 승인 | 아니요 | 승인 대기, 코드 변경 금지 |
| 33 | `P14-T3` | `P14-T2` 계약/승인 | 아니요 | 선행 구현 승인 대기 |
| 34 | `P14-GATE` | `P14-T2`, `P14-T3` | 아니요 | 실패 원인 Task에 반영 |
| 35 | `P14-T4` | Jenkins 정밀도 불일치 후속 수정 승인 | 아니요 | DB roundtrip 실패 원인 확인 |
```text
P14-T1 문서 검증 → 사용자 구현 승인 → P14-T2 → P14-T3 → P14-GATE
```
## Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
@@ -1442,6 +1637,10 @@ P7은 FCM 확장 후 전체 상태 전이를 연결하고 최종 회귀로 종
| 2026-09-30 | `PLAN-DEC-006` | 확정 | 선물 API response datetime은 전역 Jackson 설정이 아니라 DTO response boundary에서 UTC ISO 문자열로 변환한다 | 선물 API만의 공개 계약 보강이며 전역 변경은 기존 API 영향이 큼 | `P8-T1`, `client-api-summary.md` |
| 2026-09-30 | `PLAN-DEC-007` | 확정 | 기존 `categoryCode`는 내부 범용코드로 유지하고 분류번호/접수코드/대표품목을 추가하며, 신청번호는 카테고리+날짜별 sequence로 발급한다 | 사용자 확정: 카테고리 필드 의미와 사용자 폼 옵션 표시 규칙 분리 | `P9-T1`~`P9-T3` |
| 2026-10-02 | `PLAN-DEC-008` | 확정 | `COMPLETE_DELIVERY`는 수취인 필수 정보가 모두 입력된 경우에만 허용하고, 배송지 미입력 기한 초과는 주소 입력 가능 상태 3개 전체에서 `UNDELIVERABLE`로 종료한다 | 배송지 없는 선물의 전달 완료와 관리자 상태 진행 후 만료 누락 방지 | `P12-T1`, `P12-T2` |
| 2026-10-22 | `PLAN-DEC-009` | 확정 | Phase 14는 문서화만 먼저 진행하며 관리자 취소 구현은 사용자 별도 승인 후 시작한다. `RECEIVED` 전액 환불은 기존 경로와 관리자 응답/action 계약을 재사용한다 | 승인된 범위와 구현 전 사람 승인 경계 | `P14-T1`~`P14-GATE`, PRD `DEC-015` |
| 2026-10-22 | `PLAN-DEC-010` | 확정 | 기존 `admin-page-prompts.md`는 보존하고 기존 화면에 취소 기능만 추가하는 프롬프트를 `admin-gift-cancel-addition-prompt.md`로 분리한다 | 사용자 수정 요청 | `P14-T1`, PRD `DEC-016` |
| 2026-10-22 | `PLAN-DEC-011` | 확정 | 프론트엔드 추가 프롬프트에서 서버 준비 대기 조건을 제거하고 확정 API 계약으로 병행 구현한다 | 사용자 후속 요청 | `admin-gift-cancel-addition-prompt.md` |
| 2026-10-22 | `PLAN-DEC-012` | 확정 | 전체 테스트를 제외하고 관련 검증·테스트 제외 빌드·문서·임시 자원 정리로 마무리한다. 커밋은 사용자가 직접 한다 | 사용자 마무리 및 커밋 금지 요청 | `P14-GATE` |
## 발견된 문제
@@ -4,15 +4,17 @@
| 항목 | 내용 |
|---|---|
| 문서 상태 | 구현 중 |
| 문서 상태 | Phase 14 서버 구현 완료, 전체 테스트 제외 검증 완료 |
| 작성일 | 2026-09-29 |
| 최종 수정일 | 2026-09-30 |
| 최종 수정일 | 2026-10-22 |
| 대상 제품 | 크리에이터 선물하기 |
| 작성자·결정권자 | 사용자 |
| 관련 API Contract | 별도 문서 없음. 이 문서의 `8. API 계약`을 기준으로 사용 |
| 관련 구현 계획 | `docs/20260929_크리에이터_선물하기/plan-task.md` |
| 관련 review | 없음 |
> Phase 14 관리자 취소와 목록·상세 `CANCEL` 액션은 서버 코드에 구현됐다. 관련 테스트·실제 JWT HTTP·리뷰·테스트 제외 빌드를 검증했으며, 전체 테스트는 사용자 요청으로 완료 기준에서 제외했다. 운영 배포 여부와 별도 프론트엔드 구현 여부는 이 문서의 완료 상태에 포함하지 않는다.
## 1. Overview
팬이 내부 재화인 캔을 사용해 크리에이터에게 실물 선물을 보낼 수 있는 API를 제공한다.
@@ -34,6 +36,7 @@
- 팬이 선물 사이즈, 카테고리, 발신자 정보, 약관 동의를 입력해 선물 보내기를 신청할 수 있다.
- 선물 신청 시 캔을 즉시 차감하고 내부적으로 선물 신청과 캔 사용내역을 연결한다.
- 팬은 운송장 등록 전 선물 신청을 취소할 수 있고, 3일 내 운송장을 등록하지 않으면 자동취소 및 전액 환불된다.
- 관리자는 운송장 등록 전 `RECEIVED` 선물을 취소하고 발신자에게 사용 캔 전액을 리워드캔으로 환불할 수 있다.
- 크리에이터는 팬이 운송장을 등록한 선물부터 받은 선물 목록과 상세를 조회하고 7일 이내 배송지와 수령 약관 동의를 등록할 수 있다.
- 팬과 크리에이터는 같은 선물함 목록 API에서 보낸/받은 내역을 함께 또는 개별 조회할 수 있다.
- 운영자는 상태별 전용 관리자 API로 사서함 도착, 검수완료, 전달불가, 전달완료를 처리할 수 있다.
@@ -55,7 +58,7 @@
|---|---|---|---|
| 팬 | 크리에이터에게 선물을 보낸다 | 사이즈/카테고리 조회, 신청, 운송장 등록, 취소, 보낸 내역 조회, 리뷰 작성 | 인증 회원 |
| 크리에이터 | 받은 선물을 확인하고 배송지를 입력한다 | 받은 내역 조회, 배송지 입력, 수령 확인 | 인증 회원 중 크리에이터 |
| 관리자 | 선물 운영 설정을 관리한다 | 카테고리, 사이즈 가격 관리 | 관리자 |
| 관리자 | 선물 운영 설정과 선물 상태를 관리한다 | 카테고리, 사이즈 가격 관리, `RECEIVED` 선물 취소 | 관리자 |
| 운영자 | 선물 검수/전달 상태를 처리한다 | 사서함 도착, 검수완료, 전달불가, 전달완료 처리 | 관리자 또는 운영 권한 |
| 스케줄러 | 기한 초과 건을 자동 처리한다 | 운송장 미등록 자동취소, 배송지 미입력 전달불가 | 시스템 |
@@ -74,7 +77,7 @@
| `INSPECTION_COMPLETED` | 검수완료 | 운영자가 선물 검수를 통과 처리한 상태 | 아니오 |
| `DELIVERED` | 전달완료 | 크리에이터 수령확인 또는 운영자 전달완료 처리 상태 | 예 |
| `UNDELIVERABLE` | 전달불가 | 검수 실패 또는 배송지 미입력 기한 초과로 전달할 수 없는 상태 | 예 |
| `CANCELED` | 신청 취소 | 운송장 등록 전 사용자 취소 또는 자동취소 상태 | 예 |
| `CANCELED` | 신청 취소 | 운송장 등록 전 사용자 취소, ADMIN 취소 또는 자동취소 상태 | 예 |
상태 전이는 다음을 기본으로 한다.
@@ -171,6 +174,7 @@ INSPECTION_COMPLETED -> UNDELIVERABLE
- 선물 신청과 캔 사용내역은 내부적으로 연결되어야 한다.
- 사용자 캔 사용내역 화면에서 선물 신청으로 이동할 수 없어도 된다.
- 운송장 등록 전 사용자 취소 또는 3일 자동취소 시 사용 캔을 전액 환불한다.
- ADMIN은 운송장 등록 전 `RECEIVED`에서 취소할 수 있으며 기존 `refundGiftCan` 경로로 발신자에게 전액 리워드캔 환불한다. 상태 변경, 취소 시각 저장, 환불은 같은 트랜잭션에서 처리한다.
- 운송장 등록 후에는 팬이 취소할 수 없다.
### 6.8 목록 노출
@@ -248,6 +252,8 @@ INSPECTION_COMPLETED -> UNDELIVERABLE
| `GIFT-012` | 확정 | 관리자는 카테고리를 등록/수정/논리삭제할 수 있다 | 삭제는 `isActive=false`만 수행한다 | `P1` |
| `GIFT-013` | 확정 | 관리자는 사이즈별 가격을 수정할 수 있다 | 새 신청에는 변경 가격이 적용되고 기존 신청 스냅샷은 바뀌지 않는다 | `P1` |
| `GIFT-015` | 확정 | 관리자는 전체 선물함 목록과 상세를 조회하고 현재 상태에서 가능한 운영 액션을 확인할 수 있다 | 목록은 상태/신청번호/닉네임 검색과 페이징을 지원하고, 상세는 발송인/수취인 개인정보와 상품/배송/상태 정보를 내려준다 | `P10` |
| `GIFT-016` | 확정 | 관리자는 운송장 등록 전 접수된 선물을 취소할 수 있다 | 관리자만 호출할 수 있고 `RECEIVED`에서만 `CANCELED`로 전이하며 `GiftDelivery.canceledAt`을 저장하고 발신자에게 사용 캔 전액을 리워드캔으로 환불한다 | `P14-T2` |
| `GIFT-017` | 확정 | 관리자 선물함 목록과 상세는 현재 가능한 액션으로 취소 가능 여부를 함께 제공한다 | `RECEIVED`에서만 `availableActions=["CANCEL"]`; 다른 상태의 기존 액션은 유지한다 | `P14-T3` |
## 8. API 계약
@@ -843,11 +849,11 @@ Request:
Response는 저장된 받을 주소를 `name`, `address`, `phoneNumber`로 내려준다. `address`는 기존 주소 응답과 동일하게 `(우편번호) 주소, 상세주소` 형식이다. `name`, `phoneNumber`, `zipCode`, `address`는 필수이며 공백만 입력할 수 없다. `addressDetail`은 선택이다.
`availableActions`는 현재 상태별로 다음 값만 내려준다.
관리자 목록과 상세의 `availableActions`는 상태별로 다음 값을 내려준다. `RECEIVED`에서는 `CANCEL`을 제공하고, 다른 상태의 기존 매핑은 유지한다.
| 현재 상태 | availableActions |
|---|---|
| `RECEIVED` | `[]` |
| `RECEIVED` | `["CANCEL"]` |
| `TRACKING_REGISTERED` | `["ARRIVE_MAILBOX", "MARK_UNDELIVERABLE"]` |
| `ARRIVED_AT_MAILBOX` | `["COMPLETE_INSPECTION", "MARK_UNDELIVERABLE"]` |
| `INSPECTION_COMPLETED`, 수취인 정보 입력 완료 | `["COMPLETE_DELIVERY", "MARK_UNDELIVERABLE"]` |
@@ -864,6 +870,7 @@ Response는 저장된 받을 주소를 `name`, `address`, `phoneNumber`로 내
| POST | `/api/v2/admin/gifts/{applicationNo}/complete-inspection` | `ARRIVED_AT_MAILBOX` | `INSPECTION_COMPLETED` | 없음 |
| POST | `/api/v2/admin/gifts/{applicationNo}/mark-undeliverable` | `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED` | `UNDELIVERABLE` | `reason` |
| POST | `/api/v2/admin/gifts/{applicationNo}/complete-delivery` | `INSPECTION_COMPLETED` | `DELIVERED` | 저장된 수취인 이름, 휴대폰 번호, 우편번호, 주소 |
| POST | `/api/v2/admin/gifts/{applicationNo}/cancel` | `RECEIVED` | `CANCELED` | 없음 |
`complete-delivery`는 `recipientName`, `recipientPhoneNumber`, `recipientZipCode`, `recipientAddress`가 모두 null 또는 공백이 아닐 때만 성공한다. `recipientAddressDetail`은 선택 입력이다. 조건을 만족하지 않으면 상태, 전달 완료 시각, 푸시를 변경하지 않고 요청을 거부한다.
@@ -875,6 +882,31 @@ Response는 저장된 받을 주소를 `name`, `address`, `phoneNumber`로 내
}
```
#### 8.14.1 관리자 선물 신청 취소
`POST /api/v2/admin/gifts/{applicationNo}/cancel`
Request body: 없음
기존 관리자 API의 `hasRole('ADMIN')` 권한을 따르며 관리자 계정은 선물 발신자가 아니어도 취소할 수 있다. `RECEIVED`에서만 `CANCELED`로 전이하며 운송장 등록 이후에는 취소할 수 없다. 같은 트랜잭션에서 `GiftDelivery.canceledAt`을 기록하고 기존 `refundGiftCan(gift.senderMemberId, gift.canUsageId)` 경로로 발신자에게 원 결제액 전액을 리워드캔으로 환불한다. 관리자의 잔액은 변경하지 않는다.
Response `data`는 기존 `AdminGiftOperationStatusResponse`를 사용한다.
```json
{
"applicationNo": "A-1002609300001",
"status": "CANCELED",
"statusName": "신청 취소",
"occurredAt": "2026-09-30T12:00:00Z"
}
```
`occurredAt`은 `canceledAt`과 같은 UTC ISO `Z` 문자열이다. 누락되거나 취소할 수 없는 상태의 신청번호는 기존 `SodaException(messageKey = "common.error.invalid_request")`로 거부한다. 정상 `ApiResponse` envelope를 사용하며 신규 오류 스키마나 HTTP status 정책은 추가하지 않는다.
관리자 취소 시각은 `gift-schema.sql`의 `canceled_at TIMESTAMP` 정밀도에 맞춰 초 단위로 절삭한 단일 값을 저장과 응답에 사용한다. OS/JDK 시계가 나노초를 반환해도 DB 재조회 시각과 응답 시각은 일치해야 한다. 전역 날짜 변환 및 다른 상태 변경의 시각 정책은 변경하지 않는다.
이 관리자 취소는 기존 사용자 직접 취소와 운송장 미등록 자동취소를 변경하지 않는다. 취소 전용 푸시는 추가하지 않으며, 자동취소 푸시 문구나 자동취소 사유를 관리자 취소에 재사용하지 않는다.
### 8.15 자동 스케줄링 작업
| 작업 | 대상 | 처리 | 푸시 |
@@ -1014,6 +1046,9 @@ Response는 저장된 받을 주소를 `name`, `address`, `phoneNumber`로 내
- 푸시는 팬/크리에이터 수신자 분리와 크리에이터 수령확인 시 크리에이터 푸시 억제를 테스트한다.
- 스케줄러는 중복 실행 시 중복 환불/중복 푸시가 없는지 테스트한다.
- 선물 사용자/관리자 API response DTO의 날짜/시간 필드는 UTC ISO 문자열(`Z` 포함)로 직렬화되는지 controller test로 고정한다.
- 관리자 취소는 `RECEIVED`만 허용하고, 발신자에게 전액 리워드캔 환불, `canceledAt` 저장, 환불 실패 시 트랜잭션 롤백 및 중복 환불 방지를 검증한다. 운송장 등록, 사용자 취소, 자동취소, 다른 관리자 취소와의 동시 경합에서도 잘못된 이중 처리/환불이 없어야 한다.
- 관리자 취소 controller는 기존 관리자 인증/권한, 공통 invalid request, 정상 `ApiResponse` envelope와 `AdminGiftOperationStatusResponse`의 UTC `occurredAt`을 검증한다.
- 관리자 목록과 상세 모두 `RECEIVED`에서만 `CANCEL` 액션을 제공하며 기존 `COMPLETE_DELIVERY` 조건부 액션을 포함한 나머지 상태 매핑은 유지한다.
- 카테고리 `classificationNumber`, `receiptCode`, `representativeItem`의 관리자 등록/수정/조회와 사용자 폼 옵션의 `분류명/대표품목` 조합을 테스트한다.
- 신청번호는 카테고리+날짜별 sequence, 4자리 padding, `9999` 초과 자리수 증가, 병렬 unique를 테스트한다.
@@ -1029,6 +1064,7 @@ Response는 저장된 받을 주소를 `name`, `address`, `phoneNumber`로 내
- [ ] 팬/크리에이터 푸시가 지정된 문구, 수신자, 딥링크로 발송된다.
- [ ] 선물 사용자/관리자 API의 날짜/시간 응답을 클라이언트가 UTC ISO 문자열로 해석할 수 있다.
- [ ] 카테고리 추가 필드와 새 신청번호 정책이 관리자 API, 사용자 폼 옵션, 선물 신청에 반영된다.
- [x] 관리자 취소는 `RECEIVED`에서만 전액 환불과 함께 성공하고, 목록/상세의 `CANCEL` 액션은 상태에 맞게 제공된다.
## 14. Open Questions
@@ -1045,6 +1081,7 @@ Response는 저장된 받을 주소를 `name`, `address`, `phoneNumber`로 내
| `GIFT-010` | 5 | 리뷰 API 테스트 | 전달완료 후 리뷰 작성 확인 |
| `GIFT-011` | 6 | 푸시 이벤트 테스트 | 딥링크와 수신자 확인 |
| `GIFT-014` | 8 | 선물 사용자/관리자 controller 날짜 응답 테스트 | UTC ISO 문자열 확인 |
| `GIFT-016`, `GIFT-017` | 14 | 관리자 취소 service/controller 및 query service 테스트 | `RECEIVED` 액션과 취소/환불 결과 확인 |
## 16. Decision Log
@@ -1064,3 +1101,9 @@ Response는 저장된 받을 주소를 `name`, `address`, `phoneNumber`로 내
| 2026-09-29 | `DEC-012` | 확정 | 파손면책 동의 필요 카테고리는 팬 신청 시 `damageWaiverAgreed=true`를 요구한다 | 사용자 선택 A | `GIFT-003` |
| 2026-09-30 | `DEC-013` | 확정 | 선물 사용자/관리자 API의 response datetime 필드는 `LocalDateTime` 기본 JSON 대신 UTC ISO 문자열로 내려준다 | 클라이언트가 offset 없는 `LocalDateTime` 문자열을 안전하게 해석하지 못함 | `GIFT-014` |
| 2026-09-30 | `DEC-014` | 확정 | 카테고리는 분류번호, 내부 범용 카테고리 코드, 분류명, 접수코드, 대표품목, 파손면책 조건을 관리하고 신청번호는 `receiptCode-classificationNumberYYMMDDSequenceNo`로 발급한다 | 관리자 운영 필드와 사용자 표시 요구가 분리됨 | `GIFT-001`, `GIFT-002`, `GIFT-012` |
| 2026-10-22 | `DEC-015` | 확정 | 관리자 취소는 `RECEIVED`에서만 허용하고 기존 `refundGiftCan`으로 발신자에게 전액 리워드캔을 환불한다. 기존 operation status 응답을 재사용하며 관리자 취소 전용 푸시는 이번 범위에서 추가하지 않는다 | 사용자 확정: 취소 허용 상태와 문서 우선 반영. 응답/환불은 기존 계약 재사용, 신규 푸시는 요청 범위 밖 | `GIFT-016`, `GIFT-017`, `P14` |
| 2026-10-22 | `DEC-016` | 확정 | 기존 관리자 화면 전체 구현 프롬프트는 보존하고 취소 기능 추가 프롬프트를 `admin-gift-cancel-addition-prompt.md`로 분리한다 | 사용자 요청: 기존 페이지에 해당 기능만 추가하는 별도 프롬프트 | `GIFT-016`, `GIFT-017`, `P14-T1` |
## 문서 상태 안내
Phase 14 서버 구현과 관련 검증은 완료했다. 전체 테스트는 1차 실행이 도구의 10분 제한으로 중단되고 2차는 사용자 중단 후 제외됐다. 전체 테스트 통과를 주장하지 않으며 검증 명령과 한계는 `plan-task.md`의 P14-GATE 기록을 따른다. 관리자 프론트엔드는 별도 기능 추가 프롬프트의 확정 계약을 사용한다.
@@ -83,6 +83,11 @@ class AdminGiftController(
@RequestBody request: AdminGiftSizePriceRequest
) = ApiResponse.ok(AdminGiftSizePriceResponse.from(service.updateSizePrice(sizeCode, request.toCommand())))
@PostMapping("/gifts/{applicationNo}/cancel")
fun cancelGift(@PathVariable applicationNo: String) = ApiResponse.ok(
AdminGiftOperationStatusResponse.from(service.cancelGift(applicationNo))
)
@PostMapping("/gifts/{applicationNo}/arrive-mailbox")
fun arriveMailbox(@PathVariable applicationNo: String) = ApiResponse.ok(
AdminGiftOperationStatusResponse.from(service.arriveMailbox(applicationNo))
@@ -4,13 +4,19 @@ 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.Lock
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import java.time.LocalDateTime
import javax.persistence.LockModeType
interface GiftRepository : JpaRepository<Gift, Long> {
fun findByApplicationNo(applicationNo: String): Gift?
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select g from Gift g where g.applicationNo = :applicationNo")
fun findByApplicationNoForUpdate(@Param("applicationNo") applicationNo: String): Gift?
fun findBySenderMemberId(senderMemberId: Long, pageable: Pageable): Page<Gift>
fun findByRecipientMemberIdAndStatusIn(
@@ -74,6 +80,7 @@ interface GiftRepository : JpaRepository<Gift, Long> {
)
"""
)
@Lock(LockModeType.PESSIMISTIC_WRITE)
fun findReceivedGiftsWithTrackingDeadlineBefore(@Param("now") now: LocalDateTime): List<Gift>
@Query(
@@ -113,6 +120,7 @@ interface GiftRepository : JpaRepository<Gift, Long> {
)
"""
)
@Lock(LockModeType.PESSIMISTIC_WRITE)
fun findReceivedGiftsWithTrackingDeadlineReminderDue(
@Param("now") now: LocalDateTime,
@Param("reminderUntil") reminderUntil: LocalDateTime
@@ -157,6 +157,7 @@ class GiftAdminQueryService(
)
private fun GiftStatus.availableActions(delivery: GiftDelivery) = when (this) {
GiftStatus.RECEIVED -> listOf("CANCEL")
GiftStatus.TRACKING_REGISTERED -> listOf("ARRIVE_MAILBOX", "MARK_UNDELIVERABLE")
GiftStatus.ARRIVED_AT_MAILBOX -> listOf("COMPLETE_INSPECTION", "MARK_UNDELIVERABLE")
GiftStatus.INSPECTION_COMPLETED -> if (delivery.hasCompleteRecipientInformation()) {
@@ -164,7 +165,7 @@ class GiftAdminQueryService(
} else {
listOf("MARK_UNDELIVERABLE")
}
GiftStatus.RECEIVED, GiftStatus.DELIVERED, GiftStatus.UNDELIVERABLE, GiftStatus.CANCELED -> emptyList()
GiftStatus.DELIVERED, GiftStatus.UNDELIVERABLE, GiftStatus.CANCELED -> emptyList()
}
private fun GiftDelivery.recipientAddressResult(): String {
@@ -1,5 +1,6 @@
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.v2.gift.adapter.out.persistence.GiftCategory
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository
@@ -15,6 +16,7 @@ import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
data class GiftCategoryCommand(
val classificationNumber: String,
@@ -73,7 +75,8 @@ class GiftAdminService(
private val giftRepository: GiftRepository,
private val giftDeliveryRepository: GiftDeliveryRepository,
private val giftMailboxRepository: GiftMailboxRepository,
private val giftPushService: GiftPushService
private val giftPushService: GiftPushService,
private val canPaymentService: CanPaymentService
) {
@Transactional(readOnly = true)
fun getMailbox(): GiftMailboxResult? = giftMailboxRepository.findByIdOrNull(GiftMailbox.SINGLETON_ID)?.toResult()
@@ -150,6 +153,20 @@ class GiftAdminService(
return sizePriceRepository.save(sizePrice).toResult()
}
@Transactional
fun cancelGift(applicationNo: String, now: LocalDateTime = LocalDateTime.now()): GiftOperationStatusResult {
val gift = giftRepository.findByApplicationNoForUpdate(applicationNo) ?: throw invalidRequest()
if (gift.status != GiftStatus.RECEIVED) throw invalidRequest()
val delivery = giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)) ?: throw invalidRequest()
val canceledAt = now.truncatedTo(ChronoUnit.SECONDS)
gift.status = GiftStatus.CANCELED
delivery.canceledAt = canceledAt
canPaymentService.refundGiftCan(gift.senderMemberId, gift.canUsageId)
return GiftOperationStatusResult(gift.applicationNo, gift.status, gift.status.displayName, canceledAt)
}
@Transactional
fun arriveMailbox(applicationNo: String, now: LocalDateTime = LocalDateTime.now()): GiftOperationStatusResult {
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
@@ -207,7 +207,7 @@ class GiftCommandService(
@Transactional
fun cancelGift(member: Member, applicationNo: String): GiftCancellationResult {
val senderMemberId = member.id ?: throw invalidRequest()
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
val gift = giftRepository.findByApplicationNoForUpdate(applicationNo) ?: throw invalidRequest()
if (gift.senderMemberId != senderMemberId || gift.status != GiftStatus.RECEIVED) throw invalidRequest()
val canceledAt = now()
@@ -231,7 +231,7 @@ class GiftCommandService(
command: GiftTrackingRegistrationCommand
): GiftTrackingRegistrationResult {
val senderMemberId = member.id ?: throw invalidRequest()
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
val gift = giftRepository.findByApplicationNoForUpdate(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()
@@ -1,7 +1,26 @@
package kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.`in`.web
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.node.ObjectNode
import kr.co.vividnext.sodalive.can.charge.Charge
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
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.UseCanCalculateRepository
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
import kr.co.vividnext.sodalive.can.use.UseCanRepository
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.extensions.toUtcIso
import kr.co.vividnext.sodalive.jwt.TokenProvider
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.member.MemberRole
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
@@ -11,16 +30,42 @@ import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepo
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.adapter.out.scheduler.GiftScheduler
import kr.co.vividnext.sodalive.v2.gift.application.GiftCommandService
import kr.co.vividnext.sodalive.v2.gift.application.GiftPushService
import kr.co.vividnext.sodalive.v2.gift.application.GiftTrackingRegistrationCommand
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.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.EnumSource
import org.junit.jupiter.params.provider.MethodSource
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.boot.test.mock.mockito.SpyBean
import org.springframework.boot.test.web.client.TestRestTemplate
import org.springframework.boot.test.web.server.LocalServerPort
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.mock.web.MockHttpServletRequest
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.test.annotation.DirtiesContext
import org.springframework.test.context.ContextConfiguration
@@ -29,11 +74,24 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delet
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.MockMvcResultHandlers.print
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.support.TransactionTemplate
import org.springframework.web.context.request.RequestContextHolder
import org.springframework.web.context.request.ServletRequestAttributes
import java.time.LocalDateTime
import java.util.UUID
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
import java.util.concurrent.atomic.AtomicInteger
import javax.persistence.EntityManager
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
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;" +
@@ -50,15 +108,41 @@ class AdminGiftControllerTest @Autowired constructor(
private val sizePriceRepository: GiftSizePriceRepository,
private val giftRepository: GiftRepository,
private val giftDeliveryRepository: GiftDeliveryRepository,
private val memberRepository: MemberRepository
private val memberRepository: MemberRepository,
private val chargeRepository: ChargeRepository,
private val useCanRepository: UseCanRepository,
private val calculateRepository: UseCanCalculateRepository,
private val commandService: GiftCommandService,
private val scheduler: GiftScheduler,
private val transactionManager: PlatformTransactionManager,
private val entityManager: EntityManager,
private val objectMapper: ObjectMapper,
private val restTemplate: TestRestTemplate,
private val tokenProvider: TokenProvider
) {
@LocalServerPort
private var port: Int = 0
@SpyBean
private lateinit var canPaymentService: CanPaymentService
@MockBean
private lateinit var giftPushService: GiftPushService
@BeforeEach
fun deleteGiftFixtures() {
RequestContextHolder.setRequestAttributes(ServletRequestAttributes(MockHttpServletRequest()))
giftDeliveryRepository.deleteAll()
giftRepository.deleteAll()
categoryRepository.deleteAll()
}
@AfterEach
fun clearRequestContext() {
(RequestContextHolder.getRequestAttributes() as? ServletRequestAttributes)?.requestCompleted()
RequestContextHolder.resetRequestAttributes()
}
@Test
@DisplayName("관리자는 받을 주소를 조회하고 저장한다")
fun shouldManageGiftMailbox() {
@@ -352,6 +436,448 @@ class AdminGiftControllerTest @Autowired constructor(
.andExpect(jsonPath("$.success").value(false))
}
@Test
@DisplayName("발신자가 아닌 관리자는 접수 선물을 취소하고 원 결제액을 발신자 리워드캔으로 한 번만 환불한다")
fun shouldCancelReceivedGiftAndRefundOriginalSenderOnlyOnce() {
val fixture = saveFundedGift()
assertNotEquals(fixture.sender.id, fixture.admin.id)
assertRefundState(fixture, refunded = false)
val response = mockMvc.perform(cancelRequest(fixture))
.andDo(print())
.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.occurredAt").value(endsWith("Z")))
.andReturn().response
val data = objectMapper.readTree(response.contentAsByteArray).path("data")
assertEquals(setOf("applicationNo", "status", "statusName", "occurredAt"), data.fieldNames().asSequence().toSet())
val canceledAt = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(fixture.gift.id))?.canceledAt)
assertEquals(canceledAt.toUtcIso(), data.path("occurredAt").asText())
assertEquals(GiftStatus.CANCELED, giftRepository.findByApplicationNo(APPLICATION_NO)?.status)
assertRefundState(fixture, refunded = true)
mockMvc.perform(cancelRequest(fixture))
.andDo(print())
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(false))
.andExpect { assertEquals("common.error.invalid_request", (it.resolvedException as SodaException).messageKey) }
assertEquals(canceledAt, giftDeliveryRepository.findByGiftId(requireNotNull(fixture.gift.id))?.canceledAt)
assertRefundState(fixture, refunded = true)
Mockito.verifyNoInteractions(giftPushService)
}
@Test
@DisplayName("실제 JWT HTTP에서 관리자만 접수 선물의 취소 액션을 조회하고 한 번 취소·환불한다")
fun shouldCancelReceivedGiftThroughRealJwtHttpAndRequeryActions() {
val fixture = saveFundedGift()
val listPath = "/api/v2/admin/gifts?applicationNo=$APPLICATION_NO"
val detailPath = "/api/v2/admin/gifts/$APPLICATION_NO"
val cancelPath = "$detailPath/cancel"
val endpoints = listOf(HttpMethod.GET to listPath, HttpMethod.GET to detailPath, HttpMethod.POST to cancelPath)
assertNotEquals(fixture.sender.id, fixture.admin.id)
assertRefundState(fixture, refunded = false)
endpoints.forEach { (method, path) ->
giftHttp(method, path, expectedStatus = HttpStatus.UNAUTHORIZED)
}
MemberRole.values().filter { it != MemberRole.ADMIN }.forEach { role ->
val member = if (role == MemberRole.USER) {
fixture.sender
} else {
memberRepository.saveAndFlush(
Member(email = "${UUID.randomUUID()}@test.com", password = "password", nickname = "nonadmin", role = role)
)
}
val token = tokenFor(member)
endpoints.forEach { (method, path) ->
val denied = giftHttp(method, path, token)
assertFalse(denied.path("success").asBoolean())
assertEquals("You do not have permission.", denied.path("message").asText())
assertTrue(denied.path("data").isNull)
}
}
assertEquals(GiftStatus.RECEIVED, giftRepository.findByApplicationNo(APPLICATION_NO)?.status)
assertEquals(null, giftDeliveryRepository.findByGiftId(requireNotNull(fixture.gift.id))?.canceledAt)
assertRefundState(fixture, refunded = false)
val adminToken = tokenFor(fixture.admin)
val beforeList = giftHttp(HttpMethod.GET, listPath, adminToken)
val beforeDetail = giftHttp(HttpMethod.GET, detailPath, adminToken)
listOf(beforeList, beforeDetail).forEach { assertTrue(it.path("success").asBoolean()) }
assertEquals(1, beforeList.path("data").path("totalCount").asInt())
listOf(beforeList.path("data").path("items").single(), beforeDetail.path("data")).forEach { data ->
assertEquals(APPLICATION_NO, data.path("applicationNo").asText())
assertEquals("RECEIVED", data.path("status").asText())
assertEquals(listOf("CANCEL"), data.path("availableActions").map { it.asText() })
}
val canceled = giftHttp(HttpMethod.POST, cancelPath, adminToken)
assertTrue(canceled.path("success").asBoolean())
val data = canceled.path("data")
assertEquals(setOf("applicationNo", "status", "statusName", "occurredAt"), data.fieldNames().asSequence().toSet())
assertEquals(APPLICATION_NO, data.path("applicationNo").asText())
assertEquals("CANCELED", data.path("status").asText())
assertEquals("신청 취소", data.path("statusName").asText())
assertTrue(data.path("occurredAt").asText().endsWith("Z"))
val canceledAt = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(fixture.gift.id))?.canceledAt)
assertEquals(canceledAt.toUtcIso(), data.path("occurredAt").asText())
assertRefundState(fixture, refunded = true)
val repeated = giftHttp(HttpMethod.POST, cancelPath, adminToken)
assertFalse(repeated.path("success").asBoolean())
assertEquals("Invalid request.", repeated.path("message").asText())
assertTrue(repeated.path("data").isNull)
assertEquals(canceledAt, giftDeliveryRepository.findByGiftId(requireNotNull(fixture.gift.id))?.canceledAt)
assertRefundState(fixture, refunded = true)
val afterList = giftHttp(HttpMethod.GET, listPath, adminToken)
val afterDetail = giftHttp(HttpMethod.GET, detailPath, adminToken)
listOf(afterList, afterDetail).forEach { assertTrue(it.path("success").asBoolean()) }
assertEquals(1, afterList.path("data").path("totalCount").asInt())
listOf(afterList.path("data").path("items").single(), afterDetail.path("data")).forEach { item ->
assertEquals(APPLICATION_NO, item.path("applicationNo").asText())
assertEquals("CANCELED", item.path("status").asText())
assertEquals(emptyList<String>(), item.path("availableActions").map { it.asText() })
}
Mockito.verifyNoInteractions(giftPushService)
}
@ParameterizedTest
@EnumSource(value = GiftStatus::class, names = ["RECEIVED"], mode = EnumSource.Mode.EXCLUDE)
@DisplayName("관리자 취소는 모든 비접수 상태를 공통 오류로 거부하고 DB를 변경하지 않는다")
fun shouldRejectCancellationForEveryNonReceivedStatus(giftStatus: GiftStatus) {
val fixture = saveFundedGift()
TransactionTemplate(transactionManager).executeWithoutResult {
requireNotNull(giftRepository.findByApplicationNo(APPLICATION_NO)).status = giftStatus
}
mockMvc.perform(post("/api/v2/admin/gifts/$APPLICATION_NO/cancel").with(user("admin").roles("ADMIN")))
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(false))
.andExpect { assertEquals("common.error.invalid_request", (it.resolvedException as SodaException).messageKey) }
val gift = requireNotNull(giftRepository.findByApplicationNo(APPLICATION_NO))
assertEquals(giftStatus, gift.status)
assertEquals(null, giftDeliveryRepository.findByGiftId(requireNotNull(gift.id))?.canceledAt)
assertRefundState(fixture, refunded = false)
}
@Test
@DisplayName("관리자 취소는 존재하지 않는 신청번호를 공통 오류로 거부한다")
fun shouldRejectCancellationForMissingGift() {
mockMvc.perform(post("/api/v2/admin/gifts/missing/cancel").with(user("admin").roles("ADMIN")))
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(false))
.andExpect { assertEquals("common.error.invalid_request", (it.resolvedException as SodaException).messageKey) }
}
@Test
@DisplayName("미인증 사용자와 일반 사용자 및 크리에이터는 관리자 취소를 실행할 수 없다")
fun shouldDenyCancellationWithoutAdminRole() {
val fixture = saveFundedGift()
mockMvc.perform(post("/api/v2/admin/gifts/$APPLICATION_NO/cancel"))
.andDo(print())
.andExpect(status().isUnauthorized)
listOf("USER", "CREATOR").forEach { role ->
mockMvc.perform(post("/api/v2/admin/gifts/$APPLICATION_NO/cancel").with(user("other").roles(role)))
.andDo(print())
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(false))
.andExpect { assertTrue(it.resolvedException is org.springframework.security.access.AccessDeniedException) }
}
assertEquals(GiftStatus.RECEIVED, giftRepository.findByApplicationNo(APPLICATION_NO)?.status)
assertEquals(null, giftDeliveryRepository.findByGiftId(requireNotNull(fixture.gift.id))?.canceledAt)
assertRefundState(fixture, refunded = false)
}
@Test
@DisplayName("실제 환불을 DB에 flush한 뒤 예외가 발생하면 상태와 시각 및 전체 환불 DB 변경이 롤백된다")
fun shouldRollbackCancellationAndRealRefundWhenRefundFails() {
val fixture = saveFundedGift()
Mockito.doAnswer { invocation ->
invocation.callRealMethod()
entityManager.flush()
assertEquals(80, memberRepository.findById(requireNotNull(fixture.sender.id)).orElseThrow().pgRewardCan)
throw SodaException(messageKey = "common.error.invalid_request")
}.`when`(canPaymentService).refundGiftCan(requireNotNull(fixture.sender.id), fixture.gift.canUsageId)
mockMvc.perform(cancelRequest(fixture))
.andDo(print())
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(false))
assertEquals(GiftStatus.RECEIVED, giftRepository.findByApplicationNo(APPLICATION_NO)?.status)
assertEquals(null, giftDeliveryRepository.findByGiftId(requireNotNull(fixture.gift.id))?.canceledAt)
assertRefundState(fixture, refunded = false)
Mockito.verifyNoInteractions(giftPushService)
}
@ParameterizedTest
@MethodSource("cancellationRaces")
@DisplayName("관리자 취소와 동일 선물 변경은 별도 트랜잭션에서 선행 작업 커밋까지 직렬화된다")
fun shouldSerializeCancellationAgainstReceivedMutations(operations: Pair<RaceOperation, RaceOperation>) {
val fixture = saveFundedGift()
val winnerPaused = CountDownLatch(1)
val releaseWinner = CountDownLatch(1)
val contenderStarted = CountDownLatch(1)
val refundCalls = AtomicInteger()
Mockito.doAnswer { invocation ->
if (refundCalls.incrementAndGet() == 1 && operations.first != RaceOperation.TRACKING &&
operations.first != RaceOperation.REMINDER
) {
winnerPaused.countDown()
assertTrue(releaseWinner.await(10, TimeUnit.SECONDS))
}
invocation.callRealMethod()
}.`when`(canPaymentService).refundGiftCan(requireNotNull(fixture.sender.id), fixture.gift.canUsageId)
if (operations.first == RaceOperation.TRACKING) {
Mockito.doAnswer {
winnerPaused.countDown()
assertTrue(releaseWinner.await(10, TimeUnit.SECONDS))
null
}.`when`(giftPushService).sendTrackingRegisteredToRecipient(APPLICATION_NO, fixture.gift.recipientMemberId)
}
if (operations.first == RaceOperation.REMINDER) {
Mockito.doAnswer {
winnerPaused.countDown()
assertTrue(releaseWinner.await(10, TimeUnit.SECONDS))
null
}.`when`(giftPushService).sendTrackingDeadlineReminder(APPLICATION_NO, requireNotNull(fixture.sender.id))
}
val executor = Executors.newFixedThreadPool(2)
val winner = executor.submit<String> { executeRaceOperation(operations.first, fixture) }
var contender: java.util.concurrent.Future<String>? = null
try {
assertTrue(winnerPaused.await(10, TimeUnit.SECONDS))
contender = executor.submit<String> {
contenderStarted.countDown()
executeRaceOperation(operations.second, fixture)
}
assertTrue(contenderStarted.await(10, TimeUnit.SECONDS))
val waitingContender = contender
assertThrows(TimeoutException::class.java) { waitingContender.get(300, TimeUnit.MILLISECONDS) }
releaseWinner.countDown()
val expectedWinner = when (operations.first) {
RaceOperation.TRACKING -> "TRACKING_REGISTERED"
RaceOperation.REMINDER -> "REMINDED"
else -> "CANCELED"
}
val expectedContender = when {
operations.second in listOf(RaceOperation.AUTO, RaceOperation.REMINDER) -> "SKIPPED"
operations.first == RaceOperation.REMINDER -> "CANCELED"
else -> "REJECTED"
}
assertEquals(expectedWinner, winner.get(10, TimeUnit.SECONDS))
assertEquals(expectedContender, contender.get(10, TimeUnit.SECONDS))
val trackingWon = operations.first == RaceOperation.TRACKING
assertEquals(
if (trackingWon) GiftStatus.TRACKING_REGISTERED else GiftStatus.CANCELED,
giftRepository.findByApplicationNo(APPLICATION_NO)?.status
)
val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(fixture.gift.id)))
assertEquals(!trackingWon, delivery.canceledAt != null)
assertEquals(trackingWon, delivery.trackingRegisteredAt != null)
assertEquals(operations.first == RaceOperation.REMINDER, delivery.trackingDeadlineReminderSentAt != null)
assertRefundState(fixture, refunded = !trackingWon)
Mockito.verify(giftPushService, Mockito.times(if (operations.first == RaceOperation.AUTO) 1 else 0))
.sendAutoCanceledToSender(APPLICATION_NO, requireNotNull(fixture.sender.id))
} finally {
releaseWinner.countDown()
executor.shutdown()
if (!executor.awaitTermination(15, TimeUnit.SECONDS)) {
executor.shutdownNow()
assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS))
}
if (!winner.isCancelled) winner.get(1, TimeUnit.SECONDS)
contender?.let { if (!it.isCancelled) it.get(1, TimeUnit.SECONDS) }
}
}
private fun executeRaceOperation(operation: RaceOperation, fixture: FundedGift): String {
val requestAttributes = ServletRequestAttributes(MockHttpServletRequest())
RequestContextHolder.setRequestAttributes(requestAttributes)
return try {
when (operation) {
RaceOperation.ADMIN -> {
val result = mockMvc.perform(cancelRequest(fixture)).andExpect(status().isOk).andReturn()
if (objectMapper.readTree(result.response.contentAsByteArray).path("success").asBoolean()) {
"CANCELED"
} else {
assertEquals("common.error.invalid_request", (result.resolvedException as SodaException).messageKey)
"REJECTED"
}
}
RaceOperation.USER -> commandService.cancelGift(fixture.sender, APPLICATION_NO).status.name
RaceOperation.AUTO -> if (scheduler.cancelExpiredTrackingRegistrationGifts(NOW) == 1) "CANCELED" else "SKIPPED"
RaceOperation.REMINDER -> {
if (scheduler.sendDeadlineReminderGifts(NOW.minusHours(1)) == 1) "REMINDED" else "SKIPPED"
}
RaceOperation.TRACKING -> commandService.registerTracking(
fixture.sender,
APPLICATION_NO,
GiftTrackingRegistrationCommand("CJ대한통운", "1234567890")
).status.name
}
} catch (exception: SodaException) {
assertEquals("common.error.invalid_request", exception.messageKey)
"REJECTED"
} finally {
requestAttributes.requestCompleted()
RequestContextHolder.resetRequestAttributes()
}
}
private fun cancelRequest(fixture: FundedGift) = post("/api/v2/admin/gifts/$APPLICATION_NO/cancel").with(
authentication(
UsernamePasswordAuthenticationToken(
MemberAdapter(fixture.admin),
"test",
listOf(SimpleGrantedAuthority("ROLE_ADMIN"))
)
)
)
private fun tokenFor(member: Member): String = tokenProvider.createToken(
UsernamePasswordAuthenticationToken(member.email, "", listOf(SimpleGrantedAuthority("ROLE_${member.role.name}"))),
requireNotNull(member.id)
)
private fun giftHttp(
method: HttpMethod,
path: String,
token: String? = null,
expectedStatus: HttpStatus = HttpStatus.OK
): JsonNode {
val headers = HttpHeaders().apply {
token?.let { setBearerAuth(it) }
set(HttpHeaders.ACCEPT_LANGUAGE, "en")
}
val response = restTemplate.exchange(
"http://127.0.0.1:$port$path",
method,
HttpEntity<Void>(headers),
String::class.java
)
val body = objectMapper.readTree(response.body.orEmpty().ifBlank { "{}" })
val evidence = body.deepCopy<ObjectNode>()
val data = evidence.path("data")
if (method == HttpMethod.GET && data.isObject) {
(data as ObjectNode).retain("applicationNo", "status", "availableActions", "totalCount", "items")
data.path("items").forEach { (it as ObjectNode).retain("applicationNo", "status", "availableActions") }
}
println("GIFT_HTTP $method $path status=${response.statusCodeValue} body=$evidence")
assertEquals(expectedStatus, response.statusCode)
return body
}
private fun saveFundedGift(): FundedGift = requireNotNull(
TransactionTemplate(transactionManager).execute {
val sender = memberRepository.save(
Member(email = "${UUID.randomUUID()}@test.com", password = "password", nickname = "funded-sender").apply {
pgChargeCan = 100
pgRewardCan = 20
}
)
val admin = memberRepository.save(
Member(
email = "${UUID.randomUUID()}@test.com",
password = "password",
nickname = "cancel-admin",
role = MemberRole.ADMIN
).apply {
pgChargeCan = 50
pgRewardCan = 7
}
)
val funding = chargeRepository.save(
Charge(100, 20).apply {
member = sender
payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.PG)
}
)
val usageId = canPaymentService.spendGiftCan(requireNotNull(sender.id), 80, "web")
val category = categoryRepository.save(
GiftCategory(
classificationNumber = "100",
categoryCode = "FUNDED",
name = "인형",
receiptCode = "A",
representativeItem = "피규어"
)
)
val gift = giftRepository.save(
Gift(
applicationNo = APPLICATION_NO,
senderMemberId = requireNotNull(sender.id),
recipientMemberId = requireNotNull(admin.id),
status = GiftStatus.RECEIVED,
sizeCode = GiftSize.SMALL,
categoryId = requireNotNull(category.id),
categoryNameSnapshot = "인형",
salePriceCan = 80,
canUsageId = usageId,
senderTermsAgreed = true,
senderPrivacyAgreed = true,
senderTermsAgreedAt = NOW.minusDays(4),
senderPrivacyAgreedAt = NOW.minusDays(4)
)
)
giftDeliveryRepository.save(
GiftDelivery(
giftId = requireNotNull(gift.id),
senderName = "홍길동",
senderPhoneNumber = "01012345678",
senderZipCode = "06234",
senderAddress = "서울시 강남구",
trackingDeadlineAt = NOW.minusSeconds(1)
)
)
FundedGift(gift, sender, admin, requireNotNull(funding.id))
}
)
private fun assertRefundState(fixture: FundedGift, refunded: Boolean) {
TransactionTemplate(transactionManager).executeWithoutResult {
val sender = memberRepository.findById(requireNotNull(fixture.sender.id)).orElseThrow()
val admin = memberRepository.findById(requireNotNull(fixture.admin.id)).orElseThrow()
assertEquals(40, sender.pgChargeCan)
assertEquals(if (refunded) 80 else 0, sender.pgRewardCan)
assertEquals(50, admin.pgChargeCan)
assertEquals(7, admin.pgRewardCan)
val usage = useCanRepository.findById(fixture.gift.canUsageId).orElseThrow()
assertEquals(CanUsage.GIFT, usage.canUsage)
assertEquals(60, usage.can)
assertEquals(20, usage.rewardCan)
assertEquals(fixture.sender.id, usage.member?.id)
assertEquals(refunded, usage.isRefund)
val calculations = calculateRepository.findByUseCanIdAndStatus(
fixture.gift.canUsageId,
if (refunded) UseCanCalculateStatus.REFUND else UseCanCalculateStatus.RECEIVED
)
assertEquals(1, calculations.size)
assertEquals(80, calculations.sumOf { it.can })
val refunds = chargeRepository.findAll().filter { it.useCan?.id == fixture.gift.canUsageId }
assertEquals(if (refunded) 1 else 0, refunds.size)
assertEquals(if (refunded) 80 else 0, refunds.sumOf { it.rewardCan })
refunds.forEach {
assertEquals(ChargeStatus.REFUND_CHARGE, it.status)
assertEquals(0, it.chargeCan)
assertEquals(fixture.sender.id, it.member?.id)
assertEquals(PaymentGateway.PG, it.payment?.paymentGateway)
assertEquals(PaymentStatus.COMPLETE, it.payment?.status)
}
val funding = chargeRepository.findById(fixture.fundingId).orElseThrow()
assertEquals(40, funding.chargeCan)
assertEquals(0, funding.rewardCan)
}
}
private data class FundedGift(val gift: Gift, val sender: Member, val admin: Member, val fundingId: Long)
enum class RaceOperation { ADMIN, USER, AUTO, TRACKING, REMINDER }
private fun saveGift(status: GiftStatus, withRecipientAddress: Boolean = false) {
val gift = giftRepository.save(
Gift(
@@ -445,6 +971,19 @@ class AdminGiftControllerTest @Autowired constructor(
}
private companion object {
@JvmStatic
fun cancellationRaces() = listOf(
RaceOperation.ADMIN to RaceOperation.ADMIN,
RaceOperation.ADMIN to RaceOperation.USER,
RaceOperation.USER to RaceOperation.ADMIN,
RaceOperation.ADMIN to RaceOperation.AUTO,
RaceOperation.AUTO to RaceOperation.ADMIN,
RaceOperation.ADMIN to RaceOperation.TRACKING,
RaceOperation.TRACKING to RaceOperation.ADMIN,
RaceOperation.ADMIN to RaceOperation.REMINDER,
RaceOperation.REMINDER to RaceOperation.ADMIN
)
const val APPLICATION_NO = "G20260929000001"
const val SENDER_MEMBER_ID = 1L
const val RECIPIENT_MEMBER_ID = 2L
@@ -546,7 +546,7 @@ class GiftCommandServiceTest {
senderAddress = "서울시 강남구",
trackingDeadlineAt = NOW.plusDays(3)
)
Mockito.`when`(giftRepository.findByApplicationNo(APPLICATION_NO)).thenReturn(cancellationGift)
Mockito.`when`(giftRepository.findByApplicationNoForUpdate(APPLICATION_NO)).thenReturn(cancellationGift)
Mockito.`when`(deliveryRepository.findByGiftId(GIFT_ID)).thenReturn(cancellationDelivery)
}
@@ -579,7 +579,7 @@ class GiftCommandServiceTest {
senderAddress = "서울시 강남구",
trackingDeadlineAt = NOW.plusDays(3)
)
Mockito.`when`(giftRepository.findByApplicationNo(APPLICATION_NO)).thenReturn(trackingGift)
Mockito.`when`(giftRepository.findByApplicationNoForUpdate(APPLICATION_NO)).thenReturn(trackingGift)
Mockito.`when`(deliveryRepository.findByGiftId(GIFT_ID)).thenReturn(
trackingDelivery.takeIf { deliveryExists }
)
@@ -125,7 +125,7 @@ class GiftAdminQueryServiceTest @Autowired constructor(
}
@Test
@DisplayName("관리자 선물 상세는 상태별 가능한 액션을 반환하고 없는 신청번호를 거부한다")
@DisplayName("관리자 선물 목록과 상세는 상태별 가능한 액션을 반환하고 없는 신청번호를 거부한다")
fun shouldReturnAvailableActionsByStatusAndRejectUnknownApplicationNo() {
val sender = saveMember("fan-alpha")
val recipient = saveMember("creator-alpha")
@@ -136,26 +136,20 @@ class GiftAdminQueryServiceTest @Autowired constructor(
val service = service()
assertEquals(emptyList<String>(), service.getGiftDetail("A-100260930100").availableActions)
assertEquals(
listOf("ARRIVE_MAILBOX", "MARK_UNDELIVERABLE"),
service.getGiftDetail("A-100260930101").availableActions
val expectedActions = mapOf(
GiftStatus.RECEIVED to listOf("CANCEL"),
GiftStatus.TRACKING_REGISTERED to listOf("ARRIVE_MAILBOX", "MARK_UNDELIVERABLE"),
GiftStatus.ARRIVED_AT_MAILBOX to listOf("COMPLETE_INSPECTION", "MARK_UNDELIVERABLE"),
GiftStatus.INSPECTION_COMPLETED to listOf("MARK_UNDELIVERABLE"),
GiftStatus.DELIVERED to emptyList(),
GiftStatus.UNDELIVERABLE to emptyList(),
GiftStatus.CANCELED to emptyList()
)
assertEquals(
listOf("COMPLETE_INSPECTION", "MARK_UNDELIVERABLE"),
service.getGiftDetail("A-100260930102").availableActions
)
assertEquals(
listOf("MARK_UNDELIVERABLE"),
service.getGiftDetail("A-100260930103").availableActions
)
assertEquals(
listOf("MARK_UNDELIVERABLE"),
service.getGifts(GiftStatus.INSPECTION_COMPLETED, null, null, 0, 20).items.single().availableActions
)
assertEquals(emptyList<String>(), service.getGiftDetail("A-100260930104").availableActions)
assertEquals(emptyList<String>(), service.getGiftDetail("A-100260930105").availableActions)
assertEquals(emptyList<String>(), service.getGiftDetail("A-100260930106").availableActions)
expectedActions.forEach { (status, actions) ->
val item = service.getGifts(status, null, null, 0, 20).items.single()
assertEquals(actions, service.getGiftDetail(item.applicationNo).availableActions, status.name)
assertEquals(actions, item.availableActions, status.name)
}
val exception = assertThrows(SodaException::class.java) {
service.getGiftDetail("UNKNOWN")
}
@@ -1,5 +1,6 @@
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.configs.QueryDslConfig
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift
@@ -17,12 +18,14 @@ 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.EnumSource
import org.junit.jupiter.params.provider.ValueSource
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import java.time.LocalDateTime
import javax.persistence.EntityManager
@DataJpaTest(
properties = [
@@ -37,16 +40,19 @@ class GiftAdminServiceTest @Autowired constructor(
private val sizePriceRepository: GiftSizePriceRepository,
private val giftRepository: GiftRepository,
private val giftDeliveryRepository: GiftDeliveryRepository,
private val giftMailboxRepository: GiftMailboxRepository
private val giftMailboxRepository: GiftMailboxRepository,
private val entityManager: EntityManager
) {
private val giftPushService = Mockito.mock(GiftPushService::class.java)
private val canPaymentService = Mockito.mock(CanPaymentService::class.java)
private val service = GiftAdminService(
categoryRepository,
sizePriceRepository,
giftRepository,
giftDeliveryRepository,
giftMailboxRepository,
giftPushService
giftPushService,
canPaymentService
)
@Test
@@ -312,6 +318,63 @@ class GiftAdminServiceTest @Autowired constructor(
Mockito.verifyNoInteractions(giftPushService)
}
@Test
@DisplayName("운영자는 접수 선물을 취소하고 원 발신자의 결제 내역을 한 번 환불한다")
fun shouldCancelReceivedGiftWithOriginalSenderRefund() {
val (gift, delivery) = saveGift(GiftStatus.RECEIVED)
val result = service.cancelGift(APPLICATION_NO, NOW)
assertEquals(GiftOperationStatusResult(APPLICATION_NO, GiftStatus.CANCELED, "신청 취소", NOW), result)
assertEquals(GiftStatus.CANCELED, gift.status)
assertEquals(NOW, delivery.canceledAt)
assertThrows(SodaException::class.java) { service.cancelGift(APPLICATION_NO, NOW.plusSeconds(1)) }
assertEquals(NOW, delivery.canceledAt)
Mockito.verify(canPaymentService, Mockito.times(1)).refundGiftCan(SENDER_MEMBER_ID, 10L)
Mockito.verifyNoInteractions(giftPushService)
}
@ParameterizedTest
@ValueSource(ints = [0, 123456789, 999999999])
@DisplayName("관리자 취소 시각은 나노초 입력에도 초 단위 저장값과 응답이 일치한다")
fun shouldPersistAndReturnSameSecondPrecisionCancellationTime(nanos: Int) {
val (gift, _) = saveGift(GiftStatus.RECEIVED)
val giftId = requireNotNull(gift.id)
val now = LocalDateTime.of(2026, 10, 22, 23, 59, 59, nanos)
val result = service.cancelGift(APPLICATION_NO, now)
entityManager.flush()
entityManager.clear()
val persisted = requireNotNull(giftDeliveryRepository.findByGiftId(giftId)?.canceledAt)
assertEquals(persisted, result.occurredAt)
assertEquals(LocalDateTime.of(2026, 10, 22, 23, 59, 59), persisted)
assertEquals(GiftStatus.CANCELED, giftRepository.findById(giftId).orElseThrow().status)
}
@ParameterizedTest
@EnumSource(value = GiftStatus::class, names = ["RECEIVED"], mode = EnumSource.Mode.EXCLUDE)
@DisplayName("운영자는 접수가 아닌 모든 상태의 선물을 취소할 수 없다")
fun shouldRejectCancellationForEveryNonReceivedStatus(status: GiftStatus) {
val (gift, delivery) = saveGift(status)
val exception = assertThrows(SodaException::class.java) { service.cancelGift(APPLICATION_NO, NOW) }
assertEquals("common.error.invalid_request", exception.messageKey)
assertEquals(status, gift.status)
assertEquals(null, delivery.canceledAt)
Mockito.verifyNoInteractions(canPaymentService, giftPushService)
}
@Test
@DisplayName("운영자는 없는 선물을 취소할 수 없다")
fun shouldRejectCancellationForMissingGift() {
val exception = assertThrows(SodaException::class.java) { service.cancelGift("missing", NOW) }
assertEquals("common.error.invalid_request", exception.messageKey)
Mockito.verifyNoInteractions(canPaymentService, giftPushService)
}
private fun populateRecipientInformation(delivery: GiftDelivery) {
delivery.recipientName = "김소다"
delivery.recipientPhoneNumber = "01098765432"