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

132 lines
7.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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_device``tb_iot_card` 上建触发器查对方表。问题:触发器调试困难,项目中无此先例,违背"逻辑在代码层"原则
**理由**:注册表方案通过数据库 UNIQUE 约束消除竞态——两个并发写入同一标识符时,数据库只允许一个成功,另一个收到约束冲突错误并回滚事务,安全可靠且符合项目"禁止外键,关联在代码层维护"的原则。
**表结构**
```sql
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.go``device.go``iot_card.go``order.go`
6. 更新 DTO`asset_dto.go``order_dto.go``client_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 位)