## 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:** 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 文件) **实现细节**: ```go // 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 定义**: ```go // 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:添加参数验证** ```go 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:完整的权限检查** ```go // 检查支付方式权限 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: 错误处理策略 **选择**: 使用统一错误码,添加新错误码(如需要) **新增错误码**(如果需要): ```go // 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()` 只保留差异化逻辑 **示例**: ```go 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. 创建新 DTO:`CreateAdminOrderRequest` 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()` 方法