feat(pay): 同源收款测试页 GET /_test(Phase E 手动闭环验证)

pay-server 自带 /_test 测试页:创建订单→显示收款地址+精确金额(可复制)
+过期倒计时→轮询状态到 paid。同源、无 CORS/CSP 摩擦,不碰营销站安全头;
纯调 POST /order + GET /order/{id},无私钥无密钥。生产收银台后续走独角数卡门面。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-09 15:51:10 +08:00
parent 7e7381b209
commit 5fd0d3c148
11 changed files with 2413 additions and 69 deletions
+1
View File
@@ -21,6 +21,7 @@ func New(svc *pay.Service, corsOrigins string) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /order", h.createOrder)
mux.HandleFunc("GET /order/{orderNo}", h.getOrder)
mux.HandleFunc("GET /_test", h.testPage) // same-origin manual test harness (Phase E)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
+223
View File
@@ -0,0 +1,223 @@
package httpapi
import "net/http"
// testPage serves a self-contained, same-origin manual test page at GET /_test.
// Same origin as the API → no CORS, and pangolin-pay sets no restrictive CSP,
// so its inline JS/CSS run freely. This is a throwaway harness for the
// money-critical Phase E loop (create order → pay exact amount → watch paid),
// NOT the production checkout (that lives on the storefront). It only creates
// orders and polls status — no secrets, no keys.
func (h *Handler) testPage(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write([]byte(testPageHTML))
}
const testPageHTML = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>pangolin-pay · 收款测试</title>
<style>
:root{
--bg:#0b0f14; --card:#131a22; --card2:#0f151c; --line:#223040;
--fg:#e6edf3; --muted:#8899a6; --accent:#3fb950; --accent2:#58a6ff;
--warn:#d29922; --err:#f85149; --radius:14px;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);
font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;
display:flex;justify-content:center;padding:32px 16px;}
.wrap{width:100%;max-width:520px}
h1{font-size:19px;margin:0 0 4px;font-weight:650}
.sub{color:var(--muted);font-size:13px;margin:0 0 20px}
.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:20px;margin-bottom:16px}
label{display:block;font-size:12px;color:var(--muted);margin:12px 0 5px}
input{width:100%;background:var(--card2);border:1px solid var(--line);border-radius:10px;
color:var(--fg);padding:11px 12px;font-size:15px;outline:none;font-family:inherit}
input:focus{border-color:var(--accent2)}
button{width:100%;margin-top:18px;background:var(--accent);color:#04130a;border:0;border-radius:10px;
padding:13px;font-size:15px;font-weight:650;cursor:pointer}
button:disabled{opacity:.5;cursor:not-allowed}
button.ghost{background:transparent;border:1px solid var(--line);color:var(--fg);font-weight:500;margin-top:10px}
.row{display:flex;align-items:center;gap:8px}
.kv{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;
padding:11px 0;border-bottom:1px solid var(--line)}
.kv:last-child{border-bottom:0}
.kv .k{color:var(--muted);font-size:13px;white-space:nowrap}
.kv .v{text-align:right;word-break:break-all;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px}
.amt{font-size:26px;font-weight:700;color:var(--accent);font-family:ui-monospace,monospace}
.amt small{font-size:14px;color:var(--muted);font-weight:400;margin-left:4px}
.copy{background:var(--card2);border:1px solid var(--line);color:var(--accent2);
border-radius:7px;padding:3px 9px;font-size:12px;cursor:pointer;flex:none}
.copy:active{background:var(--line)}
.badge{display:inline-block;padding:3px 11px;border-radius:20px;font-size:12px;font-weight:600}
.b-pending{background:#3a2d0a;color:var(--warn)}
.b-paid{background:#0f2a17;color:var(--accent)}
.b-expired{background:#2a1215;color:var(--err)}
.note{font-size:12px;color:var(--muted);margin-top:14px;line-height:1.6}
.err{color:var(--err);font-size:13px;margin-top:12px;min-height:18px}
.hidden{display:none}
.warnbox{background:#3a2d0a33;border:1px solid #d2992244;border-radius:10px;
padding:11px 13px;font-size:12.5px;color:var(--warn);margin-top:14px;line-height:1.6}
code{background:var(--card2);padding:1px 5px;border-radius:5px;font-size:12px}
</style>
</head>
<body>
<div class="wrap">
<h1>pangolin-pay · 收款测试</h1>
<p class="sub">USDT-TRC20 · 单地址 + 唯一金额 · 手动测试页(非生产收银台)</p>
<div class="card" id="form-card">
<label>用户标识 user_ref</label>
<input id="user_ref" autocomplete="off">
<label>商品 SKU</label>
<input id="sku" value="pro-year" autocomplete="off">
<label>价格(USDT</label>
<input id="amount" value="0.1" inputmode="decimal" autocomplete="off">
<button id="create">创建订单</button>
<div class="err" id="form-err"></div>
</div>
<div class="card hidden" id="order-card">
<div class="kv">
<span class="k">订单号</span>
<span class="v" id="o-no"></span>
</div>
<div class="kv">
<span class="k">状态</span>
<span class="v"><span class="badge" id="o-status"></span></span>
</div>
<div style="text-align:center;padding:14px 0 6px">
<div style="color:var(--muted);font-size:12px;margin-bottom:4px">请支付<b style="color:var(--warn)">精确金额</b>(多付少付都不匹配)</div>
<div class="amt" id="o-amount"></div>
<button class="copy" id="copy-amt" style="margin-top:8px">复制金额</button>
</div>
<div class="kv">
<span class="k">收款地址</span>
<span class="row" style="justify-content:flex-end">
<span class="v" id="o-addr"></span>
<button class="copy" id="copy-addr">复制</button>
</span>
</div>
<div class="kv">
<span class="k">TxID</span>
<span class="v" id="o-tx">—</span>
</div>
<div class="kv">
<span class="k">过期倒计时</span>
<span class="v" id="o-ttl"></span>
</div>
<div class="warnbox">
发往此地址、且金额<b>正好等于</b>上面数字的 USDT-TRC20 转账,会在 watcher 下一轮轮询后自动标记
<code>paid</code>。金额不符 → 进 orphan 需人工对账。<br>
⚠️ 需 <code>TRONGRID_API_KEY</code> 已配置,否则 watcher 被限流可能侦测很慢。
</div>
<button class="ghost" id="reset">新建另一单</button>
<div class="err" id="order-err"></div>
</div>
<p class="note">
地址模型:所有订单收到<b>同一地址</b>,靠<b>唯一金额</b>(基准价 + 微尾数 ≤0.01U)区分。
本页仅调 <code>POST /order</code> 与 <code>GET /order/{id}</code>,无私钥、无密钥。
</p>
</div>
<script>
(function(){
var $ = function(id){ return document.getElementById(id); };
var poll = null, ttlTimer = null, curNo = null, expiresAt = 0;
function rnd(){ return 'test-' + Math.random().toString(36).slice(2, 8); }
$('user_ref').value = rnd();
function fmtUSDT(micro){ return (micro/1e6).toFixed(6).replace(/0+$/,'').replace(/\.$/,''); }
function setStatus(s){
var el = $('o-status');
el.textContent = s;
el.className = 'badge b-' + s;
}
function copy(text, btn){
navigator.clipboard.writeText(text).then(function(){
var old = btn.textContent; btn.textContent = '已复制 ✓';
setTimeout(function(){ btn.textContent = old; }, 1200);
});
}
function tickTTL(){
if(!expiresAt){ return; }
var left = Math.max(0, Math.floor((expiresAt - Date.now())/1000));
var m = Math.floor(left/60), s = left%60;
$('o-ttl').textContent = left>0 ? (m+':'+(s<10?'0':'')+s) : '已过期';
}
function showOrder(o){
curNo = o.order_no;
expiresAt = new Date(o.expires_at).getTime();
$('o-no').textContent = o.order_no;
$('o-amount').innerHTML = fmtUSDT(o.expect_amount) + '<small>USDT</small>';
$('o-addr').textContent = o.address;
$('o-tx').textContent = o.tx_id || '—';
setStatus(o.status);
$('copy-amt').onclick = function(){ copy(fmtUSDT(o.expect_amount), this); };
$('copy-addr').onclick = function(){ copy(o.address, this); };
$('form-card').classList.add('hidden');
$('order-card').classList.remove('hidden');
tickTTL();
ttlTimer = setInterval(tickTTL, 1000);
startPoll();
}
function startPoll(){
stopPoll();
poll = setInterval(function(){
fetch('/order/' + encodeURIComponent(curNo)).then(function(r){ return r.json(); }).then(function(o){
if(o.error){ return; }
setStatus(o.status);
$('o-tx').textContent = o.tx_id || '—';
if(o.status === 'paid' || o.status === 'expired'){ stopPoll(); }
}).catch(function(){});
}, 5000);
}
function stopPoll(){ if(poll){ clearInterval(poll); poll=null; } }
$('create').onclick = function(){
$('form-err').textContent = '';
var usdt = parseFloat($('amount').value);
if(!(usdt > 0)){ $('form-err').textContent = '价格必须 > 0'; return; }
var body = {
user_ref: $('user_ref').value.trim() || rnd(),
sku: $('sku').value.trim() || 'sku',
amount: Math.round(usdt * 1e6)
};
this.disabled = true; this.textContent = '创建中…';
var btn = this;
fetch('/order', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)})
.then(function(r){ return r.json().then(function(j){ return {code:r.status, j:j}; }); })
.then(function(res){
btn.disabled = false; btn.textContent = '创建订单';
if(res.code === 201){ showOrder(res.j); }
else if(res.code === 409){ $('form-err').textContent = '该 user_ref 已有活跃订单(换一个或等它过期)'; }
else { $('form-err').textContent = '错误:' + (res.j.error || res.code); }
})
.catch(function(e){
btn.disabled = false; btn.textContent = '创建订单';
$('form-err').textContent = '网络错误:' + e.message;
});
};
$('reset').onclick = function(){
stopPoll(); if(ttlTimer){ clearInterval(ttlTimer); ttlTimer=null; }
$('order-card').classList.add('hidden');
$('form-card').classList.remove('hidden');
$('user_ref').value = rnd();
};
})();
</script>
</body>
</html>`