AGC 前端错误分流基元:ClientActionError 与认证变体分类
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m50s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m9s
Project CI / Backend tests (pull_request) Failing after 16s
Project CI / Frontend tests (pull_request) Successful in 2m3s
Project CI / Repository checks (pull_request) Failing after 14s
Project CI / AI game creator shell web tests (pull_request) Failing after 1m42s
Project CI / Native shell tests (pull_request) Successful in 5m41s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m10s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 11m9s

- 新增 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 用例
This commit is contained in:
2026-10-01 15:27:31 +08:00
parent bb9b0b4d6c
commit 5013f4385f
6 changed files with 231 additions and 15 deletions
@@ -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;
}
}
@@ -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 = {
@@ -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;
}
@@ -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<ClientErrorReportContext> = {},
) {
// 调用方"带上文重抛"时用 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);
}
@@ -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();
});
});
@@ -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 与查询参数', () => {