feat(v2): reconcile Runner 骨架 + 订单级过期清理(超 TTL pending 单/零尝试孤儿单自动关闭)
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
package reconcile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pay/internal/store"
|
||||
)
|
||||
|
||||
// OrderExpirerTask 返回「关闭超 TTL 未付 pending 订单」的周期任务体。
|
||||
// cutoff = now()-ttl,now 注入(测试确定性);幂等条件 UPDATE(见 store.ExpireStaleOrders)。
|
||||
func OrderExpirerTask(orders *store.OrderStore, ttl time.Duration, now func() time.Time) func(ctx context.Context) error {
|
||||
return func(ctx context.Context) error {
|
||||
n, err := orders.ExpireStaleOrders(now().Add(-ttl), 500)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
log.Printf("[reconcile] 过期关闭 %d 个超时未付订单(TTL=%s)", n, ttl)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package reconcile_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pay/internal/model"
|
||||
"github.com/wangjia/pay/internal/reconcile"
|
||||
"github.com/wangjia/pay/internal/store"
|
||||
)
|
||||
|
||||
func TestOrderExpirerTaskClosesStalePending(t *testing.T) {
|
||||
db := model.OpenTestDB(t)
|
||||
s := store.NewOrderStore(db)
|
||||
now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
o := &model.OrderV2{OutTradeNo: "OLD", AmountMinor: 100, Currency: "USDT", Status: model.OrderPendingV2}
|
||||
_ = s.CreateOrder(o)
|
||||
_ = db.Model(&model.OrderV2{}).Where("out_trade_no = ?", "OLD").
|
||||
Update("created_at", now.Add(-2*time.Hour)).Error
|
||||
|
||||
task := reconcile.OrderExpirerTask(s, time.Hour, func() time.Time { return now })
|
||||
if err := task(context.Background()); err != nil {
|
||||
t.Fatalf("task: %v", err)
|
||||
}
|
||||
got, _ := s.GetOrder("OLD")
|
||||
if got.Status != model.OrderExpiredV2 {
|
||||
t.Fatalf("超时 pending 单应 expired, got %v", got.Status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Package reconcile 是 pay v2 的后台守护/对账装配层:把多个幂等、崩溃安全、
|
||||
// 可重跑的周期任务(订单过期清理 / webhook 死信硬化 / crypto 预留冷启动 /
|
||||
// 用量刷新 / 查单对账 / 链上孤儿发现)挂到统一 Runner。它处于 main 之下的装配层,
|
||||
// 可依赖 store/gateway/accounts/provider;gateway/provider 核心不反向依赖它。
|
||||
package reconcile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Task 一个周期任务:名字 + 间隔 + 幂等可重跑的 Run。
|
||||
type Task struct {
|
||||
Name string
|
||||
Interval time.Duration
|
||||
Run func(ctx context.Context) error
|
||||
}
|
||||
|
||||
// Runner 持有一组周期任务,逐个跑(RunOnce)或各自 ticker 常驻(Start)。
|
||||
type Runner struct {
|
||||
tasks []Task
|
||||
logf func(format string, args ...any)
|
||||
}
|
||||
|
||||
func NewRunner() *Runner { return &Runner{logf: log.Printf} }
|
||||
|
||||
// Add 注册一个周期任务。
|
||||
func (r *Runner) Add(name string, interval time.Duration, run func(ctx context.Context) error) {
|
||||
r.tasks = append(r.tasks, Task{Name: name, Interval: interval, Run: run})
|
||||
}
|
||||
|
||||
// exec 跑单个任务一次:panic recover + error 记录,绝不外抛(单任务失败不拖垮其它)。
|
||||
func (r *Runner) exec(ctx context.Context, t Task) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
r.logf("[reconcile] 任务 %s panic 已恢复: %v", t.Name, rec)
|
||||
}
|
||||
}()
|
||||
if err := t.Run(ctx); err != nil {
|
||||
r.logf("[reconcile] 任务 %s: %v", t.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// RunOnce 顺序跑一遍全部任务(启动预热 + 单测入口)。
|
||||
func (r *Runner) RunOnce(ctx context.Context) {
|
||||
for _, t := range r.tasks {
|
||||
r.exec(ctx, t)
|
||||
}
|
||||
}
|
||||
|
||||
// Start 每任务一 goroutine + 独立 ticker 常驻;ctx 取消即退出。每 tick 崩溃安全。
|
||||
func (r *Runner) Start(ctx context.Context) {
|
||||
for _, t := range r.tasks {
|
||||
t := t
|
||||
go func() {
|
||||
tk := time.NewTicker(t.Interval)
|
||||
defer tk.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-tk.C:
|
||||
r.exec(ctx, t)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package reconcile_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pay/internal/reconcile"
|
||||
)
|
||||
|
||||
func TestRunnerRunOnceExecutesAllAndRecoversPanic(t *testing.T) {
|
||||
r := reconcile.NewRunner()
|
||||
var a, b int
|
||||
r.Add("inc-a", time.Minute, func(context.Context) error { a++; return nil })
|
||||
r.Add("boom", time.Minute, func(context.Context) error { panic("kaboom") }) // 不得拖垮后续
|
||||
r.Add("inc-b", time.Minute, func(context.Context) error { b++; return errors.New("soft") })
|
||||
|
||||
r.RunOnce(context.Background()) // panic 被 recover,error 被记录,均不中断
|
||||
if a != 1 || b != 1 {
|
||||
t.Fatalf("a=%d b=%d, want 1/1(panic 任务不应阻断其它)", a, b)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user