SpringBoot 大文件基于md5实现分片上传、断点续传、秒传

news2024/12/27 13:09:43

SpringBoot 大文件基于md5实现分片上传、断点续传、秒传

  • SpringBoot 大文件基于md5实现分片上传、断点续传、秒传
  • 前言
  • 1. 基本概念
    • 1.1 分片上传
    • 1.2 断点续传
    • 1.3 秒传
    • 1.4 分片上传的实现
  • 2. 分片上传前端实现
    • 2.1 什么是WebUploader?
      • 功能特点
      • 接口说明
      • 事件API
      • Hook 机制
    • 2.2 前端代码实现
      • 2.2.1 模块引入
      • 2.2.2 核心代码
        • 核心分片组件:WebUpload.vue
        • 引用组件:App.vue
      • 2.2.3 项目结构和运行效果
  • 3 .分片上传后端实现
    • 3.1 项目结构和技术介绍
    • 3.2 核心代码
      • 控制类:FileUploadController.java
      • 核心实现方法:FileZoneRecordServiceImpl.java
  • 4. 项目运行测试
    • 4.1 测试效果
    • 4.2 数据库记录
    • 4.3 上传目录文件
    • 4.4 网络访问上传的文件
  • 5. 项目源码
  • 6.参考链接

SpringBoot 大文件基于md5实现分片上传、断点续传、秒传

阅读说明:

  • 本文适用于有初级后端开发基础或者初级前端开发者的人群
  • 如果不想看相关技术介绍,可以直接跳转到第2,3章节,可运行项目的前后端源码在文末
  • 后端地址: git clone https://gitee.com/zhouquanstudy/springboot-file-chunk-md5.git
  • 前端地址: git clone https://gitee.com/zhouquanstudy/file-chunk-upload-md5.git

如有疑问或者错误之处,敬请指正

前言

在项目开发中需要上传非常大的文件时,单次上传整个文件往往会遇到网络不稳定、带宽限制、上传失败等问题。为了解决这些问题,文件分片上传(也称为断点续传)应运而生。本文将介绍大文件上传的基本概念及其在 SpringBoot 中的实现方法,包括分片上传、断点续传和秒传技术。效果图如下:

分片上传md5

1. 基本概念

1.1 分片上传

分片上传的核心思想是将一个大文件分成若干份大小相等的多个小块数据块(称为 Part)。所有小块文件上传成功后,再将其合并成完整的原始文件。

分片上传的优点:

  • 断点续传:在网络中断或其他错误导致上传失败时,只需重新上传失败的部分,而不必从头开始上传整个文件,从而提高上传的可靠性和效率。
  • 降低网络压力:分片上传可以控制每个片段的大小,避免一次性传输大量数据导致的网络拥堵,提高网络资源的利用率。
  • 并行上传:多个分片可以同时上传,加快整体上传速度。
  • 灵活处理:服务器可以更灵活地处理和存储文件分片,减少内存和带宽的占用。

1.2 断点续传

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

断点续传的实现过程:

  1. 前端将文件按百分比进行计算,每次上传文件的百分之一(文件分片),给文件分片编号。
  2. 后端将前端每次上传的文件放入缓存目录。
  3. 前端全部文件上传完毕后,发送合并请求。
  4. 后端使用 RandomAccessFile 进行多线程读取所有分片文件,一个线程一个分片。
  5. 后端每个线程按序号将分片文件写入目标文件中。
  6. 上传过程中发生断网或手动暂停,下次上传时发送续传请求,后端删除最后一个分片。
  7. 前端重新发送上次的文件分片。

1.3 秒传

文件上传中的“秒传”是一种优化文件上传过程的技术。其主要原理是通过文件的特征值(通常是文件的哈希值,如 MD5、SHA-1 或 SHA-256 等)来判断文件是否已经存在于服务器上,从而避免重复上传相同的文件。

秒传的具体流程:

  1. 计算文件哈希值:客户端在开始上传文件之前,计算文件的哈希值。
  2. 发送哈希值:客户端将计算得到的哈希值发送给服务器。
  3. 服务器校验:服务器根据收到的哈希值查询数据库或文件存储系统,判断是否已存在相同哈希值的文件。
    • 如果文件已存在:服务器直接返回文件已存在的信息,客户端即可认为上传完成,不需实际上传文件数据。
    • 如果文件不存在:服务器通知客户端继续上传文件数据。
  4. 上传文件数据:如果服务器通知文件不存在,客户端实际上传文件数据,服务器接收后存储并更新相应哈希值记录。

秒传的优点:

  • 节省带宽:避免重复上传相同的文件,特别是在大文件上传场景中效果显著。
  • 加快上传速度:用户体验更好,对于已存在的文件可以实现“秒传”。
  • 减轻服务器负担:减少不必要的数据传输和存储压力。

秒传技术广泛应用于网盘、云存储、文件共享平台等场景中。

1.4 分片上传的实现

在 SpringBoot 中,可以通过以下步骤实现分片上传:

2.1 前端实现

前端使用 WebUploader 等库实现分片上传。具体步骤如下:

  1. 使用 WebUploader 初始化上传组件,设置分片大小及其他参数。
  2. 在文件分片上传前,计算每个分片的哈希值并发送到服务器。
  3. 服务器验证分片的哈希值,返回是否需要上传该分片。
  4. 前端根据服务器返回结果,决定是否上传分片。

2.2 后端实现

后端可以使用 SpringBoot 提供的文件上传接口来处理分片上传请求。具体步骤如下:

  1. 接收并验证前端发送的分片文件及其哈希值。
  2. 将分片文件保存到临时目录。
  3. 保存分片文件信息(如序号、哈希值等)到数据库。
  4. 在接收到所有分片后,合并分片文件为完整文件。

2. 分片上传前端实现

技术栈或技术点:vue、webuploader、elmentui

2.1 什么是WebUploader?

WebUploader 是由百度公司开发的一个现代文件上传组件,主要基于 HTML5,同时辅以 Flash 技术。它支持大文件的分片上传,提高了上传效率,并且兼容主流浏览器。

