Skip to content

RuoYi-Vue-Plus 项目中实现文件并发分片上传、断点续传、秒传及分片下载功能

实现文件并发上传、断点续传、秒传及分片下载功能是一个比较基础但又复杂的任务,需要前后端技术共同配合实现。

不同工程师的实现方式可能会有所不同,本文主要介绍在 RuoYi-Vue-Plus 项目中实现文件并发分片上传、断点续传、秒传及分片下载功能。

分片上传演示: 25033001.gif

分片下载演示: 25033002.gif

分片上传流程

核心流程

  • 前端计算分片 MD5 实现秒传
  • 预签名 URL 直传 OSS 减轻服务器负担
  • Redis 记录上传状态实现断点续传
  • 后端仅处理元数据,数据直接传到OSS

分片上传时序图:

后端业务逻辑分析

关键设计点:

  1. 秒传功能:通过 MD5 校验实现,避免重复上传相同文件
  2. 断点续传:通过 Redis 记录上传进度,支持中断后继续上传
  3. 直接上传:使用预签名 URL 让客户端直接上传到 OSS,减轻服务器负担
  4. 安全性:预签名 URL 有时效性,防止未授权访问

在数据库设计中,添加原有表 sys_oss 中列 md5_digestfile_size

sql
alter table sys_oss
    add column md5_digest varchar(32) not null comment '文件md5';
alter table sys_oss
    add column file_size bigint(20) default null comment '文件大小(单位:字节)';

相应地,实体类 SysOssSysOssBoSysOssVo 也要加上这两个字段:

java
/**
 * 文件MD5
 */
private String md5Digest;

/**
 * 文件大小(单位:字节)
 */
private Long fileSize;

后端分片上传时序图:

检查文件是否已存在(实现秒传功能)

  • 客户端计算文件 MD5 并发送到服务端
  • 服务端检查数据库中是否存在相同 MD5 的文件记录(笔者规定相同 MD5 值的文件为同一个文件)
  • 如果存在则直接返回文件信息,实现秒传
java
/**
 * 检查文件是否已上传,实现秒传功能
 * 根据文件md5Digest查询数据库中是否有记录,如果有直接返回,并且判断originalFileName是否一致,如果不一致则更新
 *
 * @param md5Digest        文件的 MD5 摘要
 * @param originalFileName 文件的原始名称
 * @return SysOssVo 对象,包含文件信息
 */
public SysOssVo checkFileExists(String md5Digest, String originalFileName) {
    SysOssVo sysOssVo = baseMapper.selectVoOne(Wrappers.<SysOss>lambdaQuery().eq(SysOss::getMd5Digest, md5Digest));
    if (ObjectUtil.isNotNull(sysOssVo)) {
        if (!sysOssVo.getOriginalName().equals(originalFileName)) {
            sysOssVo.setOriginalName(originalFileName);
            SysOss sysOss = new SysOss();
            sysOss.setOssId(sysOssVo.getOssId());
            sysOss.setOriginalName(originalFileName);
            baseMapper.updateById(sysOss);
        }
        return sysOssVo;
    }
    return null;
}

初始化分片上传

  • 客户端发送文件基本信息(文件名、MD5、大小等)
  • 服务端创建分片上传任务,生成 Upload ID
  • 将任务信息存入 Redis(包括 Upload ID、文件名等)
java
/**
 * 初始化分片上传任务
 *
 * @param multipartBo 初始化分片的参数对象
 * @return 分片上传对象信息
 */
