大文件上传下载处理方案-断点续传,秒传,分片,合并

1.断点续传介绍

通常一些文件如视频文件体积都比较大,对于这些文件的上传需求要满足大文件的上传要求。http协议本身对上传文件大小没有限制,但是客户的网络环境质量、电脑硬件环境等参差不齐,如果一个大文件快上传完了网断了没有上传完成,需要客户重新上传,用户体验非常差,所以对于大文件上传的要求最基本的是断点续传。

什么是断点续传:

引用百度百科:断点续传指的是在下载或上传时,将下载或上传任务(一个文件或一个压缩包)人为的划分为几个部分,每一个部分采用一个线程进行上传或下载,如果碰到网络故障,可以从已经上传或下载的部分开始继续上传下载未完成的部分,而没有必要从头开始上传下载,断点续传可以提高节省操作时间,提高用户体验性。

流程如下:

1、前端上传前先把文件分成块

2、一块一块的上传,上传中断后重新上传,已上传的分块则不用再上传

3、各分块上传完成最后在服务端合并文件

2.MinIO大文件分块合并思路

2.1 整体思路

1、前端对文件进行分块。

2、前端上传分块文件前请求媒资服务检查文件是否存在,如果已经存在则不再上传。

3、如果分块文件不存在则前端开始上传

4、前端请求媒资服务上传分块。

5、媒资服务将分块上传至MinIO。

6、前端将分块上传完毕请求媒资服务合并分块。

7、媒资服务判断分块上传完成则请求MinIO合并文件。

8、合并完成校验合并后的文件是否完整,如果完整则上传完成,否则删除文件

为了更好的理解文件分块上传的原理,下边用java代码测试文件的分块与合并。

2.2 文件分块

文件分块的流程如下:

1、获取源文件长度

2、根据设定的分块文件的大小计算出块数

3、从源文件读数据依次向每一个块文件写数据。

本地将大文件分片:

java 复制代码
    //本地大文件切片
    @Test
    public void splitLocalFile() throws Exception {
       //源大文件
       File sourceFile=new File("D:\\dOWN\\ttt.zip");

       //计算分块数量
        long fileTotalSize=sourceFile.length();
        long chunkSize=1024*1024*5; //每块5MB
        long chunkNum= (long) Math.ceil(fileTotalSize*1.0/chunkSize);

        try (RandomAccessFile rafRead = new RandomAccessFile(sourceFile, "r")) {
            // 1MB缓冲区(平衡IO次数和内存占用)
            byte[] buffer = new byte[1024 * 1024];
            // 遍历生成每个分块
            for (int i = 0; i < chunkNum; i++) {
                // 分块文件命名规则:数字(保证排序有序)
                File chunkFile = new File("F:\\外接项目\\E-Learn项目重构\\demo\\backend\\E-Learn-Backend\\doc\\testBigFile\\" + i);
                // 创建空分块文件
                boolean createSuccess = chunkFile.createNewFile();
                if (!createSuccess) {
                    throw new IOException("分块文件创建失败:" + chunkFile.getAbsolutePath());
                }
                // 写入分块数据(try-with-resources自动关闭写流)
                try (RandomAccessFile rafWrite = new RandomAccessFile(chunkFile, "rw")) {
                    int readLen; // 单次读取的字节数
                    // 循环读取源文件数据,写入分块文件
                    while ((readLen = rafRead.read(buffer)) != -1) {
                        rafWrite.write(buffer, 0, readLen);
                        // 分块文件大小达到阈值则停止写入(避免单个分块超过5MB)
                        if (chunkFile.length() >= chunkSize) {
                            break;
                        }
                    }
                }

            }
        }
    }

本地分片上传到minio:

java 复制代码
    //将本地分片上传到minio
    @Test
    public void test() throws IOException, ServerException, InsufficientDataException, ErrorResponseException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException {
        minioClient = MinioClient.builder()
                .endpoint("http://127.0.0.1:9000")
                .credentials("c7bbXkpNOJU5wBLw", "6TcSQfRHOPv8ZqaYN9TLMZfsNfqEbrsm")
                .build();
        // 1. 校验本地分块目录有效性
        File chunkFolder=new File("F:\\外接项目\\E-Learn项目重构\\demo\\backend\\E-Learn-Backend\\doc\\testBigFile\\");
        File[] chunkFiles=chunkFolder.listFiles();
        //2.分块文件排序(按文件数字升序排序,保证上传顺序)
        List<File> chunkFileList=Arrays.asList(chunkFiles);
        chunkFileList.sort((f1, f2) -> {
            int num1 = Integer.parseInt(f1.getName());
            int num2 = Integer.parseInt(f2.getName());
            return Integer.compare(num1, num2);
        });
        //3.逐个上传分块到minio
        for (File chunkFile : chunkFileList) {
            // MinIO中分块对象名:前缀+分块编号(如chunk/0)
            String chunkObjectName = "/chunk/" + chunkFile.getName();
            // 构建上传参数
            UploadObjectArgs uploadArgs = UploadObjectArgs.builder()
                    .bucket("test") // 目标存储桶
                    .object(chunkObjectName) // 分块对象名
                    .filename(chunkFile.getAbsolutePath()) // 本地分块路径
                    .build();
            // 执行上传(异常直接抛出)
            minioClient.uploadObject(uploadArgs);
            System.out.println("分块[" + chunkFile.getName() + "]上传成功,MinIO路径:" + chunkObjectName);
        }
        System.out.println("===== 所有分块上传完成 =====");
    }

2.3 分片合并

合并minio中的文件分片并校验其合法性:

