# P2P 시간 모델 구현 — ③ 이체 15분 · ④ 확인 10분→분쟁

작성 2026-08-20 · repo `cryptoments`
모델 정본: **`v2-docs/P2P_TIME_MODEL.md` 를 먼저 읽어라.** 이 문서는 그 모델의 구현 지침이다.
설정값은 **이미 운영 DB 에 넣었다** (§0).

---

## 목표 — 시간 개념이 명확해야 한다

이용자에게 한 줄로 설명할 수 있어야 한다.

```
매칭되면 15분 안에 입금하고 '입금완료'를 누르세요.
누른 뒤 10분 안에 확인됩니다. 넘으면 분쟁으로 넘어갑니다.
```

이 문장이 코드와 어긋나면 안 된다. **구간을 가르는 것은 시간이 아니라 상태다.**

```
CREATED       구매자가 아직 '입금완료'를 누르지 않음   → ③ (15분)
BANK_PENDING  눌렀음, 확인 대기                        → ④ (10분)
```

---

## 0. 설정 — 이미 적용 완료 (DML 하지 마라)

```
p2p.transfer_deadline_minutes  = 15    ③
p2p.confirm_deadline_minutes   = 10    ④
p2p.match_wait_max_seconds     = 1500  ② (기존)
```

**코드 폴백은 이 값과 같게 두어라.** 다르면 로컬·스테이징이 운영과 다르게 동작한다
(대기 상한에서 이미 한 번 겪었다).

---

# ③ 이체 15분

## A1. 마감 산정

`P2pDepositService` 의 `EXPIRY_MINUTES = 30` 상수를 **설정값으로 바꾼다.**

```
expires_at = (match_wait_until ?? now) + p2p.transfer_deadline_minutes
```

산정식 자체는 직전 커밋(`c4525d2`)에서 이미 이 형태다 — **분 수만 상수→설정으로** 바꾼다.
매칭 레그는 주문 `expires_at` 을 상속하므로 자동 반영된다(`legExpiresAt`).

## A2. ⚠️ 만료 대상을 `CREATED` 로 좁힌다 — 이 작업의 핵심

`P2pMatchExpiryJob` 은 지금 `CREATED` 와 `BANK_PENDING` 을 **함께** 수거하고,
그중 실효 수동만 예외로 뺀다. 그대로 두면 ③과 ④가 충돌한다.

```
t=0    매칭 성립                              ③ 마감 t=15
t=14   구매자 '입금완료' → BANK_PENDING       ④ 마감 t=24
t=15   ③ 만료 — 자동확인 회원의 BANK_PENDING 이 FAILED
       ④가 9분 남았는데 죽는다                ← 잘못된 동작
```

**`BANK_PENDING` 을 수거 대상에서 뺀다.** 입금완료를 누른 순간 ③은 끝났다.

지금의 "실효 수동 BANK_PENDING 자동 FAIL 제외"(0-1 가드)는 **이 규칙의 부분 구현**이었다 —
자동확인 회원에게도 같은 보호가 필요하다는 것이 확정 판정이다.

### 함께 확인할 것 (지우기 전에 보고하라)

```
· BANK_PENDING 최종 스크래핑 1회 (N1, 2026-07-19 이식분)
  → BANK_PENDING 을 안 보게 되면 이 블록은 죽는다.
    ④의 P2pScrapingVerifyJob 이 5초마다 같은 일을 하므로 중복이지만,
    지우기 전에 정말 동등한지 확인하고 보고하라
· P2pDepositLinkExpiryJob 의 주문 종결
  → BANK_PENDING leg 가 남아 있으면 anyActive 가드가 주문을 열어두는지 확인.
    열어두는 게 맞다 — ④·⑤가 받는다. 안 열어두면 보고하라
```

## A3. 종결 사유

`CREATED` 만료는 **매칭 취소**다. 지금 쓰는 종결 사유를 그대로 쓰되,
이용자에게 "입금 시간이 지나 취소되었습니다"로 읽혀야 한다.
새 사유 코드를 만들 필요가 있으면 제안만 하고 만들지는 마라.

