Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d54767c929 | |||
| 0f321c62c1 |
@@ -9,6 +9,9 @@ import { refreshAccessToken } from "./login"
|
||||
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/** 正在执行刷新操作的 Promise,防止主动刷新和 401 被动刷新并发竞争 */
|
||||
let activeRefreshPromise: Promise<void> | null = null
|
||||
|
||||
/** 提前刷新的缓冲时间(秒) */
|
||||
const REFRESH_BUFFER_SECONDS = 60
|
||||
|
||||
@@ -39,14 +42,55 @@ export function cancelProactiveRefresh(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 token 刷新(带并发锁,供主动刷新和被动 401 共用)
|
||||
* 返回当前刷新操作的 Promise;若已有刷新进行中则复用该 Promise。
|
||||
*/
|
||||
export function executeTokenRefresh(): Promise<void> | 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)
|
||||
}
|
||||
|
||||
+17
-12
@@ -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}`
|
||||
|
||||
@@ -64,11 +64,8 @@ export function usePreviewAssets(assetIds: string[], enabled: boolean): UsePrevi
|
||||
const [ready, setReady] = useState(false)
|
||||
const requestIdRef = useRef(0)
|
||||
|
||||
// 稳定化 assetIds:只有内容真正变化时才更新引用
|
||||
const stableAssetIds = useStableArray(assetIds)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!stableAssetIds.length || !enabled) {
|
||||
if (!assetIds.length || !enabled) {
|
||||
setAssets([])
|
||||
setReady(false)
|
||||
return
|
||||
@@ -79,7 +76,7 @@ export function usePreviewAssets(assetIds: string[], enabled: boolean): UsePrevi
|
||||
setReady(false)
|
||||
|
||||
try {
|
||||
const result = await fetchAssetsByIds(stableAssetIds)
|
||||
const result = await fetchAssetsByIds(assetIds)
|
||||
// 防止竞态:只保留最新请求的结果
|
||||
if (requestIdRef.current === thisRequestId) {
|
||||
setAssets(result)
|
||||
@@ -95,7 +92,7 @@ export function usePreviewAssets(assetIds: string[], enabled: boolean): UsePrevi
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [stableAssetIds, enabled])
|
||||
}, [assetIds, enabled])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
@@ -104,22 +101,4 @@ export function usePreviewAssets(assetIds: string[], enabled: boolean): UsePrevi
|
||||
return { assets, loading, ready, reload: load }
|
||||
}
|
||||
|
||||
/**
|
||||
* useStableArray — 数组内容稳定化 Hook
|
||||
* 只有数组内容真正变化时才返回新的引用,避免父组件 re-render 导致的无效更新
|
||||
*/
|
||||
function useStableArray<T>(array: T[]): T[] {
|
||||
const ref = useRef<T[]>(array)
|
||||
|
||||
// 比较数组内容是否真正变化
|
||||
const hasChanged =
|
||||
array.length !== ref.current.length || array.some((item, index) => item !== ref.current[index])
|
||||
|
||||
if (hasChanged) {
|
||||
ref.current = array
|
||||
}
|
||||
|
||||
return ref.current
|
||||
}
|
||||
|
||||
export default usePreviewAssets
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user