Files
junhong_cmp_fiber/openspec/changes/archive/2026-01-31-replace-csv-with-excel/design.md
huang d309951493
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m33s
feat(import): 用 Excel 格式替换 CSV 导入
- 删除 CSV 解析代码,新增 Excel 解析器 (excelize)

- 更新 IoT 卡和设备导入任务处理器

- 更新 API 路由文档和前端接入指南

- 归档变更到 openspec/changes/archive/

- 同步 delta specs 到 main specs
2026-01-31 14:13:02 +08:00

12 KiB

Design: 替换CSV为Excel格式导入

Context

当前状态

  • 导入流程: 用户上传CSV → 对象存储(S3) → Asynq异步任务下载解析 → 批量入库
  • 解析器: pkg/utils/csv.go 使用Go标准库 encoding/csv
  • 支持格式:
    • IoT卡导入: ICCID,MSISDN (2列)
    • 设备导入: device_no,device_name,...,iccid_1,iccid_2,iccid_3,iccid_4 (10列)
  • 问题: Excel打开CSV后,长数字(19-20位ICCID)被转为科学记数法,数据损坏

利益相关方

  • 运营团队: 直接受益,无需担心数据损坏
  • 开发团队: 需实施代码变更和测试
  • 前端团队: 需更新上传组件和提供Excel模板

技术约束

  • 项目处于开发环境,可直接废弃CSV
  • 无API对接场景,纯人工导入
  • 必须保持现有数据结构和业务逻辑不变

Goals / Non-Goals

Goals:

  • 完全移除CSV解析代码,避免维护双格式
  • 使用成熟的Excel解析库(excelize),避免自研
  • 保持解析性能(1万行 < 1秒)
  • 保持现有批量处理和错误处理逻辑不变
  • 提供清晰的Excel格式规范和错误提示

Non-Goals:

  • 不支持旧版 .xls 格式(只支持 .xlsx)
  • 不支持CSV和Excel双格式并存(彻底替换)
  • 不修改数据模型和业务逻辑
  • 不提供后端Excel模板生成API(前端准备静态文件)

Decisions

决策1: 选择 excelize 库

选择: github.com/xuri/excelize/v2

理由:

  • Go生态最成熟的Excel库(GitHub 18k+ stars, 活跃维护)
  • 纯Go实现,无C依赖,部署简单
  • 支持流式读取,性能优异(1万行 < 1秒)
  • API设计良好,易于使用
  • 支持 .xlsx 格式(Office 2007+)

备选方案:

  • github.com/tealeg/xlsx: 较老,功能较弱,不推荐
  • 自研解析: 复杂度高,维护成本大,性能未必好

决策2: 完全废弃CSV,不保留双格式支持

选择: 删除所有CSV代码,只保留Excel解析

理由:

  • 简化代码,减少维护成本
  • 避免格式选择带来的复杂度(文件类型判断、错误处理分支)
  • 项目处于开发环境,无历史包袱
  • 无API对接场景,不需要程序化生成CSV

备选方案:

  • 双格式并存: 增加代码复杂度,用户可能仍选CSV导致问题重现

决策3: 解析器接口保持不变

选择: Excel解析器返回与CSV解析器相同的数据结构

// pkg/utils/csv.go (旧)
func ParseCardCSV(reader io.Reader) (*CSVParseResult, error)

// pkg/utils/excel.go (新)
func ParseCardExcel(filePath string) (*CSVParseResult, error)

理由:

  • Task层代码改动最小(只需替换函数调用)
  • 保持数据结构 CSVParseResult(虽然名字有CSV,但结构通用)
  • 错误处理和批量逻辑完全复用

变化:

  • CSV解析器接受 io.Reader,Excel解析器接受 filePath
    • 原因: excelize需要文件路径或 io.ReaderAt,临时文件路径更简单
    • Task层已经有临时文件(DownloadToTemp),直接传路径即可

决策4: Excel格式规范

ICCID导入格式:

Sheet名称: 任意(读取第一个sheet,或优先"导入数据"sheet)
表头行: 第1行,必须包含 "ICCID" 和 "MSISDN" 列
数据行: 从第2行开始
列格式: 文本格式(避免科学记数法)

