# API 연동 확장 — 출금 주문키/Webhook + P2P 외부 제공 (기능 정리)

- 작성일: 2026-08-10
- 상태: **정리 단계 — 구현 착수 전.** 범위 확정 후 별도 지침서로 분리한다.
- 대상: `common`, `core`, `open-api`, `partner-api`
- 조사 근거: 코드베이스 전수 (파일:라인은 각 절에 표기)

---

## 요약 — 두 건의 성격이 전혀 다르다

| | 건 1. 출금 주문키 + Webhook | 건 2. P2P API 제공 |
|---|---|---|
| 현재 상태 | **배관 90% 완성, 연결만 끊김** | **외부 API 표면 자체가 없음** |
| 컬럼/스키마 | `partner_reference`·`partner_metadata` **이미 존재** | 출금주문 쪽 `partner_reference` **없음** |
| 난이도 | 낮음 — 필드 연결 + webhook 보강 | 높음 — 인증 모델부터 결정 필요 |
| 주된 리스크 | 하위호환(기존 파트너 페이로드) | eKYC 우회, 회원 온보딩 종속 |

건 1은 **바로 갈 수 있고**, 건 2는 **범위와 인증 모델을 먼저 정해야** 한다.

---

# 건 1. 출금 — 파트너 주문키 유지 + Webhook 전달

## 1-1. 현재 상태: 끊긴 곳은 딱 한 군데다

```
파트너 요청 ──✗── withdrawals.partner_reference ──✓── webhook "orderId"
           없음              (컬럼 존재)              (이미 읽고 있음)
```

- `Withdrawal` 엔티티에 **`partnerReference`(L53)·`partnerMetadata`(L56) 이미 있다.**
  DDL도 있다 — `CRYPTOMENTS_V2_DDL.sql:1643,1645`.
- `WebhookPayloadBuilder:241` 이 **이미 `orderId` 로 실어 보낸다.**
- 그런데 **아무도 값을 넣지 않는다.** 전 코드베이스에 `.partnerReference(...)` 세팅 **0건**.
  → **출금 webhook 의 `orderId` 는 구조적으로 항상 `null`.**

요청 DTO 3개 모두 주문키 필드가 없다.

| DTO | 경로 | 현재 필드 |
|---|---|---|
| `CreateWithdrawalRequest` | partner-api | currencyId, networkId, toAddress, whitelistId, amount, partnerUserId |
| `UserWithdrawalRequest` | open-api `/api/v1` | partnerUserId, amount, currencyType, chainType, toAddress, amountUnit, krwAmount |
| `WithdrawalRequest` | open-api widget | partnerUserId, chainType, currencyType, toAddress, amount, memo |

> **설계 문서에는 이미 있었다.** `CRYPTOMENTS_WITHDRAWAL_PROCESS.md:500-506,552-557` 이
> 요청 바디에 `partner_reference`+`partner_metadata` 를 받도록 설계했고, DDL 인덱스
> `idx_partner_ref (partner_id, partner_reference)` 까지 있다(L448). **설계만 있고 구현이 빠진 건이다.**

## 1-2. 해야 할 일

### (a) 주문키 수집 — 3개 경로 전부
요청 DTO에 `orderId`(파트너 주문번호) + `metadata`(pass-through) 추가 → `Withdrawal.partnerReference/partnerMetadata` 저장.
입금 쪽 네이밍과 맞춘다 — 입금은 이미 `orderId` 로 받고 있다(`DepositReservationRequest.orderId`).

### (b) 멱등성 — ★이게 본질이다
주문키의 진짜 가치는 조회가 아니라 **중복 출금 방지**다. 네트워크 타임아웃 후 파트너가 재요청하면
지금은 **출금이 두 번 나간다.**

- `WithdrawalRepository.findByPartnerIdAndPartnerReference(...)` 신설 (현재 조회 메서드 자체가 없음)
- 동일 `(partner_id, partner_reference)` 재요청 시 처리 방침 **결정 필요** — §1-4 결정사항 D1
- DB `UNIQUE(partner_id, partner_reference)` 제약을 걸지 여부도 함께 (현재는 일반 인덱스 설계)

