Files
junhong_cmp_fiber/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/design.md
huang 8ed3d9da93
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m0s
feat: 实现代理钱包订单创建和订单角色追踪功能
新增功能:
- 代理在后台使用 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>
2026-02-28 14:11:42 +08:00

23 KiB
Raw Blame History

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_idcreator 是审计字段,语义不同,不应混用
  • 使用 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 = 100B 看到的订单金额)
- actual_paid_amount = 80A 实际扣款)
- 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
}

事务步骤顺序理由

  1. 先创建订单:确保订单记录存在,后续步骤失败时可以回滚
  2. 后扣款:避免扣款成功但订单创建失败的情况(钱扣了但没有订单)
  3. 最后激活套餐:套餐激活依赖订单 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")
    }

    // 其他筛选条件...
}

理由

  • 代理需要看到两类订单:
    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 中定义):

// 钱包交易子类型(当 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 元

场景 1A 为 B 的卡购买套餐(代理代购)
- A 扣款80 元
- A 利润100 - 80 = 20 元
- 佣金A 已赚取差价)

场景 2平台为 B 的卡购买套餐(平台代购)
- 平台扣款0 元
- 订单金额100 元
- 佣金:给 AB 的上级),金额 = 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

决策 9DTO 响应字段设计

决策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_idoperator_typeactual_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
    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_amountactual_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

-- 添加字段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不影响业务

部署步骤

  1. 测试环境验证

    • 执行迁移脚本
    • 验证索引创建成功
    • 运行集成测试
    • 手工测试三种代购场景
  2. 灰度发布

    • 代码部署到灰度环境
    • 观察日志和监控指标
    • 验证订单创建、查询、钱包扣款功能
  3. 生产环境部署

    • 低峰期执行数据库迁移
    • 部署代码
    • 监控错误日志和业务指标
    • 验证核心功能

回滚策略

代码回滚

  • 回滚到上一版本代码即可,新增字段为 nullable,不影响老代码

数据库回滚

  • 文件:migrations/xxx_add_operator_fields_to_orders.down.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_idoperator_name
    • 是否需要隐藏?(业务决策)