From 6a9aa82bac93b0479da1e0a7070a8641e9233c31 Mon Sep 17 00:00:00 2001 From: lzh Date: Tue, 17 Mar 2026 17:44:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(infra):=20S3=20=E7=A7=81=E6=9C=89=E6=A1=B6?= =?UTF-8?q?=E9=A2=84=E7=AD=BE=E5=90=8D=E6=A0=B8=E5=BF=83=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - S3FileClient.buildDomain() 修复 COS virtual-hosted-style 域名生成 - S3FileClient.presignGetUrl() 支持跨桶签名及 endpoint 校验 - FileApi.presignGetUrl() 修复 Feign nullable 参数注解 Co-Authored-By: Claude Opus 4.6 --- .../viewsh/module/infra/api/file/FileApi.java | 146 ++--- .../file/core/client/s3/S3FileClient.java | 512 ++++++++++-------- 2 files changed, 352 insertions(+), 306 deletions(-) diff --git a/viewsh-module-infra/viewsh-module-infra-api/src/main/java/com/viewsh/module/infra/api/file/FileApi.java b/viewsh-module-infra/viewsh-module-infra-api/src/main/java/com/viewsh/module/infra/api/file/FileApi.java index 777a183..6dbfd7d 100644 --- a/viewsh-module-infra/viewsh-module-infra-api/src/main/java/com/viewsh/module/infra/api/file/FileApi.java +++ b/viewsh-module-infra/viewsh-module-infra-api/src/main/java/com/viewsh/module/infra/api/file/FileApi.java @@ -1,73 +1,73 @@ -package com.viewsh.module.infra.api.file; - -import com.viewsh.framework.common.pojo.CommonResult; -import com.viewsh.module.infra.api.file.dto.FileCreateReqDTO; -import com.viewsh.module.infra.enums.ApiConstants; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotEmpty; -import org.springframework.cloud.openfeign.FeignClient; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestParam; - -@FeignClient(name = ApiConstants.NAME) // TODO 芋艿:fallbackFactory = -@Tag(name = "RPC 服务 - 文件") -public interface FileApi { - - String PREFIX = ApiConstants.PREFIX + "/file"; - - /** - * 保存文件,并返回文件的访问路径 - * - * @param content 文件内容 - * @return 文件路径 - */ - default String createFile(byte[] content) { - return createFile(content, null, null, null); - } - - /** - * 保存文件,并返回文件的访问路径 - * - * @param content 文件内容 - * @param name 文件名称,允许空 - * @return 文件路径 - */ - default String createFile(byte[] content, String name) { - return createFile(content, name, null, null); - } - - /** - * 保存文件,并返回文件的访问路径 - * - * @param content 文件内容 - * @param name 文件名称,允许空 - * @param directory 目录,允许空 - * @param type 文件的 MIME 类型,允许空 - * @return 文件路径 - */ - default String createFile(@NotEmpty(message = "文件内容不能为空") byte[] content, - String name, String directory, String type) { - return createFile(new FileCreateReqDTO().setName(name).setDirectory(directory).setType(type).setContent(content)).getCheckedData(); - } - - @PostMapping(PREFIX + "/create") - @Operation(summary = "保存文件,并返回文件的访问路径") - CommonResult createFile(@Valid @RequestBody FileCreateReqDTO createReqDTO); - - /** - * 生成文件预签名地址,用于读取 - * - * @param url 完整的文件访问地址 - * @param expirationSeconds 访问有效期,单位秒 - * @return 文件预签名地址 - */ - @GetMapping(PREFIX + "/presigned-url") - @Operation(summary = "生成文件预签名地址,用于读取") - CommonResult presignGetUrl(@NotEmpty(message = "URL 不能为空") @RequestParam("url") String url, - Integer expirationSeconds); - -} +package com.viewsh.module.infra.api.file; + +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.module.infra.api.file.dto.FileCreateReqDTO; +import com.viewsh.module.infra.enums.ApiConstants; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +@FeignClient(name = ApiConstants.NAME) // TODO 芋艿:fallbackFactory = +@Tag(name = "RPC 服务 - 文件") +public interface FileApi { + + String PREFIX = ApiConstants.PREFIX + "/file"; + + /** + * 保存文件,并返回文件的访问路径 + * + * @param content 文件内容 + * @return 文件路径 + */ + default String createFile(byte[] content) { + return createFile(content, null, null, null); + } + + /** + * 保存文件,并返回文件的访问路径 + * + * @param content 文件内容 + * @param name 文件名称,允许空 + * @return 文件路径 + */ + default String createFile(byte[] content, String name) { + return createFile(content, name, null, null); + } + + /** + * 保存文件,并返回文件的访问路径 + * + * @param content 文件内容 + * @param name 文件名称,允许空 + * @param directory 目录,允许空 + * @param type 文件的 MIME 类型,允许空 + * @return 文件路径 + */ + default String createFile(@NotEmpty(message = "文件内容不能为空") byte[] content, + String name, String directory, String type) { + return createFile(new FileCreateReqDTO().setName(name).setDirectory(directory).setType(type).setContent(content)).getCheckedData(); + } + + @PostMapping(PREFIX + "/create") + @Operation(summary = "保存文件,并返回文件的访问路径") + CommonResult createFile(@Valid @RequestBody FileCreateReqDTO createReqDTO); + + /** + * 生成文件预签名地址,用于读取 + * + * @param url 完整的文件访问地址 + * @param expirationSeconds 访问有效期,单位秒 + * @return 文件预签名地址 + */ + @GetMapping(PREFIX + "/presigned-url") + @Operation(summary = "生成文件预签名地址,用于读取") + CommonResult presignGetUrl(@NotEmpty(message = "URL 不能为空") @RequestParam("url") String url, + @RequestParam(value = "expirationSeconds", required = false) Integer expirationSeconds); + +} diff --git a/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/framework/file/core/client/s3/S3FileClient.java b/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/framework/file/core/client/s3/S3FileClient.java index d892942..09def07 100644 --- a/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/framework/file/core/client/s3/S3FileClient.java +++ b/viewsh-module-infra/viewsh-module-infra-server/src/main/java/com/viewsh/module/infra/framework/file/core/client/s3/S3FileClient.java @@ -1,233 +1,279 @@ -package com.viewsh.module.infra.framework.file.core.client.s3; - -import cn.hutool.core.io.IoUtil; -import cn.hutool.core.util.BooleanUtil; -import cn.hutool.core.util.StrUtil; -import cn.hutool.http.HttpUtil; -import com.viewsh.framework.common.util.http.HttpUtils; -import com.viewsh.module.infra.framework.file.core.client.AbstractFileClient; -import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; -import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; -import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; -import software.amazon.awssdk.core.sync.RequestBody; -import software.amazon.awssdk.regions.Region; -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.net.URI; -import java.net.URL; -import java.time.Duration; - -/** - * 基于 S3 协议的文件客户端,实现 MinIO、阿里云、腾讯云、七牛云、华为云等云服务 - * - * @author 芋道源码 - */ -public class S3FileClient extends AbstractFileClient { - - private static final Duration EXPIRATION_DEFAULT = Duration.ofHours(24); - - private S3Client client; - private S3Presigner presigner; - - public S3FileClient(Long id, S3FileClientConfig config) { - super(id, config); - } - - @Override - protected void doInit() { - // 补全 domain - if (StrUtil.isEmpty(config.getDomain())) { - config.setDomain(buildDomain()); - } - // 初始化 S3 客户端 - // 优先级:配置的 region > 从 endpoint 解析的 region > 默认值 us-east-1 - String regionStr = resolveRegion(); - Region region = Region.of(regionStr); - AwsCredentialsProvider credentialsProvider = StaticCredentialsProvider.create( - AwsBasicCredentials.create(config.getAccessKey(), config.getAccessSecret())); - URI endpoint = URI.create(buildEndpoint()); - S3Configuration serviceConfiguration = S3Configuration.builder() // Path-style 访问 - .pathStyleAccessEnabled(Boolean.TRUE.equals(config.getEnablePathStyleAccess())) - .chunkedEncodingEnabled(false) // 禁用分块编码,参见 https://t.zsxq.com/kBy57 - .build(); - client = S3Client.builder() - .credentialsProvider(credentialsProvider) - .region(region) - .endpointOverride(endpoint) - .serviceConfiguration(serviceConfiguration) - .build(); - presigner = S3Presigner.builder() - .credentialsProvider(credentialsProvider) - .region(region) - .endpointOverride(endpoint) - .serviceConfiguration(serviceConfiguration) - .build(); - } - - @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)); - // 拼接返回路径 - return presignGetUrl(path, null); - } - - @Override - public void delete(String path) { - DeleteObjectRequest deleteRequest = DeleteObjectRequest.builder() - .bucket(config.getBucket()) - .key(path) - .build(); - client.deleteObject(deleteRequest); - } - - @Override - public byte[] getContent(String path) { - GetObjectRequest getRequest = GetObjectRequest.builder() - .bucket(config.getBucket()) - .key(path) - .build(); - return IoUtil.readBytes(client.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(); - } - - @Override - public String presignGetUrl(String url, Integer expirationSeconds) { - // 1. 将 url 转换为 path - String path = StrUtil.removePrefix(url, config.getDomain() + "/"); - path = HttpUtils.decodeUtf8(HttpUtils.removeUrlQuery(path)); - - // 2.1 情况一:公开访问:无需签名 - // 考虑到老版本的兼容,所以必须是 config.getEnablePublicAccess() 为 false 时,才进行签名 - if (!BooleanUtil.isFalse(config.getEnablePublicAccess())) { - return config.getDomain() + "/" + path; - } - - // 2.2 情况二:私有访问:生成 GET 预签名 URL - String finalPath = path; - 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(); - } - - /** - * 基于 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()); - } - - /** - * 解析 AWS 区域 - * 优先级:配置的 region > 从 endpoint 解析的 region > 默认值 us-east-1 - * - * @return 区域字符串 - */ - private String resolveRegion() { - // 1. 如果配置了 region,直接使用 - if (StrUtil.isNotEmpty(config.getRegion())) { - return config.getRegion(); - } - - // 2.1 尝试从 endpoint 中解析 region - String endpoint = config.getEndpoint(); - if (StrUtil.isEmpty(endpoint)) { - return "us-east-1"; - } - - // 2.2 移除协议头(http:// 或 https://) - String host = endpoint; - if (HttpUtil.isHttp(endpoint) || HttpUtil.isHttps(endpoint)) { - try { - host = URI.create(endpoint).getHost(); - } catch (Exception e) { - // 解析失败,使用默认值 - return "us-east-1"; - } - } - if (StrUtil.isEmpty(host)) { - return "us-east-1"; - } - - // 3.1 AWS S3 格式:s3.us-west-2.amazonaws.com 或 s3.amazonaws.com - if (host.contains("amazonaws.com")) { - // 匹配 s3.{region}.amazonaws.com 格式 - if (host.startsWith("s3.") && host.contains(".amazonaws.com")) { - String regionPart = host.substring(3, host.indexOf(".amazonaws.com")); - if (StrUtil.isNotEmpty(regionPart) && !regionPart.equals("accelerate")) { - return regionPart; - } - } - // s3.amazonaws.com 或 s3-accelerate.amazonaws.com 使用默认值 - return "us-east-1"; - } - // 3.2 阿里云 OSS 格式:oss-cn-beijing.aliyuncs.com - if (host.contains(S3FileClientConfig.ENDPOINT_ALIYUN)) { - // 匹配 oss-{region}.aliyuncs.com 格式 - if (host.startsWith("oss-") && host.contains("." + S3FileClientConfig.ENDPOINT_ALIYUN)) { - String regionPart = host.substring(4, host.indexOf("." + S3FileClientConfig.ENDPOINT_ALIYUN)); - if (StrUtil.isNotEmpty(regionPart)) { - return regionPart; - } - } - } - // 3.3 腾讯云 COS 格式:cos.ap-shanghai.myqcloud.com - if (host.contains(S3FileClientConfig.ENDPOINT_TENCENT)) { - // 匹配 cos.{region}.myqcloud.com 格式 - if (host.startsWith("cos.") && host.contains("." + S3FileClientConfig.ENDPOINT_TENCENT)) { - String regionPart = host.substring(4, host.indexOf("." + S3FileClientConfig.ENDPOINT_TENCENT)); - if (StrUtil.isNotEmpty(regionPart)) { - return regionPart; - } - } - } - - // 3.4 其他情况(MinIO、七牛云等)使用默认值 - return "us-east-1"; - } - -} +package com.viewsh.module.infra.framework.file.core.client.s3; + +import cn.hutool.core.io.IoUtil; +import cn.hutool.core.util.BooleanUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.http.HttpUtil; +import com.viewsh.framework.common.util.http.HttpUtils; +import com.viewsh.module.infra.framework.file.core.client.AbstractFileClient; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.regions.Region; +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.net.URI; +import java.net.URL; +import java.time.Duration; + +/** + * 基于 S3 协议的文件客户端,实现 MinIO、阿里云、腾讯云、七牛云、华为云等云服务 + * + * @author 芋道源码 + */ +public class S3FileClient extends AbstractFileClient { + + private static final Duration EXPIRATION_DEFAULT = Duration.ofHours(24); + + private S3Client client; + private S3Presigner presigner; + + public S3FileClient(Long id, S3FileClientConfig config) { + super(id, config); + } + + @Override + protected void doInit() { + // 补全 domain + if (StrUtil.isEmpty(config.getDomain())) { + config.setDomain(buildDomain()); + } + // 初始化 S3 客户端 + // 优先级:配置的 region > 从 endpoint 解析的 region > 默认值 us-east-1 + String regionStr = resolveRegion(); + Region region = Region.of(regionStr); + AwsCredentialsProvider credentialsProvider = StaticCredentialsProvider.create( + AwsBasicCredentials.create(config.getAccessKey(), config.getAccessSecret())); + URI endpoint = URI.create(buildEndpoint()); + S3Configuration serviceConfiguration = S3Configuration.builder() // Path-style 访问 + .pathStyleAccessEnabled(Boolean.TRUE.equals(config.getEnablePathStyleAccess())) + .chunkedEncodingEnabled(false) // 禁用分块编码,参见 https://t.zsxq.com/kBy57 + .build(); + client = S3Client.builder() + .credentialsProvider(credentialsProvider) + .region(region) + .endpointOverride(endpoint) + .serviceConfiguration(serviceConfiguration) + .build(); + presigner = S3Presigner.builder() + .credentialsProvider(credentialsProvider) + .region(region) + .endpointOverride(endpoint) + .serviceConfiguration(serviceConfiguration) + .build(); + } + + @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)); + // 拼接返回路径 + return presignGetUrl(path, null); + } + + @Override + public void delete(String path) { + DeleteObjectRequest deleteRequest = DeleteObjectRequest.builder() + .bucket(config.getBucket()) + .key(path) + .build(); + client.deleteObject(deleteRequest); + } + + @Override + public byte[] getContent(String path) { + GetObjectRequest getRequest = GetObjectRequest.builder() + .bucket(config.getBucket()) + .key(path) + .build(); + return IoUtil.readBytes(client.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(); + } + + @Override + public String presignGetUrl(String url, Integer expirationSeconds) { + // 1. 将 url 转换为 path 和 bucket + String bucket = config.getBucket(); + boolean crossBucket = false; + String path = StrUtil.removePrefix(url, config.getDomain() + "/"); + // 兼容:域名不匹配时(如同账号不同桶),从 URL 中解析 bucket 和 path + if (path.equals(url) && (path.startsWith("http://") || path.startsWith("https://"))) { + try { + URI uri = URI.create(url); + String host = uri.getHost(); + String endpointHost = getEndpointHost(); + // 校验是否同一 S3 服务(如 cos.ap-shanghai.myqcloud.com),避免误签非本服务 URL + if (host != null && endpointHost != null && host.endsWith("." + endpointHost)) { + bucket = StrUtil.subBefore(host, ".", false); + crossBucket = true; + path = StrUtil.removePrefix(uri.getPath(), "/"); + } else { + // 非同服务 URL,原样返回 + return url; + } + } catch (Exception ignored) { + return url; + } + } + path = HttpUtils.decodeUtf8(HttpUtils.removeUrlQuery(path)); + + // 2.1 情况一:公开访问:无需签名 + if (!BooleanUtil.isFalse(config.getEnablePublicAccess())) { + // 跨桶 URL 原样返回,不拼接本桶 domain + if (crossBucket) { + return url; + } + return config.getDomain() + "/" + path; + } + + // 2.2 情况二:私有访问:生成 GET 预签名 URL + String finalPath = path; + String finalBucket = bucket; + Duration expiration = expirationSeconds != null ? Duration.ofSeconds(expirationSeconds) : EXPIRATION_DEFAULT; + URL signedUrl = presigner.presignGetObject(GetObjectPresignRequest.builder() + .signatureDuration(expiration) + .getObjectRequest(b -> b.bucket(finalBucket).key(finalPath)).build()) + .url(); + return signedUrl.toString(); + } + + /** + * 获取 endpoint 的 host 部分(不含协议头) + * 例如:https://cos.ap-shanghai.myqcloud.com → cos.ap-shanghai.myqcloud.com + */ + private String getEndpointHost() { + String endpoint = config.getEndpoint(); + if (HttpUtil.isHttp(endpoint) || HttpUtil.isHttps(endpoint)) { + try { + return URI.create(endpoint).getHost(); + } catch (Exception e) { + return null; + } + } + return endpoint; + } + + /** + * 基于 bucket + endpoint 构建访问的 Domain 地址 + * + * @return Domain 地址 + */ + private String buildDomain() { + if (HttpUtil.isHttp(config.getEndpoint()) || HttpUtil.isHttps(config.getEndpoint())) { + // Path-Style(如 MinIO):{endpoint}/{bucket} + if (Boolean.TRUE.equals(config.getEnablePathStyleAccess())) { + return StrUtil.format("{}/{}", config.getEndpoint(), config.getBucket()); + } + // Virtual-Hosted-Style(如腾讯云 COS、阿里云 OSS):{scheme}://{bucket}.{host} + URI uri = URI.create(config.getEndpoint()); + return StrUtil.format("{}://{}.{}", uri.getScheme(), config.getBucket(), uri.getHost()); + } + // 没有协议头,直接拼接为虚拟主机风格 + 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()); + } + + /** + * 解析 AWS 区域 + * 优先级:配置的 region > 从 endpoint 解析的 region > 默认值 us-east-1 + * + * @return 区域字符串 + */ + private String resolveRegion() { + // 1. 如果配置了 region,直接使用 + if (StrUtil.isNotEmpty(config.getRegion())) { + return config.getRegion(); + } + + // 2.1 尝试从 endpoint 中解析 region + String endpoint = config.getEndpoint(); + if (StrUtil.isEmpty(endpoint)) { + return "us-east-1"; + } + + // 2.2 移除协议头(http:// 或 https://) + String host = endpoint; + if (HttpUtil.isHttp(endpoint) || HttpUtil.isHttps(endpoint)) { + try { + host = URI.create(endpoint).getHost(); + } catch (Exception e) { + // 解析失败,使用默认值 + return "us-east-1"; + } + } + if (StrUtil.isEmpty(host)) { + return "us-east-1"; + } + + // 3.1 AWS S3 格式:s3.us-west-2.amazonaws.com 或 s3.amazonaws.com + if (host.contains("amazonaws.com")) { + // 匹配 s3.{region}.amazonaws.com 格式 + if (host.startsWith("s3.") && host.contains(".amazonaws.com")) { + String regionPart = host.substring(3, host.indexOf(".amazonaws.com")); + if (StrUtil.isNotEmpty(regionPart) && !regionPart.equals("accelerate")) { + return regionPart; + } + } + // s3.amazonaws.com 或 s3-accelerate.amazonaws.com 使用默认值 + return "us-east-1"; + } + // 3.2 阿里云 OSS 格式:oss-cn-beijing.aliyuncs.com + if (host.contains(S3FileClientConfig.ENDPOINT_ALIYUN)) { + // 匹配 oss-{region}.aliyuncs.com 格式 + if (host.startsWith("oss-") && host.contains("." + S3FileClientConfig.ENDPOINT_ALIYUN)) { + String regionPart = host.substring(4, host.indexOf("." + S3FileClientConfig.ENDPOINT_ALIYUN)); + if (StrUtil.isNotEmpty(regionPart)) { + return regionPart; + } + } + } + // 3.3 腾讯云 COS 格式:cos.ap-shanghai.myqcloud.com + if (host.contains(S3FileClientConfig.ENDPOINT_TENCENT)) { + // 匹配 cos.{region}.myqcloud.com 格式 + if (host.startsWith("cos.") && host.contains("." + S3FileClientConfig.ENDPOINT_TENCENT)) { + String regionPart = host.substring(4, host.indexOf("." + S3FileClientConfig.ENDPOINT_TENCENT)); + if (StrUtil.isNotEmpty(regionPart)) { + return regionPart; + } + } + } + + // 3.4 其他情况(MinIO、七牛云等)使用默认值 + return "us-east-1"; + } + +}