# P2P 더스트 매칭 수정 구현 지침서

> **작성 배경 (2026-06-29)**: 150만원 입금이 위젯에서 **1원 + 1,499,999원**으로 분할됨.
>
> **실측 근거 (운영 DB)**:
> - 입금주문 #113이 **1원짜리 입금**(krw_amount=1, EXPIRED) → P2P 유동성 없음 →
>   `fillRemainderWithPartner`가 파트너 standing 출금주문 #26(krw=1, PARTNER_STANDING) 생성, 매칭 #142(PARTNER) 생성.
> - 매칭 #142 **FAILED** → `failMatch`가 standing 주문 #26을 **취소하지 않고 PENDING으로 복원** → 매칭 풀에 1원 주문 잔류(누수).
> - 1시간 뒤 실제 입금 #114(150만원) 유입 → 매칭기가 풀에 남은 1원짜리 #26을 P2P 레그로 잡고(매칭 #143, 1원) 나머지 1,499,999원을 TORQ로 라우팅.
>
> **3대 결함**:
> 1. 실패한 파트너 standing 주문이 폐기되지 않고 풀로 복원(누수).
> 2. 매칭기에 최소금액 가드 없음 → 1원짜리 주문도 매칭 후보가 됨.
> 3. 입금/출금 생성 시 최소금액 검증 없음 → 1원짜리 주문/입금 생성 가능.
>
> **결정 (사용자 확정)**: 최소금액 = **10,000원**, 세 가지 모두 수정.

---

## 설계 요약

- 신규 시스템 설정 `p2p.min_amount` (기본값 **10000**) — `getIntSetting`으로 읽음(런타임 조정 가능).
- `P2pMatchingService`에 `public int getMinAmount()` 추가 → 입금/출금 서비스가 공유(두 서비스 모두 이미 `P2pMatchingService` 주입받음).
- 신규 에러코드 `P2P_AMOUNT_TOO_SMALL`("995").

## 변경 파일 (4개)

| 파일 | 변경 |
|------|------|
| `common/exception/ErrorCodes.java` | 신규 에러코드 1개 |
| `core/p2p/P2pMatchingService.java` | getMinAmount() 추가 / failMatch standing 취소 / 매칭 후보·잔여 가드 |
| `core/p2p/P2pDepositService.java` | 입금 생성 최소금액 검증 |
| `core/p2p/P2pWithdrawService.java` | 출금 생성 최소금액 검증 |

---

## 1. ErrorCodes.java — 신규 에러코드

P2P 블록(980~994 사용 중, **995 미사용**)에 추가. `P2P_PIN_NOT_SET = ("994", ...)` 줄 **다음**에 1줄 삽입:

```java
    public static final ErrorCode P2P_PIN_NOT_SET = new ErrorCode("994", "핀코드가 설정되지 않았습니다.");
    // ▼ 신규 (더스트 방지)
    public static final ErrorCode P2P_AMOUNT_TOO_SMALL = new ErrorCode("995", "최소 거래 금액 미만입니다.");
```

> 메시지는 호출부에서 구체 문구로 덮어쓴다(아래 3·4). 상수 자체 메시지는 fallback용.

---

## 2. P2pMatchingService.java

### 2-1. getMinAmount() 헬퍼 추가 (public)

기존 `getIntSetting(...)` 헬퍼(549행 부근) **바로 위 또는 아래**에 추가:

```java
    /** P2P 최소 거래/레그 금액(원) — 이 미만은 입금/출금/레그 생성 거부(더스트 방지). */
    public int getMinAmount() {
        return getIntSetting("p2p.min_amount", 10000);
    }
```

### 2-2. failMatch — 파트너 standing 주문은 복원하지 말고 취소 (누수 차단)

**현재 (472~484행)** — leg_type 무관하게 동일 복원:
```java
        // 출금측 복원 (P2P/PARTNER leg) — 잠금 해제 + matchedAmount 차감 + 상태 복원 (TORQ 레그는 잠금/출금 없음)
        if (match.getLegType() != P2pLegType.TORQ && match.getWithdrawOrderId() != null) {
            P2pWithdrawOrder wo = withdrawRepo.findOne(match.getWithdrawOrderId());
            if (wo != null) {
                lockService.unlockForMatch(wo.getPartnerId(), wo.getNetworkId(), match.getUsdtAmount(), matchId);
                long newMatched = wo.getMatchedAmount() - match.getKrwAmount();
                if (newMatched < 0) newMatched = 0;
                wo.setMatchedAmount(newMatched);
                wo.setStatus(newMatched == 0 ? P2pWithdrawStatus.PENDING : P2pWithdrawStatus.PARTIALLY_MATCHED);
                if (newMatched == 0) wo.setMatchedAt(null);
                withdrawRepo.modify(wo);
            }
        }
```

