From f435d483992efd128656e7ff75843f513208298e 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 18:08:18 +0800 Subject: [PATCH] =?UTF-8?q?AGC=20=E8=AE=A4=E8=AF=81=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E6=AF=8F=E4=B8=AA=E5=8F=98=E4=BD=93=E4=B8=80?= =?UTF-8?q?=E4=B8=AA=E5=85=B7=E5=90=8D=E8=BD=BD=E8=8D=B7=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth_error.rs:枚举改为 newtype 变体,每个变体持有一个同名 `#[ts(export)]` 载荷结构体 - auth_session.rs:41 处构造点改为 `ClientAuthError::X(X { .. })` 并导入载荷类型 - 重新生成 services/generated:ClientAuthError.ts 变成 `{ type } & 具名类型`,新增 19 个载荷类型文件 - tests/clientAuthError.test.ts:新增编译期用例,switch (error.type) 分支里错误窄化成具名载荷类型 --- .../src-tauri/src/auth_error.rs | 443 ++++++++++++------ .../src-tauri/src/auth_session.rs | 230 +++++---- .../generated/AuthClientInitFailed.ts | 6 + .../generated/AuthNetworkUnavailable.ts | 6 + .../generated/AuthResponseMalformed.ts | 6 + .../generated/AuthServiceUnavailable.ts | 6 + .../src/services/generated/ClientAuthError.ts | 59 ++- .../generated/ClientSessionPersistFailed.ts | 6 + .../services/generated/LoginCodeMissing.ts | 6 + .../generated/PasswordEntryInputRejected.ts | 6 + .../src/services/generated/PasswordMissing.ts | 6 + .../services/generated/PermissionDenied.ts | 6 + .../generated/PhoneLoginInputRejected.ts | 6 + .../services/generated/PhoneNumberInvalid.ts | 6 + .../generated/PhoneOrPasswordMismatch.ts | 6 + .../generated/RuntimeSessionInstallFailed.ts | 6 + .../generated/SendCodeInputRejected.ts | 6 + .../generated/ServerAddressRejected.ts | 6 + .../generated/SessionAuthorityRejected.ts | 6 + .../generated/SmsCodeInvalidOrExpired.ts | 6 + .../services/generated/SmsCodeThrottled.ts | 6 + .../services/generated/UnexpectedRejection.ts | 6 + .../tests/clientAuthError.test.ts | 38 ++ 23 files changed, 613 insertions(+), 271 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/services/generated/AuthClientInitFailed.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/AuthNetworkUnavailable.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/AuthResponseMalformed.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/AuthServiceUnavailable.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/ClientSessionPersistFailed.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/LoginCodeMissing.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/PasswordEntryInputRejected.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/PasswordMissing.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/PermissionDenied.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/PhoneLoginInputRejected.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/PhoneNumberInvalid.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/PhoneOrPasswordMismatch.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/RuntimeSessionInstallFailed.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/SendCodeInputRejected.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/ServerAddressRejected.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/SessionAuthorityRejected.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/SmsCodeInvalidOrExpired.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/SmsCodeThrottled.ts create mode 100644 apps/ai-game-creator-shell/src/services/generated/UnexpectedRejection.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 index 172a7372c..1d8a19ecc 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,10 +2,16 @@ //! //! 变体名就是线上的分流键(`type`):前端只按它选通道,**不解析任何文案**,也不对错误文本做匹配。 //! +//! 每个变体持有自己的**具名载荷类型**([`ServerAddressRejected`] … [`AuthClientInitFailed`]),不是内联 +//! 匿名结构体。ts-rs 因此生成 `{ type: 'phoneNumberInvalid' } & PhoneNumberInvalid`:前端 +//! `switch (error.type)` 的每个分支都拿到一个有名字的类型,等价于 Java 的 +//! `catch (PhoneNumberInvalid e)`,不需要 `as` 断言。载荷类型必须能序列化成 map——serde 的 +//! internally tagged 表示只接受 struct / map——所以全部是命名结构体,没有无字段变体。 +//! //! 变体按**可判定的事实**命名。服务端 400 只提供 `status + message`(平台 `AppError.code` 仍是 //! 通用 `BAD_REQUEST`),所以 400 变体按"哪条请求的输入被拒"命名(例如 -//! [`ClientAuthError::PasswordEntryInputRejected`]),不假装能区分密码长度 / 手机号格式;会话路由的 -//! `401/403` 是"登录态权威失效",登录路由的 `401` 是用户可修正的输入问题,这个区分现在由变体承担, +//! [`PasswordEntryInputRejected`]),不假装能区分密码长度 / 手机号格式;会话路由的 `401/403` 是 +//! "登录态权威失效",登录路由的 `401` 是用户可修正的输入问题,这个区分现在由变体承担, //! 不再靠 `authentication-required:` 这类文本前缀。 //! //! 每个变体都带一份可展示 `message`,文案只在 Rust 生成一次(服务端原文优先,缺失时才用调用点的 @@ -16,81 +22,218 @@ use std::fmt; use serde::Serialize; use ts_rs::TS; -/// 变体名就是线上的分流键(`type`)。 +/// 变体名就是线上的分流键(`type`),每个变体持有同名载荷类型。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] -#[serde( - tag = "type", - rename_all = "camelCase", - rename_all_fields = "camelCase" -)] +#[serde(tag = "type", rename_all = "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 }, + ServerAddressRejected(ServerAddressRejected), + PhoneNumberInvalid(PhoneNumberInvalid), + PasswordMissing(PasswordMissing), + LoginCodeMissing(LoginCodeMissing), + PasswordEntryInputRejected(PasswordEntryInputRejected), + PhoneOrPasswordMismatch(PhoneOrPasswordMismatch), + SendCodeInputRejected(SendCodeInputRejected), + SmsCodeThrottled(SmsCodeThrottled), + PhoneLoginInputRejected(PhoneLoginInputRejected), + SmsCodeInvalidOrExpired(SmsCodeInvalidOrExpired), // ---- 会话:调用方按"未登录"处理,不给用户报错 ---- - /// 会话路由 401:登录态权威失效。 - SessionAuthorityRejected { message: String }, - /// 会话路由 403:当前账号没有执行此操作的权限。 - PermissionDenied { message: String }, + SessionAuthorityRejected(SessionAuthorityRejected), + PermissionDenied(PermissionDenied), // ---- 系统:调用方处理不了,带上下文重抛 ---- - /// 连接 / 超时 / 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 }, + AuthNetworkUnavailable(AuthNetworkUnavailable), + AuthServiceUnavailable(AuthServiceUnavailable), + UnexpectedRejection(UnexpectedRejection), + AuthResponseMalformed(AuthResponseMalformed), + ClientSessionPersistFailed(ClientSessionPersistFailed), + RuntimeSessionInstallFailed(RuntimeSessionInstallFailed), + AuthClientInitFailed(AuthClientInitFailed), +} + +// ---- 业务:用户自己能改,调用方给提示,不上报 ---- + +/// 服务地址不是合法 origin / 非本机未用 HTTPS / 不在构建渠道范围内。 +#[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, +} + +/// 本地前置校验:手机号为空或格式不合法。 +#[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, +} + +/// 本地前置校验:密码为空。 +#[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, +} + +/// 本地前置校验:验证码为空。 +#[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。 +#[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, +} + +/// 响应不是合法 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, +} + +/// 本机登录凭据文件读写失败。 +#[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, +} + +/// 本机运行时会话安装 / 清理失败。 +#[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, } 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, + 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, } } @@ -99,7 +242,7 @@ impl ClientAuthError { pub(crate) fn is_authority_failure(&self) -> bool { matches!( self, - Self::SessionAuthorityRejected { .. } | Self::PermissionDenied { .. } + Self::SessionAuthorityRejected(_) | Self::PermissionDenied(_) ) } } @@ -121,123 +264,116 @@ 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 { - message: "x".to_string(), - }, + ClientAuthError::ServerAddressRejected(ServerAddressRejected { + message: text("x"), + }), "serverAddressRejected", ), ( - ClientAuthError::PhoneNumberInvalid { - message: "x".to_string(), - }, + ClientAuthError::PhoneNumberInvalid(PhoneNumberInvalid { message: text("x") }), "phoneNumberInvalid", ), ( - ClientAuthError::PasswordMissing { - message: "x".to_string(), - }, + ClientAuthError::PasswordMissing(PasswordMissing { message: text("x") }), "passwordMissing", ), ( - ClientAuthError::LoginCodeMissing { - message: "x".to_string(), - }, + ClientAuthError::LoginCodeMissing(LoginCodeMissing { message: text("x") }), "loginCodeMissing", ), ( - ClientAuthError::PasswordEntryInputRejected { - message: "x".to_string(), - }, + ClientAuthError::PasswordEntryInputRejected(PasswordEntryInputRejected { + message: text("x"), + }), "passwordEntryInputRejected", ), ( - ClientAuthError::PhoneOrPasswordMismatch { - message: "x".to_string(), - }, + ClientAuthError::PhoneOrPasswordMismatch(PhoneOrPasswordMismatch { + message: text("x"), + }), "phoneOrPasswordMismatch", ), ( - ClientAuthError::SendCodeInputRejected { - message: "x".to_string(), - }, + ClientAuthError::SendCodeInputRejected(SendCodeInputRejected { + message: text("x"), + }), "sendCodeInputRejected", ), ( - ClientAuthError::SmsCodeThrottled { - message: "x".to_string(), - }, + ClientAuthError::SmsCodeThrottled(SmsCodeThrottled { message: text("x") }), "smsCodeThrottled", ), ( - ClientAuthError::PhoneLoginInputRejected { - message: "x".to_string(), - }, + ClientAuthError::PhoneLoginInputRejected(PhoneLoginInputRejected { + message: text("x"), + }), "phoneLoginInputRejected", ), ( - ClientAuthError::SmsCodeInvalidOrExpired { - message: "x".to_string(), - }, + ClientAuthError::SmsCodeInvalidOrExpired(SmsCodeInvalidOrExpired { + message: text("x"), + }), "smsCodeInvalidOrExpired", ), ( - ClientAuthError::SessionAuthorityRejected { - message: "x".to_string(), - }, + ClientAuthError::SessionAuthorityRejected(SessionAuthorityRejected { + message: text("x"), + }), "sessionAuthorityRejected", ), ( - ClientAuthError::PermissionDenied { - message: "x".to_string(), - }, + ClientAuthError::PermissionDenied(PermissionDenied { message: text("x") }), "permissionDenied", ), ( - ClientAuthError::AuthNetworkUnavailable { - message: "x".to_string(), - }, + ClientAuthError::AuthNetworkUnavailable(AuthNetworkUnavailable { + message: text("x"), + }), "authNetworkUnavailable", ), ( - ClientAuthError::AuthServiceUnavailable { + ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: 503, - message: "x".to_string(), - }, + message: text("x"), + }), "authServiceUnavailable", ), ( - ClientAuthError::UnexpectedRejection { + ClientAuthError::UnexpectedRejection(UnexpectedRejection { status: 409, - message: "x".to_string(), - }, + message: text("x"), + }), "unexpectedRejection", ), ( - ClientAuthError::AuthResponseMalformed { - message: "x".to_string(), - }, + ClientAuthError::AuthResponseMalformed(AuthResponseMalformed { + message: text("x"), + }), "authResponseMalformed", ), ( - ClientAuthError::ClientSessionPersistFailed { - message: "x".to_string(), - }, + ClientAuthError::ClientSessionPersistFailed(ClientSessionPersistFailed { + message: text("x"), + }), "clientSessionPersistFailed", ), ( - ClientAuthError::RuntimeSessionInstallFailed { - message: "x".to_string(), - }, + ClientAuthError::RuntimeSessionInstallFailed(RuntimeSessionInstallFailed { + message: text("x"), + }), "runtimeSessionInstallFailed", ), ( - ClientAuthError::AuthClientInitFailed { - message: "x".to_string(), - }, + ClientAuthError::AuthClientInitFailed(AuthClientInitFailed { message: text("x") }), "authClientInitFailed", ), ]; @@ -256,47 +392,54 @@ mod tests { #[test] fn machine_context_fields_survive_serialization() { - let unavailable = serde_json::to_value(ClientAuthError::AuthServiceUnavailable { - status: 503, - message: "账号服务暂不可用".to_string(), - }) + let unavailable = serde_json::to_value(ClientAuthError::AuthServiceUnavailable( + 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"); + let rejection = + serde_json::to_value(ClientAuthError::UnexpectedRejection(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()); + 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 { + let error = ClientAuthError::PhoneOrPasswordMismatch(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 9cd958cb8..efb7a210c 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,7 +4,14 @@ //! 只在 Rust 内存与本进程会话快照里,refresh 凭据只写在 AppData 私有文件里。换号、登出或 //! origin 变化都会让旧身份的在途请求失败关闭;同一身份的凭据轮换不改变身份代次。 -use crate::auth_error::ClientAuthError; +use crate::auth_error::{ + AuthClientInitFailed, AuthNetworkUnavailable, AuthResponseMalformed, AuthServiceUnavailable, + ClientAuthError, ClientSessionPersistFailed, LoginCodeMissing, PasswordEntryInputRejected, + PasswordMissing, PermissionDenied, PhoneLoginInputRejected, PhoneNumberInvalid, + PhoneOrPasswordMismatch, RuntimeSessionInstallFailed, SendCodeInputRejected, + ServerAddressRejected, SessionAuthorityRejected, SmsCodeInvalidOrExpired, SmsCodeThrottled, + UnexpectedRejection, +}; use crate::http_client::agc_main_site_client_builder; use crate::platform_session::{current_platform_session, PlatformSessionSnapshot}; use reqwest::{header::SET_COOKIE, Method, StatusCode}; @@ -223,30 +230,32 @@ fn session_file_path(app: &tauri::AppHandle) -> Result app.path() .app_data_dir() .map(|root| root.join(SESSION_FILE_NAME)) - .map_err(|error| ClientAuthError::ClientSessionPersistFailed { - message: format!("无法读取 AGC 应用数据目录:{error}"), + .map_err(|error| { + ClientAuthError::ClientSessionPersistFailed(ClientSessionPersistFailed { + message: format!("无法读取 AGC 应用数据目录:{error}"), + }) }) } /// 凭据文件相关的字符串错误统一收口成凭据落盘失败变体。 fn session_persist_error(message: impl Into) -> ClientAuthError { - ClientAuthError::ClientSessionPersistFailed { + ClientAuthError::ClientSessionPersistFailed(ClientSessionPersistFailed { message: message.into(), - } + }) } /// 本机运行时安装 / 清理失败的字符串错误统一收口。 fn runtime_session_error(message: impl Into) -> ClientAuthError { - ClientAuthError::RuntimeSessionInstallFailed { + ClientAuthError::RuntimeSessionInstallFailed(RuntimeSessionInstallFailed { message: message.into(), - } + }) } /// 服务地址校验(含渠道范围门禁)失败统一收口成业务变体。 fn server_address_rejected(message: impl Into) -> ClientAuthError { - ClientAuthError::ServerAddressRejected { + ClientAuthError::ServerAddressRejected(ServerAddressRejected { message: message.into(), - } + }) } /// 校验并归一化平台服务 origin。 @@ -428,19 +437,21 @@ fn build_client() -> Result { .connect_timeout(Duration::from_secs(10)) .timeout(HTTP_TIMEOUT) .build() - .map_err(|_| ClientAuthError::AuthClientInitFailed { - message: "创建登录网络客户端失败".to_string(), + .map_err(|_| { + ClientAuthError::AuthClientInitFailed(AuthClientInitFailed { + message: "创建登录网络客户端失败".to_string(), + }) }) } fn network_error_message(error: &reqwest::Error) -> ClientAuthError { - ClientAuthError::AuthNetworkUnavailable { + ClientAuthError::AuthNetworkUnavailable(AuthNetworkUnavailable { message: if error.is_timeout() { AUTH_NETWORK_TIMEOUT.to_string() } else { AUTH_NETWORK_ERROR.to_string() }, - } + }) } fn error_message(body: &str) -> Option { @@ -508,79 +519,92 @@ fn map_auth_failure( }; if status == StatusCode::UNAUTHORIZED { 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 { + 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()), + }) + } + _ => ClientAuthError::UnexpectedRejection(UnexpectedRejection { status: status_code, message: server_message.unwrap_or_else(|| fallback.to_string()), - }, + }), }; } if status == StatusCode::FORBIDDEN { return match route { - AuthRoute::Session => ClientAuthError::PermissionDenied { + AuthRoute::Session => ClientAuthError::PermissionDenied(PermissionDenied { message: server_message .unwrap_or_else(|| "当前陶泥儿账号没有执行此操作的权限".to_string()), - }, - _ => ClientAuthError::UnexpectedRejection { + }), + _ => ClientAuthError::UnexpectedRejection(UnexpectedRejection { status: status_code, message: server_message.unwrap_or_else(|| fallback.to_string()), - }, + }), }; } if status == StatusCode::TOO_MANY_REQUESTS && route == AuthRoute::SendCode { - return ClientAuthError::SmsCodeThrottled { + return ClientAuthError::SmsCodeThrottled(SmsCodeThrottled { message: prefixed(), - }; + }); } if status == StatusCode::BAD_REQUEST { return match route { - AuthRoute::PasswordEntry => ClientAuthError::PasswordEntryInputRejected { + AuthRoute::PasswordEntry => { + ClientAuthError::PasswordEntryInputRejected(PasswordEntryInputRejected { + message: prefixed(), + }) + } + AuthRoute::PhoneLogin => { + ClientAuthError::PhoneLoginInputRejected(PhoneLoginInputRejected { + message: prefixed(), + }) + } + AuthRoute::SendCode => ClientAuthError::SendCodeInputRejected(SendCodeInputRejected { message: prefixed(), - }, - AuthRoute::PhoneLogin => ClientAuthError::PhoneLoginInputRejected { - message: prefixed(), - }, - AuthRoute::SendCode => ClientAuthError::SendCodeInputRejected { - message: prefixed(), - }, - _ => ClientAuthError::UnexpectedRejection { + }), + _ => ClientAuthError::UnexpectedRejection(UnexpectedRejection { status: status_code, message: prefixed(), - }, + }), }; } if status.is_server_error() { - return ClientAuthError::AuthServiceUnavailable { + return ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: status_code, message: prefixed(), - }; + }); } - ClientAuthError::UnexpectedRejection { + ClientAuthError::UnexpectedRejection(UnexpectedRejection { status: status_code, message: prefixed(), - } + }) } fn response_data(body: &str, fallback: &str) -> Result { - let value: Value = - serde_json::from_str(body).map_err(|_| ClientAuthError::AuthResponseMalformed { + let value: Value = serde_json::from_str(body).map_err(|_| { + ClientAuthError::AuthResponseMalformed(AuthResponseMalformed { message: format!("{fallback}:登录服务响应不是合法 JSON"), - })?; + }) + })?; if value.get("ok").and_then(Value::as_bool) == Some(false) { - return Err(ClientAuthError::AuthResponseMalformed { - message: format!( - "{fallback}:{}", - error_message(body).unwrap_or_else(|| "登录服务请求失败".to_string()) - ), - }); + return Err(ClientAuthError::AuthResponseMalformed( + AuthResponseMalformed { + message: format!( + "{fallback}:{}", + error_message(body).unwrap_or_else(|| "登录服务请求失败".to_string()) + ), + }, + )); } Ok(value.get("data").cloned().unwrap_or(value)) } @@ -657,19 +681,20 @@ async fn request_auth( CookiePolicy::Capture | CookiePolicy::Require => refresh_cookie_from_response(&response), CookiePolicy::Ignore => None, }; - let text = response - .text() - .await - .map_err(|_| ClientAuthError::AuthResponseMalformed { + let text = response.text().await.map_err(|_| { + ClientAuthError::AuthResponseMalformed(AuthResponseMalformed { message: format!("{fallback}:读取响应失败"), - })?; + }) + })?; if !status.is_success() { return Err(map_auth_failure(status, &text, fallback, auth_route(route))); } if matches!(policy, CookiePolicy::Require) && captured.is_none() { - return Err(ClientAuthError::AuthResponseMalformed { - message: "登录服务未返回新的续期凭据,已停止使用旧凭据".to_string(), - }); + return Err(ClientAuthError::AuthResponseMalformed( + AuthResponseMalformed { + message: "登录服务未返回新的续期凭据,已停止使用旧凭据".to_string(), + }, + )); } Ok(AuthResponse { data: response_data(&text, fallback)?, @@ -708,9 +733,11 @@ async fn commit_authenticated_session( identity_change: bool, ) -> Result { if token.chars().count() > MAX_SECRET_CHARS { - return Err(ClientAuthError::AuthResponseMalformed { - message: "登录服务返回的凭据无效".to_string(), - }); + return Err(ClientAuthError::AuthResponseMalformed( + AuthResponseMalformed { + message: "登录服务返回的凭据无效".to_string(), + }, + )); } // 主体先于凭据落盘解析:没有主体就不写 client-session.json,避免留下半截会话文件。 let (user_id, known_user) = identity.resolve()?; @@ -802,9 +829,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 { - message: "登录失败:登录服务未返回用户身份".to_string(), - }); + return Err(ClientAuthError::AuthResponseMalformed( + AuthResponseMalformed { + message: "登录失败:登录服务未返回用户身份".to_string(), + }, + )); } Ok(user_id.to_string()) } @@ -837,9 +866,9 @@ async fn fetch_current_user( ) .await?; let me: MeResponse = serde_json::from_value(response.data).map_err(|_| { - ClientAuthError::AuthResponseMalformed { + ClientAuthError::AuthResponseMalformed(AuthResponseMalformed { message: "读取当前用户失败:响应格式无效".to_string(), - } + }) })?; Ok(me.user) } @@ -917,17 +946,16 @@ async fn refresh_session_inner( } }; let token: TokenResponse = serde_json::from_value(refreshed.data).map_err(|_| { - ClientAuthError::AuthResponseMalformed { + ClientAuthError::AuthResponseMalformed(AuthResponseMalformed { message: "刷新登录状态失败:凭据响应格式无效".to_string(), - } + }) })?; let install_token = token.token.clone(); - let new_cookie = - refreshed - .refresh_cookie - .ok_or_else(|| ClientAuthError::AuthResponseMalformed { - message: "刷新登录状态失败:缺少新的续期凭据".to_string(), - })?; + let new_cookie = refreshed.refresh_cookie.ok_or_else(|| { + ClientAuthError::AuthResponseMalformed(AuthResponseMalformed { + message: "刷新登录状态失败:缺少新的续期凭据".to_string(), + }) + })?; commit_authenticated_session( app, &session.api_base_url, @@ -1073,9 +1101,9 @@ pub(crate) async fn send_client_phone_login_code( 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(ClientAuthError::PhoneNumberInvalid { + return Err(ClientAuthError::PhoneNumberInvalid(PhoneNumberInvalid { message: "请输入正确的手机号".to_string(), - }); + })); } let client = build_client()?; let response = request_auth( @@ -1094,9 +1122,9 @@ pub(crate) async fn send_client_phone_login_code( ) .await?; let payload: SendCodeResponse = serde_json::from_value(response.data).map_err(|_| { - ClientAuthError::AuthResponseMalformed { + ClientAuthError::AuthResponseMalformed(AuthResponseMalformed { message: "发送验证码失败:响应格式无效".to_string(), - } + }) })?; Ok(ClientLoginCodeView { cooldown_seconds: payload.cooldown_seconds, @@ -1110,15 +1138,15 @@ async fn complete_login( response: AuthResponse, ) -> Result { let payload: TokenUserResponse = serde_json::from_value(response.data).map_err(|_| { - ClientAuthError::AuthResponseMalformed { + ClientAuthError::AuthResponseMalformed(AuthResponseMalformed { message: "登录失败:登录服务响应格式无效".to_string(), - } + }) })?; - let cookie = response - .refresh_cookie - .ok_or_else(|| ClientAuthError::AuthResponseMalformed { + let cookie = response.refresh_cookie.ok_or_else(|| { + ClientAuthError::AuthResponseMalformed(AuthResponseMalformed { message: "登录失败:登录服务未返回续期凭据".to_string(), - })?; + }) + })?; let user = commit_authenticated_session( app, origin, @@ -1148,14 +1176,14 @@ pub(crate) async fn login_client_with_password( 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(ClientAuthError::PhoneNumberInvalid { + return Err(ClientAuthError::PhoneNumberInvalid(PhoneNumberInvalid { message: "请输入正确的手机号".to_string(), - }); + })); } if password.trim().is_empty() { - return Err(ClientAuthError::PasswordMissing { + return Err(ClientAuthError::PasswordMissing(PasswordMissing { message: "请输入密码".to_string(), - }); + })); } let client = build_client()?; let response = request_auth( @@ -1186,14 +1214,14 @@ pub(crate) async fn login_client_with_phone_code( 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(ClientAuthError::PhoneNumberInvalid { + return Err(ClientAuthError::PhoneNumberInvalid(PhoneNumberInvalid { message: "请输入正确的手机号".to_string(), - }); + })); } if code.trim().is_empty() { - return Err(ClientAuthError::LoginCodeMissing { + return Err(ClientAuthError::LoginCodeMissing(LoginCodeMissing { message: "请输入验证码".to_string(), - }); + })); } let client = build_client()?; let response = request_auth( @@ -1321,7 +1349,7 @@ mod tests { assert!(!transient.is_authority_failure()); assert!(matches!( transient, - ClientAuthError::AuthServiceUnavailable { status: 500, .. } + ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: 500, .. }) )); assert!(transient.message().starts_with("刷新失败")); } @@ -1336,9 +1364,9 @@ mod tests { ); assert_eq!( wrong_password, - ClientAuthError::PhoneOrPasswordMismatch { + ClientAuthError::PhoneOrPasswordMismatch(PhoneOrPasswordMismatch { message: "手机号或密码错误".to_string() - } + }) ); assert!(!wrong_password.is_authority_failure()); @@ -1361,7 +1389,7 @@ mod tests { ); assert!(matches!( password_length, - ClientAuthError::PasswordEntryInputRejected { .. } + ClientAuthError::PasswordEntryInputRejected(PasswordEntryInputRejected { .. }) )); assert_eq!( password_length.message(), @@ -1376,7 +1404,7 @@ mod tests { ); assert!(matches!( throttled, - ClientAuthError::SmsCodeThrottled { .. } + ClientAuthError::SmsCodeThrottled(SmsCodeThrottled { .. }) )); assert_eq!(throttled.message(), "发送验证码失败:发送过于频繁"); @@ -1388,7 +1416,7 @@ mod tests { ); assert!(matches!( bad_code, - ClientAuthError::SmsCodeInvalidOrExpired { .. } + ClientAuthError::SmsCodeInvalidOrExpired(SmsCodeInvalidOrExpired { .. }) )); assert_eq!(bad_code.message(), "验证码错误"); } diff --git a/apps/ai-game-creator-shell/src/services/generated/AuthClientInitFailed.ts b/apps/ai-game-creator-shell/src/services/generated/AuthClientInitFailed.ts new file mode 100644 index 000000000..c714a8c25 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/AuthClientInitFailed.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 认证网络客户端构建失败。 + */ +export type AuthClientInitFailed = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/AuthNetworkUnavailable.ts b/apps/ai-game-creator-shell/src/services/generated/AuthNetworkUnavailable.ts new file mode 100644 index 000000000..87df5480a --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/AuthNetworkUnavailable.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 连接 / 超时 / DNS 等网络失败。 + */ +export type AuthNetworkUnavailable = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/AuthResponseMalformed.ts b/apps/ai-game-creator-shell/src/services/generated/AuthResponseMalformed.ts new file mode 100644 index 000000000..e7fa63683 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/AuthResponseMalformed.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 响应不是合法 JSON、缺少必需字段或契约不成立。 + */ +export type AuthResponseMalformed = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/AuthServiceUnavailable.ts b/apps/ai-game-creator-shell/src/services/generated/AuthServiceUnavailable.ts new file mode 100644 index 000000000..ea46b2edd --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/AuthServiceUnavailable.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 服务端 5xx。 + */ +export type AuthServiceUnavailable = { status: number; message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts b/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts index c80d66ae0..fb7dc66ce 100644 --- a/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts +++ b/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts @@ -1,25 +1,44 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AuthClientInitFailed } from './AuthClientInitFailed'; +import type { AuthNetworkUnavailable } from './AuthNetworkUnavailable'; +import type { AuthResponseMalformed } from './AuthResponseMalformed'; +import type { AuthServiceUnavailable } from './AuthServiceUnavailable'; +import type { ClientSessionPersistFailed } from './ClientSessionPersistFailed'; +import type { LoginCodeMissing } from './LoginCodeMissing'; +import type { PasswordEntryInputRejected } from './PasswordEntryInputRejected'; +import type { PasswordMissing } from './PasswordMissing'; +import type { PermissionDenied } from './PermissionDenied'; +import type { PhoneLoginInputRejected } from './PhoneLoginInputRejected'; +import type { PhoneNumberInvalid } from './PhoneNumberInvalid'; +import type { PhoneOrPasswordMismatch } from './PhoneOrPasswordMismatch'; +import type { RuntimeSessionInstallFailed } from './RuntimeSessionInstallFailed'; +import type { SendCodeInputRejected } from './SendCodeInputRejected'; +import type { ServerAddressRejected } from './ServerAddressRejected'; +import type { SessionAuthorityRejected } from './SessionAuthorityRejected'; +import type { SmsCodeInvalidOrExpired } from './SmsCodeInvalidOrExpired'; +import type { SmsCodeThrottled } from './SmsCodeThrottled'; +import type { UnexpectedRejection } from './UnexpectedRejection'; /** - * 变体名就是线上的分流键(`type`)。 + * 变体名就是线上的分流键(`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 }; + | ({ type: 'serverAddressRejected' } & ServerAddressRejected) + | ({ type: 'phoneNumberInvalid' } & PhoneNumberInvalid) + | ({ type: 'passwordMissing' } & PasswordMissing) + | ({ type: 'loginCodeMissing' } & LoginCodeMissing) + | ({ type: 'passwordEntryInputRejected' } & PasswordEntryInputRejected) + | ({ type: 'phoneOrPasswordMismatch' } & PhoneOrPasswordMismatch) + | ({ type: 'sendCodeInputRejected' } & SendCodeInputRejected) + | ({ type: 'smsCodeThrottled' } & SmsCodeThrottled) + | ({ type: 'phoneLoginInputRejected' } & PhoneLoginInputRejected) + | ({ type: 'smsCodeInvalidOrExpired' } & SmsCodeInvalidOrExpired) + | ({ type: 'sessionAuthorityRejected' } & SessionAuthorityRejected) + | ({ type: 'permissionDenied' } & PermissionDenied) + | ({ type: 'authNetworkUnavailable' } & AuthNetworkUnavailable) + | ({ type: 'authServiceUnavailable' } & AuthServiceUnavailable) + | ({ type: 'unexpectedRejection' } & UnexpectedRejection) + | ({ type: 'authResponseMalformed' } & AuthResponseMalformed) + | ({ type: 'clientSessionPersistFailed' } & ClientSessionPersistFailed) + | ({ type: 'runtimeSessionInstallFailed' } & RuntimeSessionInstallFailed) + | ({ type: 'authClientInitFailed' } & AuthClientInitFailed); diff --git a/apps/ai-game-creator-shell/src/services/generated/ClientSessionPersistFailed.ts b/apps/ai-game-creator-shell/src/services/generated/ClientSessionPersistFailed.ts new file mode 100644 index 000000000..ca606de34 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/ClientSessionPersistFailed.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 本机登录凭据文件读写失败。 + */ +export type ClientSessionPersistFailed = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/LoginCodeMissing.ts b/apps/ai-game-creator-shell/src/services/generated/LoginCodeMissing.ts new file mode 100644 index 000000000..f2aba1175 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/LoginCodeMissing.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 本地前置校验:验证码为空。 + */ +export type LoginCodeMissing = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/PasswordEntryInputRejected.ts b/apps/ai-game-creator-shell/src/services/generated/PasswordEntryInputRejected.ts new file mode 100644 index 000000000..b301d0721 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/PasswordEntryInputRejected.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * `/api/auth/entry` 返回 400:服务端拒绝本次输入。 + */ +export type PasswordEntryInputRejected = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/PasswordMissing.ts b/apps/ai-game-creator-shell/src/services/generated/PasswordMissing.ts new file mode 100644 index 000000000..4ebefdc04 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/PasswordMissing.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 本地前置校验:密码为空。 + */ +export type PasswordMissing = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/PermissionDenied.ts b/apps/ai-game-creator-shell/src/services/generated/PermissionDenied.ts new file mode 100644 index 000000000..95f45c21b --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/PermissionDenied.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 会话路由 403:当前账号没有执行此操作的权限。 + */ +export type PermissionDenied = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/PhoneLoginInputRejected.ts b/apps/ai-game-creator-shell/src/services/generated/PhoneLoginInputRejected.ts new file mode 100644 index 000000000..0e6ddc711 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/PhoneLoginInputRejected.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * `/api/auth/phone/login` 返回 400。 + */ +export type PhoneLoginInputRejected = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/PhoneNumberInvalid.ts b/apps/ai-game-creator-shell/src/services/generated/PhoneNumberInvalid.ts new file mode 100644 index 000000000..93946112e --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/PhoneNumberInvalid.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 本地前置校验:手机号为空或格式不合法。 + */ +export type PhoneNumberInvalid = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/PhoneOrPasswordMismatch.ts b/apps/ai-game-creator-shell/src/services/generated/PhoneOrPasswordMismatch.ts new file mode 100644 index 000000000..e7dc6f7a7 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/PhoneOrPasswordMismatch.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * `/api/auth/entry` 返回 401:手机号或密码错误。 + */ +export type PhoneOrPasswordMismatch = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/RuntimeSessionInstallFailed.ts b/apps/ai-game-creator-shell/src/services/generated/RuntimeSessionInstallFailed.ts new file mode 100644 index 000000000..57a6af5a9 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/RuntimeSessionInstallFailed.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 本机运行时会话安装 / 清理失败。 + */ +export type RuntimeSessionInstallFailed = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/SendCodeInputRejected.ts b/apps/ai-game-creator-shell/src/services/generated/SendCodeInputRejected.ts new file mode 100644 index 000000000..df085f092 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/SendCodeInputRejected.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * `/api/auth/phone/send-code` 返回 400。 + */ +export type SendCodeInputRejected = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/ServerAddressRejected.ts b/apps/ai-game-creator-shell/src/services/generated/ServerAddressRejected.ts new file mode 100644 index 000000000..c37d41541 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/ServerAddressRejected.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 服务地址不是合法 origin / 非本机未用 HTTPS / 不在构建渠道范围内。 + */ +export type ServerAddressRejected = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/SessionAuthorityRejected.ts b/apps/ai-game-creator-shell/src/services/generated/SessionAuthorityRejected.ts new file mode 100644 index 000000000..3b145b00f --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/SessionAuthorityRejected.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 会话路由 401:登录态权威失效。 + */ +export type SessionAuthorityRejected = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/SmsCodeInvalidOrExpired.ts b/apps/ai-game-creator-shell/src/services/generated/SmsCodeInvalidOrExpired.ts new file mode 100644 index 000000000..031366003 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/SmsCodeInvalidOrExpired.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * `/api/auth/phone/login` 返回 401:验证码错误或过期。 + */ +export type SmsCodeInvalidOrExpired = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/SmsCodeThrottled.ts b/apps/ai-game-creator-shell/src/services/generated/SmsCodeThrottled.ts new file mode 100644 index 000000000..7202e4abc --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/SmsCodeThrottled.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * `/api/auth/phone/send-code` 返回 429:发送过于频繁。 + */ +export type SmsCodeThrottled = { message: string }; diff --git a/apps/ai-game-creator-shell/src/services/generated/UnexpectedRejection.ts b/apps/ai-game-creator-shell/src/services/generated/UnexpectedRejection.ts new file mode 100644 index 000000000..8a26dc85b --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/generated/UnexpectedRejection.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 其它未识别的拒绝(未列举的 4xx、登录路由 403 等)。 + */ +export type UnexpectedRejection = { status: number; message: string }; diff --git a/apps/ai-game-creator-shell/tests/clientAuthError.test.ts b/apps/ai-game-creator-shell/tests/clientAuthError.test.ts index 83ec77c2a..584a63cd2 100644 --- a/apps/ai-game-creator-shell/tests/clientAuthError.test.ts +++ b/apps/ai-game-creator-shell/tests/clientAuthError.test.ts @@ -1,8 +1,46 @@ import { describe, expect, it } from 'vitest'; import { isClientAuthError } from '../src/services/clientAuthError'; +import type { AuthServiceUnavailable } from '../src/services/generated/AuthServiceUnavailable'; +import type { ClientAuthError } from '../src/services/generated/ClientAuthError'; +import type { PhoneNumberInvalid } from '../src/services/generated/PhoneNumberInvalid'; + +/** + * 编译期用例:每个变体在 `type` 上判别后都能落到一个**具名载荷类型**, + * 等价于 Java 的 `catch (PhoneNumberInvalid e)`;类型不匹配时 `tsc` 会直接报错。 + */ +function handleByNamedType(error: ClientAuthError) { + switch (error.type) { + case 'phoneNumberInvalid': { + const payload: PhoneNumberInvalid = error; + return payload.message; + } + case 'authServiceUnavailable': { + const payload: AuthServiceUnavailable = error; + return `${payload.status}:${payload.message}`; + } + default: + return ''; + } +} describe('AGC 认证命令错误的形状读取', () => { + it('判别后按具名载荷类型分支处理', () => { + expect( + handleByNamedType({ + type: 'phoneNumberInvalid', + message: '请输入正确的手机号', + }), + ).toBe('请输入正确的手机号'); + expect( + handleByNamedType({ + type: 'authServiceUnavailable', + status: 503, + message: '登录服务暂时不可用', + }), + ).toBe('503:登录服务暂时不可用'); + }); + it('只按形状读取 Rust 的结构化拒绝,裸字符串与 Error 都不算', () => { expect( isClientAuthError({