From 592bdfbc70f9423031c4a09c5c1bad83cd12f042 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Sat, 1 Aug 2026 09:37:23 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20AI=20=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=E4=BC=9A=E8=AF=9D=E5=A4=B1=E6=95=88=E5=90=8E=E7=9A=84?= =?UTF-8?q?=E4=BC=AA=E7=99=BB=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:Main 刷新令牌返回 401 后已清空会话,但 Renderer 仍保留用户信息,AI 设计持续返回 AUTH_REQUIRED。 实现:认证初始化等待 Main 同步完成;同步或刷新失败清理登录态;Workspace 识别结构化认证错误并在重新登录后恢复加载;路由守卫响应登录状态变化。 验证:49 个聚焦测试通过,TypeScript、聚焦 Lint 与 Vite 生产构建通过。 --- src/App.tsx | 16 +-- .../layout/ImageWorkspaceSidebar.tsx | 8 +- src/pages/ImageCanvas/index.tsx | 6 +- src/stores/auth.ts | 70 +++++++--- src/stores/image-workspace.ts | 62 ++++++--- tests/unit/auth-store.test.ts | 128 ++++++++++++++++-- tests/unit/image-canvas-page.test.tsx | 52 +++++++ tests/unit/login-page.test.tsx | 48 ++++++- 8 files changed, 328 insertions(+), 62 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 3fe96ec..4d05034 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -123,7 +123,7 @@ function ProtectedLayout({ setupReady: boolean; }) { const location = useLocation(); - const isAuthenticated = useAuthStore((state) => state.isAuthenticated); + const authenticated = useAuthStore((state) => state.isAuthenticated()); const allowsAnonymousImageWorkspace = imageWorkspaceLocalDevelopment && (location.pathname === '/' || location.pathname === '/image-canvas' @@ -137,7 +137,7 @@ function ProtectedLayout({ return ; } - if (authRequired && !allowsAnonymousImageWorkspace && !isAuthenticated()) { + if (authRequired && !allowsAnonymousImageWorkspace && !authenticated) { return ( state.isAuthenticated); + const authenticated = useAuthStore((state) => state.isAuthenticated()); if (!setupReady) { return ; @@ -171,7 +171,7 @@ function ProtectedModuleSelection({ return ; } - if (authRequired && !imageWorkspaceLocalDevelopment && !isAuthenticated()) { + if (authRequired && !imageWorkspaceLocalDevelopment && !authenticated) { return ; } @@ -242,14 +242,14 @@ function App() { const initProviders = useProviderStore((state) => state.init); const initAuth = useAuthStore((state) => state.init); const authInitialized = useAuthStore((state) => state.initialized); - const isAuthenticated = useAuthStore((state) => state.isAuthenticated); + const authenticated = useAuthStore((state) => state.isAuthenticated()); const bootstrapUserSync = useUserSyncStore((state) => state.bootstrap); const setupReady = setupComplete || skipSetupForE2E || rendererOnlyPreview; const authRequired = !skipSetupForE2E && !rendererOnlyPreview; const imageWorkspaceLocalDevelopment = window.electron?.imageWorkspaceLocalDevelopment === true; useEffect(() => { - initAuth(); + void initAuth(); }, [initAuth]); useEffect(() => { @@ -274,9 +274,9 @@ function App() { if (rendererOnlyPreview) return; if (!setupReady) return; if (!authInitialized) return; - if (!isAuthenticated()) return; + if (!authenticated) return; void bootstrapUserSync(); - }, [authInitialized, bootstrapUserSync, isAuthenticated, rendererOnlyPreview, setupReady]); + }, [authenticated, authInitialized, bootstrapUserSync, rendererOnlyPreview, setupReady]); // Redirect to setup wizard if not complete useEffect(() => { diff --git a/src/components/layout/ImageWorkspaceSidebar.tsx b/src/components/layout/ImageWorkspaceSidebar.tsx index 9712099..2555cb6 100644 --- a/src/components/layout/ImageWorkspaceSidebar.tsx +++ b/src/components/layout/ImageWorkspaceSidebar.tsx @@ -19,6 +19,7 @@ import { import { IMAGE_WORKSPACE_CREATE_PROJECT_EVENT } from '@/lib/image-workspace'; import { cn } from '@/lib/utils'; import { useImageWorkspaceStore } from '@/stores/image-workspace'; +import { useAuthStore } from '@/stores/auth'; import type { DesignWorkspaceSummary } from '../../../shared/image-workspace'; type ImageWorkspaceSidebarProps = { @@ -30,6 +31,7 @@ type ProjectDialogState = | { mode: 'rename'; project: DesignWorkspaceSummary }; export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSidebarProps) { + const authenticated = useAuthStore((state) => state.isAuthenticated()); const navigate = useNavigate(); const status = useImageWorkspaceStore((state) => state.status); const bootstrap = useImageWorkspaceStore((state) => state.bootstrap); @@ -46,8 +48,8 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba const [saving, setSaving] = useState(false); useEffect(() => { - if (status === 'idle') void load(); - }, [load, status]); + if (status === 'idle' || (status === 'auth-required' && authenticated)) void load(); + }, [authenticated, load, status]); useEffect(() => { const openCreateDialog = () => { @@ -154,7 +156,7 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba ) : null} - {status === 'unavailable' || status === 'error' ? ( + {status === 'unavailable' || status === 'error' || status === 'auth-required' ? (
state.isAuthenticated()); const status = useImageWorkspaceStore((state) => state.status); const bootstrap = useImageWorkspaceStore((state) => state.bootstrap); const workspace = useImageWorkspaceStore((state) => state.workspace); @@ -207,8 +209,8 @@ export function ImageCanvas() { const hasActiveTasks = tasks.some((task) => ACTIVE_TASK_STATUSES.has(task.status)); useEffect(() => { - if (status === 'idle') void load(); - }, [load, status]); + if (status === 'idle' || (status === 'auth-required' && authenticated)) void load(); + }, [authenticated, load, status]); useEffect(() => { if (!hasActiveTasks || !workspace) return; diff --git a/src/stores/auth.ts b/src/stores/auth.ts index 9249498..ba217cb 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -46,10 +46,11 @@ type AuthState = { tokenType: string | null; expiresAt: number | null; user: AuthUser | null; - init: () => void; + init: () => Promise; loginWithBrowser: () => Promise; refreshSession: () => Promise; getValidAccessToken: () => Promise; + invalidateSession: (message?: string) => void; logout: () => Promise; isAuthenticated: () => boolean; }; @@ -120,10 +121,10 @@ async function syncMainSession(session: { refreshToken: string | null; tokenType: string | null; expiresAt: number | null; -}): Promise { - if (!session.accessToken) return; +}): Promise { + if (!session.accessToken) return false; try { - await hostApiFetch('/api/auth/session/sync', { + const response = await hostApiFetch('/api/auth/session/sync', { method: 'POST', body: JSON.stringify({ accessToken: session.accessToken, @@ -132,8 +133,9 @@ async function syncMainSession(session: { expiresAt: session.expiresAt, }), }); + return response.success; } catch { - // The renderer session remains authoritative for UI; Main can resync later. + return false; } } @@ -147,8 +149,9 @@ export const useAuthStore = create()( clientId: DEFAULT_CLIENT_ID, ...getClearedSession(), - init: () => { + init: async () => { const state = get(); + if (state.loading && !state.initialized) return; const authBaseChanged = Boolean( state.authBase && trimTrailingSlash(state.authBase) !== trimTrailingSlash(DEFAULT_AUTH_BASE), @@ -159,21 +162,36 @@ export const useAuthStore = create()( && state.expiresAt <= Date.now() + TOKEN_EXPIRY_SKEW_MS, ); set({ - initialized: true, + initialized: false, loading: false, error: null, authBase: DEFAULT_AUTH_BASE, clientId: DEFAULT_CLIENT_ID, ...(expired || authBaseChanged ? getClearedSession() : {}), }); - if (!expired && !authBaseChanged && state.accessToken) { - void syncMainSession({ - accessToken: state.accessToken, - refreshToken: state.refreshToken, - tokenType: state.tokenType, - expiresAt: state.expiresAt, - }); + + if (expired || authBaseChanged || !state.accessToken) { + set({ initialized: true }); + return; } + + set({ loading: true }); + const synchronized = await syncMainSession({ + accessToken: state.accessToken, + refreshToken: state.refreshToken, + tokenType: state.tokenType, + expiresAt: state.expiresAt, + }); + if (!synchronized) { + set({ + initialized: true, + loading: false, + error: '登录状态恢复失败,请重新登录。', + ...getClearedSession(), + }); + return; + } + set({ initialized: true, loading: false }); }, loginWithBrowser: async () => { @@ -199,7 +217,9 @@ export const useAuthStore = create()( clientId, ...session, }); - await syncMainSession(session); + if (!await syncMainSession(session)) { + throw new Error('Failed to synchronize the signed-in session'); + } } catch (error) { const message = error instanceof Error ? error.message : String(error); set({ loading: false, error: message, ...getClearedSession() }); @@ -228,11 +248,18 @@ export const useAuthStore = create()( error: null, ...session, }); - await syncMainSession(session); + if (!await syncMainSession(session)) { + throw new Error('Failed to synchronize the refreshed session'); + } return session.accessToken; } catch (error) { const message = error instanceof Error ? error.message : String(error); - set({ loading: false, error: message }); + set({ + initialized: true, + loading: false, + error: message, + ...getClearedSession(), + }); return null; } }, @@ -252,6 +279,15 @@ export const useAuthStore = create()( return accessToken; }, + invalidateSession: (message = '登录已过期,请重新登录。') => { + set({ + initialized: true, + loading: false, + error: message, + ...getClearedSession(), + }); + }, + logout: async () => { const { accessToken } = get(); try { diff --git a/src/stores/image-workspace.ts b/src/stores/image-workspace.ts index fcdf2aa..5938796 100644 --- a/src/stores/image-workspace.ts +++ b/src/stores/image-workspace.ts @@ -9,6 +9,7 @@ import { renameImageWorkspaceProject, sendImageWorkspaceMessage, } from '@/lib/image-workspace'; +import { useAuthStore } from '@/stores/auth'; import { IMAGE_WORKSPACE_UNAVAILABLE_CODE, type DesignGenerationTask, @@ -17,7 +18,13 @@ import { type DesignWorkspaceSummary, } from '../../shared/image-workspace'; -export type ImageWorkspaceLoadStatus = 'idle' | 'loading' | 'ready' | 'unavailable' | 'error'; +export type ImageWorkspaceLoadStatus = + | 'idle' + | 'loading' + | 'ready' + | 'unavailable' + | 'error' + | 'auth-required'; type ImageWorkspaceState = { status: ImageWorkspaceLoadStatus; @@ -44,6 +51,10 @@ function unavailable(error: unknown): boolean { && (error.status === 501 || error.code === IMAGE_WORKSPACE_UNAVAILABLE_CODE); } +function authenticationRequired(error: unknown): boolean { + return error instanceof ImageWorkspaceApiError && error.code === 'AUTH_REQUIRED'; +} + function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error); } @@ -69,6 +80,22 @@ function upsertSummary( } export const useImageWorkspaceStore = create((set, get) => { + const handleRequestError = (error: unknown): string => { + const message = messageOf(error); + if (authenticationRequired(error)) { + useAuthStore.getState().invalidateSession(); + set({ + status: 'auth-required', + bootstrap: null, + activeWorkspaceId: null, + workspace: null, + tasks: [], + error: message, + }); + } + return message; + }; + const applyWorkspace = (workspace: DesignWorkspace): DesignWorkspace => { set((state) => ({ status: 'ready', @@ -139,14 +166,17 @@ export const useImageWorkspaceStore = create((set, get) => if (activeWorkspaceId) await loadWorkspace(activeWorkspaceId); return bootstrap; } catch (error) { - set({ - status: unavailable(error) ? 'unavailable' : 'error', - bootstrap: null, - activeWorkspaceId: null, - workspace: null, - tasks: [], - error: messageOf(error), - }); + const message = handleRequestError(error); + if (!authenticationRequired(error)) { + set({ + status: unavailable(error) ? 'unavailable' : 'error', + bootstrap: null, + activeWorkspaceId: null, + workspace: null, + tasks: [], + error: message, + }); + } return null; } finally { inFlightLoad = null; @@ -161,7 +191,7 @@ export const useImageWorkspaceStore = create((set, get) => set({ tasks: [] }); return workspace; } catch (error) { - set({ error: messageOf(error) }); + set({ error: handleRequestError(error) }); throw error; } }, @@ -170,7 +200,7 @@ export const useImageWorkspaceStore = create((set, get) => try { return applyWorkspace(await renameImageWorkspaceProject(workspaceId, title)); } catch (error) { - set({ error: messageOf(error) }); + set({ error: handleRequestError(error) }); throw error; } }, @@ -186,7 +216,7 @@ export const useImageWorkspaceStore = create((set, get) => try { await loadWorkspace(workspaceId); } catch (error) { - set({ error: messageOf(error) }); + set({ error: handleRequestError(error) }); } }, @@ -196,7 +226,7 @@ export const useImageWorkspaceStore = create((set, get) => try { return applyWorkspace(await fetchImageWorkspaceProject(workspaceId)); } catch (error) { - set({ error: messageOf(error) }); + set({ error: handleRequestError(error) }); throw error; } }, @@ -212,7 +242,7 @@ export const useImageWorkspaceStore = create((set, get) => set({ tasks }); return tasks; } catch (error) { - set({ error: messageOf(error) }); + set({ error: handleRequestError(error) }); throw error; } }, @@ -227,7 +257,7 @@ export const useImageWorkspaceStore = create((set, get) => message, )); } catch (error) { - set({ error: messageOf(error) }); + set({ error: handleRequestError(error) }); return await recoverRevisionConflict(error); } }, @@ -244,7 +274,7 @@ export const useImageWorkspaceStore = create((set, get) => await get().refreshTasks().catch(() => []); return updated; } catch (error) { - set({ error: messageOf(error) }); + set({ error: handleRequestError(error) }); return await recoverRevisionConflict(error); } }, diff --git a/tests/unit/auth-store.test.ts b/tests/unit/auth-store.test.ts index 5d448b9..e8d4973 100644 --- a/tests/unit/auth-store.test.ts +++ b/tests/unit/auth-store.test.ts @@ -36,21 +36,23 @@ describe('auth store', () => { }); it('starts browser authorization through the host api and stores the session', async () => { - hostApiFetchMock.mockResolvedValueOnce({ - success: true, - token: { - access_token: 'access-token', - refresh_token: 'refresh-token', - token_type: 'Bearer', - expires_in: 60, - username: 'zhangsan', - user_id: '1', - tenant_id: 7, - dept_id: 9, - authorities: ['ROLE_USER'], - client_id: 'app', - }, - }); + hostApiFetchMock + .mockResolvedValueOnce({ + success: true, + token: { + access_token: 'access-token', + refresh_token: 'refresh-token', + token_type: 'Bearer', + expires_in: 60, + username: 'zhangsan', + user_id: '1', + tenant_id: 7, + dept_id: 9, + authorities: ['ROLE_USER'], + client_id: 'app', + }, + }) + .mockResolvedValueOnce({ success: true }); await useAuthStore.getState().loginWithBrowser(); @@ -152,6 +154,72 @@ describe('auth store', () => { }); }); + it('waits for Main session sync before marking persisted auth initialized', async () => { + let resolveSync!: (value: { success: boolean }) => void; + hostApiFetchMock.mockImplementationOnce(() => new Promise((resolve) => { + resolveSync = resolve; + })); + useAuthStore.setState({ + initialized: false, + loading: false, + error: null, + authBase: 'https://biz.nianxx.cn/auth/', + clientId: 'app', + accessToken: 'persisted-access-token', + refreshToken: 'persisted-refresh-token', + tokenType: 'Bearer', + expiresAt: Date.now() + 60_000, + user: { + username: 'zhangsan', + userId: '1', + tenantId: 7, + deptId: 9, + authorities: ['ROLE_USER'], + }, + }); + + const initialization = useAuthStore.getState().init(); + + expect(useAuthStore.getState().initialized).toBe(false); + + resolveSync({ success: true }); + await initialization; + + expect(useAuthStore.getState().initialized).toBe(true); + }); + + it('clears persisted renderer auth when Main session sync fails', async () => { + hostApiFetchMock.mockRejectedValueOnce(new Error('Host API unavailable')); + useAuthStore.setState({ + initialized: false, + loading: false, + error: null, + authBase: 'https://biz.nianxx.cn/auth/', + clientId: 'app', + accessToken: 'persisted-access-token', + refreshToken: 'persisted-refresh-token', + tokenType: 'Bearer', + expiresAt: Date.now() + 60_000, + user: { + username: 'zhangsan', + userId: '1', + tenantId: 7, + deptId: 9, + authorities: ['ROLE_USER'], + }, + }); + + await useAuthStore.getState().init(); + + expect(useAuthStore.getState()).toMatchObject({ + initialized: true, + accessToken: null, + refreshToken: null, + user: null, + error: '登录状态恢复失败,请重新登录。', + }); + }); + it('syncs a refreshed session to Main', async () => { hostApiFetchMock .mockResolvedValueOnce({ @@ -200,6 +268,36 @@ describe('auth store', () => { }); }); + it('clears stale renderer auth when refresh fails', async () => { + hostApiFetchMock.mockRejectedValueOnce(new Error('Invalid refresh token')); + useAuthStore.setState({ + initialized: true, + loading: false, + error: null, + authBase: 'https://biz.nianxx.cn/auth/', + clientId: 'app', + accessToken: 'expired-access-token', + refreshToken: 'invalid-refresh-token', + tokenType: 'Bearer', + expiresAt: Date.now() - 1, + user: { + username: 'zhangsan', + userId: '1', + tenantId: 7, + deptId: 9, + authorities: ['ROLE_USER'], + }, + }); + + await expect(useAuthStore.getState().refreshSession()).resolves.toBeNull(); + + expect(useAuthStore.getState()).toMatchObject({ + accessToken: null, + refreshToken: null, + user: null, + }); + }); + it('logs out remotely and clears the local session', async () => { hostApiFetchMock.mockResolvedValueOnce({ success: true }); useAuthStore.setState({ diff --git a/tests/unit/image-canvas-page.test.tsx b/tests/unit/image-canvas-page.test.tsx index ac14d72..4d5ca3a 100644 --- a/tests/unit/image-canvas-page.test.tsx +++ b/tests/unit/image-canvas-page.test.tsx @@ -3,6 +3,7 @@ import { MemoryRouter } from 'react-router-dom'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ImageWorkspaceApiError } from '@/lib/image-workspace'; import { ImageCanvas } from '@/pages/ImageCanvas'; +import { useAuthStore } from '@/stores/auth'; import { useImageWorkspaceStore } from '@/stores/image-workspace'; import type { DesignGenerationTask, @@ -152,6 +153,57 @@ describe('ImageCanvas Workspace-first design experience', () => { expect(screen.getByText(/暂时无法连接设计服务/)).toBeInTheDocument(); }); + it('clears stale renderer auth when Main requires authentication', async () => { + useAuthStore.setState({ + initialized: true, + loading: false, + error: null, + accessToken: 'expired-access-token', + refreshToken: 'invalid-refresh-token', + tokenType: 'Bearer', + expiresAt: Date.now() - 1, + user: { + username: 'brother7', + userId: '1', + tenantId: null, + deptId: null, + authorities: [], + }, + }); + fetchImageWorkspaceMock.mockRejectedValueOnce(new ImageWorkspaceApiError( + 401, + 'AUTH_REQUIRED', + '请先登录后再使用 AI 设计', + )); + + render(); + + await screen.findByTestId('image-workspace-unavailable'); + expect(useAuthStore.getState()).toMatchObject({ + accessToken: null, + refreshToken: null, + user: null, + }); + expect(useImageWorkspaceStore.getState().status).toBe('auth-required'); + + useAuthStore.setState({ + accessToken: 'fresh-access-token', + refreshToken: 'fresh-refresh-token', + tokenType: 'Bearer', + expiresAt: Date.now() + 60_000, + user: { + username: 'brother7', + userId: '1', + tenantId: null, + deptId: null, + authorities: [], + }, + }); + + await waitFor(() => expect(fetchImageWorkspaceMock).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(useImageWorkspaceStore.getState().status).toBe('ready')); + }); + it('renders one fixed design Agent, a Quote, and the unified image/video task list', async () => { render(); diff --git a/tests/unit/login-page.test.tsx b/tests/unit/login-page.test.tsx index de0997d..db40a68 100644 --- a/tests/unit/login-page.test.tsx +++ b/tests/unit/login-page.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { Login } from '@/pages/Login'; @@ -66,6 +66,52 @@ describe('Login page', () => { expect(await screen.findByRole('button', { name: 'Continue in browser' })).toBeInTheDocument(); }); + it('redirects an open protected route when the current auth session is invalidated', async () => { + useSettingsStore.setState({ setupComplete: true }); + useAuthStore.setState({ + initialized: true, + loading: false, + error: null, + authBase: 'https://biz.nianxx.cn/auth/', + clientId: 'app', + accessToken: 'access-token', + refreshToken: 'refresh-token', + tokenType: 'Bearer', + expiresAt: Date.now() + 60_000, + user: { + username: 'brother7', + userId: '1', + tenantId: null, + deptId: null, + authorities: [], + }, + }); + hostApiFetchMock.mockResolvedValue({ success: true }); + + render( + + + , + ); + + await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith( + '/api/auth/session/sync', + expect.any(Object), + )); + + act(() => { + useAuthStore.setState({ + accessToken: null, + refreshToken: null, + tokenType: null, + expiresAt: null, + user: null, + }); + }); + + expect(await screen.findByRole('button', { name: 'Continue in browser' })).toBeInTheDocument(); + }); + it('opens only the local image workspace anonymously when the explicit development mode is active', async () => { useSettingsStore.setState({ setupComplete: true }); window.electron.imageWorkspaceLocalDevelopment = true;