## Context

当前 `tb_iot_card` 表有 `is_standalone` 物化列（由 DB 触发器维护），`ListStandalone` 查询硬编码 `WHERE is_standalone = true`，导致绑定设备的卡不可见。设备表 `tb_device` 有 `virtual_no` 字段，设备与卡的绑定关系存于 `tb_device_sim_binding`。

`is_standalone` 的引入背景是：30M 行表上 `NOT EXISTS` 子查询造成 9s+ 延迟，物化为布尔列后降至 <500ms。本次新增 `device_virtual_no` 采用相同的物化快照思路，避免 JOIN `tb_device_sim_binding` + `tb_device` 带来的性能回退。

## Goals / Non-Goals

**Goals:**
- 列表/详情接口展示绑定设备的 IoT 卡
- 列表/详情响应新增 `device_virtual_no` 字段
- `is_standalone` 作为可选查询参数（不传返回全部卡）
- 移除列表接口的 `virtual_no` 查询参数
- 绑定/解绑/设备导入时同步维护 `device_virtual_no` 快照

**Non-Goals:**
- 修改路由名称（保持 `/api/admin/iot-cards/standalone`）
- 改变已绑定设备的卡不可单独分配/回收的限制
- 支持通过设备虚拟号搜索卡（`device_virtual_no` 不作为搜索条件）
- 使用 DB 触发器维护 `device_virtual_no`（应用层维护）

## Decisions

### 决策 1：快照字段存在 `tb_iot_card`，由应用层维护

**选择：** 在 `tb_iot_card` 新增 `device_virtual_no VARCHAR(100) NOT NULL DEFAULT ''`，应用层在 BindCard / UnbindCard / device_import 时写入/清空。

**备选方案：**
- **DB 触发器**：`is_standalone` 使用触发器因为纯粹基于 `tb_device_sim_binding` 状态，`device_virtual_no` 需要跨表读 `tb_device.virtual_no`，触发器可以做但调试难度高、出错难追溯。
- **每次 JOIN 查询**：不引入新列，查询时 JOIN。百万级数据下分页列表性能不可接受，违背 `is_standalone` 的设计初衷。

**结论：** 应用层快照，写入路径明确（3 处），可读性和可维护性优于触发器。

### 决策 2：`is_standalone` 改为可选过滤参数，Store 层支持

**选择：** `ListStandaloneIotCardRequest` 新增 `IsStandalone *bool`，Store 层 `ListStandalone` 仅在非 nil 时加条件，不传则不过滤。

**理由：** 最小改动，前端可按需传 `is_standalone=true` 保留原有"只看独立卡"场景。

### 决策 3：写入时机覆盖全部 3 个入口

| 入口 | 文件 | 操作 |
|------|------|------|
| `BindCard` | `internal/service/device/binding.go` | 事务外：`UPDATE tb_iot_card SET device_virtual_no = ? WHERE id = ?` |
| `UnbindCard` | `internal/service/device/binding.go` | 事务外：`UPDATE tb_iot_card SET device_virtual_no = '' WHERE id = ?` |
| 设备导入 | `internal/task/device_import.go` | 事务内：批量 `UPDATE tb_iot_card SET device_virtual_no = ? WHERE id IN (?)` |

**说明：** BindCard / UnbindCard 中更新卡字段无需在事务内（绑定记录已提交），失败仅影响展示字段，不影响业务一致性；设备导入在事务内批量更新效率更高。

### 决策 4：Store 层新增 `UpdateDeviceVirtualNo` 方法

在 `IotCardStore` 新增：
```go
// UpdateDeviceVirtualNo 更新卡的设备虚拟号快照
func (s *IotCardStore) UpdateDeviceVirtualNo(ctx context.Context, cardID uint, deviceVirtualNo string) error
// BatchUpdateDeviceVirtualNo 批量更新卡的设备虚拟号快照（设备导入时使用）
func (s *IotCardStore) BatchUpdateDeviceVirtualNo(ctx context.Context, cardIDs []uint, deviceVirtualNo string) error
```

Device Service 需要注入 `IotCardStore`（目前已有），通过该方法更新，不直接操作 GORM。

## Risks / Trade-offs

**[快照不一致]** 极端情况下（写 BindCard 成功但更新 `device_virtual_no` 失败）会出现短暂不一致。  
→ 缓解：日志记录失败，不 panic；最终一致性由运维工具或重新触发绑定修复。实际概率极低（同一进程内两次 DB 写入）。

**[历史数据回填]** 迁移 SQL 执行期间可能短暂锁表（大表 UPDATE）。  
→ 缓解：使用 `UPDATE ... WHERE device_virtual_no = ''` 分批或一次性（数据量视实际情况），低峰期执行。

**[Device Service 依赖 IotCardStore]** device binding.go 需要调用 IotCardStore 方法，形成跨 Service 包的 Store 依赖。  
→ 接受：当前 `device.Service` 已注入 `iotCardStore`（用于绑定验证），添加新方法调用不引入新依赖。

## Migration Plan

1. 执行新迁移：`ALTER TABLE tb_iot_card ADD COLUMN device_virtual_no VARCHAR(100) NOT NULL DEFAULT ''`
2. 回填数据：
   ```sql
   UPDATE tb_iot_card c
   SET device_virtual_no = d.virtual_no
   FROM tb_device_sim_binding b
   JOIN tb_device d ON d.id = b.device_id
   WHERE b.iot_card_id = c.id
     AND b.bind_status = 1
     AND b.deleted_at IS NULL
     AND d.deleted_at IS NULL;
   ```
3. 部署新代码（BindCard / UnbindCard / device_import 均写入快照）
4. 回滚：删除列即可（`ALTER TABLE tb_iot_card DROP COLUMN device_virtual_no`），代码回滚到旧版本

## Open Questions

无。所有关键决策已在探索阶段与用户确认。
