代码基于Springboot (maven) + vue3 (element-plus)
上传MinIO即可以后端传也可以前端传,如果文件是从前端提交最好还是前端传比较好,通过MinIO生成签名URL能保证安全,前端直传省去后端中转也能提高传输效率。
以下例子后端部分仅支持了生成签名url提供前端直传的机制,无后端上传功能,若需要请参考其他文章。
支持单文件一次性上传、大文件分片上传。
后端提供了3个接口:
1、获取单文件上传签名URL
2、初始化分片上传并获取签名分片URLs
3、合并分片为整体文件


一、后端
maven pom.xml
<!-- MinIO SDK --><dependency><groupId>io.minio</groupId><artifactId>minio</artifactId><version>8.4.3</version></dependency>
MinioConfig.java
package com.seentao.vet.cbec.common.minio.config;import lombok.Data; import org.springframework.context.annotation.Configuration; import org.springframework.beans.factory.annotation.Value;/*** @title: MinioConfig* @Author chenjye* @Date: 2025/8/9 上午9:27* @Version 1.0*/ @Data @Configuration public class MinioConfig {@Value("${oss.type.default}")private String ossTypeDefault;@Value("${s3.oss.endpoint}")private String endpoint;@Value("${s3.oss.accessKeyId}")private String accessKeyId;@Value("${s3.oss.secretAccessKey}")private String secretAccessKey;@Value("${s3.oss.bucketName}")private String bucketName;}
MinioController.java
package com.seentao.vet.cbec.common.minio.controller;import com.chanjet.edu.course.dto.ResponseInfo; import com.seentao.vet.cbec.common.minio.util.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod;import javax.validation.Valid; import java.util.HashMap; import java.util.Map;/*** @title: MinioController* @Author chenjye* @Date: 2025/8/9 上午9:27* @Version 1.0*/ @RestController public class MinioController {@Autowiredprivate MinioUtil minioUtil;/*** 生成单文件上传签名url*/@RequestMapping (value = "/single/init", method = RequestMethod.POST)public ResponseInfo initSingleUpload(@RequestBody @Valid SingleUploadParams singleUploadParams) throws Exception {String object = singleUploadParams.getObject();String contentType = singleUploadParams.getContentType();UploadUrlsVO urlsVO;// 单文件上传,生成文件上传签名urlurlsVO = minioUtil.getUploadObjectUrl(contentType, object);Map<String, Object> result = new HashMap<>();result.put("urlsVO", urlsVO);return new ResponseInfo(result);}/*** 初始化文件分片地址及相关数据*/@RequestMapping (value = "/multipart/init", method = RequestMethod.POST)public ResponseInfo initMultiPartUpload(@RequestBody @Valid MultipartUploadParams multipartUploadParams) throws Exception {String object = multipartUploadParams.getObject(); // String originFileName = fileUploadParams.getOriginFileName(); // String suffix = FileUtil.extName(originFileName); // String fileName = FileUtil.mainName(originFileName);// 对文件重命名,并以年月日文件夹格式存储// String nestFile = DateUtil.format(LocalDateTime.now(), "yyyy/MM/dd");// String object = nestFile + "/" + originFileName; UploadUrlsVO urlsVO;// 单文件上传,生成文件上传签名urlif (multipartUploadParams.getChunkCount() == 1) {urlsVO = minioUtil.getUploadObjectUrl(multipartUploadParams.getContentType(), object);}// 分片上传,生成uploadId及文件上传签名urlelse {urlsVO = minioUtil.initMultiPartUpload(multipartUploadParams, object);}Map<String, Object> result = new HashMap<>();result.put("urlsVO", urlsVO);return new ResponseInfo(result);}/*** 文件合并(单文件不会合并,仅信息入库)*/@RequestMapping (value = "/multipart/merge", method = RequestMethod.POST)public ResponseInfo mergeMultipartUpload(@RequestBody @Valid MultipartMergeParams multipartMergeParams) {if (multipartMergeParams.getChunkCount() == 1) {return new ResponseInfo(true, "单文件不进行合并", null);} else {minioUtil.mergeMultipartUpload(multipartMergeParams.getObject(), multipartMergeParams.getUploadId());return new ResponseInfo(true, "合并完成", null);}} }
CustomMinioClient.java
package com.seentao.vet.cbec.common.minio.util; import com.google.common.collect.Multimap; import io.minio.CreateMultipartUploadResponse; import io.minio.ListPartsResponse; import io.minio.MinioAsyncClient; import io.minio.ObjectWriteResponse; import io.minio.messages.Part;/*** 由于MinioAsyncClient里某些方法是protected,不可直接调用,必须创建一个自定义类继承。*/ public class CustomMinioClient extends MinioAsyncClient {/*** 继承父类* @param client*/public CustomMinioClient(MinioAsyncClient client) {super(client);}/*** 初始化分片上传、获取 uploadId* @param bucket String 存储桶名称* @param region String* @param object String 文件名称* @param headers Multimap<String, String> 请求头* @param extraQueryParams Multimap<String, String>* @return String*/public String initMultiPartUpload(String bucket, String region, String object, Multimap<String, String> headers, Multimap<String, String> extraQueryParams) throws Exception {CreateMultipartUploadResponse response = super.createMultipartUploadAsync(bucket, region, object, headers, extraQueryParams).get();return response.result().uploadId();}/*** 合并分片* @param bucketName String 桶名称* @param region String* @param objectName String 文件名称* @param uploadId String 上传的 uploadId* @param parts Part[] 分片集合* @param extraHeaders Multimap<String, String>* @param extraQueryParams Multimap<String, String>* @return ObjectWriteResponse*/public ObjectWriteResponse mergeMultipartUpload(String bucketName, String region, String objectName, String uploadId, Part[] parts, Multimap<String, String> extraHeaders, Multimap<String, String> extraQueryParams) throws Exception {return super.completeMultipartUploadAsync(bucketName, region, objectName, uploadId, parts, extraHeaders, extraQueryParams).get();}/*** 查询当前上传后的分片信息* @param bucketName String 桶名称* @param region String* @param objectName String 文件名称* @param maxParts Integer 分片数量* @param partNumberMarker Integer 分片起始值* @param uploadId String 上传的 uploadId* @param extraHeaders Multimap<String, String>* @param extraQueryParams Multimap<String, String>* @return ListPartsResponse*/public ListPartsResponse listMultipart(String bucketName, String region, String objectName, Integer maxParts, Integer partNumberMarker, String uploadId, Multimap<String, String> extraHeaders, Multimap<String, String> extraQueryParams) throws Exception {return super.listPartsAsync(bucketName, region, objectName, maxParts, partNumberMarker, uploadId, extraHeaders, extraQueryParams).get();}}
MinioUtil.java
package com.seentao.vet.cbec.common.minio.util;import cn.hutool.core.util.IdUtil; import com.google.common.collect.HashMultimap; import com.seentao.vet.cbec.common.minio.config.MinioConfig; import io.minio.GetPresignedObjectUrlArgs; import io.minio.ListPartsResponse; import io.minio.MinioAsyncClient; import io.minio.http.Method; import io.minio.messages.Part; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import javax.annotation.PostConstruct; import javax.validation.constraints.NotNull; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors;/*** MinIO接口工具类,后端与MinIO交流的桥梁。*/ @Slf4j @Component public class MinioUtil {private CustomMinioClient customMinioClient;private String bucket;@AutowiredMinioConfig minioConfig;// spring自动注入会失败 @PostConstructpublic void init() {if (!"MINIO".equals(minioConfig.getOssTypeDefault())) {throw new RuntimeException("You are about to upload files to the MinIO server, but the currently configured object storage server is not MinIO. Please check your configuration.");}MinioAsyncClient minioClient = MinioAsyncClient.builder().endpoint(minioConfig.getEndpoint()).credentials(minioConfig.getAccessKeyId(), minioConfig.getSecretAccessKey()).build();customMinioClient = new CustomMinioClient(minioClient);bucket = minioConfig.getBucketName();}/*** 获取 Minio 中已经上传的分片文件* @param object 文件名称* @param uploadId 上传的文件id(由 minio 生成)* @return List<Integer>*/@SneakyThrowspublic List<Integer> getListParts(String object, String uploadId) {List<Part> parts = getParts(object, uploadId, bucket);return parts.stream().map(Part::partNumber).collect(Collectors.toList());}/*** 单文件签名上传* @param object 文件名称(uuid 格式)* @return UploadUrlsVO*/public UploadUrlsVO getUploadObjectUrl(String contentType, String object) throws Exception {try {UploadUrlsVO urlsVO = new UploadUrlsVO();List<String> urlList = new ArrayList<>();// 主要是针对图片,若需要通过浏览器直接查看,而不是下载,需要指定对应的 content-typeHashMultimap<String, String> headers = HashMultimap.create();if (contentType == null || contentType.equals("")) {contentType = "application/octet-stream";}headers.put("Content-Type", contentType);String uploadId = IdUtil.simpleUUID(); // Map<String, String> reqParams = new HashMap<>(); // reqParams.put("uploadId", uploadId);String url = customMinioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder().method(Method.PUT).bucket(bucket).object(object).extraHeaders(headers) // .extraQueryParams(reqParams).expiry(30, TimeUnit.MINUTES).build());urlList.add(url);// urlsVO.setUploadId(uploadId); urlsVO.setUrls(urlList);return urlsVO;} catch (Exception e) {throw new Exception(e);}}/*** 初始化分片上传* @param multipartUploadParams 前端传入的文件信息* @param object object* @return UploadUrlsVO*/public UploadUrlsVO initMultiPartUpload(MultipartUploadParams multipartUploadParams, String object) throws Exception {Integer chunkCount = multipartUploadParams.getChunkCount();String contentType = multipartUploadParams.getContentType();String uploadId = multipartUploadParams.getUploadId();UploadUrlsVO urlsVO = new UploadUrlsVO();try {HashMultimap<String, String> headers = HashMultimap.create();if (contentType == null || contentType.equals("")) {contentType = "application/octet-stream";}headers.put("Content-Type", contentType);// 如果参数中有uploadId,说明是断点续传,不能重新生成 uploadIdif (StringUtils.isBlank(uploadId)) {uploadId = customMinioClient.initMultiPartUpload(bucket, null, object, headers, null);}urlsVO.setUploadId(uploadId);List<String> partList = new ArrayList<>();Map<String, String> reqParams = new HashMap<>();reqParams.put("uploadId", uploadId);for (int i = 1; i <= chunkCount; i++) {reqParams.put("partNumber", String.valueOf(i));String uploadUrl = customMinioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder().method(Method.PUT).bucket(bucket).object(object).expiry(30, TimeUnit.MINUTES).extraQueryParams(reqParams).build());partList.add(uploadUrl);}urlsVO.setUrls(partList);return urlsVO;} catch (Exception e) {throw new Exception(e);}}/*** 合并文件* @param object object* @param uploadId uploadUd*/@SneakyThrowspublic boolean mergeMultipartUpload(String object, String uploadId) {// 获取所有分片List<Part> partsList = getParts(object, uploadId, bucket);Part[] parts = new Part[partsList.size()];int partNumber = 1;for (Part part : partsList) {parts[partNumber - 1] = new Part(partNumber, part.etag());partNumber++;}// 合并分片customMinioClient.mergeMultipartUpload(bucket, null, object, uploadId, parts, null, null);return true;}@NotNullprivate List<Part> getParts(String object, String uploadId, String bucket) throws Exception {int partNumberMarker = 0;boolean isTruncated = true;List<Part> parts = new ArrayList<>();while(isTruncated){ListPartsResponse partResult = customMinioClient.listMultipart(bucket, null, object, 1000, partNumberMarker, uploadId, null, null);parts.addAll(partResult.result().partList());// 检查是否还有更多分片isTruncated = partResult.result().isTruncated();if (isTruncated) {// 更新partNumberMarker以获取下一页的分片数据partNumberMarker = partResult.result().nextPartNumberMarker();}}return parts;}}
MultipartMergeParams.java
package com.seentao.vet.cbec.common.minio.util;import lombok.Data; import lombok.experimental.Accessors;import javax.validation.constraints.NotNull;/*** 分片上传完成后请求合并,前端参数*/ @Data @Accessors(chain = true) public class MultipartMergeParams {// @NotNull(message = "存储对象不能为空") // private String bucket; @NotNull(message = "上传id不能为空")private String uploadId;@NotNull(message = "存储对象不能为空")private String object;@NotNull(message = "分片数量不能为空")private Integer chunkCount;}
MultipartUploadParams.java
package com.seentao.vet.cbec.common.minio.util;import lombok.Data; import lombok.experimental.Accessors;import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotNull; import java.util.List;/*** 分片上传前端参数*/ @Data @Accessors(chain = true) public class MultipartUploadParams {// @NotBlank(message = "bucket 不可为空") // private String bucket;private String uploadId; // 一般不用传值,但也可以传值用于断点续传。// @NotBlank(message = "文件名不能为空") // private String originFileName;// 仅秒传会有值 // private String url;// 文件名(含前缀路径)private String object;// private String type;// @NotNull(message = "文件大小不能为空") // private Long size; @NotNull(message = "分片数量不能为空")private Integer chunkCount;// @NotNull(message = "分片大小不能为空") // private Long chunkSize;private String contentType;// // listParts 从 1 开始,前端需要上传的分片索引+1 // private List<Integer> listParts; }
SingleUploadParams.java
package com.seentao.vet.cbec.common.minio.util;import lombok.Data; import lombok.experimental.Accessors;import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotNull; import java.util.List;/*** 单文件上传前端参数*/ @Data @Accessors(chain = true) public class SingleUploadParams {// @NotBlank(message = "bucket 不可为空") // private String bucket; @NotBlank(message = "object 不可为空")private String object;@NotBlank(message = "contentType 不可为空")private String contentType;}
UploadUrlsVO.java
package com.seentao.vet.cbec.common.minio.util;import lombok.Data; import lombok.experimental.Accessors;import java.util.List;/*** 返回文件生成的分片上传地址*/ @Data @Accessors(chain = true) public class UploadUrlsVO {private String uploadId;private List<String> urls; }
二、前端
api接口定义 api/minio/index.js
import createAPI from '../config'const api = createAPI() export const minioAPI = {/* MinIO api */initSingleUpload: (data) => api.post('/common/minio/single/init', data),initMultiPartUpload: (data) => api.post('/common/minio/multipart/init', data),mergeMultipartUpload: (data) => api.post('/common/minio/multipart/merge', data), }
MinioUpload.vue
<template><div class="settings-view" v-loading="loading" element-loading-text="上传中..."><h2>MinIO Upload example</h2><hr><div><input type="file" ref="fileInput"/><el-button @click="executeUpload" type="primary">Upload</el-button></div><div v-if="bigFile">大文件上传进度:<progress :value="progress" max="100" style="width:300px;"></progress> {{progress}}%</div><div>{{status}}</div><div v-if="resultUrl">上传结果URL:<el-link :href="resultUrl" target="_blank" type="primary">{{resultUrl}}</el-link></div></div> </template><script setup> import { ref } from 'vue' import { ElMessage } from 'element-plus' import { minioAPI } from '@/api/minio'const fileInput = ref() const status = ref('') const progress = ref(0) const loading = ref(false) const bigFile = ref(false) // 是否大文件 const resultUrl = ref('')// 休眠函数 function sleep(time) {return new Promise((resolve, reject) => {setTimeout(()=>resolve(), time);}); }async function executeUpload() {bigFile.value = falsestatus.value = ''progress.value = 0console.log('fileInput.value', fileInput.value)const file = fileInput.value.files[0];console.log('file.name', file.name)console.log('file.type', file.type)console.log('file.size', file.size)// 构建object名称,单文件上传、分片上传、分片合并都要用(虽然像路径字符串,实际上对于MinIO服务器它就是对象名称) const objectName = `CBEC/test/chenjye/${file.name}`// 小于5MB使用单文件上传if (file.size / 1024 / 1024 < 5) {loading.value = truestatus.value = '请求单文件上传url'const urlResponse = await minioAPI.initSingleUpload({object: objectName,contentType: file.type})console.log('urlResponse', urlResponse)if (urlResponse.success && urlResponse.data) {const uploadUrl = urlResponse.data.urlsVO.urls[0]console.log('uploadUrl', uploadUrl)status.value = '上传url:' + uploadUrlawait sleep(3000);status.value = '正在上传'const resultResponse = await fetch(uploadUrl, {method: 'PUT',headers: {'Content-Type': file.type},body: file})console.log('resultResponse', resultResponse)if (resultResponse.ok) {status.value = '上传完成'const fileUrl = uploadUrl.split('?')[0]console.log('结果url:', fileUrl)resultUrl.value = fileUrl} else {status.value = '上传失败'console.log('上传失败', resultResponse.status + " " + resultResponse.statusText + ' ' + resultResponse.type)}loading.value = false}}// 5MB及以上使用分片上传else {bigFile.value = trueprogress.value = 0status.value = '请求分片上传url'const chunkSize = 5 * 1024 * 1024 // 5MB const chunkCount = Math.ceil(file.size / chunkSize)// 初始化多片上传 const urlsResponse = await minioAPI.initMultiPartUpload({object: objectName,chunkCount: chunkCount, // 生成指定数量个上传url contentType: file.type})console.log('urlsResponse', urlsResponse)if (!urlsResponse.success) {ElMessage({ message: '上传失败', type: 'error' })return}const uploadId = urlsResponse.data.urlsVO.uploadIdconst uploadUrls = urlsResponse.data.urlsVO.urlsconsole.log('uploadId', uploadId)console.log('uploadUrls', uploadUrls)status.value = '上传url数量:' + uploadUrls.length// 并发上传分片 const uploadPromises = []for (let i = 0; i < chunkCount; i++) {const chunk = file.slice(i * chunkSize, (i + 1) * chunkSize)const uploadPromise = fetch(uploadUrls[i], {method: 'PUT',headers: {'Content-Type': file.type,// 'Content-Length': chunk.size, },body: chunk,}).then(response => {if (!response.ok) {ElMessage({ message: `分片 ${(i + 1)} 上传失败`, type: 'error' })throw new Error(`分片 ${(i + 1)} 上传失败`);}// 更新进度 progress.value = Math.round(((i + 1) / chunkCount) * 100)}).catch(err => {ElMessage({ message: `分片 ${(i + 1)} 上传失败`, type: 'error' })throw new Error(`分片 ${(i + 1)} 上传失败`);})uploadPromises.push(uploadPromise)}await Promise.all(uploadPromises)const mergeResponse = await minioAPI.mergeMultipartUpload({object: objectName,chunkCount,uploadId})console.log('mergeResponse', mergeResponse)if (mergeResponse.success) {progress.value = 100// 结果url取签名上传urls中第一个的前半部分即可 resultUrl.value = urlsResponse.data.urlsVO.urls[0].split('?')[0]status.value = '文件上传完成'console.log('文件上传完成')} else {status.value = '文件上传失败'console.log('文件上传失败')}}}</script><style scoped> </style>
参考资料:
https://blog.csdn.net/weixin_46085718/article/details/147783679
https://blog.csdn.net/weixin_47233946/article/details/148239258
https://blog.csdn.net/qq_41323045/article/details/147202372
