Files
zt-closet-wxmini-frontend/miniprogram/utils/request.ts
T
2026-09-13 01:27:21 +08:00

256 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 === 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<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 },
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 = (): 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,
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
}
// 非关键请求(红点轮询、额度展示等)静默失败:
// 只 reject,不动登录态、不跳转,避免把已登录用户悄悄踢成未登录
if (!silentAuth) {
auth.clearTokens()
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<T = any>(filePath: string, name = 'file', url = '/clothes/upload'): Promise<T> {
const token = auth.getAccessToken()
const header: Record<string, any> = {}
if (token) header['Authorization'] = `Bearer ${token}`
return new Promise<T>((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'))
},
})
})
}