# P2P LP 분쟁 증빙 전달 — 구현 지침서

**작성일:** 2026-08-25
**근거:** TORQ 팀 핸드오프 `CRYPTOMENTS_DISPUTE_EVIDENCE_API.md` (2026-08-22)
**DDL:** v2.16 — `p2p_disputes.lp_provider` / `lp_evidence_deadline` / `lp_evidence_forwarded_at` **운영 적용 완료**
**대상 모듈:** `common`, `core`, `open-api`, `widget-ui`

---

## 0. 왜 하는가 — 한 문장

**구매자가 올린 증빙이 LP 에 도달하지 않아, LP 는 그를 "무응답"으로 보고 1시간 뒤 자동 패소시킨다.**

실측 근거 (운영 DB, 2026-08-25 조회):

| 사실 | 값 |
|---|---|
| LP(leg_type=TORQ) 분쟁 누계 | 43건 |
| 그중 패소(FAILED/CANCELLED) | 17건 |
| 패소 중 **우리가 증빙을 보관하고 있던** 건 | 4건 |
| 대표 사례 | `pm_f5ad8ddd0ec5` / escrow 935 / **500,000원** / 2026-07-30 |

대표 사례가 결정적이다. 분쟁 11:41:33 → 구매자가 우리 위젯에 증빙 업로드 11:46(5분 뒤, 기한 1시간 내) →
판정 `TORQ_DISPUTE_BUYER_TIMEOUT`. **LP 입장에서는 아무것도 오지 않았다.**

---

## 1. 진짜 원인 — 배관은 있었다

`TorqClient.submitEvidence(providerCode, escrowId, statement, evidenceUrl)` 는 **이미 구현되어 있고**
(`common/.../client/TorqClient.java:352`), `TorqService.submitEvidence` 도 있으며
(`core/.../torq/TorqService.java:693`), P2P 에서 호출하는 곳도 있다.

문제는 **호출이 한쪽 경로에만 있다**는 것이다.

```
위젯 "증빙 제출"  →  P2pWidgetController.submitDispute        (open-api:479-497)
                        │
                        ├─ match.status != DISPUTED  →  matchingService.submitDispute
                        │                                  └─ TORQ 레그면 LP 전달 ✅ (:2705-2717)
                        │
                        └─ match.status == DISPUTED  →  matchingService.submitDisputeEvidence
                                                           └─ LP 전달 ❌ 없음   (:2820-2857)
```

**LP 가 먼저 분쟁을 걸면 매칭은 이미 `DISPUTED`** 이므로 항상 아래 분기로 간다.
LP 발 분쟁이 대부분이라(오늘 건도 `dispute_source=TORQ_WEBHOOK`) 사실상 전량 미전달이었다.

> ⚠️ 이 사실을 "LP 연동이 없다"로 오해하지 말 것. 새로 만드는 게 아니라 **빠진 분기를 잇는 것**이다.
> 새 클라이언트를 또 만들면 인증·provider 라우팅이 두 벌이 된다.

---

## 2. 작업 범위

| # | 항목 | 우선도 | 모듈 |
|---|---|:---:|---|
| A | 증빙 재제출 경로에서 LP 전달 | **P0** | core |
| B | 전달 사실·기한 기록 (`lp_*` 3컬럼) | **P0** | core |
| C | LP 웹훅에서 `evidenceDeadline` 수신·저장 | **P0** | core |
| D | 위젯 증빙 제출 기한 카운트다운 | **P0** | open-api, widget-ui |
| E | 파일 1장 제약 대응 | **P0** | widget-ui |
| F | LP 분쟁도 `waiting_on=DEPOSITOR` 세우기 | P1 | core |
| G | 제출 후 "검토 중" 전환 (화면 모순 제거) | P1 | widget-ui |
| H | `TORQ_LP` 표기 → 실제 공급자 | P2 | core, open-api |
| I | 사유(`OTHER`) 폴링값 보정 | P2 | core |
| J | LP 거부를 확정 문구로 쓰지 않기 | P2 | widget-ui |

---

