# P2P 입금 재설계 — 백엔드 선행 작업 지침서

**작성**: 2026-08-22 · **상태**: 구현 대기 · **대상**: 서브에이전트
**범위**: `core` · `open-api` (+ 필요 시 `common/mapper`)
**동반 문서**: `P2P_MISDEPOSIT_PREVENTION_UX_SPEC.md`, `P2P_DEPOSIT_FLOW_MAP.md`

> 위젯 재설계(오입금 방지)를 위해 서버가 먼저 해야 할 일. **A·B 는 동시성 결함 수정이고
> C·D 는 응답 보강**이다.
>
> **이번 구현 범위는 A·B·C·D 넷이다.** E·F 는 별건으로 뺐다 — 사유는 각 절에.
>
> ☠️ **A 와 B 를 먼저 하라.** 이 둘을 건너뛰고 위젯을 붙이면 사용자가 더 자주 타는 경로에서
> lost update 가 난다.

## 0-0. 왜 A~D 만 먼저 나가는가 — 구 위젯 호환성 평가 (2026-08-22)

백엔드가 먼저 배포되면 **운영에 떠 있는 구 위젯**이 새 API 를 문다. 항목별 영향을 실측했다.

| 항목 | 응답 형식 | 구 위젯 영향 | 판정 |
|---|---|---|---|
| A | 불변 | 없음. lost update 가 사라지는 방향이라 오히려 개선 | ✅ 안전 |
| B | 불변 | 취소 요청이 매칭 트랜잭션을 기다려 **응답이 지연될 수 있다**(Phase 1 은 외부 호출이 없어 짧다). 대신 "매칭 대기 중 취소했는데 뒤늦게 카드가 뜨는" 버그가 사라진다 | ✅ 안전 (데드락만 주의) |
| C·D | **필드 추가만** | 구 위젯은 모르는 키를 무시한다 | ✅ 안전 |
| **E** | 불변 | **영향 있음 → 별건** (§E) | ⚠️ 분리 |

**CORS 는 `*` 다** (`open-api/.../config/SecurityConfig.java:45` — `setAllowedOriginPatterns(List.of("*"))`).
로컬 위젯(`serve:dev`)을 운영 API 에 그대로 붙일 수 있다. 배포 순서는
**A~D 배포 → 로컬 위젯으로 운영 검증 → 위젯 배포 → E 별도** 로 간다.

---

## 0. 먼저 읽을 것 — 이 코드베이스의 동시성 관례

`P2pMatchingService` 의 **모든 상태 전이는 원자 클레임을 쓴다.** 예외는 이번에 고칠 하나뿐이다.

```sql
-- common/src/main/java/com/cryptoments/common/mapper/P2pMatchingMapper.java:448-455
UPDATE p2p_matches SET status = #{to} WHERE id = #{id} AND status IN ('CREATED','BANK_PENDING')  -- Terminate
UPDATE p2p_matches SET status = 'DISPUTED' WHERE id = #{id} AND status = #{from}                 -- Dispute
```

**왜 필요한가** — `P2pMatchingMapper.java:304-309` 의 경고를 그대로 옮긴다.

> Axim 의 `modify` 는 "null 이 아닌 필드를 전부 SET" 하는 부분 UPDATE 라서, 잠금 없이 읽은
> 낡은 스냅샷을 되쓰면 그 사이 커밋된 동시 쓰기를 되돌린다. **2026-08-21 운영에서 실제로 터졌다.**

이 지침서의 A·B 는 전부 같은 결함의 다른 얼굴이다.

---

## A. `autoDisputeMatch` 에 원자 클레임 추가 (버그)

### 문제

`core/.../p2p/P2pMatchingService.java:2655-2682`

```java
public P2pMatch autoDisputeMatch(Long matchId, String reason) {
    P2pMatch match = matchRepo.findOne(matchId);                  // ← 잠금 없음
    if (match.getStatus() != CREATED && match.getStatus() != BANK_PENDING) return match;
    match.setStatus(P2pMatchStatus.DISPUTED);
    ...
    matchRepo.modify(match);                                       // ← 조건 없이 덮어씀
```

같은 파일의 다른 전이는 전부 클레임을 쓴다 — `submitDispute`(2472), `submitWithdrawerDispute`(2557),
N5 파킹(2812), `doCancelMatch`(2271), `failExpiredMatch`(2921), `forceCancelDisputedLeg`(2705).
**이 메서드만 빠져 있다.**

### 지금은 왜 안 터지는가, 그리고 언제 터지는가

현재 호출부 4곳이 전부 `BANK_PENDING` 레그만 다룬다
(`P2pScrapingVerifyJob.java:232, 409, 461`, `P2pManualConfirmReminderJob.java:235`).
`BANK_PENDING` 은 만료 잡이 보지 않는 상태라 경합 상대가 없다.

**§E(자동 분쟁 전환)가 이걸 `CREATED` 로 확장하는 순간 만료 잡과 정면 충돌한다.**
`P2pMatchExpiryJob`(30초)과 `P2pDepositLinkExpiryJob`(30초)이 같은 CREATED 레그를 본다.
만료 잡이 `claimMatchForTerminate` 로 `FAILED` 를 확정한 뒤에도 `autoDisputeMatch` 의
`modify` 는 조건 없이 `DISPUTED` 로 덮어쓴다.

### 수정

`claimMatchForDispute(matchId, prevStatus.name())` 를 상태 변경 **전에** 넣는다.

