Files
BlueArchiveToolkit/core/src/error_code.rs
T
nyaKazuhaandClaude Fable 5 1a25ea58dd feat(core): 引入统一错误码模型并新增 USERGUIDE
为 issue #1 的公共错误契约建立错误码模型:

- core/src/error_code.rs:ErrorCode(BAT-ERR-<域3位><序号3位>,如 BAT-ERR-300012)
  + 44 个初始码表(覆盖输入/路径安全/网络下载/校验/发布存储/解析/任务RPC/内部
  八域,网络域含代理故障 310001)、ErrorDomain、以及进入 RPC envelope / --json /
  bat-events.jsonl 的统一 ApiError { code, kind, domain, location, message, retryable }。
  location 用稳定的组件·操作标签(不随行号漂移)。
- 新增 USERGUIDE.md:bat 命令、选项(发现/同步/守护/输出)、退出码(含 75=locked)、
  运行时默认值,以及从码表同步的错误码参考(含承载结构与域一览)。

命名遵循国际惯例(英文 kind/domain slug)。码表以 error_code.rs 为准,USERGUIDE
错误码表与之逐条一致。后续各链路报错接入该码表在 issue #1 下推进。

对应 issue #1(错误码模型 + 用户指南)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 03:57:17 -07:00

398 lines
16 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.
//! 统一错误码模型。
//!
//! 为 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<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
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<String>) -> 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<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
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<u32> = codes.iter().map(ErrorCode::number).collect();
let total = numbers.len();
numbers.sort_unstable();
numbers.dedup();
assert_eq!(numbers.len(), total, "存在重复的错误码 number");
}
}