文件分片上传(模拟网盘效果)

news2025/7/10 12:51:48

文件分片上传(模拟网盘效果)

    • 文章说明
    • 简单模拟拖拽文件夹和选择文件的进度条效果
    • 效果展示
    • 结合后端实现文件上传
    • 效果展示
    • 加上分片的效果
    • 效果展示
    • 加上MD5的校验,实现秒传和分片的效果
    • 后续开发说明
    • 源码下载

文章说明

文章主要为了学习文件上传,以及分片上传的一些简单操作;更多的学习一些前端相关的文件操作的知识,包括拖拽文件函数和打开文件函数

参考资料1:window.showOpenFilePicker方法的使用

简单模拟拖拽文件夹和选择文件的进度条效果

代码如下(仿照element的样式书写,进度条也是仿照element的样式写的)

App.vue(目前还没有结合后台上传逻辑,然后也只是简单的写了一个界面效果)

<template>
  <link rel="stylesheet" href="/style/css/iconfont.css">

  <div class="drop-area" @drop="getDropItems" @click="showFilePicker">
    <div>
      <i class="iconfont icon-upload"/>
      <div class="tip-text">
        Drop file here or
        <em>click to upload</em>
      </div>
    </div>
  </div>

  <div class="file-list">
    <div v-for="(item, index) in data.fileList" :key="index" class="single-file">
      <MyProgress :percentage="item.percentage" :content="item.name"/>
    </div>
  </div>
</template>

<script>
import {onBeforeMount, reactive} from "vue";
import MyProgress from "@/MyProgress.vue";
import {message} from "@/util";

export default {
  name: "App",
  components: {MyProgress},
  setup() {
    const data = reactive({
      fileList: [],
      isUploading: false,
    });
    onBeforeMount(() => {
      onload = function () {
        document.addEventListener("drop", function (e) {
          //拖离
          e.preventDefault();
        });
        document.addEventListener("dragleave", function (e) {
          //拖后放
          e.preventDefault();
        });
        document.addEventListener("dragenter", function (e) {
          //拖进
          e.preventDefault();
        });
        document.addEventListener("dragover", function (e) {
          //拖来拖去
          e.preventDefault();
        });
      };
    });

    function getFileFromEntryRecursively(entry) {
      if (entry.isFile) {
        data.fileList.push({
          name: entry.fullPath.substring(entry.fullPath.lastIndexOf("/") + 1, entry.fullPath.length),
          percentage: 0
        });
      } else {
        let reader = entry.createReader();
        reader.readEntries((entries) => {
          entries.forEach((entry) => {
            getFileFromEntryRecursively(entry);
          });
        });
      }
    }

    function getDropItems(event) {
      if (data.isUploading) {
        message("正在上传...", "info");
        return;
      }
      data.fileList = [];
      data.isUploading = true;
      const items = event.dataTransfer.items;
      for (let i = 0; i <= items.length - 1; i++) {
        const item = items[i];
        if (item.kind === "file") {
          const reader = new FileReader();
          reader.readAsArrayBuffer(item.getAsFile());
          console.log(reader)

          const entry = item.webkitGetAsEntry();
          getFileFromEntryRecursively(entry);
        }
      }
      const timer = setInterval(() => {
        upload();
        closeTimer(timer);
      }, 100);
    }

    function upload() {
      for (let i = 0; i < data.fileList.length; i++) {
        data.fileList[i].percentage += 1;
      }
    }

    function closeTimer(timer) {
      let isOver = true;
      for (let i = 0; i < data.fileList.length; i++) {
        if (data.fileList[i].percentage !== 100) {
          isOver = false;
          break;
        }
      }
      if (isOver) {
        clearInterval(timer);
        data.isUploading = false;
      }
    }

    const pickerOpts = {
      excludeAcceptAllOption: false,
      multiple: true,
    };

    async function showFilePicker() {
      if (data.isUploading) {
        message("正在上传...", "info");
        return;
      }

      let fileHandle;
      try {
        fileHandle = await window.showOpenFilePicker(pickerOpts);
        data.fileList = [];
        data.isUploading = true;
      } catch (e) {
        if (e.name === 'AbortError' && e.message === 'The user aborted a request.') {
          message("用户没有选择文件", "info");
          return;
        } else {
          throw e;
        }
      }

      for (let i = 0; i < fileHandle.length; i++) {
        data.fileList.push({
          name: fileHandle[i].name,
          percentage: 0
        });
        const arrayBuffer = (await fileHandle[i].getFile()).arrayBuffer();
        console.log(arrayBuffer)

        let formData = new FormData();
        formData.append("file", arrayBuffer);
        console.log(formData)
      }

      const timer = setInterval(() => {
        upload();
        closeTimer(timer);
      }, 100);
    }

    return {
      data,
      getDropItems,
      showFilePicker,
    };
  },
};
</script>

<style>
* {
  padding: 0;
  margin: 0;
  box-sizing: border-box;
}

.drop-area {
  margin: 100px auto 0;
  width: 800px;
  height: 180px;
  border: 1px dashed #dcdfe6;
  display: flex;
  align-items: center;
  justify-content: center;
}

.drop-area:hover {
  border-color: #409eff;
  cursor: pointer;
}

.icon-upload::before {
  display: flex;
  justify-content: center;
  font-size: 40px;
  margin: 10px 0;
}

.tip-text {
  color: #606266;
  font-size: 14px;
  text-align: center;
}

.tip-text em {
  color: #409eff;
  font-style: normal;
}

.file-list {
  margin: 0 auto;
  width: 800px;
}

.single-file {
  margin: 10px 0;
}
</style>

MyProgress.vue

<template>
  <link rel="stylesheet" href="/style/css/iconfont.css">

  <div class="progress-container">
    <div class="bar">
      <div class="percentage" :style="{'width': props.percentage + '%'}">
        <span class="text-inside">{{ props.content + " " + props.percentage + "%" }}</span>
      </div>
    </div>
    <div class="tip-content">
      <span v-show="props.percentage !== 100">{{ props.percentage + "%" }}</span>
      <i class="iconfont icon-over" v-show="props.percentage === 100"/>
    </div>
  </div>
