Files
junhong_cmp_fiber/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/design.md
huang 5bb0ff0ddf
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m54s
fix: 修复代理钱包订单创建逻辑,拆分后台/H5端下单方法并归档变更
- 拆分订单创建为 CreateAdminOrder(后台一步支付)和 CreateH5Order(H5 两步支付)
- 新增 CreateAdminOrderRequest DTO,后台仅允许 wallet/offline 支付方式
- 同步 delta specs 到主规格(order-payment 更新 + admin-order-creation 新增)
- 归档 fix-agent-wallet-order-creation 变更
- 新增 implement-order-expiration 变更提案
2026-02-28 16:31:31 +08:00

16 KiB
Raw Blame History

Context

当前问题分析

当前代理在后台创建订单时存在三层架构缺陷:

第一层Handler 层缺少参数验证

  • internal/handler/admin/order.go:27-29 只调用了 c.BodyParser(&req),没有调用 middleware.ValidateStruct(&req)
  • 导致 DTO 中定义的 validate:"required,oneof=wallet offline" 规则完全失效
  • 用户可以传入任何 payment_method 值(如 wechatalipay),绕过验证

第二层Handler 层权限检查不完整

  • internal/handler/admin/order.go:35-43 只检查了 offlinewallet 两种支付方式的权限
  • else 分支没有任何检查,导致其他支付方式绕过权限校验
  • 前端如果没有传 payment_method(空字符串或未定义),也会进入 else 分支

第三层Service 层后台和 H5 端共用方法

  • internal/service/order/service.go:88-318Create() 方法同时服务后台和 H5 端
  • Create() 方法的 else 分支(第 310-317 行)是为 H5 端在线支付wechat/alipay准备的创建待支付订单
  • 后台误用这个分支会导致创建待支付订单,但后台没有支付界面,订单永远无法完成

现有代码结构

admin/order.Create() ─┐
                      ├─→ service.Create(buyerType, buyerID) ← 同一个方法
h5/order.Create() ────┘
                          ├─ if offline → 平台代购(激活)
                          ├─ else if wallet → 钱包支付(扣款+激活)
                          └─ else → 创建待支付订单 ← 后台误用这里

业务约束

  • 后台订单创建:代理/平台账号使用,仅支持 wallet/offline 支付,立即扣款或激活,不允许待支付状态
  • H5 端订单创建C 端用户使用,支持 wallet/wechat/alipay 支付,支持待支付状态(等待用户支付)

Goals / Non-Goals

Goals:

  1. 修复 Handler 层参数验证:确保 DTO 的 validate 规则生效
  2. 修复 Handler 层权限检查:完整覆盖所有支付方式的权限校验
  3. 拆分 Service 方法:后台和 H5 端使用独立的 Service 方法,避免逻辑混淆
  4. 后台订单一步到位wallet 支付立即扣款offline 支付立即激活,不创建待支付订单
  5. 保持 H5 端行为不变:不影响 H5 端订单创建流程
  6. 向后兼容:保留回滚方案,避免破坏现有功能

