=== 现象 === 打开 /login 页面一直加载、闪屏,无法登录。 线上访问日志:10 分钟内 /api/v1/system/license 被请求 **1566 次,全部 401**(约 2.6 次/秒)。 === 根因(三处叠加)=== 1) LicenseProvider 挂在 App 根部,包住**所有**路由(含 /login), 挂载即请求鉴权接口 /system/license;登录页没有 token → 401。 2) http.ts 的 redirectToLogin() 在已处于 /login 时仍然执行 window.location.href = '/login' —— **给 href 赋同值也会整页重载**。 3) 重载 → App 重新挂载 → LicenseProvider 再请求 → 再 401 → 再重载。 形成"请求→401→整页重载→请求"的死循环,用户看到的就是转圈+闪屏。 === 修复(三处,防御性叠加)=== 1) useLicense:没有 accessToken 时**不请求**;有 token 才拉。 并用 1 秒一次的低成本探测(只读 localStorage,不发请求)在登录成功 (token 出现)后补拉一次,避免登录后授权状态缺失。 2) licenseApi.status 使用 silent: true —— 401 不触发"清凭证+跳登录"的全局副作用。 3) redirectToLogin:已在 /login 时直接 return,不再重载页面。 === 验证 === tsc 通过;发布后 /login 不再产生 401 轮询(见发布后回归)
264 lines
8.7 KiB
TypeScript
264 lines
8.7 KiB
TypeScript
// HTTP 请求引擎 — 统一 fetch 封装:超时/取消、token 注入、结构化错误、
|
||
// 401 单飞刷新 + 队列重放(见开发标准 03 章)。组件一律通过 api 层调用,禁止直接 fetch。
|
||
import type { ApiResponse } from '@/types';
|
||
import {
|
||
getAccessToken,
|
||
getRefreshToken,
|
||
saveTokenPair,
|
||
clearAuth,
|
||
} from '@/lib/auth-storage';
|
||
|
||
const API_BASE = import.meta.env.VITE_API_URL || '';
|
||
const DEFAULT_TIMEOUT = 30000; // 30 秒默认超时
|
||
const REFRESH_TIMEOUT = 10000; // 刷新 token 接口独立超时
|
||
|
||
/** 结构化 API 错误:status 为 HTTP 状态码(网络/超时等非 HTTP 错误为 0),code 为后端业务码 */
|
||
export class ApiError extends Error {
|
||
readonly status: number;
|
||
readonly code?: number;
|
||
|
||
constructor(message: string, status = 0, code?: number) {
|
||
super(message);
|
||
this.name = 'ApiError';
|
||
this.status = status;
|
||
this.code = code;
|
||
}
|
||
}
|
||
|
||
export interface RequestOptions extends RequestInit {
|
||
timeout?: number;
|
||
/** 静默模式:401 失效不触发清凭证/跳登录的全局副作用,仅抛错(登录、轮询接口用) */
|
||
silent?: boolean;
|
||
/** 内部标记:刷新 token 后的重放请求,防止 401 无限循环 */
|
||
retried?: boolean;
|
||
}
|
||
|
||
// —— 401 单飞刷新状态 ——
|
||
// 并发 N 个 401 只触发一次刷新;刷新期间的请求进入队列,拿到新 token 后统一重放。
|
||
let isRefreshing = false;
|
||
let refreshQueue: Array<(token: string | null) => void> = [];
|
||
|
||
// 这些端点自身的 401 不触发登出跳转(错误密码、refresh 失效等场景)
|
||
const AUTH_ENDPOINTS = ['/auth/login', '/auth/refresh'];
|
||
|
||
async function doRefresh(): Promise<string | null> {
|
||
const refreshToken = getRefreshToken();
|
||
if (!refreshToken) return null;
|
||
try {
|
||
const controller = new AbortController();
|
||
const timer = setTimeout(() => controller.abort(), REFRESH_TIMEOUT);
|
||
const response = await fetch(`${API_BASE}/api/v1/auth/refresh`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ refreshToken }),
|
||
signal: controller.signal,
|
||
});
|
||
clearTimeout(timer);
|
||
if (!response.ok) return null;
|
||
const body = (await response.json()) as ApiResponse<{
|
||
accessToken: string;
|
||
refreshToken: string;
|
||
expiresAt: number;
|
||
}>;
|
||
if (body.code !== 0 || !body.data?.accessToken) return null;
|
||
saveTokenPair(body.data);
|
||
return body.data.accessToken;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
let redirecting = false;
|
||
|
||
/** 清凭证并防抖跳转登录(并发失效只产生一次跳转,携带当前路径供回跳) */
|
||
function redirectToLogin(): void {
|
||
clearAuth();
|
||
// 已经在登录页就**不要再跳**:给 location.href 赋同值也会触发整页重载,
|
||
// 而重载会重新挂载组件、再发一次需要鉴权的请求 → 又 401 → 再重载。
|
||
// 这正是登录页"一直加载 + 闪屏"的根本原因(实测 10 分钟 1566 次 401)。
|
||
if (window.location.pathname.startsWith('/login')) {
|
||
return;
|
||
}
|
||
if (redirecting) return;
|
||
redirecting = true;
|
||
const current = window.location.pathname + window.location.search;
|
||
window.location.href = `/login?redirect=${encodeURIComponent(current)}`;
|
||
}
|
||
|
||
/** 单次 HTTP 往返:组头、超时、取消、解包。401 以 ApiError(status=401) 抛出,由上层统一处理 */
|
||
async function doRequest<T>(url: string, options: RequestOptions): Promise<T> {
|
||
const { timeout = DEFAULT_TIMEOUT, signal, ...fetchOptions } = options;
|
||
|
||
const token = getAccessToken();
|
||
const headers: Record<string, string> = {
|
||
'Content-Type': 'application/json',
|
||
...((fetchOptions.headers as Record<string, string>) || {}),
|
||
};
|
||
if (token) {
|
||
headers['Authorization'] = `Bearer ${token}`;
|
||
}
|
||
|
||
const fullUrl = `${API_BASE}/api/v1${url}`;
|
||
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||
const onExternalAbort = () => controller.abort();
|
||
if (signal) {
|
||
signal.addEventListener('abort', onExternalAbort);
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(fullUrl, {
|
||
...fetchOptions,
|
||
headers,
|
||
signal: controller.signal,
|
||
});
|
||
|
||
if (response.status === 401) {
|
||
throw new ApiError('未授权或登录已过期', 401);
|
||
}
|
||
|
||
const contentType = response.headers.get('content-type');
|
||
if (!contentType || !contentType.includes('application/json')) {
|
||
const text = await response.text();
|
||
throw new ApiError(
|
||
`服务端返回了非 JSON 响应(${response.status}):${text.substring(0, 200)}`,
|
||
response.status
|
||
);
|
||
}
|
||
|
||
const body: unknown = await response.json();
|
||
// 多数接口走 {code, data, message} 包装;/health 等基础端点直接返回裸 JSON
|
||
if (
|
||
body !== null &&
|
||
typeof body === 'object' &&
|
||
'code' in body &&
|
||
typeof (body as ApiResponse<T>).code === 'number'
|
||
) {
|
||
const data = body as ApiResponse<T>;
|
||
if (data.code !== 0) {
|
||
throw new ApiError(data.message || '请求失败', response.status, data.code);
|
||
}
|
||
return data.data;
|
||
}
|
||
return body as T;
|
||
} catch (err) {
|
||
if (err instanceof ApiError) throw err;
|
||
if (err instanceof Error && err.name === 'AbortError') {
|
||
if (controller.signal.aborted && !signal?.aborted) {
|
||
throw new ApiError(`请求超时(${timeout}ms)`);
|
||
}
|
||
throw new ApiError('请求已取消');
|
||
}
|
||
if (err instanceof TypeError) {
|
||
throw new ApiError('无法连接到后端服务,请确认 API 服务已启动');
|
||
}
|
||
throw err instanceof Error ? new ApiError(err.message) : new ApiError(String(err));
|
||
} finally {
|
||
clearTimeout(timeoutId);
|
||
if (signal) {
|
||
signal.removeEventListener('abort', onExternalAbort);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 核心请求函数:在 doRequest 之上处理 401 —— 有 refreshToken 时单飞刷新并重放;
|
||
* 刷新失败或无 refreshToken 时清凭证跳登录(silent 请求除外)。
|
||
*/
|
||
export async function request<T>(
|
||
url: string,
|
||
options: RequestOptions = {}
|
||
): Promise<T> {
|
||
try {
|
||
return await doRequest<T>(url, options);
|
||
} catch (err) {
|
||
if (!(err instanceof ApiError) || err.status !== 401) throw err;
|
||
|
||
// 静默请求 / 鉴权端点自身 / 已重放过:不再刷新、不再跳转,直接失败
|
||
if (options.silent || options.retried || AUTH_ENDPOINTS.some((p) => url.startsWith(p))) {
|
||
throw err;
|
||
}
|
||
|
||
if (!getRefreshToken()) {
|
||
redirectToLogin();
|
||
throw new ApiError('登录已过期,请重新登录', 401);
|
||
}
|
||
|
||
// 已有刷新在进行中:挂入队列,等新 token 下发后重放本请求
|
||
if (isRefreshing) {
|
||
return new Promise<T>((resolve, reject) => {
|
||
refreshQueue.push((token) => {
|
||
if (!token) {
|
||
reject(new ApiError('登录已过期,请重新登录', 401));
|
||
return;
|
||
}
|
||
request<T>(url, { ...options, retried: true }).then(resolve, reject);
|
||
});
|
||
});
|
||
}
|
||
|
||
isRefreshing = true;
|
||
let newToken: string | null = null;
|
||
try {
|
||
newToken = await doRefresh();
|
||
} finally {
|
||
isRefreshing = false;
|
||
const queue = refreshQueue;
|
||
refreshQueue = [];
|
||
queue.forEach((cb) => cb(newToken));
|
||
}
|
||
|
||
if (!newToken) {
|
||
redirectToLogin();
|
||
throw new ApiError('登录已过期,请重新登录', 401);
|
||
}
|
||
return request<T>(url, { ...options, retried: true });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 语义化 HTTP 助手:自动处理 method 与 JSON 序列化,减少调用样板。
|
||
* @example
|
||
* http.get<Cluster[]>('/clusters')
|
||
* http.post<Cluster>('/clusters', { name, ... })
|
||
* http.put(`/clusters/${id}`, updates)
|
||
* http.delete(`/clusters/${id}`)
|
||
*/
|
||
export const http = {
|
||
get: <T>(url: string, options?: RequestOptions) =>
|
||
request<T>(url, { ...options, method: 'GET' }),
|
||
post: <T>(url: string, body?: unknown, options?: RequestOptions) =>
|
||
request<T>(url, {
|
||
...options,
|
||
method: 'POST',
|
||
body: body === undefined ? undefined : JSON.stringify(body),
|
||
}),
|
||
put: <T>(url: string, body?: unknown, options?: RequestOptions) =>
|
||
request<T>(url, {
|
||
...options,
|
||
method: 'PUT',
|
||
body: body === undefined ? undefined : JSON.stringify(body),
|
||
}),
|
||
delete: <T>(url: string, options?: RequestOptions) =>
|
||
request<T>(url, { ...options, method: 'DELETE' }),
|
||
};
|
||
|
||
/** 列表查询参数(分页) */
|
||
export interface ListParams {
|
||
page?: number;
|
||
pageSize?: number;
|
||
}
|
||
|
||
/** 列表查询参数:统一编码拼接,避免模板字符串拼 query 无转义 */
|
||
export function buildQuery(params: ListParams): string {
|
||
const search = new URLSearchParams();
|
||
for (const [key, value] of Object.entries(params)) {
|
||
if (value !== undefined && value !== '') {
|
||
search.set(key, String(value));
|
||
}
|
||
}
|
||
const qs = search.toString();
|
||
return qs ? `?${qs}` : '';
|
||
}
|