@Override
public MultipartVo initiateMultipart(MultipartBo multipartBo) {
    OssClient storage = OssFactory.instance();
    String md5Digest = multipartBo.getMd5Digest();
    String ossKey = GlobalConstants.OSS_CONTINUATION + LoginHelper.getUserId() + md5Digest;
    MultipartVo multipartVo = RedisUtils.getCacheObject(ossKey);
 
    if (ObjectUtil.isNotNull(multipartVo)) {
        // 获取上传分段进度
        List<PartUploadResult> listParts = storage.listParts(multipartVo.getFilename(), multipartVo.getUploadId(), null, null);
        multipartVo.setPartUploadList(listParts.stream()
            .map(x -> new MultipartVo.PartUploadResult(x.getPartNumber(), x.getETag()))
            .collect(Collectors.toList()));
    } else {
        multipartVo = new MultipartVo();
        String originalName = multipartBo.getOriginalName();
        String suffix = StringUtils.substring(originalName, originalName.lastIndexOf("."), originalName.length());
        UploadResult uploadResult = storage.initiateMultipart(suffix);
        multipartVo.setFilename(uploadResult.getFilename());
        multipartVo.setUploadId(uploadResult.getUploadId());
        multipartVo.setMd5Digest(md5Digest);
        multipartVo.setOriginalName(originalName);
        multipartVo.setSuffix(suffix);
        RedisUtils.setCacheObject(ossKey, multipartVo, Duration.ofHours(1));
        RedisUtils.setCacheObject(GlobalConstants.OSS_MULTIPART + multipartVo.getUploadId(), multipartVo, Duration.ofHours(1));
    }
 
    return multipartVo;
}
java
/**
 * 创建分片上传任务
 *
 * @param key 在 Amazon S3 中的对象键
 * @return 包含上传后的文件信息
 */
public UploadResult createMultipartUpload(String key) {
    try {
        String uploadId = client.createMultipartUpload(
            x -> x.bucket(properties.getBucketName())
                .key(key)
                .build()
        ).join().uploadId();
        return UploadResult.builder().filename(key).uploadId(uploadId).build();
    } catch (Exception e) {
        // 捕获异常并抛出自定义异常
        throw new OssException("创建分片上传任务失败,请检查配置信息:[" + e.getMessage() + "]");
    }
}

上传分片

  • 客户端将文件分成多个分片(默认 5MB)
  • 对每个分片请求服务端获取预签名 URL
  • 使用预签名 URL 直接上传分片到 OSS
  • 服务端记录已上传分片信息,可实现断点续传功能
java
/**
 * 上传文件分片
 *
 * @param multipartBo 分段上传的参数对象
 * @return 分片上传成功后的对象信息
 */
@Override
public MultipartVo uploadPart(MultipartBo multipartBo) {
    String uploadId = multipartBo.getUploadId();
    Integer partNumber = multipartBo.getPartNumber();
    MultipartVo multipartVo = RedisUtils.getCacheObject(GlobalConstants.OSS_MULTIPART + uploadId);
    if (ObjectUtil.isNull(multipartVo)) {
        throw new ServiceException("该分片任务不存在!");
    }
    OssClient storage = OssFactory.instance();
    String privateUrl = storage.uploadPartFutures(multipartVo.getFilename(), uploadId, partNumber, 60 * 60 * 72);
    multipartVo.setPreSignUrl(privateUrl);
    multipartVo.setPartNumber(partNumber);
    return multipartVo;
}
java
/**
 * 生成预签名的分片上传 URL
 *
 * @param key        在 Amazon S3 中的对象键
 * @param uploadId   分片上传任务的 Upload ID
 * @param partNumber 分片编号(从1开始递增)
 * @param second     签名持续时间(秒)
 * @return 预签名 URL 字符串
 */
public String uploadPartFutures(String key, String uploadId, Integer partNumber, Integer second) {
    URL url = presigner.presignUploadPart(
        x -> x.signatureDuration(Duration.ofSeconds(second))
            .uploadPartRequest(
                y -> y.bucket(properties.getBucketName())
                    .key(key)
                    .uploadId(uploadId)
                    .partNumber(partNumber)
                    .build()
            ).build()
    ).url();
    return url.toString();
}

查询上传进度

  • 客户端可以查询已上传的分片列表
  • 用于断点续传或进度显示
java
/**
 * 获取上传分片进度
 *
 * @param multipartBo 分片上传对象信息
 * @return 分片上传对象信息
 */
