AGC 登录失败按类型化变体分流:业务拒绝不再进错误池

- AuthenticatedClient 登录/发码 catch 改用 resolveClientAuthFailure:input/session 变体只给提示,fault 变体与未识别拒绝包成 ClientActionError 交给错误池
- clientAuth.ts 新增 resolveClientAuthFailure,判定只看结构化 type,不做文案匹配
- 删除 errorReporting.ts 的 shouldCaptureClientError,不再存在叶子自行判定要不要上报的口径
- clientAuthHost.test.ts 新增分流用例,auth.suite.ts 的登录失败用例改为结构化拒绝并断言提示文案
This commit is contained in:
2026-10-01 15:33:47 +08:00
parent 5013f4385f
commit b61fa52e8f
5 changed files with 110 additions and 27 deletions
@@ -11,6 +11,7 @@ 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 {
getClientAuthErrorMessage,
loginClientWithPassword,
@@ -18,6 +19,7 @@ import {
logoutClientAuthSession,
normalizeAuthPhoneInput,
readClientAuthState,
resolveClientAuthFailure,
sendClientPhoneLoginCode,
subscribeClientAuthState,
} from '../services/clientAuth';
@@ -34,7 +36,6 @@ import {
import {
captureClientError,
installWebviewLogBridge,
shouldCaptureClientError,
} from '../services/errorReporting';
import {
beginPlatformSessionClearTransition,
@@ -347,12 +348,18 @@ export function AuthenticatedClient({
setCodeCooldownSeconds(Math.max(0, Math.floor(response.cooldownSeconds)));
setLoginStatus(`验证码已发送,${response.expiresInSeconds} 秒内有效`);
} catch (error) {
if (shouldCaptureClientError(error))
void captureClientError(error, {
source: 'auth',
action: 'send-login-code',
});
setLoginStatus(error instanceof Error ? error.message : String(error));
// 业务拒绝(手机号格式 / 发码限流等)只给提示;系统变体与未识别变体带上文交给错误池。
const failure = resolveClientAuthFailure(error, '发送验证码失败');
setLoginStatus(failure.message);
if (failure.kind === 'report') {
void captureClientError(
new ClientActionError(
failure.message,
{ source: 'auth', action: 'send-login-code' },
error,
),
);
}
} finally {
setCodeBusy(false);
}
@@ -417,9 +424,19 @@ export function AuthenticatedClient({
setCode('');
setPassword('');
} catch (error) {
if (shouldCaptureClientError(error))
void captureClientError(error, { source: 'auth', action: 'login' });
setLoginStatus(getClientAuthErrorMessage(error, '登录失败'));
// 预期业务拒绝(密码输错 / 密码长度不合规 / 手机号格式等)在这里消化掉,永不进错误池;
// 真故障与未识别变体带上文交给错误池,指纹仍与显式采集时一致。
const failure = resolveClientAuthFailure(error, '登录失败');
setLoginStatus(failure.message);
if (failure.kind === 'report') {
void captureClientError(
new ClientActionError(
failure.message,
{ source: 'auth', action: 'login' },
error,
),
);
}
} finally {
setLoginBusy(false);
}
@@ -1,6 +1,6 @@
import type { AuthUser } from '../../../../packages/shared/src/contracts/auth';
import { resolveTauriInvoke } from '../app/tauri';
import { isClientAuthError } from './clientAuthError';
import { clientAuthErrorNotice, isClientAuthError } from './clientAuthError';
import { subscribeTauriEvent } from './tauriEventSubscription';
/** Rust 认证态事件:只承载状态投影,不含 token 或 refresh 凭据。 */
@@ -52,6 +52,30 @@ export function getClientAuthErrorMessage(error: unknown, fallback: string) {
return fallback;
}
/**
* 登录 / 发码失败的调用方分流:`notice` 是"用户自己能改或本来就该按未登录处理"的变体,
* 只显示 Rust 文案;`report` 是系统变体 / 未识别变体 / 非结构化拒绝,显示文案之外还要带上下文
* 交给错误池(见 [`captureClientError`](./errorReporting.ts))。
*
* 判定只看类型化的 `type`,不做任何文案匹配;这条规则是"输错一次密码不该被引导上报"的判据。
*/
export type ClientAuthFailureResolution =
| { kind: 'notice'; message: string }
| { kind: 'report'; message: string };
export function resolveClientAuthFailure(
error: unknown,
fallback: string,
): ClientAuthFailureResolution {
const structured = isClientAuthError(error);
const notice = structured ? clientAuthErrorNotice(structured) : null;
if (notice !== null) return { kind: 'notice', message: notice };
return {
kind: 'report',
message: getClientAuthErrorMessage(error, fallback),
};
}
type RustAuthStateView = {
status?: string;
user?: AuthUser | null;
@@ -25,16 +25,6 @@ export type DiagnosticLogFile = { name: string; content: string };
type WebviewLogLevel = 'debug' | 'info' | 'warn' | 'error' | 'log';
export function shouldCaptureClientError(error: unknown) {
if (!error || typeof error !== 'object') return true;
const candidate = error as { status?: unknown; networkError?: unknown };
if (candidate.networkError === true) return true;
if (typeof candidate.status === 'number') {
return candidate.status === 408 || candidate.status >= 500;
}
return true;
}
export async function invokeDiagnostic<T>(
invokeFn: (command: string, args?: Record<string, unknown>) => Promise<T>,
command: string,
@@ -228,13 +228,14 @@ export function registerAuthTests() {
});
});
it('shows the backend reason when the password login is rejected', async () => {
it('shows the typed business reason when the password login is rejected', async () => {
const invoke = vi.fn(async (command: string) => {
if (command === 'read_client_auth_state') {
return { status: 'unauthenticated' };
}
if (command === 'login_client_with_password') {
throw new Error('手机号或密码错误');
// Rust 命令的结构化拒绝:前端只按 `type` 分流,不解析文案。
throw { type: 'phoneOrPasswordMismatch', message: '手机号或密码错误' };
}
return null;
});
@@ -259,9 +260,10 @@ export function registerAuthTests() {
return { status: 'unauthenticated' };
}
if (command === 'login_client_with_phone_code') {
throw new Error(
'network-error: 无法连接登录服务,请确认配套后端或 API 代理已启动后重试',
);
throw {
type: 'authNetworkUnavailable',
message: '无法连接登录服务,请确认配套后端或 API 代理已启动后重试',
};
}
return null;
});
@@ -270,7 +272,7 @@ export function registerAuthTests() {
expect(
await screen.findByText(
'network-error: 无法连接登录服务,请确认配套后端或 API 代理已启动后重试',
'无法连接登录服务,请确认配套后端或 API 代理已启动后重试',
),
).not.toBeNull();
expect(document.body.textContent).not.toContain('ECONNREFUSED');
@@ -17,6 +17,7 @@ import {
normalizeAuthPhoneInput,
readClientAuthState,
refreshClientAuthSession,
resolveClientAuthFailure,
sendClientPhoneLoginCode,
} from '../src/services/clientAuth';
@@ -185,3 +186,52 @@ 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: '登录已失效,请重新登录' });
// 系统变体与未识别变体:显示 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: '登录失败',
});
});