异步导出方案

数据库表设计

首先,我们需要一张表来跟踪导出任务的状态和存储信息。

sql 复制代码
-- study_test.export_task definition

CREATE TABLE `export_task` (
  `file_id` varchar(36) COLLATE utf8mb4_0900_as_ci NOT NULL COMMENT '文件唯一ID(UUID)',
  `file_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_as_ci DEFAULT NULL COMMENT '导出文件的原始名称(如:订单报表.xlsx)',
  `file_path` varchar(500) COLLATE utf8mb4_0900_as_ci DEFAULT NULL COMMENT '在MinIO中的存储路径/对象名',
  `status` varchar(20) COLLATE utf8mb4_0900_as_ci NOT NULL DEFAULT 'PROCESSING' COMMENT '任务状态:PROCESSING-处理中,SUCCESS-成功,FAILED-失败',
  `minio_bucket` varchar(100) COLLATE utf8mb4_0900_as_ci DEFAULT NULL COMMENT 'MinIO存储桶名称',
  `download_url` varchar(500) COLLATE utf8mb4_0900_as_ci DEFAULT NULL COMMENT 'MinIO文件下载地址(可设置有效期)',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '任务创建时间',
  `finish_time` datetime DEFAULT NULL COMMENT '任务完成(成功或失败)时间',
  `error_message` text COLLATE utf8mb4_0900_as_ci COMMENT '若失败,记录错误信息',
  `create_user` varchar(100) COLLATE utf8mb4_0900_as_ci DEFAULT NULL COMMENT '发起导出的用户ID',
  `query_params` text COLLATE utf8mb4_0900_as_ci COMMENT '导出请求的查询条件(JSON格式,用于业务查询)',
  PRIMARY KEY (`file_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_ci;

ExportTask

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

import com.baomidou.mybatisplus.annotation.*;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.time.LocalDateTime;

@Data
@TableName("export_task")
@ApiModel(value = "ExportTask对象", description = "导出任务表")
public class ExportTask {

    @TableId(value = "file_id", type = IdType.ASSIGN_UUID)
    @ApiModelProperty(value = "文件唯一ID(UUID)", example = "550e8400-e29b-41d4-a716-446655440000")
    private String fileId;

    @TableField(value = "file_name")
    @ApiModelProperty(value = "导出文件的原始名称", example = "订单报表.xlsx", required = true)
    private String fileName;

    @TableField(value = "file_path")
    @ApiModelProperty(value = "在MinIO中的存储路径/对象名", example = "exports/550e8400-e29b-41d4-a716-446655440000/订单报表.xlsx")
    private String filePath;

    @TableField(value = "status")
    @ApiModelProperty(value = "任务状态:PROCESSING-处理中,SUCCESS-成功,FAILED-失败",
            example = "PROCESSING", allowableValues = "PROCESSING,SUCCESS,FAILED")
    private String status;

    @TableField(value = "minio_bucket")
    @ApiModelProperty(value = "MinIO存储桶名称", example = "export-bucket")
    private String minioBucket;

    @TableField(value = "download_url")
    @ApiModelProperty(value = "MinIO文件下载地址(可设置有效期)",
            example = "https://minio.example.com/export-bucket/exports/xxx?token=xxx")
    private String downloadUrl;

    @TableField(value = "create_time")
    @ApiModelProperty(value = "任务创建时间", example = "2024-01-29T10:30:00")
    private LocalDateTime createTime;

    @TableField(value = "finish_time")
    @ApiModelProperty(value = "任务完成(成功或失败)时间", example = "2024-01-29T10:35:00")
    private LocalDateTime finishTime;

    @TableField(value = "error_message")
    @ApiModelProperty(value = "若失败,记录错误信息", example = "文件生成超时")
    private String errorMessage;

    @TableField(value = "create_user")
    @ApiModelProperty(value = "发起导出的用户ID", example = "user123")
    private String createUser;

    @TableField(value = "query_params")
    @ApiModelProperty(value = "导出请求的查询条件(JSON格式)",
            example = "{\"startDate\":\"2024-01-01\",\"endDate\":\"2024-01-31\",\"status\":1}")
    private String queryParams;
}

ExportTaskMapper

java 复制代码
package com.example.study.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.study.controller.export.ExportTask;

public interface ExportTaskMapper extends BaseMapper<ExportTask> {
}

后端接口实现详细步骤

整个异步导出的业务流程,可以分为以下几个关键步骤,下图清晰地展示了其完整的工作流:

下面,我们来看每个环节的具体代码实现。

第1步:生成文件ID与初始化任务记录

pom.xml

XML 复制代码
<!-- EasyExcel核心依赖 -->
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>easyexcel</artifactId>
    <version>3.3.2</version> <!-- 建议使用较新版本 -->
    <exclusions>
        <!-- 排除 EasyExcel 自带的 POI 依赖,避免版本冲突 -->
        <exclusion>
            <groupId>org.apache.poi</groupId>
            <artifactId>poi</artifactId>
        </exclusion>
        <exclusion>
            <groupId>org.apache.poi</groupId>
            <artifactId>poi-ooxml</artifactId>
        </exclusion>
        <exclusion>
            <groupId>org.apache.poi</groupId>
            <artifactId>poi-ooxml-schemas</artifactId>
        </exclusion>
    </exclusions>
</dependency>

<!-- 然后显式引入一个与 EasyExcel 兼容的 POI 版本(解决java.lang.NoSuchFieldError: Factory错误是典型的 EasyExcel 和 Apache POI 版本冲突导致的报错) -->
<dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi</artifactId>
    <version>5.2.3</version> <!-- 确保版本兼容 -->
</dependency>
<dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi-ooxml</artifactId>
    <version>5.2.3</version>
</dependency>

<!-- 如果需要web导出功能,添加以下依赖 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- Lombok(如未添加) -->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <scope>provided</scope>
</dependency>

在Controller中,接收导出请求,立即生成UUID并创建任务记录。

ExportRequest

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

import com.alibaba.fastjson.JSON;
import com.example.study.mapper.ExportTaskMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpServletResponse;
import java.time.LocalDateTime;
import java.util.Date;
import java.util.UUID;

@RestController
@RequestMapping("/api/export")
public class ExportController {

    @Autowired
    private ExportTaskMapper exportTaskMapper;
    @Autowired
    private AsyncExportService asyncExportService;

    @PostMapping("/start")
    public ResponseEntity<String> startExport(@RequestBody ExportRequest request) {
        // 1. 生成唯一文件ID
        String fileId = UUID.randomUUID().toString(); // 使用Java内置UUID生成器

        // 2. 创建并保存导出任务记录,状态为"PROCESSING"
        ExportTask exportTask = new ExportTask();
        exportTask.setFileId(fileId);
        exportTask.setStatus("PROCESSING");
        exportTask.setCreateUser("用户名"); // 获取当前用户
        exportTask.setCreateTime(LocalDateTime.now());
        exportTask.setQueryParams(JSON.toJSONString(request)); // 保存查询条件

        exportTaskMapper.insert(exportTask);

        // 3. 提交异步任务(将刚创建的fileId传入)
        asyncExportService.executeExportTask(fileId, request);

        // 4. 立即返回文件ID给前端
        return ResponseEntity.ok(fileId);
    }

    @GetMapping("/status/{fileId}")
    public ResponseEntity<ExportTask> getExportStatus(@PathVariable String fileId) {
        ExportTask task = exportTaskMapper.selectById(fileId);
        return ResponseEntity.ok(task);
    }

    @GetMapping("/download/{fileId}")
    public void downloadFile(@PathVariable String fileId, HttpServletResponse response) {
        ExportTask task = exportTaskMapper.selectById(fileId);
        if (task == null || !"SUCCESS".equals(task.getStatus())) {
            throw new RuntimeException("文件不存在或尚未准备就绪");
        }

        // 重定向到MinIO的临时下载地址,或者通过MinIO服务流式传输文件
        // 具体实现取决于你的安全策略和MinIO配置
    }
}

ExportRequest

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

import com.baomidou.mybatisplus.annotation.TableField;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

@Data
public class ExportRequest {
    @TableField(value = "phone")
    @ApiModelProperty(value = "客户联系电话", example = "13800138000")
    private String phone;
}

第2步:异步执行导出任务

这是最核心的一步,使用@Async注解实现异步处理。

application.yml

java 复制代码
# 线程池配置
export-task:
  # 核心线程数:线程池中长期保持存活的线程数,即使它们处于空闲状态
  core-pool-size: 5
  # 最大线程数:线程池允许创建的最大线程数量
  max-pool-size: 10
  # 队列容量:用于存放等待执行任务的阻塞队列大小
  queue-capacity: 50
  # 线程空闲时间(秒):超出核心线程数的空闲线程存活时间,超过此时间将被回收
  keep-alive-seconds: 60
  # 线程名称前缀:便于在日志中追踪和区分线程来源
  thread-name-prefix: "export-task-"
  # 优雅关闭等待时间(秒):应用关闭时,等待正在执行任务完成的超时时间
  await-termination-seconds: 30

ThreadPoolConfig

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

import org.springframework.beans.factory.annotation.Value;
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;

/**
 * 线程池配置类
 * 负责创建并配置用于异步导出任务的线程池Bean
 */
@Configuration // 标记此类为配置类,Spring Boot启动时会自动加载
@EnableAsync // 启用Spring的异步方法执行功能
public class ThreadPoolConfig {

    // 使用@Value注解从application.yml中注入属性值,并设置默认值(冒号后部分)
    // 默认值的作用是:当配置文件中未找到对应属性时,使用默认值防止应用启动失败 [8](@ref)
    @Value("${export-task.core-pool-size}") // 核心线程数,默认5
    private Integer corePoolSize;

    @Value("${export-task.max-pool-size}") // 最大线程数,默认10
    private Integer maxPoolSize;

    @Value("${export-task.queue-capacity}") // 队列容量,默认50
    private Integer queueCapacity;

    @Value("${export-task.keep-alive-seconds}") // 线程空闲时间,默认60秒
    private Integer keepAliveSeconds;

    @Value("${export-task.thread-name-prefix}") // 线程名前缀,默认"export-task-"
    private String threadNamePrefix;

    @Value("${export-task.await-termination-seconds}") // 优雅关闭等待时间,默认30秒
    private Integer awaitTerminationSeconds;

    /**
     * 创建名为"exportTaskExecutor"的线程池Bean
     * 在其他Service中可通过@Async("exportTaskExecutor")使用此线程池执行异步任务
     */
    @Bean("exportTaskExecutor")
    public Executor exportTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();

        // 配置线程池参数,使用从YAML文件注入的值
        executor.setCorePoolSize(corePoolSize); // 设置核心线程数
        executor.setMaxPoolSize(maxPoolSize); // 设置最大线程数
        executor.setQueueCapacity(queueCapacity); // 设置队列容量
        executor.setKeepAliveSeconds(keepAliveSeconds); // 设置线程空闲时间
        executor.setThreadNamePrefix(threadNamePrefix); // 设置线程名称前缀,便于日志追踪

        // 设置拒绝策略:当线程池和队列都满时,由调用者所在线程执行任务
        // CallerRunsPolicy是一种负反馈策略,可以平缓任务提交速度
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());

        // 设置优雅关闭:等待所有任务完成后关闭线程池
        executor.setWaitForTasksToCompleteOnShutdown(true);
        // 设置等待任务完成的最大时间,避免无限期等待
        executor.setAwaitTerminationSeconds(awaitTerminationSeconds);

        // 初始化线程池
        executor.initialize();

        return executor;
    }
}

