Spring Boot 4.0 整合 MinIO:文件上传、预览与权限控制
前言
企业项目里的合同、发票、商品图片和工单附件,通常不适合直接写进数据库。对象存储负责保存文件本体,业务数据库只记录文件与业务对象的归属关系,这样备份、扩容和 CDN 分发都会更简单。
这篇教程在订单项目中接入 MinIO,完成一条最小但完整的文件链路:浏览器上传文件、后端校验、写入 Bucket、返回短期预览地址。你最终会得到一个能通过 curl 调用的上传接口,并知道哪些安全检查不能只写在 Controller 里。
读者需要会启动前面的 Spring Boot 4 多模块项目,能使用 Maven、curl 和 Docker Compose。完整代码位于 dvsusan/spring-boot4-demo。
上一章已经完成 Elasticsearch 搜索。返回课程目录可以查看整套教程的位置。
最终结果
完成后,项目会新增下面的文件与接口:
spring-boot4-demo/
├── docker-compose.yml
├── demo-integration/
│ └── src/main/java/com/susan/training/integration/file/
│ ├── FileMetadata.java
│ ├── MinioFileStorageService.java
│ └── MinioIntegrationConfiguration.java
└── demo-web/
└── src/main/java/com/susan/training/web/integration/
└── Chapter5Controller.java上传请求会依次经过校验、生成随机对象名、写入 MinIO 和生成临时 URL。MinIO 的访问密钥始终留在服务端,浏览器只拿到短期 URL。

