Spring Boot + MinIO 文件上传工具类

📌 引言

MinIO 是一个高性能的分布式对象存储系统,它与 Amazon S3 API 兼容,被广泛用于存储非结构化数据(如图片、视频、日志文件、备份文件等)。相比传统文件系统,MinIO 提供了更好的扩展性、高可用性和安全性。

MinIO 的核心特点

  • 高性能:基于 Golang 开发,性能优异,支持大规模并发
  • S3 兼容:完全兼容 AWS S3 API,易于迁移和集成
  • 轻量级:单节点部署简单,资源占用低
  • 分布式:支持分布式部署,数据自动分片和冗余
  • 安全性:支持服务端加密、数据完整性校验
  • 开源免费:采用 AGPLv3 许可证,可自由使用和修改

典型应用场景

  • 电商平台:商品图片、商品视频存储
  • 内容管理:CMS 系统的媒体文件管理
  • 日志归档:应用日志、审计日志的集中存储
  • 备份存储:数据库备份、文件备份
  • 移动应用:用户头像、用户上传文件存储
  • 远程文件采集:从外部URL抓取并存储资源

🔧 环境准备

1. 开发环境要求

  • JDK:JDK 8 或更高版本
  • Spring Boot:2.x 或 3.x
  • Maven:3.6+
  • IDE:IntelliJ IDEA 或 Eclipse

2. MinIO 服务搭建

方式一:Docker 快速部署(推荐)

bash 复制代码
# 拉取 MinIO 镜像
docker pull minio/minio

# 启动 MinIO 服务
docker run -d \
  -p 9000:9000 \
  -p 9001:9001 \
  --name minio \
  -e "MINIO_ROOT_USER=minioadmin" \
  -e "MINIO_ROOT_PASSWORD=minioadmin" \
  -v /data/minio:/data \
  minio/minio server /data --console-address ":9001"

参数说明:

  • -p 9000:9000:API 服务端口
  • -p 9001:9001:Web 控制台端口
  • MINIO_ROOT_USER:管理员账号
  • MINIO_ROOT_PASSWORD:管理员密码
  • -v /data/minio:/data:数据持久化目录

方式二:二进制文件部署

bash 复制代码
# 下载 MinIO 二进制文件
wget https://dl.min.io/server/minio/release/linux-amd64/minio

# 赋予执行权限
chmod +x minio

# 启动服务
./minio server /data --console-address ":9001"

访问 MinIO 控制台

启动成功后,访问 http://localhost:9001,使用设置的账号密码登录。

3. Spring Boot 项目配置

3.1 添加 Maven 依赖

pom.xml 中添加 MinIO Java SDK 依赖:

xml 复制代码
<dependencies>
    <!-- MinIO Java SDK -->
    <dependency>
        <groupId>io.minio</groupId>
        <artifactId>minio</artifactId>
        <version>8.5.7</version>
    </dependency>

    <!-- Spring Boot Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- Lombok(可选,简化代码) -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

3.2 配置文件

application.ymlapplication.properties 中配置 MinIO 连接信息:

yaml 复制代码
# application.yml
minio:
  endpoint: http://localhost:9000        # MinIO 服务地址
  accessKey: minioadmin                 # 访问密钥
  secretKey: minioadmin                 # 密钥
  bucketName: my-bucket                 # 默认存储桶名称

或使用 properties 格式:

properties 复制代码
# application.properties
minio.endpoint=http://localhost:9000
minio.accessKey=minioadmin
minio.secretKey=minioadmin
minio.bucketName=my-bucket

💡 核心实现

架构设计

复制代码
┌─────────────┐      ┌──────────────────┐      ┌─────────────┐
│  Controller │ ───> │  FileUploadUtil  │ ───> │   MinIO     │
│             │      │                  │      │  Server     │
└─────────────┘      └──────────────────┘      └─────────────┘
                            │
                            ├── 初始化客户端
                            ├── 文件上传(MultipartFile)
                            ├── 流式上传
                            ├── 远程URL上传
                            ├── 文件下载
                            ├── URL 生成
                            ├── 文件删除
                            └── 文件管理

工具类核心功能模块

1. 客户端初始化与配置

工具类通过 @PostConstruct 注解在 Bean 实例化后自动执行初始化逻辑,包括:

  • 创建 MinIO 客户端实例
  • 检查并创建默认存储桶
  • 记录初始化日志

2. 文件上传功能

支持三种上传方式:

  • MultipartFile 上传:适用于 Web 文件上传场景
  • 流式上传:适用于大文件或网络流上传
  • 远程URL上传:从 HTTP/HTTPS URL 直接拉取文件并上传

3. 文件访问功能

提供两种 URL 生成方式:

  • 永久 URL:基于 Endpoint 直接拼接
  • 临时 URL:带过期时间的预签名 URL

4. 文件管理功能

  • 文件删除(单个/批量)
  • 文件存在性检查
  • 文件复制
  • 文件信息获取

5. 远程文件采集

  • 支持从任意 HTTP/HTTPS URL 上传文件
  • 自动识别文件类型
  • 支持批量远程文件上传
  • 完善的超时控制和异常处理

6. 异常处理与日志

  • 统一异常捕获与转换
  • 详细的操作日志记录
  • 友好的错误信息提示

完整工具类代码

以下是完整的 MinIO 文件上传工具类代码,包含所有核心功能:

java 复制代码
package com.example.minio.util;

import io.minio.*;
import io.minio.errors.*;
import io.minio.http.Method;
import io.minio.messages.BucketExistsArgs;
import io.minio.messages.DeleteError;
import io.minio.messages.DeleteObject;
import io.minio.messages.RemoveBucketArgs;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

import javax.annotation.PostConstruct;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.*;
import java.util.concurrent.TimeUnit;

