MinIO教程(三)| Spring Boot 集成 MinIO 实战(后端篇)

MinIO教程(三)| Spring Boot 集成 MinIO 实战(后端篇)

MinIO 系列文章

【1】MinIO教程(一)| MinIO的安装(Windows)

【2】MinIO教程(二)| Spring Boot 集成 MinIO 实战(场景分析)

【3】MinIO教程(三)| Spring Boot 集成 MinIO 实战(后端篇)

【4】MinIO教程(四)| Spring Boot 集成 MinIO 实战(前端篇)

一、前言:系统概述

本文基于 SpringBoot 2.7.6 + MinIO 8.5.9 构建文件上传下载全场景解决方案,覆盖 小文件(<100MB)直接传输大文件(≥100MB)分片传输+断点续传 两大核心场景,适配企业级文档管理、视频存储、报表导出等业务需求。

核心特性

  • 场景适配:<100MB小文件用「完整流传输」(高效低开销),≥100MB大文件用「5MB分片+断点续传」(稳定抗中断);
  • 存储架构:MinIO存文件、MySQL存元数据、Redis存大文件任务状态;
  • 安全可靠:全局异常处理、参数校验、临时文件自动清理、任务过期回收;
  • 易用性:接口统一前缀、DTO继承复用、工具类封装通用逻辑,复制粘贴即可运行。

技术栈

模块 技术选型 版本要求
基础框架 SpringBoot 2.7.x
对象存储 MinIO Java Client 8.5.9
ORM MyBatis-Plus 3.5.3.1
缓存(大文件) Redisson(Redis客户端) 3.18.0
数据库 MySQL 8.0+
工具类 Lombok、Commons-IO、Jackson 最新稳定版

二、环境准备:3步搭建基础

第一步:添加依赖(pom.xml)

复制以下依赖到项目,自动引入所有核心组件:

xml 复制代码
        <!-- Web核心依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-annotations</artifactId>
        </dependency>

        <!-- MySQL驱动(连接数据库) -->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>

        <!-- MyBatis-Plus(简化CRUD) -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.5.3.1</version>
        </dependency>

        <!-- MinIO核心依赖(必须8.5.12,适配继承式客户端) -->
        <dependency>
            <groupId>io.minio</groupId>
            <artifactId>minio</artifactId>
            <version>8.5.9</version>
        </dependency>

        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
        </dependency>

        <!-- Redis(缓存分片状态) -->
        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson-spring-boot-starter</artifactId>
            <version>3.24.0</version>
        </dependency>

        <!-- Lombok(简化实体类) -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <!-- Hutool(工具类:MD5、文件处理) -->
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.8.16</version>
        </dependency>

        <!-- 参数校验 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>

第二步:创建数据库表(MySQL)

执行以下SQL创建「文件元数据表」,存储文件基础信息(适配小文件和大文件场景):

sql 复制代码
-- 创建数据库(若不存在)
CREATE DATABASE IF NOT EXISTS minio_demo CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- 切换数据库
USE minio_demo;

-- 文件元数据表:存储已上传完成的文件信息
DROP TABLE IF EXISTS `file_metadata`;
CREATE TABLE `file_metadata`  (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID(自增)',
  `upload_task_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '上传任务唯一标识(分片上传=任务ID,简单上传=SIMPLE_前缀+UUID)',
  `file_md5` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '文件标识:分片上传=真实MD5(32位),简单上传=UUID(36位,填充用)',
  `file_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '文件原始名称(如 "文档.pdf")',
  `file_size` bigint NOT NULL COMMENT '文件总大小(字节)',
  `file_type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '文件MIME类型(如 "application/pdf")',
  `minio_bucket` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'MinIO存储桶名称',
  `minio_object_name` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'MinIO中存储的唯一文件名(如 "20240520/文档_123456.pdf")',
  `minio_url` varchar(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '文件预览/下载URL(预签名URL)',
  `upload_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '分片上传的MinIO uploadId(仅分片上传非空)',
  `chunk_num` int NULL DEFAULT 1 COMMENT '总分片数(简单上传=1,分片上传=实际分片数)',
  `upload_user_id` bigint NULL DEFAULT NULL COMMENT '上传者ID(关联用户表)',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间(自动填充)',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间(自动填充)',
  `is_delete` tinyint NOT NULL DEFAULT 0 COMMENT '逻辑删除(0:未删除,1:已删除)',
  PRIMARY KEY (`id`) USING BTREE,
  INDEX `idx_upload_user_id`(`upload_user_id` ASC) USING BTREE,
  INDEX `idx_minio_object_name`(`minio_object_name` ASC) USING BTREE,
  INDEX `idx_upload_task_id`(`upload_task_id` ASC) USING BTREE,
  INDEX `idx_file_md5`(`file_md5` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '文件元数据表(适配简单上传/分片上传)' ROW_FORMAT = DYNAMIC;

第三步:配置文件(YAML)

创建2个核心配置文件,分别配置服务、数据库、MinIO、Redis等参数。

主配置(application.yml)

yaml 复制代码
# 服务器配置
server:
  port: 8080 # 后端服务端口

# Spring 核心配置
spring:
  servlet:
    multipart:
      max-file-size: 15000MB  # 单个文件最大15GB(适配大文件分片)
      max-request-size: 20000MB  # 单次请求最大20GB
  # 数据源配置(MySQL)
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver # MySQL 8.0驱动
    url: jdbc:mysql://localhost:3306/minio_demo?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&useSSL=false
    username: root # 替换为你的MySQL账号
    password: root # 替换为你的MySQL密码
  # Redis配置(Redisson)
  redis:
    redisson:
      file: classpath:/redisson-config.yml # 引用Redis详细配置

# MyBatis-Plus 配置
mybatis-plus:
  mapper-locations: classpath:mapper/**/*.xml # Mapper XML路径
  type-aliases-package: org.example.springbootminiodemo.entity # 实体类别名包
  configuration:
    map-underscore-to-camel-case: true # 下划线转驼峰(如file_name→fileName)
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 打印SQL日志(开发用)

# MinIO 配置
minio:
  endpoint: http://localhost:9000 # MinIO服务地址(默认端口9000)
  access-key: minioadmin # MinIO账号(默认)
  secret-key: minioadmin # MinIO密码(默认)
  bucket-name: file-storage-bucket # 存储桶名称(需提前在MinIO控制台创建)
  pre-sign-url-expire: 3600 # 预签名URL有效期(秒)
  min-part-size: 5242880 # 最小分片大小(5MB=5*1024*1024,MinIO要求)
  batch-download-temp-dir: D:/Downloads/minio-temp/ # 大文件批量下载临时目录(需手动创建)

Redis配置(redisson-config.yml)

src/main/resources 下创建该文件,配置Redis连接(单机模式):

yaml 复制代码
singleServerConfig:
  address: "redis://localhost:6379"
  password: "123456"
  database: 0
  connectionPoolSize: 64
  idleConnectionTimeout: 10000
  connectTimeout: 3000
  timeout: 3000
codec: !<org.redisson.codec.JsonJacksonCodec> {}

三、代码实现:全模块完整代码(后端)

1. 基础配置类:支撑系统运行

跨域配置(CorsConfig.java)

解决前后端分离跨域问题,适配前端请求:

java 复制代码
package org.example.springbootminiodemo.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * 跨域配置(匹配实际接口前缀)
 */
@Configuration
public class CorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**") // 关键:匹配你的实际接口前缀(/api/...)
                .allowedOriginPatterns("http://localhost:5173") // 推荐用allowedOriginPatterns(Spring Boot 2.4+)
                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") // 允许的请求方法
                .allowedHeaders("*") // 允许所有请求头
                .exposedHeaders("Content-Disposition") // 暴露Content-Disposition响应头给前端
                .allowCredentials(true) // 允许携带Cookie(如果需要身份验证)
                .maxAge(3600); // 预检请求缓存时间(1小时,避免频繁预检)
    }
}

MinIO配置(MinioConfig.java)

初始化MinIO客户端(原生+自定义),提供分片上传能力:

java 复制代码
package org.example.springbootminiodemo.config;

import io.minio.MinioAsyncClient;
import io.minio.MinioClient;
import lombok.Data;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * MinIO 配置类:初始化原生 MinioClient 和自定义 CustomMinioClient
 */
@Configuration
@Data // Lombok 注解,自动生成 getter/setter
public class MinioConfig {

    // 从 application.yml 读取 MinIO 配置
    @Value("${minio.endpoint}")
    private String endpoint; // MinIO API 地址

    @Value("${minio.access-key}")
    private String accessKey; // MinIO 访问密钥

    @Value("${minio.secret-key}")
    private String secretKey; // MinIO 密钥

    @Value("${minio.bucket-name}")
    private String bucketName; // 默认存储桶名称

    @Value("${minio.min-part-size}")
    private long minPartSize; // 最小分片大小(字节)

    @Value("${minio.pre-sign-url-expire}")
    private int preSignUrlExpire; // 预签名 URL 有效期(秒)

    /**
     * 配置 Minio客户端
     */
    @Bean
    public MinioClient minioClient() {
        return MinioClient.builder()
                .endpoint(endpoint) // 设置 MinIO API 地址
                .credentials(accessKey, secretKey) // 设置访问密钥和密钥
                .build();
    }

    /**
     * 配置 Minio异步客户端
     */
    @Bean
    public MinioAsyncClient minioAsyncClient() {
        return MinioAsyncClient.builder()
                .endpoint(endpoint)
                .credentials(accessKey, secretKey) // 设置访问密钥和密钥
                .build();
    }


    /**
     * 自定义MinIO客户端:解决原生客户端分片方法不可见问题(核心!)
     */
    @Bean
    public CustomMinioClient customMinioClient() {
        return new CustomMinioClient(minioAsyncClient());
    }
}

自定义MinIO客户端(CustomMinioClient.java)

继承MinioAsyncClient,调用底层分片上传方法(原生客户端方法为protected,需继承才能使用):

java 复制代码
package org.example.springbootminiodemo.config;

import com.google.common.collect.Multimap;
import io.minio.*;
import io.minio.errors.*;
import io.minio.messages.Part;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.io.InputStream;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;

/**
 * 自定义 MinIO 客户端:继承 MinioAsyncClient,获取 protected 修饰的底层分片方法调用权限
 * 目的:实现断点续传逻辑(如进度记录、分片校验)
 */
@Slf4j
public class CustomMinioClient extends MinioAsyncClient  {

    /**
     * 构造方法:通过父类 MinioClient 实例初始化
     * @param minioAsyncClient 原生 MinioClient 实例(从配置类注入)
     */
    public CustomMinioClient(MinioAsyncClient minioAsyncClient ) {
        super(minioAsyncClient);
    }

    /**
     * 初始化分片上传:获取 uploadId(分片上传的唯一标识)
     * 底层方法为 protected,继承后才能调用(对应概念 2)
     * @param bucketName 存储桶名称
     * @param objectName 文件名(MinIO 中存储的唯一名称)
     * @param headers 请求头(如 Content-Type,避免文件预览时默认下载)
     * @return uploadId 分片上传唯一标识
     */
    public String initMultipartUpload(String bucketName, String objectName, Multimap<String, String> headers)
            throws InsufficientDataException, InternalException, InvalidKeyException, IOException, NoSuchAlgorithmException, XmlParserException, ExecutionException, InterruptedException {
        // 调用S3Base中未过时的createMultipartUploadAsync方法
        CompletableFuture<CreateMultipartUploadResponse> future = super.createMultipartUploadAsync(
                bucketName, null, objectName, headers, null
        );
        // 同步阻塞获取结果(如需异步可直接返回CompletableFuture)
        CreateMultipartUploadResponse response = future.get();
        log.info("初始化分片上传成功,bucketName:{},objectName:{},uploadId:{}",
                bucketName, objectName, response.result().uploadId());
        return response.result().uploadId();
    }

    /**
     * 上传分片:将单个分片上传到 MinIO
     * 底层方法为 protected,继承后才能调用(对应概念 2)
     * @param bucketName 存储桶名称
     * @param objectName 文件名
     * @param uploadId 分片上传唯一标识
     * @param partNumber 分片序号(从 1 开始)
     * @param partSize 分片大小(字节)
     * @param partData 分片输入流
     * @return 分片上传结果(包含 ETag,合并分片时需要)
     */
    public UploadPartResponse uploadPart(String bucketName, String objectName, String uploadId,
                                         int partNumber, long partSize, InputStream partData)
            throws InsufficientDataException, InternalException, InvalidKeyException, IOException, NoSuchAlgorithmException, XmlParserException, ExecutionException, InterruptedException {
        log.info("开始上传分片,bucketName:{},objectName:{},uploadId:{},分片序号:{},分片大小:{}KB",
                bucketName, objectName, uploadId, partNumber, partSize / 1024);

        // 调用父类未过时的uploadPartAsync方法
        CompletableFuture<UploadPartResponse> future = super.uploadPartAsync(
                bucketName, null, objectName, partData, partSize, uploadId, partNumber, null, null
        );
        // 同步阻塞获取结果
        UploadPartResponse response = future.get();

        log.info("分片上传成功,分片序号:{},ETag:{}", partNumber, response.etag());
        return response;
    }

    /**
     * 合并分片:将所有上传成功的分片合并为完整文件
     * 底层方法为 protected,继承后才能调用(对应概念 2)
     * @param bucketName 存储桶名称
     * @param objectName 文件名
     * @param uploadId 分片上传唯一标识
     * @param parts 分片列表(包含分片序号和 ETag)
     * @return 合并结果(包含文件存储路径)
     */
    public ObjectWriteResponse completeMultipartUpload(String bucketName, String objectName,
                                                       String uploadId, Part[] parts)
            throws InsufficientDataException, InternalException, InvalidKeyException, IOException, NoSuchAlgorithmException, XmlParserException, ExecutionException, InterruptedException {
        log.info("开始合并分片,bucketName:{},objectName:{},uploadId:{},总分片数:{}",
                bucketName, objectName, uploadId, parts.length);

        // 调用父类未过时的completeMultipartUploadAsync方法
        CompletableFuture<ObjectWriteResponse> future = super.completeMultipartUploadAsync(
                bucketName, null, objectName, uploadId, parts, null, null
        );
        // 同步阻塞获取结果
        ObjectWriteResponse response = future.get();

        log.info("分片合并成功,文件路径:{}", response.object());
        return response;
    }

    /**
     * 查询已上传分片:用于断点续传时获取已上传的分片列表
     * 底层方法为 protected,继承后才能调用(对应概念 2)
     * @param bucketName 存储桶名称
     * @param objectName 文件名
     * @param uploadId 分片上传唯一标识
     * @return 已上传分片列表
     */
    public ListPartsResponse listUploadedParts(String bucketName, String objectName, String uploadId)
            throws InsufficientDataException, InternalException, InvalidKeyException, IOException, NoSuchAlgorithmException, XmlParserException, ExecutionException, InterruptedException {
        log.info("查询已上传分片,bucketName:{},objectName:{},uploadId:{}",
                bucketName, objectName, uploadId);

        // 调用父类未过时的listPartsAsync方法
        CompletableFuture<ListPartsResponse> future = super.listPartsAsync(
                bucketName, null, objectName, 1000, 0, uploadId, null, null
        );
        // 同步阻塞获取结果
        ListPartsResponse response = future.get();

        log.info("查询已上传分片成功,已上传分片数:{}", response.result().partList().size());
        return response;
    }

    /**
     * 取消分片上传:删除未合并的分片(如上传中断后清理无用分片)
     * @param bucketName 存储桶名称
     * @param objectName 文件名
     * @param uploadId 分片上传唯一标识
     */
    public void abortMultipartUpload(String bucketName, String objectName, String uploadId)
            throws InsufficientDataException, InternalException, InvalidKeyException, IOException, NoSuchAlgorithmException, XmlParserException, ExecutionException, InterruptedException {
        log.info("取消分片上传,清理未合并分片,bucketName:{},objectName:{},uploadId:{}",
                bucketName, objectName, uploadId);

        // 调用父类未过时的abortMultipartUploadAsync方法
        CompletableFuture<AbortMultipartUploadResponse> future = super.abortMultipartUploadAsync(
                bucketName, null, objectName, uploadId, null, null
        );
        // 同步阻塞等待完成
        future.get();

        log.info("取消分片上传成功");
    }
}

MyBatis-Plus配置(MyBatisPlusConfig.java)

配置分页插件和自动填充(创建时间、更新时间):

java 复制代码
package org.example.springbootminiodemo.config;

import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.time.LocalDateTime;

/**
 * MyBatis-Plus 配置类:分页插件+自动填充创建时间/更新时间
 */
@Configuration
public class MyBatisPlusConfig implements MetaObjectHandler {

    /**
     * 分页插件(用于文件列表分页查询)
     */
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        // 添加 MySQL 分页插件
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }

    /**
     * 自动填充创建时间(插入数据时)
     */
    @Override
    public void insertFill(MetaObject metaObject) {
        // 填充 createTime 字段(若实体类有该字段且未设置值)
        this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
        // 填充 updateTime 字段(插入时和创建时间一致)
        this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
        // 填充 isDelete 字段(默认未删除)
        this.strictInsertFill(metaObject, "isDelete", Integer.class, 0);
    }

    /**
     * 自动填充更新时间(更新数据时)
     */
    @Override
    public void updateFill(MetaObject metaObject) {
        // 填充 updateTime 字段(更新时自动更新为当前时间)
        this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
    }
}

全局异常处理器(GlobalExceptionHandler.java)

统一捕获异常,返回标准化响应,避免前端处理混乱:

java 复制代码
package org.example.springbootminiodemo.config;

import lombok.extern.slf4j.Slf4j;
import org.example.springbootminiodemo.exception.UploadTaskNotFoundException;
import org.example.springbootminiodemo.util.Result;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.security.InvalidParameterException;

