## Context

### 背景

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

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

### 业务模型（Source of Truth）

详见 [`docs/commission-package-model.md`](../../../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.go`（CalculateCostPrice 函数）

**理由**：
- 业务模型只需要一层分配（套餐分配）
- 系列分配增加了不必要的复杂度
- 所有需要的信息都可以在套餐分配中存储

### D2: 修改 ShopPackageAllocation 模型

**决策**：扩展 `ShopPackageAllocation` 以承担原 `ShopSeriesAllocation` 的职责

```go
// 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
}
```

**理由**：
- `ShopID` 和 `AllocatorShopID` 原本存储在 `ShopSeriesAllocation` 中
- `SeriesID` 用于查询一次性佣金规则（从 `PackageSeries.OneTimeCommissionConfig` 获取）
- `OneTimeCommissionAmount` 存储分配时设置的金额

### D3: 差价佣金计算简化

**决策**：删除动态计算逻辑，使用固定成本价差值

```go
// 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成本价: 130（A分配给A1时设置）

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

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

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

**决策**：一次性佣金金额只存储在 `ShopPackageAllocation.OneTimeCommissionAmount`

```go
// 套餐系列定义规则（不变）
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: 套餐价格字段简化

**决策**：移除 `Price` 和 `DataAmountMB`，保留并重命名字段

```go
// 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` 获取数据

```go
// 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: 累计充值追踪方案

**决策**：在 `IoTCard` 和 `Device` 模型中新增追踪字段

```go
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

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

### 数据库变更

```sql
-- 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. **前端发版协调**：是否需要灰度发布？
   - 取决于前端改动量，建议同步上线
