AGC 认证失败改用 ClientAuthErrorWrapper 薄包装
- clientActionError.ts 改名 clientAuthErrorWrapper.ts,类名同步为 ClientAuthErrorWrapper,error 字段标注 ClientAuthError - invokeClientAuth 改为薄包装:不读变体 message、不塞 context,只把拒绝原样装成 JS Error - 三处 catch 改用 error.error 分流,每个 case 继续用 as 取具名载荷 - captureClientError 从 error 字段取原始错误;上下文只认显式入参 - 载体、host、错误上报与 appSurface 用例同步新载体与新默认文案
This commit is contained in:
@@ -11,7 +11,6 @@ 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,
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
sendClientPhoneLoginCode,
|
||||
subscribeClientAuthState,
|
||||
} from '../services/clientAuth';
|
||||
import { ClientAuthErrorWrapper } from '../services/clientAuthErrorWrapper';
|
||||
import {
|
||||
type ClientServerPreset,
|
||||
type ClientServerSelection,
|
||||
@@ -35,7 +35,6 @@ 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';
|
||||
@@ -278,8 +277,8 @@ export function AuthenticatedClient({
|
||||
if (!isActiveRun()) return;
|
||||
// 失败一律先离开检查态:系统变体虽然要原样抛出上报,界面也不能卡在 loading。
|
||||
setAuthStatus('unauthenticated');
|
||||
if (!(error instanceof ClientActionError)) throw error;
|
||||
const failure = error.cause as ClientAuthError;
|
||||
if (!(error instanceof ClientAuthErrorWrapper)) throw error;
|
||||
const failure = error.error;
|
||||
switch (failure.type) {
|
||||
// 用户自己能改的输入 / 前置条件:展示原因并停在登录页,不进错误池。
|
||||
case 'serverAddressRejected': {
|
||||
@@ -420,7 +419,7 @@ export function AuthenticatedClient({
|
||||
/**
|
||||
* 认证失败的分流判据直接写在每个 catch 里:
|
||||
*
|
||||
* - 承载:命令失败由 `invokeClientAuth` 装进 `ClientActionError`,`cause` 就是判别联合,
|
||||
* - 承载:命令失败由 `invokeClientAuth` 装进 `ClientAuthErrorWrapper`,`error` 就是判别联合,
|
||||
* `failure.type` 是唯一分流键;
|
||||
* - 业务 / 会话变体:把载荷自带的 `message` 原样给用户,永不进错误池;
|
||||
* - 系统变体:原样 `throw`,经全局 `unhandledrejection` 交给错误池;
|
||||
@@ -450,8 +449,8 @@ export function AuthenticatedClient({
|
||||
setCodeCooldownSeconds(Math.max(0, Math.floor(response.cooldownSeconds)));
|
||||
setLoginStatus(`验证码已发送,${response.expiresInSeconds} 秒内有效`);
|
||||
} catch (error) {
|
||||
if (!(error instanceof ClientActionError)) throw error;
|
||||
const failure = error.cause as ClientAuthError;
|
||||
if (!(error instanceof ClientAuthErrorWrapper)) throw error;
|
||||
const failure = error.error;
|
||||
switch (failure.type) {
|
||||
case 'serverAddressRejected': {
|
||||
const payload = failure as ServerAddressRejected;
|
||||
@@ -591,8 +590,8 @@ export function AuthenticatedClient({
|
||||
setCode('');
|
||||
setPassword('');
|
||||
} catch (error) {
|
||||
if (!(error instanceof ClientActionError)) throw error;
|
||||
const failure = error.cause as ClientAuthError;
|
||||
if (!(error instanceof ClientAuthErrorWrapper)) throw error;
|
||||
const failure = error.error;
|
||||
switch (failure.type) {
|
||||
case 'serverAddressRejected': {
|
||||
const payload = failure as ServerAddressRejected;
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* 错误上报上下文:`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;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AuthUser } from '../../../../packages/shared/src/contracts/auth';
|
||||
import { resolveTauriInvoke } from '../app/tauri';
|
||||
import { ClientActionError } from './clientActionError';
|
||||
import { ClientAuthErrorWrapper } from './clientAuthErrorWrapper';
|
||||
import type { ClientAuthError } from './generated/ClientAuthError';
|
||||
import { subscribeTauriEvent } from './tauriEventSubscription';
|
||||
|
||||
@@ -37,11 +37,12 @@ function requireInvoke() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证命令的统一入口:把 Tauri 的拒绝装进已有的 `ClientActionError`。
|
||||
* 认证命令的统一入口:把 Tauri 的拒绝原样装进已有的 `ClientAuthErrorWrapper`。
|
||||
*
|
||||
* **信任映射,不做运行时形状嗅探**:Rust 与 TS 同包发布,认证命令的拒绝就是 ts-rs 生成的
|
||||
* `ClientAuthError` 判别联合,原样放进 `cause`;出现别的形状属于 Tauri / Rust 侧缺陷,调用方
|
||||
* `switch` 的 `default` 分支仍会把它抛出去上报。也不读文案判断、不兜底文案。
|
||||
* `ClientAuthError` 判别联合;出现别的形状属于 Tauri / Rust 侧缺陷,调用方 `switch` 的
|
||||
* `default` 分支仍会把它抛出去上报。包装本身不读变体字段(`message` 之类不保证每个变体都有)、
|
||||
* 不注入上下文、不兜底文案。
|
||||
*/
|
||||
async function invokeClientAuth<T>(
|
||||
command: string,
|
||||
@@ -55,13 +56,8 @@ async function invokeClientAuth<T>(
|
||||
? await invoke<T>(command)
|
||||
: await invoke<T>(command, args);
|
||||
} catch (error) {
|
||||
// 拒绝值就是判别联合;`message` 是每个变体都有的展示文案,分流只按 `type`。
|
||||
const failure = error as ClientAuthError;
|
||||
throw new ClientActionError(
|
||||
failure.message,
|
||||
{ source: 'auth', action: command },
|
||||
failure,
|
||||
);
|
||||
// 薄包装:原样把 Rust 的拒绝装成 JS Error;不读字段、不加字段。
|
||||
throw new ClientAuthErrorWrapper(error as ClientAuthError);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +76,7 @@ type RustAuthRefreshView =
|
||||
* 恢复登录态。
|
||||
*
|
||||
* 凭据续期、当前用户复核与本机运行时会话安装都在 Rust 内完成;读状态失败就是命令失败,
|
||||
* 由 `invokeClientAuth` 装进 `ClientActionError`(`cause` 是判别联合),不再有第三态投影。
|
||||
* 由 `invokeClientAuth` 装进 `ClientAuthErrorWrapper`(`error` 是判别联合),不再有第三态投影。
|
||||
*/
|
||||
export async function readClientAuthState(
|
||||
expectedApiBaseUrl?: string,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ClientAuthError } from './generated/ClientAuthError';
|
||||
|
||||
/**
|
||||
* 错误上报上下文:`source` 是错误池的一级维度,`action` / `page` 用于细分指纹。
|
||||
*
|
||||
* 含义与 [`captureClientError`](./errorReporting.ts) 的入参完全一致。
|
||||
*/
|
||||
export type ClientErrorReportContext = {
|
||||
source: string;
|
||||
action?: string;
|
||||
page?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 认证命令失败的 JS 侧载体:`error` 就是 Rust 的结构化拒绝(ts-rs 生成的 `ClientAuthError`
|
||||
* 判别联合)。
|
||||
*
|
||||
* 本类只把原始拒绝值装成 JS `Error`,不读、不产任何派生值:不读变体上的 `message`(变体不
|
||||
* 保证都有这个字段),不注入 `source` / `action`,`Error.message` 留空。分流只看类型化的变体,
|
||||
* **不要用文案判断**;上报上下文由调用 `captureClientError` 时的显式入参决定。
|
||||
*/
|
||||
export class ClientAuthErrorWrapper extends Error {
|
||||
readonly error: ClientAuthError;
|
||||
|
||||
constructor(error: ClientAuthError) {
|
||||
super();
|
||||
this.name = 'ClientAuthErrorWrapper';
|
||||
this.error = error;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
import {
|
||||
ClientActionError,
|
||||
ClientAuthErrorWrapper,
|
||||
type ClientErrorReportContext,
|
||||
} from './clientActionError';
|
||||
} from './clientAuthErrorWrapper';
|
||||
import {
|
||||
ackErrorReports,
|
||||
getPendingErrorReports,
|
||||
@@ -80,24 +80,25 @@ export async function captureClientError(
|
||||
error: unknown,
|
||||
context: Partial<ClientErrorReportContext> = {},
|
||||
) {
|
||||
// 调用方"带上文重抛"时用 ClientActionError 承载上下文与原始拒绝值;显式传参作为兜底。
|
||||
const actionError = error instanceof ClientActionError ? error : null;
|
||||
const errorValue = error instanceof Error ? error : new Error(String(error));
|
||||
// ClientAuthErrorWrapper 只把原始拒绝值装成 JS Error:真的 message / stack 在 `error` 字段里,
|
||||
// 载体自身是空文案。结构化 Rust 拒绝没有 JS Error 语义,落回默认文案。
|
||||
const actionError = error instanceof ClientAuthErrorWrapper ? error : null;
|
||||
// 类型上 `error` 是 ClientAuthError;Tauri 在映射外抛 Error 属于它的缺陷,这里按 unknown 兜底取文案/栈。
|
||||
const carried: unknown = actionError?.error;
|
||||
const errorValue =
|
||||
carried instanceof Error
|
||||
? carried
|
||||
: error instanceof Error
|
||||
? error
|
||||
: new Error(String(error));
|
||||
const message = errorValue.message || '未知客户端错误';
|
||||
// 原始失败的栈信息比包装点更有诊断价值(包装点只是 catch 的位置)。
|
||||
const stackSource =
|
||||
actionError?.cause instanceof Error && actionError.cause.stack
|
||||
? actionError.cause
|
||||
: errorValue;
|
||||
const stack = stackSource.stack
|
||||
? stackSource.stack.slice(0, 8_000)
|
||||
: undefined;
|
||||
const stack = errorValue.stack?.slice(0, 8_000);
|
||||
return reportClientError({
|
||||
source: actionError?.context.source ?? context.source ?? 'client',
|
||||
source: context.source ?? 'client',
|
||||
message,
|
||||
stack,
|
||||
action: actionError?.context.action ?? context.action,
|
||||
page: actionError?.context.page ?? context.page,
|
||||
action: context.action,
|
||||
page: context.page,
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach } from 'vitest';
|
||||
|
||||
import { ClientAuthErrorWrapper } from '../../src/services/clientAuthErrorWrapper';
|
||||
import { AGC_DEVELOPMENT_API_BASE_URL } from '../../src/services/clientHttp';
|
||||
import {
|
||||
currentPlatformSessionGeneration,
|
||||
@@ -294,7 +295,10 @@ export function registerAuthTests() {
|
||||
|
||||
// 先等系统变体真的经 unhandledrejection 抛出,再断言它没落到登录页文案。
|
||||
await waitFor(() => expect(reasons).toHaveLength(1));
|
||||
expect(reasons[0]).toMatchObject({
|
||||
// 载体自身 message 留空;诊断信息在 error 字段里,由上报链路取。
|
||||
expect(reasons[0]).toBeInstanceOf(ClientAuthErrorWrapper);
|
||||
expect((reasons[0] as ClientAuthErrorWrapper).error).toMatchObject({
|
||||
type: 'authNetworkUnavailable',
|
||||
message: '无法连接登录服务,请确认配套后端或 API 代理已启动后重试',
|
||||
});
|
||||
expect(screen.queryByText(/无法连接登录服务/u)).toBeNull();
|
||||
|
||||
@@ -122,9 +122,8 @@ describe('认证失败的上报判据', () => {
|
||||
|
||||
await waitFor(() => expect(reportCalls()).toHaveLength(1));
|
||||
expect(reportCalls()[0]?.[1]).toMatchObject({
|
||||
source: 'auth',
|
||||
action: 'login_client_with_password',
|
||||
message,
|
||||
source: 'unhandledrejection',
|
||||
message: '未知客户端错误',
|
||||
});
|
||||
expect(screen.queryByText(message)).toBeNull();
|
||||
} finally {
|
||||
@@ -147,8 +146,7 @@ describe('认证失败的上报判据', () => {
|
||||
|
||||
await waitFor(() => expect(reportCalls()).toHaveLength(1));
|
||||
expect(reportCalls()[0]?.[1]).toMatchObject({
|
||||
source: 'auth',
|
||||
action: 'login_client_with_password',
|
||||
source: 'unhandledrejection',
|
||||
message: 'IPC 桥接不可用',
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
/**
|
||||
* 认证命令失败的 JS 侧载体。
|
||||
*
|
||||
* Rust 结构化拒绝(普通对象)必须被 `invokeClientAuth` 装进已有的 `ClientActionError`:
|
||||
* `cause` 就是 ts-rs 生成的判别联合,`context.action` 是命令名;分流只按 `cause.type`,不读文案。
|
||||
* 另外用编译期用例钉住"每个变体一个具名载荷类型":漏列任何变体,`expectNever(error)` 都编译不过。
|
||||
* `invokeClientAuth` 是薄包装:Rust 的结构化拒绝原样装进 `ClientAuthErrorWrapper.error`,不读变体
|
||||
* 字段(`message` 之类不保证每个变体都有)、不注入上下文、载体自身 `message` 留空;分流只按
|
||||
* `error.error.type`。另外用编译期用例钉住"每个变体一个具名载荷类型":漏列任何变体,
|
||||
* `expectNever(error)` 都编译不过。
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ClientActionError } from '../src/services/clientActionError';
|
||||
import { loginClientWithPassword } from '../src/services/clientAuth';
|
||||
import { ClientAuthErrorWrapper } from '../src/services/clientAuthErrorWrapper';
|
||||
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,11 +89,9 @@ function installInvoke(handler: () => Promise<unknown>) {
|
||||
}
|
||||
|
||||
describe('认证命令失败的 JS 侧载体', () => {
|
||||
it('结构化拒绝装进 ClientActionError,cause 是判别联合', async () => {
|
||||
const rejection = {
|
||||
type: 'phoneNumberInvalid',
|
||||
message: '请输入正确的手机号',
|
||||
};
|
||||
it('结构化拒绝原样装进 error 字段,不读变体字段', async () => {
|
||||
// 故意不带 message:包装层绝不能假设每个变体都有这个字段。
|
||||
const rejection = { type: 'phoneNumberInvalid', status: 400 };
|
||||
installInvoke(async () => {
|
||||
throw rejection;
|
||||
});
|
||||
@@ -103,13 +102,9 @@ describe('认证命令失败的 JS 侧载体', () => {
|
||||
'https://dev.genarrative.world',
|
||||
).catch((error: unknown) => error);
|
||||
|
||||
expect(failure).toBeInstanceOf(ClientActionError);
|
||||
expect((failure as ClientActionError).cause).toEqual(rejection);
|
||||
expect((failure as ClientActionError).context).toEqual({
|
||||
source: 'auth',
|
||||
action: 'login_client_with_password',
|
||||
});
|
||||
expect((failure as Error).message).toBe('请输入正确的手机号');
|
||||
expect(failure).toBeInstanceOf(ClientAuthErrorWrapper);
|
||||
expect((failure as ClientAuthErrorWrapper).error).toBe(rejection);
|
||||
expect((failure as Error).message).toBe('');
|
||||
});
|
||||
|
||||
it('认证桥未安装时保持原样抛出,不包装成命令失败', async () => {
|
||||
@@ -136,30 +131,9 @@ describe('认证命令失败的 JS 侧载体', () => {
|
||||
'https://dev.genarrative.world',
|
||||
).catch((error: unknown) => error);
|
||||
|
||||
expect(failure).toBeInstanceOf(ClientActionError);
|
||||
expect((failure as Error).message).toBe('IPC 桥接异常');
|
||||
expect((failure as ClientActionError).cause).toBe(rejection);
|
||||
expect((failure as ClientActionError).context).toEqual({
|
||||
source: 'auth',
|
||||
action: 'login_client_with_password',
|
||||
});
|
||||
});
|
||||
|
||||
it('结构化拒绝缺 message 时 Error.message 为空,不造兜底文案', async () => {
|
||||
installInvoke(async () => {
|
||||
throw { type: 'unexpectedRejection', status: 409 };
|
||||
});
|
||||
|
||||
const failure = await loginClientWithPassword(
|
||||
'13800000000',
|
||||
'secret',
|
||||
'https://dev.genarrative.world',
|
||||
).catch((error: unknown) => error);
|
||||
|
||||
expect(failure).toBeInstanceOf(ClientAuthErrorWrapper);
|
||||
expect((failure as Error).message).toBe('');
|
||||
expect((failure as ClientActionError).cause).toMatchObject({
|
||||
type: 'unexpectedRejection',
|
||||
});
|
||||
expect((failure as ClientAuthErrorWrapper).error).toBe(rejection);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -158,14 +158,13 @@ test('没有原生宿主时登录能力明确失败关闭', async () => {
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('结构化失败不降级成投影值,而是以带 cause 的 ClientActionError 拒绝', async () => {
|
||||
test('结构化失败不降级成投影值,而是以携带原始拒绝值的 ClientAuthErrorWrapper 拒绝', async () => {
|
||||
const rejection = {
|
||||
type: 'authNetworkUnavailable',
|
||||
message: '无法连接登录服务',
|
||||
};
|
||||
invoke.mockRejectedValueOnce(rejection);
|
||||
await expect(refreshClientAuthSession('user-1')).rejects.toMatchObject({
|
||||
cause: rejection,
|
||||
context: { source: 'auth', action: 'refresh_client_auth_session' },
|
||||
error: rejection,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,7 +71,7 @@ vi.mock('@tauri-apps/api/core', () => ({
|
||||
}));
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
import { ClientActionError } from '../src/services/clientActionError';
|
||||
import { ClientAuthErrorWrapper } from '../src/services/clientAuthErrorWrapper';
|
||||
import {
|
||||
ackClientErrorEventsWithRetry,
|
||||
captureAgentRuntimeError,
|
||||
@@ -209,23 +209,21 @@ describe('客户端错误报告池', () => {
|
||||
expect(await getPendingClientErrorEvents()).toEqual([event]);
|
||||
});
|
||||
|
||||
it('解包调用方补的上报上下文与原始错误', async () => {
|
||||
const original = new Error('无法连接登录服务,请确认网络后重试');
|
||||
it('解包 ClientAuthErrorWrapper 里的原始拒绝值,上下文只认显式入参', async () => {
|
||||
const original = {
|
||||
type: 'authNetworkUnavailable',
|
||||
message: '无法连接登录服务,请确认网络后重试',
|
||||
};
|
||||
|
||||
await captureClientError(
|
||||
new ClientActionError(
|
||||
'无法连接登录服务,请确认网络后重试',
|
||||
{ source: 'auth', action: 'login' },
|
||||
original,
|
||||
),
|
||||
// 显式入参是兜底:ClientActionError 自带的上下文优先。
|
||||
{ source: 'unhandledrejection' },
|
||||
);
|
||||
await captureClientError(new ClientAuthErrorWrapper(original), {
|
||||
source: 'unhandledrejection',
|
||||
action: 'login',
|
||||
});
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('report_client_error', {
|
||||
source: 'auth',
|
||||
message: '无法连接登录服务,请确认网络后重试',
|
||||
stack: original.stack,
|
||||
source: 'unhandledrejection',
|
||||
message: '未知客户端错误',
|
||||
stack: expect.any(String),
|
||||
action: 'login',
|
||||
page: undefined,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user