feat(i18n): 完成 localized patch 发布回滚闭环
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s

Closes #45
This commit is contained in:
2026-08-31 00:02:55 +08:00
parent f441f1810e
commit ab21344773
26 changed files with 1916 additions and 140 deletions
+13 -13
View File
@@ -28,7 +28,7 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
11. 官方同步会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。
12. `<output>/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 路径为 `<output>/.cas`、SQLite 索引为 `<output>/resources.sqlite`,也可通过 `--import-cas-root``--import-resource-db``BAT_IMPORT_CAS_ROOT``BAT_IMPORT_RESOURCE_DB` 覆盖;`resource.index` RPC/CLI 可按类型、hash、路径模式、官方 release ID、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 分页查询现有索引,数据库不存在时返回 `available=false` 且不会创建空库。`Resource` metadata 已通过 `metadata_json` 兼容迁移保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式;当前/上一个/结构变化 catalog、失败 staging 复用、403/404、hash mismatch、CRC、metadata 迁移与 UnityFS 边界校验均有离线回归 fixture 或单测覆盖。
14. 非 dry-run 官方同步在校验完成并发布后,会先对比上一完整 release 与当前 release 的 `official-download-manifest.json`,在当前 release 下写入 `official-resource-changes.json``crowdin-translation-handoff.json`;同一 destination 只有 size 或 BLAKE3 改变才算 modified,仅 URL/CDN 根变化但内容相同不会触发解析/翻译候选。随后刷新 `official-parse-cache.json``official-textunit-index.json`,并从 Added/Modified 资源、parse cache 与 TextUnit 明细索引派生 `official-textunit-tasks.json``crowdin-textunit-queue.json`;删除资源只进入差异记录,不进入 TextUnit/Crowdin 队列。`parse.text_units``parse.errors` RPC/CLI 可按 destination、archive entry、path id、class id、field path 和 format 查询当前 release 的 TextUnit 明细与解析错误;`translation.tasks` RPC/CLI 可按 release、destination、archive entry、任务状态、parse status、TextUnit format 和 reason presence 查询离线 TextUnit 翻译任务状态与跳过/失败原因;`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 支持本地 fixtureup-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 任务历史持久化在 `<state-dir>/bat-tasks.json`(版本化、`0600` 原子写),重启后任务经 `task.*` 仍可查,中断任务标记 `task_interrupted``BAT-ERR-700005`)。
14. 非 dry-run 官方同步在校验完成并发布后,会先对比上一完整 release 与当前 release 的 `official-download-manifest.json`,在当前 release 下写入 `official-resource-changes.json``crowdin-translation-handoff.json`;同一 destination 只有 size 或 BLAKE3 改变才算 modified,仅 URL/CDN 根变化但内容相同不会触发解析/翻译候选。随后刷新 `official-parse-cache.json``official-textunit-index.json`,并从 Added/Modified 资源、parse cache 与 TextUnit 明细索引派生 `official-textunit-tasks.json``crowdin-textunit-queue.json`;删除资源只进入差异记录,不进入 TextUnit/Crowdin 队列。`parse.text_units``parse.errors` RPC/CLI 可按 destination、archive entry、path id、class id、field path 和 format 查询当前 release 的 TextUnit 明细与解析错误;`translation.tasks` RPC/CLI 可按 release、destination、archive entry、任务状态、parse status、TextUnit format 和 reason presence 查询离线 TextUnit 翻译任务状态与跳过/失败原因;`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 支持本地 fixtureup-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 任务历史持久化在 `<state-dir>/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<T>``HashSet<T>` 形态、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<T>``HashSet<T>` 形态、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 #1P0,主体已实现):`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 循环互斥;任务历史持久化于 `<state-dir>/bat-tasks.json`,daemon 重启后仍可查,中断任务标记 `task_interrupted`);错误码已接入下载、launcher/metadata、server-info/marker 与配置校验路径。剩余:发布级 `patch build` / `patch rollback`、复杂 `unityfs.*` 语义编辑、`task.create`(按设计由语义方法创建)、`daemon.clean-stable`(由 CLI 侧按进程生命周期显式执行,live RPC 内不做在线清理)、Redis 任务后端(`.env` 已预留配置键,接入时机另议)。Go 层通过 RPC 调用 Rust backend,不走 FFIFFI 降级说明见 `docs/architecture/official-resource-backend.md` §7)。
1. Issue #1P0,主体已实现):`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 循环互斥;任务历史持久化于 `<state-dir>/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,不走 FFIFFI 降级说明见 `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 / Addressablesissue #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 联调已完成。
+6 -6
View File
@@ -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 symlinkrelease ID 校验后才切换为 `localized`
11. 汉化发布状态:**受支持范围已完成闭环**。官方同步默认报告 `not_localized`,表示只发布原版资源;TextAsset、TypeTree string field 和 managed-reference string field patch 发布成功并通过 `localized-patch-manifest.json`、current symlinkrelease 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。
+3 -3
View File
@@ -20,8 +20,8 @@
- 官方同步会维护 `<output>/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/<id>` 并切换 `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/<id>` 并切换 `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 管理后台。
+3
View File
@@ -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 暴露。
+17 -1
View File
@@ -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.
+8 -8
View File
@@ -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 对象跨版本复用,不重复存储相同文件。
### P5Patch 发布前置解析
### P5Patch 发布
目标:让解析结果成为可生成汉化 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/<id>`,校验通过后发布到 `versions/<id>` 并切换 `current`
4. Patch 产物写入配置化汉化发布根下的 `.staging/<id>`,校验通过后发布到 `versions/<id>` 并切换 `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 链路。
+2 -2
View File
@@ -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 链路已可用。
优先阅读:
+47 -3
View File
@@ -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` 不覆盖已有目录,而是生成独立的 `<official-release>-manual-<unix-seconds>` 汉化 release ID;也可以用 `--localized-release-id` 显式指定新 ID。因此强制发布仍保留旧 release 和 rollback 信息。
发布接受当前实现支持的直接 TextAssetTypeTree string field 和 managed-reference
string field 条目;zip 内 bundle 和其他不支持条目使用 `parse repack` 的 spec
单独处理。`--force` 不覆盖已有目录,而是生成独立的
`<official-release>-manual-<unix-seconds>` 汉化 release ID;也可以用
`--localized-release-id` 显式指定新 ID。因此强制发布仍保留旧 release 和 rollback
信息。
当前 `i18n run` 是离线工作流:刷新 TextUnit 队列,并可用 `--translation-file` 导出工作台;真实 provider 由单独的 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/<localized-release-id>` 中复制当前官方
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 的解析类型覆盖。
+7 -3
View File
@@ -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 接口的权威实现位置分别是
+6 -5
View File
@@ -123,7 +123,7 @@
- `ResourceImportService` 的 UnityFS 摘要已经能暴露解包文件数、serialized file 数、TextAsset 名称、TextUnit 数量/格式和字段诊断。
- `MonoBehaviour``ScriptableObject` 已有基础 TypeTree 字段级反序列化和字符串提取入口;array/vector/staticvector/`List<T>`/`HashSet<T>`/map 元素与 TypeTree-covered managed reference registry payload 已保留独立 field path、offset 和 byte sizeenum `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 整体替换执行文件级 patchmap 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-006Patch 引擎基础已落地,通用发布入口仍未完成
状态:**部分关闭**
@@ -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/<id>`、校验后移动到 `versions/<id>`、原子切换 `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 重打包和双发布后的清理策略仍未完成。
- 尚未实现原版资源与汉化资源双发布后的查询、分发和清理策略。
验收:
+1 -1
View File
@@ -95,7 +95,7 @@
| 组件 | 路径 | 状态 | 说明 |
|---|---|---|---|
| Module | `go.mod``bat-api` | 已用 | 服务层模块名 |
| RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helpertyped 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` 走同一 contractfake transport 单测,配合 `internal/api/testdata/contract/` 固化 Rust 输出 mirror |
| RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helpertyped 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` 走同一 contractfake 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 固定 okmanifest/sync 走 FFI |
| FFI | `internal/ffi` | **可选** | 需 `build-ffi` |
+302 -33
View File
@@ -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<i32> {
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<PathBuf>,
translation_failure_reason: Option<String>,
translation_provider_run_id: Option<String>,
translation_from_worker: bool,
translation_provider: Option<String>,
translation_fixture: Option<PathBuf>,
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<bool>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct LocalizedPublishRpcParams {
#[serde(default, alias = "workbench", alias = "workbench_path")]
translation_file: Option<PathBuf>,
#[serde(
default,
alias = "translation_from_worker",
alias = "from_worker_results"
)]
from_worker: bool,
#[serde(default, alias = "release_id")]
localized_release_id: Option<String>,
#[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<String>,
}
// 规范方法名采用国际惯例的 `<namespace>.<action>`。`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<T: DeserializeOwned>(
})
}
fn rpc_optional_struct_params<T: DeserializeOwned + Default>(
params: Option<&serde_json::Value>,
method: &'static str,
) -> Result<T, ApiError> {
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<LocalizedPatchReport, ApiError> {
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<LocalizedRollbackReport, ApiError> {
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<String>) -> Option<String> {
value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn rpc_translation_worker_config(
params: Option<&serde_json::Value>,
) -> Result<TranslationWorkerConfig, ApiError> {
@@ -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 <ID> TextUnit ID for i18n set");
eprintln!(" --translated-text <TEXT> Inline translation for i18n set");
eprintln!(" --translated-file <PATH> UTF-8 translation file for i18n set");
eprintln!(" --from-worker Build publish input from completed provider worker results");
eprintln!(" --failure-reason <TEXT> Provider failure reason for i18n task update");
eprintln!(" --provider-run-id <ID> Provider run ID for i18n task update");
eprintln!(" --translation-provider <NAME> / --provider <NAME> Provider for i18n worker run (mock/crowdin)");
+168
View File
@@ -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(&current).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() {
@@ -515,6 +515,8 @@ pub(super) struct DaemonTaskContext {
pub(super) registry: TaskRegistry,
pub(super) queue: mpsc::Sender<TaskJob>,
pub(super) base_config: OfficialUpdateConfig,
/// 串行化会读取或修改已发布资源状态的 daemon 操作。
pub(super) sync_lock: Arc<Mutex<()>>,
pub(super) restart_controller: DaemonRestartController,
}
+41 -10
View File
@@ -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<LocalizedPatchReport> {
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<LocalizedRollbackReport> {
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)?
+12 -9
View File
@@ -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,
};
+600 -30
View File
@@ -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<LocalizedPatchOperationMetadata>,
}
/// 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<LocalizedPatchOperationMetadata>,
}
/// 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<LocalizedPatchOperationMetadata>,
}
/// 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<String>,
/// Provider run ID that produced the text, if applicable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_run_id: Option<String>,
/// 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<LocalizedTextAssetPatch>,
/// General UnityFS text/field operations to apply.
pub operations: Vec<LocalizedPatchInput>,
}
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<LocalizedPatchInput>) -> 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<LocalizedPatchInput> {
let mut operations = self
.patches
.iter()
.cloned()
.map(LocalizedPatchInput::TextAsset)
.collect::<Vec<_>>();
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<Vec<u8>> {
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<LocalizedPatchOperation> {
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<String>,
/// Expected TextAsset name, when provided.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_name: Option<String>,
/// 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<String>,
/// BLAKE3 of the source text validated before publication.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_text_blake3: Option<String>,
/// Translation provider that produced the text, if applicable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub translation_provider: Option<String>,
/// Provider run ID that produced the text, if applicable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_run_id: Option<String>,
/// Review state used by the publication input.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub review_status: Option<String>,
}
/// 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<String>,
/// Restored current symlink target, if a previous release existed.
#[serde(skip_serializing_if = "Option::is_none")]
pub restored_current_target: Option<PathBuf>,
/// 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(&current_path).ok().flatten();
validate_config(config).map_err(anyhow::Error::msg)?;
let previous_current_target = current_symlink_target(&current_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<LocalizedRollbackReport> {
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(&current_release_id);
ensure_safe_directory_path(&version_path, "当前汉化 release")
.map_err(anyhow::Error::msg)?;
if !current_points_to_version(&current_path, &version_path)? {
return Err(anyhow::anyhow!(
"汉化 current 未指向当前状态 releasecurrent={} 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(&current_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,
&current_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::<String, Vec<LocalizedPatchInput>>::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<Self> {
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<Option<PathBuf>
}
}
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/<release> 路径:{}",
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<bool> {
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<String> {
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();
+384 -9
View File
@@ -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<String>,
/// TypeTree field path, when the source is a field-level TextUnit.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub field_path: Option<String>,
/// Extracted source text. This is checked again before publishing.
pub source_text: String,
/// Human translation. `null` means not reviewed yet; an empty string is
/// an intentional empty translation.
#[serde(default)]
pub translated_text: Option<String>,
/// Provider that produced this translation, when imported from worker output.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub translation_provider: Option<String>,
/// Provider run that produced this translation, when imported from worker output.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_run_id: Option<String>,
/// Worker completion time for provider-produced text.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub translated_unix_seconds: Option<u64>,
/// Review state used by publish manifest metadata.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub review_status: Option<String>,
/// TextUnit format.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format: Option<String>,
@@ -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<TranslationWorkbench> {
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::<HashMap<_, _>>();
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<TranslationWorkbench> {
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<TranslationWorkbench> {
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<Vec<LocalizedPatchInput>> {
let index = read_textunit_index_at(resource_root)
.map_err(anyhow::Error::msg)?
.ok_or_else(|| anyhow::anyhow!("缺少当前官方 TextUnit 索引"))?;
let index_by_id = index
.units
.iter()
.map(|unit| (unit.id.as_str(), unit))
.collect::<std::collections::HashMap<_, _>>();
let mut seen = BTreeSet::new();
let mut 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<String> {
value.map(|value| {
value
.chars()
.filter(|ch| !matches!(ch, '_' | '-' | ' '))
.collect::<String>()
.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:?}"),
}
}
}
+97
View File
@@ -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, &params) {
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, &params) {
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
+60
View File
@@ -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) {
+17 -1
View File
@@ -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.
+21 -3
View File
@@ -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)
}
+2
View File
@@ -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}",
},
})
+22
View File
@@ -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)
}
+69
View File
@@ -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, &params); 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, &params); 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" {
+6
View File
@@ -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`,读取当前