diff --git a/src/http/client.ts b/src/http/client.ts index e8e333a..015b98b 100644 --- a/src/http/client.ts +++ b/src/http/client.ts @@ -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 @@ -58,12 +76,26 @@ export async function request( } 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 - if (String(body.Code) !== '200') { - throw new BusinessError(body.Message ?? '业务错误', String(body.Code ?? '')) + // 网关返回 200 + 非 envelope 形态(HTML 拦截页/null 等)属传输层异常,不能当业务错分流 + const body = res.data as ApiEnvelope | 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 } diff --git a/tests/unit/http/client.spec.ts b/tests/unit/http/client.spec.ts index 6ecc8ea..1c4a4ac 100644 --- a/tests/unit/http/client.spec.ts +++ b/tests/unit/http/client.spec.ts @@ -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((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 = (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: 'blocked' }) + 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) + }) +})