/**
 * MinIO文件上传工具类
 * 提供完整的MinIO对象存储服务操作功能,包括:
 * - 文件上传(支持MultipartFile、流式上传、远程URL上传)
 * - 文件下载/访问URL生成
 * - 文件管理(删除、检查存在、复制)
 * - 异常处理与日志记录
 *
 * @author System
 * @since 2026-01-28
 */
@Component
public class MinIOFileUploadUtil {

    private static final Logger logger = LoggerFactory.getLogger(MinIOFileUploadUtil.class);

    /**
     * 默认连接超时时间(毫秒)
     */
    private static final int DEFAULT_CONNECT_TIMEOUT = 10000;
    
    /**
     * 默认读取超时时间(毫秒)
     */
    private static final int DEFAULT_READ_TIMEOUT = 30000;

    @Value("${minio.endpoint}")
    private String endpoint;

    @Value("${minio.accessKey}")
    private String accessKey;

    @Value("${minio.secretKey}")
    private String secretKey;

    @Value("${minio.bucketName}")
    private String defaultBucketName;

    private MinioClient minioClient;

    /**
     * 初始化MinIO客户端
     * 在Bean实例化后自动执行
     */
    @PostConstruct
    public void init() {
        try {
            this.minioClient = MinioClient.builder()
                    .endpoint(endpoint)
                    .credentials(accessKey, secretKey)
                    .build();

            // 确保默认存储桶存在
            ensureBucketExists(defaultBucketName);

            logger.info("MinIO客户端初始化成功,endpoint: {}", endpoint);
        } catch (Exception e) {
            logger.error("MinIO客户端初始化失败", e);
            throw new RuntimeException("MinIO客户端初始化失败", e);
        }
    }

    /**
     * 检查并创建存储桶(如果不存在)
     *
     * @param bucketName 存储桶名称
     * @throws Exception 异常
     */
    private void ensureBucketExists(String bucketName) throws Exception {
        boolean exists = bucketExists(bucketName);
        if (!exists) {
            makeBucket(bucketName);
            logger.info("存储桶 [{}] 创建成功", bucketName);
        } else {
            logger.info("存储桶 [{}] 已存在", bucketName);
        }
    }

    /**
     * 检查存储桶是否存在
     *
     * @param bucketName 存储桶名称
     * @return true-存在,false-不存在
     */
    public boolean bucketExists(String bucketName) {
        try {
            BucketExistsArgs args = BucketExistsArgs.builder()
                    .bucket(bucketName)
                    .build();
            return minioClient.bucketExists(args);
        } catch (Exception e) {
            logger.error("检查存储桶存在性失败,bucketName: {}", bucketName, e);
            return false;
        }
    }

    /**
     * 创建存储桶
     *
     * @param bucketName 存储桶名称
     * @throws Exception 异常
     */
    public void makeBucket(String bucketName) throws Exception {
        minioClient.makeBucket(
                MakeBucketArgs.builder()
                        .bucket(bucketName)
                        .build()
        );
    }

    /**
     * 上传文件(MultipartFile方式)
     *
     * @param file      上传的文件
     * @param objectName 对象名称(文件在MinIO中的路径)
     * @return 文件访问URL
     */
    public String uploadFile(MultipartFile file, String objectName) {
        return uploadFile(file, objectName, defaultBucketName);
    }

    /**
     * 上传文件到指定存储桶
     *
     * @param file       上传的文件
     * @param objectName 对象名称
     * @param bucketName 存储桶名称
     * @return 文件访问URL
     */
    public String uploadFile(MultipartFile file, String objectName, String bucketName) {
        try {
            InputStream inputStream = file.getInputStream();

            // 设置文件元数据
            Map<String, String> headers = new HashMap<>();
            headers.put("Content-Type", file.getContentType());
            headers.put("Content-Disposition", "inline; filename=" + file.getOriginalFilename());

            // 上传文件
            ObjectWriteResponse response = minioClient.putObject(
                    PutObjectArgs.builder()
                            .bucket(bucketName)
                            .object(objectName)
                            .stream(inputStream, file.getSize(), -1)
                            .contentType(file.getContentType())
                            .headers(headers)
                            .build()
            );

            String fileUrl = getFileUrl(objectName, bucketName);
            logger.info("文件上传成功,bucket: {}, object: {}, etag: {}", bucketName, objectName, response.etag());

            return fileUrl;

        } catch (Exception e) {
            logger.error("文件上传失败,objectName: {}, bucketName: {}", objectName, bucketName, e);
            throw new RuntimeException("文件上传失败: " + e.getMessage(), e);
        }
    }

    /**
     * 流式上传文件
     *
     * @param inputStream 文件输入流
     * @param objectName  对象名称
     * @param contentType 内容类型
     * @param size        文件大小
     * @return 文件访问URL
     */
    public String uploadStream(InputStream inputStream, String objectName, String contentType, long size) {
        return uploadStream(inputStream, objectName, contentType, size, defaultBucketName);
    }

    /**
     * 流式上传到指定存储桶
     *
     * @param inputStream 文件输入流
     * @param objectName  对象名称
     * @param contentType 内容类型
     * @param size        文件大小
     * @param bucketName  存储桶名称
     * @return 文件访问URL
     */
    public String uploadStream(InputStream inputStream, String objectName, String contentType, long size, String bucketName) {
        try {
            ObjectWriteResponse response = minioClient.putObject(
                    PutObjectArgs.builder()
                            .bucket(bucketName)
                            .object(objectName)
                            .stream(inputStream, size, -1)
                            .contentType(contentType)
                            .build()
            );

            String fileUrl = getFileUrl(objectName, bucketName);
            logger.info("流式文件上传成功,bucket: {}, object: {}", bucketName, objectName);

            return fileUrl;

        } catch (Exception e) {
            logger.error("流式文件上传失败,objectName: {}", objectName, e);
            throw new RuntimeException("流式文件上传失败: " + e.getMessage(), e);
        }
    }

