feat(website): 本地联调 /buy 测试购买页(登录→下单→mock 付款→轮询开通)+ dev vite proxy

This commit is contained in:
wangjia
2026-07-11 01:32:11 +08:00
parent bc9aa38392
commit 43fead8823
4 changed files with 631 additions and 0 deletions
+23
View File
@@ -24,5 +24,28 @@ export default defineConfig({
// 关闭 JS 内联,保证 script-src 'self' 严格 CSP 下可运行。
assetsInlineLimit: 0,
},
server: {
// /buy 本地联调测试页专用:浏览器同源调后端,避免跨域。仅 dev 生效,不影响构建产物。
//
// 坑:Astro 全站 trailingSlash:'always'dev server 对不带尾斜杠、又没有匹配页面路由
// 的路径(如 /v1/pay/catalog)会在到达这里的 vite 代理之前就短路返回 Astro 自己的 404
// (已用本地 mock 后端验证:带尾斜杠能命中此代理,不带则 404,Astro middleware 同样不触发,
// 因为它只包裹「匹配到的」路由渲染,不包裹压根没有路由匹配的任意路径)。
// 于是这里约定:BuyFlow.jsx 发请求一律带尾斜杠(`/v1/pay/catalog/` 这类,与全站
// trailingSlash 约定一致),再用 rewrite 在转发前把尾斜杠去掉,避免打到后端 chi 路由
// (未挂 StripSlashes,尾斜杠会 404)。
proxy: {
'/v1': {
target: 'http://127.0.0.1:8080',
changeOrigin: true,
rewrite: (p) => p.replace(/\/$/, ''),
},
'/paydev': {
target: 'http://127.0.0.1:8090',
changeOrigin: true,
rewrite: (p) => p.replace(/^\/paydev/, '/api/v2/dev').replace(/\/$/, ''),
},
},
},
},
});
+462
View File
@@ -0,0 +1,462 @@
/**
* BuyFlow.jsx — /buy 本地联调测试页的交互状态机(React islandclient:load)。
*
* ⚠️ 仅供本地联调,不是真实购买流程;不接入 i18n 字典,不参与生产首页。
* 状态机:login → catalog → awaiting(下单后轮询) → activated / failed。
*
* 后端契约(server/internal/pay/handler.go,已核对,见任务描述):
* POST /v1/auth/login → { access_token, refresh_token, expires_in, ... }
* GET /v1/pay/catalog → { items:[{ sku, plan, days, price_minor, currency }] }
* POST /v1/pay/orders → { order_no, session:{ render_type, payload, expires_at? } }
* GET /v1/pay/orders/{no} → { order_no, pay_status, activated, expires_at? }
* POST /paydev/orders/{no}/mark-paid → 200(本地 dev-only,经 vite proxy 转发到 pay 的 dev 端点)
*
* API base:同源相对路径,留 PUBLIC_API_BASE 口子(默认空 = 同源,走 astro.config.mjs 的 vite proxy)。
*
* 坑(astro.config.mjs 里也记了一遍):全站 trailingSlash:'always'dev server 对不带尾斜杠又
* 没匹配到页面路由的路径会在到达 vite 代理前被 Astro 自己短路 404,所以这里请求一律带尾斜杠
* (与站点约定一致),astro.config.mjs 的 proxy rewrite 会在转发前把尾斜杠去掉再打后端 chi 路由。
*/
import { useEffect, useRef, useState } from 'react';
import {
Mail,
Lock,
Loader2,
CheckCircle2,
AlertCircle,
Copy,
ExternalLink,
QrCode,
RotateCcw,
FlaskConical,
} from 'lucide-react';
const API_BASE = import.meta.env.PUBLIC_API_BASE || '';
async function api(path, options = {}) {
const res = await fetch(API_BASE + path, {
...options,
headers: {
'Content-Type': 'application/json',
...(options.headers || {}),
},
});
const text = await res.text();
let body = null;
if (text) {
try {
body = JSON.parse(text);
} catch {
body = text;
}
}
if (!res.ok) {
const msg =
(body && typeof body === 'object' && (body.error || body.message)) ||
(typeof body === 'string' && body) ||
`HTTP ${res.status}`;
throw new Error(msg);
}
return body;
}
function fmtPrice(minor, currency) {
const v = (minor ?? 0) / 100;
return `${v.toFixed(2)} ${currency || ''}`.trim();
}
export default function BuyFlow() {
const [step, setStep] = useState('login'); // login | catalog | awaiting | activated
const [token, setToken] = useState('');
// login
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loginLoading, setLoginLoading] = useState(false);
const [loginError, setLoginError] = useState('');
// catalog
const [items, setItems] = useState([]);
const [catalogLoading, setCatalogLoading] = useState(false);
const [catalogError, setCatalogError] = useState('');
// order
const [orderLoading, setOrderLoading] = useState('');
const [orderError, setOrderError] = useState('');
const [order, setOrder] = useState(null); // { order_no, session }
const [payStatus, setPayStatus] = useState(null); // { pay_status, activated }
const [pollError, setPollError] = useState('');
// mock pay
const [markPaidLoading, setMarkPaidLoading] = useState(false);
const [markPaidError, setMarkPaidError] = useState('');
const pollRef = useRef(null);
const stopPolling = () => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
};
useEffect(() => stopPolling, []); // unmount 兜底清 interval
async function fetchCatalog(bearer) {
setCatalogLoading(true);
setCatalogError('');
try {
const data = await api('/v1/pay/catalog/', {
headers: { Authorization: `Bearer ${bearer}` },
});
setItems(data?.items || []);
} catch (e) {
setCatalogError(e.message || String(e));
} finally {
setCatalogLoading(false);
}
}
async function onLogin(e) {
e.preventDefault();
setLoginError('');
setLoginLoading(true);
try {
const data = await api('/v1/auth/login/', {
method: 'POST',
body: JSON.stringify({
email,
password,
device: {
id: 'web-buy',
name: 'web',
platform: 'web',
client_version: '0',
},
}),
});
setToken(data.access_token);
setStep('catalog');
fetchCatalog(data.access_token);
} catch (e) {
setLoginError(e.message || String(e));
} finally {
setLoginLoading(false);
}
}
async function onPickSku(sku) {
setOrderError('');
setOrderLoading(sku);
try {
const data = await api('/v1/pay/orders/', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify({
sku,
method: 'fake',
metadata: { is_mobile: false },
}),
});
setOrder(data);
setPayStatus(null);
setPollError('');
setStep('awaiting');
startPolling(data.order_no);
} catch (e) {
setOrderError(e.message || String(e));
} finally {
setOrderLoading('');
}
}
function startPolling(orderNo) {
stopPolling();
const tick = async () => {
try {
const data = await api(`/v1/pay/orders/${orderNo}/`, {
headers: { Authorization: `Bearer ${token}` },
});
setPayStatus(data);
setPollError('');
if (data && data.activated === true) {
stopPolling();
setStep('activated');
}
} catch (e) {
setPollError(e.message || String(e));
}
};
tick();
pollRef.current = setInterval(tick, 2500);
}
async function onMarkPaid() {
if (!order) return;
setMarkPaidError('');
setMarkPaidLoading(true);
try {
await api(`/paydev/orders/${order.order_no}/mark-paid/`, { method: 'POST' });
// 不主动切状态,靠轮询自然把 activated 翻 true。
} catch (e) {
setMarkPaidError(e.message || String(e));
} finally {
setMarkPaidLoading(false);
}
}
function onReset() {
stopPolling();
setOrder(null);
setPayStatus(null);
setOrderError('');
setPollError('');
setMarkPaidError('');
setStep('catalog');
}
return (
<div class="buy-card">
{/* ---------- step: login ---------- */}
{step === 'login' && (
<form class="buy-block" onSubmit={onLogin}>
<h2 class="buy-h">1. 登录测试账号</h2>
<p class="buy-hint">用编排时注册好的账号邮箱 + 密码登录仅本地联调使用</p>
<div class="su-row">
<div class="su-field">
<Mail />
<input
type="email"
placeholder="test@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
</div>
<div class="su-row" style={{ marginTop: '10px' }}>
<div class="su-field">
<Lock />
<input
type="password"
placeholder="密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<button class="btn btn-primary btn-lg su-btn" type="submit" disabled={loginLoading}>
{loginLoading ? <Loader2 class="buy-spin" /> : null}
<span>{loginLoading ? '登录中…' : '登录'}</span>
</button>
</div>
{loginError && (
<div class="buy-err">
<AlertCircle />
<span>{loginError}</span>
</div>
)}
</form>
)}
{/* ---------- step: catalog ---------- */}
{step === 'catalog' && (
<div class="buy-block">
<h2 class="buy-h">2. 选择套餐下单</h2>
<p class="buy-hint">已登录方法固定为 <code>fake</code>本地 mock 渠道</p>
{catalogLoading && (
<div class="buy-loading">
<Loader2 class="buy-spin" />
<span>加载套餐中</span>
</div>
)}
{catalogError && (
<div class="buy-err">
<AlertCircle />
<span>{catalogError}</span>
<button class="btn btn-ghost" type="button" onClick={() => fetchCatalog(token)}>
重试
</button>
</div>
)}
{!catalogLoading && !catalogError && items.length === 0 && (
<div class="buy-hint">catalog 为空</div>
)}
<div class="buy-grid">
{items.map((it) => (
<div class="plan buy-plan" key={it.sku}>
<div class="pname">{it.plan}</div>
<div class="price">
{fmtPrice(it.price_minor, it.currency)}
</div>
<div class="pdesc">{it.days} · {it.sku}</div>
<button
class="pcta pcta-fill"
type="button"
disabled={orderLoading === it.sku}
onClick={() => onPickSku(it.sku)}
>
{orderLoading === it.sku ? '下单中…' : '选择并下单'}
</button>
</div>
))}
</div>
{orderError && (
<div class="buy-err">
<AlertCircle />
<span>{orderError}</span>
</div>
)}
</div>
)}
{/* ---------- step: awaiting payment ---------- */}
{step === 'awaiting' && order && (
<div class="buy-block">
<h2 class="buy-h">3. 假收款 · 等待付款</h2>
<p class="buy-hint">
订单号 <code>{order.order_no}</code>
{payStatus?.pay_status ? <> · pay_status = <code>{payStatus.pay_status}</code></> : null}
</p>
<PaymentPanel session={order.session} />
<div class="buy-status">
{payStatus?.activated ? (
<div class="su-ok">
<CheckCircle2 />
<span>已开通</span>
</div>
) : (
<div class="buy-loading">
<Loader2 class="buy-spin" />
<span>轮询中 2.5s activated = {String(payStatus?.activated ?? false)}</span>
</div>
)}
{pollError && (
<div class="buy-err">
<AlertCircle />
<span>轮询出错{pollError}</span>
</div>
)}
</div>
<div class="buy-actions">
<button class="btn btn-ghost buy-mock-btn" type="button" onClick={onMarkPaid} disabled={markPaidLoading}>
<FlaskConical />
<span>{markPaidLoading ? '标记中…' : '🧪 模拟付款成功(本地联调)'}</span>
</button>
<button class="btn btn-secondary" type="button" onClick={onReset}>
<RotateCcw />
<span>换一个套餐</span>
</button>
</div>
{markPaidError && (
<div class="buy-err">
<AlertCircle />
<span>{markPaidError}</span>
</div>
)}
</div>
)}
{/* ---------- step: activated ---------- */}
{step === 'activated' && (
<div class="buy-block buy-block-center">
<div class="su-ok buy-ok-big">
<CheckCircle2 />
<span>已开通订单 {order?.order_no} 支付完成套餐已生效</span>
</div>
<button class="btn btn-primary btn-lg" type="button" onClick={onReset}>
<RotateCcw />
<span>再下一单</span>
</button>
</div>
)}
</div>
);
}
/**
* PaymentPanel — 按 render_type 多态渲染假收款信息。
* 三态:crypto_address(地址文本)/ redirect(跳转链接)/ qr(二维码内容文本,
* 不引外部 CDN 画码,直接展示可复制的原始内容,严格 CSP 下最稳妥)。
*/
function PaymentPanel({ session }) {
const [copied, setCopied] = useState(false);
if (!session) return null;
const { render_type, payload } = session;
async function copy(text) {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// 剪贴板权限失败时静默,用户可手动选中复制。
}
}
if (render_type === 'redirect') {
const url = payload?.url || '';
return (
<div class="pay-note buy-pay-panel">
<ExternalLink />
<div>
<b>跳转支付</b>
<div class="buy-mono">{url}</div>
<a class="btn btn-secondary buy-pay-cta" href={url} target="_blank" rel="noreferrer">
<ExternalLink />
<span>去支付</span>
</a>
</div>
</div>
);
}
if (render_type === 'qr') {
const content = payload?.qr_content || '';
return (
<div class="pay-note buy-pay-panel">
<QrCode />
<div>
<b>二维码内容</b>
<div class="buy-hint">未接入本地二维码渲染库直接展示原始内容可复制到二维码工具生成</div>
<div class="buy-mono buy-selectable">{content}</div>
<button class="btn btn-ghost buy-pay-cta" type="button" onClick={() => copy(content)}>
<Copy />
<span>{copied ? '已复制' : '复制'}</span>
</button>
</div>
</div>
);
}
// crypto_addressfake 渠道默认)+ 未识别的 render_type 兜底同样展示。
const address = payload?.address || payload?.qr_content || payload?.url || JSON.stringify(payload);
const amount = payload?.amount;
const currency = payload?.currency;
return (
<div class="pay-note buy-pay-panel">
<QrCode />
<div>
<b>假收款地址</b>
{amount ? (
<div class="buy-hint">
金额{amount} {currency || ''}
</div>
) : null}
<div class="buy-mono buy-selectable">{address}</div>
<button class="btn btn-ghost buy-pay-cta" type="button" onClick={() => copy(address)}>
<Copy />
<span>{copied ? '已复制' : '复制地址'}</span>
</button>
</div>
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
---
/**
* buy.astro — 本地联调测试路由 /buy:登录 → 拉套餐 → 选档下单 → 假收款 → 模拟付款 → 轮询开通。
*
* ⚠️ 仅供本地开发联调(对接 server /v1/pay/* + pay dev 端点),不是生产购买页,
* 不改首页 / PricingPlans / Pricing.astro,不接入 i18n 字典。
* 样式复用 website.css 既有类(section、wrap、plan、pcta、su- 前缀、btn- 前缀),新增小工具类见 buy.css。
*/
import '@fontsource/sora/500.css';
import '@fontsource/sora/600.css';
import '@fontsource/sora/700.css';
import '@fontsource/manrope/400.css';
import '@fontsource/manrope/500.css';
import '@fontsource/manrope/600.css';
import '@fontsource/manrope/700.css';
import '@fontsource/noto-sans-sc/400.css';
import '@fontsource/noto-sans-sc/500.css';
import '@fontsource/noto-sans-sc/700.css';
import '@fontsource/jetbrains-mono/400.css';
import '@fontsource/jetbrains-mono/500.css';
import '../styles/tokens.gen.css';
import '../styles/website.css';
import '../styles/site-extra.css';
import '../styles/buy.css';
import BuyFlow from '../components/BuyFlow.jsx';
---
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>本地联调 · 购买流程测试 — Pangolin</title>
<meta name="robots" content="noindex,nofollow" />
</head>
<body>
<div class="wrap buy-page">
<div class="buy-topbar">
<a class="brand" href="/">
<span class="nm">Pangolin</span>
</a>
<span class="buy-badge">🧪 本地联调测试页</span>
</div>
<div class="center">
<div class="eyebrow">Dev only</div>
<h1 class="h-sec">购买流程本地联调</h1>
<p class="sub-sec buy-sub">
登录 → 拉取套餐 → 选档下单(method=fake)→ 显示假收款 → 模拟付款成功 → 轮询直到已开通。
仅经 Astro dev vite proxy 同源转发到本地 pangolin-server / pay-server。
</p>
</div>
<BuyFlow client:load />
</div>
</body>
</html>
+88
View File
@@ -0,0 +1,88 @@
/* buy.css — /buy 本地联调测试页专属工具类。
只加这一页要用、website.css 里没有的样式;颜色/间距一律用既有 --token,禁手写 hex。
不改 website.css(保持「逐字迁自原型」不漂移),不影响首页任何路由。 */
.buy-page { padding: 40px 0 96px; }
.buy-topbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 40px; }
.buy-topbar .brand .nm { font-family: var(--font-display); font-weight: 700; font-size: 19px; letter-spacing: -0.01em; }
.buy-badge {
font-size: 12.5px;
font-weight: 700;
color: var(--accent);
background: var(--accent-subtle);
border: 1px solid var(--accent-border);
border-radius: var(--radius-full);
padding: 6px 12px;
}
.buy-sub { max-width: 640px; }
.buy-card {
max-width: 640px;
margin: 36px auto 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-2xl);
box-shadow: var(--shadow-sm);
padding: 32px;
}
.buy-block-center { text-align: center; display: flex; flex-direction: column; align-items: center; gap: 20px; }
.buy-h { font-family: var(--font-display); font-weight: 700; font-size: 20px; margin: 0 0 6px; }
.buy-hint { font-size: 13.5px; color: var(--fg3); line-height: 1.6; margin: 0 0 16px; }
.buy-hint code, .buy-h code { font-family: var(--font-mono); background: var(--bg-subtle); border-radius: var(--radius-sm); padding: 1px 6px; }
.buy-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 16px; margin-top: 4px; }
.buy-plan { padding: 22px 18px; }
.buy-plan .price { font-size: 26px; margin: 8px 0 4px; }
.buy-plan .pdesc { min-height: auto; margin-bottom: 14px; }
.buy-loading, .buy-err {
display: flex;
align-items: center;
gap: 8px;
font-size: 13.5px;
margin-top: 14px;
}
.buy-loading { color: var(--fg2); }
.buy-loading svg { width: 16px; height: 16px; color: var(--accent); }
.buy-err { color: var(--danger); background: var(--danger-subtle); border-radius: var(--radius-md); padding: 10px 14px; flex-wrap: wrap; }
.buy-err svg { width: 16px; height: 16px; flex-shrink: 0; }
.buy-err button { margin-left: auto; }
.buy-spin { animation: buy-spin 0.9s linear infinite; }
@keyframes buy-spin { to { transform: rotate(360deg); } }
.buy-status { margin: 20px 0; }
.buy-actions { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 22px; }
.buy-mock-btn { border: 1.5px dashed var(--border-strong); }
.buy-pay-panel { margin-top: 0; }
.buy-pay-panel > div { width: 100%; }
.buy-pay-cta { margin-top: 12px; }
.buy-mono {
font-family: var(--font-mono);
font-size: 12.5px;
color: var(--fg1);
background: var(--bg-subtle);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 10px 12px;
margin-top: 8px;
word-break: break-all;
line-height: 1.5;
}
.buy-selectable { user-select: all; }
.buy-ok-big { font-size: 15px; }
.buy-ok-big svg { width: 22px; height: 22px; }
@media (max-width: 640px) {
.buy-card { padding: 22px; margin-top: 24px; }
.buy-topbar { flex-wrap: wrap; gap: 10px; }
}