java 复制代码
    //minio中合并分片
    @Test
    public void mergeChunksInMinio() throws Exception {
        minioClient = MinioClient.builder()
                .endpoint("http://127.0.0.1:9000")
                .credentials("c7bbXkpNOJU5wBLw", "6TcSQfRHOPv8ZqaYN9TLMZfsNfqEbrsm")
                .build();
        // 1. 查询MinIO中的分块数量(避免硬编码分块数)
        ListObjectsArgs listArgs = ListObjectsArgs.builder()
                .bucket("test")
                .prefix("/chunk/") // 只查询分块前缀下的对象
                .build();
        Iterable<Result<io.minio.messages.Item>> chunkItems = minioClient.listObjects(listArgs);
        long chunkNum = 0;
        for (Result<Item> ignored : chunkItems) {
            chunkNum++;
        }
        if (chunkNum == 0) {
            throw new Exception("MinIO中未查询到分块文件,无法合并");
        }
        System.out.println("===== 合并参数 =====");
        System.out.println("待合并分块数:" + chunkNum);

        // 2. 构建分块源列表(按顺序关联每个分块)
        List<ComposeSource> composeSources = Stream.iterate(0, i -> i + 1)
                .limit(chunkNum) // 限制生成数量为分块总数
                .map(i -> ComposeSource.builder()
                        .bucket("test") // 分块所在桶
                        .object("/chunk/" + i) // 分块对象名
                        .build())
                .collect(Collectors.toList());
        // 3. 构建合并参数并执行合并
        ComposeObjectArgs composeArgs = ComposeObjectArgs.builder()
                .bucket("test") // 合并后文件所在桶
                .object("/merge/mergedFile") // 合并后文件名
                .sources(composeSources) // 分块源列表
                .build();
        minioClient.composeObject(composeArgs);
        System.out.println("===== MinIO分块合并完成 =====");
        // 4. 校验合并文件完整性(对比源文件MD5)
        validateMergeFile();
    }

    /**
     * 辅助方法:校验合并后的文件完整性
     * 逻辑:对比本地源文件和MinIO合并文件的MD5值
     * 异常:直接抛出
     */
    private void validateMergeFile() throws IOException, ServerException, InsufficientDataException, ErrorResponseException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException {
        System.out.println("===== 开始校验合并文件 =====");
        // 1. 下载MinIO合并后的文件到本地临时文件
        File mergeTempFile = new File("F:\\外接项目\\E-Learn项目重构\\demo\\backend\\E-Learn-Backend\\doc\\merres");
        if (mergeTempFile.exists()) {
            mergeTempFile.delete();
        }
        DownloadObjectArgs downloadArgs = DownloadObjectArgs.builder()
                .bucket("test")
                .object("/merge/mergedFile")
                .filename(mergeTempFile.getAbsolutePath())
                .build();
        minioClient.downloadObject(downloadArgs);

        // 2. 计算源文件MD5
        File sourceFile = new File("D:\\dOWN\\ttt.zip");
        String sourceMd5;
        try (FileInputStream sourceIs = new FileInputStream(sourceFile)) {
            sourceMd5 = DigestUtils.md5Hex(sourceIs);
        }

        // 3. 计算合并文件MD5
        String mergeMd5;
        try (FileInputStream mergeIs = new FileInputStream(mergeTempFile)) {
            mergeMd5 = DigestUtils.md5Hex(mergeIs);
        }

        // 4. 对比MD5
        if (sourceMd5.equals(mergeMd5)) {
            System.out.println("文件校验成功!MD5值:" + sourceMd5);
            // 删除临时文件
            mergeTempFile.delete();
        } else {
            throw new IOException("文件校验失败!源文件MD5:" + sourceMd5 + ",合并文件MD5:" + mergeMd5);
        }
        System.out.println("===== 校验完成 =====");
    }

清理MinIO中的文件分片(合并后执行):

java 复制代码
    //清理Minio中的文件分片(合并后执行)
    @Test
    public void cleanChunksInMinio() throws Exception {
        minioClient = MinioClient.builder()
                .endpoint("http://127.0.0.1:9000")
                .credentials("c7bbXkpNOJU5wBLw", "6TcSQfRHOPv8ZqaYN9TLMZfsNfqEbrsm")
                .build();
        // 1. 查询待删除的分块列表
        ListObjectsArgs listArgs = ListObjectsArgs.builder()
                .bucket("test")
                .prefix("/chunk/")
                .build();
        Iterable<Result<Item>> chunkItems = minioClient.listObjects(listArgs);
        // 2. 构建删除对象列表
        List<DeleteObject> deleteObjects = new ArrayList<>();
        for (Result<Item> itemResult : chunkItems) {
            Item item = itemResult.get();
            deleteObjects.add(new DeleteObject(item.objectName()));
        }
        if (deleteObjects.isEmpty()) {
            System.out.println("MinIO中无分块文件需要清理");
            return;
        }
        // 3. 批量删除分块
        System.out.println("===== 开始清理分块 =====");
        RemoveObjectsArgs removeArgs = RemoveObjectsArgs.builder()
                .bucket("test")
                .objects(deleteObjects) // 待删除的分块列表
                .build();
        Iterable<Result<DeleteError>> deleteResults = minioClient.removeObjects(removeArgs);
        // 4. 遍历删除结果(捕获单个分块删除失败的异常)
        for (Result<DeleteError> deleteResult : deleteResults) {
            DeleteError error = deleteResult.get();
            if (error != null) {
                throw new IOException("分块[" + error.objectName() + "]删除失败:" + error.message());
            }
        }

        System.out.println("===== 分块清理完成 =====");
        System.out.println("共删除分块数:" + deleteObjects.size());


    }

3.大文件上传案例演示

3.1 整体思路实现

大文件分片上传方案基于 "前端分片 + 后端合并 + 断点续传 + 秒传" 核心设计,完整流程分为 前端预处理 → 校验阶段 → 分片上传 → 后端合并 → 播放 / 下载 五个核心阶段,前后端分工明确:

  • 前端:负责文件切割、MD5 计算、分片上传(并发 + 断点)、进度回调;
  • 后端:负责分片接收、MinIO 存储、分块校验、合并分块、MD5 验真、在线播放 / 下载支持。

3.2 后端接口设计

3.2.1 整体设计

文件上传:

文件合并:

3.2.2 接口设计

Controller:

java 复制代码
import com.lgh.common.result.Result;
import com.lgh.web.service.VedioService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

/**
 * 视频接口测试
 * @Author GuihaoLv
 */
@RestController
@RequestMapping("/web/vedio")
@Tag(name="视频接口测试",description = "视频接口测试")
@Slf4j
public class VedioController {
    @Autowired
    private VedioService vedioService;

     /**
     * 文件上传前检查
     * @param fileMd5
     * @return
     */
    @PostMapping("/upload/checkFile")
    @Operation(summary = "文件上传前检查")
    public Result<Boolean> checkFile(@RequestParam("fileMd5") String fileMd5) {
        Boolean exists = vedioService.checkFileExists(fileMd5);
        return Result.success(exists);
    }

     /**
     * 分块上传前检查
     * @param fileMd5
     * @param chunk
     * @return
     */
    @PostMapping("/upload/checkChunk")
    @Operation(summary = "文件上传前检查")
    public Result<Boolean> checkChunk(@RequestParam("fileMd5") String fileMd5,
                                      @RequestParam("chunk")int chunk) {
        Boolean exists = vedioService.checkChunkExists(fileMd5,chunk);
        return Result.success(exists);
    }

    /**
     * 上传分块文件
     * @param fileMd5
     * @param chunk
     * @return
     */
    @PostMapping("/upload/uploadChunk")
    @Operation(summary = "传分块文件")
    public Result<Boolean> uploadChunk(@RequestParam("file") MultipartFile file,
                                       @RequestParam("fileMd5") String fileMd5,
                                       @RequestParam("chunk")int chunk) {
        Boolean success = vedioService.uploadChunk(file, fileMd5, chunk);
        return Result.success(success);
    }

     /**
     * 合并分块文件
     * @param fileMd5
     * @param chunkTotal
     * @return
     */
    @PostMapping("/upload/mergeChunk")
    @Operation(summary = "合并分块文件")
    public Result<Boolean> mergeChunk(@RequestParam("fileMd5") String fileMd5,
                                      @RequestParam("fileName") String fileName,
                                      @RequestParam("chunkTotal") int chunkTotal) {
        Boolean success = vedioService.mergeChunk(fileMd5, fileName, chunkTotal);
        return Result.success(success);
    }


}

Service:

