diff --git a/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/event/OssUploadEvent.java b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/event/OssUploadEvent.java
new file mode 100644
index 000000000..1a95006f9
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/event/OssUploadEvent.java
@@ -0,0 +1,53 @@
+package org.dromara.common.core.domain.event;
+
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+/**
+ * OSS 上传事件
+ *
+ * @author AprilWind
+ */
+@Data
+public class OssUploadEvent implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 对象存储主键
+ */
+ private Long ossId;
+
+ /**
+ * 文件名
+ */
+ private String fileName;
+
+ /**
+ * 原名
+ */
+ private String originalName;
+
+ /**
+ * 文件后缀名
+ */
+ private String fileSuffix;
+
+ /**
+ * URL地址
+ */
+ private String url;
+
+ /**
+ * 扩展字段
+ */
+ private String ext1;
+
+ /**
+ * 服务商
+ */
+ private String service;
+}
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..74b4fcffc 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
@@ -17,11 +17,6 @@ public interface OssConstant {
*/
String DEFAULT_CONFIG_KEY = GlobalConstants.GLOBAL_REDIS_KEY + "sys_oss:default_config";
- /**
- * 预览列表资源开关Key
- */
- String PEREVIEW_LIST_RESOURCE_KEY = "sys.oss.previewListResource";
-
/**
* 系统数据ids
*/
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 b9a90dc75..000000000
--- a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/core/OssClient.java
+++ /dev/null
@@ -1,525 +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.*;
-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.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)).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.putObjectRequest(
- y -> y.bucket(properties.getBucketName())
- .key(key)
- .contentMD5(StringUtils.isNotEmpty(md5Digest) ? md5Digest : null)
- .contentType(contentType)
- // 用于设置对象的访问控制列表(ACL)。不同云厂商对ACL的支持和实现方式有所不同,
- // 因此根据具体的云服务提供商,你可能需要进行不同的配置(自行开启,阿里云有acl权限配置,腾讯云没有acl权限配置)
- //.acl(getAccessPolicy().getObjectCannedACL())
- .build())
- .addTransferListener(LoggingTransferListener.create())
- .source(filePath).build());
-
- // 等待上传完成并获取上传结果
- 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).addTransferListener(LoggingTransferListener.create())
- .putObjectRequest(
- y -> y.bucket(properties.getBucketName())
- .key(key)
- .contentType(contentType)
- // 用于设置对象的访问控制列表(ACL)。不同云厂商对ACL的支持和实现方式有所不同,
- // 因此根据具体的云服务提供商,你可能需要进行不同的配置(自行开启,阿里云有acl权限配置,腾讯云没有acl权限配置)
- //.acl(getAccessPolicy().getObjectCannedACL())
- .build())
- .build());
-
- // 将输入流写入请求体
- 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.getObjectRequest(
- y -> y.bucket(properties.getBucketName())
- .key(removeBaseUrl(path))
- .build())
- .addTransferListener(LoggingTransferListener.create())
- .destination(tempFilePath)
- .build());
- // 等待文件下载操作完成
- 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> publisherDownloadRequest = DownloadRequest.builder()
- // 文件对象
- .getObjectRequest(y -> y.bucket(properties.getBucketName())
- .key(key)
- .build())
- .addTransferListener(LoggingTransferListener.create())
- // 使用发布订阅转换器
- .responseTransformer(AsyncResponseTransformer.toPublisher())
- .build();
-
- // 使用 S3TransferManager 下载文件
- Download> publisherDownload = transferManager.download(publisherDownloadRequest);
- // 获取下载发布订阅转换器
- 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 getPrivateUrl(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.toString();
- }
-
- /**
- * 上传 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.getDomain();
- 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.getDomain();
- 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-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysOssExt.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/entity/SysOssExt.java
similarity index 85%
rename from ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysOssExt.java
rename to ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/entity/SysOssExt.java
index 15a95e760..1e1cdc49d 100644
--- a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain/SysOssExt.java
+++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/entity/SysOssExt.java
@@ -1,4 +1,4 @@
-package org.dromara.system.domain;
+package org.dromara.common.oss.entity;
import lombok.Data;
@@ -23,12 +23,12 @@ public class SysOssExt implements Serializable {
private String bizType;
/**
- * 文件大小(单位:字节)
+ * 文件大小(单位:字节),如果为空会在上传时自动填充
*/
private Long fileSize;
/**
- * 文件类型(MIME类型,如 image/png)
+ * 文件类型(MIME类型,如 image/png),如果为空会在上传时自动填充
*/
private String contentType;
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
index 81a18e62a..e0f32b03f 100644
--- 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
@@ -12,6 +12,11 @@ import lombok.Data;
@Builder
public class UploadResult {
+ /**
+ * 对象存储主键
+ */
+ private Long ossId;
+
/**
* 文件路径
*/
@@ -22,6 +27,16 @@ public class UploadResult {
*/
private String filename;
+ /**
+ * 原名
+ */
+ private String originalName;
+
+ /**
+ * 文件后缀名
+ */
+ private String fileSuffix;
+
/**
* 已上传对象的实体标记(用来校验文件)
*/
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/StorageFactory.java
similarity index 76%
rename from ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/factory/OssFactory.java
rename to ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/factory/StorageFactory.java
index 3da1ba5cd..6d14d515c 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/StorageFactory.java
@@ -5,9 +5,10 @@ import org.dromara.common.core.constant.CacheNames;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.json.utils.JsonUtils;
import org.dromara.common.oss.constant.OssConstant;
-import org.dromara.common.oss.core.OssClient;
import org.dromara.common.oss.exception.OssException;
import org.dromara.common.oss.properties.OssProperties;
+import org.dromara.common.oss.service.StorageService;
+import org.dromara.common.oss.service.impl.OssStorageServiceImpl;
import org.dromara.common.redis.utils.CacheUtils;
import org.dromara.common.redis.utils.RedisUtils;
@@ -21,15 +22,15 @@ import java.util.concurrent.locks.ReentrantLock;
* @author Lion Li
*/
@Slf4j
-public class OssFactory {
+public class StorageFactory {
- private static final Map CLIENT_CACHE = new ConcurrentHashMap<>();
+ private static final Map CLIENT_CACHE = new ConcurrentHashMap<>();
private static final ReentrantLock LOCK = new ReentrantLock();
/**
* 获取默认实例
*/
- public static OssClient instance() {
+ public static StorageService instance() {
// 获取redis 默认类型
String configKey = RedisUtils.getCacheObject(OssConstant.DEFAULT_CONFIG_KEY);
if (StringUtils.isEmpty(configKey)) {
@@ -41,7 +42,7 @@ public class OssFactory {
/**
* 根据类型获取实例
*/
- public static OssClient instance(String configKey) {
+ public static StorageService instance(String configKey) {
String json = CacheUtils.get(CacheNames.SYS_OSS_CONFIG, configKey);
if (json == null) {
throw new OssException("系统异常, '" + configKey + "'配置信息不存在!");
@@ -52,16 +53,17 @@ public class OssFactory {
if (StringUtils.isNotBlank(properties.getTenantId())) {
key = properties.getTenantId() + ":" + configKey;
}
- OssClient client = CLIENT_CACHE.get(key);
- // 客户端不存在或配置不相同则重新构建
+ StorageService client = CLIENT_CACHE.get(key);
+ // 客户端不存在或配置不一致,需要重新创建
if (client == null || !client.checkPropertiesSame(properties)) {
LOCK.lock();
try {
client = CLIENT_CACHE.get(key);
if (client == null || !client.checkPropertiesSame(properties)) {
- CLIENT_CACHE.put(key, new OssClient(configKey, properties));
+ client = new OssStorageServiceImpl(configKey, properties);
+ CLIENT_CACHE.put(key, client);
log.info("创建OSS实例 key => {}", configKey);
- return CLIENT_CACHE.get(key);
+ return client;
}
} finally {
LOCK.unlock();
diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/handler/OssUploadEventHandler.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/handler/OssUploadEventHandler.java
new file mode 100644
index 000000000..2a743a62a
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/handler/OssUploadEventHandler.java
@@ -0,0 +1,38 @@
+package org.dromara.common.oss.handler;
+
+import lombok.extern.slf4j.Slf4j;
+import org.dromara.common.core.domain.event.OssUploadEvent;
+import org.dromara.common.core.utils.SpringUtils;
+import org.dromara.common.json.utils.JsonUtils;
+import org.dromara.common.oss.entity.SysOssExt;
+import org.dromara.common.oss.entity.UploadResult;
+
+/**
+ * OSS 上传事件处理器
+ * 用于在文件上传完成后发布事件,方便业务系统监听
+ *
+ * @author AprilWind
+ */
+@Slf4j
+public class OssUploadEventHandler {
+
+ /**
+ * 上传完成后处理事件
+ *
+ * @param configKey OSS 服务配置标识
+ * @param uploadResult 上传结果
+ * @param ext1 扩展信息
+ */
+ public void uploadHandler(String configKey, UploadResult uploadResult, SysOssExt ext1) {
+ OssUploadEvent uploadEvent = new OssUploadEvent();
+ uploadEvent.setOssId(uploadResult.getOssId());
+ uploadEvent.setUrl(uploadResult.getUrl());
+ uploadEvent.setFileName(uploadResult.getFilename());
+ uploadEvent.setService(configKey);
+ uploadEvent.setFileSuffix(uploadResult.getFileSuffix());
+ uploadEvent.setOriginalName(uploadResult.getOriginalName());
+ uploadEvent.setExt1(JsonUtils.toJsonString(ext1));
+ SpringUtils.context().publishEvent(uploadEvent);
+ }
+
+}
diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/service/StorageService.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/service/StorageService.java
new file mode 100644
index 000000000..c145b0976
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/service/StorageService.java
@@ -0,0 +1,223 @@
+package org.dromara.common.oss.service;
+
+import org.dromara.common.oss.entity.SysOssExt;
+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 org.springframework.web.multipart.MultipartFile;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.function.Consumer;
+
+/**
+ * 通用存储服务接口
+ *
+ * 定义了对象存储系统常见的功能,包括:
+ *
+ * - 文件上传(支持 MultipartFile、File、InputStream、byte[] 等多种形式)
+ * - 文件下载(支持输出流、本地临时路径、输入流等)
+ * - 文件删除
+ * - 获取私有访问 URL、临时上传 URL
+ *
+ *
+ *
+ * @author AprilWind
+ */
+public interface StorageService {
+
+ // ========== 基础信息 ==========
+
+ /**
+ * 获取存储服务的唯一配置键
+ *
+ * 用于标识不同的存储服务实例,例如:
+ * - "local" 表示本地存储
+ * - "s3" 表示 Amazon S3
+ * - "qiniu" 表示七牛云
+ *
+ *
+ * @return 存储服务配置键
+ */
+ String getConfigKey();
+
+ /**
+ * 获取当前桶权限类型
+ *
+ * @return 当前桶权限类型code
+ */
+ AccessPolicyType getAccessPolicy();
+
+ /**
+ * 检查当前存储客户端配置是否与传入配置相同
+ *
+ * 用于判断是否需要重新创建存储客户端实例。
+ *
+ *
+ * @param properties 待比较的 OSS 配置属性
+ * @return 如果配置相同返回 true,否则返回 false
+ */
+ boolean checkPropertiesSame(OssProperties properties);
+
+ // ========== 上传相关 ==========
+
+ /**
+ * 上传 {@link MultipartFile} 到对象存储
+ *
+ * @param ossId 对象存储主键
+ * @param file 要上传的 MultipartFile 对象
+ * @param ext1 扩展信息(可选),用于存储额外的业务字段
+ * @return 上传结果,包含文件 URL、ossId、文件名等信息
+ * @throws IOException 文件读取失败
+ * @throws OssException 上传失败时抛出
+ */
+ UploadResult upload(Long ossId, MultipartFile file, SysOssExt ext1) throws IOException;
+
+ /**
+ * 上传本地 {@link File} 到对象存储
+ *
+ * @param ossId 对象存储主键
+ * @param file 要上传的文件对象
+ * @param ext1 扩展信息
+ * @return 上传结果
+ * @throws OssException 上传失败时抛出
+ */
+ UploadResult upload(Long ossId, File file, SysOssExt ext1);
+
+ /**
+ * 上传 {@link File},指定文件后缀
+ *
+ * @param ossId 对象存储主键
+ * @param file 本地文件
+ * @param fileSuffix 文件后缀(如 ".jpg")
+ * @param ext1 扩展信息
+ * @return 上传结果
+ */
+ UploadResult uploadSuffix(Long ossId, File file, String fileSuffix, SysOssExt ext1);
+
+ /**
+ * 上传字节数组
+ *
+ * @param ossId 对象存储主键
+ * @param originalName 原始文件名
+ * @param data 文件字节数组
+ * @param fileSuffix 文件后缀
+ * @param contentType 内容类型(MIME)
+ * @param ext1 扩展信息
+ * @return 上传结果
+ */
+ UploadResult uploadSuffix(Long ossId, String originalName, byte[] data,
+ String fileSuffix, String contentType, SysOssExt ext1);
+
+ /**
+ * 上传输入流
+ *
+ * @param ossId 对象存储主键
+ * @param originalName 原始文件名
+ * @param inputStream 输入流
+ * @param fileSuffix 文件后缀
+ * @param length 文件长度
+ * @param contentType 内容类型
+ * @param ext1 扩展信息
+ * @return 上传结果
+ */
+ UploadResult uploadSuffix(Long ossId, String originalName, InputStream inputStream,
+ String fileSuffix, Long length, String contentType, SysOssExt ext1);
+
+ /**
+ * 上传输入流(指定对象键)
+ *
+ * @param ossId 对象存储主键
+ * @param originalName 原始文件名
+ * @param fileSuffix 文件后缀
+ * @param inputStream 输入流
+ * @param key 存储对象键
+ * @param length 文件长度
+ * @param contentType 内容类型
+ * @param ext1 扩展信息
+ * @return 上传结果
+ */
+ UploadResult upload(Long ossId, String originalName, String fileSuffix,
+ InputStream inputStream, String key, Long length, String contentType, SysOssExt ext1);
+
+ /**
+ * 上传文件路径(指定对象键)
+ *
+ * @param ossId 对象存储主键
+ * @param originalName 原始文件名
+ * @param fileSuffix 文件后缀
+ * @param filePath 本地文件路径
+ * @param key 存储对象键
+ * @param md5Digest 文件 MD5 校验值(可选)
+ * @param contentType 内容类型
+ * @param ext1 扩展信息
+ * @return 上传结果
+ */
+ UploadResult upload(Long ossId, String originalName, String fileSuffix,
+ Path filePath, String key, String md5Digest, String contentType, SysOssExt ext1);
+
+ // ========== 下载相关 ==========
+
+ /**
+ * 下载文件到输出流
+ *
+ * @param key 存储对象键
+ * @param out 输出流
+ * @param consumer 可选的回调,处理文件大小/进度
+ * @throws OssException 下载失败抛出
+ */
+ void download(String key, OutputStream out, Consumer consumer);
+
+ /**
+ * 下载文件到本地临时路径
+ *
+ * @param key 存储对象键
+ * @return 本地文件路径
+ */
+ Path downloadToPath(String key);
+
+ /**
+ * 下载文件并返回输入流
+ *
+ * @param key 存储对象键
+ * @return 输入流
+ * @throws IOException 下载失败抛出
+ */
+ InputStream downloadToStream(String key) throws IOException;
+
+ // ========== 删除相关 ==========
+
+ /**
+ * 删除对象存储中的文件
+ *
+ * @param key 存储对象键
+ */
+ void delete(String key);
+
+ // ========== URL 相关 ==========
+
+ /**
+ * 获取文件的私有访问 URL
+ *
+ * @param key 存储对象键
+ * @param expiredTime 链接有效期
+ * @return 私有 URL
+ */
+ String getPrivateUrl(String key, Duration expiredTime);
+
+ /**
+ * 获取临时上传 URL(预签名 PUT)
+ *
+ * @param key 存储对象键
+ * @param expiredTime 链接有效期
+ * @param contentType 内容类型
+ * @return 临时上传 URL
+ */
+ String getTemporaryUploadUrl(String key, Duration expiredTime, String contentType);
+
+}
diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/service/impl/OssStorageServiceImpl.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/service/impl/OssStorageServiceImpl.java
new file mode 100644
index 000000000..ad06019e3
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/service/impl/OssStorageServiceImpl.java
@@ -0,0 +1,570 @@
+package org.dromara.common.oss.service.impl;
+
+import cn.hutool.core.io.IoUtil;
+import cn.hutool.core.io.file.FileNameUtil;
+import cn.hutool.core.util.ObjectUtil;
+import lombok.extern.slf4j.Slf4j;
+import org.dromara.common.core.utils.SpringUtils;
+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.core.WriteOutSubscriber;
+import org.dromara.common.oss.entity.SysOssExt;
+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.handler.OssUploadEventHandler;
+import org.dromara.common.oss.properties.OssProperties;
+import org.dromara.common.oss.service.StorageService;
+import org.dromara.common.oss.utils.StorageUtils;
+import org.springframework.web.multipart.MultipartFile;
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+import software.amazon.awssdk.core.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.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.Optional;
+import java.util.function.Consumer;
+
+/**
+ * S3 存储协议 所有兼容S3协议的云厂商均支持
+ * 阿里云 腾讯云 七牛云 minio
+ *
+ * @author AprilWind
+ */
+@Slf4j
+public class OssStorageServiceImpl implements StorageService {
+
+ /**
+ * 服务商
+ */
+ 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 OssStorageServiceImpl(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(StorageUtils.getEndpoint(properties)))
+ .region(StorageUtils.of(properties))
+ .forcePathStyle(isStyle)
+ .httpClient(NettyNioAsyncHttpClient.builder()
+ .connectionTimeout(Duration.ofSeconds(60)).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(StorageUtils.of(properties))
+ .credentialsProvider(credentialsProvider)
+ .endpointOverride(URI.create(StorageUtils.getDomain(properties)))
+ .serviceConfiguration(config)
+ .build();
+
+ } catch (Exception e) {
+ if (e instanceof OssException) {
+ throw e;
+ }
+ throw new OssException("配置错误! 请检查系统配置:[" + e.getMessage() + "]");
+ }
+ }
+
+ /**
+ * 服务商
+ */
+ @Override
+ public String getConfigKey() {
+ return configKey;
+ }
+
+ /**
+ * 获取当前桶权限类型
+ *
+ * @return 当前桶权限类型code
+ */
+ @Override
+ public AccessPolicyType getAccessPolicy() {
+ return AccessPolicyType.getByType(properties.getAccessPolicy());
+ }
+
+ /**
+ * 检查配置是否相同
+ */
+ @Override
+ public boolean checkPropertiesSame(OssProperties properties) {
+ return ObjectUtil.equal(this.properties, properties);
+ }
+
+ /**
+ * 上传 {@link MultipartFile} 到对象存储服务
+ *
+ * @param ossId 对象存储主键
+ * @param file 要上传的 MultipartFile 对象
+ * @param ext1 扩展信息
+ * @return 上传结果,包含 URL、ossId、文件名、ETag 等
+ * @throws IOException 读取文件失败
+ */
+ @Override
+ public UploadResult upload(Long ossId, MultipartFile file, SysOssExt ext1) throws IOException {
+ String originalName = file.getOriginalFilename();
+ String fileSuffix = "." + FileNameUtil.extName(originalName);
+ return uploadSuffix(ossId, originalName, file.getBytes(), fileSuffix, file.getContentType(), ext1);
+ }
+
+ /**
+ * 上传 {@link File} 到对象存储服务
+ *
+ * @param ossId 对象存储主键
+ * @param file 要上传的本地文件
+ * @param ext1 扩展信息
+ * @return 上传结果
+ */
+ @Override
+ public UploadResult upload(Long ossId, File file, SysOssExt ext1) {
+ return uploadSuffix(ossId, file, "." + FileNameUtil.getSuffix(file), ext1);
+ }
+
+ /**
+ * 上传 {@link File},指定文件后缀
+ *
+ * @param ossId 对象存储主键
+ * @param file 本地文件
+ * @param fileSuffix 文件后缀
+ * @param ext1 扩展信息
+ * @return 上传结果
+ */
+ @Override
+ public UploadResult uploadSuffix(Long ossId, File file, String fileSuffix, SysOssExt ext1) {
+ return upload(ossId, file.getName(), fileSuffix,
+ file.toPath(), StorageUtils.getPath(properties.getPrefix(), fileSuffix),
+ null, FileUtils.getMimeType(fileSuffix), ext1);
+ }
+
+ /**
+ * 上传字节数组
+ *
+ * @param ossId 对象存储主键
+ * @param originalName 原始文件名
+ * @param data 文件字节数组
+ * @param fileSuffix 文件后缀
+ * @param contentType 内容类型
+ * @param ext1 扩展信息
+ * @return 上传结果
+ */
+ @Override
+ public UploadResult uploadSuffix(Long ossId, String originalName, byte[] data,
+ String fileSuffix, String contentType, SysOssExt ext1) {
+ return upload(ossId, originalName, fileSuffix,
+ new ByteArrayInputStream(data), StorageUtils.getPath(properties.getPrefix(), fileSuffix),
+ (long) data.length, contentType, ext1);
+ }
+
+ /**
+ * 上传输入流
+ *
+ * @param ossId 对象存储主键
+ * @param originalName 原始文件名
+ * @param inputStream 输入流
+ * @param fileSuffix 文件后缀
+ * @param length 文件长度
+ * @param contentType 内容类型
+ * @param ext1 扩展信息
+ * @return 上传结果
+ */
+ @Override
+ public UploadResult uploadSuffix(Long ossId, String originalName, InputStream inputStream,
+ String fileSuffix, Long length, String contentType, SysOssExt ext1) {
+ return upload(ossId, originalName, fileSuffix,
+ inputStream, StorageUtils.getPath(properties.getPrefix(), fileSuffix),
+ length, contentType, ext1);
+ }
+
+ /**
+ * 上传输入流(指定对象键)
+ *
+ * @param ossId 对象存储主键
+ * @param originalName 原始文件名
+ * @param fileSuffix 文件后缀
+ * @param inputStream 输入流
+ * @param key 存储对象键
+ * @param length 文件长度
+ * @param contentType 内容类型
+ * @param ext1 扩展信息
+ * @return 上传结果
+ */
+ @Override
+ public UploadResult upload(Long ossId, String originalName, String fileSuffix,
+ InputStream inputStream, String key, Long length, String contentType, SysOssExt ext1) {
+ try {
+ if (!(inputStream instanceof ByteArrayInputStream)) {
+ inputStream = new ByteArrayInputStream(IoUtil.readBytes(inputStream));
+ }
+
+ // 构建异步请求体
+ BlockingInputStreamAsyncRequestBody body = BlockingInputStreamAsyncRequestBody.builder()
+ .contentLength(length)
+ .subscribeTimeout(Duration.ofSeconds(120))
+ .build();
+
+ // 发起上传
+ Upload upload = transferManager.upload(
+ x -> x.requestBody(body).addTransferListener(LoggingTransferListener.create())
+ .putObjectRequest(y -> y.bucket(properties.getBucketName())
+ .key(key)
+ .contentType(contentType)
+ //.acl(getAccessPolicy().getObjectCannedACL()) // 按需开启 ACL
+ .build())
+ .build());
+
+ // 写入数据流
+ body.writeInputStream(inputStream);
+
+ // 等待上传完成
+ CompletedUpload uploadResult = upload.completionFuture().join();
+
+ // 构建上传结果
+ UploadResult result = UploadResult.builder()
+ .ossId(ossId)
+ .url(StorageUtils.getUrl(properties) + StringUtils.SLASH + key)
+ .filename(key)
+ .originalName(originalName)
+ .fileSuffix(fileSuffix)
+ .eTag(uploadResult.response().eTag())
+ .build();
+
+ // 如果扩展信息为空,自动创建一个
+ if (ObjectUtil.isNull(ext1)) {
+ ext1 = new SysOssExt();
+ }
+
+ // 文件大小为空时自动填充
+ if (ObjectUtil.isNull(ext1.getFileSize())) {
+ ext1.setFileSize(length);
+ }
+
+ // 文件类型为空时自动填充
+ if (StringUtils.isEmpty(ext1.getContentType())) {
+ ext1.setContentType(contentType);
+ }
+
+ SpringUtils.getBean(OssUploadEventHandler.class).uploadHandler(getConfigKey(), result, ext1);
+
+ return result;
+ } catch (Exception e) {
+ throw new OssException("ossId=" + ossId + " 上传失败: " + e.getMessage());
+ }
+ }
+
+ /**
+ * 上传文件路径(指定对象键)
+ *
+ * @param ossId 对象存储主键
+ * @param originalName 原始文件名
+ * @param fileSuffix 文件后缀
+ * @param filePath 本地文件路径
+ * @param key 对象键
+ * @param md5Digest 文件 MD5 值(可选)
+ * @param contentType 内容类型
+ * @param ext1 扩展信息
+ * @return 上传结果
+ */
+ @Override
+ public UploadResult upload(Long ossId, String originalName, String fileSuffix,
+ Path filePath, String key, String md5Digest, String contentType, SysOssExt ext1) {
+ try {
+ // 构建上传请求
+ FileUpload fileUpload = transferManager.uploadFile(
+ x -> x.putObjectRequest(y -> y.bucket(properties.getBucketName())
+ .key(key)
+ .contentMD5(StringUtils.isNotEmpty(md5Digest) ? md5Digest : null)
+ .contentType(contentType)
+ //.acl(getAccessPolicy().getObjectCannedACL())
+ .build())
+ .addTransferListener(LoggingTransferListener.create())
+ .source(filePath).build());
+
+ CompletedFileUpload uploadResult = fileUpload.completionFuture().join();
+
+ // 构建上传结果
+ UploadResult result = UploadResult.builder()
+ .ossId(ossId)
+ .url(StorageUtils.getUrl(properties) + StringUtils.SLASH + key)
+ .filename(key)
+ .originalName(originalName)
+ .fileSuffix(fileSuffix)
+ .eTag(uploadResult.response().eTag())
+ .build();
+
+ // 如果扩展信息为空,自动创建一个
+ if (ObjectUtil.isNull(ext1)) {
+ ext1 = new SysOssExt();
+ }
+
+ // 文件大小为空时自动填充
+ if (ObjectUtil.isNull(ext1.getFileSize())) {
+ ext1.setFileSize(Files.size(filePath));
+ }
+
+ // 文件类型为空时自动填充
+ if (StringUtils.isEmpty(ext1.getContentType())) {
+ ext1.setContentType(contentType);
+ }
+
+ // MD5 为空时自动填充
+ if (StringUtils.isEmpty(ext1.getMd5())) {
+ ext1.setMd5(md5Digest);
+ }
+
+ SpringUtils.getBean(OssUploadEventHandler.class)
+ .uploadHandler(getConfigKey(), result, ext1);
+
+ return result;
+ } catch (Exception e) {
+ throw new OssException("ossId=" + ossId + " 文件路径上传失败: " + e.getMessage());
+ } finally {
+ // 清理临时文件
+ FileUtils.del(filePath);
+ }
+ }
+
+ /**
+ * 下载对象存储中的文件到指定输出流
+ *
+ * @param key 文件对象键
+ * @param out 输出流
+ * @param consumer 自定义逻辑处理文件大小或进度(可选)
+ * @throws OssException 下载失败时抛出自定义异常
+ */
+ @Override
+ public void download(String key, OutputStream out, Consumer consumer) {
+ try {
+ this.download(key, consumer).writeTo(out);
+ } catch (Exception e) {
+ throw new OssException("文件下载失败,错误信息:[" + e.getMessage() + "]");
+ }
+ }
+
+ /**
+ * 下载对象存储中的文件到本地临时路径
+ *
+ * @param key 文件对象键
+ * @return 返回下载后的本地文件路径
+ * @throws OssException 下载失败时抛出自定义异常
+ */
+ @Override
+ public Path downloadToPath(String key) {
+ try {
+ Path tempFilePath = FileUtils.createTempFile().toPath();
+ log.info("开始下载对象存储文件 key={}", key);
+
+ FileDownload downloadFile = transferManager.downloadFile(
+ x -> x.getObjectRequest(
+ y -> y.bucket(properties.getBucketName())
+ .key(StorageUtils.removeBaseUrl(key, properties))
+ .build())
+ .addTransferListener(LoggingTransferListener.create())
+ .destination(tempFilePath)
+ .build());
+
+ downloadFile.completionFuture().join();
+ log.info("下载完成,临时文件路径={}", tempFilePath);
+ return tempFilePath;
+ } catch (Exception e) {
+ throw new OssException("下载文件失败,请检查配置信息: [" + e.getMessage() + "]");
+ }
+ }
+
+ /**
+ * 下载对象存储中的文件并返回输入流
+ *
+ * @param key 文件对象键
+ * @return 返回文件内容的输入流
+ * @throws OssException 下载失败时抛出自定义异常
+ */
+ @Override
+ public InputStream downloadToStream(String key) {
+ Path tempFilePath = downloadToPath(key);
+ try {
+ // 返回的输入流关闭时可删除临时文件
+ return new FilterInputStream(Files.newInputStream(tempFilePath)) {
+ @Override
+ public void close() throws IOException {
+ super.close();
+ FileUtils.del(tempFilePath);
+ }
+ };
+ } catch (IOException e) {
+ throw new OssException("获取文件输入流失败: [" + e.getMessage() + "]");
+ }
+ }
+
+ /**
+ * 删除对象存储中指定路径的文件
+ *
+ * @param path 文件路径或对象键
+ * @throws OssException 删除失败时抛出自定义异常
+ */
+ @Override
+ public void delete(String path) {
+ try {
+ client.deleteObject(
+ x -> x.bucket(properties.getBucketName())
+ .key(StorageUtils.removeBaseUrl(path, properties))
+ .build());
+ } catch (Exception e) {
+ throw new OssException("删除文件失败,请检查配置信息:[" + e.getMessage() + "]");
+ }
+ }
+
+ /**
+ * 获取私有URL链接
+ *
+ * @param key 文件对象键
+ * @param expiredTime 链接授权到期时间
+ */
+ @Override
+ public String getPrivateUrl(String key, Duration expiredTime) {
+ // 使用 AWS S3 预签名 URL 的生成器 获取对象的预签名 URL
+ URL url = presigner.presignGetObject(
+ x -> x.signatureDuration(expiredTime)
+ .getObjectRequest(
+ y -> y.bucket(properties.getBucketName())
+ .key(key)
+ .build())
+ .build())
+ .url();
+ return url.toString();
+ }
+
+ /**
+ * 获取对象的临时上传 URL(预签名 PUT URL)
+ *
+ * 前端可以使用该 URL 直接上传文件到 OSS,无需经过后端中转。
+ *
+ * @param key 文件对象键
+ * @param expiredTime 链接有效期
+ * @param contentType 上传文件的 MIME 类型,例如 "image/png"
+ * @return 预签名上传 URL
+ */
+ @Override
+ public String getTemporaryUploadUrl(String key, Duration expiredTime, String contentType) {
+ URL url = presigner.presignPutObject(
+ x -> x.signatureDuration(expiredTime)
+ .putObjectRequest(y -> y.bucket(properties.getBucketName())
+ .key(key)
+ .contentType(contentType)
+ .build())
+ .build())
+ .url();
+ return url.toString();
+ }
+
+ /**
+ * 下载文件从 Amazon S3 到 输出流
+ *
+ * @param key 文件在 Amazon S3 中的对象键
+ * @param contentLengthConsumer 文件大小消费者函数
+ * @return 写出订阅器
+ * @throws OssException 如果下载失败,抛出自定义异常
+ */
+ private WriteOutSubscriber download(String key, Consumer contentLengthConsumer) {
+ try {
+ // 构建下载请求
+ DownloadRequest> publisherDownloadRequest = DownloadRequest.builder()
+ // 文件对象
+ .getObjectRequest(y -> y.bucket(properties.getBucketName())
+ .key(key)
+ .build())
+ .addTransferListener(LoggingTransferListener.create())
+ // 使用发布订阅转换器
+ .responseTransformer(AsyncResponseTransformer.toPublisher())
+ .build();
+
+ // 使用 S3TransferManager 下载文件
+ Download> publisherDownload = transferManager.download(publisherDownloadRequest);
+ // 获取下载发布订阅转换器
+ 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() + "]");
+ }
+ }
+
+}
diff --git a/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/utils/StorageUtils.java b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/utils/StorageUtils.java
new file mode 100644
index 000000000..cc3ae6bb7
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/utils/StorageUtils.java
@@ -0,0 +1,128 @@
+package org.dromara.common.oss.utils;
+
+import org.dromara.common.core.constant.Constants;
+import org.dromara.common.core.utils.StringUtils;
+import org.dromara.common.oss.constant.OssConstant;
+import org.dromara.common.oss.properties.OssProperties;
+import software.amazon.awssdk.regions.Region;
+
+/**
+ * Storage 工具类
+ *
+ * 提供一些和路径、URL、协议、桶权限相关的通用方法
+ *
+ * @author AprilWind
+ */
+public class StorageUtils {
+
+ /**
+ * 获取是否使用 HTTPS 的协议头
+ *
+ * @param properties Oss 配置
+ * @return "https://" 或 "http://"
+ */
+ public static String getIsHttps(OssProperties properties) {
+ return OssConstant.IS_HTTPS.equals(properties.getIsHttps()) ? Constants.HTTPS : Constants.HTTP;
+ }
+
+ /**
+ * 获取完整终端点 URL
+ *
+ * @param properties Oss 配置
+ * @return 终端点 URL
+ */
+ public static String getEndpoint(OssProperties properties) {
+ String header = getIsHttps(properties);
+ return header + properties.getEndpoint();
+ }
+
+ /**
+ * 获取完整域名 URL(优先域名,其次 endpoint)
+ *
+ * @param properties Oss 配置
+ * @return URL 字符串
+ */
+ public static String getDomain(OssProperties properties) {
+ // 从配置中获取域名、终端点、是否使用 HTTPS 等信息
+ String domain = properties.getDomain();
+ String endpoint = properties.getEndpoint();
+ String header = getIsHttps(properties);
+
+ // 如果是云服务商,直接返回域名或终端点
+ 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;
+ }
+
+ /**
+ * 获取访问 URL
+ *
+ * @param properties Oss 配置
+ * @return URL
+ */
+ public static String getUrl(OssProperties properties) {
+ String domain = properties.getDomain();
+ String endpoint = properties.getEndpoint();
+ String header = getIsHttps(properties);
+
+ if (StringUtils.containsAny(endpoint, OssConstant.CLOUD_SERVICE)) {
+ return header + (StringUtils.isNotEmpty(domain) ? domain : properties.getBucketName() + "." + endpoint);
+ }
+
+ if (StringUtils.isNotEmpty(domain)) {
+ return (domain.startsWith(Constants.HTTPS) || domain.startsWith(Constants.HTTP)) ?
+ domain + "/" + properties.getBucketName() :
+ header + domain + "/" + properties.getBucketName();
+ }
+
+ return header + endpoint + "/" + properties.getBucketName();
+ }
+
+ /**
+ * 根据 prefix + suffix 构造唯一文件路径
+ *
+ * @param prefix 前缀
+ * @param suffix 后缀
+ * @return 文件路径
+ */
+ public static String getPath(String prefix, String suffix) {
+ String uuid = java.util.UUID.randomUUID().toString().replace("-", "");
+ String datePath = org.dromara.common.core.utils.DateUtils.datePath();
+ String path = (prefix != null && !prefix.isEmpty()) ? prefix + "/" + datePath + "/" + uuid : datePath + "/" + uuid;
+ return path + suffix;
+ }
+
+ /**
+ * 移除 URL 的基础路径,得到相对路径
+ *
+ * @param url 完整 URL
+ * @param properties Oss 配置
+ * @return 相对路径
+ */
+ public static String removeBaseUrl(String url, OssProperties properties) {
+ return url.replace(getUrl(properties) + "/", "");
+ }
+
+ /**
+ * 根据传入的 region 参数返回相应的 AWS 区域
+ * 如果 region 参数非空,使用 Region.of 方法创建并返回对应的 AWS 区域对象
+ * 如果 region 参数为空,返回一个默认的 AWS 区域(例如,us-east-1),作为广泛支持的区域
+ *
+ * @return 对应的 AWS 区域对象,或者默认的广泛支持的区域(us-east-1)
+ */
+ public static Region of(OssProperties properties) {
+ //AWS 区域字符串
+ String region = properties.getRegion();
+ // 如果 region 参数非空,使用 Region.of 方法创建对应的 AWS 区域对象,否则返回默认区域
+ return StringUtils.isNotEmpty(region) ? Region.of(region) : Region.US_EAST_1;
+ }
+
+}
diff --git a/ruoyi-common/ruoyi-common-oss/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/ruoyi-common/ruoyi-common-oss/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
new file mode 100644
index 000000000..319fbd944
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-oss/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -0,0 +1 @@
+org.dromara.common.oss.handler.OssUploadEventHandler
diff --git a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysOssServiceImpl.java b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysOssServiceImpl.java
index 33341a91d..7914fde0d 100644
--- a/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysOssServiceImpl.java
+++ b/ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/service/impl/SysOssServiceImpl.java
@@ -4,34 +4,34 @@ import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.incrementer.IdentifierGenerator;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.constant.CacheNames;
import org.dromara.common.core.domain.dto.OssDTO;
+import org.dromara.common.core.domain.event.OssUploadEvent;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.service.OssService;
-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.mybatis.core.page.TableDataInfo;
-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.factory.OssFactory;
+import org.dromara.common.oss.factory.StorageFactory;
+import org.dromara.common.oss.service.StorageService;
import org.dromara.system.domain.SysOss;
-import org.dromara.system.domain.SysOssExt;
import org.dromara.system.domain.bo.SysOssBo;
import org.dromara.system.domain.vo.SysOssVo;
import org.dromara.system.mapper.SysOssMapper;
import org.dromara.system.service.ISysOssService;
-import org.jetbrains.annotations.NotNull;
import org.springframework.cache.annotation.Cacheable;
+import org.springframework.context.event.EventListener;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
@@ -49,11 +49,13 @@ import java.util.Map;
*
* @author Lion Li
*/
+@Slf4j
@RequiredArgsConstructor
@Service
public class SysOssServiceImpl implements ISysOssService, OssService {
private final SysOssMapper baseMapper;
+ private final IdentifierGenerator identifierGenerator;
/**
* 查询OSS对象存储列表
@@ -164,6 +166,22 @@ public class SysOssServiceImpl implements ISysOssService, OssService {
return baseMapper.selectVoById(ossId);
}
+ /**
+ * 监听 OSS 文件上传事件,并将上传信息保存到数据库
+ */
+ @EventListener
+ public void handleOssUpload(OssUploadEvent uploadEvent) {
+ SysOss oss = new SysOss();
+ oss.setOssId(uploadEvent.getOssId());
+ oss.setUrl(uploadEvent.getUrl());
+ oss.setFileSuffix(uploadEvent.getFileSuffix());
+ oss.setFileName(uploadEvent.getFileName());
+ oss.setOriginalName(uploadEvent.getOriginalName());
+ oss.setService(uploadEvent.getService());
+ oss.setExt1(uploadEvent.getExt1());
+ baseMapper.insert(oss);
+ log.info("OSS 上传记录已保存: {}", oss.getFileName());
+ }
/**
* 文件下载方法,支持一次性下载完整文件
@@ -179,7 +197,7 @@ public class SysOssServiceImpl implements ISysOssService, OssService {
}
FileUtils.setAttachmentResponseHeader(response, sysOss.getOriginalName());
response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE + "; charset=UTF-8");
- OssClient storage = OssFactory.instance(sysOss.getService());
+ StorageService storage = StorageFactory.instance(sysOss.getService());
storage.download(sysOss.getFileName(), response.getOutputStream(), response::setContentLengthLong);
}
@@ -192,20 +210,15 @@ public class SysOssServiceImpl implements ISysOssService, OssService {
*/
@Override
public SysOssVo upload(MultipartFile file) {
- String originalfileName = file.getOriginalFilename();
- String suffix = StringUtils.substring(originalfileName, originalfileName.lastIndexOf("."), originalfileName.length());
- OssClient storage = OssFactory.instance();
+ StorageService storage = StorageFactory.instance();
+ Long ossId = identifierGenerator.nextId(null).longValue();
UploadResult uploadResult;
try {
- uploadResult = storage.uploadSuffix(file.getBytes(), suffix, file.getContentType());
+ uploadResult = storage.upload(ossId, file, null);
} 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);
+ return BeanUtil.toBean(uploadResult, SysOssVo.class);
}
/**
@@ -216,28 +229,10 @@ public class SysOssServiceImpl implements ISysOssService, OssService {
*/
@Override
public SysOssVo upload(File file) {
- String originalfileName = file.getName();
- String suffix = StringUtils.substring(originalfileName, originalfileName.lastIndexOf("."), originalfileName.length());
- OssClient storage = OssFactory.instance();
- UploadResult uploadResult = storage.uploadSuffix(file, suffix);
- SysOssExt ext1 = new SysOssExt();
- ext1.setFileSize(file.length());
- // 保存文件信息
- return buildResultEntity(originalfileName, suffix, storage.getConfigKey(), uploadResult, ext1);
- }
-
- @NotNull
- private SysOssVo buildResultEntity(String originalfileName, String suffix, String configKey, UploadResult uploadResult, SysOssExt ext1) {
- SysOss oss = new SysOss();
- oss.setUrl(uploadResult.getUrl());
- oss.setFileSuffix(suffix);
- oss.setFileName(uploadResult.getFilename());
- oss.setOriginalName(originalfileName);
- oss.setService(configKey);
- oss.setExt1(JsonUtils.toJsonString(ext1));
- baseMapper.insert(oss);
- SysOssVo sysOssVo = MapstructUtils.convert(oss, SysOssVo.class);
- return this.matchingUrl(sysOssVo);
+ StorageService storage = StorageFactory.instance();
+ Long ossId = identifierGenerator.nextId(null).longValue();
+ UploadResult uploadResult = storage.upload(ossId, file, null);
+ return BeanUtil.toBean(uploadResult, SysOssVo.class);
}
/**
@@ -254,7 +249,7 @@ public class SysOssServiceImpl implements ISysOssService, OssService {
}
List list = baseMapper.selectByIds(ids);
for (SysOss sysOss : list) {
- OssClient storage = OssFactory.instance(sysOss.getService());
+ StorageService storage = StorageFactory.instance(sysOss.getService());
storage.delete(sysOss.getUrl());
}
return baseMapper.deleteByIds(ids) > 0;
@@ -267,7 +262,7 @@ public class SysOssServiceImpl implements ISysOssService, OssService {
* @return oss 匹配Url的OSS对象
*/
private SysOssVo matchingUrl(SysOssVo oss) {
- OssClient storage = OssFactory.instance(oss.getService());
+ StorageService storage = StorageFactory.instance(oss.getService());
// 仅修改桶类型为 private 的URL,临时URL时长为120s
if (AccessPolicyType.PRIVATE == storage.getAccessPolicy()) {
oss.setUrl(storage.getPrivateUrl(oss.getFileName(), Duration.ofSeconds(120)));