最近在写视频课程的上传,需要上传的视频几百MB到几个G不等,普通的上传都限制了文件的大小,况且上传的文件太大的话会超时、异常等。所以这时候需要考虑分片上传了,把需要上传的视频分成多个小块上传到,最后再合并成一个视频进行存储。我这里上传到自有的Minio,其它存储应该也是大同小异。
前端:vue3+vue-simple-uploader
后端:springboot+Minio
先看前端效果
vue-simple-uploader原生组件的样式效果很不错了,vue-simple-uploader文档
下面这个是我上传成功后自定义的样式
再来看看流程图思路
有了流程我们就直接上代码了
1、前端安装vue-simple-uploader
npm install vue-simple-uploader@next --save
2、在main.ts
引入
import uploader from 'vue-simple-uploader'
import 'vue-simple-uploader/dist/style.css';
// ...
const app = createApp(App)
// ...
// 引入上传组件
app.use(uploader)
3、前端组件全部代码
<template><uploaderref="uploaderRef":options="options":auto-start="false":fileStatusText="fileStatusText"class="uploader-container"@file-added="onFileAdded"@file-progress="onFileProgress"@file-success="onFileSuccess"@file-error="onFileError"@file-removed="onDelete"><uploader-unsupport>您的浏览器不支持上传组件</uploader-unsupport><uploader-drop><uploader-btn :attrs="attrs">{{deProps.btnText}}</uploader-btn></uploader-drop><uploader-list><template #default="props"><!-- 已上传的文件列表 --><div v-for="file in uploadFileList" :key="file.fileId" :file="file" class="raw-list"><div class="file-title">{{ file.name }}</div><div class="file-size">{{ parseFloat(file.size / 1024 / 1024).toFixed(2) }} MB</div><div class="file-status">已上传</div><el-button size="mini" type="danger" @click="onDelete(uploadFileList, file)">删除文件</el-button></div><!-- 正在上传的文件列表 --><uploader-file v-for="file in props.fileList" :key="file.fileId" :file="file" :list="true" v-show="!file.completed" /></template></uploader-list></uploader>
</template><script setup>
import axios from 'axios';
import { getAccessToken,getTenantId } from '@/utils/auth';
import { propTypes } from '@/utils/propTypes'
const token = getAccessToken();
import {config} from '@/config/axios/config';
const emit = defineEmits(['success','delete'])const fileStatusText = {success: '上传成功',error: '上传失败',uploading: '正在上传',paused: '暂停上传',waiting: '等待上传'
};
// 原来已上传过的文件列表
const uploadFileList = ref([]);
// const uploader = ref(null);
const currentFile = ref(null);
const isPaused = ref(false);const deProps = defineProps({btnText: propTypes.string.def('选择文件上传') ,fileList: propTypes.array.def([]), // 原来已上传的文件列表singleFile: propTypes.bool.def(true), // 是否单文件上传
})uploadFileList.value = deProps.fileList;/*** 上传组件配置*/
const options = {target: config.base_url + '/infra/minio/upload', // 目标上传 URLheaders: {'tenant-id': getTenantId(),'Authorization': `Bearer ${token}`}, // 接口的定义, 根据实际情况而定chunkSize: 5 * 1024 * 1024, // 分块大小singleFile: deProps.singleFile, // 是否单文件上传simultaneousUploads: 3, // 同时上传3个分片forceChunkSize: true, // 是否强制所有的块都是小于等于 chunkSize 的值。默认是 false。// fileParameterName: 'file', // 上传文件时文件的参数名,默认filemaxChunkRetries: 3, // 最大自动失败重试上传次数testChunks: false, // 是否开启服务器分片校验// 额外的请求参数query: (file) => {return {uploadId: file.uploadId,fileName: file.name,totalChunks: file.chunks.length};},// 处理请求参数, 将参数名字修改成接口需要的processParams: (params, file, chunk) => {params.chunkIndex = chunk.offset; // 分片索引return params;}
};
// 限制上传的文件类型
const attrs = {accept: '.mp4,.png,.jpg,.txt,.pdf,.ppt,.pptx,.doc,docx,.xls,xlsx,.ofd,.zip,.rar',
};const uploaderRef = ref(null);/*** 模版中禁止了自动上传(:auto-start="false")*/
const onFileAdded = async (file) => {currentFile.value = file;isPaused.value = false;try {// 1. 初始化上传会话const initResponse = await axios.post(`${config.base_url}/infra/minio/init`,{ fileName: file.name, fileSize: file.size },{headers: {'tenant-id': getTenantId(),'Authorization': `Bearer ${token}`},});if (initResponse.data.code === 0) {file.uploadId = initResponse.data.data;} else {throw new Error(initResponse.data.msg);}// 2. 获取已上传分片列表const chunksResponse = await axios.get(`${config.base_url}/infra/minio/uploaded-parts/${file.uploadId}`,{params: { totalChunks: file.chunks.length },headers: {'tenant-id': getTenantId(),'Authorization': `Bearer ${token}`},},);if (chunksResponse.data.code === 0) {// 设置已上传分片file.uploadedChunks = chunksResponse.data.data || [];}// 开始上传file.resume();} catch (error) {file.cancel();console.error('初始化上传出错:', error);}
};// 上传进度事件
const onFileProgress = (rootFile, file, chunk) => {// 使用 progress() 方法获取上传进度const progress = Math.round(file.progress() * 100);console.log(`上传进度: ${progress}%`);
};// 文件上传成功
const onFileSuccess = async (rootFile, file, response) => {try {// 调用合并接口const mergeResponse = await axios.post(config.base_url + '/infra/minio/merge', {uploadId: file.uploadId,fileName: file.name},{headers: {'tenant-id': getTenantId(),'Authorization': `Bearer ${token}`},});if (mergeResponse.data.code === 0) {// 添加到已上传文件列表uploadFileList.value.push({fileId: file.uploadId,name: file.name,size: file.size,url: mergeResponse.data.data});} else {console.error('文件合并失败:', mergeResponse.data.msg);}console.log(uploadFileList.value)emit('success', uploadFileList.value);} catch (error) {console.error('文件合并请求失败:', error);} finally {currentFile.value = null;}
};// 文件上传失败
const onFileError = (rootFile, file, message) => {console.error('文件上传失败:', message);currentFile.value = null;
};// 暂停/继续上传
const togglePause = () => {if (!currentFile.value) return;if (isPaused.value) {currentFile.value.resume();} else {currentFile.value.pause();}isPaused.value = !isPaused.value;
};// 取消上传
const onCancel = async (file) => {if (file.status === 'uploading') {try {// 调用后端取消接口await axios.post(`${config.base_url}/infra/minio/cancel/${file.uploadId}`,{headers: {'tenant-id': getTenantId(),'Authorization': `Bearer ${token}`},});file.cancel();} catch (error) {console.error('取消上传失败:', error);}} else {file.cancel();}currentFile.value = null;
};// 删除文件
const onDelete = async (list,file) => {try {// 调用后端删除接口await axios.delete(`http://localhost:48080/admin-api/infra/minio/delete?path=${file.url}`,{headers: {'tenant-id': getTenantId(),'Authorization': `Bearer ${token}`},});// 从列表中移除const index = list.findIndex(f => f.fileId === file.fileId);if (index !== -1) {list.splice(index, 1);}emit('delete', list);} catch (error) {console.error('删除文件失败:', error);}
};
</script>
<style lang="scss" scoped>
.uploader-container {border: 1px solid #eee;border-radius: 4px;padding: 15px;
}.raw-list{display: flex;align-items: center;justify-content: space-between;padding: 10px 20px;.file-title{width: 30%;overflow: hidden;text-overflow: ellipsis;white-space: nowrap;}
}</style>
4、使用组件
<upload-chunk
ref="uploadChunkRef"
btnText="上传视频"
:fileList="fileList"
@success="uploadChunkSuccess"
@delete="uploadChunkDelete" />
前端的代码就这么多,接下来看看后端。
1、添加minio
依赖
<dependency><groupId>io.minio</groupId><artifactId>minio</artifactId><version>8.5.7</version>
</dependency>
2、创建3个请求实体类
public class FileChunkInitReqVO {private String fileId;private String fileName;private Long fileSize;
}public class FileChunkMergeReqVO {private String uploadId;private String fileName;
}public class FileChunkUploadReqVO {private String uploadId;private String fileName;private Integer chunkIndex;private Integer totalChunks;private MultipartFile file;
}
3、Controller类
/*** 删除指定文件** @param path 文件ID* @return 响应状态*/
@DeleteMapping("/delete")
@PermitAll
public CommonResult<String> deleteFile(@RequestParam String path) {try {fileService.deleteFile(path);return CommonResult.success("File deleted successfully.");} catch (IOException | NoSuchAlgorithmException | InvalidKeyException e) {return CommonResult.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "Error deleting file: " + e.getMessage());}
}///初始化分片上传
@PostMapping("/init")
@PermitAll
public CommonResult<String> initUploadSession(@RequestBody FileChunkInitReqVO reqVO) {try {String uploadId = fileService.initUploadSession(reqVO.getFileName(), reqVO.getFileSize());return CommonResult.success(uploadId);} catch (Exception e) {log.error("初始化上传会话失败", e);return CommonResult.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "初始化上传会话失败");}
}//上传文件分片
@PostMapping("/upload")
@PermitAll
public CommonResult<Boolean> uploadFilePart(@Validated FileChunkUploadReqVO reqVO) {try {boolean result = fileService.uploadFilePart(reqVO.getUploadId(),reqVO.getChunkIndex(),reqVO.getTotalChunks(),reqVO.getFile());return CommonResult.success(result);} catch (Exception e) {log.error("上传分片失败", e);return CommonResult.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "上传分片失败");}
}//获取已上传分片列表
@GetMapping("/uploaded-parts/{uploadId}")
@PermitAll
public CommonResult<List<Integer>> getUploadedParts(@PathVariable String uploadId,@RequestParam int totalChunks) {try {List<Integer> uploadedParts = fileService.getUploadedParts(uploadId, totalChunks);return CommonResult.success(uploadedParts);} catch (Exception e) {log.error("获取已上传分片失败", e);return CommonResult.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "获取已上传分片失败");}
}//合并文件分片
@PostMapping("/merge")
@PermitAll
public CommonResult<String> mergeFileParts(@RequestBody FileChunkMergeReqVO reqVO) {try {String fileUrl = fileService.mergeFileParts(reqVO.getUploadId(),reqVO.getFileName());return CommonResult.success(fileUrl);} catch (Exception e) {log.error("合并文件失败", e);return CommonResult.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "合并文件失败");}
}//取消上传
@PostMapping("/cancel/{uploadId}")
@PermitAll
public CommonResult<Boolean> cancelUpload(@PathVariable String uploadId) {try {fileService.cancelUpload(uploadId);return CommonResult.success(true);} catch (Exception e) {log.error("取消上传失败", e);return CommonResult.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "取消上传失败");}
}
5、service类
@Service
public class MinioChunkUploadService {@Resourceprivate FileMapper fileMapper;// 改成自己的private static final String endpoint = "http://xxxx";private static final String accessKey = "BDnZ1SS3Kq0pxxxxxx";private static final String accessSecret = "MAdjW4rd0hXoZNrxxxxxxxx";private static final String bucketName = "xxtigrixxx";private static final String CHUNK_PREFIX = "chunks/";private static final String MERGED_PREFIX = "merged/";/*** 创建 Minio 客户端* @return*/private MinioClient createMinioClient() {return MinioClient.builder().endpoint(endpoint).credentials(accessKey, accessSecret).build();}/*** 删除指定文件** @param path 文件路径*/public void deleteFile(String path) throws IOException, NoSuchAlgorithmException, InvalidKeyException {MinioClient minioClient = createMinioClient();try {String fileNames = MERGED_PREFIX + path;// 删除文件minioClient.removeObject(RemoveObjectArgs.builder().bucket(bucketName).object(fileNames).build());} catch (MinioException e) {throw new IOException("Error deleting file: " + e.getMessage(), e);}}// 新增方法:检查分片是否已存在public boolean checkChunkExists(String fileId, int chunkIndex)throws IOException, NoSuchAlgorithmException, InvalidKeyException {MinioClient minioClient = createMinioClient();try {String objectName = fileId + "/chunk-" + chunkIndex;minioClient.statObject(StatObjectArgs.builder().bucket(bucketName).object(objectName).build());return true;} catch (Exception e) {throw new IOException("Error checking chunk existence: " + e.getMessage(), e);}}// 新增方法:获取已上传分片列表public List<Integer> getUploadedChunks(String fileId)throws IOException, NoSuchAlgorithmException, InvalidKeyException {MinioClient minioClient = createMinioClient();List<Integer> uploadedChunks = new ArrayList<>();// 列出所有分片Iterable<Result<Item>> results = minioClient.listObjects(ListObjectsArgs.builder().bucket(bucketName).prefix(fileId + "/chunk-").build());for (Result<Item> result : results) {Item item = null;try {item = result.get();} catch (Exception e) {throw new RuntimeException(e);}String objectName = item.objectName();// 提取分片索引: fileId/chunk-123String chunkStr = objectName.substring(objectName.lastIndexOf("-") + 1);try {int chunkIndex = Integer.parseInt(chunkStr);uploadedChunks.add(chunkIndex);} catch (NumberFormatException ignored) {// 忽略无效分片名}}return uploadedChunks;}/*** 初始化上传会话*/public String initUploadSession(String fileName, long fileSize) {// 生成唯一上传IDreturn UUID.randomUUID().toString();}/*** 上传文件分片*/public boolean uploadFilePart(String uploadId, int chunkIndex, int totalChunks, MultipartFile filePart)throws IOException, NoSuchAlgorithmException, InvalidKeyException, MinioException {// 构建分片对象名称String objectName = CHUNK_PREFIX + uploadId + "/" + chunkIndex;MinioClient minioClient = createMinioClient();// 上传文件分片minioClient.putObject(PutObjectArgs.builder().bucket(bucketName).object(objectName).stream(filePart.getInputStream(), filePart.getSize(), -1).contentType(filePart.getContentType()).build());return true;}/*** 获取已上传分片列表*/public List<Integer> getUploadedParts(String uploadId, int totalChunks)throws IOException, NoSuchAlgorithmException, InvalidKeyException, MinioException {List<Integer> uploadedParts = new ArrayList<>();MinioClient minioClient = createMinioClient();// 列出所有分片Iterable<Result<Item>> results = minioClient.listObjects(ListObjectsArgs.builder().bucket(bucketName).prefix(CHUNK_PREFIX + uploadId + "/").build());for (Result<Item> result : results) {try {Item item = result.get();String objectName = item.objectName();// 提取分片索引: chunks/uploadId/123String chunkIndexStr = objectName.substring(objectName.lastIndexOf("/") + 1);uploadedParts.add(Integer.parseInt(chunkIndexStr));} catch (Exception e) {System.out.println(e.getMessage());}}return uploadedParts;}/*** 合并文件分片*/public String mergeFileParts(String uploadId, String fileName)throws IOException, NoSuchAlgorithmException, InvalidKeyException, MinioException {// 获取所有分片List<String> partNames = new ArrayList<>();List<ComposeSource> sources = new ArrayList<>();MinioClient minioClient = createMinioClient();Iterable<Result<Item>> results = minioClient.listObjects(ListObjectsArgs.builder().bucket(bucketName).prefix(CHUNK_PREFIX + uploadId + "/").build());for (Result<Item> result : results) {Item item = result.get();partNames.add(item.objectName());sources.add(ComposeSource.builder().bucket(bucketName).object(item.objectName()).build());}// 按分片索引排序sources.sort((a, b) -> {int indexA = Integer.parseInt(a.object().substring(a.object().lastIndexOf("/") + 1));int indexB = Integer.parseInt(b.object().substring(b.object().lastIndexOf("/") + 1));return Integer.compare(indexA, indexB);});// 构建最终文件对象名称String finalObjectName = MERGED_PREFIX + DateUtil.format(LocalDateTime.now(), "yyyyMMdd") + "/" + uploadId + "/" + fileName;// 合并文件minioClient.composeObject(ComposeObjectArgs.builder().bucket(bucketName).object(finalObjectName).sources(sources).build());// 删除分片文件for (String partName : partNames) {minioClient.removeObject(RemoveObjectArgs.builder().bucket(bucketName).object(partName).build());}// 返回文件访问URLreturn finalObjectName;}/*** 取消上传*/public void cancelUpload(String uploadId)throws IOException, NoSuchAlgorithmException, InvalidKeyException, MinioException {MinioClient minioClient = createMinioClient();// 删除所有分片Iterable<Result<Item>> results = minioClient.listObjects(ListObjectsArgs.builder().bucket(bucketName).prefix(CHUNK_PREFIX + uploadId + "/").build());for (Result<Item> result : results) {Item item = result.get();minioClient.removeObject(RemoveObjectArgs.builder().bucket(bucketName).object(item.objectName()).build());}}
}
OK,全部代码完成,有问题或哪里不对的地方欢迎指正