diff --git a/apps/web/src/api/auth/tokenRefresh.ts b/apps/web/src/api/auth/tokenRefresh.ts index 061dc7a19..4c1e1c29b 100644 --- a/apps/web/src/api/auth/tokenRefresh.ts +++ b/apps/web/src/api/auth/tokenRefresh.ts @@ -9,6 +9,9 @@ import { refreshAccessToken } from "./login" let refreshTimer: ReturnType | null = null +/** 正在执行刷新操作的 Promise,防止主动刷新和 401 被动刷新并发竞争 */ +let activeRefreshPromise: Promise | null = null + /** 提前刷新的缓冲时间(秒) */ const REFRESH_BUFFER_SECONDS = 60 @@ -39,14 +42,55 @@ export function cancelProactiveRefresh(): void { } } +/** + * 执行 token 刷新(带并发锁,供主动刷新和被动 401 共用) + * 返回当前刷新操作的 Promise;若已有刷新进行中则复用该 Promise。 + */ +export function executeTokenRefresh(): Promise | null { + // 已有刷新进行中 → 复用 + if (activeRefreshPromise) { + return activeRefreshPromise + } + + const { user, refreshToken: refreshTokenValue } = useAuthStore.getState() + + // 安全检查:user 或 refreshToken 为空时跳过刷新 + if (!user || !refreshTokenValue) { + return null + } + + activeRefreshPromise = (async () => { + try { + const data = await refreshAccessToken(refreshTokenValue) + const newAccessToken = data.access_token + const newRefreshToken = data.refresh_token ?? refreshTokenValue + + // 更新 Zustand store + localStorage + useAuthStore.getState().setAuth(user, newAccessToken, newRefreshToken) + + // 递归调度下一次刷新 + scheduleProactiveRefresh() + } catch { + // 刷新失败 → 清除认证状态,跳转登录页 + cancelProactiveRefresh() + useAuthStore.getState().clearAuth() + window.location.href = "/login" + } finally { + activeRefreshPromise = null + } + })() + + return activeRefreshPromise +} + /** * 调度主动刷新:在 token 过期前 REFRESH_BUFFER_SECONDS 秒自动刷新 */ export function scheduleProactiveRefresh(): void { cancelProactiveRefresh() - const accessToken = localStorage.getItem("access_token") - const refreshTokenValue = useAuthStore.getState().refreshToken + // 统一从 Zustand store 读取(与 setAuth 写入保持一致) + const { accessToken, refreshToken: refreshTokenValue } = useAuthStore.getState() if (!accessToken || !refreshTokenValue) return @@ -59,24 +103,7 @@ export function scheduleProactiveRefresh(): void { // 如果 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" - } + refreshTimer = setTimeout(() => { + executeTokenRefresh() }, delaySeconds * 1000) } diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 331ddb46d..df3f52e16 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -5,8 +5,8 @@ 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" + +import { cancelProactiveRefresh, executeTokenRefresh } from "./auth/tokenRefresh" // 创建 Axios 实例 const apiClient = axios.create({ @@ -98,21 +98,26 @@ apiClient.interceptors.response.use( isRefreshing = true try { - const data = await refreshAccessToken(refreshToken) - const newAccessToken = data.access_token - const newRefreshToken = data.refresh_token ?? refreshToken + // 使用共享的刷新函数(带并发锁 + 安全检查) + const refreshPromise = executeTokenRefresh() + if (!refreshPromise) { + // user 或 refreshToken 为空,无法刷新 + cancelProactiveRefresh() + useAuthStore.getState().clearAuth() + window.location.href = "/" + return Promise.reject(new Error("Unable to refresh: missing user or refresh token")) + } + await refreshPromise - // 更新 Zustand + localStorage - useAuthStore - .getState() - .setAuth(useAuthStore.getState().user!, newAccessToken, newRefreshToken) + // 获取刷新后的新 token + const newAccessToken = useAuthStore.getState().accessToken + if (!newAccessToken) { + return Promise.reject(new Error("Token refresh failed: no new access token")) + } // 处理排队的请求 processQueue(null, newAccessToken) - // 重新调度主动刷新(基于新 token 的过期时间) - scheduleProactiveRefresh() - // 重试原始请求 if (originalRequest.headers) { originalRequest.headers.Authorization = `Bearer ${newAccessToken}` diff --git a/apps/web/src/test/api/client.test.ts b/apps/web/src/test/api/client.test.ts index d38297266..2da52e13c 100644 --- a/apps/web/src/test/api/client.test.ts +++ b/apps/web/src/test/api/client.test.ts @@ -20,9 +20,16 @@ vi.mock("@/api/auth", () => ({ refreshAccessToken: vi.fn(), })) +vi.mock("@/api/auth/tokenRefresh", () => ({ + scheduleProactiveRefresh: vi.fn(), + cancelProactiveRefresh: vi.fn(), + executeTokenRefresh: vi.fn(), +})) + import { message } from "antd" import { useAuthStore } from "@/store/authStore" import { refreshAccessToken } from "@/api/auth" +import { executeTokenRefresh } from "@/api/auth/tokenRefresh" import apiClient from "@/api/client" // 从真实实例取出拦截器回调 @@ -264,23 +271,28 @@ describe("apiClient - 401 token refresh", () => { expect(window.location.href).toBe("/") }) - it("refreshes token on 401 and calls setAuth", async () => { + it("refreshes token on 401 and calls executeTokenRefresh", async () => { const mockSetAuth = vi.fn() - vi.mocked(useAuthStore.getState).mockReturnValue({ + let currentAccessToken = "old-access" + vi.mocked(useAuthStore.getState).mockImplementation(() => ({ user: { id: "1", email: "test@test.com" }, - accessToken: "old-access", + accessToken: currentAccessToken, refreshToken: "old-refresh", isAuthenticated: true, clearAuth: vi.fn(), - setAuth: mockSetAuth, - } as any) - vi.mocked(refreshAccessToken).mockResolvedValue({ - access_token: "new-access", - refresh_token: "new-refresh", - } as never) + setAuth: ((_user: any, newAccess: string, _newRefresh: string) => { + currentAccessToken = newAccess + mockSetAuth(_user, newAccess, _newRefresh) + }) as any, + })) + + // Mock executeTokenRefresh to simulate successful refresh + vi.mocked(executeTokenRefresh).mockImplementation(() => { + currentAccessToken = "new-access" + mockSetAuth({ id: "1", email: "test@test.com" }, "new-access", "new-refresh") + return Promise.resolve() + }) - // 拦截器重试时会调用 apiClient(config),会真的发请求,最终会 reject - // 但我们只关心刷新逻辑是否正确执行 const err = makeAxiosError(401, { detail: "Unauthorized" }) try { @@ -289,31 +301,45 @@ describe("apiClient - 401 token refresh", () => { // 重试会因为没有真实网络而失败,忽略 } - expect(refreshAccessToken).toHaveBeenCalledWith("old-refresh") + expect(executeTokenRefresh).toHaveBeenCalled() expect(mockSetAuth).toHaveBeenCalled() }) it("handles refresh failure by logging out", async () => { const mockClearAuth = vi.fn() - vi.mocked(useAuthStore.getState).mockReturnValue({ + // After executeTokenRefresh fails, it clears auth (sets accessToken to null) + // and redirects to /login. The promise resolves (doesn't reject). + let currentAccessToken: string | null = "old-access" + vi.mocked(useAuthStore.getState).mockImplementation(() => ({ user: { id: "1", email: "test@test.com" }, - accessToken: "old-access", + accessToken: currentAccessToken, refreshToken: "old-refresh", - isAuthenticated: true, - clearAuth: mockClearAuth, + isAuthenticated: currentAccessToken !== null, + clearAuth: (() => { + currentAccessToken = null + mockClearAuth() + window.location.href = "/login" + }) as any, setAuth: vi.fn(), - } as any) - vi.mocked(refreshAccessToken).mockRejectedValue(new Error("refresh failed") as never) + })) + // Mock executeTokenRefresh: simulates failure → clears auth + redirects + vi.mocked(executeTokenRefresh).mockImplementation(() => { + currentAccessToken = null + mockClearAuth() + window.location.href = "/login" + return Promise.resolve() + }) const err = makeAxiosError(401, { detail: "Unauthorized" }) try { await responseErrorInterceptor(err) } catch { - // expected + // expected - rejects because accessToken is null after failed refresh } + expect(executeTokenRefresh).toHaveBeenCalled() expect(mockClearAuth).toHaveBeenCalled() - expect(window.location.href).toBe("/") + expect(window.location.href).toBe("/login") }) })