# 订单激活幂等性修复功能总结

## 问题背景

### 业务风险

**核心规则**：同一个订单只能激活一次套餐使用记录

**潜在问题场景**：
- 用户重复点击支付按钮
- 网络超时导致客户端重试
- 支付回调重复通知
- 并发请求同时到达服务器

**风险后果**：
- 重复生成 `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)
