diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0eda6f3..fddd71b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,16 +42,36 @@ jobs: uses: actions/setup-node@v4 with: node-version: '20' - cache: 'pnpm' - cache-dependency-path: admin-web/pnpm-lock.yaml + # 这里不能写 cache: 'pnpm'。 + # 2026-09-22 实测:runner 镜像(ubuntu-24.04 / runner 2.337.0)**已不再预装 + # pnpm**,而 setup-node 的 cache 步骤在 restore 阶段就需要调用 pnpm 可执行文件, + # 于是直接报 "Unable to locate executable file: pnpm",整个 job 在第一步失败、 + # 后续步骤全部 skipped —— 表现为 admin-web job 秒红,且与业务代码无关。 + # (同批的 miniapp job 没配 cache 所以活了下来,正好反向印证了这一点。) + # 因此:先让 pnpm 存在于 PATH,缓存改为手动配置。 - # 必须钉版本:runner 预装的 pnpm 会随 GitHub 镜像升级而漂移,而本仓库的 - # pnpm-workspace.yaml 含有 onlyBuiltDependencies 等版本敏感配置, - # 且 pnpm 9 与 10 对「是否默认放行依赖 build 脚本」的语义相反 - # (esbuild / vue-demi 的 postinstall 若被静默阻断,install 能过、build 会红, - # 且错误信息不指向真正原因)。钉死后 CI 语义不再随 runner 变化。 - - name: 锁定 pnpm 版本(消除 runner 预装版本漂移) - run: npm i -g pnpm@9.15.9 + - name: 启用并锁定 pnpm + # corepack 随 Node 分发,runner 上没有 pnpm 但有 corepack。 + # 钉 9.15.9 的理由:pnpm 9 与 10 对「是否默认放行依赖 build 脚本」语义相反, + # 而 pnpm-workspace.yaml 里的 onlyBuiltDependencies 正是版本敏感项 + # (esbuild / vue-demi 的 postinstall 若被静默阻断,会出现 install 能过、 + # build 变红,且错误信息不指向真正原因)。钉死后不再随 runner 镜像升级而变化。 + run: | + corepack enable pnpm + corepack prepare pnpm@9.15.9 --activate + pnpm --version + + - name: 解析 pnpm store 路径 + id: pnpm-store + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + - name: 缓存 pnpm store + uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('admin-web/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- - name: Install Dependencies working-directory: admin-web @@ -162,7 +182,7 @@ jobs: # 第 16/17 项:`this._x()` 无定义(运行期 TypeError,曾致所有请求 Promise 永不 settle) # 与组件 WXSS 违规选择器(标签/ID/属性选择器); # 第 18 项:api.js 请求层**行为**契约(桩 wx.request 真跑,非源码断言)。 - # 守卫是纯 node 内建模块、零 npm 依赖,故无需装依赖;统一执行器负责逐个跑 + 汇总 + 非零退出, + # 统一执行器负责逐个跑 + 汇总 + 非零退出, # 并校验守卫数量下限(防止守卫被删除/改名后仍然报绿)。 steps: - uses: actions/checkout@v4 @@ -172,6 +192,16 @@ jobs: with: node-version: '20' + # 2026-09-22 修正:本 job 此前注释称「零 npm 依赖,故无需装依赖」,这个声明是错的。 + # 至少 phase04 守卫会 import miniapp/node_modules/typescript/lib/typescript.js + # (用 TS 编译器 API 解析小程序源码)。本地能跑通只因为工作区早已装过 miniapp 依赖; + # CI 的每个 job 都在**独立 runner**上,miniapp job 装的东西不会共享过来, + # 于是这里直接 ERR_MODULE_NOT_FOUND。 + # 「依赖声明与实际不符」正是本地绿 / CI 红最典型的一类来源。 + - name: 安装 miniapp 依赖(phase04 守卫需要 typescript 编译器 API) + working-directory: miniapp + run: npm install + - name: 运行 UI 契约门禁 run: node scripts/run-ui-contracts.mjs diff --git a/.gitignore b/.gitignore index b07d802..9574000 100644 --- a/.gitignore +++ b/.gitignore @@ -70,7 +70,17 @@ Thumbs.db .DS_Store # Secrets and certificates -docker-compose.prod.yml +# +# 2026-09-22 修正:`docker-compose.prod.yml` 原先被列在本段(当作密钥)而排除出 +# 版本控制,这是**误分类** —— 该文件不含任何明文密钥:所有敏感值都通过 +# `${VAR:?请设置...}` 注入,db 密码走 Docker secrets(./secrets/db_password.txt), +# 文件头注释本身也写着「敏感信息通过 .env 文件注入,不进 Git」。 +# +# 后果:backend 的 `DeploymentTests.testDockerComposeProdExists` 断言该文件存在, +# 于是「本地恒绿(文件在工作区)、CI 恒红(checkout 拿不到)」—— +# 一次典型的本地绿/CI 红假绿,且从本地永远发现不了。 +# 生产部署配置需要可复现、可评审,应与 staging 版一致纳入版本控制。 +# 真正的密钥仍由下面三条挡住。 infrastructure/secrets/ infrastructure/certs/ infrastructure/backups/ diff --git a/infrastructure/docker-compose.prod.yml b/infrastructure/docker-compose.prod.yml new file mode 100644 index 0000000..b44cc68 --- /dev/null +++ b/infrastructure/docker-compose.prod.yml @@ -0,0 +1,145 @@ +# Production Docker Compose +# 生产环境配置,敏感信息通过 .env 文件注入,不进 Git +services: + postgres: + image: postgres:16-alpine + container_name: danyang-match-postgres + environment: + POSTGRES_DB: ${DB_NAME:-danyang_match} + POSTGRES_USER: ${DB_USER:-danyang} + POSTGRES_PASSWORD_FILE: /run/secrets/db_password + ports: + - "127.0.0.1:5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./backups:/backups + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-danyang} -d ${DB_NAME:-danyang_match}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - danyang-net + secrets: + - db_password + restart: unless-stopped + + redis: + image: redis:7-alpine + container_name: danyang-match-redis + command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:?请设置REDIS_PASSWORD} + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:?请设置REDIS_PASSWORD}", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - danyang-net + restart: unless-stopped + + minio: + image: minio/minio:latest + container_name: danyang-match-minio + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?请设置MINIO_ACCESS_KEY} + MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?请设置MINIO_SECRET_KEY} + volumes: + - minio_data:/data + networks: + - danyang-net + restart: unless-stopped + + backend: + build: + context: ../backend + dockerfile: Dockerfile + container_name: danyang-match-backend + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + environment: + SPRING_PROFILES_ACTIVE: docker,production + SPRING_CONFIG_IMPORT: configtree:/run/secrets/ + DB_HOST: postgres + DB_PORT: 5432 + DB_NAME: ${DB_NAME:-danyang_match} + DB_USER: ${DB_USER:-danyang} + REDIS_HOST: redis + REDIS_PORT: 6379 + REDIS_PASSWORD: ${REDIS_PASSWORD:?请设置REDIS_PASSWORD} + MINIO_ENDPOINT: http://minio:9000 + 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} + JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:?请设置JWT_REFRESH_SECRET} + CHECKIN_TOKEN_SECRET: ${CHECKIN_TOKEN_SECRET:?请设置CHECKIN_TOKEN_SECRET} + ENCRYPTION_KEY: ${ENCRYPTION_KEY:?请设置ENCRYPTION_KEY} + WECHAT_APP_ID: ${WECHAT_APP_ID:?请设置WECHAT_APP_ID} + WECHAT_APP_SECRET: ${WECHAT_APP_SECRET:?请设置WECHAT_APP_SECRET} + VERIFICATION_PROVIDER: ${VERIFICATION_PROVIDER:?请设置VERIFICATION_PROVIDER} + SPRING_SECURITY_FALLBACK_PASSWORD: ${SPRING_SECURITY_FALLBACK_PASSWORD:?请设置SPRING_SECURITY_FALLBACK_PASSWORD} + FEATURES_APPEAL_ENABLED: ${FEATURES_APPEAL_ENABLED:-false} + ports: + - "127.0.0.1:8080:8080" + networks: + - danyang-net + secrets: + - source: db_password + target: spring.datasource.password + restart: unless-stopped + deploy: + resources: + limits: + memory: 512M + reservations: + memory: 256M + + admin-web: + build: + context: ../admin-web + dockerfile: Dockerfile + container_name: danyang-match-admin + depends_on: + - backend + ports: + # 管理端端口统一 13520(与 admin-web/vite.config.ts 的开发端口一致, + # 由 ui-tests/ui-document-baseline-contracts.mjs 钉住三处一致)。 + # 仍绑定 127.0.0.1:对外访问走 nginx 的 80/443 + /admin/,此处仅供本机直连排查。 + - "127.0.0.1:13520:80" + networks: + - danyang-net + restart: unless-stopped + + nginx: + image: nginx:alpine + container_name: danyang-match-nginx + depends_on: + - backend + - admin-web + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx-production.conf:/etc/nginx/nginx.conf:ro + - ./certs:/etc/nginx/certs:ro + networks: + - danyang-net + restart: unless-stopped + +volumes: + postgres_data: + redis_data: + minio_data: + +networks: + danyang-net: + driver: bridge + +secrets: + db_password: + file: ./secrets/db_password.txt