refactor: 用 RustFS S3 替代 MinIO 并优化 OSS endpoint 协议处理

This commit is contained in:
Ray.Hao
2026-08-25 08:21:10 +08:00
parent 7ecbc16b69
commit 6da8ea0fca
9 changed files with 270 additions and 195 deletions

View File

@@ -38,7 +38,6 @@
<fastexcel.version>1.3.0</fastexcel.version>
<!-- 对象存储 -->
<minio.version>8.5.10</minio.version>
<okhttp3.version>4.12.0</okhttp3.version>
<aliyun-sdk-oss.version>3.16.3</aliyun-sdk-oss.version>
@@ -214,11 +213,11 @@
<version>${fastexcel.version}</version>
</dependency>
<!-- MinIO 对象存储 -->
<!-- S3 对象存储 -->
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>${minio.version}</version>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
<version>2.54.2</version>
</dependency>
<!-- 阿里云 OSS 对象存储 -->

View File

@@ -12,7 +12,7 @@ import java.util.stream.Collectors;
/**
* 文件存储顶层配置:存储类型与上传限制。
* <p>
* minio / aliyun / local 子树由各自 {@code FileService} 实现类绑定,
* rustfs / aliyun / local 子树由各自 {@code FileService} 实现类绑定,
* 此处仅承载 {@code type} 与 {@code upload}。
*/
@Data
@@ -20,7 +20,7 @@ import java.util.stream.Collectors;
@ConfigurationProperties(prefix = "file-storage")
public class FileStorageProperties {
/** 存储类型:minio | aliyun | local */
/** 存储类型:rustfs | aliyun | local */
private String type;
private Upload upload = new Upload();

View File

@@ -4,6 +4,7 @@ import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.model.ObjectMetadata;
@@ -28,7 +29,8 @@ import java.time.LocalDateTime;
* 阿里云 OSS 文件存储服务。
* <p>
* 当 {@code file-storage.type=aliyun} 时生效。
* 上传文件按日期分目录存储,URL 格式:{@code https://bucket.endpoint/yyyyMMdd/uuid.suffix}。
* 上传文件按日期分目录存储,URL 格式:{@code 协议://bucket.endpoint/yyyyMMdd/uuid.suffix},
* 协议取自 endpoint 配置(未指定时默认 https)。
*/
@Component
@Slf4j
@@ -38,7 +40,7 @@ import java.time.LocalDateTime;
@Data
public class AliyunFileServiceImpl implements FileService {
/** OSS 地域节点,如 {@code oss-cn-hangzhou.aliyuncs.com} */
/** OSS 地域节点,如 {@code oss-cn-hangzhou.aliyuncs.com},兼容带 {@code http(s)://} 前缀写法 */
private String endpoint;
/** AccessKey ID */
private String accessKeyId;
@@ -51,11 +53,11 @@ public class AliyunFileServiceImpl implements FileService {
@PostConstruct
public void init() {
aliyunOssClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
aliyunOssClient = new OSSClientBuilder().build(endpointWithProtocol(), accessKeyId, accessKeySecret);
}
/**
* 上传文件到阿里云 OSS,按日期分目录,返回 {@code https://bucket.endpoint/path} 格式的 URL。
* 上传文件到阿里云 OSS,按日期分目录,返回 {@code 协议://bucket.endpoint/path} 格式的 URL。
*/
@Override
@SneakyThrows
@@ -73,7 +75,7 @@ public class AliyunFileServiceImpl implements FileService {
log.error("文件上传失败", e);
throw new BusinessException("文件上传失败", e);
}
String fileUrl = "https://" + bucket + "." + endpoint + "/" + fileName;
String fileUrl = getFileHost() + "/" + fileName;
FileInfo fileInfo = new FileInfo();
fileInfo.setName(originalFilename);
fileInfo.setUrl(fileUrl);
@@ -89,9 +91,33 @@ public class AliyunFileServiceImpl implements FileService {
@Override
public boolean deleteFile(String filePath) {
Assert.notBlank(filePath, "删除文件路径不能为空");
String fileHost = "https://" + bucket + "." + endpoint;
String fileName = filePath.substring(fileHost.length() + 1);
String fileName = filePath.substring(getFileHost().length() + 1);
aliyunOssClient.deleteObject(bucket, fileName);
return true;
}
/**
* 补全 endpoint 协议前缀:已带 {@code http://} 或 {@code https://} 的仅去除尾部 {@code /},
* 纯域名写法默认补 {@code https://},避免 OSS 客户端对无协议 endpoint 默认走 http 明文传输。
*/
private String endpointWithProtocol() {
String trimmed = StrUtil.trim(endpoint);
if (!StrUtil.startWithIgnoreCase(trimmed, "http://") && !StrUtil.startWithIgnoreCase(trimmed, "https://")) {
trimmed = "https://" + trimmed;
}
return StrUtil.removeSuffix(trimmed, "/");
}
/**
* 获取文件访问主机地址(含协议),如 {@code https://bucket.oss-cn-hangzhou.aliyuncs.com}。
* <p>
* 协议以 endpoint 配置为准,未指定时默认 https;上传拼接 URL 与删除解析 URL 共用,保证两处口径一致。
*/
private String getFileHost() {
String endpointWithProtocol = endpointWithProtocol();
int schemeEnd = endpointWithProtocol.indexOf("://");
String protocol = endpointWithProtocol.substring(0, schemeEnd);
String host = endpointWithProtocol.substring(schemeEnd + "://".length());
return protocol + "://" + bucket + "." + host;
}
}

View File

@@ -1,159 +0,0 @@
package com.youlai.boot.support.file.service.impl;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.common.exception.BusinessException;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.support.file.model.FileInfo;
import com.youlai.boot.support.file.service.FileService;
import io.minio.*;
import io.minio.http.Method;
import jakarta.annotation.PostConstruct;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
import java.time.LocalDateTime;
/**
* MinIO 文件存储服务。
* <p>
* 当 {@code file-storage.type=minio} 时生效。
* 上传文件按日期分目录存储,路径格式:{@code yyyyMMdd/uuid.suffix}。
* <ul>
* <li>配置了 {@code file-storage.minio.domain} 时直接用域名拼接 URL</li>
* <li>未配置则用 MinIO 预签名 URL(去掉 query 参数后的纯路径)</li>
* </ul>
*/
@Component
@ConditionalOnProperty(value = "file-storage.type", havingValue = "minio")
@ConfigurationProperties(prefix = "file-storage.minio")
@RequiredArgsConstructor
@Data
@Slf4j
public class MinioFileServiceImpl implements FileService {
/** MinIO 服务端点,如 {@code http://localhost:9000} */
private String endpoint;
/** 访问密钥 */
private String accessKey;
/** 秘密密钥 */
private String secretKey;
/** 存储桶名称 */
private String bucket;
/** 自定义域名(可选),配置后不再使用预签名 URL */
private String domain;
private MinioClient minioClient;
@PostConstruct
public void init() {
minioClient = MinioClient.builder()
.endpoint(endpoint)
.credentials(accessKey, secretKey)
.build();
}
/**
* 上传文件到 MinIO,按日期分目录,自动创建存储桶,返回文件访问 URL。
*/
@Override
public FileInfo uploadFile(MultipartFile file) {
createBucketIfAbsent(bucket);
String originalFilename = file.getOriginalFilename();
String suffix = FileUtil.getSuffix(originalFilename);
String dateFolder = DateUtil.format(LocalDateTime.now(), "yyyyMMdd");
String fileName = IdUtil.simpleUUID() + "." + suffix;
try (InputStream inputStream = file.getInputStream()) {
PutObjectArgs putObjectArgs = PutObjectArgs.builder()
.bucket(bucket)
.object(dateFolder + "/" + fileName)
.contentType(file.getContentType())
.stream(inputStream, inputStream.available(), -1)
.build();
minioClient.putObject(putObjectArgs);
String fileUrl;
if (StrUtil.isBlank(domain)) {
GetPresignedObjectUrlArgs getPresignedObjectUrlArgs = GetPresignedObjectUrlArgs.builder()
.bucket(bucket)
.object(dateFolder + "/" + fileName)
.method(Method.GET)
.build();
fileUrl = minioClient.getPresignedObjectUrl(getPresignedObjectUrlArgs);
fileUrl = fileUrl.substring(0, fileUrl.indexOf("?"));
} else {
fileUrl = domain + "/" + bucket + "/" + dateFolder + "/" + fileName;
}
FileInfo fileInfo = new FileInfo();
fileInfo.setName(originalFilename);
fileInfo.setUrl(fileUrl);
return fileInfo;
} catch (Exception e) {
log.error("上传文件失败", e);
throw new BusinessException(ResultCode.UPLOAD_FILE_EXCEPTION, e.getMessage());
}
}
/**
* 删除文件。
* <p>
* 从完整 URL 中提取文件名后调用 MinIO 删除。
*
* @param filePath 文件完整 URL
* @return 删除成功返回 true
*/
@Override
public boolean deleteFile(String filePath) {
Assert.notBlank(filePath, "删除文件路径不能为空");
try {
String fileName;
if (StrUtil.isNotBlank(domain)) {
fileName = filePath.substring(domain.length() + 1 + bucket.length() + 1);
} else {
fileName = filePath.substring(endpoint.length() + 1 + bucket.length() + 1);
}
minioClient.removeObject(RemoveObjectArgs.builder()
.bucket(bucket).object(fileName).build());
return true;
} catch (Exception e) {
log.error("删除文件失败", e);
throw new BusinessException(ResultCode.DELETE_FILE_EXCEPTION, e.getMessage());
}
}
/** 生成公开读的存储桶策略 JSON */
private static String publicBucketPolicy(String bucket) {
return "{\"Version\":\"2012-10-17\","
+ "\"Statement\":[{\"Effect\":\"Allow\","
+ "\"Principal\":{\"AWS\":[\"*\"]},"
+ "\"Action\":[\"s3:ListBucketMultipartUploads\",\"s3:GetBucketLocation\",\"s3:ListBucket\"],"
+ "\"Resource\":[\"arn:aws:s3:::" + bucket + "\"]},"
+ "{\"Effect\":\"Allow\",\"" + "\"Principal\":{\"AWS\":[\"*\"]},"
+ "\"Action\":[\"s3:ListMultipartUploadParts\",\"s3:PutObject\",\"s3:AbortMultipartUpload\",\"s3:DeleteObject\",\"s3:GetObject\"],"
+ "\"Resource\":[\"arn:aws:s3:::" + bucket + "/*\"]}]}";
}
/**
* 存储桶不存在时自动创建,并设置为公开读策略。
*/
@SneakyThrows
private void createBucketIfAbsent(String bucket) {
if (!minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucket).build())) {
minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
minioClient.setBucketPolicy(SetBucketPolicyArgs.builder()
.bucket(bucket).config(publicBucketPolicy(bucket)).build());
}
}
}

View File

@@ -0,0 +1,209 @@
package com.youlai.boot.support.file.service.impl;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.common.exception.BusinessException;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.support.file.model.FileInfo;
import com.youlai.boot.support.file.service.FileService;
import jakarta.annotation.PostConstruct;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.S3Configuration;
import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.HeadBucketRequest;
import software.amazon.awssdk.services.s3.model.PutBucketPolicyRequest;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.model.S3Exception;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
import java.net.URI;
import java.time.Duration;
import java.time.LocalDateTime;
/**
* RustFS 文件存储服务(S3 协议)。
* <p>
* 当 {@code file-storage.type=rustfs} 时生效。通过 AWS SDK 的 S3Client
* 连接 RustFS(S3 兼容对象存储),IP 端点必须走 path-style 寻址。
* 上传文件按日期分目录存储,路径格式:{@code yyyyMMdd/uuid.suffix}。
* <ul>
* <li>配置了 {@code file-storage.rustfs.domain} 时直接用域名拼接 URL</li>
* <li>未配置则用预签名 GET URL(去掉 query 参数后的纯路径)</li>
* </ul>
*
* @author youlai.tech
*/
@Component
@ConditionalOnProperty(value = "file-storage.type", havingValue = "rustfs")
@ConfigurationProperties(prefix = "file-storage.rustfs")
@RequiredArgsConstructor
@Data
@Slf4j
public class RustFSFileServiceImpl implements FileService {
/** S3 对象存储服务端点,如 {@code http://localhost:9000} */
private String endpoint;
/** 访问密钥 */
private String accessKey;
/** 秘密密钥 */
private String secretKey;
/** 存储桶名称 */
private String bucket;
/** 自定义域名(可选),配置后不再使用预签名 URL */
private String domain;
private S3Client s3Client;
private S3Presigner presigner;
@PostConstruct
public void init() {
// IP 端点必须走 path-style:桶名作为路径而不是子域名(public.IP 无法被 DNS 解析)
S3Configuration s3Config = S3Configuration.builder()
.pathStyleAccessEnabled(true)
.build();
s3Client = S3Client.builder()
.endpointOverride(URI.create(endpoint))
// 非 AWS 服务必填,值仅区分区域,任意即可
.region(Region.US_EAST_1)
.serviceConfiguration(s3Config)
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create(accessKey, secretKey)))
.build();
presigner = S3Presigner.builder()
.endpointOverride(URI.create(endpoint))
.region(Region.US_EAST_1)
.serviceConfiguration(s3Config)
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create(accessKey, secretKey)))
.build();
}
/**
* 上传文件到 RustFS,按日期分目录,自动创建存储桶,返回文件访问 URL。
*/
@Override
@SneakyThrows
public FileInfo uploadFile(MultipartFile file) {
createBucketIfAbsent(bucket);
String originalFilename = file.getOriginalFilename();
String suffix = FileUtil.getSuffix(originalFilename);
String dateFolder = DateUtil.format(LocalDateTime.now(), "yyyyMMdd");
String fileName = IdUtil.simpleUUID() + "." + suffix;
try {
byte[] bytes = file.getBytes();
PutObjectRequest putObjectRequest = PutObjectRequest.builder()
.bucket(bucket)
.key(dateFolder + "/" + fileName)
.contentType(file.getContentType())
.build();
// 用固定长度的字节数组上传,避免流式分块签名在某些 S3 兼容服务上报校验和不匹配
s3Client.putObject(putObjectRequest, RequestBody.fromBytes(bytes));
String fileUrl;
if (StrUtil.isBlank(domain)) {
fileUrl = presignedUrl(dateFolder, fileName);
} else {
fileUrl = domain + "/" + bucket + "/" + dateFolder + "/" + fileName;
}
FileInfo fileInfo = new FileInfo();
fileInfo.setName(originalFilename);
fileInfo.setUrl(fileUrl);
return fileInfo;
} catch (Exception e) {
log.error("上传文件失败", e);
throw new BusinessException(ResultCode.UPLOAD_FILE_EXCEPTION, e.getMessage());
}
}
/**
* 生成预签名 GET URL 并去掉 query 参数,路径直达对象。
* 依赖桶已开匿名只读(详见 createBucketIfAbsent 的策略),签名参数在 URL 中无实际作用。
*/
private String presignedUrl(String dateFolder, String fileName) {
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
.bucket(bucket)
.key(dateFolder + "/" + fileName)
.build();
GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
.signatureDuration(Duration.ofMinutes(10))
.getObjectRequest(getObjectRequest)
.build();
String url = presigner.presignGetObject(presignRequest).url().toString();
return url.substring(0, url.indexOf("?"));
}
/**
* 删除文件。
* <p>
* 从完整 URL 中提取对象名后调用 S3 删除。
*
* @param filePath 文件完整 URL
* @return 删除成功返回 true
*/
@Override
public boolean deleteFile(String filePath) {
Assert.notBlank(filePath, "删除文件路径不能为空");
try {
String fileName;
if (StrUtil.isNotBlank(domain)) {
fileName = filePath.substring(domain.length() + 1 + bucket.length() + 1);
} else {
fileName = filePath.substring(endpoint.length() + 1 + bucket.length() + 1);
}
s3Client.deleteObject(DeleteObjectRequest.builder()
.bucket(bucket).key(fileName).build());
return true;
} catch (Exception e) {
log.error("删除文件失败", e);
throw new BusinessException(ResultCode.DELETE_FILE_EXCEPTION, e.getMessage());
}
}
/** 公开只读策略:匿名可列桶与读对象,禁止任何匿名写入 */
private static String publicBucketPolicy(String bucket) {
return "{\"Version\":\"2012-10-17\","
+ "\"Statement\":[{\"Effect\":\"Allow\","
+ "\"Principal\":{\"AWS\":[\"*\"]},"
+ "\"Action\":[\"s3:GetObject\",\"s3:ListBucket\"],"
+ "\"Resource\":[\"arn:aws:s3:::" + bucket + "\",\"arn:aws:s3:::" + bucket + "/*\"]}]}";
}
/**
* 存储桶不存在时自动创建,并设置为公开只读策略。
*/
@SneakyThrows
private void createBucketIfAbsent(String bucket) {
try {
s3Client.headBucket(HeadBucketRequest.builder().bucket(bucket).build());
} catch (S3Exception e) {
// 桶不存在(404)时才创建;其他错误(如权限不足)放行由调用方抛出
if (e.statusCode() == 404) {
s3Client.createBucket(CreateBucketRequest.builder().bucket(bucket).build());
s3Client.putBucketPolicy(PutBucketPolicyRequest.builder()
.bucket(bucket).policy(publicBucketPolicy(bucket)).build());
} else {
throw e;
}
}
}
}

