From 974a6e18c3b8e6c897873b61d67492eaed437714 Mon Sep 17 00:00:00 2001 From: Yuyi-Oak <1722157266@qq.com> Date: Wed, 12 Aug 2026 09:11:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(bat):=20=E6=9A=B4=E9=9C=B2=E7=BF=BB?= =?UTF-8?q?=E8=AF=91=E4=BB=BB=E5=8A=A1=E7=8A=B6=E6=80=81=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #43 --- CURRENT_STATUS.md | 4 +- PROJECT_PLAN.md | 2 +- USERGUIDE.md | 1 + docs/guides/bat-workflows.md | 21 +++++ docs/reference/rpc-backend-api.md | 10 +- docs/reports/CURRENT_GAPS.md | 2 +- docs/reports/GO_STATUS.md | 2 +- infrastructure/src/bin/bat/app.rs | 92 ++++++++++++++++++- infrastructure/src/bin/bat/app_tests.rs | 59 ++++++++++++ .../src/bin/bat/workflow_commands.rs | 29 ++++++ internal/api/admin.go | 45 ++++++++- internal/api/api_test.go | 14 +++ internal/api/openapi.go | 10 +- internal/api/rpc_release.go | 9 ++ internal/backendrpc/client.go | 13 +++ internal/backendrpc/client_test.go | 39 ++++++++ web/admin/README.md | 2 + 17 files changed, 341 insertions(+), 13 deletions(-) diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md index bd8b17d..908bcfd 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 查询现在按一级工作流过滤,删除/执行会校验作用域,单轮执行可限制计划数;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` 的 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` 查询完整 @@ -293,7 +293,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)、`schedule.*`(list/add/update/remove/run)、`parse.*`(status/text_units/errors)、`translation.*`(tasks/handoff/task.update)、`localized.*`(status)、`catalog.*`(status/refresh/diff/versions)、`task.*`(status/list/cancel/logs)、文件级 `patch.apply` 与 `unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field` 已实现,长任务返回 `task_id` 可轮询(任务执行器单 worker FIFO,与 watch 循环互斥;任务历史持久化于 `/bat-tasks.json`,daemon 重启后仍可查,中断任务标记 `task_interrupted`);错误码已接入下载、launcher/metadata、server-info/marker 与配置校验路径。剩余:发布级 `patch build` / `patch rollback`、复杂 `unityfs.*` 语义编辑、`task.create`(按设计由语义方法创建)、`daemon.clean-stable`(由 CLI 侧按进程生命周期显式执行,live RPC 内不做在线清理)、Redis 任务后端(`.env` 已预留配置键,接入时机另议)。Go 层通过 RPC 调用 Rust backend,不走 FFI(FFI 降级说明见 `docs/architecture/official-resource-backend.md` §7)。 2. Go 侧:进度见 `docs/reports/GO_STATUS.md`。G-008 已关闭;`bat-api` 资源 bootstrap/分发 MVP 已落地,已含 `/v1/bootstrap`、`/v1/launcher/bootstrap`、launcher 资源 metadata 兼容、HTTP 鉴权/限流/日志/反代适配、动态 JSON no-store、OpenAPI、管理控制白名单、CDN Range/缓存头、RPC 周期刷新和 USERGUIDE 基础章节;仓库内 Rust/Go snapshot contract fixture 已落地,剩余为远程服务器全量 release 联调、refresh mtime/size 增量缓存和可选持久化。 -3. 文本提取 / 翻译队列 / Patch 输入:`official-textunit-index.json`、`official-textunit-tasks.json` 与 `crowdin-textunit-queue.json` 已生成;TextUnit 明细、解析错误和离线翻译任务状态/失败原因已可查询,`translation.task.update` 已提供 worker 状态回写 contract。剩余为真实 Crowdin/provider worker、翻译记忆和 Patch 构建。 +3. 文本提取 / 翻译队列 / Patch 输入:`official-textunit-index.json`、`official-textunit-tasks.json` 与 `crowdin-textunit-queue.json` 已生成;TextUnit 明细、解析错误和离线翻译任务状态/失败原因已可查询,`translation.task.update` 已提供 Rust CLI、Go typed helper 和 bat-api dashboard 状态回写入口。剩余为真实 Crowdin/provider worker、翻译记忆和 Patch 构建。 4. Issue #3(P1):AssetBundle UnityFS 基础解析校验已具备离线和隔离真实样本覆盖;对象级解析继续跟踪 G-005。 5. Issue #2(P1):继续逆向 Addressables catalog,提取 bundle hash/size/CRC 等可校验字段。 6. 通用 Binary/JSON/Text Patch 基础已落地;复杂 AssetBundle 重打包和真实翻译系统仍应后置,UnityFS TextAsset patch 发布前置已具备回归测试。 diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index c762a91..fd835a6 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 与 `translation.task.update` 已经通过 `bat.sock` 和 `bat-api` 管理接口暴露,dashboard 不维护第二套状态;该能力不扩大冻结期解析器覆盖。 12. 文档已整理:根目录保留入口文档,历史报告进入 `docs/reports/historical/`,误嵌套的 `docs/docs` 已合并。 ### 仍是骨架或占位 diff --git a/USERGUIDE.md b/USERGUIDE.md index 17b7d69..bf67b2f 100644 --- a/USERGUIDE.md +++ b/USERGUIDE.md @@ -155,6 +155,7 @@ launcher 兼容端点只服务启动前资源发现。它们复用 Rust `bat` sn | `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` + 执行报告 | +| `translation-task-update` | `translation.task.update` | `{ "task_id": "...", "status": "...", "failure_reason": "...", "provider_run_id": "..." }` | `202` + 当前任务记录 | `stop`、`clean-stable`、patch 和 UnityFS 写入命令不会经 HTTP 暴露。 diff --git a/docs/guides/bat-workflows.md b/docs/guides/bat-workflows.md index 5793060..de00922 100644 --- a/docs/guides/bat-workflows.md +++ b/docs/guides/bat-workflows.md @@ -115,6 +115,22 @@ bat i18n publish \ 当前 `i18n run` 是离线工作流:刷新 TextUnit 队列,并可用 `--translation-file` 导出工作台;不会假装调用未接入的外部 provider。 +provider worker 消费 handoff 后,可以用 `i18n task update` 回写当前 release 的任务状态: + +```bash +bat i18n task update \ + --state-dir /tmp/bat-state \ + --task-id textunit/v-current/TextAssets/Scenario.json \ + --task-status failed \ + --failure-reason "provider rejected payload" \ + --provider-run-id provider-run-1 \ + --json +``` + +`--task-status` 支持 Rust contract 中的 `queued`、`running`、`failed`、 +`completed` 和 `skipped`;命令只更新当前 release 的 +`translation-tasks.sqlite`,不会创建任意翻译任务。 + ## 持久化调度 每个一级工作流都可以管理自己的 schedule。调度计划保存在 `--state-dir/bat-schedules.json`,计划记录包含动作、参数、下一次执行时间、周期、剩余次数、启用状态和最近错误。 @@ -170,6 +186,11 @@ contract:`id`、`group`、`action`、`args`、`next_run_unix_seconds`、 `enabled`;`schedule.list` 额外接受 `id`、`group`、`enabled` 过滤, `schedule.run` 额外接受 `group`、`force` 和 `max_runs`。 +翻译任务状态也可由已鉴权的 dashboard 通过 +`POST /admin/control/translation-task-update` 回写,请求字段为 +`task_id`、`status`,以及可选的 `failure_reason`、`provider_run_id`; +该接口只转发 `translation.task.update`。 + ## 边界 解析器新增类型覆盖和新的解析格式仍受 `docs/reports/PARSER_FREEZE.md` 约束。本次 issue 43 的例外只开放已有解析输出的手动编排、缓存刷新、工作台编辑、既有 patch 实现的重打包和独立汉化发布,不扩展 UnityFS/AssetBundle/Addressables/TypeTree 的解析类型覆盖。 diff --git a/docs/reference/rpc-backend-api.md b/docs/reference/rpc-backend-api.md index f3d4f8c..9287ebf 100644 --- a/docs/reference/rpc-backend-api.md +++ b/docs/reference/rpc-backend-api.md @@ -391,6 +391,7 @@ CLI 对应关系: | `bat parse-errors` | `parse.errors` | | `bat translation-tasks` | `translation.tasks` | | `bat translation-handoff` | `translation.handoff` | +| `bat i18n task update` | `translation.task.update` | | `bat localized-status` | `localized.status` | | `bat resource-index` | `resource.index` | @@ -414,11 +415,11 @@ CLI 对应关系: - typed helper 已覆盖 daemon 已实现方法(`status/logs/stop/restart/reload/refresh/doctor`)、 `resource.state/sync/verify/repair/manifest/list`、`schedule.list/add/update/remove/run`、 `catalog.*`、`parse.*`、 - `localized.status`、`task.*` 和三个 + `localized.status`、`translation.task.update`、`task.*` 和三个 `unityfs.patch_*` 方法。 -- `resource.index`、`translation.tasks`、`translation.handoff`、 - `translation.task.update` 和 `patch.apply` 当前没有专用 typed helper; - 需要直接使用 `Call`,并仍须遵守本契约的参数和响应定义。 +- `resource.index`、`translation.tasks`、`translation.handoff` 和 + `patch.apply` 当前没有专用 typed helper;需要直接使用 `Call`,并仍须遵守 + 本契约的参数和响应定义。 `internal/api` 对 bat-api 生产路径进一步收窄接口: @@ -427,6 +428,7 @@ CLI 对应关系: | `Backend` | `daemon.status`、`daemon.doctor`、`resource.state`、`catalog.status`、`resource.manifest` | 启动发现、周期刷新和资源分发 | | `ControlBackend` | `daemon.restart`、`daemon.reload`、`daemon.refresh`、`resource.sync`、`resource.verify`、`resource.repair`、`catalog.refresh` | 鉴权后的管理控制白名单 | | `ScheduleBackend` | `schedule.list`、`schedule.add`、`schedule.update`、`schedule.remove`、`schedule.run` | 鉴权后的 dashboard 调度计划控制 | +| `TranslationBackend` | `translation.task.update` | 鉴权后的 dashboard 翻译任务状态回写 | `daemon.stop`、`daemon.clean-stable` 和任意通用 RPC 不属于 bat-api 管理控制面。 Rust dispatch、Go transport 和 bat-api 接口的权威实现位置分别是 diff --git a/docs/reports/CURRENT_GAPS.md b/docs/reports/CURRENT_GAPS.md index acd8acf..1984f93 100644 --- a/docs/reports/CURRENT_GAPS.md +++ b/docs/reports/CURRENT_GAPS.md @@ -293,7 +293,7 @@ issue 43 例外说明:新增的 `parse repack` 只编排已有 TextAsset、Typ - 官方 release 发布后还会写入版本化 `translation-handoff.json`;`translation.handoff` RPC/CLI 动态合并该快照与 SQLite worker 状态,暴露 job、unit、provider run、attempt 和 failure reason。 - `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 更新并由同一查询接口反查。 +- `translation.task.update` 已提供 queued/running/failed/completed/skipped 状态回写契约,并已通过 `bat i18n task update`、Go typed helper 和 bat-api 鉴权控制面暴露;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/docs/reports/GO_STATUS.md b/docs/reports/GO_STATUS.md index 325279f..fb91bfe 100644 --- a/docs/reports/GO_STATUS.md +++ b/docs/reports/GO_STATUS.md @@ -134,7 +134,7 @@ make build-go-cli # 产出 bin/bat-go | 项 | 状态 | |---|---| | G-008 Go 同步 CLI | **已决策关闭**(正式同步 CLI = Rust `bat`) | -| G-009 bat-api 资源 bootstrap/分发 | **部分完成**(MVP+生产控制面);已含资源 bootstrap 关系面、launcher 资源 metadata 兼容、HTTP 鉴权/限流/日志/反代适配、RPC 周期刷新/诊断、readiness、OpenAPI、管理控制白名单、Rust-owned `schedule.*` dashboard 代理和部署模板,后续远程服务器联调/可选持久化 | +| G-009 bat-api 资源 bootstrap/分发 | **部分完成**(MVP+生产控制面);已含资源 bootstrap 关系面、launcher 资源 metadata 兼容、HTTP 鉴权/限流/日志/反代适配、RPC 周期刷新/诊断、readiness、OpenAPI、管理控制白名单、Rust-owned `schedule.*` dashboard 代理、`translation.task.update` 状态回写代理和部署模板,后续远程服务器联调/可选持久化 | | issue #19 | 资源面 MVP 与 USERGUIDE 基础章节已编码;真机联调后继续补充实战样例;**未自动关 issue** | | G-010 Web | 未开始 | diff --git a/infrastructure/src/bin/bat/app.rs b/infrastructure/src/bin/bat/app.rs index 9f4c027..210fa82 100644 --- a/infrastructure/src/bin/bat/app.rs +++ b/infrastructure/src/bin/bat/app.rs @@ -80,7 +80,7 @@ use translation_query::{ }; use workflow_commands::{ run_parse_clear_cache, run_parse_once, run_publish_localized, run_repack, run_translate_once, - run_translation_set, run_translation_validate, + run_translation_set, run_translation_task_update, run_translation_validate, }; const EXIT_ERROR: i32 = 1; @@ -209,6 +209,10 @@ fn run() -> anyhow::Result { run_translation_set(&options)?; Ok(0) } + CliCommand::TranslationTaskUpdate => { + run_translation_task_update(&options)?; + Ok(0) + } CliCommand::Repack => { run_repack(&options)?; Ok(0) @@ -356,6 +360,8 @@ struct CliOptions { translation_id: Option, translation_text: Option, translation_text_file: Option, + translation_failure_reason: Option, + translation_provider_run_id: Option, localized_release_id: Option, repack_spec: Option, schedule_group: Option, @@ -439,6 +445,8 @@ impl Default for CliOptions { translation_id: None, translation_text: None, translation_text_file: None, + translation_failure_reason: None, + translation_provider_run_id: None, localized_release_id: None, repack_spec: None, schedule_group: None, @@ -519,6 +527,7 @@ enum CliCommand { Translate, TranslationValidate, TranslationSet, + TranslationTaskUpdate, Repack, PublishLocalized, ScheduleList, @@ -6621,6 +6630,12 @@ fn parse_args_with_env( options.translation_text_file = Some(PathBuf::from(next_option_value(&mut args, &flag)?)); } + "--failure-reason" | "--reason" => { + options.translation_failure_reason = Some(next_option_value(&mut args, &flag)?); + } + "--provider-run-id" => { + options.translation_provider_run_id = Some(next_option_value(&mut args, &flag)?); + } "--localized-release-id" => { options.localized_release_id = Some(next_option_value(&mut args, &flag)?); } @@ -7202,6 +7217,60 @@ fn parse_args_with_env( options.progress = false; options.banner = false; } + CliCommand::TranslationTaskUpdate => { + if options.watch || options.daemon || options.daemon_child { + return Err(anyhow::anyhow!("i18n task update 只支持单次执行")); + } + if options.config.force + || options.config.dry_run + || options.run_count.is_some() + || options.sync_option_explicit + || options.output_explicit + || options.proxy_option_explicit + || tools_are_non_default(&options.config, &options.env_baseline_config) + { + return Err(anyhow::anyhow!( + "i18n task update 只接受 --state-dir、--json/--human 和翻译任务参数" + )); + } + if options.resource_root.is_some() + || options.translation_file.is_some() + || options.translation_id.is_some() + || options.translation_text.is_some() + || options.translation_text_file.is_some() + || options.localized_release_id.is_some() + || options.repack_spec.is_some() + || options.query_offset != 0 + || options.query_limit != 100 + || options.query_resource_type.is_some() + || options.query_hash.is_some() + || options.query_path_pattern.is_some() + || options.query_official_release_id.is_some() + || options.query_platform.is_some() + || options.query_destination.is_some() + || options.query_bundle_path.is_some() + || options.query_archive_entry.is_some() + || options.query_worker_status.is_some() + || options.query_parse_status.is_some() + || options.query_path_id.is_some() + || options.query_class_id.is_some() + || options.query_field_path.is_some() + || options.query_format.is_some() + || options.query_has_reason.is_some() + || options.query_has_failure_reason.is_some() + { + return Err(anyhow::anyhow!( + "i18n task update 只接受 --task-id、--task-status、--failure-reason 和 --provider-run-id" + )); + } + if options.query_task_id.is_none() || options.query_task_status.is_none() { + return Err(anyhow::anyhow!( + "i18n task update 必须同时指定 --task-id 和 --task-status" + )); + } + options.progress = false; + options.banner = false; + } CliCommand::ScheduleList | CliCommand::ScheduleAdd | CliCommand::ScheduleUpdate @@ -7389,6 +7458,9 @@ fn parse_translation_command( options: &mut CliOptions, ) -> anyhow::Result<()> { let action = next_option_value(args, "translation")?; + if action == "task" { + return parse_translation_task_command(args, options); + } let command = match action.as_str() { "run" => CliCommand::Translate, "export" => CliCommand::Translate, @@ -7409,6 +7481,21 @@ fn parse_translation_command( Ok(()) } +fn parse_translation_task_command( + args: &mut impl Iterator, + options: &mut CliOptions, +) -> anyhow::Result<()> { + let action = next_option_value(args, "translation task")?; + let command = match action.as_str() { + "update" => CliCommand::TranslationTaskUpdate, + "list" | "status" => CliCommand::TranslationTasks, + other => return Err(anyhow::anyhow!("未知 translation task 二级命令:{other}")), + }; + ensure_command_not_set(options.command, &format!("translation task {action}"))?; + options.command = command; + Ok(()) +} + fn parse_schedule_command( args: &mut impl Iterator, options: &mut CliOptions, @@ -7520,6 +7607,7 @@ fn print_usage(binary: &str) { 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 task update Update one provider worker task status"); 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"); @@ -7602,6 +7690,8 @@ fn print_usage(binary: &str) { eprintln!(" --translation-id TextUnit ID for i18n set"); eprintln!(" --translated-text Inline translation for i18n set"); eprintln!(" --translated-file UTF-8 translation file for i18n set"); + eprintln!(" --failure-reason Provider failure reason for i18n task update"); + eprintln!(" --provider-run-id Provider run ID for i18n task update"); eprintln!(" --localized-release-id Explicit localized publication ID"); eprintln!(" --repack-spec UnityFS batch repack JSON spec"); eprintln!(); diff --git a/infrastructure/src/bin/bat/app_tests.rs b/infrastructure/src/bin/bat/app_tests.rs index 00e6a86..cdd8b26 100644 --- a/infrastructure/src/bin/bat/app_tests.rs +++ b/infrastructure/src/bin/bat/app_tests.rs @@ -228,6 +228,65 @@ fn grouped_workflow_commands_use_short_top_level_aliases() { ]) .unwrap(); assert_eq!(options.command, CliCommand::TranslationValidate); + + let options = parse(&[ + "bat", + "i18n", + "task", + "update", + "--state-dir", + "/tmp/bat-state", + "--task-id", + "textunit/v-current/TextAssets/Scenario.json", + "--task-status", + "failed", + "--failure-reason", + "provider rejected payload", + "--provider-run-id", + "provider-run-1", + ]) + .unwrap(); + assert_eq!(options.command, CliCommand::TranslationTaskUpdate); + assert_eq!( + options.query_task_id.as_deref(), + Some("textunit/v-current/TextAssets/Scenario.json") + ); + assert_eq!(options.query_task_status.as_deref(), Some("failed")); + assert_eq!( + options.translation_failure_reason.as_deref(), + Some("provider rejected payload") + ); + assert_eq!( + options.translation_provider_run_id.as_deref(), + Some("provider-run-1") + ); + assert!(parse(&["bat", "i18n", "task", "update", "--task-id", "task-only",]).is_err()); + assert!(parse(&[ + "bat", + "i18n", + "task", + "update", + "--task-id", + "task", + "--task-status", + "running", + "--worker-status", + "failed", + ]) + .is_err()); + assert!(parse(&[ + "bat", + "i18n", + "task", + "update", + "--task-id", + "task", + "--task-status", + "running", + "--translation-file", + "/tmp/workbench.json", + ]) + .is_err()); } #[test] diff --git a/infrastructure/src/bin/bat/workflow_commands.rs b/infrastructure/src/bin/bat/workflow_commands.rs index a9fddfe..dc53015 100644 --- a/infrastructure/src/bin/bat/workflow_commands.rs +++ b/infrastructure/src/bin/bat/workflow_commands.rs @@ -156,6 +156,35 @@ pub(super) fn run_translation_set(options: &CliOptions) -> anyhow::Result<()> { ) } +pub(super) fn run_translation_task_update(options: &CliOptions) -> anyhow::Result<()> { + let task_id = options + .query_task_id + .as_deref() + .ok_or_else(|| anyhow::anyhow!("i18n task update 必须指定 --task-id"))?; + let status = options + .query_task_status + .as_deref() + .ok_or_else(|| anyhow::anyhow!("i18n task update 必须指定 --task-status"))?; + let mut params = serde_json::Map::from_iter([ + ("task_id".to_string(), serde_json::json!(task_id)), + ("status".to_string(), serde_json::json!(status)), + ]); + if let Some(reason) = options.translation_failure_reason.as_deref() { + params.insert("failure_reason".to_string(), serde_json::json!(reason)); + } + if let Some(provider_run_id) = options.translation_provider_run_id.as_deref() { + params.insert( + "provider_run_id".to_string(), + serde_json::json!(provider_run_id), + ); + } + let report = update_translation_task_status_report( + &options.state_dir, + Some(&serde_json::Value::Object(params)), + )?; + print_json_value(options.output_format, &report) +} + pub(super) fn run_repack(options: &CliOptions) -> anyhow::Result<()> { let spec = options .repack_spec diff --git a/internal/api/admin.go b/internal/api/admin.go index cd24dff..16b503b 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -49,6 +49,7 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) { "/admin/control/schedule-update", "/admin/control/schedule-remove", "/admin/control/schedule-run", + "/admin/control/translation-task-update", }, } if r.Method == http.MethodHead { @@ -76,6 +77,10 @@ func (s *Server) handleAdminControl(w http.ResponseWriter, r *http.Request) { s.handleAdminScheduleControl(w, r, action) return } + if action == "translation-task-update" { + s.handleAdminTranslationTaskUpdate(w, r) + return + } request, ok := decodeAdminControlRequest(w, r) if !ok { return @@ -149,6 +154,34 @@ func (s *Server) handleAdminControl(w http.ResponseWriter, r *http.Request) { }) } +func (s *Server) handleAdminTranslationTaskUpdate(w http.ResponseWriter, r *http.Request) { + backend, ok := s.backend.(TranslationBackend) + if !ok || backend == nil { + writeErrorJSON(w, http.StatusServiceUnavailable, "translation_backend_unavailable", "Rust bat translation backend is unavailable") + return + } + var params backendrpc.TranslationTaskUpdateParams + if !decodeAdminTranslationJSON(w, r, ¶ms) { + return + } + if strings.TrimSpace(params.TaskID) == "" || strings.TrimSpace(params.Status) == "" { + writeErrorJSON(w, http.StatusBadRequest, "invalid_translation_params", "translation task update requires task_id and status") + return + } + result, err := backend.TranslationTaskUpdate(r.Context(), params) + if err != nil { + s.writeControlBackendError(w, "translation-task-update", err) + return + } + writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{ + Service: "bat-api", + Action: "translation-task-update", + RPCMethod: "translation.task.update", + Status: "accepted", + Result: result, + }) +} + 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") @@ -263,6 +296,14 @@ func (s *Server) requireAdminToken(w http.ResponseWriter, r *http.Request) bool } func decodeAdminScheduleJSON(w http.ResponseWriter, r *http.Request, target any) bool { + return decodeAdminJSON(w, r, target, "invalid_schedule_params", "schedule request") +} + +func decodeAdminTranslationJSON(w http.ResponseWriter, r *http.Request, target any) bool { + return decodeAdminJSON(w, r, target, "invalid_translation_params", "translation task request") +} + +func decodeAdminJSON(w http.ResponseWriter, r *http.Request, target any, errorCode string, subject string) bool { if r.Body == nil { return true } @@ -272,11 +313,11 @@ func decodeAdminScheduleJSON(w http.ResponseWriter, r *http.Request, target any) if errors.Is(err, io.EOF) { return true } - writeErrorJSON(w, http.StatusBadRequest, "invalid_schedule_params", "schedule request must be a JSON object") + writeErrorJSON(w, http.StatusBadRequest, errorCode, subject+" 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") + writeErrorJSON(w, http.StatusBadRequest, errorCode, subject+" must contain exactly one JSON object") return false } return true diff --git a/internal/api/api_test.go b/internal/api/api_test.go index cccc6f5..486aab2 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -588,6 +588,11 @@ func (b *controlBackend) CatalogRefresh(ctx context.Context, force bool) (*backe return &backendrpc.TaskAccepted{TaskID: "task-catalog-refresh-1", Kind: "catalog.refresh"}, nil } +func (b *controlBackend) TranslationTaskUpdate(ctx context.Context, params backendrpc.TranslationTaskUpdateParams) (json.RawMessage, error) { + b.calls = append(b.calls, "translation.task.update") + return json.RawMessage(`{"task_status":"` + params.Status + `"}`), nil +} + type scheduleBackend struct { *controlBackend scheduleCalls []string @@ -1150,6 +1155,7 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) { {name: "force sync", action: "sync", body: `{"force":true}`, rpcMethod: "resource.sync", call: "resource.sync"}, {name: "repair", action: "repair", rpcMethod: "resource.repair", call: "resource.repair"}, {name: "catalog refresh", action: "catalog-refresh", rpcMethod: "catalog.refresh", call: "catalog.refresh"}, + {name: "translation task update", action: "translation-task-update", body: `{"task_id":"textunit/v-current/Scenario","status":"failed","failure_reason":"provider rejected payload","provider_run_id":"provider-run-1"}`, rpcMethod: "translation.task.update", call: "translation.task.update"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -1172,6 +1178,14 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) { } }) } + + request := httptest.NewRequest(http.MethodPost, "/admin/control/translation-task-update", strings.NewReader(`{"task_id":""}`)) + request.Header.Set("Authorization", "Bearer control-token") + recorder := httptest.NewRecorder() + s.Handler().ServeHTTP(recorder, request) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("invalid translation update status=%d body=%s", recorder.Code, recorder.Body.String()) + } } func TestAdminControlRejectsUnauthenticatedDangerousAndUnsupportedActions(t *testing.T) { diff --git a/internal/api/openapi.go b/internal/api/openapi.go index 3d71fe1..a91896e 100644 --- a/internal/api/openapi.go +++ b/internal/api/openapi.go @@ -148,7 +148,7 @@ paths: required: true schema: type: string - enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run] + enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, translation-task-update] requestBody: required: false content: @@ -191,6 +191,14 @@ paths: type: boolean enabled: type: boolean + task_id: + type: string + status: + type: string + failure_reason: + type: string + provider_run_id: + type: string responses: "202": description: Rust bat accepted the control request. diff --git a/internal/api/rpc_release.go b/internal/api/rpc_release.go index c70cc73..0628503 100644 --- a/internal/api/rpc_release.go +++ b/internal/api/rpc_release.go @@ -49,6 +49,12 @@ type ScheduleBackend interface { ScheduleRun(ctx context.Context, params backendrpc.ScheduleRunParams) (json.RawMessage, error) } +// TranslationBackend exposes the narrow provider-worker state mutation used +// by the dashboard. It does not create arbitrary translation jobs. +type TranslationBackend interface { + TranslationTaskUpdate(ctx context.Context, params backendrpc.TranslationTaskUpdateParams) (json.RawMessage, error) +} + // RPCClient adapts *backendrpc.Client to Backend. type RPCClient struct { Client *backendrpc.Client @@ -105,6 +111,9 @@ func (r RPCClient) ScheduleRemove(ctx context.Context, params backendrpc.Schedul func (r RPCClient) ScheduleRun(ctx context.Context, params backendrpc.ScheduleRunParams) (json.RawMessage, error) { return r.Client.ScheduleRun(ctx, params) } +func (r RPCClient) TranslationTaskUpdate(ctx context.Context, params backendrpc.TranslationTaskUpdateParams) (json.RawMessage, error) { + return r.Client.TranslationTaskUpdate(ctx, params) +} func (r RPCClient) ParseStatus(ctx context.Context) (json.RawMessage, error) { return r.Client.ParseStatus(ctx) } diff --git a/internal/backendrpc/client.go b/internal/backendrpc/client.go index 3443d88..372990b 100644 --- a/internal/backendrpc/client.go +++ b/internal/backendrpc/client.go @@ -264,6 +264,15 @@ type ScheduleRunParams struct { MaxRuns *uint64 `json:"max_runs,omitempty"` } +// TranslationTaskUpdateParams is used by translation.task.update to persist +// provider worker state for one task in the current official release. +type TranslationTaskUpdateParams struct { + TaskID string `json:"task_id"` + Status string `json:"status"` + FailureReason string `json:"failure_reason,omitempty"` + ProviderRunID string `json:"provider_run_id,omitempty"` +} + // Ack is returned by accepted daemon control methods. type Ack struct { Command string `json:"command"` @@ -483,6 +492,10 @@ func (c *Client) ScheduleRun(ctx context.Context, params ScheduleRunParams) (jso return c.rawData(ctx, "schedule.run", params) } +func (c *Client) TranslationTaskUpdate(ctx context.Context, params TranslationTaskUpdateParams) (json.RawMessage, error) { + return c.rawData(ctx, "translation.task.update", params) +} + func (c *Client) CatalogStatus(ctx context.Context) (json.RawMessage, error) { return c.rawData(ctx, "catalog.status", nil) } diff --git a/internal/backendrpc/client_test.go b/internal/backendrpc/client_test.go index bb91d95..684b0bc 100644 --- a/internal/backendrpc/client_test.go +++ b/internal/backendrpc/client_test.go @@ -336,6 +336,45 @@ func TestScheduleRunSendsScopeAndMaxRuns(t *testing.T) { } } +func TestTranslationTaskUpdateSendsWorkerParams(t *testing.T) { + client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { + if req.Method != "translation.task.update" { + t.Fatalf("method = %s", req.Method) + } + var params TranslationTaskUpdateParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + t.Fatalf("decode params: %v", err) + } + if params.TaskID != "textunit/v-current/Scenario" || + params.Status != "failed" || + params.FailureReason != "provider rejected payload" || + params.ProviderRunID != "provider-run-1" { + t.Fatalf("params = %#v", params) + } + return testResponse{ + Result: testEnvelope{ + OK: true, + Status: "ok", + RequestID: "req-test-translation-update", + Data: map[string]any{"task_status": "failed"}, + }, + } + }) + + raw, err := client.TranslationTaskUpdate(context.Background(), TranslationTaskUpdateParams{ + TaskID: "textunit/v-current/Scenario", + Status: "failed", + FailureReason: "provider rejected payload", + ProviderRunID: "provider-run-1", + }) + if err != nil { + t.Fatalf("TranslationTaskUpdate 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 25fcd8d..42cc010 100644 --- a/web/admin/README.md +++ b/web/admin/README.md @@ -7,6 +7,8 @@ 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. +`POST /admin/control/translation-task-update` uses the same token and forwards +the narrow `translation.task.update` provider-worker status contract. The production panel must reuse the same HTTP authentication, rate limiting, access logging, reverse-proxy handling, and no-store dynamic response policy as