diff --git a/AGENTS.md b/AGENTS.md index 5d27d63..404bdbc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ # GitNexus — Code Intelligence -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. +This project is indexed by GitNexus as **a-share-analysis** (1520 symbols, 2137 relationships, 47 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/docs/deployment/docker-compose-production.md b/docs/deployment/docker-compose-production.md index fc91434..8e19fe0 100644 --- a/docs/deployment/docker-compose-production.md +++ b/docs/deployment/docker-compose-production.md @@ -18,6 +18,7 @@ docker build -f server/Dockerfile -t registry.example/a-share-server:<提交SHA> ```bash export A_SHARE_WEB_IMAGE='registry.example/a-share-web@sha256:<摘要>' export A_SHARE_SERVER_IMAGE='registry.example/a-share-server@sha256:<摘要>' +pnpm check:images docker compose -f deploy/compose/production.yml.example config docker compose -f deploy/compose/production.yml.example up -d docker compose -f deploy/compose/production.yml.example ps diff --git a/package.json b/package.json index 2692305..db60235 100644 --- a/package.json +++ b/package.json @@ -22,15 +22,17 @@ "check:contracts": "pnpm --filter @a-share-analysis/server openapi:generate && pnpm --filter @a-share-analysis/web api:generate && pnpm exec prettier --write server/openapi/openapi.json web/src/api/generated/schema.ts && node tools/contracts/verify-generated-contracts.mjs && node tools/boundaries/verify-project-boundaries.mjs", "test:contracts": "node --test tools/contracts/verify-generated-contracts.test.mjs", "test:boundaries": "node --test tools/boundaries/verify-project-boundaries.test.mjs", + "test:deployment": "node --test tools/deployment/verify-image-references.test.mjs", "lint": "pnpm --filter @a-share-analysis/web --if-present lint && pnpm --filter @a-share-analysis/server --if-present lint", "format:check": "pnpm exec prettier --check .", "typecheck": "pnpm --filter @a-share-analysis/web --if-present typecheck && pnpm --filter @a-share-analysis/server --if-present typecheck", - "test": "pnpm test:runtime && pnpm test:governance && pnpm test:mysql-baseline && pnpm test:environment-baseline && pnpm test:schema-baseline && pnpm test:contracts && pnpm test:boundaries && pnpm --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 test:deployment && pnpm --filter @a-share-analysis/web --if-present test && pnpm --filter @a-share-analysis/server --if-present test", "build": "pnpm --filter @a-share-analysis/web --if-present build && pnpm --filter @a-share-analysis/server --if-present build", "verify:e2e": "pnpm --filter @a-share-analysis/web test:e2e", + "check:images": "node tools/deployment/verify-image-references.mjs", "verify:docker": "node tools/deployment/verify-docker.mjs", "verify": "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" + "verify:release": "pnpm verify && pnpm check:images && pnpm verify:docker" }, "devDependencies": { "@eslint/js": "9.39.5", diff --git a/server/test/process-smoke.test.ts b/server/test/process-smoke.test.ts index a214f7f..b40a3b0 100644 --- a/server/test/process-smoke.test.ts +++ b/server/test/process-smoke.test.ts @@ -130,8 +130,8 @@ describe('NestJS 三进程入口', () => { const child = spawn(process.execPath, [launcher, entry], { cwd: path.resolve(__dirname, '..'), env: { - ...process.env, - APP_ENV: 'development', + PATH: process.env.PATH, + APP_ENV: 'production', LOG_LEVEL: 'error', REDIS_HOST: '127.0.0.1', REDIS_PORT: '6379', @@ -162,7 +162,7 @@ describe('NestJS 三进程入口', () => { ROCKETMQ_CONSUMER_CONCURRENCY: '4', SCHEDULER_ENABLED: 'true', SCHEDULER_TIMEZONE: 'Asia/Shanghai', - MARKET_DATA_MODE: 'mock', + MARKET_DATA_MODE: 'live', MARKET_DATA_PROVIDER: 'deterministic-fixture', MARKET_DATA_BASE_URL: '', MARKET_DATA_API_KEY: '', diff --git a/tools/boundaries/verify-project-boundaries.mjs b/tools/boundaries/verify-project-boundaries.mjs index fc75b40..da2bb5b 100644 --- a/tools/boundaries/verify-project-boundaries.mjs +++ b/tools/boundaries/verify-project-boundaries.mjs @@ -20,6 +20,12 @@ async function collectSourceFiles(directory) { function extractTypeScriptSource(sourcePath, sourceText) { if (path.extname(sourcePath) !== '.vue') return sourceText; + const externalScript = sourceText.match(/]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/i); + if (externalScript) { + throw new Error( + `Web Vue 组件禁止使用外部 script src:${path.basename(sourcePath)} -> ${externalScript[1]}`, + ); + } // 边界门禁只需解析 SFC 的脚本块;模板和样式不会产生 TypeScript 模块依赖。 return [...sourceText.matchAll(/]*>([\s\S]*?)<\/script>/gi)] .map((match) => match[1]) @@ -65,6 +71,9 @@ function resolvesInsideServer(specifier, sourcePath, repositoryRoot, compilerOpt return candidate === serverRoot || candidate.startsWith(`${serverRoot}${path.sep}`); } +/** + * 扫描 Web TypeScript 与 Vue 脚本,阻断对 Server 源码和内部消息契约的反向依赖。 + */ export async function verifyProjectBoundaries(repositoryRoot) { const webSourceRoot = path.join(repositoryRoot, 'web', 'src'); const configPath = path.join(repositoryRoot, 'web', 'tsconfig.json'); diff --git a/tools/boundaries/verify-project-boundaries.test.mjs b/tools/boundaries/verify-project-boundaries.test.mjs index d26f791..a7fe230 100644 --- a/tools/boundaries/verify-project-boundaries.test.mjs +++ b/tools/boundaries/verify-project-boundaries.test.mjs @@ -108,6 +108,20 @@ const value: Internal | undefined = undefined; assert.match(result.stderr, /IllegalView\.vue/); }); +test('拒绝 Vue 外部 script src 绕过源码边界', async () => { + const repositoryRoot = await createFixture(); + await writeFile( + path.join(repositoryRoot, 'web/src/ExternalScript.vue'), + `\n\n`, + ); + + const result = await runChecker(repositoryRoot); + + assert.equal(result.exitCode, 1); + assert.match(result.stderr, /禁止使用外部 script src/); + assert.match(result.stderr, /ExternalScript\.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 index 9cb9130..ed1afe2 100644 --- a/tools/contracts/verify-generated-contracts.mjs +++ b/tools/contracts/verify-generated-contracts.mjs @@ -8,6 +8,18 @@ import { fileURLToPath } from 'node:url'; * 避免用户其他工作区改动干扰契约门禁。 */ export function assertGeneratedContractsUnchanged(repositoryRoot, contractFiles) { + for (const contractFile of contractFiles) { + try { + execFileSync('git', ['ls-files', '--error-unmatch', '--', contractFile], { + cwd: repositoryRoot, + encoding: 'utf8', + stdio: ['ignore', 'ignore', 'pipe'], + }); + } catch { + throw new Error(`生成契约未纳入 Git 跟踪:${contractFile}`); + } + } + try { execFileSync('git', ['diff', '--exit-code', '--', ...contractFiles], { cwd: repositoryRoot, diff --git a/tools/contracts/verify-generated-contracts.test.mjs b/tools/contracts/verify-generated-contracts.test.mjs index 23e846d..69de7ef 100644 --- a/tools/contracts/verify-generated-contracts.test.mjs +++ b/tools/contracts/verify-generated-contracts.test.mjs @@ -34,3 +34,13 @@ test('生成契约发生漂移时以明确错误阻断', () => { /生成契约与 Git 提交版本不一致:contract\.json/, ); }); + +test('生成文件未纳入 Git 跟踪时阻断', () => { + const repositoryRoot = createGitFixture(); + writeFileSync(path.join(repositoryRoot, 'untracked-contract.json'), '{"version":1}\n'); + + assert.throws( + () => assertGeneratedContractsUnchanged(repositoryRoot, ['untracked-contract.json']), + /生成契约未纳入 Git 跟踪:untracked-contract\.json/, + ); +}); diff --git a/tools/deployment/verify-docker.mjs b/tools/deployment/verify-docker.mjs index 8f293de..11ff3ef 100644 --- a/tools/deployment/verify-docker.mjs +++ b/tools/deployment/verify-docker.mjs @@ -7,8 +7,10 @@ const serverImage = `a-share-analysis-server:verify-${suffix}`; const network = `a-share-analysis-verify-${suffix}`; const redis = `a-share-analysis-verify-redis-${suffix}`; const api = `a-share-analysis-verify-api-${suffix}`; +const scheduler = `a-share-analysis-verify-scheduler-${suffix}`; +const worker = `a-share-analysis-verify-worker-${suffix}`; const web = `a-share-analysis-verify-web-${suffix}`; -const containers = [web, api, redis]; +const containers = [web, worker, scheduler, api, redis]; function docker(args, options = {}) { return execFileSync('docker', args, { @@ -32,6 +34,34 @@ function waitForContainerCommand(container, command, description) { throw new Error(`${description} 未在 6 秒内就绪:${String(lastError?.stderr ?? '')}`); } +function verifyLongRunningProcess(container, entry, environment) { + const environmentArguments = environment.flatMap(([key, value]) => ['--env', `${key}=${value}`]); + docker( + [ + 'run', + '--detach', + '--name', + container, + ...environmentArguments, + serverImage, + 'node', + `/app/dist/apps/${entry}/main.js`, + ], + { capture: true }, + ); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 500); + const running = docker(['inspect', container, '--format', '{{.State.Running}}'], { + capture: true, + }).trim(); + if (running !== 'true') throw new Error(`${entry} 容器未保持运行`); + + docker(['stop', '--time', '5', container], { capture: true }); + const exitCode = docker(['inspect', container, '--format', '{{.State.ExitCode}}'], { + capture: true, + }).trim(); + if (exitCode !== '0') throw new Error(`${entry} 容器未优雅退出,退出码 ${exitCode}`); +} + try { docker(['build', '--file', 'web/Dockerfile', '--tag', webImage, '.']); docker(['build', '--file', 'server/Dockerfile', '--tag', serverImage, '.']); @@ -48,6 +78,50 @@ try { ); } + const sharedProcessEnvironment = [ + ['APP_ENV', 'production'], + ['LOG_LEVEL', 'error'], + ['REDIS_HOST', '127.0.0.1'], + ['REDIS_PORT', '6379'], + ['REDIS_USERNAME', ''], + ['REDIS_PASSWORD', ''], + ['REDIS_DATABASE', '1'], + ['REDIS_TLS', 'false'], + ['ROCKETMQ_ENABLED', 'false'], + ['ROCKETMQ_NAMESRV_ADDR', '127.0.0.1:9876'], + ['ROCKETMQ_ENDPOINT', '127.0.0.1:8081'], + ['ROCKETMQ_TOPIC_PREFIX', 'a-share-analysis'], + ]; + verifyLongRunningProcess(scheduler, 'scheduler', [ + ...sharedProcessEnvironment, + ['SCHEDULER_TIMEZONE', 'Asia/Shanghai'], + ['SCHEDULER_ENABLED', 'true'], + ['ROCKETMQ_SEND_TIMEOUT_MS', '3000'], + ]); + verifyLongRunningProcess(worker, 'worker', [ + ...sharedProcessEnvironment, + ['MARKET_DATA_MODE', 'live'], + ['MARKET_DATA_PROVIDER', 'deterministic-fixture'], + ['MARKET_DATA_BASE_URL', ''], + ['MARKET_DATA_API_KEY', ''], + ['MYSQL_ENABLED', 'false'], + ['MYSQL_HOST', '127.0.0.1'], + ['MYSQL_PORT', '3306'], + ['MYSQL_DATABASE', 'a_share_analysis_dev'], + ['MYSQL_USERNAME', ''], + ['MYSQL_PASSWORD', ''], + ['MYSQL_CHARSET', 'utf8mb4'], + ['MYSQL_COLLATION', 'utf8mb4_general_ci'], + ['MYSQL_TIMEZONE', '+08:00'], + ['AI_ENABLED', 'false'], + ['AI_BASE_URL', ''], + ['AI_API_KEY', ''], + ['AI_MODEL', ''], + ['AI_TIMEOUT_MS', '30000'], + ['ROCKETMQ_CONSUMER_GROUP', 'a-share-analysis-worker'], + ['ROCKETMQ_CONSUMER_CONCURRENCY', '4'], + ]); + docker(['network', 'create', network], { capture: true }); docker(['run', '--detach', '--name', redis, '--network', network, 'redis:7.4.8-alpine3.21'], { capture: true, @@ -134,7 +208,7 @@ try { 'Web 到 API 的 Nginx 代理', ); - process.stdout.write('Docker 镜像、非 root 用户、三进程入口与 Web/API 链路验证通过\n'); + process.stdout.write('Docker 镜像、非 root 用户、三进程生命周期与 Web/API 链路验证通过\n'); } finally { for (const container of containers) { try { diff --git a/tools/deployment/verify-image-references.mjs b/tools/deployment/verify-image-references.mjs new file mode 100644 index 0000000..8504e7c --- /dev/null +++ b/tools/deployment/verify-image-references.mjs @@ -0,0 +1,26 @@ +import process from 'node:process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const IMMUTABLE_IMAGE_PATTERN = /^\S+@sha256:[a-f0-9]{64}$/; + +/** + * Compose 变量语法只能阻断空值,无法拒绝 latest 或普通 tag;发布前必须先通过 + * 该校验器,确保 Web/Server 都使用包含仓库名的内容摘要引用。 + */ +export function assertImmutableImageReferences(environment) { + for (const key of ['A_SHARE_WEB_IMAGE', 'A_SHARE_SERVER_IMAGE']) { + const value = environment[key] ?? ''; + if (!IMMUTABLE_IMAGE_PATTERN.test(value)) { + throw new Error(`${key} 必须使用仓库名@sha256摘要`); + } + } +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)) +) { + assertImmutableImageReferences(process.env); + process.stdout.write('生产镜像引用验证通过\n'); +} diff --git a/tools/deployment/verify-image-references.test.mjs b/tools/deployment/verify-image-references.test.mjs new file mode 100644 index 0000000..1858998 --- /dev/null +++ b/tools/deployment/verify-image-references.test.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { assertImmutableImageReferences } from './verify-image-references.mjs'; + +const digest = `sha256:${'a'.repeat(64)}`; + +test('接受包含仓库名和 sha256 摘要的不可变镜像', () => { + assert.doesNotThrow(() => + assertImmutableImageReferences({ + A_SHARE_WEB_IMAGE: `registry.invalid/web@${digest}`, + A_SHARE_SERVER_IMAGE: `registry.invalid/server@${digest}`, + }), + ); +}); + +for (const [name, image] of [ + ['latest', 'registry.invalid/web:latest'], + ['普通 tag', 'registry.invalid/web:3a8120a'], + ['裸摘要', digest], + ['空值', ''], +]) { + test(`拒绝 Web ${name} 镜像引用`, () => { + assert.throws( + () => + assertImmutableImageReferences({ + A_SHARE_WEB_IMAGE: image, + A_SHARE_SERVER_IMAGE: `registry.invalid/server@${digest}`, + }), + /A_SHARE_WEB_IMAGE 必须使用仓库名@sha256摘要/, + ); + }); +} diff --git a/web/src/api/adapters/health.adapter.ts b/web/src/api/adapters/health.adapter.ts index 9fc702d..85dc416 100644 --- a/web/src/api/adapters/health.adapter.ts +++ b/web/src/api/adapters/health.adapter.ts @@ -3,8 +3,10 @@ import type { components } from '../generated/schema'; type HealthResponseDto = components['schemas']['HealthResponseDto']; type DependencyHealthDto = components['schemas']['DependencyHealthDto']; +/** 页面级健康状态把后端 disabled 映射为 degraded,避免与真实故障混为一谈。 */ export type HealthViewState = 'healthy' | 'degraded' | 'unavailable'; +/** 单项依赖的展示模型隐藏后端 DTO 结构,只保留名称、可用性和安全说明。 */ export interface HealthServiceViewModel { key: 'redis' | 'mysql' | 'rocketMq'; name: 'Redis' | 'MySQL' | 'RocketMQ'; @@ -12,6 +14,7 @@ export interface HealthServiceViewModel { note: string; } +/** 健康页稳定展示契约;Vue 组件不直接依赖 OpenAPI 生成类型。 */ export interface HealthViewModel { state: HealthViewState; stateLabel: '运行正常' | '部分能力停用' | '服务异常'; @@ -39,6 +42,7 @@ function toServiceViewModel( }; } +/** 将版本化 HTTP DTO 转为页面展示模型,并拒绝未知的服务端状态枚举。 */ export function toHealthViewModel(dto: HealthResponseDto): HealthViewModel { const dependencies = Object.values(dto.dependencies); for (const dependency of dependencies) assertDependencyStatus(dependency.status); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index c1a65f6..bc7e4c2 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,6 +1,7 @@ import createClient, { type Client } from 'openapi-fetch'; import type { paths } from './generated/schema'; +/** 统一的安全请求错误,不把浏览器或代理底层异常直接展示给用户。 */ export class ApiRequestError extends Error { constructor() { super('API 请求失败,请稍后重试'); @@ -8,6 +9,7 @@ export class ApiRequestError extends Error { } } +/** 创建受 OpenAPI paths 约束的客户端,并在边界统一清洗网络异常。 */ export function createApiClient( baseUrl: string, fetchImplementation: (request: Request) => Promise = globalThis.fetch, diff --git a/web/src/app/providers.ts b/web/src/app/providers.ts index 87e73c7..7809bd1 100644 --- a/web/src/app/providers.ts +++ b/web/src/app/providers.ts @@ -3,6 +3,7 @@ import type { App } from 'vue'; import { createAppRouter } from './router'; +/** 集中安装全局 Provider,防止页面自行创建重复的 Pinia 或 Router 实例。 */ export function installAppProviders(app: App) { app.use(createPinia()); app.use(createAppRouter()); diff --git a/web/src/app/router.ts b/web/src/app/router.ts index 4cf79c5..a9de58d 100644 --- a/web/src/app/router.ts +++ b/web/src/app/router.ts @@ -17,6 +17,7 @@ const routes: RouteRecordRaw[] = [ }, ]; +/** 创建可注入 history 的路由实例,使浏览器运行与单元测试共享同一路由表。 */ export function createAppRouter(history: RouterHistory = createWebHistory()) { return createRouter({ history, routes }); }