refactor(GOV-010): 统一封装前端 Axios 请求

This commit is contained in:
2026-08-13 23:02:36 +08:00
parent 3a22fdeef3
commit 4105336cb2
10 changed files with 288 additions and 86 deletions
+54 -15
View File
@@ -102,12 +102,12 @@ importers:
web: web:
dependencies: dependencies:
axios:
specifier: 1.19.0
version: 1.19.0
naive-ui: naive-ui:
specifier: 2.44.1 specifier: 2.44.1
version: 2.44.1(vue@3.5.41(typescript@5.9.3)) version: 2.44.1(vue@3.5.41(typescript@5.9.3))
openapi-fetch:
specifier: 0.17.0
version: 0.17.0
pinia: pinia:
specifier: 3.0.4 specifier: 3.0.4
version: 3.0.4(typescript@5.9.3)(vue@3.5.41(typescript@5.9.3)) 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'} engines: {node: '>=0.4.0'}
hasBin: true hasBin: true
agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
agent-base@7.1.4: agent-base@7.1.4:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'} engines: {node: '>= 14'}
@@ -1419,6 +1423,9 @@ packages:
resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==}
engines: {node: '>= 6.0.0'} engines: {node: '>= 6.0.0'}
axios@1.19.0:
resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==}
balanced-match@1.0.2: balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -1981,6 +1988,15 @@ packages:
flatted@3.4.4: flatted@3.4.4:
resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} 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: for-each@0.3.5:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2134,6 +2150,10 @@ packages:
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
engines: {node: '>= 14'} 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: https-proxy-agent@7.0.6:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'} engines: {node: '>= 14'}
@@ -2547,12 +2567,6 @@ packages:
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
engines: {node: '>=6'} 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: openapi-typescript@7.13.0:
resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==} resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==}
hasBin: true hasBin: true
@@ -2699,6 +2713,10 @@ packages:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'} 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: punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -4646,6 +4664,12 @@ snapshots:
acorn@8.18.0: {} 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: {} agent-base@7.1.4: {}
ajv-formats@2.1.1(ajv@8.20.0): ajv-formats@2.1.1(ajv@8.20.0):
@@ -4726,6 +4750,16 @@ snapshots:
aws-ssl-profiles@1.1.2: {} 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@1.0.2: {}
balanced-match@4.0.4: {} balanced-match@4.0.4: {}
@@ -5330,6 +5364,8 @@ snapshots:
flatted@3.4.4: {} flatted@3.4.4: {}
follow-redirects@1.16.0: {}
for-each@0.3.5: for-each@0.3.5:
dependencies: dependencies:
is-callable: 1.2.7 is-callable: 1.2.7
@@ -5505,6 +5541,13 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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): https-proxy-agent@7.0.6(supports-color@10.2.2):
dependencies: dependencies:
agent-base: 7.1.4 agent-base: 7.1.4
@@ -5882,12 +5925,6 @@ snapshots:
dependencies: dependencies:
mimic-fn: 2.1.0 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): openapi-typescript@7.13.0(typescript@5.9.3):
dependencies: dependencies:
'@redocly/openapi-core': 1.34.19(supports-color@10.2.2) '@redocly/openapi-core': 1.34.19(supports-color@10.2.2)
@@ -6027,6 +6064,8 @@ snapshots:
forwarded: 0.2.0 forwarded: 0.2.0
ipaddr.js: 1.9.1 ipaddr.js: 1.9.1
proxy-from-env@2.1.0: {}
punycode@2.3.1: {} punycode@2.3.1: {}
qs@6.15.3: qs@6.15.3:
+1 -1
View File
@@ -14,8 +14,8 @@
"build": "vue-tsc --noEmit && vite build" "build": "vue-tsc --noEmit && vite build"
}, },
"dependencies": { "dependencies": {
"axios": "1.19.0",
"naive-ui": "2.44.1", "naive-ui": "2.44.1",
"openapi-fetch": "0.17.0",
"pinia": "3.0.4", "pinia": "3.0.4",
"vue": "3.5.41", "vue": "3.5.41",
"vue-router": "4.6.4" "vue-router": "4.6.4"
-39
View File
@@ -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);
});
});
-30
View File
@@ -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<Response> = globalThis.fetch,
): Client<paths> {
const client = createClient<paths>({
baseUrl: baseUrl.replace(/\/$/, ''),
fetch: fetchImplementation,
});
client.use({
onError() {
// 浏览器错误可能带出内网主机、代理或底层连接细节,统一转换为安全的用户提示。
return new ApiRequestError();
},
});
return client;
}
+145
View File
@@ -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<HealthResponseDto>({
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);
});
});
+68
View File
@@ -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 = <TData>(config: AxiosRequestConfig) => Promise<TData>;
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 <TData>(config: AxiosRequestConfig): Promise<TData> => {
try {
const response = await client.request<TData>(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;
+4 -1
View File
@@ -5,7 +5,10 @@ import { describe, expect, it } from 'vitest';
import viteConfig from '../vite.config'; import viteConfig from '../vite.config';
describe('Vite 本地 API 代理', () => { describe('Vite 本地 API 代理', () => {
it('通过同源 /api 转发到本地 NestJS,避免浏览器依赖跨域配置', () => { it('统一解析源码别名并通过同源 /api 转发到本地 NestJS', () => {
expect(viteConfig.resolve?.alias).toMatchObject({
'@': expect.stringMatching(/web\/src$/),
});
expect(viteConfig.server?.proxy).toMatchObject({ expect(viteConfig.server?.proxy).toMatchObject({
'/api': { '/api': {
target: 'http://127.0.0.1:3000', target: 'http://127.0.0.1:3000',
+4
View File
@@ -9,6 +9,10 @@
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"esModuleInterop": true, "esModuleInterop": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"lib": ["ES2022", "DOM", "DOM.Iterable"], "lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vitest/globals"] "types": ["vitest/globals"]
}, },
+5
View File
@@ -27,6 +27,11 @@ const publicEnv = readDevelopmentPublicEnv();
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(webDirectory, 'src'),
},
},
define: Object.fromEntries( define: Object.fromEntries(
Object.entries(publicEnv).map(([key, value]) => [ Object.entries(publicEnv).map(([key, value]) => [
`import.meta.env.${key}`, `import.meta.env.${key}`,
+7
View File
@@ -1,8 +1,15 @@
import { fileURLToPath } from 'node:url';
import vue from '@vitejs/plugin-vue'; import vue from '@vitejs/plugin-vue';
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
test: { test: {
environment: 'jsdom', environment: 'jsdom',
include: ['src/**/*.test.ts'], include: ['src/**/*.test.ts'],