后端签名前端直传 + 断点续传:以极致性能铸就全网最优分片解决方案


前言

也许此刻的坚持无人喝彩,满是汗水与疲惫,可最难的终究是坚持。“心之所向,素履以往。生如逆旅,一苇以航。” 我们只问自由、盛放、深情、初心与勇敢,无问西东。这个时代,并不缺少完美的人,而是缺从自己心底给出真心、正义、无畏和同情的人 。

本文以minio文件存储服务器为例,但同时支持水平横向扩展。封装了统一文件服务接口。
由于时间成本原因,不再单独抽离demo放入开源仓库。完整代码将在文末全量给出。
前端部分技术支持由@流星418提供
前端采用vue3+TS
后端采用SpringBoot
文件服务器以minio为例
在这里插入图片描述

存在的问题说明

issue:https://github.com/minio/minio/issues/21116
该方案存在的问题是,前端一旦获取签名后,难以限制前端上传文件的大小,而且可能造成md5命名的文件和文件内容明显不对应。 也有一个较为合适的办法,详见issue内容提出的动态映射解决方案。


一.逻辑架构讲解

在这里插入图片描述
如图所示,大致分为四个阶段

  • 1.前端请求后端签名:前端向后端发起请求,以获取用于文件上传的签名。
  • 2.后端逻辑校验并给出签名:后端接收到前端请求后,进行相关逻辑校验,在校验通过后生成签名,并将其返回给前端。
  • 3.前端上传文件:前端在获取到后端提供的签名后,依据签名信息执行文件上传操作。
  • 4.前端判断是否需要合并分片文件:文件上传完成后,前端对上传的文件进行判断,确定是否需要对分片文件进行合并处理 。

1.1 前端请求后端签名

1.1.1 选择文件并执行文件的md5 计算

const handleFileChange = (event) => {
  const file = event.target.files[0];
  console.log(file);
  current.value = 0;
  allUploadNum.value = 0;
  if (file.size > 100 * 1024 * 1024) {
    alert("文件过大,请选择小于 100MB 的文件!");
    return;
  }
  if (file) {
    shardingOperation(file);
  }
};

//初步数据处理
async function shardingOperation(file: File) {
  selectedFile.value = file;
  // 计算文件MD5
  fileMd5.value = await calculateFileMD5(file);
  console.log("文件MD5", fileMd5.value);
  fileInfo.value = {
    md5: fileMd5.value,
    fileSize: file.size,
    fileName: file.name,
    fileType: "." + file.name.split(".").pop(),
  };
}

分片操作函数shardingOperation中使用了计算md5的方法calculateFileMD5
计算出文件的MD5之后将文件信息保存到fileInfo中以便于后续获取minio直传签名使用.需要注意的是建议使用非对称签名防前端用户篡改。
calculateFileMD5方法:
使用外部包crypto-js进行文件的md5计算,并返回文件的md5值

const calculateFileMD5 = (file): Promise<string> => {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();

    reader.onload = (e) => {
      if (!e.target || !(e.target.result instanceof ArrayBuffer)) {
        reject(new Error("Failed to read file as ArrayBuffer"));
        return;
      }
      const wordArray = CryptoJS.lib.WordArray.create(e.target.result);
      const md5 = CryptoJS.MD5(wordArray).toString();
      resolve(md5);
};

    reader.onerror = (e) => {
      reject(e);
    };
    reader.readAsArrayBuffer(file);
  });
};

1.1.2. 开始上传

首先需要获取分片的字节流数组
上传函数handleUpload中分别使用了以下方法:

(1)分割文件的方法 sliceFile
(2)将分片转成arrayBuffer的方法 getArrayBufFromBlobsV2

计算出文件的MD5之后将文件信息保存到fileInfo中以便于后续获取minio直传签名使用
(1)分割文件的方法sileceFile,接收文件和要分片的阈值,以5mb为例

/**
 * 分割文件
 * @param file 文件
 * @param baseSize 默认分块大小
 * @private
 */
function sliceFile(file: File, baseSize = 5): any[] {
  const chunkSize = baseSize * 1024 * 1024; // MB
  const chunks: any[] = [];
  let startPos = 0;
  let index = 0;
  while (startPos < file.size) {
    chunks.push({
      file: file.slice(startPos, startPos + chunkSize),
      index: index++,
    });
    startPos += chunkSize;
  }
  return chunks;
}

分割文件后返回分割的文件数组chunks,加上index是为了后续上传分片时发送给后端,避免分片索引和分片文件不一致的问题

(2)将chunks转成字节流并进一步加工数据

//file转成arrayBuffer
async function getArrayBufFromBlobsV2(chunks: any[]): Promise<ArrayBuffer[]> {
  //分片字节流
  const uploadArray: any[] = [];
  Promise.all(
    chunks.map(async (chunk) => {
      uploadArray.push({
        md5: fileMd5.value,
        fileSize: selectedFile.value!.size,
        fileType: selectedFile.value!.type,
        arrayBuffer: await chunk.file.arrayBuffer(),
        dataId: chunk.index,
      });
      return chunk.file.arrayBuffer();
    })
  );

  return uploadArray;
}

然后上传函数会对文件大小进行判断:如果小于分片阈值就直接上传,如果大于阈值就使用队列排序上传
(1)小于阈值

