## Context ### 当前系统状态 **钱包系统现状**: - 已有 Wallet、WalletTransaction 模型和 Store 层实现 - 已有 RechargeRecord 模型定义,但完全未使用(表已创建,无 Store/Service/Handler) - 个人客户只能通过购买套餐间接充值钱包,无法直接充值 - 订单支付采用"强充"机制:用户必须通过购买套餐来充值,不支持纯钱包充值 **订单系统现状**: - Order 模型已支持单卡购买和设备购买两种类型 - 支持 wallet、wechat、alipay 三种支付方式 - 订单创建 → 支付 → 激活套餐 → 触发佣金计算的完整流程已实现 - 支付回调处理支持微信和支付宝 **佣金计算现状**: - 支持成本价差佣金和一次性佣金两种类型 - 一次性佣金支持首次充值和累计充值两种触发方式 - 订单支付成功后自动更新 `AccumulatedRecharge` - **存在问题**:所有订单(包括代购订单)都会更新累计充值,都会触发一次性佣金 **系列分配配置现状**: - ShopSeriesAllocation 已支持一次性佣金配置(类型、触发方式、阈值、模式、值) - 支持梯度佣金配置(独立表 ShopSeriesOneTimeCommissionTier) - **缺失**:没有强充金额配置字段 ### 业务需求背景 1. **线下收款场景**:平台/代理线下已收款,需要为代理代购套餐,但系统无法支持 2. **个人客户充值体验**:用户想充值钱包但不想立即购买套餐,当前系统无法满足 3. **强充机制完善**: - 首次充值需强制充值阈值金额(如100元) - 累计充值可选启用强充,每次充值固定金额(如100元),避免"买39元套餐却要充1000元"的不合理情况 4. **佣金计算准确性**:代购订单不应触发一次性佣金,因为不是客户真实充值 ### 约束条件 - **技术栈**:必须使用 Fiber + GORM + Viper + Zap + Asynq,禁止外键和 GORM 关联 - **架构分层**:Handler → Service → Store → Model,严格分层 - **性能要求**:预检接口 < 100ms,充值创建 < 200ms,支付回调 < 500ms - **测试要求**:核心业务逻辑覆盖率 ≥ 90% - **向后兼容**:新增字段必须有默认值,不能破坏现有订单和佣金计算逻辑 --- ## Goals / Non-Goals ### Goals 1. **实现钱包充值系统** - 个人客户可以直接给卡/设备钱包充值(不购买套餐) - 充值支持微信支付和支付宝支付 - 充值成功自动更新钱包余额和累计充值金额 - 充值达到阈值触发一次性佣金 2. **实现强充预检机制** - 提供充值预检接口,告知用户强充要求 - 提供套餐购买预检接口,计算实际支付金额 - 创建订单/充值订单时后端强制验证,防止前端绕过 3. **实现代购订单功能** - 平台/代理可为其他代理代购套餐 - 支持线下支付方式(offline) - 代购订单不触发一次性佣金,不更新累计充值 - 代购订单仍计算差价佣金 4. **扩展强充配置** - ShopSeriesAllocation 增加强充配置字段 - 首次充值:强充金额 = 阈值(不可配置) - 累计充值:可选启用强充,配置固定充值金额 5. **修复佣金计算逻辑** - 代购订单不累加 AccumulatedRecharge - 代购订单不触发一次性佣金 - 充值订单正常触发佣金 ### Non-Goals 1. **不支持钱包转账**:用户钱包间转账不在本次范围 2. **不支持退款流程**:充值退款、订单退款流程留待后续实现 3. **不修改梯度佣金逻辑**:梯度佣金计算保持不变 4. **不修改差价佣金逻辑**:成本价差计算保持不变 5. **不支持企业客户钱包**:企业客户无钱包,本次不涉及 6. **不实现充值优惠**:充值满减、赠送等营销功能不在范围 --- ## Decisions ### Decision 1: 数据库模型设计 **选择**:使用现有 RechargeRecord 表,新增必要字段到 Order 和 ShopSeriesAllocation 表。 **理由**: - RechargeRecord 表已存在,结构合理,只需激活使用 - Order 表新增 `is_purchase_on_behalf BOOLEAN DEFAULT false` - ShopSeriesAllocation 表新增 `enable_force_recharge BOOLEAN DEFAULT false` 和 `force_recharge_amount BIGINT DEFAULT 0` **备选方案及拒绝原因**: - ~~创建新表 PurchaseOnBehalfOrder~~:增加复杂度,Order 表扩展一个字段即可 - ~~使用订单备注字段标识代购~~:不利于查询和统计,需要独立字段 **实现细节**: ```sql -- 迁移文件 1: 订单表增加代购标识 ALTER TABLE tb_order ADD COLUMN is_purchase_on_behalf BOOLEAN DEFAULT false COMMENT '是否为代购订单(平台/代理代购)'; -- 迁移文件 2: 系列分配表增加强充配置 ALTER TABLE tb_shop_series_allocation ADD COLUMN enable_force_recharge BOOLEAN DEFAULT false COMMENT '是否启用强充(累计充值时可选)', ADD COLUMN force_recharge_amount BIGINT DEFAULT 0 COMMENT '强充金额(分,0表示使用阈值金额)'; ``` --- ### Decision 2: 强充验证策略 **选择**:前端预检 + 后端强制验证的双重保障策略。 **架构**: ``` 前端调用预检接口 → 获取强充要求 → 显示给用户 ↓ 用户提交订单/充值 → 后端验证金额是否符合强充要求 ↓ 验证不通过 → 拒绝创建订单,返回错误 验证通过 → 创建订单/充值订单 ``` **预检接口设计**: 1. **钱包充值预检**:`GET /api/h5/wallets/recharge-check?resource_type=iot_card&resource_id=123` ```go type RechargeCheckResponse struct { NeedForceRecharge bool `json:"need_force_recharge"` ForceRechargeAmount int64 `json:"force_recharge_amount"` TriggerType string `json:"trigger_type"` // single_recharge/accumulated_recharge MinAmount int64 `json:"min_amount"` MaxAmount *int64 `json:"max_amount"` CurrentAccumulated int64 `json:"current_accumulated"` Threshold int64 `json:"threshold"` Message string `json:"message"` } ``` 2. **套餐购买预检**:`POST /api/h5/orders/purchase-check` ```go type PurchaseCheckRequest struct { OrderType string `json:"order_type"` ResourceID uint `json:"resource_id"` // iot_card_id/device_id PackageIDs []uint `json:"package_ids"` } type PurchaseCheckResponse struct { TotalPackageAmount int64 `json:"total_package_amount"` NeedForceRecharge bool `json:"need_force_recharge"` ForceRechargeAmount int64 `json:"force_recharge_amount"` ActualPayment int64 `json:"actual_payment"` WalletCredit int64 `json:"wallet_credit"` Message string `json:"message"` } ``` **验证逻辑**: ```go func (s *RechargeService) checkForceRechargeRequirement(ctx, resourceType, resourceID) (*ForceRechargeRequirement, error) { // 1. 查询资源(卡/设备) resource := queryResource(resourceType, resourceID) // 2. 查询系列分配 allocation := s.allocationStore.GetByID(ctx, resource.SeriesAllocationID) // 3. 判断是否需要强充 if !allocation.EnableOneTimeCommission { return &ForceRechargeRequirement{NeedForceRecharge: false}, nil } if resource.FirstCommissionPaid { return &ForceRechargeRequirement{NeedForceRecharge: false}, nil } // 4. 根据触发类型判断 if allocation.OneTimeCommissionTrigger == "single_recharge" { // 首次充值:强充金额 = 阈值 return &ForceRechargeRequirement{ NeedForceRecharge: true, ForceRechargeAmount: allocation.OneTimeCommissionThreshold, TriggerType: "single_recharge", }, nil } else { // 累计充值:检查是否启用强充 if allocation.EnableForceRecharge { return &ForceRechargeRequirement{ NeedForceRecharge: true, ForceRechargeAmount: allocation.ForceRechargeAmount, TriggerType: "accumulated_recharge", }, nil } return &ForceRechargeRequirement{NeedForceRecharge: false}, nil } } ``` **备选方案及拒绝原因**: - ~~仅前端验证~~:不安全,用户可以绕过前端直接调用 API - ~~仅后端验证~~:用户体验差,提交后才知道金额不对 --- ### Decision 3: 充值订单与套餐订单的关系 **选择**:充值订单和套餐订单完全独立,使用不同的表和流程。 **理由**: - **关注点分离**:充值订单关注钱包余额变动,套餐订单关注套餐激活 - **业务语义清晰**:RechargeRecord 表示纯充值,Order 表示购买套餐 - **支付回调区分**:通过订单号前缀区分(RCH 开头是充值,ORD 开头是订单) - **佣金计算独立**:充值和购买的佣金触发逻辑不同 **处理流程对比**: | 流程步骤 | 充值订单(RechargeRecord) | 套餐订单(Order) | |---------|---------------------------|------------------| | 创建订单 | 创建 RechargeRecord | 创建 Order + OrderItem | | 验证强充 | ✅ 验证充值金额 | ✅ 验证支付金额 | | 生成订单号 | RCH + 时间戳 + 随机数 | ORD + 时间戳 + 随机数 | | 支付 | 微信/支付宝 | 钱包/微信/支付宝/线下 | | 支付成功 | 增加钱包余额 | 激活套餐,可能返还余额 | | 更新累计充值 | ✅ 更新 AccumulatedRecharge | ✅ 更新(代购除外)| | 触发佣金 | ✅ 触发一次性佣金判断 | ✅ 触发差价+一次性佣金(代购除外)| **支付回调路由**: ```go func (h *PaymentHandler) WechatPayCallback(c *fiber.Ctx) error { result := parseWechatCallback(c.Body()) // 根据订单号前缀判断类型 if strings.HasPrefix(result.OutTradeNo, "RCH") { // 充值订单回调 return h.rechargeService.HandlePaymentCallback(ctx, result.OutTradeNo, "wechat") } else if strings.HasPrefix(result.OutTradeNo, "ORD") { // 套餐订单回调 return h.orderService.HandlePaymentCallback(ctx, result.OutTradeNo, "wechat") } return errors.New(errors.CodeInvalidParam, "无效的订单号") } ``` **备选方案及拒绝原因**: - ~~使用同一个 Order 表,通过 order_type 区分~~:语义混乱,充值不是"订单" - ~~充值也创建 Order,但 order_items 为空~~:违反业务语义,items 为空表示什么? --- ### Decision 4: 代购订单处理 **选择**:代购订单使用 `is_purchase_on_behalf` 字段标识,创建时直接标记为已支付,跳过支付流程。 **创建流程**: ``` 平台/代理创建代购订单 ↓ 查询卡/设备归属的代理店铺 ↓ 计算买家的成本价(不是卖价) ↓ 创建订单: - buyer_id = 代理店铺ID - is_purchase_on_behalf = true - payment_method = "offline" - payment_status = 2 (已支付) - total_amount = 买家成本价 ↓ 立即激活套餐(创建 PackageUsage) ↓ 触发佣金计算(仅差价佣金,不触发一次性佣金) ``` **权限控制**: ```go func (h *OrderHandler) Create(c *fiber.Ctx) error { req := parseRequest(c) userType := middleware.GetUserTypeFromContext(ctx) // 检查线下支付权限 if req.PaymentMethod == model.PaymentMethodOffline { if userType != constants.UserTypePlatform { return errors.New(errors.CodeForbidden, "只有平台账号可以使用线下支付") } } // 平台代购 vs 普通订单 if userType == constants.UserTypePlatform && req.PaymentMethod == "offline" { // 平台代购逻辑 buyerShopID := queryResourceOwner(req.OrderType, req.ResourceID) return h.service.CreatePurchaseOnBehalf(ctx, req, buyerShopID) } else { // 普通订单逻辑 shopID := middleware.GetShopIDFromContext(ctx) return h.service.Create(ctx, req, buyerType, shopID) } } ``` **备选方案及拒绝原因**: - ~~使用独立的 purchase_on_behalf_orders 表~~:增加复杂度,查询和统计不便 - ~~使用订单状态区分(如 payment_status = 5 表示代购)~~:滥用状态字段,语义不清 --- ### Decision 5: 佣金计算修复 **选择**:在佣金计算Service中增加 `is_purchase_on_behalf` 判断,代购订单跳过一次性佣金和累计充值更新。 **修改逻辑**: ```go func (s *CommissionCalculationService) CalculateCommission(ctx, orderID) error { order := s.orderStore.GetByID(ctx, orderID) // 1. 差价佣金:所有订单都计算(包括代购) costDiffRecords := s.CalculateCostDiffCommission(ctx, order) // 2. 累计充值:仅非代购订单更新 if !order.IsPurchaseOnBehalf { s.updateAccumulatedRecharge(ctx, order) } // 3. 一次性佣金:仅非代购订单触发 if !order.IsPurchaseOnBehalf { s.triggerOneTimeCommission(ctx, order) } // 4. 更新订单佣金状态 s.orderStore.UpdateCommissionStatus(ctx, orderID, CommissionStatusCalculated) } func (s *CommissionCalculationService) updateAccumulatedRecharge(ctx, order) error { if order.OrderType == "single_card" && order.IotCardID != nil { return s.db.Model(&model.IotCard{}). Where("id = ?", *order.IotCardID). Update("accumulated_recharge", gorm.Expr("accumulated_recharge + ?", order.TotalAmount)). Error } else if order.OrderType == "device" && order.DeviceID != nil { return s.db.Model(&model.Device{}). Where("id = ?", *order.DeviceID). Update("accumulated_recharge", gorm.Expr("accumulated_recharge + ?", order.TotalAmount)). Error } return nil } ``` **充值订单的佣金触发**: ```go func (s *RechargeService) HandlePaymentCallback(ctx, rechargeNo, paymentMethod) error { return s.db.Transaction(func(tx *gorm.DB) error { // 1. 更新充值订单状态 s.rechargeStore.UpdateStatus(ctx, rechargeNo, RechargeStatusPaid) // 2. 增加钱包余额 s.walletStore.IncreaseBalance(ctx, walletID, amount) // 3. 更新累计充值 s.updateAccumulatedRecharge(ctx, resourceType, resourceID, amount) // 4. 触发一次性佣金判断 s.triggerOneTimeCommissionIfNeeded(ctx, resourceType, resourceID, amount) return nil }) } ``` --- ### Decision 6: 强充金额来源 **选择**:首次充值使用阈值,累计充值使用独立配置字段。 **规则矩阵**: | 触发类型 | 强充开关 | 强充金额来源 | 说明 | |---------|---------|-------------|------| | 首次充值 | 不可配置(必须强充) | `OneTimeCommissionThreshold` | 首充必须充值阈值金额 | | 累计充值 | `EnableForceRecharge=true` | `ForceRechargeAmount`(独立配置) | 每次必须充值固定金额 | | 累计充值 | `EnableForceRecharge=false` | - | 不强充,自由金额 | **查询逻辑**: ```go func getForceRechargeAmount(allocation *ShopSeriesAllocation) int64 { if allocation.OneTimeCommissionTrigger == "single_recharge" { // 首次充值:强充金额 = 阈值 return allocation.OneTimeCommissionThreshold } else { // 累计充值:强充金额 = 配置字段(如果为0则使用阈值) if allocation.ForceRechargeAmount > 0 { return allocation.ForceRechargeAmount } return allocation.OneTimeCommissionThreshold } } ``` **备选方案及拒绝原因**: - ~~累计充值的强充金额也固定为阈值~~:不合理,会导致"买39元套餐要充1000元"的问题 - ~~累计充值的强充金额动态计算(阈值 - 当前累计)~~:不合理,每次充值金额不固定 --- ### Decision 7: 模块依赖注入 **选择**:使用现有的 `bootstrap` 包统一管理依赖注入。 **注入结构**: ```go // bootstrap/stores.go type Stores struct { // ... 现有 stores Recharge *postgres.RechargeStore // 新增 } // bootstrap/services.go type Services struct { // ... 现有 services Recharge *recharge.Service // 新增 } // bootstrap/handlers.go type Handlers struct { // ... 现有 handlers H5Recharge *h5.RechargeHandler // 新增 } // 初始化顺序:Stores → Services → Handlers func Bootstrap(deps *Dependencies) (*Handlers, error) { stores := initStores(deps.DB, deps.Redis) services := initServices(deps, stores) handlers := initHandlers(services) return handlers, nil } ``` **理由**: - 遵循现有架构模式 - 集中管理依赖,易于测试和维护 - 避免循环依赖 --- ## Risks / Trade-offs ### Risk 1: 数据库迁移风险 **风险**:新增字段的迁移可能影响现有数据。 **缓解措施**: - 所有新增字段都有默认值(`is_purchase_on_behalf DEFAULT false`) - 分阶段迁移:先添加字段,再部署代码,最后更新数据 - 迁移前备份数据库 - 在测试环境完整验证迁移流程 --- ### Risk 2: 支付回调幂等性 **风险**:充值订单和套餐订单都支持支付回调,可能重复处理。 **缓解措施**: - 检查订单/充值订单状态,已支付则直接返回成功 - 使用数据库事务保证原子性 - 钱包余额更新使用乐观锁(version 字段) ```go func (s *RechargeService) HandlePaymentCallback(ctx, rechargeNo, method) error { // 幂等性检查 recharge := s.rechargeStore.GetByRechargeNo(ctx, rechargeNo) if recharge.Status == RechargeStatusPaid || recharge.Status == RechargeStatusCompleted { return nil // 已处理,直接返回成功 } // 事务处理 return s.db.Transaction(func(tx *gorm.DB) error { // 更新充值订单状态(带状态检查) result := tx.Model(&RechargeRecord{}). Where("recharge_no = ? AND status = ?", rechargeNo, RechargeStatusPending). Updates(map[string]any{ "status": RechargeStatusPaid, "payment_method": method, "paid_at": time.Now(), }) if result.RowsAffected == 0 { return nil // 已被处理,跳过 } // 增加钱包余额(使用乐观锁) // ... }) } ``` --- ### Risk 3: 强充验证被绕过 **风险**:前端或恶意用户可能绕过强充验证。 **缓解措施**: - 后端创建订单/充值订单时强制验证,拒绝不符合要求的请求 - 记录验证失败的日志,监控异常行为 - API 接口使用认证中间件,防止未授权调用 --- ### Risk 4: 佣金计算逻辑复杂度增加 **风险**:增加代购订单判断后,佣金计算逻辑更复杂,容易出错。 **缓解措施**: - 单元测试覆盖所有场景(普通订单、代购订单、充值订单) - 使用 table-driven tests 测试各种组合 - 添加详细的日志记录,便于排查问题 **测试场景**: ```go func TestCommissionCalculation_PurchaseOnBehalf(t *testing.T) { tests := []struct { name string isPurchaseOnBehalf bool expectUpdateAccumulated bool expectOneTimeCommission bool }{ {"普通订单", false, true, true}, {"代购订单", true, false, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // 测试逻辑 }) } } ``` --- ### Trade-off 1: 预检接口性能 vs 实时性 **权衡**:预检接口需要查询系列分配配置,可能影响性能。 **选择**:不缓存系列分配配置,保证实时性。 **理由**: - 系列分配配置变更频率低 - 单次查询性能可接受(< 10ms) - 实时性更重要(避免用户看到过期的强充要求) - 如果后续性能成为瓶颈,可以引入短期缓存(如 1 分钟) --- ### Trade-off 2: 充值订单和套餐订单独立 vs 统一 **权衡**:使用独立的 RechargeRecord 表增加了一定复杂度。 **选择**:保持独立,不合并到 Order 表。 **理由**: - **语义清晰**:充值不是"订单",是钱包操作 - **查询方便**:充值记录和订单记录可以独立查询和统计 - **扩展性好**:未来可能支持银行转账充值等,不适合放在 Order 表 - **复杂度可控**:只是多一个 Store/Service/Handler,符合分层架构 --- ## Migration Plan ### 阶段 1: 数据库迁移(停机时间 < 1 分钟) 1. **创建迁移文件**: ```bash # 迁移文件 1 000XXX_add_order_purchase_on_behalf.up.sql 000XXX_add_order_purchase_on_behalf.down.sql # 迁移文件 2 000XXX_add_shop_series_allocation_force_recharge.up.sql 000XXX_add_shop_series_allocation_force_recharge.down.sql ``` 2. **执行迁移**: ```bash # 测试环境验证 migrate -path migrations -database "postgres://..." up # 生产环境执行 migrate -path migrations -database "postgres://..." up ``` 3. **验证迁移**: ```sql -- 检查字段是否添加成功 SELECT column_name, data_type, column_default FROM information_schema.columns WHERE table_name IN ('tb_order', 'tb_shop_series_allocation'); ``` --- ### 阶段 2: 代码部署(灰度发布) 1. **部署顺序**: - 先部署 API 服务(包含新接口和修复后的佣金计算) - 再部署 Worker 服务(佣金计算任务处理) 2. **灰度策略**: - 第1天:50% 流量 - 第2天:100% 流量 - 监控错误率、响应时间、佣金计算准确性 3. **回滚策略**: - 如果发现严重问题,立即回滚到旧版本 - 数据库字段保留(有默认值,不影响旧代码) - 充值订单数据保留,后续可重新处理 --- ### 阶段 3: 功能验证 1. **充值功能验证**: - 个人客户创建充值订单 - 微信/支付宝支付成功 - 钱包余额正确增加 - 累计充值正确更新 - 达到阈值时正确触发佣金 2. **代购功能验证**: - 平台创建代购订单 - 订单自动完成 - 套餐正确激活 - 差价佣金正确计算 - 一次性佣金不触发 - 累计充值不更新 3. **强充验证**: - 预检接口返回正确的强充要求 - 创建订单时正确验证强充金额 - 不符合要求的订单被拒绝 --- ### 阶段 4: 数据监控 1. **监控指标**: - 充值订单创建数量、成功率 - 代购订单创建数量 - 佣金计算准确性(抽样检查) - 累计充值更新准确性 - 预检接口响应时间 - 支付回调成功率 2. **告警规则**: - 充值订单创建失败率 > 5% - 支付回调处理失败率 > 1% - 预检接口响应时间 > 200ms - 佣金计算失败率 > 0.1% --- ## Open Questions ### Q1: 代理能否为下级代理代购? **当前设计**:平台可以为任何代理代购,代理暂不支持。 **待确认**: - 代理是否需要为下级代理代购的能力? - 如果需要,权限如何控制(只能为直属下级?还是所有下级?) - 代购时使用谁的成本价(创建人的成本价?还是买家的成本价?) **影响**:如果需要支持,Handler 层的权限检查需要调整。 --- ### Q2: 充值订单是否需要取消功能? **当前设计**:充值订单创建后不支持取消,只能超时自动关闭。 **待确认**: - 用户是否需要主动取消充值订单? - 如果支持取消,状态流转如何设计? **影响**:如果需要支持,需要增加 Cancel 接口和状态流转逻辑。 --- ### Q3: 强充金额是否需要支持范围(最小-最大)? **当前设计**:强充金额是一个固定值(如100元)。 **待确认**: - 是否需要支持金额范围(如 100-500 元之间任意金额)? - 如果支持,配置字段如何设计? **影响**:如果需要支持,ShopSeriesAllocation 需要增加 `force_recharge_min` 和 `force_recharge_max` 字段。 --- ### Q4: 充值订单是否需要支持优惠券/折扣? **当前设计**:充值订单不支持任何优惠。 **待确认**: - 未来是否需要支持充值满减、折扣等营销活动? - 如果需要,是否在本次实现? **影响**:如果需要支持,需要设计优惠券系统,超出本次范围。 **建议**:留待后续实现,本次保持简单。