```java
P2pMatchStatus prev = match.getStatus();
if (matchingMapper.claimMatchForDispute(matchId, prev.name()) == 0) {
    // 동시 전이(만료·확인·취소)에 졌다 — 조용히 물러난다.
    log.info("자동 분쟁 skip — 동시 전이: matchId={}, prev={}", matchId, prev);
    return match;
}
match.setStatus(P2pMatchStatus.DISPUTED);
...
```

⚠️ **예외를 던지지 마라.** 이 메서드는 잡 루프에서 불리고, 호출부는
`updated.getStatus() == DISPUTED` 로 성공을 판정한다(`P2pManualConfirmReminderJob.java:236` 이 그 패턴).
`submitDispute` 는 `ConflictException` 을 던지지만 그건 사용자 요청 경로다. **멱등 계약을 유지하라.**

### 하지 말 것
- `submitDispute` 로 대체하지 마라 — `disputeSubmittedBy="DEPOSITOR"` 하드코딩(2486), 예외 발생,
  TORQ 레그면 트랜잭션 안에서 외부 HTTP(2491-2503).
- `markDisputedFromTorq`(1498-1522)도 아니다 — `TORQ_LP`/`TORQ_WEBHOOK` 하드코딩(1512-1513).

### 완료 기준
1. `autoDisputeMatch` 가 클레임 실패 시 **원본을 반환**하고 예외를 던지지 않는다
2. 기존 호출부 4곳의 동작이 변하지 않는다 (성공 경로 회귀 없음)
3. `./gradlew :core:compileJava` 통과

---

## B. 입금 주문 취소 ↔ 매칭 경합 차단 (버그)

### 문제

`matchPhase1` 은 **출금 주문에는 `FOR UPDATE` 를 걸지만 입금 주문 행은 잠그지 않는다.**

```java
// core/.../P2pMatchingService.java:452-472 (진입)
@Transactional
public MatchPhase1Result matchPhase1(P2pDepositOrder depositOrder) {
    long remaining = depositOrder.getRemainingAmount();   // ← 밖에서 받은 스냅샷
    if (remaining <= 0) return MatchPhase1Result.done();  // ← 주문 종결 여부를 보지 않는다
```

그러고는 레그 생성 후 밖에서 받은 스냅샷을 통째로 되쓴다 (`tryMatchDepositLegacy` 511-513 등):

```java
depositOrder.setStatus(P2pDepositStatus.MATCHED);
depositOrder.setRemainingAmount(0L);
depositOrderRepo.modify(depositOrder);      // ← CANCELLED 를 MATCHED 로 덮어쓴다
```

한편 `cancelDepositOrder`(2325-2389)는 `depositOrderRepo.findOne(depositOrderId)`(2326)로
**잠그지 않고** 읽는다.

### 결과

비동기 매칭 워커(`matching-worker`, 1초 폴링)가 레그를 만드는 중에 구매자가 취소하면:

- 취소가 레그 목록을 읽는 시점(2340)에 워커 Phase 1 이 아직 커밋 전 → `hasInflight=false`
  → 주문 `CANCELLED`. 직후 워커가 레그 INSERT → **CANCELLED 주문에 CREATED 레그가 매달린다.**
- 더 나쁜 순서: 워커의 `modify(depositOrder)` 가 취소 커밋 **뒤에** 실행되면 낡은 스냅샷이
  `status='MATCHED'` 로 되돌린다 — 2026-08-21 사고와 동형.

수습 경로는 있다(만료 잡이 `transfer_deadline_minutes`=15분 뒤 정리). 영구 손상은 아니지만
**출금 유동성이 15분 묶이고**, 위젯에 "그만두기"를 노출하면 이 경로를 훨씬 자주 타게 된다.

### ⚠️ 개정 (2026-08-22) — 잠금을 쓰지 않는다

초판은 `matchPhase1`·`matchPhase3`·`cancelDepositOrder` 세 곳에 입금 주문 `FOR UPDATE` 를 걸고
**입금 → 출금 → 매칭** 순서를 확립하라고 했다. **폐기한다.**

**이유 1 — 교착이 생긴다.** 기존 코드베이스의 사실상 순서는 그 **반대**다. 종결·확정 계열
7~8개가 전부 `출금 주문 FOR UPDATE → 입금 주문 쓰기` 로 돈다(`confirmBankTransfer:1957`,
`failMatch:1517`, `doCancelMatch:2371`, `submitDispute:2577`, `forceCancelDisputedLeg:2829`,
`failExpiredMatch:3045`, `P2pSettlementService:522`). 반대 방향을 하나 추가하면 고전적 교착
조건이 성립하고, 가장 현실적인 시나리오가 **"구매자가 취소를 누르는 동시에 확인·만료 잡이 도는 것"**
— 하필 이번 UX 가 늘리려던 경로다.

**이유 2 — 경합 창이 애초에 없다.** 워커는 조건부 UPDATE 로 주문을 점유한다:

```sql
-- P2pMatchingMapper.java:258
UPDATE p2p_deposit_orders SET status='MATCHING', claimed_at=NOW(6) WHERE id=? AND status='PENDING'
```

한 번의 매칭 시도가 도는 창은 정확히 `MATCHING` 상태와 일치한다. 그런데 **사용자가 취소를 누를 수
있는 시점은 매칭 대기(=`PENDING`)와 이체 화면(=`MATCHED`)뿐이다.** 이체 화면의 취소는 워커가 이미
손을 뗀 뒤다. 즉 **매칭 대기 화면에 취소 버튼을 두지 않으면 창 자체가 존재하지 않는다.**

