SpringBoot中抽离服务框架实现大数据量校验并异步mq导入导出
一、解决什么问题
传统的导入导出实现,每个业务都要写一套完整的代码(Controller → Service → 解析 → 校验 → 入库 → 导出 → 下载)。当业务越来越多,会产生大量重复代码。
抽离一个单独的Exchange 框架的思路是:把导入导出中通用的流程固定下来,业务只需要实现"变化的部分"。
| 通用部分(框架负责) | 变化部分(业务实现) |
|---|---|
| 接收文件上传 | Excel 行数据结构定义 |
| 解析 Excel | 每行的校验规则 |
| 创建任务记录 | 校验通过后的入库逻辑 |
| MQ 异步调度 | 导出时的数据查询逻辑 |
| 任务状态管理 | - |
| 导出文件生成和上传 OSS | - |
| 提供统一的 API | - |
注:
博客:
https://blog.csdn.net/badao_liumang_qizhi
二、核心设计模式
2.1 模板方法模式(Template Method)
框架定义了处理流程的骨架("模板"),把可变的步骤留给子类实现:
AbstractImportTemplate(抽象类)
├── read() ← 框架实现:解析 Excel
├── checkData() ← 子类实现:业务校验
└── importDate() ← 子类实现:业务入库
AbstractExportTemplate(抽象类)
├── export() ← 框架实现:分页查询 + 写 Excel + 上传 OSS
└── 子类只需注册 DataExtractor
DataExtractor(抽象类)
└── queryData() ← 子类实现:分页查询业务数据
2.2 策略模式(Strategy)
多个业务类型(DEMO、CUSTOMER_STOCK...)各自有不同的 Importer/Exporter 实现。框架通过 BizType 枚举找到对应的实现类:
BizType.DEMO → DemoImporter / DemoExporter
BizType.CUSTOMER_STOCK → CustomerStockImporter / CustomerStockExporter
运行时根据前端传入的 bizType 参数,动态选择对应的策略执行。
2.3 观察者/事件驱动模式(MQ 解耦)
文件上传和实际处理之间用 MQ 消息解耦:
Controller(同步)→ 创建任务 → 发 MQ 消息 → 立即返回
↓
MQ Consumer(异步)→ 根据 bizType 找到 Importer → 执行校验/导入
好处:上传接口响应快、处理不阻塞请求线程、可以重试。
三、导入流程详解
┌─ 步骤1: 前端上传文件 ──────────────────────────────────────────────────┐
│ │
│ POST /create-import-task?bizType=CUSTOMER_STOCK + file │
│ │
└────────────────────────────────────────────────────────────────────────┘
↓
┌─ 步骤2: Controller 接收请求 ───────────────────────────────────────────┐
│ │
│ 1. 根据 bizType 找到对应的 AbstractImportTemplate 实现 │
│ 2. 调用 template.read(file) 解析 Excel,得到行数据列表 │
│ 3. 将每行数据序列化为 JSON,存入 exchange_task_detail 表 │
│ 4. 创建 exchange_task 主表记录(状态=待校验) │
│ 5. 发送 RocketMQ 消息(消息体=ExchangeTask 对象) │
│ 6. 返回 taskId 给前端 │
│ │
└────────────────────────────────────────────────────────────────────────┘
↓
┌─ 步骤3: MQ Consumer 异步校验 ──────────────────────────────────────────┐
│ │
│ 1. 消费消息,解析出 ExchangeTask │
│ 2. 从 exchange_task_detail 查出所有行数据 │
│ 3. JSON 反序列化为业务对象(如 CustomerStockRow) │
│ 4. 调用 template.checkData(dataList) 执行业务校验 │
│ 5. 每行的校验结果(成功/失败+原因)回写到 exchange_task_detail.status │
│ 6. 更新 exchange_task 状态为"校验完成" │
│ │
└────────────────────────────────────────────────────────────────────────┘
↓
┌─ 步骤4: 前端轮询获取校验结果 ──────────────────────────────────────────┐
│ │
│ GET /query-task-detail?taskId=1 │
│ 返回:总数、成功数、失败数、失败明细列表 │
│ │
└────────────────────────────────────────────────────────────────────────┘
↓
┌─ 步骤5: 用户确认导入 ─────────────────────────────────────────────────┐
│ │
│ GET /confirm?taskId=1 │
│ 1. 更新任务状态为"导入中" │
│ 2. 发送 RocketMQ 消息 │
│ │
└────────────────────────────────────────────────────────────────────────┘
↓
┌─ 步骤6: MQ Consumer 异步入库 ──────────────────────────────────────────┐
│ │
│ 1. 消费消息 │
│ 2. 从 exchange_task_detail 查出校验成功的行 │
│ 3. 调用 template.importDate(successList) 执行实际业务逻辑 │
│ 4. 更新每行状态为"导入成功"或"导入失败" │
│ 5. 更新 exchange_task 状态为"导入完成" │
│ │
└────────────────────────────────────────────────────────────────────────┘
数据存储结构
exchange_task(任务主表)
┌────┬──────────────────┬────────┬────────┐
│ id │ biz_type │ status │ ... │
├────┼──────────────────┼────────┼────────┤
│ 1 │ CUSTOMER_STOCK │ 4 │ ... │
└────┴──────────────────┴────────┴────────┘
exchange_task_detail(任务明细表)
┌────┬─────────┬──────────────────────────────────────┬────────┐
│ id │ task_id │ data (JSON字符串) │ status │
├────┼─────────┼──────────────────────────────────────┼────────┤
│ 1 │ 1 │ {"customerCode":"8700","stockQty":"5"}│ 3 │
│ 2 │ 1 │ {"customerCode":"8701","stockQty":"x"}│ -1 │
└────┴─────────┴──────────────────────────────────────┴────────┘
关键点:每行 Excel 数据以 JSON 字符串 存储在 data 字段中,通过 getDataType() 方法告诉框架反序列化的目标类型。
四、导出流程详解
┌─ 步骤1: 前端发起导出请求 ─────────────────────────────────────────────┐
│ │
│ POST /create-export-task │
│ Body: {"bizType":"CUSTOMER_STOCK", "queryParams":"{\"batchId\":1}"} │
│ │
└────────────────────────────────────────────────────────────────────────┘
↓
┌─ 步骤2: 创建导出任务 ─────────────────────────────────────────────────┐
│ │
│ 1. 创建 exchange_task 记录(type=export, status=待处理) │
│ 2. 发送 RocketMQ 消息 │
│ 3. 返回 taskId │
│ │
└────────────────────────────────────────────────────────────────────────┘
↓
┌─ 步骤3: MQ Consumer 异步生成文件 ─────────────────────────────────────┐
│ │
│ 1. 消费消息,根据 bizType 找到对应的 AbstractExportTemplate │
│ 2. 调用 template.export(queryParams, fileName) │
│ 内部流程: │
│ a. 创建 EasyExcel Writer + 临时文件 │
│ b. 循环调用 DataExtractor.queryData(param, page, pageSize) │
│ c. 每页数据写入 Excel(流式写入,不全部加载到内存) │
│ d. 数据为空时跳出循环 │
│ e. 将临时文件上传到 OSS │
│ f. 删除临时文件 │
│ 3. 将 OSS 文件路径写入 exchange_task.file_path │
│ 4. 更新任务状态为"导出完成" │
│ │
└────────────────────────────────────────────────────────────────────────┘
↓
┌─ 步骤4: 前端轮询获取下载地址 ─────────────────────────────────────────┐
│ │
│ GET /query-task-detail?taskId=2 │
│ 返回 filePath → 前端用 window.open(filePath) 下载 │
│ │
└────────────────────────────────────────────────────────────────────────┘
五、关键数据流转
导入时的数据变换
Excel 文件
↓ (EasyExcel 解析)
List<CustomerStockRow>(Java 对象列表)
↓ (JSON 序列化)
exchange_task_detail.data = '{"customerCode":"8700","stockQty":"5"}'
↓ (MQ 消费时 JSON 反序列化)
List<ExchangeTaskDetailDto<CustomerStockRow>>(带状态的包装对象)
↓ (校验)
每行标记 status = CHECK_SUCCESS 或 CHECK_FAIL
↓ (确认导入)
业务表 customer_stock_import_detail(最终入库)
ExchangeTaskDetailDto 结构
java
public class ExchangeTaskDetailDto<T> {
private Integer id; // exchange_task_detail 的 ID
private Integer taskId; // 关联的任务 ID
private T data; // 反序列化后的行数据对象
private Integer status; // 行状态(校验成功/失败/导入成功/导入失败)
}
六、完整示例代码
以下是一个"员工信息批量导入"的简化示例,演示如何使用这套框架。
6.1 定义数据模型(Excel 行结构)
java
package com.example.importer.employee;
import com.alibaba.excel.annotation.ExcelIgnore;
import com.alibaba.excel.annotation.ExcelProperty;
import jsh.mgt.lib.excel.annotation.ExcelFile;
import lombok.Data;
/**
* 员工导入行数据.
* headerIndex=2 表示第1行是表头,第2行是示例,第3行开始是数据.
*/
@Data
@ExcelFile(value = "员工信息导入", sheet = "员工数据", headerIndex = 2)
public class EmployeeRow {
@ExcelProperty(value = "工号", index = 0)
private String empCode;
@ExcelProperty(value = "姓名", index = 1)
private String empName;
@ExcelProperty(value = "部门", index = 2)
private String department;
@ExcelProperty(value = "职位", index = 3)
private String position;
@ExcelProperty(value = "薪资", index = 4)
private String salary;
@ExcelProperty(value = "校验结果", index = 5)
private String checkResult;
@ExcelIgnore
private Boolean checkSuccess = true;
@ExcelIgnore
private Boolean importStatus = true;
}
注解说明:
@ExcelFile:定义文件名、Sheet 名、数据起始行@ExcelProperty:定义列名和列序号@ExcelIgnore:该字段不映射 Excel 列(用于存放中间状态)
6.2 实现 Importer(校验 + 入库逻辑)
java
package com.example.importer.employee;
import com.example.enums.BizType;
import com.example.task.dto.ExchangeTaskDetailDto;
import com.example.task.enums.ExchangeTaskStatus;
import com.example.task.importer.AbstractImportTemplate;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 员工信息导入实现.
*
* 框架会自动:
* 1. 解析 Excel 文件为 List<EmployeeRow>
* 2. 异步调用 checkData() 校验
* 3. 用户确认后异步调用 importDate() 入库
*/
@Slf4j
@Component
public class EmployeeImporter extends AbstractImportTemplate<EmployeeRow> {
/**
* 告诉框架:行数据类型是 EmployeeRow.
* 框架用这个类型做 EasyExcel 解析和 JSON 反序列化.
*/
@Override
public Class<EmployeeRow> getDataType() {
return EmployeeRow.class;
}
/**
* 告诉框架:我属于哪个业务类型.
* 框架通过这个值路由到正确的 Importer.
*/
@Override
public BizType getBizType() {
return BizType.EMPLOYEE;
}
/**
* 校验逻辑(MQ 消费端异步调用).
* 对每一行做业务校验,标记成功或失败.
*/
@Override
public boolean checkData(List<ExchangeTaskDetailDto<EmployeeRow>> excelData) {
List<ExchangeTaskDetailDto<EmployeeRow>> successList = new LinkedList<>();
for (ExchangeTaskDetailDto<EmployeeRow> dto : excelData) {
EmployeeRow row = dto.getData();
// 初始设为校验失败(后面通过了再改为成功)
dto.setStatus(ExchangeTaskStatus.CHECK_FAIL.getStatus());
row.setCheckSuccess(false);
// 校验工号不能为空
if (row.getEmpCode() == null || row.getEmpCode().isBlank()) {
row.setCheckResult("工号不能为空");
continue;
}
// 校验姓名不能为空
if (row.getEmpName() == null || row.getEmpName().isBlank()) {
row.setCheckResult("姓名不能为空");
continue;
}
// 校验薪资必须为正数
try {
double salaryVal = Double.parseDouble(row.getSalary());
if (salaryVal <= 0) {
row.setCheckResult("薪资必须大于0");
continue;
}
} catch (Exception e) {
row.setCheckResult("薪资格式不正确");
continue;
}
// 全部通过
dto.setStatus(ExchangeTaskStatus.CHECK_SUCCESS.getStatus());
row.setCheckSuccess(true);
successList.add(dto);
}
return !successList.isEmpty();
}
/**
* 入库逻辑(用户确认后 MQ 消费端异步调用).
* 只会传入校验成功的数据.
*/
@Override
public boolean importDate(List<ExchangeTaskDetailDto<EmployeeRow>> excelData) {
for (ExchangeTaskDetailDto<EmployeeRow> dto : excelData) {
try {
EmployeeRow row = dto.getData();
// ===== 这里写实际的业务逻辑 =====
// 例如:调用人事服务的 Feign 接口创建员工
// employeeFeign.createEmployee(row.getEmpCode(), row.getEmpName(), ...);
log.info("员工导入成功: {} - {}", row.getEmpCode(), row.getEmpName());
dto.setStatus(ExchangeTaskStatus.SUCCESS.getStatus());
row.setImportStatus(true);
} catch (Exception e) {
dto.getData().setCheckResult("导入失败:" + e.getMessage());
dto.getData().setImportStatus(false);
dto.setStatus(ExchangeTaskStatus.FAIL.getStatus());
log.error("员工导入异常: {}", dto.getData().getEmpCode(), e);
}
}
return true;
}
@Override
public List<ExchangeTaskDetailDto<EmployeeRow>> checkImportDataForExchange(
List<ExchangeTaskDetailDto<EmployeeRow>> excelData) {
// exchange 工程内部调用的校验,复用 checkData 逻辑
checkData(excelData);
return excelData;
}
@Override
public List<ExchangeTaskDetailDto<EmployeeRow>> confirmImportDataForExchange(
List<ExchangeTaskDetailDto<EmployeeRow>> excelData) {
return new ArrayList<>();
}
}
6.3 实现 Exporter(导出)
导出参数:
java
package com.example.exporter.employee;
import lombok.Data;
/**
* 员工导出查询参数.
*/
@Data
public class EmployeeExportParam {
private String department; // 按部门筛选
}
导出行模型:
java
package com.example.exporter.employee;
import com.alibaba.excel.annotation.ExcelProperty;
import jsh.mgt.lib.excel.annotation.ExcelFile;
import lombok.Data;
/**
* 员工导出行.
*/
@Data
@ExcelFile(value = "员工信息", sheet = "员工数据", headerIndex = 1)
public class EmployeeExportRow {
@ExcelProperty(value = "工号", index = 0)
private String empCode;
@ExcelProperty(value = "姓名", index = 1)
private String empName;
@ExcelProperty(value = "部门", index = 2)
private String department;
@ExcelProperty(value = "职位", index = 3)
private String position;
@ExcelProperty(value = "薪资", index = 4)
private Double salary;
}
数据查询器(核心------告诉框架如何分页查数据):
java
package com.example.exporter.employee;
import com.example.task.exporter.DataExtractor;
import java.util.ArrayList;
import java.util.List;
import org.springframework.stereotype.Component;
/**
* 员工数据查询器.
* 框架会反复调用 queryData(param, page, pageSize) 直到返回空列表.
*/
@Component
public class EmployeeDataExtractor extends DataExtractor<EmployeeExportParam, EmployeeExportRow> {
// @Resource
// private EmployeeMapper employeeMapper; // 注入你的业务 Mapper
@Override
public List<EmployeeExportRow> queryData(EmployeeExportParam param, int page, int pageSize) {
// ===== 这里写实际的分页查询逻辑 =====
// Page<Employee> pageObj = new Page<>(page + 1, pageSize);
// LambdaQueryWrapper<Employee> wrapper = new LambdaQueryWrapper<>();
// wrapper.eq(param.getDepartment() != null, Employee::getDepartment, param.getDepartment());
// Page<Employee> result = employeeMapper.selectPage(pageObj, wrapper);
// return convert(result.getRecords()); // 转换为导出行模型
// 示例:模拟分页数据
if (page == 0) {
EmployeeExportRow row1 = new EmployeeExportRow();
row1.setEmpCode("E001");
row1.setEmpName("张三");
row1.setDepartment("技术部");
row1.setPosition("工程师");
row1.setSalary(15000.0);
EmployeeExportRow row2 = new EmployeeExportRow();
row2.setEmpCode("E002");
row2.setEmpName("李四");
row2.setDepartment("技术部");
row2.setPosition("架构师");
row2.setSalary(25000.0);
return List.of(row1, row2);
}
return new ArrayList<>(); // 第二页无数据,导出结束
}
@Override
public Class<EmployeeExportRow> getDataType() {
return EmployeeExportRow.class;
}
@Override
public Class<EmployeeExportParam> getParamType() {
return EmployeeExportParam.class;
}
@Override
public Integer pageSize() {
return 5000; // 每次查 5000 条
}
}
导出器(注册数据查询器):
java
package com.example.exporter.employee;
import com.example.enums.BizType;
import com.example.task.exporter.AbstractExportTemplate;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.stereotype.Component;
/**
* 员工信息导出.
*/
@Component
public class EmployeeExporter extends AbstractExportTemplate<EmployeeExportParam>
implements InitializingBean {
@Resource
private EmployeeDataExtractor employeeDataExtractor;
/**
* Bean 初始化时注册数据查询器.
* 一个 Exporter 可以注册多个 DataExtractor(多 Sheet 导出).
*/
@Override
public void afterPropertiesSet() throws Exception {
super.registerDataExtractors(employeeDataExtractor);
}
@Override
public String excelName() {
return "员工信息导出";
}
@Override
public BizType getBizType() {
return BizType.EMPLOYEE;
}
}
6.4 注册业务类型
java
public enum BizType {
DEMO,
EMPLOYEE, // 新增
// ... 未来的业务类型
}
6.5 前端调用流程
javascript
// ===== 导入流程 =====
// 1. 上传文件
const formData = new FormData();
formData.append('file', fileInput.files[0]);
const createRes = await fetch(
'/api/page/import/task/create-import-task?bizType=EMPLOYEE',
{ method: 'POST', body: formData }
);
const { taskId } = (await createRes.json()).data;
// 2. 轮询等待校验完成
let task;
do {
await sleep(2000); // 每2秒查一次
const res = await fetch(`/api/page/import/task/query-task-detail?taskId=${taskId}`);
task = (await res.json()).data;
} while (task.status < 2); // status=2 表示校验完成
// 3. 展示校验结果
console.log(`成功: ${task.successCount}, 失败: ${task.failCount}`);
// 4. 用户确认后触发入库
if (confirm('确认导入?')) {
await fetch(`/api/page/import/task/confirm?taskId=${taskId}`);
// 继续轮询直到 status=4(导入完成)
}
// ===== 导出流程 =====
// 1. 创建导出任务
const exportRes = await fetch('/api/page/import/task/create-export-task', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
bizType: 'EMPLOYEE',
queryParams: '{"department":"技术部"}'
})
});
const exportTaskId = (await exportRes.json()).data.taskId;
// 2. 轮询等待导出完成
let exportTask;
do {
await sleep(2000);
const res = await fetch(`/api/page/import/task/query-task-detail?taskId=${exportTaskId}`);
exportTask = (await res.json()).data;
} while (exportTask.status < 4);
// 3. 下载文件
window.open(exportTask.filePath);
七、框架的优势
| 优势 | 说明 |
|---|---|
| 统一 API | 所有业务共用一套接口(/create-import-task、/confirm、/create-export-task),前端只需按 bizType 区分 |
| 异步处理 | MQ 解耦,大数据量不阻塞请求 |
| 任务可追溯 | 每次导入导出都有任务记录,可查历史、可重新下载 |
| 校验和入库分离 | 用户可以先看校验结果再决定是否导入,减少脏数据 |
| 扩展简单 | 新增业务只需:1个 BizType + 1个 Row 类 + 1个 Importer + 1个 Exporter |
| 失败可定位 | 每行数据的校验结果和导入结果都有记录 |
八、新增一个业务类型的完整 Checklist
□ 1. BizType 枚举新增一个值
□ 2. 创建 Row 类(@ExcelFile + @ExcelProperty 注解)
□ 3. 创建 Importer(extends AbstractImportTemplate)
□ 实现 getDataType()
□ 实现 getBizType()
□ 实现 checkData() --- 校验逻辑
□ 实现 importDate() --- 入库逻辑
□ 4. 创建 ExportParam 类
□ 5. 创建 ExportRow 类(@ExcelFile + @ExcelProperty 注解)
□ 6. 创建 DataExtractor(extends DataExtractor)
□ 实现 queryData() --- 分页查询
□ 实现 getDataType()
□ 实现 getParamType()
□ 7. 创建 Exporter(extends AbstractExportTemplate)
□ afterPropertiesSet() 中注册 DataExtractor
□ 实现 excelName()
□ 实现 getBizType()
□ 8. 如果有业务表需要建表,创建 Entity + Mapper + XML
不需要写 Controller、不需要写 MQ 代码、不需要写任务管理逻辑------这些全部由框架提供。
九、框架内部运行机制(源码级分析)
9.1 请求如何路由到正确的 Importer
框架在启动时,Spring 容器会扫描所有 AbstractImportTemplate 的子类(它们都加了 @Component),然后框架内部维护一个 Map:
java
// 框架内部(简化)
Map<BizType, AbstractImportTemplate<?>> importerMap = new HashMap<>();
// 启动时自动注册:
// DEMO → DemoImporter
// CUSTOMER_STOCK → CustomerStockImporter
// EMPLOYEE → EmployeeImporter
当请求到来时:
java
// Controller 伪代码
public ExchangeTaskDto createImportTask(String bizType, MultipartFile file) {
BizType type = BizType.valueOf(bizType); // "CUSTOMER_STOCK" → BizType.CUSTOMER_STOCK
AbstractImportTemplate<?> importer = importerMap.get(type); // 找到对应实现
ExcelDetailDto<?> excelDetail = importer.read(file); // 解析 Excel
// ... 保存任务、发 MQ
}
9.2 MQ 消费时如何知道用哪个 Importer
MQ 消息体是 ExchangeTask 对象,其中包含 bizType 字段:
java
// MQ Consumer 伪代码
public void consume(ExchangeTask task) {
BizType type = BizType.valueOf(task.getBizType());
AbstractImportTemplate<?> importer = importerMap.get(type);
// 从数据库取出明细行数据
List<ExchangeTaskDetail> details = detailMapper.selectByTaskId(task.getId());
// JSON 反序列化为具体类型
List<ExchangeTaskDetailDto<T>> dtoList = details.stream().map(d -> {
T data = importer.convert(d.getData()); // JSON → EmployeeRow
ExchangeTaskDetailDto<T> dto = new ExchangeTaskDetailDto<>();
dto.setId(d.getId());
dto.setData(data);
return dto;
}).toList();
// 调用校验
importer.checkData(dtoList);
// 回写结果到数据库
for (ExchangeTaskDetailDto<T> dto : dtoList) {
detailMapper.updateStatus(dto.getId(), dto.getStatus());
}
}
9.3 导出时 DataExtractor 的循环调用
java
// AbstractExportTemplate.export() 内部(简化)
public String export(String paramStr, String fileName) {
Path tempFile = Files.createTempFile(...);
ExcelWriter excelWriter = EasyExcelFactory.write(tempFile).build();
for (DataExtractor extractor : dataExtractors) {
WriteSheet sheet = EasyExcelFactory.writerSheet(sheetName)
.head(extractor.getDataType()) // 用注解自动生成表头
.build();
T param = JsonUtil.deserialize(paramStr, extractor.getParamType());
int page = 0;
while (true) {
// 调用子类实现的分页查询
List<R> data = extractor.queryData(param, page, extractor.pageSize());
if (data.isEmpty()) break; // 没数据了,结束
excelWriter.write(data, sheet); // 写入当前页数据
page++;
}
}
excelWriter.finish();
// 上传到 OSS
String ossPath = aliOssTemplate.uploadFile(tempFile);
Files.delete(tempFile);
return ossPath;
}
十、任务状态流转
10.1 导入任务状态
创建任务 → [待校验(0)] → 发MQ
↓
MQ消费 → [校验中(1)]
↓
校验完成 → [校验完成(2)]
↓
用户确认 → [导入中(3)] → 发MQ
↓
MQ消费 → 执行 importDate()
↓
全部完成 → [导入完成(4)]
异常分支:
校验异常 → [校验失败(-1)]
导入异常 → [导入失败(-3)]
10.2 导出任务状态
创建任务 → [待处理(0)] → 发MQ
↓
MQ消费 → [处理中(1)]
↓
分页查询 + 写Excel + 上传OSS
↓
完成 → [导出完成(4)](filePath 有值)
异常分支:
处理异常 → [导出失败(-3)]
十一、exchange_task_detail.data 的序列化原理
这是框架最巧妙的设计之一------用一张通用的明细表存储所有业务类型的数据。
Excel 文件中的一行数据:
| 工号 | 姓名 | 部门 | 职位 | 薪资 |
| E001 | 张三 | 技术部 | 工程师 | 15000 |
EasyExcel 解析后的 Java 对象:
EmployeeRow { empCode="E001", empName="张三", department="技术部", position="工程师", salary="15000" }
JSON 序列化后存入 data 字段:
{"empCode":"E001","empName":"张三","department":"技术部","position":"工程师","salary":"15000"}
MQ 消费时反序列化:
importer.convert(jsonStr) → JSON → EmployeeRow 对象
(框架通过 getDataType() 知道目标类型是 EmployeeRow.class)
这样无论什么业务,明细表结构不变,只是 data 字段中的 JSON 内容不同。
十二、多 Sheet 导出
一个 Exporter 可以注册多个 DataExtractor,实现多 Sheet 导出:
java
@Component
public class OrderExporter extends AbstractExportTemplate<OrderExportParam>
implements InitializingBean {
@Resource
private OrderDataExtractor orderExtractor; // Sheet1: 订单数据
@Resource
private OrderItemDataExtractor itemExtractor; // Sheet2: 订单明细
@Override
public void afterPropertiesSet() {
registerDataExtractors(orderExtractor); // 注册第一个 Sheet
registerDataExtractors(itemExtractor); // 注册第二个 Sheet
}
}
框架会依次遍历所有 DataExtractor,每个生成一个 Sheet,最终合并为一个 Excel 文件。
十三、错误处理和重试
13.1 Redis 去重锁
java
// ImportTaskMqSender 中
Boolean flag = stringRedisTemplate.opsForValue()
.setIfAbsent(
"CHECK_KEY:" + taskId, // Key
taskId.toString(), // Value
1L, TimeUnit.HOURS // 1小时过期
);
if (!flag) {
return false; // 已经在处理中,不重复发送
}
防止同一个任务被重复发送 MQ(比如用户快速点击两次确认按钮)。
13.2 MQ 消费失败处理
java
// Consumer 中
try {
exchangeTaskService.checkForMq(importTask);
} catch (Exception e) {
log.error("校验异常", e);
throw e; // 抛出异常后 RocketMQ 会重试
}
// 成功后删除 Redis 锁
stringRedisTemplate.delete("CHECK_KEY:" + taskId);
RocketMQ 默认会重试消费失败的消息(最多16次),框架在成功后才删除 Redis 锁。
十四、总结对照表
| 概念 | 对应框架中的类/组件 | 职责 |
|---|---|---|
| 流程骨架 | AbstractImportTemplate / AbstractExportTemplate | 定义通用流程 |
| 业务实现 | XxxImporter / XxxExporter | 实现变化的部分 |
| 数据模型 | XxxRow(@ExcelProperty 注解) | 定义 Excel 列结构 |
| 业务路由 | BizType 枚举 | 标识不同业务 |
| 任务管理 | exchange_task 表 + ExchangeTaskService | 跟踪任务状态 |
| 行数据存储 | exchange_task_detail.data(JSON) | 通用存储任意结构 |
| 异步调度 | RocketMQ Producer / Consumer | 解耦上传和处理 |
| 分页导出 | DataExtractor.queryData(param, page, size) | 避免内存溢出 |
| 文件存储 | AliOssTemplate | 导出文件上传 |
| 去重保护 | Redis setIfAbsent | 防止重复处理 |
| 统一入口 | ExchangeTaskApi / ExchangeTaskController | 一套 API 服务所有业务 |