Files
junhong_cmp_fiber/openspec/changes/archive/2026-01-29-add-one-time-commission/design.md
huang e87513541b
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m41s
feat: 实现一次性佣金功能
- 新增佣金计算服务,支持一次性佣金和返佣计算
- 新增 ShopSeriesOneTimeCommissionTier 模型和存储层
- 新增两个数据库迁移:一次性佣金表和订单佣金字段
- 更新 Commission 模型,新增佣金来源和关联字段
- 更新 CommissionRecord 存储层,支持一次性佣金查询
- 更新 MyCommission 服务,集成一次性佣金计算逻辑
- 更新 ShopCommission 服务,支持一次性佣金统计
- 新增佣金计算异步任务处理器
- 更新 API 路由,新增一次性佣金相关端点
- 归档 OpenSpec 变更文档,同步规范到主规范库
2026-01-29 09:36:12 +08:00

19 KiB
Raw Blame History

Context

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

佣金来源

  1. 成本价差收入:每笔订单必触发,售价 - 成本价 = 代理收入
  2. 一次性佣金:满足触发条件时发放一次,每张卡/设备仅发放一次
  3. 周期性梯度返佣:已在 refactor-shop-package-allocation 中实现,本期不涉及

一次性佣金的两种类型

  • 固定一次性佣金充值达标后发放固定金额或比例如首充≥100元返20元
  • 梯度一次性佣金根据系列销售业绩返不同佣金如系列销售额≥5000元时首充返15元

核心业务逻辑

  • 触发条件:基于单张卡/设备的充值情况(首充或累计充值达标)
  • 返佣金额:基于该系列分配的累计销售业绩(销量或销售额)选择梯度档位
  • 发放次数:每张卡/设备仅发放一次(通过 first_commission_paid 标记)
  • 统计来源:使用 ShopSeriesCommissionStats 查询该系列分配的销售业绩

与重构的关系

  • refactor-shop-package-allocation 已实现 ShopSeriesCommissionStats 统计表(按 allocation_id 统计销售业绩)
  • 一次性佣金复用该统计表,通过 allocation_id 查询该系列分配的累计销量/销售额
  • 需要在 ShopSeriesAllocation 表新增一次性佣金配置字段
  • 需要新增 ShopSeriesOneTimeCommissionTier 表存储梯度配置

当前 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. 一次性佣金数据结构

决策:在 ShopSeriesAllocation 新增配置字段 + 新增梯度表

4.1 ShopSeriesAllocation 新增字段

type ShopSeriesAllocation struct {
    // ... 现有字段base_commission, enable_tier_commission 等)
    
    // 🆕 一次性佣金配置
    EnableOneTimeCommission     bool   `gorm:"column:enable_one_time_commission;default:false;comment:是否启用一次性佣金"`
    OneTimeCommissionType       string `gorm:"column:one_time_commission_type;type:varchar(20);comment:类型:fixed-固定 tiered-梯度"`
    OneTimeCommissionTrigger    string `gorm:"column:one_time_commission_trigger;type:varchar(30);comment:触发条件:single_recharge-单次充值 accumulated_recharge-累计充值"`
    OneTimeCommissionThreshold  int64  `gorm:"column:one_time_commission_threshold;type:bigint;comment:最低阈值(分)"`
    
    // 固定一次性佣金配置type="fixed" 时使用)
    OneTimeCommissionMode       string `gorm:"column:one_time_commission_mode;type:varchar(20);comment:模式:fixed-固定金额 percent-百分比"`
    OneTimeCommissionValue      int64  `gorm:"column:one_time_commission_value;type:bigint;comment:佣金金额(分)或比例(千分比)"`
}

4.2 新增 ShopSeriesOneTimeCommissionTier 表

// 梯度一次性佣金配置
type ShopSeriesOneTimeCommissionTier struct {
    gorm.Model
    BaseModel
    AllocationID        uint   `gorm:"column:allocation_id;not null;index;comment:系列分配ID"`
    
    // 梯度判断配置(基于系列销售业绩)
    TierType            string `gorm:"column:tier_type;type:varchar(20);not null;comment:梯度类型:sales_count-销量 sales_amount-销售额"`
    ThresholdValue      int64  `gorm:"column:threshold_value;type:bigint;not null;comment:梯度阈值(销量或销售额分)"`
    
    // 返佣配置
    CommissionMode      string `gorm:"column:commission_mode;type:varchar(20);not null;comment:返佣模式:fixed-固定金额 percent-百分比"`
    CommissionValue     int64  `gorm:"column:commission_value;type:bigint;not null;comment:返佣值(分或千分比)"`
    
    Status              int    `gorm:"column:status;type:int;default:1;comment:状态:1-启用 2-停用"`
}

// TableName 指定表名
func (ShopSeriesOneTimeCommissionTier) TableName() string {
    return "tb_shop_series_one_time_commission_tier"
}

关键说明

  • TierType: 梯度判断类型,与 ShopSeriesCommissionTier 的 tier_type 一致sales_count 或 sales_amount
  • ThresholdValue: 系列销售业绩的阈值如系列累计销售额≥5000元
  • 梯度判断使用 ShopSeriesCommissionStats 表中的统计数据(按 allocation_id 查询)

