Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs
T
k88936 254f2767cc AGC认证错误改为顶层类别加类型化reason
- auth_error.rs:顶层从拍平变体收敛为类别变体,服务地址/网络失败/响应契约破损各占一个变体,不再拆成 7+2+5 个
- auth_error.rs:新增 ServerAddressReason、AuthNetworkReason、AuthResponseInvalidReason 三个 ts-rs 枚举,payload 改持类型化 reason
- auth_session.rs:校验与映射改用 server_address_rejected / response_invalid 收口,按类型化 reason 构造
2026-10-02 02:42:17 +08:00

378 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! AGC 认证命令的结构化错误:从命令入口到出口只传这一种错误。
//!
//! 变体名就是线上的分流键(`type`):前端只按它选通道,**不解析任何文案**,也不对错误文本做匹配。
//!
//! 顶层只放**调用方要分流的类别**;同一类里可枚举的细分原因收进**类型化 `reason` 字段**,
//! 而不是各拆一个变体,也不是字符串。例如服务地址校验的 7 种失败、网络失败的超时/不可达、
//! 响应契约的 5 种破损各自只占一个变体:前端 `switch (failure.type)` 选到类别后,再用
//! `switch (payload.reason)` 在**类型化**的细分上分流,仍然不碰文案。
//!
//! Rust **不预拼用户可见文案**:载荷只装原始事实(服务端 400 的原文、HTTP 状态码、本机 IO 的
//! `detail`),服务端没给原文就是 `None`;前缀与句式由前端调用方在自己的 catch 分支按当前操作拼接。
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:服务端拒绝本次输入。
PhoneCodeLoginRejected(PhoneCodeLoginRejected),
/// `/api/auth/phone/login` 返回 401:验证码错误或过期。
SmsCodeRejected,
// ---- 会话:调用方按"未登录"处理,不给用户报错 ----
/// 会话路由 401:登录态失效。
SessionInvalidated,
/// 会话路由 403:当前账号没有执行此操作的权限。
PermissionDenied,
// ---- 系统:调用方处理不了,带上下文重抛 ----
/// 连接登录服务的传输层失败,细分原因见 [`AuthNetworkReason`]。
AuthNetworkFailure(AuthNetworkFailure),
/// 登录服务 5xx。
AuthServiceUnavailable(AuthServiceUnavailable),
/// 其它未识别的拒绝(未列举的 4xx、登录路由 403 等)。
UnexpectedRejection(UnexpectedRejection),
/// 登录服务响应的契约破损,细分原因见 [`AuthResponseInvalidReason`]。
AuthResponseInvalid(AuthResponseInvalid),
/// 本机登录凭据文件读写失败。
ClientSessionPersistFailed(ClientSessionPersistFailed),
/// 本机运行时会话安装 / 清理失败。
RuntimeSessionInstallFailed(RuntimeSessionInstallFailed),
/// 认证网络客户端构建失败。
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 {
/// 服务端原文可能缺失:缺失是 `None`,Rust 不编造兜底文案。
pub(crate) server_message: Option<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 SendCodeRejected {
/// 服务端原文可能缺失:缺失是 `None`,Rust 不编造兜底文案。
pub(crate) server_message: Option<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 PhoneCodeLoginRejected {
/// 服务端原文可能缺失:缺失是 `None`,Rust 不编造兜底文案。
pub(crate) server_message: Option<String>,
}
/// 连接登录服务的传输层失败原因。
#[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<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 AuthResponseInvalid {
pub(crate) reason: AuthResponseInvalidReason,
/// 只有 [`AuthResponseInvalidReason::ServerRejected`] 可能带服务端原文;其余是 `None`。
pub(crate) server_message: Option<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) 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,
}
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::SmsCodeRejected, "smsCodeRejected"),
(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",
),
];
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"], "磁盘只读");
}
#[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());
}
}