# Open API 1차 구현 지침서 — A(회원 등록) · B(P2P 출금) · F(가용 잔액)

> 작성 2026-08-27 · 상태: **구현 지시** · 대상 모듈 `open-api`, `core`, `common`
> 상위 설계: `OPEN_API_MEMBER_CATEGORY_DESIGN.md` — **이 지침서와 충돌하면 이 문서가 우선**한다(§0.2 참조)
> 서브에이전트는 이 문서만 읽고 구현할 수 있어야 한다.

---

## 0. 범위

### 0.1 왜 A·B·F 인가

**핵심 요구: 파트너가 P2P 출금을 매번 콘솔로 하기 어렵다.** 서버-투-서버로 자동화해야 한다.

파트너 자동화가 실제로 돌아가는 최소 세트가 A·B·F 다.

```
① F 가용 잔액 확인  →  ② A 회원 등록(최초 1회)  →  ③ B P2P 출금
```

F 가 빠지면 파트너는 얼마를 보낼 수 있는지 모른 채 던지고 409 를 반복해 맞는다.
현재 v1 잔액 API 2개가 **서로 다른 값을 "가용액"이라 부르고 있어** 그 값으로 자동화하면 반드시 어긋난다
(설계서 §7.0).

A 는 선택이 아니다 — **B 가 `partnerUserId` 로 회원을 자동 생성하는데 그 `getOrCreate` 가 동시 호출에서 500 을 낸다**(§3.1). A1 수정은 B 의 선행이다.

### 0.2 상위 설계서에서 **변경된 결정** (2026-08-27)

| 항목 | 설계서 | **이 지침서 (확정)** |
|---|---|---|
| B 파트너 오픈 게이트 | §2 선결 2 — 잔여 USDT 전환 흐름 완성이 **전제 조건** | **게이트 해제. B 구현 즉시 오픈** |

**근거 — 재조사 결과 "회원이 잔여를 받을 방법이 없다"는 사실이 아니다.** 잔여 회수 경로는 살아 있다.

```
회원 페이지 requestUsdtConvert   (open-api P2pWithdrawPageController:972)
  → 파트너 콘솔 approve-usdt      (partner-api P2pController:511)
     또는 어드민 승인             (admin-api P2pOrderManagementController:171)
       → approveUsdtConvert → completeDirectWithdrawal → 신규 withdrawals → 릴레이어 → 회원 주소
```

추가로 파트너 콘솔 `convert-direct`(즉시 전환, `P2pController:495`), `force-settle`(원화 이미 지급, `P2pController:468`).
**4경로 전부 `verifyWithdrawOwnership` 를 호출**하므로 설계서 P5(소유권 검증 누락) 지적도 호출부에서는 이미 지켜지고 있다.

| 설계서 주장 | 실제 |
|---|---|
| 운영 실적 0건 = 차단 | 아직 **아무도 안 쓴 것**이지 경로 부재가 아니다 |
| P8 자동 실행 배치 신설 | 결손 복구가 아니라 **승인 단계를 없애는 설계 변경 제안** |
| P9 주소 검증이 유일 방어선(P0급) | P8 자동화가 들어갈 때 P0. **현재는 사람이 승인 화면에서 주소를 본다** |

⚠️ **단, 대가가 있다.** B 를 열면 출금 건수 ↑ → 잔여 건수 ↑ → **콘솔 승인이 새 병목**이 된다.
피하려던 병목이 출금에서 잔여로 이동할 뿐이다. → **P8(자동 실행) + P9(주소 포맷 검증)는 B 오픈 직후 착수**한다.
이 지침서의 범위는 아니다(`P2P_REMAINDER_CONVERT_FINDINGS.md`).

### 0.3 이번 범위 밖 (건드리지 말 것)

| 항목 | 이유 |
|---|---|
| C(회원 정보) · G(사전 검증) · E(Axim Pay) | 2·3·4차 |
| `MaskingUtils` | C 전용 — 1차에 불필요 |
| F1(위젯 잔액 표시 축소) · C3(기존 응답 마스킹) | 프론트 동시 배포·파트너 공지 필요 → 별도 트랙 |
| 잔여 전환 P4·P8·P9·P10 | 별도 트랙 (B 오픈 직후) |
| `ErrorCodes` 자기충돌 601/602/701/702 | 백로그. **신규 코드를 그 근처에 두지 않기만 하면 된다** |
| `p2p_partner_locks` 잔존 3행 정리 DML | 운영 DML — 승인 필요, 별도 |

---

## 1. 공통 규약 (전 API 공통)

### 1.1 인증 · 세션

```
X-API-KEY:      {partner.api_key}
X-TIMESTAMP:    {epoch seconds}
X-ACCESS-TOKEN: Base64(HMAC-SHA256("{timestamp}.{apiKey}", partner.api_secret_hash))
```

- ☠️ 컨트롤러 세션 파라미터는 **반드시 `OpenApiSessionData`**. `WidgetSessionData` 를 쓰면 토큰 역직렬화 타입 불일치로 **401**.
- 모든 조회/쓰기는 `session.getPartnerId()` 로 스코프. **타 파트너 자원은 404**(403 아님 — 존재 누출 금지).

### 1.2 식별자 노출 정책

| 노출 O | 노출 X |
|--------|--------|
| `chainType`, `currencyType`, `partnerUserId`, `memberToken`, `orderCode` | `networkId`, `currencyId`, `p2p_members.id`, `partner_id`, `withdrawal_id` |
| `bankAccountId` ※ | |

※ `bankAccountId` 만 예외 — 파트너가 되돌려 보내는 용도. **B 는 반드시 소유권을 검증한다**(§4.4).

### 1.3 chainType 은 3개뿐 — `ETH` 는 없다

운영 `blockchain_networks` 실측: **`BSC`(56) · `POLYGON`(137) · `TRON`(728126428)** (+ `FIAT`(99)).
**예시·검증·테스트 어디에도 `ETH`/`ETHEREUM` 을 쓰지 않는다.**

`ChainCurrencyResolver` 방향별 실패 처리가 다르다:

| 메서드 | 실패 시 |
|---|---|
| `resolveNetworkId(chainType)` | `NotFoundException("600")` |
| `resolveCurrency(currencyType, chainType)` | `NotFoundException("860")` |
| `toChainType(networkId)` / `toCurrencyType(currencyId)` | **예외가 아니라 문자열 `"UNKNOWN"`** |

☠️ `"UNKNOWN"` 은 로그도 알림도 없이 **정상 200 응답에 실려 나가고 5분간 캐시**된다(`@Cacheable("chainTypeById")`).
→ **신규 API 는 `"UNKNOWN"` 을 응답에 싣지 않는다.** 해당 항목을 제외하거나 명시적으로 에러를 낸다.

### 1.4 응답 형태 — **개발 빌드 실측 반영 (2026-08-27)**

- 성공: DTO 를 **봉투 없이** 그대로 반환 (저장소에 `@RestControllerAdvice` 0건).
- 실패: 프레임워크가 `ApiError { code, message, description, data, stackTrace }` 로 변환.
- Jackson: `non_null` 제외.

#### ☠️ 직렬화는 전역 설정만 믿으면 안 된다 — 필드마다 지정해야 한다

아래 3건은 **단위 테스트가 잡지 못한다**(DTO 객체만 단언하므로). 실제 HTTP 응답에서만 드러난다.

