From 8d55f7191152559ac63ec6d8fd27a17a567084b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 1 Oct 2026 15:13:57 +0800 Subject: [PATCH] =?UTF-8?q?AGC=20=E8=AE=A4=E8=AF=81=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E7=BB=93=E6=9E=84=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 auth_error.rs:ClientAuthError 具体变体枚举,serde tag=type + ts-rs 导出,附变体名契约测试 - auth_session.rs 全量改为 Result<_, ClientAuthError>:请求/响应/凭据落盘/运行时会话安装按具体变体建模 - 路由语义由变体承担:会话 401/403 走 SessionAuthorityRejected/PermissionDenied,登录 401 保留服务端原因 - 400 变体按请求粒度命名(passwordEntryInputRejected 等),服务端只给 status+message,不做文案匹配 - 注册 auth_error 模块,生成 src/services/generated/ClientAuthError.ts --- .../src-tauri/src/auth_error.rs | 317 ++++++++++++ .../src-tauri/src/auth_session.rs | 486 +++++++++++++----- .../src-tauri/src/main.rs | 1 + .../src/services/generated/ClientAuthError.ts | 25 + 4 files changed, 688 insertions(+), 141 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/auth_error.rs create mode 100644 apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts 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..a64308628 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs @@ -0,0 +1,317 @@ +//! AGC 认证命令的结构化错误:从命令入口到出口只传这一种错误。 +//! +//! 变体名就是线上的分流键(`type`):前端只按它选通道,**不解析任何文案**,也不对错误文本做匹配。 +//! +//! 变体按**可判定的事实**命名。服务端 400 只提供 `status + message`(平台 `AppError.code` 仍是 +//! 通用 `BAD_REQUEST`),所以 400 变体按"哪条请求的输入被拒"命名(例如 +//! [`ClientAuthError::PasswordEntryInputRejected`]),不假装能区分密码长度 / 手机号格式;会话路由的 +//! `401/403` 是"登录态权威失效",登录路由的 `401` 是用户可修正的输入问题,这个区分现在由变体承担, +//! 不再靠 `authentication-required:` 这类文本前缀。 +//! +//! 每个变体都带一份可展示 `message`,文案只在 Rust 生成一次(服务端原文优先,缺失时才用调用点的 +//! 兜底文案);前端对认得的业务变体原样展示,对系统变体 / 未识别变体带上下文重抛,走上报链路。 + +use std::fmt; + +use serde::Serialize; +use ts_rs::TS; + +/// 变体名就是线上的分流键(`type`)。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] +pub(crate) enum ClientAuthError { + // ---- 业务:用户自己能改,调用方给提示,不上报 ---- + /// 服务地址不是合法 origin / 非本机未用 HTTPS / 不在构建渠道范围内。 + ServerAddressRejected { message: String }, + /// 本地前置校验:手机号为空或格式不合法。 + PhoneNumberInvalid { message: String }, + /// 本地前置校验:密码为空。 + PasswordMissing { message: String }, + /// 本地前置校验:验证码为空。 + LoginCodeMissing { message: String }, + /// `/api/auth/entry` 返回 400:服务端拒绝本次输入。 + PasswordEntryInputRejected { message: String }, + /// `/api/auth/entry` 返回 401:手机号或密码错误。 + PhoneOrPasswordMismatch { message: String }, + /// `/api/auth/phone/send-code` 返回 400。 + SendCodeInputRejected { message: String }, + /// `/api/auth/phone/send-code` 返回 429:发送过于频繁。 + SmsCodeThrottled { message: String }, + /// `/api/auth/phone/login` 返回 400。 + PhoneLoginInputRejected { message: String }, + /// `/api/auth/phone/login` 返回 401:验证码错误或过期。 + SmsCodeInvalidOrExpired { message: String }, + // ---- 会话:调用方按"未登录"处理,不给用户报错 ---- + /// 会话路由 401:登录态权威失效。 + SessionAuthorityRejected { message: String }, + /// 会话路由 403:当前账号没有执行此操作的权限。 + PermissionDenied { message: String }, + // ---- 系统:调用方处理不了,带上下文重抛 ---- + /// 连接 / 超时 / DNS 等网络失败。 + AuthNetworkUnavailable { message: String }, + /// 服务端 5xx。 + AuthServiceUnavailable { status: u16, message: String }, + /// 其它未识别的拒绝(未列举的 4xx、登录路由 403 等)。 + UnexpectedRejection { status: u16, message: String }, + /// 响应不是合法 JSON、缺少必需字段或契约不成立。 + AuthResponseMalformed { message: String }, + /// 本机登录凭据文件读写失败。 + ClientSessionPersistFailed { message: String }, + /// 本机运行时会话安装 / 清理失败。 + RuntimeSessionInstallFailed { message: String }, + /// 认证网络客户端构建失败。 + AuthClientInitFailed { message: String }, +} + +impl ClientAuthError { + /// 可展示文案:服务端原文优先,缺失时是调用点兜底。 + pub(crate) fn message(&self) -> &str { + match self { + Self::ServerAddressRejected { message } + | Self::PhoneNumberInvalid { message } + | Self::PasswordMissing { message } + | Self::LoginCodeMissing { message } + | Self::PasswordEntryInputRejected { message } + | Self::PhoneOrPasswordMismatch { message } + | Self::SendCodeInputRejected { message } + | Self::SmsCodeThrottled { message } + | Self::PhoneLoginInputRejected { message } + | Self::SmsCodeInvalidOrExpired { message } + | Self::SessionAuthorityRejected { message } + | Self::PermissionDenied { message } + | Self::AuthNetworkUnavailable { message } + | Self::AuthServiceUnavailable { message, .. } + | Self::UnexpectedRejection { message, .. } + | Self::AuthResponseMalformed { message } + | Self::ClientSessionPersistFailed { message } + | Self::RuntimeSessionInstallFailed { message } + | Self::AuthClientInitFailed { message } => message, + } + } + + /// 会话路由的 401/403 是「登录态权威失效」:调用方据此清会话、按未登录处理, + /// 既不给用户报错,也不进错误报告池。 + pub(crate) fn is_authority_failure(&self) -> bool { + matches!( + self, + Self::SessionAuthorityRejected { .. } | Self::PermissionDenied { .. } + ) + } + + /// 登录态投影里 `errorKind` 的取值:只描述"哪一类失败",不参与任何前端分支。 + pub(crate) fn error_kind(&self) -> &'static str { + match self { + Self::AuthNetworkUnavailable { .. } => "network", + Self::AuthServiceUnavailable { .. } | Self::UnexpectedRejection { .. } => "service", + Self::AuthResponseMalformed { .. } => "response", + Self::ClientSessionPersistFailed { .. } => "storage", + Self::RuntimeSessionInstallFailed { .. } | Self::AuthClientInitFailed { .. } => { + "runtime" + } + _ => "auth", + } + } +} + +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() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wire_variant_names_are_the_frontend_dispatch_keys() { + let cases = [ + ( + ClientAuthError::ServerAddressRejected { + message: "x".to_string(), + }, + "serverAddressRejected", + ), + ( + ClientAuthError::PhoneNumberInvalid { + message: "x".to_string(), + }, + "phoneNumberInvalid", + ), + ( + ClientAuthError::PasswordMissing { + message: "x".to_string(), + }, + "passwordMissing", + ), + ( + ClientAuthError::LoginCodeMissing { + message: "x".to_string(), + }, + "loginCodeMissing", + ), + ( + ClientAuthError::PasswordEntryInputRejected { + message: "x".to_string(), + }, + "passwordEntryInputRejected", + ), + ( + ClientAuthError::PhoneOrPasswordMismatch { + message: "x".to_string(), + }, + "phoneOrPasswordMismatch", + ), + ( + ClientAuthError::SendCodeInputRejected { + message: "x".to_string(), + }, + "sendCodeInputRejected", + ), + ( + ClientAuthError::SmsCodeThrottled { + message: "x".to_string(), + }, + "smsCodeThrottled", + ), + ( + ClientAuthError::PhoneLoginInputRejected { + message: "x".to_string(), + }, + "phoneLoginInputRejected", + ), + ( + ClientAuthError::SmsCodeInvalidOrExpired { + message: "x".to_string(), + }, + "smsCodeInvalidOrExpired", + ), + ( + ClientAuthError::SessionAuthorityRejected { + message: "x".to_string(), + }, + "sessionAuthorityRejected", + ), + ( + ClientAuthError::PermissionDenied { + message: "x".to_string(), + }, + "permissionDenied", + ), + ( + ClientAuthError::AuthNetworkUnavailable { + message: "x".to_string(), + }, + "authNetworkUnavailable", + ), + ( + ClientAuthError::AuthServiceUnavailable { + status: 503, + message: "x".to_string(), + }, + "authServiceUnavailable", + ), + ( + ClientAuthError::UnexpectedRejection { + status: 409, + message: "x".to_string(), + }, + "unexpectedRejection", + ), + ( + ClientAuthError::AuthResponseMalformed { + message: "x".to_string(), + }, + "authResponseMalformed", + ), + ( + ClientAuthError::ClientSessionPersistFailed { + message: "x".to_string(), + }, + "clientSessionPersistFailed", + ), + ( + ClientAuthError::RuntimeSessionInstallFailed { + message: "x".to_string(), + }, + "runtimeSessionInstallFailed", + ), + ( + ClientAuthError::AuthClientInitFailed { + message: "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) + ); + assert_eq!( + value.get("message").and_then(|value| value.as_str()), + Some(error.message()) + ); + } + } + + #[test] + fn machine_context_fields_survive_serialization() { + let unavailable = serde_json::to_value(ClientAuthError::AuthServiceUnavailable { + status: 503, + message: "账号服务暂不可用".to_string(), + }) + .expect("serialize auth error"); + assert_eq!(unavailable["type"], "authServiceUnavailable"); + assert_eq!(unavailable["status"], 503); + + let rejection = serde_json::to_value(ClientAuthError::UnexpectedRejection { + status: 409, + message: "冲突".to_string(), + }) + .expect("serialize auth error"); + assert_eq!(rejection["status"], 409); + } + + #[test] + fn only_session_authority_failures_count_as_authority_failures() { + assert!(ClientAuthError::SessionAuthorityRejected { + message: "x".to_string() + } + .is_authority_failure()); + assert!(ClientAuthError::PermissionDenied { + message: "x".to_string() + } + .is_authority_failure()); + assert!(!ClientAuthError::PhoneOrPasswordMismatch { + message: "x".to_string() + } + .is_authority_failure()); + assert!(!ClientAuthError::AuthNetworkUnavailable { + message: "x".to_string() + } + .is_authority_failure()); + } + + #[test] + fn display_and_string_conversion_use_the_display_message() { + let error = ClientAuthError::PhoneOrPasswordMismatch { + message: "手机号或密码错误".to_string(), + }; + assert_eq!(error.to_string(), "手机号或密码错误"); + assert_eq!(String::from(error), "手机号或密码错误"); + } +} 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..a1b76d1a9 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,7 @@ //! 只在 Rust 内存与本进程会话快照里,refresh 凭据只写在 AppData 私有文件里。换号、登出或 //! origin 变化都会让旧身份的在途请求失败关闭;同一身份的凭据轮换不改变身份代次。 +use crate::auth_error::ClientAuthError; use crate::http_client::agc_main_site_client_builder; use crate::platform_session::{current_platform_session, PlatformSessionSnapshot}; use reqwest::{header::SET_COOKIE, Method, StatusCode}; @@ -35,7 +36,8 @@ 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: 登录状态已失效,请重新登录"; +/// 会话路由 401 且服务端没给原因时的兜底文案。 +const AUTH_AUTHORITY_MESSAGE: &str = "登录状态已失效,请重新登录"; /// 认证态投影:只含状态、用户展示字段与 origin,不含 token 或 refresh 凭据。 #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] @@ -47,7 +49,7 @@ pub(crate) struct ClientAuthStateView { pub(crate) user: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) api_base_url: Option, - /// 失败分类:`network` / `authority` / `contract`;成功或未登录时为 `None`。 + /// 失败分类:`network` / `service` / `response` / `storage` / `runtime` / `auth`;成功或未登录时为 `None`。 #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) error_kind: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -221,11 +223,34 @@ 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| ClientAuthError::ClientSessionPersistFailed { + message: format!("无法读取 AGC 应用数据目录:{error}"), + }) +} + +/// 凭据文件相关的字符串错误统一收口成凭据落盘失败变体。 +fn session_persist_error(message: impl Into) -> ClientAuthError { + ClientAuthError::ClientSessionPersistFailed { + message: message.into(), + } +} + +/// 本机运行时安装 / 清理失败的字符串错误统一收口。 +fn runtime_session_error(message: impl Into) -> ClientAuthError { + ClientAuthError::RuntimeSessionInstallFailed { + message: message.into(), + } +} + +/// 服务地址校验(含渠道范围门禁)失败统一收口成业务变体。 +fn server_address_rejected(message: impl Into) -> ClientAuthError { + ClientAuthError::ServerAddressRejected { + message: message.into(), + } } /// 校验并归一化平台服务 origin。 @@ -271,19 +296,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 +338,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 +354,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 +410,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("陶泥儿服务地址无效"))?; { let mut segments = url .path_segments_mut() - .map_err(|_| "陶泥儿服务地址无效".to_string())?; + .map_err(|_| server_address_rejected("陶泥儿服务地址无效"))?; for segment in route.trim_start_matches('/').split('/') { if segment.is_empty() { continue; @@ -389,19 +427,23 @@ 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(|_| ClientAuthError::AuthClientInitFailed { + message: "创建登录网络客户端失败".to_string(), + }) } -fn network_error_message(error: &reqwest::Error) -> String { - if error.is_timeout() { - AUTH_NETWORK_TIMEOUT.to_string() - } else { - AUTH_NETWORK_ERROR.to_string() +fn network_error_message(error: &reqwest::Error) -> ClientAuthError { + ClientAuthError::AuthNetworkUnavailable { + message: if error.is_timeout() { + AUTH_NETWORK_TIMEOUT.to_string() + } else { + AUTH_NETWORK_ERROR.to_string() + }, } } @@ -416,64 +458,133 @@ 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` +/// 归到权威失效变体,调用方据此清会话。网络、5xx 与契约异常必须保留会话。 +fn map_auth_failure( + status: StatusCode, + body: &str, + fallback: &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 (kind, error_message(body)) { - (AuthRouteKind::Session, Some(message)) => { - format!("authentication-required: {message}") - } - (AuthRouteKind::Session, None) => AUTH_AUTHORITY_ERROR.to_string(), - (AuthRouteKind::Login, Some(message)) => message, - (AuthRouteKind::Login, None) => fallback.to_string(), + return match route { + AuthRoute::Session => ClientAuthError::SessionAuthorityRejected { + message: server_message.unwrap_or_else(|| AUTH_AUTHORITY_MESSAGE.to_string()), + }, + AuthRoute::PasswordEntry => ClientAuthError::PhoneOrPasswordMismatch { + message: server_message.unwrap_or_else(|| fallback.to_string()), + }, + AuthRoute::PhoneLogin => ClientAuthError::SmsCodeInvalidOrExpired { + message: server_message.unwrap_or_else(|| fallback.to_string()), + }, + _ => ClientAuthError::UnexpectedRejection { + status: status_code, + message: server_message.unwrap_or_else(|| fallback.to_string()), + }, }; } 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 { + message: server_message + .unwrap_or_else(|| "当前陶泥儿账号没有执行此操作的权限".to_string()), + }, + _ => ClientAuthError::UnexpectedRejection { + status: status_code, + message: server_message.unwrap_or_else(|| fallback.to_string()), + }, }; } - let detail = error_message(body).unwrap_or_else(|| format!("HTTP {}", status.as_u16())); - format!("{fallback}:{detail}") + if status == StatusCode::TOO_MANY_REQUESTS && route == AuthRoute::SendCode { + return ClientAuthError::SmsCodeThrottled { + message: prefixed(), + }; + } + if status == StatusCode::BAD_REQUEST { + return match route { + AuthRoute::PasswordEntry => ClientAuthError::PasswordEntryInputRejected { + message: prefixed(), + }, + AuthRoute::PhoneLogin => ClientAuthError::PhoneLoginInputRejected { + message: prefixed(), + }, + AuthRoute::SendCode => ClientAuthError::SendCodeInputRejected { + message: prefixed(), + }, + _ => ClientAuthError::UnexpectedRejection { + status: status_code, + message: prefixed(), + }, + }; + } + if status.is_server_error() { + return ClientAuthError::AuthServiceUnavailable { + status: status_code, + message: prefixed(), + }; + } + ClientAuthError::UnexpectedRejection { + status: status_code, + message: prefixed(), + } } -fn is_authority_failure(message: &str) -> bool { - message.starts_with("authentication-required") || message.starts_with("permission-denied") -} - -fn response_data(body: &str, fallback: &str) -> Result { +fn response_data(body: &str, fallback: &str) -> Result { let value: Value = - serde_json::from_str(body).map_err(|_| format!("{fallback}:登录服务响应不是合法 JSON"))?; + serde_json::from_str(body).map_err(|_| ClientAuthError::AuthResponseMalformed { + message: format!("{fallback}:登录服务响应不是合法 JSON"), + })?; 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::AuthResponseMalformed { + message: format!( + "{fallback}:{}", + error_message(body).unwrap_or_else(|| "登录服务请求失败".to_string()) + ), + }); } Ok(value.get("data").cloned().unwrap_or(value)) } @@ -521,7 +632,7 @@ async fn request_auth( 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, @@ -553,17 +664,16 @@ async fn request_auth( let text = response .text() .await - .map_err(|_| format!("{fallback}:读取响应失败"))?; + .map_err(|_| ClientAuthError::AuthResponseMalformed { + message: format!("{fallback}:读取响应失败"), + })?; if !status.is_success() { - return Err(map_auth_failure( - status, - &text, - fallback, - auth_route_kind(route), - )); + return Err(map_auth_failure(status, &text, fallback, auth_route(route))); } if matches!(policy, CookiePolicy::Require) && captured.is_none() { - return Err("result-unknown: 登录服务未返回新的续期凭据,已停止使用旧凭据".to_string()); + return Err(ClientAuthError::AuthResponseMalformed { + message: "result-unknown: 登录服务未返回新的续期凭据,已停止使用旧凭据".to_string(), + }); } Ok(AuthResponse { data: response_data(&text, fallback)?, @@ -584,7 +694,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 +710,11 @@ 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(ClientAuthError::AuthResponseMalformed { + message: "登录服务返回的凭据无效".to_string(), + }); } // 主体先于凭据落盘解析:没有主体就不写 client-session.json,避免留下半截会话文件。 let (user_id, known_user) = identity.resolve()?; @@ -630,7 +742,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 +760,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 +771,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( @@ -689,10 +804,12 @@ 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(ClientAuthError::AuthResponseMalformed { + message: "登录失败:登录服务未返回用户身份".to_string(), + }); } Ok(user_id.to_string()) } @@ -712,7 +829,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, @@ -724,8 +841,11 @@ async fn fetch_current_user( "读取当前用户失败", ) .await?; - let me: MeResponse = serde_json::from_value(response.data) - .map_err(|_| "读取当前用户失败:响应格式无效".to_string())?; + let me: MeResponse = serde_json::from_value(response.data).map_err(|_| { + ClientAuthError::AuthResponseMalformed { + message: "读取当前用户失败:响应格式无效".to_string(), + } + })?; Ok(me.user) } @@ -736,7 +856,7 @@ 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(), @@ -782,7 +902,7 @@ async fn refresh_session_inner( 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); @@ -797,16 +917,22 @@ async fn refresh_session_inner( status: "failed".to_string(), user: None, authoritative: false, - error_message: Some(error), + error_message: Some(error.message().to_string()), }); } }; - let token: TokenResponse = serde_json::from_value(refreshed.data) - .map_err(|_| "刷新登录状态失败:凭据响应格式无效".to_string())?; + let token: TokenResponse = serde_json::from_value(refreshed.data).map_err(|_| { + ClientAuthError::AuthResponseMalformed { + message: "刷新登录状态失败:凭据响应格式无效".to_string(), + } + })?; let install_token = token.token.clone(); - let new_cookie = refreshed - .refresh_cookie - .ok_or_else(|| "刷新登录状态失败:缺少新的续期凭据".to_string())?; + let new_cookie = + refreshed + .refresh_cookie + .ok_or_else(|| ClientAuthError::AuthResponseMalformed { + message: "刷新登录状态失败:缺少新的续期凭据".to_string(), + })?; commit_authenticated_session( app, &session.api_base_url, @@ -855,7 +981,7 @@ async fn refresh_session_inner( 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); @@ -870,7 +996,7 @@ async fn refresh_session_inner( status: "failed".to_string(), user: None, authoritative: false, - error_message: Some(error), + error_message: Some(error.message().to_string()), }), } } @@ -880,7 +1006,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() { // 凭据文件缺失但本进程仍有会话(例如同一次启动内刚登录):以会话为准。 @@ -900,7 +1026,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)?, + Some(value) => validate_client_api_base_url(value).map_err(server_address_rejected)?, None => session.api_base_url.clone(), }; if expected != session.api_base_url { @@ -927,8 +1053,8 @@ pub(crate) async fn read_client_auth_state( status: "unavailable".to_string(), user: None, api_base_url: Some(session.api_base_url), - error_kind: Some("network".to_string()), - error_message: Some(error), + error_kind: Some(error.error_kind().to_string()), + error_message: Some(error.message().to_string()), }), } } @@ -937,7 +1063,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 +1076,13 @@ pub(crate) async fn refresh_client_auth_session( 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)?; +) -> Result { + let origin = validate_client_api_base_url(&api_base_url).map_err(server_address_rejected)?; let phone = phone.trim(); if !phone_is_valid(phone) { - return Err("请输入正确的手机号".to_string()); + return Err(ClientAuthError::PhoneNumberInvalid { + message: "请输入正确的手机号".to_string(), + }); } let client = build_client()?; let response = request_auth( @@ -972,8 +1100,11 @@ pub(crate) async fn send_client_phone_login_code( "发送验证码失败", ) .await?; - let payload: SendCodeResponse = serde_json::from_value(response.data) - .map_err(|_| "发送验证码失败:响应格式无效".to_string())?; + let payload: SendCodeResponse = serde_json::from_value(response.data).map_err(|_| { + ClientAuthError::AuthResponseMalformed { + message: "发送验证码失败:响应格式无效".to_string(), + } + })?; Ok(ClientLoginCodeView { cooldown_seconds: payload.cooldown_seconds, expires_in_seconds: payload.expires_in_seconds, @@ -984,12 +1115,17 @@ async fn complete_login( app: &tauri::AppHandle, origin: &str, response: AuthResponse, -) -> Result { - let payload: TokenUserResponse = serde_json::from_value(response.data) - .map_err(|_| "登录失败:登录服务响应格式无效".to_string())?; +) -> Result { + let payload: TokenUserResponse = serde_json::from_value(response.data).map_err(|_| { + ClientAuthError::AuthResponseMalformed { + message: "登录失败:登录服务响应格式无效".to_string(), + } + })?; let cookie = response .refresh_cookie - .ok_or_else(|| "登录失败:登录服务未返回续期凭据".to_string())?; + .ok_or_else(|| ClientAuthError::AuthResponseMalformed { + message: "登录失败:登录服务未返回续期凭据".to_string(), + })?; let user = commit_authenticated_session( app, origin, @@ -1015,14 +1151,18 @@ pub(crate) async fn login_client_with_password( api_base_url: String, phone: String, password: String, -) -> Result { - let origin = validate_client_api_base_url(&api_base_url)?; +) -> Result { + let origin = validate_client_api_base_url(&api_base_url).map_err(server_address_rejected)?; let phone = phone.trim(); if !phone_is_valid(phone) { - return Err("请输入正确的手机号".to_string()); + return Err(ClientAuthError::PhoneNumberInvalid { + message: "请输入正确的手机号".to_string(), + }); } if password.trim().is_empty() { - return Err("请输入密码".to_string()); + return Err(ClientAuthError::PasswordMissing { + message: "请输入密码".to_string(), + }); } let client = build_client()?; let response = request_auth( @@ -1049,14 +1189,18 @@ pub(crate) async fn login_client_with_phone_code( api_base_url: String, phone: String, code: String, -) -> Result { - let origin = validate_client_api_base_url(&api_base_url)?; +) -> Result { + let origin = validate_client_api_base_url(&api_base_url).map_err(server_address_rejected)?; let phone = phone.trim(); if !phone_is_valid(phone) { - return Err("请输入正确的手机号".to_string()); + return Err(ClientAuthError::PhoneNumberInvalid { + message: "请输入正确的手机号".to_string(), + }); } if code.trim().is_empty() { - return Err("请输入验证码".to_string()); + return Err(ClientAuthError::LoginCodeMissing { + message: "请输入验证码".to_string(), + }); } let client = build_client()?; let response = request_auth( @@ -1079,7 +1223,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() @@ -1150,33 +1294,43 @@ 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); + 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); + } - assert!(is_authority_failure(&map_auth_failure( + #[test] + fn session_routes_classify_401_403_as_authority_failures() { + assert!(map_auth_failure( StatusCode::UNAUTHORIZED, "{}", "刷新失败", - AuthRouteKind::Session - ))); - assert!(is_authority_failure(&map_auth_failure( + AuthRoute::Session + ) + .is_authority_failure()); + assert!(map_auth_failure( StatusCode::FORBIDDEN, r#"{"error":{"message":"无权"}}"#, "刷新失败", - AuthRouteKind::Session - ))); + AuthRoute::Session + ) + .is_authority_failure()); let transient = map_auth_failure( StatusCode::INTERNAL_SERVER_ERROR, "{}", "刷新失败", - AuthRouteKind::Session, + AuthRoute::Session, ); - assert!(!is_authority_failure(&transient)); - assert!(transient.starts_with("刷新失败")); - assert!(!is_authority_failure(AUTH_NETWORK_ERROR)); - assert!(is_authority_failure(AUTH_AUTHORITY_ERROR)); + assert!(!transient.is_authority_failure()); + assert!(matches!( + transient, + ClientAuthError::AuthServiceUnavailable { status: 500, .. } + )); + assert!(transient.message().starts_with("刷新失败")); } #[test] @@ -1185,18 +1339,65 @@ mod tests { StatusCode::UNAUTHORIZED, r#"{"error":{"message":"手机号或密码错误"}}"#, "登录失败", - AuthRouteKind::Login, + AuthRoute::PasswordEntry, ); - assert_eq!(wrong_password, "手机号或密码错误"); - assert!(!is_authority_failure(&wrong_password)); + assert_eq!( + wrong_password, + ClientAuthError::PhoneOrPasswordMismatch { + message: "手机号或密码错误".to_string() + } + ); + assert!(!wrong_password.is_authority_failure()); let missing_reason = map_auth_failure( StatusCode::UNAUTHORIZED, "{}", "登录失败", - AuthRouteKind::Login, + AuthRoute::PasswordEntry, ); - assert_eq!(missing_reason, "登录失败"); + assert_eq!(missing_reason.message(), "登录失败"); + } + + #[test] + fn input_rejections_and_throttling_keep_the_server_text() { + let password_length = map_auth_failure( + StatusCode::BAD_REQUEST, + r#"{"error":{"message":"密码长度需要在 6 到 128 位之间"}}"#, + "登录失败", + AuthRoute::PasswordEntry, + ); + assert!(matches!( + password_length, + ClientAuthError::PasswordEntryInputRejected { .. } + )); + assert_eq!( + password_length.message(), + "登录失败:密码长度需要在 6 到 128 位之间" + ); + + let throttled = map_auth_failure( + StatusCode::TOO_MANY_REQUESTS, + r#"{"error":{"message":"发送过于频繁"}}"#, + "发送验证码失败", + AuthRoute::SendCode, + ); + assert!(matches!( + throttled, + ClientAuthError::SmsCodeThrottled { .. } + )); + assert_eq!(throttled.message(), "发送验证码失败:发送过于频繁"); + + let bad_code = map_auth_failure( + StatusCode::UNAUTHORIZED, + r#"{"error":{"message":"验证码错误"}}"#, + "登录失败", + AuthRoute::PhoneLogin, + ); + assert!(matches!( + bad_code, + ClientAuthError::SmsCodeInvalidOrExpired { .. } + )); + assert_eq!(bad_code.message(), "验证码错误"); } #[test] @@ -1397,7 +1598,7 @@ mod tests { // 空主体必须在登录路径就以登录语义失败:安装会话时的「陶泥儿登录用户身份无效」 // 是内部不变式,不是用户能理解的登录失败原因。 assert_eq!( - validated_session_user_id(" ").unwrap_err(), + validated_session_user_id(" ").unwrap_err().message(), "登录失败:登录服务未返回用户身份" ); } @@ -1456,7 +1657,10 @@ mod tests { .expect("login response fixture"); // 没有主体的登录响应在写凭据文件之前就以登录语义失败关闭。 assert_eq!( - SessionIdentity::Login(payload.user).resolve().unwrap_err(), + SessionIdentity::Login(payload.user) + .resolve() + .unwrap_err() + .message(), "登录失败:登录服务未返回用户身份" ); // 续期路径的空主体同样失败关闭:缺字段的凭据文件本来就读不出来,不能在这里被复活。 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 31d9323e1..582c14fd9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -118,6 +118,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/services/generated/ClientAuthError.ts b/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts new file mode 100644 index 000000000..c80d66ae0 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts @@ -0,0 +1,25 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 变体名就是线上的分流键(`type`)。 + */ +export type ClientAuthError = + | { type: 'serverAddressRejected'; message: string } + | { type: 'phoneNumberInvalid'; message: string } + | { type: 'passwordMissing'; message: string } + | { type: 'loginCodeMissing'; message: string } + | { type: 'passwordEntryInputRejected'; message: string } + | { type: 'phoneOrPasswordMismatch'; message: string } + | { type: 'sendCodeInputRejected'; message: string } + | { type: 'smsCodeThrottled'; message: string } + | { type: 'phoneLoginInputRejected'; message: string } + | { type: 'smsCodeInvalidOrExpired'; message: string } + | { type: 'sessionAuthorityRejected'; message: string } + | { type: 'permissionDenied'; message: string } + | { type: 'authNetworkUnavailable'; message: string } + | { type: 'authServiceUnavailable'; status: number; message: string } + | { type: 'unexpectedRejection'; status: number; message: string } + | { type: 'authResponseMalformed'; message: string } + | { type: 'clientSessionPersistFailed'; message: string } + | { type: 'runtimeSessionInstallFailed'; message: string } + | { type: 'authClientInitFailed'; message: string };