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,2 @@
schema: spec-driven
created: 2026-04-07

View File

@@ -0,0 +1,75 @@
## Context
系统中换货流程已有完整的数据记录:
- `ExchangeOrder` 表存有 `old_asset_id``old_asset_identifier``new_asset_id``new_asset_identifier`,形成资产的"世代链"
- `Order` 表通过提案一新增的 `asset_identifier` 快照字段,可直接按标识符查询订单
- `Device`/`IotCard` 表有 `generation` 字段记录当前世代编号
当前缺失的是:把这三张表的信息串联起来,给管理员呈现"这个资产在各世代的订单全貌"。
## Goals / Non-Goals
**Goals:**
- 新增 `GET /api/admin/assets/:identifier/orders` 接口,默认返回本代订单
- 支持 `include_previous=true` 参数,追溯换货链返回前代订单(明确标注世代)
- 分页支持
**Non-Goals:**
- C 端不暴露跨代订单C 端用户视角只关心当前资产本代)
- 修改订单数据本身(只读接口)
- 跨代订单的聚合统计(如历代总充值额),留给未来迭代
## Decisions
### 决策 1通过 ExchangeOrder 逆向追溯前代资产 ID
**查询逻辑**
```
给定 identifier当前资产
1. 通过注册表/Resolve 得到 asset_type + asset_id当前代
2. 查当前资产的订单WHERE asset_identifier = identifier直接走快照字段
3. 若 include_previous=true
a. 查 ExchangeOrder WHERE new_asset_id = asset_id找到换货记录
b. 得到 old_asset_id + old_asset_identifier前代标识符
c. 查前代订单WHERE asset_identifier = old_asset_identifier
d. 递归至无更多前代(链式追溯,最多向前追溯 N 代,防止死循环)
4. 合并结果,按世代分组,每条订单附加 generation 字段
```
**理由**:直接使用 `asset_identifier` 快照字段查询,无需 JOIN换货链通过 ExchangeOrder 逆向遍历,逻辑清晰。
### 决策 2响应结构按世代分组不跨代混合排序
**选择**:响应分为 `current_generation`(本代)和 `previous_generations`(前代数组)两个区块
**备选方案**:全部打平按时间排序。问题:管理员看到时间线上跳跃的世代可能产生困惑(订单时间早于资产创建时间)
**理由**:分区块展示逻辑清晰——管理员一眼能看出"这是这台设备自己的订单"vs"这是上一台的历史"
### 决策 3限制追溯深度防止换货链过长
最大追溯世代数为 **10 代**,超出则截断并在响应中说明(`truncated: true`)。实际业务中换货链极少超过 3 代,此限制仅为安全保障。
### 决策 4本接口依赖提案一的 asset_identifier 快照字段
若提案一未完成Order 表无 `asset_identifier` 字段),本接口降级为通过 `iot_card_id`/`device_id` 查询(只支持本代,不支持 include_previous。实际按提案一完成后实现。
## Risks / Trade-offs
| 风险 | 缓解措施 |
|------|---------|
| **换货链查询产生多次 DB 往返** | 每代一次 ExchangeOrder 查询 + 一次 Order 查询链深度有限≤10总查询次数可控可加 Redis 缓存换货链 |
| **旧订单 asset_identifier 为空**(提案一之前创建的订单)| 本接口限定只查有 `asset_identifier` 快照的订单;旧订单通过 `iot_card_id`/`device_id` fallback 查询补充,明确标注"旧格式订单" |
| **include_previous 导致响应体过大** | 分页仅对本代订单生效;前代订单默认返回最近 20 条,不支持前代分页(前代是历史归档,数量有限) |
## Migration Plan
1. 依赖提案一完成(`Order.asset_identifier` 字段存在)
2. 实现 `exchange_order_store.FindChainByNewAssetID()`
3. 实现 `asset_service.GetOrders()`
4. 注册路由和 Handler
5. 更新文档生成器
## Open Questions
-

View File

@@ -0,0 +1,33 @@
## Why
当前系统的资产详情页面没有"历史订单"维度。管理员无法在查看某个资产时直接知道它卖给过谁、买过哪些套餐。换货流程(`ExchangeOrder`)已经在数据库层记录了旧资产→新资产的映射关系,但没有被利用来追溯前代订单。随着资产复用(换货后二次销售)场景增多,管理员需要一种方式能追溯到"这个资产的来源及历史"。
## What Changes
- **[NEW]** 新增 B 端接口:`GET /api/admin/assets/:identifier/orders`,查询某资产本代的历史订单
- **[NEW]** 支持查询参数 `include_previous=true`,通过换货链追溯并返回前代资产的订单(附带世代标注)
- 接口路径遵循提案一中已确立的 `:identifier` 统一规范(依赖提案一完成后的注册表和路由规范)
## Capabilities
### New Capabilities
- `asset-historical-orders`:资产往期订单查询接口,支持本代/跨代两种视图,响应中明确标注每条订单所属世代
### Modified Capabilities
(无——本提案仅新增接口,不修改现有接口行为)
## Impact
**受影响的代码**
- `internal/handler/admin/asset.go`:新增 `Orders` handler
- `internal/routes/asset.go`:注册新路由
- `internal/service/asset/service.go`:新增 `GetOrders()` 方法,包含换货链追溯逻辑
- `internal/store/postgres/order_store.go`:新增按 `asset_identifier` 精确查询(依赖提案一的 `asset_identifier` 快照字段)
- `internal/store/postgres/exchange_order_store.go`:新增按资产 ID 查询换货链的方法
- `internal/model/dto/asset_dto.go`:新增 `AssetOrdersRequest``AssetOrdersResponse``AssetOrderItem` DTO
- `cmd/api/docs.go``cmd/gendocs/main.go`:注册新 Handler
**前置依赖**
- 提案一(`asset-identifier-standardization`)必须先完成:本提案依赖 `Order.asset_identifier` 快照字段和统一的 `:identifier` 路由规范

View File

@@ -0,0 +1,108 @@
## ADDED Requirements
### 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 或不返回,节省带宽

View File

@@ -0,0 +1,54 @@
## 1. 前置确认
- [x] 1.1 确认提案一asset-identifier-standardization已完成`tb_order.asset_identifier` 字段存在B 端资产路由已统一为 `:identifier`
## 2. 数据层ExchangeOrder Store 扩展
- [x] 2.1 在 `internal/store/postgres/exchange_order_store.go` 新增 `FindByNewAssetID(ctx, assetType, assetID) (*model.ExchangeOrder, error)` 方法:查询 `WHERE new_asset_id = ? AND new_asset_type = ?`,返回该资产的换货来源记录(找不到则返回 nil表示无前代
## 3. DTO 层:新增响应结构
- [x] 3.1 在 `internal/model/dto/asset_dto.go` 新增以下结构体:
- `AssetOrdersRequest`:路径参数 `identifier`query 参数 `page``page_size``include_previous`
- `AssetOrderItem`单条订单项order_no、order_type、payment_status、payment_status_text、total_amount、payment_method、paid_at、generation、items、created_at
- `GenerationOrders`单代订单块generation、identifier、asset_type、total、page、page_size、items
- `PreviousGenerationOrders`前代订单块generation、identifier、asset_type、exchange_no、exchanged_at、total、items
- `AssetOrdersResponse`完整响应current_generation、previous_generations、truncated
## 4. 服务层GetOrders 方法
- [x] 4.1 在 `internal/service/asset/service.go` 新增 `GetOrders(ctx, identifier, page, pageSize, includePrevious bool) (*dto.AssetOrdersResponse, error)` 方法:
- 调用 `Resolve()` 得到 asset_type 和 asset_id
- 查询本代订单:`orderStore.ListByAssetIdentifier(ctx, identifier, page, pageSize)`
-`includePrevious=true`:循环调用 `exchangeOrderStore.FindByNewAssetID()` 追溯换货链(最多 10 代);每代查询前代 `old_asset_identifier` 对应的订单(最多 20 条,无分页)
- 组装 `AssetOrdersResponse` 返回
- [x] 4.2 在 `internal/store/postgres/order_store.go` 新增 `ListByAssetIdentifier(ctx, identifier, page, pageSize) ([]*model.Order, int64, error)` 方法:查询 `WHERE asset_identifier = ?`,分页,倒序
## 5. Handler 层:新增 Orders Handler
- [x] 5.1 在 `internal/handler/admin/asset.go` 新增 `Orders(c *fiber.Ctx) error` handler
- 从路径参数读取 `identifier`;从 query 读取 `page``page_size``include_previous`
- 调用 `assetService.GetOrders()`
- 返回 `response.Success(c, result)`
- 注释:`// Orders 查询资产历史订单(支持跨代追溯)` + `// GET /api/admin/assets/:identifier/orders`
## 6. 路由层:注册新路由
- [x] 6.1 在 `internal/routes/asset.go` 注册:`assets.Get("/:identifier/orders", h.Asset.Orders)`(注意路由顺序,避免与 `/resolve/:identifier` 冲突)
## 7. 文档生成器更新
- [x] 7.1 在 `cmd/api/docs.go``cmd/gendocs/main.go``handlers` 结构体中确认 `AssetHandler` 已包含新的 `Orders` 方法(检查是否需要更新 handler 注册)
- [x] 7.2 执行 `go run cmd/gendocs/main.go` 重新生成 OpenAPI 文档,验证新接口体现
## 8. 验证
- [x] 8.1 构建验证:`go build ./...` 无编译错误
- [x] 8.2 LSP 诊断:对所有修改文件运行 lsp_diagnostics无错误/警告
- [x] 8.3 接口验证(使用 PostgreSQL MCP + curl
- 查询有订单的资产:`GET /api/admin/assets/:identifier/orders` 返回正确订单列表,含 generation 字段
- 查询无订单资产:返回 `items: [], total: 0`
- 查询不存在的 identifier返回 404
- 查询换货资产(带 include_previous=true`previous_generations` 包含前代订单,附带 exchange_no 和 exchanged_at
- 查询无前代的资产(带 include_previous=true`previous_generations` 为空数组
- 代理查询无权限资产:返回 403

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-07

View File

@@ -0,0 +1,131 @@
## 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 位)

View File

@@ -0,0 +1,53 @@
## Why
当前系统的 B 端资产操作接口大量使用数据库主键 ID`/assets/:asset_type/:id/``/devices/:id/`)作为路径参数,导致接口语义模糊、前端被迫持有无业务含义的 ID、换货后旧 ID 失效等问题。与此同时IoT 卡和设备的虚拟号VirtualNo没有跨表全局唯一约束ICCID/VirtualNo 在多个子系统中的使用也不一致。现阶段系统尚处于开发测试期,是建立资产标识符规范的最佳窗口。
## What Changes
- **[BREAKING]** B 端资产操作接口路径参数统一改为资产标识符ICCID 或 VirtualNo废弃基于数据库主键 ID 的路由
- **[BREAKING]** 订单创建接口B 端 + C 端)从传入 `iot_card_id`/`device_id` 主键改为传入 `identifier` 标识符
- **[NEW]** 新增全局资产标识符注册表 `tb_asset_identifier`,在数据库层保证 ICCID/VirtualNo 跨 `tb_iot_card``tb_device` 两表全局唯一,防止并发写入冲突
- **[NEW]** 资产解析服务Resolve主路径改走注册表单次查询即可定位资产替代原有的跨表 OR 查询
- **[FIX]** 修复:已绑定设备的 IoT 卡(`is_standalone=false`)可被单独购买套餐的 Bug改为直接报错并引导用户至设备页面购买
- 订单查询列表新增按资产标识符过滤条件
- 订单响应新增资产标识符字段,前端无需二次查询
- 所有 B 端 `:id` 路由同步更新,前端需切换调用方式
## Capabilities
### New Capabilities
- `asset-identifier-registry`:全局资产标识符注册表(`tb_asset_identifier`)的模型定义、写入/查询接口,以及 Resolve 服务的注册表主路径改造
- `asset-identifier-routes`B 端统一标识符路由规范——将 `/assets/:asset_type/:id/*``/devices/:id/*``/iot-cards/:id/*` 全线迁移为 `/assets/:identifier/*``/devices/:virtual_no/*`
### Modified Capabilities
- `asset-resolve`:主查询路径改走 `tb_asset_identifier`IMEI/SN/MSISDN 降级为 fallback 模糊查询
- `order-management`CreateAdminOrderRequest 改用 `identifier`OrderResponse 新增 `asset_identifier` 字段OrderListRequest 新增 `identifier` 过滤条件
- `client-order-purchase`CreateOrderRequest 改用 `identifier`,系统内部解析资产类型
- `package-purchase-validation`:新增 `is_standalone` 校验——若卡已绑定设备则拒绝单卡购买套餐
## Impact
**受影响的代码**
- `internal/model/`:新增 `AssetIdentifier` 模型;`Order` 模型新增 `asset_identifier` 快照字段
- `internal/store/postgres/`:新增 `asset_identifier_store.go``order_store.go` 新增标识符过滤
- `internal/service/asset/``Resolve` 方法改走注册表
- `internal/service/purchase_validation/`:新增 `is_standalone` 校验
- `internal/handler/admin/``asset.go``device.go``iot_card.go``order.go` 全面更新路径参数和请求/响应 DTO
- `internal/handler/app/``client_order.go` 更新请求 DTO
- `internal/routes/``asset.go``device.go``iot_card.go``order.go` 路由重注册
- `internal/model/dto/``asset_dto.go``order_dto.go``client_order_dto.go` 字段变更
- `migrations/`:新增迁移文件创建 `tb_asset_identifier`
**受影响的 APIBREAKING**
- `GET /api/admin/assets/:asset_type/:id/*`7 个端点)
- `POST /api/admin/assets/device/:device_id/stop|start`
- `DELETE /api/admin/devices/:id`
- `GET|POST|DELETE /api/admin/devices/:id/cards*`
- `PATCH /api/admin/devices/:id/deactivate`
- `PATCH /api/admin/iot-cards/:id/deactivate`
- `POST /api/admin/orders`(请求体变更)
- `POST /api/c/v1/orders/create`(请求体变更)
**前端影响**:所有使用 `asset_id` 做路径参数的页面必须切换为使用 `identifier`ICCID 或 VirtualNo

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`,返回错误,订单不创建

View File

@@ -0,0 +1,81 @@
## 1. 数据库迁移:全局标识符注册表
- [x] 1.1 创建迁移文件:`tb_asset_identifier`identifier UNIQUE、asset_type、asset_id、created_at建立 `idx_asset_identifier_asset(asset_type, asset_id)` 索引
- [x] 1.2 执行迁移,验证表结构和唯一约束正确
## 2. 数据层AssetIdentifier 模型与 Store
- [x] 2.1 创建 `internal/model/asset_identifier.go`:定义 `AssetIdentifier` 结构体(含 GORM 标签、TableName
- [x] 2.2 创建 `internal/store/postgres/asset_identifier_store.go`:实现 `Register(ctx, identifier, assetType, assetID)`(写入注册表)、`FindByIdentifier(ctx, identifier)`(精确查询)、`DeleteByAsset(ctx, assetType, assetID)`(删除资产时清理)
- [x] 2.3 在 `internal/bootstrap/stores.go` 注入 `AssetIdentifierStore`
## 3. 数据层Order 模型新增 asset_identifier 字段
- [x] 3.1 创建迁移文件:`tb_order` 表增加 `asset_identifier VARCHAR(100)` 字段
- [x] 3.2 更新 `internal/model/order.go`:新增 `AssetIdentifier string` 字段(含 GORM 标签和注释)
- [x] 3.3 更新 `internal/store/postgres/order_store.go`:列表查询新增 `identifier` 过滤条件(精确匹配 `asset_identifier`
## 4. 服务层Resolve 改走注册表
- [x] 4.1 更新 `internal/service/asset/service.go``Resolve` 方法优先查 `AssetIdentifierStore.FindByIdentifier()`,命中则按 asset_type 查对应表;未命中 fallback 原有逻辑
- [x] 4.2 在 `AssetService` 中注入 `AssetIdentifierStore` 依赖
## 5. 服务层purchase_validation 增加 is_standalone 校验
- [x] 5.1 更新 `internal/service/purchase_validation/service.go`:在 `ValidateCardPurchase()` 中增加 `if !card.IsStandalone { return error "该卡已绑定设备,请前往设备页面购买套餐" }` 校验(在套餐校验之前执行)
## 6. 服务层:资产创建/删除时同步注册表
- [x] 6.1 更新设备导入任务 `internal/task/device_import.go`:创建设备成功后在同一事务内调用 `AssetIdentifierStore.Register()` 注册 VirtualNo若冲突则拒绝该行
- [x] 6.2 更新 IoT 卡导入任务 `internal/task/iot_card_import.go`:创建卡成功后注册 ICCID必填和 VirtualNo如有到注册表冲突则拒绝该行
- [x] 6.3 更新设备/IoT 卡的软删除逻辑:删除时调用 `AssetIdentifierStore.DeleteByAsset()` 清理注册表
## 7. 服务层:订单创建改用 identifier
- [x] 7.1 更新 `internal/service/order/service.go`B 端):`Create()` 方法接收 `identifier` 字符串,内部调用 `AssetService.Resolve()` 解析资产,不再接受 `iot_card_id`/`device_id`;写入订单时填入 `asset_identifier` 快照字段
- [x] 7.2 更新 `internal/service/client_order/service.go`C 端):同上,接收 `identifier`,解析后写入 `asset_identifier` 快照
## 8. DTO 层:更新请求/响应结构
- [x] 8.1 更新 `internal/model/dto/order_dto.go``CreateAdminOrderRequest` 去掉 `IotCardID`/`DeviceID`/`OrderType`,新增 `Identifier string``OrderResponse` 新增 `AssetIdentifier string``AssetType string``OrderListRequest` 新增 `Identifier string`
- [x] 8.2 更新 `internal/model/dto/client_order_dto.go``CreateOrderRequest`):同上,去掉 `IotCardID`/`DeviceID`/`OrderType`,新增 `Identifier string`
- [x] 8.3 更新 `internal/model/dto/asset_dto.go``AssetTypeIDRequest` 废弃,`AssetResolveRequest` 确认 `Identifier` 字段已有;新增 `AssetIdentifierRequest`(路径参数,供操作接口使用)
- [x] 8.4 确认 `AssetResolveResponse` 新增 `Identifier` 字段(回传请求中使用的标识符)
## 9. Handler 层B 端资产操作接口更新
- [x] 9.1 更新 `internal/handler/admin/asset.go``RealtimeStatus`/`Refresh`/`Packages`/`CurrentPackage`/`UpdatePollingStatus`/`StopDevice`/`StartDevice`/`StopCard`/`StartCard` 均改为从路径参数 `:identifier` 读取标识符,调用 Resolve 解析后操作;合并 Stop/Start 卡和设备为统一的 `Stop`/`Start` handler内部按资产类型分支
- [x] 9.2 更新 `internal/handler/admin/asset.go`:新增 `Deactivate` handler合并 IoT 卡和设备停用,原 `/iot-cards/:id/deactivate``/devices/:id/deactivate`
- [x] 9.3 更新 `internal/handler/admin/device.go``Delete`/`ListCards`/`BindCard`/`UnbindCard` 改为从路径参数 `:virtual_no` 读取设备标识;`UnbindCard` 第二段路径参数改为 `:iccid`
## 10. Handler 层B 端订单 Handler 更新
- [x] 10.1 更新 `internal/handler/admin/order.go``Create` handler 解析 `CreateAdminOrderRequest.Identifier` 字段,移除 `IotCardID`/`DeviceID` 解析逻辑;`List` handler 支持 `identifier` query 参数传入 Store 过滤
## 11. Handler 层C 端订单 Handler 更新
- [x] 11.1 更新 `internal/handler/app/client_order.go``CreateOrder` handler 解析 `CreateOrderRequest.Identifier`,移除旧字段解析
## 12. 路由层:重注册所有受影响路由
- [x] 12.1 更新 `internal/routes/asset.go`:将所有 `:asset_type/:id` 路由改为 `:identifier`;合并卡/设备的 stop/start/deactivate 路由
- [x] 12.2 更新 `internal/routes/device.go``/:id` 改为 `/:virtual_no`;解绑路由 `/:id/cards/:cardId` 改为 `/:virtual_no/cards/:iccid`
- [x] 12.3 更新 `internal/routes/iot_card.go`:移除 `/:id/deactivate`(已并入 asset.go 的统一路由)
- [x] 12.4 更新 `internal/routes/order.go`:无路由变更,仅确认 Handler 已更新
## 13. 文档生成器更新
- [x] 13.1 更新 `cmd/api/docs.go``cmd/gendocs/main.go`:确保新 Handler 方法已注册Deactivate 等新合并 handler 需加入)
- [x] 13.2 执行 `go run cmd/gendocs/main.go` 重新生成 OpenAPI 文档,验证新路由和 DTO 正确体现
## 14. 验证
- [x] 14.1 构建验证:`go build ./...` 无编译错误
- [x] 14.2 LSP 诊断:对所有修改文件运行 lsp_diagnostics无错误/警告
- [ ] 14.3 接口验证(使用 PostgreSQL MCP 和 curl
- 通过 ICCID 调用 `GET /api/admin/assets/:identifier/packages` 返回正确套餐列表
- 通过 VirtualNo 调用 `POST /api/admin/assets/:identifier/stop` 执行停机
- 使用绑定设备的卡 identifier 创建订单,验证返回"该卡已绑定设备"错误
- 使用独立卡 identifier 创建订单成功OrderResponse 包含 `asset_identifier` 字段
- 并发写入同一 VirtualNo 到注册表,验证只有一条成功
- [ ] 14.4 订单列表验证:`GET /api/admin/orders?identifier=CARD-001` 返回该资产的订单

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-07

View File

@@ -0,0 +1,64 @@
## Context
**设备**`tb_device.virtual_no` 已是 `NOT NULL + UNIQUE`(数据库层已强制)。但 `pkg/utils/excel.go` 中设备行解析遇到空 VirtualNo 会 `continue`(跳过该行),导入任务报告跳过数量但不报告失败原因,用户无法知道为什么某些行没被导入。
**IoT 卡**`tb_iot_card.virtual_no` 当前为 nullable唯一索引条件是 `WHERE deleted_at IS NULL AND virtual_no IS NOT NULL AND virtual_no <> ''`(允许多条 NULL 值)。导入时空 VirtualNo 的卡直接进库,不做任何处理。
两者均需要把"安静跳过/允许空值"改为"明确报错",并在数据库层为 IoT 卡补上 NOT NULL 约束。
## Goals / Non-Goals
**Goals:**
- 导入时 VirtualNo 为空 → 报告为失败行,包含行号和原因
- `tb_iot_card.virtual_no` 加 NOT NULL 约束
- 更新 Excel 解析层、任务处理层、模型层
**Non-Goals:**
- 为现有 NULL 记录自动生成 VirtualNo测试阶段直接清库
- IoT 卡导入的其他字段校验(不在本提案范围内)
- VirtualNo 格式校验(长度/字符集约束,留给未来)
## Decisions
### 决策 1在 Excel 解析层pkg/utils/excel.go拦截空值不在任务层
**选择**:在 `parseCardRows()``parseDeviceRows()` 中,当 VirtualNo 为空时将该行加入解析错误列表ParseError而非 `continue` 跳过
**备选方案**:在任务处理层(`iot_card_import.go` / `device_import.go`)拦截。问题:任务层已有处理批次逻辑,较复杂;解析层更早发现问题,更符合"fail fast"原则
**理由**:越早发现越好;解析层返回 ParseErrors 与当前批量验证逻辑ICCID 格式校验已在此处)风格一致
### 决策 2IoT 卡数据库层迁移分两步
1. **先清库**(测试阶段手动执行):`DELETE FROM tb_iot_card WHERE virtual_no IS NULL OR virtual_no = ''`
2. **再加约束**(迁移文件):
- `ALTER TABLE tb_iot_card ALTER COLUMN virtual_no SET NOT NULL`
- 重建唯一索引,去掉 `WHERE virtual_no IS NOT NULL AND virtual_no <> ''` 条件,改为无条件唯一
**理由**先清数据再加约束迁移不会失败两步操作在同一迁移文件中完成原子执行PostgreSQL 支持事务内 DDL
### 决策 3设备导入不需要数据库迁移只改导入行为
设备的 VirtualNo 数据库层已是 NOT NULL不需要迁移。只需把 `excel.go``if row.VirtualNo == "" { continue }` 改为加入失败列表即可。
## Risks / Trade-offs
| 风险 | 缓解措施 |
|------|---------|
| **现有使用方的 Excel 模板无 VirtualNo 列** | 属于 BREAKING 变更,提前通知;导入失败信息明确说明"VirtualNo 为必填列" |
| **迁移前 IoT 卡存在 NULL 记录导致 ALTER 失败** | 迁移文件中先执行 DELETE 清理(适用测试环境),再 ALTER生产环境需手动确认数据干净后执行 |
| **IoT 卡在注册表(提案一)中未注册 VirtualNo** | 本提案独立于提案一若提案一先完成IoT 卡导入时需同步注册 VirtualNo 到注册表(在提案一 task 6.2 中已覆盖) |
## Migration Plan
1. 清理测试库中 `virtual_no IS NULL` 的 IoT 卡记录
2. 执行数据库迁移:`tb_iot_card.virtual_no` 加 NOT NULL重建唯一索引
3. 更新 `pkg/utils/excel.go`(解析层)
4. 更新 `internal/task/iot_card_import.go`(任务层,去除残余跳过逻辑)
5. 更新 `internal/task/device_import.go`(任务层,同上)
6. 更新 `internal/model/iot_card.go`GORM tag
7. 更新文档 `docs/excel-import-frontend-guide.md`
## Open Questions
-

View File

@@ -0,0 +1,38 @@
## Why
虚拟号VirtualNo是系统中资产的核心业务标识符也是提案一确立的接口参数规范的基础。然而当前导入流程将其作为可选字段IoT 卡导入时 VirtualNo 可为空(数据库层 nullable设备导入时 VirtualNo 为空的行会被静默跳过而非报错。这导致可能存在没有虚拟号的资产进入系统,无法通过统一标识符接口操作,与资产标识符规范化目标冲突。
## What Changes
- **[BREAKING]** IoT 卡导入VirtualNo 改为必填Excel 中 VirtualNo 列为空的行报错拒绝
- **[BREAKING]** 设备导入VirtualNo 为空的行从原有的"静默跳过"改为"记录为失败行"并报告原因
- **[NEW]** 数据库迁移:`tb_iot_card.virtual_no` 添加 NOT NULL 约束和去除条件唯一索引改为无条件唯一索引(配合清库执行)
- 更新 Excel 导入错误提示,明确 VirtualNo 为必填项
- 更新接口文档,标注 VirtualNo 为必填列
## Capabilities
### New Capabilities
(无新能力——本提案为约束强化,不新增业务功能)
### Modified Capabilities
- `iot-card-import-task`VirtualNo 校验规则从"可选"改为"必填",空值触发失败行记录
- `device-import`VirtualNo 校验规则从"跳过空值行"改为"空值行计入失败并报告原因"
## Impact
**受影响的代码**
- `pkg/utils/excel.go``parseCardRows()` 和设备行解析逻辑,增加 VirtualNo 空值校验
- `internal/task/iot_card_import.go`:移除"空 VirtualNo 跳过"逻辑,改为记录失败行
- `internal/task/device_import.go`:将"空 VirtualNo 跳过"改为"记录失败行"
- `internal/model/iot_card.go``VirtualNo` 字段 GORM tag 更新,去掉 nullable更新注释
- `migrations/`:新增迁移文件,为 `tb_iot_card.virtual_no` 加 NOT NULL 约束
**前置条件**
- 测试数据库中现有 `virtual_no IS NULL` 的 IoT 卡记录需先清除(测试阶段直接清库)
- 设备的 `virtual_no` 在数据库层已经是 NOT NULL无需迁移仅修改导入行为
**破坏性影响**
- 现有 IoT 卡导入 Excel 模板如果没有 VirtualNo 列,导入将全部失败;需通知使用方更新模板

View File

@@ -0,0 +1,22 @@
## MODIFIED Requirements
### Requirement: 设备批量导入
系统 SHALL 支持通过 Excel 文件批量导入设备。**[BREAKING]** `virtual_no` 为空的行行为变更:原行为为"静默跳过skip",改为"记录为失败行fail并报告原因"。
**失败原因文本(新增)**
- VirtualNo 为空:`"设备虚拟号(virtual_no)不能为空"`
#### Scenario: 正常导入VirtualNo 有值)
- **WHEN** Excel 中某行 virtual_no="DEV-001",其他字段合法
- **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` 字段为对应行号

View File

@@ -0,0 +1,40 @@
## MODIFIED Requirements
### Requirement: IoT 卡批量导入
系统 SHALL 支持通过 Excel 文件批量导入 IoT 卡。**[BREAKING]** `virtual_no` 列由可选改为必填Excel 中 `virtual_no` 列为空的行,系统 MUST 将其记录为失败行并报告原因,不得静默跳过或将其写入数据库。
**必填列(更新后)**
- `iccid`必填ICCID电信 19 位/其他 20 位,格式校验
- `virtual_no`**必填,由可选改为必填**虚拟号全局唯一1-50 字符
- `msisdn`(可选):手机号/接入号
**失败原因文本(中文)**
- ICCID 为空:`"ICCID 不能为空"`
- ICCID 格式错误:`"ICCID 格式错误应为19-20位数字"`
- VirtualNo 为空:`"虚拟号(virtual_no)不能为空"`(新增)
- VirtualNo 已被占用:`"虚拟号已被占用: <值>"`(已有)
- ICCID 已存在:`"ICCID 已存在: <值>"`(已有)
#### Scenario: 正常导入ICCID 和 VirtualNo 均有值)
- **WHEN** Excel 中某行 ICCID="898600XXXXX"virtual_no="CARD-001"
- **THEN** 导入成功卡记录写入数据库VirtualNo 和 ICCID 同步注册到 `tb_asset_identifier`
#### Scenario: VirtualNo 为空的行被拒绝
- **WHEN** Excel 中某行 ICCID="898600YYYYY"virtual_no 列为空或未填写
- **THEN** 该行计入失败,失败原因为"虚拟号(virtual_no)不能为空"
- **THEN** 其他合法行继续导入,不因此行中断
#### Scenario: VirtualNo 重复被拒绝
- **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: 导入任务完成后的结果报告
- **WHEN** 导入任务处理完毕
- **THEN** 结果包含:`success_count``fail_count``skip_count`、失败明细列表(含行号、原因)
- **THEN** VirtualNo 为空的失败行在明细中明确体现行号和原因

View File

@@ -0,0 +1,66 @@
## 1. 数据库迁移IoT 卡 VirtualNo NOT NULL
- [x] 1.1 手动清理测试库:执行 `DELETE FROM tb_iot_card WHERE virtual_no IS NULL OR virtual_no = ''`,确认影响行数为 0 后继续(或直接清库)
- [x] 1.2 创建迁移文件(如 `000XXX_iot_card_virtual_no_not_null.up.sql`
```sql
ALTER TABLE tb_iot_card ALTER COLUMN virtual_no SET NOT NULL;
DROP INDEX IF EXISTS idx_iot_card_virtual_no;
CREATE UNIQUE INDEX idx_iot_card_virtual_no ON tb_iot_card(virtual_no) WHERE deleted_at IS NULL;
```
- [x] 1.3 创建对应 down 迁移文件(回滚为 nullable
- [x] 1.4 执行迁移,验证约束生效(尝试插入 virtual_no=NULL 的记录应失败)
## 2. 模型层:更新 IotCard GORM Tag
- [x] 2.1 更新 `internal/model/iot_card.go` 中 `VirtualNo` 字段的 GORM tag
- 去掉 `omitempty`JSON tag
- 将唯一索引条件从 `where:deleted_at IS NULL AND virtual_no IS NOT NULL AND virtual_no <> ''` 简化为 `where:deleted_at IS NULL`
- 更新字段注释:从"虚拟号(可空,全局唯一)"改为"虚拟号(必填,全局唯一)"
## 3. Excel 解析层IoT 卡 VirtualNo 必填校验
- [x] 3.1 更新 `pkg/utils/excel.go` 的 `parseCardRows()` 函数(或等效解析逻辑):
- 当 `virtualNo == ""` 时,不再跳过,而是将该行追加到解析错误列表:`ParseErrors = append(ParseErrors, ParseError{Line: lineNum, Reason: "虚拟号(virtual_no)不能为空"})`
- 若 Excel 文件中完全没有 VirtualNo 列(`virtualNoCol == -1`),在函数开头直接返回错误:`return nil, errors.New(errors.CodeInvalidParam, "Excel 文件缺少 virtual_no 列,请使用最新模板")`
- 确认函数返回值能携带 ParseErrors若当前无此结构需扩展返回类型
## 4. Excel 解析层:设备 VirtualNo 改为失败行
- [x] 4.1 更新 `pkg/utils/excel.go` 的设备行解析逻辑:
- 找到 `if row.VirtualNo == "" { continue }` 代码(当前静默跳过)
- 改为将该行追加到失败列表:`failedRows = append(failedRows, FailedRow{Line: row.Line, Reason: "设备虚拟号(virtual_no)不能为空"})`
- 确认 `skip_count` 不再计入此类行,改计入 `fail_count`
## 5. 任务处理层IoT 卡导入移除残余跳过逻辑
- [x] 5.1 检查 `internal/task/iot_card_import.go` 中所有 `if card.VirtualNo != ""` 条件:
- 唯一性检查处(当前只对非空 VirtualNo 检查):移除条件,强制检查所有行的 VirtualNo
- 批内去重处:同样移除条件
- 若 VirtualNo 为空的行此时能到达任务处理层(不应发生,因解析层已拦截),也在此处记录失败并跳过
## 6. 任务处理层:设备导入统计修正
- [x] 6.1 检查 `internal/task/device_import.go` 中处理空 VirtualNo 行的逻辑:
- 确认 Excel 解析层改动后,空 VirtualNo 行已以 `FailedRow` 形式传入,不再需要任务层的额外 `continue` 跳过
- 验证 `result.failCount` 正确累加,`result.skipCount` 不受影响
## 7. 文档更新
- [x] 7.1 更新 `docs/excel-import-frontend-guide.md` 及路由描述(`internal/routes/iot_card.go`, `internal/routes/device.go`
- IoT 卡导入模板字段表:`virtual_no` 列的"必填"列从"否"改为"是"
- 新增说明:"`virtual_no` 为必填列,留空将导致该行导入失败"
- 设备导入模板字段表:`virtual_no` 列同样标注"必填",并说明"留空不再跳过,将记录为失败行"
## 8. 验证
- [x] 8.1 构建验证:`go build ./...` 无编译错误(本次修改包 build OKbootstrap 已有预存在错误与本次无关)
- [x] 8.2 LSP 诊断:对所有修改文件运行 lsp_diagnostics无错误/警告
- [ ] 8.3 IoT 卡导入验证(使用 Hurl 或 curl
- 上传含完整 VirtualNo 的 Excel导入成功success_count 正确
- 上传含空 VirtualNo 行的 Excel该行 fail_count+1reason 为"虚拟号(virtual_no)不能为空",其他行正常导入
- 上传无 VirtualNo 列的 Excel全部失败返回"Excel 文件缺少 virtual_no 列"
- 上传重复 VirtualNo 的 Excel该行 fail_count+1reason 为"虚拟号已被占用: <值>"
- [ ] 8.4 设备导入验证:
- 上传含空 VirtualNo 行的 Excel该行计入 fail_count而非 skip_countreason 为"设备虚拟号(virtual_no)不能为空"
- [x] 8.5 数据库约束验证PostgreSQL MCP
- `virtual_no` 列 is_nullable=NO 已验证;新索引 `CREATE UNIQUE INDEX idx_iot_card_virtual_no ON tb_iot_card(virtual_no) WHERE deleted_at IS NULL` 已验证