| 항목 | 전역 설정만 있을 때 실제 출력 | 필요한 조치 |
|---|---|---|
| `LocalDateTime` | `"2026-08-27T17:28:47.939097"` (ISO+마이크로초) | **필드마다 `@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")`** |
| `BigDecimal` 0 (scale 18) | `"0E-18"` (지수표기) | `toPlainString()` 을 쓰는 직렬화기 |
| DB 관리 컬럼(`insert=false`) | INSERT 직후 **null → 응답에서 필드 통째 누락** | 생성 경로에서 재조회 후 응답 조립 |

- `spring.jackson.date-format` 은 `java.util.Date` 에만 적용되고 **JSR-310 `LocalDateTime` 에는 적용되지 않는다.**
  선례: `WithdrawalResponseV1:48-49` 가 `ToStringSerializer` 와 `@JsonFormat` 을 **함께** 쓴다.
- `write-bigdecimal-as-plain: true` 는 `ToStringSerializer` 를 거치면 **무시된다**. 그렇다고 `ToStringSerializer` 를
  빼면 BigDecimal 이 JSON **숫자**(따옴표 없음)가 되어 기존 계약(문자열)이 깨진다.

#### ✅ 선결 1 해소 — validation 실패는 **HTTP 422**, `code` 도 `"422"`

지침서 초판이 "Axim jar 내부라 알 수 없다"고 남겼던 항목이다. 개발 빌드 실측으로 확정했다.

```
HTTP 422
{"code":"422","message":...,
 "description":"Validation failed for argument [0] in public org.springframework.http.ResponseEntity<...>..."}
```

- **`@response 400` 이 아니라 `@response 422`** 를 쓴다. 기존 v1 엔드포인트도 동일하게 422 다.
- ⚠️ `description` 에 **Java 메서드 시그니처와 내부 DTO 클래스명이 그대로 노출**된다.

#### ⚠️ 에러 응답에 `stackTrace` 가 실려 나간다 (기존 동작, 별도 과제)

`axim.rest.debug: false`(`application.yml:49`) 인데도 409/422 응답에 **약 18KB 의 stackTrace** 가 포함된다.
**기존 v1 엔드포인트도 동일**하므로 이번 작업이 만든 문제가 아니다. 다만 파트너 노출 API 에서는 실제 위험이므로
백로그로 남긴다(§9).

#### ⚠️ `INSUFFICIENT_BALANCE` 만 `code` 가 숫자가 아니다

`ErrorCodes:206` 이 `new ErrorCode("INSUFFICIENT_BALANCE", ...)` 로 정의돼 있어, 다른 코드가 전부 숫자인데
이것만 문자열 상수명으로 나간다. 파트너 문서에 명시할 것.

---

## 2. 공용 기반

### 2.1 `ErrorCodes` — 1100번대 신규 3건

**대역 확인 완료**: 현재 파일은 `1031` 까지 사용하고 그다음 `4120` 으로 점프한다. **1041 이상 전부 미사용.**

`common/src/main/java/com/cryptoments/common/exception/ErrorCodes.java` 끝의 1031 블록 **아래**에 추가:

```java
/** 해당 사용자를 찾을 수 없습니다. (404) */
public static final ErrorCode PARTNER_USER_NOT_FOUND = new ErrorCode("1100", "해당 사용자를 찾을 수 없습니다.");

/** 이미 처리된 요청 식별자입니다. (409) */
public static final ErrorCode DUPLICATE_PARTNER_REFERENCE = new ErrorCode("1105", "이미 처리된 요청 식별자입니다.");

/** 해당 회원의 계좌가 아닙니다. (403) */
public static final ErrorCode BANK_ACCOUNT_NOT_OWNED = new ErrorCode("1107", "해당 회원의 계좌가 아닙니다.");
```

☠️ **`OpenApiException` 을 쓰지 않는다.** 저장소 전체에서 참조 0건인 죽은 코드이고, 1001~1040 이
`ErrorCodes` 와 **7건 중복**한다. 신규 API 가 이 클래스를 쓰는 순간 파트너 분기가 깨진다.

기존 재사용: `KRW_NOT_ENABLED`(1020), `P2P_WITHDRAW_NOT_ENABLED`(1023), `INSUFFICIENT_BALANCE`,
`BANK_ACCOUNT_NOT_FOUND`(996), `P2P_MEMBER_NOT_FOUND`(991).

### 2.2 `P2pMemberPageUrlBuilder` (신규, core)

A·B 응답이 모두 `memberPageUrl` 을 내려준다. 현재 **재사용 가능한 조립 헬퍼가 없다** —
`core/notification/P2pMemberNotifier` 의 private 필드뿐이다.

`core/src/main/java/com/cryptoments/core/p2p/P2pMemberPageUrlBuilder.java`

```java
@Component
public class P2pMemberPageUrlBuilder {

    private final String baseUrl;

    public P2pMemberPageUrlBuilder(
            @Value("${cryptoments.p2p.member-page-base-url:}") String baseUrl) {
        this.baseUrl = baseUrl;
    }

    /**
     * 회원 페이지 URL 을 조립한다.
     *
     * <p>라우트 {@code /m/{token}} 은 SPA 라우터·백엔드 알림·파트너 콘솔 3곳에서 일치 확인된 값이다.
     *
     * @param memberToken 회원 토큰
     * @return 회원 페이지 URL. baseUrl 미설정이거나 토큰이 비면 {@code null}
     */
    public String build(String memberToken) { ... }
}
```

- 라우트는 **`/m/{token}`** — 코드 3곳 확인됨(SPA `router/index.ts:30`, `P2pMemberNotifier:165,212,253`, `partner-ui p2p.service.ts:154`).
- `baseUrl` 미설정 시 **`null` 반환**(빈 문자열이나 `"null/m/..."` 을 만들지 말 것). 프로퍼티는 open-api 에 이미 선언돼 있다.
- 끝 슬래시 중복 제거.

---

## 3. A. P2P 회원 등록

### `POST /api/v1/p2p/members`

### 3.1 ☠️ 선행 — `getOrCreate` 동시성 수렴 (A1)

`core/p2p/P2pMemberService.java:59` 의 `getOrCreate` 는 **read-then-insert 이고 잠금도 `DuplicateKeyException` catch 도 없다.**
`p2p_members` 에 `UNIQUE(partner_id, partner_user_id)` 가 있으므로 **동시 호출 시 진 쪽은 1062 가 그대로 올라와 500** 이 난다.

B 가 회원을 자동 생성하므로 **B 도 이 결함을 그대로 탄다.** 반드시 먼저 고친다.

```java
public P2pMember getOrCreate(Long partnerId, String partnerUserId) {
    final String normalized = PartnerUserIds.normalize(partnerUserId);
    // 정상 경로는 일반 SELECT — 여기에 잠금을 걸지 않는다
    P2pMember existing = memberRepository.findByPartnerIdAndPartnerUserId(partnerId, normalized);
    if (existing != null) return existing;

    try {
        // ... 기존 build + save 그대로 ...
    } catch (DuplicateKeyException e) {
        // 경합에서 진 쪽 — UNIQUE(partner_id, partner_user_id) 가 최종 방어선이다.
        //   ☠️ 잠금 읽기여야 한다. 일반 SELECT 는 REPEATABLE READ 스냅샷 때문에 미스한다.
        P2pMember won = memberMapper.findByPartnerIdAndPartnerUserIdForUpdate(partnerId, normalized);
        if (won != null) return won;
        throw e;   // 다른 유니크 위반이면 삼키지 않는다
    }
}
```