    /**
     * 从远程URL上传文件到MinIO
     * 支持HTTP/HTTPS协议,自动识别文件类型
     *
     * @param fileUrl     远程文件URL(如:https://example.com/image.jpg)
     * @param objectName  对象名称(文件在MinIO中的路径)
     * @return 文件访问URL
     */
    public String uploadFromRemoteUrl(String fileUrl, String objectName) {
        return uploadFromRemoteUrl(fileUrl, objectName, defaultBucketName, null);
    }

    /**
     * 从远程URL上传文件到MinIO(指定内容类型)
     *
     * @param fileUrl     远程文件URL
     * @param objectName  对象名称
     * @param bucketName  存储桶名称
     * @param contentType 内容类型(可选,不指定则自动识别)
     * @return 文件访问URL
     */
    public String uploadFromRemoteUrl(String fileUrl, String objectName, String bucketName, String contentType) {
        return uploadFromRemoteUrl(fileUrl, objectName, bucketName, contentType, 
                                   DEFAULT_CONNECT_TIMEOUT, DEFAULT_READ_TIMEOUT);
    }

    /**
     * 从远程URL上传文件到MinIO(完整参数版本)
     *
     * @param fileUrl         远程文件URL
     * @param objectName      对象名称
     * @param bucketName      存储桶名称
     * @param contentType     内容类型(可选)
     * @param connectTimeout  连接超时时间(毫秒)
     * @param readTimeout     读取超时时间(毫秒)
     * @return 文件访问URL
     */
    public String uploadFromRemoteUrl(String fileUrl, String objectName, String bucketName, 
                                       String contentType, int connectTimeout, int readTimeout) {
        HttpURLConnection connection = null;
        InputStream inputStream = null;
        
        try {
            logger.info("开始从远程URL上传文件,url: {}, objectName: {}", fileUrl, objectName);
            
            // 创建HTTP连接
            URL url = new URL(fileUrl);
            connection = (HttpURLConnection) url.openConnection();
            
            // 设置连接参数
            connection.setRequestMethod("GET");
            connection.setConnectTimeout(connectTimeout);
            connection.setReadTimeout(readTimeout);
            connection.setInstanceFollowRedirects(true); // 自动跟随重定向
            
            // 设置User-Agent,避免某些服务器拒绝请求
            connection.setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
            
            // 连接并获取响应码
            int responseCode = connection.getResponseCode();
            
            if (responseCode != HttpURLConnection.HTTP_OK) {
                throw new IOException("远程文件访问失败,HTTP响应码: " + responseCode);
            }
            
            // 获取输入流
            inputStream = connection.getInputStream();
            
            // 获取文件大小
            long fileSize = connection.getContentLengthLong();
            if (fileSize <= 0) {
                fileSize = -1; // 未知大小
                logger.warn("无法获取远程文件大小,将使用流式上传");
            }
            
            // 自动识别内容类型
            if (contentType == null || contentType.isEmpty()) {
                contentType = connection.getContentType();
                if (contentType == null || contentType.isEmpty()) {
                    contentType = "application/octet-stream"; // 默认二进制流
                    logger.warn("无法识别内容类型,使用默认类型: {}", contentType);
                }
            }
            
            // 上传到MinIO
            ObjectWriteResponse response = minioClient.putObject(
                    PutObjectArgs.builder()
                            .bucket(bucketName)
                            .object(objectName)
                            .stream(inputStream, fileSize, -1)
                            .contentType(contentType)
                            .build()
            );
            
            String minioFileUrl = getFileUrl(objectName, bucketName);
            logger.info("远程文件上传成功,url: {}, bucket: {}, object: {}, etag: {}", 
                       fileUrl, bucketName, objectName, response.etag());
            
            return minioFileUrl;
            
        } catch (Exception e) {
            logger.error("从远程URL上传文件失败,url: {}, objectName: {}", fileUrl, objectName, e);
            throw new RuntimeException("从远程URL上传文件失败: " + e.getMessage(), e);
        } finally {
            // 关闭资源
            if (inputStream != null) {
                try {
                    inputStream.close();
                } catch (IOException e) {
                    logger.error("关闭输入流失败", e);
                }
            }
            if (connection != null) {
                connection.disconnect();
            }
        }
    }

    /**
     * 批量从远程URL上传文件
     *
     * @param fileUrlMap  URL映射表(URL -> 对象名称)
     * @return 上传结果统计
     */
    public Map<String, Object> batchUploadFromRemoteUrls(Map<String, String> fileUrlMap) {
        return batchUploadFromRemoteUrls(fileUrlMap, defaultBucketName);
    }

    /**
     * 批量从远程URL上传文件到指定存储桶
     *
     * @param fileUrlMap  URL映射表(URL -> 对象名称)
     * @param bucketName  存储桶名称
     * @return 上传结果统计
     */
    public Map<String, Object> batchUploadFromRemoteUrls(Map<String, String> fileUrlMap, String bucketName) {
        Map<String, Object> result = new HashMap<>();
        int successCount = 0;
        int failCount = 0;
        List<String> failedUrls = new ArrayList<>();
        Map<String, String> successUrls = new HashMap<>(); // URL -> MinIO URL
        
        logger.info("开始批量从远程URL上传文件,总数: {}", fileUrlMap.size());
        
        for (Map.Entry<String, String> entry : fileUrlMap.entrySet()) {
            String fileUrl = entry.getKey();
            String objectName = entry.getValue();
            
            try {
                String minioUrl = uploadFromRemoteUrl(fileUrl, objectName, bucketName, null);
                successCount++;
                successUrls.put(fileUrl, minioUrl);
                
            } catch (Exception e) {
                failCount++;
                failedUrls.add(fileUrl);
                logger.error("批量上传失败,url: {}, error: {}", fileUrl, e.getMessage());
            }
        }
        
        logger.info("批量上传完成,成功: {}, 失败: {}", successCount, failCount);
        
        result.put("totalCount", fileUrlMap.size());
        result.put("successCount", successCount);
        result.put("failCount", failCount);
        result.put("successUrls", successUrls);
        result.put("failedUrls", failedUrls);
        
        return result;
    }

