# 통합 매칭 서비스 — 상세 구현 지침서

**버전**: v0.1
**작성일**: 2026-06-13
**대상**: IntelliJ(Spring Boot) + widget-ui(Vue)
**설계 문서**: `P2P_TORQ_UNIFIED_MATCHING_DESIGN.md` v0.6 (이 지침서는 그 설계의 구현 방법)

> **원칙**: 기존 P2P 레일을 최대 재사용. 신규 비용은 ① RemainderProvider 추상 ② TORQ 레그의 외부 정산/분쟁 배선 두 곳에 집중. P2P/파트너는 동일 레일(파트너 MASTER 유동성·시스템 환율·CODEF·MASTER→MASTER 정산).

---

## 0. 변경 파일 한눈에 (touch list)

| 계층 | 파일 | 변경 |
|------|------|------|
| enum | `common/enums/P2pLegType.java` | **신규** (P2P/TORQ/PARTNER) |
| entity | `common/entity/P2pMatch.java` | `legType`, `torqEscrowId` 필드 + `withdrawOrderId` nullable |
| entity | `common/entity/TorqTrade.java` | `p2pMatchId` 필드 |
| DDL | `CRYPTOMENTS_V2_DDL.sql` | p2p_matches/torq_trades ALTER |
| core | `core/p2p/RemainderProvider.java` | **신규 인터페이스** |
| core | `core/p2p/TorqRemainderProvider.java` | **신규** |
| core | `core/p2p/PartnerRemainderProvider.java` | **신규** |
| core | `core/p2p/P2pMatchingService.java` | tryMatchDeposit 폭포 재설계, createMatch legType, getBankAccountForMatch 분기, submitDispute 분기, confirmBankTransfer/정산 진입 |
| core | `core/p2p/P2pSettlementService.java` | startSettlementForMatch legType 분기(TORQ 1단계) |
| core | `core/torq/TorqService.java` | createTrade 후 p2pMatchId 링크, handleCompleted/ForceReleased/Cancelled → p2p_match 전파, deposit 재분류 |
| open-api | `controller/widget/P2pWidgetController.java` | toMatchDetailResponse legType 추가, 응답 DTO |
| open-api | `dto/p2p/P2pWidgetMatchDetailResponse.java` | `legType` 필드 |
| scheduler | `P2pScrapingVerifyJob` | leg 확인 패스(P2P/PARTNER만 CODEF, TORQ skip) |
| widget | `widget-ui/src/views/p2p.vue` | 단일 진입(라우팅 제거), leg_type 카드 배지/안내, "잔여 매칭 중" |
| widget | `widget-ui/src/api/widgetApis.js` | 라우팅 호출 제거(통합 createMatch) |
| settings | system_settings | `p2p.max_p2p_legs`, `torq_fallback_enabled`, `partner_fallback_enabled`, `purchase_fee_rate` |

---

## 1. Phase 1 — DDL + Entity/DTO

### 1.1 DDL (운영 ALTER)

```sql
-- p2p_matches: 레그 유형 + TORQ 링크 + withdraw_order_id NULL 허용
ALTER TABLE p2p_matches
  ADD COLUMN leg_type VARCHAR(10) NOT NULL DEFAULT 'P2P'
      COMMENT 'P2P | TORQ | PARTNER' AFTER deposit_order_id,
  ADD COLUMN torq_escrow_id BIGINT NULL
      COMMENT 'TORQ 레그 escrow_id' AFTER leg_type,
  MODIFY COLUMN withdraw_order_id BIGINT NULL
      COMMENT 'P2P/파트너 레그 출금주문 ID (TORQ 레그는 NULL)';
ALTER TABLE p2p_matches ADD INDEX idx_pm_torq_escrow (torq_escrow_id);

-- torq_trades: 역참조
ALTER TABLE torq_trades
  ADD COLUMN p2p_match_id BIGINT NULL
      COMMENT '통합 매칭 레그 ID (단독 TORQ는 NULL)' AFTER escrow_id;
ALTER TABLE torq_trades ADD INDEX idx_tt_p2p_match (p2p_match_id);

-- system_settings (구매수수료는 기존 p2p.fee_rate=0.02 재사용 — 신규 키 추가 금지)
INSERT INTO system_settings (setting_key, setting_value, description, value_type) VALUES
 ('p2p.max_p2p_legs','2','unified matching: max P2P legs','NUMBER'),
 ('p2p.torq_fallback_enabled','true','remainder TORQ fallback on/off','BOOLEAN'),
 ('p2p.partner_fallback_enabled','true','remainder partner-account fallback on/off','BOOLEAN')
ON DUPLICATE KEY UPDATE setting_value=VALUES(setting_value);
```

