feat(infra): S3 私有桶预签名核心能力

- S3FileClient.buildDomain() 修复 COS virtual-hosted-style 域名生成
- S3FileClient.presignGetUrl() 支持跨桶签名及 endpoint 校验
- FileApi.presignGetUrl() 修复 Feign nullable 参数注解

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lzh
2026-03-17 17:44:21 +08:00
parent 4796009e95
commit 6a9aa82bac
2 changed files with 352 additions and 306 deletions

View File

@@ -68,6 +68,6 @@ public interface FileApi {
@GetMapping(PREFIX + "/presigned-url")
@Operation(summary = "生成文件预签名地址,用于读取")
CommonResult<String> presignGetUrl(@NotEmpty(message = "URL 不能为空") @RequestParam("url") String url,
Integer expirationSeconds);
@RequestParam(value = "expirationSeconds", required = false) Integer expirationSeconds);
}

View File

@@ -114,37 +114,83 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
@Override
public String presignGetUrl(String url, Integer expirationSeconds) {
// 1. 将 url 转换为 path
// 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 情况一:公开访问:无需签名
// 考虑到老版本的兼容,所以必须是 config.getEnablePublicAccess() 为 false 时,才进行签名
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(config.getBucket()).key(finalPath)).build())
.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() {
// 如果已经是 http 或者 https则不进行拼接.主要适配 MinIO
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());
}