SpringBoot中异步导入处理-任务管理机制示例

SpringBoot中异步导入处理-任务管理机制示例

一、为什么需要任务管理

异步处理的一个核心问题:前端发起请求后,如何知道后端处理到哪一步了?

同步接口很简单------请求返回就是结果。但异步导入导出涉及:

  • 文件上传后,后端还在解析和校验
  • 用户确认后,后端还在写数据库
  • 导出请求后,后端还在生成文件

需要一个"任务记录"来追踪每次操作的状态。


注:

博客:

https://blog.csdn.net/badao_liumang_qizhi

二、表结构

2.1 exchange_task(任务主表)

sql 复制代码
CREATE TABLE exchange_task (
  id INT AUTO_INCREMENT PRIMARY KEY,
  biz_type VARCHAR(32),          -- 业务类型(DEMO/...)
  exchange_type VARCHAR(16),     -- 操作类型(IMPORT/EXPORT)
  file_name VARCHAR(256),        -- 文件名称
  file_path VARCHAR(512),        -- 文件路径(导出完成后的OSS地址)
  status INT,                    -- 任务状态
  expires DATETIME,              -- 过期时间(导出文件的有效期)
  create_user_id INT,            -- 创建人ID
  create_user_name VARCHAR(64),  -- 创建人姓名
  update_user_id INT,
  update_user_name VARCHAR(64),
  create_time DATETIME,
  update_time DATETIME,
  version INT DEFAULT 0          -- 乐观锁版本号
);

2.2 exchange_task_detail(任务明细表)

sql 复制代码
CREATE TABLE exchange_task_detail (
  id INT AUTO_INCREMENT PRIMARY KEY,
  task_id INT,          -- 关联 exchange_task.id
  data TEXT,            -- 行数据(JSON字符串)
  status INT            -- 行状态
);

2.3 两张表的关系

复制代码
exchange_task(一条记录 = 一次导入/导出操作)
  │
  │ 1 : N
  ▼
exchange_task_detail(每条记录 = Excel中的一行数据)

示例:用户上传了一个包含 8 万行的 Excel

复制代码
exchange_task:
  id=1, biz_type=CUSTOMER_STOCK, status=2(校验完成), file_name=xxx

exchange_task_detail:(8万条记录)
  id=1, task_id=1, data='{"customerCode":"8700001","stockQty":"100"}', status=1(校验成功)
  id=2, task_id=1, data='{"customerCode":"8700002","stockQty":"abc"}', status=-1(校验失败)
  id=3, task_id=1, data='{"customerCode":"8700003","stockQty":"50"}',  status=1(校验成功)
  ...

三、任务状态定义

3.1 任务主表状态(exchange_task.status)

java 复制代码
public enum ExchangeTaskStatus {
  // 导入相关
  WAIT_CHECK(0, "待校验"),        // 创建后等待MQ消费
  CHECKING(1, "校验中"),          // MQ消费端正在校验
  CHECK_DONE(2, "校验完成"),      // 校验完成,等待用户确认
  IMPORTING(3, "导入中"),         // 用户确认后,正在入库
  SUCCESS(4, "完成"),             // 导入/导出 成功完成

  CHECK_FAIL(-1, "校验失败"),     // 校验过程异常
  IMPORT_FAIL(-3, "导入失败"),    // 导入过程异常

  // 明细行状态
  CHECK_SUCCESS(1, "校验通过"),
  CHECK_FAIL_ROW(-1, "校验不通过"),
  IMPORT_SUCCESS(3, "导入成功"),
  IMPORT_FAIL_ROW(-3, "导入失败"),
}

3.2 状态流转图

导入任务:

复制代码
                 创建任务
                    │
                    ▼
    ┌──── 待校验(0) ────┐
    │                    │
    │ 发MQ              │ 异常
    ▼                    ▼
 校验中(1)         校验失败(-1)
    │
    │ 校验完成
    ▼
 校验完成(2) ←─── 前端轮询到此状态,展示结果
    │
    │ 用户点击确认
    ▼
 导入中(3)
    │
    │ 入库完成
    ▼
 完成(4)
    │
    │ 入库异常
    ▼
 导入失败(-3)

