From d2c18634eddd3720af6dd47e5a4be7c3933b70ab Mon Sep 17 00:00:00 2001
From: Citrons <909229731@qq.com>
Date: Thu, 13 Aug 2026 23:14:22 +0800
Subject: [PATCH] =?UTF-8?q?feat(GOV-010):=20=E5=BB=BA=E7=AB=8B=E5=89=8D?=
=?UTF-8?q?=E7=AB=AF=20API=20=E5=88=86=E5=9F=9F=E8=BE=B9=E7=95=8C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../boundaries/verify-project-boundaries.mjs | 81 ++++++++++++++++-
.../verify-project-boundaries.test.mjs | 87 +++++++++++++++++++
web/src/api/health/index.test.ts | 36 ++++++++
web/src/api/health/index.ts | 13 +++
4 files changed, 215 insertions(+), 2 deletions(-)
create mode 100644 web/src/api/health/index.test.ts
create mode 100644 web/src/api/health/index.ts
diff --git a/tools/boundaries/verify-project-boundaries.mjs b/tools/boundaries/verify-project-boundaries.mjs
index 117b210..cdc8fe4 100644
--- a/tools/boundaries/verify-project-boundaries.mjs
+++ b/tools/boundaries/verify-project-boundaries.mjs
@@ -48,7 +48,8 @@ function collectModuleSpecifiers(sourceFile) {
}
if (
ts.isCallExpression(node) &&
- node.expression.kind === ts.SyntaxKind.ImportKeyword &&
+ (node.expression.kind === ts.SyntaxKind.ImportKeyword ||
+ (ts.isIdentifier(node.expression) && node.expression.text === 'require')) &&
node.arguments.length === 1 &&
ts.isStringLiteralLike(node.arguments[0])
) {
@@ -61,6 +62,74 @@ function collectModuleSpecifiers(sourceFile) {
return specifiers;
}
+function isTestSource(relativeSourcePath) {
+ return /(?:^|\/)__tests__(?:\/|$)|\.(?:test|spec)\.[^.]+$/.test(relativeSourcePath);
+}
+
+function collectHttpBoundaryViolations(sourceFile, relativeSourcePath, moduleSpecifiers) {
+ if (isTestSource(relativeSourcePath)) return [];
+
+ const normalizedPath = relativeSourcePath.split(path.sep).join('/');
+ const isRequestBoundary = normalizedPath === 'utils/request.ts';
+ const isGeneratedContract = normalizedPath.startsWith('api/generated/');
+ const isBusinessApi = normalizedPath.startsWith('api/') && !isGeneratedContract;
+ const importsAxios = moduleSpecifiers.includes('axios');
+ const importsSharedRequest = moduleSpecifiers.includes('@/utils/request');
+ const violations = [];
+ let declaresBusinessApiPath = false;
+
+ if (importsAxios && !isRequestBoundary) {
+ violations.push(`Web 运行时代码禁止直接使用 Axios:${normalizedPath}`);
+ }
+
+ function containsBusinessApiPath(node) {
+ if (ts.isStringLiteralLike(node) && /^\/api(?:\/|$)/.test(node.text)) return true;
+ let found = false;
+ ts.forEachChild(node, (child) => {
+ if (!found && containsBusinessApiPath(child)) found = true;
+ });
+ return found;
+ }
+
+ function visit(node) {
+ if (ts.isCallExpression(node)) {
+ const callsFetch =
+ (ts.isIdentifier(node.expression) && node.expression.text === 'fetch') ||
+ (ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'fetch');
+ if (callsFetch) {
+ violations.push(`Web 运行时代码禁止直接调用 fetch:${normalizedPath}`);
+ }
+
+ if (
+ ts.isPropertyAccessExpression(node.expression) &&
+ ts.isIdentifier(node.expression.expression) &&
+ node.expression.expression.text === 'axios'
+ ) {
+ if (!isRequestBoundary) {
+ violations.push(`Web 运行时代码禁止直接使用 Axios:${normalizedPath}`);
+ }
+ }
+
+ const hasBusinessApiArgument = node.arguments.some(containsBusinessApiPath);
+ if (hasBusinessApiArgument) {
+ declaresBusinessApiPath = true;
+ if (!isBusinessApi) {
+ violations.push(`业务 API 路径只能声明在 web/src/api:${normalizedPath}`);
+ }
+ }
+ }
+ ts.forEachChild(node, visit);
+ }
+
+ visit(sourceFile);
+
+ if (isBusinessApi && declaresBusinessApiPath && !importsSharedRequest) {
+ violations.push(`业务 API 文件必须使用 @/utils/request:${normalizedPath}`);
+ }
+
+ return [...new Set(violations)];
+}
+
function resolvesInsideServer(specifier, sourcePath, repositoryRoot, compilerOptions) {
const serverRoot = path.join(repositoryRoot, 'server');
const resolved = ts.resolveModuleName(specifier, sourcePath, compilerOptions, ts.sys)
@@ -96,13 +165,21 @@ export async function verifyProjectBoundaries(repositoryRoot) {
true,
ts.ScriptKind.TS,
);
- for (const specifier of collectModuleSpecifiers(sourceFile)) {
+ const moduleSpecifiers = collectModuleSpecifiers(sourceFile);
+ for (const specifier of moduleSpecifiers) {
if (resolvesInsideServer(specifier, sourcePath, repositoryRoot, compilerOptions)) {
violations.push(
`Web 禁止依赖 Server:${path.relative(repositoryRoot, sourcePath)} -> ${specifier}`,
);
}
}
+ violations.push(
+ ...collectHttpBoundaryViolations(
+ sourceFile,
+ path.relative(webSourceRoot, sourcePath),
+ moduleSpecifiers,
+ ),
+ );
}
const generatedSchemaPath = path.join(webSourceRoot, 'api', 'generated', 'schema.ts');
diff --git a/tools/boundaries/verify-project-boundaries.test.mjs b/tools/boundaries/verify-project-boundaries.test.mjs
index c33a884..2f31d95 100644
--- a/tools/boundaries/verify-project-boundaries.test.mjs
+++ b/tools/boundaries/verify-project-boundaries.test.mjs
@@ -140,3 +140,90 @@ test('接受只消费公开 OpenAPI 类型的 Web 工程', async () => {
assert.equal(result.exitCode, 0);
assert.equal(result.stderr, '');
});
+
+for (const [name, relativePath, source, expectedViolation] of [
+ [
+ '页面直接导入 Axios',
+ 'web/src/views/MarketView.vue',
+ `
+
+`,
+ 'Web 运行时代码禁止直接使用 Axios',
+ ],
+ [
+ 'Store 直接调用 fetch',
+ 'web/src/stores/market.ts',
+ "export const loadMarket = () => fetch('/api/v1/market');\n",
+ 'Web 运行时代码禁止直接调用 fetch',
+ ],
+ [
+ '页面通过 globalThis 调用 fetch',
+ 'web/src/views/GlobalFetchView.ts',
+ "export const loadMarket = () => globalThis.fetch('/api/v1/market');\n",
+ 'Web 运行时代码禁止直接调用 fetch',
+ ],
+ [
+ 'Composable 自行创建 Axios 实例',
+ 'web/src/composables/use-market.ts',
+ "import axios from 'axios';\nexport const client = axios.create();\n",
+ 'Web 运行时代码禁止直接使用 Axios',
+ ],
+ [
+ '页面把业务 API 路径交给其他函数',
+ 'web/src/views/MarketView.ts',
+ "declare function load(path: string): void;\nload('/api/v1/market');\n",
+ '业务 API 路径只能声明在 web/src/api',
+ ],
+ [
+ '业务 API 绕过统一 request',
+ 'web/src/api/market/index.ts',
+ "declare function send(path: string): void;\nexport const load = () => send('/api/v1/market');\n",
+ '业务 API 文件必须使用 @/utils/request',
+ ],
+ [
+ '业务 API 通过配置对象绕过统一 request',
+ 'web/src/api/market/config.ts',
+ "declare function send(config: { url: string }): void;\nexport const load = () => send({ url: '/api/v1/market' });\n",
+ '业务 API 文件必须使用 @/utils/request',
+ ],
+]) {
+ test(`拒绝${name}`, async () => {
+ const repositoryRoot = await createFixture();
+ const sourcePath = path.join(repositoryRoot, relativePath);
+ await mkdir(path.dirname(sourcePath), { recursive: true });
+ await writeFile(sourcePath, source);
+
+ const result = await runChecker(repositoryRoot);
+
+ assert.equal(result.exitCode, 1);
+ assert.match(result.stderr, new RegExp(expectedViolation));
+ assert.match(result.stderr, new RegExp(path.basename(relativePath).replace('.', '\\.')));
+ });
+}
+
+test('接受 Axios 封装、业务 API 和页面消费的三层边界', async () => {
+ const repositoryRoot = await createFixture();
+ await mkdir(path.join(repositoryRoot, 'web/src/utils'), { recursive: true });
+ await mkdir(path.join(repositoryRoot, 'web/src/api/health'), { recursive: true });
+ await mkdir(path.join(repositoryRoot, 'web/src/views'), { recursive: true });
+ await writeFile(
+ path.join(repositoryRoot, 'web/src/utils/request.ts'),
+ "import axios from 'axios';\nexport default axios.create();\n",
+ );
+ await writeFile(
+ path.join(repositoryRoot, 'web/src/api/health/index.ts'),
+ "import request from '@/utils/request';\nexport const getHealth = () => request.get('/api/v1/health');\n",
+ );
+ await writeFile(
+ path.join(repositoryRoot, 'web/src/views/HealthView.ts'),
+ "import { getHealth } from '@/api/health';\nexport const load = () => getHealth();\n",
+ );
+
+ const result = await runChecker(repositoryRoot);
+
+ assert.equal(result.exitCode, 0);
+ assert.equal(result.stderr, '');
+});
diff --git a/web/src/api/health/index.test.ts b/web/src/api/health/index.test.ts
new file mode 100644
index 0000000..198e3ac
--- /dev/null
+++ b/web/src/api/health/index.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import request from '@/utils/request';
+import type { components } from '../generated/schema';
+import { getHealth } from './index';
+
+vi.mock('@/utils/request', () => ({ default: vi.fn() }));
+
+type HealthResponseDto = components['schemas']['HealthResponseDto'];
+
+const healthyDto: HealthResponseDto = {
+ status: 'healthy',
+ process: 'api',
+ checkedAt: '2026-08-13T09:30:00.000Z',
+ dependencies: {
+ mysql: { status: 'healthy', detail: 'ready' },
+ redis: { status: 'healthy', detail: 'ready' },
+ rocketMq: { status: 'disabled', detail: 'disabled' },
+ },
+};
+
+describe('健康业务 API', () => {
+ it('通过统一 request 请求版本化路径并返回 OpenAPI DTO', async () => {
+ const signal = new AbortController().signal;
+ vi.mocked(request).mockResolvedValue(healthyDto);
+
+ const result: HealthResponseDto = await getHealth(signal);
+
+ expect(result).toEqual(healthyDto);
+ expect(request).toHaveBeenCalledWith({
+ url: '/api/v1/health',
+ method: 'get',
+ signal,
+ });
+ });
+});
diff --git a/web/src/api/health/index.ts b/web/src/api/health/index.ts
new file mode 100644
index 0000000..c098645
--- /dev/null
+++ b/web/src/api/health/index.ts
@@ -0,0 +1,13 @@
+import type { components } from '@/api/generated/schema';
+import request from '@/utils/request';
+
+export type HealthResponseDto = components['schemas']['HealthResponseDto'];
+
+/** 健康接口只返回 OpenAPI DTO,不把 Axios 响应对象扩散到功能层。 */
+export function getHealth(signal?: AbortSignal): Promise {
+ return request({
+ url: '/api/v1/health',
+ method: 'get',
+ signal,
+ });
+}