/**
 * 全局异常处理器:统一捕获和处理所有异常
 */
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    /**
     * 处理参数无效异常
     */
    @ExceptionHandler(InvalidParameterException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST) // HTTP状态码:400
    public Result<Void> handleInvalidParameterException(InvalidParameterException e) {
        log.warn("参数无效:{}", e.getMessage());
        return Result.error(e.getMessage());
    }

    /**
     * 处理上传任务不存在异常
     */
    @ExceptionHandler(UploadTaskNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND) // HTTP状态码:404
    public Result<Void> handleUploadTaskNotFoundException(UploadTaskNotFoundException e) {
        log.warn("上传任务不存在:{}", e.getMessage());
        return Result.error(e.getMessage());
    }

    /**
     * 处理其他运行时异常
     */
    @ExceptionHandler(RuntimeException.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) // HTTP状态码:500
    public Result<Void> handleRuntimeException(RuntimeException e) {
        log.error("业务异常", e);
        return Result.error("操作失败:" + e.getMessage());
    }

    /**
     * 处理系统异常(兜底)
     */
    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public Result<Void> handleException(Exception e) {
        log.error("系统异常", e);
        return Result.error("系统繁忙,请稍后再试");
    }
}

2. 实体类:映射数据库表

文件元数据实体(FileMetadata.java)

file_metadata 表字段一一对应,存储文件核心信息:

java 复制代码
package org.example.springbootminiodemo.entity;

import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;

import java.time.LocalDateTime;

/**
 * 文件元数据表实体类:对应数据库file_metadata表
 */
@Data
@TableName("file_metadata") // 关联数据库表名
public class FileMetadata {

    /**
     * 主键ID(自增)
     */
    @TableId(type = IdType.AUTO)
    private Long id;

    /**
     * 上传任务唯一标识(分片上传=taskId,简单上传=SIMPLE_+UUID)
     */
    @TableField("upload_task_id")
    private String uploadTaskId;

    /**
     * 文件标识(分片上传=真实MD5,简单上传=UUID)
     */
    @TableField("file_md5")
    private String fileMd5;

    /**
     * 文件原始名称(如"报表.xlsx")
     */
    @TableField("file_name")
    private String fileName;

    /**
     * 文件总大小(字节)
     */
    @TableField("file_size")
    private Long fileSize;

    /**
     * 文件MIME类型(如"application/pdf")
     */
    @TableField("file_type")
    private String fileType;

    /**
     * MinIO存储桶名称
     */
    @TableField("minio_bucket")
    private String minioBucket;

    /**
     * MinIO中存储的唯一文件名(如"20240520/报表_123.pdf")
     */
    @TableField("minio_object_name")
    private String minioObjectName;

    /**
     * 文件预览/下载URL(预签名URL)
     */
    @TableField("minio_url")
    private String minioUrl;

    /**
     * 分片上传的MinIO uploadId(仅分片上传非空)
     */
    @TableField("upload_id")
    private String uploadId;

    /**
     * 总分片数(简单上传=1,分片上传=实际分片数)
     */
    @TableField("chunk_num")
    private Integer chunkNum;

    /**
     * 上传者ID(关联用户表)
     */
    @TableField("upload_user_id")
    private Long uploadUserId;

    /**
     * 创建时间(自动填充)
     */
    @TableField(value = "create_time", fill = FieldFill.INSERT)
    private LocalDateTime createTime;

    /**
     * 更新时间(自动填充)
     */
    @TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;

    /**
     * 逻辑删除(0=未删除,1=已删除)
     */
    @TableField("is_delete")
    private Integer isDelete;
}

3. DTO:数据传输对象

统一接口入参格式,通过继承复用公共字段,避免重复定义。

基础DTO(BaseFileDTO.java)

所有文件操作DTO的父类,存储共用字段:

java 复制代码
package org.example.springbootminiodemo.dto;

import lombok.Data;

/**
 * 文件操作基础DTO:存储所有场景共用字段
 */
@Data
public class BaseFileDTO {
    private Long uploadUserId; // 上传者ID(关联用户,必填)
    private String fileName;   // 文件名(可选,未传则用原始名称)
    private String fileType;   // 文件类型(可选,未传则自动识别)
}

批量操作DTO(FileBatchOperateDTO.java)

用于批量删除、批量下载接口,传递文件ID列表:

java 复制代码
package org.example.springbootminiodemo.dto;

import lombok.Data;
import lombok.EqualsAndHashCode;

import java.util.List;

/**
 * 批量操作DTO:适配批量删除、批量下载接口
 */
@Data
@EqualsAndHashCode(callSuper = true) // 继承父类的equals和hashCode
public class FileBatchOperateDTO extends BaseFileDTO {
    private List<Long> fileIdList; // 必选:待操作的文件ID集合
}

分页查询DTO(FilePageQueryDTO.java)

用于文件列表分页查询,支持多条件筛选:

java 复制代码
package org.example.springbootminiodemo.dto;

import lombok.Data;
import lombok.EqualsAndHashCode;

/**
 * 分页查询DTO:适配文件列表分页查询接口
 */
@Data
@EqualsAndHashCode(callSuper = true)
public class FilePageQueryDTO extends BaseFileDTO {
    private Integer pageNum = 1;  // 页码(默认1)
    private Integer pageSize = 10; // 每页条数(默认10,最大100)
}

分片下载DTO(FileChunkDownloadDTO.java)

用于大文件分片下载接口,支持单个/批量文件:

java 复制代码
package org.example.springbootminiodemo.dto;

import lombok.Data;
import lombok.EqualsAndHashCode;

import java.util.List;

/**
 * 分片下载DTO:适配单个/批量大文件分片下载接口
 */
@Data
@EqualsAndHashCode(callSuper = true)
public class FileChunkDownloadDTO extends BaseFileDTO {
    private Long fileId;         // 单个文件ID(二选一)
    private List<Long> fileIdList; // 批量文件ID列表(二选一,最多10个)
    private String tempZipFileName; // 批量下载专用:临时ZIP文件名(从状态查询接口获取)
}

分片上传初始化DTO(FileChunkInitCheckDTO.java)

用于大文件分片上传的「状态查询」和「任务初始化」接口:

java 复制代码
package org.example.springbootminiodemo.dto;

import lombok.Data;
import lombok.EqualsAndHashCode;

/**
 * 分片上传初始化DTO:适配状态查询、任务初始化接口
 */
@Data
@EqualsAndHashCode(callSuper = true)
public class FileChunkInitCheckDTO extends BaseFileDTO {
    private String fileMd5;    // 必选(初始化):文件唯一MD5(用于断点续传)
    private Long fileSize;     // 必选(初始化):文件总大小(字节)
    private String uploadTaskId; // 可选(查询):上传任务ID(优先用此查询)
}

分片上传操作DTO(FileChunkOperateDTO.java)

用于大文件分片上传的「上传分片」「合并分片」「暂停/恢复」接口:

java 复制代码
package org.example.springbootminiodemo.dto;

import lombok.Data;
import lombok.EqualsAndHashCode;

/**
 * 分片上传操作DTO:适配上传分片、合并分片、暂停/恢复接口
 */
@Data
@EqualsAndHashCode(callSuper = true)
public class FileChunkOperateDTO extends BaseFileDTO {
    private String uploadTaskId; // 必选:上传任务ID(定位任务)
    private Integer chunkNumber; // 可选(上传分片):当前分片序号(从1开始)
    private Integer totalChunks; // 可选(上传/合并):总分片数
}

4. 异常类:自定义业务异常

针对不同业务场景定义异常,便于定位问题和友好提示。

参数无效异常(InvalidParameterException.java)

java 复制代码
package org.example.springbootminiodemo.exception;

/**
 * 自定义异常:参数无效异常
 * 用于校验参数时(如必填项为空、参数格式错误等)抛出
 */
public class InvalidParameterException extends RuntimeException {

    /**
     * 无参构造
     */
    public InvalidParameterException() {
        super();
    }

    /**
     * 带异常消息的构造
     * @param message 异常描述消息
     */
    public InvalidParameterException(String message) {
        super(message);
    }

    /**
     * 带消息和根因的构造(用于包装底层异常)
     * @param message 异常描述消息
     * @param cause 底层异常(根因)
     */
    public InvalidParameterException(String message, Throwable cause) {
        super(message, cause);
    }

    /**
     * 带根因的构造
     * @param cause 底层异常(根因)
     */
    public InvalidParameterException(Throwable cause) {
        super(cause);
    }
}

文件不存在异常(FileNotFoundException.java)

java 复制代码
package org.example.springbootminiodemo.exception;

import lombok.NoArgsConstructor;

/**
 * 自定义文件未找到异常(业务异常)
 * 场景:文件不存在、已删除、临时文件丢失等场景抛出
 */
@NoArgsConstructor
public class FileNotFoundException extends RuntimeException {

    /**
     * 带异常信息的构造方法
     * @param message 异常描述(前端可展示/日志可记录)
     */
    public FileNotFoundException(String message) {
        super(message);
    }

    /**
     * 带异常信息+根因的构造方法(便于排查嵌套异常)
     * @param message 异常描述
     * @param cause 根异常(如IO异常、数据库查询异常等)
     */
    public FileNotFoundException(String message, Throwable cause) {
        super(message, cause);
    }

    /**
     * 序列化版本号(避免序列化警告)
     */
    private static final long serialVersionUID = 1L;
}

上传任务不存在异常(UploadTaskNotFoundException.java)

java 复制代码
package org.example.springbootminiodemo.exception;

/**
 * 自定义异常:上传任务不存在异常
 * 用于查询/操作上传任务时,任务ID无效或已过期的场景
 */
public class UploadTaskNotFoundException extends RuntimeException {

    /**
     * 无参构造
     */
    public UploadTaskNotFoundException() {
        super();
    }

    /**
     * 带异常消息的构造
     * @param message 异常描述消息
     */
    public UploadTaskNotFoundException(String message) {
        super(message);
    }

    /**
     * 带消息和根因的构造(用于包装底层异常)
     * @param message 异常描述消息
     * @param cause 底层异常(根因)
     */
    public UploadTaskNotFoundException(String message, Throwable cause) {
        super(message, cause);
    }

    /**
     * 带根因的构造
     * @param cause 底层异常(根因)
     */
    public UploadTaskNotFoundException(Throwable cause) {
        super(cause);
    }
}

5. 工具类:通用功能封装

统一响应结果(Result.java)

所有接口返回统一格式,便于前端解析:

java 复制代码
package org.example.springbootminiodemo.util;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * 统一API响应结果类
 * @param <T> 数据类型
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result<T> {
    // 状态码:200=成功,500=失败(与HTTP状态码对应)
    private int code;
    // 提示消息
    private String msg;
    // 响应数据(成功时返回)
    private T data;

    // 成功响应(无数据)
    public static <T> Result<T> success(String msg) {
        return new Result<>(200, msg, null);
    }

    // 成功响应(有数据)
    public static <T> Result<T> success(String msg, T data) {
        return new Result<>(200, msg, data);
    }

    // 失败响应
    public static <T> Result<T> error(String msg) {
        return new Result<>(500, msg, null);
    }
}

JSON工具类(JacksonUtils.java)

线程安全的JSON序列化/反序列化工具,支持LocalDateTime等类型:

java 复制代码
package org.example.springbootminiodemo.util;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import com.fasterxml.jackson.module.paramnames.ParameterNamesModule;
import lombok.extern.slf4j.Slf4j;

import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import java.util.Optional;

/**
 * 线程安全的 Jackson 工具类(覆盖常用 JSON 处理场景)
 * 说明:ObjectMapper 实例在配置完成后是线程安全的,因此采用单例模式
 */
@Slf4j
public final class JacksonUtils {

    // 单例 ObjectMapper 实例(线程安全)
    private static final ObjectMapper OBJECT_MAPPER;

    // 日期时间格式化器(可根据需求调整)
    private static final DateTimeFormatter LOCAL_DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
    private static final DateTimeFormatter LOCAL_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");

    // 静态初始化:配置 ObjectMapper(仅执行一次,保证线程安全)
    static {
        OBJECT_MAPPER = new ObjectMapper();

        // 1. 注册 JDK 8 特性模块
        OBJECT_MAPPER.registerModule(new Jdk8Module()); // 支持 Optional、Stream 等
        OBJECT_MAPPER.registerModule(new ParameterNamesModule()); // 支持构造函数参数名(配合 Lombok)

        // 2. 注册 Java 时间模块(处理 LocalDateTime 等)
        JavaTimeModule javaTimeModule = new JavaTimeModule();
        // 自定义 LocalDateTime 序列化/反序列化
        javaTimeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(LOCAL_DATE_TIME_FORMATTER));
        javaTimeModule.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(LOCAL_DATE_TIME_FORMATTER));
        // 自定义 LocalDate 序列化/反序列化
        javaTimeModule.addSerializer(LocalDate.class, new LocalDateSerializer(LOCAL_DATE_FORMATTER));
        javaTimeModule.addDeserializer(LocalDate.class, new LocalDateDeserializer(LOCAL_DATE_FORMATTER));
        OBJECT_MAPPER.registerModule(javaTimeModule);

        // 3. 全局配置(线程安全,一旦配置完成不修改)
        OBJECT_MAPPER
                // 反序列化:忽略未知字段(避免 JSON 字段多于对象属性时报错)
                .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
                // 反序列化:允许空字符串转为 null(如 "" -> null)
                .configure(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, true)
                // 序列化:允许空对象(如 {})正常序列化,不报错
                .configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false)
                // 序列化:日期不使用时间戳格式(统一用字符串)
                .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
                // 序列化:默认包含所有字段(包括 null,如需忽略可改为 NON_NULL)
                .setDefaultPropertyInclusion(JsonInclude.Include.ALWAYS);
    }

    // 私有构造函数:禁止实例化
    private JacksonUtils() {
        throw new AssertionError("工具类不允许实例化");
    }

    /**
     * 获取原始 ObjectMapper(用于扩展自定义配置,谨慎使用)
     * 注意:修改 ObjectMapper 配置可能破坏线程安全,建议仅用于读取配置
     */
    public static ObjectMapper getObjectMapper() {
        return OBJECT_MAPPER;
    }

    // ------------------------------ 序列化(对象 -> JSON)------------------------------

    /**
     * 对象转 JSON 字符串
     */
    public static String toJson(Object obj) {
        if (obj == null) {
            return null;
        }
        try {
            return OBJECT_MAPPER.writeValueAsString(obj);
        } catch (JsonProcessingException e) {
            log.error("序列化失败:对象={}", obj, e);
            throw new RuntimeException("JSON 序列化失败", e);
        }
    }

    /**
     * 对象转格式化的 JSON 字符串(带缩进,便于阅读)
     */
    public static String toPrettyJson(Object obj) {
        if (obj == null) {
            return null;
        }
        try {
            return OBJECT_MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(obj);
        } catch (JsonProcessingException e) {
            log.error("格式化序列化失败:对象={}", obj, e);
            throw new RuntimeException("JSON 格式化序列化失败", e);
        }
    }

    /**
     * 对象转 JSON 字节数组(UTF-8 编码)
     */
    public static byte[] toJsonBytes(Object obj) {
        if (obj == null) {
            return null;
        }
        try {
            return OBJECT_MAPPER.writeValueAsBytes(obj);
        } catch (JsonProcessingException e) {
            log.error("序列化字节数组失败:对象={}", obj, e);
            throw new RuntimeException("JSON 字节数组序列化失败", e);
        }
    }

    /**
     * 对象写入 JSON 文件
     */
    public static void writeToFile(Object obj, String filePath) {
        if (obj == null || filePath == null) {
            return;
        }
        try {
            OBJECT_MAPPER.writeValue(new File(filePath), obj);
        } catch (IOException e) {
            log.error("写入 JSON 文件失败:对象={}, 路径={}", obj, filePath, e);
            throw new RuntimeException("JSON 文件写入失败", e);
        }
    }

    // ------------------------------ 反序列化(JSON -> 对象)------------------------------

    /**
     * JSON 字符串转对象
     */
    public static <T> T fromJson(String json, Class<T> clazz) {
        if (json == null || json.isEmpty()) {
            return null;
        }
        try {
            return OBJECT_MAPPER.readValue(json, clazz);
        } catch (JsonProcessingException e) {
            log.error("反序列化失败:JSON={}, 目标类型={}", json, clazz, e);
            throw new RuntimeException("JSON 反序列化失败", e);
        }
    }

    /**
     * JSON 字节数组(UTF-8)转对象
     */
    public static <T> T fromJsonBytes(byte[] jsonBytes, Class<T> clazz) {
        if (jsonBytes == null || jsonBytes.length == 0) {
            return null;
        }
        try {
            return OBJECT_MAPPER.readValue(jsonBytes, clazz);
        } catch (IOException e) {
            log.error("字节数组反序列化失败:目标类型={}", clazz, e);
            throw new RuntimeException("JSON 字节数组反序列化失败", e);
        }
    }

    /**
     * JSON 文件转对象
     */
    public static <T> T fromJsonFile(String filePath, Class<T> clazz) {
        if (filePath == null) {
            return null;
        }
        try {
            return OBJECT_MAPPER.readValue(new File(filePath), clazz);
        } catch (IOException e) {
            log.error("文件反序列化失败:路径={}, 目标类型={}", filePath, clazz, e);
            throw new RuntimeException("JSON 文件反序列化失败", e);
        }
    }

    // ------------------------------ 泛型处理(List/Map 等)------------------------------

    /**
     * JSON 字符串转泛型对象(如 List<User>、Map<String, Integer>)
     * 使用示例:fromJsonGeneric(json, new TypeReference<List<User>>() {})
     */
    public static <T> T fromJsonGeneric(String json, TypeReference<T> typeReference) {
        if (json == null || json.isEmpty()) {
            return null;
        }
        try {
            return OBJECT_MAPPER.readValue(json, typeReference);
        } catch (JsonProcessingException e) {
            log.error("泛型反序列化失败:JSON={}, 类型={}", json, typeReference, e);
            throw new RuntimeException("JSON 泛型反序列化失败", e);
        }
    }

    /**
     * JSON 字符串转 List<T>(简化调用)
     */
    public static <T> List<T> fromJsonToList(String json, Class<T> elementType) {
        return fromJsonGeneric(json, new TypeReference<List<T>>() {});
    }

    /**
     * JSON 字符串转 Map<String, T>(简化调用)
     */
    public static <T> Map<String, T> fromJsonToMap(String json, Class<T> valueType) {
        return fromJsonGeneric(json, new TypeReference<Map<String, T>>() {});
    }

    // ------------------------------ JDK 8 特性支持 ------------------------------

    /**
     * JSON 字符串转 Optional<T>
     */
    public static <T> Optional<T> fromJsonToOptional(String json, Class<T> clazz) {
        T result = fromJson(json, clazz);
        return Optional.ofNullable(result);
    }

    // ------------------------------ 扩展:自定义序列化器/反序列化器 ------------------------------

    /**
     * 注册自定义序列化器(线程安全:仅初始化时调用,或同步调用)
     */
    public static <T> void registerSerializer(Class<T> type, JsonSerializer<T> serializer) {
        synchronized (OBJECT_MAPPER) { // 同步保证配置修改线程安全
            SimpleModule module = new SimpleModule();
            module.addSerializer(type, serializer);
            OBJECT_MAPPER.registerModule(module);
        }
    }

    /**
     * 注册自定义反序列化器(线程安全:仅初始化时调用,或同步调用)
     */
    public static <T> void registerDeserializer(Class<T> type, JsonDeserializer<T> deserializer) {
        synchronized (OBJECT_MAPPER) { // 同步保证配置修改线程安全
            SimpleModule module = new SimpleModule();
            module.addDeserializer(type, deserializer);
            OBJECT_MAPPER.registerModule(module);
        }
    }
}