→ **화면 설계 결정(2026-08-22)**: 진행 현황 화면의 매칭 대기 상태에 `그만두기` 를 두지 않는다.
대신 **"창을 닫아도 됩니다"** 안내를 둔다. 기다리기 싫은 사용자는 창을 닫으면 되고,
잃어버린 매칭은 이체 기한 만료로 이미 자동 정리된다(별도 구현 불필요).

☠️ **이탈 감지로 즉시 취소 API 를 부르지 마라.** 경합이 그대로 되살아난다. 이탈은 기록만 하고
만료에 맡긴다 — 결과가 같다.

### 수정 — 매칭 직전 상태 확인 (최소 가드)

**잠금 대신 진입 확인 하나로 간다.**

#### B-1. `matchPhase1` 진입에서 주문 상태를 다시 읽는다

```java
@Transactional
public MatchPhase1Result matchPhase1(P2pDepositOrder depositOrder) {
    // 파라미터 스냅샷은 트랜잭션 밖에서 왔다 — 그 사이 취소·만료됐을 수 있다.
    P2pDepositOrder fresh = depositOrderRepo.findOne(depositOrder.getId());
    if (fresh == null || isOrderClosed(fresh.getStatus())) {
        return MatchPhase1Result.done();      // 종결됨 — 레그를 만들지 않는다
    }
    depositOrder = fresh;                      // 이후 전부 fresh 를 쓴다
    ...
```

- **잠그지 않는다.** `findOne` 이다.
- `isOrderClosed` 는 **이미 있다** — `P2pMatchingService.java:2422-2427`
- ⚠️ **파라미터 객체를 계속 쓰면 안 된다.** 하위 메서드(`tryMatchDepositLegacy`,
  `tryMatchDepositUnifiedPhase1`)에 넘기는 것도 `fresh` 여야 한다.
- ⚠️ **동기 경로 주의** — `P2pDepositService.createAndMatch` 는 넘긴 **그 인스턴스**를 반환해
  위젯 응답을 만든다. `fresh` 로 일하고 파라미터를 그대로 두면 성립했는데도 응답이 옛 상태로
  나간다. 매칭 중 변할 수 있는 필드를 파라미터 객체에 되쓰는 처리가 필요하다.

#### B-2. `cancelDepositOrder` — 변경 없음

잠금을 걸지 않는다. 매칭 대기 화면에 취소 버튼이 없으므로 워커와 겹치지 않는다.

#### B-3. `matchPhase3` — 변경 없음

`matchPhase3`(1099-1141)는 **이미 재조회와 escrow 정리를 갖고 있다** — 1102-1113 이
`findOne` 으로 다시 읽고, 종결됐으면 `cancelReservedTorqLeg` 로 LP escrow 까지 정리하고 빠진다.
필요한 가드가 이미 있다. 잠금으로 바꾸지 않는다.

#### B-4. 워커의 `→ MATCHED` 전이를 조건부 UPDATE 로 (방어선)

진입 확인이 못 막는 건 **확인 직후~쓰기 사이의 밀리초 창**이다. 사용자 취소는 이 창에 도달할 수
없지만(B 개정 §이유 2), **만료 잡과 관리자 취소**는 탈 수 있다. 매칭 대기가 길어져 `expires_at`
근처까지 가면 늦은 시도와 만료가 겹친다.

비용이 거의 0 이므로 방어선으로 둔다 — 이 코드베이스 관례다
(`repriceWithdrawOrder`, `P2pMatchingMapper.java:355-373` 이 선례).

`p2p_deposit_orders` 를 `PENDING`/`MATCHING` 일 때만 `MATCHED` 로 올리는 조건부 UPDATE 를
`P2pMatchingMapper` 에 추가하고, `matchPhase1`/`matchPhase3` 의 `depositOrderRepo.modify(order)`
중 **상태를 MATCHED 로 올리는 것들**을 교체한다.

> 전부 바꾸려 하지 마라 — `remainingAmount` 만 갱신하거나 `MATCHING` 으로 되돌리는 곳까지
> 조건부로 만들면 정상 흐름이 막힌다. **`→ MATCHED` 전이만** 대상이다.

#### B-5. 착수 기한 가드 — "잃어버린 매칭"을 하나로 처리한다

**목적은 원인별 방어가 아니라 증상 하나에 가드 하나다.**

매칭이 성립했는데 아무도 그 결과를 쓰지 않는 상태를 "잃어버린 매칭"이라 부른다. 원인은 여럿이다 —
브라우저를 닫았거나, 응답이 유실됐거나, 취소·만료와 겹쳤거나, 그냥 시작하지 않았거나.
**증상은 하나다: 시작 신호가 오지 않는다.**

원인을 묻지 않고 증상 하나로 정리한다.

**정의**

| 항목 | 값 |
|---|---|
| 컬럼 | `p2p_deposit_orders.transfer_started_at DATETIME(6) NULL` — **운영 적용 완료 (2026-08-24)**, DDL v2.15 |
| 신호 | `POST /widgets/api/p2p/order/{orderCode}/start` — 위젯이 "입금 시작"을 누를 때 1회. **멱등**(이미 찍혀 있으면 no-op) |
| 대상 | `status='MATCHED'` **AND** `transfer_started_at IS NULL` **AND** 매칭 확정 후 N분 경과 |
| 매칭 확정 시각 | `MIN(p2p_matches.created_at)` — 레그는 매칭 확정 시 생성된다. 별도 컬럼을 만들지 않는다 |
| 처리 | **전체 취소** — 레그 `CANCELLED` + 잔여 복원 + 주문 `CANCELLED`, `close_reason='START_TIMEOUT'` |
| N | **3분** (`system_settings` 로 조정 가능하게) |
| 어디서 | `P2pMatchExpiryJob`(30초)에 조건 추가. **새 잡을 만들지 않는다** |