@Override
public MultipartVo uploadPartList(MultipartBo multipartBo) {
    String uploadId = multipartBo.getUploadId();
    MultipartVo multipartVo = RedisUtils.getCacheObject(GlobalConstants.OSS_MULTIPART + uploadId);
    if (ObjectUtil.isNull(multipartVo)) {
        throw new ServiceException("该分片任务不存在!");
    }
    OssClient storage = OssFactory.instance();
    List<PartUploadResult> listParts = storage.listParts(multipartVo.getFilename(), uploadId, multipartBo.getMaxParts(), multipartBo.getPartNumberMarker());
    multipartVo.setPartUploadList(listParts.stream()
        .map(x -> new MultipartVo.PartUploadResult(x.getPartNumber(), x.getETag()))
        .collect(Collectors.toList()));
    return multipartVo;
}
java
/**
 * 获取指定对象分片上传的分片列表
 *
 * @param key              在 Amazon S3 中的对象键
 * @param uploadId         分片上传任务的 Upload ID
 * @param maxParts         最大返回的分片数(默认为1000)
 * @param partNumberMarker 分片编号的标记,用于分页查询(默认为0,表示从第一个分片开始查询)
 * @return 包含分片上传结果信息的 PartUploadResult 对象列表
 */
public List<PartUploadResult> listParts(String key, String uploadId, Integer maxParts, Integer partNumberMarker) {
    try {
        List<Part> parts = client.listParts(
            x -> x.bucket(properties.getBucketName())
                .key(key)
                .uploadId(uploadId)
                .maxParts(maxParts != null ? maxParts : 1000)
                .partNumberMarker(partNumberMarker != null ? partNumberMarker : 0)
                .build()).join().parts();
        return parts.stream()
            .map(x -> PartUploadResult.builder()
                .partNumber(x.partNumber())
                .eTag(x.eTag())
                .build())
            .collect(Collectors.toList());
    } catch (Exception e) {
        // 捕获异常并抛出自定义异常
        throw new OssException("获取分片列表失败,请检查配置信息:[" + e.getMessage() + "]");
    }
}

完成上传

  • 客户端发送所有分片信息(分片编号和 eTag)
  • 服务端通知 OSS 合并所有分片
  • 将文件信息存入数据库
  • 清理 Redis 中的临时信息
java
/**
 * 合并分片
 *
 * @param multipartBo 分片上传对象信息
 * @return OSS对象存储视图对象
 */
@Override
public SysOssVo completeMultipartUpload(MultipartBo multipartBo) {
    String uploadId = multipartBo.getUploadId();
    String uploadIdKey = GlobalConstants.OSS_MULTIPART + uploadId;
    MultipartVo multipartVo = RedisUtils.getCacheObject(uploadIdKey);
    if (ObjectUtil.isNull(multipartVo)) {
        throw new ServiceException("该分片任务不存在!");
    }
    List<PartUploadResult> listParts = multipartBo.getPartUploadList().stream()
        .map(x -> PartUploadResult.builder()
            .partNumber(x.getPartNumber())
            .eTag(x.getETag())
            .build())
        .collect(Collectors.toList());
    OssClient storage = OssFactory.instance();
    UploadResult uploadResult = storage.completeMultipartUpload(multipartVo.getFilename(), uploadId, listParts);
    // 保存文件信息
    SysOssVo sysOssVo = buildResultEntity(multipartVo.getMd5Digest(), multipartBo.getFileSize(), multipartVo.getOriginalName(), multipartVo.getSuffix(), storage.getConfigKey(), uploadResult);
    RedisUtils.deleteObject(uploadIdKey);
    RedisUtils.deleteObject(GlobalConstants.OSS_CONTINUATION + LoginHelper.getUserId() + multipartVo.getMd5Digest());
    return sysOssVo;
}
java
/**
 * 完成分片上传任务
 *
 * @param key               在 Amazon S3 中的对象键
 * @param uploadId          分片上传任务的 Upload ID
 * @param partUploadResults 已完成的分片列表(必须是唯一且按照递增顺序排列,严格检查是否漏传)
 * @return 包含上传后的文件信息
 */
