SpringBoot大文件上传实现分片、断点续传

news2024/12/26 22:30:24

大文件上传流程

  1. 客户端计算文件的哈希值,客户端将哈希值发送给服务端,服务端检查数据库或文件系统中是否已存在相同哈希值的文件,如果存在相同哈希值的文件,则返回秒传成功结果,如果不存在相同哈希值的文件,则进行普通的文件上传流程。
  2. 客户端将要上传的大文件切割成固定大小的切片,客户端将每个切片逐个上传给服务端。服务端接收到每个切片后,暂存或保存在临时位置,当所有切片上传完毕时,服务端将这些切片合并成完整的文件。
  3. 客户端记录上传进度,包括已上传的切片以及每个切片的上传状态。客户端将上传进度信息发送给服务端。如果客户端上传中断或失败,重新连接后发送上传进度信息给服务端。服务端根据上传进度信息确定断点位置,并继续上传剩余的切片。服务端接收切片后,将其暂存或保存在临时位置,当所有切片上传完毕时,服务端将这些切片合并成完整的文件。

后端部分 

引入依赖

         <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.7.22</version>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.47</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <!-- 数据库-->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>${mysql.version}</version>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.5.1</version>
        </dependency>

 properties文件配置

spring.servlet.multipart.max-file-size=1GB
spring.servlet.multipart.max-request-size=1GB
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/big-file?serverTimezone=GMT%2B8&useUnicode=true&characterEncoding=utf8&autoReconnect=true&allowMultiQueries=true
spring.datasource.username=root
spring.datasource.password=root123
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.hikari.pool-name=HikariCPDatasource
spring.datasource.hikari.minimum-idle=5
spring.datasource.hikari.idle-timeout=180000
spring.datasource.hikari.maximum-pool-size=10
spring.datasource.hikari.auto-commit=true
spring.datasource.hikari.max-lifetime=1800000
spring.datasource.hikari.connection-timeout=30000
spring.datasource.hikari.connection-test-query=SELECT 1
spring.redis.host=43.139.59.28
spring.redis.port=6379
spring.redis.timeout=10s
spring.redis.password=123
spring.thymeleaf.cache=false
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html
file.path=D:\\tmp\\file
# 20 * 1024 * 1024
file.chunk-size=20971520

model

返回给前端的vo判断是否已经上传过,是就秒传,不是就分片上传 

import lombok.Data;

import java.util.List;

/**
 * 检验返回给前端的vo
 */
@Data
public class CheckResultVo {
    /**
     * 是否已上传
     */
    private Boolean uploaded;

    private String url;

    private List<Integer> uploadedChunks;
}

接收前端发送过来的分片

import lombok.Data;
import org.springframework.web.multipart.MultipartFile;

/**
 * 接收前端传过来的参数
 * 配合前端上传方法接收参数,参考官方文档
 * https://github.com/simple-uploader/Uploader/blob/develop/README_zh-CN.md#%E6%9C%8D%E5%8A%A1%E7%AB%AF%E5%A6%82%E4%BD%95%E6%8E%A5%E5%8F%97%E5%91%A2
 */

@Data
public class FileChunkDto {
    /**
     * 当前块的次序,第一个块是 1,注意不是从 0 开始的
     */
    private Integer chunkNumber;
    /**
     * 文件被分成块的总数。
     */
    private Integer totalChunks;
    /**
     * 分块大小,根据 totalSize 和这个值你就可以计算出总共的块数。注意最后一块的大小可能会比这个要大
     */
    private Long chunkSize;
    /**
     * 当前要上传块的大小,实际大小
     */
    private Long currentChunkSize;
    /**
     * 文件总大小
     */
    private Long totalSize;
    /**
     * 这个就是每个文件的唯一标示
     */
    private String identifier;
    /**
     * 文件名
     */
    private String filename;
    /**
     * 文件夹上传的时候文件的相对路径属性
     */
    private String relativePath;
    /**
     * 文件
     */
    private MultipartFile file;
}

数据库分片的实体类 

import lombok.Data;

import java.io.Serializable;
import java.time.LocalDateTime;

/**
 * 文件块存储(FileChunk)表实体类
 */
@Data
public class FileChunk implements Serializable {
    /**主键**/
    private Long id;
    /**文件名**/
    private String fileName;
    /**当前分片,从1开始**/
    private Integer chunkNumber;
    /**分片大小**/
    private Long chunkSize;
    /**当前分片大小**/
    private Long currentChunkSize;
    /**文件总大小**/
    private Long totalSize;
    /**总分片数**/
    private Integer totalChunk;
    /**文件标识 md5校验码**/
    private String identifier;
    /**相对路径**/
    private String relativePath;

    /**创建者**/
    private String createBy;
    /**创建时间**/
    private LocalDateTime createTime;
    /**更新人**/
    private String updateBy;
    /**更新时间**/
    private LocalDateTime updateTime;
}

数据库的文件的实体类 

import lombok.Data;

import java.io.Serializable;
import java.time.LocalDateTime;

/**
 * 文件存储表(FileStorage)表实体类
 */
@Data
public class FileStorage implements Serializable {
    /**主键**/
    private Long id;
    /**文件真实姓名**/
    private String realName;
    /**文件名**/
    private String fileName;
    /**文件后缀**/
    private String suffix;
    /**文件路径**/
    private String filePath;
    /**文件类型**/
    private String fileType;
    /**文件大小**/
    private Long size;
    /**检验码 md5**/
    private String identifier;
    /**创建者**/
    private String createBy;
    /**创建时间**/
    private LocalDateTime createTime;
    /**更新人**/
    private String updateBy;
    /**更新时间**/
    private LocalDateTime updateTime;
}

