文件断点续传+分片上传

什么是断点续传


用户上传大文件,网络差点的需要历时数小时,万一线路中断,不具备断点续传的服务器就只能从头重传,而断点续传就是,允许用户从上传断线的地方继续传送,这样大大减少了用户的烦恼。

1.解决上传大文件服务器内存不够的问题
2.解决如果因为其他因素导致上传终止的问题,并且刷新浏览器后仍然能够续传,重启浏览器(关闭浏览器后再打开)仍然能够继续上传,重启电脑后仍然能够上传
3.检测上传过程中因网络波动导致文件出现了内容丢失那么需要自动检测并且从新上传
解决方案
前端

1.需要进行分割上传的文件
2.需要对上传的分片文件进行指定文件序号
3.需要监控上传进度,控制进度条
4.上传完毕后需要发送合并请求
Blob 对象,操作文件

后端

1.上传分片的接口
2.合并分片的接口
3.获取分片的接口
4.其他工具方法,用于辅助
5.前端端需要注意的就是: 文件的切割,和进度条
6.后端需要注意的就是: 分片存储的地方和如何进行合并分片

效果演示

上传成功

后端代码展示

 /**
     * 检查文件是否已上传
     */
    @GetMapping("/check")
    public AjaxResult checkFile(@RequestParam String hash) {
        String uploadPath = RuoYiConfig.getUploadPath();
        String chunkFolder = uploadPath + File.separator + CHUNK_FOLDER + File.separator + hash;
        File chunkDir = new File(chunkFolder);

        if (!chunkDir.exists()) {
            return AjaxResult.success().put("isExists", false).put("uploadedChunks", new Integer[0]);
        }

        // 获取已上传的分片
        Set<Integer> uploadedChunks = new HashSet<>();
        File[] chunks = chunkDir.listFiles();
        if (chunks != null) {
            for (File chunk : chunks) {
                try {
                    int index = Integer.parseInt(chunk.getName());
                    uploadedChunks.add(index);
                } catch (NumberFormatException e) {
                    // 忽略非分片文件
                }
            }
        }

        return AjaxResult.success()
                .put("isExists", false)
                .put("uploadedChunks", uploadedChunks.toArray());
    }

    /**
     * 上传分片
     */
    @PostMapping("/upload")
    public AjaxResult uploadChunk(
            @RequestParam("chunk") MultipartFile chunk,
            @RequestParam("hash") String hash,
            @RequestParam("chunkIndex") Integer chunkIndex,
            @RequestParam("totalChunks") Integer totalChunks,
            @RequestParam("filename") String filename) {
        try {
            String uploadPath = RuoYiConfig.getUploadPath();
            String chunkFolder = uploadPath + File.separator + CHUNK_FOLDER + File.separator + hash;

            // 创建分片存储目录
            File chunkDir = new File(chunkFolder);
            if (!chunkDir.exists()) {
                chunkDir.mkdirs();
            }

            // 保存分片
            String chunkPath = chunkFolder + File.separator + chunkIndex;
            File chunkFile = new File(chunkPath);
            chunk.transferTo(chunkFile.getAbsoluteFile());

            return AjaxResult.success();
        } catch (IOException e) {
            return AjaxResult.error("分片上传失败:" + e.getMessage());
        }
    }

    /**
     * 合并分片
     */
    @PostMapping("/merge")
    public AjaxResult mergeChunks(@RequestParam("hash") String hash, @RequestParam("filename") String filename) {
        try {
            String uploadPath = RuoYiConfig.getUploadPath();
            String chunkFolder = uploadPath + File.separator + CHUNK_FOLDER + File.separator + hash;
            File chunkDir = new File(chunkFolder);

            if (!chunkDir.exists()) {
                return AjaxResult.error("分片目录不存在");
            }

            // 获取所有分片并排序
            File[] chunks = chunkDir.listFiles();
            if (chunks == null || chunks.length == 0) {
                return AjaxResult.error("没有找到分片文件");
            }

            Arrays.sort(chunks, (a, b) -> {
                int indexA = Integer.parseInt(a.getName());
                int indexB = Integer.parseInt(b.getName());
                return indexA - indexB;
            });

            // 生成最终文件路径
            String finalFileName = FileUploadUtils.extractCheckFilename(filename);
            String finalFilePath = uploadPath + File.separator + finalFileName;
            File finalFile = new File(finalFilePath);

            // 确保目标目录存在
            File parentDir = finalFile.getParentFile();
            if (!parentDir.exists()) {
                parentDir.mkdirs();
            }
            // 合并分片
            try (RandomAccessFile randomAccessFile = new RandomAccessFile(finalFile, "rw")) {
                for (File chunk : chunks) {
                    byte[] chunkData = Files.readAllBytes(chunk.toPath());
                    randomAccessFile.write(chunkData);
                }
            }

            // 删除分片目录
            FileUtils.deleteDirectory(chunkDir);
            String url = finalFilePath.replace("\\", File.separator).replace("/",File.separator);
            // 返回文件信息
            return AjaxResult.success()
                    .put("url",url)
                    .put("fileName", finalFileName)
                    .put("newFileName", FileUtils.getName(finalFileName))
                    .put("originalFilename", filename);
        } catch (Exception e) {
            return AjaxResult.error("合并分片失败:" + e.getMessage());
        }
    }