AsyncExportService

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

import com.alibaba.excel.EasyExcel;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.example.study.mapper.CustomerMapper;
import com.example.study.mapper.ExportTaskMapper;
import com.example.study.util.MinioUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;

@Service
@Slf4j
public class AsyncExportService {

    @Autowired
    private ExportTaskMapper exportTaskMapper;
    @Autowired
    private MinioUtils minioUtils;
    @Autowired
    private CustomerMapper customerMapper;

    @Async("exportTaskExecutor")
    @Transactional(rollbackFor = Exception.class)
    public void executeExportTask(String fileId, ExportRequest queryParams) {
        ExportTask task = exportTaskMapper.selectById(fileId);
        String tempFileName = null;

        try {
            // 1. 根据查询条件获取数据
            LambdaQueryWrapper<Customer> wrapper = new LambdaQueryWrapper<>();
//            wrapper.like(Customer::getPhone, queryParams.getPhone());
//            wrapper.last("LIMIT " + 100);
            wrapper.last("LIMIT " + 1000000);
            List<Customer> dataList = customerMapper.selectList(wrapper);

            // 2. 创建临时目录(如果不存在)
            Path tempDir = Paths.get("/tmp/exports");
            if (!Files.exists(tempDir)) {
                Files.createDirectories(tempDir);
            }

            // 3. 生成临时文件路径
            tempFileName = "/tmp/exports/export-" + fileId + ".xlsx";

            // 4. 使用EasyExcel写入数据
            EasyExcel.write(tempFileName, Customer.class)
                    .sheet("客户数据")
                    .doWrite(dataList);

            // 5. 准备上传MinIO
            String bucketName = "export-bucket";
            String yearMonth = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM"));
            String yearMonthDay = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
            String fileName = "客户数据" + yearMonthDay + ".xlsx";
            String objectName = "exports/" + yearMonth + "/" + fileName;

            // 6. 上传文件到MinIO
            boolean isSuccess = minioUtils.uploadFile(bucketName, objectName, tempFileName);

            if (isSuccess) {
                // 7. 获取下载地址
                String downloadUrl = minioUtils.getFileUrl(bucketName, objectName);

                // 8. 更新任务状态为成功
                task.setStatus("SUCCESS");
                task.setDownloadUrl(downloadUrl);
                task.setFilePath(objectName);
                task.setFileName(fileName);
                task.setMinioBucket(bucketName);
                task.setFinishTime(LocalDateTime.now());

                log.info("导出任务成功完成, fileId: {}", fileId);
            } else {
                throw new RuntimeException("MinIO文件上传失败");
            }

        } catch (Exception e) {
            log.error("导出任务处理失败, fileId: {}", fileId, e);
            task.setStatus("FAILED");
            task.setErrorMessage(e.getMessage());
            task.setFinishTime(LocalDateTime.now());
        } finally {
            try {
                // 9. 更新任务状态
                exportTaskMapper.updateById(task);

                // 10. 清理临时文件
                if (tempFileName != null) {
                    cleanupTempFile(tempFileName);
                }

            } catch (Exception e) {
                log.error("更新任务状态或清理临时文件时发生错误, fileId: {}", fileId, e);
            }
        }
    }

