删除认证失败上报的派生分类层,改为调用方按具体变体分流
- 删除 clientAuthErrorKind / clientAuthErrorNotice,clientAuthError.ts 只留 isClientAuthError 形状读取 - 删除 resolveClientAuthFailure 与 ClientAuthFailureResolution,AuthenticatedClient 直接 switch 具体变体名 - 同步重写相关单测与 ADR 第 3 节前端分流描述
This commit is contained in:
@@ -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 抛错
|
||||
|
||||
Reference in New Issue
Block a user