---

# ④ 입금 확인 10분 → 분쟁

## B1. 기준 시각

`p2p_matches.manual_confirm_started_at` 을 쓴다.
`P2pMatchingService:1985` 가 **BANK_PENDING 전이 때마다** 세운다 —
실효 수동/자동확인 구분 없이 채워진다(확인 완료).

```
분쟁 진입 조건 = now >= manual_confirm_started_at + p2p.confirm_deadline_minutes
```

컬럼이 NULL 인 구건은 `updated_at` 폴백 — 기존 잡이 이미 쓰는 방식을 따른다.

## B2. 두 잡으로 나눠 판정한다 (중복 없음)

두 잡 모두 이미 `findByStatus(BANK_PENDING)` 를 돈다. 서로 배타적으로 필터링한다.

```
자동확인 회원   P2pScrapingVerifyJob (5초)
                조회 성공 + 입금 미발견 + 10분 경과  → 분쟁
실효 수동 회원  P2pManualConfirmReminderJob (60초)
                10분 경과                            → 분쟁
```

- `ScrapingVerifyJob` 은 실효 수동을 건너뛰고, `ManualConfirmReminderJob` 은
  `!isEffectiveManual` 을 `continue` 한다 — **배타성이 유지되는지 확인하라.**
  겹치면 같은 매칭에 분쟁이 두 번 생긴다
- **TORQ·PARTNER 레그(`withdraw_order_id == null`)는 ④ 대상이 아니다.**
  TORQ 는 LP 가 확인하고 PARTNER 는 파트너가 확인한다. 두 잡 모두 이미 제외하고 있다 — 유지하라

## B3. ⚠️ 스크래핑이 죽었을 때 분쟁을 만들지 마라 — 필수 가드

자동확인까지 ④를 적용하면 **CODEF 장애 시 진행 중인 전건이 분쟁으로 쏟아진다.**
과거 CODEF 도메인 오류(`UnknownHostException`) 전력이 있다.

`P2pScrapingVerifyJob` 은 이미 결과를 `CONFIRMED / DISPUTED / SKIPPED / **ERROR**` 로 구분한다.

```
조회 성공 + 미발견 + 10분 경과   → 분쟁          진짜 미입금
조회 실패(ERROR)                 → 아무것도 안 함  우리 문제다. 다음 사이클
```

판정을 **그 잡 안, 결과를 아는 자리**에 둔다. 별도 컬럼도 전역 서킷브레이커도 필요 없다.
`SKIPPED` 도 분쟁을 만들지 마라 — 판단 근거가 없다는 뜻이다.

## B4. 분쟁 생성

**`P2pMatchingService.autoDisputeMatch(matchId, reason)` 를 재사용하라.**
입금자명 불일치 자동 분쟁이 이미 쓰는 경로다. 새 분쟁 생성 경로를 만들지 마라.

사유 문구는 두 경로를 구분할 수 있게: 예) `"입금 확인 지연(자동)"` / `"입금 확인 지연(수동)"`.
**최종 문구는 제안만 하고 확정하지 마라** — 이용자에게 보이는 말이라 따로 정한다.

## B5. 기존 알림 정리

```
manual-confirm.reminder-minutes  10분 회원 리마인더
manual-confirm.escalate-minutes  30분 관리자 에스컬레이션
```

- **리마인더(10분)** 는 분쟁 진입과 같은 시점이 된다. 알림은 유지하되
  **분쟁 진입 알림으로 합쳐라** — 같은 순간에 두 번 울리면 안 된다
- **에스컬레이션(30분)** 은 폐기한다. 분쟁이 이미 생겼으므로 ⑤ 감시(`P2pDisputeDueMonitorJob`)가 받는다
  설정 키와 코드를 제거하되, **제거 범위를 먼저 보고**하라

---

## 회귀 위험 — 여기가 가장 크다

이 변경은 **지금 돌고 있는 실거래에 즉시 영향**을 준다(대기 기능과 달리 스위치가 없다).