### ☠️☠️ 재조회는 반드시 잠금 읽기(`FOR UPDATE`)여야 한다 — 이 수정의 핵심

MySQL 기본 격리수준은 **REPEATABLE READ** 이고 이 저장소 어디에도 오버라이드가 없다.
일반 SELECT 로 재조회하면 **트랜잭션 스냅샷**(메서드 상단 첫 조회 시점에 확정)을 읽으므로,
경합에서 이긴 상대가 **그 이후에** 커밋한 행이 보이지 않는다 → 재조회 미스 → 재전파 →
**없애려던 500 이 그대로 재현된다.** 유니크 인덱스 검사는 최신 데이터를 보지만 일반 SELECT 는
그렇지 않다는 비대칭이 원인이다.

기존 `WithdrawalService.resolveOrderKeyConflict` 가 **바로 이 함정을 javadoc `:443-445` 에 적어두고**
`WithdrawalMapper.findByPartnerIdAndIdemKeyForUpdate`(`:75-77`, `FOR UPDATE`)를 쓴다.
**형태만 베끼고 잠금 읽기를 빠뜨리면 아무것도 고쳐지지 않는다.**

`P2pMemberRepository` 에는 ForUpdate 변형이 없으므로 매퍼를 신설한다 —
`common/src/main/java/com/cryptoments/common/mapper/P2pMemberMapper.java`

```java
@Mapper
public interface P2pMemberMapper {
    /** {@code <script>} 아님 — 등호만 사용하므로 부등호 이스케이프 이슈 없음. */
    @Select("SELECT * FROM p2p_members "
            + "WHERE partner_id = #{partnerId} AND partner_user_id = #{partnerUserId} "
            + "LIMIT 1 FOR UPDATE")
    P2pMember findByPartnerIdAndPartnerUserIdForUpdate(@Param("partnerId") Long partnerId,
                                                       @Param("partnerUserId") String partnerUserId);
}
```

- ☠️ **재조회로 못 찾으면 원래 예외를 재전파한다.** 무차별로 삼키면 다른 유니크 위반이 조용히 묻힌다.
- **정상 경로(상단 첫 조회)는 일반 SELECT 를 유지한다** — 잠금 읽기는 catch 안에서만.
- 기존 `PartnerUserIds.normalize` 호출과 나머지 빌더 로직은 **변경하지 않는다**.
- `@MapperScan(value = {"one.axim.framework.mybatis.mapper", "com.cryptoments"})` 이라 `common.mapper` 는 자동 스캔된다.

> **✅ 이 항목은 구현·리뷰 완료(2026-08-27).** 초판 지침서가 일반 SELECT 스케치를 실어 구현이 그대로
> 따라갔고, 리뷰에서 REPEATABLE READ 미수렴이 드러나 잠금 읽기로 교체했다.

#### ⚠️ B 파사드(§4.4)에는 이 규칙을 그대로 옮기지 말 것 — 조건이 다르다

| | `getOrCreate` (여기) | B 파사드 (§4.4) |
|---|---|---|
| 트랜잭션 | **`@Transactional` 안** | **트랜잭션 밖** (§4.4 규칙 1이 금지) |
| 스냅샷 | 첫 조회에서 확정 → 재조회가 **같은 스냅샷** | 조회마다 새 트랜잭션 → **매번 최신 커밋본** |
| 재조회 방식 | **`FOR UPDATE` 필요** | **일반 SELECT 로 충분** |

**파사드에 `FOR UPDATE` 를 쓰지 마라.** 트랜잭션 밖의 잠금 읽기는 즉시 해제돼 의미가 없고,
잠금을 유지하려고 `@Transactional` 을 붙이면 §4.4 규칙 1(rollback-only 상태에서 재조회 불가)을 정면으로 위반한다.
파사드가 트랜잭션 밖에 있다는 설계가 **바로 그 평범한 재조회를 성립시키는 근거**다.
(그럼에도 상대가 아직 미커밋이면 미스할 수 있고, 그 경우는 §4.4 규칙 3대로 **409** 로 응답한다.)

### 3.2 Request

| 필드 | 타입 | 필수 | 설명 |
|------|------|------|------|
| `partnerUserId` | String(100) | ✅ `@NotBlank` | 파트너 측 사용자 식별자 |
| `alias` | String(100) | | 회원 별칭 — **신규 생성 시에만 반영** |
| `memo` | String | | 관리 노트 — **신규 생성 시에만 반영** |

### 3.3 Response `201`

```json
{
  "partnerUserId": "user-001",
  "memberToken": "mbr_a1b2c3d4e5f6g7h8i9j0",
  "memberPageUrl": "https://p2p.cryptoments.cc/m/mbr_a1b2c3d4e5f6g7h8i9j0",
  "status": "ACTIVE",
  "tradingPaused": false,
  "pinSet": false,
  "depositMethod": "MANUAL",
  "bankAccountCount": 0,
  "alias": "홍길동",
  "createdAt": "2026-08-21 04:12:33"
}
```

### 3.4 지켜야 할 것

| # | 규칙 |
|---|---|
| **A2** | 응답에 **`created` 플래그를 두지 않는다.** `getOrCreate` 반환은 `P2pMember` 하나뿐이라 신규/기존 구분이 불가능하다. **항상 201**, "이미 존재하면 기존 회원을 반환한다"를 문서에 명시 |
| **A3** ☠️ | **`updateProfile` 을 호출하지 않는다.** `update()`(not `modify()`)를 써서 `alias` 만 보내면 **기존 `memo` 가 null 로 삭제**되고, read~write 사이 커밋된 PIN·텔레그램·`trading_paused` 까지 되돌린다. 기존 회원이면 `alias`/`memo` 는 **무시**한다. 수정은 기존 `PUT /members/{token}/profile` 로 |
| **A4** | 게이트는 **`partner.isKrwActive()` 만** (없으면 409 `1020`). 현행 `createMember` 에는 게이트가 아예 없으므로 이는 "추가"다 |
| A5 | `memberToken` = `"mbr_" + UUID hex 20자` = 24자, 컬럼 VARCHAR(50). 충돌 처리는 두지 않는다(80비트) |

**Partner 플래그 (실재하는 메서드만 사용)**: `isKrwActive()`, `isP2pWithdrawAllowed()`(= `krw_enabled && p2p_withdraw_enabled`).
`p2pEnabled` 는 **@deprecated** — 쓰지 않는다.

**에러**: 400 validation, 409 `1020`.

---

## 4. B. P2P 출금 — 원장 충전 (핵심)

### `POST /api/v1/p2p/withdrawals`

한 번의 호출로 아래가 전부 일어난다. 내부 진입점은 `WithdrawalService.createAndConvertToP2p`
— 파트너 콘솔 `POST /api/partner/withdrawals/p2p` 와 **동일 코드 경로**다.
**Open API 는 얇은 어댑터일 뿐 새 자금 로직을 만들지 않는다.**

```
① withdrawals 행 생성
② p2p_withdraw_orders 생성 (status=PENDING)
③ p2p_withdraw_entries CHARGE (+전액)
④ 원금 DEBIT (요청 전액)      ← 순서 고정
⑤ 수수료 확정 DEBIT (환급 없음)
⑥ withdrawals.status = COMPLETED
⑦ 파트너 웹훅 WITHDRAWAL_COMPLETED
⑧ (커밋 후) 회원 텔레그램 CHARGED
```