> **✅ 운영 DB 적용 완료 (2026-06-13)** — 검증: leg_type(기본 P2P)/torq_escrow_id/withdraw_order_id nullable, torq_trades.p2p_match_id, 인덱스 2개, settings 3건. 기존 P2P 매칭은 `leg_type='P2P'` 기본값으로 무영향. 구매수수료율 = 기존 `p2p.fee_rate=0.02` 재사용(중복 키 금지). DDL은 CI/CD 미포함 — 운영 DB 직접 적용.

### 1.2 Enum

```java
// common/enums/P2pLegType.java
public enum P2pLegType { P2P, TORQ, PARTNER }
```

### 1.3 Entity

```java
// P2pMatch.java — 추가
@XColumn("leg_type")        private P2pLegType legType;     // 기본 P2P
@XColumn("torq_escrow_id")  private Long torqEscrowId;      // TORQ 레그만
// withdraw_order_id 는 Long 유지(이미 nullable 가능)

// TorqTrade.java — 추가
@XColumn("p2p_match_id")    private Long p2pMatchId;
```

### 1.4 DTO

```java
// P2pWidgetMatchDetailResponse.java — 추가
private String legType;     // "P2P" | "TORQ" | "PARTNER"
// (disputeEvidenceUrl, disputeSubmittedBy 는 a22923c 에서 이미 추가됨)
```
`toMatchDetailResponse`에 `.legType(match.getLegType().name())` 매핑.

---

## 2. Phase 2 — 매칭 엔진 (폭포 + RemainderProvider)

### 2.1 RemainderProvider 추상

```java
// core/p2p/RemainderProvider.java
public interface RemainderProvider {
    P2pLegType legType();
    int priority();                 // 낮을수록 우선 (TORQ=10, PARTNER=20)
    boolean enabled();              // system_settings 토글
    /** 잔여 전액(remainderKrw)을 단독으로 채울 수 있으면 leg 생성, 아니면 null. */
    P2pMatch tryFill(P2pDepositOrder order, long remainderKrw);
}
```

- `TorqRemainderProvider`(priority 10), `PartnerRemainderProvider`(priority 20)를 `List<RemainderProvider>`로 주입, `priority()` 정렬.
- **잔여 < 최소금액**(TORQ)인 경우 TorqProvider.tryFill 이 `null` 반환 → 다음 provider(PARTNER).

### 2.2 tryMatchDeposit 재설계 (점진형 2단계)

```java
// P2pMatchingService.tryMatchDeposit(P2pDepositOrder order)  ── Phase 1 (TX)
@Transactional
public void tryMatchDeposit(P2pDepositOrder order) {
    long remaining = order.getRemainingAmount();
    int maxP2p = settings.getInt("p2p.max_p2p_legs", 2);

    // ── P2P 레그 분할 (최대 maxP2p) — 기존 사전계획 로직 유지, 상한만 maxP2p ──
    List<SplitLeg> plan = planP2pLegs(remaining, maxP2p);   // 기존 1-2 분할 코드 추출
    long need = remaining - sum(plan.legAmount);
    for (SplitLeg leg : plan) {
        lockService.lockForMatch(leg.wo.getPartnerId(), leg.networkId, leg.currencyId, leg.usdtNeeded, null);
        createMatch(leg.wo, order, leg.legAmount, leg.networkId, P2pLegType.P2P);
    }

    if (need <= 0) {                       // P2P 단독 충족
        order.setStatus(MATCHED); order.setRemainingAmount(0L); depositOrderRepo.modify(order);
        return;
    }
    // 잔여 존재 → 점진형: Phase 1 커밋, Phase 2 예약
    order.setStatus(MATCHING); order.setRemainingAmount(need); depositOrderRepo.modify(order);
    registerAfterCommit(() -> resolveRemainder(order.getId()));   // TransactionSynchronizationManager
}
```

```java
// Phase 2 — 잔여 해소 (잠금 미보유, 외부 I/O 허용)
@Transactional
public void resolveRemainder(Long orderId) {
    P2pDepositOrder order = depositOrderRepo.findOne(orderId);
    if (order == null || order.getStatus() != MATCHING || order.getRemainingAmount() <= 0) return;
    long need = order.getRemainingAmount();

    for (RemainderProvider p : providers) {   // priority 정렬 [TORQ, PARTNER]
        if (!p.enabled()) continue;
        P2pMatch leg = p.tryFill(order, need);
        if (leg != null) {
            order.setStatus(MATCHED); order.setRemainingAmount(0L); depositOrderRepo.modify(order);
            return;
        }
    }
    // 전 provider 실패 → 안전망: P2P leg 취소+unlock + 주문 FAILED
    failOrderAndRollbackLegs(order);
}
```

