1.断点续传介绍
通常一些文件如视频文件体积都比较大,对于这些文件的上传需求要满足大文件的上传要求。http协议本身对上传文件大小没有限制,但是客户的网络环境质量、电脑硬件环境等参差不齐,如果一个大文件快上传完了网断了没有上传完成,需要客户重新上传,用户体验非常差,所以对于大文件上传的要求最基本的是断点续传。
什么是断点续传:
引用百度百科:断点续传指的是在下载或上传时,将下载或上传任务(一个文件或一个压缩包)人为的划分为几个部分,每一个部分采用一个线程进行上传或下载,如果碰到网络故障,可以从已经上传或下载的部分开始继续上传下载未完成的部分,而没有必要从头开始上传下载,断点续传可以提高节省操作时间,提高用户体验性。

流程如下:
1、前端上传前先把文件分成块
2、一块一块的上传,上传中断后重新上传,已上传的分块则不用再上传
3、各分块上传完成最后在服务端合并文件
2.MinIO大文件分块合并思路
2.1 整体思路

1、前端对文件进行分块。
2、前端上传分块文件前请求媒资服务检查文件是否存在,如果已经存在则不再上传。
3、如果分块文件不存在则前端开始上传
4、前端请求媒资服务上传分块。
5、媒资服务将分块上传至MinIO。
6、前端将分块上传完毕请求媒资服务合并分块。
7、媒资服务判断分块上传完成则请求MinIO合并文件。
8、合并完成校验合并后的文件是否完整,如果完整则上传完成,否则删除文件
为了更好的理解文件分块上传的原理,下边用java代码测试文件的分块与合并。
2.2 文件分块
文件分块的流程如下:
1、获取源文件长度
2、根据设定的分块文件的大小计算出块数
3、从源文件读数据依次向每一个块文件写数据。
本地将大文件分片:
java
//本地大文件切片
@Test
public void splitLocalFile() throws Exception {
//源大文件
File sourceFile=new File("D:\\dOWN\\ttt.zip");
//计算分块数量
long fileTotalSize=sourceFile.length();
long chunkSize=1024*1024*5; //每块5MB
long chunkNum= (long) Math.ceil(fileTotalSize*1.0/chunkSize);
try (RandomAccessFile rafRead = new RandomAccessFile(sourceFile, "r")) {
// 1MB缓冲区(平衡IO次数和内存占用)
byte[] buffer = new byte[1024 * 1024];
// 遍历生成每个分块
for (int i = 0; i < chunkNum; i++) {
// 分块文件命名规则:数字(保证排序有序)
File chunkFile = new File("F:\\外接项目\\E-Learn项目重构\\demo\\backend\\E-Learn-Backend\\doc\\testBigFile\\" + i);
// 创建空分块文件
boolean createSuccess = chunkFile.createNewFile();
if (!createSuccess) {
throw new IOException("分块文件创建失败:" + chunkFile.getAbsolutePath());
}
// 写入分块数据(try-with-resources自动关闭写流)
try (RandomAccessFile rafWrite = new RandomAccessFile(chunkFile, "rw")) {
int readLen; // 单次读取的字节数
// 循环读取源文件数据,写入分块文件
while ((readLen = rafRead.read(buffer)) != -1) {
rafWrite.write(buffer, 0, readLen);
// 分块文件大小达到阈值则停止写入(避免单个分块超过5MB)
if (chunkFile.length() >= chunkSize) {
break;
}
}
}
}
}
}

本地分片上传到minio:
java
//将本地分片上传到minio
@Test
public void test() throws IOException, ServerException, InsufficientDataException, ErrorResponseException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException {
minioClient = MinioClient.builder()
.endpoint("http://127.0.0.1:9000")
.credentials("c7bbXkpNOJU5wBLw", "6TcSQfRHOPv8ZqaYN9TLMZfsNfqEbrsm")
.build();
// 1. 校验本地分块目录有效性
File chunkFolder=new File("F:\\外接项目\\E-Learn项目重构\\demo\\backend\\E-Learn-Backend\\doc\\testBigFile\\");
File[] chunkFiles=chunkFolder.listFiles();
//2.分块文件排序(按文件数字升序排序,保证上传顺序)
List<File> chunkFileList=Arrays.asList(chunkFiles);
chunkFileList.sort((f1, f2) -> {
int num1 = Integer.parseInt(f1.getName());
int num2 = Integer.parseInt(f2.getName());
return Integer.compare(num1, num2);
});
//3.逐个上传分块到minio
for (File chunkFile : chunkFileList) {
// MinIO中分块对象名:前缀+分块编号(如chunk/0)
String chunkObjectName = "/chunk/" + chunkFile.getName();
// 构建上传参数
UploadObjectArgs uploadArgs = UploadObjectArgs.builder()
.bucket("test") // 目标存储桶
.object(chunkObjectName) // 分块对象名
.filename(chunkFile.getAbsolutePath()) // 本地分块路径
.build();
// 执行上传(异常直接抛出)
minioClient.uploadObject(uploadArgs);
System.out.println("分块[" + chunkFile.getName() + "]上传成功,MinIO路径:" + chunkObjectName);
}
System.out.println("===== 所有分块上传完成 =====");
}