### 4.0 ★ 파트너 회계 원칙 — B 설계 전체의 기준선

> **P2P 출금은 T0 에 파트너 원장에서 나간다. 거기서 끝이다.**
> 이후 그 자금이 KRW 로 팔리든 USDT 로 회원에게 나가든 **파트너 회계와 무관하다.**

| 항목 | Open API 노출 |
|---|---|
| 출금 접수 성공/실패, 차감된 원금·수수료, 회원 안내 링크 | ✅ |
| **매칭 진행률 · 잔여액 · USDT 전환 상태** | ❌ **노출하지 않는다** |

`withdrawalStatus: COMPLETED` 는 **설계 의도다 — 이름을 바꾸지 않는다.** 파트너 문서에
"**온체인 전송 완료가 아니라 출금 접수 완료**" 한 줄만 덧붙인다.

### 4.1 ☠️ 선행 — `createAndConvertToP2p` 오버로드 (B8)

`core/withdrawal/WithdrawalService.java:1556` 의 현행 시그니처에는 **`partnerReference` 파라미터가 없고**,
`Withdrawal` 을 내부에서 직접 빌드하며 `requestSource` 를 **`CONSOLE` 로 하드코딩**한다(`:1576`).

```java
// 현행 (:1556)
public Withdrawal createAndConvertToP2p(Long partnerId, String partnerUserId,
                                        Long currencyId, Long networkId,
                                        P2pRequestCurrency requestCurrency, BigDecimal amount,
                                        Long bankAccountId, String requestedBy)
```

→ **오버로드를 추가한다.** 기존 시그니처는 그대로 두고 새 인자 2개를 받는 쪽으로 위임한다.

```java
/** 기존 호출부 호환 — CONSOLE / 멱등키 없음 */
public Withdrawal createAndConvertToP2p(... 기존 8개 ...) {
    return createAndConvertToP2p(..., WithdrawalRequestSource.CONSOLE, null);
}

/**
 * @param requestSource    요청 출처 — Open API 경로는 {@code API}
 * @param partnerReference 파트너 멱등키. {@code withdrawals.partner_reference} 에 저장되어
 *                         GENERATED 컬럼 {@code idem_key} + {@code UNIQUE(partner_id, idem_key)} 로 중복을 막는다
 */
public Withdrawal createAndConvertToP2p(... 기존 8개 ..., 
                                        WithdrawalRequestSource requestSource,
                                        String partnerReference) { ... }
```

- `Withdrawal.builder()` 에 `.requestSource(requestSource)` 와 `.partnerReference(partnerReference)` 를 추가한다.
  (`Withdrawal` 엔티티에 `partnerReference` 필드는 이미 존재 — `Withdrawal.java:68`)
- `WithdrawalRequestSource` 에 **`API` 값이 이미 있다**(`CONSOLE, API, PARTNER_API, ADMIN_CONSOLE, SYSTEM`). Open API 는 **`API`** 를 쓴다.
  → 콘솔 경로와 API 경로가 사후 추적에서 구분된다.
- **KRW 환산 로직(`krwOverride`)·`requestWithdrawal`·`approveAsP2p` 호출은 한 줄도 바꾸지 않는다.**
- ☠️ **이것이 core 의 유일한 변경이다.** 다른 core 파일을 수정하지 말 것.

### 4.2 Request

| 필드 | 타입 | 필수 | 설명 |
|------|------|------|------|
| `partnerUserId` | String | ✅ `@NotBlank` | P2P 회원이 없으면 자동 생성 |
| `chainType` | String | ✅ `@NotBlank` | `BSC`/`POLYGON`/`TRON`. 정산 체인 |
| `currencyType` | String | | 기본 `USDT` |
| `requestCurrency` | Enum | | `USDT`(기본) \| `KRW` |
| `amount` | BigDecimal | ✅ `@NotNull @DecimalMin("0.000001")` | `requestCurrency` 단위 |
| `bankAccountId` | Long | | 미지정 시 회원의 첫 계좌 자동 선택. **지정 시 소유권 검증**(§4.4) |
| `partnerReference` | String(100) | ✅ **`@NotBlank`** | 멱등키 |

### 4.3 Response `201` (신규) / `200` (멱등 히트)

```json
{
  "orderCode": "pwo_x1y2z3",
  "partnerUserId": "user-001",
  "memberToken": "mbr_a1b2",
  "memberPageUrl": "https://p2p.cryptoments.cc/m/mbr_a1b2",
  "chainType": "TRON",
  "currencyType": "USDT",
  "requestCurrency": "USDT",
  "usdtAmount": "1000.000000000000000000",
  "krwAmount": 1385000,
  "exchangeRate": "1385.0000",
  "feeAmount": "5.000000000000000000",
  "withdrawalStatus": "COMPLETED",
  "bankAccountRegistered": true,
  "partnerReference": "wd-20260821-0001",
  "createdAt": "2026-08-21 04:20:11"
}
```

| # | 규칙 |
|---|---|
| **B5** | **`principalDebited` 필드를 두지 않는다.** 판정식이 이 경로에서 항상 true 이고 실패하면 트랜잭션이 통째로 롤백돼 false 가 나올 수 없다 — 정보량 0 |
| **B1** | **`duplicated` 필드를 두지 않는다.** HTTP **201/200** 으로 구분한다 |
| **B6** | `orderCode`/`krwAmount`/`exchangeRate` 는 `findByWithdrawalId` **추가 조회**, `memberToken` 은 **회원 추가 조회**로 얻는다. `createAndConvertToP2p` 반환은 `Withdrawal` 하나뿐이다 (`PartnerWithdrawalService:317-330` 이 같은 방식) |
| **B7** | `feeAmount` 는 `approveAsP2p` 가 `upfrontFee > 0` 일 때만 세팅하므로 **요율 0이면 null** → 응답에서 **`0` 으로 coalesce** |
| — | `orderStatus`(`PENDING`/`PARTIALLY_MATCHED`/…) 를 **노출하지 않는다** (§4.0). 파트너가 볼 상태는 `withdrawalStatus` 뿐 |

### 4.4 ☠️ `OpenApiP2pFacade` — 멱등과 검증의 전부

`open-api/src/main/java/com/cryptoments/openapi/service/OpenApiP2pFacade.java` (신규)

```
createP2pWithdrawal():
  1. 게이트 선검사 — isKrwActive() → 1020,  isP2pWithdrawAllowed() → 1023      [B9]
  2. chainType/currencyType 해석 (ChainCurrencyResolver)
  3. 회원 확보 — getOrCreate (A1 수렴 적용됨)
  4. bankAccountId 소유권 검증                                                   [B4]
  5. findByPartnerIdAndIdemKey(partnerId, partnerReference)
     ├─ 히트 → findByWithdrawalId → 내용 비교 → 200 or 409 1105
     └─ 미스 ↓
  6. createAndConvertToP2p(..., WithdrawalRequestSource.API, partnerReference)
  7. catch (ConflictException | DuplicateKeyException)
     → idemKey 재조회 → 주문이 실제로 있으면 200 수렴, 없으면 원래 예외 재전파
```

복구 체인은 **이미 리포지토리에 있다 — 신규 쿼리를 만들지 않는다.**

```
WithdrawalRepository.findByPartnerIdAndIdemKey(partnerId, partnerReference)   ← :22
  → P2pWithdrawOrderRepository.findByWithdrawalId(withdrawal.getId())          ← :17
```

