fix: 修复订单支付幂等性问题,防止重复激活套餐
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m22s
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:
242
docs/fix-order-activation-idempotency/功能总结.md
Normal file
242
docs/fix-order-activation-idempotency/功能总结.md
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user