官网地址: [Web Uploader - Web Uploader (fex-team.github.io)](http://fex.baidu.com/webuploader/)

image-20240608212651303

功能特点

  1. 分片、并发上传: WebUploader 支持将大文件分割成小片段并行上传,极大地提高了上传效率。
  2. 预览、压缩: 支持常用图片格式(如 jpg、jpeg、gif、bmp、png)的预览和压缩,节省了网络传输数据量。
  3. 多途径添加文件: 支持文件多选、类型过滤、拖拽(文件和文件夹)以及图片粘贴功能。
  4. HTML5 & FLASH: 兼容所有主流浏览器,接口一致,不需要担心内部实现细节。
  5. MD5 秒传: 通过 MD5 值验证,避免重复上传相同文件。
  6. 易扩展、可拆分: 采用模块化设计,各功能独立成小组件,可自由组合搭配。

接口说明

WebUploader 提供了丰富的接口和钩子函数,以下是几个关键的接口:

  • before-send-file: 在文件发送之前执行。
  • before-file: 在文件分片后、上传之前执行。
  • after-send-file: 在所有文件分片上传完毕且无错误时执行。

WebUploader 的所有代码都在一个闭包中,对外只暴露了一个变量 WebUploader,避免与其他框架冲突。所有内部类和功能都通过 WebUploader 命名空间进行访问。

事件API

Uploader 实例拥有类似 Backbone 的事件 API,可以通过 onoffoncetrigger 进行事件绑定和触发。

uploader.on('fileQueued', function(file) {
    // 处理文件加入队列的事件
});

 this.uploader.on('uploadSuccess', (file, response) => {
     // 上传成功事件
});

除了通过 on 绑定事件外,还可以直接在 Uploader 实例上添加事件处理函数:

uploader.onFileQueued = function(file) {
    // 处理文件加入队列的事件
};

Hook 机制

关于hook机制的个人理解:Hook机制就像是在程序中的特定事件或时刻(比如做地锅鸡的时候)设定一些“钩子”。当这些事件发生时,程序会去“钩子”上找有没有要执行的额外功能,然后把这些功能执行一下。这就好比在做地锅鸡的过程中,你可以在某个步骤(比如炖鸡的时候)加上自己的调料或额外的配菜,来调整和丰富最终的味道,而不需要改动整体的食谱。

Uploader 内部功能被拆分成多个小组件,通过命令机制进行通信。例如,当用户选择文件后,filepicker 组件会发送一个添加文件的请求,负责队列的组件会根据配置项处理文件并决定是否加入队列。

webUploader.Uploader.register(
  {
    'before-send-file': 'beforeSendFile',
    'before-send': 'beforeSend',
    'after-send-file': 'afterSendFile'
  },
  {
    // 时间点1:所有分块进行上传之前调用此函数
    beforeSendFile: function(file) {
      // 利用 md5File() 方法计算文件的唯一标记符
      // 创建一个 deferred 对象
      var deferred = webUploader.Deferred();
      // 计算文件的唯一标记,用于断点续传和秒传
      // 请求后台检查文件是否已存在,实现秒传功能
      return deferred.promise();
    },
    // 时间点2:如果有分块上传,则每个分块上传之前调用此函数
    beforeSend: function(block) {
      // 向后台发送当前文件的唯一标记
      // 请求后台检查当前分块是否已存在,实现断点续传功能
      var deferred = webUploader.Deferred();
      return deferred.promise();
    },
    // 时间点3:所有分块上传成功之后调用此函数
    afterSendFile: function(file) {
      // 前台通知后台合并文件
      // 请求后台合并所有分块文件
    }
  }
);

2.2 前端代码实现

2.2.1 模块引入

在已有项目或者新的空vue项目中先执行下列命令

# 引入分片需要
npm install webuploader
npm install jquery@1.12.4

image-20240608223139745

image-20240608223551207

2.2.2 核心代码

核心分片组件:WebUpload.vue
<template>
  <div class="center-container">
    <div class="container">
      <div class="handle-box">
        <el-button type="primary" id="extend-upload-chooseFile" icon="el-icon-upload2">
          选择文件
        </el-button>
        <div class="showMsg">支持上传的文件后缀:<span style="color: #f10808; font-size: 18px">{{
            options.fileType
          }}</span></div>
      </div>
      <el-table :data="fileList" style="width: 100%">
        <el-table-column prop="fileName" label="文件名称" align="center" width="180"></el-table-column>
        <el-table-column prop="fileSize" align="center" label="文件大小" width="180"></el-table-column>
        <el-table-column label="进度" align="center" width="300">
          <template slot-scope="scope">
            <div class="progress-container">
              <el-progress :text-inside="true" :stroke-width="15" :percentage="scope.row.percentage"></el-progress>
            </div>
          </template>
        </el-table-column>
        <el-table-column label="上传速度" align="center" width="150">
          <template slot-scope="scope">
            <div>{{ scope.row.speed }}</div>
          </template>
        </el-table-column>
        <el-table-column label="操作" align="center" fixed="right">
          <template slot-scope="scope">
            <el-button type="text" icon="el-icon-close" class="red" @click="removeRow(scope.$index, scope.row)">移除
            </el-button>
          </template>
        </el-table-column>
      </el-table>
    </div>
  </div>
</template>

<script>
import $ from 'jquery'
import webUploader from 'webuploader'

export default {
  name: 'WebUpload',
  props: {
    headers: {
      type: String,
      default: ''
    },
    fileNumLimit: {
      type: Number,
      default: 100
    },
    fileSize: {
      type: Number,
      default: 100 * 1024 * 1024 * 1024
    },
    chunkSize: {
      type: Number,
      default: 1 * 1024 * 1024
    },
    uploadSuffixUrl: {
      type: String,
      default: 'http://localhost:8810'
    },
    options: {
      default: function () {
        return {
          fileType: 'doc,docx,pdf,xls,xlsx,ppt,pptx,gif,jpg,jpeg,bmp,png,rar,zip,mp4,avi',
          fileUploadUrl: '/v1/upload/zone/zoneUpload', //上传地址
          fileCheckUrl: '/v1/upload/zone/md5Check', //检测文件是否存在url
          checkChunkUrl: '/v1/upload/zone/md5Check', //检测分片url
          mergeChunksUrl: '/v1/upload/zone/merge', //合并文件请求地址 提交测试
          headers: {}
        }
      }
    },

    fileListData: {
      type: Array,
      default: function () {
        return []
      }
    }
  },
  data() {
    return {
      fileList: [], // 存储等待上传文件列表的数组
      percentage: 0, // 上传进度,初始化为0
      uploader: {}, // WebUploader实例对象
      uploadStatus: 'el-icon-upload', // 上传状态图标,默认为上传图标
      uploadStartTime: null, // 文件上传开始时间
      uploadedFiles: [] // 存储上传成功文件信息的数组
    }
  },
  mounted() {
    this.register()
    this.initUploader()
    this.initEvents()

    // 监视 fileListData 变化,并将其赋值给 fileList
    this.$watch('fileListData', (newVal) => {
      this.fileList = [...newVal];
    });

  },
  methods: {
    initUploader() {
      var fileType = this.options.fileType
      this.uploader = webUploader.create({
        // 不压缩image
        resize: false,
        // swf文件路径
        swf: '../../../assets/Uploader.swf', // swf文件路径 兼容ie的,可以不设置
        // 默认文件接收服务端。
        server: this.uploadSuffixUrl + this.options.fileUploadUrl,
        pick: {
          id: '#extend-upload-chooseFile', //指定选择文件的按钮容器
          multiple: false //开启文件多选,
        },
        accept: [
          {
            title: 'file',
            extensions: fileType,
            mimeTypes: this.buildFileType(fileType)
          }
        ],
        compressSize: 0,
        fileNumLimit: this.fileNumLimit,
        fileSizeLimit: 2 * 1024 * 1024 * 1024 * 1024,
        fileSingleSizeLimit: this.fileSize,
        chunked: true,
        threads: 10,
        chunkSize: this.chunkSize,
        prepareNextFile: false,
      })
    },

    register() {
      const that = this;
      const options = this.options;
      const uploadSuffixUrl = this.uploadSuffixUrl;
      const fileCheckUrl = uploadSuffixUrl + options.fileCheckUrl;
      const checkChunkUrl = uploadSuffixUrl + options.checkChunkUrl;
      const mergeChunksUrl = uploadSuffixUrl + options.mergeChunksUrl;

      webUploader.Uploader.register(
          {
            'before-send-file': 'beforeSendFile',
            'before-send': 'beforeSend',
            'after-send-file': 'afterSendFile'
          },
          {
            beforeSendFile: function (file) {
              const deferred = webUploader.Deferred();

              new webUploader.Uploader()
                  .md5File(file, 0, 10 * 1024 * 1024)
                  .progress(function () {
                  })
                  .then(function (val) {
                    file.fileMd5 = val

                    $.ajax({
                      type: 'POST',
                      url: fileCheckUrl,
                      data: {
                        checkType: 'FILE_EXISTS',
                        contentType: file.type,
                        zoneTotalMd5: val
                      },
                      dataType: 'json',
                      success: function (response) {
                        if (response.success) {
                          that.uploader.skipFile(file)
                          // 更新进度条
                          that.percentage = 1
                          that.$notify.success({

                            showClose: true,
                            message: `[ ${file.name} ]文件秒传`
                          })

                          that.uploadedFiles.push(response.data)
                          deferred.reject()
                        } else {
                          if (response.code === 30001) {
                            const m = response.message + ',文件后缀:' + file.ext;
                            that.uploader.skipFile(file)
                            that.setTableBtn(file.id, m)
                            that.uploadedFiles.push(response.data)
                            deferred.reject()
                          } else {
                            deferred.resolve()
                          }
                        }
                      }
                    })
                  })

              return deferred.promise()
            },
            beforeSend: function (block) {
              const deferred = webUploader.Deferred();

              new webUploader.Uploader()
                  .md5File(block.file, block.start, block.end)
                  .progress(function () {
                  })
                  .then(function (val) {
                    block.zoneMd5 = val
                    $.ajax({
                      type: 'POST',
                      url: checkChunkUrl,
                      data: {
                        checkType: 'ZONE_EXISTS',
                        zoneTotalMd5: block.file.fileMd5,
                        zoneMd5: block.zoneMd5
                      },
                      dataType: 'json',
                      success: function (response) {
                        if (response.success) {
                          deferred.reject()
                        } else {
                          deferred.resolve()
                        }
                      }
                    })
                  })
              return deferred.promise()
            },
            afterSendFile: function (file) {
              $.ajax({
                type: 'POST',
                url: mergeChunksUrl + "?totalMd5=" + file.fileMd5,
                dataType: 'JSON',
                success: function (res) {
                  if (res.success) {
                    const data = res.data.fileInfo;
                    that.uploader.skipFile(file)
                    // 更新进度条
                    that.percentage = 1
                    that.uploadedFiles.push(data)
                  }
                }

              })
            }
          }
      )
    },
    initEvents() {
      const that = this;
      const uploader = this.uploader;

      uploader.on('fileQueued', function (file) {
        // 清空现有文件列表,实现只上传单个文件
        if (!this.multiple) {
          this.fileList = []
          this.uploadedFiles = []
        }
        const fileSize = that.formatFileSize(file.size);
        const row = {
          fileId: file.id,
          fileName: file.name,
          fileSize: fileSize,
          validateMd5: '0%',
          progress: '等待上传',
          percentage: 0,
          speed: '0KB/s',
          state: '就绪'
        };
        that.fileList.push(row)
        that.uploadToServer()
      })

      this.uploader.on('uploadProgress', (file, percentage) => {
        // 找到对应文件并更新进度和速度
        let targetFile = this.fileList.find(item => item.fileId === file.id)
        if (targetFile) {
          // 计算上传速度
          const currentTime = new Date().getTime()
          const elapsedTime = (currentTime - (targetFile.startTime || currentTime)) / 1000 // 秒
          const uploadedSize = percentage * file.size
          const speed = this.formatFileSize(uploadedSize / elapsedTime) + '/s'
          // 更新文件信息
          targetFile.percentage = parseFloat((percentage * 100).toFixed(2))
          targetFile.speed = speed
          targetFile.startTime = targetFile.startTime || currentTime
        }
      })

      this.uploader.on('uploadSuccess', (file, response) => {
        this.uploadedFiles = []
        if (response.code === 10000) {
          response.data.fileName = response.data.originalName
          response.data.percentage = this.fileList[0].percentage
          response.data.fileSize = this.fileList[0].fileSize
          response.data.speed = this.fileList[0].speed
          this.uploadedFiles.push(response.data)
          // this.$message.success('上传完成')
        } else {
          this.$message.error('上传失败: ' + response.message)
        }
      })

      /**上传之前**/
      uploader.on('uploadBeforeSend', function (block, data, headers) {
        data.fileMd5 = block.file.fileMd5
        data.contentType = block.file.type
        data.chunks = block.file.chunks
        data.zoneTotalMd5 = block.file.fileMd5
        data.zoneMd5 = block.zoneMd5
        data.zoneTotalCount = block.chunks
        data.zoneNowIndex = block.chunk
        data.zoneTotalSize = block.total
        data.zoneStartSize = block.start
        data.zoneEndSize = block.end
        headers.Authorization = that.options.headers.Authorization
      })

      uploader.on('uploadFinished', function () {
        that.percentage = 1
        that.uploadStaus = 'el-icon-upload'
        that.$message.success({
          showClose: true,
          message: '文件上传完毕'
        })
      })
    },

    setTableBtn(fileId, showmsg, sid) {
      var fileList = this.fileList
      for (var i = 0; i < fileList.length; i++) {
        if (fileList[i].fileId == fileId) {
          this.fileList[i].progress = showmsg
          this.fileList[i].sid = sid || ''
        }
      }
    },
    removeRow(index, row) {
      this.fileList.splice(index, 1)
      this.removeFileFromUploaderQueue(row.fileId)
      this.$emit('removeRow', index, row)
    },

    removeFileFromUploaderQueue(fileId) {
      const files = this.uploader.getFiles()
      for (let i = 0; i < files.length; i++) {
        if (files[i].id === fileId) {
          this.uploader.removeFile(files[i], true)
          break
        }
      }
    },

    uploadToServer() {
      this.uploadStatus = 'el-icon-loading'
      this.uploadStartTime = new Date()
      this.uploader.upload()
    },

    clearFiles() {
      const that = this
      that.uploadStaus = 'el-icon-upload'
      that.uploader.reset()
      this.$emit('clearFiles', [])
    },
    buildFileType(fileType) {
      var ts = fileType.split(',')
      var ty = ''

      for (var i = 0; i < ts.length; i++) {
        ty = ty + '.' + ts[i] + ','
      }
      return ty.substring(0, ty.length - 1)
    },
    strIsNull(str) {
      if (typeof str == 'undefined' || str == null || str == '') {
        return true
      } else {
        return false
      }
    },
    formatFileSize(size) {
      var fileSize = 0
      if (size / 1024 > 1024) {
        var len = size / 1024 / 1024
        fileSize = len.toFixed(2) + 'MB'
      } else if (size / 1024 / 1024 > 1024) {
        len = size / 1024 / 1024
        fileSize = len.toFixed(2) + 'GB'
      } else {
        len = size / 1024
        fileSize = len.toFixed(2) + 'KB'
      }
      return fileSize
    }
  }
}
</script>
<style>
.center-container {
  transform: scale(1.1); /* 缩放整个容器 */
  margin-left: 300px;
  justify-content: center;
  align-items: center;
  height: 100vh; /* 让容器占满整个视口高度 */
}

.container {
  padding: 30px;
  border: 1px solid #312828;
  border-radius: 5px;
}

.handle-box {
  margin-bottom: 20px;
}

#picker div:nth-child(2) {
  width: 100% !important;
  height: 100% !important;
}