Mapper层

操作分片的数据库的mapper 

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.demo.model.FileChunk;
import org.apache.ibatis.annotations.Mapper;
/**
 * 文件块存储(FileChunk)表数据库访问层
 */
@Mapper
public interface FileChunkMapper extends BaseMapper<FileChunk> {

}

操作文件的数据库的mapper

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.demo.model.FileStorage;
import org.apache.ibatis.annotations.Mapper;

/**
 * 文件存储表(FileStorage)表数据库访问层
 */
@Mapper
public interface FileStorageMapper extends BaseMapper<FileStorage> {

}

Service层

操作数据库文件分片表的service接口 

import com.baomidou.mybatisplus.extension.service.IService;
import com.example.demo.model.CheckResultVo;
import com.example.demo.model.FileChunk;
import com.example.demo.model.FileChunkDto;

/**
 * 文件块存储(FileChunk)表服务接口
 */
public interface FileChunkService extends IService<FileChunk> {
    /**
     * 校验文件
     * @param dto 入参
     * @return obj
     */
    CheckResultVo check(FileChunkDto dto);
}

 操作数据库文件表的service接口 

import com.baomidou.mybatisplus.extension.service.IService;
import com.example.demo.model.FileChunkDto;
import com.example.demo.model.FileStorage;


import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * 文件存储表(FileStorage)表服务接口
 */
public interface FileStorageService extends IService<FileStorage> {
    /**
     * 文件上传接口
     * @param dto 入参
     * @return
     */
    Boolean uploadFile(FileChunkDto dto);

    /**
     * 下载文件
     * @param identifier
     * @param request
     * @param response
     */
    void downloadByIdentifier(String identifier, HttpServletRequest request, HttpServletResponse response);
}

Impl层

操作数据库文件分片表的impl

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.demo.mapper.FileChunkMapper;
import com.example.demo.model.CheckResultVo;
import com.example.demo.model.FileChunk;
import com.example.demo.model.FileChunkDto;
import com.example.demo.service.FileChunkService;
import org.springframework.stereotype.Service;


import java.util.ArrayList;
import java.util.List;

/**
 * 文件块存储(FileChunk)表服务实现类
 */
@Service
public class  FileChunkServiceImpl extends ServiceImpl<FileChunkMapper, FileChunk> implements FileChunkService {

    @Override
    public CheckResultVo check(FileChunkDto dto) {
        CheckResultVo vo = new CheckResultVo();
        // 1. 根据 identifier 查找数据是否存在
        List<FileChunk> list = this.list(new LambdaQueryWrapper<FileChunk>()
                .eq(FileChunk::getIdentifier, dto.getIdentifier())
                .orderByAsc(FileChunk::getChunkNumber)
        );
        // 如果是 0 说明文件不存在,则直接返回没有上传
        if (list.size() == 0) {
            vo.setUploaded(false);
            return vo;
        }
        // 如果不是0,则拿到第一个数据,查看文件是否分片
        // 如果没有分片,那么直接返回已经上穿成功
        FileChunk fileChunk = list.get(0);
        if (fileChunk.getTotalChunk() == 1) {
            vo.setUploaded(true);
            return vo;
        }
        // 处理分片
        ArrayList<Integer> uploadedFiles = new ArrayList<>();
        for (FileChunk chunk : list) {
            uploadedFiles.add(chunk.getChunkNumber());
        }
        vo.setUploadedChunks(uploadedFiles);
        return vo;
    }
}

操作数据库文件表的impl


import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.io.FileUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.demo.mapper.FileStorageMapper;
import com.example.demo.model.FileChunk;
import com.example.demo.model.FileChunkDto;
import com.example.demo.model.FileStorage;
import com.example.demo.service.FileChunkService;
import com.example.demo.service.FileStorageService;
import com.example.demo.util.BulkFileUtil;
import com.example.demo.util.RedisCache;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;


import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.util.Arrays;
import java.util.List;
import java.util.stream.IntStream;

/**
 * 文件存储表(FileStorage)表服务实现类
 */
@Service
@Slf4j
public class FileStorageServiceImpl extends ServiceImpl<FileStorageMapper, FileStorage> implements FileStorageService {

    @Resource
    private RedisCache redisCache;

    /**
     * 默认分块大小
     */
    @Value("${file.chunk-size}")
    public Long defaultChunkSize;
    /**
     * 上传地址
     */
    @Value("${file.path}")
    private String baseFileSavePath;

    @Resource
    FileChunkService fileChunkService;

    @Override
    public Boolean uploadFile(FileChunkDto dto) {
        // 简单校验,如果是正式项目,要考虑其他数据的校验
        if (dto.getFile() == null) {
            throw new RuntimeException("文件不能为空");
        }
        String fullFileName = baseFileSavePath + File.separator + dto.getFilename();
        Boolean uploadFlag;
        // 如果是单文件上传
        if (dto.getTotalChunks() == 1) {
            uploadFlag = this.uploadSingleFile(fullFileName, dto);
        } else {
            // 分片上传
            uploadFlag = this.uploadSharding(fullFileName, dto);
        }
        // 如果本次上传成功则存储数据到 表中
        if (uploadFlag) {
            this.saveFile(dto, fullFileName);
        }
        return uploadFlag;
    }

