diff --git a/backend/maven-settings.xml b/backend/maven-settings.xml index 6bcab58..11a4cf5 100644 --- a/backend/maven-settings.xml +++ b/backend/maven-settings.xml @@ -1,11 +1,49 @@ - - - central - central - Maven Central - https://repo1.maven.org/maven2 - - + + + + aliyun-with-central-fallback + + + aliyun-public + Aliyun Public + https://maven.aliyun.com/repository/public + true + false + + + central + Maven Central + https://repo.maven.apache.org/maven2 + true + false + + + + + aliyun-plugin-public + Aliyun Plugin Public + https://maven.aliyun.com/repository/public + true + false + + + central-plugin + Maven Central Plugins + https://repo.maven.apache.org/maven2 + true + false + + + + + + aliyun-with-central-fallback + diff --git a/backend/src/main/java/com/danyang/match/platform/photo/service/PhotoService.java b/backend/src/main/java/com/danyang/match/platform/photo/service/PhotoService.java index 8edc173..82bafd8 100644 --- a/backend/src/main/java/com/danyang/match/platform/photo/service/PhotoService.java +++ b/backend/src/main/java/com/danyang/match/platform/photo/service/PhotoService.java @@ -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 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 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); } /** diff --git a/backend/src/main/java/com/danyang/match/platform/shared/config/S3Config.java b/backend/src/main/java/com/danyang/match/platform/shared/config/S3Config.java index 1f2a5d8..ef8289d 100644 --- a/backend/src/main/java/com/danyang/match/platform/shared/config/S3Config.java +++ b/backend/src/main/java/com/danyang/match/platform/shared/config/S3Config.java @@ -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 网关地址。 + * + *

