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

@@ -89,6 +89,7 @@ func (h *Handler) registerIotCardImportHandler() {
h.workerResult.Stores.IotCardImportTask,
h.workerResult.Stores.IotCard,
h.workerResult.Stores.AssetWallet,
h.workerResult.Stores.AssetIdentifier,
h.storage,
h.pollingCallback,
h.logger,
@@ -107,6 +108,7 @@ func (h *Handler) registerDeviceImportHandler() {
h.workerResult.Stores.DeviceSimBinding,
h.workerResult.Stores.IotCard,
h.workerResult.Stores.AssetWallet,
h.workerResult.Stores.AssetIdentifier,
h.storage,
h.logger,
)

View File

@@ -42,6 +42,7 @@ type WorkerStores struct {
AgentWallet *postgres.AgentWalletStore
AgentWalletTransaction *postgres.AgentWalletTransactionStore
AssetWallet *postgres.AssetWalletStore
AssetIdentifier *postgres.AssetIdentifierStore
}
// WorkerServices Worker 侧所有 Service 的集合

View File

@@ -1,12 +1,14 @@
package utils
import (
"errors"
stderrors "errors"
"fmt"
"strconv"
"strings"
"github.com/xuri/excelize/v2"
pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors"
)
// CardInfo 卡信息(ICCID + MSISDN + VirtualNo)
@@ -31,6 +33,13 @@ type CSVParseError struct {
Reason string
}
// DeviceParseResult 设备导入 Excel 解析结果
type DeviceParseResult struct {
Rows []DeviceRow
TotalCount int
ParseErrors []CSVParseError
}
// DeviceRow 设备导入数据行
type DeviceRow struct {
Line int
@@ -46,9 +55,9 @@ type DeviceRow struct {
var (
// ErrExcelNoSheets Excel文件无工作表
ErrExcelNoSheets = errors.New("Excel文件无工作表")
ErrExcelNoSheets = stderrors.New("Excel文件无工作表")
// ErrExcelNoData Excel文件无数据行
ErrExcelNoData = errors.New("Excel文件无数据行(至少需要表头+1行数据)")
ErrExcelNoData = stderrors.New("Excel文件无数据行(至少需要表头+1行数据)")
)
// ParseCardExcel 解析包含 ICCID 和 MSISDN 两列的 Excel 文件
@@ -88,48 +97,45 @@ func ParseCardExcel(filePath string) (*CSVParseResult, error) {
// ParseDeviceExcel 解析设备导入 Excel 文件
// filePath: Excel文件路径(.xlsx格式)
// 返回: 设备行数组、总行数、错误
func ParseDeviceExcel(filePath string) ([]DeviceRow, int, error) {
// 1. 打开Excel文件
// 返回: 解析结果(包含有效行和失败行)、错误
func ParseDeviceExcel(filePath string) (*DeviceParseResult, error) {
f, err := excelize.OpenFile(filePath)
if err != nil {
return nil, 0, fmt.Errorf("打开Excel失败: %w", err)
return nil, fmt.Errorf("打开Excel失败: %w", err)
}
defer func() {
if err := f.Close(); err != nil {
// 日志记录关闭错误,但不影响解析结果
}
}()
// 2. 选择sheet
sheetName := selectSheet(f)
if sheetName == "" {
return nil, 0, ErrExcelNoSheets
return nil, ErrExcelNoSheets
}
// 3. 读取所有行
rows, err := f.GetRows(sheetName)
if err != nil {
return nil, 0, fmt.Errorf("读取sheet失败: %w", err)
return nil, fmt.Errorf("读取sheet失败: %w", err)
}
if len(rows) < 2 {
return nil, 0, ErrExcelNoData
return nil, ErrExcelNoData
}
// 4. 解析表头行,构建列索引
header := rows[0]
colIndex := buildDeviceColumnIndex(header)
// 5. 解析数据行
var deviceRows []DeviceRow
parseResult := &DeviceParseResult{
Rows: make([]DeviceRow, 0),
ParseErrors: make([]CSVParseError, 0),
}
for i := 1; i < len(rows); i++ {
record := rows[i]
lineNum := i + 1 // Excel行号从1开始,数据从第2行开始
lineNum := i + 1
row := DeviceRow{Line: lineNum}
// 提取各字段
if idx := colIndex["virtual_no"]; idx >= 0 && idx < len(record) {
row.VirtualNo = strings.TrimSpace(record[idx])
}
@@ -154,7 +160,6 @@ func ParseDeviceExcel(filePath string) ([]DeviceRow, int, error) {
row.Manufacturer = strings.TrimSpace(record[idx])
}
// 提取ICCID (iccid_1 ~ iccid_4)
row.ICCIDs = make([]string, 0, 4)
for j := 1; j <= 4; j++ {
colName := "iccid_" + strconv.Itoa(j)
@@ -166,20 +171,24 @@ func ParseDeviceExcel(filePath string) ([]DeviceRow, int, error) {
}
}
// 跳过虚拟号为空的行
parseResult.TotalCount++
if row.VirtualNo == "" {
parseResult.ParseErrors = append(parseResult.ParseErrors, CSVParseError{
Line: lineNum,
Reason: "设备虚拟号(virtual_no)不能为空",
})
continue
}
// 默认最大插槽数为4
if row.MaxSimSlots == 0 {
row.MaxSimSlots = 4
}
deviceRows = append(deviceRows, row)
parseResult.Rows = append(parseResult.Rows, row)
}
return deviceRows, len(deviceRows), nil
return parseResult, nil
}
// selectSheet 选择要读取的sheet
@@ -219,6 +228,10 @@ func parseCardRows(rows [][]string) (*CSVParseResult, error) {
}
}
if virtualNoCol == -1 {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "Excel 文件缺少 virtual_no 列,请使用最新模板")
}
startLine := 0
if headerSkipped {
startLine = 1
@@ -279,6 +292,16 @@ func parseCardRows(rows [][]string) (*CSVParseResult, error) {
continue
}
if virtualNo == "" {
result.ParseErrors = append(result.ParseErrors, CSVParseError{
Line: lineNum,
ICCID: iccid,
MSISDN: msisdn,
Reason: "虚拟号(virtual_no)不能为空",
})
continue
}
result.Cards = append(result.Cards, CardInfo{
ICCID: iccid,
MSISDN: msisdn,