    @SneakyThrows
    @Override
    public void downloadByIdentifier(String identifier, HttpServletRequest request, HttpServletResponse response) {
        FileStorage file = this.getOne(new LambdaQueryWrapper<FileStorage>()
                .eq(FileStorage::getIdentifier, identifier));
        if (BeanUtil.isNotEmpty(file)) {
            File toFile = new File(baseFileSavePath + File.separator + file.getFilePath());
            BulkFileUtil.downloadFile(request, response, toFile);
        } else {
            throw new RuntimeException("文件不存在");
        }
    }

    /**
     * 分片上传方法
     * 这里使用 RandomAccessFile 方法,也可以使用 MappedByteBuffer 方法上传
     * 可以省去文件合并的过程
     *
     * @param fullFileName 文件名
     * @param dto          文件dto
     */
    private Boolean uploadSharding(String fullFileName, FileChunkDto dto) {
        // try 自动资源管理
        try (RandomAccessFile randomAccessFile = new RandomAccessFile(fullFileName, "rw")) {
            // 分片大小必须和前端匹配,否则上传会导致文件损坏
            long chunkSize = dto.getChunkSize() == 0L ? defaultChunkSize : dto.getChunkSize().longValue();
            // 偏移量, 意思是我从拿一个位置开始往文件写入,每一片的大小 * 已经存的块数
            long offset = chunkSize * (dto.getChunkNumber() - 1);
            // 定位到该分片的偏移量
            randomAccessFile.seek(offset);
            // 写入
            randomAccessFile.write(dto.getFile().getBytes());
        } catch (IOException e) {
            log.error("文件上传失败:" + e);
            return Boolean.FALSE;
        }
        return Boolean.TRUE;
    }


