基于Spring Boot与Vue的分片上传与秒传实现
在处理大文件上传时,采用分片上传机制可有效提升上传成功率并支持断点续传。以下为基于 Spring Boot 后端与 Vue 前端结合的完整实现方案,包含文件分片、合并及极速秒传逻辑。
后端核心工具类:分片上传与合并
/**
* 大文件分片上传处理
* @param fileName 原始文件名
* @param file 待上传文件
* @param fileKey 唯一标识键
* @param chunkIndex 当前分片索引(从1开始)
* @param totalChunks 总分片数
*/
public static void uploadChunk(String fileName, MultipartFile file, String fileKey, int chunkIndex, int totalChunks) throws IOException {
String basePath = getDefaultBaseDir() + "/" + DateUtils.datePath() + "/" + fileKey;
File directory = new File(basePath);
if (!directory.exists()) {
directory.mkdirs();
}
// 构造分片文件路径
String chunkPath = basePath + "/" + fileKey + "." + chunkIndex;
File chunkFile = new File(chunkPath);
file.transferTo(chunkFile);
// 最后一个分片上传完成,触发合并
if (chunkIndex == totalChunks) {
mergeFiles(fileName, totalChunks, fileKey);
}
}
/**
* 合并所有分片文件
*/
private static void mergeFiles(String originalName, int totalChunks, String fileKey) throws IOException {
String destPath = getDefaultBaseDir() + "/" + DateUtils.datePath() + "/" + fileKey + "/" + originalName;
try (FileOutputStream fos = new FileOutputStream(destPath, true);
FileInputStream fis = null) {
byte[] buffer = new byte[10 * 1024 * 1024]; // 10MB 缓冲区
for (int i = 1; i <= totalChunks; i++) {
String chunkPath = getDefaultBaseDir() + "/" + DateUtils.datePath() + "/" + fileKey + "/" + fileKey + "." + i;
try (FileInputStream fisTemp = new FileInputStream(chunkPath)) {
int bytesRead;
while ((bytesRead = fisTemp.read(buffer)) != -1) {
fos.write(buffer, 0, bytesRead);
}
}
}
} catch (Exception e) {
throw new IOException("合并文件失败", e);
} finally {
System.gc(); // 建议手动触发垃圾回收
}
}
控制器接口设计
// 检查已上传分片状态
@GetMapping("/check")
public AjaxResult checkStatus(@RequestParam String key) {
PanoramicFileTb record = panoramicFileTbService.getLastUploadedChunk(key);
log.info("查询分片进度: {}", key);
return AjaxResult.success(record);
}
// 接收分片上传请求
@PostMapping("/upload")
@PreAuthorize("@ss.hasPermi('system:file:add')")
@Log(title = "文件上传", businessType = BusinessType.INSERT)
public AjaxResult handleChunkUpload(@RequestParam("file") MultipartFile file,
FileUploadRequest request) throws Exception {
uploadChunk(request.getFileName(), file, request.getKey(), request.getChunkIndex(), request.getTotalChunks());
// 更新上传记录
PanoramicFileTb info = PanoramicFileTb.builder()
.fKey(request.getKey())
.fIndex(request.getChunkIndex())
.fTotal(request.getTotalChunks())
.fName(request.getFileName())
.build();
if (panoramicFileTbService.exists(request.getKey())) {
panoramicFileTbService.update(info);
} else {
panoramicFileTbService.save(info);
}
return AjaxResult.success();
}
数据传输对象定义
public class FileUploadRequest {
private String key;
private String fileName;
private int chunkIndex;
private int totalChunks;
private long size;
private String suffix;
// getter/setter 略...
}
实体类:文件上传状态记录
@Builder
public class PanoramicFileTb extends BaseEntity {
private Integer id;
private String fKey; // 文件唯一标识
private Integer fIndex; // 当前已上传分片索引
private Integer fTotal; // 总分片数量
private String fName; // 原始文件名
// getter/setter 略...
}
服务层逻辑
@Override
public void save(PanoramicFileTb record) {
panoramicFileTbMapper.insert(record);
}
@Override
public void update(PanoramicFileTb record) {
panoramicFileTbMapper.update(record);
}
@Override
public boolean exists(String key) {
return panoramicFileTbMapper.countByKey(key) > 0;
}
@Override
public PanoramicFileTb getLastUploadedChunk(String key) {
PanoramicFileTb result = panoramicFileTbMapper.selectByFileKey(key);
return result != null ? result : PanoramicFileTb.builder()
.fKey(key)
.fIndex(-1)
.fTotal(0)
.fName("")
.build();
}
前端实现:分片上传与秒传
需安装 js-md5 用于生成文件指纹:
npm install js-md5 --save
<template>
<div class="upload-container">
<el-upload
drag
ref="uploader"
:limit="1"
:action="''"
:auto-upload="false"
:on-exceed="handleExceed"
:http-request="handleUpload"
>
<i class="el-icon-upload"></i>
<div class="el-upload__text">拖拽文件或点击上传<em>上传视频</em></div>
</el-upload>
<el-button type="primary" @click="submitUpload">开始上传</el-button>
<el-card class="preview-card">
<video :src="videoSrc" controls autoplay width="100%"></video>
</el-card>
</div>
</template>
<script>
import md5 from 'js-md5';
export default {
data() {
return {
actionUrl: 'http://localhost:8098/upload',
chunkSize: 10 * 1024 * 1024,
videoSrc: ''
};
},
methods: {
handleExceed() {
this.$message.warning('仅支持单个文件上传');
},
submitUpload() {
this.$refs.uploader.submit();
},
async checkProgress(fileKey) {
const res = await this.$http.get('/check', { params: { key: fileKey } });
return res.data?.data || {};
},
async uploadNextChunk(params, file) {
const { key, chunkIndex, totalChunks, chunkSize, fileName, suffix, size } = params;
const start = (chunkIndex - 1) * chunkSize;
const end = Math.min(size, start + chunkSize);
const chunkBlob = file.slice(start, end);
const formData = new FormData();
formData.append('file', chunkBlob);
formData.append('key', key);
formData.append('chunkIndex', chunkIndex);
formData.append('totalChunks', totalChunks);
formData.append('size', size);
formData.append('fileName', fileName);
formData.append('suffix', suffix);
const response = await this.$http.post('/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
if (response.data.status) {
this.$notify({
title: '成功',
message: `第 ${chunkIndex} 片上传完成`,
type: 'success'
});
if (chunkIndex < totalChunks) {
params.chunkIndex += 1;
this.uploadNextChunk(params, file);
} else {
this.videoSrc = response.data.data;
this.$notify({ title: '完成', message: '全部上传成功', type: 'success' });
}
}
},
async handleUpload(req) {
const file = req.file;
const name = file.name;
const ext = name.split('.').pop().toLowerCase();
if (ext !== 'mp4') {
this.$message.error('仅支持 MP4 格式视频');
return;
}
const fileSize = file.size;
const totalChunks = Math.ceil(fileSize / this.chunkSize);
const fileKey = md5(name + fileSize + file.type);
const uploadParams = {
key: fileKey,
fileName: name,
chunkIndex: 1,
totalChunks,
size: fileSize,
suffix: ext,
shardSize: this.chunkSize
};
const status = await this.checkProgress(fileKey);
const lastUploaded = status.fIndex || -1;
if (lastUploaded === -1) {
this.uploadNextChunk(uploadParams, file);
} else if (lastUploaded < totalChunks) {
uploadParams.chunkIndex = lastUploaded + 1;
this.uploadNextChunk(uploadParams, file);
} else {
this.videoSrc = status.fName;
this.$message.success('秒传成功,文件已存在');
}
}
}
};
</script>
<style scoped>
.upload-container {
padding: 20px;
}
.preview-card {
margin-top: 20px;
text-align: center;
}
</style>