> **라운딩**: `planP2pLegs`에서 마지막 leg가 `need = remaining - Σ(앞 legs)` 잔돈을 흡수 → Σ(leg krw)==주문 총액 보장. Provider leg도 `legAmount = need` 정확히.

### 2.3 createMatch 시그니처에 legType 추가

```java
private void createMatch(P2pWithdrawOrder wo, P2pDepositOrder dpo, long krw, Long networkId, P2pLegType legType) {
    ...
    P2pMatch match = P2pMatch.builder()
        .matchCode("pm_" + generateId())
        .withdrawOrderId(wo != null ? wo.getId() : null)   // TORQ 레그는 null
        .depositOrderId(dpo.getId())
        .legType(legType)
        ...
}
```

### 2.4 TorqRemainderProvider

```java
P2pMatch tryFill(P2pDepositOrder order, long remainderKrw) {
    if (!enabled()) return null;
    // 1) TORQ 견적 — 잔여 전액 가능 여부 (최소금액 미만이면 quote 실패/null → return null)
    TorqQuote q = torqService.getQuote(remainderKrw);
    if (q == null || !q.coversFull(remainderKrw)) return null;   // 부분/실패 → 파트너로
    // 2) 매칭 레코드 먼저 생성 (leg_type=TORQ, withdraw_order_id=null)
    P2pMatch leg = createTorqLegMatch(order, remainderKrw, q);   // status CREATED
    // 3) TORQ escrow 생성 (외부 — 잠금 밖), 구매자 수령주소 = 구매자 파트너 MASTER
    String receiveAddr = masterAddress(order.getPartnerId(), q.networkId());
    TorqTrade t = torqService.createTrade(order.getPartnerId(), order.getPartnerUserId(),
            remainderKrw, receiveAddr, order.getBuyerName(), /*phone*/null,
            /*bank*/null, /*holder*/null, /*acct*/null, kycUid(order), q.quoteId(), /*sessionId*/null);
    // 4) 양방향 링크
    leg.setTorqEscrowId(t.getEscrowId()); matchRepo.modify(leg);
    t.setP2pMatchId(leg.getId()); torqTradeRepository.modify(t);
    return leg;
}
```

> TORQ 레그는 **유동성 잠금 없음**(LP가 외부 공급). createTrade 실패 시 leg 롤백 후 `null` → 파트너 provider로.

### 2.5 PartnerRemainderProvider

```java
P2pMatch tryFill(P2pDepositOrder order, long remainderKrw) {
    if (!enabled()) return null;
    Partner buyerPartner = partnerRepo.findOne(order.getPartnerId());
    BankAccount svc = bankAccountRepo.findPartnerServiceAccount(order.getPartnerId());  // owner_type=PARTNER
    if (svc == null) return null;                                  // 미등록 → 실패(매칭 실패 경로)
    Long networkId = order.getNetworkId() != null ? order.getNetworkId() : DEFAULT_NETWORK_ID;
    BigDecimal rate = priceService.getSystemExchangeRate();
    BigDecimal usdtNeed = BigDecimal.valueOf(remainderKrw).divide(rate, 18, UP);
    // 파트너 MASTER 유동성 잠금 (P2P 레일과 동일)
    if (!lockService.lockForMatch(order.getPartnerId(), networkId, usdtCurrency(networkId), usdtNeed, null))
        return null;                                               // 유동성 부족 → 실패
    // 파트너 standing 출금주문 on-the-fly (owner=파트너, 계좌=서비스계좌, krw=remainder)
    P2pWithdrawOrder wo = withdrawService.createStandingPartnerOrder(order.getPartnerId(), networkId,
            svc.getId(), remainderKrw, rate);
    return createMatch(wo, order, remainderKrw, networkId, P2pLegType.PARTNER);
}
```

> 파트너 standing = "자동 무제한"의 구현 = **매칭 시점 on-the-fly 출금주문**. 한도는 출금주문 krw가 아니라 **파트너 MASTER 유동성 잠금**이 실질 cap.

---

## 3. Phase 3 — 확인/정산 분기

### 3.1 getBankAccountForMatch (계좌 노출)

