Skip to content

异步导出大文件

约 6263 字大约 21 分钟

SpringBoot

2025-10-21

一个开箱即用的异步导出框架,支持大数据量、图片、进度追踪,可快速集成到任何Spring Boot项目

📋 目录


一、方案概述

1.1 解决的问题

  • ✅ 导出大量数据时浏览器超时
  • ✅ 同步导出导致页面长时间无响应
  • ✅ 包含图片等二进制资源导致内存溢出
  • ✅ 无法追踪导出进度
  • ✅ 导出失败无法重试

1.2 技术架构

┌──────────────┐                    ┌──────────────┐
   前端应用                           后端服务    
                ①创建导出任务                    
               ──────────────────>               
                ②返回任务ID                      
               <──────────────────               
                                                
  进度展示       ③轮询任务状态        异步处理     
  (2秒/次)     <─────────────────>   (线程池)    
                                                
                ④任务完成通知                    
               <──────────────────               
                ⑤下载文件                        
               ──────────────────>               
└──────────────┘                    └──────────────┘

1.3 核心特性

特性说明
🚀 开箱即用只需实现数据查询接口即可使用
📊 进度追踪实时显示导出进度和处理数量
🎯 泛型设计支持任意数据类型的导出
🖼️ 图片支持内置图片异步下载和超时控制
💾 分批处理避免内存溢出,支持百万级数据
🔄 断点续传支持任务重试和失败恢复
🗂️ 历史记录保存导出历史,随时重新下载
🔐 权限控制用户只能访问自己的导出文件

二、快速开始

2.1 Maven依赖

<dependencies>
    <!-- Spring Boot Starter -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    
    <!-- MyBatis Plus -->
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
        <version>3.5.3.1</version>
    </dependency>
    
    <!-- POI for Excel -->
    <dependency>
        <groupId>org.apache.poi</groupId>
        <artifactId>poi-ooxml</artifactId>
        <version>5.2.3</version>
    </dependency>
    
    <!-- Hutool工具类 -->
    <dependency>
        <groupId>cn.hutool</groupId>
        <artifactId>hutool-all</artifactId>
        <version>5.8.18</version>
    </dependency>
    
    <!-- Lombok -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

2.2 配置文件

# application.yml
spring:
  # 异步任务配置
  task:
    execution:
      pool:
        core-size: 2
        max-size: 5
        queue-capacity: 100
        keep-alive: 60s

# 导出配置
export:
  # 文件存储路径
  file-path: /data/export
  # 文件保留天数
  keep-days: 7
  # 单用户最大并发任务数
  max-concurrent-per-user: 3
  # 全局最大并发任务数
  max-concurrent-global: 50

2.3 数据库脚本