</template>

<script>
export default {
  props: ["percentage", "content"],
  setup(props) {
    return {
      props
    }
  }
}
</script>

<style scoped>
.progress-container {
  display: flex;
  height: 30px;
  cursor: pointer;
  border: 1px dashed #dcdfe6;
  padding: 0 10px;
}

.bar {
  color: white;
  font-weight: 500;
  line-height: 30px;
  font-size: 14px;
  flex: 1;
}

.percentage {
  border-radius: 30px;
  background-color: #67c23a;
  white-space: nowrap;
  word-break: break-all;
  overflow: hidden;
  transition: width 0.2s linear;
}

.text-inside {
  padding-right: 10px;
  padding-left: 15px;
  float: right;
}

.tip-content {
  padding: 0 10px;
  font-size: 16px;
  line-height: 30px;
  width: 40px;
}

.icon-over::before {
  font-size: 24px;
  color: #67c23a;
}
</style>

效果展示

简单演示了选择文件和拖拽文件、拖拽文件夹的效果

在这里插入图片描述

结合后端实现文件上传

后端采用SpringBoot简单写了一个接收文件的小demo

package com.boot.controller;

import com.boot.entity.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

/**
 * <p>
 * 前端控制器
 * </p>
 *
 * @author bbyh
 * @since 2023-12-27
 */
@Slf4j
@RestController
@RequestMapping("/fragment-info")
public class FragmentInfoController {
    @PostMapping("/upload")
    public Result upload(@RequestBody MultipartFile file) {
        log.info(file.getOriginalFilename());
        return Result.success("文件上传成功", null);
    }
}

此时前端需要一些变化,将拖拽的文件列表和选择的文件列表都放入列表中,这里主要考察前端相关的文件操作;我找了一些资料,后面抓到了它的实现效果

util.js(主要就是一个 ajax 的post请求,携带一个onUploadProgress属性)

import {ElMessage} from "element-plus";
import axios from "axios";

const baseUrl = "http://127.0.0.1:8080"

export function message(msg, type) {
    ElMessage({
        message: msg,
        type: type,
        center: true,
        showClose: true,
    })
}

export const postFileRequest = (url, data, onUploadProgress) => {
    return axios({
        method: 'post',
        url: baseUrl + url,
        data: data,
        onUploadProgress: onUploadProgress,
    })
}

App.vue(主要的逻辑都写在这里了,这里的异步和Promise,真的给我上了一课,我对这些概念的理解层次还差了不少)
而且我在尝试的时候,还通过提问GPT发现了:for循环中使用了await,这会导致循环在遇到第一个await时立即退出;真是还没学到家

<template>
  <link rel="stylesheet" href="/style/css/iconfont.css">

  <div class="drop-area" @drop="getDropItems" @click="showFilePicker">
    <div>
      <i class="iconfont icon-upload"/>
      <div class="tip-text">
        Drop file here or
        <em>click to upload</em>
      </div>
    </div>
  </div>

  <div class="file-list">
    <div v-for="(item, index) in data.fileList" :key="index" class="single-file">
      <MyProgress :percentage="item.percentage" :content="item.name"/>
    </div>
  </div>
</template>

<script>
import {onBeforeMount, reactive} from "vue";
import MyProgress from "@/MyProgress.vue";
import {message, postFileRequest} from "@/util";

export default {
  name: "App",
  components: {MyProgress},
  setup() {
    const data = reactive({
      fileList: [],
      isUploading: false,
    });
    onBeforeMount(() => {
      onload = function () {
        document.addEventListener("drop", function (e) {
          //拖离
          e.preventDefault();
        });
        document.addEventListener("dragleave", function (e) {
          //拖后放
          e.preventDefault();
        });
        document.addEventListener("dragenter", function (e) {
          //拖进
          e.preventDefault();
        });
        document.addEventListener("dragover", function (e) {
          //拖来拖去
          e.preventDefault();
        });
      };
    });


    function getFileFromEntryRecursively(entry) {
      return new Promise((resolve) => {
        if (entry.isFile) {
          entry.file((file) => {
            data.fileList.push({
              name: entry.fullPath.substring(entry.fullPath.lastIndexOf("/") + 1, entry.fullPath.length),
              percentage: 0,
              file: file
            });
            resolve();
          });
        } else {
          let reader = entry.createReader();
          reader.readEntries((entries) => {
            Promise.all(entries.map(entry => getFileFromEntryRecursively(entry))).then(() => {
              resolve();
            });
          });
        }
      });
    }

    async function getDropItems(event) {
      if (data.isUploading) {
        message("正在上传...", "info");
        return;
      }
      data.fileList = [];
      data.isUploading = true;
      const items = event.dataTransfer.items;
      const promises = [];
      for (const item of items) {
        if (item.kind === "file") {
          const entry = item.webkitGetAsEntry();
          promises.push(getFileFromEntryRecursively(entry));
        }
      }
      await Promise.all(promises);

      upload();
    }

    function upload() {
      for (let i = 0; i < data.fileList.length; i++) {
        const onUploadProgress = (progressEvent) => {
          data.fileList[i].percentage = parseInt(Number(((progressEvent.loaded / progressEvent.total) * 100)).toFixed(0));
        };

        const formData = new FormData();
        formData.append("file", data.fileList[i].file);

        postFileRequest("/fragment-info/upload", formData, onUploadProgress).then((res) => {
          if (res.data.code === "200") {
            message(res.data.msg, "success");
          } else if (res.data.code === "500") {
            message(res.data.msg, "error");
          }
        });
      }
    }

    const pickerOpts = {
      excludeAcceptAllOption: false,
      multiple: true,
    };

    async function showFilePicker() {
      if (data.isUploading) {
        message("正在上传...", "info");
        return;
      }

      let fileHandle;
      try {
        fileHandle = await window.showOpenFilePicker(pickerOpts);
        data.fileList = [];
        data.isUploading = true;
      } catch (e) {
        if (e.name === 'AbortError' && e.message === 'The user aborted a request.') {
          message("用户没有选择文件", "info");
          return;
        } else {
          throw e;
        }
      }

      for (let i = 0; i < fileHandle.length; i++) {
        const file = await fileHandle[i].getFile();
        data.fileList.push({
          name: fileHandle[i].name,
          percentage: 0,
          file: file
        });
      }

      upload();
    }

    return {
      data,
      getDropItems,
      showFilePicker,
    };
  },
};
</script>

