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
3.7 KiB
3.7 KiB
订单激活幂等性修复 - 设计
目标
- 同一订单被重复支付/重复回调/重复请求时,只会激活一次套餐使用记录。
- 重复请求返回"幂等成功"(不报错,不重复激活)。
- 避免因并发导致的重复插入。
方案
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 中已定义的常量:
model.PaymentStatusPending = 1 // 待支付
model.PaymentStatusPaid = 2 // 已支付
model.PaymentStatusCancelled = 3 // 已取消
model.PaymentStatusRefunded = 4 // 已退款
错误码定义
使用 pkg/errors/ 中已有的错误码:
| 场景 | 错误码 | 错误消息 |
|---|---|---|
| 幂等成功(订单已支付) | 0 | 订单已支付(幂等成功) |
| 订单已取消 | 1050 (CodeInvalidStatus) | 订单已取消,无法支付 |
| 订单已退款 | 1050 (CodeInvalidStatus) | 订单已退款,无法支付 |
示例代码:
// 条件更新支付状态
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)。
- 新增测试通过。