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