<style>
* {
  padding: 0;
  margin: 0;
  box-sizing: border-box;
}

.drop-area {
  margin: 100px auto 0;
  width: 800px;
  height: 180px;
  border: 1px dashed #dcdfe6;
  display: flex;
  align-items: center;
  justify-content: center;
}

.drop-area:hover {
  border-color: #409eff;
  cursor: pointer;
}

.icon-upload::before {
  display: flex;
  justify-content: center;
  font-size: 40px;
  margin: 10px 0;
}

.tip-text {
  color: #606266;
  font-size: 14px;
  text-align: center;
}

.tip-text em {
  color: #409eff;
  font-style: normal;
}

.file-list {
  margin: 0 auto;
  width: 800px;
}

.single-file {
  margin: 10px 0;
}
</style>

进度条还是和上面的一样

效果展示

这次是自动的进度条展示,和之前模拟的差不多(为了方便演示,我在后端设置了最大上传大小,改为了100MB,后续的分片上传,我选择将每个分片设置为2MB,当然大小可以自己调整)

在application.properties里面增加一个配置(设置单个文件最大100MB,总请求最大200MB)

spring.servlet.multipart.enabled=true
spring.servlet.multipart.max-file-size=100MB
spring.servlet.multipart.max-request-size=200MB

在这里插入图片描述

加上分片的效果

后端代码没有变化,主要还是前端App.vue里面的逻辑添加了一些,处理分片相关的逻辑

<template>
  <link rel="stylesheet" href="/style/css/iconfont.css">

  <div class="drop-area" @drop="getDropItems" @click="showFilePicker">
    <div>
      <i class="iconfont icon-upload"/>
      <div class="tip-text">
        Drop file here or
        <em>click to upload</em>
      </div>
    </div>
  </div>

  <div class="file-list">
    <div v-for="(item, index) in data.fileList" :key="index" class="single-file" @click="showFragmentInfo(item)">
      <MyProgress :percentage="item.percentage" :content="item.name"/>
    </div>
  </div>

  <el-dialog v-model="data.fragmentDialogVisible" title="分片详情查看" width="80%">
    <div v-for="(item, index) in data.showFragmentList" :key="index" class="single-file">
      <MyProgress :percentage="item.percentage" :content="item.name"/>
    </div>
    <template #footer>
      <span class="dialog-footer">
        <el-button @click="data.fragmentDialogVisible = false">关闭</el-button>
      </span>
    </template>
  </el-dialog>
</template>

<script>
import {onBeforeMount, reactive} from "vue";
import MyProgress from "@/MyProgress.vue";
import {EACH_FILE, message, postFileRequest} from "@/util";

export default {
  name: "App",
  components: {MyProgress},
  setup() {
    const data = reactive({
      fileList: [],
      isUploading: false,
      fragmentDialogVisible: false,
      showFragmentList: []
    });

    onBeforeMount(() => {
      onload = function () {
        document.addEventListener("drop", function (e) {
          //拖离
          e.preventDefault();
        });
        document.addEventListener("dragleave", function (e) {
          //拖后放
          e.preventDefault();
        });
        document.addEventListener("dragenter", function (e) {
          //拖进
          e.preventDefault();
        });
        document.addEventListener("dragover", function (e) {
          //拖来拖去
          e.preventDefault();
        });
      };
    });

    function getFileFromEntryRecursively(entry) {
      return new Promise((resolve) => {
        if (entry.isFile) {
          entry.file((file) => {
            data.fileList.push({
              name: entry.fullPath.substring(entry.fullPath.lastIndexOf("/") + 1, entry.fullPath.length),
              percentage: 0,
              file: file,
              totalSize: file.size,
              totalCompleteSize: 0
            });
            resolve();
          });
        } else {
          let reader = entry.createReader();
          reader.readEntries((entries) => {
            Promise.all(entries.map(entry => getFileFromEntryRecursively(entry))).then(() => {
              resolve();
            });
          });
        }
      });
    }

    async function getDropItems(event) {
      if (data.isUploading) {
        message("正在上传...", "info");
        return;
      }
      data.fileList = [];
      data.isUploading = true;
      const items = event.dataTransfer.items;
      const promises = [];
      for (const item of items) {
        if (item.kind === "file") {
          const entry = item.webkitGetAsEntry();
          promises.push(getFileFromEntryRecursively(entry));
        }
      }
      await Promise.all(promises);

      upload();
    }

    function upload() {
      for (let i = 0; i < data.fileList.length; i++) {
        const fragmentCount = Math.floor(data.fileList[i].file.size / EACH_FILE) + 1;
        const fragmentList = [];
        for (let j = 0; j < fragmentCount; j++) {
          fragmentList.push({
            id: j,
            fragmentFile: data.fileList[i].file.slice(j * EACH_FILE, (j + 1) * EACH_FILE),
            completeSize: 0,
            name: data.fileList[i].name + "分片" + (j + 1),
            percentage: 0,
          });
        }
        data.fileList[i].fragmentList = fragmentList;

        for (let j = 0; j < data.fileList[i].fragmentList.length; j++) {
          const onUploadProgress = (progressEvent) => {
            data.fileList[i].fragmentList[j].completeSize = progressEvent.loaded;
            data.fileList[i].fragmentList[j].percentage = parseInt(Number(((progressEvent.loaded / progressEvent.total) * 100)).toFixed(0));
            updateTotalPercentage(i);
          };

          const formData = new FormData();
          formData.append("file", data.fileList[i].fragmentList[j].fragmentFile, data.fileList[i].fragmentList[j].name);

          postFileRequest("/fragment-info/upload", formData, onUploadProgress).then((res) => {
            if (res.data.code === "200") {
              message(res.data.msg, "success");
            } else if (res.data.code === "500") {
              message(res.data.msg, "error");
            }
          });
        }
      }
    }

    function updateTotalPercentage(i) {
      let totalCompleteSize = 0;
      for (let j = 0; j < data.fileList[i].fragmentList.length; j++) {
        totalCompleteSize += data.fileList[i].fragmentList[j].completeSize;
      }
      data.fileList[i].totalCompleteSize = totalCompleteSize;
      data.fileList[i].percentage = parseInt(Number(data.fileList[i].totalCompleteSize / data.fileList[i].totalSize * 100).toFixed(0));
    }

    const pickerOpts = {
      excludeAcceptAllOption: false,
      multiple: true,
    };

    async function showFilePicker() {
      if (data.isUploading) {
        message("正在上传...", "info");
        return;
      }

      let fileHandle;
      try {
        fileHandle = await window.showOpenFilePicker(pickerOpts);
        data.fileList = [];
        data.isUploading = true;
      } catch (e) {
        if (e.name === 'AbortError' && e.message === 'The user aborted a request.') {
          message("用户没有选择文件", "info");
          return;
        } else {
          throw e;
        }
      }

      for (let i = 0; i < fileHandle.length; i++) {
        const file = await fileHandle[i].getFile();
        data.fileList.push({
          name: fileHandle[i].name,
          percentage: 0,
          file: file,
          totalSize: file.size,
          totalCompleteSize: 0
        });
      }

      upload();
    }

    function showFragmentInfo(item) {
      data.showFragmentList = item.fragmentList;
      data.fragmentDialogVisible = true;
    }

    return {
      data,
      getDropItems,
      showFilePicker,
      showFragmentInfo,
    };
  },
};
</script>