.webuploader-element-invisible {
  position: absolute !important;
  clip: rect(1px 1px 1px 1px); /* IE6, IE7 */
  clip: rect(1px, 1px, 1px, 1px);
}

.webuploader-pick-hover {
  background: #409eff;
}

/* 统一设置 label 的字体大小 */
.el-table-column label {
  font-size: 30px;
}

.showMsg {
  margin: 5px;
  font-size: 16px;
}
</style>

引用组件:App.vue
<template>
  <div id="app">
    <main>
      <el-form :span="20">
        <el-col :span="20">
          <el-form-item>
            <!-- 分片上传组件 -->
            <WebUpload></WebUpload>
          </el-form-item>
        </el-col>
      </el-form>
    </main>
  </div>
</template>

<script>
import WebUpload from './components/WebUpload.vue'

export default {
  name: 'App',
  components: {
    WebUpload
  }
}
</script>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
</style>

同时使用了样式,因此需要引入element-ui

npm install element-ui -S

# main.js中内容
import Vue from 'vue';
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
import App from './App.vue';

Vue.use(ElementUI);

new Vue({
  el: '#app',
  render: h => h(App)
});

2.2.3 项目结构和运行效果

执行npm run sever运行后页面效果和最终项目代码结构

image-20240614075652044

3 .分片上传后端实现

