Vue + SpringBoot 实现文件的断点上传、秒传,存储到Minio

一、前端

1. 计算文件的md5值

前端页面使用的elment-plus的el-upload组件。

html 复制代码
    <el-upload action="#" :multiple="true" :auto-upload="false" :on-change="handleChange" :show-file-list="false">
      <FileButton content="上传文件" type="primary" class="file-button" />
    </el-upload>

当上传文件后,会调用handleChange 方法,可以在这里进行文件相关的操作。

javascript 复制代码
//处理文件上传
const handleChange = async (uploadFile) => {

  //文件名字
  let fileName = uploadFile.name

  //文件的大小
  const fileSize = uploadFile.size || 0


  //当前的文件对象
  let fileItem = {}
  fileItem.fileName = fileName
  fileItem.fileSize = fileSize
  fileItem.state = 1  //解码中
  fileItem.progress = 0  //进度是0
  fileItem.filePid = 102903232
  fileItem.fileMd5 = ""
  fileItem.uploadSize = 0

  fileUploadList.value.addFile(fileItem)

  //弹框显示
  isVisible.value = true

  //获得文件的md5
  if (uploadFile.raw) {
    await generateMD5OfFile(uploadFile.raw).then(
      res => {
        fileItem.fileMd5 = res
      }
    )
  }



  fileUploadList.value.addMd5(fileItem.fileName, fileItem.fileMd5)

  fileUploadList.value.changeFileState(fileItem.fileName, 2)

  //分片上传
  let chunkTotals = Math.ceil(fileSize / chunkSize);

  //分片上传
  if (chunkTotals > 0) {

    for (let chunkNumber = 0, start = 0; chunkNumber < chunkTotals; chunkNumber++, start += chunkSize) {
      //文件最后的end
      let end = Math.min(fileSize, start + chunkSize);
      // el-mement - plus中,上传的文件就在raw里面
      const files = uploadFile.raw?.slice(start, end)

      //上传的结果
      const result = await uploadFileToServer(files, chunkNumber + 1, chunkTotals, fileName , getCurrentId(), fileItem.fileMd5,userId)
      console.log(result.data)
      console.log(result.data.data)
      if (result.data.data.status === 1) {
        // console.log("上传中")
        //上传的进度
        fileUploadList.value.changeProgress(fileItem.fileName, ((end / fileSize) * 100).toFixed(1))
        //修改已经上传完成的文件大小
        fileUploadList.value.changeUploadSize(fileItem.fileName, end)
      
      } else if (result.data.data.status === 3) {

        // console.log("上传成功!"),这里是弹窗显示的文件上传进度,可以适当修改
        fileUploadList.value.changeFileState(fileItem.fileName, 3)  //上传完成
        fileUploadList.value.changeProgress(fileItem.fileName, 100)  // 进度100%

        //通过main,进行刷新
        $emit("addChangeNum")

        return ; //结束
      } else {
        message("上传失败", 'error')

        return;  //结束
      }
    }
  }
}

计算文件的MD5值

javascript 复制代码
//计算文件的md5
function generateMD5OfFile(file) {
    return new Promise((resolve, reject) => {
        let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice,                        // Read in chunks of 2MB
            chunks = Math.ceil(file.size / chunkSize),
            currentChunk = 0,
            spark = new SparkMD5.ArrayBuffer(),
            fileReader = new FileReader();

        fileReader.onload = function (e) {
            console.log('read chunk nr', currentChunk + 1, 'of', chunks);
            spark.append(e.target.result);                   // Append array buffer
            currentChunk++;

            if (currentChunk < chunks) {
                loadNext();
            } else {
                resolve(spark.end())
            }
        };

        fileReader.onerror = function () {

            reject('MD5 calc error')
        };

        function loadNext() {
            let start = currentChunk * chunkSize,
                end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize;

            fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
        }

        loadNext();
    })
}

2.计算文件切片数量

自定义文件切片大小

javascript 复制代码
//默认分片大小
const chunkSize = 5 * 1024 * 1024