导出任务:

复制代码
 创建任务 → 待处理(0) → 发MQ → 处理中(1) → 生成文件 → 完成(4)
                                              │
                                              │ 异常
                                              ▼
                                         导出失败(-3)

四、ExchangeTaskService 核心方法

4.1 创建导入任务

java 复制代码
public ExchangeTaskDto createImportTask(BizType importType, MultipartFile file) {
    // 1. 根据 bizType 找到对应的 Importer
    AbstractImportTemplate<?> importer = findImporter(importType);

    // 2. 解析 Excel 文件
    ExcelDetailDto<?> excelDetail = importer.read(file);
    List<?> dataList = excelDetail.getData();

    // 3. 创建 exchange_task 主记录
    ExchangeTask task = new ExchangeTask();
    task.setBizType(importType.name());
    task.setExchangeType("IMPORT");
    task.setFileName(excelDetail.getFileName());
    task.setStatus(0);  // 待校验
    task.setCreateTime(new Date());
    taskMapper.insert(task);

    // 4. 每行数据序列化后存入 exchange_task_detail
    for (Object rowData : dataList) {
        ExchangeTaskDetail detail = new ExchangeTaskDetail();
        detail.setTaskId(task.getId());
        detail.setData(JsonUtil.serialize(rowData));  // Java对象 → JSON字符串
        detail.setStatus(0);  // 待校验
        detailMapper.insert(detail);
    }

    // 5. 发送 RocketMQ 消息触发异步校验
    importTaskMqSender.sendCheck(task);

    // 6. 返回任务信息
    return convertToDto(task);
}

4.2 MQ 消费------异步校验

java 复制代码
public void checkForMq(ExchangeTask task) {
    // 1. 更新任务状态为"校验中"
    task.setStatus(1);
    taskMapper.updateById(task);

    // 2. 查出所有明细行
    List<ExchangeTaskDetail> details = detailMapper.selectByTaskId(task.getId());

    // 3. 找到对应的 Importer
    AbstractImportTemplate importer = findImporter(BizType.valueOf(task.getBizType()));

    // 4. JSON反序列化 + 包装为 ExchangeTaskDetailDto
    List<ExchangeTaskDetailDto> dtoList = details.stream().map(d -> {
        ExchangeTaskDetailDto dto = new ExchangeTaskDetailDto();
        dto.setId(d.getId());
        dto.setTaskId(d.getTaskId());
        dto.setData(importer.convert(d.getData()));  // JSON → 业务对象
        dto.setStatus(d.getStatus());
        return dto;
    }).toList();

    // 5. 调用业务校验逻辑
    importer.checkData(dtoList);

    // 6. 回写每行的校验结果到数据库
    for (ExchangeTaskDetailDto dto : dtoList) {
        ExchangeTaskDetail detail = new ExchangeTaskDetail();
        detail.setId(dto.getId());
        detail.setStatus(dto.getStatus());
        detail.setData(JsonUtil.serialize(dto.getData())); // 含 checkResult
        detailMapper.updateById(detail);
    }

    // 7. 更新任务状态为"校验完成"
    task.setStatus(2);
    taskMapper.updateById(task);
}

4.3 确认导入

java 复制代码
public ExchangeTaskDto confirm(Integer taskId) {
    ExchangeTask task = taskMapper.selectById(taskId);
    // 只有校验完成的任务才能确认
    if (task.getStatus() != 2) {
        throw new RuntimeException("当前状态不允许确认导入");
    }
    // 更新状态为"导入中"
    task.setStatus(3);
    taskMapper.updateById(task);
    // 发MQ触发异步入库
    importTaskMqSender.sendConfirm(task);
    return convertToDto(task);
}

4.4 MQ 消费------异步入库

