feat(server): /buy 自包含购买页(同源,USDT+哪吒,登录→下单→轮询开通)

单文件 HTML(内联 CSS+JS,go:embed)挂在公开 GET /buy,与 /v1/pay 同源免 CORS:
登录 -> 拉取套餐 -> 选 USDT/哪吒下单 -> 按 render_type(crypto_address/redirect/qr)
展示付款信息 -> 轮询订单直到 activated,页面隐藏/关闭时停止轮询。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
This commit is contained in:
wangjia
2026-07-11 14:52:21 +08:00
parent 1198ecc556
commit 1a7b93ed2f
3 changed files with 436 additions and 0 deletions
+410
View File
@@ -0,0 +1,410 @@
<!doctype html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>购买 Pangolin</title>
<style>
:root{color-scheme:dark}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;background:#0b0f17;color:#e8edf5;
font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
display:flex;align-items:flex-start;justify-content:center;padding:40px 16px}
.wrap{width:100%;max-width:460px}
.brand{text-align:center;margin:0 0 24px}
.brand .logo{font-size:32px;line-height:1}
.brand h1{font-size:18px;margin:8px 0 0;font-weight:600;color:#e8edf5}
.card{background:#141a24;border:1px solid #232c3a;border-radius:16px;
padding:28px 26px;box-shadow:0 8px 40px rgba(0,0,0,.4)}
.card + .card{margin-top:16px}
h2{font-size:16px;margin:0 0 18px;color:#e8edf5;font-weight:600}
label{display:block;font-size:13px;color:#93a1b5;margin:0 0 6px}
input[type=email],input[type=password],input[type=text]{
width:100%;padding:11px 13px;border-radius:10px;border:1px solid #2f3b4d;
background:#0f1622;color:#e8edf5;font-size:14px;margin-bottom:16px;outline:none}
input:focus{border-color:#2563eb}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;
padding:11px 18px;border-radius:10px;border:1px solid #2f3b4d;
background:#1b2432;color:#cdd7e4;text-decoration:none;font-size:14px;
cursor:pointer;width:100%;font-family:inherit}
.btn.primary{background:#2563eb;border-color:#2563eb;color:#fff}
.btn:disabled{opacity:.5;cursor:not-allowed}
.btn.small{width:auto;padding:8px 14px;font-size:13px}
.btn.ghost{background:transparent}
.err{background:#2a1416;border:1px solid #5a2329;color:#f3b4b9;border-radius:10px;
padding:10px 14px;font-size:13px;margin:0 0 16px;display:none}
.err.show{display:block}
.plan{border:1px solid #232c3a;border-radius:12px;padding:16px 18px;margin-bottom:12px}
.plan .head{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:4px}
.plan .name{font-size:15px;font-weight:600}
.plan .price{font-size:18px;font-weight:700;color:#7dd3fc}
.plan .price small{font-size:12px;color:#93a1b5;font-weight:400}
.plan .sub{font-size:12px;color:#93a1b5;margin-bottom:12px}
.plan .methods{display:flex;gap:10px}
.plan .methods .btn{flex:1}
.sub-hint{font-size:12px;color:#67728a;text-align:center;margin:14px 0 0}
.order-box{border:1px solid #232c3a;border-radius:10px;padding:16px;margin-bottom:16px}
.order-box .label{font-size:12px;color:#93a1b5;margin-bottom:6px}
.order-box .value{font-size:14px;word-break:break-all;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
background:#0f1622;border-radius:8px;padding:10px 12px;margin-bottom:10px}
.row{display:flex;gap:8px}
.row .btn{flex:1}
.amount{font-size:20px;font-weight:700;color:#7dd3fc;text-align:center;margin:2px 0 14px}
.status{display:flex;align-items:center;gap:8px;justify-content:center;font-size:14px;color:#93a1b5;margin-top:6px}
.dot{width:8px;height:8px;border-radius:50%;background:#f59e0b;animation:pulse 1.2s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
.done{color:#4ade80;font-size:18px;font-weight:700;text-align:center;padding:8px 0}
.hidden{display:none !important}
.tip{background:#0f1622;border:1px solid #232c3a;border-radius:10px;padding:12px 14px;
font-size:12px;color:#aab6c6;margin-bottom:14px}
.top-actions{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px}
.link{color:#7dd3fc;text-decoration:none;font-size:13px;cursor:pointer}
</style>
</head>
<body>
<div class="wrap">
<div class="brand">
<div class="logo">🛡️</div>
<h1>购买 Pangolin</h1>
</div>
<!-- 登录 -->
<div class="card" id="screen-login">
<h2>登录账号</h2>
<div class="err" id="login-err"></div>
<form id="login-form">
<label for="login-email">邮箱</label>
<input type="email" id="login-email" autocomplete="username" required>
<label for="login-pass">密码</label>
<input type="password" id="login-pass" autocomplete="current-password" required>
<button type="submit" class="btn primary" id="login-btn">登录</button>
</form>
</div>
<!-- 套餐 -->
<div class="card hidden" id="screen-plans">
<div class="top-actions">
<h2 style="margin:0">选择套餐</h2>
<span class="link" id="logout-link">退出登录</span>
</div>
<div class="err" id="plans-err"></div>
<div id="plan-list"></div>
<div class="sub-hint">下单后可选择 USDT 或哪吒支付</div>
</div>
<!-- 订单 / 收银 -->
<div class="card hidden" id="screen-order">
<div class="top-actions">
<h2 style="margin:0">完成付款</h2>
<span class="link" id="back-link">← 返回套餐</span>
</div>
<div class="err" id="order-err"></div>
<div id="pay-crypto" class="hidden">
<div class="tip">请转账<b>精确金额</b>到下方地址。金额或地址错误可能导致到账失败。</div>
<div class="order-box">
<div class="label">收款地址(USDT)</div>
<div class="value" id="crypto-address">-</div>
<button class="btn small" data-copy="crypto-address">复制地址</button>
</div>
<div class="amount" id="crypto-amount">-</div>
<div class="row">
<button class="btn small" data-copy-text="1">复制金额</button>
</div>
</div>
<div id="pay-redirect" class="hidden">
<div class="tip">点击下方按钮在新标签页完成支付,支付完成后回到本页等待自动确认。</div>
<a class="btn primary" id="redirect-link" href="#" target="_blank" rel="noopener">去支付 →</a>
</div>
<div id="pay-qr" class="hidden">
<div class="tip">使用支付 App 扫码,或复制下方内容到扫码工具。</div>
<div class="order-box">
<div class="label">扫码内容</div>
<div class="value" id="qr-content">-</div>
<button class="btn small" data-copy="qr-content">复制内容</button>
</div>
</div>
<div id="pay-unknown" class="hidden">
<div class="tip">收到未知的支付渲染方式,请刷新页面或联系客服。</div>
</div>
<div id="poll-status" class="status">
<span class="dot"></span>
<span>等待付款确认中…</span>
</div>
<div id="poll-done" class="done hidden">已开通 ✓</div>
</div>
</div>
<script>
(function(){
"use strict";
var state = {
accessToken: sessionStorage.getItem("buy_access_token") || "",
orderNo: null,
pollTimer: null,
};
var SKU_LABEL = {
pro_month: {name: "包月", sub: "31 天"},
pro_quarter: {name: "季付", sub: "92 天"},
pro_year: {name: "年付", sub: "366 天"},
};
var METHOD_LABEL = {crypto: "USDT 支付", nezha: "哪吒支付"};
function $(id){ return document.getElementById(id); }
function show(id){ $(id).classList.remove("hidden"); }
function hide(id){ $(id).classList.add("hidden"); }
function showErr(id, msg){
var el = $(id);
if (!msg) { el.classList.remove("show"); el.textContent = ""; return; }
el.textContent = msg;
el.classList.add("show");
}
function screen(name){
["screen-login", "screen-plans", "screen-order"].forEach(function(id){
if (id === name) show(id); else hide(id);
});
}
// ── API helper ──────────────────────────────────────────────────────────
function api(path, opts){
opts = opts || {};
var headers = opts.headers || {};
if (opts.body) headers["Content-Type"] = "application/json";
if (state.accessToken) headers["Authorization"] = "Bearer " + state.accessToken;
return fetch(path, {
method: opts.method || "GET",
headers: headers,
body: opts.body ? JSON.stringify(opts.body) : undefined,
}).then(function(res){
return res.json().catch(function(){ return {}; }).then(function(data){
if (!res.ok) {
var e = new Error(data.message_zh || data.message_en || ("请求失败(" + res.status + ")"));
e.status = res.status;
e.data = data;
throw e;
}
return data;
});
});
}
function logout(){
state.accessToken = "";
sessionStorage.removeItem("buy_access_token");
stopPolling();
screen("screen-login");
}
// ── 登录 ────────────────────────────────────────────────────────────────
$("login-form").addEventListener("submit", function(ev){
ev.preventDefault();
showErr("login-err", "");
var email = $("login-email").value.trim();
var pass = $("login-pass").value;
var btn = $("login-btn");
btn.disabled = true;
api("/v1/auth/login", {method: "POST", body: {
email: email,
password: pass,
device: {id: "web-buy", name: "web", platform: "web", client_version: "0"},
}}).then(function(data){
if (data.totp_required) {
showErr("login-err", "该账户已启用两步验证,暂不支持在购买页登录,请使用 App 或用户中心登录后购买。");
return;
}
if (!data.access_token) {
showErr("login-err", "登录响应异常,请重试。");
return;
}
state.accessToken = data.access_token;
sessionStorage.setItem("buy_access_token", data.access_token);
loadPlans();
}).catch(function(err){
showErr("login-err", err.message || "登录失败,请检查邮箱密码。");
}).finally(function(){
btn.disabled = false;
});
});
$("logout-link").addEventListener("click", logout);
$("back-link").addEventListener("click", function(){
stopPolling();
screen("screen-plans");
});
// ── 套餐 ────────────────────────────────────────────────────────────────
function loadPlans(){
showErr("plans-err", "");
screen("screen-plans");
api("/v1/pay/catalog").then(function(data){
renderPlans((data && data.items) || []);
}).catch(function(err){
if (err.status === 401) { logout(); return; }
showErr("plans-err", err.message || "套餐加载失败,请刷新重试。");
});
}
function renderPlans(items){
var list = $("plan-list");
list.innerHTML = "";
items.forEach(function(item){
var meta = SKU_LABEL[item.sku] || {name: item.sku, sub: ""};
var price = (item.price_minor / 100).toFixed(2);
var div = document.createElement("div");
div.className = "plan";
div.innerHTML =
'<div class="head"><span class="name">' + escapeHtml(meta.name) + '</span>' +
'<span class="price">¥' + price + ' <small>/ ' + escapeHtml(meta.sub) + '</small></span></div>' +
'<div class="sub">' + escapeHtml(item.plan || "") + '</div>' +
'<div class="methods">' +
'<button class="btn" data-sku="' + escapeHtml(item.sku) + '" data-method="crypto">' + METHOD_LABEL.crypto + '</button>' +
'<button class="btn" data-sku="' + escapeHtml(item.sku) + '" data-method="nezha">' + METHOD_LABEL.nezha + '</button>' +
'</div>';
list.appendChild(div);
});
list.querySelectorAll("button[data-sku]").forEach(function(btn){
btn.addEventListener("click", function(){
createOrder(btn.getAttribute("data-sku"), btn.getAttribute("data-method"), btn);
});
});
}
function escapeHtml(s){
return String(s == null ? "" : s).replace(/[&<>"']/g, function(c){
return {"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[c];
});
}
// ── 下单 ────────────────────────────────────────────────────────────────
function createOrder(sku, method, btn){
showErr("plans-err", "");
if (btn) btn.disabled = true;
api("/v1/pay/orders", {method: "POST", body: {
sku: sku, method: method, metadata: {is_mobile: "false"},
}}).then(function(data){
state.orderNo = data.order_no;
renderOrder(data.session);
screen("screen-order");
startPolling();
}).catch(function(err){
if (err.status === 401) { logout(); return; }
showErr("plans-err", err.message || "下单失败,请重试。");
}).finally(function(){
if (btn) btn.disabled = false;
});
}
function renderOrder(session){
showErr("order-err", "");
hide("pay-crypto"); hide("pay-redirect"); hide("pay-qr"); hide("pay-unknown");
hide("poll-done"); show("poll-status");
var payload = session.payload || {};
switch (session.render_type) {
case "crypto_address":
$("crypto-address").textContent = payload.address || "-";
var amountUsdt = (Number(payload.amount_minor || 0) / 1e6).toFixed(6);
$("crypto-amount").textContent = amountUsdt + " USDT";
$("crypto-amount").dataset.raw = amountUsdt;
show("pay-crypto");
break;
case "redirect":
var link = $("redirect-link");
link.href = payload.url || "#";
show("pay-redirect");
break;
case "qr":
$("qr-content").textContent = payload.qr_content || "-";
show("pay-qr");
break;
default:
show("pay-unknown");
}
}
document.addEventListener("click", function(ev){
var el = ev.target.closest("[data-copy]");
if (el) {
var srcId = el.getAttribute("data-copy");
var text = $(srcId).textContent;
copyText(text, el);
return;
}
var el2 = ev.target.closest("[data-copy-text]");
if (el2) {
var amountEl = $("crypto-amount");
copyText(amountEl.dataset.raw || amountEl.textContent, el2);
}
});
function copyText(text, btn){
var done = function(){
if (!btn) return;
var old = btn.textContent;
btn.textContent = "已复制";
setTimeout(function(){ btn.textContent = old; }, 1500);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done).catch(done);
} else {
var ta = document.createElement("textarea");
ta.value = text;
document.body.appendChild(ta);
ta.select();
try { document.execCommand("copy"); } catch (e) {}
document.body.removeChild(ta);
done();
}
}
// ── 轮询开通状态 ────────────────────────────────────────────────────────
function startPolling(){
stopPolling();
pollOnce();
state.pollTimer = setInterval(pollOnce, 4000);
}
function stopPolling(){
if (state.pollTimer) { clearInterval(state.pollTimer); state.pollTimer = null; }
}
function pollOnce(){
if (!state.orderNo) return;
api("/v1/pay/orders/" + encodeURIComponent(state.orderNo)).then(function(data){
if (data.activated) {
stopPolling();
hide("poll-status");
show("poll-done");
}
}).catch(function(err){
if (err.status === 401) { logout(); return; }
// 网络抖动/瞬时错误不打断轮询,静默重试。
});
}
document.addEventListener("visibilitychange", function(){
if (document.hidden) {
stopPolling();
} else if (state.orderNo &&
$("screen-order").classList.contains("hidden") === false &&
$("poll-done").classList.contains("hidden")) {
// 页面重新可见且订单尚未开通完成 → 恢复轮询。
startPolling();
}
});
window.addEventListener("beforeunload", stopPolling);
// ── 启动 ────────────────────────────────────────────────────────────────
if (state.accessToken) {
loadPlans();
} else {
screen("screen-login");
}
})();
</script>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
package main
import (
_ "embed"
"net/http"
)
// buyPageHTML is the self-contained, same-origin "/buy" purchase page: login
// -> pick a plan + payment method (USDT/nezha) -> create order -> poll until
// activated. It talks to /v1/auth/login, /v1/pay/catalog, /v1/pay/orders and
// /v1/pay/orders/{orderNo} via relative fetch() calls, so it's always same
// origin as the API (no CORS needed) as long as it's served from this binary.
//
//go:embed buy.html
var buyPageHTML []byte
// serveBuyPage handles GET /buy. Public (no JWT) — the page itself drives the
// login flow client-side.
func serveBuyPage(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(buyPageHTML)
}
+4
View File
@@ -131,6 +131,10 @@ func main() {
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
})
// Public (no JWT): self-contained purchase page, same-origin as /v1/pay/*
// so it can call the API with plain relative fetch()s (no CORS).
r.Get("/buy", serveBuyPage)
// Optional probe ingest route. sharedProbeStore is reused by the scheduler
// (below) when both are enabled, so they share one Redis-backed store.
var sharedProbeStore *probe.Store