feat(storage): 集成 MinIO 对象存储替代本地文件系统

- 添加 MinIO 配置类 MinioConfig 和服务类 MinioService
- 在 CommonController 中实现 MinIO 上传下载功能
- 在 FileUploadController 中集成 MinIO 上传支持
- 在 SysProfileController 中添加头像上传到 MinIO 的逻辑
- 修改文件上传工具类支持 MinIO 上传
- 添加 MinIO 相关依赖到项目配置
- 实现本地文件系统与 MinIO 的条件切换机制
This commit is contained in:
2026-06-09 11:20:59 +08:00
parent c8350b5f0e
commit 1065c62d0c
8 changed files with 533 additions and 34 deletions

View File

@@ -0,0 +1,79 @@
package com.ruoyi.common.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* MinIO 对象存储配置
*
* @author ruoyi
*/
@Component
@ConfigurationProperties(prefix = "minio")
public class MinioConfig
{
/** 是否启用 MinIOfalse 则使用本地文件系统) */
private boolean enabled = false;
/** MinIO 服务地址 */
private String endpoint;
/** 访问密钥 */
private String accessKey;
/** 秘密密钥 */
private String secretKey;
/** 存储桶名称 */
private String bucketName;
public boolean isEnabled()
{
return enabled;
}
public void setEnabled(boolean enabled)
{
this.enabled = enabled;
}
public String getEndpoint()
{
return endpoint;
}
public void setEndpoint(String endpoint)
{
this.endpoint = endpoint;
}
public String getAccessKey()
{
return accessKey;
}
public void setAccessKey(String accessKey)
{
this.accessKey = accessKey;
}
public String getSecretKey()
{
return secretKey;
}
public void setSecretKey(String secretKey)
{
this.secretKey = secretKey;
}
public String getBucketName()
{
return bucketName;
}
public void setBucketName(String bucketName)
{
this.bucketName = bucketName;
}
}

View File

