当前位置: 首页 > news >正文

springboot+vue大文件断点续传

springboot+vue大文件断点续传

    • 后端代码
    • 代码说明
      • 1.上传分片接口
      • 2. 查询已上传分片接口
      • 3. 合并分片接口
      • 4.后端流程
    • 前端组件
      • 前端流程

后端代码

@RestController
@RequestMapping("/api/commonFileUtil")
public class CommonFileController {@Autowiredprivate FilePathConfig filePathConfig;/*** 上传分片**/@PostMapping("commonUploadByModule/{module}")public ActionResult<?> commonUploadByModule(@PathVariable("module") String folder,@RequestPart("file") MultipartFile multipartFile,@RequestParam("fileHash") String fileHash,@RequestParam("chunkIndex") int chunkIndex,@RequestParam("totalChunks") int totalChunks,@RequestParam("fileName") String originalFileName) throws IOException {String baseFolder = filePathConfig.getUploadByModule() + (StrUtil.isEmpty(folder) ? "default" : folder);String tempFolder = baseFolder + "/temp/" + fileHash;FileUtil.checkAndCreateFolder(tempFolder);File chunkFile = new File(tempFolder, chunkIndex + ".part");IoUtil.write(new FileOutputStream(chunkFile), true, multipartFile.getBytes());return ActionResult.success("分片上传成功");}/*** 查询自己的分片**/@GetMapping("commonUploadByModule/{module}/status")public ActionResult<?> uploadedChunks(@PathVariable("module") String folder,@RequestParam("fileHash") String fileHash) {String baseFolder = filePathConfig.getUploadByModule() + (StrUtil.isEmpty(folder) ? "default" : folder);String tempFolder = baseFolder + "/temp/" + fileHash;List<Integer> uploaded = new ArrayList<>();File folderFile = new File(tempFolder);if (folderFile.exists()) {File[] files = folderFile.listFiles((dir, name) -> name.endsWith(".part"));if (files != null) {for (File file : files) {String name = file.getName().replace(".part", "");uploaded.add(Integer.parseInt(name));}}}Map<Object, Object> map = new HashMap<>();map.put("uploaded", uploaded);return ActionResult.success(map);}/*** 分片合并**/@PostMapping("commonUploadByModule/{module}/merge")public ActionResult<?> mergeChunks(@PathVariable("module") String folder,@RequestBody Map<String, String> params) throws IOException {String fileHash = params.get("fileHash");String originalFileName = params.get("fileName");String baseFolder = filePathConfig.getUploadByModule() + (StrUtil.isEmpty(folder) ? "default" : folder);String tempFolder = baseFolder + "/temp/" + fileHash;File[] chunks = new File(tempFolder).listFiles((dir, name) -> name.endsWith(".part"));if (chunks == null || chunks.length == 0) {return ActionResult.fail("未找到分片文件");}Arrays.sort(chunks, Comparator.comparingInt(f -> Integer.parseInt(f.getName().replace(".part", ""))));String mergedFileName = FileUtil.addPrefix(Paths.get(originalFileName).getFileName().toString());String mergedFilePath = baseFolder + "/" + mergedFileName;FileUtil.checkAndCreateFolder(baseFolder);try (FileOutputStream fos = new FileOutputStream(mergedFilePath)) {for (File chunk : chunks) {Files.copy(chunk.toPath(), fos);}}// 清理临时文件夹for (File chunk : chunks) chunk.delete();new File(tempFolder).delete();Map<String, String> map = new HashMap<>();map.put("fileName", mergedFileName);map.put("fileOriginName", originalFileName);map.put("fileSize", String.format("%.2fMB", new File(mergedFilePath).length() / 1024 / 1024f));map.put("filePath", mergedFilePath);return ActionResult.success(map);}
}

代码说明

1.上传分片接口

@PostMapping("{module}")
public ActionResult<?> uploadChunk(@PathVariable("module") String folder,@RequestPart("file") MultipartFile multipartFile,@RequestParam("fileHash") String fileHash,@RequestParam("chunkIndex") int chunkIndex,@RequestParam("totalChunks") int totalChunks,@RequestParam("fileName") String originalFileName
) throws IOException {
  • @PostMapping(“{module}”): 表示 POST 请求路径为 /uploadByModule/{module},例如:/uploadByModule/video。

  • @PathVariable(“module”) String folder: 获取路径变量 module,如 “video”,表示上传分类。

  • @RequestPart(“file”) MultipartFile multipartFile: 接收上传的文件分片。

  • 其余参数为前端额外上传的信息:

    • fileHash: 前端根据文件内容生成的唯一标识;

    • chunkIndex: 当前是第几个分片;

    • totalChunks: 总共有多少个分片;

    • fileName: 文件原始名。

String baseFolder = filePathConfig.getUploadByModule() + (StrUtil.isEmpty(folder) ? "default" : folder);
  • 构造出完整的基础目录路径,比如:/mnt/upload/video。

  • 如果路径为空,则用默认目录 /mnt/upload/default。

String tempFolder = baseFolder + "/temp/" + fileHash;
FileUtil.checkAndCreateFolder(tempFolder);
  • 构造当前文件的临时目录,如 /mnt/upload/video/temp/abc123。

  • checkAndCreateFolder() 是你自己定义的工具方法,用于确保目录存在(不存在则创建)。

File chunkFile = new File(tempFolder, chunkIndex + ".part");
IoUtil.write(new FileOutputStream(chunkFile), true, multipartFile.getBytes());
  • 创建当前分片的目标文件对象,例如 /mnt/upload/video/temp/abc123/0.part。

  • 使用 IoUtil.write() 将上传的分片写入本地文件。

2. 查询已上传分片接口

@GetMapping("{module}/status")
public ActionResult<?> uploadedChunks(@PathVariable("module") String folder,@RequestParam("fileHash") String fileHash
) {
  • GET 请求路径为:/uploadByModule/video/status?fileHash=abc123

  • 返回指定文件(通过 fileHash 唯一标识)的 已上传分片编号。

String baseFolder = filePathConfig.getUploadByModule() + (StrUtil.isEmpty(folder) ? "default" : folder);
String tempFolder = baseFolder + "/temp/" + fileHash;
  • 计算临时分片文件夹路径。
List<Integer> uploaded = new ArrayList<>();
File folderFile = new File(tempFolder);
  • 准备存储已上传的分片编号。
if (folderFile.exists()) {File[] files = folderFile.listFiles((dir, name) -> name.endsWith(".part"));
  • 判断该文件夹是否存在,如果存在则读取里面所有以 .part 结尾的文件(即分片)。
    for (File f : files) {String name = f.getName().replace(".part", "");uploaded.add(Integer.parseInt(name));}
}
  • 去除每个文件名的 .part 后缀,并转为数字(分片编号)存入列表中。

3. 合并分片接口

@PostMapping("{module}/merge")
public ActionResult<?> mergeChunks(@PathVariable("module") String folder,@RequestBody Map<String, String> params
) throws IOException {
  • POST /uploadByModule/video/merge

  • @RequestBody 参数为 JSON 对象,包含两个 key:

  • fileHash: 文件唯一标识;

  • fileName: 原始文件名。

String fileHash = params.get("fileHash");
String originalFileName = params.get("fileName");
  • 从请求体中提取参数。
String baseFolder = filePathConfig.getUploadByModule() + (StrUtil.isEmpty(folder) ? "default" : folder);
String tempFolder = baseFolder + "/temp/" + fileHash;
  • 组装临时目录。
File[] chunks = new File(tempFolder).listFiles((dir, name) -> name.endsWith(".part"));
if (chunks == null || chunks.length == 0) {return ActionResult.error("未找到分片文件");
}
Arrays.sort(chunks, Comparator.comparingInt(f -> Integer.parseInt(f.getName().replace(".part", ""))));
  • 对所有分片文件按编号升序排序,确保按顺序合并。
String mergedFileName = FileUtil.addPrefix(Paths.get(originalFileName).getFileName().toString());
String mergedFilePath = baseFolder + "/" + mergedFileName;
FileUtil.checkAndCreateFolder(baseFolder);
  • 使用你的 addPrefix() 工具方法给原始文件名加上唯一前缀;

  • 最终合并后文件路径:如 /mnt/upload/video/1651283761234_myfile.mp4

try (FileOutputStream fos = new FileOutputStream(mergedFilePath)) {for (File chunk : chunks) {Files.copy(chunk.toPath(), fos);}
}
  • 创建输出流 fos,逐个读取 .part 文件并写入合并目标文件。
for (File chunk : chunks) chunk.delete();
new File(tempFolder).delete();
  • 合并完成后删除所有分片及临时文件夹。
Map<String, String> map = new HashMap<>();
map.put("fileName", mergedFileName);
map.put("fileOriginName", originalFileName);
map.put("fileSize", String.format("%.2fMB", new File(mergedFilePath).length() / 1024f / 1024f));
map.put("filePath", mergedFilePath);
return ActionResult.success(map);
  • 返回完整信息给前端,包括:

    • 实际文件名(带前缀);

    • 原始名称;

    • 文件大小;

    • 路径。

4.后端流程

[后端]

  • 每个分片保存在 temp/{fileHash}/chunkIndex.part
  • 合并时按照顺序将所有 part 合成文件

前端组件


<template><div class="upload-wrapper"><input type="file" @change="handleFileChange" /><button :disabled="!file || uploading" @click="uploadFile">{{ uploading ? '上传中...' : '上传文件' }}</button><div v-if="uploading">上传进度:{{ progress.toFixed(2) }}%</div></div>
</template><script>
import Vue from "vue"
import SparkMD5 from 'spark-md5'
import request from "@/utils/request";export default {data () {return {file: null,chunkSize: 5 * 1024 * 1024, // 每片 5MBuploading: false,progress: 0,module: 'video' // 上传模块路径,如 default、video、image...}},methods: {handleFileChange (e) {this.file = e.target.files[0]},async uploadFile () {if (!this.file) returnthis.uploading = trueconst fileHash = await this.calculateFileHash(this.file)const totalChunks = Math.ceil(this.file.size / this.chunkSize)const uploadedChunkIndexes = await this.getUploadedChunkIndexes(fileHash)for (let index = 0; index < totalChunks; index++) {if (uploadedChunkIndexes.includes(index)) {this.updateProgress(index + 1, totalChunks)continue}const start = index * this.chunkSizeconst end = Math.min(start + this.chunkSize, this.file.size)const chunk = this.file.slice(start, end)await this.uploadChunk({chunk,fileHash,chunkIndex: index,totalChunks,fileName: this.file.name})this.updateProgress(index + 1, totalChunks)}await this.mergeChunks(fileHash, this.file.name)this.uploading = falsealert('上传成功')},updateProgress (uploadedChunks, totalChunks) {this.progress = (uploadedChunks / totalChunks) * 100},async calculateFileHash (file) {return new Promise(resolve => {const spark = new SparkMD5.ArrayBuffer()const reader = new FileReader()const chunkSize = this.chunkSizeconst chunks = Math.ceil(file.size / chunkSize)let currentChunk = 0const loadNext = () => {const start = currentChunk * chunkSizeconst end = Math.min(start + chunkSize, file.size)reader.readAsArrayBuffer(file.slice(start, end))}reader.onload = e => {spark.append(e.target.result)currentChunk++if (currentChunk < chunks) {loadNext()} else {resolve(spark.end())}}loadNext()})},async getUploadedChunkIndexes (fileHash) {// try {//   const res = await axios.get(`/api/commonFileUtil/commonUploadByModule/${this.module}/status`, {//     params: { fileHash }//   })//   return res.data.data.uploaded || []// } catch (e) {//   return []// }return new Promise((resolve, reject) => {request({url: `/api/commonFileUtil/commonUploadByModule/${this.module}/status?fileHash=${fileHash}`,method: "GET"}).then((res) => {if (res && res.data && Array.isArray(res.data.uploaded)) {resolve(res.data.uploaded)} else {// 兜底,避免不是数组时报错resolve([])}}).catch((err) => {reject([])})})},async uploadChunk ({ chunk, fileHash, chunkIndex, totalChunks, fileName }) {const formData = new FormData()formData.append('file', chunk)formData.append('fileHash', fileHash)formData.append('chunkIndex', chunkIndex)formData.append('totalChunks', totalChunks)formData.append('fileName', fileName)return new Promise((resolve, reject) => {request({url: `/api/commonFileUtil/commonUploadByModule/${this.module}`,method: "post",data: formData,}).then((res) => {resolve(res.data)}).catch((err) => {reject(err)})})},async mergeChunks (fileHash, fileName) {return new Promise((resolve, reject) => {request({url: `/api/commonFileUtil/commonUploadByModule/${this.module}/merge`,method: "post",data: {fileHash,fileName},}).then((res) => {resolve(res.data)}).catch((err) => {reject(err)})})}}
}
</script><style scoped>
.upload-wrapper {padding: 20px;border: 1px dashed #999;border-radius: 10px;width: 400px;
}
</style>

前端流程

[前端]
→ 1. 计算 fileHash
→ 2. 查询 status 接口,获取已上传分片
→ 3. 上传分片(附带 index/hash/total)
→ 4. 上传完成后,调用 merge 接口

相关文章:

  • 前端JavaScript面试题(2)
  • Promise(async、await)
  • 非本地地址调用摄像头需要https
  • Coze扣子 - AI生成数字人口播视频
  • 多环境开发配置,Spring boot
  • 【leetcode】101. 对称二叉树
  • Mysql 忘记密码后如何修改
  • 长春光博会 | 麒麟信安:构建工业数字化安全基座,赋能智能制造转型升级
  • 零基础学前端-传统前端开发(第四期-JS基础-运算)
  • sqlserver存储过程中入参使用JSON
  • redis穿透、击穿、雪崩
  • linux alignment fault对齐造成设备挂死问题定位梳理
  • 代码训练LeetCode(34)文本左右对齐
  • 行为模式-迭代器模式
  • 基于sample_aiisp再创建一路 h264编码流,和jpg的编码流
  • vue中的三种插槽方式
  • ABP vNext + OpenIddict:自定义 OAuth2/OpenID Connect 认证策略
  • 如何从 Windows 11 或 10 远程访问 Ubuntu 24.04 或 22.04 桌面
  • 使用 C++ 和 OpenCV 构建智能停车场视觉管理系统
  • Linux NFS服务器配置
  • wordpress许愿插件/哈尔滨seo关键词排名
  • 来凡网站建设公司/关键词出价计算公式
  • 网站做图分辨率是多少/免费发布推广平台
  • 怎么做本地化网站/搜索引擎最佳化
  • 在58做网站推广有用没/搜索引擎网站排名
  • 天水做网站/百度搜不干净的东西