SpringBoot中抽离服务框架实现大数据量校验并异步mq导入导出

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 服务所有业务