说明:
分片上传:
分片上传,就是将所要上传的文件,按照一定的大小,将整个文件分 隔成多个数据块(我们称之为Part)来进行分别上传,上传完之后再 由服务端对所有上传的文件进行汇总整合成原始的文件。
断点续传:
断点续传是在下载/上传时,将下载/上传任务(一个文件或一个压缩 包)人为的划分为几个部分,每一个部分采用一个线程进行上传/下载, 如果碰到网络故障,可以从已经上传/下载的部分开始继续上传/下载 未完成的部分,而没有必要从头开始上传/下载。
一、分片断点信息实体
package com.example.fileupload.entity;
import lombok.Data;
import java.util.List;
/**
* 分片文件断点信息
*/
@Data
public class PointFileIndexVo {
/** 文件唯一MD5标识(断点续传核心) */
private String fileMd5;
/** 原始文件名 */
private String fileName;
/** 当前分片序号 */
private Integer partIndex;
/** 文件总分片数 */
private Integer partNum;
/** 已上传的分片序号列表 */
private List<String> parts;
}
二、服务类的实现
* 文件上传服务实现类
*/
@Service
@Slf4j
public class FileUploadServiceImpl implements FileUploadService {
/**
* 全局上传进度缓存:key=文件MD5,value=已上传分片序号列表
* 生产环境建议替换为Redis
*/
private final Map<String, List<String>> uploadProgress = new ConcurrentHashMap<>();
/**
* 合并锁缓存:防止多线程重复合并文件
* 生产环境建议替换为Redis分布式锁
*/
private final Map<String, Integer> isMergePart = new ConcurrentHashMap<>();
/**
* 服务器文件MD5索引缓存
* 生产环境建议存入数据库
*/
private List<DiskFileIndexVo> diskFileIndexVos = new ArrayList<>();
/**
* 文件根路径(可配置到application.yml)
*/
@Value("${file.upload.base-path:${user.dir}}")
private String basePath;
/**
* 断点续传核心方法:单文件分片上传
*/
@Override
public ResultEntity<String> singleFilePartPointUpload(MultipartFile filePart, String fileInfo) {
// 解析前端传入的分片信息
PointFileIndexVo pointFileIndexVo = JSONObject.parseObject(fileInfo, PointFileIndexVo.class);
String fileMd5 = pointFileIndexVo.getFileMd5();
String fileName = pointFileIndexVo.getFileName();
Integer partIndex = pointFileIndexVo.getPartIndex();
Integer partNum = pointFileIndexVo.getPartNum();
// 跨平台兼容的文件路径定义
String filePath = basePath + File.separator + "file" + File.separator;
String tempPath = filePath + "temp" + File.separator + fileMd5 + File.separator;
// 创建临时分片目录
File dir = new File(tempPath);
if (!dir.exists()) {
dir.mkdirs();
}
// 生成分片临时文件名:文件名_分片序号.part
String tempFileNamePath = tempPath + fileName + "_" + partIndex + ".part";
try {
// 1. 保存当前分片到临时目录
filePart.transferTo(new File(tempFileNamePath));
log.info("分片上传成功:文件MD5={}, 分片序号={}", fileMd5, partIndex);
// 2. 更新上传进度
List<String> partIndexList = uploadProgress.getOrDefault(fileMd5, new ArrayList<>());
if (!partIndexList.contains(partIndex.toString())) {
partIndexList.add(partIndex.toString());
uploadProgress.put(fileMd5, partIndexList);
}
// 3. 校验是否所有分片都上传完成
File tempDir = new File(tempPath);
File[] tempFiles = tempDir.listFiles();
// 空指针安全校验
if (tempFiles == null) {
return ResultEntity.error(partIndex.toString(), "临时目录读取失败");
}
// 总分片数匹配,触发合并
if (partNum.equals(tempFiles.length)) {
// 加锁防止重复合并
if (isMergePart.get(fileMd5) != null) {
return ResultEntity.success(partIndex.toString(), "分片上传成功,等待合并");
}
isMergePart.put(fileMd5, tempFiles.length);
log.info("全部分片上传完成,开始合并:文件MD5={}, 总分片数={}", fileMd5, partNum);
// 4. 按序号合并分片为完整文件(try-with-resources自动关闭流)
String finalFilePath = filePath + fileName;
try (FileOutputStream fos = new FileOutputStream(finalFilePath)) {
byte[] buffer = new byte[1024 * 8]; // 8KB缓冲区
for (int i = 0; i < partNum; i++) {
String partFilePath = tempPath + fileName + "_" + i + ".part";
try (FileInputStream fis = new FileInputStream(partFilePath)) {
int len;
while ((len = fis.read(buffer)) != -1) {
fos.write(buffer, 0, len);
}
}
}
fos.flush();
}
log.info("文件合并完成:{}", finalFilePath);
// 5. 清理临时分片文件和目录
for (int i = 0; i < partNum; i++) {
new File(tempPath + fileName + "_" + i + ".part").delete();
}
if (Objects.requireNonNull(tempDir.listFiles()).length == 0) {
tempDir.delete();
}
// 6. 清理进度和锁缓存
isMergePart.remove(fileMd5);
uploadProgress.remove(fileMd5);
}
} catch (Exception e) {
log.error("单文件分片上传失败,文件MD5={}, 分片序号={}", fileMd5, partIndex, e);
return ResultEntity.error(fileMd5, "单文件分片上传失败");
}
// 返回当前上传成功的分片序号,供前端校验
return ResultEntity.success(partIndex.toString(), "分片上传成功");
}
/**
* 断点续传核心接口:查询已上传分片列表
*/
@Override
public ResultEntity<PointFileIndexVo> checkUploadFileIndex(PointFileIndexVo pointFileIndexVo) {
try {
String fileMd5 = pointFileIndexVo.getFileMd5();
// 1. 从缓存中获取已上传分片
List<String> uploadedParts = uploadProgress.getOrDefault(fileMd5, new ArrayList<>());
if (uploadedParts != null) {
pointFileIndexVo.setParts(uploadedParts);
return ResultEntity.success(pointFileIndexVo);
}
// 2. 双重校验:扫描磁盘临时目录,防止缓存丢失导致断点失效
String tempPath = basePath + File.separator + "file" + File.separator + "temp" + File.separator + fileMd5 + File.separator;
File tempDir = new File(tempPath);
if (tempDir.exists() && tempDir.isDirectory()) {
File[] partFiles = tempDir.listFiles();
if (partFiles != null && partFiles.length > 0) {
uploadedParts = new ArrayList<>();
for (File partFile : partFiles) {
// 从文件名中提取分片序号:文件名_0.part → 提取0
String fileName = partFile.getName();
String index = fileName.split("_")[1].replace(".part", "");
uploadedParts.add(index);
}
// 同步更新缓存
uploadProgress.put(fileMd5, uploadedParts);
}
}
pointFileIndexVo.setParts(uploadedParts);
log.info("查询文件上传进度:MD5={}, 已上传分片={}", fileMd5, uploadedParts);
return ResultEntity.success(pointFileIndexVo);
} catch (Exception e) {
log.error("上传文件进度查询异常", e);
return ResultEntity.error("上传文件进度查询异常");
}
}
}
三、前端代码
// 核心:断点续传上传
async singleFilePartPointUpload() {
const file = this.singleFilePart.file
if (!file) {
this.$message.error('请先选择文件')
return
}
this.uploadIng = true
this.uploadStatus = ''
this.abortController = new AbortController()
try {
// 1. 计算文件MD5
this.$message.info('正在计算文件MD5...')
this.fileMd5 = await this.calculateFileMd5(file)
this.$message.success('MD5计算完成,正在查询上传进度...')
// 2. 查询已上传的分片(断点续传核心)
const checkRes = await axios.post(`${BASE_URL}/file/checkUploadIndex`, {
fileMd5: this.fileMd5,
fileName: file.name
})
if (checkRes.data.code !== 200) {
throw new Error(checkRes.data.msg)
}
this.uploadedChunks = checkRes.data.data.parts || []
// 3. 计算文件分片
const fileSize = file.size
this.totalChunks = Math.ceil(fileSize / CHUNK_SIZE)
const chunks = []
// 生成分片列表
for (let i = 0; i < this.totalChunks; i++) {
const start = i * CHUNK_SIZE
const end = Math.min(start + CHUNK_SIZE, fileSize)
chunks.push({
index: i,
file: file.slice(start, end)
})
}
// 4. 过滤掉已上传的分片,只保留需要上传的
this.needUploadChunks = chunks.filter(chunk =>
!this.uploadedChunks.includes(chunk.index.toString())
)
if (this.needUploadChunks.length === 0) {
this.singlePartFileProgress = 100
this.uploadStatus = 'success'
this.uploadIng = false
this.$message.success('文件已存在,秒传成功!')
return
}
this.$message.info(`共${this.totalChunks}个分片,已上传${this.uploadedChunks.length}个,待上传${this.needUploadChunks.length}个,开始上传...`)
// 5. 循环上传分片
const successCount = this.uploadedChunks.length
for (let i = 0; i < this.needUploadChunks.length; i++) {
if (!this.uploadIng) break // 暂停上传时退出
const chunk = this.needUploadChunks[i]
const formData = new FormData()
// 分片文件
formData.append('filePart', chunk.file)
// 分片信息
const fileInfo = {
fileMd5: this.fileMd5,
fileName: file.name,
partIndex: chunk.index,
partNum: this.totalChunks
}
formData.append('fileInfo', JSON.stringify(fileInfo))
// 上传分片
const uploadRes = await axios.post(`${BASE_URL}/file/pointUpload`, formData, {
signal: this.abortController.signal,
headers: {
'Content-Type': 'multipart/form-data'
}
})
if (uploadRes.data.code !== 200) {
throw new Error(`第${chunk.index}分片上传失败:${uploadRes.data.msg}`)
}
// 更新进度
const currentSuccess = successCount + i + 1
this.singlePartFileProgress = Math.floor((currentSuccess / this.totalChunks) * 100)
this.uploadedChunks.push(chunk.index.toString())
}
// 6. 上传完成
if (this.singlePartFileProgress === 100) {
this.uploadStatus = 'success'
this.$message.success('文件上传并合并完成!')
}
} catch (error) {
if (error.name !== 'AbortError') {
this.$message.error(error.message || '上传失败')
this.uploadStatus = 'exception'
}
} finally {
this.uploadIng = false
}
}