From 34e2f0d907221bea5fec48b43b91364196523da1 Mon Sep 17 00:00:00 2001 From: Yuyi-Oak <1722157266@qq.com> Date: Wed, 2 Sep 2026 17:42:16 +0800 Subject: [PATCH] =?UTF-8?q?refactor(cli):=20=E6=A8=A1=E5=9D=97=E5=8C=96=20?= =?UTF-8?q?Rust=20=E5=89=8D=E5=8F=B0=E8=BE=93=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 11 +- CURRENT_STATUS.md | 3 +- DOCS_INDEX.md | 6 +- PROJECT_PLAN.md | 8 +- docs/architecture/README.md | 6 +- docs/architecture/assetbundle.md | 2 +- .../architecture/official-resource-backend.md | 1 + docs/architecture/resource-release-layout.md | 2 +- .../BAT_API_CONTRACT_FIXTURE_HANDOFF.md | 2 +- docs/reports/CURRENT_GAPS.md | 2 +- docs/reports/GO_STATUS.md | 2 +- infrastructure/src/bin/bat/app.rs | 1429 +---------------- infrastructure/src/bin/bat/app_tests.rs | 4 + infrastructure/src/bin/bat/patch_commands.rs | 2 + infrastructure/src/bin/bat/readonly_query.rs | 1 + infrastructure/src/bin/bat/report_output.rs | 850 ++++++++++ .../src/bin/bat/schedule_commands.rs | 1 + infrastructure/src/bin/bat/task_registry.rs | 8 +- infrastructure/src/bin/bat/terminal_output.rs | 527 ++++++ .../src/bin/bat/workflow_commands.rs | 1 + 20 files changed, 1461 insertions(+), 1407 deletions(-) create mode 100644 infrastructure/src/bin/bat/report_output.rs create mode 100644 infrastructure/src/bin/bat/terminal_output.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index bc6b2ec..5df91ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md index 31dc8dc..7129055 100644 --- a/CURRENT_STATUS.md +++ b/CURRENT_STATUS.md @@ -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 / Addressables(issue #3 / #2);CAS 用户级导入(G-011)。 +4. AssetBundle / Addressables(issue #3 / #2)的后续结构变体;G-011 剩余的 TextUnit 查询、翻译记忆和通用 Patch 发布资源视图。 --- diff --git a/DOCS_INDEX.md b/DOCS_INDEX.md index 40994df..b62d8e0 100644 --- a/DOCS_INDEX.md +++ b/DOCS_INDEX.md @@ -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 引擎级解析。 diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 79e8832..0be29d9 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -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`,本地文件未变化且缓存/索引有效时跳过重复解析。 diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 0e7df57..eb698c484 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -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 官方资源,增量检查,完整性校验,保持本地状态。 diff --git a/docs/architecture/assetbundle.md b/docs/architecture/assetbundle.md index 6367313..205d29e 100644 --- a/docs/architecture/assetbundle.md +++ b/docs/architecture/assetbundle.md @@ -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),冻结期只接受稳定性、诊断、真实回归和文档一致性修复。 diff --git a/docs/architecture/official-resource-backend.md b/docs/architecture/official-resource-backend.md index 6fa1215..9b75980 100644 --- a/docs/architecture/official-resource-backend.md +++ b/docs/architecture/official-resource-backend.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 命令) diff --git a/docs/architecture/resource-release-layout.md b/docs/architecture/resource-release-layout.md index 3bf0ccf..dc1d3e1 100644 --- a/docs/architecture/resource-release-layout.md +++ b/docs/architecture/resource-release-layout.md @@ -1,6 +1,6 @@ # 官方资源 Release 布局与资源侧契约 -- **更新时间**:2026-07-27 +- **更新时间**:2026-09-02 - **用途**:冻结日服官方资源在本地发布根上的布局、URL 映射、seed 规则、`bat`/`bat-api` 关系,以及 `bat-api` 分发 path 的 1:1 对应关系。 - **范围**:资源发现 / 清单 / 落盘 / 只读分发(**不是**完整游戏业务 API)。 - **权威代码**: diff --git a/docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md b/docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md index dd5c506..a7eca7e 100644 --- a/docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md +++ b/docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md @@ -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 已交付;本文保留生成、审核和后续扩展的协作协议。 diff --git a/docs/reports/CURRENT_GAPS.md b/docs/reports/CURRENT_GAPS.md index 614fb77..457cbc8 100644 --- a/docs/reports/CURRENT_GAPS.md +++ b/docs/reports/CURRENT_GAPS.md @@ -1,6 +1,6 @@ # 当前实现缺口清单 -- **更新时间**:2026-08-29 +- **更新时间**:2026-09-02 - **Go 进度权威**:`GO_STATUS.md` - **资源布局 / 逆向契约**:`../architecture/resource-release-layout.md` - **用途**:集中跟踪当前代码中的占位实现、设计缺口和下一步验收项。 diff --git a/docs/reports/GO_STATUS.md b/docs/reports/GO_STATUS.md index f1da426..59d5d9c 100644 --- a/docs/reports/GO_STATUS.md +++ b/docs/reports/GO_STATUS.md @@ -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` diff --git a/infrastructure/src/bin/bat/app.rs b/infrastructure/src/bin/bat/app.rs index a98c933..36617c2 100644 --- a/infrastructure/src/bin/bat/app.rs +++ b/infrastructure/src/bin/bat/app.rs @@ -56,10 +56,14 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; mod patch_commands; #[path = "readonly_query.rs"] mod readonly_query; +#[path = "report_output.rs"] +mod report_output; #[path = "schedule_commands.rs"] mod schedule_commands; #[path = "task_registry.rs"] mod task_registry; +#[path = "terminal_output.rs"] +mod terminal_output; #[path = "translation_query.rs"] mod translation_query; #[path = "workflow_commands.rs"] @@ -80,6 +84,11 @@ use task_registry::{ use task_registry::{ PersistedTaskFile, MAX_RETAINED_TASKS, MAX_TASK_LOG_LINES, TASKS_FILE_NAME, TASKS_FILE_VERSION, }; +use terminal_output::ProgressLogger; +#[cfg(test)] +use terminal_output::RotatingStructuredLogger; +#[cfg(test)] +use terminal_output::STARTUP_BANNER; use translation_query::{ build_translation_handoff_report, build_translation_tasks_report, textunit_query_json, update_translation_task_status_report, @@ -118,18 +127,6 @@ const SECONDS_PER_DAY: u64 = 24 * 60 * 60; const BEIJING_UTC_OFFSET_SECONDS: u64 = 8 * 60 * 60; const DAILY_FORCED_REFRESH_LOCAL_SECONDS: [u64; 3] = [3 * 60 * 60, 16 * 60 * 60, 18 * 60 * 60]; const DAILY_FORCED_REFRESH_LABEL: &str = "UTC+8 03:00, 16:00, 18:00"; -const STARTUP_BANNER: &str = r#" -===================================================================================== - ____ _ _ _ _ _____ _ _ _ _ - | __ )| |_ _ ___ / \ _ __ ___| |__ (_)_ _____|_ _|__ ___ | | | _(_) |_ - | _ \| | | | |/ _ \/ _ \ | '__/ __| '_ \| \ \ / / _ \ | |/ _ \ / _ \| | |/ / | __| - | |_) | | |_| | __/ ___ \| | | (__| | | | |\ V / __/ | | (_) | (_) | | <| | |_ - |____/|_|\__,_|\__/_/ \_\_| \___|_| |_|_|\_/ \___/ |_|\___/ \___/|_|_|\_\_|\__| - - BlueArchiveToolkit - Official Resource Sync -===================================================================================== -"#; pub fn main() { match run() { @@ -142,18 +139,7 @@ pub fn main() { } else { EXIT_ERROR }; - let payload = ErrorReport { - status: "error", - exit_code, - error: error_message, - next_retry_seconds: None, - }; - eprintln!( - "{}", - serde_json::to_string_pretty(&payload) - .unwrap_or_else(|_| "{\"status\":\"error\"}".to_string()) - ); - std::process::exit(exit_code); + terminal_output::print_fatal_error(error_message, exit_code); } } } @@ -167,7 +153,7 @@ fn run() -> anyhow::Result { ) && !options.daemon && options.banner { - print_startup_banner(); + terminal_output::print_startup_banner(); } match options.command { CliCommand::Run => { @@ -187,7 +173,7 @@ fn run() -> anyhow::Result { logger.log(event); })?; if should_print_status(report.update_status, options.quiet_up_to_date) { - print_report(options.output_format, &report)?; + report_output::print_report(options.output_format, &report)?; } } Ok(0) @@ -357,25 +343,16 @@ fn run_repeated_workflow( return Ok(()); } if options.progress { - eprintln!( - "{command_name} 下一轮将在 {} 后执行(已完成 {} 轮)", - format_duration(options.interval), - completed_runs + terminal_output::print_repeated_workflow_wait( + command_name, + options.interval, + completed_runs, ); } thread::sleep(options.interval); } } -#[derive(Debug, Serialize)] -struct ErrorReport<'a> { - status: &'a str, - exit_code: i32, - error: String, - #[serde(skip_serializing_if = "Option::is_none")] - next_retry_seconds: Option, -} - #[derive(Debug, Clone, PartialEq, Eq)] struct CliOptions { config: OfficialUpdateConfig, @@ -760,7 +737,7 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> { pending_scheduled_force = false; } if should_print_status(report.update_status, options.quiet_up_to_date) { - print_report(options.output_format, &report)?; + report_output::print_report(options.output_format, &report)?; } let waiting_for_official_resources = report.update_status == OfficialUpdateStatus::WaitingForOfficialResources; @@ -838,13 +815,7 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> { error ), ); - let payload = ErrorReport { - status: "error", - exit_code: EXIT_ERROR, - error: error.to_string(), - next_retry_seconds: Some(sleep_for.as_secs()), - }; - eprintln!("{}", serde_json::to_string_pretty(&payload)?); + terminal_output::print_error_report(error.to_string(), Some(sleep_for.as_secs()))?; if watch_stop_requested(daemon_control.as_ref()) { logger.log_text("daemon", "停止请求已中断本轮同步,watch 循环准备退出"); break; @@ -1684,7 +1655,9 @@ fn start_daemon_rpc_server( }); } Err(error) => { - eprintln!("[daemon] RPC socket accept 失败:{error}"); + terminal_output::print_daemon_error(format!( + "RPC socket accept 失败:{error}" + )); break; } } @@ -1720,7 +1693,7 @@ fn handle_daemon_rpc_client( let bytes_read = match reader.read_line(&mut line) { Ok(bytes_read) => bytes_read, Err(error) => { - eprintln!("[daemon] RPC socket 读取失败:{error}"); + terminal_output::print_daemon_error(format!("RPC socket 读取失败:{error}")); return; } }; @@ -1746,7 +1719,7 @@ fn handle_daemon_rpc_client( if notify_stop_after_response { daemon_control_notify_all(&control); } - eprintln!("[daemon] RPC socket 写入失败:{error}"); + terminal_output::print_daemon_error(format!("RPC socket 写入失败:{error}")); return; } if notify_stop_after_response { @@ -3908,7 +3881,7 @@ fn run_daemon_start(options: CliOptions) -> anyhow::Result<()> { } let report = start_daemon_with_options(&options)?; - print_report(options.output_format, &report)?; + report_output::print_report(options.output_format, &report)?; Ok(()) } @@ -4074,10 +4047,10 @@ fn start_daemon_with_args( fn print_daemon_status(state_dir: &Path, output_format: OutputFormat) -> anyhow::Result<()> { if let Ok(report) = daemon_rpc_call(state_dir, RPC_METHOD_STATUS, None) { - print_json_value(output_format, &report)?; + report_output::print_json_value(output_format, &report)?; } else { let report = build_daemon_status_report(state_dir)?; - print_report(output_format, &report)?; + report_output::print_report(output_format, &report)?; } Ok(()) } @@ -4117,7 +4090,7 @@ fn stop_daemon(state_dir: &Path, output_format: OutputFormat) -> anyhow::Result< pid_path, socket_path, }; - print_report(output_format, &report)?; + report_output::print_report(output_format, &report)?; return Ok(()); } } @@ -4125,7 +4098,7 @@ fn stop_daemon(state_dir: &Path, output_format: OutputFormat) -> anyhow::Result< // 非 RPC 路径:stop_daemon_inner 内部会通过 classify_pid_lock_file 校验并分类 // PID 文件(含 symlink 拒绝),无需在此重复读取。 let report = stop_daemon_inner(state_dir)?; - print_report(output_format, &report)?; + report_output::print_report(output_format, &report)?; Ok(()) } @@ -4339,7 +4312,7 @@ fn run_daemon_restart(options: &CliOptions, command_name: &'static str) -> anyho 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)?; - print_json_value(options.output_format, &report)?; + report_output::print_json_value(options.output_format, &report)?; return Ok(()); } @@ -4448,7 +4421,7 @@ fn run_daemon_restart(options: &CliOptions, command_name: &'static str) -> anyho log_path: start.log_path, socket_path: start.socket_path, }; - print_report(options.output_format, &report)?; + report_output::print_report(options.output_format, &report)?; Ok(()) } @@ -4480,7 +4453,7 @@ fn run_sync_command_with_rpc( None }; let report = rpc_call(&options.state_dir, rpc_method, params)?; - print_json_value(options.output_format, &report)?; + report_output::print_json_value(options.output_format, &report)?; return Ok(()); } @@ -4512,7 +4485,7 @@ fn run_sync_command_foreground( }, data: report, }; - print_report(options.output_format, &command_report)?; + report_output::print_report(options.output_format, &command_report)?; Ok(()) } @@ -4611,850 +4584,6 @@ fn refresh_should_use_daemon_rpc(options: &CliOptions, command_name: &str) -> bo sync_command_rpc_method(options, command_name) == Some(RPC_METHOD_REFRESH) } -fn print_report(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(()) -} - -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(()) -} - -trait HumanReport { - fn print_human(&self) -> anyhow::Result<()>; -} - -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}")), - ); -} - -fn format_daemon_download_progress_json(value: &serde_json::Value) -> Result { - let progress = serde_json::from_value::(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::(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); - } -} - -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(label: &str, value: Option) -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}"); - } -} - -fn verification_summary_lines(summary: &OfficialVerificationSummary) -> Vec { - 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", - } -} - -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::>() - .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::>(); - print_list("不可用官方 endpoint", &unavailable, 8); - } - print_list("变更 endpoint", &self.changed_endpoint_urls, 8); - print_list("计划 URL", &self.download_urls, 8); - Ok(()) - } -} - -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 CommandReport -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::>(); - 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(()) - } -} - #[derive(Debug, Serialize)] struct VerificationItemReport { url: String, @@ -5552,7 +4681,7 @@ fn run_verify_command(options: &CliOptions) -> anyhow::Result { official_hash_errors: verification.official_hash_errors, failures, }; - print_report(options.output_format, &report)?; + report_output::print_report(options.output_format, &report)?; Ok(healthy) } @@ -5577,12 +4706,12 @@ fn run_logs_command(options: &CliOptions) -> anyhow::Result<()> { RPC_METHOD_LOGS, Some(serde_json::json!({ "tail": options.tail_lines })), )?; - print_json_value(options.output_format, &report)?; + report_output::print_json_value(options.output_format, &report)?; return Ok(()); } let report = build_logs_report(&options.state_dir, options.tail_lines)?; - print_report(options.output_format, &report)?; + report_output::print_report(options.output_format, &report)?; Ok(()) } @@ -5909,14 +5038,14 @@ fn build_doctor_cas_report(config: &OfficialUpdateConfig) -> anyhow::Result anyhow::Result { let report = build_doctor_report(&options.state_dir, &options.config)?; let healthy = report.healthy; - print_report(options.output_format, &report)?; + report_output::print_report(options.output_format, &report)?; Ok(healthy) } fn run_doctor_cas_command(options: &CliOptions) -> anyhow::Result { let report = build_doctor_cas_report(&options.config)?; let healthy = report.healthy; - print_report(options.output_format, &report)?; + report_output::print_report(options.output_format, &report)?; Ok(healthy) } @@ -6202,7 +5331,7 @@ fn run_clean_stable_command(options: &CliOptions) -> anyhow::Result<()> { removed_paths, skipped_paths, }; - print_report(options.output_format, &report)?; + report_output::print_report(options.output_format, &report)?; Ok(()) } @@ -6789,240 +5918,10 @@ fn duration_until(deadline: SystemTime, now: SystemTime) -> Duration { deadline.duration_since(now).unwrap_or_default() } -#[derive(Debug, Clone)] -struct ProgressLogger { - enabled: bool, - started_at: Instant, - structured: Option, -} - -impl ProgressLogger { - fn new(enabled: bool) -> Self { - Self { - enabled, - started_at: Instant::now(), - structured: None, - } - } - - fn attach_structured_log(&mut self, path: PathBuf) { - self.structured = Some(RotatingStructuredLogger::new( - path, - STRUCTURED_LOG_MAX_BYTES, - STRUCTURED_LOG_ROTATE_KEEP, - )); - } - - fn log(&mut self, event: OfficialUpdateProgress) { - self.log_event(&event); - } - - fn log_text(&mut self, stage: &str, message: impl AsRef) { - self.log_text_inner(stage, message.as_ref()); - } - - fn log_event(&mut self, event: &OfficialUpdateProgress) { - if self.enabled { - eprintln!( - "[+{} 信息] [{}] {}", - format_duration(self.started_at.elapsed()), - localized_stage(event.stage), - event.message - ); - } - 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; - } - eprintln!( - "[+{} 信息] [{}] {}", - format_duration(self.started_at.elapsed()), - localized_stage(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); - } - } -} - -#[derive(Debug, Clone)] -struct RotatingStructuredLogger { - path: PathBuf, - max_bytes: u64, - keep: usize, -} - -impl RotatingStructuredLogger { - fn new(path: PathBuf, max_bytes: u64, keep: usize) -> Self { - Self { - path, - max_bytes, - keep, - } - } - - 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, - } -} - fn is_locked_error(message: &str) -> bool { message.contains("locked") || message.contains("锁定") } -fn print_startup_banner() { - eprintln!("{STARTUP_BANNER}"); -} - fn should_print_status(status: OfficialUpdateStatus, quiet_up_to_date: bool) -> bool { !(quiet_up_to_date && status == OfficialUpdateStatus::UpToDate) } @@ -7122,19 +6021,16 @@ fn bootstrap_env_file() { let path = exe_dir.join(ENV_FILE_NAME); if !path.exists() { match write_env_template(&path) { - Ok(()) => eprintln!( - "已生成配置模板 {}(编辑其中的 BAT_* 配置后,直接运行 `bat` 即可按 .env 启动)", - path.display() - ), + Ok(()) => terminal_output::print_env_template_created(&path), Err(error) => { - eprintln!("警告:生成 .env 配置模板失败 {}:{error}", path.display()); + terminal_output::print_env_template_warning(&path, error); return; } } } match fs::read_to_string(&path) { Ok(content) => apply_env_file(&content), - Err(error) => eprintln!("警告:读取 .env 失败 {}:{error}", path.display()), + Err(error) => terminal_output::print_env_read_warning(&path, error), } } @@ -7157,10 +6053,7 @@ fn apply_env_file(content: &str) { continue; } let Some((key, value)) = parse_env_line(line) else { - eprintln!( - "警告:.env 第 {} 行无法解析,已忽略:{raw_line}", - line_number + 1 - ); + terminal_output::print_env_parse_warning(line_number + 1, raw_line); continue; }; if env::var_os(&key).is_none() { @@ -8024,7 +6917,7 @@ fn parse_args_with_env( options.write_patch_option_explicit = true; } "--help" | "-h" => { - print_usage(&binary); + terminal_output::print_usage(&binary); std::process::exit(0); } _ => return Err(anyhow::anyhow!("未知参数:{flag}")), @@ -8859,242 +7752,6 @@ fn parse_proxy_config(value: &str) -> anyhow::Result { } } -fn print_usage(binary: &str) { - eprintln!("BlueArchiveToolkit official resource sync"); - eprintln!(); - eprintln!("Usage:"); - eprintln!(" {binary} [OPTIONS]"); - eprintln!(" {binary} [OPTIONS]"); - eprintln!(); - eprintln!("Commands:"); - eprintln!(" res pull Pull official resources once or repeatedly"); - eprintln!(" res schedule Manage resource pull schedules (CLI/RPC/dashboard)"); - eprintln!(" parse run Parse current official release"); - eprintln!(" parse clear-cache Clear regenerable parse and translation queue files"); - eprintln!(" parse repack Repack a UnityFS bundle from a JSON spec"); - eprintln!(" parse schedule Manage parse schedules"); - eprintln!(" i18n run Refresh offline translation work"); - eprintln!(" i18n export Export an editable translation workbench"); - eprintln!(" i18n set Update one translation workbench entry"); - eprintln!(" i18n get Show one translation workbench entry"); - eprintln!(" i18n unset Clear one translated workbench entry"); - eprintln!(" i18n validate Validate workbench against the current official release"); - eprintln!(" i18n proofread Mark localized workflow as manual proofreading"); - eprintln!(" i18n worker run Run translation provider worker once or repeatedly"); - eprintln!( - " i18n tasks / i18n task list / i18n task status Query current offline TextUnit translation task status" - ); - eprintln!(" i18n handoff Query current translation handoff"); - eprintln!(" i18n status Show localized release status for current official release"); - eprintln!(" i18n task update Update one provider worker task status"); - eprintln!(" i18n publish Publish a localized release from a workbench or worker results"); - eprintln!(" i18n rollback Roll back the current localized release"); - eprintln!(" i18n schedule Manage translation schedules"); - eprintln!(" refresh Run one update check, or ask a live daemon to refresh"); - eprintln!(" verify Verify remote plan, local manifest, and official seed hashes"); - eprintln!(" repair Redownload resources that fail local verification"); - eprintln!(" parse-status Show current official parse-cache status"); - eprintln!(" parse-text-units Query current official TextUnit detail index"); - eprintln!(" parse-errors Query current official parse/extraction diagnostics"); - eprintln!(" translation-tasks Query current offline TextUnit translation task status"); - eprintln!(" translation-handoff Query current translation job/unit/provider handoff"); - eprintln!(" localized-status Show localized release status for current official release"); - eprintln!(" localized-rollback Roll back the current localized release"); - eprintln!(" resource-index Query CAS + ResourceRepository index"); - eprintln!(" patch-apply Apply a Binary/JSON/Text patch file"); - eprintln!(" unityfs-patch-text-asset Patch one UnityFS TextAsset object"); - eprintln!(" unityfs-patch-string-field Patch one UnityFS TypeTree string field"); - eprintln!(" unityfs-patch-field Patch one UnityFS TypeTree field with semantic JSON"); - eprintln!(" status Show daemon state"); - eprintln!(" stop Stop daemon"); - eprintln!( - " restart Restart daemon, reusing saved args unless explicit args are passed" - ); - eprintln!(" reload Ask daemon to rediscover metadata and force refresh"); - eprintln!(" logs Show daemon log tail"); - eprintln!(" doctor Run runtime diagnostics"); - eprintln!(" doctor cas Inspect local CAS storage"); - eprintln!(" clean-stable Remove .part/.tmp/stale lock, pid, and socket files"); - eprintln!(); - eprintln!("Examples:"); - eprintln!(" {binary} --auto-discover --dry-run"); - eprintln!(" {binary} --auto-discover --watch"); - eprintln!(" {binary} --auto-discover --daemon"); - eprintln!(" {binary} res pull --auto-discover --run-count 3 --interval 1h"); - eprintln!(" {binary} parse run --force --resource-root /tmp/bat-release"); - eprintln!(" {binary} parse schedule list --state-dir /tmp/bat-schedule"); - eprintln!(" {binary} i18n export --translation-file /tmp/bat-workbench.json"); - eprintln!( - " {binary} i18n get --translation-file /tmp/bat-workbench.json --translation-id unit-1" - ); - eprintln!( - " {binary} i18n unset --translation-file /tmp/bat-workbench.json --translation-id unit-1" - ); - eprintln!(" {binary} i18n proofread --json"); - eprintln!( - " {binary} i18n worker run --provider mock --worker-concurrency 8 --run-count 2 --interval 30s" - ); - eprintln!(" {binary} i18n tasks --json"); - eprintln!(" {binary} i18n handoff --json"); - eprintln!(" {binary} i18n status --json"); - eprintln!(" {binary} i18n publish --translation-file /tmp/bat-workbench.json --force"); - eprintln!(" {binary} i18n publish --from-worker --localized-release-id release-manual-1"); - eprintln!(" {binary} i18n rollback --localized-release-id release-manual-1"); - eprintln!(" {binary} status"); - eprintln!(" {binary} refresh --force --json"); - eprintln!(); - eprintln!("Discovery:"); - eprintln!( - " --auto-discover Discover app-version, server-info, connection-group" - ); - eprintln!(" --server-info-url Use an official server-info URL"); - eprintln!(" --server-info-file Use an official server-info file name"); - eprintln!(" --server-info-path Use a local server-info JSON file"); - eprintln!(" --app-version Override app version"); - eprintln!(" --connection-group Override connection group"); - eprintln!(" --launcher-version Launcher metadata API version (default: 1.7.2)"); - eprintln!(); - eprintln!("Sync:"); - eprintln!(" --platforms Platforms, e.g. Windows,Android"); - eprintln!( - " --output Official resource publish root (default: ./bat-resources)" - ); - eprintln!( - " --localized-output Localized output root (default: ./bat-localized)" - ); - eprintln!( - " --import-repository Import verified release into CAS + ResourceRepository" - ); - eprintln!(" --no-import-repository Disable CAS + ResourceRepository import"); - eprintln!(" --import-cas-root CAS root for official release imports"); - eprintln!(" --import-resource-db SQLite ResourceRepository path"); - eprintln!(" --snapshot Override snapshot path (default: /current/official-sync-snapshot.json)"); - eprintln!(" --curl curl executable (default: curl)"); - eprintln!( - " --download-concurrency Bounded parallel downloads (default: 8, range 1..=256)" - ); - eprintln!(" --proxy curl proxy override (default: auto from env)"); - eprintln!(" --no-proxy Force direct curl connections"); - eprintln!(" --unzip unzip executable (default: unzip)"); - eprintln!(" --dry-run Do not write sync state"); - eprintln!(" --plan Include planned URLs in dry-run"); - eprintln!(" --force Force download/refresh"); - eprintln!(" --audit-local | --no-audit-local Enable/disable local manifest audit"); - eprintln!(" --repair | --no-repair Enable/disable automatic repair"); - eprintln!(" --run-count Run pull/parse/translate/publish N times"); - eprintln!(" --once Explicitly select one run"); - eprintln!(" --resource-root Use an explicit published official release root"); - eprintln!(" --translation-file / --workbench Translation workbench JSON file"); - eprintln!(" --translation-id TextUnit ID for i18n set"); - eprintln!(" --translated-text Inline translation for i18n set"); - eprintln!(" --translated-file UTF-8 translation file for i18n set"); - eprintln!(" --from-worker Build publish input from completed provider worker results"); - eprintln!(" --failure-reason Provider failure reason for i18n task update"); - eprintln!(" --provider-run-id Provider run ID for i18n task update"); - eprintln!(" --translation-provider / --provider Provider for i18n worker run (mock/crowdin)"); - eprintln!(" --translation-fixture Mock/provider fixture for i18n worker run"); - eprintln!( - " --worker-concurrency Translation worker concurrency (default: 8, range 1..=256)" - ); - eprintln!(" --worker-max-attempts Maximum claims per translation task"); - eprintln!(" --worker-lease-seconds Lease seconds for one claimed task"); - eprintln!(" --worker-retry-backoff Retry backoff after retryable failure"); - eprintln!(" --worker-retry-backoff-seconds Retry backoff seconds"); - eprintln!(" --worker-max-tasks Maximum tasks claimed in one worker run"); - eprintln!(" --worker-id Worker ID prefix for lease diagnostics"); - eprintln!(" --localized-release-id Explicit localized publication ID"); - eprintln!(" --repack-spec UnityFS batch repack JSON spec"); - eprintln!(); - eprintln!("Read-only queries:"); - eprintln!(" --offset Query offset for resource-index/parse-text-units/parse-errors/translation-tasks"); - eprintln!(" --limit Query limit for resource-index/parse-text-units/parse-errors/translation-tasks (1..=1000)"); - eprintln!(" --task-id Filter translation-tasks by stable task ID"); - eprintln!(" --resource-type asset_bundle, manifest, table_bundle, text_asset, media, other"); - eprintln!(" --hash Filter resource-index by full CAS hash"); - eprintln!( - " --path-pattern Filter resource-index or parse detail by path pattern" - ); - eprintln!(" --release-id Filter resource-index or translation-tasks by official release ID"); - eprintln!(" --platform Filter resource-index by metadata platform"); - eprintln!(" --destination Filter resource-index, parse detail, or translation-tasks by official destination"); - eprintln!(" --bundle-path Filter resource-index by metadata bundle path"); - eprintln!(" --archive-entry Filter resource-index, parse detail, or translation-tasks by ZIP/archive entry"); - eprintln!(" --task-status Filter translation-tasks by task status"); - eprintln!( - " --worker-status Filter translation-tasks by provider worker status" - ); - eprintln!(" --parse-status Filter resource-index or translation-tasks by parse status"); - eprintln!(" --path-id Filter parse detail by Unity object path ID"); - eprintln!(" --class-id Filter parse detail by Unity class ID"); - eprintln!(" --field-path Filter parse detail, or TypeTree field path after UnityFS field patch commands"); - eprintln!(" --format Filter resource-index, parse text units, or translation-tasks by payload format"); - eprintln!( - " --has-reason | --no-reason Filter translation-tasks by diagnostic reason presence" - ); - eprintln!( - " --has-failure-reason | --no-failure-reason Filter translation-tasks by provider failure reason" - ); - eprintln!(); - eprintln!("Write patch:"); - eprintln!(" --patch-kind Patch type for patch-apply"); - eprintln!(" --source-file Source file for patch-apply"); - eprintln!(" --patch-file Patch JSON file for patch-apply"); - eprintln!(" --bundle-file Source UnityFS bundle file"); - eprintln!(" --serialized-file Serialized file path inside UnityFS"); - eprintln!(" --object-path-id Unity object path ID for UnityFS patch"); - eprintln!( - " --string-field-path Deprecated alias for UnityFS TypeTree field path" - ); - eprintln!(" --replacement-file Replacement bytes or UTF-8 string file"); - eprintln!(" --replacement-text Inline replacement text for string-field patch"); - eprintln!( - " --replacement-json Semantic replacement, e.g. signed/enum/bit_field JSON" - ); - eprintln!(" --expected-name Expected TextAsset name"); - eprintln!(" --expected-value Expected source string value"); - eprintln!(" --expected-json Optional expected semantic source value"); - eprintln!(" --target-file Target output file written atomically"); - eprintln!(); - eprintln!("Daemon:"); - eprintln!(" --watch Run in foreground loop"); - eprintln!(" --daemon Start detached watch process"); - eprintln!(" --state-dir Daemon state dir (default: /tmp/bat-pid)"); - eprintln!(" --interval Normal check interval (default: 1h)"); - eprintln!(" --error-retry Retry interval after error (default: 60s)"); - eprintln!(" --quiet-up-to-date Suppress clean up-to-date reports"); - eprintln!(" --no-quiet-up-to-date Always print reports"); - eprintln!(" --tail Log lines for logs command (default: 200)"); - eprintln!(" --schedule-id / --id Schedule identifier"); - eprintln!( - " --schedule-action / --action Schedule action (pull/run/repack/publish)" - ); - eprintln!(" --schedule-at-unix First execution time"); - eprintln!(" --schedule-delay Delay first execution from now"); - eprintln!(" --schedule-every Period between executions"); - eprintln!(" --schedule-count Bounded execution count"); - eprintln!(" --schedule-max-runs Maximum plans executed by one schedule run"); - eprintln!(" --schedule-arg Argument passed to scheduled child command"); - eprintln!(" --schedule-clear-args Clear args during schedule update"); - eprintln!(" --schedule-clear-every Convert a periodic plan to one-shot"); - eprintln!(" --schedule-enabled/--schedule-disabled Enable/disable a schedule"); - eprintln!(); - eprintln!("Output:"); - eprintln!(" --human Human-readable output (default)"); - eprintln!(" --json Stable JSON output for scripts"); - eprintln!(" --progress | --no-progress Enable/disable stderr progress logs"); - eprintln!(" --banner | --no-banner Enable/disable startup banner"); - eprintln!(" -h, --help Show this help"); - eprintln!(); - eprintln!("Defaults:"); - eprintln!(" platforms: Windows,Android"); - eprintln!( - " official resource output: ./bat-resources (current -> versions/, .staging/)" - ); - eprintln!(" localized output: ./bat-localized (separate patch/export target)"); - eprintln!(" daemon state: /tmp/bat-pid (bat.sock, bat.pid, bat-status.json, bat-daemon.log, bat-events.jsonl)"); - eprintln!(" forced refresh: {DAILY_FORCED_REFRESH_LABEL}"); -} - fn parse_duration(value: &str) -> anyhow::Result { let value = value.trim(); if value.is_empty() { diff --git a/infrastructure/src/bin/bat/app_tests.rs b/infrastructure/src/bin/bat/app_tests.rs index 9313f87..3079f20 100644 --- a/infrastructure/src/bin/bat/app_tests.rs +++ b/infrastructure/src/bin/bat/app_tests.rs @@ -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, diff --git a/infrastructure/src/bin/bat/patch_commands.rs b/infrastructure/src/bin/bat/patch_commands.rs index 515843e..dddd449 100644 --- a/infrastructure/src/bin/bat/patch_commands.rs +++ b/infrastructure/src/bin/bat/patch_commands.rs @@ -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 => { diff --git a/infrastructure/src/bin/bat/readonly_query.rs b/infrastructure/src/bin/bat/readonly_query.rs index 8dafe0d..f14965a 100644 --- a/infrastructure/src/bin/bat/readonly_query.rs +++ b/infrastructure/src/bin/bat/readonly_query.rs @@ -1,3 +1,4 @@ +use super::report_output::print_json_value; use super::*; pub(super) fn run_readonly_query_command(options: &CliOptions) -> anyhow::Result<()> { diff --git a/infrastructure/src/bin/bat/report_output.rs b/infrastructure/src/bin/bat/report_output.rs new file mode 100644 index 0000000..f77a3ed --- /dev/null +++ b/infrastructure/src/bin/bat/report_output.rs @@ -0,0 +1,850 @@ +use super::*; + +pub(super) trait HumanReport { + fn print_human(&self) -> anyhow::Result<()>; +} + +pub(super) fn print_report(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 { + let progress = serde_json::from_value::(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::(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(label: &str, value: Option) +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 { + 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::>() + .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::>(); + print_list("不可用官方 endpoint", &unavailable, 8); + } + print_list("变更 endpoint", &self.changed_endpoint_urls, 8); + print_list("计划 URL", &self.download_urls, 8); + Ok(()) + } +} + +impl HumanReport for CommandReport +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::>(); + 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(()) + } +} diff --git a/infrastructure/src/bin/bat/schedule_commands.rs b/infrastructure/src/bin/bat/schedule_commands.rs index fd7f8d5..9a66af1 100644 --- a/infrastructure/src/bin/bat/schedule_commands.rs +++ b/infrastructure/src/bin/bat/schedule_commands.rs @@ -1,3 +1,4 @@ +use super::report_output::print_json_value; use super::*; const SCHEDULES_FILE_NAME: &str = "bat-schedules.json"; diff --git a/infrastructure/src/bin/bat/task_registry.rs b/infrastructure/src/bin/bat/task_registry.rs index 23a1907..76f7e97 100644 --- a/infrastructure/src/bin/bat/task_registry.rs +++ b/infrastructure/src/bin/bat/task_registry.rs @@ -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}")) + } } } diff --git a/infrastructure/src/bin/bat/terminal_output.rs b/infrastructure/src/bin/bat/terminal_output.rs new file mode 100644 index 0000000..909277e --- /dev/null +++ b/infrastructure/src/bin/bat/terminal_output.rs @@ -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, +} + +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, + next_retry_seconds: Option, +) -> 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, +} + +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) { + 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} [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 Use an official server-info URL + --server-info-file Use an official server-info file name + --server-info-path Use a local server-info JSON file + --app-version Override app version + --connection-group Override connection group + --launcher-version Launcher metadata API version (default: 1.7.2) + +Sync: + --platforms Platforms, e.g. Windows,Android + --output Official resource publish root (default: ./bat-resources) + --localized-output Localized output root (default: ./bat-localized) + --import-repository Import verified release into CAS + ResourceRepository + --no-import-repository Disable CAS + ResourceRepository import + --import-cas-root CAS root for official release imports + --import-resource-db SQLite ResourceRepository path + --snapshot Override snapshot path (default: /current/official-sync-snapshot.json) + --curl curl executable (default: curl) + --download-concurrency Bounded parallel downloads (default: 8, range 1..=256) + --proxy curl proxy override (default: auto from env) + --no-proxy Force direct curl connections + --unzip 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 Run pull/parse/translate/publish N times + --once Explicitly select one run + --resource-root Use an explicit published official release root + --translation-file / --workbench Translation workbench JSON file + --translation-id TextUnit ID for i18n set + --translated-text Inline translation for i18n set + --translated-file UTF-8 translation file for i18n set + --from-worker Build publish input from completed provider worker results + --failure-reason Provider failure reason for i18n task update + --provider-run-id Provider run ID for i18n task update + --translation-provider / --provider Provider for i18n worker run (mock/crowdin) + --translation-fixture Mock/provider fixture for i18n worker run + --worker-concurrency Translation worker concurrency (default: 8, range 1..=256) + --worker-max-attempts Maximum claims per translation task + --worker-lease-seconds Lease seconds for one claimed task + --worker-retry-backoff Retry backoff after retryable failure + --worker-retry-backoff-seconds Retry backoff seconds + --worker-max-tasks Maximum tasks claimed in one worker run + --worker-id Worker ID prefix for lease diagnostics + --localized-release-id Explicit localized publication ID + --repack-spec UnityFS batch repack JSON spec + +Read-only queries: + --offset Query offset for resource-index/parse-text-units/parse-errors/translation-tasks + --limit Query limit for resource-index/parse-text-units/parse-errors/translation-tasks (1..=1000) + --task-id Filter translation-tasks by stable task ID + --resource-type asset_bundle, manifest, table_bundle, text_asset, media, other + --hash Filter resource-index by full CAS hash + --path-pattern Filter resource-index or parse detail by path pattern + --release-id Filter resource-index or translation-tasks by official release ID + --platform Filter resource-index by metadata platform + --destination Filter resource-index, parse detail, or translation-tasks by official destination + --bundle-path Filter resource-index by metadata bundle path + --archive-entry Filter resource-index, parse detail, or translation-tasks by ZIP/archive entry + --task-status Filter translation-tasks by task status + --worker-status Filter translation-tasks by provider worker status + --parse-status Filter resource-index or translation-tasks by parse status + --path-id Filter parse detail by Unity object path ID + --class-id Filter parse detail by Unity class ID + --field-path Filter parse detail, or TypeTree field path after UnityFS field patch commands + --format 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 Patch type for patch-apply + --source-file Source file for patch-apply + --patch-file Patch JSON file for patch-apply + --bundle-file Source UnityFS bundle file + --serialized-file Serialized file path inside UnityFS + --object-path-id Unity object path ID for UnityFS patch + --string-field-path Deprecated alias for UnityFS TypeTree field path + --replacement-file Replacement bytes or UTF-8 string file + --replacement-text Inline replacement text for string-field patch + --replacement-json Semantic replacement, e.g. signed/enum/bit_field JSON + --expected-name Expected TextAsset name + --expected-value Expected source string value + --expected-json Optional expected semantic source value + --target-file Target output file written atomically + +Daemon: + --watch Run in foreground loop + --daemon Start detached watch process + --state-dir Daemon state dir (default: /tmp/bat-pid) + --interval Normal check interval (default: 1h) + --error-retry 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 Log lines for logs command (default: 200) + --schedule-id / --id Schedule identifier + --schedule-action / --action Schedule action (pull/run/repack/publish) + --schedule-at-unix First execution time + --schedule-delay Delay first execution from now + --schedule-every Period between executions + --schedule-count Bounded execution count + --schedule-max-runs Maximum plans executed by one schedule run + --schedule-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/, .staging/) + 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}"); +} diff --git a/infrastructure/src/bin/bat/workflow_commands.rs b/infrastructure/src/bin/bat/workflow_commands.rs index a4b15b9..6d072aa 100644 --- a/infrastructure/src/bin/bat/workflow_commands.rs +++ b/infrastructure/src/bin/bat/workflow_commands.rs @@ -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<()> {