fix(storage): presigned URL 改用对外可达 host,修复客户端直传断链

SeaweedFS 换存储后 MINIO_ENDPOINT 变为 seaweedfs:8333(容器名),
PhotoService 生成的 presigned URL 把该 host 直接下发给客户端,
客户端不在 docker 网络内无法解析 → 直传/直链全断。

- S3Config:新增 amazonS3Signer Bean,指向 object-storage.public-endpoint
  (缺省回退内网 endpoint,向后兼容)。独立签名端是因为 SigV4 签名覆盖
  host 头,事后改 URL 字符串会让签名失效返 403;独立客户端让签名 host
  与客户端实际命中的 host 天然一致。
- PhotoService:去掉 @RequiredArgsConstructor,改显式 @Qualifier 构造器;
  getUploadToken / getSignedUrl / getSignedUrlForAuthorizedReview 三处
  presigned 生成改用 amazonS3Signer;putObject 服务端上传仍走内网 amazonS3。
- application-docker.yml / application-dev.yml:public-endpoint 读
  MINIO_PUBLIC_ENDPOINT(缺省回退 MINIO_ENDPOINT)。
- 三套 compose:注入 MINIO_PUBLIC_ENDPOINT
  (dev localhost:8333 / staging localhost:8334 / prod 留作单一旋钮)。
- 测试:PhotoServiceTest 新增嵌套 PresignedUrlPublicHostTest,用真实
  AmazonS3 客户端(仅本地签名不联网)断言下发 URL host 为对外地址、
  不含 seaweedfs 容器名;同步补齐两处 5→6 参构造调用。

验证:本机 65 E2E 全绿 + storage_link_probe path-style 全通 +
PhotoServiceTest/PhotoUploadValidationTest/PhotoLimitGuardTest 19 例单测全绿。

顺带:backend/maven-settings.xml 的 Central 兜底由投毒的 repo1.maven.org
改为可达的 repo.maven.apache.org,使本机镜像构建可走 aliyun 主源。
This commit is contained in:
WorkBuddy
2026-09-22 17:10:40 +08:00
parent 797b1c46cf
commit dc0a62d4c5
10 changed files with 214 additions and 20 deletions
@@ -10,9 +10,10 @@ import com.danyang.match.platform.shared.config.FeatureFlagConfig;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
import com.danyang.match.platform.shared.policy.ProfilePolicy;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -30,18 +31,36 @@ import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Service
@RequiredArgsConstructor
public class PhotoService {
private static final Logger log = LoggerFactory.getLogger(PhotoService.class);
private static final int SIGNED_URL_EXPIRE_HOURS = 24;
private final UserPhotoMapper photoMapper;
/** 后端服务内连存储用的内网客户端(putObject 等服务端上传走它)。 */
private final AmazonS3 amazonS3;
/** 给客户端签发 presigned URL 用的客户端,指向对外可达的 S3 网关(见 S3Config.amazonS3Signer)。 */
private final AmazonS3 amazonS3Signer;
private final FeatureFlagConfig featureFlags;
private final ProfilePolicy profilePolicy;
private final com.danyang.match.platform.user.mapper.UserMapper userMapper;
@Autowired
public PhotoService(
UserPhotoMapper photoMapper,
@Qualifier("amazonS3") AmazonS3 amazonS3,
FeatureFlagConfig featureFlags,
ProfilePolicy profilePolicy,
com.danyang.match.platform.user.mapper.UserMapper userMapper,
@Qualifier("amazonS3Signer") AmazonS3 amazonS3Signer) {
this.photoMapper = photoMapper;
this.amazonS3 = amazonS3;
this.amazonS3Signer = amazonS3Signer;
this.featureFlags = featureFlags;
this.profilePolicy = profilePolicy;
this.userMapper = userMapper;
}
@Value("${object-storage.bucket-private}")
private String privateBucket;
@@ -59,7 +78,7 @@ public class PhotoService {
GeneratePresignedUrlRequest putRequest = new GeneratePresignedUrlRequest(privateBucket, fileKey)
.withMethod(com.amazonaws.HttpMethod.PUT)
.withExpiration(expiration);
String uploadUrl = amazonS3.generatePresignedUrl(putRequest).toString();
String uploadUrl = amazonS3Signer.generatePresignedUrl(putRequest).toString();
Map<String, Object> token = new HashMap<>();
token.put("fileKey", fileKey);
@@ -84,7 +103,7 @@ public class PhotoService {
GeneratePresignedUrlRequest getRequest = new GeneratePresignedUrlRequest(privateBucket, fileKey)
.withMethod(com.amazonaws.HttpMethod.GET)
.withExpiration(expiration);
String url = amazonS3.generatePresignedUrl(getRequest).toString();
String url = amazonS3Signer.generatePresignedUrl(getRequest).toString();
Map<String, Object> result = new HashMap<>();
result.put("url", url);
@@ -98,7 +117,7 @@ public class PhotoService {
GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(privateBucket, fileKey)
.withMethod(com.amazonaws.HttpMethod.GET)
.withExpiration(expiration);
return Map.of("url", amazonS3.generatePresignedUrl(request).toString(), "expiresIn", 600);
return Map.of("url", amazonS3Signer.generatePresignedUrl(request).toString(), "expiresIn", 600);
}
/**
@@ -30,6 +30,13 @@ public class S3Config {
@Value("${object-storage.region:us-east-1}")
private String region;
/**
* 对外可达的 S3 网关地址,用于给客户端签发 presigned URL。
* 缺省时回退到 {@code endpoint}(后端内网地址),保持向后兼容。
*/
@Value("${object-storage.public-endpoint:}")
private String publicEndpoint;
@Bean
public AmazonS3 amazonS3() {
return AmazonS3ClientBuilder.standard()
@@ -54,4 +61,31 @@ public class S3Config {
.withPathStyleAccessEnabled(true)
.build();
}
/**
* 给客户端签发 presigned URL 用的客户端,必须指向「对外可达」的 S3 网关地址。
*
* <p>旧实现只有 {@link #amazonS3()} 一个客户端(endpoint = {@code seaweedfs:8333},
* 后端连存储用的 Docker 内网地址)。{@code generatePresignedUrl} 会把 endpoint 的 host
* 直接写进下发给客户端的 uploadUrl —— 客户端在 Docker 网络之外,解析不了 {@code seaweedfs},
* 直传必然失败。这是换存储前就埋着的缺陷(E2E 对存储零覆盖,一直没暴露)。
*
* <p>这里单独建一个指向 {@code public-endpoint} 的客户端,签出来的 URL host 就是客户端能连的地址。
* {@code public-endpoint} 缺省时回退到内网 {@code endpoint}(未配置时行为与旧版一致)。
*
* <p>为何用独立客户端、而不是事后改 URL 字符串的 host:AWS SigV4 的签名覆盖 {@code host}
* 请求头,客户端实际请求的 Host 必须与签名时一致,否则 SeaweedFS 返回 403。用独立客户端签发,
* 签名里的 host 就是 public host,客户端命中同一个 public host,天然一致,无需事后改字符串。
*/
@Bean
public AmazonS3 amazonS3Signer() {
String signEndpoint = (publicEndpoint == null || publicEndpoint.isBlank()) ? endpoint : publicEndpoint;
return AmazonS3ClientBuilder.standard()
.withEndpointConfiguration(
new AwsClientBuilder.EndpointConfiguration(signEndpoint, region))
.withCredentials(new AWSStaticCredentialsProvider(
new BasicAWSCredentials(accessKey, secretKey)))
.withPathStyleAccessEnabled(true)
.build();
}
}