From 884a7b8343ad3a50fa068411b7d3073ddf80aeac Mon Sep 17 00:00:00 2001 From: ozh Date: Tue, 15 Sep 2026 11:47:18 +0800 Subject: [PATCH] =?UTF-8?q?fix(http):=20401=20=E8=B8=A2=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E9=98=B2=E9=99=88=E6=97=A7=E4=BC=9A=E8=AF=9D=E8=AF=AF=E6=B8=85?= =?UTF-8?q?=E4=B8=8E=E8=B7=B3=E8=BD=AC=E5=A4=B1=E8=B4=A5=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/http/client.ts | 25 +++++++++++++++-------- tests/unit/http/client.spec.ts | 36 ++++++++++++++++++++++------------ 2 files changed, 41 insertions(+), 20 deletions(-) diff --git a/src/http/client.ts b/src/http/client.ts index 015b98b..0e69f56 100644 --- a/src/http/client.ts +++ b/src/http/client.ts @@ -16,13 +16,21 @@ const UNAUTHORIZED_CODES = new Set(['401']) // 防重入:跳转登录页进行中不重复触发(并发 401 只踢一次) let redirectingToLogin = false -function redirectToLogin(): void { +function redirectToLogin(tokenAtStart: string | null): void { if (redirectingToLogin) return + // 仅当发起请求时的 token 仍是当前登录态才踢,避免在途旧请求 401 误清重新登录后的新会话 + // (token 为 null 的未登录请求 401 时 null === null 成立会踢,属合理行为) + if (store.getState().auth.token !== tokenAtStart) return redirectingToLogin = true store.dispatch(clearAuth()) - reLaunch(ROUTES.LOGIN).finally(() => { - redirectingToLogin = false - }) + reLaunch(ROUTES.LOGIN) + .catch((e: unknown) => { + // 跳转失败显式记录,避免 unhandled rejection 噪音(debug-first:不静默吞) + console.error('[http] 踢回登录页失败', e) + }) + .finally(() => { + redirectingToLogin = false + }) } export interface RequestConfig { @@ -49,6 +57,8 @@ export async function request( ): Promise { const method = config.method ?? 'GET' const timeout = config.timeout ?? 10000 + // 请求发起时刻的 token 快照:headers 注入与 401 陈旧竞态判断共用同一份 + const tokenAtStart = store.getState().auth.token // token 注入:从 authSlice 读取 const headers: Record = { @@ -56,8 +66,7 @@ export async function request( ...config.headers } if (config.needAuth !== false) { - const token = store.getState().auth.token - if (token) headers.Authorization = `Bearer ${token}` + if (tokenAtStart) headers.Authorization = `Bearer ${tokenAtStart}` } let res: Taro.request.SuccessCallbackResult> @@ -77,7 +86,7 @@ export async function request( if (res.statusCode !== 200) { if (res.statusCode === 401) { - redirectToLogin() + redirectToLogin(tokenAtStart) throw new AuthExpiredError('登录已过期') } throw new ApiError(`HTTP ${res.statusCode}`, res.statusCode) @@ -91,7 +100,7 @@ export async function request( const bizCode = String(body.Code) if (UNAUTHORIZED_CODES.has(bizCode)) { - redirectToLogin() + redirectToLogin(tokenAtStart) throw new AuthExpiredError(body.Message || '登录已过期') } if (bizCode !== '200') { diff --git a/tests/unit/http/client.spec.ts b/tests/unit/http/client.spec.ts index 1c4a4ac..27bc9d4 100644 --- a/tests/unit/http/client.spec.ts +++ b/tests/unit/http/client.spec.ts @@ -1,13 +1,17 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' // Taro.request 由各用例设置返回值(vi.hoisted 保证 mock 提升后仍可引用) const mockRequest = vi.hoisted(() => vi.fn()) vi.mock('@tarojs/taro', () => ({ default: { request: mockRequest } })) -// client 依赖 store 取 token / dispatch clearAuth +// client 依赖 store 取 token / dispatch clearAuth;authState 可变,支持用例模拟在途请求期间换 token +const authState = vi.hoisted(() => ({ token: 'unit-token' })) const mockDispatch = vi.hoisted(() => vi.fn()) vi.mock('@/store', () => ({ - store: { getState: () => ({ auth: { token: 'unit-token' } }), dispatch: mockDispatch } + store: { + getState: () => ({ auth: { token: authState.token } }), + dispatch: mockDispatch + } })) vi.mock('@/store/authSlice', () => ({ @@ -31,8 +35,13 @@ const okBody = (content: T) => ({ beforeEach(() => { mockRequest.mockReset() + // token 基线复位:上个用例可能模拟过换 token + authState.token = 'unit-token' }) +// 兜底释放挂起的 reLaunch,防止 redirectingToLogin flag 泄漏到后续用例 +afterEach(() => releaseRelaunch()) + describe('client envelope 解析', () => { it('Code=200 时解包返回 Content', async () => { mockRequest.mockResolvedValue(okBody({ name: '张三' })) @@ -113,29 +122,32 @@ describe('登录态失效踢回登录页', () => { await expect(request.get('/yanglao/app/ylHome/getList')).rejects.toBeInstanceOf(AuthExpiredError) expect(mockDispatch).toHaveBeenCalledWith({ type: 'auth/clearAuth' }) expect(mockReLaunch).toHaveBeenCalledWith('/pages/login/index') - releaseRelaunch() }) it('HTTP 401 同样触发踢登录并抛 AuthExpiredError', async () => { mockRequest.mockResolvedValue({ statusCode: 401, data: { Code: '401', Message: 'token 无效' } }) await expect(request.get('/demo')).rejects.toMatchObject({ name: 'AuthExpiredError' }) expect(mockDispatch).toHaveBeenCalledTimes(1) - releaseRelaunch() + }) + + it('陈旧 401:请求发起后 token 已更换时不误清新会话', async () => { + mockRequest.mockResolvedValue(unauthorizedBody) + const p = request.get('/a').catch((e) => e) + await Promise.resolve() + authState.token = 'new-token' // 模拟用户重新登录 + await p + expect(mockDispatch).not.toHaveBeenCalled() + expect(mockReLaunch).not.toHaveBeenCalled() }) it('防重入:跳转进行中并发的第二个 401 请求不重复 dispatch/reLaunch', async () => { mockRequest.mockResolvedValue(unauthorizedBody) const p1 = request.get('/a').catch((e) => e) const p2 = request.get('/b').catch((e) => e) - // mockResolvedValue + 内部 await:需 3 个 microtask tick 后 401 分支才执行 - await Promise.resolve() - await Promise.resolve() - await Promise.resolve() + await Promise.all([p1, p2]) + // 此刻 releaseRelaunch 未调用、防重入 flag 仍为 true expect(mockDispatch).toHaveBeenCalledTimes(1) expect(mockReLaunch).toHaveBeenCalledTimes(1) - releaseRelaunch() - await Promise.all([p1, p2]) - expect(mockDispatch).toHaveBeenCalledTimes(1) }) it("普通业务错 '500' 不触发踢登录", async () => {