### (c) 조회/검색
- 출금 목록 검색 키워드에 `partner_reference` 추가 (현재 `withdrawal_code`, `tx_hash` 뿐)
- 주문키로 단건 조회하는 엔드포인트 — 파트너가 자기 주문번호로 상태를 되묻는 용도

### (d) Webhook 페이로드 보강
현재 출금 페이로드에 **하드코딩·누락**이 있다.

| 필드 | 현재 | 조치 |
|---|---|---|
| `orderId` | 항상 null | (a) 로 해결 |
| `fromAddress` | **하드코딩 `null`** (L243) | MASTER 지갑 주소 조회해 채움 |
| `feeAmount` | **하드코딩 `"0"`** (L257) | 실제 수수료/가스비 — 소스 확정 필요 |
| `withdrawalCode` | 미포함 | 추가 (`wdr_...`, 파트너 문의 시 식별자) |
| `errorMessage` | 미포함 | `WITHDRAWAL_FAILED` 에 실패 사유 |
| `blockNumber` | 미포함 | 추가 |
| `metadata` | 미포함 | pass-through 반환 |

⚠️ **하위호환 3원칙 준수**(지식 repo §41): ①기존 키 제거 금지 ②`txHash` null→`""` 는 페이로드와
**서명 데이터에 동시 적용** ③서명식 `partnerId|txHash|amount|timestamp` 불변.

### (e) Webhook 발송 시점 — 구멍 메우기

현재 발송되는 것: `REQUESTED`(WithdrawalService:217) / `APPROVED`(:288) / `REJECTED`(:330) /
`CANCELLED`(:407, 파트너 취소만) / `EXHAUSTED`(:543) / `CONFIRMED`(WebhookProcessingService:468) /
`FAILED`(:612)

**안 나가는 것** — 파트너가 상태를 못 따라간다.

| 전이 | 위치 | 판단 |
|---|---|---|
| `PROCESSING` / `BROADCASTING` | Node relayer 가 DB 직접 갱신, Spring 미개입 | 필요성 논의 — §1-4 D2 |
| `COMPLETED` (P2P 원화 경로 종료) | WithdrawalService:796 | **필요** — 종결 상태인데 알림이 없다 |
| `P2P_PENDING` 전환 | :691 | 필요 |
| 관리자 `cancel()` | :345-370 | **필요** — 파트너 취소는 나가는데 관리자 취소는 안 나감 (비대칭) |
| `retry()` | :425-443 | 낮음 |
| `STALE` 마킹 | 스케줄러 | 낮음 (내부 운영용) |
| 정산출금 생성 | :233-258 | 낮음 (파트너 자신의 수수료 인출) |

## 1-3. 부수 — `/api/v1` 출금 API가 생성 1개뿐이다

외부 공개 경로에 `POST /api/v1/users/withdrawal` 하나만 있다. 조회·취소·정책·수수료 API가 없어
**파트너가 출금을 API로 운영하려면 webhook 수신 외에는 상태를 알 방법이 없다.**
주문키가 생기면 조회 API의 가치가 커지므로 함께 검토한다.

## 1-4. ★결정 필요

| # | 사항 | 선택지 |
|---|---|---|
| **D1** | 중복 주문키 재요청 처리 | (a) **기존 건 그대로 반환**(멱등, 권장) / (b) 409 거부 / (c) 허용(현행) |
| **D2** | `PROCESSING`/`BROADCASTING` webhook | Node relayer 가 상태를 쥐고 있어 Spring 에서 훅 걸 지점이 없다. 추가하려면 relayer→Spring 통지 경로가 필요 — 비용 대비 가치 판단 |
| **D3** | `feeAmount` 소스 | 출금 수수료 정책값 / 실제 가스비(`gas_cost_records`) / 둘 다 |
| **D4** | `UNIQUE(partner_id, partner_reference)` DB 제약 | 걸면 확실하나, 기존 데이터가 전부 null 이라 **null 다중 허용** 확인 필요 |

