diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8333276..267e913 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -249,15 +249,15 @@ jobs: working-directory: infrastructure run: docker compose config --quiet - # 2026-09-22:compose 里的 minio 已从 Docker Hub 迁到 quay.io 并钉版 - # (RELEASE.2025-09-07T16-13-09Z,版本由本步骤上一轮的实测输出确认)。 + # 2026-09-22:compose 的对象存储已换成 chrislusf/seaweedfs:4.47 并钉版。 # 单独保留这一步的理由:镜像拉取失败在 compose up 里会埋进几十行输出, # 这里能在起栈前秒级暴露「上游镜像下架」这类外部依赖失效 —— - # 上一轮正是靠日志里的 pull access denied 才定位到 MinIO 已移除 Docker Hub 仓库。 - - name: 校验 minio 钉版镜像可拉取并打印版本 + # 上一轮正是靠日志里的 pull access denied 才定位到 MinIO 已移除 Docker Hub 仓库 + # (minio/minio 现返回 404;教训是「第三方镜像」本身就是外部依赖风险点)。 + - name: 校验 seaweedfs 钉版镜像可拉取并打印版本 run: | - docker pull quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z - docker run --rm quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z --version + docker pull chrislusf/seaweedfs:4.47 + docker run --rm chrislusf/seaweedfs:4.47 version # 保证库为全新:seed 用 `ON CONFLICT (username) DO NOTHING`,若卷是上一轮残留, # 账号密码会停留在上一轮的 `E2E_OPERATOR_PASSWORD` 上 → 本轮登录 401(表现为各种业务失败)。 @@ -270,11 +270,14 @@ jobs: working-directory: infrastructure run: docker compose build backend - - name: 起真实栈(postgres + redis + minio + backend) - # 显式列出 minio:backend 的 depends_on 虽会隐式拉起它,但隐式依赖不利于阅读, - # 且 minio 的 healthcheck(`mc ready local`)是 backend 能否启动的前置条件。 + - name: 起真实栈(postgres + redis + seaweedfs + 建桶 + backend) + # 显式列出 seaweedfs 与 storage-init:backend 的 depends_on 虽会隐式拉起它们, + # 但隐式依赖不利于阅读,且 seaweedfs 的 healthcheck 是 backend 启动的前置条件。 + # storage-init 是**一次性**建桶容器 —— SeaweedFS 不会自动建桶, + # PutObject 到不存在的 bucket 会直接失败,少了它照片链路就是断的 + # (E2E 对存储零覆盖,不会替你发现)。 working-directory: infrastructure - run: docker compose up -d postgres redis minio backend + run: docker compose up -d postgres redis seaweedfs storage-init backend - name: 等待 backend 就绪(超时即失败,不静默跳过) # 窗口 90×5s=7.5min:首次构建后还需 Spring 启动 + Flyway 46 个迁移,5min 偏紧。 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 bbebc86..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() @@ -37,6 +44,48 @@ public class S3Config { new AwsClientBuilder.EndpointConfiguration(endpoint, region)) .withCredentials(new AWSStaticCredentialsProvider( new BasicAWSCredentials(accessKey, secretKey))) + // 必须显式开启 path-style。 + // + // AWS SDK v1 默认是 virtual-host 风格,会把 bucket 拼进主机名: + // endpoint=http://seaweedfs:8333 -> http://private.seaweedfs:8333/... + // 而 `seaweedfs` 是 Docker 内部服务名,`private.seaweedfs` 没有任何 DNS 记录, + // 请求必然失败。private 部署的对象存储几乎都用 IP 或容器名, + // virtual-host 天然不可用 —— path-style(http://host:port/bucket/key)才是正解。 + // + // 这一点在换存储前就已埋着:MinIO 时代之所以没暴露,是因为对象存储链路 + // 从未被任何测试真正走过(E2E 对存储零覆盖),而不是因为配置正确。 + // + // 排查提醒:本机有 http_proxy 时,*.localhost 这类 virtual-host 域名 + // 不在 NO_PROXY 内会走代理并返回 502,看起来像「存储不支持」, + // 实为代理假象 —— 判据里 502 不能当作「被拒绝」。 + .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 b5f8436..95e1005 100644 --- a/infrastructure/docker-compose.prod.yml +++ b/infrastructure/docker-compose.prod.yml @@ -39,23 +39,67 @@ services: - danyang-net restart: unless-stopped - minio: - # 同 docker-compose.yml 的 minio 条目:Docker Hub 上 minio/minio 仓库已被整体移除 - # (MinIO 自 2025-10 起停止发布容器镜像,仅源码分发),生产环境同样拉不到。 - # 改为仍在提供镜像的 quay.io —— 这是真实的部署阻塞,不只是 CI 问题。 - # 版本与本地/CI 保持一致,钉死为 CI 实测确认可用的 RELEASE.2025-09-07T16-13-09Z。 - image: quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z - container_name: danyang-match-minio - command: server /data --console-address ":9001" + seaweedfs: + # 同 docker-compose.yml:对象存储已从 MinIO 换成 SeaweedFS。 + # 生产换的动力比 CI 更强 —— MinIO 仓库已归档、不再产出安全补丁, + # 而这里存的是身份证与实名材料。详见 docker-compose.yml 中的完整说明。 + # 版本与本地/CI 一致(钉 4.47),避免各环境跑不同实现。 + image: chrislusf/seaweedfs:4.47 + container_name: danyang-match-seaweedfs + # 必须覆盖 entrypoint:镜像 ENTRYPOINT 是 /usr/bin/weed,只写 command 会被追加成 + # `weed sh -c ...` → `weed: unknown subcommand "sh"`(本地实测踩到)。 + entrypoint: ["sh", "-c"] + command: + - | + cat > /tmp/s3cfg.json < $$code" + done + networks: + - danyang-net + restart: "no" + backend: build: context: ../backend @@ -66,6 +110,10 @@ services: condition: service_healthy redis: condition: service_healthy + seaweedfs: + condition: service_healthy + storage-init: + condition: service_completed_successfully environment: SPRING_PROFILES_ACTIVE: docker,production SPRING_CONFIG_IMPORT: configtree:/run/secrets/ @@ -76,7 +124,13 @@ services: REDIS_HOST: redis REDIS_PORT: 6379 REDIS_PASSWORD: ${REDIS_PASSWORD:?请设置REDIS_PASSWORD} - MINIO_ENDPOINT: http://minio:9000 + # 变量名仍为 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} @@ -138,7 +192,7 @@ services: volumes: postgres_data: redis_data: - minio_data: + seaweedfs_data: networks: danyang-net: diff --git a/infrastructure/docker-compose.staging.yml b/infrastructure/docker-compose.staging.yml index be1e8b6..ba8924e 100644 --- a/infrastructure/docker-compose.staging.yml +++ b/infrastructure/docker-compose.staging.yml @@ -31,20 +31,61 @@ services: timeout: 5s retries: 5 - minio: - # 同 docker-compose.yml 的 minio 条目:Docker Hub 上 minio/minio 仓库已被整体移除 - # (MinIO 自 2025-10 起停止发布容器镜像,仅源码分发),staging 环境同样拉不到。 - # 改为仍在提供镜像的 quay.io,并钉死为与本地/CI 一致的 RELEASE.2025-09-07T16-13-09Z。 - image: quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z - command: server /data --console-address ":9001" + seaweedfs: + # 同 docker-compose.yml:对象存储已从 MinIO 换成 SeaweedFS(钉 4.47)。 + # staging 与本地/生产保持同一实现,避免「只有预发用的是另一套存储」这类偏差。 + image: chrislusf/seaweedfs:4.47 + container_name: danyang-match-staging-seaweedfs + # 必须覆盖 entrypoint:镜像 ENTRYPOINT 是 /usr/bin/weed,只写 command 会被追加成 + # `weed sh -c ...` → `weed: unknown subcommand "sh"`(本地实测踩到)。 + entrypoint: ["sh", "-c"] + command: + - | + cat > /tmp/s3cfg.json < $$code" + done + restart: "no" backend: build: @@ -66,7 +107,9 @@ services: REDIS_HOST: redis REDIS_PORT: 6379 REDIS_PASSWORD: staging-redis-pass-change-me - MINIO_ENDPOINT: http://minio:9000 + 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 @@ -103,4 +146,4 @@ services: volumes: postgres_staging_data: redis_staging_data: - minio_staging_data: + seaweedfs_staging_data: diff --git a/infrastructure/docker-compose.yml b/infrastructure/docker-compose.yml index ab2779c..84b1e10 100644 --- a/infrastructure/docker-compose.yml +++ b/infrastructure/docker-compose.yml @@ -32,44 +32,99 @@ services: timeout: 5s retries: 5 - minio: - # 2026-09-22 修正:镜像源从 Docker Hub 迁到 quay.io。 + seaweedfs: + # 2026-09-22:对象存储从 MinIO 换成 SeaweedFS。 # - # 原 `minio/minio:RELEASE.2025-09-07T16-13-09Z` 在 CI 上根本拉不到,Actions 报: - # pull access denied for minio/minio, repository does not exist - # 问题不在 tag,而在**整个仓库已从 Docker Hub 移除**:MinIO 自 2025-10 起停止发布 - # 容器镜像(改为仅源码分发),其 GitHub 仓库于 2026-02 归档;Docker Hub 上 - # minio/minio 与 minio/mc 现均返回 404。quay.io/minio/minio 仍在提供镜像。 + # 动机不是「拉不到镜像」。MinIO 的 GitHub 仓库已于 2026-02 归档,**不再产出安全补丁**, + # 已累积 CVE-2025-62506 / CVE-2026-39414 / CVE-2026-40344 / CVE-2026-42600 且不修。 + # 本项目存身份证、实名材料与用户照片 —— 无人修 CVE 才是真正的驱动因素。 + # (Docker Hub 上 minio/minio 仓库自 2026-09-12 起返回 404 只是表象;quay.io 仍在提供, + # 2026-09-22 核实时发现 quay 上仍有 2026-04 的 hotfix tag,故「换源」可以再拖, + # 「无人修 CVE」不能拖。) # - # 本机此前正是用 quay.io 的镜像重打 tag 顶替才跑通的(见 known-risks KR-01), - # 但那只是**本地绕过**、没有同步回仓库,于是「本地能起栈、CI 拉不到」。 - # 现在统一改为 quay.io,让本地与 CI 拉同一个镜像,消除这处不一致。 + # 选型依据来自本机实测而非文档推断:本项目只用到极窄的 S3 操作面 + # —— presigned PUT/GET + putObject + 两个 bucket,无 versioning / lifecycle / policy。 + # SeaweedFS 4.47 在 path-style 下这三项全部通过且**字节一致**。 # - # 版本来源(2026-09-22 于 CI 实测):迁移初期先用了 quay.io 的 latest, - # 并在 e2e job 里打印实际版本,得到 - # minio version RELEASE.2025-09-07T16-13-09Z (commit-id=07c3a429bfed…) - # 即 latest 当前正指向这个版本 —— 与本仓库原先在 Docker Hub 上钉的 tag 完全一致。 - # 现在据此钉死,恢复「可复现」这一原则(若日后 quay 上的 latest 滚动到别的版本, - # 本配置不受影响)。 - # 长期风险:quay 上的 MinIO 镜像已冻结(最后发布 2025-09),同样可能下架, - # 届时需自建镜像或迁移到其他 S3 兼容存储 —— - # 「依赖第三方镜像」本身就是一条外部依赖风险。 - image: quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z - container_name: danyang-match-minio - command: server /data --console-address ":9001" + # 必须显式启用鉴权:SeaweedFS 的 S3 网关**默认完全不鉴权** —— 实测匿名 GET 私有对象 + # 200、匿名 PUT 200、匿名列举桶 200,AK/SK 形同虚设。对本项目的敏感数据不可接受。 + # 故用 -s3.config 注入 identity;配好后匿名访问回到 403(已实测)。 + # + # 版本钉 4.47(实测 `weed version` = 4.47 c50733600),不用 latest: + # 滚动 tag 会让 CI 拉到与本地验证时不同的镜像,属不可复现的偶发失败来源。 + image: chrislusf/seaweedfs:4.47 + container_name: danyang-match-seaweedfs + # 凭据不入库:compose 无法把变量插值进挂载文件的内容,故在容器内把环境变量 + # 渲染成 s3cfg.json 再交给 weed。(未加引号的 heredoc 是故意的:需要展开变量。) + # 必须覆盖 entrypoint:该镜像的 ENTRYPOINT 是 /usr/bin/weed, + # 只写 command 会被追加成 `weed sh -c ...` → `weed: unknown subcommand "sh"`(实测踩到)。 + entrypoint: ["sh", "-c"] + command: + - | + cat > /tmp/s3cfg.json </),无需 SigV4 签名, + # 因此可复用 seaweedfs 镜像自带的 curl,**不引入新镜像依赖**。 + # 实测该方式建的目录会被 S3 网关识别为 bucket:list_buckets 可见、 + # head_bucket 200、put_object 成功,且鉴权照常生效(匿名 GET 403)。 + # + # 幂等语义(实测):首次 POST=201,已存在 POST=409,GET 到目录=200。 + # 三者都视为「桶已就位」,故重跑 up 不会失败。 + image: chrislusf/seaweedfs:4.47 + container_name: danyang-match-storage-init + depends_on: + seaweedfs: + condition: service_healthy + entrypoint: + - sh + - -c + - | + for b in public private; do + code=$$(curl -s -o /dev/null -w '%{http_code}' "http://seaweedfs:8888/buckets/$$b/" || echo 000) + if [ "$$code" = "200" ]; then echo "bucket $$b: already exists"; continue; fi + i=0 + while [ $$i -lt 30 ]; do + code=$$(curl -s -o /dev/null -w '%{http_code}' -X POST "http://seaweedfs:8888/buckets/$$b/" || echo 000) + case "$$code" in 200|201|409) break ;; esac + i=$$((i+1)); sleep 2 + done + echo "bucket $$b: create -> $$code" + done + restart: "no" backend: build: @@ -81,8 +136,11 @@ services: condition: service_healthy redis: condition: service_healthy - minio: + seaweedfs: condition: service_healthy + storage-init: + # 一次性容器:桶没建好时后端任何 putObject / presigned 直传都会失败。 + condition: service_completed_successfully environment: SPRING_PROFILES_ACTIVE: docker DB_HOST: postgres @@ -93,7 +151,15 @@ services: REDIS_HOST: redis REDIS_PORT: 6379 REDIS_PASSWORD: ${REDIS_PASSWORD:?请在本地 .env 设置 REDIS_PASSWORD} - MINIO_ENDPOINT: http://minio:9000 + # 变量名仍沿用 MINIO_* 前缀(服务端 Bean 与 application-*.yml 都在读它), + # 但它现在指向 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} @@ -147,4 +213,4 @@ services: volumes: postgres_data: redis_data: - minio_data: + seaweedfs_data: diff --git a/infrastructure/nginx-production.conf b/infrastructure/nginx-production.conf index 9023e88..dc48f32 100644 --- a/infrastructure/nginx-production.conf +++ b/infrastructure/nginx-production.conf @@ -176,7 +176,7 @@ http { proxy_set_header X-Real-IP $remote_addr; } - # MinIO public bucket (read-only) + # SeaweedFS public bucket (read-only) location /uploads/ { proxy_pass http://backend/; proxy_set_header Host $host; diff --git a/infrastructure/scripts/health-check.sh b/infrastructure/scripts/health-check.sh index c7a3286..736a2e2 100644 --- a/infrastructure/scripts/health-check.sh +++ b/infrastructure/scripts/health-check.sh @@ -44,8 +44,8 @@ check_service "PostgreSQL" \ check_service "Redis" \ "redis-cli -h redis${REDIS_PASSWORD:+ -a ${REDIS_PASSWORD}} ping 2>/dev/null | grep -q PONG && echo OK || echo FAIL" -check_service "MinIO" \ - "curl -sf http://minio:9000/minio/health/live 2>/dev/null | grep -q '\"status\":\"online\"' && echo OK || echo FAIL" +check_service "SeaweedFS" \ + "curl -s -o /dev/null -w '%{http_code}' http://seaweedfs:8333/ 2>/dev/null | grep -qE '^(200|403)$' && echo OK || echo FAIL" check_service "Backend API" \ "curl -sf http://localhost:8080/api/v1/health/live 2>/dev/null | grep -q success && echo OK || echo FAIL" diff --git a/tests/e2e/storage_link_probe.py b/tests/e2e/storage_link_probe.py new file mode 100644 index 0000000..94045a8 --- /dev/null +++ b/tests/e2e/storage_link_probe.py @@ -0,0 +1,148 @@ +"""对象存储真实链路探针(手工/CI 外验证工具,非 pytest 用例)。 + +为什么需要它 +------------ +本项目 E2E 全套对对象存储**零覆盖**(2026-09-22 核实:65 例中无任何一例 +触及 photo / upload / presigned)。而 `generatePresignedUrl` 只是本地签名、 +不访问存储,因此"签名接口返回 200"完全不能证明对象真的能存进去。 + +这直接导致一类缺陷长期隐身:bucket 从未被任何代码或脚本创建过 +(2026-09-22 实证:MinIO /data 下只有 .minio.sys,mc cp 报 +`Bucket 'private' does not exist`),于是 `putObject` 必然失败,但所有 +测试仍然全绿。 + +本探针覆盖 PhotoService 的真实调用路径,并要求**字节级一致**: + A. 服务端直传 <- amazonS3.putObject + B. presigned PUT <- getUploadToken 下发的 uploadUrl(客户端直传) + C. presigned GET <- getSignedUrl 下发的展示 URL + D. addressing style <- path / virtual-host(后端 AWS SDK v1 未显式设置) + +用法(需 boto3 + requests,不在 CI 依赖里,故不在 CI 运行) +------------------------------------------------------- + # 正向:验证链路可用 + python tests/e2e/storage_link_probe.py --endpoint http://localhost:9003 \ + --ak --sk + + # 负向:不预建桶,应当失败 —— 用于证明本探针确实有判别力, + # 而不是恒绿。改动存储配置后务必先跑这一遍。 + python tests/e2e/storage_link_probe.py --endpoint http://localhost:9003 \ + --ak --sk --no-create-bucket +""" +import argparse +import sys + +import boto3 +import requests +from botocore.config import Config +from botocore.exceptions import ClientError + +PAYLOAD = b"danyang-storage-probe-" + b"x" * 512 +KEY_DIRECT = "probe/direct.bin" +KEY_SIGNED = "probe/signed.bin" + + +def build(endpoint, ak, sk, style): + return boto3.client( + "s3", + endpoint_url=endpoint, + aws_access_key_id=ak, + aws_secret_access_key=sk, + region_name="us-east-1", + config=Config(signature_version="s3v4", s3={"addressing_style": style}), + ) + + +def bucket_exists(s3, bucket): + try: + s3.head_bucket(Bucket=bucket) + return True, "exists" + except ClientError as e: + return False, e.response.get("Error", {}).get("Code", "?") + except Exception as e: # noqa: BLE001 - 探针需容忍任意后端异常 + return False, type(e).__name__ + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--endpoint", required=True) + ap.add_argument("--ak", required=True) + ap.add_argument("--sk", required=True) + ap.add_argument("--bucket", default="private") + ap.add_argument("--no-create-bucket", action="store_true", + help="不预建桶,用于负向验证(判据应当失败)") + a = ap.parse_args() + + failures = [] + print(f"===== 对象存储链路探针 @ {a.endpoint} =====") + + for style in ("path", "virtual"): + print(f"\n--- addressing_style={style} ---") + s3 = build(a.endpoint, a.ak, a.sk, style) + ok, detail = bucket_exists(s3, a.bucket) + + if not ok: + if a.no_create_bucket: + print(f" bucket: MISSING ({detail}) -> 负向模式,跳过写入") + failures.append(f"[{style}] bucket missing") + continue + try: + s3.create_bucket(Bucket=a.bucket) + print(f" bucket: 已创建 (was {detail})") + except Exception as e: # noqa: BLE001 + print(f" bucket: 创建失败 {type(e).__name__}: {str(e)[:120]}") + failures.append(f"[{style}] create_bucket") + continue + else: + print(f" bucket: {detail}") + + # A 服务端直传 + try: + s3.put_object(Bucket=a.bucket, Key=KEY_DIRECT, Body=PAYLOAD) + print(" A put_object : OK") + except Exception as e: # noqa: BLE001 + print(f" A put_object : FAIL {type(e).__name__}: {str(e)[:110]}") + failures.append(f"[{style}] put_object") + continue + + # B presigned PUT(客户端直传) + try: + url = s3.generate_presigned_url( + "put_object", Params={"Bucket": a.bucket, "Key": KEY_SIGNED}, ExpiresIn=600 + ) + r = requests.put(url, data=PAYLOAD, timeout=30) + if r.status_code not in (200, 201): + print(f" B presigned PUT : FAIL http={r.status_code} {r.text[:110]}") + failures.append(f"[{style}] presigned PUT") + continue + print(f" B presigned PUT : OK ({r.status_code})") + except Exception as e: # noqa: BLE001 + print(f" B presigned PUT : FAIL {type(e).__name__}: {str(e)[:110]}") + failures.append(f"[{style}] presigned PUT") + continue + + # C presigned GET(字节级比对) + try: + url = s3.generate_presigned_url( + "get_object", Params={"Bucket": a.bucket, "Key": KEY_SIGNED}, ExpiresIn=600 + ) + r = requests.get(url, timeout=30) + if r.status_code == 200 and r.content == PAYLOAD: + print(f" C presigned GET : OK ({len(r.content)} bytes, 字节一致)") + else: + print(f" C presigned GET : FAIL http={r.status_code} bytes={len(r.content)}") + failures.append(f"[{style}] presigned GET") + except Exception as e: # noqa: BLE001 + print(f" C presigned GET : FAIL {type(e).__name__}: {str(e)[:110]}") + failures.append(f"[{style}] presigned GET") + + print("\n===== 汇总 =====") + print(f"failures: {len(failures)}") + for f in failures: + print(" -", f) + if failures and a.no_create_bucket: + print("(负向模式:有 failures 才是正确结果,说明判据有判别力)") + return 0 if not failures else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/uat/run-uat.sh b/tests/uat/run-uat.sh index 5fa9595..9317cba 100644 --- a/tests/uat/run-uat.sh +++ b/tests/uat/run-uat.sh @@ -70,11 +70,14 @@ else log_result "Redis 连接" "FAIL" "Redis 不可用" fi -# MinIO -if curl -sf "http://localhost:9000/minio/health/live" 2>/dev/null | grep -q online; then - log_result "MinIO 连接" "PASS" +# SeaweedFS(S3 网关)。开启鉴权后匿名根请求返回 403,403 同样说明网关正在服务, +# 故 200/403 都算存活。宿主端口:本地 8333 / staging 8334,可用 UAT_STORAGE_S3_PORT 覆盖。 +S3_PORT="${UAT_STORAGE_S3_PORT:-8333}" +code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:${S3_PORT}/" 2>/dev/null || echo 000) +if [ "${code}" = "200" ] || [ "${code}" = "403" ]; then + log_result "SeaweedFS 连接" "PASS" else - log_result "MinIO 连接" "FAIL" "对象存储不可用" + log_result "SeaweedFS 连接" "FAIL" "对象存储不可用" fi echo ""