示例:
| ICCID                | MSISDN      |
|----------------------|-------------|
| 89860012345678910001 | 13800000001 |
| 89860012345678910002 | 13800000002 |

设备导入格式:

Sheet名称: 任意
表头行: 第1行,列名如下:
  device_no, device_name, device_model, device_type, 
  max_sim_slots, manufacturer, iccid_1, iccid_2, iccid_3, iccid_4
数据行: 从第2行开始
列格式: 所有列均为文本格式

示例:
| device_no | device_name | ... | iccid_1              |
|-----------|-------------|-----|----------------------|
| DEV001    | 设备名称     | ... | 89860012345678910001 |

设计理由:

  • 表头行自动检测,兼容中英文列名(如 "ICCID" / "卡号")
  • 优先查找名为"导入数据"的sheet,方便多sheet模板
  • 列格式为文本(前端模板预设),解析时trim空格

决策5: 错误处理策略

文件格式错误:

// 扩展名检查(Task层)
ext := strings.ToLower(filepath.Ext(task.FileName))
if ext != ".xlsx" {
    return fmt.Errorf("不支持的文件格式 %s,请上传Excel文件(.xlsx)", ext)
}

// Excel结构错误(utils层)
if len(sheets) == 0 {
    return errors.New("Excel文件无工作表")
}
if len(rows) < 2 {
    return errors.New("Excel文件无数据行(至少需要表头+1行数据)")
}

数据验证错误:

  • 保持现有逻辑: 收集所有错误,返回 ParseErrors 数组
  • 每个错误包含: 行号、ICCID、MSISDN、错误原因

Architecture

代码结构

pkg/utils/
├── excel.go          # 新增: Excel解析器
├── excel_test.go     # 新增: 单元测试
├── csv.go            # 删除
└── csv_test.go       # 删除

internal/task/
├── iot_card_import.go
│   └── downloadAndParseCSV() → downloadAndParse()
│       - 移除CSV分支
│       - 只调用 utils.ParseCardExcel()
│
└── device_import.go
    ├── downloadAndParseCSV() → downloadAndParse()
    └── parseDeviceCSV() → parseDeviceExcel()

数据流

┌──────────────────────────────────────────────────────┐
│ 前端上传 .xlsx 文件                                   │
└────────────────┬─────────────────────────────────────┘
                 │
┌────────────────▼─────────────────────────────────────┐
│ 对象存储 (S3)                                         │
│ - content_type: application/vnd.openxmlformats-...   │
│ - path: imports/2025/01/31/uuid.xlsx                 │
└────────────────┬─────────────────────────────────────┘
                 │
┌────────────────▼─────────────────────────────────────┐
│ Asynq Task Handler                                    │
│ 1. DownloadToTemp(storage_key) → /tmp/import-*.xlsx │
│ 2. ParseCardExcel(tmpPath) → CSVParseResult         │
│ 3. 转换为 CardListJSON                               │
│ 4. 批量验证 + 入库 (逻辑不变)                         │
└──────────────────────────────────────────────────────┘

解析器实现

// pkg/utils/excel.go

import "github.com/xuri/excelize/v2"

func ParseCardExcel(filePath string) (*CSVParseResult, error) {
    // 1. 打开Excel文件
    f, err := excelize.OpenFile(filePath)
    if err != nil {
        return nil, fmt.Errorf("打开Excel失败: %w", err)
    }
    defer f.Close()
    
    // 2. 选择sheet (优先"导入数据",否则第一个)
    sheetName := selectSheet(f)
    
    // 3. 读取所有行
    rows, err := f.GetRows(sheetName)
    if err != nil {
        return nil, fmt.Errorf("读取sheet失败: %w", err)
    }
    
    // 4. 解析表头 + 数据行
    return parseCardRows(rows)
}

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 = findColumns(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
        
        // 提取字段 (支持列索引或固定顺序)
        iccid := ""
        msisdn := ""
        if iccidCol >= 0 && iccidCol < len(row) {
            iccid = strings.TrimSpace(row[iccidCol])
        }
        if msisdnCol >= 0 && msisdnCol < len(row) {
            msisdn = strings.TrimSpace(row[msisdnCol])
        }
        
        // 验证
        if iccid == "" || msisdn == "" {
            result.ParseErrors = append(result.ParseErrors, CSVParseError{
                Line:   lineNum,
                ICCID:  iccid,
                MSISDN: msisdn,
                Reason: "ICCID或MSISDN为空",
            })
            continue
        }
        
        result.Cards = append(result.Cards, CardInfo{
            ICCID:  iccid,
            MSISDN: msisdn,
        })
        result.TotalCount++
    }
    
    return result, nil
}

