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:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-04-07
|
||||
@@ -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 格式校验已在此处)风格一致
|
||||
|
||||
### 决策 2:IoT 卡数据库层迁移分两步
|
||||
|
||||
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
|
||||
|
||||
- 无
|
||||
@@ -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 列,导入将全部失败;需通知使用方更新模板
|
||||
@@ -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` 字段为对应行号
|
||||
@@ -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 为空的失败行在明细中明确体现行号和原因
|
||||
@@ -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 OK;bootstrap 已有预存在错误与本次无关)
|
||||
- [x] 8.2 LSP 诊断:对所有修改文件运行 lsp_diagnostics,无错误/警告
|
||||
- [ ] 8.3 IoT 卡导入验证(使用 Hurl 或 curl):
|
||||
- 上传含完整 VirtualNo 的 Excel:导入成功,success_count 正确
|
||||
- 上传含空 VirtualNo 行的 Excel:该行 fail_count+1,reason 为"虚拟号(virtual_no)不能为空",其他行正常导入
|
||||
- 上传无 VirtualNo 列的 Excel:全部失败,返回"Excel 文件缺少 virtual_no 列"
|
||||
- 上传重复 VirtualNo 的 Excel:该行 fail_count+1,reason 为"虚拟号已被占用: <值>"
|
||||
- [ ] 8.4 设备导入验证:
|
||||
- 上传含空 VirtualNo 行的 Excel:该行计入 fail_count(而非 skip_count),reason 为"设备虚拟号(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` 已验证
|
||||
Reference in New Issue
Block a user