public UploadResult completeMultipartUpload(String key, String uploadId, List<PartUploadResult> partUploadResults) {
    if (CollUtil.isEmpty(partUploadResults)) {
        throw new OssException("分片列表不能为空");
    }
    List<CompletedPart> completedParts = partUploadResults.stream()
        .map(x -> CompletedPart.builder()
            .partNumber(x.getPartNumber())
            .eTag(x.getETag())
            .build())
        .collect(Collectors.toList());
    try {
        String eTag = client.completeMultipartUpload(
            x -> x.bucket(properties.getBucketName())
                .key(key)
                .uploadId(uploadId)
                .multipartUpload(y -> y.parts(completedParts)
                    .build())
                .build()
        ).join().eTag();
        // 提取上传结果中的 ETag,并构建一个自定义的 UploadResult 对象
        return UploadResult.builder().url(getUrl() + StringUtils.SLASH + key).filename(key).eTag(eTag).build();
    } catch (Exception e) {
        // 捕获异常并抛出自定义异常
        throw new OssException("合并文件失败,请检查配置信息:[" + e.getMessage() + "]");
    }
}

取消上传

  • 客户端可以取消正在进行的上传任务
  • 服务端通知 OSS 中止上传并清理 Redis 信息
java
/**
 * 取消正在上传的文件分片
 *
 * @param multipartBo 分片上传对象信息
 * @return 是否取消成功
 */
@Override
public Boolean cancelUploading(MultipartBo multipartBo) {
    String uploadId = multipartBo.getUploadId();
    String uploadIdKey = GlobalConstants.OSS_MULTIPART + uploadId;
    MultipartVo multipartVo = RedisUtils.getCacheObject(uploadIdKey);
    if (ObjectUtil.isNull(multipartVo)) {
        throw new ServiceException("该分片任务不存在!");
    }
    OssClient storage = OssFactory.instance();
    storage.abortMultipartUpload(multipartVo.getFilename(), uploadId);
    RedisUtils.deleteObject(uploadIdKey);
    RedisUtils.deleteObject(GlobalConstants.OSS_CONTINUATION + LoginHelper.getUserId() + multipartVo.getMd5Digest());
    return true;
}
java
/**
 * 取消分片上传任务
 *
 * @param key      在 Amazon S3 中的对象键
 * @param uploadId 分片上传任务的 Upload ID
 */
public void abortMultipartUpload(String key, String uploadId) {
    try {
        client.abortMultipartUpload(
            x -> x.bucket(properties.getBucketName())
                .key(key)
                .uploadId(uploadId)
                .build()
        ).join();
    } catch (Exception e) {
        // 捕获异常并抛出自定义异常
        throw new OssException("取消分片上传任务失败,请检查配置信息:[" + e.getMessage() + "]");
    }
}

客户端业务逻辑分析

客户端分片上传时序图:

文件选择和准备

  • 用户选择文件后,前端创建文件对象,包含文件信息(名称、大小等)和上传状态
    typescript
    /**
     * 处理文件选择
     * @param e 事件对象
     */
    const handleFileChange = (e: Event) => {
      if (props.disabled) return; // 禁用状态下不处理
    
      const input = e.target as HTMLInputElement;
      if (!input.files || input.files.length === 0) return;
    
      const newFiles = Array.from(input.files).map((file) => ({
        id: Date.now() + '-' + Math.random().toString(36).substr(2, 9),
        file,
        name: file.name,
        size: file.size,
        percent: 0,
        status: 'waiting' as const,
        chunks: [],
        currentChunkIndex: 0,
        partUploadList: []
      }));
    
      files.value = [...files.value, ...newFiles];
      input.value = ''; // 重置input,允许重复选择相同文件
    };
  • 根据文件大小决定是否使用分片上传(默认 5MB 以下直接上传)
    typescript
    // 小于最小分片大小 MIN_CHUNK_SIZE 的文件使用直接上传,否则使用分片上传
    if (fileObj.file.size < MIN_CHUNK_SIZE * 1024 * 1024) {
      handleDirectUpload(fileObj, index);
    } else {
      handleMultipartUpload(fileObj, index);
    }