    /**
     * 清理临时文件
     */
    private void cleanupTempFile(String filePath) {
        try {
            Path path = Paths.get(filePath);
            if (Files.exists(path)) {
                Files.delete(path);
                log.info("临时文件清理成功: {}", filePath);

                // 尝试清理空目录
                Path parentDir = path.getParent();
                if (Files.isDirectory(parentDir) &&
                        Files.list(parentDir).count() == 0) {
                    Files.delete(parentDir);
                    log.info("空目录清理成功: {}", parentDir);
                }
            }
        } catch (IOException e) {
            log.warn("清理临时文件失败: {}, 错误: {}", filePath, e.getMessage());
            // 设置文件在JVM退出时删除
            try {
                Path path = Paths.get(filePath);
                if (Files.exists(path)) {
                    path.toFile().deleteOnExit();
                }
            } catch (Exception ex) {
                log.error("设置文件退出时删除失败: {}", filePath, ex);
            }
        }
    }


}

Customer

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

import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.format.DateTimeFormat;
import com.alibaba.excel.annotation.format.NumberFormat;
import com.baomidou.mybatisplus.annotation.*;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;

@Data
@TableName("customers")
@ApiModel(value = "Customer对象", description = "客户信息主表")
public class Customer {

    @TableId(value = "customer_id", type = IdType.AUTO)
    @ApiModelProperty(value = "客户唯一标识符", example = "10000001")
    @ExcelProperty("客户ID") // Excel列名:客户ID
    private Integer customerId;

