VUE+SPRINGBOOT实现断点续传,分片上传大文件

前端代码:

<!--上传附件弹出框 -->
    <el-dialog v-dialogDrag title="文件上传" center v-model="uploadVisible" width="60%" @close="handlerClose" destroy-on-close>
      <UploadBigFile class="uploadSlot" @closeFileDialog="closeFileDialog"></UploadBigFile>
    </el-dialog>

弹出框代码:

<template>
    <!-- 上传器 -->
    <uploader
        ref="uploader"
        :options="options"
        :autoStart=false
        :file-status-text="fileStatusText"
        @file-added="onFileAdded"
        @file-success="onFileSuccess"
        @file-progress="onFileProgress"
        @file-error="onFileError">
        <uploader-unsupport></uploader-unsupport>
        <uploader-drop>
            <div>
                <uploader-btn id="global-uploader-btn" :attrs="attrs" ref="uploadBtn">选择文件<i class="el-icon-upload el-icon--right"></i></uploader-btn>
            </div>
        </uploader-drop>
        <uploader-list></uploader-list>
    </uploader>
</template>

<script>
    import {ACCEPT_CONFIG} from '../../../assets/js/config';
    import SparkMD5 from 'spark-md5';
    import {mergeFile} from "@/api/tool/uploadFile";
    import { getToken } from "@/utils/auth";

    export default {
        data () {
            return {
                options: {
                    //目标上传 URL,默认POST
                    target: process.env.VITE_APP_BASE_API +"/vm/chunk",
                    //分块大小(单位:字节) 单个分片暂定200M
                    chunkSize: '204800000',
                    //上传文件时文件内容的参数名,对应chunk里的Multipart对象名,默认对象名为file
                    fileParameterName: 'upfile',
                    //失败后最多自动重试上传次数
                    maxChunkRetries: 3,
                    //是否开启服务器分片校验,对应GET类型同名的target URL
                    testChunks: true,
                    headers: { Authorization: "Bearer " + getToken() },
                    /*
                    服务器分片校验函数,判断秒传及断点续传,传入的参数是Uploader.Chunk实例以及请求响应信息
                    reponse码是successStatuses码时,才会进入该方法
                    reponse码如果返回的是permanentErrors 中的状态码,不会进入该方法,直接进入onFileError函数 ,并显示上传失败
                    reponse码是其他状态码,不会进入该方法,正常走标准上传
                    checkChunkUploadedByResponse函数直接return true的话,不再调用上传接口
                    */
                    checkChunkUploadedByResponse: function (chunk, response_msg) {
                        // console.log("response_msg的值",response_msg)
                        let objMessage = JSON.parse(response_msg);
                        if (objMessage.skipUpload) {
                            return true;
                        }
                        return (objMessage.uploadedChunks || []).indexOf(chunk.offset + 1) >= 0;
                    }
                },
                attrs: {
                    //上传文件的类型
                    accept: ACCEPT_CONFIG.getAll()
                },
                fileStatusText: {
                        success: '上传成功',
                        error: '上传失败',
                        uploading: '上传中',
                        paused: '暂停',
                        waiting: '等待上传'
                },
            }
        },
        methods: {
            onFileAdded(file) {
                this.computeMD5(file);
            },
            /*
            第一个参数 rootFile 就是成功上传的文件所属的根 Uploader.File 对象,它应该包含或者等于成功上传文件;
            第二个参数 file 就是当前成功的 Uploader.File 对象本身;
            第三个参数就是 message 就是服务端响应内容,永远都是字符串;
            第四个参数 chunk 就是 Uploader.Chunk 实例,它就是该文件的最后一个块实例,如果你想得到请求响应码的话,chunk.xhr.status就是
            */
            onFileSuccess(rootFile, file, response, chunk) {
                //refProjectId为预留字段,可关联附件所属目标,例如所属档案,所属工程等
                file.refProjectId = "ommb";
                this.$emit('closeFileDialog');

                mergeFile(file).then( responseData=> {
                    if(responseData === "Failure"){
                        console.log("合并操作未成功");
                    }
                }).catch(function (error){
                    console.log("合并后捕获的未知异常:"+error);
                });
            },
            onFileProgress(rootFile, file, chunk) {
                console.log(`上传中 ${file.name},chunk:${chunk.startByte / 1024 / 1024} ~ ${chunk.endByte / 1024 / 1024}`)
            },
            onFileError(rootFile, file, response, chunk) {
                console.log('上传完成后异常信息:'+response);
                this.$message({
                    message: response,
                    type: 'error'
                })
            },

            /**
             * 计算md5,实现断点续传及秒传
             * @param file
             */
            computeMD5(file) {
                file.pause();

                //单个文件的大小限制2G
                let fileSizeLimit = 2 * 1024 * 1024 * 1024;
                // console.log("文件大小:"+file.size);
                // console.log("限制大小:"+fileSizeLimit);
                if(file.size > fileSizeLimit){
                    this.$message({
                        showClose: true,
                        message: '文件大小不能超过2G'
                    });
                    file.cancel();
                }

                let fileReader = new FileReader();
                let time = new Date().getTime();
                let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
                let currentChunk = 0;
                const chunkSize = 10 * 1024 * 1000;
                let chunks = Math.ceil(file.size / chunkSize);
                let spark = new SparkMD5.ArrayBuffer();
                //由于计算整个文件的Md5太慢,因此采用只计算第1块文件的md5的方式
                let chunkNumberMD5 = 1;

                loadNext();

                fileReader.onload = (e => {
                    spark.append(e.target.result);

                    if (currentChunk < chunkNumberMD5) {
                        currentChunk++;
                        loadNext();

                        // 实时展示MD5的计算进度
                        this.$nextTick(() => {
                            $(`.myStatus_${file.id}`).text('校验MD5 ' + ((currentChunk / chunks) * 100).toFixed(0) + '%')
                        })
                    } else {
                        let md5 = spark.end();
                        file.uniqueIdentifier = md5;
                        file.resume();
                        // console.log(`MD5计算完毕:${file.name} \nMD5:${md5} \n分片:${chunks} 大小:${file.size} 用时:${new Date().getTime() - time} ms`);
                    }
                });

                fileReader.onerror = function () {
                    this.error(`文件${file.name}读取出错,请检查该文件`)
                    file.cancel();
                };

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

                    fileReader.readAsArrayBuffer(blobSlice.call(file.file, start, end));
                    currentChunk++;
                    // console.log("计算第"+currentChunk+"块");
                }
            },
            close() {
                this.uploader.cancel();
            },

            /**
             * 新增的自定义的状态: 'md5'、'transcoding'、'failed'
             * @param id
             * @param status
             */
            statusSet(id, status) {
                let statusMap = {
                    md5: {
                        text: '校验MD5',
                        bgc: '#fff'
                    },
                    merging: {
                        text: '合并中',
                        bgc: '#e2eeff'
                    },
                    transcoding: {
                        text: '转码中',
                        bgc: '#e2eeff'
                    },
                    failed: {
                        text: '上传失败',
                        bgc: '#e2eeff'
                    }
                }

                this.$nextTick(() => {
                    $(`<p class="myStatus_${id}"></p>`).appendTo(`.file_${id} .uploader-file-status`).css({
                        'position': 'absolute',
                        'top': '0',
                        'left': '0',
                        'right': '0',
                        'bottom': '0',
                        'zIndex': '1',
                        'line-height': 'initial',
                        'backgroundColor': statusMap[status].bgc
                    }).text(statusMap[status].text);
                })
            },
            statusRemove(id) {
                this.$nextTick(() => {
                    $(`.myStatus_${id}`).remove();
                })
            },

            error(msg) {
                this.$notify({
                    title: '错误',
                    message: msg,
                    type: 'error',
                    duration: 2000
                })
            }
        }
    }
