refactor(GOV-009): 彻底切换为仅源码部署

This commit is contained in:
2026-08-13 21:12:12 +08:00
parent ab48f06150
commit 47d0e0d248
35 changed files with 480 additions and 712 deletions
-13
View File
@@ -1,13 +0,0 @@
.git
.gitnexus
.claude
.local
node_modules
web/node_modules
server/node_modules
web/dist
server/dist
deploy/env/**/*.local
*.log
playwright-report
test-results
+6
View File
@@ -2,6 +2,12 @@
本项目采用语义化版本。功能级变化请从 `docs/features/` 按功能编号追溯。
## Unreleased
- 项目生产交付统一为 Ubuntu 源码部署,移除 Dockerfile、Compose、镜像校验和容器测试入口。
- 本地 Redis、MySQL 5.7.42 与 RocketMQ 5.x 均作为外部服务,项目不再提供自动启动或容器兜底。
- 新增源码发布门禁,校验 Web/Server 构建产物与 systemd、Nginx 配置一致性。
## 0.0.7 - 2026-08-13
- 将项目唯一消息队列技术基线由 RabbitMQ 调整为 RocketMQ 5.x。
+4 -5
View File
@@ -10,18 +10,17 @@ A read-only A-share market overview and analysis tool.
- `web/`: Vue 3 market terminal frontend.
- `server/`: NestJS API、Scheduler 与 Worker 三个独立进程。
- `deploy/`: MySQL 5.7、Redis、RocketMQ 5.x 的配置和双部署入口。
- `deploy/`: MySQL 5.7、Redis、RocketMQ 5.x 的配置契约,以及 systemd/Nginx 源码部署模板。
- `docs/`: Product and implementation specifications.
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):Web/Server 不可变镜像与外部配置。
- [Ubuntu 源码生产部署](docs/deployment/ubuntu-source-production.md):支持 Ubuntu 22.04/24.04 LTS x86_64。
- [本地开发环境](docs/deployment/local-development.md):使用开发机已有 Redis,应用全部从源码运行。
- [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.x、pnpm 10.25.0、Nginx 和外部基础设施由服务器管理员提供。项目不安装、启动、停止或删除 Redis、MySQL 和 RocketMQ。
## 开发入口
-23
View File
@@ -1,23 +0,0 @@
name: a-share-analysis-development
services:
redis:
image: redis:7.2.5-alpine
container_name: a-share-analysis-dev-redis
command: ['redis-server', '--appendonly', 'yes']
ports:
- '127.0.0.1:6379:6379'
volumes:
- ../../.local/redis:/data
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
networks:
- a-share-analysis-dev
networks:
a-share-analysis-dev:
name: a-share-analysis-dev
-53
View File
@@ -1,53 +0,0 @@
# Web/Server 镜像与三进程入口均已纳入发布验证;外部基础设施继续由运维管理。
name: a-share-analysis-production
services:
web:
image: ${A_SHARE_WEB_IMAGE:?必须提供不可变 Web 镜像引用}
ports:
- '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']
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'
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']
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']
env_file: ${A_SHARE_WORKER_ENV_FILE:-/etc/a-share-analysis/worker.env}
restart: unless-stopped
+3 -3
View File
@@ -54,7 +54,7 @@ tools/ 可重复执行的治理和开发工具
- 应用运行环境只定义 `development` 与 `production`;生产环境禁止模拟数据、热更新、调试端口和开发默认值回退。
- API、Scheduler、Worker、Web 分别拥有开发和生产模板;同一进程跨环境使用相同变量名,通过启动 Schema 校验条件必填项。
- 本地开发使用 Docker Compose 只启动 Redis,应用进程从源码运行;第一阶段 MySQL 与 RocketMQ 保持关闭。需要联调时可以连接本机独立运行的 RocketMQ 5.x。
- 生产同时支持 Docker Compose 和 Ubuntu 22.04/24.04 LTS x86_64 源码部署,两者共用配置契约和进程边界。
- 源码部署由管理员预装 Node.js 22 LTS、pnpm 10、Nginx 和基础设施;项目只验证,不安装或修改系统软件源。
- 本地开发直接连接开发机已开机自启的 Redis `127.0.0.1:6379`,应用进程从源码运行;第一阶段 MySQL 与 RocketMQ 保持关闭,需要联调时连接开发者独立维护的外部服务。
- 生产只支持 Ubuntu 22.04/24.04 LTS x86_64 源码部署,不提供容器运行、构建、测试或部署入口。
- 源码部署由管理员预装 Node.js 22.x、pnpm 10.25.0、Nginx 和外部基础设施;项目只验证,不安装、启动、停止或删除系统服务。
- 源码部署使用不可变发布目录、`a-share` 用户和 systemd 独立进程配置;生产 env 存放在 `/etc/a-share-analysis/`,不随代码版本切换覆盖。
@@ -1,33 +0,0 @@
# Docker Compose 生产部署
生产 Compose 部署使用两个不可变镜像:Web 静态 Nginx 镜像,以及同时包含 API、Scheduler、Worker 三个入口的 Server 镜像。MySQL 5.7.42、Redis 与 RocketMQ 5.x 由运维独立管理,不进入应用 Compose 生命周期;其他 MySQL 5.7 补丁版本也不得替代发布验收。
## 构建与配置
构建期只能给 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> .
```
把四份生产模板复制到服务器的 `/etc/a-share-analysis/`,由运维填写并设置最小权限。Web 配置在镜像构建期固化,因此 `web.env` 只作为发布参数记录,不挂载到容器;API、Scheduler、Worker 分别读取自己的外部 env 文件。所有生产镜像引用必须使用内容摘要,禁止 `latest` 和任何普通 tag。
## 发布与验证
```bash
export A_SHARE_WEB_IMAGE='registry.example/a-share-web@sha256:<摘要>'
export A_SHARE_SERVER_IMAGE='registry.example/a-share-server@sha256:<摘要>'
pnpm check:images
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 不随应用镜像回退;禁止用删除数据目录的方式回退应用。
+7 -6
View File
@@ -1,20 +1,22 @@
# 本地开发环境
本地开发由开发机提供 Node.js 22.x 与 pnpm 10.25.0,项目不安装或修改 Node。Docker Compose 只管理 Redis;Web、API、Scheduler 和 Worker分别从源码启动,MySQL 5.7.42 与 RocketMQ 5.x 使用外部开发实例且默认关闭。
本地开发由开发机提供 Node.js 22.x、pnpm 10.25.0 和已开机自启的 Redis `127.0.0.1:6379`。Web、API、Scheduler 与 Worker 全部从源码启动;MySQL 5.7.42 和 RocketMQ 5.x 也是外部服务,项目脚本不负责安装、启动、停止或删除任何基础设施。
## 首次准备
```bash
node --version
pnpm --version
redis-cli -h 127.0.0.1 -p 6379 ping
pnpm install --frozen-lockfile
docker compose -f deploy/compose/development.yml up -d
pnpm check:runtime
```
Redis 必须返回 `PONG`。如果不可达,应先修复开发机外部服务;测试和应用不会创建替代实例。
把 `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.42,字符集为 `utf8mb4`、排序规则为 `utf8mb4_general_ci`、会话时区为 `+08:00`。Schema 工具和开发检查禁止连接、探测或读取生产库。
固定开发 MySQL 只允许 `a_share_analysis_dev`,主机必须是回环地址或代码内白名单 `112.192.20.11`,服务端固定为 MySQL 5.7.42,字符集为 `utf8mb4`、排序规则为 `utf8mb4_general_ci`、会话时区为 `+08:00`。Schema 工具和开发检查禁止连接、探测或读取生产库。
## 启动顺序
@@ -34,8 +36,7 @@ API 健康端点是 `http://127.0.0.1:3000/api/v1/health`,Web 是 `http://127.
```bash
pnpm check:contracts
pnpm verify
pnpm verify:e2e
docker compose -f deploy/compose/development.yml down
pnpm verify:release
```
Redis 只绑定 `127.0.0.1:6379`,数据写入仓库忽略的 `.local/redis/`。RocketMQ 由开发者独立维护 NameServer、Broker 与需要时的 Proxy;TCP 端口可达不等于生产消费链路通过,完整联调仍需验证 Topic 路由和实际生产/消费。
应用进程通过各自终端或信号正常停止,外部 Redis/MySQL/RocketMQ 生命周期不由项目管理。RocketMQ 的 TCP 端口可达不等于生产消费链路通过,完整联调仍需验证 Topic 路由和实际生产/消费。
+12 -6
View File
@@ -1,19 +1,22 @@
# Ubuntu 源码生产部署
正式支持 Ubuntu 22.04 LTS 与 Ubuntu 24.04 LTS x86_64。服务器管理员预先提供 Node.js 22 LTS、pnpm 10、Nginx,以及 MySQL 5.7.42、Redis、RocketMQ 5.x 或相应外部服务;项目不负责安装、升级或修改系统软件源,其他 MySQL 补丁版本不得代替生产验收。
这是项目唯一生产部署方式,支持 Ubuntu 22.04 LTS 与 Ubuntu 24.04 LTS x86_64。服务器管理员预先提供 Node.js 22.x、pnpm 10.25.0、Nginx,以及外部 MySQL 5.7.42、Redis、RocketMQ 5.x;项目不负责安装、升级、启动、停止、删除或修改这些系统软件与服务。
## 构建发布目录
在固定提交的干净工作区执行:
```bash
uname -m
node --version
pnpm --version
pnpm install --frozen-lockfile
pnpm check:contracts
pnpm verify
pnpm verify:release
```
架构必须为 `x86_64`,Node.js 必须为 22.x,pnpm 必须为 10.x。每次发布使用 `/opt/a-share-analysis/releases/<版本或提交>/` 新目录,验证后再原子切换 `/opt/a-share-analysis/current`,禁止在 current 中原地覆盖。
架构必须为 `x86_64`,Node.js 必须为 22.x,pnpm 必须为 10.25.0。发布门禁会完成测试、Web/Server 构建、双视口浏览器检查,并核对 Web、API、Scheduler、Worker 产物与 systemd/Nginx 模板。
每次发布使用 `/opt/a-share-analysis/releases/<版本或提交>/` 新目录。完整传输并验证目录后,再把 `/opt/a-share-analysis/current` 原子切换到目标目录;禁止在 `current` 中原地覆盖文件,也禁止只发布部分进程产物。
## 配置与 systemd
@@ -31,10 +34,13 @@ 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
curl --fail http://127.0.0.1/
```
当前工程尚无业务表迁移;后续新增迁移后必须在切换 current 前执行并记录结果。生产环境禁止 `synchronize: true`,开发流程也禁止用生产数据库做 Schema 基线或连接测试。
当前工程尚无业务表迁移;后续新增迁移后必须在切换 `current` 前单独执行并记录结果。生产环境禁止 `synchronize: true`,开发流程也禁止用生产数据库做 Schema 基线或连接测试。
## 检查与回退
使用 `systemctl status`、`journalctl -u`、HTTP 健康端点和 Nginx 访问结果确认发布。失败时把 `current` 原子切回上一完整发布目录,重启三个服务并再次验证。生产配置和数据不随代码目录覆盖;RocketMQ 的 NameServer、Broker、Proxy 与 CommitLog 由运维独立维护。
使用 `systemctl status`、`journalctl -u`、HTTP 健康端点和 Nginx 同源访问结果确认发布。任一验证失败时,把 `/opt/a-share-analysis/current` 原子切回上一完整发布目录,重启三个 systemd 服务并重复健康检查。
`/etc/a-share-analysis/*.env` 和外部 Redis、MySQL、RocketMQ 数据不随应用目录切换而覆盖或删除。RocketMQ 的 NameServer、Broker、Proxy 与 CommitLog 由运维独立维护。
@@ -15,3 +15,4 @@ code_review: review/code-review.md
standards_review: review/standards-review.md
implementation_commit: 9203fdd
release: v0.0.5
superseded_by: GOV-009/source-only-deployment-design.md
@@ -22,7 +22,7 @@ repository
│ ├── http-contracts/ Server DTO
│ ├── message-contracts/ Server 内部 RocketMQ 信封
│ └── infrastructure/ Redis、MySQL、消息与生命周期适配
├── deploy/ Compose、systemd、Nginx、env 模板
├── deploy/ systemd、Nginx、env 源码部署模板
└── tools/ 治理、Schema、契约、边界和部署验证
```
@@ -33,7 +33,7 @@ repository
3. **一个 Server 工程、三个进程入口。** API、Scheduler、Worker 使用相同基础设施与消息契约,但启动、配置和部署彼此独立。当前规模下没有必要拆成三个仓库或三个重复 Nest 工程;未来可独立扩容进程而不复制公共代码。
4. **配置是进程级最小权限。** API 不读取任务消费配置,Scheduler 不读取 MySQL/AI 密钥,Worker 不读取 API 端口。配置先校验和冻结,再通过动态模块注入,测试与生产使用同一装配路径。
5. **基础设施通过 Adapter 隔离。** 业务代码将依赖 MessageBus、Repository 和数据源端口;RocketMQ 客户端尚未验证时明确禁用,避免以临时内存队列形成错误语义。
6. **部署方式共享同一产物。** Ubuntu 源码部署使用 Web dist 与三个 Server dist 入口;现有 Docker Compose 只作为可选交付适配层复用这些产物,不形成第二套应用逻辑。按用户 2026-08-13 的决定,日常开发不再构建 Docker,待核心功能完成后再决定保留或移除。
6. **生产只有一条源码路径。** Ubuntu 源码部署直接使用 Web dist 与三个 Server dist 入口,由 systemd 和 Nginx 管理;源码发布门禁验证这些路径一致,避免维护第二套构建和运行语义。
## 适合下一阶段的扩展方式
@@ -48,7 +48,7 @@ repository
- RocketMQ 只有契约和禁用 Adapter,尚未选择并验证 Node 客户端。
- TypeORM 已固定安全选项,但尚无业务实体和迁移。
- 用户体系和订阅能力按用户决定后置,不在 GOV-009 范围。
- Docker 交付方式尚未最终确认;现有文件和历史验证保留,但不能据此推定最终生产一定采用容器部署。
- 仅源码部署调整已实现;历史 Docker 验证只作为决策追溯,不再代表当前可用能力。
## 复核结论
@@ -61,7 +61,7 @@ RocketMQ 消息契约属于 Server 的进程间协议,只由 Scheduler 与 Wor
## 配置加载
本地启动固定读取 `deploy/env/development/<process>.env.local`;生产运行依靠 Compose `env_file` 或 systemd `EnvironmentFile` 注入,不从源码目录寻找生产 env。配置解析采用白名单 Schema,未知键可以保留给其他进程,但当前进程只解析最小集合。
本地启动固定读取 `deploy/env/development/<process>.env.local`;生产运行只依靠 systemd `EnvironmentFile` 注入,不从源码目录寻找生产 env。配置解析采用白名单 Schema,未知键可以保留给其他进程,但当前进程只解析最小集合。
敏感值只用于建立连接,错误对象和日志使用键名而不输出实际值。Web 构建只加载 `web.env.local` 中的 `VITE_` 变量。
@@ -88,7 +88,8 @@ Server 数据访问使用 Nest 官方 `@nestjs/typeorm` 集成、TypeORM 和 `my
- Web 使用 Vitest 验证启动外壳和生成客户端边界。
- Playwright 验证 Web 能启动、路由可访问且无阻断错误;GOV-009 不进行业务页面视觉设计。
- 使用真实 Redis 和固定开发 MySQL 执行只读冒烟;RocketMQ 只验证禁用态和既有管理链路,不宣称 Node 消息收发通过。
- Web、三个 Server 入口和两个 Dockerfile均执行构建验证。
- Web 与三个 Server 入口执行真实构建验证;源码发布门禁同时核对 systemd 和 Nginx 模板。
- E2E 在启动 API 前只读检查现有本机 Redis;不可达时明确失败,不启动替代基础设施。
## 回退原则
@@ -96,4 +97,4 @@ GOV-009 不迁移业务数据。回退时恢复上一 Git 标签即可移除脚
## 实施后架构论证门禁
本文档描述的是实施前目标架构。完成代码、测试、构建和 Review 后,必须根据 GitNexus 实际依赖图、Web/Server 目录、三个 Server 进程、OpenAPI 生成链路和部署镜像另写 `architecture-review.md`。复核需要说明实际实现与目标的偏差、当前选择优于替代方案的理由、何时应拆分或调整。用户再次确认前不发布 `v0.0.8`,也不把本功能视为彻底完成。
本文档描述的是实施前目标架构。完成代码、测试、构建和 Review 后,必须根据 GitNexus 实际依赖图、Web/Server 目录、三个 Server 进程、OpenAPI 生成链路和源码部署产物另写 `architecture-review.md`。复核需要说明实际实现与目标的偏差、当前选择优于替代方案的理由、何时应拆分或调整。用户再次确认前不发布 `v0.0.8`,也不把本功能视为彻底完成。
@@ -15,3 +15,4 @@ release: unreleased
completion_gate: 实施完成后必须基于实际前后端架构向用户论证并再次确认
architecture_review: architecture-review.md
source_only_deployment_design: source-only-deployment-design.md
source_only_deployment_implementation_plan: source-only-deployment-implementation-plan.md
@@ -32,7 +32,7 @@
- Server 配置解析、条件必填和启动失败策略。
- API 健康检查、OpenAPI 产物与 Web 客户端生成链路。
- Redis 与 MySQL 开发连接健康检查;RocketMQ 保持配置可选和默认关闭。
- 源码构建所需 Dockerfile 与现有生产 Compose 命令对齐。
- Ubuntu 源码部署所需 Web/Server 产物与 systemd、Nginx 命令对齐,Docker 能力由后续确认的仅源码决策移除。
- Schema 开发配置路径、固定远程开发库白名单及空库基线处理。
## 非目标
@@ -43,6 +43,7 @@
- 不把 HTTP DTO、数据库实体或消息事件做成前后端共享源码包。
- 不实现未经验证的 RocketMQ Node SDK;本功能只建立 Adapter 边界、配置校验和禁用态启动能力。
- 不创建、删除或修改生产基础设施与生产数据。
- 不管理本地或生产 Redis、MySQL、RocketMQ 的生命周期,不提供容器兜底。
## 验收标准
@@ -56,4 +57,5 @@
- Schema 工具只接受 `development`、`a_share_analysis_dev`、MySQL 5.7、`utf8mb4_general_ci` 和固定主机白名单;其他远程主机立即拒绝。
- 空开发库可以生成可验证的 captured Schema 基线,不用空 SQL 冒充结构,也不退回 `not_applicable`。
- 单元测试、契约测试、构建、开发启动冒烟和适用的浏览器验证全部通过。
- 仓库不存在活动 Dockerfile、Compose 或 Docker 专用门禁;`pnpm verify:release` 只执行源码发布检查。
- 全部实施完成后必须基于真实代码和部署产物提交《实施架构复核》,向用户论证前后端职责、依赖方向、进程边界、替代方案和演进成本;获得用户再次确认前,GOV-009 不得标记完成或创建发布标签。
@@ -46,3 +46,22 @@
- 本机 Redis `127.0.0.1:6379` 的 DB 0、1、2 分别执行只读 `PING`,三次均返回 `PONG`;未读取或修改业务键。
- 固定开发 MySQL 只读会话返回 `5.7.42-0ubuntu0.18.04.1`、库 `a_share_analysis_dev`、`utf8mb4`、`utf8mb4_general_ci`、会话时区 `+08:00`;Task 1 对象计数为 0。密码只通过临时进程环境传递,未输出或写入命令参数;未连接、探测或读取生产数据库。
- 本机 RocketMQ 只读 TCP 可达性复核:NameServer `127.0.0.1:9876`、Broker `127.0.0.1:10911`、Proxy `127.0.0.1:8081` 均可达。Node Adapter 仍保持禁用,没有创建 Topic、消费或发送消息。
## 仅源码部署调整(2026-08-13)
### RED
- `pnpm test:deployment` 因 `verify-source-deployment.mjs` 尚不存在而失败,证明新测试先于源码发布门禁实现。
- `pnpm test:environment-baseline` 因 Docker 活动文件和本地 Compose 指令仍存在而失败,证明政策测试能捕获待移除能力。
- `node --test server/tools/run-test-api.test.mjs` 因 `checkLocalRedis` 尚未导出而失败,证明外部 Redis 预检行为先有测试。
### GREEN
- 源码部署夹具:5/5;覆盖完整发布、Docker 活动文件、Worker 产物缺失、systemd root 用户和 Nginx 错误配置。
- 环境与部署契约:10/10;活动 Docker 入口不存在,本地外部 Redis和唯一 Ubuntu 源码部署规则通过。
- Redis 预检:2/2;OS 分配的本机 TCP 端口分别验证可达和不可达,不安装或启动外部服务。
- 本机 `redis-cli -h 127.0.0.1 -p 6379 ping` 返回 `PONG`。
- Server 原有 30 项 Vitest 与新增 2 项 Node 测试全部通过。
- Playwright 桌面与移动视口共 4/4,通过真实本机 Redis、API 健康接口和 Web 同源代理链路。
前述 `pnpm verify:docker` 及镜像验证内容是调整前的真实历史证据。2026-08-13 用户确认仅源码部署后,这些能力已被取代并从活动仓库删除,不再执行或作为发布依据。
@@ -6,10 +6,11 @@ GOV-009 建立了可运行的应用工程基线:
- Server 提供 API、Scheduler、Worker 三个独立入口、最小权限配置和优雅关闭生命周期。
- HTTP 契约由 Server DTO/OpenAPI 单向生成 Web 客户端;RocketMQ 消息契约保持 Server 内部。
- Redis/MySQL 只读健康检查、TypeORM MySQL 5.7.42 基线和 RocketMQ 禁用 Adapter 已建立。
- 开发/生产模板、Docker Compose、Ubuntu systemd/Nginx 与源码部署路径已对齐。
- 开发/生产模板、Ubuntu systemd/Nginx 与唯一源码部署路径已对齐。
- Schema 支持固定远程开发库与真实空库 `captured_empty`,历史清单保持不可变。
- 新增契约漂移、Vue SFC 边界、三进程生命周期、Playwright 真实 API 和 Docker 集成门禁。
- 新增契约漂移、Vue SFC 边界、三进程生命周期、Playwright 真实 API 和源码发布门禁。
- 删除 Dockerfile、Compose、镜像校验和容器测试;Redis/MySQL/RocketMQ 统一为外部服务。
当前状态为 `reviewing/unreleased`。实现提交已推送至 `origin/main`,但按用户要求,必须完成基于真实实现的架构论证并再次确认后才能创建和推送 `v0.0.8` 标签。
Docker Compose 当前降级为待定的可选交付能力:保留既有文件和历史验证,不进入后续日常开发必跑流程;核心功能完成后由用户决定最终保留 Docker 还是仅采用源码部署。
2026-08-13 后续确认的仅源码部署决策已取代此前可选 Docker 交付结论。历史验证记录继续保留用于追溯,但仓库不再提供可执行 Docker 能力。
@@ -44,8 +44,10 @@
## R7 部署与质量
- Web 与 Server 分别提供可重复构建的 Dockerfile,生产 Compose 使用不可变镜像并与真实产物路径一致。
- 源码部署的 systemd 命令必须与 Server 构建产物一致。
- 项目只支持 Ubuntu 22.04/24.04 LTS x86_64 源码部署,不提供 Docker 运行、构建、测试或部署入口。
- Web、API、Scheduler、Worker 构建产物必须完整,源码部署的 systemd 命令必须与三个 Server 构建入口一致。
- 本地 Redis 和生产 Redis/MySQL/RocketMQ 均为外部系统服务,项目脚本不得安装、启动、停止或删除它们。
- `verify:release` 必须校验真实构建产物、systemd 最小权限和 Nginx Web/API 链路;Redis 不可达时 E2E 必须明确失败且不得提供容器兜底。
- 根级检查必须覆盖格式、静态检查、类型、单元测试、契约测试和构建。
- 所有关键配置边界、契约生成和启动失败行为必须有自动化测试。
- Server 使用 `@nestjs/swagger` 输出稳定的 `openapi.json`;Web 使用 `openapi-typescript` 与 `openapi-fetch` 生成类型安全客户端。
@@ -10,8 +10,9 @@ GOV-009 没有创建业务表、执行迁移、写入生产数据库、创建 Ro
## 部署回退
- Docker 恢复上一组 Web/Server 不可变镜像摘要;源码部署把 `/opt/a-share-analysis/current` 切回上一完整发布目录并重启三个 systemd 服务。
- 把 `/opt/a-share-analysis/current` 原子切回上一完整发布目录并重启 API、Scheduler、Worker 三个 systemd 服务。
- `/etc/a-share-analysis/*.env`、本地 `*.env.local`、Redis、MySQL 和 RocketMQ 数据不随应用回退覆盖或删除。
- 回退后重新执行健康检查、契约门禁和可用性验证。
- 外部基础设施和 `/etc/a-share-analysis/*.env` 不属于应用回退范围,禁止删除或覆盖。
当前尚未创建 `v0.0.8` 标签,因此无需删除或移动标签。用户确认发布后,若发布回退也只能新建更高版本修复或明确的回退提交,不能移动已推送标签。
@@ -12,6 +12,8 @@
- Schema 固定远程白名单、未知远程拒绝和 `api.env.local` 优先级测试。
- Schema 空库 captured 清单、摘要和历史目录不可覆盖测试。
- Web、Server 类型检查、单元测试和生产构建。
- 源码发布门禁检查 Docker 活动入口不存在、四类构建产物完整、systemd 最小权限和 Nginx Web/API 链路。
- API E2E 的 Redis TCP 预检覆盖可达与不可达,证明失败时不启动替代基础设施。
## 真实环境冒烟
@@ -19,10 +21,10 @@
- 固定开发 MySQL 验证认证、5.7 服务端、数据库名、字符集、排序规则和 `+08:00` 会话时区。
- RocketMQ 验证 NameServer、Broker 和 Proxy 可达;不创建 Topic,不发送消息。
- 浏览器访问 Web 与 API 健康检查,无控制台阻断错误。
- `pnpm verify:release` 只执行源码发布链路,不调用 Docker。
## 安全检查
- Git 跟踪文件不得包含本地密码、开发库凭据、AI Key 或生产地址。
- Web 构建产物不得包含 Server env 键或消息契约。
- 连接错误和测试日志不得输出密码。
@@ -92,7 +92,7 @@ count += 1;
- 只有 `*.env.example` 可以提交;真实 `.env`、`*.env.local`、密码、Token 和 API Key 必须被 Git 忽略并由运行环境注入。
- Web 只能使用公开的 `VITE_*` 变量。数据库、Redis、RocketMQ、数据源和 AI 凭据禁止进入前端变量或构建产物。
- MySQL、RocketMQ、正式数据源与 AI 使用显式功能开关;关闭时连接变量可为空,开启后缺少任何必填值都必须拒绝启动。
- Docker Compose 与源码部署必须复用同一变量契约,禁止为某种部署方式建立语义不同的别名。
- 开发源码进程与 Ubuntu 生产源码部署复用同一变量契约;项目脚本不得安装、启动、停止或删除 Redis、MySQL、RocketMQ 等外部基础设施。
- 生产配置必须位于发布目录之外,切换应用版本不得覆盖配置;日志和诊断输出必须对配置值脱敏。
## 9. 前端规范
@@ -116,7 +116,7 @@ count += 1;
## 11. MySQL 5.7 兼容规范
- 开发、测试、Docker 和生产环境统一使用 MySQL 5.7.42,禁止使用其他 5.7 补丁、`latest`、MySQL 8.x 或其他数据库代替兼容性验证。
- 开发、测试和生产环境统一使用 MySQL 5.7.42,禁止使用其他 5.7 补丁、`latest`、MySQL 8.x 或其他数据库代替兼容性验证。
- 创建数据库必须显式声明 `DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci`;建表与迁移脚本必须显式保持 `DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci`。
- ORM 和数据库驱动必须声明 `utf8mb4`,连接建立后验证会话、数据库和表的排序规则为 `utf8mb4_general_ci`,不得依赖服务器默认值。
- 文本列默认继承表级规则。列级改变字符集或排序规则必须由具体功能设计解释,并通过 ADR 审批。
+3 -2
View File
@@ -84,7 +84,8 @@ npm run governance:finish -- FEAT-MO-001 market-overview
- 发布说明和回退方法可执行。
- 中文提交已关联功能编号。
- 当前分支和本次新建标签已推送到 `origin`,功能档案保存远程核对结果。
- 配置契约变化已同步开发/生产模板和适用的 Compose、systemd、Nginx及部署文档。
- 配置契约变化已同步开发/生产模板、systemd、Nginx 和源码部署文档。
- `pnpm verify:release` 源码发布门禁通过;外部基础设施不可达时按前置条件失败,不得由项目脚本启动替代服务。
- 下一版本开发前所需的上一发布版本 Schema 基线已验证、独立提交并推送 Gitea。
## 7. 数据库 Schema 基线
@@ -132,6 +133,6 @@ git push origin <本次标签名>
- 使用语义化版本和带注释 Git Tag。
- 发布产物必须对应确定提交,不从浮动分支临时构建。
- 应用优先通过部署上一不可变镜像回退。
- 应用通过把 `/opt/a-share-analysis/current` 原子切回上一完整不可变发布目录并重启 systemd 服务回退。
- 数据库采用可兼容迁移和前向修复,破坏性回滚需先备份和演练。
- 规则、提示词和消息契约发布后不得原地覆盖,只能新增版本。
+2 -3
View File
@@ -19,8 +19,7 @@
## 环境与部署
- [本地开发环境](deployment/local-development.md)
- [Docker Compose 生产部署](deployment/docker-compose-production.md)
- [Ubuntu 源码生产部署](deployment/ubuntu-source-production.md)
- [Ubuntu 源码生产部署(唯一生产方式)](deployment/ubuntu-source-production.md)
## 数据库版本基线
@@ -41,7 +40,7 @@
- [GOV-003:项目专用 Gitea 远程仓库](features/GOV-003-gitea-local-remote/manifest.yaml)
- [GOV-004:提交后强制推送 Gitea](features/GOV-004-mandatory-gitea-push/manifest.yaml)
- [GOV-005:MySQL 5.7 字符集与兼容性基线](features/GOV-005-mysql57-charset-baseline/manifest.yaml)
- [GOV-006:开发、生产环境与双部署方式](features/GOV-006-environment-deployment-baseline/manifest.yaml)
- [GOV-006:开发、生产环境与双部署方式(历史基线,部署方式已被 GOV-009 取代)](features/GOV-006-environment-deployment-baseline/manifest.yaml)
- [GOV-007:开发数据库 Schema 基线与版本门禁](features/GOV-007-development-schema-baseline/manifest.yaml)
- [GOV-008:RocketMQ 消息队列架构调整](features/GOV-008-rocketmq-adoption/manifest.yaml)
- [GOV-009:应用工程脚手架基线](features/GOV-009-application-scaffold-baseline/manifest.yaml)
+3 -4
View File
@@ -22,17 +22,16 @@
"check:contracts": "pnpm --filter @a-share-analysis/server openapi:generate && pnpm --filter @a-share-analysis/web api:generate && pnpm exec prettier --write server/openapi/openapi.json web/src/api/generated/schema.ts && node tools/contracts/verify-generated-contracts.mjs && node tools/boundaries/verify-project-boundaries.mjs",
"test:contracts": "node --test tools/contracts/verify-generated-contracts.test.mjs",
"test:boundaries": "node --test tools/boundaries/verify-project-boundaries.test.mjs",
"test:deployment": "node --test tools/deployment/verify-image-references.test.mjs",
"test:deployment": "node --test tools/deployment/verify-source-deployment.test.mjs",
"lint": "pnpm --filter @a-share-analysis/web --if-present lint && pnpm --filter @a-share-analysis/server --if-present lint",
"format:check": "pnpm exec prettier --check .",
"typecheck": "pnpm --filter @a-share-analysis/web --if-present typecheck && pnpm --filter @a-share-analysis/server --if-present typecheck",
"test": "pnpm test:runtime && pnpm test:governance && pnpm test:mysql-baseline && pnpm test:environment-baseline && pnpm test:schema-baseline && pnpm test:contracts && pnpm test:boundaries && pnpm test:deployment && pnpm --filter @a-share-analysis/web --if-present test && pnpm --filter @a-share-analysis/server --if-present test",
"build": "pnpm --filter @a-share-analysis/web --if-present build && pnpm --filter @a-share-analysis/server --if-present build",
"verify:e2e": "pnpm --filter @a-share-analysis/web test:e2e",
"check:images": "node tools/deployment/verify-image-references.mjs",
"verify:docker": "node tools/deployment/verify-docker.mjs",
"verify:source": "node tools/deployment/verify-source-deployment.mjs",
"verify": "pnpm check:runtime && pnpm check:contracts && pnpm lint && pnpm format:check && pnpm typecheck && pnpm test && pnpm build && pnpm verify:e2e",
"verify:release": "pnpm verify && pnpm check:images && pnpm verify:docker"
"verify:release": "pnpm verify && pnpm verify:source"
},
"devDependencies": {
"@eslint/js": "9.39.5",
-25
View File
@@ -1,25 +0,0 @@
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 -1
View File
@@ -9,7 +9,7 @@
"openapi:generate": "tsx tools/export-openapi.ts",
"lint": "eslint .",
"typecheck": "tsc --noEmit -p tsconfig.json",
"test": "vitest run",
"test": "vitest run && node --test tools/run-test-api.test.mjs",
"test:watch": "vitest",
"build": "node tools/clean-build-output.mjs && nest build api && nest build scheduler && nest build worker && node tools/verify-build-output.mjs"
},
+37 -51
View File
@@ -1,51 +1,43 @@
import { execFileSync, spawn } from 'node:child_process';
import { spawn } from 'node:child_process';
import { createConnection } from 'node:net';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
const serverRoot = path.resolve(import.meta.dirname, '..');
const launcher = path.join(serverRoot, 'node_modules', 'tsx', 'dist', 'cli.mjs');
const redisContainerName = `a-share-analysis-e2e-redis-${process.pid}`;
let ownsRedisContainer = false;
let redisPort = '6379';
const localRedisAvailable = await new Promise((resolve) => {
const socket = createConnection({ host: '127.0.0.1', port: 6379 });
const finish = (available) => {
/**
* E2E 只检查开发者已维护的本机 Redis,不安装、启动或停止任何外部服务。
* 失败信息固定指向项目要求的公开本地端点,避免泄漏可注入的测试地址。
*/
export function checkLocalRedis({ host = '127.0.0.1', port = 6379, timeoutMs = 500 } = {}) {
return new Promise((resolve, reject) => {
const socket = createConnection({ host, port });
let settled = false;
const finish = (error) => {
if (settled) return;
settled = true;
socket.destroy();
resolve(available);
if (error) reject(error);
else resolve();
};
socket.setTimeout(500, () => finish(false));
socket.once('connect', () => finish(true));
socket.once('error', () => finish(false));
});
if (!localRedisAvailable) {
execFileSync(
'docker',
[
'run',
'--rm',
'--detach',
'--name',
redisContainerName,
'--publish',
'127.0.0.1::6379',
'redis:7.4.8-alpine3.21',
],
{ stdio: ['ignore', 'pipe', 'inherit'] },
);
ownsRedisContainer = true;
redisPort = execFileSync('docker', ['port', redisContainerName, '6379/tcp'], {
encoding: 'utf8',
})
.trim()
.split(':')
.at(-1);
const unavailable = () =>
finish(new Error('本机 Redis 127.0.0.1:6379 不可达,请确认外部 Redis 服务启动后重试'));
socket.setTimeout(timeoutMs, unavailable);
socket.once('connect', () => finish());
socket.once('error', unavailable);
});
}
// 浏览器集成测试只启用 API 与禁用态 MySQL;Redis 故障会形成可验证的安全 503 响应,
// 所有值均为公开测试夹具,不读取开发者的 *.env.local 或任何生产配置。
export async function runTestApi() {
await checkLocalRedis();
// 浏览器集成测试只启用 API 与 Redis,其他依赖使用公开禁用态夹具;这里不会读取
// 开发者的 *.env.local 或任何生产配置,也不会把敏感值传给子进程。
const child = spawn(process.execPath, [launcher, 'apps/api/src/main.ts'], {
cwd: serverRoot,
env: {
@@ -55,7 +47,7 @@ const child = spawn(process.execPath, [launcher, 'apps/api/src/main.ts'], {
API_HOST: '127.0.0.1',
API_PORT: '3000',
REDIS_HOST: '127.0.0.1',
REDIS_PORT: redisPort,
REDIS_PORT: '6379',
REDIS_USERNAME: '',
REDIS_PASSWORD: '',
REDIS_DATABASE: '0',
@@ -79,25 +71,19 @@ const child = spawn(process.execPath, [launcher, 'apps/api/src/main.ts'], {
});
const forwardSignal = (signal) => {
if (ownsRedisContainer) {
try {
execFileSync('docker', ['stop', redisContainerName], { stdio: 'ignore' });
} catch {
// Playwright 可能已因启动失败清理容器,重复清理保持幂等。
}
}
if (child.exitCode === null) child.kill(signal);
};
process.once('SIGINT', () => forwardSignal('SIGINT'));
process.once('SIGTERM', () => forwardSignal('SIGTERM'));
child.once('exit', (code, signal) => {
if (ownsRedisContainer) {
try {
execFileSync('docker', ['stop', redisContainerName], { stdio: 'ignore' });
} catch {
// 容器已由信号处理清理时无需再次报告错误。
}
}
if (signal) process.kill(process.pid, signal);
else process.exit(code ?? 1);
});
}
if (
process.argv[1] &&
path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))
) {
await runTestApi();
}
+42
View File
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import net from 'node:net';
import test from 'node:test';
import { checkLocalRedis } from './run-test-api.mjs';
function listenOnLoopback() {
const server = net.createServer();
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => resolve(server));
});
}
function closeServer(server) {
return new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
test('Redis 不可达时给出外部服务提示并拒绝启动', async () => {
const server = await listenOnLoopback();
const address = server.address();
assert.ok(address && typeof address !== 'string');
await closeServer(server);
await assert.rejects(
checkLocalRedis({ host: '127.0.0.1', port: address.port, timeoutMs: 100 }),
/本机 Redis.*127\.0\.0\.1:6379.*启动后重试/,
);
});
test('Redis TCP 可达时预检通过', async (context) => {
const server = await listenOnLoopback();
context.after(() => closeServer(server));
const address = server.address();
assert.ok(address && typeof address !== 'string');
await assert.doesNotReject(() =>
checkLocalRedis({ host: '127.0.0.1', port: address.port, timeoutMs: 100 }),
);
});
-232
View File
@@ -1,232 +0,0 @@
import { execFileSync } from 'node:child_process';
import process from 'node:process';
const suffix = `${process.pid}`;
const webImage = `a-share-analysis-web:verify-${suffix}`;
const serverImage = `a-share-analysis-server:verify-${suffix}`;
const network = `a-share-analysis-verify-${suffix}`;
const redis = `a-share-analysis-verify-redis-${suffix}`;
const api = `a-share-analysis-verify-api-${suffix}`;
const scheduler = `a-share-analysis-verify-scheduler-${suffix}`;
const worker = `a-share-analysis-verify-worker-${suffix}`;
const web = `a-share-analysis-verify-web-${suffix}`;
const containers = [web, worker, scheduler, api, redis];
function docker(args, options = {}) {
return execFileSync('docker', args, {
cwd: process.cwd(),
encoding: 'utf8',
stdio: options.capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
});
}
function waitForContainerCommand(container, command, description) {
let lastError;
for (let attempt = 0; attempt < 30; attempt += 1) {
try {
docker(['exec', container, ...command], { capture: true });
return;
} catch (error) {
lastError = error;
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200);
}
}
throw new Error(`${description} 未在 6 秒内就绪:${String(lastError?.stderr ?? '')}`);
}
function verifyLongRunningProcess(container, entry, environment) {
const environmentArguments = environment.flatMap(([key, value]) => ['--env', `${key}=${value}`]);
docker(
[
'run',
'--detach',
'--name',
container,
...environmentArguments,
serverImage,
'node',
`/app/dist/apps/${entry}/main.js`,
],
{ capture: true },
);
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 500);
const running = docker(['inspect', container, '--format', '{{.State.Running}}'], {
capture: true,
}).trim();
if (running !== 'true') throw new Error(`${entry} 容器未保持运行`);
docker(['stop', '--time', '5', container], { capture: true });
const exitCode = docker(['inspect', container, '--format', '{{.State.ExitCode}}'], {
capture: true,
}).trim();
if (exitCode !== '0') throw new Error(`${entry} 容器未优雅退出,退出码 ${exitCode}`);
}
try {
docker(['build', '--file', 'web/Dockerfile', '--tag', webImage, '.']);
docker(['build', '--file', 'server/Dockerfile', '--tag', serverImage, '.']);
const serverUser = docker(['image', 'inspect', serverImage, '--format', '{{.Config.User}}'], {
capture: true,
}).trim();
if (serverUser !== 'node') throw new Error(`Server 镜像必须使用 node 用户,实际 ${serverUser}`);
for (const entry of ['api', 'scheduler', 'worker']) {
docker(
['run', '--rm', '--entrypoint', 'test', serverImage, '-f', `/app/dist/apps/${entry}/main.js`],
{ capture: true },
);
}
const sharedProcessEnvironment = [
['APP_ENV', 'production'],
['LOG_LEVEL', 'error'],
['REDIS_HOST', '127.0.0.1'],
['REDIS_PORT', '6379'],
['REDIS_USERNAME', ''],
['REDIS_PASSWORD', ''],
['REDIS_DATABASE', '1'],
['REDIS_TLS', 'false'],
['ROCKETMQ_ENABLED', 'false'],
['ROCKETMQ_NAMESRV_ADDR', '127.0.0.1:9876'],
['ROCKETMQ_ENDPOINT', '127.0.0.1:8081'],
['ROCKETMQ_TOPIC_PREFIX', 'a-share-analysis'],
];
verifyLongRunningProcess(scheduler, 'scheduler', [
...sharedProcessEnvironment,
['SCHEDULER_TIMEZONE', 'Asia/Shanghai'],
['SCHEDULER_ENABLED', 'true'],
['ROCKETMQ_SEND_TIMEOUT_MS', '3000'],
]);
verifyLongRunningProcess(worker, 'worker', [
...sharedProcessEnvironment,
['MARKET_DATA_MODE', 'live'],
['MARKET_DATA_PROVIDER', 'deterministic-fixture'],
['MARKET_DATA_BASE_URL', ''],
['MARKET_DATA_API_KEY', ''],
['MYSQL_ENABLED', 'false'],
['MYSQL_HOST', '127.0.0.1'],
['MYSQL_PORT', '3306'],
['MYSQL_DATABASE', 'a_share_analysis_dev'],
['MYSQL_USERNAME', ''],
['MYSQL_PASSWORD', ''],
['MYSQL_CHARSET', 'utf8mb4'],
['MYSQL_COLLATION', 'utf8mb4_general_ci'],
['MYSQL_TIMEZONE', '+08:00'],
['AI_ENABLED', 'false'],
['AI_BASE_URL', ''],
['AI_API_KEY', ''],
['AI_MODEL', ''],
['AI_TIMEOUT_MS', '30000'],
['ROCKETMQ_CONSUMER_GROUP', 'a-share-analysis-worker'],
['ROCKETMQ_CONSUMER_CONCURRENCY', '4'],
]);
docker(['network', 'create', network], { capture: true });
docker(['run', '--detach', '--name', redis, '--network', network, 'redis:7.4.8-alpine3.21'], {
capture: true,
});
docker(
[
'run',
'--detach',
'--name',
api,
'--network',
network,
'--network-alias',
'api',
'--env',
'APP_ENV=production',
'--env',
'LOG_LEVEL=error',
'--env',
'API_HOST=0.0.0.0',
'--env',
'API_PORT=3000',
'--env',
`REDIS_HOST=${redis}`,
'--env',
'REDIS_PORT=6379',
'--env',
'REDIS_USERNAME=',
'--env',
'REDIS_PASSWORD=',
'--env',
'REDIS_DATABASE=0',
'--env',
'REDIS_TLS=false',
'--env',
'MYSQL_ENABLED=false',
'--env',
'MYSQL_HOST=127.0.0.1',
'--env',
'MYSQL_PORT=3306',
'--env',
'MYSQL_DATABASE=a_share_analysis_dev',
'--env',
'MYSQL_USERNAME=',
'--env',
'MYSQL_PASSWORD=',
'--env',
'MYSQL_CHARSET=utf8mb4',
'--env',
'MYSQL_COLLATION=utf8mb4_general_ci',
'--env',
'MYSQL_TIMEZONE=+08:00',
'--env',
'AI_ENABLED=false',
'--env',
'AI_BASE_URL=',
'--env',
'AI_API_KEY=',
'--env',
'AI_MODEL=',
'--env',
'AI_TIMEOUT_MS=30000',
serverImage,
],
{ capture: true },
);
docker(['run', '--detach', '--name', web, '--network', network, webImage], {
capture: true,
});
waitForContainerCommand(
api,
[
'node',
'-e',
"fetch('http://127.0.0.1:3000/api/v1/health').then(r=>{if(r.status!==200)process.exit(1)})",
],
'API 健康检查',
);
waitForContainerCommand(web, ['wget', '-q', '-O', '/dev/null', 'http://127.0.0.1/'], 'Web 首页');
waitForContainerCommand(
web,
['wget', '-q', '-O', '/dev/null', 'http://127.0.0.1/api/v1/health'],
'Web 到 API 的 Nginx 代理',
);
process.stdout.write('Docker 镜像、非 root 用户、三进程生命周期与 Web/API 链路验证通过\n');
} finally {
for (const container of containers) {
try {
docker(['rm', '--force', container], { capture: true });
} catch {
// 容器未创建时保持清理幂等。
}
}
try {
docker(['network', 'rm', network], { capture: true });
} catch {
// 网络未创建或已自动移除时无需报错。
}
for (const image of [webImage, serverImage]) {
try {
docker(['image', 'rm', '--force', image], { capture: true });
} catch {
// 构建中断、镜像尚未产生时保持清理幂等。
}
}
}
@@ -1,26 +0,0 @@
import process from 'node:process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const IMMUTABLE_IMAGE_PATTERN = /^\S+@sha256:[a-f0-9]{64}$/;
/**
* Compose 变量语法只能阻断空值,无法拒绝 latest 或普通 tag;发布前必须先通过
* 该校验器,确保 Web/Server 都使用包含仓库名的内容摘要引用。
*/
export function assertImmutableImageReferences(environment) {
for (const key of ['A_SHARE_WEB_IMAGE', 'A_SHARE_SERVER_IMAGE']) {
const value = environment[key] ?? '';
if (!IMMUTABLE_IMAGE_PATTERN.test(value)) {
throw new Error(`${key} 必须使用仓库名@sha256摘要`);
}
}
}
if (
process.argv[1] &&
path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))
) {
assertImmutableImageReferences(process.env);
process.stdout.write('生产镜像引用验证通过\n');
}
@@ -1,33 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { assertImmutableImageReferences } from './verify-image-references.mjs';
const digest = `sha256:${'a'.repeat(64)}`;
test('接受包含仓库名和 sha256 摘要的不可变镜像', () => {
assert.doesNotThrow(() =>
assertImmutableImageReferences({
A_SHARE_WEB_IMAGE: `registry.invalid/web@${digest}`,
A_SHARE_SERVER_IMAGE: `registry.invalid/server@${digest}`,
}),
);
});
for (const [name, image] of [
['latest', 'registry.invalid/web:latest'],
['普通 tag', 'registry.invalid/web:3a8120a'],
['裸摘要', digest],
['空值', ''],
]) {
test(`拒绝 Web ${name} 镜像引用`, () => {
assert.throws(
() =>
assertImmutableImageReferences({
A_SHARE_WEB_IMAGE: image,
A_SHARE_SERVER_IMAGE: `registry.invalid/server@${digest}`,
}),
/A_SHARE_WEB_IMAGE 必须使用仓库名@sha256摘要/,
);
});
}
@@ -0,0 +1,140 @@
import { access, readFile } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
const defaultRepositoryRoot = path.resolve(import.meta.dirname, '../..');
export const FORBIDDEN_DOCKER_PATHS = Object.freeze([
'.dockerignore',
'Dockerfile',
'web/Dockerfile',
'web/nginx.conf',
'server/Dockerfile',
'deploy/compose/development.yml',
'deploy/compose/production.yml.example',
'docs/deployment/docker-compose-production.md',
'tools/deployment/verify-docker.mjs',
'tools/deployment/verify-image-references.mjs',
'tools/deployment/verify-image-references.test.mjs',
]);
const buildOutputs = Object.freeze([
['Web', 'web/dist/index.html'],
['API', 'server/dist/apps/api/main.js'],
['Scheduler', 'server/dist/apps/scheduler/main.js'],
['Worker', 'server/dist/apps/worker/main.js'],
]);
const serverProcesses = Object.freeze([
['api', 'API'],
['scheduler', 'Scheduler'],
['worker', 'Worker'],
]);
function resolveRoot(repositoryRoot) {
return repositoryRoot instanceof URL
? fileURLToPath(repositoryRoot)
: path.resolve(repositoryRoot);
}
async function pathExists(absolutePath) {
try {
await access(absolutePath);
return true;
} catch (error) {
if (error?.code === 'ENOENT') return false;
throw error;
}
}
async function assertDockerArtifactsAbsent(repositoryRoot) {
for (const relativePath of FORBIDDEN_DOCKER_PATHS) {
if (await pathExists(path.join(repositoryRoot, relativePath))) {
throw new Error(`禁止存在 Docker 活动文件:${relativePath}`);
}
}
}
async function assertBuildOutputsPresent(repositoryRoot) {
for (const [displayName, relativePath] of buildOutputs) {
if (!(await pathExists(path.join(repositoryRoot, relativePath)))) {
throw new Error(`${displayName} 构建入口不存在:${relativePath}`);
}
}
}
function assertUnitValue(unit, displayName, key, expectedValue) {
const pattern = new RegExp(
`^${key}=${expectedValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`,
'm',
);
if (!pattern.test(unit)) {
throw new Error(`${displayName} systemd 必须使用 ${key}=${expectedValue}`);
}
}
async function assertSystemdUnits(repositoryRoot) {
for (const [processName, displayName] of serverProcesses) {
const relativePath = `deploy/systemd/a-share-${processName}.service.example`;
let unit;
try {
unit = await readFile(path.join(repositoryRoot, relativePath), 'utf8');
} catch (error) {
if (error?.code === 'ENOENT')
throw new Error(`${displayName} systemd 配置不存在:${relativePath}`);
throw error;
}
const environmentFile = `/etc/a-share-analysis/${processName}.env`;
assertUnitValue(unit, displayName, 'User', 'a-share');
assertUnitValue(unit, displayName, 'Group', 'a-share');
assertUnitValue(unit, displayName, 'WorkingDirectory', '/opt/a-share-analysis/current');
assertUnitValue(unit, displayName, 'EnvironmentFile', environmentFile);
assertUnitValue(unit, displayName, 'ExecStartPre', `/usr/bin/test -r ${environmentFile}`);
assertUnitValue(
unit,
displayName,
'ExecStart',
`/usr/bin/node /opt/a-share-analysis/current/server/dist/apps/${processName}/main.js`,
);
}
}
async function assertNginxConfig(repositoryRoot) {
const relativePath = 'deploy/nginx/a-share-analysis.conf.example';
let nginx;
try {
nginx = await readFile(path.join(repositoryRoot, relativePath), 'utf8');
} catch (error) {
if (error?.code === 'ENOENT') throw new Error(`Nginx 配置不存在:${relativePath}`);
throw error;
}
for (const [description, pattern] of [
['Web dist 根目录', /\broot\s+\/opt\/a-share-analysis\/current\/web\/dist\s*;/],
['API 代理 location', /location\s+\/api\/\s*\{/],
['API 代理目标', /proxy_pass\s+http:\/\/127\.0\.0\.1:3000\s*;/],
]) {
if (!pattern.test(nginx)) throw new Error(`Nginx 缺少${description}`);
}
}
/**
* 源码发布门禁只验证应用产物和进程配置,不连接或改变任何外部基础设施。
*/
export async function verifySourceDeployment(repositoryRoot = defaultRepositoryRoot) {
const resolvedRoot = resolveRoot(repositoryRoot);
await assertDockerArtifactsAbsent(resolvedRoot);
await assertBuildOutputsPresent(resolvedRoot);
await assertSystemdUnits(resolvedRoot);
await assertNginxConfig(resolvedRoot);
}
if (
process.argv[1] &&
path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))
) {
await verifySourceDeployment();
process.stdout.write('源码发布产物与 systemd/Nginx 配置验证通过\n');
}
@@ -0,0 +1,100 @@
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { verifySourceDeployment } from './verify-source-deployment.mjs';
const processNames = ['api', 'scheduler', 'worker'];
function systemdUnit(processName, { user = 'a-share' } = {}) {
const displayName =
processName === 'api' ? 'API' : `${processName[0].toUpperCase()}${processName.slice(1)}`;
return `[Unit]
Description=A Share Analysis ${displayName}
[Service]
Type=simple
User=${user}
Group=a-share
WorkingDirectory=/opt/a-share-analysis/current
EnvironmentFile=/etc/a-share-analysis/${processName}.env
ExecStartPre=/usr/bin/test -r /etc/a-share-analysis/${processName}.env
ExecStart=/usr/bin/node /opt/a-share-analysis/current/server/dist/apps/${processName}/main.js
[Install]
WantedBy=multi-user.target
`;
}
async function createSourceReleaseFixture({
omit,
apiUser = 'a-share',
invalidNginx = false,
} = {}) {
const root = await mkdtemp(path.join(tmpdir(), 'a-share-source-release-'));
const files = new Map([
['web/dist/index.html', '<!doctype html><title>A Share Analysis</title>\n'],
['server/dist/apps/api/main.js', 'export {};\n'],
['server/dist/apps/scheduler/main.js', 'export {};\n'],
['server/dist/apps/worker/main.js', 'export {};\n'],
...processNames.map((processName) => [
`deploy/systemd/a-share-${processName}.service.example`,
systemdUnit(processName, { user: processName === 'api' ? apiUser : 'a-share' }),
]),
[
'deploy/nginx/a-share-analysis.conf.example',
invalidNginx
? 'server { root /srv/invalid; }\n'
: `server {
root /opt/a-share-analysis/current/web/dist;
location /api/ {
proxy_pass http://127.0.0.1:3000;
}
}
`,
],
]);
for (const [relativePath, content] of files) {
if (relativePath === omit) continue;
const absolutePath = path.join(root, relativePath);
await mkdir(path.dirname(absolutePath), { recursive: true });
await writeFile(absolutePath, content);
}
return root;
}
test('完整源码发布夹具通过', async () => {
const root = await createSourceReleaseFixture();
await assert.doesNotReject(() => verifySourceDeployment(root));
});
test('任一 Docker 活动文件会阻断源码发布', async () => {
const root = await createSourceReleaseFixture();
await writeFile(path.join(root, 'Dockerfile'), 'FROM node:22\n');
await assert.rejects(() => verifySourceDeployment(root), /禁止存在 Docker 活动文件.*Dockerfile/);
});
test('Server 构建入口缺失会阻断源码发布', async () => {
const root = await createSourceReleaseFixture({ omit: 'server/dist/apps/worker/main.js' });
await assert.rejects(() => verifySourceDeployment(root), /Worker 构建入口不存在/);
});
test('systemd 配置不是最小权限时会阻断源码发布', async () => {
const root = await createSourceReleaseFixture({ apiUser: 'root' });
await assert.rejects(() => verifySourceDeployment(root), /API systemd 必须使用 User=a-share/);
});
test('Nginx 未代理 API 或未指向 Web dist 时会阻断源码发布', async () => {
const root = await createSourceReleaseFixture({ invalidNginx: true });
await assert.rejects(() => verifySourceDeployment(root), /Nginx/);
});
+33 -86
View File
@@ -1,15 +1,10 @@
import assert from 'node:assert/strict';
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 { access, readFile } from 'node:fs/promises';
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');
@@ -115,13 +110,21 @@ test('生产模板禁止模拟数据并保持 MySQL 5.7 与 RocketMQ 配置契
}
});
test('本地 Compose 仅启动 Redis 并提供健康检查', async () => {
const compose = await readRepoFile('deploy/compose/development.yml');
assert.match(compose, /services:\s*\n\s+redis:/);
assert.match(compose, /healthcheck:/);
assert.match(compose, /127\.0\.0\.1:/);
assert.doesNotMatch(compose, /^\s{2}(mysql|rocketmq|api|worker|scheduler|web):/m);
test('活动仓库不再包含 Docker 运行构建或部署入口', async () => {
for (const relativePath of [
'.dockerignore',
'web/Dockerfile',
'web/nginx.conf',
'server/Dockerfile',
'deploy/compose/development.yml',
'deploy/compose/production.yml.example',
'docs/deployment/docker-compose-production.md',
'tools/deployment/verify-docker.mjs',
'tools/deployment/verify-image-references.mjs',
'tools/deployment/verify-image-references.test.mjs',
]) {
await assert.rejects(access(new URL(relativePath, repoRoot)), { code: 'ENOENT' });
}
});
test('活动架构与部署文档只采用 RocketMQ 5.x', async () => {
@@ -130,7 +133,6 @@ test('活动架构与部署文档只采用 RocketMQ 5.x', async () => {
'docs/architecture/system-architecture.md',
'docs/governance/code-development-standards.md',
'docs/deployment/local-development.md',
'docs/deployment/docker-compose-production.md',
'docs/deployment/ubuntu-source-production.md',
]) {
const content = await readRepoFile(relativePath);
@@ -153,73 +155,27 @@ test('生产源码部署使用独立 EnvironmentFile 并提供 Nginx 代理', as
assert.match(nginx, /\/opt\/a-share-analysis\/current\/web\/dist/);
});
test('部署文档覆盖本地、Compose 生产和 Ubuntu 源码生产', async () => {
test('部署文档要求本机外部 Redis 和唯一 Ubuntu 源码生产路径', async () => {
const local = await readRepoFile('docs/deployment/local-development.md');
const compose = await readRepoFile('docs/deployment/docker-compose-production.md');
const source = await readRepoFile('docs/deployment/ubuntu-source-production.md');
assert.match(local, /Redis/);
assert.match(compose, /Docker Compose/);
assert.match(local, /redis-cli -h 127\.0\.0\.1 -p 6379 ping/);
assert.match(local, /外部服务|开发机/);
assert.doesNotMatch(local, /docker compose/i);
assert.match(source, /Ubuntu 22\.04 LTS/);
assert.match(source, /Ubuntu 24\.04 LTS/);
assert.match(source, /Node\.js 22 LTS/);
assert.match(source, /pnpm 10/);
assert.match(source, /Node\.js 22\.x/);
assert.match(source, /pnpm 10\.25\.0/);
assert.match(source, /pnpm verify:release/);
assert.match(source, /\/opt\/a-share-analysis\/releases\//);
assert.match(source, /\/opt\/a-share-analysis\/current/);
assert.match(source, /不负责安装/);
for (const document of [local, compose, source]) {
for (const document of [local, source]) {
assert.match(document, /MySQL 5\.7\.42/);
}
});
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,
});
test('systemd 启动入口对应源码构建产物', async () => {
for (const processName of ['api', 'scheduler', 'worker']) {
const unit = await readRepoFile(`deploy/systemd/a-share-${processName}.service.example`);
const execStart = unit.match(
@@ -230,20 +186,11 @@ test('容器构建文件存在且 systemd 启动入口对应真实构建产物',
}
});
test('Docker 构建上下文排除本地配置与派生产物', async () => {
const dockerIgnore = await readRepoFile('.dockerignore');
test('根级发布命令只保留源码发布门禁', async () => {
const rootPackage = JSON.parse(await readRepoFile('package.json'));
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}`,
);
}
assert.equal(rootPackage.scripts['verify:source'], 'node tools/deployment/verify-source-deployment.mjs');
assert.equal(rootPackage.scripts['verify:release'], 'pnpm verify && pnpm verify:source');
assert.equal(rootPackage.scripts['verify:docker'], undefined);
assert.equal(rootPackage.scripts['check:images'], undefined);
});
-30
View File
@@ -1,30 +0,0 @@
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
@@ -1,20 +0,0 @@
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;
}
}