70b0419387
后端: - 主动查单加最小间隔(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>
104 lines
3.7 KiB
Go
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))
|
|
}
|
|
}
|