Files
junhong_cmp_fiber/openspec/changes/archive/2026-02-04-refactor-commission-package-model/design.md
huang b18ecfeb55
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m29s
refactor: 一次性佣金配置从套餐级别提升到系列级别
主要变更:
- 新增 tb_shop_series_allocation 表,存储系列级别的一次性佣金配置
- ShopPackageAllocation 移除 one_time_commission_amount 字段
- PackageSeries 新增 enable_one_time_commission 字段控制是否启用一次性佣金
- 新增 /api/admin/shop-series-allocations CRUD 接口
- 佣金计算逻辑改为从 ShopSeriesAllocation 获取一次性佣金金额
- 删除废弃的 ShopSeriesOneTimeCommissionTier 模型
- OpenAPI Tag '系列分配' 和 '单套餐分配' 合并为 '套餐分配'

迁移脚本:
- 000042: 重构佣金套餐模型
- 000043: 简化佣金分配
- 000044: 一次性佣金分配重构
- 000045: PackageSeries 添加 enable_one_time_commission 字段

测试:
- 新增验收测试 (shop_series_allocation, commission_calculation)
- 新增流程测试 (one_time_commission_chain)
- 删除过时的单元测试(已被验收测试覆盖)
2026-02-04 14:28:44 +08:00

16 KiB
Raw Blame History

Context

背景

当前套餐与佣金系统在快速迭代中积累了技术债务,主要问题:

  1. 套餐价格字段混乱PriceSuggestedCostPriceSuggestedRetailPrice 三个字段语义不清,不同场景使用不一致
  2. 流量字段设计缺陷DataType 暗示真/虚流量二选一,但业务需求是共存机制
  3. 分配层次过多:存在 ShopSeriesAllocation(系列分配)和 ShopPackageAllocation(套餐分配)两层,但业务模型只需要一层
  4. 差价佣金计算复杂:使用 BaseCommissionMode/Value 动态计算,但业务模型是简单的成本价差值
  5. 一次性佣金配置位置错误:配置在系列分配表中,应该只在套餐分配表中存储金额

业务模型Source of Truth

详见 docs/commission-package-model.md

核心要点:

  • 只有一层分配ShopPackageAllocation(套餐分配)
  • 差价佣金 = 下级成本价 - 自己成本价(固定差值,无动态计算)
  • 一次性佣金规则在系列定义,金额在套餐分配中设置

现有架构(需要重构)

tb_package_series          # 套餐系列(含一次性佣金规则)
    │
    ├── tb_package         # 套餐
    │
    └── tb_shop_series_allocation    # ❌ 系列分配(需要删除)
            │
            └── tb_shop_package_allocation   # 套餐分配

目标架构

tb_package_series          # 套餐系列(含一次性佣金规则配置)
    │
    └── tb_package         # 套餐
            │
            └── tb_shop_package_allocation   # 套餐分配(唯一的分配表)
                    ├── shop_id              # 被分配的店铺
                    ├── package_id           # 套餐ID
                    ├── series_id            # 系列ID新增用于关联规则
                    ├── allocator_shop_id    # 分配者店铺ID新增
                    ├── cost_price           # 该代理的成本价
                    ├── one_time_commission_amount  # 该代理能拿到的一次性佣金
                    └── status

约束条件

  • 不使用外键约束和 GORM 关联关系
  • 必须支持向后兼容的数据迁移
  • 分层架构Handler → Service → Store → Model
  • 异步任务使用 Asynq

Goals / Non-Goals

Goals

  1. 简化套餐模型:只保留 cost_price + suggested_retail_price,语义清晰
  2. 支持流量共存:真流量必填 + 虚流量可选开关,停机判断逻辑统一
  3. 删除 ShopSeriesAllocation:移除多余的分配层次
  4. 简化差价佣金计算:直接使用成本价差值,删除动态计算逻辑
  5. 统一分配模型:一次性佣金金额只存储在 ShopPackageAllocation
  6. 代理视角隔离:不同代理看到自己的成本价和能拿到的一次性佣金

Non-Goals

  • 不重构订单支付流程(仅适配新的佣金计算)
  • 不修改钱包充值逻辑(仅增加累计追踪)
  • 不修改梯度佣金的统计存储结构(仅增加统计范围开关)
  • 不处理历史订单的佣金重算(迁移只处理配置数据)

Decisions

D1: 删除 ShopSeriesAllocation 及相关表

