refactor: 用 RustFS S3 替代 MinIO 并优化 OSS endpoint 协议处理
This commit is contained in:
9
pom.xml
9
pom.xml
@@ -38,7 +38,6 @@
|
|||||||
<fastexcel.version>1.3.0</fastexcel.version>
|
<fastexcel.version>1.3.0</fastexcel.version>
|
||||||
|
|
||||||
<!-- 对象存储 -->
|
<!-- 对象存储 -->
|
||||||
<minio.version>8.5.10</minio.version>
|
|
||||||
<okhttp3.version>4.12.0</okhttp3.version>
|
<okhttp3.version>4.12.0</okhttp3.version>
|
||||||
|
|
||||||
<aliyun-sdk-oss.version>3.16.3</aliyun-sdk-oss.version>
|
<aliyun-sdk-oss.version>3.16.3</aliyun-sdk-oss.version>
|
||||||
@@ -214,11 +213,11 @@
|
|||||||
<version>${fastexcel.version}</version>
|
<version>${fastexcel.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- MinIO 对象存储 -->
|
<!-- S3 对象存储 -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.minio</groupId>
|
<groupId>software.amazon.awssdk</groupId>
|
||||||
<artifactId>minio</artifactId>
|
<artifactId>s3</artifactId>
|
||||||
<version>${minio.version}</version>
|
<version>2.54.2</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- 阿里云 OSS 对象存储 -->
|
<!-- 阿里云 OSS 对象存储 -->
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import java.util.stream.Collectors;
|
|||||||
/**
|
/**
|
||||||
* 文件存储顶层配置:存储类型与上传限制。
|
* 文件存储顶层配置:存储类型与上传限制。
|
||||||
* <p>
|
* <p>
|
||||||
* minio / aliyun / local 子树由各自 {@code FileService} 实现类绑定,
|
* rustfs / aliyun / local 子树由各自 {@code FileService} 实现类绑定,
|
||||||
* 此处仅承载 {@code type} 与 {@code upload}。
|
* 此处仅承载 {@code type} 与 {@code upload}。
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
@@ -20,7 +20,7 @@ import java.util.stream.Collectors;
|
|||||||
@ConfigurationProperties(prefix = "file-storage")
|
@ConfigurationProperties(prefix = "file-storage")
|
||||||
public class FileStorageProperties {
|
public class FileStorageProperties {
|
||||||
|
|
||||||
/** 存储类型:minio | aliyun | local */
|
/** 存储类型:rustfs | aliyun | local */
|
||||||
private String type;
|
private String type;
|
||||||
|
|
||||||
private Upload upload = new Upload();
|
private Upload upload = new Upload();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import cn.hutool.core.date.DateUtil;
|
|||||||
import cn.hutool.core.io.FileUtil;
|
import cn.hutool.core.io.FileUtil;
|
||||||
import cn.hutool.core.lang.Assert;
|
import cn.hutool.core.lang.Assert;
|
||||||
import cn.hutool.core.util.IdUtil;
|
import cn.hutool.core.util.IdUtil;
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
import com.aliyun.oss.OSS;
|
import com.aliyun.oss.OSS;
|
||||||
import com.aliyun.oss.OSSClientBuilder;
|
import com.aliyun.oss.OSSClientBuilder;
|
||||||
import com.aliyun.oss.model.ObjectMetadata;
|
import com.aliyun.oss.model.ObjectMetadata;
|
||||||
@@ -28,7 +29,8 @@ import java.time.LocalDateTime;
|
|||||||
* 阿里云 OSS 文件存储服务。
|
* 阿里云 OSS 文件存储服务。
|
||||||
* <p>
|
* <p>
|
||||||
* 当 {@code file-storage.type=aliyun} 时生效。
|
* 当 {@code file-storage.type=aliyun} 时生效。
|
||||||
* 上传文件按日期分目录存储,URL 格式:{@code https://bucket.endpoint/yyyyMMdd/uuid.suffix}。
|
* 上传文件按日期分目录存储,URL 格式:{@code 协议://bucket.endpoint/yyyyMMdd/uuid.suffix},
|
||||||
|
* 协议取自 endpoint 配置(未指定时默认 https)。
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -38,7 +40,7 @@ import java.time.LocalDateTime;
|
|||||||
@Data
|
@Data
|
||||||
public class AliyunFileServiceImpl implements FileService {
|
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;
|
private String endpoint;
|
||||||
/** AccessKey ID */
|
/** AccessKey ID */
|
||||||
private String accessKeyId;
|
private String accessKeyId;
|
||||||
@@ -51,11 +53,11 @@ public class AliyunFileServiceImpl implements FileService {
|
|||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
public void init() {
|
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
|
@Override
|
||||||
@SneakyThrows
|
@SneakyThrows
|
||||||
@@ -73,7 +75,7 @@ public class AliyunFileServiceImpl implements FileService {
|
|||||||
log.error("文件上传失败", e);
|
log.error("文件上传失败", e);
|
||||||
throw new BusinessException("文件上传失败", e);
|
throw new BusinessException("文件上传失败", e);
|
||||||
}
|
}
|
||||||
String fileUrl = "https://" + bucket + "." + endpoint + "/" + fileName;
|
String fileUrl = getFileHost() + "/" + fileName;
|
||||||
FileInfo fileInfo = new FileInfo();
|
FileInfo fileInfo = new FileInfo();
|
||||||
fileInfo.setName(originalFilename);
|
fileInfo.setName(originalFilename);
|
||||||
fileInfo.setUrl(fileUrl);
|
fileInfo.setUrl(fileUrl);
|
||||||
@@ -89,9 +91,33 @@ public class AliyunFileServiceImpl implements FileService {
|
|||||||
@Override
|
@Override
|
||||||
public boolean deleteFile(String filePath) {
|
public boolean deleteFile(String filePath) {
|
||||||
Assert.notBlank(filePath, "删除文件路径不能为空");
|
Assert.notBlank(filePath, "删除文件路径不能为空");
|
||||||
String fileHost = "https://" + bucket + "." + endpoint;
|
String fileName = filePath.substring(getFileHost().length() + 1);
|
||||||
String fileName = filePath.substring(fileHost.length() + 1);
|
|
||||||
aliyunOssClient.deleteObject(bucket, fileName);
|
aliyunOssClient.deleteObject(bucket, fileName);
|
||||||
return true;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,7 +55,7 @@ public class RoleServiceImpl extends ServiceImpl<RoleMapper, Role> implements Ro
|
|||||||
* 角色分页列表
|
* 角色分页列表
|
||||||
*
|
*
|
||||||
* @param queryParams 角色查询参数
|
* @param queryParams 角色查询参数
|
||||||
* @return {@link Page< RolePageVO >} – 角色分页列表
|
* @return {@link Page<RolePageVO>} – 角色分页列表
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public Page<RolePageVO> getRolePage(RoleQuery queryParams) {
|
public Page<RolePageVO> getRolePage(RoleQuery queryParams) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
{
|
{
|
||||||
"name": "file-storage.type",
|
"name": "file-storage.type",
|
||||||
"type": "java.lang.String",
|
"type": "java.lang.String",
|
||||||
"description": "文件存储类型:minio | aliyun | local"
|
"description": "文件存储类型:rustfs | aliyun | local"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "file-storage.upload.max-file-size",
|
"name": "file-storage.upload.max-file-size",
|
||||||
@@ -16,29 +16,29 @@
|
|||||||
"description": "允许的文件扩展名白名单(置空表示不限制)"
|
"description": "允许的文件扩展名白名单(置空表示不限制)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "file-storage.minio.endpoint",
|
"name": "file-storage.rustfs.endpoint",
|
||||||
"type": "java.lang.String",
|
"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",
|
"type": "java.lang.String",
|
||||||
"description": "MinIO 访问凭据"
|
"description": "RustFS 访问凭据"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "file-storage.minio.secret-key",
|
"name": "file-storage.rustfs.secret-key",
|
||||||
"type": "java.lang.String",
|
"type": "java.lang.String",
|
||||||
"description": "MinIO 凭据密钥"
|
"description": "RustFS 凭据密钥"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "file-storage.minio.bucket",
|
"name": "file-storage.rustfs.bucket",
|
||||||
"type": "java.lang.String",
|
"type": "java.lang.String",
|
||||||
"description": "MinIO 存储桶名称"
|
"description": "RustFS 存储桶名称"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "file-storage.minio.domain",
|
"name": "file-storage.rustfs.domain",
|
||||||
"type": "java.lang.String",
|
"type": "java.lang.String",
|
||||||
"description": "MinIO 自定义域名(文件 URL 域名)"
|
"description": "RustFS 自定义域名(文件 URL 域名)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "file-storage.aliyun.endpoint",
|
"name": "file-storage.aliyun.endpoint",
|
||||||
|
|||||||
@@ -108,8 +108,8 @@ security:
|
|||||||
|
|
||||||
# 文件存储
|
# 文件存储
|
||||||
file-storage:
|
file-storage:
|
||||||
# 存储类型:minio | aliyun | local
|
# 存储类型:rustfs | aliyun | local
|
||||||
type: minio
|
type: rustfs
|
||||||
# 上传限制
|
# 上传限制
|
||||||
upload:
|
upload:
|
||||||
# 单文件大小上限(同时作为 spring.servlet.multipart 的上限,单一来源)
|
# 单文件大小上限(同时作为 spring.servlet.multipart 的上限,单一来源)
|
||||||
@@ -120,8 +120,8 @@ file-storage:
|
|||||||
- jpeg
|
- jpeg
|
||||||
- png
|
- png
|
||||||
- gif
|
- gif
|
||||||
# MinIO 对象存储
|
# RustFS(S3) 对象存储
|
||||||
minio:
|
rustfs:
|
||||||
endpoint: http://111.229.83.153:9000
|
endpoint: http://111.229.83.153:9000
|
||||||
access-key: bybaddp7zyARpgNbEGKf
|
access-key: bybaddp7zyARpgNbEGKf
|
||||||
secret-key: p9rBdQZPBIJcMH23iyFkZkXmmawbmwPlk3JLlaaj
|
secret-key: p9rBdQZPBIJcMH23iyFkZkXmmawbmwPlk3JLlaaj
|
||||||
|
|||||||
@@ -107,8 +107,8 @@ security:
|
|||||||
|
|
||||||
# 文件存储
|
# 文件存储
|
||||||
file-storage:
|
file-storage:
|
||||||
# 存储类型:minio | aliyun | local
|
# 存储类型:rustfs | aliyun | local
|
||||||
type: minio
|
type: rustfs
|
||||||
# 上传限制
|
# 上传限制
|
||||||
upload:
|
upload:
|
||||||
# 单文件大小上限(同时作为 spring.servlet.multipart 的上限,单一来源)
|
# 单文件大小上限(同时作为 spring.servlet.multipart 的上限,单一来源)
|
||||||
@@ -119,11 +119,11 @@ file-storage:
|
|||||||
- jpeg
|
- jpeg
|
||||||
- png
|
- png
|
||||||
- gif
|
- gif
|
||||||
# MinIO 对象存储
|
# RustFS(S3) 对象存储
|
||||||
minio:
|
rustfs:
|
||||||
endpoint: http://localhost:9000
|
endpoint: http://localhost:9000
|
||||||
access-key: minioadmin
|
access-key: rustfs-admin
|
||||||
secret-key: minioadmin
|
secret-key: rustfs-admin
|
||||||
bucket: public
|
bucket: public
|
||||||
# 自定义域名:配置后文件 URL 走域名,留空则用 endpoint(IP)格式
|
# 自定义域名:配置后文件 URL 走域名,留空则用 endpoint(IP)格式
|
||||||
domain:
|
domain:
|
||||||
|
|||||||
Reference in New Issue
Block a user