java 复制代码
import org.springframework.web.multipart.MultipartFile;

public interface VedioService {

    /**
     * 文件上传前检查
     * @param fileMd5
     * @return
     */
    Boolean checkFileExists(String fileMd5);

     /**
      * 检查分块是否存在
      * @param fileMd5
      * @param chunk
      * @return
      */
    Boolean checkChunkExists(String fileMd5, int chunk);

    /**
     * 检查分块是否存在
     * @param file
     * @param fileMd5
     * @param chunk
     * @return
     */
    Boolean uploadChunk(MultipartFile file, String fileMd5, int chunk);

     /**
      * 合并分块
      * @param fileMd5
      * @param fileName
      * @return
      */
    Boolean mergeChunk(String fileMd5, String fileName, int chunkTotal);

}
java 复制代码
import com.lgh.common.properties.MinIoProperties;
import com.lgh.web.service.VedioService;
import io.minio.*;
import io.minio.errors.MinioException;
import io.minio.messages.DeleteError;
import io.minio.messages.DeleteObject;
import io.minio.messages.Item;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.compress.utils.IOUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.*;
import java.util.List;
import java.util.ArrayList;
import java.util.stream.Collectors;
import java.util.stream.Stream;

/**
 * 视频服务实现类
 * @Author GuihaoLv
 */
@Service
@Slf4j
public class VedioServiceImpl implements VedioService {
    @Autowired
    private MinioClient minioClient;
    @Autowired
    private MinIoProperties minIoProperties;


     /**
     * 文件上传前检查
     * @param fileMd5
     * @return
     */
    public Boolean checkFileExists(String fileMd5) {
        // 1. 构建完整文件在MinIO中的存储路径(与分块目录规则一致)
        String fullFilePath = getFullFilePath(fileMd5);
        try {
            // 2. 检查MinIO中是否存在该对象
            StatObjectArgs statArgs = StatObjectArgs.builder()
                    .bucket(minIoProperties.getBucketName())
                    .object(fullFilePath)
                    .build();
            minioClient.statObject(statArgs);
            log.info("文件已存在,MD5:{},MinIO路径:{}", fileMd5, fullFilePath);
            return true;
        } catch (MinioException e) {
            // MinIO返回对象不存在异常,说明文件未上传
            if (e.getMessage().equals("NoSuchKey")) {
                log.info("文件不存在,MD5:{}", fileMd5);
                return false;
            }
            // 其他异常打印日志,返回false
            log.error("检查文件存在性失败,MD5:{}", fileMd5, e);
            return false;
        } catch (Exception e) {
            log.error("检查文件存在性异常,MD5:{}", fileMd5, e);
            return false;
        }

    }

    /**
     * 检查分块是否已存在
     * 逻辑:拼接分块文件路径,检查MinIO中是否存在该分块对象
     */
    public Boolean checkChunkExists(String fileMd5, int chunk) {
        // 1. 构建分块文件在MinIO中的路径
        String chunkFilePath = getChunkFileFolderPath(fileMd5) + chunk;

        try {
            // 2. 检查分块对象是否存在
            StatObjectArgs statArgs = StatObjectArgs.builder()
                    .bucket(minIoProperties.getBucketName())
                    .object(chunkFilePath)
                    .build();
            minioClient.statObject(statArgs);
            log.info("分块已存在,MD5:{},分块索引:{}", fileMd5, chunk);
            return true;
        } catch (MinioException e) {
            if (e.getMessage().equals("NoSuchKey")) {
                log.info("分块不存在,MD5:{},分块索引:{}", fileMd5, chunk);
                return false;
            }
            log.error("检查分块存在性失败,MD5:{},分块索引:{}", fileMd5, chunk, e);
            return false;
        } catch (Exception e) {
            log.error("检查分块存在性异常,MD5:{},分块索引:{}", fileMd5, chunk, e);
            return false;
        }
    }



    /**
     * 上传分块文件到MinIO
     * 逻辑:MultipartFile转InputStream,上传到分块指定路径
     */
    public Boolean uploadChunk(MultipartFile file, String fileMd5, int chunk) {
        // 1. 校验参数
        if (file.isEmpty()) {
            log.error("上传分块失败,文件为空,MD5:{},分块索引:{}", fileMd5, chunk);
            return false;
        }

        // 2. 构建分块存储路径
        String chunkFilePath = getChunkFileFolderPath(fileMd5) + chunk;

        try (InputStream inputStream = file.getInputStream()) {
            // 3. 上传分块到MinIO
            PutObjectArgs putArgs = PutObjectArgs.builder()
                    .bucket(minIoProperties.getBucketName())
                    .object(chunkFilePath)
                    .stream(inputStream, file.getSize(), -1) // -1表示自动检测文件大小
                    .contentType(file.getContentType())
                    .build();
            minioClient.putObject(putArgs);
            log.info("分块上传成功,MD5:{},分块索引:{},路径:{}", fileMd5, chunk, chunkFilePath);
            return true;
        } catch (Exception e) {
            log.error("分块上传失败,MD5:{},分块索引:{}", fileMd5, chunk, e);
            return false;
        }
    }




    /**
     * 合并分块文件(优化版)
     * 核心逻辑:1. 有序构建分块列表 2. 合并文件 3. MD5校验 4. 清理分块
     * @param fileMd5 文件唯一标识(MD5)
     * @param fileName 原始文件名(含扩展名)
     * @param chunkTotal 分块总数(新增参数:避免遍历MinIO获取分块,提升性能)
     * @return 合并是否成功
     */
    public Boolean mergeChunk(String fileMd5, String fileName, int chunkTotal) {
        // 1. 基础路径构建
        String chunkFileFolderPath = getChunkFileFolderPath(fileMd5); //分块文件存储目录
        String extName = fileName.substring(fileName.lastIndexOf(".")); // 提取文件扩展名
        String mergeFilePath = getFilePathByMd5(fileMd5, extName); // 合并后文件路径
        try {
            // 2. 有序构建分块源列表(参考代码核心逻辑:按索引生成,保证顺序)
            List<ComposeSource> sourceObjectList = Stream.iterate(0, i -> ++i)
                    .limit(chunkTotal)
                    .map(i -> ComposeSource.builder()
                            .bucket(minIoProperties.getBucketName())
                            .object(chunkFileFolderPath.concat(Integer.toString(i)))
                            .build())
                    .collect(Collectors.toList());

            // 3. 执行MinIO分块合并
            ObjectWriteResponse response = minioClient.composeObject(
                    ComposeObjectArgs.builder()
                            .bucket(minIoProperties.getBucketName())
                            .object(mergeFilePath)
                            .sources(sourceObjectList)
                            .build());
            log.info("合并文件成功:{}", mergeFilePath);

            // 4. 下载合并后的文件,进行MD5校验(核心:保证文件完整性)
            File minioFile = downloadFileFromMinIO(minIoProperties.getBucketName(), mergeFilePath);
            if (minioFile == null) {
                log.error("下载合并后文件失败,mergeFilePath:{}", mergeFilePath);
                return false;
            }

            // 5. MD5校验逻辑
            try (InputStream newFileInputStream = new FileInputStream(minioFile)) {
                String md5Hex = DigestUtils.md5Hex(newFileInputStream);
                // 比对MD5,不一致则返回失败
                if (!fileMd5.equals(md5Hex)) {
                    log.error("文件合并校验失败,MD5不一致:原始{},合并后{}", fileMd5, md5Hex);
                    return false;
                }
                // 可选:此处可添加文件大小记录、入库等业务逻辑
                log.info("文件MD5校验通过,MD5:{}", fileMd5);
            }

            // 6. 清理分块文件(参考代码的清理逻辑,增加容错)
            clearChunkFiles(chunkFileFolderPath, chunkTotal);

            // 7. 临时文件删除(finally中兜底)
            return true;
        } catch (Exception e) {
            log.error("合并文件失败,fileMd5:{},异常:{}", fileMd5, e.getMessage(), e);
            return false;
        } finally {
            // 兜底:删除临时文件(若存在)
            File minioFile = new File(System.getProperty("java.io.tmpdir"), "minio.merge");
            if (minioFile.exists()) {
                minioFile.delete();
            }
        }
    }

