Files
junhong_cmp_fiber/openspec/changes/add-one-time-commission/design.md
huang 79c061b6fa
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m24s
feat: 实现套餐管理模块,包含套餐系列、双状态管理、废弃模型清理
- 新增套餐系列管理 (CRUD + 状态切换)
- 新增套餐管理 (CRUD + 启用/禁用 + 上架/下架双状态)
- 清理 8 个废弃分佣模型及对应数据库表
- Package 模型新增建议成本价、建议售价、上架状态字段
- 完整的 Store/Service/Handler 三层实现
- 包含单元测试和集成测试
- 归档 add-package-module change
- 新增多个 OpenSpec changes (订单支付、店铺套餐分配、一次性分佣、卡设备系列绑定)
2026-01-27 19:55:47 +08:00

7.3 KiB
Raw Blame History

Context

Phase 4 完成了订单和支付流程,现在需要实现佣金计算。当终端用户购买套餐支付成功后,系统自动计算各级代理的佣金并入账。

佣金来源

  1. 成本价差收入:每笔订单必触发,售价 - 成本价 = 代理收入
  2. 一次性佣金:满足触发条件时发放一次,金额从梯度配置获取

当前 CommissionRecord 模型过于复杂(包含冻结/解冻字段),需要简化。

Goals / Non-Goals

Goals:

  • 简化 CommissionRecord 模型
  • 实现成本价差收入计算(每笔订单)
  • 实现一次性佣金触发(充值阈值)
  • 佣金直接入账到店铺钱包
  • 提供佣金记录查询和统计

Non-Goals:

  • 不实现冻结/解冻机制
  • 不实现长期佣金(号卡专用)
  • 不实现梯度佣金统计(本期只做配置,统计后续优化)
  • 不实现佣金审批流程

Decisions

1. CommissionRecord 模型简化

决策:删除冻结相关字段,新增来源和关联字段

// 简化后的 CommissionRecord
type CommissionRecord struct {
    gorm.Model
    BaseModel
    ShopID           uint       // 店铺ID佣金归属
    OrderID          uint       // 关联订单ID
    IotCardID        uint       // 关联卡ID可空
    DeviceID         uint       // 关联设备ID可空
    CommissionSource string     // 佣金来源: cost_diff-成本价差 one_time-一次性佣金 tier_bonus-梯度奖励
    Amount           int64      // 佣金金额(分)
    BalanceAfter     int64      // 入账后钱包余额(分)
    Status           int        // 状态: 1-已入账 2-已失效
    ReleasedAt       *time.Time // 入账时间
    Remark           string     // 备注
}