5. 一次性佣金触发逻辑

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

5.1 触发条件判断

// 触发条件 A单次充值 ≥ 阈值
func CheckSingleRecharge(order *Order, threshold int64) (bool, int64) {
    triggered := order.TotalAmount >= threshold
    return triggered, order.TotalAmount
}

// 触发条件 B累计充值 ≥ 阈值
func CheckAccumulatedRecharge(card *IotCard, order *Order, threshold int64) (bool, int64) {
    // 先累加当前订单金额
    newAccumulated := card.AccumulatedRecharge + order.TotalAmount
    triggered := newAccumulated >= threshold
    return triggered, newAccumulated
}

5.2 佣金金额计算

// 检查并发放一次性佣金(单卡购买场景)
func TriggerOneTimeCommissionForCard(order *Order, card *IotCard) error {
    // 1. 检查是否已发放
    if card.FirstCommissionPaid {
        return nil
    }
    
    // 2. 获取配置
    allocation := GetAllocation(card.SeriesAllocationID)
    if !allocation.EnableOneTimeCommission {
        return nil
    }
    
    // 3. 检查充值触发条件
    var rechargeAmount int64
    switch allocation.OneTimeCommissionTrigger {
    case "single_recharge":
        rechargeAmount = order.TotalAmount
    case "accumulated_recharge":
        rechargeAmount = card.AccumulatedRecharge + order.TotalAmount
    }
    
    if rechargeAmount < allocation.OneTimeCommissionThreshold {
        return nil // 充值金额未达标
    }
    
    // 4. 计算佣金金额
    commissionAmount := calculateOneTimeCommission(allocation, order.TotalAmount)
    
    if commissionAmount <= 0 {
        return nil
    }
    
    // 5. 创建佣金记录
    record := &CommissionRecord{
        ShopID:           card.OwnerShopID,
        OrderID:          order.ID,
        IotCardID:        card.ID,
        CommissionSource: "one_time",
        Amount:           commissionAmount,
    }
    CreateCommissionRecord(record)
    CreditCommission(record)
    
    // 6. 标记已发放并更新累计充值
    card.FirstCommissionPaid = true
    if allocation.OneTimeCommissionTrigger == "accumulated_recharge" {
        card.AccumulatedRecharge = rechargeAmount
    }
    UpdateCard(card)
    
    return nil
}

// 检查并发放一次性佣金(设备购买场景)
func TriggerOneTimeCommissionForDevice(order *Order, device *Device) error {
    // 1. 检查是否已发放
    if device.FirstCommissionPaid {
        return nil
    }
    
    // 2. 获取配置
    allocation := GetAllocation(device.SeriesAllocationID)
    if !allocation.EnableOneTimeCommission {
        return nil
    }
    
    // 3. 检查充值触发条件
    var rechargeAmount int64
    switch allocation.OneTimeCommissionTrigger {
    case "single_recharge":
        rechargeAmount = order.TotalAmount
    case "accumulated_recharge":
        rechargeAmount = device.AccumulatedRecharge + order.TotalAmount
    }
    
    if rechargeAmount < allocation.OneTimeCommissionThreshold {
        return nil
    }
    
    // 4. 计算佣金金额
    commissionAmount := calculateOneTimeCommission(allocation, order.TotalAmount)
    
    if commissionAmount <= 0 {
        return nil
    }
    
    // 5. 创建佣金记录(注意:设备级购买只发放一次,不按卡数倍增)
    record := &CommissionRecord{
        ShopID:           device.OwnerShopID,
        OrderID:          order.ID,
        DeviceID:         device.ID,
        CommissionSource: "one_time",
        Amount:           commissionAmount,
    }
    CreateCommissionRecord(record)
    CreditCommission(record)
    
    // 6. 标记已发放
    device.FirstCommissionPaid = true
    if allocation.OneTimeCommissionTrigger == "accumulated_recharge" {
        device.AccumulatedRecharge = rechargeAmount
    }
    UpdateDevice(device)
    
    return nil
}

// 计算一次性佣金金额(固定或梯度)
func calculateOneTimeCommission(allocation *ShopSeriesAllocation, orderAmount int64) int64 {
    switch allocation.OneTimeCommissionType {
    case "fixed":
        // 固定一次性佣金
        return calculateFixedCommission(
            allocation.OneTimeCommissionMode,
            allocation.OneTimeCommissionValue,
            orderAmount,
        )
        
    case "tiered":
        // 梯度一次性佣金 - 基于系列销售业绩选择档位
        return calculateTieredCommission(allocation.ID, orderAmount)
    }
    
    return 0
}

// 计算固定佣金金额
func calculateFixedCommission(mode string, value int64, orderAmount int64) int64 {
    if mode == "fixed" {
        return value // 固定金额
    } else if mode == "percent" {
        return orderAmount * value / 1000 // 按充值金额的百分比
    }
    return 0
}

