当前位置: 首页 > news >正文

SpringCloud+Vue实现大文件分片下载(支持开始、暂停、继续、取消)

1. 实现效果

http://localhost:8089/#/demo

开始下载
暂停

所有代码已提交至
https://github.com/SJshenjian/cloud.git与
https://github.com/SJshenjian/cloud-web.git中,欢迎star

2. 后端核心代码

@FeignClient(value = "download", contextId = "download")
@Component
@RequestMapping("/download")
public interface DownloadClient {@GetMapping("/download")@Operation(summary = "大文件下载", tags = "通用服务", security = {@SecurityRequirement(name = "token")})ResponseEntity<Object> downloadFile(@RequestHeader HttpHeaders headers, @RequestParam String fileId);
}@RestController
public class DownloadController implements DownloadClient {private final DownloadService downloadService;public DownloadController(DownloadService downloadService) {this.downloadService = downloadService;}@Overridepublic ResponseEntity<Object> downloadFile(@RequestHeader HttpHeaders headers, @RequestParam String fileId) {return downloadService.downloadFile(headers, fileId);}
}public interface DownloadService {/*** 分片下载大文件** @param headers* @param fileId* @return*/ResponseEntity<Object> downloadFile(@RequestHeader HttpHeaders headers, @RequestParam String fileId);
}@Slf4j
@Service
public class DownloadServiceImpl implements DownloadService {@Overridepublic ResponseEntity<Object> downloadFile(HttpHeaders headers, String fileId) {Path filePath = Paths.get("/home/sfxs/files/" + fileId);File file = filePath.toFile();long fileLength = file.length();// 解析 Range 头List<HttpRange> ranges = headers.getRange();if (ranges.isEmpty()) {// 计算分片 MD5String fileMD5;try {fileMD5 = Md5Utils.calculateMD5(Files.readAllBytes(filePath));} catch (Exception e) {log.error("Failed to calculate MD5 for file {}", fileId, e);return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();}return ResponseEntity.ok().header("Content-Disposition", "attachment; filename=" + file.getName()).contentLength(fileLength).header("File-MD5", fileMD5) // 添加 MD5 响应头.body(new FileSystemResource(file));}// 处理 Range 请求HttpRange range = ranges.get(0);long start = range.getRangeStart(fileLength);long end = range.getRangeEnd(fileLength);long rangeLength = end - start + 1;log.info("start: {}, end: {}", start, end);try (RandomAccessFile raf = new RandomAccessFile(file, "r");FileChannel channel = raf.getChannel()) {ByteBuffer buffer = ByteBuffer.allocate((int) rangeLength);channel.position(start);channel.read(buffer);buffer.flip();return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT).header("Content-Range", "bytes " + start + "-" + end + "/" + fileLength).header("Accept-Ranges", "bytes").contentLength(rangeLength).body(buffer.array());} catch (Exception e) {log.error("Failed to process range request for file {}", fileId, e);return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();}// 这种方式会导致原始文件大小为0
//        return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT)
//                .header("Content-Range", "bytes " + start + "-" + end + "/" + fileLength)
//                .header("Accept-Ranges", "bytes")
//                .contentLength(rangeLength)
//                .body(new ResourceRegion(new FileSystemResource(file), start, rangeLength));// 计算当前分片的MD5, 排查用
//        byte[] chunkData;
//        try (RandomAccessFile raf = new RandomAccessFile(file, "r")) {
//            raf.seek(start);
//            chunkData = new byte[(int) rangeLength];
//            raf.read(chunkData);
//            bytesToHex(chunkData);
//            String md5 = DigestUtils.md5DigestAsHex(chunkData);
//            return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT)
//                    .header("Content-Range", "bytes " + start + "-" + end + "/" + fileLength)
//                    .header("Accept-Ranges", "bytes")
//                    .header("File-MD5", md5)  // 添加MD5校验头
//                    .contentLength(rangeLength)
//                    .body(new ResourceRegion(new FileSystemResource(file), start, rangeLength));
//        } catch (FileNotFoundException e) {
//            throw new RuntimeException(e);
//        } catch (IOException e) {
//            throw new RuntimeException(e);
//        }}
}

3. 后端超时及自定义头配置

# application.yml
spring:cloud:openfeign:client:config:default:connectTimeout: 5000      # 5秒连接超时readTimeout: 30000         # 30秒读取超时download: # 下载服务专用配置connectTimeout: 30000   # 连接超时30秒readTimeout: 3600000   # 读取超时60分钟(1小时)
@Slf4j
@Component
public class AnonymousAuthenticationEntryPoint implements AuthenticationEntryPoint {@Overridepublic void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {// 允许跨域response.setHeader("Access-Control-Allow-Origin", "*");// 允许自定义请求头token(允许head跨域) 新增File-MD5response.setHeader("Access-Control-Allow-Headers", "Authorization, Role, Accept, Origin, X-Requested-With, Content-Type, Last-Modified, File-MD5");response.setHeader("Content-type", "application/json;charset=UTF-8");response.getWriter().print(JSON.toJSONString(ResponseVo.message(ResponseCode.UN_AUTHORIZED)));}
}

3. 统一返回放开

放开FileSystemResource与ResourceRegion的返回拦截

@RestControllerAdvice
@Slf4j
public class UnitedResponseAdvice implements ResponseBodyAdvice<Object> {@Overridepublic boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {return true;}@Overridepublic Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {if (body != null && !(body instanceof ResponseVo) && !(body instanceof byte[]) && !(body instanceof FileSystemResource) && !(body instanceof ResourceRegion)) {// 放行Swagger相关if (body instanceof TreeMap && ((TreeMap)body).containsKey("oauth2RedirectUrl")) {return body;}// 解决string返回异常if (body instanceof String) {return JSON.toJSONString(ResponseVo.message(ResponseCode.SUCCESS.val(), ResponseCode.SUCCESS.des(), body));}return ResponseVo.message(ResponseCode.SUCCESS.val(), ResponseCode.SUCCESS.des(), body);}if (body == null) {return JSON.toJSONString(ResponseVo.message(ResponseCode.SUCCESS.val(), ResponseCode.SUCCESS.des(), ""));}return body;}
}

4. 前端Vue组件编写

<template><div class="download-container"><el-button-group><el-button@click="startDownload":disabled="isDownloading"type="primary"icon="el-icon-download">开始下载</el-button><el-button@click="pauseDownload":disabled="!isDownloading || isPaused"icon="el-icon-video-pause">暂停</el-button><el-button@click="resumeDownload":disabled="!isPaused"icon="el-icon-caret-right">继续</el-button><el-button@click="cancelDownload":disabled="!isDownloading"type="danger"icon="el-icon-close">取消</el-button></el-button-group><el-progress:percentage="progressPercent":status="progressStatus":stroke-width="16"class="progress-bar"/><div class="download-info"><span v-if="speed">下载速度: {{ speed }} MB/s</span><span>已下载: {{ formatFileSize(downloadedSize) }} / {{ formatFileSize(totalSize) }}</span></div></div>
</template><script>
import baseUrl from "../../util/baseUrl";
import store from "../../store";
import {md5} from "js-md5";export default {name: "DownloadFile",props: {fileId: {type: String,required: true}},data() {return {chunkSize: 5 * 1024 * 1024, // 5MB 分片maxRetries: 3,isDownloading: false,isPaused: false,progressPercent: 0,progressStatus: '',speed: '',downloadedSize: 0,totalSize: 0,controller: null,retryCount: 0,chunks: [],downloadStartTime: null,fileName: '',serverFileMD5: '' // 存储服务器提供的文件 MD5}},computed: {fileUrl() {return `${baseUrl.apiUrl}/download/download?fileId=${this.fileId}`;},estimatedTime() {if (!this.speed || this.speed <= 0) return '--';const remaining = (this.totalSize - this.downloadedSize) / (this.speed * 1024 * 1024);return remaining > 3600? `${Math.floor(remaining / 3600)}小时${Math.floor((remaining % 3600) / 60)}分钟`: `${Math.floor(remaining / 60)}分钟${Math.floor(remaining % 60)}`;}},methods: {formatFileSize(bytes) {if (bytes === 0) return '0 Bytes';const k = 1024;const sizes = ['Bytes', 'KB', 'MB', 'GB'];const i = Math.floor(Math.log(bytes) / Math.log(k));return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];},async calculateMD5(data) {try {return md5(data); // Compute MD5 hash} catch (error) {console.error('MD5 error:', error);throw error;}},async startDownload() {if (this.isDownloading) return;try {this.resetState();this.isDownloading = true;this.downloadStartTime = Date.now();this.controller = new AbortController();// 获取文件元数据await this.fetchFileMetadata();// 恢复进度或开始新下载await this.downloadChunks();} catch (err) {this.handleError(err);}},async fetchFileMetadata() {const headRes = await fetch(this.fileUrl, {method: 'HEAD',headers: {Authorization: store.getters.token,'Cache-Control': 'no-cache'}});if (!headRes.ok) {throw new Error(`获取文件信息失败: ${headRes.statusText}`);}this.totalSize = parseInt(headRes.headers.get('Content-Length')) || 0;const contentDisposition = headRes.headers.get('Content-Disposition');this.fileName = contentDisposition? contentDisposition.split('filename=')[1].replace(/"/g, ''): this.fileId;this.serverFileMD5 = headRes.headers.get('File-MD5') || ''; // 获取服务器提供的 MD5if (!this.serverFileMD5) {console.warn('服务器未提供文件 MD5,无法进行完整性验证');}// 恢复进度const savedProgress = localStorage.getItem(this.getStorageKey());if (savedProgress) {const progressData = JSON.parse(savedProgress);this.downloadedSize = progressData.downloaded;this.progressPercent = Math.round((this.downloadedSize / this.totalSize) * 100);}},getStorageKey() {return `download-${btoa(this.fileUrl)}`;},async downloadChunks() {while (this.downloadedSize < this.totalSize && !this.isPaused) {const start = this.downloadedSize;const end = Math.min(start + this.chunkSize - 1, this.totalSize - 1);try {const chunkBlob = await this.downloadChunk(start, end);const customBlob = {blob: chunkBlob,start: start,end: end};this.chunks.push(customBlob);this.retryCount = 0;} catch (err) {if (err.name === 'AbortError') return;if (this.retryCount++ < this.maxRetries) {await new Promise(resolve => setTimeout(resolve, 1000 * this.retryCount));continue;}throw err;}}if (this.downloadedSize >= this.totalSize) {await this.completeDownload();}},async downloadChunk(start, end) {const startTime = Date.now();// 禁用缓存const response = await fetch(this.fileUrl, {headers: {Range: `bytes=${start}-${end}`,Authorization: store.getters.token,'Cache-Control': 'no-cache'},signal: this.controller.signal // 添加时间戳参数以避免浏览器缓存});if (!response.ok) {throw new Error(`下载失败: ${response.statusText}`);}// 方法1:直接使用 arrayBuffer() 方法(最简单)const contentRange = response.headers.get('Content-Range');console.log('Content-Range:', contentRange);  // 确保它匹配你请求的范围const arrayBuffer = await response.arrayBuffer();// 排查用户端与服务端的MD5校验// const chunk_md5 = response.headers.get('File-MD5')// const real_md5 = await this.calculateMD5(arrayBuffer)// console.log(arrayBuffer)// if (chunk_md5 !== real_md5) {//   console.error("MD5不一致:", chunk_md5, real_md5)//   console.log("HEX" + utils.toHex(arrayBuffer))// } else {//   console.error("MD5一致:", chunk_md5, real_md5)//   console.log("HEX" + utils.toHex(arrayBuffer))// }// 更新进度this.updateProgress(start, arrayBuffer.byteLength, startTime);return arrayBuffer;// const reader = response.body.getReader();// let receivedLength = 0;// const chunks = [];//// while (true) {//   const { done, value } = await reader.read();//   if (done) break;////   chunks.push(value);//   receivedLength += value.length;////   // 更新进度//   this.updateProgress(start, receivedLength, startTime);// }// return chunks;},updateProgress(start, receivedLength, startTime) {this.downloadedSize = start + receivedLength;this.progressPercent = Math.round((this.downloadedSize / this.totalSize) * 100);// 计算下载速度const timeElapsed = (Date.now() - startTime) / 1000;this.speed = (receivedLength / 1024 / 1024 / timeElapsed).toFixed(2);// 保存进度localStorage.setItem(this.getStorageKey(),JSON.stringify({downloaded: this.downloadedSize,chunks: this.chunks.length}));},pauseDownload() {this.isPaused = true;this.controller.abort();this.progressStatus = 'warning';this.$message.warning('下载已暂停');},resumeDownload() {this.isPaused = false;this.progressStatus = '';this.controller = new AbortController();this.downloadChunks();this.$message.success('继续下载');},cancelDownload() {this.controller.abort();this.resetState();localStorage.removeItem(this.getStorageKey());this.$message.info('下载已取消');},async completeDownload() {try {this.progressStatus = 'success';await this.saveFile();this.$message.success('下载完成');} catch (err) {this.handleError(err);} finally {this.cleanup();}},async saveFile() {// 1. 排序分片const sortedChunks = this.chunks.sort((a, b) => a.start - b.start);const fullBlob = new Blob(sortedChunks.map(c => c.blob), { type: 'application/zip' });// 计算下载文件的 MD5let arrayBuffer;if (this.serverFileMD5) {arrayBuffer = await fullBlob.arrayBuffer();const downloadedMD5 = await this.calculateMD5(arrayBuffer);if (downloadedMD5 !== this.serverFileMD5) {this.progressStatus = 'exception';this.$message.error('文件校验失败:MD5 不匹配,文件可能已损坏');throw new Error('MD5 verification failed');}}// 检查 ZIP 文件头const uint8Array = new Uint8Array(arrayBuffer);const zipHeader = uint8Array.slice(0, 4);if (zipHeader[0] !== 80 || zipHeader[1] !== 75 || zipHeader[2] !== 3 || zipHeader[3] !== 4) {this.progressStatus = 'exception';this.$message.error('无效的 ZIP 文件:文件头错误');throw new Error('Invalid ZIP file header');}// 创建下载链接const url = URL.createObjectURL(fullBlob);const a = document.createElement('a');a.href = url;a.download = this.fileName;a.style.display = 'none';// 触发下载document.body.appendChild(a);a.click();// 延迟清理await new Promise(resolve => setTimeout(resolve, 100));document.body.removeChild(a);URL.revokeObjectURL(url);},resetState() {this.isDownloading = false;this.isPaused = false;this.progressPercent = 0;this.progressStatus = '';this.speed = '';this.downloadedSize = 0;this.retryCount = 0;this.chunks = [];this.controller = null;this.serverFileMD5 = '';},cleanup() {this.isDownloading = false;localStorage.removeItem(this.getStorageKey());},handleError(err) {console.error('下载错误:', err);this.progressStatus = 'exception';this.$message.error(`下载失败: ${err.message}`);this.cleanup();}},beforeUnmount() {if (this.controller) {this.controller.abort();}}
}
</script><style scoped>
.download-container {max-width: 800px;margin: 20px auto;padding: 20px;border: 1px solid #ebeef5;border-radius: 4px;background-color: #f5f7fa;
}.el-button-group {margin-bottom: 15px;
}.progress-bar {margin: 20px 0;
}.download-info {display: flex;justify-content: space-between;margin-top: 10px;color: #606266;font-size: 14px;
}.download-info span {padding: 0 5px;
}
</style>

相关文章:

  • 云原生攻防3(Docker常见攻击方式)
  • 2025年渗透测试面试题总结-华顺信安[实习]安全服务工程师(题目+回答)
  • 服务器数据恢复—Linux系统服务器崩溃且重装系统的数据恢复案例
  • 学习黑客数据小包的TLS冒险之旅
  • PHP、JAVA、Shiro反序列化
  • 云原生主要架构模式
  • java云原生实战之graalvm 环境安装
  • 考研系列-408真题计算机组成原理篇(2015-2019)
  • C++ QT 与 win32 窗口可以互操作
  • 创建thinkphp项目并配置数据库
  • 微服务架构中的多进程通信--内存池、共享内存、socket
  • Java期末总复习 编程题(偏基础)
  • Python数据可视化再探——Matplotlib模块 之一
  • Unity入门学习(四)3D数学(4)之四元数Quaternion
  • python新手学习笔记①
  • Vue2到Vue3迁移问题解析
  • uniapp-商城-63-后台 商品列表(分类展示商品的删除)
  • GO语言学习(六)
  • Python实战:打造一个功能完整的单位转换器(长度/温度/货币)
  • 5.20打卡
  • 热点问答:特朗普与俄乌总统分别通话,他们谈了什么
  • 国家发改委:系统谋划7方面53项配套举措,推动民营经济促进法落地见效
  • 河南通报部分未检疫生猪流入:立案查处,涉案猪肉被封存
  • 4年间职务侵占、受贿逾亿元,北京高院:严惩民企内部腐败
  • 一周人物|收藏家瓦尔特捐出藏品,女性艺术家“对话”摄影
  • 《五行令》《攻守占》,2个月后国博见