决策:完全删除以下表和相关代码

表名 说明 删除原因
tb_shop_series_allocation 系列分配表 多余的分配层次
tb_shop_series_allocation_config 系列分配配置版本表 依赖于 series_allocation
tb_shop_series_one_time_commission_tier 系列分配梯度配置表 梯度配置移到系列规则中

代码删除清单

  • internal/model/shop_series_allocation.go
  • internal/model/shop_series_allocation_config.go
  • internal/model/dto/shop_series_allocation.go
  • internal/store/postgres/shop_series_allocation_store.go
  • internal/store/postgres/shop_series_allocation_config_store.go
  • internal/service/shop_series_allocation/service.go
  • internal/handler/admin/shop_series_allocation.go
  • internal/routes/shop_series_allocation.go
  • pkg/utils/commission.goCalculateCostPrice 函数)

理由

  • 业务模型只需要一层分配(套餐分配)
  • 系列分配增加了不必要的复杂度
  • 所有需要的信息都可以在套餐分配中存储

D2: 修改 ShopPackageAllocation 模型

决策:扩展 ShopPackageAllocation 以承担原 ShopSeriesAllocation 的职责

// Before
type ShopPackageAllocation struct {
    ID           uint
    AllocationID uint   // ❌ 外键到 ShopSeriesAllocation删除
    PackageID    uint
    CostPrice    int64
    Status       int
}

// After
type ShopPackageAllocation struct {
    ID                        uint
    ShopID                    uint   // 被分配的店铺(新增)
    PackageID                 uint   // 套餐ID
    SeriesID                  uint   // 系列ID新增用于关联一次性佣金规则
    AllocatorShopID           uint   // 分配者店铺ID新增0表示平台
    CostPrice                 int64  // 该代理的成本价
    OneTimeCommissionAmount   int64  // 该代理能拿到的一次性佣金金额(新增)
    Status                    int
    Creator                   uint
    Updater                   uint
}

理由

  • ShopIDAllocatorShopID 原本存储在 ShopSeriesAllocation
  • SeriesID 用于查询一次性佣金规则(从 PackageSeries.OneTimeCommissionConfig 获取)
  • OneTimeCommissionAmount 存储分配时设置的金额

D3: 差价佣金计算简化

决策:删除动态计算逻辑,使用固定成本价差值

// Before: 动态计算(删除)
// pkg/utils/commission.go
func CalculateCostPrice(allocation *model.ShopSeriesAllocation, orderAmount int64) int64 {
    switch allocation.BaseCommissionMode {
    case "fixed":
        return orderAmount - allocation.BaseCommissionValue
    case "percent":
        return orderAmount * (100 - allocation.BaseCommissionValue) / 100
    }
    return orderAmount
}

// After: 简单差值计算
// 差价佣金 = 下级成本价 - 自己成本价
func CalculateDifferenceCommission(myCostPrice, subCostPrice int64) int64 {
    return subCostPrice - myCostPrice
}

示例

平台成本价: 100
代理A成本价: 120分配时设置
代理A1成本价: 130A分配给A1时设置

当A1销售时
- A1利润 = 售价 - 130
- A差价佣金 = 130 - 120 = 10固定
- 平台收入 = 120

理由

  • 业务模型明确定义差价佣金 = 下级成本价 - 自己成本价
  • 无需 BaseCommissionMode/Value 的动态计算
  • 简化代码,减少出错可能

D4: 一次性佣金金额存储位置

决策:一次性佣金金额只存储在 ShopPackageAllocation.OneTimeCommissionAmount

// 套餐系列定义规则(不变)
type PackageSeries struct {
    // ... 基础字段
    EnableOneTimeCommission bool   // 是否启用
    OneTimeCommissionConfig string // JSON触发条件、阈值、金额/梯度等
}

// 套餐分配记录每个代理能拿到的金额
type ShopPackageAllocation struct {
    // ... 其他字段
    OneTimeCommissionAmount int64  // 该代理能拿到的一次性佣金
}

分配流程

1. 平台创建套餐系列配置一次性佣金规则首充100返20
2. 平台分配给代理A设置成本价120一次性佣金20
   → 创建 ShopPackageAllocation(shop_id=A, cost_price=120, one_time_commission_amount=20)
3. 代理A分配给A1设置成本价130一次性佣金8
   → 创建 ShopPackageAllocation(shop_id=A1, cost_price=130, one_time_commission_amount=8)
