Commit af6c72ec5c5a76578f2f3861496f7275f3a27b0a
1 parent
c897b60d
feat(fe-shell): 401 统一登出回调接入 request 拦截器 REQ-USR-004
Showing
2 changed files
with
58 additions
and
0 deletions
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 | +}); |