秒传检查

  • 计算第一个分片的 MD5 值
    javascript
    // 准备分片
    fileObj.chunks = [];
    for (let i = 0; i < fileObj.file.size; i += chunkSizeInBytes.value) {
      const chunk = fileObj.file.slice(i, i + chunkSizeInBytes.value);
      fileObj.chunks.push(chunk);
    }
    
    // 计算第一个分片的MD5
    const chunk0 = await fileObj.chunks[0].arrayBuffer();
    const uint8array0 = new Uint8Array(chunk0);
    const firstChunkMd5 = await md5(uint8array0);
  • 发送检查请求到服务端,验证文件是否已存在
    javascript
    // 检查文件是否已存在
    const checkRes = await multipartUpload({
      ossStatus: 'check',
      originalName: fileObj.name,
      md5Digest: firstChunkMd5
    });
  • 如果存在则直接完成上传,实现秒传
    javascript
    if (checkRes.data) {
      fileObj.percent = 100;
      fileObj.status = 'success';
      fileObj.ossId = checkRes.data.ossId; // 保存已有文件的ossId
      updateModelValue();
      return; // 已存在的文件直接返回,不继续上传
    }
    // 如果不存在,则继续分片上传

初始化分片上传

  • 发送初始化请求获取 Upload ID
  • 服务端返回已上传的分片信息(用于断点续传)
    typescript
    // 初始化上传
    const resp = await multipartUpload({
      ossStatus: 'initiate',
      originalName: fileObj.name,
      md5Digest: firstChunkMd5
    });
    
    fileObj.uploadId = resp.data.uploadId;
    fileObj.partUploadList = resp.data.partUploadList || [];
    const startPartNumber = fileObj.partUploadList.length > 0 ? fileObj.partUploadList.length + 1 : 1;
    
    if (startPartNumber > fileObj.chunks.length) {
      fileObj.percent = 100;
      fileObj.status = 'success';
      updateModelValue();
      return;
    }
    
    fileObj.currentChunkIndex = startPartNumber - 1;

分片上传

  • 将文件分割为多个分片(默认 5MB)
  • 为每个分片请求预签名 URL
    javascript
    const uploadResp = await multipartUpload({
      ossStatus: 'upload',
      uploadId: fileObj.uploadId,
      partNumber: i + 1
    });
    
    const preSignUrl = uploadResp.data.preSignUrl;
  • 使用预签名 URL 直接上传分片到 OSS
    javascript
    const minioResp = await axios.put(preSignUrl, chunk, {
      headers: {
        'Content-Type': 'application/octet-stream'
      },
      signal: fileObj.controller?.signal
    });
  • 记录已上传分片信息
    javascript
    const eTag = minioResp.headers['etag'];
    if (!fileObj.partUploadList) fileObj.partUploadList = [];
    fileObj.partUploadList.push({
      partNumber: i + 1,
      eTag: eTag
    });

完成上传

  • 所有分片上传完成后,发送完成请求
    javascript
    const completeRes = await multipartUpload({
      ossStatus: 'complete',
      uploadId: fileObj.uploadId,
      partUploadList: fileObj.partUploadList,
      fileSize: fileObj.size
    });
  • 服务端合并分片并返回文件信息
  • 更新前端状态为上传成功
    javascript
    // 只更新当前文件的状态
    fileObj.status = 'success';
    fileObj.ossId = completeRes.data.ossId;
    fileObj.percent = 100;

