diff --git a/apps/web/src/api/auth/tokenRefresh.ts b/apps/web/src/api/auth/tokenRefresh.ts new file mode 100644 index 000000000..061dc7a19 --- /dev/null +++ b/apps/web/src/api/auth/tokenRefresh.ts @@ -0,0 +1,82 @@ +/** + * 主动 Token 刷新模块 + * + * 在 access_token 过期前主动刷新,避免 API 请求触发 401。 + * JWT payload 是 base64 编码的 JSON,无需第三方库即可解码。 + */ +import { useAuthStore } from "@/store/authStore" +import { refreshAccessToken } from "./login" + +let refreshTimer: ReturnType | null = null + +/** 提前刷新的缓冲时间(秒) */ +const REFRESH_BUFFER_SECONDS = 60 + +/** + * 解码 JWT payload(不验签,仅读取 exp 字段) + */ +function decodeJwtPayload(token: string): { exp?: number } | null { + try { + const parts = token.split(".") + if (parts.length !== 3) return null + // JWT 使用 base64url 编码,需要转换为标准 base64 + const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/") + const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4) + const decoded = atob(padded) + return JSON.parse(decoded) + } catch { + return null + } +} + +/** + * 取消已调度的主动刷新 + */ +export function cancelProactiveRefresh(): void { + if (refreshTimer) { + clearTimeout(refreshTimer) + refreshTimer = null + } +} + +/** + * 调度主动刷新:在 token 过期前 REFRESH_BUFFER_SECONDS 秒自动刷新 + */ +export function scheduleProactiveRefresh(): void { + cancelProactiveRefresh() + + const accessToken = localStorage.getItem("access_token") + const refreshTokenValue = useAuthStore.getState().refreshToken + + if (!accessToken || !refreshTokenValue) return + + const payload = decodeJwtPayload(accessToken) + if (!payload?.exp) return + + const now = Math.floor(Date.now() / 1000) + const secondsUntilExpiry = payload.exp - now + + // 如果 token 已经过期或即将在缓冲时间内过期,立即刷新 + const delaySeconds = Math.max(secondsUntilExpiry - REFRESH_BUFFER_SECONDS, 0) + + refreshTimer = setTimeout(async () => { + try { + const data = await refreshAccessToken(refreshTokenValue) + const newAccessToken = data.access_token + const newRefreshToken = data.refresh_token ?? refreshTokenValue + + // 更新 Zustand store + localStorage + useAuthStore + .getState() + .setAuth(useAuthStore.getState().user!, newAccessToken, newRefreshToken) + + // 递归调度下一次刷新 + scheduleProactiveRefresh() + } catch { + // 刷新失败 → 清除认证状态,跳转登录页 + cancelProactiveRefresh() + useAuthStore.getState().clearAuth() + window.location.href = "/login" + } + }, delaySeconds * 1000) +} diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 6f1aa2a5c..331ddb46d 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -6,6 +6,7 @@ import axios, { AxiosError, InternalAxiosRequestConfig } from "axios" import { message } from "antd" import { useAuthStore } from "@/store/authStore" import { refreshAccessToken } from "./auth" +import { scheduleProactiveRefresh, cancelProactiveRefresh } from "./auth/tokenRefresh" // 创建 Axios 实例 const apiClient = axios.create({ @@ -109,6 +110,9 @@ apiClient.interceptors.response.use( // 处理排队的请求 processQueue(null, newAccessToken) + // 重新调度主动刷新(基于新 token 的过期时间) + scheduleProactiveRefresh() + // 重试原始请求 if (originalRequest.headers) { originalRequest.headers.Authorization = `Bearer ${newAccessToken}` @@ -116,6 +120,7 @@ apiClient.interceptors.response.use( return apiClient(originalRequest) } catch (refreshError) { // 刷新失败 → 登出 + cancelProactiveRefresh() processQueue(refreshError, null) useAuthStore.getState().clearAuth() window.location.href = "/" diff --git a/apps/web/src/hooks/useAuth.ts b/apps/web/src/hooks/useAuth.ts index 5fa6ba802..d8ad50c43 100644 --- a/apps/web/src/hooks/useAuth.ts +++ b/apps/web/src/hooks/useAuth.ts @@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useNavigate } from "react-router-dom" import * as authApi from "@/api/auth" +import { scheduleProactiveRefresh, cancelProactiveRefresh } from "@/api/auth/tokenRefresh" import { useAuthStore } from "@/store/authStore" // 登录 Hook @@ -31,6 +32,9 @@ export const useLogin = () => { const user = await authApi.getCurrentUser() setAuth(user, data.access_token, refreshToken) + // 启动主动 token 刷新,避免后续请求触发 401 + scheduleProactiveRefresh() + // 跳转到登录前页面或仪表盘(与 Login.tsx onFinish 保持一致) const redirect = localStorage.getItem("login_redirect") || "/app/dashboard" localStorage.removeItem("login_redirect") @@ -73,6 +77,9 @@ export const useWechatCallback = () => { const user = await authApi.getCurrentUser() setAuth(user, result.access_token, result.refresh_token) + // 启动主动 token 刷新 + scheduleProactiveRefresh() + return { ...result, user } } @@ -121,6 +128,7 @@ export const useLogout = () => { } catch (error) { // 即使登出失败也清除本地状态 } finally { + cancelProactiveRefresh() clearAuth() queryClient.clear() navigate("/") diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 084f245bc..a4253a61a 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -9,6 +9,13 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { ConfigProvider, App as AntApp } from "antd" import zhCN from "antd/locale/zh_CN" import router from "./router" +import { scheduleProactiveRefresh } from "./api/auth/tokenRefresh" + +// 应用启动时,如果用户已登录,立即调度主动 token 刷新 +// 这样可以在 token 过期前自动刷新,避免 API 请求触发 401 +if (localStorage.getItem("access_token")) { + scheduleProactiveRefresh() +} import "./index.css" import "./styles/global.css" diff --git a/apps/web/src/test/hooks/useAuth.test.tsx b/apps/web/src/test/hooks/useAuth.test.tsx index e517f81f9..9a0d0eb4b 100644 --- a/apps/web/src/test/hooks/useAuth.test.tsx +++ b/apps/web/src/test/hooks/useAuth.test.tsx @@ -31,14 +31,27 @@ vi.mock("react-router-dom", async () => { }) vi.mock("@/store/authStore", () => ({ - useAuthStore: (selector: any) => - selector({ - user: { id: "1", username: "testuser" }, - token: "mock-token", - isAuthenticated: true, - setAuth: mockSetAuth, - clearAuth: mockClearAuth, - }), + useAuthStore: Object.assign( + (selector: any) => + selector({ + user: { id: "1", username: "testuser" }, + token: "mock-token", + refreshToken: "mock-refresh-token", + isAuthenticated: true, + setAuth: mockSetAuth, + clearAuth: mockClearAuth, + }), + { + getState: () => ({ + user: { id: "1", username: "testuser" }, + token: "mock-token", + refreshToken: "mock-refresh-token", + isAuthenticated: true, + setAuth: mockSetAuth, + clearAuth: mockClearAuth, + }), + }, + ), })) vi.mock("@tanstack/react-query", () => ({