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

@@ -1,73 +1,73 @@
package com.viewsh.module.infra.api.file; package com.viewsh.module.infra.api.file;
import com.viewsh.framework.common.pojo.CommonResult; import com.viewsh.framework.common.pojo.CommonResult;
import com.viewsh.module.infra.api.file.dto.FileCreateReqDTO; import com.viewsh.module.infra.api.file.dto.FileCreateReqDTO;
import com.viewsh.module.infra.enums.ApiConstants; import com.viewsh.module.infra.enums.ApiConstants;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotEmpty;
import org.springframework.cloud.openfeign.FeignClient; import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
@FeignClient(name = ApiConstants.NAME) // TODO 芋艿fallbackFactory = @FeignClient(name = ApiConstants.NAME) // TODO 芋艿fallbackFactory =
@Tag(name = "RPC 服务 - 文件") @Tag(name = "RPC 服务 - 文件")
public interface FileApi { public interface FileApi {
String PREFIX = ApiConstants.PREFIX + "/file"; String PREFIX = ApiConstants.PREFIX + "/file";
/** /**
* 保存文件,并返回文件的访问路径 * 保存文件,并返回文件的访问路径
* *
* @param content 文件内容 * @param content 文件内容
* @return 文件路径 * @return 文件路径
*/ */
default String createFile(byte[] content) { default String createFile(byte[] content) {
return createFile(content, null, null, null); return createFile(content, null, null, null);
} }
/** /**
* 保存文件,并返回文件的访问路径 * 保存文件,并返回文件的访问路径
* *
* @param content 文件内容 * @param content 文件内容
* @param name 文件名称,允许空 * @param name 文件名称,允许空
* @return 文件路径 * @return 文件路径
*/ */
default String createFile(byte[] content, String name) { default String createFile(byte[] content, String name) {
return createFile(content, name, null, null); return createFile(content, name, null, null);
} }
/** /**
* 保存文件,并返回文件的访问路径 * 保存文件,并返回文件的访问路径
* *
* @param content 文件内容 * @param content 文件内容
* @param name 文件名称,允许空 * @param name 文件名称,允许空
* @param directory 目录,允许空 * @param directory 目录,允许空
* @param type 文件的 MIME 类型,允许空 * @param type 文件的 MIME 类型,允许空
* @return 文件路径 * @return 文件路径
*/ */
default String createFile(@NotEmpty(message = "文件内容不能为空") byte[] content, default String createFile(@NotEmpty(message = "文件内容不能为空") byte[] content,
String name, String directory, String type) { String name, String directory, String type) {
return createFile(new FileCreateReqDTO().setName(name).setDirectory(directory).setType(type).setContent(content)).getCheckedData(); return createFile(new FileCreateReqDTO().setName(name).setDirectory(directory).setType(type).setContent(content)).getCheckedData();
} }
@PostMapping(PREFIX + "/create") @PostMapping(PREFIX + "/create")
@Operation(summary = "保存文件,并返回文件的访问路径") @Operation(summary = "保存文件,并返回文件的访问路径")
CommonResult<String> createFile(@Valid @RequestBody FileCreateReqDTO createReqDTO); CommonResult<String> createFile(@Valid @RequestBody FileCreateReqDTO createReqDTO);
/** /**
* 生成文件预签名地址,用于读取 * 生成文件预签名地址,用于读取
* *
* @param url 完整的文件访问地址 * @param url 完整的文件访问地址
* @param expirationSeconds 访问有效期,单位秒 * @param expirationSeconds 访问有效期,单位秒
* @return 文件预签名地址 * @return 文件预签名地址
*/ */
@GetMapping(PREFIX + "/presigned-url") @GetMapping(PREFIX + "/presigned-url")
@Operation(summary = "生成文件预签名地址,用于读取") @Operation(summary = "生成文件预签名地址,用于读取")
CommonResult<String> presignGetUrl(@NotEmpty(message = "URL 不能为空") @RequestParam("url") String url, CommonResult<String> presignGetUrl(@NotEmpty(message = "URL 不能为空") @RequestParam("url") String url,
Integer expirationSeconds); @RequestParam(value = "expirationSeconds", required = false) Integer expirationSeconds);
} }

View File

