# P2P 출금 주문 취소 권한 재편 + "매칭 0건" 게이트 지침서

작성: 2026-08-28 / 대상: `core` · `admin-api` · `partner-api`(hq) · `admin-ui` · `hq-ui`
성격: **정책 변경 + 신규 기능**. 자금 이동 경로를 건드리므로 단계별 검증 필수.

> **오케스트레이터 주석 (2026-08-28)**: §2 의 현황 주장과 §10-3·§10-4 의 사전 조사를
> 코드·운영 DB·운영 nginx 로그로 확인했다. 결과는 이 문서 맨 끝 「부록 — 사전 검증 결과」에 있다.
> 착수 전에 그 부록을 반드시 읽을 것.

## 1. 배경

### 1-1. 확정된 업무 흐름 (오너 결정, 2026-08-28)

```
회원      → 파트너에게 취소 요청
파트너    → 회원에게 거래중지 안내 + 상위 총판/관리자에게 요청
hq/admin  → 주문 상태를 보고 판단해서 취소 실행
```

**취소 실행 권한은 상위 총판(hq)과 관리자(admin)에만 둔다.** 파트너는 요청자이지 실행자가 아니다.

### 1-2. 왜 게이트가 필요한가 — admin 경로의 스냅샷 창

운영 DB는 `REPEATABLE-READ`다. `P2pOrderManagementService.cancelWithdrawOrder:161` 의
`@Transactional` 안에서 `loadWithdrawOrder`(평문 조회)가 **첫 consistent read** 를 일으켜
read view 가 그 시점에 확정된다. 이후 core 가 `findWithdrawOrderByCodeForUpdate`(:336)로
최신 행을 잠가도, **그 뒤의 평문 SELECT 들은 여전히 옛 스냅샷을 본다**:

- `getByOrderCode`(:354) — 주석은 "re-load" 인데 잠금으로 얻은 최신값을 오히려 버린다
- `matchRepo.findByWithdrawOrderId` (`P2pMatchingService:3159`) — 레그 취소 루프
- `nonFailedLegTotals` (`P2pOrderRepricer:161`) — 환급액 산정

```
T0  admin tx 시작 → loadWithdrawOrder 로 read view 확정
T1  매처가 새 CREATED 레그 커밋            ← T0 스냅샷에 없음
T2  cancelOrder 가 FOR UPDATE 로 잠금 (최신)
T3  레그 조회가 T0 스냅샷 → 새 레그를 못 봄
     → 레그는 취소되지 않고 살아남음
     → 환급액도 그 레그를 빼지 않음 → 과다 환급
```

결과는 **살아있는 매칭 + 전액 환급** — `cancelActiveMatchesForWithdrawOrder` 가 막으려던
#30 이중지급 패턴이다. **partner-api / open-api 경로는 외부 `@Transactional` 이 없어 이 창이 없다.
admin 경로 고유의 비대칭이다.**

### 1-3. 게이트가 창을 닫는 원리

위험의 유일한 원인은 **취소 도중 새 레그가 붙는 것**이다.

- 거래중지(`p2p_members.trading_paused=1`)면 매칭 게이트 `wm.trading_paused = 0`
  (`P2pMatchingMapper.MATCHABLE_JOIN:113`)에서 탈락해 **새 레그가 붙을 수 없다**
- 기존 레그는 T0 스냅샷에도 보이므로 정상 처리된다
- 상태 전이(CREATED→BANK_PENDING)는 `claimCreatedForCancel` **원자 클레임**이 이미 막는다

따라서 **"거래중지 + 진행 중 레그 0건"** 상태에서만 취소하면 스냅샷 창은 무해해진다.
실제로 2026-08-28 주문 58(`pwo_94720992f7f6`) 취소가 완벽했던 이유가 정확히 이 조건이었다
(`trading_paused=1` + 매칭 0건).

## 2. 현황 (2026-08-28 코드 실측 — 착수 전 재확인할 것)

### 2-1. `core P2pWithdrawService.cancelOrder` 호출자는 **정확히 2곳**

