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) { safeGo("payment-reconcile-ticker", func() { t := time.NewTicker(reconcileInterval) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: // 单轮兜底:某轮 DB/查单 panic 不该终止整个补偿定时器,下一轮继续。 safeCall("payment-reconcile-tick", func() { 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)) } }