3.1 项目结构和技术介绍

本项目的后端采用Spring Boot框架,结合MyBatis-Plus以提高数据库操作的效率。数据库使用MySQL,提供高性能和可靠性。这些技术的组合确保了系统的稳定性和高效性,并简化了开发和维护过程

image-20240614080027271

3.2 核心代码

控制类:FileUploadController.java

FileUploadController类负责处理文件上传相关的操作。其主要功能包括:

  1. 大文件分片上传:处理前端分片上传的大文件请求,接收并记录文件片段信息。
  2. MD5校验:校验文件或分片的MD5值,检查文件或分片是否已经存在,以避免重复上传。
  3. 文件合并:在所有分片上传完成后,将所有分片合并成一个完整的文件。
package com.example.zhou.controller;

import com.example.zhou.common.Result;
import com.example.zhou.common.ResultCode;
import com.example.zhou.entity.ArchiveZoneRecord;
import com.example.zhou.entity.enums.CheckType;
import com.example.zhou.param.FileUploadResultBO;
import com.example.zhou.param.ZoneUploadResultBO;
import com.example.zhou.service.IFileZoneRecordService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.validation.constraints.NotNull;
import java.util.Date;

/**
 * @author ZhouQuan
 * @desciption 文件上传操作录控制类
 * @date 2024/5/4 17:09
 */
@Validated
@Slf4j
@RestController
@RequestMapping("/v1/upload/zone")
public class FileUploadController {

    @Resource
    private IFileZoneRecordService iFileZoneRecordService;