    /**
     * 从MinIO下载文件到本地临时文件(参考代码的downloadFileFromMinIO)
     * @param bucket 桶名
     * @param objectName 对象路径
     * @return 本地临时文件
     */
    private File downloadFileFromMinIO(String bucket, String objectName) {
        File minioFile = null;
        FileOutputStream outputStream = null;
        try {
            // 从MinIO获取文件流
            InputStream stream = minioClient.getObject(GetObjectArgs.builder()
                    .bucket(bucket)
                    .object(objectName)
                    .build());
            // 创建临时文件
            minioFile = File.createTempFile("minio", ".merge");
            outputStream = new FileOutputStream(minioFile);
            IOUtils.copy(stream, outputStream); // 拷贝流到临时文件
            return minioFile;
        } catch (Exception e) {
            log.error("下载MinIO文件失败,bucket:{},object:{}", bucket, objectName, e);
            return null;
        } finally {
            // 关闭流
            if (outputStream != null) {
                try {
                    outputStream.close();
                } catch (IOException e) {
                    log.error("关闭文件输出流失败", e);
                }
            }
        }
    }

    /**
     * 清除分块文件(参考代码的clearChunkFiles,优化异常处理)
     * @param chunkFileFolderPath 分块文件目录
     * @param chunkTotal 分块总数
     */
    private void clearChunkFiles(String chunkFileFolderPath, int chunkTotal) {
        try {
            List<DeleteObject> deleteObjects = Stream.iterate(0, i -> ++i)
                    .limit(chunkTotal)
                    .map(i -> new DeleteObject(chunkFileFolderPath.concat(Integer.toString(i))))
                    .collect(Collectors.toList());

            RemoveObjectsArgs removeObjectsArgs = RemoveObjectsArgs.builder()
                    .bucket(minIoProperties.getBucketName())
                    .objects(deleteObjects)
                    .build();

            Iterable<Result<DeleteError>> results = minioClient.removeObjects(removeObjectsArgs);
            results.forEach(r -> {
                try {
                    DeleteError deleteError = r.get();
                    if (deleteError != null) {
                        log.error("清除分块文件失败,objectname:{}", deleteError.objectName());
                    }
                } catch (Exception e) {
                    log.error("遍历分块删除结果失败", e);
                }
            });
            log.info("分块文件清理完成,共处理{}个分块", chunkTotal);
        } catch (Exception e) {
            log.error("清除分块文件失败,chunkFileFolderPath:{}", chunkFileFolderPath, e);
        }
    }

    // ------------------- 私有工具方法 -------------------

    /**
     * 构建分块文件存储目录路径
     * 规则:md5前两位拆分目录 + md5 + chunk/
     * 示例:md5=abc123 → a/b/abc123/chunk/
     */
    private String getChunkFileFolderPath(String fileMd5) {
        return fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/chunk/";
    }

    /**
     * 构建完整文件存储路径(不带文件名)
     */
    private String getFullFilePath(String fileMd5) {
        return fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/";
    }

    /**
     * 构建完整文件存储路径(带文件名)
     */
    private String getFullFilePath(String fileMd5, String fileName) {
        return getFullFilePath(fileMd5) + fileName;
    }

    /**
     * 参考代码的getFilePathByMd5:构建合并后文件的完整路径
     * @param fileMd5 文件MD5
     * @param fileExt 文件扩展名(含.)
     * @return 完整路径
     */
    private String getFilePathByMd5(String fileMd5, String fileExt) {
        return fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/" + fileMd5 + fileExt;
    }


}

3.3 前端设计

3.3.1 整体设计

3.3.2 请求及工具类封装

参数类型:

TypeScript 复制代码
// src/types/upload.d.ts
/** 合并分片参数类型 */
export interface MergeChunkParams {
  fileMd5: string;    // 文件MD5
  fileName: string;   // 原始文件名
  chunkTotal: number; // 总分片数
}

/** 上传进度回调类型 */
export type ProgressCallback = (percent: number) => void;

请求函数:

TypeScript 复制代码
// src/api/bigFileApi.ts
import httpInstance from '@/utils/http';
// 导入类型(仅编译时使用,运行时无影响)
import type { MergeChunkParams } from '@/types/upload';

/**
 * 检查文件是否已上传(秒传校验)
 * @param fileMd5 文件MD5
 */
export const checkFileExists = (fileMd5: string) => {
  return httpInstance({
    url: '/web/vedio/upload/checkFile',
    method: 'POST',
    params: { fileMd5 },
  });
};

/**
 * 检查分片是否已上传(断点续传校验)
 * @param fileMd5 文件MD5
 * @param chunk 分片索引
 */
export const checkChunkExists = (fileMd5: string, chunk: number) => {
  return httpInstance({
    url: '/web/vedio/upload/checkChunk',
    method: 'POST',
    params: { fileMd5, chunk },
  });
};

/**
 * 上传分片文件
 * @param formData 分片表单数据
 * @param onProgress 单分片上传进度回调
 */
export const uploadChunk = (formData: FormData, onProgress?: (progress: number) => void) => {
  return httpInstance({
    url: '/web/vedio/upload/uploadChunk',
    method: 'POST',
    data: formData,
    headers: {
      'Content-Type': 'multipart/form-data',
    },
    onUploadProgress: (e) => {
      if (onProgress && e.total) {
        const percent = (e.loaded / e.total) * 100;
        onProgress(percent);
      }
    },
  });
};

/**
 * 合并分片文件
 * @param params 合并参数
 */
export const mergeChunk = (params: MergeChunkParams) => {
  return httpInstance({
    url: '/web/vedio/upload/mergeChunk',
    method: 'POST',
    params: params,
  });
};

/**
 * 普通文件上传(非分片)
 * @param data FormData数据
 */
export const upload = (data: FormData) => {
  return httpInstance({
    url: '/web/commonFile/upload',
    method: 'POST',
    data,
    headers: {
      'Content-Type': 'multipart/form-data',
    },
  });
};