Non-Goals:

  1. 修改订单数据模型(无需新增字段)
  2. 修改支付方式枚举(保持现有 wallet/wechat/alipay/offline
  3. 修改 H5 端订单创建逻辑(只拆分,不改逻辑)
  4. 重构整个订单系统(仅针对性修复代理钱包订单问题)

Decisions

Decision 1: Service 方法拆分策略

选择: 拆分 OrderService.Create() 为两个独立方法

方案对比:

方案 优点 缺点
方案 A在现有 Create() 中添加 isAdminContext 参数 改动最小,无 breaking change 逻辑更复杂if-else 嵌套增加,难以维护
方案 B拆分为 CreateAdminOrder()CreateH5Order() 逻辑清晰,职责分离,易于测试和维护 breaking change需要修改 Handler 层调用
方案 C保留 Create() 方法,内部调用不同的私有方法 对外 API 不变 隐藏了后台和 H5 的差异,仍然可能误用

选择方案 B 的理由:

  • 后台和 H5 端的订单创建逻辑差异巨大(后台立即扣款 vs H5 待支付),应该使用不同的方法
  • 明确的方法名(CreateAdminOrder vs CreateH5Order)可以防止误用
  • 未来如果需要进一步修改后台订单逻辑,不会影响 H5 端
  • breaking change 影响范围可控(只有 2 个 Handler 文件)

实现细节:

// CreateAdminOrder 后台订单创建(仅支持 wallet/offline立即扣款或激活
func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrderRequest, buyerType string, buyerID uint) (*dto.OrderResponse, error) {
    // 1. 验证购买合法性(复用现有逻辑)
    validationResult, err := s.validatePurchase(ctx, req.OrderType, req.IotCardID, req.DeviceID, req.PackageIDs)

    // 2. 幂等性检查(复用现有逻辑)
    existingOrderID, err := s.checkOrderIdempotency(...)

    // 3. 根据支付方式路由
    if req.PaymentMethod == model.PaymentMethodOffline {
        // 平台代购:创建订单并立即激活套餐
        return s.createOrderWithActivation(ctx, order, items)
    } else if req.PaymentMethod == model.PaymentMethodWallet {
        // 钱包支付:检查余额 → 扣款 → 创建已支付订单 → 激活套餐
        return s.createOrderWithWalletPayment(ctx, order, items, operatorShopID, buyerShopID)
    } else {
        // 不应该到这里DTO 验证已拒绝其他支付方式)
        return nil, errors.New(errors.CodeInvalidParam, "后台仅支持钱包支付或线下支付")
    }
}

// CreateH5Order H5 端订单创建(支持 wallet/wechat/alipay支持待支付状态
func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest, buyerType string, buyerID uint) (*dto.OrderResponse, error) {
    // 保留原 Create() 方法的完整逻辑
    // ...
}

迁移策略:

  1. 新增 CreateAdminOrder()CreateH5Order() 方法
  2. 将原 Create() 方法重命名为 CreateLegacy()(保留作为回滚方案)
  3. 修改 Handler 层调用新方法
  4. 测试验证后删除 CreateLegacy() 方法

Decision 2: DTO 设计

选择: 创建新的 CreateAdminOrderRequest DTO保留现有 CreateOrderRequest

理由:

  • 后台和 H5 端的参数验证规则不同(oneof=wallet offline vs oneof=wallet wechat alipay
  • 使用不同的 DTO 可以在类型层面保证后台不会传入非法支付方式
  • 符合"类型安全"原则,编译期就能发现错误

DTO 定义:

// CreateAdminOrderRequest 后台订单创建请求(仅允许 wallet/offline
type CreateAdminOrderRequest struct {
    OrderType     string `json:"order_type" validate:"required,oneof=single_card device" required:"true" description:"订单类型 (single_card:单卡购买, device:设备购买)"`
    IotCardID     *uint  `json:"iot_card_id" validate:"required_if=OrderType single_card" description:"IoT卡ID(单卡购买时必填)"`
    DeviceID      *uint  `json:"device_id" validate:"required_if=OrderType device" description:"设备ID(设备购买时必填)"`
    PackageIDs    []uint `json:"package_ids" validate:"required,min=1,max=10,dive,min=1" required:"true" minItems:"1" maxItems:"10" description:"套餐ID列表"`
    PaymentMethod string `json:"payment_method" validate:"required,oneof=wallet offline" required:"true" description:"支付方式 (wallet:钱包支付, offline:线下支付)"`
}

// CreateOrderRequest H5 端订单创建请求(保持不变)
type CreateOrderRequest struct {
    OrderType     string `json:"order_type" validate:"required,oneof=single_card device" required:"true" description:"订单类型 (single_card:单卡购买, device:设备购买)"`
    IotCardID     *uint  `json:"iot_card_id" validate:"required_if=OrderType single_card" description:"IoT卡ID(单卡购买时必填)"`
    DeviceID      *uint  `json:"device_id" validate:"required_if=OrderType device" description:"设备ID(设备购买时必填)"`
    PackageIDs    []uint `json:"package_ids" validate:"required,min=1,max=10,dive,min=1" required:"true" minItems:"1" maxItems:"10" description:"套餐ID列表"`
    PaymentMethod string `json:"payment_method" validate:"required,oneof=wallet wechat alipay" required:"true" description:"支付方式 (wallet:钱包支付, wechat:微信支付, alipay:支付宝支付)"`
}

替代方案及为何不选:

  • 使用同一个 DTO在 Handler 层校验支付方式:无法利用类型系统,容易漏掉校验
  • 使用 interface{} 类型:失去类型安全,违反 Go 最佳实践

Decision 3: Handler 层参数验证和权限检查

选择: 在 Handler 层完整实现参数验证和权限检查

修复点 1添加参数验证

func (h *OrderHandler) Create(c *fiber.Ctx) error {
    var req dto.CreateAdminOrderRequest
    if err := c.BodyParser(&req); err != nil {
        return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
    }

    // ← 添加验证(关键!)
    if err := middleware.ValidateStruct(&req); err != nil {
        return errors.New(errors.CodeInvalidParam)
    }

    // ... 后续逻辑
}

修复点 2完整的权限检查

// 检查支付方式权限
if req.PaymentMethod == model.PaymentMethodOffline {
    if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
        return errors.New(errors.CodeForbidden, "只有平台可以使用线下支付")
    }
} else if req.PaymentMethod == model.PaymentMethodWallet {
    if userType != constants.UserTypeAgent && userType != constants.UserTypePlatform && userType != constants.UserTypeSuperAdmin {
        return errors.New(errors.CodeForbidden, "无权创建订单")
    }
} else {
    // ← 添加兜底检查(防御性编程)
    return errors.New(errors.CodeInvalidParam, "后台仅支持钱包支付或线下支付")
}

