MinIO教程(三)| Spring Boot 集成 MinIO 实战(后端篇)
- 一、前言:系统概述
- 二、环境准备:3步搭建基础
- 三、代码实现:全模块完整代码(后端)
-
- [1. 基础配置类:支撑系统运行](#1. 基础配置类:支撑系统运行)
- [2. 实体类:映射数据库表](#2. 实体类:映射数据库表)
- [3. DTO:数据传输对象](#3. DTO:数据传输对象)
- [4. 异常类:自定义业务异常](#4. 异常类:自定义业务异常)
- [5. 工具类:通用功能封装](#5. 工具类:通用功能封装)
- [6. Helper类:业务工具封装](#6. Helper类:业务工具封装)
- [7. Service层:业务逻辑实现](#7. Service层:业务逻辑实现)
- [8. Controller层:接口入口定义](#8. Controller层:接口入口定义)
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("临时文件清理成功");
}
}