All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m29s
主要变更: - 新增 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) - 删除过时的单元测试(已被验收测试覆盖)
8.2 KiB
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 ← 冗余字段
问题:
- 一个系列有多个套餐时,每个套餐分配都要重复设置佣金
- 代码通过
GetByShopAndSeries+LIMIT 1获取佣金,假设同系列配置相同 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_calculation、recharge、order 都需要修改
缓解:
- 抽取公共方法:
GetSeriesAllocationForShop(shopID, seriesID) - 统一在
ShopSeriesAllocationStore提供查询接口
Risk 3: 删除代码可能遗漏引用
风险: ShopSeriesOneTimeCommissionTier 相关代码可能有遗漏引用
缓解:
- 删除前全局搜索确认
- 编译验证无引用
Open Questions
-
批量分配交互:批量分配套餐时,如果系列未分配,是自动创建还是报错?
- 当前决定:报错,要求先分配系列
-
系列分配的状态管理:系列分配禁用后,已有的套餐分配如何处理?
- 当前决定:套餐分配保持不变,但新订单不能使用禁用的系列分配