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

@@ -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
- 新增测试通过。

View File

@@ -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`(新建)

View File

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

View File

@@ -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` 记录。
- 幂等重复请求返回成功。
- 新增测试通过。

View File

@@ -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 ./...` 确保通过