diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index 947a8c4ac..1f8d92924 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -11,6 +11,7 @@ import { import type { AuthUser } from '../../../../packages/shared/src/contracts/auth'; import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png'; import { ErrorReportNotice } from '../components/error-report/ErrorReportNotice'; +import { ClientActionError } from '../services/clientActionError'; import { getClientAuthErrorMessage, loginClientWithPassword, @@ -18,6 +19,7 @@ import { logoutClientAuthSession, normalizeAuthPhoneInput, readClientAuthState, + resolveClientAuthFailure, sendClientPhoneLoginCode, subscribeClientAuthState, } from '../services/clientAuth'; @@ -34,7 +36,6 @@ import { import { captureClientError, installWebviewLogBridge, - shouldCaptureClientError, } from '../services/errorReporting'; import { beginPlatformSessionClearTransition, @@ -347,12 +348,18 @@ export function AuthenticatedClient({ setCodeCooldownSeconds(Math.max(0, Math.floor(response.cooldownSeconds))); setLoginStatus(`验证码已发送,${response.expiresInSeconds} 秒内有效`); } catch (error) { - if (shouldCaptureClientError(error)) - void captureClientError(error, { - source: 'auth', - action: 'send-login-code', - }); - setLoginStatus(error instanceof Error ? error.message : String(error)); + // 业务拒绝(手机号格式 / 发码限流等)只给提示;系统变体与未识别变体带上文交给错误池。 + const failure = resolveClientAuthFailure(error, '发送验证码失败'); + setLoginStatus(failure.message); + if (failure.kind === 'report') { + void captureClientError( + new ClientActionError( + failure.message, + { source: 'auth', action: 'send-login-code' }, + error, + ), + ); + } } finally { setCodeBusy(false); } @@ -417,9 +424,19 @@ export function AuthenticatedClient({ setCode(''); setPassword(''); } catch (error) { - if (shouldCaptureClientError(error)) - void captureClientError(error, { source: 'auth', action: 'login' }); - setLoginStatus(getClientAuthErrorMessage(error, '登录失败')); + // 预期业务拒绝(密码输错 / 密码长度不合规 / 手机号格式等)在这里消化掉,永不进错误池; + // 真故障与未识别变体带上文交给错误池,指纹仍与显式采集时一致。 + const failure = resolveClientAuthFailure(error, '登录失败'); + setLoginStatus(failure.message); + if (failure.kind === 'report') { + void captureClientError( + new ClientActionError( + failure.message, + { source: 'auth', action: 'login' }, + error, + ), + ); + } } finally { setLoginBusy(false); } diff --git a/apps/ai-game-creator-shell/src/services/clientAuth.ts b/apps/ai-game-creator-shell/src/services/clientAuth.ts index b1840ab85..2af0f6ce7 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuth.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuth.ts @@ -1,6 +1,6 @@ import type { AuthUser } from '../../../../packages/shared/src/contracts/auth'; import { resolveTauriInvoke } from '../app/tauri'; -import { isClientAuthError } from './clientAuthError'; +import { clientAuthErrorNotice, isClientAuthError } from './clientAuthError'; import { subscribeTauriEvent } from './tauriEventSubscription'; /** Rust 认证态事件:只承载状态投影,不含 token 或 refresh 凭据。 */ @@ -52,6 +52,30 @@ export function getClientAuthErrorMessage(error: unknown, fallback: string) { return fallback; } +/** + * 登录 / 发码失败的调用方分流:`notice` 是"用户自己能改或本来就该按未登录处理"的变体, + * 只显示 Rust 文案;`report` 是系统变体 / 未识别变体 / 非结构化拒绝,显示文案之外还要带上下文 + * 交给错误池(见 [`captureClientError`](./errorReporting.ts))。 + * + * 判定只看类型化的 `type`,不做任何文案匹配;这条规则是"输错一次密码不该被引导上报"的判据。 + */ +export type ClientAuthFailureResolution = + | { kind: 'notice'; message: string } + | { kind: 'report'; message: string }; + +export function resolveClientAuthFailure( + error: unknown, + fallback: string, +): ClientAuthFailureResolution { + const structured = isClientAuthError(error); + const notice = structured ? clientAuthErrorNotice(structured) : null; + if (notice !== null) return { kind: 'notice', message: notice }; + return { + kind: 'report', + message: getClientAuthErrorMessage(error, fallback), + }; +} + type RustAuthStateView = { status?: string; user?: AuthUser | null; diff --git a/apps/ai-game-creator-shell/src/services/errorReporting.ts b/apps/ai-game-creator-shell/src/services/errorReporting.ts index 7348b51bf..080bb2e4a 100644 --- a/apps/ai-game-creator-shell/src/services/errorReporting.ts +++ b/apps/ai-game-creator-shell/src/services/errorReporting.ts @@ -25,16 +25,6 @@ export type DiagnosticLogFile = { name: string; content: string }; type WebviewLogLevel = 'debug' | 'info' | 'warn' | 'error' | 'log'; -export function shouldCaptureClientError(error: unknown) { - if (!error || typeof error !== 'object') return true; - const candidate = error as { status?: unknown; networkError?: unknown }; - if (candidate.networkError === true) return true; - if (typeof candidate.status === 'number') { - return candidate.status === 408 || candidate.status >= 500; - } - return true; -} - export async function invokeDiagnostic( invokeFn: (command: string, args?: Record) => Promise, command: string, 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 a12fc9b44..8fc0df66a 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts @@ -228,13 +228,14 @@ export function registerAuthTests() { }); }); - it('shows the backend reason when the password login is rejected', async () => { + it('shows the typed business reason when the password login is rejected', async () => { const invoke = vi.fn(async (command: string) => { if (command === 'read_client_auth_state') { return { status: 'unauthenticated' }; } if (command === 'login_client_with_password') { - throw new Error('手机号或密码错误'); + // Rust 命令的结构化拒绝:前端只按 `type` 分流,不解析文案。 + throw { type: 'phoneOrPasswordMismatch', message: '手机号或密码错误' }; } return null; }); @@ -259,9 +260,10 @@ export function registerAuthTests() { return { status: 'unauthenticated' }; } if (command === 'login_client_with_phone_code') { - throw new Error( - 'network-error: 无法连接登录服务,请确认配套后端或 API 代理已启动后重试', - ); + throw { + type: 'authNetworkUnavailable', + message: '无法连接登录服务,请确认配套后端或 API 代理已启动后重试', + }; } return null; }); @@ -270,7 +272,7 @@ export function registerAuthTests() { expect( await screen.findByText( - 'network-error: 无法连接登录服务,请确认配套后端或 API 代理已启动后重试', + '无法连接登录服务,请确认配套后端或 API 代理已启动后重试', ), ).not.toBeNull(); expect(document.body.textContent).not.toContain('ECONNREFUSED'); diff --git a/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts index 4b10a4a2f..68d8a612a 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts @@ -17,6 +17,7 @@ import { normalizeAuthPhoneInput, readClientAuthState, refreshClientAuthSession, + resolveClientAuthFailure, sendClientPhoneLoginCode, } from '../src/services/clientAuth'; @@ -185,3 +186,52 @@ test('错误文案优先使用服务端原因,缺失时回落到调用方文 ).toBe('手机号或密码错误'); expect(getClientAuthErrorMessage('', '登录失败')).toBe('登录失败'); }); + +test('登录失败分流只看类型化变体:业务拒绝给提示,系统与未识别变体才进池', () => { + expect( + resolveClientAuthFailure( + { type: 'phoneOrPasswordMismatch', message: '手机号或密码错误' }, + '登录失败', + ), + ).toEqual({ kind: 'notice', message: '手机号或密码错误' }); + expect( + resolveClientAuthFailure( + { + type: 'passwordEntryInputRejected', + message: '密码长度需要在 6 到 128 位之间', + }, + '登录失败', + ), + ).toEqual({ kind: 'notice', message: '密码长度需要在 6 到 128 位之间' }); + // 会话 401/403 按"未登录"处理:给提示,但不进错误池。 + expect( + resolveClientAuthFailure( + { type: 'sessionAuthorityRejected', message: '登录已失效,请重新登录' }, + '登录失败', + ), + ).toEqual({ kind: 'notice', message: '登录已失效,请重新登录' }); + // 系统变体与未识别变体:显示 Rust 文案,同时交给错误池。 + expect( + resolveClientAuthFailure( + { type: 'authNetworkUnavailable', message: '无法连接登录服务' }, + '登录失败', + ), + ).toEqual({ kind: 'report', message: '无法连接登录服务' }); + expect( + resolveClientAuthFailure( + { type: 'brandNewRejection', message: '新变体' }, + '登录失败', + ), + ).toEqual({ kind: 'report', message: '新变体' }); + // 非结构化:裸字符串保留原文,其它形状回落调用方文案,不显示 `[object Object]`。 + expect( + resolveClientAuthFailure('network-error: 连接超时', '登录失败'), + ).toEqual({ kind: 'report', message: 'network-error: 连接超时' }); + expect( + resolveClientAuthFailure(new Error('runner clear rejected'), '登录失败'), + ).toEqual({ kind: 'report', message: 'runner clear rejected' }); + expect(resolveClientAuthFailure({ status: 500 }, '登录失败')).toEqual({ + kind: 'report', + message: '登录失败', + }); +});