feat(v2): reconcile Runner 骨架 + 订单级过期清理(超 TTL pending 单/零尝试孤儿单自动关闭)

This commit is contained in:
wangjia
2026-07-10 17:19:38 +08:00
parent aa14204640
commit 6427e8c848
6 changed files with 224 additions and 0 deletions
+32
View File
@@ -3,6 +3,7 @@ package store
import (
"errors"
"fmt"
"time"
"gorm.io/gorm"
@@ -94,3 +95,34 @@ func (s *OrderStore) ApplyRefundToOrder(outTradeNo string, fully bool) (bool, er
}
return res.RowsAffected > 0, nil
}
// ExpireStaleOrders closes pending orders whose created_at predates cutoff
// (TTL 到期未付),条件 UPDATE 只翻 status=pending 的行——与并发 settle 翻 paid
// 互斥(谁先谁赢,另一方 RowsAffected=0),故幂等且崩溃安全。含"零尝试孤儿单"
// (建单后 CreateAttempt 失败、无 attempt 的 pending 单):它 status 仍是 pending,
// 同样被扫到关闭(P2-T4 复审记录的缺口)。cutoff 由调用方用注入时钟算,store 不碰时钟。
//
// 注:与 ExpirePendingAttempts 语义不同——那个是 attempt 级(标 attempt expired,
// 订单不动,retry 前用);这个是 order 级(标整张订单 expired)。
func (s *OrderStore) ExpireStaleOrders(cutoff time.Time, limit int) (int64, error) {
if limit <= 0 || limit > 1000 {
limit = 500
}
// 先选主键再批量 UPDATE:回避 "UPDATE ... ORDER BY LIMIT" 的方言差异(sqlite/mysql)。
var ids []uint64
if err := s.db.Model(&model.OrderV2{}).
Where("status = ? AND created_at < ?", model.OrderPendingV2, cutoff).
Order("id ASC").Limit(limit).Pluck("id", &ids).Error; err != nil {
return 0, fmt.Errorf("store.ExpireStaleOrders select: %w", err)
}
if len(ids) == 0 {
return 0, nil
}
res := s.db.Model(&model.OrderV2{}).
Where("id IN ? AND status = ?", ids, model.OrderPendingV2). // status 守卫兜住 select→update 间的并发翻转
Update("status", model.OrderExpiredV2)
if res.Error != nil {
return 0, fmt.Errorf("store.ExpireStaleOrders update: %w", res.Error)
}
return res.RowsAffected, nil
}