2.3 分片合并
合并minio中的文件分片并校验其合法性:
java
//minio中合并分片
@Test
public void mergeChunksInMinio() throws Exception {
minioClient = MinioClient.builder()
.endpoint("http://127.0.0.1:9000")
.credentials("c7bbXkpNOJU5wBLw", "6TcSQfRHOPv8ZqaYN9TLMZfsNfqEbrsm")
.build();
// 1. 查询MinIO中的分块数量(避免硬编码分块数)
ListObjectsArgs listArgs = ListObjectsArgs.builder()
.bucket("test")
.prefix("/chunk/") // 只查询分块前缀下的对象
.build();
Iterable<Result<io.minio.messages.Item>> chunkItems = minioClient.listObjects(listArgs);
long chunkNum = 0;
for (Result<Item> ignored : chunkItems) {
chunkNum++;
}
if (chunkNum == 0) {
throw new Exception("MinIO中未查询到分块文件,无法合并");
}
System.out.println("===== 合并参数 =====");
System.out.println("待合并分块数:" + chunkNum);
// 2. 构建分块源列表(按顺序关联每个分块)
List<ComposeSource> composeSources = Stream.iterate(0, i -> i + 1)
.limit(chunkNum) // 限制生成数量为分块总数
.map(i -> ComposeSource.builder()
.bucket("test") // 分块所在桶
.object("/chunk/" + i) // 分块对象名
.build())
.collect(Collectors.toList());
// 3. 构建合并参数并执行合并
ComposeObjectArgs composeArgs = ComposeObjectArgs.builder()
.bucket("test") // 合并后文件所在桶
.object("/merge/mergedFile") // 合并后文件名
.sources(composeSources) // 分块源列表
.build();
minioClient.composeObject(composeArgs);
System.out.println("===== MinIO分块合并完成 =====");
// 4. 校验合并文件完整性(对比源文件MD5)
validateMergeFile();
}
/**
* 辅助方法:校验合并后的文件完整性
* 逻辑:对比本地源文件和MinIO合并文件的MD5值
* 异常:直接抛出
*/
private void validateMergeFile() throws IOException, ServerException, InsufficientDataException, ErrorResponseException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException {
System.out.println("===== 开始校验合并文件 =====");
// 1. 下载MinIO合并后的文件到本地临时文件
File mergeTempFile = new File("F:\\外接项目\\E-Learn项目重构\\demo\\backend\\E-Learn-Backend\\doc\\merres");
if (mergeTempFile.exists()) {
mergeTempFile.delete();
}
DownloadObjectArgs downloadArgs = DownloadObjectArgs.builder()
.bucket("test")
.object("/merge/mergedFile")
.filename(mergeTempFile.getAbsolutePath())
.build();
minioClient.downloadObject(downloadArgs);
// 2. 计算源文件MD5
File sourceFile = new File("D:\\dOWN\\ttt.zip");
String sourceMd5;
try (FileInputStream sourceIs = new FileInputStream(sourceFile)) {
sourceMd5 = DigestUtils.md5Hex(sourceIs);
}
// 3. 计算合并文件MD5
String mergeMd5;
try (FileInputStream mergeIs = new FileInputStream(mergeTempFile)) {
mergeMd5 = DigestUtils.md5Hex(mergeIs);
}
// 4. 对比MD5
if (sourceMd5.equals(mergeMd5)) {
System.out.println("文件校验成功!MD5值:" + sourceMd5);
// 删除临时文件
mergeTempFile.delete();
} else {
throw new IOException("文件校验失败!源文件MD5:" + sourceMd5 + ",合并文件MD5:" + mergeMd5);
}
System.out.println("===== 校验完成 =====");
}


清理MinIO中的文件分片(合并后执行):
java
//清理Minio中的文件分片(合并后执行)
@Test
public void cleanChunksInMinio() throws Exception {
minioClient = MinioClient.builder()
.endpoint("http://127.0.0.1:9000")
.credentials("c7bbXkpNOJU5wBLw", "6TcSQfRHOPv8ZqaYN9TLMZfsNfqEbrsm")
.build();
// 1. 查询待删除的分块列表
ListObjectsArgs listArgs = ListObjectsArgs.builder()
.bucket("test")
.prefix("/chunk/")
.build();
Iterable<Result<Item>> chunkItems = minioClient.listObjects(listArgs);
// 2. 构建删除对象列表
List<DeleteObject> deleteObjects = new ArrayList<>();
for (Result<Item> itemResult : chunkItems) {
Item item = itemResult.get();
deleteObjects.add(new DeleteObject(item.objectName()));
}
if (deleteObjects.isEmpty()) {
System.out.println("MinIO中无分块文件需要清理");
return;
}
// 3. 批量删除分块
System.out.println("===== 开始清理分块 =====");
RemoveObjectsArgs removeArgs = RemoveObjectsArgs.builder()
.bucket("test")
.objects(deleteObjects) // 待删除的分块列表
.build();
Iterable<Result<DeleteError>> deleteResults = minioClient.removeObjects(removeArgs);
// 4. 遍历删除结果(捕获单个分块删除失败的异常)
for (Result<DeleteError> deleteResult : deleteResults) {
DeleteError error = deleteResult.get();
if (error != null) {
throw new IOException("分块[" + error.objectName() + "]删除失败:" + error.message());
}
}
System.out.println("===== 分块清理完成 =====");
System.out.println("共删除分块数:" + deleteObjects.size());
}
3.大文件上传案例演示
3.1 整体思路实现
大文件分片上传方案基于 "前端分片 + 后端合并 + 断点续传 + 秒传" 核心设计,完整流程分为 前端预处理 → 校验阶段 → 分片上传 → 后端合并 → 播放 / 下载 五个核心阶段,前后端分工明确:
- 前端:负责文件切割、MD5 计算、分片上传(并发 + 断点)、进度回调;
- 后端:负责分片接收、MinIO 存储、分块校验、合并分块、MD5 验真、在线播放 / 下载支持。
3.2 后端接口设计
3.2.1 整体设计
文件上传:

文件合并:

