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

187 lines
9.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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 危险得多,且不可观测**。
---
### 决策 3Partial 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_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` 范式一致。)
```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
(无)