build(GOV-009): 对齐应用部署与运行入口

This commit is contained in:
2026-08-13 17:56:37 +08:00
parent 4665336a63
commit c771a98d4d
16 changed files with 311 additions and 75 deletions
+13
View File
@@ -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
+1 -1
View File
@@ -24,7 +24,7 @@
<!-- gitnexus:start -->
# 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.
+3 -3
View File
@@ -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 和基础设施由服务器管理员提供。
+29 -6
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+4 -1
View File
@@ -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
@@ -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
@@ -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
+24 -11
View File
@@ -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 不随应用镜像回退;禁止用删除数据目录的方式回退应用。
+32 -27
View File
@@ -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 路由和实际生产/消费。
+24 -21
View File
@@ -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 由运维独立维护。
+25
View File
@@ -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"]
+96 -1
View File
@@ -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}`,
);
}
});
+30
View File
@@ -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
+20
View File
@@ -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;
}
}