fix(http): 401 踢登录防陈旧会话误清与跳转失败兜底

This commit is contained in:
ozh 2026-09-15 11:47:18 +08:00
parent 3adaaa65e2
commit 884a7b8343
2 changed files with 41 additions and 20 deletions

View File

@ -16,11 +16,19 @@ const UNAUTHORIZED_CODES = new Set(['401'])
// 防重入:跳转登录页进行中不重复触发(并发 401 只踢一次) // 防重入:跳转登录页进行中不重复触发(并发 401 只踢一次)
let redirectingToLogin = false let redirectingToLogin = false
function redirectToLogin(): void { function redirectToLogin(tokenAtStart: string | null): void {
if (redirectingToLogin) return if (redirectingToLogin) return
// 仅当发起请求时的 token 仍是当前登录态才踢,避免在途旧请求 401 误清重新登录后的新会话
// (token 为 null 的未登录请求 401 时 null === null 成立会踢,属合理行为)
if (store.getState().auth.token !== tokenAtStart) return
redirectingToLogin = true redirectingToLogin = true
store.dispatch(clearAuth()) store.dispatch(clearAuth())
reLaunch(ROUTES.LOGIN).finally(() => { reLaunch(ROUTES.LOGIN)
.catch((e: unknown) => {
// 跳转失败显式记录,避免 unhandled rejection 噪音(debug-first:不静默吞)
console.error('[http] 踢回登录页失败', e)
})
.finally(() => {
redirectingToLogin = false redirectingToLogin = false
}) })
} }
@ -49,6 +57,8 @@ export async function request<T>(
): Promise<T> { ): Promise<T> {
const method = config.method ?? 'GET' const method = config.method ?? 'GET'
const timeout = config.timeout ?? 10000 const timeout = config.timeout ?? 10000
// 请求发起时刻的 token 快照:headers 注入与 401 陈旧竞态判断共用同一份
const tokenAtStart = store.getState().auth.token
// token 注入:从 authSlice 读取 // token 注入:从 authSlice 读取
const headers: Record<string, string> = { const headers: Record<string, string> = {
@ -56,8 +66,7 @@ export async function request<T>(
...config.headers ...config.headers
} }
if (config.needAuth !== false) { if (config.needAuth !== false) {
const token = store.getState().auth.token if (tokenAtStart) headers.Authorization = `Bearer ${tokenAtStart}`
if (token) headers.Authorization = `Bearer ${token}`
} }
let res: Taro.request.SuccessCallbackResult<ApiEnvelope<T>> let res: Taro.request.SuccessCallbackResult<ApiEnvelope<T>>
@ -77,7 +86,7 @@ export async function request<T>(
if (res.statusCode !== 200) { if (res.statusCode !== 200) {
if (res.statusCode === 401) { if (res.statusCode === 401) {
redirectToLogin() redirectToLogin(tokenAtStart)
throw new AuthExpiredError('登录已过期') throw new AuthExpiredError('登录已过期')
} }
throw new ApiError(`HTTP ${res.statusCode}`, res.statusCode) throw new ApiError(`HTTP ${res.statusCode}`, res.statusCode)
@ -91,7 +100,7 @@ export async function request<T>(
const bizCode = String(body.Code) const bizCode = String(body.Code)
if (UNAUTHORIZED_CODES.has(bizCode)) { if (UNAUTHORIZED_CODES.has(bizCode)) {
redirectToLogin() redirectToLogin(tokenAtStart)
throw new AuthExpiredError(body.Message || '登录已过期') throw new AuthExpiredError(body.Message || '登录已过期')
} }
if (bizCode !== '200') { if (bizCode !== '200') {

View File

@ -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 提升后仍可引用) // Taro.request 由各用例设置返回值(vi.hoisted 保证 mock 提升后仍可引用)
const mockRequest = vi.hoisted(() => vi.fn()) const mockRequest = vi.hoisted(() => vi.fn())
vi.mock('@tarojs/taro', () => ({ default: { request: mockRequest } })) 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()) const mockDispatch = vi.hoisted(() => vi.fn())
vi.mock('@/store', () => ({ vi.mock('@/store', () => ({
store: { getState: () => ({ auth: { token: 'unit-token' } }), dispatch: mockDispatch } store: {
getState: () => ({ auth: { token: authState.token } }),
dispatch: mockDispatch
}
})) }))
vi.mock('@/store/authSlice', () => ({ vi.mock('@/store/authSlice', () => ({
@ -31,8 +35,13 @@ const okBody = <T>(content: T) => ({
beforeEach(() => { beforeEach(() => {
mockRequest.mockReset() mockRequest.mockReset()
// token 基线复位:上个用例可能模拟过换 token
authState.token = 'unit-token'
}) })
// 兜底释放挂起的 reLaunch,防止 redirectingToLogin flag 泄漏到后续用例
afterEach(() => releaseRelaunch())
describe('client envelope 解析', () => { describe('client envelope 解析', () => {
it('Code=200 时解包返回 Content', async () => { it('Code=200 时解包返回 Content', async () => {
mockRequest.mockResolvedValue(okBody({ name: '张三' })) mockRequest.mockResolvedValue(okBody({ name: '张三' }))
@ -113,29 +122,32 @@ describe('登录态失效踢回登录页', () => {
await expect(request.get('/yanglao/app/ylHome/getList')).rejects.toBeInstanceOf(AuthExpiredError) await expect(request.get('/yanglao/app/ylHome/getList')).rejects.toBeInstanceOf(AuthExpiredError)
expect(mockDispatch).toHaveBeenCalledWith({ type: 'auth/clearAuth' }) expect(mockDispatch).toHaveBeenCalledWith({ type: 'auth/clearAuth' })
expect(mockReLaunch).toHaveBeenCalledWith('/pages/login/index') expect(mockReLaunch).toHaveBeenCalledWith('/pages/login/index')
releaseRelaunch()
}) })
it('HTTP 401 同样触发踢登录并抛 AuthExpiredError', async () => { it('HTTP 401 同样触发踢登录并抛 AuthExpiredError', async () => {
mockRequest.mockResolvedValue({ statusCode: 401, data: { Code: '401', Message: 'token 无效' } }) mockRequest.mockResolvedValue({ statusCode: 401, data: { Code: '401', Message: 'token 无效' } })
await expect(request.get('/demo')).rejects.toMatchObject({ name: 'AuthExpiredError' }) await expect(request.get('/demo')).rejects.toMatchObject({ name: 'AuthExpiredError' })
expect(mockDispatch).toHaveBeenCalledTimes(1) 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 () => { it('防重入:跳转进行中并发的第二个 401 请求不重复 dispatch/reLaunch', async () => {
mockRequest.mockResolvedValue(unauthorizedBody) mockRequest.mockResolvedValue(unauthorizedBody)
const p1 = request.get('/a').catch((e) => e) const p1 = request.get('/a').catch((e) => e)
const p2 = request.get('/b').catch((e) => e) const p2 = request.get('/b').catch((e) => e)
// mockResolvedValue + 内部 await:需 3 个 microtask tick 后 401 分支才执行 await Promise.all([p1, p2])
await Promise.resolve() // 此刻 releaseRelaunch 未调用、防重入 flag 仍为 true
await Promise.resolve()
await Promise.resolve()
expect(mockDispatch).toHaveBeenCalledTimes(1) expect(mockDispatch).toHaveBeenCalledTimes(1)
expect(mockReLaunch).toHaveBeenCalledTimes(1) expect(mockReLaunch).toHaveBeenCalledTimes(1)
releaseRelaunch()
await Promise.all([p1, p2])
expect(mockDispatch).toHaveBeenCalledTimes(1)
}) })
it("普通业务错 '500' 不触发踢登录", async () => { it("普通业务错 '500' 不触发踢登录", async () => {