feat(GOV-010): 建立前端 API 分域边界

This commit is contained in:
2026-08-13 23:14:22 +08:00
parent 4105336cb2
commit d2c18634ed
4 changed files with 215 additions and 2 deletions
+79 -2
View File
@@ -48,7 +48,8 @@ function collectModuleSpecifiers(sourceFile) {
} }
if ( if (
ts.isCallExpression(node) && 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 && node.arguments.length === 1 &&
ts.isStringLiteralLike(node.arguments[0]) ts.isStringLiteralLike(node.arguments[0])
) { ) {
@@ -61,6 +62,74 @@ function collectModuleSpecifiers(sourceFile) {
return specifiers; 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) { function resolvesInsideServer(specifier, sourcePath, repositoryRoot, compilerOptions) {
const serverRoot = path.join(repositoryRoot, 'server'); const serverRoot = path.join(repositoryRoot, 'server');
const resolved = ts.resolveModuleName(specifier, sourcePath, compilerOptions, ts.sys) const resolved = ts.resolveModuleName(specifier, sourcePath, compilerOptions, ts.sys)
@@ -96,13 +165,21 @@ export async function verifyProjectBoundaries(repositoryRoot) {
true, true,
ts.ScriptKind.TS, ts.ScriptKind.TS,
); );
for (const specifier of collectModuleSpecifiers(sourceFile)) { const moduleSpecifiers = collectModuleSpecifiers(sourceFile);
for (const specifier of moduleSpecifiers) {
if (resolvesInsideServer(specifier, sourcePath, repositoryRoot, compilerOptions)) { if (resolvesInsideServer(specifier, sourcePath, repositoryRoot, compilerOptions)) {
violations.push( violations.push(
`Web 禁止依赖 Server:${path.relative(repositoryRoot, sourcePath)} -> ${specifier}`, `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'); const generatedSchemaPath = path.join(webSourceRoot, 'api', 'generated', 'schema.ts');
@@ -140,3 +140,90 @@ test('接受只消费公开 OpenAPI 类型的 Web 工程', async () => {
assert.equal(result.exitCode, 0); assert.equal(result.exitCode, 0);
assert.equal(result.stderr, ''); assert.equal(result.stderr, '');
}); });
for (const [name, relativePath, source, expectedViolation] of [
[
'页面直接导入 Axios',
'web/src/views/MarketView.vue',
`<script setup lang="ts">
import axios from 'axios';
void axios.get('/api/v1/market');
</script>
<template><main /></template>
`,
'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, '');
});
+36
View File
@@ -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,
});
});
});
+13
View File
@@ -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<HealthResponseDto> {
return request<HealthResponseDto>({
url: '/api/v1/health',
method: 'get',
signal,
});
}