/** * 网络请求封装(对接 jeesharp 后端:统一响应 AjaxResult { code, msg, data }) * * 鉴权策略: * - ERP 小程序为强制登录应用,所有业务接口均带 token 三件套 * (access_token + Authorization: Bearer + satoken,与管理端 axios 拦截器一致)。 * - code:200 成功;code:201 或 HTTP 401 → token 失效,清态跳登录页; * code:205 → 无该功能权限,友好 toast。 */ import { apiConfig } from '@/config/api' import { getAccessToken, getSsoToken, clearAuthState } from './tokenStorage' import { getDeviceId, getAppType, getMobileClientHeaders } from './device' import type { ApiResponse } from '@/types' import { NeedLoginError, isNeedLoginError, promptReLogin, shouldSuppressAuthErrorToast } from './needLoginError' // 请求配置 export interface RequestConfig { url: string method?: 'GET' | 'POST' | 'PUT' | 'DELETE' data?: any params?: any header?: Record showLoading?: boolean loadingText?: string showErrorToast?: boolean /** 跳过 token 注入(用于免登录接口:getRsaPublicKey / login/captcha / login) */ skipAuth?: boolean } // 请求拦截器 function requestInterceptor(config: RequestConfig): RequestConfig { // 添加 Token(与管理端 axios 拦截器一致:access_token + Authorization + satoken) const token = config.skipAuth ? null : getAccessToken() if (token) { const satoken = getSsoToken() config.header = { ...config.header, access_token: token, Authorization: `Bearer ${token}`, ...(satoken ? { satoken } : {}) } } // 添加设备信息(与 upload 共用 X-Platform / X-Version,便于后端与网关识别) config.header = { ...config.header, ...getMobileClientHeaders(), 'X-Device-Id': getDeviceId(), 'X-App-Type': getAppType(), 'Content-Type': 'application/json' } return config } /** 无权限错误:业务 catch 中可识别做降级展示 */ export class NoPermissionError extends Error { readonly noPermission = true constructor(message?: string) { super(message || '无该功能权限') this.name = 'NoPermissionError' } } export function isNoPermissionError(error: unknown): boolean { return Boolean( error && typeof error === 'object' && (error as NoPermissionError).noPermission === true ) } // 响应拦截器 function responseInterceptor( response: UniApp.RequestSuccessCallbackResult, skipAuth = false ): ApiResponse { // HTTP 401 兜底(jeesharp 正常以业务码 201 表达 token 失效,此处双通道处理) if (response.statusCode === 401) { if (!skipAuth) { clearAuthState() promptReLogin() } throw new NeedLoginError() } const res = response.data as ApiResponse // Token 过期或无效 if (res.code === 201) { if (!skipAuth) { clearAuthState() promptReLogin() } throw new NeedLoginError(res.msg) } // 无权限(jeesharp 业务码 205) if (res.code === 205) { throw new NoPermissionError(res.msg) } // 其他错误 if (res.code !== 200) { throw new Error(res.msg || '请求失败') } return res } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } /** * 请求方法 * - GET 幂等请求失败(网络层错误)自动重试 apiConfig.retryCount 次 * - 写操作(POST/PUT/DELETE)带提交锁:同 url+payload 300ms 内重复调用直接返回进行中 Promise,防连点重复提交 */ const pendingWrites = new Map>() function writeLockKey(config: RequestConfig): string { return `${config.method}:${config.url}:${JSON.stringify(config.data ?? {})}` } export function request(config: RequestConfig): Promise> { const method = (config.method || 'GET').toUpperCase() // 写操作提交锁 if (method !== 'GET') { const key = writeLockKey(config) const pending = pendingWrites.get(key) if (pending) return pending const p = doRequest(config).finally(() => { // 稍延迟释放,覆盖连点场景 setTimeout(() => pendingWrites.delete(key), 300) }) pendingWrites.set(key, p) return p } return doRequestWithRetry(config) } async function doRequestWithRetry(config: RequestConfig): Promise> { let lastError: unknown = null for (let attempt = 0; attempt <= apiConfig.retryCount; attempt++) { try { return await doRequest(config) } catch (error) { lastError = error // 业务错误(含 201/205/400 系)不重试,仅网络层失败重试 if (!(error instanceof Error && /^网络请求失败|request:fail|timeout/i.test(error.message))) { throw error } if (attempt < apiConfig.retryCount) { await sleep(apiConfig.retryDelay) } } } throw lastError } function doRequest(config: RequestConfig): Promise> { return new Promise((resolve, reject) => { // 请求拦截 const finalConfig = requestInterceptor({ ...apiConfig, ...config }) // 显示加载提示 if (finalConfig.showLoading) { uni.showLoading({ title: finalConfig.loadingText || '加载中...', mask: true }) } // 发送请求 const method = (finalConfig.method || 'GET').toUpperCase() const requestData = method === 'GET' ? (finalConfig.params ?? finalConfig.data) : finalConfig.data uni.request({ url: finalConfig.url.startsWith('http') ? finalConfig.url : `${apiConfig.baseURL}${finalConfig.url}`, method: finalConfig.method || 'GET', data: requestData, header: finalConfig.header, timeout: apiConfig.timeout, success: (res) => { if (finalConfig.showLoading) { uni.hideLoading() } try { const result = responseInterceptor(res, finalConfig.skipAuth) resolve(result) } catch (error) { if ( finalConfig.showErrorToast && !isNeedLoginError(error) && !shouldSuppressAuthErrorToast(error) ) { uni.showToast({ title: (error as Error).message || '请求失败', icon: 'none' }) } reject(error) } }, fail: (error) => { if (finalConfig.showLoading) { uni.hideLoading() } const errorMsg = error.errMsg || '网络请求失败' if (finalConfig.showErrorToast) { uni.showToast({ title: errorMsg, icon: 'none' }) } reject(new Error(errorMsg)) } }) }) } /** * GET 请求 */ export function get(url: string, params?: any, config?: Partial): Promise> { return request({ url, method: 'GET', params, ...config }) } /** * POST 请求 */ export function post(url: string, data?: any, config?: Partial): Promise> { return request({ url, method: 'POST', data, ...config }) } /** * PUT 请求 */ export function put(url: string, data?: any, config?: Partial): Promise> { return request({ url, method: 'PUT', data, ...config }) } /** * DELETE 请求 */ export function del(url: string, config?: Partial): Promise> { return request({ url, method: 'DELETE', ...config }) } export default request