上传任务ID生成器(UploadTaskIdGenerator.java)

生成分片上传任务的唯一标识(时间戳+随机字符,避免冲突):

java 复制代码
package org.example.springbootminiodemo.util;

import java.security.SecureRandom;
import java.time.Instant;

/**
 * 上传任务ID生成器(时间戳+8位随机字符,共21位)
 */
public class UploadTaskIdGenerator {
    // 随机字符池:大小写字母 + 数字(共62个)
    private static final String CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
    private static final SecureRandom RANDOM = new SecureRandom(); // 加密级随机数生成器

    /**
     * 生成 upload_task_id:13位毫秒时间戳 + 8位随机字符
     */
    public static String generate() {
        // 1. 生成13位毫秒时间戳
        long timestamp = Instant.now().toEpochMilli();
        String timestampStr = String.valueOf(timestamp);

        // 2. 生成8位随机字符
        StringBuilder randomSb = new StringBuilder(8);
        for (int i = 0; i < 8; i++) {
            int index = RANDOM.nextInt(CHARACTERS.length());
            randomSb.append(CHARACTERS.charAt(index));
        }

        // 3. 拼接结果
        return timestampStr + randomSb.toString();
    }
}

临时ZIP清理定时任务(TempZipCleanTask.java)

定时清理大文件批量下载生成的临时ZIP文件,避免磁盘溢出:

java 复制代码
package org.example.springbootminiodemo.task;

import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.io.File;
import java.io.RandomAccessFile;
import java.util.Arrays;

@Slf4j
@Component
public class TempZipCleanTask {

    private static final String TEMP_DIR = System.getProperty("java.io.tmpdir");
    private static final String TEMP_ZIP_SUFFIX = ".zip.tmp";
    private static final long EXPIRE_TIME = 24 * 60 * 60 * 1000; // 过期时间:24小时(从最后访问时间算)

    /**
     * 每天凌晨2点执行(低峰期,影响最小)
     */
    @Scheduled(cron = "0 0 2 * * ?")
    public void cleanExpiredTempZip() {
        log.info("开始清理过期临时ZIP文件,目录:{}", TEMP_DIR);
        File tempDirFile = new File(TEMP_DIR);
        File[] tempFiles = tempDirFile.listFiles((dir, name) ->
                name.endsWith(TEMP_ZIP_SUFFIX)
        );

        if (tempFiles == null || tempFiles.length == 0) {
            log.info("无临时ZIP文件需要清理");
            return;
        }

        long currentTime = System.currentTimeMillis();
        int deleteCount = 0;

        for (File file : tempFiles) {
            // 1. 判断文件是否过期:当前时间 - 最后访问时间 > 24小时
            long lastAccessTime = file.lastModified();
            long timeDiff = currentTime - lastAccessTime;
            if (timeDiff <= EXPIRE_TIME) {
                log.debug("文件未过期,跳过清理:{},最后访问时间:{}", file.getName(), lastAccessTime);
                continue;
            }

            // 2. 判断文件是否被占用(比如用户正在下载,或其他线程在用)
            if (isFileInUse(file)) {
                log.warn("文件正在被使用,跳过清理:{}", file.getName());
                continue;
            }

            // 3. 满足"过期+未被占用",执行删除
            if (file.delete()) {
                log.info("清理过期临时ZIP文件:{},最后访问时间:{}", file.getAbsolutePath(), lastAccessTime);
                deleteCount++;
            } else {
                log.warn("文件删除失败:{}(可能无权限)", file.getAbsolutePath());
            }
        }

        log.info("临时ZIP文件清理完成,共清理:{}个,剩余:{}个",
                deleteCount, tempFiles.length - deleteCount);
    }

    /**
     * 关键工具方法:判断文件是否被占用(避免删正在下载的文件)
     */
    private boolean isFileInUse(File file) {
        RandomAccessFile raf = null;
        try {
            // 尝试以"只读"模式打开文件,如果失败,说明文件被占用
            raf = new RandomAccessFile(file, "r");
            return false; // 打开成功,文件未被占用
        } catch (Exception e) {
            // 抛出异常(如:文件被其他进程锁定),说明文件正在被使用
            return true;
        } finally {
            if (raf != null) {
                try {
                    raf.close();
                } catch (Exception e) {
                    // 忽略关闭异常
                }
            }
        }
    }
}

6. Helper类:业务工具封装

封装通用业务逻辑(如MinIO操作、元数据处理、Redis任务管理),避免Service层代码冗余。

基础文件工具(FileBasicHelper.java)

处理通用文件操作:参数校验、元数据构建、预签名URL生成等:

java 复制代码
package org.example.springbootminiodemo.helper;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import io.minio.GetPresignedObjectUrlArgs;
import io.minio.RemoveObjectArgs;
import io.minio.http.Method;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.lang3.StringUtils;
import org.example.springbootminiodemo.config.CustomMinioClient;
import org.example.springbootminiodemo.config.MinioConfig;
import org.example.springbootminiodemo.entity.FileMetadata;
import org.example.springbootminiodemo.exception.InvalidParameterException;
import org.example.springbootminiodemo.mapper.FileMetadataMapper;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;

/**
 * 通用文件操作辅助工具类(基础类+分片上传类共用)
 * 职责:封装所有文件操作的通用逻辑(MinIO基础操作、基础参数校验、元数据构建等)
 */
@Slf4j
@Component
public class FileBasicHelper {

    // ==================== 通用常量(原通用常量迁移)====================
    /** MinIO文件存储日期格式(按天分目录,便于管理) */
    public static final String MINIO_DATE_FORMAT = "yyyyMMdd";
    /** 基础参数校验相关提示 */
    public static final String ERROR_EMPTY_FILE_MD5 = "fileMd5不能为空(用于文件唯一标识)";
    public static final String ERROR_EMPTY_FILE_NAME = "fileName不能为空";
    public static final String ERROR_INVALID_FILE_SIZE = "fileSize必须大于0字节";
    public static final String ERROR_EMPTY_FILE_TYPE = "fileType不能为空(如image/png)";

    // ==================== 通用依赖注入(仅保留通用逻辑需要的Bean)====================
    private final CustomMinioClient customMinioClient;
    private final MinioConfig minioConfig;
    private final FileMetadataMapper fileMetadataMapper;

    public FileBasicHelper(CustomMinioClient customMinioClient, MinioConfig minioConfig, FileMetadataMapper fileMetadataMapper) {
        this.customMinioClient = customMinioClient;
        this.minioConfig = minioConfig;
        this.fileMetadataMapper = fileMetadataMapper;
    }

    // ==================== 通用方法(从原FileUploadHelper抽离)====================

    /**
     * 基础参数校验(通用:基础上传、分片上传都需要)
     */
    public void validateCommonParams(String fileMd5, String fileName, Long fileSize, String fileType) {
        if (StringUtils.isBlank(fileMd5)) {
            throw new InvalidParameterException(ERROR_EMPTY_FILE_MD5);
        }
        if (StringUtils.isBlank(fileName)) {
            throw new InvalidParameterException(ERROR_EMPTY_FILE_NAME);
        }
        if (fileSize == null || fileSize <= 0) {
            throw new InvalidParameterException(ERROR_INVALID_FILE_SIZE);
        }
        if (StringUtils.isBlank(fileType)) {
            throw new InvalidParameterException(ERROR_EMPTY_FILE_TYPE);
        }
    }

    /**
     * 构建文件元数据(通用:基础上传、分片上传都需要存储元数据)
     */
    public FileMetadata buildFileMetadata(String uploadTaskId, String fileMd5, String fileName, long fileSize,
                                          String minioBucket, String minioObjectName, String minioUploadId,
                                          Integer chunkNum, Long uploadUserId) {
        FileMetadata metadata = new FileMetadata();
        metadata.setUploadTaskId(uploadTaskId); // 基础上传可传null,分片上传传taskId
        metadata.setFileMd5(fileMd5);
        metadata.setFileName(fileName);
        metadata.setFileSize(fileSize);
        metadata.setFileType(FilenameUtils.getExtension(fileName));
        metadata.setMinioBucket(minioBucket);
        metadata.setMinioObjectName(minioObjectName);
        metadata.setUploadId(minioUploadId); // 基础上传可传null
        metadata.setChunkNum(chunkNum); // 基础上传传1(不分片),分片上传传总片数
        metadata.setUploadUserId(uploadUserId);
        metadata.setIsDelete(0);
        metadata.setCreateTime(LocalDateTime.now());
        metadata.setUpdateTime(LocalDateTime.now());
        return metadata;
    }

    /**
     * 生成MinIO预签名URL(通用:基础下载、分片下载都需要)
     */
    public String generateMinioPresignedUrl(String minioBucket, String minioObjectName) {
        try {
            return customMinioClient.getPresignedObjectUrl(
                    GetPresignedObjectUrlArgs.builder()
                            .bucket(minioBucket)
                            .object(minioObjectName)
                            .method(Method.GET)
                            .expiry(minioConfig.getPreSignUrlExpire())
                            .build()
            );
        } catch (Exception e) {
            log.error("生成MinIO访问URL失败,bucket={},object={}", minioBucket, minioObjectName, e);
            throw new RuntimeException("生成文件访问链接失败", e);
        }
    }

    /**
     * 回滚MinIO文件(通用:基础上传、分片上传失败时都需要回滚)
     */
    public void rollbackMinioFile(String minioBucket, String minioObjectName) {
        try {
            customMinioClient.removeObject(
                    RemoveObjectArgs.builder()
                            .bucket(minioBucket)
                            .object(minioObjectName)
                            .build()
            );
            log.info("MinIO文件回滚成功,已删除孤立文件:{}/{}", minioBucket, minioObjectName);
        } catch (Exception e) {
            log.error("MinIO文件回滚失败!孤立文件残留:{}/{},请手动清理", minioBucket, minioObjectName, e);
        }
    }

    /**
     * 通过fileMd5查询已上传的文件元数据(通用:基础上传去重、分片上传进度恢复都可能用到)
     */
    public FileMetadata queryFileByMd5AndUserId(String fileMd5, Long uploadUserId) {
        if (StringUtils.isBlank(fileMd5) || uploadUserId == null) {
            log.warn("查询文件元数据:fileMd5或uploadUserId为空");
            return null;
        }
        return fileMetadataMapper.selectOne(
                new QueryWrapper<FileMetadata>()
                        .eq("file_md5", fileMd5)
                        .eq("upload_user_id", uploadUserId)
                        .eq("is_delete", 0)
        );
    }


    // ==================== 批量下载ZIP条目名生成工具(新增)====================
    /** ZIP不支持的特殊字符集合(需替换为下划线) */
    private static final Set<Character> ILLEGAL_ZIP_CHARS = new HashSet<>(
            Arrays.asList('/', '\\', ':', '*', '?', '"', '<', '>', '|')
    );

    /**
     * 生成用户友好的ZIP条目名(保留原始文件名,重复时加(序号)后缀)
     * @param originalFileName 原始文件名(来自文件元数据)
     * @param usedEntryNames   已使用的ZIP条目名集合(确保唯一性)
     * @return 处理后的唯一ZIP条目名
     */
    public String generateZipEntryName(String originalFileName, Set<String> usedEntryNames) {
        // 步骤1:替换特殊字符(避免ZIP打包报错)
        String safeFileName = replaceIllegalChars(originalFileName);

        // 步骤2:拆分文件名和后缀(如"简历.pdf" → 文件名"简历" + 后缀".pdf")
        String baseName = safeFileName;
        String suffix = "";
        int lastDotIndex = safeFileName.lastIndexOf('.');
        if (lastDotIndex != -1 && lastDotIndex < safeFileName.length() - 1) {
            baseName = safeFileName.substring(0, lastDotIndex);
            suffix = safeFileName.substring(lastDotIndex); // 包含".",如".pdf"
        }

        // 步骤3:生成唯一文件名(重复时加序号)
        String finalEntryName = safeFileName;
        int sequence = 1;
        while (usedEntryNames.contains(finalEntryName)) {
            finalEntryName = baseName + "(" + sequence + ")" + suffix;
            sequence++;
        }

        // 记录已使用的条目名,避免后续重复
        usedEntryNames.add(finalEntryName);
        return finalEntryName;
    }

    /**
     * 替换文件名中的非法字符(ZIP不支持的字符替换为下划线)
     */
    private String replaceIllegalChars(String fileName) {
        if (StringUtils.isBlank(fileName)) {
            return "未知文件名_" + System.currentTimeMillis(); // 兜底:避免空文件名
        }
        StringBuilder safeBuilder = new StringBuilder();
        for (char c : fileName.toCharArray()) {
            safeBuilder.append(ILLEGAL_ZIP_CHARS.contains(c) ? '_' : c);
        }
        return safeBuilder.toString();
    }
}

分片下载工具(FileChunkDownloadHelper.java)

封装大文件分片下载的核心逻辑:状态查询、分片解析、临时ZIP生成等:

java 复制代码
package org.example.springbootminiodemo.helper;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import io.minio.GetObjectArgs;
import io.minio.MinioClient;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.compress.archivers.zip.Zip64Mode;
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.example.springbootminiodemo.config.MinioConfig;
import org.example.springbootminiodemo.entity.FileMetadata;
import org.example.springbootminiodemo.exception.FileNotFoundException; // 补充导入
import org.example.springbootminiodemo.exception.InvalidParameterException;
import org.example.springbootminiodemo.mapper.FileMetadataMapper; // 补充导入(关键!之前缺失)
import org.example.springbootminiodemo.vo.ChunkDownloadStatusVO;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;

import javax.servlet.http.HttpServletResponse;
import java.io.*;
import java.net.URLEncoder;
import java.util.List;
import java.util.UUID;

/**
 * 分片下载专属工具类(已修正:补充导入+逻辑优化)
 */
@Slf4j
@Component
public class FileChunkDownloadHelper {

    // ==================== 常量定义 ====================
    public static final int BUFFER_SIZE = 8192; // 8KB缓冲区
    public static final int MAX_BATCH_FILE_COUNT = 10; // 单次打包最大文件数
    public static final String TEMP_ZIP_PREFIX = "batch_download_";
    public static final String TEMP_ZIP_SUFFIX = ".zip.tmp";
    public static final String ZIP_FILE_NAME_PREFIX = "批量下载_";
    public static final String ZIP_FILE_NAME_SUFFIX = ".zip";

    // 状态码常量
    public static final int FILE_STATUS_EXIST = 1;
    public static final int FILE_STATUS_EXIST_NO_CHUNK = 2;
    public static final int FILE_STATUS_NOT_EXIST = 3;

    // ==================== 依赖注入 ====================
    private final MinioClient minioClient;
    private final MinioConfig minioConfig;
    private final FileMetadataMapper fileMetadataMapper;

    public FileChunkDownloadHelper(MinioClient minioClient, MinioConfig minioConfig,
                                   FileMetadataMapper fileMetadataMapper) {
        this.minioClient = minioClient;
        this.minioConfig = minioConfig;
        this.fileMetadataMapper = fileMetadataMapper;
    }

    @Value("${minio.batch-download-temp-dir}")
    private String batchDownloadTempDir;

    // ==================== 1. 状态查询相关工具方法 ====================
    /**
     * 查询单个文件分片下载状态(总大小、是否存在)
     */
    public ChunkDownloadStatusVO querySingleFileStatus(Long fileId) {
        // 校验文件ID
        if (fileId == null) {
            throw new InvalidParameterException("fileId不能为空");
        }

        // 查询文件元数据
        FileMetadata file = fileMetadataMapper.selectOne(
                new QueryWrapper<FileMetadata>()
                        .eq("id", fileId)
                        .eq("is_delete", 0)
        );
        if (file == null) {
            return new ChunkDownloadStatusVO()
                    .setStatus(FILE_STATUS_NOT_EXIST)
                    .setTotalSize(0L)
                    .setSupportChunk(false)
                    .setMessage("文件不存在或已删除");
        }

        // 校验MinIO存储参数
        if (StringUtils.isBlank(file.getMinioBucket()) || StringUtils.isBlank(file.getMinioObjectName())) {
            return new ChunkDownloadStatusVO()
                    .setStatus(FILE_STATUS_EXIST_NO_CHUNK)
                    .setTotalSize(file.getFileSize())
                    .setSupportChunk(false)
                    .setMessage("文件元数据不完整,不支持分片下载");
        }

        // 返回正常状态
        return new ChunkDownloadStatusVO()
                .setStatus(FILE_STATUS_EXIST)
                .setTotalSize(file.getFileSize())
                .setSupportChunk(true)
                .setMessage("文件支持分片下载")
                .setTempZipFileName(null); // 单个文件无ZIP文件名
    }