async function handleUpload() {
  const url = "http://192.168.200.26:777/minio/***/"; //这里写上后端给你获取签名的接口

  const uploadArray: any = await getArrayBufFromBlobsV2(
    sliceFile(selectedFile.value!)
  );
  current.value = 0;
  if (fileInfo.value!.fileSize < 5 * 1024 * 1024) {
    //获取签名
    const signature = (
      await makeRequest(
        url,
        JSON.stringify(fileInfo.value),
        "POST",
        "application/json"
      )
    ).data;
    if (signature.status == "UPLOADED") {
      alert("文件已存在!");
      return;
    }
    setTimeout(() => {
      console.log("分片数据", uploadArray);
      makeRequest(
        signature.fileUrl,
        uploadArray[0].arrayBuffer,
        "PUT",
        "application/octet-stream"
      );
    }, 10);
  }

makeRequest是临时封装的请求方法,后续会对此方法进行补充。

这里以5mb为例,首先获取签名,makeRequest接受四个参数:请求url、携带数据、请求类型和请求头。

获取后端返回的请求签名后对返回的字段进行判断如果文件存在就跳出不再继续执行。
不存在就调用返回的签名地址进行minio直传

(2)大于分片阈值

allUploadNum记录总分片数
流程基本一样首先发送请求获取全部的签名再执行下一步,同时allUploadNum记录总的分片数:

 //分片上传
    setTimeout(async () => {
      console.log("分片数据", uploadArray);
      allUploadNum.value = uploadArray.length;

      //获取签名
      const uploadPromises = uploadArray.map(async (item) => {
        // console.log("分片数据", item);

        const fromData = {
          fileName: fileInfo.value!.fileName,
          uploadId: item.dataId,
          partCount: uploadArray.length,
          fileSize: fileInfo.value!.fileSize,
          fileType: fileInfo.value!.fileType,
          md5: fileInfo.value!.md5,
        };

        const singature = (
          await makeRequest(
            url,
            JSON.stringify(fromData),
            "POST",
            "application/json"
          )
        ).data;
        item.singature = singature;

        console.log("singature", singature);
      });

      await Promise.all(uploadPromises);

设置最大同时分片的上传数 这里以4为例,即同时上传四个5mb的分片,完成后再继续上传新的分片

然后会对返回的签名数据的remainIdx进行判断
在这里插入图片描述
这个字段会返回该文件的待上传索引,如果不包含文件的id索引就代表已经上传过该分片,跳过当前分片的上传并使已上传分片数current加1,这里实现的是断点续传的功能

若当前分片上传数量大于4就让其等待100ms再判断,直至有新的空位。

 // 控制并发上传
      const concurrentLimit = 4;
      let activeUploads = 0;
      // 创建所有分片的上传任务
      const uploadTasks = uploadArray.map((item) => async () => {
        // 如果该分片已经上传过,则跳过
        if (!item.singature.remainIdx.includes(item.dataId)) {
          current.value++;
          return Promise.resolve();
        }
        while (activeUploads >= concurrentLimit) {
          console.log("等待上传中...");
          // 等待直到有可用的上传槽位
          await new Promise((resolve) => setTimeout(resolve, 100));
        }
        activeUploads++;
        try {
          await makeRequest(
            item.singature.fileUrl,
            item.arrayBuffer,
            "PUT",
            "application/octet-stream"
          );
          console.log("分片上传结果", item.dataId);
          current.value++;
        } finally {
          activeUploads--;
        }
      });
      // 执行所有上传任务
      await Promise.all(uploadTasks.map((task) => task()));

上传所有的分片完成后调用分片合并接口通知后端合并

 const fromData = {
        fileName: fileInfo.value!.fileName,
        partCount: uploadArray.length,
        fileType: fileInfo.value!.fileType,
        md5: fileInfo.value!.md5,
      };
      const result = await makeRequest(
        "http://192.168.200.26:777/****/compose-fragment",//这里写上你的后端合并接口
        JSON.stringify(fromData),
        "GET",
        "application/json"
      );
      console.log("分片上传结果", result);
      alert("上传成功!");
    }, 100);
  }
}

3.上传中止/断点续传

这里是我封装的请求方法和中止请求的方法,核心思路是为每个请求创建id,调用中止请求时abortRequest根据id中止请求,如果不传递id则中止全部请求。因为每个人的请求封装可能都不一样所以不再详细阐述

async function makeRequest(
  url: string,
  data: any,
  type: string,
  sendheader: any,
  requestId: string = Date.now().toString() // Unique identifier for each request
): Promise<any> {
  try {
    return new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest();

      // Store the XHR request
      activeRequests.set(requestId, xhr);

    // Get请求时,处理参数
      if (type.toUpperCase() === "GET" && data) {
        const params = new URLSearchParams(JSON.parse(data));
        url = `${url}?${params.toString()}`;
        data = null;
      }

    xhr.open(type, url, true);
      xhr.setRequestHeader("Content-Type", sendheader);

    xhr.onload = function () {
        activeRequests.delete(requestId);
        if (xhr.status === 200) {
          const response = xhr.responseText
            ? JSON.parse(xhr.responseText)
            : null;
          resolve(response as { data: { fileUrl: string } });
        } else {
          reject(new Error(`HTTP Error: ${xhr.status}`));
        }
      };

    xhr.onerror = function () {
        activeRequests.delete(requestId);
        reject(new Error("Network Error"));
      };

     xhr.onabort = function () {
        activeRequests.delete(requestId);
        reject(new Error("Request aborted"));
      };

    xhr.send(data);
    });
  } catch (error) {
    console.error("Request error:", error);
    throw error;
  }
}

4.中断请求

// 中断请求
function abortRequest(requestId?: string) {
  if (requestId) {
    // Abort specific request
    const xhr = activeRequests.get(requestId);
    if (xhr) {
      xhr.abort();
      activeRequests.delete(requestId);
    }
  } else {
    // Abort all active requests
    activeRequests.forEach((xhr) => xhr.abort());
    activeRequests.clear();
  }
}

1.2 后端逻辑校验并给出签名

第二阶段主要包含以下 5个步骤:

  • 1.文件参数合法性判断:对传入的文件参数进行校验,确认其是否符合既定规则。
  • 2.文件服务器 md5 文件名查询(秒传功能):在文件服务器中,依据 md5 文件名进行查询,以此实现秒传功能。
  • 3.是否分片上传:判断文件是否满足分片上传的各项要求。
  • 4.临时签名生成:若上述步骤均通过校验,生成并给出临时签名。
  • 5.是否需要分片合并: 若进行过分片任务。需要合并分片。

如下图所示
在这里插入图片描述
其中需要注意的点是:

    1. 鉴于前端仅传递文件相关参数,并不传输文件本身,存在用户篡改参数,致使文件实际参数与传递参数不匹配的风险。因此,必须对相关字段进行签名处理,以保障数据的一致性和安全性。
    1. 需根据前端所传递的md5值和fileType类型,拼接形成完整的云路径。在此过程中,切不可覆盖fileName字段。拼接后的云路径需与业务数据一同入库,这一操作是以此确保文件本身的信息完整且不发生改变。
    1. 云路径签名和文件名不相关,上传后的文件以后端拼接好的md5命名签名为准。

传递的FileEntity对象,详见后端源码实现部分

1.2.1 文件合法性校验

封装了文件工具类,其具备以下能力:
String getFileName(String path) 获取文件名(不包含路径前缀)
boolean checkFile(String md5, String fileType, Long fileSize) 校验文件md5参数,文件类型,文件大小
String getFilePath(String md5, String fileType) 获取文件云路径(真实云存储地址,后续根据其回显)
String getTempPath(String md5,Integer uploadId)(分片文件存储的路径,断点续传和完成分片合并后销毁。)
String generateMD5(MultipartFile file) 根据文件流计算md5(冗余方法,前端直传不涉及传递后端文件流)
byte[] createChecksum(MultipartFile file) 文件流计算md5核心实现(冗余方法,前端直传不涉及传递后端文件流)
boolean isEnableChunkUpload(Integer uploadId, Integer partCount, Long size) 校验分片参数和文件大小(分片参数不为空,且规定大于的5m。开启分片上传任务)

文件合法性校验如下图:
在这里插入图片描述
在相关流程中,需通过正则匹配对 md5 格式进行校验。同时校验后缀是否是白名单允许的。默认情况下,整个文件大小被限制在 100MB 以内,不过用户能够依据自身需求自定义文件大小上限。需留意的是,在 1.2 部分开篇已有所提及,这里要着重强调的是,可进一步对合法校验参数实施对校验参数实施签名措施。其目的在于防止前端用户恶意篡改参数,避免出现文件实际参数与请求签名时的校验参数不一致的情况,进而确保整个流程的准确性与安全性。