前端代码展示,为了方便使用,我已经将该功能封装成组件,大家可以拿走直接用

<template>
  <div class="chunk-upload">
    <el-upload
      ref="uploadRef"
      :auto-upload="false"
      :show-file-list="true"
      :file-list="fileList"
      :limit="1"
      :on-change="handleFileChange"
      :before-upload="beforeUpload"
      accept="video/*"
    >
      <el-button type="primary">选择文件</el-button>
      <template #tip>
        <div class="el-upload__tip">
          支持断点续传,单个文件大小不超过 {{ maxFileSize }}GB
        </div>
      </template>
    </el-upload>

    <!-- 上传进度显示 -->
    <div v-if="uploadProgress > 0" class="progress-container">
      <el-progress
        :percentage="uploadProgress"
        :format="progressFormat"
      />
      <div class="upload-info">
        <span>已上传: {{ formatSize(uploadedSize) }} / {{ formatSize(totalSize) }}</span>
        <div class="upload-actions">
          <el-button
            v-if="isPaused"
            @click="resumeUpload"
            type="primary"
            size="small"
          >继续上传</el-button>
          <el-button
            v-else
            @click="pauseUpload"
            type="warning"
            size="small"
          >暂停上传</el-button>
          <el-button
            @click="cancelUpload"
            type="danger"
            size="small"
          >取消上传</el-button>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import SparkMD5 from 'spark-md5';
import { check, upload, merge } from '@/api/common/chunk';
import {getToken} from "@/utils/auth";

const props = defineProps({
  maxFileSize: {
    type: Number,
    default: 10 // 默认最大10GB
  },
  modelValue: {
    type: String,
    default: ''
  }
});

const emit = defineEmits(['upload-success', 'upload-error']);

// 配置参数
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB 分片大小

// 状态量
const uploadProgress = ref(0);
const currentChunk = ref(0);
const totalChunks = ref(0);
const uploadedSize = ref(0);
const totalSize = ref(0);
const isPaused = ref(false);
const uploadingFile = ref(null);
const fileHash = ref('');
const uploadRef = ref(null);
const fileList = ref([]);

// 监听 modelValue 变化,用于回显
watch(() => props.modelValue, (newVal) => {
  if (newVal) {
    const fileName = newVal.substring(newVal.lastIndexOf('/') + 1);
    fileList.value = [{ name: fileName, url: newVal }];
  } else {
    fileList.value = [];
  }
}, { immediate: true });

// 计算文件 hash
const calculateHash = async (file) => {
  return new Promise((resolve) => {
    const spark = new SparkMD5.ArrayBuffer();
    const reader = new FileReader();
    const size = file.size;
    const offset = 2 * 1024 * 1024; // 取前2M计算hash

    reader.onload = (e) => {
      spark.append(e.target.result);
      resolve(spark.end());
    };

    reader.readAsArrayBuffer(file.slice(0, offset));
  });
};

// 检查文件是否已上传
const checkFileExists = async (hash) => {
  try {
    const response = await check({ hash });
    return response;
  } catch (error) {
    console.error('检查文件失败:', error);
    return { isExists: false, uploadedChunks: [] };
  }
};

// 上传单个分片
const uploadChunk = async (chunk, index) => {
  if (isPaused.value) {
    await new Promise(resolve => {
      const checkPause = () => {
        if (!isPaused.value) {
          resolve();
        } else {
          setTimeout(checkPause, 1000);
        }
      };
      checkPause();
    });
  }

  const formData = new FormData();
  formData.append('chunk', chunk);
  formData.append('hash', fileHash.value);
  formData.append('chunkIndex', index);
  formData.append('totalChunks', totalChunks.value);
  formData.append('filename', uploadingFile.value.name);

  try {
    // 使用 XMLHttpRequest 来获取上传进度
    await new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest();

      xhr.upload.onprogress = (event) => {
        if (event.lengthComputable) {
          // 计算当前分片的进度
          const chunkProgress = (event.loaded / event.total) * 100;
          // 计算总体进度
          const totalProgress = ((index + chunkProgress / 100) / totalChunks.value) * 100;
          uploadProgress.value = Math.round(totalProgress);
          uploadedSize.value = Math.round((totalProgress / 100) * totalSize.value);
        }
      };

      xhr.onload = () => {
        if (xhr.status === 200) {
          resolve();
        } else {
          reject(new Error(`分片 ${index} 上传失败`));
        }
      };

      xhr.onerror = () => {
        reject(new Error(`分片 ${index} 上传失败`));
      };

      xhr.open('POST', `${import.meta.env.VITE_APP_BASE_API}/common/chunk/upload`);
      xhr.setRequestHeader('Authorization', 'Bearer ' + getToken());
      xhr.send(formData);
    });

    // 更新已上传分片计数
    currentChunk.value = index + 1;
  } catch (error) {
    throw new Error(`分片 ${index} 上传失败`);
  }
};

