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
6.7 KiB
6.7 KiB
订单激活幂等性修复功能总结
问题背景
业务风险
核心规则:同一个订单只能激活一次套餐使用记录
潜在问题场景:
- 用户重复点击支付按钮
- 网络超时导致客户端重试
- 支付回调重复通知
- 并发请求同时到达服务器
风险后果:
- 重复生成
PackageUsage记录 - 用户重复获得权益
- 资金/权益漏洞
解决方案
核心机制:状态机作为幂等门闸
使用订单支付状态的条件更新作为幂等控制:
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)时:
- 重新查询订单当前状态
- 根据状态返回相应结果:
- 已支付:返回成功(幂等成功)
- 已取消:返回错误
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)
技术实现
修改的文件
-
internal/service/order/service.goWalletPay方法:条件更新 + 幂等处理HandlePaymentCallback方法:条件更新 + 幂等处理activatePackage方法:事务化 + 防御性检查
-
migrations/000033_add_unique_index_package_usage_order_package.up.sql- 创建唯一索引
-
internal/service/order/service_test.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, "订单状态异常")
}
}
// 扣减钱包余额并激活套餐
// ...
})
套餐激活防御性检查
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
}
// 创建新记录
// ...
}
}
测试验证
测试用例
-
钱包支付幂等测试
- 首次支付成功
- 第二次支付返回成功(幂等)
- 第三次支付返回成功(幂等)
- 验证只生成一条
PackageUsage记录
-
支付回调幂等测试
- 首次回调成功
- 重复回调返回成功(幂等)
- 验证只生成一条
PackageUsage记录
-
已取消订单支付测试
- 创建订单并取消
- 尝试支付
- 验证返回错误
CodeInvalidStatus
-
已支付订单支付测试
- 创建订单并支付
- 尝试再次支付
- 验证返回成功(幂等成功)
测试结果
PASS
coverage: 71.7% of statements
ok github.com/break/junhong_cmp_fiber/internal/service/order 23.555s
所有测试通过,核心业务逻辑覆盖率良好。
使用注意事项
迁移步骤
-
应用代码变更
git pull go build -
执行数据库迁移(已完成)
migrate -path migrations -database "postgresql://..." up # 输出: 33/u add_unique_index_package_usage_order_package (323.480333ms) -
验证索引创建(已验证)
索引名称: 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
监控建议
-
关键指标
- 订单支付成功率
- 幂等请求占比
PackageUsage创建失败率
-
日志监控
- 搜索 "套餐使用记录已存在" 警告日志
- 监控 "订单已取消/已退款" 错误
-
数据一致性检查
-- 检查是否有订单对应多条 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减少索引大小 - 事务内的查询都使用主键或索引字段