# Design: refactor-series-binding-to-series-id

## Context

### 当前架构问题

当前系统中，IoT卡和设备通过 `series_allocation_id` 字段绑定到 `ShopSeriesAllocation` 表，形成以下关系链：

```
IotCard/Device
  └── series_allocation_id → ShopSeriesAllocation
                                  ├── shop_id → Shop
                                  ├── series_id → PackageSeries
                                  └── 返佣配置（BaseCommissionValue, OneTimeCommission...）
```

这导致了三层职责混乱：
1. **资源属性层**（卡/设备的可购买范围）依赖于**权限配置层**（ShopSeriesAllocation）
2. 每次需要验证套餐是否可购买时，必须先查询 `ShopSeriesAllocation` 获取 `series_id`
3. 返佣计算和权限验证被强制耦合在一个查询中

### 现有数据结构

**Model 定义**：
```go
type IotCard struct {
    SeriesAllocationID *uint `gorm:"column:series_allocation_id"` // 指向 ShopSeriesAllocation
}

type Device struct {
    SeriesAllocationID *uint `gorm:"column:series_allocation_id"`
}

type ShopSeriesAllocation struct {
    ShopID              uint   // 被分配的店铺ID
    SeriesID            uint   // 套餐系列ID
    BaseCommissionValue int64  // 返佣配置
    // ... 其他返佣相关字段
}
```

**当前业务流程**：
```
购买套餐验证：
card.SeriesAllocationID → ShopSeriesAllocation.SeriesID → 验证 Package.SeriesID
                        ↓
                    同时获取返佣配置
```

### 技术约束

- PostgreSQL 14+，支持字段重命名（`ALTER TABLE ... RENAME COLUMN`）
- GORM v1.25.x，支持动态字段名
- 项目禁止外键约束，关联关系在代码层显式维护
- 开发阶段，无生产数据，可直接修改数据库结构

## Goals / Non-Goals

**Goals:**
- 将卡/设备的系列绑定从"权限分配"改为"套餐系列"，实现职责分离
- 优化查询性能，减少购买验证时的数据库查询次数
- 保持返佣计算逻辑正确性，按需查询 `ShopSeriesAllocation`
- 统一所有相关代码的字段命名（Model、DTO、Store、Service、测试）
- 更新 API 文档，明确参数变更

**Non-Goals:**
- 不修改 `ShopSeriesAllocation` 表结构和返佣计算逻辑
- 不改变 API 端点路径（仅改变请求/响应字段名）
- 不引入数据迁移工具或版本控制（直接重命名字段）
- 不考虑向后兼容性（开发阶段可 BREAKING CHANGE）

## Decisions

### 决策 1：直接重命名数据库字段

**选择**：使用 PostgreSQL 的 `ALTER TABLE ... RENAME COLUMN` 直接重命名字段

**理由**：
- 开发阶段，无生产数据，无需考虑数据迁移
- 字段含义保持一致（都是指向某个 ID），只是关联表变了
- 索引会自动重命名，无需额外处理
- 简单高效，避免引入复杂的数据迁移逻辑

**替代方案**：
- ❌ 新增 `series_id` 字段，保留 `series_allocation_id`：增加字段冗余，需要复杂的迁移逻辑
- ❌ 删除旧字段再创建新字段：会丢失索引，需要手动重建

**实施**：
```sql
-- migrations/000XXX_refactor_series_binding_to_series_id.up.sql
ALTER TABLE tb_iot_card RENAME COLUMN series_allocation_id TO series_id;
ALTER TABLE tb_device RENAME COLUMN series_allocation_id TO series_id;

COMMENT ON COLUMN tb_iot_card.series_id IS '套餐系列ID(关联PackageSeries)';
COMMENT ON COLUMN tb_device.series_id IS '套餐系列ID(关联PackageSeries)';
```

### 决策 2：新增 GetByShopAndSeries 查询方法

**选择**：在 `ShopSeriesAllocationStore` 中新增 `GetByShopAndSeries(shopID, seriesID)` 方法

**理由**：
- 返佣查询的核心需求是：根据店铺和系列查询分配配置
- 当前的 `GetByID(allocationID)` 方法不再适用（卡/设备不再存储 `allocation_id`）
- 该查询有复合索引支持：`(shop_id, series_id)`（需要验证或添加）