4. 触发一次性佣金时:
   - A1 获得8
   - A 获得20 - 8 = 12
   - 合计20 ✓

约束

  • 给下级的金额 ≤ 自己能拿到的金额
  • 给下级的金额 ≥ 0

理由

  • 与成本价分配逻辑一致,都在套餐分配中设置
  • 每个代理只存储"自己能拿到多少",计算简单
  • 删除了 ShopSeriesAllocation 后的自然归属

D5: 套餐价格字段简化

决策:移除 PriceDataAmountMB,保留并重命名字段

// Before
type Package struct {
    Price                int64  // 语义不清
    SuggestedCostPrice   int64  // 建议成本价
    SuggestedRetailPrice int64  // 建议售价
    DataType             string // real/virtual 二选一
    RealDataMB           int64
    VirtualDataMB        int64
    DataAmountMB         int64  // 语义不清
}

// After
type Package struct {
    CostPrice            int64  // 成本价(平台设置的基础成本价)
    SuggestedRetailPrice int64  // 建议售价
    RealDataMB           int64  // 真实流量(必填)
    EnableVirtualData    bool   // 是否启用虚流量
    VirtualDataMB        int64  // 虚流量(启用时必填,≤ 真实流量)
}

理由

  • Price 在不同上下文含义不同,造成混乱
  • DataType 是二选一设计,但业务需要共存
  • DataAmountMB 没有明确定义是真流量还是虚流量

D6: 代理视角套餐列表实现

决策:在 Service 层动态计算,从 ShopPackageAllocation 获取数据

// PackageService.List 方法
func (s *Service) List(ctx context.Context, req *dto.PackageListRequest) (*dto.PackagePageResult, error) {
    // 1. 查询基础套餐数据
    packages, total, err := s.store.List(ctx, req)
    
    // 2. 获取当前用户信息
    userInfo := middleware.GetUserContextInfo(ctx)
    
    // 3. 如果是代理用户,查询分配关系并填充视角数据
    if userInfo.UserType == constants.UserTypeAgent {
        allocations := s.packageAllocationStore.GetByShopAndPackages(ctx, userInfo.ShopID, packageIDs)
        for _, pkg := range packages {
            if alloc, ok := allocations[pkg.ID]; ok {
                pkg.CostPrice = alloc.CostPrice  // 覆盖为代理视角
                pkg.OneTimeCommissionAmount = alloc.OneTimeCommissionAmount
            }
        }
    }
    
    return result, nil
}

理由

  • 不存储冗余数据,避免一致性问题
  • 查询时动态计算,逻辑集中在 Service 层
  • 使用批量查询避免 N+1 问题

D7: 累计充值追踪方案

决策:在 IoTCardDevice 模型中新增追踪字段

type IoTCard struct {
    // ... 现有字段
    
    // 按套餐系列追踪JSON Map: series_id -> amount
    AccumulatedRechargeBySeriesJSON string `gorm:"column:accumulated_recharge_by_series;type:jsonb"`
    
    // 按套餐系列追踪首充状态JSON Map: series_id -> bool
    FirstRechargeTriggeredBySeriesJSON string `gorm:"column:first_recharge_triggered_by_series;type:jsonb"`
}

理由

  • 累计充值和首充状态都是"按系列"的,需要按系列追踪
  • 使用 JSONB 避免多表关联,查询性能好
  • PostgreSQL 原生支持 JSONB 索引和查询

删除清单

需要删除的文件

文件路径 类型 说明
internal/model/shop_series_allocation.go Model 系列分配模型
internal/model/shop_series_allocation_config.go Model 配置版本模型
internal/model/dto/shop_series_allocation.go DTO 请求/响应 DTO
internal/store/postgres/shop_series_allocation_store.go Store 数据访问层
internal/store/postgres/shop_series_allocation_config_store.go Store 配置版本 Store
internal/store/postgres/shop_series_allocation_store_test.go Test Store 测试
internal/service/shop_series_allocation/service.go Service 业务逻辑层
internal/handler/admin/shop_series_allocation.go Handler HTTP Handler
internal/routes/shop_series_allocation.go Routes 路由注册
tests/integration/shop_series_allocation_test.go Test 集成测试
pkg/utils/commission.go Utils CalculateCostPrice 函数

需要删除的字段

表/模型 字段 说明
ShopPackageAllocation AllocationID 外键到 ShopSeriesAllocation
ShopSeriesAllocation 整表 删除整个表
ShopSeriesAllocationConfig 整表 删除整个表