-- 导出任务表
CREATE TABLE `export_task` (
  `id` varchar(32) NOT NULL COMMENT '任务ID',
  `task_name` varchar(200) DEFAULT NULL COMMENT '任务名称',
  `task_type` varchar(50) DEFAULT NULL COMMENT '任务类型',
  `status` tinyint(4) DEFAULT 0 COMMENT '状态:0-待处理 1-处理中 2-已完成 3-失败 4-已过期',
  `progress` int(11) DEFAULT 0 COMMENT '进度百分比(0-100)',
  `query_params` text COMMENT '查询参数(JSON)',
  `total_count` int(11) DEFAULT NULL COMMENT '总记录数',
  `processed_count` int(11) DEFAULT 0 COMMENT '已处理数量',
  `file_path` varchar(500) DEFAULT NULL COMMENT '文件存储路径',
  `file_name` varchar(200) DEFAULT NULL COMMENT '文件名',
  `file_size` bigint(20) DEFAULT NULL COMMENT '文件大小(字节)',
  `error_msg` text COMMENT '错误信息',
  `create_user` varchar(32) DEFAULT NULL COMMENT '创建人ID',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `finish_time` datetime DEFAULT NULL COMMENT '完成时间',
  PRIMARY KEY (`id`),
  KEY `idx_create_user` (`create_user`),
  KEY `idx_status` (`status`),
  KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='导出任务表';

三、核心代码

3.1 通用数据提供者接口

package com.example.export.core;

import java.util.List;

/**
 * 导出数据提供者接口
 * @param <T> 数据实体类型
 */
public interface ExportDataProvider<T> {
    
    /**
     * 查询总记录数
     * @param params 查询参数
     * @return 总数
     */
    int queryTotalCount(String params);
    
    /**
     * 分页查询数据
     * @param params 查询参数
     * @param offset 偏移量
     * @param limit 每页数量
     * @return 数据列表
     */
    List<T> queryData(String params, int offset, int limit);
}

3.2 Excel行数据转换器接口

package com.example.export.core;

import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Workbook;

/**
 * Excel行数据转换器
 * @param <T> 数据实体类型
 */
public interface ExcelRowConverter<T> {
    
    /**
     * 获取表头
     * @return 表头数组
     */
    String[] getHeaders();
    
    /**
     * 设置列宽
     * @return 列宽数组(单位:256分之一字符)
     */
    default int[] getColumnWidths() {
        return new int[0]; // 默认使用自动宽度
    }
    
    /**
     * 将数据转换为Excel行
     * @param workbook Excel工作簿
     * @param row Excel行对象
     * @param data 数据对象
     * @param rowIndex 行索引
     */
    void convertToRow(Workbook workbook, Row row, T data, int rowIndex);
}

3.3 通用异步导出处理器

package com.example.export.core;

import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.IdUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.io.File;
import java.io.FileOutputStream;
import java.util.Date;
import java.util.List;

/**
 * 通用异步导出处理器
 */
@Slf4j
@Component
public class GenericAsyncExportHandler {
    
    @Resource
    private ExportTaskService exportTaskService;
    
    @Value("${export.file-path:/data/export}")
    private String exportFilePath;
    
    /**
     * 执行导出任务
     * @param taskId 任务ID
     * @param taskType 任务类型
     * @param dataProvider 数据提供者
     * @param rowConverter 行转换器
     * @param queryParams 查询参数(JSON格式)
     * @param <T> 数据类型
     */
    @Async("exportTaskExecutor")
    public <T> void executeExport(
            String taskId,
            String taskType,
            ExportDataProvider<T> dataProvider,
            ExcelRowConverter<T> rowConverter,
            String queryParams) {
        
        log.info("开始执行导出任务: taskId={}, taskType={}", taskId, taskType);
        
        SXSSFWorkbook workbook = null;
        FileOutputStream fos = null;
        
        try {
            // 1. 查询总数
            int totalCount = dataProvider.queryTotalCount(queryParams);
            log.info("查询到数据总数: {}", totalCount);
            
            if (totalCount == 0) {
                exportTaskService.updateTaskFailed(taskId, "没有符合条件的数据");
                return;
            }
            
            // 2. 更新任务总数
            exportTaskService.updateTaskTotal(taskId, totalCount);
            
            // 3. 创建Excel工作簿(使用流式写入,内存中只保留100行)
            workbook = new SXSSFWorkbook(100);
            workbook.setCompressTempFiles(true); // 启用临时文件压缩
            Sheet sheet = workbook.createSheet("数据");
            
            // 4. 创建样式
            CellStyle headerStyle = createHeaderStyle(workbook);
            CellStyle dataStyle = createDataStyle(workbook);
            
            // 5. 创建表头
            createHeaderRow(sheet, rowConverter.getHeaders(), headerStyle);
            
            // 6. 设置列宽
            setColumnWidths(sheet, rowConverter.getColumnWidths());
            
            // 7. 分批处理数据
            int batchSize = 100; // 每批处理100条
            int processedCount = 0;
            int offset = 0;
            
            while (processedCount < totalCount) {
                // 分页查询数据
                List<T> dataList = dataProvider.queryData(queryParams, offset, batchSize);
                
                if (dataList == null || dataList.isEmpty()) {
                    break;
                }
                
                // 写入Excel
                for (T data : dataList) {
                    int rowNum = processedCount + 1; // 跳过表头
                    Row row = sheet.createRow(rowNum);
                    
                    // 转换数据到Excel行
                    rowConverter.convertToRow(workbook, row, data, rowNum);
                    
                    processedCount++;
                }
                
                // 更新进度
                int progress = (int) ((processedCount * 100.0) / totalCount);
                exportTaskService.updateTaskProgress(taskId, progress, processedCount);
                log.info("导出进度: {}/{} ({}%)", processedCount, totalCount, progress);
                
                offset += batchSize;
            }
            
            // 8. 保存文件
            String fileName = generateFileName(taskType);
            File exportFile = saveWorkbook(workbook, fileName);
            
            log.info("导出完成: file={}, size={}", exportFile.getAbsolutePath(), exportFile.length());
            
            // 9. 更新任务状态为成功
            exportTaskService.updateTaskSuccess(
                taskId, 
                exportFile.getAbsolutePath(), 
                fileName, 
                exportFile.length()
            );
            
        } catch (Exception e) {
            log.error("导出任务执行失败: taskId={}", taskId, e);
            exportTaskService.updateTaskFailed(taskId, "导出失败: " + e.getMessage());
        } finally {
            // 清理资源
            closeQuietly(fos);
            if (workbook != null) {
                try {
                    workbook.dispose(); // 清理临时文件
                    workbook.close();
                } catch (Exception e) {
                    log.error("关闭工作簿失败", e);
                }
            }
        }
    }
    
    /**
     * 创建表头样式
     */
    private CellStyle createHeaderStyle(Workbook workbook) {
        CellStyle style = workbook.createCellStyle();
        
        // 背景色
        style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
        style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
        
        // 边框
        style.setBorderBottom(BorderStyle.THIN);
        style.setBorderTop(BorderStyle.THIN);
        style.setBorderLeft(BorderStyle.THIN);
        style.setBorderRight(BorderStyle.THIN);
        
        // 对齐方式
        style.setAlignment(HorizontalAlignment.CENTER);
        style.setVerticalAlignment(VerticalAlignment.CENTER);
        
        // 字体
        Font font = workbook.createFont();
        font.setBold(true);
        font.setFontHeightInPoints((short) 11);
        style.setFont(font);
        
        return style;
    }
    
    /**
     * 创建数据样式
     */
    private CellStyle createDataStyle(Workbook workbook) {
        CellStyle style = workbook.createCellStyle();
        
        // 边框
        style.setBorderBottom(BorderStyle.THIN);
        style.setBorderTop(BorderStyle.THIN);
        style.setBorderLeft(BorderStyle.THIN);
        style.setBorderRight(BorderStyle.THIN);
        
        // 对齐方式
        style.setAlignment(HorizontalAlignment.LEFT);
        style.setVerticalAlignment(VerticalAlignment.CENTER);
        style.setWrapText(true); // 自动换行
        
        return style;
    }
    
    /**
     * 创建表头行
     */
    private void createHeaderRow(Sheet sheet, String[] headers, CellStyle headerStyle) {
        Row headerRow = sheet.createRow(0);
        headerRow.setHeight((short) 500); // 设置行高
        
        for (int i = 0; i < headers.length; i++) {
            Cell cell = headerRow.createCell(i);
            cell.setCellValue(headers[i]);
            cell.setCellStyle(headerStyle);
        }
    }
    
    /**
     * 设置列宽
     */
    private void setColumnWidths(Sheet sheet, int[] columnWidths) {
        if (columnWidths == null || columnWidths.length == 0) {
            return;
        }
        
        for (int i = 0; i < columnWidths.length; i++) {
            sheet.setColumnWidth(i, columnWidths[i]);
        }
    }
    
    /**
     * 生成文件名
     */
    private String generateFileName(String taskType) {
        String timestamp = DateUtil.format(new Date(), "yyyyMMdd_HHmmss");
        return String.format("%s_%s.xlsx", taskType, timestamp);
    }
    
    /**
     * 保存工作簿到文件
     */
    private File saveWorkbook(SXSSFWorkbook workbook, String fileName) throws Exception {
        // 创建按日期分组的目录
        String dateDir = DateUtil.format(new Date(), "yyyy/MM/dd");
        File exportDir = new File(exportFilePath, dateDir);
        
        if (!exportDir.exists()) {
            exportDir.mkdirs();
        }
        
        File exportFile = new File(exportDir, fileName);
        
        try (FileOutputStream fos = new FileOutputStream(exportFile)) {
            workbook.write(fos);
            fos.flush();
        }
        
        return exportFile;
    }
    
    /**
     * 安全关闭流
     */
    private void closeQuietly(AutoCloseable closeable) {
        if (closeable != null) {
            try {
                closeable.close();
            } catch (Exception e) {
                log.warn("关闭资源失败", e);
            }
        }
    }
}

3.4 导出任务实体

package com.example.export.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

import java.io.Serializable;
import java.util.Date;

/**
 * 导出任务实体
 */
@Data
@TableName("export_task")
public class ExportTask implements Serializable {
    
    private static final long serialVersionUID = 1L;
    
    /**
     * 任务ID
     */
    @TableId(type = IdType.ASSIGN_ID)
    private String id;
    
    /**
     * 任务名称
     */
    private String taskName;
    
    /**
     * 任务类型
     */
    private String taskType;
    
    /**
     * 状态:0-待处理,1-处理中,2-已完成,3-失败,4-已过期
     */
    private Integer status;
    
    /**
     * 进度百分比:0-100
     */
    private Integer progress;
    
    /**
     * 查询参数(JSON格式)
     */
    private String queryParams;
    
    /**
     * 总记录数
     */
    private Integer totalCount;
    
    /**
     * 已处理数量
     */
    private Integer processedCount;
    
    /**
     * 文件路径
     */
    private String filePath;
    
    /**
     * 文件名
     */
    private String fileName;
    
    /**
     * 文件大小(字节)
     */
    private Long fileSize;
    
    /**
     * 错误信息
     */
    private String errorMsg;
    
    /**
     * 创建人
     */
    private String createUser;
    
    /**
     * 创建时间
     */
    private Date createTime;
    
    /**
     * 完成时间
     */
    private Date finishTime;
    
    // 状态常量
    public static final int STATUS_PENDING = 0;
    public static final int STATUS_PROCESSING = 1;
    public static final int STATUS_COMPLETED = 2;
    public static final int STATUS_FAILED = 3;
    public static final int STATUS_EXPIRED = 4;
}

3.5 导出任务服务

package com.example.export.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.example.export.entity.ExportTask;

/**
 * 导出任务服务接口
 */
public interface ExportTaskService extends IService<ExportTask> {
    
    /**
     * 创建导出任务
     */
    String createExportTask(String taskName, String taskType, String queryParams, String createUser);
    
    /**
     * 更新任务总数
     */
    void updateTaskTotal(String taskId, int totalCount);
    
    /**
     * 更新任务进度
     */
    void updateTaskProgress(String taskId, int progress, int processedCount);
    
    /**
     * 更新任务状态为完成
     */
    void updateTaskSuccess(String taskId, String filePath, String fileName, long fileSize);
    
    /**
     * 更新任务状态为失败
     */
    void updateTaskFailed(String taskId, String errorMsg);
    
    /**
     * 检查用户并发任务数限制
     */
    boolean checkConcurrentLimit(String userId, int maxConcurrent);
}
package com.example.export.service.impl;

import cn.hutool.core.util.IdUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.export.entity.ExportTask;
import com.example.export.mapper.ExportTaskMapper;
import com.example.export.service.ExportTaskService;
import org.springframework.stereotype.Service;

import java.util.Date;

/**
 * 导出任务服务实现
 */
@Service
public class ExportTaskServiceImpl extends ServiceImpl<ExportTaskMapper, ExportTask> 
        implements ExportTaskService {
    
    @Override
    public String createExportTask(String taskName, String taskType, String queryParams, String createUser) {
        ExportTask task = new ExportTask();
        task.setId(IdUtil.fastSimpleUUID());
        task.setTaskName(taskName);
        task.setTaskType(taskType);
        task.setStatus(ExportTask.STATUS_PENDING);
        task.setProgress(0);
        task.setQueryParams(queryParams);
        task.setProcessedCount(0);
        task.setCreateUser(createUser);
        task.setCreateTime(new Date());
        
        this.save(task);
        return task.getId();
    }
    
    @Override
    public void updateTaskTotal(String taskId, int totalCount) {
        ExportTask task = new ExportTask();
        task.setId(taskId);
        task.setTotalCount(totalCount);
        task.setStatus(ExportTask.STATUS_PROCESSING);
        this.updateById(task);
    }
    
    @Override
    public void updateTaskProgress(String taskId, int progress, int processedCount) {
        ExportTask task = new ExportTask();
        task.setId(taskId);
        task.setStatus(ExportTask.STATUS_PROCESSING);
        task.setProgress(progress);
        task.setProcessedCount(processedCount);
        this.updateById(task);
    }
    
    @Override
    public void updateTaskSuccess(String taskId, String filePath, String fileName, long fileSize) {
        ExportTask task = new ExportTask();
        task.setId(taskId);
        task.setStatus(ExportTask.STATUS_COMPLETED);
        task.setProgress(100);
        task.setFilePath(filePath);
        task.setFileName(fileName);
        task.setFileSize(fileSize);
        task.setFinishTime(new Date());
        this.updateById(task);
    }
    
    @Override
    public void updateTaskFailed(String taskId, String errorMsg) {
        ExportTask task = new ExportTask();
        task.setId(taskId);
        task.setStatus(ExportTask.STATUS_FAILED);
        task.setErrorMsg(errorMsg);
        task.setFinishTime(new Date());
        this.updateById(task);
    }
    
    @Override
    public boolean checkConcurrentLimit(String userId, int maxConcurrent) {
        LambdaQueryWrapper<ExportTask> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(ExportTask::getCreateUser, userId)
               .in(ExportTask::getStatus, 
                   ExportTask.STATUS_PENDING, 
                   ExportTask.STATUS_PROCESSING);
        
        long count = this.count(wrapper);
        return count < maxConcurrent;
    }
}

3.6 Mapper接口

package com.example.export.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.export.entity.ExportTask;
import org.apache.ibatis.annotations.Mapper;

/**
 * 导出任务Mapper
 */
@Mapper
public interface ExportTaskMapper extends BaseMapper<ExportTask> {
}

3.7 线程池配置

package com.example.export.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;

/**
 * 异步任务线程池配置
 */
@Configuration
@EnableAsync
public class AsyncExportConfig {
    
    /**
     * 导出任务线程池
     */
    @Bean("exportTaskExecutor")
    public Executor exportTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        
        // 核心线程数
        executor.setCorePoolSize(2);
        
        // 最大线程数
        executor.setMaxPoolSize(5);
        
        // 队列容量
        executor.setQueueCapacity(100);
        
        // 线程空闲时间(秒)
        executor.setKeepAliveSeconds(60);
        
        // 线程名称前缀
        executor.setThreadNamePrefix("export-task-");
        
        // 拒绝策略:由调用线程处理(确保任务不丢失)
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        
        // 等待所有任务结束后再关闭线程池
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.setAwaitTerminationSeconds(60);
        
        executor.initialize();
        return executor;
    }
}

