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/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..1f2a5d8 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 @@ -37,6 +37,21 @@ 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(); } } diff --git a/infrastructure/docker-compose.prod.yml b/infrastructure/docker-compose.prod.yml index b5f8436..20a2176 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,8 @@ 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 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 +187,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..953c12d 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,7 @@ services: REDIS_HOST: redis REDIS_PORT: 6379 REDIS_PASSWORD: staging-redis-pass-change-me - MINIO_ENDPOINT: http://minio:9000 + MINIO_ENDPOINT: http://seaweedfs:8333 MINIO_ACCESS_KEY: staging MINIO_SECRET_KEY: staging_dev_password JWT_ACCESS_SECRET: staging-access-secret-dev-only @@ -103,4 +144,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..fcd389a 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,11 @@ 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: http://seaweedfs: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 +209,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/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 ""