Files
LokiStack/frontend/src/api/http.ts
T
opsx f91078f814
CI / Backend (go vet + test) (push) Waiting to run
CI / Frontend (lint + build + test) (push) Waiting to run
fix(auth): 登录页无限重载闪屏 —— 401 在 /login 上仍整页跳转导致死循环
=== 现象 ===
打开 /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 轮询(见发布后回归)
2026-11-03 03:25:58 +00:00

264 lines
8.7 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.
// 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}` : '';
}