- tb_iot_card 新增 device_virtual_no 字段,存储绑定设备的虚拟号快照 - 列表/详情接口响应新增 device_virtual_no 字段 - 列表接口 is_standalone 改为可选参数(不传返回全部卡) - 移除列表接口 virtual_no 查询参数 - 绑卡/解绑时同步更新 device_virtual_no 快照 - 设备导入时批量写入 device_virtual_no 快照 - 归档 feat-iot-card-device-virtual-no 变更提案 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
5.0 KiB
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 新增:
// 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
- 执行新迁移:
ALTER TABLE tb_iot_card ADD COLUMN device_virtual_no VARCHAR(100) NOT NULL DEFAULT '' - 回填数据:
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; - 部署新代码(BindCard / UnbindCard / device_import 均写入快照)
- 回滚:删除列即可(
ALTER TABLE tb_iot_card DROP COLUMN device_virtual_no),代码回滚到旧版本
Open Questions
无。所有关键决策已在探索阶段与用户确认。