SEC_Warehouse/src/http/client.ts

129 lines
4.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 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/user'
: 'https://PROD_PLACEHOLDER/api/user'
// 后端约定的未登录业务码(占位 '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
// message 为 http 层附加的 200 内子状态(needUserInfo/noMobile 等)
Content: T & { message?: string }
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 注入:后端约定自定义 token 头(无 Bearer 前缀,联调实测)
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...config.headers
}
if (config.needAuth !== false) {
if (tokenAtStart) headers.token = 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) {
// Taro 网络 API reject 的是 { errMsg } 形状对象而非 Error 实例,需兼容读取以便透出真实原因
const raw = e instanceof Error ? e.message : (e as { errMsg?: string })?.errMsg
console.error(`[http] ${method} ${url} 网络失败:`, raw)
throw new ApiError(raw || '网络异常')
}
console.log(`[http] ${method} ${url} ←`, res.statusCode, res.data)
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)
}
// 200 成功内的子状态(Message 携带,如 needUserInfo/noMobile),附到返回值供调用方分支处理
const subState = body.Message
if (subState && subState !== '') {
console.log(`[http] ${url} 响应 Message=${subState}`, body.Content)
;(body.Content as Record<string, unknown>).message = subState
}
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' })