feat: 实现订单超时自动取消功能,支持钱包余额解冻和 Asynq Scheduler 统一调度
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m58s
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:
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user