117 lines
4.0 KiB
TypeScript
117 lines
4.0 KiB
TypeScript
import Taro from '@tarojs/taro'
|
||
import { store } from '@/store'
|
||
import { clearAuth } from '@/store/authSlice'
|
||
import { ROUTES } from '@/constants/routes'
|
||
import { reLaunch } from '@/utils/navigation'
|
||
import { ApiError, BusinessError, AuthExpiredError } from './errors'
|
||
|
||
// 测试环境(内网,开发者工具需勾选「不校验合法域名」);正式域名为占位符,上线前必须替换(spec D2)
|
||
const BASE_URL = process.env.NODE_ENV === 'development'
|
||
? 'http://192.168.10.11:39101/api/biz'
|
||
: 'https://PROD_PLACEHOLDER/api/biz'
|
||
|
||
// 后端约定的未登录业务码(占位 '401',联调确认后更新——spec D5/Open Questions 1)
|
||
const UNAUTHORIZED_CODES = new Set(['401'])
|
||
|
||
// 防重入:跳转登录页进行中不重复触发(并发 401 只踢一次)
|
||
let redirectingToLogin = false
|
||
|
||
function redirectToLogin(tokenAtStart: string | null): void {
|
||
if (redirectingToLogin) return
|
||
// 仅当发起请求时的 token 仍是当前登录态才踢,避免在途旧请求 401 误清重新登录后的新会话
|
||
// (token 为 null 的未登录请求 401 时 null === null 成立会踢,属合理行为)
|
||
if (store.getState().auth.token !== tokenAtStart) return
|
||
redirectingToLogin = true
|
||
store.dispatch(clearAuth())
|
||
reLaunch(ROUTES.LOGIN)
|
||
.catch((e: unknown) => {
|
||
// 跳转失败显式记录,避免 unhandled rejection 噪音(debug-first:不静默吞)
|
||
console.error('[http] 踢回登录页失败', e)
|
||
})
|
||
.finally(() => {
|
||
redirectingToLogin = false
|
||
})
|
||
}
|
||
|
||
export interface RequestConfig {
|
||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||
headers?: Record<string, string>
|
||
timeout?: number
|
||
needAuth?: boolean // 默认 true;登录接口自身传 false
|
||
}
|
||
|
||
// 后端统一响应格式(大写 key、字符串状态码)
|
||
export interface ApiEnvelope<T> {
|
||
Code: string
|
||
Message?: string
|
||
Content: T
|
||
RecordCount?: number
|
||
PageIndex?: number
|
||
PageSize?: number
|
||
}
|
||
|
||
export async function request<T>(
|
||
url: string,
|
||
params?: Record<string, unknown>,
|
||
config: RequestConfig = {}
|
||
): Promise<T> {
|
||
const method = config.method ?? 'GET'
|
||
const timeout = config.timeout ?? 10000
|
||
// 请求发起时刻的 token 快照:headers 注入与 401 陈旧竞态判断共用同一份
|
||
const tokenAtStart = store.getState().auth.token
|
||
|
||
// token 注入:从 authSlice 读取
|
||
const headers: Record<string, string> = {
|
||
'Content-Type': 'application/json',
|
||
...config.headers
|
||
}
|
||
if (config.needAuth !== false) {
|
||
if (tokenAtStart) headers.Authorization = `Bearer ${tokenAtStart}`
|
||
}
|
||
|
||
let res: Taro.request.SuccessCallbackResult<ApiEnvelope<T>>
|
||
try {
|
||
res = await Taro.request({
|
||
url: BASE_URL + url,
|
||
method,
|
||
// Taro.request 的 GET 参数同样走 data,运行时自动序列化为 query string
|
||
data: params,
|
||
dataType: 'json',
|
||
timeout,
|
||
header: headers
|
||
})
|
||
} catch (e) {
|
||
throw new ApiError(e instanceof Error ? e.message : '网络异常')
|
||
}
|
||
|
||
if (res.statusCode !== 200) {
|
||
if (res.statusCode === 401) {
|
||
redirectToLogin(tokenAtStart)
|
||
throw new AuthExpiredError('登录已过期')
|
||
}
|
||
throw new ApiError(`HTTP ${res.statusCode}`, res.statusCode)
|
||
}
|
||
|
||
// 网关返回 200 + 非 envelope 形态(HTML 拦截页/null 等)属传输层异常,不能当业务错分流
|
||
const body = res.data as ApiEnvelope<T> | null | string
|
||
if (body === null || typeof body !== 'object' || body.Code == null) {
|
||
throw new ApiError('响应格式异常', res.statusCode)
|
||
}
|
||
|
||
const bizCode = String(body.Code)
|
||
if (UNAUTHORIZED_CODES.has(bizCode)) {
|
||
redirectToLogin(tokenAtStart)
|
||
throw new AuthExpiredError(body.Message || '登录已过期')
|
||
}
|
||
if (bizCode !== '200') {
|
||
throw new BusinessError(body.Message || '业务错误', bizCode)
|
||
}
|
||
return body.Content
|
||
}
|
||
|
||
request.get = <T>(url: string, params?: Record<string, unknown>, config?: RequestConfig) =>
|
||
request<T>(url, params, { ...config, method: 'GET' })
|
||
|
||
request.post = <T>(url: string, params?: Record<string, unknown>, config?: RequestConfig) =>
|
||
request<T>(url, params, { ...config, method: 'POST' })
|