3.分片上传文件

上传文件到服务器

javascript 复制代码
// 上传文件到服务器
const uploadFileToServer = async (file, chunkNumber, chunkTotal, fileName,filePid, fileMd5,userId) => {
    const form = new FormData();

    // 这里的data是文件
    form.append("file", file);
    form.append("chunkNumber", chunkNumber);
    form.append("chunkTotal", chunkTotal);
    form.append("fileName", fileName)
    form.append("fileMd5", fileMd5)
    form.append("filePid", filePid)
    form.append("userId", userId)


    var result = await axios({
        url: env_server_production + '/file/upload',
        headers: { 'Content-Type': 'multipart/form-data' },
        method: "post",
        timeout: 1000000,
        data: form
    })

    return result
}

4.实现相关文件的预览

可以简单的实现对一些文件的预览,比如图片、视频、word、pdf等等。

pdf:

等等

这里使用的是vue-office

javascript 复制代码
<template>
  <div class="preview-body">

    <!-- word -->
    <vue-office-docx v-if="getFileType() == 1" :src="getFileUrl()" style="height: 400px;" @rendered="renderedHandler"
      @error="errorHandler" />

    <!-- pdf -->
    <vue-office-pdf v-else-if="getFileType() == 2" :src="getFileUrl()" style="height: 400px;"
      @rendered="renderedHandler" @error="errorHandler" />

    <!-- iamge -->
    <div v-else-if="getFileType() == 3">
      <el-image :src="getFileUrl()" style="height: 100px; width: 100px;" :zoom-rate="1.2" :max-scale="7"
        :min-scale="0.2" :preview-src-list="imageList" :initial-index="4" />
      <br>
      <el-text style="margin-left: 0px;" link type="primary">点击图片查看详情</el-text>
    </div>

    <!-- 不支持显示 -->
    <div v-else-if="getFileType() == 4">
      <br>
      该文件不支持在线浏览,请下载后查看!
    </div>

    <!-- 视频 -->
    <div v-else-if="getFileType() == 5">

        <video autoplay width="1200px" height="400px" controls 
          :src="getFileUrl()"
          id="myVideo"
          >

        </video>

    </div>

    <!-- 文本显示 -->
    <div v-else>
      <el-scrollbar height="400px" class="document-preview">
        <pre>{{ documentContent }}</pre>
      </el-scrollbar>
    </div>

  </div>
</template>

<script setup>
//引入相关样式
import VueOfficeDocx from '@vue-office/docx'
import VueOfficePdf from '@vue-office/pdf'
import '@vue-office/docx/lib/index.css'
import { ref } from 'vue'
import axios from 'axios';



const props = defineProps(['file'])
const video = document.getElementById("myVideo")


const getFileUrl = () => {
  return "http://60.205.141.200:9000/" + props.file.filePath;
}
const getFileType = () => {
  let category = props.file.fileCategory

  if (category == 18 || category == 19) {
    return 1
  }
  else if (category == 13)
    return 2

  else if (category == 9 || category == 14 || category == 5) {
    imageList.value.push(getFileUrl())
    return 3
  }
  else if (category == 20 || category == 11 || category == 15)
    return 4

  else if (category == 12) {
    //视频
    return 5
  } else {
    //文本
    readDocumentContent();
  }

}

const readDocumentContent = async () => {
  var res = await axios.get(getFileUrl(), {
    responseType: 'text',
  })
  documentContent.value = `\n${res.data}\n`
}
//文件中的内容
const documentContent = ref('')
//图片列表
const imageList = ref([])

const renderedHandler = () => {
  console.log("渲染成功")
}
const errorHandler = () => {
  console.log("渲染失败")
}

</script>

<style lang="scss" scoped>
.document-preview {
  margin-right: 100px;
  background-color: #ccc;

  width: 1164px;
  border: 2px solid #ccc;
  height: 400px;
  border-radius: 0 0 10px 10px;
  text-align: left;
}
pre {
  font-family: 'Microsoft YaHei';
}
</style>

