# 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. **系列分配的状态管理**：系列分配禁用后，已有的套餐分配如何处理？
   - 当前决定：套餐分配保持不变，但新订单不能使用禁用的系列分配
