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>
380 lines
8.7 KiB
Go
380 lines
8.7 KiB
Go
package utils
|
||
|
||
import (
|
||
stderrors "errors"
|
||
"fmt"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/xuri/excelize/v2"
|
||
|
||
pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors"
|
||
)
|
||
|
||
// CardInfo 卡信息(ICCID + MSISDN + VirtualNo)
|
||
type CardInfo struct {
|
||
ICCID string
|
||
MSISDN string
|
||
VirtualNo string
|
||
}
|
||
|
||
// CSVParseResult Excel/CSV 解析结果
|
||
type CSVParseResult struct {
|
||
Cards []CardInfo
|
||
TotalCount int
|
||
ParseErrors []CSVParseError
|
||
}
|
||
|
||
// CSVParseError Excel/CSV 解析错误
|
||
type CSVParseError struct {
|
||
Line int
|
||
ICCID string
|
||
MSISDN string
|
||
Reason string
|
||
}
|
||
|
||
// DeviceParseResult 设备导入 Excel 解析结果
|
||
type DeviceParseResult struct {
|
||
Rows []DeviceRow
|
||
TotalCount int
|
||
ParseErrors []CSVParseError
|
||
}
|
||
|
||
// DeviceRow 设备导入数据行
|
||
type DeviceRow struct {
|
||
Line int
|
||
VirtualNo string
|
||
IMEI string // 设备IMEI,对应 Excel IMEI 列,用于 Gateway API 调用
|
||
DeviceName string
|
||
DeviceModel string
|
||
DeviceType string
|
||
MaxSimSlots int
|
||
Manufacturer string
|
||
ICCIDs []string
|
||
}
|
||
|
||
var (
|
||
// ErrExcelNoSheets Excel文件无工作表
|
||
ErrExcelNoSheets = stderrors.New("Excel文件无工作表")
|
||
// ErrExcelNoData Excel文件无数据行
|
||
ErrExcelNoData = stderrors.New("Excel文件无数据行(至少需要表头+1行数据)")
|
||
)
|
||
|
||
// ParseCardExcel 解析包含 ICCID 和 MSISDN 两列的 Excel 文件
|
||
// filePath: Excel文件路径(.xlsx格式)
|
||
// 返回: 解析结果 (与CSV解析器返回相同的数据结构)
|
||
func ParseCardExcel(filePath string) (*CSVParseResult, error) {
|
||
// 1. 打开Excel文件
|
||
f, err := excelize.OpenFile(filePath)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("打开Excel失败: %w", err)
|
||
}
|
||
defer func() {
|
||
if err := f.Close(); err != nil {
|
||
// 日志记录关闭错误,但不影响解析结果
|
||
}
|
||
}()
|
||
|
||
// 2. 选择sheet (优先"导入数据",否则第一个)
|
||
sheetName := selectSheet(f)
|
||
if sheetName == "" {
|
||
return nil, ErrExcelNoSheets
|
||
}
|
||
|
||
// 3. 读取所有行
|
||
rows, err := f.GetRows(sheetName)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("读取sheet失败: %w", err)
|
||
}
|
||
|
||
if len(rows) < 2 {
|
||
return nil, ErrExcelNoData
|
||
}
|
||
|
||
// 4. 解析表头 + 数据行
|
||
return parseCardRows(rows)
|
||
}
|
||
|
||
// ParseDeviceExcel 解析设备导入 Excel 文件
|
||
// filePath: Excel文件路径(.xlsx格式)
|
||
// 返回: 解析结果(包含有效行和失败行)、错误
|
||
func ParseDeviceExcel(filePath string) (*DeviceParseResult, error) {
|
||
f, err := excelize.OpenFile(filePath)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("打开Excel失败: %w", err)
|
||
}
|
||
defer func() {
|
||
if err := f.Close(); err != nil {
|
||
}
|
||
}()
|
||
|
||
sheetName := selectSheet(f)
|
||
if sheetName == "" {
|
||
return nil, ErrExcelNoSheets
|
||
}
|
||
|
||
rows, err := f.GetRows(sheetName)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("读取sheet失败: %w", err)
|
||
}
|
||
|
||
if len(rows) < 2 {
|
||
return nil, ErrExcelNoData
|
||
}
|
||
|
||
header := rows[0]
|
||
colIndex := buildDeviceColumnIndex(header)
|
||
|
||
parseResult := &DeviceParseResult{
|
||
Rows: make([]DeviceRow, 0),
|
||
ParseErrors: make([]CSVParseError, 0),
|
||
}
|
||
|
||
for i := 1; i < len(rows); i++ {
|
||
record := rows[i]
|
||
lineNum := i + 1
|
||
|
||
row := DeviceRow{Line: lineNum}
|
||
|
||
if idx := colIndex["virtual_no"]; idx >= 0 && idx < len(record) {
|
||
row.VirtualNo = strings.TrimSpace(record[idx])
|
||
}
|
||
if idx := colIndex["imei"]; idx >= 0 && idx < len(record) {
|
||
row.IMEI = strings.TrimSpace(record[idx])
|
||
}
|
||
if idx := colIndex["device_name"]; idx >= 0 && idx < len(record) {
|
||
row.DeviceName = strings.TrimSpace(record[idx])
|
||
}
|
||
if idx := colIndex["device_model"]; idx >= 0 && idx < len(record) {
|
||
row.DeviceModel = strings.TrimSpace(record[idx])
|
||
}
|
||
if idx := colIndex["device_type"]; idx >= 0 && idx < len(record) {
|
||
row.DeviceType = strings.TrimSpace(record[idx])
|
||
}
|
||
if idx := colIndex["max_sim_slots"]; idx >= 0 && idx < len(record) {
|
||
if n, err := strconv.Atoi(strings.TrimSpace(record[idx])); err == nil {
|
||
row.MaxSimSlots = n
|
||
}
|
||
}
|
||
if idx := colIndex["manufacturer"]; idx >= 0 && idx < len(record) {
|
||
row.Manufacturer = strings.TrimSpace(record[idx])
|
||
}
|
||
|
||
row.ICCIDs = make([]string, 0, 4)
|
||
for j := 1; j <= 4; j++ {
|
||
colName := "iccid_" + strconv.Itoa(j)
|
||
if idx := colIndex[colName]; idx >= 0 && idx < len(record) {
|
||
iccid := strings.TrimSpace(record[idx])
|
||
if iccid != "" {
|
||
row.ICCIDs = append(row.ICCIDs, iccid)
|
||
}
|
||
}
|
||
}
|
||
|
||
parseResult.TotalCount++
|
||
|
||
if row.VirtualNo == "" {
|
||
parseResult.ParseErrors = append(parseResult.ParseErrors, CSVParseError{
|
||
Line: lineNum,
|
||
Reason: "设备虚拟号(virtual_no)不能为空",
|
||
})
|
||
continue
|
||
}
|
||
|
||
if row.MaxSimSlots == 0 {
|
||
row.MaxSimSlots = 4
|
||
}
|
||
|
||
parseResult.Rows = append(parseResult.Rows, row)
|
||
}
|
||
|
||
return parseResult, nil
|
||
}
|
||
|
||
// selectSheet 选择要读取的sheet
|
||
// 优先返回名为"导入数据"的sheet,否则返回第一个sheet
|
||
func selectSheet(f *excelize.File) string {
|
||
sheets := f.GetSheetList()
|
||
if len(sheets) == 0 {
|
||
return ""
|
||
}
|
||
|
||
// 优先查找"导入数据"sheet
|
||
for _, sheet := range sheets {
|
||
if sheet == "导入数据" {
|
||
return sheet
|
||
}
|
||
}
|
||
|
||
// 返回第一个sheet
|
||
return sheets[0]
|
||
}
|
||
|
||
// parseCardRows 解析卡数据行
|
||
// 自动检测表头并提取ICCID和MSISDN列
|
||
func parseCardRows(rows [][]string) (*CSVParseResult, error) {
|
||
result := &CSVParseResult{
|
||
Cards: make([]CardInfo, 0),
|
||
ParseErrors: make([]CSVParseError, 0),
|
||
}
|
||
|
||
headerSkipped := false
|
||
iccidCol, msisdnCol, virtualNoCol := -1, -1, -1
|
||
|
||
if len(rows) > 0 {
|
||
iccidCol, msisdnCol, virtualNoCol = findCardColumns(rows[0])
|
||
if iccidCol >= 0 && msisdnCol >= 0 {
|
||
headerSkipped = true
|
||
}
|
||
}
|
||
|
||
if virtualNoCol == -1 {
|
||
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "Excel 文件缺少 virtual_no 列,请使用最新模板")
|
||
}
|
||
|
||
startLine := 0
|
||
if headerSkipped {
|
||
startLine = 1
|
||
}
|
||
|
||
for i := startLine; i < len(rows); i++ {
|
||
row := rows[i]
|
||
lineNum := i + 1
|
||
|
||
if len(row) == 0 {
|
||
continue
|
||
}
|
||
|
||
iccid := ""
|
||
msisdn := ""
|
||
virtualNo := ""
|
||
|
||
if iccidCol >= 0 {
|
||
if iccidCol < len(row) {
|
||
iccid = strings.TrimSpace(row[iccidCol])
|
||
}
|
||
if msisdnCol < len(row) {
|
||
msisdn = strings.TrimSpace(row[msisdnCol])
|
||
}
|
||
if virtualNoCol >= 0 && virtualNoCol < len(row) {
|
||
virtualNo = strings.TrimSpace(row[virtualNoCol])
|
||
}
|
||
} else {
|
||
if len(row) >= 1 {
|
||
iccid = strings.TrimSpace(row[0])
|
||
}
|
||
if len(row) >= 2 {
|
||
msisdn = strings.TrimSpace(row[1])
|
||
}
|
||
}
|
||
|
||
result.TotalCount++
|
||
|
||
if iccid == "" && msisdn == "" {
|
||
continue
|
||
}
|
||
|
||
if iccid == "" {
|
||
result.ParseErrors = append(result.ParseErrors, CSVParseError{
|
||
Line: lineNum,
|
||
MSISDN: msisdn,
|
||
Reason: "ICCID不能为空",
|
||
})
|
||
continue
|
||
}
|
||
|
||
if msisdn == "" {
|
||
result.ParseErrors = append(result.ParseErrors, CSVParseError{
|
||
Line: lineNum,
|
||
ICCID: iccid,
|
||
Reason: "MSISDN不能为空",
|
||
})
|
||
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,
|
||
VirtualNo: virtualNo,
|
||
})
|
||
}
|
||
|
||
return result, nil
|
||
}
|
||
|
||
// findCardColumns 查找ICCID、MSISDN和VirtualNo列索引
|
||
// 支持中英文列名识别
|
||
func findCardColumns(header []string) (iccidCol, msisdnCol, virtualNoCol int) {
|
||
iccidCol, msisdnCol, virtualNoCol = -1, -1, -1
|
||
|
||
for i, col := range header {
|
||
colLower := strings.ToLower(strings.TrimSpace(col))
|
||
|
||
if colLower == "iccid" || colLower == "卡号" || colLower == "号码" {
|
||
if iccidCol == -1 {
|
||
iccidCol = i
|
||
}
|
||
}
|
||
|
||
if colLower == "msisdn" || colLower == "接入号" || colLower == "手机号" || colLower == "电话" {
|
||
if msisdnCol == -1 {
|
||
msisdnCol = i
|
||
}
|
||
}
|
||
|
||
if colLower == "virtual_no" || colLower == "virtualno" || colLower == "虚拟号" || colLower == "设备号" {
|
||
if virtualNoCol == -1 {
|
||
virtualNoCol = i
|
||
}
|
||
}
|
||
}
|
||
|
||
return iccidCol, msisdnCol, virtualNoCol
|
||
}
|
||
|
||
// buildDeviceColumnIndex 构建设备导入列索引
|
||
// 识别表头中的列名,返回列名到列索引的映射
|
||
// IMEI 列支持多种列名格式:imei/IMEI/设备IMEI/IMEI号
|
||
func buildDeviceColumnIndex(header []string) map[string]int {
|
||
index := map[string]int{
|
||
"virtual_no": -1,
|
||
"imei": -1,
|
||
"device_name": -1,
|
||
"device_model": -1,
|
||
"device_type": -1,
|
||
"max_sim_slots": -1,
|
||
"manufacturer": -1,
|
||
"iccid_1": -1,
|
||
"iccid_2": -1,
|
||
"iccid_3": -1,
|
||
"iccid_4": -1,
|
||
}
|
||
|
||
for i, col := range header {
|
||
normalized := strings.ToLower(strings.TrimSpace(col))
|
||
// 支持 IMEI 多种列名格式
|
||
switch normalized {
|
||
case "imei", "设备imei", "imei号":
|
||
if index["imei"] == -1 {
|
||
index["imei"] = i
|
||
}
|
||
default:
|
||
if _, exists := index[normalized]; exists {
|
||
index[normalized] = i
|
||
}
|
||
}
|
||
}
|
||
|
||
return index
|
||
}
|