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 === NOT_LOGIN_CODE 区分 export const NOT_LOGIN_CODE = 11001 // 登录失效错误码集合:命中后先尝试刷新 token,刷新失败才清理登录态并跳转登录页 export const AUTH_EXPIRED_CODES: number[] = [ 11000, // 请先登录 11001, // 登录状态已失效 11002, // 登录状态已过期 11004, // 请先登录 ] // 请求超时兜底(毫秒):弱网下 wx.request 默认 60s,用户体验极差 const REQUEST_TIMEOUT = 15000 const UPLOAD_TIMEOUT = 30000 function isAuthExpiredCode(code: number, statusCode?: number): boolean { return AUTH_EXPIRED_CODES.indexOf(code) >= 0 || statusCode === 401 } // 登录失效后的收尾:非关键请求只 reject,不动登录态 function failAuth(code: number, msg: string, silentAuth: boolean, showError: boolean) { if (!silentAuth) { console.log('[auth] failAuth 清理登录态并跳转登录页, code=', code, 'msg=', msg) auth.clearTokens() handleAuthExpired() if (showError) showToast('登录已过期,请重新登录') } else { console.log('[auth] failAuth 静默失败(不动登录态), code=', code) } return new ApiError(code, msg || '登录已过期') } // 登录失效 / 账号禁用后的跳转处理(由 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) { console.log('[auth] doRefresh 无 token,放弃刷新') resolve(false) return } console.log('[auth] doRefresh 发起刷新') wx.request({ url: `${BASE_URL}${API_PREFIX}/user/refresh-token`, method: 'POST', header: { 'Content-Type': 'application/json' }, data: { refresh_token: refreshToken, access_token: accessToken }, timeout: REQUEST_TIMEOUT, 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, }) console.log('[auth] doRefresh 成功') resolve(true) } else { console.log('[auth] doRefresh 响应异常, code=', body && body.code) resolve(false) } }, fail: () => { console.log('[auth] doRefresh 网络失败') 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) { console.log('[auth] redirectToLogin 幂等锁生效,丢弃本次跳转') return } const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : [] const current = pages[pages.length - 1] if (current && current.route === 'pages/login/login') { console.log('[auth] redirectToLogin 已在登录页,跳过') return } console.log('[auth] redirectToLogin 跳转登录页, 当前页=', current && current.route, '栈深=', pages.length) 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 = (retried = false): 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, timeout: REQUEST_TIMEOUT, 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 失效:刷新后最多重试一次,避免「刷新成功但资源接口持续 401」引发请求风暴 if (needAuth && isAuthExpiredCode(code, res.statusCode)) { if (retried) { reject(failAuth(code, body.msg, silentAuth, showError)) return } doRefresh().then((ok) => { if (ok) { doCall(true) return } // 非关键请求(红点轮询、额度展示等)静默失败: // 只 reject,不动登录态、不跳转,避免把已登录用户悄悄踢成未登录 reject(failAuth(code, body.msg, silentAuth, showError)) }) 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')) }, }) } // 直接发请求:由后端 401 判定 token 是否失效,再触发 doRefresh 重试。 // 不再做前端「过期预刷新」:expire_time 单位(秒/毫秒/剩余秒数)无法可靠猜测, // 误判会让每次请求都多一次刷新往返,刷新偶发失败时把已登录用户踢回登录页。 doCall() }) } export interface UploadOptions { showError?: boolean // 默认 true,业务错误自动 toast timeout?: number // 默认 30s silentAuth?: boolean // 非关键上传:401 只失败,不触发全局跳登录 } // 文件上传(multipart,字段名 file) export function uploadFile( filePath: string, name = 'file', url = '/clothes/upload', options: UploadOptions = {}, ): Promise { const { showError = true, timeout = UPLOAD_TIMEOUT, silentAuth = false } = options return new Promise((resolve, reject) => { // 未登录:直接失败,不发请求 if (!auth.isLoggedIn()) { reject(new ApiError(NOT_LOGIN_CODE, '未登录')) return } const doUpload = (retried = false): void => { const header: Record = {} const token = auth.getAccessToken() if (token) header['Authorization'] = `Bearer ${token}` wx.uploadFile({ url: url.startsWith('http') ? url : `${BASE_URL}${API_PREFIX}${url}`, filePath, name, header, timeout, 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 (showError) showToast('上传失败,请稍后重试') reject(new Error('upload fail')) return } if (body.code === 0) { resolve(body.data as T) return } // Token 失效:刷新后最多重试一次 if (isAuthExpiredCode(body.code, res.statusCode)) { if (retried) { reject(failAuth(body.code, body.msg, silentAuth, showError)) return } doRefresh().then((ok) => { if (ok) { doUpload(true) return } reject(failAuth(body.code, body.msg, silentAuth, showError)) }) return } const msg = (body.details && body.details[0]) || ERROR_MESSAGES[body.code] || body.msg || '上传失败' if (showError) showToast(msg) reject(new ApiError(body.code, body.msg || '上传失败', body.details)) }, fail: () => { if (showError) showToast('上传失败,请检查网络后重试') reject(new Error('upload fail')) }, }) } doUpload() }) }