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:
2026-10-01 15:13:57 +08:00
parent fe200598c0
commit 8d55f71911
4 changed files with 688 additions and 141 deletions
@@ -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 };