From 254f2767ccdb8b4e0eb2d08873bcd44bcc375086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 02:42:17 +0800 Subject: [PATCH] =?UTF-8?q?AGC=E8=AE=A4=E8=AF=81=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E9=A1=B6=E5=B1=82=E7=B1=BB=E5=88=AB=E5=8A=A0?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E5=8C=96reason?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth_error.rs:顶层从拍平变体收敛为类别变体,服务地址/网络失败/响应契约破损各占一个变体,不再拆成 7+2+5 个 - auth_error.rs:新增 ServerAddressReason、AuthNetworkReason、AuthResponseInvalidReason 三个 ts-rs 枚举,payload 改持类型化 reason - auth_session.rs:校验与映射改用 server_address_rejected / response_invalid 收口,按类型化 reason 构造 --- .../src-tauri/src/auth_error.rs | 217 +++++++++++------- .../src-tauri/src/auth_session.rs | 130 +++++++---- 2 files changed, 210 insertions(+), 137 deletions(-) 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 index bc602dfa8..8d3a9f55d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs @@ -2,19 +2,13 @@ //! //! 变体名就是线上的分流键(`type`):前端只按它选通道,**不解析任何文案**,也不对错误文本做匹配。 //! -//! 变体一律拍平在同一层,不嵌套子枚举:可枚举的事实各自成变体(例如服务地址校验的 7 种失败、 -//! 网络超时 / 不可达、响应契约的 5 种破损),类型本身说不出来的事实才进载荷(服务端原文、HTTP -//! 状态码、本机 IO 明细)。internally tagged 下无字段变体序列化成 `{ "type": "x" }`,带载荷的 -//! 是 `{ "type": "x", ... }`;ts-rs 生成 `{ type: 'x' }` 或 `{ type: 'x' } & X`,前端每个分支 -//! 拿到的形状与 Rust 一致。 +//! 顶层只放**调用方要分流的类别**;同一类里可枚举的细分原因收进**类型化 `reason` 字段**, +//! 而不是各拆一个变体,也不是字符串。例如服务地址校验的 7 种失败、网络失败的超时/不可达、 +//! 响应契约的 5 种破损各自只占一个变体:前端 `switch (failure.type)` 选到类别后,再用 +//! `switch (payload.reason)` 在**类型化**的细分上分流,仍然不碰文案。 //! -//! Rust **不预拼用户可见文案**:载荷只存原始事实(服务端 400 的原文、本机 IO 的 `detail`), -//! 前缀与句式由前端调用方在自己的 catch 分支按当前操作拼接。 -//! -//! 变体按**可判定的事实**命名。服务端 400 只提供 `status + message`(平台 `AppError.code` 仍是 -//! 通用 `BAD_REQUEST`),所以 400 变体按"哪条请求的输入被拒"命名(例如 -//! [`PasswordLoginRejected`]),不假装能区分密码长度 / 手机号格式;会话路由的 `401/403` 是 -//! "登录态失效",登录路由的 `401` 是用户可修正的输入问题,这个区分由变体承担。 +//! Rust **不预拼用户可见文案**:载荷只装原始事实(服务端 400 的原文、HTTP 状态码、本机 IO 的 +//! `detail`),服务端没给原文就是 `None`;前缀与句式由前端调用方在自己的 catch 分支按当前操作拼接。 use serde::Serialize; use ts_rs::TS; @@ -25,21 +19,9 @@ use ts_rs::TS; #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] pub(crate) enum ClientAuthError { // ---- 业务:用户自己能改,调用方给提示,不上报 ---- - /// 服务地址为空或超长。 - ServerAddressEmptyOrTooLong, - /// 服务地址不是合法 URL。 - ServerAddressNotAUrl, - /// 服务地址带用户名 / 密码。 - ServerAddressHasCredentials, - /// 服务地址带路径、查询或 fragment。 - ServerAddressHasPathOrQueryOrFragment, - /// 非本机服务地址不是 HTTPS。 - ServerAddressNotHttps, - /// 服务地址 scheme 不是 http(s)。 - ServerAddressUnsupportedScheme, - /// 发布构建里服务地址不在当前构建渠道范围内。 - ServerAddressOutsideChannel, - /// 本地前置校验:手机号为空、超长或不是纯数字。 + /// 服务地址校验失败(含渠道范围门禁),细分原因见 [`ServerAddressReason`]。 + ServerAddressRejected(ServerAddressRejected), + /// 本地前置校验:手机号为空、超长或不是纯数字(校验器只回 bool,没有更细的事实)。 PhoneNumberInvalid, /// 本地前置校验:密码为空。 PasswordMissing, @@ -65,24 +47,14 @@ pub(crate) enum ClientAuthError { PermissionDenied, // ---- 系统:调用方处理不了,带上下文重抛 ---- - /// 连接登录服务超时。 - AuthNetworkTimeout, - /// 连接登录服务失败(DNS / 连接被拒等)。 - AuthNetworkUnreachable, + /// 连接登录服务的传输层失败,细分原因见 [`AuthNetworkReason`]。 + AuthNetworkFailure(AuthNetworkFailure), /// 登录服务 5xx。 AuthServiceUnavailable(AuthServiceUnavailable), /// 其它未识别的拒绝(未列举的 4xx、登录路由 403 等)。 UnexpectedRejection(UnexpectedRejection), - /// 登录服务响应不是合法 JSON。 - AuthResponseNotJson, - /// 登录服务响应不是预期结构(缺字段 / 类型不符)。 - AuthResponseInvalidBody, - /// 登录 / 续期响应没有下发新的续期凭据。 - AuthResponseMissingRefreshCookie, - /// 登录响应没有带上会话主体(用户身份)。 - AuthResponseMissingUserIdentity, - /// 登录服务在响应体里显式拒绝(`ok: false`)。 - AuthResponseServerRejected(AuthResponseServerRejected), + /// 登录服务响应的契约破损,细分原因见 [`AuthResponseInvalidReason`]。 + AuthResponseInvalid(AuthResponseInvalid), /// 本机登录凭据文件读写失败。 ClientSessionPersistFailed(ClientSessionPersistFailed), /// 本机运行时会话安装 / 清理失败。 @@ -91,8 +63,67 @@ pub(crate) enum ClientAuthError { 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")] @@ -120,8 +151,16 @@ pub(crate) struct PhoneCodeLoginRejected { 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, Debug, PartialEq, Eq, Serialize, TS)] +#[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 { @@ -137,11 +176,13 @@ pub(crate) struct UnexpectedRejection { 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 AuthResponseServerRejected { +pub(crate) struct AuthResponseInvalid { + pub(crate) reason: AuthResponseInvalidReason, + /// 只有 [`AuthResponseInvalidReason::ServerRejected`] 可能带服务端原文;其余是 `None`。 pub(crate) server_message: Option, } @@ -177,32 +218,10 @@ mod tests { fn wire_variant_names_are_the_frontend_dispatch_keys() { let cases = [ ( - ClientAuthError::ServerAddressEmptyOrTooLong, - "serverAddressEmptyOrTooLong", - ), - ( - ClientAuthError::ServerAddressNotAUrl, - "serverAddressNotAUrl", - ), - ( - ClientAuthError::ServerAddressHasCredentials, - "serverAddressHasCredentials", - ), - ( - ClientAuthError::ServerAddressHasPathOrQueryOrFragment, - "serverAddressHasPathOrQueryOrFragment", - ), - ( - ClientAuthError::ServerAddressNotHttps, - "serverAddressNotHttps", - ), - ( - ClientAuthError::ServerAddressUnsupportedScheme, - "serverAddressUnsupportedScheme", - ), - ( - ClientAuthError::ServerAddressOutsideChannel, - "serverAddressOutsideChannel", + ClientAuthError::ServerAddressRejected(ServerAddressRejected { + reason: ServerAddressReason::NotHttps, + }), + "serverAddressRejected", ), (ClientAuthError::PhoneNumberInvalid, "phoneNumberInvalid"), (ClientAuthError::PasswordMissing, "passwordMissing"), @@ -233,10 +252,11 @@ mod tests { (ClientAuthError::SmsCodeRejected, "smsCodeRejected"), (ClientAuthError::SessionInvalidated, "sessionInvalidated"), (ClientAuthError::PermissionDenied, "permissionDenied"), - (ClientAuthError::AuthNetworkTimeout, "authNetworkTimeout"), ( - ClientAuthError::AuthNetworkUnreachable, - "authNetworkUnreachable", + ClientAuthError::AuthNetworkFailure(AuthNetworkFailure { + reason: AuthNetworkReason::Timeout, + }), + "authNetworkFailure", ), ( ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: 503 }), @@ -249,24 +269,12 @@ mod tests { }), "unexpectedRejection", ), - (ClientAuthError::AuthResponseNotJson, "authResponseNotJson"), ( - ClientAuthError::AuthResponseInvalidBody, - "authResponseInvalidBody", - ), - ( - ClientAuthError::AuthResponseMissingRefreshCookie, - "authResponseMissingRefreshCookie", - ), - ( - ClientAuthError::AuthResponseMissingUserIdentity, - "authResponseMissingUserIdentity", - ), - ( - ClientAuthError::AuthResponseServerRejected(AuthResponseServerRejected { + ClientAuthError::AuthResponseInvalid(AuthResponseInvalid { + reason: AuthResponseInvalidReason::ServerRejected, server_message: None, }), - "authResponseServerRejected", + "authResponseInvalid", ), ( ClientAuthError::ClientSessionPersistFailed(ClientSessionPersistFailed { @@ -301,6 +309,25 @@ mod tests { 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( @@ -319,6 +346,15 @@ mod tests { 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(), @@ -333,6 +369,9 @@ mod tests { assert!(ClientAuthError::SessionInvalidated.is_authority_failure()); assert!(ClientAuthError::PermissionDenied.is_authority_failure()); assert!(!ClientAuthError::PhoneOrPasswordMismatch.is_authority_failure()); - assert!(!ClientAuthError::AuthNetworkTimeout.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 5625e958d..416566950 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 @@ -5,9 +5,10 @@ //! origin 变化都会让旧身份的在途请求失败关闭;同一身份的凭据轮换不改变身份代次。 use crate::auth_error::{ - AuthResponseServerRejected, AuthServiceUnavailable, ClientAuthError, - ClientSessionPersistFailed, PasswordLoginRejected, PhoneCodeLoginRejected, - RuntimeSessionInstallFailed, SendCodeRejected, UnexpectedRejection, + 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}; @@ -241,6 +242,19 @@ fn runtime_session_error(message: impl Into) -> ClientAuthError { }) } +/// 服务地址校验失败统一收口成带类型化原因的变体。 +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。发布构建额外 @@ -250,28 +264,35 @@ fn runtime_session_error(message: impl Into) -> ClientAuthError { 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(ClientAuthError::ServerAddressEmptyOrTooLong); + return Err(server_address_rejected(ServerAddressReason::EmptyOrTooLong)); } - let parsed = Url::parse(trimmed).map_err(|_| ClientAuthError::ServerAddressNotAUrl)?; + let parsed = + Url::parse(trimmed).map_err(|_| server_address_rejected(ServerAddressReason::NotAUrl))?; if !parsed.username().is_empty() || parsed.password().is_some() { - return Err(ClientAuthError::ServerAddressHasCredentials); + return Err(server_address_rejected(ServerAddressReason::HasCredentials)); } if !matches!(parsed.path(), "" | "/") || parsed.query().is_some() || parsed.fragment().is_some() { - return Err(ClientAuthError::ServerAddressHasPathOrQueryOrFragment); + 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(ClientAuthError::ServerAddressNotHttps), - _ => return Err(ClientAuthError::ServerAddressUnsupportedScheme), + "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(ClientAuthError::ServerAddressOutsideChannel); + return Err(server_address_rejected(ServerAddressReason::OutsideChannel)); } Ok(trimmed.to_string()) } @@ -402,11 +423,11 @@ fn current_session_origin() -> Option { fn endpoint(origin: &str, route: &str) -> Result { let mut url = Url::parse(&format!("{}/", origin.trim_end_matches('/'))) - .map_err(|_| ClientAuthError::ServerAddressNotAUrl)?; + .map_err(|_| server_address_rejected(ServerAddressReason::NotAUrl))?; { let mut segments = url .path_segments_mut() - .map_err(|_| ClientAuthError::ServerAddressNotAUrl)?; + .map_err(|_| server_address_rejected(ServerAddressReason::NotAUrl))?; for segment in route.trim_start_matches('/').split('/') { if segment.is_empty() { continue; @@ -426,11 +447,12 @@ fn build_client() -> Result { } fn network_error(error: &reqwest::Error) -> ClientAuthError { - if error.is_timeout() { - ClientAuthError::AuthNetworkTimeout + let reason = if error.is_timeout() { + AuthNetworkReason::Timeout } else { - ClientAuthError::AuthNetworkUnreachable - } + AuthNetworkReason::Unreachable + }; + ClientAuthError::AuthNetworkFailure(AuthNetworkFailure { reason }) } fn error_message(body: &str) -> Option { @@ -536,14 +558,13 @@ fn map_auth_failure(status: StatusCode, body: &str, route: AuthRoute) -> ClientA } fn response_data(body: &str) -> Result { - let value: Value = - serde_json::from_str(body).map_err(|_| ClientAuthError::AuthResponseNotJson)?; + 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(ClientAuthError::AuthResponseServerRejected( - AuthResponseServerRejected { - server_message: error_message(body), - }, - )); + return Err(ClientAuthError::AuthResponseInvalid(AuthResponseInvalid { + reason: AuthResponseInvalidReason::ServerRejected, + server_message: error_message(body), + })); } Ok(value.get("data").cloned().unwrap_or(value)) } @@ -620,15 +641,18 @@ async fn request_auth( CookiePolicy::Ignore => None, }; // 拿到状态码后读 body 失败属于传输层故障:按网络不可达上报,不猜契约。 - let text = response - .text() - .await - .map_err(|_| ClientAuthError::AuthNetworkUnreachable)?; + let text = response.text().await.map_err(|_| { + ClientAuthError::AuthNetworkFailure(AuthNetworkFailure { + reason: AuthNetworkReason::Unreachable, + }) + })?; if !status.is_success() { return Err(map_auth_failure(status, &text, auth_route(route))); } if matches!(policy, CookiePolicy::Require) && captured.is_none() { - return Err(ClientAuthError::AuthResponseMissingRefreshCookie); + return Err(response_invalid( + AuthResponseInvalidReason::MissingRefreshCookie, + )); } Ok(AuthResponse { data: response_data(&text)?, @@ -667,7 +691,7 @@ async fn commit_authenticated_session( identity_change: bool, ) -> Result { if token.chars().count() > MAX_SECRET_CHARS { - return Err(ClientAuthError::AuthResponseInvalidBody); + return Err(response_invalid(AuthResponseInvalidReason::InvalidBody)); } // 主体先于凭据落盘解析:没有主体就不写 client-session.json,避免留下半截会话文件。 let (user_id, known_user) = identity.resolve()?; @@ -758,7 +782,9 @@ fn phone_is_valid(phone: &str) -> bool { fn validated_session_user_id(user_id: &str) -> Result { let user_id = user_id.trim(); if user_id.is_empty() { - return Err(ClientAuthError::AuthResponseMissingUserIdentity); + return Err(response_invalid( + AuthResponseInvalidReason::MissingUserIdentity, + )); } Ok(user_id.to_string()) } @@ -790,7 +816,7 @@ async fn fetch_current_user( ) .await?; let me: MeResponse = serde_json::from_value(response.data) - .map_err(|_| ClientAuthError::AuthResponseInvalidBody)?; + .map_err(|_| response_invalid(AuthResponseInvalidReason::InvalidBody))?; Ok(me.user) } @@ -860,11 +886,11 @@ async fn refresh_session_inner( } }; let token: TokenResponse = serde_json::from_value(refreshed.data) - .map_err(|_| ClientAuthError::AuthResponseInvalidBody)?; + .map_err(|_| response_invalid(AuthResponseInvalidReason::InvalidBody))?; let install_token = token.token.clone(); let new_cookie = refreshed .refresh_cookie - .ok_or(ClientAuthError::AuthResponseMissingRefreshCookie)?; + .ok_or_else(|| response_invalid(AuthResponseInvalidReason::MissingRefreshCookie))?; commit_authenticated_session( app, &session.api_base_url, @@ -1009,7 +1035,7 @@ pub(crate) async fn send_client_phone_login_code( ) .await?; let payload: SendCodeResponse = serde_json::from_value(response.data) - .map_err(|_| ClientAuthError::AuthResponseInvalidBody)?; + .map_err(|_| response_invalid(AuthResponseInvalidReason::InvalidBody))?; Ok(ClientLoginCodeView { cooldown_seconds: payload.cooldown_seconds, expires_in_seconds: payload.expires_in_seconds, @@ -1022,10 +1048,10 @@ async fn complete_login( response: AuthResponse, ) -> Result { let payload: TokenUserResponse = serde_json::from_value(response.data) - .map_err(|_| ClientAuthError::AuthResponseInvalidBody)?; + .map_err(|_| response_invalid(AuthResponseInvalidReason::InvalidBody))?; let cookie = response .refresh_cookie - .ok_or(ClientAuthError::AuthResponseMissingRefreshCookie)?; + .ok_or_else(|| response_invalid(AuthResponseInvalidReason::MissingRefreshCookie))?; let user = commit_authenticated_session( app, origin, @@ -1161,45 +1187,53 @@ mod tests { #[test] fn origins_reject_credentials_paths_and_plain_remote_http() { - // 每种拒绝各成一个变体:前端只按变体选提示,不读任何文案。 + // 顶层只占一个变体,细分走类型化 reason:前端 switch(reason) 仍然按类型分流,不读文案。 assert_eq!( validate_client_api_base_url("https://user:pass@example.com"), - Err(ClientAuthError::ServerAddressHasCredentials) + Err(server_address_rejected(ServerAddressReason::HasCredentials)) ); assert_eq!( validate_client_api_base_url("https://example.com/api"), - Err(ClientAuthError::ServerAddressHasPathOrQueryOrFragment) + Err(server_address_rejected( + ServerAddressReason::HasPathOrQueryOrFragment + )) ); assert_eq!( validate_client_api_base_url("https://example.com?a=1"), - Err(ClientAuthError::ServerAddressHasPathOrQueryOrFragment) + Err(server_address_rejected( + ServerAddressReason::HasPathOrQueryOrFragment + )) ); assert_eq!( validate_client_api_base_url("https://example.com#frag"), - Err(ClientAuthError::ServerAddressHasPathOrQueryOrFragment) + Err(server_address_rejected( + ServerAddressReason::HasPathOrQueryOrFragment + )) ); assert_eq!( validate_client_api_base_url("http://example.com"), - Err(ClientAuthError::ServerAddressNotHttps) + Err(server_address_rejected(ServerAddressReason::NotHttps)) ); assert_eq!( validate_client_api_base_url("ftp://example.com"), - Err(ClientAuthError::ServerAddressUnsupportedScheme) + Err(server_address_rejected( + ServerAddressReason::UnsupportedScheme + )) ); assert_eq!( validate_client_api_base_url(" "), - Err(ClientAuthError::ServerAddressEmptyOrTooLong) + Err(server_address_rejected(ServerAddressReason::EmptyOrTooLong)) ); assert_eq!( validate_client_api_base_url("不是地址"), - Err(ClientAuthError::ServerAddressNotAUrl) + Err(server_address_rejected(ServerAddressReason::NotAUrl)) ); assert_eq!( validate_client_api_base_url(&format!( "https://example.com/{}", "a".repeat(MAX_ORIGIN_CHARS) )), - Err(ClientAuthError::ServerAddressEmptyOrTooLong) + Err(server_address_rejected(ServerAddressReason::EmptyOrTooLong)) ); assert_eq!( validate_client_api_base_url("http://127.0.0.1:10001").expect("loopback debug"), @@ -1521,7 +1555,7 @@ mod tests { // 是内部不变式,不是用户能理解的登录失败原因。 assert_eq!( validated_session_user_id(" ").unwrap_err(), - ClientAuthError::AuthResponseMissingUserIdentity + response_invalid(AuthResponseInvalidReason::MissingUserIdentity) ); } @@ -1580,7 +1614,7 @@ mod tests { // 没有主体的登录响应在写凭据文件之前就以登录语义失败关闭。 assert_eq!( SessionIdentity::Login(payload.user).resolve().unwrap_err(), - ClientAuthError::AuthResponseMissingUserIdentity + response_invalid(AuthResponseInvalidReason::MissingUserIdentity) ); // 续期路径的空主体同样失败关闭:缺字段的凭据文件本来就读不出来,不能在这里被复活。 assert!(SessionIdentity::Persisted(String::new()).resolve().is_err());