AGC 认证命令错误结构化
- 新增 auth_error.rs:ClientAuthError 具体变体枚举,serde tag=type + ts-rs 导出,附变体名契约测试 - auth_session.rs 全量改为 Result<_, ClientAuthError>:请求/响应/凭据落盘/运行时会话安装按具体变体建模 - 路由语义由变体承担:会话 401/403 走 SessionAuthorityRejected/PermissionDenied,登录 401 保留服务端原因 - 400 变体按请求粒度命名(passwordEntryInputRejected 等),服务端只给 status+message,不做文案匹配 - 注册 auth_error 模块,生成 src/services/generated/ClientAuthError.ts
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
//! AGC 认证命令的结构化错误:从命令入口到出口只传这一种错误。
|
||||
//!
|
||||
//! 变体名就是线上的分流键(`type`):前端只按它选通道,**不解析任何文案**,也不对错误文本做匹配。
|
||||
//!
|
||||
//! 变体按**可判定的事实**命名。服务端 400 只提供 `status + message`(平台 `AppError.code` 仍是
|
||||
//! 通用 `BAD_REQUEST`),所以 400 变体按"哪条请求的输入被拒"命名(例如
|
||||
//! [`ClientAuthError::PasswordEntryInputRejected`]),不假装能区分密码长度 / 手机号格式;会话路由的
|
||||
//! `401/403` 是"登录态权威失效",登录路由的 `401` 是用户可修正的输入问题,这个区分现在由变体承担,
|
||||
//! 不再靠 `authentication-required:` 这类文本前缀。
|
||||
//!
|
||||
//! 每个变体都带一份可展示 `message`,文案只在 Rust 生成一次(服务端原文优先,缺失时才用调用点的
|
||||
//! 兜底文案);前端对认得的业务变体原样展示,对系统变体 / 未识别变体带上下文重抛,走上报链路。
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use serde::Serialize;
|
||||
use ts_rs::TS;
|
||||
|
||||
/// 变体名就是线上的分流键(`type`)。
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
|
||||
#[serde(
|
||||
tag = "type",
|
||||
rename_all = "camelCase",
|
||||
rename_all_fields = "camelCase"
|
||||
)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))]
|
||||
pub(crate) enum ClientAuthError {
|
||||
// ---- 业务:用户自己能改,调用方给提示,不上报 ----
|
||||
/// 服务地址不是合法 origin / 非本机未用 HTTPS / 不在构建渠道范围内。
|
||||
ServerAddressRejected { message: String },
|
||||
/// 本地前置校验:手机号为空或格式不合法。
|
||||
PhoneNumberInvalid { message: String },
|
||||
/// 本地前置校验:密码为空。
|
||||
PasswordMissing { message: String },
|
||||
/// 本地前置校验:验证码为空。
|
||||
LoginCodeMissing { message: String },
|
||||
/// `/api/auth/entry` 返回 400:服务端拒绝本次输入。
|
||||
PasswordEntryInputRejected { message: String },
|
||||
/// `/api/auth/entry` 返回 401:手机号或密码错误。
|
||||
PhoneOrPasswordMismatch { message: String },
|
||||
/// `/api/auth/phone/send-code` 返回 400。
|
||||
SendCodeInputRejected { message: String },
|
||||
/// `/api/auth/phone/send-code` 返回 429:发送过于频繁。
|
||||
SmsCodeThrottled { message: String },
|
||||
/// `/api/auth/phone/login` 返回 400。
|
||||
PhoneLoginInputRejected { message: String },
|
||||
/// `/api/auth/phone/login` 返回 401:验证码错误或过期。
|
||||
SmsCodeInvalidOrExpired { message: String },
|
||||
// ---- 会话:调用方按"未登录"处理,不给用户报错 ----
|
||||
/// 会话路由 401:登录态权威失效。
|
||||
SessionAuthorityRejected { message: String },
|
||||
/// 会话路由 403:当前账号没有执行此操作的权限。
|
||||
PermissionDenied { message: String },
|
||||
// ---- 系统:调用方处理不了,带上下文重抛 ----
|
||||
/// 连接 / 超时 / DNS 等网络失败。
|
||||
AuthNetworkUnavailable { message: String },
|
||||
/// 服务端 5xx。
|
||||
AuthServiceUnavailable { status: u16, message: String },
|
||||
/// 其它未识别的拒绝(未列举的 4xx、登录路由 403 等)。
|
||||
UnexpectedRejection { status: u16, message: String },
|
||||
/// 响应不是合法 JSON、缺少必需字段或契约不成立。
|
||||
AuthResponseMalformed { message: String },
|
||||
/// 本机登录凭据文件读写失败。
|
||||
ClientSessionPersistFailed { message: String },
|
||||
/// 本机运行时会话安装 / 清理失败。
|
||||
RuntimeSessionInstallFailed { message: String },
|
||||
/// 认证网络客户端构建失败。
|
||||
AuthClientInitFailed { message: String },
|
||||
}
|
||||
|
||||
impl ClientAuthError {
|
||||
/// 可展示文案:服务端原文优先,缺失时是调用点兜底。
|
||||
pub(crate) fn message(&self) -> &str {
|
||||
match self {
|
||||
Self::ServerAddressRejected { message }
|
||||
| Self::PhoneNumberInvalid { message }
|
||||
| Self::PasswordMissing { message }
|
||||
| Self::LoginCodeMissing { message }
|
||||
| Self::PasswordEntryInputRejected { message }
|
||||
| Self::PhoneOrPasswordMismatch { message }
|
||||
| Self::SendCodeInputRejected { message }
|
||||
| Self::SmsCodeThrottled { message }
|
||||
| Self::PhoneLoginInputRejected { message }
|
||||
| Self::SmsCodeInvalidOrExpired { message }
|
||||
| Self::SessionAuthorityRejected { message }
|
||||
| Self::PermissionDenied { message }
|
||||
| Self::AuthNetworkUnavailable { message }
|
||||
| Self::AuthServiceUnavailable { message, .. }
|
||||
| Self::UnexpectedRejection { message, .. }
|
||||
| Self::AuthResponseMalformed { message }
|
||||
| Self::ClientSessionPersistFailed { message }
|
||||
| Self::RuntimeSessionInstallFailed { message }
|
||||
| Self::AuthClientInitFailed { message } => message,
|
||||
}
|
||||
}
|
||||
|
||||
/// 会话路由的 401/403 是「登录态权威失效」:调用方据此清会话、按未登录处理,
|
||||
/// 既不给用户报错,也不进错误报告池。
|
||||
pub(crate) fn is_authority_failure(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::SessionAuthorityRejected { .. } | Self::PermissionDenied { .. }
|
||||
)
|
||||
}
|
||||
|
||||
/// 登录态投影里 `errorKind` 的取值:只描述"哪一类失败",不参与任何前端分支。
|
||||
pub(crate) fn error_kind(&self) -> &'static str {
|
||||
match self {
|
||||
Self::AuthNetworkUnavailable { .. } => "network",
|
||||
Self::AuthServiceUnavailable { .. } | Self::UnexpectedRejection { .. } => "service",
|
||||
Self::AuthResponseMalformed { .. } => "response",
|
||||
Self::ClientSessionPersistFailed { .. } => "storage",
|
||||
Self::RuntimeSessionInstallFailed { .. } | Self::AuthClientInitFailed { .. } => {
|
||||
"runtime"
|
||||
}
|
||||
_ => "auth",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ClientAuthError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(self.message())
|
||||
}
|
||||
}
|
||||
|
||||
/// 只需要一句文案的边界用它,与 `DirectTurnError` 的收口方式一致。
|
||||
impl From<ClientAuthError> for String {
|
||||
fn from(error: ClientAuthError) -> Self {
|
||||
error.message().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wire_variant_names_are_the_frontend_dispatch_keys() {
|
||||
let cases = [
|
||||
(
|
||||
ClientAuthError::ServerAddressRejected {
|
||||
message: "x".to_string(),
|
||||
},
|
||||
"serverAddressRejected",
|
||||
),
|
||||
(
|
||||
ClientAuthError::PhoneNumberInvalid {
|
||||
message: "x".to_string(),
|
||||
},
|
||||
"phoneNumberInvalid",
|
||||
),
|
||||
(
|
||||
ClientAuthError::PasswordMissing {
|
||||
message: "x".to_string(),
|
||||
},
|
||||
"passwordMissing",
|
||||
),
|
||||
(
|
||||
ClientAuthError::LoginCodeMissing {
|
||||
message: "x".to_string(),
|
||||
},
|
||||
"loginCodeMissing",
|
||||
),
|
||||
(
|
||||
ClientAuthError::PasswordEntryInputRejected {
|
||||
message: "x".to_string(),
|
||||
},
|
||||
"passwordEntryInputRejected",
|
||||
),
|
||||
(
|
||||
ClientAuthError::PhoneOrPasswordMismatch {
|
||||
message: "x".to_string(),
|
||||
},
|
||||
"phoneOrPasswordMismatch",
|
||||
),
|
||||
(
|
||||
ClientAuthError::SendCodeInputRejected {
|
||||
message: "x".to_string(),
|
||||
},
|
||||
"sendCodeInputRejected",
|
||||
),
|
||||
(
|
||||
ClientAuthError::SmsCodeThrottled {
|
||||
message: "x".to_string(),
|
||||
},
|
||||
"smsCodeThrottled",
|
||||
),
|
||||
(
|
||||
ClientAuthError::PhoneLoginInputRejected {
|
||||
message: "x".to_string(),
|
||||
},
|
||||
"phoneLoginInputRejected",
|
||||
),
|
||||
(
|
||||
ClientAuthError::SmsCodeInvalidOrExpired {
|
||||
message: "x".to_string(),
|
||||
},
|
||||
"smsCodeInvalidOrExpired",
|
||||
),
|
||||
(
|
||||
ClientAuthError::SessionAuthorityRejected {
|
||||
message: "x".to_string(),
|
||||
},
|
||||
"sessionAuthorityRejected",
|
||||
),
|
||||
(
|
||||
ClientAuthError::PermissionDenied {
|
||||
message: "x".to_string(),
|
||||
},
|
||||
"permissionDenied",
|
||||
),
|
||||
(
|
||||
ClientAuthError::AuthNetworkUnavailable {
|
||||
message: "x".to_string(),
|
||||
},
|
||||
"authNetworkUnavailable",
|
||||
),
|
||||
(
|
||||
ClientAuthError::AuthServiceUnavailable {
|
||||
status: 503,
|
||||
message: "x".to_string(),
|
||||
},
|
||||
"authServiceUnavailable",
|
||||
),
|
||||
(
|
||||
ClientAuthError::UnexpectedRejection {
|
||||
status: 409,
|
||||
message: "x".to_string(),
|
||||
},
|
||||
"unexpectedRejection",
|
||||
),
|
||||
(
|
||||
ClientAuthError::AuthResponseMalformed {
|
||||
message: "x".to_string(),
|
||||
},
|
||||
"authResponseMalformed",
|
||||
),
|
||||
(
|
||||
ClientAuthError::ClientSessionPersistFailed {
|
||||
message: "x".to_string(),
|
||||
},
|
||||
"clientSessionPersistFailed",
|
||||
),
|
||||
(
|
||||
ClientAuthError::RuntimeSessionInstallFailed {
|
||||
message: "x".to_string(),
|
||||
},
|
||||
"runtimeSessionInstallFailed",
|
||||
),
|
||||
(
|
||||
ClientAuthError::AuthClientInitFailed {
|
||||
message: "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)
|
||||
);
|
||||
assert_eq!(
|
||||
value.get("message").and_then(|value| value.as_str()),
|
||||
Some(error.message())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn machine_context_fields_survive_serialization() {
|
||||
let unavailable = serde_json::to_value(ClientAuthError::AuthServiceUnavailable {
|
||||
status: 503,
|
||||
message: "账号服务暂不可用".to_string(),
|
||||
})
|
||||
.expect("serialize auth error");
|
||||
assert_eq!(unavailable["type"], "authServiceUnavailable");
|
||||
assert_eq!(unavailable["status"], 503);
|
||||
|
||||
let rejection = serde_json::to_value(ClientAuthError::UnexpectedRejection {
|
||||
status: 409,
|
||||
message: "冲突".to_string(),
|
||||
})
|
||||
.expect("serialize auth error");
|
||||
assert_eq!(rejection["status"], 409);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_session_authority_failures_count_as_authority_failures() {
|
||||
assert!(ClientAuthError::SessionAuthorityRejected {
|
||||
message: "x".to_string()
|
||||
}
|
||||
.is_authority_failure());
|
||||
assert!(ClientAuthError::PermissionDenied {
|
||||
message: "x".to_string()
|
||||
}
|
||||
.is_authority_failure());
|
||||
assert!(!ClientAuthError::PhoneOrPasswordMismatch {
|
||||
message: "x".to_string()
|
||||
}
|
||||
.is_authority_failure());
|
||||
assert!(!ClientAuthError::AuthNetworkUnavailable {
|
||||
message: "x".to_string()
|
||||
}
|
||||
.is_authority_failure());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_and_string_conversion_use_the_display_message() {
|
||||
let error = ClientAuthError::PhoneOrPasswordMismatch {
|
||||
message: "手机号或密码错误".to_string(),
|
||||
};
|
||||
assert_eq!(error.to_string(), "手机号或密码错误");
|
||||
assert_eq!(String::from(error), "手机号或密码错误");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -118,6 +118,7 @@ mod agent_native_tools;
|
||||
mod analytics;
|
||||
mod asset_generation_tasks;
|
||||
mod assets;
|
||||
mod auth_error;
|
||||
mod auth_session;
|
||||
mod browser;
|
||||
mod builtin_plugins;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* 变体名就是线上的分流键(`type`)。
|
||||
*/
|
||||
export type ClientAuthError =
|
||||
| { type: 'serverAddressRejected'; message: string }
|
||||
| { type: 'phoneNumberInvalid'; message: string }
|
||||
| { type: 'passwordMissing'; message: string }
|
||||
| { type: 'loginCodeMissing'; message: string }
|
||||
| { type: 'passwordEntryInputRejected'; message: string }
|
||||
| { type: 'phoneOrPasswordMismatch'; message: string }
|
||||
| { type: 'sendCodeInputRejected'; message: string }
|
||||
| { type: 'smsCodeThrottled'; message: string }
|
||||
| { type: 'phoneLoginInputRejected'; message: string }
|
||||
| { type: 'smsCodeInvalidOrExpired'; message: string }
|
||||
| { type: 'sessionAuthorityRejected'; message: string }
|
||||
| { type: 'permissionDenied'; message: string }
|
||||
| { type: 'authNetworkUnavailable'; message: string }
|
||||
| { type: 'authServiceUnavailable'; status: number; message: string }
|
||||
| { type: 'unexpectedRejection'; status: number; message: string }
|
||||
| { type: 'authResponseMalformed'; message: string }
|
||||
| { type: 'clientSessionPersistFailed'; message: string }
|
||||
| { type: 'runtimeSessionInstallFailed'; message: string }
|
||||
| { type: 'authClientInitFailed'; message: string };
|
||||
Reference in New Issue
Block a user