**N=3분의 근거 (실측, 2026-08-22)**

- 실제로 이체하는 사용자는 매칭 후 **평균 64초**에 이체 완료 신고까지 마친다(7월 71초, 8월 64초).
  "시작" 확인은 그보다 훨씬 앞이다.
- 8월 645개 레그 중 **42건(6.5%)이 이체를 시작조차 하지 않았다.**
- 3분은 3곳 목록을 읽고 은행 이체한도를 가늠하는 시간까지 감안한 값이다. **더 짧게 잡으면 정상
  사용자를 죽인다.**

**이 가드가 대체하는 것**

- 브라우저 이탈 → 3분 후 정리 (이탈 감지 불필요)
- 매칭 응답 유실 → 3분 후 정리
- B-4 조건부 UPDATE 가 0행일 때 남는 레그 → 3분 후 정리 (**보상 로직 불필요**)
- 관리자 취소·만료와의 경합 잔재 → 3분 후 정리

**주의**

- ⚠️ **주문 단위 시계다. 레그별로 두지 마라.** 레그는 주문의 `expires_at` 을 상속하도록 일부러
  통일해뒀다(`legExpiresAt(order)`) — 레그마다 다른 카운트다운이 뜨는 걸 막기 위해서다.
- ⚠️ **엔티티 필드를 DDL 보다 먼저 추가하지 마라.** Axim `selectAllColumns` 가 명시적 컬럼 목록을
  만들므로 컬럼이 없으면 `p2p_deposit_orders` 를 읽는 **모든 쿼리가 즉시 깨진다**
  (`P2pMatch.java:150-156` 의 같은 경고 참조).
- 재진입 — 3분이 지났으면 이미 죽어 있다. 위젯은 종결 화면과 재시도를 제공한다.
- 1곳 매칭도 동일하게 적용한다.

☠️ **배포 순서가 생명이다**

**구 위젯은 시작 신호를 보내지 않는다.** 서버 가드를 먼저 켜면 **진행 중인 모든 거래가 3분에 죽는다.**

→ 가드를 `system_settings` 스위치로 넣고 **기본 OFF** 로 배포한다. 신 위젯이 배포되고 구 위젯
트래픽이 빠진 것을 확인한 뒤 켠다.

#### 스위치 활성화 체크리스트 (코드 리뷰에서 추가, 2026-08-22)

**끄고 배포하는 것만으로는 부족하다. 켜기 전에 아래를 전부 확인하라.**

**1. ☠️ partner-api 경유 주문에는 시작 신호가 존재하지 않는다**

`partner-api` 의 `POST /p2p/deposit-orders`(`P2pController.java:261-268`)는 **파트너 서버가 직접
부르는 연동**이다. partner-api 에는 `/start` EP 가 없고 붙일 계획도 없다.
**켜는 순간 이 경로로 생성된 P2P 입금 주문이 전부 3분에 죽는다.**

→ 켜기 전에 비중을 실측하고, 있으면 **가드 대상에서 제외할 기준부터 정하라**(생성 경로 구분).

```sql
SELECT COUNT(*) total, COUNT(payment_link_code) via_link
FROM p2p_deposit_orders WHERE created_at > NOW() - INTERVAL 7 DAY;
```

**2. 링크 위젯 2종도 `/start` 를 불러야 한다**

`/widgets/matching-links/*`, `/widgets/p2p/links/*` 는 `WidgetSessionData` 라 호출 자체는 가능하다.
**신 위젯이 그 화면에서도 `/start` 를 부르도록 구현됐는지 확인**하라.

**3. ☠️ 계좌번호는 "입금 시작" 이후에만 노출돼야 한다**

`START_TIMEOUT` 은 주문을 **종결**시키고, 종결된 주문에는 `submitDispute` 가 분쟁을 거부한다
(`P2pMatchingService:2673-2680` `isOrderClosed` 가드).
→ **시작 버튼을 안 누른 채 계좌번호만 보고 송금한 구매자는 3분 뒤 분쟁조차 걸 수 없다.**

지금 화면 설계는 계좌를 `p2p-transfer` 에만 두므로 이 조건을 만족한다
(`P2P_MISDEPOSIT_PREVENTION_UX_SPEC.md` 원칙 2). **이 제약을 깨면 3분 가드가 자금 사고 경로가 된다.**

**4. 구 위젯 트래픽 소진 확인**

```sql
SELECT COUNT(*) total, COUNT(transfer_started_at) started
FROM p2p_deposit_orders
WHERE status='MATCHED' AND created_at > NOW() - INTERVAL 1 DAY;
```
두 숫자가 붙으면 켜도 된다.

**5. 조회 실행계획 1회 확인**

`findStartTimeoutOrders` 의 상관 서브쿼리를 `EXPLAIN` 으로 본다. 테이블 규모가 작아 당장은
문제가 아니지만, `p2p_deposit_orders(status, transfer_started_at)` 인덱스가 필요해질 수 있다.

### 잠금 순서

**새 잠금을 추가하지 않으므로 순서 문제가 없다.** 기존의 `출금 주문 → 매칭` 순서가 그대로 유지된다.

⚠️ 앞으로 이 영역에 잠금을 추가할 사람을 위해: **입금 주문을 출금 주문보다 먼저 잠그지 마라.**
기존 7~8개 경로가 `출금 → 입금` 으로 돌고 있어 교착이 난다.