1.2.2 文件服务器 md5 文件名查询(秒传功能)

目前的操作方式为,如附图所示,直接读取文件服务器的查询接口。但此方式存在一定局限性,有优化的必要。一方面,当前云路径拼接格式为“project-name/yy/mm/dd/md5.jpg” ,这种格式致使md5查重仅能检测当天是否有重复文件上传,无法从更长期的维度全面查重。另一方面,可对该流程进一步优化:在文件关联数据落库后,通过查询数据库中的md5值来判断文件是否重复,或者将相关数据加载进redis进行查询。这样优化后,能突破仅按天查重的限制,从整体数据层面实现更精准、全面的文件重复判断,也能降低文件服务器压力。
在这里插入图片描述

1.2.3 分片上传

在这里插入图片描述
如下图,设置了分片阈值为5MB,当文件内容大于5MB时,并具备分片要求,开启分片功能。需要注意的是minio分片至少支持每一块为5MB,最后一块不要求。
在这里插入图片描述
整体分片逻辑如下:
在这里插入图片描述
其中步骤为:
1.检测临时分片桶是否存在
2.检测分片进度
3.记录已上传分片的索引
4.记录未上传分片索引
5.更新待上传分片索引,并附加上传签名

操作过程中有诸多要点需特别关注:
1.在创建分片桶时,由于分片属于临时文件,若不进行合并则无长期存储价值,所以必须为桶设置生命周期规则。以 minio 设置为例,如附图所示,可将分片的最长存储时间设定为 3 天,以此确保资源的合理利用与数据的有效管理。
在这里插入图片描述
2.分片的目录需要以md5命名,目的是找到同一文件的所有分片。其中uploadId可以为null,是因为为null可以查询md5下所有分片。不为null,就是分片上传路径。
在这里插入图片描述
3.在处理分片上传的过程中,对于已上传分片索引和未上传分片索引的记录,务必采用严谨的方式。需明确指出,不能仅仅因为使用了TreeSet集合,就简单地通过获取last元素来认定为最新上传的分片。这是由于上传过程采用多线程模式,分片的上传顺序是乱序的,并非按序依次完成。

正确的做法是,利用一个合适的集合来收集已经成功上传的分片索引,以此全面记录已上传的分片情况。在此基础上,依据前端所传递的分片总数,通过合理的计算来确定未上传的分片索引。最终,将待上传的分片索引整理成一个集合提供给前端,使得前端能够依据该集合的内容,精准且有针对性地发起后续的上传任务,从而确保整个分片上传流程的准确性与高效性。 特别的,若前端分片中断,minio会产生一个不符合阈值大小的分片,我们需要重新上传该部分。
在这里插入图片描述
4. 在整个流程的最后环节,需要对待上传集合进行更新。在此过程中,有一点需特别注意:前端获取分片签名与执行上传操作这两个行为之间应保持同步关系。具体而言,必须确保所有分片的签名流程全部完成之后,才开启异步上传操作。如此安排,主要是为了防止在计算待上传索引时出现错乱情况,进而保证整个文件分片上传流程的准确性与稳定性。
在这里插入图片描述

1.2.4 后端签名

若不符合分片要求,直接执行签名逻辑
在这里插入图片描述

1.2.5 分片合并

前端上传完毕后,调用该部分。
在这里插入图片描述
整体合并策略如下:
* 1. 检查上传的分片是否完整。
* 2. 如果分片不完整,返回错误信息。
* 3. 遍历所有分片并将其合并为一个完整的文件。
* 4. 删除临时分片文件。
* 5. 返回合并后的文件信息。
在这里插入图片描述

二.后端源码实现

|-- VectorThirdServerApplication.java
|-- config
|   |-- MinioProperties.java // minio基础参数配置
|   |-- OSSProperties.java // oss基础参数配置
|   `-- ThreadPoolAndVMExecutorConfig.java
|-- constants
|   `-- ConstantStorage.java // 统一云存储参数常量配置
|-- controller
|   |-- MinioController.java // minio控制类
|   |-- OSSController.java // oss控制类
|-- provider
|   `-- ThirdServerProvider.java
|-- service
|   |-- IStorageService.java // 统一云存储接口
|   `-- impl
| 		-- MinioServiceImpl.java // minio云存储接口实现类
| 		-- AliyunOSSServiceImpl.java oss云存储接口实现类

2.1 实体参数

FileEntity.java

/**
 * @author YuanJie
 * @date 2024/3/4 22:14
 */
@Getter
@Setter
public class FileEntity extends BaseEntity {
    /**
     * 文件id
     */
    private String id;
    /**
     * 文件名
     */
    @NotBlank(groups = {Add.class})
    private String fileName;
    /**
     * 文件md5值
     */
    @NotBlank(groups = {Add.class})
    private String md5;
    /**
     * 文件大小
     */
    @NotNull(groups = {Add.class})
    private Long fileSize;
    /**
     * 文件类型
     */
    @NotBlank(groups = {Add.class})
    private String fileType;
    /**
     * 云路径
     */
    private String objectName;
    /**
     * 临时链接 不入库
     */
    private String fileUrl;
    /**
     * 分片id
     */
    private Integer uploadId;
    /**
     *  分片总数
     */
    private Integer partCount;

    /**
     * 文件描述
     */
    private String fileDesc;

    /**
     * 待上传索引分片
     */
    private Set<Integer> remainIdx;
    /**
     * 上传状态 0 未上传 1 上传中 2 上传完成
     */
    private EnumStatus status;



    /**
     * 便于前端区分 fileUrl是回显地址还是临时上传地址
     */
    public enum EnumStatus {
        UN_UPLOAD,
        UPLOADING,
        UPLOADED;
    }

}

2.2 FileUtil工具类

FileUtil

/**
 * 文件工具类,用于处理文件路径、类型检测和MD5计算等操作
 *
 * @author YuanJie
 * @date 2024/4/10 下午11:59
 */
public class FileUtils {
    private static final Logger logger = LoggerFactory.getLogger(FileUtils.class);

    // 文件路径前缀
    public static final String PREFIX_IMAGE = "img/";
    public static final String PREFIX_VIDEO = "video/";
    public static final String PREFIX_AUDIO = "audio/";
    public static final String PREFIX_MUSICXML = "musicxml/";
    public static final String PREFIX_DOCUMENT = "document/";

    // 缓冲区大小
    private static final int BUFFER_SIZE = 8192;

    // 默认最大文件大小为100mb
    private static final long DEFAULT_MAX_FILE_SIZE = 1024 * 1024 * 100;
    // 断点续传和分片上传临界值 5mb
    public static final long CHUNK_SIZE_THRESHOLD = 1024 * 1024 * 5;
    // 时间格式化
    private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy/MM/dd");