## A. 증빙 재제출 경로에서 LP 전달 (P0)

**파일:** `core/src/main/java/com/cryptoments/core/p2p/P2pMatchingService.java`
**메서드:** `submitDisputeEvidence(Long matchId, String actorParty, String evidenceUrl, String memo)` (2820~)

`submitDispute` 의 전달 블록(2705-2717)과 **같은 모양**으로, 매칭 저장 뒤에 추가한다.

### 규칙

1. **조건**: `match.getLegType() == P2pLegType.TORQ && match.getTorqEscrowId() != null`
2. **행위자 제한**: `"DEPOSITOR".equals(actorParty)` 일 때만 전달한다.
   LP 에 보내는 것은 **구매자의 소명**이다. 관리자/판매자 제출을 구매자 소명으로 올려보내면 안 된다.
3. **인자 매핑** — TORQ 스펙 §3:
   - `statement` ← `memo` (구매자 자유 텍스트)
   - `evidenceUrl` ← `evidenceUrl`
   - **둘 다 비면 호출하지 않는다** (LP 가 `356` 을 준다). 호출 전에 검사할 것.
4. **실패해도 우리 트랜잭션을 깨지 않는다.** `submitDispute` 와 동일하게 try/catch 후 `log.warn`.
   우리 DB 에는 이미 저장됐고, 전달 실패는 재시도 대상이지 사용자 에러가 아니다.
5. 성공하면 **B** 의 `lp_evidence_forwarded_at` 을 기록한다.

```java
// ── LP 레그: 구매자 소명을 LP 로 전달 (2026-08-25) ──
// ☠️ 이 블록이 없어서 LP 는 구매자를 "무응답"으로 보고 +1h 에 자동 SELLER_WIN 처리했다.
//    escrow 935(50만원)가 그렇게 넘어갔다 — 구매자는 5분 만에 우리 위젯에 올렸는데도.
//    LP 가 먼저 건 분쟁은 매칭이 이미 DISPUTED 라 항상 이 메서드로 들어온다.
if (match.getLegType() == P2pLegType.TORQ
        && match.getTorqEscrowId() != null
        && "DEPOSITOR".equals(actorParty)
        && (hasText(evidenceUrl) || hasText(memo))) {   // 둘 다 비면 LP 가 356
    ...
}
```

> ⚠️ `submitDispute` 는 `torqService.submitEvidence(partnerId, escrowId, reason, evidenceUrl)` 로
> **reason 을 statement 자리에** 넘기고 있다(2710). 여기서는 `memo` 가 구매자 문장이므로 `memo` 를 쓴다.
> `submitDispute` 쪽 인자도 함께 점검하되, **동작을 바꾸는 수정은 이번 범위 밖**이다 — 발견 사항으로만 보고할 것.

---

## B. 전달 사실·기한 기록 (P0)

**DDL 은 이미 운영에 적용되어 있다.** 엔티티/리포지토리에 필드를 추가하는 일만 남았다.

**파일:** `common/src/main/java/com/cryptoments/common/entity/P2pDispute.java`

```java
@XColumn("lp_provider")
private String lpProvider;

@XColumn("lp_evidence_deadline")
private LocalDateTime lpEvidenceDeadline;

@XColumn("lp_evidence_forwarded_at")
private LocalDateTime lpEvidenceForwardedAt;
```

`P2pDisputeService` 에 갱신 메서드를 둔다 (헤더가 정본이므로 매칭 컬럼에는 두지 않는다):

- `markEvidenceForwarded(Long matchId, LocalDateTime at)` — A 성공 직후 호출
- `updateLpDeadline(Long matchId, String lpProvider, LocalDateTime deadline)` — C 에서 호출

> ⚠️ `P2pDispute` 는 `modify()` 로 부분 갱신되는 엔티티다. **읽어온 스냅샷을 그대로 되쓰지 말 것** —
> non-null 전체 SET 이라 동시 쓰기를 되돌린다(2026-08-21 운영 사고). 대상 컬럼만 UPDATE 하는
> 매퍼 메서드를 쓰거나, 조회 직후 즉시 갱신할 것.

