实现单个大文件的分片上传和断点续传

6 阅读4分钟

说明:

分片上传:

分片上传,就是将所要上传的文件,按照一定的大小,将整个文件分 隔成多个数据块(我们称之为Part)来进行分别上传,上传完之后再 由服务端对所有上传的文件进行汇总整合成原始的文件。

断点续传:

断点续传是在下载/上传时,将下载/上传任务(一个文件或一个压缩 包)人为的划分为几个部分,每一个部分采用一个线程进行上传/下载, 如果碰到网络故障,可以从已经上传/下载的部分开始继续上传/下载 未完成的部分,而没有必要从头开始上传/下载。

一、分片断点信息实体

package com.example.fileupload.entity;

import lombok.Data;
import java.util.List;

/**
 * 分片文件断点信息
 */
@Data
public class PointFileIndexVo {
    /** 文件唯一MD5标识(断点续传核心) */
    private String fileMd5;
    /** 原始文件名 */
    private String fileName;
    /** 当前分片序号 */
    private Integer partIndex;
    /** 文件总分片数 */
    private Integer partNum;
    /** 已上传的分片序号列表 */
    private List<String> parts;
}

二、服务类的实现

 * 文件上传服务实现类
 */
@Service
@Slf4j
public class FileUploadServiceImpl implements FileUploadService {

    /**
     * 全局上传进度缓存:key=文件MD5,value=已上传分片序号列表
     * 生产环境建议替换为Redis
     */
    private final Map<String, List<String>> uploadProgress = new ConcurrentHashMap<>();

    /**
     * 合并锁缓存:防止多线程重复合并文件
     * 生产环境建议替换为Redis分布式锁
     */
    private final Map<String, Integer> isMergePart = new ConcurrentHashMap<>();

    /**
     * 服务器文件MD5索引缓存
     * 生产环境建议存入数据库
     */
    private List<DiskFileIndexVo> diskFileIndexVos = new ArrayList<>();

    /**
     * 文件根路径(可配置到application.yml)
     */
    @Value("${file.upload.base-path:${user.dir}}")
    private String basePath;

    /**
     * 断点续传核心方法:单文件分片上传
     */
    @Override
    public ResultEntity<String> singleFilePartPointUpload(MultipartFile filePart, String fileInfo) {
        // 解析前端传入的分片信息
        PointFileIndexVo pointFileIndexVo = JSONObject.parseObject(fileInfo, PointFileIndexVo.class);
        String fileMd5 = pointFileIndexVo.getFileMd5();
        String fileName = pointFileIndexVo.getFileName();
        Integer partIndex = pointFileIndexVo.getPartIndex();
        Integer partNum = pointFileIndexVo.getPartNum();

        // 跨平台兼容的文件路径定义
        String filePath = basePath + File.separator + "file" + File.separator;
        String tempPath = filePath + "temp" + File.separator + fileMd5 + File.separator;

        // 创建临时分片目录
        File dir = new File(tempPath);
        if (!dir.exists()) {
            dir.mkdirs();
        }

        // 生成分片临时文件名:文件名_分片序号.part
        String tempFileNamePath = tempPath + fileName + "_" + partIndex + ".part";

        try {
            // 1. 保存当前分片到临时目录
            filePart.transferTo(new File(tempFileNamePath));
            log.info("分片上传成功:文件MD5={}, 分片序号={}", fileMd5, partIndex);

            // 2. 更新上传进度
            List<String> partIndexList = uploadProgress.getOrDefault(fileMd5, new ArrayList<>());
            if (!partIndexList.contains(partIndex.toString())) {
                partIndexList.add(partIndex.toString());
                uploadProgress.put(fileMd5, partIndexList);
            }

            // 3. 校验是否所有分片都上传完成
            File tempDir = new File(tempPath);
            File[] tempFiles = tempDir.listFiles();
            // 空指针安全校验
            if (tempFiles == null) {
                return ResultEntity.error(partIndex.toString(), "临时目录读取失败");
            }

            // 总分片数匹配,触发合并
            if (partNum.equals(tempFiles.length)) {
                // 加锁防止重复合并
                if (isMergePart.get(fileMd5) != null) {
                    return ResultEntity.success(partIndex.toString(), "分片上传成功,等待合并");
                }
                isMergePart.put(fileMd5, tempFiles.length);
                log.info("全部分片上传完成,开始合并:文件MD5={}, 总分片数={}", fileMd5, partNum);

                // 4. 按序号合并分片为完整文件(try-with-resources自动关闭流)
                String finalFilePath = filePath + fileName;
                try (FileOutputStream fos = new FileOutputStream(finalFilePath)) {
                    byte[] buffer = new byte[1024 * 8]; // 8KB缓冲区
                    for (int i = 0; i < partNum; i++) {
                        String partFilePath = tempPath + fileName + "_" + i + ".part";
                        try (FileInputStream fis = new FileInputStream(partFilePath)) {
                            int len;
                            while ((len = fis.read(buffer)) != -1) {
                                fos.write(buffer, 0, len);
                            }
                        }
                    }
                    fos.flush();
                }
                log.info("文件合并完成:{}", finalFilePath);

                // 5. 清理临时分片文件和目录
                for (int i = 0; i < partNum; i++) {
                    new File(tempPath + fileName + "_" + i + ".part").delete();
                }
                if (Objects.requireNonNull(tempDir.listFiles()).length == 0) {
                    tempDir.delete();
                }

                // 6. 清理进度和锁缓存
                isMergePart.remove(fileMd5);
                uploadProgress.remove(fileMd5);
            }

        } catch (Exception e) {
            log.error("单文件分片上传失败,文件MD5={}, 分片序号={}", fileMd5, partIndex, e);
            return ResultEntity.error(fileMd5, "单文件分片上传失败");
        }

        // 返回当前上传成功的分片序号,供前端校验
        return ResultEntity.success(partIndex.toString(), "分片上传成功");
    }