---

# 건 2. P2P — 외부 API 제공

## 2-1. 현재 상태: 외부 API 표면이 없다

**`/api/v1/*`(HMAC 서버-투-서버)에 P2P 엔드포인트 0개.** `open-api/controller/v1/` 6개 컨트롤러에
`p2p` 문자열 자체가 없다.

P2P 는 **세 가지 세션**으로만 제공된다.

| 경로 | 인증 | 용도 |
|---|---|---|
| `/widgets/api/p2p/*` | `WidgetSessionData` | 구매자(입금자) 흐름 전체 |
| `/api/partner/p2p/*` | 파트너 콘솔 **이메일/비밀번호 세션** | 파트너 운영자 화면 |
| `/p2p/page/*` | 회원 **PIN 세션** | 판매자(출금자) 개인 페이지 |

★ **`partner-api` 는 API Key/Secret 을 지원하지 않는다**(`PartnerAccessTokenConfig:15`).
로그인 세션 Bearer 뿐이라 **서버-투-서버 연동에 부적합**하다. 이게 건 2의 핵심 제약이다.

## 2-2. 기능별 제공 여부

| 기능 | 위젯 | 파트너 콘솔 | `/api/v1` |
|---|---|---|---|
| 라우팅 판정(P2P/TORQ) | O | X | **X** |
| 매칭 요청 | O | O(생성만) | **X** |
| 매칭 조회 | O | O | **X** |
| **이체 완료 신고** | O | **X** | **X** |
| **매칭/주문 취소** | O | O(주문만) | **X** |
| **분쟁 제기/증빙** | O | **X** | **X** |
| 출금주문 등록 | X | O | **X** |
| **판매자 계좌 등록(CODEF)** | X | **X** | **X** — PIN 세션 전용 |
| PIN 설정 | X | X | X — 호스팅 페이지 전용 |
| 풀 현황 | X | O | **X** |

## 2-3. ★막힌 지점 3가지

### (1) 판매자 온보딩이 Cryptoments 호스팅 페이지에 묶여 있다
회원 생성(`POST /api/partner/p2p/members`)까지는 파트너가 할 수 있으나, 그 뒤
**PIN 설정 → 계좌 등록(CODEF 스크래핑)** 은 `p2p.cryptoments.cc/m/{memberToken}` 에서만 된다.
→ **파트너 서비스 안에 P2P 판매자 기능을 심는 것이 현재 불가능.**
게다가 `CreateP2pWithdrawRequest.bankAccountId` 가 `@NotNull` 인데 파트너가 계좌를 조회할 API도 없다.

### (2) eKYC 게이트가 위젯 전용 — API 경로는 우회된다
`P2pEkycGuard` 호출자는 위젯 2곳뿐이다. `P2pController.createDepositOrder` 는
`createAndMatch(partnerId, partnerUserId, krwAmount)` 3-인자 오버로드를 타면서
**buyerName/bankCode/kycUid/phone/account 를 전부 null 로** 넘긴다(`P2pDepositService:62-64`).
→ 콘솔 세션으로는 **eKYC 없이 입금 주문 생성이 가능**하고, 그 대가로 buyer 스냅샷이 비어
**거래확인증·TORQ 잔여 레그 매칭이 깨진다**(코드 주석이 이미 경고 중, :77-79).
**API 를 열면 이 구멍이 그대로 외부에 노출된다. 규제 관점에서도 먼저 정리해야 한다.**