</script>

<style scoped>
  .handle-box {
    margin-bottom: 20px;
  }

  .handle-select {
    width: 120px;
  }

  .handle-input {
    width: 300px;
    display: inline-block;
  }

  .table {
    width: 1200px;
    font-size: 14px;
  }

  .red {
    color: #ff0000;
  }

  .mr10 {
    margin-right: 10px;
  }

  .table-td-thumb {
    display: block;
    margin: auto;
    width: 40px;
    height: 40px;
  }

  .uploadSlot {
    margin: -10px 10px 10px 30px;
  }
</style>

后台代码:

@Service
public class SysChunkServiceImpl implements ISysChunkService {
    private static final Logger logger = LoggerFactory.getLogger(SysChunkServiceImpl.class);

    /**
     * 上传文件存储在本地的根路径
     */
    @Value("${file.path}")
    private String localFilePath;

    @Autowired
    private SysChunkMapper sysChunkMapper;
    @Autowired
    private VersionPackageMapper versionPackageMapper;

    /**
     * 查询版本管理
     *
     * @param id 版本管理主键
     * @return 版本管理
     */
    @Override
    public SysChunk selectSysChunkById(String id) {
        return sysChunkMapper.selectSysChunkById(id);
    }

    /**
     * 查询版本管理列表
     *
     * @param sysChunk 版本管理
     * @return 版本管理
     */
    @Override
    public List<SysChunk> selectSysChunkList(SysChunk sysChunk) {
        return sysChunkMapper.selectSysChunkList(sysChunk);
    }

