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

6.7 KiB
Raw Permalink Blame History

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

问题背景

业务风险

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

潜在问题场景

  • 用户重复点击支付按钮
  • 网络超时导致客户端重试
  • 支付回调重复通知
  • 并发请求同时到达服务器

风险后果

  • 重复生成 PackageUsage 记录
  • 用户重复获得权益
  • 资金/权益漏洞

解决方案

核心机制:状态机作为幂等门闸

使用订单支付状态的条件更新作为幂等控制:

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

    • 新增幂等性和异常状态测试

关键代码片段

钱包支付幂等处理

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, "订单状态异常")
        }
    }
    
    // 扣减钱包余额并激活套餐
    // ...
})

套餐激活防御性检查

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. 应用代码变更

    git pull
    go build
    
  2. 执行数据库迁移(已完成)

    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. 数据一致性检查

    -- 检查是否有订单对应多条 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 减少索引大小
  • 事务内的查询都使用主键或索引字段

相关文档