Files
pangolin/pay/internal/httpapi/testpage.go
T
wangjia 5fd0d3c148 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>
2026-07-09 15:51:10 +08:00

224 lines
9.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>`