Files
sundynix-agentix/sundynix-gateway/internal/handler/payment_reconcile.go
T
Blizzard 70b0419387 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>
2026-07-20 12:46:40 +08:00

104 lines
3.7 KiB
Go

package handler
import (
"context"
"log"
"sync"
"time"
"github.com/sundynix/sundynix-gateway/internal/payment"
)
// 掉单补偿(P5.3,设计见 PAYMENT_DESIGN.md §5):
// 前端轮询只在「用户开着账单页」时才查单确认——用户扫完码就关页面的话,钱付了、
// 订单却永远挂 pending、积分永远不到账。这个后台定时器把「用户在不在场」从入账链路
// 里摘掉:周期扫 pending 微信单,逐单 reconcileOrder(与前端轮询同一份幂等落态逻辑)。
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) {
go func() {
t := time.NewTicker(reconcileInterval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
h.reconcilePending(ctx)
pruneQueryMarks()
}
}
}()
log.Printf("[payment] 掉单补偿定时器已启动(每 %s 扫一次 pending 微信单)", reconcileInterval)
}
// reconcilePending 扫一轮待补偿的 pending 微信单。渠道未配置时直接返回(不打扰)。
func (h *Handler) reconcilePending(ctx context.Context) {
if h.pay.Get(payment.ChannelWechat) == nil {
return
}
orders, err := h.db.PendingWechatOrders(ctx, 200)
if err != nil {
log.Printf("[payment] 补偿扫描取 pending 单失败: %v", err)
return
}
var paid, expired, mismatch int
for i := range orders {
o := &orders[i]
updated, mm := h.reconcileOrder(ctx, o)
switch {
case mm:
mismatch++
log.Printf("[payment] ⚠️ 订单 %s 支付金额与订单不符,已挂起待人工对账", o.ID)
case updated.Status == "paid":
paid++
case updated.Status == "expired":
expired++
}
}
// 只在有变化时记一行,避免空转刷屏。
if paid+expired+mismatch > 0 {
log.Printf("[payment] 补偿扫描:入账 %d、过期 %d、金额不符 %d(本轮 %d 单)", paid, expired, mismatch, len(orders))
}
}