    /**
     * 查询批量文件分片下载状态(预估ZIP大小、是否存在有效文件)
     */
    public ChunkDownloadStatusVO queryBatchFileStatus(List<Long> fileIdList) throws FileNotFoundException {
        // 校验文件列表
        List<FileMetadata> validFiles = validateBatchFiles(fileIdList);
        if (CollectionUtils.isEmpty(validFiles)) {
            return new ChunkDownloadStatusVO()
                    .setStatus(FILE_STATUS_NOT_EXIST)
                    .setTotalSize(0L)
                    .setSupportChunk(false)
                    .setMessage("无有效文件(不存在或已删除)");
        }

        // 预估ZIP大小(单个文件大小总和 + 10%压缩 overhead,仅用于前端分片计算)
        long totalSize = validFiles.stream().mapToLong(FileMetadata::getFileSize).sum();
        long estimatedZipSize = (long) (totalSize * 1.1); // 预估ZIP大小

        // 生成临时ZIP文件名(前端后续下载时需携带,用于匹配临时文件)
        String tempZipFileName = ZIP_FILE_NAME_PREFIX + UUID.randomUUID() + ZIP_FILE_NAME_SUFFIX;

        return new ChunkDownloadStatusVO()
                .setStatus(FILE_STATUS_EXIST)
                .setTotalSize(estimatedZipSize)
                .setSupportChunk(true)
                .setMessage("批量文件支持打包ZIP后分片下载")
                .setTempZipFileName(tempZipFileName);
    }

    // ==================== 2. 单个文件分片下载核心方法 ====================
    /**
     * 单个文件分片传输(从MinIO直接读取分片流)
     */
    public void transferSingleFileChunk(FileMetadata file, long start, long end, HttpServletResponse response) throws Exception {
        // 新增校验:避免非法范围(start > end)
        if (start > end) {
            throw new InvalidParameterException("分片范围非法:start=" + start + " > end=" + end);
        }

        String minioBucket = file.getMinioBucket();
        String minioObjectName = file.getMinioObjectName();

        // 从MinIO读取指定字节范围的流(MinIO支持Range查询,减少带宽占用)
        try (InputStream minioIn = minioClient.getObject(
                GetObjectArgs.builder()
                        .bucket(minioBucket)
                        .object(minioObjectName)
                        .offset(start) // 起始字节
                        .length(end - start + 1) // 读取长度(必须≥1)
                        .build())) {

            OutputStream out = response.getOutputStream();
            byte[] buffer = new byte[BUFFER_SIZE];
            int len;
            while ((len = minioIn.read(buffer)) != -1) {
                out.write(buffer, 0, len);
                out.flush();
            }
            log.info("单个文件分片传输完成:fileId={},范围={}-{}", file.getId(), start, end);
        } catch (Exception e) {
            log.error("单个文件分片传输失败:fileId={},范围={}-{}", file.getId(), start, end, e);
            throw e; // 向上抛出,让Service统一处理
        }
    }

    // ==================== 3. 批量文件打包ZIP相关方法(复用+优化) ====================
    /**
     * 校验批量文件合法性
     */
    public List<FileMetadata> validateBatchFiles(List<Long> fileIdList) throws FileNotFoundException {
        if (CollectionUtils.isEmpty(fileIdList)) {
            throw new InvalidParameterException("待下载文件ID列表不能为空");
        }
        if (fileIdList.size() > MAX_BATCH_FILE_COUNT) {
            throw new InvalidParameterException("单次下载文件数不能超过" + MAX_BATCH_FILE_COUNT + "个,请分批下载");
        }

        List<FileMetadata> fileMetadataList = fileMetadataMapper.selectList(
                new QueryWrapper<FileMetadata>()
                        .in("id", fileIdList)
                        .eq("is_delete", 0)
        );
        if (CollectionUtils.isEmpty(fileMetadataList)) {
            throw new FileNotFoundException("无有效下载文件(文件不存在或已删除)");
        }
        return fileMetadataList;
    }

    /**
     * 生成批量文件的临时ZIP(按前端传递的ZIP文件名命名,便于匹配)
     */
    public File createBatchZipFile(List<FileMetadata> fileMetadataList, String tempZipFileName) throws IOException {
        // 校验ZIP文件名(避免非法字符)
        if (StringUtils.isBlank(tempZipFileName) || tempZipFileName.contains("/") || tempZipFileName.contains("\\")) {
            throw new InvalidParameterException("临时ZIP文件名非法:" + tempZipFileName);
        }

        // 按前端传递的文件名创建临时文件(避免重复生成)
        File tempDir = getCustomTempDir(batchDownloadTempDir);
        File tempZipFile = new File(tempDir, tempZipFileName + TEMP_ZIP_SUFFIX);
        log.info("创建批量下载临时ZIP:{},待打包文件数:{}", tempZipFile.getAbsolutePath(), fileMetadataList.size());

        // 若临时文件已存在,直接返回(避免重复打包)
        if (tempZipFile.exists() && tempZipFile.length() > 0) {
            log.info("临时ZIP已存在,直接复用:{}", tempZipFile.getAbsolutePath());
            return tempZipFile;
        }

        // 打包ZIP(启用ZIP64支持4GB+文件)
        try (ZipArchiveOutputStream zipOut = new ZipArchiveOutputStream(new FileOutputStream(tempZipFile))) {
            zipOut.setUseZip64(Zip64Mode.Always);
            zipOut.setLevel(5); // 平衡压缩率和速度

            for (FileMetadata file : fileMetadataList) {
                String zipEntryName = file.getId() + "_" + file.getFileName(); // 避免同名冲突
                ZipArchiveEntry zipEntry = new ZipArchiveEntry(zipEntryName);
                zipOut.putArchiveEntry(zipEntry);

                // 从MinIO读取文件流写入ZIP
                try (InputStream minioIn = minioClient.getObject(
                        GetObjectArgs.builder()
                                .bucket(file.getMinioBucket())
                                .object(file.getMinioObjectName())
                                .build())) {
                    IOUtils.copy(minioIn, zipOut);
                } catch (Exception e) {
                    throw new IOException("文件打包失败:fileId=" + file.getId() + ",fileName=" + file.getFileName(), e);
                }

                zipOut.closeArchiveEntry();
                log.info("文件添加到ZIP:fileId={},文件名={}", file.getId(), file.getFileName());
            }

            zipOut.finish();
            log.info("临时ZIP生成成功:大小={}MB", tempZipFile.length() / 1024 / 1024);
            return tempZipFile;
        } catch (Exception e) {
            // 打包失败,删除临时文件
            if (tempZipFile.exists() && !tempZipFile.delete()) {
                log.warn("临时ZIP删除失败:{}", tempZipFile.getAbsolutePath());
            }
            log.error("生成临时ZIP文件失败", e);
            throw new IOException("批量文件打包失败:" + e.getMessage(), e);
        }
    }

    // ==================== 通用工具方法(复用+完善) ====================
    /**
     * 解析Range请求头(支持 bytes=0-1048575 或 bytes=1048576- 格式)
     */
    public long[] parseRangeHeader(String rangeHeader, long totalSize) {
        // 新增校验:文件总大小不能为0
        if (totalSize <= 0) {
            log.warn("文件总大小非法:{},返回默认范围0-0", totalSize);
            return new long[]{0, 0};
        }

        long start = 0;
        long end = totalSize - 1;

        if (StringUtils.isBlank(rangeHeader) || !rangeHeader.startsWith("bytes=")) {
            log.info("Range头为空或格式不支持,返回完整文件流:0-{}", end);
            return new long[]{start, end};
        }

        String rangeStr = rangeHeader.substring("bytes=".length()).trim();
        String[] rangeParts = rangeStr.split("-");
        try {
            // 解析起始字节
            if (StringUtils.isNotBlank(rangeParts[0])) {
                start = Long.parseLong(rangeParts[0]);
                start = Math.max(start, 0);
                start = Math.min(start, totalSize - 1);
            }

            // 解析结束字节
            if (rangeParts.length > 1 && StringUtils.isNotBlank(rangeParts[1])) {
                end = Long.parseLong(rangeParts[1]);
                end = Math.max(end, start);
                end = Math.min(end, totalSize - 1);
            }

            log.info("解析Range成功:{} → 实际范围:{}-{}(总大小:{})", rangeHeader, start, end, totalSize);
            return new long[]{start, end};
        } catch (NumberFormatException e) {
            log.warn("Range解析失败,返回完整文件流:{}", rangeHeader, e);
            return new long[]{start, end};
        }
    }

    /**
     * 构建分片下载响应头(支持断点续传、中文文件名)
     */
    public void buildChunkResponseHeader(HttpServletResponse response, String fileName,
                                         long start, long end, long totalSize) throws UnsupportedEncodingException {
        // 校验文件名(避免空指针)
        if (StringUtils.isBlank(fileName)) {
            fileName = "未知文件_" + System.currentTimeMillis();
        }

        // 编码中文文件名(兼容所有浏览器)
        String encodedFileName = URLEncoder.encode(fileName, "UTF-8");
        // 替换 URLEncoder 编码的空格(+)为标准 URL 空格编码(%20)
        encodedFileName = encodedFileName.replace("+", "%20");

        // 基础响应头
        response.setContentType("application/octet-stream");
        response.setCharacterEncoding("UTF-8");
        response.setHeader("Pragma", "no-cache");
        response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
        response.setDateHeader("Expires", 0);

        // 下载响应头(兼容Chrome/Firefox/Edge/Safari)
        response.setHeader("Content-Disposition",
                "attachment;filename=" + encodedFileName + ";filename*=UTF-8''" + encodedFileName);

        // 分片相关响应头(断点续传核心)
        response.setHeader("Accept-Ranges", "bytes");
        response.setHeader("Content-Range", String.format("bytes %d-%d/%d", start, end, totalSize));
        response.setContentLengthLong(end - start + 1); // 支持大文件(超过2GB)
    }

    /**
     * 传输本地文件分片(用于临时ZIP文件的分片传输)
     */
    public void transferLocalFileChunk(File localFile, long start, long end, HttpServletResponse response) throws IOException {
        // 新增校验:文件必须存在且是文件(不是目录)
        if (!localFile.exists() || !localFile.isFile()) {
            throw new FileNotFoundException("临时文件不存在或不是文件:" + localFile.getAbsolutePath());
        }
        // 校验范围合法性
        if (start > end || start < 0 || end >= localFile.length()) {
            throw new InvalidParameterException("分片范围非法:start=" + start + ",end=" + end + ",文件大小=" + localFile.length());
        }

        try (RandomAccessFile raf = new RandomAccessFile(localFile, "r")) {
            // 核心新增:更新文件的"最后访问时间"为当前时间(标记文件还在被使用)
            localFile.setLastModified(System.currentTimeMillis());

            raf.seek(start); // 精准定位起始字节
            OutputStream out = response.getOutputStream();
            byte[] buffer = new byte[BUFFER_SIZE];
            long remaining = end - start + 1;

            while (remaining > 0) {
                int readLen = raf.read(buffer, 0, (int) Math.min(buffer.length, remaining));
                if (readLen == -1) break;
                out.write(buffer, 0, readLen);
                out.flush();
                remaining -= readLen;
            }
            log.info("本地文件分片传输完成:{},范围={}-{}", localFile.getName(), start, end);
        } catch (Exception e) {
            log.error("本地文件分片传输失败:{},范围={}-{}", localFile.getName(), start, end, e);
            throw e;
        }
    }

    /**
     * 获取自定义临时目录(不存在则自动创建)
     */
    private File getCustomTempDir(String tempDirPath) {
        File tempDir = new File(tempDirPath);
        // 自动创建目录(包括父目录,比如D:/temp不存在也会一起创建)
        if (!tempDir.exists()) {
            boolean created = tempDir.mkdirs();
            if (created) {
                log.info("自定义临时目录创建成功:{}", tempDirPath);
            } else {
                log.error("自定义临时目录创建失败!权限不足或路径非法:{}", tempDirPath);
                //  fallback到系统目录,避免程序报错
                return new File(System.getProperty("java.io.tmpdir"));
            }
        }
        return tempDir;
    }

    /**
     * 清理临时ZIP文件(下载完成后调用,避免磁盘占用)
     */
    public void cleanTempZipFile(String tempZipFileName) {
        if (StringUtils.isBlank(tempZipFileName)) {
            log.warn("临时ZIP文件名为空,跳过清理");
            return;
        }

        File tempDir = getCustomTempDir(batchDownloadTempDir);
        File tempZipFile = new File(tempDir, tempZipFileName + TEMP_ZIP_SUFFIX);
        if (tempZipFile.exists() && tempZipFile.delete()) {
            log.info("临时ZIP文件清理成功:{}", tempZipFile.getAbsolutePath());
        } else {
            log.warn("临时ZIP文件清理失败:{}(可能已被删除、无权限或不是文件)", tempZipFile.getAbsolutePath());
        }
    }
}

分片上传工具(FileChunkUploadHelper.java)

java 复制代码
package org.example.springbootminiodemo.helper;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.google.common.collect.HashMultimap;
import com.google.common.collect.Multimap;
import io.minio.ListPartsResponse;
import io.minio.UploadPartResponse;
import io.minio.errors.InsufficientDataException;
import io.minio.errors.InternalException;
import io.minio.errors.XmlParserException;
import io.minio.messages.Part;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.lang3.StringUtils;
import org.example.springbootminiodemo.config.CustomMinioClient;
import org.example.springbootminiodemo.config.MinioConfig;
import org.example.springbootminiodemo.entity.FileMetadata;
import org.example.springbootminiodemo.exception.InvalidParameterException;
import org.example.springbootminiodemo.exception.UploadTaskNotFoundException;
import org.example.springbootminiodemo.mapper.FileMetadataMapper;
import org.example.springbootminiodemo.util.UploadTaskIdGenerator;
import org.redisson.api.RMap;
import org.redisson.api.RSet;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

/**
 * 分片上传专属辅助工具类(仅分片上传模块使用)
 * 职责:封装分片上传所有核心实现细节(Redis任务操作、MinIO分片逻辑、进度计算、任务清理等)
 * 依赖 FileBasicHelper 复用通用逻辑(元数据构建、MinIO预签名URL等)
 */
@Slf4j
@Component
public class FileChunkUploadHelper {

    // ==================== 分片上传专属常量(原 FileUploadHelper 迁移)====================
    public static final String REDIS_INFO_PREFIX = "minio:chunk:info:task:";
    public static final String REDIS_UPLOADED_CHUNKS_SUFFIX = ":uploaded_chunks";
    public static final String REDIS_MD5_USER_TASK_PREFIX = "minio:chunk:md5_user_task:";
    public static final String UPLOAD_STATUS_UPLOADING = "1";
    public static final String UPLOAD_STATUS_PAUSED = "2";
    public static final int CHECK_STATUS_UPLOADED = 1;
    public static final int CHECK_STATUS_UPLOADING = 2;
    public static final int CHECK_STATUS_NOT_UPLOADED = 3;
    public static final long MINIO_CHUNK_MIN_BOTTOM = 5242880L; // 5MB

    // ==================== 依赖注入(专属依赖 + 通用工具类)====================
    private final CustomMinioClient customMinioClient;
    private final RedissonClient redissonClient;
    private final FileMetadataMapper fileMetadataMapper;
    private final FileBasicHelper fileBasicHelper; // 复用通用逻辑
    private final MinioConfig minioConfig;

    public FileChunkUploadHelper(CustomMinioClient customMinioClient, RedissonClient redissonClient,
                                 FileMetadataMapper fileMetadataMapper, FileBasicHelper fileBasicHelper,
                                 MinioConfig minioConfig) {
        this.customMinioClient = customMinioClient;
        this.redissonClient = redissonClient;
        this.fileMetadataMapper = fileMetadataMapper;
        this.fileBasicHelper = fileBasicHelper;
        this.minioConfig = minioConfig;
    }

    // ==================== 核心工具方法(从 FileChunkUploadServiceImpl 抽离)====================

    /**
     * 按「文件MD5+上传者ID」查询未完成任务(进度恢复核心逻辑)
     */
    public String getLatestUnfinishedTaskId(String fileMd5, Long uploadUserId) {
        String md5UserKey = getRedisMd5UserTaskKey(fileMd5, uploadUserId);
        RSet<String> md5UserTaskSet = redissonClient.getSet(md5UserKey);
        if (md5UserTaskSet.isEmpty()) {
            return null;
        }

        // 取最新创建的任务(按Redis中任务的createTime排序)
        return md5UserTaskSet.stream()
                .max(Comparator.comparing(taskId -> {
                    RMap<String, String> taskMap = redissonClient.getMap(getRedisTaskInfoKey(taskId));
                    return taskMap.getOrDefault("createTime", "");
                }))
                .orElse(null);
    }

    /**
     * 通过uploadTaskId查询任务状态(精准查询)
     */
    public Map<String, Object> checkByUploadTaskId(String uploadTaskId) {
        Map<String, Object> result = new HashMap<>(3);

        // 1. 查数据库(已上传完成)
        FileMetadata fileMetadata = fileMetadataMapper.selectOne(
                new QueryWrapper<FileMetadata>()
                        .eq("upload_task_id", uploadTaskId)
                        .eq("is_delete", 0)
        );
        if (fileMetadata != null) {
            fileMetadata.setMinioUrl(fileBasicHelper.generateMinioPresignedUrl(
                    fileMetadata.getMinioBucket(), fileMetadata.getMinioObjectName()
            ));
            result.put("code", CHECK_STATUS_UPLOADED);
            result.put("data", fileMetadata);
            result.put("message", "任务已上传完成");
            return result;
        }

        // 2. 查Redis(上传中)
        RMap<String, String> taskInfoMap = redissonClient.getMap(getRedisTaskInfoKey(uploadTaskId));
        if (!taskInfoMap.isEmpty()) {
            LocalDateTime expireTime = LocalDateTime.parse(
                    taskInfoMap.get("expireTime"), DateTimeFormatter.ISO_LOCAL_DATE_TIME
            );
            if (expireTime.isBefore(LocalDateTime.now())) {
                cleanExpiredTask(taskInfoMap, uploadTaskId);
                result.put("code", CHECK_STATUS_NOT_UPLOADED);
                result.put("data", null);
                result.put("message", "任务已过期,可重新初始化");
                return result;
            }

            // 构造上传中数据
            List<Integer> uploadedChunks = getUploadedChunks(uploadTaskId);
            Map<String, Object> progressData = new HashMap<>(8);
            progressData.put("uploadTaskId", uploadTaskId);
            progressData.put("fileMd5", taskInfoMap.get("fileMd5"));
            progressData.put("minioUploadId", taskInfoMap.get("minioUploadId"));
            progressData.put("fileName", taskInfoMap.get("fileName"));
            progressData.put("fileSize", Long.parseLong(taskInfoMap.get("fileSize")));
            progressData.put("uploadedChunkNum", uploadedChunks.size());
            progressData.put("uploadedChunkList", uploadedChunks);
            progressData.put("uploadStatus", Integer.parseInt(taskInfoMap.get("uploadStatus")));

            result.put("code", CHECK_STATUS_UPLOADING);
            result.put("data", progressData);
            result.put("message", "任务上传中");
            return result;
        }

        // 3. 任务不存在
        result.put("code", CHECK_STATUS_NOT_UPLOADED);
        result.put("data", null);
        result.put("message", "任务不存在,可重新初始化");
        return result;
    }

