From 3a8120a33aff3fc4e1f73059eed78d88d57d33a2 Mon Sep 17 00:00:00 2001 From: Citrons <909229731@qq.com> Date: Thu, 13 Aug 2026 18:30:57 +0800 Subject: [PATCH] =?UTF-8?q?fix(GOV-009):=20=E5=AE=8C=E6=88=90=E8=84=9A?= =?UTF-8?q?=E6=89=8B=E6=9E=B6=E5=AE=A1=E6=9F=A5=E6=95=B4=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- deploy/compose/production.yml.example | 2 +- deploy/env/development/web.env.example | 2 +- docs/architecture/system-architecture.md | 2 +- docs/deployment/docker-compose-production.md | 2 +- docs/deployment/local-development.md | 6 +- docs/deployment/ubuntu-source-production.md | 2 +- docs/governance/code-development-standards.md | 4 +- package.json | 10 +- server/apps/api/src/api.module.ts | 57 ++++--- server/apps/api/src/health.controller.ts | 1 + server/apps/api/src/health.service.ts | 3 + server/apps/api/src/main.ts | 9 +- server/apps/api/test/openapi.test.ts | 9 +- server/apps/scheduler/src/main.ts | 12 +- server/apps/scheduler/src/scheduler.module.ts | 18 +- server/apps/worker/src/main.ts | 12 +- server/apps/worker/src/worker.module.ts | 18 +- server/libs/config/src/load-process-env.ts | 3 + server/libs/config/src/runtime-config.ts | 14 ++ server/libs/http-contracts/src/health.dto.ts | 5 + .../src/disabled-health.client.ts | 1 + .../src/disabled-message-bus.adapter.test.ts | 5 +- .../src/disabled-message-bus.adapter.ts | 2 + .../infrastructure/src/health-clients.test.ts | 3 +- .../infrastructure/src/message-bus.adapter.ts | 2 + .../infrastructure/src/mysql-health.client.ts | 9 +- .../infrastructure/src/process-lifecycle.ts | 29 ++++ .../infrastructure/src/redis-health.client.ts | 2 + .../infrastructure/src/typeorm-options.ts | 3 + .../src/message-envelope.test.ts | 22 ++- .../message-contracts/src/message-envelope.ts | 28 +++- server/test/process-smoke.test.ts | 77 +++++++++ server/tools/export-openapi.ts | 1 + server/tools/run-test-api.mjs | 103 ++++++++++++ .../boundaries/verify-project-boundaries.mjs | 20 ++- .../verify-project-boundaries.test.mjs | 19 +++ .../contracts/verify-generated-contracts.mjs | 29 ++++ .../verify-generated-contracts.test.mjs | 36 ++++ tools/database/schema-baseline.mjs | 22 ++- tools/database/schema-baseline.test.mjs | 33 +++- tools/deployment/verify-docker.mjs | 158 ++++++++++++++++++ .../governance/environment-baseline.test.mjs | 5 +- web/playwright.config.ts | 20 ++- web/src/vite-config.test.ts | 16 ++ web/tests/smoke.spec.ts | 8 + web/vite.config.ts | 7 + 47 files changed, 752 insertions(+), 101 deletions(-) create mode 100644 server/libs/infrastructure/src/process-lifecycle.ts create mode 100644 server/tools/run-test-api.mjs create mode 100644 tools/contracts/verify-generated-contracts.mjs create mode 100644 tools/contracts/verify-generated-contracts.test.mjs create mode 100644 tools/deployment/verify-docker.mjs create mode 100644 web/src/vite-config.test.ts diff --git a/AGENTS.md b/AGENTS.md index c9ab7fa..5d27d63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **a-share-analysis** (1453 symbols, 2031 relationships, 44 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **a-share-analysis** (1454 symbols, 2032 relationships, 44 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/deploy/compose/production.yml.example b/deploy/compose/production.yml.example index 92b1435..9d6cc8a 100644 --- a/deploy/compose/production.yml.example +++ b/deploy/compose/production.yml.example @@ -1,4 +1,4 @@ -# 应用镜像和启动入口将在 Web/Server 脚手架落地后验证。本文件先固定生产配置边界。 +# Web/Server 镜像与三进程入口均已纳入发布验证;外部基础设施继续由运维管理。 name: a-share-analysis-production services: diff --git a/deploy/env/development/web.env.example b/deploy/env/development/web.env.example index 487f9b3..dba024e 100644 --- a/deploy/env/development/web.env.example +++ b/deploy/env/development/web.env.example @@ -1,5 +1,5 @@ # VITE 变量会进入浏览器,只允许公开配置。 VITE_APP_ENV=development VITE_APP_TITLE=A-Share Analysis -VITE_API_BASE_URL=http://127.0.0.1:3000 +VITE_API_BASE_URL= VITE_ENABLE_DEVTOOLS=true diff --git a/docs/architecture/system-architecture.md b/docs/architecture/system-architecture.md index 4116a6f..69a731d 100644 --- a/docs/architecture/system-architecture.md +++ b/docs/architecture/system-architecture.md @@ -40,7 +40,7 @@ tools/ 可重复执行的治理和开发工具 ## 3. 数据库技术基线 -- 数据库服务端固定使用 MySQL 5.7.x,开发、测试和生产环境禁止使用 MySQL 8.x 代替兼容性验证。 +- 数据库服务端固定使用 MySQL 5.7.42;开发、测试和生产环境禁止使用其他补丁版本或 MySQL 8.x 代替兼容性验证。 - 数据库与表默认字符集固定为 `utf8mb4`,排序规则固定为 `utf8mb4_general_ci`。 - 数据库初始化、表结构、连接会话、ORM 配置和迁移脚本必须显式声明或验证该字符集与排序规则,不能依赖服务器默认值。 - 正式启用持久化前,必须在真实 MySQL 5.7 环境验证服务端版本、Schema/表排序规则、迁移和关键查询。 diff --git a/docs/deployment/docker-compose-production.md b/docs/deployment/docker-compose-production.md index bf1adf2..fc91434 100644 --- a/docs/deployment/docker-compose-production.md +++ b/docs/deployment/docker-compose-production.md @@ -1,6 +1,6 @@ # Docker Compose 生产部署 -生产 Compose 部署使用两个不可变镜像:Web 静态 Nginx 镜像,以及同时包含 API、Scheduler、Worker 三个入口的 Server 镜像。MySQL 5.7、Redis 与 RocketMQ 5.x 由运维独立管理,不进入应用 Compose 生命周期。 +生产 Compose 部署使用两个不可变镜像:Web 静态 Nginx 镜像,以及同时包含 API、Scheduler、Worker 三个入口的 Server 镜像。MySQL 5.7.42、Redis 与 RocketMQ 5.x 由运维独立管理,不进入应用 Compose 生命周期;其他 MySQL 5.7 补丁版本也不得替代发布验收。 ## 构建与配置 diff --git a/docs/deployment/local-development.md b/docs/deployment/local-development.md index 3e99b37..0dea1b5 100644 --- a/docs/deployment/local-development.md +++ b/docs/deployment/local-development.md @@ -1,6 +1,6 @@ # 本地开发环境 -本地开发由开发机提供 Node.js 22.x 与 pnpm 10.25.0,项目不安装或修改 Node。Docker Compose 只管理 Redis;Web、API、Scheduler 和 Worker分别从源码启动,MySQL 5.7 与 RocketMQ 5.x 使用外部开发实例且默认关闭。 +本地开发由开发机提供 Node.js 22.x 与 pnpm 10.25.0,项目不安装或修改 Node。Docker Compose 只管理 Redis;Web、API、Scheduler 和 Worker分别从源码启动,MySQL 5.7.42 与 RocketMQ 5.x 使用外部开发实例且默认关闭。 ## 首次准备 @@ -14,7 +14,7 @@ pnpm check:runtime 把 `deploy/env/development/*.env.example` 复制为同目录的 `*.env.local`。四份 local 文件被 Git 忽略:Web 只能含 `VITE_` 公开变量;API、Scheduler、Worker 按最小权限分别读取自己的配置,不共享一份全能 env。 -固定开发 MySQL 只允许 `a_share_analysis_dev`,主机必须是回环地址或代码内白名单 `112.192.20.11`,版本为 MySQL 5.7.x,字符集为 `utf8mb4`、排序规则为 `utf8mb4_general_ci`、会话时区为 `+08:00`。Schema 工具和开发检查禁止连接、探测或读取生产库。 +固定开发 MySQL 只允许 `a_share_analysis_dev`,主机必须是回环地址或代码内白名单 `112.192.20.11`,服务端补丁版本固定为 MySQL 5.7.42,字符集为 `utf8mb4`、排序规则为 `utf8mb4_general_ci`、会话时区为 `+08:00`。Schema 工具和开发检查禁止连接、探测或读取生产库。 ## 启动顺序 @@ -34,7 +34,7 @@ API 健康端点是 `http://127.0.0.1:3000/api/v1/health`,Web 是 `http://127. ```bash pnpm check:contracts pnpm verify -pnpm --filter @a-share-analysis/web test:e2e +pnpm verify:e2e docker compose -f deploy/compose/development.yml down ``` diff --git a/docs/deployment/ubuntu-source-production.md b/docs/deployment/ubuntu-source-production.md index 0fe8123..0cd2e65 100644 --- a/docs/deployment/ubuntu-source-production.md +++ b/docs/deployment/ubuntu-source-production.md @@ -1,6 +1,6 @@ # Ubuntu 源码生产部署 -正式支持 Ubuntu 22.04 LTS 与 Ubuntu 24.04 LTS x86_64。服务器管理员预先提供 Node.js 22 LTS、pnpm 10、Nginx,以及 MySQL 5.7、Redis、RocketMQ 5.x 或相应外部服务;项目不负责安装、升级或修改系统软件源。 +正式支持 Ubuntu 22.04 LTS 与 Ubuntu 24.04 LTS x86_64。服务器管理员预先提供 Node.js 22 LTS、pnpm 10、Nginx,以及 MySQL 5.7.42、Redis、RocketMQ 5.x 或相应外部服务;项目不负责安装、升级或修改系统软件源,其他 MySQL 补丁版本不得代替生产验收。 ## 构建发布目录 diff --git a/docs/governance/code-development-standards.md b/docs/governance/code-development-standards.md index 173d9d9..7116f7e 100644 --- a/docs/governance/code-development-standards.md +++ b/docs/governance/code-development-standards.md @@ -8,7 +8,7 @@ - 前端:Vue 3、Vite、TypeScript、Dart Sass、Naive UI。 - 后端:NestJS、TypeScript。 -- 数据库:MySQL 5.7.x,字符集 `utf8mb4`,排序规则 `utf8mb4_general_ci`;缓存:Redis;消息队列:RocketMQ 5.x。 +- 数据库:MySQL 5.7.42,字符集 `utf8mb4`,排序规则 `utf8mb4_general_ci`;缓存:Redis;消息队列:RocketMQ 5.x。 - 图表:Lightweight Charts负责K线和分时,ECharts负责热力图、分布和资金趋势。 - AI:由服务端调用兼容 OpenAI 格式的付费 API,密钥不得下发浏览器。 @@ -116,7 +116,7 @@ count += 1; ## 11. MySQL 5.7 兼容规范 -- 开发、测试、Docker 和生产环境必须使用明确的 MySQL 5.7 补丁版本,禁止使用 `latest`、MySQL 8.x 或其他数据库代替兼容性验证。 +- 开发、测试、Docker 和生产环境统一使用 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 审批。 diff --git a/package.json b/package.json index 20b6b4a..2692305 100644 --- a/package.json +++ b/package.json @@ -19,14 +19,18 @@ "schema:verify": "node tools/database/schema-baseline.mjs verify", "governance:start": "node tools/governance/start.mjs", "governance:finish": "node tools/governance/finish.mjs", - "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/boundaries/verify-project-boundaries.mjs", + "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", "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:boundaries && pnpm --filter @a-share-analysis/web --if-present test && pnpm --filter @a-share-analysis/server --if-present test", + "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 --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": "pnpm check:runtime && pnpm check:contracts && pnpm lint && pnpm format:check && pnpm typecheck && pnpm test && pnpm build" + "verify:e2e": "pnpm --filter @a-share-analysis/web test:e2e", + "verify:docker": "node tools/deployment/verify-docker.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 verify:docker" }, "devDependencies": { "@eslint/js": "9.39.5", diff --git a/server/apps/api/src/api.module.ts b/server/apps/api/src/api.module.ts index 1e12d5e..2787674 100644 --- a/server/apps/api/src/api.module.ts +++ b/server/apps/api/src/api.module.ts @@ -1,29 +1,44 @@ -import { Module } from '@nestjs/common'; +import { type DynamicModule, Module } from '@nestjs/common'; -import { loadApiProcessConfig } from '../../../libs/config/src/load-process-env'; +import type { ApiRuntimeConfig } from '../../../libs/config/src/runtime-config'; import { DisabledHealthClient } from '../../../libs/infrastructure/src/disabled-health.client'; import { createMysqlHealthClient } from '../../../libs/infrastructure/src/mysql-health.client'; import { createRedisHealthClient } from '../../../libs/infrastructure/src/redis-health.client'; import { HealthController } from './health.controller'; import { HealthService, MYSQL_HEALTH_PORT, REDIS_HEALTH_PORT } from './health.service'; -const apiConfig = loadApiProcessConfig(); +/** API 进程的只读配置注入令牌,确保测试和启动入口使用同一份显式配置。 */ +export const API_RUNTIME_CONFIG = Symbol('API_RUNTIME_CONFIG'); -@Module({ - controllers: [HealthController], - providers: [ - HealthService, - { - provide: REDIS_HEALTH_PORT, - useFactory: () => createRedisHealthClient(apiConfig.redis), - }, - { - provide: MYSQL_HEALTH_PORT, - useFactory: () => - apiConfig.mysql.enabled - ? createMysqlHealthClient(apiConfig.mysql) - : new DisabledHealthClient('MySQL 未启用'), - }, - ], -}) -export class ApiModule {} +@Module({}) +export class ApiModule { + /** + * 由启动入口传入已经校验和冻结的运行配置,禁止模块加载阶段隐式读取环境变量, + * 从而让 API 测试、OpenAPI 生成和生产启动之间不存在隐藏配置差异。 + */ + static register(config: ApiRuntimeConfig): DynamicModule { + return { + module: ApiModule, + controllers: [HealthController], + providers: [ + HealthService, + { provide: API_RUNTIME_CONFIG, useValue: config }, + { + provide: REDIS_HEALTH_PORT, + inject: [API_RUNTIME_CONFIG], + useFactory: (runtimeConfig: ApiRuntimeConfig) => + createRedisHealthClient(runtimeConfig.redis), + }, + { + provide: MYSQL_HEALTH_PORT, + inject: [API_RUNTIME_CONFIG], + useFactory: (runtimeConfig: ApiRuntimeConfig) => + runtimeConfig.mysql.enabled + ? createMysqlHealthClient(runtimeConfig.mysql) + : new DisabledHealthClient('MySQL 未启用'), + }, + ], + exports: [API_RUNTIME_CONFIG], + }; + } +} diff --git a/server/apps/api/src/health.controller.ts b/server/apps/api/src/health.controller.ts index 24d8c77..9bd1396 100644 --- a/server/apps/api/src/health.controller.ts +++ b/server/apps/api/src/health.controller.ts @@ -6,6 +6,7 @@ import { HealthService } from './health.service'; @ApiTags('health') @Controller('health') +/** 将内部健康结果映射为 200/503,同时保持两种响应共用同一 OpenAPI DTO。 */ export class HealthController { constructor(@Inject(HealthService) private readonly healthService: HealthService) {} diff --git a/server/apps/api/src/health.service.ts b/server/apps/api/src/health.service.ts index 0b91b24..9687aef 100644 --- a/server/apps/api/src/health.service.ts +++ b/server/apps/api/src/health.service.ts @@ -5,14 +5,17 @@ import type { HealthResponseDto, } from '../../../libs/http-contracts/src/health.dto'; +/** 依赖注入令牌避免健康服务耦合 ioredis 或 mysql2 客户端。 */ export const REDIS_HEALTH_PORT = Symbol('REDIS_HEALTH_PORT'); export const MYSQL_HEALTH_PORT = Symbol('MYSQL_HEALTH_PORT'); +/** 健康端口只要求只读检查,便于禁用态和测试实现遵循同一契约。 */ export interface DependencyHealthPort { check(): Promise; } @Injectable() +/** 聚合依赖状态并统一清洗底层错误,禁止连接信息进入 HTTP 响应。 */ export class HealthService { constructor( @Inject(REDIS_HEALTH_PORT) private readonly redis: DependencyHealthPort, diff --git a/server/apps/api/src/main.ts b/server/apps/api/src/main.ts index 27ffaf7..6b548ff 100644 --- a/server/apps/api/src/main.ts +++ b/server/apps/api/src/main.ts @@ -2,6 +2,7 @@ import 'reflect-metadata'; import { type INestApplication, type NestApplicationOptions } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; +import path from 'node:path'; import { loadApiProcessConfig } from '../../../libs/config/src/load-process-env'; import { @@ -10,14 +11,18 @@ import { } from '../../../libs/config/src/runtime-config'; import { ApiModule } from './api.module'; +/** API 引导参数只接受已完成进程级校验的配置,测试可覆盖监听端口和日志器。 */ interface BootstrapApiOptions { config: ApiRuntimeConfig; port?: number; logger?: NestApplicationOptions['logger']; } +/** 启动可独立部署的 API;配置由入口显式注入,监听前已完成全部校验。 */ export async function bootstrapApi(options: BootstrapApiOptions): Promise { - const app = await NestFactory.create(ApiModule, { logger: options.logger }); + const app = await NestFactory.create(ApiModule.register(options.config), { + logger: options.logger, + }); app.setGlobalPrefix('api/v1'); app.enableShutdownHooks(); await app.listen(options.port ?? options.config.api.port, options.config.api.host); @@ -30,6 +35,6 @@ async function runApi(): Promise { await bootstrapApi({ config }); } -if (require.main === module) { +if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(__filename)) { void runApi(); } diff --git a/server/apps/api/test/openapi.test.ts b/server/apps/api/test/openapi.test.ts index f89fafe..1b55e31 100644 --- a/server/apps/api/test/openapi.test.ts +++ b/server/apps/api/test/openapi.test.ts @@ -18,12 +18,9 @@ describe('Server OpenAPI 单向发布', () => { path.resolve(__dirname, '../../../openapi/openapi.json'), 'utf8', ); - const document = JSON.parse(content) as { - paths: Record; - components?: { schemas?: Record }; - }; + const committedDocument = JSON.parse(content) as Record; + const liveDocument = await createOpenApiDocument(); - expect(document.paths).toHaveProperty('/api/v1/health'); - expect(document.components?.schemas).toHaveProperty('HealthResponseDto'); + expect(committedDocument).toEqual(liveDocument); }); }); diff --git a/server/apps/scheduler/src/main.ts b/server/apps/scheduler/src/main.ts index a1f86d2..783c637 100644 --- a/server/apps/scheduler/src/main.ts +++ b/server/apps/scheduler/src/main.ts @@ -2,35 +2,39 @@ import 'reflect-metadata'; import { type INestApplicationContext, type NestApplicationOptions } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; +import path from 'node:path'; import { loadSchedulerProcessConfig } from '../../../libs/config/src/load-process-env'; import { toSafeRuntimeSummary, type SchedulerRuntimeConfig, } from '../../../libs/config/src/runtime-config'; +import { waitForShutdownSignal } from '../../../libs/infrastructure/src/process-lifecycle'; import { SchedulerModule } from './scheduler.module'; +/** Scheduler 引导参数确保模块只读取本进程已校验的最小配置。 */ interface BootstrapSchedulerOptions { config: SchedulerRuntimeConfig; logger?: NestApplicationOptions['logger']; } +/** 启动无 HTTP 端口的 Scheduler 应用上下文,生命周期由命令入口持有。 */ export async function bootstrapScheduler( options: BootstrapSchedulerOptions, ): Promise { - const app = await NestFactory.createApplicationContext(SchedulerModule, { + const app = await NestFactory.createApplicationContext(SchedulerModule.register(options.config), { logger: options.logger, }); - app.enableShutdownHooks(); return app; } async function runScheduler(): Promise { const config = loadSchedulerProcessConfig(); console.info('Scheduler 进程启动配置', toSafeRuntimeSummary(config)); - await bootstrapScheduler({ config }); + const app = await bootstrapScheduler({ config }); + await waitForShutdownSignal(app); } -if (require.main === module) { +if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(__filename)) { void runScheduler(); } diff --git a/server/apps/scheduler/src/scheduler.module.ts b/server/apps/scheduler/src/scheduler.module.ts index f17dbaa..2caae6b 100644 --- a/server/apps/scheduler/src/scheduler.module.ts +++ b/server/apps/scheduler/src/scheduler.module.ts @@ -1,4 +1,18 @@ -import { Module } from '@nestjs/common'; +import { type DynamicModule, Module } from '@nestjs/common'; + +import type { SchedulerRuntimeConfig } from '../../../libs/config/src/runtime-config'; + +/** Scheduler 的最小权限配置令牌,不向 API 或 Worker 暴露其任务调度配置。 */ +export const SCHEDULER_RUNTIME_CONFIG = Symbol('SCHEDULER_RUNTIME_CONFIG'); @Module({}) -export class SchedulerModule {} +export class SchedulerModule { + /** 将入口校验后的 Scheduler 配置注册进当前进程容器。 */ + static register(config: SchedulerRuntimeConfig): DynamicModule { + return { + module: SchedulerModule, + providers: [{ provide: SCHEDULER_RUNTIME_CONFIG, useValue: config }], + exports: [SCHEDULER_RUNTIME_CONFIG], + }; + } +} diff --git a/server/apps/worker/src/main.ts b/server/apps/worker/src/main.ts index b4feba5..52bae83 100644 --- a/server/apps/worker/src/main.ts +++ b/server/apps/worker/src/main.ts @@ -2,35 +2,39 @@ import 'reflect-metadata'; import { type INestApplicationContext, type NestApplicationOptions } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; +import path from 'node:path'; import { loadWorkerProcessConfig } from '../../../libs/config/src/load-process-env'; import { toSafeRuntimeSummary, type WorkerRuntimeConfig, } from '../../../libs/config/src/runtime-config'; +import { waitForShutdownSignal } from '../../../libs/infrastructure/src/process-lifecycle'; import { WorkerModule } from './worker.module'; +/** Worker 引导参数确保模块只读取本进程已校验的最小配置。 */ interface BootstrapWorkerOptions { config: WorkerRuntimeConfig; logger?: NestApplicationOptions['logger']; } +/** 启动无 HTTP 端口的 Worker 应用上下文,生命周期由命令入口持有。 */ export async function bootstrapWorker( options: BootstrapWorkerOptions, ): Promise { - const app = await NestFactory.createApplicationContext(WorkerModule, { + const app = await NestFactory.createApplicationContext(WorkerModule.register(options.config), { logger: options.logger, }); - app.enableShutdownHooks(); return app; } async function runWorker(): Promise { const config = loadWorkerProcessConfig(); console.info('Worker 进程启动配置', toSafeRuntimeSummary(config)); - await bootstrapWorker({ config }); + const app = await bootstrapWorker({ config }); + await waitForShutdownSignal(app); } -if (require.main === module) { +if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(__filename)) { void runWorker(); } diff --git a/server/apps/worker/src/worker.module.ts b/server/apps/worker/src/worker.module.ts index a11e106..eaf287f 100644 --- a/server/apps/worker/src/worker.module.ts +++ b/server/apps/worker/src/worker.module.ts @@ -1,4 +1,18 @@ -import { Module } from '@nestjs/common'; +import { type DynamicModule, Module } from '@nestjs/common'; + +import type { WorkerRuntimeConfig } from '../../../libs/config/src/runtime-config'; + +/** Worker 的最小权限配置令牌,仅包含采集、持久化、消息与 AI 处理所需配置。 */ +export const WORKER_RUNTIME_CONFIG = Symbol('WORKER_RUNTIME_CONFIG'); @Module({}) -export class WorkerModule {} +export class WorkerModule { + /** 将入口校验后的 Worker 配置注册进当前进程容器。 */ + static register(config: WorkerRuntimeConfig): DynamicModule { + return { + module: WorkerModule, + providers: [{ provide: WORKER_RUNTIME_CONFIG, useValue: config }], + exports: [WORKER_RUNTIME_CONFIG], + }; + } +} diff --git a/server/libs/config/src/load-process-env.ts b/server/libs/config/src/load-process-env.ts index dcb3164..f3583c1 100644 --- a/server/libs/config/src/load-process-env.ts +++ b/server/libs/config/src/load-process-env.ts @@ -47,14 +47,17 @@ function resolveProcessEnvironment(processName: ProcessName): NodeJS.ProcessEnv return process.env.APP_ENV === 'production' ? process.env : readDevelopmentEnv(processName); } +/** API 只加载自身 local/env 键并立即完成配置校验。 */ export function loadApiProcessConfig(): ApiRuntimeConfig { return loadApiConfig(resolveProcessEnvironment('api')); } +/** Scheduler 只加载调度与投递所需配置,不读取数据库凭据。 */ export function loadSchedulerProcessConfig(): SchedulerRuntimeConfig { return loadSchedulerConfig(resolveProcessEnvironment('scheduler')); } +/** Worker 只加载任务执行所需配置,不读取 API 监听参数。 */ export function loadWorkerProcessConfig(): WorkerRuntimeConfig { return loadWorkerConfig(resolveProcessEnvironment('worker')); } diff --git a/server/libs/config/src/runtime-config.ts b/server/libs/config/src/runtime-config.ts index 478bf57..7f169c5 100644 --- a/server/libs/config/src/runtime-config.ts +++ b/server/libs/config/src/runtime-config.ts @@ -1,6 +1,9 @@ +/** 应用环境仅允许显式的开发或生产态,禁止隐式 test/staging 绕过生产约束。 */ export type AppEnvironment = 'development' | 'production'; +/** 四级日志口径由进程入口统一校验,避免基础设施各自解释字符串。 */ export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; +/** 每个进程只接收自己的 Redis DB 与连接参数,凭据不得进入安全摘要。 */ export interface RedisRuntimeConfig { host: string; port: number; @@ -10,6 +13,7 @@ export interface RedisRuntimeConfig { tls: boolean; } +/** MySQL 配置固定 5.7.42 兼容口径,启用状态决定凭据是否必填。 */ export interface MysqlRuntimeConfig { enabled: boolean; host: string; @@ -22,6 +26,7 @@ export interface MysqlRuntimeConfig { timezone: '+08:00'; } +/** OpenAI 兼容接口配置;关闭 AI 时允许凭据为空,且不影响规则分析。 */ export interface AiRuntimeConfig { enabled: boolean; baseUrl: string; @@ -30,6 +35,7 @@ export interface AiRuntimeConfig { timeoutMs: number; } +/** RocketMQ 公共寻址配置;实际生产/消费参数由相应进程扩展。 */ export interface RocketMqRuntimeConfig { enabled: boolean; nameServerAddress: string; @@ -44,6 +50,7 @@ interface CommonRuntimeConfig { redis: RedisRuntimeConfig; } +/** API 仅获得 HTTP、Redis、MySQL 与可选 AI 配置,不读取调度和消费配置。 */ export interface ApiRuntimeConfig extends CommonRuntimeConfig { processName: 'api'; api: Readonly<{ host: string; port: number }>; @@ -51,12 +58,14 @@ export interface ApiRuntimeConfig extends CommonRuntimeConfig { ai: AiRuntimeConfig; } +/** Scheduler 只决定何时投递,因此不获得 MySQL、行情源或 AI 凭据。 */ export interface SchedulerRuntimeConfig extends CommonRuntimeConfig { processName: 'scheduler'; scheduler: Readonly<{ enabled: boolean; timezone: 'Asia/Shanghai' }>; rocketMq: RocketMqRuntimeConfig & Readonly<{ sendTimeoutMs: number }>; } +/** Worker 获得执行任务所需配置,但不读取 API 监听或 Scheduler 开关。 */ export interface WorkerRuntimeConfig extends CommonRuntimeConfig { processName: 'worker'; marketData: Readonly<{ @@ -71,6 +80,7 @@ export interface WorkerRuntimeConfig extends CommonRuntimeConfig { ai: AiRuntimeConfig; } +/** 三类配置的联合类型仅用于安全摘要等跨进程基础能力。 */ export type RuntimeConfig = ApiRuntimeConfig | SchedulerRuntimeConfig | WorkerRuntimeConfig; type EnvironmentSource = Readonly>; @@ -205,6 +215,7 @@ function loadRocketMqConfig(env: EnvironmentSource): RocketMqRuntimeConfig { }; } +/** 从 API 专属环境变量生成深冻结配置,启动后禁止被运行时代码改写。 */ export function loadApiConfig(env: EnvironmentSource): ApiRuntimeConfig { return deepFreeze({ processName: 'api', @@ -220,6 +231,7 @@ export function loadApiConfig(env: EnvironmentSource): ApiRuntimeConfig { }) as ApiRuntimeConfig; } +/** 从 Scheduler 专属变量生成深冻结配置,并保持 RocketMQ 默认禁用边界。 */ export function loadSchedulerConfig(env: EnvironmentSource): SchedulerRuntimeConfig { const rocketMq = loadRocketMqConfig(env); return deepFreeze({ @@ -241,6 +253,7 @@ export function loadSchedulerConfig(env: EnvironmentSource): SchedulerRuntimeCon }) as SchedulerRuntimeConfig; } +/** 从 Worker 专属变量生成深冻结配置,并阻止生产环境使用模拟行情。 */ export function loadWorkerConfig(env: EnvironmentSource): WorkerRuntimeConfig { const appEnv = loadAppEnv(env); const mode = parseEnum(env, 'MARKET_DATA_MODE', ['mock', 'live']); @@ -275,6 +288,7 @@ export function loadWorkerConfig(env: EnvironmentSource): WorkerRuntimeConfig { }) as WorkerRuntimeConfig; } +/** 日志只保留诊断所需的非敏感字段,绝不序列化密码、Token 或 API Key。 */ export function toSafeRuntimeSummary(config: RuntimeConfig): Readonly> { const summary: Record = { processName: config.processName, diff --git a/server/libs/http-contracts/src/health.dto.ts b/server/libs/http-contracts/src/health.dto.ts index 1a4f339..ec1567a 100644 --- a/server/libs/http-contracts/src/health.dto.ts +++ b/server/libs/http-contracts/src/health.dto.ts @@ -1,8 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; +/** API 总体健康只区分可服务与不可服务,依赖禁用不等同于故障。 */ export type HealthStatus = 'healthy' | 'unavailable'; +/** 单项依赖额外允许 disabled,用于尚未启用但符合当前配置的组件。 */ export type DependencyStatus = HealthStatus | 'disabled'; +/** 单项依赖健康契约只暴露安全摘要,不返回地址、账号或底层异常。 */ export class DependencyHealthDto { @ApiProperty({ type: String, enum: ['healthy', 'unavailable', 'disabled'] }) status!: DependencyStatus; @@ -11,6 +14,7 @@ export class DependencyHealthDto { detail!: string; } +/** 健康响应固定列出三类基础设施,便于运维区分故障和计划内禁用。 */ export class HealthDependenciesDto { @ApiProperty({ type: () => DependencyHealthDto }) redis!: DependencyHealthDto; @@ -22,6 +26,7 @@ export class HealthDependenciesDto { rocketMq!: DependencyHealthDto; } +/** API 健康端点的版本化 HTTP DTO,也是 OpenAPI 单向发布源。 */ export class HealthResponseDto { @ApiProperty({ type: String, enum: ['healthy', 'unavailable'] }) status!: HealthStatus; diff --git a/server/libs/infrastructure/src/disabled-health.client.ts b/server/libs/infrastructure/src/disabled-health.client.ts index 28eb234..d4ca917 100644 --- a/server/libs/infrastructure/src/disabled-health.client.ts +++ b/server/libs/infrastructure/src/disabled-health.client.ts @@ -1,5 +1,6 @@ import type { DependencyHealthDto } from '../../http-contracts/src/health.dto'; +/** 将计划内关闭的依赖表达为 disabled,避免误报 unavailable 或尝试建立连接。 */ export class DisabledHealthClient { constructor(private readonly detail: string) {} diff --git a/server/libs/infrastructure/src/disabled-message-bus.adapter.test.ts b/server/libs/infrastructure/src/disabled-message-bus.adapter.test.ts index 9b34a13..af82b78 100644 --- a/server/libs/infrastructure/src/disabled-message-bus.adapter.test.ts +++ b/server/libs/infrastructure/src/disabled-message-bus.adapter.test.ts @@ -8,10 +8,13 @@ describe('禁用消息总线 Adapter', () => { await expect( adapter.publish({ - version: 1, + eventId: 'event-1', eventType: 'market.snapshot.updated', + schemaVersion: 1, idempotencyKey: 'snapshot:1', occurredAt: '2026-08-13T01:30:00.000Z', + tradeDate: '2026-08-13', + traceId: 'trace-1', payload: { market: 'A_SHARE' }, }), ).rejects.toBeInstanceOf(MessageBusDisabledError); diff --git a/server/libs/infrastructure/src/disabled-message-bus.adapter.ts b/server/libs/infrastructure/src/disabled-message-bus.adapter.ts index 5f9f2d9..991fd6b 100644 --- a/server/libs/infrastructure/src/disabled-message-bus.adapter.ts +++ b/server/libs/infrastructure/src/disabled-message-bus.adapter.ts @@ -1,6 +1,7 @@ import type { MessageEnvelope } from '../../message-contracts/src/message-envelope'; import type { MessageBusAdapter, MessageHandler } from './message-bus.adapter'; +/** RocketMQ 禁用时使用专用错误,调用方不得误以为消息已投递或已订阅。 */ export class MessageBusDisabledError extends Error { constructor(operation: 'publish' | 'subscribe') { super(`RocketMQ 当前未启用,拒绝执行 ${operation}`); @@ -8,6 +9,7 @@ export class MessageBusDisabledError extends Error { } } +/** GOV-009 的显式禁用 Adapter;拒绝所有收发,不提供不可追溯的内存队列降级。 */ export class DisabledMessageBusAdapter implements MessageBusAdapter { async publish(message: Readonly>): Promise { void message; diff --git a/server/libs/infrastructure/src/health-clients.test.ts b/server/libs/infrastructure/src/health-clients.test.ts index 1b57508..968d62e 100644 --- a/server/libs/infrastructure/src/health-clients.test.ts +++ b/server/libs/infrastructure/src/health-clients.test.ts @@ -49,7 +49,7 @@ describe('基础设施只读健康客户端', () => { await expect(client.check()).resolves.toEqual({ status: 'healthy', - detail: 'MySQL 5.7 / a_share_analysis_dev / +08:00', + detail: 'MySQL 5.7.42 / a_share_analysis_dev / +08:00', }); expect(queries).toEqual([MYSQL_HEALTH_QUERY]); }); @@ -77,6 +77,7 @@ describe('基础设施只读健康客户端', () => { ); await expect(createClient({ version: '8.0.40' }).check()).rejects.toThrow(/MySQL 5.7/); + await expect(createClient({ version: '5.7.41' }).check()).rejects.toThrow(/5\.7\.42/); await expect(createClient({ databaseName: 'a_share_analysis' }).check()).rejects.toThrow( /a_share_analysis_dev/, ); diff --git a/server/libs/infrastructure/src/message-bus.adapter.ts b/server/libs/infrastructure/src/message-bus.adapter.ts index db39b24..b507ca2 100644 --- a/server/libs/infrastructure/src/message-bus.adapter.ts +++ b/server/libs/infrastructure/src/message-bus.adapter.ts @@ -1,9 +1,11 @@ import type { MessageEnvelope } from '../../message-contracts/src/message-envelope'; +/** 消息处理器只消费项目内信封,禁止业务层依赖具体 RocketMQ SDK 类型。 */ export type MessageHandler = ( message: Readonly>, ) => Promise; +/** 消息总线端口隔离客户端选型,并固定发布、订阅和资源关闭的最小语义。 */ export interface MessageBusAdapter { publish(message: Readonly>): Promise; subscribe(eventType: string, handler: MessageHandler): Promise; diff --git a/server/libs/infrastructure/src/mysql-health.client.ts b/server/libs/infrastructure/src/mysql-health.client.ts index d008304..f1dd525 100644 --- a/server/libs/infrastructure/src/mysql-health.client.ts +++ b/server/libs/infrastructure/src/mysql-health.client.ts @@ -4,6 +4,7 @@ import type { OnModuleDestroy } from '@nestjs/common'; import type { MysqlRuntimeConfig } from '../../config/src/runtime-config'; import type { DependencyHealthDto } from '../../http-contracts/src/health.dto'; +/** 固定的只读元数据查询,用于验证服务端版本、库名和会话字符口径。 */ export const MYSQL_HEALTH_QUERY = `SELECT VERSION() AS version, DATABASE() AS databaseName, @@ -24,6 +25,7 @@ interface MysqlMetadataRow { timezone: string; } +/** MySQL 健康客户端只校验元数据,不读取表数据或执行写入。 */ export class MysqlHealthClient implements OnModuleDestroy { constructor( private readonly client: MysqlQueryPort, @@ -34,7 +36,9 @@ export class MysqlHealthClient implements OnModuleDestroy { const [rows] = await this.client.query(MYSQL_HEALTH_QUERY); const row = (rows as unknown as MysqlMetadataRow[])[0]; if (!row) throw new Error('MySQL 未返回元数据'); - if (!row.version.startsWith('5.7.')) throw new Error('MySQL 5.7.x 版本要求未满足'); + if (!/^5\.7\.42(?:[-+.]|$)/.test(row.version)) { + throw new Error('MySQL 5.7.42 版本要求未满足'); + } if (row.databaseName !== this.expectedDatabase) { throw new Error(`MySQL 健康检查必须连接 ${this.expectedDatabase}`); } @@ -46,7 +50,7 @@ export class MysqlHealthClient implements OnModuleDestroy { return { status: 'healthy', - detail: `MySQL 5.7 / ${row.databaseName} / ${row.timezone}`, + detail: `MySQL 5.7.42 / ${row.databaseName} / ${row.timezone}`, }; } @@ -59,6 +63,7 @@ export class MysqlHealthClient implements OnModuleDestroy { } } +/** 创建最多两个连接的只读健康池,并显式应用 MySQL 5.7.42 会话时区。 */ export function createMysqlHealthClient(config: MysqlRuntimeConfig): MysqlHealthClient { const pool: Pool = createPool({ host: config.host, diff --git a/server/libs/infrastructure/src/process-lifecycle.ts b/server/libs/infrastructure/src/process-lifecycle.ts new file mode 100644 index 0000000..08d7285 --- /dev/null +++ b/server/libs/infrastructure/src/process-lifecycle.ts @@ -0,0 +1,29 @@ +import type { INestApplicationContext } from '@nestjs/common'; + +/** + * 非 HTTP 进程没有监听套接字,必须显式等待 systemd/Docker 的退出信号才能持续运行。 + * 收到首个信号后先移除监听器,再等待 Nest 完成资源释放,保证正常退出码为 0。 + */ +export async function waitForShutdownSignal(app: INestApplicationContext): Promise { + await new Promise((resolve, reject) => { + let shuttingDown = false; + // Signal listener 本身不保证 Node 事件循环存活;该句柄只承担进程存活锚点, + // 不执行调度业务,真正退出时会被同步清理。 + const keepAliveHandle = setInterval(() => undefined, 2_147_483_647); + + const cleanup = () => { + clearInterval(keepAliveHandle); + process.removeListener('SIGINT', shutdown); + process.removeListener('SIGTERM', shutdown); + }; + const shutdown = () => { + if (shuttingDown) return; + shuttingDown = true; + cleanup(); + void app.close().then(resolve, reject); + }; + + process.once('SIGINT', shutdown); + process.once('SIGTERM', shutdown); + }); +} diff --git a/server/libs/infrastructure/src/redis-health.client.ts b/server/libs/infrastructure/src/redis-health.client.ts index 9f78bbe..cb74fca 100644 --- a/server/libs/infrastructure/src/redis-health.client.ts +++ b/server/libs/infrastructure/src/redis-health.client.ts @@ -11,6 +11,7 @@ interface RedisPingPort { quit(): Promise; } +/** Redis 健康客户端只执行连接与 PING,不读写任何业务键。 */ export class RedisHealthClient implements OnModuleDestroy { constructor(private readonly client: RedisPingPort) {} @@ -42,6 +43,7 @@ export class RedisHealthClient implements OnModuleDestroy { } } +/** 使用 lazyConnect 创建最小连接池,只有健康请求触发真实连接。 */ export function createRedisHealthClient(config: RedisRuntimeConfig): RedisHealthClient { const client = new Redis({ host: config.host, diff --git a/server/libs/infrastructure/src/typeorm-options.ts b/server/libs/infrastructure/src/typeorm-options.ts index 3870353..cdd819f 100644 --- a/server/libs/infrastructure/src/typeorm-options.ts +++ b/server/libs/infrastructure/src/typeorm-options.ts @@ -2,6 +2,9 @@ import type { MysqlConnectionOptions } from 'typeorm/driver/mysql/MysqlConnectio import type { MysqlRuntimeConfig } from '../../config/src/runtime-config'; +/** + * 生成 MySQL 5.7.42 TypeORM 基线选项;迁移未落地前不自动运行,且永久禁止 synchronize。 + */ export function buildTypeOrmOptions(config: MysqlRuntimeConfig): MysqlConnectionOptions { return { type: 'mysql', diff --git a/server/libs/message-contracts/src/message-envelope.test.ts b/server/libs/message-contracts/src/message-envelope.test.ts index 938f333..c66666a 100644 --- a/server/libs/message-contracts/src/message-envelope.test.ts +++ b/server/libs/message-contracts/src/message-envelope.test.ts @@ -3,20 +3,26 @@ import { describe, expect, it } from 'vitest'; import { createMessageEnvelope } from './message-envelope'; describe('RocketMQ 消息信封', () => { - it('规范化并冻结版本 1 消息', () => { + it('规范化并冻结带完整追溯字段的版本 1 消息', () => { const envelope = createMessageEnvelope({ - version: 1, + eventId: 'event-20260813-001', eventType: 'market.snapshot.updated', + schemaVersion: 1, idempotencyKey: 'snapshot:2026-08-13T09:30:00+08:00', occurredAt: '2026-08-13T01:30:00.000Z', + tradeDate: '2026-08-13', + traceId: 'trace-20260813-001', payload: { market: 'A_SHARE' }, }); expect(envelope).toEqual({ - version: 1, + eventId: 'event-20260813-001', eventType: 'market.snapshot.updated', + schemaVersion: 1, idempotencyKey: 'snapshot:2026-08-13T09:30:00+08:00', occurredAt: '2026-08-13T01:30:00.000Z', + tradeDate: '2026-08-13', + traceId: 'trace-20260813-001', payload: { market: 'A_SHARE' }, }); expect(Object.isFrozen(envelope)).toBe(true); @@ -24,17 +30,23 @@ describe('RocketMQ 消息信封', () => { }); it.each([ - [{ version: 2 }, /version/], + [{ eventId: '' }, /eventId/], [{ eventType: ' ' }, /eventType/], + [{ schemaVersion: 2 }, /schemaVersion/], [{ idempotencyKey: '' }, /idempotencyKey/], [{ occurredAt: 'not-a-date' }, /occurredAt/], + [{ tradeDate: '2026-02-30' }, /tradeDate/], + [{ traceId: ' ' }, /traceId/], ])('拒绝非法信封字段 %#', (override, expectedError) => { expect(() => createMessageEnvelope({ - version: 1, + eventId: 'event-1', eventType: 'market.snapshot.updated', + schemaVersion: 1, idempotencyKey: 'snapshot:1', occurredAt: '2026-08-13T01:30:00.000Z', + tradeDate: '2026-08-13', + traceId: 'trace-1', payload: { market: 'A_SHARE' }, ...override, }), diff --git a/server/libs/message-contracts/src/message-envelope.ts b/server/libs/message-contracts/src/message-envelope.ts index a65a159..b8b6ac3 100644 --- a/server/libs/message-contracts/src/message-envelope.ts +++ b/server/libs/message-contracts/src/message-envelope.ts @@ -1,13 +1,17 @@ +/** Server 内部消息统一信封;字段同时满足幂等、版本演进和跨进程链路追踪。 */ export interface MessageEnvelope { - version: 1; + eventId: string; eventType: string; + schemaVersion: 1; idempotencyKey: string; occurredAt: string; + tradeDate: string; + traceId: string; payload: TPayload; } -type MessageEnvelopeInput = Omit, 'version'> & { - version: number; +type MessageEnvelopeInput = Omit, 'schemaVersion'> & { + schemaVersion: number; }; function deepFreeze(value: TValue): Readonly { @@ -18,22 +22,36 @@ function deepFreeze(value: TValue): Readonly { return value; } +/** 校验、规范化并深冻结消息信封,防止投递后字段被异步处理链路改写。 */ export function createMessageEnvelope( input: MessageEnvelopeInput, ): Readonly> { - if (input.version !== 1) throw new Error('version 只支持 1'); + if (!input.eventId.trim()) throw new Error('eventId 不能为空'); if (!input.eventType.trim()) throw new Error('eventType 不能为空'); + if (input.schemaVersion !== 1) throw new Error('schemaVersion 只支持 1'); if (!input.idempotencyKey.trim()) throw new Error('idempotencyKey 不能为空'); if (!input.occurredAt.trim() || Number.isNaN(Date.parse(input.occurredAt))) { throw new Error('occurredAt 必须为合法 ISO 时间'); } + if (!/^\d{4}-\d{2}-\d{2}$/.test(input.tradeDate)) { + throw new Error('tradeDate 必须为合法 YYYY-MM-DD 日期'); + } + const [year, month, day] = input.tradeDate.split('-').map(Number); + const parsedTradeDate = new Date(Date.UTC(year, month - 1, day)); + if (parsedTradeDate.toISOString().slice(0, 10) !== input.tradeDate) { + throw new Error('tradeDate 必须为合法 YYYY-MM-DD 日期'); + } + if (!input.traceId.trim()) throw new Error('traceId 不能为空'); if (input.payload === undefined) throw new Error('payload 不能为空'); return deepFreeze({ - version: 1, + eventId: input.eventId.trim(), eventType: input.eventType.trim(), + schemaVersion: 1, idempotencyKey: input.idempotencyKey.trim(), occurredAt: new Date(input.occurredAt).toISOString(), + tradeDate: input.tradeDate, + traceId: input.traceId.trim(), payload: input.payload, }) as Readonly>; } diff --git a/server/test/process-smoke.test.ts b/server/test/process-smoke.test.ts index 8df068c..a214f7f 100644 --- a/server/test/process-smoke.test.ts +++ b/server/test/process-smoke.test.ts @@ -1,10 +1,15 @@ +import { spawn } from 'node:child_process'; import { type AddressInfo } from 'node:net'; +import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { bootstrapApi } from '../apps/api/src/main'; +import { API_RUNTIME_CONFIG } from '../apps/api/src/api.module'; import { bootstrapScheduler } from '../apps/scheduler/src/main'; +import { SCHEDULER_RUNTIME_CONFIG } from '../apps/scheduler/src/scheduler.module'; import { bootstrapWorker } from '../apps/worker/src/main'; +import { WORKER_RUNTIME_CONFIG } from '../apps/worker/src/worker.module'; import type { ApiRuntimeConfig, SchedulerRuntimeConfig, @@ -64,6 +69,7 @@ describe('NestJS 三进程入口', () => { expect(address.address).toBe('127.0.0.1'); expect(address.port).toBeGreaterThan(0); + expect(app.get(API_RUNTIME_CONFIG)).toBe(config); await app.close(); expect(app.getHttpServer().listening).toBe(false); @@ -82,6 +88,7 @@ describe('NestJS 三进程入口', () => { const app = await bootstrapScheduler({ config, logger: false }); expect('getHttpServer' in app).toBe(false); + expect(app.get(SCHEDULER_RUNTIME_CONFIG)).toBe(config); await expect(app.close()).resolves.toBeUndefined(); }); @@ -109,6 +116,76 @@ describe('NestJS 三进程入口', () => { const app = await bootstrapWorker({ config, logger: false }); expect('getHttpServer' in app).toBe(false); + expect(app.get(WORKER_RUNTIME_CONFIG)).toBe(config); await expect(app.close()).resolves.toBeUndefined(); }); + + it.each([ + ['scheduler', 'apps/scheduler/src/main.ts'], + ['worker', 'apps/worker/src/main.ts'], + ])('%s 命令入口持续运行并在 SIGTERM 后正常退出', async (processName, entry) => { + const output: string[] = []; + const errors: string[] = []; + const launcher = path.resolve(__dirname, '../node_modules/tsx/dist/cli.mjs'); + const child = spawn(process.execPath, [launcher, entry], { + cwd: path.resolve(__dirname, '..'), + env: { + ...process.env, + APP_ENV: 'development', + LOG_LEVEL: 'error', + REDIS_HOST: '127.0.0.1', + REDIS_PORT: '6379', + REDIS_USERNAME: '', + REDIS_PASSWORD: '', + REDIS_DATABASE: processName === 'scheduler' ? '1' : '2', + REDIS_TLS: 'false', + 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_ENABLED: 'false', + ROCKETMQ_NAMESRV_ADDR: '127.0.0.1:9876', + ROCKETMQ_ENDPOINT: '127.0.0.1:8081', + ROCKETMQ_TOPIC_PREFIX: 'a-share-analysis', + ROCKETMQ_SEND_TIMEOUT_MS: '3000', + ROCKETMQ_CONSUMER_GROUP: 'a-share-analysis-worker', + ROCKETMQ_CONSUMER_CONCURRENCY: '4', + SCHEDULER_ENABLED: 'true', + SCHEDULER_TIMEZONE: 'Asia/Shanghai', + MARKET_DATA_MODE: 'mock', + MARKET_DATA_PROVIDER: 'deterministic-fixture', + MARKET_DATA_BASE_URL: '', + MARKET_DATA_API_KEY: '', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + child.stdout.on('data', (chunk) => output.push(String(chunk))); + child.stderr.on('data', (chunk) => errors.push(String(chunk))); + + try { + await new Promise((resolve) => setTimeout(resolve, 600)); + expect( + child.exitCode, + `${processName} 不应在启动后自行退出\nlauncher=${launcher}\nstdout=${output.join('')}\nstderr=${errors.join('')}`, + ).toBeNull(); + + child.kill('SIGTERM'); + const exitCode = await new Promise((resolve) => + child.once('exit', (code) => resolve(code)), + ); + expect(exitCode).toBe(0); + } finally { + if (child.exitCode === null) child.kill('SIGKILL'); + } + }); }); diff --git a/server/tools/export-openapi.ts b/server/tools/export-openapi.ts index d97be5e..d9a059b 100644 --- a/server/tools/export-openapi.ts +++ b/server/tools/export-openapi.ts @@ -35,6 +35,7 @@ function sortObject(value: unknown): unknown { ); } +/** 从真实 Controller/DTO 生成确定性 OpenAPI 文档,生成期间不连接外部基础设施。 */ export async function createOpenApiDocument(): Promise { const app = await NestFactory.create(OpenApiModule, { logger: false }); app.setGlobalPrefix('api/v1'); diff --git a/server/tools/run-test-api.mjs b/server/tools/run-test-api.mjs new file mode 100644 index 0000000..38f217d --- /dev/null +++ b/server/tools/run-test-api.mjs @@ -0,0 +1,103 @@ +import { execFileSync, spawn } from 'node:child_process'; +import { createConnection } from 'node:net'; +import path from 'node:path'; +import process from 'node:process'; + +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) => { + socket.destroy(); + resolve(available); + }; + 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); +} + +// 浏览器集成测试只启用 API 与禁用态 MySQL;Redis 故障会形成可验证的安全 503 响应, +// 所有值均为公开测试夹具,不读取开发者的 *.env.local 或任何生产配置。 +const child = spawn(process.execPath, [launcher, 'apps/api/src/main.ts'], { + cwd: serverRoot, + env: { + PATH: process.env.PATH, + APP_ENV: 'production', + LOG_LEVEL: 'error', + API_HOST: '127.0.0.1', + API_PORT: '3000', + REDIS_HOST: '127.0.0.1', + REDIS_PORT: redisPort, + REDIS_USERNAME: '', + REDIS_PASSWORD: '', + REDIS_DATABASE: '0', + REDIS_TLS: 'false', + 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', + }, + stdio: 'inherit', +}); + +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); +}); diff --git a/tools/boundaries/verify-project-boundaries.mjs b/tools/boundaries/verify-project-boundaries.mjs index 29db38e..fc75b40 100644 --- a/tools/boundaries/verify-project-boundaries.mjs +++ b/tools/boundaries/verify-project-boundaries.mjs @@ -3,21 +3,29 @@ import path from 'node:path'; import process from 'node:process'; import ts from 'typescript'; -const TYPESCRIPT_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts']); +const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.vue']); const INTERNAL_MESSAGE_MARKERS = ['MessageEnvelope', 'message-contracts']; -async function collectTypeScriptFiles(directory) { +async function collectSourceFiles(directory) { const entries = await readdir(directory, { withFileTypes: true }); const nestedFiles = await Promise.all( entries.map(async (entry) => { const entryPath = path.join(directory, entry.name); - if (entry.isDirectory()) return collectTypeScriptFiles(entryPath); - return TYPESCRIPT_EXTENSIONS.has(path.extname(entry.name)) ? [entryPath] : []; + if (entry.isDirectory()) return collectSourceFiles(entryPath); + return SOURCE_EXTENSIONS.has(path.extname(entry.name)) ? [entryPath] : []; }), ); return nestedFiles.flat(); } +function extractTypeScriptSource(sourcePath, sourceText) { + if (path.extname(sourcePath) !== '.vue') return sourceText; + // 边界门禁只需解析 SFC 的脚本块;模板和样式不会产生 TypeScript 模块依赖。 + return [...sourceText.matchAll(/]*>([\s\S]*?)<\/script>/gi)] + .map((match) => match[1]) + .join('\n'); +} + function collectModuleSpecifiers(sourceFile) { const specifiers = []; @@ -64,11 +72,11 @@ export async function verifyProjectBoundaries(repositoryRoot) { const compilerOptions = configFile.error ? { module: ts.ModuleKind.ESNext, moduleResolution: ts.ModuleResolutionKind.Bundler } : ts.parseJsonConfigFileContent(configFile.config, ts.sys, path.dirname(configPath)).options; - const files = await collectTypeScriptFiles(webSourceRoot); + const files = await collectSourceFiles(webSourceRoot); const violations = []; for (const sourcePath of files) { - const sourceText = await readFile(sourcePath, 'utf8'); + const sourceText = extractTypeScriptSource(sourcePath, await readFile(sourcePath, 'utf8')); const sourceFile = ts.createSourceFile( sourcePath, sourceText, diff --git a/tools/boundaries/verify-project-boundaries.test.mjs b/tools/boundaries/verify-project-boundaries.test.mjs index 455bd9a..d26f791 100644 --- a/tools/boundaries/verify-project-boundaries.test.mjs +++ b/tools/boundaries/verify-project-boundaries.test.mjs @@ -89,6 +89,25 @@ test('拒绝通过 tsconfig 路径别名依赖 Server', async () => { assert.match(result.stderr, /Web 禁止依赖 Server/); }); +test('拒绝 Vue 单文件组件脚本越界依赖 Server', async () => { + const repositoryRoot = await createFixture(); + await writeFile( + path.join(repositoryRoot, 'web/src/IllegalView.vue'), + ` + +`, + ); + + const result = await runChecker(repositoryRoot); + + assert.equal(result.exitCode, 1); + assert.match(result.stderr, /Web 禁止依赖 Server/); + assert.match(result.stderr, /IllegalView\.vue/); +}); + test('接受只消费公开 OpenAPI 类型的 Web 工程', async () => { const repositoryRoot = await createFixture(); await writeFile( diff --git a/tools/contracts/verify-generated-contracts.mjs b/tools/contracts/verify-generated-contracts.mjs new file mode 100644 index 0000000..9cb9130 --- /dev/null +++ b/tools/contracts/verify-generated-contracts.mjs @@ -0,0 +1,29 @@ +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +/** + * 生成命令可以覆盖旧文件,但覆盖不等于契约已同步提交;这里仅检查指定生成物, + * 避免用户其他工作区改动干扰契约门禁。 + */ +export function assertGeneratedContractsUnchanged(repositoryRoot, contractFiles) { + try { + execFileSync('git', ['diff', '--exit-code', '--', ...contractFiles], { + cwd: repositoryRoot, + encoding: 'utf8', + stdio: ['ignore', 'ignore', 'pipe'], + }); + } catch { + throw new Error(`生成契约与 Git 提交版本不一致:${contractFiles.join('、')}`); + } +} + +const currentFile = fileURLToPath(import.meta.url); +if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(currentFile)) { + assertGeneratedContractsUnchanged(process.cwd(), [ + 'server/openapi/openapi.json', + 'web/src/api/generated/schema.ts', + ]); + process.stdout.write('生成契约与 Git 提交版本一致\n'); +} diff --git a/tools/contracts/verify-generated-contracts.test.mjs b/tools/contracts/verify-generated-contracts.test.mjs new file mode 100644 index 0000000..23e846d --- /dev/null +++ b/tools/contracts/verify-generated-contracts.test.mjs @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { assertGeneratedContractsUnchanged } from './verify-generated-contracts.mjs'; + +function createGitFixture() { + const repositoryRoot = mkdtempSync(path.join(tmpdir(), 'a-share-contracts-')); + execFileSync('git', ['init', '-q'], { cwd: repositoryRoot }); + execFileSync('git', ['config', 'user.name', 'Contract Test'], { cwd: repositoryRoot }); + execFileSync('git', ['config', 'user.email', 'contract-test@example.invalid'], { + cwd: repositoryRoot, + }); + writeFileSync(path.join(repositoryRoot, 'contract.json'), '{"version":1}\n'); + execFileSync('git', ['add', 'contract.json'], { cwd: repositoryRoot }); + execFileSync('git', ['commit', '-qm', '测试基线'], { cwd: repositoryRoot }); + return repositoryRoot; +} + +test('生成契约与提交版本一致时通过', () => { + const repositoryRoot = createGitFixture(); + assert.doesNotThrow(() => assertGeneratedContractsUnchanged(repositoryRoot, ['contract.json'])); +}); + +test('生成契约发生漂移时以明确错误阻断', () => { + const repositoryRoot = createGitFixture(); + writeFileSync(path.join(repositoryRoot, 'contract.json'), '{"version":2}\n'); + + assert.throws( + () => assertGeneratedContractsUnchanged(repositoryRoot, ['contract.json']), + /生成契约与 Git 提交版本不一致:contract\.json/, + ); +}); diff --git a/tools/database/schema-baseline.mjs b/tools/database/schema-baseline.mjs index ce1f787..ec79f30 100644 --- a/tools/database/schema-baseline.mjs +++ b/tools/database/schema-baseline.mjs @@ -73,8 +73,8 @@ export function assertSafeDevelopmentConfiguration(config) { } /** - * 客户端与服务端分别校验,防止用 MySQL 8 客户端生成带有 5.7 无法稳定恢复的语法, - * 同时核对数据库自身字符规则,而不是信任 env 中的声明值。 + * 客户端保持 5.7 系列以生成兼容导出;服务端固定 5.7.42,避免开发、测试和生产 + * 由不同补丁行为产生漂移。同时核对数据库真实字符规则,而不是信任 env 声明。 */ export function assertMysql57Metadata(metadata) { if (!/^5\.7\.\d+(?:[-+].*)?$/.test(metadata.mysqlClientVersion ?? '')) { @@ -85,8 +85,8 @@ export function assertMysql57Metadata(metadata) { `mysqldump 客户端必须为 5.7.x,实际 ${metadata.mysqldumpClientVersion || '(空)'}`, ); } - if (!/^5\.7\.\d+(?:[-+].*)?$/.test(metadata.serverVersion ?? '')) { - throw new Error(`MySQL 服务端必须为 5.7.x,实际 ${metadata.serverVersion || '(空)'}`); + if (!/^5\.7\.42(?:[-+].*)?$/.test(metadata.serverVersion ?? '')) { + throw new Error(`MySQL 服务端必须为 5.7.42,实际 ${metadata.serverVersion || '(空)'}`); } requireExactValue(metadata.databaseCharset, REQUIRED_CHARSET, '开发数据库字符集不符合要求'); requireExactValue( @@ -320,7 +320,9 @@ export function buildCapturedArtifacts(metadata, sql) { throw new Error(`应用版本格式无效:${metadata.applicationVersion || '(空)'}`); } const normalizedSql = normalizeSchemaSql(sql); - if (!normalizedSql.trim()) { + const objectCounts = countSchemaObjects(normalizedSql); + // mysqldump 对空库仍会输出会话 SET 与头尾注释;结构对象计数为零才是空库判据。 + if (Object.values(objectCounts).every((count) => count === 0)) { return buildCapturedEmptyArtifacts(metadata); } const schemaSha256 = sha256(normalizedSql); @@ -328,7 +330,7 @@ export function buildCapturedArtifacts(metadata, sql) { ...metadata, status: 'captured', captured: true, - objectCounts: countSchemaObjects(normalizedSql), + objectCounts, schemaSha256, }); return Object.freeze({ @@ -661,7 +663,7 @@ export function runBaselineGeneration(options) { assertMysql57Metadata({ mysqlClientVersion, mysqldumpClientVersion, - serverVersion: '5.7.0-pending-server-check', + serverVersion: '5.7.42-pending-server-check', databaseCharset: REQUIRED_CHARSET, databaseCollation: REQUIRED_COLLATION, }); @@ -713,11 +715,7 @@ export function getCurrentReleasedVersion(repositoryRoot) { }; } -/** - * not_applicable 清单只记录生成当时 MySQL 未启用,不能永久替代真实结构基线。 - * 每次校验都重新读取固定开发环境配置;一旦开发库启用,旧清单立即失效,强制 - * 下一版本开发开始前先从开发库生成 captured 基线。 - */ +/** 已发布清单是不可变历史;当前开发配置只决定下一次基线生成结果。 */ export function assertNotApplicableMatchesDevelopmentConfiguration(repositoryRoot, result) { // 已发布清单记录的是发布当时的数据库启用状态。当前开发配置只能决定下一份 // 基线如何生成,不能反向否定或改写已发布版本的历史事实。 diff --git a/tools/database/schema-baseline.test.mjs b/tools/database/schema-baseline.test.mjs index a9d4563..90febec 100644 --- a/tools/database/schema-baseline.test.mjs +++ b/tools/database/schema-baseline.test.mjs @@ -39,7 +39,7 @@ const validMetadata = Object.freeze({ gitCommit: '0a69aa1f6f5cc75d72f21c43d96f5ff648e2ec93', mysqlClientVersion: '5.7.44', mysqldumpClientVersion: '5.7.44', - serverVersion: '5.7.44-log', + serverVersion: '5.7.42-log', databaseCharset: 'utf8mb4', databaseCollation: 'utf8mb4_general_ci', generatedAt: '2026-08-13T03:00:00.000Z', @@ -132,11 +132,15 @@ test('拒绝错误字符集、排序规则和不完整的启用配置', () => { ); }); -test('只接受 MySQL 5.7 客户端、服务端和固定数据库字符规则', () => { +test('只接受 MySQL 5.7 客户端、5.7.42 服务端和固定数据库字符规则', () => { assert.doesNotThrow(() => assertMysql57Metadata(validMetadata)); assert.throws( () => assertMysql57Metadata({ ...validMetadata, serverVersion: '8.0.39' }), - /服务端必须为 5\.7/, + /服务端必须为 5\.7\.42/, + ); + assert.throws( + () => assertMysql57Metadata({ ...validMetadata, serverVersion: '5.7.41' }), + /服务端必须为 5\.7\.42/, ); assert.throws( () => assertMysql57Metadata({ ...validMetadata, mysqlClientVersion: '8.0.39' }), @@ -355,7 +359,7 @@ test('启用入口分别校验 mysql 与 mysqldump 客户端版本', () => { : 'mysqldump Ver 10.13 Distrib 5.7.44, for macos'; } if (command === 'mysql') { - return '5.7.44-log\tutf8mb4\tutf8mb4_general_ci\n'; + return '5.7.42-log\tutf8mb4\tutf8mb4_general_ci\n'; } return 'CREATE TABLE `sample` (`id` int);\n'; }, @@ -423,6 +427,21 @@ test('启用的空开发库生成 captured_empty format v2 清单', () => { }); }); +test('仅含 mysqldump 会话设置的真实空库仍生成 captured_empty', () => { + const emptyDump = `-- MySQL dump 10.13 Distrib 5.7.42, for Linux +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET NAMES utf8 */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; +-- Dump completed on 2026-08-13 18:00:00 +`; + + const artifacts = buildCapturedArtifacts(validMetadata, emptyDump); + + assert.equal(artifacts.status, 'captured_empty'); + assert.deepEqual(Object.keys(artifacts.files), ['manifest.yaml']); +}); + test('启用入口把 mysqldump 空结构持久化为 captured_empty', () => { const root = makeTemporaryDirectory(); const envFile = path.join(root, 'development.env'); @@ -444,7 +463,7 @@ test('启用入口把 mysqldump 空结构持久化为 captured_empty', () => { return `${command} Ver 14.14 Distrib 5.7.44, for Linux`; } if (command === 'mysql') { - return '5.7.44-log\tutf8mb4\tutf8mb4_general_ci\n'; + return '5.7.42-log\tutf8mb4\tutf8mb4_general_ci\n'; } return ''; }, @@ -482,9 +501,9 @@ test('校验拒绝篡改成错误 MySQL 版本或伪造对象范围的真实清 const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); manifest.mysql_server_version = '8.0.39'; writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); - assert.throws(() => verifyBaselineDirectory(target, 'v0.0.5'), /服务端必须为 5\.7/); + assert.throws(() => verifyBaselineDirectory(target, 'v0.0.5'), /服务端必须为 5\.7\.42/); - manifest.mysql_server_version = '5.7.44-log'; + manifest.mysql_server_version = '5.7.42-log'; manifest.objects.events = false; writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); assert.throws(() => verifyBaselineDirectory(target, 'v0.0.5'), /events/); diff --git a/tools/deployment/verify-docker.mjs b/tools/deployment/verify-docker.mjs new file mode 100644 index 0000000..8f293de --- /dev/null +++ b/tools/deployment/verify-docker.mjs @@ -0,0 +1,158 @@ +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 web = `a-share-analysis-verify-web-${suffix}`; +const containers = [web, 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 ?? '')}`); +} + +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 }, + ); + } + + 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 { + // 构建中断、镜像尚未产生时保持清理幂等。 + } + } +} diff --git a/tools/governance/environment-baseline.test.mjs b/tools/governance/environment-baseline.test.mjs index 57c1924..22af49b 100644 --- a/tools/governance/environment-baseline.test.mjs +++ b/tools/governance/environment-baseline.test.mjs @@ -66,7 +66,7 @@ test('Web API 根地址不重复 OpenAPI 自带的 api 路径', async () => { await readRepoFile('deploy/env/production/web.env.example'), ); - assert.equal(development.get('VITE_API_BASE_URL'), 'http://127.0.0.1:3000'); + assert.equal(development.get('VITE_API_BASE_URL'), ''); assert.equal(production.get('VITE_API_BASE_URL'), ''); }); @@ -165,6 +165,9 @@ test('部署文档覆盖本地、Compose 生产和 Ubuntu 源码生产', async ( assert.match(source, /Node\.js 22 LTS/); assert.match(source, /pnpm 10/); assert.match(source, /不负责安装/); + for (const document of [local, compose, source]) { + assert.match(document, /MySQL 5\.7\.42/); + } }); test('生产 Compose 只接受显式镜像并解析为真实健康检查', async () => { diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 4296860..bf91966 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -9,12 +9,20 @@ export default defineConfig({ baseURL: 'http://127.0.0.1:5173', trace: 'retain-on-failure', }, - webServer: { - command: 'env -u NO_COLOR pnpm dev', - url: 'http://127.0.0.1:5173', - reuseExistingServer: false, - timeout: 30_000, - }, + webServer: [ + { + command: 'node ../server/tools/run-test-api.mjs', + url: 'http://127.0.0.1:3000/api/v1/health', + reuseExistingServer: false, + timeout: 30_000, + }, + { + command: 'env -u NO_COLOR pnpm dev', + url: 'http://127.0.0.1:5173', + reuseExistingServer: false, + timeout: 30_000, + }, + ], projects: [ { name: 'desktop-chromium', diff --git a/web/src/vite-config.test.ts b/web/src/vite-config.test.ts new file mode 100644 index 0000000..0c2999e --- /dev/null +++ b/web/src/vite-config.test.ts @@ -0,0 +1,16 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest'; + +import viteConfig from '../vite.config'; + +describe('Vite 本地 API 代理', () => { + it('通过同源 /api 转发到本地 NestJS,避免浏览器依赖跨域配置', () => { + expect(viteConfig.server?.proxy).toMatchObject({ + '/api': { + target: 'http://127.0.0.1:3000', + changeOrigin: false, + }, + }); + }); +}); diff --git a/web/tests/smoke.spec.ts b/web/tests/smoke.spec.ts index 4c7fedc..8521871 100644 --- a/web/tests/smoke.spec.ts +++ b/web/tests/smoke.spec.ts @@ -21,3 +21,11 @@ test('应用外壳在桌面和移动视口均可正常呈现', async ({ page }) expect(hasHorizontalOverflow).toBe(false); expect(runtimeErrors).toEqual([]); }); + +test('Web 通过同源代理访问真实 API 健康契约', async ({ request }) => { + const response = await request.get('/api/v1/health'); + const body = (await response.json()) as { process?: string; status?: string }; + + expect(response.status()).toBe(200); + expect(body).toMatchObject({ process: 'api', status: 'healthy' }); +}); diff --git a/web/vite.config.ts b/web/vite.config.ts index 5509122..58d5ba3 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -37,5 +37,12 @@ export default defineConfig({ host: '127.0.0.1', port: 5173, strictPort: true, + proxy: { + // 本地浏览器始终请求同源 /api,由 Vite 转发到 NestJS,避免开发环境放宽 CORS。 + '/api': { + target: 'http://127.0.0.1:3000', + changeOrigin: false, + }, + }, }, });