## Context 当前订单创建逻辑存在两条路径: 1. **平台代购(offline)**:平台为代理创建订单,使用 `offline` 支付方式,订单创建后立即标记已支付并激活套餐,不扣钱包 2. **其他支付方式(wallet/wechat/alipay)**:创建待支付订单(`payment_status = pending`),后续调用支付接口完成支付 问题在于:代理在后台使用 `wallet` 创建订单时,走的是第 2 条路径(创建待支付订单),但后台没有支付接口,导致订单无法完成。 **实际业务场景**:代理帮客户购买套餐(代购),需要从自己钱包扣款并立即激活套餐,这是一步完成的操作,不应该创建待支付订单。 **现有代码分析**: - `Service.Create()` 方法中,只有 `payment_method == "offline"` 才会调用 `createOrderWithActivation()`(立即完成) - `wallet` 支付会直接调用 `orderStore.Create()`,创建待支付订单,不扣款不激活 - 缺少"操作者"和"买家"的区分,无法追踪代购关系 ## Goals / Non-Goals **Goals:** 1. 支持代理在后台使用 wallet 一步完成订单(检查余额 → 扣款 → 激活套餐) 2. 区分订单中的"操作者"(谁下单)和"买家"(资源所属者),支持数据追溯和业务分析 3. 正确处理三种代购场景的价格逻辑: - 代理自购:订单金额 = 实际支付 = 自己成本价 - 代理代购(给下级):订单金额 = 下级成本价,实际支付 = 自己成本价 - 平台代购:订单金额 = 下级成本价,实际支付 = 0(不扣款) 4. 支持按订单角色筛选查询(自购、上级购买、平台购买、给下级购买) 5. 钱包流水记录支持场景区分和关联店铺追踪 6. 佣金逻辑调整:代理代购不产生佣金(已赚差价) **Non-Goals:** - 不修改 H5 端的支付流程(H5 端仍然是两步:创建待支付订单 → 调用 WalletPay) - 不修改平台代购(offline)的现有逻辑 - 不支持代理在后台使用微信/支付宝支付(后台只支持 wallet 和 offline) - 不涉及个人客户(C 端)的订单流程 ## Decisions ### 决策 1:新增订单角色追踪字段 **决策**:在 `tb_order` 表新增 4 个字段: - `operator_id` (INT, nullable):操作者 ID(店铺 ID) - `operator_type` (VARCHAR, nullable):操作者类型(`platform` / `agent`) - `actual_paid_amount` (BIGINT, nullable):实际支付金额(分) - `purchase_role` (VARCHAR):订单角色枚举 **理由**: - 现有 `buyer_id` 字段只记录买家(资源所属者),无法区分"谁下单"和"谁买单" - `operator_id` 记录操作者,支持追溯代购关系(如:平台为代理 A 代购,代理 A 为代理 B 代购) - `actual_paid_amount` 记录实际扣款金额,与 `total_amount`(订单金额)可能不同(代理代购场景) - `purchase_role` 枚举字段支持高效筛选,避免依赖文本备注 **替代方案(已拒绝)**: - ❌ 使用 `creator` 字段代替 `operator_id`:`creator` 是审计字段,语义不同,不应混用 - ❌ 使用 `remark` 字段记录代购信息:文本字段无法高效索引和筛选 - ❌ 创建单独的代购订单表:增加系统复杂度,查询需要 JOIN 多表 **`purchase_role` 枚举值**: ```go const ( PurchaseRoleSelfPurchase = "self_purchase" // 自己购买 PurchaseRolePurchasedByParent = "purchased_by_parent" // 上级代理购买 PurchaseRolePurchasedByPlatform = "purchased_by_platform" // 平台代购 PurchaseRolePurchaseForSubordinate = "purchase_for_subordinate" // 给下级购买 ) ``` **索引设计**: - `idx_orders_operator_id` (operator_id):支持"我作为操作者的订单"查询 - `idx_orders_purchase_role` (purchase_role):支持按角色筛选 --- ### 决策 2:订单创建流程重构 **决策**:在 `Service.Create()` 方法中,根据 `payment_method` 和资源归属判断场景: ``` IF payment_method == "offline": → 平台代购场景(保持现有逻辑) → buyer = 资源所属者, operator = nil, operator_type = "platform" → 价格 = 买家成本价, 实际支付 = nil(不扣款) → purchase_role = "purchased_by_platform" → 调用 createOrderWithActivation() ELSE IF payment_method == "wallet": → 获取操作者店铺 ID → 获取资源所属店铺 ID IF 资源所属 == 操作者: → 代理自购场景 → buyer = 操作者, operator = 操作者 → 价格 = 操作者成本价, 实际支付 = 操作者成本价 → purchase_role = "self_purchase" → is_purchase_on_behalf = false ELSE: → 代理代购场景 → buyer = 资源所属者, operator = 操作者 → 价格 = 买家成本价, 实际支付 = 操作者成本价 → purchase_role = "purchase_for_subordinate" → is_purchase_on_behalf = true → 调用 createOrderWithWalletPayment() ``` **理由**: - 清晰区分三种场景,每种场景的价格逻辑和字段填充规则不同 - 复用现有的 `createOrderWithActivation()`(平台代购) - 新增 `createOrderWithWalletPayment()`(代理钱包支付,含扣款逻辑) **替代方案(已拒绝)**: - ❌ 使用策略模式分离三种场景:过度设计,增加代码复杂度 - ❌ 在 Handler 层判断场景:违反分层架构,业务逻辑应在 Service 层 --- ### 决策 3:价格计算逻辑 **决策**:区分"订单金额"(`total_amount`)和"实际支付"(`actual_paid_amount`): | 场景 | 订单金额(total_amount) | 实际支付(actual_paid_amount) | 说明 | |------|------------------------|------------------------------|------| | 代理自购 | 操作者成本价 | 操作者成本价 | 两者相同 | | 代理代购 | 买家成本价 | 操作者成本价 | 操作者实际扣款少于订单金额(赚取差价) | | 平台代购 | 买家成本价 | NULL | 平台不扣款 | **理由**: - **订单金额**面向买家:买家看到的应该是"他的成本价",这样才符合业务逻辑 - **实际支付**面向操作者:操作者实际扣款金额,用于钱包流水和财务对账 - 代理代购时,操作者按自己的成本价扣款,但订单显示下级成本价,差价即为利润 **示例**: ``` 一级代理 A 成本价:80 元 二级代理 B 成本价:100 元 A 为 B 的卡购买套餐: - total_amount = 100(B 看到的订单金额) - actual_paid_amount = 80(A 实际扣款) - A 赚取差价:20 元 ``` **成本价查询**: ```go // 通过 ShopPackageAllocation 表查询店铺对套餐的成本价 allocation, err := s.shopPackageAllocationStore.GetByShopAndPackage(ctx, shopID, packageID) costPrice := allocation.CostPrice ``` --- ### 决策 4:事务处理设计 **决策**:新增 `createOrderWithWalletPayment()` 方法,使用 GORM 事务确保原子性: ```go func (s *Service) createOrderWithWalletPayment( ctx context.Context, order *model.Order, items []*model.OrderItem, operatorShopID uint, buyerShopID uint, ) (*dto.OrderResponse, error) { actualAmount := *order.ActualPaidAmount // 1. 事务外:检查钱包余额(快速失败) wallet, err := s.agentWalletStore.GetMainWallet(ctx, operatorShopID) if wallet.Balance < actualAmount { return nil, errors.New(errors.CodeInsufficientBalance, "余额不足") } // 2. 事务内:创建订单 + 扣款 + 创建流水 + 激活套餐 err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // 2.1 创建订单 if err := tx.Create(order).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "创建订单失败") } // 2.2 创建订单明细 for _, item := range items { item.OrderID = order.ID } if err := tx.CreateInBatches(items, 100).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "创建订单明细失败") } // 2.3 扣减钱包余额(乐观锁) walletResult := tx.Model(&model.AgentWallet{}). Where("id = ? AND balance >= ? AND version = ?", wallet.ID, actualAmount, wallet.Version). Updates(map[string]any{ "balance": gorm.Expr("balance - ?", actualAmount), "version": gorm.Expr("version + 1"), }) if walletResult.RowsAffected == 0 { return errors.New(errors.CodeInsufficientBalance, "余额不足或并发冲突") } // 2.4 创建钱包流水 if err := s.createWalletTransaction(ctx, tx, wallet.ID, order.ID, actualAmount, order.PurchaseRole, buyerShopID); err != nil { return err } // 2.5 激活套餐 return s.activatePackage(ctx, tx, order) }) // 3. 事务外:佣金计算(异步) if order.OperatorID == nil { s.enqueueCommissionCalculation(ctx, order.ID) } return s.buildOrderResponse(order, items), nil } ``` **事务步骤顺序理由**: 1. **先创建订单**:确保订单记录存在,后续步骤失败时可以回滚 2. **后扣款**:避免扣款成功但订单创建失败的情况(钱扣了但没有订单) 3. **最后激活套餐**:套餐激活依赖订单 ID,必须在订单创建后执行 **乐观锁设计**: - 使用 `version` 字段防止并发扣款导致余额不一致 - `WHERE balance >= ?` 确保余额充足 - `RowsAffected == 0` 表示余额不足或版本冲突,回滚事务 **替代方案(已拒绝)**: - ❌ 使用悲观锁(SELECT FOR UPDATE):会降低并发性能,乐观锁已足够 - ❌ 先扣款后创建订单:扣款成功但订单失败时难以回滚(需要补偿事务) --- ### 决策 5:订单查询 OR 逻辑 **决策**:修改 `OrderStore.List()` 方法,支持 `buyer_id = shopID OR operator_id = shopID` 查询: ```go func (s *OrderStore) List(ctx context.Context, opts *store.QueryOptions, filters map[string]any) ([]*model.Order, int64, error) { query := s.db.WithContext(ctx).Model(&model.Order{}) // 代理用户:查询作为买家或操作者的订单 if shopID, ok := filters["shop_id"].(uint); ok { query = query.Where( "(buyer_type = ? AND buyer_id = ?) OR operator_id = ?", model.BuyerTypeAgent, shopID, shopID, ) delete(filters, "shop_id") } // 其他筛选条件... } ``` **理由**: - 代理需要看到两类订单: 1. 作为买家的订单(`buyer_id = 自己`):别人为自己代购、自己购买 2. 作为操作者的订单(`operator_id = 自己`):自己为下级代购 - OR 查询在 PostgreSQL 中性能可接受(已有索引) **性能优化**: - `buyer_id` 已有索引 `idx_order_buyer` - `operator_id` 新建索引 `idx_orders_operator_id` - OR 查询会走两个索引的 UNION,性能可接受 **替代方案(已拒绝)**: - ❌ 两次查询后合并结果:代码复杂,分页逻辑难以实现 - ❌ 冗余存储(同一订单插入两次):违反数据一致性原则 --- ### 决策 6:钱包流水记录设计 **决策**:钱包流水表新增字段(如果不存在): - `transaction_subtype` (VARCHAR):交易子类型,细分 `order_payment` 场景 - `related_shop_id` (INT, nullable):关联店铺 ID,代购时记录下级店铺 **子类型枚举**(在 `pkg/constants/wallet.go` 中定义): ```go // 钱包交易子类型(当 transaction_type = "order_payment" 时) const ( WalletTransactionSubtypeSelfPurchase = "self_purchase" WalletTransactionSubtypePurchaseForSubordinate = "purchase_for_subordinate" ) ``` **流水创建逻辑**: ```go func (s *Service) createWalletTransaction( ctx context.Context, tx *gorm.DB, walletID uint, orderID uint, amount int64, purchaseRole string, relatedShopID *uint, ) error { var subtype string var remark string switch purchaseRole { case constants.PurchaseRoleSelfPurchase: subtype = constants.WalletTransactionSubtypeSelfPurchase remark = "购买套餐" case constants.PurchaseRolePurchaseForSubordinate: subtype = constants.WalletTransactionSubtypePurchaseForSubordinate // 查询下级店铺名称 buyerShop, _ := s.shopStore.GetByID(ctx, *relatedShopID) if buyerShop != nil { remark = fmt.Sprintf("为下级代理【%s】购买套餐", buyerShop.ShopName) } else { remark = "为下级代理购买套餐" } } transaction := &model.AgentWalletTransaction{ WalletID: walletID, TransactionType: constants.AgentTransactionTypeDeduct, TransactionSubtype: subtype, Amount: -amount, OrderID: &orderID, RelatedShopID: relatedShopID, Remark: remark, } return tx.Create(transaction).Error } ``` **理由**: - `transaction_subtype` 支持高效筛选(如:查询所有自购流水) - `related_shop_id` 支持追溯(如:查询为哪些下级代理购买过) - `remark` 作为辅助展示字段,包含店铺名称便于人工查看 --- ### 决策 7:佣金逻辑调整 **决策**:在 `Service.Create()` 方法中,只有 `operator_id == nil`(平台代购)才入队佣金计算任务: ```go // 佣金计算(异步) if order.OperatorID == nil { // 只有平台代购才产生佣金 s.enqueueCommissionCalculation(ctx, order.ID) } // 代理代购不入队佣金(operator_id != nil) ``` **理由**: - **代理代购**:操作者从自己钱包扣自己的成本价,已经赚取了成本价差(自己成本价 vs 下级成本价),不应再产生佣金 - **平台代购**:平台不扣款,按买家成本价计算差价佣金,激励上级代理 **示例**: ``` 一级代理 A 成本价:80 元 二级代理 B 成本价:100 元 场景 1:A 为 B 的卡购买套餐(代理代购) - A 扣款:80 元 - A 利润:100 - 80 = 20 元 - 佣金:无(A 已赚取差价) 场景 2:平台为 B 的卡购买套餐(平台代购) - 平台扣款:0 元 - 订单金额:100 元 - 佣金:给 A(B 的上级),金额 = 100 - 80 = 20 元 ``` **替代方案(已拒绝)**: - ❌ 代理代购也计算佣金:会导致双重利润(差价 + 佣金),不符合业务逻辑 --- ### 决策 8:常量定义位置 **决策**: - **订单角色枚举**(`PurchaseRole*`):定义在 `internal/model/order.go`,紧邻 Order 模型 - **钱包交易子类型**:扩展现有 `pkg/constants/wallet.go`,新增 `WalletTransactionSubtype*` 常量 - **操作者类型**:复用现有 `pkg/constants/iot.go` 中的 `OwnerTypePlatform` 和自定义 `"agent"` 字符串 **理由**: - 订单角色枚举只在订单模块使用,放在 `model/order.go` 避免常量文件膨胀 - 钱包交易子类型属于钱包系统,应在 `wallet.go` 中管理 - 避免重复定义已有常量(如 `OwnerTypePlatform`) --- ### 决策 9:DTO 响应字段设计 **决策**:`OrderResponse` 新增字段: ```go type OrderResponse struct { // ... 现有字段 // 操作者信息 OperatorID *uint `json:"operator_id,omitempty"` OperatorType string `json:"operator_type,omitempty"` OperatorName string `json:"operator_name,omitempty"` ActualPaidAmount *int64 `json:"actual_paid_amount,omitempty"` // 订单角色 PurchaseRole string `json:"purchase_role"` IsPurchasedByParent bool `json:"is_purchased_by_parent"` PurchaseRemark string `json:"purchase_remark,omitempty"` } ``` **字段说明**: - `operator_id`、`operator_type`、`actual_paid_amount`:直接映射数据库字段 - `operator_name`:查询操作者名称(Shop 表的 `shop_name`),便于前端展示 - `purchase_role`:订单角色枚举,支持前端筛选和标签展示 - `is_purchased_by_parent`:派生字段,`purchase_role == "purchased_by_parent"` - `purchase_remark`:派生字段,如"由上级代理【XX】购买"或"由平台代购" **理由**: - 前端需要友好的文本展示(如操作者名称、购买备注) - `is_purchased_by_parent` 便于前端判断是否显示"上级代购"标签 - `purchase_remark` 避免前端拼接文本逻辑 --- ## Risks / Trade-offs ### [风险] 数据库迁移失败 → 回滚策略 **问题**:新增字段的迁移脚本可能在生产环境执行失败(如表锁、超时) **缓解措施**: 1. 所有新增字段设为 `nullable`,不影响现有数据 2. 迁移脚本分步执行: - Step 1: 添加字段(不加 NOT NULL 约束) - Step 2: 数据回填(如有需要) - Step 3: 添加索引(CONCURRENTLY 方式,不锁表) 3. 回滚脚本:`DROP COLUMN IF EXISTS` 4. 测试环境充分验证后再上生产 --- ### [风险] OR 查询性能下降 → 索引优化 **问题**:`WHERE (buyer_id = X) OR (operator_id = X)` 可能无法有效使用索引 **缓解措施**: 1. 使用 `EXPLAIN ANALYZE` 验证查询计划 2. 确保两个字段都有索引 3. 如果性能不佳,考虑使用 UNION: ```sql SELECT * FROM tb_order WHERE buyer_id = X UNION SELECT * FROM tb_order WHERE operator_id = X ``` 4. 监控慢查询日志,按需优化 **预期性能**: - 单表查询,无 JOIN - 订单表数据量级:百万级 - OR 查询在 PostgreSQL 中会走 BITMAP INDEX SCAN,性能可接受 --- ### [权衡] 两个金额字段增加存储成本 → 业务清晰度 **问题**:`total_amount` 和 `actual_paid_amount` 在大部分场景下相同(代理自购),存在冗余 **权衡理由**: - **优势**:业务语义清晰,查询无需计算(如:统计实际收入用 `actual_paid_amount`) - **劣势**:存储成本增加(每订单 8 字节),数据冗余 - **结论**:业务清晰度优先,存储成本可接受(8 字节在订单数据中占比很小) --- ### [权衡] 钱包流水查询店铺名称 → 性能 vs 便利性 **问题**:创建钱包流水时查询店铺名称(`shopStore.GetByID`)会增加一次数据库查询 **权衡理由**: - **优势**:备注字段包含店铺名称,便于人工查看,无需二次查询 - **劣势**:订单创建时增加一次查询(~5ms) - **结论**:可接受,因为: 1. 查询频率低(仅代购场景) 2. Shop 表有缓存机制 3. 事务内查询,不影响一致性 4. 如果性能敏感,可以异步更新备注 --- ### [风险] 佣金逻辑调整导致收入计算错误 → 回归测试 **问题**:佣金计算逻辑变更可能影响现有代理的收入 **缓解措施**: 1. 充分的单元测试和集成测试 2. 上线前在测试环境验证佣金计算结果 3. 灰度发布,监控佣金数据异常 4. 保留 `operator_id == nil` 的判断逻辑,避免误伤平台代购 --- ## Migration Plan ### 数据库迁移步骤 **Step 1: 创建迁移脚本** 文件:`migrations/xxx_add_operator_fields_to_orders.up.sql` ```sql -- 添加字段(nullable) ALTER TABLE tb_order ADD COLUMN operator_id INT; ALTER TABLE tb_order ADD COLUMN operator_type VARCHAR(20); ALTER TABLE tb_order ADD COLUMN actual_paid_amount BIGINT; ALTER TABLE tb_order ADD COLUMN purchase_role VARCHAR(50); -- 添加注释 COMMENT ON COLUMN tb_order.operator_id IS '操作者ID(谁下的单)'; COMMENT ON COLUMN tb_order.operator_type IS '操作者类型(platform/agent)'; COMMENT ON COLUMN tb_order.actual_paid_amount IS '实际支付金额(分)'; COMMENT ON COLUMN tb_order.purchase_role IS '订单角色(self_purchase/purchased_by_parent/purchased_by_platform/purchase_for_subordinate)'; -- 添加索引(CONCURRENTLY 避免锁表) CREATE INDEX CONCURRENTLY idx_orders_operator_id ON tb_order(operator_id); CREATE INDEX CONCURRENTLY idx_orders_purchase_role ON tb_order(purchase_role); ``` **Step 2: 钱包流水表迁移**(如果字段不存在) 文件:`migrations/xxx_add_transaction_subtype_to_wallet_transaction.up.sql` ```sql -- 检查字段是否存在,不存在则添加 DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='tb_agent_wallet_transaction' AND column_name='transaction_subtype') THEN ALTER TABLE tb_agent_wallet_transaction ADD COLUMN transaction_subtype VARCHAR(50); COMMENT ON COLUMN tb_agent_wallet_transaction.transaction_subtype IS '交易子类型(细分 order_payment 场景)'; END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='tb_agent_wallet_transaction' AND column_name='related_shop_id') THEN ALTER TABLE tb_agent_wallet_transaction ADD COLUMN related_shop_id INT; COMMENT ON COLUMN tb_agent_wallet_transaction.related_shop_id IS '关联店铺ID(代购时记录下级店铺)'; END IF; END $$; ``` **Step 3: 数据回填**(可选,视历史数据需求而定) ```sql -- 回填现有订单的 purchase_role -- 平台代购订单(offline) UPDATE tb_order SET purchase_role = 'purchased_by_platform', operator_type = 'platform' WHERE payment_method = 'offline' AND is_purchase_on_behalf = true AND purchase_role IS NULL; -- 其他订单暂不回填(保持 NULL,不影响业务) ``` ### 部署步骤 1. **测试环境验证**: - 执行迁移脚本 - 验证索引创建成功 - 运行集成测试 - 手工测试三种代购场景 2. **灰度发布**: - 代码部署到灰度环境 - 观察日志和监控指标 - 验证订单创建、查询、钱包扣款功能 3. **生产环境部署**: - 低峰期执行数据库迁移 - 部署代码 - 监控错误日志和业务指标 - 验证核心功能 ### 回滚策略 **代码回滚**: - 回滚到上一版本代码即可,新增字段为 `nullable`,不影响老代码 **数据库回滚**: - 文件:`migrations/xxx_add_operator_fields_to_orders.down.sql` ```sql DROP INDEX IF EXISTS idx_orders_operator_id; DROP INDEX IF EXISTS idx_orders_purchase_role; ALTER TABLE tb_order DROP COLUMN IF EXISTS operator_id; ALTER TABLE tb_order DROP COLUMN IF EXISTS operator_type; ALTER TABLE tb_order DROP COLUMN IF EXISTS actual_paid_amount; ALTER TABLE tb_order DROP COLUMN IF EXISTS purchase_role; ``` --- ## Open Questions 1. **是否需要支持代理在后台为个人客户(C 端)代购?** - 当前设计只支持代理为代理(B2B),不支持代理为个人客户(B2C) - 如果未来需要,需要扩展 `buyer_type` 判断逻辑 2. **钱包流水的 `related_shop_id` 是否需要索引?** - 当前设计未加索引,因为查询频率低 - 如果未来需要"查询我为哪些下级购买过"功能,需要添加索引 3. **是否需要支持订单角色的批量变更?** - 当前设计 `purchase_role` 在订单创建时填充,后续不可修改 - 如果历史订单需要回填角色,需要单独的数据修复脚本 4. **代理代购时,下级代理是否能看到操作者信息?** - 当前设计:下级可以看到 `operator_id` 和 `operator_name` - 是否需要隐藏?(业务决策)