3.2.2 接口设计
Controller:
java
import com.lgh.common.result.Result;
import com.lgh.web.service.VedioService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
/**
* 视频接口测试
* @Author GuihaoLv
*/
@RestController
@RequestMapping("/web/vedio")
@Tag(name="视频接口测试",description = "视频接口测试")
@Slf4j
public class VedioController {
@Autowired
private VedioService vedioService;
/**
* 文件上传前检查
* @param fileMd5
* @return
*/
@PostMapping("/upload/checkFile")
@Operation(summary = "文件上传前检查")
public Result<Boolean> checkFile(@RequestParam("fileMd5") String fileMd5) {
Boolean exists = vedioService.checkFileExists(fileMd5);
return Result.success(exists);
}
/**
* 分块上传前检查
* @param fileMd5
* @param chunk
* @return
*/
@PostMapping("/upload/checkChunk")
@Operation(summary = "文件上传前检查")
public Result<Boolean> checkChunk(@RequestParam("fileMd5") String fileMd5,
@RequestParam("chunk")int chunk) {
Boolean exists = vedioService.checkChunkExists(fileMd5,chunk);
return Result.success(exists);
}
/**
* 上传分块文件
* @param fileMd5
* @param chunk
* @return
*/
@PostMapping("/upload/uploadChunk")
@Operation(summary = "传分块文件")
public Result<Boolean> uploadChunk(@RequestParam("file") MultipartFile file,
@RequestParam("fileMd5") String fileMd5,
@RequestParam("chunk")int chunk) {
Boolean success = vedioService.uploadChunk(file, fileMd5, chunk);
return Result.success(success);
}
/**
* 合并分块文件
* @param fileMd5
* @param chunkTotal
* @return
*/
@PostMapping("/upload/mergeChunk")
@Operation(summary = "合并分块文件")
public Result<Boolean> mergeChunk(@RequestParam("fileMd5") String fileMd5,
@RequestParam("fileName") String fileName,
@RequestParam("chunkTotal") int chunkTotal) {
Boolean success = vedioService.mergeChunk(fileMd5, fileName, chunkTotal);
return Result.success(success);
}
}
Service:
java
import org.springframework.web.multipart.MultipartFile;
public interface VedioService {
/**
* 文件上传前检查
* @param fileMd5
* @return
*/
Boolean checkFileExists(String fileMd5);
/**
* 检查分块是否存在
* @param fileMd5
* @param chunk
* @return
*/
Boolean checkChunkExists(String fileMd5, int chunk);
/**
* 检查分块是否存在
* @param file
* @param fileMd5
* @param chunk
* @return
*/
Boolean uploadChunk(MultipartFile file, String fileMd5, int chunk);
/**
* 合并分块
* @param fileMd5
* @param fileName
* @return
*/
Boolean mergeChunk(String fileMd5, String fileName, int chunkTotal);
}
java
import com.lgh.common.properties.MinIoProperties;
import com.lgh.web.service.VedioService;
import io.minio.*;
import io.minio.errors.MinioException;
import io.minio.messages.DeleteError;
import io.minio.messages.DeleteObject;
import io.minio.messages.Item;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.compress.utils.IOUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.*;
import java.util.List;
import java.util.ArrayList;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* 视频服务实现类
* @Author GuihaoLv
*/
@Service
@Slf4j
public class VedioServiceImpl implements VedioService {
@Autowired
private MinioClient minioClient;
@Autowired
private MinIoProperties minIoProperties;
/**
* 文件上传前检查
* @param fileMd5
* @return
*/
public Boolean checkFileExists(String fileMd5) {
// 1. 构建完整文件在MinIO中的存储路径(与分块目录规则一致)
String fullFilePath = getFullFilePath(fileMd5);
try {
// 2. 检查MinIO中是否存在该对象
StatObjectArgs statArgs = StatObjectArgs.builder()
.bucket(minIoProperties.getBucketName())
.object(fullFilePath)
.build();
minioClient.statObject(statArgs);
log.info("文件已存在,MD5:{},MinIO路径:{}", fileMd5, fullFilePath);
return true;
} catch (MinioException e) {
// MinIO返回对象不存在异常,说明文件未上传
if (e.getMessage().equals("NoSuchKey")) {
log.info("文件不存在,MD5:{}", fileMd5);
return false;
}
// 其他异常打印日志,返回false
log.error("检查文件存在性失败,MD5:{}", fileMd5, e);
return false;
} catch (Exception e) {
log.error("检查文件存在性异常,MD5:{}", fileMd5, e);
return false;
}
}
/**
* 检查分块是否已存在
* 逻辑:拼接分块文件路径,检查MinIO中是否存在该分块对象
*/
public Boolean checkChunkExists(String fileMd5, int chunk) {
// 1. 构建分块文件在MinIO中的路径
String chunkFilePath = getChunkFileFolderPath(fileMd5) + chunk;
try {
// 2. 检查分块对象是否存在
StatObjectArgs statArgs = StatObjectArgs.builder()
.bucket(minIoProperties.getBucketName())
.object(chunkFilePath)
.build();
minioClient.statObject(statArgs);
log.info("分块已存在,MD5:{},分块索引:{}", fileMd5, chunk);
return true;
} catch (MinioException e) {
if (e.getMessage().equals("NoSuchKey")) {
log.info("分块不存在,MD5:{},分块索引:{}", fileMd5, chunk);
return false;
}
log.error("检查分块存在性失败,MD5:{},分块索引:{}", fileMd5, chunk, e);
return false;
} catch (Exception e) {
log.error("检查分块存在性异常,MD5:{},分块索引:{}", fileMd5, chunk, e);
return false;
}
}
/**
* 上传分块文件到MinIO
* 逻辑:MultipartFile转InputStream,上传到分块指定路径
*/
public Boolean uploadChunk(MultipartFile file, String fileMd5, int chunk) {
// 1. 校验参数
if (file.isEmpty()) {
log.error("上传分块失败,文件为空,MD5:{},分块索引:{}", fileMd5, chunk);
return false;
}
// 2. 构建分块存储路径
String chunkFilePath = getChunkFileFolderPath(fileMd5) + chunk;
try (InputStream inputStream = file.getInputStream()) {
// 3. 上传分块到MinIO
PutObjectArgs putArgs = PutObjectArgs.builder()
.bucket(minIoProperties.getBucketName())
.object(chunkFilePath)
.stream(inputStream, file.getSize(), -1) // -1表示自动检测文件大小
.contentType(file.getContentType())
.build();
minioClient.putObject(putArgs);
log.info("分块上传成功,MD5:{},分块索引:{},路径:{}", fileMd5, chunk, chunkFilePath);
return true;
} catch (Exception e) {
log.error("分块上传失败,MD5:{},分块索引:{}", fileMd5, chunk, e);
return false;
}
}
/**
* 合并分块文件(优化版)
* 核心逻辑:1. 有序构建分块列表 2. 合并文件 3. MD5校验 4. 清理分块
* @param fileMd5 文件唯一标识(MD5)
* @param fileName 原始文件名(含扩展名)
* @param chunkTotal 分块总数(新增参数:避免遍历MinIO获取分块,提升性能)
* @return 合并是否成功
*/
public Boolean mergeChunk(String fileMd5, String fileName, int chunkTotal) {
// 1. 基础路径构建
String chunkFileFolderPath = getChunkFileFolderPath(fileMd5); //分块文件存储目录
String extName = fileName.substring(fileName.lastIndexOf(".")); // 提取文件扩展名
String mergeFilePath = getFilePathByMd5(fileMd5, extName); // 合并后文件路径
try {
// 2. 有序构建分块源列表(参考代码核心逻辑:按索引生成,保证顺序)
List<ComposeSource> sourceObjectList = Stream.iterate(0, i -> ++i)
.limit(chunkTotal)
.map(i -> ComposeSource.builder()
.bucket(minIoProperties.getBucketName())
.object(chunkFileFolderPath.concat(Integer.toString(i)))
.build())
.collect(Collectors.toList());
// 3. 执行MinIO分块合并
ObjectWriteResponse response = minioClient.composeObject(
ComposeObjectArgs.builder()
.bucket(minIoProperties.getBucketName())
.object(mergeFilePath)
.sources(sourceObjectList)
.build());
log.info("合并文件成功:{}", mergeFilePath);
// 4. 下载合并后的文件,进行MD5校验(核心:保证文件完整性)
File minioFile = downloadFileFromMinIO(minIoProperties.getBucketName(), mergeFilePath);
if (minioFile == null) {
log.error("下载合并后文件失败,mergeFilePath:{}", mergeFilePath);
return false;
}
// 5. MD5校验逻辑
try (InputStream newFileInputStream = new FileInputStream(minioFile)) {
String md5Hex = DigestUtils.md5Hex(newFileInputStream);
// 比对MD5,不一致则返回失败
if (!fileMd5.equals(md5Hex)) {
log.error("文件合并校验失败,MD5不一致:原始{},合并后{}", fileMd5, md5Hex);
return false;
}
// 可选:此处可添加文件大小记录、入库等业务逻辑
log.info("文件MD5校验通过,MD5:{}", fileMd5);
}
// 6. 清理分块文件(参考代码的清理逻辑,增加容错)
clearChunkFiles(chunkFileFolderPath, chunkTotal);
// 7. 临时文件删除(finally中兜底)
return true;
} catch (Exception e) {
log.error("合并文件失败,fileMd5:{},异常:{}", fileMd5, e.getMessage(), e);
return false;
} finally {
// 兜底:删除临时文件(若存在)
File minioFile = new File(System.getProperty("java.io.tmpdir"), "minio.merge");
if (minioFile.exists()) {
minioFile.delete();
}
}
}
/**
* 从MinIO下载文件到本地临时文件(参考代码的downloadFileFromMinIO)
* @param bucket 桶名
* @param objectName 对象路径
* @return 本地临时文件
*/
private File downloadFileFromMinIO(String bucket, String objectName) {
File minioFile = null;
FileOutputStream outputStream = null;
try {
// 从MinIO获取文件流
InputStream stream = minioClient.getObject(GetObjectArgs.builder()
.bucket(bucket)
.object(objectName)
.build());
// 创建临时文件
minioFile = File.createTempFile("minio", ".merge");
outputStream = new FileOutputStream(minioFile);
IOUtils.copy(stream, outputStream); // 拷贝流到临时文件
return minioFile;
} catch (Exception e) {
log.error("下载MinIO文件失败,bucket:{},object:{}", bucket, objectName, e);
return null;
} finally {
// 关闭流
if (outputStream != null) {
try {
outputStream.close();
} catch (IOException e) {
log.error("关闭文件输出流失败", e);
}
}
}
}
/**
* 清除分块文件(参考代码的clearChunkFiles,优化异常处理)
* @param chunkFileFolderPath 分块文件目录
* @param chunkTotal 分块总数
*/
private void clearChunkFiles(String chunkFileFolderPath, int chunkTotal) {
try {
List<DeleteObject> deleteObjects = Stream.iterate(0, i -> ++i)
.limit(chunkTotal)
.map(i -> new DeleteObject(chunkFileFolderPath.concat(Integer.toString(i))))
.collect(Collectors.toList());
RemoveObjectsArgs removeObjectsArgs = RemoveObjectsArgs.builder()
.bucket(minIoProperties.getBucketName())
.objects(deleteObjects)
.build();
Iterable<Result<DeleteError>> results = minioClient.removeObjects(removeObjectsArgs);
results.forEach(r -> {
try {
DeleteError deleteError = r.get();
if (deleteError != null) {
log.error("清除分块文件失败,objectname:{}", deleteError.objectName());
}
} catch (Exception e) {
log.error("遍历分块删除结果失败", e);
}
});
log.info("分块文件清理完成,共处理{}个分块", chunkTotal);
} catch (Exception e) {
log.error("清除分块文件失败,chunkFileFolderPath:{}", chunkFileFolderPath, e);
}
}
// ------------------- 私有工具方法 -------------------
/**
* 构建分块文件存储目录路径
* 规则:md5前两位拆分目录 + md5 + chunk/
* 示例:md5=abc123 → a/b/abc123/chunk/
*/
private String getChunkFileFolderPath(String fileMd5) {
return fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/chunk/";
}
/**
* 构建完整文件存储路径(不带文件名)
*/
private String getFullFilePath(String fileMd5) {
return fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/";
}
/**
* 构建完整文件存储路径(带文件名)
*/
private String getFullFilePath(String fileMd5, String fileName) {
return getFullFilePath(fileMd5) + fileName;
}
/**
* 参考代码的getFilePathByMd5:构建合并后文件的完整路径
* @param fileMd5 文件MD5
* @param fileExt 文件扩展名(含.)
* @return 完整路径
*/
private String getFilePathByMd5(String fileMd5, String fileExt) {
return fileMd5.substring(0, 1) + "/" + fileMd5.substring(1, 2) + "/" + fileMd5 + "/" + fileMd5 + fileExt;
}
}
3.3 前端设计
3.3.1 整体设计