理由:

  • 参数验证是 Handler 层的职责(参考 CLAUDE.md 架构规范)
  • 兜底检查可以防止未来新增支付方式时漏掉权限校验
  • 多层防护DTO 验证(第一道防线) + Handler 检查(第二道防线) + Service 检查(第三道防线)

Decision 4: 错误处理策略

选择: 使用统一错误码,添加新错误码(如需要)

新增错误码(如果需要):

// pkg/errors/errors.go
const (
    // ... 现有错误码
    CodeInvalidPaymentMethodForAdmin = 40008 // 后台不支持的支付方式
)

错误返回规范:

  • Handler 层参数验证失败:errors.New(errors.CodeInvalidParam)(不泄露细节)
  • Service 层钱包余额不足:errors.New(errors.CodeInsufficientBalance, "余额不足")
  • Service 层支付方式非法:errors.New(errors.CodeInvalidParam, "后台仅支持钱包支付或线下支付")

理由:

  • 遵循项目错误处理规范(参考 CLAUDE.md
  • Handler 层不直接返回底层错误,防止信息泄露

Decision 5: 向后兼容和回滚策略

选择: 保留原 Create() 方法作为 CreateLegacy(),灰度发布

迁移步骤:

  1. Phase 1: 新增 CreateAdminOrder()CreateH5Order() 方法
  2. Phase 2: 将原 Create() 重命名为 CreateLegacy()(保留,暂不调用)
  3. Phase 3: 修改 Handler 层调用新方法
  4. Phase 4: 测试环境验证
  5. Phase 5: 生产环境灰度发布(先 1% 流量,观察 1 天,再逐步放量)
  6. Phase 6: 全量上线后观察 1 周,无问题后删除 CreateLegacy() 方法

回滚方案:

  • 如果出现问题,立即修改 Handler 层调用 CreateLegacy() 方法
  • 重新部署后端5 分钟内可完成)
  • 前端无需回滚(因为 DTO 结构兼容)

理由:

  • breaking change 风险高,需要谨慎迁移
  • 保留 CreateLegacy() 方法可以快速回滚,避免长时间故障

Risks / Trade-offs

Risk 1: 前端未同步修改导致参数缺失

风险: 前端没有传 payment_method 参数,后端拒绝请求

影响: 高。后台订单创建功能完全不可用