---

## C. `evidenceDeadline` 수신·저장 (P0)

**파일:** `core/src/main/java/com/cryptoments/core/torq/TorqService.java`
**메서드:** `handleDisputed(TorqTrade trade, Map<String,Object> payload)` (1018~)

TORQ 스펙 §6: 기한은 **분쟁 진입 웹훅과 거래 조회 응답에 실려 나온다. 직접 계산하지 말 것.**

1. `extractDataField(payload, "evidenceDeadline")` 로 수신 (필드명은 실제 payload 로 확인 —
   없으면 `GET /api/widget/trades/{escrowId}` 응답의 `dispute` 블록에서 조회해 보정).
2. `markDisputedFromTorq` 이후 헤더에 저장: `updateLpDeadline(matchId, trade.getLpProvider(), deadline)`.
3. **값이 없으면 NULL 로 둔다.** 임의로 `now + 1h` 를 넣지 말 것 — LP 가 배포 설정으로 바꿀 수 있고,
   추정치를 화면에 카운트다운으로 띄우면 그 자체가 거짓말이 된다.

---

## D. 위젯 증빙 제출 기한 (P0)

### 서버 — `open-api`

`P2pWidgetMatchDetailResponse` 에 추가:

```java
/** LP 증빙 제출 기한 (ISO-8601). LP 위탁 분쟁에서만. 없으면 null */
private String lpEvidenceDeadline;
/** 기한까지 남은 초. 서버 계산값(위젯이 시계를 직접 만들지 않게). 없거나 지났으면 null */
private Long lpEvidenceRemainingSeconds;
```

`P2pWidgetController.toMergedDetail` 에서 헤더(`P2pDispute`)로부터 매핑한다.
이미 `disputeService.latestByMatchIds(...)` 로 일괄 조회하고 있으므로 **추가 쿼리 없이** 채울 수 있다.

### 위젯 — `widget-ui/src/views/p2p.vue`

- 분쟁 카드에 남은 시간을 표시한다. **서버가 준 잔여초만 흘려보낸다** — 로컬에서 마감시각을 만들지 않는다.
- 값이 없으면 **카운트다운 자리를 비운다.** (착수 기한과 같은 원칙: 없는 기한을 그리지 않는다)
- 0 이 되어도 위젯은 아무것도 종결시키지 않는다. 판정은 LP/관리자가 하고 결과는 폴링으로 온다.

---

## E. 파일 1장 제약 (P0)

TORQ 스펙 §4: **`evidenceUrl` 은 단일 문자열이고, 두 번째를 보내면 첫 번째가 사라진다.**

현재 위젯이 여러 장을 올릴 수 있는지 먼저 확인하고,

- 여러 장을 받는다면 → **한 장만 보낸다는 사실을 UI 에서 분명히 하거나**, 마지막 1장만 전송하도록 제한
- 한 장만 받는다면 → 재제출 시 "이전 파일이 대체됩니다" 를 문구로 알린다

> 합쳐서 보내기(이미지 결합)는 이번 범위 밖이다. 필요하면 TORQ 에 배열 확장을 요청한다(§10).

---

## F. LP 분쟁도 `waiting_on=DEPOSITOR` (P1)

**현상:** LP 발 분쟁은 `dispute_waiting_on` 이 NULL 로 시작한다. 그래서 구매자가 증빙을 올려도
`submitDisputeEvidence` 의 전이 조건(`actorParty.equals(openDispute.getWaitingOn())`, 2843)이 성립하지 않아
**상태가 하나도 바뀌지 않는다.** 화면은 영원히 "소명이 필요합니다" 로 남는다.

**수정:** `markDisputedFromTorq` (P2pMatchingService:1606~) 에서 헤더 생성 시 `waitingOn = "DEPOSITOR"` 로 연다.
LP 가 "입금 못 받았다"고 건 분쟁에서 다음 행동을 할 사람은 구매자다.

- `due_at` 은 `lp_evidence_deadline` 과 **별개**다. 혼동하지 말 것 —
  `due_at` 은 우리 관리자 재요청 6시간, `lp_evidence_deadline` 은 LP 의 1시간이다.