### 완료 기준
1. `matchPhase1` 이 진입에서 주문을 **재조회**하고 그 값으로 판정한다 (`findOne`, 잠금 아님)
2. 종결된 주문(`isOrderClosed`)이면 레그를 만들지 않고 빠진다
3. 하위 메서드에 `fresh` 가 전달되고, 동기 경로 응답이 깨지지 않는다
4. `→ MATCHED` 전이가 조건부 UPDATE 로 바뀐다
5. **새로 추가된 `FOR UPDATE` 가 0개다**
6. B-5 착수 기한 가드 — 신호 EP 가 **멱등**하고, 만료 처리가 **주문 전체 취소**(레그+잔여 복원)로
   가며, **스위치 기본값이 OFF** 다
7. B-5 가 **주문 단위 시계**다 — 레그별 컬럼·카운트다운을 만들지 않았다
8. `./gradlew :core:compileJava :open-api:compileJava` 통과

### 하지 말 것
- `matchPhase1` 에 `@Transactional` 을 추가/제거하지 마라 — 이미 붙어 있고, Phase 2 가 트랜잭션
  밖인 것은 외부 LP 호출을 잠금 밖으로 빼려는 **의도된 설계**다(`T2C_EXTERNAL_CALL_SPLIT_GUIDE.md`).
- `runMatchAttempt`(`P2pAsyncMatchingService.java:113-130`)에 트랜잭션을 걸지 마라 — 같은 이유.
- 워커의 `finishAttempt` 는 **이미 잠그고 판정한다**(`P2pAsyncMatchingService.java:161, 167-171`).
  건드리지 마라.

---

## C. 이체 완료 신고 시각을 위젯 응답에 노출

### 배경

위젯이 확인 대기 카드에 **"4분 경과"** 를 그리려면 신고 시각이 필요하다.
클라이언트 타이머로 재면 새로고침 때 0 으로 돌아간다.

### 정본

`common/.../entity/P2pMatch.java:188-190` — `@XColumn("manual_confirm_started_at")`
쓰기 지점은 **단 한 곳**: `P2pMatchingService.java:2204` (`submitTransferDone` 안).
`legType` 분기가 없어 P2P/TORQ/PARTNER 전부 찍힌다. `BANK_PENDING` 진입 경로도 2202 하나뿐이라
우회 경로가 없다.

### ☠️ 함정 — 이 컬럼을 "P2P 레그 한정"으로 정리하려는 TODO 가 있다

`v2-docs/P2P_MATCHES_INTEGRITY.md:196, 330` — "479건 중 478건이 TORQ 레그다. 컬럼 이름과 내용이
어긋난다 / **무해하므로 정리 시 함께**".

**이 작업으로 무해하지 않게 된다.** 위젯이 전 레그의 경과 시간을 이 값으로 그리기 때문이다.
지침서 작업의 일부로 **그 TODO 를 명시적으로 취소하는 주석을 남겨라** — `P2pMatchingService.java:2203`
주석에 "위젯 확인 대기 카드의 경과 시간 표시가 이 값을 전 레그에서 읽는다(2026-08-22). P2P 한정
정리 금지." 를 추가하고, `P2P_MATCHES_INTEGRITY.md` 해당 항목에도 취소를 append 하라.

### 수정

| 대상 | 내용 |
|---|---|
| `open-api/.../dto/p2p/P2pWidgetMatchDetailResponse.java` | `private String transferReportedAt;` 추가. 기존 시각 필드(`expiresAt` 63, `createdAt` 75, `disputedAt` 81) 옆에 두고 **JavaDoc 필수** |
| `open-api/.../controller/widget/P2pWidgetController.java` `toMergedDetail`(672-775) | 매핑 추가. 기존 형식과 동일하게 `LocalDateTime#toString()` (744, 746, 749-750 참조) |

**`P2pWidgetMatchResponse` 에는 넣지 마라** — 그쪽은 주문 단위라 레그 3개 중 어느 신고 시각인지
의미가 없다.

`toMatchDetailResponse`(653-656)는 `toMergedDetail` 로 위임하는 얇은 래퍼라 **한 곳만 고치면 된다.**

#### 병합 카드 처리

`rep = group.get(0)`(674, id 최소 레그)이 대표다. 다른 시각 필드는 전부 `rep` 기준이지만,
**신고 시각은 그룹에서 가장 이른 값을 쓴다**:

```java
.transferReportedAt(group.stream()
        .map(P2pMatch::getManualConfirmStartedAt)
        .filter(java.util.Objects::nonNull)
        .min(java.time.LocalDateTime::compareTo)
        .map(LocalDateTime::toString).orElse(null))
```

이유: 카드의 의미가 "이 카드는 언제 신고됐나"이고, 부분 실패(그룹 3레그 중 1레그만
`transfer-done` 성공) 시 대표가 아직 `CREATED` 면 `null` 이 나가면서 다른 레그는 이미 신고된
모순이 생긴다. `disputeLeg`(696-699)/`waitingLeg`(706-712)가 이미 "그룹에서 조건에 맞는 레그를
골라 쓰는" 선례다.

#### 경과 초도 함께 내려줄 것

클라이언트 시계 오차를 피한다. `remainingSeconds`(714-718)가 선례다.

```java
.transferElapsedSeconds(...)   // 서버 기준 경과 초, 신고 전이면 null
```

### 완료 기준
1. `CREATED` 레그는 `transferReportedAt=null` 이 정상이다 (신고 전)
2. 병합 카드는 그룹 내 **가장 이른** 신고 시각이 나간다
3. 형식이 기존 시각 필드와 같다 (`LocalDateTime#toString()`, 오프셋 없음)
4. 추가 쿼리 0 — `rep`/`group` 은 이미 로드된 엔티티 (§C2 N+1 금지, `P2pWidgetController.java:568-573`)
5. `P2P_MATCHES_INTEGRITY.md` 의 "P2P 한정 정리" TODO 에 취소가 기록된다