3.8 控制器

package com.example.export.controller;

import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.export.entity.ExportTask;
import com.example.export.service.ExportTaskService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.io.File;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;

/**
 * 导出任务控制器
 */
@Slf4j
@RestController
@RequestMapping("/api/export")
public class ExportTaskController {
    
    @javax.annotation.Resource
    private ExportTaskService exportTaskService;
    
    @Value("${export.max-concurrent-per-user:3}")
    private int maxConcurrentPerUser;
    
    /**
     * 查询任务状态
     */
    @GetMapping("/task/status/{taskId}")
    public ResponseEntity<Map<String, Object>> getTaskStatus(@PathVariable String taskId) {
        ExportTask task = exportTaskService.getById(taskId);
        
        if (task == null) {
            return ResponseEntity.ok(createErrorResponse("任务不存在"));
        }
        
        Map<String, Object> data = new HashMap<>();
        data.put("taskId", task.getId());
        data.put("taskName", task.getTaskName());
        data.put("status", task.getStatus());
        data.put("progress", task.getProgress());
        data.put("totalCount", task.getTotalCount());
        data.put("processedCount", task.getProcessedCount());
        data.put("fileName", task.getFileName());
        data.put("fileSize", task.getFileSize());
        data.put("errorMsg", task.getErrorMsg());
        data.put("createTime", task.getCreateTime());
        data.put("finishTime", task.getFinishTime());
        
        return ResponseEntity.ok(createSuccessResponse(data));
    }
    
