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:
@@ -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 {"&":"&","<":"<",">":">","\"":""","'":"'"}[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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user