feat: 资产标识符标准化、资产历史订单查询及导入虚拟号强制验证
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>
This commit is contained in:
2026-04-07 17:39:36 +08:00
parent 7e489a19fb
commit 80c6f6c756
69 changed files with 3039 additions and 851 deletions

View File

@@ -0,0 +1,46 @@
## ADDED Requirements
### Requirement: 全局资产标识符注册表
系统 SHALL 维护一张全局唯一的资产标识符注册表(`tb_asset_identifier`),在数据库层保证 IoT 卡的 ICCID/VirtualNo 与设备的 VirtualNo 跨两张表不重复,消除并发写入竞态风险。
注册表仅存储**全局唯一标识符**ICCID、VirtualNo不存储 IMEI/SN/MSISDN 等非唯一标识符。
**表字段**
- `id`:自增主键
- `identifier`标识符值VARCHAR(100)UNIQUE 约束)
- `asset_type`:资产类型(`iot_card``device`
- `asset_id`:对应资产的主键 ID
- `created_at`:写入时间
#### Scenario: 创建设备时注册 VirtualNo
- **WHEN** 创建新设备(通过导入或 APIVirtualNo 非空
- **THEN** 系统在同一事务内向 `tb_asset_identifier` 写入一条记录identifier=VirtualNo, asset_type=device, asset_id=新设备ID
- **THEN** 若 VirtualNo 已在注册表中存在,事务回滚,返回错误"虚拟号已被占用"
#### Scenario: 创建 IoT 卡时注册 ICCID
- **WHEN** 创建新 IoT 卡通过导入ICCID 非空
- **THEN** 系统在同一事务内向注册表写入identifier=ICCID, asset_type=iot_card, asset_id=新卡ID
- **THEN** 若 ICCID 已在注册表中存在,事务回滚,返回错误"ICCID 已被占用"
#### Scenario: 创建 IoT 卡时注册 VirtualNo如有
- **WHEN** 创建新 IoT 卡时 VirtualNo 非空
- **THEN** 系统额外向注册表写入identifier=VirtualNo, asset_type=iot_card, asset_id=新卡ID
- **THEN** 若 VirtualNo 已被其他设备或卡占用,事务回滚,返回错误"虚拟号已被占用"
#### Scenario: 并发写入同一标识符
- **WHEN** 两个并发请求同时尝试注册相同的 VirtualNo如 "CARD-001"
- **THEN** 数据库 UNIQUE 约束保证只有一个写入成功;另一个收到唯一约束冲突错误,事务回滚,返回错误"虚拟号已被占用"
#### Scenario: 软删除资产时清理注册表
- **WHEN** 软删除设备或 IoT 卡
- **THEN** 系统在同一事务内删除 `tb_asset_identifier` 中对应的记录(所有 asset_id 匹配的行),允许标识符被后续资产复用
#### Scenario: 通过标识符精确查找资产
- **WHEN** 系统需要根据 identifierICCID 或 VirtualNo定位资产
- **THEN** 系统查询 `SELECT * FROM tb_asset_identifier WHERE identifier = ?`,一次查询得到 asset_type 和 asset_id
- **THEN** 再按 asset_type 查对应表(`tb_device``tb_iot_card`)取完整记录
#### Scenario: 查询不存在的标识符
- **WHEN** 查询注册表中不存在的 identifier
- **THEN** 返回空结果not found调用方可 fallback 到原有查询逻辑

View File

@@ -0,0 +1,78 @@
## ADDED Requirements
### Requirement: B 端资产操作接口统一使用标识符路径参数
B 端所有资产操作类接口 SHALL 使用资产标识符ICCID 或 VirtualNo作为路径参数废弃原有基于数据库主键 ID 的路由。系统内部通过注册表将标识符解析为资产实体Handler 层无需关心 ID。
**标识符规则**
- IoT 卡:接受 ICCID 或 VirtualNo均为全局唯一
- 设备:接受 VirtualNo全局唯一
- 不接受 IMEI/SN/MSISDN非唯一仅 Resolve 的 fallback 路径支持)
**废弃的旧路由 → 新路由映射**
| 旧路由(废弃) | 新路由 |
|---|---|
| `GET /api/admin/assets/:asset_type/:id/realtime-status` | `GET /api/admin/assets/:identifier/realtime-status` |
| `POST /api/admin/assets/:asset_type/:id/refresh` | `POST /api/admin/assets/:identifier/refresh` |
| `GET /api/admin/assets/:asset_type/:id/packages` | `GET /api/admin/assets/:identifier/packages` |
| `GET /api/admin/assets/:asset_type/:id/current-package` | `GET /api/admin/assets/:identifier/current-package` |
| `GET /api/admin/assets/:asset_type/:id/wallet` | `GET /api/admin/assets/:identifier/wallet` |
| `GET /api/admin/assets/:asset_type/:id/wallet/transactions` | `GET /api/admin/assets/:identifier/wallet/transactions` |
| `PATCH /api/admin/assets/:asset_type/:id/polling-status` | `PATCH /api/admin/assets/:identifier/polling-status` |
| `POST /api/admin/assets/device/:device_id/stop` | `POST /api/admin/assets/:identifier/stop` |
| `POST /api/admin/assets/device/:device_id/start` | `POST /api/admin/assets/:identifier/start` |
| `POST /api/admin/assets/card/:iccid/stop` | `POST /api/admin/assets/:identifier/stop`(合并) |
| `POST /api/admin/assets/card/:iccid/start` | `POST /api/admin/assets/:identifier/start`(合并) |
| `DELETE /api/admin/devices/:id` | `DELETE /api/admin/devices/:virtual_no` |
| `GET /api/admin/devices/:id/cards` | `GET /api/admin/devices/:virtual_no/cards` |
| `POST /api/admin/devices/:id/cards` | `POST /api/admin/devices/:virtual_no/cards` |
| `DELETE /api/admin/devices/:id/cards/:cardId` | `DELETE /api/admin/devices/:virtual_no/cards/:iccid` |
| `PATCH /api/admin/devices/:id/deactivate` | `PATCH /api/admin/assets/:identifier/deactivate` |
| `PATCH /api/admin/iot-cards/:id/deactivate` | `PATCH /api/admin/assets/:identifier/deactivate`(合并) |
#### Scenario: 通过 ICCID 操作 IoT 卡
- **WHEN** 管理员请求 `GET /api/admin/assets/898600XXXXXXXX/packages`
- **THEN** 系统解析 ICCID找到对应 IoT 卡,返回该卡的套餐列表
#### Scenario: 通过 VirtualNo 操作设备
- **WHEN** 管理员请求 `POST /api/admin/assets/DEV-001/stop`
- **THEN** 系统解析 VirtualNo找到对应设备执行批量停机停该设备下所有已实名卡
#### Scenario: 通过 VirtualNo 操作绑定了设备的 IoT 卡(停机)
- **WHEN** 管理员请求 `POST /api/admin/assets/CARD-001/stop`CARD-001 是 IoT 卡的 VirtualNo
- **THEN** 系统解析 VirtualNo找到 IoT 卡,执行单卡停机
#### Scenario: stop/start 接口对卡和设备行为差异
- **WHEN** identifier 解析为 IoT 卡时调用 stop
- **THEN** 执行单卡停机
- **WHEN** identifier 解析为设备时调用 stop
- **THEN** 执行设备停机(批量停机该设备下所有已实名卡)
#### Scenario: 标识符不存在
- **WHEN** 管理员请求的 `:identifier` 在注册表和 fallback 查询中均未找到对应资产
- **THEN** 返回 HTTP 404错误消息"资产不存在"
#### Scenario: 无权限操作该资产
- **WHEN** 代理用户请求的 identifier 对应的资产不属于该代理的数据权限范围
- **THEN** 返回 HTTP 403错误消息"无权限操作该资源或资源不存在"
#### Scenario: 设备绑卡管理使用设备 VirtualNo
- **WHEN** 管理员请求 `GET /api/admin/devices/DEV-001/cards`
- **THEN** 系统通过 VirtualNo 找到设备,返回该设备绑定的卡列表
#### Scenario: 设备解绑卡使用 ICCID
- **WHEN** 管理员请求 `DELETE /api/admin/devices/DEV-001/cards/898600XXXXXXXX`
- **THEN** 系统通过 VirtualNo 找到设备,通过 ICCID 找到卡,执行解绑
### Requirement: 新路由下标识符的解析性能
资产操作接口中标识符解析 SHALL 优先走注册表(单次精确查询),保证解析延迟不超过 10ms在正常数据库负载下
#### Scenario: 注册表命中路径
- **WHEN** 请求携带的 identifier 存在于 `tb_asset_identifier`
- **THEN** 系统单次查询注册表得到 asset_type 和 asset_id无需扫描 tb_device 或 tb_iot_card
#### Scenario: 注册表未命中fallback
- **WHEN** 请求携带的 identifier 不在注册表(如 IMEI 或旧数据)
- **THEN** 系统 fallback 到原有多字段 OR 查询,同样能定位资产(性能稍低,为次要路径)

View File

@@ -0,0 +1,69 @@
## MODIFIED Requirements
### Requirement: 统一资产解析入口
系统 SHALL 提供统一的资产查找接口,通过任意标识符定位卡或设备,并返回该资产的中等聚合信息。
**API 端点**: `GET /api/admin/assets/resolve/:identifier`
**查找顺序(更新后)**:
1. **主路径**:查 `tb_asset_identifier` WHERE identifier = ? → 命中则得到 asset_type + asset_id直接查对应表取完整记录
2. **Fallback 路径**(注册表未命中时):
- 先查 `tb_device`(匹配 `virtual_no = ? OR imei = ? OR sn = ?`
- 未命中则查 `tb_iot_card`(匹配 `virtual_no = ? OR iccid = ? OR msisdn = ?`
3. 两条路径均未命中 → 返回 HTTP 404
**数据权限规则**:
- 代理用户:只能查看 `shop_id` 在自己及下级店铺范围内的资产
- 平台用户SuperAdmin/Platform可查看所有资产
- 企业账号:暂不支持此接口,调用时返回 HTTP 403
**响应结构AssetResolveResponse**:
*通用字段device 和 card 均有)*
- `asset_type`: 资产类型(`"device"``"card"`
- `asset_id`: 资产主键 ID
- `identifier`: 本次查询所用的标识符(原样回传)
- `virtual_no`: 虚拟号(设备/卡均使用此字段)
- `status`: 资产状态(整型)
- `asset_status`: 业务状态1-在库 2-已销售 3-已换货 4-已停用)
- `generation`: 资产世代编号
- `batch_no`: 批次号
- `shop_id`: 所属店铺 ID平台库存时为空
- `shop_name`: 所属店铺名称
- `series_id`: 套餐系列 ID未绑定时为空
- `series_name`: 套餐系列名称
- `first_commission_paid`: 一次性佣金是否已发放
- `accumulated_recharge`: 累计充值金额(分)
- `activated_at`: 激活时间(未激活时为空)
- `created_at`: 创建时间
- `updated_at`: 更新时间
*状态与套餐字段device 和 card 均有)*
- `real_name_status`: 实名状态(整型)
- `current_package`: 当前套餐名称(无套餐时返回空字符串)
- `package_total_mb``package_used_mb``package_remain_mb`: 套餐流量信息
- `device_protect_status`: 保护期状态
*绑定关系字段*
- `iccid`: 仅 card 类型时有值
- `bound_device_id``bound_device_no``bound_device_name`: 仅 card 类型且绑定设备时有值
- `bound_card_count``cards`: 仅 device 类型时有值
#### Scenario: 通过注册表主路径精确解析
- **WHEN** 管理员输入 identifier 为已存在于 `tb_asset_identifier` 的 VirtualNo 或 ICCID
- **THEN** 系统单次查询注册表命中,直接查对应表返回完整资产信息,响应时间 < 50ms
#### Scenario: Fallback 路径解析 IMEI
- **WHEN** 管理员输入 identifier 为设备 IMEI不在注册表中
- **THEN** 注册表未命中,系统 fallback 查 tb_device 的 imei 字段,找到后返回资产信息
- **THEN** 响应中 `identifier` 字段原样回传该 IMEI 值
#### Scenario: Fallback 路径解析 MSISDN
- **WHEN** 管理员输入 identifier 为 IoT 卡的手机号MSISDN
- **THEN** 注册表未命中fallback 查 tb_iot_card 的 msisdn 字段
- **THEN** 若存在多张卡的 MSISDN 相同返回第一条匹配记录MSISDN 非唯一,存在歧义,记录 warn 日志)
#### Scenario: 标识符完全不存在
- **WHEN** 管理员输入的 identifier 在注册表和 fallback 均未找到
- **THEN** 返回 HTTP 404错误消息"资产不存在"