**DDL 불필요.**

---

## D. 주문 종결 사유를 위젯 응답에 노출

### 배경

결과 화면이 **"왜 취소됐는지"** 를 말해야 한다. 지금은 "취소되었습니다"만 쓸 수 있다.

### 쓸 수 있는 값은 하나뿐이다

| 필드 | 값역 | 쓸 수 있나 |
|---|---|---|
| `p2p_deposit_orders.close_reason` | `ALL_SETTLED` / `ALL_CANCELLED` / `PARTIAL_EXPIRE` / `PARTIAL_LATE_SETTLE` / `NO_SETTLEMENT_EXPIRE` / `UNFILLED_NO_LIQUIDITY` / `BUYER_CANCEL` / `ADMIN_CANCEL` / `MATCH_FAILED` (`P2pSettlementService.java:131-147`) | ✅ **상수 9개** |
| `p2p_matches.resolution` | `CONFIRM`/`CANCEL`(관리자) + **자유 문자열**(`failMatch` 1466, `cancelActiveMatchesForWithdrawOrder` 2856) | ❌ 오염 |
| `p2p_matches.resolve_memo` | 100% 자유 문자열 | ☠️ **노출 금지** (아래) |
| `p2p_matches.dispute_reason` | 한글 문장 + 영문 코드 + LP 외부 문자열 혼재 | ❌ 오염 |

### ☠️ `resolve_memo` 는 절대 응답에 담지 마라

`open-api/.../dto/p2p/P2pWidgetMatchResponse.java:124-126` 인용:

> ⚠️ `resolveMemo`(관리자 처리 메모)는 **의도적으로 담지 않는다.** 내부 판정 문구라 구매자에게
> 나갈 값이 아니다. 화면에서 안 그리는 것만으로는 부족하다 — 응답 바디에 실리면 브라우저까지
> 전달된다. 회원 페이지·파트너 API 도 같은 이유로 제외한다.

같은 취지가 5곳에 더 있다(`p2p.vue:848, 887`, `P2pDisputeService.java:410`,
`TelegramMessageFormatter.java:619`, `W1B_WIDGET_DISPUTE_GUIDE.md:99`).

### 수정

`P2pWidgetMatchResponse`(주문 단위)에 `closeReason` 을 추가하고, 주문 조회/취소 응답 빌더에서 매핑한다.

**서버는 코드만 준다. 문구는 위젯 i18n 이 갖는다.** `UNFILLED_NO_LIQUIDITY` 같은 내부 용어를
그대로 노출하면 안 된다. 위젯에 이미 같은 패턴이 있다(`widget-ui/src/views/p2p.vue:1478-1480`).

### 레그별 취소 사유 — 이번 범위 밖

`p2p_matches.close_reason` 은 **운영 DB 에 없다.** DDL 주석이 명시한다 —
`CRYPTOMENTS_V2_DDL.sql:3207-3211`: *"종결(v2.10) — CANCELLED/FAILED 의 사유가 지금은 어디에도
안 남는다. ⚠️ [운영 미적용 · 2026-08-16 실측] v2.10 트랙 T3 설계분. 운영 DB 에 없다."*

⚠️ **엔티티 필드를 먼저 추가하지 마라.** `P2pMatch.java:150-156`:
> Axim `CrudSqlProvider.selectAllColumns` 가 `@XColumn` 에서 명시적 컬럼 목록을 만들므로
> (`SELECT *` 가 아니다) 컬럼이 없으면 `p2p_matches` 를 읽는 **모든 쿼리가 즉시 깨진다.**

**DDL 선적용이 확정되면 그때 별건으로.** 그전까지 결과 화면의 카드별 사유는 고정 문구로 간다.

### 완료 기준
1. `closeReason` 이 주문 단위 응답에 코드 그대로 나간다
2. `resolveMemo` 는 어떤 위젯 응답에도 실리지 않는다 (기존 상태 유지 확인)
3. `p2p_matches` 에 엔티티 필드를 추가하지 않았다

**DDL 불필요.**

---

## E. (별건) 만료 직전 자동 분쟁 전환 — 파트너 웹훅 결정 대기

**A 가 선행 조건이다.** A 없이 이걸 넣으면 만료 잡과 lost update 가 난다.

### 실측 — 규모는 작다 (2026-08-22, 운영 DB)

| 월 | P2P 레그 FAILED | TORQ 레그 FAILED | 우리가 판정한 분쟁 |
|---|---|---|---|
| 2026-06 | 15건 | 22건 | 6건 |
| 2026-07 | **0건** | 20건 | 1건 |
| 2026-08 | **1건 (15,000원)** | 10건 | 4건 |

- **E 의 대상은 P2P 레그뿐이다** — TORQ 는 LP 소유라 우리가 판정하지 않는다(위탁 가드 `972c2ab`).
- 8월 기준 **월 1건 / 1.5만원**. 유동성 묶임도 관리자 큐 증가(4→5건)도 무시할 수준이다.
- 6월 15건 → 7·8월 0~1건 — 만료 직전 스크래핑 방어선이 실제로 일하고 있다.

### 대부분의 안전망은 이미 있다

이체 완료 신고를 한 레그(`BANK_PENDING`)는 확인 마감 10분 초과 시 **이미 자동으로 분쟁이 걸린다**:
- 자동 확인 회원 → `P2pScrapingVerifyJob.disputeIfConfirmOverdue`(441-464)
- 수동 확인 회원 → `P2pManualConfirmReminderJob.disputeConfirmOverdue`(233-245)