### (3) P2P 상태 webhook 이 전무하다
나가는 건 정산 완료 시 **일반 `DEPOSIT_CONFIRMED` 하나뿐**(P2pSettlementService:376,622,1096).
매칭 생성/부분매칭/이체완료신고/취소/분쟁제기/분쟁해결/주문만료 — **전부 미발송.**
`TransactionType.P2P_WITHDRAW_EXPIRED` 는 선언만 되고 아무 데서도 쓰이지 않는 **죽은 enum**.
→ API 로 P2P 를 붙여도 **파트너가 진행 상황을 폴링으로만 알 수 있다.**
설계 문서에는 `p2p.withdraw.matched` 등 이벤트 표가 있으나(`P2P_MATCHING_ARCHITECTURE.md:876-880`) 미구현.

## 2-4. 주문키 현황 (건 1과 연결)

| 테이블 | `partner_reference` | 입력 경로 |
|---|---|---|
| `p2p_deposit_orders` | **있음**(엔티티 L106) | 위젯 `P2pWidgetMatchRequest.orderId` **하나뿐** |
| `p2p_withdraw_orders` | **없음** | — |

`CreateP2pDepositRequest`(partner-api)에도 주문키 필드가 없다.
⚠️ `p2p_deposit_orders.partner_reference` 는 **DDL 문서에 없고 엔티티에만 있다** — 수동 ALTER 로 추정.
DDL 동기화 필요.

## 2-5. 현재 가능한 우회 (권장하지 않음)

파트너 서버가 `POST /widgets/auth/token`(HMAC)으로 위젯 토큰을 발급받아
`/widgets/api/p2p/*` 를 서버에서 호출하는 것이 **기술적으로 가능**하다.
위젯 토큰에 **권한 검사가 없어**(`getPermissions()` 호출처 없음) 토큰만 있으면 전 엔드포인트 접근된다.
→ 설계 의도가 아니며, 정식 API 를 열기 전 **이 경로의 권한 검사 부재 자체를 점검**해야 한다.

## 2-6. ★결정 필요

| # | 사항 | 선택지 |
|---|---|---|
| **D5** | **인증 모델** | (a) `/api/v1/p2p/*` 신설 + 기존 HMAC 재사용(권장 — 출금 API와 일관) / (b) partner-api 에 API Key 인증 추가 / (c) 위젯 토큰 공식화 |
| **D6** | **제공 범위** | (a) **구매자 흐름만** — 파트너가 자기 UI로 P2P 구매 제공, 판매자는 기존 호스팅 페이지 유지 (작고 현실적) / (b) 구매자+판매자 전체 — 계좌 등록/CODEF 까지 API 화 (큼, 보안 검토 필요) / (c) 조회 전용부터 |
| **D7** | **eKYC 강제 여부** | API 경로에도 게이트를 걸 것인가. 걸면 파트너가 Axim 연동을 해야 하고, 안 걸면 buyer 스냅샷 결손이 외부로 확대된다. **규제 판단 필요** |
| **D8** | **P2P webhook 이벤트 집합** | 최소(매칭됨/완료/취소) / 전체(분쟁·부분매칭 포함) |
| **D9** | `p2p_withdraw_orders.partner_reference` 추가 여부 | DDL 변경 수반 |

---

## 다음 단계 제안

1. **건 1을 먼저 간다.** 배관이 이미 있어 범위가 작고, 건 2의 주문키 설계도 여기서 정해진 규칙을 따르면 된다.
2. 건 1 결정사항 D1~D4 확정 → 지침서 분리 → 서브에이전트 구현.
3. 건 2는 **D5~D7(인증·범위·eKYC)** 이 정해져야 설계가 시작된다. 특히 **D7 은 기술 결정이 아니다.**
4. 건 2 착수 전 별건으로 **위젯 토큰 권한 검사 부재**(§2-5)를 점검한다.

## 범위 밖 (이번 정리에서 제외)

- 어드민 콘솔 변경
- P2P 수수료 정책 변경
- 위젯 UI 변경
- `/widgets/api/withdrawal-fee` 가 수수료를 `"0"` 하드코딩으로 반환하고 `minAmount` 에
  `policy.singleLimit` 를 잘못 매핑하는 문제 — 별건 버그로 기록만 한다.