java 复制代码
public void importForMq(ExchangeTask task) {
    // 1. 查出校验成功的行
    List<ExchangeTaskDetail> successDetails = detailMapper.selectByTaskIdAndStatus(
        task.getId(), ExchangeTaskStatus.CHECK_SUCCESS.getStatus());

    // 2. 反序列化
    AbstractImportTemplate importer = findImporter(BizType.valueOf(task.getBizType()));
    List<ExchangeTaskDetailDto> dtoList = ... // 同校验流程

    // 3. 调用业务入库逻辑
    importer.importDate(dtoList);

    // 4. 回写导入结果
    for (ExchangeTaskDetailDto dto : dtoList) {
        detailMapper.updateStatus(dto.getId(), dto.getStatus());
    }

    // 5. 更新任务状态为"完成"
    task.setStatus(4);
    taskMapper.updateById(task);
}

4.5 查询任务详情(前端轮询)

java 复制代码
public ExchangeTaskDto queryTask(Integer taskId, Integer status, Integer pageNum, Integer pageSize) {
    // 1. 查主表
    ExchangeTask task = taskMapper.selectById(taskId);
    ExchangeTaskDto dto = convertToDto(task);

    // 2. 统计明细:成功数、失败数
    int successCount = detailMapper.countByStatus(taskId, CHECK_SUCCESS);
    int failCount = detailMapper.countByStatus(taskId, CHECK_FAIL);
    dto.setSuccessCount(successCount);
    dto.setFailCount(failCount);

    // 3. 分页查明细(可按状态筛选)
    Page<ExchangeTaskDetail> page = detailMapper.selectPage(taskId, status, pageNum, pageSize);
    dto.setDetails(page.getRecords());

    return dto;
}

4.6 创建导出任务

java 复制代码
public ExchangeTaskDto createExportTask(BizType type, String queryParams) {
    // 1. 创建任务记录
    ExchangeTask task = new ExchangeTask();
    task.setBizType(type.name());
    task.setExchangeType("EXPORT");
    task.setStatus(0);
    taskMapper.insert(task);

    // 2. 发MQ触发异步导出
    exportTaskMqSender.send(task, queryParams);

    return convertToDto(task);
}

4.7 MQ 消费------异步导出

java 复制代码
public void exportForMq(ExchangeTask task) {
    // 1. 找到对应的 Exporter
    AbstractExportTemplate exporter = findExporter(BizType.valueOf(task.getBizType()));

    // 2. 调用导出逻辑(分页查询 + 写Excel + 上传OSS)
    String ossFilePath = exporter.export(task.getQueryParams(), exporter.excelName());

    // 3. 将OSS路径存入任务记录
    task.setFilePath(ossFilePath);
    task.setStatus(4);  // 完成
    task.setExpires(DateUtils.addDays(new Date(), 1)); // 1天有效期
    taskMapper.updateById(task);
}

五、前端轮询机制

5.1 为什么用轮询而非 WebSocket

方式 优点 缺点 适用
轮询 实现简单,兼容性好 有延迟(轮询间隔),浪费请求 大多数场景
WebSocket 实时推送 需要维护长连接,实现复杂 实时性要求极高
SSE 服务端推送,比WS简单 浏览器兼容性一般 中等实时性

导入导出场景通常几秒到几十秒完成,2秒一次轮询完全够用。

5.2 前端轮询示例

javascript 复制代码
async function waitForTaskComplete(taskId) {
  const MAX_RETRY = 60;  // 最多轮询60次(2分钟)
  let retry = 0;

  while (retry < MAX_RETRY) {
    const res = await fetch(`/api/page/import/task/query-task-detail?taskId=${taskId}`);
    const { data } = await res.json();

    switch (data.status) {
      case 0: // 待处理
      case 1: // 处理中
        // 显示进度
        showProgress('处理中...');
        break;
      case 2: // 校验完成
        return { type: 'checkDone', data };
      case 4: // 完成
        return { type: 'success', data };
      case -1: // 校验失败
      case -3: // 导入/导出失败
        return { type: 'failed', data };
    }

    await sleep(2000); // 等待2秒
    retry++;
  }

  throw new Error('任务超时');
}

