feat(http): 登录态失效统一清登录态并踢回登录页

This commit is contained in:
ozh 2026-09-15 11:34:18 +08:00
parent 9fbe9266eb
commit 3adaaa65e2
2 changed files with 124 additions and 7 deletions

View File

@ -1,12 +1,30 @@
import Taro from '@tarojs/taro'
import { store } from '@/store'
import { ApiError, BusinessError } from './errors'
import { clearAuth } from '@/store/authSlice'
import { ROUTES } from '@/constants/routes'
import { reLaunch } from '@/utils/navigation'
import { ApiError, BusinessError, AuthExpiredError } from './errors'
// 测试环境(内网,开发者工具需勾选「不校验合法域名」);正式域名为占位符,上线前必须替换(spec D2)
const BASE_URL = process.env.NODE_ENV === 'development'
? 'http://192.168.10.11:39101/api/biz'
: 'https://PROD_PLACEHOLDER/api/biz'
// 后端约定的未登录业务码(占位 '401',联调确认后更新——spec D5/Open Questions 1)
const UNAUTHORIZED_CODES = new Set(['401'])
// 防重入:跳转登录页进行中不重复触发(并发 401 只踢一次)
let redirectingToLogin = false
function redirectToLogin(): void {
if (redirectingToLogin) return
redirectingToLogin = true
store.dispatch(clearAuth())
reLaunch(ROUTES.LOGIN).finally(() => {
redirectingToLogin = false
})
}
export interface RequestConfig {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
headers?: Record<string, string>
@ -58,12 +76,26 @@ export async function request<T>(
}
if (res.statusCode !== 200) {
if (res.statusCode === 401) {
redirectToLogin()
throw new AuthExpiredError('登录已过期')
}
throw new ApiError(`HTTP ${res.statusCode}`, res.statusCode)
}
const body = res.data as ApiEnvelope<T>
if (String(body.Code) !== '200') {
throw new BusinessError(body.Message ?? '业务错误', String(body.Code ?? ''))
// 网关返回 200 + 非 envelope 形态(HTML 拦截页/null 等)属传输层异常,不能当业务错分流
const body = res.data as ApiEnvelope<T> | null | string
if (body === null || typeof body !== 'object' || body.Code == null) {
throw new ApiError('响应格式异常', res.statusCode)
}
const bizCode = String(body.Code)
if (UNAUTHORIZED_CODES.has(bizCode)) {
redirectToLogin()
throw new AuthExpiredError(body.Message || '登录已过期')
}
if (bizCode !== '200') {
throw new BusinessError(body.Message || '业务错误', bizCode)
}
return body.Content
}

View File

@ -4,13 +4,25 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'
const mockRequest = vi.hoisted(() => vi.fn())
vi.mock('@tarojs/taro', () => ({ default: { request: mockRequest } }))
// client 依赖 store 取 token;此处只读,不触发 401 流程
// client 依赖 store 取 token / dispatch clearAuth
const mockDispatch = vi.hoisted(() => vi.fn())
vi.mock('@/store', () => ({
store: { getState: () => ({ auth: { token: 'unit-token' } }), dispatch: vi.fn() }
store: { getState: () => ({ auth: { token: 'unit-token' } }), dispatch: mockDispatch }
}))
vi.mock('@/store/authSlice', () => ({
clearAuth: vi.fn(() => ({ type: 'auth/clearAuth' }))
}))
// 受控 promise:reLaunch 未 resolve 前 401 防重入 flag 保持 true
let releaseRelaunch: () => void = () => {}
const mockReLaunch = vi.hoisted(
() => vi.fn(() => new Promise<void>((resolve) => { releaseRelaunch = resolve }))
)
vi.mock('@/utils/navigation', () => ({ reLaunch: mockReLaunch }))
import { request } from '@/http/client'
import { ApiError, BusinessError } from '@/http/errors'
import { ApiError, BusinessError, AuthExpiredError } from '@/http/errors'
const okBody = <T>(content: T) => ({
statusCode: 200,
@ -84,3 +96,76 @@ describe('client envelope 解析', () => {
expect(mockRequest.mock.calls[0][0].url).toMatch(/\/api\/biz\/thirdPlatform\/wxDevLogin\/miniapp\/login$/)
})
})
describe('登录态失效踢回登录页', () => {
const unauthorizedBody = {
statusCode: 200,
data: { Code: '401', Message: '未登录', Content: null }
}
beforeEach(() => {
mockDispatch.mockClear()
mockReLaunch.mockClear()
})
it("业务码 '401':抛 AuthExpiredError、dispatch clearAuth、reLaunch 登录页", async () => {
mockRequest.mockResolvedValue(unauthorizedBody)
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 请求不重复 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()
expect(mockDispatch).toHaveBeenCalledTimes(1)
expect(mockReLaunch).toHaveBeenCalledTimes(1)
releaseRelaunch()
await Promise.all([p1, p2])
expect(mockDispatch).toHaveBeenCalledTimes(1)
})
it("普通业务错 '500' 不触发踢登录", async () => {
mockRequest.mockResolvedValue({
statusCode: 200,
data: { Code: '500', Message: '服务器开小差', Content: null }
})
await expect(request.get('/demo')).rejects.toBeInstanceOf(BusinessError)
expect(mockDispatch).not.toHaveBeenCalled()
expect(mockReLaunch).not.toHaveBeenCalled()
})
it('HTTP 500 抛 ApiError 且不踢登录', async () => {
mockRequest.mockResolvedValue({ statusCode: 500, data: '' })
await expect(request.get('/demo')).rejects.toBeInstanceOf(ApiError)
expect(mockDispatch).not.toHaveBeenCalled()
})
it('200 但 body 非 envelope 形态(HTML 拦截页等)抛 ApiError 而非 BusinessError', async () => {
mockRequest.mockResolvedValue({ statusCode: 200, data: '<html>blocked</html>' })
await expect(request.get('/demo')).rejects.toMatchObject({
name: 'ApiError',
message: '响应格式异常',
statusCode: 200
})
expect(mockDispatch).not.toHaveBeenCalled()
})
it('200 且 body 为 null 抛 ApiError 而非 TypeError', async () => {
mockRequest.mockResolvedValue({ statusCode: 200, data: null })
await expect(request.get('/demo')).rejects.toBeInstanceOf(ApiError)
})
})