diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ea11424..21e6918 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,6 +1,7 @@ // ============================================================ // HTTP API 客户端 // 职责:封装 REST API 请求(auth、conversations 等) +// 内置 401 拦截 + 自动刷新 + 重试机制 // ============================================================ const API_BASE = "/api"; @@ -11,9 +12,69 @@ interface ApiResponse { 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 | 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 { + 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 { + if (!refreshPromise) { + refreshPromise = doRefresh().finally(() => { + refreshPromise = null; + }); + } + return refreshPromise; +} + +// ---- 核心请求函数 ---- + async function request( path: string, - options: RequestInit = {} + options: RequestInit = {}, + _retry = false ): Promise> { const url = `${API_BASE}${path}`; const headers: Record = { @@ -21,6 +82,14 @@ async function request( ...(options.headers as Record), }; + // 对非公开路径自动附加 access token + if (!isPublicPath(path) && !headers["Authorization"]) { + const token = authCallbacks?.getAccessToken(); + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + } + try { const res = await fetch(url, { ...options, headers }); const status = res.status; @@ -31,6 +100,14 @@ async function request( const body = await res.json(); + // 401 拦截:尝试刷新 token 后重试(仅重试一次) + if (res.status === 401 && !_retry && !isPublicPath(path) && authCallbacks) { + const refreshed = await refreshWithLock(); + if (refreshed) { + return request(path, options, true); + } + } + if (!res.ok) { return { 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> { + 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( refresh_token: string ): Promise> { @@ -96,11 +200,11 @@ export async function refreshToken( export async function logout( accessToken: string, - refreshToken: string + refreshTokenStr: string ): Promise> { return request<{ message: string }>("/auth/logout", { method: "POST", headers: authHeaders(accessToken), - body: JSON.stringify({ refresh_token: refreshToken }), + body: JSON.stringify({ refresh_token: refreshTokenStr }), }); } diff --git a/frontend/src/lib/auth.tsx b/frontend/src/lib/auth.tsx index b270f6d..6c8f51c 100644 --- a/frontend/src/lib/auth.tsx +++ b/frontend/src/lib/auth.tsx @@ -15,6 +15,7 @@ import { } from "react"; import * as api from "./api"; import type { AuthUser } from "./api"; +import { setAuthCallbacks } from "./api"; import { clearAuth, loadAccessToken, @@ -108,6 +109,29 @@ export function AuthProvider({ children }: { children: ReactNode }) { [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 并尝试刷新 useEffect(() => { const init = async () => {