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; }