- `dispute_round` 는 건드리지 않는다. **이미 폐기된 컬럼**이고 라운드는 `REQUEST_MORE` 이벤트 수로 도출한다
  (`DisputeEventService.countRounds`). 되살리지 말 것.

---

## G. 제출 후 "검토 중" 전환 (P1)

**파일:** `widget-ui/src/views/p2p.vue`

현재 `needsEvidence(m)` = `m.status === 'DISPUTED' && m.disputeSubmittedBy !== 'DEPOSITOR'` (1498).
**제기자**만 보고 **내가 제출했는지**는 보지 않는다. 제기자는 영원히 `TORQ_LP` 이므로 소명 요구가 사라지지 않는다.
같은 화면의 진행 기록에는 "증빙이 제출됐어요" 가 이미 찍혀 있어 **한 화면이 서로 다른 말을 한다.**

**수정 방향:**

1. F 가 들어가면 `disputeWaitingOn` 이 제출 시 비워지므로 `isMyTurn` 이 자연히 false 가 된다.
2. `needsEvidence` 는 "내가 이번 라운드에 제출했는가" 로 바꾼다 — 분쟁 스레드(`threadOf(m)`)에
   `EVIDENCE_SUBMITTED`(actor=DEPOSITOR)가 마지막 `REQUEST_MORE` 이후에 있으면 제출한 것이다.
3. 제출했으면 배너·버튼 대신 **검토 중** 표시. 추가 제출은 지금도 있는 조용한 링크로 남긴다
   (상대가 증빙을 붙였다고 구매자의 추가 제출을 막으면 안 된다).

---

## H. `TORQ_LP` 표기 → 실제 공급자 (P2)

`markDisputedFromTorq:1621` 이 공급자와 무관하게 `"TORQ_LP"` 를 쓴다. LP 가 둘이 된 뒤로 전부 오표기다
(2026-08-25 실측: `escrow 100000011` 은 **BARO** 인데 화면에 `TORQ_LP`).

- **저장값은 그대로 둔다.** `raised_by` 를 바꾸면 기존 43건과 어긋나고, `sourceText()` 등 매핑도 함께 깨진다.
- 대신 **B 에서 신설한 `lp_provider` 에 실제 코드를 적재**하고, 표시 계층이 그 값을 쓴다.
- `P2pDisputeService.sourceText()` (674) — `TORQ_LP/TORQ/TORQ_WEBHOOK → "TORQ"` 를
  `lp_provider` 가 있으면 그 값으로, 없으면 `"LP"` 로. **알림 문구에도 BARO 건이 TORQ 로 나가고 있다.**

> ✅ 안전 확인 완료: 판정 권한 가드(에러 1031)는 `authorityOf(legType)` 에서 파생되므로
> `TORQ_LP` 문자열과 **무관**하다. 표기를 바꿔도 가드는 그대로 동작한다.

---

## I. 사유 `OTHER` 보정 (P2)

TORQ 스펙 §8: `IN_DISPUTE` 웹훅의 `reason` 은 **항상 `OTHER`** 로 나간다(TORQ 측 버그 — 웹훅 생성이
분쟁 레코드 생성보다 한 줄 빠르다). **폴링 응답 `dispute.disputeReason` 에는 정상 값이 실린다.**

- `handleDisputed` 에서 `reason` 이 `OTHER` 이거나 비면, `torqClient.getTrade(provider, escrowId)` 로
  `dispute.disputeReason` 을 조회해 보정한다.
- 조회 실패는 무시하고 `OTHER` 로 둔다 — 사유 보정 때문에 분쟁 전파가 실패하면 안 된다.
- **`LP_NO_RESPONSE`** — LP 가 확인 버튼을 안 눌러 생긴 분쟁(=LP 귀책)이다. **이 사유일 때는 구매자에게
  증빙을 요구하면 안 된다.** 아직 값이 합의되지 않았으므로(TORQ §8) 지금은 매핑만 준비하고,
  값이 정해지면 위젯 분기를 추가한다. 이번 구현에서는 **알 수 없는 사유 = 증빙 요구**가 기본값이다.