    /**
     * 生成文件访问URL(永久URL)
     *
     * @param objectName 对象名称
     * @return 文件访问URL
     */
    public String getFileUrl(String objectName) {
        return getFileUrl(objectName, defaultBucketName);
    }

    /**
     * 生成指定存储桶的文件访问URL
     *
     * @param objectName 对象名称
     * @param bucketName 存储桶名称
     * @return 文件访问URL
     */
    public String getFileUrl(String objectName, String bucketName) {
        return String.format("%s/%s/%s", endpoint, bucketName, objectName);
    }

    /**
     * 生成临时访问URL(带过期时间)
     *
     * @param objectName 对象名称
     * @param expires    过期时间(秒)
     * @return 临时访问URL
     */
    public String getPresignedUrl(String objectName, int expires) {
        return getPresignedUrl(objectName, defaultBucketName, expires);
    }

    /**
     * 生成指定存储桶的临时访问URL
     *
     * @param objectName 对象名称
     * @param bucketName 存储桶名称
     * @param expires    过期时间(秒)
     * @return 临时访问URL
     */
    public String getPresignedUrl(String objectName, String bucketName, int expires) {
        try {
            return minioClient.getPresignedObjectUrl(
                    GetPresignedObjectUrlArgs.builder()
                            .method(Method.GET)
                            .bucket(bucketName)
                            .object(objectName)
                            .expiry(expires, TimeUnit.SECONDS)
                            .build()
            );
        } catch (Exception e) {
            logger.error("生成临时URL失败,objectName: {}", objectName, e);
            throw new RuntimeException("生成临时URL失败: " + e.getMessage(), e);
        }
    }

    /**
     * 下载文件
     *
     * @param objectName 对象名称
     * @return 文件输入流
     */
    public InputStream downloadFile(String objectName) {
        return downloadFile(objectName, defaultBucketName);
    }

    /**
     * 从指定存储桶下载文件
     *
     * @param objectName 对象名称
     * @param bucketName 存储桶名称
     * @return 文件输入流
     */
    public InputStream downloadFile(String objectName, String bucketName) {
        try {
            return minioClient.getObject(
                    GetObjectArgs.builder()
                            .bucket(bucketName)
                            .object(objectName)
                            .build()
            );
        } catch (Exception e) {
            logger.error("文件下载失败,objectName: {}, bucketName: {}", objectName, bucketName, e);
            throw new RuntimeException("文件下载失败: " + e.getMessage(), e);
        }
    }

    /**
     * 删除文件
     *
     * @param objectName 对象名称
     * @return true-删除成功,false-删除失败
     */
    public boolean deleteFile(String objectName) {
        return deleteFile(objectName, defaultBucketName);
    }

    /**
     * 从指定存储桶删除文件
     *
     * @param objectName 对象名称
     * @param bucketName 存储桶名称
     * @return true-删除成功,false-删除失败
     */
    public boolean deleteFile(String objectName, String bucketName) {
        try {
            minioClient.removeObject(
                    RemoveObjectArgs.builder()
                            .bucket(bucketName)
                            .object(objectName)
                            .build()
            );
            logger.info("文件删除成功,bucket: {}, object: {}", bucketName, objectName);
            return true;
        } catch (Exception e) {
            logger.error("文件删除失败,objectName: {}, bucketName: {}", objectName, bucketName, e);
            return false;
        }
    }

    /**
     * 批量删除文件
     *
     * @param objectNames 对象名称列表
     * @return 删除结果统计
     */
    public Map<String, Object> deleteFiles(List<String> objectNames) {
        return deleteFiles(objectNames, defaultBucketName);
    }

    /**
     * 从指定存储桶批量删除文件
     *
     * @param objectNames 对象名称列表
     * @param bucketName  存储桶名称
     * @return 删除结果统计
     */
    public Map<String, Object> deleteFiles(List<String> objectNames, String bucketName) {
        Map<String, Object> result = new HashMap<>();
        int successCount = 0;
        int failCount = 0;

        try {
            List<DeleteObject> deleteObjects = new ArrayList<>();
            for (String objectName : objectNames) {
                deleteObjects.add(new DeleteObject(objectName));
            }

            Iterable<Result<DeleteError>> results = minioClient.removeObjects(
                    RemoveObjectsArgs.builder()
                            .bucket(bucketName)
                            .objects(deleteObjects)
                            .build()
            );

            for (Result<DeleteError> resultItem : results) {
                DeleteError error = resultItem.get();
                logger.error("删除文件失败: objectName={}, message={}", error.objectName(), error.message());
                failCount++;
            }

            successCount = objectNames.size() - failCount;
            logger.info("批量删除完成,成功: {}, 失败: {}", successCount, failCount);

        } catch (Exception e) {
            logger.error("批量删除失败", e);
            failCount = objectNames.size();
        }

        result.put("successCount", successCount);
        result.put("failCount", failCount);
        result.put("totalCount", objectNames.size());

        return result;
    }