    /**
     * 获取文件名
     * @param: s
     * @return: int
     * @author YuanJie
     * @date: 2025/4/9 10:03
     */
    public static String getFileName(String path) {
        if (path == null || path.isEmpty()) {
            return "";
        }
        int lastSlashIndex = path.lastIndexOf('/');
        // 处理可能没有斜杠的情况
        String fullFilename = lastSlashIndex != -1 ? path.substring(lastSlashIndex + 1) : path;
        int lastDotIndex = fullFilename.lastIndexOf('.');
        return lastDotIndex != -1 ? fullFilename.substring(0, lastDotIndex) : fullFilename;
    }

    /**
     * 文件类型枚举
     */
    @Getter
    public enum EnumFileType {
        IMAGE(PREFIX_IMAGE, ".jpg", ".jpeg", ".png", ".webp"),
        VIDEO(PREFIX_VIDEO, ".mp4", ".m4v", ".mov"),
        AUDIO(PREFIX_AUDIO, ".mp3", ".wma", ".wav", ".flac", ".ogg", ".acc"),
        MUSICXML(PREFIX_MUSICXML, ".musicxml"),;

        private final String prefix;
        private final Set<String> extensions;

        EnumFileType(String prefix, String... extensions) {
            this.prefix = prefix;
            this.extensions = Arrays.stream(extensions)
                    .map(ext -> ext.toLowerCase(Locale.ROOT))
                    .collect(Collectors.toUnmodifiableSet());
        }

        public static EnumFileType getByExtension(String extension) {
            if (extension == null) {
                return null;
            }
            String lowerExt = extension.toLowerCase(Locale.ROOT);
            for (EnumFileType val : values()) {
                if (val.extensions.contains(lowerExt)) {
                    return val;
                }
            }
            return null;
        }
    }

    /**
     * 检查文件是否有效(非空且大小合适)
     *
     * @param md5 文件的md5值
     * @param fileType 文件类型
     * @param fileSize 文件大小
     * @return 文件是否有效
     */
    public static boolean checkFile(String md5, String fileType, Long fileSize) {
        if (!Pattern.matches(ConstantsRegex.MD5_REGEX, md5)) {
            return false;
        }
        EnumFileType enumFileType = EnumFileType.getByExtension(fileType);
        return enumFileType != null && fileSize > 0 && fileSize <= DEFAULT_MAX_FILE_SIZE;
    }

    /**
     * 获取文件路径
     * @param md5 文件的md5值
     * @return 完整的文件路径,如果文件类型不支持则返回null
     */
    public static String getFilePath(String md5, String fileType) {
        if (!Pattern.matches(ConstantsRegex.MD5_REGEX, md5)) {
            return null;
        }
        EnumFileType enumFileType = EnumFileType.getByExtension(fileType);
        return enumFileType != null ? enumFileType.getPrefix() + DATE_FORMATTER.format(LocalDate.now()) + "/" + md5 + fileType : null;
    }

    /**
     * @description: 获取临时路径
     * @author YuanJie
     * @date 2025/4/7 10:51
     */
    public static String getTempPath(String md5,Integer uploadId) {
        if (!Pattern.matches(ConstantsRegex.MD5_REGEX, md5)) {
            return null;
        }
        if (uploadId == null) {
            return DATE_FORMATTER.format(LocalDate.now()) + md5.concat("/");
        }
        return DATE_FORMATTER.format(LocalDate.now()) + md5.concat("/")+uploadId;
    }


    /**
     * 计算文件MD5
     *
     * @param file 文件
     * @return MD5哈希值,如果计算失败则返回null
     */
    private static String generateMD5(MultipartFile file) {
        if (file == null || file.isEmpty()) {
            return null;
        }
        try {
            byte[] checksum = createChecksum(file);
            return bytesToHex(checksum);
        } catch (IOException | NoSuchAlgorithmException e) {
            logger.error("Failed to generate MD5 for file: {}", file.getOriginalFilename(), e);
            return null;
        }
    }

    /**
     * 计算文件MD5校验和
     *
     * @param file 文件
     * @return MD5校验和字节数组
     * @throws IOException IO异常
     * @throws NoSuchAlgorithmException 算法异常
     */
    private static byte[] createChecksum(MultipartFile file) throws IOException, NoSuchAlgorithmException {
        MessageDigest md = MessageDigest.getInstance("MD5");
        byte[] buffer = new byte[BUFFER_SIZE];
        int numRead;

        try (var inputStream = file.getInputStream()) {
            while ((numRead = inputStream.read(buffer)) > 0) {
                md.update(buffer, 0, numRead);
            }
        }

        return md.digest();
    }

    /**
     * byte数组转16进制字符串
     *
     * @param bytes byte数组
     * @return 16进制字符串
     */
    private static String bytesToHex(byte[] bytes) {
        StringBuilder result = new StringBuilder(bytes.length * 2);
        for (byte b : bytes) {
            result.append(String.format("%02x", b & 0xff));
        }
        return result.toString();
    }

    /**
     * @description: 是否开启断点续传和分片上传
     * @author YuanJie
     * @date 2025/4/3 14:54
     */
    public static boolean isEnableChunkUpload(Integer uploadId, Integer partCount, Long size) {
        boolean b = size > CHUNK_SIZE_THRESHOLD;
        // 检查输入参数是否为 null
        if (b) {
            if (uploadId == null || partCount == null || uploadId > partCount || size > DEFAULT_MAX_FILE_SIZE)
                throw new BadCommonException("文件过大/未传递分片参数");
        }
        // 判断是否满足分片上传的条件
        return b;
    }

}

2.3 ConstantStorage

ConstantStorage.java

public interface ConstantStorage {
    /**
     * 临时文件桶
     */
    String BUCKET_DEFAULT_TEMP = "bucket-temp";
    /**
     * 规则前缀过滤
     */
    String RULE_PREFIX_TEMP_1 = "temp/";
    /**
     * 规则id
     */
    String RULE_ID_1 = "abort-incomplete-uploads";
}

2.4 minio控制层

MinioController.java

@RestController
@RequestMapping("minio")
@RequiredArgsConstructor
public class MinioController {

    private final MinioProperties minioProperties;
    @Qualifier("minioService")
    private final IStorageService<Iterable<Result<Item>>, FileEntity> storageService;

