diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0d665db --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.gitnexus +.claude +.local +node_modules +web/node_modules +server/node_modules +web/dist +server/dist +deploy/env/**/*.local +*.log +playwright-report +test-results diff --git a/AGENTS.md b/AGENTS.md index 8eac1d7..c9ab7fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **a-share-analysis** (1440 symbols, 2018 relationships, 44 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **a-share-analysis** (1453 symbols, 2031 relationships, 44 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/README.md b/README.md index 11f2c65..ae32321 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,8 @@ A read-only A-share market overview and analysis tool. ## Projects - `web/`: Vue 3 market terminal frontend. -- `server/`: NestJS API, scheduler, and workers. Added in a later module. -- `deploy/`: MySQL 5.7, Redis, and RocketMQ 5.x deployment configuration. Added with the backend module. +- `server/`: NestJS API、Scheduler 与 Worker 三个独立进程。 +- `deploy/`: MySQL 5.7、Redis、RocketMQ 5.x 的配置和双部署入口。 - `docs/`: Product and implementation specifications. The first implementation slice is the market overview screen in `web/`. @@ -18,7 +18,7 @@ The first implementation slice is the market overview screen in `web/`. ## 环境与部署 - [本地开发环境](docs/deployment/local-development.md):Compose 只启动 Redis,应用从源码运行。 -- [Docker Compose 生产部署](docs/deployment/docker-compose-production.md):当前为应用脚手架落地前的配置契约骨架。 +- [Docker Compose 生产部署](docs/deployment/docker-compose-production.md):Web/Server 不可变镜像与外部配置。 - [Ubuntu 源码生产部署](docs/deployment/ubuntu-source-production.md):支持 Ubuntu 22.04/24.04 LTS x86_64。 无秘密环境模板位于 `deploy/env/`。生产配置不进入 Git 或发布目录;Node.js 22 LTS、pnpm 10、Nginx 和基础设施由服务器管理员提供。 diff --git a/deploy/compose/production.yml.example b/deploy/compose/production.yml.example index 039524c..92b1435 100644 --- a/deploy/compose/production.yml.example +++ b/deploy/compose/production.yml.example @@ -4,27 +4,50 @@ name: a-share-analysis-production services: web: image: ${A_SHARE_WEB_IMAGE:?必须提供不可变 Web 镜像引用} - env_file: ${A_SHARE_WEB_ENV_FILE:-/etc/a-share-analysis/web.env} ports: - - "127.0.0.1:8080:80" + - '127.0.0.1:8080:80' + depends_on: + api: + condition: service_healthy + healthcheck: + test: ['CMD', 'wget', '-q', '-O', '/dev/null', 'http://127.0.0.1/'] + interval: 30s + timeout: 3s + retries: 3 + start_period: 5s restart: unless-stopped api: image: ${A_SHARE_SERVER_IMAGE:?必须提供不可变 Server 镜像引用} - command: ["node", "dist/apps/api/main.js"] + command: ['node', 'dist/apps/api/main.js'] env_file: ${A_SHARE_API_ENV_FILE:-/etc/a-share-analysis/api.env} + environment: + # 容器网络需要对 Web 服务可达;源码部署模板仍只监听回环地址。 + API_HOST: 0.0.0.0 ports: - - "127.0.0.1:3000:3000" + - '127.0.0.1:3000:3000' + healthcheck: + test: + [ + 'CMD', + 'node', + '-e', + "fetch('http://127.0.0.1:3000/api/v1/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))", + ] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s restart: unless-stopped scheduler: image: ${A_SHARE_SERVER_IMAGE:?必须提供不可变 Server 镜像引用} - command: ["node", "dist/apps/scheduler/main.js"] + command: ['node', 'dist/apps/scheduler/main.js'] env_file: ${A_SHARE_SCHEDULER_ENV_FILE:-/etc/a-share-analysis/scheduler.env} restart: unless-stopped worker: image: ${A_SHARE_SERVER_IMAGE:?必须提供不可变 Server 镜像引用} - command: ["node", "dist/apps/worker/main.js"] + command: ['node', 'dist/apps/worker/main.js'] env_file: ${A_SHARE_WORKER_ENV_FILE:-/etc/a-share-analysis/worker.env} restart: unless-stopped diff --git a/deploy/env/development/web.env.example b/deploy/env/development/web.env.example index 915e5d7..487f9b3 100644 --- a/deploy/env/development/web.env.example +++ b/deploy/env/development/web.env.example @@ -1,5 +1,5 @@ # VITE 变量会进入浏览器,只允许公开配置。 VITE_APP_ENV=development VITE_APP_TITLE=A-Share Analysis -VITE_API_BASE_URL=http://127.0.0.1:3000/api +VITE_API_BASE_URL=http://127.0.0.1:3000 VITE_ENABLE_DEVTOOLS=true diff --git a/deploy/env/production/web.env.example b/deploy/env/production/web.env.example index a5d3d58..70890db 100644 --- a/deploy/env/production/web.env.example +++ b/deploy/env/production/web.env.example @@ -1,5 +1,5 @@ # VITE 变量会进入浏览器,只允许公开配置。 VITE_APP_ENV=production VITE_APP_TITLE=A-Share Analysis -VITE_API_BASE_URL=/api +VITE_API_BASE_URL= VITE_ENABLE_DEVTOOLS=false diff --git a/deploy/systemd/a-share-api.service.example b/deploy/systemd/a-share-api.service.example index bf231e7..033188c 100644 --- a/deploy/systemd/a-share-api.service.example +++ b/deploy/systemd/a-share-api.service.example @@ -9,11 +9,14 @@ User=a-share Group=a-share WorkingDirectory=/opt/a-share-analysis/current EnvironmentFile=/etc/a-share-analysis/api.env -ExecStart=/usr/bin/node server/dist/apps/api/main.js +ExecStart=/usr/bin/node /opt/a-share-analysis/current/server/dist/apps/api/main.js +ExecStartPre=/usr/bin/test -r /etc/a-share-analysis/api.env Restart=on-failure RestartSec=5 NoNewPrivileges=true PrivateTmp=true +ProtectSystem=strict +ProtectHome=true [Install] WantedBy=multi-user.target diff --git a/deploy/systemd/a-share-scheduler.service.example b/deploy/systemd/a-share-scheduler.service.example index a598284..e09e164 100644 --- a/deploy/systemd/a-share-scheduler.service.example +++ b/deploy/systemd/a-share-scheduler.service.example @@ -9,11 +9,14 @@ User=a-share Group=a-share WorkingDirectory=/opt/a-share-analysis/current EnvironmentFile=/etc/a-share-analysis/scheduler.env -ExecStart=/usr/bin/node server/dist/apps/scheduler/main.js +ExecStart=/usr/bin/node /opt/a-share-analysis/current/server/dist/apps/scheduler/main.js +ExecStartPre=/usr/bin/test -r /etc/a-share-analysis/scheduler.env Restart=on-failure RestartSec=5 NoNewPrivileges=true PrivateTmp=true +ProtectSystem=strict +ProtectHome=true [Install] WantedBy=multi-user.target diff --git a/deploy/systemd/a-share-worker.service.example b/deploy/systemd/a-share-worker.service.example index be07c78..c192dc1 100644 --- a/deploy/systemd/a-share-worker.service.example +++ b/deploy/systemd/a-share-worker.service.example @@ -9,11 +9,14 @@ User=a-share Group=a-share WorkingDirectory=/opt/a-share-analysis/current EnvironmentFile=/etc/a-share-analysis/worker.env -ExecStart=/usr/bin/node server/dist/apps/worker/main.js +ExecStart=/usr/bin/node /opt/a-share-analysis/current/server/dist/apps/worker/main.js +ExecStartPre=/usr/bin/test -r /etc/a-share-analysis/worker.env Restart=on-failure RestartSec=5 NoNewPrivileges=true PrivateTmp=true +ProtectSystem=strict +ProtectHome=true [Install] WantedBy=multi-user.target diff --git a/docs/deployment/docker-compose-production.md b/docs/deployment/docker-compose-production.md index ff7a659..bf1adf2 100644 --- a/docs/deployment/docker-compose-production.md +++ b/docs/deployment/docker-compose-production.md @@ -1,19 +1,32 @@ # Docker Compose 生产部署 -生产 Docker Compose 使用不可变应用镜像和外部 env 文件。当前 `production.yml.example` 是部署契约骨架,应用镜像、健康检查和实际启动命令必须在 Web/Server 脚手架完成后验证,未验证前不得用于线上部署。 +生产 Compose 部署使用两个不可变镜像:Web 静态 Nginx 镜像,以及同时包含 API、Scheduler、Worker 三个入口的 Server 镜像。MySQL 5.7、Redis 与 RocketMQ 5.x 由运维独立管理,不进入应用 Compose 生命周期。 -## 配置边界 +## 构建与配置 -- 从 `deploy/env/production/` 准备四份真实配置,存放在服务器 `/etc/a-share-analysis/` 或其他受控目录。 -- Compose 通过外部路径加载,不在 YAML、镜像和 Git 中保存秘密。 -- 镜像引用必须包含版本或内容摘要,禁止浮动 `latest`。 -- 第一阶段只依赖 Redis;MySQL 和 RocketMQ 5.x 功能开关保持关闭。 -- 启用 RocketMQ 时优先连接受运维管理的外部集群或独立基础设施 Compose,不把 NameServer、Broker、Proxy 混入应用发布生命周期。 +构建期只能给 Web 注入公开变量,生产秘密不得使用 `ARG`、写入镜像或提交仓库: -## 发布与回退 +```bash +docker build -f web/Dockerfile -t registry.example/a-share-web:<提交SHA> . +docker build -f server/Dockerfile -t registry.example/a-share-server:<提交SHA> . +``` -部署前验证镜像对应已推送提交或标签、环境 Schema、健康检查和数据目录。升级只修改不可变镜像引用;失败时恢复上一完整镜像集合和未改变的外部配置。禁止删除 Redis、MySQL 或 RocketMQ CommitLog 数据目录作为应用回退手段。 +把四份生产模板复制到服务器的 `/etc/a-share-analysis/`,由运维填写并设置最小权限。Web 配置在镜像构建期固化,因此 `web.env` 只作为发布参数记录,不挂载到容器;API、Scheduler、Worker 分别读取自己的外部 env 文件。所有镜像引用必须使用明确版本或内容摘要,禁止 `latest`。 -## 验证 +## 发布与验证 -应用脚手架落地后必须补充 `docker compose config`、服务健康检查、日志读取、优雅停止和回退演练证据。本版本不声称生产 Compose 已可运行。 +```bash +export A_SHARE_WEB_IMAGE='registry.example/a-share-web@sha256:<摘要>' +export A_SHARE_SERVER_IMAGE='registry.example/a-share-server@sha256:<摘要>' +docker compose -f deploy/compose/production.yml.example config +docker compose -f deploy/compose/production.yml.example up -d +docker compose -f deploy/compose/production.yml.example ps +curl --fail http://127.0.0.1:3000/api/v1/health +curl --fail http://127.0.0.1:8080/ +``` + +当前工程尚无业务表迁移;出现 TypeORM migration 后,发布必须在启动应用前用同一 Server 镜像执行迁移,且始终禁止 `synchronize: true`。RocketMQ Node 客户端在后续功能验证前保持关闭。 + +## 回退 + +保留上一组 Web/Server 内容摘要。失败时把两个镜像变量恢复到上一组,重新执行 `config`、`up -d` 和健康检查。外部 env、Redis、MySQL 数据与 RocketMQ CommitLog 不随应用镜像回退;禁止用删除数据目录的方式回退应用。 diff --git a/docs/deployment/local-development.md b/docs/deployment/local-development.md index 2b96409..3e99b37 100644 --- a/docs/deployment/local-development.md +++ b/docs/deployment/local-development.md @@ -1,36 +1,41 @@ # 本地开发环境 -本地开发使用 Docker Compose 只启动 Redis,Web、API、Scheduler 和 Worker 从源码运行并使用开发环境模板。第一阶段不自动启动 MySQL 和 RocketMQ 5.x。 +本地开发由开发机提供 Node.js 22.x 与 pnpm 10.25.0,项目不安装或修改 Node。Docker Compose 只管理 Redis;Web、API、Scheduler 和 Worker分别从源码启动,MySQL 5.7 与 RocketMQ 5.x 使用外部开发实例且默认关闭。 -## 配置 - -从 `deploy/env/development/` 中对应的 `*.env.example` 创建本机 `*.env.local`。应用和 Schema 工具优先读取 `api.env.local`;真实文件被 Git 忽略,不得写入生产地址或真实密钥。当前固定开发 MySQL 可使用白名单地址 `112.192.20.11:3306`,Schema 工具仍只允许开发环境和 `a_share_analysis_dev`。 - -开发环境配置文件约定: - -- `api.env.local`:API 的 Redis、MySQL 和可选 AI 配置。 -- `scheduler.env.local`:Scheduler 的 Redis、调度和可选 RocketMQ 配置。 -- `worker.env.local`:Worker 的 Redis、MySQL、行情、AI 和可选 RocketMQ 配置。 -- `web.env.local`:只包含 `VITE_` 前缀的前端变量。 - -Schema 基线会在空开发库启用时生成 `captured_empty` 清单,记录 MySQL 5.7 元数据和零对象计数,不生成伪造的空 SQL;旧版本 format v1 基线保持可验证且不会被当前配置反向改写。 - -## Redis +## 首次准备 ```bash +node --version +pnpm --version +pnpm install --frozen-lockfile docker compose -f deploy/compose/development.yml up -d -docker compose -f deploy/compose/development.yml ps +pnpm check:runtime +``` + +把 `deploy/env/development/*.env.example` 复制为同目录的 `*.env.local`。四份 local 文件被 Git 忽略:Web 只能含 `VITE_` 公开变量;API、Scheduler、Worker 按最小权限分别读取自己的配置,不共享一份全能 env。 + +固定开发 MySQL 只允许 `a_share_analysis_dev`,主机必须是回环地址或代码内白名单 `112.192.20.11`,版本为 MySQL 5.7.x,字符集为 `utf8mb4`、排序规则为 `utf8mb4_general_ci`、会话时区为 `+08:00`。Schema 工具和开发检查禁止连接、探测或读取生产库。 + +## 启动顺序 + +建议分别打开四个终端,依次运行: + +```bash +pnpm --filter @a-share-analysis/server dev:api +pnpm --filter @a-share-analysis/server dev:scheduler +pnpm --filter @a-share-analysis/server dev:worker +pnpm --filter @a-share-analysis/web dev +``` + +API 健康端点是 `http://127.0.0.1:3000/api/v1/health`,Web 是 `http://127.0.0.1:5173/`。Scheduler 与 Worker 在 GOV-009 中保持 `ROCKETMQ_ENABLED=false`;没有经过验证的 Node RocketMQ 客户端时,启用配置会直接失败,不使用内存队列伪降级。 + +## 日常验证与停止 + +```bash +pnpm check:contracts +pnpm verify +pnpm --filter @a-share-analysis/web test:e2e docker compose -f deploy/compose/development.yml down ``` -Redis 只绑定 `127.0.0.1:6379`,数据写入仓库忽略的 `.local/redis/`。不得把该 Compose 文件用于生产。 - -## RocketMQ 联调 - -RocketMQ 由开发者在项目 Compose 外独立启动。应用启用前至少需要 NameServer、Broker;采用推荐的 Node.js Proxy 接入时还需要 Proxy。默认端口约定:NameServer `9876`、Broker `10911`、Proxy gRPC `8081`。 - -`nc -vz 127.0.0.1 9876` 只能证明 NameServer TCP 可达,不能证明 Topic 路由、Broker 注册或消息收发可用。完整联调必须继续检查 Broker 列表,并执行一次测试 Topic 的生产与消费。开发模板默认 `ROCKETMQ_ENABLED=false`;启用后必须提供可用的 `ROCKETMQ_ENDPOINT`,不得把生产地址写入本地配置或仓库。 - -## 应用状态 - -Web 和 Server 尚未完成脚手架,因此本版本只交付配置契约和 Redis 开发环境。后续脚手架必须使用 Node.js 22 LTS、pnpm 10 和这些模板,不能在本文档中假装应用已经可启动。 +Redis 只绑定 `127.0.0.1:6379`,数据写入仓库忽略的 `.local/redis/`。RocketMQ 由开发者独立维护 NameServer、Broker 与需要时的 Proxy;TCP 端口可达不等于生产消费链路通过,完整联调仍需验证 Topic 路由和实际生产/消费。 diff --git a/docs/deployment/ubuntu-source-production.md b/docs/deployment/ubuntu-source-production.md index 57bd016..0fe8123 100644 --- a/docs/deployment/ubuntu-source-production.md +++ b/docs/deployment/ubuntu-source-production.md @@ -1,37 +1,40 @@ # Ubuntu 源码生产部署 -正式支持 Ubuntu 22.04 LTS 与 Ubuntu 24.04 LTS 的 x86_64。服务器管理员预先提供 Node.js 22 LTS、pnpm 10、Nginx,以及 MySQL 5.7、Redis、RocketMQ 5.x 或相应外部服务;项目不负责安装或升级这些软件,也不修改系统软件源。 +正式支持 Ubuntu 22.04 LTS 与 Ubuntu 24.04 LTS x86_64。服务器管理员预先提供 Node.js 22 LTS、pnpm 10、Nginx,以及 MySQL 5.7、Redis、RocketMQ 5.x 或相应外部服务;项目不负责安装、升级或修改系统软件源。 -## 前置验证 +## 构建发布目录 ```bash uname -m node --version pnpm --version -nginx -v +pnpm install --frozen-lockfile +pnpm check:contracts +pnpm verify ``` -架构必须为 `x86_64`,Node.js 必须为 22 LTS,pnpm 必须为 10.x。不满足要求时终止部署。 +架构必须为 `x86_64`,Node.js 必须为 22.x,pnpm 必须为 10.x。每次发布使用 `/opt/a-share-analysis/releases/<版本或提交>/` 新目录,验证后再原子切换 `/opt/a-share-analysis/current`,禁止在 current 中原地覆盖。 -## 目录与配置 +## 配置与 systemd -- 发布目录:`/opt/a-share-analysis/releases/<版本或提交>/`。 -- 当前版本:`/opt/a-share-analysis/current` 符号链接。 -- 生产配置:`/etc/a-share-analysis/api.env`、`scheduler.env`、`worker.env`、`web.env`。 -- 运行用户:非登录系统用户 `a-share`。 +生产配置放在 `/etc/a-share-analysis/api.env`、`scheduler.env`、`worker.env`,建议权限 `root:a-share 0640`;Web 的 `VITE_` 公开变量在构建前注入。三个 systemd 单元通过各自的 `EnvironmentFile=` 只读取本进程需要的键,运行用户为非登录系统用户 `a-share`。 -真实配置从仓库生产模板生成,但不得放入发布目录。API、Scheduler、Worker 的 systemd 单元分别通过 `EnvironmentFile=` 读取自己的配置;包含秘密的文件建议为 `root:a-share 0640`。 +```bash +sudo install -m 0644 deploy/systemd/a-share-*.service.example /etc/systemd/system/ +sudo install -m 0644 deploy/nginx/a-share-analysis.conf.example /etc/nginx/conf.d/a-share-analysis.conf +sudo systemd-analyze verify /etc/systemd/system/a-share-api.service.example +sudo systemd-analyze verify /etc/systemd/system/a-share-scheduler.service.example +sudo systemd-analyze verify /etc/systemd/system/a-share-worker.service.example +sudo nginx -t +sudo systemctl daemon-reload +sudo systemctl restart a-share-api.service.example +sudo systemctl restart a-share-scheduler.service.example +sudo systemctl restart a-share-worker.service.example +curl --fail http://127.0.0.1:3000/api/v1/health +``` -## 构建与启动边界 +当前工程尚无业务表迁移;后续新增迁移后必须在切换 current 前执行并记录结果。生产环境禁止 `synchronize: true`,开发流程也禁止用生产数据库做 Schema 基线或连接测试。 -使用 `pnpm install --frozen-lockfile` 安装锁定依赖,在新发布目录构建 Web 和 Server。Web 静态文件由 Nginx 托管,API、Scheduler、Worker 运行编译后的 JavaScript。禁止 Vite 开发服务器、watch、调试端口和生产运行 TypeScript。 +## 检查与回退 -当前应用尚未脚手架化,仓库中的 systemd 与 Nginx 文件是路径和权限契约示例,不能冒充已经验证的线上启动文件。后续应用功能必须补充实际构建、启动和健康检查验证。 - -## 升级、检查与回退 - -在新的不可变发布目录完成安装、构建和配置校验后,原子切换 `current`,依次重启 API、Scheduler、Worker 并检查 `systemctl status`、`journalctl` 和 HTTP 健康端点。失败时切回上一完整目录并重启服务;生产配置不随版本切换覆盖。 - -项目部署流程禁止连接或读取生产数据库做开发验证。数据库备份属于独立运维职责,应用回退不得删除或覆盖数据。 - -RocketMQ 由运维独立维护 NameServer、Broker 和需要时的 Proxy。应用发布只读取受控的 RocketMQ Endpoint、Topic 前缀与 Group 配置,不负责创建或升级集群,也不得把 Broker 数据目录放进应用发布目录。 +使用 `systemctl status`、`journalctl -u`、HTTP 健康端点和 Nginx 访问结果确认发布。失败时把 `current` 原子切回上一完整发布目录,重启三个服务并再次验证。生产配置和数据不随代码目录覆盖;RocketMQ 的 NameServer、Broker、Proxy 与 CommitLog 由运维独立维护。 diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..16d93cb --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,25 @@ +FROM node:22.22.0-bookworm-slim AS builder + +ENV PNPM_HOME=/pnpm +ENV PATH=$PNPM_HOME:$PATH +RUN corepack enable && corepack prepare pnpm@10.25.0 --activate + +WORKDIR /workspace +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ +COPY web/package.json web/package.json +COPY server/package.json server/package.json +RUN pnpm install --frozen-lockfile --filter @a-share-analysis/server... + +COPY server server +RUN pnpm --filter @a-share-analysis/server build +RUN pnpm --filter @a-share-analysis/server deploy --legacy --prod /runtime + +FROM node:22.22.0-bookworm-slim AS runtime +ENV NODE_ENV=production +ENV APP_ENV=production +WORKDIR /app +COPY --from=builder --chown=node:node /runtime/package.json ./package.json +COPY --from=builder --chown=node:node /runtime/node_modules ./node_modules +COPY --from=builder --chown=node:node /workspace/server/dist ./dist +USER node +CMD ["node", "dist/apps/api/main.js"] diff --git a/tools/governance/environment-baseline.test.mjs b/tools/governance/environment-baseline.test.mjs index df45614..57c1924 100644 --- a/tools/governance/environment-baseline.test.mjs +++ b/tools/governance/environment-baseline.test.mjs @@ -1,10 +1,15 @@ import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; +import { execFile } from 'node:child_process'; +import { access, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; import test from 'node:test'; +import { promisify } from 'node:util'; const repoRoot = new URL('../../', import.meta.url); const processNames = ['api', 'scheduler', 'worker', 'web']; const sensitiveKeyPattern = /(PASSWORD|API_KEY|TOKEN|SECRET)$/; +const execFileAsync = promisify(execFile); async function readRepoFile(relativePath) { return readFile(new URL(relativePath, repoRoot), 'utf8'); @@ -53,6 +58,18 @@ test('模板不包含敏感值且 Web 只暴露 VITE 变量', async () => { } }); +test('Web API 根地址不重复 OpenAPI 自带的 api 路径', async () => { + const development = parseEnvTemplate( + await readRepoFile('deploy/env/development/web.env.example'), + ); + const production = parseEnvTemplate( + await readRepoFile('deploy/env/production/web.env.example'), + ); + + assert.equal(development.get('VITE_API_BASE_URL'), 'http://127.0.0.1:3000'); + assert.equal(production.get('VITE_API_BASE_URL'), ''); +}); + test('生产模板禁止模拟数据并保持 MySQL 5.7 与 RocketMQ 配置契约', async () => { const rocketMqTemplates = new Map(); for (const environment of ['development', 'production']) { @@ -149,3 +166,81 @@ test('部署文档覆盖本地、Compose 生产和 Ubuntu 源码生产', async ( assert.match(source, /pnpm 10/); assert.match(source, /不负责安装/); }); + +test('生产 Compose 只接受显式镜像并解析为真实健康检查', async () => { + const composePath = new URL('deploy/compose/production.yml.example', repoRoot).pathname; + const temporaryDirectory = await mkdtemp(path.join(tmpdir(), 'a-share-compose-')); + const envFiles = {}; + for (const processName of processNames) { + const envPath = path.join(temporaryDirectory, `${processName}.env`); + await writeFile(envPath, 'APP_ENV=production\n'); + envFiles[processName] = envPath; + } + + await assert.rejects( + execFileAsync('docker', ['compose', '-f', composePath, 'config'], { + env: { ...process.env }, + }), + /必须提供不可变 (?:Web|Server) 镜像引用/, + ); + + const imageDigest = `sha256:${'a'.repeat(64)}`; + const { stdout } = await execFileAsync( + 'docker', + ['compose', '-f', composePath, 'config', '--format', 'json'], + { + env: { + ...process.env, + A_SHARE_WEB_IMAGE: `registry.invalid/web@${imageDigest}`, + A_SHARE_SERVER_IMAGE: `registry.invalid/server@${imageDigest}`, + A_SHARE_WEB_ENV_FILE: envFiles.web, + A_SHARE_API_ENV_FILE: envFiles.api, + A_SHARE_SCHEDULER_ENV_FILE: envFiles.scheduler, + A_SHARE_WORKER_ENV_FILE: envFiles.worker, + }, + }, + ); + const config = JSON.parse(stdout); + + assert.equal(config.services.web.image, `registry.invalid/web@${imageDigest}`); + assert.equal(config.services.api.image, `registry.invalid/server@${imageDigest}`); + assert.deepEqual(config.services.api.command, ['node', 'dist/apps/api/main.js']); + assert.ok(config.services.web.healthcheck?.test); + assert.ok(config.services.api.healthcheck?.test); +}); + +test('容器构建文件存在且 systemd 启动入口对应真实构建产物', async () => { + await access(new URL('web/Dockerfile', repoRoot)); + await access(new URL('web/nginx.conf', repoRoot)); + await access(new URL('server/Dockerfile', repoRoot)); + await execFileAsync('pnpm', ['--filter', '@a-share-analysis/server', 'build'], { + cwd: repoRoot.pathname, + }); + + for (const processName of ['api', 'scheduler', 'worker']) { + const unit = await readRepoFile(`deploy/systemd/a-share-${processName}.service.example`); + const execStart = unit.match( + /^ExecStart=\/usr\/bin\/node \/opt\/a-share-analysis\/current\/(.+)$/m, + )?.[1]; + assert.ok(execStart, `${processName} 缺少 Node 构建入口`); + await access(new URL(execStart, repoRoot)); + } +}); + +test('Docker 构建上下文排除本地配置与派生产物', async () => { + const dockerIgnore = await readRepoFile('.dockerignore'); + + for (const requiredPattern of [ + 'deploy/env/**/*.local', + '.git', + '.gitnexus', + 'node_modules', + 'web/dist', + 'server/dist', + ]) { + assert.ok( + dockerIgnore.split(/\r?\n/).includes(requiredPattern), + `.dockerignore 缺少 ${requiredPattern}`, + ); + } +}); diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..221cb95 --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,30 @@ +FROM node:22.22.0-bookworm-slim AS builder + +ENV PNPM_HOME=/pnpm +ENV PATH=$PNPM_HOME:$PATH +RUN corepack enable && corepack prepare pnpm@10.25.0 --activate + +WORKDIR /workspace +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ +COPY web/package.json web/package.json +COPY server/package.json server/package.json +RUN pnpm install --frozen-lockfile --filter @a-share-analysis/web... + +COPY web web +ARG VITE_APP_ENV=production +ARG VITE_APP_TITLE=A-Share-Analysis +ARG VITE_API_BASE_URL= +ARG VITE_ENABLE_DEVTOOLS=false +ENV NODE_ENV=production +ENV VITE_APP_ENV=$VITE_APP_ENV +ENV VITE_APP_TITLE=$VITE_APP_TITLE +ENV VITE_API_BASE_URL=$VITE_API_BASE_URL +ENV VITE_ENABLE_DEVTOOLS=$VITE_ENABLE_DEVTOOLS +RUN pnpm --filter @a-share-analysis/web build + +FROM nginx:1.28.2-alpine3.23 +COPY web/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=builder /workspace/web/dist /usr/share/nginx/html +EXPOSE 80 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -q -O /dev/null http://127.0.0.1/ || exit 1 diff --git a/web/nginx.conf b/web/nginx.conf new file mode 100644 index 0000000..d211bca --- /dev/null +++ b/web/nginx.conf @@ -0,0 +1,20 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://api:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + try_files $uri $uri/ /index.html; + } +}