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 1d8a19ecc..bc602dfa8 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,261 +2,170 @@ //! //! 变体名就是线上的分流键(`type`):前端只按它选通道,**不解析任何文案**,也不对错误文本做匹配。 //! -//! 每个变体持有自己的**具名载荷类型**([`ServerAddressRejected`] … [`AuthClientInitFailed`]),不是内联 -//! 匿名结构体。ts-rs 因此生成 `{ type: 'phoneNumberInvalid' } & PhoneNumberInvalid`:前端 -//! `switch (error.type)` 的每个分支都拿到一个有名字的类型,等价于 Java 的 -//! `catch (PhoneNumberInvalid e)`,不需要 `as` 断言。载荷类型必须能序列化成 map——serde 的 -//! internally tagged 表示只接受 struct / map——所以全部是命名结构体,没有无字段变体。 +//! 变体一律拍平在同一层,不嵌套子枚举:可枚举的事实各自成变体(例如服务地址校验的 7 种失败、 +//! 网络超时 / 不可达、响应契约的 5 种破损),类型本身说不出来的事实才进载荷(服务端原文、HTTP +//! 状态码、本机 IO 明细)。internally tagged 下无字段变体序列化成 `{ "type": "x" }`,带载荷的 +//! 是 `{ "type": "x", ... }`;ts-rs 生成 `{ type: 'x' }` 或 `{ type: 'x' } & X`,前端每个分支 +//! 拿到的形状与 Rust 一致。 +//! +//! Rust **不预拼用户可见文案**:载荷只存原始事实(服务端 400 的原文、本机 IO 的 `detail`), +//! 前缀与句式由前端调用方在自己的 catch 分支按当前操作拼接。 //! //! 变体按**可判定的事实**命名。服务端 400 只提供 `status + message`(平台 `AppError.code` 仍是 //! 通用 `BAD_REQUEST`),所以 400 变体按"哪条请求的输入被拒"命名(例如 -//! [`PasswordEntryInputRejected`]),不假装能区分密码长度 / 手机号格式;会话路由的 `401/403` 是 -//! "登录态权威失效",登录路由的 `401` 是用户可修正的输入问题,这个区分现在由变体承担, -//! 不再靠 `authentication-required:` 这类文本前缀。 -//! -//! 每个变体都带一份可展示 `message`,文案只在 Rust 生成一次(服务端原文优先,缺失时才用调用点的 -//! 兜底文案);前端对认得的业务变体原样展示,对系统变体 / 未识别变体带上下文重抛,走上报链路。 - -use std::fmt; +//! [`PasswordLoginRejected`]),不假装能区分密码长度 / 手机号格式;会话路由的 `401/403` 是 +//! "登录态失效",登录路由的 `401` 是用户可修正的输入问题,这个区分由变体承担。 use serde::Serialize; use ts_rs::TS; -/// 变体名就是线上的分流键(`type`),每个变体持有同名载荷类型。 +/// 变体名就是线上的分流键(`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 { // ---- 业务:用户自己能改,调用方给提示,不上报 ---- - ServerAddressRejected(ServerAddressRejected), - PhoneNumberInvalid(PhoneNumberInvalid), - PasswordMissing(PasswordMissing), - LoginCodeMissing(LoginCodeMissing), - PasswordEntryInputRejected(PasswordEntryInputRejected), - PhoneOrPasswordMismatch(PhoneOrPasswordMismatch), - SendCodeInputRejected(SendCodeInputRejected), - SmsCodeThrottled(SmsCodeThrottled), - PhoneLoginInputRejected(PhoneLoginInputRejected), - SmsCodeInvalidOrExpired(SmsCodeInvalidOrExpired), + /// 服务地址为空或超长。 + ServerAddressEmptyOrTooLong, + /// 服务地址不是合法 URL。 + ServerAddressNotAUrl, + /// 服务地址带用户名 / 密码。 + ServerAddressHasCredentials, + /// 服务地址带路径、查询或 fragment。 + ServerAddressHasPathOrQueryOrFragment, + /// 非本机服务地址不是 HTTPS。 + ServerAddressNotHttps, + /// 服务地址 scheme 不是 http(s)。 + ServerAddressUnsupportedScheme, + /// 发布构建里服务地址不在当前构建渠道范围内。 + ServerAddressOutsideChannel, + /// 本地前置校验:手机号为空、超长或不是纯数字。 + 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:服务端拒绝本次输入。 + PhoneCodeLoginRejected(PhoneCodeLoginRejected), + /// `/api/auth/phone/login` 返回 401:验证码错误或过期。 + SmsCodeRejected, + // ---- 会话:调用方按"未登录"处理,不给用户报错 ---- - SessionAuthorityRejected(SessionAuthorityRejected), - PermissionDenied(PermissionDenied), + /// 会话路由 401:登录态失效。 + SessionInvalidated, + /// 会话路由 403:当前账号没有执行此操作的权限。 + PermissionDenied, + // ---- 系统:调用方处理不了,带上下文重抛 ---- - AuthNetworkUnavailable(AuthNetworkUnavailable), + /// 连接登录服务超时。 + AuthNetworkTimeout, + /// 连接登录服务失败(DNS / 连接被拒等)。 + AuthNetworkUnreachable, + /// 登录服务 5xx。 AuthServiceUnavailable(AuthServiceUnavailable), + /// 其它未识别的拒绝(未列举的 4xx、登录路由 403 等)。 UnexpectedRejection(UnexpectedRejection), - AuthResponseMalformed(AuthResponseMalformed), + /// 登录服务响应不是合法 JSON。 + AuthResponseNotJson, + /// 登录服务响应不是预期结构(缺字段 / 类型不符)。 + AuthResponseInvalidBody, + /// 登录 / 续期响应没有下发新的续期凭据。 + AuthResponseMissingRefreshCookie, + /// 登录响应没有带上会话主体(用户身份)。 + AuthResponseMissingUserIdentity, + /// 登录服务在响应体里显式拒绝(`ok: false`)。 + AuthResponseServerRejected(AuthResponseServerRejected), + /// 本机登录凭据文件读写失败。 ClientSessionPersistFailed(ClientSessionPersistFailed), + /// 本机运行时会话安装 / 清理失败。 RuntimeSessionInstallFailed(RuntimeSessionInstallFailed), - AuthClientInitFailed(AuthClientInitFailed), + /// 认证网络客户端构建失败。 + AuthClientInitFailed, } -// ---- 业务:用户自己能改,调用方给提示,不上报 ---- +// ---- 载荷:只装类型说不出来的事实 ---- -/// 服务地址不是合法 origin / 非本机未用 HTTPS / 不在构建渠道范围内。 +/// `/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 ServerAddressRejected { - pub(crate) message: String, +pub(crate) struct PasswordLoginRejected { + /// 服务端原文可能缺失:缺失是 `None`,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 PhoneNumberInvalid { - pub(crate) message: String, +pub(crate) struct SendCodeRejected { + /// 服务端原文可能缺失:缺失是 `None`,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 PasswordMissing { - pub(crate) message: String, +pub(crate) struct PhoneCodeLoginRejected { + /// 服务端原文可能缺失:缺失是 `None`,Rust 不编造兜底文案。 + 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 LoginCodeMissing { - pub(crate) message: String, -} - -/// `/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 PasswordEntryInputRejected { - pub(crate) message: String, -} - -/// `/api/auth/entry` 返回 401:手机号或密码错误。 -#[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 PhoneOrPasswordMismatch { - pub(crate) message: String, -} - -/// `/api/auth/phone/send-code` 返回 400。 -#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] -pub(crate) struct SendCodeInputRejected { - pub(crate) message: String, -} - -/// `/api/auth/phone/send-code` 返回 429:发送过于频繁。 -#[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 SmsCodeThrottled { - pub(crate) message: String, -} - -/// `/api/auth/phone/login` 返回 400。 -#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] -pub(crate) struct PhoneLoginInputRejected { - pub(crate) message: String, -} - -/// `/api/auth/phone/login` 返回 401:验证码错误或过期。 -#[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 SmsCodeInvalidOrExpired { - pub(crate) message: String, -} - -// ---- 会话:调用方按"未登录"处理,不给用户报错 ---- - -/// 会话路由 401:登录态权威失效。 -#[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 SessionAuthorityRejected { - pub(crate) message: String, -} - -/// 会话路由 403:当前账号没有执行此操作的权限。 -#[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 PermissionDenied { - pub(crate) message: String, -} - -// ---- 系统:调用方处理不了,带上下文重抛 ---- - -/// 连接 / 超时 / DNS 等网络失败。 -#[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 AuthNetworkUnavailable { - pub(crate) message: String, -} - -/// 服务端 5xx。 +/// 登录服务 5xx 的状态码。 #[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 AuthServiceUnavailable { pub(crate) status: u16, - pub(crate) message: String, } -/// 其它未识别的拒绝(未列举的 4xx、登录路由 403 等)。 +/// 其它未识别拒绝的状态码与服务端原文。 #[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) message: String, + pub(crate) server_message: Option, } -/// 响应不是合法 JSON、缺少必需字段或契约不成立。 +/// 登录服务在响应体里显式拒绝时给的原文。 #[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 AuthResponseMalformed { - pub(crate) message: String, +pub(crate) struct AuthResponseServerRejected { + 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) message: String, + 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) message: 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) message: String, + pub(crate) detail: String, } impl ClientAuthError { - /// 可展示文案:服务端原文优先,缺失时是调用点兜底。 - pub(crate) fn message(&self) -> &str { - match self { - Self::ServerAddressRejected(payload) => &payload.message, - Self::PhoneNumberInvalid(payload) => &payload.message, - Self::PasswordMissing(payload) => &payload.message, - Self::LoginCodeMissing(payload) => &payload.message, - Self::PasswordEntryInputRejected(payload) => &payload.message, - Self::PhoneOrPasswordMismatch(payload) => &payload.message, - Self::SendCodeInputRejected(payload) => &payload.message, - Self::SmsCodeThrottled(payload) => &payload.message, - Self::PhoneLoginInputRejected(payload) => &payload.message, - Self::SmsCodeInvalidOrExpired(payload) => &payload.message, - Self::SessionAuthorityRejected(payload) => &payload.message, - Self::PermissionDenied(payload) => &payload.message, - Self::AuthNetworkUnavailable(payload) => &payload.message, - Self::AuthServiceUnavailable(payload) => &payload.message, - Self::UnexpectedRejection(payload) => &payload.message, - Self::AuthResponseMalformed(payload) => &payload.message, - Self::ClientSessionPersistFailed(payload) => &payload.message, - Self::RuntimeSessionInstallFailed(payload) => &payload.message, - Self::AuthClientInitFailed(payload) => &payload.message, - } - } - - /// 会话路由的 401/403 是「登录态权威失效」:调用方据此清会话、按未登录处理, + /// 会话路由的 401/403 是「登录态失效」:调用方据此清会话、按未登录处理, /// 既不给用户报错,也不进错误报告池。 pub(crate) fn is_authority_failure(&self) -> bool { - matches!( - self, - Self::SessionAuthorityRejected(_) | Self::PermissionDenied(_) - ) - } -} - -impl fmt::Display for ClientAuthError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.message()) - } -} - -/// 只需要一句文案的边界用它,与 `DirectTurnError` 的收口方式一致。 -impl From for String { - fn from(error: ClientAuthError) -> Self { - error.message().to_string() + matches!(self, Self::SessionInvalidated | Self::PermissionDenied) } } @@ -264,116 +173,115 @@ impl From for String { mod tests { use super::*; - /// 单字段载荷的构造简写:这些变体的载荷只有一个 `message`。 - fn text(message: &str) -> String { - message.to_string() - } - #[test] fn wire_variant_names_are_the_frontend_dispatch_keys() { let cases = [ ( - ClientAuthError::ServerAddressRejected(ServerAddressRejected { - message: text("x"), + ClientAuthError::ServerAddressEmptyOrTooLong, + "serverAddressEmptyOrTooLong", + ), + ( + ClientAuthError::ServerAddressNotAUrl, + "serverAddressNotAUrl", + ), + ( + ClientAuthError::ServerAddressHasCredentials, + "serverAddressHasCredentials", + ), + ( + ClientAuthError::ServerAddressHasPathOrQueryOrFragment, + "serverAddressHasPathOrQueryOrFragment", + ), + ( + ClientAuthError::ServerAddressNotHttps, + "serverAddressNotHttps", + ), + ( + ClientAuthError::ServerAddressUnsupportedScheme, + "serverAddressUnsupportedScheme", + ), + ( + ClientAuthError::ServerAddressOutsideChannel, + "serverAddressOutsideChannel", + ), + (ClientAuthError::PhoneNumberInvalid, "phoneNumberInvalid"), + (ClientAuthError::PasswordMissing, "passwordMissing"), + (ClientAuthError::LoginCodeMissing, "loginCodeMissing"), + ( + ClientAuthError::PasswordLoginRejected(PasswordLoginRejected { + server_message: Some("x".to_string()), }), - "serverAddressRejected", + "passwordLoginRejected", ), ( - ClientAuthError::PhoneNumberInvalid(PhoneNumberInvalid { message: text("x") }), - "phoneNumberInvalid", - ), - ( - ClientAuthError::PasswordMissing(PasswordMissing { message: text("x") }), - "passwordMissing", - ), - ( - ClientAuthError::LoginCodeMissing(LoginCodeMissing { message: text("x") }), - "loginCodeMissing", - ), - ( - ClientAuthError::PasswordEntryInputRejected(PasswordEntryInputRejected { - message: text("x"), - }), - "passwordEntryInputRejected", - ), - ( - ClientAuthError::PhoneOrPasswordMismatch(PhoneOrPasswordMismatch { - message: text("x"), - }), + ClientAuthError::PhoneOrPasswordMismatch, "phoneOrPasswordMismatch", ), ( - ClientAuthError::SendCodeInputRejected(SendCodeInputRejected { - message: text("x"), + ClientAuthError::SendCodeRejected(SendCodeRejected { + server_message: Some("x".to_string()), }), - "sendCodeInputRejected", + "sendCodeRejected", ), + (ClientAuthError::SmsCodeThrottled, "smsCodeThrottled"), ( - ClientAuthError::SmsCodeThrottled(SmsCodeThrottled { message: text("x") }), - "smsCodeThrottled", - ), - ( - ClientAuthError::PhoneLoginInputRejected(PhoneLoginInputRejected { - message: text("x"), + ClientAuthError::PhoneCodeLoginRejected(PhoneCodeLoginRejected { + server_message: Some("x".to_string()), }), - "phoneLoginInputRejected", + "phoneCodeLoginRejected", + ), + (ClientAuthError::SmsCodeRejected, "smsCodeRejected"), + (ClientAuthError::SessionInvalidated, "sessionInvalidated"), + (ClientAuthError::PermissionDenied, "permissionDenied"), + (ClientAuthError::AuthNetworkTimeout, "authNetworkTimeout"), + ( + ClientAuthError::AuthNetworkUnreachable, + "authNetworkUnreachable", ), ( - ClientAuthError::SmsCodeInvalidOrExpired(SmsCodeInvalidOrExpired { - message: text("x"), - }), - "smsCodeInvalidOrExpired", - ), - ( - ClientAuthError::SessionAuthorityRejected(SessionAuthorityRejected { - message: text("x"), - }), - "sessionAuthorityRejected", - ), - ( - ClientAuthError::PermissionDenied(PermissionDenied { message: text("x") }), - "permissionDenied", - ), - ( - ClientAuthError::AuthNetworkUnavailable(AuthNetworkUnavailable { - message: text("x"), - }), - "authNetworkUnavailable", - ), - ( - ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { - status: 503, - message: text("x"), - }), + ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: 503 }), "authServiceUnavailable", ), ( ClientAuthError::UnexpectedRejection(UnexpectedRejection { status: 409, - message: text("x"), + server_message: None, }), "unexpectedRejection", ), + (ClientAuthError::AuthResponseNotJson, "authResponseNotJson"), ( - ClientAuthError::AuthResponseMalformed(AuthResponseMalformed { - message: text("x"), + ClientAuthError::AuthResponseInvalidBody, + "authResponseInvalidBody", + ), + ( + ClientAuthError::AuthResponseMissingRefreshCookie, + "authResponseMissingRefreshCookie", + ), + ( + ClientAuthError::AuthResponseMissingUserIdentity, + "authResponseMissingUserIdentity", + ), + ( + ClientAuthError::AuthResponseServerRejected(AuthResponseServerRejected { + server_message: None, }), - "authResponseMalformed", + "authResponseServerRejected", ), ( ClientAuthError::ClientSessionPersistFailed(ClientSessionPersistFailed { - message: text("x"), + detail: "x".to_string(), }), "clientSessionPersistFailed", ), ( ClientAuthError::RuntimeSessionInstallFailed(RuntimeSessionInstallFailed { - message: text("x"), + detail: "x".to_string(), }), "runtimeSessionInstallFailed", ), ( - ClientAuthError::AuthClientInitFailed(AuthClientInitFailed { message: text("x") }), + ClientAuthError::AuthClientInitFailed, "authClientInitFailed", ), ]; @@ -383,20 +291,20 @@ mod tests { value.get("type").and_then(|value| value.as_str()), Some(expected_type) ); - assert_eq!( - value.get("message").and_then(|value| value.as_str()), - Some(error.message()) - ); } } #[test] - fn machine_context_fields_survive_serialization() { + 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 payload_variants_keep_machine_facts_and_server_text() { let unavailable = serde_json::to_value(ClientAuthError::AuthServiceUnavailable( - AuthServiceUnavailable { - status: 503, - message: "账号服务暂不可用".to_string(), - }, + AuthServiceUnavailable { status: 503 }, )) .expect("serialize auth error"); assert_eq!(unavailable["type"], "authServiceUnavailable"); @@ -405,42 +313,26 @@ mod tests { let rejection = serde_json::to_value(ClientAuthError::UnexpectedRejection(UnexpectedRejection { status: 409, - message: "冲突".to_string(), + server_message: Some("冲突".to_string()), })) .expect("serialize auth error"); assert_eq!(rejection["status"], 409); + assert_eq!(rejection["serverMessage"], "冲突"); + + let persist = serde_json::to_value(ClientAuthError::ClientSessionPersistFailed( + ClientSessionPersistFailed { + detail: "磁盘只读".to_string(), + }, + )) + .expect("serialize auth error"); + assert_eq!(persist["detail"], "磁盘只读"); } #[test] - fn only_session_authority_failures_count_as_authority_failures() { - assert!( - ClientAuthError::SessionAuthorityRejected(SessionAuthorityRejected { - message: text("x"), - }) - .is_authority_failure() - ); - assert!( - ClientAuthError::PermissionDenied(PermissionDenied { message: text("x") }) - .is_authority_failure() - ); - assert!( - !ClientAuthError::PhoneOrPasswordMismatch(PhoneOrPasswordMismatch { - message: text("x"), - }) - .is_authority_failure() - ); - assert!( - !ClientAuthError::AuthNetworkUnavailable(AuthNetworkUnavailable { message: text("x") }) - .is_authority_failure() - ); - } - - #[test] - fn display_and_string_conversion_use_the_display_message() { - let error = ClientAuthError::PhoneOrPasswordMismatch(PhoneOrPasswordMismatch { - message: "手机号或密码错误".to_string(), - }); - assert_eq!(error.to_string(), "手机号或密码错误"); - assert_eq!(String::from(error), "手机号或密码错误"); + 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::AuthNetworkTimeout.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 599356ad9..5625e958d 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,12 +5,9 @@ //! origin 变化都会让旧身份的在途请求失败关闭;同一身份的凭据轮换不改变身份代次。 use crate::auth_error::{ - AuthClientInitFailed, AuthNetworkUnavailable, AuthResponseMalformed, AuthServiceUnavailable, - ClientAuthError, ClientSessionPersistFailed, LoginCodeMissing, PasswordEntryInputRejected, - PasswordMissing, PermissionDenied, PhoneLoginInputRejected, PhoneNumberInvalid, - PhoneOrPasswordMismatch, RuntimeSessionInstallFailed, SendCodeInputRejected, - ServerAddressRejected, SessionAuthorityRejected, SmsCodeInvalidOrExpired, SmsCodeThrottled, - UnexpectedRejection, + AuthResponseServerRejected, AuthServiceUnavailable, ClientAuthError, + ClientSessionPersistFailed, PasswordLoginRejected, PhoneCodeLoginRejected, + RuntimeSessionInstallFailed, SendCodeRejected, UnexpectedRejection, }; use crate::http_client::agc_main_site_client_builder; use crate::platform_session::{current_platform_session, PlatformSessionSnapshot}; @@ -40,10 +37,6 @@ 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 = "无法连接登录服务,请确认配套后端或 API 代理已启动后重试"; -const AUTH_NETWORK_TIMEOUT: &str = "登录服务响应超时,请检查服务器地址和网络后重试"; -/// 会话路由 401 且服务端没给原因时的兜底文案。 -const AUTH_AUTHORITY_MESSAGE: &str = "登录状态已失效,请重新登录"; /// 认证态投影:只含状态、用户展示字段与 origin,不含 token 或 refresh 凭据。 #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] @@ -229,7 +222,7 @@ fn session_file_path(app: &tauri::AppHandle) -> Result .map(|root| root.join(SESSION_FILE_NAME)) .map_err(|error| { ClientAuthError::ClientSessionPersistFailed(ClientSessionPersistFailed { - message: format!("无法读取 AGC 应用数据目录:{error}"), + detail: format!("无法读取 AGC 应用数据目录:{error}"), }) }) } @@ -237,21 +230,14 @@ fn session_file_path(app: &tauri::AppHandle) -> Result /// 凭据文件相关的字符串错误统一收口成凭据落盘失败变体。 fn session_persist_error(message: impl Into) -> ClientAuthError { ClientAuthError::ClientSessionPersistFailed(ClientSessionPersistFailed { - message: message.into(), + detail: message.into(), }) } /// 本机运行时安装 / 清理失败的字符串错误统一收口。 fn runtime_session_error(message: impl Into) -> ClientAuthError { ClientAuthError::RuntimeSessionInstallFailed(RuntimeSessionInstallFailed { - message: message.into(), - }) -} - -/// 服务地址校验(含渠道范围门禁)失败统一收口成业务变体。 -fn server_address_rejected(message: impl Into) -> ClientAuthError { - ClientAuthError::ServerAddressRejected(ServerAddressRejected { - message: message.into(), + detail: message.into(), }) } @@ -259,31 +245,33 @@ fn server_address_rejected(message: impl Into) -> ClientAuthError { /// /// 只接受纯 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(ClientAuthError::ServerAddressEmptyOrTooLong); } - let parsed = Url::parse(trimmed).map_err(|_| "陶泥儿服务地址无效".to_string())?; + let parsed = Url::parse(trimmed).map_err(|_| ClientAuthError::ServerAddressNotAUrl)?; if !parsed.username().is_empty() || parsed.password().is_some() { - return Err("陶泥儿服务地址不能包含凭据".to_string()); + return Err(ClientAuthError::ServerAddressHasCredentials); } if !matches!(parsed.path(), "" | "/") || parsed.query().is_some() || parsed.fragment().is_some() { - return Err("陶泥儿服务地址必须是纯地址,不能带路径或参数".to_string()); + return Err(ClientAuthError::ServerAddressHasPathOrQueryOrFragment); } 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(ClientAuthError::ServerAddressNotHttps), + _ => return Err(ClientAuthError::ServerAddressUnsupportedScheme), } 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(ClientAuthError::ServerAddressOutsideChannel); } Ok(trimmed.to_string()) } @@ -414,11 +402,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(|_| server_address_rejected("陶泥儿服务地址无效"))?; + .map_err(|_| ClientAuthError::ServerAddressNotAUrl)?; { let mut segments = url .path_segments_mut() - .map_err(|_| server_address_rejected("陶泥儿服务地址无效"))?; + .map_err(|_| ClientAuthError::ServerAddressNotAUrl)?; for segment in route.trim_start_matches('/').split('/') { if segment.is_empty() { continue; @@ -434,21 +422,15 @@ fn build_client() -> Result { .connect_timeout(Duration::from_secs(10)) .timeout(HTTP_TIMEOUT) .build() - .map_err(|_| { - ClientAuthError::AuthClientInitFailed(AuthClientInitFailed { - message: "创建登录网络客户端失败".to_string(), - }) - }) + .map_err(|_| ClientAuthError::AuthClientInitFailed) } -fn network_error_message(error: &reqwest::Error) -> ClientAuthError { - ClientAuthError::AuthNetworkUnavailable(AuthNetworkUnavailable { - message: if error.is_timeout() { - AUTH_NETWORK_TIMEOUT.to_string() - } else { - AUTH_NETWORK_ERROR.to_string() - }, - }) +fn network_error(error: &reqwest::Error) -> ClientAuthError { + if error.is_timeout() { + ClientAuthError::AuthNetworkTimeout + } else { + ClientAuthError::AuthNetworkUnreachable + } } fn error_message(body: &str) -> Option { @@ -495,111 +477,71 @@ fn auth_route(route: &str) -> AuthRoute { /// 把一次认证 HTTP 响应归类成具体变体。 /// -/// 服务端原因(「手机号或密码错误」)原样保留,不能被改写成登录失效;会话路由的 `401/403` -/// 归到权威失效变体,调用方据此清会话。网络、5xx 与契约异常必须保留会话。 -fn map_auth_failure( - status: StatusCode, - body: &str, - fallback: &str, - route: AuthRoute, -) -> ClientAuthError { +/// 会话路由的 `401/403` 归到权威失效变体,调用方据此清会话;登录路由的 `401` 是用户可修正的 +/// 输入问题。可判定的用户输入原因只进业务变体,认不出的才落到 `UnexpectedRejection`。 +/// 网络、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); - let prefixed = || { - format!( - "{fallback}:{}", - server_message - .clone() - .unwrap_or_else(|| format!("HTTP {status_code}")) - ) - }; if status == StatusCode::UNAUTHORIZED { return match route { - AuthRoute::Session => { - ClientAuthError::SessionAuthorityRejected(SessionAuthorityRejected { - message: server_message.unwrap_or_else(|| AUTH_AUTHORITY_MESSAGE.to_string()), - }) - } - AuthRoute::PasswordEntry => { - ClientAuthError::PhoneOrPasswordMismatch(PhoneOrPasswordMismatch { - message: server_message.unwrap_or_else(|| fallback.to_string()), - }) - } - AuthRoute::PhoneLogin => { - ClientAuthError::SmsCodeInvalidOrExpired(SmsCodeInvalidOrExpired { - message: server_message.unwrap_or_else(|| fallback.to_string()), - }) - } + AuthRoute::Session => ClientAuthError::SessionInvalidated, + AuthRoute::PasswordEntry => ClientAuthError::PhoneOrPasswordMismatch, + AuthRoute::PhoneLogin => ClientAuthError::SmsCodeRejected, _ => ClientAuthError::UnexpectedRejection(UnexpectedRejection { status: status_code, - message: server_message.unwrap_or_else(|| fallback.to_string()), + server_message, }), }; } if status == StatusCode::FORBIDDEN { return match route { - AuthRoute::Session => ClientAuthError::PermissionDenied(PermissionDenied { - message: server_message - .unwrap_or_else(|| "当前陶泥儿账号没有执行此操作的权限".to_string()), - }), + AuthRoute::Session => ClientAuthError::PermissionDenied, _ => ClientAuthError::UnexpectedRejection(UnexpectedRejection { status: status_code, - message: server_message.unwrap_or_else(|| fallback.to_string()), + server_message, }), }; } if status == StatusCode::TOO_MANY_REQUESTS && route == AuthRoute::SendCode { - return ClientAuthError::SmsCodeThrottled(SmsCodeThrottled { - message: prefixed(), - }); + return ClientAuthError::SmsCodeThrottled; } if status == StatusCode::BAD_REQUEST { return match route { AuthRoute::PasswordEntry => { - ClientAuthError::PasswordEntryInputRejected(PasswordEntryInputRejected { - message: prefixed(), - }) + ClientAuthError::PasswordLoginRejected(PasswordLoginRejected { server_message }) } AuthRoute::PhoneLogin => { - ClientAuthError::PhoneLoginInputRejected(PhoneLoginInputRejected { - message: prefixed(), - }) + ClientAuthError::PhoneCodeLoginRejected(PhoneCodeLoginRejected { server_message }) + } + AuthRoute::SendCode => { + ClientAuthError::SendCodeRejected(SendCodeRejected { server_message }) } - AuthRoute::SendCode => ClientAuthError::SendCodeInputRejected(SendCodeInputRejected { - message: prefixed(), - }), _ => ClientAuthError::UnexpectedRejection(UnexpectedRejection { status: status_code, - message: prefixed(), + server_message, }), }; } if status.is_server_error() { return ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: status_code, - message: prefixed(), }); } ClientAuthError::UnexpectedRejection(UnexpectedRejection { status: status_code, - message: prefixed(), + server_message, }) } -fn response_data(body: &str, fallback: &str) -> Result { - let value: Value = serde_json::from_str(body).map_err(|_| { - ClientAuthError::AuthResponseMalformed(AuthResponseMalformed { - message: format!("{fallback}:登录服务响应不是合法 JSON"), - }) - })?; +fn response_data(body: &str) -> Result { + let value: Value = + serde_json::from_str(body).map_err(|_| ClientAuthError::AuthResponseNotJson)?; if value.get("ok").and_then(Value::as_bool) == Some(false) { - return Err(ClientAuthError::AuthResponseMalformed( - AuthResponseMalformed { - message: format!( - "{fallback}:{}", - error_message(body).unwrap_or_else(|| "登录服务请求失败".to_string()) - ), + return Err(ClientAuthError::AuthResponseServerRejected( + AuthResponseServerRejected { + server_message: error_message(body), }, )); } @@ -648,7 +590,6 @@ async fn request_auth( bearer: Option<&str>, refresh_cookie: Option<&(String, String)>, policy: CookiePolicy, - fallback: &str, ) -> Result { let method = match policy { // 读取类路由用 GET;写入类路由是 POST。 @@ -672,29 +613,25 @@ 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(|_| { - ClientAuthError::AuthResponseMalformed(AuthResponseMalformed { - message: format!("{fallback}:读取响应失败"), - }) - })?; + // 拿到状态码后读 body 失败属于传输层故障:按网络不可达上报,不猜契约。 + let text = response + .text() + .await + .map_err(|_| ClientAuthError::AuthNetworkUnreachable)?; if !status.is_success() { - return Err(map_auth_failure(status, &text, fallback, auth_route(route))); + return Err(map_auth_failure(status, &text, auth_route(route))); } if matches!(policy, CookiePolicy::Require) && captured.is_none() { - return Err(ClientAuthError::AuthResponseMalformed( - AuthResponseMalformed { - message: "登录服务未返回新的续期凭据,已停止使用旧凭据".to_string(), - }, - )); + return Err(ClientAuthError::AuthResponseMissingRefreshCookie); } Ok(AuthResponse { - data: response_data(&text, fallback)?, + data: response_data(&text)?, refresh_cookie: captured, }) } @@ -730,11 +667,7 @@ async fn commit_authenticated_session( identity_change: bool, ) -> Result { if token.chars().count() > MAX_SECRET_CHARS { - return Err(ClientAuthError::AuthResponseMalformed( - AuthResponseMalformed { - message: "登录服务返回的凭据无效".to_string(), - }, - )); + return Err(ClientAuthError::AuthResponseInvalidBody); } // 主体先于凭据落盘解析:没有主体就不写 client-session.json,避免留下半截会话文件。 let (user_id, known_user) = identity.resolve()?; @@ -821,15 +754,11 @@ 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::AuthResponseMalformed( - AuthResponseMalformed { - message: "登录失败:登录服务未返回用户身份".to_string(), - }, - )); + return Err(ClientAuthError::AuthResponseMissingUserIdentity); } Ok(user_id.to_string()) } @@ -858,14 +787,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(|_| { - ClientAuthError::AuthResponseMalformed(AuthResponseMalformed { - message: "读取当前用户失败:响应格式无效".to_string(), - }) - })?; + let me: MeResponse = serde_json::from_value(response.data) + .map_err(|_| ClientAuthError::AuthResponseInvalidBody)?; Ok(me.user) } @@ -914,7 +839,6 @@ async fn refresh_session_inner( None, Some(&cookie), CookiePolicy::Require, - "刷新登录状态失败", ) .await; let refreshed = match refreshed { @@ -935,17 +859,12 @@ async fn refresh_session_inner( return Err(error); } }; - let token: TokenResponse = serde_json::from_value(refreshed.data).map_err(|_| { - ClientAuthError::AuthResponseMalformed(AuthResponseMalformed { - message: "刷新登录状态失败:凭据响应格式无效".to_string(), - }) - })?; + let token: TokenResponse = serde_json::from_value(refreshed.data) + .map_err(|_| ClientAuthError::AuthResponseInvalidBody)?; let install_token = token.token.clone(); - let new_cookie = refreshed.refresh_cookie.ok_or_else(|| { - ClientAuthError::AuthResponseMalformed(AuthResponseMalformed { - message: "刷新登录状态失败:缺少新的续期凭据".to_string(), - }) - })?; + let new_cookie = refreshed + .refresh_cookie + .ok_or(ClientAuthError::AuthResponseMissingRefreshCookie)?; commit_authenticated_session( app, &session.api_base_url, @@ -1030,7 +949,7 @@ pub(crate) async fn read_client_auth_state( .map(str::trim) .filter(|value| !value.is_empty()) { - Some(value) => validate_client_api_base_url(value).map_err(server_address_rejected)?, + Some(value) => validate_client_api_base_url(value)?, None => session.api_base_url.clone(), }; if expected != session.api_base_url { @@ -1069,12 +988,10 @@ pub(crate) async fn send_client_phone_login_code( api_base_url: String, phone: String, ) -> Result { - let origin = validate_client_api_base_url(&api_base_url).map_err(server_address_rejected)?; + let origin = validate_client_api_base_url(&api_base_url)?; let phone = phone.trim(); if !phone_is_valid(phone) { - return Err(ClientAuthError::PhoneNumberInvalid(PhoneNumberInvalid { - message: "请输入正确的手机号".to_string(), - })); + return Err(ClientAuthError::PhoneNumberInvalid); } let client = build_client()?; let response = request_auth( @@ -1089,14 +1006,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(|_| { - ClientAuthError::AuthResponseMalformed(AuthResponseMalformed { - message: "发送验证码失败:响应格式无效".to_string(), - }) - })?; + let payload: SendCodeResponse = serde_json::from_value(response.data) + .map_err(|_| ClientAuthError::AuthResponseInvalidBody)?; Ok(ClientLoginCodeView { cooldown_seconds: payload.cooldown_seconds, expires_in_seconds: payload.expires_in_seconds, @@ -1108,16 +1021,11 @@ async fn complete_login( origin: &str, response: AuthResponse, ) -> Result { - let payload: TokenUserResponse = serde_json::from_value(response.data).map_err(|_| { - ClientAuthError::AuthResponseMalformed(AuthResponseMalformed { - message: "登录失败:登录服务响应格式无效".to_string(), - }) - })?; - let cookie = response.refresh_cookie.ok_or_else(|| { - ClientAuthError::AuthResponseMalformed(AuthResponseMalformed { - message: "登录失败:登录服务未返回续期凭据".to_string(), - }) - })?; + let payload: TokenUserResponse = serde_json::from_value(response.data) + .map_err(|_| ClientAuthError::AuthResponseInvalidBody)?; + let cookie = response + .refresh_cookie + .ok_or(ClientAuthError::AuthResponseMissingRefreshCookie)?; let user = commit_authenticated_session( app, origin, @@ -1144,17 +1052,13 @@ pub(crate) async fn login_client_with_password( phone: String, password: String, ) -> Result { - let origin = validate_client_api_base_url(&api_base_url).map_err(server_address_rejected)?; + let origin = validate_client_api_base_url(&api_base_url)?; let phone = phone.trim(); if !phone_is_valid(phone) { - return Err(ClientAuthError::PhoneNumberInvalid(PhoneNumberInvalid { - message: "请输入正确的手机号".to_string(), - })); + return Err(ClientAuthError::PhoneNumberInvalid); } if password.trim().is_empty() { - return Err(ClientAuthError::PasswordMissing(PasswordMissing { - message: "请输入密码".to_string(), - })); + return Err(ClientAuthError::PasswordMissing); } let client = build_client()?; let response = request_auth( @@ -1169,7 +1073,6 @@ pub(crate) async fn login_client_with_password( None, None, CookiePolicy::Capture, - "登录失败", ) .await?; complete_login(&app, &origin, response).await @@ -1182,17 +1085,13 @@ pub(crate) async fn login_client_with_phone_code( phone: String, code: String, ) -> Result { - let origin = validate_client_api_base_url(&api_base_url).map_err(server_address_rejected)?; + let origin = validate_client_api_base_url(&api_base_url)?; let phone = phone.trim(); if !phone_is_valid(phone) { - return Err(ClientAuthError::PhoneNumberInvalid(PhoneNumberInvalid { - message: "请输入正确的手机号".to_string(), - })); + return Err(ClientAuthError::PhoneNumberInvalid); } if code.trim().is_empty() { - return Err(ClientAuthError::LoginCodeMissing(LoginCodeMissing { - message: "请输入验证码".to_string(), - })); + return Err(ClientAuthError::LoginCodeMissing); } let client = build_client()?; let response = request_auth( @@ -1207,7 +1106,6 @@ pub(crate) async fn login_client_with_phone_code( None, None, CookiePolicy::Capture, - "登录失败", ) .await?; complete_login(&app, &origin, response).await @@ -1236,7 +1134,6 @@ pub(crate) async fn logout_client_session(app: tauri::AppHandle) -> Result<(), C Some(&snapshot.access_token), None, CookiePolicy::Ignore, - "退出登录失败", ) .await; } @@ -1264,12 +1161,46 @@ 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()); + // 每种拒绝各成一个变体:前端只按变体选提示,不读任何文案。 + assert_eq!( + validate_client_api_base_url("https://user:pass@example.com"), + Err(ClientAuthError::ServerAddressHasCredentials) + ); + assert_eq!( + validate_client_api_base_url("https://example.com/api"), + Err(ClientAuthError::ServerAddressHasPathOrQueryOrFragment) + ); + assert_eq!( + validate_client_api_base_url("https://example.com?a=1"), + Err(ClientAuthError::ServerAddressHasPathOrQueryOrFragment) + ); + assert_eq!( + validate_client_api_base_url("https://example.com#frag"), + Err(ClientAuthError::ServerAddressHasPathOrQueryOrFragment) + ); + assert_eq!( + validate_client_api_base_url("http://example.com"), + Err(ClientAuthError::ServerAddressNotHttps) + ); + assert_eq!( + validate_client_api_base_url("ftp://example.com"), + Err(ClientAuthError::ServerAddressUnsupportedScheme) + ); + assert_eq!( + validate_client_api_base_url(" "), + Err(ClientAuthError::ServerAddressEmptyOrTooLong) + ); + assert_eq!( + validate_client_api_base_url("不是地址"), + Err(ClientAuthError::ServerAddressNotAUrl) + ); + assert_eq!( + validate_client_api_base_url(&format!( + "https://example.com/{}", + "a".repeat(MAX_ORIGIN_CHARS) + )), + Err(ClientAuthError::ServerAddressEmptyOrTooLong) + ); assert_eq!( validate_client_api_base_url("http://127.0.0.1:10001").expect("loopback debug"), "http://127.0.0.1:10001" @@ -1297,57 +1228,47 @@ mod tests { #[test] fn session_routes_classify_401_403_as_authority_failures() { - assert!(map_auth_failure( - StatusCode::UNAUTHORIZED, - "{}", - "刷新失败", - AuthRoute::Session - ) - .is_authority_failure()); + assert!( + map_auth_failure(StatusCode::UNAUTHORIZED, "{}", AuthRoute::Session) + .is_authority_failure() + ); assert!(map_auth_failure( StatusCode::FORBIDDEN, r#"{"error":{"message":"无权"}}"#, - "刷新失败", AuthRoute::Session ) .is_authority_failure()); - let transient = map_auth_failure( - StatusCode::INTERNAL_SERVER_ERROR, - "{}", - "刷新失败", - AuthRoute::Session, - ); - assert!(!transient.is_authority_failure()); - assert!(matches!( - transient, - ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: 500, .. }) - )); - assert!(transient.message().starts_with("刷新失败")); - } - - #[test] - fn login_routes_keep_the_server_reason_instead_of_reporting_expiry() { - let wrong_password = map_auth_failure( - StatusCode::UNAUTHORIZED, - r#"{"error":{"message":"手机号或密码错误"}}"#, - "登录失败", - AuthRoute::PasswordEntry, + assert_eq!( + map_auth_failure(StatusCode::UNAUTHORIZED, "{}", AuthRoute::Session), + ClientAuthError::SessionInvalidated ); assert_eq!( - wrong_password, - ClientAuthError::PhoneOrPasswordMismatch(PhoneOrPasswordMismatch { - message: "手机号或密码错误".to_string() - }) + map_auth_failure( + StatusCode::FORBIDDEN, + r#"{"error":{"message":"无权"}}"#, + AuthRoute::Session, + ), + ClientAuthError::PermissionDenied ); - assert!(!wrong_password.is_authority_failure()); + 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, - "{}", - "登录失败", - AuthRoute::PasswordEntry, - ); - assert_eq!(missing_reason.message(), "登录失败"); + /// 登录路由的 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 不是「用户可改的输入」,而是协议异常,属系统变体(带上文上报)。 @@ -1360,58 +1281,47 @@ mod tests { let error = map_auth_failure( status, r#"{"error":{"message":"手机号登录暂未启用"}}"#, - "发送验证码失败", AuthRoute::SendCode, ); - assert!(matches!( - error, - ClientAuthError::UnexpectedRejection(UnexpectedRejection { .. }) - )); assert!(!error.is_authority_failure()); - assert_eq!(error.message(), "手机号登录暂未启用"); + let ClientAuthError::UnexpectedRejection(UnexpectedRejection { + status: 401 | 403, + server_message: Some(message), + }) = error + else { + panic!("发码端点 401/403 必须落成带原文的系统变体"); + }; + assert_eq!(message, "手机号登录暂未启用"); } } #[test] - fn input_rejections_and_throttling_keep_the_server_text() { + 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!(matches!( - password_length, - ClientAuthError::PasswordEntryInputRejected(PasswordEntryInputRejected { .. }) - )); assert_eq!( - password_length.message(), - "登录失败:密码长度需要在 6 到 128 位之间" + 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!(matches!( - throttled, - ClientAuthError::SmsCodeThrottled(SmsCodeThrottled { .. }) - )); - assert_eq!(throttled.message(), "发送验证码失败:发送过于频繁"); + assert_eq!(throttled, ClientAuthError::SmsCodeThrottled); let bad_code = map_auth_failure( StatusCode::UNAUTHORIZED, r#"{"error":{"message":"验证码错误"}}"#, - "登录失败", AuthRoute::PhoneLogin, ); - assert!(matches!( - bad_code, - ClientAuthError::SmsCodeInvalidOrExpired(SmsCodeInvalidOrExpired { .. }) - )); - assert_eq!(bad_code.message(), "验证码错误"); + assert_eq!(bad_code, ClientAuthError::SmsCodeRejected); } #[test] @@ -1607,11 +1517,11 @@ mod tests { validated_session_user_id(" user-1 ").expect("user id"), "user-1" ); - // 空主体必须在登录路径就以登录语义失败:安装会话时的「陶泥儿登录用户身份无效」 + // 空主体必须在登录路径就失败关闭:安装会话时的「陶泥儿登录用户身份无效」 // 是内部不变式,不是用户能理解的登录失败原因。 assert_eq!( - validated_session_user_id(" ").unwrap_err().message(), - "登录失败:登录服务未返回用户身份" + validated_session_user_id(" ").unwrap_err(), + ClientAuthError::AuthResponseMissingUserIdentity ); } @@ -1669,11 +1579,8 @@ mod tests { .expect("login response fixture"); // 没有主体的登录响应在写凭据文件之前就以登录语义失败关闭。 assert_eq!( - SessionIdentity::Login(payload.user) - .resolve() - .unwrap_err() - .message(), - "登录失败:登录服务未返回用户身份" + SessionIdentity::Login(payload.user).resolve().unwrap_err(), + ClientAuthError::AuthResponseMissingUserIdentity ); // 续期路径的空主体同样失败关闭:缺字段的凭据文件本来就读不出来,不能在这里被复活。 assert!(SessionIdentity::Persisted(String::new()).resolve().is_err());