View File

@@ -55,7 +55,7 @@ public class RoleServiceImpl extends ServiceImpl<RoleMapper, Role> implements Ro
* 角色分页列表
*
* @param queryParams 角色查询参数
* @return {@link Page< RolePageVO >} – 角色分页列表
* @return {@link Page<RolePageVO>} – 角色分页列表
*/
@Override
public Page<RolePageVO> getRolePage(RoleQuery queryParams) {

View File

@@ -3,7 +3,7 @@
{
"name": "file-storage.type",
"type": "java.lang.String",
"description": "文件存储类型:minio | aliyun | local"
"description": "文件存储类型:rustfs | aliyun | local"
},
{
"name": "file-storage.upload.max-file-size",
@@ -16,29 +16,29 @@
"description": "允许的文件扩展名白名单(置空表示不限制)"
},
{
"name": "file-storage.minio.endpoint",
"name": "file-storage.rustfs.endpoint",
"type": "java.lang.String",
"description": "MinIO 服务 Endpoint"
"description": "RustFS(S3) 服务 Endpoint"
},
{
"name": "file-storage.minio.access-key",
"name": "file-storage.rustfs.access-key",
"type": "java.lang.String",
"description": "MinIO 访问凭据"
"description": "RustFS 访问凭据"
},
{
"name": "file-storage.minio.secret-key",
"name": "file-storage.rustfs.secret-key",
"type": "java.lang.String",
"description": "MinIO 凭据密钥"
"description": "RustFS 凭据密钥"
},
{
"name": "file-storage.minio.bucket",
"name": "file-storage.rustfs.bucket",
"type": "java.lang.String",
"description": "MinIO 存储桶名称"
"description": "RustFS 存储桶名称"
},
{
"name": "file-storage.minio.domain",
"name": "file-storage.rustfs.domain",
"type": "java.lang.String",
"description": "MinIO 自定义域名(文件 URL 域名)"
"description": "RustFS 自定义域名(文件 URL 域名)"
},
{
"name": "file-storage.aliyun.endpoint",

View File

@@ -108,8 +108,8 @@ security:
# 文件存储
file-storage:
# 存储类型:minio | aliyun | local
type: minio
# 存储类型:rustfs | aliyun | local
type: rustfs
# 上传限制
upload:
# 单文件大小上限(同时作为 spring.servlet.multipart 的上限,单一来源)
@@ -120,8 +120,8 @@ file-storage:
- jpeg
- png
- gif
# MinIO 对象存储
minio:
# RustFS(S3) 对象存储
rustfs:
endpoint: http://111.229.83.153:9000
access-key: bybaddp7zyARpgNbEGKf
secret-key: p9rBdQZPBIJcMH23iyFkZkXmmawbmwPlk3JLlaaj

View File

@@ -107,8 +107,8 @@ security:
# 文件存储
file-storage:
# 存储类型:minio | aliyun | local
type: minio
# 存储类型:rustfs | aliyun | local
type: rustfs
# 上传限制
upload:
# 单文件大小上限(同时作为 spring.servlet.multipart 的上限,单一来源)
@@ -119,11 +119,11 @@ file-storage:
- jpeg
- png
- gif
# MinIO 对象存储
minio:
# RustFS(S3) 对象存储
rustfs:
endpoint: http://localhost:9000
access-key: minioadmin
secret-key: minioadmin
access-key: rustfs-admin
secret-key: rustfs-admin
bucket: public
# 自定义域名:配置后文件 URL 走域名,留空则用 endpoint(IP)格式
domain: