# 丹阳公益婚恋服务平台 - 本地完整环境 services: postgres: image: postgres:16-alpine container_name: danyang-match-postgres environment: POSTGRES_DB: danyang_match POSTGRES_USER: postgres POSTGRES_PASSWORD: ${DB_PASSWORD:?请在本地 .env 设置 DB_PASSWORD} ports: - "5434:5432" volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres -d danyang_match"] interval: 10s timeout: 5s retries: 5 redis: image: redis:7-alpine container_name: danyang-match-redis ports: - "6381:6379" command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:?请在本地 .env 设置 REDIS_PASSWORD} volumes: - redis_data:/data healthcheck: test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:?请在本地 .env 设置 REDIS_PASSWORD}", "ping"] interval: 10s timeout: 5s retries: 5 seaweedfs: # 2026-09-22:对象存储从 MinIO 换成 SeaweedFS。 # # 动机不是「拉不到镜像」。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」不能拖。) # # 选型依据来自本机实测而非文档推断:本项目只用到极窄的 S3 操作面 # —— presigned PUT/GET + putObject + 两个 bucket,无 versioning / lifecycle / policy。 # SeaweedFS 4.47 在 path-style 下这三项全部通过且**字节一致**。 # # 必须显式启用鉴权: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: context: ../backend dockerfile: Dockerfile container_name: danyang-match-backend depends_on: postgres: condition: service_healthy redis: condition: service_healthy seaweedfs: condition: service_healthy storage-init: # 一次性容器:桶没建好时后端任何 putObject / presigned 直传都会失败。 condition: service_completed_successfully environment: SPRING_PROFILES_ACTIVE: docker DB_HOST: postgres DB_PORT: 5432 DB_NAME: danyang_match DB_USER: postgres DB_PASSWORD: ${DB_PASSWORD:?请在本地 .env 设置 DB_PASSWORD} REDIS_HOST: redis REDIS_PORT: 6379 REDIS_PASSWORD: ${REDIS_PASSWORD:?请在本地 .env 设置 REDIS_PASSWORD} # 变量名仍沿用 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} JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:?请在本地 .env 设置 JWT_REFRESH_SECRET} ENCRYPTION_KEY: ${ENCRYPTION_KEY:?请在本地 .env 设置 ENCRYPTION_KEY} WECHAT_APP_ID: dev-wechat-app-id WECHAT_APP_SECRET: ${WECHAT_APP_SECRET:-} ports: - "8085:8080" healthcheck: # 与 nginx 同理,用 127.0.0.1 而非 localhost:backend 当前恰好双栈监听(:::8080), # 故 localhost 解析成 ::1 也能通;但探针不应依赖「服务恰好绑了 IPv6」这一偶然事实。 test: ["CMD-SHELL", "wget -qO /dev/null http://127.0.0.1:8080/api/v1/health/live || exit 1"] interval: 30s timeout: 10s retries: 3 admin-web: build: context: ../admin-web dockerfile: Dockerfile container_name: danyang-match-admin depends_on: backend: condition: service_healthy nginx: image: nginx:alpine container_name: danyang-match-nginx depends_on: backend: condition: service_healthy admin-web: condition: service_started ports: - "6688:80" volumes: - ./nginx.conf:/etc/nginx/nginx.conf:ro healthcheck: # 探针地址必须写 127.0.0.1,不能写 localhost。 # 实测:nginx 的 `listen 80` 只绑 IPv4(容器内 netstat = 0.0.0.0:80), # 而 /etc/hosts 中 `::1 localhost` 同样存在,busybox wget 解析 localhost 时优先取 ::1 # → 探针连 IPv6 回环被拒(Connection refused)→ 容器被标 unhealthy, # 但服务其实完全正常(宿主 curl http://localhost:6688/nginx-health = 200)。 # 这是一类「探针与被探对象脱钩」的假告警,还会连带拖垮 depends_on: service_healthy。 test: ["CMD-SHELL", "wget -qO /dev/null http://127.0.0.1/nginx-health || exit 1"] interval: 30s timeout: 10s retries: 3 volumes: postgres_data: redis_data: seaweedfs_data: