333 lines
10 KiB
TypeScript
333 lines
10 KiB
TypeScript
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<string, any>
|
||
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) {
|
||
auth.clearTokens()
|
||
handleAuthExpired()
|
||
if (showError) showToast('登录已过期,请重新登录')
|
||
}
|
||
return new ApiError(code, msg || '登录已过期')
|
||
}
|
||
|
||
// 登录失效 / 账号禁用后的跳转处理(由 app.ts 注入,避免循环依赖)
|
||
let onAuthExpired: (() => void) | null = null
|
||
export function setAuthExpiredHandler(handler: () => void): void {
|
||
onAuthExpired = handler
|
||
}
|
||
|
||
// 单例刷新锁,避免并发请求同时刷新
|
||
let refreshingPromise: Promise<boolean> | null = null
|
||
|
||
function doRefresh(): Promise<boolean> {
|
||
if (!refreshingPromise) {
|
||
refreshingPromise = new Promise<boolean>((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 },
|
||
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,
|
||
})
|
||
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<T = any>(options: RequestOptions): Promise<T> {
|
||
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<T>((resolve, reject) => {
|
||
// 未登录:直接失败,不发请求,避免无 token 时批量 401 引发重定向风暴
|
||
if (needAuth && !auth.isLoggedIn()) {
|
||
reject(new ApiError(NOT_LOGIN_CODE, '未登录'))
|
||
return
|
||
}
|
||
|
||
const doCall = (retried = false): void => {
|
||
const finalHeader: Record<string, any> = {
|
||
'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'))
|
||
},
|
||
})
|
||
}
|
||
|
||
// token 已过期:先静默刷新再发请求,省掉一次必然失败的 401 往返
|
||
// (刷新失败也不直接拒绝:仍发一次,由后端判定,避免网络抖动就把用户踢下线)
|
||
if (needAuth && auth.isTokenExpired()) {
|
||
doRefresh().then(() => {
|
||
doCall()
|
||
})
|
||
return
|
||
}
|
||
|
||
doCall()
|
||
})
|
||
}
|
||
|
||
export interface UploadOptions {
|
||
showError?: boolean // 默认 true,业务错误自动 toast
|
||
timeout?: number // 默认 30s
|
||
silentAuth?: boolean // 非关键上传:401 只失败,不触发全局跳登录
|
||
}
|
||
|
||
// 文件上传(multipart,字段名 file)
|
||
export function uploadFile<T = any>(
|
||
filePath: string,
|
||
name = 'file',
|
||
url = '/clothes/upload',
|
||
options: UploadOptions = {},
|
||
): Promise<T> {
|
||
const { showError = true, timeout = UPLOAD_TIMEOUT, silentAuth = false } = options
|
||
|
||
return new Promise<T>((resolve, reject) => {
|
||
// 未登录:直接失败,不发请求
|
||
if (!auth.isLoggedIn()) {
|
||
reject(new ApiError(NOT_LOGIN_CODE, '未登录'))
|
||
return
|
||
}
|
||
|
||
const doUpload = (retried = false): void => {
|
||
const header: Record<string, any> = {}
|
||
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()
|
||
})
|
||
}
|