    /**
     * 查询任务列表
     */
    @GetMapping("/task/list")
    public ResponseEntity<Map<String, Object>> getTaskList(
            @RequestParam(defaultValue = "1") int current,
            @RequestParam(defaultValue = "10") int size,
            @RequestParam(required = false) String createUser) {
        
        Page<ExportTask> page = new Page<>(current, size);
        LambdaQueryWrapper<ExportTask> wrapper = new LambdaQueryWrapper<>();
        
        if (createUser != null) {
            wrapper.eq(ExportTask::getCreateUser, createUser);
        }
        
        wrapper.orderByDesc(ExportTask::getCreateTime);
        
        Page<ExportTask> result = exportTaskService.page(page, wrapper);
        
        Map<String, Object> data = new HashMap<>();
        data.put("records", result.getRecords());
        data.put("total", result.getTotal());
        data.put("current", result.getCurrent());
        data.put("size", result.getSize());
        
        return ResponseEntity.ok(createSuccessResponse(data));
    }
    
    /**
     * 下载导出文件
     */
    @GetMapping("/task/download/{taskId}")
    public ResponseEntity<Resource> downloadFile(@PathVariable String taskId) {
        ExportTask task = exportTaskService.getById(taskId);
        
        if (task == null || task.getStatus() != ExportTask.STATUS_COMPLETED) {
            return ResponseEntity.badRequest().build();
        }
        
        File file = new File(task.getFilePath());
        
        if (!file.exists()) {
            log.error("文件不存在: {}", task.getFilePath());
            return ResponseEntity.notFound().build();
        }
        
        try {
            // 文件名编码
            String encodedFileName = URLEncoder.encode(task.getFileName(), StandardCharsets.UTF_8.toString())
                    .replaceAll("\\+", "%20");
            
            Resource resource = new FileSystemResource(file);
            
            return ResponseEntity.ok()
                    .contentType(MediaType.APPLICATION_OCTET_STREAM)
                    .header(HttpHeaders.CONTENT_DISPOSITION, 
                            "attachment; filename*=UTF-8''" + encodedFileName)
                    .body(resource);
                    
        } catch (UnsupportedEncodingException e) {
            log.error("文件名编码失败", e);
            return ResponseEntity.internalServerError().build();
        }
    }
    
    /**
     * 删除任务
     */
    @DeleteMapping("/task/{taskId}")
    public ResponseEntity<Map<String, Object>> deleteTask(@PathVariable String taskId) {
        ExportTask task = exportTaskService.getById(taskId);
        
        if (task == null) {
            return ResponseEntity.ok(createErrorResponse("任务不存在"));
        }
        
        // 删除文件
        if (task.getFilePath() != null) {
            File file = new File(task.getFilePath());
            if (file.exists()) {
                file.delete();
            }
        }
        
        // 删除记录
        exportTaskService.removeById(taskId);
        
        return ResponseEntity.ok(createSuccessResponse("删除成功"));
    }
    
    /**
     * 创建成功响应
     */
    private Map<String, Object> createSuccessResponse(Object data) {
        Map<String, Object> response = new HashMap<>();
        response.put("code", 200);
        response.put("msg", "success");
        response.put("data", data);
        return response;
    }
    
    /**
     * 创建错误响应
     */
    private Map<String, Object> createErrorResponse(String message) {
        Map<String, Object> response = new HashMap<>();
        response.put("code", 500);
        response.put("msg", message);
        response.put("data", null);
        return response;
    }
}

四、使用示例

4.1 示例1:导出用户数据

package com.example.demo.user;

import com.example.export.core.ExportDataProvider;
import com.example.export.core.ExcelRowConverter;
import com.example.export.core.GenericAsyncExportHandler;
import com.example.export.service.ExportTaskService;
import cn.hutool.core.date.DateUtil;
import cn.hutool.json.JSONUtil;
import lombok.Data;
import org.apache.poi.ss.usermodel.*;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.*;

import javax.annotation.Resource;
import java.util.*;

/**
 * 用户数据导出示例
 */
@RestController
@RequestMapping("/api/user")
public class UserExportController {
    
    @Resource
    private UserExportService userExportService;
    
    @Resource
    private ExportTaskService exportTaskService;
    
    @Resource
    private GenericAsyncExportHandler exportHandler;
    
    /**
     * 创建导出任务
     */
    @PostMapping("/export")
    public Map<String, Object> exportUsers(@RequestBody UserQueryParams params) {
        // 检查并发限制
        if (!exportTaskService.checkConcurrentLimit(params.getUserId(), 3)) {
            return createErrorResponse("导出任务过多,请稍后再试");
        }
        
        // 创建任务
        String taskName = "用户数据导出_" + DateUtil.now();
        String queryParams = JSONUtil.toJsonStr(params);
        String taskId = exportTaskService.createExportTask(
            taskName, 
            "user_data", 
            queryParams, 
            params.getUserId()
        );
        
        // 异步执行
        exportHandler.executeExport(
            taskId,
            "user_data",
            userExportService.getDataProvider(),
            userExportService.getRowConverter(),
            queryParams
        );
        
        return createSuccessResponse(taskId);
    }
    
