254f2767cc
- 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 构造
378 lines
16 KiB
Rust
378 lines
16 KiB
Rust
//! 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());
|
||
}
|
||
}
|