fix: 修复订单支付幂等性问题,防止重复激活套餐
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:
2026-01-29 16:33:53 +08:00
parent 2b0f79be81
commit 1290160728
11 changed files with 684 additions and 103 deletions

View File

@@ -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)
})
}