mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 07:24:55 +08:00
feat(patch):统一清单驱动汉化发布
This commit is contained in:
+9
-10
@@ -1,6 +1,6 @@
|
||||
# BlueArchiveToolkit 当前工作区状态
|
||||
|
||||
- **更新时间**:2026-09-08
|
||||
- **更新时间**:2026-09-12
|
||||
- **状态来源**:本地工作区盘点、代码验证和最新提交
|
||||
- **状态分支**:`experiment`
|
||||
- **最新已推送功能提交**:以当前 `git log --oneline -1` 为准
|
||||
@@ -30,7 +30,7 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
|
||||
13. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,官方同步可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后触发导入,默认 CAS 路径为 `<output>/.cas`、SQLite 索引为 `<output>/resources.sqlite`,也可通过 `--import-cas-root`、`--import-resource-db`、`BAT_IMPORT_CAS_ROOT`、`BAT_IMPORT_RESOURCE_DB` 覆盖;`resource.index` RPC/CLI 可按类型、hash、路径模式、官方 release ID、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 分页查询现有索引,release、平台、bundle path 和常用数组 metadata 过滤已下推到 SQLite,数据库不存在时返回 `available=false` 且不会创建空库;`bat doctor cas` 可只读检查既有 CAS 根目录、对象目录、元数据库文件和对象统计,不会因诊断创建空库。`Resource` metadata 已通过 `metadata_json` 兼容迁移保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式;当前/上一个/结构变化 catalog、失败 staging 复用、403/404、hash mismatch、CRC、metadata 迁移与 UnityFS 边界校验均有离线回归 fixture 或单测覆盖。
|
||||
14. 非 dry-run 官方同步在校验完成并发布后,会先对比上一完整 release 与当前 release 的 `official-download-manifest.json`,在当前 release 下写入 `official-resource-changes.json` 和 `crowdin-translation-handoff.json`;同一 destination 只有 size 或 BLAKE3 改变才算 modified,仅 URL/CDN 根变化但内容相同不会触发解析/翻译候选。随后刷新 `official-parse-cache.json` 和 `official-textunit-index.json`,并从 Added/Modified 资源、parse cache 与 TextUnit 明细索引派生 `official-textunit-tasks.json` 和 `crowdin-textunit-queue.json`;删除资源只进入差异记录,不进入 TextUnit/Crowdin 队列。`parse.text_units` 和 `parse.errors` RPC/CLI 可按 destination、archive entry、path id、class id、field path 和 format 查询当前 release 的 TextUnit 明细与解析错误;`translation.tasks` RPC/CLI 可按 release、destination、archive entry、任务状态、parse status、TextUnit format 和 reason presence 查询离线 TextUnit 翻译任务状态与跳过/失败原因;`translation.task.update` 可回写 provider worker 状态,`translation.worker.run` 可触发 Rust provider worker 独立 claim/lease/retry 并落库 TextUnit 级译文结果,`translation.proofread` 可把汉化 workflow 标记为人工校对中;TextUnit 已包含 class id、field path、字段 offset/byte size 等可追溯定位。Crowdin provider 通过 `CROWDIN_*` 环境变量接入,mock provider 支持本地 fixture;up-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析。官方同步报告默认 `localized_release_status=not_localized`,含义是原版资源已经发布、汉化资源未发布;受支持的 UnityFS TextAsset、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` 首次启动会在二进制所在目录释放 `config.toml.example` 配置模板(`0600`),`config.toml` 存在且 Unix 权限为 `0600` 或更严格时读取并使用它;`config.toml` 不存在时仅保留模板,不自动读取 example,运行时继续使用环境变量和内置默认值。优先级为命令行参数 > 进程环境变量 > `config.toml` > 内置默认值,`BAT_SKIP_ENV_FILE` 已废弃且不再影响启动;Redis 键为预留。daemon 任务历史持久化在 `<state-dir>/bat-tasks.json`(版本化、`0600` 原子写),重启后任务经 `task.*` 仍可查,中断任务标记 `task_interrupted`(`BAT-ERR-700005`)。官方同步报告还分别统计当前 manifest 复用、历史 release 复用、CAS 复用、网络传输字节和复用回退诊断,下载事件状态使用 `release_reused`、`cas_reused`、`downloaded` 等稳定值。
|
||||
|
||||
15. Rust `bat` 已提供 `res` / `parse` / `i18n` 工作流入口:支持资源拉取、解析刷新、可再生解析缓存清理、离线翻译工作台、人工文本查看/修改/清空、工作台发布前校验、有限 TextAsset 汉化发布、人工校对状态标记、既有 patch 能力的批量重打包、单次/限定次数/周期执行和版本化 schedule CRUD。`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 不维护第二套状态。`bat-api` 已提供内嵌 dashboard MVP,静态资产由 Go embed 暴露在 `/admin/dashboard/`,页面直接调用已有鉴权接口控制资源、调度、翻译、任务、日志、parse TextUnit 查询和 localized 发布/回滚。该工作流只编排已有解析和 patch 能力,不扩大解析器覆盖;完整 AssetBundle 重打包和完整 Web 协作后台仍是后续工作。真实官方网络全量拉取 smoke 已固化,真实大文件与运行报告默认在 `/tmp` 隔离目录,不纳入 Git。Go 细节见 `docs/reports/GO_STATUS.md`。
|
||||
15. Rust `bat` 已提供 `res` / `parse` / `i18n` 工作流入口:支持资源拉取、解析刷新、可再生解析缓存清理、离线翻译工作台、人工文本查看/修改/清空、工作台发布前校验、generic manifest 驱动的 Binary/JSON/Text/受支持 UnityFS 汉化发布、人工校对状态标记、既有 patch 能力的批量重打包、单次/限定次数/周期执行和版本化 schedule CRUD。`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 不维护第二套状态。`bat-api` 已提供内嵌 dashboard MVP,静态资产由 Go embed 暴露在 `/admin/dashboard/`,页面直接调用已有鉴权接口控制资源、调度、翻译、任务、日志、parse TextUnit 查询和 localized 发布/回滚。该工作流只编排已有解析和 patch 能力,不扩大解析器覆盖;完整 AssetBundle 重打包和完整 Web 协作后台仍是后续工作。真实官方网络全量拉取 smoke 已固化,真实大文件与运行报告默认在 `/tmp` 隔离目录,不纳入 Git。Go 细节见 `docs/reports/GO_STATUS.md`。
|
||||
|
||||
当前翻译交接还包括 `translation-tasks.sqlite` 和版本化 `translation-handoff.json`;跨 release 的 Translation Memory V1 位于 `<output>/translation-memory.sqlite`,不放在 `versions/<id>` 或 release task 库中;
|
||||
`translation.tasks` 查询单项 worker 状态,`translation.handoff` 查询完整
|
||||
@@ -156,7 +156,7 @@ TextUnit scope、source history 和 approved review。worker、TM 复用、人
|
||||
|
||||
待完成:
|
||||
|
||||
- 基于已接入的 `translation.worker.run` 继续推进完整 Patch 构建/rollback;继续扩展更丰富的 TextUnit/TM/Glossary 查询和通用 Patch 发布资源视图。
|
||||
- 基于已接入的 `translation.worker.run` 继续扩展 TM/Glossary、复杂 AssetBundle fixture 和 generic manifest 发布资源视图。
|
||||
- 真实线上全量下载 smoke 已固化为 `scripts/official-full-pull-smoke.sh` 和 `make official-smoke`;实际运行报告由脚本写入隔离输出目录。
|
||||
- 增加更多权限和极端文件系统场景测试。
|
||||
|
||||
@@ -187,14 +187,14 @@ TextUnit scope、source history 和 approved review。worker、TM 复用、人
|
||||
|
||||
### `bat-patch`
|
||||
|
||||
状态:**通用 Binary/JSON/Text Patch 基础可用;受支持 localized patch 发布/rollback 已完成,通用 Patch 发布仍未完成**
|
||||
状态:**通用 manifest 驱动的受支持 Patch 发布/rollback 已完成;复杂 AssetBundle 兼容仍未完成**
|
||||
|
||||
当前已有确定性 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 驱动发布仍未迁移。
|
||||
当前已有确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、通用 Patch manifest、manifest builder、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 和对应 CLI 可对显式 UnityFS bundle 输出目标文件。`bat-assetbundle` + `LocalizedPatchService` 已把 Binary/JSON/Text 与当前支持的 UnityFS TextAsset、TypeTree string/semantic field 操作统一到有序 generic manifest,在独立 staging 中逐操作校验 source precondition、最终 hash/size 和 ZIP 内层重解析结果,发布后保留 TextUnit/provider/TM/Glossary/review/rollback provenance,并通过 `localized.publish` / `localized.rollback` RPC、`i18n publish` / `i18n rollback` CLI 和 bat-api 控制面暴露。
|
||||
|
||||
待完成:
|
||||
|
||||
- 未见样本驱动的 map entry schema 变化、unknown 字段结构语义、完整 managed reference registry 变体驱动字段修改后的语义重打包。
|
||||
- 通用 manifest 驱动的跨类型 patch build/apply/diff 发布;当前 localized 发布仍仅接受已验证 TextUnit 对应的受支持 UnityFS 文本字段,不等价于任意整体 AssetBundle 重打包。
|
||||
- 未见样本驱动的复杂 AssetBundle 重打包、更多双 release 查询/分发视图和清理策略;当前 generic manifest 只承诺已验证的 Binary/JSON/Text 与 UnityFS 结构,不等价于任意整体 AssetBundle 重打包。
|
||||
- `unityfs.inspect`、复杂 UnityFS 语义编辑和写入型发布工作流仍未开放。
|
||||
|
||||
### `bat-ffi`
|
||||
@@ -316,7 +316,6 @@ Issue 状态不作为本地实现状态的权威来源;本次复核未把远
|
||||
- **当前基线状态**:Rust `bat` 同步闭环可用;Go `bat-api` 资源 bootstrap/分发 MVP、
|
||||
HTTP 控制面、launcher 资源引导兼容、RPC 周期刷新/诊断、readiness、内嵌 dashboard
|
||||
和 `backendrpc` 可用;CAS 用户级导入、TextUnit 明细索引/查询、增量离线队列、
|
||||
通用 Binary/JSON/Text Patch 基础和受支持 localized patch 发布/rollback 可用;
|
||||
完整 AssetBundle 重打包、完整 Web 协作后台、模糊 TM 匹配和通用 manifest 发布未完成。
|
||||
- **下一工程里程碑**:复杂 AssetBundle 解析和重打包、通用 manifest Patch
|
||||
构建,以及真实官方资源长期运行验证。
|
||||
通用 Binary/JSON/Text Patch 基础、generic manifest 和受支持 localized patch 发布/rollback 可用;
|
||||
复杂 AssetBundle 重打包、完整 Web 协作后台、模糊 TM 匹配和更多双 release 运维视图未完成。
|
||||
- **下一工程里程碑**:复杂 AssetBundle 解析和重打包、双 release 运维视图,以及真实官方资源长期运行验证。
|
||||
|
||||
+6
-6
@@ -42,7 +42,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
### 仍是骨架或占位
|
||||
|
||||
1. `bat-assetbundle` 已具备 UnityFS 解包、TextAsset/TypeTree 字段读取和 TextUnit 提取;对当前真实/合成回归覆盖的结构,UnityFS TextAsset、TypeTree string、managed-reference string 和语义字段已形成 parse→modify→rebuild→reparse 闭环,保留已识别压缩/对齐/目录形态并校验未修改对象/字段;所有真实版本差异、未知字段语义和任意复杂 AssetBundle 兼容仍未完成。
|
||||
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 闭环;ZIP 内 bundle 在 `archive_entry` 可验证时会重写外层 ZIP,通用 manifest 发布与任意 AssetBundle 重打包仍后置。
|
||||
2. `bat-patch` 已具备确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、通用 Patch manifest/builder、BLAKE3/size 完整性校验和 rollback 元数据;文件级 `patch.apply` RPC / `patch-apply` CLI 与 UnityFS TextAsset / TypeTree string / TypeTree 语义字段写入入口已开放,`bat-assetbundle` + `LocalizedPatchService` 已用同一 generic manifest 完成受支持 Binary/JSON/Text/UnityFS 操作的独立 staging、发布和 rollback 闭环;ZIP 内 bundle 在 `archive_entry` 可验证时会重写外层 ZIP,并在最终发布校验中重新解析和核对实际字段值,任意 AssetBundle 重打包仍后置。
|
||||
3. Go 侧边界已确定(见 `docs/reports/GO_STATUS.md`):同步/运维命令行 = Rust `bat`;资源分发和内嵌 dashboard = `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,`translation.memory.*` 已提供 Rust-owned TM 摘要、raw source/context 查询、provenance 和显式 confirm contract,Go 侧仅代理。
|
||||
@@ -306,7 +306,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
3. 实现客户端发现、路径校验、备份、应用、回滚。
|
||||
4. 实现 `patch build`、`patch apply`、`patch rollback`、`verify`。
|
||||
5. 实现 dry-run 和安全检查。
|
||||
6. 将通用 Patch manifest 与汉化发布流程进一步统一。
|
||||
6. 在 generic Patch manifest 已统一当前支持类型后,继续补复杂 AssetBundle 兼容和双 release 运维视图。
|
||||
|
||||
验收标准:
|
||||
|
||||
@@ -387,7 +387,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
|
||||
## 5. 推荐执行顺序
|
||||
|
||||
近期不要把内嵌 dashboard MVP 扩成完整协作后台或过早扩展 AI Provider。项目当前的真实瓶颈仍是完整 Web 术语协作视图、通用 manifest 发布、复杂 AssetBundle 重打包和真实官方资源长期运行验证。
|
||||
近期不要把内嵌 dashboard MVP 扩成完整协作后台或过早扩展 AI Provider。项目当前的真实瓶颈仍是完整 Web 术语协作视图、复杂 AssetBundle 重打包、双 release 运维视图和真实官方资源长期运行验证。
|
||||
|
||||
建议顺序:
|
||||
|
||||
@@ -405,7 +405,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
`docs/reports/GO_STATUS.md`:
|
||||
|
||||
1. 继续 Addressables 结构变体与 UnityFS 复杂对象能力。
|
||||
2. 基于 `translation.worker.run` 继续推进 Patch 构建。
|
||||
2. 基于 `translation.worker.run` 继续补充复杂 AssetBundle 的 Patch 构建与发布验证。
|
||||
3. 继续扩展资源库剩余查询面:更丰富的 TextUnit/TM 查询和通用 Patch 发布所需资源视图。
|
||||
4. 在隔离环境执行真实官方网络长期运行 smoke,并保留运行报告。
|
||||
|
||||
@@ -461,9 +461,9 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
|
||||
按最终目标计算,当前总体完成度不固定写单一百分比,以模块状态、源码、测试和契约为准。
|
||||
|
||||
已完成的是稳定基线、架构骨架、部分接口、CAS V1、Rust 官方资源同步闭环、可配置 CAS/ResourceRepository 导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、provider worker、Translation Memory V1、Glossary V1、通用 Binary/JSON/Text Patch 基础、已验证结构的 AssetBundle 变长重建、受支持 localized patch 发布/rollback,以及 Go `bat-api` 资源分发、内嵌 dashboard 和同机 live 联调。下一阶段的关键是 TM/Glossary 扩展、通用 manifest 发布、真实版本与复杂 AssetBundle 兼容和官方资源长期运行报告。
|
||||
已完成的是稳定基线、架构骨架、部分接口、CAS V1、Rust 官方资源同步闭环、可配置 CAS/ResourceRepository 导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、provider worker、Translation Memory V1、Glossary V1、通用 Binary/JSON/Text Patch 基础、generic manifest、已验证结构的 AssetBundle 变长重建、受支持 localized patch 发布/rollback,以及 Go `bat-api` 资源分发、内嵌 dashboard 和同机 live 联调。下一阶段的关键是 TM/Glossary 扩展、真实版本与复杂 AssetBundle 兼容、双 release 运维视图和官方资源长期运行报告。
|
||||
|
||||
---
|
||||
|
||||
- **下一份应补充的验证材料**:真实官方网络 smoke 运行记录
|
||||
- **下一项工程任务**:推进 TM/Glossary 扩展、通用 manifest Patch 构建、复杂 AssetBundle 解析,并持续执行官方资源长期运行 smoke。
|
||||
- **下一项工程任务**:推进 TM/Glossary 扩展、复杂 AssetBundle 解析,并持续执行官方资源长期运行 smoke。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**BlueArchiveToolkit** 是一个面向长期维护的 Blue Archive 资源管理、解析、翻译和补丁工具套件。
|
||||
|
||||
当前仓库仍不是完整产品,但 Rust 侧已经具备一条可运行的官方日服资源同步链路:可以在 Linux 上通过官方 HTTP metadata 自动发现资源入口,拉取 Windows + Android 官方资源,保存同步 snapshot,校验本地下载清单,并用近乎全自动的 `--watch` / `--daemon` 常驻更新。Go module 名为 `bat-api`:正式 Go 入口是资源 bootstrap + 分发服务 `cmd/bat-api`(与 Rust `bat` 同环境运行,经 `bat.sock` RPC 周期发现 release 和 `resource_root`,提供 `/v1/bootstrap`、server-info 改写、CDN path 只读分发和内嵌管理 dashboard);`internal/backendrpc` 为 RPC client;`cmd/bat` 仅为试验骨架(产物 `bin/bat-go`,不是产品 CLI)。边界与进度见 [`docs/reports/GO_STATUS.md`](docs/reports/GO_STATUS.md)。完整游戏业务 API、完整 Web 协作后台、复杂 AssetBundle 重打包和通用 Patch 发布仍在后续阶段。
|
||||
当前仓库仍不是完整产品,但 Rust 侧已经具备一条可运行的官方日服资源同步链路:可以在 Linux 上通过官方 HTTP metadata 自动发现资源入口,拉取 Windows + Android 官方资源,保存同步 snapshot,校验本地下载清单,并用近乎全自动的 `--watch` / `--daemon` 常驻更新。Go module 名为 `bat-api`:正式 Go 入口是资源 bootstrap + 分发服务 `cmd/bat-api`(与 Rust `bat` 同环境运行,经 `bat.sock` RPC 周期发现 release 和 `resource_root`,提供 `/v1/bootstrap`、server-info 改写、CDN path 只读分发和内嵌管理 dashboard);`internal/backendrpc` 为 RPC client;`cmd/bat` 仅为试验骨架(产物 `bin/bat-go`,不是产品 CLI)。边界与进度见 [`docs/reports/GO_STATUS.md`](docs/reports/GO_STATUS.md)。完整游戏业务 API、完整 Web 协作后台和复杂 AssetBundle 重打包仍在后续阶段。
|
||||
|
||||
---
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
- `bat-api` 完整游戏业务 API / launcher 安装包更新全链仍未完成;资源 CDN、HTTP 控制面、launcher 资源引导兼容和内嵌 dashboard MVP 已可用。
|
||||
- 完整 AssetBundle 对象级解析(UnityFS 解包、object table、TypeTree node 元数据、TextAsset bytes、MonoBehaviour/ScriptableObject 基础 TypeTree 字段级解析已起步;复杂字段覆盖、发布级重打包和 Patch 发布统一仍未完成)。
|
||||
- 复杂 AssetBundle 重打包和完整翻译资产编排仍未完成;当前通用 Binary/JSON/Text Patch 基础已在 crate 层可用,localized 发布仅开放已验证 TextUnit 对应的 UnityFS 文本字段。
|
||||
- 复杂 AssetBundle 重打包和完整翻译资产编排仍未完成;当前 generic manifest 已驱动已验证的 Binary/JSON/Text 与 UnityFS localized 操作,未知结构仍明确拒绝。
|
||||
- Translation Memory、Glossary 和完整 Provider 扩展体系:Translation Memory V1 与 Glossary V1 已由 Rust `bat` 持有;仍未实现的是模糊匹配、完整 Provider 扩展体系和完整 Web 协作后台。
|
||||
- SDK、完整 Web 协作后台。
|
||||
|
||||
|
||||
+2
-2
@@ -59,7 +59,7 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon
|
||||
| `i18n status` | 显示当前汉化 release 状态 |
|
||||
| `i18n task update` | 回写 provider worker 任务状态 |
|
||||
| `i18n worker run` | 运行真实 provider worker;支持单次、限定次数和周期执行 |
|
||||
| `i18n publish` | 校验工作台并发布独立汉化 release;`--force` 使用新的手动 release ID |
|
||||
| `i18n publish` | 按工作台或 `--patch-manifest` 发布独立汉化 release;`--force` 使用新的手动 release ID |
|
||||
| `i18n schedule` | 管理翻译和汉化发布计划 |
|
||||
| `refresh` | 执行一次更新检查;若有 live daemon,则通过 RPC 请求其刷新 |
|
||||
| `verify` | 校验远端计划、本地 manifest 和官方 seed hash(dry-run + 审计当前 release) |
|
||||
@@ -195,7 +195,7 @@ launcher 兼容端点只服务启动前资源发现。它们复用 Rust `bat` sn
|
||||
| `translation-glossary-approve` | `translation.glossary.approve` | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | `202` + approved term |
|
||||
| `translation-glossary-deprecate` | `translation.glossary.deprecate` | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | `202` + deprecated term |
|
||||
| `translation-glossary-delete` | `translation.glossary.delete` | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | `202` + deleted term snapshot |
|
||||
| `localized-publish` | `localized.publish` | `{ "translation_file": "...", "localized_release_id": "..." }` 或 `{ "from_worker": true, "localized_release_id": "..." }` | `202` + localized release manifest |
|
||||
| `localized-publish` | `localized.publish` | `{ "translation_file": "...", "localized_release_id": "..." }`、`{ "from_worker": true, "localized_release_id": "..." }` 或 `{ "patch_manifest": "...", "localized_release_id": "..." }` | `202` + localized release manifest |
|
||||
| `localized-rollback` | `localized.rollback` | 可选 `{ "localized_release_id": "..." }` | `202` + rollback report |
|
||||
|
||||
`stop`、`clean-stable`、patch 和 UnityFS 写入命令不会经 HTTP 暴露。
|
||||
|
||||
@@ -723,6 +723,8 @@ paths:
|
||||
type: string
|
||||
from_worker:
|
||||
type: boolean
|
||||
patch_manifest:
|
||||
type: string
|
||||
localized_release_id:
|
||||
type: string
|
||||
responses:
|
||||
|
||||
@@ -149,8 +149,18 @@ pub fn patch_unityfs_text_asset(data: &[u8], patch: &TextAssetPatch) -> Result<V
|
||||
patch.path_id,
|
||||
None,
|
||||
)?;
|
||||
let asset = verified
|
||||
.text_assets
|
||||
let verified_serialized = verified
|
||||
.serialized_files
|
||||
.iter()
|
||||
.find(|file| file.source_path.as_deref() == Some(patch.serialized_file_path.as_str()))
|
||||
.ok_or_else(|| {
|
||||
AssetBundleError::Parse(format!(
|
||||
"patched serialized file {} was not found after rebuild",
|
||||
patch.serialized_file_path
|
||||
))
|
||||
})?;
|
||||
let asset = verified_serialized
|
||||
.text_assets()
|
||||
.iter()
|
||||
.find(|asset| asset.path_id == patch.path_id)
|
||||
.ok_or_else(|| {
|
||||
@@ -2119,6 +2129,50 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_rebuild_text_asset_verification_scopes_duplicate_path_id_to_serialized_file() {
|
||||
let first = synthetic_serialized_text_asset(b"first");
|
||||
let second = synthetic_serialized_text_asset(b"second");
|
||||
let source = synthetic_bundle_with_compressed_blocks(
|
||||
&[("CAB-first", &first), ("CAB-second", &second)],
|
||||
0,
|
||||
0,
|
||||
);
|
||||
|
||||
let patched = patch_unityfs_text_asset(
|
||||
&source,
|
||||
&TextAssetPatch {
|
||||
serialized_file_path: "CAB-second".to_string(),
|
||||
path_id: 1,
|
||||
expected_name: Some("Scenario".to_string()),
|
||||
replacement: b"localized-second".to_vec(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let reparsed = UnityFsParser::new().parse_bytes(&patched).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
reparsed
|
||||
.serialized_files
|
||||
.iter()
|
||||
.find(|file| file.source_path.as_deref() == Some("CAB-first"))
|
||||
.unwrap()
|
||||
.text_assets()[0]
|
||||
.bytes,
|
||||
b"first"
|
||||
);
|
||||
assert_eq!(
|
||||
reparsed
|
||||
.serialized_files
|
||||
.iter()
|
||||
.find(|file| file.source_path.as_deref() == Some("CAB-second"))
|
||||
.unwrap()
|
||||
.text_assets()[0]
|
||||
.bytes,
|
||||
b"localized-second"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_preserves_block_info_at_end_and_lzma_compression() {
|
||||
let serialized = synthetic_serialized_text_asset(b"old");
|
||||
|
||||
@@ -19,8 +19,9 @@ pub mod text;
|
||||
|
||||
pub use error::{PatchError, Result};
|
||||
pub use manifest::{
|
||||
PatchIntegrity, PatchKind, PatchManifest, PatchManifestFile, PatchRollback,
|
||||
PATCH_MANIFEST_VERSION,
|
||||
build_patch_manifest, validate_patch_manifest, verify_patch_file_bytes, PatchIntegrity,
|
||||
PatchKind, PatchManifest, PatchManifestBuildFile, PatchManifestFile, PatchManifestOperation,
|
||||
PatchManifestOperationPayload, PatchManifestProvenance, PatchRollback, PATCH_MANIFEST_VERSION,
|
||||
};
|
||||
|
||||
/// Patch 引擎版本号
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Patch manifest, integrity and rollback primitives.
|
||||
|
||||
use crate::PatchError;
|
||||
use crate::{binary::BinaryPatch, json::JsonPatchOperation, text::TextPatch, PatchError};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
@@ -52,10 +53,30 @@ pub struct PatchManifestFile {
|
||||
pub source_size: u64,
|
||||
/// Expected target byte length.
|
||||
pub target_size: u64,
|
||||
/// Ordered operations that produce the target bytes.
|
||||
#[serde(default)]
|
||||
pub operations: Vec<PatchManifestOperation>,
|
||||
}
|
||||
|
||||
/// Input specification for building one manifest file from verified source and
|
||||
/// target release roots.
|
||||
///
|
||||
/// Operation payloads and provenance are deliberately independent from
|
||||
/// filesystem metadata. This keeps the manifest builder usable for UnityFS operations,
|
||||
/// whose bytes are produced by `bat-assetbundle`, while still requiring the
|
||||
/// resulting source and target files to exist and match the recorded manifest.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PatchManifestBuildFile {
|
||||
/// Release-relative path.
|
||||
pub path: PathBuf,
|
||||
/// Patch kind declared for this file.
|
||||
pub patch_kind: PatchKind,
|
||||
/// Ordered operations, including archive and provenance metadata.
|
||||
pub operations: Vec<PatchManifestOperation>,
|
||||
}
|
||||
|
||||
/// Patch algorithm family used by one manifest file.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PatchKind {
|
||||
/// Deterministic binary hunk patch.
|
||||
@@ -65,7 +86,185 @@ pub enum PatchKind {
|
||||
/// UTF-8 text patch.
|
||||
Text,
|
||||
/// UnityFS TextAsset replacement patch.
|
||||
#[serde(rename = "unityfs_text_asset")]
|
||||
UnityFsTextAsset,
|
||||
/// UnityFS TypeTree string-field replacement patch.
|
||||
#[serde(rename = "unityfs_string_field")]
|
||||
UnityFsStringField,
|
||||
/// UnityFS semantic TypeTree field replacement patch.
|
||||
#[serde(rename = "unityfs_field")]
|
||||
UnityFsField,
|
||||
/// A file containing more than one supported operation kind.
|
||||
Mixed,
|
||||
}
|
||||
|
||||
/// One ordered, auditable operation in a manifest file.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PatchManifestOperation {
|
||||
/// Stable zero-based order within the file.
|
||||
pub sequence: u32,
|
||||
/// Optional BLAKE3 hash of the bytes immediately before this operation.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source_blake3: Option<String>,
|
||||
/// Optional size of the bytes immediately before this operation.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source_size: Option<u64>,
|
||||
/// Optional archive entry for a UnityFS bundle nested in a ZIP.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub archive_entry: Option<String>,
|
||||
/// Algorithm payload and UnityFS target location.
|
||||
#[serde(flatten)]
|
||||
pub payload: PatchManifestOperationPayload,
|
||||
/// Translation and review provenance, when this operation came from a
|
||||
/// localized workflow.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub provenance: Option<PatchManifestProvenance>,
|
||||
}
|
||||
|
||||
/// Supported operation payloads in the generic manifest.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum PatchManifestOperationPayload {
|
||||
/// Deterministic binary hunk patch.
|
||||
Binary {
|
||||
/// Complete binary patch document.
|
||||
patch: BinaryPatch,
|
||||
},
|
||||
/// RFC 6902 JSON Patch document.
|
||||
Json {
|
||||
/// JSON Patch array. It is retained as JSON to preserve the wire
|
||||
/// contract while the algorithm crate validates each operation.
|
||||
patch: Value,
|
||||
},
|
||||
/// UTF-8 text patch.
|
||||
Text {
|
||||
/// Complete text patch document.
|
||||
patch: TextPatch,
|
||||
},
|
||||
/// UnityFS TextAsset replacement.
|
||||
#[serde(rename = "unityfs_text_asset")]
|
||||
UnityFsTextAsset {
|
||||
/// Serialized file path in the UnityFS directory table.
|
||||
serialized_file_path: String,
|
||||
/// Unity object path ID.
|
||||
path_id: i64,
|
||||
/// Optional expected TextAsset name.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
expected_name: Option<String>,
|
||||
/// Replacement bytes.
|
||||
replacement: Vec<u8>,
|
||||
},
|
||||
/// UnityFS TypeTree string field replacement.
|
||||
#[serde(rename = "unityfs_string_field")]
|
||||
UnityFsStringField {
|
||||
/// Serialized file path in the UnityFS directory table.
|
||||
serialized_file_path: String,
|
||||
/// Unity object path ID.
|
||||
path_id: i64,
|
||||
/// TypeTree field path.
|
||||
field_path: String,
|
||||
/// Optional expected source string.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
expected_value: Option<String>,
|
||||
/// Replacement string.
|
||||
replacement: String,
|
||||
},
|
||||
/// UnityFS semantic TypeTree field replacement.
|
||||
#[serde(rename = "unityfs_field")]
|
||||
UnityFsField {
|
||||
/// Serialized file path in the UnityFS directory table.
|
||||
serialized_file_path: String,
|
||||
/// Unity object path ID.
|
||||
path_id: i64,
|
||||
/// TypeTree field path.
|
||||
field_path: String,
|
||||
/// Optional expected semantic source value.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
expected_value: Option<Value>,
|
||||
/// Replacement semantic value using the Unity serialized value schema.
|
||||
replacement: Value,
|
||||
},
|
||||
}
|
||||
|
||||
impl PatchManifestOperationPayload {
|
||||
/// Returns the file-level patch kind represented by this payload.
|
||||
pub fn patch_kind(&self) -> PatchKind {
|
||||
match self {
|
||||
Self::Binary { .. } => PatchKind::Binary,
|
||||
Self::Json { .. } => PatchKind::Json,
|
||||
Self::Text { .. } => PatchKind::Text,
|
||||
Self::UnityFsTextAsset { .. } => PatchKind::UnityFsTextAsset,
|
||||
Self::UnityFsStringField { .. } => PatchKind::UnityFsStringField,
|
||||
Self::UnityFsField { .. } => PatchKind::UnityFsField,
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(&self) -> crate::Result<()> {
|
||||
match self {
|
||||
Self::Binary { patch } if patch.version != crate::binary::BINARY_PATCH_VERSION => {
|
||||
return Err(PatchError::ApplyFailed(format!(
|
||||
"unsupported binary patch version {}",
|
||||
patch.version
|
||||
)))
|
||||
}
|
||||
Self::Text { patch } if patch.version != crate::text::TEXT_PATCH_VERSION => {
|
||||
return Err(PatchError::ApplyFailed(format!(
|
||||
"unsupported text patch version {}",
|
||||
patch.version
|
||||
)))
|
||||
}
|
||||
Self::Json { patch } => {
|
||||
serde_json::from_value::<Vec<JsonPatchOperation>>(patch.clone()).map_err(
|
||||
|error| {
|
||||
PatchError::ApplyFailed(format!(
|
||||
"invalid JSON patch operation list: {error}"
|
||||
))
|
||||
},
|
||||
)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Provenance retained for a localized operation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PatchManifestProvenance {
|
||||
/// Stable TextUnit identifier.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub text_unit_id: Option<String>,
|
||||
/// BLAKE3 of the validated source text.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source_text_blake3: Option<String>,
|
||||
/// Translation provider.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub translation_provider: Option<String>,
|
||||
/// Provider run identifier.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub provider_run_id: Option<String>,
|
||||
/// Translation source kind.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub translation_source_kind: Option<String>,
|
||||
/// Trusted Translation Memory record identifier.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub translation_memory_record_id: Option<String>,
|
||||
/// Review status.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub review_status: Option<String>,
|
||||
/// Deterministic Glossary QA report.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub glossary_qa: Option<Value>,
|
||||
/// Explicit Glossary QA override.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub glossary_override: Option<Value>,
|
||||
}
|
||||
|
||||
impl PatchManifestOperation {
|
||||
/// Returns the operation kind.
|
||||
pub fn patch_kind(&self) -> PatchKind {
|
||||
self.payload.patch_kind()
|
||||
}
|
||||
}
|
||||
|
||||
/// Rollback metadata owned by higher-level publication code.
|
||||
@@ -94,12 +293,7 @@ pub fn verify_patch_manifest_files(
|
||||
target_root: &Path,
|
||||
manifest: &PatchManifest,
|
||||
) -> crate::Result<PatchIntegrity> {
|
||||
if manifest.version != PATCH_MANIFEST_VERSION {
|
||||
return Err(PatchError::ApplyFailed(format!(
|
||||
"unsupported patch manifest version {}",
|
||||
manifest.version
|
||||
)));
|
||||
}
|
||||
validate_patch_manifest(manifest)?;
|
||||
|
||||
let mut integrity = PatchIntegrity {
|
||||
file_count: 0,
|
||||
@@ -119,6 +313,275 @@ pub fn verify_patch_manifest_files(
|
||||
Ok(integrity)
|
||||
}
|
||||
|
||||
/// Validates manifest schema, paths, operation order and kind compatibility.
|
||||
pub fn validate_patch_manifest(manifest: &PatchManifest) -> crate::Result<()> {
|
||||
if manifest.version != PATCH_MANIFEST_VERSION {
|
||||
return Err(PatchError::ApplyFailed(format!(
|
||||
"unsupported patch manifest version {}",
|
||||
manifest.version
|
||||
)));
|
||||
}
|
||||
for (label, value) in [
|
||||
("patch_id", manifest.patch_id.as_str()),
|
||||
("source_version", manifest.source_version.as_str()),
|
||||
("target_version", manifest.target_version.as_str()),
|
||||
] {
|
||||
if value.is_empty()
|
||||
|| value.contains('\0')
|
||||
|| value.contains('/')
|
||||
|| value.contains('\\')
|
||||
|| value == "."
|
||||
|| value == ".."
|
||||
{
|
||||
return Err(PatchError::ApplyFailed(format!(
|
||||
"invalid patch manifest {label}: {value}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let mut paths = std::collections::BTreeSet::new();
|
||||
for file in &manifest.files {
|
||||
resolve_manifest_path(Path::new("."), &file.path)?;
|
||||
if !paths.insert(file.path.clone()) {
|
||||
return Err(PatchError::ApplyFailed(format!(
|
||||
"patch manifest contains duplicate file path: {}",
|
||||
file.path.display()
|
||||
)));
|
||||
}
|
||||
let mut targets = std::collections::BTreeSet::new();
|
||||
for (expected_sequence, operation) in file.operations.iter().enumerate() {
|
||||
operation.payload.validate()?;
|
||||
if operation.sequence != expected_sequence as u32 {
|
||||
return Err(PatchError::ApplyFailed(format!(
|
||||
"patch manifest operation order is not contiguous for {}: expected {}, got {}",
|
||||
file.path.display(),
|
||||
expected_sequence,
|
||||
operation.sequence
|
||||
)));
|
||||
}
|
||||
if let Some(archive_entry) = operation.archive_entry.as_deref() {
|
||||
validate_archive_entry(archive_entry)?;
|
||||
}
|
||||
if operation.source_blake3.is_some() != operation.source_size.is_some() {
|
||||
return Err(PatchError::ApplyFailed(format!(
|
||||
"patch manifest operation source precondition must include hash and size: {} operation {}",
|
||||
file.path.display(),
|
||||
operation.sequence
|
||||
)));
|
||||
}
|
||||
if file.operations.len() > 1
|
||||
&& (operation.source_blake3.is_none() || operation.source_size.is_none())
|
||||
{
|
||||
return Err(PatchError::ApplyFailed(format!(
|
||||
"multiple operations require source preconditions: {} operation {}",
|
||||
file.path.display(),
|
||||
operation.sequence
|
||||
)));
|
||||
}
|
||||
if operation.archive_entry.is_some()
|
||||
&& !matches!(
|
||||
operation.payload,
|
||||
PatchManifestOperationPayload::UnityFsTextAsset { .. }
|
||||
| PatchManifestOperationPayload::UnityFsStringField { .. }
|
||||
| PatchManifestOperationPayload::UnityFsField { .. }
|
||||
)
|
||||
{
|
||||
return Err(PatchError::ApplyFailed(format!(
|
||||
"archive_entry is only supported for UnityFS operations: {} operation {}",
|
||||
file.path.display(),
|
||||
operation.sequence
|
||||
)));
|
||||
}
|
||||
if let Some(target) = unity_operation_target(operation) {
|
||||
if !targets.insert(target) {
|
||||
return Err(PatchError::ApplyFailed(format!(
|
||||
"patch manifest repeats an incompatible UnityFS target: {} operation {}",
|
||||
file.path.display(),
|
||||
operation.sequence
|
||||
)));
|
||||
}
|
||||
}
|
||||
if file.patch_kind != PatchKind::Mixed && file.patch_kind != operation.patch_kind() {
|
||||
return Err(PatchError::ApplyFailed(format!(
|
||||
"patch manifest kind mismatch for {} operation {}",
|
||||
file.path.display(),
|
||||
operation.sequence
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unity_operation_target(operation: &PatchManifestOperation) -> Option<(Option<&str>, &str, i64)> {
|
||||
let (serialized_file_path, path_id) = match &operation.payload {
|
||||
PatchManifestOperationPayload::UnityFsTextAsset {
|
||||
serialized_file_path,
|
||||
path_id,
|
||||
..
|
||||
} => (serialized_file_path, *path_id),
|
||||
PatchManifestOperationPayload::UnityFsStringField {
|
||||
serialized_file_path,
|
||||
path_id,
|
||||
..
|
||||
}
|
||||
| PatchManifestOperationPayload::UnityFsField {
|
||||
serialized_file_path,
|
||||
path_id,
|
||||
..
|
||||
} => (serialized_file_path, *path_id),
|
||||
_ => return None,
|
||||
};
|
||||
Some((
|
||||
operation.archive_entry.as_deref(),
|
||||
serialized_file_path.as_str(),
|
||||
path_id,
|
||||
))
|
||||
}
|
||||
|
||||
/// Builds a manifest from release-root bytes and ordered operation payloads.
|
||||
///
|
||||
/// This function does not infer or apply operations. Callers construct target
|
||||
/// bytes with the owning algorithm/adapter first, then this builder binds the
|
||||
/// actual source and target hash/size to the auditable manifest. Operation
|
||||
/// sequence numbers are assigned from the supplied vector order.
|
||||
pub fn build_patch_manifest(
|
||||
source_root: &Path,
|
||||
target_root: &Path,
|
||||
patch_id: impl Into<String>,
|
||||
source_version: impl Into<String>,
|
||||
target_version: impl Into<String>,
|
||||
files: Vec<PatchManifestBuildFile>,
|
||||
rollback: PatchRollback,
|
||||
) -> crate::Result<PatchManifest> {
|
||||
let mut manifest_files = Vec::with_capacity(files.len());
|
||||
for file in files {
|
||||
let source_path = resolve_manifest_path(source_root, &file.path)?;
|
||||
let target_path = resolve_manifest_path(target_root, &file.path)?;
|
||||
let source = read_manifest_file(&source_path, "source")?;
|
||||
let target = read_manifest_file(&target_path, "target")?;
|
||||
let has_unity_operation = file.operations.iter().any(|operation| {
|
||||
matches!(
|
||||
&operation.payload,
|
||||
PatchManifestOperationPayload::UnityFsTextAsset { .. }
|
||||
| PatchManifestOperationPayload::UnityFsStringField { .. }
|
||||
| PatchManifestOperationPayload::UnityFsField { .. }
|
||||
)
|
||||
});
|
||||
let has_archive_operation = file
|
||||
.operations
|
||||
.iter()
|
||||
.any(|operation| operation.archive_entry.is_some());
|
||||
if has_archive_operation && !has_unity_operation {
|
||||
return Err(PatchError::ApplyFailed(format!(
|
||||
"manifest builder archive operations must be UnityFS operations: {}",
|
||||
file.path.display()
|
||||
)));
|
||||
}
|
||||
if file.operations.len() > 1 && has_unity_operation {
|
||||
return Err(PatchError::ApplyFailed(format!(
|
||||
"manifest builder requires one UnityFS operation per file; use adapter-supplied manifest operations for multiple UnityFS targets: {}",
|
||||
file.path.display()
|
||||
)));
|
||||
}
|
||||
let operation_count = file.operations.len();
|
||||
let mut current = source.clone();
|
||||
let operations = file
|
||||
.operations
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(sequence, mut operation)| {
|
||||
operation.payload.validate()?;
|
||||
let source_blake3 = (operation_count > 1).then(|| blake3_hex(¤t));
|
||||
let source_size = (operation_count > 1).then_some(current.len() as u64);
|
||||
if operation.source_blake3.is_none() && operation_count > 1 {
|
||||
if has_archive_operation || has_unity_operation {
|
||||
return Err(PatchError::ApplyFailed(format!(
|
||||
"manifest builder requires source preconditions for multiple non-direct operations: {}",
|
||||
file.path.display()
|
||||
)));
|
||||
}
|
||||
operation.source_blake3 = source_blake3;
|
||||
operation.source_size = source_size;
|
||||
}
|
||||
if let (Some(expected_hash), Some(expected_size)) =
|
||||
(operation.source_blake3.as_deref(), operation.source_size)
|
||||
{
|
||||
if expected_hash != blake3_hex(¤t)
|
||||
|| expected_size != current.len() as u64
|
||||
{
|
||||
return Err(PatchError::ApplyFailed(format!(
|
||||
"manifest builder operation source precondition mismatch: {} operation {}",
|
||||
file.path.display(),
|
||||
sequence
|
||||
)));
|
||||
}
|
||||
}
|
||||
if !has_unity_operation && !has_archive_operation {
|
||||
current = apply_direct_payload(¤t, &operation.payload)?;
|
||||
}
|
||||
operation.sequence = sequence as u32;
|
||||
Ok(operation)
|
||||
})
|
||||
.collect::<crate::Result<Vec<_>>>()?;
|
||||
if !has_unity_operation && !has_archive_operation && current != target {
|
||||
return Err(PatchError::ApplyFailed(format!(
|
||||
"manifest builder operations do not produce target bytes: {}",
|
||||
file.path.display()
|
||||
)));
|
||||
}
|
||||
manifest_files.push(PatchManifestFile {
|
||||
path: file.path,
|
||||
patch_kind: file.patch_kind,
|
||||
source_blake3: blake3_hex(&source),
|
||||
target_blake3: blake3_hex(&target),
|
||||
source_size: source.len() as u64,
|
||||
target_size: target.len() as u64,
|
||||
operations,
|
||||
});
|
||||
}
|
||||
let manifest = PatchManifest {
|
||||
version: PATCH_MANIFEST_VERSION,
|
||||
patch_id: patch_id.into(),
|
||||
source_version: source_version.into(),
|
||||
target_version: target_version.into(),
|
||||
files: manifest_files,
|
||||
rollback,
|
||||
};
|
||||
validate_patch_manifest(&manifest)?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
fn apply_direct_payload(
|
||||
source: &[u8],
|
||||
payload: &PatchManifestOperationPayload,
|
||||
) -> crate::Result<Vec<u8>> {
|
||||
match payload {
|
||||
PatchManifestOperationPayload::Binary { patch } => {
|
||||
crate::binary::apply_binary_patch(source, patch)
|
||||
}
|
||||
PatchManifestOperationPayload::Json { patch } => {
|
||||
let source = std::str::from_utf8(source).map_err(|error| {
|
||||
PatchError::ApplyFailed(format!("JSON patch source is not UTF-8: {error}"))
|
||||
})?;
|
||||
let patch = serde_json::to_string(patch).map_err(|error| {
|
||||
PatchError::ApplyFailed(format!("failed to serialize JSON patch: {error}"))
|
||||
})?;
|
||||
crate::json::apply_json_patch(source, &patch).map(|value| value.into_bytes())
|
||||
}
|
||||
PatchManifestOperationPayload::Text { patch } => {
|
||||
let source = std::str::from_utf8(source).map_err(|error| {
|
||||
PatchError::ApplyFailed(format!("text patch source is not UTF-8: {error}"))
|
||||
})?;
|
||||
crate::text::apply_text_patch(source, patch).map(|value| value.into_bytes())
|
||||
}
|
||||
PatchManifestOperationPayload::UnityFsTextAsset { .. }
|
||||
| PatchManifestOperationPayload::UnityFsStringField { .. }
|
||||
| PatchManifestOperationPayload::UnityFsField { .. } => Err(PatchError::ApplyFailed(
|
||||
"manifest builder cannot apply UnityFS payload without bat-assetbundle".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies one manifest file entry against source and target bytes.
|
||||
pub fn verify_patch_file_bytes(
|
||||
source: &[u8],
|
||||
@@ -151,7 +614,14 @@ pub fn verify_patch_file_bytes(
|
||||
}
|
||||
|
||||
fn resolve_manifest_path(root: &Path, relative: &Path) -> crate::Result<PathBuf> {
|
||||
if relative.is_absolute() {
|
||||
if relative.is_absolute()
|
||||
|| relative.as_os_str().is_empty()
|
||||
|| relative.to_string_lossy().contains('\0')
|
||||
|| relative.to_string_lossy().contains('\\')
|
||||
|| relative == Path::new(".")
|
||||
|| relative.components().count() == 0
|
||||
|| relative.to_string_lossy().as_bytes().get(1) == Some(&b':')
|
||||
{
|
||||
return Err(PatchError::ApplyFailed(format!(
|
||||
"patch manifest path must be relative: {}",
|
||||
relative.display()
|
||||
@@ -171,6 +641,26 @@ fn resolve_manifest_path(root: &Path, relative: &Path) -> crate::Result<PathBuf>
|
||||
Ok(root.join(relative))
|
||||
}
|
||||
|
||||
fn validate_archive_entry(entry: &str) -> crate::Result<()> {
|
||||
if entry.is_empty()
|
||||
|| entry.contains('\0')
|
||||
|| entry.contains('\\')
|
||||
|| entry.as_bytes().get(1) == Some(&b':')
|
||||
{
|
||||
return Err(PatchError::ApplyFailed(format!(
|
||||
"patch manifest archive entry is unsafe: {entry}"
|
||||
)));
|
||||
}
|
||||
for component in Path::new(entry).components() {
|
||||
if !matches!(component, Component::Normal(_) | Component::CurDir) {
|
||||
return Err(PatchError::ApplyFailed(format!(
|
||||
"patch manifest archive entry escapes archive root: {entry}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_manifest_file(path: &Path, label: &str) -> crate::Result<Vec<u8>> {
|
||||
fs::read(path).map_err(|error| {
|
||||
PatchError::ApplyFailed(format!(
|
||||
@@ -238,6 +728,169 @@ mod tests {
|
||||
assert!(matches!(error, PatchError::ApplyFailed(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_patch_manifest_binds_release_metadata_and_operation_order() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let source_root = temp.path().join("source");
|
||||
let target_root = temp.path().join("target");
|
||||
fs::create_dir_all(&source_root).unwrap();
|
||||
fs::create_dir_all(&target_root).unwrap();
|
||||
let source = b"before";
|
||||
let target = b"after";
|
||||
fs::write(source_root.join("file.bin"), source).unwrap();
|
||||
fs::write(target_root.join("file.bin"), target).unwrap();
|
||||
|
||||
let manifest = build_patch_manifest(
|
||||
&source_root,
|
||||
&target_root,
|
||||
"localized-v1",
|
||||
"official-v1",
|
||||
"localized-v1",
|
||||
vec![PatchManifestBuildFile {
|
||||
path: PathBuf::from("file.bin"),
|
||||
patch_kind: PatchKind::Binary,
|
||||
operations: vec![PatchManifestOperation {
|
||||
sequence: 99,
|
||||
source_blake3: None,
|
||||
source_size: None,
|
||||
archive_entry: None,
|
||||
payload: PatchManifestOperationPayload::Binary {
|
||||
patch: crate::binary::diff(source, target),
|
||||
},
|
||||
provenance: None,
|
||||
}],
|
||||
}],
|
||||
PatchRollback {
|
||||
previous_current_target: None,
|
||||
remove_target_path: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(manifest.source_version, "official-v1");
|
||||
assert_eq!(manifest.files[0].source_blake3, blake3_hex(source));
|
||||
assert_eq!(manifest.files[0].target_blake3, blake3_hex(target));
|
||||
assert_eq!(manifest.files[0].operations[0].sequence, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_patch_manifest_records_each_direct_operation_source_precondition() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let source_root = temp.path().join("source");
|
||||
let target_root = temp.path().join("target");
|
||||
fs::create_dir_all(&source_root).unwrap();
|
||||
fs::create_dir_all(&target_root).unwrap();
|
||||
let source = b"before";
|
||||
let intermediate = b"middle";
|
||||
let target = b"after";
|
||||
fs::write(source_root.join("file.bin"), source).unwrap();
|
||||
fs::write(target_root.join("file.bin"), target).unwrap();
|
||||
let first = crate::binary::diff(source, intermediate);
|
||||
let second = crate::binary::diff(intermediate, target);
|
||||
|
||||
let manifest = build_patch_manifest(
|
||||
&source_root,
|
||||
&target_root,
|
||||
"localized-v1",
|
||||
"official-v1",
|
||||
"localized-v1",
|
||||
vec![PatchManifestBuildFile {
|
||||
path: PathBuf::from("file.bin"),
|
||||
patch_kind: PatchKind::Binary,
|
||||
operations: vec![
|
||||
PatchManifestOperation {
|
||||
sequence: 20,
|
||||
source_blake3: None,
|
||||
source_size: None,
|
||||
archive_entry: None,
|
||||
payload: PatchManifestOperationPayload::Binary { patch: first },
|
||||
provenance: None,
|
||||
},
|
||||
PatchManifestOperation {
|
||||
sequence: 21,
|
||||
source_blake3: None,
|
||||
source_size: None,
|
||||
archive_entry: None,
|
||||
payload: PatchManifestOperationPayload::Binary { patch: second },
|
||||
provenance: None,
|
||||
},
|
||||
],
|
||||
}],
|
||||
PatchRollback {
|
||||
previous_current_target: None,
|
||||
remove_target_path: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
manifest.files[0].operations[0].source_blake3,
|
||||
Some(blake3_hex(source))
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.files[0].operations[1].source_blake3,
|
||||
Some(blake3_hex(intermediate))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_patch_manifest_rejects_invalid_json_operation_payload() {
|
||||
let mut manifest = manifest_for("file.json", b"{}", b"{\"value\":1}");
|
||||
manifest.files[0].patch_kind = PatchKind::Json;
|
||||
manifest.files[0].operations = vec![PatchManifestOperation {
|
||||
sequence: 0,
|
||||
source_blake3: None,
|
||||
source_size: None,
|
||||
archive_entry: None,
|
||||
payload: PatchManifestOperationPayload::Json {
|
||||
patch: serde_json::json!({"op": "replace", "path": "/value", "value": 1}),
|
||||
},
|
||||
provenance: None,
|
||||
}];
|
||||
|
||||
assert!(validate_patch_manifest(&manifest).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_patch_manifest_rejects_repeated_unity_object_target() {
|
||||
let source = b"source";
|
||||
let target = b"target";
|
||||
let mut manifest = manifest_for("bundle", source, target);
|
||||
manifest.files[0].patch_kind = PatchKind::Mixed;
|
||||
manifest.files[0].operations = vec![
|
||||
PatchManifestOperation {
|
||||
sequence: 0,
|
||||
source_blake3: Some(blake3_hex(source)),
|
||||
source_size: Some(source.len() as u64),
|
||||
archive_entry: None,
|
||||
payload: PatchManifestOperationPayload::UnityFsStringField {
|
||||
serialized_file_path: "CAB-one".to_string(),
|
||||
path_id: 7,
|
||||
field_path: "first".to_string(),
|
||||
expected_value: None,
|
||||
replacement: "one".to_string(),
|
||||
},
|
||||
provenance: None,
|
||||
},
|
||||
PatchManifestOperation {
|
||||
sequence: 1,
|
||||
source_blake3: Some(blake3_hex(target)),
|
||||
source_size: Some(target.len() as u64),
|
||||
archive_entry: None,
|
||||
payload: PatchManifestOperationPayload::UnityFsField {
|
||||
serialized_file_path: "CAB-one".to_string(),
|
||||
path_id: 7,
|
||||
field_path: "second".to_string(),
|
||||
expected_value: None,
|
||||
replacement: serde_json::json!({"kind": "string", "value": "two"}),
|
||||
},
|
||||
provenance: None,
|
||||
},
|
||||
];
|
||||
|
||||
assert!(validate_patch_manifest(&manifest).is_err());
|
||||
}
|
||||
|
||||
fn manifest_for(path: &str, source: &[u8], target: &[u8]) -> PatchManifest {
|
||||
PatchManifest {
|
||||
version: PATCH_MANIFEST_VERSION,
|
||||
@@ -251,6 +904,7 @@ mod tests {
|
||||
target_blake3: blake3_hex(target),
|
||||
source_size: source.len() as u64,
|
||||
target_size: target.len() as u64,
|
||||
operations: Vec::new(),
|
||||
}],
|
||||
rollback: PatchRollback {
|
||||
previous_current_target: None,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# AssetBundle 与资源解析路线图
|
||||
|
||||
- **更新时间**:2026-09-04
|
||||
- **更新时间**:2026-09-12
|
||||
- **适用范围**:Rust 解析引擎、官方同步后的解析缓存、CAS/ResourceRepository 接入、后续文本提取和 Patch 发布。
|
||||
- **权威关联**:`PROJECT_PLAN.md` Milestone 3/4/5/8,`docs/reports/CURRENT_GAPS.md` G-005/G-007/G-011/G-011D。
|
||||
- **开发状态**:解析扩展当前按路线图和真实回归继续推进。
|
||||
@@ -32,7 +32,7 @@
|
||||
| UnityFS container | `.bundle`、zip 内 bundle | header、block、directory、解压文件、基础摘要 | 已支持基础解包、LZ4/LZMA、alignment、大小/计数/路径/边界校验;受支持 patch 可保留容器形态重建 |
|
||||
| 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 发布 | 已翻译 TextUnit、中间格式、原版资源 | 可验证 localized patch manifest、汉化 release 目录、current/state | 已支持 TextAsset、TypeTree string field、managed-reference string field 的 UnityFS parse→modify→rebuild→reparse;可验证 ZIP 内 bundle 时会重写外层 ZIP;通用 manifest 发布仍未完成 |
|
||||
| Patch 发布 | 已翻译 TextUnit、中间格式、原版资源 | 可验证 localized patch manifest、汉化 release 目录、current/state | generic manifest 已驱动 Binary/JSON/Text 与当前支持的 UnityFS 操作;可验证 ZIP 内 bundle 时会在外层重写后重新读取、重解析并校验定位字段/替换值 |
|
||||
|
||||
---
|
||||
|
||||
@@ -171,9 +171,9 @@ catalog 仍按“明确不支持”处理,不把低保真路径伪装成完整
|
||||
|
||||
交付:
|
||||
|
||||
1. 已定义 `localized-patch-manifest.json`:目标官方版本、localized release、输出文件、hash、size、byte delta、TextUnit/provider/review trace 和回滚信息。
|
||||
1. 已定义并接入 generic `PatchManifest`:目标官方版本、localized release、输出文件、hash、size、按序 operation、算法载荷、UnityFS 定位、TextUnit/provider/review trace 和回滚信息。
|
||||
2. 已支持 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 的 localized patch 操作;普通 ZIP 条目在 `archive_entry` 可验证、内层可重解析时会解包、重建并重写外层 ZIP,路径穿越、symlink、混合直接/ZIP patch 和无效内层 bundle 明确失败。
|
||||
3. MonoBehaviour/ScriptableObject 字段替换必须依赖 P2 字段级解析结果。
|
||||
3. MonoBehaviour/ScriptableObject 字段替换必须依赖 P2 字段级解析结果;generic manifest 不把 UnityFS 定位信息扁平化。
|
||||
4. Patch 产物写入配置化汉化发布根下的 `.staging/<id>`,校验通过后发布到 `versions/<id>` 并切换 `current`;rollback 按 manifest 恢复上一 release。
|
||||
5. 成功后发布状态从 `not_localized` 切到 `localized`;`localized.status` 要求 state、current symlink 和 patch manifest 同时匹配当前官方 release,ZIP 外层文件和内层 UnityFS 也必须通过发布后重解析校验。
|
||||
|
||||
@@ -181,7 +181,7 @@ catalog 仍按“明确不支持”处理,不把低保真路径伪装成完整
|
||||
|
||||
1. Patch 失败不影响 `bat-resources/current`。
|
||||
2. 汉化 release 保留官方相对目录结构。
|
||||
3. `localized` 状态能证明原版和汉化两套资源都已发布,且 patch manifest 可验证。
|
||||
3. `localized` 状态能证明原版和汉化两套资源都已发布,且 generic/localized patch manifest 可验证。
|
||||
|
||||
---
|
||||
|
||||
@@ -210,7 +210,7 @@ catalog 仍按“明确不支持”处理,不把低保真路径伪装成完整
|
||||
|
||||
1. 继续补充 Addressables Windows/Android 真实 catalog 样本和独立二进制格式诊断。
|
||||
2. 继续补充 TypeTree 字段 reader、MonoBehaviour/ScriptableObject 遍历和真实版本差异。
|
||||
3. 基于 `translation.worker.run` 扩展 TM/Glossary 和通用 manifest Patch 构建。
|
||||
3. 基于 `translation.worker.run` 继续扩展 TM/Glossary provenance 和真实资源发布样本。
|
||||
4. 扩展翻译任务结果在 CAS/ResourceRepository 查询面的索引。
|
||||
5. 在通用 Binary/JSON/Text Patch 基础上继续扩展复杂 AssetBundle 重打包和通用
|
||||
Patch 发布流程统一,保留当前受支持 localized patch 发布/rollback 链路。
|
||||
5. 在 generic Binary/JSON/Text Patch 基础上继续扩展复杂 AssetBundle 重打包,但只在
|
||||
新结构有真实 fixture 和完整重建验证时接入;不扩大当前 UnityFS V1 的宣称范围。
|
||||
|
||||
@@ -244,7 +244,7 @@ trusted 和 release/TextUnit/provider/run provenance;`translation.tasks` 优
|
||||
13. 发布完成后先对比上一完整 release 和当前 release 的 `official-download-manifest.json`,写出 `official-resource-changes.json` 和 `crowdin-translation-handoff.json`。同一 destination 只有 size 或 BLAKE3 变化才算 modified;新增+变更资源进入解析/翻译 handoff,删除资源只进入差异记录。当前只预留 Crowdin 本地 handoff,不发外部 API 请求。
|
||||
14. 随后刷新 active release 下的 `official-parse-cache.json` 和 `official-textunit-index.json`,并从 Added/Modified 资源、parse cache 与 TextUnit 明细索引派生 `official-textunit-tasks.json`、`crowdin-textunit-queue.json` 和版本化的 `translation-tasks.sqlite`;up-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析,重新同步队列时保留已有 worker 状态。
|
||||
15. 若启用 `--import-repository`,已校验 release 会被导入 CAS + `ResourceRepository`,并可经 `resource.index` 查询。历史 release 候选失效时,已有 CAS 对象会先经过完整性和元数据校验,再增加 release 引用并原子物化;当前 release 在 `official-cas-reuse-references.json` 中记录引用,staging/release 清理时递减,失败则回退网络并保留诊断。
|
||||
16. 官方同步报告默认给出 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布;受支持的 UnityFS TextAsset、TypeTree string field 和 managed-reference string field patch 发布成功并通过 `localized-patch-manifest.json`、current symlink、release ID 及 ZIP 内层重解析校验后,`localized.status` 才返回 `localized`,表示原版和汉化两套资源都已发布。`translation.proofread` 只会把 workflow 标记成 `manual_proofreading` / `translation.manual_proofreading`,不会回退已发布汉化 release 的发布状态。
|
||||
16. 官方同步报告默认给出 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布;generic manifest 驱动的 Binary/JSON/Text 以及当前支持的 UnityFS TextAsset、TypeTree string field 和 managed-reference string field patch 发布成功并通过 `localized-patch-manifest.json`、current symlink、release ID 及 ZIP 内层最终重解析校验后,`localized.status` 才返回 `localized`,表示原版和汉化两套资源都已发布。`translation.proofread` 只会把 workflow 标记成 `manual_proofreading` / `translation.manual_proofreading`,不会回退已发布汉化 release 的发布状态。
|
||||
|
||||
维护期特殊分支:如果官方 launcher/server-info 已经指向新资源根,但 client-patch seed marker 或必需 seed catalog 仍返回 403/404 等未开放状态,`bat` 返回 `waiting_for_official_resources`,保留现有 `current`,不创建失败 staging;若本轮启用 `--auto-discover`,会在 `<output>/official-launcher-bootstrap.pending.json` 写入待处理 launcher bootstrap 证据,供后续排障和自研客户端开发使用。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 官方资源 Release 布局与资源侧契约
|
||||
|
||||
- **更新时间**:2026-09-04
|
||||
- **更新时间**:2026-09-12
|
||||
- **用途**:冻结日服官方资源在本地发布根上的布局、URL 映射、seed 规则、`bat`/`bat-api` 关系,以及 `bat-api` 分发 path 的 1:1 对应关系。
|
||||
- **范围**:资源发现 / 清单 / 落盘 / 只读分发(**不是**完整游戏业务 API)。
|
||||
- **权威代码**:
|
||||
@@ -75,7 +75,9 @@
|
||||
<localized-output>/ # 汉化产物发布根(--localized-output / BAT_LOCALIZED_OUTPUT)
|
||||
current -> versions/<id> # 已汉化后才切换;未汉化状态不发布
|
||||
versions/<id>/ # 与官方相对路径一致的汉化资源
|
||||
localized-version-state.json # 预留:后续 Patch 发布阶段维护,官方同步阶段不写入
|
||||
localized-patch-manifest.json # localized wrapper + generic PatchManifest 审计输入/结果
|
||||
.staging/<id>/ # generic/translation patch 未发布写侧
|
||||
localized-version-state.json # localized current、官方 source release 和 workflow 状态
|
||||
```
|
||||
|
||||
官方资源发布和汉化发布是两个独立状态:
|
||||
|
||||
@@ -89,8 +89,8 @@ git check-ignore -v Cargo.lock CLAUDE.md AGENTS.md CONTRIBUTING.md
|
||||
当前开发优先推进:
|
||||
|
||||
1. 继续 AssetBundle 复杂对象解析、真实 fixture 和发布级重打包。
|
||||
2. 基于 `translation.worker.run` 继续扩展 TM/Glossary,并推进通用 manifest Patch 构建。
|
||||
3. 扩展 ResourceRepository 查询面:更丰富的 TextUnit/TM 查询和通用 Patch 发布所需资源视图。
|
||||
2. 基于 `translation.worker.run` 继续扩展 TM/Glossary,并补充复杂 AssetBundle 的真实 fixture 与发布验证。
|
||||
3. 扩展 ResourceRepository 查询面:更丰富的 TextUnit/TM 查询和 generic manifest 发布所需资源视图。
|
||||
4. 按 `docs/guides/official-full-pull-smoke.md` 在隔离目录执行真实官方网络全量下载 smoke,并保留运行报告。
|
||||
5. 在资源和翻译契约稳定后推进完整 Web 协作后台和完整游戏业务 API。
|
||||
|
||||
|
||||
@@ -336,7 +336,8 @@ source text、destination 和 archive entry 后再落库。blocking Glossary QA
|
||||
## localized patch 发布与回滚
|
||||
|
||||
`i18n publish` 会在独立的 `.staging/<localized-release-id>` 中复制当前官方
|
||||
release,校验工作台与当前 TextUnit 索引的 source/location 一致后,写入已有支持
|
||||
release,校验工作台或 generic patch manifest 与当前官方 release 的 source identity
|
||||
一致后,按确定的 operation sequence 写入 Binary、JSON、UTF-8 Text,以及已有支持
|
||||
范围内的 TextAsset、TypeTree string field 和 managed-reference string field
|
||||
patch。校验通过后才原子切换 `localized/current`,并在 release manifest 中记录
|
||||
源/目标 BLAKE3、字节数、patch kind、TextUnit、provider、review、发布时重新计算的
|
||||
@@ -359,6 +360,21 @@ bat i18n publish \
|
||||
--localized-release-id release-worker-1
|
||||
```
|
||||
|
||||
已有 generic manifest 时直接发布:
|
||||
|
||||
```bash
|
||||
bat i18n publish \
|
||||
--patch-manifest /tmp/bat-patch-manifest.json \
|
||||
--localized-release-id localized-v1
|
||||
```
|
||||
|
||||
manifest 必须声明当前官方 `source_version` 和目标 localized `target_version`,
|
||||
文件 hash/size、操作顺序和算法载荷;直接文件支持 Binary/JSON/Text,UnityFS 操作
|
||||
必须保留 serialized file、path ID、field path 或 TextAsset 定位,ZIP 内 UnityFS
|
||||
还必须保留 `archive_entry`。Rust 会在 staging 中逐操作验证 source precondition、
|
||||
最终 hash/size,并在 ZIP 外层重写后重新读取条目、重解析 UnityFS 和校验实际替换值。
|
||||
其他未支持的 UnityFS 结构仍明确拒绝。
|
||||
|
||||
发布失败会清理 staging,不切换 `current`。当前 release 的 rollback 目标由
|
||||
manifest 记录,执行后删除本次版本目录并恢复上一版本;没有上一版本时移除
|
||||
`current`:
|
||||
@@ -370,7 +386,7 @@ 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 以操作当前
|
||||
`translation_file`、`from_worker=true` 或 `patch_manifest`;rollback 可省略 release ID 以操作当前
|
||||
release。Go 只做鉴权、参数校验和转发,状态与产物仍由 Rust 持有。
|
||||
|
||||
## 边界
|
||||
|
||||
@@ -337,7 +337,7 @@ Glossary 只把 `approved` term 发送为 provider constraints。worker 会在 t
|
||||
| 方法 | 状态 | 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.publish` | 已实现 | `{ "translation_file": "...", "localized_release_id": "...", "force": false }`、`{ "from_worker": true, "localized_release_id": "...", "force": false }` 或 `{ "patch_manifest": "...", "localized_release_id": "...", "force": false }`;三者只能选一个 | 已校验并发布的汉化 release、generic/localized manifest 和完整性报告。 |
|
||||
| `localized.rollback` | 已实现 | `{ "localized_release_id": "..." }`,可省略 | 删除当前 release、恢复 manifest 记录的上一 release 和新状态。 |
|
||||
|
||||
`localized.status` 严格按 daemon / `config.toml` 或环境变量中的 `BAT_LOCALIZED_OUTPUT` 或
|
||||
@@ -353,12 +353,20 @@ Glossary 只把 `approved` term 发送为 provider constraints。worker 会在 t
|
||||
`current` symlink 指向汉化发布根下对应的 `versions/<id>`,并且该版本目录中的
|
||||
`localized-patch-manifest.json` 存在且 release ID 匹配。响应会返回
|
||||
`patch_manifest_path`、`patch_manifest_available`、
|
||||
`patch_manifest_matches_release`、`patch_file_count`、
|
||||
`patch_manifest_matches_release`、`patch_manifest_integrity_status`、
|
||||
`patch_manifest_source_version`、`patch_manifest_target_version`、
|
||||
`patch_file_count`、`patch_operation_count`、`patch_kind_counts`、
|
||||
`patch_text_asset_operation_count` 和 `rollback_previous_current_target`。
|
||||
每个 localized patch operation 的 manifest metadata 记录发布时重新计算的
|
||||
`glossary_qa`(包括 `qa_identity`)及对应 `glossary_override`,不会复用 workbench
|
||||
中已经过期的 QA 快照。
|
||||
|
||||
`localized.publish` 也可直接接收由 Rust `bat-patch` 构建的 generic manifest。
|
||||
Rust 会把其 source version 绑定当前官方 release,在独立 staging 中按 manifest
|
||||
顺序执行 Binary、JSON、UTF-8 Text 和当前支持的 UnityFS TextAsset/TypeTree 字段
|
||||
操作,并保留实际操作载荷、hash/size、定位信息和 TextUnit/TM/Glossary/review
|
||||
provenance。Go 只做鉴权、typed 参数校验和 RPC 转发。
|
||||
|
||||
### catalog
|
||||
|
||||
| 方法 | 状态 | params | data |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 当前实现缺口清单
|
||||
|
||||
- **更新时间**:2026-09-10
|
||||
- **更新时间**:2026-09-12
|
||||
- **文档角色**:只记录尚未完成、仍需验证或仍需设计的工作,不重复维护完整实现状态。
|
||||
- **当前事实**:以源码、测试、稳定契约和 `CURRENT_STATUS.md` 为准。
|
||||
- **Go 进度**:`GO_STATUS.md`
|
||||
@@ -32,21 +32,22 @@ directory 形态和未修改对象/字段,并明确拒绝未知压缩或无法
|
||||
回归和 `bat-infrastructure` 的解析缓存、ZIP 内 bundle 发布测试。新增格式覆盖必须
|
||||
同时补真实 fixture、回归测试和文档。
|
||||
|
||||
### G-006:通用 Patch 发布仍未完成
|
||||
### G-006:通用 Patch 的复杂格式和运维视图仍未完成
|
||||
|
||||
状态:**基础完成,发布流程部分完成**
|
||||
状态:**V1 已完成(当前支持类型),复杂格式和运维扩展继续推进**
|
||||
|
||||
`bat-patch` 已提供 Binary/JSON/Text Patch、manifest、BLAKE3/size 校验和
|
||||
rollback 元数据;文件级 `patch.apply` 与受支持的 UnityFS TextAsset、TypeTree
|
||||
string field、managed-reference string field 写入及 localized publish/rollback
|
||||
已可用。
|
||||
`bat-patch` 已提供 Binary/JSON/Text Patch、manifest builder、BLAKE3/size 校验和
|
||||
rollback 元数据;`LocalizedPatchService` 已使用同一有序 generic manifest 驱动
|
||||
Binary/JSON/Text 与当前支持的 UnityFS TextAsset、TypeTree string/semantic field
|
||||
写入及 localized publish/rollback。发布会在独立 staging 中校验 source/target identity、
|
||||
逐操作 precondition、ZIP 内层重解析和实际字段替换,并保留 TextUnit/TM/Glossary/review
|
||||
provenance;`localized.publish`、`i18n publish` 和 bat-api typed forwarding 均已接入。
|
||||
|
||||
仍需完成:
|
||||
|
||||
- 通用 manifest 驱动的跨类型 patch build/apply/publish/rollback。
|
||||
- 任意复杂 AssetBundle 重打包和完整翻译文件集合构建;当前 localized publish 已支持
|
||||
可验证 ZIP 内 bundle 的外层 ZIP 重写,但不扩大 UnityFS 结构支持范围。
|
||||
- 原版 release 与 localized release 双发布后的查询、分发和清理策略。
|
||||
- 原版 release 与 localized release 双发布后的更丰富查询、分发和清理策略。
|
||||
|
||||
所有发布产物必须先进入独立 staging,通过完整性校验后再原子发布;失败不得改变
|
||||
已发布的 `bat-resources/current` 或 `bat-localized/current`。
|
||||
@@ -105,14 +106,14 @@ format 等资源级过滤,`parse.text_units` / `parse.errors` 和翻译任务
|
||||
- 从同一 manifest fingerprint 追溯资源、解析缓存、翻译任务和发布产物。
|
||||
- 更多 schema 迁移、权限、并发和损坏恢复场景验证。
|
||||
|
||||
### G-011D:双 release 的完整查询与发布策略仍未完成
|
||||
### G-011D:双 release 的完整查询与运维策略仍未完成
|
||||
|
||||
状态:**受支持范围完成,通用范围部分完成**
|
||||
状态:**发布闭环完成,查询/清理扩展继续推进**
|
||||
|
||||
官方原版和 localized release 已分离,受支持 patch 可独立 staging、校验、发布和
|
||||
rollback,`localized.status` 能校验当前官方 release 与 patch manifest 的一致性。
|
||||
|
||||
仍需完成通用 patch 发布、复杂重打包、双 release 查询/分发视图和清理策略。
|
||||
仍需完成复杂重打包、双 release 查询/分发视图和清理策略。
|
||||
|
||||
### G-012:Translation Memory V1 已实现,扩展能力仍缺失
|
||||
|
||||
@@ -147,7 +148,7 @@ Rust `bat` 已提供独立项目级 `glossary.sqlite`:term/alias/recommended/a
|
||||
## 3. 后续推进顺序
|
||||
|
||||
1. 继续 G-005:更多真实 AssetBundle 样本、复杂字段解析、版本差异和任意结构重打包。
|
||||
2. 继续 G-006/G-011D:通用 manifest Patch 和双 release 查询/清理策略。
|
||||
2. 继续 G-006/G-011D:复杂 AssetBundle 兼容和双 release 查询/清理策略。
|
||||
3. 继续 G-011/G-012/G-013/G-014:资源查询、TM/Glossary 扩展和 Provider
|
||||
扩展体系。
|
||||
4. 在隔离环境执行 `make official-smoke`,补充真实网络长期运行报告。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Go 侧进度与边界(权威)
|
||||
|
||||
- **更新时间**:2026-09-04
|
||||
- **更新时间**:2026-09-12
|
||||
- **用途**:统一 Go module `bat-api` 的产品边界、既有约定和组件进度;其他文档与此冲突时以本文为准。
|
||||
- **关联缺口**:G-009(资源 bootstrap/分发);相关契约见 `docs/architecture/official-resource-backend.md` §7 和 `docs/guides/bat-api-local-live-smoke.md`
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
| 组件 | 路径 | 状态 | 说明 |
|
||||
|---|---|---|---|
|
||||
| Module | `go.mod` → `bat-api` | 已用 | 服务层模块名 |
|
||||
| RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helper;typed helper 覆盖 daemon 已实现控制/查询、`resource.state/sync/verify/repair/manifest/list`、`catalog.*`、`parse.*`、`localized.status/publish/rollback`、`task.*`、`translation.tasks`、`translation.handoff`、`translation.task.update`、`translation.worker.run`、`translation.proofread`、`translation.memory.summary/query/confirm`、`translation.glossary.summary/query/diagnose/add/update/approve/deprecate/delete` 和文件级 UnityFS patch 调用;`resource.index`、`patch.apply` 仍通过通用 `Call` 走同一 contract;fake transport 单测和 `internal/api/testdata/contract/` mirror test 固化 Rust 输出字段 |
|
||||
| RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helper;typed helper 覆盖 daemon 已实现控制/查询、`resource.state/sync/verify/repair/manifest/list`、`catalog.*`、`parse.*`、`localized.status/publish/rollback`、`task.*`、`translation.tasks`、`translation.handoff`、`translation.task.update`、`translation.worker.run`、`translation.proofread`、`translation.memory.summary/query/confirm`、`translation.glossary.summary/query/diagnose/add/update/approve/deprecate/delete` 和文件级 UnityFS patch 调用;`localized.publish` 的 typed params 支持 `translation_file`、`from_worker` 或 `patch_manifest` 三选一;`resource.index`、`patch.apply` 仍通过通用 `Call` 走同一 contract;fake transport 单测和 `internal/api/testdata/contract/` mirror test 固化 Rust 输出字段 |
|
||||
| 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理控制白名单 + translation/TM/Glossary admin forwarding + 内嵌 dashboard + `.env` |
|
||||
| 试验 CLI | `cmd/bat` | **试验** | doctor 固定 ok;manifest/sync 走 FFI |
|
||||
| FFI | `internal/ffi` | **可选** | 需 `build-ffi` |
|
||||
|
||||
@@ -395,6 +395,7 @@ struct CliOptions {
|
||||
output_explicit: bool,
|
||||
resource_root: Option<PathBuf>,
|
||||
translation_file: Option<PathBuf>,
|
||||
patch_manifest: Option<PathBuf>,
|
||||
translation_id: Option<String>,
|
||||
translation_text: Option<String>,
|
||||
translation_text_file: Option<PathBuf>,
|
||||
@@ -521,6 +522,7 @@ impl Default for CliOptions {
|
||||
output_explicit: false,
|
||||
resource_root: None,
|
||||
translation_file: None,
|
||||
patch_manifest: None,
|
||||
translation_id: None,
|
||||
translation_text: None,
|
||||
translation_text_file: None,
|
||||
@@ -1130,6 +1132,8 @@ struct LocalizedPublishRpcParams {
|
||||
alias = "from_worker_results"
|
||||
)]
|
||||
from_worker: bool,
|
||||
#[serde(default, alias = "patch_manifest_path")]
|
||||
patch_manifest: Option<PathBuf>,
|
||||
#[serde(default, alias = "release_id")]
|
||||
localized_release_id: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -1226,8 +1230,8 @@ fn is_pending_rpc_method(method: &str) -> bool {
|
||||
// 等语义方法创建,通用创建接口暂不开放。
|
||||
// daemon.clean-stable:CLI 侧按进程生命周期处理;
|
||||
// live RPC 内不做在线清理。
|
||||
// patch.* / unityfs.*:文件级写入入口已开放;发布级 patch 构建、复杂
|
||||
// UnityFS 语义编辑和 inspect 等子命令仍未开放。
|
||||
// patch.* / unityfs.*:文件级写入入口已开放;发布级 patch 构建和 inspect
|
||||
// 等子命令仍未开放,发布级 generic manifest 通过 localized.publish 进入。
|
||||
matches!(method, "task.create" | RPC_METHOD_CLEAN_STABLE)
|
||||
|| (method.starts_with("patch.") && method != RPC_METHOD_PATCH_APPLY)
|
||||
|| (method.starts_with("unityfs.")
|
||||
@@ -3277,8 +3281,13 @@ fn build_localized_status_report(
|
||||
let mut patch_manifest_path = None;
|
||||
let mut patch_manifest_available = false;
|
||||
let mut patch_manifest_matches_release = false;
|
||||
let mut patch_manifest_integrity_status = None;
|
||||
let mut patch_manifest_source_version = None;
|
||||
let mut patch_manifest_target_version = None;
|
||||
let mut patch_file_count = None;
|
||||
let mut patch_text_asset_operation_count = None;
|
||||
let mut patch_operation_count = None;
|
||||
let mut patch_kind_counts = serde_json::Map::new();
|
||||
let mut rollback_previous_current_target = None;
|
||||
let mut flow_status_code = if official_version_id.is_some() {
|
||||
ReleaseFlowStatusCode::LocalizedPending
|
||||
@@ -3326,6 +3335,47 @@ fn build_localized_status_report(
|
||||
patch_file_count = Some(manifest.file_count);
|
||||
patch_text_asset_operation_count = Some(manifest.text_asset_operation_count);
|
||||
rollback_previous_current_target = manifest.rollback.previous_current_target;
|
||||
if let Some(generic_manifest) = manifest.patch_manifest.as_ref() {
|
||||
patch_manifest_source_version =
|
||||
Some(generic_manifest.source_version.clone());
|
||||
patch_manifest_target_version =
|
||||
Some(generic_manifest.target_version.clone());
|
||||
let mut operation_count = 0usize;
|
||||
for file in &generic_manifest.files {
|
||||
for operation in &file.operations {
|
||||
operation_count += 1;
|
||||
let key = match operation.patch_kind() {
|
||||
bat_patch::PatchKind::Binary => "binary",
|
||||
bat_patch::PatchKind::Json => "json",
|
||||
bat_patch::PatchKind::Text => "text",
|
||||
bat_patch::PatchKind::UnityFsTextAsset => "unityfs_text_asset",
|
||||
bat_patch::PatchKind::UnityFsStringField => {
|
||||
"unityfs_string_field"
|
||||
}
|
||||
bat_patch::PatchKind::UnityFsField => "unityfs_field",
|
||||
bat_patch::PatchKind::Mixed => "mixed",
|
||||
}
|
||||
.to_string();
|
||||
let count = patch_kind_counts
|
||||
.entry(key)
|
||||
.or_insert_with(|| serde_json::json!(0));
|
||||
*count = serde_json::json!(count.as_u64().unwrap_or_default() + 1);
|
||||
}
|
||||
}
|
||||
patch_operation_count = Some(operation_count);
|
||||
patch_manifest_integrity_status = Some(
|
||||
if bat_patch::validate_patch_manifest(generic_manifest).is_ok() {
|
||||
"valid"
|
||||
} else {
|
||||
"invalid"
|
||||
},
|
||||
);
|
||||
patch_manifest_matches_release &= generic_manifest.source_version
|
||||
== manifest.official_release_id
|
||||
&& generic_manifest.target_version == manifest.localized_release_id;
|
||||
} else {
|
||||
patch_manifest_integrity_status = Some("legacy");
|
||||
}
|
||||
}
|
||||
if candidate.is_dir()
|
||||
&& current_points_to_published_version
|
||||
@@ -3363,8 +3413,13 @@ fn build_localized_status_report(
|
||||
"patch_manifest_path": patch_manifest_path,
|
||||
"patch_manifest_available": patch_manifest_available,
|
||||
"patch_manifest_matches_release": patch_manifest_matches_release,
|
||||
"patch_manifest_integrity_status": patch_manifest_integrity_status,
|
||||
"patch_manifest_source_version": patch_manifest_source_version,
|
||||
"patch_manifest_target_version": patch_manifest_target_version,
|
||||
"patch_file_count": patch_file_count,
|
||||
"patch_text_asset_operation_count": patch_text_asset_operation_count,
|
||||
"patch_operation_count": patch_operation_count,
|
||||
"patch_kind_counts": patch_kind_counts,
|
||||
"rollback_previous_current_target": rollback_previous_current_target,
|
||||
"matches_current_official_release": matches_current_official_release,
|
||||
"current_points_to_published_version": current_points_to_published_version,
|
||||
@@ -3487,11 +3542,17 @@ fn localized_publish_rpc_report(
|
||||
let translation_file = params
|
||||
.translation_file
|
||||
.filter(|path| !path.as_os_str().is_empty());
|
||||
if translation_file.is_some() == params.from_worker {
|
||||
let patch_manifest = params
|
||||
.patch_manifest
|
||||
.filter(|path| !path.as_os_str().is_empty());
|
||||
let input_count = usize::from(translation_file.is_some())
|
||||
+ usize::from(params.from_worker)
|
||||
+ usize::from(patch_manifest.is_some());
|
||||
if input_count != 1 {
|
||||
return Err(ApiError::new(
|
||||
ErrorCode::RPC_INVALID_PARAMS,
|
||||
RPC_METHOD_LOCALIZED_PUBLISH,
|
||||
"localized.publish 必须且只能指定 translation_file 或 from_worker",
|
||||
"localized.publish 必须且只能指定 translation_file、from_worker 或 patch_manifest",
|
||||
));
|
||||
}
|
||||
let mut config = base_config.clone();
|
||||
@@ -3501,6 +3562,7 @@ fn localized_publish_rpc_report(
|
||||
config,
|
||||
state_dir: state_dir.to_path_buf(),
|
||||
translation_file,
|
||||
patch_manifest,
|
||||
translation_from_worker: params.from_worker,
|
||||
localized_release_id: normalize_optional_rpc_string(params.localized_release_id),
|
||||
..CliOptions::default()
|
||||
@@ -6844,6 +6906,9 @@ fn parse_args_with_env(
|
||||
options.translation_file =
|
||||
Some(PathBuf::from(next_option_value(&mut args, &flag)?));
|
||||
}
|
||||
"--patch-manifest" | "--patch-manifest-file" => {
|
||||
options.patch_manifest = Some(PathBuf::from(next_option_value(&mut args, &flag)?));
|
||||
}
|
||||
"--translation-id" => {
|
||||
options.translation_id = Some(next_option_value(&mut args, &flag)?);
|
||||
}
|
||||
@@ -7554,6 +7619,9 @@ fn parse_args_with_env(
|
||||
if options.command != CliCommand::PublishLocalized && options.translation_from_worker {
|
||||
return Err(anyhow::anyhow!("--from-worker 只适用于 i18n publish"));
|
||||
}
|
||||
if options.command != CliCommand::PublishLocalized && options.patch_manifest.is_some() {
|
||||
return Err(anyhow::anyhow!("--patch-manifest 只适用于 i18n publish"));
|
||||
}
|
||||
|
||||
match options.command {
|
||||
CliCommand::Status | CliCommand::Stop | CliCommand::Logs => {
|
||||
@@ -7805,10 +7873,13 @@ fn parse_args_with_env(
|
||||
));
|
||||
}
|
||||
if matches!(options.command, CliCommand::PublishLocalized)
|
||||
&& options.translation_file.is_some() == options.translation_from_worker
|
||||
&& (usize::from(options.translation_file.is_some())
|
||||
+ usize::from(options.translation_from_worker)
|
||||
+ usize::from(options.patch_manifest.is_some())
|
||||
!= 1)
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"i18n publish 必须且只能指定 --translation-file 或 --from-worker"
|
||||
"i18n publish 必须且只能指定 --translation-file、--from-worker 或 --patch-manifest"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -659,6 +659,19 @@ fn grouped_workflow_commands_use_short_top_level_aliases() {
|
||||
assert_eq!(options.command, CliCommand::PublishLocalized);
|
||||
assert!(options.translation_from_worker);
|
||||
assert_eq!(options.localized_release_id.as_deref(), Some("localized-1"));
|
||||
let options = parse(&[
|
||||
"bat",
|
||||
"i18n",
|
||||
"publish",
|
||||
"--patch-manifest",
|
||||
"/tmp/patch-manifest.json",
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(options.command, CliCommand::PublishLocalized);
|
||||
assert_eq!(
|
||||
options.patch_manifest,
|
||||
Some(PathBuf::from("/tmp/patch-manifest.json"))
|
||||
);
|
||||
assert!(parse(&[
|
||||
"bat",
|
||||
"i18n",
|
||||
@@ -5107,6 +5120,7 @@ fn dispatch_localized_status_verifies_current_release_pointer() {
|
||||
file_count: 0,
|
||||
text_asset_operation_count: 0,
|
||||
files: Vec::new(),
|
||||
patch_manifest: None,
|
||||
rollback: bat_infrastructure::LocalizedPatchRollbackInfo {
|
||||
previous_current_target: None,
|
||||
remove_version_path: localized_version.clone(),
|
||||
@@ -5243,6 +5257,7 @@ fn dispatch_localized_rollback_restores_manifest_previous_release() {
|
||||
file_count: 0,
|
||||
text_asset_operation_count: 0,
|
||||
files: Vec::new(),
|
||||
patch_manifest: None,
|
||||
rollback: bat_infrastructure::LocalizedPatchRollbackInfo {
|
||||
previous_current_target: None,
|
||||
remove_version_path: previous.clone(),
|
||||
@@ -5261,6 +5276,7 @@ fn dispatch_localized_rollback_restores_manifest_previous_release() {
|
||||
file_count: 0,
|
||||
text_asset_operation_count: 0,
|
||||
files: Vec::new(),
|
||||
patch_manifest: None,
|
||||
rollback: bat_infrastructure::LocalizedPatchRollbackInfo {
|
||||
previous_current_target: Some(PathBuf::from("versions/release-1")),
|
||||
remove_version_path: current.clone(),
|
||||
@@ -5362,6 +5378,7 @@ fn localized_status_keeps_published_release_during_manual_proofreading() {
|
||||
file_count: 0,
|
||||
text_asset_operation_count: 0,
|
||||
files: Vec::new(),
|
||||
patch_manifest: None,
|
||||
rollback: bat_infrastructure::LocalizedPatchRollbackInfo {
|
||||
previous_current_target: None,
|
||||
remove_version_path: localized_version.clone(),
|
||||
|
||||
@@ -172,6 +172,7 @@ fn reject_patch_apply_options(options: &CliOptions, command: &str) -> anyhow::Re
|
||||
if options.patch_kind.is_some()
|
||||
|| options.patch_source_path.is_some()
|
||||
|| options.patch_patch_path.is_some()
|
||||
|| options.patch_manifest.is_some()
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"{command} 不接受 --patch-kind、--source-file 或 --patch-file"
|
||||
@@ -191,6 +192,7 @@ fn reject_unityfs_write_options(options: &CliOptions, command: &str) -> anyhow::
|
||||
|| options.unityfs_expected_value.is_some()
|
||||
|| options.unityfs_replacement_value.is_some()
|
||||
|| options.unityfs_expected_semantic_value.is_some()
|
||||
|| options.patch_manifest.is_some()
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"{command} 不接受 UnityFS 写入参数;请改用 unityfs-patch-* 命令"
|
||||
|
||||
@@ -339,6 +339,49 @@ pub(super) fn publish_localized_report(
|
||||
options: &CliOptions,
|
||||
) -> anyhow::Result<LocalizedPatchReport> {
|
||||
let (resource_root, official_release_id) = current_official_release(options)?;
|
||||
if let Some(manifest_path) = options.patch_manifest.as_ref() {
|
||||
let manifest_bytes = read_file_no_symlink(manifest_path, "generic patch manifest")
|
||||
.map_err(anyhow::Error::msg)?
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("generic patch manifest 不存在:{}", manifest_path.display())
|
||||
})?;
|
||||
let manifest: bat_patch::PatchManifest = serde_json::from_slice(&manifest_bytes)
|
||||
.map_err(|error| anyhow::anyhow!("generic patch manifest 无效:{error}"))?;
|
||||
bat_patch::validate_patch_manifest(&manifest)
|
||||
.map_err(|error| anyhow::anyhow!("generic patch manifest 无效:{error}"))?;
|
||||
if manifest.source_version != official_release_id {
|
||||
return Err(anyhow::anyhow!(
|
||||
"generic patch source version={} 与当前官方 release={} 不一致",
|
||||
manifest.source_version,
|
||||
official_release_id
|
||||
));
|
||||
}
|
||||
let localized_release_id = options
|
||||
.localized_release_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| manifest.target_version.clone());
|
||||
if localized_release_id != manifest.target_version {
|
||||
return Err(anyhow::anyhow!(
|
||||
"generic patch target version={} 必须与 localized release id={} 一致",
|
||||
manifest.target_version,
|
||||
localized_release_id
|
||||
));
|
||||
}
|
||||
let config = LocalizedPatchConfig::new(
|
||||
resource_root,
|
||||
options.config.localized_output_root.clone(),
|
||||
official_release_id,
|
||||
Vec::new(),
|
||||
)
|
||||
.with_archive_commands(
|
||||
options.config.unzip_command.clone(),
|
||||
options.config.zip_command.clone(),
|
||||
)
|
||||
.with_manifest(manifest)
|
||||
.with_localized_release_id(localized_release_id)
|
||||
.with_force(options.config.force);
|
||||
return LocalizedPatchService::new().publish(&config);
|
||||
}
|
||||
let workbench = if options.translation_from_worker {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+13
-2
@@ -1306,8 +1306,19 @@ func validateTranslationMemoryConfirmParams(params backendrpc.TranslationMemoryC
|
||||
|
||||
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")
|
||||
hasManifest := strings.TrimSpace(params.PatchManifest) != ""
|
||||
inputs := 0
|
||||
if hasFile {
|
||||
inputs++
|
||||
}
|
||||
if params.FromWorker {
|
||||
inputs++
|
||||
}
|
||||
if hasManifest {
|
||||
inputs++
|
||||
}
|
||||
if inputs != 1 {
|
||||
return errors.New("localized publish requires exactly one of translation_file, from_worker, or patch_manifest")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1954,6 +1954,7 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
|
||||
{name: "translation glossary deprecate", action: "translation-glossary-deprecate", body: `{"term_id":"term-sensei","reviewer":"reviewer","reason":"retired"}`, rpcMethod: "translation.glossary.deprecate", call: "translation.glossary.deprecate"},
|
||||
{name: "translation glossary delete", action: "translation-glossary-delete", body: `{"term_id":"term-sensei","reviewer":"reviewer","reason":"duplicate"}`, rpcMethod: "translation.glossary.delete", call: "translation.glossary.delete"},
|
||||
{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 patch manifest publish", action: "localized-publish", body: `{"patch_manifest":"/tmp/patch-manifest.json","localized_release_id":"localized-1"}`, 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 {
|
||||
@@ -2041,6 +2042,14 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid localized publish status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
request = httptest.NewRequest(http.MethodPost, "/admin/control/localized-publish", strings.NewReader(`{"from_worker":true,"patch_manifest":"/tmp/patch-manifest.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 patch manifest status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminLocalizedStatusRequiresAuthAndForwards(t *testing.T) {
|
||||
|
||||
@@ -730,6 +730,8 @@ paths:
|
||||
type: string
|
||||
from_worker:
|
||||
type: boolean
|
||||
patch_manifest:
|
||||
type: string
|
||||
localized_release_id:
|
||||
type: string
|
||||
responses:
|
||||
|
||||
@@ -637,10 +637,12 @@ type GlossaryDiagnoseReport struct {
|
||||
}
|
||||
|
||||
// LocalizedPublishParams selects the source of one localized release
|
||||
// publication. TranslationFile and FromWorker are mutually exclusive.
|
||||
// publication. Exactly one of TranslationFile, FromWorker, or PatchManifest
|
||||
// must be set.
|
||||
type LocalizedPublishParams struct {
|
||||
TranslationFile string `json:"translation_file,omitempty"`
|
||||
FromWorker bool `json:"from_worker,omitempty"`
|
||||
PatchManifest string `json:"patch_manifest,omitempty"`
|
||||
LocalizedReleaseID string `json:"localized_release_id,omitempty"`
|
||||
Force bool `json:"force,omitempty"`
|
||||
}
|
||||
|
||||
@@ -797,6 +797,38 @@ func TestLocalizedPublishSendsWorkerSourceAndReleaseOptions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalizedPublishSendsPatchManifestSource(t *testing.T) {
|
||||
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
||||
var params LocalizedPublishParams
|
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
t.Fatalf("decode params: %v", err)
|
||||
}
|
||||
if params.PatchManifest != "/tmp/patch-manifest.json" ||
|
||||
params.TranslationFile != "" || params.FromWorker {
|
||||
t.Fatalf("params = %#v", params)
|
||||
}
|
||||
return testResponse{
|
||||
Result: testEnvelope{
|
||||
OK: true,
|
||||
Status: "ok",
|
||||
RequestID: "req-test-localized-patch-manifest",
|
||||
Data: map[string]any{"status": "published"},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
raw, err := client.LocalizedPublish(context.Background(), LocalizedPublishParams{
|
||||
PatchManifest: "/tmp/patch-manifest.json",
|
||||
LocalizedReleaseID: "localized-v1",
|
||||
})
|
||||
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" {
|
||||
|
||||
Reference in New Issue
Block a user