Compare commits

..

1 Commits

Author SHA1 Message Date
xiaoxia 5520e18e2e fix: 修复 usePreviewAssets 无限循环请求
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 39s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m32s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m0s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m40s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m49s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m21s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m28s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m47s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m53s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 3m2s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 52s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m8s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 4m12s
AI Code Review / AI Code Review (pull_request) Successful in 6m47s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m0s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 7m50s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 6m21s
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 37s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m55s
CI/CD Pipeline / Integration Tests (push) Successful in 4m34s
CI/CD Pipeline / Unit Tests (push) Successful in 15m20s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 16m18s
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
- 添加 useStableArray hook 稳定化 assetIds 引用
- 只有数组内容真正变化时才触发重新请求
- 避免父组件 re-render 导致的无效 API 调用
2026-08-18 12:59:02 +08:00
4 changed files with 77 additions and 114 deletions
+21 -48
View File
@@ -9,9 +9,6 @@ import { refreshAccessToken } from "./login"
let refreshTimer: ReturnType<typeof setTimeout> | null = null
/** 正在执行刷新操作的 Promise,防止主动刷新和 401 被动刷新并发竞争 */
let activeRefreshPromise: Promise<void> | null = null
/** 提前刷新的缓冲时间(秒) */
const REFRESH_BUFFER_SECONDS = 60
@@ -42,55 +39,14 @@ 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()
// 统一从 Zustand store 读取(与 setAuth 写入保持一致)
const { accessToken, refreshToken: refreshTokenValue } = useAuthStore.getState()
const accessToken = localStorage.getItem("access_token")
const refreshTokenValue = useAuthStore.getState().refreshToken
if (!accessToken || !refreshTokenValue) return
@@ -103,7 +59,24 @@ export function scheduleProactiveRefresh(): void {
// 如果 token 已经过期或即将在缓冲时间内过期,立即刷新
const delaySeconds = Math.max(secondsUntilExpiry - REFRESH_BUFFER_SECONDS, 0)
refreshTimer = setTimeout(() => {
executeTokenRefresh()
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)
}
+12 -17
View File
@@ -5,8 +5,8 @@
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"
import { message } from "antd"
import { useAuthStore } from "@/store/authStore"
import { cancelProactiveRefresh, executeTokenRefresh } from "./auth/tokenRefresh"
import { refreshAccessToken } from "./auth"
import { scheduleProactiveRefresh, cancelProactiveRefresh } from "./auth/tokenRefresh"
// 创建 Axios 实例
const apiClient = axios.create({
@@ -98,26 +98,21 @@ apiClient.interceptors.response.use(
isRefreshing = true
try {
// 使用共享的刷新函数(带并发锁 + 安全检查)
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
const data = await refreshAccessToken(refreshToken)
const newAccessToken = data.access_token
const newRefreshToken = data.refresh_token ?? refreshToken
// 获取刷新后的新 token
const newAccessToken = useAuthStore.getState().accessToken
if (!newAccessToken) {
return Promise.reject(new Error("Token refresh failed: no new access token"))
}
// 更新 Zustand + localStorage
useAuthStore
.getState()
.setAuth(useAuthStore.getState().user!, newAccessToken, newRefreshToken)
// 处理排队的请求
processQueue(null, newAccessToken)
// 重新调度主动刷新(基于新 token 的过期时间)
scheduleProactiveRefresh()
// 重试原始请求
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`
@@ -64,8 +64,11 @@ 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 (!assetIds.length || !enabled) {
if (!stableAssetIds.length || !enabled) {
setAssets([])
setReady(false)
return
@@ -76,7 +79,7 @@ export function usePreviewAssets(assetIds: string[], enabled: boolean): UsePrevi
setReady(false)
try {
const result = await fetchAssetsByIds(assetIds)
const result = await fetchAssetsByIds(stableAssetIds)
// 防止竞态:只保留最新请求的结果
if (requestIdRef.current === thisRequestId) {
setAssets(result)
@@ -92,7 +95,7 @@ export function usePreviewAssets(assetIds: string[], enabled: boolean): UsePrevi
setLoading(false)
}
}
}, [assetIds, enabled])
}, [stableAssetIds, enabled])
useEffect(() => {
load()
@@ -101,4 +104,22 @@ 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 -46
View File
@@ -20,16 +20,9 @@ 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"
// 从真实实例取出拦截器回调
@@ -271,28 +264,23 @@ describe("apiClient - 401 token refresh", () => {
expect(window.location.href).toBe("/")
})
it("refreshes token on 401 and calls executeTokenRefresh", async () => {
it("refreshes token on 401 and calls setAuth", async () => {
const mockSetAuth = vi.fn()
let currentAccessToken = "old-access"
vi.mocked(useAuthStore.getState).mockImplementation(() => ({
vi.mocked(useAuthStore.getState).mockReturnValue({
user: { id: "1", email: "test@test.com" },
accessToken: currentAccessToken,
accessToken: "old-access",
refreshToken: "old-refresh",
isAuthenticated: true,
clearAuth: vi.fn(),
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()
})
setAuth: mockSetAuth,
} as any)
vi.mocked(refreshAccessToken).mockResolvedValue({
access_token: "new-access",
refresh_token: "new-refresh",
} as never)
// 拦截器重试时会调用 apiClient(config),会真的发请求,最终会 reject
// 但我们只关心刷新逻辑是否正确执行
const err = makeAxiosError(401, { detail: "Unauthorized" })
try {
@@ -301,45 +289,31 @@ describe("apiClient - 401 token refresh", () => {
// 重试会因为没有真实网络而失败,忽略
}
expect(executeTokenRefresh).toHaveBeenCalled()
expect(refreshAccessToken).toHaveBeenCalledWith("old-refresh")
expect(mockSetAuth).toHaveBeenCalled()
})
it("handles refresh failure by logging out", async () => {
const mockClearAuth = vi.fn()
// 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(() => ({
vi.mocked(useAuthStore.getState).mockReturnValue({
user: { id: "1", email: "test@test.com" },
accessToken: currentAccessToken,
accessToken: "old-access",
refreshToken: "old-refresh",
isAuthenticated: currentAccessToken !== null,
clearAuth: (() => {
currentAccessToken = null
mockClearAuth()
window.location.href = "/login"
}) as any,
isAuthenticated: true,
clearAuth: mockClearAuth,
setAuth: vi.fn(),
}))
// Mock executeTokenRefresh: simulates failure → clears auth + redirects
vi.mocked(executeTokenRefresh).mockImplementation(() => {
currentAccessToken = null
mockClearAuth()
window.location.href = "/login"
return Promise.resolve()
})
} as any)
vi.mocked(refreshAccessToken).mockRejectedValue(new Error("refresh failed") as never)
const err = makeAxiosError(401, { detail: "Unauthorized" })
try {
await responseErrorInterceptor(err)
} catch {
// expected - rejects because accessToken is null after failed refresh
// expected
}
expect(executeTokenRefresh).toHaveBeenCalled()
expect(mockClearAuth).toHaveBeenCalled()
expect(window.location.href).toBe("/login")
expect(window.location.href).toBe("/")
})
})