feat: 实现订单超时自动取消功能,支持钱包余额解冻和 Asynq Scheduler 统一调度
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m58s

- 新增 expires_at 字段和复合索引,待支付订单 30 分钟超时自动取消
- 实现 cancelOrder/unfreezeWalletForCancel 钱包余额解冻逻辑
- 创建 Asynq 定时任务(order_expire/alert_check/data_cleanup)
- 将原有 time.Ticker 轮询迁移至 Asynq Scheduler 统一调度
- 同步 delta specs 到 main specs 并归档变更
This commit is contained in:
2026-02-28 17:16:15 +08:00
parent 5bb0ff0ddf
commit e661b59bb9
35 changed files with 1157 additions and 314 deletions

View File

@@ -629,6 +629,7 @@ func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest
purchaseRole := ""
var sellerShopID *uint = resourceShopID
var sellerCostPrice int64
var expiresAt *time.Time // 待支付订单设置过期时间,立即支付的订单为 nil
// 场景判断offline平台代购、wallet代理钱包支付、其他待支付
if req.PaymentMethod == model.PaymentMethodOffline {
@@ -748,6 +749,7 @@ func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest
OperatorType: operatorType,
ActualPaidAmount: actualPaidAmount,
PurchaseRole: purchaseRole,
ExpiresAt: expiresAt,
}
items := s.buildOrderItems(userID, validationResult.Packages)
@@ -780,6 +782,9 @@ func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest
} else {
// 其他支付方式创建待支付订单H5 端支持 wechat/alipay
// 待支付订单设置过期时间,超过 30 分钟未支付则自动取消
expireTime := now.Add(constants.OrderExpireTimeout)
order.ExpiresAt = &expireTime
if err := s.orderStore.Create(ctx, order, items); err != nil {
return nil, err
}
@@ -908,7 +913,7 @@ func (s *Service) createWalletTransaction(ctx context.Context, tx *gorm.DB, wall
RelatedShopID: relatedShopID,
Remark: &remark,
Creator: userID,
ShopIDTag: 0, // 将在下面填充
ShopIDTag: 0, // 将在下面填充
EnterpriseIDTag: nil,
}
@@ -1144,7 +1149,124 @@ func (s *Service) Cancel(ctx context.Context, id uint, buyerType string, buyerID
return errors.New(errors.CodeInvalidStatus, "只能取消待支付的订单")
}
return s.orderStore.UpdatePaymentStatus(ctx, id, model.PaymentStatusCancelled, nil)
return s.cancelOrder(ctx, order)
}
// CancelExpiredOrders 批量取消已超时的待支付订单
// 返回已取消的订单数量和错误
func (s *Service) CancelExpiredOrders(ctx context.Context) (int, error) {
startTime := time.Now()
orders, err := s.orderStore.FindExpiredOrders(ctx, constants.OrderExpireBatchSize)
if err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询超时订单失败")
}
if len(orders) == 0 {
return 0, nil
}
cancelledCount := 0
for _, order := range orders {
if err := s.cancelOrder(ctx, order); err != nil {
s.logger.Error("自动取消超时订单失败",
zap.Uint("order_id", order.ID),
zap.String("order_no", order.OrderNo),
zap.Error(err),
)
continue
}
cancelledCount++
}
s.logger.Info("批量取消超时订单完成",
zap.Int("total", len(orders)),
zap.Int("cancelled", cancelledCount),
zap.Int("failed", len(orders)-cancelledCount),
zap.Duration("duration", time.Since(startTime)),
)
return cancelledCount, nil
}
// cancelOrder 内部取消订单逻辑(共用于手动取消和自动超时取消)
// 在事务中执行:更新订单状态为已取消、清除过期时间、解冻钱包余额(如有)
func (s *Service) cancelOrder(ctx context.Context, order *model.Order) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 使用条件更新确保幂等性:只有待支付的订单才能取消
result := tx.Model(&model.Order{}).
Where("id = ? AND payment_status = ?", order.ID, model.PaymentStatusPending).
Updates(map[string]any{
"payment_status": model.PaymentStatusCancelled,
"expires_at": nil,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新订单状态失败")
}
if result.RowsAffected == 0 {
// 订单已被处理(幂等),直接返回
return nil
}
// 检查是否需要解冻钱包余额(混合支付场景)
// 当前系统中钱包支付订单是立即支付的,不会进入待支付状态
// 此处为预留逻辑,支持未来混合支付场景的钱包解冻
if order.PaymentMethod == model.PaymentMethodWallet {
if err := s.unfreezeWalletForCancel(ctx, tx, order); err != nil {
s.logger.Error("取消订单时解冻钱包失败",
zap.Uint("order_id", order.ID),
zap.Error(err),
)
return err
}
}
return nil
})
}
// unfreezeWalletForCancel 取消订单时解冻钱包余额
// 根据买家类型和订单金额确定解冻金额和目标钱包
func (s *Service) unfreezeWalletForCancel(ctx context.Context, tx *gorm.DB, order *model.Order) error {
if order.BuyerType == model.BuyerTypeAgent {
// 代理商钱包(店铺钱包)
wallet, err := s.agentWalletStore.GetMainWallet(ctx, order.BuyerID)
if err != nil {
return errors.Wrap(errors.CodeWalletNotFound, err, "查询代理钱包失败")
}
return s.agentWalletStore.UnfreezeBalanceWithTx(ctx, tx, wallet.ID, order.TotalAmount)
} else if order.BuyerType == model.BuyerTypePersonal {
// 个人客户钱包(卡/设备钱包)
var resourceType string
var resourceID uint
if order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil {
resourceType = "iot_card"
resourceID = *order.IotCardID
} else if order.OrderType == model.OrderTypeDevice && order.DeviceID != nil {
resourceType = "device"
resourceID = *order.DeviceID
} else {
return errors.New(errors.CodeInternalError, "无法确定钱包归属")
}
wallet, err := s.cardWalletStore.GetByResourceTypeAndID(ctx, resourceType, resourceID)
if err != nil {
return errors.Wrap(errors.CodeWalletNotFound, err, "查询卡钱包失败")
}
// 卡钱包解冻:直接减少冻结余额
result := tx.Model(&model.CardWallet{}).
Where("id = ? AND frozen_balance >= ?", wallet.ID, order.TotalAmount).
Updates(map[string]any{
"frozen_balance": gorm.Expr("frozen_balance - ?", order.TotalAmount),
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeInsufficientBalance, "冻结余额不足,无法解冻")
}
return nil
}
return nil
}
func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string, buyerID uint) error {
@@ -1208,6 +1330,7 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string,
"payment_status": model.PaymentStatusPaid,
"payment_method": model.PaymentMethodWallet,
"paid_at": now,
"expires_at": nil, // 支付成功,清除过期时间
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新订单支付状态失败")
@@ -1267,6 +1390,7 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string,
"payment_status": model.PaymentStatusPaid,
"payment_method": model.PaymentMethodWallet,
"paid_at": now,
"expires_at": nil, // 支付成功,清除过期时间
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新订单支付状态失败")
@@ -1332,6 +1456,7 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, orderNo string, pay
"payment_status": model.PaymentStatusPaid,
"payment_method": paymentMethod,
"paid_at": now,
"expires_at": nil, // 支付成功,清除过期时间
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新订单支付状态失败")
@@ -1844,6 +1969,10 @@ func (s *Service) buildOrderResponse(order *model.Order, items []*model.OrderIte
Items: itemResponses,
CreatedAt: order.CreatedAt,
UpdatedAt: order.UpdatedAt,
// 订单超时信息
ExpiresAt: order.ExpiresAt,
IsExpired: order.ExpiresAt != nil && order.PaymentStatus == model.PaymentStatusPending && time.Now().After(*order.ExpiresAt),
}
}