<style>
* {
  padding: 0;
  margin: 0;
  box-sizing: border-box;
}

.drop-area {
  margin: 100px auto 0;
  width: 800px;
  height: 180px;
  border: 1px dashed #dcdfe6;
  display: flex;
  align-items: center;
  justify-content: center;
}

.drop-area:hover {
  border-color: #409eff;
  cursor: pointer;
}

.icon-upload::before {
  display: flex;
  justify-content: center;
  font-size: 40px;
  margin: 10px 0;
}

.tip-text {
  color: #606266;
  font-size: 14px;
  text-align: center;
}

.tip-text em {
  color: #409eff;
  font-style: normal;
}

.file-list {
  margin: 0 auto;
  width: 800px;
}

.single-file {
  margin: 10px 0;
}
</style>

效果展示

分片大小目前设置为2MB

在这里插入图片描述

加上MD5的校验,实现秒传和分片的效果

在这部分,我是真的被JavaScript的这个Promise和async、await给整麻了;感觉自己还差的不少

在这部分就加上了数据库部分的逻辑,Dao层采用的是Mybatis-Plus,然后本来是打算采用16位的byte数组来存md5字符串转化后的结果,不过在实现的时候遇到了一点小问题,后面我会在尝试一下看看;主要是考虑到数据库索引的速度;不过如果采用char(32) 类型的话,加上索引,速度应该也还不错

数据库创建,就只简单的创建了两个表,后面会在Gitee上同步完整版本,添加上安全校验方面的一些内容

