Files
huang 76b539e867
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m22s
chore: 归档 OpenSpec 变更 refactor-series-binding-to-series-id
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-02 12:21:00 +08:00

15 KiB
Raw Blame History

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 定义

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);

决策 3Service 层逻辑重构策略

选择:将验证和返佣查询分离,按需查询

当前逻辑(错误)

// 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 错误

缓解措施

  1. 使用 IDE 全局搜索 SeriesAllocationIDseries_allocation_id,逐一检查
  2. 运行所有测试,确保覆盖所有代码路径
  3. 分阶段提交Model → Store → Service → 测试,每个阶段验证编译通过

检测方式

# 搜索所有可能遗漏的引用
grep -r "SeriesAllocationID" internal/ --include="*.go"
grep -r "series_allocation_id" internal/ --include="*.go"

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

风险GetByShopAndSeries 查询失败时(如数据不一致),可能导致订单创建或返佣计算失败

缓解措施

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

实施

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. 个人客户场景下跳过返佣查询,实际减少查询次数

索引验证

-- 检查索引是否存在
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-offAPI Breaking Change

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

决策:接受此 trade-off理由如下

  1. 开发阶段,前后端可同步修改
  2. 字段名更语义化,降低未来维护成本
  3. 避免技术债务积累,现在修复比上线后修复成本低

前端修改清单

// 修改前
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.gointernal/model/device.go
  3. 修改所有 DTO 文件6 个结构体)
  4. 验证:编译通过,无语法错误

阶段 2Store 层(数据访问) 5. 更新 IotCardStoreDeviceStore 的查询过滤逻辑 6. 重命名 BatchUpdateSeriesAllocationBatchUpdateSeriesID 7. 重命名 ListBySeriesAllocationIDListBySeriesID 8. 新增 ShopSeriesAllocationStore.GetByShopAndSeries() 9. 新增或完善 PackageSeriesStore(如不存在) 10. 验证Store 层测试通过

阶段 3Service 层(核心业务) 11. 修改 iot_card/service.goBatchSetSeriesBinding 12. 修改 device/service.goBatchSetSeriesBinding 13. 修改 purchase_validation/service.go(关键) 14. 修改 commission_calculation/service.go(关键) 15. 修改 recharge/service.go 16. 修改 order/service.go 17. 验证Service 层测试通过

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

阶段 5测试 20. 更新 Store 层测试(约 10 个测试用例) 21. 更新 Service 层测试(约 50 个测试用例) 22. 更新集成测试(约 40 个测试用例) 23. 验证:运行 go test ./...,全部通过

阶段 6验证 & 清理 24. 运行数据库迁移:migrate up 25. 手动测试 APIPostman/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
  • 如果查询频率低,直接查询即可(有索引支持,性能可接受)

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