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 接口