Files
huang 1290160728
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m22s
fix: 修复订单支付幂等性问题,防止重复激活套餐
- 使用条件更新实现支付状态原子转换(pending -> paid)
- 重复请求返回幂等成功,不再重复激活套餐
- 新增 tb_package_usage 唯一索引(order_id, package_id)
- 新增幂等性和异常状态测试,测试覆盖率 71.7%
- 归档 OpenSpec 变更 fix-order-activation-idempotency
2026-01-29 16:33:53 +08:00

243 lines
6.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 订单激活幂等性修复功能总结
## 问题背景
### 业务风险
**核心规则**:同一个订单只能激活一次套餐使用记录
**潜在问题场景**
- 用户重复点击支付按钮
- 网络超时导致客户端重试
- 支付回调重复通知
- 并发请求同时到达服务器
**风险后果**
- 重复生成 `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(&currentOrder, 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)