// 合并分片
const mergeChunks = async () => {
  try {
    const formData = new FormData();
    formData.append('hash', fileHash.value);
    formData.append('filename', uploadingFile.value.name);

    const response = await merge(formData);
    // 触发上传成功事件,传递包含url的响应对象
    emit('upload-success', { url: response.url });
    return response.data;
  } catch (error) {
    emit('upload-error', error);
    throw new Error('文件合并失败');
  }
};

// 文件选择处理
const handleFileChange = async (file) => {
  if (!file) return;

  try {
    // 获取原始文件对象
    const rawFile = file.raw;
    uploadingFile.value = rawFile;
    totalSize.value = rawFile.size;
    totalChunks.value = Math.ceil(rawFile.size / CHUNK_SIZE);
    currentChunk.value = 0;
    uploadedSize.value = 0;
    uploadProgress.value = 0;

    // 计算文件hash
    fileHash.value = await calculateHash(rawFile);

    // 检查文件是否已上传
    const res = await checkFileExists(fileHash.value);
    if (res.isExists) {
      ElMessage.success('文件已存在,秒传成功');
      return;
    }

    // 开始上传
    await startUpload(res.uploadedChunks);
  } catch (error) {
    ElMessage.error('文件处理失败');
  }
};

// 开始上传
const startUpload = async (uploadedChunks = []) => {
  try {
    const chunks = [];
    let uploadedCount = 0;

    // 计算已上传分片的进度
    for (let i = 0; i < totalChunks.value; i++) {
      if (uploadedChunks.includes(i)) {
        uploadedCount++;
        currentChunk.value = i + 1;
        uploadedSize.value = currentChunk.value * CHUNK_SIZE;
        uploadProgress.value = Math.round((currentChunk.value / totalChunks.value) * 100);
      } else {
        const start = i * CHUNK_SIZE;
        const end = Math.min(start + CHUNK_SIZE, uploadingFile.value.size);
        chunks.push({
          index: i,
          blob: uploadingFile.value.slice(start, end)
        });
      }
    }

    // 并发上传分片
    const concurrency = 3;
    for (let i = 0; i < chunks.length; i += concurrency) {
      const chunkGroup = chunks.slice(i, i + concurrency);
      await Promise.all(chunkGroup.map(chunk => uploadChunk(chunk.blob, chunk.index)));
    }

    // 合并分片
    const result = await mergeChunks();
    uploadProgress.value = 100;
    uploadedSize.value = totalSize.value;
    ElMessage.success('上传成功');
    return result
  } catch (error) {
    ElMessage.error(error.message || '上传失败');
    emit('upload-error', error);
  }
};

// 暂停上传
const pauseUpload = () => {
  isPaused.value = true;
};

// 继续上传
const resumeUpload = () => {
  isPaused.value = false;
};

// 取消上传
const cancelUpload = () => {
  isPaused.value = true;
  uploadProgress.value = 0;
  uploadedSize.value = 0;
  currentChunk.value = 0;
  totalChunks.value = 0;
  totalSize.value = 0;
  uploadingFile.value = null;
  fileHash.value = '';
  fileList.value = [];
  if (uploadRef.value) {
    uploadRef.value.clearFiles();
  }
  // 触发上传错误事件,通知父组件
  emit('upload-error', new Error('上传已取消'));
};

// 上传前检查
const beforeUpload = (file) => {
  const isVideo = file.type.startsWith('video/');
  if (!isVideo) {
    ElMessage.error('请上传视频文件');
    return false;
  }

  const isLtMaxSize = file.size / 1024 / 1024 / 1024 < props.maxFileSize;
  if (!isLtMaxSize) {
    ElMessage.error(`文件大小不能超过 ${props.maxFileSize}GB`);
    return false;
  }

  return true;
};

// 格式化文件大小
const formatSize = (bytes) => {
  if (bytes === 0) return '0 B';
  const k = 1024;
  const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
  const i = Math.floor(Math.log(bytes) / Math.log(k));
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};

// 格式化进度显示
const progressFormat = (percentage) => {
  return `${percentage}%`;
};

// 暴露方法给父组件
defineExpose({
  cancelUpload
});
</script>

<style scoped>
.chunk-upload {
  width: 100%;
}

.progress-container {
  margin-top: 20px;
}

.upload-info {
  margin-top: 10px;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.upload-actions {
  display: flex;
  gap: 10px;
}

:deep(.el-progress-bar__inner) {
  transition: width 0.3s ease;
}

:deep(.el-progress-bar__outer) {
  background-color: #f5f7fa;
}

:deep(.el-progress__text) {
  font-size: 14px !important;
}
</style>

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值