    /**
     * 初始化MinIO分片会话+存储任务到Redis(初始化核心逻辑)
     */
    public Map<String, String> initMinioChunkSession(String fileMd5, String fileName, Long fileSize,
                                                     String fileType, Long uploadUserId, int expireHours) throws InsufficientDataException, IOException, NoSuchAlgorithmException, InvalidKeyException, ExecutionException, XmlParserException, InterruptedException, InternalException {
        // 生成任务ID
        String uploadTaskId = UploadTaskIdGenerator.generate();
        // 构建MinIO存储路径(按日期分目录+UUID防重)
        String dateDir = LocalDateTime.now().format(DateTimeFormatter.ofPattern(FileBasicHelper.MINIO_DATE_FORMAT));
        String fileExt = FilenameUtils.getExtension(fileName);
        String fileBaseName = FilenameUtils.getBaseName(fileName);
        String minioObjectName = String.format("%s/%s_%s%s",
                dateDir, fileBaseName, UUID.randomUUID(), StringUtils.isBlank(fileExt) ? "" : "." + fileExt);
        String minioBucket = minioConfig.getBucketName();

        // 初始化MinIO分片会话
        Multimap<String, String> minioHeaders = HashMultimap.create();
        minioHeaders.put("Content-Type", fileType);
        String minioUploadId = customMinioClient.initMultipartUpload(minioBucket, minioObjectName, minioHeaders);

        // 存储任务到Redis
        LocalDateTime expireTime = LocalDateTime.now().plusHours(expireHours);
        RMap<String, String> taskInfoMap = redissonClient.getMap(getRedisTaskInfoKey(uploadTaskId));
        Map<String, String> taskMetadata = new HashMap<>(11);
        taskMetadata.put("uploadTaskId", uploadTaskId);
        taskMetadata.put("fileMd5", fileMd5);
        taskMetadata.put("uploadUserId", uploadUserId == null ? "" : uploadUserId.toString());
        taskMetadata.put("fileName", fileName);
        taskMetadata.put("fileSize", fileSize.toString());
        taskMetadata.put("minioBucket", minioBucket);
        taskMetadata.put("minioObjectName", minioObjectName);
        taskMetadata.put("minioUploadId", minioUploadId);
        taskMetadata.put("uploadStatus", UPLOAD_STATUS_UPLOADING);
        taskMetadata.put("expireTime", expireTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
        taskMetadata.put("createTime", LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));

        taskInfoMap.putAll(taskMetadata);
        taskInfoMap.expire(expireHours, TimeUnit.HOURS);

        // 初始化已上传分片集合
        RSet<Integer> uploadedChunksSet = redissonClient.getSet(getRedisUploadedChunksKey(uploadTaskId));
        uploadedChunksSet.expire(expireHours, TimeUnit.HOURS);

        // 建立MD5-任务关联索引
        if (uploadUserId != null) {
            String md5UserKey = getRedisMd5UserTaskKey(fileMd5, uploadUserId);
            RSet<String> md5UserTaskSet = redissonClient.getSet(md5UserKey);
            md5UserTaskSet.add(uploadTaskId);
            md5UserTaskSet.expire(expireHours, TimeUnit.HOURS);
        }

        // 返回核心结果(任务ID、MinIO上传ID、存储路径)
        Map<String, String> result = new HashMap<>(3);
        result.put("uploadTaskId", uploadTaskId);
        result.put("minioUploadId", minioUploadId);
        result.put("minioObjectName", minioObjectName);
        return result;
    }

    /**
     * 上传单个分片到MinIO(分片上传核心逻辑)
     */
    public String uploadSingleChunkToMinIO(String uploadTaskId, Integer chunkNumber, Integer totalChunks,
                                           MultipartFile chunk, int expireHours) throws Exception {
        // 校验任务存在性
        RMap<String, String> taskInfoMap = redissonClient.getMap(getRedisTaskInfoKey(uploadTaskId));
        if (taskInfoMap.isEmpty()) {
            throw new UploadTaskNotFoundException("上传任务不存在,请重新初始化");
        }

        // 校验任务状态
        String uploadStatus = taskInfoMap.get("uploadStatus");
        if (UPLOAD_STATUS_PAUSED.equals(uploadStatus)) {
            throw new RuntimeException("任务已暂停,请先恢复上传");
        }

        // 校验分片序号
        if (chunkNumber > totalChunks) {
            throw new InvalidParameterException("分片序号超出范围(总分片数:" + totalChunks + ")");
        }

        // 校验分片大小
        long actualChunkSize = chunk.getSize();
        boolean isLastChunk = chunkNumber.equals(totalChunks);
        validateChunkSize(actualChunkSize, isLastChunk);

        // 检查是否已上传
        RSet<Integer> uploadedChunksSet = redissonClient.getSet(getRedisUploadedChunksKey(uploadTaskId));
        if (uploadedChunksSet.contains(chunkNumber)) {
            return "already_uploaded";
        }

        // 从Redis获取MinIO信息
        String minioBucket = taskInfoMap.get("minioBucket");
        String minioObjectName = taskInfoMap.get("minioObjectName");
        String minioUploadId = taskInfoMap.get("minioUploadId");

        // 上传到MinIO
        UploadPartResponse minioResponse = customMinioClient.uploadPart(
                minioBucket, minioObjectName, minioUploadId, chunkNumber, actualChunkSize, chunk.getInputStream()
        );

        // 更新Redis+刷新过期时间
        uploadedChunksSet.add(chunkNumber);
        taskInfoMap.expire(expireHours, TimeUnit.HOURS);
        uploadedChunksSet.expire(expireHours, TimeUnit.HOURS);

        return minioResponse.etag();
    }

    /**
     * 合并分片(合并核心逻辑)
     */
    public FileMetadata mergeAllChunks(String uploadTaskId, Integer totalChunks) throws Exception {
        // 校验任务存在性
        RMap<String, String> taskInfoMap = redissonClient.getMap(getRedisTaskInfoKey(uploadTaskId));
        if (taskInfoMap.isEmpty()) {
            throw new UploadTaskNotFoundException("上传任务不存在,无法合并");
        }

        // 提取任务信息
        String fileMd5 = taskInfoMap.get("fileMd5");
        String minioUploadId = taskInfoMap.get("minioUploadId");
        String minioBucket = taskInfoMap.get("minioBucket");
        String minioObjectName = taskInfoMap.get("minioObjectName");
        String fileName = taskInfoMap.get("fileName");
        long fileSize = Long.parseLong(taskInfoMap.get("fileSize"));
        Long uploadUserId = StringUtils.isBlank(taskInfoMap.get("uploadUserId")) ? null : Long.parseLong(taskInfoMap.get("uploadUserId"));

        // 校验分片完整性(Redis)
        RSet<Integer> uploadedChunksSet = redissonClient.getSet(getRedisUploadedChunksKey(uploadTaskId));
        if (uploadedChunksSet.size() != totalChunks) {
            throw new RuntimeException("分片未上传完整(已上传:" + uploadedChunksSet.size() + ",总分片数:" + totalChunks + ")");
        }

        // 校验MinIO分片数
        ListPartsResponse minioPartsResponse = customMinioClient.listUploadedParts(minioBucket, minioObjectName, minioUploadId);
        List<io.minio.messages.Part> minioParts = minioPartsResponse.result().partList();
        if (minioParts.size() != totalChunks) {
            throw new RuntimeException("MinIO分片数与总分片数不一致");
        }

        // 排序分片并合并
        Part[] mergedParts = minioParts.stream()
                .sorted(Comparator.comparingInt(io.minio.messages.Part::partNumber))
                .map(part -> new Part(part.partNumber(), part.etag()))
                .toArray(Part[]::new);
        customMinioClient.completeMultipartUpload(minioBucket, minioObjectName, minioUploadId, mergedParts);

        // 构建元数据
        FileMetadata fileMetadata = fileBasicHelper.buildFileMetadata(
                uploadTaskId, fileMd5, fileName, fileSize,
                minioBucket, minioObjectName, minioUploadId, totalChunks, uploadUserId
        );

        // 清理Redis数据
        clearRedisTaskData(uploadTaskId);
        if (StringUtils.isNotBlank(fileMd5) && uploadUserId != null) {
            String md5UserKey = getRedisMd5UserTaskKey(fileMd5, uploadUserId);
            RSet<String> md5UserTaskSet = redissonClient.getSet(md5UserKey);
            md5UserTaskSet.remove(uploadTaskId);
        }

        // 生成预签名URL
        fileMetadata.setMinioUrl(fileBasicHelper.generateMinioPresignedUrl(minioBucket, minioObjectName));
        return fileMetadata;
    }

    /**
     * 暂停/恢复任务(状态更新逻辑)
     */
    public void updateTaskStatus(String uploadTaskId, String targetStatus, int expireHours) {
        RMap<String, String> taskInfoMap = redissonClient.getMap(getRedisTaskInfoKey(uploadTaskId));
        if (taskInfoMap.isEmpty()) {
            throw new UploadTaskNotFoundException("上传任务不存在,无法操作");
        }

        taskInfoMap.put("uploadStatus", targetStatus);
        taskInfoMap.expire(expireHours, TimeUnit.HOURS);
        redissonClient.getSet(getRedisUploadedChunksKey(uploadTaskId)).expire(expireHours, TimeUnit.HOURS);
    }

    // ==================== 原有工具方法(保留+优化)====================
    public void validateChunkSize(long chunkSize, boolean isLastChunk) {
        if (!isLastChunk && chunkSize < MINIO_CHUNK_MIN_BOTTOM) {
            throw new InvalidParameterException(
                    String.format("分片大小不合法:非最后一片必须≥%dKB(MinIO最小限制),当前大小=%dKB",
                            MINIO_CHUNK_MIN_BOTTOM / 1024, chunkSize / 1024)
            );
        }
    }

    public void cleanExpiredTask(RMap<String, String> taskInfoMap, String uploadTaskId) {
        try {
            String minioBucket = taskInfoMap.get("minioBucket");
            String minioObjectName = taskInfoMap.get("minioObjectName");
            String minioUploadId = taskInfoMap.get("minioUploadId");
            customMinioClient.abortMultipartUpload(minioBucket, minioObjectName, minioUploadId);
        } catch (Exception e) {
            log.error("清理MinIO分片失败,uploadTaskId={}", uploadTaskId, e);
        }

        // 删除MD5-任务关联
        String fileMd5 = taskInfoMap.get("fileMd5");
        String uploadUserIdStr = taskInfoMap.get("uploadUserId");
        if (StringUtils.isNotBlank(fileMd5) && StringUtils.isNotBlank(uploadUserIdStr)) {
            try {
                Long uploadUserId = Long.parseLong(uploadUserIdStr);
                String md5UserKey = getRedisMd5UserTaskKey(fileMd5, uploadUserId);
                RSet<String> md5UserTaskSet = redissonClient.getSet(md5UserKey);
                md5UserTaskSet.remove(uploadTaskId);
            } catch (NumberFormatException e) {
                log.error("uploadUserId转换失败,uploadUserIdStr={}", uploadUserIdStr, e);
            }
        }

        clearRedisTaskData(uploadTaskId);
    }

    public List<Integer> getUploadedChunks(String uploadTaskId) {
        if (StringUtils.isBlank(uploadTaskId)) {
            log.warn("任务ID为空,返回空分片列表");
            return Collections.emptyList();
        }

        RSet<Integer> uploadedChunksSet = redissonClient.getSet(getRedisUploadedChunksKey(uploadTaskId));
        return uploadedChunksSet.stream().sorted().collect(Collectors.toList());
    }

    // ==================== Redis键生成方法(保留)====================
    public String getRedisTaskInfoKey(String uploadTaskId) {
        return REDIS_INFO_PREFIX + uploadTaskId;
    }

    public String getRedisUploadedChunksKey(String uploadTaskId) {
        return getRedisTaskInfoKey(uploadTaskId) + REDIS_UPLOADED_CHUNKS_SUFFIX;
    }

    public String getRedisMd5UserTaskKey(String fileMd5, Long uploadUserId) {
        if (StringUtils.isBlank(fileMd5) || uploadUserId == null) {
            throw new InvalidParameterException("fileMd5和uploadUserId不能为空");
        }
        return REDIS_MD5_USER_TASK_PREFIX + fileMd5 + "_" + uploadUserId;
    }

    public void clearRedisTaskData(String uploadTaskId) {
        redissonClient.getMap(getRedisTaskInfoKey(uploadTaskId)).delete();
        redissonClient.getSet(getRedisUploadedChunksKey(uploadTaskId)).delete();
    }
}

7. Service层:业务逻辑实现

基础文件服务

FileBasicService.java

java 复制代码
package org.example.springbootminiodemo.service;

import com.baomidou.mybatisplus.core.metadata.IPage;
import org.example.springbootminiodemo.dto.BaseFileDTO;
import org.example.springbootminiodemo.dto.FileBatchOperateDTO;
import org.example.springbootminiodemo.dto.FilePageQueryDTO;
import org.example.springbootminiodemo.entity.FileMetadata;
import org.springframework.web.multipart.MultipartFile;

import javax.servlet.http.HttpServletResponse;
import java.util.List;
import java.util.Map;

/**
 * 基础文件操作接口(适用于小文件,无需分片)
 * 核心功能:分页查询、批量上传、批量下载、批量删除
 * 特点:操作简单,无断点续传能力,建议单文件大小不超过100MB
 */
public interface FileBasicService {

    /**
     * 分页查询文件列表(支持多条件模糊筛选)
     * @param dto 入参DTO,核心用到以下字段(均为可选):
     *            - pageNum:页码(默认1)
     *            - pageSize:每页条数(默认10,最大100)
     *            - uploadUserId:上传者ID(精确匹配,筛选指定用户上传的文件)
     *            - fileName:文件名(模糊匹配,忽略大小写)
     *            - fileType:文件类型(精确匹配,如.pdf/.jpg,支持文件大类筛选)
     * @return 分页查询结果(IPage<FileMetadata>),包含:
     *         - total:总文件数
     *         - pages:总页数
     *         - current:当前页码
     *         - size:每页条数
     *         - records:当前页文件元数据列表(FileMetadata实体集合)
     * @throws Exception 抛出场景:页码/页大小非法、数据库查询失败、参数格式错误
     */
    IPage<FileMetadata> pageQueryFileList(FilePageQueryDTO dto);

    /**
     * 简单批量上传(适用于小文件,无分片逻辑,直接完整上传)
     * @param dto 入参DTO,核心用到以下字段(必填):
     *            - uploadUserId:上传者ID(绑定文件归属)
     *            - fileName:文件名(可选,未传则使用文件原始名称)
     *            - fileType:文件类型(可选,未传则自动识别文件后缀)
     * @param files 待上传的文件数组(MultipartFile类型,不可为空/空数组)
     * @return 上传成功后的文件元数据列表(FileMetadata实体集合),每个实体包含:
     *         - id:文件唯一ID
     *         - fileName:最终存储的文件名
     *         - fileSize:文件大小(字节)
     *         - fileType:文件类型
     *         - storagePath:MinIO存储路径
     *         - uploadTime:上传时间
     *         - uploadUserId:上传者ID
     * @throws Exception 抛出场景:文件数组为空、上传者ID非法、文件大小超限、MinIO上传失败、数据库插入失败
     */
    List<FileMetadata> batchSimpleUpload(BaseFileDTO dto, MultipartFile[] files);

    /**
     * 批量下载文件(自动打包为ZIP压缩包,通过响应流返回)
     * @param dto 入参DTO,核心用到以下字段(必填):
     *            - fileIdList:文件ID列表(至少包含1个有效文件ID)
     * @param response HTTP响应对象(用于输出ZIP压缩包流,设置下载响应头)
     * @throws Exception 抛出场景:文件ID列表为空、部分文件不存在、ZIP打包失败、响应流写入异常、MinIO下载失败
     */
    void batchDownloadFiles(FileBatchOperateDTO dto, HttpServletResponse response);

    /**
     * 批量删除文件(物理删除MinIO存储文件 + 逻辑删除数据库元数据记录)
     * @param dto 入参DTO,核心用到以下字段(必填):
     *            - fileIdList:文件ID列表(至少包含1个有效文件ID)
     * @return 删除结果Map,包含以下字段:
     *         - successCount:删除成功的文件数量
     *         - failCount:删除失败的文件数量
     *         - failIds:删除失败的文件ID列表(含失败原因)
     * @throws Exception 抛出场景:文件ID列表为空、数据库更新失败、MinIO删除失败、参数格式错误
     */
    Map<String, Object> batchDeleteFiles(FileBatchOperateDTO dto);
}

FileBasicServiceImpl.java

java 复制代码
package org.example.springbootminiodemo.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import io.minio.GetObjectArgs;
import io.minio.MinioClient;
import io.minio.MinioAsyncClient;
import io.minio.PutObjectArgs;
import io.minio.RemoveObjectArgs;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.compress.archivers.zip.Zip64Mode;
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.example.springbootminiodemo.config.CustomMinioClient;
import org.example.springbootminiodemo.config.MinioConfig;
import org.example.springbootminiodemo.dto.BaseFileDTO;
import org.example.springbootminiodemo.dto.FileBatchOperateDTO;
import org.example.springbootminiodemo.dto.FilePageQueryDTO;
import org.example.springbootminiodemo.entity.FileMetadata;
import org.example.springbootminiodemo.exception.InvalidParameterException;
import org.example.springbootminiodemo.helper.FileBasicHelper;
import org.example.springbootminiodemo.mapper.FileMetadataMapper;
import org.example.springbootminiodemo.service.FileBasicService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

import javax.servlet.http.HttpServletResponse;
import java.io.InputStream;
import java.net.URLEncoder;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;

/**
 * 文件基础功能实现类(通用文件操作)
 * 实现 FileBasicService 接口,处理小文件的分页查询、批量上传、批量下载、批量删除
 * 依赖 MinIO 客户端进行文件存储,数据库记录文件元数据,支持无限制重复上传(取消MD5+用户ID去重)
 */
