From 3edbe7cceef75321cb07664173d34bbd52301c9e Mon Sep 17 00:00:00 2001 From: Yuyi-Oak <1722157266@qq.com> Date: Fri, 17 Jul 2026 21:15:47 -0700 Subject: [PATCH] =?UTF-8?q?feat(daemon):=20=E4=BB=BB=E5=8A=A1=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E6=96=87=E4=BB=B6=E6=80=81=E6=8C=81=E4=B9=85=E5=8C=96?= =?UTF-8?q?=E4=B8=8E=20.env=20=E6=97=A0=E5=8F=82=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=EF=BC=88issue=20#1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 任务持久化: - 任务历史落 /bat-tasks.json(版本化、0600 原子写、不跟随 symlink),生命周期转换时 write-through;seq 持久化避免 pid 复用撞 ID - daemon 重启恢复历史,中断时仍 queued/running 的任务标记 failed, 新增错误码 TASK_INTERRUPTED(BAT-ERR-700005) - 损坏文件改名 .corrupt 留证后从空历史开始;无法识别的记录计数跳过 - core 新增 ErrorCode::ALL 公开码表与 from_id 反查(持久化错误码往返) .env 配置: - 首次启动在二进制所在目录释放 .env 模板(0600、create_new 防竞态), 之后每次启动加载为进程环境变量(不覆盖已存在变量) - 优先级:CLI > 进程环境变量 > .env > 内置默认;BAT_* 键映射到 CliOptions 默认值,BAT_WATCH/BAT_DAEMON 仅对无子命令 Run 生效且 CLI 显式模式/dry-run 时让位;Redis 键预留(未接入) - 工具/代理"非默认"判断改按 env 应用后基线,status/stop/logs 在 .env 存在时不误判;BAT_SKIP_ENV_FILE=1 整体禁用 验证:新增 10 个单测(env 解析/优先级/守卫回归/持久化往返/中断标记/ 损坏恢复/未知类型跳过);真机 e2e:.env 释放加载、daemon 纯 .env 启动、 catalog.refresh 任务带类型化错误码落盘并跨 daemon 重启恢复(含日志); fmt / clippy --workspace --all-targets -D warnings / test --workspace 全绿 Co-Authored-By: Claude Fable 5 --- CURRENT_STATUS.md | 5 +- USERGUIDE.md | 16 +- core/src/error_code.rs | 128 +-- .../architecture/official-resource-backend.md | 4 +- infrastructure/src/bin/bat_official_sync.rs | 821 +++++++++++++++++- 5 files changed, 908 insertions(+), 66 deletions(-) diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md index 9dfca13..84c41a6 100644 --- a/CURRENT_STATUS.md +++ b/CURRENT_STATUS.md @@ -23,10 +23,11 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口: 7. 支持 curl 传输层本地代理:默认自动检测 `HTTPS_PROXY` / `ALL_PROXY` / `HTTP_PROXY` 及小写环境变量(带凭据的代理推荐用环境变量配置),也可用 `--proxy ` 显式指定或 `--no-proxy` 强制直连;代理决策会写入 progress log、daemon log 和 `bat doctor` 诊断输出。代理凭据不落世界可读位置:日志/`status` 脱敏,传给 curl 经 `ALL_PROXY` 环境变量而非 argv,`--daemon` 下经环境变量下传后台子进程、不进子进程 argv 或 `bat-status.json`,复用凭据存于 `bat-proxy.secret`(`0600`)且 `clean-stable` 会清除。 8. `bat --watch` 可常驻运行,`bat --daemon` 可后台运行并用 `bat status` / `bat stop` / `bat restart` / `bat reload` / `bat logs` 管理;daemon 使用 `bat.sock` Unix socket JSON-RPC 作为 live 控制通道,PID/状态/日志文件作为快照和 fallback,`bat-events.jsonl` 记录带轮转的结构化事件日志,`bat-control.lock` 串行化控制命令;正常检查默认每 1 小时一次;远端和本地一致时默认静默,失败后默认 60 秒快速重试;CLI 默认向 stdout 输出人类可读摘要,向 stderr 输出 ASCII banner、progress log、失败分类和 quarantine 状态,需要机器输出时使用 `--json --no-progress`。 9. 远端 snapshot 未变化但输出目录为空时,会按首次运行执行全量拉取;官方 seed `.hash` 校验失败时会清理对应 manifest 条目,避免失败产物被后续本地 audit 误判为可复用。 -10. 默认资源目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`;资源目录是发布根目录,包含 `current` symlink、`versions/` 和 `.staging/`,非 dry-run 会先写 staging,校验完成后发布 versioned 目录并原子切换 `current`;如果上一轮同一 app version、bundle version 和 Addressables root 的 staging 失败但目录仍安全存在,下一轮会复用该 staging 并按 manifest 逐文件校验/补下载;后台状态目录包含 `bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log`、`bat-events.jsonl` 和短生命周期 `bat-control.lock`;非 dry-run 使用 `--output/.official-sync.lock` 防止并发写同一资源目录,live daemon 会阻止前台写命令直接修改它正在管理的同一目录。 +10. 默认资源目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`;资源目录是发布根目录,包含 `current` symlink、`versions/` 和 `.staging/`,非 dry-run 会先写 staging,校验完成后发布 versioned 目录并原子切换 `current`;如果上一轮同一 app version、bundle version 和 Addressables root 的 staging 失败但目录仍安全存在,下一轮会复用该 staging 并按 manifest 逐文件校验/补下载;后台状态目录包含 `bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log`、`bat-events.jsonl`、任务历史 `bat-tasks.json` 和短生命周期 `bat-control.lock`;非 dry-run 使用 `--output/.official-sync.lock` 防止并发写同一资源目录,live daemon 会阻止前台写命令直接修改它正在管理的同一目录。 11. 官方同步会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。 12. `/official-version-state.json` 会明确保存当前已完成版本、正在拉取版本、上一个可用版本和失败版本;同一 app version、bundle version 和 Addressables root 的失败只保留最新一条,同一版本开始重新拉取或后续发布成功时会清理对应失败记录;`bat status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、版本状态摘要、最近历史失败版本和原因、结构化日志路径和轮转日志路径,人类可读输出不会把完整版本状态 JSON 内联打印。 13. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,AssetBundle 导入会记录 UnityFS 摘要,TextAsset/Table/Media 会按类型分类;当前/上一个/结构变化 catalog、403/404、hash mismatch 均有离线回归 fixture。 +14. `bat` 首次启动会在二进制所在目录释放 `.env` 配置模板(`0600`),之后每次启动自动加载(不覆盖已存在的环境变量),支持 `BAT_OUTPUT`/`BAT_STATE_DIR`/`BAT_AUTO_DISCOVER`/`BAT_WATCH`/`BAT_DAEMON`/`BAT_PROXY` 等键,实现编辑 `.env` 后无参启动;优先级为命令行参数 > 进程环境变量 > `.env` > 内置默认值,`BAT_SKIP_ENV_FILE=1` 可整体禁用;Redis 键为预留。daemon 任务历史持久化在 `/bat-tasks.json`(版本化、`0600` 原子写),重启后任务经 `task.*` 仍可查,中断任务标记 `task_interrupted`(`BAT-ERR-700005`)。 仍需明确:这不是完整产品完成。Go CLI 最小入口、完整 AssetBundle 解析、Patch、翻译系统、API Server 和 Web 仍是后续工作;真实官方网络全量拉取 smoke 已固化为可重复脚本和 runbook(G-018 已关闭),当前正在进行长期运行测试,运行报告将在后续提供;真实大文件产物与运行报告默认保存在 `/tmp` 隔离目录,不纳入 Git。 @@ -266,7 +267,7 @@ GitHub issue 状态:#4–#16 已全部关闭(#16 为 daemon status 版本失 下一阶段必须优先完成: -1. Issue #1(P1,主体已实现):`bat.sock` Unix socket JSON-RPC 已扩展为面向 Go 服务层的 Rust Resource Backend API。统一 envelope(`ok`、`status`、`error`、`data`、`request_id`)与 `BAT-ERR` 错误码模型已落地;`daemon.*`(status/logs/stop/reload/refresh)、`resource.*`(state/sync/verify/manifest)、`catalog.*`(status/refresh/diff/versions)、`task.*`(status/list/cancel/logs)已实现,长任务返回 `task_id` 可轮询(内存态任务执行器,单 worker FIFO,与 watch 循环互斥);错误码已接入下载、launcher/metadata、server-info/marker 与配置校验路径。剩余:`patch.*` / `unityfs.*`(被引擎阻塞)、`resource.repair`(待引擎独立修复模式)、`task.create`(按设计由语义方法创建)、任务持久化(内存态,重启即失)。Go 层通过 RPC 调用 Rust backend,不走 FFI(FFI 降级说明见 `docs/architecture/official-resource-backend.md` §7)。 +1. Issue #1(P1,主体已实现):`bat.sock` Unix socket JSON-RPC 已扩展为面向 Go 服务层的 Rust Resource Backend API。统一 envelope(`ok`、`status`、`error`、`data`、`request_id`)与 `BAT-ERR` 错误码模型已落地;`daemon.*`(status/logs/stop/reload/refresh)、`resource.*`(state/sync/verify/manifest)、`catalog.*`(status/refresh/diff/versions)、`task.*`(status/list/cancel/logs)已实现,长任务返回 `task_id` 可轮询(任务执行器单 worker FIFO,与 watch 循环互斥;任务历史持久化于 `/bat-tasks.json`,daemon 重启后仍可查,中断任务标记 `task_interrupted`);错误码已接入下载、launcher/metadata、server-info/marker 与配置校验路径。剩余:`patch.*` / `unityfs.*`(被引擎阻塞)、`resource.repair`(待引擎独立修复模式)、`task.create`(按设计由语义方法创建)、Redis 任务后端(`.env` 已预留配置键,接入时机另议)。Go 层通过 RPC 调用 Rust backend,不走 FFI(FFI 降级说明见 `docs/architecture/official-resource-backend.md` §7)。 2. Go CLI 最小可用入口:`bat doctor`、稳定的 `bat --help` 命令结构,默认通过上述 RPC 或 `bat --json` 进程边界获取同步 report。 3. 官方同步结果接入 CAS + ResourceRepository 的用户级工作流(G-011 剩余部分:自动导入触发、schema 迁移、CLI 查询)。 4. Issue #3(P2):AssetBundle UnityFS 基础解析校验。 diff --git a/USERGUIDE.md b/USERGUIDE.md index f869596..0700c7a 100644 --- a/USERGUIDE.md +++ b/USERGUIDE.md @@ -116,10 +116,21 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon - 平台:`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`)。 +- 后台状态目录:`/tmp/bat-pid`(`bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log`、`bat-events.jsonl`、任务历史 `bat-tasks.json`、短生命周期 `bat-control.lock`;代理凭据在 `bat-proxy.secret`,`0600`)。 - 强制刷新:每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 各一次。 - 状态类文件默认 `0600` 权限,读写不跟随 symlink。 +### 配置文件(`.env`,无参启动) + +`bat` 首次启动时会在**二进制所在目录**释放一个 `.env` 配置模板(`0600` 权限,已存在则不动)。之后每次启动自动加载该文件,把其中的键作为进程环境变量(不覆盖已存在的环境变量),因此编辑 `.env` 后直接运行 `bat`(无参数)即可按配置启动。 + +- 优先级:**命令行参数 > 进程环境变量 > `.env` > 内置默认值**。 +- 语法:每行 `KEY=VALUE`;`#` 开头为注释;值两侧成对引号会剥除;空值视为未设置。 +- 支持的键:`BAT_OUTPUT`、`BAT_STATE_DIR`、`BAT_AUTO_DISCOVER`、`BAT_WATCH`、`BAT_DAEMON`、`BAT_PROXY`、`BAT_NO_PROXY`、`BAT_INTERVAL_SECONDS`、`BAT_ERROR_RETRY_SECONDS`、`BAT_APP_VERSION`、`BAT_CONNECTION_GROUP`、`BAT_LAUNCHER_VERSION`、`BAT_PLATFORMS`、`BAT_CURL`、`BAT_UNZIP`、`BAT_JSON`、`BAT_QUIET_UP_TO_DATE`;也可以直接写 `HTTPS_PROXY` 等通用环境变量(走现有代理自动检测)。布尔值支持 `1/0/true/false/yes/no/on/off`。 +- `BAT_WATCH` / `BAT_DAEMON` 只对无子命令的 `bat` 生效(两者同时为 `1` 时 daemon 优先);命令行显式传入 `--watch` / `--daemon` / `--dry-run` 时 `.env` 的模式开关让位。`status` / `verify` 等子命令不受它们影响。 +- `BAT_REDIS_URL` / `BAT_REDIS_PASSWORD` 为**预留键**:Redis 任务后端尚未接入,当前任务历史持久化在 `/bat-tasks.json`。 +- 设 `BAT_SKIP_ENV_FILE=1` 可让 `bat` 完全跳过 `.env` 的生成与加载。 + --- ## 4. 退出码 @@ -219,6 +230,7 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon | `BAT-ERR-700002` | rpc_invalid_params | 否 | RPC 参数无效 | | `BAT-ERR-700003` | rpc_not_implemented | 否 | 方法/命名空间尚未实现 | | `BAT-ERR-700004` | task_not_found | 否 | 任务不存在 | +| `BAT-ERR-700005` | task_interrupted | 否 | 任务因 daemon 停止/重启而中断 | | `BAT-ERR-900001` | internal | 否 | 未归类的内部错误 | 新增错误码在 `core/src/error_code.rs` 的码表登记后,同步更新本表。 @@ -290,7 +302,7 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon - `task.cancel` 请求取消:置任务的取消标志,worker 在下一个同步检查点中止,任务转为 `cancelled`(协作式,不硬杀正在执行的 curl)。 - 与后台 watch 循环的定时同步通过进程内锁互斥(任务等待而非失败)。 -- 任务目前**仅存内存**,随 daemon 生死;持久化(重启后仍可查)为后续工作。 +- 任务历史**持久化**在 `/bat-tasks.json`(版本化、`0600` 原子写):生命周期转换(入队/开始/结束)时落盘,进行中任务的 `stage`/`message`/日志以内存实时值为准、随下一次转换写入。daemon 重启后历史任务经 `task.status` / `task.list` / `task.logs` 仍可查;重启时仍处于 `queued`/`running` 的任务标记为 `failed`(错误码 `BAT-ERR-700005` task_interrupted)。文件损坏时改名 `bat-tasks.json.corrupt` 留证并从空历史开始。历史保留最近 64 条(运行中任务不裁剪)。 ### 示例 diff --git a/core/src/error_code.rs b/core/src/error_code.rs index 5175c20..ca1dc24 100644 --- a/core/src/error_code.rs +++ b/core/src/error_code.rs @@ -221,10 +221,76 @@ impl ErrorCode { 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); + /// 任务因 daemon 停止/重启而中断(未跑完即失去执行器)。 + pub const TASK_INTERRUPTED: Self = Self::new(700_005, "task_interrupted", false); // ---- 900xxx 内部/未知 ---- /// 未归类的内部错误。 pub const INTERNAL: Self = Self::new(900_001, "internal", false); + + /// 全部命名错误码的码表:查表、唯一性校验和从持久化形态反查共用。 + /// 新增错误码必须同步登记到这里。 + pub const ALL: &'static [Self] = &[ + Self::MISSING_APP_VERSION, + Self::MISSING_CONNECTION_GROUP, + Self::MISSING_SERVER_INFO_SOURCE, + Self::INVALID_PROXY_SCHEME, + Self::INVALID_ARGUMENT, + Self::DANGEROUS_OUTPUT_ROOT, + Self::PATH_ESCAPE, + Self::SYMLINK_REJECTED, + Self::FILE_PERMISSION, + Self::HTTP_FORBIDDEN, + Self::HTTP_NOT_FOUND, + Self::HTTP_CLIENT_ERROR, + Self::HTTP_TOO_MANY_REQUESTS, + Self::HTTP_SERVER_ERROR, + Self::NETWORK_DNS, + Self::NETWORK_CONNECT, + Self::NETWORK_TIMEOUT, + Self::NETWORK_TLS, + Self::NETWORK_INTERRUPTED, + Self::NETWORK_OTHER, + Self::RETRY_EXHAUSTED, + Self::QUARANTINED, + Self::NON_OFFICIAL_URL, + Self::LAUNCHER_API_REJECTED, + Self::PROXY_FAILURE, + Self::BLAKE3_MISMATCH, + Self::OFFICIAL_HASH_MISMATCH, + Self::SIZE_MISMATCH, + Self::ZIP_STRUCTURE_INVALID, + Self::RESOURCE_LOCKED, + Self::STAGING_PREPARE_FAILED, + Self::PUBLISH_FAILED, + Self::VERSION_STATE_WRITE_FAILED, + Self::CAS_HASH_MISMATCH, + Self::CAS_OBJECT_NOT_FOUND, + Self::CAS_REFERENCE_UNDERFLOW, + Self::CAS_DATABASE, + Self::MANIFEST_PARSE_FAILED, + Self::UNITYFS_PARSE_FAILED, + Self::GAME_MAIN_CONFIG_FAILED, + Self::LAUNCHER_RESPONSE_INVALID, + Self::RPC_UNKNOWN_METHOD, + Self::RPC_INVALID_PARAMS, + Self::RPC_NOT_IMPLEMENTED, + Self::TASK_NOT_FOUND, + Self::TASK_INTERRUPTED, + Self::INTERNAL, + ]; + + /// 按错误码数字反查命名常量;未登记的数字返回 `None`。 + pub fn from_number_lookup(number: u32) -> Option { + Self::ALL.iter().copied().find(|code| code.number == number) + } + + /// 按展示 ID(`BAT-ERR-NNNNNN`)反查命名常量;格式非法或未登记返回 `None`。 + /// 用于从持久化/传输形态恢复错误码。 + pub fn from_id(id: &str) -> Option { + let number = id.strip_prefix("BAT-ERR-")?.parse::().ok()?; + Self::from_number_lookup(number) + } } /// 结构化 API 错误:进入 RPC envelope、CLI `--json` 输出和结构化日志的统一形态。 @@ -348,59 +414,21 @@ mod tests { #[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::LAUNCHER_API_REJECTED, - 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::LAUNCHER_RESPONSE_INVALID, - 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(); + // 防止新增码值撞号:公开码表 `ALL` 即全部命名常量,断言 number 唯一。 + let mut numbers: Vec = ErrorCode::ALL.iter().map(ErrorCode::number).collect(); let total = numbers.len(); numbers.sort_unstable(); numbers.dedup(); assert_eq!(numbers.len(), total, "存在重复的错误码 number"); } + + #[test] + fn from_id_round_trips_registered_codes() { + // 持久化恢复路径依赖 from_id 反查:全部登记码必须可经展示 ID 往返。 + for code in ErrorCode::ALL { + assert_eq!(ErrorCode::from_id(&code.id()), Some(*code)); + } + assert_eq!(ErrorCode::from_id("BAT-ERR-999999"), None); + assert_eq!(ErrorCode::from_id("not-an-id"), None); + } } diff --git a/docs/architecture/official-resource-backend.md b/docs/architecture/official-resource-backend.md index 485846a..d298897 100644 --- a/docs/architecture/official-resource-backend.md +++ b/docs/architecture/official-resource-backend.md @@ -292,7 +292,9 @@ JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边 - 长任务(`resource.sync` / `resource.verify` / `catalog.refresh`) 入队即返回 `task_id`,经 `task.status` / `task.list` / `task.logs` 轮询,`task.cancel` 协作式取消。任务执行器是单 worker FIFO,与 - watch 循环经进程内锁互斥。 + watch 循环经进程内锁互斥。任务历史持久化于 `/bat-tasks.json` + (版本化、`0600` 原子写,生命周期转换时落盘),daemon 重启后历史任务 + 仍可经 `task.*` 查询,中断任务标记 `task_interrupted`(700005)。 - 方法命名空间与实现状态、请求/响应示例见 `USERGUIDE.md` §6: `daemon.*` / `resource.*` / `catalog.*` / `task.*` 已实现; `patch.*` / `unityfs.*` 待引擎;`task.create` / `resource.repair` diff --git a/infrastructure/src/bin/bat_official_sync.rs b/infrastructure/src/bin/bat_official_sync.rs index c693a65..425582f 100644 --- a/infrastructure/src/bin/bat_official_sync.rs +++ b/infrastructure/src/bin/bat_official_sync.rs @@ -92,6 +92,7 @@ fn main() { } fn run() -> anyhow::Result { + bootstrap_env_file(); let options = parse_args()?; if matches!( options.command, @@ -200,6 +201,9 @@ struct CliOptions { progress: bool, banner: bool, tail_lines: usize, + /// 环境变量(含 .env)应用后、命令行解析前的配置快照。 + /// 工具/代理"是否命令行显式传入"的判断以它为基线。 + env_baseline_config: OfficialUpdateConfig, } impl Default for CliOptions { @@ -222,6 +226,7 @@ impl Default for CliOptions { progress: true, banner: true, tail_lines: 200, + env_baseline_config: OfficialUpdateConfig::default(), } } } @@ -275,7 +280,9 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> { let sync_lock = Arc::new(Mutex::new(())); let (_task_worker, _task_context, _rpc_server) = if let Some(control) = daemon_control.as_ref() { - let registry = TaskRegistry::new(); + // 任务历史持久化在 state dir(此前已通过 validate_runtime_state_dir 校验)。 + let (registry, restore_summary) = TaskRegistry::with_persistence(&daemon_state_dir); + logger.log_text("daemon", format!("任务历史:{restore_summary}")); let (task_tx, task_rx) = mpsc::channel::(); let worker = { let registry = registry.clone(); @@ -746,6 +753,143 @@ struct TaskStore { tasks: HashMap, order: Vec, seq: u64, + /// 任务历史持久化文件路径;`None` 表示纯内存(测试等非 daemon 场景)。 + persist_path: Option, +} + +/// daemon 任务历史持久化文件名(位于 state dir 内,`0600` 原子写)。 +const TASKS_FILE_NAME: &str = "bat-tasks.json"; +/// 任务历史文件结构版本。 +const TASKS_FILE_VERSION: u32 = 1; + +/// 任务历史文件的持久化形态(版本化;daemon 重启后恢复任务历史用)。 +#[derive(Debug, Serialize, Deserialize)] +struct PersistedTaskFile { + version: u32, + /// 任务 ID 序号计数器;恢复它避免 pid 复用时新任务与历史任务撞 ID。 + seq: u64, + tasks: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct PersistedTaskRecord { + id: String, + kind: String, + status: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + stage: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + message: Option, + created_at: u64, + updated_at: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + started_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + finished_at: Option, + /// `ApiError` 的序列化形态(code/kind/domain/location/message/retryable)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + log: Vec, +} + +/// 把持久化的任务类型映射回静态字符串;未识别(如未来版本新增)返回 `None`。 +fn task_kind_static(kind: &str) -> Option<&'static str> { + match kind { + RPC_METHOD_RESOURCE_SYNC => Some(RPC_METHOD_RESOURCE_SYNC), + RPC_METHOD_RESOURCE_VERIFY => Some(RPC_METHOD_RESOURCE_VERIFY), + RPC_METHOD_CATALOG_REFRESH => Some(RPC_METHOD_CATALOG_REFRESH), + _ => None, + } +} + +/// 把持久化的任务状态映射回静态字符串;未识别返回 `None`。 +fn task_status_static(status: &str) -> Option<&'static str> { + match status { + "queued" => Some("queued"), + "running" => Some("running"), + "succeeded" => Some("succeeded"), + "failed" => Some("failed"), + "cancelled" => Some("cancelled"), + _ => None, + } +} + +impl PersistedTaskRecord { + fn from_record(record: &TaskRecord) -> Self { + Self { + id: record.id.clone(), + kind: record.kind.to_string(), + status: record.status.to_string(), + stage: record.stage.clone(), + message: record.message.clone(), + created_at: record.created_at, + updated_at: record.updated_at, + started_at: record.started_at, + finished_at: record.finished_at, + error: record + .error + .as_ref() + .and_then(|error| serde_json::to_value(error).ok()), + result: record.result.clone(), + log: record.log.clone(), + } + } + + /// 还原为内存任务记录;kind/status 未识别时返回 `None`(调用方计数跳过)。 + fn into_record(self) -> Option { + let kind = task_kind_static(&self.kind)?; + let status = task_status_static(&self.status)?; + // 错误从序列化形态还原:code 经码表反查(未登记回退 internal), + // location 固定为任务执行器(当前全部任务错误的唯一来源)。 + let error = self.error.as_ref().map(|value| { + let code = value + .get("code") + .and_then(serde_json::Value::as_str) + .and_then(ErrorCode::from_id) + .unwrap_or(ErrorCode::INTERNAL); + let message = value + .get("message") + .and_then(serde_json::Value::as_str) + .unwrap_or("<持久化错误信息缺失>") + .to_string(); + ApiError::new(code, "task.executor", message) + }); + Some(TaskRecord { + id: self.id, + kind, + status, + stage: self.stage, + message: self.message, + created_at: self.created_at, + updated_at: self.updated_at, + started_at: self.started_at, + finished_at: self.finished_at, + error, + result: self.result, + cancel: Arc::new(AtomicBool::new(false)), + log: self.log, + }) + } +} + +/// 读取任务历史文件。文件缺失返回 `Ok(None)`;symlink、解析失败或版本不支持返回 `Err`。 +fn load_persisted_tasks(path: &Path) -> Result, String> { + let Some(bytes) = read_file_no_symlink(path, "任务历史")? else { + return Ok(None); + }; + let file: PersistedTaskFile = serde_json::from_slice(&bytes) + .map_err(|error| format!("解析任务历史失败 {}:{error}", path.display()))?; + if file.version != TASKS_FILE_VERSION { + return Err(format!( + "不支持的任务历史版本 {},文件 {}", + file.version, + path.display() + )); + } + Ok(Some(file)) } /// 任务注册表句柄:包住内存存储,供 RPC handler 与 worker 共享。 @@ -757,16 +901,90 @@ struct TaskRegistry { } impl TaskRegistry { + /// 纯内存注册表(无持久化);生产 daemon 走 [`Self::with_persistence`]。 + #[cfg(test)] fn new() -> Self { Self { inner: Arc::new(Mutex::new(TaskStore { tasks: HashMap::new(), order: Vec::new(), seq: 0, + persist_path: None, })), } } + /// 从 state dir 恢复任务历史并启用持久化。 + /// + /// 中断时仍处于 queued/running 的任务标记为 `failed`(`TASK_INTERRUPTED`); + /// 文件缺失按空历史处理;文件损坏或版本不支持时改名 `.corrupt` 留证并从 + /// 空历史开始。返回注册表与恢复摘要(供 daemon 日志记录)。 + fn with_persistence(state_dir: &Path) -> (Self, String) { + let path = state_dir.join(TASKS_FILE_NAME); + let now = unix_seconds_now(); + let mut seq = 0; + let mut tasks = HashMap::new(); + let mut order = Vec::new(); + let summary = match load_persisted_tasks(&path) { + Ok(None) => "无历史任务文件,从空任务历史开始".to_string(), + Ok(Some(file)) => { + seq = file.seq; + let total = file.tasks.len(); + let mut interrupted = 0usize; + let mut skipped = 0usize; + for persisted in file.tasks { + let Some(mut record) = persisted.into_record() else { + skipped += 1; + continue; + }; + if !record.is_finished() { + interrupted += 1; + record.status = "failed"; + record.finished_at = Some(now); + record.updated_at = now; + record.error = Some(ApiError::new( + ErrorCode::TASK_INTERRUPTED, + "task.executor", + "daemon 停止/重启导致任务中断", + )); + record + .log + .push("[daemon] 任务因 daemon 停止/重启而中断".to_string()); + } + if tasks.insert(record.id.clone(), record.clone()).is_none() { + order.push(record.id); + } else { + skipped += 1; + } + } + format!("恢复任务历史 {total} 条(标记中断 {interrupted} 条,跳过无法识别 {skipped} 条)") + } + Err(error) => { + // 保留损坏文件供诊断(改名而非覆盖),从空历史开始。 + let corrupt = path.with_extension("json.corrupt"); + if fs::rename(&path, &corrupt).is_ok() { + format!( + "任务历史不可用({error});原文件已改名保留为 {}", + corrupt.display() + ) + } else { + format!("任务历史不可用({error});且无法改名保留原文件") + } + } + }; + let registry = Self { + inner: Arc::new(Mutex::new(TaskStore { + tasks, + order, + seq, + persist_path: Some(path), + })), + }; + // 把中断标记(或空历史)立即写回,保证文件与内存视图一致。 + registry.lock().persist(); + (registry, summary) + } + fn lock(&self) -> std::sync::MutexGuard<'_, TaskStore> { self.inner .lock() @@ -797,14 +1015,23 @@ impl TaskRegistry { store.tasks.insert(id.clone(), record); store.order.push(id.clone()); store.prune(); + store.persist(); id } fn update(&self, id: &str, update: F) { let mut store = self.lock(); + let mut status_changed = false; if let Some(record) = store.tasks.get_mut(id) { + let previous_status = record.status; update(record); record.updated_at = unix_seconds_now(); + status_changed = record.status != previous_status; + } + // 只在生命周期转换时落盘;stage/message/log 的高频进度更新以内存为准, + // 随下一次转换一起写入(避免每个进度事件一次磁盘写)。 + if status_changed { + store.persist(); } } @@ -863,6 +1090,35 @@ impl TaskRegistry { } impl TaskStore { + /// 把当前任务历史落盘(`0600` 原子写、不跟随 symlink)。 + /// + /// 持久化未启用时为 no-op;写失败只记 stderr(进 daemon 日志), + /// 不让持久化故障拖垮任务执行本身。 + fn persist(&self) { + let Some(path) = &self.persist_path else { + return; + }; + let file = PersistedTaskFile { + version: TASKS_FILE_VERSION, + seq: self.seq, + tasks: self + .order + .iter() + .filter_map(|id| self.tasks.get(id)) + .map(PersistedTaskRecord::from_record) + .collect(), + }; + match serde_json::to_vec_pretty(&file) { + Ok(bytes) => { + if let Err(error) = write_file_atomic(path, &bytes, PRIVATE_FILE_MODE, "任务历史") + { + eprintln!("[daemon] 任务历史落盘失败:{error}"); + } + } + Err(error) => eprintln!("[daemon] 任务历史序列化失败:{error}"), + } + } + /// 裁剪最旧的已结束任务,把内存占用控制在上限内;运行中/排队中的任务不裁剪。 fn prune(&mut self) { while self.order.len() > MAX_RETAINED_TASKS { @@ -2630,7 +2886,7 @@ fn run_daemon_restart(options: &CliOptions, command_name: &'static str) -> anyho let has_explicit_options = options.sync_option_explicit || options.output_explicit || options.proxy_option_explicit - || tools_are_non_default(&options.config); + || tools_are_non_default(&options.config, &options.env_baseline_config); if command_name == "reload" && !has_explicit_options && daemon_rpc_available(&options.state_dir) { let report = daemon_rpc_call(&options.state_dir, RPC_METHOD_RELOAD, None)?; @@ -4673,14 +4929,275 @@ fn should_print_status(status: OfficialUpdateStatus, quiet_up_to_date: bool) -> !(quiet_up_to_date && status == OfficialUpdateStatus::UpToDate) } -fn parse_args() -> anyhow::Result { - parse_args_from(env::args()) +/// `.env` 配置文件名(位于 bat 二进制所在目录)。 +const ENV_FILE_NAME: &str = ".env"; + +/// 设为 `1` 时完全跳过 `.env` 的生成与加载(测试与特殊部署场景用)。 +const SKIP_ENV_FILE_VAR: &str = "BAT_SKIP_ENV_FILE"; + +/// 首次启动释放的 `.env` 配置模板。 +const ENV_TEMPLATE: &str = r#"# BlueArchive Toolkit 配置文件(bat 首次启动自动生成) +# +# 直接运行 `bat`(无参数)时会按本文件配置启动。 +# 优先级:命令行参数 > 进程环境变量 > 本文件 > 内置默认值。 +# 布尔值支持 1/0/true/false/yes/no/on/off;井号开头为注释。 +# 设 BAT_SKIP_ENV_FILE=1 可让 bat 完全忽略本文件。 + +# ---- 基本配置 ---- +# 资源发布根目录(默认 ./bat-resources,相对当前工作目录) +BAT_OUTPUT=./bat-resources +# 自动发现 app-version / connection-group / server-info(无参启动建议保持 1) +BAT_AUTO_DISCOVER=1 +# 后台状态目录(bat.sock / 日志 / 任务历史等;默认 /tmp/bat-pid) +#BAT_STATE_DIR=/tmp/bat-pid +# 启动即进入常驻模式:watch(前台常驻)或 daemon(后台自托管)。 +# 只对无子命令的 `bat` 生效;同时为 1 时 daemon 优先。 +#BAT_WATCH=0 +#BAT_DAEMON=0 +# 正常检查间隔与失败重试间隔(秒) +#BAT_INTERVAL_SECONDS=3600 +#BAT_ERROR_RETRY_SECONDS=60 + +# ---- 网络 ---- +# 显式代理 URL(支持 http/https/socks4/socks4a/socks5/socks5h)。 +# 不设则自动检测 HTTPS_PROXY / ALL_PROXY / HTTP_PROXY(也可写在本文件里)。 +#BAT_PROXY=http://127.0.0.1:7897 +# 设为 1 时强制直连(忽略一切代理配置) +#BAT_NO_PROXY=0 + +# ---- 同步参数(通常保持自动发现,无需手动指定)---- +#BAT_APP_VERSION= +#BAT_CONNECTION_GROUP= +#BAT_LAUNCHER_VERSION= +# 逗号分隔:windows,android +#BAT_PLATFORMS=windows,android +#BAT_CURL=curl +#BAT_UNZIP=unzip + +# ---- 输出 ---- +# 设为 1 时输出机器可读 JSON(默认人类可读) +#BAT_JSON=0 +# 远端与本地一致时是否静默(watch/daemon 模式默认 1) +#BAT_QUIET_UP_TO_DATE= + +# ---- Redis(预留,当前未接入)---- +# 任务历史当前持久化在 /bat-tasks.json; +# Redis 任务后端落地后以下配置才会生效。 +#BAT_REDIS_URL=redis://127.0.0.1:6379 +#BAT_REDIS_PASSWORD= +"#; + +/// `.env` 引导:首次启动时在二进制所在目录释放配置模板,之后每次启动把其中的 +/// 键加载为进程环境变量(不覆盖已存在的环境变量,保持"环境变量 > .env"优先级)。 +/// +/// 任何失败只在 stderr 警告、不中断启动——`.env` 是便利层,不是启动硬依赖。 +fn bootstrap_env_file() { + if env::var(SKIP_ENV_FILE_VAR).map(|value| value == "1") == Ok(true) { + return; + } + let Ok(exe_path) = env::current_exe() else { + return; + }; + let Some(exe_dir) = exe_path.parent() else { + return; + }; + let path = exe_dir.join(ENV_FILE_NAME); + if !path.exists() { + match write_env_template(&path) { + Ok(()) => eprintln!( + "已生成配置模板 {}(编辑其中的 BAT_* 配置后,直接运行 `bat` 即可按 .env 启动)", + path.display() + ), + Err(error) => { + eprintln!("警告:生成 .env 配置模板失败 {}:{error}", path.display()); + return; + } + } + } + match fs::read_to_string(&path) { + Ok(content) => apply_env_file(&content), + Err(error) => eprintln!("警告:读取 .env 失败 {}:{error}", path.display()), + } } +/// 以 `create_new` 原子创建模板文件,避免并发启动时互相覆盖;unix 下限制 `0600` +/// 权限(`.env` 可能保存代理凭据等敏感配置)。 +fn write_env_template(path: &Path) -> std::io::Result<()> { + let mut open_options = OpenOptions::new(); + open_options.write(true).create_new(true); + #[cfg(unix)] + open_options.mode(PRIVATE_FILE_MODE); + let mut file = open_options.open(path)?; + file.write_all(ENV_TEMPLATE.as_bytes()) +} + +/// 解析 `.env` 内容,把进程环境里尚不存在的键设为环境变量。 +fn apply_env_file(content: &str) { + for (line_number, raw_line) in content.lines().enumerate() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, value)) = parse_env_line(line) else { + eprintln!( + "警告:.env 第 {} 行无法解析,已忽略:{raw_line}", + line_number + 1 + ); + continue; + }; + if env::var_os(&key).is_none() { + env::set_var(&key, value); + } + } +} + +/// 解析单行 `KEY=VALUE`。key 须为 `[A-Za-z_][A-Za-z0-9_]*`;值两侧的成对 +/// 单/双引号会剥除。不支持 `export` 前缀和多行值。 +fn parse_env_line(line: &str) -> Option<(String, String)> { + let (key, value) = line.split_once('=')?; + let key = key.trim(); + let valid_key = !key.is_empty() + && key.chars().enumerate().all(|(index, character)| { + character == '_' + || character.is_ascii_alphabetic() + || (index > 0 && character.is_ascii_digit()) + }); + if !valid_key { + return None; + } + let mut value = value.trim(); + if value.len() >= 2 { + let bytes = value.as_bytes(); + let quoted = (bytes[0] == b'"' && bytes[value.len() - 1] == b'"') + || (bytes[0] == b'\'' && bytes[value.len() - 1] == b'\''); + if quoted { + value = &value[1..value.len() - 1]; + } + } + Some((key.to_string(), value.to_string())) +} + +/// `.env`/环境变量提供的运行模式开关(延迟到命令确定后应用;值型配置 +/// 由 [`apply_bat_env_overrides`] 直接写入 options)。 +struct EnvModeOverrides { + watch: bool, + daemon: bool, +} + +/// 把 `BAT_*` 环境变量作为配置默认值写入 options。 +/// +/// 不标记任何 `*_explicit`(命令行参数在其后解析、总是覆盖);非法值报错 +/// 而非静默忽略,保证配置问题可诊断。 +fn apply_bat_env_overrides( + options: &mut CliOptions, + env_lookup: &impl Fn(&str) -> Option, +) -> anyhow::Result { + fn parse_env_bool(key: &str, value: &str) -> anyhow::Result { + match value.to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => Ok(true), + "0" | "false" | "no" | "off" => Ok(false), + other => Err(anyhow::anyhow!( + "环境变量 {key} 的布尔值无效:{other}(支持 1/0/true/false/yes/no/on/off)" + )), + } + } + fn parse_env_seconds(key: &str, value: &str) -> anyhow::Result { + let seconds = value + .parse::() + .map_err(|error| anyhow::anyhow!("环境变量 {key} 的秒数无效:{error}"))?; + Ok(Duration::from_secs(seconds)) + } + // 空值视为未设置:模板里保留 `BAT_XXX=` 形式的空行不产生副作用。 + let value = |key: &str| { + env_lookup(key) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + }; + + if let Some(v) = value("BAT_OUTPUT") { + options.config.output_root = PathBuf::from(v); + } + if let Some(v) = value("BAT_STATE_DIR") { + options.state_dir = PathBuf::from(v); + } + if let Some(v) = value("BAT_AUTO_DISCOVER") { + options.config.auto_discover = parse_env_bool("BAT_AUTO_DISCOVER", &v)?; + } + if let Some(v) = value("BAT_APP_VERSION") { + options.config.app_version = Some(v); + } + if let Some(v) = value("BAT_CONNECTION_GROUP") { + options.config.connection_group = Some(v); + } + if let Some(v) = value("BAT_LAUNCHER_VERSION") { + options.config.launcher_version = v; + } + if let Some(v) = value("BAT_PLATFORMS") { + options.config.platforms = Some(parse_platforms(&v).map_err(anyhow::Error::msg)?); + } + if let Some(v) = value("BAT_CURL") { + options.config.curl_command = PathBuf::from(v); + } + if let Some(v) = value("BAT_UNZIP") { + options.config.unzip_command = PathBuf::from(v); + } + if let Some(v) = value("BAT_PROXY") { + options.config.curl_proxy = parse_proxy_config(&v)?; + } + if let Some(v) = value("BAT_NO_PROXY") { + if parse_env_bool("BAT_NO_PROXY", &v)? { + options.config.curl_proxy = CurlProxyConfig::disabled(); + } + } + if let Some(v) = value("BAT_INTERVAL_SECONDS") { + options.interval = parse_env_seconds("BAT_INTERVAL_SECONDS", &v)?; + } + if let Some(v) = value("BAT_ERROR_RETRY_SECONDS") { + options.error_retry_interval = parse_env_seconds("BAT_ERROR_RETRY_SECONDS", &v)?; + } + if let Some(v) = value("BAT_JSON") { + if parse_env_bool("BAT_JSON", &v)? { + options.output_format = OutputFormat::Json; + } + } + if let Some(v) = value("BAT_QUIET_UP_TO_DATE") { + options.quiet_up_to_date = parse_env_bool("BAT_QUIET_UP_TO_DATE", &v)?; + options.quiet_up_to_date_explicit = true; + } + let watch = match value("BAT_WATCH") { + Some(v) => parse_env_bool("BAT_WATCH", &v)?, + None => false, + }; + let daemon = match value("BAT_DAEMON") { + Some(v) => parse_env_bool("BAT_DAEMON", &v)?, + None => false, + }; + Ok(EnvModeOverrides { watch, daemon }) +} + +fn parse_args() -> anyhow::Result { + parse_args_with_env(env::args(), |key| env::var(key).ok()) +} + +/// 测试入口:不读环境变量,解析结果只由参数决定。 +#[cfg(test)] fn parse_args_from(raw_args: impl IntoIterator) -> anyhow::Result { + parse_args_with_env(raw_args, |_| None) +} + +fn parse_args_with_env( + raw_args: impl IntoIterator, + env_lookup: impl Fn(&str) -> Option, +) -> anyhow::Result { let mut args = raw_args.into_iter(); let binary = args.next().unwrap_or_else(|| "bat".to_string()); let mut options = CliOptions::default(); + // `BAT_*` 环境变量(含 .env 加载的)先作为默认值写入,不标记 explicit; + // 命令行参数随后解析,逐字段覆盖。工具/代理的"非默认"判断以本基线为准, + // 保证 status/stop/logs 在 .env 存在时不误判为显式传了同步参数。 + let env_modes = apply_bat_env_overrides(&mut options, &env_lookup)?; + options.env_baseline_config = options.config.clone(); + let mut mode_flag_from_cli = false; while let Some(flag) = args.next() { match flag.as_str() { @@ -4832,15 +5349,18 @@ fn parse_args_from(raw_args: impl IntoIterator) -> anyhow::Result "--watch" => { options.watch = true; options.sync_option_explicit = true; + mode_flag_from_cli = true; } "--daemon" => { options.daemon = true; options.sync_option_explicit = true; + mode_flag_from_cli = true; } "--daemon-child" => { options.daemon_child = true; options.watch = true; options.sync_option_explicit = true; + mode_flag_from_cli = true; } "--interval" => { options.interval = parse_duration(&next_option_value(&mut args, &flag)?)?; @@ -4911,12 +5431,24 @@ fn parse_args_from(raw_args: impl IntoIterator) -> anyhow::Result } } + // `BAT_WATCH` / `BAT_DAEMON` 只影响无子命令的 Run(无参启动场景); + // 命令行显式选择了运行模式或 dry-run 时让位(命令行优先于 .env), + // status/verify 等子命令不受其影响。daemon 优先于 watch(daemon 自带 watch)。 + if matches!(options.command, CliCommand::Run) && !mode_flag_from_cli && !options.config.dry_run + { + if env_modes.daemon { + options.daemon = true; + } else if env_modes.watch { + options.watch = true; + } + } + match options.command { CliCommand::Status | CliCommand::Stop | CliCommand::Logs => { if options.sync_option_explicit || options.output_explicit || options.proxy_option_explicit - || tools_are_non_default(&options.config) + || tools_are_non_default(&options.config, &options.env_baseline_config) { return Err(anyhow::anyhow!( "status/stop/logs 只读取 --state-dir;资源同步参数和输出目录无关" @@ -4953,7 +5485,7 @@ fn parse_args_from(raw_args: impl IntoIterator) -> anyhow::Result if (options.sync_option_explicit || options.output_explicit || options.proxy_option_explicit - || tools_are_non_default(&options.config)) + || tools_are_non_default(&options.config, &options.env_baseline_config)) && !options.config.auto_discover && options.config.server_info_source.is_none() && options.config.connection_group.is_none() @@ -5019,11 +5551,12 @@ fn ensure_command_not_set(command: CliCommand, next: &str) -> anyhow::Result<()> } } -fn tools_are_non_default(config: &OfficialUpdateConfig) -> bool { - let defaults = OfficialUpdateConfig::default(); - config.curl_command != defaults.curl_command - || config.curl_proxy != defaults.curl_proxy - || config.unzip_command != defaults.unzip_command +/// 判断 curl/代理/unzip 是否偏离基线。基线是环境变量(含 .env)应用后的 +/// 配置快照,因此只有命令行显式传入才算"非默认"。 +fn tools_are_non_default(config: &OfficialUpdateConfig, baseline: &OfficialUpdateConfig) -> bool { + config.curl_command != baseline.curl_command + || config.curl_proxy != baseline.curl_proxy + || config.unzip_command != baseline.unzip_command } fn next_option_value( @@ -5213,6 +5746,272 @@ mod tests { parse_args_from(values.iter().map(|value| value.to_string())) } + fn parse_with_env(values: &[&str], env: &[(&str, &str)]) -> anyhow::Result { + let map: HashMap = env + .iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect(); + parse_args_with_env(values.iter().map(|value| value.to_string()), move |key| { + map.get(key).cloned() + }) + } + + #[test] + fn env_defaults_apply_and_cli_overrides() { + let options = parse_with_env( + &["bat"], + &[ + ("BAT_OUTPUT", "/srv/bat"), + ("BAT_AUTO_DISCOVER", "1"), + ("BAT_STATE_DIR", "/srv/state"), + ("BAT_INTERVAL_SECONDS", "120"), + ], + ) + .unwrap(); + assert_eq!(options.config.output_root, PathBuf::from("/srv/bat")); + assert!(options.config.auto_discover); + assert_eq!(options.state_dir, PathBuf::from("/srv/state")); + assert_eq!(options.interval, Duration::from_secs(120)); + + // 命令行覆盖环境变量。 + let options = parse_with_env( + &["bat", "--output", "/cli/out"], + &[("BAT_OUTPUT", "/srv/bat")], + ) + .unwrap(); + assert_eq!(options.config.output_root, PathBuf::from("/cli/out")); + + // 空值视为未设置(模板中保留 `BAT_XXX=` 空行无副作用)。 + let options = parse_with_env(&["bat"], &[("BAT_OUTPUT", "")]).unwrap(); + assert_eq!( + options.config.output_root, + CliOptions::default().config.output_root + ); + } + + #[test] + fn env_watch_daemon_only_affect_bare_run() { + let options = parse_with_env(&["bat"], &[("BAT_WATCH", "1")]).unwrap(); + assert!(options.watch); + + // 同时设置时 daemon 优先(daemon 自带 watch)。 + let options = parse_with_env(&["bat"], &[("BAT_WATCH", "1"), ("BAT_DAEMON", "1")]).unwrap(); + assert!(options.daemon); + assert!(!options.watch); + + // 子命令不受影响:verify 内部置 dry-run,若误吃 env watch 会直接解析失败。 + let options = parse_with_env(&["bat", "verify"], &[("BAT_WATCH", "1")]).unwrap(); + assert!(!options.watch); + assert!(!options.daemon); + + // 命令行显式 --dry-run 时 env watch 让位(命令行意图优先)。 + let options = parse_with_env(&["bat", "--dry-run"], &[("BAT_WATCH", "1")]).unwrap(); + assert!(!options.watch); + + // 命令行显式选择前台 watch 时 env daemon 让位。 + let options = parse_with_env(&["bat", "--watch"], &[("BAT_DAEMON", "1")]).unwrap(); + assert!(options.watch); + assert!(!options.daemon); + } + + #[test] + fn env_values_do_not_break_status_and_reload_guard() { + // .env 提供的代理/工具/输出目录不算"显式同步参数",status 应照常可用。 + let options = parse_with_env( + &["bat", "status"], + &[ + ("BAT_PROXY", "http://127.0.0.1:7897"), + ("BAT_CURL", "/usr/bin/curl"), + ("BAT_OUTPUT", "/srv/bat"), + ], + ) + .unwrap(); + assert_eq!(options.command, CliCommand::Status); + assert!(!tools_are_non_default( + &options.config, + &options.env_baseline_config + )); + + // 命令行再改工具才算偏离基线。 + let options = parse_with_env( + &["bat", "--curl", "/opt/curl"], + &[("BAT_PROXY", "http://127.0.0.1:7897")], + ) + .unwrap(); + assert!(tools_are_non_default( + &options.config, + &options.env_baseline_config + )); + } + + #[test] + fn env_invalid_values_error() { + assert!(parse_with_env(&["bat"], &[("BAT_WATCH", "maybe")]).is_err()); + assert!(parse_with_env(&["bat"], &[("BAT_INTERVAL_SECONDS", "abc")]).is_err()); + assert!(parse_with_env(&["bat"], &[("BAT_PROXY", "ftp://x")]).is_err()); + } + + #[test] + fn parse_env_line_handles_quotes_and_rejects_bad_keys() { + assert_eq!( + parse_env_line("KEY=value"), + Some(("KEY".to_string(), "value".to_string())) + ); + assert_eq!( + parse_env_line("KEY=\"quoted value\""), + Some(("KEY".to_string(), "quoted value".to_string())) + ); + assert_eq!( + parse_env_line("KEY='single'"), + Some(("KEY".to_string(), "single".to_string())) + ); + assert_eq!( + parse_env_line("BAT_OUTPUT = ./x"), + Some(("BAT_OUTPUT".to_string(), "./x".to_string())) + ); + assert_eq!(parse_env_line("no_equals_sign"), None); + assert_eq!(parse_env_line("1BAD=x"), None); + assert_eq!(parse_env_line("BAD KEY=x"), None); + } + + #[test] + fn env_template_is_parseable_and_bootstrap_ready() { + // 模板每个非注释行必须可解析;无参启动所需的最小配置默认启用。 + let mut keys = Vec::new(); + for line in ENV_TEMPLATE.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let (key, _) = + parse_env_line(line).unwrap_or_else(|| panic!("模板行必须可解析:{line}")); + keys.push(key); + } + assert!(keys.contains(&"BAT_OUTPUT".to_string())); + assert!(keys.contains(&"BAT_AUTO_DISCOVER".to_string())); + } + + #[test] + fn task_persistence_round_trip_and_interrupt_marking() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path(); + + let (registry, summary) = TaskRegistry::with_persistence(state_dir); + assert!(summary.contains("空任务历史"), "{summary}"); + let finished_id = registry.create(TaskKind::Sync); + registry.update(&finished_id, |record| { + record.status = "running"; + record.started_at = Some(record.created_at); + }); + registry.append_log(&finished_id, "进度 1".to_string()); + registry.update(&finished_id, |record| { + record.status = "succeeded"; + record.finished_at = Some(record.created_at + 1); + record.result = Some(serde_json::json!({ "ok": true })); + }); + let running_id = registry.create(TaskKind::Verify); + registry.update(&running_id, |record| record.status = "running"); + let failed_id = registry.create(TaskKind::Refresh); + registry.update(&failed_id, |record| { + record.status = "failed"; + record.error = Some(ApiError::new( + ErrorCode::HTTP_NOT_FOUND, + "task.executor", + "404", + )); + }); + drop(registry); + + // 重启:恢复历史;running 任务标记中断;错误码经持久化往返保留。 + let (registry, summary) = TaskRegistry::with_persistence(state_dir); + assert!(summary.contains("恢复任务历史 3 条"), "{summary}"); + assert!(summary.contains("标记中断 1 条"), "{summary}"); + let finished = registry.get(&finished_id).unwrap(); + assert_eq!(finished.status, "succeeded"); + assert_eq!(finished.result, Some(serde_json::json!({ "ok": true }))); + assert_eq!( + registry.logs(&finished_id).unwrap(), + vec!["进度 1".to_string()] + ); + let interrupted = registry.get(&running_id).unwrap(); + assert_eq!(interrupted.status, "failed"); + assert_eq!( + interrupted.error.as_ref().unwrap().code(), + ErrorCode::TASK_INTERRUPTED + ); + assert!(interrupted.finished_at.is_some()); + let failed = registry.get(&failed_id).unwrap(); + assert_eq!( + failed.error.as_ref().unwrap().code(), + ErrorCode::HTTP_NOT_FOUND + ); + + // seq 持久化:重启后(本测试内 pid 相同)新任务不与历史撞 ID。 + let new_id = registry.create(TaskKind::Sync); + assert!( + [&finished_id, &running_id, &failed_id] + .iter() + .all(|id| **id != new_id), + "新任务 ID {new_id} 与历史撞号" + ); + assert_eq!(registry.list().len(), 4); + } + + #[test] + fn task_persistence_recovers_from_corrupt_file() { + let temp = tempfile::TempDir::new().unwrap(); + fs::write(temp.path().join(TASKS_FILE_NAME), b"not-json").unwrap(); + + let (registry, summary) = TaskRegistry::with_persistence(temp.path()); + assert!(summary.contains("任务历史不可用"), "{summary}"); + // 损坏文件改名留证,不静默覆盖。 + assert!(temp.path().join("bat-tasks.json.corrupt").exists()); + assert!(registry.list().is_empty()); + + // 恢复后可正常写入新历史。 + registry.create(TaskKind::Sync); + let bytes = fs::read(temp.path().join(TASKS_FILE_NAME)).unwrap(); + let file: PersistedTaskFile = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(file.version, TASKS_FILE_VERSION); + assert_eq!(file.tasks.len(), 1); + assert_eq!(file.tasks[0].status, "queued"); + } + + #[test] + fn task_persistence_skips_unknown_kinds() { + let temp = tempfile::TempDir::new().unwrap(); + let file = serde_json::json!({ + "version": TASKS_FILE_VERSION, + "seq": 9, + "tasks": [ + { + "id": "task-1-1", + "kind": "patch.apply", + "status": "succeeded", + "created_at": 1, + "updated_at": 2 + }, + { + "id": "task-1-2", + "kind": "resource.sync", + "status": "succeeded", + "created_at": 3, + "updated_at": 4 + } + ] + }); + fs::write( + temp.path().join(TASKS_FILE_NAME), + serde_json::to_vec(&file).unwrap(), + ) + .unwrap(); + + let (registry, summary) = TaskRegistry::with_persistence(temp.path()); + assert!(summary.contains("跳过无法识别 1 条"), "{summary}"); + assert!(registry.get("task-1-1").is_none()); + assert_eq!(registry.get("task-1-2").unwrap().status, "succeeded"); + } + #[test] fn parses_auto_discover_sync_args() { let options = parse(&[