缓解措施:

  1. 后端先部署(兼容旧前端,如果 payment_method 为空,默认使用 wallet
  2. 前端修改后再部署
  3. 灰度发布,先在测试环境验证前后端联调

检测方式:

  • 监控错误日志中 CodeInvalidParam 错误的数量
  • 如果错误量激增,立即回滚

Risk 2: Service 方法拆分导致代码重复

风险: CreateAdminOrder()CreateH5Order() 有大量重复代码

影响: 中。增加维护成本

缓解措施:

  1. 提取公共逻辑为私有方法(如 validatePurchase()checkOrderIdempotency()buildOrderItems()
  2. CreateAdminOrder()CreateH5Order() 只保留差异化逻辑

示例:

func (s *Service) CreateAdminOrder(...) (*dto.OrderResponse, error) {
    // 1. 公共逻辑(提取为私有方法)
    validationResult, err := s.validatePurchase(...)
    existingOrderID, err := s.checkOrderIdempotency(...)

    // 2. 差异化逻辑(后台独有)
    if req.PaymentMethod == model.PaymentMethodWallet {
        return s.createOrderWithWalletPayment(...)
    }
}

Risk 3: 灰度发布失败导致部分用户无法下单

风险: 灰度发布过程中,部分用户使用新代码,部分用户使用旧代码,行为不一致

影响: 中。用户体验不一致

缓解措施:

  1. 灰度发布按 用户 ID 维度(而不是随机流量),确保同一用户始终使用同一版本
  2. 灰度期间密切监控错误日志和用户反馈
  3. 设置灰度开关(环境变量或配置文件),可以快速回滚到旧代码

Trade-off: 代码拆分 vs 逻辑复用

选择: 优先代码拆分(清晰的职责划分),接受一定的代码重复

理由:

  • 后台和 H5 端的订单创建逻辑差异巨大,强行复用会导致 if-else 嵌套过深,难以维护
  • "一点点代码重复" 优于 "过度抽象"(遵循 Go 惯用模式)
  • 未来如果需要修改后台订单逻辑,不会影响 H5 端

Trade-off: breaking change vs 保持现状

选择: 接受 breaking change彻底解决问题

理由:

  • 当前问题严重(代理可以创建永远无法完成的订单),必须彻底修复
  • breaking change 影响范围可控(只有 2 个 Handler 文件)
  • 保留回滚方案,风险可控

Migration Plan

Phase 1: 后端开发和测试1 天)

  1. 创建新 DTOCreateAdminOrderRequest
  2. 新增 Service 方法:CreateAdminOrder()CreateH5Order()
  3. 重命名原 Create()CreateLegacy()
  4. 修改 Handler 层调用新方法
  5. 单元测试(覆盖率 ≥ 90%

Phase 2: 前端开发0.5 天)

  1. 后台订单创建界面添加 payment_method 下拉框wallet/offline
  2. 修改请求参数,使用新 DTO

Phase 3: 联调测试0.5 天)

  1. 测试环境部署后端
  2. 测试环境部署前端
  3. 完整测试所有场景:
    • 代理钱包支付(余额充足)
    • 代理钱包支付(余额不足)
    • 平台线下支付
    • H5 端订单创建(回归测试)

Phase 4: 灰度发布3 天)

  1. Day 1: 生产环境部署后端,灰度 1% 流量
  2. Day 2: 观察错误日志,无问题则扩大到 10%
  3. Day 3: 扩大到 50%,无问题则全量

Phase 5: 清理1 天)

  1. 观察 1 周,无问题后删除 CreateLegacy() 方法
  2. 更新文档API 文档、技术文档)

总时长: 约 6 天


Rollback Strategy

触发条件:

  • 错误率超过 1%
  • 用户反馈无法下单
  • 前端未同步上线导致大量参数缺失错误

回滚步骤:

  1. 立即修改 Handler 层调用 CreateLegacy() 方法
  2. 重新部署后端5 分钟)
  3. 验证功能恢复
  4. 排查问题,修复后重新上线

Open Questions

  1. 前端是否已经传递 payment_method 参数?

    • 需要确认前端当前行为
    • 如果未传递后端需要提供默认值wallet以兼容旧版本
  2. 是否需要新增错误码 CodeInvalidPaymentMethodForAdmin

    • 当前可以复用 CodeInvalidParam
    • 如果需要区分"参数格式错误"和"支付方式不支持",可以新增
  3. 灰度发布的维度是什么?

    • 按用户 ID 灰度(推荐)
    • 按流量百分比灰度
    • 需要与运维确认灰度策略
  4. 是否需要支持后台创建待支付订单(未来需求)?

    • 当前不支持Non-Goal
    • 如果未来有需求,可以新增 CreateAdminPendingOrder() 方法