@Slf4j
@Service
public class FileBasicServiceImpl extends ServiceImpl<FileMetadataMapper, FileMetadata> implements FileBasicService {

    private final FileMetadataMapper fileMetadataMapper;
    private final CustomMinioClient customMinioClient;
    private final MinioClient minioClient;
    private final MinioAsyncClient minioAsyncClient;
    private final MinioConfig minioConfig;
    private final FileBasicHelper fileBasicHelper;

    private static final int MAX_DOWNLOAD_COUNT = 10; // 单次最大下载文件数

    /**
     * 构造器注入依赖(避免字段注入,提升可测试性)
     * @param fileMetadataMapper  文件元数据Mapper(数据库操作)
     * @param customMinioClient   自定义MinIO客户端(扩展功能)
     * @param minioClient         MinIO同步客户端(核心存储操作)
     * @param minioAsyncClient    MinIO异步客户端(备用)
     * @param minioConfig         MinIO配置类(桶名、地址等)
     * @param fileBasicHelper    文件上传工具类(元数据构建、预签名URL生成)
     */
    public FileBasicServiceImpl(
            FileMetadataMapper fileMetadataMapper, CustomMinioClient customMinioClient,
            MinioClient minioClient, MinioAsyncClient minioAsyncClient,
            MinioConfig minioConfig, FileBasicHelper fileBasicHelper) {
        this.fileMetadataMapper = fileMetadataMapper;
        this.customMinioClient = customMinioClient;
        this.minioClient = minioClient;
        this.minioAsyncClient = minioAsyncClient;
        this.minioConfig = minioConfig;
        this.fileBasicHelper = fileBasicHelper;
    }

    /**
     * 分页查询文件列表(支持多条件筛选,返回带预签名URL的文件元数据)
     * 筛选逻辑:未删除文件 → 按创建时间降序 → 支持上传者ID精确匹配、文件名模糊匹配、文件类型精确匹配
     * @param dto 入参DTO,核心字段:
     *            - pageNum:页码(默认1,小于1则自动修正为1)
     *            - pageSize:每页条数(默认10,小于1则自动修正为10)
     *            - uploadUserId:上传者ID(可选,精确匹配)
     *            - fileName:文件名(可选,模糊匹配,忽略大小写)
     *            - fileType:文件类型(可选,精确匹配,如.pdf/.jpg)
     * @return 分页结果(IPage<FileMetadata>),每条记录包含生成的MinIO预签名访问URL
     * @throws Exception 抛出场景:数据库查询异常、MinIO预签名URL生成失败
     */
    @Override
    public IPage<FileMetadata> pageQueryFileList(FilePageQueryDTO dto) {
        // 1. 参数默认值处理
        Integer pageNum = dto.getPageNum() == null || dto.getPageNum() < 1 ? 1 : dto.getPageNum();
        Integer pageSize = dto.getPageSize() == null || dto.getPageSize() < 1 ? 10 : dto.getPageSize();
        Long uploadUserId = dto.getUploadUserId();
        String fileName = dto.getFileName();
        String fileType = dto.getFileType();

        // 2. 构建分页对象和查询条件
        IPage<FileMetadata> page = new Page<>(pageNum, pageSize);
        QueryWrapper<FileMetadata> queryWrapper = new QueryWrapper<FileMetadata>()
                .eq("is_delete", 0) // 只查询未删除文件
                .orderByDesc("create_time"); // 按创建时间倒序

        // 动态添加筛选条件
        if (uploadUserId != null) {
            queryWrapper.eq("upload_user_id", uploadUserId);
        }
        if (StringUtils.isNotBlank(fileName)) {
            queryWrapper.like("file_name", "%" + fileName + "%");
        }
        if (StringUtils.isNotBlank(fileType)) {
            queryWrapper.eq("file_type", fileType);
        }

        // 3. 执行分页查询并生成预签名URL
        IPage<FileMetadata> filePage = fileMetadataMapper.selectPage(page, queryWrapper);
        filePage.getRecords().forEach(metadata -> {
            // 为每条文件记录生成MinIO预签名访问URL(有效期由fileBasicHelper配置)
            metadata.setMinioUrl(fileBasicHelper.generateMinioPresignedUrl(metadata.getMinioBucket(), metadata.getMinioObjectName()));
        });

        log.info("分页查询文件列表成功:页码={},每页条数={},总条数={},总页数={}",
                pageNum, pageSize, filePage.getTotal(), filePage.getPages());
        return filePage;
    }

    /**
     * 简单批量上传(适用于小文件,无分片逻辑,直接完整上传至MinIO)
     * 核心逻辑:取消文件去重 → MinIO上传 → 元数据保存 → 返回带预签名URL的结果
     * 支持无限制重复上传,重复文件生成独立存储路径和元数据记录
     * @param dto    入参DTO,核心字段:
     *               - uploadUserId:上传者ID(必填,绑定文件归属)
     *               - fileName:文件名(可选,未传则使用文件原始名称)
     *               - fileType:文件类型(可选,未传则自动识别文件后缀)
     * @param files  待上传的文件数组(MultipartFile类型,不可为空/空数组)
     * @return 上传成功的文件元数据列表(每个元素包含文件ID、存储路径、预签名URL等信息)
     * @throws InvalidParameterException 抛出场景:文件数组为空、上传者ID为空、文件名空
     * @throws RuntimeException          抛出场景:MinIO上传失败、数据库插入失败
     */
    @Override
    @Transactional(rollbackFor = Exception.class) // 事务管理:上传/入库失败则回滚
    public List<FileMetadata> batchSimpleUpload(BaseFileDTO dto, MultipartFile[] files) {
        // 1. 入参校验
        if (files == null || files.length == 0) {
            throw new InvalidParameterException("上传文件不能为空");
        }
        Long uploadUserId = dto.getUploadUserId();
        if (uploadUserId == null) {
            throw new InvalidParameterException("上传用户ID不能为空");
        }

        List<FileMetadata> resultList = new ArrayList<>(files.length);
        // 2. 遍历文件数组,逐个处理上传(取消去重,直接上传)
        for (MultipartFile file : files) {
            String originalFileName = file.getOriginalFilename();
            log.info("开始简单上传文件(支持重复上传):原始文件名={},文件大小={}KB", originalFileName, file.getSize() / 1024);

            // 跳过空文件
            if (file.isEmpty()) {
                log.warn("跳过空文件:{}", originalFileName);
                continue;
            }
            // 校验原始文件名非空
            if (StringUtils.isBlank(originalFileName)) {
                throw new InvalidParameterException("文件名称不能为空");
            }

            // 3. 构建MinIO存储路径(按日期分目录 + UUID防重名,确保重复上传文件独立存储)
            String dateDir = LocalDateTime.now().format(DateTimeFormatter.ofPattern(fileBasicHelper.MINIO_DATE_FORMAT));
            String fileExt = FilenameUtils.getExtension(originalFileName); // 获取文件后缀
            String fileBaseName = FilenameUtils.getBaseName(originalFileName); // 获取文件名(不含后缀)
            // MinIO存储对象名:日期目录/原文件名_UUID.后缀(UUID确保重复文件路径唯一)
            String minioObjectName = String.format("%s/%s_%s.%s",
                    dateDir, fileBaseName, UUID.randomUUID(), fileExt);
            String minioBucket = minioConfig.getBucketName(); // 从配置获取MinIO桶名

            try {
                // 4. 上传文件至MinIO
                minioClient.putObject(
                        PutObjectArgs.builder()
                                .bucket(minioBucket)
                                .object(minioObjectName)
                                .stream(file.getInputStream(), file.getSize(), -1) // -1表示自动识别文件大小
                                .contentType(file.getContentType()) // 设置文件MIME类型
                                .build()
                );
                log.info("MinIO简单上传成功:存储路径={}", minioObjectName);

                // 5. 构建文件元数据并保存至数据库(fileMd5用UUID填充,避免空值)
                String uploadTaskId = "SIMPLE_" + UUID.randomUUID().toString().replace("-", "");
                String fileMd5 = UUID.randomUUID().toString(); // 取消MD5去重,用UUID填充字段
                FileMetadata fileMetadata = fileBasicHelper.buildFileMetadata(
                        uploadTaskId, // 上传任务ID(简单上传专用前缀)
                        fileMd5, // 填充UUID,兼容实体字段
                        originalFileName, // 原始文件名
                        file.getSize(), // 文件大小(字节)
                        minioBucket, // MinIO桶名
                        minioObjectName, // MinIO存储对象名
                        "SIMPLE_UPLOAD", // 上传类型标识
                        1, // 总分片数(简单上传固定为1)
                        uploadUserId // 上传者ID
                );
                // 生成MinIO预签名访问URL
                fileMetadata.setMinioUrl(fileBasicHelper.generateMinioPresignedUrl(minioBucket, minioObjectName));

                // 保存元数据到数据库
                fileMetadataMapper.insert(fileMetadata);
                log.info("简单上传文件元数据保存成功:文件ID={},业务ID={},存储路径={}",
                        fileMetadata.getId(), fileMetadata.getUploadTaskId(), minioObjectName);
                resultList.add(fileMetadata);
            } catch (Exception e) {
                log.error("简单上传文件失败:原始文件名={}", originalFileName, e);
                throw new RuntimeException("文件上传失败:" + originalFileName + " - " + e.getMessage(), e);
            }
        }
        return resultList;
    }

    /**
     * 批量下载文件(将多个文件打包为ZIP压缩包,通过HTTP响应流返回给前端)
     * 限制:单次下载文件数不超过10个,避免ZIP包过大
     * @param dto       入参DTO,核心字段:
     *                  - fileIdList:文件ID列表(必填,至少包含1个有效ID)
     * @param response  HTTP响应对象(用于设置下载头、输出ZIP流)
     * @throws InvalidParameterException 抛出场景:文件ID列表为空、下载数量超限
     * @throws RuntimeException          抛出场景:无有效文件、ZIP打包失败、MinIO文件获取失败、响应流写入失败
     */
    @Override
    public void batchDownloadFiles(FileBatchOperateDTO dto, HttpServletResponse response) {
        // 1. 入参校验(原逻辑不变)
        List<Long> fileIdList = dto.getFileIdList();
        if (fileIdList == null || fileIdList.isEmpty()) {
            throw new InvalidParameterException("下载文件ID列表不能为空");
        }
        if (fileIdList.size() > MAX_DOWNLOAD_COUNT) {
            throw new InvalidParameterException("单次下载文件数量不能超过" + MAX_DOWNLOAD_COUNT + "个,请分批下载");
        }

        // 2. 查询有效文件元数据(未删除的文件,原逻辑不变)
        List<FileMetadata> fileList = fileMetadataMapper.selectList(
                new QueryWrapper<FileMetadata>()
                        .in("id", fileIdList)
                        .eq("is_delete", 0)
        );
        if (fileList.isEmpty()) {
            throw new RuntimeException("无有效下载文件(文件不存在或已删除)");
        }

        // 3. 核心分支:单个文件直接返回原文件,多个文件打包ZIP
        if (fileList.size() == 1) {
            // 3.1 单个文件:直接返回原文件流(不打包ZIP)
            FileMetadata singleFile = fileList.get(0);
            String minioBucket = singleFile.getMinioBucket();
            String minioObjectName = singleFile.getMinioObjectName();
            String fileName = singleFile.getFileName();
            String fileType = singleFile.getFileType(); // 文件MIME类型(如application/pdf、image/jpeg)
            long fileSize = singleFile.getFileSize(); // 文件总大小(字节)

            // 校验MinIO存储参数(原逻辑复用)
            if (StringUtils.isBlank(minioBucket) || StringUtils.isBlank(minioObjectName)) {
                log.error("文件元数据异常:文件ID={},MinIO桶名={},存储对象名={}",
                        singleFile.getId(), minioBucket, minioObjectName);
                throw new RuntimeException("文件【" + fileName + "】元数据不完整,无法下载");
            }

            try {
                // 设置响应头(适配原文件格式,禁用缓存)
                response.setCharacterEncoding("UTF-8");
                response.setHeader("Pragma", "no-cache");
                response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
                response.setDateHeader("Expires", 0);

                // 关键:设置文件本身的Content-Type(而非ZIP),支持浏览器预览
                if (StringUtils.isNotBlank(fileType)) {
                    response.setContentType(fileType);
                } else {
                    // 兜底:如果没有文件类型,设为二进制流
                    response.setContentType("application/octet-stream");
                }

                // 中文文件名编码(兼容多浏览器,修复空格问题)
                String encodedFileName = URLEncoder.encode(fileName, "UTF-8");
                // 替换 URLEncoder 编码的空格(+)为标准 URL 空格编码(%20)
                encodedFileName = encodedFileName.replace("+", "%20");
                // 双 filename 参数适配新老浏览器
                response.setHeader("Content-Disposition",
                        "attachment;filename=" + encodedFileName + ";filename*=UTF-8''" + encodedFileName);

                // 设置文件总大小(让浏览器显示下载进度)
                response.setContentLengthLong(fileSize);

                // 从MinIO获取原文件流,直接写入响应(不打包ZIP)
                try (InputStream minioInputStream = minioClient.getObject(
                        GetObjectArgs.builder()
                                .bucket(minioBucket)
                                .object(minioObjectName)
                                .build())) {
                    IOUtils.copy(minioInputStream, response.getOutputStream());
                    log.info("单个文件直接下载成功:文件ID={},文件名={},大小={}KB,文件类型={}",
                            singleFile.getId(), fileName, fileSize / 1024, fileType);
                }

            } catch (Exception e) {
                log.error("单个文件下载失败:文件ID={},文件名={}", singleFile.getId(), fileName, e);
                // 重置响应,返回友好错误提示
                response.reset();
                response.setContentType("text/plain;charset=UTF-8");
                try {
                    response.getWriter().write("文件【" + fileName + "】下载失败:" + e.getMessage());
                } catch (Exception ex) {
                    log.error("单个文件下载失败后,响应错误信息写入失败", ex);
                }
                throw new RuntimeException("文件【" + fileName + "】下载失败:" + e.getMessage(), e);
            }

        } else {
            // 3.2 多个文件:沿用原逻辑,打包ZIP返回(无任何修改)
            response.setContentType("application/zip");
            response.setCharacterEncoding("UTF-8");
            response.setHeader("Pragma", "no-cache");
            response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
            response.setDateHeader("Expires", 0);

            try {
                // 编码ZIP文件名(支持中文)
                String zipFileName = "批量下载_" + System.currentTimeMillis() + ".zip";
                String encodedFileName = URLEncoder.encode(zipFileName, "UTF-8");
                // 替换 URLEncoder 编码的空格(+)为标准 URL 空格编码(%20)
                encodedFileName = encodedFileName.replace("+", "%20");
                // 设置下载响应头(filename*=UTF-8'' 兼容多浏览器中文显示)
                response.setHeader("Content-Disposition",
                        "attachment;filename=" + encodedFileName + ";filename*=UTF-8''" + encodedFileName);
            } catch (Exception e) {
                log.error("编码ZIP文件名失败", e);
                throw new RuntimeException("下载文件名编码失败", e);
            }

            // ZIP打包核心逻辑(使用try-with-resources自动关闭流)
            try (ZipArchiveOutputStream zipOut = new ZipArchiveOutputStream(response.getOutputStream())) {
                zipOut.setUseZip64(Zip64Mode.Always); // 强制启用ZIP64,支持单个文件超过4GB
                zipOut.setLevel(5); // 压缩级别(1=最快,9=最优压缩,5=平衡)

                // ========== 新增:初始化已使用的ZIP条目名集合(关键) ==========
                Set<String> usedEntryNames = new HashSet<>();

                // 遍历文件列表,逐个添加到ZIP包
                for (FileMetadata file : fileList) {
                    String minioBucket = file.getMinioBucket();
                    String minioObjectName = file.getMinioObjectName();
                    String fileName = file.getFileName();
                    Long fileId = file.getId();

                    // 校验MinIO存储参数(桶名/对象名不能为空)
                    if (StringUtils.isBlank(minioBucket) || StringUtils.isBlank(minioObjectName)) {
                        log.error("文件元数据异常:文件ID={},MinIO桶名={},存储对象名={}",
                                fileId, minioBucket, minioObjectName);
                        throw new RuntimeException("文件【" + fileName + "】元数据不完整,无法下载");
                    }

                    // 构建ZIP条目(用文件ID前缀避免文件名重复)
                    // 调用新增的工具方法,生成"保留原名+重复加序号"的条目名
                    String zipEntryName = fileBasicHelper.generateZipEntryName(fileName, usedEntryNames);
                    // 日志:记录用户看到的文件名→文件ID映射(方便追溯)
                    log.info("ZIP条目映射:用户可见文件名={} → 系统文件ID={}", zipEntryName, fileId);
                    ZipArchiveEntry zipEntry = new ZipArchiveEntry(zipEntryName);
                    zipOut.putArchiveEntry(zipEntry); // 写入ZIP条目头

                    // 从MinIO获取文件流并写入ZIP
                    try (InputStream minioInputStream = minioClient.getObject(
                            GetObjectArgs.builder()
                                    .bucket(minioBucket)
                                    .object(minioObjectName)
                                    .build())) {
                        IOUtils.copy(minioInputStream, zipOut); // 复制流(依赖commons-io)
                        log.info("文件添加到ZIP成功:文件ID={},文件名={},ZIP条目名={}",
                                file.getId(), fileName, zipEntryName);
                    } catch (Exception e) {
                        log.error("获取MinIO文件失败:文件ID={},文件名={}", file.getId(), fileName, e);
                        throw new RuntimeException("文件【" + fileName + "】下载失败:" + e.getMessage(), e);
                    }

                    zipOut.closeArchiveEntry(); // 关闭当前ZIP条目(必须调用,否则后续条目写入失败)
                }

                zipOut.finish(); // 完成ZIP打包(触发剩余数据写入)
                log.info("批量下载ZIP打包成功:共{}个文件,请求文件ID列表={},有效文件数={}",
                        fileList.size(), fileIdList, fileList.size());

            } catch (Exception e) {
                log.error("批量下载文件失败,请求文件ID列表={}", fileIdList, e);
                // 重置响应,返回友好错误提示
                response.reset();
                response.setContentType("text/plain;charset=UTF-8");
                try {
                    response.getWriter().write("批量下载失败:" + e.getMessage());
                } catch (Exception ex) {
                    log.error("响应错误信息写入失败", ex);
                }
            }
        }
    }