    @TableField(value = "name")
    @ApiModelProperty(value = "客户全名", required = true, example = "张三")
    @ExcelProperty("客户姓名") // Excel列名:客户姓名
    private String name;

    @TableField(value = "email")
    @ApiModelProperty(value = "客户电子邮箱地址", example = "zhangsan@example.com")
    @ExcelProperty("电子邮箱") // Excel列名:电子邮箱
    private String email;

    @TableField(value = "phone")
    @ApiModelProperty(value = "客户联系电话", example = "13800138000")
    @ExcelProperty("联系电话") // Excel列名:联系电话
    private String phone;

    @TableField(value = "registration_date")
    @ApiModelProperty(value = "客户注册日期", example = "2024-01-29")
    @ExcelProperty("注册日期") // Excel列名:注册日期
    @DateTimeFormat("yyyy-MM-dd") // 日期格式化为:年-月-日
    private LocalDate registrationDate;

    @TableField(value = "credit_limit")
    @ApiModelProperty(value = "客户信用额度", example = "50000.00")
    @ExcelProperty("信用额度") // Excel列名:信用额度
    @NumberFormat("#,##0.00") // 数字格式化为:千分位分隔,保留两位小数
    private Double creditLimit;

    @TableField(value = "status")
    @ApiModelProperty(value = "客户状态:0-禁用,1-启用", example = "1", allowableValues = "0,1")
    @ExcelProperty("客户状态") // Excel列名:客户状态
    private Integer status;

