Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs
T
k88936 836f8a7348 手机号登录401归phoneCodeLoginRejected
- auth_session:/api/auth/phone/login 的 401(只来自「用户不存在」)改判为 phoneCodeLoginRejected{serverMessage},不再冒充「验证码错误或过期」
- auth_error:退役顶层变体 smsCodeRejected(唯一产生点就是这个错配的 401 分支)
- 前端三个 catch 删掉 smsCodeRejected 分支;phoneCodeLoginRejected 文案统一为「验证码登录失败:<服务端原文>」,冒号改全角
- ts-rs 重新生成 ClientAuthError 联合;carrier 用例同步删掉 smsCodeRejected
- 文档:ADR + decision-log 记录 401 归属与 smsCodeRejected 退役
2026-10-02 13:35:40 +08:00

398 lines
17 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 分支按当前操作拼接。`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<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 {
/// 服务端原文可能缺失:缺失是 `null`,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 {
/// 服务端原文可能缺失:缺失是 `null`,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,
/// 只有 `serverRejected` 可能带服务端原文;其余是 `null`。
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,
}
/// 认证网络客户端构建失败的原始错误明细。
#[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());
}
}