```java
public BankAccount getBankAccountForMatch(P2pMatch match) {
    switch (match.getLegType()) {
        case P2P, PARTNER -> { return bankAccountRepo.findOne(withdrawRepo.findOne(match.getWithdrawOrderId()).getBankAccountId()); }
        case TORQ -> { return torqLegAccount(match.getTorqEscrowId()); }  // torq_trades seller_bank/account/recipient_phone → BankAccount 어댑터
    }
}
```

### 3.2 확인 트리거 (진입점 통합)

- **단일 진입점**: `confirmBankTransfer(matchId, ref)` (기존). 어느 소스가 트리거하든 동일.
- **P2P/PARTNER**: `P2pScrapingVerifyJob` 가 leg 확인 패스에서 `leg_type IN (P2P,PARTNER)` 만 CODEF 스크래핑. TORQ 레그는 **skip**.
- **TORQ**: TORQ 웹훅(§3.4)이 `confirmBankTransfer` 호출 + 폴백 폴링.

### 3.3 정산 분기 (startSettlementForMatch)

```java
public void startSettlementForMatch(P2pMatch match) {
    if (match.getStatus() != BANK_CONFIRMED) return;
    switch (match.getLegType()) {
        case P2P, PARTNER -> startPushSettlement(match);   // 기존 로직: MASTER→구매자 MASTER (Relayer/INNER)
        case TORQ        -> creditTorqLeg(match);          // 1단계: 송금 없음, 구매자 p2p ledger CREDIT(FEE 0) + SETTLED
    }
}
```

- `startPushSettlement` = 기존 `startSettlementForMatch` 본문(P2pSettlement 생성 + on-chain/INNER) — **2% FEE 원장** 포함, 체인 쉐어.
- `creditTorqLeg` = **신규**: P2pSettlement on-chain 미생성. 구매자 p2p ledger CREDIT 엔트리(usdtAmount), **FEE 0**. match SETTLED. LP→MASTER 입금은 §3.4에서 "internal funding"으로 분류.

### 3.4 TORQ 웹훅 → p2p_match 전파 (TorqService)

```java
// handleCompleted: txHash Deposit 매칭 후 — p2p_match 링크 확인
if (trade.getP2pMatchId() != null) {
    // (a) Deposit 을 구매자 직접 크레딧이 아니라 "파트너 MASTER 내부 충전"으로 분류
    reclassifyAsInternalFunding(matchedDeposit);
    // (b) 레그 확정 → 1단계 정산
    matchingService.confirmBankTransfer(legMatchId, "TORQ_" + trade.getEscrowId()); // → creditTorqLeg → SETTLED
} else {
    // 단독 TORQ: 기존 USER_DEPOSIT/TORQ 보정 그대로
}

// handleForceReleased / handleCancelled: 분쟁 resolution 전파 (§4.3)
```

---

## 4. Phase 4 — 분쟁 분기

### 4.1 UI — 변경 없음
분쟁 화면이 이미 matchCode 단위(`a22923c`). 세 leg_type 그대로.

### 4.2 submitDispute legType 분기 (P2pMatchingService)

```java
public P2pMatch submitDispute(Long matchId, String reason, String evidenceUrl) {
    P2pMatch match = matchRepo.findOne(matchId);
    // ── 공통: 우리 DB 저장 (전 leg) ──
    match.setStatus(DISPUTED); match.setDisputedAt(now);
    match.setDisputeReason(reason); match.setDisputeEvidenceUrl(evidenceUrl);
    match.setDisputeSubmittedBy("DEPOSITOR"); matchRepo.modify(match);
    // ── TORQ 레그: TORQ 로 추가 전달 ──
    if (match.getLegType() == TORQ) {
        torqService.submitEvidence(/*partnerId*/dpoPartner(match), match.getTorqEscrowId(), reason, evidenceUrl);
    }
    return match;
}
```
> P2P/파트너는 저장만 → 우리 관리자가 admin-api `resolveDispute`로 처리(기존). 상태 가드: 기존 BANK_PENDING 제약 유지하되 leg_type별 진입 조건 점검.

### 4.3 TORQ resolution 수신 → p2p_match 반영

```java
// handleForceReleased(resolution) / handleCompleted / handleCancelled 에서
if (trade.getP2pMatchId() != null) {
    switch (resolution) {
        case "RELEASE_TO_BUYER", /*COMPLETED*/ -> matchingService.confirmBankTransfer(matchId, ref); // 구매자 승 → SETTLED
        case "DISPUTE_REJECTED", "CANCELLED"   -> matchingService.failMatch(matchId, "TORQ_REJECTED"); // → FAILED
    }
}
```

### 4.4 관리자 콘솔
- admin-api 분쟁 목록/상세에 `leg_type` 노출.
- TORQ 레그: 액션 버튼 비활성 + "TORQ 처리 중" 배지(resolution 대기). P2P/파트너만 확인/거절 가능.

