All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m33s
- 删除 CSV 解析代码,新增 Excel 解析器 (excelize) - 更新 IoT 卡和设备导入任务处理器 - 更新 API 路由文档和前端接入指南 - 归档变更到 openspec/changes/archive/ - 同步 delta specs 到 main specs
370 lines
12 KiB
Markdown
370 lines
12 KiB
Markdown
# 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解析器相同的数据结构
|
|
|
|
```go
|
|
// 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: 错误处理策略
|
|
|
|
**文件格式错误**:
|
|
```go
|
|
// 扩展名检查(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. 批量验证 + 入库 (逻辑不变) │
|
|
└──────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
### 解析器实现
|
|
```go
|
|
// 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.go` 和 `csv_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
|
|
|
|
无悬而未决的问题。所有关键决策已明确。
|