5fd0d3c148
pay-server 自带 /_test 测试页:创建订单→显示收款地址+精确金额(可复制)
+过期倒计时→轮询状态到 paid。同源、无 CORS/CSP 摩擦,不碰营销站安全头;
纯调 POST /order + GET /order/{id},无私钥无密钥。生产收银台后续走独角数卡门面。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
224 lines
9.5 KiB
Go
224 lines
9.5 KiB
Go
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>`
|