- 拆分订单创建为 CreateAdminOrder(后台一步支付)和 CreateH5Order(H5 两步支付) - 新增 CreateAdminOrderRequest DTO,后台仅允许 wallet/offline 支付方式 - 同步 delta specs 到主规格(order-payment 更新 + admin-order-creation 新增) - 归档 fix-agent-wallet-order-creation 变更 - 新增 implement-order-expiration 变更提案
16 KiB
Context
当前问题分析
当前代理在后台创建订单时存在三层架构缺陷:
第一层:Handler 层缺少参数验证
internal/handler/admin/order.go:27-29只调用了c.BodyParser(&req),没有调用middleware.ValidateStruct(&req)- 导致 DTO 中定义的
validate:"required,oneof=wallet offline"规则完全失效 - 用户可以传入任何
payment_method值(如wechat、alipay),绕过验证
第二层:Handler 层权限检查不完整
internal/handler/admin/order.go:35-43只检查了offline和wallet两种支付方式的权限else分支没有任何检查,导致其他支付方式绕过权限校验- 前端如果没有传
payment_method(空字符串或未定义),也会进入else分支
第三层:Service 层后台和 H5 端共用方法
internal/service/order/service.go:88-318的Create()方法同时服务后台和 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:
- 修复 Handler 层参数验证:确保 DTO 的
validate规则生效 - 修复 Handler 层权限检查:完整覆盖所有支付方式的权限校验
- 拆分 Service 方法:后台和 H5 端使用独立的 Service 方法,避免逻辑混淆
- 后台订单一步到位:wallet 支付立即扣款,offline 支付立即激活,不创建待支付订单
- 保持 H5 端行为不变:不影响 H5 端订单创建流程
- 向后兼容:保留回滚方案,避免破坏现有功能
Non-Goals:
- 修改订单数据模型(无需新增字段)
- 修改支付方式枚举(保持现有 wallet/wechat/alipay/offline)
- 修改 H5 端订单创建逻辑(只拆分,不改逻辑)
- 重构整个订单系统(仅针对性修复代理钱包订单问题)
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 待支付),应该使用不同的方法
- 明确的方法名(
CreateAdminOrdervsCreateH5Order)可以防止误用 - 未来如果需要进一步修改后台订单逻辑,不会影响 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() 方法的完整逻辑
// ...
}
迁移策略:
- 新增
CreateAdminOrder()和CreateH5Order()方法 - 将原
Create()方法重命名为CreateLegacy()(保留作为回滚方案) - 修改 Handler 层调用新方法
- 测试验证后删除
CreateLegacy()方法
Decision 2: DTO 设计
选择: 创建新的 CreateAdminOrderRequest DTO,保留现有 CreateOrderRequest
理由:
- 后台和 H5 端的参数验证规则不同(
oneof=wallet offlinevsoneof=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(),灰度发布
迁移步骤:
- Phase 1: 新增
CreateAdminOrder()和CreateH5Order()方法 - Phase 2: 将原
Create()重命名为CreateLegacy()(保留,暂不调用) - Phase 3: 修改 Handler 层调用新方法
- Phase 4: 测试环境验证
- Phase 5: 生产环境灰度发布(先 1% 流量,观察 1 天,再逐步放量)
- Phase 6: 全量上线后观察 1 周,无问题后删除
CreateLegacy()方法
回滚方案:
- 如果出现问题,立即修改 Handler 层调用
CreateLegacy()方法 - 重新部署后端(5 分钟内可完成)
- 前端无需回滚(因为 DTO 结构兼容)
理由:
- breaking change 风险高,需要谨慎迁移
- 保留
CreateLegacy()方法可以快速回滚,避免长时间故障
Risks / Trade-offs
Risk 1: 前端未同步修改导致参数缺失
风险: 前端没有传 payment_method 参数,后端拒绝请求
影响: 高。后台订单创建功能完全不可用
缓解措施:
- 后端先部署(兼容旧前端,如果
payment_method为空,默认使用wallet) - 前端修改后再部署
- 灰度发布,先在测试环境验证前后端联调
检测方式:
- 监控错误日志中
CodeInvalidParam错误的数量 - 如果错误量激增,立即回滚
Risk 2: Service 方法拆分导致代码重复
风险: CreateAdminOrder() 和 CreateH5Order() 有大量重复代码
影响: 中。增加维护成本
缓解措施:
- 提取公共逻辑为私有方法(如
validatePurchase()、checkOrderIdempotency()、buildOrderItems()) 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: 灰度发布失败导致部分用户无法下单
风险: 灰度发布过程中,部分用户使用新代码,部分用户使用旧代码,行为不一致
影响: 中。用户体验不一致
缓解措施:
- 灰度发布按 用户 ID 维度(而不是随机流量),确保同一用户始终使用同一版本
- 灰度期间密切监控错误日志和用户反馈
- 设置灰度开关(环境变量或配置文件),可以快速回滚到旧代码
Trade-off: 代码拆分 vs 逻辑复用
选择: 优先代码拆分(清晰的职责划分),接受一定的代码重复
理由:
- 后台和 H5 端的订单创建逻辑差异巨大,强行复用会导致 if-else 嵌套过深,难以维护
- "一点点代码重复" 优于 "过度抽象"(遵循 Go 惯用模式)
- 未来如果需要修改后台订单逻辑,不会影响 H5 端
Trade-off: breaking change vs 保持现状
选择: 接受 breaking change,彻底解决问题
理由:
- 当前问题严重(代理可以创建永远无法完成的订单),必须彻底修复
- breaking change 影响范围可控(只有 2 个 Handler 文件)
- 保留回滚方案,风险可控
Migration Plan
Phase 1: 后端开发和测试(1 天)
- 创建新 DTO:
CreateAdminOrderRequest - 新增 Service 方法:
CreateAdminOrder()和CreateH5Order() - 重命名原
Create()为CreateLegacy() - 修改 Handler 层调用新方法
- 单元测试(覆盖率 ≥ 90%)
Phase 2: 前端开发(0.5 天)
- 后台订单创建界面添加
payment_method下拉框(wallet/offline) - 修改请求参数,使用新 DTO
Phase 3: 联调测试(0.5 天)
- 测试环境部署后端
- 测试环境部署前端
- 完整测试所有场景:
- 代理钱包支付(余额充足)
- 代理钱包支付(余额不足)
- 平台线下支付
- H5 端订单创建(回归测试)
Phase 4: 灰度发布(3 天)
- Day 1: 生产环境部署后端,灰度 1% 流量
- Day 2: 观察错误日志,无问题则扩大到 10%
- Day 3: 扩大到 50%,无问题则全量
Phase 5: 清理(1 天)
- 观察 1 周,无问题后删除
CreateLegacy()方法 - 更新文档(API 文档、技术文档)
总时长: 约 6 天
Rollback Strategy
触发条件:
- 错误率超过 1%
- 用户反馈无法下单
- 前端未同步上线导致大量参数缺失错误
回滚步骤:
- 立即修改 Handler 层调用
CreateLegacy()方法 - 重新部署后端(5 分钟)
- 验证功能恢复
- 排查问题,修复后重新上线
Open Questions
-
前端是否已经传递
payment_method参数?- 需要确认前端当前行为
- 如果未传递,后端需要提供默认值(wallet)以兼容旧版本
-
是否需要新增错误码
CodeInvalidPaymentMethodForAdmin?- 当前可以复用
CodeInvalidParam - 如果需要区分"参数格式错误"和"支付方式不支持",可以新增
- 当前可以复用
-
灰度发布的维度是什么?
- 按用户 ID 灰度(推荐)
- 按流量百分比灰度
- 需要与运维确认灰度策略
-
是否需要支持后台创建待支付订单(未来需求)?
- 当前不支持(Non-Goal)
- 如果未来有需求,可以新增
CreateAdminPendingOrder()方法