3.3.2 请求及工具类封装
参数类型:
TypeScript
// src/types/upload.d.ts
/** 合并分片参数类型 */
export interface MergeChunkParams {
fileMd5: string; // 文件MD5
fileName: string; // 原始文件名
chunkTotal: number; // 总分片数
}
/** 上传进度回调类型 */
export type ProgressCallback = (percent: number) => void;
请求函数:
TypeScript
// src/api/bigFileApi.ts
import httpInstance from '@/utils/http';
// 导入类型(仅编译时使用,运行时无影响)
import type { MergeChunkParams } from '@/types/upload';
/**
* 检查文件是否已上传(秒传校验)
* @param fileMd5 文件MD5
*/
export const checkFileExists = (fileMd5: string) => {
return httpInstance({
url: '/web/vedio/upload/checkFile',
method: 'POST',
params: { fileMd5 },
});
};
/**
* 检查分片是否已上传(断点续传校验)
* @param fileMd5 文件MD5
* @param chunk 分片索引
*/
export const checkChunkExists = (fileMd5: string, chunk: number) => {
return httpInstance({
url: '/web/vedio/upload/checkChunk',
method: 'POST',
params: { fileMd5, chunk },
});
};
/**
* 上传分片文件
* @param formData 分片表单数据
* @param onProgress 单分片上传进度回调
*/
export const uploadChunk = (formData: FormData, onProgress?: (progress: number) => void) => {
return httpInstance({
url: '/web/vedio/upload/uploadChunk',
method: 'POST',
data: formData,
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (e) => {
if (onProgress && e.total) {
const percent = (e.loaded / e.total) * 100;
onProgress(percent);
}
},
});
};
/**
* 合并分片文件
* @param params 合并参数
*/
export const mergeChunk = (params: MergeChunkParams) => {
return httpInstance({
url: '/web/vedio/upload/mergeChunk',
method: 'POST',
params: params,
});
};
/**
* 普通文件上传(非分片)
* @param data FormData数据
*/
export const upload = (data: FormData) => {
return httpInstance({
url: '/web/commonFile/upload',
method: 'POST',
data,
headers: {
'Content-Type': 'multipart/form-data',
},
});
};
/**
* 文件下载
* @param fileName 文件名
*/
export const downloadFile = (fileName: string) => {
return httpInstance({
url: '/web/commonFile/download',
method: 'POST',
params: { fileName },
responseType: 'blob',
});
};
大文件处理工具:
TypeScript
// src/utils/bigFileUpload.ts
import SparkMD5 from 'spark-md5';
// 导入请求函数 + 类型(注意:type 关键字仅导入类型)
import {
checkFileExists,
checkChunkExists,
uploadChunk,
mergeChunk
} from '@/api/bigFileApi';
import type { MergeChunkParams, ProgressCallback } from '@/types/upload';
/**
* 大文件分片上传核心工具类
* 依赖 bigFileApi 中的请求函数,专注于上传逻辑封装
*/
export class BigFileUploader {
// 分片大小(5MB,适配MinIO合并要求)
private chunkSize: number = 5 * 1024 * 1024;
// 待上传文件
private file: File | null = null;
// 文件MD5
private fileMd5: string = '';
// 总分片数
private chunkTotal: number = 0;
// 上传进度回调
private progressCallback: ProgressCallback | null = null;
// 已上传分片索引
private uploadedChunks: number[] = [];
/**
* 构造函数
* @param file 待上传文件
* @param progressCallback 进度回调函数
*/
constructor(file: File, progressCallback?: ProgressCallback) {
this.file = file;
this.progressCallback = progressCallback;
// 初始化总分片数(向上取整)
this.chunkTotal = Math.ceil(file.size / this.chunkSize);
}
/**
* 私有方法:计算文件MD5(分片计算,避免大文件卡顿)
*/
private async calculateFileMd5(): Promise<string> {
return new Promise((resolve) => {
if (!this.file) return resolve('');
const spark = new SparkMD5.ArrayBuffer();
const reader = new FileReader();
const chunkSize = this.chunkSize;
const fileSize = this.file.size;
let offset = 0;
// 逐片读取文件计算MD5
const loadNextChunk = () => {
const end = Math.min(offset + chunkSize, fileSize);
const chunk = this.file!.slice(offset, end);
reader.readAsArrayBuffer(chunk);
offset = end;
};
reader.onload = (e) => {
spark.append(e.target!.result as ArrayBuffer);
// 触发MD5计算进度
this.progressCallback?.((offset / fileSize) * 100);
if (offset < fileSize) {
loadNextChunk();
} else {
const md5 = spark.end();
this.fileMd5 = md5;
resolve(md5);
}
};
loadNextChunk();
});
}
/**
* 私有方法:检查已上传的分片(断点续传)
*/
private async checkUploadedChunks(): Promise<void> {
if (!this.fileMd5) return;
this.uploadedChunks = [];
// 遍历所有分片,检查是否已上传
for (let i = 0; i < this.chunkTotal; i++) {
const res = await checkChunkExists(this.fileMd5, i);
if (res.data) { // 后端返回true表示已上传
this.uploadedChunks.push(i);
}
}
// 计算已上传进度并回调
const uploadedPercent = (this.uploadedChunks.length / this.chunkTotal) * 100;
this.progressCallback?.(uploadedPercent);
console.log(`[断点续传] 已上传分片数:${this.uploadedChunks.length}/${this.chunkTotal}`);
}
/**
* 私有方法:上传单个分片
* @param chunkIndex 分片索引
*/
private async uploadSingleChunk(chunkIndex: number): Promise<boolean> {
if (!this.file || !this.fileMd5) return false;
// 1. 切割分片文件
const start = chunkIndex * this.chunkSize;
const end = Math.min(start + this.chunkSize, this.file.size);
const chunk = this.file.slice(start, end);
// 2. 构建FormData
const formData = new FormData();
formData.append('file', chunk);
formData.append('fileMd5', this.fileMd5);
formData.append('chunk', chunkIndex.toString());
try {
// 3. 上传分片(监听单分片进度)
await uploadChunk(formData, (chunkProgress) => {
// 计算整体进度 = 已上传分片占比 + 当前分片上传进度占比
const basePercent = (this.uploadedChunks.length / this.chunkTotal) * 100;
const currentChunkPercent = (chunkProgress / 100) * (1 / this.chunkTotal) * 100;
this.progressCallback?.(basePercent + currentChunkPercent);
});
return true;
} catch (error) {
console.error(`[分片上传失败] 索引:${chunkIndex}`, error);
return false;
}
}
/**
* 私有方法:批量上传分片(并发控制)
* @param concurrency 并发数(默认3)
*/
private async uploadChunks(concurrency: number = 3): Promise<void> {
if (!this.fileMd5) return;
// 筛选需要上传的分片(排除已上传的)
const needUploadChunks = Array.from({ length: this.chunkTotal }, (_, i) => i)
.filter(i => !this.uploadedChunks.includes(i));
if (needUploadChunks.length === 0) {
this.progressCallback?.(100);
return;
}
// 并发上传控制(避免请求过多)
let currentIndex = 0;
let completedChunks = 0;
const uploadNext = async () => {
if (currentIndex >= needUploadChunks.length) return;
const chunkIndex = needUploadChunks[currentIndex];
currentIndex++;
// 上传单个分片(失败重试)
const uploadSuccess = await this.uploadSingleChunk(chunkIndex);
if (uploadSuccess) {
completedChunks++;
// 更新整体进度
const totalPercent = ((this.uploadedChunks.length + completedChunks) / this.chunkTotal) * 100;
this.progressCallback?.(totalPercent);
} else {
// 失败后重新入队(可限制重试次数)
currentIndex--;
console.log(`[分片重试] 索引:${chunkIndex}`);
}
// 继续上传下一个
await uploadNext();
};
// 启动指定数量的并发任务
const tasks = Array.from({ length: concurrency }, uploadNext);
await Promise.all(tasks);
}
/**
* 私有方法:合并分片文件
*/
private async mergeFileChunks(): Promise<boolean> {
if (!this.fileMd5 || !this.file) return false;
const mergeParams: MergeChunkParams = {
fileMd5: this.fileMd5,
fileName: this.file.name,
chunkTotal: this.chunkTotal,
};
try {
const res = await mergeChunk(mergeParams);
return res.data;
} catch (error) {
console.error('[分片合并失败]', error);
return false;
}
}
/**
* 核心方法:执行完整上传流程
* 流程:计算MD5 → 秒传校验 → 断点续传校验 → 上传分片 → 合并分片
*/
public async upload(): Promise<boolean> {
if (!this.file) {
console.error('[上传失败] 文件为空');
return false;
}
try {
// 1. 初始化进度
this.progressCallback?.(0);
// 2. 计算文件MD5
console.log('[开始计算MD5] 大文件MD5计算中...');
await this.calculateFileMd5();
console.log(`[MD5计算完成] ${this.fileMd5}`);
// 3. 秒传校验(文件已存在则直接返回成功)
const fileCheckRes = await checkFileExists(this.fileMd5);
if (fileCheckRes.data) {
this.progressCallback?.(100);
console.log('[秒传成功] 文件已存在,无需上传');
return true;
}
// 4. 断点续传校验(检查已上传分片)
await this.checkUploadedChunks();
// 5. 上传剩余分片
console.log('[开始上传分片] 共需上传:', this.chunkTotal - this.uploadedChunks.length, '个');
await this.uploadChunks(3);
// 6. 合并分片
console.log('[开始合并分片]');
const mergeSuccess = await this.mergeFileChunks();
if (mergeSuccess) {
this.progressCallback?.(100);
console.log('[上传完成] 文件上传并合并成功');
return true;
} else {
console.error('[上传失败] 分片合并失败');
return false;
}
} catch (error) {
console.error('[上传异常]', error);
return false;
}
}
}
3.3.3 测试页面
html
<template>
<div class="upload-container">
<h3>大文件分片上传(Vue3 + TS)</h3>
<!-- 文件选择 -->
<input
type="file"
ref="fileInput"
@change="handleFileSelect"
class="file-input"
/>
<!-- 上传按钮 -->
<button
@click="handleUpload"
:disabled="!selectedFile || isUploading"
class="upload-btn"
>
{{ isUploading ? '上传中...' : '开始上传' }}
</button>
<!-- 进度条 -->
<div class="progress-container" v-if="selectedFile">
<div class="progress-bar" :style="{ width: `${uploadPercent}%` }"></div>
<span class="progress-text">{{ uploadPercent.toFixed(2) }}%</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BigFileUploader } from '@/utils/bigFileUpload';
// 响应式数据
const fileInput = ref<HTMLInputElement | null>(null);
const selectedFile = ref<File | null>(null);
const isUploading = ref<boolean>(false);
const uploadPercent = ref<number>(0);
/**
* 选择文件回调
*/
const handleFileSelect = (e: Event) => {
const target = e.target as HTMLInputElement;
if (target.files && target.files[0]) {
selectedFile.value = target.files[0];
uploadPercent.value = 0; // 重置进度
}
};
/**
* 执行上传
*/
const handleUpload = async () => {
if (!selectedFile.value) return;
isUploading.value = true;
try {
// 实例化上传工具,传入进度回调
const uploader = new BigFileUploader(
selectedFile.value,
(percent) => {
uploadPercent.value = percent;
}
);
// 执行上传
const success = await uploader.upload();
if (success) {
alert('文件上传成功!');
} else {
alert('文件上传失败!');
}
} catch (error) {
console.error('[页面上传异常]', error);
alert('上传出错,请重试!');
} finally {
isUploading.value = false;
// 清空文件选择
if (fileInput.value) fileInput.value.value = '';
selectedFile.value = null;
}
};
</script>
<style scoped>
.upload-container {
width: 600px;
margin: 50px auto;
padding: 20px;
border: 1px solid #eee;
border-radius: 8px;
}
.file-input {
margin-bottom: 20px;
padding: 8px;
width: 100%;
}
.upload-btn {
padding: 10px 24px;
background: #409eff;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.upload-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.progress-container {
margin-top: 20px;
height: 24px;
border: 1px solid #e6e6e6;
border-radius: 12px;
overflow: hidden;
position: relative;
}
.progress-bar {
height: 100%;
background: #409eff;
transition: width 0.3s ease;
}
.progress-text {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
font-size: 12px;
color: #333;
line-height: 24px;
}
</style>


