import { BASE_URL, API_PREFIX, ERROR_MESSAGES } from './config' import * as auth from './auth' // 业务错误对象(携带 code,页面可按 code 定制处理) export class ApiError extends Error { code: number details?: string[] constructor(code: number, msg: string, details?: string[]) { super(msg) this.code = code this.details = details } } export interface RequestOptions { url: string method?: 'GET' | 'POST' | 'PUT' | 'DELETE' data?: any header?: Record auth?: boolean // 默认 true,需要鉴权 showError?: boolean // 默认 true,业务错误自动 toast silentAuth?: boolean // 非关键请求(如未读红点轮询):401 只失败,不触发全局跳登录 } // 未登录错误码:调用方可通过 e.code === 11001 区分 export const NOT_LOGIN_CODE = 11001 // 登录失效 / 账号禁用后的跳转处理(由 app.ts 注入,避免循环依赖) let onAuthExpired: (() => void) | null = null export function setAuthExpiredHandler(handler: () => void): void { onAuthExpired = handler } // 单例刷新锁,避免并发请求同时刷新 let refreshingPromise: Promise | null = null function doRefresh(): Promise { if (!refreshingPromise) { refreshingPromise = new Promise((resolve) => { const refreshToken = auth.getRefreshToken() const accessToken = auth.getAccessToken() if (!refreshToken || !accessToken) { resolve(false) return } wx.request({ url: `${BASE_URL}${API_PREFIX}/user/refresh-token`, method: 'POST', header: { 'Content-Type': 'application/json' }, data: { refresh_token: refreshToken, access_token: accessToken }, success: (res) => { const body = res.data as { code: number; data?: LoginTokenResponse } if (body && body.code === 0 && body.data) { auth.setTokens({ accessToken: body.data.access_token, refreshToken: body.data.refresh_token, expireTime: body.data.expire_time, }) resolve(true) } else { resolve(false) } }, fail: () => resolve(false), }) }).finally(() => { refreshingPromise = null }) } return refreshingPromise } interface LoginTokenResponse { access_token: string refresh_token: string expire_time: number } function showToast(title: string): void { wx.showToast({ title, icon: 'none', duration: 2000 }) } // 跳转登录页的幂等锁:并发的多个 401 只会触发一次路由,避免互相打断 webview 生命周期 let redirecting = false /** * 跳转登录页(全局唯一入口,幂等): * 1. 同一时间只允许一次 reLaunch,其余调用直接丢弃; * 2. 当前已在登录页时不重复跳转。 */ export function redirectToLogin(): void { if (redirecting) return const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : [] const current = pages[pages.length - 1] if (current && current.route === 'pages/login/login') return redirecting = true wx.reLaunch({ url: '/pages/login/login', complete: () => { setTimeout(() => { redirecting = false }, 1000) }, }) } // 登录失效统一处理:先清理登录态(由 app.ts 注入),再走幂等跳转 function handleAuthExpired(): void { if (onAuthExpired) onAuthExpired() redirectToLogin() } export function request(options: RequestOptions): Promise { const { url, method = 'GET', data, header = {}, auth: needAuth = true, showError = true, silentAuth = false, } = options const fullUrl = url.startsWith('http') ? url : `${BASE_URL}${API_PREFIX}${url}` return new Promise((resolve, reject) => { // 未登录:直接失败,不发请求,避免无 token 时批量 401 引发重定向风暴 if (needAuth && !auth.isLoggedIn()) { reject(new ApiError(NOT_LOGIN_CODE, '未登录')) return } const doCall = (): void => { const finalHeader: Record = { 'Content-Type': 'application/json', ...header, } if (needAuth) { const token = auth.getAccessToken() if (token) finalHeader['Authorization'] = `Bearer ${token}` } wx.request({ url: fullUrl, method, data, header: finalHeader, success: (res) => { const body = res.data as { code: number; msg: string; data?: T; details?: string[] } // 网络层异常:响应体无 code if (!body || typeof body.code !== 'number') { if (showError) showToast('网络异常,请稍后重试') reject(new Error('network error')) return } const code = body.code // 成功 if (code === 0) { resolve(body.data as T) return } // Token 失效:尝试刷新后重试一次 if ( needAuth && (code === 11001 || code === 11002 || code === 11004 || res.statusCode === 401) ) { doRefresh().then((ok) => { if (ok) { doCall() return } auth.clearTokens() // 非关键请求(红点轮询等)静默失败,不打断用户当前页面 if (!silentAuth) { handleAuthExpired() if (showError) showToast('登录已过期,请重新登录') } reject(new ApiError(code, body.msg || '登录已过期')) }) return } // 账号禁用(HTTP 200,需单独处理) if (code === 21001) { auth.clearTokens() handleAuthExpired() if (showError) showToast(ERROR_MESSAGES[21001] || '账号已被禁用') reject(new ApiError(code, body.msg || '账号已被禁用')) return } // 其余业务错误 if (showError) { const msg = (body.details && body.details[0]) || ERROR_MESSAGES[code] || body.msg || '请求失败' showToast(msg) } reject(new ApiError(code, body.msg || '请求失败', body.details)) }, fail: () => { if (showError) showToast('网络异常,请检查网络后重试') reject(new Error('request fail')) }, }) } doCall() }) } // 文件上传(multipart,字段名 file) export function uploadFile(filePath: string, name = 'file', url = '/clothes/upload'): Promise { const token = auth.getAccessToken() const header: Record = {} if (token) header['Authorization'] = `Bearer ${token}` return new Promise((resolve, reject) => { wx.uploadFile({ url: url.startsWith('http') ? url : `${BASE_URL}${API_PREFIX}${url}`, filePath, name, header, success: (res) => { let body: { code: number; msg: string; data?: T; details?: string[] } | null = null try { body = JSON.parse(res.data) } catch (e) { body = null } if (body && typeof body.code === 'number') { if (body.code === 0) { resolve(body.data as T) } else { const msg = (body.details && body.details[0]) || ERROR_MESSAGES[body.code] || body.msg || '上传失败' showToast(msg) reject(new ApiError(body.code, body.msg || '上传失败', body.details)) } } else { showToast('上传失败,请稍后重试') reject(new Error('upload fail')) } }, fail: () => { showToast('上传失败,请检查网络后重试') reject(new Error('upload fail')) }, }) }) }