# P2P/TORQ 분쟁 버그 수정 + 분쟁 관리 구현 지침

> 작성: 2026-06-27 (Cowork) · 대상: IntelliJ(Spring Boot) + admin-ui
> 우선순위: **P0 (Phase 1 버그 수정) → P1 (Phase 2 분쟁 관리/저장 노출)**
> 관련 DDL: `CRYPTOMENTS_V2_DDL.sql` (운영 DB 적용 완료 2026-06-27)

---

## 0. 배경 — 운영 장애 사례 (match #101)

2026-06-27 실제 발생한 상태 불일치 사례:

| 시각 | 이벤트 |
|------|--------|
| 07:20:08 | TORQ fallback 매칭 생성 (match=pm_14a62bdd01fd, escrow=107, 김서은, 500K) |
| 07:20:16 | 자동 accept + submitTransfer ("이체 완료 신고") |
| 07:23:52 | **TORQ Webhook `IN_DISPUTE`** 수신 → `torq_trades(id=105).status=DISPUTED` |
| 07:50:16 | `P2pMatchExpiryJob` 만료 처리 → TORQ 취소 시도 실패("취소할 수 없는 상태") → **매칭 강제 `FAILED`** |

**결과**: `torq_trades.status=DISPUTED` 인데 `p2p_matches.status=FAILED`. 분쟁이 관리자 화면에 노출되지 않고 자동 종결됨.

### 근본 원인 (정확히 한 곳)

`TorqService.handleDisputed(trade)` 는 `torq_trades` 만 `DISPUTED` 로 바꾸고 **연결된 `p2p_matches` 로 분쟁을 전파하지 않는다.** 따라서:

1. `p2p_matches.status` 는 `CREATED`/`BANK_PENDING` 으로 남는다.
2. `P2pMatchExpiryJob` 가 만료 시점에 이를 후보로 잡아 `failExpiredMatch()` 호출.
3. `failExpiredMatch()` 는 `match.status` 가 `CREATED`/`BANK_PENDING` 이면 통과 → `FAILED` 전이 → `cancelTorqLegIfAny()` 에서 TORQ가 "분쟁 중 취소 불가" 반환(삼킴).
4. 기존 만료 가드(`P2pScrapingVerifyJob.verifyMatch`)는 **P2P 은행 스크래핑 분쟁만** 감지하고 **TORQ Webhook 분쟁은 모른다.**

### 좋은 소식

관리자 분쟁 처리 인프라는 **이미 구현되어 있다.** 버그만 고치면 즉시 활용 가능:

- `P2pMatchManagementController` — 목록(`disputeOnly` 필터)/상세/판정(`/{id}/resolve`)
- `P2pMatchManagementService.resolveDispute()` — `CONFIRM`/`CANCEL`
  - TORQ 레그: `settlementService.adminSettleDisputedTorqLeg()` (admin 직접 청산)
  - P2P/PARTNER 레그: `confirmBankTransfer()` / `forceCancelDisputedLeg()`
- `P2pDashboardMapper.countDisputedMatches()` — `status='DISPUTED'` 카운트

즉 **TORQ 분쟁을 `p2p_matches.DISPUTED` 로 전파하기만 하면** 만료 자동 FAILED가 막히고(이미 `failExpiredMatch` 가 비-CREATED/BANK_PENDING 은 skip), 분쟁 목록·판정·대시보드 카운트가 전부 동작한다.

---

## Phase 1 — 버그 수정 (P0)

### C1. `TorqService.handleDisputed` — p2p_match 전파 (핵심)

`core/torq/TorqService.java`

**현재:**
```java
case "IN_DISPUTE" -> handleDisputed(trade);
...
/** IN_DISPUTE — 분쟁 진입. */
private void handleDisputed(TorqTrade trade) {
    TorqTrade disputed = trade.toBuilder()
            .status(TorqTradeStatus.DISPUTED)
            .disputedAt(LocalDateTime.now())
            .build();
    torqTradeRepository.modify(disputed);
    log.info("TORQ 분쟁 시작 (Webhook): tradeId={}, escrowId={}", trade.getId(), trade.getEscrowId());
}
```

