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

9.2 KiB
Raw Blame History

Context

tb_iot_card.iccid 是 varchar(20) 的全局唯一字段,所有精确查询均基于它。物联网平台上有两类运营商:

  • 19 位运营商如中国电信ICCID 本身为 19 位,是唯一标识符
  • 20 位运营商如移动部分号段ICCID 需要完整 20 位才唯一,第 20 位是真实业务数据而非 Luhn 校验码

上游 IoT 平台返回的 ICCID 格式不固定:同一张卡,不同时候可能传回 19 位或 20 位。当前代码使用 WHERE iccid = ? 精确匹配,格式不一致时静默失败(iotCardID == 0,函数返回 nil导致 is_current 状态更新无效。这是不可观测的数据错乱。

Goals / Non-Goals

Goals:

  • 无论上游传入 19 位还是 20 位,均能正确命中对应卡记录
  • 查询性能不降级:每次查询仍走单列精确索引,不引入模糊查询或 OR 条件
  • Miss 可观测:上游格式与存储不一致时,记录 Warn 日志暴露数据质量问题
  • 覆盖 tb_personal_customer_iccid 的同类问题

Non-Goals:

  • 不修复上游数据质量问题(这是运营侧职责)
  • 不实现跨列降级匹配19 位 Miss 不去查 20 位列,防止错误命中)
  • 不修改 ICCID 的展示逻辑和 Response DTO
  • 不影响模糊查询LIKE和范围查询>= / <=),这类查询语义上不依赖唯一性
  • 不改造个人客户发起的 ICCID 查询路径(client_auth/service.goexchange/service.goasset/service.go 中的 OR iccid = ? 条件):这三处查询的 ICCID 来自个人客户手动输入(扫码/手动填写),输入格式与数据库存储格式一致,不存在上游平台的 19/20 位不一致问题其架构违规Service 层直接写 SQL留待专项重构处理

Decisions

决策 1双列存储iccid_19 / iccid_20而非前缀 LIKE

选项 A选定:新增 iccid_19 varchar(19)iccid_20 varchar(20),分别建 Partial Index。查询时按上游传入长度路由。

选项 B否决:保留原列,所有精确查询改用 iccid LIKE 'prefix%'

否决原因:

  • PostgreSQL 在非 C locale 下 B-tree 索引不支持 LIKE需要额外 varchar_pattern_ops 索引
  • LIKE 仍是范围扫描,效率低于等值查找
  • 不解决 20 位运营商 19 位前缀不唯一的根本问题(可能错误命中另一张卡)

选项 C否决:全量标准化为 19 位存储。

否决原因:部分运营商的 19 位前缀不唯一,截断 20 位 ICCID 会造成数据冲突,存量迁移存在数据丢失风险。


决策 2按长度路由禁止跨列降级

len(upstream_iccid) == 19  →  WHERE iccid_19 = ?
len(upstream_iccid) == 20  →  WHERE iccid_20 = ?
其他长度                    →  拒绝,记录 Error 日志
Miss 时                    →  记录 Warn 日志,不更新数据,不降级查另一列

禁止降级的原因:若 20 位上游 miss 后降级查 iccid_19前 19 位),可能命中另一个运营商的 19 位卡,造成 is_current 被更新到错误卡上——错误命中比 Miss 危险得多,且不可观测


决策 3Partial Index 而非全量索引

-- iccid_19所有卡都有值过滤软删除
CREATE INDEX idx_iot_card_iccid_19 ON tb_iot_card (iccid_19) WHERE deleted_at IS NULL;

-- iccid_20仅 20 位卡有值,额外过滤 NULL
CREATE INDEX idx_iot_card_iccid_20 ON tb_iot_card (iccid_20)
    WHERE deleted_at IS NULL AND iccid_20 IS NOT NULL;

Partial Index 体积更小(排除软删除记录和 NULL 记录),查询效率更高。


决策 3.5iccid_20 模型字段必须使用指针类型 *string

Go 中 string 类型的零值是 ""空字符串。GORM 不会将空字符串自动映射为 SQL NULL,写入结果是 '',导致:

  1. Partial Index WHERE iccid_20 IS NOT NULL 会把所有 19 位卡行纳入索引,体积膨胀,优化失效
  2. 任何 WHERE iccid_20 = ? 查询不受影响('' != 任何合法 ICCID),但 miss 率虚高,触发大量误 Warn 告警

正确声明

ICCID20 *string `gorm:"column:iccid_20;type:varchar(20);comment:完整20位ICCID(仅20位运营商卡有值)"`

SplitICCID 工具函数签名须对应:

func SplitICCID(iccid string) (iccid19 string, iccid20 *string)
// 19 位:返回 (iccid, nil)
// 20 位:返回 (iccid[:19], &iccid)
// 其他:返回 ("", nil)