    /**
     * 大文件分片上传
     *
     * @param multipartFile    文件二进制数据
     * @param id               文件ID
     * @param name             文件名称
     * @param type             文件类型
     * @param lastModifiedDate 最后修改日期
     * @param fileMd5          文件MD5
     * @param zoneTotalMd5     总分片MD5
     * @param zoneMd5          当前分片MD5
     * @param zoneTotalCount   总分片数量
     * @param zoneNowIndex     当前分片序号
     * @param zoneTotalSize    文件总大小
     * @param zoneStartSize    文件开始位置
     * @param zoneEndSize      文件结束位置
     * @param request          HttpServletRequest 对象
     * @return 返回上传结果
     */
    @PostMapping("/zoneUpload")
    public Result zoneUpload(
            @RequestParam("file") @NotNull(message = "文件不能为空") MultipartFile multipartFile,
            @RequestParam("id") String id,
            @RequestParam("name") String name,
            @RequestParam("type") String type,
            @RequestParam("lastModifiedDate") Date lastModifiedDate,
            @RequestParam("fileMd5") String fileMd5,
            @RequestParam("zoneTotalMd5") String zoneTotalMd5,
            @RequestParam("zoneMd5") String zoneMd5,
            @RequestParam("zoneTotalCount") int zoneTotalCount,
            @RequestParam("zoneNowIndex") int zoneNowIndex,
            @RequestParam("zoneTotalSize") long zoneTotalSize,
            @RequestParam("zoneStartSize") long zoneStartSize,
            @RequestParam("zoneEndSize") long zoneEndSize,
            HttpServletRequest request) {
        long startTime = System.currentTimeMillis();

        // 使用构造函数初始化 ArchiveZoneRecord 对象
        ArchiveZoneRecord archiveZoneRecord = new ArchiveZoneRecord(
                id, name, type, lastModifiedDate, fileMd5, zoneTotalMd5, zoneMd5,
                zoneTotalCount, zoneNowIndex, zoneTotalSize, zoneStartSize, zoneEndSize
        );

        // 调用服务方法进行上传
        ZoneUploadResultBO resultBo = iFileZoneRecordService.zoneUpload(request, archiveZoneRecord, multipartFile);

        long endTime = System.currentTimeMillis();
        log.info("zoneUpload 上传耗时:{} ms", (endTime - startTime));

        return new Result(ResultCode.SUCCESS, resultBo);
    }


    /**
     * 校验文件或者分片的md5值
     *
     * @param ArchiveZoneRecord 文件或者分片信息
     * @param checkType         FILE_EXISTS:校验文件是否存在,ZONE_EXISTS:校验分片是否存在
     * @param request
     * @return
     */
    @PostMapping("/md5Check")
    public Result md5Check(ArchiveZoneRecord ArchiveZoneRecord, CheckType checkType, HttpServletRequest request) {
        long l = System.currentTimeMillis();
        Result result = iFileZoneRecordService.md5Check(ArchiveZoneRecord, checkType, request);
        log.info("md5Check校验耗时:{}", System.currentTimeMillis() - l);
        return result;
    }

    /**
     * 合并文件
     * 前端所有分片上传完成时,发起请求,将所有的文件合并成一个完整的文件
     *
     * @param totalMd5 总文件的MD5值
     * @param request
     * @return
     */
    @PostMapping("/merge")
    public Result mergeZoneFile(@RequestParam("totalMd5") String totalMd5, HttpServletRequest request) {
        long l = System.currentTimeMillis();
        FileUploadResultBO result = iFileZoneRecordService.mergeZoneFile(totalMd5, request);
        log.info("merge合并校验耗时:{}", System.currentTimeMillis() - l);
        return new Result(ResultCode.SUCCESS, result);
    }

}

核心实现方法:FileZoneRecordServiceImpl.java

package com.example.zhou.service.impl;

import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.zhou.common.Result;
import com.example.zhou.common.ResultCode;
import com.example.zhou.config.FileUploadConfig;
import com.example.zhou.entity.Archive;
import com.example.zhou.entity.ArchiveZoneRecord;
import com.example.zhou.entity.enums.CheckType;
import com.example.zhou.mapper.ArchiveMapper;
import com.example.zhou.mapper.ArchiveRecordMapper;
import com.example.zhou.param.FileUploadResultBO;
import com.example.zhou.param.ZoneUploadResultBO;
import com.example.zhou.service.IFileRecordService;
import com.example.zhou.service.IFileZoneRecordService;
import com.example.zhou.utils.FileHandleUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import org.springframework.util.DigestUtils;
import org.springframework.web.multipart.MultipartFile;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Date;
import java.util.List;
import java.util.UUID;

@Slf4j
@Service
public class FileZoneRecordServiceImpl extends ServiceImpl<ArchiveRecordMapper, ArchiveZoneRecord> implements IFileZoneRecordService {

    @Resource
    private ArchiveMapper archiveMapper;

    @Resource
    private FileUploadConfig fileUploadConfig;

    @Resource
    private IFileRecordService fileRecordService;

    @Resource
    private ArchiveRecordMapper archiveRecordMapper;

    @Override
    public ZoneUploadResultBO zoneUpload(HttpServletRequest request, ArchiveZoneRecord archiveZoneRecord,
                                         MultipartFile multipartFile) {
        if (multipartFile.isEmpty()) {
            // 如果文件为空,返回错误信息
            throw new RuntimeException("请选择文件");
        }

        try {
            // 根据UUID生成同步锁,避免多线程竞争,确保线程安全

            // 根据MD5和zoneTotalMd5查询分片记录
            ArchiveZoneRecord zoneRecord =
                    archiveRecordMapper.selectOne(Wrappers.<ArchiveZoneRecord>lambdaQuery()
                            .eq(ArchiveZoneRecord::getZoneMd5, archiveZoneRecord.getZoneMd5())
                            .eq(ArchiveZoneRecord::getZoneTotalMd5, archiveZoneRecord.getZoneTotalMd5())
                            .last("limit 1"));

            // 如果分片记录存在,返回已存在的分片记录信息
            if (zoneRecord != null) {
                ZoneUploadResultBO resultBo = new ZoneUploadResultBO(zoneRecord, true,
                        zoneRecord.getZoneNowIndex());
                return resultBo;
            }

            Archive archive = null;
            // 根据MD5和上传类型查询文件记录
            archive = archiveMapper.selectOne(Wrappers.<Archive>lambdaQuery()
                    .eq(Archive::getMd5Value, archiveZoneRecord.getZoneTotalMd5())
                    .last("limit 1"));
            // (文件秒传)如果文件记录已存在且已经上传完毕,则返回文件已上传的错误信息
            if (archive != null && archive.isZoneFlag() && archive.isMergeFlag()) {
                throw new RuntimeException("文件已经上传");
            }

            // 获取文件md5
            String filemd5 = archiveZoneRecord.getZoneMd5();
            // 如果分片记录的md5为空,则生成md5
            if (StringUtils.isBlank(filemd5)) {
                filemd5 = DigestUtils.md5DigestAsHex(multipartFile.getInputStream());
                archiveZoneRecord.setZoneMd5(filemd5);
            }

            // 获取文件后缀
            String fileSuffix = "." + FilenameUtils.getExtension(multipartFile.getOriginalFilename());

            // 获取保存路径
            String saveFilePath = "";
            String fileRecordId = "";

            // 如果数据库中不存在对应的文件记录,则创建新记录
            if (archive == null) {
                // 保存分片的路径
                saveFilePath = Paths.get(fileUploadConfig.getUploadFolder(), "chunks",
                        archiveZoneRecord.getZoneTotalMd5()).toString();
                // 保存文件记录
                fileRecordId = saveFileRecord(request, archiveZoneRecord, multipartFile.getOriginalFilename(),
                        saveFilePath);
            } else {
                // 如果文件记录已存在,则获取文件记录id
                fileRecordId = archive.getSid();
                saveFilePath = archive.getPath();
            }

            // 生成临时文件文件名
            String serverFileName = filemd5 + fileSuffix + ".chunks";
            // 上传文件
            FileHandleUtil.upload(multipartFile.getInputStream(), saveFilePath, serverFileName);
            // 保存分片记录
            saveFileZoneRecord(archiveZoneRecord, filemd5, fileRecordId, serverFileName, saveFilePath,
                    fileSuffix);

            // 返回结果信息
            ZoneUploadResultBO resultBo = new ZoneUploadResultBO(archiveZoneRecord, false,
                    archiveZoneRecord.getZoneNowIndex());
            return resultBo;
        } catch (Exception e) {
            e.printStackTrace();
            log.error("文件上传错误,错误消息:" + e.getMessage());
            throw new RuntimeException("文件上传错误,错误消息:" + e.getMessage());
        }
    }

