新增功能: - 代理在后台使用 wallet 支付时,订单直接完成(扣款 + 激活套餐) - 支持代理自购和代理代购场景 - 新增订单角色追踪字段(operator_id、operator_type、actual_paid_amount、purchase_role) - 订单查询支持 OR 逻辑(buyer_id 或 operator_id) - 钱包流水记录交易子类型和关联店铺 - 佣金逻辑调整:代理代购不产生佣金 数据库变更: - 订单表新增 4 个字段和 2 个索引 - 钱包流水表新增 2 个字段 - 包含迁移脚本和回滚脚本 文档: - 功能总结文档 - 部署指南 - OpenAPI 文档更新 - Specs 同步(新增 agent-order-role-tracking capability) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
23 KiB
Context
当前订单创建逻辑存在两条路径:
- 平台代购(offline):平台为代理创建订单,使用
offline支付方式,订单创建后立即标记已支付并激活套餐,不扣钱包 - 其他支付方式(wallet/wechat/alipay):创建待支付订单(
payment_status = pending),后续调用支付接口完成支付
问题在于:代理在后台使用 wallet 创建订单时,走的是第 2 条路径(创建待支付订单),但后台没有支付接口,导致订单无法完成。
实际业务场景:代理帮客户购买套餐(代购),需要从自己钱包扣款并立即激活套餐,这是一步完成的操作,不应该创建待支付订单。
现有代码分析:
Service.Create()方法中,只有payment_method == "offline"才会调用createOrderWithActivation()(立即完成)wallet支付会直接调用orderStore.Create(),创建待支付订单,不扣款不激活- 缺少"操作者"和"买家"的区分,无法追踪代购关系
Goals / Non-Goals
Goals:
- 支持代理在后台使用 wallet 一步完成订单(检查余额 → 扣款 → 激活套餐)
- 区分订单中的"操作者"(谁下单)和"买家"(资源所属者),支持数据追溯和业务分析
- 正确处理三种代购场景的价格逻辑:
- 代理自购:订单金额 = 实际支付 = 自己成本价
- 代理代购(给下级):订单金额 = 下级成本价,实际支付 = 自己成本价
- 平台代购:订单金额 = 下级成本价,实际支付 = 0(不扣款)
- 支持按订单角色筛选查询(自购、上级购买、平台购买、给下级购买)
- 钱包流水记录支持场景区分和关联店铺追踪
- 佣金逻辑调整:代理代购不产生佣金(已赚差价)
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 枚举值:
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 元
成本价查询:
// 通过 ShopPackageAllocation 表查询店铺对套餐的成本价
allocation, err := s.shopPackageAllocationStore.GetByShopAndPackage(ctx, shopID, packageID)
costPrice := allocation.CostPrice
决策 4:事务处理设计
决策:新增 createOrderWithWalletPayment() 方法,使用 GORM 事务确保原子性:
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
}
事务步骤顺序理由:
- 先创建订单:确保订单记录存在,后续步骤失败时可以回滚
- 后扣款:避免扣款成功但订单创建失败的情况(钱扣了但没有订单)
- 最后激活套餐:套餐激活依赖订单 ID,必须在订单创建后执行
乐观锁设计:
- 使用
version字段防止并发扣款导致余额不一致 WHERE balance >= ?确保余额充足RowsAffected == 0表示余额不足或版本冲突,回滚事务
替代方案(已拒绝):
- ❌ 使用悲观锁(SELECT FOR UPDATE):会降低并发性能,乐观锁已足够
- ❌ 先扣款后创建订单:扣款成功但订单失败时难以回滚(需要补偿事务)
决策 5:订单查询 OR 逻辑
决策:修改 OrderStore.List() 方法,支持 buyer_id = shopID OR operator_id = shopID 查询:
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")
}
// 其他筛选条件...
}
理由:
- 代理需要看到两类订单:
- 作为买家的订单(
buyer_id = 自己):别人为自己代购、自己购买 - 作为操作者的订单(
operator_id = 自己):自己为下级代购
- 作为买家的订单(
- OR 查询在 PostgreSQL 中性能可接受(已有索引)
性能优化:
buyer_id已有索引idx_order_buyeroperator_id新建索引idx_orders_operator_id- OR 查询会走两个索引的 UNION,性能可接受
替代方案(已拒绝):
- ❌ 两次查询后合并结果:代码复杂,分页逻辑难以实现
- ❌ 冗余存储(同一订单插入两次):违反数据一致性原则
决策 6:钱包流水记录设计
决策:钱包流水表新增字段(如果不存在):
transaction_subtype(VARCHAR):交易子类型,细分order_payment场景related_shop_id(INT, nullable):关联店铺 ID,代购时记录下级店铺
子类型枚举(在 pkg/constants/wallet.go 中定义):
// 钱包交易子类型(当 transaction_type = "order_payment" 时)
const (
WalletTransactionSubtypeSelfPurchase = "self_purchase"
WalletTransactionSubtypePurchaseForSubordinate = "purchase_for_subordinate"
)
流水创建逻辑:
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(平台代购)才入队佣金计算任务:
// 佣金计算(异步)
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 新增字段:
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
[风险] 数据库迁移失败 → 回滚策略
问题:新增字段的迁移脚本可能在生产环境执行失败(如表锁、超时)
缓解措施:
- 所有新增字段设为
nullable,不影响现有数据 - 迁移脚本分步执行:
- Step 1: 添加字段(不加 NOT NULL 约束)
- Step 2: 数据回填(如有需要)
- Step 3: 添加索引(CONCURRENTLY 方式,不锁表)
- 回滚脚本:
DROP COLUMN IF EXISTS - 测试环境充分验证后再上生产
[风险] OR 查询性能下降 → 索引优化
问题:WHERE (buyer_id = X) OR (operator_id = X) 可能无法有效使用索引
缓解措施:
- 使用
EXPLAIN ANALYZE验证查询计划 - 确保两个字段都有索引
- 如果性能不佳,考虑使用 UNION:
SELECT * FROM tb_order WHERE buyer_id = X UNION SELECT * FROM tb_order WHERE operator_id = X - 监控慢查询日志,按需优化
预期性能:
- 单表查询,无 JOIN
- 订单表数据量级:百万级
- OR 查询在 PostgreSQL 中会走 BITMAP INDEX SCAN,性能可接受
[权衡] 两个金额字段增加存储成本 → 业务清晰度
问题:total_amount 和 actual_paid_amount 在大部分场景下相同(代理自购),存在冗余
权衡理由:
- 优势:业务语义清晰,查询无需计算(如:统计实际收入用
actual_paid_amount) - 劣势:存储成本增加(每订单 8 字节),数据冗余
- 结论:业务清晰度优先,存储成本可接受(8 字节在订单数据中占比很小)
[权衡] 钱包流水查询店铺名称 → 性能 vs 便利性
问题:创建钱包流水时查询店铺名称(shopStore.GetByID)会增加一次数据库查询
权衡理由:
- 优势:备注字段包含店铺名称,便于人工查看,无需二次查询
- 劣势:订单创建时增加一次查询(~5ms)
- 结论:可接受,因为:
- 查询频率低(仅代购场景)
- Shop 表有缓存机制
- 事务内查询,不影响一致性
- 如果性能敏感,可以异步更新备注
[风险] 佣金逻辑调整导致收入计算错误 → 回归测试
问题:佣金计算逻辑变更可能影响现有代理的收入
缓解措施:
- 充分的单元测试和集成测试
- 上线前在测试环境验证佣金计算结果
- 灰度发布,监控佣金数据异常
- 保留
operator_id == nil的判断逻辑,避免误伤平台代购
Migration Plan
数据库迁移步骤
Step 1: 创建迁移脚本
文件:migrations/xxx_add_operator_fields_to_orders.up.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
-- 检查字段是否存在,不存在则添加
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: 数据回填(可选,视历史数据需求而定)
-- 回填现有订单的 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,不影响业务)
部署步骤
-
测试环境验证:
- 执行迁移脚本
- 验证索引创建成功
- 运行集成测试
- 手工测试三种代购场景
-
灰度发布:
- 代码部署到灰度环境
- 观察日志和监控指标
- 验证订单创建、查询、钱包扣款功能
-
生产环境部署:
- 低峰期执行数据库迁移
- 部署代码
- 监控错误日志和业务指标
- 验证核心功能
回滚策略
代码回滚:
- 回滚到上一版本代码即可,新增字段为
nullable,不影响老代码
数据库回滚:
- 文件:
migrations/xxx_add_operator_fields_to_orders.down.sqlDROP 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
-
是否需要支持代理在后台为个人客户(C 端)代购?
- 当前设计只支持代理为代理(B2B),不支持代理为个人客户(B2C)
- 如果未来需要,需要扩展
buyer_type判断逻辑
-
钱包流水的
related_shop_id是否需要索引?- 当前设计未加索引,因为查询频率低
- 如果未来需要"查询我为哪些下级购买过"功能,需要添加索引
-
是否需要支持订单角色的批量变更?
- 当前设计
purchase_role在订单创建时填充,后续不可修改 - 如果历史订单需要回填角色,需要单独的数据修复脚本
- 当前设计
-
代理代购时,下级代理是否能看到操作者信息?
- 当前设计:下级可以看到
operator_id和operator_name - 是否需要隐藏?(业务决策)
- 当前设计:下级可以看到