Commit 9e022006a38d0f88c1623b37c3bbacfdf1859574
1 parent
2dce637a
feat(fe-login): Axios 实例与 Result 拆包/错误拦截 REQ-USR-004
Showing
4 changed files
with
173 additions
and
0 deletions
frontend/package-lock.json
| ... | ... | @@ -28,6 +28,7 @@ |
| 28 | 28 | "@typescript-eslint/eslint-plugin": "^8.18.2", |
| 29 | 29 | "@typescript-eslint/parser": "^8.18.2", |
| 30 | 30 | "@vitejs/plugin-react": "^4.3.4", |
| 31 | + "axios-mock-adapter": "^2.1.0", | |
| 31 | 32 | "eslint": "^8.57.1", |
| 32 | 33 | "eslint-plugin-react-hooks": "^4.6.2", |
| 33 | 34 | "eslint-plugin-react-refresh": "^0.4.16", |
| ... | ... | @@ -2685,6 +2686,20 @@ |
| 2685 | 2686 | "proxy-from-env": "^2.1.0" |
| 2686 | 2687 | } |
| 2687 | 2688 | }, |
| 2689 | + "node_modules/axios-mock-adapter": { | |
| 2690 | + "version": "2.1.0", | |
| 2691 | + "resolved": "https://registry.npmjs.org/axios-mock-adapter/-/axios-mock-adapter-2.1.0.tgz", | |
| 2692 | + "integrity": "sha512-AZUe4OjECGCNNssH8SOdtneiQELsqTsat3SQQCWLPjN436/H+L9AjWfV7bF+Zg/YL9cgbhrz5671hoh+Tbn98w==", | |
| 2693 | + "dev": true, | |
| 2694 | + "license": "MIT", | |
| 2695 | + "dependencies": { | |
| 2696 | + "fast-deep-equal": "^3.1.3", | |
| 2697 | + "is-buffer": "^2.0.5" | |
| 2698 | + }, | |
| 2699 | + "peerDependencies": { | |
| 2700 | + "axios": ">= 0.17.0" | |
| 2701 | + } | |
| 2702 | + }, | |
| 2688 | 2703 | "node_modules/balanced-match": { |
| 2689 | 2704 | "version": "4.0.4", |
| 2690 | 2705 | "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", |
| ... | ... | @@ -3960,6 +3975,30 @@ |
| 3960 | 3975 | "dev": true, |
| 3961 | 3976 | "license": "ISC" |
| 3962 | 3977 | }, |
| 3978 | + "node_modules/is-buffer": { | |
| 3979 | + "version": "2.0.5", | |
| 3980 | + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", | |
| 3981 | + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", | |
| 3982 | + "dev": true, | |
| 3983 | + "funding": [ | |
| 3984 | + { | |
| 3985 | + "type": "github", | |
| 3986 | + "url": "https://github.com/sponsors/feross" | |
| 3987 | + }, | |
| 3988 | + { | |
| 3989 | + "type": "patreon", | |
| 3990 | + "url": "https://www.patreon.com/feross" | |
| 3991 | + }, | |
| 3992 | + { | |
| 3993 | + "type": "consulting", | |
| 3994 | + "url": "https://feross.org/support" | |
| 3995 | + } | |
| 3996 | + ], | |
| 3997 | + "license": "MIT", | |
| 3998 | + "engines": { | |
| 3999 | + "node": ">=4" | |
| 4000 | + } | |
| 4001 | + }, | |
| 3963 | 4002 | "node_modules/is-extglob": { |
| 3964 | 4003 | "version": "2.1.1", |
| 3965 | 4004 | "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", | ... | ... |
frontend/package.json
| ... | ... | @@ -31,6 +31,7 @@ |
| 31 | 31 | "@typescript-eslint/eslint-plugin": "^8.18.2", |
| 32 | 32 | "@typescript-eslint/parser": "^8.18.2", |
| 33 | 33 | "@vitejs/plugin-react": "^4.3.4", |
| 34 | + "axios-mock-adapter": "^2.1.0", | |
| 34 | 35 | "eslint": "^8.57.1", |
| 35 | 36 | "eslint-plugin-react-hooks": "^4.6.2", |
| 36 | 37 | "eslint-plugin-react-refresh": "^0.4.16", | ... | ... |
frontend/src/api/request.ts
0 → 100644
| 1 | +// REQ-USR-004: 统一 Axios 实例 + Result 拆包 + 错误拦截(docs/04 § 2.3 / § 2.4) | |
| 2 | +import axios, { | |
| 3 | + AxiosError, | |
| 4 | + type AxiosInstance, | |
| 5 | + type InternalAxiosRequestConfig, | |
| 6 | +} from 'axios'; | |
| 7 | + | |
| 8 | +/** token 持久化键名(D6,跨 task 引用此常量,不写字面量) */ | |
| 9 | +export const TOKEN_STORAGE_KEY = 'xly_erp_token'; | |
| 10 | + | |
| 11 | +/** 网络 / 超时 / 5xx 无可用业务码时的统一标识 */ | |
| 12 | +export const NETWORK_ERROR_CODE = -1; | |
| 13 | + | |
| 14 | +/** 后端统一响应体 Result<T>(docs/04 § 1.4) */ | |
| 15 | +export interface Result<T = unknown> { | |
| 16 | + code: number; | |
| 17 | + message: string; | |
| 18 | + data: T; | |
| 19 | +} | |
| 20 | + | |
| 21 | +/** 业务 / 网络错误,携带后端业务码供页面分流文案 */ | |
| 22 | +export class ApiError extends Error { | |
| 23 | + code: number; | |
| 24 | + constructor(code: number, message: string) { | |
| 25 | + super(message); | |
| 26 | + this.name = 'ApiError'; | |
| 27 | + this.code = code; | |
| 28 | + } | |
| 29 | +} | |
| 30 | + | |
| 31 | +const request: AxiosInstance = axios.create({ | |
| 32 | + baseURL: '/api', | |
| 33 | + timeout: 15000, | |
| 34 | + headers: { 'Content-Type': 'application/json' }, | |
| 35 | +}); | |
| 36 | + | |
| 37 | +// 请求拦截器:已登录态注入 Authorization;登录 / 版本端点无 token 时自然跳过 | |
| 38 | +request.interceptors.request.use((config: InternalAxiosRequestConfig) => { | |
| 39 | + const token = localStorage.getItem(TOKEN_STORAGE_KEY); | |
| 40 | + if (token) { | |
| 41 | + config.headers.set('Authorization', `Bearer ${token}`); | |
| 42 | + } | |
| 43 | + return config; | |
| 44 | +}); | |
| 45 | + | |
| 46 | +// 响应拦截器:拆 Result。code=0 取 data;非 0 抛 ApiError;无响应(网络/超时/5xx)兜底 | |
| 47 | +request.interceptors.response.use( | |
| 48 | + (response) => { | |
| 49 | + const body = response.data as Result; | |
| 50 | + if (body && typeof body === 'object' && 'code' in body) { | |
| 51 | + if (body.code === 0) { | |
| 52 | + return body.data; | |
| 53 | + } | |
| 54 | + return Promise.reject(new ApiError(body.code, body.message || '请求失败')); | |
| 55 | + } | |
| 56 | + // 非 Result 结构(理论上不应出现),原样返回 data | |
| 57 | + return response.data; | |
| 58 | + }, | |
| 59 | + (error: AxiosError) => { | |
| 60 | + // 已是 ApiError 直接透传(极少数情况) | |
| 61 | + if (error instanceof ApiError) { | |
| 62 | + return Promise.reject(error); | |
| 63 | + } | |
| 64 | + const body = error.response?.data as Result | undefined; | |
| 65 | + if (body && typeof body === 'object' && 'code' in body) { | |
| 66 | + return Promise.reject(new ApiError(body.code, body.message || '请求失败')); | |
| 67 | + } | |
| 68 | + // 无响应:网络异常 / 超时 / 5xx 无 Result 体 | |
| 69 | + return Promise.reject(new ApiError(NETWORK_ERROR_CODE, '网络异常,请稍后重试')); | |
| 70 | + }, | |
| 71 | +); | |
| 72 | + | |
| 73 | +export default request; | ... | ... |
frontend/tests/unit/request.test.ts
0 → 100644
| 1 | +import { describe, it, expect, beforeEach } from 'vitest'; | |
| 2 | +import MockAdapter from 'axios-mock-adapter'; | |
| 3 | +import request, { ApiError, TOKEN_STORAGE_KEY, NETWORK_ERROR_CODE } from '../../src/api/request'; | |
| 4 | + | |
| 5 | +describe('request (Axios 实例)', () => { | |
| 6 | + let mock: MockAdapter; | |
| 7 | + | |
| 8 | + beforeEach(() => { | |
| 9 | + mock = new MockAdapter(request); | |
| 10 | + localStorage.clear(); | |
| 11 | + }); | |
| 12 | + | |
| 13 | + it('baseURL is /api', () => { | |
| 14 | + expect(request.defaults.baseURL).toBe('/api'); | |
| 15 | + }); | |
| 16 | + | |
| 17 | + it('unwraps data when code is 0', async () => { | |
| 18 | + mock.onGet('/ping').reply(200, { code: 0, message: 'success', data: { foo: 1 } }); | |
| 19 | + const data = await request.get('/ping'); | |
| 20 | + expect(data).toEqual({ foo: 1 }); | |
| 21 | + }); | |
| 22 | + | |
| 23 | + it('throws ApiError carrying business code when code is non-zero', async () => { | |
| 24 | + mock.onPost('/usr/login').reply(200, { code: 40101, message: '认证失败', data: null }); | |
| 25 | + await expect(request.post('/usr/login', {})).rejects.toMatchObject({ | |
| 26 | + name: 'ApiError', | |
| 27 | + code: 40101, | |
| 28 | + }); | |
| 29 | + await expect(request.post('/usr/login', {})).rejects.toBeInstanceOf(ApiError); | |
| 30 | + }); | |
| 31 | + | |
| 32 | + it('throws network ApiError on no-response error', async () => { | |
| 33 | + mock.onGet('/usr/companies').networkError(); | |
| 34 | + await expect(request.get('/usr/companies')).rejects.toMatchObject({ | |
| 35 | + name: 'ApiError', | |
| 36 | + code: NETWORK_ERROR_CODE, | |
| 37 | + }); | |
| 38 | + }); | |
| 39 | + | |
| 40 | + it('injects Authorization header when token present', async () => { | |
| 41 | + localStorage.setItem(TOKEN_STORAGE_KEY, 'tk-123'); | |
| 42 | + let seenAuth: string | undefined; | |
| 43 | + mock.onGet('/usr/users').reply((config) => { | |
| 44 | + seenAuth = config.headers?.Authorization as string | undefined; | |
| 45 | + return [200, { code: 0, message: 'success', data: [] }]; | |
| 46 | + }); | |
| 47 | + await request.get('/usr/users'); | |
| 48 | + expect(seenAuth).toBe('Bearer tk-123'); | |
| 49 | + }); | |
| 50 | + | |
| 51 | + it('does not inject Authorization header when no token', async () => { | |
| 52 | + let seenAuth: string | undefined; | |
| 53 | + mock.onGet('/usr/companies').reply((config) => { | |
| 54 | + seenAuth = config.headers?.Authorization as string | undefined; | |
| 55 | + return [200, { code: 0, message: 'success', data: [] }]; | |
| 56 | + }); | |
| 57 | + await request.get('/usr/companies'); | |
| 58 | + expect(seenAuth).toBeUndefined(); | |
| 59 | + }); | |
| 60 | +}); | ... | ... |