控制功能

  • 暂停/继续:可暂停正在上传的分片,之后继续上传
    typescript
    /**
     * 暂停上传
     * @param index 文件索引
     */
    const pauseUpload = (index: number) => {
      const fileObj = files.value[index];
      if (fileObj && fileObj.status === 'uploading') {
        fileObj.controller?.abort();
        fileObj.status = 'paused';
      }
    };
    
    /**
     * 继续上传
     * @param index 文件索引
     */
    const resumeUpload = async (index: number) => {
       const fileObj = files.value[index];
       if (fileObj && fileObj.status === 'paused') {
          fileObj.status = 'uploading';
          fileObj.controller = new AbortController();
          await uploadChunks(index);
       }
    };
  • 取消:中止上传并清理资源
    typescript
    /**
     * 移除已上传的文件
     * @param index 文件索引
     */
    const removeUploadedFile = async (index: number) => {
               console.log('removeUploadedFile', index);
               console.log('files', files.value);
               const fileObj = files.value[index];
               if (!fileObj) return;
    
               try {
                  if (fileObj.ossId) {
                     // 调用删除接口
                     const { code } = await delOss(fileObj.ossId);
                     if (code === 200) {
                        // 删除成功后从数组中移除
                        files.value.splice(index, 1);
                        updateModelValue(); // 更新modelValue
                     } else {
                        ElMessage.error('文件删除失败');
                     }
                  } else {
                     // 如果没有ossId,直接移除
                     files.value.splice(index, 1);
                  }
               } catch (error) {
                  console.error('RemoveUploadedFile Error:', error);
                  if (error instanceof Error) {
                     ElMessage.error(`文件删除失败: ${error.message}`);
                  }
               }
            };
    
    /**
     * 取消正在上传的文件
     * @param index 文件索引
     */
    const cancelUploading = async (index: number) => {
       const fileObj = files.value[index];
       if (!fileObj) return;
    
       fileObj.status = 'cancelling';
       fileObj.controller?.abort();
    
       try {
          if (fileObj.uploadId) {
             // 调用取消上传接口
             const { data } = await multipartUpload({
                ossStatus: 'cancel',
                uploadId: fileObj.uploadId
             });
    
             if (!data) {
                ElMessage.error('取消上传失败');
             }
          }
          // 无论是否有uploadId都移除文件项
          files.value.splice(index, 1);
          updateModelValue(); // 更新modelValue
       } catch (error) {
          console.error('CancelUploading Error:', error);
          if (error instanceof Error) {
             ElMessage.error(`取消上传失败: ${error.message}`);
          }
       }
    };
  • 重试:上传失败后可重新尝试
    typescript
    /**
     * 重试上传
     * @param index 文件索引
     */
    const retryUpload = async (index: number) => {
      const fileObj = files.value[index];
      if (fileObj && fileObj.status === 'error') {
        fileObj.percent = 0;
        fileObj.status = 'waiting';
        await startUpload(index);
      }
    };

分片下载流程

核心流程

  • 范围请求(Range Header)实现分片获取
  • 多分片并发下载提高速度
  • 前端完成分片校验和合并
  • 进度实时更新显示

分片下载时序图:

后端业务逻辑分析

后端分片下载时序图:

初始化下载

  • 客户端请求文件下载初始化信息
  • 服务端返回文件大小和推荐的分片大小(默认 5MB)
java
/**
 * 初始化分片下载
 * @param ossId 文件ID
 */
@Override
public DownloadInitVo initChunkDownload(Long ossId) {
   SysOssVo sysOss = getById(ossId);
   if (ObjectUtil.isNull(sysOss)) {
       throw new ServiceException("文件不存在");
   }

   DownloadInitVo vo = new DownloadInitVo();
   vo.setFileSize(sysOss.getFileSize());
   vo.setFileName(sysOss.getOriginalName());
   vo.setChunkSize(OssConstant.DEFAULT_CHUNK_SIZE);
   return vo;
}

分片下载

  • 客户端根据文件大小计算分片范围
  • 请求指定范围的分片数据
  • 服务端从 OSS 获取指定范围的数据流
  • 客户端合并所有分片数据
java
/**
 * 下载文件分片
 * @param ossId 文件ID
 * @param chunkIndex 分片索引
 * @param chunkSize 分片大小
 */
@Override
public void downloadChunk(Long ossId, int chunkIndex, long chunkSize, HttpServletResponse response) throws IOException {
   SysOssVo sysOss = getById(ossId);
   OssClient storage = OssFactory.instance(sysOss.getService());

   long start = chunkIndex * chunkSize;
   long end = Math.min(start + chunkSize - 1, sysOss.getFileSize() - 1);

   response.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + sysOss.getFileSize());
   response.setContentType("application/octet-stream");
   storage.downloadRange(sysOss.getFileName(), response.getOutputStream(), start, end);
}
java
/**
 * 范围下载
 * @param key 对象Key
 * @param out 输出流
 * @param start 起始位置
 * @param end 结束位置
 */