六、乐观锁(Version 字段)

java 复制代码
@Version
private int version;

exchange_task 表有 version 字段,配合 MyBatis-Plus 的 @Version 注解实现乐观锁:

sql 复制代码
-- MyBatis-Plus 自动生成的 UPDATE 语句
UPDATE exchange_task 
SET status = 2, version = version + 1 
WHERE id = 1 AND version = 0;

解决的问题:防止并发更新冲突。比如:

  • MQ 消费端在更新状态为"校验完成"
  • 同时用户可能在重复点击"确认"

有了乐观锁,只有一个操作能成功 UPDATE(version 匹配的那个),另一个因为 version 不匹配而更新 0 行。


七、完整示例

7.1 简化版任务管理------"文件转换服务"

场景:用户上传 Word 文件,后台异步转换为 PDF,转换完成后用户下载。

7.2 任务表

sql 复制代码
CREATE TABLE convert_task (
  id INT AUTO_INCREMENT PRIMARY KEY,
  file_name VARCHAR(256) NOT NULL,          -- 原始文件名
  source_path VARCHAR(512),                 -- 原始文件路径
  result_path VARCHAR(512),                 -- 转换结果路径
  status INT NOT NULL DEFAULT 0,            -- 0=待处理 1=处理中 2=完成 -1=失败
  error_msg VARCHAR(512),                   -- 失败原因
  create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
  update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

7.3 Entity

java 复制代码
package com.example.entity;

import com.baomidou.mybatisplus.annotation.*;
import java.util.Date;
import lombok.Data;

@Data
@TableName("convert_task")
public class ConvertTask {
  @TableId(type = IdType.AUTO)
  private Integer id;
  private String fileName;
  private String sourcePath;
  private String resultPath;
  private Integer status;
  private String errorMsg;
  private Date createTime;
  private Date updateTime;
}

7.4 状态枚举

java 复制代码
package com.example.enums;

import lombok.Getter;

@Getter
public enum TaskStatus {
  PENDING(0, "待处理"),
  PROCESSING(1, "处理中"),
  COMPLETED(2, "完成"),
  FAILED(-1, "失败");

  private final int code;
  private final String desc;

  TaskStatus(int code, String desc) {
    this.code = code;
    this.desc = desc;
  }
}

7.5 Service

java 复制代码
package com.example.service;

import com.example.entity.ConvertTask;
import com.example.enums.TaskStatus;
import com.example.mapper.ConvertTaskMapper;
import jakarta.annotation.Resource;
import java.util.Date;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Slf4j
@Service
public class ConvertTaskService {

  @Resource
  private ConvertTaskMapper taskMapper;

  @Resource
  private ConvertMqProducer mqProducer;

  /**
   * 创建转换任务.
   * 1. 保存原始文件
   * 2. 创建任务记录(状态=待处理)
   * 3. 发MQ消息
   * 4. 返回taskId
   */
  public ConvertTask createTask(String fileName, String sourcePath) {
    ConvertTask task = new ConvertTask();
    task.setFileName(fileName);
    task.setSourcePath(sourcePath);
    task.setStatus(TaskStatus.PENDING.getCode());
    task.setCreateTime(new Date());
    taskMapper.insert(task);

    // 发送MQ,异步处理
    mqProducer.send(task.getId());
    log.info("转换任务已创建, taskId={}", task.getId());
    return task;
  }

  /**
   * 查询任务状态(前端轮询调用).
   */
  public ConvertTask queryTask(Integer taskId) {
    return taskMapper.selectById(taskId);
  }

  /**
   * MQ消费端调用:执行转换.
   */
  public void processTask(Integer taskId) {
    ConvertTask task = taskMapper.selectById(taskId);
    if (task == null || task.getStatus() != TaskStatus.PENDING.getCode()) {
      return;
    }

    // 更新状态为"处理中"
    task.setStatus(TaskStatus.PROCESSING.getCode());
    taskMapper.updateById(task);

    try {
      // 执行实际的文件转换逻辑
      String resultPath = doConvert(task.getSourcePath());

      // 更新状态为"完成"
      task.setResultPath(resultPath);
      task.setStatus(TaskStatus.COMPLETED.getCode());
      taskMapper.updateById(task);
      log.info("转换完成, taskId={}", taskId);

    } catch (Exception e) {
      // 更新状态为"失败"
      task.setStatus(TaskStatus.FAILED.getCode());
      task.setErrorMsg(e.getMessage());
      taskMapper.updateById(task);
      log.error("转换失败, taskId={}", taskId, e);
    }
  }

  private String doConvert(String sourcePath) {
    // 实际转换逻辑...
    return "oss://converted/result.pdf";
  }
}

7.6 Controller

java 复制代码
package com.example.controller;

import com.example.entity.ConvertTask;
import com.example.service.ConvertTaskService;
import jakarta.annotation.Resource;
import java.util.Map;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

@RestController
@RequestMapping("/api/convert")
public class ConvertController {

  @Resource
  private ConvertTaskService taskService;

  /**
   * 上传文件并创建转换任务.
   */
  @PostMapping("/create")
  public Map<String, Object> create(@RequestParam MultipartFile file) {
    // 保存文件到 OSS/本地...
    String sourcePath = saveFile(file);

    // 创建任务
    ConvertTask task = taskService.createTask(file.getOriginalFilename(), sourcePath);

    return Map.of("success", true, "data", Map.of(
        "taskId", task.getId(),
        "status", task.getStatus(),
        "statusDesc", "待处理"
    ));
  }

  /**
   * 查询任务状态(前端轮询).
   */
  @GetMapping("/status")
  public Map<String, Object> status(@RequestParam Integer taskId) {
    ConvertTask task = taskService.queryTask(taskId);
    if (task == null) {
      return Map.of("success", false, "errorMsg", "任务不存在");
    }
    return Map.of("success", true, "data", Map.of(
        "taskId", task.getId(),
        "status", task.getStatus(),
        "resultPath", task.getResultPath() != null ? task.getResultPath() : "",
        "errorMsg", task.getErrorMsg() != null ? task.getErrorMsg() : ""
    ));
  }
}

7.7 前端交互

javascript 复制代码
// 1. 上传文件
const formData = new FormData();
formData.append('file', fileInput.files[0]);
const res = await fetch('/api/convert/create', { method: 'POST', body: formData });
const { taskId } = (await res.json()).data;

// 2. 轮询等待完成
const checkStatus = async () => {
  const res = await fetch(`/api/convert/status?taskId=${taskId}`);
  const { data } = await res.json();

  if (data.status === 2) {
    // 完成,下载结果
    window.open(data.resultPath);
    return;
  }
  if (data.status === -1) {
    // 失败
    alert('转换失败:' + data.errorMsg);
    return;
  }
  // 继续轮询
  setTimeout(checkStatus, 2000);
};
checkStatus();

八、任务管理的核心价值

没有任务管理 有任务管理
前端不知道后台处理到哪了 前端随时可查状态
处理失败用户不知道 失败原因清楚记录
无法追溯历史操作 所有操作都有记录
重复提交无法识别 通过 taskId 去重
无法断点续传/重试 失败的任务可以重新触发
并发操作混乱 乐观锁保证状态一致

九、总结

复制代码
任务管理 = 数据库记录 + 状态机 + 前端轮询

核心思想:
  用一张表记录异步操作的生命周期,
  前端通过轮询获取最新状态,
  后端通过状态流转控制执行顺序。

exchange_task 表的角色:
  - 对前端:进度看板(当前第几步了)
  - 对后端:状态机(只有特定状态才能执行下一步)
  - 对运维:操作日志(谁在什么时候导入了什么)
  - 对系统:去重凭证(同一个任务不重复处理)