From e43efeb2699fe8c527b817f9b2a743a78a4298d3 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 01:27:18 +0800 Subject: [PATCH] =?UTF-8?q?AGC=20=E8=AE=A4=E8=AF=81=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E5=8E=BB=E6=8E=89=E6=89=8B=E5=86=99=E8=BD=BD=E4=BD=93=E7=B1=BB?= =?UTF-8?q?=EF=BC=8C=E7=9B=B4=E6=8E=A5=E7=94=A8=20ClientActionError=20+=20?= =?UTF-8?q?=E7=94=9F=E6=88=90=E5=88=A4=E5=88=AB=E8=81=94=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 clientAuthFailure.ts,invokeClientAuth 改为把拒绝装进已有 ClientActionError,cause 即 ts-rs 生成的 ClientAuthError - 三处 catch 改为 instanceof ClientActionError 后取 error.cause 分流,每个 case 用 as 取具名载荷 - 认证载体用例改名 clientAuthErrorCarrier.test.ts,断言改为 ClientActionError.cause - clientAuthHost 用例与 auth_session.rs 注释同步新载体 --- .../src/app/AuthenticatedClient.tsx | 95 ++++++++++--------- .../src/services/clientAuth.ts | 25 +++-- .../src/services/clientAuthFailure.ts | 30 ------ ...test.ts => clientAuthErrorCarrier.test.ts} | 29 +++--- .../tests/clientAuthHost.test.ts | 4 +- 5 files changed, 77 insertions(+), 106 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src/services/clientAuthFailure.ts rename apps/ai-game-creator-shell/tests/{clientAuthFailure.test.ts => clientAuthErrorCarrier.test.ts} (86%) diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index af9ab45f6..f029ec937 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 { loginClientWithPassword, loginClientWithPhoneCode, @@ -20,7 +21,6 @@ import { sendClientPhoneLoginCode, subscribeClientAuthState, } from '../services/clientAuth'; -import { ClientAuthFailure } from '../services/clientAuthFailure'; import { type ClientServerPreset, type ClientServerSelection, @@ -35,6 +35,7 @@ import { captureClientError, installWebviewLogBridge, } from '../services/errorReporting'; +import type { ClientAuthError } from '../services/generated/ClientAuthError'; import type { LoginCodeMissing } from '../services/generated/LoginCodeMissing'; import type { PasswordEntryInputRejected } from '../services/generated/PasswordEntryInputRejected'; import type { PasswordMissing } from '../services/generated/PasswordMissing'; @@ -277,65 +278,66 @@ export function AuthenticatedClient({ if (!isActiveRun()) return; // 失败一律先离开检查态:系统变体虽然要原样抛出上报,界面也不能卡在 loading。 setAuthStatus('unauthenticated'); - if (!(error instanceof ClientAuthFailure)) throw error; - switch (error.payload.type) { + if (!(error instanceof ClientActionError)) throw error; + const failure = error.cause as ClientAuthError; + switch (failure.type) { // 用户自己能改的输入 / 前置条件:展示原因并停在登录页,不进错误池。 case 'serverAddressRejected': { - const payload = error.payload as ServerAddressRejected; + const payload = failure as ServerAddressRejected; setAuthCheckError(payload.message); setLoginStatus(payload.message); break; } case 'phoneNumberInvalid': { - const payload = error.payload as PhoneNumberInvalid; + const payload = failure as PhoneNumberInvalid; setAuthCheckError(payload.message); setLoginStatus(payload.message); break; } case 'passwordMissing': { - const payload = error.payload as PasswordMissing; + const payload = failure as PasswordMissing; setAuthCheckError(payload.message); setLoginStatus(payload.message); break; } case 'loginCodeMissing': { - const payload = error.payload as LoginCodeMissing; + const payload = failure as LoginCodeMissing; setAuthCheckError(payload.message); setLoginStatus(payload.message); break; } case 'passwordEntryInputRejected': { - const payload = error.payload as PasswordEntryInputRejected; + const payload = failure as PasswordEntryInputRejected; setAuthCheckError(payload.message); setLoginStatus(payload.message); break; } case 'phoneOrPasswordMismatch': { - const payload = error.payload as PhoneOrPasswordMismatch; + const payload = failure as PhoneOrPasswordMismatch; setAuthCheckError(payload.message); setLoginStatus(payload.message); break; } case 'sendCodeInputRejected': { - const payload = error.payload as SendCodeInputRejected; + const payload = failure as SendCodeInputRejected; setAuthCheckError(payload.message); setLoginStatus(payload.message); break; } case 'smsCodeThrottled': { - const payload = error.payload as SmsCodeThrottled; + const payload = failure as SmsCodeThrottled; setAuthCheckError(payload.message); setLoginStatus(payload.message); break; } case 'phoneLoginInputRejected': { - const payload = error.payload as PhoneLoginInputRejected; + const payload = failure as PhoneLoginInputRejected; setAuthCheckError(payload.message); setLoginStatus(payload.message); break; } case 'smsCodeInvalidOrExpired': { - const payload = error.payload as SmsCodeInvalidOrExpired; + const payload = failure as SmsCodeInvalidOrExpired; setAuthCheckError(payload.message); setLoginStatus(payload.message); break; @@ -354,7 +356,7 @@ export function AuthenticatedClient({ case 'authClientInitFailed': throw error; default: { - expectNever(error.payload); + expectNever(failure); throw error; } } @@ -418,7 +420,8 @@ export function AuthenticatedClient({ /** * 认证失败的分流判据直接写在每个 catch 里: * - * - 承载:命令失败由 `invokeClientAuth` 转成 `ClientAuthFailure`,`payload.type` 是唯一分流键; + * - 承载:命令失败由 `invokeClientAuth` 装进 `ClientActionError`,`cause` 就是判别联合, + * `failure.type` 是唯一分流键; * - 业务 / 会话变体:把载荷自带的 `message` 原样给用户,永不进错误池; * - 系统变体:原样 `throw`,经全局 `unhandledrejection` 交给错误池; * - `default: expectNever` 保证 Rust 新增变体时这里编译失败。 @@ -447,65 +450,66 @@ export function AuthenticatedClient({ setCodeCooldownSeconds(Math.max(0, Math.floor(response.cooldownSeconds))); setLoginStatus(`验证码已发送,${response.expiresInSeconds} 秒内有效`); } catch (error) { - if (!(error instanceof ClientAuthFailure)) throw error; - switch (error.payload.type) { + if (!(error instanceof ClientActionError)) throw error; + const failure = error.cause as ClientAuthError; + switch (failure.type) { case 'serverAddressRejected': { - const payload = error.payload as ServerAddressRejected; + const payload = failure as ServerAddressRejected; setLoginStatus(payload.message); break; } case 'phoneNumberInvalid': { - const payload = error.payload as PhoneNumberInvalid; + const payload = failure as PhoneNumberInvalid; setLoginStatus(payload.message); break; } case 'passwordMissing': { - const payload = error.payload as PasswordMissing; + const payload = failure as PasswordMissing; setLoginStatus(payload.message); break; } case 'loginCodeMissing': { - const payload = error.payload as LoginCodeMissing; + const payload = failure as LoginCodeMissing; setLoginStatus(payload.message); break; } case 'passwordEntryInputRejected': { - const payload = error.payload as PasswordEntryInputRejected; + const payload = failure as PasswordEntryInputRejected; setLoginStatus(payload.message); break; } case 'phoneOrPasswordMismatch': { - const payload = error.payload as PhoneOrPasswordMismatch; + const payload = failure as PhoneOrPasswordMismatch; setLoginStatus(payload.message); break; } case 'sendCodeInputRejected': { - const payload = error.payload as SendCodeInputRejected; + const payload = failure as SendCodeInputRejected; setLoginStatus(payload.message); break; } case 'smsCodeThrottled': { - const payload = error.payload as SmsCodeThrottled; + const payload = failure as SmsCodeThrottled; setLoginStatus(payload.message); break; } case 'phoneLoginInputRejected': { - const payload = error.payload as PhoneLoginInputRejected; + const payload = failure as PhoneLoginInputRejected; setLoginStatus(payload.message); break; } case 'smsCodeInvalidOrExpired': { - const payload = error.payload as SmsCodeInvalidOrExpired; + const payload = failure as SmsCodeInvalidOrExpired; setLoginStatus(payload.message); break; } case 'sessionAuthorityRejected': { - const payload = error.payload as SessionAuthorityRejected; + const payload = failure as SessionAuthorityRejected; setLoginStatus(payload.message); break; } case 'permissionDenied': { - const payload = error.payload as PermissionDenied; + const payload = failure as PermissionDenied; setLoginStatus(payload.message); break; } @@ -519,7 +523,7 @@ export function AuthenticatedClient({ case 'authClientInitFailed': throw error; default: { - expectNever(error.payload); + expectNever(failure); throw error; } } @@ -587,65 +591,66 @@ export function AuthenticatedClient({ setCode(''); setPassword(''); } catch (error) { - if (!(error instanceof ClientAuthFailure)) throw error; - switch (error.payload.type) { + if (!(error instanceof ClientActionError)) throw error; + const failure = error.cause as ClientAuthError; + switch (failure.type) { case 'serverAddressRejected': { - const payload = error.payload as ServerAddressRejected; + const payload = failure as ServerAddressRejected; setLoginStatus(payload.message); break; } case 'phoneNumberInvalid': { - const payload = error.payload as PhoneNumberInvalid; + const payload = failure as PhoneNumberInvalid; setLoginStatus(payload.message); break; } case 'passwordMissing': { - const payload = error.payload as PasswordMissing; + const payload = failure as PasswordMissing; setLoginStatus(payload.message); break; } case 'loginCodeMissing': { - const payload = error.payload as LoginCodeMissing; + const payload = failure as LoginCodeMissing; setLoginStatus(payload.message); break; } case 'passwordEntryInputRejected': { - const payload = error.payload as PasswordEntryInputRejected; + const payload = failure as PasswordEntryInputRejected; setLoginStatus(payload.message); break; } case 'phoneOrPasswordMismatch': { - const payload = error.payload as PhoneOrPasswordMismatch; + const payload = failure as PhoneOrPasswordMismatch; setLoginStatus(payload.message); break; } case 'sendCodeInputRejected': { - const payload = error.payload as SendCodeInputRejected; + const payload = failure as SendCodeInputRejected; setLoginStatus(payload.message); break; } case 'smsCodeThrottled': { - const payload = error.payload as SmsCodeThrottled; + const payload = failure as SmsCodeThrottled; setLoginStatus(payload.message); break; } case 'phoneLoginInputRejected': { - const payload = error.payload as PhoneLoginInputRejected; + const payload = failure as PhoneLoginInputRejected; setLoginStatus(payload.message); break; } case 'smsCodeInvalidOrExpired': { - const payload = error.payload as SmsCodeInvalidOrExpired; + const payload = failure as SmsCodeInvalidOrExpired; setLoginStatus(payload.message); break; } case 'sessionAuthorityRejected': { - const payload = error.payload as SessionAuthorityRejected; + const payload = failure as SessionAuthorityRejected; setLoginStatus(payload.message); break; } case 'permissionDenied': { - const payload = error.payload as PermissionDenied; + const payload = failure as PermissionDenied; setLoginStatus(payload.message); break; } @@ -659,7 +664,7 @@ export function AuthenticatedClient({ case 'authClientInitFailed': throw error; default: { - expectNever(error.payload); + expectNever(failure); throw error; } } diff --git a/apps/ai-game-creator-shell/src/services/clientAuth.ts b/apps/ai-game-creator-shell/src/services/clientAuth.ts index 19c751d20..c3f0e3c5a 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 { ClientAuthFailure } from './clientAuthFailure'; +import { ClientActionError } from './clientActionError'; import type { ClientAuthError } from './generated/ClientAuthError'; import { subscribeTauriEvent } from './tauriEventSubscription'; @@ -37,11 +37,11 @@ function requireInvoke() { } /** - * 认证命令的统一入口:把 Tauri 的拒绝按 ts-rs 映射转成 JS 侧的 `ClientAuthFailure`。 + * 认证命令的统一入口:把 Tauri 的拒绝装进已有的 `ClientActionError`。 * - * **信任映射,不做运行时形状嗅探**:Rust 与 TS 同包发布,认证命令的拒绝就是 - * `ClientAuthError`;出现别的形状属于 Tauri / Rust 侧的缺陷,调用方 `switch` 的 `default` - * 分支仍会把它抛出去上报。也不读文案判断、不兜底文案。 + * **信任映射,不做运行时形状嗅探**:Rust 与 TS 同包发布,认证命令的拒绝就是 ts-rs 生成的 + * `ClientAuthError` 判别联合,原样放进 `cause`;出现别的形状属于 Tauri / Rust 侧缺陷,调用方 + * `switch` 的 `default` 分支仍会把它抛出去上报。也不读文案判断、不兜底文案。 */ async function invokeClientAuth( command: string, @@ -55,13 +55,12 @@ async function invokeClientAuth( ? await invoke(command) : await invoke(command, args); } catch (error) { - // `message` 只是给 Error 用的可展示字符串,不是分流判据;分流只按 `payload.type`。 - const rejection = error as { message?: unknown }; - throw new ClientAuthFailure( - typeof rejection.message === 'string' ? rejection.message : '', - error as ClientAuthError, - command, - error, + // 拒绝值就是判别联合;`message` 是每个变体都有的展示文案,分流只按 `type`。 + const failure = error as ClientAuthError; + throw new ClientActionError( + failure.message, + { source: 'auth', action: command }, + failure, ); } } @@ -81,7 +80,7 @@ type RustAuthRefreshView = * 恢复登录态。 * * 凭据续期、当前用户复核与本机运行时会话安装都在 Rust 内完成;读状态失败就是命令失败, - * 由 `invokeClientAuth` 转成 `ClientAuthFailure`,不再有第三态投影。 + * 由 `invokeClientAuth` 装进 `ClientActionError`(`cause` 是判别联合),不再有第三态投影。 */ export async function readClientAuthState( expectedApiBaseUrl?: string, diff --git a/apps/ai-game-creator-shell/src/services/clientAuthFailure.ts b/apps/ai-game-creator-shell/src/services/clientAuthFailure.ts deleted file mode 100644 index 4cd4386f8..000000000 --- a/apps/ai-game-creator-shell/src/services/clientAuthFailure.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { ClientActionError } from './clientActionError'; -import type { ClientAuthError } from './generated/ClientAuthError'; - -/** - * 认证命令失败在 JS 侧的载体。 - * - * Tauri 的 `invoke` 拒绝值是普通对象(Rust 序列化的结构化错误),不是 `Error`;直接抛出会被 - * 上报链路降级成 `String(obj)`,类型与文案一起丢掉。`invokeClientAuth` 统一把它转成本类: - * - * - `payload` 是 ts-rs 生成的判别联合,`payload.type` 是唯一的稳定分流键; - * - `context` 固定为 `{ source: 'auth', action: 命令名 }`,给错误池定指纹; - * - `cause` 是原始拒绝值,保留给上报链路取栈与排障。 - * - * 本类只承载事实,**不做分类、不产派生值、不兜底文案**:要不要上报、给不给用户提示,由调用方 - * 在 catch 里按 `payload.type` 判。 - */ -export class ClientAuthFailure extends ClientActionError { - readonly payload: ClientAuthError; - - constructor( - message: string, - payload: ClientAuthError, - command: string, - cause: unknown, - ) { - super(message, { source: 'auth', action: command }, cause); - this.name = 'ClientAuthFailure'; - this.payload = payload; - } -} diff --git a/apps/ai-game-creator-shell/tests/clientAuthFailure.test.ts b/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts similarity index 86% rename from apps/ai-game-creator-shell/tests/clientAuthFailure.test.ts rename to apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts index c093d98a2..cde523737 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthFailure.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts @@ -2,15 +2,14 @@ /** * 认证命令失败的 JS 侧载体。 * - * Rust 结构化拒绝(普通对象)必须被 `invokeClientAuth` 转成 `ClientAuthFailure`,非结构化拒绝 - * 原样抛出;分流只按 `payload.type`,不读文案。另外用编译期用例钉住"每个变体一个具名载荷 - * 类型":漏列任何变体,`expectNever(error)` 都编译不过。 + * Rust 结构化拒绝(普通对象)必须被 `invokeClientAuth` 装进已有的 `ClientActionError`: + * `cause` 就是 ts-rs 生成的判别联合,`context.action` 是命令名;分流只按 `cause.type`,不读文案。 + * 另外用编译期用例钉住"每个变体一个具名载荷类型":漏列任何变体,`expectNever(error)` 都编译不过。 */ import { describe, expect, it, vi } from 'vitest'; import { ClientActionError } from '../src/services/clientActionError'; import { loginClientWithPassword } from '../src/services/clientAuth'; -import { ClientAuthFailure } from '../src/services/clientAuthFailure'; import type { AuthClientInitFailed } from '../src/services/generated/AuthClientInitFailed'; import type { AuthNetworkUnavailable } from '../src/services/generated/AuthNetworkUnavailable'; import type { AuthResponseMalformed } from '../src/services/generated/AuthResponseMalformed'; @@ -88,8 +87,8 @@ function installInvoke(handler: () => Promise) { window.__TAURI__ = { core: { invoke: vi.fn(handler) } } as never; } -describe('ClientAuthFailure', () => { - it('结构化拒绝转成带 payload / context / cause 的 JS 错误', async () => { +describe('认证命令失败的 JS 侧载体', () => { + it('结构化拒绝装进 ClientActionError,cause 是判别联合', async () => { const rejection = { type: 'phoneNumberInvalid', message: '请输入正确的手机号', @@ -104,14 +103,12 @@ describe('ClientAuthFailure', () => { 'https://dev.genarrative.world', ).catch((error: unknown) => error); - expect(failure).toBeInstanceOf(ClientAuthFailure); expect(failure).toBeInstanceOf(ClientActionError); - expect((failure as ClientAuthFailure).payload).toEqual(rejection); - expect((failure as ClientAuthFailure).context).toEqual({ + expect((failure as ClientActionError).cause).toEqual(rejection); + expect((failure as ClientActionError).context).toEqual({ source: 'auth', action: 'login_client_with_password', }); - expect((failure as ClientAuthFailure).cause).toBe(rejection); expect((failure as Error).message).toBe('请输入正确的手机号'); }); @@ -139,10 +136,10 @@ describe('ClientAuthFailure', () => { 'https://dev.genarrative.world', ).catch((error: unknown) => error); - expect(failure).toBeInstanceOf(ClientAuthFailure); + expect(failure).toBeInstanceOf(ClientActionError); expect((failure as Error).message).toBe('IPC 桥接异常'); - expect((failure as ClientAuthFailure).payload).toBe(rejection); - expect((failure as ClientAuthFailure).context).toEqual({ + expect((failure as ClientActionError).cause).toBe(rejection); + expect((failure as ClientActionError).context).toEqual({ source: 'auth', action: 'login_client_with_password', }); @@ -160,9 +157,9 @@ describe('ClientAuthFailure', () => { ).catch((error: unknown) => error); expect((failure as Error).message).toBe(''); - expect((failure as ClientAuthFailure).payload.type).toBe( - 'unexpectedRejection', - ); + expect((failure as ClientActionError).cause).toMatchObject({ + type: 'unexpectedRejection', + }); }); }); diff --git a/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts index d4092efcc..cfeadcab1 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts @@ -158,14 +158,14 @@ test('没有原生宿主时登录能力明确失败关闭', async () => { expect(invoke).not.toHaveBeenCalled(); }); -test('结构化失败不降级成投影值,而是以 ClientAuthFailure 拒绝', async () => { +test('结构化失败不降级成投影值,而是以带 cause 的 ClientActionError 拒绝', async () => { const rejection = { type: 'authNetworkUnavailable', message: '无法连接登录服务', }; invoke.mockRejectedValueOnce(rejection); await expect(refreshClientAuthSession('user-1')).rejects.toMatchObject({ - payload: rejection, + cause: rejection, context: { source: 'auth', action: 'refresh_client_auth_session' }, }); });