// 计算梯度佣金金额(基于系列销售业绩)
func calculateTieredCommission(allocationID uint, orderAmount int64) int64 {
    // 1. 获取梯度配置
    tiers := GetOneTimeCommissionTiers(allocationID)
    if len(tiers) == 0 {
        return 0
    }
    
    // 2. 查询该系列分配的销售业绩统计
    stats, _ := commissionStatsService.GetCurrentStats(ctx, allocationID, "all_time")
    if stats == nil {
        return 0
    }
    
    // 3. 找到最高匹配档位
    var matchedTier *ShopSeriesOneTimeCommissionTier
    for i := range tiers {
        // 获取销售业绩值
        var salesValue int64
        if tiers[i].TierType == "sales_count" {
            salesValue = stats.TotalSalesCount
        } else { // sales_amount
            salesValue = stats.TotalSalesAmount
        }
        
        // 检查是否达到梯度阈值
        if salesValue >= tiers[i].ThresholdValue {
            if matchedTier == nil || tiers[i].ThresholdValue > matchedTier.ThresholdValue {
                matchedTier = &tiers[i]
            }
        }
    }
    
    if matchedTier == nil {
        return 0
    }
    
    // 4. 计算佣金金额
    if matchedTier.CommissionMode == "fixed" {
        return matchedTier.CommissionValue
    } else if matchedTier.CommissionMode == "percent" {
        return orderAmount * matchedTier.CommissionValue / 1000
    }
    
    return 0
}

关键说明

  1. 触发条件:基于单张卡/设备的充值金额(首充或累计充值)
  2. 梯度判断:基于该系列分配的销售业绩(从 ShopSeriesCommissionStats 查询)
  3. 设备场景:设备购买时只发放一次佣金,不按卡数倍增
  4. 统计来源:使用 allocationID 查询该系列分配的累计销量/销售额

5.3 配置示例

示例 1固定一次性佣金首充触发

{
  "enable_one_time_commission": true,
  "one_time_commission_type": "fixed",
  "one_time_commission_trigger": "single_recharge",
  "one_time_commission_threshold": 10000,  // 首充≥100元触发
  "one_time_commission_mode": "fixed",
  "one_time_commission_value": 2000        // 返20元
}

业务效果

  • 用户首次充值≥100元时代理获得20元一次性佣金
  • 该卡/设备后续再充值不再触发

示例 2梯度一次性佣金基于销售金额 + 累计充值触发)

{
  "enable_one_time_commission": true,
  "one_time_commission_type": "tiered",
  "one_time_commission_trigger": "accumulated_recharge",
  "one_time_commission_threshold": 10000,  // 累计充值≥100元才触发
  "tiers": [
    {
      "tier_type": "sales_amount",
      "threshold": 200000,      // 系列累计销售额≥2000元
      "mode": "fixed",
      "value": 1000             // 返10元
    },
    {
      "tier_type": "sales_amount",
      "threshold": 400000,      // 系列累计销售额≥4000元
      "mode": "fixed",
      "value": 1500             // 返15元
    },
    {
      "tier_type": "sales_amount",
      "threshold": 1000000,     // 系列累计销售额≥10000元
      "mode": "percent",
      "value": 100              // 返10%(按充值金额)
    }
  ]
}

业务效果

  • 当该系列分配的累计销售额≥4000元时
  • 用户累计充值≥100元触发一次性佣金
  • 代理获得15元匹配到第二档梯度
  • 如果系列销售额后续达到10000元新用户首充≥100元时代理获得充值金额的10%

示例 3梯度一次性佣金基于销售数量 + 首充触发)

{
  "enable_one_time_commission": true,
  "one_time_commission_type": "tiered",
  "one_time_commission_trigger": "single_recharge",
  "one_time_commission_threshold": 10000,  // 首充≥100元触发
  "tiers": [
    {
      "tier_type": "sales_count",
      "threshold": 20,          // 系列累计销量≥20个
      "mode": "fixed",
      "value": 1000             // 返10元
    },
    {
      "tier_type": "sales_count",
      "threshold": 40,          // 系列累计销量≥40个
      "mode": "fixed",
      "value": 1500             // 返15元
    },
    {
      "tier_type": "sales_count",
      "threshold": 120,         // 系列累计销量≥120个
      "mode": "percent",
      "value": 200              // 返20%
    }
  ]
}

业务效果

  • 当该系列分配的累计销量≥40个时
  • 用户首充≥100元触发一次性佣金
  • 代理获得15元匹配到第二档梯度

6. 钱包入账

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

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
    })
}

7. 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. 设备级购买如何处理?

    • 当前设计:设备购买时使用 Device.SeriesAllocationID 和 Device.FirstCommissionPaid
    • 待确认:设备下多张卡时,一次性佣金只发一次(按设备),还是按卡数倍增?
  3. 累计充值的统计周期?

    • 当前设计:永久累计(从卡开始使用至今)
    • 待确认:是否需要支持按自然年/月重置累计金额?
  4. 一次性佣金是否支持多级分佣?

    • 当前设计:只发放给卡/设备的直接归属店铺
    • 待确认:是否需要上级代理也获得一次性佣金?如何分配比例?