feat(import): 用 Excel 格式替换 CSV 导入
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m33s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m33s
- 删除 CSV 解析代码,新增 Excel 解析器 (excelize) - 更新 IoT 卡和设备导入任务处理器 - 更新 API 路由文档和前端接入指南 - 归档变更到 openspec/changes/archive/ - 同步 delta specs 到 main specs
This commit is contained in:
341
pkg/utils/excel.go
Normal file
341
pkg/utils/excel.go
Normal file
@@ -0,0 +1,341 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
// CardInfo 卡信息(ICCID + MSISDN)
|
||||
type CardInfo struct {
|
||||
ICCID string
|
||||
MSISDN 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
|
||||
}
|
||||
|
||||
// DeviceRow 设备导入数据行
|
||||
type DeviceRow struct {
|
||||
Line int
|
||||
DeviceNo string
|
||||
DeviceName string
|
||||
DeviceModel string
|
||||
DeviceType string
|
||||
MaxSimSlots int
|
||||
Manufacturer string
|
||||
ICCIDs []string
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrExcelNoSheets Excel文件无工作表
|
||||
ErrExcelNoSheets = errors.New("Excel文件无工作表")
|
||||
// ErrExcelNoData Excel文件无数据行
|
||||
ErrExcelNoData = errors.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) ([]DeviceRow, int, error) {
|
||||
// 1. 打开Excel文件
|
||||
f, err := excelize.OpenFile(filePath)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("打开Excel失败: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := f.Close(); err != nil {
|
||||
// 日志记录关闭错误,但不影响解析结果
|
||||
}
|
||||
}()
|
||||
|
||||
// 2. 选择sheet
|
||||
sheetName := selectSheet(f)
|
||||
if sheetName == "" {
|
||||
return nil, 0, ErrExcelNoSheets
|
||||
}
|
||||
|
||||
// 3. 读取所有行
|
||||
rows, err := f.GetRows(sheetName)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("读取sheet失败: %w", err)
|
||||
}
|
||||
|
||||
if len(rows) < 2 {
|
||||
return nil, 0, ErrExcelNoData
|
||||
}
|
||||
|
||||
// 4. 解析表头行,构建列索引
|
||||
header := rows[0]
|
||||
colIndex := buildDeviceColumnIndex(header)
|
||||
|
||||
// 5. 解析数据行
|
||||
var deviceRows []DeviceRow
|
||||
for i := 1; i < len(rows); i++ {
|
||||
record := rows[i]
|
||||
lineNum := i + 1 // Excel行号从1开始,数据从第2行开始
|
||||
|
||||
row := DeviceRow{Line: lineNum}
|
||||
|
||||
// 提取各字段
|
||||
if idx := colIndex["device_no"]; idx >= 0 && idx < len(record) {
|
||||
row.DeviceNo = 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])
|
||||
}
|
||||
|
||||
// 提取ICCID (iccid_1 ~ iccid_4)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 跳过设备号为空的行
|
||||
if row.DeviceNo == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 默认最大插槽数为4
|
||||
if row.MaxSimSlots == 0 {
|
||||
row.MaxSimSlots = 4
|
||||
}
|
||||
|
||||
deviceRows = append(deviceRows, row)
|
||||
}
|
||||
|
||||
return deviceRows, len(deviceRows), 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),
|
||||
}
|
||||
|
||||
// 检测表头 (第1行)
|
||||
headerSkipped := false
|
||||
iccidCol, msisdnCol := -1, -1
|
||||
|
||||
if len(rows) > 0 {
|
||||
iccidCol, msisdnCol = findCardColumns(rows[0])
|
||||
if iccidCol >= 0 && msisdnCol >= 0 {
|
||||
headerSkipped = true
|
||||
}
|
||||
}
|
||||
|
||||
// 确定数据开始行
|
||||
startLine := 0
|
||||
if headerSkipped {
|
||||
startLine = 1
|
||||
}
|
||||
|
||||
// 解析数据行
|
||||
for i := startLine; i < len(rows); i++ {
|
||||
row := rows[i]
|
||||
lineNum := i + 1 // Excel行号从1开始
|
||||
|
||||
// 跳过空行
|
||||
if len(row) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// 提取字段
|
||||
iccid := ""
|
||||
msisdn := ""
|
||||
|
||||
if iccidCol >= 0 {
|
||||
// 有表头,使用列索引
|
||||
if iccidCol < len(row) {
|
||||
iccid = strings.TrimSpace(row[iccidCol])
|
||||
}
|
||||
if msisdnCol < len(row) {
|
||||
msisdn = strings.TrimSpace(row[msisdnCol])
|
||||
}
|
||||
} else {
|
||||
// 无表头,假设第一列ICCID,第二列MSISDN
|
||||
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
|
||||
}
|
||||
|
||||
result.Cards = append(result.Cards, CardInfo{
|
||||
ICCID: iccid,
|
||||
MSISDN: msisdn,
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// findCardColumns 查找ICCID和MSISDN列索引
|
||||
// 支持中英文列名识别
|
||||
func findCardColumns(header []string) (iccidCol, msisdnCol int) {
|
||||
iccidCol, msisdnCol = -1, -1
|
||||
|
||||
for i, col := range header {
|
||||
colLower := strings.ToLower(strings.TrimSpace(col))
|
||||
|
||||
// 识别ICCID列
|
||||
if colLower == "iccid" || colLower == "卡号" || colLower == "号码" {
|
||||
if iccidCol == -1 { // 只取第一个匹配
|
||||
iccidCol = i
|
||||
}
|
||||
}
|
||||
|
||||
// 识别MSISDN列
|
||||
if colLower == "msisdn" || colLower == "接入号" || colLower == "手机号" || colLower == "电话" {
|
||||
if msisdnCol == -1 { // 只取第一个匹配
|
||||
msisdnCol = i
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return iccidCol, msisdnCol
|
||||
}
|
||||
|
||||
// buildDeviceColumnIndex 构建设备导入列索引
|
||||
// 识别表头中的列名,返回列名到列索引的映射
|
||||
func buildDeviceColumnIndex(header []string) map[string]int {
|
||||
index := map[string]int{
|
||||
"device_no": -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 {
|
||||
col = strings.ToLower(strings.TrimSpace(col))
|
||||
if _, exists := index[col]; exists {
|
||||
index[col] = i
|
||||
}
|
||||
}
|
||||
|
||||
return index
|
||||
}
|
||||
Reference in New Issue
Block a user