`idem_key` 는 GENERATED STORED 컬럼으로 종결실패(`FAILED/CANCELLED/REJECTED/EXHAUSTED`)면 NULL 이라
이 조회는 **"진행/성공 중인 건만 잡힌다"** 는 의미까지 정확히 맞다.
⚠️ **종결실패 상태 목록을 애플리케이션에서 다시 열거하지 말 것** — DDL 의 `CASE` 가 유일한 정의다.

#### 반드시 지킬 것 4가지

**1. ☠️ 파사드에 `@Transactional` 을 걸지 않는다.**
core 트랜잭션이 롤백된 뒤 예외를 잡아 **재조회**해야 하는데, 파사드가 같은 트랜잭션 안에 있으면
rollback-only 상태라 조회 자체가 실패한다. 파사드는 트랜잭션 **밖**에서 (a) 선조회 (b) core 호출 (c) 예외 시 재조회 순으로 동작한다.

**2. ☠️ 예외를 무차별로 삼키지 않는다.**
`ConflictException` 을 통째로 잡아 200 으로 바꾸면 `INSUFFICIENT_BALANCE` 같은 **진짜 실패까지 성공으로 둔갑**한다.
반드시 **"재조회해서 주문이 실제로 존재할 때만"** 200 으로 수렴하고, 없으면 원래 예외를 그대로 재전파한다.

**3. 잔여 경합 창은 완전히 막을 수 없다 — 409 로 응답한다.**

| 시점 | T1 | T2 |
|---|---|---|
| 1 | 선조회 미스 | 선조회 미스 |
| 2 | core 실행 중 (**미커밋**) | — |
| 3 | — | core 진입 → INSERT → UNIQUE 위반 |
| 4 | — | 재조회 — T1 미커밋이면 **미스** |

T2 가 이 창에 걸리면 **500 이 아니라 409 `1105`**("동일 참조의 요청이 처리 중")로 응답하고,
파트너는 잠시 후 `GET ?partnerReference=` 로 확인하도록 문서화한다.
**어떤 경우에도 이 경로가 새 주문을 만들지 않는다는 점이 핵심이다** — DB 유니크 제약이 최종 방어선.

**4. B4 소유권 검증은 멱등 조회(5단계) *이전*에 한다.**
멱등 히트로 200 을 반환하는 경로에서도 검증을 건너뛰면, 잘못된 `bankAccountId` 를 보낸 재요청이 검증 없이 통과한다.

#### B4 — `bankAccountId` 소유권 검증 (보안, 신규)

☠️ **`approveAsP2p` 에는 계좌 소유권 검증이 전혀 없다.** 미지정 시 `accounts.get(0)` 자동 선택뿐이라,
파트너가 임의의 `bankAccountId` 를 넣으면 **남의 계좌가 수취 계좌로 박히고 거래 확인증에 노출**된다.

```java
if (request.getBankAccountId() != null) {
    BankAccount acc = bankAccountRepository.findById(request.getBankAccountId());
    if (acc == null) throw new NotFoundException(ErrorCodes.BANK_ACCOUNT_NOT_FOUND);
    if (acc.getOwnerType() != BankAccountOwnerType.MEMBER
            || !member.getId().equals(acc.getOwnerId())) {
        throw new ForbiddenException(ErrorCodes.BANK_ACCOUNT_NOT_OWNED);   // 403 1107
    }
}
```

> 근본 수정은 `approveAsP2p` 내부에 넣는 것이 맞으나 **이번 범위에서는 파사드에서만** 한다
> (콘솔 경로 회귀 위험 회피). 별도 과제로 기록.

#### 멱등 비교 기준 — `withdrawals` 가 아니라 `p2p_withdraw_orders`

기존 `isSameWithdrawalRequest` 가 KRW 에서 깨지는 이유는 **`withdrawals.amount`(USDT)** 를 비교하는데
KRW 요청의 USDT 금액은 `amount / rate` 라 **환율에 따라 흔들리기** 때문이다.

`p2p_withdraw_orders` 에는 `request_currency` · `krw_amount` · `usdt_amount` 가 **모두 저장돼 있다.**

| 요청 | 비교 대상 | 안정성 |
|---|---|---|
| `requestCurrency=USDT` | `order.usdt_amount` | 입력값 그대로 — 안정 |
| `requestCurrency=KRW` | **`order.krw_amount`** | `krwOverride` 로 고정 저장 — **환율 무관, 안정** |

추가 비교 축: `partner_user_id`, `network_id`, `request_currency`.
→ **KRW 요청도 멱등이 성립한다.**

#### 응답 규칙

| 상황 | 응답 |
|---|---|
| 신규 생성 | **201** |
| 동일 `partnerReference` + 동일 내용 | **200** — 기존 주문 그대로 |
| 동일 `partnerReference` + **다른 내용** | **409 `1105`**, `description` 에 어느 필드가 다른지 |
| 종결실패 건의 `partnerReference` 재사용 | **201** — `idem_key` 가 NULL 이라 자연히 신규 |

⚠️ **성공한 P2P 출금의 `partnerReference` 는 영구 소진된다.** P2P 출금은 접수 즉시 `COMPLETED` 이고
`COMPLETED` 에서는 `idem_key` 가 NULL 이 되지 않는다. 주문을 취소해도 그 참조로 새 출금을 만들 수 없다.
→ 파트너 문서에 **"요청마다 새 `partnerReference` 를 쓸 것"** 명시.

### 4.5 조회 엔드포인트 2개

#### `GET /api/v1/p2p/withdrawals/{orderCode}`
접수 사실 재확인용. 응답은 §4.3 과 동일 형태. 타 파트너 주문은 **404**.

#### `GET /api/v1/p2p/withdrawals?partnerReference={ref}` — **응답 유실 복구**

이게 없으면 파트너 원장이 영구히 어긋난다:

```
파트너 → POST  →  서버 성공(원금 차감 완료)  →  응답 네트워크 타임아웃 유실
파트너 → orderCode 를 모르니 조회도 못 하고, 재시도하면 409
```

- 같은 조회 체인(`findByPartnerIdAndIdemKey` → `findByWithdrawalId`)을 쓰므로 구현 비용이 거의 없다.
- 없으면 **404** (실패했거나 애초에 접수되지 않은 것 — 파트너는 안전하게 재시도 가능).

### 4.6 만들지 않는 것

| # | 규칙 |
|---|---|
| 1 | Open API 에 **`/cancel` 엔드포인트를 만들지 않는다** — P2P 출금은 취소되지 않는다 |
| 2 | Open API 에 **전환 신청 엔드포인트를 만들지 않는다** — 회원 페이지·콘솔 surface |
| 3 | 응답에 **매칭 진행률·잔여액·`usdtConvert` 를 노출하지 않는다** |
| 4 | ⚠️ 응답 조립 시 **`p2p_withdraw_entries` 를 조회하지 않는다** — 원장 금액 3종(`P2pPageAmounts`)은 회원 페이지·콘솔 전용. 끌어오면 §4.0 원칙이 무너지고 N+1 이 따라온다 |

**파트너 문서에 반드시 명시**: "P2P 출금은 취소되지 않으며, 잔여는 회원이 USDT 로 전환해 수령한다" /
"수수료는 매칭 성사 여부와 무관하게 요청 시점에 확정되며 환급되지 않는다".

---

## 5. F. 가용 잔액 조회