둘 다 `autoDisputeMatch` 를 쓴다. **E 가 추가로 덮는 건 "이체는 했는데 신고 버튼을 안 누른 채
만료된 `CREATED` 레그" 하나뿐이다.**

### 확정된 범위 (구현하게 되면)

- 대상: **자동 확인 P2P 레그만** — `withdraw_order_id != null` 이고 실효 수동이 아닌 것
- `SKIPPED_MANUAL`(수동 확인 회원) 제외 — 만료 대상이 아니라 죽지 않고, 리마인더 잡이 따로 처리
- TORQ·PARTNER(`withdraw_order_id IS NULL`) 제외 — 스크래핑 대상이 아니라 판정 근거가 없다
- **`UNVERIFIED` 일 때만** 전환 (아래 "반드시 지킬 규칙")
- 만료 잡 **두 개 모두** 고친다

### ☠️ 별건으로 뺀 이유 — 구 위젯·파트너 영향 3건

**1. 구 위젯이 SYSTEM 분쟁을 "증빙 제출 필요"로 그린다.**
`needsEvidence = status==='DISPUTED' && disputeSubmittedBy !== 'DEPOSITOR'` (구 위젯 `p2p.vue:1204`).
자동 분쟁이 곧바로 소명 요구로 뜬다. 의도와는 맞지만 **이체 신고를 안 한 구매자에게도 뜬다** —
돈을 안 보낸 사람이 증빙을 요구받는다.

**2. 주문 종결이 보류된다.**
분쟁 레그가 있으면 `expireDepositOrder` 가 주문을 닫지 않는다. 30분에 끝나던 것이 관리자 판정까지
안 끝나고, 구 위젯은 계속 폴링하며 대기 화면에 머문다.

**3. 파트너 웹훅이 늦어진다. ← 가장 큰 것**
`closeDepositOrder` → `notifyP2pOrderComplete`(`P2pSettlementService.java:1244, 1272`) 구조라,
지금 30분에 나가던 종결 통지가 판정까지 지연된다. **주문 상태를 30분 안에 종결로 기대하는 파트너가
있으면 연동이 깨진다.**

**결정이 필요하다** — 지연을 그대로 두고 파트너 공지 / 분쟁 진입 시 중간 상태 웹훅 신설 /
웹훅이 없는 경로로 한정. 이 결정이 나기 전에는 E 를 배포하지 않는다.

### 무엇을 메우는가

`P2pMatchExpiryJob.java:110-117` 이 만료 직전 최종 스크래핑을 돌리지만 **`CONFIRMED`/`DISPUTED`
만 구제한다.** `UNVERIFIED`(조회 성공·입금 없음)면 그대로 `failExpiredMatch`(129) 로 간다.
그리고 **`FAILED` 가 된 뒤에는 분쟁을 걸 수 없다**(2026-08-14 결정, `submitDispute` 2449-2454).

즉 "돈은 보냈는데 자동 확인에 안 잡힌" 레그는 구매자가 30분 안에 직접 분쟁을 걸지 않으면 죽는다.

### 넣을 자리

`P2pMatchExpiryJob.execute()` 의 108-135 루프, `failExpiredMatch`(129) **직전**.
그 자리가 `CREATED` 레그를 보는 유일한 잡이고, 111 행에서 이미 `VerifyOutcome` 을 손에 쥐고 있다.

⚠️ **`P2pDepositLinkExpiryJob.java:101-111` 에도 같은 분기가 필요하다.** 그쪽은 `verifyMatch` 를
부르지 않고 바로 `failExpiredMatch`(110) 한다. **한쪽만 고치면 레이스에 따라 어떤 날은 분쟁,
어떤 날은 만료가 된다.**

### 쓸 메서드

`autoDisputeMatch`(2655-2682). `CREATED` 를 **이미 허용한다**(2661-2662).
단 `source` 가 `"P2P_SCRAPING"` 로 하드코딩(2673)돼 있어 만료 전환과 구분되지 않는다 →
`source` 파라미터 오버로드 추가 권장(예: `"EXPIRY"`). 기존 4개 호출부 영향 검토 필요.

### 반드시 지킬 규칙

**`UNVERIFIED` 일 때만 분쟁으로 보낸다.** `ERROR`(조회 실패)/`SKIPPED`(자격증명 없음)에서
분쟁을 만들면 **CODEF 장애 시 전건 분쟁**이 된다 — `P2pScrapingVerifyJob.java:420-423` 이 이미
겪고 명문화한 사고다.

### ⚠️ 결정이 필요한 것 — 유동성

분쟁으로 전환하면 `failExpiredMatch` 의 unlock/`restoreDepositRemaining`(2934-2951)을 타지 않아
**출금자 USDT 잠금이 관리자 판정까지 유지된다.** 지금은 만료 즉시 풀린다. 자동 판정도 없어
(`P2pDisputeDueMonitorJob` 은 알림만) 사람이 볼 때까지다.

**오너 결정 대기**: 이 유동성 묶임을 감수할지, 대상을 좁힐지(예: `SKIPPED_MANUAL` 제외).

### 컬럼 추가 불필요

`dispute_submitted_by='SYSTEM'`(2669) + `p2p_disputes.source='EXPIRY'` + `dispute_reason` 문구
조합으로 구분된다. `p2p_matches` 분쟁 12컬럼은 **DROP 예정**(`CRYPTOMENTS_V2_DDL.sql:3166-3187`)이라
새 컬럼을 거기 추가하면 이관 계획과 충돌한다.

