mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 07:24:55 +08:00
feat(bat): 完善工作流调度与 dashboard RPC
补全资源拉取、解析、翻译、重打包和本地化发布命令,支持单次、限定次数与周期调度。移除 TUI 计划并通过 schedule.* RPC 暴露给 bat-api dashboard。 Closes #43
This commit is contained in:
+4
-3
@@ -30,7 +30,7 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
|
|||||||
13. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,官方同步可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后触发导入,默认 CAS 路径为 `<output>/.cas`、SQLite 索引为 `<output>/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 或单测覆盖。
|
13. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,官方同步可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后触发导入,默认 CAS 路径为 `<output>/.cas`、SQLite 索引为 `<output>/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 任务历史持久化在 `<state-dir>/bat-tasks.json`(版本化、`0600` 原子写),重启后任务经 `task.*` 仍可查,中断任务标记 `task_interrupted`(`BAT-ERR-700005`)。
|
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 任务历史持久化在 `<state-dir>/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.sqlite` 和版本化 `translation-handoff.json`;
|
||||||
`translation.tasks` 查询单项 worker 状态,`translation.handoff` 查询完整
|
`translation.tasks` 查询单项 worker 状态,`translation.handoff` 查询完整
|
||||||
@@ -46,6 +46,7 @@ job/unit/provider run 状态。当前下载实现使用默认 8 个独立 worker
|
|||||||
- `DOCS_INDEX.md`:文档阅读顺序和索引。
|
- `DOCS_INDEX.md`:文档阅读顺序和索引。
|
||||||
- `docs/guides/official-resource-test-pull.md`:官方资源拉取与自动更新用户指南。
|
- `docs/guides/official-resource-test-pull.md`:官方资源拉取与自动更新用户指南。
|
||||||
- `docs/guides/official-full-pull-smoke.md`:真实官方全量拉取 smoke runbook 和可重复命令。
|
- `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/official-resource-backend.md`:官方资源后端设计和审核说明。
|
||||||
- `docs/architecture/assetbundle.md`:解析补全路线图,覆盖 Addressables、UnityFS、Serialized 字段级解析、文本提取、CAS 接入和 Patch 发布前置。
|
- `docs/architecture/assetbundle.md`:解析补全路线图,覆盖 Addressables、UnityFS、Serialized 字段级解析、文本提取、CAS 接入和 Patch 发布前置。
|
||||||
- `docs/reports/CURRENT_GAPS.md`:当前缺口和关闭顺序。
|
- `docs/reports/CURRENT_GAPS.md`:当前缺口和关闭顺序。
|
||||||
@@ -270,7 +271,7 @@ cargo run -p bat-infrastructure --bin bat -- \
|
|||||||
--watch
|
--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 循环互斥;任务历史持久化于 `<state-dir>/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 循环互斥;任务历史持久化于 `<state-dir>/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 增量缓存和可选持久化。
|
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 构建。
|
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。
|
4. Issue #3(P1):AssetBundle UnityFS 基础解析校验已具备离线和隔离真实样本覆盖;对象级解析继续跟踪 G-005。
|
||||||
|
|||||||
+10
-8
@@ -14,6 +14,7 @@
|
|||||||
- `docs/reports/GO_STATUS.md`:Go 侧边界、约定与组件进度(权威)。
|
- `docs/reports/GO_STATUS.md`:Go 侧边界、约定与组件进度(权威)。
|
||||||
- `docs/guides/official-resource-test-pull.md`:官方资源拉取与自动更新用户指南。
|
- `docs/guides/official-resource-test-pull.md`:官方资源拉取与自动更新用户指南。
|
||||||
- `docs/guides/official-full-pull-smoke.md`:真实官方全量拉取 smoke runbook 和可重复命令。
|
- `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/official-resource-backend.md`:官方资源后端职责、工作原理和审核说明。
|
||||||
- `docs/architecture/resource-release-layout.md`:release 布局、URL 映射、seed 规则、bat-api 分发契约(资源侧逆向权威)。
|
- `docs/architecture/resource-release-layout.md`:release 布局、URL 映射、seed 规则、bat-api 分发契约(资源侧逆向权威)。
|
||||||
- `docs/architecture/assetbundle.md`:AssetBundle、Addressables、Serialized File、文本提取和 Patch 前置解析路线图。
|
- `docs/architecture/assetbundle.md`:AssetBundle、Addressables、Serialized File、文本提取和 Patch 前置解析路线图。
|
||||||
@@ -82,14 +83,15 @@
|
|||||||
2. `PROJECT_PLAN.md`
|
2. `PROJECT_PLAN.md`
|
||||||
3. `docs/guides/official-resource-test-pull.md`
|
3. `docs/guides/official-resource-test-pull.md`
|
||||||
4. `docs/guides/official-full-pull-smoke.md`
|
4. `docs/guides/official-full-pull-smoke.md`
|
||||||
5. `docs/architecture/official-resource-backend.md`
|
5. `docs/guides/bat-workflows.md`
|
||||||
6. `docs/reference/rpc-backend-api.md`
|
6. `docs/architecture/official-resource-backend.md`
|
||||||
7. `docs/reports/CURRENT_GAPS.md`
|
7. `docs/reference/rpc-backend-api.md`
|
||||||
8. `docs/guides/baseline.md`
|
8. `docs/reports/CURRENT_GAPS.md`
|
||||||
9. `docs/architecture/README.md`
|
9. `docs/guides/baseline.md`
|
||||||
10. `docs/guides/development.md`
|
10. `docs/architecture/README.md`
|
||||||
11. `CONTRIBUTING.md`
|
11. `docs/guides/development.md`
|
||||||
12. `AGENTS.md`
|
12. `CONTRIBUTING.md`
|
||||||
|
13. `AGENTS.md`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -36,7 +36,8 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
|||||||
8. `bat-ffi` 已提供 Manifest inspect 和官方 sync plan 的可选无状态粗粒度 JSON C ABI helper。
|
8. `bat-ffi` 已提供 Manifest inspect 和官方 sync plan 的可选无状态粗粒度 JSON C ABI helper。
|
||||||
9. 官方原版资源默认发布到 `./bat-resources`,汉化产物默认发布到独立的 `./bat-localized`;当前官方同步报告会标记 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布。
|
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 明细索引和队列时只读取摘要,不重复解析。
|
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` 已合并。
|
||||||
|
|
||||||
### 仍是骨架或占位
|
### 仍是骨架或占位
|
||||||
|
|
||||||
|
|||||||
+19
-1
@@ -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 请求其刷新 |
|
| `refresh` | 执行一次更新检查;若有 live daemon,则通过 RPC 请求其刷新 |
|
||||||
| `verify` | 校验远端计划、本地 manifest 和官方 seed hash(dry-run + 审计当前 release) |
|
| `verify` | 校验远端计划、本地 manifest 和官方 seed hash(dry-run + 审计当前 release) |
|
||||||
| `repair` | 重新下载本地校验失败的资源 |
|
| `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/状态文件兼容路径。
|
`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 资源 bootstrap / 分发服务
|
||||||
|
|
||||||
`bat-api` 是 Go 侧正式服务入口,用于给客户端、补丁器或上层工具提供启动前资源入口和 CDN 形态只读分发。它不负责自动发现、下载、校验或发布资源;这些长期状态由 Rust `bat` / daemon 持有。
|
`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/HEAD /prod-clientpatch.bluearchiveyostar.com/...` | 官方 CDN path 形态资源字节 |
|
||||||
| `GET /openapi.yaml` | bat-api OpenAPI 文档 |
|
| `GET /openapi.yaml` | bat-api OpenAPI 文档 |
|
||||||
| `GET /admin/` | 管理控制入口与允许操作列表 |
|
| `GET /admin/` | 管理控制入口与允许操作列表 |
|
||||||
|
| `GET /admin/schedules` | 读取 Rust `bat` 调度计划;需要管理 token |
|
||||||
| `POST /admin/control/{action}` | 经白名单转发 Rust `bat` 控制请求;见下文 |
|
| `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,也不仿造登录、账号、网关、鉴权或游戏业务协议。
|
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 鉴权、限流和访问日志:
|
生产面对玩家分发时,应启用 HTTP token 鉴权、限流和访问日志:
|
||||||
|
|
||||||
- `BAT_API_AUTH_TOKEN`:启用 `Authorization: Bearer <token>`、`X-BAT-Token` 或 query fallback 鉴权;token 推荐由 secret manager 或进程环境提供,不建议写入提交文件。`/admin/control/*` 需要此 token。
|
- `BAT_API_AUTH_TOKEN`:启用 `Authorization: Bearer <token>`、`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_QUERY_PARAM`:query fallback 参数名,默认 `bat_token`;兼容不能写 header 的客户端,访问日志不会记录 query。
|
||||||
- `BAT_API_AUTH_EXEMPT_PATHS`:逗号分隔的免鉴权 path 或 slash-prefix,例如 `/healthz,/readyz`。
|
- `BAT_API_AUTH_EXEMPT_PATHS`:逗号分隔的免鉴权 path 或 slash-prefix,例如 `/healthz,/readyz`。
|
||||||
- `BAT_API_RATE_LIMIT_RPS` / `BAT_API_RATE_LIMIT_BURST`:按客户端 IP 的进程内 token bucket 限流;边缘反代/CDN 仍应配置独立限流。
|
- `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 |
|
| `verify` | `resource.verify` | 无 | `202` + task |
|
||||||
| `repair` | `resource.repair` | 无 | `202` + task |
|
| `repair` | `resource.repair` | 无 | `202` + task |
|
||||||
| `catalog-refresh` | `catalog.refresh` | 可选 `{ "force": true }` | `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 暴露。
|
`stop`、`clean-stable`、patch 和 UnityFS 写入命令不会经 HTTP 暴露。
|
||||||
|
|
||||||
|
|||||||
@@ -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/<release-id> \
|
||||||
|
--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 <text-unit-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` 不覆盖已有目录,而是生成独立的 `<official-release>-manual-<unix-seconds>` 汉化 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 的解析类型覆盖。
|
||||||
@@ -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 承担。
|
`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
|
```bash
|
||||||
|
|||||||
@@ -159,6 +159,40 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且
|
|||||||
|
|
||||||
删除资源只进入 `official-resource-changes.json`,不进入 Crowdin handoff。
|
删除资源只进入 `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
|
### parse
|
||||||
|
|
||||||
| 方法 | 状态 | params | data |
|
| 方法 | 状态 | params | data |
|
||||||
@@ -376,7 +410,8 @@ CLI 对应关系:
|
|||||||
- `Call` 可发送本文档中的任意已记录方法,并负责 JSON-RPC transport、
|
- `Call` 可发送本文档中的任意已记录方法,并负责 JSON-RPC transport、
|
||||||
envelope 和 `ApiError` 解码;它不是 bat-api 的 HTTP 任意 RPC proxy。
|
envelope 和 `ApiError` 解码;它不是 bat-api 的 HTTP 任意 RPC proxy。
|
||||||
- typed helper 已覆盖 daemon 已实现方法(`status/logs/stop/restart/reload/refresh/doctor`)、
|
- 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.*` 和三个
|
`localized.status`、`task.*` 和三个
|
||||||
`unityfs.patch_*` 方法。
|
`unityfs.patch_*` 方法。
|
||||||
- `resource.index`、`translation.tasks`、`translation.handoff`、
|
- `resource.index`、`translation.tasks`、`translation.handoff`、
|
||||||
@@ -389,6 +424,7 @@ CLI 对应关系:
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `Backend` | `daemon.status`、`daemon.doctor`、`resource.state`、`catalog.status`、`resource.manifest` | 启动发现、周期刷新和资源分发 |
|
| `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` | 鉴权后的管理控制白名单 |
|
| `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 管理控制面。
|
`daemon.stop`、`daemon.clean-stable` 和任意通用 RPC 不属于 bat-api 管理控制面。
|
||||||
Rust dispatch、Go transport 和 bat-api 接口的权威实现位置分别是
|
Rust dispatch、Go transport 和 bat-api 接口的权威实现位置分别是
|
||||||
|
|||||||
@@ -123,6 +123,8 @@
|
|||||||
- `ResourceImportService` 的 UnityFS 摘要已经能暴露解包文件数、serialized file 数、TextAsset 名称、TextUnit 数量/格式和字段诊断。
|
- `ResourceImportService` 的 UnityFS 摘要已经能暴露解包文件数、serialized file 数、TextAsset 名称、TextUnit 数量/格式和字段诊断。
|
||||||
- `MonoBehaviour`、`ScriptableObject` 已有基础 TypeTree 字段级反序列化和字符串提取入口;array/vector/staticvector/`List<T>`/`HashSet<T>`/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 字段结构语义和发布级重打包入口仍未完成。
|
- `MonoBehaviour`、`ScriptableObject` 已有基础 TypeTree 字段级反序列化和字符串提取入口;array/vector/staticvector/`List<T>`/`HashSet<T>`/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。
|
- 可以对 UnityFS 容器做结构校验、解包 directory 文件,并提取 serialized file 中的 TextAsset 原始 bytes。
|
||||||
@@ -165,7 +167,7 @@
|
|||||||
|
|
||||||
- 通用 Binary/JSON/Text Patch crate 能力可作为后续发布流程输入。
|
- 通用 Binary/JSON/Text Patch crate 能力可作为后续发布流程输入。
|
||||||
- 文件级写入入口可用于隔离测试和上层工具显式产物生成。
|
- 文件级写入入口可用于隔离测试和上层工具显式产物生成。
|
||||||
- 发布级 `patch build` / `patch rollback`、复杂 AssetBundle 重打包和通用 manifest 在发布命令中的正式使用仍未完成。
|
- 发布级通用 `patch build` / `patch rollback`、复杂 AssetBundle 重打包和完整翻译文件集合的正式使用仍未完成;issue 43 的 `parse repack` 与 `i18n publish` 仅覆盖已有 patch 实现支持的安全子集。
|
||||||
|
|
||||||
验收:
|
验收:
|
||||||
|
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ make build-go-cli # 产出 bin/bat-go
|
|||||||
| 项 | 状态 |
|
| 项 | 状态 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| G-008 Go 同步 CLI | **已决策关闭**(正式同步 CLI = Rust `bat`) |
|
| 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** |
|
| issue #19 | 资源面 MVP 与 USERGUIDE 基础章节已编码;真机联调后继续补充实战样例;**未自动关 issue** |
|
||||||
| G-010 Web | 未开始 |
|
| G-010 Web | 未开始 |
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,16 @@
|
|||||||
- 修正文档、CLI 帮助、RPC 参考和状态文件中与当前实现不一致的解析能力声明。
|
- 修正文档、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 能力。后续任何扩大解析覆盖的变更仍需单独解冻授权。
|
||||||
|
|
||||||
## 禁止变更
|
## 禁止变更
|
||||||
|
|
||||||
冻结期禁止以下解析相关变更:
|
冻结期禁止以下解析相关变更:
|
||||||
|
|||||||
@@ -8,22 +8,26 @@ use bat_infrastructure::DEFAULT_DOWNLOAD_CONCURRENCY;
|
|||||||
use bat_infrastructure::{
|
use bat_infrastructure::{
|
||||||
apply_patch_file, apply_unityfs_field_patch_file, apply_unityfs_string_field_patch_file,
|
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,
|
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_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,
|
read_parse_cache_at, read_snapshot, read_textunit_index_at, read_translation_workbench,
|
||||||
redact_proxy_url, resolve_curl_proxy, validate_output_root, validate_runtime_state_dir,
|
read_version_state, redact_proxy_url, repack_bundle, resolve_curl_proxy, set_translation,
|
||||||
write_file_atomic, CurlProxyConfig, CurlProxyMode, OfficialEndpointMarkerRole,
|
validate_output_root, validate_runtime_state_dir, write_file_atomic,
|
||||||
OfficialFailedVersionRecord, OfficialResourceHashVerification, OfficialResourceVerification,
|
write_official_textunit_queues, CurlProxyConfig, CurlProxyMode, LocalizedPatchConfig,
|
||||||
OfficialServerInfoSource, OfficialTextUnitQuery, OfficialTextUnitTaskQuery,
|
LocalizedPatchReport, LocalizedPatchService, OfficialEndpointMarkerRole,
|
||||||
OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService,
|
OfficialFailedVersionRecord, OfficialParseCacheService, OfficialParseConfig,
|
||||||
OfficialUpdateSnapshot, OfficialUpdateStatus, OfficialVerificationSummary,
|
OfficialResourceHashVerification, OfficialResourceVerification, OfficialServerInfoSource,
|
||||||
OfficialVersionRecord, OfficialVersionState, PatchApplyKind, PatchApplyParams,
|
OfficialTextUnitQuery, OfficialTextUnitTaskQuery, OfficialUpdateConfig, OfficialUpdateProgress,
|
||||||
PatchApplyReport, ReleaseFlowStatusCode, SqliteResourceRepository,
|
OfficialUpdateReport, OfficialUpdateService, OfficialUpdateSnapshot, OfficialUpdateStatus,
|
||||||
SqliteTranslationTaskRepository, TranslationTaskStatus, UnityFsFieldPatchParams,
|
OfficialVerificationSummary, OfficialVersionRecord, OfficialVersionState, PatchApplyKind,
|
||||||
UnityFsPatchReport, UnityFsStringFieldPatchParams, UnityFsTextAssetPatchParams,
|
PatchApplyParams, PatchApplyReport, ReleaseFlowStatusCode, RepackReport,
|
||||||
LOCALIZED_CURRENT_LINK, LOCALIZED_PATCH_MANIFEST_FILE, LOCALIZED_VERSIONS_DIR,
|
SqliteResourceRepository, SqliteTranslationTaskRepository, TranslationTaskStatus,
|
||||||
LOCALIZED_VERSION_STATE_FILE, MAX_DOWNLOAD_CONCURRENCY, MIN_DOWNLOAD_CONCURRENCY,
|
UnityFsFieldPatchParams, UnityFsPatchReport, UnityFsStringFieldPatchParams,
|
||||||
OFFICIAL_PARSE_CACHE_FILE, OFFICIAL_TEXTUNIT_INDEX_FILE, PRIVATE_FILE_MODE,
|
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::de::DeserializeOwned;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -46,16 +50,23 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
|||||||
mod patch_commands;
|
mod patch_commands;
|
||||||
#[path = "readonly_query.rs"]
|
#[path = "readonly_query.rs"]
|
||||||
mod readonly_query;
|
mod readonly_query;
|
||||||
|
#[path = "schedule_commands.rs"]
|
||||||
|
mod schedule_commands;
|
||||||
#[path = "task_registry.rs"]
|
#[path = "task_registry.rs"]
|
||||||
mod task_registry;
|
mod task_registry;
|
||||||
#[path = "translation_query.rs"]
|
#[path = "translation_query.rs"]
|
||||||
mod translation_query;
|
mod translation_query;
|
||||||
|
#[path = "workflow_commands.rs"]
|
||||||
|
mod workflow_commands;
|
||||||
use patch_commands::{
|
use patch_commands::{
|
||||||
is_write_patch_command, run_write_patch_command, validate_write_patch_options,
|
is_write_patch_command, run_write_patch_command, validate_write_patch_options,
|
||||||
};
|
};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use readonly_query::run_readonly_query_command_with_rpc;
|
use readonly_query::run_readonly_query_command_with_rpc;
|
||||||
use readonly_query::{run_readonly_query_command, validate_readonly_query_options};
|
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::{
|
use task_registry::{
|
||||||
run_task_worker, CancelOutcome, DaemonTaskContext, TaskJob, TaskKind, TaskRegistry,
|
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,
|
build_translation_handoff_report, build_translation_tasks_report, textunit_query_json,
|
||||||
update_translation_task_status_report,
|
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_ERROR: i32 = 1;
|
||||||
const EXIT_LOCKED: i32 = 75;
|
const EXIT_LOCKED: i32 = 75;
|
||||||
@@ -168,6 +182,52 @@ fn run() -> anyhow::Result<i32> {
|
|||||||
}
|
}
|
||||||
Ok(0)
|
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 => {
|
CliCommand::Status => {
|
||||||
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
|
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
|
||||||
print_daemon_status(&options.state_dir, options.output_format)?;
|
print_daemon_status(&options.state_dir, options.output_format)?;
|
||||||
@@ -233,6 +293,34 @@ fn run() -> anyhow::Result<i32> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)]
|
#[derive(Debug, Serialize)]
|
||||||
struct ErrorReport<'a> {
|
struct ErrorReport<'a> {
|
||||||
status: &'a str,
|
status: &'a str,
|
||||||
@@ -248,10 +336,31 @@ struct CliOptions {
|
|||||||
command: CliCommand,
|
command: CliCommand,
|
||||||
output_format: OutputFormat,
|
output_format: OutputFormat,
|
||||||
watch: bool,
|
watch: bool,
|
||||||
|
run_count: Option<usize>,
|
||||||
|
interval_explicit: bool,
|
||||||
daemon: bool,
|
daemon: bool,
|
||||||
daemon_child: bool,
|
daemon_child: bool,
|
||||||
state_dir: PathBuf,
|
state_dir: PathBuf,
|
||||||
output_explicit: bool,
|
output_explicit: bool,
|
||||||
|
resource_root: Option<PathBuf>,
|
||||||
|
translation_file: Option<PathBuf>,
|
||||||
|
translation_id: Option<String>,
|
||||||
|
translation_text: Option<String>,
|
||||||
|
translation_text_file: Option<PathBuf>,
|
||||||
|
localized_release_id: Option<String>,
|
||||||
|
repack_spec: Option<PathBuf>,
|
||||||
|
schedule_group: Option<String>,
|
||||||
|
schedule_action: Option<String>,
|
||||||
|
schedule_id: Option<String>,
|
||||||
|
schedule_at_unix: Option<u64>,
|
||||||
|
schedule_delay: Option<Duration>,
|
||||||
|
schedule_every: Option<Duration>,
|
||||||
|
schedule_count: Option<usize>,
|
||||||
|
schedule_args: Vec<String>,
|
||||||
|
schedule_clear_args: bool,
|
||||||
|
schedule_clear_every: bool,
|
||||||
|
schedule_enabled: Option<bool>,
|
||||||
|
schedule_option_explicit: bool,
|
||||||
sync_option_explicit: bool,
|
sync_option_explicit: bool,
|
||||||
proxy_option_explicit: bool,
|
proxy_option_explicit: bool,
|
||||||
interval: Duration,
|
interval: Duration,
|
||||||
@@ -309,10 +418,31 @@ impl Default for CliOptions {
|
|||||||
command: CliCommand::Run,
|
command: CliCommand::Run,
|
||||||
output_format: OutputFormat::Human,
|
output_format: OutputFormat::Human,
|
||||||
watch: false,
|
watch: false,
|
||||||
|
run_count: None,
|
||||||
|
interval_explicit: false,
|
||||||
daemon: false,
|
daemon: false,
|
||||||
daemon_child: false,
|
daemon_child: false,
|
||||||
state_dir: PathBuf::from(DEFAULT_DAEMON_STATE_DIR),
|
state_dir: PathBuf::from(DEFAULT_DAEMON_STATE_DIR),
|
||||||
output_explicit: false,
|
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,
|
sync_option_explicit: false,
|
||||||
proxy_option_explicit: false,
|
proxy_option_explicit: false,
|
||||||
interval: Duration::from_secs(DEFAULT_WATCH_INTERVAL_SECONDS),
|
interval: Duration::from_secs(DEFAULT_WATCH_INTERVAL_SECONDS),
|
||||||
@@ -372,6 +502,17 @@ enum OutputFormat {
|
|||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum CliCommand {
|
enum CliCommand {
|
||||||
Run,
|
Run,
|
||||||
|
Pull,
|
||||||
|
Parse,
|
||||||
|
Translate,
|
||||||
|
TranslationSet,
|
||||||
|
Repack,
|
||||||
|
PublishLocalized,
|
||||||
|
ScheduleList,
|
||||||
|
ScheduleAdd,
|
||||||
|
ScheduleUpdate,
|
||||||
|
ScheduleRemove,
|
||||||
|
ScheduleRun,
|
||||||
Status,
|
Status,
|
||||||
Stop,
|
Stop,
|
||||||
Restart,
|
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_MANIFEST: &str = "resource.manifest";
|
||||||
const RPC_METHOD_RESOURCE_INDEX: &str = "resource.index";
|
const RPC_METHOD_RESOURCE_INDEX: &str = "resource.index";
|
||||||
const RPC_METHOD_RESOURCE_LIST: &str = "resource.list";
|
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_STATUS: &str = "parse.status";
|
||||||
const RPC_METHOD_PARSE_TEXT_UNITS: &str = "parse.text_units";
|
const RPC_METHOD_PARSE_TEXT_UNITS: &str = "parse.text_units";
|
||||||
const RPC_METHOD_PARSE_ERRORS: &str = "parse.errors";
|
const RPC_METHOD_PARSE_ERRORS: &str = "parse.errors";
|
||||||
@@ -1616,6 +1762,77 @@ fn dispatch_rpc_method(
|
|||||||
"resource.state",
|
"resource.state",
|
||||||
build_resource_state_report(state_dir),
|
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::<schedule_commands::ScheduleMutationRequest>(
|
||||||
|
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::<schedule_commands::ScheduleMutationRequest>(
|
||||||
|
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::<schedule_commands::ScheduleMutationRequest>(
|
||||||
|
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 => {
|
RPC_METHOD_RESOURCE_SYNC => {
|
||||||
let force = rpc_bool_param(request.params.as_ref(), "force").unwrap_or(false);
|
let force = rpc_bool_param(request.params.as_ref(), "force").unwrap_or(false);
|
||||||
enqueue_task_envelope(tasks, TaskKind::Sync, force, request_id)
|
enqueue_task_envelope(tasks, TaskKind::Sync, force, request_id)
|
||||||
@@ -3837,6 +4054,42 @@ trait HumanReport {
|
|||||||
fn print_human(&self) -> anyhow::Result<()>;
|
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<()> {
|
fn print_human_json_value(value: &serde_json::Value) -> anyhow::Result<()> {
|
||||||
if value.get("running").is_some() && value.get("state_dir").is_some() {
|
if value.get("running").is_some() && value.get("state_dir").is_some() {
|
||||||
print_title("后台状态");
|
print_title("后台状态");
|
||||||
@@ -6180,6 +6433,11 @@ fn parse_args_with_env(
|
|||||||
|
|
||||||
while let Some(flag) = args.next() {
|
while let Some(flag) = args.next() {
|
||||||
match flag.as_str() {
|
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" => {
|
"status" => {
|
||||||
ensure_command_not_set(options.command, "status")?;
|
ensure_command_not_set(options.command, "status")?;
|
||||||
options.command = CliCommand::Status;
|
options.command = CliCommand::Status;
|
||||||
@@ -6313,6 +6571,29 @@ fn parse_args_with_env(
|
|||||||
PathBuf::from(next_option_value(&mut args, &flag)?);
|
PathBuf::from(next_option_value(&mut args, &flag)?);
|
||||||
options.output_explicit = true;
|
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" => {
|
"--import-repository" => {
|
||||||
options.config.import_repository = true;
|
options.config.import_repository = true;
|
||||||
options.sync_option_explicit = true;
|
options.sync_option_explicit = true;
|
||||||
@@ -6334,6 +6615,18 @@ fn parse_args_with_env(
|
|||||||
"--state-dir" | "--pid-dir" => {
|
"--state-dir" | "--pid-dir" => {
|
||||||
options.state_dir = PathBuf::from(next_option_value(&mut args, &flag)?);
|
options.state_dir = PathBuf::from(next_option_value(&mut args, &flag)?);
|
||||||
}
|
}
|
||||||
|
"--run-count" | "--repeat" => {
|
||||||
|
let value = next_option_value(&mut args, &flag)?
|
||||||
|
.parse::<usize>()
|
||||||
|
.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" => {
|
"--snapshot" => {
|
||||||
options.config.snapshot_path =
|
options.config.snapshot_path =
|
||||||
Some(PathBuf::from(next_option_value(&mut args, &flag)?));
|
Some(PathBuf::from(next_option_value(&mut args, &flag)?));
|
||||||
@@ -6415,6 +6708,7 @@ fn parse_args_with_env(
|
|||||||
}
|
}
|
||||||
"--interval" => {
|
"--interval" => {
|
||||||
options.interval = parse_duration(&next_option_value(&mut args, &flag)?)?;
|
options.interval = parse_duration(&next_option_value(&mut args, &flag)?)?;
|
||||||
|
options.interval_explicit = true;
|
||||||
options.sync_option_explicit = true;
|
options.sync_option_explicit = true;
|
||||||
}
|
}
|
||||||
"--interval-seconds" => {
|
"--interval-seconds" => {
|
||||||
@@ -6422,8 +6716,66 @@ fn parse_args_with_env(
|
|||||||
.parse::<u64>()
|
.parse::<u64>()
|
||||||
.map_err(|error| anyhow::anyhow!("{flag} 的秒数无效:{error}"))?;
|
.map_err(|error| anyhow::anyhow!("{flag} 的秒数无效:{error}"))?;
|
||||||
options.interval = Duration::from_secs(seconds);
|
options.interval = Duration::from_secs(seconds);
|
||||||
|
options.interval_explicit = true;
|
||||||
options.sync_option_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::<u64>()
|
||||||
|
.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::<usize>()
|
||||||
|
.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" => {
|
"--error-retry" => {
|
||||||
options.error_retry_interval =
|
options.error_retry_interval =
|
||||||
parse_duration(&next_option_value(&mut args, &flag)?)?;
|
parse_duration(&next_option_value(&mut args, &flag)?)?;
|
||||||
@@ -6741,7 +7093,7 @@ fn parse_args_with_env(
|
|||||||
options.progress = false;
|
options.progress = false;
|
||||||
options.banner = false;
|
options.banner = false;
|
||||||
}
|
}
|
||||||
CliCommand::Refresh | CliCommand::Verify | CliCommand::Repair => {
|
CliCommand::Pull | CliCommand::Refresh | CliCommand::Verify | CliCommand::Repair => {
|
||||||
if !options.config.auto_discover
|
if !options.config.auto_discover
|
||||||
&& options.config.server_info_source.is_none()
|
&& options.config.server_info_source.is_none()
|
||||||
&& options.config.connection_group.is_none()
|
&& options.config.connection_group.is_none()
|
||||||
@@ -6756,6 +7108,52 @@ fn parse_args_with_env(
|
|||||||
options.config.repair = false;
|
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 => {
|
CliCommand::Restart | CliCommand::Reload => {
|
||||||
if (options.sync_option_explicit
|
if (options.sync_option_explicit
|
||||||
|| options.output_explicit
|
|| options.output_explicit
|
||||||
@@ -6787,6 +7185,35 @@ fn parse_args_with_env(
|
|||||||
if options.error_retry_interval.is_zero() {
|
if options.error_retry_interval.is_zero() {
|
||||||
return Err(anyhow::anyhow!("watch 失败重试间隔必须大于 0"));
|
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)
|
if (options.watch || options.daemon || options.daemon_child)
|
||||||
&& !options.quiet_up_to_date_explicit
|
&& !options.quiet_up_to_date_explicit
|
||||||
{
|
{
|
||||||
@@ -6818,6 +7245,109 @@ fn parse_args_with_env(
|
|||||||
Ok(options)
|
Ok(options)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_resource_command(
|
||||||
|
args: &mut impl Iterator<Item = String>,
|
||||||
|
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<Item = String>,
|
||||||
|
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<Item = String>,
|
||||||
|
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<Item = String>,
|
||||||
|
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<()> {
|
fn ensure_command_not_set(command: CliCommand, next: &str) -> anyhow::Result<()> {
|
||||||
if command == CliCommand::Run {
|
if command == CliCommand::Run {
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -6902,6 +7432,15 @@ fn print_usage(binary: &str) {
|
|||||||
eprintln!(" {binary} <COMMAND> [OPTIONS]");
|
eprintln!(" {binary} <COMMAND> [OPTIONS]");
|
||||||
eprintln!();
|
eprintln!();
|
||||||
eprintln!("Commands:");
|
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!(" refresh Run one update check, or ask a live daemon to refresh");
|
||||||
eprintln!(" verify Verify remote plan, local manifest, and official seed hashes");
|
eprintln!(" verify Verify remote plan, local manifest, and official seed hashes");
|
||||||
eprintln!(" repair Redownload resources that fail local verification");
|
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 --dry-run");
|
||||||
eprintln!(" {binary} --auto-discover --watch");
|
eprintln!(" {binary} --auto-discover --watch");
|
||||||
eprintln!(" {binary} --auto-discover --daemon");
|
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} status");
|
||||||
eprintln!(" {binary} refresh --force --json");
|
eprintln!(" {binary} refresh --force --json");
|
||||||
eprintln!();
|
eprintln!();
|
||||||
@@ -6971,6 +7514,15 @@ fn print_usage(binary: &str) {
|
|||||||
eprintln!(" --force Force download/refresh");
|
eprintln!(" --force Force download/refresh");
|
||||||
eprintln!(" --audit-local | --no-audit-local Enable/disable local manifest audit");
|
eprintln!(" --audit-local | --no-audit-local Enable/disable local manifest audit");
|
||||||
eprintln!(" --repair | --no-repair Enable/disable automatic repair");
|
eprintln!(" --repair | --no-repair Enable/disable automatic repair");
|
||||||
|
eprintln!(" --run-count <N> Run pull/parse/translate/publish N times");
|
||||||
|
eprintln!(" --once Explicitly select one run");
|
||||||
|
eprintln!(" --resource-root <DIR> Use an explicit published official release root");
|
||||||
|
eprintln!(" --translation-file <PATH> Translation workbench JSON file");
|
||||||
|
eprintln!(" --translation-id <ID> TextUnit ID for i18n set");
|
||||||
|
eprintln!(" --translated-text <TEXT> Inline translation for i18n set");
|
||||||
|
eprintln!(" --translated-file <PATH> UTF-8 translation file for i18n set");
|
||||||
|
eprintln!(" --localized-release-id <ID> Explicit localized publication ID");
|
||||||
|
eprintln!(" --repack-spec <PATH> UnityFS batch repack JSON spec");
|
||||||
eprintln!();
|
eprintln!();
|
||||||
eprintln!("Read-only queries:");
|
eprintln!("Read-only queries:");
|
||||||
eprintln!(" --offset <N> Query offset for resource-index/parse-text-units/parse-errors/translation-tasks");
|
eprintln!(" --offset <N> 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!(" --quiet-up-to-date Suppress clean up-to-date reports");
|
||||||
eprintln!(" --no-quiet-up-to-date Always print reports");
|
eprintln!(" --no-quiet-up-to-date Always print reports");
|
||||||
eprintln!(" --tail <N> Log lines for logs command (default: 200)");
|
eprintln!(" --tail <N> Log lines for logs command (default: 200)");
|
||||||
|
eprintln!(" --schedule-id <ID> Schedule identifier");
|
||||||
|
eprintln!(" --schedule-action <ACTION> Schedule action (pull/run/repack/publish)");
|
||||||
|
eprintln!(" --schedule-at-unix <SECONDS> First execution time");
|
||||||
|
eprintln!(" --schedule-delay <DURATION> Delay first execution from now");
|
||||||
|
eprintln!(" --schedule-every <DURATION> Period between executions");
|
||||||
|
eprintln!(" --schedule-count <N> Bounded execution count");
|
||||||
|
eprintln!(" --schedule-arg <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!();
|
||||||
eprintln!("Output:");
|
eprintln!("Output:");
|
||||||
eprintln!(" --human Human-readable output (default)");
|
eprintln!(" --human Human-readable output (default)");
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::app::schedule_commands::read_schedule_file;
|
||||||
|
|
||||||
fn parse(values: &[&str]) -> anyhow::Result<CliOptions> {
|
fn parse(values: &[&str]) -> anyhow::Result<CliOptions> {
|
||||||
parse_args_from(values.iter().map(|value| value.to_string()))
|
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());
|
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]
|
#[test]
|
||||||
fn cli_download_concurrency_is_preserved_for_daemon_child() {
|
fn cli_download_concurrency_is_preserved_for_daemon_child() {
|
||||||
let options = parse(&["bat", "--download-concurrency", "4"]).unwrap();
|
let options = parse(&["bat", "--download-concurrency", "4"]).unwrap();
|
||||||
|
|||||||
@@ -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<Self> {
|
||||||
|
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<ScheduleEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
pub(super) next_run_unix_seconds: u64,
|
||||||
|
pub(super) interval_seconds: Option<u64>,
|
||||||
|
pub(super) remaining_runs: Option<usize>,
|
||||||
|
pub(super) enabled: bool,
|
||||||
|
pub(super) created_unix_seconds: u64,
|
||||||
|
pub(super) updated_unix_seconds: u64,
|
||||||
|
pub(super) last_run_unix_seconds: Option<u64>,
|
||||||
|
pub(super) last_status: Option<String>,
|
||||||
|
pub(super) last_error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub(super) struct ScheduleMutationRequest {
|
||||||
|
#[serde(default, alias = "schedule_id")]
|
||||||
|
pub(super) id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(super) group: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(super) action: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(super) args: Vec<String>,
|
||||||
|
#[serde(default, alias = "at_unix_seconds", alias = "schedule_at_unix")]
|
||||||
|
pub(super) next_run_unix_seconds: Option<u64>,
|
||||||
|
#[serde(default, alias = "schedule_delay_seconds")]
|
||||||
|
pub(super) delay_seconds: Option<u64>,
|
||||||
|
#[serde(default, alias = "schedule_every_seconds")]
|
||||||
|
pub(super) every_seconds: Option<u64>,
|
||||||
|
#[serde(default, alias = "schedule_count")]
|
||||||
|
pub(super) count: Option<usize>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(super) clear_args: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(super) clear_every: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(super) enabled: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub(super) struct ScheduleRunRequest {
|
||||||
|
#[serde(default, alias = "schedule_id")]
|
||||||
|
pub(super) id: Option<String>,
|
||||||
|
#[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<serde_json::Value> {
|
||||||
|
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<serde_json::Value> {
|
||||||
|
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<serde_json::Value> {
|
||||||
|
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<serde_json::Value> {
|
||||||
|
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<serde_json::Value> {
|
||||||
|
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<ScheduleEntry> {
|
||||||
|
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<u64> {
|
||||||
|
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<String> {
|
||||||
|
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<u64> {
|
||||||
|
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<String> {
|
||||||
|
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<ScheduleFile> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ pub mod path_security;
|
|||||||
pub mod release_flow;
|
pub mod release_flow;
|
||||||
pub mod resources;
|
pub mod resources;
|
||||||
pub mod translation_tasks;
|
pub mod translation_tasks;
|
||||||
|
pub mod translation_workflow;
|
||||||
mod zip_validation;
|
mod zip_validation;
|
||||||
|
|
||||||
pub use cas::FileSystemCasRepository;
|
pub use cas::FileSystemCasRepository;
|
||||||
@@ -138,6 +139,12 @@ pub use translation_tasks::{
|
|||||||
TRANSLATION_HANDOFF_FILE, TRANSLATION_HANDOFF_SCHEMA_VERSION, TRANSLATION_TASK_REPOSITORY_FILE,
|
TRANSLATION_HANDOFF_FILE, TRANSLATION_HANDOFF_SCHEMA_VERSION, TRANSLATION_TASK_REPOSITORY_FILE,
|
||||||
TRANSLATION_TASK_SCHEMA_VERSION,
|
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 版本号
|
/// Infrastructure 版本号
|
||||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|||||||
@@ -42,6 +42,13 @@ pub struct LocalizedPatchConfig {
|
|||||||
pub localized_output_root: PathBuf,
|
pub localized_output_root: PathBuf,
|
||||||
/// Version identifier shared with the official release.
|
/// Version identifier shared with the official release.
|
||||||
pub release_id: String,
|
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<String>,
|
||||||
|
/// 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.
|
/// Patch operations to apply.
|
||||||
pub patches: Vec<LocalizedTextAssetPatch>,
|
pub patches: Vec<LocalizedTextAssetPatch>,
|
||||||
}
|
}
|
||||||
@@ -58,9 +65,29 @@ impl LocalizedPatchConfig {
|
|||||||
official_release_root: official_release_root.into(),
|
official_release_root: official_release_root.into(),
|
||||||
localized_output_root: localized_output_root.into(),
|
localized_output_root: localized_output_root.into(),
|
||||||
release_id: release_id.into(),
|
release_id: release_id.into(),
|
||||||
|
localized_release_id: None,
|
||||||
|
force: false,
|
||||||
patches,
|
patches,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sets a distinct localized release ID.
|
||||||
|
pub fn with_localized_release_id(mut self, release_id: impl Into<String>) -> 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.
|
/// Persisted localized release state.
|
||||||
@@ -187,7 +214,7 @@ impl LocalizedPatchManifest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Result of a successful localized release publication.
|
/// Result of a successful localized release publication.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
pub struct LocalizedPatchReport {
|
pub struct LocalizedPatchReport {
|
||||||
/// Published version directory.
|
/// Published version directory.
|
||||||
pub version_path: PathBuf,
|
pub version_path: PathBuf,
|
||||||
@@ -217,14 +244,15 @@ impl LocalizedPatchService {
|
|||||||
|
|
||||||
/// Copies the official release, applies patches in staging and publishes it.
|
/// Copies the official release, applies patches in staging and publishes it.
|
||||||
pub fn publish(&self, config: &LocalizedPatchConfig) -> anyhow::Result<LocalizedPatchReport> {
|
pub fn publish(&self, config: &LocalizedPatchConfig) -> anyhow::Result<LocalizedPatchReport> {
|
||||||
|
let published_release_id = config.published_release_id().to_string();
|
||||||
let staging = config
|
let staging = config
|
||||||
.localized_output_root
|
.localized_output_root
|
||||||
.join(LOCALIZED_STAGING_DIR)
|
.join(LOCALIZED_STAGING_DIR)
|
||||||
.join(&config.release_id);
|
.join(&published_release_id);
|
||||||
let version_path = config
|
let version_path = config
|
||||||
.localized_output_root
|
.localized_output_root
|
||||||
.join(LOCALIZED_VERSIONS_DIR)
|
.join(LOCALIZED_VERSIONS_DIR)
|
||||||
.join(&config.release_id);
|
.join(&published_release_id);
|
||||||
let current_path = config.localized_output_root.join(LOCALIZED_CURRENT_LINK);
|
let current_path = config.localized_output_root.join(LOCALIZED_CURRENT_LINK);
|
||||||
let previous_current_target = current_symlink_target(¤t_path).ok().flatten();
|
let previous_current_target = current_symlink_target(¤t_path).ok().flatten();
|
||||||
let version_existed_before = version_path.exists();
|
let version_existed_before = version_path.exists();
|
||||||
@@ -257,11 +285,11 @@ impl LocalizedPatchService {
|
|||||||
let staging = config
|
let staging = config
|
||||||
.localized_output_root
|
.localized_output_root
|
||||||
.join(LOCALIZED_STAGING_DIR)
|
.join(LOCALIZED_STAGING_DIR)
|
||||||
.join(&config.release_id);
|
.join(config.published_release_id());
|
||||||
let version_path = config
|
let version_path = config
|
||||||
.localized_output_root
|
.localized_output_root
|
||||||
.join(LOCALIZED_VERSIONS_DIR)
|
.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 current_path = config.localized_output_root.join(LOCALIZED_CURRENT_LINK);
|
||||||
let state_path = config
|
let state_path = config
|
||||||
.localized_output_root
|
.localized_output_root
|
||||||
@@ -269,10 +297,12 @@ impl LocalizedPatchService {
|
|||||||
let patch_manifest_path = version_path.join(LOCALIZED_PATCH_MANIFEST_FILE);
|
let patch_manifest_path = version_path.join(LOCALIZED_PATCH_MANIFEST_FILE);
|
||||||
|
|
||||||
if version_path.exists() {
|
if version_path.exists() {
|
||||||
return Err(anyhow::anyhow!(
|
let message = if config.force {
|
||||||
"localized release already exists: {}",
|
"localized release target already exists; forced publication requires a distinct localized release id"
|
||||||
version_path.display()
|
} else {
|
||||||
));
|
"localized release already exists"
|
||||||
|
};
|
||||||
|
return Err(anyhow::anyhow!("{message}: {}", version_path.display()));
|
||||||
}
|
}
|
||||||
remove_owned_staging(&staging)?;
|
remove_owned_staging(&staging)?;
|
||||||
fs::create_dir_all(&staging)?;
|
fs::create_dir_all(&staging)?;
|
||||||
@@ -313,7 +343,7 @@ impl LocalizedPatchService {
|
|||||||
let manifest = LocalizedPatchManifest {
|
let manifest = LocalizedPatchManifest {
|
||||||
manifest_version: LOCALIZED_PATCH_MANIFEST_VERSION,
|
manifest_version: LOCALIZED_PATCH_MANIFEST_VERSION,
|
||||||
official_release_id: config.release_id.clone(),
|
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(),
|
generated_unix_seconds: unix_seconds_now(),
|
||||||
file_count: changed_files.len(),
|
file_count: changed_files.len(),
|
||||||
text_asset_operation_count: changed_files
|
text_asset_operation_count: changed_files
|
||||||
@@ -339,13 +369,13 @@ impl LocalizedPatchService {
|
|||||||
switch_current_symlink(
|
switch_current_symlink(
|
||||||
&config.localized_output_root,
|
&config.localized_output_root,
|
||||||
¤t_path,
|
¤t_path,
|
||||||
&config.release_id,
|
config.published_release_id(),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let state = LocalizedVersionState {
|
let state = LocalizedVersionState {
|
||||||
state_version: 1,
|
state_version: 1,
|
||||||
official_release_id: config.release_id.clone(),
|
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(),
|
status: "localized".to_string(),
|
||||||
updated_unix_seconds: unix_seconds_now(),
|
updated_unix_seconds: unix_seconds_now(),
|
||||||
};
|
};
|
||||||
@@ -623,13 +653,18 @@ fn validate_config(config: &LocalizedPatchConfig) -> Result<(), String> {
|
|||||||
localized.display()
|
localized.display()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if config.release_id.is_empty()
|
for (label, release_id) in [
|
||||||
|| config.release_id.contains('/')
|
("官方", config.release_id.as_str()),
|
||||||
|| config.release_id.contains('\\')
|
("汉化", config.published_release_id()),
|
||||||
|| config.release_id == "."
|
] {
|
||||||
|| config.release_id == ".."
|
if release_id.is_empty()
|
||||||
{
|
|| release_id.contains('/')
|
||||||
return Err(format!("非法汉化 release id:{}", config.release_id));
|
|| 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.official_release_root, "官方 release")?;
|
||||||
ensure_safe_directory_path(&config.localized_output_root, "汉化输出目录")?;
|
ensure_safe_directory_path(&config.localized_output_root, "汉化输出目录")?;
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ pub struct OfficialParseConfig {
|
|||||||
pub resource_root: PathBuf,
|
pub resource_root: PathBuf,
|
||||||
/// `unzip` executable used to inspect zip archives without extracting them.
|
/// `unzip` executable used to inspect zip archives without extracting them.
|
||||||
pub unzip_command: PathBuf,
|
pub unzip_command: PathBuf,
|
||||||
|
/// Ignore a matching previous cache and inspect every manifest candidate.
|
||||||
|
pub force: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OfficialParseConfig {
|
impl OfficialParseConfig {
|
||||||
@@ -43,9 +45,16 @@ impl OfficialParseConfig {
|
|||||||
Self {
|
Self {
|
||||||
resource_root: resource_root.into(),
|
resource_root: resource_root.into(),
|
||||||
unzip_command: unzip_command.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.
|
/// Returns the parse-cache path for this resource root.
|
||||||
pub fn cache_path(&self) -> PathBuf {
|
pub fn cache_path(&self) -> PathBuf {
|
||||||
self.resource_root.join(OFFICIAL_PARSE_CACHE_FILE)
|
self.resource_root.join(OFFICIAL_PARSE_CACHE_FILE)
|
||||||
@@ -357,8 +366,16 @@ impl OfficialParseCacheService {
|
|||||||
config.resource_root.display()
|
config.resource_root.display()
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let previous_cache = read_parse_cache_at(&config.resource_root)?;
|
let previous_cache = if config.force {
|
||||||
let previous_textunit_index = read_textunit_index_at(&config.resource_root)?;
|
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 {
|
let mut summary = OfficialParseSummary {
|
||||||
manifest_entry_count: manifest.entries.len(),
|
manifest_entry_count: manifest.entries.len(),
|
||||||
..OfficialParseSummary::default()
|
..OfficialParseSummary::default()
|
||||||
|
|||||||
@@ -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<TranslationWorkbenchEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
/// Unity serialized file path.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub serialized_file: Option<String>,
|
||||||
|
/// Unity object path ID.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub path_id: Option<i64>,
|
||||||
|
/// Unity TextAsset name, when known.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub asset_name: Option<String>,
|
||||||
|
/// 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<String>,
|
||||||
|
/// TextUnit format.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub format: Option<String>,
|
||||||
|
/// Extraction source kind such as TextAsset or TypeTreeField.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub text_source_kind: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exports the current official TextUnit index as an editable workbench.
|
||||||
|
pub fn export_translation_workbench(
|
||||||
|
resource_root: &Path,
|
||||||
|
official_release_id: impl Into<String>,
|
||||||
|
output_path: &Path,
|
||||||
|
) -> anyhow::Result<TranslationWorkbench> {
|
||||||
|
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<TranslationWorkbench> {
|
||||||
|
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<TranslationWorkbenchEntry> {
|
||||||
|
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<Vec<LocalizedTextAssetPatch>> {
|
||||||
|
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::<std::collections::HashMap<_, _>>();
|
||||||
|
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<RepackOperation>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
/// Inline replacement UTF-8 text.
|
||||||
|
#[serde(default)]
|
||||||
|
replacement_text: Option<String>,
|
||||||
|
/// File containing replacement bytes.
|
||||||
|
#[serde(default)]
|
||||||
|
replacement_file: Option<PathBuf>,
|
||||||
|
},
|
||||||
|
/// 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<String>,
|
||||||
|
/// Inline replacement UTF-8 text.
|
||||||
|
#[serde(default)]
|
||||||
|
replacement_text: Option<String>,
|
||||||
|
/// File containing replacement UTF-8 text.
|
||||||
|
#[serde(default)]
|
||||||
|
replacement_file: Option<PathBuf>,
|
||||||
|
},
|
||||||
|
/// 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<UnitySerializedReplacementValue>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<RepackReport> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<String>,
|
||||||
|
replacement_file: &Option<PathBuf>,
|
||||||
|
) -> anyhow::Result<Vec<u8>> {
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
let first_error = OfficialUpdateService::new().run(&config).unwrap_err();
|
||||||
assert!(first_error.to_string().contains("quarantine"));
|
assert!(first_error.to_string().contains("quarantine"));
|
||||||
assert!(first_error.to_string().contains("simulated failure"));
|
assert!(first_error.to_string().contains("simulated failure"));
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const adminControlMaxBodyBytes = 1024
|
const adminControlMaxBodyBytes = 1024
|
||||||
|
const adminScheduleMaxBodyBytes = 64 * 1024
|
||||||
|
|
||||||
type adminControlRequest struct {
|
type adminControlRequest struct {
|
||||||
Force bool `json:"force"`
|
Force bool `json:"force"`
|
||||||
@@ -33,6 +34,7 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
"/v1/release",
|
"/v1/release",
|
||||||
"/v1/resources",
|
"/v1/resources",
|
||||||
"/openapi.yaml",
|
"/openapi.yaml",
|
||||||
|
"/admin/schedules",
|
||||||
},
|
},
|
||||||
Controls: []string{
|
Controls: []string{
|
||||||
"/admin/control/reload",
|
"/admin/control/reload",
|
||||||
@@ -42,6 +44,10 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
"/admin/control/verify",
|
"/admin/control/verify",
|
||||||
"/admin/control/repair",
|
"/admin/control/repair",
|
||||||
"/admin/control/catalog-refresh",
|
"/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 {
|
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")
|
writeErrorJSON(w, http.StatusNotFound, "control_not_found", "unknown control action")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if strings.HasPrefix(action, "schedule-") {
|
||||||
|
s.handleAdminScheduleControl(w, r, action)
|
||||||
|
return
|
||||||
|
}
|
||||||
request, ok := decodeAdminControlRequest(w, r)
|
request, ok := decodeAdminControlRequest(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
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 {
|
func (s *Server) requireAdminToken(w http.ResponseWriter, r *http.Request) bool {
|
||||||
if s.cfg.AuthToken == "" {
|
if s.cfg.AuthToken == "" {
|
||||||
writeErrorJSON(w, http.StatusForbidden, "admin_auth_required", "admin controls require BAT_API_AUTH_TOKEN")
|
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
|
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) {
|
func decodeAdminControlRequest(w http.ResponseWriter, r *http.Request) (adminControlRequest, bool) {
|
||||||
var request adminControlRequest
|
var request adminControlRequest
|
||||||
if r.Body == nil {
|
if r.Body == nil {
|
||||||
|
|||||||
@@ -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
|
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) {
|
func TestDiscoverCallsStatusBeforeDoctor(t *testing.T) {
|
||||||
root := fixtureRoot(t)
|
root := fixtureRoot(t)
|
||||||
bytes := uint64(20)
|
bytes := uint64(20)
|
||||||
|
|||||||
+40
-2
@@ -115,16 +115,26 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Admin links and allowlisted control actions.
|
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}:
|
/admin/control/{action}:
|
||||||
post:
|
post:
|
||||||
summary: Forward an allowlisted control action to Rust bat
|
summary: Forward an allowlisted control or schedule action to Rust bat
|
||||||
parameters:
|
parameters:
|
||||||
- name: action
|
- name: action
|
||||||
in: path
|
in: path
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: string
|
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:
|
requestBody:
|
||||||
required: false
|
required: false
|
||||||
content:
|
content:
|
||||||
@@ -135,6 +145,34 @@ paths:
|
|||||||
properties:
|
properties:
|
||||||
force:
|
force:
|
||||||
type: boolean
|
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:
|
responses:
|
||||||
"202":
|
"202":
|
||||||
description: Rust bat accepted the control request.
|
description: Rust bat accepted the control request.
|
||||||
|
|||||||
@@ -38,6 +38,17 @@ type ControlBackend interface {
|
|||||||
CatalogRefresh(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error)
|
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.
|
// RPCClient adapts *backendrpc.Client to Backend.
|
||||||
type RPCClient struct {
|
type RPCClient struct {
|
||||||
Client *backendrpc.Client
|
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) {
|
func (r RPCClient) CatalogRefresh(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error) {
|
||||||
return r.Client.CatalogRefresh(ctx, force)
|
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) {
|
func (r RPCClient) ParseStatus(ctx context.Context) (json.RawMessage, error) {
|
||||||
return r.Client.ParseStatus(ctx)
|
return r.Client.ParseStatus(ctx)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/advanced/game/download/cdn"), s.handleLauncherCdnConfig)
|
||||||
mux.HandleFunc(launcherHostPath("/api/launcher/resource/bootstrap.json"), s.handleLauncherBootstrap)
|
mux.HandleFunc(launcherHostPath("/api/launcher/resource/bootstrap.json"), s.handleLauncherBootstrap)
|
||||||
mux.HandleFunc("/openapi.yaml", s.handleOpenAPI)
|
mux.HandleFunc("/openapi.yaml", s.handleOpenAPI)
|
||||||
|
mux.HandleFunc("/admin/schedules", s.handleAdminSchedules)
|
||||||
mux.HandleFunc("/admin/control/", s.handleAdminControl)
|
mux.HandleFunc("/admin/control/", s.handleAdminControl)
|
||||||
mux.HandleFunc("/admin/", s.handleAdminIndex)
|
mux.HandleFunc("/admin/", s.handleAdminIndex)
|
||||||
mux.HandleFunc("/"+ServerInfoHost+"/", s.handleServerInfoCDN)
|
mux.HandleFunc("/"+ServerInfoHost+"/", s.handleServerInfoCDN)
|
||||||
@@ -125,6 +126,7 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
|
|||||||
"/" + ServerInfoHost + "/...",
|
"/" + ServerInfoHost + "/...",
|
||||||
"/openapi.yaml",
|
"/openapi.yaml",
|
||||||
"/admin/",
|
"/admin/",
|
||||||
|
"/admin/schedules",
|
||||||
"/admin/control/{action}",
|
"/admin/control/{action}",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -232,6 +232,29 @@ type UnityFSFieldPatchParams struct {
|
|||||||
ExpectedValue json.RawMessage `json:"expected_value,omitempty"`
|
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.
|
// Ack is returned by accepted daemon control methods.
|
||||||
type Ack struct {
|
type Ack struct {
|
||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
@@ -427,6 +450,26 @@ func (c *Client) ResourceList(ctx context.Context, offset int, limit int) (*Reso
|
|||||||
return &out, err
|
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) {
|
func (c *Client) CatalogStatus(ctx context.Context) (json.RawMessage, error) {
|
||||||
return c.rawData(ctx, "catalog.status", nil)
|
return c.rawData(ctx, "catalog.status", nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestApplicationErrorReturnsAPIError(t *testing.T) {
|
||||||
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
||||||
if req.Method != "task.status" {
|
if req.Method != "task.status" {
|
||||||
|
|||||||
+3
-2
@@ -3,8 +3,9 @@
|
|||||||
This directory is reserved for the future player-facing `bat-api` management
|
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
|
panel. The current backend exposes a non-mutating index at `GET /admin/` that
|
||||||
returns JSON links for health, readiness, bootstrap, release, resources, and
|
returns JSON links for health, readiness, bootstrap, release, resources, and
|
||||||
OpenAPI. Mutating `/admin/control/{action}` routes are protected by the
|
OpenAPI. `GET /admin/schedules` and mutating
|
||||||
configured admin token and forward only the documented Rust `bat` allowlist.
|
`/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,
|
The production panel must reuse the same HTTP authentication, rate limiting,
|
||||||
access logging, reverse-proxy handling, and no-store dynamic response policy as
|
access logging, reverse-proxy handling, and no-store dynamic response policy as
|
||||||
|
|||||||
Reference in New Issue
Block a user