    /**
     * minio服务端签名直传
     * 要求 参数前缀为 hex  且必须带后缀
     * objectName不需要和文件名一致。保持原文件名,多增加md5字段即可。
     * 不需要重命名文件名啥的。和签名路径有关,和文件名无关
     */
    @PostMapping(value = "/v1/policy")
    @RepeatSubmit(businessType = "minio_policy")
    public R policy(
            @Validated(Add.class)
            @RequestBody FileEntity fileEntity) {
        String md5 = fileEntity.getMd5();
        String fileType = fileEntity.getFileType();
        String fileName = fileEntity.getFileName();
        Long fileSize = fileEntity.getFileSize();
        Integer uploadId = fileEntity.getUploadId();
        Integer partCount = fileEntity.getPartCount();
        // 判断文件是否合法 TODO 这几个参数需要进一步加密
        boolean b = FileUtils.checkFile(md5, fileType, fileSize);
        if (!b) return R.errorResult(EnumHttpCode.FILE_LIMIT);
        String objectName = FileUtils.getFilePath(md5, fileType);
        if (objectName == null) return R.errorResult(EnumHttpCode.FILE_LIMIT);
        // 文件存在,秒传 TODO 可优化部分数据库加载到redis,md5匹配
        if (storageService.objectExists(minioProperties.getBucketName(), objectName)) {
            String fileUrl = storageService.getOnlineViewUrlByGet(minioProperties.getBucketName(), objectName);
            fileEntity.setObjectName(objectName);
            fileEntity.setFileUrl(fileUrl);
            fileEntity.setStatus(FileEntity.EnumStatus.UPLOADED);
            return R.okResult(fileEntity);
        }
        // 判断是否启用分片上传
        if (FileUtils.isEnableChunkUpload(uploadId, partCount, fileSize)) {
            fileEntity = storageService.uploadBigFile(fileEntity);
            fileEntity.setStatus(FileEntity.EnumStatus.UN_UPLOAD);
            return R.okResult(fileEntity);
        }
        // 文件未上传 给前端返回上传策略
        fileEntity = storageService.policy(fileName, minioProperties.getBucketName(), objectName,null);
        fileEntity.setStatus(FileEntity.EnumStatus.UN_UPLOAD);
        return R.okResult(fileEntity);
    }

    /**
     * 合并分片
     * @param:
     * @return:
     * @author YuanJie
     * @date: 2025/4/8 16:49
     */
    @GetMapping(value = "/v1/compose-fragment")
    public R composeFragment(@RequestParam("fileName") String fileName,
                             @RequestParam("partCount") Integer partCount,
                             @RequestParam("fileType") String fileType,
                             @RequestParam("md5") String md5) {
        if (!Pattern.matches(ConstantsRegex.MD5_REGEX, md5)) {
            return R.okResult();
        }
        return storageService.mergeFile(minioProperties.getBucketName(), fileName,fileType, partCount,md5);
    }

    /**
     * 下载文件
     *
     * @param fileName
     * @param response
     */
//    @GetMapping(value = "/download")
//    public void download(@RequestParam("fileName") String fileName, HttpServletResponse response) {
//        // 需要通过fileName查库获取objectName
//        String objectName = testName;
//        fileName = "c:/1.jpg";
//        MinioUtils.download(minioProperties.getBucketName(), objectName, fileName);
//    }

    /**
     * 获取在线预览地址
     */
    @GetMapping(value = "/v1/preview/file")
    public R getOnlineViewUrlByGet(@RequestParam("objectName") String objectName) {
        boolean b = storageService.objectExists(minioProperties.getBucketName(), objectName);
        if (!b) {
            return R.errorResult(EnumHttpCode.FILE_NOT_EXIST);
        }
        String result = storageService.getOnlineViewUrlByGet(minioProperties.getBucketName(), objectName);
        return R.okResult(result);
    }

    /**
     * 删除文件 需要通过fileName,id查库获取objectName 对应filePath
     *
     * @param
     * @return
     */
    @GetMapping(value = "/v1/delete")
    @RepeatSubmit(businessType = "minio_del")
    public R delete(@RequestParam("objectName") String objectName) {
        storageService.deleteFile(minioProperties.getBucketName(), objectName);
        return R.okResult("删除成功");
    }

}

2.5 IStorageService<T,U>统一云存储接口层

IStorageService<T,U>.java

/**
 * 统一各大云存储服务
 * @module third-server
 * @author YuanJie
 * @date 2025/4/11 10:45
 */
public interface IStorageService<T,U>{

    /**
     * 获取上传签名
     */
    U policy(String filename,String bucketName,String objectName,String contentType);

    /**
     * 预览在线文件
     */
    String getOnlineViewUrlByGet(String bucketName, String objectName);
    /**
     * 批量获取在线地址
     */
    ConcurrentHashMap<String, String> getOnlineViewUrlListByGet(String bucketName, List<String> objectNames);
    /**
     * 判断bucket是否存在
     */
    boolean bucketExists(String bucketName);
    /**
     * 判断文件是否存在
     */
    boolean objectExists(String bucketName, String objectName);
    /**
     * 创建bucket
     */
    void createBucket(String bucketName);

    /**
     * 文件上传
     */
    String upload(String bucketName, String objectName, InputStream inputStream);
    /**
     * 删除文件
     */
    void deleteFile(String bucketName, String objectName);
    /**
     * 上传大文件
     */
    FileEntity uploadBigFile(FileEntity fileEntity);
    /**
     * 合并文件
     */
    R mergeFile(String bucketName, String fileName,String fileType, Integer partCount,String md5);

    /**
     * @param bucketName 桶名
     * @param tempPath 临时路径
     * @return T 
     * @author YuanJie
     * @date 2025/4/11 10:44
     */ 
    T listObjects(String bucketName, String tempPath);
}

2.5 Minio云接口实现类

MinioServiceImpl.java

@Slf4j
@Service("minioService")
@RequiredArgsConstructor
public class MinioServiceImpl implements IStorageService<Iterable<Result<Item>>,FileEntity>{


    private final MinioProperties minioProperties;

    private final AsyncTaskExecutor taskExecutor;

    private static MinioClient minioClient;


    /**
     * 初始化minio配置
     *
     * @param :
     * @return: void
     * @date : 2024/3/4 21:00
     * @Description: 初始化minio配置 https://min.io/docs/minio/linux/developers/java/API.html
     */
    @PostConstruct
    public void init() {
        try {
            minioClient =
                    MinioClient.builder()
                            .endpoint(minioProperties.getUrl())
                            .region(minioProperties.getRegion())
                            .credentials(minioProperties.getAccessKey(), minioProperties.getSecretKey())
                            .build();
        } catch (Exception e) {
            log.error("初始化minio配置异常: 【{}】", e.getMessage());
        }
    }

    private LifecycleConfiguration getLifecycleConfiguration() {
        List<LifecycleRule> rules = new LinkedList<>();
        rules.add(
                new LifecycleRule(
                        Status.ENABLED,
                        new AbortIncompleteMultipartUpload(3),
                        null,
                        new RuleFilter(ConstantStorage.RULE_PREFIX_TEMP_1),
                        ConstantStorage.RULE_ID_1,
                        null,
                        null,
                        null)
        );
//        rules.add(
//                new LifecycleRule(
//                        Status.ENABLED,
//                        null,
//                        new Expiration((ZonedDateTime)null,1,null),
//                        new RuleFilter(ConstantStorage.RULE_TEMP_1),
//                        ConstantStorage.RULE_ID_1,
//                        null,
//                        null,
//                        null)
//        );
//        rules.add(
//                new LifecycleRule(
//                        Status.ENABLED,
//                        null,
//                        null,
//                        new RuleFilter("documents/"),
//                        "dump-data",
//                        null,
//                        null,
//                        new Transition((ZonedDateTime) null, 30, "GLACIER")));
        return new LifecycleConfiguration(rules);
    }