    private Boolean uploadSingleFile(String fullFileName, FileChunkDto dto) {
        try {
            File localPath = new File(fullFileName);
            dto.getFile().transferTo(localPath);
            return Boolean.TRUE;
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    private void saveFile(FileChunkDto dto, String fileName) {

        FileChunk chunk = BeanUtil.copyProperties(dto, FileChunk.class);
        chunk.setFileName(dto.getFilename());
        chunk.setTotalChunk(dto.getTotalChunks());
        fileChunkService.save(chunk);
        // 这里每次上传切片都存一下缓存,
        redisCache.setCacheListByOne(dto.getIdentifier(), dto.getChunkNumber());
        // 如果所有快都上传完成,那么在文件记录表中存储一份数据
        List<Integer> chunkList = redisCache.getCacheList(dto.getIdentifier());
        Integer totalChunks = dto.getTotalChunks();
        int[] chunks = IntStream.rangeClosed(1, totalChunks).toArray();
        // 从缓存中查看是否所有块上传完成,
        if (IntStream.rangeClosed(1, totalChunks).allMatch(chunkList::contains)) {
            // 所有分片上传完成,组合分片并保存到数据库中
            String name = dto.getFilename();
            MultipartFile file = dto.getFile();
            FileStorage fileStorage = new FileStorage();
            fileStorage.setRealName(file.getOriginalFilename());
            fileStorage.setFileName(fileName);
            fileStorage.setSuffix(FileUtil.getSuffix(name));
            fileStorage.setFileType(file.getContentType());
            fileStorage.setSize(dto.getTotalSize());
            fileStorage.setIdentifier(dto.getIdentifier());
            fileStorage.setFilePath(dto.getRelativePath());
            this.save(fileStorage);
        }
    }
}

Util工具类

文件相关的工具类 

import lombok.extern.slf4j.Slf4j;
import org.apache.tomcat.util.http.fileupload.IOUtils;
import sun.misc.BASE64Encoder;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;

/**
 * 文件相关的处理
 */
@Slf4j
public class BulkFileUtil {
    /**
     * 文件下载
     * @param request
     * @param response
     * @param file
     * @throws UnsupportedEncodingException
     */
    public static void downloadFile(HttpServletRequest request, HttpServletResponse response, File file) throws UnsupportedEncodingException {
        response.setCharacterEncoding(request.getCharacterEncoding());
        response.setContentType("application/octet-stream");
        FileInputStream fis = null;

        String filename = filenameEncoding(file.getName(), request);
        try {
            fis = new FileInputStream(file);
            response.setHeader("Content-Disposition", String.format("attachment;filename=%s", filename));
            IOUtils.copy(fis, response.getOutputStream());
            response.flushBuffer();
        } catch (Exception e) {
            log.error(e.getMessage(), e);
        } finally {
            if (fis != null) {
                try {
                    fis.close();
                } catch (IOException e) {
                    log.error(e.getMessage(), e);
                }
            }
        }
    }

    /**
     * 适配不同的浏览器,确保文件名字正常
     *
     * @param filename
     * @param request
     * @return
     * @throws UnsupportedEncodingException
     */
    public static String filenameEncoding(String filename, HttpServletRequest request) throws UnsupportedEncodingException {
        // 获得请求头中的User-Agent
        String agent = request.getHeader("User-Agent");
        // 根据不同的客户端进行不同的编码

        if (agent.contains("MSIE")) {
            // IE浏览器
            filename = URLEncoder.encode(filename, "utf-8");
        } else if (agent.contains("Firefox")) {
            // 火狐浏览器
            BASE64Encoder base64Encoder = new BASE64Encoder();
            filename = "=?utf-8?B?" + base64Encoder.encode(filename.getBytes("utf-8")) + "?=";
        } else {
            // 其它浏览器
            filename = URLEncoder.encode(filename, "utf-8");
        }
        return filename;
    }
}

redis的工具类 

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.BoundSetOperations;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Component;

import java.util.*;
import java.util.concurrent.TimeUnit;

/**
 * spring redis 工具类
 *
 **/
@Component
public class RedisCache
{
    @Autowired
    public RedisTemplate redisTemplate;



    /**
     * 缓存数据
     *
     * @param key 缓存的键值
     * @param data 待缓存的数据
     * @return 缓存的对象
     */
    public <T> long setCacheListByOne(final String key, final T data)
    {
        Long count = redisTemplate.opsForList().rightPush(key, data);
        if (count != null && count == 1) {
            redisTemplate.expire(key, 60, TimeUnit.SECONDS);
        }
        return count == null ? 0 : count;
    }

    /**
     * 获得缓存的list对象
     *
     * @param key 缓存的键值
     * @return 缓存键值对应的数据
     */
    public <T> List<T> getCacheList(final String key)
    {
        return redisTemplate.opsForList().range(key, 0, -1);
    }


}

响应体工具类 

import lombok.Data;

import java.io.Serializable;
@Data
public class ResponseResult<T> implements Serializable {
    private Boolean success;
    private Integer code;
    private String msg;
    private T data;

    public ResponseResult() {
        this.success=true;
        this.code = HttpCodeEnum.SUCCESS.getCode();
        this.msg = HttpCodeEnum.SUCCESS.getMsg();
    }

    public ResponseResult(Integer code, T data) {
        this.code = code;
        this.data = data;
    }

    public ResponseResult(Integer code, String msg, T data) {
        this.code = code;
        this.msg = msg;
        this.data = data;
    }

    public ResponseResult(Integer code, String msg) {
        this.code = code;
        this.msg = msg;
    }


    public ResponseResult<?> error(Integer code, String msg) {
        this.success=false;
        this.code = code;
        this.msg = msg;
        return this;
    }



    public static ResponseResult ok(Object data) {
        ResponseResult result = new ResponseResult();
        result.setData(data);
        return result;
    }


}

controller层

返回对应的页面 

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;

/**
 * 返回对于页面
 */
@Controller
@RequestMapping("/page")
public class PageController {

    @GetMapping("/{path}")
    public String toPage(@PathVariable String path) {
        return path;
    }
}

文件上传接口 


import com.example.demo.model.CheckResultVo;
import com.example.demo.model.FileChunkDto;
import com.example.demo.service.FileChunkService;
import com.example.demo.service.FileStorageService;
import com.example.demo.util.ResponseResult;
import org.springframework.web.bind.annotation.*;


import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 文件存储表(FileStorage)表控制层
 */
@RestController
@RequestMapping("fileStorage")
public class FileStorageController {
    @Resource
    private FileStorageService fileStorageService;
    @Resource
    private FileChunkService fileChunkService;

    /**
     * 本接口为校验接口,即上传前,先根据本接口查询一下 服务器是否存在该文件
     * @param dto 入参
     * @return vo
     */
    @GetMapping("/upload")
    public ResponseResult<CheckResultVo> checkUpload(FileChunkDto dto) {
        return ResponseResult.ok(fileChunkService.check(dto));
    }

    /**
     * 本接口为实际上传接口
     * @param dto      入参
     * @param response response 配合前端返回响应的状态码
     * @return boolean
     */
    @PostMapping("/upload")
    public ResponseResult<Boolean> upload(FileChunkDto dto, HttpServletResponse response) {
        try {
            Boolean status = fileStorageService.uploadFile(dto);
            if (status) {
                return ResponseResult.ok("上传成功");
            } else {
                response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
                return ResponseResult.error("上传失败");
            }
        } catch (Exception e) {
            // 这个code 是根据前端组件的特性来的,也可以自己定义规则
            response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
            return ResponseResult.error("上传失败");
        }
    }

    /**
     * 下载接口,这里只做了普通的下载
     * @param request    req
     * @param response   res
     * @param identifier md5
     * @throws IOException 异常
     */
    @GetMapping(value = "/download/{identifier}")
    public void downloadByIdentifier(HttpServletRequest request, HttpServletResponse response, @PathVariable("identifier") String identifier) throws IOException {
        fileStorageService.downloadByIdentifier(identifier, request, response);
    }
}

前端部分

下载插件

vue.js
element-ui.js
axios.js
vue-uploader.js
spark-md5.min.js
zh-CN.js

页面部分

插件引入 

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head th:fragment="base">
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
    <script th:src="@{/plugins/vue.js}" type="text/javascript"></script>
    <script th:src="@{/plugins/element/element-ui.js}" type="text/javascript"></script>
    <link th:href="@{/plugins/element/css/element-ui.css}" rel="stylesheet" type="text/css">
    <script th:src="@{/plugins/axios.js}" type="text/javascript"></script>
    <script th:src="@{/plugins/vue-uploader.js}" type="text/javascript"></script>
    <script th:src="@{/plugins/spark-md5.min.js}" type="text/javascript"></script>
    <script th:src="@{/plugins/element/zh-CN.js}"></script>
    <script type="text/javascript">
        ELEMENT.locale(ELEMENT.lang.zhCN)
        // 添加响应拦截器
        axios.interceptors.response.use(response => {
            if (response.request.responseType === 'blob') {
                return response
            }
            // 对响应数据做点什么
            const res = response.data
            if (!!res && res.code === 1) { // 公共处理失败请求
                ELEMENT.Message({message: "异常:" + res.msg, type: "error"})
                return Promise.reject(new Error(res.msg || 'Error'));
            } else {
                return res
            }
        }, function (error) {
            // 对响应错误做点什么
            return Promise.reject(error);
        });
    </script>
</head>

</html>

上传文件主页 

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org" xmlns="http://www.w3.org/1999/html">
<head>
    <meta charset="UTF-8">
    <title>大文件上传</title>
    <head th:include="/base::base"><title></title></head>
    <link href="upload.css" rel="stylesheet" type="text/css">
</head>
<body>

<div id="app">
    <uploader
            ref="uploader"
            :options="options"
            :autoStart="false"
            :file-status-text="fileStatusText"
            @file-added="onFileAdded"
            @file-success="onFileSuccess"
            @file-error="onFileError"
            @file-progress="onFileProgress"
            class="uploader-example"
    >
        <uploader-unsupport></uploader-unsupport>
        <uploader-drop>
            <p>拖动文件到这里上传</p>
            <uploader-btn>选择文件</uploader-btn>
        </uploader-drop>
        <uploader-list>
            <el-collapse v-model="activeName" accordion>
                <el-collapse-item title="文件列表" name="1">
                    <ul class="file-list">
                        <li v-for="file in uploadFileList" :key="file.id">
                            <uploader-file :file="file" :list="true" ref="uploaderFile">
                                <div slot-scope="props" style="display: flex;align-items: center;height: 100%;">
                                    <el-progress
                                            style="width: 85%"
                                            :stroke-width="18"
                                            :show-text="true"
                                            :text-inside="true"
                                            :format="e=> showDetail(e,props)"
                                            :percentage="percentage(props)"
                                            :color="e=>progressColor(e,props)">
                                    </el-progress>
                                    <el-button :icon="icon" circle v-if="props.paused || props.isUploading"
                                               @click="pause(file)" size="mini"></el-button>
                                    <el-button icon="el-icon-close" circle @click="remove(file)"
                                               size="mini"></el-button>
                                    <el-button icon="el-icon-download" circle v-if="props.isComplete"
                                               @click="download(file)"
                                               size="mini"></el-button>
                                </div>
                            </uploader-file>
                        </li>
                        <div class="no-file" v-if="!uploadFileList.length">
                            <i class="icon icon-empty-file"></i> 暂无待上传文件
                        </div>
                    </ul>
                </el-collapse-item>
            </el-collapse>
        </uploader-list>
    </uploader>
</div>
<script>
    // 分片大小,20MB
    const CHUNK_SIZE = 20 * 1024 * 1024;
    new Vue({
        el: '#app',
        data() {
            return {
                options: {
                    // 上传地址
                    target: "/fileStorage/upload",
                    // 是否开启服务器分片校验。默认为 true
                    testChunks: true,
                    // 真正上传的时候使用的 HTTP 方法,默认 POST
                    uploadMethod: "post",
                    // 分片大小
                    chunkSize: CHUNK_SIZE,
                    // 并发上传数,默认为 3
                    simultaneousUploads: 3,
                    /**
                     * 判断分片是否上传,秒传和断点续传基于此方法
                     * 这里根据实际业务来 用来判断哪些片已经上传过了 不用再重复上传了 [这里可以用来写断点续传!!!]
                     * 检查某个文件块是否已经上传到服务器,并根据检查结果返回布尔值。
                     * chunk 表示待检查的文件块,是一个对象类型包含 offset 和 blob 两个字段。offset 表示该块在文件中的偏移量blob 表示该块对应的二进制数据
                     * message 则表示服务器返回的响应消息,是一个字符串类型。
                     */
                    checkChunkUploadedByResponse: (chunk, message) => {
                        console.log("message", message)
                        // message是后台返回
                        let messageObj = JSON.parse(message);
                        let dataObj = messageObj.data;
                        if (dataObj.uploaded !== null) {
                            return dataObj.uploaded;
                        }
                        // 判断文件或分片是否已上传,已上传返回 true
                        // 这里的 uploadedChunks 是后台返回
                        // 判断 data.uploadedChunks 数组(或空数组)中是否包含 chunk.offset + 1 的值。
                        // chunk.offset + 1 表示当前文件块在整个文件中的排序位置(从 1 开始计数),
                        // 如果该值存在于 data.uploadedChunks 中,则说明当前文件块已经上传过了,返回 true,否则返回 false。
                        return (dataObj.uploadedChunks || []).indexOf(chunk.offset + 1) >= 0;
                    },
                    parseTimeRemaining: function (timeRemaining, parsedTimeRemaining) {
                        //格式化时间
                        return parsedTimeRemaining
                            .replace(/\syears?/, "年")
                            .replace(/\days?/, "天")
                            .replace(/\shours?/, "小时")
                            .replace(/\sminutes?/, "分钟")
                            .replace(/\sseconds?/, "秒");
                    },
                },
                // 修改上传状态
                fileStatusTextObj: {
                    success: "上传成功",
                    error: "上传错误",
                    uploading: "正在上传",
                    paused: "停止上传",
                    waiting: "等待中",
                },
                uploadFileList: [],
                collapse: true,
                activeName: 1,
                icon: `el-icon-video-pause`
            }
        },
        methods: {
            onFileAdded(file, event) {
                console.log("eeeee",event)
                // event.preventDefault();
                this.uploadFileList.push(file);
                console.log("file :>> ", file);
                // 有时 fileType为空,需截取字符
                console.log("文件类型:" + file.fileType + "文件大小:" + file.size + "B");
                // 1. todo 判断文件类型是否允许上传
                // 2. 计算文件 MD5 并请求后台判断是否已上传,是则取消上传
                console.log("校验MD5");
                this.getFileMD5(file, (md5) => {
                    if (md5 !== "") {
                        // 修改文件唯一标识
                        file.uniqueIdentifier = md5;
                        // 请求后台判断是否上传
                        // 恢复上传
                        file.resume();
                    }
                });
            },
            onFileSuccess(rootFile, file, response, chunk) {
                console.log("上传成功", rootFile, file, response, chunk);
                // 这里可以做一些上传成功之后的事情,比如,如果后端需要合并的话,可以通知到后端合并
            },
            onFileError(rootFile, file, message, chunk) {
                console.log("上传出错:" + message, rootFile, file, message, chunk);
            },
            onFileProgress(rootFile, file, chunk) {
                console.log(`当前进度:${Math.ceil(file._prevProgress * 100)}%`);
            },

            // 计算文件的MD5值
            getFileMD5(file, callback) {
                let spark = new SparkMD5.ArrayBuffer();
                let fileReader = new FileReader();
                //获取文件分片对象(注意它的兼容性,在不同浏览器的写法不同)
                let blobSlice =
                    File.prototype.slice ||
                    File.prototype.mozSlice ||
                    File.prototype.webkitSlice;
                // 当前分片下标
                let currentChunk = 0;
                // 分片总数(向下取整)
                let chunks = Math.ceil(file.size / CHUNK_SIZE);
                // MD5加密开始时间
                let startTime = new Date().getTime();
                // 暂停上传
                file.pause();
                loadNext();
                // fileReader.readAsArrayBuffer操作会触发onload事件
                fileReader.onload = function (e) {
                    // console.log("currentChunk :>> ", currentChunk);
                    // 通过 e.target.result 获取到当前分片的内容,并将其追加到 MD5 计算实例 spark 中,以便后续计算整个文件的 MD5 值。
                    spark.append(e.target.result);
                    // 通过比较当前分片的索引 currentChunk 是否小于总分片数 chunks 判断是否还有下一个分片需要读取。
                    if (currentChunk < chunks) {
                        // 如果存在下一个分片,则将当前分片索引递增并调用 loadNext() 函数加载下一个分片;
                        currentChunk++;
                        loadNext();
                    } else {
                        // 否则,表示所有分片已经读取完毕,可以进行最后的 MD5 计算。
                        // 该文件的md5值
                        let md5 = spark.end();
                        console.log(
                            `MD5计算完毕:${md5},耗时:${new Date().getTime() - startTime} ms.`
                        );
                        // 回调传值md5
                        callback(md5);
                    }
                };
                fileReader.onerror = function () {
                    this.$message.error("文件读取错误");
                    file.cancel();
                };

                // 加载下一个分片
                function loadNext() {
                    // start 的计算方式为当前分片的索引乘以分片大小 CHUNK_SIZE
                    const start = currentChunk * CHUNK_SIZE;
                    // end 的计算方式为 start 加上 CHUNK_SIZE,但如果超过了文件的总大小,则取文件的大小作为结束位置。
                    const end = start + CHUNK_SIZE >= file.size ? file.size : start + CHUNK_SIZE;
                    // 文件分片操作,读取下一分片(fileReader.readAsArrayBuffer操作会触发onload事件)
                    // 通过调用 blobSlice.call(file.file, start, end) 方法获取当前分片的 Blob 对象,即指定开始和结束位置的文件分片。
                    // 接着,使用 fileReader.readAsArrayBuffer() 方法读取该 Blob 对象的内容,从而触发 onload 事件,继续进行文件的处理
                    fileReader.readAsArrayBuffer(blobSlice.call(file.file, start, end));
                }
            },
            // 上传状态文本
            fileStatusText(status, response) {
                if (status === "md5") {
                    return "校验MD5";
                } else {
                    return this.fileStatusTextObj[status];
                }
            },

            // 点击暂停
            pause(file, id) {
                console.log("file :>> ", file);
                console.log("id :>> ", id);
                if (file.paused) {
                    file.resume();
                    this.icon = 'el-icon-video-pause'
                } else {
                    this.icon = 'el-icon-video-play'
                    file.pause();
                }
            },
            // 点击删除

            remove(file) {
                this.uploadFileList.findIndex((item, index) => {
                    if (item.id === file.id) {
                        this.$nextTick(() => {
                            this.uploadFileList.splice(index, 1);
                        });
                    }
                });
            },
            showDetail(percentage, props) {
                let fileName = props.file.name;
                let isComplete = props.isComplete
                let formatUpload = this.formatFileSize(props.uploadedSize, 2);
                let fileSize = `${props.formatedSize}`;
                let timeRemaining = !isComplete ? ` 剩余时间:${props.formatedTimeRemaining}` : ''
                let uploaded = !isComplete ? ` 已上传:${formatUpload} / ${fileSize}` : ` 大小:${fileSize}`
                let speed = !isComplete ? ` 速度:${props.formatedAverageSpeed}` : ''
                if (props.error) {
                    return `${fileName} \t 上传失败`
                }
                return `${fileName} \t ${speed}  \t ${uploaded}  \t ${timeRemaining} \t  进度:${percentage} %`;
            },
            // 显示进度
            percentage(props) {
                let progress = props.progress.toFixed(2) * 100;
                return progress - 1 < 0 ? 0 : progress;
            },
            // 控制下进度条的颜色 ,异常的情况下显示红色
            progressColor(e, props) {
                if (props.error) {
                    return `#f56c6c`
                }
                if (e > 0) {
                    return `#1989fa`
                }
            },
            // 点击下载
            download(file, id) {
                console.log("file:>> ", file);
                window.location.href = `/fileStorage/download/${file.uniqueIdentifier}`;
            },
            formatFileSize(bytes, decimalPoint = 2) {
                if (bytes == 0) return "0 Bytes";
                let k = 1000,
                    sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"],
                    i = Math.floor(Math.log(bytes) / Math.log(k));
                return (
                    parseFloat((bytes / Math.pow(k, i)).toFixed(decimalPoint)) + " " + sizes[i]
                );
            }

        },
    })
</script>
</body>
</html>

上传页面对应的样式 


.uploader-example {
    width: 880px;
    padding: 15px;
    margin: 40px auto 0;
    font-size: 12px;
    box-shadow: 0 0 10px rgba(0, 0, 0, 0.4);
}
.uploader-example .uploader-btn {
    margin-right: 4px;
}
.uploader-example .uploader-list {
    max-height: 440px;
    overflow: auto;
    overflow-x: hidden;
    overflow-y: auto;
}

#global-uploader {
    position: fixed;
    z-index: 20;
    right: 15px;
    bottom: 15px;
    width: 550px;
}

.uploader-file {
    height: 90px;
}

.uploader-file-meta {
    display: none !important;
}

.operate {
    flex: 1;
    text-align: right;
}

.file-list {
    position: relative;
    height: 300px;
    overflow-x: hidden;
    overflow-y: auto;
    background-color: #fff;
    padding: 0px;
    margin: 0 auto;
    transition: all 0.5s;
}

.uploader-file-size {
    width: 15% !important;
}

.uploader-file-status {
    width: 32.5% !important;
    text-align: center !important;
}

li {
    background-color: #fff;
    list-style-type: none;
}

.no-file {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    font-size: 16px;
}

.uploader-file-name {
    width: 36% !important;
}

.uploader-file-actions {
    float: right !important;
}


/deep/ .el-progress-bar {
    width: 95%;
}

测试

访问localhost:7125/page/index.html