**변경** — PARTNER(standing)는 CANCELLED 종결, P2P(실회원)는 기존대로 복원:
```java
        // 출금측 처리 (P2P/PARTNER leg) — 잠금 해제 후, P2P(실회원)는 복원 / PARTNER(standing)는 종결 (TORQ 레그는 잠금/출금 없음)
        if (match.getLegType() != P2pLegType.TORQ && match.getWithdrawOrderId() != null) {
            P2pWithdrawOrder wo = withdrawRepo.findOne(match.getWithdrawOrderId());
            if (wo != null) {
                lockService.unlockForMatch(wo.getPartnerId(), wo.getNetworkId(), match.getUsdtAmount(), matchId);
                long newMatched = wo.getMatchedAmount() - match.getKrwAmount();
                if (newMatched < 0) newMatched = 0;
                wo.setMatchedAmount(newMatched);
                if (match.getLegType() == P2pLegType.PARTNER) {
                    // 파트너 standing 주문은 해당 입금 1건 전용 일회성 주문 — 실패 시 풀로 복원하면
                    //   다음 입금에 더스트로 끌려간다(2026-06-29 1원 누수 사고). 복원 대신 CANCELLED 종결.
                    wo.setStatus(P2pWithdrawStatus.CANCELLED);
                } else {
                    wo.setStatus(newMatched == 0 ? P2pWithdrawStatus.PENDING : P2pWithdrawStatus.PARTIALLY_MATCHED);
                    if (newMatched == 0) wo.setMatchedAt(null);
                }
                withdrawRepo.modify(wo);
            }
        }
```

### 2-3. 매칭 후보 가드 — 최소금액 미만 출금주문 스킵

가드를 추가할 위치는 두 곳(레거시 분할 + 통합). **exact 1:1 매칭(157행)은 amount==remaining≥min 이라 불필요**.

**(a) 레거시 분할 루프 (180~181행):**
```java
            long available = wo.getKrwAmount() - wo.getMatchedAmount();
            if (available <= 0) continue;
```
→ 다음으로:
```java
            long available = wo.getKrwAmount() - wo.getMatchedAmount();
            if (available < getMinAmount()) continue;   // 더스트 출금주문 스킵 (available<=0 포함)
```

**(b) 통합 매칭 루프 `tryMatchDepositUnified` (246~247행):**
```java
            long available = wo.getKrwAmount() - wo.getMatchedAmount();  // KRW 액면(고정) 기준 가용
            if (available <= 0) continue;
```
→ 다음으로:
```java
            long available = wo.getKrwAmount() - wo.getMatchedAmount();  // KRW 액면(고정) 기준 가용
            if (available < getMinAmount()) continue;   // 더스트 출금주문 스킵 (available<=0 포함)
```

> `getMinAmount()`(10000) ≥ 1 이므로 `available <= 0` 케이스도 자동 포함된다(기존 `<= 0` 의미 보존).

### 2-4. 잔여 충족 가드 — 최소금액 미만 잔여는 standing/레그 생성 금지

**(a) `fillRemainderWithPartner` (410행, 메서드 본문 첫 줄):**
```java
    private boolean fillRemainderWithPartner(P2pDepositOrder order, long need) {
        if (!getBoolSetting("p2p.partner_fallback_enabled", true)) return false;
```
→ enabled 체크 **다음**에 추가:
```java
        if (need < getMinAmount()) {
            // 최소금액 미만 잔여로 파트너 standing 주문을 만들면 더스트가 된다 — 생성 금지(2026-06-29).
            log.info("파트너 fallback 불가 — 잔여 최소금액 미만: order={}, need={}, min={}",
                    order.getOrderCode(), need, getMinAmount());
            return false;
        }
```

**(b) `fillRemainderWithTorq` (300행, 메서드 본문 첫 줄):**
```java
    private boolean fillRemainderWithTorq(P2pDepositOrder order, long need) {
        if (!getBoolSetting("p2p.torq_fallback_enabled", true)) return false;
```
→ enabled 체크 **다음**에 추가(TORQ getQuote가 자체 한도로 던지긴 하나, 외부 호출 전에 명시 차단):
```java
        if (need < getMinAmount()) {
            log.info("TORQ fallback 불가 — 잔여 최소금액 미만: order={}, need={}, min={}",
                    order.getOrderCode(), need, getMinAmount());
            return false;
        }
```

> 두 가드 모두 false면 `tryMatchDepositUnified`의 `failUnifiedOrder(order)` 경로로 P2P 레그 롤백 +
> 명시적 실패 처리된다(더스트 잔여 레그 미생성). 의도된 동작.