图 1:文件先经过后端安全边界,再由服务端写入对象存储。
开工前准备
| 工具 | 建议版本 | 检查命令 | 预期结果 |
|---|---|---|---|
| JDK | 21 | java -version | 输出 Java 21 |
| Maven | 3.9+ | mvn -version | Maven 正常使用 Java 21 |
| Docker | 可运行 Compose v2 | docker compose version | 输出 Compose 版本 |
| MinIO | Compose 中的镜像 | 启动后检查健康接口 | HTTP 200 |
在项目根目录执行:
docker compose up -d minio
curl -i http://127.0.0.1:9000/minio/health/live成功时健康检查返回 HTTP/1.1 200 OK。第一次启动需要下载镜像,时间会比后续启动更长。
第 1 步:添加 MinIO 依赖
在根 pom.xml 中固定客户端版本,避免 Maven 从不同模块解析到不同版本:
<properties>
<!-- 示例统一以 Java 21 编译,避免不同电脑的默认 JDK 造成结果差异。 -->
<java.version>21</java.version>
<!-- MinIO 官方 SDK 管理 S3 请求、签名和预签名 URL。 -->
<minio.version>8.5.9</minio.version>
</properties>在 demo-integration/pom.xml 中增加依赖:
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>${minio.version}</version>
</dependency>文件存储属于外部系统集成,所以依赖放在 demo-integration。Controller 只负责 HTTP 参数,业务模块不需要知道 MinIO SDK。
第 2 步:配置客户端和上传上限
application.yml 已经为所有上传请求设置边界:
spring:
servlet:
multipart:
# 同时限制单个文件和整个请求,避免无界上传耗尽磁盘与内存。
max-file-size: 20MB
max-request-size: 21MB
demo:
chapter5:
minio:
# 默认关闭,读者进入本章后再显式开启。
enabled: false
excel:
enabled: false只需开启第五章时使用 application-chapter5.yml:
demo:
chapter5:
minio:
enabled: true
endpoint: ${MINIO_ENDPOINT:http://127.0.0.1:9000}
access-key: ${MINIO_ACCESS_KEY:minioadmin}
secret-key: ${MINIO_SECRET_KEY:minioadmin}
bucket: ${MINIO_BUCKET:training-files}
max-file-bytes: 20971520
excel:
enabled: true
spring:
servlet:
multipart:
max-file-size: 20MB
max-request-size: 21MBminioadmin/minioadmin 只是本地教学默认值。部署时必须通过环境变量或密钥管理系统注入,不能把真实密钥提交到 Git。
第 3 步:创建 MinIO 客户端
创建 demo-integration/src/main/java/com/susan/training/integration/file/MinioIntegrationConfiguration.java:
package com.susan.training.integration.file;
import io.minio.MinioClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 显式开启 MinIO 章节时才创建对象存储客户端。
*/
@Configuration
@ConditionalOnProperty(
prefix = "demo.chapter5.minio",
name = "enabled",
havingValue = "true"
)
public class MinioIntegrationConfiguration {
@Bean
public MinioClient minioClient(
@Value("${demo.chapter5.minio.endpoint:http://127.0.0.1:9000}")
String endpoint,
@Value("${demo.chapter5.minio.access-key:minioadmin}")
String accessKey,
@Value("${demo.chapter5.minio.secret-key:minioadmin}")
String secretKey
) {
return MinioClient.builder()
.endpoint(endpoint)
.credentials(accessKey, secretKey)
.build();
}
@Bean
public MinioFileStorageService fileStorageService(
MinioClient client,
@Value("${demo.chapter5.minio.bucket:training-files}")
String bucket,
@Value("${demo.chapter5.minio.max-file-bytes:20971520}")
long maxFileBytes
) {
return new MinioFileStorageService(client, bucket, maxFileBytes);
}
}@ConditionalOnProperty 让前面章节不必安装 MinIO。配置关闭时,两个 Bean 都不会创建,启动过程也不会尝试连接对象存储。
第 4 步:定义返回给业务方的文件元数据
创建 FileMetadata.java:
package com.susan.training.integration.file;
/**
* 文件上传后返回给业务方的对象存储元数据。
*/
public record FileMetadata(
String bucket,
String objectName,
String presignedUrl
) {
}数据库通常保存 bucket + objectName,不保存临时 URL。临时 URL 过期后可以重新生成,而对象名是文件在 Bucket 中的稳定标识。
第 5 步:实现上传与预签名预览
创建 MinioFileStorageService.java:
package com.susan.training.integration.file;
import io.minio.BucketExistsArgs;
import io.minio.GetPresignedObjectUrlArgs;
import io.minio.MakeBucketArgs;
import io.minio.MinioClient;
import io.minio.PutObjectArgs;
import io.minio.http.Method;
import java.io.InputStream;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* MinIO 文件上传与临时预览地址服务。
*/
public class MinioFileStorageService {
private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of(
"application/pdf",
"text/plain",
"image/png",
"image/jpeg",
"image/webp"
);
private final MinioClient client;
private final String bucket;
private final long maxFileBytes;
private volatile boolean bucketReady;
public MinioFileStorageService(
MinioClient client,
String bucket,
long maxFileBytes
) {
this.client = client;
this.bucket = bucket;
this.maxFileBytes = maxFileBytes;
}
public FileMetadata upload(
String originalFilename,
String contentType,
long size,
InputStream inputStream
) throws Exception {
if (!ALLOWED_CONTENT_TYPES.contains(contentType)) {
throw new IllegalArgumentException("不支持的文件类型: " + contentType);
}
if (size <= 0 || size > maxFileBytes) {
throw new IllegalArgumentException("文件大小超出限制");
}
ensureBucket();
String objectName = "uploads/"
+ UUID.randomUUID()
+ extension(originalFilename);
client.putObject(PutObjectArgs.builder()
.bucket(bucket)
.object(objectName)
.stream(inputStream, size, -1)
.contentType(contentType)
.build());
return new FileMetadata(bucket, objectName, presignedUrl(objectName));
}
public String presignedUrl(String objectName) throws Exception {
if (objectName.contains("..") || objectName.startsWith("/")) {
throw new IllegalArgumentException("非法对象路径");
}
// 预签名 URL 只用于短期预览,不能让客户端直接持有 MinIO 密钥。
return client.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
.method(Method.GET)
.bucket(bucket)
.object(objectName)
.expiry(15, TimeUnit.MINUTES)
.build());
}
private synchronized void ensureBucket() throws Exception {
if (bucketReady) {
return;
}
boolean exists = client.bucketExists(BucketExistsArgs.builder()
.bucket(bucket)
.build());
if (!exists) {
client.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
}
bucketReady = true;
}
private String extension(String filename) {
if (filename == null || !filename.contains(".")) {
return "";
}
String extension = filename.substring(filename.lastIndexOf('.'))
.toLowerCase(Locale.ROOT);
return extension.matches("\\.[a-z0-9]{1,10}") ? extension : "";
}
}对象名使用 UUID,避免两个用户都上传 合同.pdf 时互相覆盖。bucketReady 只缓存“已检查”的结果;应用重启后会重新确认 Bucket 是否存在。
第 6 步:暴露上传与预览接口
在 Chapter5Controller.java 中增加三个接口。以下是本章相关的完整代码段:
@RestController
@RequestMapping("/api/v1/chapter5")
public class Chapter5Controller {
private final ObjectProvider<MinioFileStorageService> fileServiceProvider;
public Chapter5Controller(
ObjectProvider<MinioFileStorageService> fileServiceProvider
) {
this.fileServiceProvider = fileServiceProvider;
}
/**
* 上传文件并返回对象名和 15 分钟临时预览地址。
*/
@PostMapping("/files")
public ApiResponse<FileMetadata> uploadFile(
@RequestParam("file") MultipartFile file
) throws Exception {
MinioFileStorageService service = fileServiceProvider.getIfAvailable();
if (service == null) {
return ApiResponse.failure("FEATURE_DISABLED", "MinIO 未开启");
}
try (InputStream inputStream = file.getInputStream()) {
return ApiResponse.success(service.upload(
file.getOriginalFilename(),
file.getContentType(),
file.getSize(),
inputStream
));
}
}
/**
* 为已经上传的对象重新生成临时预览地址。
*/
@GetMapping("/files/url")
public ApiResponse<String> fileUrl(@RequestParam String objectName) throws Exception {
MinioFileStorageService service = fileServiceProvider.getIfAvailable();
if (service == null) {
return ApiResponse.failure("FEATURE_DISABLED", "MinIO 未开启");
}
return ApiResponse.success(service.presignedUrl(objectName));
}
}实际仓库中的 Chapter5Controller 还包含第 30 篇的 Excel 接口。这里只截取文件相关方法,完整类的导入和包声明以仓库为准。
使用 ObjectProvider 是因为功能默认关闭。Bean 不存在时接口仍能启动,并返回清晰的 FEATURE_DISABLED,而不是让整个应用启动失败。
第 7 步:启动并验证上传
在项目根目录构建并启动:
mvn clean package -DskipTests
java -jar demo-web/target/demo-web-1.0.0-SNAPSHOT.jar \
--spring.profiles.active=chapter5 \
--server.port=18100准备一个小文本文件,然后调用上传接口:
printf 'Spring Boot 4 MinIO demo\n' > /private/tmp/minio-demo.txt
curl -sS \
-F 'file=@/private/tmp/minio-demo.txt;type=text/plain' \
'http://localhost:18100/api/v1/chapter5/files'成功响应结构如下,Bucket 名称、随机对象名和 URL 每次都可能不同:
{
"code": "0",
"message": "success",
"data": {
"bucket": "training-files",
"objectName": "uploads/<uuid>.txt",
"presignedUrl": "http://127.0.0.1:9000/training-files/uploads/<uuid>.txt?..."
}
}浏览器打开 presignedUrl 能预览文件,说明上传和临时授权都成功。URL 在 15 分钟后失效,这是预期行为。
也可以打开 http://127.0.0.1:9001 登录 MinIO 控制台,在 training-files/uploads/ 下看到新对象。
检查点和失败路径
先验证类型校验:
curl -i \
-F 'file=@README.md;type=application/x-msdownload' \
'http://localhost:18100/api/v1/chapter5/files'接口会拒绝不在允许列表中的 MIME 类型。再验证大小边界:
dd if=/dev/zero of=/private/tmp/large.pdf bs=1m count=21
curl -i \
-F 'file=@/private/tmp/large.pdf;type=application/pdf' \
'http://localhost:18100/api/v1/chapter5/files'服务端会在进入业务代码前拒绝超过 20 MB 的请求,常见结果是 HTTP 413。
文件校验单元测试可以在不启动 MinIO 服务的情况下运行:
mvn -pl demo-integration -am \
-Dtest=MinioFileStorageServiceTest \
-Dsurefire.failIfNoSpecifiedTests=false \
test仓库中的两个测试分别验证拒绝不支持的 MIME 类型和超限文件,实际结果为 Tests run: 2, Failures: 0, Errors: 0。它们验证的是进入 MinIO 之前的边界,真实上传仍需按本章命令连接 MinIO 后完成。
权限控制为什么不能只看预签名 URL?
预签名 URL 解决的是“临时读取对象”,不解决“谁有权读取业务文件”。正式项目至少还要完成下面几件事:
- 登录成功后,从可信身份上下文取得当前用户和租户。
- 查数据库确认该用户拥有这条合同、发票或工单。
- 权限校验通过后才生成预签名 URL。
- 保存对象归属、哈希、原文件名、大小和创建人,便于审计与去重。
- 删除文件时先做业务权限校验,再删除对象或标记为待清理。
- 对下载行为记录审计日志,不把永久公开读权限直接开给 Bucket。
MIME 类型由客户端提供,仍然可以伪造。高安全场景还应读取文件头做魔数检测、接入病毒扫描,并根据业务要求限制扩展名和真实内容。
常见问题排查
| 现象 | 原因 | 处理方式 |
|---|---|---|
连接 127.0.0.1:9000 被拒绝 | MinIO 容器未启动 | 执行 docker compose ps 和 docker compose logs minio |
返回 FEATURE_DISABLED | 没有启用第五章配置 | 使用 --spring.profiles.active=chapter5 启动 |
| 上传返回 413 | 文件超过 Multipart 上限 | 调低测试文件,按业务重新评估上限 |
| 返回“不支持的文件类型” | 请求中的 MIME 类型不在允许列表 | 用 ;type=... 指定正确类型,并检查前端请求 |
| 预览 URL 很快打不开 | 预签名时效结束 | 根据对象名重新生成,不要把 URL 写入数据库 |
| Bucket 已存在但无写权限 | 当前 Access Key 没有该 Bucket 权限 | 检查 MinIO 用户策略和 Bucket Policy |
练习题
增加文件记录表,字段包括 id、ownerId、bucket、objectName、sha256、size、createdAt。上传前按 SHA-256 判断同一用户是否已经上传过相同内容;生成预览 URL 时只允许文件所有者查询。验收标准是:两个用户上传同名文件不会覆盖,另一个用户无法为不属于自己的对象生成 URL。
总结
MinIO 接入的核心不是把文件 put 进去,而是把身份、文件边界、对象命名和读取授权串成一条完整链路。后端保存稳定对象名,客户端只使用短期 URL,Bucket 凭据永远不进入浏览器。
下一篇继续处理大文件数据:使用 Apache POI 的 SXSSFWorkbook 分批导出 Excel,并用 SAX 和虚拟线程完成百万级数据的异步导入。
上一节:Elasticsearch 商品搜索
下一节:Excel 批处理
相关阅读:配置管理
返回课程目录
13 个企业级实战项目 · 源码 + 教程 + 答疑
简历包装指导AI Agent 实战高并发·微服务Offer 导向持续更新3000+ 同行交流1V1 答疑3 天包退
覆盖高并发、微服务、分布式与 AI Agent:100万QPS短链、企业智能知识库、商城微服务、SaaS点餐、秒杀、刷题小程序、代码生成器、AI Agent 等。券后 ¥129(原价 ¥159),不满意 3 天包退。