    private Map<String, Object> createSuccessResponse(Object data) {
        Map<String, Object> response = new HashMap<>();
        response.put("code", 200);
        response.put("msg", "success");
        response.put("data", data);
        return response;
    }
    
    private Map<String, Object> createErrorResponse(String message) {
        Map<String, Object> response = new HashMap<>();
        response.put("code", 500);
        response.put("msg", message);
        return response;
    }
}

/**
 * 用户导出服务
 */
@Service
class UserExportService {
    
    @Resource
    private UserMapper userMapper;
    
    /**
     * 获取数据提供者
     */
    public ExportDataProvider<User> getDataProvider() {
        return new ExportDataProvider<User>() {
            @Override
            public int queryTotalCount(String params) {
                UserQueryParams queryParams = JSONUtil.toBean(params, UserQueryParams.class);
                return userMapper.countByParams(queryParams);
            }
            
            @Override
            public List<User> queryData(String params, int offset, int limit) {
                UserQueryParams queryParams = JSONUtil.toBean(params, UserQueryParams.class);
                queryParams.setOffset(offset);
                queryParams.setLimit(limit);
                return userMapper.selectByParams(queryParams);
            }
        };
    }
    
    /**
     * 获取行转换器
     */
    public ExcelRowConverter<User> getRowConverter() {
        return new ExcelRowConverter<User>() {
            @Override
            public String[] getHeaders() {
                return new String[]{"用户ID", "用户名", "姓名", "邮箱", "手机号", "状态", "创建时间"};
            }
            
            @Override
            public int[] getColumnWidths() {
                return new int[]{3000, 4000, 3000, 5000, 4000, 2500, 5000};
            }
            
            @Override
            public void convertToRow(Workbook workbook, Row row, User user, int rowIndex) {
                CellStyle dataStyle = createDataStyle(workbook);
                
                createCell(row, 0, user.getId(), dataStyle);
                createCell(row, 1, user.getUsername(), dataStyle);
                createCell(row, 2, user.getRealName(), dataStyle);
                createCell(row, 3, user.getEmail(), dataStyle);
                createCell(row, 4, user.getPhone(), dataStyle);
                createCell(row, 5, user.getStatus() == 1 ? "正常" : "禁用", dataStyle);
                createCell(row, 6, DateUtil.formatDateTime(user.getCreateTime()), dataStyle);
            }
            
            private void createCell(Row row, int column, String value, CellStyle style) {
                Cell cell = row.createCell(column);
                cell.setCellValue(value != null ? value : "");
                cell.setCellStyle(style);
            }
            
            private CellStyle createDataStyle(Workbook workbook) {
                CellStyle style = workbook.createCellStyle();
                style.setBorderBottom(BorderStyle.THIN);
                style.setBorderTop(BorderStyle.THIN);
                style.setBorderLeft(BorderStyle.THIN);
                style.setBorderRight(BorderStyle.THIN);
                style.setAlignment(HorizontalAlignment.LEFT);
                style.setVerticalAlignment(VerticalAlignment.CENTER);
                return style;
            }
        };
    }
}

/**
 * 用户实体
 */
@Data
class User {
    private String id;
    private String username;
    private String realName;
    private String email;
    private String phone;
    private Integer status;
    private Date createTime;
}

/**
 * 查询参数
 */
@Data
class UserQueryParams {
    private String userId;      // 当前用户ID
    private String username;
    private String realName;
    private Integer status;
    private Date startTime;
    private Date endTime;
    private Integer offset;
    private Integer limit;
}

4.2 示例2:导出带图片的商品数据

package com.example.demo.product;

import com.example.export.core.ExportDataProvider;
import com.example.export.core.ExcelRowConverter;
import com.example.export.core.GenericAsyncExportHandler;
import com.example.export.service.ExportTaskService;
import cn.hutool.http.HttpUtil;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.ss.util.CellRangeAddress;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.*;

import javax.annotation.Resource;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.net.URL;
import java.util.concurrent.*;

/**
 * 商品导出服务(包含图片)
 */
@Slf4j
@Service
class ProductExportService {
    
    @Resource
    private ProductMapper productMapper;
    
    // 图片下载线程池
    private final ExecutorService imageExecutor = new ThreadPoolExecutor(
        10, 20, 60L, TimeUnit.SECONDS,
        new LinkedBlockingQueue<>(1000),
        new ThreadPoolExecutor.CallerRunsPolicy()
    );
    
    /**
     * 获取数据提供者
     */
    public ExportDataProvider<Product> getDataProvider() {
        return new ExportDataProvider<Product>() {
            @Override
            public int queryTotalCount(String params) {
                return productMapper.countAll();
            }
            
            @Override
            public List<Product> queryData(String params, int offset, int limit) {
                return productMapper.selectPage(offset, limit);
            }
        };
    }
    