| 경로 | 위치 | UI 호출자 |
|---|---|---|
| admin | `admin-api/.../service/P2pOrderManagementService.java:166` | ✅ `admin-ui` 목록·상세 |
| partner | `partner-api/.../controller/P2pController.java:246` | ❌ **없음 (고아 EP)** |

`partner-ui/src/api/services/p2p.service.ts` 에 `/cancel` 메서드가 아예 없다.
상세 화면의 "취소" 버튼(`P2pWithdrawOrderDetailView.vue:390`)은 **폼 닫기**이지 주문 취소가 아니다.

### 2-2. ⚠️ 위젯 취소는 **출금 주문이 아니다**

`open-api/.../widget/P2pWidgetController.java:407` `/order/{orderCode}/cancel` 의 대상은
**`p2p_deposit_orders`(구매자의 입금 주문)** 이다(`:409`). 위젯 라벨은 "그만두기"이고,
**구매자가 이체 전에 거래를 접는 유일한 경로**다.

☠️ **이 EP 는 이번 작업에서 건드리지 마라.** 막으면 구매자가 15분 만료까지 이탈할 수 없다.

**P2P 회원(출금자) 본인이 자기 출금 주문을 취소하는 기능은 코드에 존재한 적이 없다.**
`P2pWithdrawPageController` · `p2p-ui/src/api/p2p.service.ts` 모두 cancel 없음.
→ **없앨 UX 가 없다. 이번 작업은 사실상 순증(hq 신설) + 고아 EP 정리다.**

### 2-3. hq 권한 모델

- 세션: `XSessionController<HqSessionData>` (`partner-api/.../session/HqSessionData.java:16`)
- HQ 자격: `status=ACTIVE AND parent_partner_id IS NULL AND partner_type='DISTRIBUTOR'
  AND root_partner_id = id` (`HqAuthService.assertHqEligible:134-153`)
- 범위 게이트(굳어진 패턴): `JOIN partners p ON p.id = ?.partner_id AND p.root_partner_id = #{hqId}`
  — **P2P 출금 주문에 적용된 선례가 이미 있다**: `HqExplorerMapper.java:422`
- 소속 단건 검사: `HqStatsMapper.countGroupPartner(hqId, partnerId):67`
- 쓰기 EP 규약: `HqPartnerMgmtController` 5개 전부 `@RequiresOtp` (`:41-44`)

### 2-4. `trading_paused` 토글 현황

| 콘솔 | 토글 |
|---|---|
| partner-ui | ✅ `P2pMemberController.java:211,228` → `pauseTradingByPartner` |
| p2p-ui (회원 본인) | ✅ `PUT /p2p/page/settings/trading-status` |
| **admin-ui** | ❌ **없음** (admin-api 전체에 `trading_paused` 참조 0건) |
| **hq-ui** | ❌ 읽기만 (`StatsKrwView.vue:223` 카운트) |

☠️ `admin-ui/.../P2pMemberDetailView.vue:156` 의 "정지/활성화" 는 **회원 status(접근 정지)** 로
`trading_paused` 와 **다른 축**이다. 혼동해서 그걸 재사용하지 마라.

### 2-5. 레그 단건 취소 — core 에 이미 있다

`P2pMatchingService.cancelMatch(matchId, reason):2517` = `doCancelMatch():2532` +
`finalizeDepositOrderIfAllTerminal()`. 필요한 7단계를 전부 포함한다(상태가드 / wo 선잠금 /
원자 클레임 / TORQ escrow 전파 / matchedAmount·status 복원 / `withdrawLedger.unlock` /
`restoreDepositRemaining`). 현재 호출자는 위젯 구매자 1곳(`P2pWidgetController:381`)뿐이다.

> ⚠️ **2026-08-28 이후에도 호출자는 그 1곳뿐이다.** §4(콘솔 매칭 단건 취소)가 무효화되면서
> 이 메서드는 **위젯 구매자 전용**으로 남았다. ☠️ **지우지 마라** — 지우면 구매자가 자기 거래를
> 접을 수 없다. ☠️ **콘솔(admin·HQ)에서 부르지도 마라** — 아래 표의 `wo.status` 복원 때문이다.

