From 7890b667d76c91ee096431c79ee4a50111902c69 Mon Sep 17 00:00:00 2001 From: Yuyi-Oak <1722157266@qq.com> Date: Wed, 15 Jul 2026 22:32:27 +0800 Subject: [PATCH] =?UTF-8?q?fix(daemon):=20=E5=8E=BB=E9=87=8D=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E7=89=88=E6=9C=AC=E5=B9=B6=E4=BC=98=E5=8C=96=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E8=BE=93=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../architecture/official-resource-backend.md | 2 +- docs/guides/official-resource-test-pull.md | 4 +- infrastructure/src/bin/bat_official_sync.rs | 68 ++++++---- infrastructure/src/official_update.rs | 124 ++++++++++++++++-- 4 files changed, 163 insertions(+), 35 deletions(-) diff --git a/docs/architecture/official-resource-backend.md b/docs/architecture/official-resource-backend.md index 502dc67..ee73c2c 100644 --- a/docs/architecture/official-resource-backend.md +++ b/docs/architecture/official-resource-backend.md @@ -244,7 +244,7 @@ Linux 生产路径: - `OfficialUpdateService` 能持久化 v2 snapshot,并在远端 marker 内容变化时触发下载决策 - `bat` 默认向 stderr 输出 `BlueArchiveToolkit` ASCII banner 和 progress log,stdout 默认输出人类可读摘要;progress log 覆盖总体下载进度、单文件开始/完成状态、下载中断失败分类和校验结果摘要;支持 `--json` 输出稳定 JSON,支持 `--no-progress` 关闭进度日志,支持 `--no-banner` 只关闭横幅,支持 `--watch --interval 1h --error-retry 60s` 常驻运行,支持 `--daemon` Unix socket JSON-RPC 控制、`status`、`stop`、`restart`、`reload`、`logs`、`refresh --force`、`verify`、`repair`、`doctor`、`clean-stable`,非 dry-run 使用 `.official-sync.lock` 防止并发写资源目录,控制命令使用 `bat-control.lock` 防止并发状态修改,资源发布使用 `.staging`、`versions` 和 `current` 原子切换,daemon 写 `bat-events.jsonl` 结构化日志并在 `status` 中暴露下载进度、失败类型、HTTP 状态和调度状态 - curl 失败分类和重试策略已覆盖 404 不重试、5xx 重试耗尽后 quarantine、launcher primary CDN 失败后切换 official backup CDN -- `official-version-state.json` 已覆盖当前完成版本、正在拉取版本、上一个可用版本和失败版本,`bat status` 会暴露版本状态摘要 +- `official-version-state.json` 已覆盖当前完成版本、正在拉取版本、上一个可用版本和失败版本;重复失败按 app version、bundle version、Addressables root 和错误原因合并,成功发布后清理同版本失败记录,`bat status` 会暴露版本状态摘要和最近失败原因 - 资源导入链路已覆盖 CAS 写入、`ResourceRepository` 索引、AssetBundle UnityFS 摘要,以及 TextAsset/Table/Media 分类 - 离线回归样本已覆盖当前 catalog、上一个版本 catalog、catalog 结构变化、403、404 和 seed hash mismatch - `OfficialUpdateService` 能读写 `official-bootstrap-cache.json`,并支持默认开启的 `audit_local` / `repair` CLI 行为 diff --git a/docs/guides/official-resource-test-pull.md b/docs/guides/official-resource-test-pull.md index c70eb14..8f9c34c 100644 --- a/docs/guides/official-resource-test-pull.md +++ b/docs/guides/official-resource-test-pull.md @@ -161,7 +161,7 @@ cargo run -p bat-infrastructure --example official_pull_plan -- \ - 旧 launcher 包或 `resources.assets` 下载路径使用官方 launcher CDN 配置,primary CDN 失败后会切换官方 backup CDN;资源 patch host 当前只使用 server-info 返回的官方 client-patch host,不猜测非官方镜像。 - 远端和本地都一致:单次模式输出 `update_status=up_to_date`,watch 模式默认静默并等待下次检查。 - 有远端变化或本地 repair:生成 pull plan,下载完整官方资源到 staging,成功后更新 snapshot 并原子发布到 `current`。 -- 非 dry-run 会维护 `/official-version-state.json`:开始下载后写入 `in_progress_version`,发布成功后写入 `current_completed_version` 和 `previous_available_version`,失败或中断后写入 `failed_versions`。 +- 非 dry-run 会维护 `/official-version-state.json`:开始下载后写入 `in_progress_version`,发布成功后写入 `current_completed_version` 和 `previous_available_version`,失败或中断后写入 `failed_versions`。相同 app version、bundle version、Addressables root 和错误原因的重复失败只保留最新一条;同一版本后续发布成功时会清理对应失败记录。 - `--dry-run`:只报告本次是否会下载,不写 snapshot;如果 cache miss,也不会写入新的 bootstrap cache。 - `--dry-run --plan`:除更新判断外,还会解析 seed catalog 并打印完整下载 URL。 - 真实更新会输出 `downloaded_count`、`resumed_count`、`skipped_count`、`transferred_bytes`、`official_seed_hash_verified_count`。 @@ -222,7 +222,7 @@ cargo run -p bat-infrastructure --bin bat -- reload cargo run -p bat-infrastructure --bin bat -- stop ``` -`status`、`stop`、`logs`、`reload` 和默认形态的 `refresh` 会优先连接 `bat.sock`,通过 Unix socket JSON-RPC 和 live daemon 通信;socket 不可用时,`status`、`stop` 会回退到 PID/状态文件兼容路径。`status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、文本日志路径、结构化日志路径和轮转日志路径。控制命令会通过 `bat-control.lock` 做跨进程互斥,失效或损坏的控制锁会在下次控制命令或 `clean-stable` 时恢复。`restart` 会停止旧后台进程并按保存参数或显式参数重新启动;`reload` 在未显式传入同步参数时不会重启进程,而是唤醒或排队 watch 循环重新执行自动发现和强制刷新:空闲睡眠时立即执行,正在同步时等当前轮结束。所有命令默认输出人类可读摘要,脚本集成时加 `--json`。 +`status`、`stop`、`logs`、`reload` 和默认形态的 `refresh` 会优先连接 `bat.sock`,通过 Unix socket JSON-RPC 和 live daemon 通信;socket 不可用时,`status`、`stop` 会回退到 PID/状态文件兼容路径。`status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、版本状态摘要、最近失败版本和原因、文本日志路径、结构化日志路径和轮转日志路径;人类输出不会把完整 `official-version-state.json` 内联打印成 JSON。控制命令会通过 `bat-control.lock` 做跨进程互斥,失效或损坏的控制锁会在下次控制命令或 `clean-stable` 时恢复。`restart` 会停止旧后台进程并按保存参数或显式参数重新启动;`reload` 在未显式传入同步参数时不会重启进程,而是唤醒或排队 watch 循环重新执行自动发现和强制刷新:空闲睡眠时立即执行,正在同步时等当前轮结束。所有命令默认输出人类可读摘要,脚本集成时加 `--json`。 如果要把后台状态目录改到其他位置,使用 `--state-dir <目录>`: diff --git a/infrastructure/src/bin/bat_official_sync.rs b/infrastructure/src/bin/bat_official_sync.rs index a9b09ba..edcb149 100644 --- a/infrastructure/src/bin/bat_official_sync.rs +++ b/infrastructure/src/bin/bat_official_sync.rs @@ -1896,7 +1896,7 @@ fn print_human_json_value(value: &serde_json::Value) -> anyhow::Result<()> { print_json_field(value, "current_message", "当前消息"); print_json_field(value, "download_progress", "下载进度"); print_json_field(value, "version_state_path", "版本状态路径"); - print_json_field(value, "version_state", "版本状态"); + print_daemon_version_state_json_summary(value.get("version_state")); print_json_field(value, "resource_output_root", "资源目录"); print_json_field(value, "state_dir", "状态目录"); print_json_field(value, "socket_path", "socket"); @@ -1935,6 +1935,49 @@ fn print_human_json_value(value: &serde_json::Value) -> anyhow::Result<()> { Ok(()) } +fn print_daemon_version_state_json_summary(value: Option<&serde_json::Value>) { + let Some(value) = value else { + return; + }; + if value.is_null() { + return; + } + match serde_json::from_value::(value.clone()) { + Ok(version_state) => print_daemon_version_state_summary(&version_state), + Err(error) => print_field("版本状态", format!("无法解析:{error}")), + } +} + +fn print_daemon_version_state_summary(version_state: &OfficialVersionState) { + print_optional_field( + "当前完成版本", + version_state + .current_completed_version + .as_ref() + .map(|version| version.id.as_str()), + ); + print_optional_field( + "正在拉取版本", + version_state + .in_progress_version + .as_ref() + .map(|version| version.id.as_str()), + ); + print_optional_field( + "上一个可用版本", + version_state + .previous_available_version + .as_ref() + .map(|version| version.id.as_str()), + ); + print_field("失败版本数", version_state.failed_versions.len()); + if let Some(failed) = version_state.failed_versions.last() { + print_field("最近失败版本", &failed.version.id); + print_field("最近失败时间", failed.failed_unix_seconds); + print_field("最近失败原因", &failed.error); + } +} + fn print_json_field(value: &serde_json::Value, key: &str, label: &str) { let Some(value) = value.get(key) else { return; @@ -2177,28 +2220,7 @@ impl HumanReport for DaemonStatusReport { print_field("下载进度", format_daemon_download_progress(progress)); } if let Some(version_state) = self.version_state.as_ref() { - print_optional_field( - "当前完成版本", - version_state - .current_completed_version - .as_ref() - .map(|version| version.id.as_str()), - ); - print_optional_field( - "正在拉取版本", - version_state - .in_progress_version - .as_ref() - .map(|version| version.id.as_str()), - ); - print_optional_field( - "上一个可用版本", - version_state - .previous_available_version - .as_ref() - .map(|version| version.id.as_str()), - ); - print_field("失败版本数", version_state.failed_versions.len()); + print_daemon_version_state_summary(version_state); } print_optional_path_field("资源目录", self.resource_output_root.as_ref()); print_optional_path_field("版本状态", self.version_state_path.as_ref()); diff --git a/infrastructure/src/official_update.rs b/infrastructure/src/official_update.rs index ebd3db2..f993d05 100644 --- a/infrastructure/src/official_update.rs +++ b/infrastructure/src/official_update.rs @@ -1967,11 +1967,12 @@ fn recover_interrupted_version_state(path: &Path) -> anyhow::Result<()> { return Ok(()); }; - state.failed_versions.push(OfficialFailedVersionRecord { + upsert_failed_version( + &mut state.failed_versions, version, - error: "上一次官方资源同步在完成发布前中断".to_string(), - failed_unix_seconds: unix_seconds_now(), - }); + "上一次官方资源同步在完成发布前中断", + unix_seconds_now(), + ); trim_failed_versions(&mut state.failed_versions); state.updated_unix_seconds = unix_seconds_now(); write_version_state(path, &state) @@ -2047,6 +2048,9 @@ fn complete_version_state( completed_unix_seconds: Some(unix_seconds_now()), ..record }); + if let Some(current) = state.current_completed_version.as_ref() { + remove_completed_version_failures(&mut state.failed_versions, current); + } state.in_progress_version = None; state.updated_unix_seconds = unix_seconds_now(); write_version_state(path, &state) @@ -2059,11 +2063,12 @@ fn fail_version_state( ) -> anyhow::Result<()> { let mut state = read_version_state(path)?.unwrap_or_default(); state.in_progress_version = None; - state.failed_versions.push(OfficialFailedVersionRecord { - version: record, - error: error.to_string(), - failed_unix_seconds: unix_seconds_now(), - }); + upsert_failed_version( + &mut state.failed_versions, + record, + error, + unix_seconds_now(), + ); trim_failed_versions(&mut state.failed_versions); state.updated_unix_seconds = unix_seconds_now(); write_version_state(path, &state) @@ -2121,6 +2126,37 @@ fn trim_failed_versions(failed_versions: &mut Vec) } } +fn upsert_failed_version( + failed_versions: &mut Vec, + record: OfficialVersionRecord, + error: &str, + failed_unix_seconds: u64, +) { + if let Some(index) = failed_versions.iter().position(|failed| { + failed_version_matches(&failed.version, &record) && failed.error == error + }) { + failed_versions.remove(index); + } + failed_versions.push(OfficialFailedVersionRecord { + version: record, + error: error.to_string(), + failed_unix_seconds, + }); +} + +fn remove_completed_version_failures( + failed_versions: &mut Vec, + completed: &OfficialVersionRecord, +) { + failed_versions.retain(|failed| !failed_version_matches(&failed.version, completed)); +} + +fn failed_version_matches(left: &OfficialVersionRecord, right: &OfficialVersionRecord) -> bool { + left.app_version == right.app_version + && left.bundle_version == right.bundle_version + && left.addressables_root == right.addressables_root +} + fn snapshot_path_for_state_path( _snapshot: &OfficialUpdateSnapshot, resource_root: &Path, @@ -2764,6 +2800,76 @@ mod tests { assert!(state.failed_versions[0].error.contains("403")); } + #[test] + fn version_state_deduplicates_repeated_failures_and_clears_after_success() { + let temp = tempfile::TempDir::new().unwrap(); + let path = temp.path().join("official-version-state.json"); + let snapshot = OfficialUpdateSnapshot::new( + fixture_base_snapshot(), + vec![fixture_marker("current")], + Some(&fixture_bootstrap()), + ); + let active = temp.path().join("versions/active"); + + for id in ["broken-1", "broken-2"] { + let staging = temp.path().join(".staging").join(id); + let failed = prepare_in_progress_version_state( + &path, + &snapshot, + id, + &active, + None, + &staging, + &staging.join("official-sync-snapshot.json"), + ) + .unwrap(); + fail_version_state(&path, failed, "download 403").unwrap(); + } + + let state = read_version_state(&path).unwrap().unwrap(); + assert_eq!(state.failed_versions.len(), 1); + assert_eq!(state.failed_versions[0].version.id, "broken-2"); + assert_eq!(state.failed_versions[0].error, "download 403"); + + let staging = temp.path().join(".staging/broken-3"); + let failed = prepare_in_progress_version_state( + &path, + &snapshot, + "broken-3", + &active, + None, + &staging, + &staging.join("official-sync-snapshot.json"), + ) + .unwrap(); + fail_version_state(&path, failed, "download 404").unwrap(); + let state = read_version_state(&path).unwrap().unwrap(); + assert_eq!(state.failed_versions.len(), 2); + + let staging = temp.path().join(".staging/fixed"); + let fixed = prepare_in_progress_version_state( + &path, + &snapshot, + "fixed", + &active, + None, + &staging, + &staging.join("official-sync-snapshot.json"), + ) + .unwrap(); + let published = temp.path().join("versions/fixed"); + complete_version_state( + &path, + fixed, + &published, + &published.join("official-sync-snapshot.json"), + ) + .unwrap(); + + let state = read_version_state(&path).unwrap().unwrap(); + assert!(state.failed_versions.is_empty()); + } + #[test] fn update_rejects_dangerous_output_root_before_network_work() { let config = OfficialUpdateConfig {