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

83 lines
8.0 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.
## 1. 数据库迁移
> **注意**1.11.5 全部写在**同一个迁移文件**中,保持原子性;执行迁移后再发布代码。
>
> **索引注意**`CREATE INDEX CONCURRENTLY` 不能在事务块内执行golang-migrate 默认开启事务,迁移文件中必须使用 `CREATE INDEX IF NOT EXISTS`(不带 CONCURRENTLY。生产环境若需零停机建索引可在发布窗口期手动执行 CONCURRENTLY 版本。(与本项目 `000058_add_covering_index_for_deep_pagination.up.sql` 范式一致。)
- [x] 1.1 新建迁移文件,给 `tb_iot_card` 添加 `iccid_19 varchar(19)``iccid_20 varchar(20)` 两列(允许 NULL使用 `ADD COLUMN IF NOT EXISTS`
- [x] 1.1a 迁移文件中执行冲突检测(结果须为 0 行,否则停止迁移人工处理):
- 检测 19 位前缀重复:`SELECT LEFT(iccid, 19), COUNT(*) FROM tb_iot_card WHERE deleted_at IS NULL GROUP BY LEFT(iccid, 19) HAVING COUNT(*) > 1`
- 检测异常长度 ICCID`SELECT id, iccid, LENGTH(iccid) AS len FROM tb_iot_card WHERE deleted_at IS NULL AND LENGTH(iccid) NOT IN (19, 20)`(异常记录回填后将永远无法通过新查询路径命中)
- [x] 1.2 迁移文件中回填存量数据:`iccid_19 = LEFT(iccid, 19)``iccid_20 = iccid`(仅当 LENGTH(iccid)=20否则 NULL
- [x] 1.3 迁移文件中为 `iccid_19` 创建 Partial Index`CREATE INDEX IF NOT EXISTS idx_iot_card_iccid_19 ON tb_iot_card (iccid_19) WHERE deleted_at IS NULL`**禁止** CONCURRENTLY原因见上方注意事项
- [x] 1.4 迁移文件中为 `iccid_20` 创建 Partial Index`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`**禁止** CONCURRENTLY
- [x] 1.5 同一迁移文件中,给 `tb_personal_customer_iccid` 添加 `iccid_19 varchar(19)` 列(允许 NULL并回填数据`iccid_19 = LEFT(iccid, 19)`),创建对应 Partial Index`CREATE INDEX IF NOT EXISTS idx_personal_customer_iccid_19 ON tb_personal_customer_iccid (iccid_19) WHERE deleted_at IS NULL`
- [x] 1.6 执行迁移,验证所有卡记录的 `iccid_19``iccid_20` 已正确回填(使用 PostgreSQL MCP 查询验证)
## 2. Model 层更新
- [x] 2.1 在 `internal/model/iot_card.go``IotCard` 结构体中新增以下两个字段,补充 gorm 标签和中文注释:
- `ICCID19 string`非指针所有卡必填gorm tag 示例:`gorm:"column:iccid_19;type:varchar(19);comment:ICCID前19位"`
- `ICCID20 *string`**指针类型**19 位卡为 `nil`GORM 写入 NULL20 位卡为非 nil 指针gorm tag 示例:`gorm:"column:iccid_20;type:varchar(20);comment:完整20位ICCID(仅20位运营商卡有值)"`
- 禁止使用 `string` 类型存 `ICCID20`GORM 不会将空字符串自动转为 NULL会破坏 `WHERE iccid_20 IS NOT NULL` 索引语义
- [x] 2.2 在 `internal/model/personal_customer_iccid.go``PersonalCustomerICCID` 结构体中新增 `ICCID19` 字段,补充 gorm 标签和中文注释
- [x] 2.3 运行 `lsp_diagnostics` 确认 model 文件无编译错误
## 3. 工具函数与校验
- [x] 3.1 在 `pkg/utils/iccid.go` 中实现 `SplitICCID` 函数,签名和语义如下:
```go
// SplitICCID 按 ICCID 长度拆分为双列存储值
// 19 位卡:返回 (iccid, nil)
// 20 位卡:返回 (iccid[:19], &iccid)
// 其他长度:返回 ("", nil),调用方应将对应卡标记为失败
func SplitICCID(iccid string) (iccid19 string, iccid20 *string)
```
**禁止**返回空字符串 `""` 作为 `iccid20`ICCID20 字段类型为 `*string`19 位卡必须返回 `nil`GORM 才会写入 NULL。
- [x] 3.2 更新 `pkg/validator/iccid.go` 的 `ValidateICCID` 函数增加长度校验ICCID 长度必须为 19 或 20 位,否则返回 `ICCIDValidationResult{Valid: false, Message: "ICCID 长度必须为19或20位"}`;防止非法长度 ICCID 通过导入写入数据库后永远 miss
## 4. IotCard 写入路径适配
- [x] 4.1 在 `internal/task/iot_card_import.go` 的 `processBatch()` 中IotCard 初始化时调用 `utils.SplitICCID` 赋值 `ICCID19` 和 `ICCID20` 字段(此步骤在 task 3.2 的长度校验之后,理论上不会出现异常长度,但防御性保留:若 `iccid19 == ""`,标记 fail 并跳过,不写入数据库)
- [x] 4.2 使用 PostgreSQL MCP 验证导入后新记录的 `iccid_19` 和 `iccid_20` 字段已正确写入
## 5. IotCardStore 精确查询方法适配
- [x] 5.1 改造 `GetByICCID(ctx, iccid)`:按 iccid 长度路由到 `iccid_19` 或 `iccid_20` 列查询;异常长度记录 Error 日志Miss 记录 Warn 日志
- [x] 5.2 改造 `GetByICCIDs(ctx, iccids)`:按长度分组,分别查询 `iccid_19 IN ?` 和 `iccid_20 IN ?`,合并去重结果
- [x] 5.3 改造 `ExistsByICCID(ctx, iccid)`:按长度路由到对应列
- [x] 5.4 改造 `ExistsByICCIDBatch(ctx, iccids)`按长度分组查询19 位组 `Pluck("iccid_19", &list19)`20 位组 `Pluck("iccid_20", &list20)`),合并结果;因各分组内 Pluck 返回值恰好等于原始 ICCID19 位组:`iccid_19 == 原始 ICCID`20 位组:`iccid_20 == 原始 ICCID`),直接以 Pluck 值为 map key调用方 `existingMap[card.ICCID]` 逻辑无需修改
- [x] 5.5 运行 `lsp_diagnostics` 确认 `iot_card_store.go` 无编译错误
## 6. DeviceSimBindingStore 查询方法适配
- [x] 6.1 改造 `UpdateIsCurrentByDeviceID` 内部子查询:按 currentIccid 长度路由到 `iccid_19` 或 `iccid_20` 列查询 iot_card_idMiss 时记录 Warn 日志(日志字段:`iccid`、`device_id`、`column_used`,该方法持有 `deviceID` 参数可直接记录),不更新数据,不降级
- [x] 6.2 改造 `GetBoundICCIDs`:按传入 iccids 长度**分组**,分别执行两次 JOIN 查询后在应用层合并:
- 19 位组:`JOIN tb_iot_card c ON c.id = b.iot_card_id WHERE c.iccid_19 IN ? AND b.bind_status = 1 AND c.deleted_at IS NULL`SELECT `c.iccid_19 AS iccid`
- 20 位组:`JOIN tb_iot_card c ON c.id = b.iot_card_id WHERE c.iccid_20 IN ? AND b.bind_status = 1 AND c.deleted_at IS NULL`SELECT `c.iccid_20 AS iccid`
- 两组结果合并为 `map[string]bool`key 使用各组 SELECT 的 iccid 值(即原始 ICCID
- [x] 6.3 运行 `lsp_diagnostics` 确认 `device_sim_binding_store.go` 无编译错误
## 7. PersonalCustomerICCIDStore 查询方法适配
- [x] 7.1 改造 `GetByICCID(ctx, iccid)`:按 iccid 长度路由19 位查 `iccid_19`20 位查原 `iccid` 列
- [x] 7.2 改造 `GetByCustomerAndICCID(ctx, customerID, iccid)`:同上,按长度路由
- [x] 7.3 改造 `CreateOrUpdateLastUsed(ctx, customerID, iccid)`:写入时同步赋值 `ICCID19` 字段
- [x] 7.3.5 改造 `ExistsByCustomerAndICCID(ctx, customerID, iccid)`:按 iccid 长度路由19 位查 `iccid_19`20 位查原 `iccid` 列;路由策略与 `GetByCustomerAndICCID` 保持一致Miss 时直接返回 `false`,不降级
- [x] 7.4 运行 `lsp_diagnostics` 确认 `personal_customer_iccid_store.go` 无编译错误
## 8. 架构违规修复
- [x] 8.1 在 `internal/service/enterprise_card/service.go` 的 `Service` struct 中新增 `iotCardStore *postgres.IotCardStore` 字段,并更新 `New()` 函数签名增加 `iotCardStore *postgres.IotCardStore` 参数
- [x] 8.2 更新 `internal/bootstrap/services.go` 第 191 行 `enterpriseCardSvc.New()` 调用,传入 `s.IotCard`IotCardStore 实例)
- [x] 8.3 将 `enterprise_card/service.go` 中两处直接拼写 `WHERE iccid IN ?` 的内联 SQL第 46 行、第 185 行)重构为调用 `s.iotCardStore.GetByICCIDs()`
- [x] 8.4 运行 `lsp_diagnostics` 确认 `enterprise_card/service.go` 和 `bootstrap/services.go` 无编译错误
## 9. 整体验证
- [x] 9.1 执行 `go build ./...` 确认全量编译通过
- [x] 9.2 使用 PostgreSQL MCP 模拟上游场景验证:上游传入 19 位 ICCID确认正确命中 19 位卡的 `is_current` 更新
- [x] 9.3 使用 PostgreSQL MCP 模拟上游场景验证:上游传入 20 位 ICCID确认正确命中 20 位卡的 `is_current` 更新
- [x] 9.4 确认日志中 Miss 场景正确输出 Warn 日志(可通过修改测试数据触发)