---

## J. LP 거부를 확정으로 쓰지 않기 (P2)

TORQ 스펙 §2 — **LP 는 수락만 할 수 있고 반려는 TORQ 관리자만 한다.**
`lpReviewDecision=REJECTED` 는 종결이 아니라 관리자 큐로 넘어갔다는 뜻이다.

위젯에서 **"판매자가 거절했습니다"** 같은 확정형 문구를 쓰지 않는다. 현재 문구를 점검하고,
그런 표현이 있으면 "검토 중" 계열로 바꾼다. 종결은 `result` 가 채워지고 `resolvedAt` 이 생겨야 성립한다.

---

## 3. 코딩 규칙 (프로젝트 공통)

- Java 17. Entity 는 `@XColumn`, `@Getter @Setter @Builder(toBuilder=true) @NoArgsConstructor @AllArgsConstructor`. `@Data` 금지
- MyBatis 는 **interface 메서드만**. XML 매퍼 금지. `<script>` 안에서 `<`, `<=`, `<>` 직접 사용 금지 (`&lt;` 또는 CDATA)
- `IXRepository.save()` 는 PK(Long) 반환 — 엔티티가 아니다
- DTO 멤버 변수에 JavaDoc 필수
- 외부 HTTP 는 **트랜잭션 잠금을 쥔 채로 치지 않는다.** LP 전달은 커밋 이후(`sendAfterCommit` 패턴) 또는
  실패를 삼키는 try/catch 로 처리한다 — 이미 `submitDispute` 가 후자다
- 위젯 i18n 은 ko/en 양쪽에 **같은 키**를 넣는다 (현재 523/523)

## 4. 완료 기준

- [ ] LP 가 먼저 건 분쟁에서 구매자가 증빙을 올리면 `POST /api/widget/trades/{escrowId}/evidence` 가 실제로 호출된다
- [ ] 전달 성공 시 `p2p_disputes.lp_evidence_forwarded_at` 이 채워진다
- [ ] `statement`/`evidenceUrl` 이 둘 다 비면 호출하지 않는다 (356 방지)
- [ ] 전달 실패해도 우리 DB 저장과 화면 전환은 정상 진행된다
- [ ] `handleDisputed` 가 `evidenceDeadline` 과 `lpProvider` 를 헤더에 저장한다 (없으면 NULL)
- [ ] 위젯이 서버가 준 잔여초로만 기한을 표시하고, 값이 없으면 자리를 비운다
- [ ] LP 발 분쟁이 `waiting_on=DEPOSITOR` 로 열린다
- [ ] 구매자가 제출하면 화면이 "검토 중"으로 바뀌고, 소명 요구 배너가 사라진다
- [ ] 관리자 화면·알림에 BARO 건이 BARO 로 표기된다
- [ ] 판정 권한 가드(1031)가 그대로 동작한다 — TORQ 레그 분쟁을 관리자가 확정하면 409
- [ ] `./gradlew :common:compileJava :core:compileJava :open-api:compileJava` 통과
- [ ] `npm run build:prod` (widget-ui) 통과, ko/en 키 수 동일

## 5. 하지 말 것

- ☠️ 새 LP 클라이언트를 만들지 말 것 — `TorqClient.submitEvidence` 가 이미 provider 라우팅·인증을 갖고 있다
- ☠️ `dispute_round` 를 되살리지 말 것 — 폐기된 컬럼이다
- ☠️ `raised_by` 의 `TORQ_LP` 저장값을 바꾸지 말 것 — 기존 43건과 매핑이 어긋난다. 표시만 고친다
- ☠️ 증빙 기한을 `now + 1h` 로 계산하지 말 것 — LP 응답값이 정본이다
- ☠️ 위젯이 기한 만료로 무언가를 종결시키지 말 것 — 판정은 LP/관리자, 결과는 폴링
- ☠️ push 하지 말 것 — 커밋까지만. 배포는 오너가 판단한다
