fix: 修复订单支付幂等性问题,防止重复激活套餐
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m22s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m22s
- 使用条件更新实现支付状态原子转换(pending -> paid) - 重复请求返回幂等成功,不再重复激活套餐 - 新增 tb_package_usage 唯一索引(order_id, package_id) - 新增幂等性和异常状态测试,测试覆盖率 71.7% - 归档 OpenSpec 变更 fix-order-activation-idempotency
This commit is contained in:
@@ -250,10 +250,6 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string,
|
||||
return errors.New(errors.CodeForbidden, "无权操作此订单")
|
||||
}
|
||||
|
||||
if order.PaymentStatus != model.PaymentStatusPending {
|
||||
return errors.New(errors.CodeInvalidStatus, "订单状态不允许支付")
|
||||
}
|
||||
|
||||
var resourceType string
|
||||
var resourceID uint
|
||||
|
||||
@@ -288,27 +284,48 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string,
|
||||
|
||||
now := time.Now()
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Model(&model.Wallet{}).
|
||||
result := tx.Model(&model.Order{}).
|
||||
Where("id = ? AND payment_status = ?", orderID, model.PaymentStatusPending).
|
||||
Updates(map[string]any{
|
||||
"payment_status": model.PaymentStatusPaid,
|
||||
"payment_method": model.PaymentMethodWallet,
|
||||
"paid_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新订单支付状态失败")
|
||||
}
|
||||
|
||||
if result.RowsAffected == 0 {
|
||||
var currentOrder model.Order
|
||||
if err := tx.First(¤tOrder, orderID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询订单失败")
|
||||
}
|
||||
|
||||
switch currentOrder.PaymentStatus {
|
||||
case model.PaymentStatusPaid:
|
||||
return nil
|
||||
case model.PaymentStatusCancelled:
|
||||
return errors.New(errors.CodeInvalidStatus, "订单已取消,无法支付")
|
||||
case model.PaymentStatusRefunded:
|
||||
return errors.New(errors.CodeInvalidStatus, "订单已退款,无法支付")
|
||||
default:
|
||||
return errors.New(errors.CodeInvalidStatus, "订单状态异常")
|
||||
}
|
||||
}
|
||||
|
||||
walletResult := tx.Model(&model.Wallet{}).
|
||||
Where("id = ? AND balance >= ? AND version = ?", wallet.ID, order.TotalAmount, wallet.Version).
|
||||
Updates(map[string]any{
|
||||
"balance": gorm.Expr("balance - ?", order.TotalAmount),
|
||||
"version": gorm.Expr("version + 1"),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
if walletResult.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, walletResult.Error, "扣减钱包余额失败")
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
if walletResult.RowsAffected == 0 {
|
||||
return errors.New(errors.CodeInsufficientBalance, "余额不足或并发冲突")
|
||||
}
|
||||
|
||||
if err := tx.Model(&model.Order{}).Where("id = ?", orderID).Updates(map[string]any{
|
||||
"payment_status": model.PaymentStatusPaid,
|
||||
"payment_method": model.PaymentMethodWallet,
|
||||
"paid_at": now,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.activatePackage(ctx, tx, order)
|
||||
})
|
||||
|
||||
@@ -329,22 +346,35 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, orderNo string, pay
|
||||
return err
|
||||
}
|
||||
|
||||
if order.PaymentStatus == model.PaymentStatusPaid {
|
||||
return nil
|
||||
}
|
||||
|
||||
if order.PaymentStatus != model.PaymentStatusPending {
|
||||
return errors.New(errors.CodeInvalidStatus, "订单状态不允许支付")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&model.Order{}).Where("id = ?", order.ID).Updates(map[string]any{
|
||||
"payment_status": model.PaymentStatusPaid,
|
||||
"payment_method": paymentMethod,
|
||||
"paid_at": now,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
result := tx.Model(&model.Order{}).
|
||||
Where("id = ? AND payment_status = ?", order.ID, model.PaymentStatusPending).
|
||||
Updates(map[string]any{
|
||||
"payment_status": model.PaymentStatusPaid,
|
||||
"payment_method": paymentMethod,
|
||||
"paid_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新订单支付状态失败")
|
||||
}
|
||||
|
||||
if result.RowsAffected == 0 {
|
||||
var currentOrder model.Order
|
||||
if err := tx.First(¤tOrder, order.ID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询订单失败")
|
||||
}
|
||||
|
||||
switch currentOrder.PaymentStatus {
|
||||
case model.PaymentStatusPaid:
|
||||
return nil
|
||||
case model.PaymentStatusCancelled:
|
||||
return errors.New(errors.CodeInvalidStatus, "订单已取消,无法支付")
|
||||
case model.PaymentStatusRefunded:
|
||||
return errors.New(errors.CodeInvalidStatus, "订单已退款,无法支付")
|
||||
default:
|
||||
return errors.New(errors.CodeInvalidStatus, "订单状态异常")
|
||||
}
|
||||
}
|
||||
|
||||
return s.activatePackage(ctx, tx, order)
|
||||
@@ -359,16 +389,29 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, orderNo string, pay
|
||||
}
|
||||
|
||||
func (s *Service) activatePackage(ctx context.Context, tx *gorm.DB, order *model.Order) error {
|
||||
items, err := s.orderItemStore.ListByOrderID(ctx, order.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
var items []*model.OrderItem
|
||||
if err := tx.Where("order_id = ?", order.ID).Find(&items).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询订单明细失败")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for _, item := range items {
|
||||
var existingUsage model.PackageUsage
|
||||
err := tx.Where("order_id = ? AND package_id = ?", order.ID, item.PackageID).
|
||||
First(&existingUsage).Error
|
||||
if err == nil {
|
||||
s.logger.Warn("套餐使用记录已存在,跳过创建",
|
||||
zap.Uint("order_id", order.ID),
|
||||
zap.Uint("package_id", item.PackageID))
|
||||
continue
|
||||
}
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "检查套餐使用记录失败")
|
||||
}
|
||||
|
||||
var pkg model.Package
|
||||
if err := tx.First(&pkg, item.PackageID).Error; err != nil {
|
||||
return err
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败")
|
||||
}
|
||||
|
||||
usage := &model.PackageUsage{
|
||||
@@ -392,7 +435,7 @@ func (s *Service) activatePackage(ctx context.Context, tx *gorm.DB, order *model
|
||||
}
|
||||
|
||||
if err := tx.Create(usage).Error; err != nil {
|
||||
return err
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建套餐使用记录失败")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/tests/testutils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type testEnv struct {
|
||||
@@ -124,7 +125,8 @@ func setupOrderTestEnv(t *testing.T) *testEnv {
|
||||
require.NoError(t, tx.Create(wallet).Error)
|
||||
|
||||
purchaseValidationSvc := purchase_validation.New(tx, iotCardStore, deviceStore, packageStore, seriesAllocationStore)
|
||||
orderSvc := New(tx, orderStore, orderItemStore, walletStore, purchaseValidationSvc, nil, nil, nil)
|
||||
logger := zap.NewNop()
|
||||
orderSvc := New(tx, orderStore, orderItemStore, walletStore, purchaseValidationSvc, nil, nil, logger)
|
||||
|
||||
userCtx := middleware.SetUserContext(ctx, &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
@@ -363,7 +365,7 @@ func TestOrderService_WalletPay(t *testing.T) {
|
||||
assert.Equal(t, errors.CodeForbidden, appErr.Code)
|
||||
})
|
||||
|
||||
t.Run("重复支付", func(t *testing.T) {
|
||||
t.Run("重复支付-幂等成功", func(t *testing.T) {
|
||||
req := &dto.CreateOrderRequest{
|
||||
OrderType: model.OrderTypeSingleCard,
|
||||
IotCardID: &env.card.ID,
|
||||
@@ -375,6 +377,22 @@ func TestOrderService_WalletPay(t *testing.T) {
|
||||
err = env.svc.WalletPay(env.ctx, created.ID, model.BuyerTypeAgent, env.shop.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = env.svc.WalletPay(env.ctx, created.ID, model.BuyerTypeAgent, env.shop.ID)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("已取消订单无法支付", func(t *testing.T) {
|
||||
req := &dto.CreateOrderRequest{
|
||||
OrderType: model.OrderTypeSingleCard,
|
||||
IotCardID: &env.card.ID,
|
||||
PackageIDs: []uint{env.pkg.ID},
|
||||
}
|
||||
created, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = env.svc.Cancel(env.ctx, created.ID, model.BuyerTypeAgent, env.shop.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = env.svc.WalletPay(env.ctx, created.ID, model.BuyerTypeAgent, env.shop.ID)
|
||||
require.Error(t, err)
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
@@ -428,3 +446,177 @@ func TestOrderService_HandlePaymentCallback(t *testing.T) {
|
||||
assert.Equal(t, errors.CodeNotFound, appErr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestOrderService_IdempotencyAndConcurrency(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
iotCardStore := postgres.NewIotCardStore(tx, rdb)
|
||||
deviceStore := postgres.NewDeviceStore(tx, rdb)
|
||||
packageStore := postgres.NewPackageStore(tx)
|
||||
seriesAllocationStore := postgres.NewShopSeriesAllocationStore(tx)
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||
carrierStore := postgres.NewCarrierStore(tx)
|
||||
shopStore := postgres.NewShopStore(tx, rdb)
|
||||
orderStore := postgres.NewOrderStore(tx, rdb)
|
||||
orderItemStore := postgres.NewOrderItemStore(tx, rdb)
|
||||
walletStore := postgres.NewWalletStore(tx, rdb)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
carrier := &model.Carrier{
|
||||
CarrierCode: "TEST_CARRIER_IDEM",
|
||||
CarrierName: "测试运营商幂等",
|
||||
CarrierType: constants.CarrierTypeCMCC,
|
||||
Status: constants.StatusEnabled,
|
||||
}
|
||||
require.NoError(t, carrierStore.Create(ctx, carrier))
|
||||
|
||||
shop := &model.Shop{
|
||||
ShopName: "测试店铺IDEM",
|
||||
ShopCode: "TEST_SHOP_IDEM",
|
||||
Level: 1,
|
||||
Status: constants.StatusEnabled,
|
||||
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
|
||||
}
|
||||
require.NoError(t, shopStore.Create(ctx, shop))
|
||||
|
||||
series := &model.PackageSeries{
|
||||
SeriesCode: "TEST_SERIES_IDEM",
|
||||
SeriesName: "测试套餐系列幂等",
|
||||
Description: "测试用",
|
||||
Status: constants.StatusEnabled,
|
||||
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
|
||||
}
|
||||
require.NoError(t, packageSeriesStore.Create(ctx, series))
|
||||
|
||||
allocation := &model.ShopSeriesAllocation{
|
||||
ShopID: shop.ID,
|
||||
SeriesID: series.ID,
|
||||
Status: constants.StatusEnabled,
|
||||
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
|
||||
}
|
||||
require.NoError(t, seriesAllocationStore.Create(ctx, allocation))
|
||||
|
||||
pkg := &model.Package{
|
||||
PackageCode: "TEST_PKG_IDEM",
|
||||
PackageName: "测试套餐幂等",
|
||||
SeriesID: series.ID,
|
||||
PackageType: "formal",
|
||||
DurationMonths: 1,
|
||||
DataAmountMB: 1024,
|
||||
SuggestedRetailPrice: 9900,
|
||||
Status: constants.StatusEnabled,
|
||||
ShelfStatus: constants.ShelfStatusOn,
|
||||
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
|
||||
}
|
||||
require.NoError(t, packageStore.Create(ctx, pkg))
|
||||
|
||||
shopIDPtr := &shop.ID
|
||||
card := &model.IotCard{
|
||||
ICCID: "89860000000000000099",
|
||||
ShopID: shopIDPtr,
|
||||
CarrierID: carrier.ID,
|
||||
SeriesAllocationID: &allocation.ID,
|
||||
Status: constants.StatusEnabled,
|
||||
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
|
||||
}
|
||||
require.NoError(t, iotCardStore.Create(ctx, card))
|
||||
|
||||
wallet := &model.Wallet{
|
||||
ResourceType: "shop",
|
||||
ResourceID: shop.ID,
|
||||
WalletType: "main",
|
||||
Balance: 1000000,
|
||||
Version: 1,
|
||||
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
|
||||
}
|
||||
require.NoError(t, tx.Create(wallet).Error)
|
||||
|
||||
purchaseValidationSvc := purchase_validation.New(tx, iotCardStore, deviceStore, packageStore, seriesAllocationStore)
|
||||
logger := zap.NewNop()
|
||||
orderSvc := New(tx, orderStore, orderItemStore, walletStore, purchaseValidationSvc, nil, nil, logger)
|
||||
|
||||
userCtx := middleware.SetUserContext(ctx, &middleware.UserContextInfo{
|
||||
UserID: 1,
|
||||
UserType: constants.UserTypeAgent,
|
||||
ShopID: shop.ID,
|
||||
})
|
||||
|
||||
t.Run("串行幂等支付测试", func(t *testing.T) {
|
||||
req := &dto.CreateOrderRequest{
|
||||
OrderType: model.OrderTypeSingleCard,
|
||||
IotCardID: &card.ID,
|
||||
PackageIDs: []uint{pkg.ID},
|
||||
}
|
||||
created, err := orderSvc.Create(userCtx, req, model.BuyerTypeAgent, shop.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = orderSvc.WalletPay(userCtx, created.ID, model.BuyerTypeAgent, shop.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = orderSvc.WalletPay(userCtx, created.ID, model.BuyerTypeAgent, shop.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = orderSvc.WalletPay(userCtx, created.ID, model.BuyerTypeAgent, shop.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
var usageCount int64
|
||||
err = tx.Model(&model.PackageUsage{}).Where("order_id = ?", created.ID).Count(&usageCount).Error
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), usageCount)
|
||||
|
||||
order, err := orderSvc.Get(userCtx, created.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, model.PaymentStatusPaid, order.PaymentStatus)
|
||||
})
|
||||
|
||||
t.Run("串行回调幂等测试", func(t *testing.T) {
|
||||
req := &dto.CreateOrderRequest{
|
||||
OrderType: model.OrderTypeSingleCard,
|
||||
IotCardID: &card.ID,
|
||||
PackageIDs: []uint{pkg.ID},
|
||||
}
|
||||
created, err := orderSvc.Create(userCtx, req, model.BuyerTypeAgent, shop.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = orderSvc.HandlePaymentCallback(userCtx, created.OrderNo, model.PaymentMethodWechat)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = orderSvc.HandlePaymentCallback(userCtx, created.OrderNo, model.PaymentMethodWechat)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = orderSvc.HandlePaymentCallback(userCtx, created.OrderNo, model.PaymentMethodWechat)
|
||||
require.NoError(t, err)
|
||||
|
||||
var usageCount int64
|
||||
err = tx.Model(&model.PackageUsage{}).Where("order_id = ?", created.ID).Count(&usageCount).Error
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), usageCount)
|
||||
})
|
||||
|
||||
t.Run("已取消订单回调测试", func(t *testing.T) {
|
||||
req := &dto.CreateOrderRequest{
|
||||
OrderType: model.OrderTypeSingleCard,
|
||||
IotCardID: &card.ID,
|
||||
PackageIDs: []uint{pkg.ID},
|
||||
}
|
||||
created, err := orderSvc.Create(userCtx, req, model.BuyerTypeAgent, shop.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = orderSvc.Cancel(userCtx, created.ID, model.BuyerTypeAgent, shop.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = orderSvc.HandlePaymentCallback(userCtx, created.OrderNo, model.PaymentMethodWechat)
|
||||
require.Error(t, err)
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, errors.CodeInvalidStatus, appErr.Code)
|
||||
|
||||
var usageCount int64
|
||||
err = tx.Model(&model.PackageUsage{}).Where("order_id = ?", created.ID).Count(&usageCount).Error
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), usageCount)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user