☠️ **주문 단위 루프와 결정적 차이 2가지:**

| | `cancelMatch`(단건) | `cancelActiveMatchesForWithdrawOrder`(주문) |
|---|---|---|
| `wo.status` | **복원한다**(`:2569`) → 주문이 다시 매칭 후보 | 복원 안 함(`:3191`) — 부활 방지 |
| `BANK_PENDING` | **취소 대상**(`:2538`) | 취소 안 함 → **DISPUTED 파킹**(N5, `:3161`) |

## 3. 수정 A — core 취소 게이트 (필수, 최우선)

`P2pWithdrawService.cancelOrder` 진입부, **분쟁 가드(`:346`) 직후 ·
`cancelActiveMatchesForWithdrawOrder`(`:352`) 호출 전**에 게이트를 넣는다.

```
진행 중 레그(CREATED / BANK_PENDING) 가 1건이라도 있으면 → ConflictException(409)
메시지: ~~진행 중 매칭 N건 — 거래중지 후 매칭을 먼저 정리하십시오 (CREATED n / BANK_PENDING m)~~
```

> ❌ **위 메시지 규격은 2026-08-28 오너 결정으로 대체됐다 (§4 참조).**
> 매칭 단건 취소가 범위에서 빠져 "매칭을 먼저 정리하십시오"는 **존재하지 않는 버튼**을 가리킨다.
> 새 규격:
>
> ```
> 진행 중 매칭 N건 — 거래중지 후 매칭이 자연 만료되면 취소할 수 있습니다 (CREATED n / BANK_PENDING m).
> 가장 늦게 만료되는 레그의 만료 예정: yyyy-MM-dd HH:mm:ss (만료 처리는 최대 30초 뒤 반영).
> ⚠️ 이체신고(BANK_PENDING) 레그는 만료 대상이 아닙니다 — 입금 확인 기한이 지나면 분쟁으로 넘어가 관리자 판정으로 종결됩니다.
> ```

- `ErrorCodes` 에 신설 (`P2P_CANCEL_BLOCKED_BY_ACTIVE_LEG`, 기존 스타일 `new ErrorCode("코드","메시지")`,
  P2P 계열 빈 번호). 기존 `P2P_ORDER_NOT_CANCELLABLE`(상태 불일치)와 **구분**해야 화면이 다른 안내를 낸다.
- 판정은 **잠금 이후** 조회로 한다. `findWithdrawOrderByCodeForUpdate`(`:336`)로 wo 를 잠근 뒤
  레그를 세어야 T0 스냅샷을 보지 않는다. ⚠️ 잠금 전에 세면 게이트 자체가 스냅샷 창에 걸린다.
- **`core` 한 곳에만 넣는다.** admin·partner 두 호출자가 자동으로 같은 규칙을 따른다.
  각 API 계층에 복제하지 마라(정의가 갈라진다).

☠️ **게이트를 넣으면 `cancelActiveMatchesForWithdrawOrder` 는 취소 경로에서 사실상 도달 불가가 된다.**
그래도 **삭제하지 마라** — `forceSettle`(`:421`) · USDT 전환 경로가 같은 헬퍼를 쓴다.

☠️ **`forceSettle` / USDT 전환에는 이 게이트를 넣지 마라.** 성격이 다르다(파트너가 이미 원화를
지급한 뒤의 기록 · 잔여 전환). 그 경로들은 `assertRemainderProcessable`(`:725`)만 통과하면
진행 중 레그를 스스로 정리하고 종결한다. **이번 범위는 "취소" 하나다.**
다만 그 경로들도 admin 스냅샷 창을 공유하므로 **후속 과제로 기록**할 것.

## 4. ~~수정 B — 관리자/hq 매칭 단건 취소 API 신설 (필수)~~ — ❌ **무효 (2026-08-28 오너 결정으로 범위에서 제외)**