**변경:** payload를 받아 사유/증빙을 추출하고, 연결된 매칭으로 전파한다.
```java
case "IN_DISPUTE" -> handleDisputed(trade, payload);
...
private void handleDisputed(TorqTrade trade, Map<String, Object> payload) {
    String reason = extractDataField(payload, "reason");          // TORQ payload 키 확인 필요
    String evidenceUrl = extractDataField(payload, "evidenceUrl");

    TorqTrade disputed = trade.toBuilder()
            .status(TorqTradeStatus.DISPUTED)
            .disputedAt(LocalDateTime.now())
            .disputeStatement(reason)            // torq_trades 기존 필드 활용
            .disputeEvidenceUrl(evidenceUrl)
            .build();
    torqTradeRepository.modify(disputed);

    // ★ 핵심: 통합 매칭 레그로 분쟁 전파 → 만료 자동 FAILED 차단 + 관리자 노출
    if (disputed.getP2pMatchId() != null) {
        matchingService.markDisputedFromTorq(
                disputed.getP2pMatchId(), reason, evidenceUrl, trade.getEscrowId(), payload);
    }

    log.info("TORQ 분쟁 시작 (Webhook): tradeId={}, escrowId={}, matchId={}",
            trade.getId(), trade.getEscrowId(), disputed.getP2pMatchId());
}
```

> ⚠️ `payload` 의 실제 키 이름(reason/evidenceUrl)은 TORQ Webhook 스펙으로 확인할 것. 키가 없으면 null 허용. `extractDataField` 는 동일 클래스에 이미 존재.

### C2. `P2pMatchingService.markDisputedFromTorq` — 신규 메서드

`core/p2p/P2pMatchingService.java`

```java
/**
 * TORQ Webhook(IN_DISPUTE)로 들어온 분쟁을 매칭에 전파한다.
 * CREATED/BANK_PENDING → DISPUTED 로 전이하여 만료 자동 FAILED를 차단하고
 * 관리자 분쟁 목록/판정 대상으로 만든다. 이미 종결(SETTLED/FAILED/CANCELLED)된 건은 무시.
 */
@Transactional
public void markDisputedFromTorq(Long matchId, String reason, String evidenceUrl,
                                 Long escrowId, Map<String,Object> payload) {
    P2pMatch match = matchRepo.findOne(matchId);
    if (match == null) return;
    // 진행 중(CREATED/BANK_PENDING/SETTLING)만 DISPUTED 로. terminal 은 스킵.
    if (match.getStatus() == P2pMatchStatus.SETTLED
            || match.getStatus() == P2pMatchStatus.FAILED
            || match.getStatus() == P2pMatchStatus.CANCELLED) {
        log.warn("TORQ 분쟁 전파 무시(이미 종결): matchId={}, status={}", matchId, match.getStatus());
        return;
    }
    match.setStatus(P2pMatchStatus.DISPUTED);
    match.setDisputedAt(LocalDateTime.now());
    match.setDisputeReason(reason);
    match.setDisputeEvidenceUrl(evidenceUrl);
    match.setDisputeSubmittedBy("TORQ_LP");
    match.setDisputeSource("TORQ_WEBHOOK");   // 신규 컬럼
    matchRepo.modify(match);

    disputeEventService.log(matchId, escrowId, match.getDepositOrderPartnerId(),
            "RAISED", "TORQ_WEBHOOK", reason, evidenceUrl, "lp", payload);

    log.info("TORQ 분쟁 → 매칭 전파: matchId={}, escrow={}", matchId, escrowId);
}
```

> `disputeEventService` 는 Phase 2의 감사 로그 서비스(C5). Phase 1에서는 호출부를 주석 처리하거나, C5를 같이 구현해도 됨.

### C3. `failExpiredMatch` — 방어적 가드 (이중 안전망)

`core/p2p/P2pMatchingService.java` — 전파가 누락되는 경합(webhook 지연 등)에 대비.