View File

@@ -0,0 +1,35 @@
## MODIFIED Requirements
### Requirement: 客户端创建套餐购买订单
系统 SHALL 允许个人客户为其资产创建套餐购买订单。**[BREAKING]** 请求体改为使用资产标识符identifier废弃 `iot_card_id`/`device_id` 主键字段和 `order_type` 字段。
**客户端订单创建请求CreateOrderRequest**
```json
{
"identifier": "string资产标识符ICCID 或 VirtualNo必填1-50字符",
"package_ids": "[uint](套餐 ID 列表必填1-10 个)",
"payment_method": "stringwallet|wechat|alipay必填"
}
```
**废弃字段**
- `iot_card_id`(原单卡购买时必填)
- `device_id`(原设备购买时必填)
- `order_type`(改为系统根据 identifier 解析结果自动填入)
#### Scenario: 个人客户使用 VirtualNo 购买设备套餐
- **WHEN** 个人客户发送 `{ identifier: "DEV-001", package_ids: [5], payment_method: "wechat" }`
- **THEN** 系统解析 identifier 为设备,创建设备购买订单,微信支付流程正常触发
#### Scenario: 个人客户使用 ICCID 购买单卡套餐
- **WHEN** 个人客户发送 `{ identifier: "898600XXXXX", package_ids: [3], payment_method: "wallet" }`
- **THEN** 系统解析为独立 IoT 卡(`is_standalone = true`),创建单卡购买订单
#### Scenario: 个人客户尝试为绑定设备的卡购买套餐
- **WHEN** 个人客户发送 identifier 对应一张 `is_standalone = false` 的卡
- **THEN** 系统返回错误"该卡已绑定设备,请前往设备页面购买套餐"
#### Scenario: 个人客户只能操作自己绑定的资产
- **WHEN** 个人客户发送的 identifier 对应的资产不属于该客户
- **THEN** 系统返回 HTTP 403"无权限操作该资源"

