SpringBoot + MinIO 实现大文件秒传 + 断点续传 + 分片上传
- 接口调用顺序(标准固定)
- [1. 秒传+断点续传](#1. 秒传+断点续传)
- [2. 初始化](#2. 初始化)
- [3. 上传单个分片](#3. 上传单个分片)
- [4. 分片合片](#4. 分片合片)
- 实体类
- 工具类
接口调用顺序(标准固定)
- 校验 MD5(秒传入口)
- 没秒传 → 查已上传分片(断点续传用)
- 初始化分片(没续传就初始化,否则直接接着上传)
- 上传单个分片
- 全部传完 → 合并分片
1. 秒传+断点续传
秒传核心逻辑 :上传前先计算文件的 MD5 哈希值,后端查询file_info表是否存在相同 MD5 且上传状态为已完成的记录。若存在,直接返回文件 URL,实现秒传。
断点续传核心逻辑 :断点续传是分片上传的自然延伸。当上传中断后,用户重新上传相同文件时,前端先计算 MD5,后端查询file_chunk表,返回该文件已成功上传的分片序号列表。前端只需要上传不在列表中的分片即可。
java
/**
* 秒传 + 断点续传:查询已上传分片
* 前端上传前先调用,判断文件是否已存在(秒传)/ 哪些分片已上传(断点续传)
* @param fileMd5 文件唯一标识MD5
* @return 断点续传VO(是否存在、已上传分片列表)
*/
public BreakpointUploadVO checkBreakpoint(String fileMd5) throws Exception {
BreakpointUploadVO vo = new BreakpointUploadVO();
// 1. 秒传判断:查询文件是否已完整上传完成(上传状态=1)
LambdaQueryWrapper<FileInfo> infoWrapper = Wrappers.lambdaQuery();
infoWrapper.eq(FileInfo::getFileMd5, fileMd5)
.eq(FileInfo::getUploadStatus, 1);
FileInfo info = fileInfoMapper.selectOne(infoWrapper);
// 文件已存在,直接返回文件地址,实现秒传
if (info != null) {
vo.setExist(true);
vo.setFileUrl(getPresignedUrl(info.getObjectName()));
return vo;
}
// 2. 断点续传:查询该文件已上传成功的分片序号
LambdaQueryWrapper<FileChunk> chunkWrapper = Wrappers.lambdaQuery();
chunkWrapper.eq(FileChunk::getUploadId, fileMd5);
List<FileChunk> chunks = fileChunkMapper.selectList(chunkWrapper);
if (chunks != null && !chunks.isEmpty()) {
// 把已上传分片序号存入集合,返回给前端跳过上传
Set<Integer> uploadedChunks = new HashSet<>();
for (FileChunk chunk : chunks) {
uploadedChunks.add(chunk.getChunkIndex());
vo.setUploadId(fileMd5);
vo.setObjectName(chunk.getObjectName());
}
vo.setUploadedChunks(uploadedChunks);
}
vo.setExist(false);
return vo;
}
2. 初始化
核心逻辑 :秒传 / 断点续传校验不通过 时,前端发起分片上传初始化。后端直接使用文件 MD5 作为上传唯一标识 uploadId,并生成文件在 MinIO 中的唯一存储路径(objectName),返回给前端用于后续分片上传。
java
/**
* 初始化分片上传
* 生成唯一上传ID、文件存储路径(objectName)
* @param request 初始化请求参数(文件名、MD5等)
* @return 上传ID + 文件存储路径
*/
public InitUploadResponse initUpload(InitUploadRequest request) {
// 直接用文件MD5作为上传唯一标识
String uploadId = request.getFileMd5();
// 获取文件后缀名
String suffix = getSuffix(request.getFilename());
String uuid = UUID.randomUUID().toString().replace("-", "");
String objectName = "files/"
+ LocalDate.now()
+ "/"
+ uuid // 这里用 UUID,不要用 MD5
+ suffix;
return new InitUploadResponse(uploadId, objectName);
}
3. 上传单个分片
核心逻辑 :前端 将大文件按固定大小切割分片,循环调用上传接口。
后端逻辑:
- 先查询数据库,分片已存在则直接跳过,不重复上传、不重复存储。
- 分片不存在,则上传到 MinIO 的临时分片目录。
- 记录分片信息到
file_chunk表,作为断点续传的依据。
java
/**
* 上传单个分片
* 前端循环调用,分片上传;自动跳过已上传分片
* @param uploadId 上传唯一标识(文件MD5)
* @param chunkIndex 当前分片序号
* @param file 分片文件流
*/
public void uploadChunk(
String uploadId,
Integer chunkIndex,
String objectName,
MultipartFile file
) throws Exception {
// 参数合法性校验
if (uploadId == null || uploadId.isBlank()) {
throw new IllegalArgumentException("uploadId不能为空");
}
if (chunkIndex == null || chunkIndex < 0) {
throw new IllegalArgumentException("chunkIndex非法");
}
// 1. 先查数据库:该分片是否已经上传过
LambdaQueryWrapper<FileChunk> wrapper = Wrappers.lambdaQuery();
wrapper.eq(FileChunk::getUploadId, uploadId)
.eq(FileChunk::getChunkIndex, chunkIndex);
FileChunk existChunk = fileChunkMapper.selectOne(wrapper);
// 2. 已存在 → 直接返回,不执行上传、不重复存储
if (existChunk != null) {
return;
}
// 生成分片在 Minio 中的临时存储路径
String chunkObjectName = getChunkObjectName(uploadId, chunkIndex);
// 3. 上传分片到 Minio
minioClient.putObject(
PutObjectArgs.builder()
.bucket(bucket)
.object(chunkObjectName)
.stream(file.getInputStream(), file.getSize(), -1)
.contentType(file.getContentType())
.build()
);
// 4. 记录分片信息(断点续传核心依据)
FileChunk chunk = new FileChunk();
chunk.setUploadId(uploadId);
chunk.setChunkIndex(chunkIndex);
chunk.setObjectName(objectName);
chunk.setCreateTime(LocalDateTime.now());
fileChunkMapper.insert(chunk);
}
4. 分片合片
核心逻辑 :所有分片上传完成后,前端调用合并接口。
后端执行:
- 校验所有分片是否存在,按顺序组装分片。
- 调用 MinIO 合并接口,将临时分片合并为完整文件。
- 合并成功后,清理 MinIO 临时分片、删除数据库分片记录。
- 写入文件信息到 file_info 表,标记上传完成,返回文件访问地址。
java
/**
* 合并分片
* 所有分片上传完成后调用,合并为完整文件,清理临时分片
* @param request 合并请求参数
* @return 完整文件的访问地址
*/
public String completeUpload(CompleteUploadRequest request) throws Exception {
String uploadId = request.getUploadId();
String objectName = request.getObjectName();
Integer totalChunks = request.getTotalChunks();
// 参数校验
if (totalChunks == null || totalChunks <= 0) {
throw new IllegalArgumentException("totalChunks非法");
}
// 组装所有分片源,用于合并
List<ComposeSource> sources = new ArrayList<>();
for (int i = 0; i < totalChunks; i++) {
String chunkObjectName = getChunkObjectName(uploadId, i);
// 检查分片是否存在,不存在会抛出异常
minioClient.statObject(
StatObjectArgs.builder()
.bucket(bucket)
.object(chunkObjectName)
.build()
);
// 添加到合并源列表
sources.add(
ComposeSource.builder()
.bucket(bucket)
.object(chunkObjectName)
.build()
);
}
// 执行 Minio 分片合并
minioClient.composeObject(
ComposeObjectArgs.builder()
.bucket(bucket)
.object(objectName)
.sources(sources)
.build()
);
// 删除 Minio 中的临时分片文件
removeChunks(uploadId, totalChunks);
// 4. 删除数据库中的分片记录
LambdaQueryWrapper<FileChunk> wrapper = Wrappers.lambdaQuery();
wrapper.eq(FileChunk::getUploadId, uploadId);
fileChunkMapper.delete(wrapper);
// 5. 记录完整文件信息,标记上传完成
FileInfo info = new FileInfo();
info.setFileMd5(uploadId);
info.setObjectName(objectName);
info.setUploadStatus(1);
fileInfoMapper.insert(info);
// 返回带签名的文件访问地址
return getPresignedUrl(objectName);
}
实体类
java
/**
* 断点续传返回:已上传分片列表
*/
@Data
public class BreakpointUploadVO {
// 是否已经完整上传(秒传)
private boolean exist;
// 上传ID(exist=false 时返回)
private String uploadId;
// 原始文件对象名(exist=false 时返回)
private String objectName;
// 已上传的分片序号
private Set<Integer> uploadedChunks;
// 文件地址(exist=true 时返回)
private String fileUrl;
}
@Data
@TableName("file_info")
public class FileInfo {
@TableId(type = IdType.AUTO)
private Long id;
/**
* 文件MD5哈希值(秒传核心)
*/
private String fileMd5;
/**
* MinIO对象名称
*/
private String objectName;
/**
* 上传状态:0-未完成,1-已完成
*/
private Integer uploadStatus;
}
/**
* 文件分片上传记录(断点续传核心)
*/
@Data
@TableName("file_chunk")
public class FileChunk {
@TableId(type = IdType.AUTO)
private Long id;
// 文件唯一标识(MD5)
private String fileMd5;
// 分片序号
private Integer chunkIndex;
// 文件原始名字
private String objectName;
// 上传时间
private LocalDateTime createTime;
}
@Data
public class InitUploadRequest {
/**
* 文件MD5哈希值
*/
private String fileMd5;
/**
* 原始文件名
*/
private String filename;
/**
* 文件总大小
*/
private Long fileSize;
/**
* 分片总数
*/
private Integer totalChunks;
/**
* 文件 MIME 类型
*/
private String contentType;
}
@Data
@AllArgsConstructor
public class InitUploadResponse {
/**
* 本次上传 ID
*/
private String uploadId;
/**
* 最终文件对象名
*/
private String objectName;
}
@Data
public class CompleteUploadRequest {
/**
* 上传 ID
*/
private String uploadId;
/**
* 最终文件对象名
*/
private String objectName;
/**
* 分片总数
*/
private Integer totalChunks;
}
工具类
java
/**
* 删除 Minio 中的临时分片文件
* @param uploadId 上传唯一标识
* @param totalChunks 总分片数
*/
private void removeChunks(String uploadId, int totalChunks) throws Exception {
List<DeleteObject> objects = new LinkedList<>();
// 组装所有需要删除的分片
for (int i = 0; i < totalChunks; i++) {
objects.add(new DeleteObject(getChunkObjectName(uploadId, i)));
}
// 批量删除
Iterable<Result<DeleteError>> results = minioClient.removeObjects(
RemoveObjectsArgs.builder()
.bucket(bucket)
.objects(objects)
.build()
);
// 必须遍历结果,Minio才会真正执行删除
for (Result<DeleteError> result : results) {
DeleteError error = result.get();
System.err.println("删除分片失败: " + error.objectName() + ", " + error.message());
}
}
/**
* 获取 Minio 文件的预签名访问地址(有时效性,安全)
* @param objectName 文件路径
* @return 可直接访问的URL
*/
private String getPresignedUrl(String objectName) throws Exception {
return minioClient.getPresignedObjectUrl(
GetPresignedObjectUrlArgs.builder()
.bucket(bucket)
.object(objectName)
.method(Method.GET)
.expiry(60 * 60) // 有效期1小时
.build()
);
}
/**
* 生成分片在 Minio 中的临时对象名
* @param uploadId 上传唯一标识
* @param chunkIndex 分片序号
* @return 临时路径
*/
private String getChunkObjectName(String uploadId, Integer chunkIndex) {
return "temp/chunks/" + uploadId + "/" + chunkIndex;
}
/**
* 安全获取文件后缀名
* @param filename 原始文件名
* @return 后缀(如 .mp4)
*/
private String getSuffix(String filename) {
if (filename == null || filename.isBlank()) {
return "";
}
// URL编码防止中文/特殊字符问题
String safeName = URLEncoder.encode(filename, StandardCharsets.UTF_8);
int index = safeName.lastIndexOf(".");
if (index == -1) {
return "";
}
return safeName.substring(index);
}