```java
public boolean failExpiredMatch(Long matchId) {
    P2pMatch match = matchRepo.findOne(matchId);
    if (match == null) return false;
    if (match.getStatus() != P2pMatchStatus.CREATED
            && match.getStatus() != P2pMatchStatus.BANK_PENDING) {
        return false;
    }
    // ★ 가드: TORQ 레그면 torq_trades 가 DISPUTED/취소불가 상태인지 확인 → 만료 보류
    if (match.getTorqEscrowId() != null) {
        TorqTrade tt = torqTradeRepository.findByEscrowId(match.getTorqEscrowId());
        if (tt != null && (tt.getStatus() == TorqTradeStatus.DISPUTED
                        || tt.getStatus() == TorqTradeStatus.COMPLETED)) {
            // DISPUTED는 분쟁으로 승격, COMPLETED는 정산 경로로 처리되어야 함 → 만료 금지
            markDisputedFromTorq(matchId, tt.getDisputeStatement(),
                    tt.getDisputeEvidenceUrl(), tt.getEscrowId(), null);  // DISPUTED 일 때
            log.warn("만료 보류 — TORQ 레그 비정상 상태: matchId={}, torqStatus={}", matchId, tt.getStatus());
            return false;
        }
    }
    // ... 이하 기존 로직 동일 (FAILED 전이 + cancelTorqLegIfAny + 잠금해제) ...
}
```

> COMPLETED 인데 매칭이 CREATED/BANK_PENDING 인 경우는 정산 콜백 누락 신호이므로 만료시키지 말고 별도 모니터링(StaleTxMonitor) 대상으로 둔다.

### C4. `P2pMatchExpiryJob` — 후보 수집 단계 필터 (선택)

`scheduler/job/P2pMatchExpiryJob.java` — 후보 단계에서 TORQ DISPUTED를 미리 제외하면 불필요한 트랜잭션을 줄인다(C3 가드가 있으므로 필수는 아님). 기존 `scrapingVerifyJob.verifyMatch` 가드는 그대로 둔다.

### C5. `handleForceReleased` 정합성 점검

분쟁이 이제 `p2p_matches.DISPUTED` 로 올라가므로, `FORCE_RELEASED`(분쟁 해결) 수신 시:

- `REFUND_TO_BUYER`(구매자 승) → `matchingService.failMatch(...)` 호출. **`failMatch` 가 `DISPUTED → FAILED` 전이를 허용하는지 확인**(현재 CREATED/BANK_PENDING 만 허용하면 DISPUTED 건이 안 닫힘).
- `RELEASE_TO_BUYER`(LP 승) → 후속 `COMPLETED` webhook 이 매칭을 `SETTLED` 로 전환할 때 `DISPUTED → SETTLING/SETTLED` 전이 허용 확인.
- 두 경우 모두 `dispute_events` 에 `RESOLVED_*` 1건 기록(C5 서비스).

> **검증 포인트**: `failMatch`, `confirmBankTransfer`, settlement 경로의 상태 전이 화이트리스트에 `DISPUTED` 가 포함되어야 한다. 누락 시 분쟁 해결 webhook이 무시될 수 있음.

---

## Phase 2 — 분쟁 저장/노출 (P1)

### C5. 분쟁 감사 로그 — `dispute_events`

운영 DB에 `dispute_events` 테이블 적용 완료. 분쟁 생명주기를 append-only로 남겨 cryptoments 측에서 단일 소스로 조회한다.

**엔티티/리포지토리** (`common`):
- `entity/DisputeEvent.java` — `@XEntity("dispute_events")`, 컬럼: `p2pMatchId, torqEscrowId, partnerId, eventType, source, reason, evidenceUrl, actor, payloadJson(String), createdAt`
- `repository/DisputeEventRepository.java` — `IXRepository<Long, DisputeEvent>` + `List<DisputeEvent> findByP2pMatchId(Long)`