    /**
     * @param fileName 文件名
     * @param bucketName 桶名
     * @param objectName 对象名
     * @param contentType 文件类型
     * @return com.vector.mybatis.base.FileEntity
     * @author YuanJie
     * @date 2025/4/9 19:07
     */
    @Override
    public FileEntity policy(String fileName,String bucketName, String objectName,String contentType) {
        try {
            Map<String, String> requestParams = new HashMap<>();
            // 自定义签名参数
//            String customParam = "customValue";
//            String customSignature = generateCustomSignature(customParam);
            contentType = StringUtils.isBlank(contentType)? MediaType.APPLICATION_JSON_VALUE:contentType;
            requestParams.put("response-content-type", contentType);
//            requestParams.put("Content-Length","512");
//            requestParams.put("X-Custom-Param", customParam);
//            requestParams.put("X-Custom-Signature", customSignature);
            String url = minioClient.getPresignedObjectUrl(
                    GetPresignedObjectUrlArgs.builder()
                            .method(Method.PUT)
                            .bucket(bucketName)
                            .object(objectName)
                            .region(minioProperties.getRegion())
                            .expiry(5, TimeUnit.MINUTES)
                            .extraQueryParams(requestParams)
                            .build()
            );
            FileEntity fileEntity = new FileEntity();
            fileEntity.setFileName(fileName);
            fileEntity.setObjectName(objectName);
            fileEntity.setFileUrl(url);
            return fileEntity;
        } catch (Exception e) {
            throw new BadCommonException(e);
        }
    }

    /**
     * 判断 bucket是否存在
     *
     * @param bucketName: 桶名
     * @return: boolean
     * @date : 2024/3/4 21:00
     */
    @Override
    @SneakyThrows(Exception.class)
    public boolean bucketExists(String bucketName) {
        return minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
    }


    /**
     * 判断文件是否存在
     */
    @Override
    @SneakyThrows(Exception.class)
    public boolean objectExists(String bucketName, String objectName) {
        StatObjectResponse statObjectResponse = null;
        try {
            statObjectResponse = minioClient.statObject(StatObjectArgs.builder()
                    .bucket(bucketName)
                    .object(objectName).build()
            );
            return statObjectResponse != null;
        } catch (Exception e) {
            return false;
        }
    }


    /**
     * 创建 bucket
     *
     * @param bucketName: 桶名
     * @return void
     * @date 2024/3/4 21:00
     */
    @Override
    @SneakyThrows(Exception.class)
    public void createBucket(String bucketName) {
        boolean isExist = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
        if (!isExist) {
            minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
            if(ConstantStorage.BUCKET_DEFAULT_TEMP.equals(bucketName)){
                minioClient.setBucketLifecycle(SetBucketLifecycleArgs.builder()
                        .bucket(ConstantStorage.BUCKET_DEFAULT_TEMP)
                        .config(getLifecycleConfiguration())
                        .build());
            }
        }
    }


    /**
     * 文件上传
     *
     * @param bucketName:  桶名
     * @param objectName:  文件名
     * @param inputStream: 文件流
     * @return String
     * @date 2020/8/16 20:53
     */
    @Override
    @SneakyThrows(Exception.class)
    public String upload(String bucketName, String objectName, InputStream inputStream) {
        try (inputStream) {
            minioClient.putObject(
                    PutObjectArgs.builder()
                            .bucket(bucketName)
                            .object(objectName)
                            .stream(inputStream, inputStream.available(), -1)
                            .build()
            );
        }
        return getOnlineViewUrlByGet(bucketName, objectName);
    }


    /**
     * 删除文件
     *
     * @param bucketName: 桶名
     * @param objectName: 全路径文件名
     * @return void
     * @date  2024/3/4 21:00
     */
    @Override
    @SneakyThrows(Exception.class)
    public void deleteFile(String bucketName, String objectName) {
        minioClient.removeObject(
                RemoveObjectArgs.builder()
                        .bucket(bucketName)
                        .object(objectName)
                        .build());
    }

    /**
     * 下载文件
     *
     * @param bucketName: 桶名
     * @param objectName: 全路径文件名
     * @param fileName:   下载到本地的文件名
     * @return void
     * @date  2024/3/4 21:00
     */
//    @SneakyThrows(Exception.class)
//    public static void download(String bucketName, String objectName, String fileName) {
//        minioClient.downloadObject(
//                DownloadObjectArgs.builder()
//                        .bucket(bucketName)
//                        .object(objectName)
//                        .filename(fileName)
//                        .build()
//        );
//    }

    /**
     * 获取minio文件的预览地址 获取 HTTP 方法、到期时间和自定义请求参数的对象的预签名 URL。
     *
     * @param bucketName: 桶名
     * @param objectName: 全路径文件名
     * @return java.lang.String
     * @date  2024/3/4 21:00
     */
    @Override
    @SneakyThrows(Exception.class)
    public String getOnlineViewUrlByGet(String bucketName, String objectName) {
        return minioClient.getPresignedObjectUrl(
                GetPresignedObjectUrlArgs.builder()
                        .method(Method.GET)
                        .bucket(bucketName)
                        .object(objectName)
                        .expiry(1, TimeUnit.HOURS)
                        .build()
        );
    }

