Files
junhong_cmp_fiber/openspec/changes/archive/2026-04-07-asset-identifier-standardization/design.md
huang 80c6f6c756
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m21s
feat: 资产标识符标准化、资产历史订单查询及导入虚拟号强制验证
主要变更:
- 新增 AssetIdentifier 模型及 Store,统一管理资产标识符(ICCID/IMEI/SN 等)
- 新增迁移:asset_identifier 表、order 表新增 asset_identifier 字段、iot_card.virtual_no NOT NULL 约束
- 资产 Handler/Service/Route 全面重构,支持标识符路由查询与解析
- 新增资产历史订单查询接口,支持跨设备/卡/钱包维度的订单聚合
- 设备与物联卡导入任务强制校验虚拟号,缺失时直接拒绝
- Excel 工具函数优化,前端导入指引文档同步更新
- 归档三个 OpenSpec 提案:asset-identifier-standardization、asset-historical-orders、import-mandatory-virtual-no
- 更新 OpenAPI 文档及相关 DTO

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-07 17:39:36 +08:00

7.9 KiB
Raw Blame History

Context

当前系统中资产IoT 卡和设备)的操作接口混用两套参数规范:

  • B 端大多数接口:使用数据库主键 ID/assets/:asset_type/:id//devices/:id/),前端持有无业务含义的整数 ID
  • 部分接口:使用业务标识符(/assets/card/:iccid/stop/devices/by-identifier/:identifier/wifi),但命名不一致
  • C 端接口:已规范使用 identifier query 参数ICCID/VirtualNo

主要问题:

  1. IoT 卡和设备各自表内有 VirtualNo 唯一索引,但跨表没有全局唯一约束,理论上可写入相同虚拟号
  2. 订单创建接口传入 iot_card_id/device_id 主键,响应也只返回 ID不含标识符
  3. purchase_validation 未校验绑定设备的卡,允许对非独立卡(is_standalone=false)单独购买套餐
  4. Resolve 服务对两张表做 OR 多字段查询,性能随数据量增长有退化风险

Goals / Non-Goals

Goals:

  • 建立全局资产标识符注册表(tb_asset_identifier),数据库层保证 VirtualNo/ICCID 跨表全局唯一
  • B 端所有资产操作接口统一使用 :identifier 路径参数ICCID 或 VirtualNo
  • Resolve 主查询路径改走注册表,降低查询复杂度
  • 订单接口改用 identifier 传参,响应补充标识符快照字段
  • 修复绑定设备的卡可单独购买套餐的 Bug

Non-Goals:

  • IoT 卡导入强制 VirtualNo属于提案三独立推进
  • 资产往期订单查询接口(属于提案二,独立推进)
  • C 端接口变更C 端已规范,本次不动)
  • Resolve 接口的 IMEI/SN/MSISDN fallback 查询(保留现有逻辑)

Decisions

决策 1使用独立注册表tb_asset_identifier实现跨表全局唯一

选择:新增 tb_asset_identifier 表,identifier 字段加 UNIQUE 约束

备选方案

  • 应用层检查:插入前先查两张表,存在则拒绝。问题:高并发下存在竞态窗口,同一标识符可能被两个并发请求同时通过检查
  • 数据库触发器:在 tb_devicetb_iot_card 上建触发器查对方表。问题:触发器调试困难,项目中无此先例,违背"逻辑在代码层"原则

理由:注册表方案通过数据库 UNIQUE 约束消除竞态——两个并发写入同一标识符时,数据库只允许一个成功,另一个收到约束冲突错误并回滚事务,安全可靠且符合项目"禁止外键,关联在代码层维护"的原则。

表结构

CREATE TABLE tb_asset_identifier (
    id          BIGSERIAL PRIMARY KEY,
    identifier  VARCHAR(100) NOT NULL,
    asset_type  VARCHAR(20)  NOT NULL,  -- 'iot_card' | 'device'
    asset_id    BIGINT       NOT NULL,
    created_at  TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
    CONSTRAINT uq_asset_identifier UNIQUE (identifier)
);
CREATE INDEX idx_asset_identifier_asset ON tb_asset_identifier(asset_type, asset_id);