### 5.1 ☠️ 선행 — F5: 죽은 `p2p_partner_locks` 차감 제거

`core/settlement/SettlementService.java:367` `getAvailableForWithdrawal` 은 세 번째 항을 아직 뺀다.

```java
available = ledgerMapper.computeBalance(...)                 // 원장 실잔액 (fee-net)
          − withdrawalMapper.sumPendingGeneralWithdrawals()  // 진행 중 일반 출금 hold
          − p2p_partner_locks.locked_balance                 // ☠️ 죽은 항 (:374-381)
```

| 확인 | 실측 |
|---|---|
| `lockForMatch`/`unlockForMatch` 호출부 | **0건** — "잠금 게이트 제거 (2026-08-21)" 주석만 남음 |
| 운영 DB | 5행 중 3행 >0, **합계 `0.000000000000000005`** (1e-18 먼지) |

P2P 출금 원금은 **요청 시점에 원장에서 전액 DEBIT** 되므로 이미 `ledgerBalance` 에서 빠져 있다.
lock 으로 또 빼면 **이중 차감**이고, F 응답의 항등식이 깨진다.

**조치**: `:374-381` 의 `locked` 계산 블록과 `p2pPartnerLockRepository` 의존을 제거하고
`return ledger.subtract(pending);` 으로 만든다.

| 지켜야 할 것 |
|---|
| ☠️ **`P2pLockService` 는 남긴다** — `getAvailableForP2p`(읽기 전용, 파트너·어드민 풀 현황)가 쓰고 있다 |
| ☠️ **잠금을 되살리는 방향의 수정은 금지** — `p2p_partner_locks` 쓰기를 부활시키면 매칭이 조용히 멈춘다. 제거만 허용 |
| 영향 방향은 **가용액이 1e-18 늘어나는 것**이라 자금 위험 없음. 잔존 3행 DML 정리는 이번 범위 밖 |
| `freeze`(`:396`)가 같은 메서드를 쓰므로 **출금 접수 경로 회귀 테스트 필요** |

### 5.2 `GET /api/v1/partner/available-balance`

**Query**: `chainType`(선택), `currencyType`(선택). 미지정 시 활성 전체.

**Response** `200` — `List<AvailableBalanceResponse>`

```json
[{
  "chainType": "TRON", "currencyType": "USDT",
  "ledgerBalance": "1000.000000",
  "pendingWithdrawalHold": "150.000000",
  "availableBalance": "850.000000",
  "onchainSnapshot": "1002.310000",
  "onchainSnapshotAt": "2026-08-21 00:12:00"
}]
```

| # | 규칙 |
|---|---|
| 1 | `availableBalance` = `getAvailableForWithdrawal(...)` **그대로**. 음수는 0 클램프 |
| 2 | **항등식 `ledgerBalance − pendingWithdrawalHold = availableBalance` 가 정확히 성립해야 한다** (F5 선행이 조건) |
| 3 | **`p2pLockedBalance` 필드를 두지 않는다** — 값이 항상 0/먼지라 구분되지 않고 이름이 실제 P2P 재고와 무관한 **거짓말하는 필드**다 |
| 4 | **`p2pInventory` 도 이번 범위에서 넣지 않는다** — 뺄셈 항으로 오해될 위험 |
| 5 | `onchainSnapshot` 은 **참고용**. `onchainSnapshotAt` 과 함께 내려 스냅샷임을 명시한다. `onchainSnapshot ≠ ledgerBalance` 는 **정상**이다 |
| 6 | `toChainType` 이 `"UNKNOWN"` 을 반환한 네트워크는 **응답에서 제외**한다(§1.3) |

> 기존 `GET /api/v1/partner/balances`(온체인 스냅샷)는 **경로를 유지한다** — 기존 연동을 깨지 않는다.

---

## 6. 구현 배치

```
open-api/src/main/java/com/cryptoments/openapi/
├── controller/v1/
│   ├── P2pMemberV1Controller.java        A
│   ├── P2pWithdrawalV1Controller.java    B (POST + GET/{orderCode} + GET?partnerReference=)
│   └── BalanceV1Controller.java          F
├── service/
│   └── OpenApiP2pFacade.java             A·B — 게이트 선검사 + 소유권 검증 + 멱등
└── dto/ request/ · response/

core/src/main/java/com/cryptoments/core/
├── p2p/P2pMemberPageUrlBuilder.java      신규
├── p2p/P2pMemberService.java             A1 수렴 (수정)
├── withdrawal/WithdrawalService.java     B8 오버로드 (수정 — core 유일 변경 2건 중 1)
└── settlement/SettlementService.java     F5 죽은 항 제거 (수정 — 2)

common/src/main/java/com/cryptoments/common/exception/ErrorCodes.java   1100·1105·1107 추가
```

**코딩 규칙 (CLAUDE.md 준수)**

- 세션 파라미터는 전부 **`OpenApiSessionData`**
- DTO 모든 필드에 **JavaDoc 필수**. `@Data` 금지. 요청 `@Getter @Setter`, 응답 `@Getter @Builder`
- Entity 는 `@Getter @Setter @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor`
- MyBatis 는 `@XRepository` interface 메서드. **XML 매퍼 금지**
- ⚠️ `<script>` 내부에서 `<`, `<=`, `<>` 직접 사용 금지 → `&lt;`, `&lt;=`, `!=` 또는 CDATA
  (컴파일은 통과하고 **기동 시점 SAXParseException 으로 전 서비스 다운** — 2026-06-11 운영 장애)
- `@Valid @RequestBody` 필수
- **core 에 새 자금 로직을 만들지 않는다.** 위에 명시한 core 수정 3건 외에 core 를 건드리지 말 것

### restdoc generator 규칙 (필수)

파트너 문서는 `gradle-restdoc-generator 2.1.7` 이 **소스 주석에서 자동 생성**한다.
규칙을 어기면 문서가 **조용히 비어 나가고 컴파일은 통과**한다.

1. 매핑에 **`name` 필수**: `@PostMapping(value = "/p2p/withdrawals", name = "P2P 출금 요청")`
2. ☠️ **`@header` 는 메서드 javadoc 에 쓴다 — 클래스 레벨은 무시된다**(실측 확인). 현재 세션 필요 API 44개가 `headers: []` 다
3. `@auth` 값을 **실제 세션 파라미터 유무와 일치**시킨다
4. **`@response 409` 를 빠뜨리지 말 것** — 잔액 부족·참조 중복·기능 비활성이 전부 409
5. DTO 필드마다 JavaDoc + **`@XSample`**(`one.axim.gradle.annotation.XSample`)

```java
/**
 * P2P 출금을 요청한다. 파트너 잔액을 차감해 P2P 판매 재고(KRW 원장)로 충전한다.
 *
 * @param request P2P 출금 요청 (사용자 ID, 체인, 금액, 멱등키)
 * @param session 인증된 파트너 세션
 * @return 생성된 P2P 출금 주문
 * @response 201 생성 성공
 * @response 200 동일 partnerReference 재요청 — 기존 주문 반환
 * @response 422 요청 파라미터 검증 실패
 * @response 403 해당 회원의 계좌가 아님
 * @response 404 체인/통화를 찾을 수 없음
 * @response 409 잔액 부족 / 기능 비활성 / 참조 중복
 * @group P2P
 * @auth true
 * @header X-API-KEY 파트너 API 키
 * @header X-TIMESTAMP 요청 타임스탬프 (epoch seconds)
 * @header X-ACCESS-TOKEN HMAC-SHA256 서명 (Base64)
 */
```

