From 5013f4385f648c9fc047218f647e24006029497f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 1 Oct 2026 15:27:31 +0800 Subject: [PATCH] =?UTF-8?q?AGC=20=E5=89=8D=E7=AB=AF=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E5=88=86=E6=B5=81=E5=9F=BA=E5=85=83=EF=BC=9AClientActionError?= =?UTF-8?q?=20=E4=B8=8E=E8=AE=A4=E8=AF=81=E5=8F=98=E4=BD=93=E5=88=86?= =?UTF-8?q?=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 clientActionError.ts:ClientActionError 承载 source/action/page 上下文与 cause - 新增 clientAuthError.ts:isClientAuthError 形状读取 + clientAuthErrorKind 的 input/session/fault 分类 + clientAuthErrorNotice 文案 - captureClientError 解包 ClientActionError 的上下文,并优先使用 cause 的栈信息 - getClientAuthErrorMessage 认识结构化拒绝,非 Error 形状不再字符串化成 [object Object] - 新增 clientAuthError.test.ts,并在 errorReporting.test.ts 用解包用例替换 shouldCaptureClientError 用例 --- .../src/services/clientActionError.ts | 32 +++++++ .../src/services/clientAuth.ts | 14 ++- .../src/services/clientAuthError.ts | 59 ++++++++++++ .../src/services/errorReporting.ts | 23 +++-- .../tests/clientAuthError.test.ts | 89 +++++++++++++++++++ .../tests/errorReporting.test.ts | 29 ++++-- 6 files changed, 231 insertions(+), 15 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/services/clientActionError.ts create mode 100644 apps/ai-game-creator-shell/src/services/clientAuthError.ts create mode 100644 apps/ai-game-creator-shell/tests/clientAuthError.test.ts diff --git a/apps/ai-game-creator-shell/src/services/clientActionError.ts b/apps/ai-game-creator-shell/src/services/clientActionError.ts new file mode 100644 index 000000000..2e19e38f6 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/clientActionError.ts @@ -0,0 +1,32 @@ +/** + * 错误上报上下文:`source` 是错误池的一级维度,`action` / `page` 用于细分指纹。 + * + * 含义与 [`captureClientError`](./errorReporting.ts) 的入参完全一致。 + */ +export type ClientErrorReportContext = { + source: string; + action?: string; + page?: string; +}; + +/** + * 调用方判定「这是真故障 / 认不出」时的统一载体:`message` 是可展示文案,`context` 决定错误池的 + * 指纹维度,`cause` 保留原始拒绝值(Rust 的结构化 `ClientAuthError`、裸字符串或 `Error`)。 + * + * 分流只看类型化的变体,**不要用文案判断**。`captureClientError` 用 `instanceof` 解包 + * `context` 与 `cause`,所以它与显式调用 `captureClientError(error, context)` 的指纹、展示字段一致; + * 需要"带上文继续抛出"的调用方也可以直接 `throw` 它。 + */ +export class ClientActionError extends Error { + readonly context: ClientErrorReportContext; + + constructor( + message: string, + context: ClientErrorReportContext, + cause?: unknown, + ) { + super(message, { cause }); + this.name = 'ClientActionError'; + this.context = context; + } +} diff --git a/apps/ai-game-creator-shell/src/services/clientAuth.ts b/apps/ai-game-creator-shell/src/services/clientAuth.ts index acbe37ed3..b1840ab85 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuth.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuth.ts @@ -1,5 +1,6 @@ import type { AuthUser } from '../../../../packages/shared/src/contracts/auth'; import { resolveTauriInvoke } from '../app/tauri'; +import { isClientAuthError } from './clientAuthError'; import { subscribeTauriEvent } from './tauriEventSubscription'; /** Rust 认证态事件:只承载状态投影,不含 token 或 refresh 凭据。 */ @@ -36,10 +37,19 @@ function requireInvoke() { return invoke; } +/** + * 从任意拒绝值里取一条可显示文案。 + * + * 命令失败现在是结构化的(`ClientAuthError`),这里必须按形状取 Rust 那一份文案;裸字符串 + * 是旧的 `Err(String)` 残留与浏览器环境的形态。**非 Error 的其它形状不再做字符串化**—— + * `String({type,message})` 只会得到 `[object Object]`,把它当文案显示比回落更糟。 + */ export function getClientAuthErrorMessage(error: unknown, fallback: string) { + const structured = isClientAuthError(error); + if (structured) return structured.message.trim() || fallback; if (error instanceof Error && error.message.trim()) return error.message; - const message = String(error ?? '').trim(); - return message || fallback; + if (typeof error === 'string') return error.trim() || fallback; + return fallback; } type RustAuthStateView = { diff --git a/apps/ai-game-creator-shell/src/services/clientAuthError.ts b/apps/ai-game-creator-shell/src/services/clientAuthError.ts new file mode 100644 index 000000000..741e1ce6d --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/clientAuthError.ts @@ -0,0 +1,59 @@ +import type { ClientAuthError } from './generated/ClientAuthError'; + +export type { ClientAuthError }; + +/** + * `invoke` 拒绝时拿到的是 Rust 序列化出来的普通对象(不是 `Error`)。这里只做形状读取: + * `type` 是稳定判别键,`message` 是 Rust 生成的可展示文案,**文案不参与任何判断**。 + * + * 不在名单里的 `type` 也算"形状合法":新变体会落到 [`clientAuthErrorKind`] 的 `fault`, + * 由调用方交给错误池——这是故意的,见 ADR 的"未识别变体上调"。 + */ +export function isClientAuthError(value: unknown): ClientAuthError | null { + if (!value || typeof value !== 'object') return null; + const candidate = value as { type?: unknown; message?: unknown }; + if (typeof candidate.type !== 'string' || !candidate.type) return 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`, + * 表示"调用方处理不了,交给错误池"。空文案按"没有提示"处理,避免调用方拿空串当提示显示。 + */ +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/src/services/errorReporting.ts b/apps/ai-game-creator-shell/src/services/errorReporting.ts index e7acc5329..7348b51bf 100644 --- a/apps/ai-game-creator-shell/src/services/errorReporting.ts +++ b/apps/ai-game-creator-shell/src/services/errorReporting.ts @@ -1,5 +1,9 @@ import { invoke } from '@tauri-apps/api/core'; +import { + ClientActionError, + type ClientErrorReportContext, +} from './clientActionError'; import { ackErrorReports, getPendingErrorReports, @@ -84,17 +88,26 @@ export function normalizeDiagnosticText(value: string) { export async function captureClientError( error: unknown, - context: { source?: string; action?: string; page?: string } = {}, + context: Partial = {}, ) { + // 调用方"带上文重抛"时用 ClientActionError 承载上下文与原始拒绝值;显式传参作为兜底。 + const actionError = error instanceof ClientActionError ? error : null; const errorValue = error instanceof Error ? error : new Error(String(error)); const message = errorValue.message || '未知客户端错误'; - const stack = errorValue.stack ? errorValue.stack.slice(0, 8_000) : undefined; + // 原始失败的栈信息比包装点更有诊断价值(包装点只是 catch 的位置)。 + const stackSource = + actionError?.cause instanceof Error && actionError.cause.stack + ? actionError.cause + : errorValue; + const stack = stackSource.stack + ? stackSource.stack.slice(0, 8_000) + : undefined; return reportClientError({ - source: context.source ?? 'client', + source: actionError?.context.source ?? context.source ?? 'client', message, stack, - action: context.action, - page: context.page, + action: actionError?.context.action ?? context.action, + page: actionError?.context.page ?? context.page, }).catch(() => undefined); } diff --git a/apps/ai-game-creator-shell/tests/clientAuthError.test.ts b/apps/ai-game-creator-shell/tests/clientAuthError.test.ts new file mode 100644 index 000000000..cfaadde96 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/clientAuthError.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; + +import { + clientAuthErrorKind, + clientAuthErrorNotice, + isClientAuthError, +} from '../src/services/clientAuthError'; + +describe('AGC 认证命令错误分流', () => { + it('只按形状读取 Rust 的结构化拒绝,裸字符串与 Error 都不算', () => { + expect( + isClientAuthError({ + type: 'phoneOrPasswordMismatch', + message: '手机号或密码错误', + }), + ).toEqual({ type: 'phoneOrPasswordMismatch', message: '手机号或密码错误' }); + expect(isClientAuthError('手机号或密码错误')).toBeNull(); + expect(isClientAuthError(new Error('手机号或密码错误'))).toBeNull(); + expect( + isClientAuthError({ type: '', message: '手机号或密码错误' }), + ).toBeNull(); + expect(isClientAuthError({ type: 'phoneNumberInvalid' })).toBeNull(); + expect(isClientAuthError(null)).toBeNull(); + }); + + it('业务输入给提示、会话变体按未登录、系统与未识别变体交给错误池', () => { + 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'); + }); + + it('input / session 原样使用 Rust 文案,fault 与空文案不给提示', () => { + expect( + clientAuthErrorNotice({ + type: 'phoneOrPasswordMismatch', + message: '手机号或密码错误', + }), + ).toBe('手机号或密码错误'); + expect( + clientAuthErrorNotice({ + type: 'sessionAuthorityRejected', + message: '登录已失效,请重新登录', + }), + ).toBe('登录已失效,请重新登录'); + expect( + clientAuthErrorNotice({ + type: 'authServiceUnavailable', + status: 503, + message: '登录服务暂时不可用', + }), + ).toBeNull(); + expect( + clientAuthErrorNotice({ type: 'phoneNumberInvalid', message: ' ' }), + ).toBeNull(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/errorReporting.test.ts b/apps/ai-game-creator-shell/tests/errorReporting.test.ts index ec1788898..304e2e03e 100644 --- a/apps/ai-game-creator-shell/tests/errorReporting.test.ts +++ b/apps/ai-game-creator-shell/tests/errorReporting.test.ts @@ -71,6 +71,7 @@ vi.mock('@tauri-apps/api/core', () => ({ })); import { invoke } from '@tauri-apps/api/core'; +import { ClientActionError } from '../src/services/clientActionError'; import { ackClientErrorEventsWithRetry, captureAgentRuntimeError, @@ -80,7 +81,6 @@ import { markClientErrorEventsSubmitted, normalizeDiagnosticText, resetClientErrorEventsForTests, - shouldCaptureClientError, submitErrorReportBatch, subscribeClientErrorEvents, } from '../src/services/errorReporting'; @@ -209,13 +209,26 @@ describe('客户端错误报告池', () => { expect(await getPendingClientErrorEvents()).toEqual([event]); }); - it('只采集网络错误、408 和 5xx', () => { - expect(shouldCaptureClientError({ status: 400 })).toBe(false); - expect(shouldCaptureClientError({ status: 401 })).toBe(false); - expect(shouldCaptureClientError({ status: 429 })).toBe(false); - expect(shouldCaptureClientError({ status: 408 })).toBe(true); - expect(shouldCaptureClientError({ status: 503 })).toBe(true); - expect(shouldCaptureClientError({ networkError: true })).toBe(true); + it('解包调用方补的上报上下文与原始错误', async () => { + const original = new Error('无法连接登录服务,请确认网络后重试'); + + await captureClientError( + new ClientActionError( + '无法连接登录服务,请确认网络后重试', + { source: 'auth', action: 'login' }, + original, + ), + // 显式入参是兜底:ClientActionError 自带的上下文优先。 + { source: 'unhandledrejection' }, + ); + + expect(invoke).toHaveBeenCalledWith('report_client_error', { + source: 'auth', + message: '无法连接登录服务,请确认网络后重试', + stack: original.stack, + action: 'login', + page: undefined, + }); }); it('保留 API 路由但隐藏 URL origin 与查询参数', () => {