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;
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<String> createFile(@Valid @RequestBody FileCreateReqDTO createReqDTO);
/**
* 生成文件预签名地址,用于读取
*
* @param url 完整的文件访问地址
* @param expirationSeconds 访问有效期,单位秒
* @return 文件预签名地址
*/
@GetMapping(PREFIX + "/presigned-url")
@Operation(summary = "生成文件预签名地址,用于读取")
CommonResult<String> 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<String> createFile(@Valid @RequestBody FileCreateReqDTO createReqDTO);
/**
* 生成文件预签名地址,用于读取
*
* @param url 完整的文件访问地址
* @param expirationSeconds 访问有效期,单位秒
* @return 文件预签名地址
*/
@GetMapping(PREFIX + "/presigned-url")
@Operation(summary = "生成文件预签名地址,用于读取")
CommonResult<String> presignGetUrl(@NotEmpty(message = "URL 不能为空") @RequestParam("url") String url,
@RequestParam(value = "expirationSeconds", required = false) Integer expirationSeconds);
}

View File

@@ -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<S3FileClientConfig> {
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<S3FileClientConfig> {
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";
}
}