@@ -214,6 +214,36 @@ public class FileUploadUtils
return false;
}
/**
* 上传文件到 MinIO使用与本地文件系统相同的文件名规则
*
* @param minioService MinIO 服务实例
* @param baseDir 基础目录(如 upload、avatar
* @param file 上传的文件
* @param allowedExtension 允许的文件扩展名
* @return MinIO 文件访问 URL
* @throws IOException 上传失败
*/
public static final String uploadToMinio(MinioService minioService, String baseDir, MultipartFile file,
String[] allowedExtension)
throws FileSizeLimitExceededException, IOException, FileNameLengthLimitExceededException,
InvalidExtensionException
{
int fileNamelength = Objects.requireNonNull(file.getOriginalFilename()).length();
if (fileNamelength > FileUploadUtils.DEFAULT_FILE_NAME_LENGTH)
{
throw new FileNameLengthLimitExceededException(FileUploadUtils.DEFAULT_FILE_NAME_LENGTH);
}
assertAllowed(file, allowedExtension);
// 生成 objectName: upload/2025/04/13/originalName_uuid.ext
String fileName = extractFilename(file);
String objectName = baseDir + "/" + fileName;
return minioService.upload(file, objectName);
}
/**
* 获取文件名的后缀
*

View File

@@ -0,0 +1,254 @@
package com.ruoyi.common.utils.file;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import io.minio.BucketExistsArgs;
import io.minio.GetObjectArgs;
import io.minio.MakeBucketArgs;
import io.minio.MinioClient;
import io.minio.PutObjectArgs;
import io.minio.RemoveObjectArgs;
import io.minio.StatObjectArgs;
import io.minio.errors.ErrorResponseException;
import io.minio.errors.InsufficientDataException;
import io.minio.errors.InternalException;
import io.minio.errors.InvalidResponseException;
import io.minio.errors.ServerException;
import io.minio.errors.XmlParserException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.common.config.MinioConfig;
import io.minio.http.Method;
/**
* MinIO 对象存储服务
*
* @author ruoyi
*/
@Service
@ConditionalOnProperty(prefix = "minio", name = "enabled", havingValue = "true")
public class MinioService
{
private static final Logger log = LoggerFactory.getLogger(MinioService.class);
private MinioClient minioClient;
private MinioConfig minioConfig;
@Autowired
public MinioService(MinioConfig minioConfig)
{
this.minioConfig = minioConfig;
this.minioClient = MinioClient.builder()
.endpoint(minioConfig.getEndpoint())
.credentials(minioConfig.getAccessKey(), minioConfig.getSecretKey())
.build();
ensureBucketExists();
}
/**
* 确保存储桶存在,不存在则自动创建
*/
private void ensureBucketExists()
{
try
{
String bucketName = minioConfig.getBucketName();
boolean exists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
if (!exists)
{
minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
log.info("MinIO 存储桶 [{}] 已自动创建", bucketName);
}
}
catch (Exception e)
{
log.error("MinIO 存储桶初始化失败", e);
throw new RuntimeException("MinIO 存储桶初始化失败: " + e.getMessage(), e);
}
}
/**
* 上传文件到 MinIO
*
* @param file 上传的文件
* @param objectName 对象名称(含路径,如 upload/2025/04/13/xxx.png
* @return 文件访问 URL
*/
public String upload(MultipartFile file, String objectName) throws IOException
{
try
{
String contentType = file.getContentType() != null ? file.getContentType() : "application/octet-stream";
minioClient.putObject(
PutObjectArgs.builder()
.bucket(minioConfig.getBucketName())
.object(objectName)
.stream(file.getInputStream(), file.getSize(), -1)
.contentType(contentType)
.build());
log.info("MinIO 上传成功: {} -> bucket:{}", objectName, minioConfig.getBucketName());
return getUrl(objectName);
}
catch (Exception e)
{
log.error("MinIO 上传失败: {}", objectName, e);
throw new IOException("MinIO 上传失败: " + e.getMessage(), e);
}
}
/**
* 上传输入流到 MinIO
*
* @param stream 输入流
* @param objectName 对象名称
* @param size 数据大小
* @param contentType 内容类型
* @return 文件访问 URL
*/
public String upload(InputStream stream, String objectName, long size, String contentType) throws IOException
{
try
{
String ct = (contentType != null) ? contentType : "application/octet-stream";
minioClient.putObject(
PutObjectArgs.builder()
.bucket(minioConfig.getBucketName())
.object(objectName)
.stream(stream, size, -1)
.contentType(ct)
.build());
return getUrl(objectName);
}
catch (Exception e)
{
log.error("MinIO 上传失败: {}", objectName, e);
throw new IOException("MinIO 上传失败: " + e.getMessage(), e);
}
}
/**
* 获取文件访问 URL7天有效期
*
* @param objectName 对象名称
* @return 预签名 URL
*/
public String getUrl(String objectName)
{
try
{
return minioClient.getPresignedObjectUrl(
io.minio.GetPresignedObjectUrlArgs.builder()
.method(Method.GET)
.bucket(minioConfig.getBucketName())
.object(objectName)
.expiry(7 * 24 * 60 * 60) // 7天有效期
.build());
}
catch (Exception e)
{
log.warn("MinIO 获取 URL 失败: {}", objectName, e);
// 返回拼接直连地址
return minioConfig.getEndpoint() + "/" + minioConfig.getBucketName() + "/" + objectName;
}
}
/**
* 下载文件到输出流
*
* @param objectName 对象名称
* @param os 输出流
*/
public void download(String objectName, OutputStream os) throws IOException
{
try
{
try (InputStream is = minioClient.getObject(
GetObjectArgs.builder()
.bucket(minioConfig.getBucketName())
.object(objectName)
.build()))
{
byte[] buf = new byte[8192];
int len;
while ((len = is.read(buf)) > -1)
{
os.write(buf, 0, len);
}
}
}
catch (Exception e)
{
log.error("MinIO 下载失败: {}", objectName, e);
throw new IOException("MinIO 下载失败: " + e.getMessage(), e);
}
}
/**
* 删除文件
*
* @param objectName 对象名称
*/
public void delete(String objectName) throws IOException
{
try
{
minioClient.removeObject(
RemoveObjectArgs.builder()
.bucket(minioConfig.getBucketName())
.object(objectName)
.build());
log.info("MinIO 删除成功: {}", objectName);
}
catch (Exception e)
{
log.error("MinIO 删除失败: {}", objectName, e);
throw new IOException("MinIO 删除失败: " + e.getMessage(), e);
}
}
/**
* 判断对象是否存在
*
* @param objectName 对象名称
* @return true 存在
*/
public boolean exists(String objectName)
{
try
{
minioClient.statObject(
StatObjectArgs.builder()
.bucket(minioConfig.getBucketName())
.object(objectName)
.build());
return true;
}
catch (ErrorResponseException e)
{
return false;
}
catch (Exception e)
{
log.warn("MinIO 检查对象存在性失败: {}", objectName, e);
return false;
}
}
/**
* 获取 MinIO 配置
*/
public MinioConfig getMinioConfig()
{
return minioConfig;
}
}