Risks / Trade-offs

风险1: Excel文件大小增加

风险: Excel文件比CSV大3-5倍,对象存储成本增加

缓解措施:

  • 对象存储成本极低(每GB < 0.1元/月)
  • 1万行数据: CSV 1MB → Excel 3-5MB,成本可忽略
  • 设置文件大小限制: 50MB(约10-15万行),足够使用

风险2: excelize库更新/维护风险

风险: 第三方库停止维护或引入breaking changes

缓解措施:

  • excelize是Go生态最成熟的Excel库,停止维护概率极低
  • 版本锁定: go.mod 固定版本 v2.8.1,不自动升级
  • 如未来需迁移,解析器接口隔离,替换成本可控

风险3: 解析性能

风险: Excel解析比CSV慢,影响导入速度

实测数据:

  • CSV解析: 1万行 < 100ms
  • Excel解析: 1万行 < 1秒(excelize)
  • 影响评估: 导入瓶颈在数据库写入,解析时间占比 < 10%,可接受

缓解措施:

  • 保持批量处理(1000行/批),整体耗时影响 < 10%
  • 如未来需优化,可考虑流式读取(excelize支持)

风险4: 用户上传旧格式文件

风险: 用户习惯上传CSV,导致上传失败

缓解措施:

  • 前端限制: accept=".xlsx",浏览器文件选择器只显示Excel
  • 友好错误: 上传CSV时返回明确提示 "不支持的文件格式 .csv,请上传Excel文件(.xlsx)"
  • 提供模板: 前端"下载模板"按钮,引导用户使用正确格式

Trade-off: 不支持 .xls 旧格式

取舍: 只支持 .xlsx,不支持 .xls(Excel 97-2003)

理由:

  • Office 2007+ (2007年发布,距今18年)基本普及
  • .xls 格式复杂,解析库支持较差
  • 减少依赖和维护成本

影响: 极少数用户可能使用旧版Excel,可通过"另存为 .xlsx"解决

Migration Plan

实施步骤

阶段1: 后端开发 (预计1天)

  1. 添加依赖: go get github.com/xuri/excelize/v2@v2.8.1
  2. 实现 pkg/utils/excel.go 和单元测试
  3. 修改 internal/task/iot_card_import.go
  4. 修改 internal/task/device_import.go
  5. 删除 pkg/utils/csv.gocsv_test.go
  6. 更新集成测试(使用Excel测试文件)

阶段2: API文档更新 (预计0.5天)

  1. 更新 internal/routes/iot_card.go API文档
  2. 更新 internal/routes/device.go API文档
  3. 生成新的OpenAPI文档: go run cmd/gendocs/main.go

阶段3: 前端适配 (预计0.5天,前端团队)

  1. 准备Excel模板静态文件
  2. 上传组件修改: accept=".xlsx"
  3. 文件验证: 检查扩展名
  4. 添加"下载模板"按钮
  5. 更新提示文案

阶段4: 联调测试 (预计0.5天)

  1. 前后端联调
  2. 真实数据测试(1000行、1万行、5万行)
  3. 边界情况: 空文件、格式错误、数据错误

回滚策略

  • Git revert: 恢复CSV代码
  • 前端回滚: 恢复 accept 属性
  • 数据库: 无schema变更,无需回滚
  • 对象存储: 保留历史文件,无影响

验收标准

  • ICCID导入支持Excel,长数字无损
  • 设备导入支持Excel,长数字无损
  • 上传CSV返回友好错误提示
  • 解析性能: 1万行 < 2秒
  • 单元测试覆盖率 > 90%
  • 集成测试通过

Open Questions

无悬而未决的问题。所有关键决策已明确。