# 订单激活幂等性修复功能总结 ## 问题背景 ### 业务风险 **核心规则**:同一个订单只能激活一次套餐使用记录 **潜在问题场景**: - 用户重复点击支付按钮 - 网络超时导致客户端重试 - 支付回调重复通知 - 并发请求同时到达服务器 **风险后果**: - 重复生成 `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)