分裂iccid长度
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m47s

This commit is contained in:
2026-04-21 10:55:12 +08:00
parent 9942bbc74e
commit e049080f6c
18 changed files with 883 additions and 60 deletions

View File

@@ -0,0 +1,186 @@
## 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
(无)