    /**
     * 批量获取minio预览地址
     * k objectName 文件名
     * v onlineViewUrl 预览地址
     */
    @Override
    @SneakyThrows(Exception.class)
    public ConcurrentHashMap<String, String> getOnlineViewUrlListByGet(String bucketName, List<String> objectNames) {
        if (CollectionUtils.isEmpty(objectNames)) {
            return new ConcurrentHashMap<>();
        }
        ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>(objectNames.size());
        List<CompletableFuture<Void>> futures = new ArrayList<>();
        for (String objectName : objectNames) {
            CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
                String onlineViewUrl = getOnlineViewUrlByGet(bucketName, objectName);
                map.put(objectName, onlineViewUrl);
            }, taskExecutor);
            futures.add(future);
        }
        CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)).get(10, TimeUnit.SECONDS);
        return map;
    }

    /**
     * 上传大文件的核心逻辑函数。
     *
     * 功能描述:
     * 1. 检查临时文件桶是否存在,若不存在则创建;
     * 2. 检测临时文件的上传进度,返回未上传完成的分片索引;
     * 3. 根据上传进度生成上传策略并更新文件实体信息。
     *
     * 参数说明:
     * @param fileEntity 文件实体对象,包含文件名、MD5值、分片总数、当前上传ID等信息。
     *                   - fileName: 文件名;
     *                   - md5: 文件的MD5值,用于唯一标识文件;
     *                   - uploadId: 当前上传的分片索引;
     *                   - partCount: 文件分片总数。
     *
     * 返回值说明:
     * @return 更新后的文件实体对象,包含以下信息:
     *         - uploadId: 当前上传的分片索引;
     *         - remainIdx: 未上传完成的分片索引集合;
     *         - objectName: 临时文件路径;
     *         - fileUrl: 文件上传的目标URL。
     */
    @Override
    @SneakyThrows(Exception.class)
    public FileEntity uploadBigFile(FileEntity fileEntity) {
        // 获取文件的基本信息
        String fileName = fileEntity.getFileName();
        String md5 = fileEntity.getMd5();
        Integer curIdx = fileEntity.getUploadId();

        // 检查临时文件桶是否存在,若不存在则创建
        if (!bucketExists(ConstantStorage.BUCKET_DEFAULT_TEMP)) {
            createBucket(ConstantStorage.BUCKET_DEFAULT_TEMP);
        }

        // 检测临时文件的上传进度,列出已上传的分片
        String tempPath = FileUtils.getTempPath(md5, null);
        Iterable<Result<Item>> results = listObjects(ConstantStorage.BUCKET_DEFAULT_TEMP, tempPath);

        // 初始化未上传和已上传分片的索引集合
        TreeSet<Integer> remainIndex = new TreeSet<>();
        TreeSet<Integer> savedIndex = new TreeSet<>();

        // 遍历已上传的分片,记录其索引并检测是否为未完成的分片
        for (Result<Item> result : results) {
            long size = result.get().size();
            Integer idx = Integer.valueOf(FileUtils.getFileName(result.get().objectName()));
            // 若分片大小小于阈值,则认为是未上传完成的分片
            if (size < FileUtils.CHUNK_SIZE_THRESHOLD) {
                remainIndex.add(idx);
            }
            savedIndex.add(idx);
        }

        // 计算未上传的分片索引
        for (int i = 0; i < fileEntity.getPartCount(); i++) {
            if (!savedIndex.contains(i)) {
                remainIndex.add(i);
            }
        }

        // 更新文件实体的上传ID和未上传分片索引
        fileEntity.setUploadId(curIdx);
        fileEntity.setRemainIdx(remainIndex);

        // 生成上传策略并更新文件实体的目标路径和URL
        tempPath = FileUtils.getTempPath(md5, curIdx);
        FileEntity policy = policy(fileName, ConstantStorage.BUCKET_DEFAULT_TEMP, tempPath, MediaType.APPLICATION_OCTET_STREAM_VALUE);
        fileEntity.setObjectName(policy.getObjectName());
        fileEntity.setFileUrl(policy.getFileUrl());

        return fileEntity;
    }


    /**
     * 合并文件。
     *
     * @param bucketName 目标存储桶名称,用于存放合并后的文件。
     * @param fileName 文件名,用于生成最终文件的路径。
     * @param fileType 文件类型(扩展名),用于生成最终文件的路径。
     * @param partCount 分片总数,表示文件被分割成的分片数量。
     * @param md5 文件的MD5值,用于标识文件的唯一性以及临时文件的路径。
     * @return 返回一个包含合并结果的对象。如果合并成功,返回合并后的文件信息;否则返回错误信息。
     *
     * 功能描述:
     * 1. 检查上传的分片是否完整。
     * 2. 如果分片不完整,返回错误信息。
     * 3. 遍历所有分片并将其合并为一个完整的文件。
     * 4. 删除临时分片文件。
     * 5. 返回合并后的文件信息。
     */
    @Override
    @SneakyThrows(Exception.class)
    public R mergeFile(String bucketName, String fileName, String fileType, Integer partCount, String md5) {
        // 获取临时文件路径,并列出该路径下的所有分片文件
        String tempPath = FileUtils.getTempPath(md5, null);
        Iterable<Result<Item>> results = listObjects(ConstantStorage.BUCKET_DEFAULT_TEMP, tempPath);

        // 记录已上传的分片文件序号
        Set<String> savedIndex = new TreeSet<>();
        for (Result<Item> result : results) {
            savedIndex.add(result.get().objectName());
        }

        // 如果已上传的分片数量与预期分片总数不一致,返回错误信息
        if (savedIndex.size() != partCount) {
            return R.okResult();
        }

        // 遍历所有分片文件,构建合并源列表
        String objectName = FileUtils.getFilePath(md5, fileType);
        List<ComposeSource> sourceList = savedIndex.stream()
                .map(filePath -> ComposeSource.builder()
                        .bucket(ConstantStorage.BUCKET_DEFAULT_TEMP)
                        .object(filePath)
                        .build())
                .toList();

        // 使用MinIO客户端将分片文件合并为目标文件
        ObjectWriteResponse response = minioClient.composeObject(
                ComposeObjectArgs.builder()
                        .bucket(bucketName)
                        .object(objectName)
                        .sources(sourceList)
                        .build()
        );

        // 删除临时分片文件
        for (String temp : savedIndex) {
            deleteFile(ConstantStorage.BUCKET_DEFAULT_TEMP, temp);
        }

        // 获取合并后文件的在线访问URL,并封装文件信息
        String fileUrl = getOnlineViewUrlByGet(bucketName, objectName);
        FileEntity fileEntity = new FileEntity();
        fileEntity.setFileUrl(fileUrl);
        fileEntity.setObjectName(objectName);
        fileEntity.setMd5(md5);

        // 返回合并成功的文件信息
        return R.okResult(fileEntity);
    }


    /**
     * @param bucketName 桶名
     * @param tempPath 临时路径
     * @return java.lang.Iterable<io.minio.Result<io.minio.messages.Item>>
     * @author YuanJie
     * @date 2025/4/11 10:46
     */
    @Override
    @SneakyThrows(Exception.class)
    public Iterable<Result<Item>> listObjects(String bucketName, String tempPath) {
            return minioClient.listObjects(
                    ListObjectsArgs.builder()
                            .bucket(bucketName)
                            .prefix(tempPath)
                            .build());
    }
}

三.前端源码实现


<template>
  <div>
    <h1>分片上传组件</h1>
    <input type="file" @change="handleFileChange" />
    <div v-if="selectedFile">
      <p>文件名: {{ selectedFile.name }}</p>
      <p>文件大小: {{ (selectedFile.size / 1024 / 1024).toFixed(2) }} MB</p>
      <p v-if="fileMd5">文件MD5: {{ fileMd5 }}</p>
      <p>上传进度:{{ current }}/{{ allUploadNum }}</p>
      <button @click="handleUpload" :disabled="!selectedFile">开始上传</button>
      <button @click="cancelUpload">取消上传</button>
    </div>
  </div>
</template>

<script lang="ts" setup>
import { ref } from "vue";
import CryptoJS from "crypto-js";

let fileMd5 = ref<string>();
let selectedFile = ref<File>();
let current = ref(0);
let allUploadNum = ref(0);
let fileInfo = ref<{
  md5: string;
  fileSize: number;
  fileType: string;
  fileName: string;
}>();
const handleFileChange = (event) => {
  const file = event.target.files[0];
  console.log(file);
  current.value = 0;
  allUploadNum.value = 0;
  if (file.size > 100 * 1024 * 1024) {
    alert("文件过大,请选择小于 100MB 的文件!");
    return;
  }
  if (file) {
    shardingOperation(file);
  }
};

