AGC认证失败按新变体分流并补上下文

- AuthenticatedClient 的三个 catch 列全新变体:无字段变体直接给固定文案,带载荷变体先 as 再读 serverMessage
- 系统变体逐个列出后原样抛出,default 仍用 expectNever 把漏接变体卡在编译期
- clientAuth/ClientAuthErrorWrapper 注释去掉对 message 字段的假设
This commit is contained in:
2026-10-02 02:25:02 +08:00
parent 3ecee09431
commit aed2bb9c34
3 changed files with 210 additions and 120 deletions
@@ -35,18 +35,9 @@ import {
captureClientError,
installWebviewLogBridge,
} from '../services/errorReporting';
import type { LoginCodeMissing } from '../services/generated/LoginCodeMissing';
import type { PasswordEntryInputRejected } from '../services/generated/PasswordEntryInputRejected';
import type { PasswordMissing } from '../services/generated/PasswordMissing';
import type { PermissionDenied } from '../services/generated/PermissionDenied';
import type { PhoneLoginInputRejected } from '../services/generated/PhoneLoginInputRejected';
import type { PhoneNumberInvalid } from '../services/generated/PhoneNumberInvalid';
import type { PhoneOrPasswordMismatch } from '../services/generated/PhoneOrPasswordMismatch';
import type { SendCodeInputRejected } from '../services/generated/SendCodeInputRejected';
import type { ServerAddressRejected } from '../services/generated/ServerAddressRejected';
import type { SessionAuthorityRejected } from '../services/generated/SessionAuthorityRejected';
import type { SmsCodeInvalidOrExpired } from '../services/generated/SmsCodeInvalidOrExpired';
import type { SmsCodeThrottled } from '../services/generated/SmsCodeThrottled';
import type { PasswordLoginRejected } from '../services/generated/PasswordLoginRejected';
import type { PhoneCodeLoginRejected } from '../services/generated/PhoneCodeLoginRejected';
import type { SendCodeRejected } from '../services/generated/SendCodeRejected';
import {
beginPlatformSessionClearTransition,
beginPlatformSessionTransition,
@@ -280,76 +271,120 @@ export function AuthenticatedClient({
if (!(error instanceof ClientAuthErrorWrapper)) throw error;
const failure = error.error;
switch (failure.type) {
// 用户自己能改的输入 / 前置条件:展示原因并停在登录页,不进错误池。
case 'serverAddressRejected': {
const payload = failure as ServerAddressRejected;
setAuthCheckError(payload.message);
setLoginStatus(payload.message);
// 预期业务拒绝:调用方补上本次操作的上下文,展示后不进错误池。
case 'serverAddressEmptyOrTooLong': {
const text = '服务器地址非法: 不能为空或超长';
setAuthCheckError(text);
setLoginStatus(text);
break;
}
case 'serverAddressNotAUrl': {
const text = '服务器地址非法: 不是合法地址';
setAuthCheckError(text);
setLoginStatus(text);
break;
}
case 'serverAddressHasCredentials': {
const text = '服务器地址非法: 不能包含用户名或密码';
setAuthCheckError(text);
setLoginStatus(text);
break;
}
case 'serverAddressHasPathOrQueryOrFragment': {
const text = '服务器地址非法: 不能带路径、查询或片段';
setAuthCheckError(text);
setLoginStatus(text);
break;
}
case 'serverAddressNotHttps': {
const text = '服务器地址非法: 远程地址必须使用 https';
setAuthCheckError(text);
setLoginStatus(text);
break;
}
case 'serverAddressUnsupportedScheme': {
const text = '服务器地址非法: 只支持 http 或 https';
setAuthCheckError(text);
setLoginStatus(text);
break;
}
case 'serverAddressOutsideChannel': {
const text = '服务器地址非法: 不在当前构建渠道范围内';
setAuthCheckError(text);
setLoginStatus(text);
break;
}
case 'phoneNumberInvalid': {
const payload = failure as PhoneNumberInvalid;
setAuthCheckError(payload.message);
setLoginStatus(payload.message);
const text = '手机号无效: 需为纯数字且不超过 32 位';
setAuthCheckError(text);
setLoginStatus(text);
break;
}
case 'passwordMissing': {
const payload = failure as PasswordMissing;
setAuthCheckError(payload.message);
setLoginStatus(payload.message);
const text = '密码无效: 不能为空';
setAuthCheckError(text);
setLoginStatus(text);
break;
}
case 'loginCodeMissing': {
const payload = failure as LoginCodeMissing;
setAuthCheckError(payload.message);
setLoginStatus(payload.message);
const text = '验证码无效: 不能为空';
setAuthCheckError(text);
setLoginStatus(text);
break;
}
case 'passwordEntryInputRejected': {
const payload = failure as PasswordEntryInputRejected;
setAuthCheckError(payload.message);
setLoginStatus(payload.message);
case 'passwordLoginRejected': {
const payload = failure as PasswordLoginRejected;
const text = `登录失败: ${payload.serverMessage ?? '服务端拒绝了本次登录'}`;
setAuthCheckError(text);
setLoginStatus(text);
break;
}
case 'phoneOrPasswordMismatch': {
const payload = failure as PhoneOrPasswordMismatch;
setAuthCheckError(payload.message);
setLoginStatus(payload.message);
const text = '手机号或密码错误';
setAuthCheckError(text);
setLoginStatus(text);
break;
}
case 'sendCodeInputRejected': {
const payload = failure as SendCodeInputRejected;
setAuthCheckError(payload.message);
setLoginStatus(payload.message);
case 'sendCodeRejected': {
const payload = failure as SendCodeRejected;
const text = `发送验证码失败: ${payload.serverMessage ?? '服务端拒绝了本次请求'}`;
setAuthCheckError(text);
setLoginStatus(text);
break;
}
case 'smsCodeThrottled': {
const payload = failure as SmsCodeThrottled;
setAuthCheckError(payload.message);
setLoginStatus(payload.message);
const text = '发送验证码过于频繁,请稍后再试';
setAuthCheckError(text);
setLoginStatus(text);
break;
}
case 'phoneLoginInputRejected': {
const payload = failure as PhoneLoginInputRejected;
setAuthCheckError(payload.message);
setLoginStatus(payload.message);
case 'phoneCodeLoginRejected': {
const payload = failure as PhoneCodeLoginRejected;
const text = `验证码登录失败: ${payload.serverMessage ?? '服务端拒绝了本次请求'}`;
setAuthCheckError(text);
setLoginStatus(text);
break;
}
case 'smsCodeInvalidOrExpired': {
const payload = failure as SmsCodeInvalidOrExpired;
setAuthCheckError(payload.message);
setLoginStatus(payload.message);
case 'smsCodeRejected': {
const text = '验证码错误或已过期';
setAuthCheckError(text);
setLoginStatus(text);
break;
}
// 会话权威失效:Rust 已按未登录处理;走到这里也只按未登录呈现。
case 'sessionAuthorityRejected':
case 'sessionInvalidated':
case 'permissionDenied':
break;
// 系统失败:调用方处理不了,原样抛出 → 全局 unhandledrejection 交给错误池。
case 'authNetworkUnavailable':
case 'authNetworkTimeout':
case 'authNetworkUnreachable':
case 'authServiceUnavailable':
case 'unexpectedRejection':
case 'authResponseMalformed':
case 'authResponseNotJson':
case 'authResponseInvalidBody':
case 'authResponseMissingRefreshCookie':
case 'authResponseMissingUserIdentity':
case 'authResponseServerRejected':
case 'clientSessionPersistFailed':
case 'runtimeSessionInstallFailed':
case 'authClientInitFailed':
@@ -421,7 +456,8 @@ export function AuthenticatedClient({
*
* - 承载:命令失败由 `invokeClientAuth` 装进 `ClientAuthErrorWrapper`,`error` 就是判别联合,
* `failure.type` 是唯一分流键;
* - 业务 / 会话变体:把载荷自带的 `message` 原样给用户,永不进错误池;
* - 业务 / 会话变体:无载荷变体用本 catch 的固定文案;带载荷变体先 `as` 取具名类型,
* 再用它自己的 `serverMessage` 拼上下文(Rust 不预拼用户可见文案),永不进错误池;
* - 系统变体:原样 `throw`,经全局 `unhandledrejection` 交给错误池;
* - `default: expectNever` 保证 Rust 新增变体时这里编译失败。
*
@@ -452,71 +488,98 @@ export function AuthenticatedClient({
if (!(error instanceof ClientAuthErrorWrapper)) throw error;
const failure = error.error;
switch (failure.type) {
case 'serverAddressRejected': {
const payload = failure as ServerAddressRejected;
setLoginStatus(payload.message);
// 预期业务拒绝:调用方补上本次操作的上下文,展示后不进错误池。
case 'serverAddressEmptyOrTooLong': {
setLoginStatus('服务器地址非法: 不能为空或超长');
break;
}
case 'serverAddressNotAUrl': {
setLoginStatus('服务器地址非法: 不是合法地址');
break;
}
case 'serverAddressHasCredentials': {
setLoginStatus('服务器地址非法: 不能包含用户名或密码');
break;
}
case 'serverAddressHasPathOrQueryOrFragment': {
setLoginStatus('服务器地址非法: 不能带路径、查询或片段');
break;
}
case 'serverAddressNotHttps': {
setLoginStatus('服务器地址非法: 远程地址必须使用 https');
break;
}
case 'serverAddressUnsupportedScheme': {
setLoginStatus('服务器地址非法: 只支持 http 或 https');
break;
}
case 'serverAddressOutsideChannel': {
setLoginStatus('服务器地址非法: 不在当前构建渠道范围内');
break;
}
case 'phoneNumberInvalid': {
const payload = failure as PhoneNumberInvalid;
setLoginStatus(payload.message);
setLoginStatus('手机号无效: 需为纯数字且不超过 32 位');
break;
}
case 'passwordMissing': {
const payload = failure as PasswordMissing;
setLoginStatus(payload.message);
setLoginStatus('密码无效: 不能为空');
break;
}
case 'loginCodeMissing': {
const payload = failure as LoginCodeMissing;
setLoginStatus(payload.message);
setLoginStatus('验证码无效: 不能为空');
break;
}
case 'passwordEntryInputRejected': {
const payload = failure as PasswordEntryInputRejected;
setLoginStatus(payload.message);
case 'passwordLoginRejected': {
const payload = failure as PasswordLoginRejected;
setLoginStatus(
`登录失败: ${payload.serverMessage ?? '服务端拒绝了本次登录'}`,
);
break;
}
case 'phoneOrPasswordMismatch': {
const payload = failure as PhoneOrPasswordMismatch;
setLoginStatus(payload.message);
setLoginStatus('手机号或密码错误');
break;
}
case 'sendCodeInputRejected': {
const payload = failure as SendCodeInputRejected;
setLoginStatus(payload.message);
case 'sendCodeRejected': {
const payload = failure as SendCodeRejected;
setLoginStatus(
`发送验证码失败: ${payload.serverMessage ?? '服务端拒绝了本次请求'}`,
);
break;
}
case 'smsCodeThrottled': {
const payload = failure as SmsCodeThrottled;
setLoginStatus(payload.message);
setLoginStatus('发送验证码过于频繁,请稍后再试');
break;
}
case 'phoneLoginInputRejected': {
const payload = failure as PhoneLoginInputRejected;
setLoginStatus(payload.message);
case 'phoneCodeLoginRejected': {
const payload = failure as PhoneCodeLoginRejected;
setLoginStatus(
`验证码登录失败: ${payload.serverMessage ?? '服务端拒绝了本次请求'}`,
);
break;
}
case 'smsCodeInvalidOrExpired': {
const payload = failure as SmsCodeInvalidOrExpired;
setLoginStatus(payload.message);
case 'smsCodeRejected': {
setLoginStatus('验证码错误或已过期');
break;
}
case 'sessionAuthorityRejected': {
const payload = failure as SessionAuthorityRejected;
setLoginStatus(payload.message);
case 'sessionInvalidated': {
setLoginStatus('登录状态已失效,请重新登录');
break;
}
case 'permissionDenied': {
const payload = failure as PermissionDenied;
setLoginStatus(payload.message);
setLoginStatus('当前账号没有权限执行此操作');
break;
}
// 系统失败:调用方处理不了,原样抛出 → 全局 unhandledrejection 交给错误池。
case 'authNetworkUnavailable':
case 'authNetworkTimeout':
case 'authNetworkUnreachable':
case 'authServiceUnavailable':
case 'unexpectedRejection':
case 'authResponseMalformed':
case 'authResponseNotJson':
case 'authResponseInvalidBody':
case 'authResponseMissingRefreshCookie':
case 'authResponseMissingUserIdentity':
case 'authResponseServerRejected':
case 'clientSessionPersistFailed':
case 'runtimeSessionInstallFailed':
case 'authClientInitFailed':
@@ -593,71 +656,98 @@ export function AuthenticatedClient({
if (!(error instanceof ClientAuthErrorWrapper)) throw error;
const failure = error.error;
switch (failure.type) {
case 'serverAddressRejected': {
const payload = failure as ServerAddressRejected;
setLoginStatus(payload.message);
// 预期业务拒绝:调用方补上本次操作的上下文,展示后不进错误池。
case 'serverAddressEmptyOrTooLong': {
setLoginStatus('服务器地址非法: 不能为空或超长');
break;
}
case 'serverAddressNotAUrl': {
setLoginStatus('服务器地址非法: 不是合法地址');
break;
}
case 'serverAddressHasCredentials': {
setLoginStatus('服务器地址非法: 不能包含用户名或密码');
break;
}
case 'serverAddressHasPathOrQueryOrFragment': {
setLoginStatus('服务器地址非法: 不能带路径、查询或片段');
break;
}
case 'serverAddressNotHttps': {
setLoginStatus('服务器地址非法: 远程地址必须使用 https');
break;
}
case 'serverAddressUnsupportedScheme': {
setLoginStatus('服务器地址非法: 只支持 http 或 https');
break;
}
case 'serverAddressOutsideChannel': {
setLoginStatus('服务器地址非法: 不在当前构建渠道范围内');
break;
}
case 'phoneNumberInvalid': {
const payload = failure as PhoneNumberInvalid;
setLoginStatus(payload.message);
setLoginStatus('手机号无效: 需为纯数字且不超过 32 位');
break;
}
case 'passwordMissing': {
const payload = failure as PasswordMissing;
setLoginStatus(payload.message);
setLoginStatus('密码无效: 不能为空');
break;
}
case 'loginCodeMissing': {
const payload = failure as LoginCodeMissing;
setLoginStatus(payload.message);
setLoginStatus('验证码无效: 不能为空');
break;
}
case 'passwordEntryInputRejected': {
const payload = failure as PasswordEntryInputRejected;
setLoginStatus(payload.message);
case 'passwordLoginRejected': {
const payload = failure as PasswordLoginRejected;
setLoginStatus(
`登录失败: ${payload.serverMessage ?? '服务端拒绝了本次登录'}`,
);
break;
}
case 'phoneOrPasswordMismatch': {
const payload = failure as PhoneOrPasswordMismatch;
setLoginStatus(payload.message);
setLoginStatus('手机号或密码错误');
break;
}
case 'sendCodeInputRejected': {
const payload = failure as SendCodeInputRejected;
setLoginStatus(payload.message);
case 'sendCodeRejected': {
const payload = failure as SendCodeRejected;
setLoginStatus(
`发送验证码失败: ${payload.serverMessage ?? '服务端拒绝了本次请求'}`,
);
break;
}
case 'smsCodeThrottled': {
const payload = failure as SmsCodeThrottled;
setLoginStatus(payload.message);
setLoginStatus('发送验证码过于频繁,请稍后再试');
break;
}
case 'phoneLoginInputRejected': {
const payload = failure as PhoneLoginInputRejected;
setLoginStatus(payload.message);
case 'phoneCodeLoginRejected': {
const payload = failure as PhoneCodeLoginRejected;
setLoginStatus(
`验证码登录失败: ${payload.serverMessage ?? '服务端拒绝了本次请求'}`,
);
break;
}
case 'smsCodeInvalidOrExpired': {
const payload = failure as SmsCodeInvalidOrExpired;
setLoginStatus(payload.message);
case 'smsCodeRejected': {
setLoginStatus('验证码错误或已过期');
break;
}
case 'sessionAuthorityRejected': {
const payload = failure as SessionAuthorityRejected;
setLoginStatus(payload.message);
case 'sessionInvalidated': {
setLoginStatus('登录状态已失效,请重新登录');
break;
}
case 'permissionDenied': {
const payload = failure as PermissionDenied;
setLoginStatus(payload.message);
setLoginStatus('当前账号没有权限执行此操作');
break;
}
// 系统失败:调用方处理不了,原样抛出 → 全局 unhandledrejection 交给错误池。
case 'authNetworkUnavailable':
case 'authNetworkTimeout':
case 'authNetworkUnreachable':
case 'authServiceUnavailable':
case 'unexpectedRejection':
case 'authResponseMalformed':
case 'authResponseNotJson':
case 'authResponseInvalidBody':
case 'authResponseMissingRefreshCookie':
case 'authResponseMissingUserIdentity':
case 'authResponseServerRejected':
case 'clientSessionPersistFailed':
case 'runtimeSessionInstallFailed':
case 'authClientInitFailed':
@@ -41,8 +41,8 @@ function requireInvoke() {
*
* **信任映射,不做运行时形状嗅探**:Rust 与 TS 同包发布,认证命令的拒绝就是 ts-rs 生成的
* `ClientAuthError` 判别联合;出现别的形状属于 Tauri / Rust 侧缺陷,调用方 `switch` 的
* `default` 分支仍会把它抛出去上报。包装本身不读变体字段(`message` 之类不保证每个变体都有)、
* 不注入上下文、不兜底文案。
* `default` 分支仍会把它抛出去上报。包装本身不读变体字段、
* 不注入上下文、不拼用户可见文案。
*/
async function invokeClientAuth<T>(
command: string,
@@ -15,8 +15,8 @@ export type ClientErrorReportContext = {
* 认证命令失败的 JS 侧载体:`error` 就是 Rust 的结构化拒绝(ts-rs 生成的 `ClientAuthError`
* 判别联合)。
*
* 本类只把原始拒绝值装成 JS `Error`,不读、不产任何派生值:不读变体上的 `message`(变体不
* 保证都有这个字段),不注入 `source` / `action`,`Error.message` 留空。分流只看类型化的变体,
* 本类只把原始拒绝值装成 JS `Error`,不读、不产任何派生值:不读变体字段,不注入
* `source` / `action`,`Error.message` 留空。分流只看类型化的变体,
* **不要用文案判断**;上报上下文由调用 `captureClientError` 时的显式入参决定。
*/
export class ClientAuthErrorWrapper extends Error {