Files
junhong_cmp_fiber/openspec/changes/archive/2026-04-09-feat-iot-card-device-virtual-no/design.md
huang e9df1e7ded
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m28s
feat: IoT卡新增绑定设备虚拟号快照字段
- 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>
2026-04-09 10:50:46 +08:00

5.0 KiB
Raw Blame History

Context

当前 tb_iot_card 表有 is_standalone 物化列(由 DB 触发器维护),ListStandalone 查询硬编码 WHERE is_standalone = true,导致绑定设备的卡不可见。设备表 tb_devicevirtual_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 处),可读性和可维护性优于触发器。

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

选择: ListStandaloneIotCardRequest 新增 IsStandalone *boolStore 层 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 中更新卡字段无需在事务内(绑定记录已提交),失败仅影响展示字段,不影响业务一致性;设备导入在事务内批量更新效率更高。

决策 4Store 层新增 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

  1. 执行新迁移:ALTER TABLE tb_iot_card ADD COLUMN device_virtual_no VARCHAR(100) NOT NULL DEFAULT ''
  2. 回填数据:
    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

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