refactor(GOV-009): 彻底切换为仅源码部署
This commit is contained in:
@@ -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
|
||||
@@ -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。
|
||||
|
||||
@@ -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。
|
||||
|
||||
## 开发入口
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 不随应用镜像回退;禁止用删除数据目录的方式回退应用。
|
||||
@@ -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 路由和实际生产/消费。
|
||||
|
||||
@@ -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 审批。
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user