// Node.js(18+) 가맹점 연동 예: 구매(주문 완료) · 충전(고객 고정 주소 → 게임머니) · 환전(자동 출금) // npm i express / GW_URL, GW_API_KEY, GW_WEBHOOK_SECRET, GW_PAYOUT_SECRET(서명 설정 payout.auto_secret 과 같은 값) 환경변수 // 저장소는 메모리(Map)로 단순화 — 실제는 DB 트랜잭션으로 같은 처리를 할 것 const crypto = require('crypto'); const express = require('express'); const GW = process.env.GW_URL.replace(/\/$/, ''); const KEY = process.env.GW_API_KEY; const SECRET = process.env.GW_WEBHOOK_SECRET; const PAYOUT_SECRET = process.env.GW_PAYOUT_SECRET; // 게이트웨이에 보내지 않음 const GOLD_PER_USDT = 1380; // 충전 비율: 1 USDT = 1,380 골드 (가맹점이 정함) const GOLD_PER_USDT_OUT = 1420; // 환전 비율은 따로 (같으면 시세 차익 거래 당함) const orders = new Map(); // 쇼핑몰 주문번호 → {status, paid} const gold = new Map(); // 회원 → 게임머니 const seen = new Set(); // 이미 반영한 웹훅 id (같은 알림 두 번 와도 한 번만) const withdrawals = new Map(); // 환전 번호 → {user, gold, status} async function gw(path, body) { const r = await fetch(GW + path, {method: 'POST', headers: {'Authorization': 'Bearer ' + KEY, 'Content-Type': 'application/json'}, body: JSON.stringify(body)}); const j = await r.json().catch(() => ({})); if (!r.ok) throw Object.assign(new Error(j.error || 'gateway ' + r.status), {status: r.status}); return j; } function verify(req) { // X-Gw-Timestamp + "." + 원문 본문을 HMAC-SHA256, 5분 넘은 요청 거부 const ts = req.get('X-Gw-Timestamp') || '', sig = req.get('X-Gw-Signature') || ''; if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; const want = crypto.createHmac('sha256', SECRET).update(ts + '.').update(req.body).digest('hex'); return sig.length === want.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want)); } const app = express(); const user = req => req.get('X-User') || 'demo-user'; // 실제는 로그인 세션 // 1) 구매형: 주문 만들기 → 결제 페이지(또는 checkout.js 팝업) → order.paid 웹훅에서 주문 완료 app.post('/buy', express.json(), async (req, res) => { const id = 'shop-' + crypto.randomBytes(6).toString('hex'); const o = await gw('/v1/orders', {order_id: id, amount: String(req.body.price), currency: 'KRW', customer_ref: user(req)}); orders.set(id, {status: 'pending', paid: false}); res.json({gw_id: o.id, pay_url: o.pay_url}); }); // 2) 충전형: 회원마다 고정 입금 주소 (C 방식). 언제 보내도 deposit.customer 웹훅으로 들어옴 app.post('/topup/address', async (req, res) => { res.json(await gw(`/v1/customers/${encodeURIComponent(user(req))}/address`, {})); }); // 3) 환전형: 게임머니를 먼저 빼 두고(보류) 자동 출금 요청. OTP 없음 = 서명 프로그램의 자동 출금 한도 안에서만 나감 app.post('/withdraw', express.json(), async (req, res) => { const u = user(req), g = Math.floor(Number(req.body.gold)), to = String(req.body.to || ''); if (!(g > 0) || (gold.get(u) || 0) < g) return res.status(400).json({error: '잔액 부족'}); const id = 'wd-' + crypto.randomBytes(6).toString('hex'); const micro = Math.floor(g * 1e6 / GOLD_PER_USDT_OUT), amount = (micro / 1e6).toFixed(6); if (micro < 5e6) return res.status(400).json({error: '최소 5 USDT'}); gold.set(u, gold.get(u) - g); // 보류: 실패하면 웹훅에서 돌려줌 withdrawals.set(id, {user: u, gold: g, status: 'pending'}); try { // sig = 이 서버만 아는 키로 (출금 번호|주소|금액 micro) 서명 → 게이트웨이가 털려도 주소·금액을 못 바꿈 const sig = crypto.createHmac('sha256', PAYOUT_SECRET).update(`${id}|${to}|${micro}`).digest('hex'); await gw('/v1/payouts', {to, amount, ref: id, sig}); // ref 같으면 두 번 요청해도 한 건 } catch (e) { // 4xx = 게이트웨이가 확실히 거절 → 돌려줌. 끊김·시간 초과·5xx = 접수됐을 수 있음 → 보류 유지, 같은 ref 로 다시 요청 if (e.status >= 400 && e.status < 500) { gold.set(u, gold.get(u) + g); withdrawals.delete(id); return res.status(400).json({error: e.message}); } return res.status(202).json({id, status: 'pending'}); } res.json({id}); }); app.post('/gw/webhook', express.raw({type: '*/*'}), (req, res) => { if (!verify(req)) return res.sendStatus(400); const ev = JSON.parse(req.body), d = ev.data; if (seen.has(ev.id)) return res.sendStatus(200); seen.add(ev.id); if (ev.type === 'order.paid' && orders.has(d.order_id)) { // result: exact 정상 · over 더 받음 → 상품 지급 / 덜 받고 끝나면 order.expired(result under) orders.set(d.order_id, {status: 'paid', paid: true}); } else if (ev.type === 'order.expired' && orders.has(d.order_id)) { orders.set(d.order_id, {status: d.result === 'under' ? 'underpaid' : 'expired', paid: false}); } else if (ev.type === 'deposit.customer' && !d.order_id) { // 주문 없이 고정 주소로 들어온 충전만 (주문에 붙은 입금은 order.* 로 처리 → 두 번 더하지 않게) const add = Math.floor(Number(d.amount) * GOLD_PER_USDT); gold.set(d.customer_ref, (gold.get(d.customer_ref) || 0) + add); } else if (ev.type === 'job.updated' && d.kind === 'payout' && withdrawals.has(d.ref)) { const w = withdrawals.get(d.ref); if (w.status !== 'pending') return res.sendStatus(200); if (d.status === 'done') w.status = 'done'; else if (['rejected', 'failed', 'expired', 'error'].includes(d.status)) { // 한도 초과 등 → 게임머니 돌려줌 w.status = 'refunded'; gold.set(w.user, (gold.get(w.user) || 0) + w.gold); } // unknown = 체인 확인 전 → 보류 유지, 사람이 확인 } res.sendStatus(200); }); app.listen(3000, () => console.log('http://localhost:3000'));