CREATE TABLE `file_info`  (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `file_name` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '文件名称',
  `MD5` char(32) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '文件的MD5值',
  `path` varchar(200) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '文件的路径',
  `create_time` datetime NOT NULL COMMENT '文件创建时间',
  `delete_state` bit(1) NOT NULL COMMENT '文件删除状态',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;

CREATE TABLE `fragment_info`  (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `fragment_name` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '分片文件名称',
  `fragment_order` int(11) NOT NULL COMMENT '分片文件序号',
  `md5` char(32) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '分片文件的MD5值,采用转为16字节的数字存储',
  `path` varchar(200) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '分片文件存储路径',
  `create_time` datetime NOT NULL COMMENT '分片文件创建时间',
  `delete_state` bit(1) NOT NULL COMMENT '删除状态(0表示未删除,1表示删除)',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 58 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;

在这个数据表创建部分,实际少了两个字段,分别是文件的id,以及分片文件的id及其主文件id,这样会更方便后续的功能开发;不过目前的小demo,当前的数据表是够用的

后端代码(目前主要实现上传部分的逻辑,后端文件保存到指定目录和拼接的相关部分还没有补全)

package com.boot.controller;

import cn.hutool.core.collection.ListUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.boot.entity.FileInfo;
import com.boot.entity.Result;
import com.boot.service.IFileInfoService;
import com.boot.util.FileUtil;
import com.boot.util.GetCurrentTime;
import org.springframework.web.bind.annotation.*;

import javax.annotation.Resource;
import java.io.File;
import java.util.List;
import java.util.Map;

/**
 * <p>
 * 前端控制器
 * </p>
 *
 * @author bbyh
 * @since 2023-12-27
 */
@RestController
@RequestMapping("/file-info")
public class FileInfoController {

    @Resource
    private IFileInfoService fileInfoService;

    @PostMapping("/generateFile")
    public Result generateFile(@RequestBody Map<String, Object> map) {
        String name = (String) map.get("name");
        String md5 = (String) map.get("md5");
        List<String> fragmentMd5List = ListUtil.toList(map.get("fragmentMd5List").toString());

        FileInfo fileInfo = new FileInfo();
        fileInfo.setFileName(name);
        fileInfo.setMd5(md5);
        fileInfo.setPath(FileUtil.ROOT_PATH + md5 + File.separator + name);
        fileInfo.setCreateTime(GetCurrentTime.getCurrentTimeBySecond());
        fileInfo.setDeleteState(false);
        fileInfoService.save(fileInfo);

        return Result.success("文件:" + name + "上传成功", null);
    }

    @GetMapping("/checkMd5")
    public Result checkMd5(@RequestParam String md5) {
        QueryWrapper<FileInfo> wrapper = new QueryWrapper<>();
        wrapper.eq("md5", md5).eq("delete_state", "0");
        FileInfo fileInfo = fileInfoService.getOne(wrapper);
        if (fileInfo != null) {
            return Result.success("MD5已存在", null);
        } else {
            fileInfoService.remove(wrapper);
            return Result.error("MD5不存在", null);
        }
    }

}
package com.boot.controller;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.boot.entity.FragmentInfo;
import com.boot.entity.Result;
import com.boot.service.IFragmentInfoService;
import com.boot.util.GetCurrentTime;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import javax.annotation.Resource;
import java.io.File;

import static com.boot.util.FileUtil.FRAGMENT_SPLIT;
import static com.boot.util.FileUtil.ROOT_PATH;

/**
 * <p>
 * 前端控制器
 * </p>
 *
 * @author bbyh
 * @since 2023-12-27
 */
@Slf4j
@RestController
@RequestMapping("/fragment-info")
public class FragmentInfoController {
    @Resource
    private IFragmentInfoService fragmentInfoService;

    @PostMapping("/upload")
    public Result upload(@RequestBody MultipartFile file, @RequestParam String md5) {
        String originalFilename = file.getOriginalFilename();

        assert originalFilename != null;
        int lastIndexOf = originalFilename.lastIndexOf(FRAGMENT_SPLIT);
        FragmentInfo fragmentInfo = new FragmentInfo();
        fragmentInfo.setFragmentName(originalFilename);
        fragmentInfo.setFragmentOrder(Integer.parseInt(originalFilename.substring(lastIndexOf + FRAGMENT_SPLIT.length())));
        fragmentInfo.setPath(ROOT_PATH + md5 + File.separator + originalFilename);
        fragmentInfo.setMd5(md5);
        fragmentInfo.setCreateTime(GetCurrentTime.getCurrentTimeBySecond());
        fragmentInfo.setDeleteState(false);

        fragmentInfoService.save(fragmentInfo);
        return Result.success("分片文件:" + originalFilename + "上传成功", null);
    }

    @GetMapping("/checkMd5")
    public Result checkMd5(@RequestParam String md5) {
        QueryWrapper<FragmentInfo> wrapper = new QueryWrapper<>();
        wrapper.eq("md5", md5).eq("delete_state", "0");
        FragmentInfo fragmentInfo = fragmentInfoService.getOne(wrapper);
        if (fragmentInfo != null) {
            return Result.success("MD5已存在", null);
        } else {
            fragmentInfoService.remove(wrapper);
            return Result.error("MD5不存在", null);
        }
    }

}

App.vue,这里的异步的一些内容,我是感觉真的麻了,后面需要再调一调,我感觉里面肯定存在着bug,不过我目前还没测试出来;还遇到了progressEvent对象的loaded大小和文件原本的大小不一致的问题,难搞啊,后面我巧妙的转换了一下,解决了这个bug

<template>
  <link rel="stylesheet" href="/style/css/iconfont.css">

  <div class="drop-area" @drop="getDropItems" @click="showFilePicker">
    <div>
      <i class="iconfont icon-upload"/>
      <div class="tip-text">
        Drop file here or
        <em>click to upload</em>
      </div>
    </div>
  </div>

  <div class="file-list">
    <div v-for="(item, index) in data.fileList" :key="index" class="single-file" @click="showFragmentInfo(item)">
      <MyProgress :percentage="item.percentage" :content="item.name" :transition="item.transition"/>
    </div>
  </div>

  <el-dialog v-model="data.fragmentDialogVisible" title="分片详情查看" width="80%">
    <div v-for="(item, index) in data.showFragmentList" :key="index" class="single-file">
      <MyProgress :percentage="item.percentage" :content="item.name"/>
    </div>
    <template #footer>
      <span class="dialog-footer">
        <el-button @click="data.fragmentDialogVisible = false">关闭</el-button>
      </span>
    </template>
  </el-dialog>
</template>

<script>
import {onBeforeMount, reactive} from "vue";
import MyProgress from "@/MyProgress.vue";
import {calculateMD5, EACH_FILE, getRequest, message, postFileRequest, postRequest} from "@/util";

export default {
  name: "App",
  components: {MyProgress},
  setup() {
    const data = reactive({
      fileList: [],
      isUploading: false,
      fragmentDialogVisible: false,
      showFragmentList: []
    });

    onBeforeMount(() => {
      onload = function () {
        document.addEventListener("drop", function (e) {
          //拖离
          e.preventDefault();
        });
        document.addEventListener("dragleave", function (e) {
          //拖后放
          e.preventDefault();
        });
        document.addEventListener("dragenter", function (e) {
          //拖进
          e.preventDefault();
        });
        document.addEventListener("dragover", function (e) {
          //拖来拖去
          e.preventDefault();
        });
      };
    });

    function getFileFromEntryRecursively(entry) {
      return new Promise((resolve) => {
        if (entry.isFile) {
          entry.file((file) => {
            data.fileList.push({
              name: entry.fullPath.substring(entry.fullPath.lastIndexOf("/") + 1, entry.fullPath.length),
              percentage: 0,
              file: file,
              totalSize: file.size,
              totalCompleteSize: 0,
              isUpload: false
            });
            resolve();
          });
        } else {
          let reader = entry.createReader();
          reader.readEntries((entries) => {
            Promise.all(entries.map(entry => getFileFromEntryRecursively(entry))).then(() => {
              resolve();
            });
          });
        }
      });
    }

    async function getDropItems(event) {
      if (data.isUploading) {
        message("正在上传...", "info");
        return;
      }
      data.fileList = [];
      data.isUploading = true;
      const items = event.dataTransfer.items;
      const promises = [];
      for (const item of items) {
        if (item.kind === "file") {
          const entry = item.webkitGetAsEntry();
          promises.push(getFileFromEntryRecursively(entry));
        }
      }
      await Promise.all(promises);

      await upload();
    }

    async function upload() {
      const checkMd5Tip = message("正在校验文件的md5,请稍候", "info");
      await checkMd5(checkMd5Tip);

      sliceFile();

      const checkFragmentMd5Tip = message("正在校验分片文件的md5,请稍候", "info");
      await checkFragmentMd5(checkFragmentMd5Tip);

      for (let i = 0; i < data.fileList.length; i++) {
        if (data.fileList[i].isUpload) {
          continue;
        }

        for (let j = 0; j < data.fileList[i].fragmentList.length; j++) {
          if (data.fileList[i].fragmentList[j].percentage !== 100) {
            const onUploadProgress = (progressEvent) => {
              data.fileList[i].fragmentList[j].percentage = parseInt(Number(((progressEvent.loaded / progressEvent.total) * 100)).toFixed(0));
              data.fileList[i].fragmentList[j].completeSize = progressEvent.loaded / progressEvent.total * data.fileList[i].fragmentList[j].fragmentFile.size;
              updateTotalPercentage(i);
            };

            const formData = new FormData();
            formData.append("file", data.fileList[i].fragmentList[j].fragmentFile, data.fileList[i].fragmentList[j].name);

            postFileRequest("/fragment-info/upload?md5=" + data.fileList[i].fragmentList[j].md5, formData, onUploadProgress).then((res) => {
              if (res.data.code === 500) {
                message(res.data.msg, "error");
              }
            });
          }
        }
      }
    }

    async function checkMd5(checkMd5Tip) {
      const promises = [];
      const promisesCheckMd5 = [];
      for (let i = 0; i < data.fileList.length; i++) {
        promises.push(calculateMD5(data.fileList[i].file).then(md5 => {
          data.fileList[i].md5 = md5;
          promisesCheckMd5.push(getRequest("/file-info/checkMd5?md5=" + md5).then((res) => {
            if (res.data.code === 200) {
              data.fileList[i].percentage = 100;
              data.fileList[i].isUpload = true;
              data.fileList[i].transition = "none";
              data.fileList[i].totalCompleteSize = data.fileList[i].file.size;
              message(data.fileList[i].name + "文件上传完成", "success");
              checkUploadOver(i);
            }
          }));
        }));
      }
      await Promise.all(promises);
      await Promise.all(promisesCheckMd5);
      checkMd5Tip.close();
    }

    function sliceFile() {
      for (let i = 0; i < data.fileList.length; i++) {
        if (data.fileList[i].isUpload) {
          continue;
        }

        const fragmentCount = Math.floor(data.fileList[i].file.size / EACH_FILE) + 1;
        const fragmentList = [];
        for (let j = 0; j < fragmentCount; j++) {
          fragmentList.push({
            id: j,
            fragmentFile: data.fileList[i].file.slice(j * EACH_FILE, (j + 1) * EACH_FILE),
            completeSize: 0,
            name: data.fileList[i].name + "--分片" + (j + 1),
            percentage: 0,
          });
        }
        data.fileList[i].fragmentList = fragmentList;
      }
    }

    async function checkFragmentMd5(checkFragmentMd5Tip) {
      const promises = [];
      const promisesCheckMd5 = [];
      for (let i = 0; i < data.fileList.length; i++) {
        if (data.fileList[i].isUpload) {
          continue;
        }

        for (let j = 0; j < data.fileList[i].fragmentList.length; j++) {
          promises.push(calculateMD5(data.fileList[i].fragmentList[j].fragmentFile).then(md5 => {
            data.fileList[i].fragmentList[j].md5 = md5;
            promisesCheckMd5.push(getRequest("/fragment-info/checkMd5?md5=" + md5).then((res) => {
              if (res.data.code === 200) {
                data.fileList[i].fragmentList[j].percentage = 100;
                data.fileList[i].fragmentList[j].completeSize = data.fileList[i].fragmentList[j].fragmentFile.size;
              }
            }));
          }));
        }
      }
      await Promise.all(promises);
      await Promise.all(promisesCheckMd5);
      checkFragmentMd5Tip.close();
    }

    async function updateTotalPercentage(i) {
      let totalCompleteSize = 0;
      for (let j = 0; j < data.fileList[i].fragmentList.length; j++) {
        totalCompleteSize += data.fileList[i].fragmentList[j].completeSize;
      }
      data.fileList[i].totalCompleteSize = totalCompleteSize;
      data.fileList[i].percentage = parseInt(Number(data.fileList[i].totalCompleteSize / data.fileList[i].totalSize * 100).toFixed(0));
      if (data.fileList[i].percentage === 100) {
        if (!data.fileList[i].isUpload) {
          data.fileList[i].isUpload = true;
          message(data.fileList[i].name + "文件上传完成", "success");
          await generateFile(i);
          checkUploadOver(i);
        }
      }
    }

    async function generateFile(i) {
      const fragmentMd5List = [];
      for (let j = 0; j < data.fileList[i].fragmentList.length; j++) {
        fragmentMd5List.push(data.fileList[i].fragmentList[j].md5);
      }

      await postRequest("/file-info/generateFile", {
        name: data.fileList[i].name,
        md5: data.fileList[i].md5,
        fragmentMd5List: fragmentMd5List
      }).then((res) => {
        if (res.data.code === 500) {
          message(res.data.msg, "error");
        }
      });
    }

    function checkUploadOver() {
      let isOver = true;
      for (let i = 0; i < data.fileList.length; i++) {
        if (data.fileList[i].percentage !== 100) {
          isOver = false;
          break
        }
      }
      if (isOver) {
        data.isUploading = false;
      }
    }

    const pickerOpts = {
      excludeAcceptAllOption: false,
      multiple: true,
    };

    async function showFilePicker() {
      if (data.isUploading) {
        message("正在上传...", "info");
        return;
      }

      let fileHandle;
      try {
        fileHandle = await window.showOpenFilePicker(pickerOpts);
        data.fileList = [];
        data.isUploading = true;
      } catch (e) {
        if (e.name === 'AbortError' && e.message === 'The user aborted a request.') {
          message("用户没有选择文件", "info");
          return;
        } else {
          throw e;
        }
      }

      for (let i = 0; i < fileHandle.length; i++) {
        const file = await fileHandle[i].getFile();
        data.fileList.push({
          name: fileHandle[i].name,
          percentage: 0,
          file: file,
          totalSize: file.size,
          totalCompleteSize: 0,
          isUpload: false,
        });
      }

      await upload();
    }

    function showFragmentInfo(item) {
      data.showFragmentList = item.fragmentList;
      data.fragmentDialogVisible = true;
    }

    return {
      data,
      getDropItems,
      showFilePicker,
      showFragmentInfo,
    };
  },
};
</script>

<style>
* {
  padding: 0;
  margin: 0;
  box-sizing: border-box;
}

.drop-area {
  margin: 100px auto 0;
  width: 800px;
  height: 180px;
  border: 1px dashed #dcdfe6;
  display: flex;
  align-items: center;
  justify-content: center;
}

.drop-area:hover {
  border-color: #409eff;
  cursor: pointer;
}

.icon-upload::before {
  display: flex;
  justify-content: center;
  font-size: 40px;
  margin: 10px 0;
}

.tip-text {
  color: #606266;
  font-size: 14px;
  text-align: center;
}

.tip-text em {
  color: #409eff;
  font-style: normal;
}

.file-list {
  margin: 0 auto;
  width: 800px;
}

.single-file {
  margin: 10px 0;
}
</style>

MyProgress.vue(由于我在测试的过程中发现,那个宽度会经常被卡住,所以我设置了transition属性,在秒传的时候就直接不过渡了)

<template>
  <link rel="stylesheet" href="/style/css/iconfont.css">

  <div class="progress-container">
    <div class="bar">
      <div class="percentage" :style="{'width': props.percentage + '%', 'transition' : props.transition ? props.transition : 'width 0.2s linear'}">
        <span class="text-inside">{{ props.content + " " + props.percentage + "%" }}</span>
      </div>
    </div>
    <div class="tip-content">
      <span v-show="props.percentage !== 100">{{ props.percentage + "%" }}</span>
      <i class="iconfont icon-over" v-show="props.percentage === 100"/>
    </div>
  </div>
</template>

<script>
export default {
  props: ["percentage", "content", "transition"],
  setup(props) {
    return {
      props
    }
  }
}
</script>

<style scoped>
.progress-container {
  display: flex;
  height: 30px;
  cursor: pointer;
  border: 1px dashed #dcdfe6;
  padding: 0 10px;
}

.bar {
  color: white;
  font-weight: 500;
  line-height: 30px;
  font-size: 14px;
  flex: 1;
}

.percentage {
  border-radius: 30px;
  background-color: #67c23a;
  white-space: nowrap;
  word-break: break-all;
  overflow: hidden;
}

.text-inside {
  padding-right: 10px;
  padding-left: 15px;
  float: right;
}

.tip-content {
  padding: 0 10px;
  font-size: 16px;
  line-height: 30px;
  width: 40px;
}

.icon-over::before {
  font-size: 24px;
  color: #67c23a;
}
</style>

util.js(生成md5字符串采用了 crypto-js 库,还是比较方便的)

import {ElMessage} from "element-plus";
import axios from "axios";
import {MD5} from 'crypto-js';

const baseUrl = "http://127.0.0.1:8080"

export function message(msg, type) {
    return ElMessage({
        message: msg,
        type: type,
        center: true,
        showClose: true,
    })
}

export const getRequest = (url) => {
    return axios({
        method: 'get',
        url: baseUrl + url
    })
}

export const postRequest = (url, data) => {
    return axios({
        method: 'post',
        url: baseUrl + url,
        data: data,
    })
}

export const postFileRequest = (url, data, onUploadProgress) => {
    return axios({
        method: 'post',
        url: baseUrl + url,
        data: data,
        onUploadProgress: onUploadProgress,
    })
}

export const calculateMD5 = (file) => {
    return new Promise(resolve => {
        const fileReader = new FileReader();
        fileReader.readAsBinaryString(file);
        fileReader.onloadend = event => {
            resolve(MD5(event.target.result).toString());
        }
    });
}

export const EACH_FILE = 1024 * 1024 * 2;

后续开发说明

考虑到文章的篇幅,以及代码后面会多一些,我就都放到了Gitee上了,后面设计功能包括:之前写好的分片上传和秒传,下载部分也是设计成了分片下载,最后合并,不过还没有加上断点重下的功能实现;后面可以考虑结合浏览器自带的IndexDB数据库,然后实现该效果。

后面我尝试了一下,选择16字节的数组来存储32位MD5字符串转换后的结果是没问题的;然后我加上了一个简单的管理界面,来方便查看

效果预览
在这里插入图片描述

在这里插入图片描述

我简单测试了一下,还存在着不少的bug,主要有一些异常情况的处理,没有很完善;然后就是上传文件的数量限制和大小限制,没有进行很详细的设置;这方面可以再后面自主添加

源码下载

参见Gitee–在线网盘系统

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

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

相关文章

图像拼接——基于homography的特征匹配算法

目录 1. 任务要求2. 数据集3. 基于homography的特征匹配算法4. 拼接流程展示4.1 图片实例4.2 特征点位图4.3 特征点匹配结果4.4 相机校准结果4.5 拼接结果 5. 部分图像拼接结果展示 1. 任务要求 输入&#xff1a;同一个场景的两张待拼接图像&#xff08;有部分场景重合&#x…

统信系统常见问题解决方法

☞ ░ 前往老猿Python博客 ░ https://blog.csdn.net/LaoYuanPython 背景说明 本文所说的问题&#xff0c;是基于浪潮统信UOS的环境存在的问题。 一、WPS新建文档默认保存格式不对 解决办法&#xff1a; 1.编辑/opt/apps/cn.wps.wps-office-pro/files/kingsoft/wps-office/…

计算机网络【Google的TCP BBR拥塞控制算法深度解析】

Google的TCP BBR拥塞控制算法深度解析 宏观背景下的BBR 慢启动、拥塞避免、快速重传、快速恢复&#xff1a; 说实话&#xff0c;这些机制完美适应了1980年代的网络特征&#xff0c;低带宽&#xff0c;浅缓存队列&#xff0c;美好持续到了2000年代。 随后互联网大爆发&#x…

【中南林业科技大学】计算机组成原理复习包括题目讲解(超详细)

来都来了点个赞收藏关注一下再走呗&#x1f339;&#x1f339;&#x1f339;&#x1f339; 第1章&#xff1a;绪论 1.冯诺依曼机特点&#xff0c;与现代计算机的区别 冯诺依曼计算机的基本思想是&#xff1a;程序和数据以二进制形式表示&#xff0c;存储程序控制。在计算机中&…

Android14新特性 开启前台service服务

1. Android14新特性 1.1. 场景 在Android14&#xff08;targetSDK34&#xff09;系统手机开启前台service服务崩溃 ATAL EXCEPTION: mainProcess: com.inspur.lbrd, PID: 15634java.lang.RuntimeException: Unable to create service com.inspur.lbrd.service.KeepAliveServi…

[GDOUCTF 2023]泄露的伪装

[GDOUCTF 2023]泄露的伪装 wp 进入页面&#xff0c;会发现什么也没有&#xff1a; 目录扫描&#xff1a; dirsearch -u “http://node4.anna.nssctf.cn:28588/” 扫出了两个文件&#xff0c;都去访问一下&#xff0c;test.txt 是源码的副本&#xff0c;由于是文本文件&…

STL——集合算法

算法简介&#xff1a; set_intersection // 求两个容器的交集set_union // 求两个容器的并集set_difference // 求两个容器的差集 1.set_intersection 函数原型&#xff1a; set_intersection(iterator beg1, iterator end1, iterator beg2, iterator end2, iterator dest);…

NFS的基本使用

#江南的江 #每日鸡汤&#xff1a;岁月匆匆&#xff0c;时光荏苒&#xff0c;感悟人生路漫漫&#xff0c;不忘初心方得始终。 #初心和目标&#xff1a;和从前的自己博弈。 NFS(存储共享服务) 本文要点摘要&#xff1a; 下面将讨论什么是NFS&#xff0c;如何配置NFS&#xff0c;…

AI产品经理-借力

AI产品经理-借力&#xff1a;学会善用供应商改造自有产品 1.整个项目的工作方法 2.项目启动-行业调研 3.项目启动-供应商选型

【数据结构——二叉树】二叉树及其应用2023(头歌习题)【合集】

目录 第1关&#xff1a;括号表示法创建二叉树任务描述相关知识编程要求测试说明完整代码 第2关&#xff1a;先序序列创建二叉树任务描述相关知识二叉树的前序遍历如何创建一颗二叉树伪代码如下&#xff1a; 二叉树的中序遍历 编程要求测试说明完整代码 第3关&#xff1a;计算二…

蓝桥杯C/C++程序设计——单词分析

题目描述 小蓝正在学习一门神奇的语言&#xff0c;这门语言中的单词都是由小写英文字母组 成&#xff0c;有些单词很长&#xff0c;远远超过正常英文单词的长度。小蓝学了很长时间也记不住一些单词&#xff0c;他准备不再完全记忆这些单词&#xff0c;而是根据单词中哪个字母出…

Python魔法方法之__getattr__和getattribute

在Python中有这两个魔法方法容易让人混淆&#xff1a;__getattr__和getattribute。通常我们会定义__getattr__而从来不会定义getattribute&#xff0c;下面我们来看看这两个的区别。 __getattr__魔法方法 class MyClass:def __init__(self, x):self.x xdef __getattr__(self, …

HTML标签基础入门

HTML 基本语法概述标签关系HTML基础结构HTML常用标签标题标签示例 段落和换行标签示例 文本格式化标签示例 div和span标签示例 图像标签和路径示例 超链接标签示例 注释 ctrl/特殊字符示例 表格标签 表头单元格标签表格属性示例 合并单元格示例 列表标签无序列表有序列表自定义…

SpringBoot集成支付宝,看这一篇就够了。

前 言 在开始集成支付宝支付之前&#xff0c;我们需要准备一个支付宝商家账户&#xff0c;如果是个人开发者&#xff0c;可以通过注册公司或者让有公司资质的单位进行授权&#xff0c;后续在集成相关API的时候需要提供这些信息。 下面我以电脑网页端在线支付为例&#xff0c;介…

Linux系统使用yum安装MySQL

部署MySQL数据库有多种部署方式&#xff0c;常用的部署方式就有三种&#xff1a;yum安装、rpm安装以及编译安装。每一种安装方式都有自己的优势&#xff0c;那么企业当中通常情况下采用的是rpm和二进制安装的方式。 MySQL官网下载地址 Mysql 5.7的主要特性 更好的性能&#xf…

费曼学习法应用:谈自私和教育的引导

今天这个还是来源于我和九迁的对话&#xff0c;起因是中午吃饭的时候&#xff0c;九迁在学校与班主任老师和数学老师对话中带来的思考。 先听音频&#xff1a; 对话内容&#xff08;以下内容可以边听边看&#xff0c;属于语音转换过来的文字&#xff0c;最后有个总结&#xff0…

中文字符占用字节即相关原理(实现中文(中英混合)字符串的反转)

如有不对欢迎指正。 目录 一.ASCLL字符和中文字符 1.使用无符号数表示的原因(对于中文字符)&#xff1a; 2.但是并不是所有情况都是用无符号数(以下目前只是猜测,如有问题欢迎指正) &#xff1a; 1. 什么时候使用无符号数表示: 2. 不需要使用的情况&#xff1a; …

46、激活函数 - Relu 激活

本节介绍一个在神经网络中非常常见的激活函数 - Relu 激活函数。 什么是ReLU激活函数 ReLU 英文名为 Rectified Linear Unit,又称修正线性单元,是一种简单但很有效的激活函数,它的定义如下: 即当输入 x 大于零时,输出等于他自己;当输入小于等于零时,输出为零,下面是re…

2023年成都市中等职业学校学生技能大赛“网络搭建及应用”赛项竞赛样卷

2023年成都市中等职业学校学生技能大赛 “网络搭建及应用”赛项竞赛样卷 &#xff08;总分1000分&#xff09; 目录 2023年成都市中等职业学校学生技能大赛 “网络搭建及应用”赛项竞赛样卷 网络建设与调试项目&#xff08;500分&#xff09; 服务器搭建与运维项目&#xff08;…

2023年度业务风险报告:四个新风险趋势

目录 倒票的黄牛愈加疯狂 暴增的恶意网络爬虫 愈加猖獗的羊毛党 层出不穷的新风险 业务风险呈现四个趋势 防御云业务安全情报中心“2023年业务风险数据”统计显示&#xff0c;恶意爬虫风险最多&#xff0c;占总数的37.8%&#xff1b;其次是虚假账号注册&#xff0c;占18.79%&am…