/**
 * 文件下载
 * @param fileName 文件名
 */
export const downloadFile = (fileName: string) => {
  return httpInstance({
    url: '/web/commonFile/download',
    method: 'POST',
    params: { fileName },
    responseType: 'blob',
  });
};

大文件处理工具:

TypeScript 复制代码
// src/utils/bigFileUpload.ts
import SparkMD5 from 'spark-md5';
// 导入请求函数 + 类型(注意:type 关键字仅导入类型)
import {
  checkFileExists,
  checkChunkExists,
  uploadChunk,
  mergeChunk
} from '@/api/bigFileApi';
import type { MergeChunkParams, ProgressCallback } from '@/types/upload';

/**
 * 大文件分片上传核心工具类
 * 依赖 bigFileApi 中的请求函数,专注于上传逻辑封装
 */
export class BigFileUploader {
  // 分片大小(5MB,适配MinIO合并要求)
  private chunkSize: number = 5 * 1024 * 1024;
  // 待上传文件
  private file: File | null = null;
  // 文件MD5
  private fileMd5: string = '';
  // 总分片数
  private chunkTotal: number = 0;
  // 上传进度回调
  private progressCallback: ProgressCallback | null = null;
  // 已上传分片索引
  private uploadedChunks: number[] = [];

  /**
   * 构造函数
   * @param file 待上传文件
   * @param progressCallback 进度回调函数
   */
  constructor(file: File, progressCallback?: ProgressCallback) {
    this.file = file;
    this.progressCallback = progressCallback;
    // 初始化总分片数(向上取整)
    this.chunkTotal = Math.ceil(file.size / this.chunkSize);
  }

  /**
   * 私有方法:计算文件MD5(分片计算,避免大文件卡顿)
   */
  private async calculateFileMd5(): Promise<string> {
    return new Promise((resolve) => {
      if (!this.file) return resolve('');

      const spark = new SparkMD5.ArrayBuffer();
      const reader = new FileReader();
      const chunkSize = this.chunkSize;
      const fileSize = this.file.size;
      let offset = 0;

      // 逐片读取文件计算MD5
      const loadNextChunk = () => {
        const end = Math.min(offset + chunkSize, fileSize);
        const chunk = this.file!.slice(offset, end);
        reader.readAsArrayBuffer(chunk);
        offset = end;
      };

      reader.onload = (e) => {
        spark.append(e.target!.result as ArrayBuffer);
        // 触发MD5计算进度
        this.progressCallback?.((offset / fileSize) * 100);

        if (offset < fileSize) {
          loadNextChunk();
        } else {
          const md5 = spark.end();
          this.fileMd5 = md5;
          resolve(md5);
        }
      };

      loadNextChunk();
    });
  }

  /**
   * 私有方法:检查已上传的分片(断点续传)
   */
  private async checkUploadedChunks(): Promise<void> {
    if (!this.fileMd5) return;
    this.uploadedChunks = [];

    // 遍历所有分片,检查是否已上传
    for (let i = 0; i < this.chunkTotal; i++) {
      const res = await checkChunkExists(this.fileMd5, i);
      if (res.data) { // 后端返回true表示已上传
        this.uploadedChunks.push(i);
      }
    }

    // 计算已上传进度并回调
    const uploadedPercent = (this.uploadedChunks.length / this.chunkTotal) * 100;
    this.progressCallback?.(uploadedPercent);
    console.log(`[断点续传] 已上传分片数:${this.uploadedChunks.length}/${this.chunkTotal}`);
  }

  /**
   * 私有方法:上传单个分片
   * @param chunkIndex 分片索引
   */
  private async uploadSingleChunk(chunkIndex: number): Promise<boolean> {
    if (!this.file || !this.fileMd5) return false;

    // 1. 切割分片文件
    const start = chunkIndex * this.chunkSize;
    const end = Math.min(start + this.chunkSize, this.file.size);
    const chunk = this.file.slice(start, end);

    // 2. 构建FormData
    const formData = new FormData();
    formData.append('file', chunk);
    formData.append('fileMd5', this.fileMd5);
    formData.append('chunk', chunkIndex.toString());

    try {
      // 3. 上传分片(监听单分片进度)
      await uploadChunk(formData, (chunkProgress) => {
        // 计算整体进度 = 已上传分片占比 + 当前分片上传进度占比
        const basePercent = (this.uploadedChunks.length / this.chunkTotal) * 100;
        const currentChunkPercent = (chunkProgress / 100) * (1 / this.chunkTotal) * 100;
        this.progressCallback?.(basePercent + currentChunkPercent);
      });
      return true;
    } catch (error) {
      console.error(`[分片上传失败] 索引:${chunkIndex}`, error);
      return false;
    }
  }

  /**
   * 私有方法:批量上传分片(并发控制)
   * @param concurrency 并发数(默认3)
   */
  private async uploadChunks(concurrency: number = 3): Promise<void> {
    if (!this.fileMd5) return;

    // 筛选需要上传的分片(排除已上传的)
    const needUploadChunks = Array.from({ length: this.chunkTotal }, (_, i) => i)
      .filter(i => !this.uploadedChunks.includes(i));

    if (needUploadChunks.length === 0) {
      this.progressCallback?.(100);
      return;
    }

    // 并发上传控制(避免请求过多)
    let currentIndex = 0;
    let completedChunks = 0;

    const uploadNext = async () => {
      if (currentIndex >= needUploadChunks.length) return;

      const chunkIndex = needUploadChunks[currentIndex];
      currentIndex++;

      // 上传单个分片(失败重试)
      const uploadSuccess = await this.uploadSingleChunk(chunkIndex);
      if (uploadSuccess) {
        completedChunks++;
        // 更新整体进度
        const totalPercent = ((this.uploadedChunks.length + completedChunks) / this.chunkTotal) * 100;
        this.progressCallback?.(totalPercent);
      } else {
        // 失败后重新入队(可限制重试次数)
        currentIndex--;
        console.log(`[分片重试] 索引:${chunkIndex}`);
      }

      // 继续上传下一个
      await uploadNext();
    };

    // 启动指定数量的并发任务
    const tasks = Array.from({ length: concurrency }, uploadNext);
    await Promise.all(tasks);
  }

  /**
   * 私有方法:合并分片文件
   */
  private async mergeFileChunks(): Promise<boolean> {
    if (!this.fileMd5 || !this.file) return false;

    const mergeParams: MergeChunkParams = {
      fileMd5: this.fileMd5,
      fileName: this.file.name,
      chunkTotal: this.chunkTotal,
    };

    try {
      const res = await mergeChunk(mergeParams);
      return res.data;
    } catch (error) {
      console.error('[分片合并失败]', error);
      return false;
    }
  }