    /**
     * 检查文件是否存在
     *
     * @param objectName 对象名称
     * @return true-存在,false-不存在
     */
    public boolean fileExists(String objectName) {
        return fileExists(objectName, defaultBucketName);
    }

    /**
     * 检查指定存储桶中的文件是否存在
     *
     * @param objectName 对象名称
     * @param bucketName 存储桶名称
     * @return true-存在,false-不存在
     */
    public boolean fileExists(String objectName, String bucketName) {
        try {
            minioClient.statObject(
                    StatObjectArgs.builder()
                            .bucket(bucketName)
                            .object(objectName)
                            .build()
            );
            return true;
        } catch (ErrorResponseException e) {
            if (e.errorResponse().code().equals("NoSuchKey")) {
                return false;
            }
            logger.error("检查文件存在性失败,objectName: {}, bucketName: {}", objectName, bucketName, e);
            return false;
        } catch (Exception e) {
            logger.error("检查文件存在性异常,objectName: {}, bucketName: {}", objectName, bucketName, e);
            return false;
        }
    }

    /**
     * 复制文件
     *
     * @param sourceObject 源对象名称
     * @param targetObject 目标对象名称
     * @return true-复制成功,false-复制失败
     */
    public boolean copyFile(String sourceObject, String targetObject) {
        return copyFile(sourceObject, targetObject, defaultBucketName, defaultBucketName);
    }

    /**
     * 跨存储桶复制文件
     *
     * @param sourceObject     源对象名称
     * @param targetObject     目标对象名称
     * @param sourceBucketName 源存储桶名称
     * @param targetBucketName 目标存储桶名称
     * @return true-复制成功,false-复制失败
     */
    public boolean copyFile(String sourceObject, String targetObject, String sourceBucketName, String targetBucketName) {
        try {
            minioClient.copyObject(
                    CopyObjectArgs.builder()
                            .bucket(targetBucketName)
                            .object(targetObject)
                            .source(
                                    CopySource.builder()
                                            .bucket(sourceBucketName)
                                            .object(sourceObject)
                                            .build()
                            )
                            .build()
            );
            logger.info("文件复制成功,从 {}:{} 到 {}:{}", sourceBucketName, sourceObject, targetBucketName, targetObject);
            return true;
        } catch (Exception e) {
            logger.error("文件复制失败", e);
            return false;
        }
    }

    /**
     * 获取文件信息
     *
     * @param objectName 对象名称
     * @return 文件信息
     */
    public Map<String, Object> getFileInfo(String objectName) {
        return getFileInfo(objectName, defaultBucketName);
    }

    /**
     * 获取指定存储桶的文件信息
     *
     * @param objectName 对象名称
     * @param bucketName 存储桶名称
     * @return 文件信息
     */
    public Map<String, Object> getFileInfo(String objectName, String bucketName) {
        try {
            StatObjectResponse stat = minioClient.statObject(
                    StatObjectArgs.builder()
                            .bucket(bucketName)
                            .object(objectName)
                            .build()
            );

            Map<String, Object> fileInfo = new HashMap<>();
            fileInfo.put("objectName", objectName);
            fileInfo.put("bucketName", bucketName);
            fileInfo.put("size", stat.size());
            fileInfo.put("contentType", stat.contentType());
            fileInfo.put("lastModified", stat.lastModified());
            fileInfo.put("etag", stat.etag());

            return fileInfo;

        } catch (Exception e) {
            logger.error("获取文件信息失败,objectName: {}", objectName, e);
            return null;
        }
    }
}

🚀 使用示例

1. 创建 Controller 层

java 复制代码
package com.example.minio.controller;

import com.example.minio.util.MinIOFileUploadUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.io.InputStream;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * 文件上传控制器
 */
@RestController
@RequestMapping("/api/file")
public class FileUploadController {

    @Autowired
    private MinIOFileUploadUtil fileUploadUtil;

    /**
     * 单文件上传
     */
    @PostMapping("/upload")
    public ResponseEntity<Map<String, Object>> uploadFile(
            @RequestParam("file") MultipartFile file,
            @RequestParam(value = "objectName", required = false) String objectName) {
        
        Map<String, Object> result = new HashMap<>();
        
        try {
            // 如果未指定 objectName,使用原文件名
            if (objectName == null || objectName.isEmpty()) {
                objectName = file.getOriginalFilename();
            }
            
            // 添加时间戳避免文件名冲突
            objectName = System.currentTimeMillis() + "_" + objectName;
            
            // 上传文件
            String fileUrl = fileUploadUtil.uploadFile(file, objectName);
            
            result.put("success", true);
            result.put("message", "文件上传成功");
            result.put("data", Map.of(
                "objectName", objectName,
                "fileUrl", fileUrl
            ));
            
            return ResponseEntity.ok(result);
            
        } catch (Exception e) {
            result.put("success", false);
            result.put("message", "文件上传失败: " + e.getMessage());
            return ResponseEntity.status(500).body(result);
        }
    }

    /**
     * 从远程URL上传文件
     */
    @PostMapping("/uploadFromRemote")
    public ResponseEntity<Map<String, Object>> uploadFromRemote(
            @RequestParam("fileUrl") String fileUrl,
            @RequestParam(value = "objectName", required = false) String objectName) {
        
        Map<String, Object> result = new HashMap<>();
        
        try {
            // 如果未指定 objectName,从URL提取文件名
            if (objectName == null || objectName.isEmpty()) {
                String[] parts = fileUrl.split("/");
                objectName = parts[parts.length - 1];
                // 添加时间戳避免文件名冲突
                objectName = System.currentTimeMillis() + "_" + objectName;
            }
            
            // 从远程URL上传文件
            String minioUrl = fileUploadUtil.uploadFromRemoteUrl(fileUrl, objectName);
            
            result.put("success", true);
            result.put("message", "远程文件上传成功");
            result.put("data", Map.of(
                "objectName", objectName,
                "sourceUrl", fileUrl,
                "minioUrl", minioUrl
            ));
            
            return ResponseEntity.ok(result);
            
        } catch (Exception e) {
            result.put("success", false);
            result.put("message", "远程文件上传失败: " + e.getMessage());
            return ResponseEntity.status(500).body(result);
        }
    }

