# U4 — 분쟁 조회 API + 화면 3종

작성 2026-08-18 · repo `cryptoments`(admin-api, partner-api, open-api) + `cryptoments-admin`(admin-ui, partner-ui, p2p-ui)
선행 완료: 헤더 백필 46건(누락 0), 이벤트 적재 복구(S1), U1 회원 증빙 제출

---

## 현재 상태 — 기록은 쌓이는데 볼 방법이 없다

```
p2p_disputes     46행  (백필 44 + 실적재 2)     조회 API 0
dispute_events    8행                          조회 API 0 (회원 API 는 매칭 스냅샷만 반환)
```

관리자·파트너·회원 **누구도 분쟁 경위를 볼 수 없다.** 판정 버튼만 있고 근거 화면이 없다.

**이 작업은 읽기 전용이다. 분쟁을 새로 만들거나 상태를 바꾸는 코드를 쓰지 마라.**
(판정·증빙 제출·증빙 재요청은 이미 있다 — 건드리지 않는다.)

## 도메인 사실 (추측 금지 — 아래가 정본)

```
event_type   RAISED · EVIDENCE_SUBMITTED · REQUEST_MORE · RESOLVED_CONFIRM · RESOLVED_CANCEL
헤더 status  OPEN · RESOLVED
헤더 result  CONFIRM · CANCEL · NULL(판정 미기록 또는 모순)
authority    TORQ (TORQ_WEBHOOK 발) · CRYPTOMENTS
core 읽기    DisputeEventService.findByMatch(matchId)  — 이미 존재. 재구현하지 마라
```

**백필 행의 성격을 화면이 숨기지 마라.** 백필 44건은 헤더만 있고 이벤트가 0이다.
스레드가 비었을 때 "기록 없음"이 아니라 **"과거 건 — 상세 기록이 남아 있지 않습니다"** 로 표시한다.
그리고 `result` 가 NULL 인데 `result_raw` 가 있는 3건은 **매칭 결과와 어긋난 건**이다
(TORQ 구매자 타임아웃인데 매칭은 정산됨). 관리자 화면에서 눈에 띄게 표시하라.

---

# A. admin-api

## A1. 분쟁 상세 조회

```
GET /api/admin/p2p/matches/{id}/dispute
→ { header: {...} | null, events: [...] }
```

- 헤더: `p2p_disputes` 에서 `match_id` 로 조회 (`P2pDisputeRepository` 확인, 없으면 만든다)
- 이벤트: `DisputeEventService.findByMatch(id)` 재사용. **새 매퍼를 만들지 마라**
- 분쟁 이력이 없으면 `header: null, events: []` 를 200 으로 반환. **404 를 던지지 마라** —
  화면이 "분쟁 없음"을 정상 상태로 그려야 한다
- 관리자에겐 `evidenceUrl`·`actor`·`payloadJson` 전부 노출한다 (판정 근거)

## A2. 응답 DTO

`P2pDisputeDetailResponse` (헤더) + `DisputeEventResponse` (이벤트). **멤버 변수 JavaDoc 필수.**

이벤트 DTO 에 넣을 것: `id`, `eventType`, `source`, `actor`, `reason`, `amountKrw`,
`evidenceUrl`, `payloadJson`, `createdAt`.
`deliveredTo`/`deliveredAt`/`deliveryOk` 도 넣는다 — 파트너 통보 여부를 관리자가 봐야 한다.

---

# B. partner-api

## B1. 파트너 분쟁 조회

```
GET /api/partner/p2p/matches/{id}/dispute
→ 같은 구조
```

**소유 검증이 핵심이다.** 매칭은 출금 파트너와 입금 파트너 둘을 잇는다.
요청 파트너가 **둘 중 하나가 아니면 404**(403 아님 — 존재 여부를 흘리지 마라).
`P2pMatch.withdrawOrderId → p2p_withdraw_orders.partner_id`,
`depositOrderId → p2p_deposit_orders.partner_id` 두 경로를 모두 확인하라.

**상대 파트너 정보를 노출하지 마라** — 상대 파트너명·회원 식별자는 응답에서 뺀다.

## B2. 증빙 URL 노출 제한 ⚠️

증빙 파일은 **접근 제어가 없다**(URL 을 아는 사람은 누구나 연다 — 알려진 백로그).
따라서 파트너에게는 **자기 쪽 당사자가 올린 증빙 URL 만** 준다.
상대측 이벤트는 `evidenceUrl` 을 `null` 로 비우고, 대신 "증빙 제출됨" 을 알 수 있도록
`hasEvidence: true` 같은 불리언만 준다.

판정 기준: 이벤트의 `source`/`actor` 가 요청 파트너 쪽(출금측이면 WITHDRAWER 계열,
입금측이면 DEPOSITOR 계열)인지. **`source` 의 실제 값 분포를 DB 나 코드에서 먼저 확인하고**
매핑을 정하라. 애매하면 **보수적으로 가린다.**

---

# C. open-api (회원)

## C1. 기존 엔드포인트 확장

`GET /p2p/page/orders/{code}/disputes` 는 지금 `p2p_matches` 스냅샷만 반환한다
(`P2pWithdrawPageController.java:634-641`). 여기에 스레드를 붙인다.

기존 응답 구조를 **깨지 마라** — p2p-ui 가 이미 쓰고 있다. 필드를 추가하는 방식으로 확장한다.
각 분쟁 항목에 `events: [...]` 를 더한다.

## C2. 회원용 이벤트 축약 ⚠️

회원은 당사자지만 내부 운영 정보를 봐선 안 된다.