```
③ 30 → 15분     지금 30분 넘겨 살아남던 8%가 취소된다. 의도한 변화지만 첫날 취소가 늘 수 있다
④ 무기한 → 분쟁  분쟁이 자동 생성되기 시작한다. 관리자 부하가 는다
                  실측상 수동확인 8건이 전부 0.2~0.6분에 끝났으므로 발동은 드물어야 한다
                  ⚠️ 첫 주는 분쟁 유입량을 반드시 관찰할 것
```

**`p2p.confirm_deadline_minutes` 를 0 이하로 두면 ④를 끌 수 있게 하라.** 롤백 수단이 필요하다.
(③은 설정값을 되돌리면 된다)

## 코딩 규칙

- **DDL·DML 금지. DB 접속 금지. 운영 서버 접속 금지** — 설정은 이미 넣었다
- 새 워커·새 스케줄러·새 테이블·새 분쟁 경로 금지. 기존 잡·기존 `autoDisputeMatch` 에 얹는다
- 코드 폴백은 운영 설정값(15/10)과 **같게** 두어라
- MyBatis `<script>` 안에 `<` `<=` `<>` 금지 — SAXParseException 으로 전 서비스 다운
- `IXRepository.modify()` 는 non-null 필드만 갱신
- 매칭 대기(②) 관련 코드를 건드리지 마라 — 직전 커밋에서 끝났다
- 분쟁 읽기 이관(T5) 코드를 건드리지 마라 — 미배포 상태다
- 새 라이브러리 금지 · git commit / push 하지 마라

## 완료 기준

```
 1  ③ 마감이 p2p.transfer_deadline_minutes 에서 온다 (폴백 15)
 2  ★ P2pMatchExpiryJob 이 BANK_PENDING 을 수거하지 않는다 — CREATED 만
 3  ★ ④ 판정이 두 잡에 배타적으로 들어간다 (같은 매칭이 양쪽에 걸리지 않음을 코드로 증명)
 4  ★ 스크래핑 ERROR·SKIPPED 면 분쟁을 만들지 않는다 — 코드 경로로 증명
 5  TORQ·PARTNER 레그는 ④ 대상이 아니다
 6  분쟁 생성이 autoDisputeMatch 재사용이다 (새 경로 0)
 7  10분 리마인더와 분쟁 진입이 같은 순간에 두 번 울리지 않는다
 8  p2p.confirm_deadline_minutes <= 0 이면 ④가 꺼진다
 9  ./gradlew compileJava 통과
10  git commit·push 하지 않았다
```

## 보고 형식

- 수정 파일:라인 + 한 줄
- 2·3·4 를 **코드 경로로 증명**
- A2 "함께 확인할 것" 2건의 확인 결과 (지웠으면 왜)
- B5 에스컬레이션 제거 범위
- 분쟁 사유 문구 **제안**
- 지침이 실제 코드와 어긋난 지점 — **고치지 말고 먼저 보고**
- 빌드 결과

## 착수 전 필수 확인

```
v2-docs/P2P_TIME_MODEL.md                                    ← 모델 정본. 먼저 읽어라
core/.../p2p/P2pDepositService.java                          EXPIRY_MINUTES · expires_at 산정
core/.../p2p/P2pMatchingService.java                         :1985 manual_confirm_started_at
                                                             :2431 autoDisputeMatch
scheduler/.../job/P2pMatchExpiryJob.java                     ★ CREATED/BANK_PENDING 수거 · 0-1 가드 · N1
scheduler/.../job/P2pScrapingVerifyJob.java                  ★ 결과 4분류 · 자동 분쟁 기존 호출
scheduler/.../job/P2pManualConfirmReminderJob.java           ★ isEffectiveManual 필터 · 10/30분
scheduler/.../job/P2pDepositLinkExpiryJob.java               주문 종결 anyActive 가드
```

지침과 다르면 **멈추고 보고하라.**
특히 §A2(BANK_PENDING 제외)로 죽는 코드가 있으면 지우기 전에 보고할 것.
