Fix/修复登录用户参数等错误被上报 #567

Merged
k88936 merged 77 commits from fix/wrong-report into master 2026-10-02 15:02:27 +08:00
43 changed files with 2786 additions and 441 deletions
@@ -128,14 +128,17 @@ test('协议与 Agent 模式是独立字段,可按模型单独选择', async (
render(<AdminAgcModelsPage token="test" onUnauthorized={vi.fn()} />);
await screen.findByDisplayValue('claude-opus-5-5');
const agentMode = screen.getByLabelText('模型 1 Agent 模式') as HTMLSelectElement;
const agentMode = screen.getByLabelText(
'模型 1 Agent 模式',
) as HTMLSelectElement;
const protocol = screen.getByLabelText('模型 1 协议') as HTMLSelectElement;
expect(agentMode.value).toBe('cc');
expect(protocol.value).toBe('anthropic');
// 执行器下拉里不再混入协议取值。
expect(
Array.from(agentMode.options).map((option) => option.value),
).toEqual(['codex', 'cc']);
expect(Array.from(agentMode.options).map((option) => option.value)).toEqual([
'codex',
'cc',
]);
fireEvent.change(agentMode, { target: { value: 'codex' } });
fireEvent.change(protocol, { target: { value: 'openai_chat' } });
@@ -433,6 +433,7 @@ const APP_INVOKE_BARE_CALL_NAMES = new Set([
'directInvoke',
'invokeInput',
'invokeAuthenticatedInput',
'invokeClientAuth',
'invokeDiagnostic',
]);
@@ -16,8 +16,12 @@
//!
//! 事件载荷(`thread_manager::wire::DirectTurnFailure`)仍然只有 `{kind, message}` 两个字段:
//! 那是**线上协议**,由 [`DirectTurnError::wire_kind`] 与 `Display` 在这一个出口投影出来,不是
//! 另一种状态模型。跨进程边界(`#[tauri::command]`)同样只给前端一个字符串:那是**序列化**,
//! 由 `Display` 一处生成;Rust 侧任何地方都不再解析这个字符串。
//! 另一种状态模型。跨进程边界(`#[tauri::command]`)的**入队失败**载荷是结构化的
//! [`DirectTurnEnqueueFailure`](变体 + 一条可展示 `message`);`message` 有两条来源:
//! 不需要留痕的失败用 [`DirectTurnError`] 的 `Display`,需要留痕的宿主 / 环境事实改用
//! `record_direct_codex_failure` 生成的脱敏诊断收口文案(`direct-codex-failure:v2 …`,同时写
//! `.agent/runtime/errors` 与错误上报池)。其余只需要一句文案的出口仍由 `Display` 生成。
//! 无论哪条来源,Rust 侧任何地方都不解析这个字符串。
//!
//! 谁负责产生哪个变体:
//! - 命令入口与回合编排(`direct_runtime`):调用级拒绝、阶段失败;
@@ -0,0 +1,397 @@
//! AGC 认证命令的结构化错误:从命令入口到出口只传这一种错误。
//!
//! 变体名就是线上的分流键(`type`):前端只按它选通道,**不解析任何文案**,也不对错误文本做匹配。
//!
//! 顶层只放**调用方要分流的类别**;同一类里可枚举的细分原因收进**类型化 `reason` 字段**,
//! 而不是各拆一个变体,也不是字符串。例如服务地址校验的 7 种失败、网络失败的超时/不可达、
//! 响应契约的 5 种破损各自只占一个变体:前端 `switch (failure.type)` 选到类别后,再用
//! `switch (payload.reason)` 在**类型化**的细分上分流,仍然不碰文案。
//!
//! Rust **不预拼用户可见文案**:载荷只装原始事实(服务端 400 的原文、HTTP 状态码、本机 IO /
//! 网络客户端构建失败的原始错误 `detail`),服务端没给原文就是 `None`;前缀与句式由前端调用方在
//! 自己的 catch 分支按当前操作拼接。`detail` 是给调用方分流与诊断用的原始事实,**不直接贴在界面上**;
//! 报告侧由 sanitize 把路径等替换成占位符。
use serde::Serialize;
use ts_rs::TS;
/// 变体名就是线上的分流键(`type`),带载荷的变体持有同名载荷类型。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
#[serde(tag = "type", rename_all = "camelCase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))]
pub(crate) enum ClientAuthError {
// ---- 业务:用户自己能改,调用方给提示,不上报 ----
/// 服务地址校验失败(含渠道范围门禁),细分原因见 [`ServerAddressReason`]。
ServerAddressRejected(ServerAddressRejected),
/// 本地前置校验:手机号为空、超长或不是纯数字(校验器只回 bool,没有更细的事实)。
PhoneNumberInvalid,
/// 本地前置校验:密码为空。
PasswordMissing,
/// 本地前置校验:验证码为空。
LoginCodeMissing,
/// `/api/auth/entry` 返回 400:服务端拒绝本次输入。
PasswordLoginRejected(PasswordLoginRejected),
/// `/api/auth/entry` 返回 401:手机号或密码错误。
PhoneOrPasswordMismatch,
/// `/api/auth/phone/send-code` 返回 400:服务端拒绝本次输入。
SendCodeRejected(SendCodeRejected),
/// `/api/auth/phone/send-code` 返回 429:发送过于频繁。
SmsCodeThrottled,
/// `/api/auth/phone/login` 返回 400/401:服务端拒绝本次验证码登录。
///
/// 401 目前只来自「用户不存在」;该路由验证通过后会即时建号,所以这条分支实际很少触发。
PhoneCodeLoginRejected(PhoneCodeLoginRejected),
// ---- 会话:调用方按"未登录"处理,不给用户报错 ----
/// 会话路由 401:登录态失效。
SessionInvalidated,
/// 会话路由 403:当前账号没有执行此操作的权限。
PermissionDenied,
// ---- 系统:调用方处理不了,带上下文重抛 ----
/// 连接登录服务的传输层失败,细分原因见 [`AuthNetworkReason`]。
AuthNetworkFailure(AuthNetworkFailure),
/// 登录服务 5xx。
AuthServiceUnavailable(AuthServiceUnavailable),
/// 其它未识别的拒绝(未列举的 4xx、登录路由 403 等)。
UnexpectedRejection(UnexpectedRejection),
/// 登录服务响应的契约破损,细分原因见 [`AuthResponseInvalidReason`]。
AuthResponseInvalid(AuthResponseInvalid),
/// 本机登录凭据文件读写失败;`detail` 是原始错误,供调用方与诊断用、不直接展示。
ClientSessionPersistFailed(ClientSessionPersistFailed),
/// 本机运行时会话安装 / 清理失败;`detail` 同上。
RuntimeSessionInstallFailed(RuntimeSessionInstallFailed),
/// 认证网络客户端构建失败;`detail` 是原始错误。
AuthClientInitFailed(AuthClientInitFailed),
}
// ---- 可枚举的细分原因:类型化字段,不是字符串 ----
/// 服务地址校验失败的具体原因。
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))]
pub(crate) enum ServerAddressReason {
/// 为空或超长。
EmptyOrTooLong,
/// 不是合法 URL。
NotAUrl,
/// 带用户名 / 密码。
HasCredentials,
/// 带路径、查询或 fragment。
HasPathOrQueryOrFragment,
/// 非本机地址不是 HTTPS。
NotHttps,
/// scheme 不是 http(s)。
UnsupportedScheme,
/// 发布构建里不在当前构建渠道范围内。
OutsideChannel,
}
/// 连接登录服务失败的具体原因。
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))]
pub(crate) enum AuthNetworkReason {
/// 超时。
Timeout,
/// DNS / 连接被拒 / 读响应失败等。
Unreachable,
}
/// 登录响应契约破损的具体原因。
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))]
pub(crate) enum AuthResponseInvalidReason {
/// 不是合法 JSON。
NotJson,
/// 不是预期结构(缺字段 / 类型不符 / 凭据格式无效)。
InvalidBody,
/// 没有下发新的续期凭据。
MissingRefreshCookie,
/// 没有带上会话主体(用户身份)。
MissingUserIdentity,
/// 响应体里显式拒绝(`ok: false`)。
ServerRejected,
}
// ---- 载荷:只装类型说不出来的事实 ----
/// 服务地址被拒的具体原因。
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))]
pub(crate) struct ServerAddressRejected {
pub(crate) reason: ServerAddressReason,
}
/// `/api/auth/entry` 返回 400 时服务端给的原文。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))]
pub(crate) struct PasswordLoginRejected {
/// 服务端原文可能缺失:缺失是 `null`,Rust 不编造兜底文案。
pub(crate) server_message: Option<String>,
}
/// `/api/auth/phone/send-code` 返回 400 时服务端给的原文。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))]
pub(crate) struct SendCodeRejected {
/// 服务端原文可能缺失:缺失是 `null`,Rust 不编造兜底文案。
pub(crate) server_message: Option<String>,
}
/// `/api/auth/phone/login` 返回 400 时服务端给的原文。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))]
pub(crate) struct PhoneCodeLoginRejected {
/// 服务端原文可能缺失:缺失是 `null`,Rust 不编造兜底文案。
pub(crate) server_message: Option<String>,
}
/// 连接登录服务的传输层失败原因。
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))]
pub(crate) struct AuthNetworkFailure {
pub(crate) reason: AuthNetworkReason,
}
/// 登录服务 5xx 的状态码。
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))]
pub(crate) struct AuthServiceUnavailable {
pub(crate) status: u16,
}
/// 其它未识别拒绝的状态码与服务端原文。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))]
pub(crate) struct UnexpectedRejection {
pub(crate) status: u16,
pub(crate) server_message: Option<String>,
}
/// 登录响应契约破损的原因与服务端原文。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))]
pub(crate) struct AuthResponseInvalid {
pub(crate) reason: AuthResponseInvalidReason,
/// 只有 `serverRejected` 可能带服务端原文;其余是 `null`。
pub(crate) server_message: Option<String>,
}
/// 本机凭据文件读写的原始错误明细。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))]
pub(crate) struct ClientSessionPersistFailed {
pub(crate) detail: String,
}
/// 本机运行时会话安装 / 清理的原始错误明细。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))]
pub(crate) struct RuntimeSessionInstallFailed {
pub(crate) detail: String,
}
/// 认证网络客户端构建失败的原始错误明细。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))]
pub(crate) struct AuthClientInitFailed {
pub(crate) detail: String,
}
impl ClientAuthError {
/// 会话路由的 401/403 是「登录态失效」:调用方据此清会话、按未登录处理,
/// 既不给用户报错,也不进错误报告池。
pub(crate) fn is_authority_failure(&self) -> bool {
matches!(self, Self::SessionInvalidated | Self::PermissionDenied)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wire_variant_names_are_the_frontend_dispatch_keys() {
let cases = [
(
ClientAuthError::ServerAddressRejected(ServerAddressRejected {
reason: ServerAddressReason::NotHttps,
}),
"serverAddressRejected",
),
(ClientAuthError::PhoneNumberInvalid, "phoneNumberInvalid"),
(ClientAuthError::PasswordMissing, "passwordMissing"),
(ClientAuthError::LoginCodeMissing, "loginCodeMissing"),
(
ClientAuthError::PasswordLoginRejected(PasswordLoginRejected {
server_message: Some("x".to_string()),
}),
"passwordLoginRejected",
),
(
ClientAuthError::PhoneOrPasswordMismatch,
"phoneOrPasswordMismatch",
),
(
ClientAuthError::SendCodeRejected(SendCodeRejected {
server_message: Some("x".to_string()),
}),
"sendCodeRejected",
),
(ClientAuthError::SmsCodeThrottled, "smsCodeThrottled"),
(
ClientAuthError::PhoneCodeLoginRejected(PhoneCodeLoginRejected {
server_message: Some("x".to_string()),
}),
"phoneCodeLoginRejected",
),
(ClientAuthError::SessionInvalidated, "sessionInvalidated"),
(ClientAuthError::PermissionDenied, "permissionDenied"),
(
ClientAuthError::AuthNetworkFailure(AuthNetworkFailure {
reason: AuthNetworkReason::Timeout,
}),
"authNetworkFailure",
),
(
ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: 503 }),
"authServiceUnavailable",
),
(
ClientAuthError::UnexpectedRejection(UnexpectedRejection {
status: 409,
server_message: None,
}),
"unexpectedRejection",
),
(
ClientAuthError::AuthResponseInvalid(AuthResponseInvalid {
reason: AuthResponseInvalidReason::ServerRejected,
server_message: None,
}),
"authResponseInvalid",
),
(
ClientAuthError::ClientSessionPersistFailed(ClientSessionPersistFailed {
detail: "x".to_string(),
}),
"clientSessionPersistFailed",
),
(
ClientAuthError::RuntimeSessionInstallFailed(RuntimeSessionInstallFailed {
detail: "x".to_string(),
}),
"runtimeSessionInstallFailed",
),
(
ClientAuthError::AuthClientInitFailed(AuthClientInitFailed {
detail: "x".to_string(),
}),
"authClientInitFailed",
),
];
for (error, expected_type) in cases {
let value = serde_json::to_value(&error).expect("serialize auth error");
assert_eq!(
value.get("type").and_then(|value| value.as_str()),
Some(expected_type)
);
}
}
#[test]
fn unit_variants_serialize_without_payload_fields() {
let value =
serde_json::to_value(ClientAuthError::LoginCodeMissing).expect("serialize auth error");
assert_eq!(value, serde_json::json!({ "type": "loginCodeMissing" }));
}
#[test]
fn reason_fields_are_typed_enums_not_strings() {
let rejected = serde_json::to_value(ClientAuthError::ServerAddressRejected(
ServerAddressRejected {
reason: ServerAddressReason::HasPathOrQueryOrFragment,
},
))
.expect("serialize auth error");
assert_eq!(rejected["type"], "serverAddressRejected");
assert_eq!(rejected["reason"], "hasPathOrQueryOrFragment");
let network =
serde_json::to_value(ClientAuthError::AuthNetworkFailure(AuthNetworkFailure {
reason: AuthNetworkReason::Unreachable,
}))
.expect("serialize auth error");
assert_eq!(network["reason"], "unreachable");
}
#[test]
fn payload_variants_keep_machine_facts_and_server_text() {
let unavailable = serde_json::to_value(ClientAuthError::AuthServiceUnavailable(
AuthServiceUnavailable { status: 503 },
))
.expect("serialize auth error");
assert_eq!(unavailable["type"], "authServiceUnavailable");
assert_eq!(unavailable["status"], 503);
let rejection =
serde_json::to_value(ClientAuthError::UnexpectedRejection(UnexpectedRejection {
status: 409,
server_message: Some("冲突".to_string()),
}))
.expect("serialize auth error");
assert_eq!(rejection["status"], 409);
assert_eq!(rejection["serverMessage"], "冲突");
let invalid =
serde_json::to_value(ClientAuthError::AuthResponseInvalid(AuthResponseInvalid {
reason: AuthResponseInvalidReason::ServerRejected,
server_message: Some("登录服务请求失败".to_string()),
}))
.expect("serialize auth error");
assert_eq!(invalid["reason"], "serverRejected");
assert_eq!(invalid["serverMessage"], "登录服务请求失败");
let persist = serde_json::to_value(ClientAuthError::ClientSessionPersistFailed(
ClientSessionPersistFailed {
detail: "磁盘只读".to_string(),
},
))
.expect("serialize auth error");
assert_eq!(persist["detail"], "磁盘只读");
let init = serde_json::to_value(ClientAuthError::AuthClientInitFailed(
AuthClientInitFailed {
detail: "tls handshake failed".to_string(),
},
))
.expect("serialize auth error");
assert_eq!(init["type"], "authClientInitFailed");
assert_eq!(init["detail"], "tls handshake failed");
}
#[test]
fn only_session_failures_count_as_authority_failures() {
assert!(ClientAuthError::SessionInvalidated.is_authority_failure());
assert!(ClientAuthError::PermissionDenied.is_authority_failure());
assert!(!ClientAuthError::PhoneOrPasswordMismatch.is_authority_failure());
assert!(!ClientAuthError::AuthNetworkFailure(AuthNetworkFailure {
reason: AuthNetworkReason::Timeout,
})
.is_authority_failure());
}
}
File diff suppressed because it is too large Load Diff
@@ -79,6 +79,7 @@ mod agent_native_tools;
mod analytics;
mod asset_generation_tasks;
mod assets;
mod auth_error;
mod auth_session;
mod browser;
mod builtin_plugins;
File diff suppressed because it is too large Load Diff
@@ -84,7 +84,12 @@ const defaultRuntimeConfigDraft: GameCreatorAppConfig = {
};
type RuntimeSettingsSection =
'general' | 'workspace' | 'agents' | 'extensions' | 'advanced' | 'about';
| 'general'
| 'workspace'
| 'agents'
| 'extensions'
| 'advanced'
| 'about';
type RuntimeConfigToast = {
tone: 'success' | 'error';
@@ -1,5 +1,7 @@
import type { AuthUser } from '../../../../packages/shared/src/contracts/auth';
import { resolveTauriInvoke } from '../app/tauri';
import { ClientAuthErrorWrapper } from './clientAuthErrorWrapper';
import type { ClientAuthError } from './generated/ClientAuthError';
import { subscribeTauriEvent } from './tauriEventSubscription';
/** Rust 认证态事件:只承载状态投影,不含 token 或 refresh 凭据。 */
@@ -7,14 +9,12 @@ export const CLIENT_AUTH_STATE_CHANGED_EVENT = 'agc-client-auth-state-changed';
export type ClientAuthState =
| { status: 'authenticated'; user: AuthUser; apiBaseUrl: string }
| { status: 'unauthenticated' }
| { status: 'unavailable'; message: string };
| { status: 'unauthenticated' };
export type ClientAuthRefreshResult =
| { status: 'refreshed'; user: AuthUser }
| { status: 'unauthenticated' }
| { status: 'stale' }
| { status: 'failed'; message: string; authoritative: boolean };
| { status: 'stale' };
export type ClientLoginCodeResult = {
cooldownSeconds: number;
@@ -36,46 +36,64 @@ function requireInvoke() {
return invoke;
}
export function getClientAuthErrorMessage(error: unknown, fallback: string) {
if (error instanceof Error && error.message.trim()) return error.message;
const message = String(error ?? '').trim();
return message || fallback;
/**
* 认证命令的统一入口:把 Tauri 的拒绝原样装进已有的 `ClientAuthErrorWrapper`。
*
* **信任映射,不做运行时形状嗅探**:Rust 与 TS 同包发布,认证命令的拒绝就是 ts-rs 生成的
* `ClientAuthError` 判别联合;出现别的形状属于 Tauri / Rust 侧缺陷,调用方 `switch` 的
* `default` 分支仍会把它抛出去上报。包装本身不读变体字段、
* 不注入上下文、不拼用户可见文案。
*/
async function invokeClientAuth<T>(
command: string,
args?: Record<string, unknown>,
): Promise<T> {
// 认证桥未安装是我们自己的失败关闭错误,不是命令拒绝:放在 try 之外,原样抛出。
const invoke = requireInvoke();
try {
// 不带参数时保持 `invoke(command)` 的单参调用形态,别给命令多塞一个 undefined。
return args === undefined
? await invoke<T>(command)
: await invoke<T>(command, args);
} catch (error) {
// 薄包装:原样把 Rust 的拒绝装成 JS Error;不读字段、不加字段。
throw new ClientAuthErrorWrapper(error as ClientAuthError);
}
}
type RustAuthStateView = {
status?: string;
user?: AuthUser | null;
apiBaseUrl?: string | null;
errorKind?: string | null;
errorMessage?: string | null;
};
/** Rust 认证态投影,与 `ClientAuthStateView` 一一对应。 */
type RustAuthStateView =
| { status: 'authenticated'; user: AuthUser; apiBaseUrl: string }
| { status: 'unauthenticated' };
/** Rust 续期结果投影,与 `ClientAuthRefreshView` 一一对应。 */
type RustAuthRefreshView =
| { status: 'refreshed'; user: AuthUser }
| { status: 'unauthenticated' }
| { status: 'stale' };
/**
* 恢复登录态。
*
* 凭据续期、当前用户复核与本机运行时会话安装都在 Rust 内完成;这里只把结果投影成
* `authenticated` / `unauthenticated` / `unavailable` 三态,供登录页决定展示分支。
* 凭据续期、当前用户复核与本机运行时会话安装都在 Rust 内完成;读状态失败就是命令失败,
* 由 `invokeClientAuth` 装进 `ClientAuthErrorWrapper`(`error` 是判别联合),不再有第三态投影。
*/
export async function readClientAuthState(
expectedApiBaseUrl?: string,
): Promise<ClientAuthState> {
const invoke = requireInvoke();
const view = await invoke<RustAuthStateView>('read_client_auth_state', {
expectedApiBaseUrl: expectedApiBaseUrl?.trim() || null,
});
if (view?.status === 'authenticated' && view.user && view.apiBaseUrl) {
const view = await invokeClientAuth<RustAuthStateView>(
'read_client_auth_state',
{
expectedApiBaseUrl: expectedApiBaseUrl?.trim() || null,
},
);
if (view.status === 'authenticated') {
return {
status: 'authenticated',
user: view.user,
apiBaseUrl: view.apiBaseUrl,
};
}
if (view?.status === 'unavailable') {
return {
status: 'unavailable',
message: view.errorMessage?.trim() || '登录服务暂时不可用,请稍后重试',
};
}
return { status: 'unauthenticated' };
}
@@ -83,8 +101,7 @@ export async function sendClientPhoneLoginCode(
phone: string,
apiBaseUrl: string,
): Promise<ClientLoginCodeResult> {
const invoke = requireInvoke();
const result = await invoke<{
const result = await invokeClientAuth<{
cooldownSeconds?: number;
expiresInSeconds?: number;
}>('send_client_phone_login_code', {
@@ -102,8 +119,7 @@ export async function loginClientWithPassword(
password: string,
apiBaseUrl: string,
): Promise<AuthUser> {
const invoke = requireInvoke();
return invoke<AuthUser>('login_client_with_password', {
return invokeClientAuth<AuthUser>('login_client_with_password', {
apiBaseUrl,
phone: normalizeAuthPhoneInput(phone),
password: password.trim(),
@@ -115,8 +131,7 @@ export async function loginClientWithPhoneCode(
code: string,
apiBaseUrl: string,
): Promise<AuthUser> {
const invoke = requireInvoke();
return invoke<AuthUser>('login_client_with_phone_code', {
return invokeClientAuth<AuthUser>('login_client_with_phone_code', {
apiBaseUrl,
phone: normalizeAuthPhoneInput(phone),
code: code.trim(),
@@ -125,43 +140,23 @@ export async function loginClientWithPhoneCode(
/** 登出:Rust 负责服务端撤销、凭据清除与本机运行时会话清理。 */
export async function logoutClientAuthSession(): Promise<void> {
const invoke = requireInvoke();
await invoke('logout_client_session');
await invokeClientAuth('logout_client_session');
}
export async function refreshClientAuthSession(
expectedUserId?: string,
): Promise<ClientAuthRefreshResult> {
const invoke = requireInvoke();
const view = await invoke<{
status?: string;
user?: AuthUser | null;
authoritative?: boolean;
errorMessage?: string | null;
}>('refresh_client_auth_session', {
expectedUserId: expectedUserId?.trim() || null,
});
switch (view?.status) {
case 'refreshed':
if (!view.user) {
return {
status: 'failed',
message: '刷新登录状态失败',
authoritative: false,
};
}
return { status: 'refreshed', user: view.user };
case 'unauthenticated':
return { status: 'unauthenticated' };
case 'stale':
return { status: 'stale' };
default:
return {
status: 'failed',
message: view?.errorMessage?.trim() || '刷新登录状态失败',
authoritative: view?.authoritative === true,
};
const view = await invokeClientAuth<RustAuthRefreshView>(
'refresh_client_auth_session',
{ expectedUserId: expectedUserId?.trim() || null },
);
if (view.status === 'refreshed') {
return { status: 'refreshed', user: view.user };
}
if (view.status === 'unauthenticated') {
return { status: 'unauthenticated' };
}
return { status: 'stale' };
}
/** 订阅 Rust 认证态事件,返回幂等释放函数。 */
@@ -172,7 +167,7 @@ export function subscribeClientAuthState(
CLIENT_AUTH_STATE_CHANGED_EVENT,
(event) => {
const view = event.payload;
if (view?.status === 'authenticated' && view.user && view.apiBaseUrl) {
if (view.status === 'authenticated') {
listener({
status: 'authenticated',
user: view.user,
@@ -0,0 +1,34 @@
import type { ClientAuthError } from './generated/ClientAuthError';
/**
* 错误上报上下文:`source` 是错误池的一级维度,`action` / `page` 用于细分指纹。
*
* 含义与 [`captureClientError`](./errorReporting.ts) 的入参完全一致。
*/
export type ClientErrorReportContext = {
source: string;
action?: string;
page?: string;
};
/**
* 认证命令失败的 JS 侧载体:`error` 就是 Rust 的结构化拒绝(ts-rs 生成的 `ClientAuthError`
* 判别联合)。
*
* 构造时把整份载荷 `JSON.stringify` 进 `Error.message`:结构化拒绝是普通对象,序列化后
* 上报事件拿到的是机器事实(变体名与载荷),而不是 `[object Object]`;不读任何变体字段、
* 不拼用户文案、不注入 `source` / `action`。分流只看类型化的变体,
* **不要用文案判断**;上报上下文由调用 `captureClientError` 时的显式入参决定。
*
* Tauri 缺陷抛出的真 `Error` 序列化只有 `{}`,但上报链路对真 `Error` 优先用其自身
* message/stack,不受影响。
*/
export class ClientAuthErrorWrapper extends Error {
readonly error: ClientAuthError;
constructor(error: ClientAuthError) {
super(JSON.stringify(error));
this.name = 'ClientAuthErrorWrapper';
this.error = error;
}
}
@@ -1,5 +1,9 @@
import { invoke } from '@tauri-apps/api/core';
import {
ClientAuthErrorWrapper,
type ClientErrorReportContext,
} from './clientAuthErrorWrapper';
import {
ackErrorReports,
getPendingErrorReports,
@@ -21,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,
@@ -84,11 +78,23 @@ export function normalizeDiagnosticText(value: string) {
export async function captureClientError(
error: unknown,
context: { source?: string; action?: string; page?: string } = {},
context: Partial<ClientErrorReportContext> = {},
) {
const errorValue = error instanceof Error ? error : new Error(String(error));
// ClientAuthErrorWrapper 构造时已把整份载荷序列化进 message:结构化拒绝在这里直接作为事件
// 文案上报;真 Error 仍优先用它自己的 message / stack。
const actionError = error instanceof ClientAuthErrorWrapper ? error : null;
// 类型上 `error` 是 ClientAuthError;Tauri 在映射外抛 Error 属于它的缺陷,这里按 unknown 兜底取文案/栈。
const carried: unknown = actionError?.error;
let errorValue: Error;
if (carried instanceof Error) {
errorValue = carried;
} else if (error instanceof Error) {
errorValue = error;
} else {
errorValue = new Error(String(error));
}
const message = errorValue.message || '未知客户端错误';
const stack = errorValue.stack ? errorValue.stack.slice(0, 8_000) : undefined;
const stack = errorValue.stack?.slice(0, 8_000);
return reportClientError({
source: context.source ?? 'client',
message,
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* 认证网络客户端构建失败的原始错误明细。
*/
export type AuthClientInitFailed = { detail: string };
@@ -0,0 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AuthNetworkReason } from './AuthNetworkReason';
/**
* 连接登录服务的传输层失败原因。
*/
export type AuthNetworkFailure = { reason: AuthNetworkReason };
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* 连接登录服务失败的具体原因。
*/
export type AuthNetworkReason = 'timeout' | 'unreachable';
@@ -0,0 +1,13 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AuthResponseInvalidReason } from './AuthResponseInvalidReason';
/**
* 登录响应契约破损的原因与服务端原文。
*/
export type AuthResponseInvalid = {
reason: AuthResponseInvalidReason;
/**
* 只有 `serverRejected` 可能带服务端原文;其余是 `null`。
*/
serverMessage: string | null;
};
@@ -0,0 +1,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* 登录响应契约破损的具体原因。
*/
export type AuthResponseInvalidReason =
| 'notJson'
| 'invalidBody'
| 'missingRefreshCookie'
| 'missingUserIdentity'
| 'serverRejected';
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* 登录服务 5xx 的状态码。
*/
export type AuthServiceUnavailable = { status: number };
@@ -0,0 +1,35 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AuthClientInitFailed } from './AuthClientInitFailed';
import type { AuthNetworkFailure } from './AuthNetworkFailure';
import type { AuthResponseInvalid } from './AuthResponseInvalid';
import type { AuthServiceUnavailable } from './AuthServiceUnavailable';
import type { ClientSessionPersistFailed } from './ClientSessionPersistFailed';
import type { PasswordLoginRejected } from './PasswordLoginRejected';
import type { PhoneCodeLoginRejected } from './PhoneCodeLoginRejected';
import type { RuntimeSessionInstallFailed } from './RuntimeSessionInstallFailed';
import type { SendCodeRejected } from './SendCodeRejected';
import type { ServerAddressRejected } from './ServerAddressRejected';
import type { UnexpectedRejection } from './UnexpectedRejection';
/**
* 变体名就是线上的分流键(`type`),带载荷的变体持有同名载荷类型。
*/
export type ClientAuthError =
| ({ type: 'serverAddressRejected' } & ServerAddressRejected)
| { type: 'phoneNumberInvalid' }
| { type: 'passwordMissing' }
| { type: 'loginCodeMissing' }
| ({ type: 'passwordLoginRejected' } & PasswordLoginRejected)
| { type: 'phoneOrPasswordMismatch' }
| ({ type: 'sendCodeRejected' } & SendCodeRejected)
| { type: 'smsCodeThrottled' }
| ({ type: 'phoneCodeLoginRejected' } & PhoneCodeLoginRejected)
| { type: 'sessionInvalidated' }
| { type: 'permissionDenied' }
| ({ type: 'authNetworkFailure' } & AuthNetworkFailure)
| ({ type: 'authServiceUnavailable' } & AuthServiceUnavailable)
| ({ type: 'unexpectedRejection' } & UnexpectedRejection)
| ({ type: 'authResponseInvalid' } & AuthResponseInvalid)
| ({ type: 'clientSessionPersistFailed' } & ClientSessionPersistFailed)
| ({ type: 'runtimeSessionInstallFailed' } & RuntimeSessionInstallFailed)
| ({ type: 'authClientInitFailed' } & AuthClientInitFailed);
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* 本机凭据文件读写的原始错误明细。
*/
export type ClientSessionPersistFailed = { detail: string };
@@ -0,0 +1,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* `/api/auth/entry` 返回 400 时服务端给的原文。
*/
export type PasswordLoginRejected = {
/**
* 服务端原文可能缺失:缺失是 `null`,Rust 不编造兜底文案。
*/
serverMessage: string | null;
};
@@ -0,0 +1,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* `/api/auth/phone/login` 返回 400 时服务端给的原文。
*/
export type PhoneCodeLoginRejected = {
/**
* 服务端原文可能缺失:缺失是 `null`,Rust 不编造兜底文案。
*/
serverMessage: string | null;
};
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* 本机运行时会话安装 / 清理的原始错误明细。
*/
export type RuntimeSessionInstallFailed = { detail: string };
@@ -0,0 +1,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* `/api/auth/phone/send-code` 返回 400 时服务端给的原文。
*/
export type SendCodeRejected = {
/**
* 服务端原文可能缺失:缺失是 `null`,Rust 不编造兜底文案。
*/
serverMessage: string | null;
};
@@ -0,0 +1,13 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* 服务地址校验失败的具体原因。
*/
export type ServerAddressReason =
| 'emptyOrTooLong'
| 'notAUrl'
| 'hasCredentials'
| 'hasPathOrQueryOrFragment'
| 'notHttps'
| 'unsupportedScheme'
| 'outsideChannel';
@@ -0,0 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ServerAddressReason } from './ServerAddressReason';
/**
* 服务地址被拒的具体原因。
*/
export type ServerAddressRejected = { reason: ServerAddressReason };

Some files were not shown because too many files have changed in this diff Show More