feat: 优化
This commit is contained in:
@@ -52,6 +52,17 @@ public interface FileClient {
|
||||
throw new UnsupportedOperationException("不支持的操作");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获得文件预签名地址,用于上传(包含 Content-Type 签名)
|
||||
*
|
||||
* @param path 相对路径
|
||||
* @param contentType 文件的 Content-Type
|
||||
* @return 文件预签名地址
|
||||
*/
|
||||
default String presignPutUrl(String path, String contentType) {
|
||||
throw new UnsupportedOperationException("不支持的操作");
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成文件预签名地址,用于读取
|
||||
*
|
||||
|
||||
@@ -7,6 +7,11 @@ import cn.hutool.core.util.URLUtil;
|
||||
import cn.hutool.http.HttpUtil;
|
||||
import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
|
||||
import cn.iocoder.yudao.module.infra.framework.file.core.client.AbstractFileClient;
|
||||
import com.aliyun.oss.OSS;
|
||||
import com.aliyun.oss.OSSClient;
|
||||
import com.aliyun.oss.HttpMethod;
|
||||
import com.aliyun.oss.model.OSSObject;
|
||||
import com.aliyun.oss.model.ObjectMetadata;
|
||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
|
||||
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
||||
@@ -16,15 +21,16 @@ import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.S3Configuration;
|
||||
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
|
||||
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
|
||||
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.time.Duration;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Date;
|
||||
|
||||
import org.springframework.web.util.UriUtils;
|
||||
|
||||
@@ -36,9 +42,15 @@ import org.springframework.web.util.UriUtils;
|
||||
public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
|
||||
|
||||
private static final Duration EXPIRATION_DEFAULT = Duration.ofHours(24);
|
||||
private static final String DEFAULT_REGION = "us-east-1";
|
||||
|
||||
private S3Client client;
|
||||
private S3Presigner presigner;
|
||||
/** S3 客户端(非阿里云使用) */
|
||||
private S3Client s3Client;
|
||||
private S3Presigner s3Presigner;
|
||||
/** 阿里云 OSS 客户端(阿里云专用) */
|
||||
private OSS aliyunOssClient;
|
||||
/** 是否为阿里云 */
|
||||
private boolean isAliyun;
|
||||
|
||||
public S3FileClient(Long id, S3FileClientConfig config) {
|
||||
super(id, config);
|
||||
@@ -50,22 +62,47 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
|
||||
if (StrUtil.isEmpty(config.getDomain())) {
|
||||
config.setDomain(buildDomain());
|
||||
}
|
||||
// 初始化 S3 客户端
|
||||
Region region = Region.of("us-east-1"); // 必须填,但填什么都行,常见的值有 "us-east-1",不填会报错
|
||||
|
||||
// 判断是否为阿里云
|
||||
isAliyun = StrUtil.contains(config.getEndpoint(), S3FileClientConfig.ENDPOINT_ALIYUN);
|
||||
|
||||
if (isAliyun) {
|
||||
// 阿里云:使用阿里云 OSS SDK
|
||||
initAliyunClient();
|
||||
} else {
|
||||
// 其他云厂商:使用 AWS S3 SDK
|
||||
initS3Client();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化阿里云 OSS 客户端
|
||||
*/
|
||||
private void initAliyunClient() {
|
||||
String region = extractRegionFromEndpoint(config.getEndpoint());
|
||||
String endpoint = "https://oss-" + region + ".aliyuncs.com";
|
||||
aliyunOssClient = new OSSClient(endpoint, config.getAccessKey(), config.getAccessSecret());
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 AWS S3 客户端
|
||||
*/
|
||||
private void initS3Client() {
|
||||
Region region = Region.of(extractRegionFromEndpoint(config.getEndpoint()));
|
||||
AwsCredentialsProvider credentialsProvider = StaticCredentialsProvider.create(
|
||||
AwsBasicCredentials.create(config.getAccessKey(), config.getAccessSecret()));
|
||||
URI endpoint = URI.create(buildEndpoint());
|
||||
S3Configuration serviceConfiguration = S3Configuration.builder() // Path-style 访问
|
||||
S3Configuration serviceConfiguration = S3Configuration.builder()
|
||||
.pathStyleAccessEnabled(Boolean.TRUE.equals(config.getEnablePathStyleAccess()))
|
||||
.chunkedEncodingEnabled(false) // 禁用分块编码,参见 https://t.zsxq.com/kBy57
|
||||
.chunkedEncodingEnabled(false)
|
||||
.build();
|
||||
client = S3Client.builder()
|
||||
s3Client = S3Client.builder()
|
||||
.credentialsProvider(credentialsProvider)
|
||||
.region(region)
|
||||
.endpointOverride(endpoint)
|
||||
.serviceConfiguration(serviceConfiguration)
|
||||
.build();
|
||||
presigner = S3Presigner.builder()
|
||||
s3Presigner = S3Presigner.builder()
|
||||
.credentialsProvider(credentialsProvider)
|
||||
.region(region)
|
||||
.endpointOverride(endpoint)
|
||||
@@ -75,125 +112,196 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
|
||||
|
||||
@Override
|
||||
public String upload(byte[] content, String path, String type) {
|
||||
// 构造 PutObjectRequest
|
||||
PutObjectRequest putRequest = PutObjectRequest.builder()
|
||||
.bucket(config.getBucket())
|
||||
.key(path)
|
||||
.contentType(type)
|
||||
.contentLength((long) content.length)
|
||||
.build();
|
||||
// 上传文件
|
||||
client.putObject(putRequest, RequestBody.fromBytes(content));
|
||||
// 拼接返回路径
|
||||
if (isAliyun) {
|
||||
ObjectMetadata metadata = new ObjectMetadata();
|
||||
metadata.setContentType(type);
|
||||
metadata.setContentLength(content.length);
|
||||
com.aliyun.oss.model.PutObjectRequest putRequest = new com.aliyun.oss.model.PutObjectRequest(
|
||||
config.getBucket(), path, new ByteArrayInputStream(content), metadata);
|
||||
aliyunOssClient.putObject(putRequest);
|
||||
} else {
|
||||
software.amazon.awssdk.services.s3.model.PutObjectRequest putRequest = software.amazon.awssdk.services.s3.model.PutObjectRequest.builder()
|
||||
.bucket(config.getBucket())
|
||||
.key(path)
|
||||
.contentType(type)
|
||||
.contentLength((long) content.length)
|
||||
.build();
|
||||
s3Client.putObject(putRequest, RequestBody.fromBytes(content));
|
||||
}
|
||||
return presignGetUrl(path, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String path) {
|
||||
DeleteObjectRequest deleteRequest = DeleteObjectRequest.builder()
|
||||
.bucket(config.getBucket())
|
||||
.key(path)
|
||||
.build();
|
||||
client.deleteObject(deleteRequest);
|
||||
if (isAliyun) {
|
||||
aliyunOssClient.deleteObject(config.getBucket(), path);
|
||||
} else {
|
||||
DeleteObjectRequest deleteRequest = DeleteObjectRequest.builder()
|
||||
.bucket(config.getBucket())
|
||||
.key(path)
|
||||
.build();
|
||||
s3Client.deleteObject(deleteRequest);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] getContent(String path) {
|
||||
GetObjectRequest getRequest = GetObjectRequest.builder()
|
||||
.bucket(config.getBucket())
|
||||
.key(path)
|
||||
.build();
|
||||
return IoUtil.readBytes(client.getObject(getRequest));
|
||||
if (isAliyun) {
|
||||
OSSObject ossObject = aliyunOssClient.getObject(config.getBucket(), path);
|
||||
return IoUtil.readBytes(ossObject.getObjectContent());
|
||||
} else {
|
||||
GetObjectRequest getRequest = GetObjectRequest.builder()
|
||||
.bucket(config.getBucket())
|
||||
.key(path)
|
||||
.build();
|
||||
return IoUtil.readBytes(s3Client.getObject(getRequest));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String presignPutUrl(String path) {
|
||||
return presigner.presignPutObject(PutObjectPresignRequest.builder()
|
||||
.signatureDuration(EXPIRATION_DEFAULT)
|
||||
.putObjectRequest(b -> b.bucket(config.getBucket()).key(path)).build())
|
||||
.url().toString();
|
||||
return presignPutUrl(path, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String presignPutUrl(String path, String contentType) {
|
||||
String signedUrl;
|
||||
if (isAliyun) {
|
||||
Date expirationDate = new Date(System.currentTimeMillis() + EXPIRATION_DEFAULT.toMillis());
|
||||
// 使用 GeneratePresignedUrlRequest 以支持设置 Content-Type
|
||||
com.aliyun.oss.model.GeneratePresignedUrlRequest request =
|
||||
new com.aliyun.oss.model.GeneratePresignedUrlRequest(config.getBucket(), path, HttpMethod.PUT);
|
||||
request.setExpiration(expirationDate);
|
||||
// 如果指定了 Content-Type,则包含在签名中
|
||||
if (StrUtil.isNotBlank(contentType)) {
|
||||
request.setContentType(contentType);
|
||||
}
|
||||
URL url = aliyunOssClient.generatePresignedUrl(request);
|
||||
signedUrl = url.toString();
|
||||
} else {
|
||||
software.amazon.awssdk.services.s3.model.PutObjectRequest.Builder putRequestBuilder =
|
||||
software.amazon.awssdk.services.s3.model.PutObjectRequest.builder()
|
||||
.bucket(config.getBucket())
|
||||
.key(path);
|
||||
// 如果指定了 Content-Type,则包含在签名中
|
||||
if (StrUtil.isNotBlank(contentType)) {
|
||||
putRequestBuilder.contentType(contentType);
|
||||
}
|
||||
signedUrl = s3Presigner.presignPutObject(PutObjectPresignRequest.builder()
|
||||
.signatureDuration(EXPIRATION_DEFAULT)
|
||||
.putObjectRequest(putRequestBuilder.build()).build())
|
||||
.url().toString();
|
||||
}
|
||||
// 直传使用 OSS 原始域名,不替换为 CDN(CDN 可能不支持签名直传)
|
||||
return signedUrl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String presignGetUrl(String url, Integer expirationSeconds) {
|
||||
// 1. 将 url 转换为 path(支持多种URL格式)
|
||||
String path = extractPathFromUrl(url);
|
||||
// 1. 将 url 转换为 path
|
||||
String path = StrUtil.removePrefix(url, config.getDomain() + "/");
|
||||
path = HttpUtils.removeUrlQuery(path);
|
||||
String decodedPath = URLUtil.decode(path, StandardCharsets.UTF_8);
|
||||
|
||||
// 2.1 情况一:公开访问:无需签名
|
||||
// 考虑到老版本的兼容,所以必须是 config.getEnablePublicAccess() 为 false 时,才进行签名
|
||||
// 2. 公开访问:无需签名
|
||||
if (!BooleanUtil.isFalse(config.getEnablePublicAccess())) {
|
||||
String encodedPath = UriUtils.encodePath(decodedPath, StandardCharsets.UTF_8);
|
||||
return config.getDomain() + "/" + encodedPath;
|
||||
}
|
||||
|
||||
// 2.2 情况二:私有访问:生成 GET 预签名 URL
|
||||
String finalPath = decodedPath;
|
||||
Duration expiration = expirationSeconds != null ? Duration.ofSeconds(expirationSeconds) : EXPIRATION_DEFAULT;
|
||||
URL signedUrl = presigner.presignGetObject(GetObjectPresignRequest.builder()
|
||||
.signatureDuration(expiration)
|
||||
.getObjectRequest(b -> b.bucket(config.getBucket()).key(finalPath)).build())
|
||||
.url();
|
||||
return signedUrl.toString();
|
||||
// 3. 私有访问:生成预签名 URL
|
||||
int expiration = expirationSeconds != null ? expirationSeconds : (int) EXPIRATION_DEFAULT.getSeconds();
|
||||
|
||||
String signedUrl;
|
||||
if (isAliyun) {
|
||||
Date expirationDate = new Date(System.currentTimeMillis() + expiration * 1000L);
|
||||
signedUrl = aliyunOssClient.generatePresignedUrl(config.getBucket(), decodedPath, expirationDate).toString();
|
||||
} else {
|
||||
Duration duration = Duration.ofSeconds(expiration);
|
||||
signedUrl = s3Presigner.presignGetObject(GetObjectPresignRequest.builder()
|
||||
.signatureDuration(duration)
|
||||
.getObjectRequest(b -> b.bucket(config.getBucket()).key(decodedPath)).build())
|
||||
.url().toString();
|
||||
}
|
||||
|
||||
// 4. 替换为 CDN 域名
|
||||
if (StrUtil.isNotEmpty(config.getDomain())) {
|
||||
return replaceUrlHost(signedUrl, config.getDomain());
|
||||
}
|
||||
return signedUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从URL中提取相对路径
|
||||
* 支持多种URL格式:
|
||||
* 1. CDN域名:https://oss.muyetools.cn/path/to/file
|
||||
* 2. OSS原始域名:https://bucket.oss-region.aliyuncs.com/path/to/file
|
||||
* 3. 相对路径:path/to/file
|
||||
* 替换 URL 的 host 部分为目标域名
|
||||
*
|
||||
* @param url 原始 URL
|
||||
* @param targetDomain 目标域名(含协议)
|
||||
* @return 替换后的 URL
|
||||
*/
|
||||
private String extractPathFromUrl(String url) {
|
||||
if (StrUtil.isEmpty(url)) {
|
||||
return url;
|
||||
}
|
||||
|
||||
// 1. 尝试移除配置的域名前缀
|
||||
String path = StrUtil.removePrefix(url, config.getDomain() + "/");
|
||||
if (!StrUtil.equals(url, path)) {
|
||||
return path;
|
||||
}
|
||||
|
||||
// 2. 如果不是以配置域名开头,检查是否是完整URL
|
||||
if (url.contains("://")) {
|
||||
// 提取域名后的路径
|
||||
int pathStart = url.indexOf("/", url.indexOf("://") + 3);
|
||||
if (pathStart > 0) {
|
||||
return url.substring(pathStart + 1);
|
||||
private String replaceUrlHost(String url, String targetDomain) {
|
||||
try {
|
||||
URI uri = new URI(url);
|
||||
String originalHost = uri.getScheme() + "://" + uri.getHost();
|
||||
if (uri.getPort() > 0) {
|
||||
originalHost += ":" + uri.getPort();
|
||||
}
|
||||
return StrUtil.replace(url, originalHost, targetDomain);
|
||||
} catch (Exception e) {
|
||||
// 降级处理:使用 buildDomain()
|
||||
return StrUtil.replace(url, buildDomain(), targetDomain);
|
||||
}
|
||||
|
||||
// 3. 已经是相对路径,直接返回
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于 bucket + endpoint 构建访问的 Domain 地址
|
||||
*
|
||||
* @return Domain 地址
|
||||
*/
|
||||
private String buildDomain() {
|
||||
// 如果已经是 http 或者 https,则不进行拼接.主要适配 MinIO
|
||||
if (HttpUtil.isHttp(config.getEndpoint()) || HttpUtil.isHttps(config.getEndpoint())) {
|
||||
return StrUtil.format("{}/{}", config.getEndpoint(), config.getBucket());
|
||||
}
|
||||
// 阿里云、腾讯云、华为云都适合。七牛云比较特殊,必须有自定义域名
|
||||
return StrUtil.format("https://{}.{}", config.getBucket(), config.getEndpoint());
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点地址补全协议头
|
||||
*
|
||||
* @return 节点地址
|
||||
*/
|
||||
private String buildEndpoint() {
|
||||
// 如果已经是 http 或者 https,则不进行拼接
|
||||
if (HttpUtil.isHttp(config.getEndpoint()) || HttpUtil.isHttps(config.getEndpoint())) {
|
||||
return config.getEndpoint();
|
||||
}
|
||||
return StrUtil.format("https://{}", config.getEndpoint());
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 endpoint 中提取 region
|
||||
*/
|
||||
private String extractRegionFromEndpoint(String endpoint) {
|
||||
if (StrUtil.isEmpty(endpoint)) {
|
||||
return DEFAULT_REGION;
|
||||
}
|
||||
|
||||
// 移除协议前缀和 bucket 前缀
|
||||
String host = StrUtil.removePrefixIgnoreCase(endpoint, "https://");
|
||||
host = StrUtil.removePrefixIgnoreCase(host, "http://");
|
||||
host = StrUtil.removePrefix(host, config.getBucket() + ".");
|
||||
|
||||
// 阿里云 OSS
|
||||
if (host.contains(S3FileClientConfig.ENDPOINT_ALIYUN)) {
|
||||
String prefix = StrUtil.subBefore(host, "." + S3FileClientConfig.ENDPOINT_ALIYUN, false);
|
||||
if (StrUtil.isNotEmpty(prefix)) {
|
||||
return StrUtil.removePrefix(prefix, "oss-");
|
||||
}
|
||||
}
|
||||
|
||||
// 腾讯云 COS
|
||||
if (host.contains(S3FileClientConfig.ENDPOINT_TENCENT)) {
|
||||
String region = StrUtil.subBefore(host, "." + S3FileClientConfig.ENDPOINT_TENCENT, false);
|
||||
if (StrUtil.isNotEmpty(region) && region.startsWith("cos.")) {
|
||||
return region.substring(4);
|
||||
}
|
||||
}
|
||||
|
||||
return DEFAULT_REGION;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user