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
|
||||
}
|
||||
|
||||
|
||||
@@ -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("仍在有效期内的标记不该被误删")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user