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("临时文件清理成功");
    }
}
相关推荐
柯南二号21 小时前
【后端】【Java】一文详解Spring Boot RESTful 接口统一返回与异常处理实践
java·spring boot·状态模式·restful
南龙大魔王21 小时前
spring ai Alibaba(SAA)学习(二)
java·人工智能·spring boot·学习·ai
汤姆yu1 天前
基于springboot的运动服服饰销售购买商城系统
java·spring boot·后端
期待のcode1 天前
Springboot数据层开发—Springboot整合JdbcTemplate和Mybatis
spring boot·后端·mybatis
柯南二号1 天前
【后端】【Java】一文深入理解 Spring Boot RESTful 风格接口开发
java·spring boot·restful
全栈独立开发者1 天前
软考架构师实战:Spring Boot 3.5 + DeepSeek 开发 AI 应用,上线 24 小时数据复盘(2C1G 服务器抗压实录)
java·spring boot·后端
E***U9451 天前
Java 校招 / 社招:Spring Boot 项目实战指南
java·开发语言·spring boot
m0_740043731 天前
SpringBoot03-Mybatis框架入门
java·数据库·spring boot·sql·spring·mybatis
一 乐1 天前
心理健康管理|基于springboot + vue心理健康管理系统(源码+数据库+文档)
java·开发语言·前端·数据库·vue.js·spring boot·后端
白宇横流学长1 天前
基于SpringBoot实现的食尚生活外卖配送管理系统设计与实现【源码+文档】
spring boot·后端·生活