//! AGC 认证命令的结构化错误:从命令入口到出口只传这一种错误。 //! //! 变体名就是线上的分流键(`type`):前端只按它选通道,**不解析任何文案**,也不对错误文本做匹配。 //! //! 顶层只放**调用方要分流的类别**;同一类里可枚举的细分原因收进**类型化 `reason` 字段**, //! 而不是各拆一个变体,也不是字符串。例如服务地址校验的 7 种失败、网络失败的超时/不可达、 //! 响应契约的 5 种破损各自只占一个变体:前端 `switch (failure.type)` 选到类别后,再用 //! `switch (payload.reason)` 在**类型化**的细分上分流,仍然不碰文案。 //! //! Rust **不预拼用户可见文案**:载荷只装原始事实(服务端 400 的原文、HTTP 状态码、本机 IO / //! 网络客户端构建失败的原始错误 `detail`),服务端没给原文就是 `None`;前缀与句式由前端调用方在 //! 自己的 catch 分支按当前操作拼接。`detail` 是给调用方分流与诊断用的原始事实,**不直接贴在界面上**; //! 报告侧由 sanitize 把路径等替换成占位符。 use serde::Serialize; use ts_rs::TS; /// 变体名就是线上的分流键(`type`),带载荷的变体持有同名载荷类型。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] #[serde(tag = "type", rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] pub(crate) enum ClientAuthError { // ---- 业务:用户自己能改,调用方给提示,不上报 ---- /// 服务地址校验失败(含渠道范围门禁),细分原因见 [`ServerAddressReason`]。 ServerAddressRejected(ServerAddressRejected), /// 本地前置校验:手机号为空、超长或不是纯数字(校验器只回 bool,没有更细的事实)。 PhoneNumberInvalid, /// 本地前置校验:密码为空。 PasswordMissing, /// 本地前置校验:验证码为空。 LoginCodeMissing, /// `/api/auth/entry` 返回 400:服务端拒绝本次输入。 PasswordLoginRejected(PasswordLoginRejected), /// `/api/auth/entry` 返回 401:手机号或密码错误。 PhoneOrPasswordMismatch, /// `/api/auth/phone/send-code` 返回 400:服务端拒绝本次输入。 SendCodeRejected(SendCodeRejected), /// `/api/auth/phone/send-code` 返回 429:发送过于频繁。 SmsCodeThrottled, /// `/api/auth/phone/login` 返回 400/401:服务端拒绝本次验证码登录。 /// /// 401 目前只来自「用户不存在」;该路由验证通过后会即时建号,所以这条分支实际很少触发。 PhoneCodeLoginRejected(PhoneCodeLoginRejected), // ---- 会话:调用方按"未登录"处理,不给用户报错 ---- /// 会话路由 401:登录态失效。 SessionInvalidated, /// 会话路由 403:当前账号没有执行此操作的权限。 PermissionDenied, // ---- 系统:调用方处理不了,带上下文重抛 ---- /// 连接登录服务的传输层失败,细分原因见 [`AuthNetworkReason`]。 AuthNetworkFailure(AuthNetworkFailure), /// 登录服务 5xx。 AuthServiceUnavailable(AuthServiceUnavailable), /// 其它未识别的拒绝(未列举的 4xx、登录路由 403 等)。 UnexpectedRejection(UnexpectedRejection), /// 登录服务响应的契约破损,细分原因见 [`AuthResponseInvalidReason`]。 AuthResponseInvalid(AuthResponseInvalid), /// 本机登录凭据文件读写失败;`detail` 是原始错误,供调用方与诊断用、不直接展示。 ClientSessionPersistFailed(ClientSessionPersistFailed), /// 本机运行时会话安装 / 清理失败;`detail` 同上。 RuntimeSessionInstallFailed(RuntimeSessionInstallFailed), /// 认证网络客户端构建失败;`detail` 是原始错误。 AuthClientInitFailed(AuthClientInitFailed), } // ---- 可枚举的细分原因:类型化字段,不是字符串 ---- /// 服务地址校验失败的具体原因。 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] pub(crate) enum ServerAddressReason { /// 为空或超长。 EmptyOrTooLong, /// 不是合法 URL。 NotAUrl, /// 带用户名 / 密码。 HasCredentials, /// 带路径、查询或 fragment。 HasPathOrQueryOrFragment, /// 非本机地址不是 HTTPS。 NotHttps, /// scheme 不是 http(s)。 UnsupportedScheme, /// 发布构建里不在当前构建渠道范围内。 OutsideChannel, } /// 连接登录服务失败的具体原因。 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] pub(crate) enum AuthNetworkReason { /// 超时。 Timeout, /// DNS / 连接被拒 / 读响应失败等。 Unreachable, } /// 登录响应契约破损的具体原因。 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] pub(crate) enum AuthResponseInvalidReason { /// 不是合法 JSON。 NotJson, /// 不是预期结构(缺字段 / 类型不符 / 凭据格式无效)。 InvalidBody, /// 没有下发新的续期凭据。 MissingRefreshCookie, /// 没有带上会话主体(用户身份)。 MissingUserIdentity, /// 响应体里显式拒绝(`ok: false`)。 ServerRejected, } // ---- 载荷:只装类型说不出来的事实 ---- /// 服务地址被拒的具体原因。 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] pub(crate) struct ServerAddressRejected { pub(crate) reason: ServerAddressReason, } /// `/api/auth/entry` 返回 400 时服务端给的原文。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] pub(crate) struct PasswordLoginRejected { /// 服务端原文可能缺失:缺失是 `null`,Rust 不编造兜底文案。 pub(crate) server_message: Option, } /// `/api/auth/phone/send-code` 返回 400 时服务端给的原文。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] pub(crate) struct SendCodeRejected { /// 服务端原文可能缺失:缺失是 `null`,Rust 不编造兜底文案。 pub(crate) server_message: Option, } /// `/api/auth/phone/login` 返回 400 时服务端给的原文。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] pub(crate) struct PhoneCodeLoginRejected { /// 服务端原文可能缺失:缺失是 `null`,Rust 不编造兜底文案。 pub(crate) server_message: Option, } /// 连接登录服务的传输层失败原因。 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] pub(crate) struct AuthNetworkFailure { pub(crate) reason: AuthNetworkReason, } /// 登录服务 5xx 的状态码。 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] pub(crate) struct AuthServiceUnavailable { pub(crate) status: u16, } /// 其它未识别拒绝的状态码与服务端原文。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] pub(crate) struct UnexpectedRejection { pub(crate) status: u16, pub(crate) server_message: Option, } /// 登录响应契约破损的原因与服务端原文。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] pub(crate) struct AuthResponseInvalid { pub(crate) reason: AuthResponseInvalidReason, /// 只有 `serverRejected` 可能带服务端原文;其余是 `null`。 pub(crate) server_message: Option, } /// 本机凭据文件读写的原始错误明细。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] pub(crate) struct ClientSessionPersistFailed { pub(crate) detail: String, } /// 本机运行时会话安装 / 清理的原始错误明细。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] pub(crate) struct RuntimeSessionInstallFailed { pub(crate) detail: String, } /// 认证网络客户端构建失败的原始错误明细。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] pub(crate) struct AuthClientInitFailed { pub(crate) detail: String, } impl ClientAuthError { /// 会话路由的 401/403 是「登录态失效」:调用方据此清会话、按未登录处理, /// 既不给用户报错,也不进错误报告池。 pub(crate) fn is_authority_failure(&self) -> bool { matches!(self, Self::SessionInvalidated | Self::PermissionDenied) } } #[cfg(test)] mod tests { use super::*; #[test] fn wire_variant_names_are_the_frontend_dispatch_keys() { let cases = [ ( ClientAuthError::ServerAddressRejected(ServerAddressRejected { reason: ServerAddressReason::NotHttps, }), "serverAddressRejected", ), (ClientAuthError::PhoneNumberInvalid, "phoneNumberInvalid"), (ClientAuthError::PasswordMissing, "passwordMissing"), (ClientAuthError::LoginCodeMissing, "loginCodeMissing"), ( ClientAuthError::PasswordLoginRejected(PasswordLoginRejected { server_message: Some("x".to_string()), }), "passwordLoginRejected", ), ( ClientAuthError::PhoneOrPasswordMismatch, "phoneOrPasswordMismatch", ), ( ClientAuthError::SendCodeRejected(SendCodeRejected { server_message: Some("x".to_string()), }), "sendCodeRejected", ), (ClientAuthError::SmsCodeThrottled, "smsCodeThrottled"), ( ClientAuthError::PhoneCodeLoginRejected(PhoneCodeLoginRejected { server_message: Some("x".to_string()), }), "phoneCodeLoginRejected", ), (ClientAuthError::SessionInvalidated, "sessionInvalidated"), (ClientAuthError::PermissionDenied, "permissionDenied"), ( ClientAuthError::AuthNetworkFailure(AuthNetworkFailure { reason: AuthNetworkReason::Timeout, }), "authNetworkFailure", ), ( ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: 503 }), "authServiceUnavailable", ), ( ClientAuthError::UnexpectedRejection(UnexpectedRejection { status: 409, server_message: None, }), "unexpectedRejection", ), ( ClientAuthError::AuthResponseInvalid(AuthResponseInvalid { reason: AuthResponseInvalidReason::ServerRejected, server_message: None, }), "authResponseInvalid", ), ( ClientAuthError::ClientSessionPersistFailed(ClientSessionPersistFailed { detail: "x".to_string(), }), "clientSessionPersistFailed", ), ( ClientAuthError::RuntimeSessionInstallFailed(RuntimeSessionInstallFailed { detail: "x".to_string(), }), "runtimeSessionInstallFailed", ), ( ClientAuthError::AuthClientInitFailed(AuthClientInitFailed { detail: "x".to_string(), }), "authClientInitFailed", ), ]; for (error, expected_type) in cases { let value = serde_json::to_value(&error).expect("serialize auth error"); assert_eq!( value.get("type").and_then(|value| value.as_str()), Some(expected_type) ); } } #[test] fn unit_variants_serialize_without_payload_fields() { let value = serde_json::to_value(ClientAuthError::LoginCodeMissing).expect("serialize auth error"); assert_eq!(value, serde_json::json!({ "type": "loginCodeMissing" })); } #[test] fn reason_fields_are_typed_enums_not_strings() { let rejected = serde_json::to_value(ClientAuthError::ServerAddressRejected( ServerAddressRejected { reason: ServerAddressReason::HasPathOrQueryOrFragment, }, )) .expect("serialize auth error"); assert_eq!(rejected["type"], "serverAddressRejected"); assert_eq!(rejected["reason"], "hasPathOrQueryOrFragment"); let network = serde_json::to_value(ClientAuthError::AuthNetworkFailure(AuthNetworkFailure { reason: AuthNetworkReason::Unreachable, })) .expect("serialize auth error"); assert_eq!(network["reason"], "unreachable"); } #[test] fn payload_variants_keep_machine_facts_and_server_text() { let unavailable = serde_json::to_value(ClientAuthError::AuthServiceUnavailable( AuthServiceUnavailable { status: 503 }, )) .expect("serialize auth error"); assert_eq!(unavailable["type"], "authServiceUnavailable"); assert_eq!(unavailable["status"], 503); let rejection = serde_json::to_value(ClientAuthError::UnexpectedRejection(UnexpectedRejection { status: 409, server_message: Some("冲突".to_string()), })) .expect("serialize auth error"); assert_eq!(rejection["status"], 409); assert_eq!(rejection["serverMessage"], "冲突"); let invalid = serde_json::to_value(ClientAuthError::AuthResponseInvalid(AuthResponseInvalid { reason: AuthResponseInvalidReason::ServerRejected, server_message: Some("登录服务请求失败".to_string()), })) .expect("serialize auth error"); assert_eq!(invalid["reason"], "serverRejected"); assert_eq!(invalid["serverMessage"], "登录服务请求失败"); let persist = serde_json::to_value(ClientAuthError::ClientSessionPersistFailed( ClientSessionPersistFailed { detail: "磁盘只读".to_string(), }, )) .expect("serialize auth error"); assert_eq!(persist["detail"], "磁盘只读"); let init = serde_json::to_value(ClientAuthError::AuthClientInitFailed( AuthClientInitFailed { detail: "tls handshake failed".to_string(), }, )) .expect("serialize auth error"); assert_eq!(init["type"], "authClientInitFailed"); assert_eq!(init["detail"], "tls handshake failed"); } #[test] fn only_session_failures_count_as_authority_failures() { assert!(ClientAuthError::SessionInvalidated.is_authority_failure()); assert!(ClientAuthError::PermissionDenied.is_authority_failure()); assert!(!ClientAuthError::PhoneOrPasswordMismatch.is_authority_failure()); assert!(!ClientAuthError::AuthNetworkFailure(AuthNetworkFailure { reason: AuthNetworkReason::Timeout, }) .is_authority_failure()); } }