Files
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

8.2 KiB

Design: refactor-one-time-commission-allocation

Context

当前状态

一次性佣金配置分散在两个层级:

tb_package_series
├── one_time_commission_config (JSONB)  ← 平台定义的规则(触发条件、梯度等)
│   └── { enable: true, trigger_type: "first_recharge", ... }
│
tb_shop_package_allocation
├── one_time_commission_amount  ← 代理能拿的金额(但这是套餐级!)
└── series_id  ← 冗余字段

问题:

  1. 一个系列有多个套餐时,每个套餐分配都要重复设置佣金
  2. 代码通过 GetByShopAndSeries + LIMIT 1 获取佣金,假设同系列配置相同
  3. enable 藏在 JSON 里,无法索引查询

目标状态

tb_package_series
├── enable_one_time_commission (bool)  ← 提升到顶层,可索引
├── one_time_commission_config (JSONB)  ← 只存规则详情
│
tb_shop_series_allocation (新表)
├── shop_id + series_id (唯一)  ← 一个店铺+系列只有一条记录
├── one_time_commission_amount  ← 代理能拿的一次性佣金
│
tb_shop_package_allocation
├── series_allocation_id  ← 关联系列分配
├── cost_price  ← 只管成本价
└── (移除 one_time_commission_amount, series_id)

约束

  • 遵循 Handler → Service → Store → Model 分层
  • 禁止外键约束,通过 ID 字段手动关联
  • 常量定义在 pkg/constants/
  • 开发阶段,无需数据迁移

Goals / Non-Goals

Goals:

  • 职责分离:系列分配管一次性佣金,套餐分配只管成本价
  • 数据不冗余:一个 shop + series 只有一条佣金配置
  • 消除隐性假设:直接查询系列分配,无需"取第一个"
  • 查询高效:enable_one_time_commission 可索引

Non-Goals:

  • 不保留旧接口兼容性
  • 不支持代理自定义梯度规则
  • 不修改前端交互流程
  • 不做数据迁移

Decisions

Decision 1: 新建 ShopSeriesAllocation 表而非修改现有表

选择: 新建 tb_shop_series_allocation

备选方案:

  • A) 在 ShopPackageAllocation 中保留佣金,添加约束确保同系列一致
  • B) 新建 ShopSeriesAllocation 表,专门管理系列级配置

选择 B 的理由:

  • 职责单一:套餐分配只管成本价,系列分配只管佣金
  • 数据模型清晰:一个 shop + series 对应一条记录,符合业务概念
  • 避免复杂约束:方案 A 需要触发器或应用层约束保证一致性

Decision 2: enable_one_time_commission 提升到顶层字段

选择: 在 PackageSeries 添加布尔字段 enable_one_time_commission

备选方案:

  • A) 保留在 JSON 中,查询时解析
  • B) 提升到顶层字段

选择 B 的理由:

  • 可建索引:WHERE enable_one_time_commission = true
  • 减少 JSON 解析开销
  • 语义清晰:开关是开关,配置是配置

Decision 3: 梯度模式下的链式分配

选择: 代理能拿的金额 = min(梯度匹配金额, 上级给的上限)

备选方案:

  • A) 梯度模式下完全由销量决定,无上限约束
  • B) 固定模式和梯度模式统一使用链式分配

选择 B 的理由:

  • 业务一致性:不论哪种模式,上级都能控制下级的佣金上限
  • 防止佣金倒挂:下级不可能拿到比上级给的更多

Decision 4: 套餐分配依赖系列分配

选择: 分配套餐前必须先分配对应的系列

实现方式:

// ShopPackageAllocationService.Create
func (s *Service) Create(ctx context.Context, req *dto.CreateRequest) error {
    // 1. 获取套餐信息
    pkg, _ := s.packageStore.GetByID(ctx, req.PackageID)
    
    // 2. 检查系列分配是否存在
    seriesAlloc, err := s.seriesAllocationStore.GetByShopAndSeries(ctx, req.ShopID, pkg.SeriesID)
    if err == gorm.ErrRecordNotFound {
        return errors.New(errors.CodeInvalidParam, "请先分配该套餐所属的系列")
    }
    
    // 3. 创建套餐分配,关联系列分配
    allocation := &model.ShopPackageAllocation{
        ShopID:              req.ShopID,
        PackageID:           req.PackageID,
        SeriesAllocationID:  seriesAlloc.ID,
        CostPrice:           req.CostPrice,
        // ...
    }
    return s.store.Create(ctx, allocation)
}

