fix(http): 401 踢登录防陈旧会话误清与跳转失败兜底
This commit is contained in:
parent
3adaaa65e2
commit
884a7b8343
|
|
@ -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') {
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue