Files
pangolin/pay/internal/httpapi/testpage.go
wangjia bced7b35e0 fix(pay): 测试页状态改中文标签(消歧义)
pending/paid/expired 显示层映射为 等待付款/付款完成/已过期,徽章字体改 sans。
枚举值不变(程序仍用英文);发货/开通属门面(独角数卡),不在收款服务里。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 19:08:41 +08:00

257 lines
12 KiB
Go
Raw Permalink 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.
//
// 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>`