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:
@@ -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: '登录失败',
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user