From ab21344773e7d2943113fd50f035c181a96b8019 Mon Sep 17 00:00:00 2001 From: Yuyi-Oak <1722157266@qq.com> Date: Mon, 31 Aug 2026 00:02:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(i18n):=20=E5=AE=8C=E6=88=90=20localized=20?= =?UTF-8?q?patch=20=E5=8F=91=E5=B8=83=E5=9B=9E=E6=BB=9A=E9=97=AD=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #45 --- CURRENT_STATUS.md | 26 +- PROJECT_PLAN.md | 12 +- README.md | 6 +- USERGUIDE.md | 3 + api/openapi/bat-api.yaml | 18 +- docs/architecture/assetbundle.md | 16 +- docs/guides/baseline.md | 4 +- docs/guides/bat-workflows.md | 50 +- docs/reference/rpc-backend-api.md | 10 +- docs/reports/CURRENT_GAPS.md | 11 +- docs/reports/GO_STATUS.md | 2 +- infrastructure/src/bin/bat/app.rs | 335 +++++++++- infrastructure/src/bin/bat/app_tests.rs | 168 +++++ infrastructure/src/bin/bat/task_registry.rs | 2 + .../src/bin/bat/workflow_commands.rs | 51 +- infrastructure/src/lib.rs | 21 +- infrastructure/src/localized_patch.rs | 630 +++++++++++++++++- infrastructure/src/translation_workflow.rs | 393 ++++++++++- internal/api/admin.go | 97 +++ internal/api/api_test.go | 60 ++ internal/api/openapi.go | 18 +- internal/api/rpc_release.go | 24 +- internal/api/server.go | 2 + internal/backendrpc/client.go | 22 + internal/backendrpc/client_test.go | 69 ++ web/admin/README.md | 6 + 26 files changed, 1916 insertions(+), 140 deletions(-) diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md index 3f3d8bd..ce02e18 100644 --- a/CURRENT_STATUS.md +++ b/CURRENT_STATUS.md @@ -28,7 +28,7 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口: 11. 官方同步会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。 12. `/official-version-state.json` 会明确保存当前已完成版本、正在拉取版本、上一个可用版本和失败版本;同一 app version、bundle version 和 Addressables root 的失败只保留最新一条,同一版本开始重新拉取或后续发布成功时会清理对应失败记录;`bat status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、版本状态摘要、最近历史失败版本和原因、结构化日志路径和轮转日志路径,人类可读输出不会把完整版本状态 JSON 内联打印。 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 翻译任务状态与跳过/失败原因;`translation.task.update` 可回写 provider worker 状态,`translation.worker.run` 可触发 Rust provider worker 独立 claim/lease/retry 并落库 TextUnit 级译文结果,`translation.proofread` 可把汉化 workflow 标记为人工校对中;TextUnit 已包含 class id、field path、字段 offset/byte size 等可追溯定位。Crowdin provider 通过 `CROWDIN_*` 环境变量接入,mock provider 支持本地 fixture;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` 和 `BAT_TRANSLATION_*` 等键,实现编辑 `.env` 后无参启动;优先级为命令行参数 > 进程环境变量 > `.env` > 内置默认值,`BAT_SKIP_ENV_FILE=1` 可整体禁用;Redis 键为预留。daemon 任务历史持久化在 `/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 翻译任务状态与跳过/失败原因;`translation.task.update` 可回写 provider worker 状态,`translation.worker.run` 可触发 Rust provider worker 独立 claim/lease/retry 并落库 TextUnit 级译文结果,`translation.proofread` 可把汉化 workflow 标记为人工校对中;TextUnit 已包含 class id、field path、字段 offset/byte size 等可追溯定位。Crowdin provider 通过 `CROWDIN_*` 环境变量接入,mock provider 支持本地 fixture;up-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析。官方同步报告默认 `localized_release_status=not_localized`,含义是原版资源已经发布、汉化资源未发布;受支持的 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 发布成功后会写带 trace 的 `localized-patch-manifest.json`,校验 hash/size/diff/rollback 后才允许 `localized.status` 返回 `status=published`、`status_code=localized.published` 和 `localized_release_status=localized`,并可用 `localized.rollback` 显式恢复上一 release。`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` 和 `BAT_TRANSLATION_*` 等键,实现编辑 `.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。issue 44 已接入 `translation.worker.run` provider worker:默认并发 8、范围 `1..=256`,每个 worker 独立 claim 下一项任务并落库 lease、失败分类、重试计划和 TextUnit 译文结果。schedule 查询现在按一级工作流过滤,删除/执行会校验作用域,单轮执行可限制计划数;schedule CRUD、翻译任务查询/交接视图、翻译任务状态回写、provider worker 触发和 `translation.proofread` 状态标记已通过 `bat.sock` 的 RPC 以及 `bat-api` 的鉴权管理接口暴露,dashboard 不维护第二套状态。该例外只编排已有解析和 patch 能力,不扩大解析器覆盖;完整 AssetBundle 重打包和 Web 前端仍是后续工作。G-008(产品级 Go 同步 CLI)已决策关闭。真实官方网络全量拉取 smoke 已固化(G-018 已关闭);真实大文件与运行报告默认在 `/tmp` 隔离目录,不纳入 Git。Go 细节见 `docs/reports/GO_STATUS.md`。 @@ -104,7 +104,7 @@ job/unit/provider run 状态。当前下载实现使用默认 8 个独立 worker 待完成: -- `crates/bat-assetbundle` 已具备 UnityFS 容器、对象表、TypeTree 元数据、基础字段读取、TextAsset 和 TextUnit 提取;UnityFS 容器已补充总大小、计数、路径、重复 directory、LZMA 和边界校验,并通过 UnityPy 真实 bundle 隔离回归;UnityFS TextAsset patch 发布前置链路已可用,真实复杂版本差异、重打包和通用 Patch 仍未实现。 +- `crates/bat-assetbundle` 已具备 UnityFS 容器、对象表、TypeTree 元数据、基础字段读取、TextAsset 和 TextUnit 提取;UnityFS 容器已补充总大小、计数、路径、重复 directory、LZMA 和边界校验,并通过 UnityPy 真实 bundle 隔离回归;已有 TextAsset、TypeTree string field 和 managed-reference string field 的 localized patch 发布闭环,真实复杂版本差异、整体 AssetBundle 重打包和通用 Patch 仍未实现。 - Addressables parser 已覆盖当前真实形态 fixture/golden 与 `m_Crc`,但仍需继续覆盖二进制/压缩字段组合和更细失败诊断。 - 客户端发现、备份、应用补丁流程尚未连接真实实现。 @@ -152,7 +152,7 @@ job/unit/provider run 状态。当前下载实现使用默认 8 个独立 worker ### `bat-assetbundle` -状态:**UnityFS 解包、TypeTree 字段读取、TextUnit 提取和 TextAsset patch 发布前置已起步;复杂结构覆盖、重打包与 Patch 发布统一未完成** +状态:**UnityFS 解包、TypeTree 字段读取、TextUnit 提取和受支持 localized patch 发布已可用;复杂结构覆盖与整体 AssetBundle 重打包仍冻结** 冻结说明:自 2026-07-30 起,解析模块进入维护冻结。冻结期只允许修复编译、测试、clippy、崩溃、错误诊断、真实运行回归和文档不一致;不新增 TypeTree 语义类型、不扩大 UnityFS / AssetBundle / Addressables 解析覆盖、不开放新的写入型解析 RPC/CLI,也不以合成 fixture 宣称新增解析能力。冻结细则见 `docs/reports/PARSER_FREEZE.md`。 @@ -171,20 +171,20 @@ job/unit/provider run 状态。当前下载实现使用默认 8 个独立 worker 待完成: - 真实 MonoBehaviour、ScriptableObject 版本差异、复杂容器结构调整、unknown 字段结构语义和未见样本驱动的完整 managed reference registry / map entry 变体覆盖;TypeTree-covered managed reference 字段与 registry 记录已可结构化解码,常见 full typename 可拆解为 assembly/namespace/class,不做低保真猜测。 -- 复杂对象整体结构修改后的发布级 AssetBundle 重打包;UnityFS TextAsset、TypeTree string 字段、managed-reference registry payload 字符串、基础语义字段、enum、bit_field、object 字段组合和 TypeTree schema 支撑的 array/vector/map 整体替换的文件级链路已具备重建后校验,发布级 manifest/apply/diff/rollback 仍需统一。 +- 复杂对象整体结构修改后的发布级 AssetBundle 重打包;UnityFS TextAsset、TypeTree string 字段、managed-reference registry payload 字符串、基础语义字段、enum、bit_field、object 字段组合和 TypeTree schema 支撑的 array/vector/map 整体替换的文件级链路已具备重建后校验,受支持 localized patch 已有独立 staging、manifest、current、状态校验和显式 rollback;整体 AssetBundle 发布仍不在冻结范围内。 - 真实资源 fixture 覆盖对象级解析和文本提取。 - 详细补全顺序见 `docs/architecture/assetbundle.md`。 ### `bat-patch` -状态:**通用 Binary/JSON/Text Patch 基础可用;文件级 patch / UnityFS 写入入口已开放,发布级 Patch 仍未完成** +状态:**通用 Binary/JSON/Text Patch 基础可用;受支持 localized patch 发布/rollback 已完成,通用 Patch 发布仍未完成** -当前已有确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、通用 Patch manifest、BLAKE3/size 完整性校验和 rollback 元数据。`patch.apply` RPC 与 `patch-apply` CLI 已可对显式 source/patch/target 文件执行 Binary/JSON/Text patch,并返回 size/BLAKE3 报告;`unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field` RPC 和 `unityfs-patch-text-asset` / `unityfs-patch-string-field` / `unityfs-patch-field` CLI 已可对显式 UnityFS bundle 输出目标文件。`unityfs.patch_field` 支持 bool、signed/unsigned integer、float raw bits、string、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 整体替换语义 JSON 值;array/vector/List/HashSet/map 扩容会复用当前首个元素或 TypeTree data node 的编码 schema,空容器扩容已用合成 fixture 覆盖,嵌套 vector `Array`、`List` 和 `HashSet` 形态、enum、bit_field、unknown fixed-size raw bytes、managed-reference registry `data` 和 `managedReferenceData` payload 字符串已有重建后重解析 fixture。`bat-assetbundle` + `LocalizedPatchService` 已能对 UnityFS TextAsset 执行替换、写 patch manifest、记录 diff/rollback,并发布到独立汉化 release;`LocalizedPatchManifest` 可转换为通用 `bat_patch::PatchManifest`,但现有汉化 manifest 文件格式不强制迁移。 +当前已有确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、通用 Patch manifest、BLAKE3/size 完整性校验和 rollback 元数据。`patch.apply` RPC 与 `patch-apply` CLI 已可对显式 source/patch/target 文件执行 Binary/JSON/Text patch,并返回 size/BLAKE3 报告;`unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field` RPC 和 `unityfs-patch-text-asset` / `unityfs-patch-string-field` / `unityfs-patch-field` CLI 已可对显式 UnityFS bundle 输出目标文件。`unityfs.patch_field` 支持 bool、signed/unsigned integer、float raw bits、string、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 整体替换语义 JSON 值;array/vector/List/HashSet/map 扩容会复用当前首个元素或 TypeTree data node 的编码 schema,空容器扩容已用合成 fixture 覆盖,嵌套 vector `Array`、`List` 和 `HashSet` 形态、enum、bit_field、unknown fixed-size raw bytes、managed-reference registry `data` 和 `managedReferenceData` payload 字符串已有重建后重解析 fixture。`bat-assetbundle` + `LocalizedPatchService` 已能对 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 执行替换,写入带 TextUnit/provider/review/rollback trace 的 localized patch manifest,在独立 staging 校验后发布汉化 release,并通过 `localized.publish` / `localized.rollback` RPC、`i18n publish` / `i18n rollback` CLI 和 bat-api 控制面暴露;`LocalizedPatchManifest` 可转换为通用 `bat_patch::PatchManifest`,通用 manifest 驱动发布仍未迁移。 待完成: - 未见样本驱动的 map entry schema 变化、unknown 字段结构语义、完整 managed reference registry 变体驱动字段修改后的语义重打包。 -- 发布级 `patch build` / `patch rollback` / 通用 manifest 驱动发布;当前文件级写入入口不切换 release,不替代汉化发布流程。 +- 通用 manifest 驱动的跨类型 patch build/apply/diff 发布;当前 localized 发布仅接受已验证 TextUnit 对应的受支持 UnityFS 文本字段,并不等价于整体 AssetBundle 重打包。 - `unityfs.inspect`、复杂 UnityFS 语义编辑和写入型发布工作流仍未开放。 ### `bat-ffi` @@ -291,12 +291,12 @@ 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/proofread)、`localized.*`(status)、`catalog.*`(status/refresh/diff/versions)、`task.*`(status/list/cancel/logs)、文件级 `patch.apply` 与 `unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field` 已实现,长任务返回 `task_id` 可轮询(任务执行器单 worker FIFO,与 watch 循环互斥;任务历史持久化于 `/bat-tasks.json`,daemon 重启后仍可查,中断任务标记 `task_interrupted`);错误码已接入下载、launcher/metadata、server-info/marker 与配置校验路径。剩余:发布级 `patch build` / `patch rollback`、复杂 `unityfs.*` 语义编辑、`task.create`(按设计由语义方法创建)、`daemon.clean-stable`(由 CLI 侧按进程生命周期显式执行,live RPC 内不做在线清理)、Redis 任务后端(`.env` 已预留配置键,接入时机另议)。Go 层通过 RPC 调用 Rust backend,不走 FFI(FFI 降级说明见 `docs/architecture/official-resource-backend.md` §7)。 +1. Issue #1(P0,主体已实现):`bat.sock` Unix socket JSON-RPC 已扩展为面向 Go 服务层的 Rust Resource Backend API。统一 envelope(`ok`、`status`、`error`、`data`、`request_id`)与 `BAT-ERR` 错误码模型已落地;`daemon.*`(status/logs/stop/restart/reload/refresh/doctor)、`resource.*`(state/sync/verify/repair/manifest/list/index)、`schedule.*`(list/add/update/remove/run)、`parse.*`(status/text_units/errors)、`translation.*`(tasks/handoff/task.update/proofread/worker.run)、`localized.*`(status/publish/rollback)、`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 与配置校验路径。剩余:通用 manifest 驱动发布、复杂 `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、管理控制白名单、translation admin 查询/控制、CDN Range/缓存头、RPC 周期刷新和 USERGUIDE 基础章节;仓库内 Rust/Go snapshot contract fixture 与同机 live smoke 已落地。失效 `resource_root` 或 manifest 不完整时会清空旧索引并使 `/readyz` 返回 `503`;编码 dot-segment 会在路由规范化前拒绝。refresh mtime/size 增量缓存和可选持久化另议。 -3. 文本提取 / 翻译队列 / Patch 输入:`official-textunit-index.json`、`official-textunit-tasks.json` 与 `crowdin-textunit-queue.json` 已生成;TextUnit 明细、解析错误和离线翻译任务状态/失败原因已可查询,`translation.tasks` / `translation.handoff` 已提供 Go typed helper 和 bat-api dashboard 查询入口,`translation.task.update` 已提供 Rust CLI、Go typed helper 和 bat-api dashboard 状态回写入口,`translation.worker.run` 已提供 mock/Crowdin provider worker、lease、重试和 TextUnit 译文结果落库,`translation.proofread` 已提供人工校对状态标记入口。剩余为翻译记忆、完整 Patch 构建/rollback 和复杂重打包。 +3. 文本提取 / 翻译队列 / Patch 输入:`official-textunit-index.json`、`official-textunit-tasks.json` 与 `crowdin-textunit-queue.json` 已生成;TextUnit 明细、解析错误和离线翻译任务状态/失败原因已可查询,`translation.tasks` / `translation.handoff` 已提供 Go typed helper 和 bat-api dashboard 查询入口,`translation.task.update` 已提供 Rust CLI、Go typed helper 和 bat-api dashboard 状态回写入口,`translation.worker.run` 已提供 mock/Crowdin provider worker、lease、重试和 TextUnit 译文结果落库,`translation.proofread` 已提供人工校对状态标记入口。受支持 TextAsset、TypeTree string field 和 managed-reference string field 已可从 workbench/worker 结果生成 localized patch,在独立 staging 校验后发布并显式 rollback,相关 status/publish/rollback RPC 与 bat-api 控制入口已暴露。剩余为翻译记忆、通用 manifest 发布和复杂重打包。 4. Issue #3(已完成当前目标):AssetBundle UnityFS 基础解析校验已覆盖 header、block、directory、LZ4/LZMA、alignment、总大小、计数、路径安全、重复 directory、边界和隔离 UnityPy 真实样本;复杂对象解析与发布级重打包继续跟踪 G-005。 5. Issue #2(已完成当前目标):Addressables JSON/compact catalog 已提取 bundle provider、name、hash/size/CRC、资源类型和依赖关系,并贯通 ResourceEntry、SQLite 与 resource.index 输出;独立二进制格式仍明确拒绝。 -6. 通用 Binary/JSON/Text Patch 基础已落地;复杂 AssetBundle 重打包和真实翻译系统仍应后置,UnityFS TextAsset patch 发布前置已具备回归测试。 +6. 通用 Binary/JSON/Text Patch 基础已落地;受支持 localized patch 发布/rollback 已具备回归测试,复杂 AssetBundle 重打包、通用 manifest 发布和真实翻译记忆仍后置。 非阻塞跟踪项:官方同步长期运行测试正在进行,运行报告将在后续提供。 @@ -306,7 +306,7 @@ GitHub issue 状态:#1 已关闭;#17 的历史决定不代表当前下载实 立即任务: -1. Issue #1 收尾:协议基础设施、最小方法集、`catalog.*`、`parse.*`、`localized.*`、`task.*`、`resource.repair`、`daemon.restart`、文件级 `patch.apply` / `unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field`、任务持久化、错误码模型与文档均已完成;剩余发布级 `patch build`/`rollback`、复杂 `unityfs.*` 语义编辑以及 `task.create`、`daemon.clean-stable` 的设计边界确认。 +1. Issue #1 收尾:协议基础设施、最小方法集、`catalog.*`、`parse.*`、`localized.*`、`task.*`、`resource.repair`、`daemon.restart`、文件级 `patch.apply` / `unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field`、任务持久化、错误码模型与文档均已完成;剩余通用 manifest 发布、复杂 `unityfs.*` 语义编辑以及 `task.create`、`daemon.clean-stable` 的设计边界确认。 2. 真实官方网络全量下载长期运行报告(`make official-smoke`);issue #19 的同机 live 联调已完成。 3. 跟进官方同步长期运行测试报告。 4. AssetBundle / Addressables(issue #3 / #2);CAS 用户级导入(G-011)。 @@ -314,5 +314,5 @@ GitHub issue 状态:#1 已关闭;#17 的历史决定不代表当前下载实 --- - **当前总体完成度**:不再固定写单一百分比,以各模块状态、`GO_STATUS.md` 和 issue 为准。 -- **当前基线状态**:Rust `bat` 同步闭环可用;Go `bat-api` 资源 bootstrap/分发 MVP + 玩家-facing HTTP 控制面 + launcher 资源引导兼容 + RPC 周期刷新/诊断 + readiness + `backendrpc` 可用;CAS 用户级导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、通用 Binary/JSON/Text Patch 基础和 UnityFS TextAsset patch 发布前置可用;完整 AssetBundle 重打包未完成。 -- **下一工程里程碑**:翻译记忆、完整 localized Patch 构建/rollback、复杂 AssetBundle 解析和重打包;`bat-api` 同机 live 联调已完成。 +- **当前基线状态**:Rust `bat` 同步闭环可用;Go `bat-api` 资源 bootstrap/分发 MVP + 玩家-facing HTTP 控制面 + launcher 资源引导兼容 + RPC 周期刷新/诊断 + readiness + `backendrpc` 可用;CAS 用户级导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、通用 Binary/JSON/Text Patch 基础和受支持 localized patch 发布/rollback 可用;完整 AssetBundle 重打包与通用 manifest 发布未完成。 +- **下一工程里程碑**:翻译记忆、通用 manifest Patch 构建、复杂 AssetBundle 解析和重打包;`bat-api` 同机 live 联调已完成。 diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 60151b8..e0627dd 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -41,12 +41,12 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付 ### 仍是骨架或占位 -1. `bat-assetbundle` 已具备 UnityFS 解包和 TextAsset 提取基础能力(header/block info/directory、LZ4/LZMA block info 与数据 block、directory 文件提取、serialized file object table、TypeTree node 元数据、TextAsset bytes、TypeTree-covered managed reference payload TextUnit 上下文),并已有 UnityFS TextAsset patch 前置能力;MonoBehaviour/ScriptableObject 复杂字段级解析、重打包和通用 Patch 仍未完成。 -2. `bat-patch` 已具备确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、通用 Patch manifest、BLAKE3/size 完整性校验和 rollback 元数据;文件级 `patch.apply` RPC / `patch-apply` CLI 与 UnityFS TextAsset / TypeTree string / TypeTree 语义字段写入入口已开放,当前发布级可用的是 `bat-assetbundle` + `LocalizedPatchService` 的 UnityFS TextAsset patch 前置链路。 +1. `bat-assetbundle` 已具备 UnityFS 解包和 TextAsset 提取基础能力(header/block info/directory、LZ4/LZMA block info 与数据 block、directory 文件提取、serialized file object table、TypeTree node 元数据、TextAsset bytes、TypeTree-covered managed reference payload TextUnit 上下文),并已有受支持 localized patch 发布能力;MonoBehaviour/ScriptableObject 复杂字段级解析、整体重打包和通用 Patch 仍未完成。 +2. `bat-patch` 已具备确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、通用 Patch manifest、BLAKE3/size 完整性校验和 rollback 元数据;文件级 `patch.apply` RPC / `patch-apply` CLI 与 UnityFS TextAsset / TypeTree string / TypeTree 语义字段写入入口已开放,`bat-assetbundle` + `LocalizedPatchService` 已完成受支持 TextUnit 到 localized patch manifest、独立 staging、发布和 rollback 闭环,通用 manifest 发布与整体 AssetBundle 重打包仍后置。 3. Go 侧边界已冻结(见 `docs/reports/GO_STATUS.md`):同步/运维命令行 = Rust `bat`;资源分发 = `cmd/bat-api` MVP;`internal/backendrpc` 完成;`cmd/bat` 仅为试验(`bin/bat-go`)。完整游戏业务 API / Web / SDK 仍未完成。 4. Addressables parser 已覆盖当前真实形态 fixture/golden,但还不是完整 Unity Addressables/SBP catalog 兼容层。 5. 官方同步结果可配置为发布后自动导入 CAS + ResourceRepository,并通过 `resource.index` RPC/CLI 查询;Resource metadata 已保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 摘要,资源级查询已覆盖 release、平台、destination、archive entry、parse status 和 TextUnit format;单条 TextUnit 明细和解析错误已持久化到 `official-textunit-index.json`,可通过 `parse.text_units` / `parse.errors` 查询;离线 TextUnit 翻译任务状态和跳过/失败原因可通过 `translation.tasks` 查询,`translation.task.update` 已提供 worker 状态回写 contract,`translation.worker.run` 已提供真实 provider worker 触发、lease/retry 和结果落库 contract,`translation.proofread` 已提供汉化 workflow 人工校对标记 contract。 -6. 汉化 Patch 发布前置已具备 UnityFS TextAsset manifest/apply/diff/rollback/完整性校验和 `localized.status` 严格校验;真实 provider worker 已接入,翻译记忆到完整汉化文件集合的构建仍未完成。 +6. 受支持汉化 Patch 发布已具备 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 的 manifest/apply/rollback/完整性校验和 `localized.status` 严格校验;真实 provider worker 已接入,翻译记忆到完整汉化文件集合的构建仍未完成。 7. 真实官方网络全量下载 smoke 已固化为可重复脚本和 runbook(G-018 已关闭);真实运行记录处于长期运行测试阶段,报告待后续提供。 8. 完整 Web、数据库迁移、插件加载机制尚未实现;`bat-api` 资源 bootstrap/分发 OpenAPI 已通过 `/openapi.yaml` 提供,完整游戏业务 API 的 OpenAPI 仍未完成。 9. 原 Git 历史未恢复;当前仓库以新初始化基线为准。 @@ -183,7 +183,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付 8. Linux 生产同步不依赖已安装官方启动器:**已完成当前 Rust 入口**。`--auto-discover` 只使用官方 HTTP metadata 和临时目录解析 `GameMainConfig`。 9. 真实官方网络全量下载 smoke test:**命令已固化(G-018 已关闭)**。`scripts/official-full-pull-smoke.sh` / `make official-smoke` 已固化 dry-run、首次下载、二次 up-to-date 和本地损坏 repair 的可重复流程;真实运行处于长期运行测试阶段,报告待后续提供。 10. 官方发布后的增量 handoff 与解析缓存:**已完成基础入口**。新 release 发布后先生成 `official-resource-changes.json` 和 `crowdin-translation-handoff.json`,新增+变更资源进入解析/翻译候选;`official-parse-cache.json` 基于下载 manifest 覆盖直接 UnityFS bundle、zip 内 UnityFS 条目和非候选资源记录;随后生成 `official-textunit-index.json`、`official-textunit-tasks.json` 与 `crowdin-textunit-queue.json`,本地文件未变化且缓存/索引有效时跳过重复解析。 -11. 汉化发布状态:**已完成前置闭环**。官方同步默认报告 `not_localized`,表示只发布原版资源;UnityFS TextAsset patch 发布成功并通过 `localized-patch-manifest.json`、current symlink 和 release ID 校验后才切换为 `localized`。 +11. 汉化发布状态:**受支持范围已完成闭环**。官方同步默认报告 `not_localized`,表示只发布原版资源;TextAsset、TypeTree string field 和 managed-reference string field patch 发布成功并通过 `localized-patch-manifest.json`、current symlink、release ID 和 rollback 校验后才切换为 `localized`,可通过 `localized.publish` / `localized.rollback` 与 bat-api 管理接口控制。 验收标准: @@ -459,9 +459,9 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付 按最终目标计算,当前总体完成度不再固定写单一百分比,以模块状态和 issue 收敛情况为准。 -已完成的是稳定基线、架构骨架、部分接口、CAS V1、Rust 官方资源同步闭环、可配置 CAS/ResourceRepository 导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、provider worker、通用 Binary/JSON/Text Patch 基础、UnityFS TextAsset patch 发布前置,以及 Go `bat-api` 资源分发和同机 live 联调。下一阶段的关键是翻译记忆、完整 localized Patch 构建/rollback、复杂 AssetBundle 解析/重打包和官方资源长期运行报告。 +已完成的是稳定基线、架构骨架、部分接口、CAS V1、Rust 官方资源同步闭环、可配置 CAS/ResourceRepository 导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、provider worker、通用 Binary/JSON/Text Patch 基础、受支持 localized patch 发布/rollback,以及 Go `bat-api` 资源分发和同机 live 联调。下一阶段的关键是翻译记忆、通用 manifest 发布、复杂 AssetBundle 解析/重打包和官方资源长期运行报告。 --- - **下一份应更新文档**:真实官方网络 smoke 记录 -- **下一项工程任务**:推进翻译记忆、完整 localized Patch 构建/rollback、复杂 AssetBundle 解析,并持续执行官方资源长期运行 smoke。 +- **下一项工程任务**:推进翻译记忆、通用 manifest Patch 构建、复杂 AssetBundle 解析,并持续执行官方资源长期运行 smoke。 diff --git a/README.md b/README.md index 033c9a2..b8a8514 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,8 @@ - 官方同步会维护 `/official-version-state.json`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。 - 资源导入链路可配置为在官方 release 发布后写入 CAS + `ResourceRepository`,资源 metadata 会记录 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式,TextAsset/Table/Media 会按类型分类索引;`resource.index` RPC/CLI 可按类型、hash、路径模式、官方 release ID、平台、destination、archive entry、parse status 和 TextUnit format 分页查询索引。 - 新 release 发布后会生成 `official-resource-changes.json`、`official-parse-cache.json`、`official-textunit-index.json`、`official-textunit-tasks.json`、`crowdin-translation-handoff.json`、`crowdin-textunit-queue.json`、`translation-tasks.sqlite` 和 `translation-handoff.json`;其中 TextUnit/Crowdin 队列只使用 Added/Modified 资源,不调用 Crowdin 网络 API,离线 TextUnit 翻译任务可通过 `translation.tasks` / `translation.handoff` RPC 或 CLI 查询状态、跳过/失败原因和 provider run 交接。 -- `LocalizedPatchService` 已具备 UnityFS TextAsset patch 发布前置能力:在 `--localized-output` / `BAT_LOCALIZED_OUTPUT` 配置的独立汉化目录 staging 中复制官方 release、应用 TextAsset patch、写 `localized-patch-manifest.json`(hash、size、diff、rollback)、校验后发布到 `versions/` 并切换 `current`。 -- `bat-patch` 已具备通用 Patch 基础:确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、Patch manifest、BLAKE3/size 完整性校验和 rollback 元数据;文件级 `patch.apply` RPC / `patch-apply` CLI 与 UnityFS TextAsset / TypeTree string / TypeTree 语义字段写入入口已开放,TypeTree 语义字段支持基础标量、固定 Unity float/int/hash 值类型的 leaf/direct-child 形态、PPtr、managed-reference registry payload 字符串、object 字段组合、unknown fixed-size raw bytes 同长度替换和 TypeTree schema 支撑的 array/vector/map 整体替换;TextUnit 提取会把 managed-reference 类型信息保留为上下文而非翻译文本,汉化发布当前仍走 UnityFS TextAsset 前置链路。 +- `LocalizedPatchService` 已具备受支持的 UnityFS localized patch 发布/回滚能力:在 `--localized-output` / `BAT_LOCALIZED_OUTPUT` 配置的独立汉化目录 staging 中复制官方 release、应用 TextAsset、TypeTree string field 或 managed-reference string field patch、写入带 TextUnit/provider/review/rollback trace 的 `localized-patch-manifest.json`,校验后发布到 `versions/` 并切换 `current`,也可显式 rollback。 +- `bat-patch` 已具备通用 Patch 基础:确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、Patch manifest、BLAKE3/size 完整性校验和 rollback 元数据;文件级 `patch.apply` RPC / `patch-apply` CLI 与 UnityFS TextAsset / TypeTree string / TypeTree 语义字段写入入口已开放,TypeTree 语义字段支持基础标量、固定 Unity float/int/hash 值类型的 leaf/direct-child 形态、PPtr、managed-reference registry payload 字符串、object 字段组合、unknown fixed-size raw bytes 同长度替换和 TypeTree schema 支撑的 array/vector/map 整体替换;TextUnit 提取会把 managed-reference 类型信息保留为上下文而非翻译文本,受支持 localized 发布通过独立 manifest/staging/current 流程完成。 - `bat-ffi` 可选无状态 C ABI 兼容层:仅保留 Manifest inspect 和官方 sync plan 的粗粒度 JSON helper,不作为 Go CLI 或生产同步的主集成边界。 - 文档路线图、当前状态、缺口清单、官方资源运行指南。 @@ -29,7 +29,7 @@ - `bat-api` 完整游戏业务 API / launcher 安装包更新全链仍未完成;资源 CDN、HTTP 控制面和 launcher 资源引导兼容已可用。 - 完整 AssetBundle 对象级解析(UnityFS 解包、object table、TypeTree node 元数据、TextAsset bytes、MonoBehaviour/ScriptableObject 基础 TypeTree 字段级解析已起步;复杂字段覆盖、发布级重打包和 Patch 发布统一仍未完成)。 -- 复杂 AssetBundle 重打包和真实翻译构建 worker;当前通用 Binary/JSON/Text Patch 基础已在 crate 层可用,发布链路仍只开放 UnityFS TextAsset patch 前置能力。 +- 复杂 AssetBundle 重打包和真实翻译构建 worker;当前通用 Binary/JSON/Text Patch 基础已在 crate 层可用,localized 发布仅开放已验证 TextUnit 对应的 UnityFS 文本字段。 - Translation Memory、Glossary、AI Provider。 - SDK、Web 管理后台。 diff --git a/USERGUIDE.md b/USERGUIDE.md index 3ed15b5..5e09db8 100644 --- a/USERGUIDE.md +++ b/USERGUIDE.md @@ -140,6 +140,7 @@ BAT_API_SKIP_ENV_FILE=1 go run ./cmd/bat-api \ | `GET /admin/schedules?id=...&group=...&enabled=...` | 读取/过滤 Rust `bat` 调度计划;需要管理 token | | `GET /admin/translation/tasks?limit=100&worker_status=failed` | 读取/过滤 Rust 翻译任务和 provider worker 状态;需要管理 token | | `GET /admin/translation/handoff` | 读取当前 release 的完整翻译交接视图;需要管理 token | +| `GET /admin/translation/status` | 读取当前汉化 release、current 指针和 workflow 状态;需要管理 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,也不仿造登录、账号、网关、鉴权或游戏业务协议。 @@ -172,6 +173,8 @@ launcher 兼容端点只服务启动前资源发现。它们复用 Rust `bat` sn | `translation-task-update` | `translation.task.update` | `{ "task_id": "...", "status": "...", "failure_reason": "...", "provider_run_id": "..." }` | `202` + 当前任务记录 | | `translation-worker-run` | `translation.worker.run` | `{ "provider": "mock", "concurrency": 8, "max_tasks": 2 }` | `202` + worker task | | `translation-proofread` | `translation.proofread` | 无 | `202` + 汉化状态 | +| `localized-publish` | `localized.publish` | `{ "translation_file": "...", "localized_release_id": "..." }` 或 `{ "from_worker": true, "localized_release_id": "..." }` | `202` + localized release manifest | +| `localized-rollback` | `localized.rollback` | 可选 `{ "localized_release_id": "..." }` | `202` + rollback report | `stop`、`clean-stable`、patch 和 UnityFS 写入命令不会经 HTTP 暴露。 diff --git a/api/openapi/bat-api.yaml b/api/openapi/bat-api.yaml index dc13d50..0231530 100644 --- a/api/openapi/bat-api.yaml +++ b/api/openapi/bat-api.yaml @@ -206,6 +206,16 @@ paths: description: Missing or invalid admin token. "503": description: Rust bat translation backend is unavailable. + /admin/translation/status: + get: + summary: Read Rust-owned localized release status + responses: + "200": + description: Current localized status JSON report from Rust bat. + "401": + description: Missing or invalid admin token. + "503": + description: Rust bat localized backend is unavailable. /admin/control/{action}: post: summary: Forward an allowlisted control or schedule action to Rust bat @@ -215,7 +225,7 @@ paths: required: true schema: type: string - enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, translation-task-update, translation-worker-run, translation-proofread] + enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, translation-task-update, translation-worker-run, translation-proofread, localized-publish, localized-rollback] requestBody: required: false content: @@ -294,6 +304,12 @@ paths: minimum: 1 worker_id: type: string + translation_file: + type: string + from_worker: + type: boolean + localized_release_id: + type: string responses: "202": description: Rust bat accepted the control request. diff --git a/docs/architecture/assetbundle.md b/docs/architecture/assetbundle.md index 9e3c108..6367313 100644 --- a/docs/architecture/assetbundle.md +++ b/docs/architecture/assetbundle.md @@ -32,7 +32,7 @@ | UnityFS container | `.bundle`、zip 内 bundle | header、block、directory、解压文件、基础摘要 | 已支持基础解包、LZ4/LZMA、alignment、大小/计数/路径/边界校验 | | Serialized file | UnityFS directory 文件 | header、type table、TypeTree node、object table、TextAsset bytes | 已支持基础表结构和 TextAsset bytes | | Unity 对象字段 | TextAsset、MonoBehaviour、ScriptableObject | 可翻译文本单元、上下文、资源定位 | TypeTree 基础字段读取、`SerializedReference` / prefixed managed-reference metadata alias、payload 提取和字符串提取已落地,真实结构覆盖继续扩大 | -| Patch 发布前解析 | 已翻译文本、中间格式、原版资源 | 可验证 patch manifest、汉化 release 目录 | UnityFS TextAsset 前置已落地,通用 Binary/JSON/Text Patch 与文件级 patch / UnityFS 写入入口可用,发布级 build/rollback 未开放 | +| Patch 发布 | 已翻译 TextUnit、中间格式、原版资源 | 可验证 localized patch manifest、汉化 release 目录、current/state | TextAsset、TypeTree string field 和 managed-reference string field 的 localized publish/rollback 已落地;整体 AssetBundle 重打包与通用 manifest 发布仍冻结 | --- @@ -163,16 +163,16 @@ catalog 仍按“明确不支持”处理,不把低保真路径伪装成完整 2. 解析缓存、资源变更集和 repository 数据能从同一 manifest fingerprint 追溯。 3. CAS 对象跨版本复用,不重复存储相同文件。 -### P5:Patch 发布前置解析 +### P5:Patch 发布 -目标:让解析结果成为可生成汉化 patch 的输入。 +目标:让解析结果成为可生成、校验和回滚汉化 patch 的输入。 交付: -1. 已定义 `localized-patch-manifest.json`:目标官方版本、localized release、输出文件、hash、size、byte delta、TextAsset 操作和回滚信息。 -2. 已支持 UnityFS TextAsset raw bytes 替换的最小 patch 路径。 +1. 已定义 `localized-patch-manifest.json`:目标官方版本、localized release、输出文件、hash、size、byte delta、TextUnit/provider/review trace 和回滚信息。 +2. 已支持 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 的 localized patch 操作。 3. MonoBehaviour/ScriptableObject 字段替换必须依赖 P2 字段级解析结果。 -4. Patch 产物写入配置化汉化发布根下的 `.staging/`,校验通过后发布到 `versions/` 并切换 `current`。 +4. Patch 产物写入配置化汉化发布根下的 `.staging/`,校验通过后发布到 `versions/` 并切换 `current`;rollback 按 manifest 恢复上一 release。 5. 成功后发布状态从 `not_localized` 切到 `localized`;`localized.status` 要求 state、current symlink 和 patch manifest 同时匹配当前官方 release。 验收: @@ -208,6 +208,6 @@ catalog 仍按“明确不支持”处理,不把低保真路径伪装成完整 1. 完成 Addressables Windows/Android 当前版本 catalog 样本集合,关闭 G-007 当前阶段。 2. 完成 TypeTree 字段 reader 和 MonoBehaviour/ScriptableObject 遍历,推进 G-005。 -3. 基于 `translation.worker.run` 推进翻译记忆和 Patch 构建。 +3. 基于 `translation.worker.run` 推进翻译记忆和通用 manifest Patch 构建。 4. 扩展翻译任务结果在 CAS/ResourceRepository 查询面的索引,推进 G-011。 -5. 在通用 Binary/JSON/Text Patch 基础上继续扩展复杂 AssetBundle 重打包和发布流程统一,保留当前 UnityFS TextAsset patch 发布前置链路。 +5. 在通用 Binary/JSON/Text Patch 基础上继续扩展复杂 AssetBundle 重打包和通用 Patch 发布流程统一,保留当前受支持 localized patch 发布/rollback 链路。 diff --git a/docs/guides/baseline.md b/docs/guides/baseline.md index 6bb8b03..c0d4b90 100644 --- a/docs/guides/baseline.md +++ b/docs/guides/baseline.md @@ -92,9 +92,9 @@ CAS V1 和 Rust 官方同步闭环完成后,下一阶段优先推进: 1. 继续联调 Go `bat-api` 与 Rust daemon 的资源分发路径;Go 同步 CLI 不再作为产品目标。 2. 按 `docs/guides/official-full-pull-smoke.md` 在隔离目录执行真实官方网络全量下载 smoke,并保留运行报告。 -3. 基于 `translation.worker.run` 推进翻译记忆和 Patch 构建。 +3. 基于 `translation.worker.run` 推进翻译记忆和通用 manifest Patch 构建。 4. 扩展 ResourceRepository 查询面:CAS 诊断入口、面向大索引的查询优化和更丰富 TextUnit 查询。 -5. 继续完善 AssetBundle 复杂对象解析、复杂对象重打包和 Patch 发布流程统一;通用 Binary/JSON/Text Patch 基础与 UnityFS TextAsset patch 发布前置链路已可用。 +5. 继续完善 AssetBundle 复杂对象解析、复杂对象重打包和通用 Patch 发布流程统一;通用 Binary/JSON/Text Patch 基础与受支持 localized patch 发布/rollback 链路已可用。 优先阅读: diff --git a/docs/guides/bat-workflows.md b/docs/guides/bat-workflows.md index 80a35f5..f434f63 100644 --- a/docs/guides/bat-workflows.md +++ b/docs/guides/bat-workflows.md @@ -119,8 +119,8 @@ bat i18n validate \ --translation-file /tmp/bat-workbench.json ``` -报告会区分未审核、原文未变化、可直接 `i18n publish` 的 TextAsset,以及必须使用 -`parse repack` 的 TypeTree/嵌套 archive 条目。 +报告会区分未审核、原文未变化、可直接 `i18n publish` 的受支持 TextAsset/ +TypeTree string field 条目,以及需要先经过独立 repack 流程的 ZIP 内或其他不支持条目。 发布汉化 release: @@ -131,7 +131,12 @@ bat i18n publish \ --translation-file /tmp/bat-workbench.json ``` -发布只接受当前实现支持的直接 TextAsset 条目;TypeTree 字段和 zip 内 bundle 使用 `parse repack` 的 spec 单独处理。`--force` 不覆盖已有目录,而是生成独立的 `-manual-` 汉化 release ID;也可以用 `--localized-release-id` 显式指定新 ID。因此强制发布仍保留旧 release 和 rollback 信息。 +发布接受当前实现支持的直接 TextAsset、TypeTree string field 和 managed-reference +string field 条目;zip 内 bundle 和其他不支持条目使用 `parse repack` 的 spec +单独处理。`--force` 不覆盖已有目录,而是生成独立的 +`-manual-` 汉化 release ID;也可以用 +`--localized-release-id` 显式指定新 ID。因此强制发布仍保留旧 release 和 rollback +信息。 当前 `i18n run` 是离线工作流:刷新 TextUnit 队列,并可用 `--translation-file` 导出工作台;真实 provider 由单独的 worker 命令消费 `translation-tasks.sqlite`。 @@ -266,6 +271,45 @@ Rust `translation.tasks` / `translation.handoff`,不在 Go 侧维护状态。 `POST /admin/control/translation-proofread` 会把当前汉化 workflow 标记为人工校对中; 该接口只转发 `translation.proofread`,不会改动已发布汉化 release 指针。 +## localized patch 发布与回滚 + +`i18n publish` 会在独立的 `.staging/` 中复制当前官方 +release,校验工作台与当前 TextUnit 索引的 source/location 一致后,写入已有支持 +范围内的 TextAsset、TypeTree string field 和 managed-reference string field +patch。校验通过后才原子切换 `localized/current`,并在 release manifest 中记录 +源/目标 BLAKE3、字节数、patch kind、TextUnit、provider、review 和 rollback +信息。ZIP 内 bundle 不会被静默改写。 + +使用人工编辑的工作台发布: + +```bash +bat i18n publish \ + --translation-file /tmp/bat-workbench.json \ + --localized-release-id release-manual-1 +``` + +使用已完成 provider worker 的译文结果发布: + +```bash +bat i18n publish \ + --from-worker \ + --localized-release-id release-worker-1 +``` + +发布失败会清理 staging,不切换 `current`。当前 release 的 rollback 目标由 +manifest 记录,执行后删除本次版本目录并恢复上一版本;没有上一版本时移除 +`current`: + +```bash +bat i18n rollback --localized-release-id release-worker-1 +``` + +Rust RPC 方法为 `localized.publish` 和 `localized.rollback`;bat-api 对应为 +`POST /admin/control/localized-publish`、`POST /admin/control/localized-rollback` +以及鉴权的 `GET /admin/translation/status`。发布请求必须且只能包含 +`translation_file` 或 `from_worker=true`;rollback 可省略 release ID 以操作当前 +release。Go 只做鉴权、参数校验和转发,状态与产物仍由 Rust 持有。 + ## 边界 解析器新增类型覆盖和新的解析格式仍受 `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 3a2dd07..c7b3d48 100644 --- a/docs/reference/rpc-backend-api.md +++ b/docs/reference/rpc-backend-api.md @@ -292,6 +292,8 @@ token 不会进入报告、任务记录或调试输出。 | 方法 | 状态 | params | data | |---|---|---|---| | `localized.status` | 已实现 | `null` | 汉化发布状态、当前官方 release 匹配关系和汉化输出目录。 | +| `localized.publish` | 已实现 | `{ "translation_file": "...", "localized_release_id": "...", "force": false }` 或 `{ "from_worker": true, "localized_release_id": "...", "force": false }` | 已校验并发布的汉化 release、manifest 和完整性报告。 | +| `localized.rollback` | 已实现 | `{ "localized_release_id": "..." }`,可省略 | 删除当前 release、恢复 manifest 记录的上一 release 和新状态。 | `localized.status` 严格按 daemon / `.env` 中的 `BAT_LOCALIZED_OUTPUT` 或 `--localized-output` 查询汉化产物目录,不把 `./bat-resources` 与 @@ -392,7 +394,7 @@ daemon 重启后仍处于 `queued` 或 `running` 的历史任务会被标记为 `data` 会返回 source / patch 或 replacement / target 的 size 与 BLAKE3。`target_path` 不能与输入文件相同。 -仍关闭的范围:发布级 `patch build` / `patch rollback`、复杂 UnityFS 语义编辑、 +仍关闭的范围:通用 manifest 驱动的发布级 `patch build` / `patch rollback`、复杂 UnityFS 语义编辑、 `unityfs.inspect`、通用 manifest 驱动 release 切换。调用这些规划方法仍返回 `BAT-ERR-700003`。 @@ -452,8 +454,9 @@ 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`、`translation.tasks`、`translation.handoff`、 - `translation.task.update`、`translation.worker.run`、`translation.proofread`、 + `localized.status`、`localized.publish`、`localized.rollback`、 + `translation.tasks`、`translation.handoff`、`translation.task.update`、 + `translation.worker.run`、`translation.proofread`、 `task.*` 和三个 `unityfs.patch_*` 方法。 - `resource.index` 和 `patch.apply` 当前没有专用 typed helper;需要直接使用 `Call`,并仍须遵守 本契约的参数和响应定义。 @@ -466,6 +469,7 @@ CLI 对应关系: | `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.tasks`、`translation.handoff`、`translation.task.update`、`translation.worker.run`、`translation.proofread` | 鉴权后的 dashboard 翻译任务查询、交接视图、状态回写、provider worker 触发与人工校对标记 | +| `LocalizedBackend` | `localized.status`、`localized.publish`、`localized.rollback` | 鉴权后的汉化 release 状态、发布与显式回滚 | `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 12df15b..f687bcc 100644 --- a/docs/reports/CURRENT_GAPS.md +++ b/docs/reports/CURRENT_GAPS.md @@ -123,7 +123,7 @@ - `ResourceImportService` 的 UnityFS 摘要已经能暴露解包文件数、serialized file 数、TextAsset 名称、TextUnit 数量/格式和字段诊断。 - `MonoBehaviour`、`ScriptableObject` 已有基础 TypeTree 字段级反序列化和字符串提取入口;array/vector/staticvector/`List`/`HashSet`/map 元素与 TypeTree-covered managed reference registry payload 已保留独立 field path、offset 和 byte size,enum `value__` backing field 会暴露为语义化 `{type_name, storage_type, value}`,`LayerMask` / `BitField` 的 `m_Bits` backing field 会暴露为语义化 `{type_name, storage_type, bits}`,managed-reference full typename 可拆为 assembly/namespace/class,常见 `m_ManagedReferences` / `RefIds` / `m_RefIds` / verbose type 字段命名、`managedReference*` / `serializedReference*` prefixed metadata、`SerializedReference` 节点 alias 和 `data` / `value` / `payload` / `object` / `managedReferencePayload` / `referencePayload` / `serializedReferencePayload` / `managedReferenceValue` / `referenceValue` / `serializedReferenceValue` / `managedReferenceObject` / `referenceObject` / `serializedReferenceObject` / `managedReferenceData` / `referenceData` / `serializedData` / `serializedReferenceData` payload 命名已有回归覆盖,多记录 registry 聚合已有单元回归,TextUnit 提取会跳过 registry 元数据字符串并把它们作为 payload 文本上下文,fallback 字段遍历也会跳过常见 managed-reference 元数据别名,并按 `RefIds[n]` 等记录前缀或子字段推导 metadata 写入 payload TextUnit context;当前可对 string、bool、integer、float raw bits、bytes、enum、bit_field、常见固定 Unity float/int/hash 值类型的 leaf/direct-child 形态、unknown fixed-size raw bytes 同长度替换、PPtr、object 字段组合和 TypeTree schema 支撑的 array/vector/List/HashSet/map 整体替换执行文件级 patch,map entry 的 `first/second` 与 `key/value` 字段命名已有 serialized 和 UnityFS 重建回归,ScriptableObject `key/value` map 解析、变长替换和 UnityFS 重建已有专门回归;真实版本差异、未见样本驱动的复杂 managed reference registry / map entry 变体、unknown 字段结构语义和发布级重打包入口仍未完成。 -issue 43 例外说明:新增的 `parse repack` 只编排已有 TextAsset、TypeTree string 和受支持语义 field patch 实现;它不新增解析器类型、字段族或 catalog 覆盖。人工翻译工作台和有限 TextAsset 发布也只消费已有 TextUnit 输出。 +issue 43 例外说明:新增的 `parse repack` 只编排已有 TextAsset、TypeTree string 和受支持语义 field patch 实现;它不新增解析器类型、字段族或 catalog 覆盖。人工翻译工作台和 localized publish 只消费已有 TextUnit 输出,当前 publish 支持 TextAsset、TypeTree string field 和 managed-reference string field。 影响: @@ -149,7 +149,7 @@ issue 43 例外说明:新增的 `parse repack` 只编排已有 TextAsset、Typ - 见 `docs/architecture/assetbundle.md` 的 P2/P3/P5。 -### G-006:Patch 引擎基础已落地,发布入口仍未完成 +### G-006:Patch 引擎基础已落地,通用发布入口仍未完成 状态:**部分关闭** @@ -358,7 +358,7 @@ issue 43 例外说明:新增的 `parse repack` 只编排已有 TextAsset、Typ - `cargo test -p bat-infrastructure version_state` - `cargo test -p bat-infrastructure --test official_game_main_config_bootstrap` -### G-011D:汉化发布状态与 Patch 发布流程未完成 +### G-011D:受支持汉化发布已完成,通用 Patch 发布流程仍未完成 状态:**部分关闭** @@ -368,13 +368,14 @@ issue 43 例外说明:新增的 `parse repack` 只编排已有 TextAsset、Typ - CLI 支持 `--localized-output` / `BAT_LOCALIZED_OUTPUT`,并拒绝官方目录和汉化目录相同或互相嵌套。 - 官方同步报告新增 `localized_release_status=not_localized`,明确表示原版资源已发布、汉化资源未发布;`translation.proofread` 只写 workflow 标记,不会把 `localized_release_status` 从 `localized` 回退成 `not_localized`。 - `LocalizedPatchService` 已具备将给定汉化文件按官方相对路径发布到 `--localized-output` / `BAT_LOCALIZED_OUTPUT` 指定目录下的 `.staging/`、校验后移动到 `versions/`、原子切换 `current` 并写入 `localized-version-state.json` 的基础能力。 -- `LocalizedPatchService` 已写入结构化 `localized-patch-manifest.json`,记录 TextAsset 操作、原始/汉化 hash、size、byte delta 和 rollback 信息;发布前后会校验 manifest hash/size 与 current symlink,失败时清理 staging / 未完成 version。 +- `LocalizedPatchService` 已写入结构化 `localized-patch-manifest.json`,记录 TextAsset、TypeTree string field 和 managed-reference string field 操作、原始/汉化 hash、size、byte delta、TextUnit/provider/review 和 rollback 信息;发布前后会校验 manifest hash/size 与 current symlink,失败时清理 staging / 未完成 version。 +- 已完成从 workbench 或 completed provider worker 结果生成受支持 patch operation 的流程;`localized.publish` / `localized.rollback` RPC、`i18n publish` / `i18n rollback` CLI,以及 bat-api 的鉴权 status/publish/rollback 接口已落地。 - `localized.status` RPC 会读取 `.env` / daemon 配置中的汉化输出目录,校验汉化状态是否匹配当前官方 release,且要求 patch manifest 存在并匹配 release,避免写死 `./bat-localized` 或误报手工状态;其中 `status` / `status_code` 返回生命周期状态,`localized_release_status` 保留 `localized` / `not_localized` 发布标签,`translation_workflow_status=manual_proofreading` 仅描述人工校对流程,不影响已发布汉化资源继续正常发布。 仍未完成: - 真实 Patch/翻译构建阶段尚未从 `crowdin-textunit-queue.json`、翻译记忆和 Crowdin 结果生成完整汉化文件集合。 -- 通用 Binary/JSON/Text Patch crate 基础和文件级 `patch.apply` / UnityFS 写入入口已经实现;复杂 AssetBundle 重打包、发布级 patch build/rollback 和与汉化发布流程的统一仍未完成。 +- 通用 Binary/JSON/Text Patch crate 基础和文件级 `patch.apply` / UnityFS 写入入口已经实现;通用 manifest 驱动的跨类型发布、复杂 AssetBundle 重打包和双发布后的清理策略仍未完成。 - 尚未实现原版资源与汉化资源双发布后的查询、分发和清理策略。 验收: diff --git a/docs/reports/GO_STATUS.md b/docs/reports/GO_STATUS.md index 0fa506c..9a5d507 100644 --- a/docs/reports/GO_STATUS.md +++ b/docs/reports/GO_STATUS.md @@ -95,7 +95,7 @@ | 组件 | 路径 | 状态 | 说明 | |---|---|---|---| | Module | `go.mod` → `bat-api` | 已用 | 服务层模块名 | -| RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helper;typed helper 覆盖 daemon 已实现控制/查询、`resource.state/sync/verify/repair/manifest/list`、`catalog.*`、`parse.*`、`localized.status`、`task.*`、`translation.tasks`、`translation.handoff`、`translation.task.update`、`translation.worker.run`、`translation.proofread` 和文件级 UnityFS patch 调用;`resource.index`、`patch.apply` 仍通过通用 `Call` 走同一 contract;fake transport 单测,配合 `internal/api/testdata/contract/` 固化 Rust 输出 mirror | +| RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helper;typed helper 覆盖 daemon 已实现控制/查询、`resource.state/sync/verify/repair/manifest/list`、`catalog.*`、`parse.*`、`localized.status/publish/rollback`、`task.*`、`translation.tasks`、`translation.handoff`、`translation.task.update`、`translation.worker.run`、`translation.proofread` 和文件级 UnityFS patch 调用;`resource.index`、`patch.apply` 仍通过通用 `Call` 走同一 contract;fake transport 单测,配合 `internal/api/testdata/contract/` 固化 Rust 输出 mirror | | 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理控制白名单 + `.env` | | 试验 CLI | `cmd/bat` | **试验** | doctor 固定 ok;manifest/sync 走 FFI | | FFI | `internal/ffi` | **可选** | 需 `build-ffi` | diff --git a/infrastructure/src/bin/bat/app.rs b/infrastructure/src/bin/bat/app.rs index 67ce70e..8221a75 100644 --- a/infrastructure/src/bin/bat/app.rs +++ b/infrastructure/src/bin/bat/app.rs @@ -7,24 +7,25 @@ use bat_core::{ApiError, ErrorCode}; use bat_infrastructure::DEFAULT_DOWNLOAD_CONCURRENCY; use bat_infrastructure::{ apply_patch_file, apply_unityfs_field_patch_file, apply_unityfs_string_field_patch_file, - apply_unityfs_text_asset_patch_file, changed_endpoint_urls, diff_extended_snapshot, - export_translation_workbench, gc_orphan_staging, get_translation_entry, lexical_absolute, - localized_text_asset_patches, open_append_file, read_download_manifest_at, - read_file_no_symlink, read_localized_patch_manifest_at, read_localized_version_state, - read_parse_cache_at, read_snapshot, read_textunit_index_at, read_translation_workbench, - read_version_state, redact_proxy_url, repack_bundle, resolve_curl_proxy, set_translation, - unset_translation, 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, OfficialUpdateReport, - OfficialUpdateService, OfficialUpdateSnapshot, OfficialUpdateStatus, - OfficialVerificationSummary, OfficialVersionRecord, OfficialVersionState, PatchApplyKind, - PatchApplyParams, PatchApplyReport, ReleaseFlowStatusCode, RepackReport, - SqliteResourceRepository, SqliteTranslationTaskRepository, TranslationProviderKind, - TranslationTaskStatus, TranslationWorkerConfig, UnityFsFieldPatchParams, UnityFsPatchReport, + apply_unityfs_text_asset_patch_file, changed_endpoint_urls, + completed_worker_translation_workbench, diff_extended_snapshot, export_translation_workbench, + gc_orphan_staging, get_translation_entry, lexical_absolute, localized_patch_operations, + open_append_file, read_download_manifest_at, read_file_no_symlink, + read_localized_patch_manifest_at, read_localized_version_state, read_parse_cache_at, + read_snapshot, read_textunit_index_at, read_translation_workbench, read_version_state, + redact_proxy_url, repack_bundle, resolve_curl_proxy, set_translation, unset_translation, + validate_output_root, validate_runtime_state_dir, validate_translation_workbench, + write_file_atomic, write_official_textunit_queues, CurlProxyConfig, CurlProxyMode, + LocalizedPatchConfig, LocalizedPatchReport, LocalizedPatchService, LocalizedRollbackReport, + OfficialEndpointMarkerRole, OfficialFailedVersionRecord, OfficialParseCacheService, + OfficialParseConfig, OfficialResourceHashVerification, OfficialResourceVerification, + OfficialServerInfoSource, OfficialTextUnitQuery, OfficialTextUnitTaskQuery, + OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, + OfficialUpdateSnapshot, OfficialUpdateStatus, OfficialVerificationSummary, + OfficialVersionRecord, OfficialVersionState, PatchApplyKind, PatchApplyParams, + PatchApplyReport, ReleaseFlowStatusCode, RepackReport, SqliteResourceRepository, + SqliteTranslationTaskRepository, TranslationProviderKind, TranslationTaskStatus, + TranslationWorkerConfig, UnityFsFieldPatchParams, UnityFsPatchReport, UnityFsStringFieldPatchParams, UnityFsTextAssetPatchParams, CROWDIN_TEXTUNIT_QUEUE_FILE, DEFAULT_TRANSLATION_CONCURRENCY, DEFAULT_TRANSLATION_LEASE_SECONDS, DEFAULT_TRANSLATION_MAX_ATTEMPTS, DEFAULT_TRANSLATION_RETRY_BACKOFF, LOCALIZED_CURRENT_LINK, @@ -84,6 +85,7 @@ use translation_query::{ update_translation_task_status_report, }; use workflow_commands::{ + localized_rollback_report, publish_localized_report, run_localized_rollback, run_parse_clear_cache, run_parse_once, run_publish_localized, run_repack, run_translate_once, run_translation_get, run_translation_proofread, run_translation_set, run_translation_task_update, run_translation_unset, run_translation_validate, @@ -244,6 +246,10 @@ fn run() -> anyhow::Result { run_repeated_workflow(&options, "publish-localized", run_publish_localized)?; Ok(0) } + CliCommand::LocalizedRollback => { + run_localized_rollback(&options)?; + Ok(0) + } CliCommand::ScheduleList => { run_schedule_list(&options)?; Ok(0) @@ -385,6 +391,7 @@ struct CliOptions { translation_text_file: Option, translation_failure_reason: Option, translation_provider_run_id: Option, + translation_from_worker: bool, translation_provider: Option, translation_fixture: Option, worker_concurrency: usize, @@ -479,6 +486,7 @@ impl Default for CliOptions { translation_text_file: None, translation_failure_reason: None, translation_provider_run_id: None, + translation_from_worker: false, translation_provider: None, translation_fixture: None, worker_concurrency: DEFAULT_TRANSLATION_CONCURRENCY, @@ -575,6 +583,7 @@ enum CliCommand { TranslationProofread, Repack, PublishLocalized, + LocalizedRollback, ScheduleList, ScheduleAdd, ScheduleUpdate, @@ -648,6 +657,7 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> { registry, queue: task_tx, base_config: options.config.clone(), + sync_lock: Arc::clone(&sync_lock), restart_controller: spawn_daemon_restart_controller, }; let server = @@ -1023,6 +1033,35 @@ struct DaemonRpcAck { force: Option, } +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct LocalizedPublishRpcParams { + #[serde(default, alias = "workbench", alias = "workbench_path")] + translation_file: Option, + #[serde( + default, + alias = "translation_from_worker", + alias = "from_worker_results" + )] + from_worker: bool, + #[serde(default, alias = "release_id")] + localized_release_id: Option, + #[serde(default)] + force: bool, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct LocalizedRollbackRpcParams { + #[serde( + default, + alias = "release_id", + alias = "expected_release_id", + alias = "expected_localized_release_id" + )] + localized_release_id: Option, +} + // 规范方法名采用国际惯例的 `.`。`bat.*` 保留为向后兼容别名。 const RPC_METHOD_STATUS: &str = "daemon.status"; const RPC_METHOD_STOP: &str = "daemon.stop"; @@ -1053,6 +1092,8 @@ const RPC_METHOD_TRANSLATION_TASK_UPDATE: &str = "translation.task.update"; const RPC_METHOD_TRANSLATION_PROOFREAD: &str = "translation.proofread"; const RPC_METHOD_TRANSLATION_WORKER_RUN: &str = "translation.worker.run"; const RPC_METHOD_LOCALIZED_STATUS: &str = "localized.status"; +const RPC_METHOD_LOCALIZED_PUBLISH: &str = "localized.publish"; +const RPC_METHOD_LOCALIZED_ROLLBACK: &str = "localized.rollback"; const RPC_METHOD_CATALOG_STATUS: &str = "catalog.status"; const RPC_METHOD_CATALOG_VERSIONS: &str = "catalog.versions"; const RPC_METHOD_CATALOG_DIFF: &str = "catalog.diff"; @@ -2048,12 +2089,18 @@ fn dispatch_rpc_method( "translation.task.update", update_translation_task_status_report(state_dir, request.params.as_ref()), ), - RPC_METHOD_TRANSLATION_PROOFREAD => rpc_envelope_from_result( - request_id, - "translation.proofread", - mark_localized_manual_proofreading_report(state_dir, &tasks.base_config) - .and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)), - ), + RPC_METHOD_TRANSLATION_PROOFREAD => { + let _sync_guard = tasks + .sync_lock + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + rpc_envelope_from_result( + request_id, + "translation.proofread", + mark_localized_manual_proofreading_report(state_dir, &tasks.base_config) + .and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)), + ) + } RPC_METHOD_TRANSLATION_WORKER_RUN => { let config = match rpc_translation_worker_config(request.params.as_ref()) { Ok(config) => config, @@ -2061,11 +2108,53 @@ fn dispatch_rpc_method( }; enqueue_translation_worker_envelope(tasks, config, request_id) } - RPC_METHOD_LOCALIZED_STATUS => rpc_envelope_from_result( - request_id, - "localized.status", - build_localized_status_report(state_dir, &tasks.base_config), - ), + RPC_METHOD_LOCALIZED_STATUS => { + let _sync_guard = tasks + .sync_lock + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + rpc_envelope_from_result( + request_id, + "localized.status", + build_localized_status_report(state_dir, &tasks.base_config), + ) + } + RPC_METHOD_LOCALIZED_PUBLISH => { + let _sync_guard = tasks + .sync_lock + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + match localized_publish_rpc_report( + state_dir, + &tasks.base_config, + request.params.as_ref(), + ) { + Ok(report) => rpc_envelope_from_result( + request_id, + RPC_METHOD_LOCALIZED_PUBLISH, + serde_json::to_value(report).map_err(anyhow::Error::from), + ), + Err(error) => rpc_envelope_error(request_id, error), + } + } + RPC_METHOD_LOCALIZED_ROLLBACK => { + let _sync_guard = tasks + .sync_lock + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + match localized_rollback_rpc_report( + state_dir, + &tasks.base_config, + request.params.as_ref(), + ) { + Ok(report) => rpc_envelope_from_result( + request_id, + RPC_METHOD_LOCALIZED_ROLLBACK, + serde_json::to_value(report).map_err(anyhow::Error::from), + ), + Err(error) => rpc_envelope_error(request_id, error), + } + } RPC_METHOD_CATALOG_STATUS => rpc_envelope_from_result( request_id, "catalog.status", @@ -3150,6 +3239,98 @@ fn rpc_struct_params( }) } +fn rpc_optional_struct_params( + params: Option<&serde_json::Value>, + method: &'static str, +) -> Result { + let Some(params) = params else { + return Ok(T::default()); + }; + if params.is_null() { + return Ok(T::default()); + } + if !params.is_object() { + return Err(ApiError::new( + ErrorCode::RPC_INVALID_PARAMS, + method, + "params 必须是 JSON object", + )); + } + serde_json::from_value(params.clone()).map_err(|error| { + ApiError::new( + ErrorCode::RPC_INVALID_PARAMS, + method, + format!("params 无效:{error}"), + ) + }) +} + +fn localized_publish_rpc_report( + state_dir: &Path, + base_config: &OfficialUpdateConfig, + params: Option<&serde_json::Value>, +) -> Result { + let params: LocalizedPublishRpcParams = + rpc_optional_struct_params(params, RPC_METHOD_LOCALIZED_PUBLISH)?; + let translation_file = params + .translation_file + .filter(|path| !path.as_os_str().is_empty()); + if translation_file.is_some() == params.from_worker { + return Err(ApiError::new( + ErrorCode::RPC_INVALID_PARAMS, + RPC_METHOD_LOCALIZED_PUBLISH, + "localized.publish 必须且只能指定 translation_file 或 from_worker", + )); + } + let mut config = base_config.clone(); + config.force = params.force; + let options = CliOptions { + command: CliCommand::PublishLocalized, + config, + state_dir: state_dir.to_path_buf(), + translation_file, + translation_from_worker: params.from_worker, + localized_release_id: normalize_optional_rpc_string(params.localized_release_id), + ..CliOptions::default() + }; + publish_localized_report(&options).map_err(|error| { + ApiError::new( + ErrorCode::INTERNAL, + RPC_METHOD_LOCALIZED_PUBLISH, + error.to_string(), + ) + }) +} + +fn localized_rollback_rpc_report( + state_dir: &Path, + base_config: &OfficialUpdateConfig, + params: Option<&serde_json::Value>, +) -> Result { + let params: LocalizedRollbackRpcParams = + rpc_optional_struct_params(params, RPC_METHOD_LOCALIZED_ROLLBACK)?; + let options = CliOptions { + command: CliCommand::LocalizedRollback, + config: base_config.clone(), + state_dir: state_dir.to_path_buf(), + localized_release_id: normalize_optional_rpc_string(params.localized_release_id), + ..CliOptions::default() + }; + localized_rollback_report(&options).map_err(|error| { + ApiError::new( + ErrorCode::INTERNAL, + RPC_METHOD_LOCALIZED_ROLLBACK, + error.to_string(), + ) + }) +} + +fn normalize_optional_rpc_string(value: Option) -> Option { + value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + fn rpc_translation_worker_config( params: Option<&serde_json::Value>, ) -> Result { @@ -4484,6 +4665,24 @@ impl HumanReport for LocalizedPatchReport { } } +impl HumanReport for LocalizedRollbackReport { + fn print_human(&self) -> anyhow::Result<()> { + print_title("汉化 release 回滚"); + print_field("命令", self.command); + print_field("状态", self.status); + print_field("回滚 release", &self.rolled_back_release_id); + print_optional_field("恢复 release", self.restored_release_id.as_deref()); + print_path_field("汉化输出目录", &self.localized_output_root); + print_path_field("current", &self.current_path); + print_path_field("状态文件", &self.state_path); + print_path_field("删除版本目录", &self.removed_version_path); + print_optional_path_field("恢复 current 目标", self.restored_current_target.as_ref()); + print_field("新状态", &self.state.status); + print_optional_field("当前 release", self.state.current_release_id.as_deref()); + Ok(()) + } +} + impl HumanReport for bat_infrastructure::LocalizedTranslationWorkflowReport { fn print_human(&self) -> anyhow::Result<()> { print_title("汉化工作流状态"); @@ -6971,6 +7170,10 @@ fn parse_args_with_env( ensure_command_not_set(options.command, "localized-status")?; options.command = CliCommand::LocalizedStatus; } + "localized-rollback" => { + ensure_command_not_set(options.command, "localized-rollback")?; + options.command = CliCommand::LocalizedRollback; + } "resource-index" => { ensure_command_not_set(options.command, "resource-index")?; options.command = CliCommand::ResourceIndex; @@ -7069,6 +7272,9 @@ fn parse_args_with_env( options.translation_text_file = Some(PathBuf::from(next_option_value(&mut args, &flag)?)); } + "--from-worker" | "--translation-from-worker" => { + options.translation_from_worker = true; + } "--failure-reason" | "--reason" => { options.translation_failure_reason = Some(next_option_value(&mut args, &flag)?); } @@ -7589,6 +7795,9 @@ fn parse_args_with_env( { return Err(anyhow::anyhow!("翻译 worker 参数只适用于 i18n worker run")); } + if options.command != CliCommand::PublishLocalized && options.translation_from_worker { + return Err(anyhow::anyhow!("--from-worker 只适用于 i18n publish")); + } match options.command { CliCommand::Status | CliCommand::Stop | CliCommand::Logs => { @@ -7671,6 +7880,13 @@ fn parse_args_with_env( "parse/translate/publish-localized 不能使用 --dry-run" )); } + if matches!(options.command, CliCommand::PublishLocalized) + && options.translation_file.is_some() == options.translation_from_worker + { + return Err(anyhow::anyhow!( + "i18n publish 必须且只能指定 --translation-file 或 --from-worker" + )); + } } CliCommand::ParseClearCache => { if options.watch || options.daemon || options.daemon_child { @@ -7693,9 +7909,13 @@ fn parse_args_with_env( 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() { + if options.config.force + || options.config.dry_run + || options.run_count.is_some() + || options.translation_from_worker + { return Err(anyhow::anyhow!( - "i18n validate 不支持 --force、--dry-run 或 --run-count" + "i18n validate 不支持 --force、--dry-run、--run-count 或 --from-worker" )); } options.progress = false; @@ -7737,6 +7957,7 @@ fn parse_args_with_env( || options.schedule_option_explicit || options.translation_failure_reason.is_some() || options.translation_provider_run_id.is_some() + || options.translation_from_worker || options.proxy_option_explicit || tools_are_non_default(&options.config, &options.env_baseline_config) { @@ -7751,7 +7972,11 @@ fn parse_args_with_env( if options.watch || options.daemon || options.daemon_child { return Err(anyhow::anyhow!("repack 只支持单次执行")); } - if options.config.force || options.sync_option_explicit || options.run_count.is_some() { + if options.config.force + || options.sync_option_explicit + || options.run_count.is_some() + || options.translation_from_worker + { return Err(anyhow::anyhow!("repack 不接受资源同步选项")); } options.progress = false; @@ -7778,6 +8003,7 @@ fn parse_args_with_env( || options.translation_id.is_some() || options.translation_text.is_some() || options.translation_text_file.is_some() + || options.translation_from_worker || options.localized_release_id.is_some() || options.repack_spec.is_some() || options.query_offset != 0 @@ -7828,6 +8054,7 @@ fn parse_args_with_env( || options.translation_text_file.is_some() || options.translation_failure_reason.is_some() || options.translation_provider_run_id.is_some() + || options.translation_from_worker || options.localized_release_id.is_some() || options.repack_spec.is_some() || options.proxy_option_explicit @@ -7882,6 +8109,7 @@ fn parse_args_with_env( || options.translation_text_file.is_some() || options.translation_failure_reason.is_some() || options.translation_provider_run_id.is_some() + || options.translation_from_worker || options.localized_release_id.is_some() || options.repack_spec.is_some() || options.query_option_explicit @@ -7894,6 +8122,41 @@ fn parse_args_with_env( options.progress = false; options.banner = false; } + CliCommand::LocalizedRollback => { + if options.watch || options.daemon || options.daemon_child { + return Err(anyhow::anyhow!("i18n rollback 只支持单次执行或 RPC 调用")); + } + if options.config.force + || options.config.dry_run + || options.run_count.is_some() + || options.sync_option_explicit + || options.proxy_option_explicit + || tools_are_non_default(&options.config, &options.env_baseline_config) + { + return Err(anyhow::anyhow!( + "i18n rollback 只接受 --output、--localized-output、--state-dir、--localized-release-id 和 --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.translation_failure_reason.is_some() + || options.translation_provider_run_id.is_some() + || options.translation_from_worker + || options.repack_spec.is_some() + || options.query_option_explicit + || options.schedule_option_explicit + || options.translation_worker_option_explicit + { + return Err(anyhow::anyhow!( + "i18n rollback 不接受工作台、任务、查询、调度、repack 或 worker 参数" + )); + } + options.progress = false; + options.banner = false; + } CliCommand::ScheduleList | CliCommand::ScheduleAdd | CliCommand::ScheduleUpdate @@ -8103,6 +8366,7 @@ fn parse_translation_command( "unset" | "clear" => CliCommand::TranslationUnset, "validate" => CliCommand::TranslationValidate, "publish" => CliCommand::PublishLocalized, + "rollback" => CliCommand::LocalizedRollback, "proofread" => CliCommand::TranslationProofread, "tasks" => CliCommand::TranslationTasks, "handoff" => CliCommand::TranslationHandoff, @@ -8333,7 +8597,8 @@ fn print_usage(binary: &str) { eprintln!(" i18n handoff Query current translation handoff"); eprintln!(" i18n status Show localized release status for current official release"); eprintln!(" i18n task update Update one provider worker task status"); - eprintln!(" i18n publish Publish a localized release"); + eprintln!(" i18n publish Publish a localized release from a workbench or worker results"); + eprintln!(" i18n rollback Roll back the current localized release"); eprintln!(" i18n schedule Manage translation schedules"); eprintln!(" refresh Run one update check, or ask a live daemon to refresh"); eprintln!(" verify Verify remote plan, local manifest, and official seed hashes"); @@ -8344,6 +8609,7 @@ fn print_usage(binary: &str) { eprintln!(" translation-tasks Query current offline TextUnit translation task status"); eprintln!(" translation-handoff Query current translation job/unit/provider handoff"); eprintln!(" localized-status Show localized release status for current official release"); + eprintln!(" localized-rollback Roll back the current localized release"); eprintln!(" resource-index Query CAS + ResourceRepository index"); eprintln!(" patch-apply Apply a Binary/JSON/Text patch file"); eprintln!(" unityfs-patch-text-asset Patch one UnityFS TextAsset object"); @@ -8381,6 +8647,8 @@ fn print_usage(binary: &str) { eprintln!(" {binary} i18n handoff --json"); eprintln!(" {binary} i18n status --json"); eprintln!(" {binary} i18n publish --translation-file /tmp/bat-workbench.json --force"); + eprintln!(" {binary} i18n publish --from-worker --localized-release-id release-manual-1"); + eprintln!(" {binary} i18n rollback --localized-release-id release-manual-1"); eprintln!(" {binary} status"); eprintln!(" {binary} refresh --force --json"); eprintln!(); @@ -8429,6 +8697,7 @@ 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!(" --from-worker Build publish input from completed provider worker results"); eprintln!(" --failure-reason Provider failure reason for i18n task update"); eprintln!(" --provider-run-id Provider run ID for i18n task update"); eprintln!(" --translation-provider / --provider Provider for i18n worker run (mock/crowdin)"); diff --git a/infrastructure/src/bin/bat/app_tests.rs b/infrastructure/src/bin/bat/app_tests.rs index 7679498..4913501 100644 --- a/infrastructure/src/bin/bat/app_tests.rs +++ b/infrastructure/src/bin/bat/app_tests.rs @@ -299,6 +299,27 @@ fn translation_worker_rpc_params_are_strict_and_accept_aliases() { assert!(rpc_translation_worker_config(Some(&serde_json::json!({"max_tasks": 0}))).is_err()); } +#[test] +fn localized_publish_rpc_params_require_one_input_source() { + let config = OfficialUpdateConfig::default(); + assert!(localized_publish_rpc_report(Path::new("/tmp/bat-state"), &config, None,).is_err()); + assert!(localized_publish_rpc_report( + Path::new("/tmp/bat-state"), + &config, + Some(&serde_json::json!({ + "translation_file": "/tmp/workbench.json", + "from_worker": true + })), + ) + .is_err()); + assert!(localized_publish_rpc_report( + Path::new("/tmp/bat-state"), + &config, + Some(&serde_json::json!({"unexpected": true})), + ) + .is_err()); +} + #[test] fn grouped_workflow_commands_use_short_top_level_aliases() { let options = parse(&[ @@ -388,6 +409,38 @@ fn grouped_workflow_commands_use_short_top_level_aliases() { .unwrap(); assert_eq!(options.command, CliCommand::TranslationValidate); + let options = parse(&[ + "bat", + "i18n", + "publish", + "--from-worker", + "--localized-release-id", + "localized-1", + ]) + .unwrap(); + assert_eq!(options.command, CliCommand::PublishLocalized); + assert!(options.translation_from_worker); + assert_eq!(options.localized_release_id.as_deref(), Some("localized-1")); + assert!(parse(&[ + "bat", + "i18n", + "publish", + "--from-worker", + "--translation-file", + "/tmp/workbench.json", + ]) + .is_err()); + + let options = parse(&[ + "bat", + "i18n", + "rollback", + "--localized-release-id", + "localized-1", + ]) + .unwrap(); + assert_eq!(options.command, CliCommand::LocalizedRollback); + let options = parse(&[ "bat", "i18n", @@ -548,8 +601,13 @@ fn translation_workbench_commands_read_update_and_clear_entries() { serialized_file: Some("CAB-test".to_string()), path_id: Some(7), asset_name: Some("Story".to_string()), + field_path: None, source_text: "原文".to_string(), translated_text: None, + translation_provider: None, + provider_run_id: None, + translated_unix_seconds: None, + review_status: None, format: Some("plain".to_string()), text_source_kind: Some("text_asset".to_string()), }], @@ -2048,6 +2106,7 @@ fn test_task_context_with_config(base_config: OfficialUpdateConfig) -> DaemonTas registry: TaskRegistry::new(), queue, base_config, + sync_lock: Arc::new(Mutex::new(())), restart_controller: test_restart_controller, } } @@ -2217,6 +2276,7 @@ fn dispatch_daemon_doctor_returns_report() { registry: TaskRegistry::new(), queue, base_config, + sync_lock: Arc::new(Mutex::new(())), restart_controller: test_restart_controller, }; @@ -2246,6 +2306,7 @@ fn dispatch_resource_sync_enqueues_task() { registry: TaskRegistry::new(), queue, base_config: OfficialUpdateConfig::default(), + sync_lock: Arc::new(Mutex::new(())), restart_controller: test_restart_controller, }; @@ -2306,6 +2367,7 @@ fn dispatch_resource_repair_enqueues_repair_task() { registry: TaskRegistry::new(), queue, base_config, + sync_lock: Arc::new(Mutex::new(())), restart_controller: test_restart_controller, }; @@ -3597,6 +3659,7 @@ fn dispatch_catalog_refresh_enqueues_task() { registry: TaskRegistry::new(), queue, base_config: OfficialUpdateConfig::default(), + sync_lock: Arc::new(Mutex::new(())), restart_controller: test_restart_controller, }; let envelope = dispatch_rpc_method( @@ -4508,6 +4571,111 @@ fn dispatch_translation_proofread_marks_state_for_dashboard() { ); } +#[cfg(unix)] +#[test] +fn dispatch_localized_rollback_restores_manifest_previous_release() { + use std::os::unix::fs::symlink; + + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + let output_root = temp.path().join("output"); + let localized_root = temp.path().join("localized"); + write_catalog_fixture_with_localized( + &state_dir, + &output_root, + &localized_root, + "bundle-b2", + None, + ); + let previous = localized_root + .join(LOCALIZED_VERSIONS_DIR) + .join("release-1"); + let current = localized_root + .join(LOCALIZED_VERSIONS_DIR) + .join("release-2"); + fs::create_dir_all(&previous).unwrap(); + fs::create_dir_all(¤t).unwrap(); + fs::write( + previous.join(LOCALIZED_PATCH_MANIFEST_FILE), + serde_json::to_vec(&bat_infrastructure::LocalizedPatchManifest { + manifest_version: bat_infrastructure::LOCALIZED_PATCH_MANIFEST_VERSION, + official_release_id: "v-current".to_string(), + localized_release_id: "release-1".to_string(), + generated_unix_seconds: 123, + file_count: 0, + text_asset_operation_count: 0, + files: Vec::new(), + rollback: bat_infrastructure::LocalizedPatchRollbackInfo { + previous_current_target: None, + remove_version_path: previous.clone(), + }, + }) + .unwrap(), + ) + .unwrap(); + fs::write( + current.join(LOCALIZED_PATCH_MANIFEST_FILE), + serde_json::to_vec(&bat_infrastructure::LocalizedPatchManifest { + manifest_version: bat_infrastructure::LOCALIZED_PATCH_MANIFEST_VERSION, + official_release_id: "v-current".to_string(), + localized_release_id: "release-2".to_string(), + generated_unix_seconds: 124, + file_count: 0, + text_asset_operation_count: 0, + files: Vec::new(), + rollback: bat_infrastructure::LocalizedPatchRollbackInfo { + previous_current_target: Some(PathBuf::from("versions/release-1")), + remove_version_path: current.clone(), + }, + }) + .unwrap(), + ) + .unwrap(); + symlink( + Path::new(LOCALIZED_VERSIONS_DIR).join("release-2"), + localized_root.join(LOCALIZED_CURRENT_LINK), + ) + .unwrap(); + fs::write( + localized_root.join(LOCALIZED_VERSION_STATE_FILE), + serde_json::to_vec(&bat_infrastructure::LocalizedVersionState { + state_version: bat_infrastructure::LOCALIZED_VERSION_STATE_VERSION, + official_release_id: "v-current".to_string(), + current_release_id: Some("release-2".to_string()), + status: "localized".to_string(), + translation_workflow_status: None, + updated_unix_seconds: 124, + }) + .unwrap(), + ) + .unwrap(); + let tasks = test_task_context_with_config(OfficialUpdateConfig { + localized_output_root: localized_root.clone(), + ..OfficialUpdateConfig::default() + }); + + let envelope = dispatch_rpc_method( + &rpc_request( + "localized.rollback", + Some(serde_json::json!({"localized_release_id": "release-2"})), + ), + &state_dir, + &new_daemon_control(), + &tasks, + "req-localized-rollback-1".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["status"], "rolled_back"); + assert_eq!(value["data"]["rolled_back_release_id"], "release-2"); + assert_eq!(value["data"]["restored_release_id"], "release-1"); + assert!(!current.exists()); + assert_eq!( + fs::read_link(localized_root.join(LOCALIZED_CURRENT_LINK)).unwrap(), + PathBuf::from("versions/release-1") + ); +} + #[cfg(unix)] #[test] fn localized_status_keeps_published_release_during_manual_proofreading() { diff --git a/infrastructure/src/bin/bat/task_registry.rs b/infrastructure/src/bin/bat/task_registry.rs index 3b3e64f..23a1907 100644 --- a/infrastructure/src/bin/bat/task_registry.rs +++ b/infrastructure/src/bin/bat/task_registry.rs @@ -515,6 +515,8 @@ pub(super) struct DaemonTaskContext { pub(super) registry: TaskRegistry, pub(super) queue: mpsc::Sender, pub(super) base_config: OfficialUpdateConfig, + /// 串行化会读取或修改已发布资源状态的 daemon 操作。 + pub(super) sync_lock: Arc>, pub(super) restart_controller: DaemonRestartController, } diff --git a/infrastructure/src/bin/bat/workflow_commands.rs b/infrastructure/src/bin/bat/workflow_commands.rs index a9f03ea..a4b15b9 100644 --- a/infrastructure/src/bin/bat/workflow_commands.rs +++ b/infrastructure/src/bin/bat/workflow_commands.rs @@ -299,13 +299,25 @@ pub(super) fn run_repack(options: &CliOptions) -> anyhow::Result<()> { 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"))?; +pub(super) fn publish_localized_report( + options: &CliOptions, +) -> anyhow::Result { let (resource_root, official_release_id) = current_official_release(options)?; - let workbench = read_translation_workbench(translation_file)?; + let workbench = if options.translation_from_worker { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + runtime.block_on(completed_worker_translation_workbench( + &resource_root, + &official_release_id, + ))? + } else { + let translation_file = options + .translation_file + .as_ref() + .ok_or_else(|| anyhow::anyhow!("publish-localized 必须指定 --translation-file"))?; + read_translation_workbench(translation_file)? + }; if workbench.official_release_id != official_release_id { return Err(anyhow::anyhow!( "翻译工作台 release={} 与当前官方 release={} 不一致;请重新导出", @@ -319,7 +331,7 @@ pub(super) fn run_publish_localized(options: &CliOptions) -> anyhow::Result<()> "翻译工作台资源根目录与当前 release 不一致;请重新导出" )); } - let patches = localized_text_asset_patches(&resource_root, &workbench)?; + let operations = localized_patch_operations(&resource_root, &workbench)?; let localized_release_id = options.localized_release_id.clone().or_else(|| { options .config @@ -330,17 +342,36 @@ pub(super) fn run_publish_localized(options: &CliOptions) -> anyhow::Result<()> resource_root, options.config.localized_output_root.clone(), official_release_id, - patches, + Vec::new(), ) + .with_operations(operations) .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)?; + LocalizedPatchService::new().publish(&config) +} + +pub(super) fn run_publish_localized(options: &CliOptions) -> anyhow::Result<()> { + let report = publish_localized_report(options)?; print_report(options.output_format, &report) } -fn current_official_release(options: &CliOptions) -> anyhow::Result<(PathBuf, String)> { +pub(super) fn localized_rollback_report( + options: &CliOptions, +) -> anyhow::Result { + LocalizedPatchService::new().rollback( + &options.config.localized_output_root, + options.localized_release_id.as_deref(), + ) +} + +pub(super) fn run_localized_rollback(options: &CliOptions) -> anyhow::Result<()> { + let report = localized_rollback_report(options)?; + print_report(options.output_format, &report) +} + +pub(super) 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)? diff --git a/infrastructure/src/lib.rs b/infrastructure/src/lib.rs index 2c7d66c..eacf06c 100644 --- a/infrastructure/src/lib.rs +++ b/infrastructure/src/lib.rs @@ -48,12 +48,14 @@ pub use import::{ }; pub use localized_patch::{ mark_localized_manual_proofreading, read_localized_patch_manifest_at, - read_localized_version_state, write_localized_version_state, LocalizedPatchConfig, - LocalizedPatchFile, LocalizedPatchIntegrity, LocalizedPatchManifest, LocalizedPatchOperation, + read_localized_version_state, write_localized_version_state, LocalizedFieldPatch, + LocalizedPatchConfig, LocalizedPatchFile, LocalizedPatchInput, LocalizedPatchIntegrity, + LocalizedPatchManifest, LocalizedPatchOperation, LocalizedPatchOperationMetadata, LocalizedPatchReport, LocalizedPatchRollbackInfo, LocalizedPatchService, - LocalizedTextAssetPatch, LocalizedTranslationWorkflowReport, LocalizedVersionState, - LOCALIZED_CURRENT_LINK, LOCALIZED_PATCH_MANIFEST_FILE, LOCALIZED_PATCH_MANIFEST_VERSION, - LOCALIZED_STAGING_DIR, LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING, + LocalizedRollbackReport, LocalizedStringFieldPatch, LocalizedTextAssetPatch, + LocalizedTranslationWorkflowReport, LocalizedVersionState, LOCALIZED_CURRENT_LINK, + LOCALIZED_PATCH_MANIFEST_FILE, LOCALIZED_PATCH_MANIFEST_VERSION, LOCALIZED_STAGING_DIR, + LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING, LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING_LABEL, LOCALIZED_VERSIONS_DIR, LOCALIZED_VERSION_STATE_FILE, LOCALIZED_VERSION_STATE_VERSION, }; @@ -155,10 +157,11 @@ pub use translation_worker::{ MOCK_TRANSLATION_FIXTURE_VERSION, }; pub use translation_workflow::{ - export_translation_workbench, get_translation_entry, localized_text_asset_patches, - read_translation_workbench, repack_bundle, set_translation, unset_translation, - validate_translation_workbench, write_translation_workbench, RepackOperation, RepackReport, - RepackSpec, TranslationWorkbench, TranslationWorkbenchEntry, + completed_worker_translation_workbench, export_completed_worker_translation_workbench, + export_translation_workbench, get_translation_entry, localized_patch_operations, + localized_text_asset_patches, read_translation_workbench, repack_bundle, set_translation, + unset_translation, validate_translation_workbench, write_translation_workbench, + RepackOperation, RepackReport, RepackSpec, TranslationWorkbench, TranslationWorkbenchEntry, TranslationWorkbenchValidationReport, REPACK_SPEC_VERSION, TRANSLATION_WORKBENCH_VERSION, }; diff --git a/infrastructure/src/localized_patch.rs b/infrastructure/src/localized_patch.rs index 5c8f101..89afe3c 100644 --- a/infrastructure/src/localized_patch.rs +++ b/infrastructure/src/localized_patch.rs @@ -1,7 +1,11 @@ -//! Localized release publishing for verified TextAsset patches. +//! Localized release publishing for verified UnityFS text patches. -use bat_assetbundle::{patch_unityfs_text_asset, TextAssetPatch}; +use bat_assetbundle::{ + patch_unityfs_field, patch_unityfs_string_field, patch_unityfs_text_asset, FieldPatch, + StringFieldPatch, TextAssetPatch, +}; use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -37,6 +41,58 @@ pub struct LocalizedTextAssetPatch { pub bundle_path: String, /// TextAsset replacement inside the bundle. pub text_asset: TextAssetPatch, + /// TextUnit/provider metadata recorded in the localized manifest. + pub metadata: Option, +} + +/// One TypeTree string patch operation against a bundle in an official release. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LocalizedStringFieldPatch { + /// Relative path of the UnityFS bundle under the official release. + pub bundle_path: String, + /// String field replacement inside the bundle. + pub string_field: StringFieldPatch, + /// TextUnit/provider metadata recorded in the localized manifest. + pub metadata: Option, +} + +/// One semantic TypeTree patch operation against a bundle in an official release. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LocalizedFieldPatch { + /// Relative path of the UnityFS bundle under the official release. + pub bundle_path: String, + /// Semantic field replacement inside the bundle. + pub field: FieldPatch, + /// TextUnit/provider metadata recorded in the localized manifest. + pub metadata: Option, +} + +/// Supported localized UnityFS patch operation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LocalizedPatchInput { + /// Replace a TextAsset payload. + TextAsset(LocalizedTextAssetPatch), + /// Replace a TypeTree string field. + StringField(LocalizedStringFieldPatch), + /// Replace a supported semantic TypeTree field. + Field(LocalizedFieldPatch), +} + +/// Trace metadata for one localized patch operation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LocalizedPatchOperationMetadata { + /// Stable TextUnit ID from the official TextUnit index. + pub text_unit_id: String, + /// BLAKE3 of the source text validated before publication. + pub source_text_blake3: String, + /// Translation provider that produced the text, if applicable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub translation_provider: Option, + /// Provider run ID that produced the text, if applicable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_run_id: Option, + /// Review state used by the publication input. + pub review_status: String, } /// Configuration for one localized release publication. @@ -57,6 +113,8 @@ pub struct LocalizedPatchConfig { pub force: bool, /// Patch operations to apply. pub patches: Vec, + /// General UnityFS text/field operations to apply. + pub operations: Vec, } impl LocalizedPatchConfig { @@ -74,6 +132,7 @@ impl LocalizedPatchConfig { localized_release_id: None, force: false, patches, + operations: Vec::new(), } } @@ -89,11 +148,67 @@ impl LocalizedPatchConfig { self } + /// Sets general localized patch operations. + pub fn with_operations(mut self, operations: Vec) -> Self { + self.operations = operations; + self + } + fn published_release_id(&self) -> &str { self.localized_release_id .as_deref() .unwrap_or(&self.release_id) } + + fn patch_operations(&self) -> Vec { + let mut operations = self + .patches + .iter() + .cloned() + .map(LocalizedPatchInput::TextAsset) + .collect::>(); + operations.extend(self.operations.iter().cloned()); + operations + } +} + +impl LocalizedPatchInput { + fn bundle_path(&self) -> &str { + match self { + Self::TextAsset(operation) => &operation.bundle_path, + Self::StringField(operation) => &operation.bundle_path, + Self::Field(operation) => &operation.bundle_path, + } + } + + fn apply(&self, input: &[u8]) -> anyhow::Result> { + match self { + Self::TextAsset(operation) => { + Ok(patch_unityfs_text_asset(input, &operation.text_asset)?) + } + Self::StringField(operation) => { + Ok(patch_unityfs_string_field(input, &operation.string_field)?) + } + Self::Field(operation) => Ok(patch_unityfs_field(input, &operation.field)?), + } + } + + fn manifest_operation(&self) -> anyhow::Result { + match self { + Self::TextAsset(operation) => Ok(LocalizedPatchOperation::from_text_asset_patch( + &operation.text_asset, + operation.metadata.as_ref(), + )), + Self::StringField(operation) => Ok(LocalizedPatchOperation::from_string_field_patch( + &operation.string_field, + operation.metadata.as_ref(), + )), + Self::Field(operation) => LocalizedPatchOperation::from_field_patch( + &operation.field, + operation.metadata.as_ref(), + ), + } + } } /// Persisted localized release state. @@ -187,16 +302,38 @@ pub struct LocalizedPatchFile { /// One TextAsset patch operation recorded in the localized patch manifest. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct LocalizedPatchOperation { + /// Patch operation kind, for example `unityfs_text_asset`. + #[serde(default = "default_patch_operation_kind")] + pub patch_kind: String, /// UnityFS directory path of the serialized file. pub serialized_file_path: String, /// Unity object path ID. pub path_id: i64, + /// TypeTree field path for field-level patches. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub field_path: Option, /// Expected TextAsset name, when provided. + #[serde(default, skip_serializing_if = "Option::is_none")] pub expected_name: Option, /// Replacement payload size. pub replacement_bytes: u64, /// BLAKE3 of the replacement payload. pub replacement_blake3: String, + /// Stable TextUnit ID from the official TextUnit index. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub text_unit_id: Option, + /// BLAKE3 of the source text validated before publication. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_text_blake3: Option, + /// Translation provider that produced the text, if applicable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub translation_provider: Option, + /// Provider run ID that produced the text, if applicable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_run_id: Option, + /// Review state used by the publication input. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub review_status: Option, } /// Rollback information recorded for a localized publication. @@ -288,7 +425,34 @@ pub struct LocalizedPatchReport { pub integrity: LocalizedPatchIntegrity, } -/// Applies TextAsset patches and atomically publishes a localized release. +/// Result of rolling back the current localized release. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct LocalizedRollbackReport { + /// Stable command name. + pub command: &'static str, + /// Operation status. + pub status: &'static str, + /// Localized output root. + pub localized_output_root: PathBuf, + /// Atomic current pointer. + pub current_path: PathBuf, + /// Version state path. + pub state_path: PathBuf, + /// Localized release that was current before rollback. + pub rolled_back_release_id: String, + /// Removed current version directory. + pub removed_version_path: PathBuf, + /// Restored localized release ID, if a previous release existed. + #[serde(skip_serializing_if = "Option::is_none")] + pub restored_release_id: Option, + /// Restored current symlink target, if a previous release existed. + #[serde(skip_serializing_if = "Option::is_none")] + pub restored_current_target: Option, + /// New persisted localized version state. + pub state: LocalizedVersionState, +} + +/// Applies supported UnityFS text patches and atomically publishes a localized release. #[derive(Debug, Default, Clone, Copy)] pub struct LocalizedPatchService; @@ -310,7 +474,11 @@ impl LocalizedPatchService { .join(LOCALIZED_VERSIONS_DIR) .join(&published_release_id); let current_path = config.localized_output_root.join(LOCALIZED_CURRENT_LINK); - let previous_current_target = current_symlink_target(¤t_path).ok().flatten(); + validate_config(config).map_err(anyhow::Error::msg)?; + let previous_current_target = current_symlink_target(¤t_path)?; + if let Some(target) = previous_current_target.as_deref() { + validate_previous_current_target(&config.localized_output_root, target)?; + } let version_existed_before = version_path.exists(); match self.publish_inner(config, previous_current_target.clone()) { Ok(report) => Ok(report), @@ -332,6 +500,159 @@ impl LocalizedPatchService { } } + /// Rolls back the current localized release to the manifest-recorded + /// previous current target. + pub fn rollback( + &self, + localized_output_root: &Path, + expected_release_id: Option<&str>, + ) -> anyhow::Result { + ensure_safe_directory_path(localized_output_root, "汉化输出目录") + .map_err(anyhow::Error::msg)?; + let versions_root = localized_output_root.join(LOCALIZED_VERSIONS_DIR); + ensure_safe_directory_path(&versions_root, "汉化 versions 目录") + .map_err(anyhow::Error::msg)?; + let current_path = localized_output_root.join(LOCALIZED_CURRENT_LINK); + let state_path = localized_output_root.join(LOCALIZED_VERSION_STATE_FILE); + let state = read_localized_version_state(localized_output_root)?.ok_or_else(|| { + anyhow::anyhow!("缺少汉化版本状态,无法 rollback:{}", state_path.display()) + })?; + let current_release_id = state + .current_release_id + .clone() + .ok_or_else(|| anyhow::anyhow!("当前没有已发布汉化 release,无法 rollback"))?; + if let Some(expected) = expected_release_id { + if expected != current_release_id { + return Err(anyhow::anyhow!( + "rollback 目标 release 不一致:当前={} 请求={}", + current_release_id, + expected + )); + } + } + let version_path = localized_output_root + .join(LOCALIZED_VERSIONS_DIR) + .join(¤t_release_id); + ensure_safe_directory_path(&version_path, "当前汉化 release") + .map_err(anyhow::Error::msg)?; + if !current_points_to_version(¤t_path, &version_path)? { + return Err(anyhow::anyhow!( + "汉化 current 未指向当前状态 release:current={} version={}", + current_path.display(), + version_path.display() + )); + } + let manifest = read_localized_patch_manifest_at(&version_path)?.ok_or_else(|| { + anyhow::anyhow!( + "缺少当前汉化 release manifest:{}", + version_path.join(LOCALIZED_PATCH_MANIFEST_FILE).display() + ) + })?; + if manifest.localized_release_id != current_release_id { + return Err(anyhow::anyhow!( + "manifest release={} 与当前状态 release={} 不一致", + manifest.localized_release_id, + current_release_id + )); + } + if manifest.official_release_id != state.official_release_id { + return Err(anyhow::anyhow!( + "manifest 官方 release={} 与状态 release={} 不一致", + manifest.official_release_id, + state.official_release_id + )); + } + let remove_version_path = manifest.rollback.remove_version_path.clone(); + let expected_version_path = localized_output_root + .join(LOCALIZED_VERSIONS_DIR) + .join(¤t_release_id); + if lexical_absolute(&remove_version_path).map_err(anyhow::Error::msg)? + != lexical_absolute(&expected_version_path).map_err(anyhow::Error::msg)? + { + return Err(anyhow::anyhow!( + "rollback manifest 删除路径必须指向当前 release:{}", + current_release_id + )); + } + ensure_path_within_root(localized_output_root, &remove_version_path) + .map_err(anyhow::Error::msg)?; + let restored_release_id = + match manifest.rollback.previous_current_target.as_deref() { + None => None, + Some(target) => Some(release_id_from_current_target(target).ok_or_else(|| { + anyhow::anyhow!("rollback manifest 上一 current 目标格式无效") + })?), + }; + let mut restored_manifest = None; + if let Some(previous_target) = manifest.rollback.previous_current_target.as_deref() { + let previous_path = localized_output_root.join(previous_target); + ensure_path_within_root(localized_output_root, &previous_path) + .map_err(anyhow::Error::msg)?; + ensure_safe_directory_path(&previous_path, "上一汉化 release") + .map_err(anyhow::Error::msg)?; + if !previous_path.is_dir() { + return Err(anyhow::anyhow!( + "rollback 记录的上一汉化 release 不存在:{}", + previous_path.display() + )); + } + restored_manifest = Some( + read_localized_patch_manifest_at(&previous_path)?.ok_or_else(|| { + anyhow::anyhow!( + "rollback 记录的上一汉化 release 缺少 manifest:{}", + previous_path.display() + ) + })?, + ); + } + + restore_current_symlink( + localized_output_root, + ¤t_path, + manifest.rollback.previous_current_target.as_ref(), + )?; + remove_owned_path(&remove_version_path)?; + + let previous_official_release_id = state.official_release_id; + let previous_workflow_status = state.translation_workflow_status; + let restored_official_release_id = restored_manifest + .as_ref() + .map(|manifest| manifest.official_release_id.clone()) + .unwrap_or_else(|| previous_official_release_id.clone()); + let translation_workflow_status = + if restored_official_release_id == previous_official_release_id { + previous_workflow_status + } else { + None + }; + let new_state = LocalizedVersionState { + state_version: LOCALIZED_VERSION_STATE_VERSION, + official_release_id: restored_official_release_id, + current_release_id: restored_release_id.clone(), + status: if restored_release_id.is_some() { + "localized".to_string() + } else { + "not_localized".to_string() + }, + translation_workflow_status, + updated_unix_seconds: unix_seconds_now(), + }; + write_localized_version_state(localized_output_root, &new_state)?; + + Ok(LocalizedRollbackReport { + command: "localized.rollback", + status: "rolled_back", + localized_output_root: localized_output_root.to_path_buf(), + current_path, + state_path, + rolled_back_release_id: current_release_id, + removed_version_path: remove_version_path, + restored_release_id, + restored_current_target: manifest.rollback.previous_current_target, + state: new_state, + }) + } + fn publish_inner( &self, config: &LocalizedPatchConfig, @@ -350,53 +671,85 @@ impl LocalizedPatchService { let state_path = config .localized_output_root .join(LOCALIZED_VERSION_STATE_FILE); + let versions_root = config.localized_output_root.join(LOCALIZED_VERSIONS_DIR); + let staging_root = config.localized_output_root.join(LOCALIZED_STAGING_DIR); + ensure_safe_directory_path(&versions_root, "汉化 versions 目录") + .map_err(anyhow::Error::msg)?; + ensure_safe_directory_path(&staging_root, "汉化 staging 目录") + .map_err(anyhow::Error::msg)?; let patch_manifest_path = version_path.join(LOCALIZED_PATCH_MANIFEST_FILE); let previous_state = read_localized_version_state(&config.localized_output_root)?; let translation_workflow_status = previous_state .filter(|state| state.official_release_id == config.release_id) .and_then(|state| state.translation_workflow_status); - if version_path.exists() { - let message = if config.force { - "localized release target already exists; forced publication requires a distinct localized release id" - } else { - "localized release already exists" - }; - return Err(anyhow::anyhow!("{message}: {}", version_path.display())); + match fs::symlink_metadata(&version_path) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(anyhow::anyhow!( + "汉化 release 目标不能是 symlink:{}", + version_path.display() + )); + } + Ok(metadata) if !metadata.is_dir() => { + return Err(anyhow::anyhow!( + "汉化 release 目标已存在但不是目录:{}", + version_path.display() + )); + } + Ok(_) => { + let message = if config.force { + "localized release target already exists; forced publication requires a distinct localized release id" + } else { + "localized release already exists" + }; + return Err(anyhow::anyhow!("{message}: {}", version_path.display())); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), } remove_owned_staging(&staging)?; fs::create_dir_all(&staging)?; copy_tree(&config.official_release_root, &staging)?; - let mut changed_files = Vec::with_capacity(config.patches.len()); - for operation in &config.patches { - let target = staging.join(Path::new(&operation.bundle_path)); + let operations = config.patch_operations(); + let mut grouped = BTreeMap::>::new(); + for operation in operations { + grouped + .entry(operation.bundle_path().to_string()) + .or_default() + .push(operation); + } + + let mut changed_files = Vec::with_capacity(grouped.len()); + for (bundle_path, operations) in grouped { + let target = staging.join(Path::new(&bundle_path)); ensure_path_within_root(&staging, &target).map_err(anyhow::Error::msg)?; ensure_safe_file_target(&staging, &target, "汉化 patch 输入") .map_err(anyhow::Error::msg)?; let original = fs::read(&target)?; - let patched = patch_unityfs_text_asset(&original, &operation.text_asset) - .map_err(|error| anyhow::anyhow!("{}: {error}", operation.bundle_path))?; + let mut patched = original.clone(); + let mut manifest_operations = Vec::with_capacity(operations.len()); + for operation in operations { + patched = operation + .apply(&patched) + .map_err(|error| anyhow::anyhow!("{bundle_path}: {error}"))?; + manifest_operations.push(operation.manifest_operation()?); + } if original == patched { - return Err(anyhow::anyhow!( - "patch produced no change: {}", - operation.bundle_path - )); + return Err(anyhow::anyhow!("patch produced no change: {bundle_path}")); } write_file_atomic(&target, &patched, STATE_FILE_MODE, "汉化 patch 输出") .map_err(anyhow::Error::msg)?; let original_blake3 = blake3::hash(&original).to_hex().to_string(); let localized_blake3 = blake3::hash(&patched).to_hex().to_string(); changed_files.push(LocalizedPatchFile { - path: operation.bundle_path.clone(), + path: bundle_path, original_blake3, localized_blake3, original_bytes: original.len() as u64, localized_bytes: patched.len() as u64, byte_delta: patched.len() as i64 - original.len() as i64, - text_asset_operations: vec![LocalizedPatchOperation::from_text_asset_patch( - &operation.text_asset, - )], + text_asset_operations: manifest_operations, }); } @@ -571,13 +924,112 @@ pub fn read_localized_patch_manifest_at( } impl LocalizedPatchOperation { - fn from_text_asset_patch(patch: &TextAssetPatch) -> Self { + fn from_text_asset_patch( + patch: &TextAssetPatch, + metadata: Option<&LocalizedPatchOperationMetadata>, + ) -> Self { + Self::with_metadata( + Self { + patch_kind: "unityfs_text_asset".to_string(), + serialized_file_path: patch.serialized_file_path.clone(), + path_id: patch.path_id, + field_path: None, + expected_name: patch.expected_name.clone(), + replacement_bytes: patch.replacement.len() as u64, + replacement_blake3: blake3::hash(&patch.replacement).to_hex().to_string(), + text_unit_id: None, + source_text_blake3: None, + translation_provider: None, + provider_run_id: None, + review_status: None, + }, + metadata, + ) + } + + fn from_string_field_patch( + patch: &StringFieldPatch, + metadata: Option<&LocalizedPatchOperationMetadata>, + ) -> Self { + Self::with_metadata( + Self { + patch_kind: "unityfs_string_field".to_string(), + serialized_file_path: patch.serialized_file_path.clone(), + path_id: patch.path_id, + field_path: Some(patch.field_path.clone()), + expected_name: None, + replacement_bytes: patch.replacement.len() as u64, + replacement_blake3: blake3::hash(patch.replacement.as_bytes()) + .to_hex() + .to_string(), + text_unit_id: None, + source_text_blake3: None, + translation_provider: None, + provider_run_id: None, + review_status: None, + }, + metadata, + ) + } + + fn from_field_patch( + patch: &FieldPatch, + metadata: Option<&LocalizedPatchOperationMetadata>, + ) -> anyhow::Result { + let replacement = serde_json::to_vec(&patch.replacement)?; + Ok(Self::with_metadata( + Self { + patch_kind: "unityfs_field".to_string(), + serialized_file_path: patch.serialized_file_path.clone(), + path_id: patch.path_id, + field_path: Some(patch.field_path.clone()), + expected_name: None, + replacement_bytes: replacement.len() as u64, + replacement_blake3: blake3::hash(&replacement).to_hex().to_string(), + text_unit_id: None, + source_text_blake3: None, + translation_provider: None, + provider_run_id: None, + review_status: None, + }, + metadata, + )) + } + + fn with_metadata( + mut operation: Self, + metadata: Option<&LocalizedPatchOperationMetadata>, + ) -> Self { + if let Some(metadata) = metadata { + operation.text_unit_id = Some(metadata.text_unit_id.clone()); + operation.source_text_blake3 = Some(metadata.source_text_blake3.clone()); + operation.translation_provider = metadata.translation_provider.clone(); + operation.provider_run_id = metadata.provider_run_id.clone(); + operation.review_status = Some(metadata.review_status.clone()); + } + operation + } +} + +fn default_patch_operation_kind() -> String { + "unityfs_text_asset".to_string() +} + +impl Default for LocalizedPatchOperation { + fn default() -> Self { Self { - serialized_file_path: patch.serialized_file_path.clone(), - path_id: patch.path_id, - expected_name: patch.expected_name.clone(), - replacement_bytes: patch.replacement.len() as u64, - replacement_blake3: blake3::hash(&patch.replacement).to_hex().to_string(), + patch_kind: default_patch_operation_kind(), + serialized_file_path: String::new(), + path_id: 0, + field_path: None, + expected_name: None, + replacement_bytes: 0, + replacement_blake3: String::new(), + text_unit_id: None, + source_text_blake3: None, + translation_provider: None, + provider_run_id: None, + review_status: None, } } } @@ -690,6 +1142,25 @@ fn current_symlink_target(current_path: &Path) -> anyhow::Result } } +fn validate_previous_current_target(root: &Path, target: &Path) -> anyhow::Result<()> { + if release_id_from_current_target(target).is_none() { + return Err(anyhow::anyhow!( + "汉化 current 目标不是受支持的 versions/ 路径:{}", + target.display() + )); + } + let target_path = root.join(target); + ensure_path_within_root(root, &target_path).map_err(anyhow::Error::msg)?; + ensure_safe_directory_path(&target_path, "汉化 current 目标").map_err(anyhow::Error::msg)?; + if !target_path.is_dir() { + return Err(anyhow::anyhow!( + "汉化 current 目标 release 不存在:{}", + target_path.display() + )); + } + Ok(()) +} + fn current_points_to_version(current_path: &Path, version_path: &Path) -> anyhow::Result { let metadata = fs::symlink_metadata(current_path)?; if !metadata.file_type().is_symlink() { @@ -698,6 +1169,18 @@ fn current_points_to_version(current_path: &Path, version_path: &Path) -> anyhow Ok(fs::canonicalize(current_path)? == fs::canonicalize(version_path)?) } +fn release_id_from_current_target(target: &Path) -> Option { + let mut components = target.components(); + match (components.next(), components.next(), components.next()) { + ( + Some(std::path::Component::Normal(root)), + Some(std::path::Component::Normal(release_id)), + None, + ) if root == LOCALIZED_VERSIONS_DIR => release_id.to_str().map(str::to_string), + _ => None, + } +} + fn rollback_failed_publish( localized_output_root: &Path, staging: &Path, @@ -907,11 +1390,18 @@ mod tests { localized_bytes: target.len() as u64, byte_delta: target.len() as i64 - source.len() as i64, text_asset_operations: vec![LocalizedPatchOperation { + patch_kind: "unityfs_text_asset".to_string(), serialized_file_path: "CAB-asset".to_string(), path_id: 1, + field_path: None, expected_name: Some("Text".to_string()), replacement_bytes: target.len() as u64, replacement_blake3: blake3::hash(target).to_hex().to_string(), + text_unit_id: Some("unit-1".to_string()), + source_text_blake3: Some(blake3::hash(source).to_hex().to_string()), + translation_provider: Some("mock".to_string()), + provider_run_id: Some("mock:unit-1:attempt-1".to_string()), + review_status: Some("provider_completed".to_string()), }], }], rollback: LocalizedPatchRollbackInfo { @@ -987,6 +1477,85 @@ mod tests { assert_eq!(manifest.rollback.previous_current_target, None); } + #[cfg(unix)] + #[test] + fn rollback_restores_previous_localized_release() { + let temp = TempDir::new().unwrap(); + let official = temp.path().join("official-release"); + let localized = temp.path().join("localized"); + fs::create_dir_all(official.join("TableBundles")).unwrap(); + fs::write( + official.join("TableBundles/TableCatalog.bytes"), + b"official", + ) + .unwrap(); + let service = LocalizedPatchService::new(); + + service + .publish(&LocalizedPatchConfig::new( + &official, + &localized, + "release-1", + Vec::new(), + )) + .unwrap(); + service + .publish( + &LocalizedPatchConfig::new(&official, &localized, "release-1", Vec::new()) + .with_localized_release_id("release-2"), + ) + .unwrap(); + + let report = service.rollback(&localized, Some("release-2")).unwrap(); + + assert_eq!(report.rolled_back_release_id, "release-2"); + assert_eq!(report.restored_release_id.as_deref(), Some("release-1")); + assert!(!localized + .join(LOCALIZED_VERSIONS_DIR) + .join("release-2") + .exists()); + assert_eq!( + fs::read_link(localized.join(LOCALIZED_CURRENT_LINK)).unwrap(), + PathBuf::from("versions/release-1") + ); + let state = read_localized_version_state(&localized).unwrap().unwrap(); + assert_eq!(state.status, "localized"); + assert_eq!(state.current_release_id.as_deref(), Some("release-1")); + } + + #[cfg(unix)] + #[test] + fn publish_rejects_invalid_existing_current_target() { + use std::os::unix::fs::symlink; + + let temp = TempDir::new().unwrap(); + let official = temp.path().join("official-release"); + let localized = temp.path().join("localized"); + fs::create_dir_all(official.join("TableBundles")).unwrap(); + fs::write( + official.join("TableBundles/TableCatalog.bytes"), + b"official", + ) + .unwrap(); + fs::create_dir_all(localized.join(LOCALIZED_VERSIONS_DIR)).unwrap(); + symlink( + temp.path().join("outside"), + localized.join(LOCALIZED_CURRENT_LINK), + ) + .unwrap(); + + let error = LocalizedPatchService::new() + .publish(&LocalizedPatchConfig::new( + &official, + &localized, + "release-1", + Vec::new(), + )) + .unwrap_err(); + + assert!(error.to_string().contains("current 目标不是受支持")); + } + #[test] fn localized_state_reads_legacy_file_without_workflow_status() { let temp = TempDir::new().unwrap(); @@ -1060,6 +1629,7 @@ mod tests { vec![LocalizedTextAssetPatch { bundle_path: "Bundles/bad.bundle".to_string(), text_asset: TextAssetPatch::new("CAB-bad", 1, b"replacement".to_vec()), + metadata: None, }], )) .unwrap_err(); diff --git a/infrastructure/src/translation_workflow.rs b/infrastructure/src/translation_workflow.rs index c76c2e2..1b4fff5 100644 --- a/infrastructure/src/translation_workflow.rs +++ b/infrastructure/src/translation_workflow.rs @@ -1,17 +1,22 @@ //! Manual translation workbench and controlled UnityFS repack workflows. use crate::official_parse::{read_textunit_index_at, OfficialTextUnitIndexUnit}; +use crate::official_textunit_queue::OfficialTextUnitTaskQuery; use crate::path_security::{ ensure_safe_file_target, lexical_absolute, read_file_no_symlink, write_file_atomic, STATE_FILE_MODE, }; -use crate::LocalizedTextAssetPatch; +use crate::{ + LocalizedPatchInput, LocalizedPatchOperationMetadata, LocalizedStringFieldPatch, + LocalizedTextAssetPatch, PersistedTranslationTask, SqliteTranslationTaskRepository, + TranslationTaskStatus, TranslationTaskUnitResult, +}; 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, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -52,12 +57,27 @@ pub struct TranslationWorkbenchEntry { /// Unity TextAsset name, when known. #[serde(default, skip_serializing_if = "Option::is_none")] pub asset_name: Option, + /// TypeTree field path, when the source is a field-level TextUnit. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub field_path: Option, /// Extracted source text. This is checked again before publishing. pub source_text: String, /// Human translation. `null` means not reviewed yet; an empty string is /// an intentional empty translation. #[serde(default)] pub translated_text: Option, + /// Provider that produced this translation, when imported from worker output. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub translation_provider: Option, + /// Provider run that produced this translation, when imported from worker output. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_run_id: Option, + /// Worker completion time for provider-produced text. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub translated_unix_seconds: Option, + /// Review state used by publish manifest metadata. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub review_status: Option, /// TextUnit format. #[serde(default, skip_serializing_if = "Option::is_none")] pub format: Option, @@ -83,7 +103,7 @@ pub struct TranslationWorkbenchValidationReport { 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`. + /// Changed entries outside the direct localized publish support range. pub repack_entries: usize, } @@ -116,6 +136,94 @@ pub fn export_translation_workbench( Ok(workbench) } +/// Builds a workbench from completed provider worker results. +pub async fn completed_worker_translation_workbench( + resource_root: &Path, + official_release_id: &str, +) -> anyhow::Result { + let index = read_textunit_index_at(resource_root) + .map_err(anyhow::Error::msg)? + .ok_or_else(|| anyhow::anyhow!("缺少当前官方 TextUnit 索引"))?; + let index_by_id = index + .units + .iter() + .map(|unit| (unit.id.as_str(), unit)) + .collect::>(); + let repository_path = SqliteTranslationTaskRepository::repository_path(resource_root); + let repository = SqliteTranslationTaskRepository::open(&repository_path) + .await + .map_err(|error| anyhow::anyhow!("打开翻译任务状态库失败:{error}"))?; + let tasks = repository + .list(&OfficialTextUnitTaskQuery { + official_release_id: Some(official_release_id.to_string()), + ..Default::default() + }) + .await + .map_err(|error| anyhow::anyhow!("读取翻译任务状态失败:{error}"))?; + let mut result_by_unit = BTreeMap::new(); + for task in tasks + .iter() + .filter(|task| task.task_status == TranslationTaskStatus::Completed) + { + if task.task.official_release_id != official_release_id { + return Err(anyhow::anyhow!( + "worker 任务 {} 的官方 release={} 与当前 release={} 不一致", + task.task.task_id, + task.task.official_release_id, + official_release_id + )); + } + for result in &task.translation_results { + let unit = index_by_id.get(result.unit_id.as_str()).ok_or_else(|| { + anyhow::anyhow!("worker 结果引用了未知 TextUnit:{}", result.unit_id) + })?; + validate_worker_result(task, unit, result)?; + if result_by_unit + .insert(result.unit_id.as_str(), (unit, task, result)) + .is_some() + { + return Err(anyhow::anyhow!( + "worker 结果包含重复 TextUnit:{}", + result.unit_id + )); + } + } + } + if result_by_unit.is_empty() { + return Err(anyhow::anyhow!( + "当前 release 没有 completed provider 翻译结果可发布" + )); + } + let entries = index + .units + .iter() + .filter_map(|unit| { + result_by_unit + .get(unit.id.as_str()) + .map(|(_, task, result)| workbench_entry_from_worker_result(unit, task, result)) + }) + .collect(); + Ok(TranslationWorkbench { + schema_version: TRANSLATION_WORKBENCH_VERSION, + official_release_id: official_release_id.to_string(), + official_resource_root: lexical_absolute(resource_root).map_err(anyhow::Error::msg)?, + generated_unix_seconds: unix_seconds_now(), + entries, + }) +} + +/// Exports completed provider worker results as an editable workbench. +pub async fn export_completed_worker_translation_workbench( + resource_root: &Path, + official_release_id: &str, + output_path: &Path, +) -> anyhow::Result { + let workbench = + completed_worker_translation_workbench(resource_root, official_release_id).await?; + write_translation_workbench(output_path, &workbench)?; + Ok(workbench) +} + /// Reads and validates a manual translation workbench. pub fn read_translation_workbench(path: &Path) -> anyhow::Result { let bytes = read_file_no_symlink(path, "翻译工作台") @@ -253,8 +361,12 @@ pub fn validate_translation_workbench( continue; } changed_entries += 1; + let source_kind = normalized_text_source_kind(entry.text_source_kind.as_deref()); let is_publishable = entry.archive_entry.is_none() - && entry.text_source_kind.as_deref() == Some("text_asset"); + && matches!( + source_kind.as_deref(), + Some("textasset" | "typetreefield" | "managedreferencefield") + ); if is_publishable { let serialized_file = entry .serialized_file @@ -263,10 +375,21 @@ pub fn validate_translation_workbench( let path_id = entry .path_id .ok_or_else(|| anyhow::anyhow!("TextUnit {} 没有 path_id", entry.id))?; + let field_path = if source_kind.as_deref() == Some("textasset") { + None + } else { + Some( + entry + .field_path + .clone() + .ok_or_else(|| anyhow::anyhow!("TextUnit {} 没有 field_path", entry.id))?, + ) + }; if !seen_patch_targets.insert(( entry.destination.clone(), serialized_file.clone(), path_id, + field_path, )) { return Err(anyhow::anyhow!( "翻译工作台包含重复 patch 目标:{}", @@ -290,11 +413,122 @@ pub fn validate_translation_workbench( }) } -/// Converts reviewed direct TextAsset entries to localized patch operations. +/// Converts reviewed entries to localized patch operations supported by the +/// current UnityFS write layer. /// -/// TypeTree fields and zip-inner bundles are intentionally rejected here. -/// They need a different patch representation and must not silently become a -/// TextAsset replacement. +/// ZIP-inner bundles are intentionally rejected here because they require a +/// separate archive rewrite boundary. +pub fn localized_patch_operations( + resource_root: &Path, + workbench: &TranslationWorkbench, +) -> anyhow::Result> { + let index = read_textunit_index_at(resource_root) + .map_err(anyhow::Error::msg)? + .ok_or_else(|| anyhow::anyhow!("缺少当前官方 TextUnit 索引"))?; + let index_by_id = index + .units + .iter() + .map(|unit| (unit.id.as_str(), unit)) + .collect::>(); + let mut seen = BTreeSet::new(); + let mut operations = 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 + )); + } + let source_kind = normalized_text_source_kind(entry.text_source_kind.as_deref()); + let field_path = entry.field_path.clone(); + if !seen.insert(( + entry.destination.clone(), + serialized_file.clone(), + path_id, + field_path.clone(), + )) { + return Err(anyhow::anyhow!( + "翻译工作台包含重复 patch 目标:{}", + entry.id + )); + } + let metadata = Some(localized_patch_metadata(entry)); + match source_kind.as_deref() { + Some("textasset") => { + let mut patch = TextAssetPatch::new( + serialized_file, + path_id, + translated_text.as_bytes().to_vec(), + ); + patch.expected_name = entry.asset_name.clone(); + operations.push(LocalizedPatchInput::TextAsset(LocalizedTextAssetPatch { + bundle_path: entry.destination.clone(), + text_asset: patch, + metadata, + })); + } + Some("typetreefield" | "managedreferencefield") => { + let field_path = field_path.ok_or_else(|| { + anyhow::anyhow!( + "TextUnit {} 没有 field_path,不能生成 TypeTree patch", + entry.id + ) + })?; + operations.push(LocalizedPatchInput::StringField( + LocalizedStringFieldPatch { + bundle_path: entry.destination.clone(), + string_field: StringFieldPatch { + serialized_file_path: serialized_file, + path_id, + field_path, + expected_value: Some(entry.source_text.clone()), + replacement: translated_text.clone(), + }, + metadata, + }, + )); + } + _ => { + return Err(anyhow::anyhow!( + "TextUnit {} 的来源不在当前 localized publish 支持范围内", + entry.id + )); + } + } + } + + if operations.is_empty() { + return Err(anyhow::anyhow!( + "翻译工作台没有可发布的已修改 TextUnit;请先用 translation-set 调整文本或导入 worker 结果" + )); + } + Ok(operations) +} + +/// Converts reviewed direct TextAsset entries to localized patch operations. pub fn localized_text_asset_patches( resource_root: &Path, workbench: &TranslationWorkbench, @@ -339,7 +573,9 @@ pub fn localized_text_asset_patches( entry.id )); } - if entry.text_source_kind.as_deref() != Some("text_asset") { + if normalized_text_source_kind(entry.text_source_kind.as_deref()).as_deref() + != Some("textasset") + { return Err(anyhow::anyhow!( "TextUnit {} 的来源不是 TextAsset;请使用 repack spec 的 TypeTree 操作", entry.id @@ -360,6 +596,7 @@ pub fn localized_text_asset_patches( patches.push(LocalizedTextAssetPatch { bundle_path: entry.destination.clone(), text_asset: patch, + metadata: Some(localized_patch_metadata(entry)), }); } @@ -371,6 +608,31 @@ pub fn localized_text_asset_patches( Ok(patches) } +fn localized_patch_metadata(entry: &TranslationWorkbenchEntry) -> LocalizedPatchOperationMetadata { + LocalizedPatchOperationMetadata { + text_unit_id: entry.id.clone(), + source_text_blake3: blake3::hash(entry.source_text.as_bytes()) + .to_hex() + .to_string(), + translation_provider: entry.translation_provider.clone(), + provider_run_id: entry.provider_run_id.clone(), + review_status: entry + .review_status + .clone() + .unwrap_or_else(|| "manual_reviewed".to_string()), + } +} + +fn normalized_text_source_kind(value: Option<&str>) -> Option { + value.map(|value| { + value + .chars() + .filter(|ch| !matches!(ch, '_' | '-' | ' ')) + .collect::() + .to_ascii_lowercase() + }) +} + /// Batch UnityFS repack specification. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RepackSpec { @@ -597,6 +859,49 @@ fn read_text_replacement( } } +fn validate_worker_result( + task: &PersistedTranslationTask, + unit: &OfficialTextUnitIndexUnit, + result: &TranslationTaskUnitResult, +) -> anyhow::Result<()> { + if task.task.destination != unit.destination || task.task.archive_entry != unit.archive_entry { + return Err(anyhow::anyhow!( + "worker 任务 {} 与 TextUnit {} 的 destination/archive entry 不一致", + task.task.task_id, + unit.id + )); + } + if result.source_text != unit.source_text { + return Err(anyhow::anyhow!( + "worker 结果 {} 的 source_text 与当前 TextUnit 索引不一致", + result.unit_id + )); + } + Ok(()) +} + +fn workbench_entry_from_worker_result( + unit: &OfficialTextUnitIndexUnit, + task: &PersistedTranslationTask, + result: &TranslationTaskUnitResult, +) -> TranslationWorkbenchEntry { + let mut entry = TranslationWorkbenchEntry::from_index(unit); + entry.translated_text = Some(result.translated_text.clone()); + entry.translation_provider = task + .provider + .clone() + .or_else(|| Some(result.provider.clone())) + .filter(|provider| !provider.trim().is_empty()); + entry.provider_run_id = task + .provider_run_id + .clone() + .or_else(|| Some(result.provider_run_id.clone())) + .filter(|provider_run_id| !provider_run_id.trim().is_empty()); + entry.translated_unix_seconds = Some(result.translated_unix_seconds); + entry.review_status = Some("provider_completed".to_string()); + entry +} + fn validate_workbench_entry( entry: &TranslationWorkbenchEntry, current: &OfficialTextUnitIndexUnit, @@ -607,6 +912,7 @@ fn validate_workbench_entry( || entry.serialized_file != current.serialized_file || entry.path_id != current.path_id || entry.asset_name != current.asset_name + || entry.field_path != current.field_path || entry.format != current.format || entry.text_source_kind != current.text_source_kind { @@ -627,8 +933,13 @@ impl TranslationWorkbenchEntry { serialized_file: unit.serialized_file.clone(), path_id: unit.path_id, asset_name: unit.asset_name.clone(), + field_path: unit.field_path.clone(), source_text: unit.source_text.clone(), translated_text: None, + translation_provider: None, + provider_run_id: None, + translated_unix_seconds: None, + review_status: None, format: unit.format.clone(), text_source_kind: unit.text_source_kind.clone(), } @@ -659,8 +970,13 @@ mod tests { serialized_file: Some("CAB-test".to_string()), path_id: Some(7), asset_name: Some("Story".to_string()), + field_path: None, source_text: "原文".to_string(), translated_text: None, + translation_provider: None, + provider_run_id: None, + translated_unix_seconds: None, + review_status: None, format: Some("plain".to_string()), text_source_kind: Some("text_asset".to_string()), }], @@ -743,4 +1059,63 @@ mod tests { assert_eq!(report.publishable_entries, 1); assert_eq!(report.unreviewed_entries, 0); } + + #[test] + fn localized_operations_preserve_type_tree_field_traceability() { + 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(114), + field_path: Some("Scenario.Message".to_string()), + field_offset: Some(16), + field_byte_size: Some(8), + format: Some("plain".to_string()), + text_source_kind: Some("TypeTreeField".to_string()), + asset_name: None, + context: Default::default(), + }], + errors: Vec::new(), + }; + crate::official_parse::write_textunit_index_at(temp.path(), &index).unwrap(); + let mut workbench = workbench(temp.path()); + let entry = &mut workbench.entries[0]; + entry.asset_name = None; + entry.field_path = Some("Scenario.Message".to_string()); + entry.text_source_kind = Some("TypeTreeField".to_string()); + entry.translated_text = Some("译文".to_string()); + entry.translation_provider = Some("mock".to_string()); + entry.provider_run_id = Some("run-1".to_string()); + + let operations = localized_patch_operations(temp.path(), &workbench).unwrap(); + assert_eq!(operations.len(), 1); + match &operations[0] { + LocalizedPatchInput::StringField(operation) => { + assert_eq!(operation.string_field.field_path, "Scenario.Message"); + assert_eq!( + operation.string_field.expected_value.as_deref(), + Some("原文") + ); + assert_eq!(operation.string_field.replacement, "译文"); + let metadata = operation.metadata.as_ref().unwrap(); + assert_eq!(metadata.text_unit_id, "unit-1"); + assert_eq!(metadata.translation_provider.as_deref(), Some("mock")); + assert_eq!(metadata.provider_run_id.as_deref(), Some("run-1")); + } + other => panic!("unexpected localized operation: {other:?}"), + } + } } diff --git a/internal/api/admin.go b/internal/api/admin.go index b912c2e..d5470d6 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -39,6 +39,7 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) { "/admin/schedules", "/admin/translation/tasks", "/admin/translation/handoff", + "/admin/translation/status", }, Controls: []string{ "/admin/control/reload", @@ -55,6 +56,8 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) { "/admin/control/translation-task-update", "/admin/control/translation-worker-run", "/admin/control/translation-proofread", + "/admin/control/localized-publish", + "/admin/control/localized-rollback", }, } if r.Method == http.MethodHead { @@ -94,6 +97,14 @@ func (s *Server) handleAdminControl(w http.ResponseWriter, r *http.Request) { s.handleAdminTranslationProofread(w, r) return } + if action == "localized-publish" { + s.handleAdminLocalizedPublish(w, r) + return + } + if action == "localized-rollback" { + s.handleAdminLocalizedRollback(w, r) + return + } request, ok := decodeAdminControlRequest(w, r) if !ok { return @@ -243,6 +254,84 @@ func (s *Server) handleAdminTranslationProofread(w http.ResponseWriter, r *http. }) } +func (s *Server) handleAdminLocalizedPublish(w http.ResponseWriter, r *http.Request) { + backend, ok := s.backend.(LocalizedBackend) + if !ok || backend == nil { + writeErrorJSON(w, http.StatusServiceUnavailable, "localized_backend_unavailable", "Rust bat localized backend is unavailable") + return + } + var params backendrpc.LocalizedPublishParams + if !decodeAdminTranslationJSON(w, r, ¶ms) { + return + } + if err := validateLocalizedPublishParams(params); err != nil { + writeErrorJSON(w, http.StatusBadRequest, "invalid_localized_params", err.Error()) + return + } + result, err := backend.LocalizedPublish(r.Context(), params) + if err != nil { + s.writeControlBackendError(w, "localized-publish", err) + return + } + writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{ + Service: "bat-api", + Action: "localized-publish", + RPCMethod: "localized.publish", + Status: "accepted", + Result: result, + }) +} + +func (s *Server) handleAdminLocalizedRollback(w http.ResponseWriter, r *http.Request) { + backend, ok := s.backend.(LocalizedBackend) + if !ok || backend == nil { + writeErrorJSON(w, http.StatusServiceUnavailable, "localized_backend_unavailable", "Rust bat localized backend is unavailable") + return + } + var params backendrpc.LocalizedRollbackParams + if !decodeAdminTranslationJSON(w, r, ¶ms) { + return + } + result, err := backend.LocalizedRollback(r.Context(), params) + if err != nil { + s.writeControlBackendError(w, "localized-rollback", err) + return + } + writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{ + Service: "bat-api", + Action: "localized-rollback", + RPCMethod: "localized.rollback", + Status: "accepted", + Result: result, + }) +} + +func (s *Server) handleAdminLocalizedStatus(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.(LocalizedBackend) + if !ok || backend == nil { + writeErrorJSON(w, http.StatusServiceUnavailable, "localized_backend_unavailable", "Rust bat localized backend is unavailable") + return + } + result, err := backend.LocalizedStatus(r.Context()) + if err != nil { + s.writeControlBackendError(w, "localized-status", 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) handleAdminTranslationTasks(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") @@ -494,6 +583,14 @@ func validateTranslationWorkerRunParams(params backendrpc.TranslationWorkerRunPa return nil } +func validateLocalizedPublishParams(params backendrpc.LocalizedPublishParams) error { + hasFile := strings.TrimSpace(params.TranslationFile) != "" + if hasFile == params.FromWorker { + return errors.New("localized publish requires exactly one of translation_file or from_worker") + } + return nil +} + func decodeAdminJSON(w http.ResponseWriter, r *http.Request, target any, errorCode string, subject string) bool { if r.Body == nil { return true diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 8e0feb4..2a06055 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -590,6 +590,8 @@ type controlBackend struct { *fakeBackend calls []string translationTaskListParams []backendrpc.TranslationTaskListParams + localizedPublishParams []backendrpc.LocalizedPublishParams + localizedRollbackParams []backendrpc.LocalizedRollbackParams } func (b *controlBackend) DaemonReload(ctx context.Context) (*backendrpc.Ack, error) { @@ -669,6 +671,23 @@ func (b *controlBackend) TranslationProofread(ctx context.Context) (json.RawMess return json.RawMessage(`{"translation_workflow_status":"manual_proofreading"}`), nil } +func (b *controlBackend) LocalizedStatus(ctx context.Context) (json.RawMessage, error) { + b.calls = append(b.calls, "localized.status") + return json.RawMessage(`{"localized_release_status":"localized","status_code":"localized.published"}`), nil +} + +func (b *controlBackend) LocalizedPublish(ctx context.Context, params backendrpc.LocalizedPublishParams) (json.RawMessage, error) { + b.calls = append(b.calls, "localized.publish") + b.localizedPublishParams = append(b.localizedPublishParams, params) + return json.RawMessage(`{"status":"published","localized_release_id":"localized-1"}`), nil +} + +func (b *controlBackend) LocalizedRollback(ctx context.Context, params backendrpc.LocalizedRollbackParams) (json.RawMessage, error) { + b.calls = append(b.calls, "localized.rollback") + b.localizedRollbackParams = append(b.localizedRollbackParams, params) + return json.RawMessage(`{"status":"rolled_back","rolled_back_release_id":"localized-1"}`), nil +} + type scheduleBackend struct { *controlBackend scheduleCalls []string @@ -1420,6 +1439,8 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) { {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"}, {name: "translation worker run", action: "translation-worker-run", body: `{"provider":"mock","concurrency":8,"max_tasks":2,"retry_backoff_seconds":0,"worker_id":"dashboard-worker"}`, rpcMethod: "translation.worker.run", call: "translation.worker.run"}, {name: "translation proofread", action: "translation-proofread", rpcMethod: "translation.proofread", call: "translation.proofread"}, + {name: "localized publish", action: "localized-publish", body: `{"from_worker":true,"localized_release_id":"localized-1","force":true}`, rpcMethod: "localized.publish", call: "localized.publish"}, + {name: "localized rollback", action: "localized-rollback", body: `{"localized_release_id":"localized-1"}`, rpcMethod: "localized.rollback", call: "localized.rollback"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -1458,6 +1479,45 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) { if recorder.Code != http.StatusBadRequest { t.Fatalf("invalid translation worker status=%d body=%s", recorder.Code, recorder.Body.String()) } + + request = httptest.NewRequest(http.MethodPost, "/admin/control/localized-publish", strings.NewReader(`{"from_worker":true,"translation_file":"/tmp/workbench.json"}`)) + request.Header.Set("Authorization", "Bearer control-token") + recorder = httptest.NewRecorder() + s.Handler().ServeHTTP(recorder, request) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("invalid localized publish status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestAdminLocalizedStatusRequiresAuthAndForwards(t *testing.T) { + cfg := DefaultConfig() + cfg.AuthToken = "control-token" + if err := cfg.Normalize(); err != nil { + t.Fatal(err) + } + backend := &controlBackend{fakeBackend: &fakeBackend{}} + s := NewServer(cfg, backend, nil) + + request := httptest.NewRequest(http.MethodGet, "/admin/translation/status", nil) + recorder := httptest.NewRecorder() + s.Handler().ServeHTTP(recorder, request) + if recorder.Code != http.StatusUnauthorized { + t.Fatalf("unauthenticated status=%d body=%s", recorder.Code, recorder.Body.String()) + } + + request = httptest.NewRequest(http.MethodGet, "/admin/translation/status", nil) + request.Header.Set("Authorization", "Bearer control-token") + recorder = httptest.NewRecorder() + s.Handler().ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } + if !strings.Contains(recorder.Body.String(), `"localized.published"`) { + t.Fatalf("status body=%s", recorder.Body.String()) + } + if len(backend.calls) != 1 || backend.calls[0] != "localized.status" { + t.Fatalf("calls=%v", backend.calls) + } } func TestAdminControlRejectsUnauthenticatedDangerousAndUnsupportedActions(t *testing.T) { diff --git a/internal/api/openapi.go b/internal/api/openapi.go index b3cf30a..f5ccb16 100644 --- a/internal/api/openapi.go +++ b/internal/api/openapi.go @@ -213,6 +213,16 @@ paths: description: Missing or invalid admin token. "503": description: Rust bat translation backend is unavailable. + /admin/translation/status: + get: + summary: Read Rust-owned localized release status + responses: + "200": + description: Current localized status JSON report from Rust bat. + "401": + description: Missing or invalid admin token. + "503": + description: Rust bat localized backend is unavailable. /admin/control/{action}: post: summary: Forward an allowlisted control or schedule action to Rust bat @@ -222,7 +232,7 @@ paths: required: true schema: type: string - enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, translation-task-update, translation-worker-run, translation-proofread] + enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, translation-task-update, translation-worker-run, translation-proofread, localized-publish, localized-rollback] requestBody: required: false content: @@ -301,6 +311,12 @@ paths: minimum: 1 worker_id: type: string + translation_file: + type: string + from_worker: + type: boolean + localized_release_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 9d2529a..54c79a4 100644 --- a/internal/api/rpc_release.go +++ b/internal/api/rpc_release.go @@ -60,6 +60,14 @@ type TranslationBackend interface { TranslationProofread(ctx context.Context) (json.RawMessage, error) } +// LocalizedBackend exposes localized release status and the explicit +// publish/rollback controls used by the authenticated dashboard. +type LocalizedBackend interface { + LocalizedStatus(ctx context.Context) (json.RawMessage, error) + LocalizedPublish(ctx context.Context, params backendrpc.LocalizedPublishParams) (json.RawMessage, error) + LocalizedRollback(ctx context.Context, params backendrpc.LocalizedRollbackParams) (json.RawMessage, error) +} + // RPCClient adapts *backendrpc.Client to Backend. type RPCClient struct { Client *backendrpc.Client @@ -135,6 +143,19 @@ func (r RPCClient) TranslationWorkerRun(ctx context.Context, params backendrpc.T func (r RPCClient) TranslationProofread(ctx context.Context) (json.RawMessage, error) { return r.Client.TranslationProofread(ctx) } + +func (r RPCClient) LocalizedStatus(ctx context.Context) (json.RawMessage, error) { + return r.Client.LocalizedStatus(ctx) +} + +func (r RPCClient) LocalizedPublish(ctx context.Context, params backendrpc.LocalizedPublishParams) (json.RawMessage, error) { + return r.Client.LocalizedPublish(ctx, params) +} + +func (r RPCClient) LocalizedRollback(ctx context.Context, params backendrpc.LocalizedRollbackParams) (json.RawMessage, error) { + return r.Client.LocalizedRollback(ctx, params) +} + func (r RPCClient) ParseStatus(ctx context.Context) (json.RawMessage, error) { return r.Client.ParseStatus(ctx) } @@ -144,9 +165,6 @@ func (r RPCClient) ParseTextUnits(ctx context.Context, query backendrpc.TextUnit func (r RPCClient) ParseErrors(ctx context.Context, query backendrpc.TextUnitQueryParams) (json.RawMessage, error) { return r.Client.ParseErrors(ctx, query) } -func (r RPCClient) LocalizedStatus(ctx context.Context) (json.RawMessage, error) { - return r.Client.LocalizedStatus(ctx) -} func (r RPCClient) UnityFSPatchTextAsset(ctx context.Context, params backendrpc.UnityFSTextAssetPatchParams) (json.RawMessage, error) { return r.Client.UnityFSPatchTextAsset(ctx, params) } diff --git a/internal/api/server.go b/internal/api/server.go index 07ba4e9..8a1a2fb 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -64,6 +64,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/admin/schedules", s.handleAdminSchedules) mux.HandleFunc("/admin/translation/tasks", s.handleAdminTranslationTasks) mux.HandleFunc("/admin/translation/handoff", s.handleAdminTranslationHandoff) + mux.HandleFunc("/admin/translation/status", s.handleAdminLocalizedStatus) mux.HandleFunc("/admin/control/", s.handleAdminControl) mux.HandleFunc("/admin/", s.handleAdminIndex) mux.HandleFunc("/"+ServerInfoHost+"/", s.handleServerInfoCDN) @@ -131,6 +132,7 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { "/admin/schedules", "/admin/translation/tasks", "/admin/translation/handoff", + "/admin/translation/status", "/admin/control/{action}", }, }) diff --git a/internal/backendrpc/client.go b/internal/backendrpc/client.go index 217e2d8..454e836 100644 --- a/internal/backendrpc/client.go +++ b/internal/backendrpc/client.go @@ -325,6 +325,20 @@ type TranslationWorkerRunResult struct { Worker TranslationWorkerConfig `json:"worker"` } +// LocalizedPublishParams selects the source of one localized release +// publication. TranslationFile and FromWorker are mutually exclusive. +type LocalizedPublishParams struct { + TranslationFile string `json:"translation_file,omitempty"` + FromWorker bool `json:"from_worker,omitempty"` + LocalizedReleaseID string `json:"localized_release_id,omitempty"` + Force bool `json:"force,omitempty"` +} + +// LocalizedRollbackParams optionally restricts rollback to one current release. +type LocalizedRollbackParams struct { + LocalizedReleaseID string `json:"localized_release_id,omitempty"` +} + // TranslationProofread marks the current localized workflow as manual // proofreading. Rust owns the persisted localized state. func (c *Client) TranslationProofread(ctx context.Context) (json.RawMessage, error) { @@ -568,6 +582,14 @@ func (c *Client) TranslationWorkerRun(ctx context.Context, params TranslationWor return &out, err } +func (c *Client) LocalizedPublish(ctx context.Context, params LocalizedPublishParams) (json.RawMessage, error) { + return c.rawData(ctx, "localized.publish", params) +} + +func (c *Client) LocalizedRollback(ctx context.Context, params LocalizedRollbackParams) (json.RawMessage, error) { + return c.rawData(ctx, "localized.rollback", 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 2bbc060..8319e95 100644 --- a/internal/backendrpc/client_test.go +++ b/internal/backendrpc/client_test.go @@ -561,6 +561,75 @@ func TestTranslationProofreadUsesRustMethod(t *testing.T) { } } +func TestLocalizedPublishSendsWorkerSourceAndReleaseOptions(t *testing.T) { + client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { + if req.Method != "localized.publish" { + t.Fatalf("method = %s", req.Method) + } + var params LocalizedPublishParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + t.Fatalf("decode params: %v", err) + } + if !params.FromWorker || params.TranslationFile != "" || + params.LocalizedReleaseID != "localized-1" || !params.Force { + t.Fatalf("params = %#v", params) + } + return testResponse{ + Result: testEnvelope{ + OK: true, + Status: "ok", + RequestID: "req-test-localized-publish", + Data: map[string]any{"status": "published"}, + }, + } + }) + + raw, err := client.LocalizedPublish(context.Background(), LocalizedPublishParams{ + FromWorker: true, + LocalizedReleaseID: "localized-1", + Force: true, + }) + if err != nil { + t.Fatalf("LocalizedPublish error: %v", err) + } + if !json.Valid(raw) { + t.Fatalf("invalid raw JSON: %s", string(raw)) + } +} + +func TestLocalizedRollbackSendsExpectedRelease(t *testing.T) { + client := newTestClient(t, func(t *testing.T, req testRequest) testResponse { + if req.Method != "localized.rollback" { + t.Fatalf("method = %s", req.Method) + } + var params LocalizedRollbackParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + t.Fatalf("decode params: %v", err) + } + if params.LocalizedReleaseID != "localized-1" { + t.Fatalf("params = %#v", params) + } + return testResponse{ + Result: testEnvelope{ + OK: true, + Status: "ok", + RequestID: "req-test-localized-rollback", + Data: map[string]any{"status": "rolled_back"}, + }, + } + }) + + raw, err := client.LocalizedRollback(context.Background(), LocalizedRollbackParams{ + LocalizedReleaseID: "localized-1", + }) + if err != nil { + t.Fatalf("LocalizedRollback 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 a11c123..bc309d0 100644 --- a/web/admin/README.md +++ b/web/admin/README.md @@ -16,6 +16,12 @@ `translation.worker.run`,触发 Rust provider worker。 - `POST /admin/control/translation-proofread`:转发 `translation.proofread`,把汉化 workflow 标记为人工校对中。 +- `POST /admin/control/localized-publish`:转发 `localized.publish`,从工作台或 + completed worker 结果构建并发布汉化 release。 +- `POST /admin/control/localized-rollback`:转发 `localized.rollback`,恢复 + manifest 记录的上一汉化 release。 +- `GET /admin/translation/status`:转发 `localized.status`,读取当前汉化 release + 与 workflow 状态。 - `GET /admin/translation/tasks`:转发 `translation.tasks`,按分页、release、 TextUnit、状态和失败原因过滤查询 worker 状态。 - `GET /admin/translation/handoff`:转发 `translation.handoff`,读取当前