> ### ❌ 이 절은 구현되지 않는다. 구현했던 것도 되돌렸다.
>
> **오너 결정 (2026-08-28)**: *"매칭 취소는 필요 없어. 출금건만 취소되면 돼."*
>
> **왜 뺐나.** `cancelMatch` 는 출금 주문의 `matched_amount` 를 되돌리고 상태를
> `PENDING` / `PARTIALLY_MATCHED` 로 **복원**한다 — 즉 **주문을 되살린다**. 아래 B-1 스스로
> "종결과 묶지 마라"라고 경고할 만큼 위험한 도구를, 접으려는 주문 옆에 나란히 놓는 설계였다.
> 운영자가 두 버튼의 차이를 매번 정확히 구분하리라는 가정에 기대는 안전장치는 언젠가 깨진다(#30).
>
> **대체 흐름 (이것이 유일한 절차다).**
>
> ```
> 회원 거래중지  ->  진행 중 레그 자연 만료  ->  출금 주문 취소 재시도
> ```
>
> - 거래중지(§6)로 **새 레그가 더 붙는 것을 멈춘다.** 이것이 없으면 만료를 기다리는 동안
>   매처가 계속 새 매칭을 붙여 영원히 취소할 수 없다 — 거래중지가 이 흐름의 전제다.
> - 미이체(`CREATED`) 레그는 **최대 약 15분 30초** 안에 스스로 빠진다
>   (`p2p.transfer_deadline_minutes`=15 + `P2pMatchExpiryJob` `fixedDelay`=30s).
> - ⚠️ 이체신고(`BANK_PENDING`) 레그는 **만료 대상이 아니다.** ③ 만료 잡은 `CREATED` 만
>   수거한다(`P2pMatchExpiryJob`). 이 레그는 ④(입금 확인 구간)가 소유하며 기한 초과 시
>   **분쟁**으로 승격되어 **관리자 판정**으로만 종결된다 — 총판이 할 수 있는 것은 없다.
> - 즉시성이 없다는 것은 사실이고, 그것을 **감수하기로 한 결정**이다.
>
> **되돌린 것 (2026-08-28).**
>
> | 대상 | 처리 |
> |---|---|
> | admin `POST /api/admin/p2p/matches/{id}/cancel` | 제거 |
> | `P2pMatchManagementService.cancelMatch(...)` | 제거 |
> | `P2pMatchManagementServiceCancelTest` | 제거 |
> | hq `POST /api/hq/p2p/matches/{id}/cancel` | 제거 |
> | `HqP2pActionService.cancelMatch(...)` (+ 사장된 `onMatch` · `requireGroupMatch`) | 제거 |
> | `AuditAction.CANCEL_P2P_MATCH` · `HqAuditAction.CANCEL_P2P_MATCH` · `HqAuditAction.TARGET_P2P_MATCH` | 참조 0 -> 제거 |
> | `ErrorCodes.P2P_MATCH_BANK_PENDING_NOT_CANCELLABLE`(1033) | 참조 0 -> 제거. **1033 은 결번**이며 재사용 금지 |
>
> **☠️ 남겨 둔 것 — 지우지 마라.**
>
> - `core P2pMatchingService.cancelMatch(Long, String)` — **위젯 구매자 경로 전용**으로 살아 있다
>   (`open-api P2pWidgetController POST /widgets/api/p2p/match/{matchCode}/cancel`).
>   지우면 **구매자가 자기 거래를 접을 수 없다.**
> - `countActiveLegsForWithdrawOrder` · `ActiveLegCount` — §3 취소 게이트의 판정 재료.
> - `cancelActiveMatchesForWithdrawOrder` — `forceSettle` · USDT 전환이 쓴다.
>
> **☠️ 게이트 메시지도 함께 고쳤다.** 옛 문구("거래중지 후 **매칭을 먼저 정리하십시오**")는
> 이제 **존재하지 않는 버튼**을 가리킨다. §3 의 메시지 규격을 아래로 대체한다:
>
> ```
> 진행 중 매칭 N건 — 거래중지 후 매칭이 자연 만료되면 취소할 수 있습니다 (CREATED n / BANK_PENDING m).
> 가장 늦게 만료되는 레그의 만료 예정: yyyy-MM-dd HH:mm:ss (만료 처리는 최대 30초 뒤 반영).
> ⚠️ 이체신고(BANK_PENDING) 레그는 만료 대상이 아닙니다 — 입금 확인 기한이 지나면 분쟁으로 넘어가 관리자 판정으로 종결됩니다.
> ```
>
> - 만료 예정 시각의 근거는 진행 중 레그 중 **가장 늦은** `p2p_matches.expires_at` 이다
>   (`ActiveLegCount.lastExpiresAt`, `CancelPreview.activeLegExpiresAt`).
>   ☠️ **값이 없으면 그 문장을 통째로 생략한다** — 추정 시각을 지어내면 그 시각에 다시 눌러 또 409 를 받는다.
> - 마지막 줄은 `bankPendingLegCount > 0` 일 때만 붙인다.
>
> ---

<details>
<summary>▶ 이력 보존 — 무효화된 §4 원문 (❌ 구현하지 마라)</summary>

### ❌ B-1. admin (무효)

`P2pMatchManagementController` (base `/api/admin/p2p/matches`) 에 추가:

```java
@PostMapping(name = "P2P 매칭 취소", value = "/{id}/cancel")
public P2pMatchDetailResponse cancelMatch(@PathVariable Long id,
                                          @RequestParam(required = false) String reason)
```

→ `P2pMatchManagementService.cancelMatch(id, reason, getSession().getAdminId())`
→ 내부에서 `matchingService.cancelMatch(id, reason)` 위임

- `P2pSettlementManagementService` 는 정산 재시도/봉쇄 전용이니 쓰지 마라.
- `AuditAction` enum 에 상수 신설 + `auditLogService.log(adminId, …, id, details)` 패턴 재사용.

☠️ **서비스에서 `status == CREATED` 를 선검사하라.** `doCancelMatch:2537` 은 `BANK_PENDING` 도
허용한다. 구매자가 "입금완료"를 이미 누른 레그를 관리자가 취소하면 **자금 사고**다.
BANK_PENDING 은 분쟁 판정(`/resolve`)으로만 처리한다.

☠️ **이 EP 를 "주문 종결"과 묶어 노출하지 마라.** `cancelMatch` 는 `wo.status` 를
PENDING/PARTIALLY_MATCHED 로 **되돌린다**(`:2569`). 종결 맥락에서 부르면 주문이 매칭 후보로
부활한다. **독립 액션**으로만 둔다 — 그래서 수정 D(거래중지)가 선행돼야 한다.

### ❌ B-2. hq (무효)

동일 기능을 `/api/hq/` 아래에 신설한다(§5의 제약 전부 적용).

</details>

## 5. 수정 C — hq 취소 EP 신설 (필수)

hq-ui 는 현재 **전부 조회 전용**(dashboard/revenue/stats/partners/explorer)이다.
P2P 출금 주문 목록·상세·취소를 신설한다.

☠️ **필수 제약 (하나라도 어기면 401 또는 권한 구멍):**

1. **경로는 반드시 `/api/hq/` 로 시작.** 밖에 두면 `HqSecurityFilter.shouldNotFilter`(`:61-63`)가
   아예 개입하지 않는다.
2. **컨트롤러는 `XSessionController<HqSessionData>`.** `PartnerSessionData` 로 쓰면
   필터가 세팅한 인증과 어긋나 401.
3. **범위 게이트 필수** — `JOIN partners p ON p.id = o.partner_id AND p.root_partner_id = #{hqId}`
   (선례 `HqExplorerMapper.java:422`) 또는 `countGroupPartner`. **`hqId` 는 세션에서만 온다.**
   요청 파라미터로 받으면 타 총판 주문을 취소할 수 있다.
4. **쓰기 EP 는 `@RequiresOtp`** — hq 쓰기 규약(`HqPartnerMgmtController:41-44`).
   빠뜨리면 2FA 사용자가 OTP 없이 자금 액션을 실행한다.
   프론트도 `withOtp()` 래퍼(`hq-ui/src/api/hq/partners.ts:70-83`)를 함께 붙여야 한다 —
   한쪽만 맞추면 막다른 에러가 난다.
5. `@Transactional(noRollbackFor = P2pDisputeParkedException.class)` — 빠뜨리면 분쟁 파킹이 롤백돼 유실된다.
6. hq-ui 새 API 서비스는 기존 `hq-ui/src/api/client.ts` 를 써라
   (`Authorization` + `Access-Token` 둘 다 세팅 + `401-OTP` 처리가 들어 있다).

## 6. 수정 D — `trading_paused` 토글을 admin·hq 에 신설 (필수)

취소 실행자가 hq/admin 인데 **거래중지를 걸 수단이 없으면 업무 흐름이 끊긴다**
(현재는 파트너·회원 본인만 가능).

- admin: `P2pMemberManagement` 계열에 pause/resume 추가
- hq: `/api/hq/` 아래 동일 기능 + **범위 게이트**
- 백엔드 로직은 `partner-api P2pMemberController:211,228` → `memberService.pauseTradingByPartner`
  를 참고하되, **소유권 검사를 각 콘솔의 권한 모델로 바꿔라**(admin=전체, hq=`root_partner_id` 범위).
- ☠️ 회원 `status`(접근 정지)와 **다른 축**이다. UI 문구·필드를 섞지 마라.

## 7. 수정 E — partner-api 고아 취소 EP 처리

`partner-api P2pController:242` `/withdraw-orders/{orderCode}/cancel` 은 호출 UI 가 없다.

- **바로 삭제하지 마라.** 파트너가 서버 API 를 직접 호출해 왔는지는 코드로 알 수 없다.
- **먼저 운영 로그를 확인**하고(최근 30일 해당 경로 호출 여부), 호출 이력이 없으면 제거,
  있으면 **410 Gone + 안내 메시지**("취소는 상위 총판/관리자에게 요청하십시오")로 전환한다.
- 수정 A 의 게이트는 core 에 있으므로, 이 EP 가 남아 있어도 규칙은 동일하게 적용된다.

## 8. 수정 F — 판단 근거를 화면에 노출 (필수)

"상태를 보고 판단해서 취소" 하려면 화면에 근거가 있어야 한다.
대상: `admin-ui/src/views/p2p/P2pWithdrawOrderDetailView.vue` · `…ListView.vue` + hq-ui 신설 화면.

| 데이터 | 현황 | 조치 |
|---|---|---|
| 진행 중 레그 수 | 간접(`legs[].status`) | **서버가 `createdLegCount`/`bankPendingLegCount` 를 명시 필드로 내려라.** 게이트 판정을 클라에서 재계산하면 서버와 어긋난다 |
| 출금원장 잔여 | ✅ `remainingAmount` | 그대로 사용. ⚠️ `krw_amount − confirmed_amount` 가 **아니다**(원장 잔액). 화면에서 재계산 금지 |
| `trading_paused` | ❌ 없음 | `P2pWithdrawOrderInfoResponse` 확장 + 매퍼에 `LEFT JOIN p2p_members m ON m.partner_id = o.partner_id AND m.partner_user_id = o.partner_user_id`. ⚠️ 주문에 회원 FK 가 없어 조인 키는 이 둘뿐이고, 미등록 회원이면 NULL |
| 예상 환급액 | ❌ 없음 | **드라이런 조회 EP 신설.** `computeRemainder` 는 `cancelOrder` 트랜잭션 안에서만 돈다. ☠️ 조회 경로에 `findWithdrawOrderByCodeForUpdate` 를 끌고 오지 마라 — 매처를 블로킹한다. 잠금 없이 읽고 "참고값"으로 표기 |

- 취소 버튼은 **게이트 조건 미충족 시 비활성** + 사유 표기
  (`진행 중 매칭 2건 · 거래중지 필요` / `약 12분 후 자동 만료`).
- ⚠️ **목록 화면 취소 버튼에 확인 다이얼로그가 없다**(`P2pWithdrawOrderListView.vue:214-224`,
  핸들러 `:59` 즉시 실행). 이번에 함께 정리하라(상세에는 있다).
- ☠️ **DDL 변경 금지.** 회원 FK 컬럼을 새로 만들고 싶어도 만들지 마라 — DDL 은 오케스트레이터 몫이다.

## 9. 하지 말 것

- ☠️ `open-api P2pWidgetController:407` 수정 — **구매자 입금 주문 취소**다. 출금과 무관하고,
  막으면 구매자가 15분 만료까지 이탈 불가.
- ☠️ 직접 `p2p_withdraw_orders.status` UPDATE — 반드시 `cancelOrder` 위임
  (`P2pOrderManagementService:163-165` 경고).
- ☠️ `cancelMatch` 를 **콘솔(admin·HQ)에서 호출**하기 — 2026-08-28 오너 결정으로 범위에서 빠졌다(§4).
  주문 종결 흐름에 끼워 넣는 것은 물론이고 **독립 액션으로도 두지 마라** — `wo.status` 복원으로
  접으려던 주문이 부활한다. 위젯 구매자 경로 1곳만 남긴다.
- ☠️ `P2pMatchExpiryJob` 에 `BANK_PENDING` 추가 — 명시 금지(`:96-98`), ④ 구간이 소유한다.
- ☠️ 새 멱등 장치 신설 — 재취소 방지는 `alreadyResolved`(`:377`) 하나가 담당한다.
- ☠️ `p2p_partner_locks` 되살리기 — 폐기됐다. 되살리면 매칭이 조용히 멈춘다.
- 게이트를 API 계층마다 복제하기 — core 한 곳만.
- MyBatis `<script>` 안에서 `<` `<=` `<>` 직접 사용 — 기동 시 SAXParseException 으로 전 서비스 다운.
  `&lt;` / `!=` / CDATA 를 쓸 것.

## 10. 완료 기준

1. 빌드: `./gradlew :core:compileJava :admin-api:compileJava :partner-api:compileJava` 통과
   + `cd cryptoments-admin/admin-ui && npm run build` , `hq-ui` 동일
2. 단위 테스트로 고정:
   - CREATED 레그 1건 → 취소 **409** (신규 에러코드)
   - BANK_PENDING 레그 1건 → 취소 **409**
   - 레그 0건 → 기존 동작 그대로(환급 CREDIT 1건, RELEASE 1건) — **회귀 없음**
   - 레그가 전부 CANCELLED/FAILED → 취소 **허용**(진행 중이 아니다)
   - 분쟁 레그 → 기존 `P2P_CONVERT_BLOCKED_BY_DISPUTE` 유지(새 게이트가 우회로를 만들지 않았는지)
   - ~~매칭 단건 취소: CREATED → 성공 / BANK_PENDING → **거부** / 이미 CANCELLED → 멱등~~
     ❌ **무효 (2026-08-28 오너 결정으로 범위에서 제외 — §4 참조)**. 대상 API 가 없으므로
     이 3종은 작성하지 않으며, 작성돼 있던 것은 제거했다
     (`P2pMatchManagementServiceCancelTest` 전체, `HqP2pActionServiceTest` 의 매칭 취소 4종).
     대신 **차단 문구 회귀 테스트 3종**을 넣었다 — "매칭을 먼저 정리하라"(존재하지 않는 버튼)로
     되돌아가지 않는지, 만료 예정 시각을 지어내지 않는지, `BANK_PENDING` 에 "기다리면 풀린다"고
     말하지 않는지 (`P2pWithdrawServiceCancelGateTest` 2-b · 2-c · 2-d).
   - hq: 타 총판 주문 취소 시도 → **403/404** (범위 게이트)
   - hq: OTP 미첨부 쓰기 → **401-OTP**
3. **운영 데이터 읽기 검증**: 현재 `PENDING`/`PARTIALLY_MATCHED` 주문 각각에 대해
   새 게이트가 어떻게 판정하는지 SELECT 로 시뮬레이션하고 보고하라.
   진행 중 레그를 가진 주문이 몇 건인지, 그 주문들이 배포 후 취소 불가가 되는지 명시할 것.
4. 수정 E: partner-api EP 호출 이력 조사 결과를 보고에 포함.

## 11. 커밋

`cryptoments` · `cryptoments-admin` 두 레포 모두 **커밋만 하고 push 하지 마라**
(오케스트레이터 리뷰 후 오너 승인 시 push).
작업 트리에 다른 워크스트림의 미커밋 편집이 있을 수 있다 — **네 변경만** 커밋하라.

## 12. 후속 (이번 범위 밖)

- admin 경로 read view 순서 정리 — `loadWithdrawOrder` 평문 조회 제거 또는 잠금 후 읽기로 전환.
  게이트가 취소는 막지만 **`forceSettle` · USDT 전환은 같은 창을 공유**한다.
- `withdrawRepo.modify()` lost-update — 스냅샷 엔티티의 non-null 전 필드를 SET 하므로
  재가격이 끼어들면 환율·액면을 되돌린다.
- 취소 시 파트너 웹훅 미발송 — 신규 모델 건은 접수 시 `WITHDRAWAL_COMPLETED` 를 이미 보냈고
  취소 통지가 없다. 파트너 장부에 "완료"로 남는다(2026-08-28 주문 58 실측).
- `trading_paused=1` 회원도 출금 주문을 만들 수 있으나 매칭 게이트에서 영구 탈락 →
  접수 시 안내 또는 관리자 화면 사유 표기.

---

## 부록 — 오케스트레이터 사전 검증 결과 (2026-08-28)

§2 의 현황과 §10-3·§10-4 의 사전 조사를 실측했다. **아래는 확정 사실이므로 재조사하지 말 것.**

### A. §2 코드 주장 — 전부 확인됨

| 주장 | 검증 |
|---|---|
| `cancelOrder` 호출자 정확히 2곳 | ✅ `P2pOrderManagementService:166` · `P2pController:246` |
| 위젯 취소는 입금 주문 대상 | ✅ `P2pWidgetController:411` → `depositOrderRepo.findByOrderCode` |
| `cancelMatch` 존재 | ✅ `P2pMatchingService:2518` |
| admin-api 에 `trading_paused` 참조 0건 | ✅ 유일한 1건은 `P2pWithdrawPoolMapper` **javadoc 주석** |

### B. §10-3 게이트 배포 영향 — **0건**

운영 DB 실측. 출금 주문 50건의 상태 분포: `COMPLETED` 45 · `CANCELLED` 4 · `PENDING` 1.

미종결(`PENDING`/`PARTIALLY_MATCHED`) 주문은 **단 1건**:

```
id 51  pwo_9d3290c9e2e9  PENDING  partner 44  151,360 KRW
       CREATED 레그 0 · BANK_PENDING 레그 0  →  게이트 통과 (취소 가능)
```

**지금 배포해도 취소 불가가 되는 주문은 없다.** 게이트는 현재 운영을 막지 않는다.

### C. §10-4 고아 EP 호출 이력 — **410 Gone 으로 확정**

`partner-api` journald 에는 **요청 경로가 아예 남지 않는다**(82만 줄에 `/api/` 경로 0건).
경로 추적은 nginx 접근 로그로만 가능하다.

nginx 접근 로그 전체 기간에서 해당 경로 호출은 **2건뿐**:

```
162.158.19.4 [16/Aug/2026:05:38:44] "POST /api/partner/p2p/withdraw-orders/x/cancel"           401  curl/7.88.1
162.158.19.4 [16/Aug/2026:05:38:44] "POST /api/partner/p2p/withdraw-orders/x/cancel-remaining" 401  curl/7.88.1
```

주문코드가 리터럴 `x` · 전부 `401` · UA `curl` · 하나는 **2026-08-17 에 이미 제거된**
`cancel-remaining` 을 친다 → 파트너 연동이 아니라 스캐너/수동 테스트다.
(대조군: 같은 기간 `/api/partner/p2p/**` 총 18,321건이 정상 기록됨 — 로그는 살아 있다)

⚠️ **다만 nginx 로그 보존이 15일(8/14~8/28)뿐이라 "쓴 적 없음"의 증명은 아니다.**
그래서 **삭제하지 않고 `410 Gone` + 안내 메시지로 전환한다**(§7 의 두 갈래 중 후자).
