From 0f321c62c13720f4f624b15a49d73e123cb927e5 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 18 Aug 2026 12:49:05 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20token=E5=88=B7=E6=96=B0=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=BA=90=E7=BB=9F=E4=B8=80+=E9=9D=9E=E7=A9=BA?= =?UTF-8?q?=E6=96=AD=E8=A8=80=E4=BF=AE=E5=A4=8D+=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E7=AB=9E=E6=80=81=E9=94=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1 — 数据源统一: - scheduleProactiveRefresh() 中 accessToken 和 refreshToken 统一从 Zustand store 读取 - 修复前:accessToken 从 localStorage 读,refreshToken 从 Zustand 读(不一致) - 修复后:两者都从 useAuthStore.getState() 读取(单一数据源) Bug 2 — 非空断言修复: - 移除 client.ts 和 tokenRefresh.ts 中的 user! 强制非空断言 - executeTokenRefresh() 在刷新前检查 user 是否为空,为空则跳过刷新 - client.ts 401 处理中,executeTokenRefresh() 返回 null 时直接登出 并发竞态修复: - 新增 executeTokenRefresh() 共享函数,带 activeRefreshPromise 锁 - 主动刷新(setTimeout)和被动刷新(401 拦截器)共用同一把锁 - 多个组件同时触发刷新时,只发一次请求,后续调用复用 Promise 测试更新: - client.test.ts 适配新逻辑,mock executeTokenRefresh 替代直接 mock refreshAccessToken - 43 个测试全部通过 --- apps/web/src/api/auth/tokenRefresh.ts | 69 +++++++++++++++++++-------- apps/web/src/api/client.ts | 29 ++++++----- apps/web/src/test/api/client.test.ts | 66 +++++++++++++++++-------- 3 files changed, 111 insertions(+), 53 deletions(-) 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..c5ed059db 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") }) }) -- 2.54.0 From d54767c9293eb3a93dd01e3262aa34f3dbfbf6f5 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 18 Aug 2026 12:55:50 +0800 Subject: [PATCH 2/2] style: format client.test.ts with prettier --- apps/web/src/test/api/client.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/test/api/client.test.ts b/apps/web/src/test/api/client.test.ts index c5ed059db..2da52e13c 100644 --- a/apps/web/src/test/api/client.test.ts +++ b/apps/web/src/test/api/client.test.ts @@ -285,7 +285,7 @@ describe("apiClient - 401 token refresh", () => { mockSetAuth(_user, newAccess, _newRefresh) }) as any, })) - + // Mock executeTokenRefresh to simulate successful refresh vi.mocked(executeTokenRefresh).mockImplementation(() => { currentAccessToken = "new-access" -- 2.54.0