From cb550e3d5cd23e2718655e677f5eccc621040689 Mon Sep 17 00:00:00 2001
From: Citrons <909229731@qq.com>
Date: Thu, 13 Aug 2026 18:51:14 +0800
Subject: [PATCH] =?UTF-8?q?fix(GOV-009):=20=E5=AE=8C=E6=88=90=E6=9C=80?=
=?UTF-8?q?=E7=BB=88=E5=A4=8D=E5=AE=A1=E6=95=B4=E6=94=B9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
AGENTS.md | 2 +-
docs/deployment/docker-compose-production.md | 1 +
package.json | 6 +-
server/test/process-smoke.test.ts | 6 +-
.../boundaries/verify-project-boundaries.mjs | 9 +++
.../verify-project-boundaries.test.mjs | 14 ++++
.../contracts/verify-generated-contracts.mjs | 12 +++
.../verify-generated-contracts.test.mjs | 10 +++
tools/deployment/verify-docker.mjs | 78 ++++++++++++++++++-
tools/deployment/verify-image-references.mjs | 26 +++++++
.../verify-image-references.test.mjs | 33 ++++++++
web/src/api/adapters/health.adapter.ts | 4 +
web/src/api/client.ts | 2 +
web/src/app/providers.ts | 1 +
web/src/app/router.ts | 1 +
15 files changed, 197 insertions(+), 8 deletions(-)
create mode 100644 tools/deployment/verify-image-references.mjs
create mode 100644 tools/deployment/verify-image-references.test.mjs
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(/\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 });
}