    /**
     * 断点续传核心接口:查询已上传分片列表
     */
    @Override
    public ResultEntity<PointFileIndexVo> checkUploadFileIndex(PointFileIndexVo pointFileIndexVo) {
        try {
            String fileMd5 = pointFileIndexVo.getFileMd5();
            // 1. 从缓存中获取已上传分片
            List<String> uploadedParts = uploadProgress.getOrDefault(fileMd5, new ArrayList<>());
            if (uploadedParts != null) {
        pointFileIndexVo.setParts(uploadedParts);
        return ResultEntity.success(pointFileIndexVo);
            }
            // 2. 双重校验:扫描磁盘临时目录,防止缓存丢失导致断点失效
            String tempPath = basePath + File.separator + "file" + File.separator + "temp" + File.separator + fileMd5 + File.separator;
            File tempDir = new File(tempPath);
            if (tempDir.exists() && tempDir.isDirectory()) {
                File[] partFiles = tempDir.listFiles();
                if (partFiles != null && partFiles.length > 0) {
                    uploadedParts = new ArrayList<>();
                    for (File partFile : partFiles) {
                        // 从文件名中提取分片序号:文件名_0.part → 提取0
                        String fileName = partFile.getName();
                        String index = fileName.split("_")[1].replace(".part", "");
                        uploadedParts.add(index);
                    }
                    // 同步更新缓存
                    uploadProgress.put(fileMd5, uploadedParts);
                }
            }

            pointFileIndexVo.setParts(uploadedParts);
            log.info("查询文件上传进度:MD5={}, 已上传分片={}", fileMd5, uploadedParts);
            return ResultEntity.success(pointFileIndexVo);
        } catch (Exception e) {
            log.error("上传文件进度查询异常", e);
            return ResultEntity.error("上传文件进度查询异常");
        }
    }


}

三、前端代码

 // 核心:断点续传上传
    async singleFilePartPointUpload() {
      const file = this.singleFilePart.file
      if (!file) {
        this.$message.error('请先选择文件')
        return
      }

      this.uploadIng = true
      this.uploadStatus = ''
      this.abortController = new AbortController()

      try {
        // 1. 计算文件MD5
        this.$message.info('正在计算文件MD5...')
        this.fileMd5 = await this.calculateFileMd5(file)
        this.$message.success('MD5计算完成,正在查询上传进度...')

        // 2. 查询已上传的分片(断点续传核心)
        const checkRes = await axios.post(`${BASE_URL}/file/checkUploadIndex`, {
          fileMd5: this.fileMd5,
          fileName: file.name
        })
        if (checkRes.data.code !== 200) {
          throw new Error(checkRes.data.msg)
        }

        this.uploadedChunks = checkRes.data.data.parts || []
        // 3. 计算文件分片
        const fileSize = file.size
        this.totalChunks = Math.ceil(fileSize / CHUNK_SIZE)
        const chunks = []

        // 生成分片列表
        for (let i = 0; i < this.totalChunks; i++) {
          const start = i * CHUNK_SIZE
          const end = Math.min(start + CHUNK_SIZE, fileSize)
          chunks.push({
            index: i,
            file: file.slice(start, end)
          })
        }

        // 4. 过滤掉已上传的分片,只保留需要上传的
        this.needUploadChunks = chunks.filter(chunk =>
          !this.uploadedChunks.includes(chunk.index.toString())
        )

        if (this.needUploadChunks.length === 0) {
          this.singlePartFileProgress = 100
          this.uploadStatus = 'success'
          this.uploadIng = false
          this.$message.success('文件已存在,秒传成功!')
          return
        }

        this.$message.info(`共${this.totalChunks}个分片,已上传${this.uploadedChunks.length}个,待上传${this.needUploadChunks.length}个,开始上传...`)

        // 5. 循环上传分片
        const successCount = this.uploadedChunks.length
        for (let i = 0; i < this.needUploadChunks.length; i++) {
          if (!this.uploadIng) break // 暂停上传时退出

          const chunk = this.needUploadChunks[i]
          const formData = new FormData()
          // 分片文件
          formData.append('filePart', chunk.file)
          // 分片信息
          const fileInfo = {
            fileMd5: this.fileMd5,
            fileName: file.name,
            partIndex: chunk.index,
            partNum: this.totalChunks
          }
          formData.append('fileInfo', JSON.stringify(fileInfo))

          // 上传分片
          const uploadRes = await axios.post(`${BASE_URL}/file/pointUpload`, formData, {
            signal: this.abortController.signal,
            headers: {
              'Content-Type': 'multipart/form-data'
            }
          })

          if (uploadRes.data.code !== 200) {
            throw new Error(`第${chunk.index}分片上传失败:${uploadRes.data.msg}`)
          }

          // 更新进度
          const currentSuccess = successCount + i + 1
          this.singlePartFileProgress = Math.floor((currentSuccess / this.totalChunks) * 100)
          this.uploadedChunks.push(chunk.index.toString())
        }

        // 6. 上传完成
        if (this.singlePartFileProgress === 100) {
          this.uploadStatus = 'success'
          this.$message.success('文件上传并合并完成!')
        }
      } catch (error) {
        if (error.name !== 'AbortError') {
          this.$message.error(error.message || '上传失败')
          this.uploadStatus = 'exception'
        }
      } finally {
        this.uploadIng = false
      }
    }