fix(GOV-009): 完成脚手架审查整改
This commit is contained in:
@@ -24,7 +24,7 @@
|
||||
<!-- gitnexus:start -->
|
||||
# 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.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 应用镜像和启动入口将在 Web/Server 脚手架落地后验证。本文件先固定生产配置边界。
|
||||
# Web/Server 镜像与三进程入口均已纳入发布验证;外部基础设施继续由运维管理。
|
||||
name: a-share-analysis-production
|
||||
|
||||
services:
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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/表排序规则、迁移和关键查询。
|
||||
|
||||
@@ -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 补丁版本也不得替代发布验收。
|
||||
|
||||
## 构建与配置
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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 补丁版本不得代替生产验收。
|
||||
|
||||
## 构建发布目录
|
||||
|
||||
|
||||
@@ -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 审批。
|
||||
|
||||
+7
-3
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
@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,
|
||||
useFactory: () => createRedisHealthClient(apiConfig.redis),
|
||||
inject: [API_RUNTIME_CONFIG],
|
||||
useFactory: (runtimeConfig: ApiRuntimeConfig) =>
|
||||
createRedisHealthClient(runtimeConfig.redis),
|
||||
},
|
||||
{
|
||||
provide: MYSQL_HEALTH_PORT,
|
||||
useFactory: () =>
|
||||
apiConfig.mysql.enabled
|
||||
? createMysqlHealthClient(apiConfig.mysql)
|
||||
inject: [API_RUNTIME_CONFIG],
|
||||
useFactory: (runtimeConfig: ApiRuntimeConfig) =>
|
||||
runtimeConfig.mysql.enabled
|
||||
? createMysqlHealthClient(runtimeConfig.mysql)
|
||||
: new DisabledHealthClient('MySQL 未启用'),
|
||||
},
|
||||
],
|
||||
})
|
||||
export class ApiModule {}
|
||||
exports: [API_RUNTIME_CONFIG],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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<DependencyHealthDto>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
/** 聚合依赖状态并统一清洗底层错误,禁止连接信息进入 HTTP 响应。 */
|
||||
export class HealthService {
|
||||
constructor(
|
||||
@Inject(REDIS_HEALTH_PORT) private readonly redis: DependencyHealthPort,
|
||||
|
||||
@@ -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<INestApplication> {
|
||||
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<void> {
|
||||
await bootstrapApi({ config });
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(__filename)) {
|
||||
void runApi();
|
||||
}
|
||||
|
||||
@@ -18,12 +18,9 @@ describe('Server OpenAPI 单向发布', () => {
|
||||
path.resolve(__dirname, '../../../openapi/openapi.json'),
|
||||
'utf8',
|
||||
);
|
||||
const document = JSON.parse(content) as {
|
||||
paths: Record<string, unknown>;
|
||||
components?: { schemas?: Record<string, unknown> };
|
||||
};
|
||||
const committedDocument = JSON.parse(content) as Record<string, unknown>;
|
||||
const liveDocument = await createOpenApiDocument();
|
||||
|
||||
expect(document.paths).toHaveProperty('/api/v1/health');
|
||||
expect(document.components?.schemas).toHaveProperty('HealthResponseDto');
|
||||
expect(committedDocument).toEqual(liveDocument);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<INestApplicationContext> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<INestApplicationContext> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
@@ -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<Record<string, string | undefined>>;
|
||||
|
||||
@@ -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<Record<string, unknown>> {
|
||||
const summary: Record<string, unknown> = {
|
||||
processName: config.processName,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { DependencyHealthDto } from '../../http-contracts/src/health.dto';
|
||||
|
||||
/** 将计划内关闭的依赖表达为 disabled,避免误报 unavailable 或尝试建立连接。 */
|
||||
export class DisabledHealthClient {
|
||||
constructor(private readonly detail: string) {}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<TPayload>(message: Readonly<MessageEnvelope<TPayload>>): Promise<void> {
|
||||
void message;
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { MessageEnvelope } from '../../message-contracts/src/message-envelope';
|
||||
|
||||
/** 消息处理器只消费项目内信封,禁止业务层依赖具体 RocketMQ SDK 类型。 */
|
||||
export type MessageHandler<TPayload = unknown> = (
|
||||
message: Readonly<MessageEnvelope<TPayload>>,
|
||||
) => Promise<void>;
|
||||
|
||||
/** 消息总线端口隔离客户端选型,并固定发布、订阅和资源关闭的最小语义。 */
|
||||
export interface MessageBusAdapter {
|
||||
publish<TPayload>(message: Readonly<MessageEnvelope<TPayload>>): Promise<void>;
|
||||
subscribe<TPayload>(eventType: string, handler: MessageHandler<TPayload>): Promise<void>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { INestApplicationContext } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* 非 HTTP 进程没有监听套接字,必须显式等待 systemd/Docker 的退出信号才能持续运行。
|
||||
* 收到首个信号后先移除监听器,再等待 Nest 完成资源释放,保证正常退出码为 0。
|
||||
*/
|
||||
export async function waitForShutdownSignal(app: INestApplicationContext): Promise<void> {
|
||||
await new Promise<void>((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);
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,7 @@ interface RedisPingPort {
|
||||
quit(): Promise<unknown>;
|
||||
}
|
||||
|
||||
/** 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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
/** Server 内部消息统一信封;字段同时满足幂等、版本演进和跨进程链路追踪。 */
|
||||
export interface MessageEnvelope<TPayload> {
|
||||
version: 1;
|
||||
eventId: string;
|
||||
eventType: string;
|
||||
schemaVersion: 1;
|
||||
idempotencyKey: string;
|
||||
occurredAt: string;
|
||||
tradeDate: string;
|
||||
traceId: string;
|
||||
payload: TPayload;
|
||||
}
|
||||
|
||||
type MessageEnvelopeInput<TPayload> = Omit<MessageEnvelope<TPayload>, 'version'> & {
|
||||
version: number;
|
||||
type MessageEnvelopeInput<TPayload> = Omit<MessageEnvelope<TPayload>, 'schemaVersion'> & {
|
||||
schemaVersion: number;
|
||||
};
|
||||
|
||||
function deepFreeze<TValue>(value: TValue): Readonly<TValue> {
|
||||
@@ -18,22 +22,36 @@ function deepFreeze<TValue>(value: TValue): Readonly<TValue> {
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 校验、规范化并深冻结消息信封,防止投递后字段被异步处理链路改写。 */
|
||||
export function createMessageEnvelope<TPayload>(
|
||||
input: MessageEnvelopeInput<TPayload>,
|
||||
): Readonly<MessageEnvelope<TPayload>> {
|
||||
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<MessageEnvelope<TPayload>>;
|
||||
}
|
||||
|
||||
@@ -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<number | null>((resolve) =>
|
||||
child.once('exit', (code) => resolve(code)),
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
} finally {
|
||||
if (child.exitCode === null) child.kill('SIGKILL');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ function sortObject(value: unknown): unknown {
|
||||
);
|
||||
}
|
||||
|
||||
/** 从真实 Controller/DTO 生成确定性 OpenAPI 文档,生成期间不连接外部基础设施。 */
|
||||
export async function createOpenApiDocument(): Promise<OpenAPIObject> {
|
||||
const app = await NestFactory.create(OpenApiModule, { logger: false });
|
||||
app.setGlobalPrefix('api/v1');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(/<script\b[^>]*>([\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,
|
||||
|
||||
@@ -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'),
|
||||
`<script setup lang="ts">
|
||||
import type { Internal } from '../../server/libs/internal';
|
||||
const value: Internal | undefined = undefined;
|
||||
</script>
|
||||
<template><main>{{ value }}</main></template>
|
||||
`,
|
||||
);
|
||||
|
||||
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(
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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) {
|
||||
// 已发布清单记录的是发布当时的数据库启用状态。当前开发配置只能决定下一份
|
||||
// 基线如何生成,不能反向否定或改写已发布版本的历史事实。
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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 {
|
||||
// 构建中断、镜像尚未产生时保持清理幂等。
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -9,12 +9,20 @@ export default defineConfig({
|
||||
baseURL: 'http://127.0.0.1:5173',
|
||||
trace: 'retain-on-failure',
|
||||
},
|
||||
webServer: {
|
||||
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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user