All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m47s
187 lines
9.2 KiB
Markdown
187 lines
9.2 KiB
Markdown
## 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
|
||
|
||
(无)
|