4.大文件下载案例演示
分片下载核心是 「前端拆范围、后端读片段、前端合文件」,完全复用上传阶段的 MD5 / 文件路径规则,解决大文件单次下载超时、内存溢出、中断后重传的问题。
4.1 整体思路
后端设计:

前端设计:

4.2 后端实现
controller:
java
/**
* 大文件分片下载接口(支持Range字节范围请求)
* @param fileMd5 文件MD5(定位MinIO文件)
* @param fileName 文件名(含扩展名,用于拼接路径)
* @param request 获取Range请求头
* @param response 返回分片流+响应头
*/
@PostMapping("/download/largeFile")
@Operation(summary = "大文件分片下载")
public void downloadLargeFile(@RequestParam("fileMd5") String fileMd5,
@RequestParam("fileName") String fileName,
HttpServletRequest request,
HttpServletResponse response) {
try {
// 调用服务层分片下载逻辑
vedioService.downloadLargeFile(fileMd5, fileName, request, response);
} catch (Exception e) {
log.error("大文件分片下载失败,fileMd5:{}", fileMd5, e);
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
}
}
/**
* 辅助接口:获取文件总大小(前端初始化分片下载时调用)
*/
@PostMapping("/download/getFileSize")
@Operation(summary = "获取文件总大小")
public Result<Long> getFileSize(@RequestParam("fileMd5") String fileMd5,
@RequestParam("fileName") String fileName) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException {
Long res=vedioService.getFileSize(fileMd5, fileName);
return Result.success(res);
}
Service:
java
/**
* 下载大文件
* @param fileMd5
* @param fileName
* @param request
* @param response
*/
void downloadLargeFile(String fileMd5, String fileName, HttpServletRequest request, HttpServletResponse response) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException;
/**
* 获取文件大小
* @param fileMd5
* @param fileName
*/
Long getFileSize(String fileMd5, String fileName) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException;
java
/**
* 大文件分片下载核心逻辑
* 支持Range请求,返回指定字节范围的文件流
*/
public void downloadLargeFile(String fileMd5, String fileName, HttpServletRequest request, HttpServletResponse response) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException {
// 1. 构建MinIO中完整文件路径(复用现有路径规则)
String extName = fileName.substring(fileName.lastIndexOf("."));
String mergeFilePath = getFilePathByMd5(fileMd5, extName);
String bucket = minIoProperties.getBucketName();
// 2. 获取文件元信息(总大小、Content-Type)
StatObjectArgs statArgs = StatObjectArgs.builder()
.bucket(bucket)
.object(mergeFilePath)
.build();
StatObjectResponse statResponse = minioClient.statObject(statArgs);
long fileTotalSize = statResponse.size();
String contentType = statResponse.contentType();
// 3. 解析前端Range请求头(格式:Range: bytes=0-4999999)
String rangeHeader = request.getHeader("Range");
long start = 0;
long end = fileTotalSize - 1;
if (rangeHeader != null && rangeHeader.startsWith("bytes=")) {
// 拆分Range参数
String[] rangeParts = rangeHeader.replace("bytes=", "").split("-");
start = Long.parseLong(rangeParts[0]);
// 处理结束字节:前端传了则用前端值,否则取文件末尾
if (rangeParts.length > 1 && !rangeParts[1].isEmpty()) {
end = Long.parseLong(rangeParts[1]);
}
// 校验Range有效性
if (start < 0 || end >= fileTotalSize || start > end) {
response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
response.setHeader("Content-Range", "bytes */" + fileTotalSize);
return;
}
// 响应部分内容(206)
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
response.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + fileTotalSize);
}
// 4. 设置下载响应头
response.setHeader("Content-Type", contentType);
response.setHeader("Content-Length", String.valueOf(end - start + 1)); // 当前分片大小
response.setHeader("Accept-Ranges", "bytes"); // 告知前端支持分片下载
// 触发浏览器下载(指定文件名,解决中文乱码)
response.setHeader("Content-Disposition", "attachment; filename=\"" +
URLEncoder.encode(fileName, StandardCharsets.UTF_8.toString()) + "\"");
response.setHeader("Cache-Control", "no-cache");
// 5. 从MinIO读取指定字节范围的文件流并返回(核心修正:兼容所有SDK版本)
InputStream stream = null;
InputStream fullStream = null;
try {
// 第一步:获取完整文件流(放弃SDK的extraHeader,手动处理Range)
GetObjectArgs getArgs = GetObjectArgs.builder()
.bucket(bucket)
.object(mergeFilePath)
.build();
fullStream = minioClient.getObject(getArgs);
// 第二步:手动截取指定字节范围的流(跳过start字节,读取end-start+1字节)
// 方式1:适合中小文件(<1GB),简单直接
byte[] fullBytes = IOUtils.toByteArray(fullStream);
byte[] rangeBytes = new byte[(int) (end - start + 1)];
System.arraycopy(fullBytes, (int) start, rangeBytes, 0, rangeBytes.length);
stream = new ByteArrayInputStream(rangeBytes);
// 6. 流式返回(避免内存溢出)
IOUtils.copy(stream, response.getOutputStream());
response.getOutputStream().flush();
} finally {
// 兜底关闭流,防止资源泄漏
if (stream != null) {
stream.close();
}
if (fullStream != null) {
fullStream.close();
}
}
}
/**
*获取文件大小
* @throws InternalException
*/
public Long getFileSize(String fileMd5, String fileName) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException {
String extName = fileName.substring(fileName.lastIndexOf("."));
String mergeFilePath = getFilePathByMd5(fileMd5, extName);
StatObjectResponse statResponse = minioClient.statObject(StatObjectArgs.builder()
.bucket(minIoProperties.getBucketName())
.object(mergeFilePath)
.build());
return statResponse.size();
}
4.3 前端实现
请求函数:
java
/**
* 获取文件总大小(分片下载前置)
* @param fileMd5 文件MD5
* @param fileName 文件名
*/
export const getFileSize = (fileMd5: string, fileName: string) => {
return httpInstance({
url: '/web/vedio/download/getFileSize',
method: 'POST',
params: { fileMd5, fileName },
});
};
/**
* 分片下载文件片段
* @param fileMd5 文件MD5
* @param fileName 文件名
* @param start 起始字节
* @param end 结束字节
* @param onProgress 分片下载进度回调
*/
export const downloadFileChunk = (
fileMd5: string,
fileName: string,
start: number,
end: number,
onProgress?: (progress: number) => void
) => {
return httpInstance({
url: '/web/vedio/download/largeFile',
method: 'POST',
params: { fileMd5, fileName },
headers: {
'Range': `bytes=${start}-${end}`, // 核心:指定字节范围
},
responseType: 'blob', // 接收二进制流
onDownloadProgress: (e) => {
if (onProgress && e.total) {
const percent = (e.loaded / e.total) * 100;
onProgress(percent);
}
},
});
};
大文件下载工具:
java
import { getFileSize, downloadFileChunk } from '@/api/bigFileApi';
/**
* 大文件分片下载工具类
* 适配现有上传逻辑的MD5/文件名规则
*/
export class BigFileDownloader {
// 分片大小(5MB,与上传分片大小一致)
private chunkSize: number = 5 * 1024 * 1024;
// 文件MD5
private fileMd5: string = '';
// 文件名(含扩展名)
private fileName: string = '';
// 文件总大小
private fileTotalSize: number = 0;
// 总分片数
private chunkTotal: number = 0;
// 下载进度回调
private progressCallback: ((percent: number) => void) | null = null;
// 已下载的分片二进制数据
private downloadedChunks: Blob[] = [];
/**
* 构造函数
* @param fileMd5 文件MD5
* @param fileName 文件名
* @param progressCallback 进度回调
*/
constructor(fileMd5: string, fileName: string, progressCallback?: (percent: number) => void) {
this.fileMd5 = fileMd5;
this.fileName = fileName;
this.progressCallback = progressCallback;
}
/**
* 初始化:获取文件总大小,计算总分片数
*/
private async init(): Promise<boolean> {
try {
const res = await getFileSize(this.fileMd5, this.fileName);
this.fileTotalSize = res.data;
this.chunkTotal = Math.ceil(this.fileTotalSize / this.chunkSize);
this.progressCallback?.(0);
return true;
} catch (error) {
console.error('[下载初始化失败] 获取文件大小失败', error);
return false;
}
}
/**
* 下载单个分片
* @param chunkIndex 分片索引
*/
private async downloadSingleChunk(chunkIndex: number): Promise<boolean> {
const start = chunkIndex * this.chunkSize;
const end = Math.min(start + this.chunkSize - 1, this.fileTotalSize - 1);
try {
const res = await downloadFileChunk(
this.fileMd5,
this.fileName,
start,
end,
(chunkProgress) => {
// 计算整体进度:已下载分片占比 + 当前分片下载进度占比
const basePercent = (this.downloadedChunks.length / this.chunkTotal) * 100;
const currentChunkPercent = (chunkProgress / 100) * (1 / this.chunkTotal) * 100;
this.progressCallback?.(basePercent + currentChunkPercent);
}
);
// 保存分片二进制数据
this.downloadedChunks[chunkIndex] = res.data;
return true;
} catch (error) {
console.error(`[分片下载失败] 索引:${chunkIndex}`, error);
return false;
}
}
/**
* 并发下载分片(控制并发数)
* @param concurrency 并发数(默认3)
*/
private async downloadChunks(concurrency: number = 3): Promise<boolean> {
// 生成所有分片索引
const chunkIndexes = Array.from({ length: this.chunkTotal }, (_, i) => i);
let currentIndex = 0;
let completedCount = 0;
// 递归下载单个分片(失败重试)
const downloadNext = async () => {
if (currentIndex >= chunkIndexes.length) return;
const index = chunkIndexes[currentIndex];
currentIndex++;
// 重试3次
let retryCount = 3;
let success = false;
while (retryCount > 0 && !success) {
success = await this.downloadSingleChunk(index);
if (!success) {
retryCount--;
console.log(`[分片重试] 索引:${index},剩余重试次数:${retryCount}`);
}
}
if (success) {
completedCount++;
// 更新整体进度
const totalPercent = (completedCount / this.chunkTotal) * 100;
this.progressCallback?.(totalPercent);
} else {
throw new Error(`分片${index}下载失败,已达最大重试次数`);
}
await downloadNext();
};
// 启动并发任务
const tasks = Array.from({ length: concurrency }, downloadNext);
try {
await Promise.all(tasks);
return true;
} catch (error) {
console.error('[分片下载异常]', error);
return false;
}
}
/**
* 合并分片并触发下载
*/
private mergeAndDownload(): void {
// 合并所有分片为完整Blob
const completeBlob = new Blob(this.downloadedChunks, {
type: this.getFileType(this.fileName),
});
// 创建下载链接
const downloadUrl = URL.createObjectURL(completeBlob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = this.fileName; // 指定下载文件名
a.click();
// 释放内存
URL.revokeObjectURL(downloadUrl);
this.progressCallback?.(100);
console.log('[下载完成] 文件已合并并触发下载');
}
/**
* 辅助:根据文件名获取文件类型
*/
private getFileType(fileName: string): string {
const ext = fileName.substring(fileName.lastIndexOf('.')).toLowerCase();
const typeMap: Record<string, string> = {
'.mp4': 'video/mp4',
'.avi': 'video/x-msvideo',
'.mov': 'video/quicktime',
'.pdf': 'application/pdf',
'.zip': 'application/zip',
'.txt': 'text/plain',
'.jpg': 'image/jpeg',
'.png': 'image/png',
};
return typeMap[ext] || 'application/octet-stream';
}
/**
* 核心方法:执行完整分片下载流程
*/
public async download(): Promise<boolean> {
try {
// 1. 初始化(获取文件大小)
const initSuccess = await this.init();
if (!initSuccess) return false;
// 2. 下载所有分片
const downloadSuccess = await this.downloadChunks(3);
if (!downloadSuccess) return false;
// 3. 合并并触发下载
this.mergeAndDownload();
return true;
} catch (error) {
console.error('[下载流程异常]', error);
return false;
}
}
}
测试页面:
html
<!--大文件下载-->
<template>
<div class="download-container">
<h3>大文件分片下载</h3>
<!-- 假设已获取文件MD5和文件名 -->
<button @click="handleDownload" :disabled="isDownloading">
{{ isDownloading ? '下载中...' : '开始下载' }}
</button>
<div class="progress-container" v-if="isDownloading">
<div class="progress-bar" :style="{ width: `${downloadPercent}%` }"></div>
<span class="progress-text">{{ downloadPercent.toFixed(2) }}%</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BigFileDownloader } from '@/utils/BigFileDownloader.ts';
// 模拟已上传文件的MD5和文件名(实际从后端获取)
const fileMd5 = '6a8141f0af53be5d54610499e7c696d1'; // 替换为实际MD5
const fileName = '6a8141f0af53be5d54610499e7c696d1.zip'; // 替换为实际文件名
// 响应式数据
const isDownloading = ref(false);
const downloadPercent = ref(0);
/**
* 执行分片下载
*/
const handleDownload = async () => {
isDownloading.value = true;
downloadPercent.value = 0;
// 实例化下载工具类
const downloader = new BigFileDownloader(
fileMd5,
fileName,
(percent) => {
downloadPercent.value = percent;
}
);
try {
const success = await downloader.download();
if (!success) {
alert('文件下载失败!');
} else {
alert('文件下载成功!');
}
} catch (error) {
console.error('[页面下载异常]', error);
alert('下载出错,请重试!');
} finally {
isDownloading.value = false;
}
};
</script>
<style scoped>
.download-container {
width: 600px;
margin: 50px auto;
padding: 20px;
border: 1px solid #eee;
border-radius: 8px;
}
button {
padding: 10px 24px;
background: #409eff;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
.progress-container {
margin-top: 20px;
height: 24px;
border: 1px solid #e6e6e6;
border-radius: 12px;
overflow: hidden;
position: relative;
}
.progress-bar {
height: 100%;
background: #409eff;
transition: width 0.3s ease;
}
.progress-text {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
font-size: 12px;
color: #333;
line-height: 24px;
}
</style>
