diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/builder/Builder.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/builder/Builder.java new file mode 100644 index 000000000..481ed08f8 --- /dev/null +++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/builder/Builder.java @@ -0,0 +1,14 @@ +package org.dromara.common.oss.s3.builder; + +/** + * 构建器 + * + * @param 参数类型 + * @param 构建目标类型 + * @author 秋辞未寒 + */ +public interface Builder { + + R build(T param); + +} diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/builder/CloudServiceBucketUrlBuilder.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/builder/CloudServiceBucketUrlBuilder.java new file mode 100644 index 000000000..299925463 --- /dev/null +++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/builder/CloudServiceBucketUrlBuilder.java @@ -0,0 +1,41 @@ +package org.dromara.common.oss.s3.builder; + +import org.dromara.common.oss.s3.config.S3StorageClientConfig; +import org.dromara.common.oss.s3.exception.S3StorageException; +import org.dromara.common.oss.s3.util.BucketUrlUtil; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.lang.StringBuilder; +import java.util.Optional; + +/** + * 云服务商文件对象桶URL构建器 + * + * @author 秋辞未寒 + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public enum CloudServiceBucketUrlBuilder implements StringBuilder { + + INSTANCE; + + @Override + public String build(S3StorageClientConfig config) { + boolean useHttps = config.useHttps(); + Optional domainOpt = config.domain().filter(s -> !s.isBlank()); + // 如果已经配置了自定义域名,则优先使用域名 + if (domainOpt.isPresent()) { + // 云服务商一般都支持桶映射到域名,这里不再特殊处理,仅处理链接的协议头即可 + return BucketUrlUtil.getDomainUrl(useHttps, domainOpt.get()); + } + // 否则使用站点 + String endpoint = config.endpoint() + .filter(s -> !s.isBlank()) + .orElseThrow(() -> S3StorageException.of("endpoint is not configured.")); + // 如果未配置桶,则抛异常 + String bucket = config.bucket() + .filter(s -> !s.isBlank()) + .orElseThrow(() -> S3StorageException.of("bucket is not configured.")); + return BucketUrlUtil.getSiteStyleBucketUrl(useHttps, endpoint, bucket); + } +} diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/builder/DefaultS3StorageClientBuilder.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/builder/DefaultS3StorageClientBuilder.java new file mode 100644 index 000000000..d7ab01676 --- /dev/null +++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/builder/DefaultS3StorageClientBuilder.java @@ -0,0 +1,86 @@ +package org.dromara.common.oss.s3.builder; + +import org.dromara.common.oss.s3.client.S3StorageClient; +import org.dromara.common.oss.s3.client.S3StorageClientImpl; +import org.dromara.common.oss.s3.config.S3StorageClientConfig; +import org.dromara.common.oss.s3.exception.S3StorageException; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.http.nio.netty.NettyNioAsyncHttpClient; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3AsyncClient; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.transfer.s3.S3TransferManager; + +import java.net.URI; +import java.time.Duration; + +/** + * 默认S3存储客户端构建器 + * + * @author 秋辞未寒 + */ +public enum DefaultS3StorageClientBuilder implements S3StorageClientBuilder { + INSTANCE; + + @Override + public S3StorageClient build(S3StorageClientConfig config) { + String accessKey = config.accessKey() + .filter(bucket -> !bucket.isBlank()) + .orElseThrow(() -> S3StorageException.of("accessKey is not configured.")); + String secretKey = config.secretKey() + .filter(bucket -> !bucket.isBlank()) + .orElseThrow(() -> S3StorageException.of("secretKey is not configured.")); + Region region = config.region() + .orElse(Region.US_EAST_1); + String endpointUrl = config.getEndpointUrl(); + String domainUrl = config.getDomainUrl(); + // MinIO 使用 HTTPS 限制使用域名访问,站点填域名。需要启用路径样式访问 + boolean usePathStyleAccess = config.usePathStyleAccess(); + try { + // 创建 AWS 认证信息 + StaticCredentialsProvider credentialsProvider = StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey)); + + // 创建AWS基于 Netty 的 S3 客户端 + S3AsyncClient client = S3AsyncClient.builder() + .credentialsProvider(credentialsProvider) + .endpointOverride(URI.create(endpointUrl)) + .region(region) + .forcePathStyle(usePathStyleAccess) + .httpClient( + NettyNioAsyncHttpClient.builder() + .connectionTimeout(Duration.ofSeconds(60)) + .connectionAcquisitionTimeout(Duration.ofSeconds(30)) + .maxConcurrency(100) + .maxPendingConnectionAcquires(1000) + .build() + ) + .build(); + + //AWS基于 CRT 的 S3 AsyncClient 实例用作 S3 传输管理器的底层客户端 + S3TransferManager transferManager = S3TransferManager.builder().s3Client(client).build(); + + // 创建 预签名 URL 的生成器 实例,用于生成 S3 预签名 URL + S3Presigner presigner = S3Presigner.builder() + .region(region) + .credentialsProvider(credentialsProvider) + .endpointOverride(URI.create(domainUrl)) + .serviceConfiguration( + // 创建 S3 配置对象 + S3Configuration.builder() + .chunkedEncodingEnabled(false) + .pathStyleAccessEnabled(usePathStyleAccess) + .build() + ) + .build(); + + return new S3StorageClientImpl(config,client,transferManager,presigner); + } catch (Exception e) { + if (e instanceof S3StorageException) { + throw e; + } + throw S3StorageException.of(e); + } + } +} diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/builder/MinioBucketUrlBuilder.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/builder/MinioBucketUrlBuilder.java new file mode 100644 index 000000000..c9beac2e5 --- /dev/null +++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/builder/MinioBucketUrlBuilder.java @@ -0,0 +1,40 @@ +package org.dromara.common.oss.s3.builder; + +import org.dromara.common.oss.s3.config.S3StorageClientConfig; +import org.dromara.common.oss.s3.exception.S3StorageException; +import org.dromara.common.oss.s3.util.BucketUrlUtil; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.lang.StringBuilder; +import java.util.Optional; + +/** + * MinIO文件对象桶URL构建器 + * + * @author 秋辞未寒 + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public enum MinioBucketUrlBuilder implements StringBuilder { + + INSTANCE; + + @Override + public String build(S3StorageClientConfig config) { + boolean useHttps = config.useHttps(); + // 如果未配置桶,则抛异常 + String bucket = config.bucket() + .filter(s -> !s.isBlank()) + .orElseThrow(() -> S3StorageException.of("bucket is not configured.")); + Optional domainOpt = config.domain().filter(s -> !s.isBlank()); + // 如果已经配置了自定义域名,则优先使用域名 + if (domainOpt.isPresent()) { + return BucketUrlUtil.getPathStyleBucketUrl(useHttps, domainOpt.get(), bucket); + } + // 否则使用站点 + String endpoint = config.endpoint() + .filter(s -> !s.isBlank()) + .orElseThrow(() -> S3StorageException.of("endpoint is not configured.")); + return BucketUrlUtil.getPathStyleBucketUrl(useHttps, endpoint, bucket); + } +} diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/builder/S3StorageClientBuilder.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/builder/S3StorageClientBuilder.java new file mode 100644 index 000000000..12ee1db9b --- /dev/null +++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/builder/S3StorageClientBuilder.java @@ -0,0 +1,13 @@ +package org.dromara.common.oss.s3.builder; + +import org.dromara.common.oss.s3.client.S3StorageClient; + +/** + * S3存储客户端构建器 + * + * @param 参数类型 + * @author 秋辞未寒 + */ +public interface S3StorageClientBuilder extends Builder { + +} diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/builder/S3StorageClientConfigBuilder.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/builder/S3StorageClientConfigBuilder.java new file mode 100644 index 000000000..bf09db5d1 --- /dev/null +++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/builder/S3StorageClientConfigBuilder.java @@ -0,0 +1,13 @@ +package org.dromara.common.oss.s3.builder; + +import org.dromara.common.oss.s3.config.S3StorageClientConfig; + +/** + * S3存储客户端配置构建器 + * + * @param 参数类型 + * @author 秋辞未寒 + */ +public interface S3StorageClientConfigBuilder extends Builder { + +} diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/builder/StringBuilder.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/builder/StringBuilder.java new file mode 100644 index 000000000..9beae91cf --- /dev/null +++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/builder/StringBuilder.java @@ -0,0 +1,11 @@ +package org.dromara.common.oss.s3.builder; + +/** + * 字符串构建器 + * + * @param 参数类型 + * @author 秋辞未寒 + */ +public interface StringBuilder extends Builder { + +} diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/client/S3StorageClient.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/client/S3StorageClient.java new file mode 100644 index 000000000..ba131e8c5 --- /dev/null +++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/client/S3StorageClient.java @@ -0,0 +1,392 @@ +package org.dromara.common.oss.s3.client; + +import org.dromara.common.oss.s3.config.S3StorageClientConfig; +import org.dromara.common.oss.s3.domain.GetObjectResult; +import org.dromara.common.oss.s3.domain.HandleAsyncResult; +import org.dromara.common.oss.s3.domain.PutObjectResult; +import org.dromara.common.oss.s3.io.OutputStreamDownloadSubscriber; +import software.amazon.awssdk.core.async.AsyncRequestBody; +import software.amazon.awssdk.core.async.AsyncResponseTransformer; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectResponse; +import software.amazon.awssdk.transfer.s3.model.CompletedUpload; +import software.amazon.awssdk.transfer.s3.progress.TransferListener; + +import java.io.File; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.RandomAccessFile; +import java.nio.channels.ReadableByteChannel; +import java.nio.channels.WritableByteChannel; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Collection; +import java.util.Map; +import java.util.function.BiFunction; +import java.util.function.Consumer; + +/** + * S3 存储客户端接口。 + *

+ * 本接口同时提供两套对象操作 API: + * 一套通过 {@code bucketXxx(...)} 显式指定存储桶, + * 另一套通过无前缀方法使用默认存储桶。 + *

+ * + * @author 秋辞未寒 + */ +public interface S3StorageClient extends AutoCloseable { + + /** + * 执行自定义上传请求。 + * + * @param body 上传请求体 + * @param putObjectRequestBuilderConsumer PutObject 请求构建回调 + * @param transferListeners 传输监听器集合 + * @param handleAsyncAction 上传完成后的结果处理函数 + * @param 返回值类型 + * @return 处理后的结果 + */ + T doCustomUpload(AsyncRequestBody body, Consumer putObjectRequestBuilderConsumer, Collection transferListeners, BiFunction handleAsyncAction); + + /** + * 执行自定义上传请求。 + * + * @param body 上传请求体 + * @param putObjectRequestBuilderConsumer PutObject 请求构建回调 + * @param handleAsyncAction 上传完成后的结果处理函数 + * @param 返回值类型 + * @return 处理后的结果 + */ + T doCustomUpload(AsyncRequestBody body, Consumer putObjectRequestBuilderConsumer, BiFunction handleAsyncAction); + + /** + * 执行自定义上传请求,并返回统一异步处理结果。 + * + * @param body 上传请求体 + * @param putObjectRequestBuilderConsumer PutObject 请求构建回调 + * @param transferListeners 传输监听器集合 + * @return 上传结果 + */ + HandleAsyncResult doCustomUpload(AsyncRequestBody body, Consumer putObjectRequestBuilderConsumer, Collection transferListeners); + + /** + * 执行自定义上传请求,并返回统一异步处理结果。 + * + * @param body 上传请求体 + * @param putObjectRequestBuilderConsumer PutObject 请求构建回调 + * @return 上传结果 + */ + HandleAsyncResult doCustomUpload(AsyncRequestBody body, Consumer putObjectRequestBuilderConsumer); + + /** + * 将本地路径对应的文件上传到指定存储桶。 + * + * @param bucket 存储桶名称 + * @param key 对象键 + * @param path 文件路径 + * @return 上传结果 + */ + PutObjectResult bucketUpload(String bucket, String key, Path path); + + /** + * 将文件上传到指定存储桶。 + * + * @param bucket 存储桶名称 + * @param key 对象键 + * @param file 文件对象 + * @return 上传结果 + */ + PutObjectResult bucketUpload(String bucket, String key, File file); + + /** + * 将随机访问文件上传到指定存储桶。 + * + * @param bucket 存储桶名称 + * @param key 对象键 + * @param file 随机访问文件 + * @return 上传结果 + */ + PutObjectResult bucketUpload(String bucket, String key, RandomAccessFile file); + + /** + * 将可读通道中的数据上传到指定存储桶。 + * + * @param bucket 存储桶名称 + * @param key 对象键 + * @param channel 数据通道 + * @param contentLength 内容长度 + * @return 上传结果 + */ + PutObjectResult bucketUpload(String bucket, String key, ReadableByteChannel channel, long contentLength); + + /** + * 将输入流中的数据上传到指定存储桶。 + * + * @param bucket 存储桶名称 + * @param key 对象键 + * @param in 输入流 + * @param contentLength 内容长度 + * @return 上传结果 + */ + PutObjectResult bucketUpload(String bucket, String key, InputStream in, long contentLength); + + /** + * 将字节数组上传到指定存储桶。 + * + * @param bucket 存储桶名称 + * @param key 对象键 + * @param data 字节数组 + * @return 上传结果 + */ + PutObjectResult bucketUpload(String bucket, String key, byte[] data); + + /** + * 执行自定义下载请求。 + * + * @param getObjectRequestBuilderConsumer GetObject 请求构建回调 + * @param responseTransformer 下载响应转换器 + * @param transferListeners 传输监听器集合 + * @param 下载结果类型 + * @return 下载结果 + */ + T doCustomDownload(Consumer getObjectRequestBuilderConsumer, AsyncResponseTransformer responseTransformer, Collection transferListeners); + + /** + * 将指定存储桶中的对象下载到订阅器。 + * + * @param bucket 存储桶名称 + * @param key 对象键 + * @param downloadSubscriber 下载订阅器 + * @return 下载结果 + */ + GetObjectResult bucketDownload(String bucket, String key, OutputStreamDownloadSubscriber downloadSubscriber); + + /** + * 将指定存储桶中的对象下载到本地路径。 + * + * @param bucket 存储桶名称 + * @param key 对象键 + * @param path 本地路径 + * @return 下载结果 + */ + GetObjectResult bucketDownload(String bucket, String key, Path path); + + /** + * 将指定存储桶中的对象下载到文件。 + * + * @param bucket 存储桶名称 + * @param key 对象键 + * @param file 本地文件 + * @return 下载结果 + */ + GetObjectResult bucketDownload(String bucket, String key, File file); + + /** + * 将指定存储桶中的对象下载到随机访问文件。 + * + * @param bucket 存储桶名称 + * @param key 对象键 + * @param file 随机访问文件 + * @return 下载结果 + */ + GetObjectResult bucketDownload(String bucket, String key, RandomAccessFile file); + + /** + * 将指定存储桶中的对象下载到可写通道。 + * + * @param bucket 存储桶名称 + * @param key 对象键 + * @param channel 可写通道 + * @return 下载结果 + */ + GetObjectResult bucketDownload(String bucket, String key, WritableByteChannel channel); + + /** + * 将指定存储桶中的对象下载到输出流。 + * + * @param bucket 存储桶名称 + * @param key 对象键 + * @param out 输出流 + * @return 下载结果 + */ + GetObjectResult bucketDownload(String bucket, String key, OutputStream out); + + /** + * 删除指定存储桶中的对象。 + * + * @param bucket 存储桶名称 + * @param key 对象键 + * @return 是否删除成功 + */ + boolean bucketDelete(String bucket, String key); + + /** + * 生成指定存储桶中文件下载的预签名 URL。 + * + * @param bucket 存储桶名称 + * @param key 对象键 + * @param expiredTime URL 过期时间 + * @return 预签名下载 URL + */ + String bucketPresignGetUrl(String bucket, String key, Duration expiredTime); + + /** + * 生成指定存储桶中文件上传的预签名 URL。 + * + * @param bucket 存储桶名称 + * @param key 对象键 + * @param expiredTime URL 过期时间 + * @param metadata 对象元数据 + * @return 预签名上传 URL + */ + String bucketPresignPutUrl(String bucket, String key, Duration expiredTime, Map metadata); + + /** + * 将本地路径对应的文件上传到默认存储桶。 + * + * @param key 对象键 + * @param path 文件路径 + * @return 上传结果 + */ + PutObjectResult upload(String key, Path path); + + /** + * 将文件上传到默认存储桶。 + * + * @param key 对象键 + * @param file 文件对象 + * @return 上传结果 + */ + PutObjectResult upload(String key, File file); + + /** + * 将随机访问文件上传到默认存储桶。 + * + * @param key 对象键 + * @param file 随机访问文件 + * @return 上传结果 + */ + PutObjectResult upload(String key, RandomAccessFile file); + + /** + * 将可读通道中的数据上传到默认存储桶。 + * + * @param key 对象键 + * @param channel 数据通道 + * @param contentLength 内容长度 + * @return 上传结果 + */ + PutObjectResult upload(String key, ReadableByteChannel channel, long contentLength); + + /** + * 将输入流中的数据上传到默认存储桶。 + * + * @param key 对象键 + * @param in 输入流 + * @param contentLength 内容长度 + * @return 上传结果 + */ + PutObjectResult upload(String key, InputStream in, long contentLength); + + /** + * 将字节数组上传到默认存储桶。 + * + * @param key 对象键 + * @param data 字节数组 + * @return 上传结果 + */ + PutObjectResult upload(String key, byte[] data); + + /** + * 将默认存储桶中的对象下载到订阅器。 + * + * @param key 对象键 + * @param downloadSubscriber 下载订阅器 + * @return 下载结果 + */ + GetObjectResult download(String key, OutputStreamDownloadSubscriber downloadSubscriber); + + /** + * 将默认存储桶中的对象下载到本地路径。 + * + * @param key 对象键 + * @param path 本地路径 + * @return 下载结果 + */ + GetObjectResult download(String key, Path path); + + /** + * 将默认存储桶中的对象下载到文件。 + * + * @param key 对象键 + * @param file 本地文件 + * @return 下载结果 + */ + GetObjectResult download(String key, File file); + + /** + * 将默认存储桶中的对象下载到随机访问文件。 + * + * @param key 对象键 + * @param file 随机访问文件 + * @return 下载结果 + */ + GetObjectResult download(String key, RandomAccessFile file); + + /** + * 将默认存储桶中的对象下载到可写通道。 + * + * @param key 对象键 + * @param channel 可写通道 + * @return 下载结果 + */ + GetObjectResult download(String key, WritableByteChannel channel); + + /** + * 将默认存储桶中的对象下载到输出流。 + * + * @param key 对象键 + * @param out 输出流 + * @return 下载结果 + */ + GetObjectResult download(String key, OutputStream out); + + /** + * 删除默认存储桶中的对象。 + * + * @param key 对象键 + * @return 是否删除成功 + */ + boolean delete(String key); + + /** + * 生成默认存储桶中文件下载的预签名 URL。 + * + * @param key 对象键 + * @param expiredTime URL 过期时间 + * @return 预签名下载 URL + */ + String presignGetUrl(String key, Duration expiredTime); + + /** + * 生成默认存储桶中文件上传的预签名 URL。 + * + * @param key 对象键 + * @param expiredTime URL 过期时间 + * @param metadata 对象元数据 + * @return 预签名上传 URL + */ + String presignPutUrl(String key, Duration expiredTime, Map metadata); + + + /** + * 校验客户端配置与传入的配置是否一致 + * + * @param config 配置 + * @return 是否一致 + */ + boolean verifyConfig(S3StorageClientConfig config); +} diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/client/S3StorageClientImpl.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/client/S3StorageClientImpl.java new file mode 100644 index 000000000..2f3f68a13 --- /dev/null +++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/client/S3StorageClientImpl.java @@ -0,0 +1,410 @@ +package org.dromara.common.oss.s3.client; + +import org.dromara.common.oss.s3.config.S3StorageClientConfig; +import org.dromara.common.oss.s3.domain.GetObjectResult; +import org.dromara.common.oss.s3.domain.HandleAsyncResult; +import org.dromara.common.oss.s3.domain.PutObjectResult; +import org.dromara.common.oss.s3.exception.S3StorageException; +import org.dromara.common.oss.s3.io.OutputStreamDownloadSubscriber; +import software.amazon.awssdk.core.async.AsyncRequestBody; +import software.amazon.awssdk.core.async.AsyncResponseTransformer; +import software.amazon.awssdk.core.async.ResponsePublisher; +import software.amazon.awssdk.services.s3.S3AsyncClient; +import software.amazon.awssdk.services.s3.model.*; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.transfer.s3.S3TransferManager; +import software.amazon.awssdk.transfer.s3.model.CompletedUpload; +import software.amazon.awssdk.transfer.s3.model.DownloadRequest; +import software.amazon.awssdk.transfer.s3.progress.TransferListener; + +import java.io.*; +import java.nio.channels.Channels; +import java.nio.channels.FileChannel; +import java.nio.channels.ReadableByteChannel; +import java.nio.channels.WritableByteChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.BiFunction; +import java.util.function.Consumer; + +/** + * S3 存储客户端实现类。 + * + * @author 秋辞未寒 + */ +public class S3StorageClientImpl implements S3StorageClient { + + /** + * S3 存储客户端配置。 + */ + private final S3StorageClientConfig config; + + /** + * Amazon S3 异步客户端。 + */ + private final S3AsyncClient s3AsyncClient; + + /** + * 用于管理 S3 数据传输的高级工具。 + */ + private final S3TransferManager s3TransferManager; + + /** + * AWS S3 预签名 URL 生成器。 + */ + private final S3Presigner s3Presigner; + + /** + * 异步调度线程池。 + */ + private final ExecutorService executorService; + + public S3StorageClientImpl(S3StorageClientConfig config, S3AsyncClient s3AsyncClient, S3TransferManager s3TransferManager, S3Presigner s3Presigner) { + this(config,s3AsyncClient,s3TransferManager,s3Presigner, Executors.newSingleThreadExecutor()); + } + + public S3StorageClientImpl(S3StorageClientConfig config, S3AsyncClient s3AsyncClient, S3TransferManager s3TransferManager, S3Presigner s3Presigner, ExecutorService executorService) { + this.config = config; + this.s3AsyncClient = s3AsyncClient; + this.s3TransferManager = s3TransferManager; + this.s3Presigner = s3Presigner; + this.executorService = executorService; + } + + @Override + public T doCustomUpload(AsyncRequestBody body, Consumer putObjectRequestBuilderConsumer, Collection transferListeners, BiFunction handleAsyncAction) { + try { + return s3TransferManager.upload(uploadRequestBuilder -> { + uploadRequestBuilder.requestBody(body) + .putObjectRequest(putObjectRequestBuilderConsumer) + .transferListeners(transferListeners); + }) + .completionFuture() + .handleAsync(handleAsyncAction) + .join(); + } catch (Exception e) { + if (e instanceof S3StorageException ex) { + throw ex; + } + throw S3StorageException.of(e); + } + } + + @Override + public T doCustomUpload(AsyncRequestBody body, Consumer putObjectRequestBuilderConsumer, BiFunction handleAsyncAction) { + return doCustomUpload(body, putObjectRequestBuilderConsumer, null, handleAsyncAction); + } + + @Override + public HandleAsyncResult doCustomUpload(AsyncRequestBody body, Consumer putObjectRequestBuilderConsumer, Collection transferListeners) { + return doCustomUpload(body, putObjectRequestBuilderConsumer, transferListeners, (completedUpload, throwable) -> HandleAsyncResult.of(completedUpload.response(), throwable)); + } + + @Override + public HandleAsyncResult doCustomUpload(AsyncRequestBody body, Consumer putObjectRequestBuilderConsumer) { + return doCustomUpload(body, putObjectRequestBuilderConsumer, null, (completedUpload, throwable) -> HandleAsyncResult.of(completedUpload.response(), throwable)); + } + + @Override + public PutObjectResult bucketUpload(String bucket, String key, Path path) { + AsyncRequestBody body = AsyncRequestBody.fromFile(path); + return bucketUpload(bucket, key, body); + } + + @Override + public PutObjectResult bucketUpload(String bucket, String key, File file) { + AsyncRequestBody body = AsyncRequestBody.fromFile(file); + return bucketUpload(bucket, key, body); + } + + @Override + public PutObjectResult bucketUpload(String bucket, String key, RandomAccessFile file) { + try { + return bucketUpload(bucket, key, file.getChannel(), -1L); + } catch (Exception e) { + if (e instanceof S3StorageException ex) { + throw ex; + } + throw S3StorageException.of(e); + } + } + + @Override + public PutObjectResult bucketUpload(String bucket, String key, ReadableByteChannel channel, long contentLength) { + long size = contentLength; + try (channel; InputStream in = Channels.newInputStream(channel)) { + if (channel instanceof FileChannel fileChannel) { + size = fileChannel.size(); + } + return bucketUpload(bucket, key, in, size); + } catch (Exception e) { + if (e instanceof S3StorageException ex) { + throw ex; + } + throw S3StorageException.of(e); + } + } + + @Override + public PutObjectResult bucketUpload(String bucket, String key, InputStream in, long contentLength) { + AsyncRequestBody body = AsyncRequestBody.fromInputStream(builder -> builder.inputStream(in) + .contentLength(contentLength) + .executor(executorService)); + return bucketUpload(bucket, key, body); + } + + @Override + public PutObjectResult bucketUpload(String bucket, String key, byte[] data) { + try (ByteArrayInputStream in = new ByteArrayInputStream(data)) { + return bucketUpload(bucket, key, in, data.length); + } catch (Exception e) { + if (e instanceof S3StorageException ex) { + throw ex; + } + throw S3StorageException.of(e); + } + } + + + private PutObjectResult bucketUpload(String bucket, String key, AsyncRequestBody body) { + HandleAsyncResult result = doCustomUpload(body, builder -> builder.bucket(bucket).key(key)); + if (result.isFailure()) { + throw S3StorageException.of(result.error()); + } + Optional opt = result.getResult(); + if (opt.isEmpty()) { + throw S3StorageException.of("response is empty."); + } + PutObjectResponse response = opt.get(); + return PutObjectResult.of(null, key, response.eTag(), response.size()); + } + + @Override + public T doCustomDownload(Consumer getObjectRequestBuilderConsumer, AsyncResponseTransformer responseTransformer, Collection transferListeners) { + try { + DownloadRequest downloadRequest = DownloadRequest.builder() + .responseTransformer(responseTransformer) + .getObjectRequest(getObjectRequestBuilderConsumer) + .transferListeners(transferListeners) + .build(); + return s3TransferManager.download(downloadRequest) + .completionFuture() + .join() + .result(); + } catch (Exception e) { + if (e instanceof S3StorageException ex) { + throw ex; + } + throw S3StorageException.of(e); + } + } + + @Override + public GetObjectResult bucketDownload(String bucket, String key, OutputStreamDownloadSubscriber downloadSubscriber) { + try { + ResponsePublisher publisher = doCustomDownload(builder -> builder.bucket(bucket).key(key), AsyncResponseTransformer.toPublisher(), null); + GetObjectResult getObjectResult = buildGetObjectResult(key, publisher.response()); + publisher.subscribe(downloadSubscriber); + return getObjectResult; + } catch (Exception e) { + if (e instanceof S3StorageException ex) { + throw ex; + } + throw S3StorageException.of(e); + } + } + + @Override + public GetObjectResult bucketDownload(String bucket, String key, Path path) { + try (OutputStream out = Files.newOutputStream(path)) { + return bucketDownload(bucket, key, out); + } catch (Exception e) { + if (e instanceof S3StorageException ex) { + throw ex; + } + throw S3StorageException.of(e); + } + } + + @Override + public GetObjectResult bucketDownload(String bucket, String key, File file) { + try (FileOutputStream out = new FileOutputStream(file)) { + return bucketDownload(bucket, key, out); + } catch (Exception e) { + if (e instanceof S3StorageException ex) { + throw ex; + } + throw S3StorageException.of(e); + } + } + + @Override + public GetObjectResult bucketDownload(String bucket, String key, RandomAccessFile file) { + return bucketDownload(bucket, key, file.getChannel()); + } + + @Override + public GetObjectResult bucketDownload(String bucket, String key, WritableByteChannel channel) { + return bucketDownload(bucket, key, OutputStreamDownloadSubscriber.create(channel)); + } + + @Override + public GetObjectResult bucketDownload(String bucket, String key, OutputStream out) { + return bucketDownload(bucket, key, OutputStreamDownloadSubscriber.create(out)); + } + + private GetObjectResult buildGetObjectResult(String key, GetObjectResponse response) { + return GetObjectResult.of( + key, + response.eTag(), + LocalDateTime.from(response.lastModified()), + response.contentLength(), + response.contentType(), + response.contentDisposition(), + response.contentRange(), + response.contentEncoding(), + response.contentLanguage(), + response.metadata() + ); + } + + @Override + public boolean bucketDelete(String bucket, String key) { + try { + DeleteObjectResponse response = s3AsyncClient.deleteObject(builder -> builder.bucket(bucket).key(key)).join(); + return Boolean.TRUE.equals(response.deleteMarker()); + } catch (Exception e) { + throw S3StorageException.of(e); + } + } + + @Override + public String bucketPresignGetUrl(String bucket, String key, Duration expiredTime) { + try { + return s3Presigner.presignGetObject(getObjectPresignRequestBuilder -> { + getObjectPresignRequestBuilder.signatureDuration(expiredTime) + .getObjectRequest(getObjectRequestBuilder -> getObjectRequestBuilder.bucket(bucket).key(key)); + }) + .url() + .toExternalForm(); + } catch (Exception e) { + throw S3StorageException.of(e); + } + } + + @Override + public String bucketPresignPutUrl(String bucket, String key, Duration expiredTime, Map metadata) { + try { + return s3Presigner.presignPutObject(putObjectPresignRequestBuilder -> { + putObjectPresignRequestBuilder.signatureDuration(expiredTime) + .putObjectRequest(putObjectRequestBuilder -> putObjectRequestBuilder.bucket(bucket).key(key).metadata(metadata)); + }) + .url() + .toExternalForm(); + } catch (Exception e) { + throw S3StorageException.of(e); + } + } + + @Override + public PutObjectResult upload(String key, Path path) { + return bucketUpload(defaultBucket(), key, path); + } + + @Override + public PutObjectResult upload(String key, File file) { + return bucketUpload(defaultBucket(), key, file); + } + + @Override + public PutObjectResult upload(String key, RandomAccessFile file) { + return bucketUpload(defaultBucket(), key, file); + } + + @Override + public PutObjectResult upload(String key, ReadableByteChannel channel, long contentLength) { + return bucketUpload(defaultBucket(), key, channel, contentLength); + } + + @Override + public PutObjectResult upload(String key, InputStream in, long contentLength) { + return bucketUpload(defaultBucket(), key, in, contentLength); + } + + @Override + public PutObjectResult upload(String key, byte[] data) { + return bucketUpload(defaultBucket(), key, data); + } + + @Override + public GetObjectResult download(String key, OutputStreamDownloadSubscriber downloadSubscriber) { + return bucketDownload(defaultBucket(), key, downloadSubscriber); + } + + @Override + public GetObjectResult download(String key, Path path) { + return bucketDownload(defaultBucket(), key, path); + } + + @Override + public GetObjectResult download(String key, File file) { + return bucketDownload(defaultBucket(), key, file); + } + + @Override + public GetObjectResult download(String key, RandomAccessFile file) { + return bucketDownload(defaultBucket(), key, file); + } + + @Override + public GetObjectResult download(String key, WritableByteChannel channel) { + return bucketDownload(defaultBucket(), key, channel); + } + + @Override + public GetObjectResult download(String key, OutputStream out) { + return bucketDownload(defaultBucket(), key, out); + } + + @Override + public boolean delete(String key) { + return bucketDelete(defaultBucket(), key); + } + + @Override + public String presignGetUrl(String key, Duration expiredTime) { + return bucketPresignGetUrl(defaultBucket(), key, expiredTime); + } + + @Override + public String presignPutUrl(String key, Duration expiredTime, Map metadata) { + return bucketPresignPutUrl(defaultBucket(), key, expiredTime, metadata); + } + + private String defaultBucket() { + return config.bucket() + .filter(bucket -> !bucket.isBlank()) + .orElseThrow(() -> S3StorageException.of("bucket is not configured.")); + } + + @Override + public boolean verifyConfig(S3StorageClientConfig config) { + return Objects.equals(this.config,config); + } + + @Override + public void close() throws Exception { + s3TransferManager.close(); + s3AsyncClient.close(); + s3Presigner.close(); + executorService.close(); + } +} diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/config/S3AclConfig.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/config/S3AclConfig.java new file mode 100644 index 000000000..ed9d6bdfb --- /dev/null +++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/config/S3AclConfig.java @@ -0,0 +1,58 @@ +package org.dromara.common.oss.s3.config; + +import org.dromara.common.oss.s3.enums.AccessPolicy; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.RequiredArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Optional; + +/** + * ACL访问策略配置 + * + * @author 秋辞未寒 + */ +@RequiredArgsConstructor +@Builder +@EqualsAndHashCode +public class S3AclConfig implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 是否启用ACL + */ + private final boolean enabled; + + /** + * 访问策略 + */ + private final AccessPolicy accessPolicy; + + public boolean enabled() { + return enabled; + } + + public Optional accessPolicy() { + return Optional.ofNullable(accessPolicy); + } + + /** + * 复制ACL访问策略配置对象 + */ + public static S3AclConfig copy(S3AclConfig config) { + return toBuilder(config).build(); + } + + /** + * 转为ACL访问策略配置构建器对象 + */ + public static S3AclConfigBuilder toBuilder(S3AclConfig config) { + return builder() + .enabled(config.enabled()) + .accessPolicy(config.accessPolicy().orElse(null)); + } +} diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/config/S3StorageClientConfig.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/config/S3StorageClientConfig.java new file mode 100644 index 000000000..2c15c33a8 --- /dev/null +++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/config/S3StorageClientConfig.java @@ -0,0 +1,213 @@ +package org.dromara.common.oss.s3.config; + +import org.dromara.common.oss.s3.builder.CloudServiceBucketUrlBuilder; +import org.dromara.common.oss.s3.builder.MinioBucketUrlBuilder; +import org.dromara.common.oss.s3.exception.S3StorageException; +import org.dromara.common.oss.s3.util.BucketUrlUtil; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.RequiredArgsConstructor; +import software.amazon.awssdk.regions.Region; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Optional; + +/** + * S3存储客户端配置 + * + * @author 秋辞未寒 + */ +@RequiredArgsConstructor +@Builder +@EqualsAndHashCode +public class S3StorageClientConfig implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 访问端点 + */ + private final String endpoint; + + /** + * 自定义域名 + */ + private final String domain; + + /** + * 是否使用HTTPS协议 + */ + private final boolean useHttps; + + /** + * 是否使用路径样式访问(使用域名需要启用路径样式访问) + */ + private final boolean usePathStyleAccess; + + /** + * ACCESS_KEY + */ + private final String accessKey; + + /** + * SECRET_KEY + */ + private final String secretKey; + + /** + * 存储桶 + */ + private final String bucket; + + /** + * 存储区域 + */ + private final Region region; + + /** + * 前缀 + */ + private final String prefix; + + /** + * ACL访问策略配置 + */ + private final S3AclConfig aclConfig; + + /** + * 访问端点 + */ + public Optional endpoint() { + return Optional.ofNullable(endpoint); + } + + /** + * 自定义域名 + */ + public Optional domain() { + return Optional.ofNullable(domain); + } + /** + * 是否使用HTTPS协议 + */ + public boolean useHttps() { + return useHttps; + } + + /** + * 是否使用路径样式访问(使用域名需要启用路径样式访问) + */ + public boolean usePathStyleAccess() { + return usePathStyleAccess; + } + + /** + * ACCESS_KEY + */ + public Optional accessKey() { + return Optional.ofNullable(accessKey); + } + + /** + * SECRET_KEY + */ + public Optional secretKey() { + return Optional.ofNullable(secretKey); + } + + /** + * 存储桶 + */ + public Optional bucket() { + return Optional.ofNullable(bucket); + } + + /** + * 存储区域 + */ + public Optional region() { + return Optional.ofNullable(region); + } + + /** + * 前缀 + */ + public Optional prefix() { + return Optional.ofNullable(prefix); + } + + /** + * ACL访问策略配置 + */ + public Optional aclConfig() { + return Optional.ofNullable(aclConfig); + } + + /** + * 获取访问站点URL地址 + * + * @return 访问站点URL地址 + */ + public String getEndpointUrl(){ + String endpoint = endpoint() + .filter(s -> !s.isBlank()) + .orElseThrow(() -> S3StorageException.of("endpoint is not configured.")); + return BucketUrlUtil.getDomainUrl(useHttps, endpoint); + } + + /** + * 获取域名URL地址 + * + * @return 域名URL地址 + */ + public String getDomainUrl(){ + return domain() + .filter(s -> !s.isBlank()) + // 如果已经配置了自定义域名,则优先使用域名 + .map(s -> BucketUrlUtil.getDomainUrl(useHttps, s)) + // 否则使用站点 + .orElseGet(this::getEndpointUrl); + } + + /** + * 获取桶URL地址 + * + * @param isCloudService 是否是云服务商 + * @return 桶URL地址 + */ + public String getBucketUrl(boolean isCloudService){ + // 如果是云服务商,则优先使用云服务商的 + if (isCloudService) { + return CloudServiceBucketUrlBuilder.INSTANCE.build(this); + } + // 否则默认使用MinIO的 + return MinioBucketUrlBuilder.INSTANCE.build(this); + } + + /** + * 复制S3存储客户端配置对象 + */ + public static S3StorageClientConfig copy(S3StorageClientConfig config) { + return toBuilder(config).build(); + } + + /** + * 转为S3存储客户端配置构建器对象 + */ + public static S3StorageClientConfigBuilder toBuilder(S3StorageClientConfig config) { + S3StorageClientConfigBuilder builder = builder() + .endpoint(config.endpoint().orElse(null)) + .domain(config.domain().orElse(null)) + .useHttps(config.useHttps()) + .usePathStyleAccess(config.usePathStyleAccess()) + .accessKey(config.accessKey().orElse(null)) + .secretKey(config.secretKey().orElse(null)) + .bucket(config.bucket().orElse(null)) + .region(config.region().orElse(null)) + .prefix(config.prefix().orElse(null)); + config.aclConfig().ifPresent(s3AclConfig -> builder.aclConfig(S3AclConfig.copy(s3AclConfig))); + return builder; + } +} diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/domain/GetObjectResult.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/domain/GetObjectResult.java new file mode 100644 index 000000000..e34730484 --- /dev/null +++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/domain/GetObjectResult.java @@ -0,0 +1,40 @@ +package org.dromara.common.oss.s3.domain; + +import java.time.LocalDateTime; +import java.util.Map; + +/** + * Get文件对象结果 + * + * @param key + * @param eTag + * @param lastModified + * @param size + * @param contentType + * @param contentDisposition + * @param contentRange + * @param contentEncoding + * @param contentLanguage + * @param metadata + * @author 秋辞未寒 + */ +public record GetObjectResult( + String key, + String eTag, + LocalDateTime lastModified, + long size, + String contentType, + String contentDisposition, + String contentRange, + String contentEncoding, + String contentLanguage, + Map metadata +) { + + public static GetObjectResult of(String key, String eTag, LocalDateTime lastModified, long size + , String contentType, String contentDisposition, String contentRange, String contentEncoding, String contentLanguage + , Map metadata) { + return new GetObjectResult(key, eTag, lastModified, size, contentType, contentDisposition, contentRange, contentEncoding, contentLanguage, metadata); + } + +} diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/domain/HandleAsyncResult.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/domain/HandleAsyncResult.java new file mode 100644 index 000000000..a03232127 --- /dev/null +++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/domain/HandleAsyncResult.java @@ -0,0 +1,45 @@ +package org.dromara.common.oss.s3.domain; + +import java.util.Optional; + +/** + * 处理异步结果 + * + * @param result 结果 + * @param error 异常错误 + * @param 结果类型 + * @author 秋辞未寒 + */ +public record HandleAsyncResult( + T result, + Throwable error +) { + + public Optional getResult() { + return Optional.ofNullable(result); + } + + public Optional getError() { + return Optional.ofNullable(error); + } + + public boolean isSuccess() { + return getError().isEmpty(); + } + + public boolean isFailure() { + return getError().isPresent(); + } + + public static HandleAsyncResult of(T result, Throwable error) { + return new HandleAsyncResult(result, error); + } + + public static HandleAsyncResult success(T result) { + return new HandleAsyncResult(result, null); + } + + public static HandleAsyncResult failure(Throwable error) { + return new HandleAsyncResult(null, error); + } +} diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/domain/Options.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/domain/Options.java new file mode 100644 index 000000000..16d925342 --- /dev/null +++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/domain/Options.java @@ -0,0 +1,94 @@ +package org.dromara.common.oss.s3.domain; + +import lombok.AccessLevel; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; +import software.amazon.awssdk.transfer.s3.progress.TransferListener; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +/** + * 可选项 + * + * @author 秋辞未寒 + */ +@Data +@EqualsAndHashCode +@Accessors(chain = true) +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class Options { + + /** + * 文件长度 + */ + private Long length; + + /** + * 文件MD5摘要 + */ + private String md5Digest; + + /** + * 内容类型 + */ + private String contentType; + + /** + * 元数据 + */ + private Map metadata; + + /** + * 传输监听器 + */ + private Collection transferListeners; + + /** + * 添加元数据项 + */ + public Options addMetadataItem(String key, String value) { + if (this.metadata == null) { + this.metadata = new HashMap<>(); + } + this.metadata.put(key, value); + return this; + } + + /** + * 添加监听器 + */ + public Options addTransferListener(TransferListener transferListener) { + if (this.transferListeners == null) { + this.transferListeners = new ArrayList<>(); + } + this.transferListeners.add(transferListener); + return this; + } + + /** + * 创建可选项对象 + */ + public static Options builder() { + return new Options(); + } + + /** + * 复制一个新的可选项对象 + * + * @param options 可选项对象 + * @return 新的可选项对象 + */ + public static Options copy(Options options) { + return builder() + .setLength(options.getLength()) + .setMd5Digest(options.getMd5Digest()) + .setContentType(options.getContentType()) + .setMetadata(options.getMetadata()) + .setTransferListeners(options.getTransferListeners()); + } +} diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/domain/PutObjectResult.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/domain/PutObjectResult.java new file mode 100644 index 000000000..fd36b8f06 --- /dev/null +++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/domain/PutObjectResult.java @@ -0,0 +1,23 @@ +package org.dromara.common.oss.s3.domain; + +/** + * Put文件对象结果 + * + * @param url + * @param key + * @param eTag + * @param size + * @author 秋辞未寒 + */ +public record PutObjectResult( + String url, + String key, + String eTag, + long size +) { + + public static PutObjectResult of(String url, String key, String eTag, long size) { + return new PutObjectResult(url, key, eTag, size); + } + +} diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/enums/AccessPolicy.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/enums/AccessPolicy.java new file mode 100644 index 000000000..03686fcd6 --- /dev/null +++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/enums/AccessPolicy.java @@ -0,0 +1,42 @@ +package org.dromara.common.oss.s3.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import software.amazon.awssdk.services.s3.model.BucketCannedACL; +import software.amazon.awssdk.services.s3.model.ObjectCannedACL; + +/** + * 访问策略 + * + * @author 秋辞未寒 + */ +@Getter +@AllArgsConstructor +public enum AccessPolicy { + + /** + * 私有 + */ + PRIVATE(BucketCannedACL.PRIVATE, ObjectCannedACL.PRIVATE), + + /** + * 公有读写 + */ + PUBLIC_READ_WRITE(BucketCannedACL.PUBLIC_READ_WRITE, ObjectCannedACL.PUBLIC_READ_WRITE), + + /** + * 公有只读 + */ + PUBLIC_READ(BucketCannedACL.PUBLIC_READ, ObjectCannedACL.PUBLIC_READ); + + /** + * 桶权限 + */ + private final BucketCannedACL bucketCannedACL; + + /** + * 文件对象权限 + */ + private final ObjectCannedACL objectCannedACL; + +} diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/exception/S3StorageException.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/exception/S3StorageException.java new file mode 100644 index 000000000..305f3ce09 --- /dev/null +++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/exception/S3StorageException.java @@ -0,0 +1,47 @@ +package org.dromara.common.oss.s3.exception; + +import java.io.Serial; + +/** + * S3对象存储异常 + * + * @author 秋辞未寒 + */ +public class S3StorageException extends RuntimeException { + + @Serial + private static final long serialVersionUID = 1L; + + public S3StorageException(String message) { + super(message); + } + + public S3StorageException(String message, Throwable cause) { + super(message, cause); + } + + public S3StorageException(Throwable cause) { + super(cause); + } + + public S3StorageException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { + super(message, cause, enableSuppression, writableStackTrace); + } + + public static S3StorageException of(String message) { + return new S3StorageException(message); + } + + public static S3StorageException of(String message, Throwable cause) { + return new S3StorageException(message, cause); + } + + public static S3StorageException of(Throwable cause) { + return new S3StorageException(cause); + } + + public static S3StorageException of(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { + return new S3StorageException(message, cause, enableSuppression, writableStackTrace); + } + +} diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/factory/S3StorageClientFactory.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/factory/S3StorageClientFactory.java new file mode 100644 index 000000000..07b342003 --- /dev/null +++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/factory/S3StorageClientFactory.java @@ -0,0 +1,76 @@ +package org.dromara.common.oss.s3.factory; + +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.constant.CacheNames; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.json.utils.JsonUtils; +import org.dromara.common.oss.constant.OssConstant; +import org.dromara.common.redis.utils.CacheUtils; +import org.dromara.common.redis.utils.RedisUtils; +import org.dromara.common.oss.s3.builder.DefaultS3StorageClientBuilder; +import org.dromara.common.oss.s3.client.S3StorageClient; +import org.dromara.common.oss.s3.config.S3StorageClientConfig; +import org.dromara.common.oss.s3.exception.S3StorageException; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * S3存储客户端工厂 + * + * @author 秋辞未寒 + */ +@Slf4j +public class S3StorageClientFactory { + + private static final Map CLIENT_CACHE = new ConcurrentHashMap<>(); + + /** + * 获取默认实例 + */ + public static S3StorageClient instance() { + // 获取redis 默认类型 + String configKey = RedisUtils.getCacheObject(OssConstant.DEFAULT_CONFIG_KEY); + if (StringUtils.isEmpty(configKey)) { + throw S3StorageException.of("文件存储服务类型无法找到!"); + } + return instance(configKey); + } + + /** + * 根据类型获取实例 + */ + public static S3StorageClient instance(String configKey) { + // 使用租户标识避免多个租户相同key实例覆盖 + return CLIENT_CACHE.computeIfAbsent(configKey, S3StorageClientFactory::instanceCache); + } + + /** + * 使用缓存实例化 + */ + private static S3StorageClient instanceCache(String configKey) { + String json = CacheUtils.get(CacheNames.SYS_OSS_CONFIG, configKey); + if (json == null) { + throw S3StorageException.of("系统异常, '" + configKey + "'配置信息不存在!"); + } + S3StorageClientConfig config = JsonUtils.parseObject(json, S3StorageClientConfig.class); + return DefaultS3StorageClientBuilder.INSTANCE.build(config); + } + + /** + * 移除实例 + */ + public static boolean remove(String configKey) { + S3StorageClient client = CLIENT_CACHE.remove(configKey); + if (client == null) { + return false; + } + try { + client.close(); + } catch (Exception e) { + log.warn("S3存储客户端关闭异常,错误信息: {}",e.getMessage(),e); + } + return true; + } + +} diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/io/OutputStreamDownloadSubscriber.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/io/OutputStreamDownloadSubscriber.java new file mode 100644 index 000000000..6b3dbdb40 --- /dev/null +++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/io/OutputStreamDownloadSubscriber.java @@ -0,0 +1,74 @@ +package org.dromara.common.oss.s3.io; + +import org.dromara.common.oss.s3.exception.S3StorageException; + +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.nio.channels.Channels; +import java.nio.channels.WritableByteChannel; +import java.util.function.Consumer; + +/** + * 输出流下载订阅器 + * + * @author 秋辞未寒 + */ +public class OutputStreamDownloadSubscriber implements Consumer, AutoCloseable { + + private final WritableByteChannel channel; + + private OutputStreamDownloadSubscriber(WritableByteChannel channel) { + this.channel = channel; + } + + private OutputStreamDownloadSubscriber(OutputStream out) { + // 创建可写入的字节通道 + if (out instanceof FileOutputStream outputStream) { + // 如果是文件输入流,直接获取文件输出流的 Channel + channel = outputStream.getChannel(); + } else { + channel = Channels.newChannel(out); + } + } + + @Override + public void accept(ByteBuffer byteBuffer) { + try (channel) { + while (byteBuffer.hasRemaining()) { + channel.write(byteBuffer); + } + } catch (IOException e) { + throw S3StorageException.of(e); + } + } + + @Override + public void close() throws Exception { + if (channel.isOpen()) { + channel.close(); + } + } + + /** + * 创建一个输出流下载订阅器 + * + * @param out 输出流 + * @return 输出流下载订阅器 + */ + public static OutputStreamDownloadSubscriber create(OutputStream out) { + return new OutputStreamDownloadSubscriber(out); + } + + /** + * 创建一个输出流下载订阅器 + * + * @param channel 可写字节通道 + * @return 输出流下载订阅器 + */ + public static OutputStreamDownloadSubscriber create(WritableByteChannel channel) { + return new OutputStreamDownloadSubscriber(channel); + } + +} diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/util/BucketUrlUtil.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/util/BucketUrlUtil.java new file mode 100644 index 000000000..a63c94d0d --- /dev/null +++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/s3/util/BucketUrlUtil.java @@ -0,0 +1,88 @@ +package org.dromara.common.oss.s3.util; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * 桶链接工具类 + * + * @author 秋辞未寒 + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class BucketUrlUtil { + + public static final String HTTP_PROTOCOL_HEADER = "http://"; + public static final String HTTPS_PROTOCOL_HEADER = "https://"; + + public static final String EMPTY_STRING = ""; + + // 路径风格 例:https://s3examples.com/images + private static final String PATH_STYLE_HTTP_FORMATE = "http://%s/%s"; + private static final String PATH_STYLE_HTTPS_FORMATE = "https://%s/%s"; + + // 站点风格 例:https://images.oss-cn-beijing.aliyuncs.com + private static final String SITE_STYLE_HTTP_FORMATE = "http://%s.%s"; + private static final String SITE_STYLE_HTTPS_FORMATE = "https://%s.%s"; + + /** + * 获取域名地址 例:https://s3examples.com + * + * @param isHttps 是否为HTTP + * @param base 基础地址(可以是IP、站点或者域名) + * @return 域名地址 + */ + public static String getDomainUrl(boolean isHttps, String base) { + String baseUrl = removeHttpProtocolHeader(base); + if (isHttps) { + return HTTPS_PROTOCOL_HEADER + baseUrl; + } + return HTTP_PROTOCOL_HEADER + baseUrl; + } + + /** + * 获取路径风格的桶地址 例:https://s3examples.com/images + * + * @param isHttps 是否为HTTP + * @param base 基础地址(可以是IP、站点或者域名) + * @param bucketName 桶名称 + * @return 路径风格的桶地址 + */ + public static String getPathStyleBucketUrl(boolean isHttps, String base, String bucketName) { + String baseUrl = removeHttpProtocolHeader(base); + if (isHttps) { + return String.format(PATH_STYLE_HTTPS_FORMATE, baseUrl, bucketName); + } + return String.format(PATH_STYLE_HTTP_FORMATE, baseUrl, bucketName); + } + + /** + * 获取站点风格的桶地址 例:https://images.oss-cn-beijing.aliyuncs.com + * + * @param isHttps 是否为HTTP + * @param base 基础地址(可以是IP、站点或者域名) + * @param bucketName 桶名称 + * @return 站点风格的桶地址 + */ + public static String getSiteStyleBucketUrl(boolean isHttps, String base, String bucketName) { + String baseUrl = removeHttpProtocolHeader(base); + if (isHttps) { + return String.format(SITE_STYLE_HTTPS_FORMATE, bucketName, baseUrl); + } + return String.format(SITE_STYLE_HTTP_FORMATE, bucketName, baseUrl); + } + + /** + * 移除HTTP/HTTPS协议头(如果有的话) + * + * @param url 链接地址 + * @return 移除HTTP/HTTPS协议头后的地址 + */ + public static String removeHttpProtocolHeader(String url) { + String s = url.toLowerCase(); + if (s.startsWith(HTTP_PROTOCOL_HEADER) || s.startsWith(HTTPS_PROTOCOL_HEADER)) { + return s.replace(HTTP_PROTOCOL_HEADER, EMPTY_STRING) + .replace(HTTPS_PROTOCOL_HEADER, EMPTY_STRING); + } + return s; + } +}