From 5a5499b6151931c5992d11f322da262ba410fa39 Mon Sep 17 00:00:00 2001 From: AprilWind <2100166581@qq.com> Date: Mon, 18 Aug 2025 16:06:17 +0800 Subject: [PATCH] =?UTF-8?q?add=20=E5=AE=9E=E7=8E=B0=E9=80=9A=E7=94=A8?= =?UTF-8?q?=E5=AD=98=E5=82=A8=E6=9C=8D=E5=8A=A1=E6=8E=A5=E5=8F=A3=E5=8F=8A?= =?UTF-8?q?=E5=85=B6=E5=AE=9E=E7=8E=B0=EF=BC=8C=E6=94=AF=E6=8C=81=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E4=B8=8A=E4=BC=A0=E3=80=81=E4=B8=8B=E8=BD=BD=E5=92=8C?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/domain/event/OssUploadEvent.java | 53 ++ .../common/oss/constant/OssConstant.java | 5 - .../dromara/common/oss/core/OssClient.java | 525 ---------------- .../dromara/common/oss/entity}/SysOssExt.java | 6 +- .../common/oss/entity/UploadResult.java | 15 + .../{OssFactory.java => StorageFactory.java} | 20 +- .../oss/handler/OssUploadEventHandler.java | 38 ++ .../common/oss/service/StorageService.java | 223 +++++++ .../service/impl/OssStorageServiceImpl.java | 570 ++++++++++++++++++ .../common/oss/utils/StorageUtils.java | 128 ++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../service/impl/SysOssServiceImpl.java | 77 ++- 12 files changed, 1079 insertions(+), 582 deletions(-) create mode 100644 ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/event/OssUploadEvent.java delete mode 100644 ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/core/OssClient.java rename {ruoyi-modules/ruoyi-system/src/main/java/org/dromara/system/domain => ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/entity}/SysOssExt.java (85%) rename ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/factory/{OssFactory.java => StorageFactory.java} (76%) create mode 100644 ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/handler/OssUploadEventHandler.java create mode 100644 ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/service/StorageService.java create mode 100644 ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/service/impl/OssStorageServiceImpl.java create mode 100644 ruoyi-common/ruoyi-common-oss/src/main/java/org/dromara/common/oss/utils/StorageUtils.java create mode 100644 ruoyi-common/ruoyi-common-oss/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 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..62d0be4ba 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,35 +4,36 @@ 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.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -49,11 +50,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 +167,23 @@ public class SysOssServiceImpl implements ISysOssService, OssService { return baseMapper.selectVoById(ossId); } + /** + * 监听 OSS 文件上传事件,并将上传信息保存到数据库 + */ + @Async + @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 +199,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 +212,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 +231,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 +251,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 +264,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)));