导入历史跟踪机制实战指南
一、概述
在后台管理系统中,Excel 批量导入是高频操作。用户提交导入后,通常需要知道:这次导入了多少条、成功多少、失败多少、失败文件在哪里下载。如果只在接口响应中返回结果,用户关闭页面后就无法再次查看。
导入历史跟踪机制解决了这个问题:每次导入操作都记录到一张"导入管理表"中,用户可以在"导入历史"页面随时查看所有历史导入记录和结果。
二、架构设计
核心思路:在导入流程的关键节点(开始、完成、失败),通过远程服务将导入状态和结果持久化到导入管理表中。
流程节点:
| 节点 | 动作 | status |
|---|---|---|
| 导入开始 | 创建导入记录 | 1(进行中) |
| 导入成功 | 更新记录(成功数、失败数、失败文件) | 2(成功) |
| 导入失败 | 更新记录(异常信息) | 3(失败) |
数据流向:
Controller 接收文件 ↓ 解析 Excel → 生成 taskId → 创建导入记录(status=1) ↓ Service 逐条处理 ↓ 处理完成 → 更新导入记录(status=2,含结果统计) ↓(异常时) 捕获异常 → 更新导入记录(status=3)→ 抛出异常
三、核心数据模型
3.1 导入记录表
CREATE TABLE import_manage (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
member_id INT NULL COMMENT '所属用户/租户ID',
function_code VARCHAR(100) NOT NULL COMMENT '功能标识',
function_name VARCHAR(100) NOT NULL COMMENT '功能名称',
task_id VARCHAR(50) NOT NULL COMMENT '任务唯一ID',
file_name VARCHAR(200) NULL COMMENT '导入文件名',
file_path VARCHAR(500) NULL COMMENT '失败文件下载地址',
qty INT NULL COMMENT '导入总条数',
success_qty INT NULL COMMENT '成功条数',
error_qty INT NULL COMMENT '失败条数',
status TINYINT NOT NULL COMMENT '1-进行中 2-成功 3-失败',
operator_code VARCHAR(50) NULL COMMENT '操作人工号',
operator_name VARCHAR(50) NULL COMMENT '操作人姓名',
create_time DATETIME NOT NULL COMMENT '创建时间',
update_time DATETIME NOT NULL COMMENT '更新时间'
) COMMENT '导入管理表';
3.2 导入记录 DTO
java
@Data
public class ImportManageDto implements Serializable {
private Integer memberId; // 所属用户ID
private String functionCode; // 功能标识(唯一区分不同业务)
private String functionName; // 功能名称(前端展示)
private String taskId; // 任务唯一ID(UUID)
private String fileName; // 导入文件名
private String filePath; // 失败文件OSS地址
private Integer qty; // 导入总条数
private Integer successQty; // 成功条数
private Integer errorQty; // 失败条数
private Integer status; // 1-进行中 2-成功 3-失败
private Integer userId; // 操作人ID
private String operatorCode; // 操作人工号
private String operatorName; // 操作人姓名
}
注:
博客:
https://blog.csdn.net/badao_liumang_qizhi
四、完整示例
4.1 远程服务接口(Feign)
java
@FeignClient(name = "base-service")
public interface BaseFeign {
/**
* 创建导入记录.
*/
@PostMapping("/inner/import/import-manage/save-import-manage")
RestControllerResult<Boolean> saveImportManage(@RequestBody ImportManageDto paramsDto);
/**
* 更新导入记录.
*/
@PostMapping("/inner/import/import-manage/update-import-manage")
RestControllerResult<Boolean> updateImportManage(@RequestBody ImportManageDto paramsDto);
}
4.2 Controller 实现
java
@Slf4j
@RestController
@RequestMapping("/api/page/product")
public class ProductImportController {
@Resource
private ProductImportService productImportService;
@Resource
private BaseFeign baseFeign;
@PostMapping("/import-product")
public RestControllerResult<ImportResultDto> importProduct(
@RequestParam("file") MultipartFile file) {
// 1. 文件校验
if (file == null || file.isEmpty()) {
throw new BusinessException("导入文件不能为空");
}
String fileName = file.getOriginalFilename();
if (fileName == null || (!fileName.endsWith(".xls") && !fileName.endsWith(".xlsx"))) {
throw new BusinessException("仅支持Excel格式文件");
}
// 2. 构建导入历史记录
ImportManageDto importManage = new ImportManageDto();
importManage.setMemberId(UserContext.getMemberId());
importManage.setFunctionCode("productImport");
importManage.setFunctionName("商品信息导入");
importManage.setTaskId(UUID.randomUUID().toString().replaceAll("-", ""));
importManage.setFileName(fileName);
importManage.setStatus(1); // 进行中
importManage.setUserId(UserContext.getUserId());
importManage.setOperatorCode(UserContext.getUserCode());
importManage.setOperatorName(UserContext.getUserName());
try {
// 3. 解析Excel
List<ProductImportDto> dataList = ExcelUtil.parse(file, ProductImportDto.class);
if (dataList == null || dataList.isEmpty()) {
throw new BusinessException("导入文件中无有效数据");
}
// 4. 记录导入总数,创建导入记录
importManage.setQty(dataList.size());
baseFeign.saveImportManage(importManage);
// 5. 执行导入
ImportResultDto importResult = productImportService.importProduct(dataList);
// 6. 导入成功,更新记录
importManage.setSuccessQty(importResult.getSuccessCount());
importManage.setErrorQty(importResult.getFailCount());
importManage.setFilePath(importResult.getFailUrl());
importManage.setStatus(2); // 成功
baseFeign.updateImportManage(importManage);
RestControllerResult<ImportResultDto> result = new RestControllerResult<>();
result.setSuccess(true);
result.setData(importResult);
return result;
} catch (BusinessException e) {
// 7. 业务异常,记录失败
importManage.setStatus(3);
baseFeign.saveImportManage(importManage);
throw e;
} catch (Exception e) {
// 8. 系统异常,记录失败
log.error("导入异常", e);
importManage.setStatus(3);
baseFeign.saveImportManage(importManage);
throw new BusinessException("导入失败,请检查文件格式");
}
}
}
4.3 前端导入历史页面
前端可以调用导入历史查询接口,展示:
| 导入时间 | 功能名称 | 文件名 | 总条数 | 成功 | 失败 | 状态 | 操作 |
|---|---|---|---|---|---|---|---|
| 2026-05-29 14:30 | 商品信息导入 | product_20260529.xlsx | 100 | 97 | 3 | 成功 | 下载失败文件 |
| 2026-05-29 10:15 | 商品信息导入 | product_batch.xlsx | 50 | 50 | 0 | 成功 | - |
| 2026-05-28 16:00 | 商品信息导入 | invalid.doc | 0 | 0 | 0 | 失败 | - |
五、关键设计点
5.1 taskId 的作用
- 唯一标识:每次导入生成唯一 UUID,用于关联创建和更新操作
- 幂等保证:同一个 taskId 不会重复创建记录
- 异步场景:如果导入是异步的,前端可通过 taskId 轮询进度
5.2 functionCode 设计
java
// 不同业务使用不同的 functionCode
"productImport" // 商品导入
"customerImport" // 客户导入
"stockCheckImport" // 库存盘点导入
"warehousePriorityImport" // 仓库优先级导入
作用:
- 前端按 functionCode 筛选展示对应模块的导入历史
- 后端可以按 functionCode 做权限控制
5.3 status 状态流转
创建时 → status=1(进行中)
↓
┌─────┴─────┐
↓ ↓
status=2 status=3
(成功) (失败)
注意:状态只会从 1 流转到 2 或 3,不会回退。
5.4 filePath 的使用
- 导入全部成功时:filePath 为空
- 导入部分失败时:filePath 存放失败数据 Excel 的 OSS 下载地址
- 前端根据 filePath 是否为空决定是否显示"下载失败文件"按钮
六、异常处理策略
6.1 何时创建 vs 何时更新
| 场景 | 调用方法 | 说明 |
|---|---|---|
| 文件解析成功,开始处理 | saveImportManage(status=1) | 此时已知总条数 |
| 处理完成 | updateImportManage(status=2) | 更新成功/失败数 |
| 文件解析失败 | saveImportManage(status=3) | 直接标记失败 |
| 处理过程中异常 | updateImportManage(status=3) | 更新为失败 |
6.2 Feign 调用失败的处理
java
// 导入历史记录是辅助功能,不能影响主流程
try {
baseFeign.saveImportManage(importManage);
} catch (Exception e) {
log.warn("创建导入记录失败,不影响导入流程", e);
// 不抛出异常,继续执行导入
}
6.3 异常时的记录策略
java
try {
// 正常流程...
} catch (BusinessException e) {
// 业务异常(如文件为空):记录失败状态
importManage.setStatus(3);
baseFeign.saveImportManage(importManage);
throw e; // 继续抛出给前端
} catch (Exception e) {
// 系统异常:记录失败状态
importManage.setStatus(3);
baseFeign.saveImportManage(importManage);
throw new BusinessException("导入失败");
}
七、同步导入 vs 异步导入的历史跟踪差异
| 维度 | 同步导入 | 异步导入(MQ) |
|---|---|---|
| 创建记录时机 | Controller 中解析文件后 | Controller 中发送 MQ 前 |
| 更新记录时机 | Service 返回后立即更新 | MQ 消费者处理完成后更新 |
| 前端交互 | 等待接口返回结果 | 返回 taskId,轮询进度 |
| 失败处理 | catch 中更新 status=3 | 消费者 catch 中更新 |
| 适用场景 | 数据量小(< 1000条) | 数据量大(> 1000条) |
异步导入示例
java
// Controller:创建记录 + 发MQ
importManage.setStatus(1);
baseFeign.saveImportManage(importManage);
mqSender.send(dataList, importManage); // 发MQ
return taskId; // 返回taskId给前端
// MQ Consumer:处理完成后更新记录
try {
ImportResultDto result = doImport(dataList);
importManage.setSuccessQty(result.getSuccessCount());
importManage.setStatus(2);
baseFeign.updateImportManage(importManage);
} catch (Exception e) {
importManage.setStatus(3);
baseFeign.updateImportManage(importManage);
}
八、导入历史查询接口设计
java
// 查询当前用户的导入历史
@GetMapping("/list-import-history")
public RestControllerResult<List<ImportHistoryDto>> listImportHistory(
@RequestParam String functionCode,
@RequestParam(required = false) Integer pageNum,
@RequestParam(required = false) Integer pageSize) {
// 按 memberId + functionCode 查询,按时间倒序
}
返回示例:
json
{
"success": true,
"data": [
{
"taskId": "abc123",
"functionName": "商品信息导入",
"fileName": "product_20260529.xlsx",
"qty": 100,
"successQty": 97,
"errorQty": 3,
"status": 2,
"filePath": "https://oss.example.com/fail_abc123.xlsx",
"createTime": "2026-05-29 14:30:25",
"operatorName": "admin 张三"
}
]
}
九、最佳实践清单
- taskId 使用 UUID:保证全局唯一,便于关联创建和更新
- functionCode 按业务模块区分:方便前端按模块展示历史
- 记录创建时机在文件解析成功后:确保有 qty(总条数)信息
- 导入历史记录不影响主流程:Feign 调用失败时仅记录日志,不抛异常
- 状态只前进不回退:1→2 或 1→3
- 失败文件地址存 filePath:前端据此展示下载按钮
- catch 中必须更新状态为失败:避免记录一直处于"进行中"
- 设置操作人信息:方便管理员排查是谁做的导入
- 异步导入时前端轮询 taskId:通过 status 判断是否完成
- 导入历史定期清理:超过 30 天的记录可以归档或删除