    /**
     * 新增版本管理
     *
     * @param sysChunk 版本管理
     * @return 结果
     */
    @Override
    public int insertSysChunk(SysChunk sysChunk) {
        sysChunk.setId(SnowflakeIdWorker.getUUID());
        return sysChunkMapper.insertSysChunk(sysChunk);
    }

    /**
     * 修改版本管理
     *
     * @param sysChunk 版本管理
     * @return 结果
     */
    @Override
    public int updateSysChunk(SysChunk sysChunk) {
        return sysChunkMapper.updateSysChunk(sysChunk);
    }

    /**
     * 批量删除版本管理
     *
     * @param ids 需要删除的版本管理主键
     * @return 结果
     */
    @Override
    public int deleteSysChunkByIds(String[] ids) {
        return sysChunkMapper.deleteSysChunkByIds(ids);
    }

    /**
     * 删除分片
     *
     * @param id 版本管理主键
     * @return 结果
     */
    @Override
    public int deleteSysChunkById(String id) {
        return sysChunkMapper.deleteSysChunkById(id);
    }

    @Override
    public int deleteSysChunkByIdentifier(String identifier) {
        return sysChunkMapper.deleteSysChunkByIdentifier(identifier);
    }

    /**
     * 删除版本管理信息
     *
     * @param versionPackage
     * @return
     */
    @Transactional(rollbackFor = Exception.class)
    @Override
    public int deleteFile(VersionPackage versionPackage) {
        //删除版本
        versionPackageMapper.deleteVersionPackageByVersionId(versionPackage.getVersionId());
        //删除版本分片
        sysChunkMapper.deleteSysChunkByIdentifier(versionPackage.getIdentifier());
        //删除版本存放的目录
        FileUtils.delFile(versionPackage.getLocation());

        return 1;
    }

    @Override
    public String uploadChunk(SysChunk chunk) {
        String apiRlt = VmConstants.SUCCESS_CODE;

        MultipartFile file = chunk.getUpfile();

        logger.info("file originName: {}, chunkNumber: {}", file.getOriginalFilename(), chunk.getChunkNumber());

        try {
            byte[] bytes = file.getBytes();
            Path path = Paths.get(generatePath(localFilePath, chunk));
            //文件写入指定路径
            Files.write(path, bytes);
            if (insertSysChunk(chunk) < 0) {
                apiRlt = VmConstants.FAIL_CODE;
            }

        } catch (IOException e) {
            logger.error("uploadChunk IOException" , e);
            apiRlt = VmConstants.FAIL_CODE;
        }
        return apiRlt;
    }

    @Override
    public UploadResult checkChunk(SysChunk chunk) {
        UploadResult ur = new UploadResult();
        //完整文件路径
        String file = localFilePath + "/" + chunk.getIdentifier() + "/" + chunk.getFilename();

        //先判断整个文件是否已经上传过了,如果是,则告诉前端跳过上传,实现秒传
        if (fileExists(file)) {
            ur.setSkipUpload(true);
            ur.setLocation(file);
            ur.setMessage("完整文件已存在,直接跳过上传,实现秒传");
            return ur;
        }

        //如果完整文件不存在,则去数据库判断当前哪些文件块已经上传过了,把结果告诉前端,跳过这些文件块的上传,实现断点续传
        ArrayList<Integer> list = sysChunkMapper.selectChunkNumbers(chunk);
        if (CollectionUtils.isNotEmpty(list)) {
            ur.setSkipUpload(false);
            ur.setUploadedChunks(list);
            ur.setMessage("部分文件块已存在,继续上传剩余文件块,实现断点续传");
            return ur;
        }
        return ur;
    }

    /**
     * 生成上传后的文件路径
     *
     * @param uploadFolder 本地路径
     * @param chunk        分片信息
     * @return
     */
    public String generatePath(String uploadFolder, SysChunk chunk) {
        StringBuilder sb = new StringBuilder();
        sb.append(uploadFolder).append("/").append(chunk.getIdentifier());

        //把字符串拼接为Path
        Path path = Paths.get(sb.toString());
        //判断uploadFolder/identifier 路径是否存在,不存在则创建
        if (!Files.isWritable(path)) {
            logger.info("path not exist,create path: {}", sb.toString());
            try {
                Files.createDirectories(path);
            } catch (IOException e) {
                logger.error(e.getMessage(), e);
            }
        }

        return sb.append("/")
                .append(chunk.getFilename())
                .append("-")
                .append(chunk.getChunkNumber()).toString();
    }