  /**
   * 核心方法:执行完整上传流程
   * 流程:计算MD5 → 秒传校验 → 断点续传校验 → 上传分片 → 合并分片
   */
  public async upload(): Promise<boolean> {
    if (!this.file) {
      console.error('[上传失败] 文件为空');
      return false;
    }

    try {
      // 1. 初始化进度
      this.progressCallback?.(0);

      // 2. 计算文件MD5
      console.log('[开始计算MD5] 大文件MD5计算中...');
      await this.calculateFileMd5();
      console.log(`[MD5计算完成] ${this.fileMd5}`);

      // 3. 秒传校验(文件已存在则直接返回成功)
      const fileCheckRes = await checkFileExists(this.fileMd5);
      if (fileCheckRes.data) {
        this.progressCallback?.(100);
        console.log('[秒传成功] 文件已存在,无需上传');
        return true;
      }

      // 4. 断点续传校验(检查已上传分片)
      await this.checkUploadedChunks();

      // 5. 上传剩余分片
      console.log('[开始上传分片] 共需上传:', this.chunkTotal - this.uploadedChunks.length, '个');
      await this.uploadChunks(3);

      // 6. 合并分片
      console.log('[开始合并分片]');
      const mergeSuccess = await this.mergeFileChunks();
      if (mergeSuccess) {
        this.progressCallback?.(100);
        console.log('[上传完成] 文件上传并合并成功');
        return true;
      } else {
        console.error('[上传失败] 分片合并失败');
        return false;
      }
    } catch (error) {
      console.error('[上传异常]', error);
      return false;
    }
  }
}

3.3.3 测试页面

html 复制代码
<template>
  <div class="upload-container">
    <h3>大文件分片上传(Vue3 + TS)</h3>

    <!-- 文件选择 -->
    <input
      type="file"
      ref="fileInput"
      @change="handleFileSelect"
      class="file-input"
    />

    <!-- 上传按钮 -->
    <button
      @click="handleUpload"
      :disabled="!selectedFile || isUploading"
      class="upload-btn"
    >
      {{ isUploading ? '上传中...' : '开始上传' }}
    </button>

    <!-- 进度条 -->
    <div class="progress-container" v-if="selectedFile">
      <div class="progress-bar" :style="{ width: `${uploadPercent}%` }"></div>
      <span class="progress-text">{{ uploadPercent.toFixed(2) }}%</span>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { BigFileUploader } from '@/utils/bigFileUpload';

// 响应式数据
const fileInput = ref<HTMLInputElement | null>(null);
const selectedFile = ref<File | null>(null);
const isUploading = ref<boolean>(false);
const uploadPercent = ref<number>(0);

/**
 * 选择文件回调
 */
const handleFileSelect = (e: Event) => {
  const target = e.target as HTMLInputElement;
  if (target.files && target.files[0]) {
    selectedFile.value = target.files[0];
    uploadPercent.value = 0; // 重置进度
  }
};

/**
 * 执行上传
 */
const handleUpload = async () => {
  if (!selectedFile.value) return;

  isUploading.value = true;
  try {
    // 实例化上传工具,传入进度回调
    const uploader = new BigFileUploader(
      selectedFile.value,
      (percent) => {
        uploadPercent.value = percent;
      }
    );

    // 执行上传
    const success = await uploader.upload();
    if (success) {
      alert('文件上传成功!');
    } else {
      alert('文件上传失败!');
    }
  } catch (error) {
    console.error('[页面上传异常]', error);
    alert('上传出错,请重试!');
  } finally {
    isUploading.value = false;
    // 清空文件选择
    if (fileInput.value) fileInput.value.value = '';
    selectedFile.value = null;
  }
};
</script>

<style scoped>
.upload-container {
  width: 600px;
  margin: 50px auto;
  padding: 20px;
  border: 1px solid #eee;
  border-radius: 8px;
}

.file-input {
  margin-bottom: 20px;
  padding: 8px;
  width: 100%;
}

.upload-btn {
  padding: 10px 24px;
  background: #409eff;
  color: #fff;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
}

.upload-btn:disabled {
  background: #ccc;
  cursor: not-allowed;
}

.progress-container {
  margin-top: 20px;
  height: 24px;
  border: 1px solid #e6e6e6;
  border-radius: 12px;
  overflow: hidden;
  position: relative;
}

.progress-bar {
  height: 100%;
  background: #409eff;
  transition: width 0.3s ease;
}

.progress-text {
  position: absolute;
  top: 0;
  left: 50%;
  transform: translateX(-50%);
  font-size: 12px;
  color: #333;
  line-height: 24px;
}
</style>

4.大文件下载案例演示

分片下载核心是 「前端拆范围、后端读片段、前端合文件」,完全复用上传阶段的 MD5 / 文件路径规则,解决大文件单次下载超时、内存溢出、中断后重传的问题。

4.1 整体思路

后端设计:

前端设计:

4.2 后端实现

controller:

java 复制代码
  /**
     * 大文件分片下载接口(支持Range字节范围请求)
     * @param fileMd5 文件MD5(定位MinIO文件)
     * @param fileName 文件名(含扩展名,用于拼接路径)
     * @param request 获取Range请求头
     * @param response 返回分片流+响应头
     */
    @PostMapping("/download/largeFile")
    @Operation(summary = "大文件分片下载")
    public void downloadLargeFile(@RequestParam("fileMd5") String fileMd5,
                                  @RequestParam("fileName") String fileName,
                                  HttpServletRequest request,
                                  HttpServletResponse response) {
        try {
            // 调用服务层分片下载逻辑
            vedioService.downloadLargeFile(fileMd5, fileName, request, response);
        } catch (Exception e) {
            log.error("大文件分片下载失败,fileMd5:{}", fileMd5, e);
            response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
        }
    }

    /**
     * 辅助接口:获取文件总大小(前端初始化分片下载时调用)
     */
    @PostMapping("/download/getFileSize")
    @Operation(summary = "获取文件总大小")
    public Result<Long> getFileSize(@RequestParam("fileMd5") String fileMd5,
                                    @RequestParam("fileName") String fileName) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException {
        Long res=vedioService.getFileSize(fileMd5, fileName);
        return Result.success(res);
    }

Service:

java 复制代码
    /**
     * 下载大文件
     * @param fileMd5
     * @param fileName
     * @param request
     * @param response
     */
    void downloadLargeFile(String fileMd5, String fileName, HttpServletRequest request, HttpServletResponse response) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException;

    /**
     * 获取文件大小
     * @param fileMd5
     * @param fileName
     */
    Long getFileSize(String fileMd5, String fileName) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException;
