Commit 9e022006a38d0f88c1623b37c3bbacfdf1859574

Authored by zichun
1 parent 2dce637a

feat(fe-login): Axios 实例与 Result 拆包/错误拦截 REQ-USR-004

frontend/package-lock.json
@@ -28,6 +28,7 @@ @@ -28,6 +28,7 @@
28 "@typescript-eslint/eslint-plugin": "^8.18.2", 28 "@typescript-eslint/eslint-plugin": "^8.18.2",
29 "@typescript-eslint/parser": "^8.18.2", 29 "@typescript-eslint/parser": "^8.18.2",
30 "@vitejs/plugin-react": "^4.3.4", 30 "@vitejs/plugin-react": "^4.3.4",
  31 + "axios-mock-adapter": "^2.1.0",
31 "eslint": "^8.57.1", 32 "eslint": "^8.57.1",
32 "eslint-plugin-react-hooks": "^4.6.2", 33 "eslint-plugin-react-hooks": "^4.6.2",
33 "eslint-plugin-react-refresh": "^0.4.16", 34 "eslint-plugin-react-refresh": "^0.4.16",
@@ -2685,6 +2686,20 @@ @@ -2685,6 +2686,20 @@
2685 "proxy-from-env": "^2.1.0" 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 "node_modules/balanced-match": { 2703 "node_modules/balanced-match": {
2689 "version": "4.0.4", 2704 "version": "4.0.4",
2690 "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", 2705 "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@@ -3960,6 +3975,30 @@ @@ -3960,6 +3975,30 @@
3960 "dev": true, 3975 "dev": true,
3961 "license": "ISC" 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 "node_modules/is-extglob": { 4002 "node_modules/is-extglob": {
3964 "version": "2.1.1", 4003 "version": "2.1.1",
3965 "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", 4004 "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
frontend/package.json
@@ -31,6 +31,7 @@ @@ -31,6 +31,7 @@
31 "@typescript-eslint/eslint-plugin": "^8.18.2", 31 "@typescript-eslint/eslint-plugin": "^8.18.2",
32 "@typescript-eslint/parser": "^8.18.2", 32 "@typescript-eslint/parser": "^8.18.2",
33 "@vitejs/plugin-react": "^4.3.4", 33 "@vitejs/plugin-react": "^4.3.4",
  34 + "axios-mock-adapter": "^2.1.0",
34 "eslint": "^8.57.1", 35 "eslint": "^8.57.1",
35 "eslint-plugin-react-hooks": "^4.6.2", 36 "eslint-plugin-react-hooks": "^4.6.2",
36 "eslint-plugin-react-refresh": "^0.4.16", 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 +});