    /**
     * 根据文件的全路径名判断文件是否存在
     *
     * @param file
     * @return
     */
    public boolean fileExists(String file) {
        Path path = Paths.get(file);
        boolean fileExists = Files.exists(path, new LinkOption[]{LinkOption.NOFOLLOW_LINKS});
        return fileExists;
    }

    @Override
    public String mergeFile(SysFileVO fileInfoVO) {
        String rlt = VmConstants.FAIL;

        //前端组件参数转换为model对象
        VersionPackage fileInfo = buildVersionPackage(fileInfoVO);

        LoginUser user = SecurityUtils.getLoginUser();
        Date now = DateUtils.getNowDate();
        fileInfo.setCreateBy(user.getUsername());
        fileInfo.setCreateTime(now);
        fileInfo.setUpdateBy(user.getUsername());
        fileInfo.setUpdateTime(now);

        String filename = fileInfoVO.getName();
        //进行文件的合并操作
        String file = localFilePath + "/" + fileInfo.getIdentifier() + "/" + filename;
        String folder = localFilePath + "/" + fileInfo.getIdentifier();
        String fileSuccess = merge(file, folder, filename);

        fileInfo.setLocation(file);

        //文件合并成功后,保存记录至数据库
        if (VmConstants.SUCCESS_CODE.equals(fileSuccess)) {
            if (versionPackageMapper.insertVersionPackage(fileInfo) > 0) {
                rlt = VmConstants.SUCCESS;
            }
        }

        //如果已经存在,则判断是否同一个项目,同一个项目的不用新增记录,否则新增
        if (VmConstants.EXIST_CODE.equals(fileSuccess)) {
            List<VersionPackage> tfList = versionPackageMapper.selectVersionPackageList(fileInfo);
            if (tfList != null) {
                if (tfList.size() == 0 || (tfList.size() > 0 && !fileInfo.getSuitScope().equals(tfList.get(0).getSuitScope()))) {
                    if (versionPackageMapper.insertVersionPackage(fileInfo) > 0) {
                        rlt = VmConstants.SUCCESS;
                    }
                }
            }
        }
        return rlt;
    }

    /**
     * 根据上传的文件构造版本包对象
     * @param fileInfoVO
     * @return
     */
    private VersionPackage buildVersionPackage(SysFileVO fileInfoVO) {
        String filename = fileInfoVO.getName();
        String id = SnowflakeIdWorker.getUUID();

        VersionPackage fileInfo = new VersionPackage();
        fileInfo.setVersionId(id);
        fileInfo.setGenerateDate(new Date());
        fileInfo.setVersionPackage(filename);
        fileInfo.setVersionType("1");
        fileInfo.setVersionRadio("1");
        //第一个下划线到最后一个点
        int startIndex = filename.indexOf("_");
        int endIndex = filename.lastIndexOf(".");
        String versionNo = filename.substring(startIndex+1 , endIndex);
        fileInfo.setVersionNo(versionNo);
        fileInfo.setIdentifier(fileInfoVO.getUniqueIdentifier());
        fileInfo.setTotalSize(fileInfoVO.getSize());
        fileInfo.setSuitScope(fileInfoVO.getRefProjectId());
        fileInfo.setStatus("0");
        return fileInfo;
    }

    /**
     * 文件合并
     *
     * @param file
     * @param folder
     * @param filename
     * @return
     */
    public String merge(String file, String folder, String filename) {
        //默认合并成功
        String rlt = VmConstants.SUCCESS_CODE;

        try {
            //先判断文件是否存在
            if (fileExists(file)) {
                //文件已存在
                return VmConstants.EXIST_CODE;
            }

            //不存在的话,进行合并
            Files.createFile(Paths.get(file));

            Files.list(Paths.get(folder))
                    .filter(path -> !path.getFileName().toString().equals(filename))
                    .sorted((o1, o2) -> {
                        String p1 = o1.getFileName().toString();
                        String p2 = o2.getFileName().toString();
                        int i1 = p1.lastIndexOf("-");
                        int i2 = p2.lastIndexOf("-");
                        return Integer.valueOf(p2.substring(i2)).compareTo(Integer.valueOf(p1.substring(i1)));
                    })
                    .forEach(path -> {
                        try {
                            //以追加的形式写入文件
                            Files.write(Paths.get(file), Files.readAllBytes(path), StandardOpenOption.APPEND);
                            //合并后删除该块
                            Files.delete(path);
                        } catch (IOException e) {
                            logger.error(e.getMessage(), e);
                        }
                    });
        } catch (IOException e) {
            logger.error(e.getMessage(), e);
            //合并失败
            rlt = VmConstants.FAIL_CODE;
        }

        return rlt;
    }

}

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值