All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m21s
主要变更: - 新增 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>
132 lines
7.9 KiB
Markdown
132 lines
7.9 KiB
Markdown
## 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 更新时(目前无此场景,预留),先删旧记录再插新记录
|
||
|
||
### 决策 2:Resolve 主路径改走注册表,保留 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)
|
||
```
|
||
|
||
### 决策 3:B 端接口统一 :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_type;API 不再接受 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 位)
|