feat: 前端 401 拦截器 — access token 过期时自动刷新并重试原请求

api.ts:
- 增加 setAuthCallbacks 回调注入机制(避免 api/auth 循环依赖)
- request() 对非公开路径自动附加 Authorization header
- 收到 401 时自动触发 refresh token 刷新,成功后重试原请求
- 并发保护:多个 401 只触发一次 refresh,其余等待同一 Promise
- refreshTokenDirect 内部方法绕过 401 拦截避免递归

auth.tsx:
- 用 ref 保存 persistAuth/scheduleRefresh 最新引用(避免闭包陈旧)
- 初始化时调用 setAuthCallbacks 注入认证回调
This commit is contained in:
hhs
2026-06-20 14:57:27 +08:00
parent ea70d2efc6
commit d78cdb509d
2 changed files with 131 additions and 3 deletions

View File

@@ -1,6 +1,7 @@
// ============================================================ // ============================================================
// HTTP API 客户端 // HTTP API 客户端
// 职责:封装 REST API 请求auth、conversations 等) // 职责:封装 REST API 请求auth、conversations 等)
// 内置 401 拦截 + 自动刷新 + 重试机制
// ============================================================ // ============================================================
const API_BASE = "/api"; const API_BASE = "/api";
@@ -11,9 +12,69 @@ interface ApiResponse<T> {
status: number; status: number;
} }
// ---- 认证回调(由 AuthProvider 注入,避免循环依赖) ----
interface AuthCallbacks {
getAccessToken: () => string | null;
getRefreshToken: () => string | null;
onRefreshSuccess: (user: AuthUser, accessToken: string, refreshToken: string) => void;
onRefreshFailed: () => void;
}
let authCallbacks: AuthCallbacks | null = null;
let refreshPromise: Promise<boolean> | null = null;
/** 由 AuthProvider 在初始化时调用,注入认证回调。 */
export function setAuthCallbacks(callbacks: AuthCallbacks): void {
authCallbacks = callbacks;
}
/** 不需要认证的公开路径。 */
const PUBLIC_PATHS = new Set([
"/auth/register",
"/auth/login",
"/auth/refresh",
]);
function isPublicPath(path: string): boolean {
return PUBLIC_PATHS.has(path);
}
/** 尝试用 refresh token 换取新的 access token。 */
async function doRefresh(): Promise<boolean> {
const rt = authCallbacks?.getRefreshToken();
if (!rt) return false;
const res = await refreshTokenDirect(rt);
if (res.data) {
authCallbacks?.onRefreshSuccess(
res.data.user,
res.data.access_token,
res.data.refresh_token
);
return true;
}
authCallbacks?.onRefreshFailed();
return false;
}
/** 带并发保护的刷新:多个 401 只触发一次 refresh。 */
async function refreshWithLock(): Promise<boolean> {
if (!refreshPromise) {
refreshPromise = doRefresh().finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
// ---- 核心请求函数 ----
async function request<T>( async function request<T>(
path: string, path: string,
options: RequestInit = {} options: RequestInit = {},
_retry = false
): Promise<ApiResponse<T>> { ): Promise<ApiResponse<T>> {
const url = `${API_BASE}${path}`; const url = `${API_BASE}${path}`;
const headers: Record<string, string> = { const headers: Record<string, string> = {
@@ -21,6 +82,14 @@ async function request<T>(
...(options.headers as Record<string, string>), ...(options.headers as Record<string, string>),
}; };
// 对非公开路径自动附加 access token
if (!isPublicPath(path) && !headers["Authorization"]) {
const token = authCallbacks?.getAccessToken();
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
}
try { try {
const res = await fetch(url, { ...options, headers }); const res = await fetch(url, { ...options, headers });
const status = res.status; const status = res.status;
@@ -31,6 +100,14 @@ async function request<T>(
const body = await res.json(); const body = await res.json();
// 401 拦截:尝试刷新 token 后重试(仅重试一次)
if (res.status === 401 && !_retry && !isPublicPath(path) && authCallbacks) {
const refreshed = await refreshWithLock();
if (refreshed) {
return request<T>(path, options, true);
}
}
if (!res.ok) { if (!res.ok) {
return { return {
error: { code: body.error || "UNKNOWN", message: body.message || "请求失败" }, error: { code: body.error || "UNKNOWN", message: body.message || "请求失败" },
@@ -85,6 +162,33 @@ export async function login(
}); });
} }
/** 内部用的 refresh 请求,不经过 401 拦截(避免递归)。 */
async function refreshTokenDirect(
refresh_token: string
): Promise<ApiResponse<AuthResponse>> {
const url = `${API_BASE}/auth/refresh`;
try {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token }),
});
const body = await res.json();
if (!res.ok) {
return {
error: { code: body.error || "UNKNOWN", message: body.message || "请求失败" },
status: res.status,
};
}
return { data: body as AuthResponse, status: res.status };
} catch {
return {
error: { code: "NETWORK_ERROR", message: "网络连接失败" },
status: 0,
};
}
}
export async function refreshToken( export async function refreshToken(
refresh_token: string refresh_token: string
): Promise<ApiResponse<AuthResponse>> { ): Promise<ApiResponse<AuthResponse>> {
@@ -96,11 +200,11 @@ export async function refreshToken(
export async function logout( export async function logout(
accessToken: string, accessToken: string,
refreshToken: string refreshTokenStr: string
): Promise<ApiResponse<{ message: string }>> { ): Promise<ApiResponse<{ message: string }>> {
return request<{ message: string }>("/auth/logout", { return request<{ message: string }>("/auth/logout", {
method: "POST", method: "POST",
headers: authHeaders(accessToken), headers: authHeaders(accessToken),
body: JSON.stringify({ refresh_token: refreshToken }), body: JSON.stringify({ refresh_token: refreshTokenStr }),
}); });
} }

View File

@@ -15,6 +15,7 @@ import {
} from "react"; } from "react";
import * as api from "./api"; import * as api from "./api";
import type { AuthUser } from "./api"; import type { AuthUser } from "./api";
import { setAuthCallbacks } from "./api";
import { import {
clearAuth, clearAuth,
loadAccessToken, loadAccessToken,
@@ -108,6 +109,29 @@ export function AuthProvider({ children }: { children: ReactNode }) {
[clearRefreshTimer, persistAuth] [clearRefreshTimer, persistAuth]
); );
// 用 ref 保存最新回调,供 API 层 401 拦截器使用(避免闭包陈旧)
const persistAuthRef = useRef(persistAuth);
const scheduleRefreshRef = useRef(scheduleRefresh);
persistAuthRef.current = persistAuth;
scheduleRefreshRef.current = scheduleRefresh;
// 注册 API 层认证回调(用于 401 拦截器)
useEffect(() => {
setAuthCallbacks({
getAccessToken: () => loadAccessToken(),
getRefreshToken: () => loadRefreshToken(),
onRefreshSuccess: (u, at, rt) => {
persistAuthRef.current(u, at, rt);
scheduleRefreshRef.current(at);
},
onRefreshFailed: () => {
clearAuth();
setUser(null);
setAccessToken(null);
},
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// 初始化:检查已有 token 并尝试刷新 // 初始化:检查已有 token 并尝试刷新
useEffect(() => { useEffect(() => {
const init = async () => { const init = async () => {