二、后端

后端使用minio,minio先接收分片文件,上传完成所有的分片文件后,在合并分片文件,删除中间文件即可。

1.接收分片文件、合并文件。

java 复制代码
/**
     * 上传文件方法。
     * 该方法负责检查文件是否已存在,如果存在,则返回已存在标志;如果不存在且是完整文件,则上传文件到MinIO并保存文件信息到数据库。
     *
     * @param fileVO 文件相关信息VO,包含文件本身、MD5、文件名等。
     * @return 如果文件已存在,返回秒传状态码;如果文件上传完成,返回上传完成状态码;否则返回null。
     * @throws GeneralException 如果文件为空,抛出通用异常。
     */
    @Override
    @Transactional(rollbackFor = Exception.class)  //所有的操作都在一个事务里面。
    public HashMap<Object, Object> uploadFile(FileVO fileVO) {

        if(fileVO.getFile().isEmpty())
            throw  new GeneralException("文件上传异常");

        FileInfo insertItem = new FileInfo();
        Date now = new Date();
        HashMap<Object, Object> map = new HashMap<>();

        //第一片文件
        if(fileVO.getChunkNumber() == 1){
            //先去数据库看看有没有这个文件
            QueryWrapper<FileInfo> queryWrapper = new QueryWrapper<>();
            queryWrapper.eq("file_md5", fileVO.getFileMd5());

            //通过Md5查询,别人是不是已经传过这个文件了(文件名不影响文件的MD5值)。
            List<FileInfo> fileInfoList = fileInfoMapper.selectList(queryWrapper);
            FileInfo fileInfo = null;
            if(fileInfoList.size() > 0){
                fileInfo = fileInfoList.get(0);
            }
            //别人已经上传过这个文件了,直接秒传
            if(fileInfo != null){
                log.info("服务器中有相同的文件,直接秒传");
                //说明minIO中有对应的文件
                insertItem.setUserId(fileVO.getUserId());
                insertItem.setFileMd5(fileVO.getFileMd5());
                insertItem.setFileName(fileInfo.getFileName());
                insertItem.setFileCategory(fileInfo.getFileCategory());
                insertItem.setFileId(StringUtil.getRandomString(10));
                insertItem.setDelFlag(FileDelFlagEnums.USING.getFlag());
                insertItem.setFilePid(fileVO.getFilePid());
                insertItem.setFilePath(fileInfo.getFilePath());
                insertItem.setCreateTime(now);
                insertItem.setFileSize(fileInfo.getFileSize());
                insertItem.setState(UploadStatus.UPLOAD_FINISH.getStatus());


                fileInfoMapper.insert(insertItem);
                System.err.println(insertItem);

                map.put("status",UploadStatus.UPLOAD_FINISH.getStatus());
                map.put("fileId",insertItem.getFileId());
                return map;
            }
            //插入 一个切片
            redisUtil.set(fileVO.getFileMd5(),0);
        }

        if(Integer.parseInt(redisUtil.get(fileVO.getFileMd5()).toString()) >= fileVO.getChunkNumber()){
            //说明这片文件已经上传过了。
            map.put("status",UploadStatus.UPLOADING.getStatus());
            return map;
        }

        //只有一段,直接放到服务器就行
        if(fileVO.getChunkTotal() == 1){
            int lastDotIndex = fileVO.getFileName().lastIndexOf(".");
            String type = fileVO.getFileName().substring(lastDotIndex + 1);

            String url = minioUtils.uploadFile(MessageConstant.MINIO_BUCKET,fileVO.getFileName(), fileVO.getFile());

            insertItem.setUserId(fileVO.getUserId());
            insertItem.setFileMd5(fileVO.getFileMd5());
            insertItem.setFileName(fileVO.getFileName());
            insertItem.setFileCategory(FileCategoryEnums.getByCode(type).getCategory());
            insertItem.setFileId(StringUtil.getRandomString(10));
            insertItem.setDelFlag(FileDelFlagEnums.USING.getFlag());
            insertItem.setFilePid(fileVO.getFilePid());
            insertItem.setFilePath(url);
            insertItem.setCreateTime(now);
            insertItem.setFileSize(fileVO.getFile().getSize());
            insertItem.setState(UploadStatus.UPLOAD_FINISH.getStatus());

            fileInfoMapper.insert(insertItem);

            //删除redis中的切片上传信息
            redisUtil.del(fileVO.getFileMd5());
            map.put("status",UploadStatus.UPLOAD_FINISH.getStatus());
            map.put("fileId",insertItem.getFileId());

            return map;
        }

        log.info("分片上传====> md5 :{} ,=====> index :{}",fileVO.getFileMd5(),fileVO.getChunkNumber());
        //不止一片,继续上传
        //放切片文件的目录是 文件的userId + md5值,这个是唯一的。
        String objectName = fileVO.getUserId() + fileVO.getFileMd5() ;

        try {
            minioUtils.putChunkObject(fileVO.getFile().getInputStream(), MessageConstant.MINIO_BUCKET, objectName + "/" + fileVO.getChunkNumber());
        } catch (IOException e) {
            throw new GeneralException("文件上传异常!");
        }

        //最后一片,进行合并
        if(Objects.equals(fileVO.getChunkNumber(), fileVO.getChunkTotal())){

            //获得文件类型
            int lastDotIndex = fileVO.getFileName().lastIndexOf(".");
            String type = fileVO.getFileName().substring(lastDotIndex + 1);

            //objectName : userId+md5
            String filePath = minioUtils.composeObject(MessageConstant.MINIO_BUCKET,MessageConstant.MINIO_BUCKET,objectName, type);


            insertItem.setUserId(fileVO.getUserId());
            insertItem.setFileMd5(fileVO.getFileMd5());
            insertItem.setFileName(fileVO.getFileName());
            insertItem.setFileCategory(FileCategoryEnums.getByCode(type).getCategory());
            insertItem.setFileId(StringUtil.getRandomString(10));
            insertItem.setDelFlag(FileDelFlagEnums.USING.getFlag());
            insertItem.setFilePid(fileVO.getFilePid());
            insertItem.setFilePath(filePath);
            insertItem.setCreateTime(now);
            Long fileSize = MessageConstant.DEFAULT_CHUNK_SIZE * (fileVO.getChunkTotal() - 1) + fileVO.getFile().getSize();
            insertItem.setFileSize(fileSize);
            insertItem.setState(UploadStatus.UPLOAD_FINISH.getStatus());

            //插入一条数据
            System.out.println(fileInfoMapper.insert(insertItem));

            //删除minio中的临时文件目录
            System.out.println(minioUtils.deleteFolder(MessageConstant.MINIO_BUCKET, objectName));

            //删除redis中的切片上传信息
            redisUtil.del(fileVO.getFileMd5());
            map.put("status",UploadStatus.UPLOAD_FINISH.getStatus());
            map.put("fileId",insertItem.getFileId());

            return map;
        }

        //更新redis中的切片上传信息
        redisUtil.incrby(fileVO.getFileMd5(),1);

        //上传中
        map.put("status",UploadStatus.UPLOADING.getStatus());
        return map;
    }

如何做到秒传?

一个文件有个不重复的md5值,所谓的秒传其实就是你要上传的文件,别人已经上传过了,minio中已经有这个文件了,再解析完文件的md5值之后,后端发现数据库中md5存在了,所以就不用上传文件了,直接在数据库中创建一个信息即可,也就实现了秒传。

如何做到断点传递?

传统传递过程是一整个文件上传,如果中断了下次传的时候,需要重新上传;断点传递,每次传递的时候,可以把分片信息放到redis中,同时下一次传分片的时候,判断一下,redis中时候已经有了这个分片,如果有就不用上传此分片文件,即断点传递。

相关推荐
崔庆才丨静觅12 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606113 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了13 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅13 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅13 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅14 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment14 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅14 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊14 小时前
jwt介绍
前端
爱敲代码的小鱼14 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax