Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
15 KiB
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...)
这导致了三层职责混乱:
- 资源属性层(卡/设备的可购买范围)依赖于权限配置层(ShopSeriesAllocation)
- 每次需要验证套餐是否可购买时,必须先查询
ShopSeriesAllocation获取series_id - 返佣计算和权限验证被强制耦合在一个查询中
现有数据结构
Model 定义:
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:增加字段冗余,需要复杂的迁移逻辑 - ❌ 删除旧字段再创建新字段:会丢失索引,需要手动重建
实施:
-- 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:增加查询次数,性能倒退
实施:
// 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) 复合索引,如无则添加:
CREATE INDEX IF NOT EXISTS idx_shop_series_allocation_shop_series
ON tb_shop_series_allocation(shop_id, series_id);
决策 3:Service 层逻辑重构策略
选择:将验证和返佣查询分离,按需查询
当前逻辑(错误):
// ValidateCardPurchase
allocation := s.seriesAllocationStore.GetByID(card.SeriesAllocationID) // 必须查询
seriesID := allocation.SeriesID // 获取 series_id
packages := s.validatePackages(packageIDs, seriesID)
// allocation 同时用于返佣计算
重构后逻辑(正确):
// 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 中,先验证系列是否存在,再验证操作者权限
当前逻辑(冗余):
// 验证分配是否存在
allocation := s.seriesAllocationStore.GetByID(req.SeriesAllocationID)
// 验证卡是否属于分配的店铺
if card.ShopID != allocation.ShopID {
return errors.New("卡不属于该店铺")
}
重构后逻辑:
// 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
数据准备顺序:
// 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)
验证查询:
// 验证返佣配置查询
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 错误
缓解措施:
- 使用 IDE 全局搜索
SeriesAllocationID和series_allocation_id,逐一检查 - 运行所有测试,确保覆盖所有代码路径
- 分阶段提交:Model → Store → Service → 测试,每个阶段验证编译通过
检测方式:
# 搜索所有可能遗漏的引用
grep -r "SeriesAllocationID" internal/ --include="*.go"
grep -r "series_allocation_id" internal/ --include="*.go"
风险 2:返佣查询失败导致业务中断
风险:GetByShopAndSeries 查询失败时(如数据不一致),可能导致订单创建或返佣计算失败
缓解措施:
- 查询失败时区分
ErrRecordNotFound和其他错误ErrRecordNotFound:视为"无返佣配置",继续业务流程- 其他错误:返回错误,中断流程
- 在关键流程(订单创建、充值)中,返佣计算失败不应阻止主流程
- 添加日志记录,方便排查数据不一致问题
实施:
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 查询增加(只是查询方式变了)
性能优化:
- 确保
(shop_id, series_id)有复合索引 GetByShopAndSeries添加status = 1条件,利用索引过滤- 个人客户场景下跳过返佣查询,实际减少查询次数
索引验证:
-- 检查索引是否存在
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,理由如下:
- 开发阶段,前后端可同步修改
- 字段名更语义化,降低未来维护成本
- 避免技术债务积累,现在修复比上线后修复成本低
前端修改清单:
// 修改前
interface BatchSetCardSeriesBindingRequest {
iccids: string[];
series_allocation_id: number; // ❌
}
// 修改后
interface BatchSetCardSeriesBindingRequest {
iccids: string[];
series_id: number; // ✅
}
Migration Plan
实施步骤
阶段 1:数据库 & Model(基础设施)
- 创建数据库迁移文件
000XXX_refactor_series_binding_to_series_id.up.sql - 修改
internal/model/iot_card.go和internal/model/device.go - 修改所有 DTO 文件(6 个结构体)
- 验证:编译通过,无语法错误
阶段 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 策略
如果在开发阶段发现问题,可以通过以下方式回滚:
数据库回滚:
-- 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) 复合索引?
需要验证:
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'tb_shop_series_allocation'
AND indexdef LIKE '%shop_id%'
AND indexdef LIKE '%series_id%';
如果没有,需要添加:
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)
- 如果查询频率低,直接查询即可(有索引支持,性能可接受)
决策:暂不缓存,保持代码简洁。如果性能测试发现瓶颈,再优化。