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

大文件上传流程

  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判断是否已经上传过,是就秒传,不是就分片上传

java 复制代码
import lombok.Data;

import java.util.List;

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

    private String url;

    private List<Integer> uploadedChunks;
}

接收前端发送过来的分片

java 复制代码
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;
}

数据库分片的实体类

java 复制代码
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;
}

数据库的文件的实体类

java 复制代码
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

java 复制代码
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

java 复制代码
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接口

java 复制代码
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接口

java 复制代码
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

java 复制代码
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

java 复制代码
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工具类

文件相关的工具类

java 复制代码
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的工具类

java 复制代码
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);
    }


}

响应体工具类

java 复制代码
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层

返回对应的页面

java 复制代码
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;
    }
}

文件上传接口

java 复制代码
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 |

页面部分

插件引入

html 复制代码
<!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>

上传文件主页

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>

上传页面对应的样式

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

选择上传文件

上传成功

上传文件目录

相关推荐
Chrikk1 小时前
Go-性能调优实战案例
开发语言·后端·golang
幼儿园老大*1 小时前
Go的环境搭建以及GoLand安装教程
开发语言·经验分享·后端·golang·go
canyuemanyue1 小时前
go语言连续监控事件并回调处理
开发语言·后端·golang
杜杜的man1 小时前
【go从零单排】go语言中的指针
开发语言·后端·golang
测开小菜鸟1 小时前
使用python向钉钉群聊发送消息
java·python·钉钉
P.H. Infinity2 小时前
【RabbitMQ】04-发送者可靠性
java·rabbitmq·java-rabbitmq
生命几十年3万天2 小时前
java的threadlocal为何内存泄漏
java
caridle3 小时前
教程:使用 InterBase Express 访问数据库(五):TIBTransaction
java·数据库·express
^velpro^3 小时前
数据库连接池的创建
java·开发语言·数据库
苹果醋33 小时前
Java8->Java19的初步探索
java·运维·spring boot·mysql·nginx