    /**
     * 批量从远程URL上传文件
     */
    @PostMapping("/batchUploadFromRemote")
    public ResponseEntity<Map<String, Object>> batchUploadFromRemote(
            @RequestBody Map<String, String> urlMap) {
        
        Map<String, Object> result = new HashMap<>();
        
        try {
            // 批量上传
            Map<String, Object> uploadResult = fileUploadUtil.batchUploadFromRemoteUrls(urlMap);
            
            result.put("success", true);
            result.put("message", "批量上传完成");
            result.put("data", uploadResult);
            
            return ResponseEntity.ok(result);
            
        } catch (Exception e) {
            result.put("success", false);
            result.put("message", "批量上传失败: " + e.getMessage());
            return ResponseEntity.status(500).body(result);
        }
    }

    /**
     * 获取文件访问URL
     */
    @GetMapping("/url/{objectName}")
    public ResponseEntity<Map<String, Object>> getFileUrl(
            @PathVariable String objectName,
            @RequestParam(value = "expires", required = false, defaultValue = "3600") int expires) {
        
        Map<String, Object> result = new HashMap<>();
        
        try {
            // 生成临时访问URL(默认1小时过期)
            String url = fileUploadUtil.getPresignedUrl(objectName, expires);
            
            result.put("success", true);
            result.put("data", Map.of(
                "url", url,
                "expires", expires
            ));
            
            return ResponseEntity.ok(result);
            
        } catch (Exception e) {
            result.put("success", false);
            result.put("message", "生成URL失败: " + e.getMessage());
            return ResponseEntity.status(500).body(result);
        }
    }

    /**
     * 下载文件
     */
    @GetMapping("/download/{objectName}")
    public ResponseEntity<InputStream> downloadFile(@PathVariable String objectName) {
        try {
            InputStream inputStream = fileUploadUtil.downloadFile(objectName);
            
            return ResponseEntity.ok()
                    .header("Content-Disposition", "attachment; filename=" + objectName)
                    .body(inputStream);
                    
        } catch (Exception e) {
            return ResponseEntity.status(500).build();
        }
    }

    /**
     * 删除文件
     */
    @DeleteMapping("/{objectName}")
    public ResponseEntity<Map<String, Object>> deleteFile(@PathVariable String objectName) {
        Map<String, Object> result = new HashMap<>();
        
        try {
            boolean deleted = fileUploadUtil.deleteFile(objectName);
            
            result.put("success", deleted);
            result.put("message", deleted ? "删除成功" : "删除失败");
            
            return ResponseEntity.ok(result);
            
        } catch (Exception e) {
            result.put("success", false);
            result.put("message", "删除失败: " + e.getMessage());
            return ResponseEntity.status(500).body(result);
        }
    }

    /**
     * 检查文件是否存在
     */
    @GetMapping("/exists/{objectName}")
    public ResponseEntity<Map<String, Object>> checkFileExists(@PathVariable String objectName) {
        Map<String, Object> result = new HashMap<>();
        
        boolean exists = fileUploadUtil.fileExists(objectName);
        
        result.put("exists", exists);
        result.put("objectName", objectName);
        
        return ResponseEntity.ok(result);
    }
}

2. 远程文件上传示例

单个远程文件上传

java 复制代码
@Service
public class RemoteFileService {

    @Autowired
    private MinIOFileUploadUtil fileUploadUtil;
    
    /**
     * 从外部URL采集图片并存储
     */
    public String crawlAndStoreImage(String imageUrl, String productId) {
        try {
            // 生成唯一的文件名
            String objectName = String.format("products/%s/%s.jpg", 
                productId, 
                System.currentTimeMillis()
            );
            
            // 从远程URL上传到MinIO
            String minioUrl = fileUploadUtil.uploadFromRemoteUrl(imageUrl, objectName);
            
            logger.info("图片采集成功,原始URL: {}, MinIO URL: {}", imageUrl, minioUrl);
            
            return minioUrl;
            
        } catch (Exception e) {
            logger.error("图片采集失败,URL: {}", imageUrl, e);
            throw new RuntimeException("图片采集失败", e);
        }
    }
}

批量远程文件上传

java 复制代码
@Service
public class BatchFileService {

    @Autowired
    private MinIOFileUploadUtil fileUploadUtil;
    
    /**
     * 批量采集商品图片
     */
    public Map<String, Object> batchCrawlProductImages(List<String> imageUrls, String productId) {
        // 构建URL映射表
        Map<String, String> urlMap = new HashMap<>();
        
        for (int i = 0; i < imageUrls.size(); i++) {
            String imageUrl = imageUrls.get(i);
            String objectName = String.format("products/%s/image_%d.jpg", productId, i + 1);
            urlMap.put(imageUrl, objectName);
        }
        
        // 批量上传
        Map<String, Object> result = fileUploadUtil.batchUploadFromRemoteUrls(urlMap);
        
        logger.info("批量采集完成,总数: {}, 成功: {}, 失败: {}", 
            result.get("totalCount"),
            result.get("successCount"),
            result.get("failCount")
        );
        
        return result;
    }
}

3. 集成到业务服务

java 复制代码
@Service
public class UserService {

    @Autowired
    private MinIOFileUploadUtil fileUploadUtil;
    