**서비스** (`core`): `DisputeEventService.log(matchId, escrowId, partnerId, eventType, source, reason, evidenceUrl, actor, payload)` — payload는 JSON 직렬화하여 `payload_json` 저장. 모든 분쟁 분기(C2 RAISED, resolveDispute RESOLVED_*, 증빙제출 EVIDENCE_SUBMITTED, 만료보류 EXPIRY_BLOCKED)에서 호출.

기록 시점:
| event_type | 호출 위치 |
|---|---|
| RAISED | `markDisputedFromTorq` (TORQ) / 스크래핑 분쟁 / 회원 분쟁 제기 |
| EVIDENCE_SUBMITTED | 증빙 업로드 API |
| RESOLVED_CONFIRM / RESOLVED_CANCEL | `resolveDispute` |
| EXPIRY_BLOCKED | `failExpiredMatch` 가드(C3) |

### C6. 관리자 분쟁 화면 (admin-ui + admin-api)

**API는 대부분 존재** — 누락분만 추가:

1. **상세 응답 확장** — `P2pMatchDetailResponse` 에 `disputeSource`, `disputeSubmittedBy`, `disputeReason`, `disputeEvidenceUrl`, 그리고 TORQ 레그면 `torqStatus`(torq_trades 조인) 포함. `P2pMatchSearchMapper.findMatchDetail` 쿼리에 분쟁 필드 추가.
2. **분쟁 이벤트 타임라인 API (신규)** — `GET /api/admin/p2p/matches/{id}/dispute-events` → `disputeEventRepository.findByP2pMatchId(id)`.
3. **admin-ui 화면**:
   - 분쟁 목록: 기존 `getMatches?disputeOnly=true` 활용. 컬럼에 `분쟁출처`, `제기자`, `경과시간`, `레그유형` 추가.
   - 분쟁 상세: 매칭 정보 + 은행/계좌 + **분쟁 이벤트 타임라인** + 증빙 링크 + 판정 버튼(CONFIRM/CANCEL → `/{id}/resolve`).
   - TORQ 레그 분쟁은 "TORQ_LP 제기" 뱃지로 구분.

> ⚠️ admin-ui 코딩 규칙은 `ADMIN_UI_IMPLEMENTATION_GUIDE.md` 준수. 컨트롤러 경로/세션 타입은 admin-api 기존 패턴(`AdminSessionData`) 따름.

---

## 검증 체크리스트

### 단위/통합
- [ ] TORQ `IN_DISPUTE` webhook 수신 → `p2p_matches.status=DISPUTED`, `dispute_source=TORQ_WEBHOOK`, `dispute_submitted_by=TORQ_LP` 확인
- [ ] DISPUTED 매칭이 `P2pMatchExpiryJob` 만료 후보에서 제외(FAILED 안 됨) 확인
- [ ] `failExpiredMatch` 가 DISPUTED/COMPLETED TORQ 레그를 만료시키지 않음(가드)
- [ ] `resolveDispute(CONFIRM/CANCEL)` 이 DISPUTED 매칭에서 정상 동작 + `dispute_events` RESOLVED 기록
- [ ] `FORCE_RELEASED`(REFUND/RELEASE)가 DISPUTED→FAILED/SETTLED 전이 허용
- [ ] `countDisputedMatches` 가 TORQ 분쟁 포함하여 증가

### 운영 데이터 정합 (수동, match #101)
- [ ] 기존 불일치 건(#101: match FAILED / torq #105 DISPUTED) 처리 방침 결정 — 운영자가 admin 판정으로 마감하거나 데이터 보정. (`p2p-dispute-ops-policy` 메모: 장기 미해결 분쟁은 관리자 운영 몫)

---

## 배포 순서

1. `common` 엔티티/리포지토리(DisputeEvent) → `core`(DisputeEventService, markDisputedFromTorq, failExpiredMatch 가드, handleDisputed 시그니처) → `scheduler`/`open-api` 컴파일
2. `admin-api` 상세/타임라인 API
3. `admin-ui` 분쟁 화면
4. `git push origin main` (백엔드/프론트 각 레포) → GitLab CI/CD 자동 배포. **DDL은 이미 운영 적용 완료.**
