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
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:
@@ -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 与查询参数', () => {
|
||||
|
||||
Reference in New Issue
Block a user