# 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) - 如果查询频率低,直接查询即可(有索引支持,性能可接受) **决策**:暂不缓存,保持代码简洁。如果性能测试发现瓶颈,再优化。