From 6da8ea0fcab6f33a12a88cc0a75bf2bf2d08fcec Mon Sep 17 00:00:00 2001
From: "Ray.Hao" <1490493387@qq.com>
Date: Tue, 25 Aug 2026 08:21:10 +0800
Subject: [PATCH] =?UTF-8?q?refactor:=20=E7=94=A8=20RustFS=20S3=20=E6=9B=BF?=
=?UTF-8?q?=E4=BB=A3=20MinIO=20=E5=B9=B6=E4=BC=98=E5=8C=96=20OSS=20endpoin?=
=?UTF-8?q?t=20=E5=8D=8F=E8=AE=AE=E5=A4=84=E7=90=86?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
pom.xml | 9 +-
.../property/FileStorageProperties.java | 4 +-
.../service/impl/AliyunFileServiceImpl.java | 40 +++-
.../service/impl/MinioFileServiceImpl.java | 159 -------------
.../service/impl/RustFSFileServiceImpl.java | 209 ++++++++++++++++++
.../system/service/impl/RoleServiceImpl.java | 2 +-
...itional-spring-configuration-metadata.json | 22 +-
src/main/resources/application-dev.yml | 8 +-
src/main/resources/application-prod.yml | 12 +-
9 files changed, 270 insertions(+), 195 deletions(-)
delete mode 100644 src/main/java/com/youlai/boot/support/file/service/impl/MinioFileServiceImpl.java
create mode 100644 src/main/java/com/youlai/boot/support/file/service/impl/RustFSFileServiceImpl.java
diff --git a/pom.xml b/pom.xml
index 843ee6e8..96dad4c8 100644
--- a/pom.xml
+++ b/pom.xml
@@ -38,7 +38,6 @@
- * 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(); diff --git a/src/main/java/com/youlai/boot/support/file/service/impl/AliyunFileServiceImpl.java b/src/main/java/com/youlai/boot/support/file/service/impl/AliyunFileServiceImpl.java index b50a32ec..3fb16ce7 100644 --- a/src/main/java/com/youlai/boot/support/file/service/impl/AliyunFileServiceImpl.java +++ b/src/main/java/com/youlai/boot/support/file/service/impl/AliyunFileServiceImpl.java @@ -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 文件存储服务。 *
* 当 {@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}。 + *
+ * 协议以 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; + } } diff --git a/src/main/java/com/youlai/boot/support/file/service/impl/MinioFileServiceImpl.java b/src/main/java/com/youlai/boot/support/file/service/impl/MinioFileServiceImpl.java deleted file mode 100644 index 072a1d81..00000000 --- a/src/main/java/com/youlai/boot/support/file/service/impl/MinioFileServiceImpl.java +++ /dev/null @@ -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 文件存储服务。 - *
- * 当 {@code file-storage.type=minio} 时生效。 - * 上传文件按日期分目录存储,路径格式:{@code yyyyMMdd/uuid.suffix}。 - *
- * 从完整 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()); - } - } -} diff --git a/src/main/java/com/youlai/boot/support/file/service/impl/RustFSFileServiceImpl.java b/src/main/java/com/youlai/boot/support/file/service/impl/RustFSFileServiceImpl.java new file mode 100644 index 00000000..290e3ed6 --- /dev/null +++ b/src/main/java/com/youlai/boot/support/file/service/impl/RustFSFileServiceImpl.java @@ -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 协议)。 + *
+ * 当 {@code file-storage.type=rustfs} 时生效。通过 AWS SDK 的 S3Client + * 连接 RustFS(S3 兼容对象存储),IP 端点必须走 path-style 寻址。 + * 上传文件按日期分目录存储,路径格式:{@code yyyyMMdd/uuid.suffix}。 + *
+ * 从完整 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;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/youlai/boot/system/service/impl/RoleServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/RoleServiceImpl.java
index bf65a57d..b62faf43 100644
--- a/src/main/java/com/youlai/boot/system/service/impl/RoleServiceImpl.java
+++ b/src/main/java/com/youlai/boot/system/service/impl/RoleServiceImpl.java
@@ -55,7 +55,7 @@ public class RoleServiceImpl extends ServiceImpl