//分片操作
async function shardingOperation(file: File) {
  selectedFile.value = file;

  // 计算文件MD5
  fileMd5.value = await calculateFileMD5(file);
  console.log("文件MD5", fileMd5.value);

  fileInfo.value = {
    md5: fileMd5.value,
    fileSize: file.size,
    fileName: file.name,
    fileType: "." + file.name.split(".").pop(),
  };

  console.log("分割文件结果", sliceFile(file));
  console.log("arrayBuffer", await getArrayBufFromBlobsV2(sliceFile(file)));
}
// 取消上传逻辑
async function cancelUpload() {
  console.log("取消上传");
  abortRequest();
}

async function handleUpload() {
  const url = "http://192.168.200.26:10100/third-server/minio/v1/policy";

  const uploadArray: any = await getArrayBufFromBlobsV2(
    sliceFile(selectedFile.value!)
  );

  current.value = 0;

  if (fileInfo.value!.fileSize < 5 * 1024 * 1024) {
    //获取签名
    const signature = (
      await makeRequest(
        url,
        JSON.stringify(fileInfo.value),
        "POST",
        "application/json"
      )
    ).data;

    if (signature.status == "UPLOADED") {
      alert("文件已存在!");
      return;
    }

    setTimeout(() => {
      console.log("分片数据", uploadArray);
      makeRequest(
        signature.fileUrl,
        uploadArray[0].arrayBuffer,
        "PUT",
        "application/octet-stream"
      );
    }, 10);
  } else {
    //分片上传
    setTimeout(async () => {
      console.log("分片数据", uploadArray);
      allUploadNum.value = uploadArray.length;

      //获取签名
      const uploadPromises = uploadArray.map(async (item) => {
        // console.log("分片数据", item);

        const fromData = {
          fileName: fileInfo.value!.fileName,
          uploadId: item.dataId,
          partCount: uploadArray.length,
          fileSize: fileInfo.value!.fileSize,
          fileType: fileInfo.value!.fileType,
          md5: fileInfo.value!.md5,
        };

        const singature = (
          await makeRequest(
            url,
            JSON.stringify(fromData),
            "POST",
            "application/json"
          )
        ).data;
        item.singature = singature;

        console.log("singature", singature);
      });

      await Promise.all(uploadPromises);

      // 控制并发上传
      const concurrentLimit = 4;
      let activeUploads = 0;

      // 创建所有分片的上传任务
      const uploadTasks = uploadArray.map((item) => async () => {
        // 如果该分片已经上传过,则跳过
        if (!item.singature.remainIdx.includes(item.dataId)) {
          current.value++;
          return Promise.resolve();
        }

        while (activeUploads >= concurrentLimit) {
          console.log("等待上传中...");
          // 等待直到有可用的上传槽位
          await new Promise((resolve) => setTimeout(resolve, 100));
        }

        activeUploads++;
        try {
          await makeRequest(
            item.singature.fileUrl,
            item.arrayBuffer,
            "PUT",
            "application/octet-stream"
          );
          console.log("分片上传结果", item.dataId);
          current.value++;
        } finally {
          activeUploads--;
        }
      });

      // 执行所有上传任务
      await Promise.all(uploadTasks.map((task) => task()));

      const fromData = {
        fileName: fileInfo.value!.fileName,
        partCount: uploadArray.length,
        fileType: fileInfo.value!.fileType,
        md5: fileInfo.value!.md5,
      };
      const result = await makeRequest(
        "http://192.168.200.26:10100/third-server/minio/v1/compose-fragment",
        JSON.stringify(fromData),
        "GET",
        "application/json"
      );
      console.log("分片上传结果", result);
      alert("上传成功!");
    }, 100);
  }
}
/**
 * 分割文件
 * @param file
 * @param baseSize 默认分块大小
 * @private
 */
function sliceFile(file: File, baseSize = 5): any[] {
  const chunkSize = baseSize * 1024 * 1024; // KB
  const chunks: any[] = [];
  let startPos = 0;
  let index = 0;
  while (startPos < file.size) {
    chunks.push({
      file: file.slice(startPos, startPos + chunkSize),
      index: index++,
    });
    startPos += chunkSize;
  }
  return chunks;
}
//file转成arrayBuffer
async function getArrayBufFromBlobsV2(chunks: any[]): Promise<ArrayBuffer[]> {
  //分片字节流
  const uploadArray: any[] = [];
  Promise.all(
    chunks.map(async (chunk) => {
      uploadArray.push({
        md5: fileMd5.value,
        fileSize: selectedFile.value!.size,
        fileType: selectedFile.value!.type,
        arrayBuffer: await chunk.file.arrayBuffer(),
        dataId: chunk.index,
      });
      return chunk.file.arrayBuffer();
    })
  );

  return uploadArray;
}

// Store active XHR requests
const activeRequests = new Map<string, XMLHttpRequest>();
// 发送请求封装
async function makeRequest(
  url: string,
  data: any,
  type: string,
  sendheader: any,
  requestId: string = Date.now().toString() // Unique identifier for each request
): Promise<any> {
  try {
    return new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest();

      // Store the XHR request
      activeRequests.set(requestId, xhr);

      // Get请求时,处理参数
      if (type.toUpperCase() === "GET" && data) {
        const params = new URLSearchParams(JSON.parse(data));
        url = `${url}?${params.toString()}`;
        data = null;
      }

      xhr.open(type, url, true);
      xhr.setRequestHeader("Content-Type", sendheader);

      xhr.onload = function () {
        activeRequests.delete(requestId);
        if (xhr.status === 200) {
          const response = xhr.responseText
            ? JSON.parse(xhr.responseText)
            : null;
          resolve(response as { data: { fileUrl: string } });
        } else {
          reject(new Error(`HTTP Error: ${xhr.status}`));
        }
      };

      xhr.onerror = function () {
        activeRequests.delete(requestId);
        reject(new Error("Network Error"));
      };

      xhr.onabort = function () {
        activeRequests.delete(requestId);
        reject(new Error("Request aborted"));
      };

      xhr.send(data);
    });
  } catch (error) {
    console.error("Request error:", error);
    throw error;
  }
}

// 中断请求
function abortRequest(requestId?: string) {
  if (requestId) {
    // Abort specific request
    const xhr = activeRequests.get(requestId);
    if (xhr) {
      xhr.abort();
      activeRequests.delete(requestId);
    }
  } else {
    // Abort all active requests
    activeRequests.forEach((xhr) => xhr.abort());
    activeRequests.clear();
  }
}
// 计算文件MD5
const calculateFileMD5 = (file): Promise<string> => {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();

    reader.onload = (e) => {
      if (!e.target || !(e.target.result instanceof ArrayBuffer)) {
        reject(new Error("Failed to read file as ArrayBuffer"));
        return;
      }
      const wordArray = CryptoJS.lib.WordArray.create(e.target.result);
      const md5 = CryptoJS.MD5(wordArray).toString();
      resolve(md5);
    };

    reader.onerror = (e) => {
      reject(e);
    };

    reader.readAsArrayBuffer(file);
  });
};
</script>

<style scoped></style>
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

最难不过坚持丶

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值