    /**
     * 保存分片记录
     *
     * @param archiveZoneRecord
     * @param fileMd5
     * @param fileRecordId
     * @param serverFileName
     * @param localPath
     * @param fileSuffix
     */
    private void saveFileZoneRecord(ArchiveZoneRecord archiveZoneRecord, String fileMd5, String fileRecordId,
                                    String serverFileName, String localPath, String fileSuffix) {
        archiveZoneRecord.setSid(UUID.randomUUID() + "");
        archiveZoneRecord.setZoneMd5(fileMd5);
        archiveZoneRecord.setArchiveSid(fileRecordId);
        archiveZoneRecord.setName(serverFileName);
        archiveZoneRecord.setZonePath(localPath);
        archiveZoneRecord.setZoneCheckDate(new Date());
        archiveZoneRecord.setZoneSuffix(fileSuffix);
        super.saveOrUpdate(archiveZoneRecord);
    }

    private String saveFileRecord(HttpServletRequest request, ArchiveZoneRecord ArchiveZoneRecord,
                                  String originalFilename, String localPath) {
        Archive archive = new Archive();
        archive.setSize(ArchiveZoneRecord.getZoneTotalSize());
        archive.setFileType(FilenameUtils.getExtension(originalFilename));
        archive.setMd5Value(ArchiveZoneRecord.getZoneTotalMd5());
        archive.setOriginalName(originalFilename);
        archive.setPath(localPath);
        archive.setZoneFlag(true);
        archive.setMergeFlag(false);
        archive.setZoneTotal(ArchiveZoneRecord.getZoneTotalCount());
        archive.setZoneDate(LocalDateTime.now());
        fileRecordService.saveOrUpdate(archive);
        return archive.getSid();
    }

    @Override
    public Result md5Check(ArchiveZoneRecord archiveZoneRecord, CheckType checkType, HttpServletRequest request) {
        if (checkType == CheckType.FILE_EXISTS) {
            Archive archive = archiveMapper.selectOne(Wrappers.<Archive>lambdaQuery()
                    .eq(Archive::getMd5Value, archiveZoneRecord.getZoneTotalMd5())
                    .last("limit 1"));
            return archive != null && archive.isMergeFlag() ?
                    new Result(ResultCode.FILEUPLOADED, archive) :
                    new Result(ResultCode.SERVER_ERROR, "请选择文件上传");
        } else {
            ArchiveZoneRecord ArchiveZoneRecordDB =
                    archiveRecordMapper.selectOne(Wrappers.<ArchiveZoneRecord>lambdaQuery()
                            .eq(ArchiveZoneRecord::getZoneMd5, archiveZoneRecord.getZoneMd5())
                            .eq(ArchiveZoneRecord::getZoneTotalMd5, archiveZoneRecord.getZoneTotalMd5())
                            .last("limit 1"));
            return ArchiveZoneRecordDB != null ?
                    new Result(ResultCode.SUCCESS, ArchiveZoneRecordDB) :
                    new Result(ResultCode.SERVER_ERROR, "分片文件不存在,继续上传");
        }
    }


    /**
     * 合并分片文件并保存到服务器
     *
     * @param totalMd5 分片文件的总MD5值
     * @param request  HttpServletRequest对象
     * @return 返回合并结果
     */
    @Override
    public FileUploadResultBO mergeZoneFile(String totalMd5, HttpServletRequest request) {
        FileUploadResultBO resultBO = new FileUploadResultBO();
        if (totalMd5 == null || totalMd5.trim().length() == 0) {
            throw new RuntimeException("总MD5值不能为空");
        }

        // 查询总MD5值对应的文件信息
        Archive archive = archiveMapper.selectOne(Wrappers.<Archive>lambdaQuery()
                .eq(Archive::getMd5Value, totalMd5)
                .last("limit 1"));
        if (archive == null) {
            throw new RuntimeException("文件MD5:" + totalMd5 + "对应的文件不存在");
        }


        if (archive.isZoneFlag() && archive.isMergeFlag()) {
            // 如果文件已上传并合并完成,则返回文件信息
            resultBO.setFileId(archive.getSid());
            resultBO.setFileInfo(archive);
            Path netPath = Paths.get(fileUploadConfig.getStaticAccessPath(), archive.getFileType(),
                    archive.getPath());
            resultBO.setNetworkPath(netPath.toString());
            return resultBO;
        }

        String fileType = archive.getFileType();

        // 查询分片记录
        List<ArchiveZoneRecord> archiveZoneRecords = super.list(Wrappers.<ArchiveZoneRecord>lambdaQuery()
                .eq(ArchiveZoneRecord::getZoneTotalMd5, totalMd5)
                .orderByAsc(ArchiveZoneRecord::getZoneNowIndex)
        );

        if (CollectionUtils.isEmpty(archiveZoneRecords)) {
            throw new RuntimeException("文件MD5:" + totalMd5 + "对应的分片记录不存在");
        }

        // 获取当前日期和时间用于生成文件路径
        String pathDate = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy/MMdd/HH"));
        // 获取文件上传路径(不包含文件名) 示例:D:/upload/file/2023/03/08/
        String localPath = Paths.get(fileUploadConfig.getUploadFolder(), fileType, pathDate).toString();
        // 生成唯一文件名
        String saveFileName = UUID.randomUUID() + "." + archive.getFileType();

        // 设置文件信息的路径和全路径
        archive.setFullPath(localPath + saveFileName);
        archive.setPath(Paths.get(pathDate, saveFileName).toString());
        archive.setFileName(saveFileName);

        // 合并分片文件并写入文件
        mergeAndWriteFile(localPath, saveFileName, archiveZoneRecords, pathDate, archive);

        // 保存或更新文件信息
        fileRecordService.saveOrUpdate(archive);

        // 获取网络访问路径
        Path netPath = Paths.get(fileUploadConfig.getUploadUrl(), fileUploadConfig.getStaticAccessPath(),
                fileType, pathDate, saveFileName);

        resultBO.setNetworkPath(netPath.toString());
        resultBO.setFileInfo(archive);
        resultBO.setFileId(archive.getSid());
        return resultBO;
    }

