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 | 11 | /** 网络 / 超时 / 5xx 无可用业务码时的统一标识 */ |
| 12 | 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 | 28 | /** 后端统一响应体 Result<T>(docs/04 § 1.4) */ |
| 15 | 29 | export interface Result<T = unknown> { |
| 16 | 30 | code: number; |
| ... | ... | @@ -61,6 +75,10 @@ request.interceptors.response.use( |
| 61 | 75 | if (error instanceof ApiError) { |
| 62 | 76 | return Promise.reject(error); |
| 63 | 77 | } |
| 78 | + // 被动 401:触发统一登出回调(若已注册),再走原 ApiError 映射(BR10 / D11) | |
| 79 | + if (error.response?.status === HTTP_UNAUTHORIZED && onUnauthorized) { | |
| 80 | + onUnauthorized(); | |
| 81 | + } | |
| 64 | 82 | const body = error.response?.data as Result | undefined; |
| 65 | 83 | if (body && typeof body === 'object' && 'code' in body) { |
| 66 | 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 | +}); | ... | ... |