Compare commits
2 Commits
0627ffec42
...
cd807e58e3
| Author | SHA1 | Date | |
|---|---|---|---|
| cd807e58e3 | |||
| ff7e749bf2 |
@@ -63,9 +63,10 @@ type ListStandaloneIotCardResponse struct {
|
||||
}
|
||||
|
||||
type ImportIotCardRequest struct {
|
||||
CarrierID uint `json:"carrier_id" validate:"required,min=1" required:"true" minimum:"1" description:"运营商ID"`
|
||||
BatchNo string `json:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号"`
|
||||
FileKey string `json:"file_key" validate:"required,min=1,max=500" required:"true" minLength:"1" maxLength:"500" description:"对象存储文件路径(通过 /storage/upload-url 获取)"`
|
||||
CarrierID uint `json:"carrier_id" validate:"required,min=1" required:"true" minimum:"1" description:"运营商ID"`
|
||||
BatchNo string `json:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号"`
|
||||
FileKey string `json:"file_key" validate:"required,min=1,max=500" required:"true" minLength:"1" maxLength:"500" description:"对象存储文件路径(通过 /storage/upload-url 获取)"`
|
||||
CardCategory string `json:"card_category" validate:"omitempty,oneof=normal industry" description:"卡业务类型 (normal:普通卡, industry:行业卡),默认 normal"`
|
||||
}
|
||||
|
||||
type ImportIotCardResponse struct {
|
||||
@@ -93,6 +94,7 @@ type ImportTaskResponse struct {
|
||||
CarrierType string `json:"carrier_type,omitempty" description:"运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)"`
|
||||
CarrierName string `json:"carrier_name,omitempty" description:"运营商名称"`
|
||||
BatchNo string `json:"batch_no,omitempty" description:"批次号"`
|
||||
CardCategory string `json:"card_category" description:"卡业务类型 (normal:普通卡, industry:行业卡)"`
|
||||
FileName string `json:"file_name,omitempty" description:"文件名"`
|
||||
TotalCount int `json:"total_count" description:"总数"`
|
||||
SuccessCount int `json:"success_count" description:"成功数"`
|
||||
|
||||
@@ -31,6 +31,7 @@ type IotCardImportTask struct {
|
||||
CardList CardListJSON `gorm:"column:card_list;type:jsonb;comment:待导入卡列表[{iccid,msisdn}]" json:"-"`
|
||||
StorageBucket string `gorm:"column:storage_bucket;type:varchar(100);comment:对象存储桶名" json:"storage_bucket,omitempty"`
|
||||
StorageKey string `gorm:"column:storage_key;type:varchar(500);comment:对象存储文件路径" json:"storage_key,omitempty"`
|
||||
CardCategory string `gorm:"column:card_category;type:varchar(20);default:normal;not null;comment:卡业务类型(normal/industry)" json:"card_category"`
|
||||
}
|
||||
|
||||
// CardItem 卡信息(ICCID + MSISDN + VirtualNo)
|
||||
|
||||
@@ -70,15 +70,21 @@ func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportIotCardRe
|
||||
taskNo := s.importTaskStore.GenerateTaskNo(ctx)
|
||||
fileName := filepath.Base(req.FileKey)
|
||||
|
||||
cardCategory := req.CardCategory
|
||||
if cardCategory == "" {
|
||||
cardCategory = constants.CardCategoryNormal
|
||||
}
|
||||
|
||||
task := &model.IotCardImportTask{
|
||||
TaskNo: taskNo,
|
||||
Status: model.ImportTaskStatusPending,
|
||||
CarrierID: req.CarrierID,
|
||||
CarrierType: carrier.CarrierType,
|
||||
CarrierName: carrier.CarrierName,
|
||||
BatchNo: req.BatchNo,
|
||||
FileName: fileName,
|
||||
StorageKey: req.FileKey,
|
||||
TaskNo: taskNo,
|
||||
Status: model.ImportTaskStatusPending,
|
||||
CarrierID: req.CarrierID,
|
||||
CarrierType: carrier.CarrierType,
|
||||
CarrierName: carrier.CarrierName,
|
||||
BatchNo: req.BatchNo,
|
||||
FileName: fileName,
|
||||
StorageKey: req.FileKey,
|
||||
CardCategory: cardCategory,
|
||||
}
|
||||
task.Creator = userID
|
||||
task.Updater = userID
|
||||
@@ -202,6 +208,7 @@ func (s *Service) toTaskResponse(task *model.IotCardImportTask) *dto.ImportTaskR
|
||||
CarrierType: task.CarrierType,
|
||||
CarrierName: task.CarrierName,
|
||||
BatchNo: task.BatchNo,
|
||||
CardCategory: task.CardCategory,
|
||||
FileName: task.FileName,
|
||||
TotalCount: task.TotalCount,
|
||||
SuccessCount: task.SuccessCount,
|
||||
|
||||
@@ -354,7 +354,7 @@ func (h *IotCardImportHandler) processBatch(ctx context.Context, task *model.Iot
|
||||
CarrierName: task.CarrierName,
|
||||
BatchNo: task.BatchNo,
|
||||
Status: constants.IotCardStatusInStock,
|
||||
CardCategory: constants.CardCategoryNormal,
|
||||
CardCategory: task.CardCategory,
|
||||
ActivationStatus: constants.ActivationStatusInactive,
|
||||
RealNameStatus: constants.RealNameStatusNotVerified,
|
||||
NetworkStatus: constants.NetworkStatusOffline,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 回滚:删除 card_category 列
|
||||
ALTER TABLE tb_iot_card_import_task
|
||||
DROP COLUMN IF EXISTS card_category;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- 为 tb_iot_card_import_task 新增 card_category 列,记录导入批次的卡业务类型
|
||||
ALTER TABLE tb_iot_card_import_task
|
||||
ADD COLUMN card_category VARCHAR(20) NOT NULL DEFAULT 'normal';
|
||||
|
||||
COMMENT ON COLUMN tb_iot_card_import_task.card_category IS '卡业务类型(normal:普通卡/industry:行业卡)';
|
||||
2
openspec/changes/excel-import-refactor/.openspec.yaml
Normal file
2
openspec/changes/excel-import-refactor/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-04-09
|
||||
57
openspec/changes/excel-import-refactor/design.md
Normal file
57
openspec/changes/excel-import-refactor/design.md
Normal file
@@ -0,0 +1,57 @@
|
||||
## Context
|
||||
|
||||
当前 IoT 卡和设备的 Excel 导入依赖列名识别逻辑(`findCardColumns` / `buildDeviceColumnIndex`)。业务侧要求改用中文表头,但设备导入的大多数列名只支持英文,导致中文表头文件解析全部失败。
|
||||
|
||||
同时,IoT 卡导入时 `card_category` 写死为 `normal`,无法在导入批次中指定行业卡。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Excel 导入不依赖表头内容,按固定列位置读取,支持任意语言表头
|
||||
- 锁定 IoT 卡和设备导入的列顺序为业务确认版本
|
||||
- IoT 卡导入支持批次级别的 `card_category`(行业卡/普通卡)
|
||||
|
||||
**Non-Goals:**
|
||||
- 不支持列顺序自定义(固定列位置就是合约)
|
||||
- 不支持每行独立设置 `card_category`(批次统一即可)
|
||||
- 不修改导入的其他业务逻辑(校验规则、权限等不变)
|
||||
|
||||
## Decisions
|
||||
|
||||
### 决策1:按列位置读取,完全移除列名识别
|
||||
|
||||
**选择**: 直接按列索引取值,第一行永远跳过(表头行)。
|
||||
|
||||
**备选方案**: 在现有映射中添加中文列名别名。
|
||||
|
||||
**理由**: 中文别名方案需要为每个字段定义多套名称(中文/英文/变体),维护成本高且仍然脆弱(业务随时可能换叫法)。按位置读取更简单,也更明确——模板即契约,列顺序固定。
|
||||
|
||||
### 决策2:card_category 放在请求参数而非 Excel 列
|
||||
|
||||
**选择**: 在 `ImportIotCardRequest` 和 `IotCardImportTask` 中加 `card_category` 字段,不在 Excel 里加列。
|
||||
|
||||
**理由**: 业务现实是一批卡要么全是行业卡,要么全是普通卡,不会混批。前端用下拉选择比 Excel 里填枚举值更直观,也不影响 Excel 列顺序。
|
||||
|
||||
### 决策3:card_category 默认值为 normal
|
||||
|
||||
**选择**: 不传时默认 `normal`(普通卡)。
|
||||
|
||||
**理由**: 存量接口调用不需要修改,向下兼容。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[风险] 模板纪律性**:按位置读取意味着列顺序必须严格匹配。用户如果自行调整 Excel 列顺序,数据会静默读错(没有报错提示)。→ 缓解:提供官方模板下载,文档中明确说明列顺序不可更改。
|
||||
- **[权衡] 丢失了列名容错性**:现有逻辑允许列顺序任意,改后不再支持。接受此代价,换取代码简单和中文支持。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 更新 `pkg/utils/excel.go`,替换解析逻辑(按列位置取值,删除 `findCardColumns` / `buildDeviceColumnIndex`)
|
||||
2. 为 `tb_iot_card_import_task` 创建迁移,新增 `card_category VARCHAR(20) NOT NULL DEFAULT 'normal'` 列
|
||||
3. 更新 DTO(`ImportIotCardRequest` 加 `card_category`)、model(`IotCardImportTask` 加 `CardCategory`)、service(`CreateImportTask` 将请求字段写入 task)、task handler(创建 IoT 卡时使用 `task.CardCategory` 替代常量)
|
||||
4. **同步更新导入模板文件**:IoT 卡、设备的 `.xlsx` 模板列顺序需与新实现严格对齐;如模板由前端维护,需在变更交接时通知前端同步
|
||||
|
||||
**说明**:卡主表 `tb_iot_card` 早有 `card_category` 字段,本次迁移仅为批次级选择提供"载体列",让 Worker 从 DB 任务行读取批次类型,而非从 Asynq 载荷传递(保持现有"载荷只带 TaskID"的设计一致性)。
|
||||
|
||||
## Open Questions
|
||||
|
||||
(无)
|
||||
31
openspec/changes/excel-import-refactor/proposal.md
Normal file
31
openspec/changes/excel-import-refactor/proposal.md
Normal file
@@ -0,0 +1,31 @@
|
||||
## Why
|
||||
|
||||
业务侧要求 Excel 导入模板使用中文表头,但现有实现依赖英文列名匹配,导致中文表头的文件无法正确导入。同时,IoT 卡导入时卡业务类型(行业卡/普通卡)写死为普通卡,无法在导入批次中指定行业卡。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Excel 按列位置读取**:移除基于列名识别的逻辑,改为按固定列索引取值,第一行固定作为表头跳过,表头内容不再影响解析结果
|
||||
- **删除冗余函数**:删除 `findCardColumns()` 和 `buildDeviceColumnIndex()` 两个函数
|
||||
- **设备列顺序调整**:设备导入列顺序从原来的排列调整为业务确认的顺序(IMEI 提前至第5列)
|
||||
- **IoT 卡导入支持卡业务类型**:`ImportIotCardRequest` 新增 `card_category` 字段(批次级别),导入时按请求参数设置,默认为 `normal`
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
(无新能力)
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `iot-card-import-task`:导入请求新增 `card_category` 字段,任务模型同步新增该字段,导入时按批次统一设置卡业务类型
|
||||
- `device-import`:移除列名识别逻辑,改为按固定列位置读取,列顺序调整为业务确认顺序
|
||||
|
||||
## Impact
|
||||
|
||||
- `pkg/utils/excel.go`:核心解析逻辑重写(按列位置取值),删除 `findCardColumns` / `buildDeviceColumnIndex` 两个函数
|
||||
- `internal/model/dto/iot_card_dto.go`:`ImportIotCardRequest` 新增 `CardCategory` 字段(带 `oneof=normal industry` 校验),`ImportTaskResponse` 新增 `card_category` 返回
|
||||
- `internal/model/iot_card_import_task.go`:`IotCardImportTask` 新增 `CardCategory` 字段
|
||||
- `migrations/`:新增迁移为 `tb_iot_card_import_task` 增加 `card_category` 列(注:卡主表 `tb_iot_card` 早已有该字段,本次只补"批次级载体列")
|
||||
- `internal/service/iot_card_import/service.go`:`CreateImportTask` 将 `req.CardCategory` 写入 task,空串兜底为 `normal`
|
||||
- `internal/task/iot_card_import.go`:使用 `task.CardCategory` 替代写死的 `CardCategoryNormal`
|
||||
- **模板文件**:需同步更新 IoT 卡、设备导入模板 `.xlsx`,确保列顺序与代码一致(高风险:列顺序不匹配会导致数据静默错位)
|
||||
@@ -0,0 +1,101 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 设备批量导入
|
||||
|
||||
系统 SHALL 提供设备批量导入功能,通过 Excel 文件导入设备并自动绑定卡,按固定列位置读取,表头行内容不影响解析结果,仅平台用户可操作。
|
||||
|
||||
**API 端点**: `POST /api/admin/devices/import`
|
||||
|
||||
**请求参数**:
|
||||
- `batch_no`: 批次号(必填)
|
||||
- `file_key`: 对象存储文件路径(必填,通过 /storage/upload-url 获取)
|
||||
|
||||
**Excel 格式**:
|
||||
- **文件格式**: 仅支持 `.xlsx` (Excel 2007+)
|
||||
- **Sheet**: 读取第一个sheet,或优先读取名为"导入数据"的sheet
|
||||
- **表头行**: 第1行固定为表头,永远跳过,内容不限(中文、英文均可)
|
||||
- **列位置(固定,不可变)**:
|
||||
```
|
||||
第1列(索引0): 虚拟号(必填,全局唯一)
|
||||
第2列(索引1): 设备名称(可选)
|
||||
第3列(索引2): 设备型号(可选)
|
||||
第4列(索引3): 设备类型(可选)
|
||||
第5列(索引4): IMEI(可选)
|
||||
第6列(索引5): 制造商(可选)
|
||||
第7列(索引6): 最大SIM槽数(可选,默认4,范围1-4)
|
||||
第8列(索引7): 卡1 ICCID(可选)
|
||||
第9列(索引8): 卡2 ICCID(可选)
|
||||
第10列(索引9): 卡3 ICCID(可选)
|
||||
第11列(索引10): 卡4 ICCID(可选)
|
||||
```
|
||||
- **列格式**: 所有列应设置为文本格式(避免数字被转为科学记数法)
|
||||
|
||||
**失败原因文本**:
|
||||
- VirtualNo 为空:`"设备虚拟号(virtual_no)不能为空"`
|
||||
- MaxSimSlots 越界:`"最大SIM槽数必须在1-4之间"`
|
||||
|
||||
**导入规则**:
|
||||
- 按列索引取值,不识别列名,第一行永远跳过
|
||||
- 若整行目标列皆为空,视为空行跳过,不计入 total,不计入失败
|
||||
- MaxSimSlots 为空或 0 回填默认值 4;非空且不在 [1,4] 记录为失败
|
||||
- 导入的设备 shop_id = NULL(平台库存)
|
||||
- 导入的设备 status = 1(在库)
|
||||
- 设备号重复则该行跳过
|
||||
- ICCID 必须已存在于系统中(先导入卡,再导入设备)
|
||||
- ICCID 不存在则该行失败
|
||||
- ICCID 已绑定其他设备则该行失败
|
||||
- 导入通过异步任务处理,立即返回任务 ID
|
||||
|
||||
**权限**: 仅平台用户
|
||||
|
||||
**响应**:
|
||||
- `task_id`: 导入任务 ID
|
||||
- `task_no`: 任务编号
|
||||
- `message`: 提示信息
|
||||
|
||||
#### Scenario: 提交设备导入任务
|
||||
|
||||
- **WHEN** 平台管理员上传 Excel 文件并提交导入请求
|
||||
- **THEN** 系统创建导入任务,返回任务 ID,开始异步处理
|
||||
|
||||
#### Scenario: 中文表头正常导入
|
||||
|
||||
- **GIVEN** Excel 文件第1行表头为 `虚拟号 | 设备名称 | 设备型号 | 设备类型 | IMEI | 制造商 | 最大SIM槽数 | 卡1 | 卡2 | 卡3 | 卡4`
|
||||
- **WHEN** 系统解析该 Excel 文件
|
||||
- **THEN** 系统跳过第1行,从第2行开始按列位置解析数据
|
||||
|
||||
#### Scenario: 代理尝试导入设备
|
||||
|
||||
- **WHEN** 代理用户尝试导入设备
|
||||
- **THEN** 系统返回 403 错误,提示"无权限执行此操作"
|
||||
|
||||
#### Scenario: 文件格式错误
|
||||
|
||||
- **WHEN** 平台管理员上传非 Excel 格式(.xlsx)的文件
|
||||
- **THEN** 系统创建任务但处理失败,任务状态为"失败",错误信息为"不支持的文件格式 .csv,请上传Excel文件(.xlsx)"
|
||||
|
||||
#### Scenario: Excel结构错误
|
||||
|
||||
- **WHEN** 平台管理员上传的Excel文件无工作表或无数据行
|
||||
- **THEN** 系统创建任务但处理失败,任务状态为"失败",记录相应错误信息
|
||||
|
||||
#### Scenario: VirtualNo 为空的行记录为失败
|
||||
|
||||
- **WHEN** Excel 中某行第1列(虚拟号)为空
|
||||
- **THEN** 该行计入失败(`fail_count++`),失败原因为"设备虚拟号(virtual_no)不能为空"
|
||||
- **THEN** 其他合法行继续导入,不因此行中断
|
||||
|
||||
#### Scenario: ICCID 不存在
|
||||
|
||||
- **WHEN** Excel 中某行的 ICCID 在系统中不存在
|
||||
- **THEN** 该行导入失败,记录失败原因"ICCID 不存在"
|
||||
|
||||
#### Scenario: ICCID 已绑定其他设备
|
||||
|
||||
- **WHEN** Excel 中某行的 ICCID 已绑定到其他设备
|
||||
- **THEN** 该行导入失败,记录失败原因"ICCID 已绑定其他设备"
|
||||
|
||||
#### Scenario: 设备号重复
|
||||
|
||||
- **WHEN** Excel 中某行的设备号在系统中已存在
|
||||
- **THEN** 该行被跳过,记录跳过原因"设备号已存在"
|
||||
@@ -0,0 +1,98 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Excel 文件格式规范
|
||||
|
||||
系统 SHALL 要求 Excel 文件必须包含 ICCID、MSISDN、虚拟号三列,按固定列位置读取,表头行内容不影响解析结果。
|
||||
|
||||
**文件格式要求**:
|
||||
- **文件格式**: 仅支持 `.xlsx` (Excel 2007+)
|
||||
- **Sheet**: 读取第一个sheet,或优先读取名为"导入数据"的sheet
|
||||
- **表头行**: 第1行固定为表头,永远跳过,内容不限(中文、英文均可)
|
||||
- **列位置(固定,不可变)**:
|
||||
- 第1列(索引0): ICCID
|
||||
- 第2列(索引1): MSISDN
|
||||
- 第3列(索引2): 虚拟号(必填)
|
||||
- **列格式**: 应设置为文本格式(避免长数字被转为科学记数法)
|
||||
|
||||
**解析规则**:
|
||||
- 按列索引取值,不识别列名,第一行永远跳过
|
||||
- 自动去除单元格首尾空格
|
||||
- 若 ICCID、MSISDN、virtual_no 三列皆为空,视为空行跳过,不计入 total,不计入失败
|
||||
- ICCID 为空(但其他列非空)的行记录为失败,失败原因为"ICCID 不能为空"
|
||||
- MSISDN 为空(但其他列非空)的行记录为失败,失败原因为"MSISDN 不能为空"
|
||||
- virtual_no(第3列)为空的行记录为失败,失败原因为"虚拟号(virtual_no)不能为空"
|
||||
|
||||
**virtual_no 导入规则**:
|
||||
- virtual_no 为必填,为空则该行记录为失败
|
||||
- virtual_no 全局唯一(跨卡和设备),重复则失败,原因为"虚拟号已被占用: <值>"
|
||||
|
||||
#### Scenario: 正常导入(ICCID 和 VirtualNo 均有值)
|
||||
|
||||
- **WHEN** Excel 中某行第1列 ICCID="898600XXXXX",第3列 virtual_no="CARD-001",第2列 msisdn="13800000001"
|
||||
- **THEN** 导入成功,卡记录写入数据库,VirtualNo 和 ICCID 同步注册到 `tb_asset_identifier`
|
||||
|
||||
#### Scenario: 中文表头正常导入
|
||||
|
||||
- **GIVEN** Excel 文件第1行表头为 `ICCID | 接入号 | 虚拟号`(任意中文或英文内容)
|
||||
- **WHEN** 系统解析该 Excel 文件
|
||||
- **THEN** 系统跳过第1行,从第2行开始按列位置解析数据
|
||||
|
||||
#### Scenario: VirtualNo 为空的行被拒绝
|
||||
|
||||
- **WHEN** Excel 中某行第3列为空
|
||||
- **THEN** 该行计入失败,失败原因为"虚拟号(virtual_no)不能为空"
|
||||
- **THEN** 其他合法行继续导入,不因此行中断
|
||||
|
||||
#### Scenario: VirtualNo 重复被拒绝
|
||||
|
||||
- **WHEN** Excel 中某行第3列的值与已有卡/设备的 VirtualNo 重复(跨表)
|
||||
- **THEN** 该行计入失败,原因为"虚拟号已被占用: <值>"
|
||||
|
||||
#### Scenario: MSISDN 为空的行记录失败
|
||||
|
||||
- **WHEN** Excel 中某行第2列为空
|
||||
- **THEN** 该行标记为失败,原因为"MSISDN 不能为空"
|
||||
|
||||
#### Scenario: 长数字无损解析
|
||||
|
||||
- **GIVEN** Excel 文件中第1列设置为文本格式,包含 20 位数字 "89860012345678901234"
|
||||
- **WHEN** 系统解析该 Excel 文件
|
||||
- **THEN** ICCID 完整保留为 "89860012345678901234",无精度损失,无科学记数法
|
||||
|
||||
#### Scenario: 拒绝非Excel格式文件
|
||||
|
||||
- **GIVEN** 上传文件扩展名为 .csv
|
||||
- **WHEN** 系统尝试解析该文件
|
||||
- **THEN** 系统返回错误"不支持的文件格式 .csv,请上传Excel文件(.xlsx)"
|
||||
|
||||
---
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 导入批次支持卡业务类型
|
||||
|
||||
系统 SHALL 在 IoT 卡导入请求中支持 `card_category` 参数,以批次为单位指定导入卡的业务类型,默认为普通卡。
|
||||
|
||||
**请求字段**:
|
||||
- `card_category`: 卡业务类型(枚举:`normal` / `industry`,可选,默认 `normal`)
|
||||
|
||||
**任务字段**:
|
||||
- `IotCardImportTask` 新增 `card_category` 字段(VARCHAR(20),默认 `normal`)
|
||||
|
||||
**导入行为**:
|
||||
- 导入任务中所有卡统一使用 `card_category` 的值,不支持同一批次混用
|
||||
|
||||
#### Scenario: 不传 card_category 时默认为普通卡
|
||||
|
||||
- **WHEN** 导入请求未包含 `card_category` 字段
|
||||
- **THEN** 导入的所有卡 `card_category` 为 `normal`
|
||||
|
||||
#### Scenario: 指定行业卡导入
|
||||
|
||||
- **WHEN** 导入请求中 `card_category` = `industry`
|
||||
- **THEN** 该批次导入的所有卡 `card_category` 均为 `industry`
|
||||
|
||||
#### Scenario: 传入无效 card_category
|
||||
|
||||
- **WHEN** 导入请求中 `card_category` = `unknown`(非枚举值)
|
||||
- **THEN** 系统返回参数校验错误,提示卡业务类型无效
|
||||
33
openspec/changes/excel-import-refactor/tasks.md
Normal file
33
openspec/changes/excel-import-refactor/tasks.md
Normal file
@@ -0,0 +1,33 @@
|
||||
## 1. Excel 按列位置读取(IoT 卡)
|
||||
|
||||
- [x] 1.1 重写 `parseCardRows`:移除 `findCardColumns` 调用,第1行固定跳过,按 col0=ICCID、col1=MSISDN、col2=VirtualNo 取值
|
||||
- [x] 1.2 保留空行跳过逻辑:若 ICCID、MSISDN、VirtualNo 三列皆为空,`continue` 且不计入 total(与原实现一致,避免尾部空行被判失败)
|
||||
- [x] 1.3 对齐失败原因文案,与 spec 的 Scenario 完全一致("ICCID 不能为空"/"MSISDN 不能为空"/"虚拟号(virtual_no)不能为空",注意半角空格)
|
||||
- [x] 1.4 删除 `findCardColumns` 函数
|
||||
|
||||
## 2. Excel 按列位置读取(设备)
|
||||
|
||||
- [x] 2.1 重写 `ParseDeviceExcel`:移除 `buildDeviceColumnIndex` 调用,第1行固定跳过,按确认列顺序取值(col0=虚拟号、col1=设备名称、col2=设备型号、col3=设备类型、col4=IMEI、col5=制造商、col6=最大SIM槽数、col7~10=卡1~4)
|
||||
- [x] 2.2 保留空行跳过逻辑:若整行目标列皆为空,`continue` 且不计入 total
|
||||
- [x] 2.3 `MaxSimSlots` 范围校验:读取后若不在 [1,4],计入失败(原因:"最大SIM槽数必须在1-4之间");为空/0 时回填默认值 4
|
||||
- [x] 2.4 删除 `buildDeviceColumnIndex` 函数
|
||||
|
||||
## 3. IoT 卡导入支持卡业务类型
|
||||
|
||||
- [x] 3.1 `ImportIotCardRequest` DTO 新增 `CardCategory string` 字段,tag:`json:"card_category" validate:"omitempty,oneof=normal industry" description:"卡业务类型 (normal:普通卡, industry:行业卡),默认 normal"`
|
||||
- [x] 3.2 `IotCardImportTask` model 新增 `CardCategory` 字段,gorm tag:`column:card_category;type:varchar(20);default:normal;not null;comment:卡业务类型(normal/industry)`
|
||||
- [x] 3.3 创建数据库迁移 `000111_add_card_category_to_import_task`(up/down 成对),为 `tb_iot_card_import_task` 新增 `card_category VARCHAR(20) NOT NULL DEFAULT 'normal'` 列并加 COMMENT
|
||||
- [x] 3.4 修改 `internal/service/iot_card_import/service.go CreateImportTask`:构建 `IotCardImportTask` 时写入 `CardCategory`,空串时兜底为 `constants.CardCategoryNormal`
|
||||
- [x] 3.5 修改 `internal/task/iot_card_import.go` 的 `processBatch`:将写死的 `CardCategory: constants.CardCategoryNormal` 改为 `task.CardCategory`
|
||||
- [x] 3.6 更新 `ImportTaskResponse` DTO 增加 `card_category` 字段返回,便于列表/详情查询时展示批次类型
|
||||
|
||||
## 4. 模板文件同步
|
||||
|
||||
- [x] 4.1 更新仓库内(若有)的 IoT 卡、设备导入模板 `.xlsx`,使列顺序与新实现完全一致;若模板由前端维护,在提案交接时通知前端同步更新下载模板
|
||||
|
||||
## 5. 手动验证
|
||||
|
||||
- [ ] 5.1 准备中文表头的 IoT 卡样例 xlsx(含正常行、空行、ICCID/MSISDN/虚拟号为空行、虚拟号重复行),调用 `POST /api/admin/iot-cards/import` 指定 `card_category=industry`,用 PostgreSQL MCP 验证 `tb_iot_card_import_task.card_category='industry'` 且新增卡的 `tb_iot_card.card_category='industry'`
|
||||
- [ ] 5.2 准备中文表头的设备样例 xlsx(含 IMEI 在第5列),调用 `POST /api/admin/devices/import`,验证成功/失败行数与失败原因文案
|
||||
- [ ] 5.3 不传 `card_category` 时验证默认值为 `normal`
|
||||
- [ ] 5.4 传入 `card_category=invalid` 时验证返回参数校验错误
|
||||
68
openspec/specs/agent-fund-summary/spec.md
Normal file
68
openspec/specs/agent-fund-summary/spec.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# agent-fund-summary Specification
|
||||
|
||||
## Purpose
|
||||
代理商资金概况列表,聚合展示代理的预充值钱包(主钱包)和佣金钱包余额信息,供平台和代理多层级查看资金概况。
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 代理商资金概况列表
|
||||
|
||||
系统 SHALL 提供 `GET /api/admin/shops/fund-summary` 接口,返回分页的代理商资金概况列表,同时包含预充值钱包(主钱包)和佣金钱包的余额信息。
|
||||
|
||||
**响应字段**(`ShopFundSummaryItem`):
|
||||
- `shop_id`:店铺 ID
|
||||
- `shop_name`:店铺名称
|
||||
- `shop_code`:店铺编码
|
||||
- `username`:主账号用户名
|
||||
- `phone`:主账号手机号
|
||||
- `main_balance`:预充值钱包余额(分)
|
||||
- `main_frozen_balance`:预充值钱包冻结余额(分)
|
||||
- `total_commission`:累计佣金总额(分)
|
||||
- `withdrawn_commission`:已提现佣金(分)
|
||||
- `unwithdraw_commission`:未提现佣金(分)
|
||||
- `frozen_commission`:冻结中佣金(分)
|
||||
- `withdrawing_commission`:提现中佣金(分)
|
||||
- `available_commission`:可提现佣金(分)
|
||||
- `created_at`:店铺创建时间
|
||||
|
||||
**查询参数**(`ShopFundSummaryListReq`):
|
||||
- `page`:页码(默认 1)
|
||||
- `page_size`:每页数量(默认 20,最大 100)
|
||||
- `shop_name`:店铺名称模糊查询
|
||||
- `username`:主账号用户名模糊查询
|
||||
|
||||
**实现要求**:
|
||||
- 主钱包余额通过 `AgentWalletStore.GetShopMainWalletBatch` 批量查询,避免 N+1
|
||||
- 若代理暂无主钱包记录(未充值),`main_balance` 和 `main_frozen_balance` 返回 0
|
||||
- `main_frozen_balance` 字段为未来预留(当前业务无主钱包冻结场景,值恒为 0),前端可暂不展示
|
||||
- 数据权限:列表查询走 `Shop` 表的数据权限过滤(`SubordinateShopIDs`)。平台人员返回全部代理;代理账号返回自己 + 所有下级店铺(而不是只返回自己一条)
|
||||
|
||||
#### Scenario: 平台人员查看所有代理资金概况
|
||||
|
||||
- **WHEN** 平台人员请求 `GET /shops/fund-summary`
|
||||
- **THEN** 系统返回所有代理的分页列表,每条包含 `main_balance` 和佣金钱包字段
|
||||
|
||||
#### Scenario: 无下级代理的账号查看自己的资金概况
|
||||
|
||||
- **WHEN** 无下级代理的代理账号请求 `GET /shops/fund-summary`
|
||||
- **THEN** 系统按数据权限过滤,只返回该代理自己的一条记录
|
||||
|
||||
#### Scenario: 有下级代理的顶级代理查看资金概况
|
||||
|
||||
- **WHEN** 一个拥有多个下级代理的顶级代理账号请求 `GET /shops/fund-summary`
|
||||
- **THEN** 系统返回自己 + 所有下级代理店铺的资金概况列表(按 `SubordinateShopIDs` 过滤)
|
||||
|
||||
#### Scenario: 代理暂无主钱包时返回零值
|
||||
|
||||
- **WHEN** 代理从未充值,`tb_agent_wallet` 中无该店铺的 `wallet_type=main` 记录
|
||||
- **THEN** `main_balance` 和 `main_frozen_balance` 返回 0,其余字段正常返回
|
||||
|
||||
#### Scenario: 按店铺名称过滤
|
||||
|
||||
- **WHEN** 传入 `shop_name=张三`
|
||||
- **THEN** 系统只返回店铺名称包含"张三"的代理记录
|
||||
|
||||
#### Scenario: 企业账号无权访问
|
||||
|
||||
- **WHEN** 企业账号请求此接口
|
||||
- **THEN** 系统返回 403 错误,消息为"企业账号无权访问代理资金功能"
|
||||
@@ -316,3 +316,24 @@ func RedisAgentWalletLockKey(shopID uint, walletType string) string
|
||||
- **THEN** 系统使用 Redis 分布式锁 `agent_wallet:lock:10:main`,第一个请求获得锁,第二个请求等待或失败
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 批量查询店铺主钱包余额
|
||||
|
||||
系统 SHALL 在 `AgentWalletStore` 中提供 `GetShopMainWalletBatch(ctx, shopIDs []uint) map[uint]*AgentWallet` 方法,一次查询多个店铺的主钱包(`wallet_type=main`)记录,返回以 `shop_id` 为 key 的 map。
|
||||
|
||||
**实现要求**:
|
||||
- 使用 `WHERE shop_id IN (?) AND wallet_type = 'main'` 单次查询,不得逐条查询
|
||||
- 不在 map 中的 shop_id 表示该店铺暂无主钱包,调用方按零值处理
|
||||
- 与现有 `GetShopCommissionSummaryBatch` 对称设计
|
||||
|
||||
#### Scenario: 批量查询多个店铺的主钱包
|
||||
|
||||
- **WHEN** 传入 shopIDs `[1, 2, 3]`,其中 shop 3 无主钱包记录
|
||||
- **THEN** 返回 map `{1: &wallet1, 2: &wallet2}`,shop 3 不在 map 中
|
||||
|
||||
#### Scenario: 传入空列表
|
||||
|
||||
- **WHEN** 传入空 `shopIDs`
|
||||
- **THEN** 直接返回空 map,不执行 DB 查询
|
||||
|
||||
---
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 迁移接口的越权校验(通用要求)
|
||||
|
||||
本规范下所有 `/shops/:shop_id/...` 迁移/新增接口,Service 层方法入口 SHALL 调用 `middleware.CanManageShop(ctx, shopID)` 做显式越权校验。Handler 层只做参数解析,不承担权限逻辑。该要求同时追溯适用于已有但缺少校验的 `ListShopWithdrawalRequests`、`ListShopCommissionRecords` 两个方法(回归漏洞修复)。
|
||||
|
||||
统一错误返回:`errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")`。
|
||||
|
||||
#### Scenario: 代理传非自己管辖店铺 ID 被 Service 层拦截
|
||||
|
||||
- **WHEN** 代理 A(shopID=10)请求本规范任意一个 `/shops/:shop_id/...` 接口,传入代理 B(shopID=20)的 shopID
|
||||
- **THEN** Service 层入口的 `middleware.CanManageShop(ctx, 20)` 返回 error,接口返回 403,消息 "无权限操作该资源或资源不存在"
|
||||
|
||||
#### Scenario: 平台人员不受 CanManageShop 限制
|
||||
|
||||
- **WHEN** 平台人员请求任意 `/shops/:shop_id/...` 接口
|
||||
- **THEN** `middleware.CanManageShop` 直接放行,接口正常返回数据
|
||||
|
||||
#### Scenario: 已有方法 ListShopCommissionRecords / ListShopWithdrawalRequests 补齐校验
|
||||
|
||||
- **WHEN** 代理账号请求 `/shops/{非自己管辖shopID}/commission-records` 或 `/withdrawal-requests`
|
||||
- **THEN** 实现后返回 403(回归漏洞修复前这两个方法会返回数据)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 查询佣金记录列表
|
||||
|
||||
系统 SHALL 提供佣金记录列表查询,支持按店铺、佣金来源、时间范围、状态筛选。
|
||||
|
||||
70
openspec/specs/main-wallet-transactions/spec.md
Normal file
70
openspec/specs/main-wallet-transactions/spec.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# main-wallet-transactions Specification
|
||||
|
||||
## Purpose
|
||||
预充值钱包(主钱包)交易流水查询,为管理员和代理提供主钱包的充值、扣款、退款等流水明细查看能力。
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 预充值钱包流水查询
|
||||
|
||||
系统 SHALL 提供 `GET /api/admin/shops/:shop_id/main-wallet/transactions` 接口,分页返回指定代理店铺的预充值钱包(主钱包)交易流水记录。
|
||||
|
||||
**响应字段**(`MainWalletTransactionItem`):
|
||||
- `id`:流水记录 ID
|
||||
- `transaction_type`:交易类型。主钱包可能出现的值为 `recharge`-充值入账 / `deduct`-套餐扣款 / `refund`-退款(当前 DB 数据仅见 `recharge`,`deduct` / `refund` 随代购扣款/退款业务上线而出现;接口不对类型做枚举白名单,透传 DB 原值)
|
||||
- `transaction_subtype`:交易子类型(细分场景,如 `order_payment`,可为空)
|
||||
- `amount`:变动金额(分,正数为入账,负数为扣款)
|
||||
- `balance_before`:变动前余额(分)
|
||||
- `balance_after`:变动后余额(分)
|
||||
- `remark`:备注(可为空)
|
||||
- `created_at`:流水时间
|
||||
|
||||
**查询参数**(`MainWalletTransactionListRequest`):
|
||||
- `shop_id`:路径参数,店铺 ID(必填)
|
||||
- `page`:页码(默认 1)
|
||||
- `page_size`:每页数量(默认 20,最大 100)
|
||||
- `transaction_type`:按类型过滤(可选)
|
||||
- `start_date`:开始日期,`YYYY-MM-DD`(可选)
|
||||
- `end_date`:结束日期,`YYYY-MM-DD`(可选)
|
||||
|
||||
**实现要求**:
|
||||
- **Service 层入口必须调用 `middleware.CanManageShop(ctx, shopID)` 做越权校验**,校验失败直接返回 `errors.CodeForbidden`;不得依赖 `GetMainWallet` 的隐式过滤(该方法不做权限校验)
|
||||
- 先通过 `AgentWalletStore.GetMainWallet(shopID)` 获取主钱包;若不存在则返回空列表(`total=0`),不报错
|
||||
- 使用 `AgentWalletTransactionStore.ListByWalletIDWithFilters / CountByWalletID`(task 2.2 新增)查询流水,支持 transaction_type 和日期过滤
|
||||
- 旧方法 `ListByShopID / CountByShopID` 已在 task 2.3 删除(会跨钱包类型返回数据,易被误用)
|
||||
- 结果按 `created_at DESC` 排序
|
||||
|
||||
#### Scenario: 平台人员查看指定代理的预充值流水
|
||||
|
||||
- **WHEN** 平台人员请求 `GET /shops/123/main-wallet/transactions`
|
||||
- **THEN** 系统返回店铺 123 的主钱包流水,按时间倒序,含变动前后余额
|
||||
|
||||
#### Scenario: 代理查看自己的预充值流水
|
||||
|
||||
- **WHEN** 代理账号请求 `GET /shops/自己shop_id/main-wallet/transactions`
|
||||
- **THEN** 系统返回该代理自己的主钱包流水记录
|
||||
|
||||
#### Scenario: 代理尝试查看他人流水被拦截
|
||||
|
||||
- **WHEN** 代理账号请求 `GET /shops/他人shop_id/main-wallet/transactions`
|
||||
- **THEN** 系统返回 403 错误,消息为"无权限操作该资源或资源不存在"
|
||||
|
||||
#### Scenario: 代理暂无主钱包时返回空列表
|
||||
|
||||
- **WHEN** 代理从未充值,主钱包不存在
|
||||
- **THEN** 系统返回空列表,`total` 为 0,不报错
|
||||
|
||||
#### Scenario: 按交易类型过滤
|
||||
|
||||
- **WHEN** 传入 `transaction_type=recharge`
|
||||
- **THEN** 系统只返回充值入账类型的流水
|
||||
|
||||
#### Scenario: 按日期范围过滤
|
||||
|
||||
- **WHEN** 传入 `start_date=2026-01-01&end_date=2026-03-31`
|
||||
- **THEN** 系统只返回该日期范围内的流水记录
|
||||
|
||||
#### Scenario: 企业账号无权访问
|
||||
|
||||
- **WHEN** 企业账号请求此接口
|
||||
- **THEN** 系统返回 403 错误
|
||||
@@ -120,55 +120,50 @@ func ParseDeviceExcel(filePath string) (*DeviceParseResult, error) {
|
||||
return nil, ErrExcelNoData
|
||||
}
|
||||
|
||||
header := rows[0]
|
||||
colIndex := buildDeviceColumnIndex(header)
|
||||
|
||||
parseResult := &DeviceParseResult{
|
||||
Rows: make([]DeviceRow, 0),
|
||||
ParseErrors: make([]CSVParseError, 0),
|
||||
}
|
||||
|
||||
// 按固定列位置读取,第1行为表头跳过:
|
||||
// col0=虚拟号 col1=设备名称 col2=设备型号 col3=设备类型 col4=IMEI
|
||||
// col5=制造商 col6=最大SIM槽数 col7~10=卡1~4 ICCID
|
||||
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
|
||||
getCol := func(idx int) string {
|
||||
if idx < len(record) {
|
||||
return strings.TrimSpace(record[idx])
|
||||
}
|
||||
}
|
||||
if idx := colIndex["manufacturer"]; idx >= 0 && idx < len(record) {
|
||||
row.Manufacturer = strings.TrimSpace(record[idx])
|
||||
return ""
|
||||
}
|
||||
|
||||
row.VirtualNo = getCol(0)
|
||||
row.DeviceName = getCol(1)
|
||||
row.DeviceModel = getCol(2)
|
||||
row.DeviceType = getCol(3)
|
||||
row.IMEI = getCol(4)
|
||||
row.Manufacturer = getCol(5)
|
||||
maxSimSlotsStr := getCol(6)
|
||||
|
||||
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)
|
||||
}
|
||||
for j := 7; j <= 10; j++ {
|
||||
iccid := getCol(j)
|
||||
if iccid != "" {
|
||||
row.ICCIDs = append(row.ICCIDs, iccid)
|
||||
}
|
||||
}
|
||||
|
||||
// 整行目标列皆为空,视为空行跳过,不计入 total
|
||||
if row.VirtualNo == "" && row.DeviceName == "" && row.DeviceModel == "" &&
|
||||
row.DeviceType == "" && row.IMEI == "" && row.Manufacturer == "" &&
|
||||
maxSimSlotsStr == "" && len(row.ICCIDs) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
parseResult.TotalCount++
|
||||
|
||||
if row.VirtualNo == "" {
|
||||
@@ -179,8 +174,22 @@ func ParseDeviceExcel(filePath string) (*DeviceParseResult, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 解析最大SIM槽数
|
||||
if maxSimSlotsStr != "" {
|
||||
if n, err := strconv.Atoi(maxSimSlotsStr); err == nil {
|
||||
row.MaxSimSlots = n
|
||||
}
|
||||
}
|
||||
|
||||
// 范围校验:不在 [1,4] 则失败;为空/0 时回填默认值 4
|
||||
if row.MaxSimSlots == 0 {
|
||||
row.MaxSimSlots = 4
|
||||
} else if row.MaxSimSlots < 1 || row.MaxSimSlots > 4 {
|
||||
parseResult.ParseErrors = append(parseResult.ParseErrors, CSVParseError{
|
||||
Line: lineNum,
|
||||
Reason: "最大SIM槽数必须在1-4之间",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
parseResult.Rows = append(parseResult.Rows, row)
|
||||
@@ -209,70 +218,44 @@ func selectSheet(f *excelize.File) string {
|
||||
}
|
||||
|
||||
// parseCardRows 解析卡数据行
|
||||
// 自动检测表头并提取ICCID和MSISDN列
|
||||
// 第1行固定为表头跳过,按列位置取值:col0=ICCID、col1=MSISDN、col2=VirtualNo
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
startLine := 0
|
||||
if headerSkipped {
|
||||
startLine = 1
|
||||
}
|
||||
|
||||
for i := startLine; i < len(rows); i++ {
|
||||
// 从第2行(索引1)开始,跳过第1行表头
|
||||
for i := 1; 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])
|
||||
}
|
||||
if len(row) >= 1 {
|
||||
iccid = strings.TrimSpace(row[0])
|
||||
}
|
||||
if len(row) >= 2 {
|
||||
msisdn = strings.TrimSpace(row[1])
|
||||
}
|
||||
if len(row) >= 3 {
|
||||
virtualNo = strings.TrimSpace(row[2])
|
||||
}
|
||||
|
||||
// 三列皆为空视为空行,跳过且不计入 total
|
||||
if iccid == "" && msisdn == "" && virtualNo == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
result.TotalCount++
|
||||
|
||||
if iccid == "" && msisdn == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if iccid == "" {
|
||||
result.ParseErrors = append(result.ParseErrors, CSVParseError{
|
||||
Line: lineNum,
|
||||
MSISDN: msisdn,
|
||||
Reason: "ICCID不能为空",
|
||||
Reason: "ICCID 不能为空",
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -281,7 +264,16 @@ func parseCardRows(rows [][]string) (*CSVParseResult, error) {
|
||||
result.ParseErrors = append(result.ParseErrors, CSVParseError{
|
||||
Line: lineNum,
|
||||
ICCID: iccid,
|
||||
Reason: "MSISDN不能为空",
|
||||
Reason: "MSISDN 不能为空",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if virtualNo == "" {
|
||||
result.ParseErrors = append(result.ParseErrors, CSVParseError{
|
||||
Line: lineNum,
|
||||
ICCID: iccid,
|
||||
Reason: "虚拟号(virtual_no)不能为空",
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -296,68 +288,3 @@ func parseCardRows(rows [][]string) (*CSVParseResult, error) {
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user