public void downloadRange(String key, OutputStream out, long start, long end) {
   try {
       Download<ResponseInputStream<GetObjectResponse>> download = transferManager.download(DownloadRequest.builder()
           .getObjectRequest(GetObjectRequest.builder()
               .bucket(properties.getBucketName())
               .key(key)
               .range("bytes=" + start + "-" + end)
               .build())
           .responseTransformer(AsyncResponseTransformer.toBlockingInputStream())
           .build());
       try (ResponseInputStream<GetObjectResponse> res = download.completionFuture().join().result()) {
           res.transferTo(out);
       }
   } catch (Exception e) {
       throw new OssException("分片下载失败: " + e.getMessage());
   }
}

客户端业务逻辑分析

客户端分片下载时序图:

初始化下载

  • 请求文件信息(大小、名称等)
    typescript
    // 1. 初始化下载
    const initRes = await axios.get(initUrl, { headers: globalHeaders() });
    const { fileSize, fileName, chunkSize } = initRes.data.data;
  • 计算分片数量和大小
    typescript
    const start = chunkIndex * chunkSize;
    const end = Math.min(start + chunkSize - 1, fileSize - 1);
    const currentChunkSize = end - start + 1;

并发下载

  • 使用多个并发请求下载不同分片
  • 跟踪每个分片的下载进度
typescript
// 5. 下载任务
const downloadTask = async (chunkIndex: number) => {
   const start = chunkIndex * chunkSize;
   const end = Math.min(start + chunkSize - 1, fileSize - 1);
   const currentChunkSize = end - start + 1;
   progressMap.set(chunkIndex, 0);

   try {
      const response = await axios.get(chunkDownloadUrl, {
         params: {
            chunkIndex,
            chunkSize: currentChunkSize
         },
         responseType: 'blob',
         headers: {
            ...globalHeaders(),
            'Range': `bytes=${start}-${end}`, // 明确指定范围
            'Cache-Control': 'no-cache'
         },
         onDownloadProgress: (progressEvent) => {
            // 实时更新该分片的下载量
            progressMap.set(chunkIndex, progressEvent.loaded);
            updateProgress();
         }
      });
      return {
         index: chunkIndex,
         data: response.data
      };
   } catch (error) {
      console.error(`分片 ${chunkIndex} 下载失败:`, error);
      throw error;
   }
};

// 6. 控制并发下载
const CONCURRENCY = 3;
const chunkCount = Math.ceil(fileSize / chunkSize);
const chunks: Array<{ index: number; data: Blob }> = [];

for (let i = 0; i < chunkCount; i += CONCURRENCY) {
  const currentChunks = Array.from({ length: Math.min(CONCURRENCY, chunkCount - i) }, (_, j) => downloadTask(i + j));

  const results = await Promise.all(currentChunks);
  chunks.push(...results);
}

合并文件

  • 所有分片下载完成后按顺序合并
  • 使用 FileSaver 保存完整文件
typescript
// 7. 合并文件
downloadLoadingInstance.setText('文件合并中...');
chunks.sort((a, b) => a.index - b.index);
const fullBlob = new Blob(
  chunks.map((chunk) => chunk.data),
  { type: 'application/octet-stream' }
);

// 8. 保存文件
FileSaver.saveAs(fullBlob, fileName);
downloadLoadingInstance.setText('下载完成!');

进度显示

  • 实时显示整体下载进度
  • 提供取消功能
typescript
// 4. 进度更新方法
const updateProgress = () => {
  const totalDownloaded = Array.from(progressMap.values()).reduce((a, b) => a + b, 0);
  const percent = Math.round((totalDownloaded / fileSize) * 100);
  // ${formatFileSize(totalDownloaded)}/${formatFileSize(fileSize)} 可显示下载大小信息,格式如:830MB/1.2GB
  downloadLoadingInstance.setText(`下载中... ${percent}%`);
};
编程洪同学服务平台是一个广泛收集编程相关内容和资源,旨在满足编程爱好者和专业开发人员的需求的网站。无论您是初学者还是经验丰富的开发者,都可以在这里找到有用的信息和资料,我们将助您提升编程技能和知识。
专业开发
高端定制
售后无忧
站内资源均为本站制作或收集于互联网等平台,如有侵权,请第一时间联系本站,敬请谅解!本站资源仅限于学习与参考,严禁用于各种非法活动,否则后果自行负责,本站概不承担!