    /**
     * 合并分片文件并写入文件
     *
     * @param localPath          存储文件的本地路径
     * @param saveFileName       保存的文件名
     * @param archiveZoneRecords 分片文件的记录列表
     * @param pathDate           文件路径日期部分
     * @param archive            文件档案对象
     */
    private void mergeAndWriteFile(String localPath, String saveFileName, List<ArchiveZoneRecord> archiveZoneRecords,
                                   String pathDate, Archive archive) {
        String allPath = Paths.get(localPath, saveFileName).toString();
        File targetFile = new File(allPath);

        FileOutputStream fileOutputStream = null;
        try {
            if (!targetFile.exists()) {
                // 创建目录如果不存在
                FileHandleUtil.createDirIfNotExists(localPath);

                // 创建目标临时文件,如果不存在则创建
                targetFile.getParentFile().mkdirs();
                targetFile.createNewFile();
            }

            fileOutputStream = new FileOutputStream(targetFile, true); // 使用追加模式

            // 合并分片文件
            for (ArchiveZoneRecord archiveZoneRecord : archiveZoneRecords) {
                File partFile = new File(archiveZoneRecord.getZonePath(), archiveZoneRecord.getName());
                try (FileInputStream fis = new FileInputStream(partFile)) {
                    byte[] buffer = new byte[1024];
                    int len;
                    while ((len = fis.read(buffer)) != -1) {
                        fileOutputStream.write(buffer, 0, len);
                    }
                }
            }

            // 更新文件信息
            archive.setZoneMergeDate(LocalDateTime.now());
            archive.setMergeFlag(true);
            fileRecordService.saveOrUpdate(archive);

            // 删除由于并发导致文件archive多条重复记录,todo 这里在上传方法中使用乐观锁锁来避免
            fileRecordService.remove(Wrappers.<Archive>lambdaQuery()
                    .eq(Archive::getMd5Value, archive.getMd5Value())
                    .isNotNull(Archive::isMergeFlag)
            );

        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException("文件合并失败原因:" + e.getMessage());
        } finally {
            if (fileOutputStream != null) {
                try {
                    fileOutputStream.close();
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
            }
        }
    }
}

4. 项目运行测试

4.1 测试效果

分片上传-md5

4.2 数据库记录

如下图所示:文件表中存储已经上传到服务器中当前文件的上传信息,文件分片表则记录了当前文件分片所有的分片信息

image-20240614085402463

4.3 上传目录文件

如下图所示:上传目录中存在chunks(分片文件夹)和mp4(合并后的文件)

image-20240614090157192

4.4 网络访问上传的文件

image-20240614090716951

访问效果如下:

image-20240614090820595

5. 项目源码

gitee项目地址:

# 后端地址
git clone https://gitee.com/zhouquanstudy/springboot-file-chunk-md5.git
# 前端地址
git clone https://gitee.com/zhouquanstudy/file-chunk-upload-md5.git

项目压缩包

image-20240614094512339

https://zhouquanquan.lanzouh.com/b00g2d7sdg
密码:bpyg

6.参考链接