写入时机

  • 创建设备/IoT 卡时,在同一事务内写入注册表
  • 软删除时,同步从注册表删除(或标记 deleted_at避免已删除的标识符阻塞复用
  • VirtualNo 更新时(目前无此场景,预留),先删旧记录再插新记录

决策 2Resolve 主路径改走注册表,保留 fallback

选择Resolve 方法优先查 tb_asset_identifier(精确匹配),未命中再 fallback 到原有跨表 OR 查询(处理 IMEI/SN/MSISDN 等非注册标识符)

理由:注册表只存 VirtualNo 和 ICCID全局唯一的标识符IMEI/SN/MSISDN 因非唯一不写入注册表。Resolve 接口仍需支持这些模糊标识符(方便管理员查找),但精确路径走注册表可大幅提升性能。

Resolve(identifier) 逻辑:
  1. 查 tb_asset_identifier WHERE identifier = ?
     → 命中:得到 asset_type + asset_id按 type 查完整记录
     → 未命中fallback 到原有逻辑OR 多字段查 device/iot_card

决策 3B 端接口统一 :identifier不区分 card/device 类型

选择:路由改为 /admin/assets/:identifier/*,系统内部通过 Resolve 得到 asset_type无需路径中显式传类型

备选方案

  • 保留 :asset_type 前缀/admin/assets/card/:iccid/packages。问题:调用方需要知道"这是卡还是设备"才能构造 URL而 identifier 本身已能唯一定位
  • 两段式 Resolve + ID:前端先调 Resolve 拿 ID 再操作。问题:前端耦合两个接口,且 ID 无业务含义

理由identifier 已能唯一定位资产通过注册表asset_type 冗余URL 语义更自然(/assets/ICCID-001/packages 而非 /assets/card/ICCID-001/packages)。

注意仅操作类接口packages/realtime/stop/start 等)使用统一 :identifier。设备绑卡管理(/devices/:virtual_no/cards)因属于设备专属操作,路径保留 devices 前缀但改用 VirtualNo。

决策 4订单创建改用 identifier系统内部解析 asset_type

选择CreateOrderRequest 去掉 iot_card_id/device_id,改为 identifier 字段,order_type 字段同时废弃(由 identifier 解析结果决定)

理由:与接口统一规范一致;同时自然修复"绑定设备的卡可单独购买"Bug——解析 identifier 后得到 is_standalone=false 的卡,直接在 purchase_validation 层拦截。

Order 模型快照:订单记录中增加 asset_identifier VARCHAR(100) 字段,存储下单时资产的标识符快照(类似 order_no 的快照思路),便于历史查询而不依赖关联查询。

Risks / Trade-offs

风险 缓解措施
Breaking 变更B 端所有资产路由改变,前端需全量更新 系统处于测试阶段,前端可同步切换;通过 OpenAPI 文档明确新路由
存量资产未在注册表:现有 tb_device 和 tb_iot_card 数据需回填注册表 提供数据迁移脚本,回填时检查 VirtualNo 跨表唯一性;直接清库(测试阶段)则无此问题
注册表成为热点:资产创建时额外写一张表 注册表操作在事务内,单次 INSERT正常导入批量处理影响极小
Resolve fallback 性能IMEI/SN 仍走全表 OR 查询 fallback 路径为次要场景,精确路径(注册表)已涵盖主要查询;长期可为 IMEI/SN 建索引
order_type 废弃兼容:历史订单含 order_type 字段 历史数据保留,新创建订单由系统根据 identifier 填入 order_typeAPI 不再接受 order_type 输入

Migration Plan

步骤一:数据库迁移

  1. 创建迁移文件,建立 tb_asset_identifier
  2. 执行回填脚本:将所有现有设备的 VirtualNo 和 IoT 卡的 ICCID/VirtualNo 写入注册表
  3. 测试阶段:直接清库则跳过步骤 2

步骤二:后端实现(可分 PR 推进)

  1. 新增 AssetIdentifier 模型和 asset_identifier_store
  2. 改造 Resolve 服务,注册表主路径 + fallback
  3. 更新 purchase_validation,增加 is_standalone 校验
  4. 更新 Order 模型,增加 asset_identifier 快照字段
  5. 重写 B 端路由:asset.godevice.goiot_card.goorder.go
  6. 更新 DTOasset_dto.goorder_dto.goclient_order_dto.go
  7. 更新 Handler 层参数解析
  8. 更新 gendocs 文档生成

步骤三:验证

  1. 全量 API 回归测试
  2. 并发写入同一 VirtualNo 压测(验证注册表唯一约束)
  3. Resolve fallback 路径验证IMEI/SN 查询)

回滚方案保留旧路由映射别名路由72 小时,确认前端切换完毕后删除

Open Questions

  • 已解决VirtualNo 跨表全局唯一策略(独立注册表)
  • 已解决B 端路由统一方案(方案 B统一 :identifier
  • 已解决订单传参方式identifier不传 asset_type
  • 待确认Order 模型中 asset_identifier 快照字段的数据库类型选 VARCHAR(100) 是否满足所有标识符长度ICCID 最长 20 位VirtualNo 最长 100 位)