diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f673fb..a78b17d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,12 +102,12 @@ importers: web: dependencies: + axios: + specifier: 1.19.0 + version: 1.19.0 naive-ui: specifier: 2.44.1 version: 2.44.1(vue@3.5.41(typescript@5.9.3)) - openapi-fetch: - specifier: 0.17.0 - version: 0.17.0 pinia: specifier: 3.0.4 version: 3.0.4(typescript@5.9.3)(vue@3.5.41(typescript@5.9.3)) @@ -1315,6 +1315,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -1419,6 +1423,9 @@ packages: resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} engines: {node: '>= 6.0.0'} + axios@1.19.0: + resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1981,6 +1988,15 @@ packages: flatted@3.4.4: resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -2134,6 +2150,10 @@ packages: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -2547,12 +2567,6 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} - openapi-fetch@0.17.0: - resolution: {integrity: sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==} - - openapi-typescript-helpers@0.1.0: - resolution: {integrity: sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==} - openapi-typescript@7.13.0: resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==} hasBin: true @@ -2699,6 +2713,10 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -4646,6 +4664,12 @@ snapshots: acorn@8.18.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + agent-base@7.1.4: {} ajv-formats@2.1.1(ajv@8.20.0): @@ -4726,6 +4750,16 @@ snapshots: aws-ssl-profiles@1.1.2: {} + axios@1.19.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + balanced-match@1.0.2: {} balanced-match@4.0.4: {} @@ -5330,6 +5364,8 @@ snapshots: flatted@3.4.4: {} + follow-redirects@1.16.0: {} + for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -5505,6 +5541,13 @@ snapshots: transitivePeerDependencies: - supports-color + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + https-proxy-agent@7.0.6(supports-color@10.2.2): dependencies: agent-base: 7.1.4 @@ -5882,12 +5925,6 @@ snapshots: dependencies: mimic-fn: 2.1.0 - openapi-fetch@0.17.0: - dependencies: - openapi-typescript-helpers: 0.1.0 - - openapi-typescript-helpers@0.1.0: {} - openapi-typescript@7.13.0(typescript@5.9.3): dependencies: '@redocly/openapi-core': 1.34.19(supports-color@10.2.2) @@ -6027,6 +6064,8 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-from-env@2.1.0: {} + punycode@2.3.1: {} qs@6.15.3: diff --git a/web/package.json b/web/package.json index 9a935f7..aff2b64 100644 --- a/web/package.json +++ b/web/package.json @@ -14,8 +14,8 @@ "build": "vue-tsc --noEmit && vite build" }, "dependencies": { + "axios": "1.19.0", "naive-ui": "2.44.1", - "openapi-fetch": "0.17.0", "pinia": "3.0.4", "vue": "3.5.41", "vue-router": "4.6.4" diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts deleted file mode 100644 index cbc6d0f..0000000 --- a/web/src/api/client.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { ApiRequestError, createApiClient } from './client'; - -describe('OpenAPI 请求客户端', () => { - it('使用调用方提供的 API 根地址请求公开路径', async () => { - let requestedUrl = ''; - const client = createApiClient('http://127.0.0.1:3000', async (request) => { - requestedUrl = request.url; - return Response.json({ - 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' }, - }, - }); - }); - - await client.GET('/api/v1/health'); - - expect(requestedUrl).toBe('http://127.0.0.1:3000/api/v1/health'); - }); - - it('把网络错误归一化为不泄漏底层连接信息的错误', async () => { - const client = createApiClient('http://127.0.0.1:3000', async () => { - throw new Error('connect ECONNREFUSED mysql://root:secret@internal'); - }); - - await expect(client.GET('/api/v1/health')).rejects.toEqual( - expect.objectContaining({ - name: 'ApiRequestError', - message: 'API 请求失败,请稍后重试', - }), - ); - await expect(client.GET('/api/v1/health')).rejects.toBeInstanceOf(ApiRequestError); - }); -}); diff --git a/web/src/api/client.ts b/web/src/api/client.ts deleted file mode 100644 index bc7e4c2..0000000 --- a/web/src/api/client.ts +++ /dev/null @@ -1,30 +0,0 @@ -import createClient, { type Client } from 'openapi-fetch'; -import type { paths } from './generated/schema'; - -/** 统一的安全请求错误,不把浏览器或代理底层异常直接展示给用户。 */ -export class ApiRequestError extends Error { - constructor() { - super('API 请求失败,请稍后重试'); - this.name = 'ApiRequestError'; - } -} - -/** 创建受 OpenAPI paths 约束的客户端,并在边界统一清洗网络异常。 */ -export function createApiClient( - baseUrl: string, - fetchImplementation: (request: Request) => Promise = globalThis.fetch, -): Client { - const client = createClient({ - baseUrl: baseUrl.replace(/\/$/, ''), - fetch: fetchImplementation, - }); - - client.use({ - onError() { - // 浏览器错误可能带出内网主机、代理或底层连接细节,统一转换为安全的用户提示。 - return new ApiRequestError(); - }, - }); - - return client; -} diff --git a/web/src/utils/request.test.ts b/web/src/utils/request.test.ts new file mode 100644 index 0000000..0801dc3 --- /dev/null +++ b/web/src/utils/request.test.ts @@ -0,0 +1,145 @@ +import axios, { + AxiosError, + CanceledError, + type AxiosAdapter, + type AxiosRequestConfig, + type InternalAxiosRequestConfig, +} from 'axios'; +import { describe, expect, it } from 'vitest'; + +import type { components } from '../api/generated/schema'; +import { ApiRequestError, createRequestClient } from './request'; + +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' }, + }, +}; + +function createResponseAdapter( + inspectConfig?: (config: InternalAxiosRequestConfig) => void, +): AxiosAdapter { + return async (config) => { + inspectConfig?.(config); + return { + data: healthyDto, + status: 200, + statusText: 'OK', + headers: {}, + config, + }; + }; +} + +describe('统一 Axios 请求封装', () => { + it('使用规范化根地址和有限超时请求并只返回类型化 DTO', async () => { + let actualConfig: InternalAxiosRequestConfig | undefined; + const request = createRequestClient({ + baseURL: 'http://127.0.0.1:3000/', + timeoutMs: 10_000, + adapter: createResponseAdapter((config) => { + actualConfig = config; + }), + }); + + const result = await request({ + url: '/api/v1/health', + method: 'get', + }); + + expect(result).toEqual(healthyDto); + expect(actualConfig).toMatchObject({ + baseURL: 'http://127.0.0.1:3000', + timeout: 10_000, + url: '/api/v1/health', + method: 'get', + }); + }); + + it('把 HTTP 错误转换为只有分类和状态码的脱敏错误', async () => { + const request = createRequestClient({ + baseURL: 'http://127.0.0.1:3000', + adapter: async (config) => { + throw new AxiosError( + 'mysql://root:secret@internal', + 'ERR_BAD_RESPONSE', + config, + undefined, + { + data: { secret: 'Root@990819' }, + status: 503, + statusText: 'Unavailable', + headers: {}, + config, + }, + ); + }, + }); + + const error = await request({ url: '/api/v1/health' }).catch((cause: unknown) => cause); + + expect(error).toEqual( + expect.objectContaining({ + name: 'ApiRequestError', + message: 'API 请求失败,请稍后重试', + kind: 'http', + status: 503, + }), + ); + expect(error).toBeInstanceOf(ApiRequestError); + expect(JSON.stringify(error)).not.toMatch(/secret|internal|Root@990819/); + }); + + it('把没有响应的连接失败转换为网络错误且不保留原始地址', async () => { + const request = createRequestClient({ + baseURL: 'http://127.0.0.1:3000', + adapter: async (config) => { + throw new AxiosError( + 'connect ECONNREFUSED http://internal-api:3000', + 'ERR_NETWORK', + config, + ); + }, + }); + + const error = await request({ url: '/api/v1/health' }).catch((cause: unknown) => cause); + + expect(error).toEqual( + expect.objectContaining({ + name: 'ApiRequestError', + message: 'API 请求失败,请稍后重试', + kind: 'network', + }), + ); + expect(error).not.toHaveProperty('status'); + expect(JSON.stringify(error)).not.toMatch(/internal-api|ECONNREFUSED/); + }); + + it('保留取消请求语义而不错误映射为服务故障', async () => { + const controller = new AbortController(); + const adapter: AxiosAdapter = async (config) => { + throw new CanceledError('canceled', config); + }; + const request = createRequestClient({ + baseURL: '', + adapter, + }); + const config: AxiosRequestConfig = { + url: '/api/v1/health', + signal: controller.signal, + }; + controller.abort(); + + const error = await request(config).catch((cause: unknown) => cause); + + expect(axios.isCancel(error)).toBe(true); + expect(error).not.toBeInstanceOf(ApiRequestError); + }); +}); diff --git a/web/src/utils/request.ts b/web/src/utils/request.ts new file mode 100644 index 0000000..a872f99 --- /dev/null +++ b/web/src/utils/request.ts @@ -0,0 +1,68 @@ +import axios, { type AxiosAdapter, type AxiosRequestConfig } from 'axios'; + +export type RequestErrorKind = 'network' | 'http' | 'unknown'; + +/** + * 对功能层暴露稳定且脱敏的错误。原始 Axios 错误可能包含内网地址、请求头或 + * 上游正文,因此不得通过 cause、config、request 或 response 继续向外传播。 + */ +export class ApiRequestError extends Error { + readonly kind: RequestErrorKind; + declare readonly status?: number; + + constructor(kind: RequestErrorKind, status?: number) { + super('API 请求失败,请稍后重试'); + this.name = 'ApiRequestError'; + this.kind = kind; + if (status !== undefined) this.status = status; + } +} + +export interface RequestClientOptions { + baseURL: string; + timeoutMs?: number; + adapter?: AxiosAdapter; +} + +export type RequestClient = (config: AxiosRequestConfig) => Promise; + +function toApiRequestError(error: unknown): ApiRequestError { + if (!axios.isAxiosError(error)) return new ApiRequestError('unknown'); + if (error.response) return new ApiRequestError('http', error.response.status); + return new ApiRequestError('network'); +} + +/** + * 创建唯一请求边界并只返回 DTO 数据。显式包装 `client.request` 可以避免 + * `AxiosResponse` 类型渗透到业务 API,同时保留 adapter 注入以测试真实 Axios 流程。 + */ +export function createRequestClient({ + baseURL, + timeoutMs = 10_000, + adapter, +}: RequestClientOptions): RequestClient { + const client = axios.create({ + baseURL: baseURL.replace(/\/$/, ''), + timeout: timeoutMs, + adapter, + }); + + // 认证能力接入后只扩展这个拦截器,当前不提前实现 Token 或刷新流程。 + client.interceptors.request.use((config) => config); + + return async (config: AxiosRequestConfig): Promise => { + try { + const response = await client.request(config); + return response.data; + } catch (error) { + if (axios.isCancel(error)) throw error; + throw toApiRequestError(error); + } + }; +} + +const request = createRequestClient({ + baseURL: import.meta.env.VITE_API_BASE_URL || '', +}); + +export default request; diff --git a/web/src/vite-config.test.ts b/web/src/vite-config.test.ts index 0c2999e..295b5b5 100644 --- a/web/src/vite-config.test.ts +++ b/web/src/vite-config.test.ts @@ -5,7 +5,10 @@ import { describe, expect, it } from 'vitest'; import viteConfig from '../vite.config'; describe('Vite 本地 API 代理', () => { - it('通过同源 /api 转发到本地 NestJS,避免浏览器依赖跨域配置', () => { + it('统一解析源码别名并通过同源 /api 转发到本地 NestJS', () => { + expect(viteConfig.resolve?.alias).toMatchObject({ + '@': expect.stringMatching(/web\/src$/), + }); expect(viteConfig.server?.proxy).toMatchObject({ '/api': { target: 'http://127.0.0.1:3000', diff --git a/web/tsconfig.json b/web/tsconfig.json index e82f087..ceb6287 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -9,6 +9,10 @@ "resolveJsonModule": true, "isolatedModules": true, "esModuleInterop": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + }, "lib": ["ES2022", "DOM", "DOM.Iterable"], "types": ["vitest/globals"] }, diff --git a/web/vite.config.ts b/web/vite.config.ts index 58d5ba3..5b314cd 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -27,6 +27,11 @@ const publicEnv = readDevelopmentPublicEnv(); export default defineConfig({ plugins: [vue()], + resolve: { + alias: { + '@': path.resolve(webDirectory, 'src'), + }, + }, define: Object.fromEntries( Object.entries(publicEnv).map(([key, value]) => [ `import.meta.env.${key}`, diff --git a/web/vitest.config.ts b/web/vitest.config.ts index ce17acb..cf881f3 100644 --- a/web/vitest.config.ts +++ b/web/vitest.config.ts @@ -1,8 +1,15 @@ +import { fileURLToPath } from 'node:url'; + import vue from '@vitejs/plugin-vue'; import { defineConfig } from 'vitest/config'; export default defineConfig({ plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, test: { environment: 'jsdom', include: ['src/**/*.test.ts'],