import Taro from '@tarojs/taro' import { store } from '@/store' 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(tokenAtStart: string | null): void { if (redirectingToLogin) return // 仅当发起请求时的 token 仍是当前登录态才踢,避免在途旧请求 401 误清重新登录后的新会话 // (token 为 null 的未登录请求 401 时 null === null 成立会踢,属合理行为) if (store.getState().auth.token !== tokenAtStart) return redirectingToLogin = true store.dispatch(clearAuth()) reLaunch(ROUTES.LOGIN) .catch((e: unknown) => { // 跳转失败显式记录,避免 unhandled rejection 噪音(debug-first:不静默吞) console.error('[http] 踢回登录页失败', e) }) .finally(() => { redirectingToLogin = false }) } export interface RequestConfig { method?: 'GET' | 'POST' | 'PUT' | 'DELETE' headers?: Record timeout?: number needAuth?: boolean // 默认 true;登录接口自身传 false } // 后端统一响应格式(大写 key、字符串状态码) export interface ApiEnvelope { Code: string Message?: string Content: T RecordCount?: number PageIndex?: number PageSize?: number } export async function request( url: string, params?: Record, config: RequestConfig = {} ): Promise { const method = config.method ?? 'GET' const timeout = config.timeout ?? 10000 // 请求发起时刻的 token 快照:headers 注入与 401 陈旧竞态判断共用同一份 const tokenAtStart = store.getState().auth.token // token 注入:从 authSlice 读取 const headers: Record = { 'Content-Type': 'application/json', ...config.headers } if (config.needAuth !== false) { if (tokenAtStart) headers.Authorization = `Bearer ${tokenAtStart}` } let res: Taro.request.SuccessCallbackResult> try { res = await Taro.request({ url: BASE_URL + url, method, // Taro.request 的 GET 参数同样走 data,运行时自动序列化为 query string data: params, dataType: 'json', timeout, header: headers }) } catch (e) { throw new ApiError(e instanceof Error ? e.message : '网络异常') } if (res.statusCode !== 200) { if (res.statusCode === 401) { redirectToLogin(tokenAtStart) throw new AuthExpiredError('登录已过期') } throw new ApiError(`HTTP ${res.statusCode}`, res.statusCode) } // 网关返回 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(tokenAtStart) throw new AuthExpiredError(body.Message || '登录已过期') } if (bizCode !== '200') { throw new BusinessError(body.Message || '业务错误', bizCode) } return body.Content } request.get = (url: string, params?: Record, config?: RequestConfig) => request(url, params, { ...config, method: 'GET' }) request.post = (url: string, params?: Record, config?: RequestConfig) => request(url, params, { ...config, method: 'POST' })