 选择上传文件

上传成功

 上传文件目录

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

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

相关文章

GO 中的指针?

本文也主要聊聊在 GO 中的指针和内存&#xff0c;希望对你有点帮助 如果你学习过 C 语言&#xff0c;你就非常清楚指针的高效和重要性 使用 GO 语言也是一样&#xff0c;项目代码中&#xff0c;不知道你是否会看到函数参数中会传递各种 map&#xff0c;slice &#xff0c;自定…

使用正则表达式批量修改函数

贪心匹配&#xff0c;替换中的$1代表括号中的第一组。 使用[\s\S\r]代表所有字符&#xff0c;同时加个问号代表不贪心匹配:

【RP-RV1126】烧录固件使用记录

文章目录 烧录完整固件进入MASKROM模式固件烧录升级中&#xff1a;升级完成&#xff1a; 烧录部分进入Loader模式选择文件切换loader模式 烧录完整固件 完整固件就是update.img包含了所有的部件&#xff0c;烧录后可以直接运行。 全局编译&#xff1a;./build.sh all生成固件…

Java数据结构————优先级队列(堆)

一 、 优先级队列 有些情况下&#xff0c;操作的数据可能带有优先级&#xff0c; 一般出队列时&#xff0c;可能需要优先级高的元素先出队列。 数据结构应该提供两个最基本的操作&#xff0c; 一个是返回最高优先级对象&#xff0c; 一个是添加新的对象。 这种数据结构就是优…

(一)正点原子STM32MP135移植——准备

一、简述 使用板卡&#xff1a;正点原子的ATK-DLMP135 V1.2 从i.mx6ull学习完过来&#xff0c;想继续学习一下移植uboot和内核的&#xff0c;但是原子官方没有MP135的移植教程&#xff0c;STM32MP157的移植教程用的又是老版本的代码&#xff0c;ST官方更新后的代码不兼容老版本…

微信小程序button按钮去除边框去除背景色

button边框 去除button边框 在button上添加plain“true”在css中添加button.avatar-wrapper {background: none}用于去除button背景色在css中添加button.avatar-wrapper[plain]{ border:0 }用于去除button边框

数组结构与算法

文章目录 数据结构与算法稀疏数组sparse队列单向链表双向链表单向环形列表&#xff1a;CircleSingleLinkedList栈递归排序算法快速排序思路 树赫夫曼树 &#xff08;HuffmanTree&#xff09;二叉排序树&#xff08;Binary sort tree&#xff09;构建二叉树遍历二叉树 平衡二叉树…

Doris数据库BE——冷热数据方案

新的冷热数据方案是在整合了存算分离模型的基础上建立的&#xff0c;其核心思路是&#xff1a;DORIS本地存储作为热数据的载体&#xff0c;而外部集群&#xff08;HDFS、S3等&#xff09;作为冷数据的载体。数据在导入的过程中&#xff0c;先作为热数据存在&#xff0c;存储于B…

[架构之路-228]:计算机硬件与体系结构 - 硬盘存储结构原理:如何表征0和1,即如何存储0和1,如何读数据,如何写数据(修改数据)

目录 前言&#xff1a; 一、磁盘的盘面组成 1.1 磁盘是什么 ​编辑1.2 磁盘存储介质 1.3 磁盘数据的组织 1.3.1 分层组织&#xff1a;盘面号 1.3.2 扇区和磁道 1.3.3 数据 1.3.4 磁盘数据0和1的存储方式 1.3.5 磁盘数据0和1的修正方法 1.3.6 磁盘数据0和1的读 二、…

一键AI高清换脸——基于InsightFace、CodeFormer实现高清换脸与验证换脸后效果能否通过人脸比对、人脸识别算法

前言 AI换脸是指利用基于深度学习和计算机视觉来替换或合成图像或视频中的人脸。可以将一个人的脸替换为另一个人的脸,或者将一个人的表情合成到另一个人的照片或视频中。算法常常被用在娱乐目上,例如在社交媒体上创建有趣的照片或视频,也有用于电影制作、特效制作、人脸编…

华为云云耀云服务器L实例评测|Ubuntu云锁防火墙安装搭建使用

华为云云耀云服务器L实例评测&#xff5c;Ubuntu安装云锁防火墙对抗服务器入侵和网络攻击 1.前言概述 华为云耀云服务器L实例是新一代开箱即用、面向中小企业和开发者打造的全新轻量应用云服务器。多种产品规格&#xff0c;满足您对成本、性能及技术创新的诉求。云耀云服务器L…

【VUE·疑难问题】定义 table 中每行的高度(使用 element-UI)

一、如何定义 table 中每一行的 height &#xff1f; 1.table例子 <!-- 二、table --><div style"overflow: hidden;display: block;height: 68vh;width: 100%;"><el-table stripe show-header style"width: 100%" :data"tableData&q…

nodejs+vue养老人员活体鉴权服务系统elementui

系统 统计数据&#xff1a;统计报表、人员台账、机构数据、上报数据、核验报表等&#xff0c;养老人员活体鉴权服务是目前国家养老人员管理的重要环节&#xff0c;主要为以养老机构中养老人员信息为基础&#xff0c;每月进行活体鉴权识别并统计数据为养老补助等管理。前端功能&…

开箱报告,Simulink Toolbox库模块使用指南(七)——S-Fuction Builter模块

S-Fuction Builter S-Fuction Builter模块&#xff0c;Mathworks官方Help对该部分内容的说明如下所示。 DFT算法的原理讲解和模块开发在前几篇文章中已经完成了&#xff0c;本文介绍如何使用S-Fuction Builter模块一步到位地自动开发DFT算法模块&#xff0c;包括建立C MEX S-Fu…

水浒传数据集汇总

很喜欢《水浒传》&#xff0c;希望能将它融入我的考研复习中&#xff0c;打算用水浒传数据来贯穿数据结构的各种知识&#xff0c;先汇总下找到的数据集 天池上看到的一个水浒传文本数据集&#xff1a;https://tianchi.aliyun.com/dataset/36027 Hareric/masterworkNLP: 基于社…

CUDA C编程权威指南:1.1-CUDA基础知识点梳理

主要整理了N多年前&#xff08;2013年&#xff09;学习CUDA的时候开始总结的知识点&#xff0c;好长时间不写CUDA代码了&#xff0c;现在LLM推理需要重新学习CUDA编程&#xff0c;看来出来混迟早要还的。 1.CUDA 解析&#xff1a;2007年&#xff0c;NVIDIA推出CUDA&#xff08…

软件或游戏提示msvcp120.dll丢失的5种常用解决方法,msvcp120.dll文件全面解析

在当今数字化的时代&#xff0c;我们的生活已经离不开各种软件和游戏。然而&#xff0c;有时候我们可能会遇到一些技术问题&#xff0c;比如“软件或游戏提示msvcp120.dll丢失”。这个问题对于许多人来说可能很棘手&#xff0c;但是只要掌握了正确的解决方法&#xff0c;就能轻…

软件工程第四周

模型建立的基本理念 模型是对现实世界复杂系统的简化和抽象&#xff0c;目的是为了更好地理解、分析和预测系统的行为。它能够真实反映研究对象的整体结构 or 某一侧面&#xff08;功能、反应&#xff09;的本质特征和变化规律。可以建立不同的子模型用于反应系统不同的侧面。同…

《机器人SLAM导航核心技术与实战》第1季:第6章_机器人底盘

视频讲解 【第1季】6.第6章_机器人底盘-视频讲解 【第1季】6.1.第6章_机器人底盘_底盘运动学模型-视频讲解 【第1季】6.2.第6章_机器人底盘_底盘性能指标-视频讲解 【第1季】6.3.第6章_机器人底盘_典型机器人底盘搭建-视频讲解 第1季&#xff1a;第6章_机器人底盘 先 导 课…

SpringBoot二手车管理系统

本系统采用基于JAVA语言实现、架构模式选择B/S架构&#xff0c;Tomcat7.0及以上作为运行服务器支持&#xff0c;基于JAVA、springboot等主要技术和框架设计&#xff0c;idea作为开发环境&#xff0c;数据库采用MYSQL5.7以上. 采用技术: SpringBootMySQL