    @TableField(value = "created_at", fill = FieldFill.INSERT)
    @ApiModelProperty(value = "记录创建时间", example = "2024-01-29T10:30:00")
    @ExcelProperty("创建时间") // Excel列名:创建时间
    @DateTimeFormat("yyyy-MM-dd HH:mm:ss") // 日期时间格式化为:年-月-日 时:分:秒
    private LocalDateTime createdAt;
}

CustomerMapper

java 复制代码
package com.example.study.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.study.controller.export.Customer;

public interface CustomerMapper extends BaseMapper<Customer> {
}

第3步:MinIO服务层工具类

配置文件

java 复制代码
minio:
  endpoint: http://127.0.0.1:9000
  accessKey: minioadmin
  secretKey: minioadmin
  bucketName: studytest

MinioUtils

java 复制代码
package com.example.study.util;

import com.example.study.entity.minio.ObjectItem;
import io.minio.*;
import io.minio.http.Method;
import io.minio.messages.DeleteError;
import io.minio.messages.DeleteObject;
import io.minio.messages.Item;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.compress.utils.IOUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
/**
 * @description: minio工具类
 * @version:3.0
 */
@Component
@Slf4j
public class MinioUtils {
    @Autowired
    private MinioClient minioClient;
    @Value("${minio.bucketName}")
    private String bucketName;
    /**
     * description: 判断bucket是否存在,不存在则创建
     *
     * @return: void
     */
    public void existBucket(String name) {
        try {
            boolean exists =
                    minioClient.bucketExists(BucketExistsArgs.builder().bucket(name).build());
            if (!exists) {
                minioClient.makeBucket(MakeBucketArgs.builder().bucket(name).build());
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    /**
     * 创建存储bucket
     * @param bucketName 存储bucket名称
     * @return Boolean
     */
    public Boolean makeBucket(String bucketName) {
        try {
            minioClient.makeBucket(MakeBucketArgs.builder()
                    .bucket(bucketName)
                    .build());
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
        return true;
    }
    /**
     * 删除存储bucket
     * @param bucketName 存储bucket名称
     * @return Boolean
     */
    public Boolean removeBucket(String bucketName) {
        try {
            minioClient.removeBucket(RemoveBucketArgs.builder()
                    .bucket(bucketName)
                    .build());
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
        return true;
    }

    public boolean uploadFile(String bucketName, String objectName, String filePath) {
        try {
            // 确保存储桶存在
            boolean found = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
            if (!found) {
                minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
            }
            // 上传文件
            minioClient.uploadObject(
                    UploadObjectArgs.builder()
                            .bucket(bucketName)
                            .object(objectName)
                            .filename(filePath)
                            .build());
            return true;
        } catch (Exception e) {
            log.error("上传文件到MinIO失败", e);
            return false;
        }
    }

    public String getFileUrl(String bucketName, String objectName) {
        try {
            // 获取一个有时效性的下载地址(例如7天内有效)
            return minioClient.getPresignedObjectUrl(
                    GetPresignedObjectUrlArgs.builder()
                            .method(Method.GET)
                            .bucket(bucketName)
                            .object(objectName)
//                            .expiry(7 * 24 * 60 * 60) // 7天有效期
                            .build());
        } catch (Exception e) {
            log.error("获取MinIO文件URL失败", e);
            return null;
        }
    }

    /**
     * description: 上传文件
     *
     * @param multipartFile
     * @return: java.lang.String
     */
    public List<String> upload(MultipartFile[] multipartFile) {
        List<String> names = new ArrayList<>(multipartFile.length);
        for (MultipartFile file : multipartFile) {
            String fileName = file.getOriginalFilename();
            String[] split = fileName.split("\\.");
            if (split.length > 1) {
                fileName = split[0] + "_" + System.currentTimeMillis() + "." +
                        split[1];
            } else {
                fileName = fileName + System.currentTimeMillis();
            }
            InputStream in = null;
            try {
                in = file.getInputStream();
                minioClient.putObject(PutObjectArgs.builder()
                        .bucket(bucketName)
                        .object(fileName)
                        .stream(in, in.available(), -1)
                        .contentType(file.getContentType())
                        .build()
                );
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                if (in != null) {
                    try {
                        in.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }
            names.add(fileName);
        }
        return names;
    }
    /**
     * description: 下载文件
     *
     * @param fileName
     * @return: org.springframework.http.ResponseEntity<byte [ ]>
     */
    public ResponseEntity<byte[]> download(String fileName) {
        ResponseEntity<byte[]> responseEntity = null;
        InputStream in = null;
        ByteArrayOutputStream out = null;
        try {
            in = minioClient.getObject(GetObjectArgs.builder().bucket(bucketName).object(fileName).build());
            out = new ByteArrayOutputStream();
            IOUtils.copy(in, out);
            //封装返回值
            byte[] bytes = out.toByteArray();
            HttpHeaders headers = new HttpHeaders();
            try {
                headers.add("Content-Disposition", "attachment;filename=" +
                        URLEncoder.encode(fileName, "UTF-8"));
            } catch (UnsupportedEncodingException e) {
                e.printStackTrace();
            }
            headers.setContentLength(bytes.length);
            headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
            headers.setAccessControlExposeHeaders(Arrays.asList("*"));
            responseEntity = new ResponseEntity<byte[]>(bytes, headers,
                    HttpStatus.OK);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            try {
                if (in != null) {
                    try {
                        in.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
                if (out != null) {
                    out.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        return responseEntity;
    }

    /**
     * 查看文件对象
     * @param bucketName 存储bucket名称
     * @return 存储bucket内文件对象信息
     */
    public List<ObjectItem> listObjects(String bucketName) {
        Iterable<Result<Item>> results = minioClient.listObjects(
                ListObjectsArgs.builder().bucket(bucketName).build());
        List<ObjectItem> objectItems = new ArrayList<>();
        try {
            for (Result<Item> result : results) {
                Item item = result.get();
                ObjectItem objectItem = new ObjectItem();
                objectItem.setObjectName(item.objectName());
                objectItem.setSize(item.size());
                objectItems.add(objectItem);
            }
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
        return objectItems;
    }
    /**
     * 批量删除文件对象
     * @param bucketName 存储bucket名称
     * @param objects 对象名称集合
     */
    public Iterable<Result<DeleteError>> removeObjects(String bucketName,
                                                       List<String> objects) {
        List<DeleteObject> dos = objects.stream().map(e -> new
                DeleteObject(e)).collect(Collectors.toList());
        Iterable<Result<DeleteError>> results =
                minioClient.removeObjects(RemoveObjectsArgs.builder().bucket(bucketName).objects
                        (dos).build());
        return results;
    }
}

第4步:状态查询与文件下载接口

前端需要凭file_id轮询任务状态,并在成功后获取下载地址。

java 复制代码
    @GetMapping("/status/{fileId}")
    public ResponseEntity<ExportTask> getExportStatus(@PathVariable String fileId) {
        ExportTask task = exportTaskMapper.selectById(fileId);
        return ResponseEntity.ok(task);
    }

    @GetMapping("/download/{fileId}")
    public void downloadFile(@PathVariable String fileId, HttpServletResponse response) {
        ExportTask task = exportTaskMapper.selectById(fileId);
        if (task == null || !"SUCCESS".equals(task.getStatus())) {
            throw new RuntimeException("文件不存在或尚未准备就绪");
        }

        // 重定向到MinIO的临时下载地址,或者通过MinIO服务流式传输文件
        // 具体实现取决于你的安全策略和MinIO配置
    }

建议

  1. 线程池配置 :为@Async创建专用的线程池,避免核心业务资源被导出任务挤占。

  2. 大文件处理:如果数据量极大(十万级以上),应采用分页查询、流式写入的方式,防止内存溢出(OOM)。

  3. 状态通知:除了前端轮询,可以在任务完成后通过WebSocket主动通知前端,提升用户体验。

  4. 文件清理 :可以设置一个定时任务,定期清理状态为FAILED或创建时间过久的任务记录及其在MinIO中的文件。

  5. 权限控制:在状态查询和下载接口中,务必加入权限校验,确保用户只能访问自己创建的文件。

相关推荐
没有bug.的程序员2 小时前
Spring Boot 与 Redis:缓存穿透/击穿/雪崩的终极攻防实战指南
java·spring boot·redis·缓存·缓存穿透·缓存击穿·缓存雪崩
草履虫建模2 小时前
Java 基础到进阶|专栏导航:路线图 + 目录(持续更新)
java·开发语言·spring boot·spring cloud·maven·基础·进阶
Zhu_S W2 小时前
Java多进程监控器技术实现详解
java·开发语言
Anastasiozzzz2 小时前
LeetCodeHot100 347. 前 K 个高频元素
java·算法·面试·职场和发展
三水不滴2 小时前
从原理、场景、解决方案深度分析Redis分布式Session
数据库·经验分享·redis·笔记·分布式·后端·性能优化
青芒.2 小时前
macOS Java 多版本环境配置完全指南
java·开发语言·macos
Hx_Ma162 小时前
SpringMVC框架(上)
java·后端
幼稚园的山代王2 小时前
JDK 11 LinkedHashMap 详解(底层原理+设计思想)
java·开发语言
不积硅步2 小时前
jenkins安装jdk、maven、git
java·jenkins·maven