feat(http): 登录态失效统一清登录态并踢回登录页
This commit is contained in:
parent
9fbe9266eb
commit
3adaaa65e2
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue