fix(payment): 支付轮询链路收口——查单节流 + 前端轮询重写

后端:
- 主动查单加最小间隔(5s)。此前 BillingOrderStatus 见 pending 就直连微信查单,
  而前端 2.5s 轮一次 —— 单笔订单在 30min TTL 内可打出约 720 次渠道调用,
  微信侧有频控,并发用户一多先被限流的是我们自己。回调才是入账主路径,
  查单只是兜底,节流最坏只让在场用户多等 5s。
- 标记随订单落终态清除,并在补偿定时器每轮 prune 掉超 TTL 的残留
  (用户扫码前就关弹窗的订单不会再被轮询,其标记无人回收)。
- 下单响应补 expires_at:二维码有效期由服务端 orderTTL 说了算,
  前端硬编码一份迟早漂移。

前端(sundynix-web):
- orderStatus 不再丢掉 warn。服务端在「已付但金额与订单不符」时不入账、
  挂起人工核对,订单一直停在 pending —— 丢掉 warn 用户就会一直等一个
  永远不会来的结果。现在弹窗显式告警并给出订单号。
- setInterval → 递归 setTimeout:请求慢于间隔时 setInterval 会把请求摞起来,
  对「每次可能触发渠道查单」的端点尤其糟。
- 标签页切走时暂停轮询(用户扫完码要切去微信 App),切回前台立刻补查一次。
- 连续失败指数退避至 15s 封顶,断网时不再定频猛打。
- 二维码倒计时 + 失效遮罩,过期不再让用户扫一个必然失败的码。
- 明说「关掉也不会丢钱,到账会自动补上」——掉单补偿定时器本就兜底,
  但此前 UI 没讲,用户只能守着弹窗。
- 关闭弹窗一律刷新余额:用户可能在关闭前一刻付款、状态刚落地。

