基于SpringBoot实现大文件分片上传

1、前言

在项目开发遇到大文件,比如视频之类的文件,直接上传速度很慢,而且受限于服务器带宽,用户体验很不好,所以需要优化,常见的解决方案就是分片上传。

2、环境

SpringBoot 3.3.5 +JDK 17 +MinIO

3、实现

3.1 后端分片

开始做分片时,首先考虑在服务端做分片看看能否提升整体文件上传速度。首先说结论,不能,甚至可能比直接上传大文件还慢。所以大文件分片上传必须要前端配合,只在后端实现不行,但是还是说一下思路:首先通过接口拿到文件,然后对文件进行分片,然后上传,最后再合并,即使借助线程池,实现同时上传多个分片,效率还是不行,主要原因是,分片还有合并IO耗时太长。

3.2 前端分片,后端合并

主要流程:前端拿到文件,然后分片,并行调用上传接口(这个是重点),所有分片上传完成,调用合并接口,得到完整文件的url。分片上传效率高的核心就是:浏览器并行调用分片文件的上传接口,因为是并行,所以节省了大量时间。

完整功能时序图:

image-20260221125510029

断点续传:前端每次上传文件前,先调用检查接口,看看是否已上传部分的分片,只需要上传缺失的分片就可以了,这样即使因为网络或者其他什么原因导致没有上传功能,下次可以重新上传。

文件合并:有两种实现方式,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);
    }
}
}