Files
junhong_cmp_fiber/openspec/changes/iccid-dual-column-lookup/tasks.md
huang e049080f6c
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m47s
分裂iccid长度
2026-04-21 10:55:12 +08:00

8.0 KiB
Raw Blame History

1. 数据库迁移

注意1.11.5 全部写在同一个迁移文件中,保持原子性;执行迁移后再发布代码。

索引注意CREATE INDEX CONCURRENTLY 不能在事务块内执行golang-migrate 默认开启事务,迁移文件中必须使用 CREATE INDEX IF NOT EXISTS(不带 CONCURRENTLY。生产环境若需零停机建索引可在发布窗口期手动执行 CONCURRENTLY 版本。(与本项目 000058_add_covering_index_for_deep_pagination.up.sql 范式一致。)

  • 1.1 新建迁移文件,给 tb_iot_card 添加 iccid_19 varchar(19)iccid_20 varchar(20) 两列(允许 NULL使用 ADD COLUMN IF NOT EXISTS
  • 1.1a 迁移文件中执行冲突检测(结果须为 0 行,否则停止迁移人工处理):
    • 检测 19 位前缀重复:SELECT LEFT(iccid, 19), COUNT(*) FROM tb_iot_card WHERE deleted_at IS NULL GROUP BY LEFT(iccid, 19) HAVING COUNT(*) > 1
    • 检测异常长度 ICCIDSELECT id, iccid, LENGTH(iccid) AS len FROM tb_iot_card WHERE deleted_at IS NULL AND LENGTH(iccid) NOT IN (19, 20)(异常记录回填后将永远无法通过新查询路径命中)
  • 1.2 迁移文件中回填存量数据:iccid_19 = LEFT(iccid, 19)iccid_20 = iccid(仅当 LENGTH(iccid)=20否则 NULL
  • 1.3 迁移文件中为 iccid_19 创建 Partial IndexCREATE INDEX IF NOT EXISTS idx_iot_card_iccid_19 ON tb_iot_card (iccid_19) WHERE deleted_at IS NULL禁止 CONCURRENTLY原因见上方注意事项
  • 1.4 迁移文件中为 iccid_20 创建 Partial IndexCREATE INDEX IF NOT EXISTS idx_iot_card_iccid_20 ON tb_iot_card (iccid_20) WHERE deleted_at IS NULL AND iccid_20 IS NOT NULL禁止 CONCURRENTLY
  • 1.5 同一迁移文件中,给 tb_personal_customer_iccid 添加 iccid_19 varchar(19) 列(允许 NULL并回填数据iccid_19 = LEFT(iccid, 19)),创建对应 Partial IndexCREATE INDEX IF NOT EXISTS idx_personal_customer_iccid_19 ON tb_personal_customer_iccid (iccid_19) WHERE deleted_at IS NULL
  • 1.6 执行迁移,验证所有卡记录的 iccid_19iccid_20 已正确回填(使用 PostgreSQL MCP 查询验证)

2. Model 层更新

  • 2.1 在 internal/model/iot_card.goIotCard 结构体中新增以下两个字段,补充 gorm 标签和中文注释:
    • ICCID19 string非指针所有卡必填gorm tag 示例:gorm:"column:iccid_19;type:varchar(19);comment:ICCID前19位"
    • ICCID20 *string指针类型19 位卡为 nilGORM 写入 NULL20 位卡为非 nil 指针gorm tag 示例:gorm:"column:iccid_20;type:varchar(20);comment:完整20位ICCID(仅20位运营商卡有值)"
    • 禁止使用 string 类型存 ICCID20GORM 不会将空字符串自动转为 NULL会破坏 WHERE iccid_20 IS NOT NULL 索引语义
  • 2.2 在 internal/model/personal_customer_iccid.goPersonalCustomerICCID 结构体中新增 ICCID19 字段,补充 gorm 标签和中文注释
  • 2.3 运行 lsp_diagnostics 确认 model 文件无编译错误

3. 工具函数与校验

  • 3.1 在 pkg/utils/iccid.go 中实现 SplitICCID 函数,签名和语义如下:

    // SplitICCID 按 ICCID 长度拆分为双列存储值
    // 19 位卡:返回 (iccid, nil)
    // 20 位卡:返回 (iccid[:19], &iccid)
    // 其他长度:返回 ("", nil),调用方应将对应卡标记为失败
    func SplitICCID(iccid string) (iccid19 string, iccid20 *string)
    

    禁止返回空字符串 "" 作为 iccid20ICCID20 字段类型为 *string19 位卡必须返回 nilGORM 才会写入 NULL。

  • 3.2 更新 pkg/validator/iccid.goValidateICCID 函数增加长度校验ICCID 长度必须为 19 或 20 位,否则返回 ICCIDValidationResult{Valid: false, Message: "ICCID 长度必须为19或20位"};防止非法长度 ICCID 通过导入写入数据库后永远 miss

4. IotCard 写入路径适配

  • 4.1 在 internal/task/iot_card_import.goprocessBatch()IotCard 初始化时调用 utils.SplitICCID 赋值 ICCID19ICCID20 字段(此步骤在 task 3.2 的长度校验之后,理论上不会出现异常长度,但防御性保留:若 iccid19 == "",标记 fail 并跳过,不写入数据库)
  • 4.2 使用 PostgreSQL MCP 验证导入后新记录的 iccid_19iccid_20 字段已正确写入

5. IotCardStore 精确查询方法适配

  • 5.1 改造 GetByICCID(ctx, iccid):按 iccid 长度路由到 iccid_19iccid_20 列查询;异常长度记录 Error 日志Miss 记录 Warn 日志
  • 5.2 改造 GetByICCIDs(ctx, iccids):按长度分组,分别查询 iccid_19 IN ?iccid_20 IN ?,合并去重结果
  • 5.3 改造 ExistsByICCID(ctx, iccid):按长度路由到对应列
  • 5.4 改造 ExistsByICCIDBatch(ctx, iccids)按长度分组查询19 位组 Pluck("iccid_19", &list19)20 位组 Pluck("iccid_20", &list20)),合并结果;因各分组内 Pluck 返回值恰好等于原始 ICCID19 位组:iccid_19 == 原始 ICCID20 位组:iccid_20 == 原始 ICCID),直接以 Pluck 值为 map key调用方 existingMap[card.ICCID] 逻辑无需修改
  • 5.5 运行 lsp_diagnostics 确认 iot_card_store.go 无编译错误

6. DeviceSimBindingStore 查询方法适配

  • 6.1 改造 UpdateIsCurrentByDeviceID 内部子查询:按 currentIccid 长度路由到 iccid_19iccid_20 列查询 iot_card_idMiss 时记录 Warn 日志(日志字段:icciddevice_idcolumn_used,该方法持有 deviceID 参数可直接记录),不更新数据,不降级
  • 6.2 改造 GetBoundICCIDs:按传入 iccids 长度分组,分别执行两次 JOIN 查询后在应用层合并:
    • 19 位组:JOIN tb_iot_card c ON c.id = b.iot_card_id WHERE c.iccid_19 IN ? AND b.bind_status = 1 AND c.deleted_at IS NULLSELECT c.iccid_19 AS iccid
    • 20 位组:JOIN tb_iot_card c ON c.id = b.iot_card_id WHERE c.iccid_20 IN ? AND b.bind_status = 1 AND c.deleted_at IS NULLSELECT c.iccid_20 AS iccid
    • 两组结果合并为 map[string]boolkey 使用各组 SELECT 的 iccid 值(即原始 ICCID
  • 6.3 运行 lsp_diagnostics 确认 device_sim_binding_store.go 无编译错误

7. PersonalCustomerICCIDStore 查询方法适配

  • 7.1 改造 GetByICCID(ctx, iccid):按 iccid 长度路由19 位查 iccid_1920 位查原 iccid
  • 7.2 改造 GetByCustomerAndICCID(ctx, customerID, iccid):同上,按长度路由
  • 7.3 改造 CreateOrUpdateLastUsed(ctx, customerID, iccid):写入时同步赋值 ICCID19 字段
  • 7.3.5 改造 ExistsByCustomerAndICCID(ctx, customerID, iccid):按 iccid 长度路由19 位查 iccid_1920 位查原 iccid 列;路由策略与 GetByCustomerAndICCID 保持一致Miss 时直接返回 false,不降级
  • 7.4 运行 lsp_diagnostics 确认 personal_customer_iccid_store.go 无编译错误

8. 架构违规修复

  • 8.1 在 internal/service/enterprise_card/service.goService struct 中新增 iotCardStore *postgres.IotCardStore 字段,并更新 New() 函数签名增加 iotCardStore *postgres.IotCardStore 参数
  • 8.2 更新 internal/bootstrap/services.go 第 191 行 enterpriseCardSvc.New() 调用,传入 s.IotCardIotCardStore 实例)
  • 8.3 将 enterprise_card/service.go 中两处直接拼写 WHERE iccid IN ? 的内联 SQL第 46 行、第 185 行)重构为调用 s.iotCardStore.GetByICCIDs()
  • 8.4 运行 lsp_diagnostics 确认 enterprise_card/service.gobootstrap/services.go 无编译错误

9. 整体验证

  • 9.1 执行 go build ./... 确认全量编译通过
  • 9.2 使用 PostgreSQL MCP 模拟上游场景验证:上游传入 19 位 ICCID确认正确命中 19 位卡的 is_current 更新
  • 9.3 使用 PostgreSQL MCP 模拟上游场景验证:上游传入 20 位 ICCID确认正确命中 20 位卡的 is_current 更新
  • 9.4 确认日志中 Miss 场景正确输出 Warn 日志(可通过修改测试数据触发)