    /**
     * 用户头像上传
     */
    public String uploadUserAvatar(Long userId, MultipartFile avatarFile) {
        // 生成唯一的文件名:user/avatar/{userId}/avatar_{timestamp}.jpg
        String objectName = String.format("user/avatar/%d/avatar_%d.%s", 
            userId, 
            System.currentTimeMillis(),
            getFileExtension(avatarFile.getOriginalFilename())
        );
        
        // 上传文件
        return fileUploadUtil.uploadFile(avatarFile, objectName);
    }
    
    /**
     * 从外部URL采集用户头像
     */
    public String crawlUserAvatar(Long userId, String avatarUrl) {
        String objectName = String.format("user/avatar/%d/avatar_%d.jpg", 
            userId, 
            System.currentTimeMillis()
        );
        
        // 从远程URL上传
        return fileUploadUtil.uploadFromRemoteUrl(avatarUrl, objectName);
    }
    
    /**
     * 获取用户头像URL
     */
    public String getUserAvatarUrl(String objectName) {
        // 生成7天有效期的临时URL
        return fileUploadUtil.getPresignedUrl(objectName, 7 * 24 * 3600);
    }
    
    /**
     * 获取文件扩展名
     */
    private String getFileExtension(String filename) {
        if (filename == null) return "jpg";
        int lastDot = filename.lastIndexOf('.');
        return lastDot > 0 ? filename.substring(lastDot + 1) : "jpg";
    }
}

❓ 常见问题与解决方案

1. 连接失败:Connection refused

问题现象

复制代码
io.minio.errors.MinioException: Connection refused

可能原因

  • MinIO 服务未启动
  • 端口配置错误
  • 防火墙阻止连接

解决方案

bash 复制代码
# 检查 MinIO 服务状态
docker ps | grep minio

# 检查端口是否监听
netstat -tlnp | grep 9000

# 检查防火墙设置
firewall-cmd --list-ports

2. 认证失败:Access Denied

问题现象

复制代码
io.minio.errors.ErrorResponseException: Access Denied

可能原因

  • AccessKey 或 SecretKey 配置错误
  • 用户权限不足

解决方案

  • 检查配置文件中的密钥是否正确
  • 在 MinIO 控制台中创建新用户并分配适当权限

3. 存储桶不存在:NoSuchBucket

问题现象

复制代码
io.minio.errors.ErrorResponseException: NoSuchBucket

解决方案

  • 工具类已实现自动创建存储桶功能
  • 手动在控制台创建存储桶
  • 检查存储桶名称是否正确

4. 文件名中文乱码

问题现象:上传的中文文件名在 MinIO 中显示为乱码

解决方案

java 复制代码
// 对文件名进行 URL 编码
String encodedFileName = URLEncoder.encode(file.getOriginalFilename(), "UTF-8");
String objectName = System.currentTimeMillis() + "_" + encodedFileName;

5. 大文件上传失败

问题现象:大文件上传时出现内存溢出或超时

解决方案

yaml 复制代码
# application.yml
spring:
  servlet:
    multipart:
      max-file-size: 100MB      # 单个文件最大大小
      max-request-size: 500MB    # 请求最大大小
  mvc:
    async:
      request-timeout: 300000   # 异步请求超时时间(毫秒)

server:
  tomcat:
    max-swallow-size: 500MB     # Tomcat 最大请求大小

6. 跨域问题(CORS)

问题现象:前端无法访问 MinIO 文件

解决方案

在 MinIO 控制台配置 CORS 规则:

复制代码
Allowed Origins: *
Allowed Methods: GET, PUT, POST, DELETE, HEAD
Allowed Headers: *
Expose Headers: ETag, X-Amz-Request-Id
Max Age: 86400

或通过 MC 命令行工具配置:

bash 复制代码
mc alias set myminio http://localhost:9000 minioadmin minioadmin

mc admin config set myminio cors:
  - http://localhost:3000
  - http://localhost:8080

7. 远程文件上传失败

问题1:连接超时

问题现象

复制代码
java.net.SocketTimeoutException: connect timed out

解决方案

java 复制代码
// 增加超时时间
String minioUrl = fileUploadUtil.uploadFromRemoteUrl(
    fileUrl, 
    objectName, 
    bucketName, 
    null,
    30000,  // 连接超时 30 秒
    60000   // 读取超时 60 秒
);

问题2:403 Forbidden

问题现象

复制代码
java.io.IOException: 远程文件访问失败,HTTP响应码: 403

可能原因

  • 目标服务器防盗链
  • 需要 Referer 或特定 Header
  • IP 地址被限制

解决方案

java 复制代码
// 在 uploadFromRemoteUrl 方法中添加额外请求头
connection.setRequestProperty("Referer", "https://example.com");
connection.setRequestProperty("Accept", "image/webp,*/*");
connection.setRequestProperty("Accept-Language", "zh-CN,zh;q=0.9");

问题3:重定向问题

问题现象:某些 URL 返回 302/301 重定向,但文件未正确上传

解决方案

工具类已设置 connection.setInstanceFollowRedirects(true) 自动跟随重定向。如果仍有问题,可以手动处理:

java 复制代码
// 手动处理重定向
while (true) {
    int responseCode = connection.getResponseCode();
    
    if (responseCode == HttpURLConnection.HTTP_MOVED_PERM 
        || responseCode == HttpURLConnection.HTTP_MOVED_TEMP
        || responseCode == HttpURLConnection.HTTP_SEE_OTHER) {
        
        String newUrl = connection.getHeaderField("Location");
        connection.disconnect();
        connection = (HttpURLConnection) new URL(newUrl).openConnection();
        // 重新设置参数...
        
    } else if (responseCode == HttpURLConnection.HTTP_OK) {
        break;
    } else {
        throw new IOException("远程文件访问失败,HTTP响应码: " + responseCode);
    }
}