Decision 5: 删除未使用的 ShopSeriesOneTimeCommissionTier

选择: 直接删除表和相关代码

理由:

  • 经代码搜索确认完全未使用
  • Store 方法未被调用
  • 保留会造成混淆

架构设计

模块结构

internal/
├── model/
│   ├── shop_series_allocation.go          # 新增
│   ├── shop_package_allocation.go         # 修改
│   ├── package.go                          # 修改 PackageSeries
│   └── dto/
│       ├── shop_series_allocation.go      # 新增
│       └── shop_package_allocation.go     # 修改
│
├── store/postgres/
│   ├── shop_series_allocation_store.go    # 新增
│   └── shop_package_allocation_store.go   # 修改
│
├── service/
│   ├── shop_series_allocation/            # 新增
│   │   └── service.go
│   ├── shop_package_allocation/           # 修改
│   ├── commission_calculation/            # 修改
│   ├── recharge/                          # 修改
│   └── order/                             # 修改
│
├── handler/admin/
│   ├── shop_series_allocation.go          # 新增
│   └── shop_package_allocation.go         # 修改
│
└── bootstrap/
    ├── stores.go                          # 添加新 store
    ├── services.go                        # 添加新 service
    └── handlers.go                        # 添加新 handler

依赖注入

// bootstrap/stores.go
type Stores struct {
    // 新增
    ShopSeriesAllocation *postgres.ShopSeriesAllocationStore
    // ...
}

// bootstrap/services.go
type Services struct {
    // 新增
    ShopSeriesAllocation *shop_series_allocation.Service
    // ...
}

// 依赖关系
ShopSeriesAllocationService
├── ShopSeriesAllocationStore
├── PackageSeriesStore
└── ShopStore (获取下级店铺列表)

ShopPackageAllocationService
├── ShopPackageAllocationStore
├── ShopSeriesAllocationStore   新增依赖
├── PackageStore
└── ShopStore

事务处理

删除系列分配时级联处理:

func (s *Service) Delete(ctx context.Context, id uint) error {
    return s.store.Transaction(ctx, func(tx *gorm.DB) error {
        // 1. 检查是否有关联的套餐分配
        count, _ := s.packageAllocationStore.CountBySeriesAllocationID(ctx, id)
        if count > 0 {
            return errors.New(errors.CodeInvalidParam, "存在关联的套餐分配,无法删除")
        }
        
        // 2. 删除系列分配
        return s.store.Delete(ctx, id)
    })
}

常量定义

// pkg/constants/redis.go
func RedisShopSeriesAllocationKey(shopID, seriesID uint) string {
    return fmt.Sprintf("shop_series_alloc:%d:%d", shopID, seriesID)
}

// pkg/constants/constants.go
const (
    // 系列分配状态
    SeriesAllocationStatusEnabled  = 1
    SeriesAllocationStatusDisabled = 2
)

Risks / Trade-offs

Risk 1: 套餐分配 API 破坏性变更

风险: 移除 one_time_commission_amount 参数会破坏现有 API 调用

缓解:

  • 开发阶段,无生产调用方
  • 前端后续单独处理

Risk 2: 佣金计算逻辑改动涉及多个 Service

风险: commission_calculationrechargeorder 都需要修改

缓解:

  • 抽取公共方法:GetSeriesAllocationForShop(shopID, seriesID)
  • 统一在 ShopSeriesAllocationStore 提供查询接口

Risk 3: 删除代码可能遗漏引用

风险: ShopSeriesOneTimeCommissionTier 相关代码可能有遗漏引用

缓解:

  • 删除前全局搜索确认
  • 编译验证无引用

Open Questions

  1. 批量分配交互:批量分配套餐时,如果系列未分配,是自动创建还是报错?

    • 当前决定:报错,要求先分配系列
  2. 系列分配的状态管理:系列分配禁用后,已有的套餐分配如何处理?

    • 当前决定:套餐分配保持不变,但新订单不能使用禁用的系列分配