Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs
T
k88936 53e2889bbd
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m20s
Project CI / Backend tests (pull_request) Failing after 21s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m10s
Project CI / Frontend tests (pull_request) Successful in 2m27s
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m50s
Project CI / Native shell tests (pull_request) Successful in 6m25s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
本机IO失败去掉detail载荷
- Rust 错误枚举:clientSessionPersistFailed / runtimeSessionInstallFailed 改无字段变体,删除会带 AppData 路径的 payload 结构体
- auth_session:本机凭据/运行时会话 IO 失败改成写本地日志后返回无字段变体,不再把原始 error 放进载荷
- 前端:登录检查/发码/登录三处 catch 去掉 as 取 detail,改用本操作的固定文案
- ts-rs 重新生成 ClientAuthError 联合,删除两个已不存在的载荷绑定文件
- 补充 auth_error 用例:两个变体序列化为仅含 type 的无字段形状
2026-10-02 11:57:31 +08:00

362 lines
15 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 状态码),服务端没给原文
//! 就是 `None`;前缀与句式由前端调用方在自己的 catch 分支按当前操作拼接。本机 IO 的原始错误只写本地
//! 日志,不进载荷(避免把 AppData 路径带进登录页与上报包),对应变体无字段、前端给固定文案。
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,
/// 本机运行时会话安装 / 清理失败;原始错误只写本地日志。
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>,
}
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",
),
(
ClientAuthError::RuntimeSessionInstallFailed,
"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() {
for (error, expected_type) in [
(ClientAuthError::LoginCodeMissing, "loginCodeMissing"),
(
ClientAuthError::ClientSessionPersistFailed,
"clientSessionPersistFailed",
),
(
ClientAuthError::RuntimeSessionInstallFailed,
"runtimeSessionInstallFailed",
),
] {
let value = serde_json::to_value(error).expect("serialize auth error");
assert_eq!(value, serde_json::json!({ "type": expected_type }));
}
}
#[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"], "登录服务请求失败");
}
#[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());
}
}