diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/client/AbstractOssClientImpl.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/client/AbstractOssClientImpl.java
new file mode 100644
index 000000000..249661b8f
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/client/AbstractOssClientImpl.java
@@ -0,0 +1,533 @@
+package org.dromara.common.oss.client;
+
+import cn.hutool.core.lang.Assert;
+import cn.hutool.core.util.IdUtil;
+import lombok.extern.slf4j.Slf4j;
+import org.dromara.common.core.utils.StringUtils;
+import org.dromara.common.oss.config.OssClientConfig;
+import org.dromara.common.oss.exception.S3StorageException;
+import org.dromara.common.oss.io.OutputStreamDownloadSubscriber;
+import org.dromara.common.oss.model.GetObjectResult;
+import org.dromara.common.oss.model.HandleAsyncResult;
+import org.dromara.common.oss.model.PutObjectResult;
+import software.amazon.awssdk.core.ResponseInputStream;
+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.ZoneOffset;
+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.atomic.AtomicBoolean;
+import java.util.function.BiFunction;
+import java.util.function.Consumer;
+import java.util.function.Function;
+
+/**
+ * 抽象S3存储客户端实现类。
+ *
+ * @author 秋辞未寒
+ */
+@Slf4j
+public abstract class AbstractOssClientImpl implements OssClient {
+
+ private final AtomicBoolean initialized = new AtomicBoolean(false);
+
+ /**
+ * S3 存储客户端ID
+ *
+ * 用于标识客户端,初始化后不允许更改
+ */
+ protected final String clientId;
+
+ /**
+ * S3 存储客户端配置。
+ */
+ protected OssClientConfig config;
+
+ /**
+ * Amazon S3 异步客户端。
+ */
+ protected S3AsyncClient s3AsyncClient;
+
+ /**
+ * 用于管理 S3 数据传输的高级工具。
+ */
+ protected S3TransferManager s3TransferManager;
+
+ /**
+ * AWS S3 预签名 URL 生成器。
+ */
+ protected S3Presigner s3Presigner;
+
+ /**
+ * 异步调度线程池。
+ */
+ protected ExecutorService asyncExecutor;
+
+ public AbstractOssClientImpl(String clientId, OssClientConfig config) {
+ Assert.notNull(config, () -> S3StorageException.form("S3StorageClientConfig must not be null"));
+ // 如果没有设置存储客户端ID,则随机生成一个
+ this.clientId = StringUtils.isBlank(clientId) ? IdUtil.fastSimpleUUID() : clientId;
+ this.config = config;
+ this.initialize();
+ }
+
+ @Override
+ public String clientId() {
+ return this.clientId;
+ }
+
+ @Override
+ public OssClientConfig config() {
+ // 仅返回copy副本,防篡改
+ return this.config.copy();
+ }
+
+ @Override
+ public boolean isInitialized() {
+ return initialized.get();
+ }
+
+ @Override
+ public void initialize() {
+ // 如果已经是初始化状态,则直接返回
+ if (isInitialized()) {
+ return;
+ }
+ try {
+ doInitialize();
+ // 将状态转为已初始化
+ initialized.compareAndSet(false, true);
+ } catch (Exception e) {
+ if (e instanceof S3StorageException) {
+ throw e;
+ }
+ throw S3StorageException.form(e);
+ }
+ }
+
+ abstract void doInitialize();
+
+ @Override
+ public void refresh(OssClientConfig config) {
+ if (Objects.equals(this.config, config)) {
+ return;
+ }
+ // 如果状态本来就是未初始化,直接则调用初始化
+ if (!initialized.get()) {
+ this.initialize();
+ }
+ // 将状态转为未初始化
+ if (initialized.compareAndSet(false, true)) {
+ try {
+ this.close();
+ } catch (Exception e) {
+ // 异常不影响刷新逻辑,此处屏蔽异常
+ }
+ // 状态交换成功才进行刷新
+ this.initialize();
+ }
+ }
+
+ @Override
+ public boolean verifyConfig(Function verifyConfigAction) {
+ OssClientConfig config = config();
+ return Boolean.TRUE.equals(verifyConfigAction.apply(config));
+ }
+
+ @Override
+ public boolean verifyConfig(OssClientConfig verifyConfig) {
+ return verifyConfig((config) -> Objects.equals(config, verifyConfig));
+ }
+
+ @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.form(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) -> {
+ if (completedUpload == null) {
+ return HandleAsyncResult.of(null, throwable);
+ }
+ return HandleAsyncResult.of(completedUpload.response(), throwable);
+ });
+ }
+
+ @Override
+ public HandleAsyncResult doCustomUpload(AsyncRequestBody body, Consumer putObjectRequestBuilderConsumer) {
+ return doCustomUpload(body, putObjectRequestBuilderConsumer, null, (completedUpload, throwable) -> {
+ if (completedUpload == null) {
+ return HandleAsyncResult.of(null, throwable);
+ }
+ return 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.form(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.form(e);
+ }
+ }
+
+ @Override
+ public PutObjectResult bucketUpload(String bucket, String key, InputStream in, long contentLength) {
+ AsyncRequestBody body = AsyncRequestBody.fromInputStream(in, contentLength, asyncExecutor);
+ 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.form(e);
+ }
+ }
+
+
+ private PutObjectResult bucketUpload(String bucket, String key, AsyncRequestBody body) {
+ Long contentLength = body.contentLength().orElse(null);
+ HandleAsyncResult result = doCustomUpload(body, builder -> {
+ builder.bucket(bucket)
+ .key(key)
+ .contentLength(contentLength)
+ ;
+ });
+ if (result.isFailure()) {
+ throw S3StorageException.form(result.error());
+ }
+ Optional opt = result.getResult();
+ if (opt.isEmpty()) {
+ throw S3StorageException.form("response is empty.");
+ }
+ PutObjectResponse response = opt.get();
+ String bucketUrl = config.getBucketUrl(bucket);
+ // 不知道什么原因导致 response.size() 返回了一个 null size ,此处做一个适配...
+ Long size = response.size();
+ size = size == null ? contentLength : size;
+ return PutObjectResult.form("%s/%s".formatted(bucketUrl, key), key, response.eTag(), size == null ? 0 : 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.form(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).join();
+ return getObjectResult;
+ } catch (Exception e) {
+ if (e instanceof S3StorageException ex) {
+ throw ex;
+ }
+ throw S3StorageException.form(e);
+ }
+ }
+
+ @Override
+ public T bucketDownload(String bucket, String key, BiFunction downloadTransformer) {
+ try {
+ ResponseInputStream responseInputStream = doCustomDownload(builder -> builder.bucket(bucket).key(key), AsyncResponseTransformer.toBlockingInputStream(), null);
+ GetObjectResponse response = responseInputStream.response();
+ GetObjectResult getObjectResult = buildGetObjectResult(key, response);
+ return downloadTransformer.apply(getObjectResult, responseInputStream);
+ } catch (Exception e) {
+ if (e instanceof S3StorageException ex) {
+ throw ex;
+ }
+ throw S3StorageException.form(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.form(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.form(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.form(
+ key,
+ response.eTag(),
+ response.lastModified().atOffset(ZoneOffset.UTC).toLocalDateTime(),
+ 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.form(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.form(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.form(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 T download(String key, BiFunction downloadTransformer) {
+ return bucketDownload(defaultBucket(), key, downloadTransformer);
+ }
+
+ @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.form("bucket is not configured."));
+ }
+
+ @Override
+ public void close() throws Exception {
+ if (s3TransferManager != null) {
+ s3TransferManager.close();
+ }
+ if (s3AsyncClient != null) {
+ s3AsyncClient.close();
+ }
+ if (s3Presigner != null) {
+ s3Presigner.close();
+ }
+ if (asyncExecutor != null) {
+ asyncExecutor.close();
+ }
+ // 重置初始化状态为 false
+ initialized.compareAndSet(true, false);
+ }
+}
diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/client/DefaultOssClientImpl.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/client/DefaultOssClientImpl.java
new file mode 100644
index 000000000..837ec68df
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/client/DefaultOssClientImpl.java
@@ -0,0 +1,90 @@
+package org.dromara.common.oss.client;
+
+import org.dromara.common.oss.config.OssAsyncExecutorConfig;
+import org.dromara.common.oss.config.OssClientConfig;
+import org.dromara.common.oss.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;
+import java.util.concurrent.Executors;
+
+/**
+ * 默认S3存储客户端实现类。
+ *
+ * @author 秋辞未寒
+ */
+public class DefaultOssClientImpl extends AbstractOssClientImpl {
+
+ public DefaultOssClientImpl(String clientId, OssClientConfig config) {
+ super(clientId, config);
+ }
+
+ @Override
+ void doInitialize() {
+ // 校验配置
+ String accessKey = config.accessKey()
+ .filter(bucket -> !bucket.isBlank())
+ .orElseThrow(() -> S3StorageException.form("accessKey is not configured."));
+ String secretKey = config.secretKey()
+ .filter(bucket -> !bucket.isBlank())
+ .orElseThrow(() -> S3StorageException.form("secretKey is not configured."));
+ String endpointUrl = config.getEndpointUrl();
+ String domainUrl = config.getDomainUrl();
+ Region region = config.region().orElse(Region.US_EAST_1);
+ // MinIO 使用 HTTPS 限制使用域名访问,站点填域名。需要启用路径样式访问
+ boolean usePathStyleAccess = config.usePathStyleAccess();
+
+ // 创建 AWS 认证信息
+ StaticCredentialsProvider credentialsProvider = StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey));
+
+ // 创建AWS基于 Netty 的 S3 客户端
+ this.s3AsyncClient = 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 传输管理器的底层客户端
+ this.s3TransferManager = S3TransferManager.builder().s3Client(this.s3AsyncClient).build();
+
+ // 创建 预签名 URL 的生成器 实例,用于生成 S3 预签名 URL
+ this.s3Presigner = S3Presigner.builder()
+ .region(region)
+ .credentialsProvider(credentialsProvider)
+ .endpointOverride(URI.create(domainUrl))
+ .serviceConfiguration(
+ // 创建 S3 配置对象
+ S3Configuration.builder()
+ .chunkedEncodingEnabled(false)
+ .pathStyleAccessEnabled(usePathStyleAccess)
+ .build()
+ )
+ .build();
+
+ // 创建异步调度器对象
+ OssAsyncExecutorConfig asyncExecutorConfig = config.asyncExecutorConfig();
+ // 是否使用虚拟线程
+ if (asyncExecutorConfig.enabledVirtualThread()) {
+ this.asyncExecutor = Executors.newVirtualThreadPerTaskExecutor();
+ } else {
+ this.asyncExecutor = Executors.newScheduledThreadPool(asyncExecutorConfig.corePoolSize());
+ }
+ }
+}
diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/client/OssClient.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/client/OssClient.java
new file mode 100644
index 000000000..23b2eee9b
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/client/OssClient.java
@@ -0,0 +1,457 @@
+package org.dromara.common.oss.client;
+
+import cn.hutool.core.util.IdUtil;
+import org.dromara.common.oss.config.OssClientConfig;
+import org.dromara.common.oss.io.OutputStreamDownloadSubscriber;
+import org.dromara.common.oss.model.GetObjectResult;
+import org.dromara.common.oss.model.HandleAsyncResult;
+import org.dromara.common.oss.model.PutObjectResult;
+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;
+import java.util.function.Function;
+
+/**
+ * S3 存储客户端接口。
+ *
+ * 本接口同时提供两套对象操作 API:
+ * 一套通过 {@code bucketXxx(...)} 显式指定存储桶,
+ * 另一套通过无前缀方法使用默认存储桶。
+ *
+ *
+ * @author 秋辞未寒
+ */
+public interface OssClient extends AutoCloseable {
+
+ /**
+ * S3 存储客户端ID
+ *
+ * 用于标识客户端,初始化后不允许更改
+ *
+ * @return S3 存储客户端ID
+ */
+ default String clientId() {
+ return IdUtil.fastSimpleUUID();
+ }
+
+ /**
+ * 获取客户端配置copy副本
+ */
+ OssClientConfig config();
+
+ /**
+ * 是否已经初始化
+ */
+ boolean isInitialized();
+
+ /**
+ * 初始化客户端
+ */
+ void initialize();
+
+ /**
+ * 刷新客户端配置
+ *
+ * @param config 配置项
+ */
+ void refresh(OssClientConfig config);
+
+ /**
+ * 校验客户端配置
+ *
+ *
注意:该方法不会修改任何既有的配置和状态,你看可以理解为这仅仅是一个配置展示的方法,以供调用者根据当前的配置,自行决定是否需要重新构建客户端。
+ *
+ * @param verifyConfigAction 校验配置动作函数
+ * @return 是否一致
+ */
+ boolean verifyConfig(Function verifyConfigAction);
+
+ /**
+ * 校验客户端配置与传入的待校验配置是否一致
+ *
+ * 注意:该方法不会修改任何既有的配置和状态,你看可以理解为这仅仅是一个配置展示的方法,以供调用者根据当前的配置,自行决定是否需要重新构建客户端。
+ *
+ * @param verifyConfig 待校验的配置
+ * @return 是否一致
+ */
+ boolean verifyConfig(OssClientConfig verifyConfig);
+
+ /**
+ * 执行自定义上传请求。
+ *
+ * @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 downloadTransformer 下载转换器
+ * @return 下载结果
+ */
+ T bucketDownload(String bucket, String key, BiFunction downloadTransformer);
+
+ /**
+ * 将指定存储桶中的对象下载到本地路径。
+ *
+ * @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 downloadTransformer 下载转换器
+ * @return 下载结果
+ */
+ T download(String key, BiFunction downloadTransformer);
+
+ /**
+ * 将默认存储桶中的对象下载到本地路径。
+ *
+ * @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);
+}
diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/config/AccessControlPolicyConfig.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/config/AccessControlPolicyConfig.java
new file mode 100644
index 000000000..ab75fc734
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/config/AccessControlPolicyConfig.java
@@ -0,0 +1,53 @@
+package org.dromara.common.oss.config;
+
+import lombok.Builder;
+import org.dromara.common.oss.enums.AccessPolicy;
+import org.jspecify.annotations.NonNull;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.Optional;
+
+/**
+ * S3 ACL访问策略配置
+ *
+ * @param enabled 是否启用ACL
+ * @param accessPolicy 访问策略
+ * @author 秋辞未寒
+ */
+@Builder
+public record AccessControlPolicyConfig(
+ boolean enabled
+ , AccessPolicy accessPolicy
+) implements Config, Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 默认访问策略配置
+ */
+ public static final AccessControlPolicyConfig DEFAULT = AccessControlPolicyConfig.builder()
+ .enabled(false)
+ .accessPolicy(AccessPolicy.PUBLIC_READ_WRITE)
+ .build();
+
+ @Override
+ public @NonNull AccessPolicy accessPolicy() {
+ return Optional.ofNullable(accessPolicy)
+ .orElse(AccessPolicy.PUBLIC_READ_WRITE);
+ }
+
+ @Override
+ public AccessControlPolicyConfig copy() {
+ return toBuilder().build();
+ }
+
+ @Override
+ public AccessControlPolicyConfigBuilder toBuilder() {
+ return builder()
+ .enabled(enabled)
+ .accessPolicy(accessPolicy);
+ }
+
+}
diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/config/Config.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/config/Config.java
new file mode 100644
index 000000000..6ef1d06f4
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/config/Config.java
@@ -0,0 +1,25 @@
+package org.dromara.common.oss.config;
+
+/**
+ * 配置对象接口
+ *
+ * @param 配置类型
+ * @param 配置构建器类型
+ *
+ * @author 秋辞未寒
+ */
+public interface Config {
+
+ /**
+ * 配置对象拷贝
+ * @return 拷贝后的新配置对象
+ */
+ T copy();
+
+ /**
+ * 转为构建器对象
+ * @return 构建器对象
+ */
+ B toBuilder();
+
+}
diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/config/OssAsyncExecutorConfig.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/config/OssAsyncExecutorConfig.java
new file mode 100644
index 000000000..6fc7eeb55
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/config/OssAsyncExecutorConfig.java
@@ -0,0 +1,66 @@
+package org.dromara.common.oss.config;
+
+import lombok.Builder;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+/**
+ * S3 异步执行器配置
+ *
+ * @param enabledVirtualThread 是否启用虚拟线程
+ * @param corePoolSize 核心线程数
+ *
+ * 默认为当前CPU核心数,该配置项在配置了虚拟线程后会失效
+ * @author 秋辞未寒
+ */
+@Builder
+public record OssAsyncExecutorConfig(
+ boolean enabledVirtualThread
+ , int corePoolSize
+) implements Config, Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 默认核心线程数 = 当前处理器核心数
+ */
+ public static final int DEFAULT_CORE_POOL_SIZE = Runtime.getRuntime().availableProcessors();
+
+ /**
+ * 默认异步执行器配置
+ */
+ public static final OssAsyncExecutorConfig DEFAULT = OssAsyncExecutorConfig.builder()
+ .enabledVirtualThread(false)
+ .corePoolSize(DEFAULT_CORE_POOL_SIZE)
+ .build();
+
+ /**
+ * 是否启用虚拟线程
+ */
+ @Override
+ public boolean enabledVirtualThread() {
+ return enabledVirtualThread;
+ }
+
+ /**
+ * 核心线程数
+ */
+ @Override
+ public int corePoolSize() {
+ return corePoolSize;
+ }
+
+ @Override
+ public OssAsyncExecutorConfig copy() {
+ return toBuilder().build();
+ }
+
+ @Override
+ public OssAsyncExecutorConfigBuilder toBuilder() {
+ return builder()
+ .enabledVirtualThread(enabledVirtualThread)
+ .corePoolSize(corePoolSize);
+ }
+}
diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/config/OssClientConfig.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/config/OssClientConfig.java
new file mode 100644
index 000000000..2c46270f7
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/config/OssClientConfig.java
@@ -0,0 +1,291 @@
+package org.dromara.common.oss.config;
+
+import cn.hutool.http.HttpUtil;
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.RequiredArgsConstructor;
+import org.dromara.common.core.constant.SystemConstants;
+import org.dromara.common.core.utils.StringUtils;
+import org.dromara.common.oss.constant.OssConstant;
+import org.dromara.common.oss.enums.AccessPolicy;
+import org.dromara.common.oss.exception.S3StorageException;
+import org.dromara.common.oss.properties.OssProperties;
+import org.dromara.common.oss.util.BucketUrlUtil;
+import org.jspecify.annotations.NonNull;
+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 OssClientConfig implements Config, 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 AccessControlPolicyConfig accessControlPolicyConfig;
+
+ /**
+ * 异步调度池配置
+ */
+ private final OssAsyncExecutorConfig asyncExecutorConfig;
+
+ /**
+ * 访问端点
+ */
+ 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);
+ }
+
+ public static OssClientConfig formProperties(OssProperties properties) {
+ return formPropertiesBuilder(properties).build();
+ }
+
+ public static OssClientConfigBuilder formPropertiesBuilder(OssProperties properties) {
+ String regionString = properties.getRegion();
+ Region region = Region.US_EAST_1;
+ if (StringUtils.isNotBlank(regionString)) {
+ region = Region.of(regionString);
+ }
+
+ // 是否使用路径风格应当由使用者明确去配置,此处的配置只是为了适配旧的配置项
+ // MinIO 使用 HTTPS 限制使用域名访问,站点填域名。需要启用路径样式访问
+ boolean usePathStyleAccess = !StringUtils.containsAny(properties.getEndpoint(), OssConstant.CLOUD_SERVICE);
+
+ // 绝大多数的云厂商都是不允许操作ACL的,所以此处的默认配置也是禁用ACL的
+ AccessControlPolicyConfig accessControlPolicyConfig = AccessControlPolicyConfig.DEFAULT;
+ // 目前自定义实现的 Client 上传/下载/删除中并没有实际使用到ACL相关配置
+ // 仅有业务中的链接预签名使用到(SysOssServiceImpl#matchingUrl),更多只是作为一个扩展点保留,如有需要ACL的自行实现调用逻辑
+ String accessPolicyString = properties.getAccessPolicy();
+ if (StringUtils.isNotBlank(accessPolicyString)) {
+ accessControlPolicyConfig = AccessControlPolicyConfig.builder()
+ .enabled(true)
+ .accessPolicy(AccessPolicy.formType(accessPolicyString))
+ .build();
+ }
+ return builder()
+ .endpoint(properties.getEndpoint())
+ .domain(properties.getDomainUrl())
+ .accessKey(properties.getAccessKey())
+ .secretKey(properties.getSecretKey())
+ .bucket(properties.getBucketName())
+ .region(region)
+ .useHttps(SystemConstants.YES.equals(properties.getIsHttps()))
+ .usePathStyleAccess(usePathStyleAccess)
+ .accessControlPolicyConfig(accessControlPolicyConfig);
+ }
+
+ /**
+ * 获取访问站点URL地址
+ *
+ * @return 访问站点URL地址
+ */
+ public String getEndpointUrl() {
+ String endpoint = endpoint()
+ .filter(s -> !s.isBlank())
+ .orElseThrow(() -> S3StorageException.form("endpoint is not configured."));
+ return BucketUrlUtil.rebuildUrlHeader(useHttps, endpoint);
+ }
+
+ /**
+ * 获取域名URL地址
+ *
+ * @return 域名URL地址
+ */
+ public String getDomainUrl() {
+ return domain()
+ // 如果已经配置了自定义域名,则优先使用域名
+ // 检查携带协议头
+ .filter(s -> HttpUtil.isHttp(s) || HttpUtil.isHttps(s))
+ // 否则使用站点
+ .orElseGet(this::getEndpointUrl);
+ }
+
+ /**
+ * 获取桶URL地址
+ *
+ * @return 桶URL地址
+ */
+ public String getBucketUrl() {
+ // 如果未配置桶,则抛异常
+ String bucket = bucket()
+ .filter(s -> !s.isBlank())
+ .orElseThrow(() -> S3StorageException.form("bucket is not configured."));
+ return getBucketUrl(bucket);
+ }
+
+ /**
+ * 获取桶URL地址
+ *
+ * @return 桶URL地址
+ */
+ public String getBucketUrl(String bucket) {
+ // 如果已经配置了自定义域名,则优先使用域名
+ String url = domain()
+ // 检查携带协议头
+ .filter(s -> HttpUtil.isHttp(s) || HttpUtil.isHttps(s))
+ // 否则使用站点
+ .orElseGet(() ->
+ endpoint()
+ .filter(s -> !s.isBlank())
+ .orElseThrow(() -> S3StorageException.form("endpoint is not configured."))
+ );
+ // 根据是否使用路径风格配置项决定存储桶的URL风格
+ return usePathStyleAccess ? BucketUrlUtil.getPathStyleBucketUrl(useHttps, url, bucket) : BucketUrlUtil.getSiteStyleBucketUrl(useHttps, url, bucket);
+ }
+
+ /**
+ * ACL访问策略配置
+ */
+ public @NonNull AccessControlPolicyConfig accessControlPolicyConfig() {
+ return Optional.ofNullable(accessControlPolicyConfig)
+ .orElse(AccessControlPolicyConfig.DEFAULT);
+ }
+
+ /**
+ * ACL访问策略配置
+ */
+ public @NonNull OssAsyncExecutorConfig asyncExecutorConfig() {
+ return Optional.ofNullable(asyncExecutorConfig)
+ .orElse(OssAsyncExecutorConfig.DEFAULT);
+ }
+
+ /**
+ * 复制S3存储客户端配置对象
+ */
+ @Override
+ public OssClientConfig copy() {
+ return toBuilder().build();
+ }
+
+ /**
+ * 转为S3存储客户端配置构建器对象
+ */
+ @Override
+ public OssClientConfigBuilder toBuilder() {
+ return builder()
+ .endpoint(endpoint)
+ .domain(domain)
+ .useHttps(useHttps)
+ .usePathStyleAccess(usePathStyleAccess)
+ .accessKey(accessKey)
+ .secretKey(secretKey)
+ .bucket(bucket)
+ .region(region)
+ .prefix(prefix)
+ .accessControlPolicyConfig(accessControlPolicyConfig().copy())
+ .asyncExecutorConfig(asyncExecutorConfig().copy());
+ }
+}
diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/constant/OssConstant.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/constant/OssConstant.java
index 9d8db9335..9bdb23368 100644
--- a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/constant/OssConstant.java
+++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/constant/OssConstant.java
@@ -32,9 +32,4 @@ public interface OssConstant {
*/
String[] CLOUD_SERVICE = new String[] {"aliyun", "qcloud", "qiniu", "obs"};
- /**
- * https 状态
- */
- String IS_HTTPS = "Y";
-
}
diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/core/OssClient.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/core/OssClient.java
deleted file mode 100644
index 51d14ba7e..000000000
--- a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/core/OssClient.java
+++ /dev/null
@@ -1,564 +0,0 @@
-package org.dromara.common.oss.core;
-
-import cn.hutool.core.io.IoUtil;
-import cn.hutool.core.util.IdUtil;
-import lombok.extern.slf4j.Slf4j;
-import org.dromara.common.core.constant.Constants;
-import org.dromara.common.core.utils.DateUtils;
-import org.dromara.common.core.utils.StringUtils;
-import org.dromara.common.core.utils.file.FileUtils;
-import org.dromara.common.oss.constant.OssConstant;
-import org.dromara.common.oss.entity.UploadResult;
-import org.dromara.common.oss.enums.AccessPolicyType;
-import org.dromara.common.oss.exception.OssException;
-import org.dromara.common.oss.properties.OssProperties;
-import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
-import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
-import software.amazon.awssdk.core.async.AsyncResponseTransformer;
-import software.amazon.awssdk.core.async.BlockingInputStreamAsyncRequestBody;
-import software.amazon.awssdk.core.async.ResponsePublisher;
-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.model.GetObjectResponse;
-import software.amazon.awssdk.services.s3.presigner.S3Presigner;
-import software.amazon.awssdk.transfer.s3.S3TransferManager;
-import software.amazon.awssdk.transfer.s3.model.*;
-import software.amazon.awssdk.transfer.s3.progress.LoggingTransferListener;
-
-import java.io.*;
-import java.net.URI;
-import java.net.URL;
-import java.nio.channels.Channels;
-import java.nio.channels.WritableByteChannel;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.time.Duration;
-import java.util.Map;
-import java.util.Optional;
-import java.util.function.Consumer;
-
-/**
- * S3 存储协议 所有兼容S3协议的云厂商均支持
- * 阿里云 腾讯云 七牛云 minio
- *
- * @author AprilWind
- */
-@Slf4j
-public class OssClient {
-
- /**
- * 服务商
- */
- private final String configKey;
-
- /**
- * 配置属性
- */
- private final OssProperties properties;
-
- /**
- * Amazon S3 异步客户端
- */
- private final S3AsyncClient client;
-
- /**
- * 用于管理 S3 数据传输的高级工具
- */
- private final S3TransferManager transferManager;
-
- /**
- * AWS S3 预签名 URL 的生成器
- */
- private final S3Presigner presigner;
-
- /**
- * 构造方法
- *
- * @param configKey 配置键
- * @param ossProperties Oss配置属性
- */
- public OssClient(String configKey, OssProperties ossProperties) {
- this.configKey = configKey;
- this.properties = ossProperties;
- try {
- // 创建 AWS 认证信息
- StaticCredentialsProvider credentialsProvider = StaticCredentialsProvider.create(
- AwsBasicCredentials.create(properties.getAccessKey(), properties.getSecretKey()));
-
- // MinIO 使用 HTTPS 限制使用域名访问,站点填域名。需要启用路径样式访问
- boolean isStyle = !StringUtils.containsAny(properties.getEndpoint(), OssConstant.CLOUD_SERVICE);
-
- // 创建AWS基于 Netty 的 S3 客户端
- this.client = S3AsyncClient.builder()
- .credentialsProvider(credentialsProvider)
- .endpointOverride(URI.create(getEndpoint()))
- .region(of())
- .forcePathStyle(isStyle)
- .httpClient(NettyNioAsyncHttpClient.builder()
- .connectionTimeout(Duration.ofSeconds(60))
- .connectionAcquisitionTimeout(Duration.ofSeconds(30))
- .maxConcurrency(100)
- .maxPendingConnectionAcquires(1000)
- .build())
- .build();
-
- //AWS基于 CRT 的 S3 AsyncClient 实例用作 S3 传输管理器的底层客户端
- this.transferManager = S3TransferManager.builder().s3Client(this.client).build();
-
- // 创建 S3 配置对象
- S3Configuration config = S3Configuration.builder().chunkedEncodingEnabled(false)
- .pathStyleAccessEnabled(isStyle).build();
-
- // 创建 预签名 URL 的生成器 实例,用于生成 S3 预签名 URL
- this.presigner = S3Presigner.builder()
- .region(of())
- .credentialsProvider(credentialsProvider)
- .endpointOverride(URI.create(getDomain()))
- .serviceConfiguration(config)
- .build();
-
- } catch (Exception e) {
- if (e instanceof OssException) {
- throw e;
- }
- throw new OssException("配置错误! 请检查系统配置:[" + e.getMessage() + "]");
- }
- }
-
- /**
- * 上传文件到 Amazon S3,并返回上传结果
- *
- * @param filePath 本地文件路径
- * @param key 在 Amazon S3 中的对象键
- * @param md5Digest 本地文件的 MD5 哈希值(可选)
- * @param contentType 文件内容类型
- * @return UploadResult 包含上传后的文件信息
- * @throws OssException 如果上传失败,抛出自定义异常
- */
- public UploadResult upload(Path filePath, String key, String md5Digest, String contentType) {
- try {
- // 构建上传请求对象
- FileUpload fileUpload = transferManager.uploadFile(
- x -> {
- x.source(filePath).putObjectRequest(
- y -> y.bucket(properties.getBucketName())
- .key(key)
- .contentMD5(StringUtils.isNotEmpty(md5Digest) ? md5Digest : null)
- .contentType(contentType)
- // 用于设置对象的访问控制列表(ACL)。不同云厂商对ACL的支持和实现方式有所不同,
- // 因此根据具体的云服务提供商,你可能需要进行不同的配置(自行开启,阿里云有acl权限配置,腾讯云没有acl权限配置)
- //.acl(getAccessPolicy().getObjectCannedACL())
- .build()
- );
- if (log.isDebugEnabled()) {
- x.addTransferListener(LoggingTransferListener.create());
- }
- }
- );
- // 等待上传完成并获取上传结果
- CompletedFileUpload uploadResult = fileUpload.completionFuture().join();
- String eTag = uploadResult.response().eTag();
-
- // 提取上传结果中的 ETag,并构建一个自定义的 UploadResult 对象
- return UploadResult.builder().url(getUrl() + StringUtils.SLASH + key).filename(key).eTag(eTag).build();
- } catch (Exception e) {
- // 捕获异常并抛出自定义异常
- throw new OssException("上传文件失败,请检查配置信息:[" + e.getMessage() + "]");
- } finally {
- // 无论上传是否成功,最终都会删除临时文件
- FileUtils.del(filePath);
- }
- }
-
- /**
- * 上传 InputStream 到 Amazon S3
- *
- * @param inputStream 要上传的输入流
- * @param key 在 Amazon S3 中的对象键
- * @param length 输入流的长度
- * @param contentType 文件内容类型
- * @return UploadResult 包含上传后的文件信息
- * @throws OssException 如果上传失败,抛出自定义异常
- */
- public UploadResult upload(InputStream inputStream, String key, Long length, String contentType) {
- // 如果输入流不是 ByteArrayInputStream,则将其读取为字节数组再创建 ByteArrayInputStream
- if (!(inputStream instanceof ByteArrayInputStream)) {
- inputStream = new ByteArrayInputStream(IoUtil.readBytes(inputStream));
- }
- try {
- // 创建异步请求体(length如果为空会报错)
- BlockingInputStreamAsyncRequestBody body = BlockingInputStreamAsyncRequestBody.builder()
- .contentLength(length)
- .subscribeTimeout(Duration.ofSeconds(120))
- .build();
-
- // 使用 transferManager 进行上传
- Upload upload = transferManager.upload(
- x -> {
- x.requestBody(body).putObjectRequest(
- y -> y.bucket(properties.getBucketName())
- .key(key)
- .contentType(contentType)
- // 用于设置对象的访问控制列表(ACL)。不同云厂商对ACL的支持和实现方式有所不同,
- // 因此根据具体的云服务提供商,你可能需要进行不同的配置(自行开启,阿里云有acl权限配置,腾讯云没有acl权限配置)
- //.acl(getAccessPolicy().getObjectCannedACL())
- .build()
- );
- if (log.isDebugEnabled()) {
- x.addTransferListener(LoggingTransferListener.create());
- }
- }
- );
-
- // 将输入流写入请求体
- body.writeInputStream(inputStream);
-
- // 等待文件上传操作完成
- CompletedUpload uploadResult = upload.completionFuture().join();
- String eTag = uploadResult.response().eTag();
-
- // 提取上传结果中的 ETag,并构建一个自定义的 UploadResult 对象
- return UploadResult.builder().url(getUrl() + StringUtils.SLASH + key).filename(key).eTag(eTag).build();
- } catch (Exception e) {
- throw new OssException("上传文件失败,请检查配置信息:[" + e.getMessage() + "]");
- }
- }
-
- /**
- * 下载文件从 Amazon S3 到临时目录
- *
- * @param path 文件在 Amazon S3 中的对象键
- * @return 下载后的文件在本地的临时路径
- * @throws OssException 如果下载失败,抛出自定义异常
- */
- public Path fileDownload(String path) {
- // 构建临时文件
- Path tempFilePath = FileUtils.createTempFile().toPath();
- // 使用 S3TransferManager 下载文件
- FileDownload downloadFile = transferManager.downloadFile(
- x -> {
- x.destination(tempFilePath).getObjectRequest(
- y -> y.bucket(properties.getBucketName())
- .key(removeBaseUrl(path))
- .build()
- );
- if (log.isDebugEnabled()) {
- x.addTransferListener(LoggingTransferListener.create());
- }
- }
- );
- // 等待文件下载操作完成
- downloadFile.completionFuture().join();
- return tempFilePath;
- }
-
- /**
- * 下载文件从 Amazon S3 到 输出流
- *
- * @param key 文件在 Amazon S3 中的对象键
- * @param out 输出流
- * @param consumer 自定义处理逻辑
- * @throws OssException 如果下载失败,抛出自定义异常
- */
- public void download(String key, OutputStream out, Consumer consumer) {
- try {
- this.download(key, consumer).writeTo(out);
- } catch (Exception e) {
- throw new OssException("文件下载失败,错误信息:[" + e.getMessage() + "]");
- }
- }
-
- /**
- * 下载文件从 Amazon S3 到 输出流
- *
- * @param key 文件在 Amazon S3 中的对象键
- * @param contentLengthConsumer 文件大小消费者函数
- * @return 写出订阅器
- * @throws OssException 如果下载失败,抛出自定义异常
- */
- public WriteOutSubscriber download(String key, Consumer contentLengthConsumer) {
- try {
- DownloadRequest.TypedBuilder> typedBuilder = DownloadRequest.builder()
- // 使用发布订阅转换器
- .responseTransformer(AsyncResponseTransformer.toPublisher())
- // 文件对象
- .getObjectRequest(y -> y.bucket(properties.getBucketName()).key(key).build());
- if (log.isDebugEnabled()) {
- typedBuilder.addTransferListener(LoggingTransferListener.create());
- }
-
- // 使用 S3TransferManager 下载文件
- Download> publisherDownload = transferManager.download(typedBuilder.build());
- // 获取下载发布订阅转换器
- ResponsePublisher publisher = publisherDownload.completionFuture().join().result();
- // 执行文件大小消费者函数
- Optional.ofNullable(contentLengthConsumer)
- .ifPresent(lengthConsumer -> lengthConsumer.accept(publisher.response().contentLength()));
-
- // 构建写出订阅器对象
- return out -> {
- // 创建可写入的字节通道
- try (WritableByteChannel channel = Channels.newChannel(out)) {
- // 订阅数据
- publisher.subscribe(byteBuffer -> {
- while (byteBuffer.hasRemaining()) {
- try {
- channel.write(byteBuffer);
- } catch (IOException e) {
- throw new RuntimeException(e);
- }
- }
- }).join();
- }
- };
- } catch (Exception e) {
- throw new OssException("文件下载失败,错误信息:[" + e.getMessage() + "]");
- }
- }
-
- /**
- * 删除云存储服务中指定路径下文件
- *
- * @param path 指定路径
- */
- public void delete(String path) {
- try {
- client.deleteObject(
- x -> x.bucket(properties.getBucketName())
- .key(removeBaseUrl(path))
- .build());
- } catch (Exception e) {
- throw new OssException("删除文件失败,请检查配置信息:[" + e.getMessage() + "]");
- }
- }
-
- /**
- * 创建下载请求的预签名URL
- *
- * @param objectKey 对象KEY
- * @param expiredTime 链接授权到期时间
- */
- public String createPresignedGetUrl(String objectKey, Duration expiredTime) {
- // 使用 AWS S3 预签名 URL 的生成器 获取下载对象的预签名 URL
- URL url = presigner.presignGetObject(
- x -> x.signatureDuration(expiredTime)
- .getObjectRequest(
- y -> y.bucket(properties.getBucketName())
- .key(objectKey)
- .build())
- .build())
- .url();
- return url.toExternalForm();
- }
-
- /**
- * 创建上传请求的预签名URL
- *
- * @param objectKey 对象KEY
- * @param expiredTime 链接授权到期时间
- * @param metadata 元数据
- */
- public String createPresignedPutUrl(String objectKey, Duration expiredTime, Map metadata) {
- // 使用 AWS S3 预签名 URL 的生成器 获取上传文件对象的预签名 URL
- URL url = presigner.presignPutObject(
- x -> x.signatureDuration(expiredTime)
- .putObjectRequest(
- y -> y.bucket(properties.getBucketName())
- .key(objectKey)
- .metadata(metadata)
- .build())
- .build())
- .url();
- return url.toExternalForm();
- }
-
- /**
- * 上传 byte[] 数据到 Amazon S3,使用指定的后缀构造对象键。
- *
- * @param data 要上传的 byte[] 数据
- * @param suffix 对象键的后缀
- * @return UploadResult 包含上传后的文件信息
- * @throws OssException 如果上传失败,抛出自定义异常
- */
- public UploadResult uploadSuffix(byte[] data, String suffix, String contentType) {
- return upload(new ByteArrayInputStream(data), getPath(properties.getPrefix(), suffix), Long.valueOf(data.length), contentType);
- }
-
- /**
- * 上传 InputStream 到 Amazon S3,使用指定的后缀构造对象键。
- *
- * @param inputStream 要上传的输入流
- * @param suffix 对象键的后缀
- * @param length 输入流的长度
- * @return UploadResult 包含上传后的文件信息
- * @throws OssException 如果上传失败,抛出自定义异常
- */
- public UploadResult uploadSuffix(InputStream inputStream, String suffix, Long length, String contentType) {
- return upload(inputStream, getPath(properties.getPrefix(), suffix), length, contentType);
- }
-
- /**
- * 上传文件到 Amazon S3,使用指定的后缀构造对象键
- *
- * @param file 要上传的文件
- * @param suffix 对象键的后缀
- * @return UploadResult 包含上传后的文件信息
- * @throws OssException 如果上传失败,抛出自定义异常
- */
- public UploadResult uploadSuffix(File file, String suffix) {
- return upload(file.toPath(), getPath(properties.getPrefix(), suffix), null, FileUtils.getMimeType(suffix));
- }
-
- /**
- * 获取文件输入流
- *
- * @param path 完整文件路径
- * @return 输入流
- */
- public InputStream getObjectContent(String path) throws IOException {
- // 下载文件到临时目录
- Path tempFilePath = fileDownload(path);
- // 创建输入流
- InputStream inputStream = Files.newInputStream(tempFilePath);
- // 删除临时文件
- FileUtils.del(tempFilePath);
- // 返回对象内容的输入流
- return inputStream;
- }
-
- /**
- * 获取 S3 客户端的终端点 URL
- *
- * @return 终端点 URL
- */
- public String getEndpoint() {
- // 根据配置文件中的是否使用 HTTPS,设置协议头部
- String header = getIsHttps();
- // 拼接协议头部和终端点,得到完整的终端点 URL
- return header + properties.getEndpoint();
- }
-
- /**
- * 获取 S3 客户端的终端点 URL(自定义域名)
- *
- * @return 终端点 URL
- */
- public String getDomain() {
- // 从配置中获取域名、终端点、是否使用 HTTPS 等信息
- String domain = properties.getDomainUrl();
- String endpoint = properties.getEndpoint();
- String header = getIsHttps();
-
- // 如果是云服务商,直接返回域名或终端点
- if (StringUtils.containsAny(endpoint, OssConstant.CLOUD_SERVICE)) {
- return StringUtils.isNotEmpty(domain) ? header + domain : header + endpoint;
- }
-
- // 如果是 MinIO,处理域名并返回
- if (StringUtils.isNotEmpty(domain)) {
- return domain.startsWith(Constants.HTTPS) || domain.startsWith(Constants.HTTP) ? domain : header + domain;
- }
-
- // 返回终端点
- return header + endpoint;
- }
-
- /**
- * 根据传入的 region 参数返回相应的 AWS 区域
- * 如果 region 参数非空,使用 Region.of 方法创建并返回对应的 AWS 区域对象
- * 如果 region 参数为空,返回一个默认的 AWS 区域(例如,us-east-1),作为广泛支持的区域
- *
- * @return 对应的 AWS 区域对象,或者默认的广泛支持的区域(us-east-1)
- */
- public Region of() {
- //AWS 区域字符串
- String region = properties.getRegion();
- // 如果 region 参数非空,使用 Region.of 方法创建对应的 AWS 区域对象,否则返回默认区域
- return StringUtils.isNotEmpty(region) ? Region.of(region) : Region.US_EAST_1;
- }
-
- /**
- * 获取云存储服务的URL
- *
- * @return 文件路径
- */
- public String getUrl() {
- String domain = properties.getDomainUrl();
- String endpoint = properties.getEndpoint();
- String header = getIsHttps();
- // 云服务商直接返回
- if (StringUtils.containsAny(endpoint, OssConstant.CLOUD_SERVICE)) {
- return header + (StringUtils.isNotEmpty(domain) ? domain : properties.getBucketName() + "." + endpoint);
- }
- // MinIO 单独处理
- if (StringUtils.isNotEmpty(domain)) {
- // 如果 domain 以 "https://" 或 "http://" 开头
- return (domain.startsWith(Constants.HTTPS) || domain.startsWith(Constants.HTTP)) ?
- domain + StringUtils.SLASH + properties.getBucketName() : header + domain + StringUtils.SLASH + properties.getBucketName();
- }
- return header + endpoint + StringUtils.SLASH + properties.getBucketName();
- }
-
- /**
- * 生成一个符合特定规则的、唯一的文件路径。通过使用日期、UUID、前缀和后缀等元素的组合,确保了文件路径的独一无二性
- *
- * @param prefix 前缀
- * @param suffix 后缀
- * @return 文件路径
- */
- public String getPath(String prefix, String suffix) {
- // 生成uuid
- String uuid = IdUtil.fastSimpleUUID();
- // 生成日期路径
- String datePath = DateUtils.datePath();
- // 拼接路径
- String path = StringUtils.isNotEmpty(prefix) ?
- prefix + StringUtils.SLASH + datePath + StringUtils.SLASH + uuid : datePath + StringUtils.SLASH + uuid;
- return path + suffix;
- }
-
- /**
- * 移除路径中的基础URL部分,得到相对路径
- *
- * @param path 完整的路径,包括基础URL和相对路径
- * @return 去除基础URL后的相对路径
- */
- public String removeBaseUrl(String path) {
- return path.replace(getUrl() + StringUtils.SLASH, "");
- }
-
- /**
- * 服务商
- */
- public String getConfigKey() {
- return configKey;
- }
-
- /**
- * 获取是否使用 HTTPS 的配置,并返回相应的协议头部。
- *
- * @return 协议头部,根据是否使用 HTTPS 返回 "https://" 或 "http://"
- */
- public String getIsHttps() {
- return OssConstant.IS_HTTPS.equals(properties.getIsHttps()) ? Constants.HTTPS : Constants.HTTP;
- }
-
- /**
- * 检查配置是否相同
- */
- public boolean checkPropertiesSame(OssProperties properties) {
- return this.properties.equals(properties);
- }
-
- /**
- * 获取当前桶权限类型
- *
- * @return 当前桶权限类型code
- */
- public AccessPolicyType getAccessPolicy() {
- return AccessPolicyType.getByType(properties.getAccessPolicy());
- }
-
-}
diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/core/WriteOutSubscriber.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/core/WriteOutSubscriber.java
deleted file mode 100644
index d3a9841a1..000000000
--- a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/core/WriteOutSubscriber.java
+++ /dev/null
@@ -1,15 +0,0 @@
-package org.dromara.common.oss.core;
-
-import java.io.IOException;
-
-/**
- * 写出订阅器
- *
- * @author 秋辞未寒
- */
-@FunctionalInterface
-public interface WriteOutSubscriber {
-
- void writeTo(T out) throws IOException;
-
-}
diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/entity/UploadResult.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/entity/UploadResult.java
deleted file mode 100644
index 81a18e62a..000000000
--- a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/entity/UploadResult.java
+++ /dev/null
@@ -1,30 +0,0 @@
-package org.dromara.common.oss.entity;
-
-import lombok.Builder;
-import lombok.Data;
-
-/**
- * 上传返回体
- *
- * @author Lion Li
- */
-@Data
-@Builder
-public class UploadResult {
-
- /**
- * 文件路径
- */
- private String url;
-
- /**
- * 文件名
- */
- private String filename;
-
- /**
- * 已上传对象的实体标记(用来校验文件)
- */
- private String eTag;
-
-}
diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/enums/AccessPolicy.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/enums/AccessPolicy.java
new file mode 100644
index 000000000..0d5f4eeef
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/enums/AccessPolicy.java
@@ -0,0 +1,56 @@
+package org.dromara.common.oss.enums;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import org.dromara.common.oss.exception.S3StorageException;
+import software.amazon.awssdk.services.s3.model.BucketCannedACL;
+import software.amazon.awssdk.services.s3.model.ObjectCannedACL;
+
+import java.util.Arrays;
+
+/**
+ * 访问策略
+ *
+ * @author 秋辞未寒
+ */
+@Getter
+@AllArgsConstructor
+public enum AccessPolicy {
+
+ /**
+ * 私有
+ */
+ PRIVATE(0,BucketCannedACL.PRIVATE, ObjectCannedACL.PRIVATE),
+
+ /**
+ * 公有读写
+ */
+ PUBLIC_READ_WRITE(1,BucketCannedACL.PUBLIC_READ_WRITE, ObjectCannedACL.PUBLIC_READ_WRITE),
+
+ /**
+ * 公有只读
+ */
+ PUBLIC_READ(2,BucketCannedACL.PUBLIC_READ, ObjectCannedACL.PUBLIC_READ);
+
+ /**
+ * 访问策略类型
+ */
+ private final Integer type;
+
+ /**
+ * 桶权限
+ */
+ private final BucketCannedACL bucketCannedACL;
+
+ /**
+ * 文件对象权限
+ */
+ private final ObjectCannedACL objectCannedACL;
+
+ public static AccessPolicy formType(String type) {
+ return Arrays.stream(values())
+ .filter(policy -> policy.getType().toString().equals(type))
+ .findFirst()
+ .orElseThrow(() -> S3StorageException.form("'type' not found By " + type));
+ }
+}
diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/enums/AccessPolicyType.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/enums/AccessPolicyType.java
deleted file mode 100644
index 45b13beda..000000000
--- a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/enums/AccessPolicyType.java
+++ /dev/null
@@ -1,56 +0,0 @@
-package org.dromara.common.oss.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 AccessPolicyType {
-
- /**
- * private
- */
- PRIVATE("0", BucketCannedACL.PRIVATE, ObjectCannedACL.PRIVATE),
-
- /**
- * public
- */
- PUBLIC("1", BucketCannedACL.PUBLIC_READ_WRITE, ObjectCannedACL.PUBLIC_READ_WRITE),
-
- /**
- * custom
- */
- CUSTOM("2", BucketCannedACL.PUBLIC_READ, ObjectCannedACL.PUBLIC_READ);
-
- /**
- * 桶 权限类型(数据库值)
- */
- private final String type;
-
- /**
- * 桶 权限类型
- */
- private final BucketCannedACL bucketCannedACL;
-
- /**
- * 文件对象 权限类型
- */
- private final ObjectCannedACL objectCannedACL;
-
- public static AccessPolicyType getByType(String type) {
- for (AccessPolicyType value : values()) {
- if (value.getType().equals(type)) {
- return value;
- }
- }
- throw new RuntimeException("'type' not found By " + type);
- }
-
-}
diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/exception/OssException.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/exception/OssException.java
deleted file mode 100644
index 52e9623da..000000000
--- a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/exception/OssException.java
+++ /dev/null
@@ -1,19 +0,0 @@
-package org.dromara.common.oss.exception;
-
-import java.io.Serial;
-
-/**
- * OSS异常类
- *
- * @author Lion Li
- */
-public class OssException extends RuntimeException {
-
- @Serial
- private static final long serialVersionUID = 1L;
-
- public OssException(String msg) {
- super(msg);
- }
-
-}
diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/exception/S3StorageException.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/exception/S3StorageException.java
new file mode 100644
index 000000000..77c3124b2
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/exception/S3StorageException.java
@@ -0,0 +1,47 @@
+package org.dromara.common.oss.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 form(String message) {
+ return new S3StorageException(message);
+ }
+
+ public static S3StorageException form(String message, Throwable cause) {
+ return new S3StorageException(message, cause);
+ }
+
+ public static S3StorageException form(Throwable cause) {
+ return new S3StorageException(cause);
+ }
+
+ public static S3StorageException form(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/factory/OssFactory.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/factory/OssFactory.java
index 7a0d4672b..c2a85a3b0 100644
--- a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/factory/OssFactory.java
+++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/factory/OssFactory.java
@@ -1,24 +1,26 @@
package org.dromara.common.oss.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.client.DefaultOssClientImpl;
+import org.dromara.common.oss.client.OssClient;
+import org.dromara.common.oss.config.OssClientConfig;
import org.dromara.common.oss.constant.OssConstant;
-import org.dromara.common.oss.core.OssClient;
-import org.dromara.common.oss.exception.OssException;
+import org.dromara.common.oss.exception.S3StorageException;
import org.dromara.common.oss.properties.OssProperties;
import org.dromara.common.redis.utils.CacheUtils;
import org.dromara.common.redis.utils.RedisUtils;
-import lombok.extern.slf4j.Slf4j;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantLock;
/**
- * 文件上传Factory
+ * S3存储客户端工厂
*
- * @author Lion Li
+ * @author 秋辞未寒
*/
@Slf4j
public class OssFactory {
@@ -33,7 +35,7 @@ public class OssFactory {
// 获取redis 默认类型
String configKey = RedisUtils.getCacheObject(OssConstant.DEFAULT_CONFIG_KEY);
if (StringUtils.isEmpty(configKey)) {
- throw new OssException("文件存储服务类型无法找到!");
+ throw S3StorageException.form("文件存储服务类型无法找到!");
}
return instance(configKey);
}
@@ -41,29 +43,47 @@ public class OssFactory {
/**
* 根据类型获取实例
*/
- public static synchronized OssClient instance(String configKey) {
+ public static OssClient instance(String configKey) {
String json = CacheUtils.get(CacheNames.SYS_OSS_CONFIG, configKey);
if (json == null) {
- throw new OssException("系统异常, '" + configKey + "'配置信息不存在!");
+ throw S3StorageException.form("系统异常, '" + configKey + "'配置信息不存在!");
}
OssProperties properties = JsonUtils.parseObject(json, OssProperties.class);
- // 使用租户标识避免多个租户相同key实例覆盖
- OssClient client = CLIENT_CACHE.get(configKey);
- // 客户端不存在或配置不相同则重新构建
- if (client == null || !client.checkPropertiesSame(properties)) {
- LOCK.lock();
- try {
- client = CLIENT_CACHE.get(configKey);
- if (client == null || !client.checkPropertiesSame(properties)) {
- CLIENT_CACHE.put(configKey, new OssClient(configKey, properties));
- log.info("创建OSS实例 key => {}", configKey);
- return CLIENT_CACHE.get(configKey);
+ OssClientConfig config = OssClientConfig.formProperties(properties);
+ LOCK.lock();
+ try {
+ // 如果已经存在,则校验配置一致性
+ if (CLIENT_CACHE.containsKey(configKey)) {
+ OssClient client = CLIENT_CACHE.get(configKey);
+ if (!client.verifyConfig(config)) {
+ // 配置不一致,刷新配置
+ client.refresh(config);
+ CLIENT_CACHE.put(configKey, client);
}
- } finally {
- LOCK.unlock();
+ return client;
}
+ DefaultOssClientImpl client = new DefaultOssClientImpl(configKey, config);
+ CLIENT_CACHE.put(configKey, client);
+ return client;
+ } finally {
+ LOCK.lock();
}
- return client;
+ }
+
+ /**
+ * 移除实例
+ */
+ public static boolean remove(String configKey) {
+ OssClient 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/io/OutputStreamDownloadSubscriber.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/io/OutputStreamDownloadSubscriber.java
new file mode 100644
index 000000000..14f96a517
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/io/OutputStreamDownloadSubscriber.java
@@ -0,0 +1,99 @@
+package org.dromara.common.oss.io;
+
+import org.dromara.common.oss.exception.S3StorageException;
+
+import java.io.FileOutputStream;
+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 final boolean allowAutoClose;
+
+ private OutputStreamDownloadSubscriber(WritableByteChannel channel, boolean allowAutoClose) {
+ this.channel = channel;
+ this.allowAutoClose = allowAutoClose;
+ }
+
+ private OutputStreamDownloadSubscriber(OutputStream out, boolean allowAutoClose) {
+ this.allowAutoClose = allowAutoClose;
+ // 创建可写入的字节通道
+ if (out instanceof FileOutputStream outputStream) {
+ // 如果是文件输入流,直接获取文件输出流的 Channel
+ channel = outputStream.getChannel();
+ } else {
+ channel = Channels.newChannel(out);
+ }
+ }
+
+ @Override
+ public void accept(ByteBuffer byteBuffer) {
+ try {
+ while (byteBuffer.hasRemaining()) {
+ channel.write(byteBuffer);
+ }
+ } catch (Exception e) {
+ throw S3StorageException.form(e);
+ }
+ }
+
+ @Override
+ public void close() throws Exception {
+ if (channel.isOpen() && allowAutoClose) {
+ channel.close();
+ }
+ }
+
+ /**
+ * 创建一个输出流下载订阅器
+ *
+ * @param out 输出流
+ * @return 输出流下载订阅器
+ */
+ public static OutputStreamDownloadSubscriber create(OutputStream out) {
+ return create(out, false);
+ }
+
+ /**
+ * 创建一个输出流下载订阅器
+ *
+ * @param out 输出流
+ * @param allowAutoClose 是否允许自动关闭流
+ * @return 输出流下载订阅器
+ */
+ public static OutputStreamDownloadSubscriber create(OutputStream out, boolean allowAutoClose) {
+ return new OutputStreamDownloadSubscriber(out, allowAutoClose);
+ }
+
+ /**
+ * 创建一个输出流下载订阅器
+ *
+ * @param channel 可写字节通道
+ * @return 输出流下载订阅器
+ */
+ public static OutputStreamDownloadSubscriber create(WritableByteChannel channel) {
+ return create(channel, false);
+ }
+
+ /**
+ * 创建一个输出流下载订阅器
+ *
+ * @param channel 可写字节通道
+ * @param allowAutoClose 是否允许自动关闭流
+ * @return 输出流下载订阅器
+ */
+ public static OutputStreamDownloadSubscriber create(WritableByteChannel channel, boolean allowAutoClose) {
+ return new OutputStreamDownloadSubscriber(channel, allowAutoClose);
+ }
+
+}
diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/model/GetObjectResult.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/model/GetObjectResult.java
new file mode 100644
index 000000000..a486b1274
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/model/GetObjectResult.java
@@ -0,0 +1,40 @@
+package org.dromara.common.oss.model;
+
+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 form(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/model/HandleAsyncResult.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/model/HandleAsyncResult.java
new file mode 100644
index 000000000..d98f08901
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/model/HandleAsyncResult.java
@@ -0,0 +1,45 @@
+package org.dromara.common.oss.model;
+
+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/model/Options.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/model/Options.java
new file mode 100644
index 000000000..cb30a5a21
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/model/Options.java
@@ -0,0 +1,79 @@
+package org.dromara.common.oss.model;
+
+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();
+ }
+}
diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/model/PutObjectResult.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/model/PutObjectResult.java
new file mode 100644
index 000000000..76e72aaa9
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/model/PutObjectResult.java
@@ -0,0 +1,23 @@
+package org.dromara.common.oss.model;
+
+/**
+ * Put文件对象结果
+ *
+ * @param url
+ * @param key
+ * @param eTag
+ * @param size
+ * @author 秋辞未寒
+ */
+public record PutObjectResult(
+ String url,
+ String key,
+ String eTag,
+ long size
+) {
+
+ public static PutObjectResult form(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/util/BucketUrlUtil.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/util/BucketUrlUtil.java
new file mode 100644
index 000000000..a7e7fd91e
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/util/BucketUrlUtil.java
@@ -0,0 +1,88 @@
+package org.dromara.common.oss.util;
+
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+import org.dromara.common.core.utils.StringUtils;
+
+/**
+ * 桶链接工具类
+ *
+ * @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";
+
+ /**
+ * 重建链接协议头(将IP、域名、站点的协议头改成HTTP或者HTTPS)
+ *
+ * @param isHttps 是否为HTTP
+ * @param base 基础地址(可以是IP、站点或者域名)
+ * @return 域名地址
+ */
+ public static String rebuildUrlHeader(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) {
+ if (StringUtils.startsWithIgnoreCase(url, HTTP_PROTOCOL_HEADER) || StringUtils.startsWithIgnoreCase(url, HTTPS_PROTOCOL_HEADER)) {
+ return url.replace(HTTP_PROTOCOL_HEADER, EMPTY_STRING)
+ .replace(HTTPS_PROTOCOL_HEADER, EMPTY_STRING);
+ }
+ return url;
+ }
+}
diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/util/S3ObjectUtil.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/util/S3ObjectUtil.java
new file mode 100644
index 000000000..9138949fa
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/util/S3ObjectUtil.java
@@ -0,0 +1,46 @@
+package org.dromara.common.oss.util;
+
+import cn.hutool.core.util.IdUtil;
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+import org.dromara.common.core.utils.DateUtils;
+import org.dromara.common.core.utils.StringUtils;
+
+/**
+ * S3文件对象工具类
+ *
+ * @author 秋辞未寒
+ */
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+public class S3ObjectUtil {
+
+ /**
+ * 生成一个 【自定义前缀 + 日期路径 + SimpleUUID.文件后缀】 的对象Key 示例: images/20260321/019d0f89c9b1130a48c90dbca0475a.jpg
+ *
+ * @param prefix 前缀
+ * @param withSuffixFileName 带后缀的文件名
+ * @return 文件路径对象Key
+ */
+ public static String buildPathKey(String prefix, String withSuffixFileName) {
+ // 获取后缀
+ String suffix = StringUtils.substring(withSuffixFileName, withSuffixFileName.lastIndexOf("."), withSuffixFileName.length());
+ // 生成日期路径
+ String datePath = DateUtils.datePath();
+ // 生成uuid
+ String uuid = IdUtil.fastSimpleUUID();
+ // 拼接路径
+ String path = StringUtils.isNotEmpty(prefix) ? prefix + StringUtils.SLASH + datePath + StringUtils.SLASH + uuid : datePath + StringUtils.SLASH + uuid;
+ return path + suffix;
+ }
+
+ /**
+ * 生成一个 【日期路径 + SimpleUUID.文件后缀】 的对象Key 示例: 20260321/019d0f89c9b1130a48c90dbca0475a.jpg
+ *
+ * @param withSuffixFileName 带后缀的文件名
+ * @return 文件路径对象Key
+ */
+ public static String buildPathKey(String withSuffixFileName) {
+ return buildPathKey("", withSuffixFileName);
+ }
+
+}
diff --git a/ruoyi-modules/ruoyi-resource/src/main/java/org/dromara/resource/controller/SysOssConfigController.java b/ruoyi-modules/ruoyi-resource/src/main/java/org/dromara/resource/controller/SysOssConfigController.java
index 5ad3182d6..c62de3993 100644
--- a/ruoyi-modules/ruoyi-resource/src/main/java/org/dromara/resource/controller/SysOssConfigController.java
+++ b/ruoyi-modules/ruoyi-resource/src/main/java/org/dromara/resource/controller/SysOssConfigController.java
@@ -35,7 +35,7 @@ import java.util.Arrays;
@RequestMapping("/oss/config")
public class SysOssConfigController extends BaseController {
- private final ISysOssConfigService iSysOssConfigService;
+ private final ISysOssConfigService ossConfigService;
/**
* 查询对象存储配置列表
@@ -43,7 +43,7 @@ public class SysOssConfigController extends BaseController {
@SaCheckPermission("system:ossConfig:list")
@GetMapping("/list")
public R> list(@Validated(QueryGroup.class) SysOssConfigBo bo, PageQuery pageQuery) {
- return R.ok(iSysOssConfigService.queryPageList(bo, pageQuery));
+ return R.ok(ossConfigService.queryPageList(bo, pageQuery));
}
/**
@@ -54,7 +54,7 @@ public class SysOssConfigController extends BaseController {
@SaCheckPermission("system:ossConfig:list")
@GetMapping("/{ossConfigId}")
public R getInfo(@NotNull(message = "主键不能为空") @PathVariable("ossConfigId") Long ossConfigId) {
- return R.ok(iSysOssConfigService.queryById(ossConfigId));
+ return R.ok(ossConfigService.queryById(ossConfigId));
}
/**
@@ -64,7 +64,7 @@ public class SysOssConfigController extends BaseController {
@Log(title = "对象存储配置", businessType = BusinessType.INSERT)
@PostMapping()
public R add(@Validated(AddGroup.class) @RequestBody SysOssConfigBo bo) {
- return toAjax(iSysOssConfigService.insertByBo(bo));
+ return toAjax(ossConfigService.insertByBo(bo));
}
/**
@@ -74,7 +74,7 @@ public class SysOssConfigController extends BaseController {
@Log(title = "对象存储配置", businessType = BusinessType.UPDATE)
@PutMapping()
public R edit(@Validated(EditGroup.class) @RequestBody SysOssConfigBo bo) {
- return toAjax(iSysOssConfigService.updateByBo(bo));
+ return toAjax(ossConfigService.updateByBo(bo));
}
/**
@@ -86,7 +86,7 @@ public class SysOssConfigController extends BaseController {
@Log(title = "对象存储配置", businessType = BusinessType.DELETE)
@DeleteMapping("/{ossConfigIds}")
public R remove(@NotEmpty(message = "主键不能为空") @PathVariable Long[] ossConfigIds) {
- return toAjax(iSysOssConfigService.deleteWithValidByIds(Arrays.asList(ossConfigIds), true));
+ return toAjax(ossConfigService.deleteWithValidByIds(Arrays.asList(ossConfigIds), true));
}
/**
@@ -97,6 +97,6 @@ public class SysOssConfigController extends BaseController {
@RepeatSubmit()
@PutMapping("/changeStatus")
public R changeStatus(@RequestBody SysOssConfigBo bo) {
- return toAjax(iSysOssConfigService.updateOssConfigStatus(bo));
+ return toAjax(ossConfigService.updateOssConfigStatus(bo));
}
}
diff --git a/ruoyi-modules/ruoyi-resource/src/main/java/org/dromara/resource/controller/SysOssController.java b/ruoyi-modules/ruoyi-resource/src/main/java/org/dromara/resource/controller/SysOssController.java
index 786693f36..a2e283dad 100644
--- a/ruoyi-modules/ruoyi-resource/src/main/java/org/dromara/resource/controller/SysOssController.java
+++ b/ruoyi-modules/ruoyi-resource/src/main/java/org/dromara/resource/controller/SysOssController.java
@@ -17,6 +17,7 @@ import org.dromara.resource.domain.vo.SysOssUploadVo;
import org.dromara.resource.domain.vo.SysOssVo;
import org.dromara.resource.service.ISysOssService;
import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@@ -36,7 +37,7 @@ import java.util.List;
@RequestMapping("/oss")
public class SysOssController extends BaseController {
- private final ISysOssService iSysOssService;
+ private final ISysOssService ossService;
/**
* 查询OSS对象存储列表
@@ -44,7 +45,7 @@ public class SysOssController extends BaseController {
@SaCheckPermission("system:oss:list")
@GetMapping("/list")
public R> list(@Validated(QueryGroup.class) SysOssBo bo, PageQuery pageQuery) {
- return R.ok(iSysOssService.queryPageList(bo, pageQuery));
+ return R.ok(ossService.queryPageList(bo, pageQuery));
}
/**
@@ -55,7 +56,7 @@ public class SysOssController extends BaseController {
@SaCheckPermission("system:oss:query")
@GetMapping("/listByIds/{ossIds}")
public R> listByIds(@NotEmpty(message = "主键不能为空") @PathVariable Long[] ossIds) {
- List list = iSysOssService.listByIds(Arrays.asList(ossIds));
+ List list = ossService.listByIds(Arrays.asList(ossIds));
return R.ok(list);
}
@@ -68,7 +69,7 @@ public class SysOssController extends BaseController {
@Log(title = "OSS对象存储", businessType = BusinessType.INSERT)
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public R upload(@RequestPart("file") MultipartFile file) {
- SysOssVo oss = iSysOssService.upload(file);
+ SysOssVo oss = ossService.upload(file);
SysOssUploadVo uploadVo = new SysOssUploadVo();
uploadVo.setUrl(oss.getUrl());
uploadVo.setFileName(oss.getOriginalName());
@@ -77,14 +78,15 @@ public class SysOssController extends BaseController {
}
/**
- * 下载OSS对象存储
+ * 下载OSS对象
*
* @param ossId OSS对象ID
+ * @throws IOException IO 异常
*/
@SaCheckPermission("system:oss:download")
@GetMapping("/download/{ossId}")
- public void download(@PathVariable Long ossId, HttpServletResponse response) throws IOException {
- iSysOssService.download(ossId, response);
+ public ResponseEntity download(@PathVariable Long ossId) throws IOException {
+ return ossService.download(ossId);
}
/**
@@ -96,7 +98,7 @@ public class SysOssController extends BaseController {
@Log(title = "OSS对象存储", businessType = BusinessType.DELETE)
@DeleteMapping("/{ossIds}")
public R remove(@NotEmpty(message = "主键不能为空") @PathVariable Long[] ossIds) {
- return toAjax(iSysOssService.deleteWithValidByIds(Arrays.asList(ossIds), true));
+ return toAjax(ossService.deleteWithValidByIds(Arrays.asList(ossIds), true));
}
}
diff --git a/ruoyi-modules/ruoyi-resource/src/main/java/org/dromara/resource/service/ISysOssService.java b/ruoyi-modules/ruoyi-resource/src/main/java/org/dromara/resource/service/ISysOssService.java
index 463ac6285..e84fc6672 100644
--- a/ruoyi-modules/ruoyi-resource/src/main/java/org/dromara/resource/service/ISysOssService.java
+++ b/ruoyi-modules/ruoyi-resource/src/main/java/org/dromara/resource/service/ISysOssService.java
@@ -5,6 +5,7 @@ import org.dromara.common.mybatis.core.page.PageQuery;
import org.dromara.common.core.domain.PageResult;
import org.dromara.resource.domain.bo.SysOssBo;
import org.dromara.resource.domain.vo.SysOssVo;
+import org.springframework.http.ResponseEntity;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
@@ -80,9 +81,8 @@ public interface ISysOssService {
* 文件下载方法,支持一次性下载完整文件
*
* @param ossId OSS对象ID
- * @param response HttpServletResponse对象,用于设置响应头和向客户端发送文件内容
*/
- void download(Long ossId, HttpServletResponse response) throws IOException;
+ ResponseEntity download(Long ossId);
/**
* 删除OSS对象存储
diff --git a/ruoyi-modules/ruoyi-resource/src/main/java/org/dromara/resource/service/impl/SysOssServiceImpl.java b/ruoyi-modules/ruoyi-resource/src/main/java/org/dromara/resource/service/impl/SysOssServiceImpl.java
index cced3d618..9ec6320bb 100644
--- a/ruoyi-modules/ruoyi-resource/src/main/java/org/dromara/resource/service/impl/SysOssServiceImpl.java
+++ b/ruoyi-modules/ruoyi-resource/src/main/java/org/dromara/resource/service/impl/SysOssServiceImpl.java
@@ -2,33 +2,37 @@ package org.dromara.resource.service.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.convert.Convert;
+import cn.hutool.core.io.IoUtil;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
-import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.constant.CacheNames;
+import org.dromara.common.core.domain.PageResult;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.MapstructUtils;
import org.dromara.common.core.utils.SpringUtils;
+import org.dromara.common.core.utils.StreamUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.core.utils.file.FileUtils;
import org.dromara.common.json.utils.JsonUtils;
import org.dromara.common.mybatis.core.page.PageQuery;
-import org.dromara.common.core.domain.PageResult;
-import org.dromara.common.oss.core.OssClient;
-import org.dromara.common.oss.entity.UploadResult;
-import org.dromara.common.oss.enums.AccessPolicyType;
+import org.dromara.common.oss.client.OssClient;
+import org.dromara.common.oss.enums.AccessPolicy;
import org.dromara.common.oss.factory.OssFactory;
+import org.dromara.common.oss.model.PutObjectResult;
+import org.dromara.common.oss.util.S3ObjectUtil;
import org.dromara.resource.domain.SysOss;
import org.dromara.resource.domain.SysOssExt;
import org.dromara.resource.domain.bo.SysOssBo;
import org.dromara.resource.domain.vo.SysOssVo;
import org.dromara.resource.mapper.SysOssMapper;
import org.dromara.resource.service.ISysOssService;
+import org.jetbrains.annotations.NotNull;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
@@ -39,7 +43,6 @@ import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
-import java.util.stream.Collectors;
/**
* 文件上传 服务层实现
@@ -63,7 +66,7 @@ public class SysOssServiceImpl implements ISysOssService {
public PageResult queryPageList(SysOssBo bo, PageQuery pageQuery) {
LambdaQueryWrapper lqw = buildQueryWrapper(bo);
Page result = baseMapper.selectVoPage(pageQuery.build(), lqw);
- List filterResult = result.getRecords().stream().map(this::matchingUrl).collect(Collectors.toList());
+ List filterResult = StreamUtils.toList(result.getRecords(), this::matchingUrl);
result.setRecords(filterResult);
return PageResult.build(result.getRecords(), result.getTotal());
}
@@ -116,6 +119,12 @@ public class SysOssServiceImpl implements ISysOssService {
return StringUtils.joinComma(list);
}
+ /**
+ * 构造 OSS 文件列表查询条件。
+ *
+ * @param bo 文件筛选条件
+ * @return 包含文件名、后缀、归属服务和创建时间区间的查询包装器
+ */
private LambdaQueryWrapper buildQueryWrapper(SysOssBo bo) {
Map params = bo.getParams();
LambdaQueryWrapper lqw = Wrappers.lambdaQuery();
@@ -146,19 +155,28 @@ public class SysOssServiceImpl implements ISysOssService {
/**
* 文件下载方法,支持一次性下载完整文件
*
- * @param ossId OSS对象ID
- * @param response HttpServletResponse对象,用于设置响应头和向客户端发送文件内容
+ * @param ossId OSS对象ID
*/
@Override
- public void download(Long ossId, HttpServletResponse response) throws IOException {
+ public ResponseEntity download(Long ossId) {
SysOssVo sysOss = SpringUtils.getAopProxy(this).getById(ossId);
if (ObjectUtil.isNull(sysOss)) {
throw new ServiceException("文件数据不存在!");
}
- FileUtils.setAttachmentResponseHeader(response, sysOss.getOriginalName());
- response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE + "; charset=UTF-8");
- OssClient storage = OssFactory.instance(sysOss.getService());
- storage.download(sysOss.getFileName(), response.getOutputStream(), response::setContentLengthLong);
+ String percentEncodedFileName = FileUtils.percentEncode(sysOss.getOriginalName());
+ String contentDispositionValue = "attachment; filename=%s;filename*=utf-8''%s".formatted(percentEncodedFileName, percentEncodedFileName);
+ return OssFactory.instance(sysOss.getService())
+ .download(sysOss.getFileName(), (result, inputStream) -> {
+ // 构建响应实体
+ return ResponseEntity.ok()
+ .header("Access-Control-Expose-Headers", "Content-Disposition,download-filename")
+ .header("Content-disposition", contentDispositionValue)
+ .header("download-filename", percentEncodedFileName)
+ .contentType(MediaType.APPLICATION_OCTET_STREAM)
+ .contentLength(result.size())
+ .body(IoUtil.readBytes(inputStream));
+ });
+
}
/**
@@ -175,18 +193,18 @@ public class SysOssServiceImpl implements ISysOssService {
}
String originalfileName = file.getOriginalFilename();
String suffix = StringUtils.substring(originalfileName, originalfileName.lastIndexOf("."), originalfileName.length());
- OssClient storage = OssFactory.instance();
- UploadResult uploadResult;
+ OssClient instance = OssFactory.instance();
try {
- uploadResult = storage.uploadSuffix(file.getBytes(), suffix, file.getContentType());
+ String pathKey = S3ObjectUtil.buildPathKey(originalfileName);
+ PutObjectResult result = instance.upload(pathKey, file.getInputStream(), file.getSize());
+ SysOssExt ext1 = new SysOssExt();
+ ext1.setFileSize(file.getSize());
+ ext1.setContentType(file.getContentType());
+ // 保存文件信息
+ return buildResultEntity(originalfileName, suffix, instance.clientId(), result, ext1);
} catch (IOException e) {
throw new ServiceException(e.getMessage());
}
- SysOssExt ext1 = new SysOssExt();
- ext1.setFileSize(file.getSize());
- ext1.setContentType(file.getContentType());
- // 保存文件信息
- return buildResultEntity(originalfileName, suffix, storage.getConfigKey(), uploadResult, ext1);
}
/**
@@ -202,20 +220,31 @@ public class SysOssServiceImpl implements ISysOssService {
}
String originalfileName = file.getName();
String suffix = StringUtils.substring(originalfileName, originalfileName.lastIndexOf("."), originalfileName.length());
- OssClient storage = OssFactory.instance();
- long length = file.length();
- UploadResult uploadResult = storage.uploadSuffix(file, suffix);
+ OssClient instance = OssFactory.instance();
+ String pathKey = S3ObjectUtil.buildPathKey(originalfileName);
+ PutObjectResult result = instance.upload(pathKey, file);
SysOssExt ext1 = new SysOssExt();
- ext1.setFileSize(length);
+ ext1.setFileSize(result.size());
// 保存文件信息
- return buildResultEntity(originalfileName, suffix, storage.getConfigKey(), uploadResult, ext1);
+ return buildResultEntity(originalfileName, suffix, instance.clientId(), result, ext1);
}
- private SysOssVo buildResultEntity(String originalfileName, String suffix, String configKey, UploadResult uploadResult, SysOssExt ext1) {
+ /**
+ * 组装上传结果并持久化文件元数据。
+ *
+ * @param originalfileName 原始文件名
+ * @param suffix 文件后缀
+ * @param configKey 存储配置标识
+ * @param result 上传结果
+ * @param ext1 扩展属性对象
+ * @return 持久化后的文件信息视图
+ */
+ @NotNull
+ private SysOssVo buildResultEntity(String originalfileName, String suffix, String configKey, PutObjectResult result, SysOssExt ext1) {
SysOss oss = new SysOss();
- oss.setUrl(uploadResult.getUrl());
+ oss.setUrl(result.url());
oss.setFileSuffix(suffix);
- oss.setFileName(uploadResult.getFilename());
+ oss.setFileName(result.key());
oss.setOriginalName(originalfileName);
oss.setService(configKey);
oss.setExt1(JsonUtils.toJsonString(ext1));
@@ -254,8 +283,7 @@ public class SysOssServiceImpl implements ISysOssService {
}
List list = baseMapper.selectByIds(ids);
for (SysOss sysOss : list) {
- OssClient storage = OssFactory.instance(sysOss.getService());
- storage.delete(sysOss.getUrl());
+ OssFactory.instance(sysOss.getService()).delete(sysOss.getFileName());
}
return baseMapper.deleteByIds(ids) > 0;
}
@@ -267,10 +295,10 @@ public class SysOssServiceImpl implements ISysOssService {
* @return oss 匹配Url的OSS对象
*/
private SysOssVo matchingUrl(SysOssVo oss) {
- OssClient storage = OssFactory.instance(oss.getService());
+ OssClient instance = OssFactory.instance(oss.getService());
// 仅修改桶类型为 private 的URL,临时URL时长为120s
- if (AccessPolicyType.PRIVATE == storage.getAccessPolicy()) {
- oss.setUrl(storage.createPresignedGetUrl(oss.getFileName(), Duration.ofSeconds(120)));
+ if (instance.verifyConfig(config -> AccessPolicy.PRIVATE.equals(config.accessControlPolicyConfig().accessPolicy()))) {
+ oss.setUrl(instance.presignGetUrl(oss.getFileName(), Duration.ofSeconds(120)));
}
return oss;
}