需要修改的文件

tasks.md 中的详细任务列表。


Risks / Trade-offs

R1: 数据迁移复杂度

风险:现有数据结构与新结构差异大,迁移可能导致数据丢失或不一致

缓解措施

  • 分阶段迁移:先新增字段,再迁移数据,最后删除旧字段
  • 迁移前完整备份
  • 迁移脚本支持回滚
  • 新旧字段并存过渡期2周

R2: API 破坏性变更

风险:前端需要同步修改,上线需要协调

缓解措施

  • 提前沟通 API 变更内容
  • 删除 /api/admin/shop-series-allocations/* 路由
  • 修改 /api/admin/shop-package-allocations/* 路由参数
  • 提供详细的迁移文档

R3: 链式分配计算性能

风险:触发一次性佣金时需要沿代理链向上计算,可能涉及多级查询

缓解措施

  • 使用 Redis 缓存代理链关系
  • 限制代理层级(最多 7 级)
  • 佣金分配使用异步任务处理

R4: JSONB 字段查询性能

风险:累计充值和首充状态使用 JSONB 存储,复杂查询可能慢

缓解措施

  • 为常用查询路径创建 JSONB 索引
  • 触发检查时先查内存/Redis 缓存
  • 监控查询性能,必要时重构为独立表

Migration Plan

注意:当前处于开发阶段,无需数据迁移,直接修改表结构和代码。

数据库变更

-- 1. ShopPackageAllocation 新增字段
ALTER TABLE tb_shop_package_allocation
ADD COLUMN IF NOT EXISTS shop_id BIGINT,
ADD COLUMN IF NOT EXISTS series_id BIGINT,
ADD COLUMN IF NOT EXISTS allocator_shop_id BIGINT DEFAULT 0,
ADD COLUMN IF NOT EXISTS one_time_commission_amount BIGINT DEFAULT 0;

-- 2. ShopPackageAllocation 删除字段
ALTER TABLE tb_shop_package_allocation
DROP COLUMN IF EXISTS allocation_id;

-- 3. Package 表调整
ALTER TABLE tb_package 
DROP COLUMN IF EXISTS price,
DROP COLUMN IF EXISTS data_type,
DROP COLUMN IF EXISTS data_amount_mb,
ADD COLUMN IF NOT EXISTS enable_virtual_data BOOLEAN DEFAULT false;

-- 4. IoTCard 新增追踪字段
ALTER TABLE tb_iot_card
ADD COLUMN IF NOT EXISTS accumulated_recharge_by_series JSONB DEFAULT '{}',
ADD COLUMN IF NOT EXISTS first_recharge_triggered_by_series JSONB DEFAULT '{}';

-- 5. Device 新增追踪字段
ALTER TABLE tb_device
ADD COLUMN IF NOT EXISTS accumulated_recharge_by_series JSONB DEFAULT '{}',
ADD COLUMN IF NOT EXISTS first_recharge_triggered_by_series JSONB DEFAULT '{}';

-- 6. 删除废弃表
DROP TABLE IF EXISTS tb_shop_series_allocation;
DROP TABLE IF EXISTS tb_shop_series_allocation_config;
DROP TABLE IF EXISTS tb_shop_series_one_time_commission_tier;

代码变更顺序

  1. Model 层

    • 修改 ShopPackageAllocation 模型
    • 删除 ShopSeriesAllocation 相关模型
  2. DTO 层

    • 修改 ShopPackageAllocation DTO
    • 删除 ShopSeriesAllocation DTO
  3. Store 层

    • 修改 ShopPackageAllocationStore
    • 删除 ShopSeriesAllocationStore
  4. Service 层

    • 修改所有依赖 ShopSeriesAllocation 的 Service
    • 删除 ShopSeriesAllocationService
  5. Handler/Routes 层

    • 删除 ShopSeriesAllocationHandler
    • 删除相关路由注册
  6. Bootstrap 层

    • 移除所有 ShopSeriesAllocation 相关初始化

Open Questions

  1. 历史订单佣金:已完成的订单佣金是否需要按新规则重算?

    • 建议:不重算,保持历史数据稳定
  2. 过渡期时长:新旧字段并存多久?

    • 建议2周观察期确认无问题后清理
  3. 前端发版协调:是否需要灰度发布?

    • 取决于前端改动量,建议同步上线