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 ""