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

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>
相关推荐
2501_921649493 小时前
外汇与贵金属行情 API 集成指南:WebSocket 与 REST 调用实践
网络·后端·python·websocket·网络协议·金融
VX:Fegn08953 小时前
计算机毕业设计|基于springboot + vue超市管理系统(源码+数据库+文档)
数据库·vue.js·spring boot·后端·课程设计
TVtoPP3 小时前
使用StockTV API获取印度股票数据:完整Python实战指南
开发语言·后端·python·金融·数据分析
IT_陈寒3 小时前
JavaScript 开发者必知的 7 个 ES2023 新特性,第5个能让代码量减少50%
前端·人工智能·后端
Kiri霧3 小时前
Go 字符串格式化
开发语言·后端·golang
JaguarJack3 小时前
PHP 8.6 新增 clamp() 函数
后端·php
桃花岛主703 小时前
go-micro,v5启动微服务的正确方法
开发语言·后端·golang
BingoGo3 小时前
PHP 8.6 新增 clamp() 函数
后端·php
Kiri霧3 小时前
Go 结构体高级用法
开发语言·后端·golang