Commit af6c72ec5c5a76578f2f3861496f7275f3a27b0a

Authored by zichun
1 parent c897b60d

feat(fe-shell): 401 统一登出回调接入 request 拦截器 REQ-USR-004

frontend/src/api/request.ts
@@ -11,6 +11,20 @@ export const TOKEN_STORAGE_KEY = 'xly_erp_token'; @@ -11,6 +11,20 @@ export const TOKEN_STORAGE_KEY = 'xly_erp_token';
11 /** 网络 / 超时 / 5xx 无可用业务码时的统一标识 */ 11 /** 网络 / 超时 / 5xx 无可用业务码时的统一标识 */
12 export const NETWORK_ERROR_CODE = -1; 12 export const NETWORK_ERROR_CODE = -1;
13 13
  14 +/** HTTP 未授权状态码(BR10 / D11,被动登录失效统一登出) */
  15 +export const HTTP_UNAUTHORIZED = 401;
  16 +
  17 +/**
  18 + * 401 统一登出回调单例(D11)。拦截器内无法使用 React hooks(useNavigate/message),
  19 + * 故由外壳挂载时通过 registerUnauthorizedHandler 注册回调,拦截器捕获 HTTP 401 时调用之。
  20 + */
  21 +let onUnauthorized: (() => void) | null = null;
  22 +
  23 +/** 注册(或传 null 清除)被动 401 统一登出回调 */
  24 +export function registerUnauthorizedHandler(fn: (() => void) | null): void {
  25 + onUnauthorized = fn;
  26 +}
  27 +
14 /** 后端统一响应体 Result<T>(docs/04 § 1.4) */ 28 /** 后端统一响应体 Result<T>(docs/04 § 1.4) */
15 export interface Result<T = unknown> { 29 export interface Result<T = unknown> {
16 code: number; 30 code: number;
@@ -61,6 +75,10 @@ request.interceptors.response.use( @@ -61,6 +75,10 @@ request.interceptors.response.use(
61 if (error instanceof ApiError) { 75 if (error instanceof ApiError) {
62 return Promise.reject(error); 76 return Promise.reject(error);
63 } 77 }
  78 + // 被动 401:触发统一登出回调(若已注册),再走原 ApiError 映射(BR10 / D11)
  79 + if (error.response?.status === HTTP_UNAUTHORIZED && onUnauthorized) {
  80 + onUnauthorized();
  81 + }
64 const body = error.response?.data as Result | undefined; 82 const body = error.response?.data as Result | undefined;
65 if (body && typeof body === 'object' && 'code' in body) { 83 if (body && typeof body === 'object' && 'code' in body) {
66 return Promise.reject(new ApiError(body.code, body.message || '请求失败')); 84 return Promise.reject(new ApiError(body.code, body.message || '请求失败'));
frontend/tests/unit/request.unauthorized.test.ts 0 → 100644
  1 +// REQ-USR-004: request.ts 401 统一登出回调(BR10 / D11)
  2 +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
  3 +import MockAdapter from 'axios-mock-adapter';
  4 +import request, {
  5 + ApiError,
  6 + registerUnauthorizedHandler,
  7 + HTTP_UNAUTHORIZED,
  8 +} from '../../src/api/request';
  9 +
  10 +describe('request 401 统一登出回调', () => {
  11 + let mock: MockAdapter;
  12 +
  13 + beforeEach(() => {
  14 + mock = new MockAdapter(request);
  15 + });
  16 +
  17 + afterEach(() => {
  18 + mock.restore();
  19 + // 清理已注册回调,避免跨用例污染
  20 + registerUnauthorizedHandler(null);
  21 + });
  22 +
  23 + it('exposes HTTP_UNAUTHORIZED constant = 401', () => {
  24 + expect(HTTP_UNAUTHORIZED).toBe(401);
  25 + });
  26 +
  27 + it('HTTP 401 triggers registered onUnauthorized then rejects ApiError', async () => {
  28 + const spy = vi.fn();
  29 + registerUnauthorizedHandler(spy);
  30 + mock.onGet('/usr/users').reply(401, { code: 40100, message: '未授权', data: null });
  31 + await expect(request.get('/usr/users')).rejects.toBeInstanceOf(ApiError);
  32 + expect(spy).toHaveBeenCalledTimes(1);
  33 + });
  34 +
  35 + it('no handler registered does not throw extra error', async () => {
  36 + registerUnauthorizedHandler(null);
  37 + mock.onGet('/usr/users').reply(401, { code: 40100, message: '未授权', data: null });
  38 + await expect(request.get('/usr/users')).rejects.toBeInstanceOf(ApiError);
  39 + });
  40 +});