diff --git a/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx b/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx index 612d21826..feb4ce9d3 100644 --- a/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx +++ b/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx @@ -128,14 +128,17 @@ test('协议与 Agent 模式是独立字段,可按模型单独选择', async ( render(); 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' } }); diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index a7dda892c..8a79804c2 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -433,6 +433,7 @@ const APP_INVOKE_BARE_CALL_NAMES = new Set([ 'directInvoke', 'invokeInput', 'invokeAuthenticatedInput', + 'invokeClientAuth', 'invokeDiagnostic', ]); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs index 39cbfbb72..196ef735a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs @@ -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`):调用级拒绝、阶段失败; diff --git a/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs b/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs new file mode 100644 index 000000000..8c62e8deb --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs @@ -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, +} + +/// `/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, +} + +/// `/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, +} + +/// 连接登录服务的传输层失败原因。 +#[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, +} + +/// 登录响应契约破损的原因与服务端原文。 +#[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, +} + +/// 本机凭据文件读写的原始错误明细。 +#[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()); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs index 2b0e3e940..25ea5b770 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs @@ -4,6 +4,12 @@ //! 只在 Rust 内存与本进程会话快照里,refresh 凭据只写在 AppData 私有文件里。换号、登出或 //! origin 变化都会让旧身份的在途请求失败关闭;同一身份的凭据轮换不改变身份代次。 +use crate::auth_error::{ + AuthClientInitFailed, AuthNetworkFailure, AuthNetworkReason, AuthResponseInvalid, + AuthResponseInvalidReason, AuthServiceUnavailable, ClientAuthError, ClientSessionPersistFailed, + PasswordLoginRejected, PhoneCodeLoginRejected, RuntimeSessionInstallFailed, SendCodeRejected, + ServerAddressReason, ServerAddressRejected, UnexpectedRejection, +}; use crate::http_client::agc_main_site_client_builder; use crate::platform_session::{current_platform_session, PlatformSessionSnapshot}; use reqwest::{header::SET_COOKIE, Method, StatusCode}; @@ -32,26 +38,17 @@ const API_RESPONSE_ENVELOPE_HEADER: &str = "x-genarrative-response-envelope"; const API_RESPONSE_ENVELOPE_VERSION: &str = "v1"; const AGC_CLIENT_MARKER_HEADER: &str = "x-genarrative-client"; const AGC_CLIENT_MARKER_VALUE: &str = "agc"; -const AUTH_NETWORK_ERROR: &str = - "network-error: 无法连接登录服务,请确认配套后端或 API 代理已启动后重试"; -const AUTH_NETWORK_TIMEOUT: &str = "network-error: 登录服务响应超时,请检查服务器地址和网络后重试"; -const AUTH_AUTHORITY_ERROR: &str = "authentication-required: 登录状态已失效,请重新登录"; /// 认证态投影:只含状态、用户展示字段与 origin,不含 token 或 refresh 凭据。 #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct ClientAuthStateView { - /// `authenticated` / `unauthenticated` / `unavailable`。 + /// `authenticated` / `unauthenticated`。 pub(crate) status: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) user: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) api_base_url: Option, - /// 失败分类:`network` / `authority` / `contract`;成功或未登录时为 `None`。 - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) error_kind: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) error_message: Option, } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] @@ -61,18 +58,17 @@ pub(crate) struct ClientLoginCodeView { pub(crate) expires_in_seconds: u64, } -/// 续期结果。`stale` 表示续期期间身份已经变化,调用方不得重放旧身份请求。 +/// 续期结果。`stale` 表示续期期间身份已经变化,调用方不得重放旧身份请求; +/// 非权威失败(网络 / 5xx / 契约异常)不在这里降级成字符串,直接以 `Err(ClientAuthError)` 返回。 #[derive(Clone, Debug, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct ClientAuthRefreshView { - /// `refreshed` / `unauthenticated` / `stale` / `failed`。 + /// `refreshed` / `unauthenticated` / `stale`。 pub(crate) status: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) user: Option, #[serde(default)] pub(crate) authoritative: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) error_message: Option, } #[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] @@ -221,42 +217,82 @@ struct SendCodeResponse { expires_in_seconds: u64, } -fn session_file_path(app: &tauri::AppHandle) -> Result { +fn session_file_path(app: &tauri::AppHandle) -> Result { app.path() .app_data_dir() .map(|root| root.join(SESSION_FILE_NAME)) - .map_err(|error| format!("无法读取 AGC 应用数据目录:{error}")) + .map_err(|error| { + let detail = format!("无法读取 AGC 应用数据目录:{error}"); + app_log!("{detail}"); + ClientAuthError::ClientSessionPersistFailed(ClientSessionPersistFailed { detail }) + }) +} + +/// 凭据文件相关的字符串错误统一收口成凭据落盘失败变体。 +fn session_persist_error(message: impl Into) -> ClientAuthError { + let detail = message.into(); + app_log!("{detail}"); + ClientAuthError::ClientSessionPersistFailed(ClientSessionPersistFailed { detail }) +} + +/// 本机运行时安装 / 清理失败的字符串错误统一收口。 +fn runtime_session_error(message: impl Into) -> ClientAuthError { + let detail = message.into(); + app_log!("{detail}"); + ClientAuthError::RuntimeSessionInstallFailed(RuntimeSessionInstallFailed { detail }) +} + +/// 服务地址校验失败统一收口成带类型化原因的变体。 +fn server_address_rejected(reason: ServerAddressReason) -> ClientAuthError { + ClientAuthError::ServerAddressRejected(ServerAddressRejected { reason }) +} + +/// 响应契约破损(不含服务端原文)统一收口。 +fn response_invalid(reason: AuthResponseInvalidReason) -> ClientAuthError { + ClientAuthError::AuthResponseInvalid(AuthResponseInvalid { + reason, + server_message: None, + }) } /// 校验并归一化平台服务 origin。 /// /// 只接受纯 origin:不允许凭据、路径、查询或 fragment;非本机必须 HTTPS。发布构建额外 /// 限制在已知渠道 origin 内,避免调试期写入的自定义地址在正式包里被继续使用。 -pub(crate) fn validate_client_api_base_url(value: &str) -> Result { +/// +/// 每种拒绝各成一个变体:调用方只按变体选提示,不需要读任何文案。 +pub(crate) fn validate_client_api_base_url(value: &str) -> Result { let trimmed = value.trim().trim_end_matches('/'); if trimmed.is_empty() || trimmed.chars().count() > MAX_ORIGIN_CHARS { - return Err("陶泥儿服务地址无效".to_string()); + return Err(server_address_rejected(ServerAddressReason::EmptyOrTooLong)); } - let parsed = Url::parse(trimmed).map_err(|_| "陶泥儿服务地址无效".to_string())?; + let parsed = + Url::parse(trimmed).map_err(|_| server_address_rejected(ServerAddressReason::NotAUrl))?; if !parsed.username().is_empty() || parsed.password().is_some() { - return Err("陶泥儿服务地址不能包含凭据".to_string()); + return Err(server_address_rejected(ServerAddressReason::HasCredentials)); } if !matches!(parsed.path(), "" | "/") || parsed.query().is_some() || parsed.fragment().is_some() { - return Err("陶泥儿服务地址必须是纯地址,不能带路径或参数".to_string()); + return Err(server_address_rejected( + ServerAddressReason::HasPathOrQueryOrFragment, + )); } let host = parsed.host_str().unwrap_or_default().to_ascii_lowercase(); match parsed.scheme() { "https" => {} "http" if matches!(host.as_str(), "localhost" | "127.0.0.1" | "[::1]") => {} - "http" => return Err("非本机服务器必须使用 HTTPS".to_string()), - _ => return Err("陶泥儿服务地址必须是 HTTP(S) 地址".to_string()), + "http" => return Err(server_address_rejected(ServerAddressReason::NotHttps)), + _ => { + return Err(server_address_rejected( + ServerAddressReason::UnsupportedScheme, + )) + } } if !cfg!(debug_assertions) && !matches!(trimmed, RELEASE_ORIGIN | DEVELOPMENT_ORIGIN) && !matches!(host.as_str(), "localhost" | "127.0.0.1" | "[::1]") { - return Err("服务器地址不在当前构建渠道范围内".to_string()); + return Err(server_address_rejected(ServerAddressReason::OutsideChannel)); } Ok(trimmed.to_string()) } @@ -271,19 +307,25 @@ fn read_session_file_at(path: &Path) -> Option { file.is_complete().then_some(file) } -fn write_session_file_at(path: &Path, session: Option<&ClientSessionFile>) -> Result<(), String> { +fn write_session_file_at( + path: &Path, + session: Option<&ClientSessionFile>, +) -> Result<(), ClientAuthError> { let Some(session) = session else { if fs::symlink_metadata(path).is_ok() { - fs::remove_file(path).map_err(|error| format!("清除客户端登录凭据失败:{error}"))?; + fs::remove_file(path).map_err(|error| { + session_persist_error(format!("清除客户端登录凭据失败:{error}")) + })?; } return Ok(()); }; let parent = path .parent() - .ok_or_else(|| "客户端登录凭据缺少父目录".to_string())?; - crate::ensure_game_creator_private_directory_tree(parent, "客户端登录凭据目录")?; + .ok_or_else(|| session_persist_error("客户端登录凭据缺少父目录"))?; + crate::ensure_game_creator_private_directory_tree(parent, "客户端登录凭据目录") + .map_err(session_persist_error)?; let content = serde_json::to_string_pretty(session) - .map_err(|error| format!("序列化客户端登录凭据失败:{error}"))?; + .map_err(|error| session_persist_error(format!("序列化客户端登录凭据失败:{error}")))?; let temp_path = path.with_file_name(format!( ".{}.tmp.{}.{}", path.file_name() @@ -307,15 +349,15 @@ fn write_session_file_at(path: &Path, session: Option<&ClientSessionFile>) -> Re use std::os::windows::fs::OpenOptionsExt; options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); } - let mut handle = options - .open(&temp_path) - .map_err(|error| format!("创建客户端登录凭据临时文件失败:{error}"))?; + let mut handle = options.open(&temp_path).map_err(|error| { + session_persist_error(format!("创建客户端登录凭据临时文件失败:{error}")) + })?; if let Err(error) = crate::harden_new_game_creator_private_path(&temp_path, false, "客户端登录凭据") { drop(handle); let _ = fs::remove_file(&temp_path); - return Err(error); + return Err(session_persist_error(error)); } let write_result = handle .write_all(format!("{content}\n").as_bytes()) @@ -323,23 +365,30 @@ fn write_session_file_at(path: &Path, session: Option<&ClientSessionFile>) -> Re drop(handle); if let Err(error) = write_result { let _ = fs::remove_file(&temp_path); - return Err(format!("写入客户端登录凭据失败:{error}")); + return Err(session_persist_error(format!( + "写入客户端登录凭据失败:{error}" + ))); } if fs::symlink_metadata(path).is_ok() { - crate::prepare_game_creator_private_path_for_read(path, false, "客户端登录凭据")?; + crate::prepare_game_creator_private_path_for_read(path, false, "客户端登录凭据") + .map_err(session_persist_error)?; #[cfg(windows)] fs::remove_file(path).map_err(|error| { let _ = fs::remove_file(&temp_path); - format!("替换客户端登录凭据失败:{error}") + session_persist_error(format!("替换客户端登录凭据失败:{error}")) })?; } if let Err(error) = fs::rename(&temp_path, path) { let _ = fs::remove_file(&temp_path); - return Err(format!("提交客户端登录凭据失败:{error}")); + return Err(session_persist_error(format!( + "提交客户端登录凭据失败:{error}" + ))); } Ok(()) } -fn require_app_session(app: &tauri::AppHandle) -> Result, String> { +fn require_app_session( + app: &tauri::AppHandle, +) -> Result, ClientAuthError> { let path = session_file_path(app)?; let mut state = auth_state() .lock() @@ -372,13 +421,13 @@ fn current_session_origin() -> Option { current_platform_session().map(|snapshot| snapshot.api_base_url) } -fn endpoint(origin: &str, route: &str) -> Result { +fn endpoint(origin: &str, route: &str) -> Result { let mut url = Url::parse(&format!("{}/", origin.trim_end_matches('/'))) - .map_err(|_| "陶泥儿服务地址无效".to_string())?; + .map_err(|_| server_address_rejected(ServerAddressReason::NotAUrl))?; { let mut segments = url .path_segments_mut() - .map_err(|_| "陶泥儿服务地址无效".to_string())?; + .map_err(|_| server_address_rejected(ServerAddressReason::NotAUrl))?; for segment in route.trim_start_matches('/').split('/') { if segment.is_empty() { continue; @@ -389,20 +438,25 @@ fn endpoint(origin: &str, route: &str) -> Result { Ok(url.to_string()) } -fn build_client() -> Result { +fn build_client() -> Result { agc_main_site_client_builder() .connect_timeout(Duration::from_secs(10)) .timeout(HTTP_TIMEOUT) .build() - .map_err(|_| "创建登录网络客户端失败".to_string()) + .map_err(|error| { + ClientAuthError::AuthClientInitFailed(AuthClientInitFailed { + detail: error.to_string(), + }) + }) } -fn network_error_message(error: &reqwest::Error) -> String { - if error.is_timeout() { - AUTH_NETWORK_TIMEOUT.to_string() +fn network_error(error: &reqwest::Error) -> ClientAuthError { + let reason = if error.is_timeout() { + AuthNetworkReason::Timeout } else { - AUTH_NETWORK_ERROR.to_string() - } + AuthNetworkReason::Unreachable + }; + ClientAuthError::AuthNetworkFailure(AuthNetworkFailure { reason }) } fn error_message(body: &str) -> Option { @@ -416,64 +470,131 @@ fn error_message(body: &str) -> Option { .map(ToString::to_string) } -/// 认证路由语义:会话路由的 401/403 是权威失效,登录路由的 401/403 是用户可修正的输入问题。 +/// 认证路由语义:路由决定 401/403/429 落到哪个具体变体。 +/// +/// 会话路由的 `401/403` 是登录态权威失效,登录路由的 `401` 是用户可修正的输入问题; +/// 这个区分现在由 [`ClientAuthError`] 的变体承担,不再靠文本前缀。 #[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum AuthRouteKind { - Login, +enum AuthRoute { + PasswordEntry, + PhoneLogin, + SendCode, Session, + Other, } -fn auth_route_kind(route: &str) -> AuthRouteKind { - if route.ends_with("/me") || route.ends_with("/refresh") { - AuthRouteKind::Session +fn auth_route(route: &str) -> AuthRoute { + let route = route.trim_end_matches('/'); + if route.ends_with("/api/auth/entry") { + AuthRoute::PasswordEntry + } else if route.ends_with("/api/auth/phone/login") { + AuthRoute::PhoneLogin + } else if route.ends_with("/api/auth/phone/send-code") { + AuthRoute::SendCode + } else if route.ends_with("/api/auth/me") + || route.ends_with("/api/auth/refresh") + || route.ends_with("/api/auth/logout") + { + AuthRoute::Session } else { - AuthRouteKind::Login + AuthRoute::Other } } -/// 把一次认证 HTTP 响应归类成稳定文案。 +/// 把一次认证 HTTP 响应归类成具体变体。 /// -/// 会话路由的 `401/403` 带 `authentication-required` / `permission-denied` 前缀,调用方 -/// 可以据此清会话;登录路由保留服务端原因(「手机号或密码错误」),不能被改写成登录失效。 -/// 网络、5xx 与契约异常必须保留会话。 -fn map_auth_failure(status: StatusCode, body: &str, fallback: &str, kind: AuthRouteKind) -> String { +/// 会话路由的 `401/403` 归到权威失效变体,调用方据此清会话;登录路由的 `401` 是用户可修正的 +/// 输入问题。可判定的用户输入原因只进业务变体,认不出的才落到 `UnexpectedRejection`。 +/// `429` 也按路由判定:发码路由是频控,登录路由是「验证码错误次数过多」,两者都是用户可修正的 +/// 输入问题;其余路由的 `429` 仍按未识别拒绝处理。网络、5xx 与契约异常必须保留会话。 +fn map_auth_failure(status: StatusCode, body: &str, route: AuthRoute) -> ClientAuthError { crate::platform_maintenance::watch_platform_response(status.as_u16(), body); + let status_code = status.as_u16(); + let server_message = error_message(body); if status == StatusCode::UNAUTHORIZED { - return match (kind, error_message(body)) { - (AuthRouteKind::Session, Some(message)) => { - format!("authentication-required: {message}") + return match route { + AuthRoute::Session => ClientAuthError::SessionInvalidated, + AuthRoute::PasswordEntry => ClientAuthError::PhoneOrPasswordMismatch, + AuthRoute::PhoneLogin => { + ClientAuthError::PhoneCodeLoginRejected(PhoneCodeLoginRejected { server_message }) } - (AuthRouteKind::Session, None) => AUTH_AUTHORITY_ERROR.to_string(), - (AuthRouteKind::Login, Some(message)) => message, - (AuthRouteKind::Login, None) => fallback.to_string(), + _ => ClientAuthError::UnexpectedRejection(UnexpectedRejection { + status: status_code, + server_message, + }), }; } if status == StatusCode::FORBIDDEN { - return match (kind, error_message(body)) { - (AuthRouteKind::Session, Some(message)) => format!("permission-denied: {message}"), - (AuthRouteKind::Session, None) => { - "permission-denied: 当前陶泥儿账号没有执行此操作的权限".to_string() - } - (AuthRouteKind::Login, Some(message)) => message, - (AuthRouteKind::Login, None) => fallback.to_string(), + return match route { + AuthRoute::Session => ClientAuthError::PermissionDenied, + _ => ClientAuthError::UnexpectedRejection(UnexpectedRejection { + status: status_code, + server_message, + }), }; } - let detail = error_message(body).unwrap_or_else(|| format!("HTTP {}", status.as_u16())); - format!("{fallback}:{detail}") + if status == StatusCode::TOO_MANY_REQUESTS { + return match route { + AuthRoute::SendCode => ClientAuthError::SmsCodeThrottled, + AuthRoute::PhoneLogin => { + ClientAuthError::PhoneCodeLoginRejected(PhoneCodeLoginRejected { server_message }) + } + _ => ClientAuthError::UnexpectedRejection(UnexpectedRejection { + status: status_code, + server_message, + }), + }; + } + if status == StatusCode::BAD_REQUEST { + return match route { + AuthRoute::PasswordEntry => { + ClientAuthError::PasswordLoginRejected(PasswordLoginRejected { server_message }) + } + AuthRoute::PhoneLogin => { + ClientAuthError::PhoneCodeLoginRejected(PhoneCodeLoginRejected { server_message }) + } + AuthRoute::SendCode => { + ClientAuthError::SendCodeRejected(SendCodeRejected { server_message }) + } + _ => ClientAuthError::UnexpectedRejection(UnexpectedRejection { + status: status_code, + server_message, + }), + }; + } + if status.is_server_error() { + return ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { + status: status_code, + }); + } + ClientAuthError::UnexpectedRejection(UnexpectedRejection { + status: status_code, + server_message, + }) } -fn is_authority_failure(message: &str) -> bool { - message.starts_with("authentication-required") || message.starts_with("permission-denied") +/// body 读取失败时的归类。 +/// +/// 状态码此时已经拿到:非 2xx 按状态码走已有分类(body 没读到,服务端原文就是 `None`), +/// 不把服务端失败误报成传输层故障;只有 2xx 响应没收完才归网络不可达。 +fn classify_unreadable_body(status: StatusCode, route: AuthRoute) -> ClientAuthError { + if status.is_success() { + ClientAuthError::AuthNetworkFailure(AuthNetworkFailure { + reason: AuthNetworkReason::Unreachable, + }) + } else { + map_auth_failure(status, "", route) + } } -fn response_data(body: &str, fallback: &str) -> Result { - let value: Value = - serde_json::from_str(body).map_err(|_| format!("{fallback}:登录服务响应不是合法 JSON"))?; +fn response_data(body: &str) -> Result { + let value: Value = serde_json::from_str(body) + .map_err(|_| response_invalid(AuthResponseInvalidReason::NotJson))?; if value.get("ok").and_then(Value::as_bool) == Some(false) { - return Err(format!( - "{fallback}:{}", - error_message(body).unwrap_or_else(|| "登录服务请求失败".to_string()) - )); + return Err(ClientAuthError::AuthResponseInvalid(AuthResponseInvalid { + reason: AuthResponseInvalidReason::ServerRejected, + server_message: error_message(body), + })); } Ok(value.get("data").cloned().unwrap_or(value)) } @@ -520,8 +641,7 @@ async fn request_auth( bearer: Option<&str>, refresh_cookie: Option<&(String, String)>, policy: CookiePolicy, - fallback: &str, -) -> Result { +) -> Result { let method = match policy { // 读取类路由用 GET;写入类路由是 POST。 CookiePolicy::Ignore if route.ends_with("/me") => Method::GET, @@ -544,29 +664,26 @@ async fn request_auth( let response = request .send() .await - .map_err(|error| network_error_message(&error))?; + .map_err(|error| network_error(&error))?; let status = response.status(); let captured = match policy { CookiePolicy::Capture | CookiePolicy::Require => refresh_cookie_from_response(&response), CookiePolicy::Ignore => None, }; - let text = response - .text() - .await - .map_err(|_| format!("{fallback}:读取响应失败"))?; + let text = match response.text().await { + Ok(text) => text, + Err(_) => return Err(classify_unreadable_body(status, auth_route(route))), + }; if !status.is_success() { - return Err(map_auth_failure( - status, - &text, - fallback, - auth_route_kind(route), - )); + return Err(map_auth_failure(status, &text, auth_route(route))); } if matches!(policy, CookiePolicy::Require) && captured.is_none() { - return Err("result-unknown: 登录服务未返回新的续期凭据,已停止使用旧凭据".to_string()); + return Err(response_invalid( + AuthResponseInvalidReason::MissingRefreshCookie, + )); } Ok(AuthResponse { - data: response_data(&text, fallback)?, + data: response_data(&text)?, refresh_cookie: captured, }) } @@ -584,7 +701,7 @@ enum SessionIdentity { impl SessionIdentity { /// 拆出会话主体与展示用的用户投影:空主体在这里失败关闭,既不能写凭据文件,也不能 /// 装进本进程会话。 - fn resolve(self) -> Result<(String, Option), String> { + fn resolve(self) -> Result<(String, Option), ClientAuthError> { match self { Self::Login(user) => Ok((validated_session_user_id(&user.id)?, Some(user))), Self::Persisted(user_id) => Ok((validated_session_user_id(&user_id)?, None)), @@ -600,9 +717,9 @@ async fn commit_authenticated_session( token: String, refresh_cookie: (String, String), identity_change: bool, -) -> Result { +) -> Result { if token.chars().count() > MAX_SECRET_CHARS { - return Err("登录服务返回的凭据无效".to_string()); + return Err(response_invalid(AuthResponseInvalidReason::InvalidBody)); } // 主体先于凭据落盘解析:没有主体就不写 client-session.json,避免留下半截会话文件。 let (user_id, known_user) = identity.resolve()?; @@ -630,7 +747,8 @@ async fn commit_authenticated_session( identity_generation, revision, ) - .await?; + .await + .map_err(runtime_session_error)?; // 续期路径只知道 user_id:展示字段随后会用新 token 通过 /api/auth/me 复核。 Ok(known_user.unwrap_or_else(|| AuthUserPayload { id: user_id, @@ -647,7 +765,7 @@ async fn commit_authenticated_session( })) } -async fn clear_authenticated_session(app: &tauri::AppHandle) -> Result<(), String> { +async fn clear_authenticated_session(app: &tauri::AppHandle) -> Result<(), ClientAuthError> { let path = session_file_path(app)?; write_session_file_at(&path, None)?; { @@ -658,7 +776,9 @@ async fn clear_authenticated_session(app: &tauri::AppHandle) -> Result<(), Strin state.access_token_issued_at = None; } let (identity_generation, revision) = reserve_session_write(true); - crate::commands::clear_client_session_locally(identity_generation, revision).await + crate::commands::clear_client_session_locally(identity_generation, revision) + .await + .map_err(runtime_session_error) } fn auth_state_view( @@ -670,8 +790,6 @@ fn auth_state_view( status: status.to_string(), user, api_base_url, - error_kind: None, - error_message: None, } } @@ -688,11 +806,13 @@ fn phone_is_valid(phone: &str) -> bool { /// 归一化会话主体:凭据文件与进程快照都靠它判定「是谁」。 /// /// 主体缺失时不能退化成空串,否则本机凭据文件不完整、安装会话还会以「陶泥儿登录用户 -/// 身份无效」失败关闭;这里先给出登录语义的明确原因。 -fn validated_session_user_id(user_id: &str) -> Result { +/// 身份无效」失败关闭;这里先按「响应缺主体」失败关闭。 +fn validated_session_user_id(user_id: &str) -> Result { let user_id = user_id.trim(); if user_id.is_empty() { - return Err("登录失败:登录服务未返回用户身份".to_string()); + return Err(response_invalid( + AuthResponseInvalidReason::MissingUserIdentity, + )); } Ok(user_id.to_string()) } @@ -712,7 +832,7 @@ fn credential_rotated_elsewhere( async fn fetch_current_user( client: &reqwest::Client, snapshot: &PlatformSessionSnapshot, -) -> Result, String> { +) -> Result, ClientAuthError> { let response = request_auth( client, &snapshot.api_base_url, @@ -721,11 +841,10 @@ async fn fetch_current_user( Some(&snapshot.access_token), None, CookiePolicy::Ignore, - "读取当前用户失败", ) .await?; let me: MeResponse = serde_json::from_value(response.data) - .map_err(|_| "读取当前用户失败:响应格式无效".to_string())?; + .map_err(|_| response_invalid(AuthResponseInvalidReason::InvalidBody))?; Ok(me.user) } @@ -736,13 +855,12 @@ async fn fetch_current_user( async fn refresh_session_inner( app: &tauri::AppHandle, expected_user_id: Option<&str>, -) -> Result { +) -> Result { let Some(session) = require_app_session(app)? else { return Ok(ClientAuthRefreshView { status: "unauthenticated".to_string(), user: None, authoritative: true, - error_message: None, }); }; let _guard = refresh_lock().lock().await; @@ -758,7 +876,6 @@ async fn refresh_session_inner( status: "refreshed".to_string(), user: Some(user), authoritative: false, - error_message: None, }); } } @@ -776,13 +893,12 @@ async fn refresh_session_inner( None, Some(&cookie), CookiePolicy::Require, - "刷新登录状态失败", ) .await; let refreshed = match refreshed { Ok(response) => response, Err(error) => { - if is_authority_failure(&error) { + if error.is_authority_failure() { clear_authenticated_session(app).await?; let view = auth_state_view("unauthenticated", None, None); emit_auth_state(app, &view); @@ -790,23 +906,19 @@ async fn refresh_session_inner( status: "unauthenticated".to_string(), user: None, authoritative: true, - error_message: None, }); } - return Ok(ClientAuthRefreshView { - status: "failed".to_string(), - user: None, - authoritative: false, - error_message: Some(error), - }); + // 非权威失败不再降级成字符串:把 typed error 原样交给命令出口, + // 由前端 `invokeClientAuth` 装进 `ClientAuthErrorWrapper`(`cause` 是判别联合)后按变体分流。 + return Err(error); } }; let token: TokenResponse = serde_json::from_value(refreshed.data) - .map_err(|_| "刷新登录状态失败:凭据响应格式无效".to_string())?; + .map_err(|_| response_invalid(AuthResponseInvalidReason::InvalidBody))?; let install_token = token.token.clone(); let new_cookie = refreshed .refresh_cookie - .ok_or_else(|| "刷新登录状态失败:缺少新的续期凭据".to_string())?; + .ok_or_else(|| response_invalid(AuthResponseInvalidReason::MissingRefreshCookie))?; commit_authenticated_session( app, &session.api_base_url, @@ -828,7 +940,6 @@ async fn refresh_session_inner( status: "stale".to_string(), user: None, authoritative: false, - error_message: None, }); } let view = auth_state_view( @@ -841,7 +952,6 @@ async fn refresh_session_inner( status: "refreshed".to_string(), user: Some(user), authoritative: false, - error_message: None, }) } Ok(None) => { @@ -852,10 +962,9 @@ async fn refresh_session_inner( status: "unauthenticated".to_string(), user: None, authoritative: true, - error_message: None, }) } - Err(error) if is_authority_failure(&error) => { + Err(error) if error.is_authority_failure() => { clear_authenticated_session(app).await?; let view = auth_state_view("unauthenticated", None, None); emit_auth_state(app, &view); @@ -863,15 +972,9 @@ async fn refresh_session_inner( status: "unauthenticated".to_string(), user: None, authoritative: true, - error_message: None, }) } - Err(error) => Ok(ClientAuthRefreshView { - status: "failed".to_string(), - user: None, - authoritative: false, - error_message: Some(error), - }), + Err(error) => Err(error), } } @@ -880,7 +983,7 @@ async fn refresh_session_inner( pub(crate) async fn read_client_auth_state( app: tauri::AppHandle, expected_api_base_url: Option, -) -> Result { +) -> Result { let Some(session) = require_app_session(&app)? else { if let Some(snapshot) = current_platform_session() { // 凭据文件缺失但本进程仍有会话(例如同一次启动内刚登录):以会话为准。 @@ -914,22 +1017,10 @@ pub(crate) async fn read_client_auth_state( refresh.user, Some(session.api_base_url), )), - "stale" | "unauthenticated" => Ok(auth_state_view("unauthenticated", None, None)), - _ => Ok(ClientAuthStateView { - status: "unavailable".to_string(), - user: None, - api_base_url: Some(session.api_base_url), - error_kind: Some("network".to_string()), - error_message: refresh.error_message, - }), + // `stale`(续期期间身份已变化)与 `unauthenticated` 都按未登录处理。 + _ => Ok(auth_state_view("unauthenticated", None, None)), }, - Err(error) => Ok(ClientAuthStateView { - status: "unavailable".to_string(), - user: None, - api_base_url: Some(session.api_base_url), - error_kind: Some("network".to_string()), - error_message: Some(error), - }), + Err(error) => Err(error), } } @@ -937,7 +1028,7 @@ pub(crate) async fn read_client_auth_state( pub(crate) async fn refresh_client_auth_session( app: tauri::AppHandle, expected_user_id: Option, -) -> Result { +) -> Result { let expected = expected_user_id .as_deref() .map(str::trim) @@ -950,11 +1041,11 @@ pub(crate) async fn refresh_client_auth_session( pub(crate) async fn send_client_phone_login_code( api_base_url: String, phone: String, -) -> Result { +) -> Result { let origin = validate_client_api_base_url(&api_base_url)?; let phone = phone.trim(); if !phone_is_valid(phone) { - return Err("请输入正确的手机号".to_string()); + return Err(ClientAuthError::PhoneNumberInvalid); } let client = build_client()?; let response = request_auth( @@ -969,11 +1060,10 @@ pub(crate) async fn send_client_phone_login_code( None, None, CookiePolicy::Ignore, - "发送验证码失败", ) .await?; let payload: SendCodeResponse = serde_json::from_value(response.data) - .map_err(|_| "发送验证码失败:响应格式无效".to_string())?; + .map_err(|_| response_invalid(AuthResponseInvalidReason::InvalidBody))?; Ok(ClientLoginCodeView { cooldown_seconds: payload.cooldown_seconds, expires_in_seconds: payload.expires_in_seconds, @@ -984,12 +1074,12 @@ async fn complete_login( app: &tauri::AppHandle, origin: &str, response: AuthResponse, -) -> Result { +) -> Result { let payload: TokenUserResponse = serde_json::from_value(response.data) - .map_err(|_| "登录失败:登录服务响应格式无效".to_string())?; + .map_err(|_| response_invalid(AuthResponseInvalidReason::InvalidBody))?; let cookie = response .refresh_cookie - .ok_or_else(|| "登录失败:登录服务未返回续期凭据".to_string())?; + .ok_or_else(|| response_invalid(AuthResponseInvalidReason::MissingRefreshCookie))?; let user = commit_authenticated_session( app, origin, @@ -1015,14 +1105,14 @@ pub(crate) async fn login_client_with_password( api_base_url: String, phone: String, password: String, -) -> Result { +) -> Result { let origin = validate_client_api_base_url(&api_base_url)?; let phone = phone.trim(); if !phone_is_valid(phone) { - return Err("请输入正确的手机号".to_string()); + return Err(ClientAuthError::PhoneNumberInvalid); } if password.trim().is_empty() { - return Err("请输入密码".to_string()); + return Err(ClientAuthError::PasswordMissing); } let client = build_client()?; let response = request_auth( @@ -1037,7 +1127,6 @@ pub(crate) async fn login_client_with_password( None, None, CookiePolicy::Capture, - "登录失败", ) .await?; complete_login(&app, &origin, response).await @@ -1049,14 +1138,14 @@ pub(crate) async fn login_client_with_phone_code( api_base_url: String, phone: String, code: String, -) -> Result { +) -> Result { let origin = validate_client_api_base_url(&api_base_url)?; let phone = phone.trim(); if !phone_is_valid(phone) { - return Err("请输入正确的手机号".to_string()); + return Err(ClientAuthError::PhoneNumberInvalid); } if code.trim().is_empty() { - return Err("请输入验证码".to_string()); + return Err(ClientAuthError::LoginCodeMissing); } let client = build_client()?; let response = request_auth( @@ -1071,7 +1160,6 @@ pub(crate) async fn login_client_with_phone_code( None, None, CookiePolicy::Capture, - "登录失败", ) .await?; complete_login(&app, &origin, response).await @@ -1079,7 +1167,7 @@ pub(crate) async fn login_client_with_phone_code( /// 登出:先尝试服务端撤销,再无条件清掉本地凭据与本进程会话。 #[tauri::command] -pub(crate) async fn logout_client_session(app: tauri::AppHandle) -> Result<(), String> { +pub(crate) async fn logout_client_session(app: tauri::AppHandle) -> Result<(), ClientAuthError> { let origin = current_session_origin() .or_else(|| { auth_state() @@ -1100,7 +1188,6 @@ pub(crate) async fn logout_client_session(app: tauri::AppHandle) -> Result<(), S Some(&snapshot.access_token), None, CookiePolicy::Ignore, - "退出登录失败", ) .await; } @@ -1128,12 +1215,54 @@ mod tests { #[test] fn origins_reject_credentials_paths_and_plain_remote_http() { - assert!(validate_client_api_base_url("https://user:pass@example.com").is_err()); - assert!(validate_client_api_base_url("https://example.com/api").is_err()); - assert!(validate_client_api_base_url("https://example.com?a=1").is_err()); - assert!(validate_client_api_base_url("http://example.com").is_err()); - assert!(validate_client_api_base_url("ftp://example.com").is_err()); - assert!(validate_client_api_base_url(" ").is_err()); + // 顶层只占一个变体,细分走类型化 reason:前端 switch(reason) 仍然按类型分流,不读文案。 + assert_eq!( + validate_client_api_base_url("https://user:pass@example.com"), + Err(server_address_rejected(ServerAddressReason::HasCredentials)) + ); + assert_eq!( + validate_client_api_base_url("https://example.com/api"), + Err(server_address_rejected( + ServerAddressReason::HasPathOrQueryOrFragment + )) + ); + assert_eq!( + validate_client_api_base_url("https://example.com?a=1"), + Err(server_address_rejected( + ServerAddressReason::HasPathOrQueryOrFragment + )) + ); + assert_eq!( + validate_client_api_base_url("https://example.com#frag"), + Err(server_address_rejected( + ServerAddressReason::HasPathOrQueryOrFragment + )) + ); + assert_eq!( + validate_client_api_base_url("http://example.com"), + Err(server_address_rejected(ServerAddressReason::NotHttps)) + ); + assert_eq!( + validate_client_api_base_url("ftp://example.com"), + Err(server_address_rejected( + ServerAddressReason::UnsupportedScheme + )) + ); + assert_eq!( + validate_client_api_base_url(" "), + Err(server_address_rejected(ServerAddressReason::EmptyOrTooLong)) + ); + assert_eq!( + validate_client_api_base_url("不是地址"), + Err(server_address_rejected(ServerAddressReason::NotAUrl)) + ); + assert_eq!( + validate_client_api_base_url(&format!( + "https://example.com/{}", + "a".repeat(MAX_ORIGIN_CHARS) + )), + Err(server_address_rejected(ServerAddressReason::EmptyOrTooLong)) + ); assert_eq!( validate_client_api_base_url("http://127.0.0.1:10001").expect("loopback debug"), "http://127.0.0.1:10001" @@ -1150,53 +1279,170 @@ mod tests { } #[test] - fn session_routes_classify_401_403_as_authority_failures() { - assert_eq!(auth_route_kind("/api/auth/refresh"), AuthRouteKind::Session); - assert_eq!(auth_route_kind("/api/auth/me"), AuthRouteKind::Session); - assert_eq!(auth_route_kind("/api/auth/entry"), AuthRouteKind::Login); - - assert!(is_authority_failure(&map_auth_failure( - StatusCode::UNAUTHORIZED, - "{}", - "刷新失败", - AuthRouteKind::Session - ))); - assert!(is_authority_failure(&map_auth_failure( - StatusCode::FORBIDDEN, - r#"{"error":{"message":"无权"}}"#, - "刷新失败", - AuthRouteKind::Session - ))); - let transient = map_auth_failure( - StatusCode::INTERNAL_SERVER_ERROR, - "{}", - "刷新失败", - AuthRouteKind::Session, - ); - assert!(!is_authority_failure(&transient)); - assert!(transient.starts_with("刷新失败")); - assert!(!is_authority_failure(AUTH_NETWORK_ERROR)); - assert!(is_authority_failure(AUTH_AUTHORITY_ERROR)); + fn routes_are_classified_by_their_concrete_endpoint() { + assert_eq!(auth_route("/api/auth/refresh"), AuthRoute::Session); + assert_eq!(auth_route("/api/auth/me"), AuthRoute::Session); + assert_eq!(auth_route("/api/auth/entry"), AuthRoute::PasswordEntry); + assert_eq!(auth_route("/api/auth/phone/login"), AuthRoute::PhoneLogin); + assert_eq!(auth_route("/api/auth/phone/send-code"), AuthRoute::SendCode); + assert_eq!(auth_route("/api/other"), AuthRoute::Other); } #[test] - fn login_routes_keep_the_server_reason_instead_of_reporting_expiry() { - let wrong_password = map_auth_failure( - StatusCode::UNAUTHORIZED, - r#"{"error":{"message":"手机号或密码错误"}}"#, - "登录失败", - AuthRouteKind::Login, + fn session_routes_classify_401_403_as_authority_failures() { + assert!( + map_auth_failure(StatusCode::UNAUTHORIZED, "{}", AuthRoute::Session) + .is_authority_failure() ); - assert_eq!(wrong_password, "手机号或密码错误"); - assert!(!is_authority_failure(&wrong_password)); + assert!(map_auth_failure( + StatusCode::FORBIDDEN, + r#"{"error":{"message":"无权"}}"#, + AuthRoute::Session + ) + .is_authority_failure()); + assert_eq!( + map_auth_failure(StatusCode::UNAUTHORIZED, "{}", AuthRoute::Session), + ClientAuthError::SessionInvalidated + ); + assert_eq!( + map_auth_failure( + StatusCode::FORBIDDEN, + r#"{"error":{"message":"无权"}}"#, + AuthRoute::Session, + ), + ClientAuthError::PermissionDenied + ); + let transient = + map_auth_failure(StatusCode::INTERNAL_SERVER_ERROR, "{}", AuthRoute::Session); + assert!(!transient.is_authority_failure()); + assert_eq!( + transient, + ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: 500 }) + ); + } - let missing_reason = map_auth_failure( - StatusCode::UNAUTHORIZED, - "{}", - "登录失败", - AuthRouteKind::Login, + /// 登录路由的 401 是用户可修正的输入问题,不能和会话失效混在一起。 + /// + /// 服务端文案不再进载荷:变体本身已经说清事实,前缀/展示文案由前端调用方按当前操作给。 + #[test] + fn login_route_401_stays_a_user_correctable_mismatch() { + for body in [r#"{"error":{"message":"手机号或密码错误"}}"#, "{}"] { + let error = map_auth_failure(StatusCode::UNAUTHORIZED, body, AuthRoute::PasswordEntry); + assert_eq!(error, ClientAuthError::PhoneOrPasswordMismatch); + assert!(!error.is_authority_failure()); + } + } + + /// 发码端点不带凭据:401/403 不是「用户可改的输入」,而是协议异常,属系统变体(带上文上报)。 + /// + /// api-server 的 `send_phone_code` 只会返回 400(手机号登录未启用)与 5xx;401/403 真出现时 + /// 说明契约破了,不该被当成业务提示吞掉。 + #[test] + fn send_code_401_403_stay_system_rejections() { + for status in [StatusCode::UNAUTHORIZED, StatusCode::FORBIDDEN] { + let error = map_auth_failure( + status, + r#"{"error":{"message":"手机号登录暂未启用"}}"#, + AuthRoute::SendCode, + ); + assert!(!error.is_authority_failure()); + let ClientAuthError::UnexpectedRejection(UnexpectedRejection { + status: 401 | 403, + server_message: Some(message), + }) = error + else { + panic!("发码端点 401/403 必须落成带原文的系统变体"); + }; + assert_eq!(message, "手机号登录暂未启用"); + } + } + + #[test] + fn input_rejections_keep_the_server_text_while_self_describing_variants_stay_bare() { + let password_length = map_auth_failure( + StatusCode::BAD_REQUEST, + r#"{"error":{"message":"密码长度需要在 6 到 128 位之间"}}"#, + AuthRoute::PasswordEntry, + ); + assert_eq!( + password_length, + ClientAuthError::PasswordLoginRejected(PasswordLoginRejected { + server_message: Some("密码长度需要在 6 到 128 位之间".to_string()), + }) + ); + + let throttled = map_auth_failure( + StatusCode::TOO_MANY_REQUESTS, + r#"{"error":{"message":"发送过于频繁"}}"#, + AuthRoute::SendCode, + ); + assert_eq!(throttled, ClientAuthError::SmsCodeThrottled); + + // 登录路由的 429 是「验证码错误次数过多」:用户可修正的输入问题,不能进系统变体被上报。 + let verify_attempts_exceeded = map_auth_failure( + StatusCode::TOO_MANY_REQUESTS, + r#"{"error":{"message":"验证码错误次数过多,请重新获取验证码"}}"#, + AuthRoute::PhoneLogin, + ); + assert_eq!( + verify_attempts_exceeded, + ClientAuthError::PhoneCodeLoginRejected(PhoneCodeLoginRejected { + server_message: Some("验证码错误次数过多,请重新获取验证码".to_string()), + }) + ); + + // 其余路由的 429 仍未识别拒绝:既不冒充业务原因,也不冒充发码频控。 + let unknown_throttle = map_auth_failure( + StatusCode::TOO_MANY_REQUESTS, + r#"{"error":{"message":"限流"}}"#, + AuthRoute::Other, + ); + assert_eq!( + unknown_throttle, + ClientAuthError::UnexpectedRejection(UnexpectedRejection { + status: 429, + server_message: Some("限流".to_string()), + }) + ); + + // 登录路由的 401 只来自「用户不存在」:仍是业务变体,保留服务端原文,不能进系统变体。 + let user_not_found = map_auth_failure( + StatusCode::UNAUTHORIZED, + r#"{"error":{"message":"用户不存在"}}"#, + AuthRoute::PhoneLogin, + ); + assert_eq!( + user_not_found, + ClientAuthError::PhoneCodeLoginRejected(PhoneCodeLoginRejected { + server_message: Some("用户不存在".to_string()), + }) + ); + } + + /// 读 body 失败时状态码已经拿到,不能被压成「网络不可达」。 + #[test] + fn unreadable_body_keeps_the_known_status() { + assert_eq!( + classify_unreadable_body(StatusCode::SERVICE_UNAVAILABLE, AuthRoute::PhoneLogin), + ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: 503 }) + ); + assert_eq!( + classify_unreadable_body(StatusCode::UNAUTHORIZED, AuthRoute::Session), + ClientAuthError::SessionInvalidated + ); + assert_eq!( + classify_unreadable_body(StatusCode::BAD_REQUEST, AuthRoute::PasswordEntry), + ClientAuthError::PasswordLoginRejected(PasswordLoginRejected { + server_message: None + }) + ); + // 只有 2xx 响应没收完才是传输层故障。 + assert_eq!( + classify_unreadable_body(StatusCode::OK, AuthRoute::Other), + ClientAuthError::AuthNetworkFailure(AuthNetworkFailure { + reason: AuthNetworkReason::Unreachable, + }) ); - assert_eq!(missing_reason, "登录失败"); } #[test] @@ -1271,8 +1517,6 @@ mod tests { wechat_account: None, }), api_base_url: Some(DEVELOPMENT_ORIGIN.to_string()), - error_kind: None, - error_message: None, }; let serialized = serde_json::to_string(&view).expect("serialize auth state"); for forbidden in [ @@ -1394,11 +1638,11 @@ mod tests { validated_session_user_id(" user-1 ").expect("user id"), "user-1" ); - // 空主体必须在登录路径就以登录语义失败:安装会话时的「陶泥儿登录用户身份无效」 + // 空主体必须在登录路径就失败关闭:安装会话时的「陶泥儿登录用户身份无效」 // 是内部不变式,不是用户能理解的登录失败原因。 assert_eq!( validated_session_user_id(" ").unwrap_err(), - "登录失败:登录服务未返回用户身份" + response_invalid(AuthResponseInvalidReason::MissingUserIdentity) ); } @@ -1457,7 +1701,7 @@ mod tests { // 没有主体的登录响应在写凭据文件之前就以登录语义失败关闭。 assert_eq!( SessionIdentity::Login(payload.user).resolve().unwrap_err(), - "登录失败:登录服务未返回用户身份" + response_invalid(AuthResponseInvalidReason::MissingUserIdentity) ); // 续期路径的空主体同样失败关闭:缺字段的凭据文件本来就读不出来,不能在这里被复活。 assert!(SessionIdentity::Persisted(String::new()).resolve().is_err()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index befa993db..b580e5bf2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -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; diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index 947a8c4ac..42c8e687f 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -12,7 +12,6 @@ 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 { - getClientAuthErrorMessage, loginClientWithPassword, loginClientWithPhoneCode, logoutClientAuthSession, @@ -21,6 +20,7 @@ import { sendClientPhoneLoginCode, subscribeClientAuthState, } from '../services/clientAuth'; +import { ClientAuthErrorWrapper } from '../services/clientAuthErrorWrapper'; import { type ClientServerPreset, type ClientServerSelection, @@ -34,8 +34,15 @@ import { import { captureClientError, installWebviewLogBridge, - shouldCaptureClientError, } from '../services/errorReporting'; +import type { AuthNetworkFailure } from '../services/generated/AuthNetworkFailure'; +import type { AuthResponseInvalid } from '../services/generated/AuthResponseInvalid'; +import type { AuthServiceUnavailable } from '../services/generated/AuthServiceUnavailable'; +import type { PasswordLoginRejected } from '../services/generated/PasswordLoginRejected'; +import type { PhoneCodeLoginRejected } from '../services/generated/PhoneCodeLoginRejected'; +import type { SendCodeRejected } from '../services/generated/SendCodeRejected'; +import type { ServerAddressRejected } from '../services/generated/ServerAddressRejected'; +import type { UnexpectedRejection } from '../services/generated/UnexpectedRejection'; import { beginPlatformSessionClearTransition, beginPlatformSessionTransition, @@ -66,6 +73,14 @@ const AUTH_CHECK_REQUEST_TIMEOUT_MS = 15_000; // 30s startup deadline. Keep the UI fence slightly above that worst case. const AUTH_CHECK_RUNNER_TIMEOUT_MS = 45_000; +/** + * 编译期穷尽检查:参数只能是 `never`,switch 漏掉任何一个变体这一行就编译不过。 + * 运行时不做事(`void`),所以 `default` 分支还要自己 `throw`。 + */ +function expectNever(value: never): void { + void value; +} + function withAuthCheckTimeout( promise: Promise, timeoutMs: number, @@ -236,12 +251,6 @@ export function AuthenticatedClient({ '检查登录状态超时,请检查服务器地址和网络后重试', ); if (!isActiveRun()) return; - if (state.status === 'unavailable') { - setAuthCheckError(state.message); - setLoginStatus(state.message); - setAuthStatus('unauthenticated'); - return; - } if (state.status !== 'authenticated') { setAuthStatus('unauthenticated'); return; @@ -262,13 +271,191 @@ export function AuthenticatedClient({ setAuthStatus('authenticated'); } catch (error) { if (!isActiveRun()) return; - const message = getClientAuthErrorMessage( - error, - '登录服务暂时不可用,请稍后重试', - ); - setAuthCheckError(message); - setLoginStatus(message); + // 失败一律先离开检查态:系统变体虽然要原样抛出上报,界面也不能卡在 loading。 setAuthStatus('unauthenticated'); + // 启动检查超时等我们自己的失败:改动前会显示原因 + 重试按钮,这里恢复同样的可见反馈。 + if (!(error instanceof ClientAuthErrorWrapper)) { + const text = error instanceof Error ? error.message : String(error); + setAuthCheckError(text); + setLoginStatus(text); + throw error; + } + const failure = error.error; + switch (failure.type) { + // 预期业务拒绝:调用方补上本次操作的上下文,展示后不进错误池。 + case 'serverAddressRejected': { + const payload = failure as ServerAddressRejected; + let text: string; + switch (payload.reason) { + case 'emptyOrTooLong': + text = '服务器地址非法: 不能为空或超长'; + break; + case 'notAUrl': + text = '服务器地址非法: 不是合法地址'; + break; + case 'hasCredentials': + text = '服务器地址非法: 不能包含用户名或密码'; + break; + case 'hasPathOrQueryOrFragment': + text = '服务器地址非法: 不能带路径、查询或片段'; + break; + case 'notHttps': + text = '服务器地址非法: 远程地址必须使用 https'; + break; + case 'unsupportedScheme': + text = '服务器地址非法: 只支持 http 或 https'; + break; + case 'outsideChannel': + text = '服务器地址非法: 不在当前构建渠道范围内'; + break; + default: { + expectNever(payload.reason); + text = '服务器地址非法'; + } + } + setAuthCheckError(text); + setLoginStatus(text); + break; + } + case 'phoneNumberInvalid': { + const text = '手机号无效: 需为纯数字且不超过 32 位'; + setAuthCheckError(text); + setLoginStatus(text); + break; + } + case 'passwordMissing': { + const text = '密码无效: 不能为空'; + setAuthCheckError(text); + setLoginStatus(text); + break; + } + case 'loginCodeMissing': { + const text = '验证码无效: 不能为空'; + setAuthCheckError(text); + setLoginStatus(text); + break; + } + case 'passwordLoginRejected': { + const payload = failure as PasswordLoginRejected; + const text = `登录失败: ${payload.serverMessage ?? '服务端拒绝了本次登录'}`; + setAuthCheckError(text); + setLoginStatus(text); + break; + } + case 'phoneOrPasswordMismatch': { + const text = '手机号或密码错误'; + setAuthCheckError(text); + setLoginStatus(text); + break; + } + case 'sendCodeRejected': { + const payload = failure as SendCodeRejected; + const text = `发送验证码失败: ${payload.serverMessage ?? '服务端拒绝了本次请求'}`; + setAuthCheckError(text); + setLoginStatus(text); + break; + } + case 'smsCodeThrottled': { + const text = '发送验证码过于频繁,请稍后再试'; + setAuthCheckError(text); + setLoginStatus(text); + break; + } + case 'phoneCodeLoginRejected': { + const payload = failure as PhoneCodeLoginRejected; + const text = `验证码登录失败:${payload.serverMessage ?? '服务端拒绝了本次请求'}`; + setAuthCheckError(text); + setLoginStatus(text); + break; + } + // 会话权威失效:Rust 已按未登录处理;走到这里也只按未登录呈现。 + case 'sessionInvalidated': + case 'permissionDenied': + break; + // 系统失败:调用方处理不了,先按原始事实给用户可见反馈,再原样抛出 → 全局 unhandledrejection 交给错误池。 + case 'authNetworkFailure': { + const payload = failure as AuthNetworkFailure; + const text = + payload.reason === 'timeout' + ? '登录服务响应超时,请检查服务器地址和网络后重试' + : '无法连接登录服务,请确认配套后端或 API 代理已启动后重试'; + setAuthCheckError(text); + setLoginStatus(text); + throw error; + } + case 'authServiceUnavailable': { + const payload = failure as AuthServiceUnavailable; + const text = `登录服务暂时不可用(HTTP ${payload.status})`; + setAuthCheckError(text); + setLoginStatus(text); + throw error; + } + case 'unexpectedRejection': { + const payload = failure as UnexpectedRejection; + const text = payload.serverMessage + ? `登录服务请求失败(HTTP ${payload.status}):${payload.serverMessage}` + : `登录服务请求失败(HTTP ${payload.status})`; + setAuthCheckError(text); + setLoginStatus(text); + throw error; + } + case 'authResponseInvalid': { + const payload = failure as AuthResponseInvalid; + let text: string; + switch (payload.reason) { + case 'notJson': + text = '登录服务响应不是合法 JSON'; + break; + case 'invalidBody': + text = '登录服务响应结构不符合契约'; + break; + case 'missingRefreshCookie': + text = '登录服务未返回新的续期凭据'; + break; + case 'missingUserIdentity': + text = '登录服务未返回用户身份'; + break; + case 'serverRejected': + text = payload.serverMessage + ? `登录服务拒绝了本次请求:${payload.serverMessage}` + : '登录服务拒绝了本次请求'; + break; + default: { + expectNever(payload.reason); + text = '登录服务响应不符合契约'; + } + } + setAuthCheckError(text); + setLoginStatus(text); + throw error; + } + case 'clientSessionPersistFailed': { + const text = '本机登录凭据读写失败'; + setAuthCheckError(text); + setLoginStatus(text); + throw error; + } + case 'runtimeSessionInstallFailed': { + const text = '本机运行时会话安装失败'; + setAuthCheckError(text); + setLoginStatus(text); + throw error; + } + case 'authClientInitFailed': { + const text = '创建登录网络客户端失败'; + setAuthCheckError(text); + setLoginStatus(text); + throw error; + } + default: { + expectNever(failure); + // 结构化映射外(Tauri 级失败等):仍然原样抛出上报,但界面给一行泛化提示,别停在空白状态。 + const text = '登录服务返回了未识别的错误,请重试或提交错误报告'; + setAuthCheckError(text); + setLoginStatus(text); + throw error; + } + } } } void hydrateAuth(); @@ -326,6 +513,20 @@ export function AuthenticatedClient({ return () => window.clearInterval(timer); }, [codeCooldownSeconds]); + /** + * 认证失败的分流判据直接写在每个 catch 里: + * + * - 承载:命令失败由 `invokeClientAuth` 装进 `ClientAuthErrorWrapper`,`error` 就是判别联合, + * `failure.type` 是唯一分流键; + * - 业务 / 会话变体:无载荷变体用本 catch 的固定文案;带载荷变体先 `as` 取具名类型, + * 可枚举的细分再用 `switch (payload.reason)` 在类型化 reason 上分流并拼上下文 + * (Rust 不预拼用户可见文案),永不进错误池; + * - 系统变体:原样 `throw`,经全局 `unhandledrejection` 交给错误池; + * - `default: expectNever` 保证 Rust 新增变体时这里编译失败。 + * + * 不把判据抽成函数:判定必须发生在 catch 里。 + */ + async function handleSendCode() { if (codeBusy || codeCooldownSeconds > 0) { return; @@ -347,12 +548,169 @@ 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)); + // 本地运行时超时等我们自己的失败也要离开「正在发送验证码」并给出原因,改动前就是如此。 + if (!(error instanceof ClientAuthErrorWrapper)) { + setLoginStatus(error instanceof Error ? error.message : String(error)); + throw error; + } + const failure = error.error; + switch (failure.type) { + // 预期业务拒绝:调用方补上本次操作的上下文,展示后不进错误池。 + case 'serverAddressRejected': { + const payload = failure as ServerAddressRejected; + let text: string; + switch (payload.reason) { + case 'emptyOrTooLong': + text = '服务器地址非法: 不能为空或超长'; + break; + case 'notAUrl': + text = '服务器地址非法: 不是合法地址'; + break; + case 'hasCredentials': + text = '服务器地址非法: 不能包含用户名或密码'; + break; + case 'hasPathOrQueryOrFragment': + text = '服务器地址非法: 不能带路径、查询或片段'; + break; + case 'notHttps': + text = '服务器地址非法: 远程地址必须使用 https'; + break; + case 'unsupportedScheme': + text = '服务器地址非法: 只支持 http 或 https'; + break; + case 'outsideChannel': + text = '服务器地址非法: 不在当前构建渠道范围内'; + break; + default: { + expectNever(payload.reason); + text = '服务器地址非法'; + } + } + setLoginStatus(text); + break; + } + case 'phoneNumberInvalid': { + setLoginStatus('手机号无效: 需为纯数字且不超过 32 位'); + break; + } + case 'passwordMissing': { + setLoginStatus('密码无效: 不能为空'); + break; + } + case 'loginCodeMissing': { + setLoginStatus('验证码无效: 不能为空'); + break; + } + case 'passwordLoginRejected': { + const payload = failure as PasswordLoginRejected; + setLoginStatus( + `登录失败: ${payload.serverMessage ?? '服务端拒绝了本次登录'}`, + ); + break; + } + case 'phoneOrPasswordMismatch': { + setLoginStatus('手机号或密码错误'); + break; + } + case 'sendCodeRejected': { + const payload = failure as SendCodeRejected; + setLoginStatus( + `发送验证码失败: ${payload.serverMessage ?? '服务端拒绝了本次请求'}`, + ); + break; + } + case 'smsCodeThrottled': { + setLoginStatus('发送验证码过于频繁,请稍后再试'); + break; + } + case 'phoneCodeLoginRejected': { + const payload = failure as PhoneCodeLoginRejected; + setLoginStatus( + `验证码登录失败:${payload.serverMessage ?? '服务端拒绝了本次请求'}`, + ); + break; + } + case 'sessionInvalidated': { + setLoginStatus('登录状态已失效,请重新登录'); + break; + } + case 'permissionDenied': { + setLoginStatus('当前账号没有权限执行此操作'); + break; + } + // 系统失败:调用方处理不了,先按原始事实给用户可见反馈,再原样抛出 → 全局 unhandledrejection 交给错误池。 + case 'authNetworkFailure': { + const payload = failure as AuthNetworkFailure; + setLoginStatus( + payload.reason === 'timeout' + ? '发送验证码失败:登录服务响应超时,请检查服务器地址和网络后重试' + : '发送验证码失败:无法连接登录服务,请确认配套后端或 API 代理已启动后重试', + ); + throw error; + } + case 'authServiceUnavailable': { + const payload = failure as AuthServiceUnavailable; + setLoginStatus( + `发送验证码失败:登录服务暂时不可用(HTTP ${payload.status})`, + ); + throw error; + } + case 'unexpectedRejection': { + const payload = failure as UnexpectedRejection; + setLoginStatus( + payload.serverMessage + ? `发送验证码失败:登录服务请求失败(HTTP ${payload.status}):${payload.serverMessage}` + : `发送验证码失败:登录服务请求失败(HTTP ${payload.status})`, + ); + throw error; + } + case 'authResponseInvalid': { + const payload = failure as AuthResponseInvalid; + let text: string; + switch (payload.reason) { + case 'notJson': + text = '发送验证码失败:登录服务响应不是合法 JSON'; + break; + case 'invalidBody': + text = '发送验证码失败:登录服务响应结构不符合契约'; + break; + case 'missingRefreshCookie': + text = '发送验证码失败:登录服务未返回新的续期凭据'; + break; + case 'missingUserIdentity': + text = '发送验证码失败:登录服务未返回用户身份'; + break; + case 'serverRejected': + text = payload.serverMessage + ? `发送验证码失败:登录服务拒绝了本次请求:${payload.serverMessage}` + : '发送验证码失败:登录服务拒绝了本次请求'; + break; + default: { + expectNever(payload.reason); + text = '发送验证码失败:登录服务响应不符合契约'; + } + } + setLoginStatus(text); + throw error; + } + case 'clientSessionPersistFailed': { + setLoginStatus('发送验证码失败:本机登录凭据读写失败'); + throw error; + } + case 'runtimeSessionInstallFailed': { + setLoginStatus('发送验证码失败:本机运行时会话安装失败'); + throw error; + } + case 'authClientInitFailed': { + setLoginStatus('发送验证码失败:创建登录网络客户端失败'); + throw error; + } + default: { + expectNever(failure); + setLoginStatus('登录服务返回了未识别的错误,请重试或提交错误报告'); + throw error; + } + } } finally { setCodeBusy(false); } @@ -417,9 +775,169 @@ export function AuthenticatedClient({ setCode(''); setPassword(''); } catch (error) { - if (shouldCaptureClientError(error)) - void captureClientError(error, { source: 'auth', action: 'login' }); - setLoginStatus(getClientAuthErrorMessage(error, '登录失败')); + // 本地运行时超时等我们自己的失败也要离开「正在登录」并给出原因,改动前就是如此。 + if (!(error instanceof ClientAuthErrorWrapper)) { + setLoginStatus(error instanceof Error ? error.message : String(error)); + throw error; + } + const failure = error.error; + switch (failure.type) { + // 预期业务拒绝:调用方补上本次操作的上下文,展示后不进错误池。 + case 'serverAddressRejected': { + const payload = failure as ServerAddressRejected; + let text: string; + switch (payload.reason) { + case 'emptyOrTooLong': + text = '服务器地址非法: 不能为空或超长'; + break; + case 'notAUrl': + text = '服务器地址非法: 不是合法地址'; + break; + case 'hasCredentials': + text = '服务器地址非法: 不能包含用户名或密码'; + break; + case 'hasPathOrQueryOrFragment': + text = '服务器地址非法: 不能带路径、查询或片段'; + break; + case 'notHttps': + text = '服务器地址非法: 远程地址必须使用 https'; + break; + case 'unsupportedScheme': + text = '服务器地址非法: 只支持 http 或 https'; + break; + case 'outsideChannel': + text = '服务器地址非法: 不在当前构建渠道范围内'; + break; + default: { + expectNever(payload.reason); + text = '服务器地址非法'; + } + } + setLoginStatus(text); + break; + } + case 'phoneNumberInvalid': { + setLoginStatus('手机号无效: 需为纯数字且不超过 32 位'); + break; + } + case 'passwordMissing': { + setLoginStatus('密码无效: 不能为空'); + break; + } + case 'loginCodeMissing': { + setLoginStatus('验证码无效: 不能为空'); + break; + } + case 'passwordLoginRejected': { + const payload = failure as PasswordLoginRejected; + setLoginStatus( + `登录失败: ${payload.serverMessage ?? '服务端拒绝了本次登录'}`, + ); + break; + } + case 'phoneOrPasswordMismatch': { + setLoginStatus('手机号或密码错误'); + break; + } + case 'sendCodeRejected': { + const payload = failure as SendCodeRejected; + setLoginStatus( + `发送验证码失败: ${payload.serverMessage ?? '服务端拒绝了本次请求'}`, + ); + break; + } + case 'smsCodeThrottled': { + setLoginStatus('发送验证码过于频繁,请稍后再试'); + break; + } + case 'phoneCodeLoginRejected': { + const payload = failure as PhoneCodeLoginRejected; + setLoginStatus( + `验证码登录失败:${payload.serverMessage ?? '服务端拒绝了本次请求'}`, + ); + break; + } + case 'sessionInvalidated': { + setLoginStatus('登录状态已失效,请重新登录'); + break; + } + case 'permissionDenied': { + setLoginStatus('当前账号没有权限执行此操作'); + break; + } + // 系统失败:调用方处理不了,先按原始事实给用户可见反馈,再原样抛出 → 全局 unhandledrejection 交给错误池。 + case 'authNetworkFailure': { + const payload = failure as AuthNetworkFailure; + setLoginStatus( + payload.reason === 'timeout' + ? '登录失败:登录服务响应超时,请检查服务器地址和网络后重试' + : '登录失败:无法连接登录服务,请确认配套后端或 API 代理已启动后重试', + ); + throw error; + } + case 'authServiceUnavailable': { + const payload = failure as AuthServiceUnavailable; + setLoginStatus( + `登录失败:登录服务暂时不可用(HTTP ${payload.status})`, + ); + throw error; + } + case 'unexpectedRejection': { + const payload = failure as UnexpectedRejection; + setLoginStatus( + payload.serverMessage + ? `登录失败:登录服务请求失败(HTTP ${payload.status}):${payload.serverMessage}` + : `登录失败:登录服务请求失败(HTTP ${payload.status})`, + ); + throw error; + } + case 'authResponseInvalid': { + const payload = failure as AuthResponseInvalid; + let text: string; + switch (payload.reason) { + case 'notJson': + text = '登录失败:登录服务响应不是合法 JSON'; + break; + case 'invalidBody': + text = '登录失败:登录服务响应结构不符合契约'; + break; + case 'missingRefreshCookie': + text = '登录失败:登录服务未返回新的续期凭据'; + break; + case 'missingUserIdentity': + text = '登录失败:登录服务未返回用户身份'; + break; + case 'serverRejected': + text = payload.serverMessage + ? `登录失败:登录服务拒绝了本次请求:${payload.serverMessage}` + : '登录失败:登录服务拒绝了本次请求'; + break; + default: { + expectNever(payload.reason); + text = '登录失败:登录服务响应不符合契约'; + } + } + setLoginStatus(text); + throw error; + } + case 'clientSessionPersistFailed': { + setLoginStatus('登录失败:本机登录凭据读写失败'); + throw error; + } + case 'runtimeSessionInstallFailed': { + setLoginStatus('登录失败:本机运行时会话安装失败'); + throw error; + } + case 'authClientInitFailed': { + setLoginStatus('登录失败:创建登录网络客户端失败'); + throw error; + } + default: { + expectNever(failure); + setLoginStatus('登录服务返回了未识别的错误,请重试或提交错误报告'); + throw error; + } + } } finally { setLoginBusy(false); } diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx index 0d62ed6dc..4556f232c 100644 --- a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx +++ b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx @@ -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'; diff --git a/apps/ai-game-creator-shell/src/services/clientAuth.ts b/apps/ai-game-creator-shell/src/services/clientAuth.ts index acbe37ed3..4ab11e049 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuth.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuth.ts @@ -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( + command: string, + args?: Record, +): Promise { + // 认证桥未安装是我们自己的失败关闭错误,不是命令拒绝:放在 try 之外,原样抛出。 + const invoke = requireInvoke(); + try { + // 不带参数时保持 `invoke(command)` 的单参调用形态,别给命令多塞一个 undefined。 + return args === undefined + ? await invoke(command) + : await invoke(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 { - const invoke = requireInvoke(); - const view = await invoke('read_client_auth_state', { - expectedApiBaseUrl: expectedApiBaseUrl?.trim() || null, - }); - if (view?.status === 'authenticated' && view.user && view.apiBaseUrl) { + const view = await invokeClientAuth( + '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 { - 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 { - const invoke = requireInvoke(); - return invoke('login_client_with_password', { + return invokeClientAuth('login_client_with_password', { apiBaseUrl, phone: normalizeAuthPhoneInput(phone), password: password.trim(), @@ -115,8 +131,7 @@ export async function loginClientWithPhoneCode( code: string, apiBaseUrl: string, ): Promise { - const invoke = requireInvoke(); - return invoke('login_client_with_phone_code', { + return invokeClientAuth('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 { - const invoke = requireInvoke(); - await invoke('logout_client_session'); + await invokeClientAuth('logout_client_session'); } export async function refreshClientAuthSession( expectedUserId?: string, ): Promise { - 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( + '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, diff --git a/apps/ai-game-creator-shell/src/services/clientAuthErrorWrapper.ts b/apps/ai-game-creator-shell/src/services/clientAuthErrorWrapper.ts new file mode 100644 index 000000000..a9432d3b6 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/clientAuthErrorWrapper.ts @@ -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; + } +} diff --git a/apps/ai-game-creator-shell/src/services/errorReporting.ts b/apps/ai-game-creator-shell/src/services/errorReporting.ts index e7acc5329..c22a703ab 100644 --- a/apps/ai-game-creator-shell/src/services/errorReporting.ts +++ b/apps/ai-game-creator-shell/src/services/errorReporting.ts @@ -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( invokeFn: (command: string, args?: Record) => Promise, 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 = {}, ) { - 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, diff --git a/apps/ai-game-creator-shell/src/services/generated/AuthClientInitFailed.ts b/apps/ai-game-creator-shell/src/services/generated/AuthClientInitFailed.ts new file mode 100644 index 000000000..73188e2d3 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/AuthClientInitFailed.ts @@ -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 }; diff --git a/apps/ai-game-creator-shell/src/services/generated/AuthNetworkFailure.ts b/apps/ai-game-creator-shell/src/services/generated/AuthNetworkFailure.ts new file mode 100644 index 000000000..a852d6759 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/AuthNetworkFailure.ts @@ -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 }; diff --git a/apps/ai-game-creator-shell/src/services/generated/AuthNetworkReason.ts b/apps/ai-game-creator-shell/src/services/generated/AuthNetworkReason.ts new file mode 100644 index 000000000..73f2a73f9 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/AuthNetworkReason.ts @@ -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'; diff --git a/apps/ai-game-creator-shell/src/services/generated/AuthResponseInvalid.ts b/apps/ai-game-creator-shell/src/services/generated/AuthResponseInvalid.ts new file mode 100644 index 000000000..b9b57f307 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/AuthResponseInvalid.ts @@ -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; +}; diff --git a/apps/ai-game-creator-shell/src/services/generated/AuthResponseInvalidReason.ts b/apps/ai-game-creator-shell/src/services/generated/AuthResponseInvalidReason.ts new file mode 100644 index 000000000..cd7dd488f --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/AuthResponseInvalidReason.ts @@ -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'; diff --git a/apps/ai-game-creator-shell/src/services/generated/AuthServiceUnavailable.ts b/apps/ai-game-creator-shell/src/services/generated/AuthServiceUnavailable.ts new file mode 100644 index 000000000..600fdcc93 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/AuthServiceUnavailable.ts @@ -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 }; diff --git a/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts b/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts new file mode 100644 index 000000000..9e87dc86d --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts @@ -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); diff --git a/apps/ai-game-creator-shell/src/services/generated/ClientSessionPersistFailed.ts b/apps/ai-game-creator-shell/src/services/generated/ClientSessionPersistFailed.ts new file mode 100644 index 000000000..258f43fb0 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/ClientSessionPersistFailed.ts @@ -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 }; diff --git a/apps/ai-game-creator-shell/src/services/generated/PasswordLoginRejected.ts b/apps/ai-game-creator-shell/src/services/generated/PasswordLoginRejected.ts new file mode 100644 index 000000000..450754f46 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/PasswordLoginRejected.ts @@ -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; +}; diff --git a/apps/ai-game-creator-shell/src/services/generated/PhoneCodeLoginRejected.ts b/apps/ai-game-creator-shell/src/services/generated/PhoneCodeLoginRejected.ts new file mode 100644 index 000000000..403eaebbd --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/PhoneCodeLoginRejected.ts @@ -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; +}; diff --git a/apps/ai-game-creator-shell/src/services/generated/RuntimeSessionInstallFailed.ts b/apps/ai-game-creator-shell/src/services/generated/RuntimeSessionInstallFailed.ts new file mode 100644 index 000000000..df2940b5d --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/RuntimeSessionInstallFailed.ts @@ -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 }; diff --git a/apps/ai-game-creator-shell/src/services/generated/SendCodeRejected.ts b/apps/ai-game-creator-shell/src/services/generated/SendCodeRejected.ts new file mode 100644 index 000000000..cdf2a07c0 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/SendCodeRejected.ts @@ -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; +}; diff --git a/apps/ai-game-creator-shell/src/services/generated/ServerAddressReason.ts b/apps/ai-game-creator-shell/src/services/generated/ServerAddressReason.ts new file mode 100644 index 000000000..c60ebb82b --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/ServerAddressReason.ts @@ -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'; diff --git a/apps/ai-game-creator-shell/src/services/generated/ServerAddressRejected.ts b/apps/ai-game-creator-shell/src/services/generated/ServerAddressRejected.ts new file mode 100644 index 000000000..fdcc96aa0 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/ServerAddressRejected.ts @@ -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 }; diff --git a/apps/ai-game-creator-shell/src/services/generated/UnexpectedRejection.ts b/apps/ai-game-creator-shell/src/services/generated/UnexpectedRejection.ts new file mode 100644 index 000000000..b2c4041eb --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/UnexpectedRejection.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 其它未识别拒绝的状态码与服务端原文。 + */ +export type UnexpectedRejection = { + status: number; + serverMessage: string | null; +}; diff --git a/apps/ai-game-creator-shell/src/services/platformSession.ts b/apps/ai-game-creator-shell/src/services/platformSession.ts index 372aa7ca9..4e8886b51 100644 --- a/apps/ai-game-creator-shell/src/services/platformSession.ts +++ b/apps/ai-game-creator-shell/src/services/platformSession.ts @@ -168,14 +168,8 @@ export function requestPlatformSessionRefresh(expectedUserId?: string) { authoritative: true, }; } - if (result.status === 'stale') { - return { status: 'stale' }; - } - return { - status: 'failed', - error: new Error(result.message), - authoritative: result.authoritative, - }; + // `stale`:续期期间身份已变化,调用方不得重放旧身份请求。 + return { status: 'stale' }; })().then((result) => { notifyPlatformSessionRefresh(result); return result; diff --git a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts index 36b65a319..5a37fbda6 100644 --- a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts +++ b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts @@ -663,8 +663,8 @@ describe('Agent Runtime Provider 状态投影', () => { ).toBe('陶泥儿智能创作 执行失败,请稍后重试'); }); - test('拒单文案只取收口文案里的脱敏摘要与建议,不套阶段标签', () => { - // 阶段说的是"失败发生在交付的哪一步",而拒单是"这一轮没有开始":阶段只会是默认值, + test('入队失败文案只取收口文案里的脱敏摘要与建议,不套阶段标签', () => { + // 阶段说的是"失败发生在交付的哪一步",而入队失败是"这一轮没有开始":阶段只会是默认值, // 套上去会把没发生的事讲成发生了。 expect( projectRuntimeVisibleRejectionError( @@ -682,7 +682,7 @@ describe('Agent Runtime Provider 状态投影', () => { ), ).toBe('陶泥儿智能创作 服务连接已断开,请稍后重试'); // 已知边界(本轮不动):映射里"拒绝"那条子串分支会先认领"拒绝访问"这类文件系统事实; - // 目录锚不定的拒单在聊天里走 `Display` 原样显示(它不在上报名单里),所以摸不到这句。 + // 目录锚不定的入队失败在聊天里走 `Display` 原样显示(它不在上报名单里),所以摸不到这句。 expect( projectRuntimeVisibleRejectionError( '无法锚定 Direct 调用项目目录:拒绝访问', diff --git a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts index a12fc9b44..24b61408c 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts @@ -1,10 +1,12 @@ import { afterEach } from 'vitest'; +import { ClientAuthErrorWrapper } from '../../src/services/clientAuthErrorWrapper'; import { AGC_DEVELOPMENT_API_BASE_URL } from '../../src/services/clientHttp'; import { currentPlatformSessionGeneration, resetPlatformSessionForTests, } from '../../src/services/platformSession'; +import { installUnhandledRejectionBridge } from '../unhandledRejectionBridge'; import { act, AuthenticatedClient, @@ -93,32 +95,62 @@ async function loginWithCode() { fireEvent.click(screen.getByRole('button', { name: '登录' })); } +/** + * 捕获 window 侧 `unhandledrejection`:返回已收到的 `reason` 列表与释放函数, + * 释放时同时摘掉监听和 Node 侧桥接。 + */ +function captureWindowRejections() { + const uninstallBridge = installUnhandledRejectionBridge(); + const reasons: unknown[] = []; + const onRejection = (event: Event) => { + reasons.push((event as Event & { reason?: unknown }).reason); + }; + window.addEventListener('unhandledrejection', onRejection); + return { + reasons, + uninstall() { + window.removeEventListener('unhandledrejection', onRejection); + uninstallBridge(); + }, + }; +} + export function registerAuthTests() { afterEach(() => { resetPlatformSessionForTests(); delete window.__TAURI__; }); - it('leaves startup loading with an actionable retry after auth check timeout', async () => { - renderAuthSurface(async (command: string) => { - if (command === 'read_client_auth_state') { - return await new Promise(() => {}); - } - return null; - }); + it('throws a startup timeout through to unhandledrejection and leaves the loading state', async () => { + const rejections = captureWindowRejections(); + try { + renderAuthSurface(async (command: string) => { + if (command === 'read_client_auth_state') { + return await new Promise(() => {}); + } + return null; + }); - expect( - await screen.findByText( - '检查登录状态超时,请检查服务器地址和网络后重试', - undefined, - { - timeout: 20_000, - }, - ), - ).not.toBeNull(); - expect( - screen.getByRole('button', { name: '重试登录状态检查' }), - ).toHaveProperty('disabled', false); + await waitFor(() => expect(rejections.reasons).toHaveLength(1), { + timeout: 20_000, + }); + expect(rejections.reasons[0]).toBeInstanceOf(Error); + expect((rejections.reasons[0] as Error).message).toContain( + '检查登录状态超时', + ); + // 启动检查失败在改动前就会显示原因 + 重试按钮;上报是叠加的,不是替换。 + expect( + await screen.findByText( + '检查登录状态超时,请检查服务器地址和网络后重试', + ), + ).not.toBeNull(); + expect( + screen.getByRole('button', { name: '重试登录状态检查' }), + ).toHaveProperty('disabled', false); + await screen.findByRole('main', { name: '登录' }); + } finally { + rejections.uninstall(); + } }, 30_000); it('renders the unauthenticated client with the shared light platform theme and product image', async () => { @@ -228,13 +260,17 @@ 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: 'passwordLoginRejected', + serverMessage: '手机号或密码错误', + }; } return null; }); @@ -249,51 +285,66 @@ export function registerAuthTests() { }); fireEvent.click(screen.getByRole('button', { name: '登录' })); - expect(await screen.findByText('手机号或密码错误')).not.toBeNull(); + expect( + await screen.findByText('登录失败: 手机号或密码错误'), + ).not.toBeNull(); expect(screen.queryByLabelText('已登录')).toBeNull(); }); - it('keeps transport details out of the login failure copy', async () => { - const invoke = vi.fn(async (command: string) => { - if (command === 'read_client_auth_state') { - return { status: 'unauthenticated' }; - } - if (command === 'login_client_with_phone_code') { - throw new Error( - 'network-error: 无法连接登录服务,请确认配套后端或 API 代理已启动后重试', - ); - } - return null; - }); - renderAuthSurface(invoke); - await loginWithCode(); + it('shows the system variant copy and still throws it through', async () => { + const rejections = captureWindowRejections(); + try { + const invoke = vi.fn(async (command: string) => { + if (command === 'read_client_auth_state') { + return { status: 'unauthenticated' }; + } + if (command === 'login_client_with_phone_code') { + throw { type: 'authNetworkFailure', reason: 'unreachable' }; + } + return null; + }); + renderAuthSurface(invoke); + await loginWithCode(); - expect( - await screen.findByText( - 'network-error: 无法连接登录服务,请确认配套后端或 API 代理已启动后重试', - ), - ).not.toBeNull(); - expect(document.body.textContent).not.toContain('ECONNREFUSED'); + // 先等系统变体真的经 unhandledrejection 抛出。 + await waitFor(() => expect(rejections.reasons).toHaveLength(1)); + // 载体 message 是载荷序列化(上报链路直接取用);结构化诊断在 error 字段里。 + expect(rejections.reasons[0]).toBeInstanceOf(ClientAuthErrorWrapper); + expect( + (rejections.reasons[0] as ClientAuthErrorWrapper).error, + ).toMatchObject({ + type: 'authNetworkFailure', + reason: 'unreachable', + }); + // 改动前系统失败也会在登录页给出一行提示;这里恢复同样的可见反馈,只是不泄露传输层细节。 + expect( + await screen.findByText( + '登录失败:无法连接登录服务,请确认配套后端或 API 代理已启动后重试', + ), + ).not.toBeNull(); + expect(document.body.textContent).not.toContain('ECONNREFUSED'); + await screen.findByRole('main', { name: '登录' }); + } finally { + rejections.uninstall(); + } }); - it('keeps a retry when the startup check reports the service unavailable', async () => { + it('keeps a retry when the startup check is rejected by a business variant', async () => { let reads = 0; const invoke = vi.fn(async (command: string) => { if (command === 'read_client_auth_state') { reads += 1; - return reads === 1 - ? { - status: 'unavailable', - errorMessage: 'network-error: 无法连接登录服务', - } - : { status: 'unauthenticated' }; + if (reads === 1) { + throw { type: 'serverAddressRejected', reason: 'notHttps' }; + } + return { status: 'unauthenticated' }; } return null; }); renderAuthSurface(invoke); expect( - await screen.findByText('network-error: 无法连接登录服务'), + await screen.findByText('服务器地址非法: 远程地址必须使用 https'), ).not.toBeNull(); expect(screen.queryByLabelText('已登录')).toBeNull(); diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 3bdc76a74..3f5131976 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1857,7 +1857,8 @@ export function registerHomeProjectCreationTests() { '卡住的自动建项', ); let resolveAutomaticProject: - ((result: Record) => void) | null = null; + | ((result: Record) => void) + | null = null; const invoke = vi.fn(async (command: string) => { if (command === 'preflight_web_game_creation') return { status: 'ready' }; if (command === 'create_automatic_local_game_project') { diff --git a/apps/ai-game-creator-shell/tests/appUpdate.test.ts b/apps/ai-game-creator-shell/tests/appUpdate.test.ts index 40a46bd59..1b014cdc4 100644 --- a/apps/ai-game-creator-shell/tests/appUpdate.test.ts +++ b/apps/ai-game-creator-shell/tests/appUpdate.test.ts @@ -43,8 +43,9 @@ describe('AGC 客户端更新', () => { it('开发态开关关闭时不请求清单', async () => { vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '0'); vi.resetModules(); - const { checkForAppUpdate, resetAppUpdateCheckForTests } = - await import('../src/services/appUpdate'); + const { checkForAppUpdate, resetAppUpdateCheckForTests } = await import( + '../src/services/appUpdate' + ); await expect(checkForAppUpdate()).resolves.toBeNull(); expect(checkMock).not.toHaveBeenCalled(); @@ -55,8 +56,9 @@ describe('AGC 客户端更新', () => { vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '1'); vi.resetModules(); checkMock.mockResolvedValue(fakeUpdate() as never); - const { checkForAppUpdate, resetAppUpdateCheckForTests } = - await import('../src/services/appUpdate'); + const { checkForAppUpdate, resetAppUpdateCheckForTests } = await import( + '../src/services/appUpdate' + ); await expect(checkForAppUpdate()).resolves.toEqual({ version: '99.0.0', @@ -152,8 +154,9 @@ describe('AGC 客户端更新', () => { it('没有待安装更新时安装请求失败关闭', async () => { vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '1'); vi.resetModules(); - const { installAppUpdate, resetAppUpdateCheckForTests } = - await import('../src/services/appUpdate'); + const { installAppUpdate, resetAppUpdateCheckForTests } = await import( + '../src/services/appUpdate' + ); await expect(installAppUpdate()).rejects.toThrow('没有可安装的更新'); resetAppUpdateCheckForTests(); diff --git a/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx b/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx new file mode 100644 index 000000000..b088f8435 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx @@ -0,0 +1,185 @@ +/** @vitest-environment jsdom */ + +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { createElement } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +/** + * 认证失败分流的上报判据(端到端,判据是"这条失败到底有没有进错误池")。 + * + * 认证命令与错误上报走的不是同一个 invoke 入口:认证命令来自 `window.__TAURI__.core.invoke` + * (本文件注入替身),错误上报来自 `@tauri-apps/api/core` 的模块级 `invoke`。所以只有 mock 掉 + * 模块级 invoke,才能观察到 `report_client_error` 是否真的被调用。 + */ +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn(async (command: string, _args?: Record) => { + if (command === 'get_pending_error_reports') return []; + return undefined; + }), +})); + +import { invoke } from '@tauri-apps/api/core'; + +import { AuthenticatedClient } from '../src/app/AuthenticatedClient'; +import { resetPlatformSessionForTests } from '../src/services/platformSession'; +import { installUnhandledRejectionBridge } from './unhandledRejectionBridge'; + +type AuthInvoke = ( + command: string, + args?: Record, +) => Promise; + +function reportCalls() { + return vi + .mocked(invoke) + .mock.calls.filter(([command]) => command === 'report_client_error'); +} + +function renderLogin(invokeAuth: AuthInvoke) { + window.__TAURI__ = { core: { invoke: invokeAuth } } as never; + render( + createElement(AuthenticatedClient, null, () => + createElement('main', { 'aria-label': '已登录' }), + ), + ); +} + +async function submitPasswordLogin(invokeAuth: AuthInvoke) { + renderLogin(invokeAuth); + await screen.findByRole('main', { name: '登录' }); + fireEvent.click(screen.getByRole('button', { name: '密码登录' })); + fireEvent.change(screen.getByLabelText('手机号'), { + target: { value: '13800000000' }, + }); + fireEvent.change(screen.getByLabelText('密码'), { + target: { value: 'wrong-password' }, + }); + fireEvent.click(screen.getByRole('button', { name: '登录' })); +} + +describe('认证失败的上报判据', () => { + afterEach(() => { + cleanup(); + resetPlatformSessionForTests(); + delete window.__TAURI__; + vi.mocked(invoke).mockClear(); + }); + + it('业务拒绝(手机号或密码错误)只显示文案,不写错误池', async () => { + await submitPasswordLogin(async (command) => { + if (command === 'read_client_auth_state') { + return { status: 'unauthenticated' }; + } + if (command === 'login_client_with_password') { + throw { type: 'phoneOrPasswordMismatch' }; + } + return null; + }); + + expect(await screen.findByText('手机号或密码错误')).not.toBeNull(); + expect(reportCalls()).toHaveLength(0); + }); + + it('服务端 400 的原文经 passwordLoginRejected 透出,仍不写错误池', async () => { + await submitPasswordLogin(async (command) => { + if (command === 'read_client_auth_state') { + return { status: 'unauthenticated' }; + } + if (command === 'login_client_with_password') { + throw { + type: 'passwordLoginRejected', + serverMessage: '密码长度需要在 6 到 128 位之间', + }; + } + return null; + }); + + expect( + await screen.findByText('登录失败: 密码长度需要在 6 到 128 位之间'), + ).not.toBeNull(); + expect(reportCalls()).toHaveLength(0); + }); + + it('发码限流同样只显示文案,不写错误池', async () => { + renderLogin(async (command) => { + if (command === 'read_client_auth_state') { + return { status: 'unauthenticated' }; + } + if (command === 'send_client_phone_login_code') { + throw { type: 'smsCodeThrottled' }; + } + return null; + }); + await screen.findByRole('main', { name: '登录' }); + fireEvent.change(screen.getByLabelText('手机号'), { + target: { value: '13800000000' }, + }); + fireEvent.click(screen.getByRole('button', { name: '获取验证码' })); + + expect( + await screen.findByText('发送验证码过于频繁,请稍后再试'), + ).not.toBeNull(); + expect(reportCalls()).toHaveLength(0); + }); + + it('系统变体(网络不可达)原样抛出,经 unhandledrejection 上报一次', async () => { + const uninstall = installUnhandledRejectionBridge(); + try { + await submitPasswordLogin(async (command) => { + if (command === 'read_client_auth_state') { + return { status: 'unauthenticated' }; + } + if (command === 'login_client_with_password') { + throw { type: 'authNetworkFailure', reason: 'unreachable' }; + } + return null; + }); + + await waitFor(() => expect(reportCalls()).toHaveLength(1)); + expect(reportCalls()[0]?.[1]).toMatchObject({ + source: 'unhandledrejection', + message: JSON.stringify({ + type: 'authNetworkFailure', + reason: 'unreachable', + }), + }); + } finally { + uninstall(); + } + }); + + it('Tauri 的意外拒绝形状也被包装后原样抛出上报', async () => { + const uninstall = installUnhandledRejectionBridge(); + try { + await submitPasswordLogin(async (command) => { + if (command === 'read_client_auth_state') { + return { status: 'unauthenticated' }; + } + if (command === 'login_client_with_password') { + throw new Error('IPC 桥接不可用'); + } + return null; + }); + + await waitFor(() => expect(reportCalls()).toHaveLength(1)); + expect(reportCalls()[0]?.[1]).toMatchObject({ + source: 'unhandledrejection', + message: 'IPC 桥接不可用', + }); + // 映射外的拒绝仍然上报,但界面要给一行泛化提示,不能停在无解释的登录页。 + expect( + await screen.findByText( + '登录服务返回了未识别的错误,请重试或提交错误报告', + ), + ).not.toBeNull(); + } finally { + uninstall(); + } + }); +}); diff --git a/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts b/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts new file mode 100644 index 000000000..d8b0b27a2 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/clientAuthErrorCarrier.test.ts @@ -0,0 +1,227 @@ +// @vitest-environment jsdom +/** + * 认证命令失败的 JS 侧载体。 + * + * `invokeClientAuth` 是薄包装:Rust 的结构化拒绝原样装进 `ClientAuthErrorWrapper.error`,不读变体 + * 字段(`serverMessage` 之类不保证每个变体都有)、不注入上下文,载体构造时把整份载荷序列化进 + * `Error.message`;分流只按 `error.error.type`。另外用编译期用例钉住变体全集:无字段变体直接取固定文案,带载荷变体先 + * `as` 取具名类型,可枚举的细分再按类型化 reason 分流;漏列任何变体或 reason, + * `expectNever(...)` 都编译不过。 + */ +import { describe, expect, it, vi } from 'vitest'; + +import { loginClientWithPassword } from '../src/services/clientAuth'; +import { ClientAuthErrorWrapper } from '../src/services/clientAuthErrorWrapper'; +import type { AuthClientInitFailed } from '../src/services/generated/AuthClientInitFailed'; +import type { AuthNetworkFailure } from '../src/services/generated/AuthNetworkFailure'; +import type { AuthResponseInvalid } from '../src/services/generated/AuthResponseInvalid'; +import type { AuthServiceUnavailable } from '../src/services/generated/AuthServiceUnavailable'; +import type { ClientAuthError } from '../src/services/generated/ClientAuthError'; +import type { ClientSessionPersistFailed } from '../src/services/generated/ClientSessionPersistFailed'; +import type { PasswordLoginRejected } from '../src/services/generated/PasswordLoginRejected'; +import type { PhoneCodeLoginRejected } from '../src/services/generated/PhoneCodeLoginRejected'; +import type { RuntimeSessionInstallFailed } from '../src/services/generated/RuntimeSessionInstallFailed'; +import type { SendCodeRejected } from '../src/services/generated/SendCodeRejected'; +import type { ServerAddressRejected } from '../src/services/generated/ServerAddressRejected'; +import type { UnexpectedRejection } from '../src/services/generated/UnexpectedRejection'; + +function expectNever(value: never): void { + void value; +} + +/** 编译期用例:顶层变体逐个列全;带载荷的分支先 `as`,reason 再按类型化枚举分流。 */ +function variantMessage(error: ClientAuthError): string { + switch (error.type) { + case 'serverAddressRejected': { + const payload = error as ServerAddressRejected; + switch (payload.reason) { + case 'emptyOrTooLong': + return '不能为空或超长'; + case 'notAUrl': + return '不是合法地址'; + case 'hasCredentials': + return '不能包含凭据'; + case 'hasPathOrQueryOrFragment': + return '不能带路径、查询或 fragment'; + case 'notHttps': + return '远程地址不是 https'; + case 'unsupportedScheme': + return '不是 http(s) 地址'; + case 'outsideChannel': + return '不在当前构建渠道范围内'; + default: { + expectNever(payload.reason); + return ''; + } + } + } + case 'phoneNumberInvalid': + return '手机号格式不正确'; + case 'passwordMissing': + return '密码为空'; + case 'loginCodeMissing': + return '验证码为空'; + case 'passwordLoginRejected': + return (error as PasswordLoginRejected).serverMessage ?? '登录输入被拒绝'; + case 'phoneOrPasswordMismatch': + return '手机号或密码错误'; + case 'sendCodeRejected': + return (error as SendCodeRejected).serverMessage ?? '发码被拒绝'; + case 'smsCodeThrottled': + return '发码过于频繁'; + case 'phoneCodeLoginRejected': + return ( + (error as PhoneCodeLoginRejected).serverMessage ?? '验证码登录被拒绝' + ); + case 'sessionInvalidated': + return '登录态失效'; + case 'permissionDenied': + return '权限不足'; + case 'authNetworkFailure': { + const payload = error as AuthNetworkFailure; + switch (payload.reason) { + case 'timeout': + return '连接超时'; + case 'unreachable': + return '连接失败'; + default: { + expectNever(payload.reason); + return ''; + } + } + } + case 'authServiceUnavailable': + return `HTTP ${(error as AuthServiceUnavailable).status}`; + case 'unexpectedRejection': + return (error as UnexpectedRejection).serverMessage ?? '未识别拒绝'; + case 'authResponseInvalid': { + const payload = error as AuthResponseInvalid; + switch (payload.reason) { + case 'notJson': + return '响应不是 JSON'; + case 'invalidBody': + return '响应结构不符'; + case 'missingRefreshCookie': + return '缺续期凭据'; + case 'missingUserIdentity': + return '缺用户主体'; + case 'serverRejected': + return payload.serverMessage ?? '服务端显式拒绝'; + default: { + expectNever(payload.reason); + return ''; + } + } + } + case 'clientSessionPersistFailed': + return (error as ClientSessionPersistFailed).detail; + case 'runtimeSessionInstallFailed': + return (error as RuntimeSessionInstallFailed).detail; + case 'authClientInitFailed': + return (error as AuthClientInitFailed).detail; + default: { + expectNever(error); + return ''; + } + } +} + +function installInvoke(handler: () => Promise) { + window.__TAURI__ = { core: { invoke: vi.fn(handler) } } as never; +} + +describe('认证命令失败的 JS 侧载体', () => { + it('结构化拒绝原样装进 error 字段,不读变体字段', async () => { + // 故意不带 serverMessage:包装层绝不能假设每个变体都有这个字段。 + const rejection = { type: 'phoneNumberInvalid' }; + installInvoke(async () => { + throw rejection; + }); + + const failure = await loginClientWithPassword( + '13800000000', + 'secret', + 'https://dev.genarrative.world', + ).catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(ClientAuthErrorWrapper); + expect((failure as ClientAuthErrorWrapper).error).toBe(rejection); + expect((failure as Error).message).toBe(JSON.stringify(rejection)); + }); + + it('认证桥未安装时保持原样抛出,不包装成命令失败', async () => { + delete window.__TAURI__; + + await expect( + loginClientWithPassword( + '13800000000', + 'secret', + 'https://dev.genarrative.world', + ), + ).rejects.toThrow('需要在 Tauri App 内登录'); + }); + + it('Tauri 的意外形状也按映射包装:不做嗅探,但仍是可上报的 Error', async () => { + const rejection = new Error('IPC 桥接异常'); + installInvoke(async () => { + throw rejection; + }); + + const failure = await loginClientWithPassword( + '13800000000', + 'secret', + 'https://dev.genarrative.world', + ).catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(ClientAuthErrorWrapper); + // Error 没有可枚举属性,序列化就是空对象;上报链路对真 Error 优先用其自身 message/stack。 + expect((failure as Error).message).toBe('{}'); + expect((failure as ClientAuthErrorWrapper).error).toBe(rejection); + }); +}); + +describe('变体分流', () => { + it('无字段变体直接取固定文案,带载荷变体按具名类型取值', () => { + expect(variantMessage({ type: 'phoneNumberInvalid' })).toBe( + '手机号格式不正确', + ); + expect( + variantMessage({ + type: 'passwordLoginRejected', + serverMessage: '密码长度需要在 6 到 128 位之间', + }), + ).toBe('密码长度需要在 6 到 128 位之间'); + expect( + variantMessage({ type: 'passwordLoginRejected', serverMessage: null }), + ).toBe('登录输入被拒绝'); + expect( + variantMessage({ type: 'authServiceUnavailable', status: 503 }), + ).toBe('HTTP 503'); + }); + + it('可枚举细分按类型化 reason 分流,不读服务端文案', () => { + expect( + variantMessage({ + type: 'serverAddressRejected', + reason: 'notHttps', + }), + ).toBe('远程地址不是 https'); + expect( + variantMessage({ type: 'authNetworkFailure', reason: 'timeout' }), + ).toBe('连接超时'); + expect( + variantMessage({ + type: 'authResponseInvalid', + reason: 'serverRejected', + serverMessage: '登录服务请求失败', + }), + ).toBe('登录服务请求失败'); + expect( + variantMessage({ + type: 'authResponseInvalid', + reason: 'missingRefreshCookie', + serverMessage: null, + }), + ).toBe('缺续期凭据'); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts index 4b10a4a2f..6c6393676 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthHost.test.ts @@ -10,7 +10,6 @@ import { beforeEach, expect, test, vi } from 'vitest'; const invoke = vi.fn(); import { - getClientAuthErrorMessage, loginClientWithPassword, loginClientWithPhoneCode, logoutClientAuthSession, @@ -47,7 +46,7 @@ test('手机号归一化只保留可提交的纯号码', () => { expect(normalizeAuthPhoneInput('0086-138-0000-0000')).toBe('008613800000000'); }); -test('恢复登录态映射为 authenticated / unauthenticated / unavailable', async () => { +test('恢复登录态映射为 authenticated / unauthenticated', async () => { invoke.mockResolvedValueOnce({ status: 'authenticated', user: testUser, @@ -71,15 +70,6 @@ test('恢复登录态映射为 authenticated / unauthenticated / unavailable', a expect(invoke).toHaveBeenCalledWith('read_client_auth_state', { expectedApiBaseUrl: null, }); - - invoke.mockResolvedValueOnce({ - status: 'unavailable', - errorMessage: 'network-error: 无法连接登录服务', - }); - await expect(readClientAuthState()).resolves.toEqual({ - status: 'unavailable', - message: 'network-error: 无法连接登录服务', - }); }); test('登录只提交结构化参数,凭据由 Rust 持有', async () => { @@ -135,7 +125,7 @@ test('登录只提交结构化参数,凭据由 Rust 持有', async () => { expect(serialized).not.toContain('cookie'); }); -test('续期结果区分成功、失效、身份变化与暂时失败', async () => { +test('续期结果区分成功、失效与身份变化', async () => { invoke.mockResolvedValueOnce({ status: 'refreshed', user: testUser }); await expect(refreshClientAuthSession('user-1')).resolves.toEqual({ status: 'refreshed', @@ -151,17 +141,6 @@ test('续期结果区分成功、失效、身份变化与暂时失败', async () await expect(refreshClientAuthSession()).resolves.toEqual({ status: 'stale', }); - - invoke.mockResolvedValueOnce({ - status: 'failed', - authoritative: false, - errorMessage: 'network-error: 无法连接登录服务', - }); - await expect(refreshClientAuthSession()).resolves.toEqual({ - status: 'failed', - message: 'network-error: 无法连接登录服务', - authoritative: false, - }); }); test('没有原生宿主时登录能力明确失败关闭', async () => { @@ -179,9 +158,10 @@ test('没有原生宿主时登录能力明确失败关闭', async () => { expect(invoke).not.toHaveBeenCalled(); }); -test('错误文案优先使用服务端原因,缺失时回落到调用方文案', () => { - expect( - getClientAuthErrorMessage(new Error('手机号或密码错误'), '登录失败'), - ).toBe('手机号或密码错误'); - expect(getClientAuthErrorMessage('', '登录失败')).toBe('登录失败'); +test('结构化失败不降级成投影值,而是以携带原始拒绝值的 ClientAuthErrorWrapper 拒绝', async () => { + const rejection = { type: 'authNetworkFailure', reason: 'unreachable' }; + invoke.mockRejectedValueOnce(rejection); + await expect(refreshClientAuthSession('user-1')).rejects.toMatchObject({ + error: rejection, + }); }); diff --git a/apps/ai-game-creator-shell/tests/errorReporting.test.ts b/apps/ai-game-creator-shell/tests/errorReporting.test.ts index ec1788898..dc4e8e9c7 100644 --- a/apps/ai-game-creator-shell/tests/errorReporting.test.ts +++ b/apps/ai-game-creator-shell/tests/errorReporting.test.ts @@ -71,6 +71,7 @@ vi.mock('@tauri-apps/api/core', () => ({ })); import { invoke } from '@tauri-apps/api/core'; +import { ClientAuthErrorWrapper } from '../src/services/clientAuthErrorWrapper'; import { ackClientErrorEventsWithRetry, captureAgentRuntimeError, @@ -80,10 +81,10 @@ import { markClientErrorEventsSubmitted, normalizeDiagnosticText, resetClientErrorEventsForTests, - shouldCaptureClientError, submitErrorReportBatch, subscribeClientErrorEvents, } from '../src/services/errorReporting'; +import type { ClientAuthError } from '../src/services/generated/ClientAuthError'; describe('客户端错误报告池', () => { afterEach(() => { @@ -209,13 +210,39 @@ describe('客户端错误报告池', () => { expect(await getPendingClientErrorEvents()).toEqual([event]); }); - it('只采集网络错误、408 和 5xx', () => { - expect(shouldCaptureClientError({ status: 400 })).toBe(false); - expect(shouldCaptureClientError({ status: 401 })).toBe(false); - expect(shouldCaptureClientError({ status: 429 })).toBe(false); - expect(shouldCaptureClientError({ status: 408 })).toBe(true); - expect(shouldCaptureClientError({ status: 503 })).toBe(true); - expect(shouldCaptureClientError({ networkError: true })).toBe(true); + it('解包 ClientAuthErrorWrapper,把载体序列化后的 message 原样上报,上下文只认显式入参', async () => { + const original = { type: 'authNetworkFailure', reason: 'unreachable' }; + + await captureClientError(new ClientAuthErrorWrapper(original), { + source: 'unhandledrejection', + action: 'login', + }); + + expect(invoke).toHaveBeenCalledWith('report_client_error', { + source: 'unhandledrejection', + message: JSON.stringify(original), + stack: expect.any(String), + action: 'login', + page: undefined, + }); + }); + + it('包装的真 Error 仍优先用它的 message/stack,而不是空对象序列化', async () => { + // Tauri 在映射外抛真 Error 属于它的缺陷:载体照装,但取文案时仍用这颗 Error 自己。 + const original = new Error('IPC 桥接异常'); + + await captureClientError( + new ClientAuthErrorWrapper(original as unknown as ClientAuthError), + { source: 'unhandledrejection' }, + ); + + expect(invoke).toHaveBeenCalledWith('report_client_error', { + source: 'unhandledrejection', + message: 'IPC 桥接异常', + stack: original.stack, + action: undefined, + page: undefined, + }); }); it('保留 API 路由但隐藏 URL origin 与查询参数', () => { diff --git a/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.test.ts b/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.test.ts new file mode 100644 index 000000000..94287a7cb --- /dev/null +++ b/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.test.ts @@ -0,0 +1,105 @@ +// @vitest-environment jsdom +/** + * jsdom `unhandledrejection` 桥的「只吞被消费的拒绝」语义。 + * + * 桥接管 Node 监听器是为了让组件能收到 window 事件;但没人消费的拒绝必须交回原监听器, + * 否则用例会静默吞掉真实的未处理拒绝。 + */ +import { afterEach, expect, test } from 'vitest'; + +import { installUnhandledRejectionBridge } from './unhandledRejectionBridge'; + +const installed: Array<() => void> = []; +const processListeners: Array<(reason: unknown, promise: unknown) => void> = []; +const windowListeners: Array<(event: Event) => void> = []; +let parkedListeners: Array<(reason: unknown, promise: unknown) => void> = []; + +afterEach(() => { + while (installed.length > 0) installed.pop()?.(); + while (processListeners.length > 0) { + process.removeListener('unhandledRejection', processListeners.pop()!); + } + while (windowListeners.length > 0) { + window.removeEventListener('unhandledrejection', windowListeners.pop()!); + } + for (const listener of parkedListeners) { + process.on('unhandledRejection', listener); + } + parkedListeners = []; +}); + +/** 停放 vitest 自己的监听器,好观察「交回原监听器」而不触发用例失败上报。 */ +function parkProcessListeners() { + parkedListeners = process.listeners('unhandledRejection'); + process.removeAllListeners('unhandledRejection'); +} + +function trackProcessListener() { + const forwarded: unknown[] = []; + const listener = (reason: unknown) => { + forwarded.push(reason); + }; + processListeners.push(listener); + process.on('unhandledRejection', listener); + return forwarded; +} + +test('没人读 reason 的拒绝会交回原监听器', () => { + parkProcessListeners(); + const forwarded = trackProcessListener(); + installed.push(installUnhandledRejectionBridge()); + + const reason = new Error('意外的拒绝'); + process.emit('unhandledRejection', reason, Promise.resolve()); + + expect(forwarded).toEqual([reason]); +}); + +test('window 侧读过 reason 的拒绝不再交回原监听器', () => { + const forwarded = trackProcessListener(); + const consumed: unknown[] = []; + const listener = (event: Event) => { + consumed.push((event as Event & { reason?: unknown }).reason); + }; + windowListeners.push(listener); + window.addEventListener('unhandledrejection', listener); + installed.push(installUnhandledRejectionBridge()); + + const reason = new Error('已接手的拒绝'); + process.emit('unhandledRejection', reason, Promise.resolve()); + + expect(consumed).toEqual([reason]); + expect(forwarded).toEqual([]); +}); + +test('交回原监听器时 this 绑成 process,和 Node EventEmitter 一致', () => { + parkProcessListeners(); + const receivers: unknown[] = []; + const listener = function (this: unknown) { + receivers.push(this); + }; + processListeners.push(listener); + process.on('unhandledRejection', listener); + installed.push(installUnhandledRejectionBridge()); + + process.emit('unhandledRejection', new Error('需要交回'), Promise.resolve()); + + expect(receivers).toEqual([process]); +}); + +test('合成事件同时带 reason 与 promise,读任一个都算被消费', () => { + const forwarded = trackProcessListener(); + let received: unknown = null; + const listener = (event: Event) => { + received = (event as Event & { promise?: unknown }).promise; + }; + windowListeners.push(listener); + window.addEventListener('unhandledrejection', listener); + installed.push(installUnhandledRejectionBridge()); + + const promise = Promise.resolve(); + process.emit('unhandledRejection', new Error('带 promise 的拒绝'), promise); + + expect(received).toBe(promise); + expect(forwarded).toEqual([]); +}); diff --git a/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.ts b/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.ts new file mode 100644 index 000000000..41f5a4046 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/unhandledRejectionBridge.ts @@ -0,0 +1,98 @@ +/** + * 把 Node 的 `unhandledRejection` 桥接成 jsdom `window` 的 `unhandledrejection` 事件。 + * + * 生产 WebView 里,`void` 掉的 async handler(`onSubmit` / `onClick`)抛出的拒绝会派发 + * `unhandledrejection`,被 `AuthenticatedClient` 的全局 handler 交给错误池。jsdom 既不派发 + * 该事件,vitest 又会把未处理的拒绝当成用例失败;需要观察这条链路的用例用它接管 Node + * 监听器,再合成一个与 WebView `PromiseRejectionEvent` 同形(`reason` + `promise`)的 window 事件。 + * + * 只抑制**被 window 侧消费**的拒绝:合成事件的 `reason` / `promise` 是访问器,任何监听方读过 + * 任一个就算有人接手;没人读说明这条拒绝是意外的,交回安装前的监听器(含 vitest 的失败上报), + * 不会被静默吞掉。 + * + * 安装前用 `rawListeners` 取原始监听器:`process.once` 注册的包装(`.listener`)在拆卸时 + * 按 `once` 装回去,不会退化成每次拒绝都触发的普通监听器;安装期间新增的监听器仍排在 + * 恢复出来的监听器之前。 + * + * 用法:用例内 `const uninstall = installUnhandledRejectionBridge()`,在 `finally` 或 + * `afterEach` 里 `uninstall()`。 + */ +type RejectionListener = (reason: unknown, promise: Promise) => void; +type RawRejectionListener = RejectionListener & { + listener?: RejectionListener; +}; + +function invokeRejectionListener( + listener: RawRejectionListener, + reason: unknown, + promise: Promise, +) { + // once 包装必须调它包裹的原函数;Node 的 EventEmitter 把 this 绑成 process, + // 这里用 Reflect.apply 对齐,别让依赖 this 的原监听器拿到 undefined。 + Reflect.apply(listener.listener ?? listener, process, [reason, promise]); +} + +/** + * 合成一个与 WebView `unhandledrejection` 同形的 window 事件。 + * + * 浏览器里本来就是 `PromiseRejectionEvent`(带 `reason` / `promise`),有构造器就直接用它; + * jsdom 不实现这个类时退回普通 `Event`。两种来源都再把 `reason` / `promise` 覆盖成实例访问器: + * 既保证 jsdom 分支下 `promise` 同样可读,也让「读过任一个字段」都能记成有人接手。 + */ +function createRejectionEvent( + reason: unknown, + promise: Promise, + markConsumed: () => void, +): Event { + const event = + typeof PromiseRejectionEvent === 'function' + ? new PromiseRejectionEvent('unhandledrejection', { promise, reason }) + : new Event('unhandledrejection'); + Object.defineProperties(event, { + reason: { + get() { + markConsumed(); + return reason; + }, + configurable: true, + }, + promise: { + get() { + markConsumed(); + return promise; + }, + configurable: true, + }, + }); + return event; +} + +export function installUnhandledRejectionBridge() { + const originalListeners = process.rawListeners( + 'unhandledRejection', + ) as RawRejectionListener[]; + process.removeAllListeners('unhandledRejection'); + const handleRejection = (reason: unknown, promise: Promise) => { + let consumed = false; + const event = createRejectionEvent(reason, promise, () => { + consumed = true; + }); + window.dispatchEvent(event); + if (!consumed) { + for (const listener of originalListeners) { + invokeRejectionListener(listener, reason, promise); + } + } + }; + process.on('unhandledRejection', handleRejection); + return () => { + process.removeListener('unhandledRejection', handleRejection); + for (const listener of originalListeners) { + if (listener.listener) { + process.once('unhandledRejection', listener.listener); + } else { + process.on('unhandledRejection', listener); + } + } + }; +} diff --git a/docs/README.md b/docs/README.md index d038b7fba..f684f47e7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -55,6 +55,8 @@ - [DirectProject 命令接单化](./adr/【ADR】DirectProject命令接单化-2026-09-23.md):命令只负责接单、事件流回答整轮结果;拒单前置、失败后置。 - [DirectProject 命令接单化实施计划](./technical/【实施计划】DirectProject命令接单化-2026-09-23.md):四步落地顺序、每步不变式与验收;四步均已落地。 - [DirectProject 命令入队化与待发消息队列归宿主](./adr/【ADR】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md):命令只负责入队,放行归 Thread Manager;待发消息队列作为运行态事件归宿主、前端只投影;CLI 直连入口与调用身份守卫一并退役。 +- [AGC 命令错误结构化与错误报告口径](./adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md):AGC 命令失败按具体变体建模并用 ts-rs 导出,前端按变体分流、不匹配文案;报告池只收没人处理的错误。 +- [AGC 认证失败的 JS 侧载体与抛出时机](./adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md):认证命令统一经 `invokeClientAuth` 把拒绝装进 `ClientAuthErrorWrapper`(`error` 字段就是 ts-rs 生成的 `ClientAuthError` 判别联合),不新增手写错误类;判定只写在 catch 子句里,无字段变体用固定文案、带载荷分支先 `as` 取自己的具名载荷类型(可枚举细分再 `switch (payload.reason)` 在类型化枚举上分流),系统变体原样抛出经 `unhandledrejection` 入池,`default: expectNever` 编译期挡住漏接变体。 - [DirectProject 命令入队化与待发消息队列归宿主实施计划](./technical/【实施计划】DirectProject命令入队化与待发消息队列归宿主-2026-09-24.md):五步落地顺序、每步不变式与验收;待实施。 - [GameAgent 对话工具调用卡片](./technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md):把右侧对话里的执行命令 / 写文件投影成 Codex 风格可折叠卡片,含采集、独立历史文件、事件字段与回读契约。 - [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。 @@ -130,7 +132,7 @@ - [UI 编辑器图片素材选择器](./technical/【前端设计】UI编辑器图片素材选择器-2026-09-03.md) - [后台 Dashboard 运营看板方案](./technical/【后台管理】Dashboard运营看板方案-2026-06-23.md) - [后台多账号与 Tab 访问权限方案](./technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md) -- [Pingora 独立网关试点](<./technical/【开发运维】Pingora独立网关试点-2026-06-11.md>) +- [Pingora 独立网关试点](./technical/【开发运维】Pingora独立网关试点-2026-06-11.md) - [AGC 后台模型别名与对话选择](./technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md):官方目录、本地自定义 LLM 开关、端点模型勾选与预览。 - [UI 编辑器工作流完成通知弹窗](./technical/【设计】UI编辑器工作流完成通知弹窗-2026-09-04.md) - [官网 SEO 地基实施约定](./technical/【SEO】官网SEO地基实施约定-2026-07-10.md) diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md new file mode 100644 index 000000000..3ee3053a5 --- /dev/null +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -0,0 +1,118 @@ +# 【ADR】AGC 命令错误结构化与错误报告口径 + +状态:已接受(2026-10-01 落地,实施顺序见同日的决策记录与 +[`【技术方案】AGC错误报告与诊断上传-2026-08-31`](../technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md)) + +## 背景 + +用户在登录页把密码输错一次,报告面板就出现两条"错误事件"并弹出「发现问题」: + +```text +登录失败:密码长度需要在 6 到 128 位之间 — auth · 1 次 +手机号或密码错误 — auth · 1 次 +``` + +根因不是文案,而是两件事叠加: + +1. 登录已经下沉到 Rust(`login_client_with_password` 等命令),命令失败以 `Err(String)` 返回; + Tauri 把 `String` 原样交给 JS,`invoke` 以**裸字符串**拒绝,前端拿到的东西没有任何类型信息。 +2. WebView 侧的 `shouldCaptureClientError` 对"非 object"值走默认 `return true`,于是 + `handleLoginSubmit` 的 catch 把"用户输错密码"当成缺陷事件报进了错误池。 + +这与 [`【技术方案】AGC错误报告与诊断上传-2026-08-31`](../technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md) +已写明的"预期的 4xx 登录/鉴权失败不进入错误报告池"直接冲突——口径早就定了,是错误通道的实现方式违背了它。 + +DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接单化-2026-09-23`](./【ADR】DirectProject命令接单化-2026-09-23.md) §4): +命令返回结构化 typed error,前端按变体分流,"认不得的变体或非结构化错误"原样抛出走上报链路。 +本 ADR 把这套口径推广成 AGC 命令错误的通用约定,并同时收窄错误报告池的入口。 + +## 决策 + +### 1. 命令错误按具体变体建模,不按文案匹配 + +- Rust 侧定义具体变体枚举(auth 首个落地:`ClientAuthError`),`#[derive(Serialize, TS)]` + + `#[serde(tag = "type", rename_all = "camelCase")]`,用 ts-rs 导出到 + `apps/ai-game-creator-shell/src/services/generated/`;生成物不手改。 +- **顶层只放调用方要分流的类别,可枚举的细分原因收进类型化 `reason` 字段**——既不拆成几十个顶层变体, + 也不用字符串。`reason` 自己也是 `#[derive(Serialize, TS)]` 的枚举。internally tagged 下无字段变体是 + `{ type: 'x' }`;newtype 变体是 `{ type: 'x' } & X`,ts-rs 为它生成 `generated/X.ts`。例如服务地址 + 校验是 `serverAddressRejected` + `ServerAddressReason`(`emptyOrTooLong` / `notAUrl` / `hasCredentials` / + `hasPathOrQueryOrFragment` / `notHttps` / `unsupportedScheme` / `outsideChannel`),网络失败是 + `authNetworkFailure` + `AuthNetworkReason`(`timeout` / `unreachable`),响应契约破损是 + `authResponseInvalid` + `AuthResponseInvalidReason`(`notJson` / `invalidBody` / `missingRefreshCookie` / + `missingUserIdentity` / `serverRejected`),而不是拆成 7 + 2 + 5 个顶层变体。 +- 前端 `switch (error.type)` 必须列全顶层变体:无字段变体直接取本 catch 的固定文案;带载荷变体先 `as` + 取自己的具名载荷类型,可枚举的细分再 `switch (payload.reason)` 在**类型化**的 `reason` 上分流(等价于 + Java 的嵌套 `switch`,仍不碰文案)。顶层与 `reason` 的 `default` 都用 `expectNever`,漏接变体或漏接 + `reason` 都是**编译错误**。**不假设所有变体都有同一个字段**,也不做任何文案匹配。 +- Rust **不预拼用户可见文案**:载荷只装原始事实(类型化的 `reason`、服务端 400 的原文 `serverMessage`、 + HTTP 状态码、本机 IO / 网络客户端构建失败的原始 `detail`),服务端没给原文就是 `None`;前缀与句式由 + 前端调用方在自己的 catch 分支按当前操作拼接。**原始错误必须留在载荷里**(调用方据此分流,报告包据此 + 诊断),同时另记一行本地日志;但 `detail` **不直接贴在界面上**:`clientSessionPersistFailed` / + `runtimeSessionInstallFailed` / `authClientInitFailed` 三个 catch 用本操作的固定文案,路径等敏感片段 + 由报告侧的 sanitize 换成占位符。 +- `#[tauri::command]` 的 `Err` 直接携带该枚举(Tauri 2 的 `InvokeError(pub serde_json::Value)` 支持结构化错误)。 + 这是 DirectProject 已有的做法(`enqueue_direct_codex_turn -> Result<(), DirectTurnEnqueueFailure>`),不是新约定。 +- 变体按**可判定的事实**命名。服务端 400 只提供 `status + message`(`AppError.code` 仍是通用 + `BAD_REQUEST`),所以 400 变体按"哪条请求的输入被拒"命名(如 `passwordLoginRejected`), + 不假装能区分密码长度/手机号格式;**任何地方都不允许对错误文案做判断**。 +- `429` 同样按路由判定:发码路由是频控(`smsCodeThrottled`),登录路由是「验证码错误次数过多」 + (复用 `phoneCodeLoginRejected`,仍是用户可修正的输入问题、不进池),其余路由的 `429` 才落到 + `unexpectedRejection`。 +- `/api/auth/phone/login` 的 `401` 只来自「用户不存在」(验证码错误/失效/过期都是 `400`),同样归 + `phoneCodeLoginRejected`;为此退役的 `smsCodeRejected` 曾把 401 冒充成「验证码错误或过期」,属于错配。 +- 本地前置校验只做布尔判定、拿不到更细事实时不编字段:手机号校验 `phone_is_valid` 只回 true/false, + 所以 `phoneNumberInvalid` 保持无字段,提示由调用方给固定文案;编不出事实的"细分原因"不硬造。 + +### 2. 报告池只收"没有任何调用方处理"的错误 + +谁抛出、谁判定。分层规则: + +- **预期业务拒绝**(用户输入、前置条件、预期 4xx):由调用方消化并给用户反馈,**永不进池**。 +- **真故障**(网络不可达、5xx、写盘/运行时安装失败、agent 终态失败):由调用方带上文交给错误池 + (`ClientAuthErrorWrapper` + `captureClientError`);`window.onerror` / `unhandledrejection` 只兜底 + 没人接手的错误。Rust 侧 agent 终态失败仍由失败投影入池。 +- **WebView 全局 handler 是兜底**:任何没人 catch 的错误都进池。 +- **408/5xx/网络的判定由调用方在 catch 里做**:AGC shell 的 WebView 侧没有 fetch 边界的自动判定 + (`shouldCaptureClientError` 只认测试构造过、生产代码从不产生的 `{status}` / `{networkError}` + 形状,随本 ADR 删除);4xx 一律不报,交给上层调用方。 + +### 3. 前端按变体分流(2026-10-01 修订) + +- 本节原先的"调用方用 `isClientAuthError` 读形状、`switch (error.type)` 分流、`default` 交池"已被 + [`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`](./【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md) + 取代:`invokeClientAuth` 把结构化拒绝装进 `ClientAuthErrorWrapper`(`error` 字段是判别联合),判定只写在 + catch 子句里,无字段 `case` 用本 catch 的固定文案,带载荷 `case` 先 `as` 取具名载荷类型。 +- "未识别变体上调是**故意**的"不再成立:`default` 改为 `expectNever(failure)`,漏接变体是 + **编译错误**,不再是运行时报进池。 +- 系统变体与非结构化拒绝仍由调用方原样 `throw`,经全局 `unhandledrejection` 入池; + `captureClientError` 的 `instanceof ClientAuthErrorWrapper` 解包保持不变。 +- 删除 `shouldCaptureClientError`:不再存在"叶子自己判定要不要报"的口径。 + +### 4. 报告面板与通知行为不变 + +默认选中快照中的全部事件、只由通知中的「查看并报告」打开、poisoned 快照用 fallback 等承诺保持不变; +本次只保证"不该进池的东西不再进池"。 + +## 后果与边界 + +- auth 三命令(`login_client_with_password`、`login_client_with_phone_code`、`send_client_phone_login_code`) + 及其共用链路(`request_auth` / `map_auth_failure` / `response_data` / `network_error_message`)全量改为 + `Result<_, ClientAuthError>`;`read_client_auth_state`、`refresh_client_auth_session`、 + `logout_client_session` 的失败面同步结构化(会话 401/403 仍是"未登录"路径,不是错误)。 +- 未识别变体不再靠运行时"上调"兜底:前端 switch 必须列全变体,靠 `expectNever` 在编译期挡住漏接。 +- 仍保留的显式采集点(`captureAgentRuntimeError`、`ResourceReferenceInput` 偏好写盘、`invokeDiagnostic`) + 在后续变更里按同一口径重抛/删除,本 ADR 不改它们的行为。 + +## 验收 + +```text +npm run ai-game-creator-shell:typecheck +npx vitest run apps/ai-game-creator-shell/tests/errorReporting.test.ts apps/ai-game-creator-shell/tests/ErrorReportDialog.test.tsx apps/ai-game-creator-shell/tests/ErrorReportNotice.test.tsx +cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml export_bindings +npm run ai-game-creator-shell:check:rust:shell +npm run check:generated-bindings +npm run check:doc-index +npm run check:encoding +git diff --check +``` diff --git a/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md b/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md new file mode 100644 index 000000000..7a8a7aa85 --- /dev/null +++ b/docs/adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md @@ -0,0 +1,158 @@ +# 【ADR】AGC 认证失败的 JS 侧载体与抛出时机 + +状态:已接受(2026-10-01 落地,实施顺序见同日的决策记录与 +[`【技术方案】AGC错误报告与诊断上传-2026-08-31`](../technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md)) + +前置:[`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](./【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) +已经把 Rust 侧的认证命令失败建模成 `ClientAuthError`,ts-rs 生成 +`{ type: 'x' } & X` 的判别联合。本 ADR 只解决这份结构化拒绝到了 WebView 之后 +"怎么传、谁来判、什么时候抛"。 + +## 背景 + +Rust 已经返回结构化错误,但 Tauri 的 `invoke` 拒绝值是**普通对象**,不是 `Error`: + +- 调用方 `catch (error)` 拿到的是 `{ type, reason, ... }`,没有栈。原样 `throw` 它, + 上报链路的 `error instanceof Error` 判断会把它降级成 `new Error(String(error))` + (`[object Object]`),文案与类型一起丢掉。 +- 前一版在渲染层加了 `isClientAuthError` / `getClientAuthErrorMessage` / + `presentAuthFailure` 三层:形状读取、文案回落、分类提示(均已删除)。它们既不是类型事实源,又在 + "取文案"里悄悄承担了"要不要上报"的判断,与"由调用方判定"的口径冲突。 + +## 决策 + +### 1. 不新增 JS 错误类型:直接用生成的 `ClientAuthError` + +ts-rs 已经把 `ClientAuthError` 生成成判别联合(`src/services/generated/ClientAuthError.ts`), +前端只有这一个错误事实源,**不再另建 `ClientAuthFailure` 之类的手写类**——那只会退化成 +`payload` / `cause` 的一层别名,给同一个事实源挂第二个名字。 + +原始拒绝值是普通对象,直接 `throw` 会被上报链路降级成 `String(obj)`;所以包装层把它装进 +**已有**的 `ClientAuthErrorWrapper`,载体只持有一个 `ClientAuthError` 类型的 `error` 字段,值就是原始拒绝值(也就是那个 +判别联合),并在构造时把整份载荷 `JSON.stringify` 写进 `Error.message`——上报事件因此拿到的是 +机器事实(变体名与载荷),而不是 `[object Object]`。它**不读任何变体字段、不拼用户文案**: +不塞 `context`,展示文案与上报的 `source` / `action` 都由调用 `captureClientError` 时的显式 +入参决定;catch 里 `error.error as ClientAuthError` 直接分流。 + +- 只有带载荷的变体才有具名载荷类型:无字段变体在 ts-rs 里就是 `{ type: 'x' }`,不生成文件; + 有字段的变体才生成 `X.ts`(可枚举的细分 `reason` 字段自己也是生成的枚举,如 + `ServerAddressReason.ts` / `AuthNetworkReason.ts` / `AuthResponseInvalidReason.ts`)。这些具名类型是 §3 + 每个带载荷 `case` 里 `as X` 的目标,也正是"不要假设所有变体字段相同"的落点——没有字段可读的变体 + 不需要、也不允许硬造一个空载荷类型。 + +### 2. 一个包装函数:`invokeClientAuth` + +`clientAuth.ts` 里所有认证命令都经它调用: + +```ts +async function invokeClientAuth(command, args): Promise { + const invoke = requireInvoke(); // 认证桥未装:我们自己的失败关闭错误,原样抛出 + try { + return await invoke(command, args); + } catch (error) { + // 原样把 Rust 的拒绝装成 JS Error;不读字段、不加字段。 + throw new ClientAuthErrorWrapper(error); + } +} +``` + +- **不做运行时形状嗅探**:不再检查 `type` 存不存在。Rust 与 TS 同包发布,形状由 ts-rs 保证; + 出现别的形状属于 Tauri / Rust 侧的缺陷,`switch` 的 `default` 分支仍会把它抛出去上报,不会 + 静默吞掉——只是不再在包装层替 Tauri 兜底。 +- `requireInvoke()` 放在 `try` 之外:认证桥未安装是我们自己的失败关闭错误,不是命令拒绝,保持 + 原样抛出(`需要在 Tauri App 内登录`)。 +- **不读任何变体字段、不拼用户文案**:`Error.message` 是构造时对整份载荷的序列化,不注入 + `source` / `action`;载体把原始拒绝值原样放进 `error`。展示文案与上报上下文都由 catch + 子句里拿到具名载荷的调用方决定。Tauri 缺陷抛出的真 `Error` 序列化后只有 `{}`,但上报链路 + 对真 `Error` 优先用其自身 message/stack。 +- 该包装是"Rust 结构化错误 → JS 错误对象"的唯一转换点:不做分类、不读文案判断、不兜底文案。 + +### 3. 判定只写在 catch 子句里,用具体变体 + +```ts +catch (error) { + if (!(error instanceof ClientAuthErrorWrapper)) throw error; // 超时 / 桥未装等我们自己的错误 + const failure = error.error as ClientAuthError; + switch (failure.type) { + case 'phoneNumberInvalid': { + // 无字段变体:文案由本 catch 给,不读任何字段。 + setLoginStatus('手机号无效: 需为纯数字且不超过 32 位'); + break; + } + case 'passwordLoginRejected': { + const payload = failure as PasswordLoginRejected; + // 前缀由本 catch 按当前操作提供;Rust 只给服务端原文(可能为 null)。 + setLoginStatus(`登录失败: ${payload.serverMessage ?? '服务端拒绝了本次登录'}`); + break; + } + // ... 每个业务 / 会话变体一个分支 + case 'authNetworkFailure': { + const payload = failure as AuthNetworkFailure; + // 可枚举的细分在类型化 reason 上再分流,仍然不碰文案。 + switch (payload.reason) { + case 'timeout': + case 'unreachable': + break; + default: + expectNever(payload.reason); + } + throw error; + } + // ... 其余系统变体逐个列出后原样抛出 + default: + expectNever(failure); + } +} +``` + +- **每个带载荷的业务 / 会话 `case` 用 `as` 取自己的具名载荷类型**,再读它自己的字段;字段是可枚举的 + 细分 `reason` 时,再 `switch (payload.reason)` 在类型化枚举上分流。无字段的 `case` 直接用本 catch 的 + 固定文案。不写跨变体的通用读取,也不让 Rust 预拼上下文。前缀取自当前 catch 的操作语义(登录、发码、 + 启动检查各自可以不同),等价于 Java 的 `catch (PasswordLoginRejected e)`。系统变体先按载荷里的原始 + 事实(`reason` / `status` / `serverMessage`)给一行可见反馈,再原样抛出;带 `detail` 的本机失败是例外, + 只用固定文案,`detail` 只用于分流与诊断、不贴到界面上——变体名必须逐个列出,`default` 的 + `expectNever` 才成立。 +- 业务 / 会话变体:Rust 只给可判定事实(无字段变体连字段都没有;带载荷变体给类型化 `reason` / + `serverMessage` / `status`),调用方在自己的 catch 里补上本次操作的上下文前缀(例如 + 「服务器地址非法: 远程地址必须使用 https」、「登录失败: 密码长度需要在 6 到 128 位之间」)。 +- 系统变体:调用方处理不了,先给一行可见反馈(载荷原始事实,不建兜底文案层),再**原样 `throw`**。 + `onSubmit` / `onClick` 这类 `void` 掉的 handler 抛出的拒绝最终以 `unhandledrejection` 结算,由全局 + handler 交给错误池。 +- `default: expectNever(failure)`(`expectNever(value: never)`)让"Rust 加了变体而这里 + 没接"变成**编译错误**。这是上一版"未识别变体上调是故意的"的替代方案:判据从运行时前移到 + 编译期。 +- 不把这段 switch 抽成 presenter / helper 函数:判定必须发生在 catch 里,包装函数只负责 + "把结构化拒绝转成 JS 错误"。 + +### 4. Rust 侧失败不再降级成字符串 + +结构化必须一路到底:`refresh_session_inner` 的非权威失败直接返回 `Err(ClientAuthError)`, +`ClientAuthStateView` / `ClientAuthRefreshView` 不再有 `errorMessage` 字段,续期结果不再有 +`failed` 状态(`authoritative` 只在"未登录"上为 true,`failed` 恒为 false,删除它不丢信息)。 + +- `ClientAuthState` 收敛为 `authenticated | unauthenticated`:读状态失败就是命令失败,由 + `invokeClientAuth` 装进 `ClientAuthErrorWrapper`(`error` 是判别联合),不再有第三种 + "unavailable 投影"。 +- `ClientAuthRefreshResult` 收敛为 `refreshed | unauthenticated | stale`。 + +## 后果与边界 + +- 新增认证命令或新增 `ClientAuthError` 变体,必须同时改所有 catch 的 switch,否则 `tsc` 失败。 +- `platformSession` 续期失败继续按"网络类失败不降级身份、不标权威失败"处理 + (`authoritative: false`),与旧 `failed` 分支语义一致。 +- 全局 `unhandledrejection` 是系统变体的唯一出口,调用方不再直接调 `captureClientError`; + 系统变体上报的 `source` 就是该 handler 的显式入参(`unhandledrejection`),载体不再携带 + `action`;结构化拒绝的 `Error.message` 是构造载体时生成的载荷序列化,`captureClientError` + 直接把它当作事件 `message`,事件指纹因此按变体区分,报告面板呈现的是机器事实。 + +## 验收 + +```text +npm run ai-game-creator-shell:typecheck +npx vitest run apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx apps/ai-game-creator-shell/tests/clientAuthHost.test.ts apps/ai-game-creator-shell/tests/platformSession.test.ts +cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml auth_error +npm run check:generated-bindings +npm run check:doc-index +npm run check:encoding +git diff --check +``` diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index c94736de9..1b0a1a4b6 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6,6 +6,23 @@ - 影响范围:根开发脚本、AGC 开发启动编排、本地开发运维文档;不改变 API、schema、生产部署和独立 `npm run agc` 行为。 - 验证方式:参数/状态单测、开发栈健康端点 smoke、`.app/dev-stack.json` 身份复用检查、进程树收束检查。 +## 2026-10-01 AGC 命令错误结构化与错误报告口径 + +- 决策:AGC 命令失败按**具体变体**建模(Rust `#[derive(Serialize, TS)]` 枚举 + `#[serde(tag = "type", rename_all = "camelCase")]` + ts-rs 导出,生成物不手改),`#[tauri::command]` 的 `Err` 直接携带结构化枚举;前端先按 `type` 选类别、可枚举细分再按类型化 `reason` 分流,**任何地方都不对错误文案做判断**。做法沿用 DirectProject 既有约定(`enqueue_direct_codex_turn -> Result<(), DirectTurnEnqueueFailure>`),不是新机制。 +- 决策:错误报告池只收**没有任何调用方处理**的错误。预期业务拒绝(用户输入 / 前置条件 / 预期 4xx)由调用方消化并给反馈,永不进池;真故障由调用方带上下文交给错误池(`ClientAuthErrorWrapper` 承载 `source/action/page`,`captureClientError` 用 `instanceof` 解包),`window.onerror` / `unhandledrejection` 只兜底没人接手的错误;408/5xx/网络的判定由调用方在 catch 里做(4xx 一律不报);Rust agent 终态失败仍由失败投影入池。删除 WebView 侧 `shouldCaptureClientError`。 +- 边界:变体按**可判定的事实**命名——服务端 400 只给 `status + message`(`AppError.code` 仍是通用 `BAD_REQUEST`),所以 400 变体按"哪条请求的输入被拒"命名(如 `passwordLoginRejected`),不假装能区分密码长度/手机号格式。报告面板默认全选、只由通知打开的既有承诺不变。`captureAgentRuntimeError`、`ResourceReferenceInput` 偏好写盘、`invokeDiagnostic` 三处显式采集点保持原行为,按同一口径改造或删除留在后续变更。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/{auth_error.rs,auth_session.rs}`、`apps/ai-game-creator-shell/src/services/{clientAuthErrorWrapper.ts,errorReporting.ts,clientAuth.ts}`、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`、`apps/ai-game-creator-shell/src/services/generated/`(ts-rs 生成:`ClientAuthError.ts` + 有字段变体的载荷文件)。 +- 决策(补充):TS 形状**只有带载荷的变体才有具名载荷类型**——无字段变体在 ts-rs 里就是 `{ type: 'x' }`,有字段的变体是 newtype 变体持有同名 `#[ts(export)]` 结构体,生成 `{ type: 'x' } & X` 与 `src/services/generated/X.ts`;可枚举的细分原因是类型化枚举字段(`ServerAddressReason` / `AuthNetworkReason` / `AuthResponseInvalidReason`),不是字符串、也不各拆一个顶层变体。前端 `switch (error.type)` 的无字段分支用固定文案,带载荷分支先 `as X` 再读它自己的字段,`reason` 是枚举时再 `switch (payload.reason)`(`default` 同样用 `expectNever`)。不允许在前端手写这层类型,也不再包派生分类 / 提示文案函数(`clientAuthErrorKind`、`clientAuthErrorNotice`、`resolveClientAuthFailure` 已删除)。 +- 验证:见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 的验收清单;关键判据是"登录 400/401 业务变体不产生 `report_client_error`、不弹「发现问题」"。 +- 决策(2026-10-01,JS 侧载体与抛出时机):认证命令统一经 `invokeClientAuth(command, args)` 调用;拒绝值原样装进已有的 `ClientAuthErrorWrapper`(载体只有一个 `ClientAuthError` 类型的 `error` 字段,值就是 ts-rs 生成的判别联合;不读变体字段、不塞 `context`,构造时把整份载荷 `JSON.stringify` 进 `Error.message`,上报事件因此拿到机器事实),**不新增手写错误类**(`ClientAuthFailure` 已删除),形状完全信任 tauri + ts-rs 映射、不做运行时嗅探。形状读取 / 文案回落 / 提示分类三层(`isClientAuthError`、`getClientAuthErrorMessage`、`presentAuthFailure`)全部删除。 +- 决策(2026-10-01,判定位置与出口):要不要上报只由 catch 子句里的 `switch (failure.type)` 判,`failure = error.error`;无字段业务 / 会话变体用本 catch 的固定文案,带载荷变体先 `as` 取自己的具名类型、再用它自己的 `reason` / `serverMessage` / `status` 拼上本次操作的上下文前缀(`reason` 是枚举时再 `switch (payload.reason)`);Rust 不预拼用户可见文案、服务端原文缺失就是 `null`(无兜底文案)。系统变体原样 `throw` 经全局 `unhandledrejection` 入池(`captureClientError` 用 `instanceof` 解包 `error` 字段取原始错误),`default: expectNever(failure)` 让漏接变体编译失败。取代"未识别变体上调是故意的"。 +- 决策(2026-10-01,Rust 侧不再降级):`refresh_session_inner` 的非权威失败直接 `Err(ClientAuthError)`,`ClientAuthStateView` / `ClientAuthRefreshView` 删除 `errorMessage`,续期结果删除 `failed`;`ClientAuthState` 收敛为 `authenticated | unauthenticated`,`ClientAuthRefreshResult` 收敛为 `refreshed | unauthenticated | stale`。 +- 追加(2026-10-02,429 按路由判定):`/api/auth/phone/login` 验证码错误次数耗尽返回的 429 是用户可修正的输入问题,映射为 `phoneCodeLoginRejected`(复用现有业务变体、不进错误池);发码路由仍是 `smsCodeThrottled`,其余路由的 429 仍是 `unexpectedRejection`。 +- 追加(2026-10-02,401 归 phoneCodeLoginRejected):`/api/auth/phone/login` 的 401 只来自「用户不存在」(验证码错误/失效/过期在服务端都是 400,已由 `phoneCodeLoginRejected { serverMessage }` 带原文);顶层变体 `smsCodeRejected` 退役删除,前端三个 catch 去掉了它那个「验证码错误或已过期」的固定分支,`phoneCodeLoginRejected` 的文案统一为「验证码登录失败:<服务端原文>」。 +- 追加(2026-10-02,读 body 失败按已确认状态码归类):AGC 认证请求拿到 `status` 后 `response.text()` 失败,不再一律压成 `authNetworkFailure { unreachable }`;非 2xx 走既有分类(`serverMessage` 为 `None`,如 503 → `authServiceUnavailable { 503 }`),只有 2xx 响应没收完才算传输层故障。分类收敛在 `classify_unreadable_body`。 +- 追加(2026-10-02,系统类失败保留原始错误载荷):`clientSessionPersistFailed` / `runtimeSessionInstallFailed` / `authClientInitFailed` 都带 `detail: string`(原始 error),既让调用方有机会分流处理,也让报告包带够诊断信息;原始 error 同时经 `app_log!`(落盘前过 `sanitize_diagnostic_message`)记一行本地日志。`detail` 不贴到界面上:三个 catch 用本操作的固定文案(登录检查 / 发码 / 登录各自不同)。取代上一版"本机 IO 失败不进载荷、原始 error 只进日志"。 +- 影响范围(2026-10-01 第二轮):`apps/ai-game-creator-shell/src/services/{clientAuth.ts,platformSession.ts}`(`clientAuthError.ts` 删除)、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`、`apps/ai-game-creator-shell/src-tauri/src/auth_session.rs`、对应 vitest 用例。 + ## 2026-10-02 launcher 页面高度契约:外壳分高度,页面不再自己算窗口高度 - 背景:PR #228(`6d2c275d3`)只给项目页补了「外壳纵向 flex + 页面 `flex: 1 1 auto`」的高度修复;其余页面仍各自算高度——帮助页没写高度也没有内层滚动容器,内容一长就被外壳 `overflow: hidden` 裁掉且无法滚动;首页用 `h-screen` / `h-[calc(100vh-32px)]`,模板库用 JS 量父级高度写内联 `height`。 @@ -8152,7 +8169,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 ## 2026-08-22 AGC Tauri 命令调用可达性失败关闭 -- 决策:`check-config.mjs` 的 App 调用扫描必须识别现役精确形态:裸 `invoke`、`directInvoke`、素材画布的 `invokeInput` / `invokeAuthenticatedInput` wrapper,以及对象字段 `.invoke`;不以包含 `invoke` 的任意名称、动态命令变量、注释、字符串、模板或正则文本作为可达证据。 +- 决策:`check-config.mjs` 的 App 调用扫描必须识别现役精确形态:裸 `invoke`、`directInvoke`、素材画布的 `invokeInput` / `invokeAuthenticatedInput`、认证命令的 `invokeClientAuth` wrapper,以及对象字段 `.invoke`;不以包含 `invoke` 的任意名称、动态命令变量、注释、字符串、模板或正则文本作为可达证据。 - allowlist 边界:前端源码已调用的命令不得继续保留在 explicit native-only allowlist。allowlist 只承载确实由原生窗口或原生侧流程触发、App 源码不直接调用的 handler;源码调用与 allowlist 必须互斥。 - 门禁:逐文件使用仓库锁定的 TypeScript AST 解析,设置文件数量、单文件 / 总源码长度、命令长度和调用数量上限。回归测试同时锁定直接、wrapper、对象字段的正例与诱饵 / 动态 / 畸形输入的反例,并证明删除真实 wrapper 调用后 handler 可达性检查失败,不能由错误 allowlist 继续误绿。 @@ -9191,6 +9208,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 影响面:`apps/ai-game-creator-shell/src-tauri/build_support/{package-layout.json,package-layout.generated.rs,package_layout.rs,godot_bundle.rs}`、`src-tauri/build.rs`、`scripts/{prepare-bundled-resources.mjs,prepare-bundled-resources.test.mjs,check-package-layout.mjs,build-release.mjs}`、两份 `.taurignore`、技术方案 §4.9/§8、M3 里程碑、运维文档、决策日志与排障经验。 - 验证:准备步骤 13 条用例通过(含三类准备步骤调度、指纹跳过、缺产物失败关闭、幂等与失败关闭);`npm run agc:bundled-resources:check` 通过;`cargo check --no-default-features` 通过(构建脚本仅剩只读校验,且不再出现在随包资源的写入路径上)。 - 边界(未验证):Windows 真机未验证——powershell/cargo 两条命令路径、Unity/Godot/Cocos 产物归位、包内容一致性与客户端加载,需按 M3 里程碑的验收清单在 Windows 上确认。 + ## 2026-09-24 命令入队化与待发消息队列归宿主:放行归 Thread Manager,CLI 直连入口退役 - 决策(词表):「接单 / 拒单」退役,命令边界的成功与失败改叫「入队 / 入队失败」;旧「接单」的语义角色 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 9e25578ef..6bd76c225 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,16 @@ 这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。 +## 2026-10-01 用户输错一次密码被当成"客户端出问题了"引导上报 + +- **现象**:登录页密码输错(或密码长度不合规)后弹出「发现问题」,报告面板「错误事件(2)」列出 `密码长度需要在 6 到 128 位之间 — auth · 1 次` 与 `手机号或密码错误 — auth · 1 次`,默认全选,与 react-render / 5xx / agent-runtime 终态失败视觉等价。 +- **原因**:① 登录已下沉 Rust,`login_client_with_password` 等命令失败返回 `Err(String)`,Tauri 以**裸字符串**拒绝 `invoke`,前端拿不到任何类型信息;② `shouldCaptureClientError` 对非 object 值走默认 `return true`,`handleLoginSubmit` 的 catch 把预期业务拒绝报进了错误池。技术方案里"预期 4xx 登录/鉴权失败不进池"的口径早就成立,是错误通道的实现方式违背了它。 +- **处理(现行口径)**:命令错误一律按具体变体结构化(`Result<_, ClientAuthError>` + ts-rs 导出),认证命令统一经 `invokeClientAuth` 调用:结构化拒绝原样装进已有的 `ClientAuthErrorWrapper`(只有一个 `error` 字段,值是判别联合),UI 在 catch 里按具体变体分流——认得的业务 / 会话变体只给用户反馈,系统变体原样 `throw` 经 `unhandledrejection` 入池,非结构化拒绝原样抛出;删除 `shouldCaptureClientError`。详见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 与 [`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`](../../adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md)。 +- **判据/取证**:`npx vitest run apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx`——登录返回结构化业务变体时 `report_client_error` 不被调用;系统变体只上报一次(`source` 取全局 `unhandledrejection` handler 的显式入参;载体不再携带 `action`)。Rust 侧 `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml auth_error` 钉住变体 `type` 与 400/401/429/5xx/网络映射。 +- **形状约定**:`ClientAuthError` 顶层只放调用方要分流的类别,可枚举细分收进类型化枚举 `reason` 字段——无字段变体在 TS 里就是 `{ type: 'x' }`;带载荷变体是 `{ type: 'x' } & X`,`X` 由 ts-rs 导出到 `src/services/generated/X.ts`(Rust 侧是 newtype 变体持有同名结构体),细分原因枚举(`ServerAddressReason` / `AuthNetworkReason` / `AuthResponseInvalidReason`)同样由 ts-rs 生成。**不要手写这些类型**,也不要在前端再加一层分类 / 提示文案派生函数——判别一律写在 catch 子句里:`const failure = error.error; switch (failure.type)`,无字段 `case` 用本 catch 的固定文案,带载荷 `case` 先 `as X` 再读自己的字段,`reason` 是枚举时再 `switch (payload.reason)`,两处 `default` 都用 `expectNever` 保证漏接编译失败。改形状只能改 Rust 再跑 `cargo test` 重新导出,生成物随后交给 prettier;ts-rs 只写文件、不删文件,变体从有载荷改成无字段时要手动清掉孤立的 `X.ts`(本次 `AuthResponseServerRejected.ts` 就是这样删的)。 +- **Rust 侧不得把结构化错误降级成字符串**:`refresh_session_inner` 的非权威失败直接返回 `Err(ClientAuthError)`,视图不带 `errorMessage`;一旦折成 `String`,前端就只能拿文案判断,变体信息永久丢失。 +- **关联**:`apps/ai-game-creator-shell/src-tauri/src/auth_session.rs`、`apps/ai-game-creator-shell/src/services/{clientAuth.ts,errorReporting.ts,platformSession.ts}`、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`。 + ## 2026-10-01 Windows `dev:all` 启动 npm 子进程报 `spawn EINVAL` - **现象**:`npm run dev:all` 能完成 AGC Vite 端口预留,但在启动根开发栈前输出 `spawn EINVAL`。 @@ -180,6 +190,7 @@ - **现象**:直接编辑 `apps/ai-game-creator-shell/src-tauri/build_support/package-layout.generated.rs`,或另写一份组件白名单,`npm run agc:typecheck`(链内含 `npm run agc:bundled-resources:check`)会立刻失败并报「随包资源声明与 Rust 常量不一致」。 - **正确做法**:改 `build_support/package-layout.json`,运行 `npm run agc:bundled-resources:sync` 重新生成;改布局同时递增 `layoutVersion`(参与准备步骤的缓存 key)。声明里的 `codex.version` 必须与应用锁定的 `@openai/codex` 一致,门禁会对照 `apps/ai-game-creator-shell/package.json` 校验。 - **边界(M1 完成时)**:准备步骤 `scripts/prepare-bundled-resources.mjs` 尚未接入 dev / 发布入口,`npm run agc` 仍由构建脚本 staging;构建脚本当前既写资源又做只读校验,`AGC_SKIP_RESOURCE_STAGING=1` 可只跑校验。构建脚本重建 `resources/plugins` 时会整体删除该目录,所以插件侧的准备步骤清单要等 M2 接管写入后才成立,插件目录现在只校验必需组件与符号链接。 + ## 2026-09-24 模型输出的围栏会粘在正文行里:聊天 Markdown 必须先归一化再解析 - **现象**:AGC 对话里代码块解析错位——引言行被当成代码渲染(`…实现细节(game.js):```js`),或者代码块收不住、把后面的正文一起吞进去(`… return centerOn(projection); }````)。文本本身「看起来没问题」,容易被当成渲染器坏了。 diff --git a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md index 74f326266..3e6a24c08 100644 --- a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md +++ b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md @@ -8,10 +8,11 @@ AI Game Creator Shell 采用 IDEA 风格的当前进程错误报告:错误事 - 诊断 URL 保留可定位的 API 路由路径,隐藏 origin、URL 账号密码、查询参数、fragment 和路径中的敏感标识;普通资源 URL 与本地文件路径继续隐藏。网络错误、HTTP 错误与响应体超时均应带安全路由,不能只剩 ``。历史已经脱敏的归档不推测或补造原路由。 -- 捕获 React render error、`window.onerror`、`unhandledrejection` 以及显式标记的 Tauri/API/Agent 错误。Agent Runtime 的终态失败、预算耗尽和启动确认失败由 Rust 失败投影统一入池;Direct Codex 与专业 Agent 的前台裸 Tauri invoke catch 作为补充入口,重复事件由同一 fingerprint 合并,主动取消和“同一 turn 已在运行”不作为错误采集。 +- 报告池只收**没有任何调用方处理**的错误:React render error、`window.onerror`、`unhandledrejection`,以及调用方判定为真故障后带上下文交给错误池(`ClientAuthErrorWrapper`)的错误。Agent Runtime 的终态失败、预算耗尽和启动确认失败由 Rust 失败投影统一入池;Direct Codex 与专业 Agent 的前台裸 Tauri invoke catch 作为补充入口,重复事件由同一 fingerprint 合并,主动取消和“同一 turn 已在运行”不作为错误采集。分层口径见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 与 [`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`](../adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md)。 - 事件字段包括 eventId、fingerprint、source、message、stack、时间和次数;重复事件合并。不再携带 severity、errorCode、page、action、requestId 等无法稳定关联的字段。 - 指纹计算可使用调用方的 page/action 及脱敏后的首个调用点作为进程内区分输入,但这些上下文不会作为事件字段上传;消息与 stack 在入池前统一脱敏,WebCrypto 失败时降级为稳定可读指纹,采集本身不得产生新的未处理拒绝。 -- 客户端 API 自动采集只覆盖网络错误、408 和 5xx;预期的 4xx 登录/鉴权失败不进入错误报告池。 +- 命令失败按具体变体建模(Rust 枚举 + ts-rs 导出的 `type` 判别联合;顶层只放调用方要分流的类别,可枚举细分收进类型化枚举 `reason` 字段,无字段变体生成 `{ type }`,带载荷变体生成 `{ type } & 载荷类型`),前端按变体分流,**任何地方都不对错误文案做判断**:认得的业务变体(用户输入 / 前置条件 / 预期 4xx)由调用方消化并给用户反馈,永不进池;系统变体、未识别变体和结构化之外的拒绝原样抛出,走上面的兜底入口。认证命令的封装形态:`invokeClientAuth` 只是薄包装,把结构化拒绝原样装进**已有**的 `ClientAuthErrorWrapper`(载体只有一个 `ClientAuthError` 类型的 `error` 字段,值就是 ts-rs 生成的判别联合;不读变体字段、不塞 `context`,构造时把整份载荷 `JSON.stringify` 进 `Error.message`,上报事件因此拿到机器事实),不新增手写错误类;判定只写在 catch 子句里,无字段变体用本 catch 的固定文案,带载荷变体先 `as` 取自己的具名载荷类型(`reason` 是枚举时再 `switch (payload.reason)`)、再用它自己的字段拼上本次操作的上下文前缀,`default` 用 `expectNever` 在编译期挡住漏接变体。 +- 客户端 API 自动采集只覆盖网络错误、408 和 5xx(`clientApi` 作为 `fetch` 的调用方在抛出前判定);预期的 4xx 登录/鉴权失败不进入错误报告池。 - Rust 侧通过 `app_log!` 将普通文本日志同时输出到 stderr 和 AppData `diagnostics/application.log`,超出 256 KiB 滚动到 `application.previous.log`;WebView 的 console 输出通过 `append_application_log` 镜像到同一 raw log,并在客户端桥接处再次脱敏;`read_diagnostic_logs` 只读取应用级日志。 - 这里有两套互不相干的东西,不要互相代入:**错误报告事件池**是进程内 `error_report` 的结构化事件(本次变更不动它,仍然只在内存里、提交时才生成 `events.jsonl`);**统一 Agent Runtime 错误事件**是项目内 sidecar `.agent/runtime/errors/.json`,既不进事件池也不进报告包。因为报告包里的日志附件只有 AppData 应用日志,所以 sidecar 的同一份已脱敏诊断再作为**日志行**(不是报告事件)投影成两行:`agent.runtime.error`(身份行:eventId / source / stage / code / retryable / clientTurnId / elapsedMs / detailRef,全部是程序生成或调用方常量)与 `agent.runtime.error.detail`(详情行:hint / summary / detail / metadata,自由文本只出现在这里)。两行都由 `agent/runtime_error.rs` 从同一份 diagnosis 生成,不新增字段来源;落到日志前 summary 按 320 字符、detail / metadata 按(1200 / 200 字符)预算脱敏截断(summary 由调用方给,`direct_tool_bridge` 会传工具错误原文,而 `app_log!` 同时把整行写 stderr,那里没有 `sanitize_diagnostic_message` 兜底)。`sanitize_diagnostic_message` 命中凭据标记时替换的是**整行**,自由文本因此只放详情行:详情行被吃掉也不影响身份行定位事件。 - 报告面板只由自动诊断通知中的“查看并报告”打开,不提供聊天命令、崩溃页按钮或其他手动入口;默认选中当前快照中的全部事件,用户可取消不想提交的事件。允许填写最多 2,000 字中文描述并取消日志附件;本版本不支持截图或任意文件附件。