bced7b35e0
pending/paid/expired 显示层映射为 等待付款/付款完成/已过期,徽章字体改 sans。 枚举值不变(程序仍用英文);发货/开通属门面(独角数卡),不在收款服务里。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
257 lines
12 KiB
Go
257 lines
12 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.
|
||
//
|
||
// Styling mirrors the marketing site's warm sand/clay palette (design tokens in
|
||
// design/prototype/tokens.css, light theme default, brand = clay-500 #B96A3D).
|
||
// Values are inlined here because pangolin-pay is a separate origin and cannot
|
||
// @import the site's stylesheet; keep them in sync if the brand palette changes.
|
||
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">
|
||
<meta name="theme-color" content="#B96A3D">
|
||
<title>pangolin-pay · 收款测试</title>
|
||
<style>
|
||
:root{
|
||
--bg:#FAF8F4; --surface:#FFFFFF; --surface-2:#F2EEE7;
|
||
--fg1:#1F1C18; --fg2:#6B6253; --fg3:#8C8270;
|
||
--accent:#B96A3D; --accent-hover:#9E5630; --accent-subtle:#FAF3ED; --accent-border:#E6C7AC;
|
||
--border:#E6DFD3; --border-strong:#D2C8B8;
|
||
--success:#5B8C5A; --success-subtle:#E9F0E6;
|
||
--warning:#D69A3C; --warning-subtle:#F8EED6;
|
||
--danger:#C0533B; --danger-subtle:#F6E1DA;
|
||
--radius-lg:14px; --radius-md:10px; --radius-sm:8px;
|
||
--font-display:'Sora','Noto Sans SC',system-ui,sans-serif;
|
||
--font-sans:'Manrope','Noto Sans SC',system-ui,sans-serif;
|
||
--font-mono:'JetBrains Mono',ui-monospace,'SFMono-Regular',Menlo,monospace;
|
||
--shadow-md:0 4px 14px rgba(45,30,20,.08);
|
||
}
|
||
*{box-sizing:border-box}
|
||
body{margin:0;background:var(--bg);color:var(--fg1);
|
||
font-family:var(--font-sans);font-size:15px;line-height:1.55;
|
||
-webkit-font-smoothing:antialiased;
|
||
display:flex;justify-content:center;padding:40px 16px;}
|
||
.wrap{width:100%;max-width:480px}
|
||
h1{font-family:var(--font-display);font-size:22px;margin:0 0 5px;font-weight:700;letter-spacing:-.02em}
|
||
h1 .dot{color:var(--accent)}
|
||
.sub{color:var(--fg2);font-size:13px;margin:0 0 24px}
|
||
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);
|
||
padding:22px;margin-bottom:16px;box-shadow:var(--shadow-md)}
|
||
label{display:block;font-size:12px;color:var(--fg2);margin:14px 0 6px;font-weight:500}
|
||
label:first-child{margin-top:0}
|
||
input{width:100%;background:var(--bg);border:1px solid var(--border-strong);border-radius:var(--radius-md);
|
||
color:var(--fg1);padding:11px 13px;font-size:15px;outline:none;font-family:inherit;transition:border-color .15s}
|
||
input:focus{border-color:var(--accent)}
|
||
button{width:100%;margin-top:20px;background:var(--accent);color:#fff;border:0;border-radius:var(--radius-md);
|
||
padding:13px;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit;transition:background .15s}
|
||
button:hover:not(:disabled){background:var(--accent-hover)}
|
||
button:disabled{opacity:.5;cursor:not-allowed}
|
||
button.ghost{background:transparent;border:1px solid var(--border-strong);color:var(--fg1);font-weight:500;margin-top:12px}
|
||
button.ghost:hover:not(:disabled){background:var(--surface-2)}
|
||
button.copy{margin-top:10px;background:var(--accent-subtle);border:1px solid var(--accent-border);
|
||
color:var(--accent-hover);font-weight:600;font-size:13px;padding:9px}
|
||
button.copy:hover:not(:disabled){background:#F5E7D8}
|
||
.kv{display:flex;justify-content:space-between;align-items:center;gap:12px;
|
||
padding:12px 0;border-bottom:1px solid var(--border)}
|
||
.kv .k{color:var(--fg2);font-size:13px;white-space:nowrap;flex:none}
|
||
.kv .v{text-align:right;font-family:var(--font-mono);font-size:13px;word-break:break-all}
|
||
.amtbox{text-align:center;padding:20px 0 6px}
|
||
.amtbox .cap{color:var(--fg2);font-size:12px;margin-bottom:8px}
|
||
.amtbox .cap b{color:var(--warning)}
|
||
.amt{font-family:var(--font-mono);font-size:30px;font-weight:700;color:var(--success);letter-spacing:-.01em}
|
||
.amt small{font-size:14px;color:var(--fg3);font-weight:400;margin-left:5px;font-family:var(--font-sans)}
|
||
.addrblock{margin:6px 0 2px}
|
||
.addrblock .k{color:var(--fg2);font-size:12px;margin-bottom:6px}
|
||
.addr{font-family:var(--font-mono);font-size:14px;background:var(--surface-2);border:1px solid var(--border);
|
||
border-radius:var(--radius-md);padding:12px 14px;word-break:break-all;line-height:1.5;color:var(--fg1)}
|
||
.badge{display:inline-block;padding:4px 12px;border-radius:999px;font-size:12px;font-weight:600;font-family:var(--font-sans)}
|
||
.b-pending{background:var(--warning-subtle);color:#9A6B1F}
|
||
.b-paid{background:var(--success-subtle);color:#3E6B3D}
|
||
.b-expired{background:var(--danger-subtle);color:#8F3826}
|
||
.note{font-size:12px;color:var(--fg2);margin-top:16px;line-height:1.65}
|
||
.note b{color:var(--fg1)}
|
||
.err{color:var(--danger);font-size:13px;margin-top:12px;min-height:18px}
|
||
.hidden{display:none}
|
||
.warnbox{background:var(--warning-subtle);border:1px solid #EBD7A6;border-radius:var(--radius-md);
|
||
padding:12px 14px;font-size:12.5px;color:#7A5514;margin-top:16px;line-height:1.65}
|
||
.warnbox b{color:#5E4110}
|
||
code{background:var(--surface-2);border:1px solid var(--border);padding:1px 5px;border-radius:5px;font-size:12px;font-family:var(--font-mono)}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="wrap">
|
||
<h1>pangolin-pay <span class="dot">·</span> 收款测试</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 class="amtbox">
|
||
<div class="cap">请支付<b>精确金额</b>(多付少付都不匹配)</div>
|
||
<div class="amt" id="o-amount"></div>
|
||
</div>
|
||
<button class="copy" id="copy-amt">复制金额</button>
|
||
|
||
<div class="addrblock">
|
||
<div class="k">收款地址</div>
|
||
<div class="addr" id="o-addr"></div>
|
||
</div>
|
||
<button class="copy" id="copy-addr">复制地址</button>
|
||
|
||
<div class="kv" style="margin-top:6px">
|
||
<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> —— <code>POST /order</code> 建单拿地址+唯一金额、<code>GET /order/{id}</code>
|
||
轮询到 <code>paid</code>。地址模型:所有订单收到<b>同一地址</b>,靠<b>唯一金额</b>(基准价 + 微尾数
|
||
≤0.01U)区分。本页无私钥、无密钥。
|
||
</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(/\.$/,''); }
|
||
|
||
// pay-server 只有 pending/paid/expired 三态;显示层映射为中文(发货/开通归门面,不在此服务)。
|
||
var STATUS_LABEL = { pending:'等待付款', paid:'付款完成', expired:'已过期' };
|
||
function setStatus(s){
|
||
var el = $('o-status');
|
||
el.textContent = STATUS_LABEL[s] || 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>`
|