feat(v2): 对账主体——周期查单收敛 + 已付订单抽查 + 退款修复扫描/卡滞告警 + main 装配 reconcile Runner

Task 5(债务 #6):两条腿收敛对账——① SyncPendingTask 调度 P2 gateway.SyncPendingAttempts
逐 pending attempt 查单收敛(防掉单);② PaidSpotCheckTask 对近期已付 attempt 反查渠道
核对金额/币种,漂移(渠道侧已退款/拒付而本地仍 paid)只告警不改状态。

追加两条 P4 T3 opus review 义务(该 review 产出时本 worktree 已分叉,P4 退款主体在主
checkout;这里对本 worktree 已有的 store.RefundStore/OrderStore 接口——P4 T2,在 base
里——建自愈扫描,设计为可在合并 P4 后继续工作):
- RefundApplyTask:退款修复扫描,重算 succeeded 退款之和,自愈「退款成功但订单卡 paid」
  的崩溃窗口(MarkRefundStatus 翻 succeeded 后、ApplyRefundToOrder 调用前崩溃)。候选订单
  =有 succeeded 退款的订单 ∪ 当前处于 refunding/partially_refunded 态的订单;走既有
  ApplyRefundToOrder 条件 UPDATE,目标态与当前态一致时跳过,天然幂等。
- RefundStuckAlertTask:卡滞 processing/manual_pending 退款超阈值(默认 30min)打 WARN,
  只观测不改状态;渠道退款查询 API 面留待后续。

main 装配前 4 job(order-expire/usage-refresh/sync-pending/paid-spotcheck)+ 上述两个退款
job 挂上 reconcile.Runner;acctPicker 的 limit_aware 用量源改用 reconcile.NewUsageSource
替 NopUsage。crypto 冷启动 Warm/孤儿扫描(AddCryptoJobs)留给 Task 6 追加。

config.go 新增 ReconcileConfig(含 Task 6 预留的 orphan_* 字段)+ 默认值;crypto.go 补
SetReservationLoader 构造后注入 setter(Task 6 依赖)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
This commit is contained in:
wangjia
2026-07-10 17:45:01 +08:00
parent 4ac4e6ca9f
commit 04c3f4f31a
11 changed files with 678 additions and 2 deletions
+32 -2
View File
@@ -1,6 +1,7 @@
package main
import (
"context"
"errors"
"log"
"strings"
@@ -17,6 +18,7 @@ import (
"github.com/wangjia/pay/internal/gateway"
"github.com/wangjia/pay/internal/model"
"github.com/wangjia/pay/internal/providerbuild"
"github.com/wangjia/pay/internal/reconcile"
"github.com/wangjia/pay/internal/router"
"github.com/wangjia/pay/internal/store"
"github.com/wangjia/pay/internal/webhook"
@@ -53,11 +55,39 @@ func main() {
acctReg := accounts.New(config.C.Accounts)
pReg := providerbuild.BuildRegistry(acctReg) // 配置驱动:有 enabled 账户才注册对应渠道(P3)
// P5 多账户路由:按 config.routing.<channel> 选策略(缺省 round_robin)。
// limit_aware 用量数据源 P6 对账就绪前用空源(NopUsage,退化为 round_robin)。
acctPicker := accounts.NewRouter(acctReg, config.C.Routing, accounts.NopUsage{})
// limit_aware 用量数据源:P6 对账 Runner 周期 Refresh 的真实用量源(替 NopUsage)。
usage := reconcile.NewUsageSource(orderStore, time.Now)
acctPicker := accounts.NewRouter(acctReg, config.C.Routing, usage)
gw := gateway.New(orderStore, pReg, acctPicker, productResolver, notifier, "cn")
router.SetupV2(r, gw)
// P6 后台守护 / 对账:订单过期清理 + 用量刷新 + 查单对账收敛 + 已付抽查 +
// 退款修复扫描/卡滞告警(P4 T3 review 追加义务,归到本任务一起装配)。
// crypto 预留冷启动 Warm + 孤儿扫描留给 Task 6 追加(AddCryptoJobs)。
if config.C.Reconcile.Enabled {
rc := config.C.Reconcile
refundStore := store.NewRefundStore(db)
runner := reconcile.NewRunner()
runner.Add("order-expire", time.Duration(rc.ExpireEverySec)*time.Second,
reconcile.OrderExpirerTask(orderStore, time.Duration(rc.OrderTTLMin)*time.Minute, time.Now))
runner.Add("usage-refresh", time.Duration(rc.UsageEverySec)*time.Second,
reconcile.RefreshUsageTask(usage))
runner.Add("sync-pending", time.Duration(rc.SyncEverySec)*time.Second,
reconcile.SyncPendingTask(gw, 100))
runner.Add("paid-spotcheck", time.Duration(rc.SpotCheckEverySec)*time.Second,
reconcile.PaidSpotCheckTask(orderStore, pReg, time.Duration(rc.SpotCheckWindowMin)*time.Minute, time.Now))
runner.Add("refund-apply-sweep", time.Duration(rc.RefundApplyEverySec)*time.Second,
reconcile.RefundApplyTask(orderStore, refundStore, 200))
runner.Add("refund-stuck-alert", time.Duration(rc.RefundApplyEverySec)*time.Second,
reconcile.RefundStuckAlertTask(refundStore, time.Duration(rc.RefundStuckWarnMin)*time.Minute, time.Now))
// crypto 孤儿扫描(Task 6)在此追加:reconcile.AddCryptoJobs(runner, pReg, acctReg, orderStore, rc)
ctx := context.Background()
runner.RunOnce(ctx) // 启动预热:先跑一遍(usage 快照/过期清理/退款自愈立即生效)
runner.Start(ctx)
log.Printf("[reconcile] 后台守护已启动(过期清理/用量刷新/查单对账/已付抽查/退款修复扫描/退款卡滞告警)")
}
if config.C.QuerySync.Enabled {
orderSvc.StartQuerySync(
time.Duration(config.C.QuerySync.IntervalSec)*time.Second,