import Taro from '@tarojs/taro' import { BaseUrl, TenantId } from '@/config/app' import { clearStorageByLoginUser } from '@/utils/server' export interface RequestConfig { url: string method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' data?: any header?: Record timeout?: number retry?: number showLoading?: boolean showError?: boolean returnRaw?: boolean } interface ApiResponse { code: number message?: string data?: T } export enum ErrorType { NETWORK_ERROR = 'NETWORK_ERROR', TIMEOUT_ERROR = 'TIMEOUT_ERROR', BUSINESS_ERROR = 'BUSINESS_ERROR', AUTH_ERROR = 'AUTH_ERROR', UNKNOWN_ERROR = 'UNKNOWN_ERROR', } export class RequestError extends Error { public type: ErrorType public code?: number public data?: any constructor(message: string, type: ErrorType, code?: number, data?: any) { super(message) this.name = 'RequestError' this.type = type this.code = code this.data = data } } const DEFAULT_CONFIG: Required> = { timeout: 10000, retry: 2, showLoading: false, showError: true, returnRaw: true, } const baseUrl = BaseUrl const requestInterceptor = (config: RequestConfig): RequestConfig => { const token = Taro.getStorageSync('access_token') const tenantId = Taro.getStorageSync('TenantId') || TenantId const defaultHeaders: Record = { 'Content-Type': 'application/json', TenantId: String(tenantId), } if (token) { defaultHeaders.Authorization = token } config.header = { ...defaultHeaders, ...config.header } if (config.showLoading) { Taro.showLoading({ title: '加载中...' }) } return config } // Redis 临时故障关键词 - 这类 401 是服务端问题,不应清除本地 token const REDIS_ERROR_KEYWORDS = ['RedisSystemException', 'RedisException', 'Operation timed out', 'Redis'] const isRedisError = (message?: string): boolean => { if (!message) return false return REDIS_ERROR_KEYWORDS.some((keyword) => message.includes(keyword)) } // 防抖:避免同一时间多个接口都触发跳登录页 let authErrorTimer: ReturnType | null = null const handleAuthError = (message?: string) => { // Redis 临时故障导致的 401,不清除 token,静默处理 if (isRedisError(message)) { console.warn('[Auth] Redis 临时故障导致 401,忽略处理:', message) return } clearStorageByLoginUser() // 防抖:多个接口同时 401 只弹一次 Toast if (authErrorTimer) return authErrorTimer = setTimeout(() => { authErrorTimer = null }, 3000) Taro.showToast({ title: '登录已过期,请重新登录', icon: 'none', duration: 2000, }) } const responseInterceptor = (response: any, config: RequestConfig): T => { if (config.showLoading) { Taro.hideLoading() } const { statusCode, data } = response if (statusCode !== 200) { throw new RequestError(`HTTP错误: ${statusCode}`, ErrorType.NETWORK_ERROR, statusCode, data) } if (data === null || data === undefined) { throw new RequestError('响应数据为空', ErrorType.NETWORK_ERROR, statusCode, data) } if (typeof data === 'object' && data !== null && 'code' in data) { const apiResponse = data as ApiResponse if (apiResponse.code === 0) { return (config.returnRaw ? data : apiResponse.data) as T } if (apiResponse.code === 401 || apiResponse.code === 403) { handleAuthError(apiResponse.message) throw new RequestError(apiResponse.message || '认证失败', ErrorType.AUTH_ERROR, apiResponse.code, apiResponse.data) } throw new RequestError(apiResponse.message || '请求失败', ErrorType.BUSINESS_ERROR, apiResponse.code, apiResponse.data) } return data as T } const handleError = (error: RequestError, config: RequestConfig) => { if (config.showLoading) { Taro.hideLoading() } if (!config.showError) return const titleMap: Record = { [ErrorType.NETWORK_ERROR]: '网络连接失败', [ErrorType.TIMEOUT_ERROR]: '请求超时', [ErrorType.BUSINESS_ERROR]: error.message || '操作失败', [ErrorType.AUTH_ERROR]: '认证失败', [ErrorType.UNKNOWN_ERROR]: '未知错误', } Taro.showToast({ title: titleMap[error.type] || '请求失败', icon: 'none', duration: 2000, }) } const executeRequest = (config: RequestConfig): Promise => new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(new RequestError('请求超时', ErrorType.TIMEOUT_ERROR)) }, config.timeout || DEFAULT_CONFIG.timeout) Taro.request({ url: config.url, method: config.method || 'GET', data: config.data || {}, header: config.header || {}, success: (res) => { clearTimeout(timer) try { resolve(responseInterceptor(res, config)) } catch (error) { reject(error) } }, fail: (err) => { clearTimeout(timer) reject(new RequestError(err.errMsg || '网络请求失败', ErrorType.NETWORK_ERROR, undefined, err)) }, }) }) const retryRequest = async (config: RequestConfig, retryCount = 0): Promise => { try { return await executeRequest(config) } catch (error) { const requestError = error as RequestError if (requestError.type === ErrorType.AUTH_ERROR || requestError.type === ErrorType.BUSINESS_ERROR) { throw error } if (retryCount < (config.retry || DEFAULT_CONFIG.retry)) { await new Promise((resolve) => setTimeout(resolve, 1000 * (retryCount + 1))) return retryRequest(config, retryCount + 1) } throw error } } export async function request(options: RequestConfig): Promise { try { const config = requestInterceptor({ ...DEFAULT_CONFIG, ...options }) return await retryRequest(config) } catch (error) { const requestError = error as RequestError handleError(requestError, options) throw requestError } } const buildUrl = (url: string): string => { if (url.startsWith('http://') || url.startsWith('https://')) { return url } return `${baseUrl}${url}` } const buildQueryString = (params: Record): string => { const queryString = Object.keys(params) .filter((key) => params[key] !== undefined && params[key] !== null) .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`) .join('&') return queryString ? `?${queryString}` : '' } export function get(url: string, params?: any, config?: Partial): Promise { const fullUrl = buildUrl(url) + (params ? buildQueryString(params) : '') return request({ url: fullUrl, method: 'GET', returnRaw: true, ...config }) } export function post(url: string, data?: any, config?: Partial): Promise { return request({ url: buildUrl(url), method: 'POST', data, returnRaw: true, ...config }) } export function put(url: string, data?: any, config?: Partial): Promise { return request({ url: buildUrl(url), method: 'PUT', data, returnRaw: true, ...config }) } export function patch(url: string, data?: any, config?: Partial): Promise { return request({ url: buildUrl(url), method: 'PATCH', data, returnRaw: true, ...config }) } export function del(url: string, data?: any, config?: Partial): Promise { return request({ url: buildUrl(url), method: 'DELETE', data, returnRaw: true, ...config }) } export function getData(url: string, params?: any, config?: Partial): Promise { const fullUrl = buildUrl(url) + (params ? buildQueryString(params) : '') return request({ url: fullUrl, method: 'GET', returnRaw: false, ...config }) } export function postData(url: string, data?: any, config?: Partial): Promise { return request({ url: buildUrl(url), method: 'POST', data, returnRaw: false, ...config }) } export function putData(url: string, data?: any, config?: Partial): Promise { return request({ url: buildUrl(url), method: 'PUT', data, returnRaw: false, ...config }) } export function delData(url: string, data?: any, config?: Partial): Promise { return request({ url: buildUrl(url), method: 'DELETE', data, returnRaw: false, ...config }) } export default { request, get, post, put, patch, del, getData, postData, putData, delData, ErrorType, RequestError, }