@@ -1,233 +1,279 @@
package com.viewsh.module.infra.framework.file.core.client.s3; package com.viewsh.module.infra.framework.file.core.client.s3;
import cn.hutool.core.io.IoUtil; import cn.hutool.core.io.IoUtil;
import cn.hutool.core.util.BooleanUtil; import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpUtil; import cn.hutool.http.HttpUtil;
import com.viewsh.framework.common.util.http.HttpUtils; import com.viewsh.framework.common.util.http.HttpUtils;
import com.viewsh.module.infra.framework.file.core.client.AbstractFileClient; import com.viewsh.module.infra.framework.file.core.client.AbstractFileClient;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.S3Configuration; import software.amazon.awssdk.services.s3.S3Configuration;
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
import software.amazon.awssdk.services.s3.model.GetObjectRequest; import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.PutObjectRequest; import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.presigner.S3Presigner; 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.GetObjectPresignRequest;
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;
import java.net.URI; import java.net.URI;
import java.net.URL; import java.net.URL;
import java.time.Duration; import java.time.Duration;
/** /**
* 基于 S3 协议的文件客户端,实现 MinIO、阿里云、腾讯云、七牛云、华为云等云服务 * 基于 S3 协议的文件客户端,实现 MinIO、阿里云、腾讯云、七牛云、华为云等云服务
* *
* @author 芋道源码 * @author 芋道源码
*/ */
public class S3FileClient extends AbstractFileClient<S3FileClientConfig> { public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
private static final Duration EXPIRATION_DEFAULT = Duration.ofHours(24); private static final Duration EXPIRATION_DEFAULT = Duration.ofHours(24);
private S3Client client; private S3Client client;
private S3Presigner presigner; private S3Presigner presigner;
public S3FileClient(Long id, S3FileClientConfig config) { public S3FileClient(Long id, S3FileClientConfig config) {
super(id, config); super(id, config);
} }
@Override @Override
protected void doInit() { protected void doInit() {
// 补全 domain // 补全 domain
if (StrUtil.isEmpty(config.getDomain())) { if (StrUtil.isEmpty(config.getDomain())) {
config.setDomain(buildDomain()); config.setDomain(buildDomain());
} }
// 初始化 S3 客户端 // 初始化 S3 客户端
// 优先级:配置的 region > 从 endpoint 解析的 region > 默认值 us-east-1 // 优先级:配置的 region > 从 endpoint 解析的 region > 默认值 us-east-1
String regionStr = resolveRegion(); String regionStr = resolveRegion();
Region region = Region.of(regionStr); Region region = Region.of(regionStr);
AwsCredentialsProvider credentialsProvider = StaticCredentialsProvider.create( AwsCredentialsProvider credentialsProvider = StaticCredentialsProvider.create(
AwsBasicCredentials.create(config.getAccessKey(), config.getAccessSecret())); AwsBasicCredentials.create(config.getAccessKey(), config.getAccessSecret()));
URI endpoint = URI.create(buildEndpoint()); URI endpoint = URI.create(buildEndpoint());
S3Configuration serviceConfiguration = S3Configuration.builder() // Path-style 访问 S3Configuration serviceConfiguration = S3Configuration.builder() // Path-style 访问
.pathStyleAccessEnabled(Boolean.TRUE.equals(config.getEnablePathStyleAccess())) .pathStyleAccessEnabled(Boolean.TRUE.equals(config.getEnablePathStyleAccess()))
.chunkedEncodingEnabled(false) // 禁用分块编码,参见 https://t.zsxq.com/kBy57 .chunkedEncodingEnabled(false) // 禁用分块编码,参见 https://t.zsxq.com/kBy57
.build(); .build();
client = S3Client.builder() client = S3Client.builder()
.credentialsProvider(credentialsProvider) .credentialsProvider(credentialsProvider)
.region(region) .region(region)
.endpointOverride(endpoint) .endpointOverride(endpoint)
.serviceConfiguration(serviceConfiguration) .serviceConfiguration(serviceConfiguration)
.build(); .build();
presigner = S3Presigner.builder() presigner = S3Presigner.builder()
.credentialsProvider(credentialsProvider) .credentialsProvider(credentialsProvider)
.region(region) .region(region)
.endpointOverride(endpoint) .endpointOverride(endpoint)
.serviceConfiguration(serviceConfiguration) .serviceConfiguration(serviceConfiguration)
.build(); .build();
} }
@Override @Override
public String upload(byte[] content, String path, String type) { public String upload(byte[] content, String path, String type) {
// 构造 PutObjectRequest // 构造 PutObjectRequest
PutObjectRequest putRequest = PutObjectRequest.builder() PutObjectRequest putRequest = PutObjectRequest.builder()
.bucket(config.getBucket()) .bucket(config.getBucket())
.key(path) .key(path)
.contentType(type) .contentType(type)
.contentLength((long) content.length) .contentLength((long) content.length)
.build(); .build();
// 上传文件 // 上传文件
client.putObject(putRequest, RequestBody.fromBytes(content)); client.putObject(putRequest, RequestBody.fromBytes(content));
// 拼接返回路径 // 拼接返回路径
return presignGetUrl(path, null); return presignGetUrl(path, null);
} }
@Override @Override
public void delete(String path) { public void delete(String path) {
DeleteObjectRequest deleteRequest = DeleteObjectRequest.builder() DeleteObjectRequest deleteRequest = DeleteObjectRequest.builder()
.bucket(config.getBucket()) .bucket(config.getBucket())
.key(path) .key(path)
.build(); .build();
client.deleteObject(deleteRequest); client.deleteObject(deleteRequest);
} }
@Override @Override
public byte[] getContent(String path) { public byte[] getContent(String path) {
GetObjectRequest getRequest = GetObjectRequest.builder() GetObjectRequest getRequest = GetObjectRequest.builder()
.bucket(config.getBucket()) .bucket(config.getBucket())
.key(path) .key(path)
.build(); .build();
return IoUtil.readBytes(client.getObject(getRequest)); return IoUtil.readBytes(client.getObject(getRequest));
} }
@Override @Override
public String presignPutUrl(String path) { public String presignPutUrl(String path) {
return presigner.presignPutObject(PutObjectPresignRequest.builder() return presigner.presignPutObject(PutObjectPresignRequest.builder()
.signatureDuration(EXPIRATION_DEFAULT) .signatureDuration(EXPIRATION_DEFAULT)
.putObjectRequest(b -> b.bucket(config.getBucket()).key(path)).build()) .putObjectRequest(b -> b.bucket(config.getBucket()).key(path)).build())
.url().toString(); .url().toString();
} }
@Override @Override
public String presignGetUrl(String url, Integer expirationSeconds) { public String presignGetUrl(String url, Integer expirationSeconds) {
// 1. 将 url 转换为 path // 1. 将 url 转换为 path 和 bucket
String path = StrUtil.removePrefix(url, config.getDomain() + "/"); String bucket = config.getBucket();
path = HttpUtils.decodeUtf8(HttpUtils.removeUrlQuery(path)); boolean crossBucket = false;
String path = StrUtil.removePrefix(url, config.getDomain() + "/");
// 2.1 情况一:公开访问:无需签名 // 兼容:域名不匹配时(如同账号不同桶),从 URL 中解析 bucket 和 path
// 考虑到老版本的兼容,所以必须是 config.getEnablePublicAccess() 为 false 时,才进行签名 if (path.equals(url) && (path.startsWith("http://") || path.startsWith("https://"))) {
if (!BooleanUtil.isFalse(config.getEnablePublicAccess())) { try {
return config.getDomain() + "/" + path; URI uri = URI.create(url);
} String host = uri.getHost();
String endpointHost = getEndpointHost();
// 2.2 情况二:私有访问:生成 GET 预签名 URL // 校验是否同一 S3 服务(如 cos.ap-shanghai.myqcloud.com避免误签非本服务 URL
String finalPath = path; if (host != null && endpointHost != null && host.endsWith("." + endpointHost)) {
Duration expiration = expirationSeconds != null ? Duration.ofSeconds(expirationSeconds) : EXPIRATION_DEFAULT; bucket = StrUtil.subBefore(host, ".", false);
URL signedUrl = presigner.presignGetObject(GetObjectPresignRequest.builder() crossBucket = true;
.signatureDuration(expiration) path = StrUtil.removePrefix(uri.getPath(), "/");
.getObjectRequest(b -> b.bucket(config.getBucket()).key(finalPath)).build()) } else {
.url(); // 非同服务 URL原样返回
return signedUrl.toString(); return url;
} }
} catch (Exception ignored) {
/** return url;
* 基于 bucket + endpoint 构建访问的 Domain 地址 }
* }
* @return Domain 地址 path = HttpUtils.decodeUtf8(HttpUtils.removeUrlQuery(path));
*/
private String buildDomain() { // 2.1 情况一:公开访问:无需签名
// 如果已经是 http 或者 https则不进行拼接.主要适配 MinIO if (!BooleanUtil.isFalse(config.getEnablePublicAccess())) {
if (HttpUtil.isHttp(config.getEndpoint()) || HttpUtil.isHttps(config.getEndpoint())) { // 跨桶 URL 原样返回,不拼接本桶 domain
return StrUtil.format("{}/{}", config.getEndpoint(), config.getBucket()); if (crossBucket) {
} return url;
// 阿里云、腾讯云、华为云都适合。七牛云比较特殊,必须有自定义域名 }
return StrUtil.format("https://{}.{}", config.getBucket(), config.getEndpoint()); return config.getDomain() + "/" + path;
} }
/** // 2.2 情况二:私有访问:生成 GET 预签名 URL
* 节点地址补全协议头 String finalPath = path;
* String finalBucket = bucket;
* @return 节点地址 Duration expiration = expirationSeconds != null ? Duration.ofSeconds(expirationSeconds) : EXPIRATION_DEFAULT;
*/ URL signedUrl = presigner.presignGetObject(GetObjectPresignRequest.builder()
private String buildEndpoint() { .signatureDuration(expiration)
// 如果已经是 http 或者 https则不进行拼接 .getObjectRequest(b -> b.bucket(finalBucket).key(finalPath)).build())
if (HttpUtil.isHttp(config.getEndpoint()) || HttpUtil.isHttps(config.getEndpoint())) { .url();
return config.getEndpoint(); return signedUrl.toString();
} }
return StrUtil.format("https://{}", config.getEndpoint());
} /**
* 获取 endpoint 的 host 部分(不含协议头)
/** * 例如https://cos.ap-shanghai.myqcloud.com → cos.ap-shanghai.myqcloud.com
* 解析 AWS 区域 */
* 优先级:配置的 region > 从 endpoint 解析的 region > 默认值 us-east-1 private String getEndpointHost() {
* String endpoint = config.getEndpoint();
* @return 区域字符串 if (HttpUtil.isHttp(endpoint) || HttpUtil.isHttps(endpoint)) {
*/ try {
private String resolveRegion() { return URI.create(endpoint).getHost();
// 1. 如果配置了 region直接使用 } catch (Exception e) {
if (StrUtil.isNotEmpty(config.getRegion())) { return null;
return config.getRegion(); }
} }
return endpoint;
// 2.1 尝试从 endpoint 中解析 region }
String endpoint = config.getEndpoint();
if (StrUtil.isEmpty(endpoint)) { /**
return "us-east-1"; * 基于 bucket + endpoint 构建访问的 Domain 地址
} *
* @return Domain 地址
// 2.2 移除协议头http:// 或 https:// */
String host = endpoint; private String buildDomain() {
if (HttpUtil.isHttp(endpoint) || HttpUtil.isHttps(endpoint)) { if (HttpUtil.isHttp(config.getEndpoint()) || HttpUtil.isHttps(config.getEndpoint())) {
try { // Path-Style如 MinIO{endpoint}/{bucket}
host = URI.create(endpoint).getHost(); if (Boolean.TRUE.equals(config.getEnablePathStyleAccess())) {
} catch (Exception e) { return StrUtil.format("{}/{}", config.getEndpoint(), config.getBucket());
// 解析失败,使用默认值 }
return "us-east-1"; // Virtual-Hosted-Style如腾讯云 COS、阿里云 OSS{scheme}://{bucket}.{host}
} URI uri = URI.create(config.getEndpoint());
} return StrUtil.format("{}://{}.{}", uri.getScheme(), config.getBucket(), uri.getHost());
if (StrUtil.isEmpty(host)) { }
return "us-east-1"; // 没有协议头,直接拼接为虚拟主机风格
} return StrUtil.format("https://{}.{}", config.getBucket(), config.getEndpoint());
}
// 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")); * @return 节点地址
if (StrUtil.isNotEmpty(regionPart) && !regionPart.equals("accelerate")) { */
return regionPart; private String buildEndpoint() {
} // 如果已经是 http 或者 https则不进行拼接
} if (HttpUtil.isHttp(config.getEndpoint()) || HttpUtil.isHttps(config.getEndpoint())) {
// s3.amazonaws.com 或 s3-accelerate.amazonaws.com 使用默认值 return config.getEndpoint();
return "us-east-1"; }
} return StrUtil.format("https://{}", config.getEndpoint());
// 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)) { * 解析 AWS 区域
String regionPart = host.substring(4, host.indexOf("." + S3FileClientConfig.ENDPOINT_ALIYUN)); * 优先级:配置的 region > 从 endpoint 解析的 region > 默认值 us-east-1
if (StrUtil.isNotEmpty(regionPart)) { *
return regionPart; * @return 区域字符串
} */
} private String resolveRegion() {
} // 1. 如果配置了 region直接使用
// 3.3 腾讯云 COS 格式cos.ap-shanghai.myqcloud.com if (StrUtil.isNotEmpty(config.getRegion())) {
if (host.contains(S3FileClientConfig.ENDPOINT_TENCENT)) { return config.getRegion();
// 匹配 cos.{region}.myqcloud.com 格式 }
if (host.startsWith("cos.") && host.contains("." + S3FileClientConfig.ENDPOINT_TENCENT)) {
String regionPart = host.substring(4, host.indexOf("." + S3FileClientConfig.ENDPOINT_TENCENT)); // 2.1 尝试从 endpoint 中解析 region
if (StrUtil.isNotEmpty(regionPart)) { String endpoint = config.getEndpoint();
return regionPart; if (StrUtil.isEmpty(endpoint)) {
} return "us-east-1";
} }
}
// 2.2 移除协议头http:// 或 https://
// 3.4 其他情况MinIO、七牛云等使用默认值 String host = endpoint;
return "us-east-1"; 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";
}
}