diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index 82553f8e1..a77f00f30 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -19,10 +19,10 @@ import { logoutClientAuthSession, normalizeAuthPhoneInput, readClientAuthState, - resolveClientAuthFailure, sendClientPhoneLoginCode, subscribeClientAuthState, } from '../services/clientAuth'; +import { isClientAuthError } from '../services/clientAuthError'; import { type ClientServerPreset, type ClientServerSelection, @@ -328,25 +328,46 @@ export function AuthenticatedClient({ }, [codeCooldownSeconds]); /** - * 认证动作失败的统一收口:可识别的业务 / 会话拒绝只给用户提示;真故障与未识别变体带上文交给 - * 错误池。`action` 决定错误池指纹,新增认证动作必须走这里、不要在各自的 catch 里另拼一套。 + * 认证动作失败的统一收口:**要不要上报由这里按具体变体判**,不做任何文案匹配。 + * + * 名单里的是"用户自己能改的输入 / 前置条件"与"会话路由 401/403":只给提示。其余(网络、 + * 5xx、写盘、运行时、响应不合法,以及 Rust 新增而这里没接的变体)带上文交给错误池—— + * `default` 指向上报,新变体不会被静默吞掉。`action` 决定错误池指纹,新增认证动作必须走这里。 */ function presentAuthFailure( error: unknown, fallback: string, action: string, ) { - const failure = resolveClientAuthFailure(error, fallback); - setLoginStatus(failure.message); - if (failure.kind === 'report') { - void captureClientError( - new ClientActionError( - failure.message, - { source: 'auth', action }, - error, - ), - ); + const structured = isClientAuthError(error); + if (structured) { + switch (structured.type) { + // 用户自己能改的输入 / 前置条件,以及会话路由 401/403(按“未登录”处理): + // 这些都给用户提示,不进错误池。 + case 'serverAddressRejected': + case 'phoneNumberInvalid': + case 'passwordMissing': + case 'loginCodeMissing': + case 'passwordEntryInputRejected': + case 'phoneOrPasswordMismatch': + case 'sendCodeInputRejected': + case 'smsCodeThrottled': + case 'phoneLoginInputRejected': + case 'smsCodeInvalidOrExpired': + case 'sessionAuthorityRejected': + case 'permissionDenied': { + setLoginStatus(structured.message.trim() || fallback); + return; + } + default: + break; + } } + const message = getClientAuthErrorMessage(error, fallback); + setLoginStatus(message); + void captureClientError( + new ClientActionError(message, { source: 'auth', action }, error), + ); } async function handleSendCode() { diff --git a/apps/ai-game-creator-shell/src/services/clientAuth.ts b/apps/ai-game-creator-shell/src/services/clientAuth.ts index 183730522..8b76fd3fb 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuth.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuth.ts @@ -1,10 +1,6 @@ import type { AuthUser } from '../../../../packages/shared/src/contracts/auth'; import { resolveTauriInvoke } from '../app/tauri'; -import { - clientAuthErrorKind, - clientAuthErrorNotice, - isClientAuthError, -} from './clientAuthError'; +import { isClientAuthError } from './clientAuthError'; import { subscribeTauriEvent } from './tauriEventSubscription'; /** Rust 认证态事件:只承载状态投影,不含 token 或 refresh 凭据。 */ @@ -47,6 +43,8 @@ function requireInvoke() { * 命令失败现在是结构化的(`ClientAuthError`),这里必须按形状取 Rust 那一份文案;裸字符串 * 是旧的 `Err(String)` 残留与浏览器环境的形态。**非 Error 的其它形状不再做字符串化**—— * `String({type,message})` 只会得到 `[object Object]`,把它当文案显示比回落更糟。 + * + * 它只取文案,**不判要不要上报**:那由调用方在 catch 里按具体变体决定。 */ export function getClientAuthErrorMessage(error: unknown, fallback: string) { const structured = isClientAuthError(error); @@ -56,39 +54,6 @@ export function getClientAuthErrorMessage(error: unknown, fallback: string) { return fallback; } -/** - * 登录 / 发码失败的调用方分流:`notice` 是"用户自己能改或本来就该按未登录处理"的变体, - * 只显示 Rust 文案;`report` 是系统变体 / 未识别变体 / 非结构化拒绝,显示文案之外还要带上下文 - * 交给错误池(见 [`captureClientError`](./errorReporting.ts))。 - * - * 判定只看类型化的 `type`(`clientAuthErrorKind`),不做任何文案匹配;这条规则是 - * "输错一次密码不该被引导上报"的判据。 - */ -export type ClientAuthFailureResolution = - | { kind: 'notice'; message: string } - | { kind: 'report'; message: string }; - -export function resolveClientAuthFailure( - error: unknown, - fallback: string, -): ClientAuthFailureResolution { - const structured = isClientAuthError(error); - // 分流判据是 `clientAuthErrorKind`,不是"有没有提示":`clientAuthErrorNotice` 对空文案也返回 - // `null`,拿它当判据会把"Rust 给了可识别的 input/session 变体、但文案为空"错当成 fault 上报。 - if (structured && clientAuthErrorKind(structured) !== 'fault') { - return { - kind: 'notice', - message: - clientAuthErrorNotice(structured) ?? - getClientAuthErrorMessage(error, fallback), - }; - } - return { - kind: 'report', - message: getClientAuthErrorMessage(error, fallback), - }; -} - type RustAuthStateView = { status?: string; user?: AuthUser | null; diff --git a/apps/ai-game-creator-shell/src/services/clientAuthError.ts b/apps/ai-game-creator-shell/src/services/clientAuthError.ts index 1ac31eaa0..6029e91ee 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuthError.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuthError.ts @@ -6,13 +6,13 @@ export type { ClientAuthError }; * `invoke` 拒绝时拿到的是 Rust 序列化出来的普通对象(不是 `Error`)。这里只做形状读取: * `type` 是稳定判别键,`message` 是 Rust 生成的可展示文案,**文案不参与任何判断**。 * - * 不在名单里的 `type` 也算"形状合法":新变体会落到 [`clientAuthErrorKind`] 的 `fault`, - * 由调用方交给错误池——这是故意的,见 ADR 的"未识别变体上调"。 + * 这里**不做任何分类**:要不要上报由调用方在 catch 里按具体变体判(`switch (error.type)`), + * 不在名单里的新变体由调用方的 `default` 分支交给错误池——见 ADR 的"未识别变体上调"。 * * **`status` 故意不校验**:`authServiceUnavailable` / `unexpectedRejection` 在生成类型里带必填 - * `status`,但全仓没有调用方读它(这两个变体只走 `fault` 分支显示 `message`)。把 `status` - * 纳入校验只会让"缺 `status` 但 `message` 可用"的拒绝落回非结构化分支,把 Rust 的文案换成 - * 调用方兜底文案——诊断信息更少、行为更差。将来真要用 `status` 做判断时,先补校验再用。 + * `status`,但全仓没有调用方读它(这两个变体只显示 `message`)。把 `status` 纳入校验只会让 + * "缺 `status` 但 `message` 可用"的拒绝落回非结构化分支,把 Rust 的文案换成调用方兜底文案 + * ——诊断信息更少、行为更差。将来真要用 `status` 做判断时,先补校验再用。 */ export function isClientAuthError(value: unknown): ClientAuthError | null { if (!value || typeof value !== 'object') return null; @@ -21,47 +21,3 @@ export function isClientAuthError(value: unknown): ClientAuthError | null { if (typeof candidate.message !== 'string') return null; return value as ClientAuthError; } - -/** - * 变体分流的稳定分类,与 Rust `ClientAuthError` 的三段注释一一对应: - * - * - `input`:用户自己能改的输入 / 前置条件(登录 400/401、发码 429 等),调用方给提示后消化掉。 - * - `session`:会话路由 401/403,调用方按"未登录"处理,不报错也不进池。 - * - `fault`:网络 / 5xx / 写盘 / 运行时 / 响应不合法,以及**未识别变体**,由调用方交给错误池。 - */ -export type ClientAuthErrorKind = 'input' | 'session' | 'fault'; - -export function clientAuthErrorKind( - error: ClientAuthError, -): ClientAuthErrorKind { - switch (error.type) { - case 'serverAddressRejected': - case 'phoneNumberInvalid': - case 'passwordMissing': - case 'loginCodeMissing': - case 'passwordEntryInputRejected': - case 'phoneOrPasswordMismatch': - case 'sendCodeInputRejected': - case 'smsCodeThrottled': - case 'phoneLoginInputRejected': - case 'smsCodeInvalidOrExpired': - return 'input'; - case 'sessionAuthorityRejected': - case 'permissionDenied': - return 'session'; - default: - return 'fault'; - } -} - -/** - * 用户可读的提示:`input` / `session` 原样使用 Rust 生成的那一份文案;`fault` 返回 `null`, - * 表示"调用方处理不了,交给错误池"。空文案也按"没有提示"处理,避免调用方拿空串当提示显示。 - * - * **`null` 同时表示这两件事,所以它不是分流判据**:判定"要不要进错误池"必须用 - * [`clientAuthErrorKind`],只有 `fault` 才上报;否则一个文案为空的可识别变体会被误报。 - */ -export function clientAuthErrorNotice(error: ClientAuthError): string | null { - if (clientAuthErrorKind(error) === 'fault') return null; - return error.message.trim() || null; -} diff --git a/apps/ai-game-creator-shell/tests/clientAuthError.test.ts b/apps/ai-game-creator-shell/tests/clientAuthError.test.ts index cfaadde96..83ec77c2a 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthError.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthError.test.ts @@ -1,12 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { - clientAuthErrorKind, - clientAuthErrorNotice, - isClientAuthError, -} from '../src/services/clientAuthError'; +import { isClientAuthError } from '../src/services/clientAuthError'; -describe('AGC 认证命令错误分流', () => { +describe('AGC 认证命令错误的形状读取', () => { it('只按形状读取 Rust 的结构化拒绝,裸字符串与 Error 都不算', () => { expect( isClientAuthError({ @@ -23,67 +19,21 @@ describe('AGC 认证命令错误分流', () => { expect(isClientAuthError(null)).toBeNull(); }); - it('业务输入给提示、会话变体按未登录、系统与未识别变体交给错误池', () => { + it('未知变体也算形状合法,由调用方按 default 交给错误池', () => { expect( - clientAuthErrorKind({ - type: 'passwordEntryInputRejected', - message: '密码长度需要在 6 到 128 位之间', - }), - ).toBe('input'); - expect( - clientAuthErrorKind({ - type: 'phoneOrPasswordMismatch', - message: '手机号或密码错误', - }), - ).toBe('input'); - expect( - clientAuthErrorKind({ - type: 'sessionAuthorityRejected', - message: '登录已失效,请重新登录', - }), - ).toBe('session'); - expect( - clientAuthErrorKind({ - type: 'authNetworkUnavailable', - message: '无法连接登录服务', - }), - ).toBe('fault'); - expect( - clientAuthErrorKind({ - type: 'runtimeSessionInstallFailed', - message: '本机运行时会话安装失败', - }), - ).toBe('fault'); - // 未识别变体按 fault 处理:Rust 加了变体但界面没接,属于缺陷,必须进池。 - expect( - clientAuthErrorKind( - isClientAuthError({ type: 'brandNewRejection', message: '新变体' })!, - ), - ).toBe('fault'); + isClientAuthError({ type: 'brandNewRejection', message: '新变体' }), + ).toEqual({ type: 'brandNewRejection', message: '新变体' }); }); - it('input / session 原样使用 Rust 文案,fault 与空文案不给提示', () => { + it('status 故意不校验:缺 status 但 message 可用的拒绝仍可读', () => { expect( - clientAuthErrorNotice({ - type: 'phoneOrPasswordMismatch', - message: '手机号或密码错误', - }), - ).toBe('手机号或密码错误'); - expect( - clientAuthErrorNotice({ - type: 'sessionAuthorityRejected', - message: '登录已失效,请重新登录', - }), - ).toBe('登录已失效,请重新登录'); - expect( - clientAuthErrorNotice({ + isClientAuthError({ type: 'authServiceUnavailable', - status: 503, message: '登录服务暂时不可用', }), - ).toBeNull(); - expect( - clientAuthErrorNotice({ type: 'phoneNumberInvalid', message: ' ' }), - ).toBeNull(); + ).toEqual({ + type: 'authServiceUnavailable', + message: '登录服务暂时不可用', + }); }); }); diff --git a/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts index 79b279bd8..e6cef4552 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts @@ -17,7 +17,6 @@ import { normalizeAuthPhoneInput, readClientAuthState, refreshClientAuthSession, - resolveClientAuthFailure, sendClientPhoneLoginCode, } from '../src/services/clientAuth'; @@ -186,60 +185,3 @@ 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: '登录已失效,请重新登录' }); - // 文案为空的可识别变体仍按"用户可改"处理:给兜底提示,不进错误池 - // (分流判据是 kind,不是"有没有提示文案")。 - expect( - resolveClientAuthFailure( - { type: 'phoneNumberInvalid', 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: '登录失败', - }); -}); diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index e896f0188..2420735e5 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -55,8 +55,9 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 ### 3. 前端按变体分流,认不出就交池 -- `isClientAuthError` 只做形状读取(`type` 是稳定判别键),`clientAuthErrorKind` 用 - `switch (error.type)` 给出 `input` / `session` / `fault` 三类;`fault` 表示"认不出或宿主/环境事实"。 +- `isClientAuthError` 只做形状读取(`type` 是稳定判别键),**不做分类、不产出派生值**。 +- 调用方自己 `switch (error.type)`,逐个列出具体变体:业务输入 / 会话变体给提示,`default`(系统变体 + 与 Rust 新增而界面没接的变体)带上文交池。判据是具体变体名,不是聚合出来的类别,也不是文案匹配。 - 认不出、系统类、非结构化拒绝 → 调用方包成 `ClientActionError(message, context, cause)` 交给 `captureClientError`:`instanceof` 解包 `context` 与 `cause`,指纹/展示字段与今天一致。 **不把 rejection 留在没人接手的 Promise 上**:`onSubmit` / `onClick` 这类 `void` 掉的 handler 抛错