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
@@ -81,7 +81,11 @@ func (h *Handler) BillingCreateOrder(c *gin.Context) {
c.JSON(http.StatusBadGateway, gin.H{"error": channel + "下单失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"order_id": o.ID, "code_url": intent.CodeURL, "amount_fen": o.AmountFen})
// expires_at 由后端下发:二维码有效期是服务端的 orderTTL 说了算,前端硬编码一份迟早漂移。
c.JSON(http.StatusOK, gin.H{
"order_id": o.ID, "code_url": intent.CodeURL, "amount_fen": o.AmountFen,
"expires_at": o.CreatedAt.Add(orderTTL),
})
}
// BillingOrderStatus: GET /api/v1/billing/orders/:id —— 前端轮询订单态。
@@ -113,25 +117,33 @@ func (h *Handler) BillingOrderStatus(c *gin.Context) {
func (h *Handler) reconcileOrder(ctx context.Context, o *store.PaymentOrder) (*store.PaymentOrder, bool) {
ch := h.pay.Get(o.Channel) // 按订单实际所用渠道查单,不写死微信
if o.Status != store.OrderPending || ch == nil {
forgetQuery(o.ID)
return o, false
}
if r, err := ch.QueryOrder(ctx, o.ID); err == nil {
switch {
case r.Paid && r.AmountFen == o.AmountFen:
if _, err := h.db.MarkOrderPaid(ctx, o.ID, r.ChannelTxn); err == nil {
// 节流:间隔内跳过真实查单,只走下面本地可判定的 TTL 过期。回调是主路径,
// 少查几次不影响到账,只影响「用户在场时的确认延迟」,最坏多等 5s。
if allowQuery(o.ID) {
if r, err := ch.QueryOrder(ctx, o.ID); err == nil {
switch {
case r.Paid && r.AmountFen == o.AmountFen:
if _, err := h.db.MarkOrderPaid(ctx, o.ID, r.ChannelTxn); err == nil {
o, _ = h.db.GetOrder(ctx, o.ID)
}
case r.Paid: // 金额对不上:不入账,人工对账(比错账便宜)
return o, true
case r.Closed:
_ = h.db.ExpireOrder(ctx, o.ID)
o, _ = h.db.GetOrder(ctx, o.ID)
}
case r.Paid: // 金额对不上:不入账,人工对账(比错账便宜)
return o, true
case r.Closed:
_ = h.db.ExpireOrder(ctx, o.ID)
o, _ = h.db.GetOrder(ctx, o.ID)
}
}
if o.Status == store.OrderPending && time.Since(o.CreatedAt) > orderTTL {
_ = h.db.ExpireOrder(ctx, o.ID)
o, _ = h.db.GetOrder(ctx, o.ID)
}
if o.Status != store.OrderPending {
forgetQuery(o.ID) // 已落终态,标记没用了
}
return o, false
}
@@ -3,6 +3,7 @@ package handler
import (
"context"
"log"
"sync"
"time"
"github.com/sundynix/sundynix-gateway/internal/payment"
@@ -15,6 +16,43 @@ import (
const reconcileInterval = 1 * time.Minute
// 主动查单节流:前端支付弹窗每 2.5s 轮一次单态,而 reconcileOrder 见 pending 就直连渠道
// 查单——单笔订单在 30min TTL 内能打出约 720 次微信查单调用,微信侧有频控,多用户并发时
// 先被限流的反而是我们自己。回调才是入账主路径,查单只是兜底,给它一个最小间隔即可:
// 间隔内的轮询直接返回本地状态(订单一旦被回调入账,本地状态本来就是最新的)。
// 补偿定时器每分钟才跑一轮,远大于这个间隔,不受影响。
const minQueryInterval = 5 * time.Second
// lastQuery: orderID -> 上次真正打渠道查单的时刻。仅用于限流,进程级即可
// (多实例各自限流,量级仍降两个数量级);订单落终态或超期时清理,见 forgetQuery/pruneQueryMarks。
var lastQuery sync.Map
// allowQuery 判断此刻是否放行一次真实查单,放行则记下时刻。
func allowQuery(orderID string) bool {
now := time.Now()
if v, ok := lastQuery.Load(orderID); ok {
if last, _ := v.(time.Time); now.Sub(last) < minQueryInterval {
return false
}
}
lastQuery.Store(orderID, now)
return true
}
func forgetQuery(orderID string) { lastQuery.Delete(orderID) }
// pruneQueryMarks 清掉超过 TTL 的残留标记 —— 用户扫码前就关掉弹窗的订单不会再被轮询,
// 其标记无人清理,不定期回收会随进程运行时长单调增长。
func pruneQueryMarks() {
cutoff := time.Now().Add(-orderTTL)
lastQuery.Range(func(k, v any) bool {
if t, _ := v.(time.Time); t.Before(cutoff) {
lastQuery.Delete(k)
}
return true
})
}
// StartReconcile 启动掉单补偿定时器(微信渠道未配置时空转,几乎零成本)。随进程生命周期运行,
// ctx 取消即退出。返回给调用方保存以便优雅停机时取消。
func (h *Handler) StartReconcile(ctx context.Context) {
@@ -27,6 +65,7 @@ func (h *Handler) StartReconcile(ctx context.Context) {
return
case <-t.C:
h.reconcilePending(ctx)
pruneQueryMarks()
}
}
}()
@@ -0,0 +1,52 @@
package handler
import (
"testing"
"time"
)
// 查单节流是限制「我们打微信查单接口」频率的唯一闸门:前端支付弹窗 2.5s 轮一次,
// 没有它的话单笔订单在 30min TTL 内能打出数百次渠道调用。这里把行为钉死。
func TestAllowQuery_Throttle(t *testing.T) {
const id = "test-order-throttle"
defer forgetQuery(id)
if !allowQuery(id) {
t.Fatal("首次查单必须放行——否则用户扫码后要白等一个间隔才确认")
}
if allowQuery(id) {
t.Fatal("间隔内的第二次应被拦下")
}
// 间隔过后重新放行
lastQuery.Store(id, time.Now().Add(-minQueryInterval-time.Second))
if !allowQuery(id) {
t.Fatal("超过 minQueryInterval 后应重新放行")
}
}
// 订单落终态后标记要清掉,否则同一订单号(或长期挂着的 pending)会把 map 撑大。
func TestForgetQuery_And_Prune(t *testing.T) {
const id = "test-order-forget"
allowQuery(id)
forgetQuery(id)
if _, ok := lastQuery.Load(id); ok {
t.Fatal("forgetQuery 应删除标记")
}
// 用户扫码前就关掉弹窗的订单不会再被轮询,标记无人清理 —— 靠定时 prune 回收
const stale = "test-order-stale"
lastQuery.Store(stale, time.Now().Add(-orderTTL-time.Minute))
const fresh = "test-order-fresh"
lastQuery.Store(fresh, time.Now())
defer forgetQuery(fresh)
pruneQueryMarks()
if _, ok := lastQuery.Load(stale); ok {
t.Fatal("超过 orderTTL 的陈旧标记应被回收")
}
if _, ok := lastQuery.Load(fresh); !ok {
t.Fatal("仍在有效期内的标记不该被误删")
}
}
+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>