# 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: 套餐分配依赖系列分配 **选择**: 分配套餐前必须先分配对应的系列 **实现方式**: ```go // 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 ``` ### 依赖注入 ```go // 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 ``` ### 事务处理 **删除系列分配时级联处理**: ```go 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) }) } ``` ### 常量定义 ```go // 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_calculation`、`recharge`、`order` 都需要修改 **缓解**: - 抽取公共方法:`GetSeriesAllocationForShop(shopID, seriesID)` - 统一在 `ShopSeriesAllocationStore` 提供查询接口 ### Risk 3: 删除代码可能遗漏引用 **风险**: `ShopSeriesOneTimeCommissionTier` 相关代码可能有遗漏引用 **缓解**: - 删除前全局搜索确认 - 编译验证无引用 ## Open Questions 1. **批量分配交互**:批量分配套餐时,如果系列未分配,是自动创建还是报错? - 当前决定:报错,要求先分配系列 2. **系列分配的状态管理**:系列分配禁用后,已有的套餐分配如何处理? - 当前决定:套餐分配保持不变,但新订单不能使用禁用的系列分配