# 需求08:设备批量分配代理或套餐系列(Excel导入) > 状态:待评审 > 模板:前端静态资源,后端仅负责上传校验和异步处理。 --- ## 背景 设备号不连续,无法通过号段批量分配。需要通过上传 Excel 表(表头:设备号)完成两项独立操作: 1. 批量分配设备给代理。 2. 批量分配设备给套餐系列。 两项操作可以复用解析、异步任务和失败明细基础设施,但**一个任务只能执行一个业务命令**,不能在一次提交中同时修改 `shop_id` 和 `series_id`。 `Device` 表已有 `shop_id *uint` 和 `series_id *uint` 字段,分配即更新这两个字段。 ```mermaid sequenceDiagram actor User as 平台员工 participant Web as 设备管理前端 participant API as BatchAllocation API participant DB as PostgreSQL participant Worker as Asynq Worker User->>Web: 选择“分配代理”或“分配套餐系列”并上传 Excel Web->>API: POST /api/admin/devices/batch-assign-shop 或 batch-assign-series API->>DB: 创建任务并保存上传文件引用 API-->>Web: task_id API->>Worker: 入队处理任务 Worker->>DB: 按设备号批量查询并条件更新 Worker->>DB: 写成功数、失败数和失败明细 Web->>API: 轮询任务详情 API-->>Web: 处理结果 ``` --- ## 数据库变更 新建批量分配任务表(不复用 `DeviceImportTask`,业务语义不同): ```sql CREATE TABLE tb_device_batch_allocation_task ( id BIGSERIAL PRIMARY KEY, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), deleted_at TIMESTAMPTZ, creator BIGINT NOT NULL DEFAULT 0, updater BIGINT NOT NULL DEFAULT 0, task_no VARCHAR(30) NOT NULL, source_file_key VARCHAR(500) NOT NULL, operation_type VARCHAR(30) NOT NULL, target_id BIGINT NOT NULL, operator_id BIGINT NOT NULL, total_count INT NOT NULL DEFAULT 0, success_count INT NOT NULL DEFAULT 0, fail_count INT NOT NULL DEFAULT 0, status INT NOT NULL DEFAULT 1, failed_items JSONB, started_at TIMESTAMPTZ, completed_at TIMESTAMPTZ ); CREATE UNIQUE INDEX idx_device_batch_allocation_task_no ON tb_device_batch_allocation_task(task_no) WHERE deleted_at IS NULL; CREATE INDEX idx_device_batch_allocation_task_operation ON tb_device_batch_allocation_task(operation_type, status, created_at DESC); ``` 状态常量:1=处理中,2=已完成,3=失败。 `operation_type`:`assign_shop`(分配代理)或 `assign_series`(分配套餐系列)。`target_id` 随操作类型分别指向代理店铺或套餐系列;不建立数据库外键。 失败明细存 JSONB(`failed_items`),失败条数有限,无需单独明细表。 --- ## Model(`internal/model/device_batch_allocation_task.go`) ```go // DeviceBatchAllocationTask 设备批量分配任务模型 type DeviceBatchAllocationTask struct { gorm.Model BaseModel `gorm:"embedded"` TaskNo string `gorm:"column:task_no;type:varchar(30);uniqueIndex:idx_device_batch_allocation_task_no,where:deleted_at IS NULL;not null" json:"task_no"` SourceFileKey string `gorm:"column:source_file_key;type:varchar(500);not null;comment:待处理Excel对象存储Key" json:"source_file_key"` OperationType string `gorm:"column:operation_type;type:varchar(30);not null;comment:操作类型 assign_shop=分配代理 assign_series=分配套餐系列" json:"operation_type"` TargetID uint `gorm:"column:target_id;not null;comment:操作目标ID(代理店铺或套餐系列)" json:"target_id"` OperatorID uint `gorm:"column:operator_id;not null;comment:操作人ID" json:"operator_id"` TotalCount int `gorm:"column:total_count;default:0;comment:总记录数" json:"total_count"` SuccessCount int `gorm:"column:success_count;default:0;comment:成功数" json:"success_count"` FailCount int `gorm:"column:fail_count;default:0;comment:失败数" json:"fail_count"` Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1=处理中 2=已完成 3=失败" json:"status"` FailedItems ImportResultItems `gorm:"column:failed_items;type:jsonb;comment:失败记录详情" json:"failed_items"` StartedAt *time.Time `gorm:"column:started_at" json:"started_at"` CompletedAt *time.Time `gorm:"column:completed_at" json:"completed_at"` } func (DeviceBatchAllocationTask) TableName() string { return "tb_device_batch_allocation_task" } ``` > `ImportResultItems` 复用 `internal/model/device_import_task.go` 中已定义的类型。 --- ## API 设计 ### 1. 前端静态 Excel 模板 模板由前端项目作为静态资源随版本发布,后端不提供模板下载接口。 - 文件建议命名:`设备批量分配模板-v1.xlsx` - 表头:`设备号`(一列) - 前端“下载模板”按钮直接下载静态文件。 - 后端只校验上传文件的表头、行数和内容,不读取或生成前端模板文件。 模板字段发生变化时,前后端必须在同一发布批次升级;旧模板仍可能被用户保存在本地,因此后端错误需要明确指出缺失或未知表头。 ### 2. 上传并提交 ``` POST /api/admin/devices/batch-assign-shop Content-Type: multipart/form-data 字段: shop_id: 123 (必填,分配给哪个代理) file: ``` ```text POST /api/admin/devices/batch-assign-series Content-Type: multipart/form-data 字段: series_id: 45 (必填,分配给哪个套餐系列) file: ``` 两个接口内部创建同一类任务表记录,但分别写入 `operation_type=assign_shop|assign_series`。请求中不接受另一个目标字段,避免前端通过隐藏参数把两个业务动作合并。 **处理逻辑(Asynq 异步)**: 1. API 将上传文件保存到对象存储,把 `source_file_key` 写入任务;Worker 从对象存储下载并解析 Excel 2. 对设备号去重后分批查询 `tb_device`(按 `virtual_no` 或 `imei`),禁止逐行查询 3. `assign_shop` 仅按状态/归属条件批量更新 `shop_id`;`assign_series` 仅更新 `series_id`。 4. 未找到的:记录失败原因"设备号不存在"。 5. `assign_shop` 遇到已分配给其他代理的设备:记录"该设备已分配,请先回收";`assign_series` 不改变代理归属。 6. Worker 重试时只处理仍未满足目标结果的设备;已经分配到同一目标的记录按幂等成功处理。 临时本地文件路径不能进入 Asynq 载荷。任务结束后源文件按统一对象存储生命周期清理,清理失败不影响任务结果。 限制:单文件最大 10MB、去重前最多 1000 行、Worker 每批最多 200 条、任务最多保留 1000 条失败明细。超过限制在 API 或解析阶段返回明确错误,不创建无限时长任务。 ### 3. 查询任务状态 ``` GET /api/admin/devices/batch-allocation/{task_id} ``` 响应: ```json { "task_no": "DBA20240101001", "operation_type": "assign_shop", "target_id": 123, "status": 2, "status_name": "已完成", "total_count": 100, "success_count": 95, "fail_count": 5, "failed_items": [ { "line": 3, "virtual_no": "12345", "reason": "设备号不存在" }, { "line": 7, "virtual_no": "67890", "reason": "该设备已分配,请先回收" } ], "started_at": "2024-01-01T10:00:00Z", "completed_at": "2024-01-01T10:00:05Z" } ``` --- ## 前端对接 ### 入口 设备管理页 > 批量操作: - "批量分配代理" - "批量分配套餐系列" ### 操作流程 1. 点击其中一个批量操作入口。 2. "批量分配代理"弹框只显示代理选择器和 Excel 上传区域;"批量分配套餐系列"弹框只显示套餐系列选择器和 Excel 上传区域。 3. 上传后点击"提交",分别调用 `POST /api/admin/devices/batch-assign-shop` 或 `POST /api/admin/devices/batch-assign-series`。 4. 提示"任务提交成功,正在处理..." 5. 轮询 `GET /api/admin/devices/batch-allocation/{task_id}` 直到 `status != 1` 6. 展示结果:成功X条,失败X条,失败明细在页面展示 ### Excel 规范 - 表头:`设备号` - 每行一个设备号(支持虚拟号或IMEI)