⚠️ `dispute_reason` 은 **자유 문자열이고 이용자에게 그대로 노출된다**
(`P2pScrapingVerifyJob.java:50-56`). 위젯이 문자열 매칭으로 분기하면 문구 수정 시 조용히 깨진다 —
**코드값이 필요하면 `p2p_disputes.source` 를 써라.**

---

## F. (보류) 실입금액 탐지 — 오탐 검토 선행

### 현재

금액 비교는 **완전 일치, 허용 오차 0**:
- 단일 레그 `P2pScrapingVerifyJob.java:189` — `if (parseAmount(tx.getAccountIn()) != match.getKrwAmount()) continue;`
- 그룹 `:366` — 같은 로직 (**두 곳이 같아서 반드시 함께 고쳐야 한다**)

금액 필터를 통과 못 한 거래는 **버려진다.** CODEF 응답 전체는 `VerifyResult.transactions` 로
받아오지만 어디에도 저장하지 않는다(`P2pScrapingService.java:307-320` — `scraping_call_logs` 에
`txnCount` 건수만). 그래서 "얼마가 들어왔는지"가 남지 않는다.

이름 대조(`nameMatches` 487-496, prefix 2자까지 허용)는 **금액 게이트 뒤에** 있어서 무용지물이다.
→ **탐지하려면 필터 순서를 뒤집어야 한다** (이름 먼저, 금액은 비교 대상으로).

### ☠️ 오탐 위험이 크다

지금의 엄격한 일치는 **사고 대응의 산물**이다:
- `P2pScrapingVerifyJob.java:169` — *2026-06-16: -10분 창이 셀러 계좌의 과거 동일금액 입금을 잘못 잡던 문제*
- `:105-107` — *미입금·미클릭 레그가 셀러 계좌의 과거 동일금액 입금에 걸려 '입금자명 불일치(자동)'로
  잘못 분쟁되던 문제 차단*

**금액 게이트를 느슨하게 하면 판매자 계좌의 무관한 입금을 이 주문의 결제로 오인한다.**

### 반드시 지킬 규칙 (구현하게 되면)

**탐지는 하되 자동 확정(`confirmBankTransfer`)에는 절대 쓰지 않는다.** 분쟁 정보로만 쓴다.

`ref` 중복 소비(193-197, `buildRef` = `날짜_시간_금액` 467-469)는 금액이 ref 의 일부라 다른 금액은
다른 ref 다 → **nearMiss 를 기록만 하고 소비하지 않으면 안전하다.**

### 기록할 자리

`dispute_events.amount_krw` 가 **이미 있고 비어 있다** — 쓰기 경로는 `submitWithdrawerDispute`
(`P2pMatchingService.java:2582`) 단 1곳이고 실측 0건.
`autoDisputeMatch`(2673)는 `amountKrw=null` 로 넘긴다 → 시그니처 확장 필요.

`AMOUNT_SHORT`/`AMOUNT_OVER` 는 **Java enum 이 아니라 주석뿐이고, 서버에서 생성하는 코드가 0건**이다.
회원이 보내면 검증 없이 저장될 뿐이다.

`p2p_bank_confirmations`(실입금 정본 테이블)는 **코드가 하나도 없다** — DDL/문서에만 존재.

---

## G. 작업 순서와 검증

```
── 이번 범위 (1차 배포 단위) ──────
A (autoDisputeMatch 클레임)          ← 독립
B (취소 ↔ 매칭 잠금)                 ← 독립
C (신고 시각 응답)                   ← 독립
D (close_reason 응답)                ← 독립
──────────────────────────────────
E (자동 분쟁 전환)   ← A 선행 + 파트너 웹훅 결정
F (실입금액 탐지)    ← 오탐 검토 선행
```

**A·B·C·D 는 서로 독립이라 한 번에 간다.** 넷 다 구 위젯에 영향이 없어(§0-0) 위젯보다 먼저
배포할 수 있다. E·F 는 이번 범위가 아니다.

### 빌드

샌드박스는 Java 11 이라 gradle 이 안 돌 수 있다. 억지로 시도하지 말고 **수정 파일과 변경 요지,
컴파일 검증 여부를 보고**하라. 검증 가능하면 `./gradlew :core:compileJava :open-api:compileJava`.

### 절대 금지

- `git add` / `commit` / `checkout` / `stash` / `restore` 등 **git 쓰기 명령 일절 금지.**
  커밋은 오케스트레이터가 한다.
- **DDL 변경 금지.** 이 지침서의 A~D 는 전부 DDL 불필요다.
- `widget-ui` / `cryptoments-admin` 손대지 마라 — 프론트는 별건이다.
- 작업 중 다른 미커밋 변경이 보이면 **되돌리지 마라.**

---

## H. 보고할 것

1. 수정한 파일 목록 (전체 경로)
2. A: 클레임 삽입 위치와 실패 시 반환값
3. B: 잠금을 건 지점 4곳(`matchPhase1` 진입 / `cancelDepositOrder` / `matchPhase3` / 조건부 UPDATE)과
   **잠금 순서가 입금 주문 → 출금 주문 → 매칭 으로 일관됨을 어떻게 확인했는지**
4. B: `matchPhase1` 하위 메서드에 `fresh` 를 넘기도록 바꾼 곳 전부
5. C: 병합 카드 처리 방식, `P2P_MATCHES_INTEGRITY.md` TODO 취소 기록
6. D: `resolveMemo` 가 여전히 응답에 없음을 어떻게 확인했는지
7. 각 완료 기준에 대한 충족 근거
8. 지침서와 다르게 구현했거나 판단이 필요한 지점