旧实现只有 {@link #amazonS3()} 一个客户端(endpoint = {@code seaweedfs:8333}, + * 后端连存储用的 Docker 内网地址)。{@code generatePresignedUrl} 会把 endpoint 的 host + * 直接写进下发给客户端的 uploadUrl —— 客户端在 Docker 网络之外,解析不了 {@code seaweedfs}, + * 直传必然失败。这是换存储前就埋着的缺陷(E2E 对存储零覆盖,一直没暴露)。 + * + *

这里单独建一个指向 {@code public-endpoint} 的客户端,签出来的 URL host 就是客户端能连的地址。 + * {@code public-endpoint} 缺省时回退到内网 {@code endpoint}(未配置时行为与旧版一致)。 + * + *

为何用独立客户端、而不是事后改 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(); + } } diff --git a/backend/src/main/resources/application-docker.yml b/backend/src/main/resources/application-docker.yml index 54c1478..17ef40a 100644 --- a/backend/src/main/resources/application-docker.yml +++ b/backend/src/main/resources/application-docker.yml @@ -28,6 +28,9 @@ jwt: object-storage: endpoint: ${MINIO_ENDPOINT:http://localhost:9000} + # 对外可达的 S3 网关地址:presigned URL 下发给客户端直传用。 + # 缺省回退到内网 endpoint(容器名),那时直传仍不可达 —— 运行环境必须显式配置。 + public-endpoint: ${MINIO_PUBLIC_ENDPOINT:${MINIO_ENDPOINT}} access-key: ${MINIO_ACCESS_KEY} secret-key: ${MINIO_SECRET_KEY} bucket-public: ${MINIO_BUCKET_PUBLIC:public} diff --git a/backend/src/test/java/com/danyang/match/platform/photo/PhotoLimitGuardTest.java b/backend/src/test/java/com/danyang/match/platform/photo/PhotoLimitGuardTest.java index a7e7ef6..07d2a14 100644 --- a/backend/src/test/java/com/danyang/match/platform/photo/PhotoLimitGuardTest.java +++ b/backend/src/test/java/com/danyang/match/platform/photo/PhotoLimitGuardTest.java @@ -46,7 +46,7 @@ class PhotoLimitGuardTest { when(mapper.insert(any(UserPhoto.class))).thenReturn(1); ProfilePolicy policy = mock(ProfilePolicy.class); when(policy.getMaxPhotoCount()).thenReturn(maxCount); - return new PhotoService(mapper, s3, mock(FeatureFlagConfig.class), policy, userMapper); + return new PhotoService(mapper, s3, mock(FeatureFlagConfig.class), policy, userMapper, s3); } private UserMapper mockUserMapper() { @@ -109,7 +109,7 @@ class PhotoLimitGuardTest { when(userMapper.lockUserRow(anyString())).thenReturn("u1"); ProfilePolicy policy = mock(ProfilePolicy.class); when(policy.getMaxPhotoCount()).thenReturn(6); - PhotoService svc = new PhotoService(mapper, s3, mock(FeatureFlagConfig.class), policy, userMapper); + PhotoService svc = new PhotoService(mapper, s3, mock(FeatureFlagConfig.class), policy, userMapper, s3); // 即便历史上传过 10 张(已软删),active=0 → 可再传 assertDoesNotThrow(() -> svc.ensureWithinPhotoLimit("u1", "lifestyle")); diff --git a/backend/src/test/java/com/danyang/match/platform/photo/PhotoServiceTest.java b/backend/src/test/java/com/danyang/match/platform/photo/PhotoServiceTest.java index 99df407..75592c1 100644 --- a/backend/src/test/java/com/danyang/match/platform/photo/PhotoServiceTest.java +++ b/backend/src/test/java/com/danyang/match/platform/photo/PhotoServiceTest.java @@ -1,13 +1,23 @@ package com.danyang.match.platform.photo; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.client.builder.AwsClientBuilder; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest; import com.danyang.match.platform.photo.service.PhotoService; import org.junit.jupiter.api.Test; +import java.util.Date; +import java.util.Map; +import java.util.concurrent.TimeUnit; + import static org.junit.jupiter.api.Assertions.*; class PhotoServiceTest { - private final PhotoService photoService = new PhotoService(null, null, null, null, null); + private final PhotoService photoService = new PhotoService(null, null, null, null, null, null); @Test void testValidateMagicNumberJpeg() { @@ -42,3 +52,82 @@ class PhotoServiceTest { assertFalse(photoService.isAllowedMimeType("text/plain")); } } + +/** + * 回归守卫:presigned URL 下发给客户端时必须指向「对外可达」地址,而非后端内网容器名。 + * + *

旧实现用内网 endpoint(seaweedfs:8333)签发,客户端在 Docker 网络外解析不了该主机名, + * 直传必然失败。这里用真实 AmazonS3 客户端(仅本地签名、不联网)重现 S3Config 的双客户端逻辑: + * 内网客户端 = seaweedfs:8333,签发客户端 = 对外地址;断言下发的 URL host 是后者、不含容器名。 + * + *

负向判据:若有人把 PhotoService 改回用 {@code amazonS3}(内网)签发,URL 会变成 + * seaweedfs:8333,本测试立即失败 —— 证明守卫有效,而非恒绿。 + */ +class PresignedUrlPublicHostTest { + + private static AmazonS3 buildClient(String endpoint) { + return AmazonS3ClientBuilder.standard() + .withEndpointConfiguration(new AwsClientBuilder.EndpointConfiguration(endpoint, "us-east-1")) + .withCredentials(new AWSStaticCredentialsProvider(new BasicAWSCredentials("ak", "sk"))) + .withPathStyleAccessEnabled(true) + .build(); + } + + /** @Value 字段在 new 构造下为 null,这里用反射补上桶名,否则 generatePresignedUrl 会因桶名为空抛 IllegalArgument。 */ + private static void setPrivateBucket(PhotoService svc, String bucket) throws Exception { + var f = PhotoService.class.getDeclaredField("privateBucket"); + f.setAccessible(true); + f.set(svc, bucket); + } + + @Test + void uploadTokenUrlUsesPublicEndpointNotInternal() throws Exception { + AmazonS3 internal = buildClient("http://seaweedfs:8333"); + AmazonS3 signer = buildClient("http://localhost:8333"); + PhotoService svc = new PhotoService(null, internal, null, null, null, signer); + setPrivateBucket(svc, "private"); + + Map token = svc.getUploadToken("u1", "avatar"); + String uploadUrl = (String) token.get("uploadUrl"); + + assertTrue(uploadUrl.startsWith("http://localhost:8333/"), + "presigned upload URL 必须指向对外可达地址,实际: " + uploadUrl); + assertFalse(uploadUrl.contains("seaweedfs"), + "presigned upload URL 不得包含内网容器名,实际: " + uploadUrl); + } + + @Test + void reviewSignedUrlUsesPublicEndpoint() throws Exception { + AmazonS3 internal = buildClient("http://seaweedfs:8333"); + AmazonS3 signer = buildClient("http://localhost:8334"); + PhotoService svc = new PhotoService(null, internal, null, null, null, signer); + setPrivateBucket(svc, "private"); + + Map r = svc.getSignedUrlForAuthorizedReview("photos/u1/avatar/x.jpg"); + String url = (String) r.get("url"); + + assertTrue(url.startsWith("http://localhost:8334/"), + "审核签名 URL 必须指向对外可达地址,实际: " + url); + assertFalse(url.contains("seaweedfs"), + "审核签名 URL 不得包含内网容器名,实际: " + url); + } + + @Test + void getSignedUrlForDisplayUsesPublicEndpoint() { + AmazonS3 internal = buildClient("http://seaweedfs:8333"); + AmazonS3 signer = buildClient("https://storage.example.com"); + PhotoService svc = new PhotoService(null, internal, null, null, null, signer); + + // 复刻 getSignedUrl 的签名逻辑(避免触发 DB 查询):直接签名一个 GET 请求 + Date expiration = new Date(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1)); + GeneratePresignedUrlRequest req = new GeneratePresignedUrlRequest("private", "photos/u1/avatar/x.jpg") + .withMethod(com.amazonaws.HttpMethod.GET) + .withExpiration(expiration); + String url = signer.generatePresignedUrl(req).toString(); + + assertTrue(url.startsWith("https://storage.example.com/"), + "展示签名 URL 必须指向对外可达地址,实际: " + url); + assertFalse(url.contains("seaweedfs"), + "展示签名 URL 不得包含内网容器名,实际: " + url); + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/photo/PhotoUploadValidationTest.java b/backend/src/test/java/com/danyang/match/platform/photo/PhotoUploadValidationTest.java index 4e59dda..7215598 100644 --- a/backend/src/test/java/com/danyang/match/platform/photo/PhotoUploadValidationTest.java +++ b/backend/src/test/java/com/danyang/match/platform/photo/PhotoUploadValidationTest.java @@ -39,7 +39,7 @@ class PhotoUploadValidationTest { private PhotoService newService(AmazonS3 s3, UserPhotoMapper mapper, FeatureFlagConfig flags) { return new PhotoService(mapper, s3, flags, new DefaultProfilePolicy(new ProfileProperties()), - org.mockito.Mockito.mock(com.danyang.match.platform.user.mapper.UserMapper.class)); + org.mockito.Mockito.mock(com.danyang.match.platform.user.mapper.UserMapper.class), s3); } private FeatureFlagConfig localEvidenceFlags(int maxMB, String allowedTypes) { diff --git a/infrastructure/docker-compose.prod.yml b/infrastructure/docker-compose.prod.yml index 20a2176..95e1005 100644 --- a/infrastructure/docker-compose.prod.yml +++ b/infrastructure/docker-compose.prod.yml @@ -126,6 +126,11 @@ services: REDIS_PASSWORD: ${REDIS_PASSWORD:?请设置REDIS_PASSWORD} # 变量名仍为 MINIO_*(服务端配置键未改),实际指向 SeaweedFS 的 S3 网关。 MINIO_ENDPOINT: http://seaweedfs:8333 + # 客户端直传 presigned URL 用的「对外可达」地址。 + # 生产 S3 网关当前未 host-publish(客户端经 nginx/ingress 访问),故缺省仍是容器名 + # —— 即「presigned host 对外可达」缺陷在生产仍待配置 ingress 后才彻底解决。 + # 一旦生产 S3 网关暴露了对外地址,把该变量设成那个地址即可,无需再改代码。 + MINIO_PUBLIC_ENDPOINT: ${MINIO_PUBLIC_ENDPOINT:http://seaweedfs:8333} MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:?请设置MINIO_ACCESS_KEY} MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:?请设置MINIO_SECRET_KEY} JWT_ACCESS_SECRET: ${JWT_ACCESS_SECRET:?请设置JWT_ACCESS_SECRET} diff --git a/infrastructure/docker-compose.staging.yml b/infrastructure/docker-compose.staging.yml index 953c12d..ba8924e 100644 --- a/infrastructure/docker-compose.staging.yml +++ b/infrastructure/docker-compose.staging.yml @@ -108,6 +108,8 @@ services: REDIS_PORT: 6379 REDIS_PASSWORD: staging-redis-pass-change-me MINIO_ENDPOINT: http://seaweedfs:8333 + # 客户端直传 presigned URL 用:staging S3 网关 publish 到宿主 8334。 + MINIO_PUBLIC_ENDPOINT: http://localhost:8334 MINIO_ACCESS_KEY: staging MINIO_SECRET_KEY: staging_dev_password JWT_ACCESS_SECRET: staging-access-secret-dev-only diff --git a/infrastructure/docker-compose.yml b/infrastructure/docker-compose.yml index fcd389a..84b1e10 100644 --- a/infrastructure/docker-compose.yml +++ b/infrastructure/docker-compose.yml @@ -152,10 +152,14 @@ services: REDIS_PORT: 6379 REDIS_PASSWORD: ${REDIS_PASSWORD:?请在本地 .env 设置 REDIS_PASSWORD} # 变量名仍沿用 MINIO_* 前缀(服务端 Bean 与 application-*.yml 都在读它), - # 但它现在指向 SeaweedFS 的 S3 网关 —— 属历史命名,待下一轮统一改名: - # 那时会同时处理「presigned URL 的 host 必须是对外可达地址」这一缺陷, - # 届时 endpoint 语义会从单一值拆成「内网签发地址 + 外网访问地址」。 + # 但它现在指向 SeaweedFS 的 S3 网关 —— 属历史命名,待下一轮统一改名。 + # 「presigned URL 的 host 必须是对外可达地址」缺陷已修复:endpoint 拆成 + # 内网签发地址(MINIO_ENDPOINT=seaweedfs:8333,后端连存储)+ + # 外网访问地址(MINIO_PUBLIC_ENDPOINT=localhost:8333,客户端直传)。 MINIO_ENDPOINT: http://seaweedfs:8333 + # 对内网 endpoint 拆出「对外可达」地址:客户端直传 presigned URL 用。 + # 本地 S3 网关已 publish 到宿主 8333,故客户端填 localhost:8333 即可连通。 + MINIO_PUBLIC_ENDPOINT: http://localhost:8333 MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:?请在本地 .env 设置 MINIO_ACCESS_KEY} MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:?请在本地 .env 设置 MINIO_SECRET_KEY} JWT_ACCESS_SECRET: ${JWT_ACCESS_SECRET:?请在本地 .env 设置 JWT_ACCESS_SECRET}