View File

@@ -0,0 +1,66 @@
## MODIFIED Requirements
### Requirement: 创建套餐购买订单
系统 SHALL 允许买家创建套餐购买订单。**[BREAKING]** 请求体改为使用资产标识符identifier废弃 `iot_card_id`/`device_id` 主键字段;`order_type` 字段由系统根据 identifier 解析结果自动填入,请求体不再接受。
**后台订单创建请求CreateAdminOrderRequest**
```json
{
"identifier": "string资产标识符ICCID 或 VirtualNo必填",
"package_ids": "[uint](套餐 ID 列表必填1-10 个)",
"payment_method": "stringwallet|offline必填"
}
```
**废弃字段**
- `iot_card_id`(原用于单卡购买)
- `device_id`(原用于设备购买)
- `order_type`(改为系统自动推断)
#### Scenario: 使用 VirtualNo 创建设备订单
- **WHEN** 管理员请求体携带 `{ identifier: "DEV-001", package_ids: [5], payment_method: "wallet" }`
- **THEN** 系统解析 identifier 为设备,自动设置 `order_type = device`,创建设备购买订单
#### Scenario: 使用 ICCID 创建单卡订单
- **WHEN** 管理员请求体携带 `{ identifier: "898600XXXXX", package_ids: [5], payment_method: "wallet" }`
- **THEN** 系统解析 identifier 为独立 IoT 卡(`is_standalone = true`),自动设置 `order_type = single_card`,创建单卡购买订单
#### Scenario: 使用绑定设备的卡 ICCID 创建订单被拒
- **WHEN** 管理员请求体携带 `{ identifier: "898600XXXXX" }` 但该卡 `is_standalone = false`
- **THEN** 系统返回错误"该卡已绑定设备,请前往设备页面购买套餐",订单不创建
#### Scenario: identifier 不存在
- **WHEN** 请求的 identifier 无法解析到任何资产
- **THEN** 返回错误"资产不存在",订单不创建
## ADDED Requirements
### Requirement: 订单响应包含资产标识符
订单响应OrderResponseSHALL 包含资产标识符字段,前端无需额外请求即可展示资产信息。
**新增响应字段**
- `asset_identifier`下单时资产的标识符快照ICCID 或 VirtualNo
- `asset_type`:资产类型(`single_card` 对应 `iot_card``device` 对应 `device`
#### Scenario: 查看订单详情时展示资产标识符
- **WHEN** 管理员查询订单详情 `GET /api/admin/orders/:id`
- **THEN** 响应中包含 `asset_identifier`(如 "DEV-001")和 `asset_type`(如 "device"
- **THEN** 即使原资产已被删除,`asset_identifier` 仍可读(快照字段)
### Requirement: 订单列表支持按资产标识符过滤
系统 SHALL 在订单列表查询OrderListRequest中支持 `identifier` 过滤参数,按照资产标识符精确匹配订单(匹配 `asset_identifier` 快照字段)。
#### Scenario: 按 ICCID 查询该卡的历史订单
- **WHEN** 管理员查询 `GET /api/admin/orders?identifier=898600XXXXX`
- **THEN** 返回 `asset_identifier = "898600XXXXX"` 的所有订单(分页)
#### Scenario: 按设备 VirtualNo 查询该设备的历史订单
- **WHEN** 管理员查询 `GET /api/admin/orders?identifier=DEV-001`
- **THEN** 返回 `asset_identifier = "DEV-001"` 的所有订单(分页)
#### Scenario: 标识符无匹配订单
- **WHEN** 管理员查询不存在订单的 identifier
- **THEN** 返回空列表(`items: []``total: 0`),不返回错误

View File

@@ -0,0 +1,19 @@
## ADDED Requirements
### Requirement: 已绑定设备的卡不允许单独购买套餐
购买套餐验证时,系统 MUST 检查目标 IoT 卡是否为独立卡(`is_standalone = true`)。若卡已绑定设备(`is_standalone = false`),必须拒绝购买并引导用户至对应设备页面操作。
此规则适用于所有套餐购买入口C 端个人客户、B 端代理/平台)。
#### Scenario: 独立卡正常购买
- **WHEN** 买家为 IoT 卡购买套餐,该卡的 `is_standalone = true`(未绑定任何设备)
- **THEN** 验证通过,继续后续购买流程
#### Scenario: 已绑定设备的卡被单独购买
- **WHEN** 买家使用 IoT 卡的 ICCID 或 VirtualNo 购买套餐,该卡的 `is_standalone = false`
- **THEN** 系统拒绝购买,返回错误码 `CodeInvalidParam`,错误消息"该卡已绑定设备,请前往设备页面购买套餐"
#### Scenario: B 端代理通过卡标识符购买套餐(绑定了设备的卡)
- **WHEN** 代理使用已绑定设备的卡的 ICCID 创建订单
- **THEN** 系统在 `ValidateCardPurchase()` 中检查 `is_standalone`,返回错误,订单不创建