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

Springboot+vue3 MinIO文件前端直传例子

代码基于Springboot (maven) + vue3 (element-plus)

上传MinIO即可以后端传也可以前端传,如果文件是从前端提交最好还是前端传比较好,通过MinIO生成签名URL能保证安全,前端直传省去后端中转也能提高传输效率。

以下例子后端部分仅支持了生成签名url提供前端直传的机制,无后端上传功能,若需要请参考其他文章。

支持单文件一次性上传、大文件分片上传。

 

后端提供了3个接口:

1、获取单文件上传签名URL

2、初始化分片上传并获取签名分片URLs

3、合并分片为整体文件

 

image

 

 

image

 

一、后端

maven pom.xml

        <!-- MinIO SDK --><dependency><groupId>io.minio</groupId><artifactId>minio</artifactId><version>8.4.3</version></dependency>
View Code

 

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;}
View Code

 

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);}}
}
View Code


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();}}
View Code


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;}}
View Code

 

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;}
View Code


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;

}
View Code


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;}
View Code


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;
}
View Code

 

二、前端

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),
}
View Code

 

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>&emsp;{{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>
View Code

 

参考资料:

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

 

http://www.aitangshan.cn/news/688.html

相关文章:

  • 【刷题笔记】日照集训 Day3
  • GAS_Aura-The Gameplay Ability System
  • 深度解析10BASE-T1S PLCA的多节点通信效率
  • ESP32 + PCA9685(16通道 PWM 扩展模块)来驱动多个 9g 舵机
  • k8s 新版创建完 serviceaccount 后-- 不再生成 对应的--token
  • 验证码厂商对比及选型
  • debian更换NVIDIA 官方驱动
  • 经纬恒润推动汽车软件安全新生态,打造全流程质量协同新范式
  • 2025杭电多校第七场 矩形框选、伤害冷却比 个人题解 - CUC
  • 7 月 SeaTunnel 社区狂飙:新特性、强优化、贡献者满分输出
  • 在K8S中,假设一家基于整体架构的公司处理许多产品。现在,随着公司在当今规模化行业中的发展,其整体架构开始引起问题,如何看待公司从单一服务转向微服务并部署其服务容器?
  • GAS_Aura-Post Process Highlight
  • Host startup hook
  • 育儿计划
  • 在请求目标中找到无效字符。有效字符在RFC 7230和RFC 3986中定义处理方式
  • docker run 后报错/bin/bash: /bin/bash: cannot execute binary file
  • Proteus 9.0 SP2 安装使用图文指南 | EDA电路仿真软件
  • Claude Code使用指南
  • C++ 去除字符串中的控制字符
  • 芯片安全标准驱动库,筑牢芯片功能安全基石
  • windows实现键盘记录
  • Linux 安装 Nginx 并配置为开机自启动
  • 在K8S中,有一种情况,公司希望通过保持最低成本来提高效率和技术运营速度,该公司实该如何现这一目标?
  • 基于MATLAB的单目深度估计神经网络实现指南
  • DLL Injection for Notepad
  • 在K8S中,有一家公司想要修改其部署方法,并希望构建一个可扩展性和响应性更高的平台,该公司要如何实现这一目标以满足他们的客户?
  • 记一次 .NET 某汽车控制焊接软件 卡死分析
  • 在K8S中,我们都知道从单服务到微服务的转变从开发方面解决了问题,但在部署方面却增加了问题,公司该如何解决部署方面的问题?
  • 扣子 Coze 产品体验功能
  • 为什么现在的音乐+图片的多媒体形式的感染力这么强