//! 统一错误码模型。 //! //! 为 CLI、daemon RPC 和结构化日志提供一套稳定的数字错误码(`BAT-ERR-NNNNNN`)、 //! 错误类别(kind)、问题位置(location)和可重试标记,作为项目公共错误契约。 //! //! 码格式:`BAT-ERR-<域 3 位><序号 3 位>`,共 6 位,例如 `BAT-ERR-300012`。 //! 域号按百位分大类,中间位保留给子域。 use serde::ser::{Serialize, SerializeStruct, Serializer}; /// 错误大类(域),对应错误码首位所在的百位区间。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ErrorDomain { /// 输入/配置(`1xxxxx`):CLI 参数、配置校验、代理配置等。 Input, /// 路径/安全边界(`2xxxxx`):危险目录、路径逃逸、symlink、权限。 PathSecurity, /// 网络/下载(`3xxxxx`):curl HTTP/网络错误、代理故障、重试耗尽、quarantine。 Network, /// 校验/完整性(`4xxxxx`):BLAKE3、官方 seed hash、size、ZIP 结构。 Integrity, /// 发布/版本状态/存储(`5xxxxx`):staging、原子发布、version-state、锁、CAS。 PublishStorage, /// 解析/适配(`6xxxxx`):manifest、UnityFS、GameMainConfig。 Parse, /// 任务/RPC(`7xxxxx`):未知方法、参数非法、未实现、任务不存在。 TaskRpc, /// 内部/未知(`9xxxxx`):兜底。 Internal, } impl ErrorDomain { /// 从错误码数字推断所属域。 pub fn from_number(number: u32) -> Self { match number / 100_000 { 1 => Self::Input, 2 => Self::PathSecurity, 3 => Self::Network, 4 => Self::Integrity, 5 => Self::PublishStorage, 6 => Self::Parse, 7 => Self::TaskRpc, _ => Self::Internal, } } /// 返回域的稳定英文标签。 pub fn label(self) -> &'static str { match self { Self::Input => "input", Self::PathSecurity => "path_security", Self::Network => "network", Self::Integrity => "integrity", Self::PublishStorage => "publish_storage", Self::Parse => "parse", Self::TaskRpc => "task_rpc", Self::Internal => "internal", } } } /// 单个稳定错误码:数字、类别 slug 和默认可重试标记。 /// /// 通过命名常量引用(如 [`ErrorCode::HTTP_FORBIDDEN`]),保证码值稳定、可查表。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ErrorCode { number: u32, kind: &'static str, retryable: bool, } impl ErrorCode { const fn new(number: u32, kind: &'static str, retryable: bool) -> Self { Self { number, kind, retryable, } } /// 返回稳定的展示 ID,例如 `BAT-ERR-300012`。 pub fn id(&self) -> String { format!("BAT-ERR-{:06}", self.number) } /// 返回错误码数字。 pub fn number(&self) -> u32 { self.number } /// 返回错误类别 slug(英文、稳定)。 pub fn kind(&self) -> &'static str { self.kind } /// 返回该类错误默认是否可重试。 pub fn retryable(&self) -> bool { self.retryable } /// 返回所属错误域。 pub fn domain(&self) -> ErrorDomain { ErrorDomain::from_number(self.number) } } impl Serialize for ErrorCode { fn serialize(&self, serializer: S) -> Result { serializer.serialize_str(&self.id()) } } /// 错误码码表。新增错误码只需在此登记,`docs`/`USERGUIDE.md` 从这里同步。 impl ErrorCode { // ---- 100xxx 输入/配置 ---- /// 缺少应用版本(未传 `--app-version` 且未启用 `--auto-discover`)。 pub const MISSING_APP_VERSION: Self = Self::new(100_001, "missing_app_version", false); /// 缺少连接组。 pub const MISSING_CONNECTION_GROUP: Self = Self::new(100_002, "missing_connection_group", false); /// 缺少服务器信息来源。 pub const MISSING_SERVER_INFO_SOURCE: Self = Self::new(100_003, "missing_server_info_source", false); /// 代理 URL scheme 不受支持。 pub const INVALID_PROXY_SCHEME: Self = Self::new(100_004, "invalid_proxy_scheme", false); /// 命令行参数无效。 pub const INVALID_ARGUMENT: Self = Self::new(100_010, "invalid_argument", false); // ---- 200xxx 路径/安全边界 ---- /// 输出目录被判定为危险路径。 pub const DANGEROUS_OUTPUT_ROOT: Self = Self::new(200_001, "dangerous_output_root", false); /// 路径逃逸出允许根目录。 pub const PATH_ESCAPE: Self = Self::new(200_002, "path_escape", false); /// 目标不允许是 symlink 或路径组件含 symlink。 pub const SYMLINK_REJECTED: Self = Self::new(200_003, "symlink_rejected", false); /// 文件权限或模式错误。 pub const FILE_PERMISSION: Self = Self::new(200_004, "file_permission", false); // ---- 300xxx 网络/下载 ---- /// HTTP 403。 pub const HTTP_FORBIDDEN: Self = Self::new(300_001, "http_forbidden", false); /// HTTP 404。 pub const HTTP_NOT_FOUND: Self = Self::new(300_002, "http_not_found", false); /// HTTP 其它 4xx。 pub const HTTP_CLIENT_ERROR: Self = Self::new(300_003, "http_client_error", false); /// HTTP 429 / 请求过多。 pub const HTTP_TOO_MANY_REQUESTS: Self = Self::new(300_004, "http_too_many_requests", true); /// HTTP 5xx。 pub const HTTP_SERVER_ERROR: Self = Self::new(300_005, "http_server_error", true); /// DNS 解析失败。 pub const NETWORK_DNS: Self = Self::new(300_010, "network_dns", true); /// 连接失败。 pub const NETWORK_CONNECT: Self = Self::new(300_011, "network_connect", true); /// 超时。 pub const NETWORK_TIMEOUT: Self = Self::new(300_012, "network_timeout", true); /// TLS 失败。 pub const NETWORK_TLS: Self = Self::new(300_013, "network_tls", true); /// 传输中断。 pub const NETWORK_INTERRUPTED: Self = Self::new(300_014, "network_interrupted", true); /// 其它网络错误。 pub const NETWORK_OTHER: Self = Self::new(300_015, "network_other", true); /// 下载重试次数耗尽。 pub const RETRY_EXHAUSTED: Self = Self::new(300_020, "retry_exhausted", false); /// URL 因反复失败进入 quarantine。 pub const QUARANTINED: Self = Self::new(300_021, "quarantined", false); /// URL 不是官方 host(被拒绝)。 pub const NON_OFFICIAL_URL: Self = Self::new(300_030, "non_official_url", false); /// 代理自身故障(认证/解析/连接代理失败)。 pub const PROXY_FAILURE: Self = Self::new(310_001, "proxy_failure", false); // ---- 400xxx 校验/完整性 ---- /// 本地 BLAKE3 与 manifest 不符。 pub const BLAKE3_MISMATCH: Self = Self::new(400_001, "blake3_mismatch", false); /// 官方 seed `.hash`(xxHash32)校验不符。 pub const OFFICIAL_HASH_MISMATCH: Self = Self::new(400_002, "official_hash_mismatch", false); /// 文件大小与 manifest 不符。 pub const SIZE_MISMATCH: Self = Self::new(400_003, "size_mismatch", false); /// ZIP 结构无效。 pub const ZIP_STRUCTURE_INVALID: Self = Self::new(400_004, "zip_structure_invalid", false); // ---- 500xxx 发布/版本状态/存储 ---- /// 资源目录锁冲突。 pub const RESOURCE_LOCKED: Self = Self::new(500_001, "resource_locked", false); /// staging 准备失败。 pub const STAGING_PREPARE_FAILED: Self = Self::new(500_002, "staging_prepare_failed", false); /// 原子发布失败。 pub const PUBLISH_FAILED: Self = Self::new(500_003, "publish_failed", false); /// 版本状态写入失败。 pub const VERSION_STATE_WRITE_FAILED: Self = Self::new(500_004, "version_state_write_failed", true); /// CAS 对象 Hash 不匹配。 pub const CAS_HASH_MISMATCH: Self = Self::new(500_010, "cas_hash_mismatch", false); /// CAS 对象不存在。 pub const CAS_OBJECT_NOT_FOUND: Self = Self::new(500_011, "cas_object_not_found", false); /// CAS 引用计数下溢。 pub const CAS_REFERENCE_UNDERFLOW: Self = Self::new(500_012, "cas_reference_underflow", false); /// CAS 元数据库错误。 pub const CAS_DATABASE: Self = Self::new(500_013, "cas_database", false); // ---- 600xxx 解析/适配 ---- /// Manifest / Addressables catalog 解析失败。 pub const MANIFEST_PARSE_FAILED: Self = Self::new(600_001, "manifest_parse_failed", false); /// UnityFS 解析失败。 pub const UNITYFS_PARSE_FAILED: Self = Self::new(600_002, "unityfs_parse_failed", false); /// GameMainConfig 解密/解析失败。 pub const GAME_MAIN_CONFIG_FAILED: Self = Self::new(600_003, "game_main_config_failed", false); // ---- 700xxx 任务/RPC ---- /// 未知 RPC 方法。 pub const RPC_UNKNOWN_METHOD: Self = Self::new(700_001, "rpc_unknown_method", false); /// RPC 参数无效。 pub const RPC_INVALID_PARAMS: Self = Self::new(700_002, "rpc_invalid_params", false); /// 方法/命名空间尚未实现。 pub const RPC_NOT_IMPLEMENTED: Self = Self::new(700_003, "rpc_not_implemented", false); /// 任务不存在。 pub const TASK_NOT_FOUND: Self = Self::new(700_004, "task_not_found", false); // ---- 900xxx 内部/未知 ---- /// 未归类的内部错误。 pub const INTERNAL: Self = Self::new(900_001, "internal", false); } /// 结构化 API 错误:进入 RPC envelope、CLI `--json` 输出和结构化日志的统一形态。 #[derive(Debug, Clone)] pub struct ApiError { code: ErrorCode, location: &'static str, message: String, } impl ApiError { /// 构造错误:给定错误码、稳定的问题位置标签(如 `official-sync.download.pull_one`) /// 和人类可读消息。 pub fn new(code: ErrorCode, location: &'static str, message: impl Into) -> Self { Self { code, location, message: message.into(), } } /// 错误码。 pub fn code(&self) -> ErrorCode { self.code } /// 问题位置标签。 pub fn location(&self) -> &'static str { self.location } /// 人类可读消息。 pub fn message(&self) -> &str { &self.message } /// 是否可重试(取自错误码默认值)。 pub fn retryable(&self) -> bool { self.code.retryable() } } impl std::fmt::Display for ApiError { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( formatter, "[{} {}] {} (@{})", self.code.id(), self.code.kind(), self.message, self.location ) } } impl std::error::Error for ApiError {} impl Serialize for ApiError { fn serialize(&self, serializer: S) -> Result { let mut state = serializer.serialize_struct("ApiError", 6)?; state.serialize_field("code", &self.code.id())?; state.serialize_field("kind", self.code.kind())?; state.serialize_field("domain", self.code.domain().label())?; state.serialize_field("location", self.location)?; state.serialize_field("message", &self.message)?; state.serialize_field("retryable", &self.code.retryable())?; state.end() } } #[cfg(test)] mod tests { use super::*; #[test] fn code_id_is_zero_padded_six_digits() { assert_eq!(ErrorCode::MISSING_APP_VERSION.id(), "BAT-ERR-100001"); assert_eq!(ErrorCode::HTTP_FORBIDDEN.id(), "BAT-ERR-300001"); assert_eq!(ErrorCode::PROXY_FAILURE.id(), "BAT-ERR-310001"); assert_eq!(ErrorCode::INTERNAL.id(), "BAT-ERR-900001"); } #[test] fn code_maps_to_domain() { assert_eq!(ErrorCode::INVALID_ARGUMENT.domain(), ErrorDomain::Input); assert_eq!(ErrorCode::PATH_ESCAPE.domain(), ErrorDomain::PathSecurity); assert_eq!(ErrorCode::HTTP_SERVER_ERROR.domain(), ErrorDomain::Network); assert_eq!(ErrorCode::PROXY_FAILURE.domain(), ErrorDomain::Network); assert_eq!(ErrorCode::BLAKE3_MISMATCH.domain(), ErrorDomain::Integrity); assert_eq!( ErrorCode::PUBLISH_FAILED.domain(), ErrorDomain::PublishStorage ); assert_eq!(ErrorCode::UNITYFS_PARSE_FAILED.domain(), ErrorDomain::Parse); assert_eq!(ErrorCode::TASK_NOT_FOUND.domain(), ErrorDomain::TaskRpc); assert_eq!(ErrorCode::INTERNAL.domain(), ErrorDomain::Internal); } #[test] fn retryable_reflects_code_default() { assert!(ErrorCode::HTTP_SERVER_ERROR.retryable()); assert!(!ErrorCode::HTTP_FORBIDDEN.retryable()); assert!(!ErrorCode::PROXY_FAILURE.retryable()); } #[test] fn api_error_serializes_full_envelope_shape() { let error = ApiError::new( ErrorCode::PROXY_FAILURE, "official-sync.download.pull_one", "代理返回 407 认证失败", ); let value = serde_json::to_value(&error).unwrap(); assert_eq!(value["code"], "BAT-ERR-310001"); assert_eq!(value["kind"], "proxy_failure"); assert_eq!(value["domain"], "network"); assert_eq!(value["location"], "official-sync.download.pull_one"); assert_eq!(value["message"], "代理返回 407 认证失败"); assert_eq!(value["retryable"], false); } #[test] fn error_codes_are_unique() { // 防止新增码值撞号:列举全部命名常量,断言 number 唯一。 let codes = [ ErrorCode::MISSING_APP_VERSION, ErrorCode::MISSING_CONNECTION_GROUP, ErrorCode::MISSING_SERVER_INFO_SOURCE, ErrorCode::INVALID_PROXY_SCHEME, ErrorCode::INVALID_ARGUMENT, ErrorCode::DANGEROUS_OUTPUT_ROOT, ErrorCode::PATH_ESCAPE, ErrorCode::SYMLINK_REJECTED, ErrorCode::FILE_PERMISSION, ErrorCode::HTTP_FORBIDDEN, ErrorCode::HTTP_NOT_FOUND, ErrorCode::HTTP_CLIENT_ERROR, ErrorCode::HTTP_TOO_MANY_REQUESTS, ErrorCode::HTTP_SERVER_ERROR, ErrorCode::NETWORK_DNS, ErrorCode::NETWORK_CONNECT, ErrorCode::NETWORK_TIMEOUT, ErrorCode::NETWORK_TLS, ErrorCode::NETWORK_INTERRUPTED, ErrorCode::NETWORK_OTHER, ErrorCode::RETRY_EXHAUSTED, ErrorCode::QUARANTINED, ErrorCode::NON_OFFICIAL_URL, ErrorCode::PROXY_FAILURE, ErrorCode::BLAKE3_MISMATCH, ErrorCode::OFFICIAL_HASH_MISMATCH, ErrorCode::SIZE_MISMATCH, ErrorCode::ZIP_STRUCTURE_INVALID, ErrorCode::RESOURCE_LOCKED, ErrorCode::STAGING_PREPARE_FAILED, ErrorCode::PUBLISH_FAILED, ErrorCode::VERSION_STATE_WRITE_FAILED, ErrorCode::CAS_HASH_MISMATCH, ErrorCode::CAS_OBJECT_NOT_FOUND, ErrorCode::CAS_REFERENCE_UNDERFLOW, ErrorCode::CAS_DATABASE, ErrorCode::MANIFEST_PARSE_FAILED, ErrorCode::UNITYFS_PARSE_FAILED, ErrorCode::GAME_MAIN_CONFIG_FAILED, ErrorCode::RPC_UNKNOWN_METHOD, ErrorCode::RPC_INVALID_PARAMS, ErrorCode::RPC_NOT_IMPLEMENTED, ErrorCode::TASK_NOT_FOUND, ErrorCode::INTERNAL, ]; let mut numbers: Vec = codes.iter().map(ErrorCode::number).collect(); let total = numbers.len(); numbers.sort_unstable(); numbers.dedup(); assert_eq!(numbers.len(), total, "存在重复的错误码 number"); } }