## 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` 范式一致。）

- [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 写入 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` 索引语义
- [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 返回值恰好等于原始 ICCID（19 位组：`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_id；Miss 时记录 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 日志（可通过修改测试数据触发）
