## 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 无。所有关键决策已在探索阶段与用户确认。