feat: 资产标识符标准化、资产历史订单查询及导入虚拟号强制验证
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m21s
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:
106
openspec/specs/asset-historical-orders/spec.md
Normal file
106
openspec/specs/asset-historical-orders/spec.md
Normal file
@@ -0,0 +1,106 @@
|
||||
### Requirement: 查询资产本代历史订单
|
||||
|
||||
系统 SHALL 提供接口,让管理员查询某资产本代(当前世代)的全部历史订单,支持分页。
|
||||
|
||||
**API 端点**:`GET /api/admin/assets/:identifier/orders`
|
||||
|
||||
**请求参数**:
|
||||
- `:identifier`(路径):资产标识符(ICCID 或 VirtualNo),必填
|
||||
- `page`(query):页码,默认 1
|
||||
- `page_size`(query):每页数量,默认 20,最大 100
|
||||
- `include_previous`(query):是否包含前代订单,布尔值,默认 false
|
||||
|
||||
**权限规则**:
|
||||
- 代理用户:只能查看数据权限范围内资产的订单
|
||||
- 平台/超管:可查看所有资产订单
|
||||
- 企业账号:不支持此接口(返回 403)
|
||||
|
||||
#### Scenario: 查询本代订单(默认)
|
||||
- **WHEN** 管理员请求 `GET /api/admin/assets/DEV-001/orders`
|
||||
- **THEN** 返回该资产(当前世代)的订单列表,按创建时间倒序,支持分页
|
||||
- **THEN** 响应中每条订单包含 `generation` 字段,值为资产当前世代编号
|
||||
|
||||
#### Scenario: 资产无订单
|
||||
- **WHEN** 管理员查询一个从未购买过套餐的资产
|
||||
- **THEN** 返回 `{ items: [], total: 0, page: 1 }`,不返回错误
|
||||
|
||||
#### Scenario: identifier 不存在
|
||||
- **WHEN** 请求的 identifier 无法解析到任何资产
|
||||
- **THEN** 返回 HTTP 404,错误消息"资产不存在"
|
||||
|
||||
#### Scenario: 代理查询无权限资产
|
||||
- **WHEN** 代理用户请求不属于其数据权限范围的资产订单
|
||||
- **THEN** 返回 HTTP 403,错误消息"无权限操作该资源或资源不存在"
|
||||
|
||||
### Requirement: 查询资产跨代历史订单(含前代)
|
||||
|
||||
当 `include_previous=true` 时,系统 SHALL 通过换货链追溯前代资产,返回前代订单,并在响应中区分世代来源。
|
||||
|
||||
**追溯逻辑**:
|
||||
1. 通过 `ExchangeOrder.new_asset_id` 逆向查找当前资产的换货来源
|
||||
2. 得到前代的 `old_asset_identifier`,查询该标识符的订单
|
||||
3. 递归追溯,最多向前 10 代(安全上限)
|
||||
|
||||
**响应结构(AssetOrdersResponse)**:
|
||||
```json
|
||||
{
|
||||
"current_generation": {
|
||||
"generation": 2,
|
||||
"identifier": "DEV-001",
|
||||
"asset_type": "device",
|
||||
"total": 5,
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
"items": [ ...订单列表... ]
|
||||
},
|
||||
"previous_generations": [
|
||||
{
|
||||
"generation": 1,
|
||||
"identifier": "DEV-OLD-001",
|
||||
"asset_type": "device",
|
||||
"exchange_no": "EXC20260101XXXXXX",
|
||||
"exchanged_at": "2026-01-01T00:00:00Z",
|
||||
"total": 3,
|
||||
"items": [ ...前代订单列表(最近20条)... ]
|
||||
}
|
||||
],
|
||||
"truncated": false
|
||||
}
|
||||
```
|
||||
|
||||
**每条订单项(AssetOrderItem)包含**:
|
||||
- `order_no`:订单号
|
||||
- `order_type`:订单类型(single_card / device)
|
||||
- `payment_status`:支付状态
|
||||
- `payment_status_text`:支付状态文本
|
||||
- `total_amount`:订单金额(分)
|
||||
- `payment_method`:支付方式
|
||||
- `paid_at`:支付时间(可空)
|
||||
- `generation`:订单所属资产世代
|
||||
- `items`:套餐明细列表
|
||||
- `created_at`:订单创建时间
|
||||
|
||||
#### Scenario: 查询换货后资产的全代际订单
|
||||
- **WHEN** 管理员请求 `GET /api/admin/assets/DEV-001/orders?include_previous=true`,DEV-001 是换货后的新设备(第2代),原设备为 DEV-OLD-001(第1代)
|
||||
- **THEN** `current_generation` 包含 DEV-001 本代的订单(generation=2)
|
||||
- **THEN** `previous_generations[0]` 包含 DEV-OLD-001 的订单(generation=1),附带换货单号和换货时间
|
||||
- **THEN** `truncated=false`(未超出追溯上限)
|
||||
|
||||
#### Scenario: 资产本身就是第一代(无前代)
|
||||
- **WHEN** 管理员请求带 `include_previous=true`,但该资产从未经过换货
|
||||
- **THEN** `previous_generations` 为空数组 `[]`
|
||||
- **THEN** `current_generation` 正常返回本代订单
|
||||
|
||||
#### Scenario: 换货链超过追溯上限
|
||||
- **WHEN** 换货链深度超过 10 代
|
||||
- **THEN** 追溯在第 10 代截断,`truncated=true`
|
||||
- **THEN** 已追溯到的前代数据正常返回
|
||||
|
||||
#### Scenario: 前代订单分页
|
||||
- **WHEN** 请求带 `include_previous=true`
|
||||
- **THEN** 分页参数(page/page_size)只对 `current_generation` 的订单生效
|
||||
- **THEN** 前代订单每代最多返回 20 条(不支持前代内分页)
|
||||
|
||||
#### Scenario: 无 include_previous 时响应不含前代字段
|
||||
- **WHEN** 管理员请求不带 `include_previous=true`(或传 false)
|
||||
- **THEN** 响应结构中 `previous_generations` 字段为 null 或不返回,节省带宽
|
||||
52
openspec/specs/asset-identifier-registry/spec.md
Normal file
52
openspec/specs/asset-identifier-registry/spec.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Capability: 全局资产标识符注册表
|
||||
|
||||
## Purpose
|
||||
|
||||
维护一张全局唯一的资产标识符注册表(`tb_asset_identifier`),在数据库层保证 IoT 卡的 ICCID/VirtualNo 与设备的 VirtualNo 跨两张表不重复,消除并发写入竞态风险,同时提供高性能的标识符→资产 ID 精确查找能力。
|
||||
|
||||
## 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** 创建新设备(通过导入或 API),VirtualNo 非空
|
||||
- **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** 系统需要根据 identifier(ICCID 或 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 到原有查询逻辑
|
||||
86
openspec/specs/asset-identifier-routes/spec.md
Normal file
86
openspec/specs/asset-identifier-routes/spec.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# Capability: 资产操作路由标识符标准化
|
||||
|
||||
## Purpose
|
||||
|
||||
定义 B 端资产操作类接口统一使用资产标识符(ICCID 或 VirtualNo)作为路径参数的规范,废弃原有基于数据库主键 ID 的路由,并规定标识符解析的性能要求。
|
||||
|
||||
## 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 查询,同样能定位资产(性能稍低,为次要路径)
|
||||
@@ -12,10 +12,12 @@
|
||||
|
||||
**API 端点**: `GET /api/admin/assets/resolve/:identifier`
|
||||
|
||||
**查找顺序**:
|
||||
1. 先在 `tb_device` 表查找(匹配 `virtual_no = ? OR imei = ? OR sn = ?`)
|
||||
2. 未命中则在 `tb_iot_card` 表查找(匹配 `virtual_no = ? OR iccid = ? OR msisdn = ?`)
|
||||
3. 两表均未命中 → 返回 HTTP 404
|
||||
**查找顺序(更新后)**:
|
||||
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
|
||||
4. 找到后应用数据权限过滤,无权限 → 返回 HTTP 403
|
||||
|
||||
**数据权限规则**:
|
||||
@@ -28,8 +30,11 @@
|
||||
*通用字段(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`: 所属店铺名称
|
||||
@@ -136,3 +141,20 @@
|
||||
|
||||
- **WHEN** 管理员查询一台没有购买任何套餐的设备
|
||||
- **THEN** `current_package = ""`,`package_total_mb = 0`,`package_used_mb = 0`,`package_remain_mb = 0`
|
||||
|
||||
#### 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 日志)
|
||||
|
||||
@@ -4,17 +4,47 @@
|
||||
|
||||
### Requirement: D1 创建套餐购买订单接口
|
||||
|
||||
系统 SHALL 提供 `POST /api/c/v1/orders/create`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier`、`package_ids[]`、`app_type`。接口流程 MUST 按顺序执行:归属校验 → 套餐校验(含加油包前置)→ 实名校验 → OpenID 查询 → 幂等检查 → 强充检查 → 分流创建。实名不满足时 MUST 返回 `NEED_REALNAME`。OpenID 缺失时 MUST 返回 `OPENID_NOT_FOUND`。幂等 MUST 使用 Redis 业务键 + 分布式锁。分流规则 MUST 为:
|
||||
系统 SHALL 提供 `POST /api/c/v1/orders/create`,并且 MUST 要求个人客户认证。**[BREAKING]** 请求体改为使用资产标识符(identifier),废弃 `iot_card_id`/`device_id` 主键字段和 `order_type` 字段。请求体 MUST 包含 `identifier`、`package_ids[]`、`payment_method`。接口流程 MUST 按顺序执行:归属校验 → 套餐校验(含加油包前置)→ 实名校验 → OpenID 查询 → 幂等检查 → 强充检查 → 分流创建。实名不满足时 MUST 返回 `NEED_REALNAME`。OpenID 缺失时 MUST 返回 `OPENID_NOT_FOUND`。幂等 MUST 使用 Redis 业务键 + 分布式锁。分流规则 MUST 为:
|
||||
|
||||
- 无强充:创建套餐订单并返回 `order_type="package"`、`order`、`pay_config`
|
||||
- 需强充:创建充值单并返回 `order_type="recharge"`、`recharge`、`pay_config`、`linked_package_info`
|
||||
|
||||
**客户端订单创建请求(CreateOrderRequest)**:
|
||||
```json
|
||||
{
|
||||
"identifier": "string(资产标识符,ICCID 或 VirtualNo,必填,1-50字符)",
|
||||
"package_ids": "[uint](套餐 ID 列表,必填,1-10 个)",
|
||||
"payment_method": "string(wallet|wechat|alipay,必填)"
|
||||
}
|
||||
```
|
||||
|
||||
**废弃字段**:
|
||||
- `iot_card_id`(原单卡购买时必填)
|
||||
- `device_id`(原设备购买时必填)
|
||||
- `order_type`(改为系统根据 identifier 解析结果自动填入)
|
||||
|
||||
响应体 MUST 包含前端可直接渲染字段。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`、`NEED_REALNAME/该套餐需实名认证后购买`、`OPENID_NOT_FOUND/未找到微信授权信息,请先完成授权`、`IDEMPOTENT_CONFLICT/请求处理中,请勿重复提交`、`PACKAGE_NOT_AVAILABLE/套餐不可购买`。
|
||||
|
||||
#### Scenario: 命中强充返回 recharge 结构
|
||||
- **WHEN** 客户购买套餐触发强充要求
|
||||
- **THEN** 系统返回 `order_type="recharge"`,包含充值单与关联套餐信息
|
||||
|
||||
#### 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"无权限操作该资源"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: D2 套餐订单列表接口
|
||||
|
||||
@@ -19,21 +19,22 @@ TBD - created by archiving change add-device-management. Update Purpose after ar
|
||||
- **表头行**: 第1行,列名如下(顺序可任意):
|
||||
```
|
||||
device_no, device_name, device_model, device_type,
|
||||
max_sim_slots, manufacturer, iccid_1, iccid_2, iccid_3, iccid_4
|
||||
max_sim_slots, manufacturer, virtual_no, iccid_1, iccid_2, iccid_3, iccid_4
|
||||
```
|
||||
- **数据行**: 从第2行开始
|
||||
- **列格式**: 所有列应设置为文本格式(避免数字被转为科学记数法)
|
||||
|
||||
**示例Excel内容**:
|
||||
```
|
||||
| device_no | device_name | device_model | device_type | max_sim_slots | manufacturer | iccid_1 | iccid_2 | iccid_3 | iccid_4 |
|
||||
|-----------|--------------|--------------|-------------|---------------|--------------|----------------------|----------------------|---------|---------|
|
||||
| DEV-001 | GPS追踪器A | GT06N | GPS Tracker | 4 | Concox | 8986001234567890001 | 8986001234567890002 | | |
|
||||
| DEV-002 | GPS追踪器B | GT06N | GPS Tracker | 4 | Concox | 8986001234567890003 | | | |
|
||||
| device_no | device_name | device_model | device_type | max_sim_slots | manufacturer | virtual_no | iccid_1 | iccid_2 | iccid_3 | iccid_4 |
|
||||
|-----------|--------------|--------------|-------------|---------------|--------------|------------|----------------------|----------------------|---------|---------|
|
||||
| DEV-001 | GPS追踪器A | GT06N | GPS Tracker | 4 | Concox | VNO-001 | 8986001234567890001 | 8986001234567890002 | | |
|
||||
| DEV-002 | GPS追踪器B | GT06N | GPS Tracker | 4 | Concox | VNO-002 | 8986001234567890003 | | | |
|
||||
```
|
||||
|
||||
**字段说明**:
|
||||
- `device_no`: 设备号(必填,唯一)
|
||||
- `virtual_no`: 虚拟号(**必填**,全局唯一);**[BREAKING]** 为空时不再静默跳过,改为记录为失败行
|
||||
- `device_name`: 设备名称(可选)
|
||||
- `device_model`: 设备型号(可选)
|
||||
- `device_type`: 设备类型(可选)
|
||||
@@ -41,6 +42,9 @@ TBD - created by archiving change add-device-management. Update Purpose after ar
|
||||
- `manufacturer`: 制造商(可选)
|
||||
- `iccid_1` ~ `iccid_4`: 对应插槽 1-4 的 ICCID(可选,空值表示该插槽无卡)
|
||||
|
||||
**失败原因文本**:
|
||||
- VirtualNo 为空:`"设备虚拟号(virtual_no)不能为空"`
|
||||
|
||||
**导入规则**:
|
||||
- 导入的设备 shop_id = NULL(平台库存)
|
||||
- 导入的设备 status = 1(在库)
|
||||
@@ -127,6 +131,18 @@ TBD - created by archiving change add-device-management. Update Purpose after ar
|
||||
- **WHEN** Excel 中某行的设备号在系统中已存在
|
||||
- **THEN** 该行被跳过,记录跳过原因"设备号已存在"
|
||||
|
||||
#### Scenario: VirtualNo 为空的行记录为失败(原为跳过)
|
||||
|
||||
- **WHEN** Excel 中某行 virtual_no 列为空或未填写
|
||||
- **THEN** 该行计入失败(`fail_count++`),失败原因为"设备虚拟号(virtual_no)不能为空"
|
||||
- **THEN** 该行不再计入 `skip_count`
|
||||
- **THEN** 其他合法行继续导入,不因此行中断
|
||||
|
||||
#### Scenario: 导入任务结果报告包含 VirtualNo 失败原因
|
||||
|
||||
- **WHEN** 导入任务处理完毕,含有 VirtualNo 为空的行
|
||||
- **THEN** 失败明细列表中,该行的 `reason` 字段为"设备虚拟号(virtual_no)不能为空",`line` 字段为对应行号
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 设备导入任务列表查询
|
||||
|
||||
@@ -201,8 +201,8 @@ TBD - created by archiving change iot-card-standalone-management. Update Purpose
|
||||
- **表头识别关键字**:
|
||||
- ICCID 列: iccid/ICCID/卡号/号码
|
||||
- MSISDN 列: msisdn/MSISDN/接入号/手机号/电话/号码
|
||||
- virtual_no 列(新增,可选): virtual_no/VirtualNo/虚拟号/设备号
|
||||
- **列数要求**: 至少 2 列(ICCID 和 MSISDN),virtual_no 为可选第三列
|
||||
- virtual_no 列(**必填**): virtual_no/VirtualNo/虚拟号/设备号
|
||||
- **列数要求**: 至少 3 列(ICCID、MSISDN、virtual_no),virtual_no 为必填列
|
||||
- **列格式**: 应设置为文本格式(避免长数字被转为科学记数法)
|
||||
|
||||
**解析规则**:
|
||||
@@ -211,12 +211,13 @@ TBD - created by archiving change iot-card-standalone-management. Update Purpose
|
||||
- 跳过空行
|
||||
- ICCID 为空的行记录为失败
|
||||
- MSISDN 为空的行记录为失败
|
||||
- virtual_no 为空的行:跳过该列(不填入,保留原值)
|
||||
- virtual_no 为空的行:**[BREAKING]** 记录为失败行,失败原因为"虚拟号(virtual_no)不能为空"(原行为为跳过该列)
|
||||
- Excel 中不包含 virtual_no 列时:所有数据行均因 VirtualNo 为空而失败,返回错误提示"Excel 文件缺少 virtual_no 列,请使用最新模板"
|
||||
|
||||
**virtual_no 导入规则(只补空白)**:
|
||||
- 该行 virtual_no 不为空 + 数据库当前值为 NULL:填入新值
|
||||
- 该行 virtual_no 不为空 + 数据库当前值已有值:跳过(不覆盖)
|
||||
- **批次级唯一性检查**:在执行导入前,先检查整批中所有非空 virtual_no 是否与数据库现存值重复;有任意冲突则**整批失败**,响应中返回冲突的 virtual_no 及行号列表
|
||||
**virtual_no 导入规则**:
|
||||
- virtual_no 为必填列,为空则该行记录为失败
|
||||
- virtual_no 全局唯一(跨卡和设备),重复则失败,原因为"虚拟号已被占用: <值>"
|
||||
- **批次级唯一性检查**:在执行导入前,先检查整批中所有 virtual_no 是否与数据库现存值重复;有任意冲突则**整批失败**,响应中返回冲突的 virtual_no 及行号列表
|
||||
|
||||
**示例 Excel 内容**:
|
||||
```
|
||||
@@ -226,20 +227,27 @@ TBD - created by archiving change iot-card-standalone-management. Update Purpose
|
||||
| 89860012345678901235 | 13800000002 | |
|
||||
```
|
||||
|
||||
#### Scenario: 解析标准双列 Excel 文件(无 virtual_no 列)
|
||||
#### Scenario: 正常导入(ICCID 和 VirtualNo 均有值)
|
||||
|
||||
- **WHEN** Excel 文件只含 ICCID 和 MSISDN 两列,无虚拟号列
|
||||
- **THEN** 解析结果包含 2 条有效记录,virtual_no 字段为空,不影响导入逻辑
|
||||
- **WHEN** Excel 中某行 ICCID="898600XXXXX",virtual_no="CARD-001",msisdn="13800000001"
|
||||
- **THEN** 导入成功,卡记录写入数据库,VirtualNo 和 ICCID 同步注册到 `tb_asset_identifier`
|
||||
|
||||
#### Scenario: 解析含 virtual_no 列的三列 Excel
|
||||
#### Scenario: VirtualNo 为空的行被拒绝
|
||||
|
||||
- **WHEN** Excel 文件含 ICCID、MSISDN、虚拟号三列,某行 virtual_no = "CARD-001",对应卡当前 virtual_no 为 NULL
|
||||
- **THEN** 解析后该卡的 virtual_no 填入 "CARD-001"
|
||||
- **WHEN** Excel 中某行 ICCID="898600YYYYY",virtual_no 列为空或未填写
|
||||
- **THEN** 该行计入失败,失败原因为"虚拟号(virtual_no)不能为空"
|
||||
- **THEN** 其他合法行继续导入,不因此行中断
|
||||
|
||||
#### Scenario: virtual_no 已有值时不覆盖
|
||||
#### Scenario: VirtualNo 重复被拒绝
|
||||
|
||||
- **WHEN** Excel 中某行 virtual_no = "CARD-NEW",但该卡数据库中已有 virtual_no = "CARD-OLD"
|
||||
- **THEN** 该卡的 virtual_no 保持 "CARD-OLD" 不变,该行跳过(不报错,不计入失败)
|
||||
- **WHEN** Excel 中某行的 virtual_no 与已有卡/设备的 VirtualNo 重复(跨表)
|
||||
- **THEN** 该行计入失败,原因为"虚拟号已被占用: <值>"
|
||||
|
||||
#### Scenario: Excel 文件中无 VirtualNo 列
|
||||
|
||||
- **WHEN** Excel 表头中不包含 `virtual_no`/`虚拟号`/`设备号` 等可识别列名
|
||||
- **THEN** 所有数据行均因 VirtualNo 为空而失败,导入结果中 `fail_count = 总行数`,`success_count = 0`
|
||||
- **THEN** 返回错误提示:"Excel 文件缺少 virtual_no 列,请使用最新模板"
|
||||
|
||||
#### Scenario: 批次中有 virtual_no 与现存数据重复
|
||||
|
||||
@@ -252,6 +260,12 @@ TBD - created by archiving change iot-card-standalone-management. Update Purpose
|
||||
- **WHEN** 系统解析该 Excel 文件
|
||||
- **THEN** 系统正确识别三列,按规则处理 virtual_no
|
||||
|
||||
#### Scenario: 导入任务完成后的结果报告
|
||||
|
||||
- **WHEN** 导入任务处理完毕
|
||||
- **THEN** 结果包含:`success_count`、`fail_count`、`skip_count`、失败明细列表(含行号、原因)
|
||||
- **THEN** VirtualNo 为空的失败行在明细中明确体现行号和原因
|
||||
|
||||
#### Scenario: 拒绝非Excel格式文件
|
||||
|
||||
- **GIVEN** 上传文件扩展名为 .csv
|
||||
|
||||
@@ -26,7 +26,21 @@
|
||||
|
||||
### Requirement: 创建套餐购买订单
|
||||
|
||||
系统 SHALL 允许买家创建套餐购买订单。订单类型分为单卡购买和设备购买。创建前 MUST 验证购买权限和强充要求。**后台订单接口 MUST 支持 `payment_method` 字段(wallet/offline),根据支付方式自动设置 `is_purchase_on_behalf` 标识**。
|
||||
系统 SHALL 允许买家创建套餐购买订单。**[BREAKING]** 请求体改为使用资产标识符(identifier),废弃 `iot_card_id`/`device_id` 主键字段;`order_type` 字段由系统根据 identifier 解析结果自动填入,请求体不再接受。创建前 MUST 验证购买权限和强充要求。**后台订单接口 MUST 支持 `payment_method` 字段(wallet/offline),根据支付方式自动设置 `is_purchase_on_behalf` 标识**。
|
||||
|
||||
**后台订单创建请求(CreateAdminOrderRequest)**:
|
||||
```json
|
||||
{
|
||||
"identifier": "string(资产标识符,ICCID 或 VirtualNo,必填)",
|
||||
"package_ids": "[uint](套餐 ID 列表,必填,1-10 个)",
|
||||
"payment_method": "string(wallet|offline,必填)"
|
||||
}
|
||||
```
|
||||
|
||||
**废弃字段**:
|
||||
- `iot_card_id`(原用于单卡购买)
|
||||
- `device_id`(原用于设备购买)
|
||||
- `order_type`(改为系统自动推断)
|
||||
|
||||
**支付方式和订单类型映射**:
|
||||
- `payment_method = "wallet"`:扣买家钱包,`is_purchase_on_behalf = false`(普通订单)
|
||||
@@ -72,6 +86,22 @@
|
||||
- **WHEN** 买家尝试购买已下架的套餐
|
||||
- **THEN** 系统返回错误 "该套餐已下架"
|
||||
|
||||
#### 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** 返回错误"资产不存在",订单不创建
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 查询订单列表
|
||||
@@ -224,3 +254,36 @@
|
||||
#### Scenario: 后台管理端可为未实名载体购买
|
||||
- **WHEN** 管理员通过后台为未实名载体购买套餐
|
||||
- **THEN** 系统创建订单成功,PackageUsage status=0, pending_realname_activation=true
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 订单响应包含资产标识符
|
||||
|
||||
订单响应(OrderResponse)SHALL 包含资产标识符字段,前端无需额外请求即可展示资产信息。
|
||||
|
||||
**新增响应字段**:
|
||||
- `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`),不返回错误
|
||||
|
||||
@@ -117,3 +117,23 @@
|
||||
- **WHEN** 代理渠道某套餐存在 `retail_price < cost_price`
|
||||
- **THEN** 系统 MUST 不展示该套餐,且不允许该套餐进入下单校验
|
||||
|
||||
---
|
||||
|
||||
### 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`,返回错误,订单不创建
|
||||
|
||||
|
||||
Reference in New Issue
Block a user