파트너 유저에게 원화 지급 → 파트너 USDT 정산 · 출금팀 중개 · BSC/TRON 단일 체인 락업
| 구분 | 항목 | 확정 |
|---|---|---|
| 금액·환율 | 금액 기준 | 원화(KRW) — 유저는 신청액 전액 수령. KRW는 BIGINT 원 단위 정수 |
| 환율 | 접수 순간 빗썸 USDT 시세 각인 · 직전 대비 ±5% 초과 시 거절 · 캐시 5분 허용 | |
| 소수점 | USDT 6자리 올림(ceil) — 내림하면 정산 분배가 모자란다 | |
| 출금 한도 | 단건 20만 ~ 500만원 | |
| 수수료 | 1% = 크립토먼츠 0.5% + 출금팀 0.5% · 파트너 부담 · 파트너별 요율 필드 | |
| 락업 | 락업 시점 | 요청(접수) 시점 — 접수 트랜잭션이 곧 락업 트랜잭션. 락업 실패 = 접수 실패 |
| 구현 | 컬럼 없음. status IN ('REQUESTED','APPROVED') 파생 집계 | |
| 체인 | BSC 메인 + TRON 폴백 · 접수 시 단일 체인 확정 · 분할 없음 · 회수도 동일 체인 | |
| 동시성 | 파트너 × 체인 비관적 락 — 기존 requestWithdrawal 프로토콜 재사용 | |
| 오더 | 상태 | 6종 — 요청 · 승인 · 완료 · 반려 · 회수 · 실패 |
| 유효시간 | 1시간 · 초과 시 실패(EXPIRED) · TTL 연장 가능(팀 1회 / 운영자 무제한) | |
| 정렬 | 접수순(FIFO) | |
| 출금팀 | 구성 | 1팀 · 주야간 교대 · 정산은 팀 단일 계정 · 전 구간 단독 처리 |
| 지급 타이머 | 20만~100만 30/60분 · 100만~500만 60/120분 (지연인출제도 반영) | |
| 보고 | 증빙 필수 + 계좌 뒷4자리 대조 (불일치 시 차단) | |
| 예치금 | 팀이 자기 계좌에 입금 기준 약 30% 자율 유지 — 시스템 관리 대상 아님 | |
| 정산 | 처리 | 원자적 3분할 (전부 or 전무) · 건별 즉시 내부 적립 |
| 팀 인출 | 실시간 — 지급 확인 후 이상 없으면 즉시 인출 가능 (홀드 없음). 사후 반송 시에는 마이너스 상계로 회수 (다음 정산분에서 자동 차감) | |
| 가스비 | 팀 인출 시 크립토먼츠 부담 (기존 Relayer 대납 재사용) | |
| 채널 | 정보 분리 | API = 사유 코드만 / 잔액·내역은 관리페이지 + 파트너별 텔레그램 |
| 제외 | 범위 밖 | LP ❌ · eKYC ❌ · 재배정 ❌ · 오더 선점락 ❌ · 고액 2인 확인 ❌ |
| 주체 | 내주는 것 | 받는 것 | 접점 |
|---|---|---|---|
| 파트너사 2개 → 확장 | USDT (접수 시 락업) | 유저 출금 대행 완료 | 신청·조회·가용성 API / webhook / 관리페이지 / 텔레그램 |
| 유저(수취인) | — | 원화 신청액 전액 | 파트너 앱·웹 (크립토먼츠와 직접 접점 없음) |
| 출금팀 1팀 · 주야간 | 유저에게 원화 지급 | USDT + 0.5% (팀 계정 적립) | 출금팀 오더 페이지 + 텔레그램 단톡방 |
| 크립토먼츠 | 중개 · 환산 · 정산 · 회수 협조 | 수수료 0.5% | 시스템 · 운영자 콘솔 |
출금팀은 partners 테이블의 내부 파트너로 등록한다 — 원장 적립과 인출이 기존 구조를 그대로 재사용한다.
원화 신청액 ÷ 빗썸 시세 = 락업 USDT · 유저는 1,000,000원 전액 수령
올림 이유 — 내림하면 정산 분배 시 극소량이 모자라 실패한다. 파트너가 아주 조금 더 부담하지만 절대 부족하지 않다.
남는 dust는 정산 시 파트너에게 원복한다.
락업 총액은 저장하지 않는다 — usdt_amount + fee_amount 로 유도 (“저장하지 않고 유도” 원칙).
| 구간 | 지급 경고 | 에스컬레이션 | 비고 |
|---|---|---|---|
| 20만 ~ 100만 미만 | 30분 | 60분 | 단독 처리 |
| 100만 ~ 500만 | 60분 | 120분 | 은행 지연인출제도(100만↑ 30분 출금제한) 반영 |
| 범위 밖 | 접수 거절 — KRW_AMOUNT_OUT_OF_RANGE(1112) | ||
| 상태 | 진입 | hold | 다음 |
|---|---|---|---|
| 요청 | 접수 + 락업 완료 | 🔒 대상 | 승인 / 반려 / 실패(만료·취소) |
| 승인 | 출금팀 승인 (잔액 검증 없음) | 🔒 대상 | 완료 / 실패 |
| 완료 | 지급 보고 + 정산 분배 | ✅ 소멸 | 회수 (사후 가능) |
| 반려 | 출금팀 거절 (사유 필수) | ↩️ 자동 해제 | 종결 |
| 실패 | 만료 · 취소 · 확인실패 · 정산실패 | ↩️ 자동 해제 | 종결 |
| 회수 | 이체 반송 → 운영자 역정산 | ↩️ 파트너 복구 | 종결 |
settlement_balances.frozen_amount 는 2026-07-02 운영에서 DROP 됐다 (교차간섭 · 드리프트).
freeze() 는 DB 쓰기가 없는 검증 전용이고 unfreeze() 는 no-op 이다.
hold 는 매번 레코드 status 에서 파생된다.
-- 파트너 USDT 가용잔액 (SettlementService.getAvailableBreakdown) available = computeBalance(ledger_entries) -- CREDIT+ADJUSTMENT − DEBIT−FEE − sumPendingGeneralWithdrawals -- 기존: withdrawals 파생 − sumPendingCollection -- 기존: collection_queue 파생 − sumPendingKrwOrderHold -- 신규 ← 이 한 항만 추가 -- KrwWithdrawOrderMapper.sumPendingHold SELECT COALESCE(SUM(usdt_amount + fee_amount), 0) FROM krw_withdraw_orders WHERE partner_id = #{partnerId} AND currency_id = #{currencyId} AND network_id = #{networkId} AND status IN ('REQUESTED', 'APPROVED');
축은 (partner_id, currency_id, network_id) 3키다. USDT는 체인마다 다른 currency_id 를 갖는다
(BSC=1 / POLYGON=2 / TRON=3) — “단일 체인 락업”이 기존 구조와 정확히 맞는다. BSC 부족 시 TRON 으로 폴백하되 한 오더는 한 체인만 쓴다.
-- 정산 트랜잭션 안에서 반드시 함께 일어나야 한다 ① status : APPROVED → COMPLETED -- hold 집계에서 빠짐 ② ledger DEBIT 기록 -- 실감소 반영 -- 둘이 갈라지면 ①만 반영 → 유령잔액 (묶이지도 차감되지도 않음) ②만 반영 → 이중차감 (hold + DEBIT 양쪽)
P2P 가 이 지점에서 두 번 사고를 냈다 — getSystemUnrealizedFeeBySource(2026-07-02 제거),
p2p_partner_locks.locked_balance(2026-08-27 제거). 신규 항 추가 시 기존 3항과 이중차감이 없는지 반드시 검증할 것.
| 영역 | 담당 | 내용 |
|---|---|---|
| 출금 화면 · 버튼 · 입력폼 | 파트너사 | 유저에게 보여주는 UI 전부. 크립토먼츠 관여 없음 |
| 유저 안내 문구 노출 | 파트너사 | 우리가 userMessage 를 내려주되, 노출 방식은 파트너 결정 |
| 출금 요청 접수 | 크립토먼츠 | API 로 요청 수신 → 검증 · 환율 각인 · 락업 · 오더 생성 |
| 상태별 응답 반환 | 크립토먼츠 | 동기 응답(HTTP) · 조회 API · 결과 webhook |
| 출금팀 처리 · 정산 | 크립토먼츠 | 오더 페이지 · 승인 · 지급 확인 · USDT 정산 |
구조는 단순하다 — 우리는 출금 요청 API 를 받고, 상태별로 API 를 반환한다. 그 앞단(유저가 무엇을 보는지)은 파트너사 영역이다.
락업이 접수 트랜잭션의 일부이므로, 락업이 실패하면 INSERT 자체가 롤백된다.
오더 레코드가 남지 않고, 오더 코드도 발급되지 않는다.
POST /api/v1/krw-withdrawals ↓ ... 검증 · 환율 각인 · 체인 선택 ... ↓ 🔒 파트너 × 체인 FOR UPDATE ↓ 가용액 검증 → 부족 ↓ 트랜잭션 롤백 — 오더 생성 안 됨 ↓ HTTP 409 KRW_PARTNER_BALANCE_SHORT (1117)
오더가 없으므로 조회해도 나오지 않고(404), webhook 도 발송되지 않는다.
파트너는 동기 응답만으로 실패를 알 수 있다 — 비동기 대기가 필요 없다.
HTTP 409 Conflict { "code": "1117", "reasonCode": "TEMPORARILY_UNAVAILABLE", "retryable": true, "retryAfterSeconds": 300, "userMessage": "현재 출금 신청이 일시 중단되었습니다. 잠시 후 다시 시도해 주세요." } // orderCode 없음 — 오더가 생성되지 않았다
| reasonCode | HTTP | retryable | 원인 |
|---|---|---|---|
TEMPORARILY_UNAVAILABLE | 409 | ✅ | 파트너 잔액 부족 · 동시 경합 · 동시진행 상한 |
SERVICE_CLOSED | 409 | ✅ | 점검시간 · 접수 중지 · 사전차단 버퍼 |
AMOUNT_OUT_OF_RANGE | 400 | ❌ | 20만 미만 / 500만 초과 |
DAILY_LIMIT_EXCEEDED | 409 | ❌ | 파트너·유저 일일 한도 |
RATE_UNAVAILABLE | 503 | ✅ | 시세 조회 실패 · 캐시 초과 · ±5% 이상치 |
DUPLICATE_ORDER_ID | 409 | ❌ | 멱등키 충돌 (다른 내용으로 재사용) |
userMessage 를 우리가 내려주는 이유 — 파트너마다 제각각 번역하다
“테더 잔액 부족” 같은 내부 사정이 유저에게 새는 것을 막는다.
파트너는 이 문자열을 그대로 쓰면 되고, 쓸지 말지는 파트너 선택이다.
| 장치 | 형태 | 내용 |
|---|---|---|
| 가용성 조회 API 신규 | API | GET /availability — available · maxAmountKrw.
파트너가 요청 전에 확인할 수 있게 제공한다 (활용 여부는 파트너 판단) |
| 가용액 임계 경고 | 텔레그램 | low_balance_alert_usdt 이하 → 파트너 채널 즉시 알림 + 소진 예측.
부족해지기 전에 충전 유도 |
| 잔액 현황 | 관리페이지 | 체인별 가용/락업중 · 최대 출금 가능액 · 부족 경고 (API 로는 주지 않음) |
| 동시 진행 상한 | 설정 | concurrent_limit_krw — 락업 폭주로 잔액이 한 번에 소진되는 것을 방지 |
-- ═══════════════════════════════════════════════════════════════════ -- │ SECTION 10: 원화 오프램프 출금 (KRW Off-ramp Withdrawal) │ -- │ v2.20 추가 (2026-09-17) │ -- ═══════════════════════════════════════════════════════════════════ -- 10-1. 원화 출금 오더 -- ★ 락업은 접수 시점에 이뤄진다. 접수 트랜잭션이 곧 락업 트랜잭션이다. -- ☠️ 잔액/락업 컬럼을 두지 않는다. frozen_amount 는 2026-07-02 DROP 됐다. -- hold 는 status IN ('REQUESTED','APPROVED') 파생이며, 상태가 바뀌면 -- 자동으로 빠진다. "원복" 이라는 별도 동작은 존재하지 않는다. CREATE TABLE krw_withdraw_orders ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_code VARCHAR(30) NOT NULL COMMENT '오더 코드 — kwo_{nanoid}', partner_id BIGINT NOT NULL COMMENT 'partners.id', partner_user_id VARCHAR(255) NOT NULL COMMENT '파트너 회원 ID', -- 금액 (KRW 원 단위 정수) krw_amount BIGINT NOT NULL COMMENT '신청 원화액 = 유저 수령액', -- 환율 각인 (접수 순간 고정) rate_krw DECIMAL(20,4) NOT NULL COMMENT '빗썸 USDT 시세', rate_at DATETIME(6) NOT NULL COMMENT '조회 시각 — 분쟁 근거', -- USDT 환산 (6자리 올림) usdt_amount DECIMAL(36,18) NOT NULL COMMENT '환산 원금 (krw ÷ rate, 올림)', fee_rate DECIMAL(10,6) NOT NULL COMMENT '적용 요율 — 퍼센트 (1.0 = 1%)', fee_amount DECIMAL(36,18) NOT NULL COMMENT '수수료 확정액 (파트너 부담)', -- ※ 락업 총액은 저장하지 않는다 — usdt_amount + fee_amount 로 유도 -- ★ 락업 대상 — 접수 시 확정 currency_id BIGINT NOT NULL COMMENT 'BSC=1 / POLYGON=2 / TRON=3', network_id BIGINT NOT NULL COMMENT '회수도 동일 체인', -- 수취 계좌 (개인정보 — 목록 마스킹) bank_code VARCHAR(20) NOT NULL, account_no VARCHAR(50) NOT NULL, account_holder VARCHAR(100) NOT NULL COMMENT '예금주 — 화면 강조, 육안 대조', partner_reference VARCHAR(255) COMMENT '파트너 주문번호 — 멱등키 원본', partner_metadata JSON COMMENT 'pass-through', -- 상태 6종 status VARCHAR(20) NOT NULL DEFAULT 'REQUESTED' COMMENT 'REQUESTED(요청) → APPROVED(승인) → COMPLETED(완료) COMPLETED → REVERSED(회수) 분기: REJECTED(반려) / FAILED(실패) ★ hold 대상 = REQUESTED, APPROVED', -- 처리자 (감사) approved_by VARCHAR(255), approved_at DATETIME(6), -- 지급 보고 (상태 아님 — 승인 구간의 시각 기록) reported_by VARCHAR(255), reported_at DATETIME(6), proof_type VARCHAR(20) COMMENT 'IMAGE / TXNO', proof_ref VARCHAR(500) COMMENT '이체확인증 경로 또는 거래번호', paid_account_last4 VARCHAR(4) COMMENT '실제 송금 계좌 뒷4자리 — 대조 근거', report_id VARCHAR(64) COMMENT '보고 멱등키', -- 정산 settled_at DATETIME(6), settle_retry_count INT NOT NULL DEFAULT 0 COMMENT '★정산 재시도 횟수 (최대 5회)', -- 회수 (완료 이후 사후) reversed_at DATETIME(6), reverse_reason VARCHAR(500), -- 종결 closed_at DATETIME(6), close_reason VARCHAR(50) COMMENT 'AGENT_REJECTED / EXPIRED / CANCELLED_BY_PARTNER / CANCELLED_BY_ADMIN / PAYMENT_MISMATCH / SETTLE_FAILED / REVERSED', incident_note TEXT COMMENT '사고 메모 — 회수는 시스템 밖 트랙', expire_at DATETIME(6) NOT NULL COMMENT '접수 + TTL', extend_count INT NOT NULL DEFAULT 0 COMMENT 'TTL 연장 횟수', created_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6), updated_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), -- 멱등키 — withdrawals.idem_key 패턴 그대로 -- ★ 종결 상태 목록을 애플리케이션에서 다시 열거하지 말 것. 이 CASE 가 유일한 정의다. idem_key VARCHAR(255) GENERATED ALWAYS AS ( CASE WHEN status IN ('REJECTED','FAILED','REVERSED') THEN NULL ELSE partner_reference END ) STORED, UNIQUE KEY uk_kwo_code (order_code), UNIQUE KEY uk_kwo_partner_idem (partner_id, idem_key), KEY idx_kwo_hold (partner_id, currency_id, network_id, status), -- hold 집계 전용 KEY idx_kwo_status_created (status, created_at), KEY idx_kwo_expire (status, expire_at), KEY idx_kwo_settle_pending (status, reported_at, settled_at), -- ★정산 대기 조회 KEY idx_kwo_partner_user (partner_id, partner_user_id, created_at) ) COMMENT '원화 오프램프 출금 오더 — 락업은 접수 시점, hold 는 status 파생'; -- 10-2. 파트너별 원화 출금 설정 CREATE TABLE partner_krw_withdraw_configs ( id BIGINT AUTO_INCREMENT PRIMARY KEY, partner_id BIGINT NOT NULL, is_enabled TINYINT(1) NOT NULL DEFAULT 0, fee_rate DECIMAL(10,6) NOT NULL DEFAULT 1.000000 COMMENT '총 요율 %', system_fee_rate DECIMAL(10,6) NOT NULL DEFAULT 0.500000 COMMENT '크립토먼츠 몫 %', min_amount_krw BIGINT NULL COMMENT 'NULL = 전역 설정', max_amount_krw BIGINT NULL, daily_limit_krw BIGINT NULL, user_daily_limit_krw BIGINT NULL, user_daily_limit_count INT NULL, concurrent_limit_krw BIGINT NULL COMMENT '★동시 진행(hold) 상한 — 락업 폭주 방지', low_balance_alert_usdt DECIMAL(36,18) NULL COMMENT '★가용액 임계 — 이하면 사전 경고', created_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6), updated_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE KEY uk_pkwc_partner (partner_id) ) COMMENT '파트너별 원화 출금 설정 — 요율·한도·임계';
컨벤션 준수 — ENUM 미사용(VARCHAR+COMMENT) · FK 없음 · DATETIME(6) ·
KRW=BIGINT / USDT=DECIMAL(36,18) / 환율=DECIMAL(20,4) / 요율=DECIMAL(10,6) 퍼센트 ·
코드 {prefix}_{nanoid} · 인덱스 uk_/idx_ 테이블 약어 접두.
원장 참조는 단일 축으로 고정 — reference_type='KRW_WITHDRAW' + reference_id=krw_withdraw_orders.id.
p2p_settlements 는 두 ID 공간을 섞어 파트너 경계를 넘는 조인 사고를 냈다.
requestWithdrawal 프로토콜 재사용[락 밖] ① 멱등 검사 (partner_reference) ← 최우선. 뒤에 두면 중복이 다른 오류로 먼저 실패 ② 서비스 게이트 (점검시간 · 접수중지 · 사전차단 버퍼) ③ 금액 범위 (파트너 설정 > 전역 설정) · 일일/유저 한도 ④ 시세 조회 → ±5% 이상치 필터 → 각인 ← 캐시 5분, 초과 시 거절. 임의 추정 금지 ⑤ 체인 후보 스캔 (BSC 우선, 읽기 전용) ← I/O 를 락 안에 넣으면 전 파트너가 직렬 대기 [락 안 — 선택된 1개 체인만] ⑥ 🔒 lockPartnerBalanceForWithdrawal(partner, currency, network) FOR UPDATE ⑦ 연타 차단 (60초 창) ← 반드시 락 이후. 앞에 두면 동시 요청이 전부 통과 ⑧ 동시 진행 상한 검사 (concurrent_limit_krw) (2026-05-21 사고: 1초 53건) ⑨ 가용액 검증 (자기 미포함) → 부족 시 409 ⑩ INSERT (REQUESTED) ← save 를 재검증보다 먼저 (자기 레코드가 hold 에 포함되도록) ⑪ 저장 후 재검증 (자기 포함) → 음수면 롤백 ⑫ 상태 이력 + 출금팀 텔레그램
상태 가드(REQUESTED) + 만료 확인
→ UPDATE status=APPROVED,
approved_by, approved_at
→ 상태 이력
이미 락업돼 있으므로 잔액을 보지 않는다. 승인은 “이 건을 처리하겠다”는 선언이다.
① 상태 가드 (APPROVED 만) ② report_id 멱등 — 재시도 시 기존 결과 반환 (에러 던지면 또 누른다) ③ 증빙 필수 (proof_type + proof_ref) ④ 계좌 뒷4자리 대조 → 불일치 409 ⑤ UPDATE reported_*, proof_*, last4 ⑥ 곧바로 정산 트리거
@Transactional // 전부 or 전무 ① 상태 가드 (APPROVED + reported_at NOT NULL) ② settlementService.debit (파트너, usdt + fee, KRW_WITHDRAW, order.id) ③ settlementService.credit(출금팀, usdt + 팀몫, KRW_WITHDRAW, order.id) ④ settlementService.recordFee( 시스템몫, KRW_WITHDRAW, order.id) ⑤ UPDATE status=COMPLETED, settled_at ← 팀 인출은 즉시 가능 ⑥ (트랜잭션 밖) webhook + 텔레그램 ← 알림 실패가 자금 트랜잭션을 깨지 않게 try/catch
⚠ 정산 실패 시 — 트랜잭션이 롤백되어 상태는 APPROVED 로 남는다.
지급은 이미 끝났으므로 KrwSettleRetryJob 이
status='APPROVED' AND reported_at IS NOT NULL AND settled_at IS NULL 을 주워 재시도한다.
UPDATE status = REJECTED | FAILED, closed_at, close_reason → hold 집계에서 자동 제외 → 별도 "원복" 코드는 존재하지 않는다 → webhook + 파트너 텔레그램
@Transactional ① 가드 (COMPLETED + reversed_at IS NULL) ② 출금팀 DEBIT (usdt + 팀몫 회수) ③ 시스템 수수료 반환 (ADJUSTMENT) ④ 파트너 CREDIT (전액 복구) ⑤ UPDATE status=REVERSED, reversed_at
open-api — 파트너 서버 연동 (HMAC 3헤더)| Method | Path | 설명 |
|---|---|---|
| GET | /api/v1/krw-withdrawals/availability 신규 |
가용성 조회 — available · maxAmountKrw · reason.
파트너가 요청 전 확인용으로 선택 사용. 잔액은 주지 않는다 |
| POST | /api/v1/krw-withdrawals |
신청 → 201 / 멱등 재요청 200 / 잔액부족 409 |
| GET | /api/v1/krw-withdrawals/{orderCode} | 단건 조회 — 정본 |
| GET | /api/v1/krw-withdrawals?partnerReference= |
응답 유실 복구 — 404 = 접수 안 됨, 재시도 안전 |
| GET | /api/v1/krw-withdrawals | 목록 XPage |
// POST 요청 { "orderId": "P20260917-001", // 멱등키 (빈문자 → null 정규화) "userId": "user_12345", "krwAmount": 1000000, // 원 단위 정수 "bankCode": "004", "accountNo": "12345678901234", "accountHolder": "홍길동", "metadata": "{...}" } // pass-through // 응답 — ❌ 잔액 정보 없음 { "orderCode": "kwo_a1b2c3d4", "status": "PROCESSING", "krwAmount": 1000000, "estimatedMinutes": 30 }
상태 매핑 (내부 6종 → 파트너 3종) — 요청·승인 → PROCESSING /
완료 → COMPLETED / 반려·실패·회수 → FAILED + reasonCode
partner-api — 콘솔 (세션 + OTP)| Method | Path |
|---|---|
| GET | /api/partner/krw-withdrawals 목록 |
| GET | /api/partner/krw-withdrawals/{code} 상세 |
| POST | .../{code}/cancel OTP — 요청 상태만 |
| GET | .../balance-status — 체인별 잔액·hold·최대 가능액 |
admin-api — 출금팀 · 운영자| Path | 권한 |
|---|---|
GET /api/admin/krw-withdraw-orders | 팀 |
GET .../dashboard | 팀 |
POST .../{code}/approve | 팀 |
POST .../{code}/reject | 팀 — 사유 필수 |
POST .../{code}/report | 팀 — 증빙 + 뒷4자리 |
POST .../{code}/extend 신규 | 팀 1회 / 운영자 ∞ |
POST .../{code}/cancel | 운영자 |
POST .../{code}/reverse | 운영자 전용 |
GET .../reconcile | 운영자 — 대사 |
모든 매핑에 한글 name = + Javadoc @group/@auth/@response/@header —
이게 없으면 Axim REST 문서 생성기가 파트너 문서를 만들지 못한다.
목록은 XPage<T> + @XPaginationDefault(column="id") XPagination + from/to/status/search.
| eventType | eventId | 트리거 |
|---|---|---|
KRW_WITHDRAWAL_COMPLETED | evt_kwo_{code}_ok | 정산 완료 |
KRW_WITHDRAWAL_FAILED | evt_kwo_{code}_fail | 반려·실패 (reason 구분) |
KRW_WITHDRAWAL_REVERSED | evt_kwo_{code}_rev | 회수(역정산) |
// 실패 페이로드 — 유저 문구를 우리가 내려준다 { "eventType": "KRW_WITHDRAWAL_FAILED", "eventId": "evt_kwo_a1b2c3d4_fail", // ★ 멱등 처리용 — 기존 출금엔 없는 필드 "reasonCode": "TEMPORARILY_UNAVAILABLE", "retryable": true, "retryAfterSeconds": 300, "userMessage": "현재 출금 신청이 일시 중단되었습니다. 잠시 후 다시 시도해 주세요." }
| reasonCode | retryable | 성격 |
|---|---|---|
TEMPORARILY_UNAVAILABLE | ✅ | 잔액 부족 · 동시 경합 |
SERVICE_CLOSED | ✅ | 점검시간 · 접수 중지 |
AGENT_REJECTED | ✅ | 출금팀 반려 |
EXPIRED | ✅ | 시간 초과 |
AMOUNT_OUT_OF_RANGE | ❌ | 금액 수정 필요 |
DAILY_LIMIT_EXCEEDED | ❌ | 내일 다시 |
구현 규칙 — 페이로드는 WebhookPayloadBuilder 안에서만 생성한다(2026-08-07 통합 이력, 호출부 JSON 조립 금지) ·
서명식 partnerId|txHash|amount|timestamp 불변(txHash는 "") ·
발송은 notificationService.send(...) 한 줄 + try/catch ·
재시도 30s/60s/300s/900s/3600s 5회는 기존 인프라 그대로.
텔레그램 — 발송은 Spring 전담(Node 봇은 수신만). TelegramMessageFormatter 에
krwOrderRequested / Approved / Rejected / Paid / Settled / Reversed / LowBalance 추가.
출금팀 단톡방 + partner_telegram_configs 파트너별 채널.
KRW_ORDER_NOT_FOUND (1110) KRW_ORDER_STATUS_INVALID (1111) KRW_AMOUNT_OUT_OF_RANGE (1112) KRW_SERVICE_CLOSED (1113) KRW_RATE_UNAVAILABLE (1114) KRW_RATE_DEVIATION (1115) KRW_ORDER_EXPIRED (1116) KRW_PARTNER_BALANCE_SHORT (1117) ← 접수 거절 KRW_PAYMENT_ACCOUNT_MISMATCH (1118) KRW_PROOF_REQUIRED (1119) KRW_CANCEL_NOT_ALLOWED (1120) KRW_REVERSE_NOT_ALLOWED (1121) KRW_DAILY_LIMIT_EXCEEDED (1122) KRW_CONCURRENT_LIMIT_EXCEEDED (1123)
360번대는 4개(366~369)만 남아 온체인 출금용으로 남긴다. 결번 재사용 금지 · 각 상수 Javadoc 에 사고 근거·날짜·유사 코드와의 구분 기재.
| Job | 주기 | 역할 |
|---|---|---|
KrwOrderExpireJob | 1분 | TTL 초과 → 실패(EXPIRED) |
KrwOrderRemindJob | 5분 | 리마인드 · 지급 지연 경고/에스컬 |
KrwSettleRetryJob 신규 | 5분 | 정산 실패 건 재시도 (최대 5회) — 지급은 끝났는데 정산만 실패한 건 |
KrwLowBalanceJob 신규 | 10분 | 가용액 임계 경고 + 소진 예측 (스로틀 적용) |
KrwReconcileJob | 일 1회 | 대사 — 잔액변동 vs 정산합계 + hold 정합성 |
system_settings INSERT 블록에 함께 추가⚠ 코드에서 조회하는데 DDL 씨드에 없는 키가 이미 7개 드리프트돼 있다. 신규 키는 반드시 DDL INSERT 블록에 함께 넣고, 코드에는 상수 + 폴백 기본값을 둔다.
화면은 cryptoments-admin repo 에 만든다 (본 repo 에는 추가하지 않는다).
설계서는 ADMIN_CONSOLE_UI_HANDOFF.md 의 “엔드포인트 표 / 목록 컬럼 표 / 액션 표” 3단 포맷을 따른다.
admin-ui 신규 메뉴 「원화 출금」| 오더 | 파트너 | 원화액 | 필요 USDT | 체인 | 남은시간 | 상태 |
|---|---|---|---|---|---|---|
| kwo_a1b2 | A사 | 1,000,000 | 706.29 | BSC | 48분 | 요청 |
| kwo_c3d4 | B사 | 500,000 | 353.15 | BSC | 55분 | 요청 |
| kwo_e5f6 | A사 | 800,000 | 565.03 | BSC | ⚠ 8분 | 승인 |
잔액부족 배지는 없다 — 접수 단계에서 이미 걸러진다. 대신 남은시간(TTL) 이 핵심 컬럼이다.
상세(SlideDrawer) : 계좌·금액 [복사] 버튼(수기 입력 제거) · 예금주명 강조 · 남은시간 ·
[승인] [반려] [시간 연장] · 지급완료 보고 폼(증빙 + 뒷4자리 입력·대조, 불일치 시 버튼 비활성)
partner-ui| 체인 | 가용 | 락업중(hold) | 합계 |
|---|---|---|---|
| BSC | 494.00 | 706.29 | 1,200.29 |
접수 거절이 나기 전에 미리 알리는 것이 이 화면의 핵심 목적이다.
메뉴는 router/index.ts 와 constants.ts 의 MENU_ITEMS 둘 다 추가.
admin-ui| 화면 | 기능 |
|---|---|
| 전체 오더 조회 | 상태·파트너·기간 필터 |
| 회수(역정산) | 완료 → 반송 처리. ConfirmDialog 2단(금액·파트너 명시) |
| 강제 취소 | 운영자 권한 |
| 일일 대사 | 잔액변동 vs 정산합계 + hold 집계 정합성 |
| 설정 | system_settings 키 편집 |
| 사고 메모 | incident_note |
UI 규칙 — 목록은 useDataTable composable 로만 · 공통 컴포넌트
(DataTable/StatusBadge/AmountDisplay/DateDisplay/EmptyState/LoadingSkeleton) 사용 ·
서비스 레이어는 얇은 URL 매핑만(에러는 인터셉터 위임, catch 에서 mock 반환 금지) ·
20건/페이지, XPagination 1-based, 기본 created_at DESC ·
로딩은 스피너가 아니라 스켈레톤 · 위험 액션은 ConfirmDialog 필수 ·
계좌·예금주는 목록 마스킹(상세에서만 전체).
| 구간 | 케이스 | 처리 | 상태 |
|---|---|---|---|
| A 접수 · 락업 | 중복 order_id | 멱등 — 기존 오더 반환 | ✅ |
| 금액 범위 밖 / 한도 초과 | 거절 (사유 코드) | ✅ | |
| 시세 조회 실패 | 5분 캐시 허용, 초과 시 거절 · 임의 추정 금지 | ✅ | |
| 시세 이상치(스파이크) | 직전 대비 ±5% 초과 거절 + 알림 | ✅ | |
| 점검시간 / 접수중지 / 사전차단 버퍼 | SERVICE_CLOSED | ✅ | |
| 잔액 부족 | 409 + 롤백 — 오더 생성 안 됨. 조회 404 · webhook 미발송.
retryable + userMessage 반환 | ✅ | |
| 동시 신청 경합 | 파트너×체인 FOR UPDATE 직렬화 + save 후 재검증 | ✅ | |
| 락업 폭주 | concurrent_limit_krw 동시 진행 상한 | ✅ | |
| B 대기 | TTL 1시간 초과 | 실패(EXPIRED) — hold 자동 해제 | ✅ |
| 처리 중 시간 부족 | TTL 연장 (팀 1회 / 운영자 ∞) | ✅ | |
| 파트너 취소 | 요청 상태만 가능 | ✅ | |
| C 처리 | 승인 → 지급 → 보고 | 정상 경로 | ✅ |
| 반려 | 사유 필수 → hold 자동 해제 + 파트너 통지 | ✅ | |
| 승인 후 방치 | 금액대별 경고 → 에스컬 → 수동 종결 (자동 실패 없음) | ✅ | |
| 다른 계좌로 이체 | 뒷4자리 대조로 보고 차단 → 미지급 유지 → 재송금. 출금팀 책임 + 크립토먼츠 오프라인 회수 협조 | ✅ | |
| 중복 보고 | 멱등 3중(프론트·report_id·상태가드) — 기존 결과 반환 | ✅ | |
| D 정산 · 회수 | 정상 정산 | 원자적 3분할 + hold 해제 동시 | ✅ |
| 이체 금액 불일치 | 정산 보류 → 차액 이체 or 수동 실패. 자동 정산 금지 | ✅ | |
| 이체 반송 / 유저 미수령 | 회수(역정산) — 팀 잔액 부족 시 마이너스 상계(다음 정산분에서 차감) | ✅ | |
| 정산 중 장애 | 트랜잭션 롤백 → 승인 상태 복귀 · 정체 감지 알림 + 재개 | ✅ | |
| E 연동 | webhook 유실 | 조회 API 가 정본 — 연동문서에 명시 | ✅ |
| webhook 중복 수신 | eventId 로 파트너가 멱등 처리 | ✅ | |
| 파트너 USDT 충전 | 기존 입금 플로우 재사용 · confirm 지연은 “확인 중” 표시 | ✅ |
상태를 6종으로 줄이면서 출금중 을 없앴다. 정산 트랜잭션이 실패하면 롤백되어
상태가 승인 으로 남는데 — 유저는 이미 원화를 받았다.
출금팀은 USDT 를 못 받고, 파트너 락도 안 풀린 채 무한 방치된다.
게다가 지급 지연 타이머가 또 울려 출금팀이 “이미 보냈는데 왜 경고가 오지” 하게 된다.
settle_retry_count 컬럼 +
② 정산 대기 판별 status='APPROVED' AND reported_at IS NOT NULL AND settled_at IS NULL +
③ KrwSettleRetryJob(5분, 최대 5회) 재시도 → 초과 시 관리자 에스컬레이션 +
④ 지급 지연 타이머 조건에 reported_at IS NULL 추가 (이게 빠지면 오경보)정산 직후 출금팀이 USDT 를 인출하는데, 은행 반송은 며칠 뒤 올 수 있다. 그때 역정산하려 해도 팀 잔액이 이미 비어 있으면 파트너에게 돌려줄 방법이 없다.
23:10 접수 → 락업 → 00:10 만료. 그런데 23:30부터 점검으로 송금 불가 → 유저는 못 받고 재신청.
pre_maintenance_block_minutes(기본 60분).
점검시간 시작 60분 전부터 접수 차단해 TTL 안에 처리 불가능한 오더가 생기지 않게 한다.승인 시 락업일 때는 처리되는 만큼만 묶였는데, 이제 접수되는 족족 1시간씩 묶인다.
concurrent_limit_krw 동시 진행 상한.
초과 시 KRW_CONCURRENT_LIMIT_EXCEEDED 로 거절해 잔액을 보호한다.BSC + TRON 둘 다 사용으로 확정됐다. BSC 가 부족하면 TRON 으로 락업되므로, 같은 파트너인데 오더마다 체인이 다를 수 있다. 운영자가 “왜 이 건만 트론이지” 하고 헷갈린다.
반려·실패되면 idem_key 가 NULL 이 되어 같은 orderId 로 재신청이 가능하다.
그런데 새 orderCode 가 발급되고 환율도 새로 잡힌다.
파트너가 모르면 “같은 주문번호인데 왜 금액이 다르지” 한다.
isRetry: true 와 새 환율을 함께 반환.파트너가 취소를 누르는 순간 출금팀이 승인할 수 있다. 설계에는 가드가 있지만 문서에 명시가 빠져 있었다.
409 KRW_CANCEL_NOT_ALLOWED,
취소가 먼저면 승인은 409 KRW_ORDER_STATUS_INVALID.접수 시 각인 → 출금팀이 나중에 승인. 시세가 불리하면 반려하면 되지만, 반려가 잦으면 유저 경험이 나빠진다.
estimatedMinutes 응답값의 근거) ·
만료율(TTL 1시간이 현실적인지 판단)가용액이 임계 근처에서 오르내리면 텔레그램이 분당 수십 건 갈 수 있다.
SystemAlertNotifier 의 스로틀을 재사용한다.| # | 제안 | 상태 | 이유 |
|---|---|---|---|
| 1 | 정산 재시도 배치 신규 | 반영 | 지급은 끝났는데 정산만 실패한 건을 주워 5회 재시도 → 초과 시 관리자 |
| 2 | 마이너스 상계 신규 | 반영 | 실시간 인출의 전제 — 반송 시 회수할 유일한 수단 |
| 3 | 지급 타이머 조건 수정 | 반영 | reported_at IS NULL 추가 — 보고한 건에 오경보가 가지 않게 |
| 4 | 가용성 조회 API | 반영 | 파트너가 요청 전 확인 — 불필요한 409 감소 (사용 여부는 파트너 판단) |
| 5 | 동시 진행 상한 | 반영 | 접수 시 락업의 부작용(잔액 소진) 차단 |
| 6 | 가용액 임계 사전 경고 | 반영 | 거절 나기 전에 충전 유도 |
| 7 | 점검시간 사전 차단 버퍼 | 반영 | TTL 안에 처리 불가능한 시간대 접수 차단 |
| 8 | TTL 연장 액션 | 반영 | 만료로 죽이는 것보다 저렴 |
| 9 | webhook eventId | 반영 | 기존 출금의 구멍(멱등 불가)을 반복하지 않는다 |
| 10 | userMessage 하달 | 반영 | 내부 사정(“테더 부족”)이 유저에게 새지 않게 |
| 11 | 출금팀 = 내부 파트너 등록 | 반영 | 원장·인출을 기존 구조 그대로 재사용 |
| 12 | 체인별 표시·대사 | 검토 | BSC/TRON 폴백으로 오더마다 체인이 갈리므로 |
| 13 | isRetry 응답 | 검토 | 멱등키 재사용 시 새 환율 혼란 방지 |
| 14 | 모니터링 지표 4종 | 검토 | 거절율·반려율·처리시간·만료율 |
| 15 | 알림 스로틀 | 검토 | 잔액 경고 폭주 방지 |
| 16 | 대사에 hold 정합성 포함 | 반영 | 파생 hold 모델의 유일한 감시 수단 |
| 단계 | 내용 | 비고 |
|---|---|---|
| 0 | DDL 확정 + 운영 적용 | SECTION 10 · 헤더 v2.20 · system_settings 씨드 동시 |
| 1 | common — Entity · Repository · Mapper · ErrorCodes | 컨벤션 준수 (XEntity/XColumn/XRepository) |
| 2 | core — KrwWithdrawService + 가용잔액 산식 확장 | ⚠ 위험 구간 — 기존 출금·P2P·집금과 이중차감 검증 필수 |
| 3 | admin-api — 출금팀 · 운영자 API | |
| 4 | open-api + partner-api | availability API 포함 |
| 5 | Webhook · 텔레그램 포매터 | WebhookPayloadBuilder 확장 + eventId |
| 6 | scheduler Job 4종 | |
| 7 | UI — cryptoments-admin repo | admin-ui · partner-ui |
| 8 | 연동 문서 · 약관 | guide-ui EN 본문 + KO 사전 동시 갱신 |
| 원칙 | 적용 |
|---|---|
| ① 저장하지 않고 유도 | 락업 총액·hold·정산 누적을 컬럼으로 만들지 않는다. 만들면 두 번째 진실이 된다 |
| ② 애매하면 사람이 판단 | 분쟁·금액 불일치·이상거래는 자동 판정 금지 — 오판 비용이 훨씬 크다 |
| ③ 숫자는 시스템이, 실행은 사람이 | 환율·원화액·필요 USDT 를 시스템이 확정 → 출금팀은 표시된 금액 그대로 이체 |
| ④ 값은 전부 설정으로 | 시간·한도·요율·임계 — 코드 수정 없이 운영이 조정 |
| ⑤ 기존 것 재사용 | 시세 · 텔레그램 · 원장 · webhook 파이프라인 · 상태이력 · Relayer 대납 |
| ⑥ 자동 실패는 락 없는 구간에만 | 만료는 자동, 승인 이후는 전부 사람이 종결 (돈이 이미 나갔을 수 있다) |
| ⑦ 내부 사정을 응답에 싣지 않는다 | 잔액·체인 내역은 API 로 주지 않는다. 사유 코드 + userMessage 까지가 우리 몫 |
| ⑧ 책임 경계를 지킨다 | 우리는 요청 접수 · 상태별 응답 · 처리 · 정산. 파트너 화면·버튼은 파트너 영역 |