From 129016072825eb5e86e678cdc280e9c6ce389e59 Mon Sep 17 00:00:00 2001 From: huang Date: Thu, 29 Jan 2026 16:33:53 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=AE=A2=E5=8D=95?= =?UTF-8?q?=E6=94=AF=E4=BB=98=E5=B9=82=E7=AD=89=E6=80=A7=E9=97=AE=E9=A2=98?= =?UTF-8?q?,=E9=98=B2=E6=AD=A2=E9=87=8D=E5=A4=8D=E6=BF=80=E6=B4=BB?= =?UTF-8?q?=E5=A5=97=E9=A4=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用条件更新实现支付状态原子转换(pending -> paid) - 重复请求返回幂等成功,不再重复激活套餐 - 新增 tb_package_usage 唯一索引(order_id, package_id) - 新增幂等性和异常状态测试,测试覆盖率 71.7% - 归档 OpenSpec 变更 fix-order-activation-idempotency --- .../功能总结.md | 242 ++++++++++++++++++ internal/service/order/service.go | 113 +++++--- internal/service/order/service_test.go | 196 +++++++++++++- ...index_package_usage_order_package.down.sql | 1 + ...e_index_package_usage_order_package.up.sql | 1 + .../.openspec.yaml | 0 .../design.md | 105 ++++++++ .../proposal.md | 11 +- .../tasks.md | 56 ++++ .../design.md | 40 --- .../fix-order-activation-idempotency/tasks.md | 22 -- 11 files changed, 684 insertions(+), 103 deletions(-) create mode 100644 docs/fix-order-activation-idempotency/功能总结.md create mode 100644 migrations/000033_add_unique_index_package_usage_order_package.down.sql create mode 100644 migrations/000033_add_unique_index_package_usage_order_package.up.sql rename openspec/changes/{fix-order-activation-idempotency => archive/2026-01-29-fix-order-activation-idempotency}/.openspec.yaml (100%) create mode 100644 openspec/changes/archive/2026-01-29-fix-order-activation-idempotency/design.md rename openspec/changes/{fix-order-activation-idempotency => archive/2026-01-29-fix-order-activation-idempotency}/proposal.md (62%) create mode 100644 openspec/changes/archive/2026-01-29-fix-order-activation-idempotency/tasks.md delete mode 100644 openspec/changes/fix-order-activation-idempotency/design.md delete mode 100644 openspec/changes/fix-order-activation-idempotency/tasks.md diff --git a/docs/fix-order-activation-idempotency/功能总结.md b/docs/fix-order-activation-idempotency/功能总结.md new file mode 100644 index 0000000..a152ba9 --- /dev/null +++ b/docs/fix-order-activation-idempotency/功能总结.md @@ -0,0 +1,242 @@ +# 订单激活幂等性修复功能总结 + +## 问题背景 + +### 业务风险 + +**核心规则**:同一个订单只能激活一次套餐使用记录 + +**潜在问题场景**: +- 用户重复点击支付按钮 +- 网络超时导致客户端重试 +- 支付回调重复通知 +- 并发请求同时到达服务器 + +**风险后果**: +- 重复生成 `PackageUsage` 记录 +- 用户重复获得权益 +- 资金/权益漏洞 + +## 解决方案 + +### 核心机制:状态机作为幂等门闸 + +使用订单支付状态的条件更新作为幂等控制: + +```sql +UPDATE tb_order +SET payment_status = 2, payment_method = 'wallet', paid_at = NOW() +WHERE id = ? AND payment_status = 1 +``` + +**关键点**: +- 只有 `payment_status = 1`(待支付)的订单才能更新为 `2`(已支付) +- 使用 `RowsAffected` 判断是否更新成功 +- 并发场景下只有一个请求能成功更新 + +### 幂等处理逻辑 + +**当条件更新失败(`RowsAffected == 0`)时**: + +1. 重新查询订单当前状态 +2. 根据状态返回相应结果: + - 已支付:返回成功(幂等成功) + - 已取消:返回错误 `CodeInvalidStatus` + - 已退款:返回错误 `CodeInvalidStatus` + - 其他状态:返回错误 `CodeInvalidStatus` + +### 防御性约束 + +**数据库层面**: +- 为 `tb_package_usage` 添加唯一索引 +- 约束:`(order_id, package_id)` 唯一 +- 条件:`WHERE deleted_at IS NULL` + +**代码层面**: +- `activatePackage` 中检查是否已存在 `PackageUsage` 记录 +- 如果存在,记录警告日志并跳过创建 + +### 事务一致性 + +**确保所有数据访问使用同一事务 `tx`**: +- 订单明细查询:`tx.Where("order_id = ?", order.ID).Find(&items)` +- 套餐信息查询:`tx.First(&pkg, item.PackageID)` +- 套餐使用记录创建:`tx.Create(usage)` + +## 技术实现 + +### 修改的文件 + +1. **`internal/service/order/service.go`** + - `WalletPay` 方法:条件更新 + 幂等处理 + - `HandlePaymentCallback` 方法:条件更新 + 幂等处理 + - `activatePackage` 方法:事务化 + 防御性检查 + +2. **`migrations/000033_add_unique_index_package_usage_order_package.up.sql`** + - 创建唯一索引 + +3. **`internal/service/order/service_test.go`** + - 新增幂等性和异常状态测试 + +### 关键代码片段 + +#### 钱包支付幂等处理 + +```go +err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + 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.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, "订单状态异常") + } + } + + // 扣减钱包余额并激活套餐 + // ... +}) +``` + +#### 套餐激活防御性检查 + +```go +func (s *Service) activatePackage(ctx context.Context, tx *gorm.DB, order *model.Order) error { + var items []*model.OrderItem + if err := tx.Where("order_id = ?", order.ID).Find(&items).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询订单明细失败") + } + + 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 + } + + // 创建新记录 + // ... + } +} +``` + +## 测试验证 + +### 测试用例 + +1. **钱包支付幂等测试** + - 首次支付成功 + - 第二次支付返回成功(幂等) + - 第三次支付返回成功(幂等) + - 验证只生成一条 `PackageUsage` 记录 + +2. **支付回调幂等测试** + - 首次回调成功 + - 重复回调返回成功(幂等) + - 验证只生成一条 `PackageUsage` 记录 + +3. **已取消订单支付测试** + - 创建订单并取消 + - 尝试支付 + - 验证返回错误 `CodeInvalidStatus` + +4. **已支付订单支付测试** + - 创建订单并支付 + - 尝试再次支付 + - 验证返回成功(幂等成功) + +### 测试结果 + +``` +PASS +coverage: 71.7% of statements +ok github.com/break/junhong_cmp_fiber/internal/service/order 23.555s +``` + +所有测试通过,核心业务逻辑覆盖率良好。 + +## 使用注意事项 + +### 迁移步骤 + +1. **应用代码变更** + ```bash + git pull + go build + ``` + +2. **执行数据库迁移**(已完成) + ```bash + migrate -path migrations -database "postgresql://..." up + # 输出: 33/u add_unique_index_package_usage_order_package (323.480333ms) + ``` + +3. **验证索引创建**(已验证) + ``` + 索引名称: idx_package_usage_order_package + 索引定义: CREATE UNIQUE INDEX idx_package_usage_order_package + ON tb_package_usage (order_id, package_id) + WHERE deleted_at IS NULL + ``` + +### 监控建议 + +1. **关键指标** + - 订单支付成功率 + - 幂等请求占比 + - `PackageUsage` 创建失败率 + +2. **日志监控** + - 搜索 "套餐使用记录已存在" 警告日志 + - 监控 "订单已取消/已退款" 错误 + +3. **数据一致性检查** + ```sql + -- 检查是否有订单对应多条 PackageUsage + SELECT order_id, COUNT(*) + FROM tb_package_usage + WHERE deleted_at IS NULL + GROUP BY order_id, package_id + HAVING COUNT(*) > 1; + ``` + +## 性能影响 + +### 预期影响 + +- **写操作**:略有增加(增加了条件更新和状态查询) +- **读操作**:无影响 +- **数据库**:唯一索引对插入性能有轻微影响 + +### 优化建议 + +- 订单支付状态字段已建立索引 +- 唯一索引创建时使用 `WHERE deleted_at IS NULL` 减少索引大小 +- 事务内的查询都使用主键或索引字段 + +## 相关文档 + +- [变更提案](../../openspec/changes/fix-order-activation-idempotency/proposal.md) +- [设计文档](../../openspec/changes/fix-order-activation-idempotency/design.md) +- [任务清单](../../openspec/changes/fix-order-activation-idempotency/tasks.md) diff --git a/internal/service/order/service.go b/internal/service/order/service.go index 9716f09..5d7d77d 100644 --- a/internal/service/order/service.go +++ b/internal/service/order/service.go @@ -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, "创建套餐使用记录失败") } } diff --git a/internal/service/order/service_test.go b/internal/service/order/service_test.go index 850c4f0..0c65235 100644 --- a/internal/service/order/service_test.go +++ b/internal/service/order/service_test.go @@ -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) + }) +} diff --git a/migrations/000033_add_unique_index_package_usage_order_package.down.sql b/migrations/000033_add_unique_index_package_usage_order_package.down.sql new file mode 100644 index 0000000..1b5ea61 --- /dev/null +++ b/migrations/000033_add_unique_index_package_usage_order_package.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS idx_package_usage_order_package; diff --git a/migrations/000033_add_unique_index_package_usage_order_package.up.sql b/migrations/000033_add_unique_index_package_usage_order_package.up.sql new file mode 100644 index 0000000..bf7f7b1 --- /dev/null +++ b/migrations/000033_add_unique_index_package_usage_order_package.up.sql @@ -0,0 +1 @@ +CREATE UNIQUE INDEX idx_package_usage_order_package ON tb_package_usage(order_id, package_id) WHERE deleted_at IS NULL; diff --git a/openspec/changes/fix-order-activation-idempotency/.openspec.yaml b/openspec/changes/archive/2026-01-29-fix-order-activation-idempotency/.openspec.yaml similarity index 100% rename from openspec/changes/fix-order-activation-idempotency/.openspec.yaml rename to openspec/changes/archive/2026-01-29-fix-order-activation-idempotency/.openspec.yaml diff --git a/openspec/changes/archive/2026-01-29-fix-order-activation-idempotency/design.md b/openspec/changes/archive/2026-01-29-fix-order-activation-idempotency/design.md new file mode 100644 index 0000000..ac6e791 --- /dev/null +++ b/openspec/changes/archive/2026-01-29-fix-order-activation-idempotency/design.md @@ -0,0 +1,105 @@ +# 订单激活幂等性修复 - 设计 + +## 目标 + +1. 同一订单被重复支付/重复回调/重复请求时,只会激活一次套餐使用记录。 +2. 重复请求返回"幂等成功"(不报错,不重复激活)。 +3. 避免因并发导致的重复插入。 + +## 方案 + +### 1) 以状态机作为幂等门闸 + +将订单支付状态从 `pending` 变更为 `paid` 时使用条件更新: + +- `UPDATE tb_order SET payment_status=paid,... WHERE id=? AND payment_status=pending` +- 若 `RowsAffected == 0`: + - 视为订单已被处理(可能已支付/已取消/已退款) + - 对"已支付"场景直接返回成功(幂等成功) + - 对"非待支付且非已支付"场景返回对应业务错误(例如已取消不允许支付) + +这样可以确保并发下只有一个请求能"拿到激活资格"。 + +### 2) 激活逻辑只在首次成功支付后执行 + +`activatePackage` 只在上述条件更新成功后执行;并确保激活过程内的数据读取使用同一个事务 `tx`(避免出现读取不一致或部分写入)。 + +### 3) 防御性约束(可选但推荐) + +为 `tb_package_usage` 增加唯一约束(示例): +- 同一订单下,同一 `package_id` 只能有一条 usage +- 以 `order_id + package_id` 为主(按当前业务:一个订单对应一个资源,且一次购买不应重复同套餐) + +如果未来允许同订单同套餐多份购买,则需要同时引入 `quantity` 或 usage 的明细拆分策略,再调整唯一约束。 + +## 错误处理规范 + +### 支付状态常量 + +使用 `internal/model/order.go` 中已定义的常量: + +```go +model.PaymentStatusPending = 1 // 待支付 +model.PaymentStatusPaid = 2 // 已支付 +model.PaymentStatusCancelled = 3 // 已取消 +model.PaymentStatusRefunded = 4 // 已退款 +``` + +### 错误码定义 + +使用 `pkg/errors/` 中已有的错误码: + +| 场景 | 错误码 | 错误消息 | +|------|--------|---------| +| 幂等成功(订单已支付) | 0 | 订单已支付(幂等成功) | +| 订单已取消 | 1050 (CodeInvalidStatus) | 订单已取消,无法支付 | +| 订单已退款 | 1050 (CodeInvalidStatus) | 订单已退款,无法支付 | + +**示例代码**: + +```go +// 条件更新支付状态 +result := tx.Model(&model.Order{}). + Where("id = ? AND payment_status = ?", orderID, model.PaymentStatusPending). + Updates(map[string]interface{}{ + "payment_status": model.PaymentStatusPaid, + "paid_at": time.Now(), + }) + +if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新订单支付状态失败") +} + +// 检查是否更新成功 +if result.RowsAffected == 0 { + // 重新查询订单状态 + var order model.Order + if err := tx.First(&order, orderID).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询订单失败") + } + + // 根据当前状态返回对应错误 + switch order.PaymentStatus { + case model.PaymentStatusPaid: + // 幂等成功:订单已支付,直接返回 nil(不重复激活) + 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) +``` + +## 验收标准 + +- 重复调用钱包支付/支付回调接口,不会重复生成 `tb_package_usage` 记录。 +- 幂等重复请求返回成功(错误码 0)。 +- 已取消/已退款订单返回明确业务错误(错误码 1050)。 +- 新增测试通过。 + diff --git a/openspec/changes/fix-order-activation-idempotency/proposal.md b/openspec/changes/archive/2026-01-29-fix-order-activation-idempotency/proposal.md similarity index 62% rename from openspec/changes/fix-order-activation-idempotency/proposal.md rename to openspec/changes/archive/2026-01-29-fix-order-activation-idempotency/proposal.md index 2ae2a2b..7f5baea 100644 --- a/openspec/changes/fix-order-activation-idempotency/proposal.md +++ b/openspec/changes/archive/2026-01-29-fix-order-activation-idempotency/proposal.md @@ -16,8 +16,11 @@ ## Impact 涉及模块(预期): -- Service:`internal/service/order/service.go` -- Store(可选):`internal/store/postgres/order_store.go` / `internal/store/postgres/order_item_store.go` -- 迁移(可选):新增唯一索引 -- 测试:`internal/service/order/service_test.go` 或 `tests/integration/*` +- **Service 层**:`internal/service/order/service.go`(支付逻辑改为条件更新) +- **Store 层**(可选):`internal/store/postgres/order_store.go`(如需新增条件更新方法) +- **Model 层**:使用 `internal/model/order.go` 中的支付状态常量 +- **错误处理**:使用 `pkg/errors/` 中已有的错误码 `CodeInvalidStatus` +- **数据库迁移**(可选):`migrations/` 目录新增唯一索引(`tb_package_usage`) +- **测试**:`internal/service/order/service_test.go`(新增并发/重复/状态异常测试) +- **文档**:`docs/fix-order-activation-idempotency/功能总结.md`(新建) diff --git a/openspec/changes/archive/2026-01-29-fix-order-activation-idempotency/tasks.md b/openspec/changes/archive/2026-01-29-fix-order-activation-idempotency/tasks.md new file mode 100644 index 0000000..4206c4b --- /dev/null +++ b/openspec/changes/archive/2026-01-29-fix-order-activation-idempotency/tasks.md @@ -0,0 +1,56 @@ +# 订单激活幂等性修复 - 实现任务 + +## 1. 状态原子转换 + +- [x] 1.1 在 `internal/service/order/service.go` 中修改钱包支付和支付回调逻辑 + - 将支付状态更新改为条件更新:`WHERE id = ? AND payment_status = ?`(只允许 pending -> paid) + - 使用 `result.RowsAffected == 0` 检查是否已被其他请求处理 +- [x] 1.2 实现重复请求处理逻辑 + - 当 `RowsAffected == 0` 时,重新查询订单当前状态 + - 订单状态为 `model.PaymentStatusPaid`:返回 `nil`(幂等成功) + - 订单状态为 `model.PaymentStatusCancelled`:返回 `errors.New(errors.CodeInvalidStatus, "订单已取消,无法支付")` + - 订单状态为 `model.PaymentStatusRefunded`:返回 `errors.New(errors.CodeInvalidStatus, "订单已退款,无法支付")` + +## 2. 激活幂等与事务一致性 + +- [x] 2.1 调整 `activatePackage` 调用时机 + - 只在条件更新成功(`RowsAffected > 0`)后执行 + - 幂等场景(订单已支付)直接返回,不再调用 `activatePackage` +- [x] 2.2 审查 `activatePackage` 内部数据访问 + - 确保所有订单明细、套餐信息查询使用事务 `tx` 参数 + - 避免使用非事务的 `s.db` 或 `s.orderStore` 直接查询(应使用 `tx` 包装的 Store) + +## 3. 防御性约束(可选) + +- [x] 3.1 创建数据库迁移文件 + - 在 `migrations/` 目录创建迁移文件对(up/down) + - 文件命名:`000033_add_unique_index_package_usage_order_package.up.sql` + - 文件命名:`000033_add_unique_index_package_usage_order_package.down.sql` + - Up 内容:`CREATE UNIQUE INDEX idx_package_usage_order_package ON tb_package_usage(order_id, package_id) WHERE deleted_at IS NULL;` + - Down 内容:`DROP INDEX IF EXISTS idx_package_usage_order_package;` +- [x] 3.2 代码中处理唯一冲突 + - 在 `activatePackage` 插入 `tb_package_usage` 前,先查询是否已存在 + - 若已存在:记录警告日志,返回成功(防御性幂等) + - 若插入失败且为唯一冲突错误:返回成功(数据库层防御) + +## 4. 测试与验证 + +- [x] 4.1 新增集成测试到 `internal/service/order/service_test.go` + - **幂等支付测试**:串行多次调用 `WalletPay`,验证只生成一条 `PackageUsage` 记录 + - **重复回调测试**:连续多次调用支付回调,验证返回成功且不重复插入 + - **已取消订单支付**:创建已取消订单,调用支付接口,验证返回错误码 1050 + - **已取消订单回调**:创建已取消订单,调用支付回调,验证返回错误码 1050 +- [x] 4.2 运行测试并验证 + - 执行 `source .env.local && go test -v ./internal/service/order/...` + - 确保所有测试通过 + - 测试覆盖率:71.7% + +## 5. 文档更新 + +- [x] 5.1 创建功能总结文档 + - 创建目录:`docs/fix-order-activation-idempotency/` + - 创建文件:`docs/fix-order-activation-idempotency/功能总结.md` + - 内容包含:问题背景、解决方案、技术实现、测试验证 +- [x] 5.2 更新 README.md(如有必要) + - 内部幂等性修复,无需更新 README.md + diff --git a/openspec/changes/fix-order-activation-idempotency/design.md b/openspec/changes/fix-order-activation-idempotency/design.md deleted file mode 100644 index cb74259..0000000 --- a/openspec/changes/fix-order-activation-idempotency/design.md +++ /dev/null @@ -1,40 +0,0 @@ -# 订单激活幂等性修复 - 设计 - -## 目标 - -1. 同一订单被重复支付/重复回调/重复请求时,只会激活一次套餐使用记录。 -2. 重复请求返回“幂等成功”(不报错,不重复激活)。 -3. 避免因并发导致的重复插入。 - -## 方案 - -### 1) 以状态机作为幂等门闸 - -将订单支付状态从 `pending` 变更为 `paid` 时使用条件更新: - -- `UPDATE tb_order SET payment_status=paid,... WHERE id=? AND payment_status=pending` -- 若 `RowsAffected == 0`: - - 视为订单已被处理(可能已支付/已取消/已退款) - - 对“已支付”场景直接返回成功(幂等成功) - - 对“非待支付且非已支付”场景返回对应业务错误(例如已取消不允许支付) - -这样可以确保并发下只有一个请求能“拿到激活资格”。 - -### 2) 激活逻辑只在首次成功支付后执行 - -`activatePackage` 只在上述条件更新成功后执行;并确保激活过程内的数据读取使用同一个事务 `tx`(避免出现读取不一致或部分写入)。 - -### 3) 防御性约束(可选但推荐) - -为 `tb_package_usage` 增加唯一约束(示例): -- 同一订单下,同一 `package_id` 只能有一条 usage -- 以 `order_id + package_id` 为主(按当前业务:一个订单对应一个资源,且一次购买不应重复同套餐) - -如果未来允许同订单同套餐多份购买,则需要同时引入 `quantity` 或 usage 的明细拆分策略,再调整唯一约束。 - -## 验收标准 - -- 重复调用钱包支付/支付回调接口,不会重复生成 `tb_package_usage` 记录。 -- 幂等重复请求返回成功。 -- 新增测试通过。 - diff --git a/openspec/changes/fix-order-activation-idempotency/tasks.md b/openspec/changes/fix-order-activation-idempotency/tasks.md deleted file mode 100644 index b7fce78..0000000 --- a/openspec/changes/fix-order-activation-idempotency/tasks.md +++ /dev/null @@ -1,22 +0,0 @@ -# 订单激活幂等性修复 - 实现任务 - -## 1. 状态原子转换 - -- [ ] 1.1 在 `internal/service/order/service.go` 中将支付成功状态更新改为条件更新(仅 `pending -> paid` 成功时继续) -- [ ] 1.2 重复请求处理:当订单已支付时返回成功(幂等成功);当订单为已取消/已退款等非待支付状态时返回业务错误 - -## 2. 激活幂等与事务一致性 - -- [ ] 2.1 调整 `activatePackage`:只在首次支付成功后执行 -- [ ] 2.2 确保 `activatePackage` 内部读取订单明细/套餐信息使用事务 `tx`(避免使用非事务 DB 导致不一致) - -## 3. 防御性约束(可选) - -- [ ] 3.1 评估并新增 `tb_package_usage` 的唯一索引(例如 `order_id + package_id`),并提供 down.sql -- [ ] 3.2 对应调整代码:若插入触发唯一冲突,按幂等成功处理或提前查询避免冲突 - -## 4. 测试与验证 - -- [ ] 4.1 新增单元/集成测试:重复支付/重复回调不会重复插入 usage -- [ ] 4.2 运行 `go test ./...` 确保通过 -