java 复制代码
    /**
     * 大文件分片下载核心逻辑
     * 支持Range请求,返回指定字节范围的文件流
     */
    public void downloadLargeFile(String fileMd5, String fileName, HttpServletRequest request, HttpServletResponse response) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException {
        // 1. 构建MinIO中完整文件路径(复用现有路径规则)
        String extName = fileName.substring(fileName.lastIndexOf("."));
        String mergeFilePath = getFilePathByMd5(fileMd5, extName);
        String bucket = minIoProperties.getBucketName();

        // 2. 获取文件元信息(总大小、Content-Type)
        StatObjectArgs statArgs = StatObjectArgs.builder()
                .bucket(bucket)
                .object(mergeFilePath)
                .build();
        StatObjectResponse statResponse = minioClient.statObject(statArgs);
        long fileTotalSize = statResponse.size();
        String contentType = statResponse.contentType();

        // 3. 解析前端Range请求头(格式:Range: bytes=0-4999999)
        String rangeHeader = request.getHeader("Range");
        long start = 0;
        long end = fileTotalSize - 1;

        if (rangeHeader != null && rangeHeader.startsWith("bytes=")) {
            // 拆分Range参数
            String[] rangeParts = rangeHeader.replace("bytes=", "").split("-");
            start = Long.parseLong(rangeParts[0]);
            // 处理结束字节:前端传了则用前端值,否则取文件末尾
            if (rangeParts.length > 1 && !rangeParts[1].isEmpty()) {
                end = Long.parseLong(rangeParts[1]);
            }
            // 校验Range有效性
            if (start < 0 || end >= fileTotalSize || start > end) {
                response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
                response.setHeader("Content-Range", "bytes */" + fileTotalSize);
                return;
            }
            // 响应部分内容(206)
            response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
            response.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + fileTotalSize);
        }

        // 4. 设置下载响应头
        response.setHeader("Content-Type", contentType);
        response.setHeader("Content-Length", String.valueOf(end - start + 1)); // 当前分片大小
        response.setHeader("Accept-Ranges", "bytes"); // 告知前端支持分片下载
        // 触发浏览器下载(指定文件名,解决中文乱码)
        response.setHeader("Content-Disposition", "attachment; filename=\"" +
                URLEncoder.encode(fileName, StandardCharsets.UTF_8.toString()) + "\"");
        response.setHeader("Cache-Control", "no-cache");

        // 5. 从MinIO读取指定字节范围的文件流并返回(核心修正:兼容所有SDK版本)
        InputStream stream = null;
        InputStream fullStream = null;
        try {
            // 第一步:获取完整文件流(放弃SDK的extraHeader,手动处理Range)
            GetObjectArgs getArgs = GetObjectArgs.builder()
                    .bucket(bucket)
                    .object(mergeFilePath)
                    .build();
            fullStream = minioClient.getObject(getArgs);

            // 第二步:手动截取指定字节范围的流(跳过start字节,读取end-start+1字节)
            // 方式1:适合中小文件(<1GB),简单直接
            byte[] fullBytes = IOUtils.toByteArray(fullStream);
            byte[] rangeBytes = new byte[(int) (end - start + 1)];
            System.arraycopy(fullBytes, (int) start, rangeBytes, 0, rangeBytes.length);
            stream = new ByteArrayInputStream(rangeBytes);

            // 6. 流式返回(避免内存溢出)
            IOUtils.copy(stream, response.getOutputStream());
            response.getOutputStream().flush();
        } finally {
            // 兜底关闭流,防止资源泄漏
            if (stream != null) {
                stream.close();
            }
            if (fullStream != null) {
                fullStream.close();
            }
        }
    }


    /**
     *获取文件大小
     * @throws InternalException
     */
    public Long getFileSize(String fileMd5, String fileName) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException {
         String extName = fileName.substring(fileName.lastIndexOf("."));
         String mergeFilePath = getFilePathByMd5(fileMd5, extName);
         StatObjectResponse statResponse = minioClient.statObject(StatObjectArgs.builder()
                 .bucket(minIoProperties.getBucketName())
                 .object(mergeFilePath)
                 .build());
         return statResponse.size();
    }

4.3 前端实现

请求函数:

java 复制代码
/**
 * 获取文件总大小(分片下载前置)
 * @param fileMd5 文件MD5
 * @param fileName 文件名
 */
export const getFileSize = (fileMd5: string, fileName: string) => {
  return httpInstance({
    url: '/web/vedio/download/getFileSize',
    method: 'POST',
    params: { fileMd5, fileName },
  });
};

/**
 * 分片下载文件片段
 * @param fileMd5 文件MD5
 * @param fileName 文件名
 * @param start 起始字节
 * @param end 结束字节
 * @param onProgress 分片下载进度回调
 */
export const downloadFileChunk = (
  fileMd5: string,
  fileName: string,
  start: number,
  end: number,
  onProgress?: (progress: number) => void
) => {
  return httpInstance({
    url: '/web/vedio/download/largeFile',
    method: 'POST',
    params: { fileMd5, fileName },
    headers: {
      'Range': `bytes=${start}-${end}`, // 核心:指定字节范围
    },
    responseType: 'blob', // 接收二进制流
    onDownloadProgress: (e) => {
      if (onProgress && e.total) {
        const percent = (e.loaded / e.total) * 100;
        onProgress(percent);
      }
    },
  });
};

大文件下载工具:

java 复制代码
import { getFileSize, downloadFileChunk } from '@/api/bigFileApi';

/**
 * 大文件分片下载工具类
 * 适配现有上传逻辑的MD5/文件名规则
 */
export class BigFileDownloader {
  // 分片大小(5MB,与上传分片大小一致)
  private chunkSize: number = 5 * 1024 * 1024;
  // 文件MD5
  private fileMd5: string = '';
  // 文件名(含扩展名)
  private fileName: string = '';
  // 文件总大小
  private fileTotalSize: number = 0;
  // 总分片数
  private chunkTotal: number = 0;
  // 下载进度回调
  private progressCallback: ((percent: number) => void) | null = null;
  // 已下载的分片二进制数据
  private downloadedChunks: Blob[] = [];

  /**
   * 构造函数
   * @param fileMd5 文件MD5
   * @param fileName 文件名
   * @param progressCallback 进度回调
   */
  constructor(fileMd5: string, fileName: string, progressCallback?: (percent: number) => void) {
    this.fileMd5 = fileMd5;
    this.fileName = fileName;
    this.progressCallback = progressCallback;
  }

  /**
   * 初始化:获取文件总大小,计算总分片数
   */
  private async init(): Promise<boolean> {
    try {
      const res = await getFileSize(this.fileMd5, this.fileName);
      this.fileTotalSize = res.data;
      this.chunkTotal = Math.ceil(this.fileTotalSize / this.chunkSize);
      this.progressCallback?.(0);
      return true;
    } catch (error) {
      console.error('[下载初始化失败] 获取文件大小失败', error);
      return false;
    }
  }

  /**
   * 下载单个分片
   * @param chunkIndex 分片索引
   */
  private async downloadSingleChunk(chunkIndex: number): Promise<boolean> {
    const start = chunkIndex * this.chunkSize;
    const end = Math.min(start + this.chunkSize - 1, this.fileTotalSize - 1);

    try {
      const res = await downloadFileChunk(
        this.fileMd5,
        this.fileName,
        start,
        end,
        (chunkProgress) => {
          // 计算整体进度:已下载分片占比 + 当前分片下载进度占比
          const basePercent = (this.downloadedChunks.length / this.chunkTotal) * 100;
          const currentChunkPercent = (chunkProgress / 100) * (1 / this.chunkTotal) * 100;
          this.progressCallback?.(basePercent + currentChunkPercent);
        }
      );
      // 保存分片二进制数据
      this.downloadedChunks[chunkIndex] = res.data;
      return true;
    } catch (error) {
      console.error(`[分片下载失败] 索引:${chunkIndex}`, error);
      return false;
    }
  }

