## 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

（无）
