# P2P 주문 종결 가드 보강 지침서 (P0-1 + P0-2)

> 근거: `cryptoments-knowledge/domains/p2p/fund-safety-audit-2026-07-17.md`
> 선행 커밋 `a62e5ee`(convert 3경로 분쟁 가드)의 대칭 완성 — 나머지 종결 경로 + 잔여 처리 1회성.

## 목표

1. **분쟁 가드 대칭**: 주문 종결 경로 전부(cancelOrder / forceSettle / cancelRemaining / admin cancelWithdrawOrder)에 DISPUTED 매칭 존재 시 차단.
2. **잔여 처리 1회성**: refundType이 이미 설정된 주문(잔여 처리 완료)과 터미널 상태 주문에서 잔여 지급 경로(forceSettle / cancelRemaining / convertToDirectWithdrawal / requestUsdtConvert) 재실행 차단.

## 수정 파일 (3개)

### 1. `common/.../exception/ErrorCodes.java`

- 기존 1026 `P2P_CONVERT_BLOCKED_BY_DISPUTE` 메시지를 일반화: "분쟁 진행 중인 매칭이 있어 주문을 처리할 수 없습니다. 관리자 판정 후 다시 시도하세요." (상수명은 유지 — 사용처 호환)
- 신규 1027 추가:
```java
// P2P 잔여 처리 1회성 (1027)
//   forceSettle/cancelRemaining/convert 는 주문당 1회만 — refund_type 설정 이후 재실행되면
//   같은 잔여가 반복 지급된다(예: force-settle 후 stale 전환요청 승인 = KRW+USDT 이중지급).
public static final ErrorCode P2P_REMAINDER_ALREADY_PROCESSED = new ErrorCode("1027",
        "이미 잔여분이 처리되었거나 종결된 주문입니다.");
```

### 2. `core/.../p2p/P2pWithdrawService.java`

private 헬퍼 추가:

```java
/** 터미널 상태 집합 — 종결/잔여 처리 불가. */
private static final Set<P2pWithdrawStatus> TERMINAL_STATUSES = Set.of(
        P2pWithdrawStatus.COMPLETED, P2pWithdrawStatus.CANCELLED, P2pWithdrawStatus.EXPIRED);

/**
 * 잔여 지급 경로 공통 가드 — (1) 터미널/기처리 주문 재실행 차단(1027), (2) 분쟁 매칭 차단(1026).
 * 배경: audit 2026-07-17 P0-1/P0-2. convert 3경로 가드(a62e5ee)의 대칭 완성.
 */
private void assertRemainderProcessable(P2pWithdrawOrder order) {
    if (TERMINAL_STATUSES.contains(order.getStatus()) || order.getRefundType() != null) {
        throw new ConflictException(ErrorCodes.P2P_REMAINDER_ALREADY_PROCESSED,
                "status=" + order.getStatus() + ", refundType=" + order.getRefundType());
    }
    if (matchingService.hasDisputedMatchForWithdrawOrder(order.getId())) {
        throw new ConflictException(ErrorCodes.P2P_CONVERT_BLOCKED_BY_DISPUTE);
    }
}
```

적용 (각 메서드 진입부, 첫 mutation 이전):

| 메서드 | 수정 |
|---|---|
| `cancelOrder(212)` | 기존 상태 가드 **유지** + 그 직후 분쟁 가드만 추가: `if (matchingService.hasDisputedMatchForWithdrawOrder(order.getId())) throw new ConflictException(ErrorCodes.P2P_CONVERT_BLOCKED_BY_DISPUTE);` (취소는 refundType 미설정 경로라 1027 불필요, 기존 가드가 터미널 차단) |
| `forceSettle(253)` | 진입부에 `assertRemainderProcessable(order);` (현재 가드 전무) |
| `cancelRemaining(284)` | 진입부에 `assertRemainderProcessable(order);` (현재 가드 전무) |
| `convertToDirectWithdrawal(317)` | 기존 withdrawalId null 체크 다음에 `assertRemainderProcessable(order);` 추가, **기존 분쟁 가드(326-328)는 헬퍼와 중복이므로 제거** |
| `requestUsdtConvert(362)` | 기존 분쟁 가드(368-370)를 `assertRemainderProcessable(order);`로 **교체** (요청 접수도 터미널/기처리 주문이면 무의미) |

주의:
- `approveUsdtConvert`는 `convertToDirectWithdrawal`로 위임되므로 자동 커버 — 자체 분쟁 재검사(413-415)는 유지해도 무방(이중 방어).
- import 추가: `java.util.Set` (이미 있으면 생략).
- 각 가드에 **왜 막는지 1-2줄 주석 필수** (위 헬퍼 javadoc 수준).

### 3. `admin-api/.../service/P2pOrderManagementService.java`

`cancelWithdrawOrder(121-145)`를 core 위임으로 변경 — 현재는 상태만 CANCELLED로 직접 update하여 진행중 매칭 취소(Fix B)·원본 withdrawal 종결·분쟁 가드가 전부 우회됨:

```java
@Transactional
public P2pWithdrawOrder cancelWithdrawOrder(Long id, String reason, Long adminId)
        throws NotFoundException, ConflictException {
    P2pWithdrawOrder order = loadWithdrawOrder(id);
    // core cancelOrder 위임 — Fix B(진행중 매칭 취소+잠금해제) + 분쟁 가드 + 원본 withdrawal 종결.
    //   직접 status update는 살아있는 leg를 방치해 "취소된 주문에 정산"(#30 패턴)을 만든다.
    P2pWithdrawOrder result = p2pWithdrawService.cancelOrder(
            order.getOrderCode(), "ADMIN: " + (reason != null ? reason : "관리자 취소"));

    Map<String, Object> details = new HashMap<>();
    details.put("orderCode", order.getOrderCode());
    details.put("reason", reason);
    auditLogService.log(adminId, AuditAction.CANCEL_P2P_WITHDRAW_ORDER, id, details);

    log.info("P2P 출금 주문 Admin 취소: orderId={}, adminId={}", id, adminId);
    return result;
}
```

- `WITHDRAW_CANCELLABLE_STATUSES` 상수와 관련 검사는 삭제 (core가 동일 검사 수행). 상수가 다른 곳에서 쓰이면 유지하고 검사만 제거.
- `p2pWithdrawService` 필드는 이미 존재(forceSettle 위임에서 사용 중).

## 하지 말 것

- `cancelActiveMatchesForWithdrawOrder`의 BANK_PENDING 취소 정책 변경 금지 (P1-5는 별도 정책 결정 사안)
- `computeRemainder` 계산식 변경 금지
- confirmBankTransfer/정산 쪽 수정 금지 (P1-4는 별도 패치)
- DDL 변경 없음
- XML 매퍼 금지, 기존 코드 컨벤션(ErrorCodes 상수, ConflictException) 준수

## 완료 기준

1. `./gradlew :common:compileJava :core:compileJava :admin-api:compileJava :partner-api:compileJava` 성공 (오케스트레이터가 로컬에서 수행 — 구현자는 코드만)
2. 종결 4경로 모두에서: DISPUTED 매칭 존재 시 1026 예외
3. COMPLETED/CANCELLED/EXPIRED 또는 refundType!=null 주문에서 forceSettle/cancelRemaining/convert-direct/convert 요청 시 1027 예외
4. 기존 정상 흐름(분쟁 없는 PENDING/PARTIALLY_MATCHED 주문의 취소/강제정산/잔여취소/전환) 동작 불변