---

## 3. P2pDepositService.java — 입금 생성 최소금액 검증

`createAndMatch(...)` 의 `P2P_NOT_ENABLED` 체크(95~97행) **다음**에 추가:

```java
        if (!Boolean.TRUE.equals(partner.getP2pEnabled())) {
            throw new ConflictException(ErrorCodes.P2P_NOT_ENABLED);
        }
        // 최소 거래 금액 미만 입금 거부 (더스트 방지, 2026-06-29)
        int minAmount = matchingService.getMinAmount();
        if (krwAmount == null || krwAmount < minAmount) {
            throw new ConflictException(ErrorCodes.P2P_AMOUNT_TOO_SMALL,
                    "최소 거래 금액은 " + String.format("%,d", minAmount) + "원입니다.");
        }
```

> `matchingService`는 이미 주입된 필드(`P2pMatchingService`). `krwAmount`는 `Long`이므로 null 체크 포함.
> `ConflictException`/`ErrorCodes`는 이미 import됨.

---

## 4. P2pWithdrawService.java — 출금 생성 최소금액 검증

`createOrder(...)` 에서 `krwAmount`가 확정되는 지점(86~87행, KRW/USDT 분기 직후) **다음**에 추가:

```java
        } else {
            usdtAmount = amount;
            krwAmount = amount.multiply(exchangeRate).setScale(0, RoundingMode.HALF_UP).longValue();
        }
        // 최소 거래 금액 미만 출금 거부 (더스트 방지, 2026-06-29)
        int minAmount = matchingService.getMinAmount();
        if (krwAmount < minAmount) {
            throw new ConflictException(ErrorCodes.P2P_AMOUNT_TOO_SMALL,
                    "최소 거래 금액은 " + String.format("%,d", minAmount) + "원입니다.");
        }
```

> `matchingService`는 이미 주입된 필드. `ConflictException`/`ErrorCodes` import 확인(미존재 시 추가).

---

## 5. 시스템 설정 등록 (선택 — 기본값 10000으로 동작)

코드 기본값이 10000이라 미등록 시에도 동작한다. 운영에서 값을 조정하려면 `system_settings`에 등록:

```sql
INSERT INTO system_settings (setting_key, setting_value, description)
VALUES ('p2p.min_amount', '10000', 'P2P 최소 거래/레그 금액(원) — 미만은 입금/출금/레그 생성 거부')
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value);
```
(DDL 변경 아님 — 데이터 1행. CI/CD 무관, 운영 DB 직접 적용.)

---

## 6. 즉시 운영 조치 — 잔류 1원 주문 정리

수정 배포와 별개로, 현재 풀에 떠 있는 1원 standing 주문 #26을 종결시켜야 추가 오염이 멈춘다.
(매칭 #143은 BANK_PENDING 상태 — 정리 방법은 운영자 확인 후 결정.)

```sql
-- 현재 상태 확인
SELECT id, order_code, partner_user_id, krw_amount, matched_amount, status FROM p2p_withdraw_orders WHERE id=26;
-- 종결 (확인 후): UPDATE p2p_withdraw_orders SET status='CANCELLED' WHERE id=26 AND status NOT IN ('SETTLING','COMPLETED');
```

---

## 7. 검증 체크리스트 (구현 후)

1. **컴파일**: `./gradlew :common:compileJava :core:compileJava :partner-api:compileJava :open-api:compileJava :scheduler:compileJava` 전부 SUCCESS.
2. **failMatch 분기**: PARTNER leg 실패 시 standing 주문이 `CANCELLED`로 가는지(복원 아님), P2P leg는 기존대로 PENDING/PARTIALLY 복원되는지 코드 리뷰.
3. **가드 위치**: 후보 스킵(`available < getMinAmount()`)이 레거시·통합 루프 양쪽에, 잔여 가드(`need < getMinAmount()`)가 partner·torq 폴백 양쪽에 들어갔는지.
4. **최소금액 단일 출처**: 입금/출금/매칭이 모두 `getMinAmount()`(=`p2p.min_amount`) 하나를 참조하는지(상수 하드코딩 분산 금지).
5. **회귀**: 정상 금액(예: 100,000원) 입금/출금/매칭이 영향 없는지(10000 이상은 종전과 동일 동작).
6. **엣지**: 잔여가 min 미만으로 남는 분할 케이스는 더스트 레그 대신 주문 실패(failUnifiedOrder)로 가는지 — 의도된 동작임을 확인.

## 배포

`cryptoments-backend` → `git push origin main` → CI/CD 자동 배포. DDL 변경 없음(설정 1행은 운영 DB 직접).