**替代方案**：
- ❌ 在 Service 层手动拼接查询条件：违反分层原则，Store 层应封装所有数据访问逻辑
- ❌ 继续使用 `GetByID`，在 Service 层先查询获取 ID：增加查询次数，性能倒退

**实施**：
```go
// internal/store/postgres/shop_series_allocation_store.go
func (s *ShopSeriesAllocationStore) GetByShopAndSeries(
    ctx context.Context,
    shopID uint,
    seriesID uint,
) (*model.ShopSeriesAllocation, error) {
    var allocation model.ShopSeriesAllocation
    err := s.db.WithContext(ctx).
        Where("shop_id = ? AND series_id = ? AND status = ?", shopID, seriesID, 1).
        First(&allocation).Error
    if err != nil {
        return nil, err
    }
    return &allocation, nil
}
```

**索引验证**：需要检查 `tb_shop_series_allocation` 表是否有 `(shop_id, series_id)` 复合索引，如无则添加：
```sql
CREATE INDEX IF NOT EXISTS idx_shop_series_allocation_shop_series 
    ON tb_shop_series_allocation(shop_id, series_id);
```

### 决策 3：Service 层逻辑重构策略

**选择**：将验证和返佣查询分离，按需查询

**当前逻辑（错误）**：
```go
// ValidateCardPurchase
allocation := s.seriesAllocationStore.GetByID(card.SeriesAllocationID) // 必须查询
seriesID := allocation.SeriesID // 获取 series_id
packages := s.validatePackages(packageIDs, seriesID)
// allocation 同时用于返佣计算
```

**重构后逻辑（正确）**：
```go
// ValidateCardPurchase
seriesID := *card.SeriesID // 直接使用，无需查询
packages := s.validatePackages(packageIDs, seriesID)

// 按需查询返佣配置（仅当需要时）
var allocation *model.ShopSeriesAllocation
if card.ShopID != nil && *card.ShopID > 0 {
    allocation, _ = s.seriesAllocationStore.GetByShopAndSeries(*card.ShopID, seriesID)
}
```

**优势**：
- 购买验证减少一次数据库查询（直接使用 `card.series_id`）
- 个人客户场景下（`shop_id = NULL`）无需查询 `ShopSeriesAllocation`
- 返佣查询失败不影响购买流程（`allocation = nil` 表示无返佣）

### 决策 4：权限验证逻辑优化

**选择**：在 `BatchSetSeriesBinding` 中，先验证系列是否存在，再验证操作者权限

**当前逻辑（冗余）**：
```go
// 验证分配是否存在
allocation := s.seriesAllocationStore.GetByID(req.SeriesAllocationID)

// 验证卡是否属于分配的店铺
if card.ShopID != allocation.ShopID {
    return errors.New("卡不属于该店铺")
}
```

**重构后逻辑**：
```go
// 1. 验证系列是否存在
series := s.packageSeriesStore.GetByID(req.SeriesID)
if series.Status != 1 {
    return errors.New("套餐系列已禁用")
}

// 2. 验证操作者权限（仅代理）
if operatorShopID != nil {
    allocation, err := s.seriesAllocationStore.GetByShopAndSeries(*operatorShopID, req.SeriesID)
    if err != nil || allocation.Status != 1 {
        return errors.New("您没有权限分配该套餐系列")
    }
}

// 3. 验证卡的权限（基于 card.shop_id）
if operatorShopID != nil && !s.hasPermission(*operatorShopID, card.ShopID) {
    return errors.New("无权操作该卡")
}
```

**优势**：
- 职责清晰：系列验证、权限验证、资源验证分离
- 错误提示更准确：区分"系列不存在"、"无权限"、"无权操作该卡"
- 平台用户（`operatorShopID = nil`）跳过权限检查，直接操作

### 决策 5：测试数据准备策略

**选择**：测试时先创建 `PackageSeries`，再创建 `ShopSeriesAllocation`，最后设置卡/设备的 `series_id`

