From 9fbe9266ebd613159fe0abdf676584479cd9a89e Mon Sep 17 00:00:00 2001 From: ozh Date: Tue, 15 Sep 2026 11:18:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(http):=20=E9=80=82=E9=85=8D=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E5=A4=A7=E5=86=99=20envelope=20=E4=B8=8E=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E7=8E=AF=E5=A2=83=20BASE=5FURL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/http/client.ts | 23 +++++---- tests/unit/http/client.spec.ts | 86 ++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 8 deletions(-) create mode 100644 tests/unit/http/client.spec.ts diff --git a/src/http/client.ts b/src/http/client.ts index 66b5b97..e8e333a 100644 --- a/src/http/client.ts +++ b/src/http/client.ts @@ -2,7 +2,10 @@ import Taro from '@tarojs/taro' import { store } from '@/store' import { ApiError, BusinessError } from './errors' -const BASE_URL = '' // 后端就绪后填入 +// 测试环境(内网,开发者工具需勾选「不校验合法域名」);正式域名为占位符,上线前必须替换(spec D2) +const BASE_URL = process.env.NODE_ENV === 'development' + ? 'http://192.168.10.11:39101/api/biz' + : 'https://PROD_PLACEHOLDER/api/biz' export interface RequestConfig { method?: 'GET' | 'POST' | 'PUT' | 'DELETE' @@ -11,10 +14,14 @@ export interface RequestConfig { needAuth?: boolean // 默认 true;登录接口自身传 false } +// 后端统一响应格式(大写 key、字符串状态码) export interface ApiEnvelope { - code: number - data: T - message?: string + Code: string + Message?: string + Content: T + RecordCount?: number + PageIndex?: number + PageSize?: number } export async function request( @@ -25,7 +32,7 @@ export async function request( const method = config.method ?? 'GET' const timeout = config.timeout ?? 10000 - // token 注入(spec §3B):从 authSlice 读取 + // token 注入:从 authSlice 读取 const headers: Record = { 'Content-Type': 'application/json', ...config.headers @@ -55,10 +62,10 @@ export async function request( } const body = res.data as ApiEnvelope - if (body.code !== 0) { - throw new BusinessError(body.message ?? '业务错误', body.code) + if (String(body.Code) !== '200') { + throw new BusinessError(body.Message ?? '业务错误', String(body.Code ?? '')) } - return body.data + return body.Content } request.get = (url: string, params?: Record, config?: RequestConfig) => diff --git a/tests/unit/http/client.spec.ts b/tests/unit/http/client.spec.ts new file mode 100644 index 0000000..6ecc8ea --- /dev/null +++ b/tests/unit/http/client.spec.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' + +// Taro.request 由各用例设置返回值(vi.hoisted 保证 mock 提升后仍可引用) +const mockRequest = vi.hoisted(() => vi.fn()) +vi.mock('@tarojs/taro', () => ({ default: { request: mockRequest } })) + +// client 依赖 store 取 token;此处只读,不触发 401 流程 +vi.mock('@/store', () => ({ + store: { getState: () => ({ auth: { token: 'unit-token' } }), dispatch: vi.fn() } +})) + +import { request } from '@/http/client' +import { ApiError, BusinessError } from '@/http/errors' + +const okBody = (content: T) => ({ + statusCode: 200, + data: { Code: '200', Message: '', Content: content, RecordCount: 0, PageIndex: 0, PageSize: 0 } +}) + +beforeEach(() => { + mockRequest.mockReset() +}) + +describe('client envelope 解析', () => { + it('Code=200 时解包返回 Content', async () => { + mockRequest.mockResolvedValue(okBody({ name: '张三' })) + const res = await request.get<{ name: string }>('/demo') + expect(res).toEqual({ name: '张三' }) + // GET 参数经 data 透传(Taro 运行时序列化为 query string) + expect(mockRequest.mock.calls[0][0].data).toBeUndefined() + }) + + it('GET 参数放入 data 字段', async () => { + mockRequest.mockResolvedValue(okBody(null)) + await request.get('/demo', { page: 1 }) + expect(mockRequest.mock.calls[0][0].data).toEqual({ page: 1 }) + }) + + it("Code='500' 抛 BusinessError(message + 字符串 code)", async () => { + mockRequest.mockResolvedValue({ + statusCode: 200, + data: { Code: '500', Message: '手机号已绑定', Content: null } + }) + await expect(request.get('/demo')).rejects.toMatchObject({ + name: 'BusinessError', + message: '手机号已绑定', + code: '500' + }) + await expect(request.get('/demo')).rejects.toBeInstanceOf(BusinessError) + }) + + it('HTTP 404 抛 ApiError 并带 statusCode', async () => { + mockRequest.mockResolvedValue({ statusCode: 404, data: 'Not Found' }) + await expect(request.get('/demo')).rejects.toMatchObject({ + name: 'ApiError', + message: 'HTTP 404', + statusCode: 404 + }) + }) + + it('Taro.request 异常(断网等)抛 ApiError', async () => { + mockRequest.mockRejectedValue(new Error('request:fail timeout')) + await expect(request.get('/demo')).rejects.toMatchObject({ + name: 'ApiError', + message: 'request:fail timeout' + }) + }) + + it('需鉴权请求注入 Bearer token 头', async () => { + mockRequest.mockResolvedValue(okBody(null)) + await request.get('/demo') + expect(mockRequest.mock.calls[0][0].header.Authorization).toBe('Bearer unit-token') + }) + + it('needAuth: false 时不注入 Authorization', async () => { + mockRequest.mockResolvedValue(okBody(null)) + await request.post('/demo', {}, { needAuth: false }) + expect(mockRequest.mock.calls[0][0].header.Authorization).toBeUndefined() + }) + + it('BASE_URL 拼接:请求 url = BASE_URL + path', async () => { + mockRequest.mockResolvedValue(okBody(null)) + await request.get('/thirdPlatform/wxDevLogin/miniapp/login') + expect(mockRequest.mock.calls[0][0].url).toMatch(/\/api\/biz\/thirdPlatform\/wxDevLogin\/miniapp\/login$/) + }) +})