From 0784d5b532f3a7f30f378370f9c982395e59ba7a Mon Sep 17 00:00:00 2001 From: Yuyi-Oak <1722157266@qq.com> Date: Mon, 3 Aug 2026 22:16:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(bat):=20=E5=AE=8C=E5=96=84=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E6=B5=81=E8=B0=83=E5=BA=A6=E4=B8=8E=20dashboard=20RPC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补全资源拉取、解析、翻译、重打包和本地化发布命令,支持单次、限定次数与周期调度。移除 TUI 计划并通过 schedule.* RPC 暴露给 bat-api dashboard。 Closes #43 --- CURRENT_STATUS.md | 7 +- DOCS_INDEX.md | 18 +- PROJECT_PLAN.md | 3 +- USERGUIDE.md | 20 +- docs/guides/bat-workflows.md | 147 ++++ docs/guides/development.md | 2 + docs/reference/rpc-backend-api.md | 38 +- docs/reports/CURRENT_GAPS.md | 4 +- docs/reports/GO_STATUS.md | 2 +- docs/reports/PARSER_FREEZE.md | 10 + infrastructure/src/bin/bat/app.rs | 594 ++++++++++++++- infrastructure/src/bin/bat/app_tests.rs | 249 +++++++ .../src/bin/bat/schedule_commands.rs | 689 ++++++++++++++++++ .../src/bin/bat/workflow_commands.rs | 193 +++++ infrastructure/src/lib.rs | 7 + infrastructure/src/localized_patch.rs | 73 +- infrastructure/src/official_parse.rs | 21 +- infrastructure/src/translation_workflow.rs | 544 ++++++++++++++ .../official_game_main_config_bootstrap.rs | 5 +- internal/api/admin.go | 109 +++ internal/api/api_test.go | 90 +++ internal/api/openapi.go | 42 +- internal/api/rpc_release.go | 26 + internal/api/server.go | 2 + internal/backendrpc/client.go | 43 ++ internal/backendrpc/client_test.go | 46 ++ web/admin/README.md | 5 +- 27 files changed, 2931 insertions(+), 58 deletions(-) create mode 100644 docs/guides/bat-workflows.md create mode 100644 infrastructure/src/bin/bat/schedule_commands.rs create mode 100644 infrastructure/src/bin/bat/workflow_commands.rs create mode 100644 infrastructure/src/translation_workflow.rs diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md index df5fa5e..fdfb59b 100644 --- a/CURRENT_STATUS.md +++ b/CURRENT_STATUS.md @@ -30,7 +30,7 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口: 13. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,官方同步可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后触发导入,默认 CAS 路径为 `/.cas`、SQLite 索引为 `/resources.sqlite`,也可通过 `--import-cas-root`、`--import-resource-db`、`BAT_IMPORT_CAS_ROOT`、`BAT_IMPORT_RESOURCE_DB` 覆盖;`resource.index` RPC/CLI 可按类型、hash、路径模式、官方 release ID、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 分页查询现有索引,数据库不存在时返回 `available=false` 且不会创建空库。`Resource` metadata 已通过 `metadata_json` 兼容迁移保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式;当前/上一个/结构变化 catalog、失败 staging 复用、403/404、hash mismatch、CRC、metadata 迁移与 UnityFS 边界校验均有离线回归 fixture 或单测覆盖。 14. 非 dry-run 官方同步在校验完成并发布后,会先对比上一完整 release 与当前 release 的 `official-download-manifest.json`,在当前 release 下写入 `official-resource-changes.json` 和 `crowdin-translation-handoff.json`;同一 destination 只有 size 或 BLAKE3 改变才算 modified,仅 URL/CDN 根变化但内容相同不会触发解析/翻译候选。随后刷新 `official-parse-cache.json` 和 `official-textunit-index.json`,并从 Added/Modified 资源、parse cache 与 TextUnit 明细索引派生 `official-textunit-tasks.json` 和 `crowdin-textunit-queue.json`;删除资源只进入差异记录,不进入 TextUnit/Crowdin 队列。`parse.text_units` 和 `parse.errors` RPC/CLI 可按 destination、archive entry、path id、class id、field path 和 format 查询当前 release 的 TextUnit 明细与解析错误;`translation.tasks` RPC/CLI 可按 release、destination、archive entry、任务状态、parse status、TextUnit format 和 reason presence 查询离线 TextUnit 翻译任务状态与跳过/失败原因;TextUnit 已包含 class id、field path、字段 offset/byte size 等可追溯定位。Crowdin 当前仅预留本地离线队列,不发网络请求;up-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析。官方同步报告默认 `localized_release_status=not_localized`,含义是原版资源已经发布、汉化资源未发布;UnityFS TextAsset patch 发布成功后会写 `localized-patch-manifest.json`,校验 hash/size/diff/rollback 后才允许 `localized.status` 返回 `status=published`、`status_code=localized.published` 和 `localized_release_status=localized`。`bat` 首次启动会在二进制所在目录释放 `.env` 配置模板(`0600`),之后每次启动自动加载(不覆盖已存在的环境变量),支持 `BAT_OUTPUT`/`BAT_LOCALIZED_OUTPUT`/`BAT_IMPORT_REPOSITORY`/`BAT_IMPORT_CAS_ROOT`/`BAT_IMPORT_RESOURCE_DB`/`BAT_STATE_DIR`/`BAT_AUTO_DISCOVER`/`BAT_WATCH`/`BAT_DAEMON`/`BAT_PROXY` 等键,实现编辑 `.env` 后无参启动;优先级为命令行参数 > 进程环境变量 > `.env` > 内置默认值,`BAT_SKIP_ENV_FILE=1` 可整体禁用;Redis 键为预留。daemon 任务历史持久化在 `/bat-tasks.json`(版本化、`0600` 原子写),重启后任务经 `task.*` 仍可查,中断任务标记 `task_interrupted`(`BAT-ERR-700005`)。 -仍需明确:这不是完整产品完成。完整 AssetBundle 重打包、翻译、Web、以及 `bat-api` 的服务器联调/可选业务扩展仍是后续工作;G-008(产品级 Go 同步 CLI)已决策关闭。真实官方网络全量拉取 smoke 已固化(G-018 已关闭);真实大文件与运行报告默认在 `/tmp` 隔离目录,不纳入 Git。Go 细节见 `docs/reports/GO_STATUS.md`。 +15. issue 43 已补齐 Rust `bat` 的 `res` / `parse` / `i18n` 工作流入口:支持资源拉取、解析刷新、离线翻译工作台、人工文本修改、有限 TextAsset 汉化发布、既有 patch 能力的批量重打包、单次/限定次数/周期执行和版本化 schedule CRUD。schedule CRUD 已通过 `bat.sock` 的 `schedule.*` RPC 以及 `bat-api` 的鉴权管理接口暴露,dashboard 不维护第二套状态。该例外只编排已有解析和 patch 能力,不扩大解析器覆盖;完整 AssetBundle 重打包、真实 provider worker 和 Web 前端仍是后续工作。G-008(产品级 Go 同步 CLI)已决策关闭。真实官方网络全量拉取 smoke 已固化(G-018 已关闭);真实大文件与运行报告默认在 `/tmp` 隔离目录,不纳入 Git。Go 细节见 `docs/reports/GO_STATUS.md`。 当前翻译交接还包括 `translation-tasks.sqlite` 和版本化 `translation-handoff.json`; `translation.tasks` 查询单项 worker 状态,`translation.handoff` 查询完整 @@ -46,6 +46,7 @@ job/unit/provider run 状态。当前下载实现使用默认 8 个独立 worker - `DOCS_INDEX.md`:文档阅读顺序和索引。 - `docs/guides/official-resource-test-pull.md`:官方资源拉取与自动更新用户指南。 - `docs/guides/official-full-pull-smoke.md`:真实官方全量拉取 smoke runbook 和可重复命令。 +- `docs/guides/bat-workflows.md`:Rust `bat` 的 `res` / `parse` / `i18n` 工作流、调度计划和 `bat-api` 调度接口。 - `docs/architecture/official-resource-backend.md`:官方资源后端设计和审核说明。 - `docs/architecture/assetbundle.md`:解析补全路线图,覆盖 Addressables、UnityFS、Serialized 字段级解析、文本提取、CAS 接入和 Patch 发布前置。 - `docs/reports/CURRENT_GAPS.md`:当前缺口和关闭顺序。 @@ -270,7 +271,7 @@ cargo run -p bat-infrastructure --bin bat -- \ --watch ``` -资源 HTTP bootstrap / 只读分发入口是 Go `cmd/bat-api`。生产拓扑下它与 Rust `bat` 同环境运行,经 `bat.sock` RPC 获取当前 `resource_root`,不在配置里写死资源目录;本地开发不能全量跑 `bat` 时用 fixture 和 Go 门禁验证。`internal/api/testdata/contract/` 已固化来自 Rust 输出并经归一化的 `catalog.status`、`resource.manifest` 和 `official-sync-snapshot.json` contract fixture,Go mirror 测试会防止字段名、null 语义和 `game_main_config_bootstrap` 再次漂移。`bat-api` 已补 launcher 资源引导兼容端点和玩家-facing HTTP 控制面(token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单;`reload` / `refresh` / `restart` / `sync` / `verify` / `repair` / `catalog-refresh` 可经 Web 转发),响应只来自已发布 snapshot/RPC,不提供登录、网关、鉴权或完整 package update manifest。 +资源 HTTP bootstrap / 只读分发入口是 Go `cmd/bat-api`。生产拓扑下它与 Rust `bat` 同环境运行,经 `bat.sock` RPC 获取当前 `resource_root`,不在配置里写死资源目录;本地开发不能全量跑 `bat` 时用 fixture 和 Go 门禁验证。`internal/api/testdata/contract/` 已固化来自 Rust 输出并经归一化的 `catalog.status`、`resource.manifest` 和 `official-sync-snapshot.json` contract fixture,Go mirror 测试会防止字段名、null 语义和 `game_main_config_bootstrap` 再次漂移。`bat-api` 已补 launcher 资源引导兼容端点、玩家-facing HTTP 控制面和鉴权调度管理接口(token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单;`reload` / `refresh` / `restart` / `sync` / `verify` / `repair` / `catalog-refresh` 及 `schedule.*` 可经 Web 转发),响应只来自已发布 snapshot/RPC,不提供登录、网关、鉴权或完整 package update manifest。 生产要求: @@ -290,7 +291,7 @@ GitHub issue 状态:#1 已关闭;#17 的历史决定不代表当前下载实 下一阶段必须优先完成: -1. Issue #1(P0,主体已实现):`bat.sock` Unix socket JSON-RPC 已扩展为面向 Go 服务层的 Rust Resource Backend API。统一 envelope(`ok`、`status`、`error`、`data`、`request_id`)与 `BAT-ERR` 错误码模型已落地;`daemon.*`(status/logs/stop/restart/reload/refresh/doctor)、`resource.*`(state/sync/verify/repair/manifest/list/index)、`parse.*`(status/text_units/errors)、`translation.*`(tasks/handoff/task.update)、`localized.*`(status)、`catalog.*`(status/refresh/diff/versions)、`task.*`(status/list/cancel/logs)、文件级 `patch.apply` 与 `unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field` 已实现,长任务返回 `task_id` 可轮询(任务执行器单 worker FIFO,与 watch 循环互斥;任务历史持久化于 `/bat-tasks.json`,daemon 重启后仍可查,中断任务标记 `task_interrupted`);错误码已接入下载、launcher/metadata、server-info/marker 与配置校验路径。剩余:发布级 `patch build` / `patch rollback`、复杂 `unityfs.*` 语义编辑、`task.create`(按设计由语义方法创建)、`daemon.clean-stable`(由 CLI 侧按进程生命周期显式执行,live RPC 内不做在线清理)、Redis 任务后端(`.env` 已预留配置键,接入时机另议)。Go 层通过 RPC 调用 Rust backend,不走 FFI(FFI 降级说明见 `docs/architecture/official-resource-backend.md` §7)。 +1. Issue #1(P0,主体已实现):`bat.sock` Unix socket JSON-RPC 已扩展为面向 Go 服务层的 Rust Resource Backend API。统一 envelope(`ok`、`status`、`error`、`data`、`request_id`)与 `BAT-ERR` 错误码模型已落地;`daemon.*`(status/logs/stop/restart/reload/refresh/doctor)、`resource.*`(state/sync/verify/repair/manifest/list/index)、`schedule.*`(list/add/update/remove/run)、`parse.*`(status/text_units/errors)、`translation.*`(tasks/handoff/task.update)、`localized.*`(status)、`catalog.*`(status/refresh/diff/versions)、`task.*`(status/list/cancel/logs)、文件级 `patch.apply` 与 `unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field` 已实现,长任务返回 `task_id` 可轮询(任务执行器单 worker FIFO,与 watch 循环互斥;任务历史持久化于 `/bat-tasks.json`,daemon 重启后仍可查,中断任务标记 `task_interrupted`);错误码已接入下载、launcher/metadata、server-info/marker 与配置校验路径。剩余:发布级 `patch build` / `patch rollback`、复杂 `unityfs.*` 语义编辑、`task.create`(按设计由语义方法创建)、`daemon.clean-stable`(由 CLI 侧按进程生命周期显式执行,live RPC 内不做在线清理)、Redis 任务后端(`.env` 已预留配置键,接入时机另议)。Go 层通过 RPC 调用 Rust backend,不走 FFI(FFI 降级说明见 `docs/architecture/official-resource-backend.md` §7)。 2. Go 侧:进度见 `docs/reports/GO_STATUS.md`。G-008 已关闭;`bat-api` 资源 bootstrap/分发 MVP 已落地,已含 `/v1/bootstrap`、`/v1/launcher/bootstrap`、launcher 资源 metadata 兼容、HTTP 鉴权/限流/日志/反代适配、动态 JSON no-store、OpenAPI、管理控制白名单、CDN Range/缓存头、RPC 周期刷新和 USERGUIDE 基础章节;仓库内 Rust/Go snapshot contract fixture 已落地,剩余为远程服务器全量 release 联调、refresh mtime/size 增量缓存和可选持久化。 3. 文本提取 / 翻译队列 / Patch 输入:`official-textunit-index.json`、`official-textunit-tasks.json` 与 `crowdin-textunit-queue.json` 已生成;TextUnit 明细、解析错误和离线翻译任务状态/失败原因已可查询,`translation.task.update` 已提供 worker 状态回写 contract。剩余为真实 Crowdin/provider worker、翻译记忆和 Patch 构建。 4. Issue #3(P1):AssetBundle UnityFS 基础解析校验已具备离线和隔离真实样本覆盖;对象级解析继续跟踪 G-005。 diff --git a/DOCS_INDEX.md b/DOCS_INDEX.md index 853b9c7..494c23e 100644 --- a/DOCS_INDEX.md +++ b/DOCS_INDEX.md @@ -14,6 +14,7 @@ - `docs/reports/GO_STATUS.md`:Go 侧边界、约定与组件进度(权威)。 - `docs/guides/official-resource-test-pull.md`:官方资源拉取与自动更新用户指南。 - `docs/guides/official-full-pull-smoke.md`:真实官方全量拉取 smoke runbook 和可重复命令。 +- `docs/guides/bat-workflows.md`:Rust `bat` 的 `res` / `parse` / `i18n` 工作流、调度计划和 `bat-api` 调度接口。 - `docs/architecture/official-resource-backend.md`:官方资源后端职责、工作原理和审核说明。 - `docs/architecture/resource-release-layout.md`:release 布局、URL 映射、seed 规则、bat-api 分发契约(资源侧逆向权威)。 - `docs/architecture/assetbundle.md`:AssetBundle、Addressables、Serialized File、文本提取和 Patch 前置解析路线图。 @@ -82,14 +83,15 @@ 2. `PROJECT_PLAN.md` 3. `docs/guides/official-resource-test-pull.md` 4. `docs/guides/official-full-pull-smoke.md` -5. `docs/architecture/official-resource-backend.md` -6. `docs/reference/rpc-backend-api.md` -7. `docs/reports/CURRENT_GAPS.md` -8. `docs/guides/baseline.md` -9. `docs/architecture/README.md` -10. `docs/guides/development.md` -11. `CONTRIBUTING.md` -12. `AGENTS.md` +5. `docs/guides/bat-workflows.md` +6. `docs/architecture/official-resource-backend.md` +7. `docs/reference/rpc-backend-api.md` +8. `docs/reports/CURRENT_GAPS.md` +9. `docs/guides/baseline.md` +10. `docs/architecture/README.md` +11. `docs/guides/development.md` +12. `CONTRIBUTING.md` +13. `AGENTS.md` --- diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 2df9f34..4cc9753 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -36,7 +36,8 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付 8. `bat-ffi` 已提供 Manifest inspect 和官方 sync plan 的可选无状态粗粒度 JSON C ABI helper。 9. 官方原版资源默认发布到 `./bat-resources`,汉化产物默认发布到独立的 `./bat-localized`;当前官方同步报告会标记 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布。 10. 官方同步校验完成并发布新 release 后会生成 `official-resource-changes.json`、`crowdin-translation-handoff.json`、`official-parse-cache.json`、`official-textunit-index.json`、`official-textunit-tasks.json` 和 `crowdin-textunit-queue.json`,用 Added/Modified 资源驱动后续解析/翻译增量;up-to-date 轮询在已有有效缓存、TextUnit 明细索引和队列时只读取摘要,不重复解析。 -11. 文档已整理:根目录保留入口文档,历史报告进入 `docs/reports/historical/`,误嵌套的 `docs/docs` 已合并。 +11. issue 43 已补齐 Rust `bat` 的 `res` / `parse` / `i18n` 工作流:单次/限定次数/周期执行、版本化 schedule CRUD、离线翻译工作台、人工文本修改、既有 patch 能力的批量重打包和独立汉化 release 发布;schedule CRUD 已经通过 `bat.sock` 和 `bat-api` 管理接口暴露,dashboard 不维护第二套状态;该能力不扩大冻结期解析器覆盖。 +12. 文档已整理:根目录保留入口文档,历史报告进入 `docs/reports/historical/`,误嵌套的 `docs/docs` 已合并。 ### 仍是骨架或占位 diff --git a/USERGUIDE.md b/USERGUIDE.md index 9bd76ba..9f9a71f 100644 --- a/USERGUIDE.md +++ b/USERGUIDE.md @@ -44,6 +44,14 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon | 命令 | 说明 | |---|---| +| `res pull` | 拉取官方资源;支持单次、限定次数和 `--watch` 周期执行 | +| `res schedule` | 管理资源拉取计划;CLI、RPC 和 `bat-api` dashboard 共用计划状态 | +| `parse run` | 执行当前官方 release 的解析和 TextUnit 队列刷新 | +| `parse repack` | 根据 JSON spec 批量重打包 UnityFS bundle | +| `i18n run` / `i18n export` | 刷新离线翻译队列或导出可编辑翻译工作台 | +| `i18n set` | 手动修改一个翻译工作台条目 | +| `i18n publish` | 校验工作台并发布独立汉化 release;`--force` 使用新的手动 release ID | +| `i18n schedule` | 管理翻译和汉化发布计划 | | `refresh` | 执行一次更新检查;若有 live daemon,则通过 RPC 请求其刷新 | | `verify` | 校验远端计划、本地 manifest 和官方 seed hash(dry-run + 审计当前 release) | | `repair` | 重新下载本地校验失败的资源 | @@ -57,6 +65,11 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon `status`/`stop`/`logs`/`reload` 和默认形态的 `refresh` 优先走 `bat.sock` JSON-RPC;socket 不可用时 `status`/`stop` 回退到 PID/状态文件兼容路径。 +Rust `bat` 工作流的完整命令、工作台字段、重打包 spec、调度计划和 +`bat-api` 调度接口见 [`docs/guides/bat-workflows.md`](docs/guides/bat-workflows.md)。 +一级命令推荐使用短名称 `res`、`parse`、`i18n`;`resource`、`resources`、 +`translation`、`translate` 仍是兼容别名。 + ### bat-api 资源 bootstrap / 分发服务 `bat-api` 是 Go 侧正式服务入口,用于给客户端、补丁器或上层工具提供启动前资源入口和 CDN 形态只读分发。它不负责自动发现、下载、校验或发布资源;这些长期状态由 Rust `bat` / daemon 持有。 @@ -110,13 +123,14 @@ BAT_API_SKIP_ENV_FILE=1 go run ./cmd/bat-api \ | `GET/HEAD /prod-clientpatch.bluearchiveyostar.com/...` | 官方 CDN path 形态资源字节 | | `GET /openapi.yaml` | bat-api OpenAPI 文档 | | `GET /admin/` | 管理控制入口与允许操作列表 | +| `GET /admin/schedules` | 读取 Rust `bat` 调度计划;需要管理 token | | `POST /admin/control/{action}` | 经白名单转发 Rust `bat` 控制请求;见下文 | launcher 兼容端点只服务启动前资源发现。它们复用 Rust `bat` snapshot 中的 `launcher_metadata` 和 `game_main_config_bootstrap`,显式标记 `scope=resource_bootstrap_only` / `package_update_manifest=false`。`bat-api` 不下载 launcher 包,不生成官方 PC package update manifest,也不仿造登录、账号、网关、鉴权或游戏业务协议。 生产面对玩家分发时,应启用 HTTP token 鉴权、限流和访问日志: -- `BAT_API_AUTH_TOKEN`:启用 `Authorization: Bearer `、`X-BAT-Token` 或 query fallback 鉴权;token 推荐由 secret manager 或进程环境提供,不建议写入提交文件。`/admin/control/*` 需要此 token。 +- `BAT_API_AUTH_TOKEN`:启用 `Authorization: Bearer `、`X-BAT-Token` 或 query fallback 鉴权;token 推荐由 secret manager 或进程环境提供,不建议写入提交文件。`/admin/control/*` 和 `/admin/schedules` 需要此 token。 - `BAT_API_AUTH_QUERY_PARAM`:query fallback 参数名,默认 `bat_token`;兼容不能写 header 的客户端,访问日志不会记录 query。 - `BAT_API_AUTH_EXEMPT_PATHS`:逗号分隔的免鉴权 path 或 slash-prefix,例如 `/healthz,/readyz`。 - `BAT_API_RATE_LIMIT_RPS` / `BAT_API_RATE_LIMIT_BURST`:按客户端 IP 的进程内 token bucket 限流;边缘反代/CDN 仍应配置独立限流。 @@ -135,6 +149,10 @@ launcher 兼容端点只服务启动前资源发现。它们复用 Rust `bat` sn | `verify` | `resource.verify` | 无 | `202` + task | | `repair` | `resource.repair` | 无 | `202` + task | | `catalog-refresh` | `catalog.refresh` | 可选 `{ "force": true }` | `202` + task | +| `schedule-add` | `schedule.add` | 调度 mutation JSON | `202` + Rust schedule report | +| `schedule-update` | `schedule.update` | 调度 mutation JSON | `202` + Rust schedule report | +| `schedule-remove` | `schedule.remove` | `{ "id": "..." }` | `202` + Rust schedule report | +| `schedule-run` | `schedule.run` | 可选 `{ "id": "...", "force": true }` | `202` + 执行报告 | `stop`、`clean-stable`、patch 和 UnityFS 写入命令不会经 HTTP 暴露。 diff --git a/docs/guides/bat-workflows.md b/docs/guides/bat-workflows.md new file mode 100644 index 0000000..e465e1a --- /dev/null +++ b/docs/guides/bat-workflows.md @@ -0,0 +1,147 @@ +# Rust bat 工作流命令 + +Rust `bat` 的工作流入口按三个一级命令组织: + +- `res`:官方资源拉取、校验、修复和拉取计划。 +- `parse`:当前官方 release 的解析和 UnityFS 重打包。 +- `i18n`:离线翻译工作台、人工文本修改和汉化 release 发布。 + +`resource`、`resources`、`translation` 和 `translate` 仍作为长别名接受,但文档示例统一使用 `res` 和 `i18n`。 + +## 资源拉取 + +单次拉取: + +```bash +bat res pull --auto-discover --output /tmp/bat-resources +``` + +同一进程内限定次数执行。第二轮及以后必须显式给出间隔: + +```bash +bat res pull --auto-discover \ + --run-count 3 \ + --interval 1h \ + --output /tmp/bat-resources +``` + +无限周期执行使用 `--watch`: + +```bash +bat res pull --auto-discover --watch --interval 1h \ + --output /tmp/bat-resources +``` + +资源下载默认使用 8 个独立 worker,允许范围为 `1..=256`。worker 完成当前 URL 后立即领取共享队列中的下一个任务,进度按完成顺序统计,最终报告仍按计划顺序输出。 + +## 解析与重打包 + +解析当前已发布 release: + +```bash +bat parse run --output /tmp/bat-resources +``` + +也可以显式指定隔离的已发布 release 根目录: + +```bash +bat parse run \ + --resource-root /tmp/bat-resources/versions/ \ + --force +``` + +解析结果会刷新 `official-parse-cache.json`、`official-textunit-index.json` 和翻译队列。`--force` 忽略已有解析缓存,但仍要求输入 release 已通过官方下载 manifest 校验。 + +批量 UnityFS 重打包使用 JSON spec。spec 的 `schema_version` 当前为 `1`,支持 `text_asset`、`string_field` 和受支持的语义 `field` 操作: + +```bash +bat parse repack --repack-spec /tmp/bat-repack.json +``` + +重打包写入独立的 `target_bundle`,逐个操作后由底层 UnityFS patch 实现重建并校验,不允许 source 和 target 相同。 + +## 翻译工作台与发布 + +导出可人工编辑的工作台: + +```bash +bat i18n export \ + --output /tmp/bat-resources \ + --translation-file /tmp/bat-workbench.json +``` + +修改一个条目: + +```bash +bat i18n set \ + --translation-file /tmp/bat-workbench.json \ + --translation-id \ + --translated-text '中文文本' +``` + +也可以使用 `--translated-file` 读取 UTF-8 文本。工作台会保存 source text、release ID、TextUnit 目标和人工译文;发布前会重新读取当前 TextUnit 索引,拒绝过期 release、source text 或 patch 目标。 + +发布汉化 release: + +```bash +bat i18n publish \ + --output /tmp/bat-resources \ + --localized-output /tmp/bat-localized \ + --translation-file /tmp/bat-workbench.json +``` + +发布只接受当前实现支持的直接 TextAsset 条目;TypeTree 字段和 zip 内 bundle 使用 `parse repack` 的 spec 单独处理。`--force` 不覆盖已有目录,而是生成独立的 `-manual-` 汉化 release ID;也可以用 `--localized-release-id` 显式指定新 ID。因此强制发布仍保留旧 release 和 rollback 信息。 + +当前 `i18n run` 是离线工作流:刷新 TextUnit 队列,并可用 `--translation-file` 导出工作台;不会假装调用未接入的外部 provider。 + +## 持久化调度 + +每个一级工作流都可以管理自己的 schedule。调度计划保存在 `--state-dir/bat-schedules.json`,计划记录包含动作、参数、下一次执行时间、周期、剩余次数、启用状态和最近错误。 + +新增一个每天执行的资源拉取计划: + +```bash +bat res schedule add \ + --state-dir /tmp/bat-schedule \ + --schedule-id daily-pull \ + --schedule-action pull \ + --schedule-delay 1s \ + --schedule-every 24h \ + --schedule-arg --auto-discover \ + --schedule-arg --output \ + --schedule-arg /tmp/bat-resources +``` + +计划操作: + +```bash +bat res schedule list --state-dir /tmp/bat-schedule +bat res schedule update --state-dir /tmp/bat-schedule --schedule-id daily-pull --schedule-every 12h +bat res schedule remove --state-dir /tmp/bat-schedule --schedule-id daily-pull +bat res schedule run --state-dir /tmp/bat-schedule +``` + +`parse schedule add` 默认动作是 `run`,`i18n schedule add` 默认动作也是 `run`;可以用 `--schedule-action repack` 或 `--schedule-action publish` 选择对应动作。`--schedule-count` 限定执行次数,省略表示周期无限执行;没有 `--schedule-every` 的计划执行一次后自动停用。 + +`schedule update` 可以用 `--schedule-clear-every` 将周期计划改为单次计划;`schedule remove` 会删除计划。`schedule run --force` 会忽略到期时间立即执行指定计划。 + +## bat-api 调度接口 + +dashboard 通过 `bat-api` 转发到 Rust `bat.sock`,不维护第二份计划状态。Rust RPC 方法为: + +- `schedule.list` +- `schedule.add` +- `schedule.update` +- `schedule.remove` +- `schedule.run` + +`bat-api` 对应接口为 `GET /admin/schedules` 和 +`POST /admin/control/schedule-add|schedule-update|schedule-remove|schedule-run`, +均要求配置 `BAT_API_AUTH_TOKEN` 并携带管理 token。请求字段沿用 Rust +contract:`id`、`group`、`action`、`args`、`next_run_unix_seconds`、 +`delay_seconds`、`every_seconds`、`count`、`clear_args`、`clear_every`、 +`enabled`;`schedule.run` 额外接受 `force`。 + +## 边界 + +解析器新增类型覆盖和新的解析格式仍受 `docs/reports/PARSER_FREEZE.md` 约束。本次 issue 43 的例外只开放已有解析输出的手动编排、缓存刷新、工作台编辑、既有 patch 实现的重打包和独立汉化发布,不扩展 UnityFS/AssetBundle/Addressables/TypeTree 的解析类型覆盖。 diff --git a/docs/guides/development.md b/docs/guides/development.md index 42fc8fd..b5a818d 100644 --- a/docs/guides/development.md +++ b/docs/guides/development.md @@ -189,6 +189,8 @@ cargo clippy -p bat-core -p bat-adapters -p bat-infrastructure --all-targets -- `bat-ffi` 只是可选无状态 C ABI 兼容层。修改 FFI 时必须运行 `cargo test -p bat-ffi -- --nocapture`。Go 服务层默认经 `internal/backendrpc` 调 daemon;同步任务由 Rust `bat` 执行,不由 Go 试验 CLI 承担。 +Rust `bat` 的资源拉取、解析、翻译工作流、重打包、汉化发布和持久化调度命令见 [`docs/guides/bat-workflows.md`](bat-workflows.md)。推荐使用 `res`、`parse`、`i18n` 三个一级命令;该工作流当前对应 issue `#43`。 + ### 集成测试 ```bash diff --git a/docs/reference/rpc-backend-api.md b/docs/reference/rpc-backend-api.md index 7d1f423..f71fa50 100644 --- a/docs/reference/rpc-backend-api.md +++ b/docs/reference/rpc-backend-api.md @@ -159,6 +159,40 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且 删除资源只进入 `official-resource-changes.json`,不进入 Crowdin handoff。 +### schedule + +调度计划由 Rust `bat` 持有,状态文件为 daemon `state_dir` 下的 +`bat-schedules.json`。CLI、RPC 和 `bat-api` dashboard 都调用同一组原子 +读改写逻辑,不在 Go 侧复制计划状态。 + +| 方法 | 状态 | params | data | +|---|---|---|---| +| `schedule.list` | 已实现 | `null` | `{ "command": "schedule-list", "schedules": [...] }`。 | +| `schedule.add` | 已实现 | 调度 mutation | 新建 schedule report。 | +| `schedule.update` | 已实现 | 调度 mutation,必须有 `id` | 更新后的 schedule report。 | +| `schedule.remove` | 已实现 | `{ "id": "daily-pull" }` | 删除报告。 | +| `schedule.run` | 已实现 | `{ "id": "daily-pull", "force": true }`,字段可省略 | 到期或强制执行报告;省略 `id` 执行所有到期计划。 | + +调度 mutation 字段如下: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | string | 计划 ID;add 必填,update/remove 用于定位。 | +| `group` | string | `res`、`parse` 或 `i18n`;对应一级工作流。 | +| `action` | string | `res` 的 `pull/refresh/verify/repair`、`parse` 的 `run/repack`、`i18n` 的 `run/export/publish`。 | +| `args` | string[] | 目标工作流的 CLI 参数。 | +| `next_run_unix_seconds` | uint64 | 指定下一次执行时间;不能和 `delay_seconds` 同时使用。 | +| `delay_seconds` | uint64 | 从当前时间计算下一次执行时间。 | +| `every_seconds` | uint64 | 周期秒数;必须大于 0。 | +| `count` | uint64 | 最大执行次数;省略周期无限执行,非周期计划默认执行一次。 | +| `clear_args` | bool | update 时清空工作流参数。 | +| `clear_every` | bool | update 时清除周期并转为单次计划。 | +| `enabled` | bool | 启用或停用计划。 | + +`count > 1` 必须和周期同时存在;`schedule.run` 的 `force=true` 只忽略 +到期时间,不会绕过 `enabled=false`。每次执行前先持久化下一次状态,执行后 +再持久化成功/失败和错误信息,避免进程中断后重复领取同一计划。 + ### parse | 方法 | 状态 | params | data | @@ -376,7 +410,8 @@ CLI 对应关系: - `Call` 可发送本文档中的任意已记录方法,并负责 JSON-RPC transport、 envelope 和 `ApiError` 解码;它不是 bat-api 的 HTTP 任意 RPC proxy。 - typed helper 已覆盖 daemon 已实现方法(`status/logs/stop/restart/reload/refresh/doctor`)、 - `resource.state/sync/verify/repair/manifest/list`、`catalog.*`、`parse.*`、 + `resource.state/sync/verify/repair/manifest/list`、`schedule.list/add/update/remove/run`、 + `catalog.*`、`parse.*`、 `localized.status`、`task.*` 和三个 `unityfs.patch_*` 方法。 - `resource.index`、`translation.tasks`、`translation.handoff`、 @@ -389,6 +424,7 @@ CLI 对应关系: |---|---|---| | `Backend` | `daemon.status`、`daemon.doctor`、`resource.state`、`catalog.status`、`resource.manifest` | 启动发现、周期刷新和资源分发 | | `ControlBackend` | `daemon.restart`、`daemon.reload`、`daemon.refresh`、`resource.sync`、`resource.verify`、`resource.repair`、`catalog.refresh` | 鉴权后的管理控制白名单 | +| `ScheduleBackend` | `schedule.list`、`schedule.add`、`schedule.update`、`schedule.remove`、`schedule.run` | 鉴权后的 dashboard 调度计划控制 | `daemon.stop`、`daemon.clean-stable` 和任意通用 RPC 不属于 bat-api 管理控制面。 Rust dispatch、Go transport 和 bat-api 接口的权威实现位置分别是 diff --git a/docs/reports/CURRENT_GAPS.md b/docs/reports/CURRENT_GAPS.md index 12dd3ef..c8f161b 100644 --- a/docs/reports/CURRENT_GAPS.md +++ b/docs/reports/CURRENT_GAPS.md @@ -123,6 +123,8 @@ - `ResourceImportService` 的 UnityFS 摘要已经能暴露解包文件数、serialized file 数、TextAsset 名称、TextUnit 数量/格式和字段诊断。 - `MonoBehaviour`、`ScriptableObject` 已有基础 TypeTree 字段级反序列化和字符串提取入口;array/vector/staticvector/`List`/`HashSet`/map 元素与 TypeTree-covered managed reference registry payload 已保留独立 field path、offset 和 byte size,enum `value__` backing field 会暴露为语义化 `{type_name, storage_type, value}`,`LayerMask` / `BitField` 的 `m_Bits` backing field 会暴露为语义化 `{type_name, storage_type, bits}`,managed-reference full typename 可拆为 assembly/namespace/class,常见 `m_ManagedReferences` / `RefIds` / `m_RefIds` / verbose type 字段命名、`managedReference*` / `serializedReference*` prefixed metadata、`SerializedReference` 节点 alias 和 `data` / `value` / `payload` / `object` / `managedReferencePayload` / `referencePayload` / `serializedReferencePayload` / `managedReferenceValue` / `referenceValue` / `serializedReferenceValue` / `managedReferenceObject` / `referenceObject` / `serializedReferenceObject` / `managedReferenceData` / `referenceData` / `serializedData` / `serializedReferenceData` payload 命名已有回归覆盖,多记录 registry 聚合已有单元回归,TextUnit 提取会跳过 registry 元数据字符串并把它们作为 payload 文本上下文,fallback 字段遍历也会跳过常见 managed-reference 元数据别名,并按 `RefIds[n]` 等记录前缀或子字段推导 metadata 写入 payload TextUnit context;当前可对 string、bool、integer、float raw bits、bytes、enum、bit_field、常见固定 Unity float/int/hash 值类型的 leaf/direct-child 形态、unknown fixed-size raw bytes 同长度替换、PPtr、object 字段组合和 TypeTree schema 支撑的 array/vector/List/HashSet/map 整体替换执行文件级 patch,map entry 的 `first/second` 与 `key/value` 字段命名已有 serialized 和 UnityFS 重建回归,ScriptableObject `key/value` map 解析、变长替换和 UnityFS 重建已有专门回归;真实版本差异、未见样本驱动的复杂 managed reference registry / map entry 变体、unknown 字段结构语义和发布级重打包入口仍未完成。 +issue 43 例外说明:新增的 `parse repack` 只编排已有 TextAsset、TypeTree string 和受支持语义 field patch 实现;它不新增解析器类型、字段族或 catalog 覆盖。人工翻译工作台和有限 TextAsset 发布也只消费已有 TextUnit 输出。 + 影响: - 可以对 UnityFS 容器做结构校验、解包 directory 文件,并提取 serialized file 中的 TextAsset 原始 bytes。 @@ -165,7 +167,7 @@ - 通用 Binary/JSON/Text Patch crate 能力可作为后续发布流程输入。 - 文件级写入入口可用于隔离测试和上层工具显式产物生成。 -- 发布级 `patch build` / `patch rollback`、复杂 AssetBundle 重打包和通用 manifest 在发布命令中的正式使用仍未完成。 +- 发布级通用 `patch build` / `patch rollback`、复杂 AssetBundle 重打包和完整翻译文件集合的正式使用仍未完成;issue 43 的 `parse repack` 与 `i18n publish` 仅覆盖已有 patch 实现支持的安全子集。 验收: diff --git a/docs/reports/GO_STATUS.md b/docs/reports/GO_STATUS.md index ad731a7..325279f 100644 --- a/docs/reports/GO_STATUS.md +++ b/docs/reports/GO_STATUS.md @@ -134,7 +134,7 @@ make build-go-cli # 产出 bin/bat-go | 项 | 状态 | |---|---| | G-008 Go 同步 CLI | **已决策关闭**(正式同步 CLI = Rust `bat`) | -| G-009 bat-api 资源 bootstrap/分发 | **部分完成**(MVP+生产控制面);已含资源 bootstrap 关系面、launcher 资源 metadata 兼容、HTTP 鉴权/限流/日志/反代适配、RPC 周期刷新/诊断、readiness、OpenAPI、管理控制白名单和部署模板,后续远程服务器联调/可选持久化 | +| G-009 bat-api 资源 bootstrap/分发 | **部分完成**(MVP+生产控制面);已含资源 bootstrap 关系面、launcher 资源 metadata 兼容、HTTP 鉴权/限流/日志/反代适配、RPC 周期刷新/诊断、readiness、OpenAPI、管理控制白名单、Rust-owned `schedule.*` dashboard 代理和部署模板,后续远程服务器联调/可选持久化 | | issue #19 | 资源面 MVP 与 USERGUIDE 基础章节已编码;真机联调后继续补充实战样例;**未自动关 issue** | | G-010 Web | 未开始 | diff --git a/docs/reports/PARSER_FREEZE.md b/docs/reports/PARSER_FREEZE.md index e6858f9..539ae0c 100644 --- a/docs/reports/PARSER_FREEZE.md +++ b/docs/reports/PARSER_FREEZE.md @@ -27,6 +27,16 @@ - 修正文档、CLI 帮助、RPC 参考和状态文件中与当前实现不一致的解析能力声明。 - 改善错误信息、日志字段、状态记录和失败恢复,但不得改变解析输出契约,除非是修复错误契约且同步迁移说明。 +## issue 43 的明确例外 + +本次 issue 43 经用户明确授权,允许新增 `bat` 的工作流编排入口: + +- `parse run` 只刷新已有解析输出、TextUnit 索引和翻译队列; +- `parse repack` 只调用已有 TextAsset、TypeTree string 和受支持语义字段 patch 实现; +- `i18n` 工作台和 `publish` 只消费已有 TextUnit 输出,并发布独立汉化 release。 + +该例外不解冻解析器,不新增 UnityFS/AssetBundle/Addressables/TypeTree 解析类型、字段覆盖、catalog 结构或合成 fixture 能力。后续任何扩大解析覆盖的变更仍需单独解冻授权。 + ## 禁止变更 冻结期禁止以下解析相关变更: diff --git a/infrastructure/src/bin/bat/app.rs b/infrastructure/src/bin/bat/app.rs index cb113b5..bf844f0 100644 --- a/infrastructure/src/bin/bat/app.rs +++ b/infrastructure/src/bin/bat/app.rs @@ -8,22 +8,26 @@ use bat_infrastructure::DEFAULT_DOWNLOAD_CONCURRENCY; use bat_infrastructure::{ apply_patch_file, apply_unityfs_field_patch_file, apply_unityfs_string_field_patch_file, apply_unityfs_text_asset_patch_file, changed_endpoint_urls, diff_extended_snapshot, - gc_orphan_staging, lexical_absolute, open_append_file, read_download_manifest_at, + export_translation_workbench, gc_orphan_staging, lexical_absolute, + localized_text_asset_patches, open_append_file, read_download_manifest_at, read_file_no_symlink, read_localized_patch_manifest_at, read_localized_version_state, - read_parse_cache_at, read_snapshot, read_textunit_index_at, read_version_state, - redact_proxy_url, resolve_curl_proxy, validate_output_root, validate_runtime_state_dir, - write_file_atomic, CurlProxyConfig, CurlProxyMode, OfficialEndpointMarkerRole, - OfficialFailedVersionRecord, OfficialResourceHashVerification, OfficialResourceVerification, - OfficialServerInfoSource, OfficialTextUnitQuery, OfficialTextUnitTaskQuery, - OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, - OfficialUpdateSnapshot, OfficialUpdateStatus, OfficialVerificationSummary, - OfficialVersionRecord, OfficialVersionState, PatchApplyKind, PatchApplyParams, - PatchApplyReport, ReleaseFlowStatusCode, SqliteResourceRepository, - SqliteTranslationTaskRepository, TranslationTaskStatus, UnityFsFieldPatchParams, - UnityFsPatchReport, UnityFsStringFieldPatchParams, UnityFsTextAssetPatchParams, - LOCALIZED_CURRENT_LINK, LOCALIZED_PATCH_MANIFEST_FILE, LOCALIZED_VERSIONS_DIR, - LOCALIZED_VERSION_STATE_FILE, MAX_DOWNLOAD_CONCURRENCY, MIN_DOWNLOAD_CONCURRENCY, - OFFICIAL_PARSE_CACHE_FILE, OFFICIAL_TEXTUNIT_INDEX_FILE, PRIVATE_FILE_MODE, + read_parse_cache_at, read_snapshot, read_textunit_index_at, read_translation_workbench, + read_version_state, redact_proxy_url, repack_bundle, resolve_curl_proxy, set_translation, + validate_output_root, validate_runtime_state_dir, write_file_atomic, + write_official_textunit_queues, CurlProxyConfig, CurlProxyMode, LocalizedPatchConfig, + LocalizedPatchReport, LocalizedPatchService, OfficialEndpointMarkerRole, + OfficialFailedVersionRecord, OfficialParseCacheService, OfficialParseConfig, + OfficialResourceHashVerification, OfficialResourceVerification, OfficialServerInfoSource, + OfficialTextUnitQuery, OfficialTextUnitTaskQuery, OfficialUpdateConfig, OfficialUpdateProgress, + OfficialUpdateReport, OfficialUpdateService, OfficialUpdateSnapshot, OfficialUpdateStatus, + OfficialVerificationSummary, OfficialVersionRecord, OfficialVersionState, PatchApplyKind, + PatchApplyParams, PatchApplyReport, ReleaseFlowStatusCode, RepackReport, + SqliteResourceRepository, SqliteTranslationTaskRepository, TranslationTaskStatus, + UnityFsFieldPatchParams, UnityFsPatchReport, UnityFsStringFieldPatchParams, + UnityFsTextAssetPatchParams, LOCALIZED_CURRENT_LINK, LOCALIZED_PATCH_MANIFEST_FILE, + LOCALIZED_VERSIONS_DIR, LOCALIZED_VERSION_STATE_FILE, MAX_DOWNLOAD_CONCURRENCY, + MIN_DOWNLOAD_CONCURRENCY, OFFICIAL_PARSE_CACHE_FILE, OFFICIAL_TEXTUNIT_INDEX_FILE, + PRIVATE_FILE_MODE, }; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; @@ -46,16 +50,23 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; mod patch_commands; #[path = "readonly_query.rs"] mod readonly_query; +#[path = "schedule_commands.rs"] +mod schedule_commands; #[path = "task_registry.rs"] mod task_registry; #[path = "translation_query.rs"] mod translation_query; +#[path = "workflow_commands.rs"] +mod workflow_commands; use patch_commands::{ is_write_patch_command, run_write_patch_command, validate_write_patch_options, }; #[cfg(test)] use readonly_query::run_readonly_query_command_with_rpc; use readonly_query::{run_readonly_query_command, validate_readonly_query_options}; +use schedule_commands::{ + run_schedule_add, run_schedule_list, run_schedule_remove, run_schedule_run, run_schedule_update, +}; use task_registry::{ run_task_worker, CancelOutcome, DaemonTaskContext, TaskJob, TaskKind, TaskRegistry, }; @@ -67,6 +78,9 @@ use translation_query::{ build_translation_handoff_report, build_translation_tasks_report, textunit_query_json, update_translation_task_status_report, }; +use workflow_commands::{ + run_parse_once, run_publish_localized, run_repack, run_translate_once, run_translation_set, +}; const EXIT_ERROR: i32 = 1; const EXIT_LOCKED: i32 = 75; @@ -168,6 +182,52 @@ fn run() -> anyhow::Result { } Ok(0) } + CliCommand::Pull => { + run_repeated_workflow(&options, "pull", |options| { + run_sync_command_foreground(options, "pull") + })?; + Ok(0) + } + CliCommand::Parse => { + run_repeated_workflow(&options, "parse", run_parse_once)?; + Ok(0) + } + CliCommand::Translate => { + run_repeated_workflow(&options, "translate", run_translate_once)?; + Ok(0) + } + CliCommand::TranslationSet => { + run_translation_set(&options)?; + Ok(0) + } + CliCommand::Repack => { + run_repack(&options)?; + Ok(0) + } + CliCommand::PublishLocalized => { + run_repeated_workflow(&options, "publish-localized", run_publish_localized)?; + Ok(0) + } + CliCommand::ScheduleList => { + run_schedule_list(&options)?; + Ok(0) + } + CliCommand::ScheduleAdd => { + run_schedule_add(&options)?; + Ok(0) + } + CliCommand::ScheduleUpdate => { + run_schedule_update(&options)?; + Ok(0) + } + CliCommand::ScheduleRemove => { + run_schedule_remove(&options)?; + Ok(0) + } + CliCommand::ScheduleRun => { + run_schedule_run(&options)?; + Ok(0) + } CliCommand::Status => { let _control_lock = DaemonControlLock::acquire(&options.state_dir)?; print_daemon_status(&options.state_dir, options.output_format)?; @@ -233,6 +293,34 @@ fn run() -> anyhow::Result { } } +fn run_repeated_workflow( + options: &CliOptions, + command_name: &'static str, + mut operation: impl FnMut(&CliOptions) -> anyhow::Result<()>, +) -> anyhow::Result<()> { + let max_runs = if options.watch { + None + } else { + Some(options.run_count.unwrap_or(1)) + }; + let mut completed_runs = 0usize; + loop { + operation(options)?; + completed_runs += 1; + if max_runs.is_some_and(|limit| completed_runs >= limit) { + return Ok(()); + } + if options.progress { + eprintln!( + "{command_name} 下一轮将在 {} 后执行(已完成 {} 轮)", + format_duration(options.interval), + completed_runs + ); + } + thread::sleep(options.interval); + } +} + #[derive(Debug, Serialize)] struct ErrorReport<'a> { status: &'a str, @@ -248,10 +336,31 @@ struct CliOptions { command: CliCommand, output_format: OutputFormat, watch: bool, + run_count: Option, + interval_explicit: bool, daemon: bool, daemon_child: bool, state_dir: PathBuf, output_explicit: bool, + resource_root: Option, + translation_file: Option, + translation_id: Option, + translation_text: Option, + translation_text_file: Option, + localized_release_id: Option, + repack_spec: Option, + schedule_group: Option, + schedule_action: Option, + schedule_id: Option, + schedule_at_unix: Option, + schedule_delay: Option, + schedule_every: Option, + schedule_count: Option, + schedule_args: Vec, + schedule_clear_args: bool, + schedule_clear_every: bool, + schedule_enabled: Option, + schedule_option_explicit: bool, sync_option_explicit: bool, proxy_option_explicit: bool, interval: Duration, @@ -309,10 +418,31 @@ impl Default for CliOptions { command: CliCommand::Run, output_format: OutputFormat::Human, watch: false, + run_count: None, + interval_explicit: false, daemon: false, daemon_child: false, state_dir: PathBuf::from(DEFAULT_DAEMON_STATE_DIR), output_explicit: false, + resource_root: None, + translation_file: None, + translation_id: None, + translation_text: None, + translation_text_file: None, + localized_release_id: None, + repack_spec: None, + schedule_group: None, + schedule_action: None, + schedule_id: None, + schedule_at_unix: None, + schedule_delay: None, + schedule_every: None, + schedule_count: None, + schedule_args: Vec::new(), + schedule_clear_args: false, + schedule_clear_every: false, + schedule_enabled: None, + schedule_option_explicit: false, sync_option_explicit: false, proxy_option_explicit: false, interval: Duration::from_secs(DEFAULT_WATCH_INTERVAL_SECONDS), @@ -372,6 +502,17 @@ enum OutputFormat { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum CliCommand { Run, + Pull, + Parse, + Translate, + TranslationSet, + Repack, + PublishLocalized, + ScheduleList, + ScheduleAdd, + ScheduleUpdate, + ScheduleRemove, + ScheduleRun, Status, Stop, Restart, @@ -831,6 +972,11 @@ const RPC_METHOD_RESOURCE_REPAIR: &str = "resource.repair"; const RPC_METHOD_RESOURCE_MANIFEST: &str = "resource.manifest"; const RPC_METHOD_RESOURCE_INDEX: &str = "resource.index"; const RPC_METHOD_RESOURCE_LIST: &str = "resource.list"; +const RPC_METHOD_SCHEDULE_LIST: &str = "schedule.list"; +const RPC_METHOD_SCHEDULE_ADD: &str = "schedule.add"; +const RPC_METHOD_SCHEDULE_UPDATE: &str = "schedule.update"; +const RPC_METHOD_SCHEDULE_REMOVE: &str = "schedule.remove"; +const RPC_METHOD_SCHEDULE_RUN: &str = "schedule.run"; const RPC_METHOD_PARSE_STATUS: &str = "parse.status"; const RPC_METHOD_PARSE_TEXT_UNITS: &str = "parse.text_units"; const RPC_METHOD_PARSE_ERRORS: &str = "parse.errors"; @@ -1616,6 +1762,77 @@ fn dispatch_rpc_method( "resource.state", build_resource_state_report(state_dir), ), + RPC_METHOD_SCHEDULE_LIST => rpc_envelope_from_result( + request_id, + RPC_METHOD_SCHEDULE_LIST, + schedule_commands::schedule_list_report(state_dir), + ), + RPC_METHOD_SCHEDULE_ADD => { + let params = match rpc_struct_params::( + request.params.as_ref(), + RPC_METHOD_SCHEDULE_ADD, + ) { + Ok(params) => params, + Err(error) => return rpc_envelope_error(request_id, error), + }; + rpc_envelope_from_result( + request_id, + RPC_METHOD_SCHEDULE_ADD, + schedule_commands::schedule_add_report(state_dir, params), + ) + } + RPC_METHOD_SCHEDULE_UPDATE => { + let params = match rpc_struct_params::( + request.params.as_ref(), + RPC_METHOD_SCHEDULE_UPDATE, + ) { + Ok(params) => params, + Err(error) => return rpc_envelope_error(request_id, error), + }; + rpc_envelope_from_result( + request_id, + RPC_METHOD_SCHEDULE_UPDATE, + schedule_commands::schedule_update_report(state_dir, params), + ) + } + RPC_METHOD_SCHEDULE_REMOVE => { + let params = match rpc_struct_params::( + request.params.as_ref(), + RPC_METHOD_SCHEDULE_REMOVE, + ) { + Ok(params) => params, + Err(error) => return rpc_envelope_error(request_id, error), + }; + rpc_envelope_from_result( + request_id, + RPC_METHOD_SCHEDULE_REMOVE, + schedule_commands::schedule_remove_report(state_dir, params), + ) + } + RPC_METHOD_SCHEDULE_RUN => { + let params = request + .params + .as_ref() + .map(|value| serde_json::from_value(value.clone())) + .transpose() + .map_err(|error| { + ApiError::new( + ErrorCode::RPC_INVALID_PARAMS, + RPC_METHOD_SCHEDULE_RUN, + format!("params 无效:{error}"), + ) + }); + let params = match params { + Ok(Some(params)) => params, + Ok(None) => schedule_commands::ScheduleRunRequest::default(), + Err(error) => return rpc_envelope_error(request_id, error), + }; + rpc_envelope_from_result( + request_id, + RPC_METHOD_SCHEDULE_RUN, + schedule_commands::schedule_run_report(state_dir, params), + ) + } RPC_METHOD_RESOURCE_SYNC => { let force = rpc_bool_param(request.params.as_ref(), "force").unwrap_or(false); enqueue_task_envelope(tasks, TaskKind::Sync, force, request_id) @@ -3837,6 +4054,42 @@ 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(()) + } +} + 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("后台状态"); @@ -6180,6 +6433,11 @@ fn parse_args_with_env( while let Some(flag) = args.next() { match flag.as_str() { + "res" | "resource" | "resources" => parse_resource_command(&mut args, &mut options)?, + "parse" => parse_parse_command(&mut args, &mut options)?, + "i18n" | "tr" | "translation" | "translate" => { + parse_translation_command(&mut args, &mut options)? + } "status" => { ensure_command_not_set(options.command, "status")?; options.command = CliCommand::Status; @@ -6313,6 +6571,29 @@ fn parse_args_with_env( PathBuf::from(next_option_value(&mut args, &flag)?); options.output_explicit = true; } + "--resource-root" => { + options.resource_root = Some(PathBuf::from(next_option_value(&mut args, &flag)?)); + } + "--translation-file" | "--workbench" => { + options.translation_file = + Some(PathBuf::from(next_option_value(&mut args, &flag)?)); + } + "--translation-id" => { + options.translation_id = Some(next_option_value(&mut args, &flag)?); + } + "--translated-text" => { + options.translation_text = Some(next_option_value(&mut args, &flag)?); + } + "--translated-file" => { + options.translation_text_file = + Some(PathBuf::from(next_option_value(&mut args, &flag)?)); + } + "--localized-release-id" => { + options.localized_release_id = Some(next_option_value(&mut args, &flag)?); + } + "--repack-spec" => { + options.repack_spec = Some(PathBuf::from(next_option_value(&mut args, &flag)?)); + } "--import-repository" => { options.config.import_repository = true; options.sync_option_explicit = true; @@ -6334,6 +6615,18 @@ fn parse_args_with_env( "--state-dir" | "--pid-dir" => { options.state_dir = PathBuf::from(next_option_value(&mut args, &flag)?); } + "--run-count" | "--repeat" => { + let value = next_option_value(&mut args, &flag)? + .parse::() + .map_err(|error| anyhow::anyhow!("{flag} 无效:{error}"))?; + if value == 0 { + return Err(anyhow::anyhow!("{flag} 必须大于 0")); + } + options.run_count = Some(value); + } + "--once" => { + options.run_count = Some(1); + } "--snapshot" => { options.config.snapshot_path = Some(PathBuf::from(next_option_value(&mut args, &flag)?)); @@ -6415,6 +6708,7 @@ fn parse_args_with_env( } "--interval" => { options.interval = parse_duration(&next_option_value(&mut args, &flag)?)?; + options.interval_explicit = true; options.sync_option_explicit = true; } "--interval-seconds" => { @@ -6422,8 +6716,66 @@ fn parse_args_with_env( .parse::() .map_err(|error| anyhow::anyhow!("{flag} 的秒数无效:{error}"))?; options.interval = Duration::from_secs(seconds); + options.interval_explicit = true; options.sync_option_explicit = true; } + "--schedule-id" | "--id" => { + options.schedule_id = Some(next_option_value(&mut args, &flag)?); + options.schedule_option_explicit = true; + } + "--schedule-action" | "--action" => { + options.schedule_action = Some(next_option_value(&mut args, &flag)?); + options.schedule_option_explicit = true; + } + "--schedule-at-unix" => { + let value = next_option_value(&mut args, &flag)? + .parse::() + .map_err(|error| anyhow::anyhow!("{flag} 无效:{error}"))?; + options.schedule_at_unix = Some(value); + options.schedule_option_explicit = true; + } + "--schedule-delay" => { + options.schedule_delay = + Some(parse_duration(&next_option_value(&mut args, &flag)?)?); + options.schedule_option_explicit = true; + } + "--schedule-every" => { + options.schedule_every = + Some(parse_duration(&next_option_value(&mut args, &flag)?)?); + options.schedule_option_explicit = true; + } + "--schedule-count" => { + let value = next_option_value(&mut args, &flag)? + .parse::() + .map_err(|error| anyhow::anyhow!("{flag} 无效:{error}"))?; + if value == 0 { + return Err(anyhow::anyhow!("--schedule-count 必须大于 0")); + } + options.schedule_count = Some(value); + options.schedule_option_explicit = true; + } + "--schedule-arg" => { + options + .schedule_args + .push(next_option_value(&mut args, &flag)?); + options.schedule_option_explicit = true; + } + "--schedule-clear-args" => { + options.schedule_clear_args = true; + options.schedule_option_explicit = true; + } + "--schedule-clear-every" => { + options.schedule_clear_every = true; + options.schedule_option_explicit = true; + } + "--schedule-enabled" => { + options.schedule_enabled = Some(true); + options.schedule_option_explicit = true; + } + "--schedule-disabled" => { + options.schedule_enabled = Some(false); + options.schedule_option_explicit = true; + } "--error-retry" => { options.error_retry_interval = parse_duration(&next_option_value(&mut args, &flag)?)?; @@ -6741,7 +7093,7 @@ fn parse_args_with_env( options.progress = false; options.banner = false; } - CliCommand::Refresh | CliCommand::Verify | CliCommand::Repair => { + CliCommand::Pull | CliCommand::Refresh | CliCommand::Verify | CliCommand::Repair => { if !options.config.auto_discover && options.config.server_info_source.is_none() && options.config.connection_group.is_none() @@ -6756,6 +7108,52 @@ fn parse_args_with_env( options.config.repair = false; } } + CliCommand::Parse | CliCommand::Translate | CliCommand::PublishLocalized => { + if options.daemon || options.daemon_child { + return Err(anyhow::anyhow!( + "parse/translate/publish-localized 使用 --watch 或 schedule,不支持 daemon" + )); + } + if options.config.dry_run { + return Err(anyhow::anyhow!( + "parse/translate/publish-localized 不能使用 --dry-run" + )); + } + } + CliCommand::TranslationSet | CliCommand::Repack => { + if options.watch || options.daemon || options.daemon_child { + return Err(anyhow::anyhow!("translation set/repack 只支持单次执行")); + } + if options.config.force || options.sync_option_explicit { + return Err(anyhow::anyhow!("translation set/repack 不接受资源同步选项")); + } + options.progress = false; + options.banner = false; + } + CliCommand::ScheduleList + | CliCommand::ScheduleAdd + | CliCommand::ScheduleUpdate + | CliCommand::ScheduleRemove + | CliCommand::ScheduleRun => { + if options.daemon || options.daemon_child { + return Err(anyhow::anyhow!("schedule 命令不能使用 daemon")); + } + if options.output_explicit + || options.proxy_option_explicit + || (options.config.force && !matches!(options.command, CliCommand::ScheduleRun)) + || options.config.dry_run + || tools_are_non_default(&options.config, &options.env_baseline_config) + { + return Err(anyhow::anyhow!( + "schedule 命令只接受 --state-dir、--json/--human、调度选项和 schedule run 的 --watch/--interval/--force" + )); + } + if options.interval_explicit && !matches!(options.command, CliCommand::ScheduleRun) { + return Err(anyhow::anyhow!("--interval 只适用于 schedule run 的轮询")); + } + options.progress = false; + options.banner = false; + } CliCommand::Restart | CliCommand::Reload => { if (options.sync_option_explicit || options.output_explicit @@ -6787,6 +7185,35 @@ fn parse_args_with_env( if options.error_retry_interval.is_zero() { return Err(anyhow::anyhow!("watch 失败重试间隔必须大于 0")); } + if options.run_count.is_some_and(|count| count > 1) + && !options.interval_explicit + && matches!( + options.command, + CliCommand::Pull + | CliCommand::Parse + | CliCommand::Translate + | CliCommand::PublishLocalized + ) + { + return Err(anyhow::anyhow!( + "--run-count 大于 1 时必须显式指定 --interval" + )); + } + if matches!( + options.command, + CliCommand::Pull | CliCommand::Parse | CliCommand::Translate | CliCommand::PublishLocalized + ) { + if options.watch && options.run_count.is_some() { + return Err(anyhow::anyhow!( + "--watch 与 --run-count 不能同时指定;周期执行请使用 --watch" + )); + } + if options.interval_explicit && !options.watch && options.run_count.unwrap_or(1) <= 1 { + return Err(anyhow::anyhow!( + "--interval 需要配合 --watch 或 --run-count 大于 1" + )); + } + } if (options.watch || options.daemon || options.daemon_child) && !options.quiet_up_to_date_explicit { @@ -6818,6 +7245,109 @@ fn parse_args_with_env( Ok(options) } +fn parse_resource_command( + args: &mut impl Iterator, + options: &mut CliOptions, +) -> anyhow::Result<()> { + let action = next_option_value(args, "resource")?; + match action.as_str() { + "pull" => { + ensure_command_not_set(options.command, "resource pull")?; + options.command = CliCommand::Pull; + } + "refresh" => { + ensure_command_not_set(options.command, "resource refresh")?; + options.command = CliCommand::Refresh; + } + "verify" => { + ensure_command_not_set(options.command, "resource verify")?; + options.command = CliCommand::Verify; + } + "repair" => { + ensure_command_not_set(options.command, "resource repair")?; + options.command = CliCommand::Repair; + } + "status" => { + ensure_command_not_set(options.command, "resource status")?; + options.command = CliCommand::ResourceIndex; + } + "index" => { + ensure_command_not_set(options.command, "resource index")?; + options.command = CliCommand::ResourceIndex; + } + "schedule" => { + options.schedule_group = Some("res".to_string()); + parse_schedule_command(args, options)? + } + other => return Err(anyhow::anyhow!("未知 resource 二级命令:{other}")), + } + Ok(()) +} + +fn parse_parse_command( + args: &mut impl Iterator, + options: &mut CliOptions, +) -> anyhow::Result<()> { + let action = next_option_value(args, "parse")?; + let command = match action.as_str() { + "run" => CliCommand::Parse, + "status" => CliCommand::ParseStatus, + "text-units" => CliCommand::ParseTextUnits, + "errors" => CliCommand::ParseErrors, + "schedule" => { + options.schedule_group = Some("parse".to_string()); + return parse_schedule_command(args, options); + } + "repack" => CliCommand::Repack, + other => return Err(anyhow::anyhow!("未知 parse 二级命令:{other}")), + }; + ensure_command_not_set(options.command, &format!("parse {action}"))?; + options.command = command; + Ok(()) +} + +fn parse_translation_command( + args: &mut impl Iterator, + options: &mut CliOptions, +) -> anyhow::Result<()> { + let action = next_option_value(args, "translation")?; + let command = match action.as_str() { + "run" => CliCommand::Translate, + "export" => CliCommand::Translate, + "set" => CliCommand::TranslationSet, + "publish" => CliCommand::PublishLocalized, + "tasks" => CliCommand::TranslationTasks, + "handoff" => CliCommand::TranslationHandoff, + "status" => CliCommand::LocalizedStatus, + "schedule" => { + options.schedule_group = Some("i18n".to_string()); + return parse_schedule_command(args, options); + } + other => return Err(anyhow::anyhow!("未知 translation 二级命令:{other}")), + }; + ensure_command_not_set(options.command, &format!("translation {action}"))?; + options.command = command; + Ok(()) +} + +fn parse_schedule_command( + args: &mut impl Iterator, + options: &mut CliOptions, +) -> anyhow::Result<()> { + let action = next_option_value(args, "schedule")?; + let command = match action.as_str() { + "list" => CliCommand::ScheduleList, + "add" => CliCommand::ScheduleAdd, + "update" => CliCommand::ScheduleUpdate, + "remove" | "delete" => CliCommand::ScheduleRemove, + "run" => CliCommand::ScheduleRun, + other => return Err(anyhow::anyhow!("未知 schedule 二级命令:{other}")), + }; + ensure_command_not_set(options.command, &format!("schedule {action}"))?; + options.command = command; + Ok(()) +} + fn ensure_command_not_set(command: CliCommand, next: &str) -> anyhow::Result<()> { if command == CliCommand::Run { Ok(()) @@ -6902,6 +7432,15 @@ fn print_usage(binary: &str) { 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 repack Repack a UnityFS bundle from a JSON spec"); + 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 publish Publish a 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"); @@ -6930,6 +7469,10 @@ fn print_usage(binary: &str) { 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} i18n export --translation-file /tmp/bat-workbench.json"); + eprintln!(" {binary} i18n publish --translation-file /tmp/bat-workbench.json --force"); eprintln!(" {binary} status"); eprintln!(" {binary} refresh --force --json"); eprintln!(); @@ -6971,6 +7514,15 @@ fn print_usage(binary: &str) { 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 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!(" --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"); @@ -7031,6 +7583,16 @@ fn print_usage(binary: &str) { 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 Schedule identifier"); + eprintln!(" --schedule-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-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)"); diff --git a/infrastructure/src/bin/bat/app_tests.rs b/infrastructure/src/bin/bat/app_tests.rs index dd26d3e..6ee1a52 100644 --- a/infrastructure/src/bin/bat/app_tests.rs +++ b/infrastructure/src/bin/bat/app_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::app::schedule_commands::read_schedule_file; fn parse(values: &[&str]) -> anyhow::Result { parse_args_from(values.iter().map(|value| value.to_string())) @@ -136,6 +137,254 @@ fn env_invalid_values_error() { assert!(parse(&["bat", "--download-concurrency", "257"]).is_err()); } +#[test] +fn grouped_workflow_commands_use_short_top_level_aliases() { + let options = parse(&[ + "bat", + "res", + "pull", + "--run-count", + "3", + "--interval", + "10s", + "--download-concurrency", + "8", + ]) + .unwrap(); + assert_eq!(options.command, CliCommand::Pull); + assert_eq!(options.run_count, Some(3)); + assert_eq!(options.interval, Duration::from_secs(10)); + assert!(options.interval_explicit); + + let options = parse(&[ + "bat", + "parse", + "run", + "--resource-root", + "/tmp/official-release", + "--force", + ]) + .unwrap(); + assert_eq!(options.command, CliCommand::Parse); + assert_eq!( + options.resource_root, + Some(PathBuf::from("/tmp/official-release")) + ); + assert!(options.config.force); + + let options = parse(&[ + "bat", + "i18n", + "set", + "--translation-file", + "/tmp/workbench.json", + "--translation-id", + "unit-1", + "--translated-text", + "你好", + ]) + .unwrap(); + assert_eq!(options.command, CliCommand::TranslationSet); + assert_eq!( + options.translation_file, + Some(PathBuf::from("/tmp/workbench.json")) + ); + assert_eq!(options.translation_id.as_deref(), Some("unit-1")); + + let options = parse(&[ + "bat", + "parse", + "repack", + "--repack-spec", + "/tmp/repack.json", + ]) + .unwrap(); + assert_eq!(options.command, CliCommand::Repack); + assert_eq!(options.repack_spec, Some(PathBuf::from("/tmp/repack.json"))); +} + +#[test] +fn grouped_command_long_aliases_and_schedule_options_are_accepted() { + let options = parse(&[ + "bat", + "resources", + "schedule", + "add", + "--schedule-id", + "nightly-pull", + "--schedule-action", + "pull", + "--schedule-delay", + "5m", + "--schedule-every", + "1h", + "--schedule-count", + "4", + "--schedule-arg", + "--auto-discover", + ]) + .unwrap(); + assert_eq!(options.command, CliCommand::ScheduleAdd); + assert_eq!(options.schedule_group.as_deref(), Some("res")); + assert_eq!(options.schedule_id.as_deref(), Some("nightly-pull")); + assert_eq!(options.schedule_action.as_deref(), Some("pull")); + assert_eq!(options.schedule_delay, Some(Duration::from_secs(300))); + assert_eq!(options.schedule_every, Some(Duration::from_secs(3600))); + assert_eq!(options.schedule_count, Some(4)); + assert_eq!(options.schedule_args, vec!["--auto-discover"]); + + let options = parse(&["bat", "translate", "schedule", "run", "--force"]).unwrap(); + assert_eq!(options.command, CliCommand::ScheduleRun); + assert_eq!(options.schedule_group.as_deref(), Some("i18n")); + assert!(options.config.force); +} + +#[test] +fn repeated_workflow_requires_explicit_interval_after_first_run() { + assert!(parse(&["bat", "parse", "run", "--run-count", "2"]).is_err()); + assert!(parse(&["bat", "parse", "run", "--run-count", "0"]).is_err()); + assert!(parse(&["bat", "parse", "run", "--watch", "--run-count", "2"]).is_err()); + assert!(parse(&["bat", "parse", "run", "--interval", "1m"]).is_err()); + assert!(parse(&[ + "bat", + "parse", + "run", + "--run-count", + "2", + "--interval", + "1m", + ]) + .is_ok()); +} + +#[test] +fn schedule_crud_persists_and_updates_a_workflow_plan() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().to_string_lossy().to_string(); + let add = parse(&[ + "bat", + "res", + "schedule", + "add", + "--state-dir", + &state_dir, + "--schedule-id", + "pull-once", + "--schedule-action", + "pull", + "--schedule-delay", + "1s", + "--schedule-every", + "1h", + "--schedule-count", + "2", + "--schedule-arg", + "--auto-discover", + ]) + .unwrap(); + run_schedule_add(&add).unwrap(); + let file = read_schedule_file(temp.path()).unwrap(); + assert_eq!(file.schedules.len(), 1); + assert_eq!(file.schedules[0].id, "pull-once"); + assert_eq!(file.schedules[0].remaining_runs, Some(2)); + assert_eq!(file.schedules[0].interval_seconds, Some(3600)); + assert_eq!(file.schedules[0].args, vec!["--auto-discover"]); + + let update = parse(&[ + "bat", + "res", + "schedule", + "update", + "--state-dir", + &state_dir, + "--schedule-id", + "pull-once", + "--schedule-disabled", + "--schedule-every", + "1h", + ]) + .unwrap(); + run_schedule_update(&update).unwrap(); + let file = read_schedule_file(temp.path()).unwrap(); + assert!(!file.schedules[0].enabled); + assert_eq!(file.schedules[0].interval_seconds, Some(3600)); + + let clear_every = parse(&[ + "bat", + "res", + "schedule", + "update", + "--state-dir", + &state_dir, + "--schedule-id", + "pull-once", + "--schedule-clear-every", + "--schedule-count", + "1", + ]) + .unwrap(); + run_schedule_update(&clear_every).unwrap(); + let file = read_schedule_file(temp.path()).unwrap(); + assert_eq!(file.schedules[0].interval_seconds, None); + assert_eq!(file.schedules[0].remaining_runs, Some(1)); + + let remove = parse(&[ + "bat", + "res", + "schedule", + "remove", + "--state-dir", + &state_dir, + "--schedule-id", + "pull-once", + ]) + .unwrap(); + run_schedule_remove(&remove).unwrap(); + assert!(read_schedule_file(temp.path()) + .unwrap() + .schedules + .is_empty()); +} + +#[test] +fn dispatch_schedule_crud_uses_shared_state_file() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path(); + let control = new_daemon_control(); + + let add = dispatch_rpc_method( + &rpc_request( + "schedule.add", + Some(serde_json::json!({ + "id": "rpc-pull", + "group": "res", + "action": "pull", + "delay_seconds": 60, + "every_seconds": 3600, + "count": 2 + })), + ), + state_dir, + &control, + &test_task_context(), + "req-schedule-add".to_string(), + ); + let add = serde_json::to_value(add).unwrap(); + assert_eq!(add["ok"], true); + assert_eq!(add["data"]["schedule"]["id"], "rpc-pull"); + + let list = dispatch_rpc_method( + &rpc_request("schedule.list", None), + state_dir, + &control, + &test_task_context(), + "req-schedule-list".to_string(), + ); + let list = serde_json::to_value(list).unwrap(); + assert_eq!(list["ok"], true); + assert_eq!(list["data"]["schedules"][0]["id"], "rpc-pull"); +} + #[test] fn cli_download_concurrency_is_preserved_for_daemon_child() { let options = parse(&["bat", "--download-concurrency", "4"]).unwrap(); diff --git a/infrastructure/src/bin/bat/schedule_commands.rs b/infrastructure/src/bin/bat/schedule_commands.rs new file mode 100644 index 0000000..7604914 --- /dev/null +++ b/infrastructure/src/bin/bat/schedule_commands.rs @@ -0,0 +1,689 @@ +use super::*; + +const SCHEDULES_FILE_NAME: &str = "bat-schedules.json"; +const SCHEDULE_LOCK_FILE_NAME: &str = "bat-schedule.lock"; +const SCHEDULES_SCHEMA_VERSION: u32 = 1; +static SCHEDULE_FILE_LOCK: Mutex<()> = Mutex::new(()); + +#[derive(Debug)] +struct ScheduleFileLock { + path: PathBuf, + pid: u32, +} + +impl ScheduleFileLock { + fn acquire(state_dir: &Path) -> anyhow::Result { + validate_runtime_state_dir(state_dir).map_err(anyhow::Error::msg)?; + fs::create_dir_all(state_dir)?; + let path = state_dir.join(SCHEDULE_LOCK_FILE_NAME); + let pid = std::process::id(); + for attempt in 0..=1 { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + options.mode(PRIVATE_FILE_MODE); + match options.open(&path) { + Ok(mut file) => { + file.write_all(pid.to_string().as_bytes())?; + return Ok(Self { path, pid }); + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + if attempt == 0 && remove_recoverable_pid_lock(&path)? { + continue; + } + return Err(anyhow::anyhow!( + "调度计划已被锁定:{};{}", + path.display(), + describe_pid_lock_owner(&path)? + )); + } + Err(error) => { + return Err(anyhow::anyhow!( + "获取调度计划锁失败 {}:{error}", + path.display() + )); + } + } + } + Err(anyhow::anyhow!("获取调度计划锁失败")) + } +} + +impl Drop for ScheduleFileLock { + fn drop(&mut self) { + let expected = self.pid.to_string(); + if fs::symlink_metadata(&self.path) + .map(|metadata| metadata.file_type().is_symlink()) + .unwrap_or(false) + { + return; + } + if fs::read_to_string(&self.path) + .map(|contents| contents.trim() == expected) + .unwrap_or(false) + { + let _ = fs::remove_file(&self.path); + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(super) struct ScheduleFile { + pub(super) schema_version: u32, + pub(super) schedules: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(super) struct ScheduleEntry { + pub(super) id: String, + pub(super) group: String, + pub(super) action: String, + pub(super) args: Vec, + pub(super) next_run_unix_seconds: u64, + pub(super) interval_seconds: Option, + pub(super) remaining_runs: Option, + pub(super) enabled: bool, + pub(super) created_unix_seconds: u64, + pub(super) updated_unix_seconds: u64, + pub(super) last_run_unix_seconds: Option, + pub(super) last_status: Option, + pub(super) last_error: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub(super) struct ScheduleMutationRequest { + #[serde(default, alias = "schedule_id")] + pub(super) id: Option, + #[serde(default)] + pub(super) group: Option, + #[serde(default)] + pub(super) action: Option, + #[serde(default)] + pub(super) args: Vec, + #[serde(default, alias = "at_unix_seconds", alias = "schedule_at_unix")] + pub(super) next_run_unix_seconds: Option, + #[serde(default, alias = "schedule_delay_seconds")] + pub(super) delay_seconds: Option, + #[serde(default, alias = "schedule_every_seconds")] + pub(super) every_seconds: Option, + #[serde(default, alias = "schedule_count")] + pub(super) count: Option, + #[serde(default)] + pub(super) clear_args: bool, + #[serde(default)] + pub(super) clear_every: bool, + #[serde(default)] + pub(super) enabled: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub(super) struct ScheduleRunRequest { + #[serde(default, alias = "schedule_id")] + pub(super) id: Option, + #[serde(default)] + pub(super) force: bool, +} + +pub(super) fn run_schedule_list(options: &CliOptions) -> anyhow::Result<()> { + print_json_value( + options.output_format, + &schedule_list_report(&options.state_dir)?, + ) +} + +pub(super) fn run_schedule_add(options: &CliOptions) -> anyhow::Result<()> { + validate_schedule_command_options(options, false)?; + let request = schedule_request_from_options(options); + print_json_value( + options.output_format, + &schedule_add_report(&options.state_dir, request)?, + ) +} + +pub(super) fn run_schedule_update(options: &CliOptions) -> anyhow::Result<()> { + validate_schedule_command_options(options, true)?; + let request = schedule_request_from_options(options); + print_json_value( + options.output_format, + &schedule_update_report(&options.state_dir, request)?, + ) +} + +pub(super) fn run_schedule_remove(options: &CliOptions) -> anyhow::Result<()> { + validate_schedule_command_options(options, true)?; + let request = schedule_request_from_options(options); + print_json_value( + options.output_format, + &schedule_remove_report(&options.state_dir, request)?, + ) +} + +pub(super) fn run_schedule_run(options: &CliOptions) -> anyhow::Result<()> { + validate_schedule_command_options(options, true)?; + loop { + let request = ScheduleRunRequest { + id: options.schedule_id.clone(), + force: options.config.force, + }; + print_json_value( + options.output_format, + &schedule_run_report(&options.state_dir, request)?, + )?; + if !options.watch { + return Ok(()); + } + thread::sleep(options.interval); + } +} + +pub(super) fn schedule_list_report(state_dir: &Path) -> anyhow::Result { + let _guard = SCHEDULE_FILE_LOCK + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + let _schedule_lock = ScheduleFileLock::acquire(state_dir)?; + let file = read_schedule_file(state_dir)?; + Ok(serde_json::json!({ + "command": "schedule-list", + "status": "ok", + "state_file": schedule_file_path(state_dir), + "schedules": file.schedules, + })) +} + +pub(super) fn schedule_add_report( + state_dir: &Path, + request: ScheduleMutationRequest, +) -> anyhow::Result { + let _guard = SCHEDULE_FILE_LOCK + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + let _schedule_lock = ScheduleFileLock::acquire(state_dir)?; + let mut file = read_schedule_file(state_dir)?; + let id = request + .id + .as_deref() + .ok_or_else(|| anyhow::anyhow!("schedule add 必须指定 --schedule-id"))?; + if file.schedules.iter().any(|entry| entry.id == id) { + return Err(anyhow::anyhow!("schedule 已存在:{id}")); + } + let now = unix_seconds_now(); + let entry = build_schedule_entry(&request, now)?; + file.schedules.push(entry.clone()); + write_schedule_file(state_dir, &file)?; + Ok(schedule_result_value( + state_dir, + "schedule-add", + "created", + &entry, + )) +} + +pub(super) fn schedule_update_report( + state_dir: &Path, + request: ScheduleMutationRequest, +) -> anyhow::Result { + let _guard = SCHEDULE_FILE_LOCK + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + let _schedule_lock = ScheduleFileLock::acquire(state_dir)?; + validate_schedule_mutation(&request, true)?; + let id = request + .id + .as_deref() + .ok_or_else(|| anyhow::anyhow!("schedule update 必须指定 --schedule-id"))?; + let mut file = read_schedule_file(state_dir)?; + let entry = file + .schedules + .iter_mut() + .find(|entry| entry.id == id) + .ok_or_else(|| anyhow::anyhow!("schedule 不存在:{id}"))?; + if let Some(group) = request + .group + .as_deref() + .map(normalize_schedule_group) + .transpose()? + { + if group != entry.group { + return Err(anyhow::anyhow!( + "schedule {} 属于 {},不能从 {} 二级命令更新", + id, + entry.group, + group + )); + } + } + if let Some(action) = request.action.as_deref() { + validate_schedule_action(entry.group.as_str(), action)?; + entry.action = action.to_string(); + } + if let Some(at) = request.next_run_unix_seconds { + entry.next_run_unix_seconds = at; + } + if let Some(delay) = request.delay_seconds { + entry.next_run_unix_seconds = unix_seconds_now().saturating_add(delay); + } + if let Some(every) = request.every_seconds { + entry.interval_seconds = Some(nonzero_seconds( + Duration::from_secs(every), + "--schedule-every", + )?); + } + if request.clear_every { + entry.interval_seconds = None; + } + if let Some(count) = request.count { + entry.remaining_runs = Some(count); + } + if request.clear_args { + entry.args.clear(); + } + if !request.args.is_empty() { + validate_schedule_args(&request.args)?; + entry.args = request.args.clone(); + } + if let Some(enabled) = request.enabled { + entry.enabled = enabled; + } + if entry.interval_seconds.is_none() && request.clear_every && request.count.is_none() { + entry.remaining_runs = Some(1); + } + validate_schedule_entry_shape(entry)?; + entry.updated_unix_seconds = unix_seconds_now(); + let updated = entry.clone(); + write_schedule_file(state_dir, &file)?; + Ok(schedule_result_value( + state_dir, + "schedule-update", + "updated", + &updated, + )) +} + +pub(super) fn schedule_remove_report( + state_dir: &Path, + request: ScheduleMutationRequest, +) -> anyhow::Result { + let _guard = SCHEDULE_FILE_LOCK + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + let _schedule_lock = ScheduleFileLock::acquire(state_dir)?; + let id = request + .id + .as_deref() + .ok_or_else(|| anyhow::anyhow!("schedule remove 必须指定 --schedule-id"))?; + let mut file = read_schedule_file(state_dir)?; + let before = file.schedules.len(); + file.schedules.retain(|entry| entry.id != id); + if file.schedules.len() == before { + return Err(anyhow::anyhow!("schedule 不存在:{id}")); + } + write_schedule_file(state_dir, &file)?; + Ok(serde_json::json!({ + "command": "schedule-remove", + "status": "removed", + "id": id, + "state_file": schedule_file_path(state_dir), + })) +} + +pub(super) fn schedule_run_report( + state_dir: &Path, + request: ScheduleRunRequest, +) -> anyhow::Result { + let _guard = SCHEDULE_FILE_LOCK + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + let _schedule_lock = ScheduleFileLock::acquire(state_dir)?; + let now = unix_seconds_now(); + let selected_id = request.id.as_deref(); + let mut file = read_schedule_file(state_dir)?; + let mut results = Vec::new(); + for index in 0..file.schedules.len() { + let due = { + let entry = &file.schedules[index]; + entry.enabled + && (request.force || entry.next_run_unix_seconds <= now) + && selected_id.is_none_or(|id| id == entry.id) + }; + if !due { + continue; + } + let entry = &mut file.schedules[index]; + let id = entry.id.clone(); + let command = schedule_child_command(entry, state_dir); + let started = unix_seconds_now(); + if let Some(remaining) = entry.remaining_runs.as_mut() { + *remaining = remaining.saturating_sub(1); + } + entry.last_run_unix_seconds = Some(started); + entry.updated_unix_seconds = started; + entry.enabled = entry.remaining_runs != Some(0); + entry.next_run_unix_seconds = entry + .interval_seconds + .map(|seconds| started.saturating_add(seconds)) + .unwrap_or(started); + write_schedule_file(state_dir, &file)?; + + let status = Command::new(&command[0]).args(&command[1..]).status(); + let (status_label, error) = match status { + Ok(status) if status.success() => ("completed".to_string(), None), + Ok(status) => ( + "failed".to_string(), + Some(format!("子命令退出码:{}", status.code().unwrap_or(-1))), + ), + Err(error) => ("failed".to_string(), Some(error.to_string())), + }; + let (next_run_unix_seconds, enabled) = { + let entry = &mut file.schedules[index]; + entry.last_status = Some(status_label.clone()); + entry.last_error = error.clone(); + entry.updated_unix_seconds = unix_seconds_now(); + (entry.next_run_unix_seconds, entry.enabled) + }; + write_schedule_file(state_dir, &file)?; + results.push(serde_json::json!({ + "id": id, + "command": command, + "status": status_label, + "error": error, + "next_run_unix_seconds": next_run_unix_seconds, + "enabled": enabled, + })); + } + if selected_id.is_some() && results.is_empty() { + let status = match file + .schedules + .iter() + .find(|entry| Some(entry.id.as_str()) == selected_id) + { + None => "not_found", + Some(entry) if !entry.enabled => "disabled", + Some(_) => "not_due", + }; + return Ok(serde_json::json!({ + "command": "schedule-run", + "status": status, + "now_unix_seconds": now, + "executed": [], + })); + } + Ok(serde_json::json!({ + "command": "schedule-run", + "status": "completed", + "now_unix_seconds": now, + "executed": results, + })) +} + +fn build_schedule_entry( + request: &ScheduleMutationRequest, + now: u64, +) -> anyhow::Result { + validate_schedule_mutation(request, false)?; + let group = request + .group + .as_deref() + .map(normalize_schedule_group) + .transpose()? + .ok_or_else(|| anyhow::anyhow!("schedule 命令缺少所属一级命令"))?; + let action = request + .action + .as_deref() + .map(str::to_string) + .unwrap_or_else(|| default_schedule_action(&group).to_string()); + validate_schedule_action(&group, &action)?; + validate_schedule_args(&request.args)?; + let next_run = schedule_next_run(request, now)?; + let interval_seconds = request + .every_seconds + .map(|value| nonzero_seconds(Duration::from_secs(value), "--schedule-every")) + .transpose()?; + let remaining_runs = request + .count + .or_else(|| interval_seconds.is_none().then_some(1)); + if interval_seconds.is_none() && remaining_runs.is_some_and(|count| count > 1) { + return Err(anyhow::anyhow!( + "--schedule-count 大于 1 时必须指定 --schedule-every" + )); + } + Ok(ScheduleEntry { + id: request + .id + .clone() + .ok_or_else(|| anyhow::anyhow!("schedule add 必须指定 --schedule-id"))?, + group, + action, + args: request.args.clone(), + next_run_unix_seconds: next_run, + interval_seconds, + remaining_runs, + enabled: request.enabled.unwrap_or(true), + created_unix_seconds: now, + updated_unix_seconds: now, + last_run_unix_seconds: None, + last_status: None, + last_error: None, + }) +} + +fn validate_schedule_mutation( + request: &ScheduleMutationRequest, + update: bool, +) -> anyhow::Result<()> { + if request.next_run_unix_seconds.is_some() && request.delay_seconds.is_some() { + return Err(anyhow::anyhow!( + "next_run_unix_seconds 与 delay_seconds 只能指定一个" + )); + } + if request.every_seconds.is_some() && request.clear_every { + return Err(anyhow::anyhow!("every_seconds 与 clear_every 只能指定一个")); + } + if request.count == Some(0) { + return Err(anyhow::anyhow!("count 必须大于 0")); + } + if request.clear_args && !update { + return Err(anyhow::anyhow!("clear_args 只适用于 schedule update")); + } + if request.clear_every && !update { + return Err(anyhow::anyhow!("clear_every 只适用于 schedule update")); + } + Ok(()) +} + +fn schedule_next_run(request: &ScheduleMutationRequest, now: u64) -> anyhow::Result { + match (request.next_run_unix_seconds, request.delay_seconds) { + (Some(_), Some(_)) => Err(anyhow::anyhow!( + "--schedule-at-unix 与 --schedule-delay 只能指定一个" + )), + (Some(at), None) => Ok(at), + (None, Some(delay)) => Ok(now.saturating_add(delay)), + (None, None) => Ok(now), + } +} + +fn schedule_child_command(entry: &ScheduleEntry, state_dir: &Path) -> Vec { + let executable = env::current_exe().unwrap_or_else(|_| PathBuf::from("bat")); + let mut command = vec![ + executable.to_string_lossy().into_owned(), + entry.group.clone(), + entry.action.clone(), + ]; + command.extend(entry.args.iter().cloned()); + if !entry.args.iter().any(|arg| arg == "--state-dir") { + command.push("--state-dir".to_string()); + command.push(state_dir.to_string_lossy().into_owned()); + } + command.push("--no-banner".to_string()); + command.push("--no-progress".to_string()); + command +} + +fn validate_schedule_command_options( + options: &CliOptions, + allow_empty: bool, +) -> anyhow::Result<()> { + if !allow_empty && options.schedule_group.is_none() { + return Err(anyhow::anyhow!("schedule 命令缺少所属一级命令")); + } + if options.watch && !matches!(options.command, CliCommand::ScheduleRun) { + return Err(anyhow::anyhow!("只有 schedule run 支持 --watch")); + } + if options.interval.is_zero() { + return Err(anyhow::anyhow!("schedule 轮询间隔必须大于 0")); + } + if options.schedule_every.is_some_and(|value| value.is_zero()) { + return Err(anyhow::anyhow!("--schedule-every 必须大于 0")); + } + if options.schedule_delay.is_some_and(|value| value.is_zero()) { + return Err(anyhow::anyhow!("--schedule-delay 必须大于 0")); + } + if options.schedule_count == Some(0) { + return Err(anyhow::anyhow!("--schedule-count 必须大于 0")); + } + if options.schedule_clear_args && !matches!(options.command, CliCommand::ScheduleUpdate) { + return Err(anyhow::anyhow!( + "--schedule-clear-args 只适用于 schedule update" + )); + } + if options.schedule_clear_every && !matches!(options.command, CliCommand::ScheduleUpdate) { + return Err(anyhow::anyhow!( + "--schedule-clear-every 只适用于 schedule update" + )); + } + Ok(()) +} + +fn validate_schedule_action(group: &str, action: &str) -> anyhow::Result<()> { + let valid = match group { + "res" => matches!(action, "pull" | "refresh" | "verify" | "repair"), + "parse" => matches!(action, "run" | "repack"), + "i18n" => matches!(action, "run" | "export" | "publish"), + _ => false, + }; + if valid { + Ok(()) + } else { + Err(anyhow::anyhow!( + "不支持的 schedule action:group={group}, action={action}" + )) + } +} + +fn validate_schedule_entry_shape(entry: &ScheduleEntry) -> anyhow::Result<()> { + if entry.interval_seconds.is_none() && entry.remaining_runs.is_some_and(|count| count > 1) { + return Err(anyhow::anyhow!( + "非周期 schedule 不能保留多次执行次数;请设置 --schedule-every" + )); + } + Ok(()) +} + +fn default_schedule_action(group: &str) -> &'static str { + match group { + "res" => "pull", + "parse" => "run", + "i18n" => "run", + _ => "", + } +} + +fn validate_schedule_args(args: &[String]) -> anyhow::Result<()> { + if let Some(arg) = args + .iter() + .find(|arg| arg.starts_with("--schedule-") || matches!(arg.as_str(), "--id" | "--action")) + { + return Err(anyhow::anyhow!( + "schedule 子命令参数不能嵌套调度控制选项:{arg}" + )); + } + Ok(()) +} + +fn nonzero_seconds(value: Duration, flag: &str) -> anyhow::Result { + let seconds = value.as_secs(); + if seconds == 0 { + return Err(anyhow::anyhow!("{flag} 必须至少为 1s")); + } + Ok(seconds) +} + +fn schedule_request_from_options(options: &CliOptions) -> ScheduleMutationRequest { + ScheduleMutationRequest { + id: options.schedule_id.clone(), + group: options.schedule_group.clone(), + action: options.schedule_action.clone(), + args: options.schedule_args.clone(), + next_run_unix_seconds: options.schedule_at_unix, + delay_seconds: options.schedule_delay.map(|value| value.as_secs()), + every_seconds: options.schedule_every.map(|value| value.as_secs()), + count: options.schedule_count, + clear_args: options.schedule_clear_args, + clear_every: options.schedule_clear_every, + enabled: options.schedule_enabled, + } +} + +fn normalize_schedule_group(group: &str) -> anyhow::Result { + let normalized = match group { + "res" | "resource" | "resources" => "res", + "parse" => "parse", + "i18n" | "tr" | "translation" | "translate" => "i18n", + other => { + return Err(anyhow::anyhow!( + "schedule 不支持的一级命令:{other}(支持 res、parse、i18n)" + )) + } + }; + Ok(normalized.to_string()) +} + +fn schedule_result_value( + state_dir: &Path, + command: &'static str, + status: &'static str, + entry: &ScheduleEntry, +) -> serde_json::Value { + serde_json::json!({ + "command": command, + "status": status, + "schedule": entry, + "state_file": schedule_file_path(state_dir), + }) +} + +fn schedule_file_path(state_dir: &Path) -> PathBuf { + state_dir.join(SCHEDULES_FILE_NAME) +} + +pub(super) fn read_schedule_file(state_dir: &Path) -> anyhow::Result { + let path = schedule_file_path(state_dir); + let Some(bytes) = read_file_no_symlink(&path, "调度计划文件").map_err(anyhow::Error::msg)? + else { + return Ok(ScheduleFile { + schema_version: SCHEDULES_SCHEMA_VERSION, + schedules: Vec::new(), + }); + }; + let file: ScheduleFile = serde_json::from_slice(&bytes)?; + if file.schema_version != SCHEDULES_SCHEMA_VERSION { + return Err(anyhow::anyhow!( + "不支持的调度计划 schema:{},当前版本={}", + file.schema_version, + SCHEDULES_SCHEMA_VERSION + )); + } + Ok(file) +} + +fn write_schedule_file(state_dir: &Path, file: &ScheduleFile) -> anyhow::Result<()> { + validate_runtime_state_dir(state_dir).map_err(anyhow::Error::msg)?; + let path = schedule_file_path(state_dir); + let bytes = serde_json::to_vec_pretty(file)?; + write_file_atomic( + &path, + &bytes, + bat_infrastructure::STATE_FILE_MODE, + "调度计划文件", + ) + .map_err(anyhow::Error::msg) +} diff --git a/infrastructure/src/bin/bat/workflow_commands.rs b/infrastructure/src/bin/bat/workflow_commands.rs new file mode 100644 index 0000000..3295bc8 --- /dev/null +++ b/infrastructure/src/bin/bat/workflow_commands.rs @@ -0,0 +1,193 @@ +use super::*; + +pub(super) fn run_parse_once(options: &CliOptions) -> anyhow::Result<()> { + let (resource_root, release_id) = current_official_release(options)?; + let parse_config = + OfficialParseConfig::new(&resource_root, options.config.unzip_command.clone()) + .with_force(options.config.force); + let parse_report = OfficialParseCacheService::new() + .run(&parse_config) + .map_err(anyhow::Error::msg)?; + let queue_report = + write_official_textunit_queues(&resource_root).map_err(anyhow::Error::msg)?; + let data = serde_json::json!({ + "official_release_id": release_id, + "resource_root": resource_root, + "forced": options.config.force, + "parse": parse_report, + "translation_queue": queue_report, + }); + print_report( + options.output_format, + &CommandReport { + command: "parse", + status: "completed", + message: "官方资源解析已执行", + data, + }, + ) +} + +pub(super) fn run_translate_once(options: &CliOptions) -> anyhow::Result<()> { + let (resource_root, release_id) = current_official_release(options)?; + let queue = write_official_textunit_queues(&resource_root).map_err(anyhow::Error::msg)?; + let exported = options + .translation_file + .as_ref() + .map(|path| { + export_translation_workbench(&resource_root, release_id.clone(), path).map( + |workbench| { + serde_json::json!({ + "path": path, + "entry_count": workbench.entries.len(), + }) + }, + ) + }) + .transpose()?; + let data = serde_json::json!({ + "official_release_id": release_id, + "resource_root": resource_root, + "queue": queue, + "workbench": exported, + "provider": "offline", + "note": "当前 translate 只生成/刷新离线队列和可编辑工作台,不调用外部翻译 provider", + }); + print_report( + options.output_format, + &CommandReport { + command: "translate", + status: "queued", + message: "翻译离线队列已刷新", + data, + }, + ) +} + +pub(super) fn run_translation_set(options: &CliOptions) -> anyhow::Result<()> { + let path = options + .translation_file + .as_ref() + .ok_or_else(|| anyhow::anyhow!("translation-set 必须指定 --translation-file"))?; + let text = match (&options.translation_text, &options.translation_text_file) { + (Some(_), Some(_)) => { + return Err(anyhow::anyhow!( + "--translated-text 与 --translated-file 只能指定一个" + )) + } + (Some(text), None) => text.clone(), + (None, Some(path)) => String::from_utf8( + read_file_no_symlink(path, "翻译文本文件") + .map_err(anyhow::Error::msg)? + .ok_or_else(|| anyhow::anyhow!("翻译文本文件不存在:{}", path.display()))?, + )?, + (None, None) => { + return Err(anyhow::anyhow!( + "translation-set 必须指定 --translated-text 或 --translated-file" + )) + } + }; + let entry_id = options + .translation_id + .as_deref() + .ok_or_else(|| anyhow::anyhow!("translation-set 必须指定 --translation-id"))?; + let entry = set_translation(path, entry_id, text)?; + let data = serde_json::json!({ + "translation_file": path, + "entry": entry, + }); + print_report( + options.output_format, + &CommandReport { + command: "translation-set", + status: "updated", + message: "翻译工作台条目已更新", + data, + }, + ) +} + +pub(super) fn run_repack(options: &CliOptions) -> anyhow::Result<()> { + let spec = options + .repack_spec + .as_ref() + .ok_or_else(|| anyhow::anyhow!("repack 必须指定 --repack-spec"))?; + let report = repack_bundle(spec)?; + print_report(options.output_format, &report) +} + +pub(super) fn run_publish_localized(options: &CliOptions) -> anyhow::Result<()> { + let translation_file = options + .translation_file + .as_ref() + .ok_or_else(|| anyhow::anyhow!("publish-localized 必须指定 --translation-file"))?; + let (resource_root, official_release_id) = current_official_release(options)?; + let workbench = read_translation_workbench(translation_file)?; + if workbench.official_release_id != official_release_id { + return Err(anyhow::anyhow!( + "翻译工作台 release={} 与当前官方 release={} 不一致;请重新导出", + workbench.official_release_id, + official_release_id + )); + } + let expected_root = lexical_absolute(&resource_root).map_err(anyhow::Error::msg)?; + if workbench.official_resource_root != expected_root { + return Err(anyhow::anyhow!( + "翻译工作台资源根目录与当前 release 不一致;请重新导出" + )); + } + let patches = localized_text_asset_patches(&resource_root, &workbench)?; + let localized_release_id = options.localized_release_id.clone().or_else(|| { + options + .config + .force + .then(|| format!("{}-manual-{}", official_release_id, unix_seconds_now())) + }); + let mut config = LocalizedPatchConfig::new( + resource_root, + options.config.localized_output_root.clone(), + official_release_id, + patches, + ) + .with_force(options.config.force); + if let Some(release_id) = localized_release_id { + config = config.with_localized_release_id(release_id); + } + let report = LocalizedPatchService::new().publish(&config)?; + print_report(options.output_format, &report) +} + +fn current_official_release(options: &CliOptions) -> anyhow::Result<(PathBuf, String)> { + let state = read_version_state(&options.config.version_state_path())?; + let resource_root = if let Some(resource_root) = options.resource_root.clone() { + lexical_absolute(&resource_root).map_err(anyhow::Error::msg)? + } else { + state + .as_ref() + .and_then(|state| state.current_completed_version.as_ref()) + .map(|record| record.resource_root.clone()) + .unwrap_or(active_official_resource_root(&options.config.output_root)?) + }; + let release_id = state + .as_ref() + .and_then(|state| state.current_completed_version.as_ref()) + .filter(|_| options.resource_root.is_none()) + .map(|record| record.id.clone()) + .or_else(|| { + resource_root + .file_name() + .and_then(|name| name.to_str()) + .map(str::to_string) + }) + .ok_or_else(|| anyhow::anyhow!("无法从当前官方资源根目录确定 release id"))?; + if read_download_manifest_at(&resource_root) + .map_err(anyhow::Error::msg)? + .is_none() + { + return Err(anyhow::anyhow!( + "当前官方 release 缺少官方下载 manifest:{}", + resource_root.display() + )); + } + Ok((resource_root, release_id)) +} diff --git a/infrastructure/src/lib.rs b/infrastructure/src/lib.rs index 666160f..434fc72 100644 --- a/infrastructure/src/lib.rs +++ b/infrastructure/src/lib.rs @@ -30,6 +30,7 @@ pub mod path_security; pub mod release_flow; pub mod resources; pub mod translation_tasks; +pub mod translation_workflow; mod zip_validation; pub use cas::FileSystemCasRepository; @@ -138,6 +139,12 @@ pub use translation_tasks::{ TRANSLATION_HANDOFF_FILE, TRANSLATION_HANDOFF_SCHEMA_VERSION, TRANSLATION_TASK_REPOSITORY_FILE, TRANSLATION_TASK_SCHEMA_VERSION, }; +pub use translation_workflow::{ + export_translation_workbench, localized_text_asset_patches, read_translation_workbench, + repack_bundle, set_translation, write_translation_workbench, RepackOperation, RepackReport, + RepackSpec, TranslationWorkbench, TranslationWorkbenchEntry, REPACK_SPEC_VERSION, + TRANSLATION_WORKBENCH_VERSION, +}; /// Infrastructure 版本号 pub const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/infrastructure/src/localized_patch.rs b/infrastructure/src/localized_patch.rs index db3bc50..64826fb 100644 --- a/infrastructure/src/localized_patch.rs +++ b/infrastructure/src/localized_patch.rs @@ -42,6 +42,13 @@ pub struct LocalizedPatchConfig { pub localized_output_root: PathBuf, /// Version identifier shared with the official release. pub release_id: String, + /// Optional distinct localized release ID. When omitted, `release_id` is + /// used for backward-compatible publication paths. + pub localized_release_id: Option, + /// Allow publishing a new localized release even when the source release + /// already has a localized current release. The caller should normally + /// provide a distinct localized release ID. + pub force: bool, /// Patch operations to apply. pub patches: Vec, } @@ -58,9 +65,29 @@ impl LocalizedPatchConfig { official_release_root: official_release_root.into(), localized_output_root: localized_output_root.into(), release_id: release_id.into(), + localized_release_id: None, + force: false, patches, } } + + /// Sets a distinct localized release ID. + pub fn with_localized_release_id(mut self, release_id: impl Into) -> Self { + self.localized_release_id = Some(release_id.into()); + self + } + + /// Enables or disables forced publication. + pub fn with_force(mut self, force: bool) -> Self { + self.force = force; + self + } + + fn published_release_id(&self) -> &str { + self.localized_release_id + .as_deref() + .unwrap_or(&self.release_id) + } } /// Persisted localized release state. @@ -187,7 +214,7 @@ impl LocalizedPatchManifest { } /// Result of a successful localized release publication. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct LocalizedPatchReport { /// Published version directory. pub version_path: PathBuf, @@ -217,14 +244,15 @@ impl LocalizedPatchService { /// Copies the official release, applies patches in staging and publishes it. pub fn publish(&self, config: &LocalizedPatchConfig) -> anyhow::Result { + let published_release_id = config.published_release_id().to_string(); let staging = config .localized_output_root .join(LOCALIZED_STAGING_DIR) - .join(&config.release_id); + .join(&published_release_id); let version_path = config .localized_output_root .join(LOCALIZED_VERSIONS_DIR) - .join(&config.release_id); + .join(&published_release_id); let current_path = config.localized_output_root.join(LOCALIZED_CURRENT_LINK); let previous_current_target = current_symlink_target(¤t_path).ok().flatten(); let version_existed_before = version_path.exists(); @@ -257,11 +285,11 @@ impl LocalizedPatchService { let staging = config .localized_output_root .join(LOCALIZED_STAGING_DIR) - .join(&config.release_id); + .join(config.published_release_id()); let version_path = config .localized_output_root .join(LOCALIZED_VERSIONS_DIR) - .join(&config.release_id); + .join(config.published_release_id()); let current_path = config.localized_output_root.join(LOCALIZED_CURRENT_LINK); let state_path = config .localized_output_root @@ -269,10 +297,12 @@ impl LocalizedPatchService { let patch_manifest_path = version_path.join(LOCALIZED_PATCH_MANIFEST_FILE); if version_path.exists() { - return Err(anyhow::anyhow!( - "localized release already exists: {}", - version_path.display() - )); + let message = if config.force { + "localized release target already exists; forced publication requires a distinct localized release id" + } else { + "localized release already exists" + }; + return Err(anyhow::anyhow!("{message}: {}", version_path.display())); } remove_owned_staging(&staging)?; fs::create_dir_all(&staging)?; @@ -313,7 +343,7 @@ impl LocalizedPatchService { let manifest = LocalizedPatchManifest { manifest_version: LOCALIZED_PATCH_MANIFEST_VERSION, official_release_id: config.release_id.clone(), - localized_release_id: config.release_id.clone(), + localized_release_id: config.published_release_id().to_string(), generated_unix_seconds: unix_seconds_now(), file_count: changed_files.len(), text_asset_operation_count: changed_files @@ -339,13 +369,13 @@ impl LocalizedPatchService { switch_current_symlink( &config.localized_output_root, ¤t_path, - &config.release_id, + config.published_release_id(), )?; let state = LocalizedVersionState { state_version: 1, official_release_id: config.release_id.clone(), - current_release_id: Some(config.release_id.clone()), + current_release_id: Some(config.published_release_id().to_string()), status: "localized".to_string(), updated_unix_seconds: unix_seconds_now(), }; @@ -623,13 +653,18 @@ fn validate_config(config: &LocalizedPatchConfig) -> Result<(), String> { localized.display() )); } - if config.release_id.is_empty() - || config.release_id.contains('/') - || config.release_id.contains('\\') - || config.release_id == "." - || config.release_id == ".." - { - return Err(format!("非法汉化 release id:{}", config.release_id)); + for (label, release_id) in [ + ("官方", config.release_id.as_str()), + ("汉化", config.published_release_id()), + ] { + if release_id.is_empty() + || release_id.contains('/') + || release_id.contains('\\') + || release_id == "." + || release_id == ".." + { + return Err(format!("非法{label} release id:{release_id}")); + } } ensure_safe_directory_path(&config.official_release_root, "官方 release")?; ensure_safe_directory_path(&config.localized_output_root, "汉化输出目录")?; diff --git a/infrastructure/src/official_parse.rs b/infrastructure/src/official_parse.rs index 8248292..86bb498 100644 --- a/infrastructure/src/official_parse.rs +++ b/infrastructure/src/official_parse.rs @@ -35,6 +35,8 @@ pub struct OfficialParseConfig { pub resource_root: PathBuf, /// `unzip` executable used to inspect zip archives without extracting them. pub unzip_command: PathBuf, + /// Ignore a matching previous cache and inspect every manifest candidate. + pub force: bool, } impl OfficialParseConfig { @@ -43,9 +45,16 @@ impl OfficialParseConfig { Self { resource_root: resource_root.into(), unzip_command: unzip_command.into(), + force: false, } } + /// Enables or disables forced cache regeneration. + pub fn with_force(mut self, force: bool) -> Self { + self.force = force; + self + } + /// Returns the parse-cache path for this resource root. pub fn cache_path(&self) -> PathBuf { self.resource_root.join(OFFICIAL_PARSE_CACHE_FILE) @@ -357,8 +366,16 @@ impl OfficialParseCacheService { config.resource_root.display() ) })?; - let previous_cache = read_parse_cache_at(&config.resource_root)?; - let previous_textunit_index = read_textunit_index_at(&config.resource_root)?; + let previous_cache = if config.force { + None + } else { + read_parse_cache_at(&config.resource_root)? + }; + let previous_textunit_index = if config.force { + None + } else { + read_textunit_index_at(&config.resource_root)? + }; let mut summary = OfficialParseSummary { manifest_entry_count: manifest.entries.len(), ..OfficialParseSummary::default() diff --git a/infrastructure/src/translation_workflow.rs b/infrastructure/src/translation_workflow.rs new file mode 100644 index 0000000..bafee1d --- /dev/null +++ b/infrastructure/src/translation_workflow.rs @@ -0,0 +1,544 @@ +//! Manual translation workbench and controlled UnityFS repack workflows. + +use crate::official_parse::{read_textunit_index_at, OfficialTextUnitIndexUnit}; +use crate::path_security::{ + ensure_safe_file_target, lexical_absolute, read_file_no_symlink, write_file_atomic, + STATE_FILE_MODE, +}; +use crate::LocalizedTextAssetPatch; +use bat_assetbundle::{ + patch_unityfs_field, patch_unityfs_string_field, patch_unityfs_text_asset, FieldPatch, + StringFieldPatch, TextAssetPatch, UnitySerializedReplacementValue, +}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Current manual translation workbench schema. +pub const TRANSLATION_WORKBENCH_VERSION: u32 = 1; + +/// A manually editable translation file for one official release. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TranslationWorkbench { + /// Workbench schema version. + pub schema_version: u32, + /// Official release consumed by this workbench. + pub official_release_id: String, + /// Official release root used to generate the entries. + pub official_resource_root: PathBuf, + /// Workbench generation time. + pub generated_unix_seconds: u64, + /// TextUnit entries in stable parse-index order. + pub entries: Vec, +} + +/// One manually editable TextUnit translation entry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TranslationWorkbenchEntry { + /// Stable TextUnit ID. + pub id: String, + /// Relative official resource destination. + pub destination: String, + /// Archive entry, when the source is nested in a zip. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub archive_entry: Option, + /// Unity serialized file path. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub serialized_file: Option, + /// Unity object path ID. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path_id: Option, + /// Unity TextAsset name, when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub asset_name: Option, + /// Extracted source text. This is checked again before publishing. + pub source_text: String, + /// Human translation. `null` means not reviewed yet; an empty string is + /// an intentional empty translation. + #[serde(default)] + pub translated_text: Option, + /// TextUnit format. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub format: Option, + /// Extraction source kind such as TextAsset or TypeTreeField. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub text_source_kind: Option, +} + +/// Exports the current official TextUnit index as an editable workbench. +pub fn export_translation_workbench( + resource_root: &Path, + official_release_id: impl Into, + output_path: &Path, +) -> anyhow::Result { + let index = read_textunit_index_at(resource_root) + .map_err(anyhow::Error::msg)? + .ok_or_else(|| { + anyhow::anyhow!( + "缺少官方 TextUnit 索引,无法导出翻译工作台:{}", + resource_root.display() + ) + })?; + let workbench = TranslationWorkbench { + schema_version: TRANSLATION_WORKBENCH_VERSION, + official_release_id: official_release_id.into(), + official_resource_root: lexical_absolute(resource_root).map_err(anyhow::Error::msg)?, + generated_unix_seconds: unix_seconds_now(), + entries: index + .units + .iter() + .map(TranslationWorkbenchEntry::from_index) + .collect(), + }; + write_translation_workbench(output_path, &workbench)?; + Ok(workbench) +} + +/// Reads and validates a manual translation workbench. +pub fn read_translation_workbench(path: &Path) -> anyhow::Result { + let bytes = read_file_no_symlink(path, "翻译工作台") + .map_err(anyhow::Error::msg)? + .ok_or_else(|| anyhow::anyhow!("翻译工作台不存在:{}", path.display()))?; + let workbench: TranslationWorkbench = serde_json::from_slice(&bytes)?; + if workbench.schema_version != TRANSLATION_WORKBENCH_VERSION { + return Err(anyhow::anyhow!( + "不支持的翻译工作台 schema:{},当前版本={}", + workbench.schema_version, + TRANSLATION_WORKBENCH_VERSION + )); + } + Ok(workbench) +} + +/// Writes a translation workbench atomically. +pub fn write_translation_workbench( + path: &Path, + workbench: &TranslationWorkbench, +) -> anyhow::Result<()> { + let path = lexical_absolute(path).map_err(anyhow::Error::msg)?; + let parent = path + .parent() + .ok_or_else(|| anyhow::anyhow!("翻译工作台缺少父目录:{}", path.display()))?; + ensure_safe_file_target(parent, &path, "翻译工作台").map_err(anyhow::Error::msg)?; + let bytes = serde_json::to_vec_pretty(workbench)?; + write_file_atomic(&path, &bytes, STATE_FILE_MODE, "翻译工作台").map_err(anyhow::Error::msg)?; + Ok(()) +} + +/// Updates one translation entry and writes the workbench atomically. +pub fn set_translation( + workbench_path: &Path, + entry_id: &str, + translated_text: String, +) -> anyhow::Result { + let mut workbench = read_translation_workbench(workbench_path)?; + let entry = workbench + .entries + .iter_mut() + .find(|entry| entry.id == entry_id) + .ok_or_else(|| anyhow::anyhow!("翻译工作台中不存在 TextUnit:{entry_id}"))?; + entry.translated_text = Some(translated_text); + let updated = entry.clone(); + workbench.generated_unix_seconds = unix_seconds_now(); + write_translation_workbench(workbench_path, &workbench)?; + Ok(updated) +} + +/// Converts reviewed direct TextAsset entries to localized patch operations. +/// +/// TypeTree fields and zip-inner bundles are intentionally rejected here. +/// They need a different patch representation and must not silently become a +/// TextAsset replacement. +pub fn localized_text_asset_patches( + resource_root: &Path, + workbench: &TranslationWorkbench, +) -> anyhow::Result> { + let index = read_textunit_index_at(resource_root) + .map_err(anyhow::Error::msg)? + .ok_or_else(|| anyhow::anyhow!("缺少当前官方 TextUnit 索引"))?; + let index_by_id = index + .units + .iter() + .map(|unit| (unit.id.as_str(), unit)) + .collect::>(); + let mut seen = BTreeSet::new(); + let mut patches = Vec::new(); + + for entry in &workbench.entries { + let Some(translated_text) = entry.translated_text.as_ref() else { + continue; + }; + let current = index_by_id + .get(entry.id.as_str()) + .ok_or_else(|| anyhow::anyhow!("翻译工作台条目不属于当前 release:{}", entry.id))?; + validate_workbench_entry(entry, current)?; + if translated_text == &entry.source_text { + continue; + } + let Some(serialized_file) = entry.serialized_file.clone() else { + return Err(anyhow::anyhow!( + "TextUnit {} 没有 serialized_file,当前不能生成重打包 patch", + entry.id + )); + }; + let Some(path_id) = entry.path_id else { + return Err(anyhow::anyhow!( + "TextUnit {} 没有 path_id,当前不能生成重打包 patch", + entry.id + )); + }; + if entry.archive_entry.is_some() { + return Err(anyhow::anyhow!( + "TextUnit {} 位于 zip archive entry,当前 publish-localized 不支持直接修改 zip 内 bundle", + entry.id + )); + } + if entry.text_source_kind.as_deref() != Some("text_asset") { + return Err(anyhow::anyhow!( + "TextUnit {} 的来源不是 TextAsset;请使用 repack spec 的 TypeTree 操作", + entry.id + )); + } + if !seen.insert((entry.destination.clone(), serialized_file.clone(), path_id)) { + return Err(anyhow::anyhow!( + "翻译工作台包含重复 patch 目标:{}", + entry.id + )); + } + let mut patch = TextAssetPatch::new( + serialized_file, + path_id, + translated_text.as_bytes().to_vec(), + ); + patch.expected_name = entry.asset_name.clone(); + patches.push(LocalizedTextAssetPatch { + bundle_path: entry.destination.clone(), + text_asset: patch, + }); + } + + if patches.is_empty() { + return Err(anyhow::anyhow!( + "翻译工作台没有可发布的已修改 TextAsset;请先用 translation-set 调整文本" + )); + } + Ok(patches) +} + +/// Batch UnityFS repack specification. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RepackSpec { + /// Repack specification schema. + pub schema_version: u32, + /// Source bundle file. + pub source_bundle: PathBuf, + /// Atomically written target bundle file. + pub target_bundle: PathBuf, + /// Ordered operations applied to the source bytes. + pub operations: Vec, +} + +/// Current batch repack schema. +pub const REPACK_SPEC_VERSION: u32 = 1; + +/// One ordered UnityFS repack operation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum RepackOperation { + /// Replace one TextAsset payload. + TextAsset { + /// Unity serialized file path. + serialized_file: String, + /// Unity object path ID. + path_id: i64, + /// Optional expected TextAsset name. + #[serde(default)] + expected_name: Option, + /// Inline replacement UTF-8 text. + #[serde(default)] + replacement_text: Option, + /// File containing replacement bytes. + #[serde(default)] + replacement_file: Option, + }, + /// Replace one TypeTree string field. + StringField { + /// Unity serialized file path. + serialized_file: String, + /// Unity object path ID. + path_id: i64, + /// TypeTree field path. + field_path: String, + /// Optional expected source string. + #[serde(default)] + expected_value: Option, + /// Inline replacement UTF-8 text. + #[serde(default)] + replacement_text: Option, + /// File containing replacement UTF-8 text. + #[serde(default)] + replacement_file: Option, + }, + /// Replace one supported semantic TypeTree field. + Field { + /// Unity serialized file path. + serialized_file: String, + /// Unity object path ID. + path_id: i64, + /// TypeTree field path. + field_path: String, + /// Replacement semantic value. + replacement: UnitySerializedReplacementValue, + /// Optional expected semantic source value. + #[serde(default)] + expected_value: Option, + }, +} + +/// Result of a batch repack. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RepackReport { + /// Stable command name. + pub command: &'static str, + /// Operation status. + pub status: &'static str, + /// Absolute source bundle path. + pub source_bundle: PathBuf, + /// Absolute target bundle path. + pub target_bundle: PathBuf, + /// Number of operations applied. + pub operation_count: usize, + /// Source BLAKE3. + pub source_blake3: String, + /// Target BLAKE3. + pub target_blake3: String, + /// Source size. + pub source_bytes: u64, + /// Target size. + pub target_bytes: u64, +} + +/// Applies an ordered repack specification and verifies each rebuild through +/// the underlying UnityFS patch implementation. +pub fn repack_bundle(spec_path: &Path) -> anyhow::Result { + let spec_bytes = read_file_no_symlink(spec_path, "UnityFS repack spec") + .map_err(anyhow::Error::msg)? + .ok_or_else(|| anyhow::anyhow!("UnityFS repack spec 不存在:{}", spec_path.display()))?; + let spec: RepackSpec = serde_json::from_slice(&spec_bytes)?; + if spec.schema_version != REPACK_SPEC_VERSION { + return Err(anyhow::anyhow!( + "不支持的 UnityFS repack spec schema:{},当前版本={}", + spec.schema_version, + REPACK_SPEC_VERSION + )); + } + if spec.operations.is_empty() { + return Err(anyhow::anyhow!( + "UnityFS repack spec 至少需要一个 operation" + )); + } + let source_path = lexical_absolute(&spec.source_bundle).map_err(anyhow::Error::msg)?; + let target_path = lexical_absolute(&spec.target_bundle).map_err(anyhow::Error::msg)?; + if source_path == target_path { + return Err(anyhow::anyhow!( + "repack target_bundle 不能与 source_bundle 相同" + )); + } + let source = read_file_no_symlink(&source_path, "UnityFS source bundle") + .map_err(anyhow::Error::msg)? + .ok_or_else(|| { + anyhow::anyhow!("UnityFS source bundle 不存在:{}", source_path.display()) + })?; + let mut current = source.clone(); + for (index, operation) in spec.operations.iter().enumerate() { + current = apply_repack_operation(¤t, operation) + .map_err(|error| anyhow::anyhow!("repack operation {} 失败:{error}", index + 1))?; + } + let parent = target_path + .parent() + .ok_or_else(|| anyhow::anyhow!("repack target_bundle 缺少父目录"))?; + ensure_safe_file_target(parent, &target_path, "UnityFS repack target") + .map_err(anyhow::Error::msg)?; + write_file_atomic( + &target_path, + ¤t, + STATE_FILE_MODE, + "UnityFS repack target", + ) + .map_err(anyhow::Error::msg)?; + Ok(RepackReport { + command: "repack", + status: "repacked", + source_bundle: source_path, + target_bundle: target_path, + operation_count: spec.operations.len(), + source_blake3: blake3::hash(&source).to_hex().to_string(), + target_blake3: blake3::hash(¤t).to_hex().to_string(), + source_bytes: source.len() as u64, + target_bytes: current.len() as u64, + }) +} + +fn apply_repack_operation(input: &[u8], operation: &RepackOperation) -> anyhow::Result> { + match operation { + RepackOperation::TextAsset { + serialized_file, + path_id, + expected_name, + replacement_text, + replacement_file, + } => { + let replacement = read_text_replacement(replacement_text, replacement_file)?; + let mut patch = TextAssetPatch::new(serialized_file, *path_id, replacement); + patch.expected_name = expected_name.clone(); + Ok(patch_unityfs_text_asset(input, &patch)?) + } + RepackOperation::StringField { + serialized_file, + path_id, + field_path, + expected_value, + replacement_text, + replacement_file, + } => { + let replacement = + String::from_utf8(read_text_replacement(replacement_text, replacement_file)?)?; + Ok(patch_unityfs_string_field( + input, + &StringFieldPatch { + serialized_file_path: serialized_file.clone(), + path_id: *path_id, + field_path: field_path.clone(), + expected_value: expected_value.clone(), + replacement, + }, + )?) + } + RepackOperation::Field { + serialized_file, + path_id, + field_path, + replacement, + expected_value, + } => Ok(patch_unityfs_field( + input, + &FieldPatch { + serialized_file_path: serialized_file.clone(), + path_id: *path_id, + field_path: field_path.clone(), + expected_value: expected_value.clone(), + replacement: replacement.clone(), + }, + )?), + } +} + +fn read_text_replacement( + replacement_text: &Option, + replacement_file: &Option, +) -> anyhow::Result> { + match (replacement_text, replacement_file) { + (Some(_), Some(_)) => Err(anyhow::anyhow!( + "replacement_text 与 replacement_file 只能指定一个" + )), + (Some(text), None) => Ok(text.as_bytes().to_vec()), + (None, Some(path)) => read_file_no_symlink(path, "repack replacement file") + .map_err(anyhow::Error::msg)? + .ok_or_else(|| anyhow::anyhow!("repack replacement file 不存在:{}", path.display())), + (None, None) => Err(anyhow::anyhow!( + "必须指定 replacement_text 或 replacement_file" + )), + } +} + +fn validate_workbench_entry( + entry: &TranslationWorkbenchEntry, + current: &OfficialTextUnitIndexUnit, +) -> anyhow::Result<()> { + if entry.source_text != current.source_text + || entry.destination != current.destination + || entry.archive_entry != current.archive_entry + || entry.serialized_file != current.serialized_file + || entry.path_id != current.path_id + || entry.asset_name != current.asset_name + || entry.format != current.format + || entry.text_source_kind != current.text_source_kind + { + return Err(anyhow::anyhow!( + "翻译工作台条目 {} 与当前 TextUnit 索引不一致,请重新 translation-export", + entry.id + )); + } + Ok(()) +} + +impl TranslationWorkbenchEntry { + fn from_index(unit: &OfficialTextUnitIndexUnit) -> Self { + Self { + id: unit.id.clone(), + destination: unit.destination.clone(), + archive_entry: unit.archive_entry.clone(), + serialized_file: unit.serialized_file.clone(), + path_id: unit.path_id, + asset_name: unit.asset_name.clone(), + source_text: unit.source_text.clone(), + translated_text: None, + format: unit.format.clone(), + text_source_kind: unit.text_source_kind.clone(), + } + } +} + +fn unix_seconds_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn workbench(path: &Path) -> TranslationWorkbench { + TranslationWorkbench { + schema_version: TRANSLATION_WORKBENCH_VERSION, + official_release_id: "release-1".to_string(), + official_resource_root: path.to_path_buf(), + generated_unix_seconds: 1, + entries: vec![TranslationWorkbenchEntry { + id: "unit-1".to_string(), + destination: "bundles/test.bundle".to_string(), + archive_entry: None, + serialized_file: Some("CAB-test".to_string()), + path_id: Some(7), + asset_name: Some("Story".to_string()), + source_text: "原文".to_string(), + translated_text: None, + format: Some("plain".to_string()), + text_source_kind: Some("text_asset".to_string()), + }], + } + } + + #[test] + fn translation_set_round_trips_atomically() { + let temp = tempfile::TempDir::new().unwrap(); + let path = temp.path().join("workbench.json"); + write_translation_workbench(&path, &workbench(temp.path())).unwrap(); + let updated = set_translation(&path, "unit-1", "译文".to_string()).unwrap(); + assert_eq!(updated.translated_text.as_deref(), Some("译文")); + let loaded = read_translation_workbench(&path).unwrap(); + assert_eq!(loaded.entries[0].translated_text.as_deref(), Some("译文")); + } + + #[test] + fn translation_set_rejects_unknown_unit() { + let temp = tempfile::TempDir::new().unwrap(); + let path = temp.path().join("workbench.json"); + write_translation_workbench(&path, &workbench(temp.path())).unwrap(); + let error = set_translation(&path, "missing", "译文".to_string()).unwrap_err(); + assert!(error.to_string().contains("不存在 TextUnit")); + } +} diff --git a/infrastructure/tests/official_game_main_config_bootstrap.rs b/infrastructure/tests/official_game_main_config_bootstrap.rs index 51e2b96..7a9146c 100644 --- a/infrastructure/tests/official_game_main_config_bootstrap.rs +++ b/infrastructure/tests/official_game_main_config_bootstrap.rs @@ -379,7 +379,10 @@ fn official_update_reuses_failed_staging_after_interrupted_download() { }), ); - let config = harness.sync_config("failed-staging-output"); + // 该 fixture 使用一个共享失败计数文件模拟单个资源的中断; + // 设为单 worker,避免其它并行资源消耗这个测试专用的失败次数。 + let mut config = harness.sync_config("failed-staging-output"); + config.download_concurrency = 1; let first_error = OfficialUpdateService::new().run(&config).unwrap_err(); assert!(first_error.to_string().contains("quarantine")); assert!(first_error.to_string().contains("simulated failure")); diff --git a/internal/api/admin.go b/internal/api/admin.go index 9aee99c..227dd10 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -12,6 +12,7 @@ import ( ) const adminControlMaxBodyBytes = 1024 +const adminScheduleMaxBodyBytes = 64 * 1024 type adminControlRequest struct { Force bool `json:"force"` @@ -33,6 +34,7 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) { "/v1/release", "/v1/resources", "/openapi.yaml", + "/admin/schedules", }, Controls: []string{ "/admin/control/reload", @@ -42,6 +44,10 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) { "/admin/control/verify", "/admin/control/repair", "/admin/control/catalog-refresh", + "/admin/control/schedule-add", + "/admin/control/schedule-update", + "/admin/control/schedule-remove", + "/admin/control/schedule-run", }, } if r.Method == http.MethodHead { @@ -65,6 +71,10 @@ func (s *Server) handleAdminControl(w http.ResponseWriter, r *http.Request) { writeErrorJSON(w, http.StatusNotFound, "control_not_found", "unknown control action") return } + if strings.HasPrefix(action, "schedule-") { + s.handleAdminScheduleControl(w, r, action) + return + } request, ok := decodeAdminControlRequest(w, r) if !ok { return @@ -138,6 +148,85 @@ func (s *Server) handleAdminControl(w http.ResponseWriter, r *http.Request) { }) } +func (s *Server) handleAdminSchedules(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + if !s.requireAdminToken(w, r) { + return + } + backend, ok := s.backend.(ScheduleBackend) + if !ok || backend == nil { + writeErrorJSON(w, http.StatusServiceUnavailable, "schedule_backend_unavailable", "Rust bat schedule backend is unavailable") + return + } + result, err := backend.ScheduleList(r.Context()) + if err != nil { + s.writeControlBackendError(w, "schedule-list", err) + return + } + if r.Method == http.MethodHead { + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + return + } + writeNoStoreJSON(w, http.StatusOK, result) +} + +func (s *Server) handleAdminScheduleControl(w http.ResponseWriter, r *http.Request, action string) { + backend, ok := s.backend.(ScheduleBackend) + if !ok || backend == nil { + writeErrorJSON(w, http.StatusServiceUnavailable, "schedule_backend_unavailable", "Rust bat schedule backend is unavailable") + return + } + + var ( + method string + result json.RawMessage + err error + ) + switch action { + case "schedule-add", "schedule-update", "schedule-remove": + var params backendrpc.ScheduleMutationParams + if !decodeAdminScheduleJSON(w, r, ¶ms) { + return + } + switch action { + case "schedule-add": + method = "schedule.add" + result, err = backend.ScheduleAdd(r.Context(), params) + case "schedule-update": + method = "schedule.update" + result, err = backend.ScheduleUpdate(r.Context(), params) + default: + method = "schedule.remove" + result, err = backend.ScheduleRemove(r.Context(), params) + } + case "schedule-run": + var params backendrpc.ScheduleRunParams + if !decodeAdminScheduleJSON(w, r, ¶ms) { + return + } + method = "schedule.run" + result, err = backend.ScheduleRun(r.Context(), params) + default: + writeErrorJSON(w, http.StatusNotFound, "control_not_found", "unknown control action") + return + } + if err != nil { + s.writeControlBackendError(w, action, err) + return + } + writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{ + Service: "bat-api", + Action: action, + RPCMethod: method, + Status: "accepted", + Result: result, + }) +} + func (s *Server) requireAdminToken(w http.ResponseWriter, r *http.Request) bool { if s.cfg.AuthToken == "" { writeErrorJSON(w, http.StatusForbidden, "admin_auth_required", "admin controls require BAT_API_AUTH_TOKEN") @@ -151,6 +240,26 @@ func (s *Server) requireAdminToken(w http.ResponseWriter, r *http.Request) bool return true } +func decodeAdminScheduleJSON(w http.ResponseWriter, r *http.Request, target any) bool { + if r.Body == nil { + return true + } + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, adminScheduleMaxBodyBytes)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + if errors.Is(err, io.EOF) { + return true + } + writeErrorJSON(w, http.StatusBadRequest, "invalid_schedule_params", "schedule request must be a JSON object") + return false + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + writeErrorJSON(w, http.StatusBadRequest, "invalid_schedule_params", "schedule request must contain exactly one JSON object") + return false + } + return true +} + func decodeAdminControlRequest(w http.ResponseWriter, r *http.Request) (adminControlRequest, bool) { var request adminControlRequest if r.Body == nil { diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 99950a8..2bacfda 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -588,6 +588,96 @@ func (b *controlBackend) CatalogRefresh(ctx context.Context, force bool) (*backe return &backendrpc.TaskAccepted{TaskID: "task-catalog-refresh-1", Kind: "catalog.refresh"}, nil } +type scheduleBackend struct { + *controlBackend + scheduleCalls []string + scheduleRaw json.RawMessage +} + +func (b *scheduleBackend) ScheduleList(ctx context.Context) (json.RawMessage, error) { + b.scheduleCalls = append(b.scheduleCalls, "schedule.list") + return b.scheduleRaw, nil +} + +func (b *scheduleBackend) ScheduleAdd(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error) { + b.scheduleCalls = append(b.scheduleCalls, "schedule.add") + return b.scheduleRaw, nil +} + +func (b *scheduleBackend) ScheduleUpdate(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error) { + b.scheduleCalls = append(b.scheduleCalls, "schedule.update") + return b.scheduleRaw, nil +} + +func (b *scheduleBackend) ScheduleRemove(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error) { + b.scheduleCalls = append(b.scheduleCalls, "schedule.remove") + return b.scheduleRaw, nil +} + +func (b *scheduleBackend) ScheduleRun(ctx context.Context, params backendrpc.ScheduleRunParams) (json.RawMessage, error) { + b.scheduleCalls = append(b.scheduleCalls, "schedule.run") + return b.scheduleRaw, nil +} + +func TestAdminScheduleEndpointsProxyAuthenticatedRequests(t *testing.T) { + cfg := DefaultConfig() + cfg.AuthToken = "schedule-token" + if err := cfg.Normalize(); err != nil { + t.Fatal(err) + } + backend := &scheduleBackend{ + controlBackend: &controlBackend{fakeBackend: &fakeBackend{}}, + scheduleRaw: json.RawMessage(`{"command":"schedule-list","status":"ok","schedules":[]}`), + } + s := NewServer(cfg, backend, nil) + + request := httptest.NewRequest(http.MethodGet, "/admin/schedules", nil) + request.Header.Set("Authorization", "Bearer schedule-token") + recorder := httptest.NewRecorder() + s.Handler().ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("list status=%d body=%s", recorder.Code, recorder.Body.String()) + } + if !json.Valid(recorder.Body.Bytes()) { + t.Fatalf("list body is not JSON: %s", recorder.Body.String()) + } + + request = httptest.NewRequest( + http.MethodPost, + "/admin/control/schedule-update", + strings.NewReader(`{"id":"nightly-pull","every_seconds":3600,"clear_every":false}`), + ) + request.Header.Set("Authorization", "Bearer schedule-token") + recorder = httptest.NewRecorder() + s.Handler().ServeHTTP(recorder, request) + if recorder.Code != http.StatusAccepted { + t.Fatalf("update status=%d body=%s", recorder.Code, recorder.Body.String()) + } + if len(backend.scheduleCalls) != 2 || + backend.scheduleCalls[0] != "schedule.list" || + backend.scheduleCalls[1] != "schedule.update" { + t.Fatalf("schedule calls=%v", backend.scheduleCalls) + } + + request = httptest.NewRequest(http.MethodPost, "/admin/control/schedule-run", nil) + request.Header.Set("Authorization", "Bearer schedule-token") + recorder = httptest.NewRecorder() + s.Handler().ServeHTTP(recorder, request) + if recorder.Code != http.StatusAccepted { + t.Fatalf("run status=%d body=%s", recorder.Code, recorder.Body.String()) + } + if len(backend.scheduleCalls) != 3 || backend.scheduleCalls[2] != "schedule.run" { + t.Fatalf("schedule calls=%v", backend.scheduleCalls) + } + + request = httptest.NewRequest(http.MethodGet, "/admin/schedules", nil) + recorder = httptest.NewRecorder() + s.Handler().ServeHTTP(recorder, request) + if recorder.Code != http.StatusUnauthorized { + t.Fatalf("unauthenticated list status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + func TestDiscoverCallsStatusBeforeDoctor(t *testing.T) { root := fixtureRoot(t) bytes := uint64(20) diff --git a/internal/api/openapi.go b/internal/api/openapi.go index 69d6282..90a8f5f 100644 --- a/internal/api/openapi.go +++ b/internal/api/openapi.go @@ -115,16 +115,26 @@ paths: responses: "200": description: Admin links and allowlisted control actions. + /admin/schedules: + get: + summary: List Rust-owned resource workflow schedules + responses: + "200": + description: Current schedule JSON report. + "401": + description: Missing or invalid admin token. + "503": + description: Rust bat schedule backend is unavailable. /admin/control/{action}: post: - summary: Forward an allowlisted control action to Rust bat + summary: Forward an allowlisted control or schedule action to Rust bat parameters: - name: action in: path required: true schema: type: string - enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh] + enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run] requestBody: required: false content: @@ -135,6 +145,34 @@ paths: properties: force: type: boolean + id: + type: string + group: + type: string + action: + type: string + args: + type: array + items: + type: string + next_run_unix_seconds: + type: integer + format: int64 + delay_seconds: + type: integer + format: int64 + every_seconds: + type: integer + format: int64 + count: + type: integer + format: int64 + clear_args: + type: boolean + clear_every: + type: boolean + enabled: + type: boolean responses: "202": description: Rust bat accepted the control request. diff --git a/internal/api/rpc_release.go b/internal/api/rpc_release.go index 3db2511..bb77b41 100644 --- a/internal/api/rpc_release.go +++ b/internal/api/rpc_release.go @@ -38,6 +38,17 @@ type ControlBackend interface { CatalogRefresh(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error) } +// ScheduleBackend exposes the Rust-owned schedule store to an authenticated +// dashboard. The JSON result remains Rust's report shape so the API does not +// duplicate schedule state or invent a second schema. +type ScheduleBackend interface { + ScheduleList(ctx context.Context) (json.RawMessage, error) + ScheduleAdd(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error) + ScheduleUpdate(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error) + ScheduleRemove(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error) + ScheduleRun(ctx context.Context, params backendrpc.ScheduleRunParams) (json.RawMessage, error) +} + // RPCClient adapts *backendrpc.Client to Backend. type RPCClient struct { Client *backendrpc.Client @@ -79,6 +90,21 @@ func (r RPCClient) ResourceRepair(ctx context.Context) (*backendrpc.TaskAccepted func (r RPCClient) CatalogRefresh(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error) { return r.Client.CatalogRefresh(ctx, force) } +func (r RPCClient) ScheduleList(ctx context.Context) (json.RawMessage, error) { + return r.Client.ScheduleList(ctx) +} +func (r RPCClient) ScheduleAdd(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error) { + return r.Client.ScheduleAdd(ctx, params) +} +func (r RPCClient) ScheduleUpdate(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error) { + return r.Client.ScheduleUpdate(ctx, params) +} +func (r RPCClient) ScheduleRemove(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error) { + return r.Client.ScheduleRemove(ctx, params) +} +func (r RPCClient) ScheduleRun(ctx context.Context, params backendrpc.ScheduleRunParams) (json.RawMessage, error) { + return r.Client.ScheduleRun(ctx, params) +} func (r RPCClient) ParseStatus(ctx context.Context) (json.RawMessage, error) { return r.Client.ParseStatus(ctx) } diff --git a/internal/api/server.go b/internal/api/server.go index 211b1dd..65aebf6 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -61,6 +61,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc(launcherHostPath("/api/launcher/advanced/game/download/cdn"), s.handleLauncherCdnConfig) mux.HandleFunc(launcherHostPath("/api/launcher/resource/bootstrap.json"), s.handleLauncherBootstrap) mux.HandleFunc("/openapi.yaml", s.handleOpenAPI) + mux.HandleFunc("/admin/schedules", s.handleAdminSchedules) mux.HandleFunc("/admin/control/", s.handleAdminControl) mux.HandleFunc("/admin/", s.handleAdminIndex) mux.HandleFunc("/"+ServerInfoHost+"/", s.handleServerInfoCDN) @@ -125,6 +126,7 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { "/" + ServerInfoHost + "/...", "/openapi.yaml", "/admin/", + "/admin/schedules", "/admin/control/{action}", }, }) diff --git a/internal/backendrpc/client.go b/internal/backendrpc/client.go index aa682c9..1358112 100644 --- a/internal/backendrpc/client.go +++ b/internal/backendrpc/client.go @@ -232,6 +232,29 @@ type UnityFSFieldPatchParams struct { ExpectedValue json.RawMessage `json:"expected_value,omitempty"` } +// ScheduleMutationParams is shared by schedule.add, schedule.update, and +// schedule.remove. Omitted pointer fields preserve the existing schedule on +// update; clear_* fields explicitly remove values. +type ScheduleMutationParams struct { + ID string `json:"id,omitempty"` + Group string `json:"group,omitempty"` + Action string `json:"action,omitempty"` + Args []string `json:"args,omitempty"` + NextRunUnixSeconds *uint64 `json:"next_run_unix_seconds,omitempty"` + DelaySeconds *uint64 `json:"delay_seconds,omitempty"` + EverySeconds *uint64 `json:"every_seconds,omitempty"` + Count *uint64 `json:"count,omitempty"` + ClearArgs bool `json:"clear_args,omitempty"` + ClearEvery bool `json:"clear_every,omitempty"` + Enabled *bool `json:"enabled,omitempty"` +} + +// ScheduleRunParams selects a schedule or runs every due enabled schedule. +type ScheduleRunParams struct { + ID string `json:"id,omitempty"` + Force bool `json:"force,omitempty"` +} + // Ack is returned by accepted daemon control methods. type Ack struct { Command string `json:"command"` @@ -427,6 +450,26 @@ func (c *Client) ResourceList(ctx context.Context, offset int, limit int) (*Reso return &out, err } +func (c *Client) ScheduleList(ctx context.Context) (json.RawMessage, error) { + return c.rawData(ctx, "schedule.list", nil) +} + +func (c *Client) ScheduleAdd(ctx context.Context, params ScheduleMutationParams) (json.RawMessage, error) { + return c.rawData(ctx, "schedule.add", params) +} + +func (c *Client) ScheduleUpdate(ctx context.Context, params ScheduleMutationParams) (json.RawMessage, error) { + return c.rawData(ctx, "schedule.update", params) +} + +func (c *Client) ScheduleRemove(ctx context.Context, params ScheduleMutationParams) (json.RawMessage, error) { + return c.rawData(ctx, "schedule.remove", params) +} + +func (c *Client) ScheduleRun(ctx context.Context, params ScheduleRunParams) (json.RawMessage, error) { + return c.rawData(ctx, "schedule.run", params) +} + func (c *Client) CatalogStatus(ctx context.Context) (json.RawMessage, error) { return c.rawData(ctx, "catalog.status", nil) } diff --git a/internal/backendrpc/client_test.go b/internal/backendrpc/client_test.go index 7a5d078..27ed6eb 100644 --- a/internal/backendrpc/client_test.go +++ b/internal/backendrpc/client_test.go @@ -252,6 +252,52 @@ func TestResourceListSendsPagination(t *testing.T) { } } +func TestScheduleUpdateSendsMutationParams(t *testing.T) { + every := uint64(3600) + count := uint64(4) + enabled := true + client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { + if req.Method != "schedule.update" { + t.Fatalf("method = %s", req.Method) + } + var params ScheduleMutationParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + t.Fatalf("decode params: %v", err) + } + if params.ID != "nightly-pull" || params.Group != "res" || params.Action != "pull" || + params.EverySeconds == nil || *params.EverySeconds != every || + params.Count == nil || *params.Count != count || params.Enabled == nil || !*params.Enabled { + t.Fatalf("params = %#v", params) + } + return testResponse{ + Result: testEnvelope{ + OK: true, + Status: "ok", + RequestID: "req-test-schedule", + Data: map[string]any{ + "command": "schedule-update", + "status": "updated", + }, + }, + } + }) + + raw, err := client.ScheduleUpdate(context.Background(), ScheduleMutationParams{ + ID: "nightly-pull", + Group: "res", + Action: "pull", + EverySeconds: &every, + Count: &count, + Enabled: &enabled, + }) + if err != nil { + t.Fatalf("ScheduleUpdate error: %v", err) + } + if !json.Valid(raw) { + t.Fatalf("invalid raw JSON: %s", string(raw)) + } +} + func TestApplicationErrorReturnsAPIError(t *testing.T) { client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { if req.Method != "task.status" { diff --git a/web/admin/README.md b/web/admin/README.md index f686287..87a0fb7 100644 --- a/web/admin/README.md +++ b/web/admin/README.md @@ -3,8 +3,9 @@ This directory is reserved for the future player-facing `bat-api` management panel. The current backend exposes a non-mutating index at `GET /admin/` that returns JSON links for health, readiness, bootstrap, release, resources, and -OpenAPI. Mutating `/admin/control/{action}` routes are protected by the -configured admin token and forward only the documented Rust `bat` allowlist. +OpenAPI. `GET /admin/schedules` and mutating +`/admin/control/schedule-{add,update,remove,run}` routes are protected by the +configured admin token and forward the Rust-owned schedule contract. The production panel must reuse the same HTTP authentication, rate limiting, access logging, reverse-proxy handling, and no-store dynamic response policy as