**数据准备顺序**：
```go
// 1. 创建套餐系列
series := &model.PackageSeries{SeriesCode: "TEST-SERIES", SeriesName: "测试系列", Status: 1}
db.Create(series)

// 2. 创建店铺系列分配
allocation := &model.ShopSeriesAllocation{
    ShopID: shopA.ID,
    SeriesID: series.ID,
    BaseCommissionValue: 200, // 20%
    Status: 1,
}
db.Create(allocation)

// 3. 卡/设备直接绑定系列
card := &model.IotCard{ICCID: "898600...", SeriesID: &series.ID, ShopID: &shopA.ID}
db.Create(card)
```

**验证查询**：
```go
// 验证返佣配置查询
allocation, err := seriesAllocationStore.GetByShopAndSeries(shopA.ID, series.ID)
assert.NoError(t, err)
assert.Equal(t, int64(200), allocation.BaseCommissionValue)
```

## Risks / Trade-offs

### 风险 1：遗漏字段名修改导致运行时错误

**风险**：涉及约 150 处代码需要修改，可能遗漏某些地方，导致运行时 `column not found` 错误

**缓解措施**：
1. 使用 IDE 全局搜索 `SeriesAllocationID` 和 `series_allocation_id`，逐一检查
2. 运行所有测试，确保覆盖所有代码路径
3. 分阶段提交：Model → Store → Service → 测试，每个阶段验证编译通过

**检测方式**：
```bash
# 搜索所有可能遗漏的引用
grep -r "SeriesAllocationID" internal/ --include="*.go"
grep -r "series_allocation_id" internal/ --include="*.go"
```

### 风险 2：返佣查询失败导致业务中断

**风险**：`GetByShopAndSeries` 查询失败时（如数据不一致），可能导致订单创建或返佣计算失败

**缓解措施**：
1. 查询失败时区分 `ErrRecordNotFound` 和其他错误
   - `ErrRecordNotFound`：视为"无返佣配置"，继续业务流程
   - 其他错误：返回错误，中断流程
2. 在关键流程（订单创建、充值）中，返佣计算失败不应阻止主流程
3. 添加日志记录，方便排查数据不一致问题

**实施**：
```go
allocation, err := s.seriesAllocationStore.GetByShopAndSeries(shopID, seriesID)
if err != nil {
    if err == gorm.ErrRecordNotFound {
        // 无返佣配置，个人客户或未分配的店铺
        return nil, nil // 不计算返佣
    }
    return nil, err // 其他错误，中断流程
}
```

### 风险 3：性能回退（增加查询次数）

**风险**：虽然购买验证减少了一次查询，但返佣计算仍需查询 `ShopSeriesAllocation`，可能增加总查询次数

**实际影响**：
- 购买验证：减少 1 次查询（`GetByID(allocation_id)`）
- 返佣计算：增加 1 次条件查询（`GetByShopAndSeries(shop_id, series_id)`）
- **净影响**：0 查询增加（只是查询方式变了）

**性能优化**：
1. 确保 `(shop_id, series_id)` 有复合索引
2. `GetByShopAndSeries` 添加 `status = 1` 条件，利用索引过滤
3. 个人客户场景下跳过返佣查询，实际减少查询次数

**索引验证**：
```sql
-- 检查索引是否存在
SELECT indexname, indexdef 
FROM pg_indexes 
WHERE tablename = 'tb_shop_series_allocation';

-- 如果不存在，添加复合索引
CREATE INDEX idx_shop_series_allocation_shop_series 
    ON tb_shop_series_allocation(shop_id, series_id) 
    WHERE status = 1;
```

### Trade-off：API Breaking Change

**Trade-off**：修改 API 请求/响应字段名是 BREAKING CHANGE，需要前端同步修改

**决策**：接受此 trade-off，理由如下：
1. 开发阶段，前后端可同步修改
2. 字段名更语义化，降低未来维护成本
3. 避免技术债务积累，现在修复比上线后修复成本低

**前端修改清单**：
```typescript
// 修改前
interface BatchSetCardSeriesBindingRequest {
  iccids: string[];
  series_allocation_id: number; // ❌
}

// 修改后
interface BatchSetCardSeriesBindingRequest {
  iccids: string[];
  series_id: number; // ✅
}
```