    /**
     * 获取行转换器(带图片)
     */
    public ExcelRowConverter<Product> getRowConverter() {
        return new ExcelRowConverter<Product>() {
            @Override
            public String[] getHeaders() {
                return new String[]{"商品ID", "商品名称", "价格", "库存", "分类", "商品图片"};
            }
            
            @Override
            public int[] getColumnWidths() {
                return new int[]{3000, 6000, 3000, 3000, 4000, 5000};
            }
            
            @Override
            public void convertToRow(Workbook workbook, Row row, Product product, int rowIndex) {
                // 设置行高(图片需要更高的行)
                row.setHeight((short) 1200);
                
                CellStyle dataStyle = createDataStyle(workbook);
                
                createCell(row, 0, product.getId(), dataStyle);
                createCell(row, 1, product.getName(), dataStyle);
                createCell(row, 2, String.valueOf(product.getPrice()), dataStyle);
                createCell(row, 3, String.valueOf(product.getStock()), dataStyle);
                createCell(row, 4, product.getCategory(), dataStyle);
                createCell(row, 5, "", dataStyle);
                
                // 异步下载并插入图片
                if (product.getImageUrl() != null) {
                    insertImageAsync(workbook, row.getSheet(), product.getImageUrl(), rowIndex);
                }
            }
            
            /**
             * 异步插入图片
             */
            private void insertImageAsync(Workbook workbook, Sheet sheet, String imageUrl, int rowIndex) {
                Future<byte[]> future = imageExecutor.submit(() -> downloadImage(imageUrl));
                
                try {
                    // 5秒超时
                    byte[] imageBytes = future.get(5, TimeUnit.SECONDS);
                    
                    if (imageBytes != null && imageBytes.length > 0) {
                        // 添加图片到工作簿
                        int pictureIdx = workbook.addPicture(imageBytes, Workbook.PICTURE_TYPE_JPEG);
                        
                        // 创建绘图对象
                        Drawing<?> drawing = sheet.createDrawingPatriarch();
                        
                        // 设置图片位置(第6列,从当前行到下一行)
                        ClientAnchor anchor = drawing.createAnchor(
                            0, 0, 0, 0,     // dx1, dy1, dx2, dy2
                            5, rowIndex + 1, 6, rowIndex + 2  // col1, row1, col2, row2
                        );
                        
                        anchor.setAnchorType(ClientAnchor.AnchorType.MOVE_AND_RESIZE);
                        
                        // 创建图片
                        Picture picture = drawing.createPicture(anchor, pictureIdx);
                        picture.resize(1.0);  // 调整大小
                    }
                } catch (TimeoutException e) {
                    log.warn("图片下载超时: {}", imageUrl);
                    future.cancel(true);
                } catch (Exception e) {
                    log.warn("插入图片失败: {}", imageUrl, e);
                }
            }
            
            /**
             * 下载图片
             */
            private byte[] downloadImage(String imageUrl) {
                try {
                    URL url = new URL(imageUrl);
                    try (InputStream in = url.openStream();
                         ByteArrayOutputStream out = new ByteArrayOutputStream()) {
                        
                        byte[] buffer = new byte[8192];
                        int bytesRead;
                        while ((bytesRead = in.read(buffer)) != -1) {
                            out.write(buffer, 0, bytesRead);
                        }
                        return out.toByteArray();
                    }
                } catch (Exception e) {
                    log.warn("下载图片失败: {}", imageUrl, e);
                    return null;
                }
            }
            
            private void createCell(Row row, int column, String value, CellStyle style) {
                Cell cell = row.createCell(column);
                cell.setCellValue(value != null ? value : "");
                cell.setCellStyle(style);
            }
            
            private CellStyle createDataStyle(Workbook workbook) {
                CellStyle style = workbook.createCellStyle();
                style.setBorderBottom(BorderStyle.THIN);
                style.setBorderTop(BorderStyle.THIN);
                style.setBorderLeft(BorderStyle.THIN);
                style.setBorderRight(BorderStyle.THIN);
                style.setAlignment(HorizontalAlignment.CENTER);
                style.setVerticalAlignment(VerticalAlignment.CENTER);
                return style;
            }
        };
    }
}

/**
 * 商品实体
 */
@Data
class Product {
    private String id;
    private String name;
    private Double price;
    private Integer stock;
    private String category;
    private String imageUrl;
}

4.3 前端代码(Vue3 + Element Plus)

<template>
  <div class="export-container">
    <!-- 导出按钮 -->
    <el-button type="primary" @click="handleExport">
      <el-icon><Download /></el-icon>
      导出数据
    </el-button>
    
    <!-- 导出历史按钮 -->
    <el-button @click="showExportHistory">
      <el-icon><Document /></el-icon>
      导出记录
    </el-button>
    
    <!-- 进度对话框 -->
    <el-dialog
      v-model="progressVisible"
      title="导出进度"
      width="500px"
      :close-on-click-modal="false"
      :close-on-press-escape="false"
    >
      <div class="progress-content">
        <el-progress
          :percentage="taskInfo.progress"
          :status="getProgressStatus()"
        />
        
        <div class="progress-info">
          <p v-if="taskInfo.status === 0">等待处理...</p>
          <p v-else-if="taskInfo.status === 1">
            正在处理: {{ taskInfo.processedCount }} / {{ taskInfo.totalCount }}
          </p>
          <p v-else-if="taskInfo.status === 2" class="success">
            导出完成!文件大小: {{ formatFileSize(taskInfo.fileSize) }}
          </p>
          <p v-else-if="taskInfo.status === 3" class="error">
            导出失败: {{ taskInfo.errorMsg }}
          </p>
        </div>
      </div>
      
      <template #footer>
        <el-button
          v-if="taskInfo.status === 2"
          type="primary"
          @click="downloadFile"
        >
          下载文件
        </el-button>
        <el-button @click="progressVisible = false">关闭</el-button>
      </template>
    </el-dialog>
    
    <!-- 导出历史对话框 -->
    <el-dialog
      v-model="historyVisible"
      title="导出记录"
      width="800px"
    >
      <el-table
        :data="historyList"
        v-loading="historyLoading"
        style="width: 100%"
      >
        <el-table-column prop="taskName" label="任务名称" width="200" />
        <el-table-column label="状态" width="100">
          <template #default="{ row }">
            <el-tag :type="getStatusType(row.status)">
              {{ getStatusText(row.status) }}
            </el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="progress" label="进度" width="100">
          <template #default="{ row }">
            {{ row.progress }}%
          </template>
        </el-table-column>
        <el-table-column prop="totalCount" label="记录数" width="100" />
        <el-table-column label="文件大小" width="100">
          <template #default="{ row }">
            {{ formatFileSize(row.fileSize) }}
          </template>
        </el-table-column>
        <el-table-column prop="createTime" label="创建时间" width="180" />
        <el-table-column label="操作" fixed="right">
          <template #default="{ row }">
            <el-button
              v-if="row.status === 2"
              link
              type="primary"
              @click="downloadHistoryFile(row.id)"
            >
              下载
            </el-button>
            <el-button
              v-if="row.status === 1"
              link
              type="primary"
              @click="viewProgress(row.id)"
            >
              查看进度
            </el-button>
            <el-button
              link
              type="danger"
              @click="deleteTask(row.id)"
            >
              删除
            </el-button>
          </template>
        </el-table-column>
      </el-table>
      
      <el-pagination
        v-model:current-page="pagination.current"
        v-model:page-size="pagination.size"
        :total="pagination.total"
        layout="total, prev, pager, next"
        @current-change="loadHistoryList"
      />
    </el-dialog>
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { Download, Document } from '@element-plus/icons-vue'
import axios from 'axios'

// 进度对话框
const progressVisible = ref(false)
const taskInfo = reactive({
  taskId: '',
  status: 0,
  progress: 0,
  totalCount: 0,
  processedCount: 0,
  fileName: '',
  fileSize: 0,
  errorMsg: ''
})

// 历史记录对话框
const historyVisible = ref(false)
const historyLoading = ref(false)
const historyList = ref([])
const pagination = reactive({
  current: 1,
  size: 10,
  total: 0
})

// 轮询定时器
let pollingTimer = null

