基于SpringBoot实现大文件分片上传
1、前言
在项目开发遇到大文件,比如视频之类的文件,直接上传速度很慢,而且受限于服务器带宽,用户体验很不好,所以需要优化,常见的解决方案就是分片上传。
2、环境
SpringBoot 3.3.5 +JDK 17 +MinIO
3、实现
3.1 后端分片
开始做分片时,首先考虑在服务端做分片看看能否提升整体文件上传速度。首先说结论,不能,甚至可能比直接上传大文件还慢。所以大文件分片上传必须要前端配合,只在后端实现不行,但是还是说一下思路:首先通过接口拿到文件,然后对文件进行分片,然后上传,最后再合并,即使借助线程池,实现同时上传多个分片,效率还是不行,主要原因是,分片还有合并IO耗时太长。
3.2 前端分片,后端合并
主要流程:前端拿到文件,然后分片,并行调用上传接口(这个是重点),所有分片上传完成,调用合并接口,得到完整文件的url。分片上传效率高的核心就是:浏览器并行调用分片文件的上传接口,因为是并行,所以节省了大量时间。
完整功能时序图:

断点续传:前端每次上传文件前,先调用检查接口,看看是否已上传部分的分片,只需要上传缺失的分片就可以了,这样即使因为网络或者其他什么原因导致没有上传功能,下次可以重新上传。
文件合并:有两种实现方式,1、将分隔好的小文件上传到MinIO,直接使用MinIO的api,在MinIO中实现文件合并;2、将分隔好的小文件上传到服务器,在服务器使用javaIO将文件合并,最后再上传到MinIO。两种方式都可以。
4、扩展(秒传功能)
在实现文件成功上传后,可以将已上传的文件地址持久化到数据库或者放到缓存中,用文件MD5值和url作为唯一关联,这样每次上传文件前,可以先查询是否已上传文件,有则直接返回url,这就是秒传的功能。
5、代码实现
5.1minio引入依赖
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.2.1</version>
</dependency>
5.2 minio上传工具相关代码
package com.gotion.common.config;
import io.minio.MinioClient;
import okhttp3.ConnectionPool;
import okhttp3.OkHttpClient;
import org.apache.logging.log4j.util.Strings;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.TimeUnit;
/**
* Minio 配置信息
*
*/
@Configuration
@ConfigurationProperties(prefix = "minio")
public class MinioConfig {
/**
* 服务地址
*/
private static String url;
/**
* 用户名
*/
private static String accessKey;
/**
* 密码
*/
private static String secretKey;
/**
* 存储桶名称
*/
private static String bucketName;
/**
* 域名地址
*/
private static String domainName;
/**
* 替换域名地址
*/
private static String repalceDomainName;
/**
* 替换IP地址
*/
private static String replaceIpName= Strings.EMPTY;
public static String getUrl()
{
return url;
}
public void setUrl(String url)
{
MinioConfig.url = url;
}
public static String getAccessKey()
{
return accessKey;
}
public void setAccessKey(String accessKey)
{
MinioConfig.accessKey = accessKey;
}
public static String getSecretKey()
{
return secretKey;
}
public void setSecretKey(String secretKey)
{
MinioConfig.secretKey = secretKey;
}
public static String getBucketName()
{
return bucketName;
}
public void setBucketName(String bucketName)
{
MinioConfig.bucketName = bucketName;
}
public static String getDomainName() {
return domainName;
}
public void setDomainName(String domainName) {
MinioConfig.domainName = domainName;
}
public static String getRepalceDomainName() {
return repalceDomainName;
}
public void setRepalceDomainName(String repalceDomainName) {
MinioConfig.repalceDomainName = repalceDomainName;
}
@Bean
public MinioClient getMinioClient()
{
//return MinioClient.builder().endpoint(url).credentials(accessKey, secretKey).build();
OkHttpClient httpClient = new OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.writeTimeout(300, TimeUnit.SECONDS) // 上传超时
.readTimeout(300, TimeUnit.SECONDS) // 读取超时
.connectionPool(new ConnectionPool(50, 30, TimeUnit.MINUTES)) // 连接池优化
.build();
return MinioClient.builder()
.endpoint(url)
.credentials(accessKey, secretKey)
.httpClient(httpClient)
.build();
}
public static String getReplaceIpName() {
return replaceIpName;
}
public void setReplaceIpName(String replaceIpName) {
MinioConfig.replaceIpName = replaceIpName;
}
}
package com.gotion.common.utils;
import com.gotion.common.utils.spring.SpringUtils;
import io.minio.*;
import io.minio.http.Method;
import io.minio.messages.DeleteError;
import io.minio.messages.DeleteObject;
import io.minio.messages.Item;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
@Slf4j
public class MinioUtil {
/**
* 上传文件
*
* @param bucketName 桶名称
* @param fileName
* @throws IOException
*/
public static String uploadFile(String bucketName, String fileName, MultipartFile multipartFile) throws IOException
{
String url = "";
MinioClient minioClient = SpringUtils.getBean(MinioClient.class);
try (InputStream inputStream = multipartFile.getInputStream())
{
minioClient.putObject(PutObjectArgs.builder().bucket(bucketName).object(fileName).stream(inputStream, multipartFile.getSize(), -1).contentType(multipartFile.getContentType()).build());
url = minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder().bucket(bucketName).object(fileName).method(Method.GET).build());
url = url.substring(0, url.indexOf('?'));
return ServletUtils.urlDecode(url);
}
catch (Exception e)
{
throw new IOException(e.getMessage(), e);
}
}
/**
* 删除文件
*
* @param bucketName 桶名称
* @param filePath 删除文件夹
* @throws IOException
*/
public static void deleteFiles(String bucketName, String filePath) {
try {
MinioClient minioClient = SpringUtils.getBean(MinioClient.class);
Iterable<Result<Item>> fileList = minioClient.listObjects(ListObjectsArgs.builder().bucket(bucketName).prefix(filePath).recursive(false).build());
List<DeleteObject> list = new ArrayList<>();
for (Result<Item> item : fileList) {
list.add(new DeleteObject(item.get().objectName()));
}
Iterable<Result<DeleteError>> iterable = minioClient.removeObjects(RemoveObjectsArgs.builder().bucket(bucketName).objects(list).build());
for (Result<DeleteError> result : iterable) {
DeleteError error = result.get();
log.info("minio删除错误->bucketName={},objectName={},message={}", error.bucketName(), error.objectName(), error.message());
}
} catch (Exception e) {
log.error("批量删除点检照片异常", e);
throw new RuntimeException("批量删除点检照片异常");
}
}
/**
* 批量删除文件
*
* @param bucketName 桶名称
* @param objectNames 删除文件
*
*/
public static void deleteFiles(String bucketName, List<String> objectNames) {
try {
MinioClient minioClient = SpringUtils.getBean(MinioClient.class);
List<DeleteObject> objects = objectNames.stream()
.map(DeleteObject::new)
.collect(Collectors.toList());
Iterable<Result<DeleteError>> results = minioClient.removeObjects(
RemoveObjectsArgs.builder()
.bucket(bucketName)
.objects(objects)
.build()
);
// 处理删除错误
for (Result<DeleteError> result : results) {
DeleteError error = result.get();
log.error("删除文件失败: {}", error.objectName());
}
} catch (Exception e) {
throw new RuntimeException("批量删除文件失败", e);
}
}
}
package com.gotion.bbs.util;
import com.gotion.common.config.MinioConfig;
import com.gotion.common.config.RuoYiConfig;
import com.gotion.common.exception.file.FileNameLengthLimitExceededException;
import com.gotion.common.exception.file.FileSizeLimitExceededException;
import com.gotion.common.exception.file.InvalidExtensionException;
import com.gotion.common.utils.DateUtils;
import com.gotion.common.utils.MinioUtil;
import com.gotion.common.utils.StringUtils;
import com.gotion.common.utils.file.MimeTypeUtils;
import com.gotion.common.utils.uuid.Seq;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FilenameUtils;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
import java.util.Objects;
import static com.gotion.common.config.MinioConfig.getDomainName;
/**
* 论坛文件上传工具类
*
*
*/
@Slf4j
public class BBSFileUploadUtils
{
/**
* 默认大小 55M
*/
public static final long DEFAULT_MAX_SIZE = 55 * 1024 * 1024L;
/**
* 默认的文件名最大长度 100
*/
public static final int DEFAULT_FILE_NAME_LENGTH = 100;
/**
* http头
*/
public static final String HTTP_URL = "http://";
/**
* 默认上传的地址
*/
private static String defaultBaseDir = RuoYiConfig.getProfile();
public static void setDefaultBaseDir(String defaultBaseDir)
{
BBSFileUploadUtils.defaultBaseDir = defaultBaseDir;
}
public static String getDefaultBaseDir()
{
return defaultBaseDir;
}
/**
* Minio默认上传的地址
*/
private static final String bucketName = MinioConfig.getBucketName();
public static String getBucketName() { return bucketName; }
/**
* 编码文件名
*
* @param file file
* @param folder folder
* @return String
*/
public static String extractFilename(MultipartFile file, String folder) {
if (folder != null) {
return StringUtils.format("{}/{}/{}_{}.{}", folder, DateUtils.datePath(),
FilenameUtils.getBaseName(file.getOriginalFilename()), Seq.getId(Seq.uploadSeqType), getExtension(file));
}
return StringUtils.format("{}/{}_{}.{}", DateUtils.datePath(),
FilenameUtils.getBaseName(file.getOriginalFilename()), Seq.getId(Seq.uploadSeqType), getExtension(file));
}
/**
* 文件大小校验
*
* @param file 上传的文件
* @throws FileSizeLimitExceededException 如果超出最大大小
* @throws InvalidExtensionException 文件类型校验异常
*/
public static void assertAllowed(MultipartFile file, String[] allowedExtension)
throws FileSizeLimitExceededException, InvalidExtensionException
{
long size = file.getSize();
if (size > DEFAULT_MAX_SIZE)
{
throw new FileSizeLimitExceededException(DEFAULT_MAX_SIZE / 1024 / 1024);
}
String fileName = file.getOriginalFilename();
String extension = getExtension(file);
if (allowedExtension != null && !isAllowedExtension(extension, allowedExtension))
{
if (allowedExtension == MimeTypeUtils.IMAGE_EXTENSION)
{
throw new InvalidExtensionException.InvalidImageExtensionException(allowedExtension, extension,
fileName);
}
else if (allowedExtension == MimeTypeUtils.FLASH_EXTENSION)
{
throw new InvalidExtensionException.InvalidFlashExtensionException(allowedExtension, extension,
fileName);
}
else if (allowedExtension == MimeTypeUtils.MEDIA_EXTENSION)
{
throw new InvalidExtensionException.InvalidMediaExtensionException(allowedExtension, extension,
fileName);
}
else if (allowedExtension == MimeTypeUtils.VIDEO_EXTENSION)
{
throw new InvalidExtensionException.InvalidVideoExtensionException(allowedExtension, extension,
fileName);
}
else
{
throw new InvalidExtensionException(allowedExtension, extension, fileName);
}
}
}
/**
* 判断MIME类型是否是允许的MIME类型
*
* @param extension 扩展名
* @param allowedExtension 允许的MIME类型
* @return 是否允许
*/
public static boolean isAllowedExtension(String extension, String[] allowedExtension)
{
for (String str : allowedExtension)
{
if (str.equalsIgnoreCase(extension))
{
return true;
}
}
return false;
}
/**
* 获取文件名的后缀
*
* @param file 表单文件
* @return 后缀名
*/
public static String getExtension(MultipartFile file)
{
String extension = FilenameUtils.getExtension(file.getOriginalFilename());
if (StringUtils.isEmpty(extension))
{
extension = MimeTypeUtils.getExtension(Objects.requireNonNull(file.getContentType()));
}
return extension;
}
/**
* 分文件夹上传到Minio服务器
*
* @param file 上传的文件
* @param folder 文件夹名称
* @param allowedExtension 允许的扩展名
* @return 文件名称
* @throws IOException 异常
*/
public static String uploadMinio(MultipartFile file, String folder, String[] allowedExtension) throws IOException {
try {
String path = uploadMinio(getBucketName(), file, allowedExtension, folder);
// 替换ip和端口的访问方式改成域名
String fileName = getDomainName() + path.substring(path.indexOf("/", HTTP_URL.length()));
// 处理可能存在的栓斜杠
return fixDoubleSlashes(fileName);
} catch (Exception e) {
throw new IOException(e.getMessage(), e);
}
}
public static String uploadMinio(String bucketName, MultipartFile file, String[] allowedExtension, String folder)
throws FileSizeLimitExceededException, IOException, FileNameLengthLimitExceededException,
InvalidExtensionException
{
int fileNameLength = Objects.requireNonNull(file.getOriginalFilename()).length();
if (fileNameLength > BBSFileUploadUtils.DEFAULT_FILE_NAME_LENGTH)
{
throw new FileNameLengthLimitExceededException(BBSFileUploadUtils.DEFAULT_FILE_NAME_LENGTH);
}
assertAllowed(file, allowedExtension);
try
{
String fileName = extractFilename(file, folder);
return MinioUtil.uploadFile(bucketName, fileName, file);
}
catch (Exception e)
{
throw new IOException(e.getMessage(), e);
}
}
/**
* 分文件夹上传到Minio服务器
*
* @param file 上传的文件
* @param folder 文件夹名称
* @return 文件名称
* @throws IOException 异常
*/
public static String uploadMinio(MultipartFile file, String folder) throws IOException {
try {
String path = uploadMinio(getBucketName(), file, MimeTypeUtils.DEFAULT_ALLOWED_EXTENSION, folder);
// 替换ip和端口的访问方式改成域名
String fileName = getDomainName() + path.substring(path.indexOf("/", HTTP_URL.length()));
// 处理可能存在的栓斜杠
return fixDoubleSlashes(fileName);
} catch (Exception e) {
throw new IOException(e.getMessage(), e);
}
}
public static String fixDoubleSlashes(String url) {
if (url.startsWith("http://")) {
String protocol = "http://";
String rest = url.substring(7); // 移除"http://"
rest = rest.replace("//", "/");
return protocol + rest;
}
if (url.startsWith("https://")) {
String protocol = "https://";
String rest = url.substring(8); // 移除"http://"
rest = rest.replace("//", "/");
return protocol + rest;
}
return url.replace("//", "/");
}
/**
* 删除MinIO中的文件夹(文件夹下的文件)
*
* @param filePath 文件名(包含路径)
* @throws IOException 异常
*/
public static void deleteMinio(String filePath) throws IOException {
try {
// 从URL中提取实际的文件路径
String fileFolder = extractFilePathFromUrl(filePath);
MinioUtil.deleteFiles(getBucketName(), fileFolder);
} catch (Exception e) {
throw new IOException("删除文件失败: " + e.getMessage(), e);
}
}
/**
* 批量删除MinIO中的文件
* @param filePath 文件名(包含路径)
* @throws IOException 异常
*/
public static void deleteFilesBatch(List<String> filePath) throws IOException {
try {
// 从URL中提取实际的文件路径
List<String> objectNames = extractFilePathFromUrl(filePath);
MinioUtil.deleteFiles(getBucketName(), objectNames);
} catch (Exception e) {
throw new IOException("删除文件失败: " + e.getMessage(), e);
}
}
/**
*
* 批量从URL中提取实际文件路径
* @param filePath 文件路径列表
*
*/
private static List<String> extractFilePathFromUrl(List<String> filePath) {
return filePath.stream()
.map(BBSFileUploadUtils::extractFilePathFromUrl).toList();
}
/**
* 从完整URL中提取文件夹路径
*
* @param fileUrl 完整的文件夹URL
* @return 文件路径
*/
private static String extractFilePathFromUrl(String fileUrl) {
if (StringUtils.isEmpty(fileUrl)) {
throw new IllegalArgumentException("文件URL不能为空");
}
try {
// 移除域名部分,获取相对路径
String domain = getDomainName();
if (StringUtils.isNotEmpty(domain) && fileUrl.startsWith(domain)) {
String path = fileUrl.substring(domain.length());
// 移除可能的前导斜杠
if (path.startsWith("/")) {
path = path.substring(1);
}
// 移除bucketName部分
String bucketName = getBucketName();
if (StringUtils.isNotEmpty(bucketName) && path.startsWith(bucketName + "/")) {
path = path.substring(bucketName.length() + 1);
}
return path;
}
// 如果是完整URL(http://或https://),提取路径部分
if (fileUrl.startsWith("http://") || fileUrl.startsWith("https://")) {
java.net.URL url = new java.net.URL(fileUrl);
String path = url.getPath();
// 移除前导斜杠
if (path.startsWith("/")) {
path = path.substring(1);
}
// 移除bucketName部分
String bucketName = getBucketName();
if (StringUtils.isNotEmpty(bucketName) && path.startsWith(bucketName + "/")) {
path = path.substring(bucketName.length() + 1);
}
return path;
}
// 如果已经是相对路径,移除bucketName前缀
String cleanPath = fileUrl;
if (cleanPath.startsWith("/")) {
cleanPath = cleanPath.substring(1);
}
String bucketName = getBucketName();
if (StringUtils.isNotEmpty(bucketName) && cleanPath.startsWith(bucketName + "/")) {
cleanPath = cleanPath.substring(bucketName.length() + 1);
}
return cleanPath;
} catch (Exception e) {
throw new IllegalArgumentException("无效的文件URL格式: " + fileUrl, e);
}
}
}
5.3 分片上传接口
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import java.util.List;
@Data
public class FileMergeReqVO {
/**
* 文件标识ID
*/
private String fileId;
/**
* 文件名
*/
private String fileName;
/**
* 合并文件列表
*/
@NotEmpty(message = "合并文件列表不允许为空")
private List<String> partNames;
}
package com.gotion.bbs.service.impl;
import com.gotion.bbs.domain.vo.FileMergeReqVO;
import com.gotion.bbs.util.BBSFileUploadUtils;
import com.gotion.common.config.MinioConfig;
import com.gotion.common.utils.DateUtils;
import com.gotion.common.utils.MinioUtil;
import com.gotion.common.utils.ServletUtils;
import com.gotion.common.utils.StringUtils;
import com.gotion.common.utils.file.MimeTypeUtils;
import com.gotion.common.utils.spring.SpringUtils;
import com.gotion.common.utils.uuid.Seq;
import io.minio.*;
import io.minio.errors.*;
import io.minio.http.Method;
import io.minio.messages.DeleteError;
import io.minio.messages.DeleteObject;
import io.minio.messages.Item;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FilenameUtils;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;
import static com.gotion.bbs.util.BBSFileUploadUtils.HTTP_URL;
import static com.gotion.common.config.MinioConfig.getDomainName;
@Slf4j
@Service
public class FileUploadService {
private final String bucketName = MinioConfig.getBucketName();
private static final String FILE_PATH_BBS ="/bbs";
private static final String FILE_PATH_TEMP ="bbs/temp/";
/**
* 创建 MinIO 客户端
*
* @return MinioClient 实例
*/
private MinioClient createMinioClient() {
return SpringUtils.getBean(MinioClient.class);
}
/**
* 如果存储桶不存在,则创建存储桶
*/
public void createBucketIfNotExists() throws IOException, NoSuchAlgorithmException, InvalidKeyException {
MinioClient minioClient = createMinioClient();
try {
boolean found = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
if (!found) {
minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
}
} catch (MinioException e) {
throw new IOException("Error checking or creating bucket: " + e.getMessage(), e);
}
}
/**
* 上传文件分片到MinIO
*
* @param fileId 文件标识符
* @param filePart 文件分片
* @return 分片对象名称
*/
public String uploadFilePart(String fileId, String fileName, MultipartFile filePart, Integer chunkIndex) throws IOException {
// 构建分片对象名称
String objectName = FILE_PATH_TEMP + fileId + "/" + fileName + '-' + chunkIndex;
String url = MinioUtil.uploadFile(bucketName, objectName, filePart);
return BBSFileUploadUtils.fixDoubleSlashes(url);
// MinioClient minioClient = createMinioClient();
// try {
// // 构建分片对象名称
// String objectName = "/bbs/temp/"+fileId + "/" + fileName + '-' + chunkIndex;
// // 设置上传参数
// PutObjectArgs putObjectArgs = PutObjectArgs.builder()
// .bucket(bucketName)
// .object(objectName)
// .stream(filePart.getInputStream(), filePart.getSize(), -1)
// .contentType(filePart.getContentType())
// .build();
// // 上传文件分片
// minioClient.putObject(putObjectArgs);
// return objectName;
// } catch (MinioException e) {
// throw new IOException("Error uploading file part: " + e.getMessage(), e);
// }
}
private String handleUrl(String url) {
url = ServletUtils.urlDecode(url);
url = url.substring(0, url.indexOf('?'));
String newUrl = getDomainName() + url.substring(url.indexOf("/", HTTP_URL.length()));
return BBSFileUploadUtils.fixDoubleSlashes(newUrl);
}
/**
* 合并多个文件分片为一个完整文件
*/
public String mergeFileParts(FileMergeReqVO reqVO) throws IOException, NoSuchAlgorithmException, InvalidKeyException {
MinioClient minioClient = createMinioClient();
ThreadPoolTaskExecutor executor = SpringUtils.getBean("threadPoolTaskExecutor");
try {
// 构建最终文件对象名称
String finalObjectName = extractFilename(reqVO.getFileName(), FILE_PATH_BBS);
// 1. 对分片按序号排序(确保合并顺序正确),minio会跳过最后一个分片的大小校验,如果顺序是乱的,最后一个分片可能会校验失败(最后一个分片总是小于等于分片大小的)
List<String> sortedPartNames = reqVO.getPartNames().stream()
.sorted(Comparator.comparingInt(name -> {
// 提取分片序号(如 "48.mp4-9" → 9)
String indexStr = name.substring(name.lastIndexOf("-") + 1);
return Integer.parseInt(indexStr);
}))
.toList();
// 构建ComposeSource数组
List<ComposeSource> sources = sortedPartNames.stream().map(name ->
ComposeSource.builder().bucket(bucketName).object(FILE_PATH_TEMP + name).build()).toList();
// 设置合并参数
ComposeObjectArgs composeObjectArgs = ComposeObjectArgs.builder()
.bucket(bucketName)
.object(finalObjectName)
.sources(sources)
.build();
log.info("合并分片列表: {}", reqVO.getPartNames());
// 合并文件分片
minioClient.composeObject(composeObjectArgs);
// 生成文件访问URL(去除URL中的签名参数)
String presignedUrl = minioClient.getPresignedObjectUrl(
GetPresignedObjectUrlArgs.builder()
.bucket(bucketName)
.object(finalObjectName)
.method(Method.GET)
.build());
// 异步清理分片(不阻塞主流程)
CompletableFuture.runAsync(() -> cleanupPartsAsync(reqVO.getPartNames(), minioClient), executor);
// 删除合并后的分片
// for (String partName : reqVO.getPartNames()) {
// minioClient.removeObject(RemoveObjectArgs.builder().bucket(bucketName).object(partName).build());
// }
return handleUrl(presignedUrl);
} catch (MinioException e) {
throw new IOException("Error merging file parts: " + e.getMessage(), e);
}
}
/**
* 编码文件名
*
* @param fileName fileName
* @param folder folder
* @return String
*/
public String extractFilename(String fileName, String folder) {
if (folder != null) {
return StringUtils.format("{}/{}/{}_{}.{}", folder, DateUtils.datePath(),
FilenameUtils.getBaseName(fileName), Seq.getId(Seq.uploadSeqType), getExtension(fileName));
}
return StringUtils.format("{}/{}_{}.{}", DateUtils.datePath(),
FilenameUtils.getBaseName(fileName), Seq.getId(Seq.uploadSeqType), getExtension(fileName));
}
/**
* 获取文件名的后缀
*
* @param originalFilename 表单文件名
* @return 后缀名
*/
public String getExtension(String originalFilename)
{
String extension = FilenameUtils.getExtension(originalFilename);
if (StringUtils.isEmpty(extension))
{
extension = MimeTypeUtils.getExtension(Objects.requireNonNull(originalFilename));
}
return extension;
}
/**
* 异步批量清理分片(提高效率)
*/
private void cleanupPartsAsync(List<String> partNames, MinioClient minioClient) {
if (partNames == null || partNames.isEmpty()) return;
try {
// 批量删除分片
List<DeleteObject> objects = partNames.stream()
.map(fileName -> new DeleteObject(FILE_PATH_TEMP + fileName))
.collect(Collectors.toList());
Iterable<Result<DeleteError>> results = minioClient.removeObjects(
RemoveObjectsArgs.builder()
.bucket(bucketName)
.objects(objects)
.build()
);
// 记录删除错误(非阻塞)
for (Result<DeleteError> result : results) {
try {
DeleteError error = result.get();
log.error("分片删除失败: {},原因:{}", error.objectName(), error.message());
} catch (Exception e) {
log.error("处理分片删除结果异常: {}", e.getMessage());
}
}
} catch (Exception e) {
log.error("批量清理分片异常: {}", e.getMessage());
}
}
/**
* 删除指定文件
*
* @param fileName 文件名
*/
public void deleteFile(String fileName) throws IOException, NoSuchAlgorithmException, InvalidKeyException {
MinioClient minioClient = createMinioClient();
try {
// 删除文件
minioClient.removeObject(RemoveObjectArgs.builder().bucket(bucketName).object(fileName).build());
} catch (MinioException e) {
throw new IOException("Error deleting file: " + e.getMessage(), e);
}
}
public List<Integer> uploadFilePartCheck(String fileId) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException {
MinioClient minioClient = createMinioClient();
// MinIO 临时分片存储路径(示例:temp/{fileId}/fileName-chunk)
String prefix = FILE_PATH_TEMP + fileId + "/";
// 查询 MinIO 中已存在的分片
List<Integer> uploadedChunks = new ArrayList<>();
Iterable<Result<Item>> results = minioClient.listObjects(
ListObjectsArgs.builder()
.bucket(bucketName) // 替换为你的桶名
.prefix(prefix)
.build()
);
// 解析分片序号(从 objectName 中提取 fileName-chunk 的数字部分)
for (Result<Item> result : results) {
Item item = result.get();
String objectName = item.objectName();
int chunkIndex = extractChunkIndex(objectName); // 提取分片索引
if (chunkIndex >= 0) {
uploadedChunks.add(chunkIndex);
}
}
return uploadedChunks;
}
private int extractChunkIndex(String objectName) {
try {
String[] parts = objectName.split("-");
return Integer.parseInt(parts[parts.length - 1]);
} catch (Exception e) {
return -1; // 无效分片名
}
}
}
package com.gotion.bbs.controller;
import com.google.common.base.Stopwatch;
import com.gotion.bbs.domain.vo.FileMergeReqVO;
import com.gotion.bbs.service.impl.FileUploadService;
import com.gotion.bbs.util.BBSFileUploadUtils;
import com.gotion.bbs.util.BBSFileUploadUtils2;
import com.gotion.common.core.domain.AjaxResult;
import com.gotion.common.utils.StringUtils;
import com.gotion.common.utils.file.FileUtils;
import com.gotion.framework.config.ServerConfig;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.springframework.http.HttpStatus;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* @author zqg
* @description:
* @date: 2025/8/7 17:42
*/
@Slf4j
@RestController
@RequestMapping("/bbs")
public class BbsFileUploadController {
@Resource
private FileUploadService fileUploadService;
private static final String FILE_PATH_BBS ="/bbs";
@Resource
private ServerConfig serverConfig;
private static final String FILE_DELIMETER = ",";
/**
* 测试上传,后端分片,再上传,最后合并。该方案不行,速度甚至比单独上传大文件都慢。文件分片必须放到前端,然后并行调用分片上传接口
*/
@PostMapping("/uploadLargeFileParallel")
public AjaxResult uploadLargeFileParallel(MultipartFile file) {
try {
// 上传并返回新文件名称
//String fileName = BBSFileUploadUtils2.uploadLargeFile(file,FILE_PATH_BBS+"/test");
String fileName = BBSFileUploadUtils2.uploadLargeFileParallel(file,FILE_PATH_BBS+"/test");
AjaxResult ajax = AjaxResult.success();
ajax.put("url", fileName);
ajax.put("fileName", fileName);
ajax.put("newFileName", FileUtils.getName(fileName));
ajax.put("originalFilename", file.getOriginalFilename());
return ajax;
} catch (Exception e) {
return AjaxResult.error(e.getMessage());
}
}
/**
* 社区文件上传请求(单个)
* 不校验文件格式
*/
@PostMapping("/upload")
public AjaxResult uploadFile(MultipartFile file) {
try {
// 上传并返回新文件名称
log.info("文件上传开始:{},时间戳{}",file.getOriginalFilename(), System.currentTimeMillis());
Stopwatch stopwatch = Stopwatch.createStarted();
String fileName = BBSFileUploadUtils.uploadMinio(file,FILE_PATH_BBS,null);
stopwatch.stop();
log.info("文件上传结束:{},时间戳{},耗时:{}",file.getOriginalFilename(), System.currentTimeMillis(),stopwatch.elapsed(TimeUnit.MILLISECONDS));
AjaxResult ajax = AjaxResult.success();
ajax.put("url", fileName);
ajax.put("fileName", fileName);
ajax.put("newFileName", FileUtils.getName(fileName));
ajax.put("originalFilename", file.getOriginalFilename());
return ajax;
} catch (Exception e) {
return AjaxResult.error(e.getMessage());
}
}
/**
* 社区文件上传请求(多个)
*/
@PostMapping("/uploads")
public AjaxResult uploadFiles(List<MultipartFile> files) {
try {
// 上传文件路径
List<String> urls = new ArrayList<>();
List<String> fileNames = new ArrayList<>();
List<String> newFileNames = new ArrayList<>();
List<String> originalFilenames = new ArrayList<>();
for (MultipartFile file : files) {
// 上传并返回新文件名称
String fileName = BBSFileUploadUtils.uploadMinio(
file,
FILE_PATH_BBS,null
);
String url = serverConfig.getUrl() + fileName;
urls.add(url);
fileNames.add(fileName);
newFileNames.add(FileUtils.getName(fileName));
originalFilenames.add(file.getOriginalFilename());
}
AjaxResult ajax = AjaxResult.success();
ajax.put("urls", StringUtils.join(urls, FILE_DELIMETER));
ajax.put("fileNames", StringUtils.join(fileNames, FILE_DELIMETER));
ajax.put("newFileNames", StringUtils.join(newFileNames, FILE_DELIMETER));
ajax.put("originalFilenames", StringUtils.join(originalFilenames, FILE_DELIMETER));
return ajax;
} catch (Exception e) {
return AjaxResult.error(e.getMessage());
}
}
/**
*
* 删除minio文件夹(文件夹下的所有文件)
* 例如:https://mom.gotion.com.cn:9199/oatest-minio/school-oa/bbs/v1/
*/
@PostMapping("/delete")
public AjaxResult deleteFile(@RequestParam String filePath) {
// 参数校验
if (filePath == null || filePath.trim().isEmpty()) {
return AjaxResult.error("文件名不能为空");
}
// 防止路径遍历攻击
// 安全校验:检查是否为合法的文件路径
if (filePath.contains("../") || filePath.startsWith("/")) {
return AjaxResult.error("文件名不合法");
}
try {
BBSFileUploadUtils.deleteMinio(filePath);
// 记录操作日志
log.info("文件删除成功: {}", filePath);
return AjaxResult.success();
} catch (Exception e) {
// 记录错误日志
log.error("文件删除失败: {}", filePath, e);
return AjaxResult.error("文件删除失败");
}
}
/**
* 批量删除minio文件
* 例如:https://mom.gotion.com.cn:9199/oatest-minio/school-oa/bbs/2025/08/14/1.mp4
*/
@PostMapping("/deleteBatch")
public AjaxResult deleteFiles(@RequestParam List<String> filePath) {
// 参数校验
if (CollectionUtils.isEmpty(filePath)) {
return AjaxResult.error("文件名不能为空");
}
for (String path : filePath) {
// 防止路径遍历攻击
// 安全校验:检查是否为合法的文件路径
if (path.contains("../") || path.startsWith("/")) {
return AjaxResult.error("文件名不合法");
}
}
try {
BBSFileUploadUtils.deleteFilesBatch(filePath);
// 记录操作日志
log.info("文件批量删除成功: {}", filePath);
return AjaxResult.success();
} catch (Exception e) {
// 记录错误日志
log.error("文件批量删除失败: {}", filePath, e);
return AjaxResult.error("文件批量删除失败");
}
}
@GetMapping("/upload/part/check")
public AjaxResult uploadFilePartCheck(String fileId) throws Exception {
log.info("文件上传检查:{}",fileId);
return AjaxResult.success(fileUploadService.uploadFilePartCheck(fileId));
}
/**
* 上传文件分片
*
* @param fileId 文件标识符
* @param filePart 文件分片
* @param chunkIndex 当前分片索引
* @return 响应状态
*/
@PostMapping("/upload/part/{fileId}")
public AjaxResult uploadFilePart(
@PathVariable String fileId,
@RequestParam String fileName,
@RequestParam MultipartFile filePart,
@RequestParam int chunkIndex) {
try {
// 上传文件分片
String objectName = fileUploadService.uploadFilePart(fileId,fileName, filePart, chunkIndex);
AjaxResult ajax = AjaxResult.success();
return ajax.put("url", objectName);
} catch (IOException e) {
return AjaxResult.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "Error uploading file part: " + e.getMessage());
}
}
/**
* 合并文件分片
*
* @param reqVO 参数
* @return 响应状态
*/
@PostMapping("/merge")
public AjaxResult mergeFileParts(@RequestBody @Validated FileMergeReqVO reqVO) {
try {
String url = fileUploadService.mergeFileParts(reqVO);
AjaxResult ajax = AjaxResult.success();
return ajax.put("url", url);
} catch (IOException | NoSuchAlgorithmException | InvalidKeyException e) {
return AjaxResult.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "Error merging file parts: " + e.getMessage());
}
}
}
前端核心组件文件:
<template>
<div class="upload-container">
<!-- 上传区域 -->
<div
class="upload-area"
@click="selectFile"
:class="{ dragging: isDragging }"
>
<input
type="file"
ref="fileInput"
class="file-input"
@change="handleFileChange"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleDrop"
>
<div class="upload-hint">
<el-icon class="upload-icon">
<UploadFilled />
</el-icon>
<p>点击或拖拽文件到此处上传</p>
<p class="text-sm text-gray-500">支持大文件分片上传,自动断点续传(分片大小:5MB)实际会根据文件大小动态调整</p>
</div>
</div>
<!-- 上传进度 -->
<div v-if="uploading" class="progress-card">
<div class="file-info">
<span class="file-name">{{ fileName }}</span>
<span class="file-size">{{ formatFileSize(fileSize) }}</span>
</div>
<!-- 进度条 -->
<el-progress
:percentage="progress"
:status="uploadSuccess ? 'success' : (errorMessage ? 'exception' : '')"
class="progress-bar"
/>
<!-- 进度统计 -->
<div class="progress-stats">
<span>{{ progress }}%</span>
<span>{{ formatFileSize(uploadedSize) }} / {{ formatFileSize(fileSize) }}</span>
</div>
<!-- 操作按钮 -->
<div class="upload-controls">
<el-button
@click="toggleUpload"
size="small"
:icon="paused ? UploadFilled : VideoPause"
type="primary"
>
{{ paused ? '继续' : '暂停' }}
</el-button>
<el-button
@click="cancelUpload"
size="small"
icon="DeleteFilled"
type="danger"
style="margin-left: 8px"
>
取消
</el-button>
</div>
</div>
<!-- 上传成功 -->
<div v-if="uploadSuccess && !uploading " class="result-card success">
<el-icon color="#42b983"><Check/></el-icon>
<span>文件上传成功!</span>
<el-link
:href="fileUrl"
target="_blank"
type="primary"
style="margin-left: 8px"
>
查看文件
</el-link>
</div>
<!-- 上传失败 -->
<div v-if="errorMessage && !uploadSuccess" class="result-card error">
<el-icon color="#f56c6c"><WarningFilled /></el-icon>
<span>{{ errorMessage }}</span>
<el-button
@click="retryUpload"
size="small"
type="primary"
style="margin-left: 8px"
>
重试
</el-button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import {
ElProgress,
ElButton,
ElIcon,
ElLink,
ElMessage
} from 'element-plus'
import { UploadFilled, VideoPause,Check,WarningFilled } from '@element-plus/icons-vue'
import SparkMD5 from 'spark-md5'
import request from '../utils/request'
// 声明自定义事件(传递给父组件)
const emit = defineEmits(['upload-success', 'upload-error'])
// DOM 引用
const fileInput = ref(null)
// 状态管理
const file = ref(null) // 当前选择的文件
const fileName = ref('') // 文件名
const fileSize = ref(0) // 文件大小(字节)
const fileId = ref('') // 文件唯一标识(MD5)
const chunkSize = ref(5 * 1024 * 1024) // 分片大小:5MB
const totalChunks = ref(0) // 总分片数
const uploadedChunks = ref(new Set()) // 已上传分片索引
const chunksToUpload = ref([]) // 待上传分片索引
const uploading = ref(false) // 是否正在上传
const paused = ref(false) // 是否暂停
const progress = ref(0) // 上传进度(%)
const uploadedSize = ref(0) // 已上传大小(字节)
const isDragging = ref(false) // 是否拖拽中
const uploadSuccess = ref(false) // 上传成功
const errorMessage = ref('') // 错误信息
const fileUrl = ref('') // 上传成功后的文件URL
const abortControllers = ref(new Map()) // 取消请求控制器
const currentUploads = ref(0) // 当前并发上传数
const maxConcurrent = ref(6) // 最大并发数
// 初始化:检查 MinIO 存储桶(可选,后端已处理则可删除)
onMounted(() => {
// request.get('/createBucketIfNotExists').catch(err => {
// console.warn('检查存储桶失败:', err)
// })
})
// 选择文件
const selectFile = () => {
fileInput.value.click()
}
// 处理文件选择
const handleFileChange = (e) => {
const selectedFile = e.target.files[0]
if (selectedFile) {
initUpload(selectedFile)
}
// 重置 input,支持重复选择同一文件
fileInput.value.value = ''
}
// 处理拖拽文件
const handleDrop = (e) => {
isDragging.value = false
const droppedFile = e.dataTransfer.files[0]
if (droppedFile) {
initUpload(droppedFile)
}
}
// 初始化上传(重置状态 + 计算文件MD5)
const initUpload = (selectedFile) => {
// 重置所有状态
resetState()
// 设置文件基础信息
file.value = selectedFile
fileName.value = selectedFile.name
fileSize.value = selectedFile.size
// 动态设置分片大小(根据文件大小)
chunkSize.value = calculateChunkSize(fileSize.value)
// 小文件直接完整上传,不计算MD5和分片
if (chunkSize.value === 0 || fileSize.value <= chunkSize.value) {
uploadEntireFile(); // 直接上传整个文件
} else {
calculateFileMD5(); // 大文件走分片逻辑
}
}
// 动态计算分片大小
const calculateChunkSize = (fileSize) => {
const MB = 1024 * 1024
if (fileSize < 10 * MB) {
return fileSize // 小于10MB:不分片(整个文件作为1个分片)
} else if (fileSize < 50 * MB) {
return 5 * MB // 10MB~50MB:分片5MB
} else if (fileSize < 100 * MB) {
return 10 * MB // 50MB~100MB:分片10MB
} else if (fileSize < 500 * MB) {
return 20 * MB // 100MB~500MB:分片20MB
} else {
return 50 * MB // 大于500MB:分片50MB
}
}
// 计算文件MD5(用于唯一标识和断点续传)
const calculateFileMD5 = () => {
const spark = new SparkMD5.ArrayBuffer()
const reader = new FileReader()
const md5ChunkSize = 2 * 1024 * 1024 // 计算MD5的分片大小:2MB
let offset = 0 // 读取偏移量
let loaded = 0 // 已读取大小
// 显示准备中提示
const loadingMsg = ElMessage({
message: '正在准备文件,请稍候...',
type: 'info',
duration: 0
})
// 读取完成回调
reader.onload = (e) => {
spark.append(e.target.result)
loaded += e.target.result.byteLength
// 更新准备进度
const prepareProgress = Math.round((loaded / fileSize.value) * 100)
if (prepareProgress < 100) {
ElMessage.closeAll()
ElMessage({
message: `正在准备文件:${prepareProgress}%`,
type: 'info',
duration: 0
})
}
// 继续读取下一分片
offset += md5ChunkSize
if (offset < fileSize.value) {
readNextMD5Chunk(reader, offset, md5ChunkSize)
} else {
// MD5计算完成
fileId.value = spark.end()
ElMessage.closeAll()
ElMessage.success('文件准备完成,开始上传')
// 计算总分片数
totalChunks.value = Math.ceil(fileSize.value / chunkSize.value)
// 检查已上传分片(断点续传)
checkUploadedChunks()
}
}
// 读取错误回调
reader.onerror = () => {
ElMessage.closeAll()
errorMessage.value = '文件读取失败,请重试'
emit('upload-error', '文件读取失败')
}
// 开始读取第一个分片
readNextMD5Chunk(reader, offset, md5ChunkSize)
}
// 读取下一个分片用于计算MD5
const readNextMD5Chunk = (reader, offset, size) => {
const blob = file.value.slice(offset, offset + size)
reader.readAsArrayBuffer(blob)
}
// 直接上传完整文件(不分片)
const uploadEntireFile = () => {
uploading.value = true;
progress.value = 0;
const formData = new FormData();
formData.append('file', file.value);
//formData.append('fileName', fileName.value);
request({
url: '/bbs/upload', // 新增的后端接口
method: 'POST',
data: formData,
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e) => {
progress.value = Math.round((e.loaded / e.total) * 100);
}
})
.then((response) => {
console.log(response)
uploadSuccess.value = true;
fileUrl.value = response.url; // 假设后端返回文件URL
emit('upload-success', {
name: fileName.value,
url: fileUrl.value,
size: fileSize.value,
fileId: file.value.name + '-' + fileSize.value // 简易唯一标识
});
})
.catch((err) => {
errorMessage.value = '上传失败:' + err.message;
emit('upload-error', errorMessage.value);
})
.finally(() => {
uploading.value = false;
});
};
// 检查已上传分片(断点续传核心)
const checkUploadedChunks = () => {
if (!fileId.value) return
// 调用后端接口查询已上传分片
request.get(`/bbs/upload/part/check?fileId=${fileId.value}`)
.then((uploadedIndexes) => {
// 记录已上传分片
uploadedIndexes.forEach(index => uploadedChunks.value.add(Number(index)))
// 计算已上传大小和进度
uploadedSize.value = uploadedChunks.value.size * chunkSize.value
progress.value = Math.round((uploadedChunks.value.size / totalChunks.value) * 100)
// 生成待上传分片列表
chunksToUpload.value = []
for (let i = 0; i < totalChunks.value; i++) {
if (!uploadedChunks.value.has(i)) {
chunksToUpload.value.push(i)
}
}
// 所有分片已上传:直接合并
if (uploadedChunks.value.size === totalChunks.value) {
mergeChunks()
} else {
// 开始上传
uploading.value = true
startUploading()
}
})
.catch((err) => {
errorMessage.value = '查询已上传分片失败:' + err.message
emit('upload-error', errorMessage.value)
})
}
// 开始并发上传分片
const startUploading = () => {
// if (paused.value) {
// paused.value = false
// }
// 控制并发:不超过 maxConcurrent
while (currentUploads.value < maxConcurrent.value && chunksToUpload.value.length > 0) {
const chunkIndex = chunksToUpload.value.shift()
uploadSingleChunk(chunkIndex)
}
}
// 上传单个分片
const uploadSingleChunk = (chunkIndex) => {
if (!file.value || paused.value) return
currentUploads.value++
// 计算分片起止位置
const start = chunkIndex * chunkSize.value
const end = Math.min(start + chunkSize.value, fileSize.value)
const chunkBlob = file.value.slice(start, end)
// 构建 FormData
const formData = new FormData()
formData.append('filePart', chunkBlob)
formData.append('fileName', fileName.value)
formData.append('chunkIndex', chunkIndex)
//formData.append('totalChunks', totalChunks.value)
// 创建取消控制器
const controller = new AbortController()
abortControllers.value.set(chunkIndex, controller)
// 上传分片
request({
url: `/bbs/upload/part/${fileId.value}`,
method: 'POST',
data: formData,
headers: { 'Content-Type': 'multipart/form-data' },
signal: controller.signal,
// 监听上传进度
onUploadProgress: (progressEvent) => {
const chunkLoaded = progressEvent.loaded
const chunkTotal = progressEvent.total || (end - start)
const chunkProgress = chunkLoaded / chunkTotal
// 计算总进度
const totalLoaded = uploadedSize.value + (chunkProgress * (end - start))
progress.value = Math.round((totalLoaded / fileSize.value) * 100)
}
})
.then(() => {
// 分片上传成功
uploadedChunks.value.add(chunkIndex)
uploadedSize.value += (end - start)
progress.value = Math.round((uploadedChunks.value.size / totalChunks.value) * 100)
// 所有分片上传完成:合并
if (uploadedChunks.value.size === totalChunks.value) {
mergeChunks()
return
}
// 继续上传下一个分片
startUploading()
})
.catch((err) => {
// ✅ 1. 判断是否是取消(暂停)
const isCanceled =
err.name === 'AbortError' ||
err.name === 'CanceledError' ||
err.message?.toLowerCase().includes('canceled') ||
err.message?.toLowerCase().includes('aborted')
// ✅ 2. 暂停状态,需要把分片放回队列
if (isCanceled && paused) {
chunksToUpload.value.unshift(chunkIndex)
}
// ✅ 3. 只有真实错误才提示
if (!isCanceled) {
errorMessage.value = `分片 ${chunkIndex + 1} 上传失败:${err.message}`
ElMessage.error(errorMessage.value)
emit('upload-error', errorMessage.value)
}
// ✅ 4. 静默处理取消操作
})
// .catch((err) => {
// // 排除手动取消的错误
// console.log('错误===》',err.name)
// if (err.name !== 'AbortError') {
// chunksToUpload.value.unshift(chunkIndex) // 重新加入待上传队列
// errorMessage.value = `分片 ${chunkIndex + 1} 上传失败:${err.message}`
// emit('upload-error', errorMessage.value)
// }
// })
.finally(() => {
currentUploads.value--
abortControllers.value.delete(chunkIndex)
//nextTick(startUploading)
})
}
// 合并分片
const mergeChunks = () => {
// 构建合并请求参数
const mergeParams = {
fileId: fileId.value,
fileName: fileName.value,
totalChunks: totalChunks.value,
// 生成已上传分片名称列表(对应后端 FILE_PATH_TEMP 路径)
partNames: Array.from(uploadedChunks.value).map(index =>
`${fileId.value}/${fileName.value}-${index}`
)
}
// 调用后端合并接口
request.post('/bbs/merge', mergeParams)
.then((fileUrlResp) => {
console.log('合并成功===>',fileUrlResp)
// 合并成功
uploadSuccess.value = true
uploading.value = false
fileUrl.value = fileUrlResp.url
ElMessage.closeAll()
ElMessage.success('文件上传成功!')
// 传递成功事件给父组件
emit('upload-success', {
name: fileName.value,
url: fileUrl.value,
size: fileSize.value,
fileId: fileId.value
})
})
.catch((err) => {
errorMessage.value = '文件合并失败:' + err.message
uploading.value = false
emit('upload-error', errorMessage.value)
})
}
// 暂停/继续上传
const toggleUpload = () => {
if (paused.value) {
// 继续上传
paused.value = false
startUploading()
} else {
// 暂停上传:取消所有正在进行的请求
paused.value = true
abortControllers.value.forEach(controller => controller.abort())
abortControllers.value.clear()
currentUploads.value = 0
}
}
// 取消上传
const cancelUpload = () => {
//可以调用后端接口,删除已经上传的部分分片文件
// 取消所有请求
abortControllers.value.forEach(controller => controller.abort())
// 重置状态
resetState()
ElMessage.info('上传已取消')
}
// 重试上传
// const retryUpload = () => {
// errorMessage.value = ''
// if (file.value) {
// checkUploadedChunks()
// }
// }
// 重试上传 - 完整版本
const retryUpload = () => {
errorMessage.value = ''
if (file.value) {
// 显示重试提示
ElMessage.info('正在重新上传...')
// 重置上传状态
resetUploadState()
// 重新计算分片大小
const currentChunkSize = calculateChunkSize(fileSize.value)
chunkSize.value = currentChunkSize
if (currentChunkSize === 0 || fileSize.value <= currentChunkSize) {
// 小文件:直接上传
uploadEntireFile()
} else {
// 大文件:如果已有MD5则直接检查分片,否则重新计算MD5
if (fileId.value) {
checkUploadedChunks()
} else {
calculateFileMD5()
}
}
}
}
// 重置上传状态(保留文件基本信息)
const resetUploadState = () => {
totalChunks.value = 0
uploadedChunks.value.clear()
chunksToUpload.value = []
uploading.value = false
paused.value = false
progress.value = 0
uploadedSize.value = 0
uploadSuccess.value = false
errorMessage.value = ''
fileUrl.value = ''
abortControllers.value.clear()
currentUploads.value = 0
}
// 重置所有状态
const resetState = () => {
file.value = null
fileName.value = ''
fileSize.value = 0
fileId.value = ''
totalChunks.value = 0
uploadedChunks.value.clear()
chunksToUpload.value = []
uploading.value = false
paused.value = false
progress.value = 0
uploadedSize.value = 0
uploadSuccess.value = false
errorMessage.value = ''
fileUrl.value = ''
abortControllers.value.clear()
currentUploads.value = 0
}
// 格式化文件大小(字节 → KB/MB/GB)
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 B'
const k = 1024
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${units[i]}`
}
</script>
<style scoped>
.upload-container {
width: 100%;
}
/* 上传区域 */
.upload-area {
border: 2px dashed #dcdfe6;
border-radius: 8px;
padding: 40px 20px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
background-color: #fff;
}
.upload-area.dragging {
border-color: #42b983;
background-color: rgba(66, 185, 131, 0.05);
}
.file-input {
display: none;
}
.upload-hint {
color: #606266;
}
.upload-icon {
font-size: 48px;
color: #42b983;
margin-bottom: 16px;
}
.text-sm {
font-size: 12px;
margin-top: 8px;
}
.text-gray-500 {
color: #909399;
}
/* 进度卡片 */
.progress-card {
margin-top: 16px;
padding: 16px;
border-radius: 8px;
background-color: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.file-info {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-size: 14px;
}
.file-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 60%;
}
.file-size {
color: #909399;
}
.progress-bar {
margin: 8px 0;
}
.progress-stats {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #909399;
margin-bottom: 12px;
}
/* 结果卡片 */
.result-card {
margin-top: 16px;
padding: 12px 16px;
border-radius: 8px;
display: flex;
align-items: center;
font-size: 14px;
}
.success {
background-color: rgba(66, 185, 131, 0.1);
color: #359e6f;
}
.error {
background-color: rgba(245, 108, 108, 0.1);
color: #e45252;
}
</style>
前端代码demo:zqg2233/vue-study
最后,如果是分片上传到服务器,在服务器上合并成完整文件,可参考下面的工具类:
package com.gotion.system.utils;
import cn.hutool.core.util.IdUtil;
import com.gotion.common.config.MinioConfig;
import com.gotion.common.utils.SecurityUtils;
import com.gotion.common.utils.file.FileUploadUtils;
import com.gotion.common.utils.spring.SpringUtils;
import com.gotion.system.domain.SysFileStorage;
import com.gotion.system.service.ISysFileService;
import io.minio.*;
import io.minio.http.Method;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.multipart.MultipartFile;
import ws.schild.jave.MultimediaObject;
import ws.schild.jave.info.MultimediaInfo;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
/**
* 分片上传工具类
*
*/
@Slf4j
public class ChunkUploadUtils {
/**
* 临时文件存储目录
*/
private static final String CHUNK_TEMP_DIR = System.getProperty("java.io.tmpdir") + "/chunk_upload/";
/**
* 上传文件分片
*
* @param file 文件分片
* @param identifier 文件唯一标识
* @param chunkNumber 分片序号
* @return 是否上传成功
*/
public static boolean uploadChunk(MultipartFile file, String identifier, Integer chunkNumber) {
try {
// 创建临时目录
Path tempDir = Paths.get(CHUNK_TEMP_DIR, identifier);
if (!Files.exists(tempDir)) {
Files.createDirectories(tempDir);
}
// 保存分片文件
Path chunkPath = tempDir.resolve("chunk_" + chunkNumber);
file.transferTo(chunkPath);
return true;
} catch (Exception e) {
log.error("上传文件分片失败", e);
return false;
}
}
/**
* 检查分片是否已上传
*
* @param identifier 文件唯一标识
* @param chunkNumber 分片序号
* @return 是否已上传
*/
public static boolean checkChunk(String identifier, Integer chunkNumber) {
try {
Path chunkPath = Paths.get(CHUNK_TEMP_DIR, identifier, "chunk_" + chunkNumber);
return Files.exists(chunkPath);
} catch (Exception e) {
log.error("检查分片是否存在失败", e);
return false;
}
}
/**
* 合并分片文件
*/
public static SysFileStorage mergeChunks(String identifier, Integer totalChunks, String filename, String folder, ISysFileService iSysFileService) {
Path mergedFilePath = null;
try {
// 创建临时目录
Path tempDir = Paths.get(CHUNK_TEMP_DIR, identifier);
if (!Files.exists(tempDir)) {
Files.createDirectories(tempDir);
}
// 创建合并后的文件路径
String mergedFileName = FileUploadUtils.extractFilename(new MockMultipartFile(filename), folder);
mergedFilePath = Paths.get(CHUNK_TEMP_DIR, identifier, mergedFileName);
if (!Files.exists(mergedFilePath.getParent())) {
Files.createDirectories(mergedFilePath.getParent());
}
// 合并所有分片 - 使用缓冲流提高性能
long totalSize = 0;
try (BufferedOutputStream outputStream = new BufferedOutputStream(
Files.newOutputStream(mergedFilePath,
StandardOpenOption.CREATE,
StandardOpenOption.WRITE,
StandardOpenOption.TRUNCATE_EXISTING))) {
for (int i = 1; i <= totalChunks; i++) {
Path chunkPath = Paths.get(CHUNK_TEMP_DIR, identifier, "chunk_" + i);
if (Files.exists(chunkPath)) {
try (BufferedInputStream chunkStream = new BufferedInputStream(Files.newInputStream(chunkPath))) {
byte[] buffer = new byte[8192]; // 8KB缓冲区
int bytesRead;
while ((bytesRead = chunkStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
totalSize += bytesRead;
}
}
log.info("已合并分片: {}, 大小: {} bytes", chunkPath, Files.size(chunkPath));
} else {
log.error("分片不存在: {}", chunkPath);
throw new RuntimeException("分片不存在: " + chunkPath);
}
}
}
// 强制刷新到磁盘并验证文件
long fileSize = Files.size(mergedFilePath);
log.info("文件合并完成: {}, 大小: {} bytes, 计算大小: {} bytes",
mergedFilePath, fileSize, totalSize);
if (fileSize != totalSize) {
throw new RuntimeException("文件大小不匹配,可能合并过程中出现错误");
}
// 获取文件类型信息
String contentType = Files.probeContentType(mergedFilePath);
if (contentType == null) {
contentType = getContentTypeByFilename(filename);
}
String fileExtension = getFileExtension(filename);
log.info("文件类型: {}, 文件扩展名: {}", contentType, fileExtension);
// === 新增:如果是视频文件,获取视频时长 ===
double duration = 0;
try {
if (isVideoFile(fileExtension)) {
duration = getVideoDurationWithJave(mergedFilePath);
}
log.info("视频时长: {} 秒", duration);
} catch (Exception e) {
log.warn("获取视频时长失败: {}", e.getMessage());
}
// 上传到Minio - 重新打开文件流确保独立性
MinioClient minioClient = SpringUtils.getBean(MinioClient.class);
InputStream uploadInputStream = null;
try {
uploadInputStream = new BufferedInputStream(Files.newInputStream(mergedFilePath));
ObjectWriteResponse response = minioClient.putObject(PutObjectArgs.builder()
.bucket(MinioConfig.getBucketName())
.object(mergedFileName)
.stream(uploadInputStream, fileSize, -1)
.contentType(contentType)
.build());
log.info("文件上传成功: {}, ETag: {}", response.object(), response.etag());
// 生成直接访问URL而不是预签名URL(更稳定)
String baseUrl = MinioConfig.getDomainName(); // 替换为您的MinIO地址
String directUrl = baseUrl + "/" + MinioConfig.getBucketName() + "/" + mergedFileName;
// // 或者使用预签名URL但处理编码问题
// String presignedUrl = minioClient.getPresignedObjectUrl(
// GetPresignedObjectUrlArgs.builder()
// .bucket(MinioConfig.getBucketName())
// .object(mergedFileName)
// .method(Method.GET)
// .expiry(24 * 60 * 60) // 24小时有效期
// .build());
log.info("生成直接访问URL: {}", directUrl);
// log.info("生成预签名URL: {}", presignedUrl);
// 保存文件信息到数据库
SysFileStorage sysFileStorage = new SysFileStorage();
sysFileStorage.setFileName(filename);
sysFileStorage.setFileId(IdUtil.getSnowflakeNextId());
sysFileStorage.setFileUrl(directUrl); // 使用直接URL
sysFileStorage.setFilePath(mergedFileName);
sysFileStorage.setFileSize(fileSize);
sysFileStorage.setCreateBy(SecurityUtils.getUserId() != null ? SecurityUtils.getUserId().toString() : "system");
sysFileStorage.setCreateTime(new Date());
sysFileStorage.setFileType(fileExtension);
sysFileStorage.setDuration(String.valueOf(duration));
sysFileStorage.setContentType(contentType);
sysFileStorage.setStatus("0");
// 保存到数据库
iSysFileService.insertFile(sysFileStorage);
log.info("文件信息保存到数据库成功,文件ID: {}", sysFileStorage.getFileId());
return sysFileStorage;
} finally {
// 确保上传流关闭
if (uploadInputStream != null) {
try {
uploadInputStream.close();
} catch (IOException e) {
log.warn("关闭上传流时发生错误: {}", e.getMessage());
}
}
}
} catch(
Exception e)
{
log.error("合并分片文件失败", e);
// 记录更详细的错误信息
if (mergedFilePath != null && Files.exists(mergedFilePath)) {
try {
log.error("合并后的文件大小: {} bytes", Files.size(mergedFilePath));
} catch (IOException ioException) {
log.error("无法获取合并文件大小");
}
}
return null;
} finally
{
// 清理临时文件
cleanupTempFiles(identifier);
}
}
/**
* 根据文件名获取文件扩展名
*/
private static String getFileExtension(String filename) {
if (filename == null || filename.lastIndexOf(".") == -1) {
return "";
}
return filename.substring(filename.lastIndexOf(".") + 1).toLowerCase();
}
/**
* 根据文件名获取Content-Type
* 当Files.probeContentType无法识别时使用
*/
private static String getContentTypeByFilename(String filename) {
String extension = getFileExtension(filename);
Map<String, String> contentTypeMap = new HashMap<>();
// 常见文件类型的MIME类型映射
contentTypeMap.put("txt", "text/plain");
contentTypeMap.put("pdf", "application/pdf");
contentTypeMap.put("doc", "application/msword");
contentTypeMap.put("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document");
contentTypeMap.put("xls", "application/vnd.ms-excel");
contentTypeMap.put("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
contentTypeMap.put("ppt", "application/vnd.ms-powerpoint");
contentTypeMap.put("pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation");
contentTypeMap.put("jpg", "image/jpeg");
contentTypeMap.put("jpeg", "image/jpeg");
contentTypeMap.put("png", "image/png");
contentTypeMap.put("gif", "image/gif");
contentTypeMap.put("bmp", "image/bmp");
contentTypeMap.put("mp4", "video/mp4");
contentTypeMap.put("avi", "video/x-msvideo");
contentTypeMap.put("mov", "video/quicktime");
contentTypeMap.put("zip", "application/zip");
contentTypeMap.put("rar", "application/x-rar-compressed");
contentTypeMap.put("js", "application/javascript");
contentTypeMap.put("css", "text/css");
contentTypeMap.put("html", "text/html");
contentTypeMap.put("json", "application/json");
contentTypeMap.put("xml", "application/xml");
return contentTypeMap.getOrDefault(extension, "application/octet-stream");
}
/**
* 清理临时文件
*
* @param identifier 文件唯一标识
*/
public static void cleanupTempFiles(String identifier) {
try {
Path tempDir = Paths.get(CHUNK_TEMP_DIR, identifier);
if (Files.exists(tempDir)) {
Files.walk(tempDir)
.sorted(java.util.Comparator.reverseOrder())
.map(Path::toFile)
.forEach(java.io.File::delete);
}
} catch (Exception e) {
log.error("清理临时文件失败", e);
}
}
/**
* Mock MultipartFile实现类,用于创建文件名
*/
private static class MockMultipartFile implements MultipartFile {
private final String name;
public MockMultipartFile(String name) {
this.name = name;
}
@Override
public String getName() {
return name;
}
@Override
public String getOriginalFilename() {
return name;
}
@Override
public String getContentType() {
return null;
}
@Override
public boolean isEmpty() {
return false;
}
@Override
public long getSize() {
return 0;
}
@Override
public byte[] getBytes() throws IOException {
return new byte[0];
}
@Override
public InputStream getInputStream() throws IOException {
return null;
}
@Override
public void transferTo(Path dest) throws IOException, IllegalStateException {
}
@Override
public void transferTo(java.io.File dest) throws IOException, IllegalStateException {
}
}
public static String getFileExtensionByName(String filename) {
if (filename == null || filename.isEmpty()) {
return null;
}
int lastDotIndex = filename.lastIndexOf('.');
if (lastDotIndex > 0 && lastDotIndex < filename.length() - 1) {
return filename.substring(lastDotIndex + 1);
}
return null;
}
/**
* 判断是否为视频文件
*/
private static boolean isVideoFile(String fileExtension) {
if (fileExtension == null) return false;
Set<String> videoExtensions = Set.of(
"mp4", "avi", "mov", "wmv", "flv", "mkv", "webm",
"m4v", "3gp", "mpg", "mpeg", "ts", "mts"
);
return videoExtensions.contains(fileExtension.toLowerCase());
}
/**
* 使用JAVE2获取视频时长
*/
private static Double getVideoDurationWithJave(Path videoPath) {
try {
File source = videoPath.toFile();
MultimediaObject multimediaObject = new MultimediaObject(source);
MultimediaInfo info = multimediaObject.getInfo();
// 获取时长(毫秒)
long durationInMillis = info.getDuration();
// 转换为秒
return durationInMillis / 1000.0;
} catch (Exception e) {
throw new RuntimeException("获取视频时长失败: " + e.getMessage(), e);
}
}
}