diff --git a/USERGUIDE.md b/USERGUIDE.md new file mode 100644 index 0000000..5e06742 --- /dev/null +++ b/USERGUIDE.md @@ -0,0 +1,222 @@ +# BlueArchiveToolkit 用户指南 + +本指南面向 `bat` 官方资源同步二进制的使用者,覆盖:命令、选项、退出码、运行时行为,以及统一错误码参考。 + +- 权威运行/部署说明另见 `docs/guides/official-resource-test-pull.md` 和 `docs/guides/deployment.md`。 +- 本文档中的命令、选项以 `bat --help` 为准;错误码以 `core/src/error_code.rs` 的码表为准。 + +--- + +## 1. 概览 + +`bat` 是 Linux 上官方日服(Yostar JP)资源同步的正式入口。它可以: + +- `--auto-discover` 从官方 HTTP metadata 解析 `GameMainConfig`,自动获得 app-version、连接组和 server-info,不安装、不启动官方启动器。 +- 生成官方全量 pull plan、执行真实下载,维护 release 内的下载 manifest,并做 size + BLAKE3 复用校验、官方 seed `.hash`(标准 xxHash32(seed=0))强校验、ZIP 结构校验。 +- 断点续传、失败分类重试、下载 quarantine、本地 manifest audit/repair。 +- 原子发布:先写 `.staging/`,校验通过后发布 `versions/` 并原子切换 `current` symlink。 +- 常驻运行(`--watch`)或后台化(`--daemon`),通过 `bat.sock` Unix socket JSON-RPC 控制。 + +### 运行形态 + +```bash +# 一次性 dry-run(不写状态) +bat --auto-discover --dry-run + +# 前台常驻 +bat --auto-discover --watch --output /var/lib/bluearchive-toolkit/official + +# 后台守护 +bat --auto-discover --daemon --output /var/lib/bluearchive-toolkit/official +``` + +带凭据的代理**推荐用环境变量**(凭据不进命令行/argv/状态文件): + +```bash +HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon +``` + +--- + +## 2. 命令 + +无子命令时执行一次性同步(或配合 `--watch`/`--daemon`)。子命令如下: + +| 命令 | 说明 | +|---|---| +| `refresh` | 执行一次更新检查;若有 live daemon,则通过 RPC 请求其刷新 | +| `verify` | 校验远端计划、本地 manifest 和官方 seed hash(dry-run + 审计当前 release) | +| `repair` | 重新下载本地校验失败的资源 | +| `status` | 显示 daemon 状态 | +| `stop` | 停止 daemon | +| `restart` | 重启 daemon;未显式传参时复用保存的参数 | +| `reload` | 请求 daemon 重新自动发现并强制刷新 | +| `logs` | 显示 daemon 日志尾部(配合 `--tail`) | +| `doctor` | 运行时诊断(输出目录、状态目录、curl/unzip、代理、PID、socket、锁) | +| `clean-stable` | 清理 `.part`/`.tmp`/失效锁、PID、socket(daemon 运行中会拒绝执行) | + +`status`/`stop`/`logs`/`reload` 和默认形态的 `refresh` 优先走 `bat.sock` JSON-RPC;socket 不可用时 `status`/`stop` 回退到 PID/状态文件兼容路径。 + +--- + +## 3. 选项 + +### 发现(Discovery) + +| 选项 | 说明 | +|---|---| +| `--auto-discover` | 自动发现 app-version、server-info、连接组 | +| `--server-info-url ` | 使用官方 server-info URL | +| `--server-info-file ` | 使用官方 server-info 文件名 | +| `--server-info-path ` | 使用本地 server-info JSON 文件 | +| `--app-version ` | 覆盖 app 版本 | +| `--connection-group ` | 覆盖连接组 | +| `--launcher-version ` | 启动器 metadata API 版本(默认 `1.7.2`) | + +### 同步(Sync) + +| 选项 | 说明 | +|---|---| +| `--platforms ` | 平台,如 `Windows,Android`(默认 `Windows,Android`) | +| `--output ` | 资源发布根目录(默认 `./bat-resources`) | +| `--snapshot ` | 覆盖 snapshot 路径(默认 `/current/official-sync-snapshot.json`) | +| `--curl ` | curl 可执行文件(默认 `curl`) | +| `--proxy ` | curl 代理覆盖(默认 `auto`,从环境变量检测)。scheme 支持 http/https/socks4/socks4a/socks5/socks5h | +| `--no-proxy` | 强制直连 | +| `--unzip ` | unzip 可执行文件(默认 `unzip`) | +| `--dry-run` | 不写同步状态 | +| `--plan` | dry-run 时输出计划中的 URL | +| `--force` | 强制下载/刷新 | +| `--audit-local` / `--no-audit-local` | 启用/关闭本地 manifest 审计 | +| `--repair` / `--no-repair` | 启用/关闭自动修复 | + +### 守护(Daemon) + +| 选项 | 说明 | +|---|---| +| `--watch` | 前台常驻循环 | +| `--daemon` | 启动脱离终端的后台 watch 进程 | +| `--state-dir ` | 后台状态目录(默认 `/tmp/bat-pid`) | +| `--interval ` | 正常检查周期(默认 `1h`) | +| `--error-retry ` | 失败后重试周期(默认 `60s`) | +| `--quiet-up-to-date` / `--no-quiet-up-to-date` | 静默/总是打印 up-to-date 报告 | +| `--tail ` | `logs` 命令返回的日志行数(默认 `200`) | + +### 输出(Output) + +| 选项 | 说明 | +|---|---| +| `--human` | 人类可读输出(默认) | +| `--json` | 面向脚本的稳定 JSON 输出 | +| `--progress` / `--no-progress` | 启用/关闭 stderr 进度日志 | +| `--banner` / `--no-banner` | 启用/关闭启动横幅 | +| `-h`, `--help` | 显示帮助 | + +### 默认值与运行时行为 + +- 平台:`Windows,Android`。 +- 资源输出:`./bat-resources`(`current` → `versions/`、`.staging/`)。 +- 后台状态目录:`/tmp/bat-pid`(`bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log`、`bat-events.jsonl`、短生命周期 `bat-control.lock`;代理凭据在 `bat-proxy.secret`,`0600`)。 +- 强制刷新:每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 各一次。 +- 状态类文件默认 `0600` 权限,读写不跟随 symlink。 + +--- + +## 4. 退出码 + +| 退出码 | 含义 | +|---|---| +| `0` | 成功;`verify`/`doctor` 检查通过 | +| `1` | 普通错误 | +| `75` | 资源目录锁冲突(有 live daemon 正在管理同一目录,或 `.official-sync.lock` 被占用) | + +`verify` 或 `doctor` 发现问题也返回非 0。`--json` 模式下错误以 JSON 写到 stderr。 + +--- + +## 5. 错误码参考 + +`bat` 使用一套稳定的数字错误码,作为 CLI、daemon RPC 和结构化日志的公共错误契约。 + +- **格式**:`BAT-ERR-<域 3 位><序号 3 位>`,共 6 位,例如 `BAT-ERR-300012`。 +- **域**:错误码首位按百位分大类。 +- **承载结构**(RPC envelope 的 `error` 字段、`--json` 输出、`bat-events.jsonl` 一致): + + ```json + "error": { + "code": "BAT-ERR-310001", + "kind": "proxy_failure", + "domain": "network", + "location": "official-sync.download.pull_one", + "message": "代理返回 407 认证失败", + "retryable": false + } + ``` + + `location` 是稳定的「组件·操作」标签(跟随语义、不随行号漂移)。`retryable` 是该类错误的默认可重试性。 + +> 说明:错误码模型(`core/src/error_code.rs`)已建立并作为公共契约;将各链路的报错逐步接入到该码表的工作在 issue #1 下推进。下表随码表更新。 + +### 域一览 + +| 域 | 区间 | 含义 | +|---|---|---| +| input | `1xxxxx` | 输入/配置:CLI 参数、配置校验、代理配置 | +| path_security | `2xxxxx` | 路径/安全边界:危险目录、路径逃逸、symlink、权限 | +| network | `3xxxxx` | 网络/下载:HTTP/网络错误、代理故障、重试耗尽、quarantine | +| integrity | `4xxxxx` | 校验/完整性:BLAKE3、官方 seed hash、size、ZIP 结构 | +| publish_storage | `5xxxxx` | 发布/版本状态/存储:staging、原子发布、version-state、锁、CAS | +| parse | `6xxxxx` | 解析/适配:manifest、UnityFS、GameMainConfig | +| task_rpc | `7xxxxx` | 任务/RPC:未知方法、参数非法、未实现、任务不存在 | +| internal | `9xxxxx` | 内部/未知:兜底 | + +### 码表 + +| 码 | kind | 可重试 | 含义 | +|---|---|:---:|---| +| `BAT-ERR-100001` | missing_app_version | 否 | 缺少应用版本(未传 `--app-version` 且未启用 `--auto-discover`) | +| `BAT-ERR-100002` | missing_connection_group | 否 | 缺少连接组 | +| `BAT-ERR-100003` | missing_server_info_source | 否 | 缺少服务器信息来源 | +| `BAT-ERR-100004` | invalid_proxy_scheme | 否 | 代理 URL scheme 不受支持 | +| `BAT-ERR-100010` | invalid_argument | 否 | 命令行参数无效 | +| `BAT-ERR-200001` | dangerous_output_root | 否 | 输出目录被判定为危险路径 | +| `BAT-ERR-200002` | path_escape | 否 | 路径逃逸出允许根目录 | +| `BAT-ERR-200003` | symlink_rejected | 否 | 目标不允许是 symlink 或路径组件含 symlink | +| `BAT-ERR-200004` | file_permission | 否 | 文件权限或模式错误 | +| `BAT-ERR-300001` | http_forbidden | 否 | HTTP 403 | +| `BAT-ERR-300002` | http_not_found | 否 | HTTP 404 | +| `BAT-ERR-300003` | http_client_error | 否 | HTTP 其它 4xx | +| `BAT-ERR-300004` | http_too_many_requests | 是 | HTTP 429 / 请求过多 | +| `BAT-ERR-300005` | http_server_error | 是 | HTTP 5xx | +| `BAT-ERR-300010` | network_dns | 是 | DNS 解析失败 | +| `BAT-ERR-300011` | network_connect | 是 | 连接失败 | +| `BAT-ERR-300012` | network_timeout | 是 | 超时 | +| `BAT-ERR-300013` | network_tls | 是 | TLS 失败 | +| `BAT-ERR-300014` | network_interrupted | 是 | 传输中断 | +| `BAT-ERR-300015` | network_other | 是 | 其它网络错误 | +| `BAT-ERR-300020` | retry_exhausted | 否 | 下载重试次数耗尽 | +| `BAT-ERR-300021` | quarantined | 否 | URL 因反复失败进入 quarantine | +| `BAT-ERR-300030` | non_official_url | 否 | URL 不是官方 host(被拒绝) | +| `BAT-ERR-310001` | proxy_failure | 否 | 代理自身故障(认证/解析/连接代理失败) | +| `BAT-ERR-400001` | blake3_mismatch | 否 | 本地 BLAKE3 与 manifest 不符 | +| `BAT-ERR-400002` | official_hash_mismatch | 否 | 官方 seed `.hash`(xxHash32)校验不符 | +| `BAT-ERR-400003` | size_mismatch | 否 | 文件大小与 manifest 不符 | +| `BAT-ERR-400004` | zip_structure_invalid | 否 | ZIP 结构无效 | +| `BAT-ERR-500001` | resource_locked | 否 | 资源目录锁冲突(对应退出码 75) | +| `BAT-ERR-500002` | staging_prepare_failed | 否 | staging 准备失败 | +| `BAT-ERR-500003` | publish_failed | 否 | 原子发布失败 | +| `BAT-ERR-500004` | version_state_write_failed | 是 | 版本状态写入失败 | +| `BAT-ERR-500010` | cas_hash_mismatch | 否 | CAS 对象 Hash 不匹配 | +| `BAT-ERR-500011` | cas_object_not_found | 否 | CAS 对象不存在 | +| `BAT-ERR-500012` | cas_reference_underflow | 否 | CAS 引用计数下溢 | +| `BAT-ERR-500013` | cas_database | 否 | CAS 元数据库错误 | +| `BAT-ERR-600001` | manifest_parse_failed | 否 | Manifest / Addressables catalog 解析失败 | +| `BAT-ERR-600002` | unityfs_parse_failed | 否 | UnityFS 解析失败 | +| `BAT-ERR-600003` | game_main_config_failed | 否 | GameMainConfig 解密/解析失败 | +| `BAT-ERR-700001` | rpc_unknown_method | 否 | 未知 RPC 方法 | +| `BAT-ERR-700002` | rpc_invalid_params | 否 | RPC 参数无效 | +| `BAT-ERR-700003` | rpc_not_implemented | 否 | 方法/命名空间尚未实现 | +| `BAT-ERR-700004` | task_not_found | 否 | 任务不存在 | +| `BAT-ERR-900001` | internal | 否 | 未归类的内部错误 | + +新增错误码在 `core/src/error_code.rs` 的码表登记后,同步更新本表。 diff --git a/core/src/error_code.rs b/core/src/error_code.rs new file mode 100644 index 0000000..ced72d9 --- /dev/null +++ b/core/src/error_code.rs @@ -0,0 +1,397 @@ +//! 统一错误码模型。 +//! +//! 为 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"); + } +} diff --git a/core/src/lib.rs b/core/src/lib.rs index 99de35c..f01cfc2 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -17,10 +17,12 @@ pub mod domain; pub mod error; +pub mod error_code; pub mod repositories; pub mod services; pub use error::{Error, Result}; +pub use error_code::{ApiError, ErrorCode, ErrorDomain}; /// Core 版本号 pub const VERSION: &str = env!("CARGO_PKG_VERSION");