/**
 * 创建导出任务
 */
const handleExport = async () => {
  try {
    const params = {
      userId: 'current-user-id', // 从登录信息获取
      // 其他查询参数...
    }
    
    const response = await axios.post('/api/user/export', params)
    
    if (response.data.code === 200) {
      taskInfo.taskId = response.data.data
      taskInfo.status = 0
      taskInfo.progress = 0
      
      progressVisible.value = true
      startPolling()
      
      ElMessage.success('导出任务已创建')
    } else {
      ElMessage.error(response.data.msg || '创建任务失败')
    }
  } catch (error) {
    console.error('创建导出任务失败:', error)
    ElMessage.error('创建导出任务失败')
  }
}

/**
 * 开始轮询
 */
const startPolling = () => {
  stopPolling()
  queryTaskStatus()
  pollingTimer = setInterval(queryTaskStatus, 2000)
}

/**
 * 停止轮询
 */
const stopPolling = () => {
  if (pollingTimer) {
    clearInterval(pollingTimer)
    pollingTimer = null
  }
}

/**
 * 查询任务状态
 */
const queryTaskStatus = async () => {
  try {
    const response = await axios.get(`/api/export/task/status/${taskInfo.taskId}`)
    
    if (response.data.code === 200) {
      const data = response.data.data
      Object.assign(taskInfo, data)
      
      // 任务完成或失败,停止轮询
      if (data.status === 2 || data.status === 3) {
        stopPolling()
      }
    }
  } catch (error) {
    console.error('查询任务状态失败:', error)
  }
}

/**
 * 下载文件
 */
const downloadFile = async () => {
  try {
    ElMessage.info('开始下载文件...')
    
    const response = await axios.get(
      `/api/export/task/download/${taskInfo.taskId}`,
      { responseType: 'blob' }
    )
    
    // 创建Blob
    const blob = new Blob([response.data], {
      type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
    })
    
    // 创建下载链接
    const url = window.URL.createObjectURL(blob)
    const link = document.createElement('a')
    link.href = url
    link.download = taskInfo.fileName
    link.click()
    
    // 清理
    window.URL.revokeObjectURL(url)
    
    ElMessage.success('文件下载成功')
    progressVisible.value = false
  } catch (error) {
    console.error('下载文件失败:', error)
    ElMessage.error('下载文件失败')
  }
}

/**
 * 显示导出历史
 */
const showExportHistory = () => {
  historyVisible.value = true
  pagination.current = 1
  loadHistoryList()
}

/**
 * 加载历史列表
 */
const loadHistoryList = async () => {
  try {
    historyLoading.value = true
    
    const response = await axios.get('/api/export/task/list', {
      params: {
        current: pagination.current,
        size: pagination.size,
        createUser: 'current-user-id' // 从登录信息获取
      }
    })
    
    if (response.data.code === 200) {
      historyList.value = response.data.data.records
      pagination.total = response.data.data.total
    }
  } catch (error) {
    console.error('加载导出记录失败:', error)
    ElMessage.error('加载导出记录失败')
  } finally {
    historyLoading.value = false
  }
}

/**
 * 下载历史文件
 */
const downloadHistoryFile = async (taskId) => {
  try {
    ElMessage.info('开始下载文件...')
    
    const response = await axios.get(
      `/api/export/task/download/${taskId}`,
      { responseType: 'blob' }
    )
    
    // 从响应头获取文件名
    const contentDisposition = response.headers['content-disposition']
    let fileName = '导出文件.xlsx'
    if (contentDisposition) {
      const match = contentDisposition.match(/filename\*=UTF-8''(.+)/)
      if (match) {
        fileName = decodeURIComponent(match[1])
      }
    }
    
    // 创建下载
    const blob = new Blob([response.data])
    const url = window.URL.createObjectURL(blob)
    const link = document.createElement('a')
    link.href = url
    link.download = fileName
    link.click()
    window.URL.revokeObjectURL(url)
    
    ElMessage.success('文件下载成功')
  } catch (error) {
    console.error('下载文件失败:', error)
    ElMessage.error('下载文件失败')
  }
}

/**
 * 查看进度
 */
const viewProgress = (taskId) => {
  taskInfo.taskId = taskId
  historyVisible.value = false
  progressVisible.value = true
  startPolling()
}

/**
 * 删除任务
 */
const deleteTask = async (taskId) => {
  try {
    await ElMessageBox.confirm('确定要删除这个任务吗?', '提示', {
      type: 'warning'
    })
    
    const response = await axios.delete(`/api/export/task/${taskId}`)
    
    if (response.data.code === 200) {
      ElMessage.success('删除成功')
      loadHistoryList()
    } else {
      ElMessage.error(response.data.msg)
    }
  } catch (error) {
    if (error !== 'cancel') {
      console.error('删除任务失败:', error)
      ElMessage.error('删除任务失败')
    }
  }
}

/**
 * 获取进度状态
 */
const getProgressStatus = () => {
  if (taskInfo.status === 2) return 'success'
  if (taskInfo.status === 3) return 'exception'
  return null
}

/**
 * 获取状态文本
 */
const getStatusText = (status) => {
  const map = {
    0: '等待中',
    1: '处理中',
    2: '已完成',
    3: '失败',
    4: '已过期'
  }
  return map[status] || '未知'
}

/**
 * 获取状态类型
 */
const getStatusType = (status) => {
  const map = {
    0: 'info',
    1: 'warning',
    2: 'success',
    3: 'danger',
    4: 'info'
  }
  return map[status] || 'info'
}

/**
 * 格式化文件大小
 */
const formatFileSize = (bytes) => {
  if (!bytes || bytes === 0) return '-'
  
  const k = 1024
  const sizes = ['B', 'KB', 'MB', 'GB']
  const i = Math.floor(Math.log(bytes) / Math.log(k))
  
  return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i]
}

// 组件卸载时清理定时器
onUnmounted(() => {
  stopPolling()
})
</script>

<style scoped>
.export-container {
  padding: 20px;
}

.progress-content {
  padding: 20px 0;
}

.progress-info {
  margin-top: 20px;
  text-align: center;
}

.progress-info p {
  margin: 10px 0;
  font-size: 14px;
}

.progress-info .success {
  color: #67c23a;
}

.progress-info .error {
  color: #f56c6c;
}
</style>

五、高级特性

5.1 定时清理过期文件

package com.example.export.task;

import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.FileUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.example.export.entity.ExportTask;
import com.example.export.service.ExportTaskService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.util.Date;
import java.util.List;