**생성·커밋**
```bash
./gradlew :open-api:generateSpecBundle
# build/docs/spec-bundle.json → open-api/src/main/resources/static/docs/ 로 복사 후 커밋
```
☠️ **결과물을 반드시 커밋한다.** `processResources` 는 `build/docs/spec-bundle.json` 이 없으면 **조용히 스킵**하고
이미 커밋된 옛 파일을 배포한다. 빌드 디렉터리는 CI 에서 청소된다.
현재 서빙본은 **2026-04-29 커밋으로 4개월 stale** 이다.

---

## 7. 완료 기준

### 공통
- [ ] `./gradlew :common:compileJava :core:compileJava :open-api:compileJava` 통과
- [ ] 신규 컨트롤러가 전부 `OpenApiSessionData` 를 쓴다 (`WidgetSessionData` 0건)
- [ ] 응답 어디에도 `currencyId`/`networkId`/`partnerId`/`p2p_members.id`/`withdrawal_id` 가 없다
- [ ] `chainType` 예시·검증·테스트에 `ETH` 가 없다
- [ ] `toChainType` 이 `"UNKNOWN"` 을 반환한 항목이 응답에 실리지 않는다
- [ ] 신규 에러코드가 1100번대이고 **`OpenApiException` 을 쓰지 않는다**
- [ ] 타 파트너 자원 조회 시 **404** (403 아님)

### A
- [ ] `getOrCreate` 에 `DuplicateKeyException` 수렴이 들어갔고, 재조회 실패 시 **원래 예외를 재전파**한다
- [ ] **동시 요청 2건에서 500 이 0건**이다 (테스트로 증명)
- [ ] 응답에 `created` 필드가 없고 **항상 201** 이다
- [ ] **A 가 `updateProfile` 을 호출하지 않는다** — 재요청이 기존 `memo` 를 지우지 않는지 테스트

### B — 자금 이동. 멱등 항목은 **전부 테스트로 증명**한다
- [ ] `createAndConvertToP2p` 를 호출하고 **자체 원금 DEBIT·수수료 로직을 새로 짜지 않았다**
- [ ] core 변경이 **B8 오버로드 + A1 + F5 3건뿐**이다
- [ ] 실행 순서가 **원금 DEBIT → 수수료 FEE** 로 유지된다
- [ ] `requestSource` 가 **`API`** 로 기록된다 (콘솔 건과 구분 가능)
- [ ] `partnerReference` 가 `@NotBlank` 다
- [ ] **동일 `partnerReference` + 동일 내용 재요청 → 200**, 원금이 **한 번만** 차감된다 (**원장 실측**)
- [ ] **`requestCurrency=KRW` 재요청이 환율 변동 후에도 200** 이다 (시세 조작 후 테스트)
- [ ] **동일 `partnerReference` + 다른 금액 → 409 `1105`**, 새 주문이 생기지 않는다
- [ ] 종결실패 건의 `partnerReference` 재사용 → **201**
- [ ] **동시 요청 2건이 원금을 두 번 차감하지 않는다** (하나는 200 수렴 또는 409)
- [ ] ☠️ **파사드에 `@Transactional` 이 없다**
- [ ] ☠️ `INSUFFICIENT_BALANCE` 같은 진짜 실패가 200 으로 둔갑하지 않는다 (재조회 성공 시에만 수렴)
- [ ] `GET ?partnerReference=` 가 존재하고 미접수 건에 **404** 를 반환한다
- [ ] **`bankAccountId` 소유권을 검증한다** — 남의 계좌 지정 시 **403 `1107`**, 검증이 멱등 조회보다 **앞**에 있다
- [ ] 응답에 `duplicated`/`principalDebited`/`orderStatus` 필드가 **없다**
- [ ] 응답에 **매칭 진행률·잔여액·`usdtConvert` 가 없다**
- [ ] `feeAmount` 가 null 이면 **0** 으로 나간다
- [ ] 게이트를 **파사드 진입부에서 선검사**한다 (`1020` → `1023` 순서)
- [ ] 응답 조립이 **`p2p_withdraw_entries` 를 조회하지 않는다**
- [ ] Open API 에 `/cancel` 과 전환 신청 엔드포인트가 **없다**

### F
- [ ] `availableBalance` 가 `getAvailableForWithdrawal` 반환값 **그대로**다
- [ ] **`ledgerBalance − pendingWithdrawalHold = availableBalance`** 가 정확히 성립한다
- [ ] `getAvailableForWithdrawal` 에서 `p2p_partner_locks` 차감이 제거됐고 **쓰기를 되살린 코드가 0건**이다
- [ ] **`P2pLockService.getAvailableForP2p` 는 살아 있다**
- [ ] 응답에 `p2pLockedBalance`/`p2pInventory` 필드가 **없다**
- [ ] **출금 접수 경로(`freeze`) 회귀 테스트 통과** — F5 가 같은 메서드를 건드렸다
- [ ] 가용액을 저장하는 새 컬럼을 만들지 않았다

### restdoc
- [ ] 모든 신규 매핑에 **`name = "한글 API 명"`** 이 있다
- [ ] ☠️ HMAC 3헤더가 **메서드 javadoc** 에 있다
- [ ] `@auth` 값이 실제 세션 파라미터 유무와 일치한다
- [ ] `@response 409` 가 빠지지 않았다
- [ ] DTO 필드마다 JavaDoc + `@XSample` 이 있다
- [ ] `generateSpecBundle` 후 **`static/docs/spec-bundle.json` 을 커밋**했다
- [ ] 생성된 spec-bundle 에서 신규 API 의 `headers` 가 **`[]` 가 아니다**

---

## 8. DDL 변경

**없다.** B 의 멱등은 `withdrawals.idem_key` + `UNIQUE(partner_id, idem_key)` 가 **이미 존재**한다.
(E 의 `axim_payments` UNIQUE 추가는 4차 범위)

## 8.5 ☠️ 동시성 — 개발 빌드 실측으로만 드러난 것들 (2026-08-27)

**단위 테스트·정적 리뷰가 셋 다 놓쳤다.** 목킹이 프레임워크의 실제 동작과 달랐고, 리뷰는 코드의
*형태*만 확인했다. 5중 동시 요청을 실제로 던져서야 층층이 드러났다. **C·G·E 에서도 같은 방식으로 검증할 것.**

### (1) Axim `IXRepository.save()` 는 유니크 위반을 감싼다 — `catch (DuplicateKeyException)` 은 죽는다

```
java.lang.RuntimeException: Could not determine save action for `withdrawals`: Duplicate entry ...
  Caused by: org.springframework.dao.DuplicateKeyException
    Caused by: java.sql.SQLIntegrityConstraintViolationException
```

→ **예외 타입이 아니라 원인 체인으로 판정**한다. `common/util/DataAccessExceptions.isDuplicateKey(t)`.
목킹도 **감싼 형태**로 해야 한다 — 감싸지 않은 목은 옛 코드에서도 통과해서 결함을 숨긴다.

⚠️ `WithdrawalService:340`·`:1031` 의 `resolveOrderKeyConflict` 도 같은 이유로 **죽어 있을 가능성이 높다**
(콘솔·어드민 경로 영향). 별도 과제 → §9.

### (2) 잠금 읽기를 넣으면 그 다음 층은 데드락이다

