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 {

    @Autowired
    private 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;

        // 单文件上传,生成文件上传签名url
        urlsVO = 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;
        // 单文件上传,生成文件上传签名url
        if (multipartUploadParams.getChunkCount() == 1) {
            urlsVO = minioUtil.getUploadObjectUrl(multipartUploadParams.getContentType(), object);
        }
        // 分片上传,生成uploadId及文件上传签名url
        else {
            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;

    @Autowired
    MinioConfig minioConfig;

    // spring自动注入会失败
    @PostConstruct
    public 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>
     */
    @SneakyThrows
    public 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-type
            HashMultimap<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,说明是断点续传,不能重新生成 uploadId
            if (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
     */
    @SneakyThrows
    public 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;
    }

    @NotNull
    private  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 = false
  status.value = ''
  progress.value = 0

  console.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 = true
    status.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:' + uploadUrl
      await 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 = true
    progress.value = 0
    status.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.uploadId
    const uploadUrls = urlsResponse.data.urlsVO.urls
    console.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

 

posted @ 2025-08-12 11:49  jsper  阅读(192)  评论(0)    收藏  举报