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, + }); +}