From ff5949b98dd4f84d27145ce3d5eebc4b9266e3f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 02:56:08 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAGC=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E4=B8=8E=E5=8F=91=E7=A0=81=E7=B3=BB=E7=BB=9F=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E6=AE=8B=E7=95=99=E5=BF=99=E7=A2=8C=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - handleSendCode/handleLoginSubmit 的 catch 在抛出前把系统失败原因写进 loginStatus,离开「正在发送验证码」「正在登录」 - 系统变体按类型化载荷拼出提示文案后再原样抛出,恢复改动前「显示 + 上报」的可见行为 - 本地运行时超时等非载体错误同样先写 loginStatus 再抛出 - appSurface 用例改为断言系统变体文案落到登录页且仍经 unhandledrejection 抛出 --- .../src/app/AuthenticatedClient.tsx | 168 ++++++++++++++++-- .../tests/appSurface/auth.suite.ts | 11 +- 2 files changed, 158 insertions(+), 21 deletions(-) diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index c26b260bf..a35b82a5d 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -554,7 +554,11 @@ export function AuthenticatedClient({ setCodeCooldownSeconds(Math.max(0, Math.floor(response.cooldownSeconds))); setLoginStatus(`验证码已发送,${response.expiresInSeconds} 秒内有效`); } catch (error) { - if (!(error instanceof ClientAuthErrorWrapper)) throw error; + // 本地运行时超时等我们自己的失败也要离开「正在发送验证码」并给出原因,改动前就是如此。 + if (!(error instanceof ClientAuthErrorWrapper)) { + setLoginStatus(error instanceof Error ? error.message : String(error)); + throw error; + } const failure = error.error; switch (failure.type) { // 预期业务拒绝:调用方补上本次操作的上下文,展示后不进错误池。 @@ -644,15 +648,79 @@ export function AuthenticatedClient({ setLoginStatus('当前账号没有权限执行此操作'); break; } - // 系统失败:调用方处理不了,原样抛出 → 全局 unhandledrejection 交给错误池。 - case 'authNetworkFailure': - case 'authServiceUnavailable': - case 'unexpectedRejection': - case 'authResponseInvalid': - case 'clientSessionPersistFailed': - case 'runtimeSessionInstallFailed': - case 'authClientInitFailed': + // 系统失败:调用方处理不了,先按原始事实给用户可见反馈,再原样抛出 → 全局 unhandledrejection 交给错误池。 + case 'authNetworkFailure': { + const payload = failure as AuthNetworkFailure; + setLoginStatus( + payload.reason === 'timeout' + ? '发送验证码失败:登录服务响应超时,请检查服务器地址和网络后重试' + : '发送验证码失败:无法连接登录服务,请确认配套后端或 API 代理已启动后重试', + ); throw error; + } + case 'authServiceUnavailable': { + const payload = failure as AuthServiceUnavailable; + setLoginStatus( + `发送验证码失败:登录服务暂时不可用(HTTP ${payload.status})`, + ); + throw error; + } + case 'unexpectedRejection': { + const payload = failure as UnexpectedRejection; + setLoginStatus( + payload.serverMessage + ? `发送验证码失败:登录服务请求失败(HTTP ${payload.status}):${payload.serverMessage}` + : `发送验证码失败:登录服务请求失败(HTTP ${payload.status})`, + ); + throw error; + } + case 'authResponseInvalid': { + const payload = failure as AuthResponseInvalid; + let text: string; + switch (payload.reason) { + case 'notJson': + text = '发送验证码失败:登录服务响应不是合法 JSON'; + break; + case 'invalidBody': + text = '发送验证码失败:登录服务响应结构不符合契约'; + break; + case 'missingRefreshCookie': + text = '发送验证码失败:登录服务未返回新的续期凭据'; + break; + case 'missingUserIdentity': + text = '发送验证码失败:登录服务未返回用户身份'; + break; + case 'serverRejected': + text = payload.serverMessage + ? `发送验证码失败:登录服务拒绝了本次请求:${payload.serverMessage}` + : '发送验证码失败:登录服务拒绝了本次请求'; + break; + default: { + expectNever(payload.reason); + text = '发送验证码失败:登录服务响应不符合契约'; + } + } + setLoginStatus(text); + throw error; + } + case 'clientSessionPersistFailed': { + const payload = failure as ClientSessionPersistFailed; + setLoginStatus( + `发送验证码失败:本机登录凭据读写失败:${payload.detail}`, + ); + throw error; + } + case 'runtimeSessionInstallFailed': { + const payload = failure as RuntimeSessionInstallFailed; + setLoginStatus( + `发送验证码失败:本机运行时会话安装失败:${payload.detail}`, + ); + throw error; + } + case 'authClientInitFailed': { + setLoginStatus('发送验证码失败:创建登录网络客户端失败'); + throw error; + } default: { expectNever(failure); throw error; @@ -722,7 +790,11 @@ export function AuthenticatedClient({ setCode(''); setPassword(''); } catch (error) { - if (!(error instanceof ClientAuthErrorWrapper)) throw error; + // 本地运行时超时等我们自己的失败也要离开「正在登录」并给出原因,改动前就是如此。 + if (!(error instanceof ClientAuthErrorWrapper)) { + setLoginStatus(error instanceof Error ? error.message : String(error)); + throw error; + } const failure = error.error; switch (failure.type) { // 预期业务拒绝:调用方补上本次操作的上下文,展示后不进错误池。 @@ -812,15 +884,75 @@ export function AuthenticatedClient({ setLoginStatus('当前账号没有权限执行此操作'); break; } - // 系统失败:调用方处理不了,原样抛出 → 全局 unhandledrejection 交给错误池。 - case 'authNetworkFailure': - case 'authServiceUnavailable': - case 'unexpectedRejection': - case 'authResponseInvalid': - case 'clientSessionPersistFailed': - case 'runtimeSessionInstallFailed': - case 'authClientInitFailed': + // 系统失败:调用方处理不了,先按原始事实给用户可见反馈,再原样抛出 → 全局 unhandledrejection 交给错误池。 + case 'authNetworkFailure': { + const payload = failure as AuthNetworkFailure; + setLoginStatus( + payload.reason === 'timeout' + ? '登录失败:登录服务响应超时,请检查服务器地址和网络后重试' + : '登录失败:无法连接登录服务,请确认配套后端或 API 代理已启动后重试', + ); throw error; + } + case 'authServiceUnavailable': { + const payload = failure as AuthServiceUnavailable; + setLoginStatus( + `登录失败:登录服务暂时不可用(HTTP ${payload.status})`, + ); + throw error; + } + case 'unexpectedRejection': { + const payload = failure as UnexpectedRejection; + setLoginStatus( + payload.serverMessage + ? `登录失败:登录服务请求失败(HTTP ${payload.status}):${payload.serverMessage}` + : `登录失败:登录服务请求失败(HTTP ${payload.status})`, + ); + throw error; + } + case 'authResponseInvalid': { + const payload = failure as AuthResponseInvalid; + let text: string; + switch (payload.reason) { + case 'notJson': + text = '登录失败:登录服务响应不是合法 JSON'; + break; + case 'invalidBody': + text = '登录失败:登录服务响应结构不符合契约'; + break; + case 'missingRefreshCookie': + text = '登录失败:登录服务未返回新的续期凭据'; + break; + case 'missingUserIdentity': + text = '登录失败:登录服务未返回用户身份'; + break; + case 'serverRejected': + text = payload.serverMessage + ? `登录失败:登录服务拒绝了本次请求:${payload.serverMessage}` + : '登录失败:登录服务拒绝了本次请求'; + break; + default: { + expectNever(payload.reason); + text = '登录失败:登录服务响应不符合契约'; + } + } + setLoginStatus(text); + throw error; + } + case 'clientSessionPersistFailed': { + const payload = failure as ClientSessionPersistFailed; + setLoginStatus(`登录失败:本机登录凭据读写失败:${payload.detail}`); + throw error; + } + case 'runtimeSessionInstallFailed': { + const payload = failure as RuntimeSessionInstallFailed; + setLoginStatus(`登录失败:本机运行时会话安装失败:${payload.detail}`); + throw error; + } + case 'authClientInitFailed': { + setLoginStatus('登录失败:创建登录网络客户端失败'); + throw error; + } default: { expectNever(failure); throw error; diff --git a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts index 9ead2250f..b480a0e0c 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts @@ -281,7 +281,7 @@ export function registerAuthTests() { expect(screen.queryByLabelText('已登录')).toBeNull(); }); - it('keeps transport details out of the login copy by throwing system variants through', async () => { + it('shows the system variant copy and still throws it through', async () => { const uninstall = installUnhandledRejectionBridge(); const reasons: unknown[] = []; const onRejection = (event: Event) => { @@ -301,7 +301,7 @@ export function registerAuthTests() { renderAuthSurface(invoke); await loginWithCode(); - // 先等系统变体真的经 unhandledrejection 抛出,再断言它没落到登录页文案。 + // 先等系统变体真的经 unhandledrejection 抛出。 await waitFor(() => expect(reasons).toHaveLength(1)); // 载体自身 message 留空;诊断信息在 error 字段里,由上报链路取。 expect(reasons[0]).toBeInstanceOf(ClientAuthErrorWrapper); @@ -309,7 +309,12 @@ export function registerAuthTests() { type: 'authNetworkFailure', reason: 'unreachable', }); - expect(screen.queryByText(/无法连接登录服务/u)).toBeNull(); + // 改动前系统失败也会在登录页给出一行提示;这里恢复同样的可见反馈,只是不泄露传输层细节。 + expect( + await screen.findByText( + '登录失败:无法连接登录服务,请确认配套后端或 API 代理已启动后重试', + ), + ).not.toBeNull(); expect(document.body.textContent).not.toContain('ECONNREFUSED'); await screen.findByRole('main', { name: '登录' }); } finally {