Springboot实现断点续传、分片下载
原理
断点续传:指的是在上传/下载时,将任务(一个文件或压缩包)人为的划分为几个部分,每一个部分采用一个线程进行上传/下载。如果碰到网络故障,可以从已经上传/下载的部分开始继续上传/下载未完成的部分,而没有必要从头开始上传/下载。可以节省时间,提高速度。
合并:下载时通过线程池创建任务进行下载或上传、当判断最后一个分片传完时,调用合并方法,根据之前定义的文件名称顺序进行合并,肯能出现最后一个分片传完,之前分片未传完的情况,需要使用for循环进行判断,多文件未传输完,则等待一会继续判断。
本文将详细介绍断点续传、分片下载的前后端完整代码(前端代码需完善,后端逻辑可直接用)
目录结构
一、后端代码
项目依赖
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.3.1</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.1.0</version>
</dependency>
</dependencies>
application.yml配置
file:
upload:
dir: D:/tmp
spring:
servlet:
multipart:
enabled: true
max-file-size: 100MB
max-request-size: 100MB
file-size-threshold: 2KB
实体类
FileChunk
import lombok.Data;
@Data
public class FileChunk {
/**
* 当前文件块,从1开始
*/
private Integer chunkNumber;
/**
* 分块大小
*/
private Long chunkSize;
/**
* 当前分块大小
*/
private Long currentChunkSize;
/**
* 总大小
*/
private Long totalSize;
/**
* 文件标识
*/
private String identifier;
/**
* 文件名
*/
private String filename;
/**
* 相对路径
*/
private String relativePath;
/**
* 总块数
*/
private Integer totalChunks;
}
响应类
WebResponse
public class WebResponse {
private boolean success;
private String message;
private Object data;
public WebResponse(boolean success, String message, Object data) {
this.success = success;
this.message = message;
this.data = data;
}
public static WebResponse success(String message, Object data) {
return new WebResponse(true, message, data);
}
public static WebResponse success(String message) {
return new WebResponse(true, message, null);
}
public static WebResponse error(String message) {
return new WebResponse(false, message, null);
}
public boolean isSuccess() {
return success;
}
public void setSuccess(boolean success) {
this.success = success;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public Object getData() {
return data;
}
public void setData(Object data) {
this.data = data;
}
}
断点续传
FileUploadController
import com.dreampen.dto.FileChunk;
import com.dreampen.response.WebResponse;
import com.dreampen.service.FileUploadService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("/api/upload")
@CrossOrigin // 允许跨域请求
public class FileUploadController {
@Autowired
private FileUploadService fileUploadService;
/**
* 检查文件或文件块是否已存在
*/
@GetMapping("/check")
public ResponseEntity<Void> checkFileExists(FileChunk chunk) {
boolean exists = fileUploadService.checkFileExists(chunk);
if (exists) {
// 分片存在,返回 200
return ResponseEntity.ok().build();
} else {
// 分片不存在,返回 404
return ResponseEntity.notFound().build();
}
}
/**
* 上传文件块
*/
@PostMapping("/chunk")
public WebResponse uploadChunk(
@RequestParam(value = "chunkNumber") Integer chunkNumber,
@RequestParam(value = "chunkSize") Long chunkSize,
@RequestParam(value = "currentChunkSize") Long currentChunkSize,
@RequestParam(value = "totalSize") Long totalSize,
@RequestParam(value = "identifier") String identifier,
@RequestParam(value = "filename") String filename,
@RequestParam(value = "totalChunks") Integer totalChunks,
@RequestParam("file") MultipartFile file) {
String identifierName = identifier;
// 如果 identifierName 包含逗号,取第一个值
if (identifierName.contains(",")) {
identifierName = identifierName.split(",")[0];
}
FileChunk chunk = new FileChunk();
chunk.setChunkNumber(chunkNumber);
chunk.setChunkSize(chunkSize);
chunk.setCurrentChunkSize(currentChunkSize);
chunk.setTotalSize(totalSize);
chunk.setIdentifier(identifierName);
chunk.setFilename(filename);
chunk.setTotalChunks(totalChunks);
return fileUploadService.uploadChunk(chunk, file);
}
/**
* 合并文件块
*/
@PostMapping("/merge")
public WebResponse mergeChunks(
@RequestParam("identifier") String identifier,
@RequestParam("filename") String filename,
@RequestParam("totalChunks") Integer totalChunks) {
return fileUploadService.mergeChunks(identifier, filename, totalChunks);
}
}
FileUploadService
import cn.hutool.core.io.FileUtil;
import com.dreampen.dto.FileChunk;
import com.dreampen.response.WebResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;
@Service
public class FileUploadService {
private static final Logger logger = LoggerFactory.getLogger(FileUploadService.class);
@Value("${file.upload.dir}")
private String uploadDir;
/**
* 检查文件是否已上传过
*/
public boolean checkFileExists(FileChunk chunk) {
String storeChunkPath = uploadDir + File.separator + "chunks" + File.separator + chunk.getIdentifier() + File.separator + chunk.getChunkNumber();
File storeChunk = new File(storeChunkPath);
return storeChunk.exists() && chunk.getChunkSize() == storeChunk.length();
}
/**
* 上传文件块
*/
public WebResponse uploadChunk(FileChunk chunk, MultipartFile file) {
try {
if (file.isEmpty()) {
return WebResponse.error("文件块为空");
}
// 创建块文件目录
String chunkDirPath = uploadDir + File.separator + "chunks" + File.separator + chunk.getIdentifier();
File chunkDir = new File(chunkDirPath);
if (!chunkDir.exists()) {
chunkDir.mkdirs();
}
// 保存分块
String chunkPath = chunkDirPath + File.separator + chunk.getChunkNumber();
file.transferTo(new File(chunkPath));
return WebResponse.success("文件块上传成功");
} catch (IOException e) {
logger.error(e.getMessage(),e);
return WebResponse.error("文件块上传失败: " + e.getMessage());
}
}
/**
* 合并文件块
*/
public WebResponse mergeChunks(String identifier, String filename, Integer totalChunks) {
try {
String chunkDirPath = uploadDir + File.separator + "chunks" + File.separator + identifier;
if(!FileUtil.exist(chunkDirPath)){
return WebResponse.error("文件合并失败, 目录不存在" );
}
File chunkDir = new File(chunkDirPath);
// 创建目标文件
String filePath = uploadDir + File.separator + filename;
File destFile = new File(filePath);
if (destFile.exists()) {
destFile.delete();
}
// 使用RandomAccessFile合并文件块
try (RandomAccessFile randomAccessFile = new RandomAccessFile(destFile, "rw")) {
byte[] buffer = new byte[1024];
for (int i = 1; i <= totalChunks; i++) {
File chunk = new File(chunkDirPath + File.separator + i);
if (!chunk.exists()) {
return WebResponse.error("文件块" + i + "不存在");
}
try (java.io.FileInputStream fis = new java.io.FileInputStream(chunk)) {
int len;
while ((len = fis.read(buffer)) != -1) {
randomAccessFile.write(buffer, 0, len);
}
}
}
}
// 清理临时文件块
FileUtil.del(chunkDir);
return WebResponse.success("文件合并成功", filePath);
} catch (IOException e) {
logger.error(e.getMessage(),e);
return WebResponse.error("文件合并失败: " + e.getMessage());
}
}
}
分片下载
FileDownloadController
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/download")
@CrossOrigin
public class FileDownloadController {
@Value("${file.upload.dir}")
private String uploadDir;
/**
* 获取文件列表
*/
@GetMapping("/files")
public ResponseEntity<List<Map<String, Object>>> getFileList() {
File dir = new File(uploadDir);
if (!dir.exists() || !dir.isDirectory()) {
return ResponseEntity.ok(new ArrayList<>());
}
List<Map<String, Object>> fileList = new ArrayList<>();
File[] files = dir.listFiles((d, name) -> !name.equals("chunks"));
if (files != null) {
for (File file : files) {
if (file.isFile()) {
Map<String, Object> fileInfo = new HashMap<>();
fileInfo.put("fileName", file.getName());
fileInfo.put("size", file.length());
fileList.add(fileInfo);
}
}
}
return ResponseEntity.ok(fileList);
}
/**
* 分片下载文件
*/
@GetMapping("/chunk/{fileName}")
public ResponseEntity<byte[]> downloadChunk(
@PathVariable String fileName,
@RequestHeader(value = "Range", required = false) String rangeHeader
) {
File file = new File(uploadDir + File.separator + fileName);
if (!file.exists()) {
return ResponseEntity.notFound().build();
}
try {
long fileLength = file.length();
long start = 0;
long end = fileLength - 1;
// 解析Range头
if (rangeHeader != null && rangeHeader.startsWith("bytes=")) {
String[] ranges = rangeHeader.substring("bytes=".length()).split("-");
start = Long.parseLong(ranges[0]);
if (ranges.length > 1 && !ranges[1].isEmpty()) {
end = Long.parseLong(ranges[1]);
}
}
// 边界检查
if (start < 0 || end >= fileLength || start > end) {
return ResponseEntity.status(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE).build();
}
long contentLength = end - start + 1;
// 读取指定范围的文件内容
byte[] content = new byte[(int) contentLength];
try (RandomAccessFile randomAccessFile = new RandomAccessFile(file, "r")) {
randomAccessFile.seek(start);
randomAccessFile.read(content);
}
HttpHeaders headers = new HttpHeaders();
headers.add("Content-Range", String.format("bytes %d-%d/%d", start, end, fileLength));
headers.add("Accept-Ranges", "bytes");
headers.add("Content-Type", "application/octet-stream");
headers.add("Content-Length", String.valueOf(contentLength));
headers.add("Content-Disposition", "attachment; filename=\"" + fileName + "\"");
return new ResponseEntity<>(content, headers,
rangeHeader != null ? HttpStatus.PARTIAL_CONTENT : HttpStatus.OK);
} catch (IOException e) {
e.printStackTrace();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
}
前端代码
index.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title></title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
.upload-container, .download-container {
margin-top: 30px;
padding: 20px;
border: 1px solid #ddd;
border-radius: 5px;
}
.progress {
margin-top: 10px;
height: 25px;
}
.file-list {
margin-top: 20px;
}
.file-item {
padding: 10px;
margin-bottom: 5px;
border: 1px solid #eee;
border-radius: 5px;
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
</head>
<body>
<div class="container">
<!-- 上传区域 -->
<div class="upload-container">
<h3>文件上传(支持断点续传)</h3>
<div class="mb-3">
<label for="fileUpload" class="form-label">选择文件</label>
<input class="form-control" type="file" id="fileUpload">
</div>
<button id="uploadBtn" class="btn btn-primary">上传文件</button>
<div class="progress d-none" id="uploadProgress">
<div class="progress-bar" role="progressbar" style="width: 0%;" id="uploadProgressBar">0%</div>
</div>
<div id="uploadStatus" class="mt-2"></div>
</div>
<div class="download-container">
<h3>文件列表</h3>
<button id="refreshBtn" class="btn btn-secondary mb-3">刷新文件列表</button>
<div id="fileList" class="file-list">
<div class="alert alert-info">点击刷新按钮获取文件列表</div>
</div>
<div class="progress d-none" id="downloadProgress">
<div class="progress-bar" role="progressbar" style="width: 0%;" id="downloadProgressBar">0%</div>
</div>
<button id="pauseBtn" class="btn btn-warning mt-2 d-none" onclick="toggleDownload()">暂停</button>
<div id="downloadStatus" class="mt-2"></div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/resumablejs@1.1.0/resumable.min.js"></script>
<script>
// 基础配置
const API_BASE_URL = 'http://localhost:8555/api';
// DOM元素
const uploadBtn = document.getElementById('uploadBtn');
const fileUpload = document.getElementById('fileUpload');
const uploadProgress = document.getElementById('uploadProgress');
const uploadProgressBar = document.getElementById('uploadProgressBar');
const uploadStatus = document.getElementById('uploadStatus');
const refreshBtn = document.getElementById('refreshBtn');
const fileList = document.getElementById('fileList');
const downloadProgress = document.getElementById('downloadProgress');
const downloadProgressBar = document.getElementById('downloadProgressBar');
const downloadStatus = document.getElementById('downloadStatus');
// 初始化resumable.js
const resumable = new Resumable({
target: `${API_BASE_URL}/upload/chunk`,
query: {},
chunkSize: 1 * 1024 * 1024, // 分片大小为1MB
simultaneousUploads: 3,
testChunks: true,
throttleProgressCallbacks: 1,
chunkNumberParameterName: 'chunkNumber',
chunkSizeParameterName: 'chunkSize',
currentChunkSizeParameterName: 'currentChunkSize',
totalSizeParameterName: 'totalSize',
identifierParameterName: 'identifier',
fileNameParameterName: 'filename',
totalChunksParameterName: 'totalChunks',
method: 'POST',
headers: {
'Accept': 'application/json'
},
testMethod: 'GET',
testTarget: `${API_BASE_URL}/upload/check`
});
// 分配事件监听器
resumable.assignBrowse(fileUpload);
// 文件添加事件 - 显示文件名
resumable.on('fileAdded', function(file) {
console.log('File added:', file);
// 显示已选择的文件名
uploadStatus.innerHTML = `<div class="alert alert-info">已选择文件: ${file.fileName} (${formatFileSize(file.size)})</div>`;
// 显示文件信息卡片
const fileInfoDiv = document.createElement('div');
fileInfoDiv.className = 'card mt-2 mb-2';
fileInfoDiv.innerHTML = `
<div class="card-body">
<h5 class="card-title">文件信息</h5>
<p class="card-text">文件名: ${file.fileName}</p>
<p class="card-text">大小: ${formatFileSize(file.size)}</p>
<p class="card-text">类型: ${file.file.type || '未知'}</p>
<p class="card-text">分片数: ${file.chunks.length}</p>
</div>
`;
// 清除旧的文件信息
const oldFileInfo = document.querySelector('.card');
if (oldFileInfo) {
oldFileInfo.remove();
}
// 插入到uploadStatus之前
uploadStatus.parentNode.insertBefore(fileInfoDiv, uploadStatus);
});
// 格式化文件大小
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// 分块开始事件
resumable.on('chunkingStart', function(file) {
console.log('开始分块:', file.fileName);
});
// 分块进度事件
resumable.on('chunkingProgress', function(file, ratio) {
console.log('分块进度:', Math.floor(ratio * 100) + '%');
});
// 分块完成事件
resumable.on('chunkingComplete', function(file) {
console.log('分块完成');
});
// 上传开始事件
resumable.on('uploadStart', function() {
console.log('开始上传');
uploadStatus.innerHTML = '<div class="alert alert-info">开始上传文件块...</div>';
window.mergeCalled = false;
});
// 上传进度事件
resumable.on('fileProgress', function(file) {
const progress = Math.floor(file.progress() * 100);
uploadProgress.classList.remove('d-none');
uploadProgressBar.style.width = `${progress}%`;
uploadProgressBar.textContent = `${progress}%`;
// 显示当前上传块信息
const uploadedChunks = file.chunks.filter(chunk => chunk.status === 2).length;
const totalChunks = file.chunks.length;
uploadStatus.innerHTML = `<div class="alert alert-info">正在上传: ${uploadedChunks}/${totalChunks} 块 (${progress}%)</div>`;
});
// 总体进度事件
resumable.on('progress', function() {
console.log('总体进度:', Math.floor(resumable.progress() * 100) + '%');
});
// 上传成功事件
resumable.on('fileSuccess', function(file, response) {
console.log('文件上传成功,准备合并');
const parsedResponse = JSON.parse(response);
if (parsedResponse.success) {
// 避免重复调用合并接口
if (window.mergeCalled) {
console.log('合并已经调用过,跳过');
return;
}
window.mergeCalled = true;
uploadStatus.innerHTML = '<div class="alert alert-info">所有分块上传成功,正在合并文件...</div>';
// 使用FormData发送合并请求
const formData = new FormData();
formData.append('identifier', file.uniqueIdentifier);
formData.append('filename', file.fileName);
formData.append('totalChunks', file.chunks.length);
axios.post(`${API_BASE_URL}/upload/merge`, formData)
.then(function(response) {
if (response.data.success) {
uploadStatus.innerHTML = `<div class="alert alert-success">文件上传并合并成功!</div>`;
// 刷新文件列表
refreshFileList();
} else {
uploadStatus.innerHTML = `<div class="alert alert-danger">文件合并失败: ${response.data.message}</div>`;
}
})
.catch(function(error) {
uploadStatus.innerHTML = `<div class="alert alert-danger">合并请求出错: ${error.message}</div>`;
});
} else {
uploadStatus.innerHTML = `<div class="alert alert-danger">上传失败: ${parsedResponse.message}</div>`;
}
});
// 块上传错误事件
resumable.on('chunkUploadError', function(file, chunk, message) {
console.error('块上传错误:', chunk.offset, message);
uploadStatus.innerHTML = `<div class="alert alert-warning">块 ${chunk.offset+1}/${file.chunks.length} 上传失败,系统将重试</div>`;
});
// 上传错误事件
resumable.on('fileError', function(file, response) {
try {
const parsedResponse = JSON.parse(response);
uploadStatus.innerHTML = `<div class="alert alert-danger">上传错误: ${parsedResponse.message || '未知错误'}</div>`;
} catch (e) {
uploadStatus.innerHTML = `<div class="alert alert-danger">上传错误: ${response || '未知错误'}</div>`;
}
});
// 点击上传按钮事件
uploadBtn.addEventListener('click', function() {
if (!resumable.files.length) {
uploadStatus.innerHTML = '<div class="alert alert-warning">请先选择文件!</div>';
return;
}
uploadStatus.innerHTML = '<div class="alert alert-info">开始上传...</div>';
resumable.upload();
});
// 获取文件列表
function refreshFileList() {
axios.get(`${API_BASE_URL}/download/files`)
.then(function(response) {
if (response.data.length > 0) {
let html = '';
response.data.forEach(function(file) {
html += `
<div class="file-item">
<span>${file.fileName} (${formatFileSize(file.size)})</span>
<div>
<button class="btn btn-primary btn-sm" onclick="startDownload('${file.fileName}')" id="downloadBtn-${file.fileName}">下载</button>
<button class="btn btn-warning btn-sm d-none" onclick="toggleDownload()" id="pauseBtn">暂停</button>
</div>
</div>
`;
});
fileList.innerHTML = html;
} else {
fileList.innerHTML = '<div class="alert alert-info">没有文件</div>';
}
})
.catch(function(error) {
fileList.innerHTML = `<div class="alert alert-danger">获取文件列表失败: ${error.message}</div>`;
});
}
// 全局变量用于控制下载状态
let isDownloading = false;
let downloadedChunks = [];
let currentFileName = '';
let currentStart = 0;
let currentTotalSize = 0;
// 切换下载状态
function toggleDownload() {
const pauseBtn = document.getElementById('pauseBtn');
if (isDownloading) {
isDownloading = false;
pauseBtn.textContent = '继续';
downloadStatus.innerHTML = '<div class="alert alert-warning">下载已暂停</div>';
} else {
isDownloading = true;
pauseBtn.textContent = '暂停';
if (currentFileName) {
continueDownload();
}
}
}
// 继续下载
async function continueDownload() {
if (!currentFileName || !isDownloading) return;
downloadStatus.innerHTML = '<div class="alert alert-info">继续下载文件...</div>';
await startDownload(currentFileName, true);
}
// 分片下载文件
async function startDownload(fileName, isContinue = false) {
// 检查是否正在下载
if (isDownloading && !isContinue) {
downloadStatus.innerHTML = '<div class="alert alert-warning">文件正在下载中,请等待当前下载完成...</div>';
return;
}
// 禁用当前文件的下载按钮
const downloadBtn = document.getElementById(`downloadBtn-${fileName}`);
if (downloadBtn) {
downloadBtn.disabled = true;
downloadBtn.classList.add('disabled');
}
const chunkSize = 1024 * 1024; // 1MB per chunk
let downloadedSize = 0;
try {
// 重置下载状态
if (!isContinue) {
currentFileName = fileName;
currentStart = 0;
downloadedChunks = [];
isDownloading = true;
// 获取文件大小
const response = await axios.get(`${API_BASE_URL}/download/chunk/${fileName}`);
currentTotalSize = parseInt(response.headers['content-length']);
}
downloadProgress.classList.remove('d-none');
document.getElementById('pauseBtn').classList.remove('d-none');
document.getElementById('pauseBtn').textContent = '暂停';
// 计算已下载大小
downloadedSize = downloadedChunks.reduce((acc, chunk) => acc + chunk.size, 0);
while (currentStart < currentTotalSize && isDownloading) {
const end = Math.min(currentStart + chunkSize, currentTotalSize);
const chunk = await downloadChunk(fileName, currentStart, end, currentTotalSize);
downloadedChunks.push(chunk);
downloadedSize += chunk.size;
// 更新进度
const progress = Math.floor((downloadedSize / currentTotalSize) * 100);
downloadProgressBar.style.width = `${progress}%`;
downloadProgressBar.textContent = `${progress}%`;
downloadStatus.innerHTML = `<div class="alert alert-info">已下载: ${formatFileSize(downloadedSize)} / ${formatFileSize(currentTotalSize)}</div>`;
currentStart = end;
}
// 如果下载完成
if (currentStart >= currentTotalSize) {
// 合并所有分片
const blob = new Blob(downloadedChunks, { type: 'application/octet-stream' });
const url = window.URL.createObjectURL(blob);
// 创建下载链接
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
// 重置状态
downloadStatus.innerHTML = '<div class="alert alert-success">文件下载完成!</div>';
document.getElementById('pauseBtn').classList.add('d-none');
resetDownloadState();
}
} catch (error) {
downloadStatus.innerHTML = `<div class="alert alert-danger">下载失败: ${error.message}</div>`;
console.error('下载错误:', error);
resetDownloadState();
}
}
// 重置下载状态
function resetDownloadState() {
isDownloading = false;
currentFileName = '';
currentStart = 0;
currentTotalSize = 0;
downloadedChunks = [];
// 恢复所有下载按钮状态
document.querySelectorAll('[id^="downloadBtn-"]').forEach(btn => {
btn.disabled = false;
btn.classList.remove('disabled');
});
}
// 下载单个分片
async function downloadChunk(fileName, start, end, total) {
const response = await axios.get(`${API_BASE_URL}/download/chunk/${fileName}`, {
headers: {
Range: `bytes=${start}-${end-1}`
},
responseType: 'blob'
});
return response.data;
}
// 刷新按钮事件
refreshBtn.addEventListener('click', refreshFileList);
// 初始加载文件列表
document.addEventListener('DOMContentLoaded', function() {
refreshFileList();
});
</script>
</body>
</html>
核心实现原理详解
文件分片:使用Resumable.js将大文件分割成多个小块(默认1MB),每块单独上传
检查已上传部分:上传前先调用/api/upload/check检查服务器已保存的分片
断点续传流程:文件的唯一标识符由文件名和大小计算得出,服务端在临时目录下按标识符创建文件夹存储分片,上传完成后调用合并接口,服务端将分片按顺序合并
文件合并:服务端使用RandomAccessFile实现高效文件合并
分片下载:前端Header需要配置Range,来读取当前下载是从第几个切片进行下载