```
노출     eventType · createdAt · reason(있으면) · 본인이 올린 evidenceUrl
숨김     actor · payloadJson · deliveredTo/At/Ok · 상대측 evidenceUrl · resolvedBy
```

회원 화면은 **출금자(WITHDRAWER) 당사자**다 (`P2pWithdrawPageController` 는 이미 그렇게 고정돼 있다 — :610).
따라서 `source`/`actor` 가 WITHDRAWER 계열인 이벤트의 증빙만 URL 을 준다.

**RESOLVED_* 이벤트는 결과만 보여준다** — 판정 메모(`resolveMemo`)는 내부 문구라 노출하지 마라.

---

# D. 화면

## D1. admin-ui — 매칭 상세 분쟁 타임라인

**파일** `admin-ui/src/views/p2p/P2pMatchDetailView.vue`

U2 에서 확장한 분쟁 카드 **아래에** 타임라인을 붙인다.

- 시간순(오래된 것 위). `eventType` 별로 아이콘/색 구분
- 각 항목: 시각 · 유형(한글) · 주체(source/actor) · 사유 · 증빙 링크 · 파트너 통보 여부
- **이벤트가 0건이면** "과거 건 — 상세 기록이 남아 있지 않습니다" (빈 상태 문구를 "기록 없음"으로 하지 마라)
- **헤더 `result` 가 NULL 인데 `result_raw` 가 있으면 경고 배지** — "TORQ 판정과 매칭 결과가 어긋남"

`eventType` 한글 라벨은 U1 에서 만든 `STATUS_LABELS_KO` 에 넣지 마라 — 상태가 아니라 이벤트다.
이 화면 로컬 상수로 둔다.

## D2. partner-ui — 거래 추적에 분쟁 타임라인

**파일** `partner-ui/src/views/partner/transactions/TransactionTrackingView.vue`

레그 상세에 이미 `disputedAt`/`disputeReason` 을 보여주는 자리가 있다(:166-170).
그 아래에 타임라인을 접이식(기본 접힘)으로 붙인다. 분쟁 이력이 없는 레그엔 아무것도 그리지 마라.

증빙은 B2 규칙에 따라 링크가 없을 수 있다 — 그 경우 "증빙 제출됨(열람 불가)" 로 표시한다.

## D3. p2p-ui — 회원 스레드

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

U1 에서 증빙 제출 폼을 붙인 카드에 **스레드를 추가**한다.

- 시간순, 모바일이므로 간결하게 (아이콘 + 한 줄 + 시각)
- 문구는 이 repo 의 기존 톤을 따른다 — 회원 대상 존댓말·평이한 표현
  (예: "관리자가 확인 중이에요", "증빙을 제출했어요")
- **금액·판정 결과를 단정적으로 쓰지 마라.** 진행 중 건에 "해결됨" 같은 표현 금지
- 이벤트 0건이면 스레드 영역 자체를 그리지 마라 (모바일 공간 낭비)

기존 UX 패턴은 `components/sheets/ManualConfirmSheet.vue` 와 U1 에서 만든 증빙 폼을 따른다.

---

## 코딩 규칙

- Java 17 · Lombok(`@Data` 금지) · DTO 멤버 JavaDoc 필수
- MyBatis 는 `@Mapper` + `@Select` interface. **XML 매퍼 금지**
- `<script>` 안에서 `<`, `<=`, `<>` 직접 사용 금지 → `&lt;` / `!=` (부팅 시 SAXParseException = 전 서비스 다운)
- **open-api 세션 타입** — `/widgets/api/*` 는 `WidgetSessionData`, `/api/v1/*` 는 `OpenApiSessionData`.
  회원 페이지(`/p2p/page/*`)는 **기존 컨트롤러가 쓰는 방식을 그대로 따르라.** 바꾸면 401 이 난다
- Vue 는 기존 파일 스타일. 새 라이브러리 도입 금지
- **DDL 을 만들거나 실행하지 마라. DB 에 접속하지 마라**

## 완료 기준

```
1  ./gradlew :admin-api:compileJava :partner-api:compileJava :open-api:compileJava 통과
2  admin-ui · partner-ui · p2p-ui 빌드 통과 (타입 에러 0)
3  분쟁 이력이 없는 매칭을 조회해도 200 + 빈 결과 (404 아님)
4  파트너 API 가 남의 매칭에 404 를 준다
5  회원 응답에 actor·payloadJson·상대측 evidenceUrl·resolveMemo 가 없다
6  admin 타임라인이 이벤트 0건일 때 "과거 건" 문구를 낸다
7  result NULL + result_raw 있음 인 3건에 경고 배지가 뜬다
```

## 보고 형식

- 작업별 수정 파일:라인 + 한 줄
- **B2 의 증빙 노출 판정을 어떤 기준으로 구현했는지** (source 값 분포 근거 포함)
- C2 에서 실제로 가린 필드 목록
- 지침이 실제 코드와 어긋난 지점 — **고치지 말고 먼저 보고**
- 빌드 결과

## 착수 전 필수 확인

```
common/.../entity/P2pDispute.java · DisputeEvent.java
core/.../p2p/DisputeEventService.java        (findByMatch — 재사용 대상)
core/.../p2p/P2pDisputeService.java          (헤더 생성/종결 로직 — 읽기만)
open-api/.../controller/p2p/P2pWithdrawPageController.java:604-641  (기존 응답 구조·세션 방식)
partner-api 의 기존 파트너 소유 검증 패턴    (매칭 접근 제어를 어떻게 하고 있는지)
admin-ui/src/views/p2p/P2pMatchDetailView.vue  (U2 에서 만든 분쟁 카드 위치)
```

지침과 다르면 **멈추고 보고하라.**