    /**
     * 批量删除文件(物理删除MinIO存储文件 + 逻辑删除数据库元数据)
     * 逻辑:遍历文件ID → 校验文件状态 → MinIO删除文件 → 数据库更新删除标记
     * @param dto 入参DTO,核心字段:
     *            - fileIdList:文件ID列表(必填,至少包含1个ID)
     * @return 删除结果Map,结构如下:
     *         - total:请求删除的文件总数
     *         - successIds:删除成功的文件ID列表
     *         - failIds:删除失败的文件信息列表(每个元素含fileId和reason)
     * @throws InvalidParameterException 抛出场景:文件ID列表为空
     */
    @Override
    @Transactional(rollbackFor = Exception.class) // 事务管理:数据库更新失败则回滚
    public Map<String, Object> batchDeleteFiles(FileBatchOperateDTO dto) {
        // 1. 入参校验
        List<Long> fileIdList = dto.getFileIdList();
        if (fileIdList == null || fileIdList.isEmpty()) {
            throw new InvalidParameterException("删除文件ID列表不能为空");
        }

        List<Long> successIds = new ArrayList<>(); // 删除成功的ID
        List<Map<String, Object>> failIds = new ArrayList<>(); // 删除失败的ID及原因

        // 2. 遍历文件ID,逐个处理删除
        for (Long fileId : fileIdList) {
            try {
                // 查询文件元数据
                FileMetadata file = fileMetadataMapper.selectById(fileId);
                if (file == null || file.getIsDelete() == 1) {
                    // 文件不存在或已删除
                    Map<String, Object> failInfo = new HashMap<>(2);
                    failInfo.put("fileId", fileId);
                    failInfo.put("reason", "文件不存在或已删除");
                    failIds.add(failInfo);
                    log.warn("删除文件失败:文件ID={},原因=文件不存在或已删除", fileId);
                    continue;
                }

                // 3. 物理删除MinIO中的文件
                String minioBucket = file.getMinioBucket();
                String minioObjectName = file.getMinioObjectName();
                minioClient.removeObject(
                        RemoveObjectArgs.builder()
                                .bucket(minioBucket)
                                .object(minioObjectName)
                                .build()
                );
                log.info("MinIO文件物理删除成功:桶名={},存储路径={}", minioBucket, minioObjectName);

                // 4. 逻辑删除数据库元数据(更新is_delete=1和更新时间)
                FileMetadata updateEntity = new FileMetadata();
                updateEntity.setId(fileId);
                updateEntity.setIsDelete(1); // 1=已删除
                updateEntity.setUpdateTime(LocalDateTime.now());
                fileMetadataMapper.updateById(updateEntity);
                log.info("数据库文件元数据逻辑删除成功:文件ID={}", fileId);

                successIds.add(fileId);
            } catch (Exception e) {
                // 捕获异常,记录失败信息(不中断批量处理)
                log.error("删除文件失败:文件ID={}", fileId, e);
                Map<String, Object> failInfo = new HashMap<>(2);
                failInfo.put("fileId", fileId);
                failInfo.put("reason", e.getMessage());
                failIds.add(failInfo);
            }
        }

        // 3. 构建返回结果
        Map<String, Object> result = new HashMap<>(3);
        result.put("total", fileIdList.size());
        result.put("successIds", successIds);
        result.put("failIds", failIds);
        log.info("批量删除文件完成:请求总数={},成功数={},失败数={}",
                fileIdList.size(), successIds.size(), failIds.size());
        return result;
    }
}

分片上传服务

FileChunkUploadService.java

java 复制代码
package org.example.springbootminiodemo.service;

import org.example.springbootminiodemo.dto.FileChunkInitCheckDTO;
import org.example.springbootminiodemo.dto.FileChunkOperateDTO;
import org.example.springbootminiodemo.entity.FileMetadata;
import org.springframework.web.multipart.MultipartFile;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
import java.util.Map;

/**
 * 分片上传+断点续传服务接口(基于 upload_task_id 标识任务)
 * 核心流程:状态检查 → 初始化分片 → 上传分片 → 合并分片
 * 支持断点续传(查询已上传分片)、暂停/恢复上传
 */
public interface FileChunkUploadService {

    /**
     * 检查文件上传状态(支持两种查询维度,二选一即可)
     * @param dto 入参DTO,核心用到以下字段:
     *            - fileMd5:文件唯一MD5(可选,与uploadTaskId二选一)
     *            - uploadTaskId:上传任务ID(可选,与fileMd5二选一)
     *            - uploadUserId:上传者ID(可选,用于区分同文件不同上传人)
     * @return 状态结果Map,包含以下结构:
     *         - code:状态码(1=已上传完成,2=上传中,3=未上传)
     *         - data:对应状态数据(code=1返回FileMetadata;code=2返回已上传分片列表+进度;code=3返回null)
     *         - message:状态描述
     * @throws Exception 抛出场景:参数非法、数据库查询失败、MinIO连接异常
     */
    Map<String, Object> checkFileStatus(FileChunkInitCheckDTO dto);

    /**
     * 初始化分片上传任务(生成唯一任务ID,创建上传进度记录)
     * @param dto 入参DTO,核心用到以下字段(均为必填):
     *            - fileMd5:文件唯一MD5(用于文件去重)
     *            - fileName:文件原始名称(含后缀)
     *            - fileSize:文件总大小(字节)
     *            - fileType:文件类型(后缀名,如.pdf/.mp4)
     *            - uploadUserId:上传者ID(可选,未传则默认为0)
     * @return 初始化结果Map,包含以下字段:
     *         - uploadTaskId:分片上传任务ID(后端生成,后续操作核心标识)
     *         - totalChunks:建议的总分片数(根据文件大小和分片大小计算)
     *         - chunkSize:建议的单分片大小(默认1MB,可配置)
     * @throws Exception 抛出场景:参数为空/非法、文件已存在、数据库插入失败、MinIO桶不存在
     */
    Map<String, Object> initChunkUpload(FileChunkInitCheckDTO dto);

    /**
     * 上传单个分片文件(核心方法,基于任务ID定位上传进度)
     * @param dto 入参DTO,核心用到以下字段(均为必填):
     *            - uploadTaskId:上传任务ID(初始化时返回)
     *            - chunkNumber:分片序号(从1开始,与前端保持一致)
     *            - totalChunks:总分片数(校验分片序号合法性)
     * @param chunk 单个分片文件(MultipartFile类型,不可为空)
     * @return 分片上传结果Map,包含以下字段:
     *         - chunkNumber:当前上传的分片序号
     *         - etag:MinIO返回的分片ETag(合并时需用到)
     *         - progress:当前上传进度(百分比,保留两位小数)
     * @throws Exception 抛出场景:任务ID不存在、分片序号非法、文件为空、MinIO上传失败、数据库更新进度失败
     */
    Map<String, Object> uploadChunk(FileChunkOperateDTO dto, MultipartFile chunk) throws Exception;


    /**
     * 查询已上传的分片序号列表(用于断点续传,避免重复上传)
     * @param dto 入参DTO,核心用到以下字段(必填):
     *            - uploadTaskId:上传任务ID(定位对应任务)
     * @return 已上传的分片序号列表(升序排列,如[1,3,5])
     * @throws Exception 抛出场景:任务ID不存在、数据库查询失败
     */
    List<Integer> getUploadedChunks(FileChunkOperateDTO dto);

    /**
     * 合并所有分片(生成完整文件,更新文件元数据)
     * @param dto 入参DTO,核心用到以下字段(均为必填):
     *            - uploadTaskId:上传任务ID(定位分片集合)
     *            - totalChunks:总分片数(校验是否所有分片均已上传)
     * @return 合并后的文件元数据(FileMetadata实体,包含文件ID、存储路径、大小等信息)
     * @throws Exception 抛出场景:任务ID不存在、分片未上传完整、MinIO合并失败、数据库更新失败
     */
    FileMetadata mergeChunks(FileChunkOperateDTO dto) throws Exception;

    /**
     * 暂停分片上传任务(更新任务状态为"暂停",暂停期间无法上传分片)
     * @param dto 入参DTO,核心用到以下字段(必填):
     *            - uploadTaskId:上传任务ID(定位对应任务)
     * @throws Exception 抛出场景:任务ID不存在、任务已完成/已删除、数据库更新失败
     */
    void pauseChunkUpload(FileChunkOperateDTO dto);

    /**
     * 恢复分片上传任务(更新任务状态为"上传中",恢复分片上传权限)
     * @param dto 入参DTO,核心用到以下字段(必填):
     *            - uploadTaskId:上传任务ID(定位对应任务)
     * @throws Exception 抛出场景:任务ID不存在、任务已完成/已删除、数据库更新失败
     */
    void resumeChunkUpload(FileChunkOperateDTO dto);
}

FileChunkUploadServiceImpl.java

java 复制代码
package org.example.springbootminiodemo.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.example.springbootminiodemo.dto.FileChunkInitCheckDTO;
import org.example.springbootminiodemo.dto.FileChunkOperateDTO;
import org.example.springbootminiodemo.entity.FileMetadata;
import org.example.springbootminiodemo.exception.InvalidParameterException;
import org.example.springbootminiodemo.helper.FileBasicHelper;
import org.example.springbootminiodemo.helper.FileChunkUploadHelper;
import org.example.springbootminiodemo.mapper.FileMetadataMapper;
import org.example.springbootminiodemo.service.FileChunkUploadService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * 分片上传+断点续传
 *
 */
@Slf4j
@Service
public class FileChunkUploadServiceImpl extends ServiceImpl<FileMetadataMapper, FileMetadata> implements FileChunkUploadService {

    @Value("${minio.chunk.expire-hours:24}")
    private int expireHours; // 任务过期时间

    // 依赖注入(仅保留必要依赖,核心逻辑交给工具类)
    private final FileMetadataMapper fileMetadataMapper;
    private final FileChunkUploadHelper fileChunkUploadHelper; // 分片上传专属工具类
    private final FileBasicHelper fileBasicHelper; // 通用工具类

    @Autowired
    public FileChunkUploadServiceImpl(FileMetadataMapper fileMetadataMapper, FileChunkUploadHelper fileChunkUploadHelper,
                                      FileBasicHelper fileBasicHelper) {
        this.fileMetadataMapper = fileMetadataMapper;
        this.fileChunkUploadHelper = fileChunkUploadHelper;
        this.fileBasicHelper = fileBasicHelper;
    }

    /**
     * 检查文件上传状态
     */
    @Override
    public Map<String, Object> checkFileStatus(FileChunkInitCheckDTO dto) {
        String fileMd5 = dto.getFileMd5();
        String uploadTaskId = dto.getUploadTaskId();
        Long uploadUserId = dto.getUploadUserId();
        Map<String, Object> result = new HashMap<>(3);

        // 优先按taskId查询
        if (StringUtils.isNotBlank(uploadTaskId)) {
            log.info("按任务ID查询状态:uploadTaskId={}", uploadTaskId);
            return fileChunkUploadHelper.checkByUploadTaskId(uploadTaskId);
        }

        // 按MD5+用户ID查询
        if (StringUtils.isNotBlank(fileMd5) && uploadUserId != null) {
            log.info("按MD5+用户ID查询未完成任务:fileMd5={},uploadUserId={}", fileMd5, uploadUserId);
            String latestTaskId = fileChunkUploadHelper.getLatestUnfinishedTaskId(fileMd5, uploadUserId);
            if (latestTaskId != null) {
                return fileChunkUploadHelper.checkByUploadTaskId(latestTaskId);
            }

            result.put("code", FileChunkUploadHelper.CHECK_STATUS_NOT_UPLOADED);
            result.put("data", null);
            result.put("message", "无未完成上传任务,可初始化新任务");
            return result;
        }

        // 无有效参数
        log.warn("未传递有效查询参数");
        result.put("code", FileChunkUploadHelper.CHECK_STATUS_NOT_UPLOADED);
        result.put("data", null);
        result.put("message", "无有效查询参数");
        return result;
    }

    /**
     * 初始化分片上传
     */
    @Override
    public Map<String, Object> initChunkUpload(FileChunkInitCheckDTO dto) {
        String fileMd5 = dto.getFileMd5();
        String fileName = dto.getFileName();
        Long fileSize = dto.getFileSize();
        String fileType = dto.getFileType();
        Long uploadUserId = dto.getUploadUserId();

        Map<String, Object> result = new HashMap<>(4);

        try {
            // 1. 通用参数校验(调用通用工具类)
            fileBasicHelper.validateCommonParams(fileMd5, fileName, fileSize, fileType);
            log.info("初始化分片上传任务:fileName={},fileSize={}字节,fileMd5={}", fileName, fileSize, fileMd5);

            // 2. 调用工具类完成核心逻辑(MinIO会话+Redis存储)
            Map<String, String> sessionInfo = fileChunkUploadHelper.initMinioChunkSession(
                    fileMd5, fileName, fileSize, fileType, uploadUserId, expireHours
            );

            // 3. 封装返回结果
            result.put("uploadTaskId", sessionInfo.get("uploadTaskId"));
            result.put("minioUploadId", sessionInfo.get("minioUploadId"));
            result.put( "fileMd5", fileMd5);
            result.put("message", "任务初始化成功,支持重复上传和进度恢复");
            return result;

        } catch (InvalidParameterException e) {
            log.error("初始化失败:参数非法", e);
            throw e;
        } catch (Exception e) {
            log.error("初始化失败:fileMd5={},fileName={}", fileMd5, fileName, e);
            throw new RuntimeException("分片上传初始化失败:" + e.getMessage());
        }
    }

    /**
     * 上传单个分片(流程编排:参数校验 → 调用工具类 → 封装结果)
     */
    @Override
    public Map<String, Object> uploadChunk(FileChunkOperateDTO dto, MultipartFile chunk) throws Exception {
        String uploadTaskId = dto.getUploadTaskId();
        Integer chunkNumber = dto.getChunkNumber();
        Integer totalChunks = dto.getTotalChunks();

        Map<String, Object> result = new HashMap<>(3);

        // 1. 基础参数校验
        if (chunk == null || chunk.isEmpty()) {
            throw new InvalidParameterException("分片文件不能为空");
        }
        log.info("上传分片:uploadTaskId={},分片序号={},总分片数={}", uploadTaskId, chunkNumber, totalChunks);

        // 2. 调用工具类完成分片上传
        String etag = fileChunkUploadHelper.uploadSingleChunkToMinIO(
                uploadTaskId, chunkNumber, totalChunks, chunk, expireHours
        );

        // 3. 封装结果
        result.put("success", true);
        result.put("chunkNumber", chunkNumber);
        result.put( "etag", etag);
        return result;
    }

    /**
     * 查询已上传分片(直接调用工具类)
     */
    @Override
    public List<Integer> getUploadedChunks(FileChunkOperateDTO dto) {
        String uploadTaskId = dto.getUploadTaskId();
        if (StringUtils.isBlank(uploadTaskId)) {
            throw new InvalidParameterException("uploadTaskId不能为空");
        }
        log.info("查询已上传分片:uploadTaskId={}", uploadTaskId);
        return fileChunkUploadHelper.getUploadedChunks(uploadTaskId);
    }

    /**
     * 合并分片(流程编排:参数校验 → 事务控制 → 调用工具类 → 入库 → 封装结果)
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public FileMetadata mergeChunks(FileChunkOperateDTO dto) throws Exception {
        String uploadTaskId = dto.getUploadTaskId();
        Integer totalChunks = dto.getTotalChunks();

        // 1. 参数校验
        if (StringUtils.isBlank(uploadTaskId)) {
            throw new InvalidParameterException("uploadTaskId不能为空");
        }
        if (totalChunks == null || totalChunks < 1) {
            throw new InvalidParameterException("totalChunks必须≥1");
        }
        log.info("合并分片:uploadTaskId={},总分片数={}", uploadTaskId, totalChunks);

        // 2. 调用工具类完成合并逻辑
        FileMetadata fileMetadata = fileChunkUploadHelper.mergeAllChunks(uploadTaskId, totalChunks);

        // 3. 元数据入库(工具类已构建元数据,此处仅入库)
        fileMetadataMapper.insert(fileMetadata);
        log.info("合并成功:fileId={},uploadTaskId={}", fileMetadata.getId(), uploadTaskId);

        return fileMetadata;
    }

    /**
     * 暂停任务(调用工具类)
     */
    @Override
    public void pauseChunkUpload(FileChunkOperateDTO dto) {
        String uploadTaskId = dto.getUploadTaskId();
        if (StringUtils.isBlank(uploadTaskId)) {
            throw new InvalidParameterException("uploadTaskId不能为空");
        }
        log.info("暂停任务:uploadTaskId={}", uploadTaskId);
        fileChunkUploadHelper.updateTaskStatus(uploadTaskId, FileChunkUploadHelper.UPLOAD_STATUS_PAUSED, expireHours);
        log.info("任务暂停成功:uploadTaskId={}", uploadTaskId);
    }

    /**
     * 恢复任务(调用工具类)
     */
    @Override
    public void resumeChunkUpload(FileChunkOperateDTO dto) {
        String uploadTaskId = dto.getUploadTaskId();
        if (StringUtils.isBlank(uploadTaskId)) {
            throw new InvalidParameterException("uploadTaskId不能为空");
        }
        log.info("恢复任务:uploadTaskId={}", uploadTaskId);
        fileChunkUploadHelper.updateTaskStatus(uploadTaskId, FileChunkUploadHelper.UPLOAD_STATUS_UPLOADING, expireHours);
        log.info("任务恢复成功:uploadTaskId={}", uploadTaskId);
    }
}

分片下载服务

FileChunkDownloadService.java

java 复制代码
package org.example.springbootminiodemo.service;

import org.example.springbootminiodemo.dto.FileChunkDownloadDTO; // 替换为新DTO
import org.example.springbootminiodemo.vo.ChunkDownloadStatusVO;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * 大文件分片下载核心接口(已适配FileChunkDownloadDTO)
 */
public interface FileChunkDownloadService {

    /**
     * 1. 分片下载状态查询(断点续传前置接口)
     */
    ChunkDownloadStatusVO queryChunkDownloadStatus(FileChunkDownloadDTO dto);

