diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md index fdfb59b..bd8b17d 100644 --- a/CURRENT_STATUS.md +++ b/CURRENT_STATUS.md @@ -30,7 +30,7 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口: 13. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,官方同步可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后触发导入,默认 CAS 路径为 `/.cas`、SQLite 索引为 `/resources.sqlite`,也可通过 `--import-cas-root`、`--import-resource-db`、`BAT_IMPORT_CAS_ROOT`、`BAT_IMPORT_RESOURCE_DB` 覆盖;`resource.index` RPC/CLI 可按类型、hash、路径模式、官方 release ID、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 分页查询现有索引,数据库不存在时返回 `available=false` 且不会创建空库。`Resource` metadata 已通过 `metadata_json` 兼容迁移保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式;当前/上一个/结构变化 catalog、失败 staging 复用、403/404、hash mismatch、CRC、metadata 迁移与 UnityFS 边界校验均有离线回归 fixture 或单测覆盖。 14. 非 dry-run 官方同步在校验完成并发布后,会先对比上一完整 release 与当前 release 的 `official-download-manifest.json`,在当前 release 下写入 `official-resource-changes.json` 和 `crowdin-translation-handoff.json`;同一 destination 只有 size 或 BLAKE3 改变才算 modified,仅 URL/CDN 根变化但内容相同不会触发解析/翻译候选。随后刷新 `official-parse-cache.json` 和 `official-textunit-index.json`,并从 Added/Modified 资源、parse cache 与 TextUnit 明细索引派生 `official-textunit-tasks.json` 和 `crowdin-textunit-queue.json`;删除资源只进入差异记录,不进入 TextUnit/Crowdin 队列。`parse.text_units` 和 `parse.errors` RPC/CLI 可按 destination、archive entry、path id、class id、field path 和 format 查询当前 release 的 TextUnit 明细与解析错误;`translation.tasks` RPC/CLI 可按 release、destination、archive entry、任务状态、parse status、TextUnit format 和 reason presence 查询离线 TextUnit 翻译任务状态与跳过/失败原因;TextUnit 已包含 class id、field path、字段 offset/byte size 等可追溯定位。Crowdin 当前仅预留本地离线队列,不发网络请求;up-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析。官方同步报告默认 `localized_release_status=not_localized`,含义是原版资源已经发布、汉化资源未发布;UnityFS TextAsset patch 发布成功后会写 `localized-patch-manifest.json`,校验 hash/size/diff/rollback 后才允许 `localized.status` 返回 `status=published`、`status_code=localized.published` 和 `localized_release_status=localized`。`bat` 首次启动会在二进制所在目录释放 `.env` 配置模板(`0600`),之后每次启动自动加载(不覆盖已存在的环境变量),支持 `BAT_OUTPUT`/`BAT_LOCALIZED_OUTPUT`/`BAT_IMPORT_REPOSITORY`/`BAT_IMPORT_CAS_ROOT`/`BAT_IMPORT_RESOURCE_DB`/`BAT_STATE_DIR`/`BAT_AUTO_DISCOVER`/`BAT_WATCH`/`BAT_DAEMON`/`BAT_PROXY` 等键,实现编辑 `.env` 后无参启动;优先级为命令行参数 > 进程环境变量 > `.env` > 内置默认值,`BAT_SKIP_ENV_FILE=1` 可整体禁用;Redis 键为预留。daemon 任务历史持久化在 `/bat-tasks.json`(版本化、`0600` 原子写),重启后任务经 `task.*` 仍可查,中断任务标记 `task_interrupted`(`BAT-ERR-700005`)。 -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`。 +15. issue 43 已补齐 Rust `bat` 的 `res` / `parse` / `i18n` 工作流入口:支持资源拉取、解析刷新、可再生解析缓存清理、离线翻译工作台、人工文本修改、工作台发布前校验、有限 TextAsset 汉化发布、既有 patch 能力的批量重打包、单次/限定次数/周期执行和版本化 schedule CRUD。schedule 查询现在按一级工作流过滤,删除/执行会校验作用域,单轮执行可限制计划数;schedule CRUD 已通过 `bat.sock` 的 `schedule.*` RPC 以及 `bat-api` 的鉴权管理接口暴露,dashboard 不维护第二套状态。该例外只编排已有解析和 patch 能力,不扩大解析器覆盖;完整 AssetBundle 重打包、真实 provider worker 和 Web 前端仍是后续工作。G-008(产品级 Go 同步 CLI)已决策关闭。真实官方网络全量拉取 smoke 已固化(G-018 已关闭);真实大文件与运行报告默认在 `/tmp` 隔离目录,不纳入 Git。Go 细节见 `docs/reports/GO_STATUS.md`。 当前翻译交接还包括 `translation-tasks.sqlite` 和版本化 `translation-handoff.json`; `translation.tasks` 查询单项 worker 状态,`translation.handoff` 查询完整 diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 4cc9753..c762a91 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -36,7 +36,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付 8. `bat-ffi` 已提供 Manifest inspect 和官方 sync plan 的可选无状态粗粒度 JSON C ABI helper。 9. 官方原版资源默认发布到 `./bat-resources`,汉化产物默认发布到独立的 `./bat-localized`;当前官方同步报告会标记 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布。 10. 官方同步校验完成并发布新 release 后会生成 `official-resource-changes.json`、`crowdin-translation-handoff.json`、`official-parse-cache.json`、`official-textunit-index.json`、`official-textunit-tasks.json` 和 `crowdin-textunit-queue.json`,用 Added/Modified 资源驱动后续解析/翻译增量;up-to-date 轮询在已有有效缓存、TextUnit 明细索引和队列时只读取摘要,不重复解析。 -11. issue 43 已补齐 Rust `bat` 的 `res` / `parse` / `i18n` 工作流:单次/限定次数/周期执行、版本化 schedule CRUD、离线翻译工作台、人工文本修改、既有 patch 能力的批量重打包和独立汉化 release 发布;schedule CRUD 已经通过 `bat.sock` 和 `bat-api` 管理接口暴露,dashboard 不维护第二套状态;该能力不扩大冻结期解析器覆盖。 +11. issue 43 已补齐 Rust `bat` 的 `res` / `parse` / `i18n` 工作流:单次/限定次数/周期执行、版本化 schedule CRUD 与作用域过滤、解析缓存清理、翻译工作台校验、离线翻译工作台、人工文本修改、既有 patch 能力的批量重打包和独立汉化 release 发布;schedule CRUD 已经通过 `bat.sock` 和 `bat-api` 管理接口暴露,dashboard 不维护第二套状态;该能力不扩大冻结期解析器覆盖。 12. 文档已整理:根目录保留入口文档,历史报告进入 `docs/reports/historical/`,误嵌套的 `docs/docs` 已合并。 ### 仍是骨架或占位 diff --git a/USERGUIDE.md b/USERGUIDE.md index 9f9a71f..17b7d69 100644 --- a/USERGUIDE.md +++ b/USERGUIDE.md @@ -47,9 +47,11 @@ 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 clear-cache` | 使用 `--force` 清理当前 release 的可再生解析缓存和翻译队列 | | `parse repack` | 根据 JSON spec 批量重打包 UnityFS bundle | | `i18n run` / `i18n export` | 刷新离线翻译队列或导出可编辑翻译工作台 | | `i18n set` | 手动修改一个翻译工作台条目 | +| `i18n validate` | 发布前校验工作台 release、source text 和 patch 目标 | | `i18n publish` | 校验工作台并发布独立汉化 release;`--force` 使用新的手动 release ID | | `i18n schedule` | 管理翻译和汉化发布计划 | | `refresh` | 执行一次更新检查;若有 live daemon,则通过 RPC 请求其刷新 | @@ -123,7 +125,7 @@ BAT_API_SKIP_ENV_FILE=1 go run ./cmd/bat-api \ | `GET/HEAD /prod-clientpatch.bluearchiveyostar.com/...` | 官方 CDN path 形态资源字节 | | `GET /openapi.yaml` | bat-api OpenAPI 文档 | | `GET /admin/` | 管理控制入口与允许操作列表 | -| `GET /admin/schedules` | 读取 Rust `bat` 调度计划;需要管理 token | +| `GET /admin/schedules?id=...&group=...&enabled=...` | 读取/过滤 Rust `bat` 调度计划;需要管理 token | | `POST /admin/control/{action}` | 经白名单转发 Rust `bat` 控制请求;见下文 | launcher 兼容端点只服务启动前资源发现。它们复用 Rust `bat` snapshot 中的 `launcher_metadata` 和 `game_main_config_bootstrap`,显式标记 `scope=resource_bootstrap_only` / `package_update_manifest=false`。`bat-api` 不下载 launcher 包,不生成官方 PC package update manifest,也不仿造登录、账号、网关、鉴权或游戏业务协议。 diff --git a/docs/guides/bat-workflows.md b/docs/guides/bat-workflows.md index e465e1a..5793060 100644 --- a/docs/guides/bat-workflows.md +++ b/docs/guides/bat-workflows.md @@ -52,6 +52,16 @@ bat parse run \ 解析结果会刷新 `official-parse-cache.json`、`official-textunit-index.json` 和翻译队列。`--force` 忽略已有解析缓存,但仍要求输入 release 已通过官方下载 manifest 校验。 +清理当前 release 的可再生解析缓存和离线翻译队列: + +```bash +bat parse clear-cache \ + --resource-root /tmp/bat-resources/versions/ \ + --force +``` + +该命令不会删除 `translation-tasks.sqlite`;worker 状态必须通过任务接口单独维护。 + 批量 UnityFS 重打包使用 JSON spec。spec 的 `schema_version` 当前为 `1`,支持 `text_asset`、`string_field` 和受支持的语义 `field` 操作: ```bash @@ -81,6 +91,17 @@ bat i18n set \ 也可以使用 `--translated-file` 读取 UTF-8 文本。工作台会保存 source text、release ID、TextUnit 目标和人工译文;发布前会重新读取当前 TextUnit 索引,拒绝过期 release、source text 或 patch 目标。 +发布前可只做工作台审计: + +```bash +bat i18n validate \ + --resource-root /tmp/bat-resources/versions/ \ + --translation-file /tmp/bat-workbench.json +``` + +报告会区分未审核、原文未变化、可直接 `i18n publish` 的 TextAsset,以及必须使用 +`parse repack` 的 TypeTree/嵌套 archive 条目。 + 发布汉化 release: ```bash @@ -123,7 +144,12 @@ 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` 会忽略到期时间立即执行指定计划。 +`res/parse/i18n schedule list` 默认只显示对应一级命令的计划;也可以用 +`--schedule-id`、`--schedule-enabled` 或 `--schedule-disabled` 过滤。计划删除和执行 +会校验一级命令作用域,避免误操作其他工作流。`schedule update` 可以用 +`--schedule-clear-every` 将周期计划改为单次计划;`schedule remove` 会删除计划。 +`schedule run --force` 会忽略到期时间立即执行指定计划,`--schedule-max-runs N` +限制本轮最多执行 N 个到期计划。 ## bat-api 调度接口 @@ -137,10 +163,12 @@ dashboard 通过 `bat-api` 转发到 Rust `bat.sock`,不维护第二份计划 `bat-api` 对应接口为 `GET /admin/schedules` 和 `POST /admin/control/schedule-add|schedule-update|schedule-remove|schedule-run`, -均要求配置 `BAT_API_AUTH_TOKEN` 并携带管理 token。请求字段沿用 Rust +均要求配置 `BAT_API_AUTH_TOKEN` 并携带管理 token。列表接口支持 `id`、`group`、 +`enabled` query 过滤;请求字段沿用 Rust contract:`id`、`group`、`action`、`args`、`next_run_unix_seconds`、 `delay_seconds`、`every_seconds`、`count`、`clear_args`、`clear_every`、 -`enabled`;`schedule.run` 额外接受 `force`。 +`enabled`;`schedule.list` 额外接受 `id`、`group`、`enabled` 过滤, +`schedule.run` 额外接受 `group`、`force` 和 `max_runs`。 ## 边界 diff --git a/docs/reference/rpc-backend-api.md b/docs/reference/rpc-backend-api.md index f71fa50..f3d4f8c 100644 --- a/docs/reference/rpc-backend-api.md +++ b/docs/reference/rpc-backend-api.md @@ -167,11 +167,11 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且 | 方法 | 状态 | params | data | |---|---|---|---| -| `schedule.list` | 已实现 | `null` | `{ "command": "schedule-list", "schedules": [...] }`。 | +| `schedule.list` | 已实现 | `null` 或 `{ "id": "...", "group": "res", "enabled": true }` | `{ "command": "schedule-list", "query": {...}, "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` 执行所有到期计划。 | +| `schedule.run` | 已实现 | `{ "id": "daily-pull", "group": "res", "force": true, "max_runs": 1 }`,字段可省略 | 到期或强制执行报告;省略 `id` 执行指定 group 的到期计划。 | 调度 mutation 字段如下: @@ -179,7 +179,7 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且 |---|---|---| | `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`。 | +| `action` | string | `res` 的 `pull/refresh/verify/repair`、`parse` 的 `run/repack/clear-cache`、`i18n` 的 `run/export/validate/publish`。 | | `args` | string[] | 目标工作流的 CLI 参数。 | | `next_run_unix_seconds` | uint64 | 指定下一次执行时间;不能和 `delay_seconds` 同时使用。 | | `delay_seconds` | uint64 | 从当前时间计算下一次执行时间。 | @@ -189,6 +189,8 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且 | `clear_every` | bool | update 时清除周期并转为单次计划。 | | `enabled` | bool | 启用或停用计划。 | +`schedule.run.max_runs` 必须大于 0,用于限制一次轮询最多领取的到期计划数。 + `count > 1` 必须和周期同时存在;`schedule.run` 的 `force=true` 只忽略 到期时间,不会绕过 `enabled=false`。每次执行前先持久化下一次状态,执行后 再持久化成功/失败和错误信息,避免进程中断后重复领取同一计划。 diff --git a/docs/reports/CURRENT_GAPS.md b/docs/reports/CURRENT_GAPS.md index c8f161b..acd8acf 100644 --- a/docs/reports/CURRENT_GAPS.md +++ b/docs/reports/CURRENT_GAPS.md @@ -167,7 +167,7 @@ issue 43 例外说明:新增的 `parse repack` 只编排已有 TextAsset、Typ - 通用 Binary/JSON/Text Patch crate 能力可作为后续发布流程输入。 - 文件级写入入口可用于隔离测试和上层工具显式产物生成。 -- 发布级通用 `patch build` / `patch rollback`、复杂 AssetBundle 重打包和完整翻译文件集合的正式使用仍未完成;issue 43 的 `parse repack` 与 `i18n publish` 仅覆盖已有 patch 实现支持的安全子集。 +- 发布级通用 `patch build` / `patch rollback`、复杂 AssetBundle 重打包和完整翻译文件集合的正式使用仍未完成;issue 43 的 `parse repack`、`parse clear-cache`、`i18n validate` 与 `i18n publish` 仅覆盖已有 patch 实现支持的安全子集。 验收: @@ -294,6 +294,7 @@ issue 43 例外说明:新增的 `parse repack` 只编排已有 TextAsset、Typ - `translation-tasks.sqlite` 由 `schema_migrations` 管理 durable task state、attempt count、provider run ID 和 failure reason;重复同步会保留已有 worker 状态。 - `translation.tasks` RPC/CLI 优先查询 `translation-tasks.sqlite`,旧 release 没有状态库时回退到 `official-textunit-tasks.json`;返回队列 `status`、worker `task_status`、failure reason 和时间/尝试次数。 - `translation.task.update` RPC 已提供 queued/running/failed/completed/skipped 状态回写契约,provider worker 可在消费 handoff 后按 task_id 更新并由同一查询接口反查。 +- `i18n validate` 可在发布前校验工作台 release、source text、重复 patch 目标,并区分可直接发布的 TextAsset 与必须进入 `parse repack` 的条目;`parse clear-cache` 只删除可再生解析/队列 JSON,保留 `translation-tasks.sqlite` 的 worker 状态。 - 真实 Crowdin 网络 worker、翻译记忆和完整 localized repack 仍属于后续翻译系统工作,不在当前 Rust 离线状态仓储范围内。 验收: diff --git a/infrastructure/src/bin/bat/app.rs b/infrastructure/src/bin/bat/app.rs index bf844f0..9f4c027 100644 --- a/infrastructure/src/bin/bat/app.rs +++ b/infrastructure/src/bin/bat/app.rs @@ -13,9 +13,9 @@ use bat_infrastructure::{ read_file_no_symlink, read_localized_patch_manifest_at, read_localized_version_state, read_parse_cache_at, read_snapshot, read_textunit_index_at, read_translation_workbench, read_version_state, redact_proxy_url, repack_bundle, resolve_curl_proxy, set_translation, - validate_output_root, validate_runtime_state_dir, write_file_atomic, - write_official_textunit_queues, CurlProxyConfig, CurlProxyMode, LocalizedPatchConfig, - LocalizedPatchReport, LocalizedPatchService, OfficialEndpointMarkerRole, + validate_output_root, validate_runtime_state_dir, validate_translation_workbench, + write_file_atomic, write_official_textunit_queues, CurlProxyConfig, CurlProxyMode, + LocalizedPatchConfig, LocalizedPatchReport, LocalizedPatchService, OfficialEndpointMarkerRole, OfficialFailedVersionRecord, OfficialParseCacheService, OfficialParseConfig, OfficialResourceHashVerification, OfficialResourceVerification, OfficialServerInfoSource, OfficialTextUnitQuery, OfficialTextUnitTaskQuery, OfficialUpdateConfig, OfficialUpdateProgress, @@ -24,10 +24,10 @@ use bat_infrastructure::{ PatchApplyParams, PatchApplyReport, ReleaseFlowStatusCode, RepackReport, SqliteResourceRepository, SqliteTranslationTaskRepository, TranslationTaskStatus, UnityFsFieldPatchParams, UnityFsPatchReport, UnityFsStringFieldPatchParams, - UnityFsTextAssetPatchParams, LOCALIZED_CURRENT_LINK, LOCALIZED_PATCH_MANIFEST_FILE, - LOCALIZED_VERSIONS_DIR, LOCALIZED_VERSION_STATE_FILE, MAX_DOWNLOAD_CONCURRENCY, - MIN_DOWNLOAD_CONCURRENCY, OFFICIAL_PARSE_CACHE_FILE, OFFICIAL_TEXTUNIT_INDEX_FILE, - PRIVATE_FILE_MODE, + UnityFsTextAssetPatchParams, CROWDIN_TEXTUNIT_QUEUE_FILE, 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, OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE, PRIVATE_FILE_MODE, }; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; @@ -79,7 +79,8 @@ use translation_query::{ update_translation_task_status_report, }; use workflow_commands::{ - run_parse_once, run_publish_localized, run_repack, run_translate_once, run_translation_set, + run_parse_clear_cache, run_parse_once, run_publish_localized, run_repack, run_translate_once, + run_translation_set, run_translation_validate, }; const EXIT_ERROR: i32 = 1; @@ -192,10 +193,18 @@ fn run() -> anyhow::Result { run_repeated_workflow(&options, "parse", run_parse_once)?; Ok(0) } + CliCommand::ParseClearCache => { + run_parse_clear_cache(&options)?; + Ok(0) + } CliCommand::Translate => { run_repeated_workflow(&options, "translate", run_translate_once)?; Ok(0) } + CliCommand::TranslationValidate => { + run_translation_validate(&options)?; + Ok(0) + } CliCommand::TranslationSet => { run_translation_set(&options)?; Ok(0) @@ -356,6 +365,7 @@ struct CliOptions { schedule_delay: Option, schedule_every: Option, schedule_count: Option, + schedule_max_runs: Option, schedule_args: Vec, schedule_clear_args: bool, schedule_clear_every: bool, @@ -438,6 +448,7 @@ impl Default for CliOptions { schedule_delay: None, schedule_every: None, schedule_count: None, + schedule_max_runs: None, schedule_args: Vec::new(), schedule_clear_args: false, schedule_clear_every: false, @@ -504,7 +515,9 @@ enum CliCommand { Run, Pull, Parse, + ParseClearCache, Translate, + TranslationValidate, TranslationSet, Repack, PublishLocalized, @@ -1762,11 +1775,31 @@ fn dispatch_rpc_method( "resource.state", build_resource_state_report(state_dir), ), - RPC_METHOD_SCHEDULE_LIST => rpc_envelope_from_result( - request_id, - RPC_METHOD_SCHEDULE_LIST, - schedule_commands::schedule_list_report(state_dir), - ), + RPC_METHOD_SCHEDULE_LIST => { + let params = request + .params + .as_ref() + .filter(|params| !params.is_null()) + .map(|params| serde_json::from_value(params.clone())) + .transpose() + .map_err(|error| { + ApiError::new( + ErrorCode::RPC_INVALID_PARAMS, + RPC_METHOD_SCHEDULE_LIST, + format!("params 无效:{error}"), + ) + }); + let params = match params { + Ok(Some(params)) => params, + Ok(None) => schedule_commands::ScheduleListRequest::default(), + Err(error) => return rpc_envelope_error(request_id, error), + }; + rpc_envelope_from_result( + request_id, + RPC_METHOD_SCHEDULE_LIST, + schedule_commands::schedule_list_report_with_request(state_dir, params), + ) + } RPC_METHOD_SCHEDULE_ADD => { let params = match rpc_struct_params::( request.params.as_ref(), @@ -6754,6 +6787,16 @@ fn parse_args_with_env( options.schedule_count = Some(value); options.schedule_option_explicit = true; } + "--schedule-max-runs" => { + let value = next_option_value(&mut args, &flag)? + .parse::() + .map_err(|error| anyhow::anyhow!("{flag} 无效:{error}"))?; + if value == 0 { + return Err(anyhow::anyhow!("--schedule-max-runs 必须大于 0")); + } + options.schedule_max_runs = Some(value); + options.schedule_option_explicit = true; + } "--schedule-arg" => { options .schedule_args @@ -7120,11 +7163,40 @@ fn parse_args_with_env( )); } } + CliCommand::ParseClearCache => { + if options.watch || options.daemon || options.daemon_child { + return Err(anyhow::anyhow!("parse clear-cache 只支持单次执行")); + } + if !options.config.force { + return Err(anyhow::anyhow!( + "parse clear-cache 是破坏性操作,必须显式指定 --force" + )); + } + if options.config.dry_run || options.run_count.is_some() { + return Err(anyhow::anyhow!( + "parse clear-cache 不支持 --dry-run 或 --run-count" + )); + } + options.progress = false; + options.banner = false; + } + CliCommand::TranslationValidate => { + if options.watch || options.daemon || options.daemon_child { + return Err(anyhow::anyhow!("i18n validate 只支持单次执行")); + } + if options.config.force || options.config.dry_run || options.run_count.is_some() { + return Err(anyhow::anyhow!( + "i18n validate 不支持 --force、--dry-run 或 --run-count" + )); + } + options.progress = false; + options.banner = false; + } 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 { + if options.config.force || options.sync_option_explicit || options.run_count.is_some() { return Err(anyhow::anyhow!("translation set/repack 不接受资源同步选项")); } options.progress = false; @@ -7151,6 +7223,11 @@ fn parse_args_with_env( if options.interval_explicit && !matches!(options.command, CliCommand::ScheduleRun) { return Err(anyhow::anyhow!("--interval 只适用于 schedule run 的轮询")); } + if options.schedule_max_runs.is_some() + && !matches!(options.command, CliCommand::ScheduleRun) + { + return Err(anyhow::anyhow!("--schedule-max-runs 只适用于 schedule run")); + } options.progress = false; options.banner = false; } @@ -7294,6 +7371,7 @@ fn parse_parse_command( "status" => CliCommand::ParseStatus, "text-units" => CliCommand::ParseTextUnits, "errors" => CliCommand::ParseErrors, + "clear-cache" => CliCommand::ParseClearCache, "schedule" => { options.schedule_group = Some("parse".to_string()); return parse_schedule_command(args, options); @@ -7315,6 +7393,7 @@ fn parse_translation_command( "run" => CliCommand::Translate, "export" => CliCommand::Translate, "set" => CliCommand::TranslationSet, + "validate" => CliCommand::TranslationValidate, "publish" => CliCommand::PublishLocalized, "tasks" => CliCommand::TranslationTasks, "handoff" => CliCommand::TranslationHandoff, @@ -7435,10 +7514,12 @@ fn print_usage(binary: &str) { eprintln!(" res pull Pull official resources once or repeatedly"); eprintln!(" res schedule Manage resource pull schedules (CLI/RPC/dashboard)"); eprintln!(" parse run Parse current official release"); + eprintln!(" parse clear-cache Clear regenerable parse and translation queue files"); eprintln!(" parse repack Repack a UnityFS bundle from a JSON spec"); eprintln!(" i18n run Refresh offline translation work"); eprintln!(" i18n export Export an editable translation workbench"); eprintln!(" i18n set Update one translation workbench entry"); + eprintln!(" i18n validate Validate workbench against the current official release"); 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"); @@ -7589,6 +7670,7 @@ fn print_usage(binary: &str) { eprintln!(" --schedule-delay Delay first execution from now"); eprintln!(" --schedule-every Period between executions"); eprintln!(" --schedule-count Bounded execution count"); + eprintln!(" --schedule-max-runs Maximum plans executed by one schedule run"); eprintln!(" --schedule-arg Argument passed to scheduled child command"); eprintln!(" --schedule-clear-args Clear args during schedule update"); eprintln!(" --schedule-clear-every Convert a periodic plan to one-shot"); diff --git a/infrastructure/src/bin/bat/app_tests.rs b/infrastructure/src/bin/bat/app_tests.rs index 6ee1a52..00e6a86 100644 --- a/infrastructure/src/bin/bat/app_tests.rs +++ b/infrastructure/src/bin/bat/app_tests.rs @@ -1,5 +1,8 @@ use super::*; -use crate::app::schedule_commands::read_schedule_file; +use crate::app::schedule_commands::{ + read_schedule_file, schedule_add_report, schedule_list_report_with_request, + ScheduleListRequest, ScheduleMutationRequest, +}; fn parse(values: &[&str]) -> anyhow::Result { parse_args_from(values.iter().map(|value| value.to_string())) @@ -201,6 +204,30 @@ fn grouped_workflow_commands_use_short_top_level_aliases() { .unwrap(); assert_eq!(options.command, CliCommand::Repack); assert_eq!(options.repack_spec, Some(PathBuf::from("/tmp/repack.json"))); + + let options = parse(&[ + "bat", + "parse", + "clear-cache", + "--resource-root", + "/tmp/official-release", + "--force", + ]) + .unwrap(); + assert_eq!(options.command, CliCommand::ParseClearCache); + assert!(options.config.force); + + let options = parse(&[ + "bat", + "i18n", + "validate", + "--resource-root", + "/tmp/official-release", + "--translation-file", + "/tmp/workbench.json", + ]) + .unwrap(); + assert_eq!(options.command, CliCommand::TranslationValidate); } #[test] @@ -233,16 +260,47 @@ fn grouped_command_long_aliases_and_schedule_options_are_accepted() { assert_eq!(options.schedule_count, Some(4)); assert_eq!(options.schedule_args, vec!["--auto-discover"]); - let options = parse(&["bat", "translate", "schedule", "run", "--force"]).unwrap(); + let options = parse(&[ + "bat", + "translate", + "schedule", + "run", + "--force", + "--schedule-max-runs", + "2", + ]) + .unwrap(); assert_eq!(options.command, CliCommand::ScheduleRun); assert_eq!(options.schedule_group.as_deref(), Some("i18n")); assert!(options.config.force); + assert_eq!(options.schedule_max_runs, Some(2)); } #[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", "clear-cache"]).is_err()); + assert!(parse(&[ + "bat", + "i18n", + "validate", + "--force", + "--translation-file", + "/tmp/workbench.json" + ]) + .is_err()); + assert!(parse(&[ + "bat", + "res", + "schedule", + "add", + "--schedule-id", + "pull", + "--schedule-max-runs", + "1" + ]) + .is_err()); assert!(parse(&["bat", "parse", "run", "--watch", "--run-count", "2"]).is_err()); assert!(parse(&["bat", "parse", "run", "--interval", "1m"]).is_err()); assert!(parse(&[ @@ -346,6 +404,59 @@ fn schedule_crud_persists_and_updates_a_workflow_plan() { .is_empty()); } +#[test] +fn schedule_scope_filters_and_protects_cross_workflow_mutations() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path(); + schedule_add_report( + state_dir, + ScheduleMutationRequest { + id: Some("res-pull".to_string()), + group: Some("res".to_string()), + action: Some("pull".to_string()), + delay_seconds: Some(1), + ..ScheduleMutationRequest::default() + }, + ) + .unwrap(); + schedule_add_report( + state_dir, + ScheduleMutationRequest { + id: Some("parse-run".to_string()), + group: Some("parse".to_string()), + action: Some("run".to_string()), + delay_seconds: Some(1), + ..ScheduleMutationRequest::default() + }, + ) + .unwrap(); + + let report = schedule_list_report_with_request( + state_dir, + ScheduleListRequest { + group: Some("res".to_string()), + ..ScheduleListRequest::default() + }, + ) + .unwrap(); + assert_eq!(report["schedules"].as_array().unwrap().len(), 1); + assert_eq!(report["schedules"][0]["id"], "res-pull"); + + let cross_workflow_remove = parse(&[ + "bat", + "res", + "schedule", + "remove", + "--state-dir", + state_dir.to_str().unwrap(), + "--schedule-id", + "parse-run", + ]) + .unwrap(); + assert!(run_schedule_remove(&cross_workflow_remove).is_err()); + assert_eq!(read_schedule_file(state_dir).unwrap().schedules.len(), 2); +} + #[test] fn dispatch_schedule_crud_uses_shared_state_file() { let temp = tempfile::TempDir::new().unwrap(); @@ -383,6 +494,24 @@ fn dispatch_schedule_crud_uses_shared_state_file() { let list = serde_json::to_value(list).unwrap(); assert_eq!(list["ok"], true); assert_eq!(list["data"]["schedules"][0]["id"], "rpc-pull"); + + let filtered = dispatch_rpc_method( + &rpc_request( + "schedule.list", + Some(serde_json::json!({ + "group": "res", + "enabled": true + })), + ), + state_dir, + &control, + &test_task_context(), + "req-schedule-list-filtered".to_string(), + ); + let filtered = serde_json::to_value(filtered).unwrap(); + assert_eq!(filtered["ok"], true); + assert_eq!(filtered["data"]["query"]["group"], "res"); + assert_eq!(filtered["data"]["schedules"].as_array().unwrap().len(), 1); } #[test] diff --git a/infrastructure/src/bin/bat/schedule_commands.rs b/infrastructure/src/bin/bat/schedule_commands.rs index 7604914..fd7f8d5 100644 --- a/infrastructure/src/bin/bat/schedule_commands.rs +++ b/infrastructure/src/bin/bat/schedule_commands.rs @@ -116,18 +116,37 @@ pub(super) struct ScheduleMutationRequest { pub(super) enabled: Option, } +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub(super) struct ScheduleListRequest { + #[serde(default, alias = "schedule_id")] + pub(super) id: Option, + #[serde(default)] + pub(super) group: Option, + #[serde(default)] + pub(super) enabled: Option, +} + #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub(super) struct ScheduleRunRequest { #[serde(default, alias = "schedule_id")] pub(super) id: Option, #[serde(default)] + pub(super) group: Option, + #[serde(default)] pub(super) force: bool, + #[serde(default)] + pub(super) max_runs: Option, } pub(super) fn run_schedule_list(options: &CliOptions) -> anyhow::Result<()> { + let request = ScheduleListRequest { + id: options.schedule_id.clone(), + group: options.schedule_group.clone(), + enabled: options.schedule_enabled, + }; print_json_value( options.output_format, - &schedule_list_report(&options.state_dir)?, + &schedule_list_report_with_request(&options.state_dir, request)?, ) } @@ -163,7 +182,9 @@ pub(super) fn run_schedule_run(options: &CliOptions) -> anyhow::Result<()> { loop { let request = ScheduleRunRequest { id: options.schedule_id.clone(), + group: options.schedule_group.clone(), force: options.config.force, + max_runs: options.schedule_max_runs, }; print_json_value( options.output_format, @@ -176,17 +197,37 @@ pub(super) fn run_schedule_run(options: &CliOptions) -> anyhow::Result<()> { } } -pub(super) fn schedule_list_report(state_dir: &Path) -> anyhow::Result { +pub(super) fn schedule_list_report_with_request( + state_dir: &Path, + request: ScheduleListRequest, +) -> anyhow::Result { let _guard = SCHEDULE_FILE_LOCK .lock() .unwrap_or_else(|poison| poison.into_inner()); let _schedule_lock = ScheduleFileLock::acquire(state_dir)?; let file = read_schedule_file(state_dir)?; + let group = request + .group + .as_deref() + .map(normalize_schedule_group) + .transpose()?; + let schedules = file + .schedules + .iter() + .filter(|entry| request.id.as_deref().is_none_or(|id| id == entry.id)) + .filter(|entry| group.as_deref().is_none_or(|group| group == entry.group)) + .filter(|entry| { + request + .enabled + .is_none_or(|enabled| enabled == entry.enabled) + }) + .collect::>(); Ok(serde_json::json!({ "command": "schedule-list", "status": "ok", "state_file": schedule_file_path(state_dir), - "schedules": file.schedules, + "query": request, + "schedules": schedules, })) } @@ -312,11 +353,27 @@ pub(super) fn schedule_remove_report( .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}")); + let group = request + .group + .as_deref() + .map(normalize_schedule_group) + .transpose()?; + let index = file + .schedules + .iter() + .position(|entry| entry.id == id) + .ok_or_else(|| anyhow::anyhow!("schedule 不存在:{id}"))?; + if let Some(group) = group { + if file.schedules[index].group != group { + return Err(anyhow::anyhow!( + "schedule {} 属于 {},不能从 {} 二级命令删除", + id, + file.schedules[index].group, + group + )); + } } + file.schedules.remove(index); write_schedule_file(state_dir, &file)?; Ok(serde_json::json!({ "command": "schedule-remove", @@ -337,13 +394,37 @@ pub(super) fn schedule_run_report( let now = unix_seconds_now(); let selected_id = request.id.as_deref(); let mut file = read_schedule_file(state_dir)?; + let group = request + .group + .as_deref() + .map(normalize_schedule_group) + .transpose()?; + if let (Some(id), Some(group)) = (selected_id, group.as_deref()) { + if let Some(entry) = file.schedules.iter().find(|entry| entry.id == id) { + if entry.group != group { + return Err(anyhow::anyhow!( + "schedule {} 属于 {},不能从 {} 二级命令执行", + id, + entry.group, + group + )); + } + } + } + if request.max_runs == Some(0) { + return Err(anyhow::anyhow!("max_runs 必须大于 0")); + } let mut results = Vec::new(); for index in 0..file.schedules.len() { + if request.max_runs.is_some_and(|max| results.len() >= max) { + break; + } 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) + && group.as_deref().is_none_or(|group| group == entry.group) }; if !due { continue; @@ -540,6 +621,12 @@ fn validate_schedule_command_options( if options.schedule_count == Some(0) { return Err(anyhow::anyhow!("--schedule-count 必须大于 0")); } + if options.schedule_max_runs == Some(0) { + return Err(anyhow::anyhow!("--schedule-max-runs 必须大于 0")); + } + if options.schedule_max_runs.is_some() && !matches!(options.command, CliCommand::ScheduleRun) { + return Err(anyhow::anyhow!("--schedule-max-runs 只适用于 schedule run")); + } if options.schedule_clear_args && !matches!(options.command, CliCommand::ScheduleUpdate) { return Err(anyhow::anyhow!( "--schedule-clear-args 只适用于 schedule update" @@ -556,8 +643,8 @@ fn validate_schedule_command_options( 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"), + "parse" => matches!(action, "run" | "repack" | "clear-cache"), + "i18n" => matches!(action, "run" | "export" | "validate" | "publish"), _ => false, }; if valid { diff --git a/infrastructure/src/bin/bat/workflow_commands.rs b/infrastructure/src/bin/bat/workflow_commands.rs index 3295bc8..a9fddfe 100644 --- a/infrastructure/src/bin/bat/workflow_commands.rs +++ b/infrastructure/src/bin/bat/workflow_commands.rs @@ -28,6 +28,38 @@ pub(super) fn run_parse_once(options: &CliOptions) -> anyhow::Result<()> { ) } +pub(super) fn run_parse_clear_cache(options: &CliOptions) -> anyhow::Result<()> { + let (resource_root, release_id) = current_official_release(options)?; + let artifact_names = [ + OFFICIAL_PARSE_CACHE_FILE, + OFFICIAL_TEXTUNIT_INDEX_FILE, + OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE, + CROWDIN_TEXTUNIT_QUEUE_FILE, + ]; + let mut removed = Vec::new(); + for name in artifact_names { + let path = resource_root.join(name); + if remove_regenerable_file(&path)? { + removed.push(path); + } + } + let data = serde_json::json!({ + "official_release_id": release_id, + "resource_root": resource_root, + "removed": removed, + "translation_task_repository_preserved": true, + }); + print_report( + options.output_format, + &CommandReport { + command: "parse-clear-cache", + status: "cleared", + message: "当前官方 release 的可再生解析缓存和翻译队列已清理", + 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)?; @@ -64,6 +96,23 @@ pub(super) fn run_translate_once(options: &CliOptions) -> anyhow::Result<()> { ) } +pub(super) fn run_translation_validate(options: &CliOptions) -> anyhow::Result<()> { + let path = options + .translation_file + .as_ref() + .ok_or_else(|| anyhow::anyhow!("i18n validate 必须指定 --translation-file"))?; + let (resource_root, release_id) = current_official_release(options)?; + let workbench = read_translation_workbench(path)?; + let validation = validate_translation_workbench(&resource_root, &release_id, &workbench)?; + let data = serde_json::json!({ + "official_release_id": release_id, + "resource_root": resource_root, + "translation_file": path, + "validation": validation, + }); + print_json_value(options.output_format, &data) +} + pub(super) fn run_translation_set(options: &CliOptions) -> anyhow::Result<()> { let path = options .translation_file @@ -191,3 +240,25 @@ fn current_official_release(options: &CliOptions) -> anyhow::Result<(PathBuf, St } Ok((resource_root, release_id)) } + +fn remove_regenerable_file(path: &Path) -> anyhow::Result { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error.into()), + }; + if metadata.file_type().is_symlink() { + return Err(anyhow::anyhow!( + "拒绝删除符号链接形式的可再生文件:{}", + path.display() + )); + } + if !metadata.is_file() { + return Err(anyhow::anyhow!( + "可再生缓存路径不是普通文件:{}", + path.display() + )); + } + fs::remove_file(path)?; + Ok(true) +} diff --git a/infrastructure/src/lib.rs b/infrastructure/src/lib.rs index 434fc72..d927a76 100644 --- a/infrastructure/src/lib.rs +++ b/infrastructure/src/lib.rs @@ -141,9 +141,9 @@ pub use translation_tasks::{ }; 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, + repack_bundle, set_translation, validate_translation_workbench, write_translation_workbench, + RepackOperation, RepackReport, RepackSpec, TranslationWorkbench, TranslationWorkbenchEntry, + TranslationWorkbenchValidationReport, REPACK_SPEC_VERSION, TRANSLATION_WORKBENCH_VERSION, }; /// Infrastructure 版本号 diff --git a/infrastructure/src/translation_workflow.rs b/infrastructure/src/translation_workflow.rs index bafee1d..d9968ae 100644 --- a/infrastructure/src/translation_workflow.rs +++ b/infrastructure/src/translation_workflow.rs @@ -11,7 +11,7 @@ use bat_assetbundle::{ StringFieldPatch, TextAssetPatch, UnitySerializedReplacementValue, }; use serde::{Deserialize, Serialize}; -use std::collections::BTreeSet; +use std::collections::{BTreeSet, HashMap}; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -66,6 +66,27 @@ pub struct TranslationWorkbenchEntry { pub text_source_kind: Option, } +/// Summary produced by `i18n validate`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct TranslationWorkbenchValidationReport { + /// Stable command name. + pub command: &'static str, + /// Validation status. + pub status: &'static str, + /// Number of entries in the workbench. + pub total_entries: usize, + /// Entries without a human translation. + pub unreviewed_entries: usize, + /// Entries whose translation equals the source text. + pub unchanged_entries: usize, + /// Entries with a changed translation. + pub changed_entries: usize, + /// Changed direct TextAsset entries usable by `i18n publish`. + pub publishable_entries: usize, + /// Changed TypeTree or nested-archive entries requiring `parse repack`. + pub repack_entries: usize, +} + /// Exports the current official TextUnit index as an editable workbench. pub fn export_translation_workbench( resource_root: &Path, @@ -145,6 +166,99 @@ pub fn set_translation( Ok(updated) } +/// Validates a workbench against the current official TextUnit index. +/// +/// This checks the release identity and every stored source/target location +/// before a publish operation. Unsupported patch targets are reported as +/// `repack_entries` so reviewers can choose the appropriate command. +pub fn validate_translation_workbench( + resource_root: &Path, + official_release_id: &str, + workbench: &TranslationWorkbench, +) -> anyhow::Result { + let expected_root = lexical_absolute(resource_root).map_err(anyhow::Error::msg)?; + if workbench.official_release_id != official_release_id { + return Err(anyhow::anyhow!( + "翻译工作台 release={} 与当前官方 release={} 不一致;请重新导出", + workbench.official_release_id, + official_release_id + )); + } + if workbench.official_resource_root != expected_root { + return Err(anyhow::anyhow!( + "翻译工作台资源根目录与当前 release 不一致;请重新导出" + )); + } + let index = read_textunit_index_at(resource_root) + .map_err(anyhow::Error::msg)? + .ok_or_else(|| anyhow::anyhow!("缺少当前官方 TextUnit 索引"))?; + let index_by_id = index + .units + .iter() + .map(|unit| (unit.id.as_str(), unit)) + .collect::>(); + let mut seen_ids = BTreeSet::new(); + let mut seen_patch_targets = BTreeSet::new(); + let mut unreviewed_entries = 0; + let mut unchanged_entries = 0; + let mut changed_entries = 0; + let mut publishable_entries = 0; + let mut repack_entries = 0; + + for entry in &workbench.entries { + if !seen_ids.insert(entry.id.as_str()) { + return Err(anyhow::anyhow!("翻译工作台包含重复 TextUnit:{}", entry.id)); + } + let current = index_by_id + .get(entry.id.as_str()) + .ok_or_else(|| anyhow::anyhow!("翻译工作台条目不属于当前 release:{}", entry.id))?; + validate_workbench_entry(entry, current)?; + let Some(translated_text) = entry.translated_text.as_ref() else { + unreviewed_entries += 1; + continue; + }; + if translated_text == &entry.source_text { + unchanged_entries += 1; + continue; + } + changed_entries += 1; + let is_publishable = entry.archive_entry.is_none() + && entry.text_source_kind.as_deref() == Some("text_asset"); + if is_publishable { + let serialized_file = entry + .serialized_file + .as_ref() + .ok_or_else(|| anyhow::anyhow!("TextUnit {} 没有 serialized_file", entry.id))?; + let path_id = entry + .path_id + .ok_or_else(|| anyhow::anyhow!("TextUnit {} 没有 path_id", entry.id))?; + if !seen_patch_targets.insert(( + entry.destination.clone(), + serialized_file.clone(), + path_id, + )) { + return Err(anyhow::anyhow!( + "翻译工作台包含重复 patch 目标:{}", + entry.id + )); + } + publishable_entries += 1; + } else { + repack_entries += 1; + } + } + Ok(TranslationWorkbenchValidationReport { + command: "translation-validate", + status: "valid", + total_entries: workbench.entries.len(), + unreviewed_entries, + unchanged_entries, + changed_entries, + publishable_entries, + repack_entries, + }) +} + /// Converts reviewed direct TextAsset entries to localized patch operations. /// /// TypeTree fields and zip-inner bundles are intentionally rejected here. @@ -541,4 +655,44 @@ mod tests { let error = set_translation(&path, "missing", "译文".to_string()).unwrap_err(); assert!(error.to_string().contains("不存在 TextUnit")); } + + #[test] + fn validation_reports_publishable_and_unreviewed_entries() { + let temp = tempfile::TempDir::new().unwrap(); + let index = crate::official_parse::OfficialTextUnitIndex { + version: crate::official_parse::OFFICIAL_TEXTUNIT_INDEX_VERSION, + generated_unix_seconds: 1, + resource_root: temp.path().to_path_buf(), + summary: Default::default(), + units: vec![OfficialTextUnitIndexUnit { + id: "unit-1".to_string(), + parse_entry_key: "bundle".to_string(), + source_url: "https://example.invalid/bundle".to_string(), + destination: "bundles/test.bundle".to_string(), + archive_entry: None, + source_kind: crate::official_parse::OfficialParseSourceKind::DirectBundle, + unity_version: None, + source_text: "原文".to_string(), + serialized_file: Some("CAB-test".to_string()), + path_id: Some(7), + class_id: Some(49), + field_path: None, + field_offset: None, + field_byte_size: None, + format: Some("plain".to_string()), + text_source_kind: Some("text_asset".to_string()), + asset_name: Some("Story".to_string()), + context: Default::default(), + }], + errors: Vec::new(), + }; + crate::official_parse::write_textunit_index_at(temp.path(), &index).unwrap(); + let mut wb = workbench(temp.path()); + wb.entries[0].translated_text = Some("译文".to_string()); + let report = validate_translation_workbench(temp.path(), "release-1", &wb).unwrap(); + assert_eq!(report.total_entries, 1); + assert_eq!(report.changed_entries, 1); + assert_eq!(report.publishable_entries, 1); + assert_eq!(report.unreviewed_entries, 0); + } } diff --git a/internal/api/admin.go b/internal/api/admin.go index 227dd10..cd24dff 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -6,6 +6,7 @@ import ( "errors" "io" "net/http" + "strconv" "strings" "bat-api/internal/backendrpc" @@ -161,7 +162,12 @@ func (s *Server) handleAdminSchedules(w http.ResponseWriter, r *http.Request) { writeErrorJSON(w, http.StatusServiceUnavailable, "schedule_backend_unavailable", "Rust bat schedule backend is unavailable") return } - result, err := backend.ScheduleList(r.Context()) + params, err := scheduleListParams(r) + if err != nil { + writeErrorJSON(w, http.StatusBadRequest, "invalid_schedule_query", err.Error()) + return + } + result, err := backend.ScheduleList(r.Context(), params) if err != nil { s.writeControlBackendError(w, "schedule-list", err) return @@ -174,6 +180,22 @@ func (s *Server) handleAdminSchedules(w http.ResponseWriter, r *http.Request) { writeNoStoreJSON(w, http.StatusOK, result) } +func scheduleListParams(r *http.Request) (backendrpc.ScheduleListParams, error) { + query := r.URL.Query() + params := backendrpc.ScheduleListParams{ + ID: query.Get("id"), + Group: query.Get("group"), + } + if raw := query.Get("enabled"); raw != "" { + enabled, err := strconv.ParseBool(raw) + if err != nil { + return backendrpc.ScheduleListParams{}, errors.New("enabled must be a boolean") + } + params.Enabled = &enabled + } + return params, nil +} + func (s *Server) handleAdminScheduleControl(w http.ResponseWriter, r *http.Request, action string) { backend, ok := s.backend.(ScheduleBackend) if !ok || backend == nil { diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 2bacfda..cccc6f5 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -590,12 +590,14 @@ func (b *controlBackend) CatalogRefresh(ctx context.Context, force bool) (*backe type scheduleBackend struct { *controlBackend - scheduleCalls []string - scheduleRaw json.RawMessage + scheduleCalls []string + scheduleListParams []backendrpc.ScheduleListParams + scheduleRaw json.RawMessage } -func (b *scheduleBackend) ScheduleList(ctx context.Context) (json.RawMessage, error) { +func (b *scheduleBackend) ScheduleList(ctx context.Context, params backendrpc.ScheduleListParams) (json.RawMessage, error) { b.scheduleCalls = append(b.scheduleCalls, "schedule.list") + b.scheduleListParams = append(b.scheduleListParams, params) return b.scheduleRaw, nil } @@ -631,7 +633,7 @@ func TestAdminScheduleEndpointsProxyAuthenticatedRequests(t *testing.T) { } s := NewServer(cfg, backend, nil) - request := httptest.NewRequest(http.MethodGet, "/admin/schedules", nil) + request := httptest.NewRequest(http.MethodGet, "/admin/schedules?id=nightly-pull&group=res&enabled=true", nil) request.Header.Set("Authorization", "Bearer schedule-token") recorder := httptest.NewRecorder() s.Handler().ServeHTTP(recorder, request) @@ -641,6 +643,13 @@ func TestAdminScheduleEndpointsProxyAuthenticatedRequests(t *testing.T) { if !json.Valid(recorder.Body.Bytes()) { t.Fatalf("list body is not JSON: %s", recorder.Body.String()) } + if len(backend.scheduleListParams) != 1 || + backend.scheduleListParams[0].ID != "nightly-pull" || + backend.scheduleListParams[0].Group != "res" || + backend.scheduleListParams[0].Enabled == nil || + !*backend.scheduleListParams[0].Enabled { + t.Fatalf("list params=%#v", backend.scheduleListParams) + } request = httptest.NewRequest( http.MethodPost, @@ -676,6 +685,14 @@ func TestAdminScheduleEndpointsProxyAuthenticatedRequests(t *testing.T) { if recorder.Code != http.StatusUnauthorized { t.Fatalf("unauthenticated list status=%d body=%s", recorder.Code, recorder.Body.String()) } + + request = httptest.NewRequest(http.MethodGet, "/admin/schedules?enabled=invalid", nil) + request.Header.Set("Authorization", "Bearer schedule-token") + recorder = httptest.NewRecorder() + s.Handler().ServeHTTP(recorder, request) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("invalid query status=%d body=%s", recorder.Code, recorder.Body.String()) + } } func TestDiscoverCallsStatusBeforeDoctor(t *testing.T) { diff --git a/internal/api/openapi.go b/internal/api/openapi.go index 90a8f5f..3d71fe1 100644 --- a/internal/api/openapi.go +++ b/internal/api/openapi.go @@ -118,6 +118,20 @@ paths: /admin/schedules: get: summary: List Rust-owned resource workflow schedules + parameters: + - name: id + in: query + schema: + type: string + - name: group + in: query + schema: + type: string + enum: [res, parse, i18n] + - name: enabled + in: query + schema: + type: boolean responses: "200": description: Current schedule JSON report. @@ -167,6 +181,10 @@ paths: count: type: integer format: int64 + max_runs: + type: integer + format: int64 + minimum: 1 clear_args: type: boolean clear_every: diff --git a/internal/api/rpc_release.go b/internal/api/rpc_release.go index bb77b41..c70cc73 100644 --- a/internal/api/rpc_release.go +++ b/internal/api/rpc_release.go @@ -42,7 +42,7 @@ type ControlBackend interface { // 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) + ScheduleList(ctx context.Context, params backendrpc.ScheduleListParams) (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) @@ -90,8 +90,8 @@ func (r RPCClient) ResourceRepair(ctx context.Context) (*backendrpc.TaskAccepted func (r RPCClient) CatalogRefresh(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error) { return r.Client.CatalogRefresh(ctx, force) } -func (r RPCClient) ScheduleList(ctx context.Context) (json.RawMessage, error) { - return r.Client.ScheduleList(ctx) +func (r RPCClient) ScheduleList(ctx context.Context, params backendrpc.ScheduleListParams) (json.RawMessage, error) { + return r.Client.ScheduleListFiltered(ctx, params) } func (r RPCClient) ScheduleAdd(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error) { return r.Client.ScheduleAdd(ctx, params) diff --git a/internal/backendrpc/client.go b/internal/backendrpc/client.go index 1358112..3443d88 100644 --- a/internal/backendrpc/client.go +++ b/internal/backendrpc/client.go @@ -249,10 +249,19 @@ type ScheduleMutationParams struct { Enabled *bool `json:"enabled,omitempty"` } +// ScheduleListParams filters the Rust-owned schedule store. +type ScheduleListParams struct { + ID string `json:"id,omitempty"` + Group string `json:"group,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"` + ID string `json:"id,omitempty"` + Group string `json:"group,omitempty"` + Force bool `json:"force,omitempty"` + MaxRuns *uint64 `json:"max_runs,omitempty"` } // Ack is returned by accepted daemon control methods. @@ -454,6 +463,10 @@ func (c *Client) ScheduleList(ctx context.Context) (json.RawMessage, error) { return c.rawData(ctx, "schedule.list", nil) } +func (c *Client) ScheduleListFiltered(ctx context.Context, params ScheduleListParams) (json.RawMessage, error) { + return c.rawData(ctx, "schedule.list", params) +} + func (c *Client) ScheduleAdd(ctx context.Context, params ScheduleMutationParams) (json.RawMessage, error) { return c.rawData(ctx, "schedule.add", params) } diff --git a/internal/backendrpc/client_test.go b/internal/backendrpc/client_test.go index 27ed6eb..bb91d95 100644 --- a/internal/backendrpc/client_test.go +++ b/internal/backendrpc/client_test.go @@ -298,6 +298,44 @@ func TestScheduleUpdateSendsMutationParams(t *testing.T) { } } +func TestScheduleRunSendsScopeAndMaxRuns(t *testing.T) { + maxRuns := uint64(2) + client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { + if req.Method != "schedule.run" { + t.Fatalf("method = %s", req.Method) + } + var params ScheduleRunParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + t.Fatalf("decode params: %v", err) + } + if params.ID != "nightly-pull" || params.Group != "res" || !params.Force || + params.MaxRuns == nil || *params.MaxRuns != maxRuns { + t.Fatalf("params = %#v", params) + } + return testResponse{ + Result: testEnvelope{ + OK: true, + Status: "ok", + RequestID: "req-test-schedule-run", + Data: map[string]any{"command": "schedule-run", "executed": []any{}}, + }, + } + }) + + raw, err := client.ScheduleRun(context.Background(), ScheduleRunParams{ + ID: "nightly-pull", + Group: "res", + Force: true, + MaxRuns: &maxRuns, + }) + if err != nil { + t.Fatalf("ScheduleRun error: %v", err) + } + if !json.Valid(raw) { + t.Fatalf("invalid raw JSON: %s", string(raw)) + } +} + func TestApplicationErrorReturnsAPIError(t *testing.T) { client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { if req.Method != "task.status" { diff --git a/web/admin/README.md b/web/admin/README.md index 87a0fb7..25fcd8d 100644 --- a/web/admin/README.md +++ b/web/admin/README.md @@ -6,6 +6,7 @@ returns JSON links for health, readiness, bootstrap, release, resources, and OpenAPI. `GET /admin/schedules` and mutating `/admin/control/schedule-{add,update,remove,run}` routes are protected by the configured admin token and forward the Rust-owned schedule contract. +`GET /admin/schedules` accepts optional `id`, `group`, and `enabled` filters. The production panel must reuse the same HTTP authentication, rate limiting, access logging, reverse-proxy handling, and no-store dynamic response policy as