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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user