`FOR UPDATE` 재조회(§3.1)는 REPEATABLE READ 때문에 필요하지만, 경합자가 여럿이면 서로 물려
`DeadlockLoserDataAccessException` 이 난다. 유니크 위반만 처리하면 **데드락이 그대로 500 으로 나간다.**

☠️ **MySQL 은 데드락 시 트랜잭션 전체를 롤백한다.** 따라서 `@Transactional` 메서드(`getOrCreate`) 안에서는
복구가 불가능하다 — **트랜잭션 밖(파사드)에서 잡아야** 새 트랜잭션으로 최신 커밋본을 읽을 수 있다.
B 파사드가 트랜잭션 밖이어야 하는 이유(§4.4 규칙 1)와 정확히 같은 근거다.

### (3) "신규 생성인가"를 `before == null` 로 판정하면 경합에서 틀린다

`getOrCreate` 가 경합에 져서 **남이 만든 회원으로 수렴**해도 `before == null` 은 그대로 참이다.
그 상태로 `applyInitialProfile` 을 태우면 **남의 회원에 내 alias/memo 를 쓴다.**

⚠️ "alias·memo 가 둘 다 null 일 때만 쓴다"는 가드는 **보호가 되지 않는다** — 경합 승자는 방금 생성된
회원이라 둘 다 null 인 것이 정상이다. 실험으로 확인됐다.
→ 수렴 여부를 **명시적 플래그**(`MemberResolution.converged()`)로 전달해 차단한다.

### 실측 결과 (5중 동시)

| 시나리오 | 수정 전 | 수정 후 |
|---|---|---|
| B 동일 `partnerReference` | 201×1, **500×4** | 201×1, **409 `1105`×4** |
| A 동일 `partnerUserId` | 201×3, **500×2** → (1차 수정 후) **데드락 500×3** | **201×5**, alias 오염 없음 |
| B 신규 `partnerUserId` (잠재) | (미발견) | 201×1, **200×4** 멱등 수렴 |

**모든 경우에 자금·데이터는 안전했다** — DB 유니크 제약이 최종 방어선으로 동작했다(출금 1건·DEBIT 1건·회원 1건).
깨진 것은 **응답 계약**이었다.

### 그 밖에 실측으로 확정된 것

- **P2P 출금 1건당 파트너 웹훅이 2건** 나간다 — `WITHDRAWAL_REQUESTED`(PENDING_APPROVAL) → `WITHDRAWAL_COMPLETED`.
  §4 흐름도의 ⑦은 후자만 적었다. **파트너 문서에 둘 다 명시할 것** (첫 이벤트를 "승인 대기"로 오해한다).
- 텔레그램 발송 실패는 **출금을 실패시키지 않는다**(dev 봇 토큰 미설정 상태에서 201 정상).
- `idem_key` GENERATED 동작 확인 — 종결실패(CANCELLED 27·EXHAUSTED 13·REJECTED 7·FAILED 1) **전부 NULL**,
  COMPLETED 중 참조키 보유 7건은 **NOT NULL**. "종결실패는 재사용 가능, 성공은 영구 소진"이 성립한다.
- `MIN_AMOUNT_NOT_MET`(최소 10 USD) 도 `INSUFFICIENT_BALANCE` 와 같이 **code 가 숫자가 아니다** → §9 X9.

---

## 9. 후속 (이 지침서 범위 밖)

| 순서 | 항목 |
|---|---|
| B 오픈 **직후** | 잔여 전환 P4(강제 취소 제거) → P8(자동 실행 배치) → P9(주소 포맷 검증, P8 과 동시 필수) |
| 2차 | G 출금 사전 검증 (`WithdrawalPrecheckService` core 승격 + 위젯도 전환) |
| 3차 | C 회원 정보 통합 조회 (`MaskingUtils` · `MemberInfoAssembler` · C1 eKYC 장애 분리) |
| 4차 | E Axim Pay (E3·E4 선행 + `axim_payments` UNIQUE DDL) |
| 별도 트랙 | F1 위젯 잔액 · C3 기존 응답 마스킹 · E1-b/E8 위젯 IDOR |
| 백로그 | X1~X6 (`ErrorCodes` 자기충돌, `OpenApiException` 정리, `openapi.json` 파손) |
| **신규 X7** ⚠️ | **파트너 노출 에러 응답의 `stackTrace` 제거** — `axim.rest.debug: false` 인데도 18KB 가 나간다(§1.4 실측). 기존 v1 전 엔드포인트 해당 |
| **신규 X8** | **기존 DTO 10종의 `0E-18` 지수표기** — `ToStringSerializer` 를 쓰는 모든 BigDecimal 필드가 같은 잠재 문제를 안고 있다. 신규 3종만 `PlainBigDecimalSerializer` 로 고쳤다(파트너 응답이 바뀌는 변경이라 별도 트랙) |
| **신규 X9** | **`ErrorCodes.INSUFFICIENT_BALANCE`·`MIN_AMOUNT_NOT_MET` 의 code 가 숫자가 아니다** — 파트너 분기 일관성 |
| **신규 X10** ☠️ | **`WithdrawalService.resolveOrderKeyConflict`(`:340`·`:1031`)가 죽어 있을 가능성** — `catch (DuplicateKeyException)` 인데 `save()` 가 `RuntimeException` 으로 감싼다(§8.5-1). **콘솔·어드민 P2P 출금 경로가 경합에서 500 을 낼 수 있다.** `DataAccessExceptions` 를 그대로 재사용해 고칠 수 있다 |
| **신규 X11** | **락 대기 타임아웃(`CannotAcquireLockException`)은 여전히 500** — 데드락과 같은 성격(재시도 가능)이라 409 로 수렴시킬지 판단 필요 |
| **신규 X12** | `getOrCreate` **내부 선조회**에서 기존 회원을 찾아 반환하는 경로는 `converged=false` 라 §8.5-(3) 방어를 우회한다. `created_at == null`(=방금 INSERT) 로 판정을 조일 수 있으나 기존 테스트 픽스처와 충돌 |

### ⛔ 미결정 — `spec-bundle.json` 공개 범위 (커밋 전 반드시 결정)

`generateSpecBundle` 재생성 시 API 가 **58 → 118건**이 된다(사라진 항목 없음). 신규 5건 외 **55건은
그동안 문서에 없던 것**이다 — 서빙본이 2026-04-29 이후 stale 이었기 때문이다.

새로 실리는 것: P2P 출금자 페이지 18건(인증·PIN 설정·계좌 등록/수정/삭제·분쟁 제기), P2P 회원 설정 7건,
TORQ·링크 위젯 등 29건 — **대부분 파트너가 아니라 우리 UI 가 쓰는 내부 엔드포인트다.**

⚠️ `SecurityConfig:36` 이 `anyRequest().permitAll()` 이라 **`GET /docs/spec-bundle.json` 은 인증 없이 공개**된다.

| 선택지 | 내용 |
|---|---|
| **A (권장)** | 내부·위젯 엔드포인트에 `@XApiIgnore` 를 붙이고 재생성 — 55건 분류 필요 |
| B | 신규 5건만 반영하고 나머지는 유지 — 수작업 병합이라 다음 재생성 때 재발 |
| C | 전부 공개 수용 — 인증은 걸려 있어 취약점은 아니나 내부 표면이 드러남 |

**결정 전까지 `static/docs/spec-bundle.json` 을 커밋하지 않는다.**
