From af6c72ec5c5a76578f2f3861496f7275f3a27b0a Mon Sep 17 00:00:00 2001 From: zichun Date: Mon, 1 Jun 2026 17:10:39 +0800 Subject: [PATCH] feat(fe-shell): 401 统一登出回调接入 request 拦截器 REQ-USR-004 --- frontend/src/api/request.ts | 18 ++++++++++++++++++ frontend/tests/unit/request.unauthorized.test.ts | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 0 deletions(-) create mode 100644 frontend/tests/unit/request.unauthorized.test.ts diff --git a/frontend/src/api/request.ts b/frontend/src/api/request.ts index 57635ea..af2039d 100644 --- a/frontend/src/api/request.ts +++ b/frontend/src/api/request.ts @@ -11,6 +11,20 @@ export const TOKEN_STORAGE_KEY = 'xly_erp_token'; /** 网络 / 超时 / 5xx 无可用业务码时的统一标识 */ export const NETWORK_ERROR_CODE = -1; +/** HTTP 未授权状态码(BR10 / D11,被动登录失效统一登出) */ +export const HTTP_UNAUTHORIZED = 401; + +/** + * 401 统一登出回调单例(D11)。拦截器内无法使用 React hooks(useNavigate/message), + * 故由外壳挂载时通过 registerUnauthorizedHandler 注册回调,拦截器捕获 HTTP 401 时调用之。 + */ +let onUnauthorized: (() => void) | null = null; + +/** 注册(或传 null 清除)被动 401 统一登出回调 */ +export function registerUnauthorizedHandler(fn: (() => void) | null): void { + onUnauthorized = fn; +} + /** 后端统一响应体 Result(docs/04 § 1.4) */ export interface Result { code: number; @@ -61,6 +75,10 @@ request.interceptors.response.use( if (error instanceof ApiError) { return Promise.reject(error); } + // 被动 401:触发统一登出回调(若已注册),再走原 ApiError 映射(BR10 / D11) + if (error.response?.status === HTTP_UNAUTHORIZED && onUnauthorized) { + onUnauthorized(); + } const body = error.response?.data as Result | undefined; if (body && typeof body === 'object' && 'code' in body) { return Promise.reject(new ApiError(body.code, body.message || '请求失败')); diff --git a/frontend/tests/unit/request.unauthorized.test.ts b/frontend/tests/unit/request.unauthorized.test.ts new file mode 100644 index 0000000..e368d63 --- /dev/null +++ b/frontend/tests/unit/request.unauthorized.test.ts @@ -0,0 +1,40 @@ +// REQ-USR-004: request.ts 401 统一登出回调(BR10 / D11) +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import MockAdapter from 'axios-mock-adapter'; +import request, { + ApiError, + registerUnauthorizedHandler, + HTTP_UNAUTHORIZED, +} from '../../src/api/request'; + +describe('request 401 统一登出回调', () => { + let mock: MockAdapter; + + beforeEach(() => { + mock = new MockAdapter(request); + }); + + afterEach(() => { + mock.restore(); + // 清理已注册回调,避免跨用例污染 + registerUnauthorizedHandler(null); + }); + + it('exposes HTTP_UNAUTHORIZED constant = 401', () => { + expect(HTTP_UNAUTHORIZED).toBe(401); + }); + + it('HTTP 401 triggers registered onUnauthorized then rejects ApiError', async () => { + const spy = vi.fn(); + registerUnauthorizedHandler(spy); + mock.onGet('/usr/users').reply(401, { code: 40100, message: '未授权', data: null }); + await expect(request.get('/usr/users')).rejects.toBeInstanceOf(ApiError); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('no handler registered does not throw extra error', async () => { + registerUnauthorizedHandler(null); + mock.onGet('/usr/users').reply(401, { code: 40100, message: '未授权', data: null }); + await expect(request.get('/usr/users')).rejects.toBeInstanceOf(ApiError); + }); +}); -- libgit2 0.22.2