  1. 官方地址 https://github.com/fex-team/webuploader
  2. 基于SpringBoot和WebUploader实现大文件分块上传.断点续传.秒传-阿里云开发者社区 (aliyun.com)
  3. 在Vue项目中使用WebUploader实现文件上传_vue webuploader-CSDN博客
  4. vue中大文件上传webuploader前端用法_vue webuploader 大文件上传-CSDN博客

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1821499.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

休闲零食连锁迎来“万店”时代!“鸣鸣很忙”快速扩张有何秘诀?

6月12日&#xff0c;零食很忙与赵一鸣零食合并后的集团名称正式变更为“鸣鸣很忙”集团。目前&#xff0c;该集团旗下的双品牌全国门店总数已经突破10000家&#xff0c;标志着休闲零食连锁行业正式迎来“万店”时代。在激烈的市场竞争中&#xff0c;“鸣鸣很忙”以全国门店数第…

【Numpy】一文向您详细介绍 np.abs()

【Numpy】一文向您详细介绍 np.abs() 下滑即可查看博客内容 &#x1f308; 欢迎莅临我的个人主页 &#x1f448;这里是我静心耕耘深度学习领域、真诚分享知识与智慧的小天地&#xff01;&#x1f387; &#x1f393; 博主简介&#xff1a;985高校的普通本硕&#xff0c;曾…

rsa加签验签C#和js以及java互通

js实现rsa加签验签 https://github.com/kjur/jsrsasign 11.1.0版本 解压选择需要的版本&#xff0c;这里选择all版本了 <!DOCTYPE html> <html lang"en"> <head><meta charset"UTF-8"><title>JS RSA加签验签</title&g…

【Altium】AD-Fill、Region、Polygon之间的区别

【更多软件使用问题请点击亿道电子官方网站】 1、 文档目标 Fill、Polygon、Region介绍&#xff0c;了解三者的区别。 2、 知识点 正片层、负片层&#xff0c;以及AD叠层管理中的设置。 3、软硬件环境 1&#xff09;、无关 2&#xff09;、无关 3&#xff09;、无关 4、…

动作识别综合指南

本文将概述当前动作识别&#xff08;action recognition&#xff09;的方法和途径。 为了展示动作识别任务的复杂性&#xff0c;我想举这个例子&#xff1a; 你能明白我在这里做什么吗&#xff1f;我想不能。至少你不会确定答案。我正在钻孔。 你能弄清楚我接下来要做什么吗&…

10. 安全性

这里写自定义目录标题 第10章 安全性10.1 安全性通用场景10.2 安全性策略不安全状态避免替代预测模型 不安全状态检测超时时间戳条件监测健全性检查比较 抑制冗余限制后果屏障 恢复 10.3基于策略的安全问卷10.4 安全性的模式10.5 扩展阅读10.6 问题讨论 第10章 安全性 吉尔斯&a…

GaN VCSEL:工艺革新引领精准波长控制新纪元

日本工程师们凭借精湛的技艺&#xff0c;开创了一种革命性的生产工艺&#xff0c;让VCSEL的制造达到了前所未有的高效与精准。这一成果由名城大学与国家先进工业科学技术研究所的精英们联手铸就&#xff0c;将氮化镓基VCSELs的商业化进程推向了新的高峰。它们将有望成为自适应前…

ArcGIS for js 4.x FeatureLayer 点选查询

示例&#xff1a; 代码如下&#xff1a; <template><view id"mapView"></view></template><script setup> import "arcgis/core/assets/esri/themes/light/main.css"; import Map from "arcgis/core/Map.js"; im…

【AI基础】第五步:纯天然保姆喂饭级-安装并运行chatglm3-6b

类似于 【AI基础】第三步&#xff1a;纯天然保姆喂饭级-安装并运行chatglm2-6b&#xff0c;有一些细节不一样。 此系列文章列表&#xff1a; 【AI基础】第一步&#xff1a;安装python开发环境-windows篇_下载安装ai环境python 【AI基础】第一步&#xff1a;安装python开发环境-…

五分钟看完WWDC24

大家好&#xff0c;我是小编阿文。欢迎您关注我们&#xff0c;经常分享有关Android出海&#xff0c;iOS出海&#xff0c;App市场政策实时更新&#xff0c;互金市场投放策略&#xff0c;最新互金新闻资讯等文章&#xff0c;期待与您共航世界之海。 北京时间6月11日凌晨1点&…

SylixOS下UDP组播测试程序

SylixOS下UDP组播测试 测试效果截图如下: udp组播发送测试程序。 /********************************************************************************************************* ** ** 中国软件开源组织 ** ** …

华为wlan实验

分为三步&#xff1a;1、网络互通&#xff0c;2、AP上线&#xff0c;3、wlan业务 1、网络互通 crow-sw: vlan batch 20 100 dhcp enable int vlan 20 ip add 192.168.20.1 24 dhcp select interfaceinterface GigabitEthernet0/0/2port link-type accessport default vlan 100…

构建 LLM 应用为什么需要文本加载器,langchain 中如何使用文本加载器?

构建 LLM 应用为什么需要文本加载器&#xff0c;langchain 中如何使用文本加载器&#xff1f; 上一篇文章中 [使用langchain搭建本地知识库系统(新) 我们构建一个 RAG 的本地应用&#xff0c;我们使用到了网页的文本加载器用来动态获取网页的数据。 在不同的应用场景中需要使…

【Spine学习07】之跑步动作制作思路总结

前几节试着做了待机和走路动画 现在开始尝试做跑步动作 注意跑步动作和走路一样 暂时不需要使用IK约束但是会用到塞贝尔曲线&#xff08;模拟裙子飞起动效&#xff09; 第一步&#xff1a; 先将人物整体斜放置&#xff08;因为人跑步的时候&#xff0c;身体前倾&#xff09; …

速度与激情:解锁8款免费文件传输利器,让大数据秒传成为可能

以下是8个免费高速文件传输工具的推荐&#xff0c;这些工具可以帮助您彻底告别数据线&#xff0c;使文件传输更加便捷和高效&#xff1a; 1、百度网盘 特点&#xff1a;云存储和共享应用&#xff0c;支持多种形式的文件存储和分享&#xff0c;提供大容量的免费存储空间。 适用…

力扣每日一题(2024-06-14)2786. 访问数组中的位置使分数最大

参考官方题解2786. 访问数组中的位置使分数最大 - 力扣&#xff08;LeetCode&#xff09; 问题描述 给定一个下标从 0 开始的整数数组 nums 和一个正整数 x。你一开始在数组的第 0 个位置&#xff0c;你可以移动到满足 i < j 的任意位置 j。如果你访问的位置 i&#xff0c…

毕业生季,你的校园卡开始注销了吗?

​ 毕业季&#xff0c;好多朋友们已经走出校园了&#xff0c;换了一个新的城市接着工作了&#xff0c;那么&#xff0c;之前办理的校园卡你都是怎么处理的&#xff1f; 其实&#xff0c;校园卡就是为了方便校园生活而推出的一种卡&#xff0c;在学校期间可能比较优惠&#xff…

企业薪酬体系的搭建

随着企业的逐步发展&#xff0c;其人力资源管理方面的问题也逐渐显露出来&#xff0c;诸如职责不清、相互推卸责任、员工工作积极性较低等问题&#xff0c;这些管理上的问题导致产品质量不断下降&#xff0c;客户投诉率也不断上升&#xff0c;且优秀人员的流失率也有增加的趋势…

沃沃阀门×蓝卓 | 再度携手!数字化车间项目启动会顺利召开

6月13日&#xff0c;蓝卓与沃沃阀门数字化车间项目正式启动&#xff0c;依托蓝卓supOS工业操作系统&#xff0c;打造统一数字化底座&#xff0c;助推沃沃阀门物料自动配送、产销高效协同、设备全面管理、车间可视化管理等目标实现。 丽水莲都区经信局副局长李军舫、区经信局信…

Arco-design <a-range-picker> 快捷键日期点击后不关闭面板

需求&#xff1a;点击快捷日期&#xff0c;面板预览日期&#xff0c;点击确定后触发事件 思路&#xff1a;手动控制面板开启和关闭&#xff0c;点击input时开启&#xff0c;点击面板确定或除input和面板的其他位置时关闭面板 <a-range-pickerid"input"v-model&quo…