## Migration Plan

### 实施步骤

**阶段 1：数据库 & Model（基础设施）**
1. 创建数据库迁移文件 `000XXX_refactor_series_binding_to_series_id.up.sql`
2. 修改 `internal/model/iot_card.go` 和 `internal/model/device.go`
3. 修改所有 DTO 文件（6 个结构体）
4. **验证**：编译通过，无语法错误

**阶段 2：Store 层（数据访问）**
5. 更新 `IotCardStore` 和 `DeviceStore` 的查询过滤逻辑
6. 重命名 `BatchUpdateSeriesAllocation` → `BatchUpdateSeriesID`
7. 重命名 `ListBySeriesAllocationID` → `ListBySeriesID`
8. 新增 `ShopSeriesAllocationStore.GetByShopAndSeries()`
9. 新增或完善 `PackageSeriesStore`（如不存在）
10. **验证**：Store 层测试通过

**阶段 3：Service 层（核心业务）**
11. 修改 `iot_card/service.go` 的 `BatchSetSeriesBinding`
12. 修改 `device/service.go` 的 `BatchSetSeriesBinding`
13. 修改 `purchase_validation/service.go`（关键）
14. 修改 `commission_calculation/service.go`（关键）
15. 修改 `recharge/service.go`
16. 修改 `order/service.go`
17. **验证**：Service 层测试通过

**阶段 4：Handler & Routes**
18. 更新路由描述（API 文档）
19. **验证**：Handler 层无需修改（使用 DTO）

**阶段 5：测试**
20. 更新 Store 层测试（约 10 个测试用例）
21. 更新 Service 层测试（约 50 个测试用例）
22. 更新集成测试（约 40 个测试用例）
23. **验证**：运行 `go test ./...`，全部通过

**阶段 6：验证 & 清理**
24. 运行数据库迁移：`migrate up`
25. 手动测试 API（Postman/curl）
26. 检查日志，确认无错误
27. 更新 API 文档（OpenAPI spec）
28. 清理临时代码和注释

### Rollback 策略

如果在开发阶段发现问题，可以通过以下方式回滚：

**数据库回滚**：
```sql
-- migrations/000XXX_refactor_series_binding_to_series_id.down.sql
ALTER TABLE tb_iot_card RENAME COLUMN series_id TO series_allocation_id;
ALTER TABLE tb_device RENAME COLUMN series_id TO series_allocation_id;

COMMENT ON COLUMN tb_iot_card.series_allocation_id IS '套餐系列分配ID(关联ShopSeriesAllocation)';
COMMENT ON COLUMN tb_device.series_allocation_id IS '套餐系列分配ID(关联ShopSeriesAllocation)';
```

**代码回滚**：
- 使用 Git 回滚到重构前的 commit
- 执行 `migrate down` 回滚数据库

## Open Questions

### Q1: tb_shop_series_allocation 表是否有 (shop_id, series_id) 复合索引？

**需要验证**：
```sql
SELECT indexname, indexdef 
FROM pg_indexes 
WHERE tablename = 'tb_shop_series_allocation'
  AND indexdef LIKE '%shop_id%'
  AND indexdef LIKE '%series_id%';
```

**如果没有，需要添加**：
```sql
CREATE INDEX idx_shop_series_allocation_shop_series 
    ON tb_shop_series_allocation(shop_id, series_id) 
    WHERE status = 1;
```

### Q2: PackageSeriesStore 是否已存在？

**需要确认**：是否已有 `internal/store/postgres/package_series_store.go`

**如果不存在，需要创建**：
- 实现 `GetByID(ctx, id)` 方法
- 在 `bootstrap/stores.go` 中注册
- 在 Service 中注入依赖

### Q3: 是否需要在 commission_calculation 中缓存 allocation 查询结果？

**场景**：同一笔订单多次计算返佣时，可能多次查询同一个 `(shop_id, series_id)` 的 allocation

**考虑**：
- 如果查询频率高，可以在 Service 层缓存查询结果（使用 map）
- 如果查询频率低，直接查询即可（有索引支持，性能可接受）

**决策**：暂不缓存，保持代码简洁。如果性能测试发现瓶颈，再优化。