删除字段

  • agent_id(改用 shop_id
  • rule_id(不再关联复杂规则)
  • commission_type(改用 commission_source
  • unfrozen_at、冻结相关状态

2. 佣金计算流程

决策:订单支付成功后异步计算

订单支付成功
    ↓
发送异步任务 (Asynq)
    ↓
佣金计算任务执行:
    1. 获取订单信息
    2. 遍历代理层级(从销售店铺到顶级)
    3. 每级计算成本价差收入
    4. 检查一次性佣金触发条件
    5. 创建 CommissionRecord
    6. 更新店铺钱包余额
    7. 更新订单 commission_status

3. 成本价差收入计算

决策:各级代理按自己的成本价差计算

计算规则

  • 终端销售代理:收入 = 售价 - 自己的成本价
  • 中间层级代理:收入 = 下级的成本价 - 自己的成本价
func CalculateCostDiffCommission(order *Order) []CommissionRecord {
    var records []CommissionRecord
    
    // 获取销售店铺(终端销售的代理)
    sellerShop := GetShop(order.SellerShopID)
    sellerCostPrice := GetCostPrice(sellerShop.ID, order.PackageID)
    
    // 终端销售代理的收入 = 售价 - 成本价
    sellerProfit := order.TotalAmount - sellerCostPrice
    if sellerProfit > 0 {
        records = append(records, CommissionRecord{
            ShopID:           sellerShop.ID,
            OrderID:          order.ID,
            CommissionSource: "cost_diff",
            Amount:           sellerProfit,
        })
    }
    
    // 遍历上级代理链
    childCostPrice := sellerCostPrice
    currentShop := GetShop(sellerShop.ParentID)
    
    for currentShop != nil {
        // 获取当前店铺的成本价
        myCostPrice := GetCostPrice(currentShop.ID, order.PackageID)
        
        // 收入 = 下级成本价 - 自己成本价
        profit := childCostPrice - myCostPrice
        if profit > 0 {
            records = append(records, CommissionRecord{
                ShopID:           currentShop.ID,
                OrderID:          order.ID,
                CommissionSource: "cost_diff",
                Amount:           profit,
            })
        }
        
        // 移动到上级
        childCostPrice = myCostPrice
        currentShop = GetShop(currentShop.ParentID)
    }
    
    return records
}

4. 一次性佣金触发

决策:两种触发类型,每张卡/设备只触发一次

// 触发类型 A一次性充值 ≥ 阈值
func CheckOneTimeRecharge(order *Order, threshold int64) bool {
    return order.TotalAmount >= threshold
}

// 触发类型 B累计充值 ≥ 阈值
func CheckAccumulatedRecharge(card *IotCard, threshold int64) bool {
    return card.AccumulatedRecharge >= threshold
}

// 检查并发放一次性佣金
func TriggerOneTimeCommission(order *Order, card *IotCard) {
    if card.FirstCommissionPaid {
        return // 已发放过
    }
    
    // 获取配置的触发条件和金额
    tier := GetCommissionTier(card.SeriesAllocationID)
    
    // 检查触发条件
    triggered := false
    switch tier.TriggerType {
    case "one_time_recharge":
        triggered = CheckOneTimeRecharge(order, tier.ThresholdValue)
    case "accumulated_recharge":
        triggered = CheckAccumulatedRecharge(card, tier.ThresholdValue)
    }
    
    if triggered {
        // 发放佣金
        CreateCommissionRecord(...)
        // 标记已发放
        card.FirstCommissionPaid = true
        UpdateCard(card)
    }
}

5. 钱包入账

决策:直接入账,无冻结期

func CreditCommission(record *CommissionRecord) error {
    return Transaction(func(tx *gorm.DB) error {
        // 1. 获取店铺钱包
        wallet := GetWallet("shop", record.ShopID)
        
        // 2. 增加余额
        wallet.Balance += record.Amount
        UpdateWallet(wallet)
        
        // 3. 记录余额
        record.BalanceAfter = wallet.Balance
        record.Status = 1 // 已入账
        record.ReleasedAt = time.Now()
        UpdateCommissionRecord(record)
        
        // 4. 创建钱包交易记录
        CreateWalletTransaction(...)
        
        return nil
    })
}

6. API 设计

# 佣金记录查询
GET /api/admin/commission-records              佣金记录列表
GET /api/admin/commission-records/:id          佣金记录详情

# 佣金统计
GET /api/admin/commission-stats                佣金统计(总收入、各来源占比)
GET /api/admin/commission-stats/daily          每日佣金统计

Risks / Trade-offs

风险 1异步计算失败

风险:佣金计算任务失败导致佣金未发放

缓解

  • Asynq 自动重试机制
  • 记录任务执行日志
  • 提供手动触发补偿接口

风险 2并发更新钱包余额

风险:多笔佣金同时入账导致余额计算错误

缓解

  • 使用数据库事务
  • 钱包更新使用乐观锁或悲观锁

风险 3代理层级变更

风险:订单支付后代理层级变更,佣金计算基于哪个时间点?

缓解

  • 佣金计算基于订单支付时的代理关系
  • 订单中可记录销售店铺ID快照

Open Questions

  1. 梯度佣金何时统计?

    • 当前设计:本期只做配置,不做实际统计
    • 待确认:是否需要定时任务统计并发放梯度奖励?
  2. 累计充值是否包含当前订单?

    • 当前设计:先更新累计充值,再检查触发条件
    • 待确认:是否正确?
  3. 一次性佣金发放给谁?

    • 当前设计:发放给卡/设备的直接归属店铺
    • 待确认:是否需要多级分佣?