refactor(cli): 模块化 Rust 前台输出
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s

This commit is contained in:
2026-09-02 17:42:16 +08:00
parent bd1e1a06f2
commit 34e2f0d907
20 changed files with 1461 additions and 1407 deletions
+6 -5
View File
@@ -22,18 +22,19 @@
- USERGUIDE 补充 `bat-api` 资源 bootstrap / 分发章节,说明与 Rust `bat` 的运行关系、接口、CDN 响应语义和不仿造业务 API 的边界
- 官方同步新增 `official-parse-cache.json`:校验发布后从下载 manifest 覆盖直接 UnityFS bundle、zip 内 UnityFS 条目和非候选资源记录;URL、相对路径、size 和 BLAKE3 未变化时跳过重复解析
- 官方原版资源和汉化产物目录分离:`BAT_OUTPUT`/`--output` 默认 `./bat-resources``BAT_LOCALIZED_OUTPUT`/`--localized-output` 默认 `./bat-localized`;同步报告新增 `localized_release_status=not_localized`,后续 Patch 发布完成后才切换为 `localized`
- 官方资源同步支持从已发布历史 release 或 CAS 复用已校验文件:复用前检查 size、BLAKE3 和 ZIP 结构,失败保留诊断并回退网络;release 级 CAS 引用写入 `official-cas-reuse-references.json`,清理流程同步回收引用
- Rust `bat` 的报告渲染与前台终端输出分别模块化到 `report_output.rs``terminal_output.rs`,保持 JSON、人类摘要、帮助、进度和错误输出契约不变
### 修复
- 官方下载失败重试之间加入指数退避(网络类失败 200ms→400ms→800ms…,上限 5s
### 计划
- [ ] `bat-api` 后续:refresh mtime/size 增量缓存、完整 launcher 安装包更新链(若需要,新 issue)、API 持久化层接入预留 database/redis 配置;Rust/Go snapshot contract fixture 与同机 live smoke 已完成
- [ ] 官方同步结果接入 CAS + ResourceRepository 的用户级工作流
- [ ] 扩展 CAS + ResourceRepository 的用户级查询、翻译记忆和通用 Patch 发布资源视图
- [ ] 官方下载/导入路径接入 CRC/size 校验(复用 `verify_downloaded_bytes`
- [ ] 汉化 Patch 发布:维护 `localized-output/current``localized-version-state``localized` 状态切换和回滚
- [ ] 实现翻译系统
- [ ] 完成发布级 Patch build/rollback、复杂 AssetBundle 重打包和汉化发布统一
- [ ] 实现 Web 管理后台
- [ ] 完成通用 manifest 驱动的 Patch build/rollback、复杂 AssetBundle 重打包和完整汉化文件集合发布
- [ ] 扩展 provider 编排、翻译记忆和人工协作工作流
- [ ] 完成 Web 协作后台的持久化、权限和长期任务能力
## [0.2.0] - 2026-07-17
+2 -1
View File
@@ -143,6 +143,7 @@ job/unit/provider run 状态。当前下载实现使用默认 8 个独立 worker
- `OfficialResourcePullService`:官方 URL 拒绝策略、目标路径映射、下载 manifest、下载 quarantine、`.part` 续传、curl 代理配置、403/404/5xx 分类重试、ZIP 结构校验、官方 seed `.hash` 校验、本地全量 verify。
- `OfficialUpdateService`:官方 metadata auto-discover、bootstrap cache、snapshot diff、marker diff、本地 audit/repair、失败 staging 恢复。
- `bat`:正式 CLI binary,支持 one-shot、`--proxy` / `--no-proxy``--watch``--daemon``status``stop``restart``reload``refresh``logs``verify``repair``doctor``clean-stable`
- `report_output.rs``terminal_output.rs`:分别负责结果报告渲染和前台终端诊断、帮助、进度及结构化日志输出。
待完成:
@@ -309,7 +310,7 @@ GitHub issue 状态:#1 已关闭;#17 的历史决定不代表当前下载实
1. Issue #1 收尾:协议基础设施、最小方法集、`catalog.*``parse.*``localized.*``task.*``resource.repair``daemon.restart`、文件级 `patch.apply` / `unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field`、任务持久化、错误码模型与文档均已完成;剩余通用 manifest 发布、复杂 `unityfs.*` 语义编辑以及 `task.create``daemon.clean-stable` 的设计边界确认。
2. 真实官方网络全量下载长期运行报告(`make official-smoke`);issue #19 的同机 live 联调已完成。
3. 跟进官方同步长期运行测试报告。
4. AssetBundle / Addressablesissue #3 / #2CAS 用户级导入(G-011
4. AssetBundle / Addressablesissue #3 / #2的后续结构变体;G-011 剩余的 TextUnit 查询、翻译记忆和通用 Patch 发布资源视图
---
+4 -2
View File
@@ -1,6 +1,6 @@
# BlueArchiveToolkit 文档索引
- **更新时间**2026-08-31
- **更新时间**2026-09-02
- **说明**:本索引用于快速定位当前权威文档和历史资料。
---
@@ -109,6 +109,7 @@
- CAS V1:原子写入、BLAKE3 校验、引用计数、GC、并发测试和损坏检测。
- 文档整理和路线图重制。
- Rust 官方资源同步闭环:`bat``--auto-discover``--watch``--daemon`、Unix socket JSON-RPC 后台控制、`status``stop``restart``reload``refresh``logs``verify``repair``doctor``clean-stable`、北京时间固定强制刷新、snapshot、manifest audit/repair、官方 seed `.hash` 校验。
- 官方 release 历史文件与 CAS 对象复用:复用前执行 size、BLAKE3 和 ZIP 结构校验,失败回退网络,引用写入版本化清单并由清理流程回收。
- 官方原版资源与汉化产物目录分离:`./bat-resources` 只承载原版 release`./bat-localized` 承载后续汉化 release;当前官方同步报告 `not_localized`Patch 发布完成后才进入 `localized`
- 官方 release 会维护 `official-parse-cache.json`,用于跳过未变化资源的重复解析。
- `bat-api/internal/backendrpc` typed Unix socket JSON-RPC client。
@@ -116,6 +117,7 @@
- 真实官方网络全量拉取 smoke 已固化为 `scripts/official-full-pull-smoke.sh``make official-smoke`,默认写入 `/tmp` 隔离目录并输出本地运行报告。
- `bat-api` 同机 live smoke 已固化为 `scripts/bat-api-local-live-smoke.sh``make bat-api-local-live-smoke`,覆盖真实 `bat.sock`、release 切换、未 ready、恢复和 CDN 读路径。
- `bat` 运行时 progress log 已覆盖下载已完成计数、单文件下载进度和校验结果摘要。
- `bat` 前台结果渲染与终端诊断输出已分别收敛到 `report_output.rs``terminal_output.rs`CLI 流程模块只负责编排。
- Addressables 当前真实形态 fixture/golden 覆盖。
- 解析补全路线图已固化到 `docs/architecture/assetbundle.md`:解析缓存、Addressables、UnityFS、Serialized 字段级解析、文本提取、CAS 接入和 Patch 发布前置。
- SQLite Resource Repository 和可选无状态 `bat-ffi` JSON 兼容接口。
@@ -123,5 +125,5 @@
优先待办:
- `bat-api` 与完整官方网络长期运行(独立于 issue #19`make official-smoke` 跟踪)。
- 将官方同步结果接入 CAS + ResourceRepository 的用户级流程
- 扩展 CAS + ResourceRepository 的用户级查询、翻译记忆和通用 Patch 发布资源视图
- 推进 AssetBundle UnityFS 引擎级解析。
+4 -4
View File
@@ -1,7 +1,7 @@
# BlueArchiveToolkit 完整开发计划
- **项目名称**BlueArchiveToolkit
- **文档版本**2026-08-03 状态复核版
- **文档版本**2026-09-02 状态复核版
- **权威状态**:以本文档和 `CURRENT_STATUS.md` 为准,旧阶段报告仅作历史参考。
- **最终目标**:构建一个可长期维护、可扩展、可审计的 Blue Archive 资源管理、文本提取、翻译和补丁平台。
@@ -22,7 +22,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
## 2. 当前真实状态
本节来自 2026-08-03 的工作区盘点、本地验证和最新功能提交。
本节来自 2026-09-02 的工作区盘点、本地验证和最新功能提交。
### 已具备
@@ -169,7 +169,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
**目标**:能够获取、解析和同步 Blue Archive 资源清单。
**当前状态**:部分完成。Rust 官方日服资源同步链路已经具备正式 one-shot 和 `--watch` 常驻入口;`bat-api` 资源 bootstrap/分发入口已落地,但完整解析覆盖、CAS 用户级编排和真实线上 smoke 仍待完成。
**当前状态**:部分完成。Rust 官方日服资源同步链路已经具备正式 one-shot 和 `--watch` 常驻入口;`bat-api` 资源 bootstrap/分发入口已落地,CAS + ResourceRepository 导入、历史 release/CAS 复用已可用,但完整解析覆盖、丰富查询/翻译记忆和真实线上 smoke 仍待完成。
交付物:
@@ -179,7 +179,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
4. Rust 自动更新入口:**已完成当前生产入口**。`bat` 支持 snapshot、marker diff、bootstrap cache、one-shot、`--watch``--daemon`、默认 1 小时间隔、北京时间固定强制刷新,以及 Unix socket JSON-RPC 后台运维命令返回。
5. Go 入口边界:**已冻结**。同步命令行 = Rust `bat`G-008 关闭);资源分发 = `bat-api` MVP(G-009 部分完成)。详见 `docs/reports/GO_STATUS.md`
6. 用户级 `sync``manifest inspect``cache status`**未完成**。Rust `bat --json` 是当前稳定进程边界;`bat-ffi` 只提供可选兼容用的 Manifest inspect 和 sync plan JSON helper。
7. 下载结果写入 CAS + ResourceRepository**部分完成**。CAS 和 SQLite ResourceRepository 已存在,官方同步入口可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后导入;`resource.index` 可按资源级 release、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 查询现有索引和资源 metadata,常用 metadata 过滤已下推到 SQLite`bat doctor cas` 可只读诊断既有 CAS 根目录、对象目录、元数据库文件和对象统计;`parse.text_units` / `parse.errors` 可查询当前 release 的 TextUnit 明细与解析错误;`translation.tasks` 可查询离线 TextUnit 翻译任务状态、跳过/失败原因和 worker 结果。剩余工作是更丰富的 TextUnit 查询、翻译记忆查询和通用 Patch 发布所需资源视图。
7. 下载结果写入 CAS + ResourceRepository**基础能力可用,查询面仍部分完成**。CAS 和 SQLite ResourceRepository 已存在,官方同步入口可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后导入;`resource.index` 可按资源级 release、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 查询现有索引和资源 metadata,常用 metadata 过滤已下推到 SQLite`bat doctor cas` 可只读诊断既有 CAS 根目录、对象目录、元数据库文件和对象统计;`parse.text_units` / `parse.errors` 可查询当前 release 的 TextUnit 明细与解析错误;`translation.tasks` 可查询离线 TextUnit 翻译任务状态、跳过/失败原因和 worker 结果。剩余工作是更丰富的 TextUnit 查询、翻译记忆查询和通用 Patch 发布所需资源视图。
8. Linux 生产同步不依赖已安装官方启动器:**已完成当前 Rust 入口**。`--auto-discover` 只使用官方 HTTP metadata 和临时目录解析 `GameMainConfig`
9. 真实官方网络全量下载 smoke test:**命令已固化(G-018 已关闭)**。`scripts/official-full-pull-smoke.sh` / `make official-smoke` 已固化 dry-run、首次下载、二次 up-to-date 和本地损坏 repair 的可重复流程;真实运行处于长期运行测试阶段,报告待后续提供。
10. 官方发布后的增量 handoff 与解析缓存:**已完成基础入口**。新 release 发布后先生成 `official-resource-changes.json``crowdin-translation-handoff.json`,新增+变更资源进入解析/翻译候选;`official-parse-cache.json` 基于下载 manifest 覆盖直接 UnityFS bundle、zip 内 UnityFS 条目和非候选资源记录;随后生成 `official-textunit-index.json``official-textunit-tasks.json``crowdin-textunit-queue.json`,本地文件未变化且缓存/索引有效时跳过重复解析。
+4 -2
View File
@@ -9,7 +9,9 @@ BlueArchive Toolkit 采用 **Monorepo + 多语言混合** 架构,旨在构建
当前已经可用的官方资源入口包括:
- `infrastructure/src/bin/bat_official_sync.rs`:Linux 官方资源同步薄入口,构建为 `bat`;控制面组合与实现位于 `infrastructure/src/bin/bat/`,支持 one-shot、`--watch``--daemon``status``stop``restart``reload``refresh``logs``verify``repair``doctor``clean-stable`
- `infrastructure/src/bin/bat/app.rs`CLI/env、daemon/watch、RPC dispatch、状态输出与顶层流程组合。
- `infrastructure/src/bin/bat/app.rs`CLI/env、daemon/watch、RPC dispatch 与顶层流程组合。
- `infrastructure/src/bin/bat/report_output.rs`:人类可读报告、JSON 查询结果和报告格式化。
- `infrastructure/src/bin/bat/terminal_output.rs`:前台错误、帮助、启动提示、进度和结构化日志输出。
- `infrastructure/src/bin/bat/task_registry.rs`:任务注册表、任务持久化、取消和 daemon worker。
- `infrastructure/src/bin/bat/readonly_query.rs`parse/resource/translation/localized 只读查询及 RPC 选择。
- `infrastructure/src/bin/bat/translation_query.rs`:翻译任务与 handoff 查询、worker 状态更新。
@@ -97,7 +99,7 @@ cas/
---
### 2. 官方资源同步器 (Rust 当前实现,Go 后续编排)
### 2. 官方资源同步器 (Rust 当前实现,Go 侧读取与编排)
**职责**:从官方日服 HTTP metadata 自动发现资源入口,下载 Windows + Android 官方资源,增量检查,完整性校验,保持本地状态。
+1 -1
View File
@@ -1,6 +1,6 @@
# AssetBundle 与资源解析路线图
- **更新时间**2026-08-19
- **更新时间**2026-09-02
- **适用范围**:Rust 解析引擎、官方同步后的解析缓存、CAS/ResourceRepository 接入、后续文本提取和 Patch 发布。
- **权威关联**`PROJECT_PLAN.md` Milestone 3/4/5/8`docs/reports/CURRENT_GAPS.md` G-005/G-007/G-011/G-011D。
- **维护冻结**:解析扩展遵循 [`docs/reports/PARSER_FREEZE.md`](../reports/PARSER_FREEZE.md),冻结期只接受稳定性、诊断、真实回归和文档一致性修复。
@@ -251,6 +251,7 @@ release 根目录写入 `translation-tasks.sqlite`,由版本化 `schema_migrat
- `infrastructure/src/official_update.rs`
- `infrastructure/src/bin/bat_official_sync.rs`(薄入口)
- `infrastructure/src/bin/bat/app.rs`(控制面组合)
- `infrastructure/src/bin/bat/report_output.rs``terminal_output.rs`(前台报告和终端输出)
- `infrastructure/src/bin/bat/task_registry.rs`(任务注册表、持久化和 worker)
- `infrastructure/src/bin/bat/readonly_query.rs``translation_query.rs`(只读查询)
- `infrastructure/src/bin/bat/patch_commands.rs`patch 命令)
+1 -1
View File
@@ -1,6 +1,6 @@
# 官方资源 Release 布局与资源侧契约
- **更新时间**2026-07-27
- **更新时间**2026-09-02
- **用途**:冻结日服官方资源在本地发布根上的布局、URL 映射、seed 规则、`bat`/`bat-api` 关系,以及 `bat-api` 分发 path 的 1:1 对应关系。
- **范围**:资源发现 / 清单 / 落盘 / 只读分发(**不是**完整游戏业务 API)。
- **权威代码**
@@ -1,6 +1,6 @@
# bat-api / Rust bat Contract Fixture Handoff
更新时间:2026-08-29
更新时间:2026-09-02
本文用于两个 Codex 窗口之间间接联调 `bat-api` 与 Rust `bat` 的跨语言 contract fixture。
仓库内归一化 fixture 已交付;本文保留生成、审核和后续扩展的协作协议。
+1 -1
View File
@@ -1,6 +1,6 @@
# 当前实现缺口清单
- **更新时间**2026-08-29
- **更新时间**2026-09-02
- **Go 进度权威**`GO_STATUS.md`
- **资源布局 / 逆向契约**`../architecture/resource-release-layout.md`
- **用途**:集中跟踪当前代码中的占位实现、设计缺口和下一步验收项。
+1 -1
View File
@@ -1,6 +1,6 @@
# Go 侧进度与边界(权威)
- **更新时间**2026-08-31
- **更新时间**2026-09-02
- **用途**:统一 Go module `bat-api` 的产品边界、既有约定和组件进度;其他文档与此冲突时以本文为准。
- **关联**issue #19 / G-009(资源 bootstrap/分发)、G-008(已决策关闭)、`docs/architecture/official-resource-backend.md` §7、`docs/guides/bat-api-local-live-smoke.md`
File diff suppressed because it is too large Load Diff
+4
View File
@@ -1,3 +1,7 @@
use super::report_output::{
format_daemon_download_progress_json, verification_summary_lines,
visible_historical_failed_versions,
};
use super::*;
use crate::app::schedule_commands::{
read_schedule_file, schedule_add_report, schedule_list_report_with_request,
@@ -1,3 +1,5 @@
use super::report_output::print_report;
pub(super) fn run_write_patch_command(options: &CliOptions) -> anyhow::Result<()> {
match options.command {
CliCommand::PatchApply => {
@@ -1,3 +1,4 @@
use super::report_output::print_json_value;
use super::*;
pub(super) fn run_readonly_query_command(options: &CliOptions) -> anyhow::Result<()> {
+850
View File
@@ -0,0 +1,850 @@
use super::*;
pub(super) trait HumanReport {
fn print_human(&self) -> anyhow::Result<()>;
}
pub(super) fn print_report<T>(format: OutputFormat, report: &T) -> anyhow::Result<()>
where
T: Serialize + HumanReport,
{
match format {
OutputFormat::Json => println!("{}", serde_json::to_string_pretty(report)?),
OutputFormat::Human => report.print_human()?,
}
Ok(())
}
pub(super) fn print_json_value(
format: OutputFormat,
value: &serde_json::Value,
) -> anyhow::Result<()> {
match format {
OutputFormat::Json => println!("{}", serde_json::to_string_pretty(value)?),
OutputFormat::Human => print_human_json_value(value)?,
}
Ok(())
}
impl HumanReport for serde_json::Value {
fn print_human(&self) -> anyhow::Result<()> {
print_human_json_value(self)
}
}
impl HumanReport for RepackReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title("UnityFS 重打包");
print_field("命令", self.command);
print_field("状态", self.status);
print_path_field("源 bundle", &self.source_bundle);
print_path_field("目标 bundle", &self.target_bundle);
print_field("操作数", self.operation_count);
print_field("源字节", self.source_bytes);
print_field("目标字节", self.target_bytes);
print_field("源 BLAKE3", &self.source_blake3);
print_field("目标 BLAKE3", &self.target_blake3);
Ok(())
}
}
impl HumanReport for LocalizedPatchReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title("汉化 release 发布");
print_path_field("版本目录", &self.version_path);
print_path_field("current", &self.current_path);
print_path_field("状态文件", &self.state_path);
print_path_field("patch manifest", &self.patch_manifest_path);
print_field("变更文件数", self.files.len());
print_field("TextAsset 操作数", self.manifest.text_asset_operation_count);
print_field("校验文件数", self.integrity.verified_changed_file_count);
Ok(())
}
}
impl HumanReport for LocalizedRollbackReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title("汉化 release 回滚");
print_field("命令", self.command);
print_field("状态", self.status);
print_field("回滚 release", &self.rolled_back_release_id);
print_optional_field("恢复 release", self.restored_release_id.as_deref());
print_path_field("汉化输出目录", &self.localized_output_root);
print_path_field("current", &self.current_path);
print_path_field("状态文件", &self.state_path);
print_path_field("删除版本目录", &self.removed_version_path);
print_optional_path_field("恢复 current 目标", self.restored_current_target.as_ref());
print_field("新状态", &self.state.status);
print_optional_field("当前 release", self.state.current_release_id.as_deref());
Ok(())
}
}
impl HumanReport for bat_infrastructure::LocalizedTranslationWorkflowReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title("汉化工作流状态");
print_field("命令", self.command);
print_field("状态", self.status);
print_field("官方 release", &self.official_release_id);
print_optional_field("汉化 release", self.current_release_id.as_deref());
print_field("汉化发布状态", &self.localized_release_status);
print_field("工作流状态", &self.translation_workflow_status);
print_field("工作流状态码", self.translation_workflow_status_code);
print_field("工作流标签", self.translation_workflow_label);
print_field("允许发布", format_bool(self.publish_allowed));
print_path_field("汉化输出目录", &self.localized_output_root);
print_path_field("状态文件", &self.state_path);
Ok(())
}
}
impl HumanReport for bat_infrastructure::TranslationWorkerReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title("翻译 provider worker");
print_field("命令", self.command);
print_field("状态", self.status);
print_field("官方 release", &self.official_release_id);
print_field("provider", &self.provider);
print_field("回收 lease", self.recovered_lease_count);
print_field("领取任务", self.claimed_count);
print_field("完成任务", self.completed_count);
print_field("失败任务", self.failed_count);
print_field("已安排重试", self.retry_scheduled_count);
print_field("剩余任务", self.remaining_count);
for failure in &self.failures {
println!(
" - {} [{}] retryable={} {}",
failure.task_id,
failure.failure_class,
format_bool(failure.retryable),
failure.failure_reason
);
}
Ok(())
}
}
fn print_human_json_value(value: &serde_json::Value) -> anyhow::Result<()> {
if value.get("running").is_some() && value.get("state_dir").is_some() {
print_title("后台状态");
print_json_field(value, "status", "状态");
print_json_field(value, "message", "消息");
print_json_field(value, "running", "运行中");
print_json_field(value, "pid", "PID");
print_json_field(value, "daemon_state", "后台状态");
print_json_field(value, "rpc_available", "RPC 可用");
print_json_field(value, "stale_pid_file", "失效 PID");
print_json_field(value, "stale_socket", "失效 socket");
print_json_field(value, "last_update_status", "上次同步");
print_json_field(value, "last_success_unix_seconds", "最后成功时间");
print_json_field(value, "last_error", "上次错误");
print_json_field(value, "next_retry_seconds", "下次重试秒数");
print_json_field(value, "next_check_unix_seconds", "下次检查时间");
print_json_field(value, "current_stage", "当前阶段");
print_json_field(value, "current_message", "当前消息");
print_daemon_download_progress_json_summary(value.get("download_progress"));
print_json_field(value, "version_state_path", "版本状态路径");
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");
print_json_field(value, "log_path", "日志");
print_json_field(value, "structured_log_path", "结构化日志");
print_json_field(value, "rotated_structured_log_paths", "轮转日志");
return Ok(());
}
if value.get("command").and_then(serde_json::Value::as_str) == Some("logs") {
print_title("后台日志");
print_json_field(value, "status", "状态");
print_json_field(value, "message", "消息");
print_json_field(value, "log_path", "日志");
print_json_field(value, "bytes", "字节");
print_json_field(value, "total_lines", "总行数");
print_json_field(value, "returned_lines", "返回行数");
if let Some(content) = value.get("content").and_then(serde_json::Value::as_str) {
if !content.is_empty() {
println!();
println!("{content}");
}
}
return Ok(());
}
if value.get("command").is_some() && value.get("status").is_some() {
print_title("后台命令");
print_json_field(value, "command", "命令");
print_json_field(value, "status", "状态");
print_json_field(value, "message", "消息");
print_json_field(value, "force", "force");
print_json_field(value, "state_dir", "状态目录");
print_json_field(value, "socket_path", "socket");
return Ok(());
}
println!("{}", serde_json::to_string_pretty(value)?);
Ok(())
}
fn print_daemon_download_progress_json_summary(value: Option<&serde_json::Value>) {
let Some(value) = value else {
return;
};
if value.is_null() {
return;
}
print_field(
"下载进度",
format_daemon_download_progress_json(value)
.unwrap_or_else(|error| format!("无法解析:{error}")),
);
}
pub(super) fn format_daemon_download_progress_json(
value: &serde_json::Value,
) -> Result<String, String> {
let progress = serde_json::from_value::<DaemonDownloadProgress>(value.clone())
.map_err(|error| error.to_string())?;
Ok(format_daemon_download_progress(&progress))
}
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::<OfficialVersionState>(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()),
);
let historical_failures = visible_historical_failed_versions(version_state);
print_field("历史失败版本数", historical_failures.len());
if let Some(failed) = historical_failures.last() {
print_field("最近历史失败版本", &failed.version.id);
print_field("最近历史失败时间", failed.failed_unix_seconds);
print_field("最近历史失败原因", &failed.error);
}
}
pub(super) fn visible_historical_failed_versions(
version_state: &OfficialVersionState,
) -> Vec<&OfficialFailedVersionRecord> {
let in_progress = version_state.in_progress_version.as_ref();
version_state
.failed_versions
.iter()
.filter(|failed| {
!in_progress.is_some_and(|version| version_matches_for_status(&failed.version, version))
})
.collect()
}
fn version_matches_for_status(left: &OfficialVersionRecord, right: &OfficialVersionRecord) -> bool {
left.app_version == right.app_version
&& left.bundle_version == right.bundle_version
&& left.addressables_root == right.addressables_root
}
fn print_json_field(value: &serde_json::Value, key: &str, label: &str) {
let Some(value) = value.get(key) else {
return;
};
if value.is_null() {
return;
}
if let Some(value) = value.as_str() {
print_field(label, value);
} else {
print_field(label, value);
}
}
fn print_title(title: &str) {
println!("{title}");
}
fn print_field(label: &str, value: impl std::fmt::Display) {
println!(" {label:<18} {value}");
}
fn print_optional_field<T>(label: &str, value: Option<T>)
where
T: std::fmt::Display,
{
if let Some(value) = value {
print_field(label, value);
}
}
fn print_path_field(label: &str, value: &Path) {
print_field(label, value.display());
}
fn print_optional_path_field(label: &str, value: Option<&PathBuf>) {
if let Some(value) = value {
print_path_field(label, value);
}
}
fn print_list(label: &str, values: &[String], limit: usize) {
if values.is_empty() {
return;
}
println!(" {label}:");
for value in values.iter().take(limit) {
println!(" - {value}");
}
if values.len() > limit {
println!(" ... 还有 {}", values.len() - limit);
}
}
fn format_daemon_download_progress(progress: &DaemonDownloadProgress) -> String {
let status = progress.status.as_deref().unwrap_or("running");
if let Some(hash) = progress.official_hash.as_ref() {
return format!(
"{}/{} official_hash algorithm={} expected={} actual={} data={} hash={}",
progress.index,
progress.total,
hash.algorithm.as_str(),
hash.expected,
hash.actual,
hash.data_url,
hash.hash_url
);
}
if status == "failed" {
return format!(
"{}/{} failed kind={} http={} retryable={} attempts={} quarantined={} {}",
progress.index,
progress.total,
progress.failure_kind.as_deref().unwrap_or("unknown"),
progress
.failure_http_status
.map(|status| status.to_string())
.unwrap_or_else(|| "none".to_string()),
progress
.failure_retryable
.map(|retryable| retryable.to_string())
.unwrap_or_else(|| "unknown".to_string()),
progress
.failure_attempts
.map(|attempts| attempts.to_string())
.unwrap_or_else(|| "0".to_string()),
progress
.quarantined
.map(|quarantined| quarantined.to_string())
.unwrap_or_else(|| "false".to_string()),
progress.url
);
}
if let Some(verification) = progress.verification.as_ref() {
return format!(
"{}/{} {} bytes={} blake3={} zip_checked={} zip_verified={} {}",
progress.index,
progress.total,
status,
verification.actual_bytes,
verification.actual_blake3,
verification.zip_checked,
verification.zip_structure_verified,
progress.url
);
}
format!(
"{}/{} {} {}",
progress.index, progress.total, status, progress.url
)
}
fn print_verification_summary(summary: &OfficialVerificationSummary) {
println!(" 校验摘要:");
for line in verification_summary_lines(summary) {
println!(" - {line}");
}
}
pub(super) fn verification_summary_lines(summary: &OfficialVerificationSummary) -> Vec<String> {
vec![
format!(
"官方 .hash 强校验: {} 对 ({})",
summary.official_hash_verified_count, summary.official_hash_scope
),
format!(
"本地 BLAKE3 复用校验: {} 项通过, {} 项需修复 ({})",
summary.local_manifest_blake3_verified_count,
summary.local_manifest_repair_needed_count,
summary.local_manifest_blake3_scope
),
format!(
"ZIP 结构校验: {} 个 ZIP 通过 ({})",
summary.zip_structure_verified_count, summary.zip_structure_scope
),
]
}
fn format_bool(value: bool) -> &'static str {
if value {
"yes"
} else {
"no"
}
}
fn format_bytes(value: u64) -> String {
const KIB: f64 = 1024.0;
const MIB: f64 = 1024.0 * 1024.0;
const GIB: f64 = 1024.0 * 1024.0 * 1024.0;
let value_f = value as f64;
if value_f >= GIB {
format!("{value_f:.2} GiB", value_f = value_f / GIB)
} else if value_f >= MIB {
format!("{value_f:.2} MiB", value_f = value_f / MIB)
} else if value_f >= KIB {
format!("{value_f:.2} KiB", value_f = value_f / KIB)
} else {
format!("{value} B")
}
}
fn platform_label(platform: PatchPlatform) -> &'static str {
match platform {
PatchPlatform::Windows => "Windows",
PatchPlatform::Android => "Android",
}
}
fn endpoint_kind_label_for_human(kind: YostarJpResourceEndpointKind) -> &'static str {
match kind {
YostarJpResourceEndpointKind::TableCatalog => "table_catalog",
YostarJpResourceEndpointKind::TableCatalogHash => "table_catalog_hash",
YostarJpResourceEndpointKind::AddressablesCatalog => "addressables_catalog",
YostarJpResourceEndpointKind::AddressablesCatalogHash => "addressables_catalog_hash",
YostarJpResourceEndpointKind::BundlePackingInfo => "bundle_packing_info",
YostarJpResourceEndpointKind::BundlePackingInfoHash => "bundle_packing_info_hash",
YostarJpResourceEndpointKind::MediaCatalog => "media_catalog",
YostarJpResourceEndpointKind::MediaCatalogHash => "media_catalog_hash",
}
}
impl HumanReport for OfficialUpdateReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title("官方资源同步");
print_field("状态", self.update_status.as_str());
print_field("状态码", self.status_code.as_str());
print_field("应用版本", &self.app_version);
print_optional_field("Bundle 版本", self.bundle_version.as_deref());
print_field("连接组", &self.connection_group);
print_field(
"平台",
self.platforms
.iter()
.map(|platform| platform_label(*platform))
.collect::<Vec<_>>()
.join(", "),
);
print_field("需要下载", format_bool(self.should_download));
print_field(
"等待官方资源",
format_bool(self.waiting_for_official_resources),
);
print_field("首次同步", format_bool(self.is_initial));
print_field("强制刷新", format_bool(self.force));
print_field("本地审计", format_bool(self.audit_local));
print_field("自动修复", format_bool(self.repair));
print_field("dry-run", format_bool(self.dry_run));
print_path_field("官方资源目录", &self.output_root);
print_path_field("汉化输出目录", &self.localized_output_root);
print_field("汉化发布状态", self.localized_release_status.as_str());
print_path_field("汉化 current", &self.localized_current_path);
print_optional_path_field(
"汉化 published",
self.localized_published_version_path.as_ref(),
);
print_path_field("active release", &self.active_resource_root);
print_path_field("current", &self.current_path);
print_path_field("version state", &self.version_state_path);
print_optional_path_field("staging", self.staging_path.as_ref());
print_optional_path_field("published", self.published_version_path.as_ref());
print_path_field("snapshot", &self.snapshot_path);
print_path_field("manifest", &self.download_manifest);
print_optional_path_field("资源变更集", self.resource_change_set_path.as_ref());
print_optional_path_field("Crowdin handoff", self.crowdin_handoff_path.as_ref());
print_optional_path_field("解析缓存", self.parse_cache_path.as_ref());
print_optional_path_field("TextUnit 任务队列", self.textunit_task_queue_path.as_ref());
print_optional_path_field(
"Crowdin TextUnit 队列",
self.crowdin_textunit_queue_path.as_ref(),
);
print_optional_path_field("写入 snapshot", self.snapshot_written.as_ref());
print_optional_path_field(
"启动器引导产物",
self.launcher_bootstrap_artifact_path.as_ref(),
);
print_optional_path_field("bootstrap cache", self.bootstrap_cache_path.as_ref());
print_optional_field("bootstrap 命中", self.bootstrap_cache_hit.map(format_bool));
print_optional_field("计划 URL 数", self.download_url_count);
print_optional_field("资源数", self.resource_count);
print_field("已下载", self.downloaded_count);
print_field("已续传", self.resumed_count);
print_field("当前 manifest 复用", self.skipped_count);
print_field("历史 release 复用", self.release_reused_count);
print_field("CAS 复用", self.cas_reused_count);
print_field("复用量", format_bytes(self.reused_bytes));
print_field("传输量", format_bytes(self.transferred_bytes));
print_field("复用诊断", self.reuse_warnings.len());
for warning in &self.reuse_warnings {
println!(
" - 复用回退 [{}] {} {}",
warning.source, warning.url, warning.message
);
}
print_field("最终大小", format_bytes(self.final_bytes));
print_field("本地校验通过", self.local_manifest_verified_count);
print_field("需修复", self.local_manifest_repair_needed_count);
print_field("官方 hash 校验", self.official_seed_hash_verified_count);
print_verification_summary(&self.verification_summary);
if let Some(summary) = self.resource_change_summary.as_ref() {
print_field("新增资源", summary.added_count);
print_field("变更资源", summary.modified_count);
print_field("删除资源", summary.removed_count);
print_field("解析候选", summary.parse_candidate_count);
print_field("Crowdin 候选", summary.translation_candidate_count);
}
if let Some(summary) = self.parse_summary.as_ref() {
print_field("解析缓存条目", summary.cache_entry_count);
print_field("解析成功 bundle", summary.parsed_bundle_count);
print_field("解析复用", summary.skipped_unchanged_count);
print_field("解析不支持", summary.unsupported_count);
print_field("解析失败", summary.failed_count);
print_field("TextAsset", summary.text_asset_count);
print_field("TextUnit", summary.text_unit_count);
print_field("二进制 TextAsset", summary.skipped_binary_text_asset_count);
print_field("TextUnit 诊断", summary.text_unit_error_count);
}
if let Some(summary) = self.textunit_task_summary.as_ref() {
print_field("TextUnit 资源候选", summary.resource_candidate_count);
print_field("TextUnit 解析条目", summary.parse_entry_count);
print_field("TextUnit 任务", summary.queued_task_count);
print_field("增量 TextUnit", summary.text_unit_count);
print_field("TextUnit 无解析", summary.skipped_no_parse_entry_count);
print_field("TextUnit 无文本", summary.skipped_no_text_unit_count);
print_field("TextUnit 解析失败", summary.skipped_parse_failed_count);
print_field("TextUnit 不支持", summary.skipped_unsupported_count);
}
print_field("catalog marker", self.addressables_marker_checked_count);
if !self.unavailable_endpoints.is_empty() {
let unavailable = self
.unavailable_endpoints
.iter()
.map(|endpoint| {
format!(
"{}{} kind={} http={} {}",
endpoint_kind_label_for_human(endpoint.kind),
endpoint
.platform
.map(|platform| format!(" ({})", platform_label(platform)))
.unwrap_or_default(),
endpoint.error_kind,
endpoint
.http_status
.map(|status| status.to_string())
.unwrap_or_else(|| "none".to_string()),
endpoint.url
)
})
.collect::<Vec<_>>();
print_list("不可用官方 endpoint", &unavailable, 8);
}
print_list("变更 endpoint", &self.changed_endpoint_urls, 8);
print_list("计划 URL", &self.download_urls, 8);
Ok(())
}
}
impl<T> HumanReport for CommandReport<T>
where
T: Serialize + HumanReport,
{
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("命令", self.command);
print_field("状态", self.status);
self.data.print_human()
}
}
impl HumanReport for PatchApplyReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("命令", self.command);
print_field("状态", self.status);
print_field("Patch 类型", self.kind.as_str());
print_path_field("源文件", &self.source_path);
print_path_field("Patch 文件", &self.patch_path);
print_path_field("目标文件", &self.target_path);
print_field("源字节", self.source_size);
print_field("Patch 字节", self.patch_size);
print_field("目标字节", self.target_size);
print_field("源 BLAKE3", &self.source_blake3);
print_field("Patch BLAKE3", &self.patch_blake3);
print_field("目标 BLAKE3", &self.target_blake3);
Ok(())
}
}
impl HumanReport for UnityFsPatchReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("命令", self.command);
print_field("状态", self.status);
print_path_field("源 bundle", &self.bundle_path);
print_field("Serialized 文件", &self.serialized_file_path);
print_field("Path ID", self.path_id);
print_optional_field("字段路径", self.field_path.as_deref());
print_path_field("目标 bundle", &self.target_path);
print_field("源字节", self.source_size);
print_field("替换字节", self.replacement_size);
print_field("目标字节", self.target_size);
print_field("源 BLAKE3", &self.source_blake3);
print_field("替换 BLAKE3", &self.replacement_blake3);
print_field("目标 BLAKE3", &self.target_blake3);
Ok(())
}
}
impl HumanReport for DaemonStartReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("状态", self.status);
print_field("PID", self.pid);
print_path_field("资源目录", &self.resource_output_root);
print_path_field("汉化目录", &self.localized_output_root);
print_path_field("状态目录", &self.state_dir);
print_path_field("socket", &self.socket_path);
print_path_field("日志", &self.log_path);
print_path_field("结构化日志", &self.structured_log_path);
Ok(())
}
}
impl HumanReport for DaemonStatusReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title("后台状态");
print_field("状态", self.status);
print_field("消息", self.message);
print_field("运行中", format_bool(self.running));
print_optional_field("PID", self.pid);
print_optional_field("后台状态", self.daemon_state.as_deref());
print_field("RPC 可用", format_bool(self.rpc_available));
print_field("失效 PID", format_bool(self.stale_pid_file));
print_field("失效 socket", format_bool(self.stale_socket));
print_optional_field("上次同步", self.last_update_status.as_deref());
print_optional_field("最后成功时间", self.last_success_unix_seconds);
print_optional_field("上次错误", self.last_error.as_deref());
print_optional_field("下次重试秒数", self.next_retry_seconds);
print_optional_field("下次检查时间", self.next_check_unix_seconds);
print_optional_field("当前阶段", self.current_stage.as_deref());
print_optional_field("当前消息", self.current_message.as_deref());
if let Some(progress) = self.download_progress.as_ref() {
print_field("下载进度", format_daemon_download_progress(progress));
}
if let Some(version_state) = self.version_state.as_ref() {
print_daemon_version_state_summary(version_state);
}
print_optional_path_field("资源目录", self.resource_output_root.as_ref());
print_optional_path_field("汉化目录", self.localized_output_root.as_ref());
print_optional_path_field("版本状态", self.version_state_path.as_ref());
print_path_field("状态目录", &self.state_dir);
print_path_field("socket", &self.socket_path);
print_optional_path_field("日志", self.log_path.as_ref());
print_optional_path_field("结构化日志", self.structured_log_path.as_ref());
let rotated = self
.rotated_structured_log_paths
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>();
print_list("轮转日志", &rotated, 5);
Ok(())
}
}
impl HumanReport for DaemonStopReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("状态", self.status);
print_field("已停止", format_bool(self.stopped));
print_optional_field("PID", self.pid);
print_path_field("状态目录", &self.state_dir);
print_path_field("socket", &self.socket_path);
Ok(())
}
}
impl HumanReport for DaemonControlReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("命令", self.command);
print_field("状态", self.status);
print_field("策略", self.strategy);
print_optional_field("旧 PID", self.previous_pid);
print_field("PID", self.pid);
print_path_field("资源目录", &self.resource_output_root);
print_path_field("状态目录", &self.state_dir);
print_path_field("socket", &self.socket_path);
print_path_field("日志", &self.log_path);
Ok(())
}
}
impl HumanReport for VerifyCommandReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("命令", self.command);
print_field("状态", self.status);
print_field("健康", format_bool(self.healthy));
print_field("远端状态", &self.remote_update_status);
print_path_field("校验资源目录", &self.verified_resource_root);
print_optional_field("计划 URL 数", self.planned_url_count);
print_field("计划异常数", self.expected_plan_failure_count);
print_field("本地 manifest 项", self.local_manifest_entry_count);
print_field("本地校验通过", self.local_manifest_verified_count);
print_field("本地失败数", self.local_manifest_failure_count);
print_field("官方 hash 对", self.official_hash_pair_count);
print_field("官方 hash 通过", self.official_hash_verified_count);
print_verification_summary(&self.verification_summary);
if !self.official_hash_errors.is_empty() {
print_list("官方 hash 错误", &self.official_hash_errors, 8);
}
if !self.failures.is_empty() {
println!(" 失败项:");
for item in self.failures.iter().take(12) {
println!(" - {} -> {}", item.status, item.destination.display());
}
if self.failures.len() > 12 {
println!(" ... 还有 {}", self.failures.len() - 12);
}
}
Ok(())
}
}
impl HumanReport for LogsReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("状态", self.status);
print_path_field("日志", &self.log_path);
print_field("存在", format_bool(self.exists));
print_field("为空", format_bool(self.empty));
print_field("字节", self.bytes);
print_field("总行数", self.total_lines);
print_field("返回行数", self.returned_lines);
if !self.content.is_empty() {
println!();
println!("{}", self.content);
}
Ok(())
}
}
impl HumanReport for DoctorReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("命令", self.command);
print_field("状态", self.status);
print_field("健康", format_bool(self.healthy));
println!(" 检查:");
for check in &self.checks {
println!(
" [{}] {} - {}",
if check.ok { "OK" } else { "FAIL" },
check.name,
check.message
);
}
Ok(())
}
}
impl HumanReport for DoctorCasReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("命令", self.command);
print_field("状态", self.status);
print_field("健康", format_bool(self.healthy));
print_path_field("CAS 目录", &self.cas_root);
print_path_field("对象目录", &self.objects_dir);
print_path_field("元数据库", &self.metadata_db_path);
print_field("对象数", self.object_count);
print_field("总字节", self.total_size);
print_field("无效对象文件", self.invalid_object_count);
println!(" 检查:");
for check in &self.checks {
println!(
" [{}] {} - {}",
if check.ok { "OK" } else { "FAIL" },
check.name,
check.message
);
}
Ok(())
}
}
impl HumanReport for CleanStableReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("命令", self.command);
print_field("状态", self.status);
print_path_field("资源目录", &self.output_root);
print_path_field("状态目录", &self.state_dir);
if !self.removed_paths.is_empty() {
println!(" 已清理:");
for path in &self.removed_paths {
println!(" - {}", path.display());
}
}
if !self.skipped_paths.is_empty() {
println!(" 已跳过:");
for path in &self.skipped_paths {
println!(" - {}", path.display());
}
}
Ok(())
}
}
impl HumanReport for DaemonRpcAck {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("命令", self.command);
print_field("状态", self.status);
print_optional_field("force", self.force.map(format_bool));
print_path_field("状态目录", &self.state_dir);
print_path_field("socket", &self.socket_path);
Ok(())
}
}
@@ -1,3 +1,4 @@
use super::report_output::print_json_value;
use super::*;
const SCHEDULES_FILE_NAME: &str = "bat-schedules.json";
+6 -2
View File
@@ -475,10 +475,14 @@ impl TaskStore {
Ok(bytes) => {
if let Err(error) = write_file_atomic(path, &bytes, PRIVATE_FILE_MODE, "任务历史")
{
eprintln!("[daemon] 任务历史落盘失败:{error}");
super::terminal_output::print_daemon_error(format!(
"任务历史落盘失败:{error}"
));
}
}
Err(error) => eprintln!("[daemon] 任务历史序列化失败:{error}"),
Err(error) => {
super::terminal_output::print_daemon_error(format!("任务历史序列化失败:{error}"))
}
}
}
@@ -0,0 +1,527 @@
use super::*;
pub(super) const STARTUP_BANNER: &str = r#"
=====================================================================================
____ _ _ _ _ _____ _ _ _ _
| __ )| |_ _ ___ / \ _ __ ___| |__ (_)_ _____|_ _|__ ___ | | | _(_) |_
| _ \| | | | |/ _ \/ _ \ | '__/ __| '_ \| \ \ / / _ \ | |/ _ \ / _ \| | |/ / | __|
| |_) | | |_| | __/ ___ \| | | (__| | | | |\ V / __/ | | (_) | (_) | | <| | |_
|____/|_|\__,_|\__/_/ \_\_| \___|_| |_|_|\_/ \___/ |_|\___/ \___/|_|_|\_\_|\__|
BlueArchiveToolkit
Official Resource Sync
=====================================================================================
"#;
#[derive(Debug, Serialize)]
struct ErrorReport {
status: &'static str,
exit_code: i32,
error: String,
#[serde(skip_serializing_if = "Option::is_none")]
next_retry_seconds: Option<u64>,
}
pub(super) fn print_fatal_error(error: String, exit_code: i32) -> ! {
let payload = ErrorReport {
status: "error",
exit_code,
error,
next_retry_seconds: None,
};
eprintln!(
"{}",
serde_json::to_string_pretty(&payload)
.unwrap_or_else(|_| "{\"status\":\"error\"}".to_string())
);
std::process::exit(exit_code);
}
pub(super) fn print_error_report(
error: impl Into<String>,
next_retry_seconds: Option<u64>,
) -> anyhow::Result<()> {
let payload = ErrorReport {
status: "error",
exit_code: EXIT_ERROR,
error: error.into(),
next_retry_seconds,
};
eprintln!("{}", serde_json::to_string_pretty(&payload)?);
Ok(())
}
pub(super) fn print_repeated_workflow_wait(
command_name: &str,
interval: Duration,
completed_runs: usize,
) {
eprintln!(
"{command_name} 下一轮将在 {} 后执行(已完成 {} 轮)",
format_duration(interval),
completed_runs
);
}
pub(super) fn print_daemon_error(message: impl std::fmt::Display) {
eprintln!("[daemon] {message}");
}
pub(super) fn print_startup_banner() {
eprintln!("{STARTUP_BANNER}");
}
pub(super) fn print_env_template_created(path: &Path) {
eprintln!(
"已生成配置模板 {}(编辑其中的 BAT_* 配置后,直接运行 `bat` 即可按 .env 启动)",
path.display()
);
}
pub(super) fn print_env_template_warning(path: &Path, error: impl std::fmt::Display) {
eprintln!("警告:生成 .env 配置模板失败 {}{error}", path.display());
}
pub(super) fn print_env_read_warning(path: &Path, error: impl std::fmt::Display) {
eprintln!("警告:读取 .env 失败 {}{error}", path.display());
}
pub(super) fn print_env_parse_warning(line_number: usize, raw_line: &str) {
eprintln!(
"警告:.env 第 {} 行无法解析,已忽略:{raw_line}",
line_number
);
}
#[derive(Debug, Clone)]
pub(super) struct ProgressLogger {
enabled: bool,
started_at: Instant,
structured: Option<RotatingStructuredLogger>,
}
impl ProgressLogger {
pub(super) fn new(enabled: bool) -> Self {
Self {
enabled,
started_at: Instant::now(),
structured: None,
}
}
pub(super) fn attach_structured_log(&mut self, path: PathBuf) {
self.structured = Some(RotatingStructuredLogger::new(
path,
STRUCTURED_LOG_MAX_BYTES,
STRUCTURED_LOG_ROTATE_KEEP,
));
}
pub(super) fn log(&mut self, event: OfficialUpdateProgress) {
self.log_event(&event);
}
pub(super) fn log_text(&mut self, stage: &str, message: impl AsRef<str>) {
self.log_text_inner(stage, message.as_ref());
}
fn log_event(&mut self, event: &OfficialUpdateProgress) {
if self.enabled {
print_progress_line(
self.started_at.elapsed(),
event.stage,
event.message.as_str(),
);
}
if let Some(structured) = self.structured.as_mut() {
let _ = structured.write_event(self.started_at.elapsed(), event);
}
}
fn log_text_inner(&mut self, stage: &str, message: &str) {
if !self.enabled {
if let Some(structured) = self.structured.as_mut() {
let event = OfficialUpdateProgress::new(stage_to_static(stage), message);
let _ = structured.write_event(self.started_at.elapsed(), &event);
}
return;
}
print_progress_line(self.started_at.elapsed(), stage, message);
if let Some(structured) = self.structured.as_mut() {
let event = OfficialUpdateProgress::new(stage_to_static(stage), message);
let _ = structured.write_event(self.started_at.elapsed(), &event);
}
}
}
fn print_progress_line(elapsed: Duration, stage: &str, message: &str) {
eprintln!(
"[+{} 信息] [{}] {}",
format_duration(elapsed),
localized_stage(stage),
message
);
}
#[derive(Debug, Clone)]
pub(super) struct RotatingStructuredLogger {
path: PathBuf,
max_bytes: u64,
keep: usize,
}
impl RotatingStructuredLogger {
pub(super) fn new(path: PathBuf, max_bytes: u64, keep: usize) -> Self {
Self {
path,
max_bytes,
keep,
}
}
pub(super) fn write_event(
&mut self,
elapsed: Duration,
event: &OfficialUpdateProgress,
) -> anyhow::Result<()> {
let payload = serde_json::json!({
"timestamp_unix_seconds": unix_seconds_now(),
"elapsed_ms": elapsed.as_millis(),
"level": "info",
"stage": event.stage,
"stage_label": localized_stage(event.stage),
"status_code": event.status_code.as_str(),
"status_phase": event.status_code.phase(),
"message": event.message.as_str(),
"download": event.download_index.map(|index| serde_json::json!({
"index": index,
"total": event.download_total.unwrap_or(index),
"url": event.download_url.as_deref(),
"status": event.download_status.as_deref(),
"bytes": event.download_bytes,
"transferred_bytes": event.download_transferred_bytes,
"failure_kind": event.download_failure_kind.as_deref(),
"failure_http_status": event.download_failure_http_status,
"failure_retryable": event.download_failure_retryable,
"failure_attempts": event.download_failure_attempts,
"quarantined": event.download_quarantined,
"verification": event.download_verification,
"official_hash": event.official_hash_verification,
})),
});
let mut line = serde_json::to_vec(&payload)?;
line.push(b'\n');
self.rotate_if_needed(line.len() as u64)?;
let mut file = open_append_file(&self.path, PRIVATE_FILE_MODE, "结构化日志")
.map_err(anyhow::Error::msg)?;
file.write_all(&line)?;
file.flush()?;
Ok(())
}
fn rotate_if_needed(&self, incoming_bytes: u64) -> anyhow::Result<()> {
let current_len = match fs::symlink_metadata(&self.path) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err(anyhow::anyhow!(
"结构化日志不能是 symlink{}",
self.path.display()
))
}
Ok(metadata) if metadata.is_file() => metadata.len(),
Ok(_) => {
return Err(anyhow::anyhow!(
"结构化日志已存在但不是普通文件:{}",
self.path.display()
))
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => 0,
Err(error) => return Err(error.into()),
};
if current_len.saturating_add(incoming_bytes) <= self.max_bytes {
return Ok(());
}
for index in (1..=self.keep).rev() {
let from = if index == 1 {
self.path.clone()
} else {
rotated_structured_log_path(&self.path, index - 1)
};
let to = rotated_structured_log_path(&self.path, index);
if !path_exists_no_follow(&from)? {
continue;
}
if path_exists_no_follow(&to)? {
fs::remove_file(&to)?;
}
fs::rename(&from, &to)?;
}
Ok(())
}
}
fn stage_to_static(stage: &str) -> &'static str {
match stage {
"start" => "start",
"lock" => "lock",
"bootstrap" => "bootstrap",
"launcher" => "launcher",
"bootstrap-cache" => "bootstrap-cache",
"game-main-config" => "game-main-config",
"metadata" => "metadata",
"server-info" => "server-info",
"discovery" => "discovery",
"markers" => "markers",
"marker" => "marker",
"catalog" => "catalog",
"local-state" => "local-state",
"audit" => "audit",
"decision" => "decision",
"plan" => "plan",
"download" => "download",
"snapshot" => "snapshot",
"publish" => "publish",
"parse" => "parse",
"finish" => "finish",
"watch" => "watch",
"daemon" => "daemon",
"dry-run" => "dry-run",
_ => "log",
}
}
fn localized_stage(stage: &str) -> &str {
match stage {
"start" => "启动",
"lock" => "",
"bootstrap" => "启动发现",
"launcher" => "启动器",
"bootstrap-cache" => "启动缓存",
"game-main-config" => "游戏配置",
"metadata" => "元数据",
"server-info" => "服务器信息",
"discovery" => "发现",
"markers" => "标记",
"marker" => "标记",
"snapshot" => "快照",
"decision" => "决策",
"plan" => "计划",
"catalog" => "目录",
"inventory" => "清单",
"local-state" => "本地状态",
"audit" => "审计",
"dry-run" => "试运行",
"download" => "下载",
"publish" => "发布",
"parse" => "解析",
"resource" => "资源",
"finish" => "完成",
"watch" => "常驻",
"daemon" => "后台",
_ => stage,
}
}
pub(super) fn print_usage(binary: &str) {
let usage = format!(
r#"BlueArchiveToolkit official resource sync
Usage:
{binary} [OPTIONS]
{binary} <COMMAND> [OPTIONS]
Commands:
res pull Pull official resources once or repeatedly
res schedule Manage resource pull schedules (CLI/RPC/dashboard)
parse run Parse current official release
parse clear-cache Clear regenerable parse and translation queue files
parse repack Repack a UnityFS bundle from a JSON spec
parse schedule Manage parse schedules
i18n run Refresh offline translation work
i18n export Export an editable translation workbench
i18n set Update one translation workbench entry
i18n get Show one translation workbench entry
i18n unset Clear one translated workbench entry
i18n validate Validate workbench against the current official release
i18n proofread Mark localized workflow as manual proofreading
i18n worker run Run translation provider worker once or repeatedly
i18n tasks / i18n task list / i18n task status Query current offline TextUnit translation task status
i18n handoff Query current translation handoff
i18n status Show localized release status for current official release
i18n task update Update one provider worker task status
i18n publish Publish a localized release from a workbench or worker results
i18n rollback Roll back the current localized release
i18n schedule Manage translation schedules
refresh Run one update check, or ask a live daemon to refresh
verify Verify remote plan, local manifest, and official seed hashes
repair Redownload resources that fail local verification
parse-status Show current official parse-cache status
parse-text-units Query current official TextUnit detail index
parse-errors Query current official parse/extraction diagnostics
translation-tasks Query current offline TextUnit translation task status
translation-handoff Query current translation job/unit/provider handoff
localized-status Show localized release status for current official release
localized-rollback Roll back the current localized release
resource-index Query CAS + ResourceRepository index
patch-apply Apply a Binary/JSON/Text patch file
unityfs-patch-text-asset Patch one UnityFS TextAsset object
unityfs-patch-string-field Patch one UnityFS TypeTree string field
unityfs-patch-field Patch one UnityFS TypeTree field with semantic JSON
status Show daemon state
stop Stop daemon
restart Restart daemon, reusing saved args unless explicit args are passed
reload Ask daemon to rediscover metadata and force refresh
logs Show daemon log tail
doctor Run runtime diagnostics
doctor cas Inspect local CAS storage
clean-stable Remove .part/.tmp/stale lock, pid, and socket files
Examples:
{binary} --auto-discover --dry-run
{binary} --auto-discover --watch
{binary} --auto-discover --daemon
{binary} res pull --auto-discover --run-count 3 --interval 1h
{binary} parse run --force --resource-root /tmp/bat-release
{binary} parse schedule list --state-dir /tmp/bat-schedule
{binary} i18n export --translation-file /tmp/bat-workbench.json
{binary} i18n get --translation-file /tmp/bat-workbench.json --translation-id unit-1
{binary} i18n unset --translation-file /tmp/bat-workbench.json --translation-id unit-1
{binary} i18n proofread --json
{binary} i18n worker run --provider mock --worker-concurrency 8 --run-count 2 --interval 30s
{binary} i18n tasks --json
{binary} i18n handoff --json
{binary} i18n status --json
{binary} i18n publish --translation-file /tmp/bat-workbench.json --force
{binary} i18n publish --from-worker --localized-release-id release-manual-1
{binary} i18n rollback --localized-release-id release-manual-1
{binary} status
{binary} refresh --force --json
Discovery:
--auto-discover Discover app-version, server-info, connection-group
--server-info-url <URL> Use an official server-info URL
--server-info-file <NAME> Use an official server-info file name
--server-info-path <PATH> Use a local server-info JSON file
--app-version <VERSION> Override app version
--connection-group <NAME> Override connection group
--launcher-version <VERSION> Launcher metadata API version (default: 1.7.2)
Sync:
--platforms <LIST> Platforms, e.g. Windows,Android
--output <DIR> Official resource publish root (default: ./bat-resources)
--localized-output <DIR> Localized output root (default: ./bat-localized)
--import-repository Import verified release into CAS + ResourceRepository
--no-import-repository Disable CAS + ResourceRepository import
--import-cas-root <DIR> CAS root for official release imports
--import-resource-db <PATH> SQLite ResourceRepository path
--snapshot <PATH> Override snapshot path (default: <output>/current/official-sync-snapshot.json)
--curl <PATH> curl executable (default: curl)
--download-concurrency <N> Bounded parallel downloads (default: 8, range 1..=256)
--proxy <URL|auto|none> curl proxy override (default: auto from env)
--no-proxy Force direct curl connections
--unzip <PATH> unzip executable (default: unzip)
--dry-run Do not write sync state
--plan Include planned URLs in dry-run
--force Force download/refresh
--audit-local | --no-audit-local Enable/disable local manifest audit
--repair | --no-repair Enable/disable automatic repair
--run-count <N> Run pull/parse/translate/publish N times
--once Explicitly select one run
--resource-root <DIR> Use an explicit published official release root
--translation-file <PATH> / --workbench <PATH> Translation workbench JSON file
--translation-id <ID> TextUnit ID for i18n set
--translated-text <TEXT> Inline translation for i18n set
--translated-file <PATH> UTF-8 translation file for i18n set
--from-worker Build publish input from completed provider worker results
--failure-reason <TEXT> Provider failure reason for i18n task update
--provider-run-id <ID> Provider run ID for i18n task update
--translation-provider <NAME> / --provider <NAME> Provider for i18n worker run (mock/crowdin)
--translation-fixture <PATH> Mock/provider fixture for i18n worker run
--worker-concurrency <N> Translation worker concurrency (default: 8, range 1..=256)
--worker-max-attempts <N> Maximum claims per translation task
--worker-lease-seconds <N> Lease seconds for one claimed task
--worker-retry-backoff <DURATION> Retry backoff after retryable failure
--worker-retry-backoff-seconds <N> Retry backoff seconds
--worker-max-tasks <N> Maximum tasks claimed in one worker run
--worker-id <ID> Worker ID prefix for lease diagnostics
--localized-release-id <ID> Explicit localized publication ID
--repack-spec <PATH> UnityFS batch repack JSON spec
Read-only queries:
--offset <N> Query offset for resource-index/parse-text-units/parse-errors/translation-tasks
--limit <N> Query limit for resource-index/parse-text-units/parse-errors/translation-tasks (1..=1000)
--task-id <ID> Filter translation-tasks by stable task ID
--resource-type <TYPE> asset_bundle, manifest, table_bundle, text_asset, media, other
--hash <HASH> Filter resource-index by full CAS hash
--path-pattern <GLOB> Filter resource-index or parse detail by path pattern
--release-id <ID> Filter resource-index or translation-tasks by official release ID
--platform <NAME> Filter resource-index by metadata platform
--destination <PATH> Filter resource-index, parse detail, or translation-tasks by official destination
--bundle-path <PATH> Filter resource-index by metadata bundle path
--archive-entry <PATH> Filter resource-index, parse detail, or translation-tasks by ZIP/archive entry
--task-status <STATUS> Filter translation-tasks by task status
--worker-status <STATUS> Filter translation-tasks by provider worker status
--parse-status <STATUS> Filter resource-index or translation-tasks by parse status
--path-id <ID> Filter parse detail by Unity object path ID
--class-id <ID> Filter parse detail by Unity class ID
--field-path <PATH> Filter parse detail, or TypeTree field path after UnityFS field patch commands
--format <NAME> Filter resource-index, parse text units, or translation-tasks by payload format
--has-reason | --no-reason Filter translation-tasks by diagnostic reason presence
--has-failure-reason | --no-failure-reason Filter translation-tasks by provider failure reason
Write patch:
--patch-kind <binary|json|text> Patch type for patch-apply
--source-file <PATH> Source file for patch-apply
--patch-file <PATH> Patch JSON file for patch-apply
--bundle-file <PATH> Source UnityFS bundle file
--serialized-file <PATH> Serialized file path inside UnityFS
--object-path-id <ID> Unity object path ID for UnityFS patch
--string-field-path <PATH> Deprecated alias for UnityFS TypeTree field path
--replacement-file <PATH> Replacement bytes or UTF-8 string file
--replacement-text <TEXT> Inline replacement text for string-field patch
--replacement-json <JSON> Semantic replacement, e.g. signed/enum/bit_field JSON
--expected-name <NAME> Expected TextAsset name
--expected-value <TEXT> Expected source string value
--expected-json <JSON> Optional expected semantic source value
--target-file <PATH> Target output file written atomically
Daemon:
--watch Run in foreground loop
--daemon Start detached watch process
--state-dir <DIR> Daemon state dir (default: /tmp/bat-pid)
--interval <DURATION> Normal check interval (default: 1h)
--error-retry <DURATION> Retry interval after error (default: 60s)
--quiet-up-to-date Suppress clean up-to-date reports
--no-quiet-up-to-date Always print reports
--tail <N> Log lines for logs command (default: 200)
--schedule-id <ID> / --id <ID> Schedule identifier
--schedule-action <ACTION> / --action <ACTION> Schedule action (pull/run/repack/publish)
--schedule-at-unix <SECONDS> First execution time
--schedule-delay <DURATION> Delay first execution from now
--schedule-every <DURATION> Period between executions
--schedule-count <N> Bounded execution count
--schedule-max-runs <N> Maximum plans executed by one schedule run
--schedule-arg <ARG> Argument passed to scheduled child command
--schedule-clear-args Clear args during schedule update
--schedule-clear-every Convert a periodic plan to one-shot
--schedule-enabled/--schedule-disabled Enable/disable a schedule
Output:
--human Human-readable output (default)
--json Stable JSON output for scripts
--progress | --no-progress Enable/disable stderr progress logs
--banner | --no-banner Enable/disable startup banner
-h, --help Show this help
Defaults:
platforms: Windows,Android
official resource output: ./bat-resources (current -> versions/<id>, .staging/<id>)
localized output: ./bat-localized (separate patch/export target)
daemon state: /tmp/bat-pid (bat.sock, bat.pid, bat-status.json, bat-daemon.log, bat-events.jsonl)
forced refresh: {DAILY_FORCED_REFRESH_LABEL}
"#,
binary = binary,
DAILY_FORCED_REFRESH_LABEL = DAILY_FORCED_REFRESH_LABEL,
);
eprint!("{usage}");
}
@@ -1,3 +1,4 @@
use super::report_output::{print_json_value, print_report};
use super::*;
pub(super) fn run_parse_once(options: &CliOptions) -> anyhow::Result<()> {