build(GOV-009): 对齐应用部署与运行入口
This commit is contained in:
@@ -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
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
<!-- gitnexus:start -->
|
<!-- gitnexus:start -->
|
||||||
# GitNexus — Code Intelligence
|
# 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.
|
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ A read-only A-share market overview and analysis tool.
|
|||||||
## Projects
|
## Projects
|
||||||
|
|
||||||
- `web/`: Vue 3 market terminal frontend.
|
- `web/`: Vue 3 market terminal frontend.
|
||||||
- `server/`: NestJS API, scheduler, and workers. Added in a later module.
|
- `server/`: NestJS API、Scheduler 与 Worker 三个独立进程。
|
||||||
- `deploy/`: MySQL 5.7, Redis, and RocketMQ 5.x deployment configuration. Added with the backend module.
|
- `deploy/`: MySQL 5.7、Redis、RocketMQ 5.x 的配置和双部署入口。
|
||||||
- `docs/`: Product and implementation specifications.
|
- `docs/`: Product and implementation specifications.
|
||||||
|
|
||||||
The first implementation slice is the market overview screen in `web/`.
|
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,应用从源码运行。
|
- [本地开发环境](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。
|
- [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 和基础设施由服务器管理员提供。
|
无秘密环境模板位于 `deploy/env/`。生产配置不进入 Git 或发布目录;Node.js 22 LTS、pnpm 10、Nginx 和基础设施由服务器管理员提供。
|
||||||
|
|||||||
@@ -4,27 +4,50 @@ name: a-share-analysis-production
|
|||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
image: ${A_SHARE_WEB_IMAGE:?必须提供不可变 Web 镜像引用}
|
image: ${A_SHARE_WEB_IMAGE:?必须提供不可变 Web 镜像引用}
|
||||||
env_file: ${A_SHARE_WEB_ENV_FILE:-/etc/a-share-analysis/web.env}
|
|
||||||
ports:
|
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
|
restart: unless-stopped
|
||||||
|
|
||||||
api:
|
api:
|
||||||
image: ${A_SHARE_SERVER_IMAGE:?必须提供不可变 Server 镜像引用}
|
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}
|
env_file: ${A_SHARE_API_ENV_FILE:-/etc/a-share-analysis/api.env}
|
||||||
|
environment:
|
||||||
|
# 容器网络需要对 Web 服务可达;源码部署模板仍只监听回环地址。
|
||||||
|
API_HOST: 0.0.0.0
|
||||||
ports:
|
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
|
restart: unless-stopped
|
||||||
|
|
||||||
scheduler:
|
scheduler:
|
||||||
image: ${A_SHARE_SERVER_IMAGE:?必须提供不可变 Server 镜像引用}
|
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}
|
env_file: ${A_SHARE_SCHEDULER_ENV_FILE:-/etc/a-share-analysis/scheduler.env}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
worker:
|
worker:
|
||||||
image: ${A_SHARE_SERVER_IMAGE:?必须提供不可变 Server 镜像引用}
|
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}
|
env_file: ${A_SHARE_WORKER_ENV_FILE:-/etc/a-share-analysis/worker.env}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# VITE 变量会进入浏览器,只允许公开配置。
|
# VITE 变量会进入浏览器,只允许公开配置。
|
||||||
VITE_APP_ENV=development
|
VITE_APP_ENV=development
|
||||||
VITE_APP_TITLE=A-Share Analysis
|
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
|
VITE_ENABLE_DEVTOOLS=true
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# VITE 变量会进入浏览器,只允许公开配置。
|
# VITE 变量会进入浏览器,只允许公开配置。
|
||||||
VITE_APP_ENV=production
|
VITE_APP_ENV=production
|
||||||
VITE_APP_TITLE=A-Share Analysis
|
VITE_APP_TITLE=A-Share Analysis
|
||||||
VITE_API_BASE_URL=/api
|
VITE_API_BASE_URL=
|
||||||
VITE_ENABLE_DEVTOOLS=false
|
VITE_ENABLE_DEVTOOLS=false
|
||||||
|
|||||||
@@ -9,11 +9,14 @@ User=a-share
|
|||||||
Group=a-share
|
Group=a-share
|
||||||
WorkingDirectory=/opt/a-share-analysis/current
|
WorkingDirectory=/opt/a-share-analysis/current
|
||||||
EnvironmentFile=/etc/a-share-analysis/api.env
|
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
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
NoNewPrivileges=true
|
NoNewPrivileges=true
|
||||||
PrivateTmp=true
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|||||||
@@ -9,11 +9,14 @@ User=a-share
|
|||||||
Group=a-share
|
Group=a-share
|
||||||
WorkingDirectory=/opt/a-share-analysis/current
|
WorkingDirectory=/opt/a-share-analysis/current
|
||||||
EnvironmentFile=/etc/a-share-analysis/scheduler.env
|
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
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
NoNewPrivileges=true
|
NoNewPrivileges=true
|
||||||
PrivateTmp=true
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|||||||
@@ -9,11 +9,14 @@ User=a-share
|
|||||||
Group=a-share
|
Group=a-share
|
||||||
WorkingDirectory=/opt/a-share-analysis/current
|
WorkingDirectory=/opt/a-share-analysis/current
|
||||||
EnvironmentFile=/etc/a-share-analysis/worker.env
|
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
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
NoNewPrivileges=true
|
NoNewPrivileges=true
|
||||||
PrivateTmp=true
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|||||||
@@ -1,19 +1,32 @@
|
|||||||
# Docker Compose 生产部署
|
# 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/` 或其他受控目录。
|
构建期只能给 Web 注入公开变量,生产秘密不得使用 `ARG`、写入镜像或提交仓库:
|
||||||
- Compose 通过外部路径加载,不在 YAML、镜像和 Git 中保存秘密。
|
|
||||||
- 镜像引用必须包含版本或内容摘要,禁止浮动 `latest`。
|
|
||||||
- 第一阶段只依赖 Redis;MySQL 和 RocketMQ 5.x 功能开关保持关闭。
|
|
||||||
- 启用 RocketMQ 时优先连接受运维管理的外部集群或独立基础设施 Compose,不把 NameServer、Broker、Proxy 混入应用发布生命周期。
|
|
||||||
|
|
||||||
## 发布与回退
|
```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 不随应用镜像回退;禁止用删除数据目录的方式回退应用。
|
||||||
|
|||||||
@@ -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
|
```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 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
|
docker compose -f deploy/compose/development.yml down
|
||||||
```
|
```
|
||||||
|
|
||||||
Redis 只绑定 `127.0.0.1:6379`,数据写入仓库忽略的 `.local/redis/`。不得把该 Compose 文件用于生产。
|
Redis 只绑定 `127.0.0.1:6379`,数据写入仓库忽略的 `.local/redis/`。RocketMQ 由开发者独立维护 NameServer、Broker 与需要时的 Proxy;TCP 端口可达不等于生产消费链路通过,完整联调仍需验证 Topic 路由和实际生产/消费。
|
||||||
|
|
||||||
## 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 和这些模板,不能在本文档中假装应用已经可启动。
|
|
||||||
|
|||||||
@@ -1,37 +1,40 @@
|
|||||||
# Ubuntu 源码生产部署
|
# 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
|
```bash
|
||||||
uname -m
|
uname -m
|
||||||
node --version
|
node --version
|
||||||
pnpm --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/<版本或提交>/`。
|
生产配置放在 `/etc/a-share-analysis/api.env`、`scheduler.env`、`worker.env`,建议权限 `root:a-share 0640`;Web 的 `VITE_` 公开变量在构建前注入。三个 systemd 单元通过各自的 `EnvironmentFile=` 只读取本进程需要的键,运行用户为非登录系统用户 `a-share`。
|
||||||
- 当前版本:`/opt/a-share-analysis/current` 符号链接。
|
|
||||||
- 生产配置:`/etc/a-share-analysis/api.env`、`scheduler.env`、`worker.env`、`web.env`。
|
|
||||||
- 运行用户:非登录系统用户 `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 文件是路径和权限契约示例,不能冒充已经验证的线上启动文件。后续应用功能必须补充实际构建、启动和健康检查验证。
|
使用 `systemctl status`、`journalctl -u`、HTTP 健康端点和 Nginx 访问结果确认发布。失败时把 `current` 原子切回上一完整发布目录,重启三个服务并再次验证。生产配置和数据不随代码目录覆盖;RocketMQ 的 NameServer、Broker、Proxy 与 CommitLog 由运维独立维护。
|
||||||
|
|
||||||
## 升级、检查与回退
|
|
||||||
|
|
||||||
在新的不可变发布目录完成安装、构建和配置校验后,原子切换 `current`,依次重启 API、Scheduler、Worker 并检查 `systemctl status`、`journalctl` 和 HTTP 健康端点。失败时切回上一完整目录并重启服务;生产配置不随版本切换覆盖。
|
|
||||||
|
|
||||||
项目部署流程禁止连接或读取生产数据库做开发验证。数据库备份属于独立运维职责,应用回退不得删除或覆盖数据。
|
|
||||||
|
|
||||||
RocketMQ 由运维独立维护 NameServer、Broker 和需要时的 Proxy。应用发布只读取受控的 RocketMQ Endpoint、Topic 前缀与 Group 配置,不负责创建或升级集群,也不得把 Broker 数据目录放进应用发布目录。
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
import assert from 'node:assert/strict';
|
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 test from 'node:test';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
|
||||||
const repoRoot = new URL('../../', import.meta.url);
|
const repoRoot = new URL('../../', import.meta.url);
|
||||||
const processNames = ['api', 'scheduler', 'worker', 'web'];
|
const processNames = ['api', 'scheduler', 'worker', 'web'];
|
||||||
const sensitiveKeyPattern = /(PASSWORD|API_KEY|TOKEN|SECRET)$/;
|
const sensitiveKeyPattern = /(PASSWORD|API_KEY|TOKEN|SECRET)$/;
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
async function readRepoFile(relativePath) {
|
async function readRepoFile(relativePath) {
|
||||||
return readFile(new URL(relativePath, repoRoot), 'utf8');
|
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 () => {
|
test('生产模板禁止模拟数据并保持 MySQL 5.7 与 RocketMQ 配置契约', async () => {
|
||||||
const rocketMqTemplates = new Map();
|
const rocketMqTemplates = new Map();
|
||||||
for (const environment of ['development', 'production']) {
|
for (const environment of ['development', 'production']) {
|
||||||
@@ -149,3 +166,81 @@ test('部署文档覆盖本地、Compose 生产和 Ubuntu 源码生产', async (
|
|||||||
assert.match(source, /pnpm 10/);
|
assert.match(source, /pnpm 10/);
|
||||||
assert.match(source, /不负责安装/);
|
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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user