From 6cb0b5d6a78faa9f5abcb1eb564bfab34f77aeed Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Wed, 8 Jul 2026 14:24:06 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AE=A2=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=E8=AE=A4=E8=AF=81=E5=BC=82=E5=B8=B8=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 登录态探活遇到服务不可达时保留本地 access token。 退出登录重试时 refresh 失败仍继续调用服务端 logout。 补充客户端认证异常处理回归测试。 --- apps/ai-game-creator-shell/src/App.tsx | 73 ++++++++++++-- .../tests/appSurface.test.ts | 96 +++++++++++++++++++ 2 files changed, 163 insertions(+), 6 deletions(-) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 84b0355b2..817e58742 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -947,6 +947,36 @@ function resolveClientAuthApiUrl(url: string) { let clientAuthRefreshPromise: Promise | null = null; +class ClientAuthRequestError extends Error { + readonly status: number | null; + readonly networkError: boolean; + + constructor( + message: string, + options: { status?: number | null; networkError?: boolean } = {}, + ) { + super(message); + this.name = 'ClientAuthRequestError'; + this.status = options.status ?? null; + this.networkError = options.networkError ?? false; + } +} + +function isClientAuthUnauthorizedError(error: unknown) { + return ( + error instanceof ClientAuthRequestError && + (error.status === 401 || error.status === 403) + ); +} + +function isClientAuthRecoverableCheckError(error: unknown) { + return !isClientAuthUnauthorizedError(error); +} + +function getClientAuthErrorMessage(error: unknown, fallback: string) { + return error instanceof Error ? error.message : fallback; +} + async function readAuthErrorMessage(response: Response, fallback: string) { const text = await response.text(); if (!text.trim()) { @@ -983,10 +1013,16 @@ async function requestAuthJson( headers, }); } catch { - throw new Error('无法连接登录服务,请确认配套后端或 API 代理已启动后重试'); + throw new ClientAuthRequestError( + '无法连接登录服务,请确认配套后端或 API 代理已启动后重试', + { networkError: true }, + ); } if (!response.ok) { - throw new Error(await readAuthErrorMessage(response, fallbackMessage)); + throw new ClientAuthRequestError( + await readAuthErrorMessage(response, fallbackMessage), + { status: response.status }, + ); } const text = await response.text(); return text ? unwrapApiResponse(JSON.parse(text) as T) : (null as T); @@ -1084,7 +1120,7 @@ async function logoutClientAuthSession() { '退出登录失败', ); } catch { - await refreshClientAuthAccessToken(); + await refreshClientAuthAccessToken().catch(() => ''); await requestAuthJson( '/api/auth/logout', { method: 'POST' }, @@ -1140,10 +1176,23 @@ export function AuthenticatedClient({ } clearStoredAuthAccessToken(); setAuthStatus('unauthenticated'); - } catch { + } catch (error) { if (disposed) { return; } + if ( + getStoredAuthAccessToken() && + isClientAuthRecoverableCheckError(error) + ) { + setLoginStatus( + getClientAuthErrorMessage( + error, + '登录服务暂时不可用,请稍后重试', + ), + ); + setAuthStatus('unauthenticated'); + return; + } if (getStoredAuthAccessToken()) { try { await refreshClientAuthAccessToken(); @@ -1156,8 +1205,20 @@ export function AuthenticatedClient({ setAuthStatus('authenticated'); return; } - } catch { - // fall through to local logout below + } catch (retryError) { + if ( + getStoredAuthAccessToken() && + isClientAuthRecoverableCheckError(retryError) + ) { + setLoginStatus( + getClientAuthErrorMessage( + retryError, + '登录服务暂时不可用,请稍后重试', + ), + ); + setAuthStatus('unauthenticated'); + return; + } } } clearStoredAuthAccessToken(); diff --git a/apps/ai-game-creator-shell/tests/appSurface.test.ts b/apps/ai-game-creator-shell/tests/appSurface.test.ts index 97d217d36..dfcb3926e 100644 --- a/apps/ai-game-creator-shell/tests/appSurface.test.ts +++ b/apps/ai-game-creator-shell/tests/appSurface.test.ts @@ -269,6 +269,102 @@ describe('AI 游戏创作 App 界面边界', () => { expect(screen.queryByLabelText('已登录')).toBeNull(); }); + it('keeps the stored token when startup auth check cannot reach the service', async () => { + window.localStorage.setItem( + 'genarrative.auth.access-token.v1', + 'existing-token', + ); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation( + async (input: RequestInfo | URL) => { + const url = String(input); + if (url === '/api/auth/me') { + throw new TypeError('Load failed'); + } + throw new Error(`unexpected fetch ${url}`); + }, + ); + + render( + React.createElement(AuthenticatedClient, null, ({ user }) => + React.createElement('main', { 'aria-label': '已登录' }, user.displayName), + ), + ); + + expect(await screen.findByRole('main', { name: '登录' })).not.toBeNull(); + expect( + screen.getByText( + '无法连接登录服务,请确认配套后端或 API 代理已启动后重试', + ), + ).not.toBeNull(); + expect(window.localStorage.getItem('genarrative.auth.access-token.v1')).toBe( + 'existing-token', + ); + expect(screen.queryByLabelText('已登录')).toBeNull(); + expect( + fetchSpy.mock.calls.filter(([input]) => String(input) === '/api/auth/me'), + ).toHaveLength(1); + }); + + it('still calls logout when token refresh fails during logout retry', async () => { + window.localStorage.setItem( + 'genarrative.auth.access-token.v1', + 'existing-token', + ); + let logoutCalls = 0; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation( + async (input: RequestInfo | URL) => { + const url = String(input); + if (url === '/api/auth/me') { + return new Response( + JSON.stringify({ + user: testAuthUser, + availableLoginMethods: ['password'], + }), + { status: 200 }, + ); + } + if (url === '/api/auth/logout') { + logoutCalls += 1; + return new Response('', { status: logoutCalls === 1 ? 500 : 200 }); + } + if (url === '/api/auth/refresh') { + return new Response('', { status: 401 }); + } + throw new Error(`unexpected fetch ${url}`); + }, + ); + + render( + React.createElement(AuthenticatedClient, null, ({ user, logout }) => + React.createElement( + 'main', + { 'aria-label': '已登录' }, + React.createElement('span', null, user.displayName), + React.createElement('button', { type: 'button', onClick: logout }, '退出'), + ), + ), + ); + + expect(await screen.findByLabelText('已登录')).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '退出' })); + + expect(await screen.findByRole('main', { name: '登录' })).not.toBeNull(); + expect(screen.getByText('已退出登录')).not.toBeNull(); + expect(window.localStorage.getItem('genarrative.auth.access-token.v1')).toBe( + null, + ); + expect( + fetchSpy.mock.calls.filter( + ([input]) => String(input) === '/api/auth/logout', + ), + ).toHaveLength(2); + expect( + fetchSpy.mock.calls.filter( + ([input]) => String(input) === '/api/auth/refresh', + ), + ).toHaveLength(1); + }); + it('derives agent card status from the latest run trace step', () => { const manifest = createGameCreationAppManifest( 'local-project-draft',