决策 4写入路径在 Task 层赋值,不修改 Store 层

IotCard 记录只有一个创建入口(iot_card_import.goprocessBatch())。在 IotCard 初始化时计算并赋值 ICCID19 / ICCID20Store 层的 Create/CreateBatch 无需感知新字段GORM 自动处理)。


决策 5.5GetBoundICCIDs 混合长度路由实现方案

当调用方传入 iccids 混合 19 位和 20 位时,禁止用 OR 条件(c.iccid_19 IN ? OR c.iccid_20 IN ?),因为 OR 会导致全表扫描,无法走 Partial Index。

采用两次独立查询 + 应用层合并

func (s *DeviceSimBindingStore) GetBoundICCIDs(ctx context.Context, iccids []string) (map[string]bool, error) {
    var list19, list20 []string
    for _, id := range iccids {
        if len(id) == 19 { list19 = append(list19, id) }
        if len(id) == 20 { list20 = append(list20, id) }
    }
    result := make(map[string]bool)
    // 19 位组查 iccid_19SELECT c.iccid_19 AS iccid
    // 20 位组查 iccid_20SELECT c.iccid_20 AS iccid
    // 合并两组结果到 result
}

同样规则适用于 IotCardStore.GetByICCIDs(分组后分别 Find合并 []*model.IotCard 并去重)。


决策 6enterprise_card/service.go 的内联 SQL 移交 Store 层

该文件有两处(第 46 行、第 185 行)WHERE iccid IN ? 内联 SQL 直接绕过了 Store 层,且同时绕过了数据权限过滤(middleware.ApplyShopFilter)。此次改造中将其重构为调用 IotCardStore.GetByICCIDs(),消除架构违规并恢复数据权限过滤。

依赖注入变更enterprise_card.Service struct 需新增 iotCardStore *postgres.IotCardStore 字段,New() 签名增加该参数,internal/bootstrap/services.go 调用处同步传入 s.IotCard

Risks / Trade-offs

[风险] 存量数据中可能存在 19/20 位冲突记录 → 迁移前先执行冲突检测 SQL若有冲突人工处理后再执行回填。

[风险] 迁移期间(已加列、未更新代码)查询仍走旧 iccid 列 → 迁移分两阶段:先上线代码(新代码查新列),再回填存量数据;或先回填数据再上线代码。推荐先回填数据再发布代码,避免窗口期数据不一致。

[Trade-off] 两次查询 vs 一次查询正常路径仍是一次精确索引查询。Miss 场景不发起第二次查询,直接记录日志,无性能影响。

[Trade-off] 存储空间:每张卡多存约 39 字节19 + 20百万卡约 39MB可接受。

Migration Plan

第一步(迁移文件)

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

-- 1. 加列(允许 NULL不阻塞写入
ALTER TABLE tb_iot_card ADD COLUMN IF NOT EXISTS iccid_19 varchar(19);
ALTER TABLE tb_iot_card ADD COLUMN IF NOT EXISTS iccid_20 varchar(20);

-- 2. 冲突检测(执行后人工确认均为 0 行,否则停止迁移人工处理)
-- 2a. 检测 19 位前缀重复19 位运营商卡的 iccid_19 必须唯一)
SELECT LEFT(iccid, 19), COUNT(*)
FROM tb_iot_card WHERE deleted_at IS NULL
GROUP BY LEFT(iccid, 19) HAVING COUNT(*) > 1;

-- 2b. 检测异常长度 ICCID长度既非 19 也非 20 的记录,回填后将永远无法通过新查询路径命中)
SELECT id, iccid, LENGTH(iccid) AS len
FROM tb_iot_card WHERE deleted_at IS NULL AND LENGTH(iccid) NOT IN (19, 20);

-- 3. 回填存量数据
UPDATE tb_iot_card SET
    iccid_19 = LEFT(iccid, 19),
    iccid_20 = CASE WHEN LENGTH(iccid) = 20 THEN iccid ELSE NULL END;

-- 4. 建索引(迁移文件中不用 CONCURRENTLY避免事务冲突
-- 生产零停机版本(手动执行):
--   CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_iot_card_iccid_19
--       ON tb_iot_card (iccid_19) WHERE deleted_at IS NULL;
--   CREATE INDEX CONCURRENTLY 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;
CREATE INDEX IF NOT EXISTS idx_iot_card_iccid_19
    ON tb_iot_card (iccid_19) WHERE deleted_at IS NULL;
CREATE 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;

tb_personal_customer_iccid 同理,仅加 iccid_19 一列该表只需要按19位匹配

第二步(代码发布):更新 Model、Store 查询方法、Task 写入逻辑。

回滚方案:新列加 NULL 约束可随时 DROP代码回滚至旧版本恢复 WHERE iccid = ? 查询即可。旧 iccid 列全程保留,不做变更。

Open Questions

(无)