add 实现通用存储服务接口及其实现,支持文件上传、下载和删除功能

This commit is contained in:
AprilWind 2025-08-18 16:06:17 +08:00
parent f02601ab2c
commit 5a5499b615
12 changed files with 1079 additions and 582 deletions

View File

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

View File

@ -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
*/

View File

@ -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<Long> 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<OutputStream> download(String key, Consumer<Long> contentLengthConsumer) {
try {
// 构建下载请求
DownloadRequest<ResponsePublisher<GetObjectResponse>> publisherDownloadRequest = DownloadRequest.builder()
// 文件对象
.getObjectRequest(y -> y.bucket(properties.getBucketName())
.key(key)
.build())
.addTransferListener(LoggingTransferListener.create())
// 使用发布订阅转换器
.responseTransformer(AsyncResponseTransformer.toPublisher())
.build();
// 使用 S3TransferManager 下载文件
Download<ResponsePublisher<GetObjectResponse>> publisherDownload = transferManager.download(publisherDownloadRequest);
// 获取下载发布订阅转换器
ResponsePublisher<GetObjectResponse> 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());
}
}

View File

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

View File

@ -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;
/**
* 已上传对象的实体标记用来校验文件
*/

View File

@ -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<String, OssClient> CLIENT_CACHE = new ConcurrentHashMap<>();
private static final Map<String, StorageService> 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();

View File

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

View File

@ -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;
/**
* 通用存储服务接口
* <p>
* 定义了对象存储系统常见的功能包括
* <ul>
* <li>文件上传支持 MultipartFileFileInputStreambyte[] 等多种形式</li>
* <li>文件下载支持输出流本地临时路径输入流等</li>
* <li>文件删除</li>
* <li>获取私有访问 URL临时上传 URL</li>
* </ul>
* </p>
*
* @author AprilWind
*/
public interface StorageService {
// ========== 基础信息 ==========
/**
* 获取存储服务的唯一配置键
* <p>
* 用于标识不同的存储服务实例例如
* - "local" 表示本地存储
* - "s3" 表示 Amazon S3
* - "qiniu" 表示七牛云
* </p>
*
* @return 存储服务配置键
*/
String getConfigKey();
/**
* 获取当前桶权限类型
*
* @return 当前桶权限类型code
*/
AccessPolicyType getAccessPolicy();
/**
* 检查当前存储客户端配置是否与传入配置相同
* <p>
* 用于判断是否需要重新创建存储客户端实例
* </p>
*
* @param properties 待比较的 OSS 配置属性
* @return 如果配置相同返回 true否则返回 false
*/
boolean checkPropertiesSame(OssProperties properties);
// ========== 上传相关 ==========
/**
* 上传 {@link MultipartFile} 到对象存储
*
* @param ossId 对象存储主键
* @param file 要上传的 MultipartFile 对象
* @param ext1 扩展信息可选用于存储额外的业务字段
* @return 上传结果包含文件 URLossId文件名等信息
* @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<Long> 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);
}

View File

@ -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 上传结果包含 URLossId文件名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<Long> 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
* <p>
* 前端可以使用该 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<OutputStream> download(String key, Consumer<Long> contentLengthConsumer) {
try {
// 构建下载请求
DownloadRequest<ResponsePublisher<GetObjectResponse>> publisherDownloadRequest = DownloadRequest.builder()
// 文件对象
.getObjectRequest(y -> y.bucket(properties.getBucketName())
.key(key)
.build())
.addTransferListener(LoggingTransferListener.create())
// 使用发布订阅转换器
.responseTransformer(AsyncResponseTransformer.toPublisher())
.build();
// 使用 S3TransferManager 下载文件
Download<ResponsePublisher<GetObjectResponse>> publisherDownload = transferManager.download(publisherDownloadRequest);
// 获取下载发布订阅转换器
ResponsePublisher<GetObjectResponse> 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() + "]");
}
}
}

View File

@ -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 工具类
* <p>
* 提供一些和路径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;
}
}

View File

@ -0,0 +1 @@
org.dromara.common.oss.handler.OssUploadEventHandler

View File

@ -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<SysOss> 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)));