---

## 5. Phase 5 — 위젯 (p2p.vue)

1. **단일 진입**: `runRouting`/`p2pRouteApi` 택일 제거 → 항상 `p2pCreateMatchApi`. TORQ 택일 분기(torq.vue 이동) 삭제.
2. **점진 노출**: 응답 status=MATCHING(잔여 존재)이면 P2P 카드 1~2개 + "잔여 매칭 중" placeholder. 폴링이 잔여 leg(3번째 카드) 픽업.
3. **leg_type 카드 배지**: 각 매칭 카드에 `legType` 표시 — P2P/파트너 = 계좌이체 안내(기존), TORQ = LP 송금 안내(torq.vue 송금 컴포넌트 이식, recipient_phone vs seller_account 분기).
4. **이체완료/확인**: 레그별 `transfer-done` → leg_type 무관 동일 폴링. 전 레그 확인 시 verifying→success.
5. **분쟁**: 동일 화면(변경 없음). TORQ 레그 분쟁도 같은 제출 → 서버가 TORQ 전달.

---

## 6. Phase 6 — 수수료/집계

- 구매수수료 2%: **P2P/파트너 레그만** FEE 원장 + 체인 쉐어(`p2p_order_shares`). 기존 `aggregateDailyFees`가 `leg_type='TORQ'` 제외.
- 입금수수료: 전 레그 미적용(매칭 서비스엔 deposit_fee 없음).
- TORQ 레그: FEE 0, 쉐어 분배 제외.

---

## 7. 구현 순서 (의존성)

```
1. DDL + Enum + Entity/DTO            (Phase 1)
2. RemainderProvider 추상 + 2 구현    (Phase 2)
3. tryMatchDeposit 폭포 + resolveRemainder (Phase 2)
4. getBankAccountForMatch / 정산 분기 (Phase 3)
5. TORQ 링크 + 웹훅 전파 + deposit 재분류 (Phase 3/4)
6. submitDispute 분기                 (Phase 4)
7. 위젯 단일 진입 + leg 카드           (Phase 5)
8. 집계 TORQ 제외                      (Phase 6)
```

---

## 8. 테스트 체크리스트

| # | 시나리오 | 기대 |
|---|---------|------|
| 1 | P2P 1건 전액 | 1카드, 단독 SETTLED |
| 2 | P2P 2건 전액 | 2카드, 분할 SETTLED |
| 3 | P2P 2건 + 잔여 TORQ 전액 | 3카드, TORQ leg LP→MASTER 1단계 정산 |
| 4 | P2P 2건 + 잔여<TORQ최소 → 파트너 | 3카드, 파트너 leg CODEF 정산 |
| 5 | TORQ 실패 → 파트너 | 파트너 대체, 3카드 |
| 6 | P2P+TORQ 미충족 & 파트너 미등록 | 매칭 실패 + P2P leg 롤백/unlock |
| 7 | TORQ 레그 분쟁 | 우리 DB 저장 + TORQ 전달, FORCE_RELEASED→SETTLED / REJECTED→FAILED |
| 8 | P2P 레그 분쟁 | 우리 관리자 resolve, 타 레그 독립 진행 |
| 9 | 라운딩 | Σ(leg krw)==주문 총액, 1원 누락 없음 |
| 10 | 수수료 | P2P/파트너 leg만 2% FEE+쉐어, TORQ leg FEE 0 |
| 11 | 점진 노출 | P2P 카드 즉시 + 잔여 매칭 중 → 3번째 카드 등장 |
| 12 | 이중 크레딧 방지 | TORQ leg 구매자 크레딧 1회(p2p ledger), LP→MASTER는 internal funding |

---

## 9. 리스크

- **withdraw_order_id NULL 분기 누락** — 정산·unlock·계좌조회 전 경로에서 `leg_type==TORQ` 가드 확인(NPE/오정산 방지). 자금 사고 직결.
- **TORQ 외부호출 트랜잭션 경계** — `resolveRemainder`는 P2P 잠금 커밋 후(AFTER_COMMIT) 별 트랜잭션. createTrade 실패 시 leg 롤백만, P2P leg는 유지(부분) 또는 안전망 FAIL.
- **이중 크레딧** — TORQ leg는 p2p ledger 1회만. LP→MASTER Deposit 반드시 internal funding 재분류.
- **롤백 안전판** — `torq_fallback_enabled=false` / `partner_fallback_enabled=false`로 즉시 기존 동작 복귀.