  /**
   * 并发下载分片(控制并发数)
   * @param concurrency 并发数(默认3)
   */
  private async downloadChunks(concurrency: number = 3): Promise<boolean> {
    // 生成所有分片索引
    const chunkIndexes = Array.from({ length: this.chunkTotal }, (_, i) => i);
    let currentIndex = 0;
    let completedCount = 0;

    // 递归下载单个分片(失败重试)
    const downloadNext = async () => {
      if (currentIndex >= chunkIndexes.length) return;

      const index = chunkIndexes[currentIndex];
      currentIndex++;

      // 重试3次
      let retryCount = 3;
      let success = false;
      while (retryCount > 0 && !success) {
        success = await this.downloadSingleChunk(index);
        if (!success) {
          retryCount--;
          console.log(`[分片重试] 索引:${index},剩余重试次数:${retryCount}`);
        }
      }

      if (success) {
        completedCount++;
        // 更新整体进度
        const totalPercent = (completedCount / this.chunkTotal) * 100;
        this.progressCallback?.(totalPercent);
      } else {
        throw new Error(`分片${index}下载失败,已达最大重试次数`);
      }

      await downloadNext();
    };

    // 启动并发任务
    const tasks = Array.from({ length: concurrency }, downloadNext);
    try {
      await Promise.all(tasks);
      return true;
    } catch (error) {
      console.error('[分片下载异常]', error);
      return false;
    }
  }

  /**
   * 合并分片并触发下载
   */
  private mergeAndDownload(): void {
    // 合并所有分片为完整Blob
    const completeBlob = new Blob(this.downloadedChunks, {
      type: this.getFileType(this.fileName),
    });

    // 创建下载链接
    const downloadUrl = URL.createObjectURL(completeBlob);
    const a = document.createElement('a');
    a.href = downloadUrl;
    a.download = this.fileName; // 指定下载文件名
    a.click();

    // 释放内存
    URL.revokeObjectURL(downloadUrl);
    this.progressCallback?.(100);
    console.log('[下载完成] 文件已合并并触发下载');
  }

  /**
   * 辅助:根据文件名获取文件类型
   */
  private getFileType(fileName: string): string {
    const ext = fileName.substring(fileName.lastIndexOf('.')).toLowerCase();
    const typeMap: Record<string, string> = {
      '.mp4': 'video/mp4',
      '.avi': 'video/x-msvideo',
      '.mov': 'video/quicktime',
      '.pdf': 'application/pdf',
      '.zip': 'application/zip',
      '.txt': 'text/plain',
      '.jpg': 'image/jpeg',
      '.png': 'image/png',
    };
    return typeMap[ext] || 'application/octet-stream';
  }

  /**
   * 核心方法:执行完整分片下载流程
   */
  public async download(): Promise<boolean> {
    try {
      // 1. 初始化(获取文件大小)
      const initSuccess = await this.init();
      if (!initSuccess) return false;

      // 2. 下载所有分片
      const downloadSuccess = await this.downloadChunks(3);
      if (!downloadSuccess) return false;

      // 3. 合并并触发下载
      this.mergeAndDownload();
      return true;
    } catch (error) {
      console.error('[下载流程异常]', error);
      return false;
    }
  }
}

测试页面:

html 复制代码
<!--大文件下载-->
<template>
  <div class="download-container">
    <h3>大文件分片下载</h3>
    <!-- 假设已获取文件MD5和文件名 -->
    <button @click="handleDownload" :disabled="isDownloading">
      {{ isDownloading ? '下载中...' : '开始下载' }}
    </button>
    <div class="progress-container" v-if="isDownloading">
      <div class="progress-bar" :style="{ width: `${downloadPercent}%` }"></div>
      <span class="progress-text">{{ downloadPercent.toFixed(2) }}%</span>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { BigFileDownloader } from '@/utils/BigFileDownloader.ts';

// 模拟已上传文件的MD5和文件名(实际从后端获取)
const fileMd5 = '6a8141f0af53be5d54610499e7c696d1'; // 替换为实际MD5
const fileName = '6a8141f0af53be5d54610499e7c696d1.zip'; // 替换为实际文件名

// 响应式数据
const isDownloading = ref(false);
const downloadPercent = ref(0);

/**
 * 执行分片下载
 */
const handleDownload = async () => {
  isDownloading.value = true;
  downloadPercent.value = 0;

  // 实例化下载工具类
  const downloader = new BigFileDownloader(
    fileMd5,
    fileName,
    (percent) => {
      downloadPercent.value = percent;
    }
  );

  try {
    const success = await downloader.download();
    if (!success) {
      alert('文件下载失败!');
    } else {
      alert('文件下载成功!');
    }
  } catch (error) {
    console.error('[页面下载异常]', error);
    alert('下载出错,请重试!');
  } finally {
    isDownloading.value = false;
  }
};
</script>

<style scoped>
.download-container {
  width: 600px;
  margin: 50px auto;
  padding: 20px;
  border: 1px solid #eee;
  border-radius: 8px;
}

button {
  padding: 10px 24px;
  background: #409eff;
  color: #fff;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
}

button:disabled {
  background: #ccc;
  cursor: not-allowed;
}

.progress-container {
  margin-top: 20px;
  height: 24px;
  border: 1px solid #e6e6e6;
  border-radius: 12px;
  overflow: hidden;
  position: relative;
}

.progress-bar {
  height: 100%;
  background: #409eff;
  transition: width 0.3s ease;
}

.progress-text {
  position: absolute;
  top: 0;
  left: 50%;
  transform: translateX(-50%);
  font-size: 12px;
  color: #333;
  line-height: 24px;
}
</style>
相关推荐
想要成为糕糕手1 分钟前
🚀 NestJS 完全入门指南: Module/Controller/Service讲解 + 实战 CRUD
后端·nestjs
开心就好20251 分钟前
appuploader-cli 使用教程:在 Windows 上用命令行把 IPA 上传到 App Store
后端·ios
倾颜2 分钟前
Node.js 为什么会慢?从 Event Loop、CPU、内存到并发控制聊性能问题
后端
用户921080262864 分钟前
AI 应用平台为什么要拆分 Java 后端和 Python AI 服务
后端
星火102413 分钟前
【LangChain4j系列08】Agentic AI 多智能体协作
人工智能·后端
凤山老林17 分钟前
零停机数据库演进:Spring Boot 集成 Flyway 与平滑DDL变更策略
数据库·spring boot·后端
步行cgn18 分钟前
MyBatis <sql> 标签详解:SQL 片段的定义与复用
后端
想要成为糕糕手20 分钟前
🏭 设计模式之工厂模式:从蜜雪冰城到 NestJS,把「new」外包出去
后端·nestjs
抓哇小菜鸡23 分钟前
Spring Boot + 本地大模型(Ollama/DeepSeek) + MyBatis-Plus 企业级智能体数据分析系统从零到一源码全解析
spring boot·后端·mybatis
星火102423 分钟前
【LangChain4j系列07】结构化输出与类型安全
人工智能·后端