8. 临时 URL 无效

问题现象:生成的临时 URL 无法访问

解决方案

  • 检查系统时间是否同步(时间不同步会导致签名验证失败)
  • 确认 URL 未过期
  • 检查文件是否存在于指定存储桶

9. 批量上传部分失败

问题现象:批量上传时部分文件失败

解决方案

java 复制代码
// 获取批量上传结果
Map<String, Object> result = fileUploadUtil.batchUploadFromRemoteUrls(urlMap);

// 检查失败的URL列表
List<String> failedUrls = (List<String>) result.get("failedUrls");

// 重试失败的文件
if (!failedUrls.isEmpty()) {
    logger.info("重试失败的文件,数量: {}", failedUrls.size());
    // 实现重试逻辑...
}

10. 性能优化建议

上传优化
  • 使用分片上传处理大文件
  • 启用多线程并发上传
  • 使用压缩减少传输数据量
远程上传优化
  • 对于大批量任务,考虑使用线程池
  • 实现失败重试机制
  • 添加进度监控
java 复制代码
// 使用线程池批量上传
ExecutorService executor = Executors.newFixedThreadPool(10);

List<Future<String>> futures = new ArrayList<>();

for (Map.Entry<String, String> entry : urlMap.entrySet()) {
    final String url = entry.getKey();
    final String objectName = entry.getValue();
    
    Future<String> future = executor.submit(() -> {
        return fileUploadUtil.uploadFromRemoteUrl(url, objectName);
    });
    
    futures.add(future);
}

// 等待所有任务完成
for (Future<String> future : futures) {
    try {
        String result = future.get();
        // 处理结果...
    } catch (Exception e) {
        // 处理异常...
    }
}

executor.shutdown();

下载优化

  • 使用 CDN 加速
  • 启用缓存策略
  • 使用 Range 请求支持断点续传
java 复制代码
// 启用分片上传示例
minioClient.uploadObject(
    UploadObjectArgs.builder()
        .bucket(bucketName)
        .object(objectName)
        .filename(localFilePath)
        .contentType("application/octet-stream")
        .partSize(10 * 1024 * 1024)  // 10MB 分片
        .build()
);

📚 总结与扩展建议

核心要点总结

  1. MinIO 优势:高性能、S3 兼容、轻量级、分布式、安全可靠
  2. 集成要点:依赖引入、配置文件、客户端初始化、工具类封装
  3. 功能完整
    • 上传:MultipartFile、流式、远程URL
    • 下载/URL:永久URL、临时URL
    • 管理:删除、检查存在、复制、信息获取
  4. 生产就绪:完善的日志、异常处理、配置灵活
  5. 远程采集:支持HTTP/HTTPS、自动识别类型、批量处理、超时控制

扩展建议

1. 文件类型与大小限制

java 复制代码
public class FileUploadValidator {
    
    // 允许的文件类型
    private static final Set<String> ALLOWED_TYPES = Set.of(
        "image/jpeg", "image/png", "image/gif", 
        "application/pdf", "text/plain"
    );
    
    // 最大文件大小(10MB)
    private static final long MAX_FILE_SIZE = 10 * 1024 * 1024;
    
    public static void validate(MultipartFile file) {
        // 检查文件类型
        if (!ALLOWED_TYPES.contains(file.getContentType())) {
            throw new IllegalArgumentException("不支持的文件类型");
        }
        
        // 检查文件大小
        if (file.getSize() > MAX_FILE_SIZE) {
            throw new IllegalArgumentException("文件大小超过限制");
        }
    }
}

2. 文件预览功能

java 复制代码
/**
 * 文件预览服务
 */
@Service
public class FilePreviewService {

    @Autowired
    private MinIOFileUploadUtil fileUploadUtil;
    
    /**
     * 生成图片缩略图预览URL
     */
    public String getThumbnailUrl(String objectName, int width, int height) {
        // 使用 MinIO 的图片处理功能
        // 格式:http://endpoint/bucket/object?process=resize/width,height
        return String.format("%s/%s?process=resize/%d,%d", 
            fileUploadUtil.getFileUrl(objectName), 
            width, height);
    }
    
    /**
     * 生成PDF预览URL(第一页)
     */
    public String getPdfPreviewUrl(String objectName) {
        // 使用 PDF.js 等工具生成预览
        // 这里返回临时URL供前端处理
        return fileUploadUtil.getPresignedUrl(objectName, 3600);
    }
}
相关推荐
2601_949613022 小时前
flutter_for_openharmony家庭药箱管理app实战+药品详情实现
java·前端·flutter
短剑重铸之日2 小时前
《SpringCloud实用版》Stream + RocketMQ 实现可靠消息 & 事务消息
后端·rocketmq·springcloud·消息中间件·事务消息
木井巳2 小时前
【递归算法】求根节点到叶节点数字之和
java·算法·leetcode·深度优先
没有bug.的程序员2 小时前
Spring Boot 事务管理:@Transactional 失效场景、底层内幕与分布式补偿实战终极指南
java·spring boot·分布式·后端·transactional·失效场景·底层内幕
华农第一蒟蒻2 小时前
一次服务器CPU飙升的排查与解决
java·运维·服务器·spring boot·arthas
m0_748229992 小时前
帝国CMS后台搭建全攻略
java·c语言·开发语言·学习
码农娟2 小时前
Hutool XML工具-XmlUtil的使用
xml·java
LuminescenceJ2 小时前
GoEdge 开源CDN 架构设计与工作原理分析
分布式·后端·网络协议·网络安全·rpc·开源·信息与通信
Tony Bai2 小时前
【分布式系统】11 理论的试金石:用 Go 从零实现一个迷你 Raft 共识
开发语言·后端·golang