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:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -42,6 +42,7 @@ type WorkerStores struct {
|
||||
AgentWallet *postgres.AgentWalletStore
|
||||
AgentWalletTransaction *postgres.AgentWalletTransactionStore
|
||||
AssetWallet *postgres.AssetWalletStore
|
||||
AssetIdentifier *postgres.AssetIdentifierStore
|
||||
}
|
||||
|
||||
// WorkerServices Worker 侧所有 Service 的集合
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user