测试:新增 handler 包首个测试,钉住节流与标记回收行为(-race 通过)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-20 12:46:40 +08:00
parent 34500e66a7
commit 70b0419387
5 changed files with 236 additions and 41 deletions
+9 -5
View File
@@ -271,8 +271,11 @@ export async function billingPacks(): Promise<{ packs: Pack[]; channels: string[
return { packs: d.packs ?? [], channels: d.channels ?? [] };
}
// createWechatOrder 微信 Native 下单:返回订单号 + code_url(渲染成二维码扫码付)
export async function createWechatOrder(packId: string): Promise<{ order_id: string; code_url: string; amount_fen: number }> {
// createWechatOrder 微信 Native 下单:返回订单号 + code_url(渲染成二维码扫码付)
// + expires_at(二维码有效期,由服务端 orderTTL 决定,前端只负责倒计时展示)。
export async function createWechatOrder(
packId: string,
): Promise<{ order_id: string; code_url: string; amount_fen: number; expires_at: string }> {
const res = guard401(
await fetch(`${GATEWAY}/api/v1/billing/orders`, {
method: "POST",
@@ -284,10 +287,11 @@ export async function createWechatOrder(packId: string): Promise<{ order_id: str
}
// orderStatus 轮询订单态(pending 时服务端顺路主动查单,本地也能确认到账)。
export async function orderStatus(orderId: string): Promise<TopupOrder> {
// warn 必须一并返回:服务端在「已付但金额与订单不符」时不入账、挂起人工核对,
// 订单会一直停在 pending —— 丢掉 warn 的话用户付了钱、界面却只会一直转圈等下去。
export async function orderStatus(orderId: string): Promise<{ order: TopupOrder; warn?: string }> {
const res = guard401(await fetch(`${GATEWAY}/api/v1/billing/orders/${orderId}`, { headers: bearer() }));
const d = await jsonOrThrow<{ order: TopupOrder }>(res, "查询失败");
return d.order;
return jsonOrThrow<{ order: TopupOrder; warn?: string }>(res, "查询失败");
}
// redeemCode 核销兑换码,返回入账后的余额。
+114 -26
View File
@@ -191,11 +191,10 @@ export function Usage() {
pack={paying}
onClose={(paid) => {
setPaying(null);
if (paid) {
toast.push("success", "支付成功,积分已入账");
load();
refresh();
}
if (paid) toast.push("success", "支付成功,积分已入账");
// 无论如何都刷一次:用户可能在关弹窗前一刻付款、状态刚落地还没被轮询看到。
load();
refresh();
}}
/>
)}
@@ -203,51 +202,111 @@ export function Usage() {
);
}
// PayDialog 微信 Native 扫码支付:下单 → code_url 画二维码 → 轮询单态2.5s
// 服务端 pending 时会顺路主动查单,收不到公网回调的环境也能确认到账)。
// PayDialog 微信 Native 扫码支付:下单 → code_url 画二维码 → 轮询单态确认到账。
//
// 轮询这条链上几件必须处理的事:
// - 递归 setTimeout 而非 setInterval:请求慢于间隔时 setInterval 会把请求摞起来,
// 而这个端点每次都可能触发一次渠道查单,摞起来等于放大对微信的调用。
// - 标签页切走时暂停:用户扫完码要切到微信 App 付款,这段时间页面在后台空转没意义;
// 切回来立刻补查一次,回前台的确认几乎无延迟。
// - 连续失败指数退避:断网时别以固定频率猛打,封顶 15s。
// - warn(已付但金额与订单不符)必须显式呈现:这种单服务端**不入账**、订单一直停在
// pending,不提示的话用户会一直等一个永远不会来的结果。
const POLL_BASE_MS = 2500;
const POLL_MAX_MS = 15000;
const mmss = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
function PayDialog({ pack, onClose }: { pack: Pack; onClose: (paid: boolean) => void }) {
const [qr, setQr] = useState("");
const [err, setErr] = useState("");
const [warn, setWarn] = useState("");
const [state, setState] = useState<"creating" | "waiting" | "paid" | "expired">("creating");
const [left, setLeft] = useState(0); // 二维码剩余有效秒数
const orderRef = useRef("");
const expiresRef = useRef(0);
useEffect(() => {
let alive = true;
let timer: number | null = null;
let failures = 0;
const stop = () => {
if (timer) window.clearTimeout(timer);
timer = null;
};
const schedule = (ms: number) => {
stop();
timer = window.setTimeout(() => void tick(), ms);
};
const tick = async () => {
if (!alive) return;
if (document.hidden) return schedule(POLL_BASE_MS); // 后台不打请求,等切回来补
try {
const { order, warn: w } = await orderStatus(orderRef.current);
if (!alive) return;
failures = 0;
setWarn(w ?? "");
if (order.status === "paid") {
setState("paid");
stop();
window.setTimeout(() => onClose(true), 800);
return;
}
if (order.status === "expired" || order.status === "failed") {
setState("expired");
stop();
return;
}
schedule(POLL_BASE_MS);
} catch {
if (!alive) return;
failures += 1;
schedule(Math.min(POLL_BASE_MS * 2 ** failures, POLL_MAX_MS));
}
};
// 切回前台立刻查一次,不用等下一个 tick
const onVisible = () => {
if (!document.hidden && alive && orderRef.current) schedule(0);
};
document.addEventListener("visibilitychange", onVisible);
(async () => {
try {
const o = await createWechatOrder(pack.id);
if (!alive) return;
orderRef.current = o.order_id;
expiresRef.current = new Date(o.expires_at).getTime();
setLeft(Math.max(0, Math.round((expiresRef.current - Date.now()) / 1000)));
setQr(await QRCode.toDataURL(o.code_url, { width: 240, margin: 1 }));
setState("waiting");
timer = window.setInterval(async () => {
try {
const s = await orderStatus(orderRef.current);
if (!alive) return;
if (s.status === "paid") {
setState("paid");
if (timer) window.clearInterval(timer);
window.setTimeout(() => onClose(true), 800);
} else if (s.status === "expired" || s.status === "failed") {
setState("expired");
if (timer) window.clearInterval(timer);
}
} catch {
/* 单次轮询失败忽略,下个 tick 再试 */
}
}, 2500);
schedule(POLL_BASE_MS);
} catch (e) {
if (alive) setErr((e as Error).message);
}
})();
return () => {
alive = false;
if (timer) window.clearInterval(timer);
stop();
document.removeEventListener("visibilitychange", onVisible);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pack.id]);
// 倒计时:到点本地先置过期,省一次「扫了个死码才知道」。服务端 TTL 仍是权威。
useEffect(() => {
if (state !== "waiting") return;
const t = window.setInterval(() => {
const s = Math.max(0, Math.round((expiresRef.current - Date.now()) / 1000));
setLeft(s);
if (s === 0) setState("expired");
}, 1000);
return () => window.clearInterval(t);
}, [state]);
return (
<Dialog open title={`微信扫码 · ${pack.name}`} onClose={() => onClose(state === "paid")}>
<div className="flex flex-col items-center gap-3 py-2">
@@ -258,15 +317,44 @@ function PayDialog({ pack, onClose }: { pack: Pack; onClose: (paid: boolean) =>
) : (
<>
{/* 二维码底色固定纯白:扫码器对暗色主题下的低对比码识别率差 */}
<div className="rounded-lg bg-white p-2">
<div className="relative rounded-lg bg-white p-2">
<img src={qr} alt="微信支付二维码" width={240} height={240} />
{state === "expired" && (
// 盖住失效的码,避免用户扫一个必然失败的二维码
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-slate-900/80 text-xs text-slate-200">
</div>
)}
</div>
<div className="text-center">
<div className="text-lg font-semibold tabular-nums text-slate-100">¥{(pack.price_fen / 100).toFixed(2)}</div>
<div className="mt-1 text-xs text-slate-500">
{state === "paid" ? "✅ 已支付,入账中…" : state === "expired" ? "订单已过期,请关闭后重新下单" : `微信扫一扫支付,到账 ${fmtCredits(pack.credits_micro)} 积分`}
{state === "paid"
? "✅ 已支付,入账中…"
: state === "expired"
? "订单已过期,请关闭后重新下单"
: `微信扫一扫支付,到账 ${fmtCredits(pack.credits_micro)} 积分`}
</div>
{state === "waiting" && (
<div className="mt-1 text-[11px] tabular-nums text-slate-500"> {mmss(left)} </div>
)}
</div>
{/* 已付但金额与订单不符:服务端不入账、挂起人工核对,必须告诉用户别再等 */}
{warn && (
<p className="rounded-md bg-amber-500/10 px-3 py-2 text-center text-[11px] leading-relaxed text-amber-300">
{warn}
<br />
{orderRef.current}
</p>
)}
{/* 掉单补偿定时器在后台兜底,关页面不会丢钱——说清楚,省得用户守着不敢关 */}
{state === "waiting" && !warn && (
<p className="text-center text-[11px] leading-relaxed text-slate-600">
</p>
)}
</>
)}
</div>