From 70b04193873b7d9675908d1fc9d01e20d58f42f4 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Mon, 20 Jul 2026 12:46:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(payment):=20=E6=94=AF=E4=BB=98=E8=BD=AE?= =?UTF-8?q?=E8=AF=A2=E9=93=BE=E8=B7=AF=E6=94=B6=E5=8F=A3=E2=80=94=E2=80=94?= =?UTF-8?q?=E6=9F=A5=E5=8D=95=E8=8A=82=E6=B5=81=20+=20=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E8=BD=AE=E8=AF=A2=E9=87=8D=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - 主动查单加最小间隔(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 --- .../internal/handler/billing_pay.go | 32 ++-- .../internal/handler/payment_reconcile.go | 39 +++++ .../handler/payment_reconcile_test.go | 52 +++++++ sundynix-web/src/api.ts | 14 +- sundynix-web/src/pages/Usage.tsx | 140 ++++++++++++++---- 5 files changed, 236 insertions(+), 41 deletions(-) create mode 100644 sundynix-gateway/internal/handler/payment_reconcile_test.go diff --git a/sundynix-gateway/internal/handler/billing_pay.go b/sundynix-gateway/internal/handler/billing_pay.go index fc5b73d..c1a14fc 100644 --- a/sundynix-gateway/internal/handler/billing_pay.go +++ b/sundynix-gateway/internal/handler/billing_pay.go @@ -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 } diff --git a/sundynix-gateway/internal/handler/payment_reconcile.go b/sundynix-gateway/internal/handler/payment_reconcile.go index ff82621..756714e 100644 --- a/sundynix-gateway/internal/handler/payment_reconcile.go +++ b/sundynix-gateway/internal/handler/payment_reconcile.go @@ -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() } } }() diff --git a/sundynix-gateway/internal/handler/payment_reconcile_test.go b/sundynix-gateway/internal/handler/payment_reconcile_test.go new file mode 100644 index 0000000..f216a5a --- /dev/null +++ b/sundynix-gateway/internal/handler/payment_reconcile_test.go @@ -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("仍在有效期内的标记不该被误删") + } +} diff --git a/sundynix-web/src/api.ts b/sundynix-web/src/api.ts index 00b13f1..7b50adb 100644 --- a/sundynix-web/src/api.ts +++ b/sundynix-web/src/api.ts @@ -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 { +// 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 核销兑换码,返回入账后的余额。 diff --git a/sundynix-web/src/pages/Usage.tsx b/sundynix-web/src/pages/Usage.tsx index 6f61dcf..a3db9d8 100644 --- a/sundynix-web/src/pages/Usage.tsx +++ b/sundynix-web/src/pages/Usage.tsx @@ -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 ( onClose(state === "paid")}>
@@ -258,15 +317,44 @@ function PayDialog({ pack, onClose }: { pack: Pack; onClose: (paid: boolean) => ) : ( <> {/* 二维码底色固定纯白:扫码器对暗色主题下的低对比码识别率差 */} -
+
微信支付二维码 + {state === "expired" && ( + // 盖住失效的码,避免用户扫一个必然失败的二维码 +
+ 二维码已失效 +
+ )}
¥{(pack.price_fen / 100).toFixed(2)}
- {state === "paid" ? "✅ 已支付,入账中…" : state === "expired" ? "订单已过期,请关闭后重新下单" : `微信扫一扫支付,到账 ${fmtCredits(pack.credits_micro)} 积分`} + {state === "paid" + ? "✅ 已支付,入账中…" + : state === "expired" + ? "订单已过期,请关闭后重新下单" + : `微信扫一扫支付,到账 ${fmtCredits(pack.credits_micro)} 积分`}
+ {state === "waiting" && ( +
二维码 {mmss(left)} 后失效
+ )}
+ + {/* 已付但金额与订单不符:服务端不入账、挂起人工核对,必须告诉用户别再等 */} + {warn && ( +

+ {warn} +
+ 请联系客服并提供订单号 {orderRef.current} +

+ )} + + {/* 掉单补偿定时器在后台兜底,关页面不会丢钱——说清楚,省得用户守着不敢关 */} + {state === "waiting" && !warn && ( +

+ 付完没反应?关掉也没关系,到账后积分会自动补上。 +

+ )} )}