    /**
     * 2. 单个大文件分片下载
     */
    void singleFileChunkDownload(FileChunkDownloadDTO dto, HttpServletRequest request, HttpServletResponse response) throws Exception;

    /**
     * 3. 批量文件打包ZIP分片下载
     */
    void batchZipChunkDownload(FileChunkDownloadDTO dto, HttpServletRequest request, HttpServletResponse response) throws Exception;

    /**
     * 4. 临时ZIP文件清理接口
     */
    void cleanTempZipFile(String tempZipFileName);
}

FileChunkDownloadServiceImpl.java

java 复制代码
package org.example.springbootminiodemo.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; // 补充导入
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.example.springbootminiodemo.entity.FileMetadata;
import org.example.springbootminiodemo.exception.FileNotFoundException;
import org.example.springbootminiodemo.exception.InvalidParameterException;
import org.example.springbootminiodemo.helper.FileChunkDownloadHelper;
import org.example.springbootminiodemo.mapper.FileMetadataMapper;
import org.example.springbootminiodemo.dto.FileChunkDownloadDTO; // 替换为新DTO(核心!)
import org.example.springbootminiodemo.service.FileChunkDownloadService;
import org.example.springbootminiodemo.vo.ChunkDownloadStatusVO; // 补充导入
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.util.List;

/**
 * 大文件分片下载 Service 实现(已修正:适配FileChunkDownloadDTO+修复逻辑漏洞)
 */
@Slf4j
@Service
public class FileChunkDownloadServiceImpl extends ServiceImpl<FileMetadataMapper, FileMetadata> implements FileChunkDownloadService {

    @Resource
    private FileChunkDownloadHelper chunkDownloadHelper;

    @Resource
    private FileMetadataMapper fileMetadataMapper;

    // ==================== 1. 分片下载状态查询(修复参数校验+适配DTO) ====================
    @Override
    public ChunkDownloadStatusVO queryChunkDownloadStatus(FileChunkDownloadDTO dto) {
        // 第一步:校验DTO非空
        if (dto == null) {
            throw new InvalidParameterException("请求参数DTO不能为空");
        }

        Long fileId = dto.getFileId();
        List<Long> fileIdList = dto.getFileIdList();

        // 第二步:严格二选一校验(不能同时传/同时不传)
        boolean hasFileId = fileId != null;
        boolean hasFileIdList = !CollectionUtils.isEmpty(fileIdList);

        if (hasFileId && hasFileIdList) {
            throw new InvalidParameterException("参数冲突:只能传递fileId(单个文件)或fileIdList(批量文件),不能同时传递");
        }
        if (!hasFileId && !hasFileIdList) {
            throw new InvalidParameterException("参数缺失:必须传递fileId(单个文件)或fileIdList(批量文件)");
        }
        if (hasFileIdList && fileIdList.size() > FileChunkDownloadHelper.MAX_BATCH_FILE_COUNT) {
            throw new InvalidParameterException("批量文件数超出限制:最多支持" + FileChunkDownloadHelper.MAX_BATCH_FILE_COUNT + "个,请分批下载");
        }

        // 第三步:分发查询逻辑
        if (hasFileId) {
            log.info("查询单个文件分片下载状态:fileId={}", fileId);
            return chunkDownloadHelper.querySingleFileStatus(fileId);
        } else {
            log.info("查询批量文件分片下载状态:fileIdList={}", fileIdList);
            try {
                return chunkDownloadHelper.queryBatchFileStatus(fileIdList);
            } catch (FileNotFoundException e) {
                throw new InvalidParameterException(e.getMessage());
            }
        }
    }

    // ==================== 2. 单个大文件分片下载(适配DTO+补充校验) ====================
    @Override
    public void singleFileChunkDownload(FileChunkDownloadDTO dto, HttpServletRequest request, HttpServletResponse response) throws Exception {
        // 第一步:校验核心参数
        if (dto == null) {
            throw new InvalidParameterException("请求参数DTO不能为空");
        }
        if (request == null || response == null) {
            throw new InvalidParameterException("HTTP请求/响应对象不能为空");
        }

        Long fileId = dto.getFileId();
        if (fileId == null) {
            throw new InvalidParameterException("单个文件分片下载必须传递fileId");
        }

        // 第二步:查询文件元数据(校验存在性)
        FileMetadata file = fileMetadataMapper.selectOne(
                new QueryWrapper<FileMetadata>()
                        .eq("id", fileId)
                        .eq("is_delete", 0)
        );
        if (file == null) {
            throw new FileNotFoundException("文件不存在或已删除:fileId=" + fileId);
        }

        // 第三步:解析Range头,确定分片范围
        String rangeHeader = request.getHeader("Range");
        long totalSize = file.getFileSize();
        long[] range = chunkDownloadHelper.parseRangeHeader(rangeHeader, totalSize);
        long start = range[0];
        long end = range[1];

        // 第四步:构建响应头(支持断点续传)
        chunkDownloadHelper.buildChunkResponseHeader(response, file.getFileName(), start, end, totalSize);

        // 第五步:传输分片流(从MinIO直接读取,无需本地缓存)
        chunkDownloadHelper.transferSingleFileChunk(file, start, end, response);
        log.info("单个文件分片下载完成:fileId={},文件名={},范围={}-{}", fileId, file.getFileName(), start, end);
    }

    // ==================== 3. 批量文件打包ZIP分片下载(适配DTO+优化逻辑) ====================
    @Override
    public void batchZipChunkDownload(FileChunkDownloadDTO dto, HttpServletRequest request, HttpServletResponse response) throws Exception {
        // 第一步:校验核心参数
        if (dto == null) {
            throw new InvalidParameterException("请求参数DTO不能为空");
        }
        if (request == null || response == null) {
            throw new InvalidParameterException("HTTP请求/响应对象不能为空");
        }

        List<Long> fileIdList = dto.getFileIdList();
        String tempZipFileName = dto.getTempZipFileName();

        // 严格参数校验(依赖状态查询接口的返回结果)
        if (CollectionUtils.isEmpty(fileIdList)) {
            throw new InvalidParameterException("批量文件分片下载必须传递fileIdList");
        }
        if (StringUtils.isBlank(tempZipFileName)) {
            throw new InvalidParameterException("tempZipFileName不能为空!请先调用【分片下载状态查询接口】获取");
        }
        if (fileIdList.size() > FileChunkDownloadHelper.MAX_BATCH_FILE_COUNT) {
            throw new InvalidParameterException("批量文件数超出限制:最多支持" + FileChunkDownloadHelper.MAX_BATCH_FILE_COUNT + "个,请分批下载");
        }

        // 第二步:校验并获取有效文件列表
        List<FileMetadata> validFiles = chunkDownloadHelper.validateBatchFiles(fileIdList);

        // 第三步:生成临时ZIP文件(已存在则复用)
        File tempZipFile = chunkDownloadHelper.createBatchZipFile(validFiles, tempZipFileName);
        long totalSize = tempZipFile.length();

        // 第四步:解析Range头,确定分片范围
        String rangeHeader = request.getHeader("Range");
        long[] range = chunkDownloadHelper.parseRangeHeader(rangeHeader, totalSize);
        long start = range[0];
        long end = range[1];

        // 第五步:构建响应头
        chunkDownloadHelper.buildChunkResponseHeader(response, tempZipFileName, start, end, totalSize);

        // 第六步:传输ZIP分片流
        chunkDownloadHelper.transferLocalFileChunk(tempZipFile, start, end, response);
        log.info("批量ZIP分片下载完成:ZIP文件名={},范围={}-{},总大小={}MB",
                tempZipFileName, start, end, totalSize / 1024 / 1024);
    }

    // ==================== 补充:临时ZIP文件清理接口(实现接口方法+加@Override) ====================
    @Override // 关键!之前漏了@Override,导致接口方法未实现
    public void cleanTempZipFile(String tempZipFileName) {
        if (StringUtils.isBlank(tempZipFileName)) {
            throw new InvalidParameterException("tempZipFileName不能为空");
        }
        log.info("开始清理临时ZIP文件:{}", tempZipFileName);
        chunkDownloadHelper.cleanTempZipFile(tempZipFileName);
    }
}

8. Controller层:接口入口定义

基础文件接口

java 复制代码
package org.example.springbootminiodemo.controller;

import com.baomidou.mybatisplus.core.metadata.IPage;
import org.example.springbootminiodemo.dto.BaseFileDTO;
import org.example.springbootminiodemo.dto.FileBatchOperateDTO;
import org.example.springbootminiodemo.dto.FilePageQueryDTO;
import org.example.springbootminiodemo.entity.FileMetadata;
import org.example.springbootminiodemo.service.FileBasicService;
import org.example.springbootminiodemo.util.Result;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
import java.util.Map;

/**
 * 基础文件接口:处理小文件(<100MB)的上传、下载、查询、删除
 * 接口前缀:/api/file/basic
 */
@RestController
@RequestMapping("/api/file/basic")
public class FileBasicController {

    @Resource
    private FileBasicService fileBasicService;

    /**
     * 分页查询文件列表
     * 请求方式:GET
     * 请求参数:?pageNum=1&pageSize=10&uploadUserId=123&fileName=报表&fileType=pdf
     */
    @GetMapping("/page-query")
    public Result<IPage<FileMetadata>> pageQueryFileList(FilePageQueryDTO dto) {
        IPage<FileMetadata> filePage = fileBasicService.pageQueryFileList(dto);
        return Result.success("查询成功", filePage);
    }

    /**
     * 批量小文件上传(单个文件<100MB)
     * 请求方式:POST
     * 请求参数:form-data 格式(uploadUserId=123 + files=[文件1, 文件2])
     */
    @PostMapping("/batch-upload")
    public Result<List<FileMetadata>> batchSimpleUpload(BaseFileDTO dto, @RequestParam("files") MultipartFile[] files) {
        List<FileMetadata> result = fileBasicService.batchSimpleUpload(dto, files);
        return Result.success("上传成功", result);
    }

    /**
     * 批量小文件下载(单个/多个)
     * 请求方式:POST
     * 请求体:{"fileIdList": [1,2,3]}
     */
    @PostMapping("/batch-download")
    public void batchDownloadFiles(@RequestBody FileBatchOperateDTO dto, HttpServletResponse response) {
        fileBasicService.batchDownloadFiles(dto, response);
    }

    /**
     * 批量删除文件
     * 请求方式:POST
     * 请求体:{"fileIdList": [1,2,3]}
     */
    @PostMapping("/batch-delete")
    public Result<Map<String, Object>> batchDeleteFiles(@RequestBody FileBatchOperateDTO dto) {
        Map<String, Object> result = fileBasicService.batchDeleteFiles(dto);
        return Result.success("删除完成", result);
    }
}

分片上传接口

java 复制代码
package org.example.springbootminiodemo.controller;

import org.example.springbootminiodemo.dto.FileChunkInitCheckDTO;
import org.example.springbootminiodemo.dto.FileChunkOperateDTO;
import org.example.springbootminiodemo.entity.FileMetadata;
import org.example.springbootminiodemo.service.FileChunkUploadService;
import org.example.springbootminiodemo.util.Result;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

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

/**
 * 分片上传接口:处理大文件(≥100MB)的分片上传、断点续传
 * 接口前缀:/api/file/chunk/upload
 */
@RestController
@RequestMapping("/api/file/chunk/upload")
public class FileChunkUploadController {

    @Resource
    private FileChunkUploadService chunkUploadService;

    /**
     * 检查文件上传状态(已完成/上传中/未上传)
     * 请求方式:GET
     * 请求参数:?fileMd5=xxx&uploadUserId=123 或 ?uploadTaskId=xxx
     */
    @GetMapping("/check")
    public Result<Map<String, Object>> checkFileStatus(FileChunkInitCheckDTO dto) {
        Map<String, Object> status = chunkUploadService.checkFileStatus(dto);
        return Result.success("查询成功", status);
    }

    /**
     * 初始化分片上传任务
     * 请求方式:POST
     * 请求体:{"fileMd5":"xxx","fileName":"视频.mp4","fileSize":209715200,"fileType":"video/mp4","uploadUserId":123}
     */
    @PostMapping("/init")
    public Result<Map<String, Object>> initChunkUpload(@RequestBody FileChunkInitCheckDTO dto) {
        Map<String, Object> initResult = chunkUploadService.initChunkUpload(dto);
        return Result.success("初始化成功", initResult);
    }

    /**
     * 上传单个分片
     * 请求方式:POST
     * 请求参数:form-data 格式(uploadTaskId=xxx&chunkNumber=1&totalChunks=40 + chunk=[分片文件])
     */
    @PostMapping("/chunk")
    public Result<Map<String, Object>> uploadChunk(FileChunkOperateDTO dto, @RequestParam("chunk") MultipartFile chunk) throws Exception {
        Map<String, Object> result = chunkUploadService.uploadChunk(dto, chunk);
        return Result.success("分片上传成功", result);
    }

    /**
     * 查询已上传的分片序号列表
     * 请求方式:GET
     * 请求参数:?uploadTaskId=xxx
     */
    @GetMapping("/uploaded-chunks")
    public Result<List<Integer>> getUploadedChunks(FileChunkOperateDTO dto) {
        List<Integer> chunks = chunkUploadService.getUploadedChunks(dto);
        return Result.success("查询成功", chunks);
    }

    /**
     * 合并所有分片
     * 请求方式:POST
     * 请求体:{"uploadTaskId":"xxx","totalChunks":40}
     */
    @PostMapping("/merge")
    public Result<FileMetadata> mergeChunks(@RequestBody FileChunkOperateDTO dto) throws Exception {
        FileMetadata fileMetadata = chunkUploadService.mergeChunks(dto);
        return Result.success("合并成功", fileMetadata);
    }

    /**
     * 暂停分片上传
     * 请求方式:POST
     * 请求体:{"uploadTaskId":"xxx"}
     */
    @PostMapping("/pause")
    public Result<Void> pauseChunkUpload(@RequestBody FileChunkOperateDTO dto) {
        chunkUploadService.pauseChunkUpload(dto);
        return Result.success("上传已暂停");
    }

    /**
     * 恢复分片上传
     * 请求方式:POST
     * 请求体:{"uploadTaskId":"xxx"}
     */
    @PostMapping("/resume")
    public Result<Void> resumeChunkUpload(@RequestBody FileChunkOperateDTO dto) {
        chunkUploadService.resumeChunkUpload(dto);
        return Result.success("上传已恢复");
    }
}

分片下载接口

java 复制代码
package org.example.springbootminiodemo.controller;

import org.example.springbootminiodemo.dto.FileChunkDownloadDTO;
import org.example.springbootminiodemo.service.FileChunkDownloadService;
import org.example.springbootminiodemo.util.Result;
import org.example.springbootminiodemo.vo.ChunkDownloadStatusVO;
import org.springframework.web.bind.annotation.*;

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

/**
 * 分片下载接口:处理大文件(≥100MB)的分片下载、批量ZIP分片下载
 * 接口前缀:/api/file/chunk/download
 */
@RestController
@RequestMapping("/api/file/chunk/download")
public class FileChunkDownloadController {

    @Resource
    private FileChunkDownloadService chunkDownloadService;

    /**
     * 查询分片下载状态(是否支持分片、文件总大小、临时ZIP文件名)
     * 请求方式:POST
     * 请求体:{"fileId":123} 或 {"fileIdList":[123,456]}
     */
    @PostMapping("/status")
    public Result<ChunkDownloadStatusVO> queryChunkDownloadStatus(@RequestBody FileChunkDownloadDTO dto) {
        ChunkDownloadStatusVO status = chunkDownloadService.queryChunkDownloadStatus(dto);
        return Result.success("查询成功", status);
    }

    /**
     * 单个大文件分片下载
     * 请求方式:POST
     * 请求体:{"fileId":123}
     * 请求头:Range: bytes=0-5242879
     */
    @PostMapping("/single")
    public void singleFileChunkDownload(@RequestBody FileChunkDownloadDTO dto,
                                        HttpServletRequest request, HttpServletResponse response) throws Exception {
        chunkDownloadService.singleFileChunkDownload(dto, request, response);
    }

    /**
     * 批量大文件打包ZIP分片下载
     * 请求方式:POST
     * 请求体:{"fileIdList":[123,456],"tempZipFileName":"批量下载_xxx.zip"}
     * 请求头:Range: bytes=0-5242879
     */
    @PostMapping("/batch/zip")
    public void batchZipChunkDownload(@RequestBody FileChunkDownloadDTO dto,
                                      HttpServletRequest request, HttpServletResponse response) throws Exception {
        chunkDownloadService.batchZipChunkDownload(dto, request, response);
    }

    /**
     * 清理临时ZIP文件
     * 请求方式:DELETE
     * 请求路径:/api/file/chunk/download/clean/批量下载_xxx.zip
     */
    @DeleteMapping("/clean/{tempZipFileName}")
    public Result<Void> cleanTempZipFile(@PathVariable String tempZipFileName) {
        chunkDownloadService.cleanTempZipFile(tempZipFileName);
        return Result.success("临时文件清理成功");
    }
}
相关推荐
q***47181 小时前
Spring Boot 3.3.4 升级导致 Logback 之前回滚策略配置不兼容问题解决
java·spring boot·logback
n***33351 小时前
SpringBoot返回文件让前端下载的几种方式
前端·spring boot·后端
毕设源码-邱学长2 小时前
【开题答辩全过程】以 基于SpringBoot的医院血库管理系统设计与实现为例,包含答辩的问题和答案
java·spring boot·后端
q***72872 小时前
Spring Boot集成Kafka:最佳实践与详细指南
spring boot·kafka·linq
清风徐来QCQ2 小时前
Spring Boot 静态资源路径映射
java·spring boot·后端
踏浪无痕2 小时前
@Transactional做不到的5件事,我用这6种方法解决了
spring boot·后端·面试
程序定小飞2 小时前
基于springboot的体育馆使用预约平台的设计与实现
java·开发语言·spring boot·后端·spring
计算机毕业设计小途3 小时前
计算机毕业设计推荐:基于SpringBoot的水产养殖管理系统【Java+spring boot+MySQL、Java项目、Java毕设、Java项目定制定做】
java·spring boot·mysql
s***4533 小时前
解决Spring Boot中Druid连接池“discard long time none received connection“警告
spring boot·后端·oracle