Files
zt-closet-wxmini-frontend/miniprogram/utils/request.ts
T
2026-09-13 02:28:46 +08:00

363 lines
12 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'
import { logger } from './logger'
// 日志模块名
const LOG = 'request'
// 业务错误对象(携带 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) {
logger.warn(LOG, '登录态失效,清理 token 并跳转登录页, code=', code, 'msg=', msg)
auth.clearTokens()
handleAuthExpired()
if (showError) showToast('登录已过期,请重新登录')
} else {
logger.warn(LOG, '登录态失效但静默处理(不动登录态), 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<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) {
logger.warn(LOG, 'doRefresh 缺少 refresh/access token,放弃刷新')
resolve(false)
return
}
logger.debug(LOG, '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,
})
logger.debug(LOG, 'doRefresh 成功')
resolve(true)
} else {
logger.error(LOG, 'doRefresh 响应异常, code=', body && body.code)
resolve(false)
}
},
fail: () => {
logger.error(LOG, '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) {
logger.debug(LOG, 'redirectToLogin 幂等锁生效,丢弃本次跳转')
return
}
const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : []
const current = pages[pages.length - 1]
if (current && current.route === 'pages/login/login') {
logger.debug(LOG, 'redirectToLogin 已在登录页,跳过')
return
}
logger.info(LOG, '跳转登录页, 当前页=', 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<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()) {
logger.debug(LOG, '未登录直接 reject,不发请求, url=', url)
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}`
}
logger.debug(LOG, '请求发起', method, fullUrl, 'needAuth=', needAuth, 'retried=', retried)
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') {
logger.error(LOG, '响应体无 code(网络层异常)', method, fullUrl, 'statusCode=', res.statusCode)
if (showError) showToast('网络异常,请稍后重试')
reject(new Error('network error'))
return
}
const code = body.code
// 成功
if (code === 0) {
logger.debug(LOG, '请求成功', method, fullUrl)
resolve(body.data as T)
return
}
// Token 失效:刷新后最多重试一次,避免「刷新成功但资源接口持续 401」引发请求风暴
if (needAuth && isAuthExpiredCode(code, res.statusCode)) {
if (retried) {
reject(failAuth(code, body.msg, silentAuth, showError))
return
}
logger.info(LOG, '命中登录失效码,尝试刷新后重试, code=', code, 'url=', fullUrl)
doRefresh().then((ok) => {
if (ok) {
doCall(true)
return
}
// 非关键请求(红点轮询、额度展示等)静默失败:
// 只 reject,不动登录态、不跳转,避免把已登录用户悄悄踢成未登录
reject(failAuth(code, body.msg, silentAuth, showError))
})
return
}
// 账号禁用(HTTP 200,需单独处理)
if (code === 21001) {
logger.error(LOG, '账号被禁用,清理登录态, code=', code)
auth.clearTokens()
handleAuthExpired()
if (showError) showToast(ERROR_MESSAGES[21001] || '账号已被禁用')
reject(new ApiError(code, body.msg || '账号已被禁用'))
return
}
// 其余业务错误
logger.warn(LOG, '业务错误, code=', code, 'url=', fullUrl, 'msg=', body.msg)
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: () => {
logger.error(LOG, 'wx.request 失败(网络层)', method, fullUrl)
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<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()) {
logger.debug(LOG, '未登录直接 reject,不上传, url=', url)
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}`
logger.debug(LOG, '上传发起, url=', url, 'retried=', retried)
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') {
logger.error(LOG, '上传响应解析失败(非 JSON)', url, 'raw=', res.data)
if (showError) showToast('上传失败,请稍后重试')
reject(new Error('upload fail'))
return
}
if (body.code === 0) {
logger.info(LOG, '上传成功, url=', url)
resolve(body.data as T)
return
}
// Token 失效:刷新后最多重试一次
if (isAuthExpiredCode(body.code, res.statusCode)) {
if (retried) {
reject(failAuth(body.code, body.msg, silentAuth, showError))
return
}
logger.info(LOG, '上传命中登录失效码,尝试刷新后重试, code=', body.code)
doRefresh().then((ok) => {
if (ok) {
doUpload(true)
return
}
reject(failAuth(body.code, body.msg, silentAuth, showError))
})
return
}
logger.warn(LOG, '上传业务错误, code=', body.code, 'url=', url, 'msg=', body.msg)
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: () => {
logger.error(LOG, 'wx.uploadFile 失败(网络层), url=', url)
if (showError) showToast('上传失败,请检查网络后重试')
reject(new Error('upload fail'))
},
})
}
doUpload()
})
}