/**
 * 导出文件清理任务
 */
@Slf4j
@Component
public class ExportFileCleanTask {
    
    @Resource
    private ExportTaskService exportTaskService;
    
    @Value("${export.keep-days:7}")
    private int keepDays;
    
    /**
     * 每天凌晨2点执行清理
     */
    @Scheduled(cron = "0 0 2 * * ?")
    public void cleanExpiredFiles() {
        log.info("开始清理过期导出文件,保留天数: {}", keepDays);
        
        try {
            // 计算过期时间
            Date expireDate = DateUtil.offsetDay(new Date(), -keepDays);
            
            // 查询过期且已完成的任务
            LambdaQueryWrapper<ExportTask> wrapper = new LambdaQueryWrapper<>();
            wrapper.eq(ExportTask::getStatus, ExportTask.STATUS_COMPLETED)
                   .lt(ExportTask::getFinishTime, expireDate);
            
            List<ExportTask> expiredTasks = exportTaskService.list(wrapper);
            
            int successCount = 0;
            int failCount = 0;
            
            for (ExportTask task : expiredTasks) {
                try {
                    // 删除文件
                    if (task.getFilePath() != null) {
                        FileUtil.del(task.getFilePath());
                    }
                    
                    // 更新任务状态为已过期
                    task.setStatus(ExportTask.STATUS_EXPIRED);
                    task.setFilePath(null);
                    exportTaskService.updateById(task);
                    
                    successCount++;
                } catch (Exception e) {
                    log.error("删除文件失败: {}", task.getFilePath(), e);
                    failCount++;
                }
            }
            
            log.info("清理完成,成功: {}, 失败: {}", successCount, failCount);
            
        } catch (Exception e) {
            log.error("清理过期文件失败", e);
        }
    }
}

5.2 全局异常处理

package com.example.export.exception;

import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.HashMap;
import java.util.Map;

/**
 * 全局异常处理器
 */
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler(Exception.class)
    public Map<String, Object> handleException(Exception e) {
        log.error("系统异常", e);
        
        Map<String, Object> response = new HashMap<>();
        response.put("code", 500);
        response.put("msg", "系统异常: " + e.getMessage());
        response.put("data", null);
        
        return response;
    }
    
    @ExceptionHandler(IllegalArgumentException.class)
    public Map<String, Object> handleIllegalArgumentException(IllegalArgumentException e) {
        log.warn("参数错误: {}", e.getMessage());
        
        Map<String, Object> response = new HashMap<>();
        response.put("code", 400);
        response.put("msg", e.getMessage());
        response.put("data", null);
        
        return response;
    }
}

5.3 并发控制拦截器

package com.example.export.interceptor;

import com.example.export.service.ExportTaskService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * 导出并发控制拦截器
 */
@Slf4j
@Component
public class ExportConcurrentInterceptor implements HandlerInterceptor {
    
    @Resource
    private ExportTaskService exportTaskService;
    
    @Value("${export.max-concurrent-per-user:3}")
    private int maxConcurrentPerUser;
    
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        // 获取当前用户ID(从Session或Token中获取)
        String userId = getCurrentUserId(request);
        
        if (userId == null) {
            return true;
        }
        
        // 检查并发限制
        if (!exportTaskService.checkConcurrentLimit(userId, maxConcurrentPerUser)) {
            log.warn("用户 {} 导出任务超过限制", userId);
            response.setStatus(429); // Too Many Requests
            return false;
        }
        
        return true;
    }
    
    private String getCurrentUserId(HttpServletRequest request) {
        // 这里需要根据实际情况从Session或JWT Token中获取用户ID
        return request.getHeader("User-Id");
    }
}

六、性能优化

6.1 数据库索引优化

-- 任务状态查询索引
CREATE INDEX idx_status_create_time ON export_task(status, create_time);

-- 用户任务查询索引
CREATE INDEX idx_user_status ON export_task(create_user, status);

-- 过期任务清理索引
CREATE INDEX idx_status_finish_time ON export_task(status, finish_time);

6.2 Excel写入优化

// 使用SXSSFWorkbook流式写入
SXSSFWorkbook workbook = new SXSSFWorkbook(100); // 内存中只保留100行
workbook.setCompressTempFiles(true); // 压缩临时文件

// 禁用自动刷新
sheet.setRandomAccessWindowSize(100);

// 完成后清理
workbook.dispose();

6.3 查询优化

// 使用游标方式查询大数据量
@Select("SELECT * FROM user WHERE status = #{status}")
@Options(resultSetType = ResultSetType.FORWARD_ONLY, fetchSize = 1000)
@ResultType(User.class)
void selectByStatus(@Param("status") Integer status, ResultHandler<User> handler);

// 在导出时使用
userMapper.selectByStatus(1, context -> {
    User user = (User) context.getResultObject();
    writeToExcel(user);
});

七、常见问题

Q1: 导出任务一直处于待处理状态?

原因:线程池队列已满或线程池未正确配置

解决方案

  1. 检查线程池配置是否生效
  2. 查看日志是否有异常信息
  3. 确认 @EnableAsync 注解已添加

Q2: 内存溢出怎么办?

解决方案

  1. 使用 SXSSFWorkbook 替代 XSSFWorkbook
  2. 减小批次大小 batchSize
  3. 调整 JVM 参数 -Xmx

Q3: 图片下载超时导致导出变慢?

解决方案

  1. 设置合理的超时时间(5-10秒)
  2. 增加图片下载线程池大小
  3. 图片失败不影响整体导出

Q4: 如何实现断点续传?

解决方案

// 记录最后处理的offset
task.setLastOffset(offset);
exportTaskService.updateById(task);

// 重试时从lastOffset开始
int startOffset = task.getLastOffset() != null ? task.getLastOffset() : 0;

Q5: 如何支持更多文件格式?

解决方案

  • CSV: 使用 OpenCSV
  • PDF: 使用 iTextApache PDFBox
  • Word: 使用 Apache POI-OOXML

八、总结

本方案提供了一个完整、通用、可扩展的Excel异步导出解决方案,具有以下特点:

开箱即用:只需实现数据查询接口即可快速集成
高性能:分批处理、流式写入、异步执行
可扩展:泛型设计,支持任意数据类型
生产就绪:完善的异常处理、日志记录、监控

适用于各类需要导出大量数据的业务场景,如订单导出、用户数据导出、财务报表等。


贡献者

  • flycodeuflycodeu

公告板

2025-03-04正式迁移知识库到此项目