删除认证失败上报的派生分类层,改为调用方按具体变体分流

- 删除 clientAuthErrorKind / clientAuthErrorNotice,clientAuthError.ts 只留 isClientAuthError 形状读取
- 删除 resolveClientAuthFailure 与 ClientAuthFailureResolution,AuthenticatedClient 直接 switch 具体变体名
- 同步重写相关单测与 ADR 第 3 节前端分流描述
This commit is contained in:
2026-10-01 17:55:54 +08:00
parent c2a27aed2a
commit b561db9249
6 changed files with 56 additions and 221 deletions
@@ -19,10 +19,10 @@ import {
logoutClientAuthSession,
normalizeAuthPhoneInput,
readClientAuthState,
resolveClientAuthFailure,
sendClientPhoneLoginCode,
subscribeClientAuthState,
} from '../services/clientAuth';
import { isClientAuthError } from '../services/clientAuthError';
import {
type ClientServerPreset,
type ClientServerSelection,
@@ -328,25 +328,46 @@ export function AuthenticatedClient({
}, [codeCooldownSeconds]);
/**
* 认证动作失败的统一收口:可识别的业务 / 会话拒绝只给用户提示;真故障与未识别变体带上文交给
* 错误池。`action` 决定错误池指纹,新增认证动作必须走这里、不要在各自的 catch 里另拼一套。
* 认证动作失败的统一收口:**要不要上报由这里按具体变体判**,不做任何文案匹配。
*
* 名单里的是"用户自己能改的输入 / 前置条件"与"会话路由 401/403":只给提示。其余(网络、
* 5xx、写盘、运行时、响应不合法,以及 Rust 新增而这里没接的变体)带上文交给错误池——
* `default` 指向上报,新变体不会被静默吞掉。`action` 决定错误池指纹,新增认证动作必须走这里。
*/
function presentAuthFailure(
error: unknown,
fallback: string,
action: string,
) {
const failure = resolveClientAuthFailure(error, fallback);
setLoginStatus(failure.message);
if (failure.kind === 'report') {
void captureClientError(
new ClientActionError(
failure.message,
{ source: 'auth', action },
error,
),
);
const structured = isClientAuthError(error);
if (structured) {
switch (structured.type) {
// 用户自己能改的输入 / 前置条件,以及会话路由 401/403(按“未登录”处理):
// 这些都给用户提示,不进错误池。
case 'serverAddressRejected':
case 'phoneNumberInvalid':
case 'passwordMissing':
case 'loginCodeMissing':
case 'passwordEntryInputRejected':
case 'phoneOrPasswordMismatch':
case 'sendCodeInputRejected':
case 'smsCodeThrottled':
case 'phoneLoginInputRejected':
case 'smsCodeInvalidOrExpired':
case 'sessionAuthorityRejected':
case 'permissionDenied': {
setLoginStatus(structured.message.trim() || fallback);
return;
}
default:
break;
}
}
const message = getClientAuthErrorMessage(error, fallback);
setLoginStatus(message);
void captureClientError(
new ClientActionError(message, { source: 'auth', action }, error),
);
}
async function handleSendCode() {
@@ -1,10 +1,6 @@
import type { AuthUser } from '../../../../packages/shared/src/contracts/auth';
import { resolveTauriInvoke } from '../app/tauri';
import {
clientAuthErrorKind,
clientAuthErrorNotice,
isClientAuthError,
} from './clientAuthError';
import { isClientAuthError } from './clientAuthError';
import { subscribeTauriEvent } from './tauriEventSubscription';
/** Rust 认证态事件:只承载状态投影,不含 token 或 refresh 凭据。 */
@@ -47,6 +43,8 @@ function requireInvoke() {
* 命令失败现在是结构化的(`ClientAuthError`),这里必须按形状取 Rust 那一份文案;裸字符串
* 是旧的 `Err(String)` 残留与浏览器环境的形态。**非 Error 的其它形状不再做字符串化**——
* `String({type,message})` 只会得到 `[object Object]`,把它当文案显示比回落更糟。
*
* 它只取文案,**不判要不要上报**:那由调用方在 catch 里按具体变体决定。
*/
export function getClientAuthErrorMessage(error: unknown, fallback: string) {
const structured = isClientAuthError(error);
@@ -56,39 +54,6 @@ export function getClientAuthErrorMessage(error: unknown, fallback: string) {
return fallback;
}
/**
* 登录 / 发码失败的调用方分流:`notice` 是"用户自己能改或本来就该按未登录处理"的变体,
* 只显示 Rust 文案;`report` 是系统变体 / 未识别变体 / 非结构化拒绝,显示文案之外还要带上下文
* 交给错误池(见 [`captureClientError`](./errorReporting.ts))。
*
* 判定只看类型化的 `type`(`clientAuthErrorKind`),不做任何文案匹配;这条规则是
* "输错一次密码不该被引导上报"的判据。
*/
export type ClientAuthFailureResolution =
| { kind: 'notice'; message: string }
| { kind: 'report'; message: string };
export function resolveClientAuthFailure(
error: unknown,
fallback: string,
): ClientAuthFailureResolution {
const structured = isClientAuthError(error);
// 分流判据是 `clientAuthErrorKind`,不是"有没有提示":`clientAuthErrorNotice` 对空文案也返回
// `null`,拿它当判据会把"Rust 给了可识别的 input/session 变体、但文案为空"错当成 fault 上报。
if (structured && clientAuthErrorKind(structured) !== 'fault') {
return {
kind: 'notice',
message:
clientAuthErrorNotice(structured) ??
getClientAuthErrorMessage(error, fallback),
};
}
return {
kind: 'report',
message: getClientAuthErrorMessage(error, fallback),
};
}
type RustAuthStateView = {
status?: string;
user?: AuthUser | null;
@@ -6,13 +6,13 @@ export type { ClientAuthError };
* `invoke` 拒绝时拿到的是 Rust 序列化出来的普通对象(不是 `Error`)。这里只做形状读取:
* `type` 是稳定判别键,`message` 是 Rust 生成的可展示文案,**文案不参与任何判断**。
*
* 不在名单里的 `type` 也算"形状合法":新变体会落到 [`clientAuthErrorKind`] 的 `fault`,
* 由调用方交给错误池——这是故意的,见 ADR 的"未识别变体上调"。
* 这里**不做任何分类**:要不要上报由调用方在 catch 里按具体变体判(`switch (error.type)`),
* 不在名单里的新变体由调用方的 `default` 分支交给错误池——见 ADR 的"未识别变体上调"。
*
* **`status` 故意不校验**:`authServiceUnavailable` / `unexpectedRejection` 在生成类型里带必填
* `status`,但全仓没有调用方读它(这两个变体只走 `fault` 分支显示 `message`)。把 `status`
* 纳入校验只会让"缺 `status` 但 `message` 可用"的拒绝落回非结构化分支,把 Rust 的文案换成
* 调用方兜底文案——诊断信息更少、行为更差。将来真要用 `status` 做判断时,先补校验再用。
* `status`,但全仓没有调用方读它(这两个变体只显示 `message`)。把 `status` 纳入校验只会让
* "缺 `status` 但 `message` 可用"的拒绝落回非结构化分支,把 Rust 的文案换成调用方兜底文案
* ——诊断信息更少、行为更差。将来真要用 `status` 做判断时,先补校验再用。
*/
export function isClientAuthError(value: unknown): ClientAuthError | null {
if (!value || typeof value !== 'object') return null;
@@ -21,47 +21,3 @@ export function isClientAuthError(value: unknown): ClientAuthError | 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`,
* 表示"调用方处理不了,交给错误池"。空文案也按"没有提示"处理,避免调用方拿空串当提示显示。
*
* **`null` 同时表示这两件事,所以它不是分流判据**:判定"要不要进错误池"必须用
* [`clientAuthErrorKind`],只有 `fault` 才上报;否则一个文案为空的可识别变体会被误报。
*/
export function clientAuthErrorNotice(error: ClientAuthError): string | null {
if (clientAuthErrorKind(error) === 'fault') return null;
return error.message.trim() || null;
}
@@ -1,12 +1,8 @@
import { describe, expect, it } from 'vitest';
import {
clientAuthErrorKind,
clientAuthErrorNotice,
isClientAuthError,
} from '../src/services/clientAuthError';
import { isClientAuthError } from '../src/services/clientAuthError';
describe('AGC 认证命令错误分流', () => {
describe('AGC 认证命令错误的形状读取', () => {
it('只按形状读取 Rust 的结构化拒绝,裸字符串与 Error 都不算', () => {
expect(
isClientAuthError({
@@ -23,67 +19,21 @@ describe('AGC 认证命令错误分流', () => {
expect(isClientAuthError(null)).toBeNull();
});
it('业务输入给提示、会话变体按未登录、系统与未识别变体交给错误池', () => {
it('未知变体也算形状合法,由调用方按 default 交给错误池', () => {
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');
isClientAuthError({ type: 'brandNewRejection', message: '新变体' }),
).toEqual({ type: 'brandNewRejection', message: '新变体' });
});
it('input / session 原样使用 Rust 文案,fault 与空文案不给提示', () => {
it('status 故意不校验:缺 status 但 message 可用的拒绝仍可读', () => {
expect(
clientAuthErrorNotice({
type: 'phoneOrPasswordMismatch',
message: '手机号或密码错误',
}),
).toBe('手机号或密码错误');
expect(
clientAuthErrorNotice({
type: 'sessionAuthorityRejected',
message: '登录已失效,请重新登录',
}),
).toBe('登录已失效,请重新登录');
expect(
clientAuthErrorNotice({
isClientAuthError({
type: 'authServiceUnavailable',
status: 503,
message: '登录服务暂时不可用',
}),
).toBeNull();
expect(
clientAuthErrorNotice({ type: 'phoneNumberInvalid', message: ' ' }),
).toBeNull();
).toEqual({
type: 'authServiceUnavailable',
message: '登录服务暂时不可用',
});
});
});
@@ -17,7 +17,6 @@ import {
normalizeAuthPhoneInput,
readClientAuthState,
refreshClientAuthSession,
resolveClientAuthFailure,
sendClientPhoneLoginCode,
} from '../src/services/clientAuth';
@@ -186,60 +185,3 @@ test('错误文案优先使用服务端原因,缺失时回落到调用方文
).toBe('手机号或密码错误');
expect(getClientAuthErrorMessage('', '登录失败')).toBe('登录失败');
});
test('登录失败分流只看类型化变体:业务拒绝给提示,系统与未识别变体才进池', () => {
expect(
resolveClientAuthFailure(
{ type: 'phoneOrPasswordMismatch', message: '手机号或密码错误' },
'登录失败',
),
).toEqual({ kind: 'notice', message: '手机号或密码错误' });
expect(
resolveClientAuthFailure(
{
type: 'passwordEntryInputRejected',
message: '密码长度需要在 6 到 128 位之间',
},
'登录失败',
),
).toEqual({ kind: 'notice', message: '密码长度需要在 6 到 128 位之间' });
// 会话 401/403 按"未登录"处理:给提示,但不进错误池。
expect(
resolveClientAuthFailure(
{ type: 'sessionAuthorityRejected', message: '登录已失效,请重新登录' },
'登录失败',
),
).toEqual({ kind: 'notice', message: '登录已失效,请重新登录' });
// 文案为空的可识别变体仍按"用户可改"处理:给兜底提示,不进错误池
// (分流判据是 kind,不是"有没有提示文案")。
expect(
resolveClientAuthFailure(
{ type: 'phoneNumberInvalid', message: ' ' },
'登录失败',
),
).toEqual({ kind: 'notice', message: '登录失败' });
// 系统变体与未识别变体:显示 Rust 文案,同时交给错误池。
expect(
resolveClientAuthFailure(
{ type: 'authNetworkUnavailable', message: '无法连接登录服务' },
'登录失败',
),
).toEqual({ kind: 'report', message: '无法连接登录服务' });
expect(
resolveClientAuthFailure(
{ type: 'brandNewRejection', message: '新变体' },
'登录失败',
),
).toEqual({ kind: 'report', message: '新变体' });
// 非结构化:裸字符串保留原文,其它形状回落调用方文案,不显示 `[object Object]`。
expect(
resolveClientAuthFailure('network-error: 连接超时', '登录失败'),
).toEqual({ kind: 'report', message: 'network-error: 连接超时' });
expect(
resolveClientAuthFailure(new Error('runner clear rejected'), '登录失败'),
).toEqual({ kind: 'report', message: 'runner clear rejected' });
expect(resolveClientAuthFailure({ status: 500 }, '登录失败')).toEqual({
kind: 'report',
message: '登录失败',
});
});
@@ -55,8 +55,9 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接
### 3. 前端按变体分流,认不出就交池
- `isClientAuthError` 只做形状读取(`type` 是稳定判别键),`clientAuthErrorKind` 用
`switch (error.type)` 给出 `input` / `session` / `fault` 三类;`fault` 表示"认不出或宿主/环境事实"。
- `isClientAuthError` 只做形状读取(`type` 是稳定判别键),**不做分类、不产出派生值**。
- 调用方自己 `switch (error.type)`,逐个列出具体变体:业务输入 / 会话变体给提示,`default`(系统变体
与 Rust 新增而界面没接的变体)带上文交池。判据是具体变体名,不是聚合出来的类别,也不是文案匹配。
- 认不出、系统类、非结构化拒绝 → 调用方包成 `ClientActionError(message, context, cause)` 交给
`captureClientError`:`instanceof` 解包 `context` 与 `cause`,指纹/展示字段与今天一致。
**不把 rejection 留在没人接手的 Promise 上**:`onSubmit` / `onClick` 这类 `void` 掉的 handler 抛错