## 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.go`、`exchange/service.go`、`asset/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 危险得多,且不可观测**。 --- ### 决策 3:Partial Index 而非全量索引 ```sql -- 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.5:`iccid_20` 模型字段必须使用指针类型 `*string` Go 中 `string` 类型的零值是 `""`(空字符串)。GORM 不会将空字符串自动映射为 SQL `NULL`,写入结果是 `''`,导致: 1. Partial Index `WHERE iccid_20 IS NOT NULL` 会把所有 19 位卡行纳入索引,体积膨胀,优化失效 2. 任何 `WHERE iccid_20 = ?` 查询不受影响(`'' != 任何合法 ICCID`),但 miss 率虚高,触发大量误 Warn 告警 **正确声明**: ```go ICCID20 *string `gorm:"column:iccid_20;type:varchar(20);comment:完整20位ICCID(仅20位运营商卡有值)"` ``` `SplitICCID` 工具函数签名须对应: ```go func SplitICCID(iccid string) (iccid19 string, iccid20 *string) // 19 位:返回 (iccid, nil) // 20 位:返回 (iccid[:19], &iccid) // 其他:返回 ("", nil) ``` --- ### 决策 4:写入路径在 Task 层赋值,不修改 Store 层 IotCard 记录只有一个创建入口(`iot_card_import.go` 的 `processBatch()`)。在 IotCard 初始化时计算并赋值 `ICCID19` / `ICCID20`,Store 层的 `Create`/`CreateBatch` 无需感知新字段(GORM 自动处理)。 --- ### 决策 5.5:`GetBoundICCIDs` 混合长度路由实现方案 当调用方传入 iccids 混合 19 位和 20 位时,禁止用 OR 条件(`c.iccid_19 IN ? OR c.iccid_20 IN ?`),因为 OR 会导致全表扫描,无法走 Partial Index。 **采用两次独立查询 + 应用层合并**: ```go 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_19,SELECT c.iccid_19 AS iccid // 20 位组查 iccid_20,SELECT c.iccid_20 AS iccid // 合并两组结果到 result } ``` 同样规则适用于 `IotCardStore.GetByICCIDs`(分组后分别 Find,合并 `[]*model.IotCard` 并去重)。 --- ### 决策 6:enterprise_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` 范式一致。) ```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 (无)