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
+1 -1
View File
@@ -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"
-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';
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',
+4
View File
@@ -9,6 +9,10 @@
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vitest/globals"]
},
+5
View File
@@ -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}`,
+7
View File
@@ -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'],