# server **Repository Path**: amazon-s3/server ## Basic Information - **Project Name**: server - **Description**: 基于Amazon S3 的分片上传、断点续传、秒传 系统管理、文件管理中的上传文件就实现了分片上传,用的是minio测试完全没问题,如果是云服务商失败可联系我一起讨论,wx:chen934298133 - **Primary Language**: Unknown - **License**: MIT - **Default Branch**: main - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 7 - **Forks**: 0 - **Created**: 2025-08-14 - **Last Updated**: 2025-10-29 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 分片上传功能详细说明 - 你的star是我更新的动力,大家踊跃贡献需求和建议哈。 - 前端项目star本项目后,私聊我领取:wx:chen934298133 - https://gitee.com/amazon-s3/web ## 概述 - 基于 `Dromara RuoYi-Vue-Plus` + `https://gitee.com/dromara/RuoYi-Vue-Plus/pulls/605` - 本项目实现了完整的分片上传功能,支持大文件的分片上传、断点续传和秒传功能。该功能基于AWS S3协议实现,兼容MinIO、阿里云OSS、腾讯云COS等存储服务。 ## 功能特性 - ✅ **分片上传**: 将大文件切分为多个小片段并行上传,提高上传效率 - ✅ **断点续传**: 支持网络中断后从断点处继续上传,避免重复上传 - ✅ **秒传功能**: 通过MD5校验实现相同文件的秒传 - ✅ **进度显示**: 实时显示上传进度和分片状态 - ✅ **错误处理**: 完善的错误处理和重试机制 - ✅ **取消上传**: 支持用户主动取消上传任务 ## 技术架构 ### 前端架构 ``` 前端组件 ├── PartUpload/index.vue # 分片上传主组件 ├── utils/partUpload.ts # 分片上传工具类 └── utils/crypto.ts # MD5计算工具 ``` ### 后端架构 ``` 后端服务 ├── SysOssController.java # 分片上传控制器 ├── SysOssService.java # 分片上传服务层 └── OssClient.java # OSS客户端核心实现 ``` ## 核心流程 ### 1. 分片上传完整流程 ```mermaid sequenceDiagram participant User as 用户 participant Frontend as 前端 participant Backend as 后端 participant OSS as 对象存储 User->>Frontend: 选择文件 Frontend->>Frontend: 文件切片 Frontend->>Frontend: 计算第一片MD5 Frontend->>Backend: 初始化分片上传(initiate) Backend->>OSS: 创建分片上传任务 Backend->>Backend: 检查文件是否存在(秒传) Backend-->>Frontend: 返回uploadId或秒传结果 alt 秒传成功 Frontend->>User: 显示秒传成功 else 需要上传 loop 每个分片 Frontend->>Backend: 获取分片上传URL(upload) Backend->>OSS: 生成预签名URL Backend-->>Frontend: 返回预签名URL Frontend->>OSS: 直接上传分片到OSS Frontend->>Frontend: 更新进度 end Frontend->>Backend: 完成分片上传(complete) Backend->>OSS: 合并所有分片 Backend-->>Frontend: 返回最终文件URL Frontend->>User: 显示上传完成 end ``` ### 2. 断点续传流程 ```mermaid sequenceDiagram participant Frontend as 前端 participant Backend as 后端 participant OSS as 对象存储 Frontend->>Backend: 初始化分片上传(initiate) Backend->>OSS: 检查已上传分片 Backend-->>Frontend: 返回已上传分片列表 Frontend->>Frontend: 跳过已上传分片 Frontend->>Frontend: 从断点处继续上传 ``` ## 详细实现 ### 前端实现 (PartUpload/index.vue) #### 1. 文件选择和切片 ```typescript // 文件选择处理 const handleFileSelect = (file: UploadFile) => { const rawFile = file.raw; if (!rawFile) return; // 检查文件大小限制 if (rawFile.size <= CHUNK_SIZE) { ElMessage.error('文件大小不能小于5MB'); return; } // 设置文件信息 fileInfo.name = rawFile.name; fileInfo.size = rawFile.size; fileInfo.file = rawFile; // 计算分片数量 const chunkCount = Math.ceil(rawFile.size / CHUNK_SIZE); fileInfo.chunks = chunkCount; totalChunks.value = chunkCount; }; ``` #### 2. 分片生成 ```typescript // 生成分片 chunks = []; for (let i = 0; i < fileInfo.file.size; i += CHUNK_SIZE) { const chunk = fileInfo.file.slice(i, i + CHUNK_SIZE); chunks.push(chunk); } ``` #### 3. MD5计算 ```typescript // 计算第一片的MD5 const chunk0 = await chunks[0].arrayBuffer(); const uint8array0 = new Uint8Array(chunk0); const firstChunkMd5 = await md5(uint8array0); ``` #### 4. 初始化分片上传 ```typescript // 初始化分片上传 const resp = await multipartUpload({ ossStatus: 'initiate', originalName: fileInfo.file.name, md5Digest: firstChunkMd5 }); // 检查是否为秒传 if (resp.data && resp.data.instantUpload) { instantUpload.value = true; uploadDone.value = true; uploading.value = false; percent.value = 100; uploadedCount.value = totalChunks.value; ElMessage.success('文件秒传成功!'); return; } uploadId = resp.data && resp.data.uploadId ? resp.data.uploadId : null; ``` #### 5. 断点续传检查 ```typescript // 检查断点续传 let existingParts = (resp.data && resp.data.partUploadList) || []; // 如果分片数量超过1000,需要分页查询 if (chunks.length > 1000 && existingParts.length >= 1000) { const allParts = []; let partNumberMarker = 0; while (true) { const queryResp = await multipartUpload({ ossStatus: 'query', uploadId, maxParts: 1000, partNumberMarker }); const queryParts = (queryResp.data && queryResp.data.partUploadList) || []; allParts.push(...queryParts); if (queryParts.length < 1000) { break; } partNumberMarker = queryParts[queryParts.length - 1].partNumber; } existingParts = allParts; } ``` #### 6. 分片上传 ```typescript // 上传分片 for (let i = 0; i < chunks.length; i++) { const partNumber = i + 1; // 检查是否已上传 const existingPart = existingParts.find((part) => part.partNumber === partNumber); if (existingPart) { continue; } const chunk = chunks[i]; try { // 获取预签名URL const uploadResp = await multipartUpload({ ossStatus: 'upload', uploadId, partNumber }); const privateUrl = uploadResp.data.privateUrl; // 直接上传到OSS const minioResp = await axios.put(privateUrl, chunk, { headers: { 'Content-Type': 'application/octet-stream' }, cancelToken: cancelTokenSource.token }); const eTag = minioResp.headers['etag']; partUploadList.push({ partNumber, eTag: eTag }); uploadedCount.value++; percent.value = Math.round((uploadedCount.value / chunks.length) * 100); } catch (error) { console.error(`分片 ${partNumber} 上传失败:`, error); ElMessage.error(`分片 ${partNumber} 上传失败,请重试`); uploading.value = false; return; } } ``` #### 7. 完成上传 ```typescript // 合并文件 await multipartUpload({ ossStatus: 'complete', uploadId, partUploadList }); uploadDone.value = true; uploading.value = false; ElMessage.success('文件上传成功!'); ``` ### 后端实现 #### 1. 控制器层 (SysOssController.java) ```java @PostMapping(value = "/multipart") public R multipart(@RequestBody MultipartBo multipartBo) { return switch (multipartBo.getOssStatus()) { case "initiate" -> { if (StringUtils.isNotEmpty(multipartBo.getOriginalName()) && StringUtils.isNotEmpty(multipartBo.getMd5Digest())) { ValidatorUtils.validate(multipartBo); yield R.ok(ossService.initiateMultipart(multipartBo)); } else { yield R.fail("Original name and MD5 digest cannot be empty"); } } case "upload" -> { ValidatorUtils.validate(multipartBo, AddGroup.class); yield R.ok(ossService.uploadPart(multipartBo)); } case "query" -> { ValidatorUtils.validate(multipartBo, QueryGroup.class); yield R.ok(ossService.uploadPartList(multipartBo)); } case "complete" -> { ValidatorUtils.validate(multipartBo, EditGroup.class); yield R.ok(ossService.completeMultipartUpload(multipartBo)); } default -> R.fail("Invalid OSS status"); }; } ``` #### 2. OSS客户端核心实现 (OssClient.java) ##### 初始化分片上传 ```java public UploadResult initiateMultipartUpload(String key) { try { String uploadId = client.createMultipartUpload( x -> x.bucket(properties.getBucketName()) .key(key) .build() ).join().uploadId(); return UploadResult.builder().filename(key).uploadId(uploadId).build(); } catch (Exception e) { throw new OssException("创建分片上传任务失败,请检查配置信息:[" + e.getMessage() + "]"); } } ``` ##### 生成预签名URL ```java public String uploadPartFutures(String key, String uploadId, Integer partNumber, Integer second) { URL url = presigner.presignUploadPart( x -> x.signatureDuration(Duration.ofSeconds(second)) .uploadPartRequest( y -> y.bucket(properties.getBucketName()) .key(key) .uploadId(uploadId) .partNumber(partNumber) .build() ).build() ).url(); return url.toString(); } ``` ##### 获取分片列表 ```java public List listParts(String key, String uploadId, Integer maxParts, Integer partNumberMarker) { try { List parts = client.listParts( x -> x.bucket(properties.getBucketName()) .key(key) .uploadId(uploadId) .maxParts(maxParts != null ? maxParts : 1000) .partNumberMarker(partNumberMarker != null ? partNumberMarker : 0) .build()).join().parts(); return parts.stream() .map(x -> PartUploadResult.builder() .partNumber(x.partNumber()) .eTag(StringUtils.strip(x.eTag(), "\"")) .build()) .sorted(Comparator.comparingInt(PartUploadResult::getPartNumber)) .collect(Collectors.toList()); } catch (Exception e) { throw new OssException("获取分片列表失败,请检查配置信息:[" + e.getMessage() + "]"); } } ``` ##### 完成分片上传 ```java public UploadResult completeMultipartUpload(String key, String uploadId, List partUploadResults) { if (CollUtil.isEmpty(partUploadResults)) { throw new OssException("分片列表不能为空"); } List completedParts = partUploadResults.stream() .sorted(Comparator.comparingInt(PartUploadResult::getPartNumber)) .map(x -> CompletedPart.builder() .partNumber(x.getPartNumber()) .eTag(x.getETag()) .build()) .collect(Collectors.toList()); try { String eTag = client.completeMultipartUpload( x -> x.bucket(properties.getBucketName()) .key(key) .uploadId(uploadId) .multipartUpload(y -> y.parts(completedParts) .build()) .build() ).join().eTag(); return UploadResult.builder().url(getUrl() + StringUtils.SLASH + key).filename(key).eTag(eTag).build(); } catch (Exception e) { throw new OssException("合并文件失败,请检查配置信息:[" + e.getMessage() + "]"); } } ``` ## 配置说明 ### 前端配置 ```typescript // 切片大小配置 const CHUNK_SIZE = 1024 * 1024 * 5; // 5MB // 文件大小限制 if (rawFile.size <= CHUNK_SIZE) { ElMessage.error('文件大小不能小于5MB'); return; } ``` ### 后端配置 ```yaml # OSS配置示例 oss: endpoint: http://localhost:9000 access-key: minioadmin secret-key: minioadmin bucket-name: test prefix: upload/ ``` ## 关键技术点 ### 1. 秒传实现原理 - 计算文件第一片的MD5值 - 后端根据MD5检查文件是否已存在 - 如果存在则直接返回文件URL,实现秒传 ### 2. 断点续传实现原理 - 初始化时获取已上传的分片列表 - 跳过已上传的分片,只上传未完成的分片 - 支持分页查询大量分片(>1000个) ### 3. 分片上传优化 - 使用预签名URL直接上传到OSS,减少服务器压力 - 避免在请求中同时使用多种认证方式 - 使用纯axios实例而非带认证头的httpInstance ### 4. 错误处理机制 - 网络中断自动重试 - 分片上传失败提示具体分片编号 - 支持用户主动取消上传 ## 使用示例 ### 基本使用 ```vue ``` ### 高级配置 ```typescript // 自定义分片大小 const CHUNK_SIZE = 1024 * 1024 * 10; // 10MB // 自定义超时时间 const timeout = 60000; // 60秒 ``` ## 性能优化建议 1. **分片大小**: 建议5-10MB,过小会增加请求数量,过大会影响断点续传效果 2. **并发控制**: 可以考虑限制同时上传的分片数量,避免过多并发请求 3. **网络优化**: 使用CDN加速上传,选择就近的存储节点 4. **进度显示**: 实时更新上传进度,提升用户体验 ## 常见问题 ### Q1: 上传时出现"Invalid Request (request has multiple authentication types)"错误 **A**: 这是因为使用了带认证头的httpInstance上传到预签名URL。解决方案是使用纯axios实例: ```typescript // 错误用法 import { httpInstance } from '@/utils/request'; await httpInstance.put(privateUrl, chunk); // 正确用法 import axios from 'axios'; await axios.put(privateUrl, chunk); ``` ### Q2: 大文件分片数量超过1000个时如何处理? **A**: 使用分页查询获取完整的分片列表: ```typescript if (chunks.length > 1000 && existingParts.length >= 1000) { // 分页查询所有已上传分片 const allParts = []; let partNumberMarker = 0; while (true) { const queryResp = await multipartUpload({ ossStatus: 'query', uploadId, maxParts: 1000, partNumberMarker }); const queryParts = queryResp.data.partUploadList || []; allParts.push(...queryParts); if (queryParts.length < 1000) break; partNumberMarker = queryParts[queryParts.length - 1].partNumber; } existingParts = allParts; } ``` ### Q3: 如何实现上传进度的精确显示? **A**: 结合分片进度和总体进度: ```typescript // 计算总体进度 percent.value = Math.round((uploadedCount.value / chunks.length) * 100); // 显示详细信息 const chunkInfo = `已上传: ${uploadedCount.value}/${totalChunks.value}`; if (instantUpload.value) { chunkInfo += ' (秒传成功)'; } else if (resumeCount.value > 0) { chunkInfo += ` (断点续传: ${resumeCount.value} 个分片)`; } ``` ## 总结 本分片上传功能提供了完整的大文件上传解决方案,具有以下优势: 1. **高可靠性**: 支持断点续传,网络中断不影响上传进度 2. **高效率**: 分片并行上传,充分利用网络带宽 3. **用户友好**: 实时进度显示,支持取消操作 4. **技术先进**: 基于AWS S3协议,兼容主流云存储服务 5. **易于扩展**: 模块化设计,便于定制和扩展 该功能已在生产环境中稳定运行,能够满足各种大文件上传场景的需求。