mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 11:34:59 +08:00
Compare commits
58
Commits
03021ad649
...
experiment
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff1adb91ee
|
||
|
|
e486f1aaaa
|
||
|
|
7f7d757f15
|
||
|
|
99355effe4
|
||
|
|
13b0bd5b45
|
||
|
|
c17904ee1c
|
||
|
|
32fc64fa83
|
||
|
|
37d49c9793
|
||
|
|
5bae90cb14
|
||
|
|
786b739f99
|
||
|
|
f2c20367a6
|
||
|
|
30d1cd77e8
|
||
|
|
8d57a63697
|
||
|
|
8a77502272
|
||
|
|
69b6e36bf0
|
||
|
|
68c6c91b1e
|
||
|
|
0275a890bc
|
||
|
|
94483ff14d
|
||
|
|
8fc93b8f39
|
||
|
|
e373e3fd32
|
||
|
|
7d6389806b
|
||
|
|
93f4bc69b3
|
||
|
|
d21c01a697
|
||
|
|
34e2f0d907
|
||
|
|
bd1e1a06f2
|
||
|
|
fdd4075e7e
|
||
|
|
4ed81f0030
|
||
|
|
ab21344773
|
||
|
|
f441f1810e
|
||
|
|
7f465523e1
|
||
|
|
90083302a2
|
||
|
|
9d4f8d903c
|
||
|
|
72c1a879a3
|
||
|
|
550ee7fd9a
|
||
|
|
974a6e18c3
|
||
|
|
1933d6acb0
|
||
|
|
0784d5b532
|
||
|
|
3b103be8a9
|
||
|
|
a2e2ae8ac5
|
||
|
|
7c863d10d1
|
||
|
|
9a5b3ba39b
|
||
|
|
2b053e247d
|
||
|
|
ba28e067c9
|
||
|
|
6442c7661d
|
||
|
|
8b64cc94f3
|
||
|
|
d533c88108
|
||
|
|
12c5d365ab
|
||
|
|
a05d3ee6af
|
||
|
|
8d930bf4d7
|
||
|
|
80e6718e8a
|
||
|
|
df361cff28
|
||
|
|
1e466d3374
|
||
|
|
6af7706190
|
||
|
|
20ddd67947
|
||
|
|
4cc143f66d
|
||
|
|
99e6b3a23a
|
||
|
|
16e73327b4
|
||
|
|
8ec0e12795
|
@@ -1,163 +0,0 @@
|
||||
# Gitea Actions workflow for the Rust workspace.
|
||||
# Self-hosted runner friendly.
|
||||
# Does not use external GitHub Actions.
|
||||
|
||||
name: bat-rust
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
rust:
|
||||
name: Build and test Rust
|
||||
runs-on: linux
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
BAT_SKIP_ENV_FILE: "1"
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
: "${GITHUB_SERVER_URL:?GITHUB_SERVER_URL is required}"
|
||||
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
|
||||
: "${GITHUB_SHA:?GITHUB_SHA is required}"
|
||||
|
||||
repo_url="${GITHUB_SERVER_URL%/}/${GITHUB_REPOSITORY}.git"
|
||||
|
||||
if [ -d .git ]; then
|
||||
git remote set-url origin "${repo_url}"
|
||||
else
|
||||
git init .
|
||||
git remote add origin "${repo_url}"
|
||||
fi
|
||||
|
||||
ref="${GITHUB_REF:-${GITHUB_SHA}}"
|
||||
|
||||
git fetch \
|
||||
--no-tags \
|
||||
--depth=1 \
|
||||
origin \
|
||||
"${ref}" \
|
||||
|| git fetch \
|
||||
--no-tags \
|
||||
--depth=1 \
|
||||
origin \
|
||||
"${GITHUB_SHA}"
|
||||
|
||||
git checkout --force --detach FETCH_HEAD
|
||||
|
||||
git submodule update --init --recursive
|
||||
|
||||
|
||||
- name: Show tool versions
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
source /var/lib/act_runner/env.sh
|
||||
|
||||
command -v git
|
||||
command -v rustc
|
||||
command -v cargo
|
||||
|
||||
rustc --version
|
||||
cargo --version
|
||||
rustfmt --version
|
||||
cargo clippy --version
|
||||
|
||||
|
||||
- name: Check formatting
|
||||
shell: bash
|
||||
run: |
|
||||
source /var/lib/act_runner/env.sh
|
||||
cargo fmt --all -- --check
|
||||
|
||||
|
||||
- name: Check workspace
|
||||
shell: bash
|
||||
run: |
|
||||
source /var/lib/act_runner/env.sh
|
||||
cargo check --workspace --locked
|
||||
|
||||
|
||||
- name: Build workspace
|
||||
shell: bash
|
||||
run: |
|
||||
source /var/lib/act_runner/env.sh
|
||||
|
||||
cargo build \
|
||||
--workspace \
|
||||
--release \
|
||||
--locked
|
||||
|
||||
|
||||
- name: Run clippy
|
||||
shell: bash
|
||||
run: |
|
||||
source /var/lib/act_runner/env.sh
|
||||
|
||||
cargo clippy \
|
||||
--workspace \
|
||||
--all-targets \
|
||||
--locked \
|
||||
-- -D warnings
|
||||
|
||||
|
||||
- name: Run tests
|
||||
shell: bash
|
||||
run: |
|
||||
source /var/lib/act_runner/env.sh
|
||||
|
||||
cargo test \
|
||||
--workspace \
|
||||
--locked
|
||||
|
||||
|
||||
- name: Package binary
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
mkdir -p artifact
|
||||
|
||||
cp target/release/bat artifact/
|
||||
|
||||
tar \
|
||||
-czf \
|
||||
bat-linux-x86_64.tar.gz \
|
||||
-C artifact \
|
||||
bat
|
||||
|
||||
sha256sum \
|
||||
bat-linux-x86_64.tar.gz \
|
||||
> bat-linux-x86_64.sha256
|
||||
|
||||
|
||||
- name: Prepare artifact
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
mkdir upload
|
||||
|
||||
cp bat-linux-x86_64.tar.gz upload/
|
||||
cp bat-linux-x86_64.sha256 upload/
|
||||
|
||||
cd upload
|
||||
|
||||
zip -q \
|
||||
../bat-linux-x86_64.zip \
|
||||
*
|
||||
|
||||
cd ..
|
||||
|
||||
ls -lh bat-linux-x86_64.zip
|
||||
@@ -53,6 +53,7 @@ pg_log/
|
||||
/docs/reports/fuck-u-code-*.md
|
||||
/docs/reports/*-current.generated.md
|
||||
/docs/reports/**/SMOKE_REPORT.md
|
||||
CHECK.md
|
||||
|
||||
# Backups
|
||||
/deployments/backups/
|
||||
|
||||
@@ -1,79 +1,274 @@
|
||||
# Agent 开发规则
|
||||
# AGENTS.md
|
||||
|
||||
本文件是 BlueArchive Toolkit 中 AI agent、自动化开发助手和长期维护脚本的权威入口。它替代旧 `CLAUDE.md` 中真正长期有效的工程规则。
|
||||
本文件用于约束在 BlueArchiveToolkit 中工作的 AI Agent。
|
||||
|
||||
## 语言和表达
|
||||
具体开发进度看 `CURRENT_STATUS.md`,开发计划看 `PROJECT_PLAN.md`,当前能力缺口看 `docs/reports/CURRENT_GAPS.md`,具体工程任务、优先级和依赖看根目录 `TODO.md`。这里不记录具体任务和阶段待办。
|
||||
|
||||
1. 默认使用简体中文交流、写文档、写提交说明、写开发日志和写 API 说明。
|
||||
2. 代码中的包名、类型名、函数名、变量名、数据库字段、协议字段和命令参数保持英文命名规范。
|
||||
3. 代码注释只在能降低理解成本时添加;不要写复述代码行为的空泛注释。
|
||||
4. 如果任务、上游规范或第三方 API 明确要求英文,可以在对应位置使用英文。
|
||||
## 基本要求
|
||||
|
||||
## 项目定位
|
||||
默认使用简体中文交流、写文档和提交说明。代码标识符、协议字段、数据库字段、命令参数等保持英文。
|
||||
|
||||
BlueArchive Toolkit 是长期维护的开源工具链,不是 demo、一次性脚本或临时试验项目。
|
||||
BlueArchiveToolkit 是长期维护项目。不要为了尽快完成当前任务引入明显的临时实现,也不要把未来计划描述成已经存在的能力。
|
||||
|
||||
长期目标包括但不限于:
|
||||
修改代码前先读相关实现。涉及跨模块改动时,至少确认当前状态、相关架构文档、测试和已有接口,不要只看一个文件就重新设计整个模块。
|
||||
|
||||
1. 官方资源同步、版本管理、增量同步、断点续传、重试、限速、缓存和校验。
|
||||
2. Content Addressable Storage(CAS)、引用计数、垃圾回收、多版本共享和完整性校验。
|
||||
3. UnityFS / AssetBundle / Addressables / Manifest 解析框架,并保持解析器与业务逻辑解耦。
|
||||
4. 文本提取、翻译记忆、术语库、AI Provider 抽象、Patch、CLI、Web、API、SDK 和插件系统。
|
||||
5. 支持未来扩展到其他区域、语言或 Unity 游戏;不要把架构写死到单一版本。
|
||||
如果发现用户提出的方案、现有代码或文档本身有问题,直接指出。不要为了迎合要求保留明显不合理的设计。
|
||||
|
||||
## 工程边界
|
||||
## 工程修改原则
|
||||
|
||||
1. 默认工作于用户本地环境。不要把生产环境当作开发环境。
|
||||
2. 真实资源下载、smoke run 和手动验证必须写入隔离目录,例如 `/tmp` 或显式指定的测试目录。
|
||||
3. 不要默认读取、修改或污染现有客户端目录、生产资源目录或 `/home/wanye/D/BlueArchive` 这类本地资源目录。
|
||||
4. 不要要求安装官方启动器作为生产运行前提。可以分析启动器资源或官方公开数据,但生产链路必须能在 Linux 环境中独立运行。
|
||||
5. 涉及官方资源时,优先使用官方 `.hash`、catalog、manifest 和可复现 fixture 做校验依据。
|
||||
BlueArchiveToolkit 不以“最小修复”为工程目标。不要为了让单个 testcase 通过、暂时消除表面症状或缩小 diff,而留下已经能够确认的同根因问题。
|
||||
|
||||
## 架构原则
|
||||
处理问题时优先保证长期可维护性、可用性、安全性、明确契约、恢复能力和回归覆盖。进入一个工程边界后,应根据实际相关性检查正常路径、异常路径、并发、重试、恢复、兼容、持久化和资源限制,并把属于同一 root cause 或同一 contract 的问题完整收口。
|
||||
|
||||
1. 仓库采用 monorepo;模块必须边界清晰、高内聚、低耦合。
|
||||
2. 公共接口应稳定、可测试、可维护,并为未来扩展保留合理空间。
|
||||
3. Rust 侧优先承担二进制解析、AssetBundle、Patch、CAS 和官方资源后端能力;Go 侧优先承担 CLI、运维入口和面向用户的命令编排。边界调整必须先说明理由。
|
||||
4. Rust/Go 默认集成路径优先进程边界(当前为 `bat --json`)或未来稳定 SDK;`bat-ffi` 仅作为可选无状态 C ABI 兼容层,不能扩展成 daemon、下载器、CAS handle 或主控制面。
|
||||
5. SDK 不得与 CLI 耦合;解析器不得与业务流程耦合;Provider、存储后端、Patch 算法和解析器应保留插件化扩展点。
|
||||
6. 不引入 God Object、God Class、超长函数、超长文件、硬编码、魔法数字、重复代码、临时实现或只为当前测试通过的伪实现。
|
||||
7. 不使用 `TODO`、`FIXME` 掩盖未完成设计。确实无法完成时,应在当前缺口文档中说明边界、风险和后续工作。
|
||||
这不意味着无边界重构。不要为了架构形式、代码行数或“以后也许会用”扩大修改范围;与当前 contract 无关的问题应记录到 `TODO.md`,留给后续独立处理。
|
||||
|
||||
## 当前冻结
|
||||
跨模块问题必须沿真实状态所有权和调用链检查。例如 Rust 状态经 RPC 暴露给 Go,再由 HTTP 或 Web 消费时,不能只修改其中一层而让其他层继续保持矛盾语义。
|
||||
|
||||
1. UnityFS / AssetBundle / Addressables / TypeTree 解析模块当前处于维护冻结,细则见 `docs/reports/PARSER_FREEZE.md`。
|
||||
2. 冻结期不继续新增解析类型、字段族、catalog 结构覆盖、写入型解析 RPC/CLI 或合成 fixture 驱动的能力扩展。
|
||||
3. 冻结期允许且优先处理编译、测试、clippy、真实运行回归、错误诊断、状态一致性、缓存复用和文档一致性问题。
|
||||
4. 如果用户明确要求继续解析扩展,必须先指出冻结状态、说明风险,并获得明确解冻或例外授权。
|
||||
持久化和状态机修改应考虑 schema/version、transaction、crash consistency、retry、recovery 与兼容读取;解析器、压缩包和其他外部输入应考虑 size/count/depth 等资源边界以及 malformed input 的确定性失败。
|
||||
|
||||
## 开发流程
|
||||
## 以什么为准
|
||||
|
||||
1. 动手前先读相关文档和代码,确认当前真实状态。
|
||||
2. 对跨模块、架构、数据格式或用户工作流有影响的改动,先给出设计判断或简短计划。
|
||||
3. 实现后必须同步验证。验证范围要覆盖改动实际影响面,而不是只跑最窄的命令。
|
||||
4. 涉及用户可见行为、运行方式、架构边界或缺口状态时,必须同步更新文档。
|
||||
5. 保持改动范围和任务目标一致;不要顺手做无关重构或格式化 churn。
|
||||
6. 如果需求、技术路线或设计存在明显风险,应直接指出并给出可执行替代方案。
|
||||
7. 不确定的事实必须查证或询问;不要凭空调用不存在的接口、命令、路径或线上资源。
|
||||
仓库里有不少历史文档,不能混着看。
|
||||
|
||||
## 质量要求
|
||||
判断**当前实现**时,优先参考:
|
||||
|
||||
1. 所有错误必须显式处理,并给出可诊断信息。
|
||||
2. 日志应结构化或至少足够定位阶段、路径、版本、URL、重试、校验和失败原因。
|
||||
3. 下载、写文件、状态切换和发布操作必须考虑原子性、断点续传、并发锁、失败恢复和清理策略。
|
||||
4. 本地状态文件和索引必须有版本字段或兼容策略。
|
||||
5. 新增 fixture、golden 或回归样本时,应说明它覆盖的真实风险。
|
||||
6. 默认验证命令见 `docs/guides/development.md`;稳定工程基线见 `docs/guides/baseline.md`。
|
||||
* 当前源码和测试;
|
||||
* `CURRENT_STATUS.md`;
|
||||
* 对应模块的专项状态文档,例如 `docs/reports/GO_STATUS.md`;
|
||||
* 已冻结的 RPC、release、schema 等契约。
|
||||
|
||||
## 文档职责
|
||||
`PROJECT_PLAN.md` 和 `CURRENT_GAPS.md` 描述的是计划和缺口,不代表功能已经实现。
|
||||
|
||||
长期规则的权威位置如下:
|
||||
`docs/archive/` 和 `docs/reports/historical/` 只用于追溯历史,不应作为当前实现依据。
|
||||
|
||||
1. `AGENTS.md`:agent 行为、工程边界、架构原则和质量要求。
|
||||
2. `CONTRIBUTING.md`:贡献者工作流、提交规范、验证和 PR 要求。
|
||||
3. `docs/guides/development.md`:环境准备、开发命令、测试、调试和真实资源验证方式。
|
||||
4. `PROJECT_PLAN.md`:产品目标、阶段路线图和长期能力规划。
|
||||
5. `CURRENT_STATUS.md`:当前实现状态。
|
||||
6. `docs/reports/CURRENT_GAPS.md`:当前缺口、优先级和关闭顺序。
|
||||
如果文档之间冲突,先核对源码和测试,再判断哪份文档已经过时。修代码时顺手修正相关权威文档,不要让冲突继续留在仓库里。
|
||||
|
||||
`CLAUDE.md` 只保留兼容入口,不应继续新增长期规则。
|
||||
ADR 记录架构决策,但旧 ADR 中已经被后续实现明确替代的部分不能机械照搬。
|
||||
|
||||
## 现有边界
|
||||
|
||||
当前正式的资源同步和运维入口是 Rust `bat`。
|
||||
|
||||
官方资源发现、下载、校验、版本状态、staging、release 发布、watch/daemon、任务和相关长期状态都由 Rust 侧负责。不要在 Go、Web 或其他模块再实现一套相同状态机。
|
||||
|
||||
Go `bat-api` 是资源 bootstrap、只读分发和管理入口。它通过 `bat.sock` RPC 消费 Rust 状态,并读取 Rust 已经发布的资源。不要让它直接管理官方同步状态,也不要在 Go 里重新实现 CAS、AssetBundle 解析或 Patch 核心算法。
|
||||
|
||||
`bat-ffi` 只是兼容接口,不是主集成方式。不要把 daemon、下载器、CAS 长生命周期状态或新的主控制面塞进 FFI。
|
||||
|
||||
官方原版 release 和 localized release 是两套独立生命周期。已经发布的官方 release 应当视为不可变输入,汉化和 Patch 必须走独立 staging、校验、发布和 rollback 流程。
|
||||
|
||||
前端、API 和 CLI 不应维护第二套业务状态。状态以真正拥有它的后端模块为准。
|
||||
|
||||
## 模块和接口
|
||||
|
||||
优先使用仓库已经存在的抽象,例如 Repository、Adapter、Driver、Registry、Provider、RPC 和现有状态模型。
|
||||
|
||||
不要因为新增一个功能就平行实现第二套下载、存储、解析、Patch、翻译任务或 release 系统。
|
||||
|
||||
容易随着 Blue Archive、Unity、Addressables 或外部服务变化的逻辑应尽量留在 adapter/driver/provider 一侧,不要散进整个业务代码。
|
||||
|
||||
同时不要为了“以后可能会扩展”提前创建大量无实际用途的接口。只有已经存在多实现需求,或确定属于高变化边界的部分,才值得进一步抽象。
|
||||
|
||||
公共或持久化接口修改时要考虑兼容性。特别注意:
|
||||
|
||||
* RPC method 和 schema;
|
||||
* `status` / `status_code`;
|
||||
* `BAT-ERR-*` 错误码;
|
||||
* CLI JSON 输出;
|
||||
* release layout;
|
||||
* manifest/state 文件;
|
||||
* SQLite/PostgreSQL schema;
|
||||
* Patch manifest;
|
||||
* HTTP API。
|
||||
|
||||
不要静默改变已有字段的含义。确实需要破坏性修改时,先考虑版本号、迁移或兼容读取。
|
||||
|
||||
## Dashboard 开发与设计
|
||||
|
||||
BlueArchiveToolkit 包含两个面向不同使用者的 Dashboard:用户 Dashboard 与运营 Dashboard。两者属于同一产品,应共享基础视觉语言、组件风格和交互一致性,但不得因为复用组件而混淆产品职责、信息层级或权限边界。
|
||||
|
||||
涉及 Dashboard、Web UI、页面布局、视觉样式、组件设计或交互体验的任务,在开始设计和修改前必须阅读仓库根目录的 `DESIGN.md`。
|
||||
|
||||
`DESIGN.md` 是 Dashboard 的主要视觉参考与设计灵感来源。应理解并延续其中的色彩关系、排版、空间、边框、层级、组件形态和交互气质,但不得机械复制其来源产品的页面结构、品牌内容或不适合 BlueArchiveToolkit 的设计。实际页面的信息架构始终由 BlueArchiveToolkit 当前功能、真实数据结构和使用场景决定。
|
||||
|
||||
### 用户 Dashboard
|
||||
|
||||
用户 Dashboard 面向普通 BlueArchiveToolkit 用户,目标是以尽可能低的认知负担完成与汉化相关的用户操作。
|
||||
|
||||
当前用户可控制的核心能力仅包括:
|
||||
|
||||
* 文字汉化是否启用;
|
||||
* 图像汉化是否启用。
|
||||
|
||||
用户端可以展示与这些操作直接相关的必要信息,例如汉化状态、当前可用版本、更新状态、操作反馈或用户需要处理的异常,但不得暴露内部运维实现。
|
||||
|
||||
除非未来产品需求明确改变,否则用户 Dashboard 不应展示或要求用户理解:
|
||||
|
||||
* `bat` / `bat-api` 内部状态;
|
||||
* RPC、daemon、worker;
|
||||
* CAS;
|
||||
* Provider / provider run;
|
||||
* Translation Memory 内部记录;
|
||||
* translation task;
|
||||
* Parser;
|
||||
* official/localized release 的内部实现细节;
|
||||
* 服务端日志、内部错误栈和运维指标。
|
||||
|
||||
用户端优先保证清晰、简洁、可信和易操作。不要为了表现“Dashboard 感”堆积 KPI 卡片、图表、技术指标或无实际用途的信息。
|
||||
|
||||
### 运营 Dashboard
|
||||
|
||||
运营 Dashboard 面向项目运营和维护者,用于观察和管理 BlueArchiveToolkit 的真实运行状态。
|
||||
|
||||
运营端可以根据当前后端实际提供的 contract 展示和组织:
|
||||
|
||||
* `bat` 与 `bat-api` 运行状态;
|
||||
* official resource / official release;
|
||||
* localized resource / localized release;
|
||||
* 资源同步与更新状态;
|
||||
* Translation / Translation Memory;
|
||||
* Provider 与 worker;
|
||||
* task / job;
|
||||
* daemon/runtime;
|
||||
* CAS;
|
||||
* 错误、诊断与日志;
|
||||
* 配置和必要的运营操作。
|
||||
|
||||
运营 Dashboard 是高信息密度的 developer/operations interface。优先使用结构化列表、表格、紧凑状态信息、清晰的主次层级和按需 drill-down,而不是将所有数据做成大型 Card。
|
||||
|
||||
首页应帮助运营者快速回答“系统是否正常、哪里需要处理、最近发生了什么”,而不是简单罗列所有可获得的指标。
|
||||
|
||||
### 两个 Dashboard 的关系
|
||||
|
||||
两个 Dashboard 应共享:
|
||||
|
||||
* 基础 Design Token;
|
||||
* Typography;
|
||||
* Color System;
|
||||
* Button、Input、Switch、Dialog 等基础组件;
|
||||
* Loading、Empty、Error、Warning、Success 等状态语言;
|
||||
* Motion 与交互反馈原则;
|
||||
* 品牌识别。
|
||||
|
||||
但可以拥有不同的:
|
||||
|
||||
* Navigation;
|
||||
* 页面结构;
|
||||
* 信息密度;
|
||||
* 内容层级;
|
||||
* 默认组件尺寸;
|
||||
* 数据展示方式。
|
||||
|
||||
不要把运营 Dashboard 简单裁剪几个菜单后作为用户 Dashboard,也不要为了用户端的简洁限制运营端所需的信息密度。
|
||||
|
||||
### 设计实现原则
|
||||
|
||||
Dashboard 设计必须以真实接口和真实状态为依据。不得为了视觉完整性伪造后端不存在的数据、指标、趋势、操作或状态。
|
||||
|
||||
如果设计需要当前 API/RPC 尚未提供的信息,应明确指出缺失 contract,而不是在前端维护第二份业务状态或通过猜测拼接数据。
|
||||
|
||||
优先复用项目现有前端组件和设计基础。引入新组件模式前先确认现有组件无法合理满足需求,避免同一项目逐步形成多套 Card、Table、Badge、Button 或状态展示体系。
|
||||
|
||||
`DESIGN.md` 是视觉方向,不高于项目稳定架构与产品事实。发生冲突时按以下优先级处理:
|
||||
|
||||
`AGENTS.md` 与稳定产品/接口契约 > 当前明确任务需求 > `DESIGN.md` > Agent 自身设计偏好。
|
||||
|
||||
## 代码修改
|
||||
|
||||
先弄清楚代码为什么放在当前位置,再决定是继续修改还是拆模块。
|
||||
|
||||
仓库里已经存在一些较大的文件。不要因为“文件太长”机械拆分,但也不要继续往一个已经承担过多职责的文件里塞新的独立功能。按职责拆,不按行数拆。
|
||||
|
||||
避免:
|
||||
|
||||
* 重复实现已有能力;
|
||||
* 大范围无关重构;
|
||||
* 为测试专门加入生产逻辑;
|
||||
* 静默吞错;
|
||||
* 无说明的硬编码;
|
||||
* 魔法数字;
|
||||
* 假实现、空实现冒充完成功能;
|
||||
* 用代码内 `TODO` / `FIXME` 代替根目录 `TODO.md`、`CURRENT_GAPS.md` 或其他正式缺口记录。
|
||||
|
||||
如果当前任务确实无法完成某一部分,应明确限制实现范围;具体后续工程任务记录到根目录 `TODO.md`,能力缺口同步到 `CURRENT_GAPS.md`,需要外部协作时再使用 Issue。
|
||||
|
||||
## TODO 任务治理
|
||||
|
||||
根目录 `TODO.md` 是具体工程任务、优先级、依赖关系和完成条件的仓库内任务账本。开始具体开发前,应读取与当前工作相关的 TODO;完成任务或发现独立新问题后,应同步更新其状态和依赖。
|
||||
|
||||
`TODO.md` 不是当前实现事实来源。源码和测试、`CURRENT_STATUS.md`、专项 current-status 文档以及稳定 contract 的优先级高于 TODO 描述。若 TODO 与当前实现冲突,应先核对事实并更新过时 TODO,不要按照旧条目重新实现已经完成的能力。
|
||||
|
||||
属于当前任务同一 root cause 或同一 contract 的已确认问题,不得仅为了缩小 patch 而登记 TODO 后绕过;应在当前工程边界内一起收口。明显独立的问题应记录到 `TODO.md`,避免当前修改无限扩张。
|
||||
|
||||
`docs/reports/CURRENT_GAPS.md` 用于记录产品或工程能力层面的当前缺口;`PROJECT_PLAN.md` 用于长期路线;`TODO.md` 用于可执行任务追踪。不要把这些职责混在一起。
|
||||
|
||||
## 文件、网络和发布安全
|
||||
|
||||
资源处理代码不能绕过现有的路径和完整性检查。
|
||||
|
||||
涉及文件写入、下载、CAS、Patch、release 或客户端文件时,应继续遵守仓库现有做法,包括路径归属检查、symlink 防护、临时文件、原子写入、hash/size 校验、失败不发布不完整结果等。
|
||||
|
||||
官方资源链路只使用项目当前允许的官方来源。不要为了绕过失败偷偷加入镜像或来源不明的 fallback。
|
||||
|
||||
密钥、Token、代理凭据等不能进入 Git,也不能无必要地出现在日志、状态文件或进程参数中。
|
||||
|
||||
## 测试
|
||||
|
||||
根据改动范围运行仓库已有的测试和检查,不要自己发明另一套质量流程。
|
||||
|
||||
Rust 修改通常至少考虑:
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
cargo check --workspace
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
```
|
||||
|
||||
Go / bat-api 修改使用仓库现有 Makefile 和对应 `go test` / `go vet` 门禁。
|
||||
|
||||
涉及文档状态时运行:
|
||||
|
||||
```bash
|
||||
make check-docs
|
||||
```
|
||||
|
||||
涉及真实资源格式、RPC contract、release 或网络流程时,优先补已有 fixture、contract test、integration test 或 smoke,而不是只写一个理想化单元测试。
|
||||
|
||||
不能只根据合成样本宣称支持新的官方格式。
|
||||
|
||||
不方便运行某项重要测试时,在结果里说明没有运行什么以及原因。
|
||||
|
||||
## 文档
|
||||
|
||||
改动如果影响用户或其他模块能够观察到的行为,就同步对应文档。
|
||||
|
||||
尤其是:
|
||||
|
||||
* CLI;
|
||||
* RPC;
|
||||
* HTTP API;
|
||||
* 配置项;
|
||||
* release 布局;
|
||||
* schema;
|
||||
* 错误码和状态码;
|
||||
* 模块职责;
|
||||
* 当前实现状态。
|
||||
|
||||
不要把具体任务、临时优先级或某次实现方案写进本文件。
|
||||
|
||||
新的长期架构决策应该进入 ADR 或对应架构文档;开发路线进入 `PROJECT_PLAN.md`;实际进度进入 `CURRENT_STATUS.md`;能力缺口进入 `docs/reports/CURRENT_GAPS.md`;具体工程任务、依赖和完成条件进入根目录 `TODO.md`;需要外部协作时再使用 Issue。
|
||||
|
||||
Dashboard 的视觉方向与设计灵感进入根目录 `DESIGN.md`;Dashboard 的产品职责、状态所有权和接口事实仍以本文件与稳定产品/接口契约为准。
|
||||
|
||||
## 工作方式
|
||||
|
||||
局部且模式明确的修改可以直接做。
|
||||
|
||||
涉及公共契约、新子系统、持久化格式、跨语言边界、大范围重构或安全边界时,先把现有实现和影响范围弄清楚,再动代码。
|
||||
|
||||
完成后检查三件事:
|
||||
|
||||
1. 有没有重复仓库已经存在的能力;
|
||||
2. 有没有无意改变稳定接口或状态所有权;
|
||||
3. 代码、测试和权威文档是否仍然一致。
|
||||
|
||||
+10
-8
@@ -9,11 +9,12 @@
|
||||
### 新增
|
||||
- Addressables catalog 提取 `m_Crc`(bundle IEEE CRC-32):`ResourceEntry` 新增 `crc` 字段(compact/expanded 两种形态均解析),SQLite 持久化并对旧库幂等迁移补列;core 新增 `crc32_ieee` 与 `ResourceEntry::verify_downloaded_bytes`(按声明的 size/CRC 校验字节)(issue #2)
|
||||
- UnityFS 解析新增目录条目越界校验:directory 的 `offset+size` 必须落在解压数据区内,截断/损坏 bundle 的越界目录条目不再被静默接受(issue #3)
|
||||
- 官方资源下载回归顺序执行:manifest/quarantine 簿记与 seed `.hash` 校验保持串行,`fail-fast` 与「不发布不完整资源」不变量不变(issue #17,按 wontfix 关闭多线程下载目标)
|
||||
- 官方资源下载使用默认 8 个独立 worker;每个 worker 完成当前 URL 后立即从共享计划队列领取下一个任务,manifest/quarantine 簿记与 seed `.hash` 校验逐项保持一致,`fail-fast` 与「不发布不完整资源」不变量不变(issue #17 的历史决定不代表当前并发实现)
|
||||
- 新增 `cmd/bat-api` 资源 bootstrap/分发 HTTP 服务(issue #19 / G-009 资源面):与 Rust `bat` 同环境运行,经 `bat.sock` RPC(`daemon.status` → `daemon.doctor` → catalog/manifest)发现已发布 release 和 `resource_root`,按官方 CDN host/path 只读提供资源;管理面 `/healthz` `/v1/release` `/v1/resources`;可选 server-info 仅改写 Addressables root;`.env` 配置监听端口/RPC socket/刷新周期/预留数据库键。资源自动拉取仍由 Rust `bat` 负责
|
||||
- `cmd/bat-api` 新增 `/v1/bootstrap`:把 Rust `bat` 的 RPC 健康、已发布 release 摘要、server-info URL、client-patch base 和改写后的 Addressables root 组织成启动前资源发现响应,固定 `bat` 是资源生产者、`bat-api` 是只读 bootstrap/分发层的关系
|
||||
- `bat-api` CDN 分发补齐 Range / HEAD / 条件请求语义:基于 manifest BLAKE3 生成 ETag,返回 Last-Modified、Accept-Ranges 和长期缓存头,`.hash` 以 `text/plain` 返回
|
||||
- `bat-api` 增加 RPC 周期刷新(`BAT_API_REFRESH_INTERVAL` / `--refresh-interval`),用于跟随远程长期运行的 Rust `bat` 发布新 release;生产不应写死 `BAT_API_RESOURCE_ROOT`
|
||||
- `bat-api` 增加 RPC 周期刷新(`BAT_API_REFRESH_INTERVAL` / `--refresh-interval`),用于跟随同机 Rust `bat` 发布新 release;生产不应写死 `BAT_API_RESOURCE_ROOT`
|
||||
- `bat-api` 增加同机 live smoke(`make bat-api-local-live-smoke`),在隔离 `/tmp` 目录验证真实 `bat.sock`、release 切换、未 ready、恢复和 CDN 读路径;失效 resource root、manifest 不完整和编码 dot-segment 不会继续分发旧索引
|
||||
- `bat-api` 增加 refresh 诊断和 `/readyz`:`/healthz` 暴露最近一次 RPC refresh 的时间、耗时、warning 和错误,`/readyz` 在无可分发 release 时返回 `503`
|
||||
- `bat-api` 增加 launcher 资源引导兼容:`/v1/launcher/bootstrap`、`/api/launcher/game/config`、`/api/launcher/game/config/json`、`/api/launcher/advanced/game/download/cdn` 及 `/api-launcher-jp.yo-star.com/...` host 形状入口,响应来自 Rust `bat` 已发布 snapshot/RPC,明确不提供登录、网关、鉴权或完整 package update manifest
|
||||
- `bat-api` 增加玩家-facing HTTP 控制面:token 鉴权、进程内限流、访问日志、反代 IP 适配、安全响应头、动态 JSON `Cache-Control: no-store`、`/v1/resources` 分页上限、统一 JSON error、OpenAPI (`/openapi.yaml`) 和 `/admin/` 管理面板预留
|
||||
@@ -21,18 +22,19 @@
|
||||
- USERGUIDE 补充 `bat-api` 资源 bootstrap / 分发章节,说明与 Rust `bat` 的运行关系、接口、CDN 响应语义和不仿造业务 API 的边界
|
||||
- 官方同步新增 `official-parse-cache.json`:校验发布后从下载 manifest 覆盖直接 UnityFS bundle、zip 内 UnityFS 条目和非候选资源记录;URL、相对路径、size 和 BLAKE3 未变化时跳过重复解析
|
||||
- 官方原版资源和汉化产物目录分离:`BAT_OUTPUT`/`--output` 默认 `./bat-resources`,`BAT_LOCALIZED_OUTPUT`/`--localized-output` 默认 `./bat-localized`;同步报告新增 `localized_release_status=not_localized`,后续 Patch 发布完成后才切换为 `localized`
|
||||
- 官方资源同步支持从已发布历史 release 或 CAS 复用已校验文件:复用前检查 size、BLAKE3 和 ZIP 结构,失败保留诊断并回退网络;release 级 CAS 引用写入 `official-cas-reuse-references.json`,清理流程同步回收引用
|
||||
- Rust `bat` 的报告渲染与前台终端输出分别模块化到 `report_output.rs` 和 `terminal_output.rs`,保持 JSON、人类摘要、帮助、进度和错误输出契约不变
|
||||
|
||||
### 修复
|
||||
- 官方下载失败重试之间加入指数退避(网络类失败 200ms→400ms→800ms…,上限 5s)
|
||||
|
||||
### 计划
|
||||
- [ ] `bat-api` 后续:全量 release 联调、Rust/Go snapshot contract fixture(用户审核后)、refresh mtime/size 增量缓存、完整 launcher 安装包更新链(若需要,新 issue)、API 持久化层接入预留 database/redis 配置
|
||||
- [ ] 官方同步结果接入 CAS + ResourceRepository 的用户级工作流
|
||||
- [ ] `bat-api` 后续:refresh mtime/size 增量缓存、完整 launcher 安装包更新链(若需要,新 issue)、API 持久化层接入预留 database/redis 配置;Rust/Go snapshot contract fixture 与同机 live smoke 已完成
|
||||
- [ ] 扩展 CAS + ResourceRepository 的用户级查询、翻译记忆和通用 Patch 发布资源视图
|
||||
- [ ] 官方下载/导入路径接入 CRC/size 校验(复用 `verify_downloaded_bytes`)
|
||||
- [ ] 汉化 Patch 发布:维护 `localized-output/current`、`localized-version-state`、`localized` 状态切换和回滚
|
||||
- [ ] 实现翻译系统
|
||||
- [ ] 实现 Patch 引擎
|
||||
- [ ] 实现 Web 管理后台
|
||||
- [ ] 完成通用 manifest 驱动的 Patch build/rollback、复杂 AssetBundle 重打包和完整汉化文件集合发布
|
||||
- [ ] 扩展 provider 编排、翻译记忆和人工协作工作流
|
||||
- [ ] 完成 Web 协作后台的持久化、权限和长期任务能力
|
||||
|
||||
## [0.2.0] - 2026-07-17
|
||||
|
||||
|
||||
+6
-1
@@ -37,9 +37,14 @@
|
||||
基础验证命令见 `docs/guides/development.md`。常用最低门禁:
|
||||
|
||||
```bash
|
||||
cargo fmt --check
|
||||
cargo fmt --all -- --check
|
||||
cargo check --workspace
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
make test-go-api
|
||||
make build-go-api
|
||||
go vet ./...
|
||||
make check-docs
|
||||
```
|
||||
|
||||
如果改动只影响部分 crate,可以先跑更窄的测试,但合并前必须确保影响面被覆盖。官方资源同步、下载、daemon、status、verify 或 repair 相关改动还应运行:
|
||||
|
||||
+119
-64
@@ -1,6 +1,6 @@
|
||||
# BlueArchiveToolkit 当前工作区状态
|
||||
|
||||
- **更新时间**:2026-07-26
|
||||
- **更新时间**:2026-09-13
|
||||
- **状态来源**:本地工作区盘点、代码验证和最新提交
|
||||
- **状态分支**:`experiment`
|
||||
- **最新已推送功能提交**:以当前 `git log --oneline -1` 为准
|
||||
@@ -20,17 +20,71 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
|
||||
3. 默认平台为 `Windows + Android`。
|
||||
4. 能生成官方全量 pull plan,执行真实下载,维护 release 内的 `official-download-manifest.json`。
|
||||
5. 下载后使用本地 manifest 的 size + BLAKE3 校验复用文件;所有 `.zip` 在下载验收、复用、本地 audit/verify 时做 ZIP 结构校验;官方 seed `.hash` 使用标准 `xxHash32(seed=0)` 强校验(早期实现的非标准 avalanche 常量已修正)。
|
||||
6. 支持 `.part` 断点续传、失败后 clean retry、本地 manifest audit/repair、失败 staging 恢复复用、403/404/5xx 分类重试(重试带指数退避)、下载 quarantine 诊断,以及旧 launcher 包官方 primary/backup CDN 切换。启动器/server-info 先行更新但 client-patch seed marker 或必需 seed catalog 尚未开放时,会进入 `waiting_for_official_resources`,保留现有 `current`,不创建失败 staging,也不写入失败版本循环;启用 `--auto-discover` 的非 dry-run 会写入 `<output>/official-launcher-bootstrap.pending.json` 作为维护期证据。下载执行保持顺序处理;manifest/quarantine 簿记与 seed `.hash` 校验仍逐项执行,`fail-fast` 与「不发布不完整资源」不变量不变。下载进度按已完成数量单调上报,不再使用 plan 序号计算百分比。
|
||||
6. 支持 `.part` 断点续传、失败后 clean retry、本地 manifest audit/repair、失败 staging 恢复复用、403/404/5xx 分类重试(重试带指数退避)、下载 quarantine 诊断,以及旧 launcher 包官方 primary/backup CDN 切换。启动器/server-info 先行更新但 client-patch seed marker 或必需 seed catalog 尚未开放时,会进入 `waiting_for_official_resources`,保留现有 `current`,不创建失败 staging,也不写入失败版本循环;启用 `--auto-discover` 的非 dry-run 会写入 `<output>/official-launcher-bootstrap.pending.json` 作为维护期证据。下载默认使用 8 个独立 worker,范围为 `1..=256`;每个 worker 完成当前 URL 后立即从共享计划队列领取下一个任务,进度按实际完成顺序即时上报,最终 report 资源列表仍按计划顺序输出。manifest/quarantine 簿记与 seed `.hash` 校验仍逐项执行,`fail-fast` 与「不发布不完整资源」不变量不变。下载进度按已完成数量单调上报,不再使用 plan 序号计算百分比。新 staging 还会按规范化 destination 查找已发布历史 release,重新校验 size、BLAKE3 和 ZIP 结构后用硬链接或跨文件系统复制复用;历史文件不满足条件时再验证配置的 CAS 对象,最后才回退网络,并把 `release_reused`、`cas_reused`、`downloaded` 和复用诊断写入报告。
|
||||
7. 支持 curl 传输层本地代理:默认自动检测 `HTTPS_PROXY` / `ALL_PROXY` / `HTTP_PROXY` 及小写环境变量(带凭据的代理推荐用环境变量配置),也可用 `--proxy <URL>` 显式指定或 `--no-proxy` 强制直连;代理决策会写入 progress log、daemon log 和 `bat doctor` 诊断输出。代理凭据不落世界可读位置:日志/`status` 脱敏,传给 curl 经 `ALL_PROXY` 环境变量而非 argv,`--daemon` 下经环境变量下传后台子进程、不进子进程 argv 或 `bat-status.json`,复用凭据存于 `bat-proxy.secret`(`0600`)且 `clean-stable` 会清除。
|
||||
8. `bat --watch` 可常驻运行,`bat --daemon` 可后台运行并用 `bat status` / `bat stop` / `bat restart` / `bat reload` / `bat logs` 管理;daemon 使用 `bat.sock` Unix socket JSON-RPC 作为 live 控制通道,PID/状态/日志文件作为快照和 fallback,`bat-events.jsonl` 记录带轮转的结构化事件日志,`bat-control.lock` 串行化控制命令;正常检查默认每 1 小时一次;远端和本地一致时默认静默,失败后默认 60 秒快速重试,官方资源端尚未开放时状态为 `waiting` 并同样按错误重试间隔探测;CLI 默认向 stdout 输出人类可读摘要,向 stderr 输出 ASCII banner、progress log、失败分类和 quarantine 状态,需要机器输出时使用 `--json --no-progress`。
|
||||
8. `bat --watch` 可常驻运行,`bat --daemon` 可后台运行并用 `bat status` / `bat stop` / `bat restart` / `bat reload` / `bat logs` 管理;daemon 使用 `bat.sock` Unix socket JSON-RPC 作为 live 控制通道,PID/状态/日志文件作为快照和 fallback,`bat-events.jsonl` 记录带轮转的结构化事件日志,`bat-control.lock` 串行化控制命令;正常检查默认每 1 小时一次;远端和本地一致时默认静默,失败后默认 60 秒快速重试,官方资源端尚未开放时状态为 `waiting` 并同样按错误重试间隔探测;`resource.state` / `catalog.status` / `parse.status` / `localized.status` 会返回 `status` 与稳定 `status_code`(如 `official.up_to_date`、`official.published`、`parse.completed`、`translation.queued_offline`、`localized.published`、`distribution.ready`),供 `bat-api` 等读侧判断阶段、终态和重试属性;CLI 默认向 stdout 输出人类可读摘要,向 stderr 输出 ASCII banner、progress log、失败分类和 quarantine 状态,需要机器输出时使用 `--json --no-progress`。
|
||||
9. 远端 snapshot 未变化但输出目录为空时,会按首次运行执行全量拉取;官方 seed `.hash` 校验失败时会清理对应 manifest 条目,避免失败产物被后续本地 audit 误判为可复用。
|
||||
10. 默认官方原版资源目录是 `./bat-resources`,默认汉化产物目录是 `./bat-localized`,默认后台状态目录是 `/tmp/bat-pid`;官方资源目录是发布根目录,包含 `current` symlink、`versions/<id>` 和 `.staging/<id>`,非 dry-run 会先写 staging,校验完成后发布 versioned 目录并原子切换 `current`;启用 `--auto-discover` 的 release 会包含 `official-launcher-bootstrap.json`,up-to-date 轮询会为旧 release 补写该产物;如果上一轮同一 app version、bundle version 和 Addressables root 的 staging 失败但目录仍安全存在,下一轮会复用该 staging 并按 manifest 逐文件校验/补下载;后台状态目录包含 `bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log`、`bat-events.jsonl`、任务历史 `bat-tasks.json` 和短生命周期 `bat-control.lock`;非 dry-run 使用 `--output/.official-sync.lock` 防止并发写同一资源目录,live daemon 会阻止前台写命令直接修改它正在管理的同一目录。
|
||||
10. 默认官方原版资源目录是 `./bat-resources`,默认汉化产物目录是 `./bat-localized`,默认后台状态目录是 `/tmp/bat-pid`;官方资源目录是发布根目录,包含 `current` symlink、`versions/<id>` 和 `.staging/<id>`,非 dry-run 会先写 staging,校验完成后发布 versioned 目录并原子切换 `current`;启用 `--auto-discover` 的 release 会包含 `official-launcher-bootstrap.json`,up-to-date 轮询会为旧 release 补写该产物;如果上一轮同一 app version、bundle version 和 Addressables root 的 staging 失败但目录仍安全存在,下一轮会复用该 staging 并按 manifest 逐文件校验/补下载;从 CAS 复用的 release 会在自身目录保存版本化 `official-cas-reuse-references.json`,孤儿 staging 清理或 release 清理时按清单递减 CAS 引用,避免 CAS GC 误删仍被 release 使用的对象;后台状态目录包含 `bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log`、`bat-events.jsonl`、任务历史 `bat-tasks.json` 和短生命周期 `bat-control.lock`;非 dry-run 使用 `--output/.official-sync.lock` 防止并发写同一资源目录,live daemon 会阻止前台写命令直接修改它正在管理的同一目录。
|
||||
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 可按类型、hash、路径模式分页查询现有索引,数据库不存在时返回 `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 明细与解析错误;TextUnit 已包含 class id、field path、字段 offset/byte size 等可追溯定位。Crowdin 当前仅预留本地离线队列,不发网络请求;up-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析。官方同步报告默认 `localized_release_status=not_localized`,含义是原版资源已经发布、汉化资源未发布;UnityFS TextAsset patch 发布成功后会写 `localized-patch-manifest.json`,校验 hash/size/diff/rollback 后才允许 `localized.status` 返回 `localized`。`bat` 首次启动会在二进制所在目录释放 `.env` 配置模板(`0600`),之后每次启动自动加载(不覆盖已存在的环境变量),支持 `BAT_OUTPUT`/`BAT_LOCALIZED_OUTPUT`/`BAT_IMPORT_REPOSITORY`/`BAT_IMPORT_CAS_ROOT`/`BAT_IMPORT_RESOURCE_DB`/`BAT_STATE_DIR`/`BAT_AUTO_DISCOVER`/`BAT_WATCH`/`BAT_DAEMON`/`BAT_PROXY` 等键,实现编辑 `.env` 后无参启动;优先级为命令行参数 > 进程环境变量 > `.env` > 内置默认值,`BAT_SKIP_ENV_FILE=1` 可整体禁用;Redis 键为预留。daemon 任务历史持久化在 `<state-dir>/bat-tasks.json`(版本化、`0600` 原子写),重启后任务经 `task.*` 仍可查,中断任务标记 `task_interrupted`(`BAT-ERR-700005`)。
|
||||
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` 等稳定值。
|
||||
|
||||
仍需明确:这不是完整产品完成。完整 AssetBundle 重打包、翻译、Web、以及 `bat-api` 的服务器联调/可选业务扩展仍是后续工作;G-008(产品级 Go 同步 CLI)已决策关闭。真实官方网络全量拉取 smoke 已固化(G-018 已关闭);真实大文件与运行报告默认在 `/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 SQLite persistence schema V2 位于 `<output>/translation-memory.sqlite`,不放在 `versions/<id>` 或 release task 库中;
|
||||
`translation.tasks` 查询单项 worker 状态,`translation.handoff` 查询完整
|
||||
job/unit/provider run 状态;`translation.memory.summary/query/confirm/conflicts/resolve_conflict`
|
||||
提供 Rust-owned TM 的摘要、source/context 查询、显式 trusted 确认和冲突治理,
|
||||
`bat-api` 仅作 typed 管理转发。当前下载实现使用默认 8 个独立 worker,完成后动态领取
|
||||
任务,最终资源报告按 pull plan 顺序输出。
|
||||
项目级 Glossary 使用 Glossary domain/feature contract V1,由 SQLite persistence schema V2 承载,位于 `<output>/glossary.sqlite`,独立于 release task 和 TM;
|
||||
Rust `bat` 持有 term/alias/recommended/allowed/category/priority、全局或
|
||||
TextUnit scope、source history 和 approved review。worker、TM 复用、人工 task
|
||||
结果和 workbench publish 都执行确定性 QA;blocking deviation 必须携带
|
||||
稳定 `qa_identity` 以及 reviewer/reason/provenance 的显式 override,所有接受路径都会
|
||||
按当前 QA 精确校验 identity。`translation.glossary.*` 已通过
|
||||
`bat.sock` 暴露,Go `bat-api` 仅做鉴权 typed forwarding。
|
||||
|
||||
三个长期 SQLite owner 现在统一使用只读 schema preflight、精确 component
|
||||
fingerprint 和 `BEGIN IMMEDIATE` writer transaction:Translation Tasks 从 V1
|
||||
按显式 `v1 -> v2` step 迁移,当前版本为 V2;Translation Memory persistence schema
|
||||
当前版本为 V2,Glossary domain/feature contract 为 V1、persistence schema 为 V2。
|
||||
Translation Memory V2 正式建立
|
||||
current Trusted 唯一性、显式 supersede、冲突只读诊断、resolve_conflict 和 audit event;
|
||||
Glossary persistence schema V2 正式吸收历史上未升版本的
|
||||
`glossary_term_deletions` schema drift:原始 V1-A 会在事务内创建 deletion audit
|
||||
表,带 deletion audit 的 V1-B 只提升 bookkeeping version。future、未知或版本与结构
|
||||
不一致的数据库在任何 schema/data mutation 前 fail closed;migration 失败会 rollback,
|
||||
已知无 `schema_migrations` 表的历史 fingerprint 可安全补建版本表后重试。正式 schema
|
||||
路径不再使用 `ensure_column` 隐式补列。
|
||||
|
||||
`localized.status` 现将 generic manifest schema/contract 与已发布 artifact integrity
|
||||
分开报告;current、state 和 identity 存在但文件被截断或手工修改时返回
|
||||
`localized.degraded`,只读检查不会自动回滚、删除或修复。双 release 的
|
||||
`release.attestation` 已由 Rust 从 current、canonical versioned resource root、publication
|
||||
anchor、manifest identity 和 verification generation/freshness 生成,供 Go current
|
||||
readiness 使用;`resource.manifest` 请求必须绑定同一 generation,`release.status/list/distribution/cleanup`
|
||||
仍由 Rust 从既有状态、manifest、文件系统和 CAS/reference 元数据统一生成,Go 仅 typed 转发。
|
||||
CAS repository 的对象文件、引用计数
|
||||
和 GC 通过跨进程操作锁协调,release-local CAS 引用以 `(ownership_id, ordinal)` ownership
|
||||
记录幂等释放;新清单持久化 `ownership_id`,旧清单按 output-root scope、稳定 source
|
||||
identity 和 generation-aware legacy cleanup path 迁移;localized publish/rollback 通过
|
||||
output-root 单写者锁和事务日志恢复
|
||||
current、version-state、version 目录,publish 只有最终 `verified` phase 才能恢复为已提交。
|
||||
localized release 还写入实际 bytes/BLAKE3、完整 source mapping identity 和 destination
|
||||
index 的 `localized-distribution-manifest.json`;official download manifest 同样持久化
|
||||
canonical mapping identity 和 destination index。新 official release 在完整发布验证后
|
||||
额外写入独立的 `official-distribution-publication.json`,关联 release ID、mapping
|
||||
identity、manifest content identity 和 entry count。`release.distribution` 只选择
|
||||
publication identity 与当前 official manifest 一致的 release;`destination=...` 时
|
||||
比较 persisted identity、查目标索引并校验单文件,返回 exactly one entry 且不在分发热路径
|
||||
执行完整 release audit;缺少 publication metadata 的 legacy release 仍可读和清理,但不
|
||||
满足 distribution-ready。`release.cleanup execute` 与 official sync 共用同一个
|
||||
`.official-sync.lock`,localized cleanup 继续使用 `.localized-release.lock`。legacy CAS
|
||||
manifest 首次 cleanup 时按 output-root scope、release generation 和稳定 source identity
|
||||
迁移;已有 basename ledger 的部分 cleanup 只有在 output-root 持久化的显式 compatibility
|
||||
marker 存在时才保持兼容 key,无法证明归属时拒绝 cleanup;完成后同名新 generation 不再复用。
|
||||
|
||||
---
|
||||
|
||||
@@ -41,6 +95,7 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
|
||||
- `DOCS_INDEX.md`:文档阅读顺序和索引。
|
||||
- `docs/guides/official-resource-test-pull.md`:官方资源拉取与自动更新用户指南。
|
||||
- `docs/guides/official-full-pull-smoke.md`:真实官方全量拉取 smoke runbook 和可重复命令。
|
||||
- `docs/guides/bat-workflows.md`:Rust `bat` 的 `res` / `parse` / `i18n` 工作流、调度计划和 `bat-api` 调度接口。
|
||||
- `docs/architecture/official-resource-backend.md`:官方资源后端设计和审核说明。
|
||||
- `docs/architecture/assetbundle.md`:解析补全路线图,覆盖 Addressables、UnityFS、Serialized 字段级解析、文本提取、CAS 接入和 Patch 发布前置。
|
||||
- `docs/reports/CURRENT_GAPS.md`:当前缺口和关闭顺序。
|
||||
@@ -80,7 +135,7 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
|
||||
待完成:
|
||||
|
||||
- 领域服务模块仍为空。
|
||||
- Glossary、Provider、Patch、Manifest 等后续仓储/服务接口需要补齐。
|
||||
- Provider、Patch、Manifest 等后续仓储/服务接口需要补齐。
|
||||
- 公共错误模型需要与 CLI/API 错误码统一。
|
||||
|
||||
### `bat-adapters`
|
||||
@@ -91,14 +146,14 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
|
||||
|
||||
- Unity adapter trait、注册表、Unity 2021.3 adapter 基础解析与校验。
|
||||
- Manifest driver trait、Addressables driver、注册表。
|
||||
- Addressables JSON catalog 的 path、hash、size、address、dependencies、metadata 解析。
|
||||
- Addressables JSON/compact catalog 的 path、hash、size、address、dependencies、provider ID、bundle name、CRC、metadata 解析。
|
||||
- 真实形态 Addressables fixture/golden 测试。
|
||||
- 当前 catalog、上一个版本 catalog、结构变化 catalog 的离线回归 fixture。
|
||||
- 官方日服 `server-info`、URL 规则、平台 discovery 和 inventory 枚举;`MediaCatalog.bytes` 使用官方相对路径生成媒体 URL,覆盖 `GameData/`、`Prologue/` 下的 zip/mp4/png/jpg/ogg/wav 等媒体资源,避免把叶子文件名误拼到媒体根目录。
|
||||
|
||||
待完成:
|
||||
|
||||
- `crates/bat-assetbundle` 已具备 UnityFS 容器、对象表、TypeTree 元数据、基础字段读取、TextAsset 和 TextUnit 提取;UnityFS TextAsset patch 发布前置链路已可用,真实复杂版本差异、重打包和通用 Patch 仍未实现。
|
||||
- `crates/bat-assetbundle` 已具备 UnityFS 容器、对象表、TypeTree 元数据、基础字段读取、TextAsset 和 TextUnit 提取;UnityFS 容器已补充总大小、计数、路径、重复 directory、LZMA 和边界校验,并通过 UnityPy 真实 bundle 隔离回归。对当前真实/合成回归覆盖的结构,TextAsset、TypeTree string field、managed-reference string field 和语义字段已形成 parse→modify→rebuild→reparse 闭环,重建保留已识别压缩/对齐/目录形态并校验未修改对象/字段;整体任意 AssetBundle、未知结构和全部真实版本差异仍未实现。
|
||||
- Addressables parser 已覆盖当前真实形态 fixture/golden 与 `m_Crc`,但仍需继续覆盖二进制/压缩字段组合和更细失败诊断。
|
||||
- 客户端发现、备份、应用补丁流程尚未连接真实实现。
|
||||
|
||||
@@ -137,18 +192,22 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
|
||||
- `OfficialResourcePullService`:官方 URL 拒绝策略、目标路径映射、下载 manifest、下载 quarantine、`.part` 续传、curl 代理配置、403/404/5xx 分类重试、ZIP 结构校验、官方 seed `.hash` 校验、本地全量 verify。
|
||||
- `OfficialUpdateService`:官方 metadata auto-discover、bootstrap cache、snapshot diff、marker diff、本地 audit/repair、失败 staging 恢复。
|
||||
- `bat`:正式 CLI binary,支持 one-shot、`--proxy` / `--no-proxy`、`--watch`、`--daemon`、`status`、`stop`、`restart`、`reload`、`refresh`、`logs`、`verify`、`repair`、`doctor` 和 `clean-stable`。
|
||||
- `release_ops.rs`:从既有 official/localized state、manifest、filesystem 和 CAS reference
|
||||
元数据生成双 release `status/list/distribution/cleanup`;默认 official 分发,localized
|
||||
和历史 release 仅在 Rust 完整性验证通过后可选,cleanup 使用 dry-run `plan_id` 和执行前重验证。
|
||||
- `report_output.rs`、`terminal_output.rs`:分别负责结果报告渲染和前台终端诊断、帮助、进度及结构化日志输出。
|
||||
|
||||
待完成:
|
||||
|
||||
- 将 `crowdin-textunit-queue.json` 接入真实 Crowdin worker、翻译记忆和 Patch 构建;继续扩展 ResourceRepository 对翻译任务状态和 CAS 诊断的查询面。
|
||||
- 基于已接入的 `translation.worker.run` 继续扩展 TM/Glossary 和复杂 AssetBundle fixture;generic manifest V1 与双 release 运维 V1 已完成。
|
||||
- 真实线上全量下载 smoke 已固化为 `scripts/official-full-pull-smoke.sh` 和 `make official-smoke`;实际运行报告由脚本写入隔离输出目录。
|
||||
- 增加更多权限和极端文件系统场景测试。
|
||||
|
||||
### `bat-assetbundle`
|
||||
|
||||
状态:**UnityFS 解包、TypeTree 字段读取、TextUnit 提取和 TextAsset patch 发布前置已起步;复杂结构覆盖、重打包与 Patch 发布统一未完成**
|
||||
状态:**已验证 UnityFS 结构的解析、变长修改、重建、重解析和受支持 localized 发布可用;任意复杂结构兼容仍待继续补齐**
|
||||
|
||||
冻结说明:自 2026-07-30 起,解析模块进入维护冻结。冻结期只允许修复编译、测试、clippy、崩溃、错误诊断、真实运行回归和文档不一致;不新增 TypeTree 语义类型、不扩大 UnityFS / AssetBundle / Addressables 解析覆盖、不开放新的写入型解析 RPC/CLI,也不以合成 fixture 宣称新增解析能力。冻结细则见 `docs/reports/PARSER_FREEZE.md`。
|
||||
解析扩展当前按路线图和真实 fixture 验收推进。
|
||||
|
||||
当前已有:
|
||||
|
||||
@@ -165,20 +224,20 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
|
||||
待完成:
|
||||
|
||||
- 真实 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 staging/manifest/current/rollback;带 `archive_entry` 的可验证 ZIP 内 bundle 也会重写外层 ZIP。
|
||||
- 真实资源 fixture 覆盖对象级解析和文本提取。
|
||||
- 详细补全顺序见 `docs/architecture/assetbundle.md`。
|
||||
|
||||
### `bat-patch`
|
||||
|
||||
状态:**通用 Binary/JSON/Text Patch 基础可用;文件级 patch / UnityFS 写入入口已开放,发布级 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 执行替换、写 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、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 变体驱动字段修改后的语义重打包。
|
||||
- 发布级 `patch build` / `patch rollback` / 通用 manifest 驱动发布;当前文件级写入入口不切换 release,不替代汉化发布流程。
|
||||
- 未见样本驱动的复杂 AssetBundle 重打包;当前 generic manifest 和双 release 运维 V1 只承诺已验证的 Binary/JSON/Text、UnityFS 结构及 Rust-owned release 查询/分发/安全清理,不等价于任意整体 AssetBundle 重打包。
|
||||
- `unityfs.inspect`、复杂 UnityFS 语义编辑和写入型发布工作流仍未开放。
|
||||
|
||||
### `bat-ffi`
|
||||
@@ -196,7 +255,8 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
|
||||
|
||||
- `bat-ffi` 只暴露粗粒度、无状态、一次调用一次 JSON 输入输出的 C ABI helper。
|
||||
- 它不持有 downloader、daemon、CAS handle、资源目录锁或长生命周期状态。
|
||||
- 未来 Go 产品入口和生产运维默认应调用 `bat --json` 进程边界;未来稳定 SDK 也优先于 FFI。
|
||||
- 新的 Go 集成和生产运维读侧默认应调用 `bat.sock` RPC;`bat --json` 仅是
|
||||
Rust CLI 的机器输出形态,`bat-ffi` 仍是可选兼容层。
|
||||
- FFI 仅用于需要嵌入 C ABI 的兼容场景,不能作为官方同步控制面或主集成边界。
|
||||
|
||||
待完成:
|
||||
@@ -206,50 +266,50 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
|
||||
|
||||
### Go / API / Web
|
||||
|
||||
状态:**边界已冻结;资源分发 MVP 已落地。权威细节见 `docs/reports/GO_STATUS.md`。**
|
||||
状态:**边界已确定;资源分发 MVP 已落地。权威细节见 `docs/reports/GO_STATUS.md`。**
|
||||
|
||||
| 角色 | 所有者 | 状态 |
|
||||
|---|---|---|
|
||||
| 同步/运维命令行(近乎全自动) | Rust `bat` | 产品入口 |
|
||||
| 资源 bootstrap / 分发 HTTP | Go `cmd/bat-api` | bootstrap + CDN MVP + RPC 周期刷新/诊断 + readiness |
|
||||
| 资源 bootstrap / 分发 HTTP | Go `cmd/bat-api` | bootstrap + official/localized/historical verified CDN MVP + RPC 周期刷新/诊断 + readiness + release 管理转发 + 内嵌 dashboard |
|
||||
| daemon RPC client | `internal/backendrpc` | 完成 |
|
||||
| 试验 CLI | `cmd/bat` → `bin/bat-go` | 非产品 |
|
||||
| FFI | `internal/ffi` | 可选 |
|
||||
| 空目录 `api/` `pkg/` 等 | 占位 | 无实现 |
|
||||
| Web | `web/` | 空(G-010) |
|
||||
| Web | `web/` | 内嵌 dashboard MVP;完整协作后台仍未完成 |
|
||||
|
||||
默认 Go 门禁:`make test-go-api`、`make build-go-api`(无 FFI)。
|
||||
默认 Go/docs 只读门禁:`make ci-check`(Rust fmt/check/build/clippy/test、Go API
|
||||
format/test/vet/build、固定版本 `golangci-lint 2.12.2`、docs/OpenAPI/RPC contract;
|
||||
无 FFI)。`make format` / `make fmt` 才会修改源码;required 工具缺失或版本不匹配直接失败。
|
||||
|
||||
---
|
||||
|
||||
## 4. 已验证结果
|
||||
|
||||
近期 Rust 侧复核已运行并通过:
|
||||
以下命令已于 2026-09-04 在本地工作区执行并通过:
|
||||
|
||||
```bash
|
||||
cargo fmt --check
|
||||
cargo test --offline --workspace --quiet
|
||||
cargo clippy --offline --workspace --all-targets -- -D warnings
|
||||
cargo test --offline -p bat-patch --quiet
|
||||
cargo test --offline -p bat-assetbundle --quiet
|
||||
cargo test --offline -p bat-infrastructure official_parse --quiet
|
||||
cargo test --offline -p bat-infrastructure dispatch_parse --quiet
|
||||
cargo test --offline -p bat-infrastructure localized_patch --quiet
|
||||
cargo fmt --all -- --check
|
||||
cargo check --workspace --locked
|
||||
cargo test --workspace --locked
|
||||
cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
```
|
||||
|
||||
本次 bat-api 侧复核已运行并通过:
|
||||
Go 与文档门禁:
|
||||
|
||||
```bash
|
||||
env GOCACHE=/tmp/bat-go-cache GOMODCACHE=/tmp/bat-go-modcache make test-go-api
|
||||
env GOCACHE=/tmp/bat-go-cache GOMODCACHE=/tmp/bat-go-modcache make build-go-api
|
||||
env GOCACHE=/tmp/bat-go-cache GOMODCACHE=/tmp/bat-go-modcache go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/...
|
||||
GOCACHE=/tmp/bat-go-cache GOMODCACHE=/tmp/bat-go-modcache make test-go-api
|
||||
GOCACHE=/tmp/bat-go-cache GOMODCACHE=/tmp/bat-go-modcache make build-go-api
|
||||
GOCACHE=/tmp/bat-go-cache GOMODCACHE=/tmp/bat-go-modcache go test ./...
|
||||
GOCACHE=/tmp/bat-go-cache GOMODCACHE=/tmp/bat-go-modcache go vet ./...
|
||||
make check-docs
|
||||
```
|
||||
|
||||
未执行 / 后置:
|
||||
当前仍未作为本地事实确认的项目包括:
|
||||
|
||||
- 真实官方全量 smoke 长期运行报告(G-018 命令已固化)。
|
||||
- `bat-api` 对远程长期运行 `bat` / 全量 release 的 SSH 联调(等连接信息)。
|
||||
- Web(G-010)。
|
||||
- 真实官方全量 smoke 长期运行报告;命令已固化为 `make official-smoke`。
|
||||
- `bat-api` 同机 live 联调:已由 `make bat-api-local-live-smoke` 在 `/tmp` 隔离目录完成;真实官方网络全量下载仍由 `make official-smoke` 独立跟踪。
|
||||
- 完整 Web 协作后台。
|
||||
|
||||
---
|
||||
|
||||
@@ -264,7 +324,7 @@ cargo run -p bat-infrastructure --bin bat -- \
|
||||
--watch
|
||||
```
|
||||
|
||||
资源 HTTP bootstrap / 只读分发入口是 Go `cmd/bat-api`。生产拓扑下它与 Rust `bat` 同环境运行,经 `bat.sock` RPC 获取当前 `resource_root`,不在配置里写死资源目录;本地开发不能全量跑 `bat` 时用 fixture 和 Go 门禁验证。`bat-api` 已补 launcher 资源引导兼容端点和玩家-facing HTTP 控制面(token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理面板预留),响应只来自已发布 snapshot/RPC,不提供登录、网关、鉴权或完整 package update manifest。
|
||||
资源 HTTP bootstrap / 只读分发入口是 Go `cmd/bat-api`。生产拓扑下它与 Rust `bat` 同环境运行,经 `bat.sock` RPC 获取 Rust 当前 official `release.attestation`,再按 release/publication/mapping/manifest identity 和 verification generation 绑定读取 `resource.manifest`,不在配置里写死资源目录;轻量 attestation 只读取 current、canonical versioned root、publication anchor、manifest 元数据和 freshness,不遍历历史 release 或计算资源文件 BLAKE3。Rust watch 周期负责 current 本地 manifest 验证并更新 attestation,默认 freshness window 为 `2 * 3600 + 60 = 7260` 秒;HTTP readiness 还要求 Go 分页快照完整且本地路径安全;本地开发不能全量跑 `bat` 时用 fixture 和 Go 门禁验证。`internal/api/testdata/contract/` 已固化来自 Rust 输出并经归一化的 `catalog.status`、`resource.manifest`、`official-sync-snapshot.json` 和 Glossary query contract fixture,Go mirror 测试会防止字段名、null 语义和 provenance 再次漂移;TM/Glossary 另有 Rust/Go 字段镜像测试覆盖 match、trust、translated text、term history 和 source provenance。`bat-api` 已补 launcher 资源引导兼容端点、玩家-facing HTTP 控制面和鉴权调度/translation/TM/Glossary 管理接口(token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单;`reload` / `refresh` / `restart` / `sync` / `verify` / `repair` / `catalog-refresh`、`schedule.*`、`task.*` 查询/取消、`daemon.logs`、`parse.*` 查询、`translation.tasks` / `translation.handoff` 查询、`translation.task.update`、`translation.worker.run`、`translation.proofread`、`translation.memory.summary/query/confirm/conflicts/resolve_conflict`、`translation.glossary.*`、`localized.publish` 和 `localized.rollback` 可经 dashboard/API 转发),响应只来自已发布 snapshot/RPC,不提供官方账号登录、游戏网关协议或完整 package update manifest。
|
||||
|
||||
生产要求:
|
||||
|
||||
@@ -278,34 +338,29 @@ cargo run -p bat-infrastructure --bin bat -- \
|
||||
|
||||
---
|
||||
|
||||
## 6. 当前阻塞项
|
||||
## 6. 当前开发基础与后续工作
|
||||
|
||||
GitHub issue 状态:#1 已关闭;#17 已按 wontfix 关闭(多线程下载入口已移除,下载回归顺序执行并保留指数退避与单调进度上报,子 issue #20–#23 均已关闭)。其他 open issue 的实时标签以 GitHub 为准。
|
||||
Issue 状态不作为本地实现状态的权威来源;本次复核未把远端 Issue 列表作为已验证事实。
|
||||
当前实现以源码、测试、稳定契约和本文件的模块状态为准。
|
||||
|
||||
下一阶段必须优先完成:
|
||||
当前非阻塞验证跟踪:
|
||||
|
||||
1. Issue #1(P0,主体已实现):`bat.sock` Unix socket JSON-RPC 已扩展为面向 Go 服务层的 Rust Resource Backend API。统一 envelope(`ok`、`status`、`error`、`data`、`request_id`)与 `BAT-ERR` 错误码模型已落地;`daemon.*`(status/logs/stop/reload/refresh/doctor)、`resource.*`(state/sync/verify/repair/manifest/list/index)、`parse.*`(status/text_units/errors)、`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.restart` / `daemon.clean-stable`(由 CLI 侧按进程生命周期显式执行,live RPC 内不做自重启或在线清理)、Redis 任务后端(`.env` 已预留配置键,接入时机另议)。Go 层通过 RPC 调用 Rust backend,不走 FFI(FFI 降级说明见 `docs/architecture/official-resource-backend.md` §7)。
|
||||
2. Go 侧:进度见 `docs/reports/GO_STATUS.md`。G-008 已关闭;`bat-api` 资源 bootstrap/分发 MVP 已落地,已含 `/v1/bootstrap`、`/v1/launcher/bootstrap`、launcher 资源 metadata 兼容、HTTP 鉴权/限流/日志/反代适配、动态 JSON no-store、OpenAPI、管理面板预留、CDN Range/缓存头、RPC 周期刷新和 USERGUIDE 基础章节;剩余为远程服务器全量 release 联调、Rust/Go snapshot contract fixture(用户审核后)、refresh mtime/size 增量缓存和可选持久化。
|
||||
3. 文本提取 / 翻译队列 / Patch 输入:`official-textunit-index.json`、`official-textunit-tasks.json` 与 `crowdin-textunit-queue.json` 已生成并可查询 TextUnit 明细;剩余为真实 Crowdin worker、翻译记忆、Patch 构建和翻译任务状态查询。
|
||||
4. Issue #3(P1):AssetBundle UnityFS 基础解析校验已具备离线和隔离真实样本覆盖;对象级解析继续跟踪 G-005。
|
||||
5. Issue #2(P1):继续逆向 Addressables catalog,提取 bundle hash/size/CRC 等可校验字段。
|
||||
6. 通用 Binary/JSON/Text Patch 基础已落地;复杂 AssetBundle 重打包和真实翻译系统仍应后置,UnityFS TextAsset patch 发布前置已具备回归测试。
|
||||
- 使用 `make official-smoke` 执行真实官方网络长期运行测试,并将报告留在隔离目录。
|
||||
|
||||
非阻塞跟踪项:官方同步长期运行测试正在进行,运行报告将在后续提供。
|
||||
后续工程顺序:
|
||||
|
||||
1. 继续复杂 AssetBundle:真实样本、复杂字段解析和发布级重打包。
|
||||
2. 继续通用 Patch:真实样本驱动的复杂 AssetBundle 兼容;双 release 查询、分发、rollback 边界和安全清理 V1 已完成。
|
||||
3. 继续资源查询和翻译基础设施:更丰富的查询和 Provider
|
||||
扩展体系。
|
||||
4. 在资源和翻译契约稳定后推进完整 Web 协作后台和完整游戏业务 API。
|
||||
|
||||
---
|
||||
|
||||
## 7. 下一步建议
|
||||
|
||||
立即任务:
|
||||
|
||||
1. Issue #1 收尾:协议基础设施、最小方法集、`catalog.*`、`parse.*`、`task.*`、`resource.repair`、文件级 `patch.apply` / `unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field`、任务持久化、错误码模型与文档均已完成;剩余发布级 `patch build`/`rollback`、复杂 `unityfs.*` 语义编辑以及 `task.create`、`daemon.restart`、`daemon.clean-stable` 的设计边界确认。
|
||||
2. `bat-api` 与远程长期运行的 `bat` / 全量 release 联调(含 `/v1/bootstrap`、`/v1/launcher/bootstrap`、server-info 和 CDN path;issue #19 剩余)。
|
||||
3. 跟进官方同步长期运行测试报告。
|
||||
4. AssetBundle / Addressables(issue #3 / #2);CAS 用户级导入(G-011)。
|
||||
|
||||
---
|
||||
|
||||
- **当前总体完成度**:不再固定写单一百分比,以各模块状态、`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 重打包未完成。
|
||||
- **下一工程里程碑**:bat-api 联调、真实 Crowdin worker / 翻译记忆、翻译任务状态查询、复杂 AssetBundle 解析和重打包。
|
||||
- **当前总体完成度**:不固定写单一百分比,以各模块状态、源码、测试和契约为准。
|
||||
- **当前基线状态**:Rust `bat` 同步闭环可用;Go `bat-api` 资源 bootstrap/分发 MVP、
|
||||
HTTP 控制面、launcher 资源引导兼容、RPC 周期刷新/诊断、readiness、内嵌 dashboard
|
||||
和 `backendrpc` 可用;CAS 用户级导入、TextUnit 明细索引/查询、增量离线队列、
|
||||
通用 Binary/JSON/Text Patch 基础、generic manifest 和受支持 localized patch 发布/rollback 可用;
|
||||
复杂 AssetBundle 重打包、完整 Web 协作后台、模糊 TM 匹配和更高阶 release retention 未完成;双 release 运维 V1 已完成。
|
||||
- **下一工程里程碑**:复杂 AssetBundle 解析和重打包,以及真实官方资源长期运行验证。
|
||||
|
||||
Generated
+2
@@ -103,6 +103,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"blake3",
|
||||
"hex",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
@@ -118,6 +119,7 @@ version = "1.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"blake3",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
# Design System Inspiration of Linear
|
||||
|
||||
## 1. Visual Theme & Atmosphere
|
||||
|
||||
Linear's website is a masterclass in dark-mode-first product design — a near-black canvas (`#08090a`) where content emerges from darkness like starlight. The overall impression is one of extreme precision engineering: every element exists in a carefully calibrated hierarchy of luminance, from barely-visible borders (`rgba(255,255,255,0.05)`) to soft, luminous text (`#f7f8f8`). This is not a dark theme applied to a light design — it is darkness as the native medium, where information density is managed through subtle gradations of white opacity rather than color variation.
|
||||
|
||||
The typography system is built entirely on Inter Variable with OpenType features `"cv01"` and `"ss03"` enabled globally, giving the typeface a cleaner, more geometric character. Inter is used at a remarkable range of weights — from 300 (light body) through 510 (medium, Linear's signature weight) to 590 (semibold emphasis). The 510 weight is particularly distinctive: it sits between regular and medium, creating a subtle emphasis that doesn't shout. At display sizes (72px, 64px, 48px), Inter uses aggressive negative letter-spacing (-1.584px to -1.056px), creating compressed, authoritative headlines that feel engineered rather than designed. Berkeley Mono serves as the monospace companion for code and technical labels, with fallbacks to ui-monospace, SF Mono, and Menlo.
|
||||
|
||||
The color system is almost entirely achromatic — dark backgrounds with white/gray text — punctuated by a single brand accent: Linear's signature indigo-violet (`#5e6ad2` for backgrounds, `#7170ff` for interactive accents). This accent color is used sparingly and intentionally, appearing only on CTAs, active states, and brand elements. The border system uses ultra-thin, semi-transparent white borders (`rgba(255,255,255,0.05)` to `rgba(255,255,255,0.08)`) that create structure without visual noise, like wireframes drawn in moonlight.
|
||||
|
||||
**Key Characteristics:**
|
||||
- Dark-mode-native: `#08090a` marketing background, `#0f1011` panel background, `#191a1b` elevated surfaces
|
||||
- Inter Variable with `"cv01", "ss03"` globally — geometric alternates for a cleaner aesthetic
|
||||
- Signature weight 510 (between regular and medium) for most UI text
|
||||
- Aggressive negative letter-spacing at display sizes (-1.584px at 72px, -1.056px at 48px)
|
||||
- Brand indigo-violet: `#5e6ad2` (bg) / `#7170ff` (accent) / `#828fff` (hover) — the only chromatic color in the system
|
||||
- Semi-transparent white borders throughout: `rgba(255,255,255,0.05)` to `rgba(255,255,255,0.08)`
|
||||
- Button backgrounds at near-zero opacity: `rgba(255,255,255,0.02)` to `rgba(255,255,255,0.05)`
|
||||
- Multi-layered shadows with inset variants for depth on dark surfaces
|
||||
- Radix UI primitives as the component foundation (6 detected primitives)
|
||||
- Success green (`#27a644`, `#10b981`) used only for status indicators
|
||||
|
||||
## 2. Color Palette & Roles
|
||||
|
||||
### Background Surfaces
|
||||
- **Marketing Black** (`#010102` / `#08090a`): The deepest background — the canvas for hero sections and marketing pages. Near-pure black with an imperceptible blue-cool undertone.
|
||||
- **Panel Dark** (`#0f1011`): Sidebar and panel backgrounds. One step up from the marketing black.
|
||||
- **Level 3 Surface** (`#191a1b`): Elevated surface areas, card backgrounds, dropdowns.
|
||||
- **Secondary Surface** (`#28282c`): The lightest dark surface — used for hover states and slightly elevated components.
|
||||
|
||||
### Text & Content
|
||||
- **Primary Text** (`#f7f8f8`): Near-white with a barely-warm cast. The default text color — not pure white, preventing eye strain on dark backgrounds.
|
||||
- **Secondary Text** (`#d0d6e0`): Cool silver-gray for body text, descriptions, and secondary content.
|
||||
- **Tertiary Text** (`#8a8f98`): Muted gray for placeholders, metadata, and de-emphasized content.
|
||||
- **Quaternary Text** (`#62666d`): The most subdued text — timestamps, disabled states, subtle labels.
|
||||
|
||||
### Brand & Accent
|
||||
- **Brand Indigo** (`#5e6ad2`): Primary brand color — used for CTA button backgrounds, brand marks, and key interactive surfaces.
|
||||
- **Accent Violet** (`#7170ff`): Brighter variant for interactive elements — links, active states, selected items.
|
||||
- **Accent Hover** (`#828fff`): Lighter, more saturated variant for hover states on accent elements.
|
||||
- **Security Lavender** (`#7a7fad`): Muted indigo used specifically for security-related UI elements.
|
||||
|
||||
### Status Colors
|
||||
- **Green** (`#27a644`): Primary success/active status. Used for "in progress" indicators.
|
||||
- **Emerald** (`#10b981`): Secondary success — pill badges, completion states.
|
||||
|
||||
### Border & Divider
|
||||
- **Border Primary** (`#23252a`): Solid dark border for prominent separations.
|
||||
- **Border Secondary** (`#34343a`): Slightly lighter solid border.
|
||||
- **Border Tertiary** (`#3e3e44`): Lightest solid border variant.
|
||||
- **Border Subtle** (`rgba(255,255,255,0.05)`): Ultra-subtle semi-transparent border — the default.
|
||||
- **Border Standard** (`rgba(255,255,255,0.08)`): Standard semi-transparent border for cards, inputs, code blocks.
|
||||
- **Line Tint** (`#141516`): Nearly invisible line for the subtlest divisions.
|
||||
- **Line Tertiary** (`#18191a`): Slightly more visible divider line.
|
||||
|
||||
### Light Mode Neutrals (for light theme contexts)
|
||||
- **Light Background** (`#f7f8f8`): Page background in light mode.
|
||||
- **Light Surface** (`#f3f4f5` / `#f5f6f7`): Subtle surface tinting.
|
||||
- **Light Border** (`#d0d6e0`): Visible border in light contexts.
|
||||
- **Light Border Alt** (`#e6e6e6`): Alternative lighter border.
|
||||
- **Pure White** (`#ffffff`): Card surfaces, highlights.
|
||||
|
||||
### Overlay
|
||||
- **Overlay Primary** (`rgba(0,0,0,0.85)`): Modal/dialog backdrop — extremely dark for focus isolation.
|
||||
|
||||
## 3. Typography Rules
|
||||
|
||||
### Font Family
|
||||
- **Primary**: `Inter Variable`, with fallbacks: `SF Pro Display, -apple-system, system-ui, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Open Sans, Helvetica Neue`
|
||||
- **Monospace**: `Berkeley Mono`, with fallbacks: `ui-monospace, SF Mono, Menlo`
|
||||
- **OpenType Features**: `"cv01", "ss03"` enabled globally — cv01 provides an alternate lowercase 'a' (single-story), ss03 adjusts specific letterforms for a cleaner geometric appearance.
|
||||
|
||||
### Hierarchy
|
||||
|
||||
| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes |
|
||||
|------|------|------|--------|-------------|----------------|-------|
|
||||
| Display XL | Inter Variable | 72px (4.50rem) | 510 | 1.00 (tight) | -1.584px | Hero headlines, maximum impact |
|
||||
| Display Large | Inter Variable | 64px (4.00rem) | 510 | 1.00 (tight) | -1.408px | Secondary hero text |
|
||||
| Display | Inter Variable | 48px (3.00rem) | 510 | 1.00 (tight) | -1.056px | Section headlines |
|
||||
| Heading 1 | Inter Variable | 32px (2.00rem) | 400 | 1.13 (tight) | -0.704px | Major section titles |
|
||||
| Heading 2 | Inter Variable | 24px (1.50rem) | 400 | 1.33 | -0.288px | Sub-section headings |
|
||||
| Heading 3 | Inter Variable | 20px (1.25rem) | 590 | 1.33 | -0.24px | Feature titles, card headers |
|
||||
| Body Large | Inter Variable | 18px (1.13rem) | 400 | 1.60 (relaxed) | -0.165px | Introduction text, feature descriptions |
|
||||
| Body Emphasis | Inter Variable | 17px (1.06rem) | 590 | 1.60 (relaxed) | normal | Emphasized body, sub-headings in content |
|
||||
| Body | Inter Variable | 16px (1.00rem) | 400 | 1.50 | normal | Standard reading text |
|
||||
| Body Medium | Inter Variable | 16px (1.00rem) | 510 | 1.50 | normal | Navigation, labels |
|
||||
| Body Semibold | Inter Variable | 16px (1.00rem) | 590 | 1.50 | normal | Strong emphasis |
|
||||
| Small | Inter Variable | 15px (0.94rem) | 400 | 1.60 (relaxed) | -0.165px | Secondary body text |
|
||||
| Small Medium | Inter Variable | 15px (0.94rem) | 510 | 1.60 (relaxed) | -0.165px | Emphasized small text |
|
||||
| Small Semibold | Inter Variable | 15px (0.94rem) | 590 | 1.60 (relaxed) | -0.165px | Strong small text |
|
||||
| Small Light | Inter Variable | 15px (0.94rem) | 300 | 1.47 | -0.165px | De-emphasized body |
|
||||
| Caption Large | Inter Variable | 14px (0.88rem) | 510–590 | 1.50 | -0.182px | Sub-labels, category headers |
|
||||
| Caption | Inter Variable | 13px (0.81rem) | 400–510 | 1.50 | -0.13px | Metadata, timestamps |
|
||||
| Label | Inter Variable | 12px (0.75rem) | 400–590 | 1.40 | normal | Button text, small labels |
|
||||
| Micro | Inter Variable | 11px (0.69rem) | 510 | 1.40 | normal | Tiny labels |
|
||||
| Tiny | Inter Variable | 10px (0.63rem) | 400–510 | 1.50 | -0.15px | Overline text, sometimes uppercase |
|
||||
| Link Large | Inter Variable | 16px (1.00rem) | 400 | 1.50 | normal | Standard links |
|
||||
| Link Medium | Inter Variable | 15px (0.94rem) | 510 | 2.67 | normal | Spaced navigation links |
|
||||
| Link Small | Inter Variable | 14px (0.88rem) | 510 | 1.50 | normal | Compact links |
|
||||
| Link Caption | Inter Variable | 13px (0.81rem) | 400–510 | 1.50 | -0.13px | Footer, metadata links |
|
||||
| Mono Body | Berkeley Mono | 14px (0.88rem) | 400 | 1.50 | normal | Code blocks |
|
||||
| Mono Caption | Berkeley Mono | 13px (0.81rem) | 400 | 1.50 | normal | Code labels |
|
||||
| Mono Label | Berkeley Mono | 12px (0.75rem) | 400 | 1.40 | normal | Code metadata, sometimes uppercase |
|
||||
|
||||
### Principles
|
||||
- **510 is the signature weight**: Linear uses Inter Variable's 510 weight (between regular 400 and medium 500) as its default emphasis weight. This creates a subtly bolded feel without the heaviness of traditional medium or semibold.
|
||||
- **Compression at scale**: Display sizes use progressively tighter letter-spacing — -1.584px at 72px, -1.408px at 64px, -1.056px at 48px, -0.704px at 32px. Below 24px, spacing relaxes toward normal.
|
||||
- **OpenType as identity**: `"cv01", "ss03"` aren't decorative — they transform Inter into Linear's distinctive typeface, giving it a more geometric, purposeful character.
|
||||
- **Three-tier weight system**: 400 (reading), 510 (emphasis/UI), 590 (strong emphasis). The 300 weight appears only in deliberately de-emphasized contexts.
|
||||
|
||||
## 4. Component Stylings
|
||||
|
||||
### Buttons
|
||||
|
||||
**Ghost Button (Default)**
|
||||
- Background: `rgba(255,255,255,0.02)`
|
||||
- Text: `#e2e4e7` (near-white)
|
||||
- Padding: comfortable
|
||||
- Radius: 6px
|
||||
- Border: `1px solid rgb(36, 40, 44)`
|
||||
- Outline: none
|
||||
- Focus shadow: `rgba(0,0,0,0.1) 0px 4px 12px`
|
||||
- Use: Standard actions, secondary CTAs
|
||||
|
||||
**Subtle Button**
|
||||
- Background: `rgba(255,255,255,0.04)`
|
||||
- Text: `#d0d6e0` (silver-gray)
|
||||
- Padding: 0px 6px
|
||||
- Radius: 6px
|
||||
- Use: Toolbar actions, contextual buttons
|
||||
|
||||
**Primary Brand Button (Inferred)**
|
||||
- Background: `#5e6ad2` (brand indigo)
|
||||
- Text: `#ffffff`
|
||||
- Padding: 8px 16px
|
||||
- Radius: 6px
|
||||
- Hover: `#828fff` shift
|
||||
- Use: Primary CTAs ("Start building", "Sign up")
|
||||
|
||||
**Icon Button (Circle)**
|
||||
- Background: `rgba(255,255,255,0.03)` or `rgba(255,255,255,0.05)`
|
||||
- Text: `#f7f8f8` or `#ffffff`
|
||||
- Radius: 50%
|
||||
- Border: `1px solid rgba(255,255,255,0.08)`
|
||||
- Use: Close, menu toggle, icon-only actions
|
||||
|
||||
**Pill Button**
|
||||
- Background: transparent
|
||||
- Text: `#d0d6e0`
|
||||
- Padding: 0px 10px 0px 5px
|
||||
- Radius: 9999px
|
||||
- Border: `1px solid rgb(35, 37, 42)`
|
||||
- Use: Filter chips, tags, status indicators
|
||||
|
||||
**Small Toolbar Button**
|
||||
- Background: `rgba(255,255,255,0.05)`
|
||||
- Text: `#62666d` (muted)
|
||||
- Radius: 2px
|
||||
- Border: `1px solid rgba(255,255,255,0.05)`
|
||||
- Shadow: `rgba(0,0,0,0.03) 0px 1.2px 0px 0px`
|
||||
- Font: 12px weight 510
|
||||
- Use: Toolbar actions, quick-access controls
|
||||
|
||||
### Cards & Containers
|
||||
- Background: `rgba(255,255,255,0.02)` to `rgba(255,255,255,0.05)` (never solid — always translucent)
|
||||
- Border: `1px solid rgba(255,255,255,0.08)` (standard) or `1px solid rgba(255,255,255,0.05)` (subtle)
|
||||
- Radius: 8px (standard), 12px (featured), 22px (large panels)
|
||||
- Shadow: `rgba(0,0,0,0.2) 0px 0px 0px 1px` or layered multi-shadow stacks
|
||||
- Hover: subtle background opacity increase
|
||||
|
||||
### Inputs & Forms
|
||||
|
||||
**Text Area**
|
||||
- Background: `rgba(255,255,255,0.02)`
|
||||
- Text: `#d0d6e0`
|
||||
- Border: `1px solid rgba(255,255,255,0.08)`
|
||||
- Padding: 12px 14px
|
||||
- Radius: 6px
|
||||
|
||||
**Search Input**
|
||||
- Background: transparent
|
||||
- Text: `#f7f8f8`
|
||||
- Padding: 1px 32px (icon-aware)
|
||||
|
||||
**Button-style Input**
|
||||
- Text: `#8a8f98`
|
||||
- Padding: 1px 6px
|
||||
- Radius: 5px
|
||||
- Focus shadow: multi-layer stack
|
||||
|
||||
### Badges & Pills
|
||||
|
||||
**Success Pill**
|
||||
- Background: `#10b981`
|
||||
- Text: `#f7f8f8`
|
||||
- Radius: 50% (circular)
|
||||
- Font: 10px weight 510
|
||||
- Use: Status dots, completion indicators
|
||||
|
||||
**Neutral Pill**
|
||||
- Background: transparent
|
||||
- Text: `#d0d6e0`
|
||||
- Padding: 0px 10px 0px 5px
|
||||
- Radius: 9999px
|
||||
- Border: `1px solid rgb(35, 37, 42)`
|
||||
- Font: 12px weight 510
|
||||
- Use: Tags, filter chips, category labels
|
||||
|
||||
**Subtle Badge**
|
||||
- Background: `rgba(255,255,255,0.05)`
|
||||
- Text: `#f7f8f8`
|
||||
- Padding: 0px 8px 0px 2px
|
||||
- Radius: 2px
|
||||
- Border: `1px solid rgba(255,255,255,0.05)`
|
||||
- Font: 10px weight 510
|
||||
- Use: Inline labels, version tags
|
||||
|
||||
### Navigation
|
||||
- Dark sticky header on near-black background
|
||||
- Linear logomark left-aligned (SVG icon)
|
||||
- Links: Inter Variable 13–14px weight 510, `#d0d6e0` text
|
||||
- Active/hover: text lightens to `#f7f8f8`
|
||||
- CTA: Brand indigo button or ghost button
|
||||
- Mobile: hamburger collapse
|
||||
- Search: command palette trigger (`/` or `Cmd+K`)
|
||||
|
||||
### Image Treatment
|
||||
- Product screenshots on dark backgrounds with subtle border (`rgba(255,255,255,0.08)`)
|
||||
- Top-rounded images: `12px 12px 0px 0px` radius
|
||||
- Dashboard/issue previews dominate feature sections
|
||||
- Subtle shadow beneath screenshots: `rgba(0,0,0,0.4) 0px 2px 4px`
|
||||
|
||||
## 5. Layout Principles
|
||||
|
||||
### Spacing System
|
||||
- Base unit: 8px
|
||||
- Scale: 1px, 4px, 7px, 8px, 11px, 12px, 16px, 19px, 20px, 22px, 24px, 28px, 32px, 35px
|
||||
- The 7px and 11px values suggest micro-adjustments for optical alignment
|
||||
- Primary rhythm: 8px, 16px, 24px, 32px (standard 8px grid)
|
||||
|
||||
### Grid & Container
|
||||
- Max content width: approximately 1200px
|
||||
- Hero: centered single-column with generous vertical padding
|
||||
- Feature sections: 2–3 column grids for feature cards
|
||||
- Full-width dark sections with internal max-width constraints
|
||||
- Changelog: single-column timeline layout
|
||||
|
||||
### Whitespace Philosophy
|
||||
- **Darkness as space**: On Linear's dark canvas, empty space isn't white — it's absence. The near-black background IS the whitespace, and content emerges from it.
|
||||
- **Compressed headlines, expanded surroundings**: Display text at 72px with -1.584px tracking is dense and compressed, but sits within vast dark padding. The contrast between typographic density and spatial generosity creates tension.
|
||||
- **Section isolation**: Each feature section is separated by generous vertical padding (80px+) with no visible dividers — the dark background provides natural separation.
|
||||
|
||||
### Border Radius Scale
|
||||
- Micro (2px): Inline badges, toolbar buttons, subtle tags
|
||||
- Standard (4px): Small containers, list items
|
||||
- Comfortable (6px): Buttons, inputs, functional elements
|
||||
- Card (8px): Cards, dropdowns, popovers
|
||||
- Panel (12px): Panels, featured cards, section containers
|
||||
- Large (22px): Large panel elements
|
||||
- Full Pill (9999px): Chips, filter pills, status tags
|
||||
- Circle (50%): Icon buttons, avatars, status dots
|
||||
|
||||
## 6. Depth & Elevation
|
||||
|
||||
| Level | Treatment | Use |
|
||||
|-------|-----------|-----|
|
||||
| Flat (Level 0) | No shadow, `#010102` bg | Page background, deepest canvas |
|
||||
| Subtle (Level 1) | `rgba(0,0,0,0.03) 0px 1.2px 0px` | Toolbar buttons, micro-elevation |
|
||||
| Surface (Level 2) | `rgba(255,255,255,0.05)` bg + `1px solid rgba(255,255,255,0.08)` border | Cards, input fields, containers |
|
||||
| Inset (Level 2b) | `rgba(0,0,0,0.2) 0px 0px 12px 0px inset` | Recessed panels, inner shadows |
|
||||
| Ring (Level 3) | `rgba(0,0,0,0.2) 0px 0px 0px 1px` | Border-as-shadow technique |
|
||||
| Elevated (Level 4) | `rgba(0,0,0,0.4) 0px 2px 4px` | Floating elements, dropdowns |
|
||||
| Dialog (Level 5) | Multi-layer stack: `rgba(0,0,0,0) 0px 8px 2px, rgba(0,0,0,0.01) 0px 5px 2px, rgba(0,0,0,0.04) 0px 3px 2px, rgba(0,0,0,0.07) 0px 1px 1px, rgba(0,0,0,0.08) 0px 0px 1px` | Popovers, command palette, modals |
|
||||
| Focus | `rgba(0,0,0,0.1) 0px 4px 12px` + additional layers | Keyboard focus on interactive elements |
|
||||
|
||||
**Shadow Philosophy**: On dark surfaces, traditional shadows (dark on dark) are nearly invisible. Linear solves this by using semi-transparent white borders as the primary depth indicator. Elevation isn't communicated through shadow darkness but through background luminance steps — each level slightly increases the white opacity of the surface background (`0.02` → `0.04` → `0.05`), creating a subtle stacking effect. The inset shadow technique (`rgba(0,0,0,0.2) 0px 0px 12px 0px inset`) creates a unique "sunken" effect for recessed panels, adding dimensional depth that traditional dark themes lack.
|
||||
|
||||
## 7. Do's and Don'ts
|
||||
|
||||
### Do
|
||||
- Use Inter Variable with `"cv01", "ss03"` on ALL text — these features are fundamental to Linear's typeface identity
|
||||
- Use weight 510 as your default emphasis weight — it's Linear's signature between-weight
|
||||
- Apply aggressive negative letter-spacing at display sizes (-1.584px at 72px, -1.056px at 48px)
|
||||
- Build on near-black backgrounds: `#08090a` for marketing, `#0f1011` for panels, `#191a1b` for elevated surfaces
|
||||
- Use semi-transparent white borders (`rgba(255,255,255,0.05)` to `rgba(255,255,255,0.08)`) instead of solid dark borders
|
||||
- Keep button backgrounds nearly transparent: `rgba(255,255,255,0.02)` to `rgba(255,255,255,0.05)`
|
||||
- Reserve brand indigo (`#5e6ad2` / `#7170ff`) for primary CTAs and interactive accents only
|
||||
- Use `#f7f8f8` for primary text — not pure `#ffffff`, which would be too harsh
|
||||
- Apply the luminance stacking model: deeper = darker bg, elevated = slightly lighter bg
|
||||
|
||||
### Don't
|
||||
- Don't use pure white (`#ffffff`) as primary text — `#f7f8f8` prevents eye strain
|
||||
- Don't use solid colored backgrounds for buttons — transparency is the system (rgba white at 0.02–0.05)
|
||||
- Don't apply the brand indigo decoratively — it's reserved for interactive/CTA elements only
|
||||
- Don't use positive letter-spacing on display text — Inter at large sizes always runs negative
|
||||
- Don't use visible/opaque borders on dark backgrounds — borders should be whisper-thin semi-transparent white
|
||||
- Don't skip the OpenType features (`"cv01", "ss03"`) — without them, it's generic Inter, not Linear's Inter
|
||||
- Don't use weight 700 (bold) — Linear's maximum weight is 590, with 510 as the workhorse
|
||||
- Don't introduce warm colors into the UI chrome — the palette is cool gray with blue-violet accent only
|
||||
- Don't use drop shadows for elevation on dark surfaces — use background luminance stepping instead
|
||||
|
||||
## 8. Responsive Behavior
|
||||
|
||||
### Breakpoints
|
||||
| Name | Width | Key Changes |
|
||||
|------|-------|-------------|
|
||||
| Mobile Small | <600px | Single column, compact padding |
|
||||
| Mobile | 600–640px | Standard mobile layout |
|
||||
| Tablet | 640–768px | Two-column grids begin |
|
||||
| Desktop Small | 768–1024px | Full card grids, expanded padding |
|
||||
| Desktop | 1024–1280px | Standard desktop, full navigation |
|
||||
| Large Desktop | >1280px | Full layout, generous margins |
|
||||
|
||||
### Touch Targets
|
||||
- Buttons use comfortable padding with 6px radius minimum
|
||||
- Navigation links at 13–14px with adequate spacing
|
||||
- Pill tags have 10px horizontal padding for touch accessibility
|
||||
- Icon buttons at 50% radius ensure circular, easy-to-tap targets
|
||||
- Search trigger is prominently placed with generous hit area
|
||||
|
||||
### Collapsing Strategy
|
||||
- Hero: 72px → 48px → 32px display text, tracking adjusts proportionally
|
||||
- Navigation: horizontal links + CTAs → hamburger menu at 768px
|
||||
- Feature cards: 3-column → 2-column → single column stacked
|
||||
- Product screenshots: maintain aspect ratio, may reduce padding
|
||||
- Changelog: timeline maintains single-column through all sizes
|
||||
- Footer: multi-column → stacked single column
|
||||
- Section spacing: 80px+ → 48px on mobile
|
||||
|
||||
### Image Behavior
|
||||
- Dashboard screenshots maintain border treatment at all sizes
|
||||
- Hero visuals simplify on mobile (fewer floating UI elements)
|
||||
- Product screenshots use responsive sizing with consistent radius
|
||||
- Dark background ensures screenshots blend naturally at any viewport
|
||||
|
||||
## 9. Agent Prompt Guide
|
||||
|
||||
### Quick Color Reference
|
||||
- Primary CTA: Brand Indigo (`#5e6ad2`)
|
||||
- Page Background: Marketing Black (`#08090a`)
|
||||
- Panel Background: Panel Dark (`#0f1011`)
|
||||
- Surface: Level 3 (`#191a1b`)
|
||||
- Heading text: Primary White (`#f7f8f8`)
|
||||
- Body text: Silver Gray (`#d0d6e0`)
|
||||
- Muted text: Tertiary Gray (`#8a8f98`)
|
||||
- Subtle text: Quaternary Gray (`#62666d`)
|
||||
- Accent: Violet (`#7170ff`)
|
||||
- Accent Hover: Light Violet (`#828fff`)
|
||||
- Border (default): `rgba(255,255,255,0.08)`
|
||||
- Border (subtle): `rgba(255,255,255,0.05)`
|
||||
- Focus ring: Multi-layer shadow stack
|
||||
|
||||
### Example Component Prompts
|
||||
- "Create a hero section on `#08090a` background. Headline at 48px Inter Variable weight 510, line-height 1.00, letter-spacing -1.056px, color `#f7f8f8`, font-feature-settings `'cv01', 'ss03'`. Subtitle at 18px weight 400, line-height 1.60, color `#8a8f98`. Brand CTA button (`#5e6ad2`, 6px radius, 8px 16px padding) and ghost button (`rgba(255,255,255,0.02)` bg, `1px solid rgba(255,255,255,0.08)` border, 6px radius)."
|
||||
- "Design a card on dark background: `rgba(255,255,255,0.02)` background, `1px solid rgba(255,255,255,0.08)` border, 8px radius. Title at 20px Inter Variable weight 590, letter-spacing -0.24px, color `#f7f8f8`. Body at 15px weight 400, color `#8a8f98`, letter-spacing -0.165px."
|
||||
- "Build a pill badge: transparent background, `#d0d6e0` text, 9999px radius, 0px 10px padding, `1px solid #23252a` border, 12px Inter Variable weight 510."
|
||||
- "Create navigation: dark sticky header on `#0f1011`. Inter Variable 13px weight 510 for links, `#d0d6e0` text. Brand indigo CTA `#5e6ad2` right-aligned with 6px radius. Bottom border: `1px solid rgba(255,255,255,0.05)`."
|
||||
- "Design a command palette: `#191a1b` background, `1px solid rgba(255,255,255,0.08)` border, 12px radius, multi-layer shadow stack. Input at 16px Inter Variable weight 400, `#f7f8f8` text. Results list with 13px weight 510 labels in `#d0d6e0` and 12px metadata in `#62666d`."
|
||||
|
||||
### Iteration Guide
|
||||
1. Always set font-feature-settings `"cv01", "ss03"` on all Inter text — this is non-negotiable for Linear's look
|
||||
2. Letter-spacing scales with font size: -1.584px at 72px, -1.056px at 48px, -0.704px at 32px, normal below 16px
|
||||
3. Three weights: 400 (read), 510 (emphasize/navigate), 590 (announce)
|
||||
4. Surface elevation via background opacity: `rgba(255,255,255, 0.02 → 0.04 → 0.05)` — never solid backgrounds on dark
|
||||
5. Brand indigo (`#5e6ad2` / `#7170ff`) is the only chromatic color — everything else is grayscale
|
||||
6. Borders are always semi-transparent white, never solid dark colors on dark backgrounds
|
||||
7. Berkeley Mono for any code or technical content, Inter Variable for everything else
|
||||
+136
-93
@@ -1,120 +1,163 @@
|
||||
# BlueArchiveToolkit 文档索引
|
||||
# BlueArchive Toolkit 文档分类索引
|
||||
|
||||
- **更新时间**:2026-07-24
|
||||
- **说明**:本索引用于快速定位当前权威文档和历史资料。
|
||||
- **更新时间**:2026-09-13
|
||||
- **用途**:按用途、时效性和权威级别定位文档。
|
||||
- **原则**:目录是物理归档方式,不能单独代表文档权威性;当前源码、测试和下列当前文档优先于历史报告。
|
||||
|
||||
---
|
||||
## 1. 项目入口与协作规则
|
||||
|
||||
## 1. 权威入口
|
||||
这些文件位于仓库根目录,是项目级入口或协作规则:
|
||||
|
||||
- `README.md`:项目概览、当前能力和快速开始。
|
||||
- `USERGUIDE.md`:`bat` 用户指南、命令、配置、错误码和常用 RPC 说明。
|
||||
- `CURRENT_STATUS.md`:当前实现状态,使用源码和测试复核后维护。
|
||||
- `PROJECT_PLAN.md`:长期目标、里程碑和后续路线图。
|
||||
- `CONTRIBUTING.md`:贡献流程、提交规范和验证要求。
|
||||
- `CHANGELOG.md`:版本变更记录,不作为当前实现的唯一依据。
|
||||
- `CLAUDE.md`:旧工具兼容入口,不承载独立规则。
|
||||
- `AGENTS.md`:AI agent 长期协作规则。
|
||||
- `TODO.md`:具体工程任务、优先级、依赖与完成条件的仓库内任务账本;不作为当前实现事实来源。
|
||||
- `DESIGN.md`:Dashboard 的主要视觉参考与设计灵感来源;涉及 Dashboard/Web UI/布局/视觉/组件/交互任务时必须先阅读。
|
||||
|
||||
## 2. 当前状态、计划与缺口
|
||||
|
||||
这些文件描述当前项目,不应写入未经源码或测试证明的完成状态:
|
||||
|
||||
- `CURRENT_STATUS.md`:全项目当前状态总览。
|
||||
- `docs/reports/GO_STATUS.md`:Go `bat-api` 边界和组件进度的权威文档。
|
||||
- `docs/reports/CURRENT_GAPS.md`:当前缺口、影响和推进顺序。
|
||||
- `PROJECT_PLAN.md`:目标和路线图;其中的计划项不等于已实现。
|
||||
- `TODO.md`:当前可执行工程任务、优先级、依赖与验收条件;条目状态不高于源码、测试和 current-status 文档。
|
||||
- `docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md`:Rust 输出、Go contract fixture 和联调的当前交接说明。
|
||||
|
||||
## 3. 架构、决策与稳定契约
|
||||
|
||||
### 3.1 架构总览和专题
|
||||
|
||||
- `docs/architecture/README.md`:总体架构和目标边界;当前实现以 `CURRENT_STATUS.md` 为准。
|
||||
- `docs/architecture/official-resource-backend.md`:官方资源发现、清单、下载、发布和导入边界。
|
||||
- `docs/architecture/resource-release-layout.md`:release 目录、URL 映射、seed 和 `bat-api` 分发契约。
|
||||
- `docs/architecture/assetbundle.md`:Addressables、UnityFS、Serialized File、TextUnit、CAS 和 Patch 的解析路线图。
|
||||
|
||||
### 3.2 架构决策记录
|
||||
|
||||
- `docs/architecture/adr/0001-engine-and-application-boundaries.md`:Rust 引擎与 Go 应用层边界。
|
||||
- `docs/architecture/adr/0002-cas-v1-design-boundary.md`:CAS V1 设计边界。
|
||||
- `docs/architecture/adr/0003-cas-core-interface-and-error-boundary.md`:CAS 核心接口和错误边界。
|
||||
- `docs/architecture/adr/0004-rust-bat-go-bat-api-resource-boundary.md`:当前 Rust `bat` 与 Go `bat-api` 资源控制面边界。
|
||||
|
||||
### 3.3 对外接口规范
|
||||
|
||||
- `README.md`:项目概览、当前可用能力和快速开始。
|
||||
- `PROJECT_PLAN.md`:完整开发计划和最终目标路线图。
|
||||
- `CURRENT_STATUS.md`:当前工作区真实状态。
|
||||
- `docs/reports/CURRENT_GAPS.md`:当前实现缺口和关闭顺序。
|
||||
- `docs/reports/GO_STATUS.md`:Go 侧边界、约定与组件进度(权威)。
|
||||
- `docs/guides/official-resource-test-pull.md`:官方资源拉取与自动更新用户指南。
|
||||
- `docs/guides/official-full-pull-smoke.md`:真实官方全量拉取 smoke runbook 和可重复命令。
|
||||
- `docs/architecture/official-resource-backend.md`:官方资源后端职责、工作原理和审核说明。
|
||||
- `docs/architecture/resource-release-layout.md`:release 布局、URL 映射、seed 规则、bat-api 分发契约(资源侧逆向权威)。
|
||||
- `docs/architecture/assetbundle.md`:AssetBundle、Addressables、Serialized File、文本提取和 Patch 前置解析路线图。
|
||||
- `docs/reference/rpc-backend-api.md`:Rust Resource Backend JSON-RPC 稳定 contract。
|
||||
- `CHANGELOG.md`:版本变更记录。
|
||||
- `AGENTS.md`:AI agent 和自动化开发助手长期规则。
|
||||
- `CONTRIBUTING.md`:贡献者协作、提交和验证要求。
|
||||
- `CLAUDE.md`:Claude Code 等旧工具的兼容入口。
|
||||
- `api/openapi/bat-api.yaml`:`bat-api` HTTP OpenAPI 静态规范。
|
||||
- `docs/api/README.md`:API 文档入口及规范索引。
|
||||
|
||||
---
|
||||
契约文档涉及字段、状态码、错误码、release layout 或路径语义时,必须与源码测试和 `internal/api/testdata/contract/` 一起复核。
|
||||
|
||||
## 2. 架构与指南
|
||||
|
||||
- `docs/architecture/README.md`:总体架构设计。
|
||||
- `docs/api/README.md`:API 设计入口。
|
||||
- `docs/reference/rpc-backend-api.md`:Rust Resource Backend JSON-RPC 稳定 contract。
|
||||
- `docs/guides/development.md`:开发指南。
|
||||
- `docs/guides/deployment.md`:部署指南。
|
||||
- `deployments/systemd/`:官方资源同步生产 systemd unit 和环境文件示例。
|
||||
- `docs/guides/official-resource-test-pull.md`:官方资源拉取与自动更新用户指南。
|
||||
- `docs/guides/official-full-pull-smoke.md`:真实官方全量拉取 smoke runbook 和可重复命令。
|
||||
- `docs/guides/baseline.md`:稳定工程基线指南。
|
||||
- `docs/architecture/adr/0001-engine-and-application-boundaries.md`:Rust/Go 边界决策。
|
||||
- `docs/architecture/adr/0002-cas-v1-design-boundary.md`:CAS V1 边界决策。
|
||||
- `docs/architecture/adr/0003-cas-core-interface-and-error-boundary.md`:CAS 核心接口和错误边界冻结。
|
||||
### 3.4 Dashboard 设计参考
|
||||
|
||||
后续建议新增:
|
||||
- `DESIGN.md`:用户 Dashboard 与运营 Dashboard 的主要视觉参考和设计灵感来源,描述应延续的色彩关系、排版、空间、边框、层级、组件形态和交互气质。它不定义后端事实、权限或业务状态,也不要求复制参考来源的页面结构或品牌内容。
|
||||
- Dashboard 的稳定产品职责、信息边界和设计执行规则见 `AGENTS.md` 的“Dashboard 开发与设计”。用户 Dashboard 与运营 Dashboard 共享基础视觉语言和组件体系,但拥有不同的信息架构、信息密度和权限边界。
|
||||
- Dashboard 设计必须以当前真实 API/RPC contract 和数据结构为依据。若所需信息尚无后端 contract,应记录缺口,而不是在前端维护第二份业务状态或伪造指标。
|
||||
|
||||
- `docs/architecture/cas.md`:CAS 生产级设计。
|
||||
- `docs/architecture/translation.md`:翻译系统设计。
|
||||
发生冲突时遵循:`AGENTS.md` 与稳定产品/接口契约 > 当前明确任务需求 > `DESIGN.md` > Agent 自身设计偏好。
|
||||
|
||||
---
|
||||
## 4. 用户、开发与运维指南
|
||||
|
||||
## 3. 分析资料
|
||||
这些文件描述如何使用或验证已经存在的能力:
|
||||
|
||||
- `docs/assetbundle_analysis.json`:AssetBundle 分析资料。
|
||||
- `docs/textassets_analysis.json`:TextAsset 分析资料。
|
||||
- `docs/archive/BLUE_ARCHIVE_TECHNICAL_ANALYSIS.md`:历史技术分析。
|
||||
- `docs/archive/ARCHITECTURE_REVIEW.md`:历史架构审查。
|
||||
- `docs/archive/ARCHITECTURE_REVIEW_SUMMARY.md`:历史架构审查摘要。
|
||||
- `docs/archive/READY_FOR_PHASE_1.md`:历史 Phase 1 准备文档。
|
||||
- `docs/archive/REFACTOR_CHECKLIST.md`:历史重构清单。
|
||||
- `docs/guides/development.md`:本地开发、测试、调试和代码质量流程。
|
||||
- `docs/guides/deployment.md`:部署、systemd、Docker 和运维说明。
|
||||
- `docs/guides/official-resource-test-pull.md`:官方资源拉取与自动更新运行指南。
|
||||
- `docs/guides/official-full-pull-smoke.md`:真实官方全量拉取 smoke runbook。
|
||||
- `docs/guides/bat-api-local-live-smoke.md`:Rust `bat` 与 Go `bat-api` 同机 live 联调。
|
||||
- `docs/guides/bat-workflows.md`:`res`、`parse`、`i18n` 工作流和调度接口。
|
||||
- `docs/guides/baseline.md`:稳定工程基线和合并前检查。
|
||||
- `scripts/check-doc-status.sh`:当前状态、占位目录和关键契约文字门禁。
|
||||
- `scripts/check-doc-links.sh`:全仓库 Markdown 本地链接门禁。
|
||||
|
||||
---
|
||||
`deployments/` 下的 systemd、Docker、环境文件和数据库配置是部署材料,不作为独立架构文档;其行为说明以本节指南和当前源码为准。
|
||||
|
||||
## 4. 历史报告
|
||||
## 5. 分析资料和机器产物
|
||||
|
||||
历史报告已按来源和主题归档,供追溯使用,不再代表当前状态。
|
||||
以下资料用于分析或测试,不是当前能力声明:
|
||||
|
||||
- `docs/assetbundle_analysis.json`:AssetBundle 分析数据。
|
||||
- `docs/textassets_analysis.json`:TextAsset 分析数据。
|
||||
- `adapters/tests/fixtures/`、`adapters/tests/golden/`:Manifest / Addressables fixture 和 golden。
|
||||
- `infrastructure/tests/fixtures/`、`infrastructure/tests/golden/`:官方同步和导入 fixture。
|
||||
- `internal/api/testdata/contract/`:Rust-Go contract mirror 和 Go contract tests 输入。
|
||||
- `internal/api/testdata/release/`:`bat-api` 本地 release fixture。
|
||||
|
||||
测试 fixture 可以证明特定行为,但不能单独证明对所有真实官方格式的完整支持;真实样本和运行 smoke 仍需单独标注。
|
||||
|
||||
## 6. 模块状态说明
|
||||
|
||||
以下 README 是模块占位或边界说明,不是完整实现文档:
|
||||
|
||||
- `api/README.md`
|
||||
- `api/proto/README.md`
|
||||
- `api/openapi/README.md`
|
||||
- `internal/config/README.md`
|
||||
- `internal/downloader/README.md`
|
||||
- `internal/extractor/README.md`
|
||||
- `internal/manifest/README.md`
|
||||
- `internal/storage/README.md`
|
||||
- `pkg/README.md`
|
||||
- `pkg/cas/README.md`
|
||||
- `pkg/translator/README.md`
|
||||
- `pkg/types/README.md`
|
||||
- `web/README.md`
|
||||
- `web/admin/README.md`
|
||||
- `web/shared/README.md`
|
||||
|
||||
这些目录的实现状态以 `docs/reports/GO_STATUS.md`、对应源码和测试为准;不能因为目录或 README 存在就视为模块已完成。
|
||||
|
||||
## 7. 历史归档
|
||||
|
||||
以下内容只用于追溯,不能作为当前实现、当前优先级或当前测试结果的证据:
|
||||
|
||||
- `docs/archive/`:早期架构审查、技术分析、重构清单和 Phase 1 准备材料。
|
||||
- `docs/reports/historical/root/`:原根目录阶段报告。
|
||||
- `docs/reports/historical/current-stage/`:已被 `CURRENT_STATUS.md` 和当前指南取代的阶段交接、推送前核查报告。
|
||||
- `docs/reports/historical/week2/`:Week 2 相关报告。
|
||||
- `docs/reports/historical/week3/`:Week 3 相关报告。注意:这些报告中存在“完成”和“回滚”的冲突描述。
|
||||
- `docs/reports/historical/build-logs/`:历史构建、测试、Clippy 输出。
|
||||
- `docs/reports/historical/current-stage/`:已被当前状态和指南取代的阶段交接报告。
|
||||
- `docs/reports/historical/week2/`:Week 2 报告和当时的构建/测试输出。
|
||||
- `docs/reports/historical/week3/`:Week 3 报告;其中存在互相冲突的完成描述。
|
||||
- `docs/reports/historical/PARSER_FREEZE.md`:已解除的解析模块维护冻结历史记录,不构成当前开发约束。
|
||||
- `docs/reports/historical/build-logs/`:历史构建、测试和 Clippy 输出。
|
||||
- `docs/reports/historical/quality/`:历史质量报告。
|
||||
- `docs/reports/historical/nested-docs/`:从误嵌套 `docs/docs` 移出的报告。
|
||||
- `docs/reports/historical/nested-docs/`:从旧目录结构迁移出来的历史报告。
|
||||
|
||||
---
|
||||
## 8. 推荐阅读顺序
|
||||
|
||||
## 5. 当前阅读顺序
|
||||
### 8.1 项目与开发者通用阅读顺序
|
||||
|
||||
新开发者或新会话建议按以下顺序阅读:
|
||||
1. `README.md`
|
||||
2. `CURRENT_STATUS.md`
|
||||
3. `PROJECT_PLAN.md`
|
||||
4. `docs/reports/CURRENT_GAPS.md`
|
||||
5. `docs/reports/GO_STATUS.md`
|
||||
6. `docs/architecture/official-resource-backend.md`
|
||||
7. `docs/architecture/resource-release-layout.md`
|
||||
8. `docs/reference/rpc-backend-api.md`
|
||||
9. `docs/architecture/assetbundle.md`
|
||||
10. `docs/guides/official-resource-test-pull.md`
|
||||
11. `docs/guides/bat-workflows.md`
|
||||
12. `docs/guides/development.md`
|
||||
13. `CONTRIBUTING.md`
|
||||
14. `AGENTS.md`
|
||||
|
||||
1. `CURRENT_STATUS.md`
|
||||
2. `PROJECT_PLAN.md`
|
||||
3. `docs/guides/official-resource-test-pull.md`
|
||||
4. `docs/guides/official-full-pull-smoke.md`
|
||||
5. `docs/architecture/official-resource-backend.md`
|
||||
6. `docs/reference/rpc-backend-api.md`
|
||||
7. `docs/reports/CURRENT_GAPS.md`
|
||||
8. `docs/guides/baseline.md`
|
||||
9. `docs/architecture/README.md`
|
||||
10. `docs/guides/development.md`
|
||||
11. `CONTRIBUTING.md`
|
||||
12. `AGENTS.md`
|
||||
### 8.2 AI / Agent 开发接管顺序
|
||||
|
||||
---
|
||||
Agent 进入仓库进行开发时优先按以下顺序建立上下文:
|
||||
|
||||
## 6. 状态摘要
|
||||
1. `AGENTS.md`:先确定长期规则、状态所有权和开发边界;
|
||||
2. `DOCS_INDEX.md`:确认当前任务应阅读的权威文档;
|
||||
3. `CURRENT_STATUS.md` 与对应专项状态文档:确认当前已经实现的事实;
|
||||
4. `TODO.md`:确认当前具体任务、优先级、依赖和完成条件;
|
||||
5. 当前任务直接相关的源码、tests、稳定 contract 和架构文档;
|
||||
6. `docs/reports/CURRENT_GAPS.md` / `PROJECT_PLAN.md`:需要判断能力缺口或后续路线时再读取。
|
||||
|
||||
当前总体完成度不再固定写单一百分比,以 `CURRENT_STATUS.md` 和 `CURRENT_GAPS.md` 的模块状态为准。
|
||||
涉及 Dashboard、Web UI、页面布局、视觉样式、组件或交互体验时,在设计或修改前额外必须阅读 `DESIGN.md`。
|
||||
|
||||
已完成:
|
||||
|
||||
- Rust 领域模型和仓储接口骨架。
|
||||
- Unity/Manifest/Client 适配器框架。
|
||||
- CAS V1:原子写入、BLAKE3 校验、引用计数、GC、并发测试和损坏检测。
|
||||
- 文档整理和路线图重制。
|
||||
- Rust 官方资源同步闭环:`bat`、`--auto-discover`、`--watch`、`--daemon`、Unix socket JSON-RPC 后台控制、`status`、`stop`、`restart`、`reload`、`refresh`、`logs`、`verify`、`repair`、`doctor`、`clean-stable`、北京时间固定强制刷新、snapshot、manifest audit/repair、官方 seed `.hash` 校验。
|
||||
- 官方原版资源与汉化产物目录分离:`./bat-resources` 只承载原版 release,`./bat-localized` 承载后续汉化 release;当前官方同步报告 `not_localized`,Patch 发布完成后才进入 `localized`。
|
||||
- 官方 release 会维护 `official-parse-cache.json`,用于跳过未变化资源的重复解析。
|
||||
- `bat-api/internal/backendrpc` typed Unix socket JSON-RPC client。
|
||||
- `cmd/bat-api` 资源分发 HTTP MVP(进度见 `docs/reports/GO_STATUS.md`)。
|
||||
- 真实官方网络全量拉取 smoke 已固化为 `scripts/official-full-pull-smoke.sh` 和 `make official-smoke`,默认写入 `/tmp` 隔离目录并输出本地运行报告。
|
||||
- `bat` 运行时 progress log 已覆盖下载已完成计数、单文件下载进度和校验结果摘要。
|
||||
- Addressables 当前真实形态 fixture/golden 覆盖。
|
||||
- 解析补全路线图已固化到 `docs/architecture/assetbundle.md`:解析缓存、Addressables、UnityFS、Serialized 字段级解析、文本提取、CAS 接入和 Patch 发布前置。
|
||||
- SQLite Resource Repository 和可选无状态 `bat-ffi` JSON 兼容接口。
|
||||
|
||||
优先待办:
|
||||
|
||||
- `bat-api` 与全量 release / 服务器 daemon 联调(issue #19 剩余)。
|
||||
- 将官方同步结果接入 CAS + ResourceRepository 的用户级流程。
|
||||
- 推进 AssetBundle UnityFS 引擎级解析。
|
||||
阅读顺序中的状态和契约结论必须回到当前源码、测试和实际命令验证;`TODO.md`、`CURRENT_GAPS.md` 和 `PROJECT_PLAN.md` 均不能把计划项提升为已实现事实;历史报告只用于解释演进过程。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: help build build-ffi test clean check fmt lint install dev docker-build docker-up docker-down official-smoke build-go build-go-api build-go-cli test-go test-go-api test-go-ffi test-go-all
|
||||
.PHONY: help build build-ffi test clean check check-docs check-go-format format fmt lint ci ci-check install dev docker-build docker-up docker-down official-smoke bat-api-local-live-smoke build-go build-go-api build-go-cli test-go test-go-api test-go-ffi test-go-all
|
||||
|
||||
# 默认目标
|
||||
.DEFAULT_GOAL := help
|
||||
@@ -72,21 +72,21 @@ test-go-all: test-go-api test-go-ffi ## 全部 Go 测试
|
||||
bench: ## 运行性能基准测试
|
||||
@echo "$(BLUE)Running benchmarks...$(NC)"
|
||||
cargo bench --workspace
|
||||
@if [ -n "$$(go list ./... 2>/dev/null)" ]; then \
|
||||
go test -bench=. -benchmem ./...; \
|
||||
else \
|
||||
echo "$(YELLOW)No Go packages yet, skipping Go benchmarks...$(NC)"; \
|
||||
fi
|
||||
go test -bench=. -benchmem ./...
|
||||
|
||||
official-smoke: ## 运行真实官方全量拉取 smoke(默认写入 /tmp 隔离目录)
|
||||
@echo "$(BLUE)Running official full pull smoke...$(NC)"
|
||||
./scripts/official-full-pull-smoke.sh
|
||||
|
||||
bat-api-local-live-smoke: ## 在同一临时主机环境联调 Rust bat.sock 与 Go bat-api
|
||||
@echo "$(BLUE)Running local bat/bat-api live smoke...$(NC)"
|
||||
./scripts/bat-api-local-live-smoke.sh
|
||||
|
||||
# ============================================================================
|
||||
# 代码质量
|
||||
# ============================================================================
|
||||
|
||||
check: check-rust check-go ## 检查代码(不编译)
|
||||
check: check-rust check-go check-docs ## 检查代码和状态文档(不编译)
|
||||
|
||||
check-rust: ## 检查 Rust 代码
|
||||
@echo "$(BLUE)Checking Rust code...$(NC)"
|
||||
@@ -94,40 +94,47 @@ check-rust: ## 检查 Rust 代码
|
||||
|
||||
check-go: ## 检查 Go 代码
|
||||
@echo "$(BLUE)Checking Go code...$(NC)"
|
||||
@if [ -n "$$(go list ./... 2>/dev/null)" ]; then \
|
||||
go vet ./...; \
|
||||
else \
|
||||
echo "$(YELLOW)No Go packages yet, skipping...$(NC)"; \
|
||||
fi
|
||||
go vet ./...
|
||||
|
||||
check-go-format: ## 检查 Go 格式(只读)
|
||||
@echo "$(BLUE)Checking Go formatting...$(NC)"
|
||||
bash scripts/check-go-format.sh
|
||||
|
||||
check-docs: ## 检查权威状态文档与占位目录声明
|
||||
@echo "$(BLUE)Checking documentation status claims...$(NC)"
|
||||
bash scripts/check-doc-status.sh
|
||||
|
||||
fmt: fmt-rust fmt-go ## 格式化所有代码
|
||||
|
||||
format: fmt ## 格式化所有代码(会修改工作树)
|
||||
|
||||
fmt-rust: ## 格式化 Rust 代码
|
||||
@echo "$(BLUE)Formatting Rust code...$(NC)"
|
||||
cargo fmt --all
|
||||
|
||||
fmt-go: ## 格式化 Go 代码
|
||||
@echo "$(BLUE)Formatting Go code...$(NC)"
|
||||
@if [ -n "$$(go list ./... 2>/dev/null)" ]; then \
|
||||
go fmt ./...; \
|
||||
else \
|
||||
echo "$(YELLOW)No Go packages yet, skipping...$(NC)"; \
|
||||
fi
|
||||
go fmt ./...
|
||||
|
||||
lint: lint-rust lint-go ## 运行所有 Linter
|
||||
|
||||
lint-rust: ## Rust Clippy 检查
|
||||
@echo "$(BLUE)Running Clippy...$(NC)"
|
||||
cargo clippy --workspace -- -D warnings
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
|
||||
lint-go: ## Go Linter 检查
|
||||
lint-go: ## Go Linter 检查(required)
|
||||
@echo "$(BLUE)Running golangci-lint...$(NC)"
|
||||
@command -v golangci-lint >/dev/null 2>&1 || { echo "$(YELLOW)golangci-lint not installed, skipping...$(NC)"; exit 0; }
|
||||
@if [ -n "$$(go list ./... 2>/dev/null)" ]; then \
|
||||
golangci-lint run ./...; \
|
||||
else \
|
||||
echo "$(YELLOW)No Go packages yet, skipping...$(NC)"; \
|
||||
fi
|
||||
@. scripts/ci-versions.sh; \
|
||||
command -v golangci-lint >/dev/null 2>&1 || { \
|
||||
echo "$(YELLOW)required gate failed: golangci-lint $${GOLANGCI_LINT_VERSION} is not installed$(NC)"; \
|
||||
exit 1; \
|
||||
}; \
|
||||
actual="$$(golangci_lint_actual_version)"; \
|
||||
test "$${actual}" = "$${GOLANGCI_LINT_VERSION}" || { \
|
||||
echo "$(YELLOW)required gate failed: golangci-lint version required=$${GOLANGCI_LINT_VERSION} actual=$${actual:-unknown}$(NC)"; \
|
||||
exit 1; \
|
||||
}; \
|
||||
XDG_CACHE_HOME="$${XDG_CACHE_HOME:-/tmp/bat-xdg-cache}" golangci-lint run ./...
|
||||
|
||||
# ============================================================================
|
||||
# 清理
|
||||
@@ -193,5 +200,7 @@ docs: ## 生成文档
|
||||
# CI/CD
|
||||
# ============================================================================
|
||||
|
||||
ci: fmt lint test ## 运行 CI 检查(本地模拟)
|
||||
@echo "$(GREEN)✓ All CI checks passed!$(NC)"
|
||||
ci-check: ## 运行只读 required CI 门禁(含固定版本 Go lint)
|
||||
@bash scripts/ci-check.sh
|
||||
|
||||
ci: ci-check ## 运行只读 CI 检查(兼容旧命令名)
|
||||
|
||||
+62
-56
@@ -1,8 +1,8 @@
|
||||
# BlueArchiveToolkit 完整开发计划
|
||||
|
||||
- **项目名称**:BlueArchiveToolkit
|
||||
- **文档版本**:2026-07-20 状态收口版
|
||||
- **权威状态**:以本文档和 `CURRENT_STATUS.md` 为准,旧阶段报告仅作历史参考。
|
||||
- **文档版本**:2026-09-04 状态复核版
|
||||
- **文档角色**:长期目标、里程碑和路线图;当前实现以源码、测试、稳定契约和 `CURRENT_STATUS.md` 为准,旧阶段报告仅作历史参考。
|
||||
- **最终目标**:构建一个可长期维护、可扩展、可审计的 Blue Archive 资源管理、文本提取、翻译和补丁平台。
|
||||
|
||||
---
|
||||
@@ -22,7 +22,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
|
||||
## 2. 当前真实状态
|
||||
|
||||
本节来自 2026-07-20 的工作区盘点、本地验证和最新功能提交。
|
||||
本节来自 2026-09-04 的工作区盘点、本地验证和最新功能提交。
|
||||
|
||||
### 已具备
|
||||
|
||||
@@ -32,22 +32,23 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
4. `bat-cas-engine` 已完成 CAS V1:原子写入、BLAKE3 Hash、SQLite 引用计数、GC、并发测试、损坏检测。
|
||||
5. `bat-infrastructure` 已改为 CAS 仓储适配层,不再重复实现对象存储。
|
||||
6. `bat-infrastructure` 已提供官方资源 pull/update 服务,正式入口是 Rust binary `bat`。
|
||||
7. `bat` 支持 `--auto-discover`、`--watch`、`--daemon`、默认 1 小时间隔、本地 manifest audit/repair、官方 seed `.hash` 校验、snapshot/cache,以及基于 Unix socket JSON-RPC 的 live control/backend 方法(`daemon.status/logs/stop/reload/refresh/doctor`、`resource.sync/verify/repair/state/manifest/list/index`、`parse.status/text_units/errors`、`localized.status`、`catalog.*`、`task.*`);`restart` 和 `clean-stable` 仍由 CLI 侧按进程生命周期显式执行。
|
||||
7. `bat` 支持 `--auto-discover`、`--watch`、`--daemon`、默认 1 小时间隔、本地 manifest audit/repair、官方 seed `.hash` 校验、snapshot/cache,以及基于 Unix socket JSON-RPC 的 live control/backend 方法(`daemon.status/logs/stop/restart/reload/refresh/doctor`、`resource.sync/verify/repair/state/manifest/list/index`、`parse.status/text_units/errors`、`translation.*`、`localized.status`、`release.status/list/distribution/cleanup`、`catalog.*`、`task.*`);`daemon.restart` 通过 Rust lifecycle controller 复用 CLI restart 路径,`clean-stable` 仍由 CLI 侧按进程生命周期显式执行。
|
||||
8. `bat-ffi` 已提供 Manifest inspect 和官方 sync plan 的可选无状态粗粒度 JSON C ABI helper。
|
||||
9. 官方原版资源默认发布到 `./bat-resources`,汉化产物默认发布到独立的 `./bat-localized`;当前官方同步报告会标记 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布。
|
||||
9. 官方原版资源默认发布到 `./bat-resources`,汉化产物默认发布到独立的 `./bat-localized`;当前官方同步报告会标记 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布;`translation.proofread` 可把汉化 workflow 标记为人工校对中,但不会覆盖已发布的汉化 release。
|
||||
10. 官方同步校验完成并发布新 release 后会生成 `official-resource-changes.json`、`crowdin-translation-handoff.json`、`official-parse-cache.json`、`official-textunit-index.json`、`official-textunit-tasks.json` 和 `crowdin-textunit-queue.json`,用 Added/Modified 资源驱动后续解析/翻译增量;up-to-date 轮询在已有有效缓存、TextUnit 明细索引和队列时只读取摘要,不重复解析。
|
||||
11. 文档已整理:根目录保留入口文档,历史报告进入 `docs/reports/historical/`,误嵌套的 `docs/docs` 已合并。
|
||||
11. Rust `bat` 已提供 `res` / `parse` / `i18n` 工作流:单次/限定次数/周期执行、版本化 schedule CRUD 与作用域过滤、解析缓存清理、翻译工作台校验、离线翻译工作台、人工文本查看/修改/清空、翻译任务状态回写、人工校对状态标记、既有 patch 能力的批量重打包和独立汉化 release 发布;`translation.worker.run` 已接入 provider worker,默认并发 8、范围 `1..=256`,每个 worker 独立 claim 下一项任务并落库 lease、失败分类、重试计划和 TextUnit 译文结果;`bat-api` 已提供内嵌 dashboard MVP,直接调用已有鉴权接口控制资源、调度、任务、日志、parse、翻译、TM 和 localized 发布/回滚;schedule CRUD、`translation.tasks` / `translation.handoff`、`translation.task.update`、`translation.worker.run`、`translation.proofread` 和 `translation.memory.*` 已经通过 `bat.sock` 和 `bat-api` 管理接口暴露,dashboard 不维护第二套状态;新解析覆盖仍需真实 fixture 和回归验收。
|
||||
12. 文档已整理:根目录保留入口文档,历史报告进入 `docs/reports/historical/`,误嵌套的 `docs/docs` 已合并。
|
||||
|
||||
### 仍是骨架或占位
|
||||
|
||||
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 前置链路。
|
||||
3. Go 侧边界已冻结(见 `docs/reports/GO_STATUS.md`):同步/运维命令行 = Rust `bat`;资源分发 = `cmd/bat-api` MVP;`internal/backendrpc` 完成;`cmd/bat` 仅为试验(`bin/bat-go`)。完整游戏业务 API / Web / SDK 仍未完成。
|
||||
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/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 查询;Resource metadata 已保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 摘要;单条 TextUnit 明细和解析错误已持久化到 `official-textunit-index.json`,可通过 `parse.text_units` / `parse.errors` 查询,翻译任务状态仍需继续推进。
|
||||
6. 汉化 Patch 发布前置已具备 UnityFS TextAsset manifest/apply/diff/rollback/完整性校验和 `localized.status` 严格校验;真实 Crowdin worker、翻译记忆到完整汉化文件集合的构建仍未完成。
|
||||
7. 真实官方网络全量下载 smoke 已固化为可重复脚本和 runbook(G-018 已关闭);真实运行记录处于长期运行测试阶段,报告待后续提供。
|
||||
8. Web、数据库迁移、OpenAPI、插件加载机制尚未实现。
|
||||
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 侧仅代理。
|
||||
6. 受支持汉化 Patch 发布已具备 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 的 manifest/apply/rollback/完整性校验和 `localized.status` 严格校验;ZIP 内 bundle 在 `archive_entry` 可验证时会重写外层 ZIP。真实 provider worker 与项目级 Translation Memory persistence schema V2 已接入,翻译记忆到完整汉化文件集合的构建仍未完成。
|
||||
7. 真实官方网络全量下载 smoke 已固化为可重复脚本和 runbook;真实运行记录处于长期运行测试阶段,报告待后续提供。
|
||||
8. 内嵌 dashboard MVP 已实现;完整 Web 协作后台、数据库迁移、插件加载机制尚未实现;`bat-api` 资源 bootstrap/分发/OpenAPI/管理控制面已通过 `/openapi.yaml` 提供,完整游戏业务 API 的 OpenAPI 仍未完成。
|
||||
9. 原 Git 历史未恢复;当前仓库以新初始化基线为准。
|
||||
|
||||
### 已验证
|
||||
@@ -72,8 +73,8 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
|
||||
### 3.2 技术决策
|
||||
|
||||
1. **Rust**:保留为核心引擎语言,用于 CAS、AssetBundle、Patch、完整资源拉取和更新检查核心逻辑;`bat --json` 进程边界是当前主集成路径,FFI 仅作为可选兼容层。
|
||||
2. **Go**:用于最小稳定 CLI、服务编排、API Server、任务编排、Provider 集成;不强制要求 Rust 核心能力必须写成库供 Go 调用。
|
||||
1. **Rust**:保留为核心引擎语言,用于 CAS、AssetBundle、Patch、完整资源拉取和更新检查核心逻辑;`bat.sock` RPC 是 Go `bat-api` 的当前主集成边界,`bat --json` 是 Rust CLI 的机器输出形态,FFI 仅作为可选兼容层。
|
||||
2. **Go**:当前用于 `bat-api` 资源 bootstrap/分发和 Rust RPC 管理入口;完整服务编排、API Server、任务编排和 Provider 集成仍是目标能力,不强制要求 Rust 核心能力必须写成库供 Go 调用。
|
||||
3. **PostgreSQL**:作为服务端主数据库,承载翻译记忆库、术语库、任务、审核和用户权限。
|
||||
4. **SQLite**:仅作为本地 CLI 可选元数据后端,必须通过仓储抽象隔离,不能绑定业务逻辑。
|
||||
5. **Redis**:用于服务端缓存、任务状态、限流和短期锁。
|
||||
@@ -87,7 +88,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
2. 公共接口具备文档、错误语义和兼容性说明。
|
||||
3. 单元测试覆盖核心分支;跨模块能力补集成测试。
|
||||
4. `cargo fmt`、`cargo clippy --workspace --all-targets -- -D warnings`、`cargo test --workspace` 通过。
|
||||
5. Go 模块落地后,`go test ./...`、`go vet ./...` 通过。
|
||||
5. Go 当前门禁通过 `make test-go-api`、`make build-go-api` 和 `go vet ./...`。
|
||||
6. 用户可见命令必须有 `doctor` 检查和失败恢复建议。
|
||||
|
||||
---
|
||||
@@ -168,21 +169,23 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
|
||||
**目标**:能够获取、解析和同步 Blue Archive 资源清单。
|
||||
|
||||
**当前状态**:部分完成。Rust 官方日服资源同步链路已经具备正式 one-shot 和 `--watch` 常驻入口;Go 产品入口、完整解析覆盖、CAS 导入编排和真实线上 smoke 仍待完成。
|
||||
**当前状态**:部分完成。Rust 官方日服资源同步链路已经具备正式 one-shot 和 `--watch` 常驻入口;`bat-api` 资源 bootstrap/分发入口已落地,CAS + ResourceRepository 导入、历史 release/CAS 复用和 Translation Memory persistence schema V2 已可用,但完整解析覆盖、丰富查询扩展和真实线上 smoke 仍待完成。
|
||||
|
||||
交付物:
|
||||
|
||||
1. Addressables Catalog 真实字段解析:**部分完成**。当前已覆盖 path、hash、size、address、dependencies、metadata 和真实形态 fixture/golden;仍需继续覆盖更多官方 catalog 结构变体。
|
||||
2. 资源版本、区域、渠道、远端 URL、Hash、大小、依赖关系模型:**部分完成**。`Resource` 和官方 endpoint/snapshot 模型已扩展;仍需冻结 Go CLI/API 可见模型。
|
||||
3. Rust 官方下载器:**已完成当前生产入口需要的核心能力**。包含官方 URL 校验、`.part` 续传、重试、本地 manifest size+BLAKE3 校验、官方 seed `.hash` 校验和 repair。
|
||||
1. Addressables Catalog 目标字段解析:**当前目标完成**。JSON/compact 已覆盖 path、hash、size、address、dependencies、provider ID、bundle name、CRC、metadata,并通过 fixture/golden 与 SQLite 迁移回归;独立二进制 catalog 仍明确拒绝。
|
||||
2. 资源版本、区域、渠道、远端 URL、Hash、大小、依赖关系模型:**部分完成**。`Resource` 和官方 endpoint/snapshot 模型已扩展;Go CLI/API 可见模型仍需在稳定 contract 中继续收敛。
|
||||
3. Rust 官方下载器:**已完成当前生产入口需要的核心能力**。包含官方 URL 校验、`.part` 续传、重试、本地 manifest size+BLAKE3 校验、官方 seed `.hash` 校验、repair、已发布历史 release/CAS 复用,以及默认 8、范围 `1..=256` 的有界并发 scheduler;worker 动态领取任务,进度按完成数单调上报,report 保持 plan 顺序,复用和网络传输分别统计。
|
||||
4. Rust 自动更新入口:**已完成当前生产入口**。`bat` 支持 snapshot、marker diff、bootstrap cache、one-shot、`--watch`、`--daemon`、默认 1 小时间隔、北京时间固定强制刷新,以及 Unix socket JSON-RPC 后台运维命令返回。
|
||||
5. Go 入口边界:**已冻结**。同步命令行 = Rust `bat`(G-008 关闭);资源分发 = `bat-api` MVP(G-009 部分完成)。详见 `docs/reports/GO_STATUS.md`。
|
||||
6. 用户级 `sync`、`manifest inspect`、`cache status`:**未完成**。Rust `bat --json` 是当前稳定进程边界;`bat-ffi` 只提供可选兼容用的 Manifest inspect 和 sync plan JSON helper。
|
||||
7. 下载结果写入 CAS + ResourceRepository:**部分完成**。CAS 和 SQLite ResourceRepository 已存在,官方同步入口可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后导入;`resource.index` 可查询现有索引和资源 metadata;`parse.text_units` / `parse.errors` 可查询当前 release 的 TextUnit 明细与解析错误。剩余工作是翻译任务状态、CAS 诊断入口和更丰富查询。
|
||||
5. Go 入口边界:**已确定**。同步命令行 = Rust `bat`;资源分发 = `bat-api` MVP。详见 `docs/reports/GO_STATUS.md`。
|
||||
6. Go 用户级 `sync`、`manifest inspect`、`cache status`:**未完成**。Rust `bat`
|
||||
是当前正式资源同步 CLI,`bat --json` 是其机器输出形态;`bat-ffi` 只提供可选
|
||||
兼容用的 Manifest inspect 和 sync plan JSON helper。
|
||||
7. 下载结果写入 CAS + ResourceRepository:**基础能力可用,查询面仍部分完成**。CAS 和 SQLite ResourceRepository 已存在,官方同步入口可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后导入;`resource.index` 可按资源级 release、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 查询现有索引和资源 metadata,常用 metadata 过滤已下推到 SQLite;`bat doctor cas` 可只读诊断既有 CAS 根目录、对象目录、元数据库文件和对象统计;`parse.text_units` / `parse.errors` 可查询当前 release 的 TextUnit 明细与解析错误;`translation.tasks` 和 `translation.memory.*` 可查询离线 TextUnit 与项目级 TM。剩余工作是更丰富的 TextUnit/TM 查询和通用 Patch 发布所需资源视图。
|
||||
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 的可重复流程;真实运行处于长期运行测试阶段,报告待后续提供。
|
||||
9. 真实官方网络全量下载 smoke test:**命令已固化**。`scripts/official-full-pull-smoke.sh` / `make official-smoke` 已固化 dry-run、首次下载、二次 up-to-date 和本地损坏 repair 的可重复流程;真实运行处于长期运行测试阶段,报告待后续提供。
|
||||
10. 官方发布后的增量 handoff 与解析缓存:**已完成基础入口**。新 release 发布后先生成 `official-resource-changes.json` 和 `crowdin-translation-handoff.json`,新增+变更资源进入解析/翻译候选;`official-parse-cache.json` 基于下载 manifest 覆盖直接 UnityFS bundle、zip 内 UnityFS 条目和非候选资源记录;随后生成 `official-textunit-index.json`、`official-textunit-tasks.json` 与 `crowdin-textunit-queue.json`,本地文件未变化且缓存/索引有效时跳过重复解析。
|
||||
11. 汉化发布状态:**已完成前置闭环**。官方同步默认报告 `not_localized`,表示只发布原版资源;UnityFS TextAsset patch 发布成功并通过 `localized-patch-manifest.json`、current symlink 和 release ID 校验后才切换为 `localized`。
|
||||
11. 汉化发布状态:**受支持范围已完成闭环**。官方同步默认报告 `not_localized`,表示只发布原版资源;TextAsset、TypeTree string field 和 managed-reference string field patch 发布成功并通过 `localized-patch-manifest.json`、current symlink、release ID 和 rollback 校验后才切换为 `localized`,可通过 `localized.publish` / `localized.rollback` 与 bat-api 管理接口控制。
|
||||
|
||||
验收标准:
|
||||
|
||||
@@ -202,15 +205,17 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
|
||||
### Milestone 4:Unity AssetBundle 解析
|
||||
|
||||
当前解析扩展按路线图和真实回归继续推进。
|
||||
|
||||
**目标**:建立可扩展 AssetBundle 解析框架,并首先支持文本相关资源。
|
||||
|
||||
交付物:
|
||||
|
||||
1. **解析缓存闭环**:官方同步发布后生成 `official-parse-cache.json`,覆盖 manifest 全部条目、直接 bundle、zip 内 bundle、非候选资源和解析失败诊断;未变化文件按 URL、相对路径、size 和 BLAKE3 复用解析结果。
|
||||
2. **Addressables 完整化**:覆盖 Windows/Android JSON、compact JSON 和后续二进制 catalog 入口,解析 provider、internal id、primary key、dependency、bundle name、hash、size、CRC 和资源类型。
|
||||
3. **UnityFS 容器层**:继续完善 header、block info、directory、data block、压缩、alignment、边界错误、directory 文件提取和真实样本回归。
|
||||
3. **UnityFS 容器层**:基础目标已完成 header、block info、directory、data block、LZ4/LZMA、alignment、总大小/计数/路径/边界错误、directory 文件提取和 UnityPy 真实样本回归;当前已验证结构另有压缩/对齐保留的变长发布级重建闭环,复杂版本差异和任意结构重打包另行推进。
|
||||
4. **Serialized file 层**:稳定 Unity serialized file header、type table、TypeTree node、object table、path id、class id 和 raw object bytes 表示。
|
||||
5. **字段级解析层**:实现 TypeTree 字段 reader,支持 bool、integer、float、string、bytes、array、vector/staticvector 嵌套 `Array`、`List<T>` / `HashSet<T>` 集合 alias、map、PPtr、enum `value__` backing field、`LayerMask` / `BitField` 的 `m_Bits` backing field、常见固定 Unity float/int/hash 值类型的 leaf/direct-child 形态、unknown fixed-size raw bytes 保留和同长度替换、TypeTree-covered managed reference / `SerializedReference` alias 和 TypeTree-covered managed reference registry 记录;managed-reference full typename 可拆为 assembly/namespace/class,常见 `m_ManagedReferences` / `RefIds` / verbose type 字段命名、`managedReference*` / `serializedReference*` metadata 和 `data` / `value` / `payload` / `object` / `managedReferencePayload` / `referencePayload` / `serializedReferencePayload` / `managedReferenceValue` / `referenceValue` / `serializedReferenceValue` / `managedReferenceObject` / `referenceObject` / `serializedReferenceObject` / `managedReferenceData` / `referenceData` / `serializedData` / `serializedReferenceData` payload 命名已有回归覆盖,TextUnit 只提取 payload 字符串并按结构化 record、`RefIds[n]` 等记录前缀或子字段保留类型上下文;array/vector/List/HashSet/map 元素与 registry payload 字段保留独立 field path、offset 和 byte size,可支撑字符串元素、managed-reference registry payload 字段、基础语义字段 patch、enum/bit_field 语义 patch、固定值类型 patch、unknown fixed-size bytes patch、object 字段组合和 TypeTree schema 支撑的 array/vector/List/HashSet/map 整体变长替换,`first/second` 与 `key/value` map entry schema 已有回归覆盖;解析模块当前处于维护冻结,未见样本驱动的完整 managed reference registry / map entry 变体和 unknown 字段结构语义暂不继续扩展,除非属于冻结规则允许的稳定性修复。
|
||||
5. **字段级解析层**:实现 TypeTree 字段 reader,支持 bool、integer、float、string、bytes、array、vector/staticvector 嵌套 `Array`、`List<T>` / `HashSet<T>` 集合 alias、map、PPtr、enum `value__` backing field、`LayerMask` / `BitField` 的 `m_Bits` backing field、常见固定 Unity float/int/hash 值类型的 leaf/direct-child 形态、unknown fixed-size raw bytes 保留和同长度替换、TypeTree-covered managed reference / `SerializedReference` alias 和 TypeTree-covered managed reference registry 记录;managed-reference full typename 可拆为 assembly/namespace/class,常见 `m_ManagedReferences` / `RefIds` / verbose type 字段命名、`managedReference*` / `serializedReference*` metadata 和 `data` / `value` / `payload` / `object` / `managedReferencePayload` / `referencePayload` / `serializedReferencePayload` / `managedReferenceValue` / `referenceValue` / `serializedReferenceValue` / `managedReferenceObject` / `referenceObject` / `serializedReferenceObject` / `managedReferenceData` / `referenceData` / `serializedData` / `serializedReferenceData` payload 命名已有回归覆盖,TextUnit 只提取 payload 字符串并按结构化 record、`RefIds[n]` 等记录前缀或子字段保留类型上下文;array/vector/List/HashSet/map 元素与 registry payload 字段保留独立 field path、offset 和 byte size,可支撑字符串元素、managed-reference registry payload 字段、基础语义字段 patch、enum/bit_field 语义 patch、固定值类型 patch、unknown fixed-size bytes patch、object 字段组合和 TypeTree schema 支撑的 array/vector/List/HashSet/map 整体变长替换,`first/second` 与 `key/value` map entry schema 已有回归覆盖;解析模块当前仍有真实版本差异、未见样本驱动的完整 managed reference registry / map entry 变体和 unknown 字段结构语义需要继续推进。
|
||||
6. **文本对象入口**:实现 TextAsset、MonoBehaviour、ScriptableObject 的可扩展提取入口,输出可追溯到 bundle、serialized file、path id 和 field path 的文本定位。
|
||||
7. **工具与接口**:编写 `bundle inspect`、`bundle extract`、`text extract` 的最小稳定入口;CLI/RPC/API 使用解析器输出,不直接耦合解析内部结构。
|
||||
8. **汉化发布前置**:解析结果必须能作为 Patch 输入;Patch 发布阶段才写入 `--localized-output` / `BAT_LOCALIZED_OUTPUT` 配置的汉化输出目录并切换 `localized` 状态。
|
||||
@@ -255,11 +260,10 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
|
||||
交付物:
|
||||
|
||||
1. PostgreSQL schema:source_text、translation、translation_memory、glossary、review、history。
|
||||
2. 实现精确匹配、模糊匹配、上下文匹配。
|
||||
3. 实现术语优先级、别名、分类、冲突检测和审核状态。
|
||||
4. 实现导入导出和版本历史。
|
||||
5. 实现 `translate memory`、`glossary` CLI 子命令。
|
||||
1. Translation Memory persistence schema V2 已使用项目级 SQLite schema:source raw/hash、translation、完整 context、candidate/trusted、provenance、supersede 关系和 audit event。
|
||||
2. 已实现 raw source + 完整 context exact match、current Trusted 唯一性和冲突诊断;模糊匹配和完整导入导出仍待实现。Glossary domain/feature contract V1 已由 SQLite persistence schema V2 承载,接入 approved review、scope/alias/priority、provider constraints、确定性 QA 和显式 override。
|
||||
3. 已实现显式 per-record confirm、supersede 和历史冲突 resolve;Glossary 已实现术语优先级、别名、分类、冲突检测和审核历史,批量审核与完整导入导出仍待实现。
|
||||
4. 已实现 `bat i18n memory summary|query|confirm|conflicts|resolve-conflict` 与对应 Rust RPC。
|
||||
|
||||
验收标准:
|
||||
|
||||
@@ -302,7 +306,8 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
3. 实现客户端发现、路径校验、备份、应用、回滚。
|
||||
4. 实现 `patch build`、`patch apply`、`patch rollback`、`verify`。
|
||||
5. 实现 dry-run 和安全检查。
|
||||
6. 将通用 Patch manifest 与汉化发布流程进一步统一。
|
||||
6. generic Patch manifest V1 已统一当前支持类型;双 release 的查询、分发选择和安全 cleanup V1 已由 Rust `bat` 持有,复杂 AssetBundle 兼容继续由真实样本驱动。
|
||||
7. `release.status` / `release.list` 提供 official/localized current 与历史 release 统一视图;`release.distribution` 只选择已验证资源,默认 official;`release.cleanup` 采用 dry-run `plan_id`、执行前重验证和 CAS/reference 保护,rollback 保持独立。
|
||||
|
||||
验收标准:
|
||||
|
||||
@@ -320,12 +325,14 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
|
||||
交付物:
|
||||
|
||||
1. Go CLI 主入口和命令体系。
|
||||
1. 正式 Rust `bat` CLI 和命令体系;Go 侧面向 `bat-api`、SDK 和服务集成发展。
|
||||
2. 配置系统:项目级、用户级、环境变量、密钥管理。
|
||||
3. Go SDK:Manifest、Sync、CAS、Extract、Translate、Patch。
|
||||
4. REST API Server:认证、权限、统一错误码、OpenAPI。
|
||||
5. 后台任务系统:同步、提取、翻译、补丁构建。
|
||||
|
||||
当前边界:正式同步与运维 CLI 继续由 Rust `bat` 承担;Go `cmd/bat` 仅为试验入口,Go 产品化工作集中在 `bat-api`、SDK 和服务集成。
|
||||
|
||||
验收标准:
|
||||
|
||||
1. CLI 命令风格统一,支持 JSON 输出和人类可读输出。
|
||||
@@ -337,12 +344,12 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
|
||||
### Milestone 10:Web 管理后台
|
||||
|
||||
**目标**:为翻译协作和资源管理提供可用后台。
|
||||
**目标**:在已落地的 `bat-api` 内嵌 dashboard MVP 之上,为翻译协作和资源管理提供完整后台。
|
||||
|
||||
交付物:
|
||||
|
||||
1. 登录、权限、用户角色。
|
||||
2. Dashboard:同步状态、翻译进度、质量问题、队列状态。
|
||||
2. Dashboard:同步状态、翻译进度、质量问题、队列状态;当前 MVP 已覆盖资源、调度、任务、日志、parse、翻译和 localized 控制。
|
||||
3. 翻译审核:列表、详情、Diff、批量操作。
|
||||
4. 术语管理:搜索、冲突提示、审核。
|
||||
5. 资源浏览:版本、资源、Bundle、文本定位。
|
||||
@@ -363,7 +370,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
|
||||
交付物:
|
||||
|
||||
1. 发布验证:format、lint、test、build、security audit、release artifact 由本地可重复命令、自托管 Gitea linux-runner workflow 与脚本承担(决策:不引入 GitHub Workflows 等托管 CI,见 `docs/reports/CURRENT_GAPS.md` G-017)。
|
||||
1. 发布验证:format、lint、test、build、security audit、release artifact 由本地可重复命令和脚本承担;项目以本地 `make ci-check` 作为唯一完整 required quality gate,当前不依赖 Gitea、GitHub Actions 或其它远端 CI runner。
|
||||
2. Docker Compose:本地开发、服务端部署。
|
||||
3. 数据备份与恢复文档。
|
||||
4. 用户文档、开发文档、故障排查文档。
|
||||
@@ -381,7 +388,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
|
||||
## 5. 推荐执行顺序
|
||||
|
||||
近期不要直接跳到 Web 或 AI Provider。项目当前的真实瓶颈是资源解析、增量变更集进入文本提取/翻译队列、`bat-api` 与全量 release 联调,以及真实端到端验证。
|
||||
近期不要把内嵌 dashboard MVP 扩成完整协作后台或过早扩展 AI Provider。项目当前的真实瓶颈仍是完整 Web 术语协作视图、复杂 AssetBundle 重打包和真实官方资源长期运行验证。
|
||||
|
||||
建议顺序:
|
||||
|
||||
@@ -389,20 +396,19 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
2. 完成 Milestone 5,再开始翻译系统。
|
||||
3. 完成 Milestone 6 和 7,建立可审计翻译流程。
|
||||
4. 完成 Milestone 8,形成可交付补丁。
|
||||
5. 最后补齐 CLI/API/Web/发布工程。
|
||||
5. 最后补齐完整 CLI/API/Web 协作后台和发布工程。
|
||||
|
||||
---
|
||||
|
||||
## 6. 近期具体任务
|
||||
## 6. 当前开发入口
|
||||
|
||||
优先完善 Rust 解析与资源库接入,并联调 Go 资源分发。边界见 `docs/reports/GO_STATUS.md`:
|
||||
当前优先推进 Rust 解析、资源库查询和翻译发布能力。边界见
|
||||
`docs/reports/GO_STATUS.md`:
|
||||
|
||||
1. issue #17 已关闭(顺序下载 + 指数退避)。
|
||||
2. G-008 已决策关闭:同步/运维命令行 = 近乎全自动的 Rust `bat`。
|
||||
3. G-009 / issue #19:`bat-api` 资源分发 MVP 已落地;优先服务器联调;拉取仍在 Rust `bat`。
|
||||
4. 继续 Addressables(issue #2)与 UnityFS(issue #3 / G-005)。
|
||||
5. 将 `crowdin-textunit-queue.json` 接入真实 Crowdin worker、翻译记忆和 Patch 构建。
|
||||
6. 继续扩展 G-011 剩余查询面:翻译任务状态、`doctor cas` 诊断入口和更丰富 TextUnit 查询。
|
||||
1. 继续 Addressables 结构变体与 UnityFS 复杂对象能力。
|
||||
2. 基于 `translation.worker.run` 继续补充复杂 AssetBundle 的 Patch 构建与发布验证。
|
||||
3. 继续扩展资源库剩余查询面:更丰富的 TextUnit/TM 查询和通用 Patch 发布所需资源视图。
|
||||
4. 在隔离环境执行真实官方网络长期运行 smoke,并保留运行报告。
|
||||
|
||||
---
|
||||
|
||||
@@ -410,7 +416,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
|
||||
### SQLite 权限问题
|
||||
|
||||
旧 Week 3 报告提到 SQLite 文件权限导致测试失败。处理策略:
|
||||
本地 SQLite 元数据后端的权限和恢复风险需要通过显式测试覆盖。处理策略:
|
||||
|
||||
1. 本地元数据后端必须使用临时目录和明确权限测试。
|
||||
2. SQLite 只作为 adapter,不进入领域层。
|
||||
@@ -430,7 +436,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
处理策略:
|
||||
|
||||
1. Rust 提供稳定引擎能力,并在当前阶段承担可生产运行的官方资源同步 CLI、watch 和 daemon。
|
||||
2. Go 的长期职责包括资源分发 HTTP(`bat-api`)、服务编排、网络和 Provider;同步/运维命令行由近乎全自动的 Rust `bat` 承担。不能把试验性 `cmd/bat` 视为产品 CLI。
|
||||
2. Go 的目标职责包括资源分发 HTTP(当前为 `bat-api`)、服务编排、网络和 Provider;同步/运维命令行由近乎全自动的 Rust `bat` 承担。不能把试验性 `cmd/bat` 视为产品 CLI。
|
||||
3. 跨边界优先进程或 SDK,FFI 只作为可选的粗粒度、无状态、安全、可测试兼容 API。
|
||||
4. Rust 不需要被强制写成 Go 调用库;当前 `bat --watch` / `bat --daemon` 是允许长期运行的 Rust 生产任务。
|
||||
|
||||
@@ -443,22 +449,22 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
3. smoke test 只记录命令、状态和摘要,不把大体积官方资源纳入 Git。
|
||||
4. 下载成功后必须通过 `official-download-manifest.json` audit 和官方 seed `.hash` 校验报告确认。
|
||||
|
||||
### 过早做 Web
|
||||
### Web 范围控制
|
||||
|
||||
处理策略:
|
||||
|
||||
1. Web 依赖可用 API 和数据库,不应早于核心同步、提取、翻译模型。
|
||||
2. 先完成 CLI 和 API,再构建 Web。
|
||||
1. 当前内嵌 dashboard 只编排已有 API,不维护第二套业务状态。
|
||||
2. 完整协作后台应在权限、翻译模型和持久化 API 明确后继续建设。
|
||||
|
||||
---
|
||||
|
||||
## 8. 当前完成度评估
|
||||
|
||||
按最终目标计算,当前总体完成度不再固定写单一百分比,以模块状态和 issue 收敛情况为准。
|
||||
按最终目标计算,当前总体完成度不固定写单一百分比,以模块状态、源码、测试和契约为准。
|
||||
|
||||
已完成的是稳定基线、架构骨架、部分接口、CAS V1、Rust 官方资源同步闭环、可配置 CAS/ResourceRepository 导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、通用 Binary/JSON/Text Patch 基础、UnityFS TextAsset patch 发布前置,以及 Go `bat-api` 资源分发 MVP。下一阶段的关键是真实 Crowdin worker、翻译记忆、复杂 AssetBundle 解析/重打包,以及 bat-api 与全量 release 联调。
|
||||
已完成的是稳定基线、架构骨架、部分接口、CAS V1、Rust 官方资源同步闭环、可配置 CAS/ResourceRepository 导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、provider worker、Translation Memory persistence schema V2、Glossary domain/feature contract V1(SQLite persistence schema V2)、通用 Binary/JSON/Text Patch 基础、generic manifest V1、已验证结构的 AssetBundle 变长重建、受支持 localized patch 发布/rollback、Rust-owned 双 release 查询/分发/cleanup V1,以及 Go `bat-api` 资源分发、内嵌 dashboard 和同机 live 联调。下一阶段的关键是 TM/Glossary 扩展、真实版本与复杂 AssetBundle 兼容和官方资源长期运行报告。
|
||||
|
||||
---
|
||||
|
||||
- **下一份应更新文档**:真实官方网络 smoke 记录
|
||||
- **下一项工程任务**:执行官方同步端到端 smoke,推进真实 Crowdin worker / 翻译记忆、复杂 AssetBundle 解析和 bat-api 全量 release 联调。
|
||||
- **下一份应补充的验证材料**:真实官方网络 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 只读分发);`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 重打包仍在后续阶段。
|
||||
|
||||
---
|
||||
|
||||
@@ -13,25 +13,26 @@
|
||||
- `bat-adapters` Unity、Manifest、Client 集成框架,以及当前真实形态 Addressables catalog 解析覆盖,含 `m_Crc` 提取和 UnityFS 解包/TextAsset 提取基础校验。
|
||||
- `bat-cas-engine` CAS V1:原子写入、BLAKE3 校验、引用计数、GC、并发写入测试、损坏检测。
|
||||
- `bat-infrastructure` CAS 适配层、SQLite Resource Repository、资源导入服务、官方资源 pull/update 服务。
|
||||
- `bat`:官方资源自动发现、全量拉取、原子发布到 `current -> versions/<id>`、本地 manifest audit/repair、`.part` 断点续传、403/404/5xx 分类重试、指数退避、顺序下载、下载 quarantine 诊断、ZIP 结构校验、官方 seed `.hash` 校验、snapshot/cache、版本化 `official-launcher-bootstrap.json`、`--watch` 常驻更新、`--daemon` 后台运行,以及 Unix socket JSON-RPC live control/backend 方法(`daemon.status/logs/stop/reload/refresh/doctor`、`resource.sync/verify/repair/state/manifest/list/index`、`parse.status/text_units/errors`、`localized.status`、`catalog.*`、`task.*`)。
|
||||
- `bat`:官方资源自动发现、全量拉取、原子发布到 `current -> versions/<id>`、本地 manifest audit/repair、`.part` 断点续传、403/404/5xx 分类重试、指数退避、默认并发 8(可配置 `1..=256`,report 按 plan 顺序、进度按完成数单调上报)、已发布历史 release 与 CAS 复用、下载 quarantine 诊断、ZIP 结构校验、官方 seed `.hash` 校验、snapshot/cache、版本化 `official-launcher-bootstrap.json`、`--watch` 常驻更新、`--daemon` 后台运行,以及 Unix socket JSON-RPC live control/backend 方法(`daemon.*`、`resource.*`、`parse.*`、`translation.tasks/handoff/task.update/worker.run`、`translation.memory.*`、`translation.glossary.*`、`localized.status`、`catalog.*`、`task.*`、`patch.apply`、`unityfs.patch_*`)。
|
||||
- `internal/backendrpc`:Go 侧 typed Unix socket JSON-RPC client,是 `bat-api` 调用 Rust daemon 的默认路径。
|
||||
- `cmd/bat-api`:资源 bootstrap + 分发 HTTP MVP(issue #19 / G-009);`/v1/bootstrap` 和 `/v1/launcher/bootstrap` 组织 `bat` 已发布 release 的启动前资源入口,launcher 形状兼容端点仅输出资源 metadata / GameMainConfig 引导,`/healthz` 暴露 RPC refresh 诊断,`/readyz` 做 release readiness,CDN path 支持 `GET`/`HEAD`/`Range`、ETag、Last-Modified 和缓存头;玩家-facing 控制面已具备 token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI 和管理面板预留;`.env` 配置端口/RPC socket/刷新周期;生产资源根来自 RPC,不负责自动拉取。
|
||||
- Go 边界权威说明:[`docs/reports/GO_STATUS.md`](docs/reports/GO_STATUS.md)(G-008 已关闭:同步 CLI = Rust `bat`)。
|
||||
- `cmd/bat-api`:资源 bootstrap + 分发 HTTP MVP(G-009);`/v1/bootstrap` 和 `/v1/launcher/bootstrap` 组织 `bat` 已发布 release 的启动前资源入口,launcher 形状兼容端点仅输出资源 metadata / GameMainConfig 引导,`/healthz` 暴露 RPC refresh 诊断,`/readyz` 做 release readiness,CDN path 支持 `GET`/`HEAD`/`Range`、ETag、Last-Modified 和缓存头;玩家-facing 控制面已具备 token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单、task/log/parse/translation/TM admin 查询控制入口和无构建内嵌 dashboard;`.env` 配置端口/RPC socket/刷新周期;生产资源根和长期状态来自 RPC,不负责自动拉取。
|
||||
- Go 边界权威说明:[`docs/reports/GO_STATUS.md`](docs/reports/GO_STATUS.md)(同步 CLI = Rust `bat`)。
|
||||
- 官方同步会维护 `<output>/official-version-state.json`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。
|
||||
- 资源导入链路可配置为在官方 release 发布后写入 CAS + `ResourceRepository`,资源 metadata 会记录 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式,TextAsset/Table/Media 会按类型分类索引;`resource.index` RPC 可按类型、hash、路径模式分页查询索引。
|
||||
- 新 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`;其中 TextUnit/Crowdin 队列只使用 Added/Modified 资源,不调用 Crowdin 网络 API。
|
||||
- `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 前置链路。
|
||||
- 资源导入链路可配置为在官方 release 发布后写入 CAS + `ResourceRepository`,资源 metadata 会记录 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式,TextAsset/Table/Media 会按类型分类索引;`resource.index` RPC/CLI 可按类型、hash、路径模式、官方 release ID、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 分页查询索引,常用 metadata 过滤会下推到 SQLite;历史 release 复用会重新校验 size、BLAKE3 和 ZIP 结构,失败时按历史 release、CAS、网络顺序回退,CAS 引用记录在 `official-cas-reuse-references.json` 中;`bat doctor cas` 可只读诊断既有 CAS 目录、对象数、对象字节数和元数据库文件状态。
|
||||
- 新 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 交接。
|
||||
- `translation.worker.run` 已提供 Rust `bat` 的 mock/Crowdin provider worker,支持 lease、失败重试、TextUnit 译文结果落库、Translation Memory persistence schema V2 和 Glossary domain/feature contract V1(SQLite persistence schema V2);Glossary 独立于 release task/TM,支持全局与 TextUnit scope、alias、priority、approved review、冲突诊断、provider constraints、deletion audit 和确定性 QA。TM 独立于 release task 库,支持 candidate/trusted、完整 context exact match、显式 confirm、supersede、冲突诊断/解决和 provenance 查询。模糊匹配和完整 Provider 扩展体系仍待实现。
|
||||
- `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 或生产同步的主集成边界。
|
||||
- 文档路线图、当前状态、缺口清单、官方资源运行指南。
|
||||
|
||||
仍未完成:
|
||||
|
||||
- `bat-api` 完整游戏业务 API / launcher 安装包更新全链(资源 CDN、HTTP 控制面和 launcher 资源引导兼容已可用)。
|
||||
- `bat-api` 完整游戏业务 API / launcher 安装包更新全链仍未完成;资源 CDN、HTTP 控制面、launcher 资源引导兼容和内嵌 dashboard MVP 已可用。
|
||||
- 完整 AssetBundle 对象级解析(UnityFS 解包、object table、TypeTree node 元数据、TextAsset bytes、MonoBehaviour/ScriptableObject 基础 TypeTree 字段级解析已起步;复杂字段覆盖、发布级重打包和 Patch 发布统一仍未完成)。
|
||||
- 复杂 AssetBundle 重打包和真实翻译构建 worker;当前通用 Binary/JSON/Text Patch 基础已在 crate 层可用,发布链路仍只开放 UnityFS TextAsset patch 前置能力。
|
||||
- Translation Memory、Glossary、AI Provider。
|
||||
- SDK、Web 管理后台。
|
||||
- 复杂 AssetBundle 重打包和完整翻译资产编排仍未完成;当前 generic manifest 已驱动已验证的 Binary/JSON/Text 与 UnityFS localized 操作,未知结构仍明确拒绝。
|
||||
- Translation Memory、Glossary 和完整 Provider 扩展体系:Translation Memory persistence schema V2 与 Glossary domain/feature contract V1(SQLite persistence schema V2)已由 Rust `bat` 持有;仍未实现的是模糊匹配、完整 Provider 扩展体系和完整 Web 协作后台。
|
||||
- SDK、完整 Web 协作后台。
|
||||
|
||||
详细状态见:
|
||||
|
||||
@@ -42,6 +43,7 @@
|
||||
- [当前缺口清单](docs/reports/CURRENT_GAPS.md)
|
||||
- [官方资源拉取与自动更新指南](docs/guides/official-resource-test-pull.md)
|
||||
- [官方全量拉取 Smoke Runbook](docs/guides/official-full-pull-smoke.md)
|
||||
- [bat-api 同机 Live Smoke Runbook](docs/guides/bat-api-local-live-smoke.md)
|
||||
- [官方资源后端说明](docs/architecture/official-resource-backend.md)
|
||||
|
||||
---
|
||||
@@ -51,19 +53,20 @@
|
||||
前置要求:
|
||||
|
||||
- Rust 1.75+
|
||||
- Go 1.22+
|
||||
- Go 1.26.4+
|
||||
- `curl`
|
||||
- `unzip`,仅旧版 launcher manifest 指向整包 ZIP 且 `--auto-discover` 需要从 ZIP 解析 `GameMainConfig` 时使用;当前目录型 manifest 会直接下载 `resources.assets`
|
||||
|
||||
运行当前通用验证:
|
||||
|
||||
```bash
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
go test ./...
|
||||
go vet ./...
|
||||
make ci-check
|
||||
```
|
||||
|
||||
`make ci-check` 是只读 required 门禁;`make format` / `make fmt` 才会格式化源码。
|
||||
Go lint 是 required gate,使用 `scripts/ci-versions.sh` 固定的
|
||||
`golangci-lint 2.12.2`;工具缺失或版本不匹配都会失败。
|
||||
|
||||
查看官方同步命令:
|
||||
|
||||
```bash
|
||||
@@ -101,11 +104,11 @@ cargo run -p bat-infrastructure --bin bat -- reload
|
||||
cargo run -p bat-infrastructure --bin bat -- stop
|
||||
```
|
||||
|
||||
`status`、`stop`、`logs`、`reload`、默认形态的 `refresh` 和默认形态的 `repair` 会优先连接 live RPC socket;socket 不可用时,状态和停止命令会回退到 PID/状态文件兼容路径。`reload` 不再强制重启进程,而是让后台 watch 循环重新自动发现并执行强制刷新:空闲睡眠时立即唤醒,正在同步时排队到当前轮结束后执行。确实需要替换启动参数时使用 `restart` 或给 `reload` 显式传入同步参数。后台 daemon 正在管理某个资源目录时,前台 `run/watch/refresh/repair` 不能直接写同一目录;默认形态的 `refresh`/`repair` 会改走 RPC,显式参数导致无法走 RPC 时需要先 `stop`。
|
||||
`status`、`stop`、`restart`、`logs`、`reload`、默认形态的 `refresh` 和默认形态的 `repair` 会优先连接 live RPC socket;socket 不可用时,状态和停止命令会回退到 PID/状态文件兼容路径。`restart` 会通过 Rust lifecycle controller 复用 CLI restart 路径替换后台进程;`reload` 不再强制重启进程,而是让后台 watch 循环重新自动发现并执行强制刷新:空闲睡眠时立即唤醒,正在同步时排队到当前轮结束后执行。确实需要替换启动参数时使用 `restart`,或给 `reload` 显式传入同步、输出、worker/TM 等 daemon 启动参数。后台 daemon 正在管理某个资源目录时,前台 `run/watch/refresh/repair` 不能直接写同一目录;默认形态的 `refresh`/`repair` 会改走 RPC,显式参数导致无法走 RPC 时需要先 `stop`。
|
||||
|
||||
`bat` 会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。
|
||||
|
||||
非 dry-run 同步不会把新文件直接写进生产可读目录。官方原版资源会先下载到 `<output>/.staging/<id>`,完成 manifest、BLAKE3、ZIP 和官方 `.hash` 校验后移动到 `<output>/versions/<id>`,再原子切换 `<output>/current` symlink;生产读取方应只读取 `<output>/current`。同步过程会更新 `<output>/official-version-state.json`:下载开始时写入 `in_progress_version`,发布成功后写入 `current_completed_version` 和 `previous_available_version`,失败或中断时写入 `failed_versions`。启用 `--auto-discover` 时,已发布 release 会写入 `official-launcher-bootstrap.json`,其中包含 launcher metadata、launcher CDN config、remote manifest 文件列表、选中的 `resources.assets` 来源和 `GameMainConfig` 摘要;官方资源端尚未开放时会写 `<output>/official-launcher-bootstrap.pending.json`,但不会切换 `current`。新 release 发布后会对比上一完整 release 的 download manifest,在当前 release 下写入 `official-resource-changes.json`、`crowdin-translation-handoff.json`、`official-parse-cache.json`、`official-textunit-index.json`、`official-textunit-tasks.json` 和 `crowdin-textunit-queue.json`;新增+变更资源作为解析/翻译候选,删除资源只进入差异记录。up-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析。官方同步报告默认 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布;后续 Patch/导出写入 `--localized-output` / `BAT_LOCALIZED_OUTPUT` 指定的独立目录,保留官方相对目录结构,manifest 校验通过后才切换为 `localized`。
|
||||
非 dry-run 同步不会把新文件直接写进生产可读目录。官方原版资源会先下载到 `<output>/.staging/<id>`,完成 manifest、BLAKE3、ZIP 和官方 `.hash` 校验后移动到 `<output>/versions/<id>`,再原子切换 `<output>/current` symlink;生产读取方应只读取 `<output>/current`。同步过程会更新 `<output>/official-version-state.json`:下载开始时写入 `in_progress_version`,发布成功后写入 `current_completed_version` 和 `previous_available_version`,失败或中断时写入 `failed_versions`。启用 `--auto-discover` 时,已发布 release 会写入 `official-launcher-bootstrap.json`,其中包含 launcher metadata、launcher CDN config、remote manifest 文件列表、选中的 `resources.assets` 来源和 `GameMainConfig` 摘要;官方资源端尚未开放时会写 `<output>/official-launcher-bootstrap.pending.json`,但不会切换 `current`。新 release 发布后会对比上一完整 release 的 download manifest,在当前 release 下写入 `official-resource-changes.json`、`crowdin-translation-handoff.json`、`official-parse-cache.json`、`official-textunit-index.json`、`official-textunit-tasks.json` 和 `crowdin-textunit-queue.json`;新增+变更资源作为解析/翻译候选,删除资源只进入差异记录。复用历史 release 时会先按 destination 找候选并重新校验 size、BLAKE3、ZIP 结构,必要时验证 CAS;候选不可靠就记录诊断并回退网络,不会静默复用。CAS release 引用存放在 `official-cas-reuse-references.json`,孤儿 staging 清理时会递减这些引用。up-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析。官方同步报告默认 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布;后续 Patch/导出写入 `--localized-output` / `BAT_LOCALIZED_OUTPUT` 指定的独立目录,保留官方相对目录结构,manifest 校验通过后才切换为 `localized`。
|
||||
|
||||
资源操作命令默认输出人类可读摘要,并在没有显式 metadata 参数时默认走官方自动发现。脚本或上层程序需要稳定结构化输出时加 `--json`:
|
||||
|
||||
@@ -135,18 +138,18 @@ make official-smoke
|
||||
|
||||
该 smoke 会执行 dry-run plan、首次全量拉取、二次 `up_to_date` 检查、本地文件破坏后的 `repair`、repair 后 `verify`,并在 `report/SMOKE_REPORT.md` 记录命令、输出目录、active release、文件数量、release 大小和被破坏文件。大型官方资源文件不纳入 Git。
|
||||
|
||||
生产官方资源输出目录和汉化产物目录都必须使用独立目录,不要指向已有客户端目录,也不要指向 `/home/wanye/D/BlueArchive` 这类人工维护或开发资源目录。需要覆盖官方原版资源位置时,用 `--output <资源目录>` 或 `.env` 中的 `BAT_OUTPUT`;需要覆盖汉化产物位置时,用 `--localized-output <目录>` 或 `.env` 中的 `BAT_LOCALIZED_OUTPUT`;需要启用官方 release 导入 CAS/索引时,用 `--import-repository`,并可用 `--import-cas-root`、`--import-resource-db` 或 `.env` 中的 `BAT_IMPORT_CAS_ROOT`、`BAT_IMPORT_RESOURCE_DB` 覆盖默认路径;需要覆盖后台状态目录时,用 `--state-dir <状态目录>`。
|
||||
生产官方资源输出目录和汉化产物目录都必须使用独立目录,不要指向已有客户端目录,也不要指向 `/home/wanye/D/BlueArchive` 这类人工维护或开发资源目录。需要覆盖官方原版资源位置时,用 `--output <资源目录>` 或 `config.toml` 中 `[resource].output_root` / 环境变量 `BAT_OUTPUT`;需要覆盖汉化产物位置时,用 `--localized-output <目录>` 或 `config.toml` 中 `[localized].output_root` / 环境变量 `BAT_LOCALIZED_OUTPUT`;需要启用官方 release 导入 CAS/索引时,用 `--import-repository`,并可用 `config.toml` 中 `[repository].import_cas_root`、`[repository].import_resource_repository_path` 或环境变量 `BAT_IMPORT_CAS_ROOT`、`BAT_IMPORT_RESOURCE_DB` 覆盖默认路径;需要覆盖后台状态目录时,用 `--state-dir <状态目录>` 或 `config.toml` 中 `[runtime].state_dir`。
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Rust:CAS、官方资源同步核心、AssetBundle/Patch 引擎;当前生产同步入口是 `bat` binary。
|
||||
- Go:计划中的最小 CLI、服务编排、API Server、SDK;默认通过 `bat --json` 进程边界或未来 SDK 集成 Rust 能力。
|
||||
- Go:当前正式入口是 `bat-api` 资源 bootstrap/分发服务、内嵌 dashboard 和 `internal/backendrpc`;完整游戏业务 API、SDK、Provider 编排仍按路线图推进,`cmd/bat` 仅为试验 CLI。
|
||||
- `bat-ffi`:可选兼容层,只暴露无状态粗粒度 JSON C ABI,不承载 daemon、下载器、CAS handle 或主控制面。
|
||||
- PostgreSQL:计划中的服务端主数据库。
|
||||
- Redis:计划中的缓存、队列状态、限流和短期锁。
|
||||
- Vue 3 + TypeScript:计划中的 Web 管理后台。
|
||||
- Vue 3 + TypeScript:计划中的完整 Web 协作后台;当前已先提供无构建内嵌 dashboard。
|
||||
- Docker / Docker Compose:数据库和后续服务部署配置。
|
||||
|
||||
---
|
||||
@@ -167,8 +170,8 @@ BlueArchiveToolkit/
|
||||
├── internal/ffi/ # 可选 CGO 兼容包装,不是 Go CLI 主路径
|
||||
├── cmd/ # Go CLI 试验骨架与后续产品入口
|
||||
├── pkg/ # Go SDK 包,尚未实现
|
||||
├── api/ # API 定义,尚未实现
|
||||
├── web/ # Web 管理后台,尚未实现
|
||||
├── api/ # 预留 API 定义;bat-api OpenAPI 静态规范已提供,完整业务 API 尚未实现
|
||||
├── web/ # bat-api 内嵌 dashboard 静态资产;完整协作后台仍在后续阶段
|
||||
├── deployments/ # Docker 和部署配置
|
||||
├── docs/ # 文档、历史报告和分析资料
|
||||
├── Cargo.toml
|
||||
@@ -182,13 +185,13 @@ BlueArchiveToolkit/
|
||||
|
||||
近期优先级:
|
||||
|
||||
1. 收敛 Go CLI 产品入口的最终形态:当前 `cmd/bat` 仅有 `doctor`、`manifest inspect`、`sync plan` 试验能力,不应误写成完整 CLI。
|
||||
1. 维护并联调 Go `bat-api` 资源 bootstrap/分发入口和内嵌 dashboard;`cmd/bat` 仅有 `doctor`、`manifest inspect`、`sync plan` 试验能力,不应误写成完整产品 CLI。
|
||||
2. 补齐 AssetBundle UnityFS 引擎级解析。
|
||||
3. 扩展 Addressables catalog 解析覆盖,继续用真实形态 fixture/golden 锁定行为。
|
||||
4. 将 `official-textunit-tasks.json` / `crowdin-textunit-queue.json` 接入真实 Crowdin worker、翻译记忆和 Patch 构建。
|
||||
4. 基于 `translation.worker.run` provider worker 继续推进完整 Patch 构建和发布/回滚闭环。
|
||||
5. 按 smoke runbook 在具备网络和磁盘窗口的环境中执行真实官方全量拉取,并保留本地报告。
|
||||
|
||||
不建议在 Go 产品入口、资源解析和文本提取基础能力完成前优先开发 Web UI。
|
||||
当前已提供直接调用 bat-api 鉴权接口的内嵌 dashboard;完整 Web 协作后台仍应在 TM 扩展、权限模型和持久化 API 明确后推进。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+200
-15
@@ -12,9 +12,9 @@
|
||||
`bat` 是 Linux 上官方日服(Yostar JP)资源同步的正式入口。它可以:
|
||||
|
||||
- `--auto-discover` 从官方 HTTP metadata 解析 `GameMainConfig`,自动获得 app-version、连接组和 server-info,不安装、不启动官方启动器;已发布 release 会保存 `official-launcher-bootstrap.json`。
|
||||
- 生成官方全量 pull plan、执行真实下载,维护 release 内的下载 manifest,并做 size + BLAKE3 复用校验、官方 seed `.hash`(标准 xxHash32(seed=0))强校验、ZIP 结构校验。
|
||||
- 生成官方全量 pull plan、执行真实下载,维护 release 内的下载 manifest,并做 size + BLAKE3 复用校验、已发布历史 release/CAS 复用、官方 seed `.hash`(标准 xxHash32(seed=0))强校验、ZIP 结构校验。
|
||||
- 断点续传、失败分类重试、下载 quarantine、本地 manifest audit/repair。
|
||||
- 原子发布:先写 `.staging/<id>`,校验通过后发布 `versions/<id>` 并原子切换 `current` symlink。
|
||||
- 原子发布:先写 `.staging/<id>`,优先复用已验证历史 release/CAS,校验通过后发布 `versions/<id>` 并原子切换 `current` symlink;CAS 引用记录在 release 内的 `official-cas-reuse-references.json`。
|
||||
- 常驻运行(`--watch`)或后台化(`--daemon`),通过 `bat.sock` Unix socket JSON-RPC 控制。
|
||||
|
||||
### 运行形态
|
||||
@@ -44,6 +44,23 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon
|
||||
|
||||
| 命令 | 说明 |
|
||||
|---|---|
|
||||
| `res pull` | 拉取官方资源;支持单次、限定次数和 `--watch` 周期执行 |
|
||||
| `res schedule` | 管理资源拉取计划;CLI、RPC 和 `bat-api` dashboard 共用计划状态 |
|
||||
| `parse run` | 执行当前官方 release 的解析和 TextUnit 队列刷新 |
|
||||
| `parse clear-cache` | 使用 `--force` 清理当前 release 的可再生解析缓存和翻译队列 |
|
||||
| `parse repack` | 根据 JSON spec 批量重打包 UnityFS bundle |
|
||||
| `parse schedule` | 管理解析计划;与 `res schedule` / `i18n schedule` 共用同一计划状态 |
|
||||
| `i18n run` / `i18n export` | 刷新离线翻译队列或导出可编辑翻译工作台 |
|
||||
| `i18n set` / `i18n get` / `i18n unset` | 手动查看、修改或清空一个翻译工作台条目;也可通过 `i18n workbench ...` 或 `--workbench` 访问 |
|
||||
| `i18n validate` | 发布前校验工作台 release、source text 和 patch 目标 |
|
||||
| `i18n proofread` | 将当前汉化 workflow 标记为人工校对中 |
|
||||
| `i18n tasks` / `i18n task list` / `i18n task status` | 查询当前离线 TextUnit 翻译任务状态 |
|
||||
| `i18n handoff` | 查询当前翻译交接视图 |
|
||||
| `i18n status` | 显示当前汉化 release 状态 |
|
||||
| `i18n task update` | 回写 provider worker 任务状态 |
|
||||
| `i18n worker run` | 运行真实 provider worker;支持单次、限定次数和周期执行 |
|
||||
| `i18n publish` | 按工作台或 `--patch-manifest` 发布独立汉化 release;`--force` 使用新的手动 release ID |
|
||||
| `i18n schedule` | 管理翻译和汉化发布计划 |
|
||||
| `refresh` | 执行一次更新检查;若有 live daemon,则通过 RPC 请求其刷新 |
|
||||
| `verify` | 校验远端计划、本地 manifest 和官方 seed hash(dry-run + 审计当前 release) |
|
||||
| `repair` | 重新下载本地校验失败的资源 |
|
||||
@@ -56,6 +73,17 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon
|
||||
| `clean-stable` | 清理 `.part`/`.tmp`/失效锁、PID、socket(daemon 运行中会拒绝执行) |
|
||||
|
||||
`status`/`stop`/`logs`/`reload` 和默认形态的 `refresh` 优先走 `bat.sock` JSON-RPC;socket 不可用时 `status`/`stop` 回退到 PID/状态文件兼容路径。
|
||||
需要替换 daemon 启动参数时使用 `restart`,或给 `reload` 显式传入同步、输出、worker/TM 等启动参数;未显式传参的 `restart` 复用上次保存的启动命令。
|
||||
|
||||
Rust `bat` 工作流的完整命令、工作台字段、重打包 spec、调度计划和
|
||||
`bat-api` 调度接口见 [`docs/guides/bat-workflows.md`](docs/guides/bat-workflows.md)。
|
||||
一级命令推荐使用短名称 `res`、`parse`、`i18n`;`resource`、`resources`、
|
||||
`translation`、`translate` 仍是兼容别名。
|
||||
其中 `translation` / `translate` 也支持 `tasks`、`handoff`、`status` 和
|
||||
`task update`;`--translation-file` 也可写成 `--workbench`,`--schedule-id` /
|
||||
`--schedule-action` 也可简写为 `--id` / `--action`。
|
||||
`resource status`、`resource schedule`、`translation tasks`、`translation handoff`
|
||||
和 `translation status` 也都与对应短命令一致。
|
||||
|
||||
### bat-api 资源 bootstrap / 分发服务
|
||||
|
||||
@@ -109,13 +137,32 @@ BAT_API_SKIP_ENV_FILE=1 go run ./cmd/bat-api \
|
||||
| `GET /yostar-serverinfo.bluearchiveyostar.com/server-info.json` | 官方 host/path 形态的 server-info |
|
||||
| `GET/HEAD /prod-clientpatch.bluearchiveyostar.com/...` | 官方 CDN path 形态资源字节 |
|
||||
| `GET /openapi.yaml` | bat-api OpenAPI 文档 |
|
||||
| `GET /admin/` | 管理面板预留入口;当前返回 JSON 链接,未来接入 Web UI |
|
||||
| `GET /admin/` | 管理控制入口与允许操作列表 |
|
||||
| `GET /admin/dashboard/` | 内嵌管理 dashboard 静态页面;页面调用的管理 API 仍需要 token |
|
||||
| `GET /admin/diagnostics` | 读取 Rust daemon 诊断;需要管理 token |
|
||||
| `GET /admin/logs?tail=200` | 读取 Rust daemon 日志尾部;需要管理 token |
|
||||
| `GET /admin/tasks` | 读取 Rust daemon 任务列表;需要管理 token |
|
||||
| `GET /admin/tasks/status?task_id=...` | 读取单项任务状态;需要管理 token |
|
||||
| `GET /admin/tasks/logs?task_id=...` | 读取单项任务日志;需要管理 token |
|
||||
| `GET /admin/schedules?id=...&group=...&enabled=...` | 读取/过滤 Rust `bat` 调度计划;需要管理 token |
|
||||
| `GET /admin/parse/status` | 读取当前 release 解析状态;需要管理 token |
|
||||
| `GET /admin/parse/text-units?...` | 分页查询当前 release TextUnit 明细;需要管理 token |
|
||||
| `GET /admin/parse/errors?...` | 分页查询当前 release 解析错误;需要管理 token |
|
||||
| `GET /admin/translation/tasks?limit=100&worker_status=failed` | 读取/过滤 Rust 翻译任务和 provider worker 状态;需要管理 token |
|
||||
| `GET /admin/translation/handoff` | 读取当前 release 的完整翻译交接视图;需要管理 token |
|
||||
| `GET /admin/translation/memory/summary` | 读取 Rust TM schema、记录总数及 candidate/trusted 等状态计数;需要管理 token |
|
||||
| `GET /admin/translation/memory/query?source_text=...&source_context=...&limit=100` | 按 raw source/context 查询 Rust TM 记录、复用判定和 provenance;需要管理 token |
|
||||
| `GET /admin/translation/glossary/summary` | 读取 Rust Glossary schema 和 review-state 计数;需要管理 token |
|
||||
| `GET /admin/translation/glossary/query?source_text=...&review_status=approved&limit=100` | 查询 Rust term、scope、source provenance 和 history;需要管理 token |
|
||||
| `GET /admin/translation/glossary/diagnose?source_text=...&context=...` | 执行 deterministic Glossary constraints/diagnostics QA 并返回 `qa_identity`;需要管理 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,也不仿造登录、账号、网关、鉴权或游戏业务协议。
|
||||
|
||||
生产面对玩家分发时,应启用 HTTP token 鉴权、限流和访问日志:
|
||||
|
||||
- `BAT_API_AUTH_TOKEN`:启用 `Authorization: Bearer <token>`、`X-BAT-Token` 或 query fallback 鉴权;token 推荐由 secret manager 或进程环境提供,不建议写入提交文件。
|
||||
- `BAT_API_AUTH_TOKEN`:启用 `Authorization: Bearer <token>`、`X-BAT-Token` 或 query fallback 鉴权;token 推荐由 secret manager 或进程环境提供,不建议写入提交文件。`/admin/control/*`、`/admin/schedules`、`/admin/tasks*`、`/admin/logs`、`/admin/diagnostics`、`/admin/parse/*` 和 `/admin/translation/*` 需要此 token;`/admin/dashboard/` 静态资产默认免鉴权,便于浏览器打开后再在页面内配置 token。
|
||||
- `BAT_API_AUTH_QUERY_PARAM`:query fallback 参数名,默认 `bat_token`;兼容不能写 header 的客户端,访问日志不会记录 query。
|
||||
- `BAT_API_AUTH_EXEMPT_PATHS`:逗号分隔的免鉴权 path 或 slash-prefix,例如 `/healthz,/readyz`。
|
||||
- `BAT_API_RATE_LIMIT_RPS` / `BAT_API_RATE_LIMIT_BURST`:按客户端 IP 的进程内 token bucket 限流;边缘反代/CDN 仍应配置独立限流。
|
||||
@@ -123,7 +170,38 @@ launcher 兼容端点只服务启动前资源发现。它们复用 Rust `bat` sn
|
||||
- `BAT_API_ACCESS_LOG`:结构化访问日志,记录 method/path/status/bytes/duration/client_ip/request_id/user_agent,不记录 query string。
|
||||
- `BAT_API_MAX_RESOURCE_LIMIT`:`/v1/resources` 最大分页上限,默认 `1000`。
|
||||
|
||||
所有动态 JSON(bootstrap、health、ready、release、resources、launcher 兼容、server-info、OpenAPI、admin placeholder 和错误响应)显式返回 `Cache-Control: no-store`。资源字节 CDN path 仍返回长期 immutable cache header。
|
||||
`POST /admin/control/{action}` 只转发固定白名单内的 Rust RPC,不是任意 RPC proxy:
|
||||
|
||||
| action | Rust RPC | 参数 | 返回 |
|
||||
|---|---|---|---|
|
||||
| `reload` | `daemon.reload` | 无 | `202` accepted |
|
||||
| `refresh` | `daemon.refresh` | 可选 `{ "force": true }` | `202` accepted |
|
||||
| `restart` | `daemon.restart` | 无 | `202` accepted |
|
||||
| `sync` | `resource.sync` | 可选 `{ "force": true }` | `202` + task |
|
||||
| `verify` | `resource.verify` | 无 | `202` + task |
|
||||
| `repair` | `resource.repair` | 无 | `202` + task |
|
||||
| `catalog-refresh` | `catalog.refresh` | 可选 `{ "force": true }` | `202` + task |
|
||||
| `schedule-add` | `schedule.add` | 调度 mutation JSON | `202` + Rust schedule report |
|
||||
| `schedule-update` | `schedule.update` | 调度 mutation JSON | `202` + Rust schedule report |
|
||||
| `schedule-remove` | `schedule.remove` | `{ "id": "..." }` | `202` + Rust schedule report |
|
||||
| `schedule-run` | `schedule.run` | 可选 `{ "id": "...", "force": true }` | `202` + 执行报告 |
|
||||
| `task-cancel` | `task.cancel` | `{ "task_id": "..." }` | `202` + 取消请求结果 |
|
||||
| `translation-task-update` | `translation.task.update` | `{ "task_id": "...", "status": "completed", "provider": "manual", "provider_run_id": "...", "translation_results": [{ "unit_id": "...", "source_text": "...", "translated_text": "...", "glossary_override": { "qa_identity": "...", "reviewer": "...", "reason": "...", "provenance": "...", "confirmed_unix_seconds": 1 } }] }` | `202` + 当前任务记录 |
|
||||
| `translation-worker-run` | `translation.worker.run` | `{ "provider": "mock", "concurrency": 8, "max_tasks": 2 }` | `202` + worker task |
|
||||
| `translation-proofread` | `translation.proofread` | 无 | `202` + 汉化状态 |
|
||||
| `translation-memory-confirm` | `translation.memory.confirm` | `{ "record_id": "...", "reviewer": "...", "reason": "...", "supersede_record_id": "..." }` | `202` + 已确认的 TM 记录 |
|
||||
| `translation-memory-resolve-conflict` | `translation.memory.resolve_conflict` | `{ "winner_record_id": "...", "expected_trusted_record_ids": ["..."], "reviewer": "...", "reason": "..." }` | `202` + 冲突解决报告 |
|
||||
| `translation-glossary-add` | `translation.glossary.add` | term draft JSON | `202` + Glossary term |
|
||||
| `translation-glossary-update` | `translation.glossary.update` | term draft + `reviewer` | `202` + Glossary term |
|
||||
| `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": "..." }` 或 `{ "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 暴露。
|
||||
|
||||
所有动态 JSON(bootstrap、health、ready、release、resources、launcher 兼容、server-info、OpenAPI、admin 和错误响应)显式返回 `Cache-Control: no-store`。资源字节 CDN path 仍返回长期 immutable cache header。
|
||||
|
||||
CDN path 只服务 manifest 索引内且磁盘存在、size 匹配的文件。响应支持 `GET`、`HEAD`、`Range`、条件请求、ETag、Last-Modified、Accept-Ranges 和长期缓存头;ETag 优先使用 manifest 中的 BLAKE3。`.hash` 以 `text/plain` 返回,其它未知扩展默认为 `application/octet-stream`。
|
||||
|
||||
@@ -171,6 +249,7 @@ curl -i -H 'Range: bytes=0-1023' \
|
||||
| `--proxy <URL\|auto\|none>` | curl 代理覆盖(默认 `auto`,从环境变量检测)。scheme 支持 http/https/socks4/socks4a/socks5/socks5h |
|
||||
| `--no-proxy` | 强制直连 |
|
||||
| `--unzip <PATH>` | unzip 可执行文件(默认 `unzip`) |
|
||||
| `--zip <PATH>` | zip 可执行文件(默认 `zip`) |
|
||||
| `--dry-run` | 不写同步状态 |
|
||||
| `--plan` | dry-run 时输出计划中的 URL |
|
||||
| `--force` | 强制下载/刷新 |
|
||||
@@ -207,16 +286,78 @@ curl -i -H 'Range: bytes=0-1023' \
|
||||
- 强制刷新:每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 各一次。
|
||||
- 状态类文件默认 `0600` 权限,读写不跟随 symlink。
|
||||
|
||||
### 配置文件(`.env`,无参启动)
|
||||
### 配置文件(config.toml,无参启动)
|
||||
|
||||
`bat` 首次启动时会在**二进制所在目录**释放一个 `.env` 配置模板(`0600` 权限,已存在则不动)。之后每次启动自动加载该文件,把其中的键作为进程环境变量(不覆盖已存在的环境变量),因此编辑 `.env` 后直接运行 `bat`(无参数)即可按配置启动。
|
||||
`bat` 首次启动时会在**二进制所在目录**释放一个 `config.toml.example` 配置模板(`0600` 权限,已存在则不动)。程序只读取同目录下的 `config.toml`;`config.toml.example` 只是模板,不会被自动读取,也不会自动复制或重命名为 `config.toml`。没有 `config.toml` 时,程序继续使用进程环境变量和内置默认值启动。
|
||||
|
||||
- 优先级:**命令行参数 > 进程环境变量 > `.env` > 内置默认值**。
|
||||
- 语法:每行 `KEY=VALUE`;`#` 开头为注释;值两侧成对引号会剥除;空值视为未设置。
|
||||
- 支持的键:`BAT_OUTPUT`、`BAT_STATE_DIR`、`BAT_AUTO_DISCOVER`、`BAT_WATCH`、`BAT_DAEMON`、`BAT_PROXY`、`BAT_NO_PROXY`、`BAT_INTERVAL_SECONDS`、`BAT_ERROR_RETRY_SECONDS`、`BAT_APP_VERSION`、`BAT_CONNECTION_GROUP`、`BAT_LAUNCHER_VERSION`、`BAT_PLATFORMS`、`BAT_CURL`、`BAT_UNZIP`、`BAT_JSON`、`BAT_QUIET_UP_TO_DATE`;也可以直接写 `HTTPS_PROXY` 等通用环境变量(走现有代理自动检测)。布尔值支持 `1/0/true/false/yes/no/on/off`。
|
||||
- `BAT_WATCH` / `BAT_DAEMON` 只对无子命令的 `bat` 生效(两者同时为 `1` 时 daemon 优先);命令行显式传入 `--watch` / `--daemon` / `--dry-run` 时 `.env` 的模式开关让位。`status` / `verify` 等子命令不受它们影响。
|
||||
由于 `config.toml` 可能包含代理凭据,Unix 下实际 `config.toml` 必须保持 `0600` 或更严格;权限过宽时程序会拒绝读取。
|
||||
|
||||
- 优先级:**命令行参数 > 进程环境变量 > `config.toml` > 内置默认值**。
|
||||
- `config.toml` 的字段按职责分组:`[runtime]`、`[resource]`、`[localized]`、`[repository]`、`[network]`、`[translation.worker]`。
|
||||
- 现有 `BAT_*` 环境变量仍然有效,可继续覆盖 `config.toml` 中的同名配置。
|
||||
- `BAT_SKIP_ENV_FILE` 已废弃且不再影响启动。
|
||||
- 支持的环境变量:`BAT_OUTPUT`、`BAT_LOCALIZED_OUTPUT`、`BAT_STATE_DIR`、`BAT_AUTO_DISCOVER`、`BAT_WATCH`、`BAT_DAEMON`、`BAT_IMPORT_REPOSITORY`、`BAT_IMPORT_CAS_ROOT`、`BAT_IMPORT_RESOURCE_DB`、`BAT_PROXY`、`BAT_NO_PROXY`、`BAT_INTERVAL_SECONDS`、`BAT_ERROR_RETRY_SECONDS`、`BAT_APP_VERSION`、`BAT_CONNECTION_GROUP`、`BAT_LAUNCHER_VERSION`、`BAT_PLATFORMS`、`BAT_CURL`、`BAT_DOWNLOAD_CONCURRENCY`、`BAT_UNZIP`、`BAT_ZIP`、`BAT_JSON`、`BAT_QUIET_UP_TO_DATE`、`BAT_TRANSLATION_PROVIDER`、`BAT_TRANSLATION_FIXTURE`、`BAT_TRANSLATION_MEMORY_PATH`、`BAT_GLOSSARY_PATH`、`BAT_TRANSLATION_CONCURRENCY`、`BAT_TRANSLATION_MAX_ATTEMPTS`、`BAT_TRANSLATION_LEASE_SECONDS`、`BAT_TRANSLATION_RETRY_BACKOFF_SECONDS`、`BAT_TRANSLATION_MAX_TASKS`、`BAT_TRANSLATION_WORKER_ID`;也可以直接写 `HTTPS_PROXY` 等通用环境变量(走现有代理自动检测)。布尔值支持 `1/0/true/false/yes/no/on/off`。
|
||||
- `BAT_WATCH` / `BAT_DAEMON` 只对无子命令的 `bat` 生效(两者同时为 `1` 时 daemon 优先);命令行显式传入 `--watch` / `--daemon` / `--dry-run` 时运行模式设置让位。`status` / `verify` 等子命令不受它们影响。
|
||||
- 已运行的 daemon 不会热读 `config.toml`;默认 `reload` 只唤醒后台重新发现和刷新。需要应用配置文件变更时,使用带显式启动参数的 `restart`/`reload`,或先 `stop` 再重新启动 daemon。
|
||||
- `BAT_REDIS_URL` / `BAT_REDIS_PASSWORD` 为**预留键**:Redis 任务后端尚未接入,当前任务历史持久化在 `<state-dir>/bat-tasks.json`。
|
||||
- 设 `BAT_SKIP_ENV_FILE=1` 可让 `bat` 完全跳过 `.env` 的生成与加载。
|
||||
|
||||
### Translation Memory persistence schema V2
|
||||
|
||||
Translation Memory 由 Rust `bat` 独立持有,默认路径为
|
||||
`<output>/translation-memory.sqlite`,不在 `versions/<id>` 内,也不使用当前 release
|
||||
的 `translation-tasks.sqlite`。可通过 `[translation.worker].translation_memory_path`、
|
||||
`BAT_TRANSLATION_MEMORY_PATH` 或 `--translation-memory-path` 覆盖。
|
||||
|
||||
```bash
|
||||
bat i18n memory summary
|
||||
bat i18n memory query --tm-source-text '原始文本' --tm-context-json '{"destination":"Table.bytes","archive_entry":"","field_path":"Text"}'
|
||||
bat i18n memory confirm --tm-record-id 'tm-...' --tm-reviewer 'operator' --tm-reason '人工校对通过'
|
||||
bat i18n memory conflicts --tm-limit 100
|
||||
bat i18n memory resolve-conflict --tm-record-id 'tm-winner-...' \
|
||||
--tm-expected-trusted-record-ids-json '["tm-winner-...","tm-loser-..."]' \
|
||||
--tm-reviewer 'operator' --tm-reason '确认唯一译文'
|
||||
```
|
||||
|
||||
只有 raw source 完全相同、完整 context 完全相同且状态为 current `trusted` 的单条记录会被
|
||||
worker 自动复用。provider 输出写入先是 `candidate`;manual task result 即使 completed
|
||||
也不会自动建立 TM 或 trusted。查询、诊断、确认和冲突治理对应 Rust
|
||||
RPC `translation.memory.summary`、`translation.memory.query`、
|
||||
`translation.memory.confirm`、`translation.memory.conflicts` 和
|
||||
`translation.memory.resolve_conflict`。同一 identity 存在多个 current Trusted 时,
|
||||
查询返回 `trusted_conflict`,worker 禁止自动复用;确认不同译文必须显式指定 supersede
|
||||
目标,历史冲突必须通过 resolve_conflict 选择稳定 record ID。context 不完整或不一致、
|
||||
normalized source 辅助命中和 workflow `proofread` 都不会自动建立 trusted 记录。
|
||||
|
||||
### Glossary domain/feature contract V1,SQLite persistence schema V2
|
||||
|
||||
Glossary 由 Rust `bat` 独立持有,默认路径为 `<output>/glossary.sqlite`,不位于
|
||||
`versions/<id>`,也不与 TM 或当前 release 的 task 库共用。配置覆盖方式为
|
||||
`[translation.worker].glossary_path`、`BAT_GLOSSARY_PATH` 或
|
||||
`--glossary-path`。缺少数据库时 summary/query/diagnose 返回 `available=false`,
|
||||
不会因只读查询创建空库。
|
||||
|
||||
```bash
|
||||
bat i18n glossary summary
|
||||
bat i18n glossary add --glossary-term-id term-sensei \
|
||||
--glossary-source-term Sensei \
|
||||
--glossary-recommended-translation 老师 \
|
||||
--glossary-allowed-translations-json '["老师大人"]' \
|
||||
--glossary-source-kind manual --glossary-source-ref issue-123
|
||||
bat i18n glossary approve --glossary-term-id term-sensei \
|
||||
--glossary-reviewer operator --glossary-reason '术语审校通过'
|
||||
bat i18n glossary diagnose --glossary-source-text 'Sensei' \
|
||||
--glossary-context-json '{"destination":"Table.bytes"}'
|
||||
bat i18n glossary delete --glossary-term-id term-sensei \
|
||||
--glossary-reviewer operator --glossary-reason '重复术语'
|
||||
```
|
||||
|
||||
只有 `approved` term 会进入 provider constraints 和 TM 自动复用前的 QA。
|
||||
scope 为空表示全局;同一 TextUnit 内冲突会 blocked,priority、scope specificity、
|
||||
匹配长度和 term ID 使用确定性排序。允许但非推荐译法只产生 warning,系统不会在译文
|
||||
生成后自动替换文本。provider、TM、人工 task update、workbench 和 localized publish
|
||||
均执行相同 QA;每个具体 QA 都有稳定的 `qa_identity`。blocking deviation 需要
|
||||
`qa_identity`、`reviewer`、`reason`、`provenance` 和确认时间组成显式 override;
|
||||
Glossary 相关定义变化会使受影响 override 失效,无关术语变化不会使其失效。
|
||||
|
||||
---
|
||||
|
||||
@@ -360,12 +501,26 @@ curl -i -H 'Range: bytes=0-1023' \
|
||||
| `daemon.stop` | ✅ | 请求停止(`accepted`) |
|
||||
| `daemon.reload` | ✅ | 请求重新发现并强制刷新(`accepted`) |
|
||||
| `daemon.refresh` | ✅ | 请求刷新检查(`params.force`,`accepted`) |
|
||||
| `daemon.restart` | ✅ | 启动 Rust lifecycle controller,并在响应后停止当前 daemon(`accepted`) |
|
||||
| `daemon.doctor` | ✅ | 返回运行时诊断报告(只读,不清理、不重启) |
|
||||
| `resource.state` | ✅ | 资源发布根 + 版本状态 + 上次同步结果 |
|
||||
| `resource.sync` | ✅ | 触发同步任务(`params.force`),返回 `task_id` |
|
||||
| `resource.verify` | ✅ | 触发校验任务(dry-run + audit),返回 `task_id` |
|
||||
| `resource.repair` | ✅ | 触发本地 manifest 审计 + 修复任务,返回 `task_id`;不继承 `force` |
|
||||
| `resource.manifest` / `resource.list` | ✅ | 当前版本下载 manifest 分页查询(`params.offset` 默认 0、`params.limit` 默认 100/上限 1000) |
|
||||
| `resource.index` | ✅ | 查询现有 SQLite ResourceRepository 索引,支持资源类型、hash、路径模式、release、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 过滤 |
|
||||
| `release.status` | ✅ | 查询 official/localized 当前与历史 release 的统一状态、来源关系和完整性 |
|
||||
| `release.list` | ✅ | 按 namespace 查询历史 release 摘要,识别 current、legacy、stale 和 damaged |
|
||||
| `release.distribution` | ✅ | 选择已验证的 official 或 localized 当前/历史 release;默认 official,不跨 channel fallback |
|
||||
| `release.cleanup` | ✅ | 先 dry-run 生成 `plan_id`,再由 Rust 重验证引用后清理无引用历史 release |
|
||||
| `parse.status` | ✅ | 查询当前 release 的解析缓存、TextUnit 索引和队列摘要 |
|
||||
| `parse.text_units` / `parse.errors` | ✅ | 查询当前 release 的 TextUnit 明细和解析错误 |
|
||||
| `translation.tasks` | ✅ | 查询离线 TextUnit 翻译任务及 worker 状态 |
|
||||
| `translation.handoff` | ✅ | 查询完整 job/unit/provider run 交接视图 |
|
||||
| `translation.task.update` | ✅ | 回写当前 release 的 provider worker 状态 |
|
||||
| `translation.worker.run` | ✅ | 触发 Rust provider worker,落库 TextUnit 译文结果、lease、失败分类和重试状态 |
|
||||
| `translation.proofread` | ✅ | 将当前汉化 workflow 标记为人工校对中 |
|
||||
| `localized.status` | ✅ | 查询汉化 release 与当前官方 release 的匹配状态 |
|
||||
| `catalog.status` | ✅ | 当前已发布版本的 catalog 概览(app/bundle 版本、addressables 根、端点与 marker 计数、launcher 元数据) |
|
||||
| `catalog.versions` | ✅ | 版本历史:current / in_progress / previous / failed |
|
||||
| `catalog.diff` | ✅ | 当前 snapshot 相对上一个可用版本的差异(base_delta + extended_delta + 变更端点 URL) |
|
||||
@@ -374,10 +529,28 @@ curl -i -H 'Range: bytes=0-1023' \
|
||||
| `task.list` | ✅ | 列出全部任务(最新在前) |
|
||||
| `task.cancel` | ✅ | 请求取消任务(`params.task_id`);协作式,在同步检查点生效 |
|
||||
| `task.logs` | ✅ | 返回任务的进度日志(`params.task_id`,有界) |
|
||||
| `daemon.restart` / `daemon.clean-stable` / `patch.*` / `unityfs.*` / `task.create` | ⏳ | 已规划,返回 `BAT-ERR-700003`(not implemented);restart/clean-stable 仍由 CLI 侧按进程生命周期显式执行,patch/unityfs 待引擎实现,task.create 暂不开放通用任务入口 |
|
||||
| `patch.apply` | ✅ | 对显式 source/patch/target 文件同步执行 Binary/JSON/Text patch |
|
||||
| `unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field` | ✅ | 对显式 UnityFS bundle 文件执行文件级写入并原子输出 |
|
||||
| `daemon.clean-stable` / 发布级 patch 方法 / 其他未开放 `unityfs.*` / `task.create` | ⏳ | 返回 `BAT-ERR-700003`(not implemented);clean-stable 仍由 CLI 侧按进程生命周期显式执行,task.create 暂不开放通用任务入口 |
|
||||
| 未知方法 | — | `BAT-ERR-700001`(unknown method) |
|
||||
|
||||
只读查询(`daemon.doctor` / `resource.state` / `resource.manifest` / `resource.list` / `catalog.status` / `catalog.versions` / `catalog.diff`)在尚无已发布版本或对应文件不存在时返回 `ok: true` 且 `data.available: false`(正常状态而非错误,便于调用方直接分支)。
|
||||
只读查询(`daemon.doctor` / `resource.state` / `resource.manifest` / `resource.list` /
|
||||
`resource.index` / `release.status` / `release.list` / `release.distribution` / `parse.*` / `translation.tasks` / `translation.handoff` /
|
||||
`localized.status` / `catalog.status` / `catalog.versions` / `catalog.diff`)在尚无已发布版本
|
||||
或对应文件不存在时返回 `ok: true` 且 `data.available: false`(正常状态而非错误,便于调用方直接分支)。
|
||||
|
||||
`localized.status` 会分别返回 `patch_manifest_contract_status` 和
|
||||
`artifact_integrity_status`。current 仍存在但 release 文件被截断或手工修改时,
|
||||
状态为 `degraded` / `localized.degraded`,不会自动回滚、删除或修复。`release.distribution`
|
||||
默认选择 official;只有 Rust 已验证的当前或显式历史 release 可分发,localized、
|
||||
staging、损坏或路径不安全的 release 不会回退到另一 channel。
|
||||
|
||||
`release.list` 的每项摘要还会返回 `rollback_available`、`stale`、`damaged`、
|
||||
`referenced` 和 `unknown`,便于区分可回滚、损坏和证据不足的历史 release。
|
||||
|
||||
双 release cleanup 不是独立 CLI:通过 `release.cleanup` 先执行 dry-run,再把返回的
|
||||
`plan_id` 传给 `execute=true`。Rust 会保护 current、rollback、staging、source、
|
||||
状态/manifest/CAS 引用和未知归属对象;rollback 仍使用独立的 `localized.rollback`。
|
||||
|
||||
### 任务模型
|
||||
|
||||
@@ -385,7 +558,7 @@ curl -i -H 'Range: bytes=0-1023' \
|
||||
|
||||
```json
|
||||
{ "id": "task-1234-1", "kind": "resource.sync",
|
||||
"status": "queued|running|succeeded|failed",
|
||||
"status": "queued|running|succeeded|failed|cancelled",
|
||||
"stage": "download", "message": "…",
|
||||
"created_at": …, "updated_at": …, "started_at": …, "finished_at": …,
|
||||
"error": { … }, "result": { … } }
|
||||
@@ -419,4 +592,16 @@ printf '{"jsonrpc":"2.0","id":5,"method":"resource.manifest","params":{"offset":
|
||||
# 触发本地资源审计+修复任务
|
||||
printf '{"jsonrpc":"2.0","id":6,"method":"resource.repair"}\n' \
|
||||
| socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock
|
||||
|
||||
# 查询双 release 状态,并选择已验证的 localized release
|
||||
printf '{"jsonrpc":"2.0","id":7,"method":"release.status"}\n' \
|
||||
| socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock
|
||||
printf '{"jsonrpc":"2.0","id":8,"method":"release.distribution","params":{"channel":"localized"}}\n' \
|
||||
| socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock
|
||||
|
||||
# cleanup 必须先 dry-run,再使用同一 plan_id 执行
|
||||
printf '{"jsonrpc":"2.0","id":9,"method":"release.cleanup","params":{"execute":false}}\n' \
|
||||
| socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock
|
||||
printf '{"jsonrpc":"2.0","id":10,"method":"release.cleanup","params":{"execute":true,"plan_id":"<plan-id>"}}\n' \
|
||||
| socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock
|
||||
```
|
||||
|
||||
@@ -2,6 +2,45 @@
|
||||
|
||||
use async_trait::async_trait;
|
||||
use bat_core::domain::{GameClient, GameRegion};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Linux-first client discovery backed by explicitly supplied roots.
|
||||
///
|
||||
/// The adapter never scans home directories implicitly and does not require
|
||||
/// the official launcher. The roots are normally a staging/import directory
|
||||
/// selected by the caller.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct LinuxClientDiscovery {
|
||||
roots: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl LinuxClientDiscovery {
|
||||
/// Creates a discovery adapter for explicit candidate roots.
|
||||
pub fn new(roots: Vec<PathBuf>) -> Self {
|
||||
Self { roots }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ClientDiscovery for LinuxClientDiscovery {
|
||||
async fn discover_all(&self) -> Result<Vec<GameClient>, String> {
|
||||
GameClient::discover_in_roots(&self.roots).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
async fn verify_client(&self, path: &str) -> bool {
|
||||
GameClient::new(PathBuf::from(path), GameRegion::Japan)
|
||||
.verify_integrity()
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn detect_region(&self, path: &str) -> Result<GameRegion, String> {
|
||||
if self.verify_client(path).await {
|
||||
Ok(GameRegion::Japan)
|
||||
} else {
|
||||
Err(format!("不是有效的 Linux Blue Archive 客户端:{path}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 客户端发现接口
|
||||
///
|
||||
@@ -49,5 +88,44 @@ pub trait ClientDiscovery: Send + Sync {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
// 测试将在实现时添加
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn linux_discovery_uses_explicit_roots_and_verifies_layout() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let client = temp.path().join("BlueArchive_JP");
|
||||
fs::create_dir_all(client.join("BlueArchive_Data/StreamingAssets/AssetBundles")).unwrap();
|
||||
let discovery = LinuxClientDiscovery::new(vec![temp.path().to_path_buf()]);
|
||||
|
||||
let clients = discovery.discover_all().await.unwrap();
|
||||
assert_eq!(clients.len(), 1);
|
||||
assert!(
|
||||
discovery
|
||||
.verify_client(clients[0].install_path.to_str().unwrap())
|
||||
.await
|
||||
);
|
||||
assert_eq!(
|
||||
discovery
|
||||
.detect_region(clients[0].install_path.to_str().unwrap())
|
||||
.await
|
||||
.unwrap(),
|
||||
GameRegion::Japan
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn linux_discovery_rejects_unrelated_path() {
|
||||
let discovery = LinuxClientDiscovery::default();
|
||||
assert!(
|
||||
!discovery
|
||||
.verify_client("/tmp/not-a-blue-archive-client")
|
||||
.await
|
||||
);
|
||||
assert!(discovery
|
||||
.detect_region("/tmp/not-a-blue-archive-client")
|
||||
.await
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,6 +256,22 @@ impl AddressablesCatalogDriver {
|
||||
}
|
||||
|
||||
let address = Self::string_field(value, &["address", "Address", "m_Address", "key", "Key"]);
|
||||
let provider_id = Self::catalog_string_field(
|
||||
value,
|
||||
&[
|
||||
"provider_id",
|
||||
"providerId",
|
||||
"ProviderId",
|
||||
"provider",
|
||||
"Provider",
|
||||
"m_ProviderId",
|
||||
"m_Provider",
|
||||
],
|
||||
);
|
||||
let bundle_name = Self::catalog_string_field(
|
||||
value,
|
||||
&["bundle_name", "bundleName", "BundleName", "m_BundleName"],
|
||||
);
|
||||
let hash = value
|
||||
.get("hash")
|
||||
.or_else(|| value.get("Hash"))
|
||||
@@ -264,28 +280,32 @@ impl AddressablesCatalogDriver {
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| format!("addressable_{}", index));
|
||||
|
||||
let size = value
|
||||
.get("size")
|
||||
.or_else(|| value.get("Size"))
|
||||
.or_else(|| value.get("m_Size"))
|
||||
.and_then(|value| value.as_u64())
|
||||
.unwrap_or_default();
|
||||
let size = Self::u64_field(value, &["size", "Size", "m_Size"]).unwrap_or_default();
|
||||
|
||||
let dependencies = Self::dependencies_from_entry(value);
|
||||
let crc = value
|
||||
.get("crc")
|
||||
.or_else(|| value.get("Crc"))
|
||||
.or_else(|| value.get("m_Crc"))
|
||||
.and_then(|value| value.as_u64())
|
||||
.and_then(|value| u32::try_from(value).ok());
|
||||
let crc = Self::u32_field(value, &["crc", "Crc", "m_Crc"]);
|
||||
let resource_type_name = Self::type_name_field(
|
||||
value,
|
||||
&[
|
||||
"resource_type",
|
||||
"resourceType",
|
||||
"ResourceType",
|
||||
"m_ResourceType",
|
||||
],
|
||||
);
|
||||
|
||||
Some(ResourceEntry {
|
||||
path: path.to_string(),
|
||||
hash,
|
||||
size,
|
||||
resource_type: Self::resource_type_for_path(path),
|
||||
resource_type: Self::resource_type_for_compact_entry(
|
||||
path,
|
||||
resource_type_name.as_deref(),
|
||||
),
|
||||
address,
|
||||
dependencies,
|
||||
provider_id,
|
||||
bundle_name,
|
||||
crc,
|
||||
})
|
||||
}
|
||||
@@ -299,6 +319,42 @@ impl AddressablesCatalogDriver {
|
||||
})
|
||||
}
|
||||
|
||||
fn catalog_string_field(value: &Value, fields: &[&str]) -> Option<String> {
|
||||
Self::string_field(value, fields).or_else(|| {
|
||||
["extra_data", "ExtraData", "m_ExtraData", "data", "Data"]
|
||||
.iter()
|
||||
.find_map(|field| value.get(*field))
|
||||
.and_then(|extra| Self::string_field(extra, fields))
|
||||
})
|
||||
}
|
||||
|
||||
fn type_name_field(value: &Value, fields: &[&str]) -> Option<String> {
|
||||
fields.iter().find_map(|field| {
|
||||
let value = value.get(*field)?;
|
||||
value.as_str().map(ToOwned::to_owned).or_else(|| {
|
||||
value
|
||||
.get("m_ClassName")
|
||||
.or_else(|| value.get("ClassName"))
|
||||
.or_else(|| value.get("class_name"))
|
||||
.and_then(|name| name.as_str())
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn u64_field(value: &Value, fields: &[&str]) -> Option<u64> {
|
||||
fields.iter().find_map(|field| {
|
||||
let value = value.get(*field)?;
|
||||
value
|
||||
.as_u64()
|
||||
.or_else(|| value.as_str()?.parse::<u64>().ok())
|
||||
})
|
||||
}
|
||||
|
||||
fn u32_field(value: &Value, fields: &[&str]) -> Option<u32> {
|
||||
Self::u64_field(value, fields).and_then(|value| u32::try_from(value).ok())
|
||||
}
|
||||
|
||||
fn string_array_field(value: &Value, fields: &[&str]) -> Vec<String> {
|
||||
for field in fields {
|
||||
if let Some(array) = value.get(field).and_then(|value| value.as_array()) {
|
||||
@@ -383,6 +439,8 @@ impl AddressablesCatalogDriver {
|
||||
resource_type: Self::resource_type_for_path(path),
|
||||
address: None,
|
||||
dependencies: Vec::new(),
|
||||
provider_id: None,
|
||||
bundle_name: None,
|
||||
crc: None,
|
||||
})
|
||||
})
|
||||
@@ -421,6 +479,8 @@ impl AddressablesCatalogDriver {
|
||||
resource_type,
|
||||
address: None,
|
||||
dependencies: Vec::new(),
|
||||
provider_id: None,
|
||||
bundle_name: None,
|
||||
crc: None,
|
||||
});
|
||||
}
|
||||
@@ -479,8 +539,9 @@ impl AddressablesCatalogDriver {
|
||||
internal_ids.len()
|
||||
)
|
||||
})?;
|
||||
provider_ids
|
||||
let provider_id = provider_ids
|
||||
.get(record.provider_index as usize)
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"compact entry {index} provider_index {} out of range {}",
|
||||
@@ -499,13 +560,12 @@ impl AddressablesCatalogDriver {
|
||||
)
|
||||
})?;
|
||||
let path = Self::normalize_internal_id(&internal_id_prefixes, internal_id);
|
||||
let extra = Self::extra_data_at(&extra_data, record.data_index);
|
||||
let resource_type_name = Self::resource_type_name(json, record.resource_type_index);
|
||||
let extra = Self::extra_data_at(&extra_data, record.data_index)?;
|
||||
let resource_type_name = Self::resource_type_name(json, record.resource_type_index)?;
|
||||
let dependencies = Self::compact_dependencies(record, &entry_records, &buckets, &keys);
|
||||
let hash = extra
|
||||
.hash
|
||||
.filter(|value| !value.is_empty())
|
||||
.or_else(|| extra.bundle_name.filter(|value| !value.is_empty()))
|
||||
.unwrap_or_else(|| format!("addressable_{}", index));
|
||||
|
||||
resources.push(ResourceEntry {
|
||||
@@ -522,6 +582,8 @@ impl AddressablesCatalogDriver {
|
||||
Some(primary_key)
|
||||
},
|
||||
dependencies,
|
||||
provider_id: Some(provider_id),
|
||||
bundle_name: extra.bundle_name,
|
||||
crc: extra.crc,
|
||||
});
|
||||
}
|
||||
@@ -632,25 +694,34 @@ impl AddressablesCatalogDriver {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn extra_data_at(extra_data: &[u8], data_index: i32) -> AddressablesExtraData {
|
||||
fn extra_data_at(extra_data: &[u8], data_index: i32) -> Result<AddressablesExtraData, String> {
|
||||
if data_index < 0 {
|
||||
return AddressablesExtraData::default();
|
||||
return Ok(AddressablesExtraData::default());
|
||||
}
|
||||
|
||||
let Some((object, _)) = Self::read_serialized_object(extra_data, data_index as usize)
|
||||
else {
|
||||
return AddressablesExtraData::default();
|
||||
return Err(format!(
|
||||
"compact entry extra data index {} is not decodable",
|
||||
data_index
|
||||
));
|
||||
};
|
||||
|
||||
let AddressablesObject::JsonObject { json, .. } = object else {
|
||||
return AddressablesExtraData::default();
|
||||
return Err(format!(
|
||||
"compact entry extra data index {} is not a JSON object",
|
||||
data_index
|
||||
));
|
||||
};
|
||||
|
||||
let Some(json) = json else {
|
||||
return AddressablesExtraData::default();
|
||||
return Err(format!(
|
||||
"compact entry extra data index {} contains invalid JSON",
|
||||
data_index
|
||||
));
|
||||
};
|
||||
|
||||
AddressablesExtraData {
|
||||
Ok(AddressablesExtraData {
|
||||
hash: json
|
||||
.get("m_Hash")
|
||||
.and_then(|value| value.as_str())
|
||||
@@ -659,26 +730,31 @@ impl AddressablesCatalogDriver {
|
||||
.get("m_BundleName")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(ToOwned::to_owned),
|
||||
bundle_size: json.get("m_BundleSize").and_then(|value| value.as_u64()),
|
||||
bundle_size: Self::u64_field(&json, &["m_BundleSize", "bundle_size", "size"]),
|
||||
// m_Crc 是 bundle 的 IEEE CRC-32;0 表示不做 CRC 校验,忠实保留原值。
|
||||
crc: json
|
||||
.get("m_Crc")
|
||||
.and_then(|value| value.as_u64())
|
||||
.and_then(|value| u32::try_from(value).ok()),
|
||||
}
|
||||
crc: Self::u32_field(&json, &["m_Crc", "crc", "Crc"]),
|
||||
})
|
||||
}
|
||||
|
||||
fn resource_type_name(json: &Value, index: i32) -> Option<String> {
|
||||
fn resource_type_name(json: &Value, index: i32) -> Result<Option<String>, String> {
|
||||
if index < 0 {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
json.get("m_resourceTypes")?
|
||||
.as_array()?
|
||||
.get(index as usize)?
|
||||
.get("m_ClassName")?
|
||||
.as_str()
|
||||
.map(ToOwned::to_owned)
|
||||
let resource_types = json
|
||||
.get("m_resourceTypes")
|
||||
.and_then(|value| value.as_array())
|
||||
.ok_or_else(|| "compact catalog missing m_resourceTypes array".to_string())?;
|
||||
let value = resource_types.get(index as usize).ok_or_else(|| {
|
||||
format!(
|
||||
"compact resource_type_index {} out of range {}",
|
||||
index,
|
||||
resource_types.len()
|
||||
)
|
||||
})?;
|
||||
Self::type_name_field(value, &["m_ClassName", "ClassName", "class_name"])
|
||||
.ok_or_else(|| format!("compact resource type {} has no class name", index))
|
||||
.map(Some)
|
||||
}
|
||||
|
||||
fn normalize_internal_id(prefixes: &[String], internal_id: &str) -> String {
|
||||
@@ -869,6 +945,25 @@ impl AddressablesCatalogDriver {
|
||||
fallback_hash_count.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let provider_count = resources
|
||||
.iter()
|
||||
.filter(|entry| entry.provider_id.is_some())
|
||||
.count();
|
||||
if provider_count > 0 {
|
||||
extra.insert("provider_id_count".to_string(), provider_count.to_string());
|
||||
}
|
||||
|
||||
let bundle_name_count = resources
|
||||
.iter()
|
||||
.filter(|entry| entry.bundle_name.is_some())
|
||||
.count();
|
||||
if bundle_name_count > 0 {
|
||||
extra.insert(
|
||||
"bundle_name_count".to_string(),
|
||||
bundle_name_count.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1146,6 +1241,8 @@ mod tests {
|
||||
"hash": "synthetic-entry-hash",
|
||||
"size": 119,
|
||||
"crc": 3735928559,
|
||||
"provider_id": "synthetic-provider",
|
||||
"bundle_name": "synthetic-bundle",
|
||||
"address": "Character_001",
|
||||
"dependencies": ["synthetic/shared.bundle"]
|
||||
},
|
||||
@@ -1163,6 +1260,14 @@ mod tests {
|
||||
assert_eq!(manifest.resources[0].path, "synthetic/minimal.bundle");
|
||||
assert_eq!(manifest.resources[0].hash, "synthetic-entry-hash");
|
||||
assert_eq!(manifest.resources[0].size, 119);
|
||||
assert_eq!(
|
||||
manifest.resources[0].provider_id.as_deref(),
|
||||
Some("synthetic-provider")
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.resources[0].bundle_name.as_deref(),
|
||||
Some("synthetic-bundle")
|
||||
);
|
||||
// m_Crc(此处 0xDEADBEEF)应被提取;缺该字段的条目为 None。
|
||||
assert_eq!(manifest.resources[0].crc, Some(0xDEAD_BEEF));
|
||||
assert_eq!(manifest.resources[1].crc, None);
|
||||
@@ -1207,6 +1312,14 @@ mod tests {
|
||||
manifest.metadata.extra.get("dependency_count"),
|
||||
Some(&"1".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.metadata.extra.get("provider_id_count"),
|
||||
Some(&"1".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.metadata.extra.get("bundle_name_count"),
|
||||
Some(&"1".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1243,6 +1356,14 @@ mod tests {
|
||||
assert_eq!(resource.hash, "hash-compact");
|
||||
assert_eq!(resource.size, 42);
|
||||
assert_eq!(resource.crc, Some(0x1234_5678));
|
||||
assert_eq!(
|
||||
resource.provider_id.as_deref(),
|
||||
Some("UnityEngine.ResourceManagement.ResourceProviders.AssetBundleProvider")
|
||||
);
|
||||
assert_eq!(
|
||||
resource.bundle_name.as_deref(),
|
||||
Some("synthetic-bundle-name")
|
||||
);
|
||||
assert_eq!(resource.resource_type, ResourceType::AssetBundle);
|
||||
assert_eq!(resource.address.as_deref(), Some("synthetic.bundle"));
|
||||
assert_eq!(
|
||||
@@ -1261,6 +1382,14 @@ mod tests {
|
||||
manifest.metadata.extra.get("declared_crc_count"),
|
||||
Some(&"1".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.metadata.extra.get("provider_id_count"),
|
||||
Some(&"1".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.metadata.extra.get("bundle_name_count"),
|
||||
Some(&"1".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
//! Official resource backend seams.
|
||||
//!
|
||||
//! The update pipeline consumes these small contracts instead of depending on
|
||||
//! one region's URL and catalog rules everywhere. The JP implementation is
|
||||
//! the only production adapter today; adding another region should implement
|
||||
//! this module's contracts without changing downloader orchestration.
|
||||
|
||||
use super::inventory::{YostarJpPlatformCatalogInventory, YostarJpPlatformDownloadInventory};
|
||||
use super::yostar_jp::{
|
||||
is_official_yostar_jp_url, server_info_url, PatchPlatform, YostarJpResourceDiscoveryPlan,
|
||||
YostarJpResourceRoot, YostarJpServerInfo,
|
||||
};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Catalog bytes required to build one platform's download inventory.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PlatformCatalogInput<'a> {
|
||||
/// Platform represented by the catalog.
|
||||
pub platform: PatchPlatform,
|
||||
/// `BundlePackingInfo.bytes` payload.
|
||||
pub bundle_packing_info: &'a [u8],
|
||||
/// `MediaCatalog.bytes` payload.
|
||||
pub media_catalog: &'a [u8],
|
||||
}
|
||||
|
||||
/// Platform catalog parser selected by an official resource backend.
|
||||
pub trait InventoryParser: Send + Sync {
|
||||
/// Parses verified seed catalog payloads into a platform-aware inventory.
|
||||
fn parse_inventory(
|
||||
&self,
|
||||
table_catalog: &[u8],
|
||||
platform_catalogs: &[PlatformCatalogInput<'_>],
|
||||
) -> YostarJpPlatformDownloadInventory;
|
||||
}
|
||||
|
||||
/// Verification result returned by a sidecar hash strategy.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SidecarHashVerification {
|
||||
/// Decimal or textual expected digest parsed from the sidecar.
|
||||
pub expected: String,
|
||||
/// Digest computed from the resource bytes.
|
||||
pub actual: String,
|
||||
}
|
||||
|
||||
/// Hash sidecar policy independent from download orchestration.
|
||||
pub trait SidecarHashStrategy: Send + Sync {
|
||||
/// Stable algorithm identifier used in diagnostics.
|
||||
fn algorithm_id(&self) -> &'static str;
|
||||
|
||||
/// Parses and verifies one resource payload against a sidecar.
|
||||
fn verify(&self, data: &[u8], sidecar: &[u8]) -> Result<SidecarHashVerification, String>;
|
||||
}
|
||||
|
||||
/// Official JP decimal `xxHash32(seed=0)` sidecar strategy.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct XxHash32DecimalSeedZero;
|
||||
|
||||
impl XxHash32DecimalSeedZero {
|
||||
/// Computes the decimal digest used by this sidecar strategy.
|
||||
pub fn digest(self, data: &[u8]) -> String {
|
||||
xxhash32(data).to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl SidecarHashStrategy for XxHash32DecimalSeedZero {
|
||||
fn algorithm_id(&self) -> &'static str {
|
||||
"xxhash32_decimal"
|
||||
}
|
||||
|
||||
fn verify(&self, data: &[u8], sidecar: &[u8]) -> Result<SidecarHashVerification, String> {
|
||||
let expected = std::str::from_utf8(sidecar)
|
||||
.map_err(|error| format!("官方 hash sidecar 不是 UTF-8:{error}"))?
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.map_err(|error| format!("官方 hash sidecar 不是十进制 xxHash32:{error}"))?;
|
||||
let actual = self.digest(data);
|
||||
let mismatch = expected.to_string() != actual;
|
||||
let verification = SidecarHashVerification {
|
||||
expected: expected.to_string(),
|
||||
actual,
|
||||
};
|
||||
if mismatch {
|
||||
return Err(format!(
|
||||
"官方 hash 校验失败:期望 {},实际 {}",
|
||||
verification.expected, verification.actual
|
||||
));
|
||||
}
|
||||
Ok(verification)
|
||||
}
|
||||
}
|
||||
|
||||
/// Region/backend contract used by official resource orchestration.
|
||||
pub trait OfficialResourceBackend: InventoryParser + Send + Sync {
|
||||
/// Stable backend identifier persisted in diagnostics.
|
||||
fn backend_id(&self) -> &'static str;
|
||||
|
||||
/// Builds the server-info URL from an official metadata file name.
|
||||
fn server_info_url(&self, file_name: &str) -> Result<String, String>;
|
||||
|
||||
/// Selects a discovery plan from server-info and requested platforms.
|
||||
fn discovery_plan(
|
||||
&self,
|
||||
server_info: &YostarJpServerInfo,
|
||||
connection_group: &str,
|
||||
app_version: &str,
|
||||
platforms: &[PatchPlatform],
|
||||
) -> Result<YostarJpResourceDiscoveryPlan, String>;
|
||||
|
||||
/// Validates that a URL belongs to this backend's official hosts.
|
||||
fn is_official_url(&self, url: &str) -> bool;
|
||||
}
|
||||
|
||||
/// URL-to-destination mapping contract for a resource backend.
|
||||
pub trait DownloadUrlMapper: Send + Sync {
|
||||
/// Maps an official HTTPS URL to a relative release destination.
|
||||
fn relative_destination(&self, url: &str) -> Result<PathBuf, String>;
|
||||
}
|
||||
|
||||
/// The currently supported official Blue Archive JP backend.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct YostarJpBackend;
|
||||
|
||||
impl InventoryParser for YostarJpBackend {
|
||||
fn parse_inventory(
|
||||
&self,
|
||||
table_catalog: &[u8],
|
||||
platform_catalogs: &[PlatformCatalogInput<'_>],
|
||||
) -> YostarJpPlatformDownloadInventory {
|
||||
let catalogs = platform_catalogs
|
||||
.iter()
|
||||
.map(|catalog| {
|
||||
YostarJpPlatformCatalogInventory::from_catalog_bytes(
|
||||
catalog.platform,
|
||||
catalog.bundle_packing_info,
|
||||
catalog.media_catalog,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
YostarJpPlatformDownloadInventory::from_catalog_bytes(table_catalog, catalogs)
|
||||
}
|
||||
}
|
||||
|
||||
impl OfficialResourceBackend for YostarJpBackend {
|
||||
fn backend_id(&self) -> &'static str {
|
||||
"bluearchive.yostar.jp"
|
||||
}
|
||||
|
||||
fn server_info_url(&self, file_name: &str) -> Result<String, String> {
|
||||
server_info_url(file_name)
|
||||
}
|
||||
|
||||
fn discovery_plan(
|
||||
&self,
|
||||
server_info: &YostarJpServerInfo,
|
||||
connection_group: &str,
|
||||
app_version: &str,
|
||||
platforms: &[PatchPlatform],
|
||||
) -> Result<YostarJpResourceDiscoveryPlan, String> {
|
||||
server_info.discovery_plan(connection_group, app_version, platforms)
|
||||
}
|
||||
|
||||
fn is_official_url(&self, url: &str) -> bool {
|
||||
is_official_yostar_jp_url(url)
|
||||
}
|
||||
}
|
||||
|
||||
fn xxhash32(bytes: &[u8]) -> u32 {
|
||||
const PRIME1: u32 = 0x9E37_79B1;
|
||||
const PRIME2: u32 = 0x85EB_CA77;
|
||||
const PRIME3: u32 = 0xC2B2_AE3D;
|
||||
const PRIME4: u32 = 0x27D4_EB2F;
|
||||
const PRIME5: u32 = 0x1656_67B1;
|
||||
|
||||
let len = bytes.len();
|
||||
let mut index = 0usize;
|
||||
let mut hash = if len >= 16 {
|
||||
let mut v1 = PRIME1.wrapping_add(PRIME2);
|
||||
let mut v2 = PRIME2;
|
||||
let mut v3 = 0;
|
||||
let mut v4 = 0u32.wrapping_sub(PRIME1);
|
||||
while index + 16 <= len {
|
||||
v1 = xxhash32_round(v1, read_u32_le(bytes, index));
|
||||
v2 = xxhash32_round(v2, read_u32_le(bytes, index + 4));
|
||||
v3 = xxhash32_round(v3, read_u32_le(bytes, index + 8));
|
||||
v4 = xxhash32_round(v4, read_u32_le(bytes, index + 12));
|
||||
index += 16;
|
||||
}
|
||||
v1.rotate_left(1)
|
||||
.wrapping_add(v2.rotate_left(7))
|
||||
.wrapping_add(v3.rotate_left(12))
|
||||
.wrapping_add(v4.rotate_left(18))
|
||||
} else {
|
||||
PRIME5
|
||||
}
|
||||
.wrapping_add(len as u32);
|
||||
|
||||
while index + 4 <= len {
|
||||
hash = hash
|
||||
.wrapping_add(read_u32_le(bytes, index).wrapping_mul(PRIME3))
|
||||
.rotate_left(17)
|
||||
.wrapping_mul(PRIME4);
|
||||
index += 4;
|
||||
}
|
||||
while index < len {
|
||||
hash = hash
|
||||
.wrapping_add((bytes[index] as u32).wrapping_mul(PRIME5))
|
||||
.rotate_left(11)
|
||||
.wrapping_mul(PRIME1);
|
||||
index += 1;
|
||||
}
|
||||
hash ^= hash >> 15;
|
||||
hash = hash.wrapping_mul(PRIME2);
|
||||
hash ^= hash >> 13;
|
||||
hash = hash.wrapping_mul(PRIME3);
|
||||
hash ^ (hash >> 16)
|
||||
}
|
||||
|
||||
fn xxhash32_round(acc: u32, input: u32) -> u32 {
|
||||
acc.wrapping_add(input.wrapping_mul(0x85EB_CA77))
|
||||
.rotate_left(13)
|
||||
.wrapping_mul(0x9E37_79B1)
|
||||
}
|
||||
|
||||
fn read_u32_le(bytes: &[u8], offset: usize) -> u32 {
|
||||
u32::from_le_bytes([
|
||||
bytes[offset],
|
||||
bytes[offset + 1],
|
||||
bytes[offset + 2],
|
||||
bytes[offset + 3],
|
||||
])
|
||||
}
|
||||
|
||||
impl DownloadUrlMapper for YostarJpBackend {
|
||||
fn relative_destination(&self, url: &str) -> Result<PathBuf, String> {
|
||||
let rest = url
|
||||
.strip_prefix("https://")
|
||||
.ok_or_else(|| format!("官方 URL 必须使用 https:{url}"))?;
|
||||
let (host, path) = rest
|
||||
.split_once('/')
|
||||
.ok_or_else(|| format!("官方 URL 缺少路径:{url}"))?;
|
||||
let mut destination = PathBuf::from(sanitize_component(host, url)?);
|
||||
for segment in path.split('/') {
|
||||
if segment.is_empty() {
|
||||
continue;
|
||||
}
|
||||
destination.push(sanitize_component(segment, url)?);
|
||||
}
|
||||
Ok(destination)
|
||||
}
|
||||
}
|
||||
|
||||
impl YostarJpBackend {
|
||||
/// Returns the validated resource-root builder for an official root.
|
||||
pub fn resource_root(&self, addressables_root: &str) -> Result<YostarJpResourceRoot, String> {
|
||||
YostarJpResourceRoot::from_addressables_root(addressables_root)
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_component(component: &str, url: &str) -> Result<String, String> {
|
||||
if component == "." || component == ".." || component.is_empty() {
|
||||
return Err(format!("官方 URL 包含不安全路径片段:{url}"));
|
||||
}
|
||||
if component.contains('?') || component.contains('#') {
|
||||
return Err(format!(
|
||||
"官方资源 URL 包含 query 或 fragment 等不安全路径字符:{url}"
|
||||
));
|
||||
}
|
||||
if component.contains('\\') {
|
||||
return Err(format!("官方资源 URL 包含不安全路径字符:{url}"));
|
||||
}
|
||||
Ok(component.to_string())
|
||||
}
|
||||
|
||||
/// Joins a backend-relative destination below an output root.
|
||||
pub fn destination_under_root(root: &Path, relative: &Path) -> Result<PathBuf, String> {
|
||||
if relative.is_absolute() {
|
||||
return Err(format!(
|
||||
"backend destination must be relative: {}",
|
||||
relative.display()
|
||||
));
|
||||
}
|
||||
let destination = root.join(relative);
|
||||
if destination
|
||||
.components()
|
||||
.any(|component| matches!(component, std::path::Component::ParentDir))
|
||||
{
|
||||
return Err(format!(
|
||||
"backend destination escapes output root: {}",
|
||||
relative.display()
|
||||
));
|
||||
}
|
||||
Ok(destination)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn jp_backend_keeps_discovery_and_inventory_rules_in_one_adapter() {
|
||||
let backend = YostarJpBackend;
|
||||
let server_info = YostarJpServerInfo::from_json(
|
||||
r#"{"ConnectionGroups":[{"Name":"Prod","AddressablesCatalogUrlRoot":"https://prod-clientpatch.bluearchiveyostar.com/r93_fixture"}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let plan = backend
|
||||
.discovery_plan(&server_info, "Prod", "1.70.0", &[PatchPlatform::Windows])
|
||||
.unwrap();
|
||||
assert_eq!(backend.backend_id(), "bluearchive.yostar.jp");
|
||||
assert!(backend.is_official_url(&plan.endpoints[0].url));
|
||||
|
||||
let inventory = backend.parse_inventory(
|
||||
b"ExcelDB.db ExcelDB.db",
|
||||
&[PlatformCatalogInput {
|
||||
platform: PatchPlatform::Windows,
|
||||
bundle_packing_info: b"FullPatch_000.zip",
|
||||
media_catalog: b"GameData/Audio/JP.zip",
|
||||
}],
|
||||
);
|
||||
assert_eq!(inventory.table_file_names, vec!["ExcelDB.db"]);
|
||||
assert_eq!(inventory.platform_catalogs.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jp_hash_strategy_verifies_decimal_xxhash32_sidecars() {
|
||||
let strategy = XxHash32DecimalSeedZero;
|
||||
assert_eq!(strategy.algorithm_id(), "xxhash32_decimal");
|
||||
assert_eq!(
|
||||
strategy.verify(b"", b"46947589").unwrap(),
|
||||
SidecarHashVerification {
|
||||
expected: "46947589".to_string(),
|
||||
actual: "46947589".to_string(),
|
||||
}
|
||||
);
|
||||
assert!(strategy.verify(b"changed", b"46947589").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jp_backend_maps_and_rejects_unsafe_destinations() {
|
||||
let backend = YostarJpBackend;
|
||||
assert_eq!(
|
||||
backend
|
||||
.relative_destination(
|
||||
"https://prod-clientpatch.bluearchiveyostar.com/r93/TableBundles/a.bytes"
|
||||
)
|
||||
.unwrap(),
|
||||
PathBuf::from("prod-clientpatch.bluearchiveyostar.com/r93/TableBundles/a.bytes")
|
||||
);
|
||||
assert!(backend
|
||||
.relative_destination("https://prod-clientpatch.bluearchiveyostar.com/r93/../secret")
|
||||
.is_err());
|
||||
assert!(!backend.is_official_url("https://example.invalid/a"));
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,17 @@
|
||||
//! client endpoints. Mirror-specific layers such as `bluearchive.cafe` or
|
||||
//! `text=jp/voice=jp/media=jp` are intentionally excluded.
|
||||
|
||||
pub mod backend;
|
||||
pub mod game_main_config;
|
||||
pub mod inventory;
|
||||
pub mod launcher;
|
||||
pub mod yostar_jp;
|
||||
|
||||
pub use backend::{
|
||||
destination_under_root, DownloadUrlMapper, InventoryParser, OfficialResourceBackend,
|
||||
PlatformCatalogInput, SidecarHashStrategy, SidecarHashVerification, XxHash32DecimalSeedZero,
|
||||
YostarJpBackend,
|
||||
};
|
||||
pub use game_main_config::YostarJpGameMainConfig;
|
||||
pub use inventory::{
|
||||
YostarJpDownloadInventory, YostarJpPlatformCatalogInventory, YostarJpPlatformDownloadInventory,
|
||||
|
||||
@@ -21,6 +21,19 @@ async fn parses_current_catalog_fixture_with_resource_categories() {
|
||||
manifest.resources[3].dependencies,
|
||||
vec!["shared_dependencies.bundle".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.resources[0].provider_id.as_deref(),
|
||||
Some("provider-table")
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.resources[0].bundle_name.as_deref(),
|
||||
Some("table-bundle")
|
||||
);
|
||||
assert_eq!(manifest.resources[0].crc, Some(0x1234_5678));
|
||||
assert_eq!(
|
||||
manifest.resources[3].provider_id.as_deref(),
|
||||
Some("provider-bundle")
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.metadata.cdn_prefixes,
|
||||
vec!["https://fixture.invalid/current/".to_string()]
|
||||
@@ -63,6 +76,18 @@ async fn parses_catalog_structure_change_with_alias_fields() {
|
||||
manifest.resources[0].dependencies,
|
||||
vec!["shared_assets_current.bundle".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.resources[0].provider_id.as_deref(),
|
||||
Some("provider-android")
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.resources[0].bundle_name.as_deref(),
|
||||
Some("title-android-bundle")
|
||||
);
|
||||
assert_eq!(manifest.resources[1].resource_type, ResourceType::TextAsset);
|
||||
assert_eq!(manifest.resources[1].address.as_deref(), Some("lesson"));
|
||||
assert_eq!(
|
||||
manifest.resources[1].provider_id.as_deref(),
|
||||
Some("provider-text")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"internal_id": "TableBundles/ExcelDB.db",
|
||||
"hash": "current-table-hash",
|
||||
"size": 4096,
|
||||
"provider_id": "provider-table",
|
||||
"bundle_name": "table-bundle",
|
||||
"crc": "305419896",
|
||||
"address": "ExcelDB",
|
||||
"dependencies": []
|
||||
},
|
||||
@@ -15,6 +18,8 @@
|
||||
"internal_id": "MediaResources-Windows/voice/title.acb",
|
||||
"hash": "current-media-hash",
|
||||
"size": 2048,
|
||||
"m_ProviderId": "provider-media",
|
||||
"m_BundleName": "media-bundle",
|
||||
"address": "title",
|
||||
"dependencies": []
|
||||
},
|
||||
@@ -22,6 +27,8 @@
|
||||
"internal_id": "TextAssets/dialogue.csv",
|
||||
"hash": "current-text-hash",
|
||||
"size": 128,
|
||||
"Provider": "provider-text",
|
||||
"BundleName": "text-bundle",
|
||||
"address": "dialogue",
|
||||
"dependencies": []
|
||||
},
|
||||
@@ -29,6 +36,8 @@
|
||||
"internal_id": "shared_assets_current.bundle",
|
||||
"hash": "current-bundle-hash",
|
||||
"size": 8192,
|
||||
"provider": "provider-bundle",
|
||||
"bundleName": "shared-bundle",
|
||||
"address": "shared_assets_current",
|
||||
"dependencies": [
|
||||
"shared_dependencies.bundle"
|
||||
|
||||
+4
@@ -9,6 +9,8 @@
|
||||
"InternalId": "MediaResources-Android/voice/title.awb",
|
||||
"Hash": "changed-media-hash",
|
||||
"Size": 65536,
|
||||
"ProviderId": "provider-android",
|
||||
"BundleName": "title-android-bundle",
|
||||
"Address": "title-android",
|
||||
"m_Dependencies": [
|
||||
"shared_assets_current.bundle"
|
||||
@@ -18,6 +20,8 @@
|
||||
"Path": "TextAssets/lesson.json",
|
||||
"Hash": "changed-text-hash",
|
||||
"Size": 512,
|
||||
"provider_id": "provider-text",
|
||||
"bundle_name": "lesson-bundle",
|
||||
"Key": "lesson"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
"dependencies": [
|
||||
"shared_assets_all_123.bundle"
|
||||
],
|
||||
"provider_id": "UnityEngine.ResourceManagement.ResourceProviders.AssetBundleProvider",
|
||||
"bundle_name": "bundle-main",
|
||||
"crc": 0
|
||||
},
|
||||
{
|
||||
@@ -22,6 +24,8 @@
|
||||
"resource_type": "AssetBundle",
|
||||
"address": "academy-_mxload-prefabs-2025-08-26_assets_all_1581352935.bundle",
|
||||
"dependencies": [],
|
||||
"provider_id": "UnityEngine.ResourceManagement.ResourceProviders.AssetBundleProvider",
|
||||
"bundle_name": "bundle-second",
|
||||
"crc": 0
|
||||
},
|
||||
{
|
||||
@@ -31,16 +35,20 @@
|
||||
"resource_type": "AssetBundle",
|
||||
"address": "shared_assets_all_123.bundle",
|
||||
"dependencies": [],
|
||||
"provider_id": "UnityEngine.ResourceManagement.ResourceProviders.AssetBundleProvider",
|
||||
"bundle_name": "bundle-shared",
|
||||
"crc": 0
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"asset_bundle_count": "3",
|
||||
"declared_size_count": "3",
|
||||
"bundle_name_count": "3",
|
||||
"dependency_count": "1",
|
||||
"internal_id_count": "3",
|
||||
"resource_count": "3",
|
||||
"resource_type_count": "1",
|
||||
"provider_id_count": "3",
|
||||
"key_object_count": "4",
|
||||
"bucket_record_count": "4",
|
||||
"entry_record_count": "3",
|
||||
|
||||
@@ -22,6 +22,8 @@ async fn parses_real_shape_addressables_catalog_against_golden() {
|
||||
"resource_type": format!("{:?}", resource.resource_type),
|
||||
"address": resource.address,
|
||||
"dependencies": resource.dependencies,
|
||||
"provider_id": resource.provider_id,
|
||||
"bundle_name": resource.bundle_name,
|
||||
"crc": resource.crc,
|
||||
})
|
||||
}).collect::<Vec<_>>(),
|
||||
|
||||
@@ -5,5 +5,6 @@ HTTP surface. The running service also exposes the same contract at
|
||||
`GET /openapi.yaml`.
|
||||
|
||||
This contract covers resource bootstrap, launcher resource compatibility,
|
||||
server-info rewrite, CDN-shaped resource bytes, auth schemes, and the reserved
|
||||
admin panel entry. It does not describe a full game business API.
|
||||
server-info rewrite, CDN-shaped resource bytes, auth schemes, and the admin
|
||||
panel's Rust-forwarded translation/TM management routes. It does not describe
|
||||
a full game business API.
|
||||
|
||||
+757
-11
@@ -2,7 +2,7 @@ openapi: 3.0.3
|
||||
info:
|
||||
title: BlueArchive Toolkit bat-api
|
||||
version: 0.1.0
|
||||
description: Resource bootstrap and read-only distribution API.
|
||||
description: Resource bootstrap, read-only distribution, and authenticated Rust bat control proxy.
|
||||
servers:
|
||||
- url: http://127.0.0.1:18080
|
||||
security:
|
||||
@@ -20,25 +20,25 @@ paths:
|
||||
summary: Release readiness
|
||||
responses:
|
||||
"200":
|
||||
description: A distributable release is available.
|
||||
description: A current official release authorized by Rust release.attestation and fully represented by the bound local read snapshot is available.
|
||||
"503":
|
||||
description: No distributable release is available.
|
||||
description: The Rust current attestation is unavailable, stale, invalid, or the bound local read snapshot is not distributable.
|
||||
/v1/bootstrap:
|
||||
get:
|
||||
summary: Startup resource bootstrap
|
||||
responses:
|
||||
"200":
|
||||
description: Resource bootstrap response.
|
||||
description: Resource bootstrap response with the same distribution health used by readiness and current CDN serving.
|
||||
"503":
|
||||
description: Release is not ready.
|
||||
description: The current release is not distributable.
|
||||
/v1/launcher/bootstrap:
|
||||
get:
|
||||
summary: Launcher-shaped resource bootstrap
|
||||
responses:
|
||||
"200":
|
||||
description: Launcher bootstrap response.
|
||||
description: Launcher bootstrap response with the current release distribution health.
|
||||
"503":
|
||||
description: Release is not ready.
|
||||
description: The current release is not distributable.
|
||||
/api/launcher/game/config:
|
||||
get:
|
||||
summary: Resource-only launcher game config compatibility
|
||||
@@ -71,7 +71,58 @@ paths:
|
||||
summary: Current release summary
|
||||
responses:
|
||||
"200":
|
||||
description: Release summary.
|
||||
description: Release summary including Rust-owned whole-release distribution health.
|
||||
/v1/releases:
|
||||
get:
|
||||
summary: Rust-owned official and localized release history
|
||||
parameters:
|
||||
- name: channel
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum: [official, localized]
|
||||
responses:
|
||||
"200":
|
||||
description: Release history and manifest/artifact integrity summaries.
|
||||
"503":
|
||||
description: Rust bat release backend is unavailable.
|
||||
/v1/distribution:
|
||||
get:
|
||||
summary: Select a verified official or localized release for distribution
|
||||
parameters:
|
||||
- name: channel
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum: [official, localized]
|
||||
default: official
|
||||
- name: release_id
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: destination
|
||||
in: query
|
||||
description: Optional release-relative path for single-entry lookup; Rust returns exactly one entry and revalidates the selected channel's actual bytes and BLAKE3.
|
||||
schema:
|
||||
type: string
|
||||
- name: offset
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 0
|
||||
- name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 1000
|
||||
responses:
|
||||
"200":
|
||||
description: Rust-verified selected release and resource manifest page.
|
||||
"409":
|
||||
description: Selected release is missing, stale, damaged, or not distributable.
|
||||
"503":
|
||||
description: Rust bat release backend is unavailable.
|
||||
/v1/resources:
|
||||
get:
|
||||
summary: Paginated resource manifest entries
|
||||
@@ -102,12 +153,707 @@ paths:
|
||||
responses:
|
||||
"200":
|
||||
description: OpenAPI YAML.
|
||||
/admin/:
|
||||
/admin/dashboard/:
|
||||
get:
|
||||
summary: Reserved admin panel entry
|
||||
summary: Embedded bat-api dashboard
|
||||
security: []
|
||||
responses:
|
||||
"200":
|
||||
description: Admin panel placeholder and links.
|
||||
description: Static dashboard HTML.
|
||||
/admin/:
|
||||
get:
|
||||
summary: Admin control entry
|
||||
responses:
|
||||
"200":
|
||||
description: Admin links and allowlisted control actions.
|
||||
/admin/diagnostics:
|
||||
get:
|
||||
summary: Read Rust daemon doctor diagnostics
|
||||
responses:
|
||||
"200":
|
||||
description: Current daemon.doctor report.
|
||||
"401":
|
||||
description: Missing or invalid admin token.
|
||||
"503":
|
||||
description: Rust bat diagnostics backend is unavailable.
|
||||
/admin/logs:
|
||||
get:
|
||||
summary: Read Rust daemon log tail
|
||||
parameters:
|
||||
- name: tail
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 2000
|
||||
responses:
|
||||
"200":
|
||||
description: Current daemon.logs report.
|
||||
"400":
|
||||
description: Invalid log query.
|
||||
"401":
|
||||
description: Missing or invalid admin token.
|
||||
"503":
|
||||
description: Rust bat log backend is unavailable.
|
||||
/admin/tasks:
|
||||
get:
|
||||
summary: List Rust-owned async daemon tasks
|
||||
responses:
|
||||
"200":
|
||||
description: Current task.list report.
|
||||
"401":
|
||||
description: Missing or invalid admin token.
|
||||
"503":
|
||||
description: Rust bat task backend is unavailable.
|
||||
/admin/tasks/status:
|
||||
get:
|
||||
summary: Read one Rust-owned async daemon task
|
||||
parameters:
|
||||
- name: task_id
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Current task.status report.
|
||||
"400":
|
||||
description: Missing or invalid task_id.
|
||||
"401":
|
||||
description: Missing or invalid admin token.
|
||||
"503":
|
||||
description: Rust bat task backend is unavailable.
|
||||
/admin/tasks/logs:
|
||||
get:
|
||||
summary: Read one Rust-owned async daemon task log
|
||||
parameters:
|
||||
- name: task_id
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Current task.logs report.
|
||||
"400":
|
||||
description: Missing or invalid task_id.
|
||||
"401":
|
||||
description: Missing or invalid admin token.
|
||||
"503":
|
||||
description: Rust bat task backend is unavailable.
|
||||
/admin/parse/status:
|
||||
get:
|
||||
summary: Read Rust-owned parse/TextUnit index status
|
||||
responses:
|
||||
"200":
|
||||
description: Current parse.status report.
|
||||
"401":
|
||||
description: Missing or invalid admin token.
|
||||
"503":
|
||||
description: Rust bat parse backend is unavailable.
|
||||
/admin/parse/text-units:
|
||||
get:
|
||||
summary: Query Rust-owned TextUnit index entries
|
||||
parameters:
|
||||
- name: offset
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 0
|
||||
- name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 1000
|
||||
- name: destination
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: path_pattern
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: archive_entry
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: path_id
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
- name: class_id
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
- name: field_path
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: format
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Current parse.text_units report.
|
||||
"400":
|
||||
description: Invalid TextUnit query.
|
||||
"401":
|
||||
description: Missing or invalid admin token.
|
||||
"503":
|
||||
description: Rust bat parse backend is unavailable.
|
||||
/admin/parse/errors:
|
||||
get:
|
||||
summary: Query Rust-owned TextUnit extraction diagnostics
|
||||
parameters:
|
||||
- name: offset
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 0
|
||||
- name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 1000
|
||||
- name: destination
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: path_pattern
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: archive_entry
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: path_id
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
- name: class_id
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
- name: field_path
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: format
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Current parse.errors report.
|
||||
"400":
|
||||
description: Invalid parse error query.
|
||||
"401":
|
||||
description: Missing or invalid admin token.
|
||||
"503":
|
||||
description: Rust bat parse backend is unavailable.
|
||||
/admin/schedules:
|
||||
get:
|
||||
summary: List Rust-owned resource workflow schedules
|
||||
parameters:
|
||||
- name: id
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: group
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum: [res, parse, i18n]
|
||||
- name: enabled
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
responses:
|
||||
"200":
|
||||
description: Current schedule JSON report.
|
||||
"401":
|
||||
description: Missing or invalid admin token.
|
||||
"503":
|
||||
description: Rust bat schedule backend is unavailable.
|
||||
/admin/translation/tasks:
|
||||
get:
|
||||
summary: List Rust-owned translation task worker status
|
||||
parameters:
|
||||
- name: offset
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 0
|
||||
- name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 1000
|
||||
- name: task_id
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: release_id
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: destination
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: archive_entry
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: status
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: worker_status
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: parse_status
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: format
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: has_reason
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
- name: has_failure_reason
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
responses:
|
||||
"200":
|
||||
description: Current translation task JSON report from Rust bat.
|
||||
"400":
|
||||
description: Invalid translation task query.
|
||||
"401":
|
||||
description: Missing or invalid admin token.
|
||||
"503":
|
||||
description: Rust bat translation backend is unavailable.
|
||||
/admin/translation/handoff:
|
||||
get:
|
||||
summary: Read Rust-owned translation handoff state
|
||||
responses:
|
||||
"200":
|
||||
description: Current translation handoff JSON report from Rust bat.
|
||||
"401":
|
||||
description: Missing or invalid admin token.
|
||||
"503":
|
||||
description: Rust bat translation backend is unavailable.
|
||||
/admin/translation/memory/summary:
|
||||
get:
|
||||
summary: Read Rust-owned Translation Memory summary
|
||||
parameters:
|
||||
- name: translation_memory_path
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Translation Memory availability and candidate/trusted counts.
|
||||
"401":
|
||||
description: Missing or invalid admin token.
|
||||
"503":
|
||||
description: Rust bat Translation Memory backend is unavailable.
|
||||
/admin/translation/memory/query:
|
||||
get:
|
||||
summary: Query Rust-owned Translation Memory records
|
||||
parameters:
|
||||
- name: source_text
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: source_context
|
||||
in: query
|
||||
description: JSON object whose values are strings.
|
||||
schema:
|
||||
type: string
|
||||
- name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 1
|
||||
maximum: 1000
|
||||
default: 100
|
||||
- name: translation_memory_path
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Translation Memory matches with reuse decision and provenance.
|
||||
"400":
|
||||
description: Missing source text or invalid context/limit.
|
||||
"401":
|
||||
description: Missing or invalid admin token.
|
||||
"503":
|
||||
description: Rust bat Translation Memory backend is unavailable.
|
||||
/admin/translation/memory/conflicts:
|
||||
get:
|
||||
summary: List Rust-owned Translation Memory Trusted conflicts
|
||||
parameters:
|
||||
- name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 1
|
||||
maximum: 1000
|
||||
default: 100
|
||||
- name: translation_memory_path
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Translation Memory exact-identity Trusted conflict groups.
|
||||
"400":
|
||||
description: Invalid conflict list limit.
|
||||
"401":
|
||||
description: Missing or invalid admin token.
|
||||
"503":
|
||||
description: Rust bat Translation Memory backend is unavailable.
|
||||
/admin/translation/glossary/summary:
|
||||
get:
|
||||
summary: Read Rust-owned Glossary summary
|
||||
parameters:
|
||||
- name: glossary_path
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Glossary availability and review-state counts.
|
||||
"401":
|
||||
description: Missing or invalid admin token.
|
||||
"503":
|
||||
description: Rust bat Glossary backend is unavailable.
|
||||
/admin/translation/glossary/query:
|
||||
get:
|
||||
summary: Query Rust-owned Glossary terms
|
||||
parameters:
|
||||
- name: source_text
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: category
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: review_status
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum: [draft, approved, deprecated, rejected]
|
||||
- name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 1
|
||||
maximum: 1000
|
||||
default: 100
|
||||
- name: glossary_path
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Glossary terms with source and review history.
|
||||
"400":
|
||||
description: Invalid Glossary query.
|
||||
"401":
|
||||
description: Missing or invalid admin token.
|
||||
"503":
|
||||
description: Rust bat Glossary backend is unavailable.
|
||||
/admin/translation/glossary/diagnose:
|
||||
get:
|
||||
summary: Run deterministic Glossary diagnostics
|
||||
parameters:
|
||||
- name: source_text
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: context
|
||||
in: query
|
||||
description: JSON object whose values are strings.
|
||||
schema:
|
||||
type: string
|
||||
- name: glossary_path
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Glossary constraints, diagnostics, blocked decision, and stable qa_identity.
|
||||
"400":
|
||||
description: Missing source text or invalid context.
|
||||
"401":
|
||||
description: Missing or invalid admin token.
|
||||
"503":
|
||||
description: Rust bat Glossary 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/releases/status:
|
||||
get:
|
||||
summary: Read the unified Rust-owned release status view
|
||||
responses:
|
||||
"200":
|
||||
description: Official/localized current relation and integrity status.
|
||||
"401":
|
||||
description: Missing or invalid admin token.
|
||||
"503":
|
||||
description: Rust bat release backend is unavailable.
|
||||
/admin/releases:
|
||||
get:
|
||||
summary: Read Rust-owned historical release summaries
|
||||
parameters:
|
||||
- name: channel
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum: [official, localized]
|
||||
responses:
|
||||
"200":
|
||||
description: Historical release summaries.
|
||||
"401":
|
||||
description: Missing or invalid admin token.
|
||||
"503":
|
||||
description: Rust bat release backend is unavailable.
|
||||
/admin/control/{action}:
|
||||
post:
|
||||
summary: Forward an allowlisted control or schedule action to Rust bat
|
||||
parameters:
|
||||
- name: action
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, translation-memory-confirm, translation-memory-resolve-conflict, translation-glossary-add, translation-glossary-update, translation-glossary-approve, translation-glossary-deprecate, translation-glossary-delete, localized-publish, localized-rollback, release-cleanup]
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
properties:
|
||||
force:
|
||||
type: boolean
|
||||
id:
|
||||
type: string
|
||||
group:
|
||||
type: string
|
||||
action:
|
||||
type: string
|
||||
args:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
next_run_unix_seconds:
|
||||
type: integer
|
||||
format: int64
|
||||
delay_seconds:
|
||||
type: integer
|
||||
format: int64
|
||||
every_seconds:
|
||||
type: integer
|
||||
format: int64
|
||||
count:
|
||||
type: integer
|
||||
format: int64
|
||||
max_runs:
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 1
|
||||
clear_args:
|
||||
type: boolean
|
||||
clear_every:
|
||||
type: boolean
|
||||
enabled:
|
||||
type: boolean
|
||||
task_id:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
failure_reason:
|
||||
type: string
|
||||
provider_run_id:
|
||||
type: string
|
||||
provider:
|
||||
type: string
|
||||
translation_results:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [unit_id, source_text, translated_text]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
unit_id:
|
||||
type: string
|
||||
source_text:
|
||||
type: string
|
||||
translated_text:
|
||||
type: string
|
||||
glossary_override:
|
||||
type: object
|
||||
required: [qa_identity, reviewer, reason, provenance, confirmed_unix_seconds]
|
||||
additionalProperties: false
|
||||
properties:
|
||||
qa_identity:
|
||||
type: string
|
||||
minLength: 1
|
||||
reviewer:
|
||||
type: string
|
||||
reason:
|
||||
type: string
|
||||
provenance:
|
||||
type: string
|
||||
confirmed_unix_seconds:
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 1
|
||||
fixture_path:
|
||||
type: string
|
||||
concurrency:
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 1
|
||||
maximum: 256
|
||||
max_attempts:
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 1
|
||||
lease_seconds:
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 1
|
||||
retry_backoff_seconds:
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 0
|
||||
max_tasks:
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 1
|
||||
worker_id:
|
||||
type: string
|
||||
translation_memory_path:
|
||||
type: string
|
||||
glossary_path:
|
||||
type: string
|
||||
record_id:
|
||||
type: string
|
||||
winner_record_id:
|
||||
type: string
|
||||
expected_trusted_record_ids:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
supersede_record_id:
|
||||
type: string
|
||||
reviewer:
|
||||
type: string
|
||||
reason:
|
||||
type: string
|
||||
term_id:
|
||||
type: string
|
||||
source_term:
|
||||
type: string
|
||||
aliases:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
recommended_translation:
|
||||
type: string
|
||||
allowed_translations:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
source_language:
|
||||
type: string
|
||||
target_language:
|
||||
type: string
|
||||
category:
|
||||
type: string
|
||||
priority:
|
||||
type: integer
|
||||
format: int64
|
||||
scope:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
review_status:
|
||||
type: string
|
||||
enum: [draft, approved, deprecated, rejected]
|
||||
source:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [source_kind, observed_unix_seconds]
|
||||
properties:
|
||||
source_kind:
|
||||
type: string
|
||||
enum: [manual, imported]
|
||||
source_ref:
|
||||
type: string
|
||||
source_author:
|
||||
type: string
|
||||
source_note:
|
||||
type: string
|
||||
observed_unix_seconds:
|
||||
type: integer
|
||||
format: int64
|
||||
reviewer:
|
||||
type: string
|
||||
reason:
|
||||
type: string
|
||||
translation_file:
|
||||
type: string
|
||||
from_worker:
|
||||
type: boolean
|
||||
patch_manifest:
|
||||
type: string
|
||||
localized_release_id:
|
||||
type: string
|
||||
responses:
|
||||
"202":
|
||||
description: Rust bat accepted the control request.
|
||||
"400":
|
||||
description: Invalid action parameters.
|
||||
"401":
|
||||
description: Missing or invalid admin token.
|
||||
"403":
|
||||
description: Control is not exposed or no admin token is configured.
|
||||
"501":
|
||||
description: Rust bat does not implement the requested control action.
|
||||
"502":
|
||||
description: Rust bat rejected the control request.
|
||||
/prod-clientpatch.bluearchiveyostar.com/{path}:
|
||||
get:
|
||||
summary: CDN-shaped resource bytes
|
||||
|
||||
@@ -9,7 +9,3 @@ func InspectManifest(rawJSON string) (string, error) {
|
||||
func BuildSyncPlan(currentJSON, previousJSON string) (string, error) {
|
||||
return ffi.BuildSyncPlan(currentJSON, previousJSON)
|
||||
}
|
||||
|
||||
func batVersion() (string, error) {
|
||||
return ffi.Version()
|
||||
}
|
||||
|
||||
+2
-2
@@ -21,6 +21,6 @@ func runSync(args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stdout, result)
|
||||
return nil
|
||||
_, err = fmt.Fprintln(os.Stdout, result)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
async-trait.workspace = true
|
||||
tokio.workspace = true
|
||||
blake3.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["test-util", "macros"] }
|
||||
|
||||
+130
-19
@@ -1,6 +1,11 @@
|
||||
//! 游戏客户端领域对象
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const CLIENT_ROOTS_ENV: &str = "BAT_CLIENT_ROOTS";
|
||||
|
||||
/// 游戏区域
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
@@ -69,18 +74,58 @@ impl GameClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// 发现本地安装的客户端
|
||||
/// 发现显式配置根目录下的本地客户端。
|
||||
///
|
||||
/// # 返回
|
||||
/// - 成功:返回找到的所有客户端
|
||||
/// - 失败:返回错误
|
||||
///
|
||||
/// # 注意
|
||||
/// 此功能将在 Phase 3 实现
|
||||
/// 默认不扫描系统目录。调用方必须通过 `BAT_CLIENT_ROOTS` 提供一个或
|
||||
/// 多个路径;路径格式使用平台原生路径分隔符。没有配置时返回空列表。
|
||||
pub fn discover() -> crate::Result<Vec<GameClient>> {
|
||||
Err(crate::Error::NotImplemented(
|
||||
"客户端发现功能将在 Phase 3 实现".to_string(),
|
||||
))
|
||||
let Some(value) = env::var_os(CLIENT_ROOTS_ENV) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let roots = env::split_paths(&value).collect::<Vec<_>>();
|
||||
Self::discover_in_roots(&roots)
|
||||
}
|
||||
|
||||
/// 在调用方明确提供的隔离根目录下发现客户端。
|
||||
///
|
||||
/// 每个根目录只检查根本身和它的直接子目录,不递归扫描用户目录。
|
||||
/// 当前核心模型的默认发现区域为日本服;其他区域应由适配器提供
|
||||
/// 专用区域识别策略。
|
||||
pub fn discover_in_roots(roots: &[PathBuf]) -> crate::Result<Vec<GameClient>> {
|
||||
let mut candidates = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
for root in roots {
|
||||
if !is_real_directory(root)? || has_symlink_component(root)? {
|
||||
continue;
|
||||
}
|
||||
if seen.insert(root.clone()) {
|
||||
candidates.push(root.clone());
|
||||
}
|
||||
|
||||
for entry in fs::read_dir(root)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if !is_real_directory(&path)? || has_symlink_component(&path)? {
|
||||
continue;
|
||||
}
|
||||
if seen.insert(path.clone()) {
|
||||
candidates.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut clients = Vec::new();
|
||||
for path in candidates {
|
||||
if client_layout_is_present(&path)? {
|
||||
clients.push(GameClient::new(path, GameRegion::Japan));
|
||||
}
|
||||
}
|
||||
Ok(clients)
|
||||
}
|
||||
|
||||
/// 验证客户端完整性
|
||||
@@ -89,12 +134,11 @@ impl GameClient {
|
||||
/// - true: 客户端完整
|
||||
/// - false: 客户端损坏
|
||||
///
|
||||
/// # 注意
|
||||
/// 此功能将在 Phase 3 实现
|
||||
pub fn verify_integrity(&self) -> crate::Result<bool> {
|
||||
Err(crate::Error::NotImplemented(
|
||||
"完整性验证将在 Phase 3 实现".to_string(),
|
||||
))
|
||||
if !is_real_directory(&self.install_path)? || has_symlink_component(&self.install_path)? {
|
||||
return Ok(false);
|
||||
}
|
||||
client_layout_is_present(&self.install_path)
|
||||
}
|
||||
|
||||
/// 获取 StreamingAssets 目录路径
|
||||
@@ -110,9 +154,40 @@ impl GameClient {
|
||||
}
|
||||
}
|
||||
|
||||
fn client_layout_is_present(path: &Path) -> crate::Result<bool> {
|
||||
Ok(is_real_directory(&path.join("BlueArchive_Data"))?
|
||||
&& is_real_directory(&path.join("BlueArchive_Data/StreamingAssets"))?
|
||||
&& is_real_directory(&path.join("BlueArchive_Data/StreamingAssets/AssetBundles"))?
|
||||
&& !has_symlink_component(path)?)
|
||||
}
|
||||
|
||||
fn is_real_directory(path: &Path) -> crate::Result<bool> {
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(metadata) => Ok(metadata.is_dir() && !metadata.file_type().is_symlink()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn has_symlink_component(path: &Path) -> crate::Result<bool> {
|
||||
let mut current = PathBuf::new();
|
||||
for component in path.components() {
|
||||
current.push(component.as_os_str());
|
||||
match fs::symlink_metadata(¤t) {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => return Ok(true),
|
||||
Ok(_) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_game_region_code() {
|
||||
@@ -153,12 +228,48 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discover_not_implemented() {
|
||||
let result = GameClient::discover();
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(
|
||||
result.unwrap_err(),
|
||||
crate::Error::NotImplemented(_)
|
||||
));
|
||||
fn test_discover_without_explicit_roots_is_empty() {
|
||||
// discover() 不得因为测试机或用户 home 中存在目录而扫描它们。
|
||||
assert!(GameClient::discover_in_roots(&[]).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discover_and_verify_isolated_client_layout() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let client_root = temp.path().join("BlueArchive_JP");
|
||||
fs::create_dir_all(client_root.join("BlueArchive_Data/StreamingAssets/AssetBundles"))
|
||||
.unwrap();
|
||||
|
||||
let clients = GameClient::discover_in_roots(&[temp.path().to_path_buf()]).unwrap();
|
||||
assert_eq!(clients.len(), 1);
|
||||
assert_eq!(clients[0].install_path, client_root);
|
||||
assert_eq!(clients[0].region, GameRegion::Japan);
|
||||
assert!(clients[0].verify_integrity().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_integrity_rejects_incomplete_layout() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let client = GameClient::new(temp.path().join("missing"), GameRegion::Japan);
|
||||
assert!(!client.verify_integrity().unwrap());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_discovery_and_integrity_reject_symlinked_client() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let temp = TempDir::new().unwrap();
|
||||
let real = temp.path().join("real");
|
||||
fs::create_dir_all(real.join("BlueArchive_Data/StreamingAssets/AssetBundles")).unwrap();
|
||||
let link = temp.path().join("link");
|
||||
symlink(&real, &link).unwrap();
|
||||
|
||||
let clients = GameClient::discover_in_roots(&[temp.path().to_path_buf()]).unwrap();
|
||||
assert_eq!(clients.len(), 1);
|
||||
assert_eq!(clients[0].install_path, real);
|
||||
assert!(!GameClient::new(link, GameRegion::Japan)
|
||||
.verify_integrity()
|
||||
.unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,11 +2,19 @@
|
||||
|
||||
pub mod game_client;
|
||||
pub mod game_version;
|
||||
pub mod glossary;
|
||||
pub mod resource;
|
||||
pub mod translation;
|
||||
pub mod translation_memory;
|
||||
|
||||
pub use game_client::{ClientStatus, GameClient, GameRegion};
|
||||
pub use game_version::{GameVersion, UnityVersion};
|
||||
pub use glossary::{
|
||||
evaluate_glossary, validate_glossary_draft, validate_glossary_override, GlossaryConstraint,
|
||||
GlossaryDiagnostic, GlossaryDiagnosticKind, GlossaryEvaluation, GlossaryHistoryRecord,
|
||||
GlossaryOverride, GlossaryQaReport, GlossaryQaStatus, GlossaryReviewStatus, GlossarySourceKind,
|
||||
GlossarySourceRecord, GlossarySummary, GlossaryTerm, GlossaryTermDraft, GlossaryTermSnapshot,
|
||||
};
|
||||
pub use resource::{
|
||||
crc32_ieee, IntegrityMismatch, Resource, ResourceEntry, ResourceMetadata, ResourceType,
|
||||
};
|
||||
@@ -14,3 +22,9 @@ pub use translation::{
|
||||
ExtractedText, SourceText, TextContext, TextMetadata, TextSource, TranslatedText,
|
||||
TranslationStatus,
|
||||
};
|
||||
pub use translation_memory::{
|
||||
TranslationMemoryConflict, TranslationMemoryContext, TranslationMemoryDraft,
|
||||
TranslationMemoryEntry, TranslationMemoryMatch, TranslationMemoryMatchKind,
|
||||
TranslationMemorySourceKind, TranslationMemorySourceTrace, TranslationMemorySummary,
|
||||
TranslationMemoryTrustStatus,
|
||||
};
|
||||
|
||||
@@ -34,6 +34,16 @@ pub struct ResourceEntry {
|
||||
pub address: Option<String>,
|
||||
/// 该资源依赖的其他资源标识
|
||||
pub dependencies: Vec<String>,
|
||||
/// Addressables provider ID。
|
||||
///
|
||||
/// 旧的 manifest 和资源索引没有该字段,缺省时保持 `None`。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub provider_id: Option<String>,
|
||||
/// Addressables bundle name。
|
||||
///
|
||||
/// 该值是定位/诊断字段,不作为资源 hash 的替代值。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub bundle_name: Option<String>,
|
||||
/// Addressables bundle 的 CRC32(catalog 中的 `m_Crc`)。
|
||||
///
|
||||
/// `None` 表示 catalog 未提供该字段;Unity 用 `0` 表示「不做 CRC 校验」,
|
||||
@@ -202,6 +212,8 @@ mod tests {
|
||||
resource_type: ResourceType::AssetBundle,
|
||||
address: None,
|
||||
dependencies: Vec::new(),
|
||||
provider_id: None,
|
||||
bundle_name: None,
|
||||
crc,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
//! Translation Memory 领域对象。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// TM 记录的来源类型。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TranslationMemorySourceKind {
|
||||
/// 来自 provider 输出。
|
||||
Provider,
|
||||
/// 来自人工确认。
|
||||
Manual,
|
||||
/// 来自外部导入。
|
||||
Imported,
|
||||
}
|
||||
|
||||
impl TranslationMemorySourceKind {
|
||||
/// 返回稳定的持久化标签。
|
||||
pub const fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Provider => "provider",
|
||||
Self::Manual => "manual",
|
||||
Self::Imported => "imported",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// TM 记录的可信状态。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TranslationMemoryTrustStatus {
|
||||
/// 候选记录,不能自动复用。
|
||||
Candidate,
|
||||
/// 已确认可信,可在强匹配时自动复用。
|
||||
Trusted,
|
||||
/// 已被后续记录取代。
|
||||
Superseded,
|
||||
/// 已明确拒绝。
|
||||
Rejected,
|
||||
}
|
||||
|
||||
impl TranslationMemoryTrustStatus {
|
||||
/// 返回稳定的持久化标签。
|
||||
pub const fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Candidate => "candidate",
|
||||
Self::Trusted => "trusted",
|
||||
Self::Superseded => "superseded",
|
||||
Self::Rejected => "rejected",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// TM 查询结果的匹配类型。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TranslationMemoryMatchKind {
|
||||
/// 原始 source 和上下文都完全匹配,且记录可信,可自动复用。
|
||||
StrongExact,
|
||||
/// 同一 exact identity 存在多个 Trusted,必须人工治理。
|
||||
TrustedConflict,
|
||||
/// 原始 source 完全匹配,但上下文不同或不足,不能自动复用。
|
||||
CandidateExact,
|
||||
/// 原始 source 匹配,但上下文不兼容,不能自动复用。
|
||||
SourceOnly,
|
||||
}
|
||||
|
||||
impl TranslationMemoryMatchKind {
|
||||
/// 返回稳定的查询结果标签。
|
||||
pub const fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::StrongExact => "strong_exact",
|
||||
Self::TrustedConflict => "trusted_conflict",
|
||||
Self::CandidateExact => "candidate_exact",
|
||||
Self::SourceOnly => "source_only",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 稳定的上下文键值。
|
||||
pub type TranslationMemoryContext = BTreeMap<String, String>;
|
||||
|
||||
/// TM 记录的 TextUnit / provider 溯源信息。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct TranslationMemorySourceTrace {
|
||||
/// 源官方 release ID。
|
||||
pub official_release_id: String,
|
||||
/// 来源 TextUnit ID。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub unit_id: Option<String>,
|
||||
/// 来源任务 ID。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub task_id: Option<String>,
|
||||
/// 源资源 destination。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub destination: Option<String>,
|
||||
/// 源 ZIP/archive entry。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub archive_entry: Option<String>,
|
||||
/// Unity serialized file。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub serialized_file: Option<String>,
|
||||
/// Unity object path ID。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub path_id: Option<i64>,
|
||||
/// Unity class ID。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub class_id: Option<i32>,
|
||||
/// TypeTree 字段路径。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub field_path: Option<String>,
|
||||
/// TextUnit format。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub format: Option<String>,
|
||||
/// TextAsset 名称。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub asset_name: Option<String>,
|
||||
/// TextUnit 来源类型。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub text_source_kind: Option<String>,
|
||||
/// 源 URL。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source_url: Option<String>,
|
||||
}
|
||||
|
||||
/// TM 记录的候选输入。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct TranslationMemoryDraft {
|
||||
/// 原始 source text。
|
||||
pub source_text: String,
|
||||
/// 稳定上下文。
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub source_context: TranslationMemoryContext,
|
||||
/// 译文。
|
||||
pub translated_text: String,
|
||||
/// 译文来源类型。
|
||||
pub translation_source_kind: TranslationMemorySourceKind,
|
||||
/// 源官方 release。
|
||||
pub official_release_id: String,
|
||||
/// 原始 TextUnit / provider 溯源。
|
||||
pub source_trace: TranslationMemorySourceTrace,
|
||||
/// provider。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub provider: Option<String>,
|
||||
/// provider run。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub provider_run_id: Option<String>,
|
||||
/// 创建时间。
|
||||
pub observed_unix_seconds: u64,
|
||||
}
|
||||
|
||||
/// 持久化 TM 记录。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct TranslationMemoryEntry {
|
||||
/// 稳定记录 ID。
|
||||
pub record_id: String,
|
||||
/// 原始 source text。
|
||||
pub source_text: String,
|
||||
/// source text hash。
|
||||
pub source_hash: String,
|
||||
/// 保守归一化后的 source text,仅用于辅助查询。
|
||||
pub normalized_source_text: String,
|
||||
/// 稳定上下文。
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub source_context: TranslationMemoryContext,
|
||||
/// 上下文 hash。
|
||||
pub source_context_hash: String,
|
||||
/// 译文。
|
||||
pub translated_text: String,
|
||||
/// 译文来源类型。
|
||||
pub translation_source_kind: TranslationMemorySourceKind,
|
||||
/// 当前可信状态。
|
||||
pub trust_status: TranslationMemoryTrustStatus,
|
||||
/// 源官方 release。
|
||||
pub official_release_id: String,
|
||||
/// 原始 TextUnit / provider 溯源。
|
||||
pub source_trace: TranslationMemorySourceTrace,
|
||||
/// provider。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub provider: Option<String>,
|
||||
/// provider run。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub provider_run_id: Option<String>,
|
||||
/// 创建时间。
|
||||
pub created_unix_seconds: u64,
|
||||
/// 更新时间。
|
||||
pub updated_unix_seconds: u64,
|
||||
/// 可信确认时间。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub trusted_unix_seconds: Option<u64>,
|
||||
/// 可信确认人。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub trusted_by: Option<String>,
|
||||
/// 可信确认说明。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub trusted_reason: Option<String>,
|
||||
/// 该记录替代了哪条记录。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub supersedes_record_id: Option<String>,
|
||||
/// 该记录被哪条记录替代。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub superseded_by_record_id: Option<String>,
|
||||
}
|
||||
|
||||
/// TM 查询结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct TranslationMemoryMatch {
|
||||
/// 记录本体。
|
||||
pub entry: TranslationMemoryEntry,
|
||||
/// 匹配类型。
|
||||
pub match_kind: TranslationMemoryMatchKind,
|
||||
/// 是否允许自动复用。
|
||||
pub can_auto_reuse: bool,
|
||||
}
|
||||
|
||||
/// 一个 exact identity 的历史多 Trusted 冲突组。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct TranslationMemoryConflict {
|
||||
/// 原始 source text。
|
||||
pub source_text: String,
|
||||
/// source text hash,仅用于稳定定位和辅助查询。
|
||||
pub source_hash: String,
|
||||
/// 完整 source context。
|
||||
pub source_context: TranslationMemoryContext,
|
||||
/// source context hash,仅用于稳定定位和辅助查询。
|
||||
pub source_context_hash: String,
|
||||
/// 当前数据库中属于该冲突组的 Trusted record ID。
|
||||
pub trusted_record_ids: Vec<String>,
|
||||
/// 冲突组记录及其原始 trust provenance。
|
||||
pub records: Vec<TranslationMemoryEntry>,
|
||||
}
|
||||
|
||||
/// TM 仓储摘要。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct TranslationMemorySummary {
|
||||
/// schema 版本。
|
||||
pub schema_version: u32,
|
||||
/// 记录总数。
|
||||
pub record_count: u64,
|
||||
/// 可信记录数。
|
||||
pub trusted_count: u64,
|
||||
/// 候选记录数。
|
||||
pub candidate_count: u64,
|
||||
/// 已替代记录数。
|
||||
pub superseded_count: u64,
|
||||
/// 已拒绝记录数。
|
||||
pub rejected_count: u64,
|
||||
/// exact identity 的 Trusted 冲突组数量。
|
||||
pub trusted_conflict_group_count: u64,
|
||||
/// 具备 current Trusted authorization 的 exact identity 数量。
|
||||
pub current_trusted_count: u64,
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Glossary repository boundary.
|
||||
|
||||
use crate::domain::{GlossaryEvaluation, TranslationMemoryContext};
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// Read-only matching boundary consumed by translation workers.
|
||||
#[async_trait]
|
||||
pub trait GlossaryRepository: Send + Sync {
|
||||
/// Evaluates approved terms against one source TextUnit.
|
||||
async fn evaluate(
|
||||
&self,
|
||||
source_text: &str,
|
||||
context: &TranslationMemoryContext,
|
||||
) -> crate::Result<GlossaryEvaluation>;
|
||||
}
|
||||
@@ -3,9 +3,13 @@
|
||||
//! 定义所有数据访问接口
|
||||
|
||||
pub mod cas_repository;
|
||||
pub mod glossary_repository;
|
||||
pub mod resource_repository;
|
||||
pub mod translation_memory_repository;
|
||||
pub mod translation_repository;
|
||||
|
||||
pub use cas_repository::CasRepository;
|
||||
pub use glossary_repository::GlossaryRepository;
|
||||
pub use resource_repository::ResourceRepository;
|
||||
pub use translation_memory_repository::TranslationMemoryRepository;
|
||||
pub use translation_repository::TranslationRepository;
|
||||
|
||||
@@ -37,7 +37,7 @@ use async_trait::async_trait;
|
||||
|
||||
/// 资源查询条件
|
||||
///
|
||||
/// 用于构建灵活的资源查询。支持按类型、Hash、路径模式过滤。
|
||||
/// 用于构建灵活的资源查询。支持按类型、Hash、路径、官方 release 和解析摘要过滤。
|
||||
///
|
||||
/// # 示例
|
||||
///
|
||||
@@ -56,9 +56,10 @@ use async_trait::async_trait;
|
||||
/// resource_type: Some(ResourceType::AssetBundle),
|
||||
/// hash: Some("abc123".to_string()),
|
||||
/// path_pattern: Some("academy-*.bundle".to_string()),
|
||||
/// ..ResourceQuery::all()
|
||||
/// };
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ResourceQuery {
|
||||
/// 按资源类型过滤
|
||||
///
|
||||
@@ -90,6 +91,41 @@ pub struct ResourceQuery {
|
||||
/// - `"**/*.json"` - 匹配所有 JSON 文件
|
||||
/// - `"assets/???.png"` - 匹配三个字符的 PNG 文件
|
||||
pub path_pattern: Option<String>,
|
||||
|
||||
/// 按官方 release ID 过滤。
|
||||
///
|
||||
/// 匹配 `ResourceMetadata::official_release_id`。
|
||||
pub official_release_id: Option<String>,
|
||||
|
||||
/// 按资源平台过滤。
|
||||
///
|
||||
/// 匹配 `ResourceMetadata::platform`,例如 `windows` 或 `android`。
|
||||
pub platform: Option<String>,
|
||||
|
||||
/// 按官方 destination 过滤。
|
||||
///
|
||||
/// 匹配 `ResourceEntry::path`,用于从 release manifest destination 反查资源。
|
||||
pub destination: Option<String>,
|
||||
|
||||
/// 按资源或所在 bundle 的官方相对路径过滤。
|
||||
///
|
||||
/// 匹配 `ResourceMetadata::bundle_path`。
|
||||
pub bundle_path: Option<String>,
|
||||
|
||||
/// 按 ZIP/archive entry 过滤。
|
||||
///
|
||||
/// 匹配 `ResourceMetadata::archive_entries` 中的任意一项。
|
||||
pub archive_entry: Option<String>,
|
||||
|
||||
/// 按解析状态过滤。
|
||||
///
|
||||
/// 匹配 `ResourceMetadata::parse_statuses` 中的任意一项。
|
||||
pub parse_status: Option<String>,
|
||||
|
||||
/// 按 TextUnit payload format 过滤。
|
||||
///
|
||||
/// 匹配 `ResourceMetadata::text_unit_formats` 中的任意一项。
|
||||
pub text_unit_format: Option<String>,
|
||||
}
|
||||
|
||||
impl ResourceQuery {
|
||||
@@ -105,11 +141,7 @@ impl ResourceQuery {
|
||||
/// let all_resources = repo.list(ResourceQuery::all()).await?;
|
||||
/// ```
|
||||
pub fn all() -> Self {
|
||||
Self {
|
||||
resource_type: None,
|
||||
hash: None,
|
||||
path_pattern: None,
|
||||
}
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 按类型查询
|
||||
@@ -132,8 +164,7 @@ impl ResourceQuery {
|
||||
pub fn by_type(resource_type: ResourceType) -> Self {
|
||||
Self {
|
||||
resource_type: Some(resource_type),
|
||||
hash: None,
|
||||
path_pattern: None,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,11 +187,23 @@ impl ResourceQuery {
|
||||
/// ```
|
||||
pub fn by_hash(hash: String) -> Self {
|
||||
Self {
|
||||
resource_type: None,
|
||||
hash: Some(hash),
|
||||
path_pattern: None,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否包含通用仓储需要读取完整 `Resource` 后才能判断的条件。
|
||||
///
|
||||
/// 具体后端可以把这些 metadata 条件下推到自身索引;内存实现仍用完整
|
||||
/// `Resource` 过滤来保持 `list()` 与 `count()` 的语义一致。
|
||||
pub fn requires_resource_scan(&self) -> bool {
|
||||
self.official_release_id.is_some()
|
||||
|| self.platform.is_some()
|
||||
|| self.bundle_path.is_some()
|
||||
|| self.archive_entry.is_some()
|
||||
|| self.parse_status.is_some()
|
||||
|| self.text_unit_format.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// 资源仓储接口
|
||||
@@ -305,6 +348,7 @@ pub trait ResourceRepository: Send + Sync {
|
||||
/// resource_type: Some(ResourceType::AssetBundle),
|
||||
/// path_pattern: Some("academy-*.bundle".to_string()),
|
||||
/// hash: None,
|
||||
/// ..ResourceQuery::all()
|
||||
/// };
|
||||
/// let filtered = repo.list(query).await?;
|
||||
/// ```
|
||||
@@ -416,6 +460,13 @@ mod tests {
|
||||
assert!(query.resource_type.is_none());
|
||||
assert!(query.hash.is_none());
|
||||
assert!(query.path_pattern.is_none());
|
||||
assert!(query.official_release_id.is_none());
|
||||
assert!(query.platform.is_none());
|
||||
assert!(query.destination.is_none());
|
||||
assert!(query.bundle_path.is_none());
|
||||
assert!(query.archive_entry.is_none());
|
||||
assert!(query.parse_status.is_none());
|
||||
assert!(query.text_unit_format.is_none());
|
||||
}
|
||||
|
||||
/// 测试按类型查询
|
||||
@@ -425,6 +476,7 @@ mod tests {
|
||||
assert_eq!(query.resource_type, Some(ResourceType::AssetBundle));
|
||||
assert!(query.hash.is_none());
|
||||
assert!(query.path_pattern.is_none());
|
||||
assert!(!query.requires_resource_scan());
|
||||
}
|
||||
|
||||
/// 测试按 Hash 查询
|
||||
@@ -434,6 +486,7 @@ mod tests {
|
||||
assert_eq!(query.hash, Some("abc123".to_string()));
|
||||
assert!(query.resource_type.is_none());
|
||||
assert!(query.path_pattern.is_none());
|
||||
assert!(!query.requires_resource_scan());
|
||||
}
|
||||
|
||||
/// 测试组合查询
|
||||
@@ -443,11 +496,32 @@ mod tests {
|
||||
resource_type: Some(ResourceType::AssetBundle),
|
||||
hash: Some("hash123".to_string()),
|
||||
path_pattern: Some("*.bundle".to_string()),
|
||||
official_release_id: Some("v-current".to_string()),
|
||||
platform: Some("windows".to_string()),
|
||||
destination: Some("Bundles/academy.bundle".to_string()),
|
||||
bundle_path: Some("Bundles/academy.bundle".to_string()),
|
||||
archive_entry: Some("academy".to_string()),
|
||||
parse_status: Some("parsed".to_string()),
|
||||
text_unit_format: Some("json".to_string()),
|
||||
};
|
||||
|
||||
assert_eq!(query.resource_type, Some(ResourceType::AssetBundle));
|
||||
assert_eq!(query.hash, Some("hash123".to_string()));
|
||||
assert_eq!(query.path_pattern, Some("*.bundle".to_string()));
|
||||
assert_eq!(query.official_release_id, Some("v-current".to_string()));
|
||||
assert_eq!(query.platform, Some("windows".to_string()));
|
||||
assert_eq!(
|
||||
query.destination,
|
||||
Some("Bundles/academy.bundle".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
query.bundle_path,
|
||||
Some("Bundles/academy.bundle".to_string())
|
||||
);
|
||||
assert_eq!(query.archive_entry, Some("academy".to_string()));
|
||||
assert_eq!(query.parse_status, Some("parsed".to_string()));
|
||||
assert_eq!(query.text_unit_format, Some("json".to_string()));
|
||||
assert!(query.requires_resource_scan());
|
||||
}
|
||||
|
||||
/// 测试 ResourceQuery 可以被克隆
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Translation Memory 仓储契约。
|
||||
|
||||
use crate::domain::{
|
||||
TranslationMemoryConflict, TranslationMemoryContext, TranslationMemoryDraft,
|
||||
TranslationMemoryEntry, TranslationMemoryMatch, TranslationMemorySummary,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// 跨 official release 持久化的 Translation Memory 仓储。
|
||||
///
|
||||
/// 该契约描述 exact-match TM 和明确人工 Trusted 治理。仓储实现不得把
|
||||
/// `TranslationTaskStatus::Completed` 或 provider 成功隐式解释为 trusted。
|
||||
#[async_trait]
|
||||
pub trait TranslationMemoryRepository: Send + Sync {
|
||||
/// 保存一条 provider/manual/imported 译文候选。
|
||||
///
|
||||
/// 相同 source、上下文、译文、来源 release 和来源类型的重复写入必须幂等;
|
||||
/// 已 trusted 的记录不得被普通候选静默覆盖。
|
||||
async fn upsert_candidate(
|
||||
&self,
|
||||
draft: TranslationMemoryDraft,
|
||||
) -> crate::Result<TranslationMemoryEntry>;
|
||||
|
||||
/// 按原始 source text 和上下文查询精确匹配。
|
||||
///
|
||||
/// 实现可以返回 source 归一化后但原文不同的辅助候选,但这类结果不能自动复用。
|
||||
async fn find_matches(
|
||||
&self,
|
||||
source_text: &str,
|
||||
source_context: &TranslationMemoryContext,
|
||||
limit: usize,
|
||||
) -> crate::Result<Vec<TranslationMemoryMatch>>;
|
||||
|
||||
/// 显式确认一条记录为 trusted。
|
||||
async fn confirm(
|
||||
&self,
|
||||
record_id: &str,
|
||||
reviewer: &str,
|
||||
reason: Option<String>,
|
||||
) -> crate::Result<TranslationMemoryEntry>;
|
||||
|
||||
/// 确认记录,并在需要时显式 supersede 当前唯一 Trusted。
|
||||
async fn confirm_with_supersede(
|
||||
&self,
|
||||
record_id: &str,
|
||||
reviewer: &str,
|
||||
reason: Option<String>,
|
||||
supersede_record_id: Option<&str>,
|
||||
) -> crate::Result<TranslationMemoryEntry>;
|
||||
|
||||
/// 列出历史上存在多个 Trusted 的 exact identity 冲突组。
|
||||
async fn list_conflicts(&self, limit: usize) -> crate::Result<Vec<TranslationMemoryConflict>>;
|
||||
|
||||
/// 使用事务内精确的 expected set 显式解决一个 Trusted 冲突组。
|
||||
async fn resolve_conflict(
|
||||
&self,
|
||||
winner_record_id: &str,
|
||||
expected_trusted_record_ids: &[String],
|
||||
reviewer: &str,
|
||||
reason: &str,
|
||||
) -> crate::Result<TranslationMemoryEntry>;
|
||||
|
||||
/// 按稳定记录 ID 读取一条 TM 记录。
|
||||
async fn find(&self, record_id: &str) -> crate::Result<TranslationMemoryEntry>;
|
||||
|
||||
/// 读取数据库和记录统计。
|
||||
async fn summary(&self) -> crate::Result<TranslationMemorySummary>;
|
||||
}
|
||||
@@ -17,8 +17,8 @@ pub mod types;
|
||||
pub use error::{AssetBundleError, Result};
|
||||
pub use parser::{compression_from_flags, Parser, UnityFsParser};
|
||||
pub use patch::{
|
||||
patch_unityfs_field, patch_unityfs_string_field, patch_unityfs_text_asset, FieldPatch,
|
||||
StringFieldPatch, TextAssetPatch,
|
||||
patch_unityfs_field, patch_unityfs_string_field, patch_unityfs_text_asset,
|
||||
rebuild_unityfs_bundle, FieldPatch, StringFieldPatch, TextAssetPatch,
|
||||
};
|
||||
pub use serialized::{
|
||||
UnityManagedReferenceMetadata, UnityManagedReferenceRecord, UnitySerializedField,
|
||||
|
||||
@@ -6,6 +6,7 @@ use crate::types::{
|
||||
ParsedAssetBundle, RawAssetBundle, UnityFsBlockInfo, UnityFsBundle, UnityFsCompression,
|
||||
UnityFsDirectoryInfo, UnityFsFile, UnityFsHeader, UnitySerializedParseError,
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
use std::io::Cursor;
|
||||
|
||||
const UNITYFS_COMPRESSION_MASK: u32 = 0x3f;
|
||||
@@ -113,9 +114,9 @@ fn parse_unityfs(data: &[u8]) -> Result<UnityFsBundle> {
|
||||
flags: reader.read_u32("flags")?,
|
||||
};
|
||||
|
||||
if header.total_size > data.len() as u64 {
|
||||
if header.total_size != data.len() as u64 {
|
||||
return Err(AssetBundleError::Parse(format!(
|
||||
"UnityFS total_size {} exceeds file size {}",
|
||||
"UnityFS total_size {} does not match file size {}",
|
||||
header.total_size,
|
||||
data.len()
|
||||
)));
|
||||
@@ -173,6 +174,7 @@ fn parse_unityfs(data: &[u8]) -> Result<UnityFsBundle> {
|
||||
compressed_data_size,
|
||||
uncompressed_data_size,
|
||||
raw_data: data.to_vec(),
|
||||
uncompressed_data,
|
||||
files,
|
||||
serialized_files,
|
||||
text_assets,
|
||||
@@ -446,8 +448,14 @@ fn parse_blocks_info(
|
||||
"invalid UnityFS block count: {block_count}"
|
||||
)));
|
||||
}
|
||||
let block_count = checked_record_count(
|
||||
block_count,
|
||||
data.len().saturating_sub(reader.offset()),
|
||||
10,
|
||||
"block_count",
|
||||
)?;
|
||||
|
||||
let mut blocks = Vec::with_capacity(block_count as usize);
|
||||
let mut blocks = Vec::with_capacity(block_count);
|
||||
for _ in 0..block_count {
|
||||
let uncompressed_size = reader.read_u32("block_uncompressed_size")?;
|
||||
let compressed_size = reader.read_u32("block_compressed_size")?;
|
||||
@@ -466,8 +474,14 @@ fn parse_blocks_info(
|
||||
"invalid UnityFS directory count: {directory_count}"
|
||||
)));
|
||||
}
|
||||
let directory_count = checked_record_count(
|
||||
directory_count,
|
||||
data.len().saturating_sub(reader.offset()),
|
||||
21,
|
||||
"directory_count",
|
||||
)?;
|
||||
|
||||
let mut directories = Vec::with_capacity(directory_count as usize);
|
||||
let mut directories = Vec::with_capacity(directory_count);
|
||||
for _ in 0..directory_count {
|
||||
directories.push(UnityFsDirectoryInfo {
|
||||
offset: reader.read_u64("directory_offset")?,
|
||||
@@ -546,7 +560,15 @@ fn validate_directory_bounds(
|
||||
data_region_size: u64,
|
||||
directories: &[UnityFsDirectoryInfo],
|
||||
) -> Result<()> {
|
||||
let mut paths = HashSet::with_capacity(directories.len());
|
||||
for (index, directory) in directories.iter().enumerate() {
|
||||
validate_directory_path(index, &directory.path)?;
|
||||
if !paths.insert(directory.path.as_str()) {
|
||||
return Err(AssetBundleError::Parse(format!(
|
||||
"UnityFS directory {} (index {index}) is duplicated",
|
||||
directory.path
|
||||
)));
|
||||
}
|
||||
let end = directory
|
||||
.offset
|
||||
.checked_add(directory.size)
|
||||
@@ -567,6 +589,45 @@ fn validate_directory_bounds(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn checked_record_count(
|
||||
count: i32,
|
||||
remaining_bytes: usize,
|
||||
minimum_record_size: usize,
|
||||
field: &str,
|
||||
) -> Result<usize> {
|
||||
let count = usize::try_from(count)
|
||||
.map_err(|_| AssetBundleError::Parse(format!("invalid UnityFS {field}: {count}")))?;
|
||||
let required_bytes = count.checked_mul(minimum_record_size).ok_or_else(|| {
|
||||
AssetBundleError::Parse(format!(
|
||||
"UnityFS {field} record size overflows usize: count {count}, minimum {minimum_record_size}"
|
||||
))
|
||||
})?;
|
||||
if required_bytes > remaining_bytes {
|
||||
return Err(AssetBundleError::Parse(format!(
|
||||
"UnityFS {field} count {count} requires at least {required_bytes} bytes, only {remaining_bytes} remain"
|
||||
)));
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
fn validate_directory_path(index: usize, path: &str) -> Result<()> {
|
||||
let is_windows_absolute = path.as_bytes().get(1) == Some(&b':');
|
||||
if path.is_empty()
|
||||
|| path.starts_with('/')
|
||||
|| path.starts_with('\\')
|
||||
|| is_windows_absolute
|
||||
|| path
|
||||
.replace('\\', "/")
|
||||
.split('/')
|
||||
.any(|component| component == "..")
|
||||
{
|
||||
return Err(AssetBundleError::Parse(format!(
|
||||
"UnityFS directory path is unsafe at index {index}: {path:?}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct UnityFsReader<'a> {
|
||||
data: &'a [u8],
|
||||
offset: usize,
|
||||
@@ -972,6 +1033,21 @@ mod tests {
|
||||
assert_eq!(parsed.files[0].data, payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_lzma_compressed_data_block() {
|
||||
let parser = UnityFsParser::new();
|
||||
let payload = b"localized-lzma-payload";
|
||||
let mut compressed = Vec::new();
|
||||
lzma_rs::lzma_compress(&mut Cursor::new(payload), &mut compressed).unwrap();
|
||||
let data = synthetic_unityfs_bundle_with_payload("CAB-lzma", payload, &compressed, 1);
|
||||
|
||||
let parsed = parser.parse_bytes(&data).unwrap();
|
||||
|
||||
assert_eq!(parsed.files.len(), 1);
|
||||
assert_eq!(parsed.files[0].path, "CAB-lzma");
|
||||
assert_eq!(parsed.files[0].data, payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_data_block_after_block_info_alignment_padding() {
|
||||
let parser = UnityFsParser::new();
|
||||
@@ -1002,6 +1078,62 @@ mod tests {
|
||||
assert!(error.contains("uncompressed data region size 4"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsafe_directory_path() {
|
||||
let parser = UnityFsParser::new();
|
||||
let data = synthetic_unityfs_bundle_with_payload("../outside", b"data", b"data", 0);
|
||||
|
||||
let error = parser.parse_bytes(&data).unwrap_err().to_string();
|
||||
|
||||
assert!(error.contains("unsafe"), "{error}");
|
||||
assert!(error.contains("../outside"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_directory_path() {
|
||||
let parser = UnityFsParser::new();
|
||||
let mut blocks_info = blocks_info(4);
|
||||
push_i32_at(&mut blocks_info, 16 + 4 + 10, 2);
|
||||
push_u64(&mut blocks_info, 0);
|
||||
push_u64(&mut blocks_info, 0);
|
||||
push_u32(&mut blocks_info, 0);
|
||||
push_c_string(&mut blocks_info, "CAB-test");
|
||||
let data = synthetic_unityfs_bundle(&blocks_info, 0, false);
|
||||
|
||||
let error = parser.parse_bytes(&data).unwrap_err().to_string();
|
||||
|
||||
assert!(error.contains("duplicated"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_declared_total_size_mismatch() {
|
||||
let parser = UnityFsParser::new();
|
||||
let mut data = synthetic_unityfs_bundle(&blocks_info(4), 0, false);
|
||||
let total_size_offset = b"UnityFS\0".len() + 4 + b"5.x.x\0".len() + b"2021.3.56f2\0".len();
|
||||
let declared_size = (data.len() as u64) - 1;
|
||||
data[total_size_offset..total_size_offset + 8]
|
||||
.copy_from_slice(&declared_size.to_be_bytes());
|
||||
|
||||
let error = parser.parse_bytes(&data).unwrap_err().to_string();
|
||||
|
||||
assert!(error.contains("does not match file size"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_block_count_that_cannot_fit_in_block_info() {
|
||||
let parser = UnityFsParser::new();
|
||||
let mut data = synthetic_unityfs_bundle(&blocks_info(4), 0, false);
|
||||
// The fixed test header is aligned to offset 64; block_count follows
|
||||
// the 16-byte blocks-info hash.
|
||||
let block_count_offset = 64 + 16;
|
||||
data[block_count_offset..block_count_offset + 4].copy_from_slice(&i32::MAX.to_be_bytes());
|
||||
|
||||
let error = parser.parse_bytes(&data).unwrap_err().to_string();
|
||||
|
||||
assert!(error.contains("block_count"), "{error}");
|
||||
assert!(error.contains("requires at least"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_truncated_header_with_field_context() {
|
||||
let parser = UnityFsParser::new();
|
||||
@@ -1023,4 +1155,8 @@ mod tests {
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
|
||||
fn push_i32_at(data: &mut [u8], offset: usize, value: i32) {
|
||||
data[offset..offset + 4].copy_from_slice(&value.to_be_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use crate::parser::UnityFsParser;
|
||||
use crate::serialized::{
|
||||
UnitySerializedField, UnitySerializedReplacementValue, UnitySerializedValue,
|
||||
};
|
||||
use crate::types::UnityFsBundle;
|
||||
use crate::types::{UnityFsBundle, UnityFsCompression};
|
||||
use md5::{Digest, Md5};
|
||||
|
||||
/// One TextAsset replacement inside a serialized UnityFS directory file.
|
||||
@@ -105,9 +105,9 @@ impl FieldPatch {
|
||||
|
||||
/// Patches one TextAsset and rebuilds the UnityFS container.
|
||||
///
|
||||
/// The rebuilt bundle uses a single uncompressed data block. This keeps the
|
||||
/// patch path deterministic and avoids relying on a compressor-specific
|
||||
/// implementation while preserving all directory file paths and metadata.
|
||||
/// The rebuilt bundle retains the parsed block count, compression modes,
|
||||
/// alignment flags and directory metadata while recalculating all variable
|
||||
/// offsets and sizes.
|
||||
pub fn patch_unityfs_text_asset(data: &[u8], patch: &TextAssetPatch) -> Result<Vec<u8>> {
|
||||
let parser = UnityFsParser::new();
|
||||
let mut bundle = parser.parse_bytes(data)?;
|
||||
@@ -142,8 +142,25 @@ pub fn patch_unityfs_text_asset(data: &[u8], patch: &TextAssetPatch) -> Result<V
|
||||
|
||||
let rebuilt = rebuild_unityfs(&bundle)?;
|
||||
let verified = parser.parse_bytes(&rebuilt)?;
|
||||
let asset = verified
|
||||
.text_assets
|
||||
verify_rebuild_preserves_unmodified_content(
|
||||
&bundle,
|
||||
&verified,
|
||||
&patch.serialized_file_path,
|
||||
patch.path_id,
|
||||
None,
|
||||
)?;
|
||||
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(|| {
|
||||
@@ -197,6 +214,13 @@ pub fn patch_unityfs_string_field(data: &[u8], patch: &StringFieldPatch) -> Resu
|
||||
|
||||
let rebuilt = rebuild_unityfs(&bundle)?;
|
||||
let verified = parser.parse_bytes(&rebuilt)?;
|
||||
verify_rebuild_preserves_unmodified_content(
|
||||
&bundle,
|
||||
&verified,
|
||||
&patch.serialized_file_path,
|
||||
patch.path_id,
|
||||
Some(&patch.field_path),
|
||||
)?;
|
||||
let serialized = verified
|
||||
.serialized_files
|
||||
.iter()
|
||||
@@ -259,6 +283,13 @@ pub fn patch_unityfs_field(data: &[u8], patch: &FieldPatch) -> Result<Vec<u8>> {
|
||||
|
||||
let rebuilt = rebuild_unityfs(&bundle)?;
|
||||
let verified = parser.parse_bytes(&rebuilt)?;
|
||||
verify_rebuild_preserves_unmodified_content(
|
||||
&bundle,
|
||||
&verified,
|
||||
&patch.serialized_file_path,
|
||||
patch.path_id,
|
||||
Some(&patch.field_path),
|
||||
)?;
|
||||
let serialized = verified
|
||||
.serialized_files
|
||||
.iter()
|
||||
@@ -285,7 +316,12 @@ pub fn patch_unityfs_field(data: &[u8], patch: &FieldPatch) -> Result<Vec<u8>> {
|
||||
Ok(rebuilt)
|
||||
}
|
||||
|
||||
fn rebuild_unityfs(bundle: &UnityFsBundle) -> Result<Vec<u8>> {
|
||||
/// Rebuilds a parsed UnityFS bundle while preserving its container shape.
|
||||
///
|
||||
/// The directory order, path, flags, header version and block compression
|
||||
/// modes are retained. Variable-length file changes are reflected in
|
||||
/// directory offsets and block sizes; no raw offset is patched blindly.
|
||||
pub fn rebuild_unityfs_bundle(bundle: &UnityFsBundle) -> Result<Vec<u8>> {
|
||||
if bundle.files.len() != bundle.directories.len() {
|
||||
return Err(AssetBundleError::Parse(format!(
|
||||
"UnityFS file/directory count mismatch: files={}, directories={}",
|
||||
@@ -293,24 +329,123 @@ fn rebuild_unityfs(bundle: &UnityFsBundle) -> Result<Vec<u8>> {
|
||||
bundle.directories.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let mut uncompressed_data = Vec::new();
|
||||
let mut directory_offsets = Vec::with_capacity(bundle.files.len());
|
||||
for file in &bundle.files {
|
||||
let offset = u64::try_from(uncompressed_data.len())
|
||||
.map_err(|_| AssetBundleError::Parse("UnityFS data offset overflow".to_string()))?;
|
||||
directory_offsets.push(offset);
|
||||
uncompressed_data.extend_from_slice(&file.data);
|
||||
let block_uncompressed_size = bundle
|
||||
.blocks
|
||||
.iter()
|
||||
.try_fold(0u64, |total, block| {
|
||||
total.checked_add(u64::from(block.uncompressed_size))
|
||||
})
|
||||
.ok_or_else(|| AssetBundleError::Parse("UnityFS block size overflow".to_string()))?;
|
||||
let retained_uncompressed_size = u64::try_from(bundle.uncompressed_data.len())
|
||||
.map_err(|_| AssetBundleError::Parse("UnityFS data size does not fit u64".to_string()))?;
|
||||
if block_uncompressed_size != retained_uncompressed_size
|
||||
|| bundle.uncompressed_data_size != retained_uncompressed_size
|
||||
{
|
||||
return Err(AssetBundleError::Parse(format!(
|
||||
"UnityFS block table/data size mismatch: blocks={}, retained={}, declared={}",
|
||||
block_uncompressed_size, retained_uncompressed_size, bundle.uncompressed_data_size
|
||||
)));
|
||||
}
|
||||
|
||||
let data_size = u32::try_from(uncompressed_data.len()).map_err(|_| {
|
||||
AssetBundleError::Parse("UnityFS rebuilt data exceeds u32 size".to_string())
|
||||
let mut ordered_directories = bundle.directories.iter().enumerate().collect::<Vec<_>>();
|
||||
ordered_directories.sort_by_key(|(_, directory)| directory.offset);
|
||||
let mut uncompressed_data = Vec::with_capacity(bundle.uncompressed_data.len());
|
||||
let mut directory_offsets = vec![0u64; bundle.files.len()];
|
||||
let mut cursor = 0usize;
|
||||
for (index, directory) in ordered_directories {
|
||||
let file = bundle.files.get(index).ok_or_else(|| {
|
||||
AssetBundleError::Parse(format!(
|
||||
"UnityFS directory {} has no corresponding file",
|
||||
directory.path
|
||||
))
|
||||
})?;
|
||||
if file.path != directory.path {
|
||||
return Err(AssetBundleError::Parse(format!(
|
||||
"UnityFS file/directory path mismatch: file={:?}, directory={:?}",
|
||||
file.path, directory.path
|
||||
)));
|
||||
}
|
||||
let start = usize::try_from(directory.offset).map_err(|_| {
|
||||
AssetBundleError::Parse(format!(
|
||||
"UnityFS directory {} offset does not fit usize",
|
||||
directory.path
|
||||
))
|
||||
})?;
|
||||
let end = start
|
||||
.checked_add(usize::try_from(directory.size).map_err(|_| {
|
||||
AssetBundleError::Parse(format!(
|
||||
"UnityFS directory {} size does not fit usize",
|
||||
directory.path
|
||||
))
|
||||
})?)
|
||||
.ok_or_else(|| {
|
||||
AssetBundleError::Parse(format!(
|
||||
"UnityFS directory {} range overflows usize",
|
||||
directory.path
|
||||
))
|
||||
})?;
|
||||
if start < cursor || end > bundle.uncompressed_data.len() {
|
||||
return Err(AssetBundleError::Parse(format!(
|
||||
"UnityFS directory {} overlaps or exceeds the original data region",
|
||||
directory.path
|
||||
)));
|
||||
}
|
||||
uncompressed_data.extend_from_slice(&bundle.uncompressed_data[cursor..start]);
|
||||
directory_offsets[index] = u64::try_from(uncompressed_data.len()).map_err(|_| {
|
||||
AssetBundleError::Parse("UnityFS directory offset overflow".to_string())
|
||||
})?;
|
||||
uncompressed_data.extend_from_slice(&file.data);
|
||||
cursor = end;
|
||||
}
|
||||
uncompressed_data.extend_from_slice(&bundle.uncompressed_data[cursor..]);
|
||||
|
||||
let block_sizes = repartition_blocks(uncompressed_data.len(), &bundle.blocks)?;
|
||||
let mut compressed_blocks = Vec::with_capacity(bundle.blocks.len());
|
||||
for (index, (block, size)) in bundle.blocks.iter().zip(&block_sizes).enumerate() {
|
||||
let start = block_sizes[..index].iter().sum::<usize>();
|
||||
let end = start.checked_add(*size).ok_or_else(|| {
|
||||
AssetBundleError::Parse(format!("UnityFS rebuilt block {index} range overflows"))
|
||||
})?;
|
||||
let bytes = uncompressed_data.get(start..end).ok_or_else(|| {
|
||||
AssetBundleError::Parse(format!(
|
||||
"UnityFS rebuilt block {index} range {}..{} exceeds data {}",
|
||||
start,
|
||||
end,
|
||||
uncompressed_data.len()
|
||||
))
|
||||
})?;
|
||||
compressed_blocks.push(compress_unityfs_bytes(
|
||||
bytes,
|
||||
block.compression,
|
||||
&format!("UnityFS data block {index}"),
|
||||
)?);
|
||||
}
|
||||
|
||||
let mut blocks_info_body = Vec::new();
|
||||
push_i32_be(&mut blocks_info_body, 1);
|
||||
push_u32_be(&mut blocks_info_body, data_size);
|
||||
push_u32_be(&mut blocks_info_body, data_size);
|
||||
push_u16_be(&mut blocks_info_body, 0);
|
||||
push_i32_be(
|
||||
&mut blocks_info_body,
|
||||
i32::try_from(bundle.blocks.len())
|
||||
.map_err(|_| AssetBundleError::Parse("UnityFS block count exceeds i32".to_string()))?,
|
||||
);
|
||||
for (block, (bytes, uncompressed_size)) in bundle
|
||||
.blocks
|
||||
.iter()
|
||||
.zip(compressed_blocks.iter().zip(block_sizes.iter()))
|
||||
{
|
||||
push_u32_be(
|
||||
&mut blocks_info_body,
|
||||
u32::try_from(*uncompressed_size).map_err(|_| {
|
||||
AssetBundleError::Parse("UnityFS rebuilt block exceeds u32 size".to_string())
|
||||
})?,
|
||||
);
|
||||
push_u32_be(
|
||||
&mut blocks_info_body,
|
||||
u32::try_from(bytes.len()).map_err(|_| {
|
||||
AssetBundleError::Parse("UnityFS compressed block exceeds u32 size".to_string())
|
||||
})?,
|
||||
);
|
||||
push_u16_be(&mut blocks_info_body, block.flags);
|
||||
}
|
||||
push_i32_be(
|
||||
&mut blocks_info_body,
|
||||
i32::try_from(bundle.files.len()).map_err(|_| {
|
||||
@@ -328,9 +463,14 @@ fn rebuild_unityfs(bundle: &UnityFsBundle) -> Result<Vec<u8>> {
|
||||
push_c_string(&mut blocks_info_body, &file.path);
|
||||
}
|
||||
let digest = Md5::digest(&blocks_info_body);
|
||||
let mut blocks_info = Vec::with_capacity(16 + blocks_info_body.len());
|
||||
blocks_info.extend_from_slice(&digest);
|
||||
blocks_info.extend_from_slice(&blocks_info_body);
|
||||
let mut blocks_info_uncompressed = Vec::with_capacity(16 + blocks_info_body.len());
|
||||
blocks_info_uncompressed.extend_from_slice(&digest);
|
||||
blocks_info_uncompressed.extend_from_slice(&blocks_info_body);
|
||||
let blocks_info = compress_unityfs_bytes(
|
||||
&blocks_info_uncompressed,
|
||||
compression_from_header_flags(bundle.header.flags),
|
||||
"UnityFS block info",
|
||||
)?;
|
||||
|
||||
let mut output = Vec::new();
|
||||
push_c_string(&mut output, "UnityFS");
|
||||
@@ -342,27 +482,395 @@ fn rebuild_unityfs(bundle: &UnityFsBundle) -> Result<Vec<u8>> {
|
||||
push_u32_be(
|
||||
&mut output,
|
||||
u32::try_from(blocks_info.len()).map_err(|_| {
|
||||
AssetBundleError::Parse("UnityFS block info exceeds u32 size".to_string())
|
||||
AssetBundleError::Parse("UnityFS compressed block info exceeds u32 size".to_string())
|
||||
})?,
|
||||
);
|
||||
push_u32_be(
|
||||
&mut output,
|
||||
u32::try_from(blocks_info.len()).map_err(|_| {
|
||||
u32::try_from(blocks_info_uncompressed.len()).map_err(|_| {
|
||||
AssetBundleError::Parse("UnityFS block info exceeds u32 size".to_string())
|
||||
})?,
|
||||
);
|
||||
push_u32_be(&mut output, 0);
|
||||
push_u32_be(&mut output, bundle.header.flags);
|
||||
if bundle.header.format_version >= 7 {
|
||||
align_vec(&mut output, 16);
|
||||
}
|
||||
if block_info_at_end(bundle.header.flags) {
|
||||
if block_data_aligned(bundle.header.flags) {
|
||||
align_vec(&mut output, 16);
|
||||
}
|
||||
for bytes in &compressed_blocks {
|
||||
output.extend_from_slice(bytes);
|
||||
}
|
||||
output.extend_from_slice(&blocks_info);
|
||||
output.extend_from_slice(&uncompressed_data);
|
||||
} else {
|
||||
output.extend_from_slice(&blocks_info);
|
||||
if block_data_aligned(bundle.header.flags) {
|
||||
align_vec(&mut output, 16);
|
||||
}
|
||||
for bytes in &compressed_blocks {
|
||||
output.extend_from_slice(bytes);
|
||||
}
|
||||
}
|
||||
let total_size = u64::try_from(output.len())
|
||||
.map_err(|_| AssetBundleError::Parse("UnityFS rebuilt size overflow".to_string()))?;
|
||||
output[total_size_offset..total_size_offset + 8].copy_from_slice(&total_size.to_be_bytes());
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn rebuild_unityfs(bundle: &UnityFsBundle) -> Result<Vec<u8>> {
|
||||
rebuild_unityfs_bundle(bundle)
|
||||
}
|
||||
|
||||
fn compression_from_header_flags(flags: u32) -> UnityFsCompression {
|
||||
crate::parser::compression_from_flags((flags & 0x3f) as u16)
|
||||
}
|
||||
|
||||
fn block_info_at_end(flags: u32) -> bool {
|
||||
flags & 0x80 != 0
|
||||
}
|
||||
|
||||
fn block_data_aligned(flags: u32) -> bool {
|
||||
flags & 0x200 != 0
|
||||
}
|
||||
|
||||
fn repartition_blocks(
|
||||
total_size: usize,
|
||||
blocks: &[crate::types::UnityFsBlockInfo],
|
||||
) -> Result<Vec<usize>> {
|
||||
if blocks.is_empty() {
|
||||
return Err(AssetBundleError::Parse(
|
||||
"UnityFS bundle has no blocks to rebuild".to_string(),
|
||||
));
|
||||
}
|
||||
let original_total = blocks
|
||||
.iter()
|
||||
.try_fold(0u64, |total, block| {
|
||||
total.checked_add(u64::from(block.uncompressed_size))
|
||||
})
|
||||
.ok_or_else(|| AssetBundleError::Parse("UnityFS block size overflow".to_string()))?;
|
||||
if original_total == 0 {
|
||||
return Ok(vec![0; blocks.len()]);
|
||||
}
|
||||
let total = u64::try_from(total_size)
|
||||
.map_err(|_| AssetBundleError::Parse("UnityFS data size does not fit u64".to_string()))?;
|
||||
let mut result = Vec::with_capacity(blocks.len());
|
||||
let mut previous = 0u64;
|
||||
let mut cumulative = 0u64;
|
||||
for block in blocks {
|
||||
cumulative = cumulative
|
||||
.checked_add(u64::from(block.uncompressed_size))
|
||||
.ok_or_else(|| {
|
||||
AssetBundleError::Parse("UnityFS block boundary overflow".to_string())
|
||||
})?;
|
||||
let boundary = total.checked_mul(cumulative).ok_or_else(|| {
|
||||
AssetBundleError::Parse("UnityFS block boundary overflow".to_string())
|
||||
})? / original_total;
|
||||
result.push(usize::try_from(boundary - previous).map_err(|_| {
|
||||
AssetBundleError::Parse("UnityFS rebuilt block size does not fit usize".to_string())
|
||||
})?);
|
||||
previous = boundary;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn compress_unityfs_bytes(
|
||||
data: &[u8],
|
||||
compression: UnityFsCompression,
|
||||
context: &str,
|
||||
) -> Result<Vec<u8>> {
|
||||
match compression {
|
||||
UnityFsCompression::None => Ok(data.to_vec()),
|
||||
UnityFsCompression::Lz4 => {
|
||||
lz4::block::compress(data, Some(lz4::block::CompressionMode::FAST(1)), false).map_err(
|
||||
|error| {
|
||||
AssetBundleError::Parse(format!("Failed to compress {context} as LZ4: {error}"))
|
||||
},
|
||||
)
|
||||
}
|
||||
UnityFsCompression::Lz4Hc => lz4::block::compress(
|
||||
data,
|
||||
Some(lz4::block::CompressionMode::HIGHCOMPRESSION(9)),
|
||||
false,
|
||||
)
|
||||
.map_err(|error| {
|
||||
AssetBundleError::Parse(format!("Failed to compress {context} as LZ4HC: {error}"))
|
||||
}),
|
||||
UnityFsCompression::Lzma => {
|
||||
let mut output = Vec::new();
|
||||
lzma_rs::lzma_compress(&mut std::io::Cursor::new(data), &mut output).map_err(
|
||||
|error| {
|
||||
AssetBundleError::Parse(format!(
|
||||
"Failed to compress {context} as LZMA: {error}"
|
||||
))
|
||||
},
|
||||
)?;
|
||||
Ok(output)
|
||||
}
|
||||
UnityFsCompression::Unknown(value) => Err(AssetBundleError::UnsupportedFormat(format!(
|
||||
"unsupported {context} compression flag: {value}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_rebuild_preserves_unmodified_content(
|
||||
original: &UnityFsBundle,
|
||||
rebuilt_bytes: &UnityFsBundle,
|
||||
modified_serialized_path: &str,
|
||||
modified_path_id: i64,
|
||||
modified_field_path: Option<&str>,
|
||||
) -> Result<()> {
|
||||
if original.header.format_version != rebuilt_bytes.header.format_version
|
||||
|| original.header.target_version != rebuilt_bytes.header.target_version
|
||||
|| original.header.unity_version != rebuilt_bytes.header.unity_version
|
||||
|| original.header.flags != rebuilt_bytes.header.flags
|
||||
{
|
||||
return Err(AssetBundleError::Parse(
|
||||
"UnityFS rebuild changed immutable header metadata".to_string(),
|
||||
));
|
||||
}
|
||||
if original.blocks.len() != rebuilt_bytes.blocks.len()
|
||||
|| original.directories.len() != rebuilt_bytes.directories.len()
|
||||
{
|
||||
return Err(AssetBundleError::Parse(
|
||||
"UnityFS rebuild changed block or directory count".to_string(),
|
||||
));
|
||||
}
|
||||
if original.serialized_files.len() != rebuilt_bytes.serialized_files.len() {
|
||||
return Err(AssetBundleError::Parse(
|
||||
"UnityFS rebuild changed serialized-file count".to_string(),
|
||||
));
|
||||
}
|
||||
if original.serialized_parse_errors != rebuilt_bytes.serialized_parse_errors {
|
||||
return Err(AssetBundleError::Parse(
|
||||
"UnityFS rebuild changed serialized-file diagnostics".to_string(),
|
||||
));
|
||||
}
|
||||
for (original, rebuilt) in original.blocks.iter().zip(&rebuilt_bytes.blocks) {
|
||||
if original.flags != rebuilt.flags || original.compression != rebuilt.compression {
|
||||
return Err(AssetBundleError::Parse(
|
||||
"UnityFS rebuild changed block compression metadata".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
for (original, rebuilt) in original.directories.iter().zip(&rebuilt_bytes.directories) {
|
||||
if original.path != rebuilt.path || original.flags != rebuilt.flags {
|
||||
return Err(AssetBundleError::Parse(
|
||||
"UnityFS rebuild changed directory path or flags".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
for (original_file, rebuilt_file) in original.files.iter().zip(&rebuilt_bytes.files) {
|
||||
if original_file.path != modified_serialized_path
|
||||
&& (original_file.path != rebuilt_file.path || original_file.data != rebuilt_file.data)
|
||||
{
|
||||
return Err(AssetBundleError::Parse(format!(
|
||||
"UnityFS rebuild changed an unmodified directory file: {}",
|
||||
original_file.path
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
for original_file in &original.serialized_files {
|
||||
let rebuilt_file = rebuilt_bytes
|
||||
.serialized_files
|
||||
.iter()
|
||||
.find(|candidate| candidate.source_path == original_file.source_path)
|
||||
.ok_or_else(|| {
|
||||
AssetBundleError::Parse(format!(
|
||||
"UnityFS rebuild lost serialized file {:?}",
|
||||
original_file.source_path
|
||||
))
|
||||
})?;
|
||||
if original_file.source_path != rebuilt_file.source_path
|
||||
|| original_file.version != rebuilt_file.version
|
||||
|| original_file.unity_version != rebuilt_file.unity_version
|
||||
|| original_file.platform != rebuilt_file.platform
|
||||
|| original_file.types != rebuilt_file.types
|
||||
|| original_file.objects.len() != rebuilt_file.objects.len()
|
||||
{
|
||||
return Err(AssetBundleError::Parse(
|
||||
"UnityFS rebuild changed serialized-file metadata".to_string(),
|
||||
));
|
||||
}
|
||||
for (original_object, rebuilt_object) in
|
||||
original_file.objects.iter().zip(&rebuilt_file.objects)
|
||||
{
|
||||
if original_object.path_id != rebuilt_object.path_id
|
||||
|| original_object.type_index != rebuilt_object.type_index
|
||||
|| original_object.class_id != rebuilt_object.class_id
|
||||
{
|
||||
return Err(AssetBundleError::Parse(format!(
|
||||
"UnityFS rebuild changed object table entry path_id {}",
|
||||
original_object.path_id
|
||||
)));
|
||||
}
|
||||
let is_modified_object = original_file.source_path.as_deref()
|
||||
== Some(modified_serialized_path)
|
||||
&& original_object.path_id == modified_path_id;
|
||||
if !is_modified_object && original_object.byte_size != rebuilt_object.byte_size {
|
||||
return Err(AssetBundleError::Parse(format!(
|
||||
"UnityFS rebuild changed unmodified object byte size path_id {}",
|
||||
original_object.path_id
|
||||
)));
|
||||
}
|
||||
if is_modified_object {
|
||||
if let Some(field_path) = modified_field_path {
|
||||
let original_fields = original_file.fields_for_object_entry(original_object)?;
|
||||
let rebuilt_fields = rebuilt_file.fields_for_object_entry(rebuilt_object)?;
|
||||
verify_unmodified_fields(
|
||||
&original_fields,
|
||||
&rebuilt_fields,
|
||||
field_path,
|
||||
original_object.path_id,
|
||||
)?;
|
||||
} else {
|
||||
let original_asset = original_file
|
||||
.text_assets()
|
||||
.iter()
|
||||
.find(|asset| asset.path_id == modified_path_id);
|
||||
let rebuilt_asset = rebuilt_file
|
||||
.text_assets()
|
||||
.iter()
|
||||
.find(|asset| asset.path_id == modified_path_id);
|
||||
if original_asset.map(|asset| &asset.name)
|
||||
!= rebuilt_asset.map(|asset| &asset.name)
|
||||
{
|
||||
return Err(AssetBundleError::Parse(format!(
|
||||
"TextAsset path_id {} name changed while rebuilding",
|
||||
modified_path_id
|
||||
)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if original_file.object_bytes(original_object)?
|
||||
!= rebuilt_file.object_bytes(rebuilt_object)?
|
||||
{
|
||||
return Err(AssetBundleError::Parse(format!(
|
||||
"UnityFS rebuild changed unmodified object path_id {}",
|
||||
original_object.path_id
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn verify_unmodified_fields(
|
||||
original: &[UnitySerializedField],
|
||||
rebuilt: &[UnitySerializedField],
|
||||
modified_field_path: &str,
|
||||
path_id: i64,
|
||||
) -> Result<()> {
|
||||
if original.len() != rebuilt.len() {
|
||||
return Err(AssetBundleError::Parse(format!(
|
||||
"Unity object path_id {path_id} changed field count while rebuilding"
|
||||
)));
|
||||
}
|
||||
for (original, rebuilt) in original.iter().zip(rebuilt) {
|
||||
if original.path != rebuilt.path
|
||||
|| original.name != rebuilt.name
|
||||
|| original.type_name != rebuilt.type_name
|
||||
|| original.type_tree_node_index != rebuilt.type_tree_node_index
|
||||
{
|
||||
return Err(AssetBundleError::Parse(format!(
|
||||
"Unity object path_id {path_id} changed field metadata at {}",
|
||||
original.path
|
||||
)));
|
||||
}
|
||||
if !field_path_contains(&original.path, modified_field_path)
|
||||
&& original.byte_size != rebuilt.byte_size
|
||||
{
|
||||
return Err(AssetBundleError::Parse(format!(
|
||||
"Unity object path_id {path_id} changed unmodified field byte size at {}",
|
||||
original.path
|
||||
)));
|
||||
}
|
||||
if original.path == modified_field_path {
|
||||
continue;
|
||||
}
|
||||
verify_unmodified_values(
|
||||
&original.value,
|
||||
&rebuilt.value,
|
||||
modified_field_path,
|
||||
path_id,
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn field_path_contains(parent: &str, child: &str) -> bool {
|
||||
child == parent
|
||||
|| child
|
||||
.strip_prefix(parent)
|
||||
.is_some_and(|suffix| suffix.starts_with('.') || suffix.starts_with('['))
|
||||
}
|
||||
|
||||
fn verify_unmodified_values(
|
||||
original: &UnitySerializedValue,
|
||||
rebuilt: &UnitySerializedValue,
|
||||
modified_field_path: &str,
|
||||
path_id: i64,
|
||||
) -> Result<()> {
|
||||
match (original, rebuilt) {
|
||||
(UnitySerializedValue::Object(original), UnitySerializedValue::Object(rebuilt))
|
||||
| (UnitySerializedValue::Array(original), UnitySerializedValue::Array(rebuilt))
|
||||
| (UnitySerializedValue::Map(original), UnitySerializedValue::Map(rebuilt)) => {
|
||||
verify_unmodified_fields(original, rebuilt, modified_field_path, path_id)
|
||||
}
|
||||
(
|
||||
UnitySerializedValue::ManagedReference {
|
||||
type_name: original_type,
|
||||
metadata: original_metadata,
|
||||
fields: original_fields,
|
||||
bytes: original_bytes,
|
||||
},
|
||||
UnitySerializedValue::ManagedReference {
|
||||
type_name: rebuilt_type,
|
||||
metadata: rebuilt_metadata,
|
||||
fields: rebuilt_fields,
|
||||
bytes: rebuilt_bytes,
|
||||
},
|
||||
) => {
|
||||
if original_type != rebuilt_type
|
||||
|| original_metadata != rebuilt_metadata
|
||||
|| original_bytes != rebuilt_bytes
|
||||
{
|
||||
return Err(AssetBundleError::Parse(format!(
|
||||
"Unity object path_id {path_id} changed managed-reference metadata"
|
||||
)));
|
||||
}
|
||||
verify_unmodified_fields(
|
||||
original_fields,
|
||||
rebuilt_fields,
|
||||
modified_field_path,
|
||||
path_id,
|
||||
)
|
||||
}
|
||||
(
|
||||
UnitySerializedValue::ManagedReferenceRegistry {
|
||||
references: _,
|
||||
fields: original_fields,
|
||||
},
|
||||
UnitySerializedValue::ManagedReferenceRegistry {
|
||||
references: _,
|
||||
fields: rebuilt_fields,
|
||||
},
|
||||
) => verify_unmodified_fields(
|
||||
original_fields,
|
||||
rebuilt_fields,
|
||||
modified_field_path,
|
||||
path_id,
|
||||
),
|
||||
(original, rebuilt) if original != rebuilt => Err(AssetBundleError::Parse(format!(
|
||||
"Unity object path_id {path_id} changed an unmodified field value"
|
||||
))),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn find_field_value<'a>(
|
||||
fields: &'a [UnitySerializedField],
|
||||
field_path: &str,
|
||||
@@ -489,6 +997,99 @@ mod tests {
|
||||
data
|
||||
}
|
||||
|
||||
fn synthetic_bundle_with_compressed_blocks(
|
||||
files: &[(&str, &[u8])],
|
||||
block_flags: u16,
|
||||
header_flags: u32,
|
||||
) -> Vec<u8> {
|
||||
let mut uncompressed = Vec::new();
|
||||
let mut directory_offsets = Vec::with_capacity(files.len());
|
||||
for (_, bytes) in files {
|
||||
directory_offsets.push(uncompressed.len() as u64);
|
||||
uncompressed.extend_from_slice(bytes);
|
||||
}
|
||||
let split = uncompressed.len() / 2;
|
||||
let split = if uncompressed.is_empty() {
|
||||
0
|
||||
} else {
|
||||
split.max(1).min(uncompressed.len())
|
||||
};
|
||||
let chunks = [&uncompressed[..split], &uncompressed[split..]];
|
||||
let compressed_chunks = chunks
|
||||
.iter()
|
||||
.map(|chunk| match block_flags & 0x3f {
|
||||
0 => chunk.to_vec(),
|
||||
1 => {
|
||||
let mut output = Vec::new();
|
||||
lzma_rs::lzma_compress(&mut std::io::Cursor::new(chunk), &mut output).unwrap();
|
||||
output
|
||||
}
|
||||
2..=4 => lz4::block::compress(chunk, None, false).unwrap(),
|
||||
value => panic!("unsupported fixture compression {value}"),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut block_info_body = Vec::new();
|
||||
push_i32_be(&mut block_info_body, chunks.len() as i32);
|
||||
for (chunk, compressed) in chunks.iter().zip(&compressed_chunks) {
|
||||
push_u32_be(&mut block_info_body, chunk.len() as u32);
|
||||
push_u32_be(&mut block_info_body, compressed.len() as u32);
|
||||
push_u16_be(&mut block_info_body, block_flags);
|
||||
}
|
||||
push_i32_be(&mut block_info_body, files.len() as i32);
|
||||
for ((path, bytes), offset) in files.iter().zip(directory_offsets) {
|
||||
push_u64_be(&mut block_info_body, offset);
|
||||
push_u64_be(&mut block_info_body, bytes.len() as u64);
|
||||
push_u32_be(&mut block_info_body, 0);
|
||||
push_c_string(&mut block_info_body, path);
|
||||
}
|
||||
let mut block_info = vec![0; 16];
|
||||
block_info.extend_from_slice(&block_info_body);
|
||||
let block_info = match header_flags & 0x3f {
|
||||
0 => block_info,
|
||||
1 => {
|
||||
let mut output = Vec::new();
|
||||
lzma_rs::lzma_compress(&mut std::io::Cursor::new(&block_info), &mut output)
|
||||
.unwrap();
|
||||
output
|
||||
}
|
||||
2..=4 => lz4::block::compress(&block_info, None, false).unwrap(),
|
||||
value => panic!("unsupported fixture block-info compression {value}"),
|
||||
};
|
||||
|
||||
let mut data = Vec::new();
|
||||
push_c_string(&mut data, "UnityFS");
|
||||
push_u32_be(&mut data, 8);
|
||||
push_c_string(&mut data, "5.x.x");
|
||||
push_c_string(&mut data, "2021.3.56f2");
|
||||
let total_size_offset = data.len();
|
||||
push_u64_be(&mut data, 0);
|
||||
push_u32_be(&mut data, block_info.len() as u32);
|
||||
push_u32_be(&mut data, (16 + block_info_body.len()) as u32);
|
||||
push_u32_be(&mut data, header_flags);
|
||||
align_vec(&mut data, 16);
|
||||
if header_flags & 0x80 != 0 {
|
||||
if header_flags & 0x200 != 0 {
|
||||
align_vec(&mut data, 16);
|
||||
}
|
||||
for compressed in &compressed_chunks {
|
||||
data.extend_from_slice(compressed);
|
||||
}
|
||||
data.extend_from_slice(&block_info);
|
||||
} else {
|
||||
data.extend_from_slice(&block_info);
|
||||
if header_flags & 0x200 != 0 {
|
||||
align_vec(&mut data, 16);
|
||||
}
|
||||
for compressed in &compressed_chunks {
|
||||
data.extend_from_slice(compressed);
|
||||
}
|
||||
}
|
||||
let total_size = data.len() as u64;
|
||||
data[total_size_offset..total_size_offset + 8].copy_from_slice(&total_size.to_be_bytes());
|
||||
data
|
||||
}
|
||||
|
||||
fn push_i16_le(data: &mut Vec<u8>, value: i16) {
|
||||
data.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
@@ -549,6 +1150,66 @@ mod tests {
|
||||
file
|
||||
}
|
||||
|
||||
fn synthetic_serialized_two_text_assets(first: &[u8], second: &[u8]) -> Vec<u8> {
|
||||
fn text_asset_object(bytes: &[u8]) -> Vec<u8> {
|
||||
let mut object = Vec::new();
|
||||
push_u32_le(&mut object, 8);
|
||||
object.extend_from_slice(b"Scenario");
|
||||
align_vec(&mut object, 4);
|
||||
push_u32_le(&mut object, bytes.len() as u32);
|
||||
object.extend_from_slice(bytes);
|
||||
object
|
||||
}
|
||||
|
||||
let first_object = text_asset_object(first);
|
||||
let second_object = text_asset_object(second);
|
||||
let mut object_data = Vec::new();
|
||||
let first_offset = object_data.len() as u64;
|
||||
object_data.extend_from_slice(&first_object);
|
||||
let first_size = object_data.len() as u64 - first_offset;
|
||||
let second_offset = object_data.len() as u64;
|
||||
object_data.extend_from_slice(&second_object);
|
||||
let second_size = object_data.len() as u64 - second_offset;
|
||||
|
||||
let mut metadata = Vec::new();
|
||||
metadata.extend_from_slice(b"2021.3.56f2\0");
|
||||
push_i32_le(&mut metadata, 19);
|
||||
metadata.push(0);
|
||||
push_i32_le(&mut metadata, 1);
|
||||
push_i32_le(&mut metadata, 49);
|
||||
metadata.push(0);
|
||||
push_i16_le(&mut metadata, 0);
|
||||
metadata.extend_from_slice(&[0; 16]);
|
||||
push_i32_le(&mut metadata, 2);
|
||||
align_vec(&mut metadata, 4);
|
||||
for (path_id, offset, size) in [
|
||||
(1i64, first_offset, first_size),
|
||||
(2, second_offset, second_size),
|
||||
] {
|
||||
metadata.extend_from_slice(&path_id.to_le_bytes());
|
||||
push_u64_le(&mut metadata, offset);
|
||||
push_u32_le(&mut metadata, size as u32);
|
||||
push_i32_le(&mut metadata, 0);
|
||||
}
|
||||
|
||||
let header_len = 48usize;
|
||||
let data_offset = header_len + metadata.len();
|
||||
let file_size = data_offset + object_data.len();
|
||||
let mut file = Vec::new();
|
||||
push_u32_be(&mut file, metadata.len() as u32);
|
||||
push_u32_be(&mut file, file_size as u32);
|
||||
push_u32_be(&mut file, 22);
|
||||
push_u32_be(&mut file, 0);
|
||||
file.extend_from_slice(&[0, 0, 0, 0]);
|
||||
push_u32_be(&mut file, metadata.len() as u32);
|
||||
push_u64_be(&mut file, file_size as u64);
|
||||
push_u64_be(&mut file, data_offset as u64);
|
||||
push_u64_be(&mut file, 0);
|
||||
file.extend_from_slice(&metadata);
|
||||
file.extend_from_slice(&object_data);
|
||||
file
|
||||
}
|
||||
|
||||
fn synthetic_serialized_monobehaviour() -> Vec<u8> {
|
||||
let mut object_data = Vec::new();
|
||||
push_u32_le(&mut object_data, 5);
|
||||
@@ -1391,6 +2052,179 @@ mod tests {
|
||||
assert_eq!(parsed.unity_version, reparsed.unity_version);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_preserves_compressed_blocks_alignment_and_untouched_files() {
|
||||
let serialized = synthetic_serialized_text_asset(b"old");
|
||||
let source = synthetic_bundle_with_compressed_blocks(
|
||||
&[("CAB-scenario", &serialized), ("CAB-untouched", b"keep")],
|
||||
2,
|
||||
2 | 0x200,
|
||||
);
|
||||
let parsed = UnityFsParser::new().parse_bytes(&source).unwrap();
|
||||
assert_eq!(parsed.blocks.len(), 2);
|
||||
assert_eq!(parsed.blocks[0].compression, UnityFsCompression::Lz4);
|
||||
assert_eq!(parsed.header.flags, 2 | 0x200);
|
||||
|
||||
let patched = patch_unityfs_text_asset(
|
||||
&source,
|
||||
&TextAssetPatch {
|
||||
serialized_file_path: "CAB-scenario".to_string(),
|
||||
path_id: 1,
|
||||
expected_name: Some("Scenario".to_string()),
|
||||
replacement: b"a longer localized payload".to_vec(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let reparsed = UnityFsParser::new().parse_bytes(&patched).unwrap();
|
||||
|
||||
assert_eq!(reparsed.blocks.len(), 2);
|
||||
assert_eq!(reparsed.blocks[0].compression, UnityFsCompression::Lz4);
|
||||
assert_eq!(reparsed.blocks[1].compression, UnityFsCompression::Lz4);
|
||||
assert_eq!(reparsed.header.flags, parsed.header.flags);
|
||||
assert_eq!(
|
||||
reparsed
|
||||
.files
|
||||
.iter()
|
||||
.find(|file| file.path == "CAB-untouched")
|
||||
.unwrap()
|
||||
.data,
|
||||
b"keep"
|
||||
);
|
||||
assert_eq!(reparsed.text_assets[0].bytes, b"a longer localized payload");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_preserves_unmodified_serialized_objects() {
|
||||
let serialized = synthetic_serialized_two_text_assets(b"old", b"keep-object");
|
||||
let source = synthetic_bundle_with_path(&serialized, "CAB-scenario");
|
||||
let patched = patch_unityfs_text_asset(
|
||||
&source,
|
||||
&TextAssetPatch {
|
||||
serialized_file_path: "CAB-scenario".to_string(),
|
||||
path_id: 1,
|
||||
expected_name: Some("Scenario".to_string()),
|
||||
replacement: b"localized".to_vec(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let reparsed = UnityFsParser::new().parse_bytes(&patched).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
reparsed
|
||||
.text_assets
|
||||
.iter()
|
||||
.find(|asset| asset.path_id == 1)
|
||||
.unwrap()
|
||||
.bytes,
|
||||
b"localized"
|
||||
);
|
||||
assert_eq!(
|
||||
reparsed
|
||||
.text_assets
|
||||
.iter()
|
||||
.find(|asset| asset.path_id == 2)
|
||||
.unwrap()
|
||||
.bytes,
|
||||
b"keep-object"
|
||||
);
|
||||
}
|
||||
|
||||
#[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");
|
||||
let source = synthetic_bundle_with_compressed_blocks(
|
||||
&[("CAB-scenario", &serialized)],
|
||||
1,
|
||||
1 | 0x80 | 0x200,
|
||||
);
|
||||
let patched = patch_unityfs_text_asset(
|
||||
&source,
|
||||
&TextAssetPatch {
|
||||
serialized_file_path: "CAB-scenario".to_string(),
|
||||
path_id: 1,
|
||||
expected_name: None,
|
||||
replacement: b"localized".to_vec(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let reparsed = UnityFsParser::new().parse_bytes(&patched).unwrap();
|
||||
|
||||
assert_eq!(reparsed.header.flags, 1 | 0x80 | 0x200);
|
||||
assert_eq!(reparsed.blocks[0].compression, UnityFsCompression::Lzma);
|
||||
assert_eq!(reparsed.blocks[1].compression, UnityFsCompression::Lzma);
|
||||
assert_eq!(reparsed.text_assets[0].bytes, b"localized");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_rejects_unknown_compression_without_guessing() {
|
||||
let source = synthetic_bundle(b"payload");
|
||||
let mut parsed = UnityFsParser::new().parse_bytes(&source).unwrap();
|
||||
parsed.blocks[0].compression = UnityFsCompression::Unknown(63);
|
||||
|
||||
let error = rebuild_unityfs_bundle(&parsed).unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
AssetBundleError::UnsupportedFormat(message)
|
||||
if message.contains("unsupported UnityFS data block 0 compression flag: 63")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_rejects_inconsistent_uncompressed_block_table() {
|
||||
let source = synthetic_bundle(b"payload");
|
||||
let mut parsed = UnityFsParser::new().parse_bytes(&source).unwrap();
|
||||
parsed.blocks[0].uncompressed_size += 1;
|
||||
|
||||
let error = rebuild_unityfs_bundle(&parsed).unwrap_err().to_string();
|
||||
|
||||
assert!(error.contains("block table/data size mismatch"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patches_text_asset_and_verifies_reparsed_payload() {
|
||||
let original_text = "こんにちは".as_bytes();
|
||||
|
||||
@@ -1088,6 +1088,34 @@ impl UnitySerializedFile {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the raw payload bytes for one object table entry.
|
||||
///
|
||||
/// The returned slice is still owned by the parsed serialized file. It is
|
||||
/// useful to compare untouched objects across a variable-length rebuild.
|
||||
pub fn object_bytes(&self, object: &UnitySerializedObject) -> Result<&[u8]> {
|
||||
let object_start = self
|
||||
.data_offset
|
||||
.checked_add(usize::try_from(object.byte_start).map_err(|_| {
|
||||
AssetBundleError::Parse(format!(
|
||||
"Unity object path_id {} byte_start does not fit usize",
|
||||
object.path_id
|
||||
))
|
||||
})?)
|
||||
.ok_or_else(|| AssetBundleError::Parse("Unity object offset overflow".to_string()))?;
|
||||
let object_end = object_start
|
||||
.checked_add(object.byte_size as usize)
|
||||
.ok_or_else(|| AssetBundleError::Parse("Unity object size overflow".to_string()))?;
|
||||
self.raw_data.get(object_start..object_end).ok_or_else(|| {
|
||||
AssetBundleError::Parse(format!(
|
||||
"Unity object path_id {} byte range {}..{} exceeds file size {}",
|
||||
object.path_id,
|
||||
object_start,
|
||||
object_end,
|
||||
self.raw_data.len()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn rewrite_object_payload(
|
||||
&self,
|
||||
target_index: usize,
|
||||
|
||||
@@ -62,6 +62,9 @@ pub struct UnityFsBundle {
|
||||
pub uncompressed_data_size: u64,
|
||||
/// Original bytes retained for future extraction/serialization.
|
||||
pub raw_data: Vec<u8>,
|
||||
/// Uncompressed data region retained so rebuilds can preserve gaps and
|
||||
/// trailing bytes that are not represented by directory entries.
|
||||
pub(crate) uncompressed_data: Vec<u8>,
|
||||
/// Files extracted from the UnityFS uncompressed data region.
|
||||
pub files: Vec<UnityFsFile>,
|
||||
/// Serialized files parsed from UnityFS directory files.
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
use bat_assetbundle::UnityFsParser;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires BAT_REAL_UNITYFS_BUNDLE pointing at an isolated real UnityFS bundle"]
|
||||
fn parses_isolated_real_unityfs_bundle() {
|
||||
let path = PathBuf::from(
|
||||
std::env::var("BAT_REAL_UNITYFS_BUNDLE").expect("BAT_REAL_UNITYFS_BUNDLE must be set"),
|
||||
);
|
||||
let data = std::fs::read(&path).expect("read isolated real UnityFS bundle");
|
||||
let parsed = UnityFsParser::new()
|
||||
.parse_bytes(&data)
|
||||
.expect("parse isolated real UnityFS bundle");
|
||||
|
||||
assert_eq!(parsed.header.total_size, data.len() as u64);
|
||||
assert!(!parsed.header.unity_version.is_empty());
|
||||
assert!(!parsed.blocks.is_empty());
|
||||
assert!(!parsed.directories.is_empty());
|
||||
assert_eq!(parsed.files.len(), parsed.directories.len());
|
||||
assert_eq!(
|
||||
parsed.uncompressed_data_size,
|
||||
parsed.files.iter().map(|file| file.size).sum::<u64>()
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tracing.workspace = true
|
||||
async-trait.workspace = true
|
||||
libc = "0.2"
|
||||
|
||||
# 文件系统操作
|
||||
tokio = { workspace = true, features = ["fs", "io-util"] }
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
use crate::error::{CasError, Result};
|
||||
use crate::hash::Hash;
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions, SqliteQueryResult};
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteQueryResult};
|
||||
use sqlx::SqlitePool;
|
||||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// CAS 对象元数据。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -41,7 +41,9 @@ impl SqliteRefCounter {
|
||||
let options =
|
||||
SqliteConnectOptions::from_str(&format!("sqlite://{}", path.as_ref().display()))
|
||||
.map_err(|error| CasError::Database(error.to_string()))?
|
||||
.create_if_missing(true);
|
||||
.create_if_missing(true)
|
||||
.journal_mode(SqliteJournalMode::Wal)
|
||||
.busy_timeout(Duration::from_secs(30));
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
@@ -82,6 +84,22 @@ impl SqliteRefCounter {
|
||||
)
|
||||
.await?;
|
||||
|
||||
let _release_reference_table = Self::execute_query(
|
||||
&self.pool,
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS cas_release_references (
|
||||
release_id TEXT NOT NULL,
|
||||
ordinal INTEGER NOT NULL CHECK(ordinal >= 0),
|
||||
object_id TEXT NOT NULL,
|
||||
released INTEGER NOT NULL CHECK(released IN (0, 1)),
|
||||
PRIMARY KEY(release_id, ordinal)
|
||||
)
|
||||
"#,
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -281,6 +299,107 @@ impl SqliteRefCounter {
|
||||
.await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Atomically releases one durable release ownership record.
|
||||
///
|
||||
/// The ownership row and the reference decrement are committed in the
|
||||
/// same SQLite transaction. Retrying the same `(ownership_id, ordinal)` is
|
||||
/// therefore idempotent, while a different ownership keeps its own row and
|
||||
/// reference count. The legacy SQL column name is retained for schema
|
||||
/// compatibility.
|
||||
pub async fn release_reference_once(
|
||||
&self,
|
||||
ownership_id: &str,
|
||||
ordinal: u64,
|
||||
hash: &Hash,
|
||||
) -> Result<bool> {
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
let existing: Option<(String, i64)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT object_id, released
|
||||
FROM cas_release_references
|
||||
WHERE release_id = ?1 AND ordinal = ?2
|
||||
"#,
|
||||
)
|
||||
.bind(ownership_id)
|
||||
.bind(ordinal as i64)
|
||||
.fetch_optional(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
if let Some((object_id, released)) = existing {
|
||||
if object_id != hash.to_string() {
|
||||
return Err(CasError::Other(anyhow::anyhow!(
|
||||
"CAS release ownership mismatch: ownership_id={} ordinal={} expected={} actual={}",
|
||||
ownership_id,
|
||||
ordinal,
|
||||
object_id,
|
||||
hash
|
||||
)));
|
||||
}
|
||||
if released != 0 {
|
||||
transaction.commit().await?;
|
||||
return Ok(false);
|
||||
}
|
||||
return Err(CasError::Other(anyhow::anyhow!(
|
||||
"CAS release ownership record is not in a retryable state: ownership_id={} ordinal={}",
|
||||
ownership_id,
|
||||
ordinal
|
||||
)));
|
||||
}
|
||||
|
||||
let now = Self::now();
|
||||
let updated: Option<i64> = sqlx::query_scalar(
|
||||
r#"
|
||||
UPDATE cas_objects
|
||||
SET ref_count = ref_count - 1,
|
||||
updated_at = ?1,
|
||||
zero_ref_at = CASE WHEN ref_count = 1 THEN ?1 ELSE zero_ref_at END
|
||||
WHERE hash = ?2 AND ref_count > 0
|
||||
RETURNING ref_count
|
||||
"#,
|
||||
)
|
||||
.bind(now)
|
||||
.bind(hash.to_string())
|
||||
.fetch_optional(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
if updated.is_none() {
|
||||
let exists: Option<i64> =
|
||||
sqlx::query_scalar("SELECT ref_count FROM cas_objects WHERE hash = ?1")
|
||||
.bind(hash.to_string())
|
||||
.fetch_optional(&mut *transaction)
|
||||
.await?;
|
||||
if exists.is_some() {
|
||||
return Err(CasError::ReferenceUnderflow(hash.to_string()));
|
||||
}
|
||||
return Err(CasError::ObjectNotFound(hash.to_string()));
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO cas_release_references(release_id, ordinal, object_id, released)
|
||||
VALUES(?1, ?2, ?3, 1)
|
||||
"#,
|
||||
)
|
||||
.bind(ownership_id)
|
||||
.bind(ordinal as i64)
|
||||
.bind(hash.to_string())
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Returns whether the durable ledger contains any row for an ownership.
|
||||
pub async fn has_release_ownership(&self, ownership_id: &str) -> Result<bool> {
|
||||
let exists: i64 = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM cas_release_references WHERE release_id = ?1)",
|
||||
)
|
||||
.bind(ownership_id)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(exists != 0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -4,7 +4,25 @@ use crate::error::{CasError, Result};
|
||||
use crate::hash::{compute_hash, Hash};
|
||||
use crate::refcount::SqliteRefCounter;
|
||||
use crate::storage::{FileSystemStorage, Storage, StorageStats};
|
||||
use std::fs::OpenOptions;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
struct CasOperationLock {
|
||||
file: std::fs::File,
|
||||
}
|
||||
|
||||
impl Drop for CasOperationLock {
|
||||
fn drop(&mut self) {
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::flock(
|
||||
std::os::unix::io::AsRawFd::as_raw_fd(&self.file),
|
||||
libc::LOCK_UN,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 文件系统 CAS repository。
|
||||
///
|
||||
@@ -34,8 +52,16 @@ impl FileSystemCasRepository {
|
||||
&self.storage
|
||||
}
|
||||
|
||||
async fn acquire_operation_lock(&self) -> Result<CasOperationLock> {
|
||||
let path = self.storage.root().join(".cas-operation.lock");
|
||||
tokio::task::spawn_blocking(move || acquire_operation_lock_sync(path))
|
||||
.await
|
||||
.map_err(|error| CasError::Other(anyhow::anyhow!("CAS lock task failed: {error}")))?
|
||||
}
|
||||
|
||||
/// 存储对象并增加引用计数。
|
||||
pub async fn store(&self, data: &[u8]) -> Result<Hash> {
|
||||
let _lock = self.acquire_operation_lock().await?;
|
||||
let hash = compute_hash(data);
|
||||
let existed = self.storage.exists(&hash).await?;
|
||||
let stored_hash = self.storage.put(data).await?;
|
||||
@@ -70,17 +96,20 @@ impl FileSystemCasRepository {
|
||||
|
||||
/// 读取对象并验证 Hash。
|
||||
pub async fn get(&self, hash: &Hash) -> Result<Vec<u8>> {
|
||||
let _lock = self.acquire_operation_lock().await?;
|
||||
let data = self.storage.get(hash).await?;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// 检查对象是否存在。
|
||||
pub async fn exists(&self, hash: &Hash) -> Result<bool> {
|
||||
let _lock = self.acquire_operation_lock().await?;
|
||||
self.storage.exists(hash).await
|
||||
}
|
||||
|
||||
/// 增加引用计数。
|
||||
pub async fn add_reference(&self, hash: &Hash) -> Result<u64> {
|
||||
let _lock = self.acquire_operation_lock().await?;
|
||||
if !self.storage.exists(hash).await? {
|
||||
return Err(CasError::ObjectNotFound(hash.to_string()));
|
||||
}
|
||||
@@ -94,22 +123,26 @@ impl FileSystemCasRepository {
|
||||
|
||||
/// 减少引用计数。
|
||||
pub async fn remove_reference(&self, hash: &Hash) -> Result<u64> {
|
||||
let _lock = self.acquire_operation_lock().await?;
|
||||
self.ref_counter.remove_reference(hash).await
|
||||
}
|
||||
|
||||
/// 获取引用计数。
|
||||
pub async fn get_reference_count(&self, hash: &Hash) -> Result<u64> {
|
||||
let _lock = self.acquire_operation_lock().await?;
|
||||
self.ref_counter.get_reference_count(hash).await
|
||||
}
|
||||
|
||||
/// 返回当前 GC 候选对象。
|
||||
pub async fn gc_candidates(&self) -> Result<Vec<Hash>> {
|
||||
self.ref_counter.zero_ref_objects().await
|
||||
let _lock = self.acquire_operation_lock().await?;
|
||||
self.gc_candidates_unlocked().await
|
||||
}
|
||||
|
||||
/// 删除引用计数为 0 的对象。
|
||||
pub async fn gc(&self) -> Result<u64> {
|
||||
let candidates = self.gc_candidates().await?;
|
||||
let _lock = self.acquire_operation_lock().await?;
|
||||
let candidates = self.gc_candidates_unlocked().await?;
|
||||
let mut deleted = 0u64;
|
||||
|
||||
for hash in candidates {
|
||||
@@ -130,10 +163,52 @@ impl FileSystemCasRepository {
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
/// Releases one release-owned reference exactly once.
|
||||
pub async fn release_reference_once(
|
||||
&self,
|
||||
ownership_id: &str,
|
||||
ordinal: u64,
|
||||
hash: &Hash,
|
||||
) -> Result<bool> {
|
||||
let _lock = self.acquire_operation_lock().await?;
|
||||
self.ref_counter
|
||||
.release_reference_once(ownership_id, ordinal, hash)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Returns whether the durable release ownership ledger has any row.
|
||||
pub async fn has_release_ownership(&self, ownership_id: &str) -> Result<bool> {
|
||||
let _lock = self.acquire_operation_lock().await?;
|
||||
self.ref_counter.has_release_ownership(ownership_id).await
|
||||
}
|
||||
|
||||
/// 获取存储统计信息。
|
||||
pub async fn stats(&self) -> Result<StorageStats> {
|
||||
let _lock = self.acquire_operation_lock().await?;
|
||||
self.storage.stats().await
|
||||
}
|
||||
|
||||
async fn gc_candidates_unlocked(&self) -> Result<Vec<Hash>> {
|
||||
self.ref_counter.zero_ref_objects().await
|
||||
}
|
||||
}
|
||||
|
||||
fn acquire_operation_lock_sync(path: PathBuf) -> Result<CasOperationLock> {
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(path)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let result =
|
||||
unsafe { libc::flock(std::os::unix::io::AsRawFd::as_raw_fd(&file), libc::LOCK_EX) };
|
||||
if result != 0 {
|
||||
return Err(CasError::Io(std::io::Error::last_os_error()));
|
||||
}
|
||||
}
|
||||
Ok(CasOperationLock { file })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -228,6 +303,53 @@ mod tests {
|
||||
assert_eq!(repo.get_reference_count(&hash).await.unwrap(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cross_repository_gc_and_store_preserve_object_lifetime() {
|
||||
let (temp_dir, repo) = temp_repo().await;
|
||||
let hash = repo.store(b"cross-process lifetime").await.unwrap();
|
||||
assert_eq!(repo.remove_reference(&hash).await.unwrap(), 0);
|
||||
|
||||
let other = FileSystemCasRepository::new(temp_dir.path()).await.unwrap();
|
||||
let (gc_result, store_result) =
|
||||
tokio::join!(repo.gc(), other.store(b"cross-process lifetime"));
|
||||
|
||||
gc_result.unwrap();
|
||||
assert_eq!(store_result.unwrap(), hash);
|
||||
assert_eq!(other.get_reference_count(&hash).await.unwrap(), 1);
|
||||
assert_eq!(other.get(&hash).await.unwrap(), b"cross-process lifetime");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn release_reference_is_idempotent_after_retry() {
|
||||
let (_temp_dir, repo) = temp_repo().await;
|
||||
let hash = repo.store(b"owned").await.unwrap();
|
||||
assert!(repo
|
||||
.release_reference_once("release-a", 0, &hash)
|
||||
.await
|
||||
.unwrap());
|
||||
assert!(!repo
|
||||
.release_reference_once("release-a", 0, &hash)
|
||||
.await
|
||||
.unwrap());
|
||||
assert_eq!(repo.get_reference_count(&hash).await.unwrap(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn release_reference_ownership_is_scoped_per_release() {
|
||||
let (_temp_dir, repo) = temp_repo().await;
|
||||
let hash = repo.store(b"shared ownership").await.unwrap();
|
||||
assert_eq!(repo.add_reference(&hash).await.unwrap(), 2);
|
||||
assert!(repo
|
||||
.release_reference_once("release-a", 0, &hash)
|
||||
.await
|
||||
.unwrap());
|
||||
assert!(repo
|
||||
.release_reference_once("release-b", 0, &hash)
|
||||
.await
|
||||
.unwrap());
|
||||
assert_eq!(repo.get_reference_count(&hash).await.unwrap(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn corrupted_object_is_detected_through_repository() {
|
||||
let (_temp_dir, repo) = temp_repo().await;
|
||||
|
||||
@@ -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,403 @@ 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 unity_operations = file
|
||||
.operations
|
||||
.iter()
|
||||
.filter_map(unity_operation_target)
|
||||
.collect::<Vec<_>>();
|
||||
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 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
|
||||
)));
|
||||
}
|
||||
}
|
||||
for (index, left) in unity_operations.iter().enumerate() {
|
||||
for right in unity_operations.iter().skip(index + 1) {
|
||||
if unity_operation_targets_conflict(left, right) {
|
||||
return Err(PatchError::ApplyFailed(format!(
|
||||
"patch manifest contains overlapping UnityFS targets in {}: {} and {}",
|
||||
file.path.display(),
|
||||
left.describe(),
|
||||
right.describe()
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum UnityOperationTarget<'a> {
|
||||
WholeObject {
|
||||
archive_entry: Option<&'a str>,
|
||||
serialized_file_path: &'a str,
|
||||
path_id: i64,
|
||||
},
|
||||
Field {
|
||||
archive_entry: Option<&'a str>,
|
||||
serialized_file_path: &'a str,
|
||||
path_id: i64,
|
||||
field_path: &'a str,
|
||||
},
|
||||
}
|
||||
|
||||
impl UnityOperationTarget<'_> {
|
||||
fn archive_entry(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::WholeObject { archive_entry, .. } | Self::Field { archive_entry, .. } => {
|
||||
*archive_entry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn serialized_file_path(&self) -> &str {
|
||||
match self {
|
||||
Self::WholeObject {
|
||||
serialized_file_path,
|
||||
..
|
||||
}
|
||||
| Self::Field {
|
||||
serialized_file_path,
|
||||
..
|
||||
} => serialized_file_path,
|
||||
}
|
||||
}
|
||||
|
||||
fn path_id(&self) -> i64 {
|
||||
match self {
|
||||
Self::WholeObject { path_id, .. } | Self::Field { path_id, .. } => *path_id,
|
||||
}
|
||||
}
|
||||
|
||||
fn describe(self) -> String {
|
||||
match self {
|
||||
Self::WholeObject {
|
||||
archive_entry,
|
||||
serialized_file_path,
|
||||
path_id,
|
||||
} => format!(
|
||||
"archive={archive_entry:?}, serialized_file={serialized_file_path}, path_id={path_id}, object"
|
||||
),
|
||||
Self::Field {
|
||||
archive_entry,
|
||||
serialized_file_path,
|
||||
path_id,
|
||||
field_path,
|
||||
} => format!(
|
||||
"archive={archive_entry:?}, serialized_file={serialized_file_path}, path_id={path_id}, field={field_path}"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unity_operation_target(operation: &PatchManifestOperation) -> Option<UnityOperationTarget<'_>> {
|
||||
let archive_entry = operation.archive_entry.as_deref();
|
||||
match &operation.payload {
|
||||
PatchManifestOperationPayload::UnityFsTextAsset {
|
||||
serialized_file_path,
|
||||
path_id,
|
||||
..
|
||||
} => Some(UnityOperationTarget::WholeObject {
|
||||
archive_entry,
|
||||
serialized_file_path,
|
||||
path_id: *path_id,
|
||||
}),
|
||||
PatchManifestOperationPayload::UnityFsStringField {
|
||||
serialized_file_path,
|
||||
path_id,
|
||||
field_path,
|
||||
..
|
||||
}
|
||||
| PatchManifestOperationPayload::UnityFsField {
|
||||
serialized_file_path,
|
||||
path_id,
|
||||
field_path,
|
||||
..
|
||||
} => Some(UnityOperationTarget::Field {
|
||||
archive_entry,
|
||||
serialized_file_path,
|
||||
path_id: *path_id,
|
||||
field_path,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn unity_operation_targets_conflict(
|
||||
left: &UnityOperationTarget<'_>,
|
||||
right: &UnityOperationTarget<'_>,
|
||||
) -> bool {
|
||||
if left.archive_entry() != right.archive_entry()
|
||||
|| left.serialized_file_path() != right.serialized_file_path()
|
||||
|| left.path_id() != right.path_id()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
match (left, right) {
|
||||
(UnityOperationTarget::WholeObject { .. }, _)
|
||||
| (_, UnityOperationTarget::WholeObject { .. }) => true,
|
||||
(
|
||||
UnityOperationTarget::Field {
|
||||
field_path: left_path,
|
||||
..
|
||||
},
|
||||
UnityOperationTarget::Field {
|
||||
field_path: right_path,
|
||||
..
|
||||
},
|
||||
) => field_paths_overlap(left_path, right_path),
|
||||
}
|
||||
}
|
||||
|
||||
fn field_paths_overlap(left: &str, right: &str) -> bool {
|
||||
left == right || is_field_path_parent(left, right) || is_field_path_parent(right, left)
|
||||
}
|
||||
|
||||
fn is_field_path_parent(parent: &str, child: &str) -> bool {
|
||||
child
|
||||
.strip_prefix(parent)
|
||||
.is_some_and(|suffix| suffix.starts_with('.') || suffix.starts_with('['))
|
||||
}
|
||||
|
||||
/// 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()
|
||||
)));
|
||||
}
|
||||
let operation_count = file.operations.len();
|
||||
let direct_operations = !has_unity_operation && !has_archive_operation;
|
||||
let mut current = source.clone();
|
||||
let operations = file
|
||||
.operations
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(sequence, mut operation)| {
|
||||
operation.payload.validate()?;
|
||||
if direct_operations && operation.source_blake3.is_none() && operation_count > 1 {
|
||||
operation.source_blake3 = Some(blake3_hex(¤t));
|
||||
operation.source_size = Some(current.len() as u64);
|
||||
} else if !direct_operations
|
||||
&& operation_count > 1
|
||||
&& operation.source_blake3.is_none()
|
||||
{
|
||||
return Err(PatchError::ApplyFailed(format!(
|
||||
"manifest builder requires source preconditions for multiple non-direct operations: {}",
|
||||
file.path.display()
|
||||
)));
|
||||
}
|
||||
if direct_operations {
|
||||
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
|
||||
)));
|
||||
}
|
||||
}
|
||||
current = apply_direct_payload(¤t, &operation.payload)?;
|
||||
} else if operation_count == 1 {
|
||||
if let (Some(expected_hash), Some(expected_size)) =
|
||||
(operation.source_blake3.as_deref(), operation.source_size)
|
||||
{
|
||||
if expected_hash != blake3_hex(&source)
|
||||
|| expected_size != source.len() as u64
|
||||
{
|
||||
return Err(PatchError::ApplyFailed(format!(
|
||||
"manifest builder operation source precondition mismatch: {} operation {}",
|
||||
file.path.display(),
|
||||
sequence
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
/*
|
||||
* Non-direct operations are produced by the owning adapter.
|
||||
* Their source preconditions describe adapter-produced
|
||||
* intermediate bytes, which this crate cannot reconstruct.
|
||||
*/
|
||||
operation.sequence = sequence as u32;
|
||||
Ok(operation)
|
||||
})
|
||||
.collect::<crate::Result<Vec<_>>>()?;
|
||||
if direct_operations && 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 +742,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 +769,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 +856,269 @@ 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_allows_sibling_fields_on_one_unity_object() {
|
||||
let manifest = unity_manifest(vec![
|
||||
unity_string_operation(None, "first", 0),
|
||||
unity_field_operation(None, "second", 1),
|
||||
]);
|
||||
|
||||
validate_patch_manifest(&manifest).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_patch_manifest_rejects_duplicate_unity_field() {
|
||||
let manifest = unity_manifest(vec![
|
||||
unity_string_operation(None, "first", 0),
|
||||
unity_string_operation(None, "first", 1),
|
||||
]);
|
||||
|
||||
assert!(validate_patch_manifest(&manifest).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_patch_manifest_rejects_whole_object_and_field_overlap() {
|
||||
let manifest = unity_manifest(vec![
|
||||
unity_text_asset_operation(None, 0),
|
||||
unity_string_operation(None, "first", 1),
|
||||
]);
|
||||
|
||||
assert!(validate_patch_manifest(&manifest).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_patch_manifest_rejects_parent_child_field_overlap() {
|
||||
let manifest = unity_manifest(vec![
|
||||
unity_string_operation(None, "root", 0),
|
||||
unity_field_operation(None, "root.child", 1),
|
||||
]);
|
||||
|
||||
assert!(validate_patch_manifest(&manifest).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_patch_manifest_applies_the_same_identity_rules_inside_zip_entries() {
|
||||
validate_patch_manifest(&unity_manifest(vec![
|
||||
unity_string_operation(Some("bundles/one.bundle"), "first", 0),
|
||||
unity_field_operation(Some("bundles/one.bundle"), "second", 1),
|
||||
]))
|
||||
.unwrap();
|
||||
|
||||
for operations in [
|
||||
vec![
|
||||
unity_string_operation(Some("bundles/one.bundle"), "first", 0),
|
||||
unity_string_operation(Some("bundles/one.bundle"), "first", 1),
|
||||
],
|
||||
vec![
|
||||
unity_text_asset_operation(Some("bundles/one.bundle"), 0),
|
||||
unity_string_operation(Some("bundles/one.bundle"), "first", 1),
|
||||
],
|
||||
vec![
|
||||
unity_string_operation(Some("bundles/one.bundle"), "root", 0),
|
||||
unity_field_operation(Some("bundles/one.bundle"), "root.child", 1),
|
||||
],
|
||||
] {
|
||||
assert!(validate_patch_manifest(&unity_manifest(operations)).is_err());
|
||||
}
|
||||
|
||||
validate_patch_manifest(&unity_manifest(vec![
|
||||
unity_string_operation(Some("bundles/one.bundle"), "first", 0),
|
||||
unity_string_operation(Some("bundles/two.bundle"), "first", 1),
|
||||
]))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn unity_manifest(operations: Vec<PatchManifestOperation>) -> PatchManifest {
|
||||
let mut manifest = manifest_for("bundle", b"source", b"target");
|
||||
manifest.files[0].patch_kind = PatchKind::Mixed;
|
||||
manifest.files[0].operations = operations;
|
||||
manifest
|
||||
}
|
||||
|
||||
fn unity_string_operation(
|
||||
archive_entry: Option<&str>,
|
||||
field_path: &str,
|
||||
sequence: u32,
|
||||
) -> PatchManifestOperation {
|
||||
PatchManifestOperation {
|
||||
sequence,
|
||||
source_blake3: Some(blake3_hex(b"source")),
|
||||
source_size: Some(6),
|
||||
archive_entry: archive_entry.map(str::to_string),
|
||||
payload: PatchManifestOperationPayload::UnityFsStringField {
|
||||
serialized_file_path: "CAB-one".to_string(),
|
||||
path_id: 7,
|
||||
field_path: field_path.to_string(),
|
||||
expected_value: None,
|
||||
replacement: "replacement".to_string(),
|
||||
},
|
||||
provenance: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn unity_field_operation(
|
||||
archive_entry: Option<&str>,
|
||||
field_path: &str,
|
||||
sequence: u32,
|
||||
) -> PatchManifestOperation {
|
||||
PatchManifestOperation {
|
||||
sequence,
|
||||
source_blake3: Some(blake3_hex(b"source")),
|
||||
source_size: Some(6),
|
||||
archive_entry: archive_entry.map(str::to_string),
|
||||
payload: PatchManifestOperationPayload::UnityFsField {
|
||||
serialized_file_path: "CAB-one".to_string(),
|
||||
path_id: 7,
|
||||
field_path: field_path.to_string(),
|
||||
expected_value: None,
|
||||
replacement: serde_json::json!({"kind": "string", "value": "replacement"}),
|
||||
},
|
||||
provenance: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn unity_text_asset_operation(
|
||||
archive_entry: Option<&str>,
|
||||
sequence: u32,
|
||||
) -> PatchManifestOperation {
|
||||
PatchManifestOperation {
|
||||
sequence,
|
||||
source_blake3: Some(blake3_hex(b"source")),
|
||||
source_size: Some(6),
|
||||
archive_entry: archive_entry.map(str::to_string),
|
||||
payload: PatchManifestOperationPayload::UnityFsTextAsset {
|
||||
serialized_file_path: "CAB-one".to_string(),
|
||||
path_id: 7,
|
||||
expected_name: None,
|
||||
replacement: b"replacement".to_vec(),
|
||||
},
|
||||
provenance: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn manifest_for(path: &str, source: &[u8], target: &[u8]) -> PatchManifest {
|
||||
PatchManifest {
|
||||
version: PATCH_MANIFEST_VERSION,
|
||||
@@ -251,6 +1132,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,
|
||||
|
||||
@@ -10,9 +10,9 @@ DB_MODE=remote
|
||||
|
||||
# PostgreSQL 配置
|
||||
# 本地模式:使用 localhost:5432
|
||||
# 远程模式:填写远程服务器的公网 IP 和端口
|
||||
DB_HOST=your.remote.server.com # 远程服务器地址(或 localhost 用于本地)
|
||||
DB_PORT=5432
|
||||
# 远程模式:优先使用私网/VPN;SSH tunnel 时填写本地转发地址和端口
|
||||
DB_HOST=127.0.0.1 # 本地或 SSH tunnel 地址
|
||||
DB_PORT=15432
|
||||
DB_USER=bat_user
|
||||
DB_PASSWORD=your_secure_password_here
|
||||
DB_NAME=bluearchive_toolkit
|
||||
@@ -32,9 +32,9 @@ DB_SSL_MODE=prefer
|
||||
|
||||
# Redis 配置
|
||||
# 本地模式:使用 localhost:6379
|
||||
# 远程模式:填写远程服务器的公网 IP 和端口
|
||||
REDIS_HOST=your.remote.server.com # 远程服务器地址(或 localhost 用于本地)
|
||||
REDIS_PORT=6379
|
||||
# 远程模式:优先使用私网/VPN;SSH tunnel 时填写本地转发地址和端口
|
||||
REDIS_HOST=127.0.0.1 # 本地或 SSH tunnel 地址
|
||||
REDIS_PORT=16379
|
||||
REDIS_PASSWORD=your_redis_password_here
|
||||
REDIS_DB=0
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ services:
|
||||
POSTGRES_PASSWORD: bat_dev_password
|
||||
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C"
|
||||
ports:
|
||||
- "0.0.0.0:5432:5432"
|
||||
- "127.0.0.1:5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./postgres-init:/docker-entrypoint-initdb.d
|
||||
@@ -41,7 +41,7 @@ services:
|
||||
profiles: ["local-db"] # 只有指定 --profile local-db 才启动
|
||||
command: redis-server /usr/local/etc/redis/redis.conf
|
||||
ports:
|
||||
- "0.0.0.0:6379:6379"
|
||||
- "127.0.0.1:6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
- ./redis.conf:/usr/local/etc/redis/redis.conf
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# 1. 将此文件和相关配置上传到远程服务器
|
||||
# 2. 复制 .env.example 为 .env 并配置密码
|
||||
# 3. 运行:docker compose -f docker-compose.remote-db.yml up -d
|
||||
# 4. 确保防火墙开放 5432 和 6379 端口
|
||||
# 4. 默认仅绑定宿主机回环地址;远程开发使用私网、VPN 或 SSH tunnel
|
||||
|
||||
version: '3.9'
|
||||
|
||||
@@ -20,7 +20,7 @@ services:
|
||||
POSTGRES_PASSWORD: ${REMOTE_DB_POSTGRES_PASSWORD}
|
||||
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C"
|
||||
ports:
|
||||
- "0.0.0.0:5432:5432" # 监听所有网络接口
|
||||
- "127.0.0.1:5432:5432" # 不直接暴露到公网
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./postgres-init:/docker-entrypoint-initdb.d
|
||||
@@ -44,7 +44,7 @@ services:
|
||||
container_name: bat-redis
|
||||
command: redis-server /usr/local/etc/redis/redis.conf
|
||||
ports:
|
||||
- "0.0.0.0:6379:6379" # 监听所有网络接口
|
||||
- "127.0.0.1:6379:6379" # 不直接暴露到公网
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
- ./redis-remote.conf:/usr/local/etc/redis/redis.conf
|
||||
|
||||
+24
-39
@@ -1,48 +1,33 @@
|
||||
# API 文档
|
||||
|
||||
本目录包含 BlueArchive Toolkit 的 API 文档。
|
||||
本目录是 API 文档入口。当前实现分为两层,不能把 Rust daemon RPC
|
||||
和 Go HTTP 服务混写成一个接口:
|
||||
|
||||
当前 API Server 尚未实现,本文件只记录规划边界,不代表已有可运行 HTTP 服务或 OpenAPI 产物。
|
||||
## Rust daemon RPC
|
||||
|
||||
## OpenAPI 规范
|
||||
Rust `bat` 通过 `/tmp/bat-pid/bat.sock` 提供换行分隔的 JSON-RPC 2.0
|
||||
Resource Backend。方法、参数、envelope、错误码、Go 调用白名单以
|
||||
[`rpc-backend-api.md`](../reference/rpc-backend-api.md) 为准。
|
||||
|
||||
OpenAPI 文档将在 API Server 落地后生成,目标使用 OpenAPI 3.0 标准。当前仓库尚未提供 `openapi/` 生成产物。
|
||||
## Go bat-api HTTP
|
||||
|
||||
## 文档生成
|
||||
Go `cmd/bat-api` 是资源 bootstrap、已发布资源分发和鉴权控制服务,不是完整
|
||||
游戏业务 API。已实现的 HTTP surface 包括:
|
||||
|
||||
API 文档将在开发过程中自动生成和更新。
|
||||
- `/healthz`、`/readyz`
|
||||
- `/v1/bootstrap`、`/v1/launcher/bootstrap`、`/v1/release`、`/v1/resources`
|
||||
- `/v1/server-info` 和 CDN 形状资源路径
|
||||
- `/api/launcher/game/config` 兼容端点
|
||||
- `/admin/` 与白名单 `/admin/control/{action}`;其中翻译管理面包含
|
||||
`/admin/translation/tasks`、`/admin/translation/handoff`、
|
||||
`/admin/translation/memory/summary`、`/admin/translation/memory/query` 和
|
||||
`translation-memory-confirm`、`translation-memory-resolve-conflict` 转发
|
||||
- `/openapi.yaml`
|
||||
|
||||
**计划**:
|
||||
- 使用 `swag` (Go) 从代码注释生成 OpenAPI 文档
|
||||
- 提供 Swagger UI 在线查看
|
||||
- 支持导出为 Markdown、HTML 等格式
|
||||
HTTP 路由的 OpenAPI 文本由 `internal/api/openapi.go` 提供,运行中的服务也可
|
||||
通过 `GET /openapi.yaml` 获取。配置、鉴权、部署边界和示例见
|
||||
[`USERGUIDE.md`](../../USERGUIDE.md) 与
|
||||
[`GO_STATUS.md`](../reports/GO_STATUS.md)。
|
||||
|
||||
---
|
||||
|
||||
## 核心 API 端点(规划中)
|
||||
|
||||
### 认证
|
||||
- `POST /api/v1/auth/login` - 用户登录
|
||||
- `POST /api/v1/auth/logout` - 用户登出
|
||||
- `POST /api/v1/auth/refresh` - 刷新 Token
|
||||
|
||||
### 翻译管理
|
||||
- `GET /api/v1/translations` - 获取翻译列表
|
||||
- `POST /api/v1/translations` - 创建翻译
|
||||
- `PUT /api/v1/translations/:id` - 更新翻译
|
||||
- `DELETE /api/v1/translations/:id` - 删除翻译
|
||||
|
||||
### 术语管理
|
||||
- `GET /api/v1/glossary` - 获取术语列表
|
||||
- `POST /api/v1/glossary` - 创建术语
|
||||
- `PUT /api/v1/glossary/:id` - 更新术语
|
||||
- `DELETE /api/v1/glossary/:id` - 删除术语
|
||||
|
||||
### 资源同步
|
||||
- `POST /api/v1/sync/start` - 启动同步
|
||||
- `GET /api/v1/sync/status` - 查询同步状态
|
||||
- `POST /api/v1/sync/cancel` - 取消同步
|
||||
|
||||
---
|
||||
|
||||
更多详细文档将在 API Server 实现后补充。
|
||||
账号登录、完整翻译管理、术语库、游戏业务协议和完整 launcher 安装包更新链
|
||||
当前不属于已实现接口。
|
||||
|
||||
+90
-39
@@ -4,11 +4,19 @@
|
||||
|
||||
BlueArchive Toolkit 采用 **Monorepo + 多语言混合** 架构,旨在构建一个可持续维护十年以上的工业级开源项目。
|
||||
|
||||
当前文档描述目标架构和已经落地的关键边界。它不是部署手册;当前可部署能力只有 Rust 官方资源同步任务。API Server、Web、Provider 编排和 Go 产品入口仍未完成,实际实现状态以根目录 `CURRENT_STATUS.md` 和 `PROJECT_PLAN.md` 为准。
|
||||
当前文档描述目标架构和已经落地的关键边界。它不是部署手册;当前可部署能力包括 Rust 官方资源同步任务和 Go `cmd/bat-api` 资源 bootstrap/分发服务。完整游戏业务 API、Web、Provider 编排和 SDK 仍未完成,实际实现状态以源码、测试和根目录 `CURRENT_STATUS.md` 为准;`PROJECT_PLAN.md` 只描述目标和路线图。
|
||||
|
||||
当前已经可用的官方资源入口包括:
|
||||
|
||||
- `infrastructure/src/bin/bat_official_sync.rs`:Linux 官方资源同步正式入口,构建为 `bat`,支持 one-shot、`--watch`、`--daemon`、`status`、`stop`、`restart`、`reload`、`refresh`、`logs`、`verify`、`repair`、`doctor` 和 `clean-stable`。
|
||||
- `infrastructure/src/bin/bat_official_sync.rs`:Linux 官方资源同步薄入口,构建为 `bat`;控制面组合与实现位于 `infrastructure/src/bin/bat/`,支持 one-shot、`--watch`、`--daemon`、`status`、`stop`、`restart`、`reload`、`refresh`、`logs`、`verify`、`repair`、`doctor` 和 `clean-stable`。
|
||||
- `infrastructure/src/bin/bat/app.rs`:CLI/env、daemon/watch、RPC dispatch 与顶层流程组合。
|
||||
- `infrastructure/src/bin/bat/report_output.rs`:人类可读报告、JSON 查询结果和报告格式化。
|
||||
- `infrastructure/src/bin/bat/terminal_output.rs`:前台错误、帮助、启动提示、进度和结构化日志输出。
|
||||
- `infrastructure/src/bin/bat/task_registry.rs`:任务注册表、任务持久化、取消和 daemon worker。
|
||||
- `infrastructure/src/bin/bat/readonly_query.rs`:parse/resource/translation/localized 只读查询及 RPC 选择。
|
||||
- `infrastructure/src/bin/bat/translation_query.rs`:翻译任务与 handoff 查询、worker 状态更新。
|
||||
- `infrastructure/src/bin/bat/patch_commands.rs`:文件 patch 与 UnityFS 写入命令参数校验和执行。
|
||||
- `infrastructure/src/bin/bat/app_tests.rs`:控制面回归测试,避免测试代码继续堆积在入口实现中。
|
||||
- `infrastructure/src/official_update.rs`:官方自动更新核心服务,负责 auto-discover、snapshot、marker diff、本地 audit/repair。
|
||||
- `infrastructure/examples/official_pull_plan.rs`:开发/审计用 pull plan 入口。
|
||||
- `infrastructure/examples/official_update_check.rs`:历史/开发入口,生产优先使用 `bat`。
|
||||
@@ -17,9 +25,11 @@ BlueArchive Toolkit 采用 **Monorepo + 多语言混合** 架构,旨在构建
|
||||
|
||||
已接受的架构决策:
|
||||
|
||||
- `adr/0001-engine-and-application-boundaries.md`:Rust 引擎与 Go 应用层边界。
|
||||
- `adr/0001-engine-and-application-boundaries.md`:历史语言/层次边界决策;资源同步职责已由 ADR 0004 取代。
|
||||
- `adr/0002-cas-v1-design-boundary.md`:CAS V1 设计边界。
|
||||
- `adr/0003-cas-core-interface-and-error-boundary.md`:CAS 核心接口与错误边界冻结。
|
||||
- `adr/0004-rust-bat-go-bat-api-resource-boundary.md`:当前 Rust `bat` 与 Go `bat-api`
|
||||
的资源控制面边界。
|
||||
|
||||
---
|
||||
|
||||
@@ -33,16 +43,30 @@ BlueArchive Toolkit 采用 **Monorepo + 多语言混合** 架构,旨在构建
|
||||
|
||||
### 2. 语言选型
|
||||
|
||||
| 模块 | 语言 | 理由 |
|
||||
| 模块 | 语言 | 当前定位 |
|
||||
|------|------|------|
|
||||
| CLI、API Server、服务编排 | Go | 并发模型优秀、部署简单、生态成熟 |
|
||||
| 官方资源同步核心、AssetBundle 解析、Patch 引擎、CAS 引擎 | Rust | 零成本抽象、内存安全、性能和二进制处理更可靠 |
|
||||
| Web 管理后台 | Vue 3 + TypeScript | 渐进式、类型安全、生态完善 |
|
||||
| 官方资源同步与运维 CLI、同步核心 | Rust | **当前实现**;`bat` 负责生产资源和长期状态 |
|
||||
| 资源 bootstrap、只读分发和 Rust 管理入口 | Go | **当前实现**;`cmd/bat-api` 通过 `bat.sock` RPC 工作 |
|
||||
| AssetBundle 解析、Patch 引擎、CAS 引擎 | Rust | **当前已有基础,复杂覆盖仍按路线图推进** |
|
||||
| 完整 API、服务编排和 Provider | Go | **目标设计,尚未完整实现** |
|
||||
| 完整 Web 协作后台 | Vue 3 + TypeScript | **目标设计**;当前只有内嵌 dashboard MVP |
|
||||
|
||||
### 3. 数据流设计
|
||||
|
||||
当前已落地的数据流:
|
||||
|
||||
```
|
||||
用户请求 → CLI/API → Go 业务层 → bat --json / SDK → Rust 核心/同步层 → CAS 存储 → 数据库
|
||||
官方 metadata → Rust bat / daemon → release + current + manifest
|
||||
↓
|
||||
bat.sock JSON-RPC
|
||||
↓
|
||||
Go bat-api → bootstrap / CDN / dashboard
|
||||
```
|
||||
|
||||
目标扩展数据流(其中 Go 业务层、SDK、数据库和 Redis 尚未全部实现):
|
||||
|
||||
```
|
||||
用户请求 → CLI/API → Go 业务层 → bat.sock RPC / SDK → Rust 核心/同步层 → CAS 存储 → 数据库
|
||||
↓ ↓
|
||||
Web UI 缓存层 (Redis)
|
||||
```
|
||||
@@ -91,7 +115,7 @@ cas/
|
||||
|
||||
---
|
||||
|
||||
### 2. 官方资源同步器 (Rust 当前实现,Go 后续编排)
|
||||
### 2. 官方资源同步器 (Rust 当前实现,Go 侧读取)
|
||||
|
||||
**职责**:从官方日服 HTTP metadata 自动发现资源入口,下载 Windows + Android 官方资源,增量检查,完整性校验,保持本地状态。
|
||||
|
||||
@@ -120,25 +144,30 @@ current symlink → official-sync-snapshot.json + official-download-manifest.jso
|
||||
- `refresh --force` 可手动强制刷新;`verify` 只读校验当前官方计划、本地 manifest 和官方 seed hash;`repair` 尝试修复异常资源。
|
||||
- 非 dry-run 同步先写 `.staging/<id>`,校验完成后发布 `versions/<id>` 并原子切换 `current` symlink。
|
||||
- `--daemon` 使用状态目录下的 `bat.sock` 作为 Unix socket JSON-RPC live control plane;PID、状态和日志文件是快照与 fallback,`bat-events.jsonl` 是结构化轮转日志。
|
||||
- `status`、`logs`、`reload`、`stop` 和默认形态的 `refresh` 优先通过 RPC 管理后台进程;控制命令通过 `bat-control.lock` 串行化;`restart` 负责重启或替换启动参数;live daemon 会阻止前台写命令直接修改同一资源目录;`doctor` 做运行时诊断;`clean-stable` 清理临时文件和失效/损坏状态。
|
||||
- `status`、`logs`、`restart`、`reload`、`stop` 和默认形态的 `refresh` 优先通过 RPC 管理后台进程;控制命令通过 `bat-control.lock` 串行化;`restart` 通过 Rust lifecycle controller 复用 CLI restart 路径重启或替换启动参数;live daemon 会阻止前台写命令直接修改同一资源目录;`doctor` 做运行时诊断;`clean-stable` 清理临时文件和失效/损坏状态。
|
||||
- 远端 marker 无变化且本地 manifest clean 时不下载。
|
||||
- 本地文件损坏时 repair。
|
||||
- 官方 seed `.hash` 强校验;Addressables `catalog_*.hash` 作为变更 marker。
|
||||
|
||||
**后续 Go 职责**:
|
||||
**Go 当前职责**:
|
||||
|
||||
- 提供最小稳定 CLI。
|
||||
- 默认通过 `bat --json` 进程边界包装 Rust 同步入口,并转发结构化 report。
|
||||
- `bat-ffi` 仅作为可选无状态 C ABI 兼容层,不承载官方同步 daemon、下载器或 CAS handle。
|
||||
- 编排 API Server、任务队列、Provider 和用户配置。
|
||||
- `bat-api` 通过 `bat.sock` RPC 读取 Rust 已发布 release、manifest、snapshot 和状态。
|
||||
- 提供资源 bootstrap、server-info 改写、只读 CDN path、readiness、OpenAPI 和白名单管理转发;
|
||||
翻译任务与 TM 管理接口只通过 Rust RPC 代理,不在 Go 侧持有状态。
|
||||
- 不运行另一套同步器,不直接管理官方下载、staging、version-state、CAS 或解析状态。
|
||||
|
||||
完整 API、服务编排、Provider 和用户配置属于目标扩展,不能从本节推断为当前已实现。
|
||||
|
||||
---
|
||||
|
||||
### 3. AssetBundle 解析器 (Rust)
|
||||
### 3. AssetBundle 解析器 (Rust,当前基础与目标扩展)
|
||||
|
||||
**职责**:解析 Unity AssetBundle,提取资源
|
||||
|
||||
**插件化架构**:
|
||||
以下插件注册和动态加载是目标扩展;当前实现以 `crates/bat-assetbundle`、
|
||||
`bat-adapters` 和真实 fixture 覆盖为准。
|
||||
|
||||
**目标插件化架构**:
|
||||
```rust
|
||||
pub trait AssetParser {
|
||||
fn name(&self) -> &str;
|
||||
@@ -164,11 +193,19 @@ pub struct ParserRegistry {
|
||||
|
||||
---
|
||||
|
||||
### 4. 翻译系统 (Go)
|
||||
### 4. 翻译系统(目标扩展,Go;当前 worker 由 Rust `bat` 承担)
|
||||
|
||||
当前已实现的是 Rust `bat` 的离线 TextUnit 队列、mock/Crowdin provider worker、
|
||||
lease/retry、结果落库、项目级 Translation Memory persistence schema V2 和独立 Glossary
|
||||
domain/feature contract V1(SQLite persistence schema V2)。TM 位于独立 SQLite,按 raw
|
||||
source + 完整 context 做 current Trusted exact reuse,candidate 必须显式 confirm;
|
||||
同一 identity 的不同译文必须显式 supersede,历史 Trusted 冲突必须显式 resolve;
|
||||
Glossary 只有 approved term 进入 provider/TM 自动流程,并在结果上执行确定性 QA;模糊
|
||||
匹配和完整 Provider 体系仍属后续缺口。
|
||||
|
||||
**架构**:
|
||||
```
|
||||
Text Extractor → Translation Memory (查询) → AI Provider → Glossary (术语替换) → Output
|
||||
Text Extractor → Glossary constraints + TM exact query → AI Provider → Glossary QA → Output
|
||||
↓ ↓
|
||||
PostgreSQL 审核队列
|
||||
```
|
||||
@@ -182,7 +219,11 @@ type TranslationProvider interface {
|
||||
}
|
||||
```
|
||||
|
||||
**实现**:
|
||||
**当前实现**:
|
||||
- Rust `bat` 的 mock provider worker
|
||||
- Rust `bat` 的 Crowdin provider worker
|
||||
|
||||
**目标 Provider**:
|
||||
- DeepL Provider
|
||||
- OpenAI Provider
|
||||
- Anthropic Provider
|
||||
@@ -190,18 +231,19 @@ type TranslationProvider interface {
|
||||
- Azure Translator Provider
|
||||
|
||||
**翻译记忆库**:
|
||||
- 精确匹配:100% 匹配直接使用
|
||||
- 模糊匹配:使用相似度算法(Levenshtein Distance)
|
||||
- 上下文匹配:根据前后文提高匹配准确度
|
||||
- 当前规则:raw source 完全相同、完整 context 完全相同且只有一条 current Trusted 时自动复用。
|
||||
- provider 输出写入先是 candidate;manual task result 不会自动建立 TM 或 trusted。`bat i18n memory confirm` 显式确认单条记录后才可自动复用。
|
||||
- source、context、release、TextUnit、provider 和 run provenance 保存在 Rust TM SQLite 中。
|
||||
- 模糊匹配、术语优先级和 PostgreSQL 服务化仍不是当前实现。
|
||||
|
||||
---
|
||||
|
||||
### 5. Patch 引擎 (Rust)
|
||||
### 5. Patch 引擎 (Rust,当前基础与目标扩展)
|
||||
|
||||
**职责**:生成和应用补丁
|
||||
|
||||
**支持的 Patch 类型**:
|
||||
1. **Binary Patch**:使用 bsdiff 算法
|
||||
1. **Binary Patch**:确定性 Binary hunk diff/apply(当前实现)
|
||||
2. **JSON Patch**:RFC 6902 标准
|
||||
3. **Text Patch**:基于 diff 算法
|
||||
|
||||
@@ -224,7 +266,10 @@ patch/
|
||||
|
||||
---
|
||||
|
||||
### 6. API Server (Go)
|
||||
### 6. API Server (Go,目标设计)
|
||||
|
||||
当前可用的 Go HTTP 服务是 `cmd/bat-api` 的资源 bootstrap、只读分发和 Rust 管理
|
||||
入口,不是下列完整游戏业务 API。
|
||||
|
||||
**框架**:Gin 或 Echo
|
||||
|
||||
@@ -251,7 +296,10 @@ HTTP Request → Middleware (Auth, CORS, Logger) → Handler → Service → Rep
|
||||
|
||||
---
|
||||
|
||||
### 7. Web 后台 (Vue 3)
|
||||
### 7. Web 后台 (Vue 3,目标设计)
|
||||
|
||||
当前只有 `bat-api` 内嵌 dashboard MVP;Rust `bat` 的 Glossary domain/feature contract V1
|
||||
及 SQLite persistence schema V2 已实现,登录、角色、Web 术语管理和完整协作审核仍未实现。
|
||||
|
||||
**技术栈**:
|
||||
- Vue 3 + Composition API
|
||||
@@ -264,13 +312,16 @@ HTTP Request → Middleware (Auth, CORS, Logger) → Handler → Service → Rep
|
||||
**模块**:
|
||||
- Dashboard(统计概览)
|
||||
- 翻译审核(Translation Review)
|
||||
- 术语管理(Glossary Manager)
|
||||
- Web 术语管理(Glossary Manager)
|
||||
- 资源浏览(Asset Browser)
|
||||
- 用户管理(User Management)
|
||||
|
||||
---
|
||||
|
||||
## 数据库设计
|
||||
## 数据库设计(目标设计)
|
||||
|
||||
当前 Rust 资源链路使用 SQLite 维护本地 CAS、ResourceRepository 和翻译任务状态;
|
||||
PostgreSQL/Redis 业务服务端方案尚未完整落地。
|
||||
|
||||
### PostgreSQL Schema
|
||||
|
||||
@@ -314,7 +365,11 @@ CREATE TABLE resource_versions (
|
||||
|
||||
---
|
||||
|
||||
## 部署架构
|
||||
## 部署架构(目标设计)
|
||||
|
||||
当前可部署形态是 Rust `bat` 官方资源同步任务和同机/共享文件系统的 Go
|
||||
`bat-api` 资源 bootstrap/分发服务。以下多实例 API、PostgreSQL 主从和 Redis
|
||||
集群属于目标部署形态。
|
||||
|
||||
### 本地开发模式
|
||||
|
||||
@@ -342,7 +397,7 @@ API Server (多实例)
|
||||
|
||||
---
|
||||
|
||||
## 安全设计
|
||||
## 安全设计(目标设计)
|
||||
|
||||
1. **认证**:JWT Token
|
||||
2. **授权**:RBAC (Role-Based Access Control)
|
||||
@@ -353,7 +408,7 @@ API Server (多实例)
|
||||
|
||||
---
|
||||
|
||||
## 性能优化
|
||||
## 性能优化(目标设计)
|
||||
|
||||
1. **缓存策略**:
|
||||
- Redis 缓存热点数据
|
||||
@@ -372,7 +427,7 @@ API Server (多实例)
|
||||
|
||||
---
|
||||
|
||||
## 监控与日志
|
||||
## 监控与日志(目标设计)
|
||||
|
||||
- **日志**:结构化日志(JSON 格式)
|
||||
- **指标**:Prometheus + Grafana
|
||||
@@ -393,10 +448,6 @@ API Server (多实例)
|
||||
更多详细设计文档:
|
||||
|
||||
- [官方资源后端说明](./official-resource-backend.md)
|
||||
- [资源 release 布局与分发契约](./resource-release-layout.md)
|
||||
- [AssetBundle 解析与发布路线图](./assetbundle.md)
|
||||
- [API 设计](../api/README.md)
|
||||
|
||||
待创建的详细设计文档:
|
||||
|
||||
- `docs/architecture/cas.md`
|
||||
- `docs/architecture/assetbundle.md`
|
||||
- `docs/architecture/translation.md`
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
# ADR 0001: Rust 引擎与 Go 应用层边界
|
||||
|
||||
**状态**:已接受
|
||||
**状态**:已接受(历史决策;资源同步职责已由 ADR 0004 取代)
|
||||
**日期**:2026-06-28
|
||||
**关联计划**:`../../../PROJECT_PLAN.md`
|
||||
|
||||
---
|
||||
|
||||
> 历史说明:本文保留 2026-06-28 的原始语言和层次决策。其关于 Go 负责资源同步、
|
||||
> 下载器和任务调度的职责描述已被当前实现和 ADR 0004 取代;阅读当前资源边界时,
|
||||
> 以 ADR 0004、`CURRENT_STATUS.md` 和 `docs/reports/GO_STATUS.md` 为准。
|
||||
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
BlueArchiveToolkit 的最终目标覆盖资源同步、CAS、AssetBundle 解析、文本提取、翻译、Patch、CLI、API Server、Web 和 SDK。项目天然包含二进制解析、文件完整性、网络同步、任务编排、数据库、用户界面等不同类型的问题。
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# ADR 0004: Rust bat 与 Go bat-api 当前资源控制面边界
|
||||
|
||||
**状态**:已接受
|
||||
**日期**:2026-09-04
|
||||
**关联文档**:`../../../CURRENT_STATUS.md`、`../../../docs/reports/GO_STATUS.md`
|
||||
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
项目同时包含 Rust 资源引擎和 Go HTTP 服务。历史设计曾把资源同步、下载器
|
||||
和任务调度归入 Go 应用层,但当前实现已经由 Rust `bat` 统一持有这些长期状态。
|
||||
如果继续沿用旧职责描述,会让 Go、Web 或其他入口重复实现资源状态机。
|
||||
|
||||
---
|
||||
|
||||
## 决策
|
||||
|
||||
1. **Rust `bat` 是官方资源生产者和状态拥有者**:
|
||||
- 负责官方 metadata 发现、下载、校验、staging、release 发布和 `current` 切换;
|
||||
- 负责 watch/daemon、`bat.sock` JSON-RPC、任务、日志、版本状态、解析、
|
||||
翻译 worker 和 localized release 状态;
|
||||
- 负责 CAS、AssetBundle 解析、Patch 核心算法及其文件安全边界。
|
||||
|
||||
2. **Go `bat-api` 是资源读侧和管理入口**:
|
||||
- 通过 `bat.sock` RPC 发现 Rust 已发布的 `resource_root`、snapshot、manifest
|
||||
和状态;
|
||||
- 提供资源 bootstrap、server-info 改写、只读 CDN path、readiness、OpenAPI
|
||||
以及鉴权后的白名单管理转发;翻译任务和 TM 管理面只转发 Rust RPC;
|
||||
- 不下载官方资源、不写 staging、不维护 version-state,不复制 CAS、解析器、
|
||||
Patch 核心算法或同步状态机。
|
||||
|
||||
3. **Go `cmd/bat` 和 `bat-ffi` 不是主集成边界**:
|
||||
- `cmd/bat` 只保留试验 CLI;
|
||||
- `bat-ffi` 只保留无状态、粗粒度、一次调用一次输入输出的兼容 helper;
|
||||
- 新的跨语言控制和查询能力优先增加 Rust RPC contract,再由
|
||||
`internal/backendrpc` 消费。
|
||||
|
||||
4. **完整游戏业务 API、完整 Web 协作后台和 Provider 扩展体系仍是后续目标;Rust `bat` 已持有
|
||||
Glossary domain/feature contract V1(SQLite persistence schema V2),Web 术语协作视图仍待建设**;
|
||||
Translation Memory persistence schema V2 已由 Rust `bat` 持有,不能从目标架构图推断 Go
|
||||
侧拥有第二份状态。
|
||||
|
||||
---
|
||||
|
||||
## 后果
|
||||
|
||||
- 资源同步只有一个长期状态拥有者,`bat-api` 可以安全地横向扩展为只读服务。
|
||||
- Rust RPC、release layout、manifest 和 `status/status_code` 成为跨语言稳定契约。
|
||||
- Go 侧新增控制接口必须经过白名单和 RPC schema 复核。
|
||||
- 完整业务 API 和协作后台未来落地时,仍需遵守 Rust `bat` 对资源状态的所有权。
|
||||
|
||||
---
|
||||
|
||||
## 当前验证依据
|
||||
|
||||
- `infrastructure/src/bin/bat/`
|
||||
- `infrastructure/src/official_update.rs`
|
||||
- `internal/backendrpc/`
|
||||
- `cmd/bat-api/`
|
||||
- `docs/reference/rpc-backend-api.md`
|
||||
- `internal/api/testdata/contract/`
|
||||
@@ -1,8 +1,9 @@
|
||||
# AssetBundle 与资源解析路线图
|
||||
|
||||
- **更新时间**:2026-07-26
|
||||
- **更新时间**: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。
|
||||
- **开发状态**:解析扩展当前按路线图和真实回归继续推进。
|
||||
|
||||
---
|
||||
|
||||
@@ -27,11 +28,11 @@
|
||||
| 层级 | 输入 | 输出 | 当前状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| 官方 seed manifest | `TableCatalog.bytes`、`BundlePackingInfo.bytes`、`MediaCatalog.bytes` | 完整下载 URL、相对路径、hash 校验边界 | 已用于下载计划,仍需沉淀更多结构化字段 |
|
||||
| Addressables catalog | `catalog_*.zip` 内 JSON/bin catalog、`catalog_*.hash` | asset path、provider、dependencies、size、CRC、bundle name | JSON/compact 当前样本已覆盖,仍需更多真实结构变体 |
|
||||
| UnityFS container | `.bundle`、zip 内 bundle | header、block、directory、解压文件、基础摘要 | 已支持基础解包、LZ4/LZMA、边界校验 |
|
||||
| Addressables catalog | `catalog_*.zip` 内 JSON/bin catalog、`catalog_*.hash` | asset path、provider、dependencies、size、CRC、bundle name | JSON/compact 当前目标字段已覆盖;未知结构返回明确错误 |
|
||||
| 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 发布前解析 | 已翻译文本、中间格式、原版资源 | 可验证 patch manifest、汉化 release 目录 | UnityFS TextAsset 前置已落地,通用 Binary/JSON/Text Patch 与文件级 patch / UnityFS 写入入口可用,发布级 build/rollback 未开放 |
|
||||
| Patch 发布 | 已翻译 TextUnit、中间格式、原版资源 | 可验证 localized patch manifest、汉化 release 目录、current/state | generic manifest 已驱动 Binary/JSON/Text 与当前支持的 UnityFS 操作;可验证 ZIP 内 bundle 时会在外层重写后重新读取、重解析并校验定位字段/替换值 |
|
||||
|
||||
---
|
||||
|
||||
@@ -39,23 +40,26 @@
|
||||
|
||||
`crates/bat-assetbundle` 已经承担解析核心:
|
||||
|
||||
1. `UnityFsParser` 解析 UnityFS header、block info、directory。
|
||||
1. `UnityFsParser` 解析 UnityFS header、block info、directory,并校验声明总大小与实际文件大小。
|
||||
2. 支持 LZ4/LZMA block info 和数据 block 解压。
|
||||
3. 支持 block info at end 和官方样本中出现的 block data alignment。
|
||||
4. 能从 UnityFS directory 提取文件 bytes。
|
||||
5. `serialized` 模块能读取 Unity serialized file header、type table、TypeTree node 元数据、object table。
|
||||
6. 能提取 TextAsset 的 name 和原始 bytes。
|
||||
7. TypeTree field reader 已支持基础标量、string、bytes、array、vector/staticvector 嵌套 `Array` 形态、`List<T>` / `HashSet<T>` 集合 alias、map、PPtr、enum `value__` backing field、`LayerMask` / `BitField` 的 `m_Bits` backing field、嵌套对象、常见固定 Unity float/int/hash 值类型的 leaf 和 direct child TypeTree 形态、unknown fixed-size raw bytes 保留和同长度替换、TypeTree-covered managed reference、TypeTree-covered managed reference registry 记录、`m_ManagedReferences` / `RefIds` / `m_RefIds` / verbose type 字段等 registry 命名变体、`id` / `typeInfo` 等 metadata 命名变体、`data` / `value` / `payload` / `object` / `managedReferencePayload` / `referencePayload` / `serializedReferencePayload` / `managedReferenceValue` / `referenceValue` / `serializedReferenceValue` / `managedReferenceObject` / `referenceObject` / `serializedReferenceObject` / `managedReferenceData` / `referenceData` / `serializedData` 等 payload 命名变体、managed-reference full typename 拆解和字段 offset/size 诊断。
|
||||
8. `TextUnitExtractor` 已把 JSON/CSV/TSV/plain TextAsset、TypeTree 字符串字段和 TypeTree-covered managed reference payload 字符串输出为可序列化 TextUnit/JSONL;zip 场景保留 archive entry,TextUnit 明细包含 serialized file、path id、class id、field path、字段 offset/byte size、format、asset name 和上下文。managed-reference 类型元数据保留为 payload context,不进入翻译文本队列;即使 registry 暂时只能走 fallback 字段遍历,`RefIds`、`className`、`namespaceName`、`asmName` 等元数据别名也会被跳过,payload/value/object 家族和 `managedReferenceData` / `referenceData` / `serializedData` 仍按 payload 处理,并按 `RefIds[n]` 等记录前缀或子字段推导 metadata,避免多条 fallback record 混用 managed-reference context。
|
||||
9. `ResourceImportService` 能把 AssetBundle 摘要、TextAsset/Table/Media 分类和 TextUnit 摘要写入导入报告。
|
||||
10. 官方同步后 `OfficialParseCacheService` 能从 `official-download-manifest.json` 遍历所有资源,解析直接 bundle 和 zip 内条目,非候选资源记录为 unsupported,并缓存 TextUnit 数量/格式/诊断摘要,同时写出 `official-textunit-index.json` 供 `parse.text_units` / `parse.errors` 查询。
|
||||
4. 能从 UnityFS directory 提取文件 bytes,并拒绝越界、重复或不安全路径。
|
||||
5. 对 block/directory 计数先按剩余字节做有界检查,避免损坏输入触发超大内存分配。
|
||||
6. `serialized` 模块能读取 Unity serialized file header、type table、TypeTree node 元数据、object table。
|
||||
7. 能提取 TextAsset 的 name 和原始 bytes。
|
||||
8. TypeTree field reader 已支持基础标量、string、bytes、array、vector/staticvector 嵌套 `Array` 形态、`List<T>` / `HashSet<T>` 集合 alias、map、PPtr、enum `value__` backing field、`LayerMask` / `BitField` 的 `m_Bits` backing field、嵌套对象、常见固定 Unity float/int/hash 值类型的 leaf 和 direct child TypeTree 形态、unknown fixed-size raw bytes 保留和同长度替换、TypeTree-covered managed reference、TypeTree-covered managed reference registry 记录、`m_ManagedReferences` / `RefIds` / `m_RefIds` / verbose type 字段等 registry 命名变体、`id` / `typeInfo` 等 metadata 命名变体、`data` / `value` / `payload` / `object` / `managedReferencePayload` / `referencePayload` / `serializedReferencePayload` / `managedReferenceValue` / `referenceValue` / `serializedReferenceValue` / `managedReferenceObject` / `referenceObject` / `serializedReferenceObject` / `managedReferenceData` / `referenceData` / `serializedData` 等 payload 命名变体、managed-reference full typename 拆解和字段 offset/size 诊断。
|
||||
9. `TextUnitExtractor` 已把 JSON/CSV/TSV/plain TextAsset、TypeTree 字符串字段和 TypeTree-covered managed reference payload 字符串输出为可序列化 TextUnit/JSONL;zip 场景保留 archive entry,TextUnit 明细包含 serialized file、path id、class id、field path、字段 offset/byte size、format、asset name 和上下文。managed-reference 类型元数据保留为 payload context,不进入翻译文本队列;即使 registry 暂时只能走 fallback 字段遍历,`RefIds`、`className`、`namespaceName`、`asmName` 等元数据别名也会被跳过,payload/value/object 家族和 `managedReferenceData` / `referenceData` / `serializedData` 仍按 payload 处理,并按 `RefIds[n]` 等记录前缀或子字段推导 metadata,避免多条 fallback record 混用 managed-reference context。
|
||||
10. `ResourceImportService` 能把 AssetBundle 摘要、TextAsset/Table/Media 分类和 TextUnit 摘要写入导入报告。
|
||||
11. 官方同步后 `OfficialParseCacheService` 能从 `official-download-manifest.json` 遍历所有资源,解析直接 bundle 和 zip 内条目,非候选资源记录为 unsupported,并缓存 TextUnit 数量/格式/诊断摘要,同时写出 `official-textunit-index.json` 供 `parse.text_units` / `parse.errors` 查询。
|
||||
12. `rebuild_unityfs_bundle` 保留当前已验证 bundle 的 header 版本、directory 顺序/路径/flags、block 数量/压缩模式、block-info-at-end 和 block-data alignment;变长目录文件会重新计算目录 offset、block size、block-info hash 和总大小,未知压缩模式明确拒绝。
|
||||
13. 重建后会重解析并校验未修改 directory 文件、serialized object table、未修改 object raw bytes,以及目标对象的未修改 TypeTree 字段;不能证明保真的结构不会发布。
|
||||
|
||||
当前还不能宣称完整:
|
||||
|
||||
1. TypeTree-covered managed reference 字段和 registry 记录已可结构化解码并参与文本提取,常见 registry 命名别名(含 `m_ManagedReferences`、`RefIds`、`m_RefIds`、verbose type 字段)、metadata 命名别名(含 `id`、`typeInfo`)、payload 命名别名(含 `data`、`value`、`payload`、`object`、`managedReferencePayload`、`referencePayload`、`serializedReferencePayload`、`managedReferenceValue`、`referenceValue`、`serializedReferenceValue`、`managedReferenceObject`、`referenceObject`、`serializedReferenceObject`、`managedReferenceData`、`referenceData`、`serializedData`)、full typename 拆解和 payload-only TextUnit 提取已有回归覆盖,多记录 registry 聚合也已有单元回归;fallback 字段遍历会跳过常见 registry 元数据字符串,避免误入翻译队列,并按记录前缀或子字段可推导 metadata 保留 managed-reference TextUnit context。enum `value__` backing field 和 `LayerMask` / `BitField` 的 `m_Bits` backing field 已可语义化解码和替换;`Vector2f/3f/4f`、`Quaternionf`、`ColorRGBA`、`Rectf`、`AABB/Bounds/Ray`、`Matrix4x4f`、`Vector2Int/Vector3Int`、`RectInt`、`BoundsInt`、`RangeInt`、`GUID`、`Hash128` 等固定 Unity 值类型的 leaf 和 direct child TypeTree 形态已可结构化解码和语义替换;array/vector/staticvector/List/HashSet/map 元素与 registry payload 字段已保留独立 field path、offset 和 byte size,可用于字符串元素 patch,managed-reference registry payload 字符串、enum、bit_field、unknown fixed-size raw bytes、object 字段组合和 TypeTree schema 支撑的 array/vector/List/HashSet/map 已可整体变长替换,`first/second` 与 `key/value` map entry schema 已有 serialized 和 UnityFS 重建回归,ScriptableObject `key/value` map 解析、变长替换和 UnityFS 重建已有专门回归,且嵌套 vector `Array`、`List<T>` / `HashSet<T>` 集合 alias、enum、bit_field、unknown fixed-size raw bytes 与 managed-reference payload 字段已有重建回归覆盖;解析模块当前处于维护冻结,未见样本驱动的完整 managed reference registry / map entry 变体、unknown 字段结构语义和版本差异冻结后再推进。
|
||||
2. Addressables bin/compact catalog 结构变体仍需真实样本驱动补齐。
|
||||
1. TypeTree-covered managed reference 字段和 registry 记录已可结构化解码并参与文本提取,常见 registry 命名别名(含 `m_ManagedReferences`、`RefIds`、`m_RefIds`、verbose type 字段)、metadata 命名别名(含 `id`、`typeInfo`)、payload 命名别名(含 `data`、`value`、`payload`、`object`、`managedReferencePayload`、`referencePayload`、`serializedReferencePayload`、`managedReferenceValue`、`referenceValue`、`serializedReferenceValue`、`managedReferenceObject`、`referenceObject`、`serializedReferenceObject`、`managedReferenceData`、`referenceData`、`serializedData`)、full typename 拆解和 payload-only TextUnit 提取已有回归覆盖,多记录 registry 聚合也已有单元回归;fallback 字段遍历会跳过常见 registry 元数据字符串,避免误入翻译队列,并按记录前缀或子字段可推导 metadata 保留 managed-reference TextUnit context。enum `value__` backing field 和 `LayerMask` / `BitField` 的 `m_Bits` backing field 已可语义化解码和替换;`Vector2f/3f/4f`、`Quaternionf`、`ColorRGBA`、`Rectf`、`AABB/Bounds/Ray`、`Matrix4x4f`、`Vector2Int/Vector3Int`、`RectInt`、`BoundsInt`、`RangeInt`、`GUID`、`Hash128` 等固定 Unity 值类型的 leaf 和 direct child TypeTree 形态已可结构化解码和语义替换;array/vector/staticvector/List/HashSet/map 元素与 registry payload 字段已保留独立 field path、offset 和 byte size,可用于字符串元素 patch,managed-reference registry payload 字符串、enum、bit_field、unknown fixed-size raw bytes、object 字段组合和 TypeTree schema 支撑的 array/vector/List/HashSet/map 已可整体变长替换,`first/second` 与 `key/value` map entry schema 已有 serialized 和 UnityFS 重建回归,ScriptableObject `key/value` map 解析、变长替换和 UnityFS 重建已有专门回归,且嵌套 vector `Array`、`List<T>` / `HashSet<T>` 集合 alias、enum、bit_field、unknown fixed-size raw bytes 与 managed-reference payload 字段已有重建回归覆盖;后续仍需继续补齐真实样本驱动的完整 managed reference registry / map entry 变体、unknown 字段结构语义和版本差异。
|
||||
2. Addressables 当前目标 JSON/compact 字段链已补齐;未识别的独立二进制格式仍返回明确错误,不静默降级。
|
||||
3. 官方 release 已可配置导入 CAS + ResourceRepository,并可通过 `resource.index` 查询现有资源索引;Resource metadata 已记录 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 摘要。
|
||||
4. 不能完成复杂对象字段重打包,也不能从真实 Crowdin 结果自动生成完整汉化文件集合。
|
||||
4. 尚未覆盖所有真实 Unity 版本、未知字段语义和任意复杂 AssetBundle 结构,也不能从真实 Crowdin 结果自动生成完整汉化文件集合;当前仅对已有真实/合成回归覆盖的结构宣称支持。
|
||||
|
||||
---
|
||||
|
||||
@@ -97,6 +101,11 @@
|
||||
4. 将 Windows/Android catalog 样本拆成可复现 fixture,不把大文件纳入 Git。
|
||||
5. 对未知结构返回明确错误或保真 raw metadata,不静默丢字段。
|
||||
|
||||
当前 JSON/compact 字段链已完成:provider ID、bundle name、
|
||||
primary/dependency key、resource type、hash、size 和 CRC 会进入 `ResourceEntry`,
|
||||
并通过 SQLite `ResourceRepository` 持久化;旧索引会按列迁移继续可读。独立二进制
|
||||
catalog 仍按“明确不支持”处理,不把低保真路径伪装成完整解析。
|
||||
|
||||
验收:
|
||||
|
||||
1. 当前目标版本 Windows/Android catalog 样本集合解析通过。
|
||||
@@ -147,7 +156,7 @@
|
||||
|
||||
1. 官方同步完成后可配置触发导入 CAS + ResourceRepository(已具备 `--import-repository` / `BAT_IMPORT_REPOSITORY=1`)。
|
||||
2. ResourceRepository 已保存官方 manifest 资源的类型、路径、hash、size 和 metadata;metadata 包含 release、平台、bundle path、parse status、TextAsset 名称、TextUnit 数量/格式。
|
||||
3. 支持 RPC/CLI 查询资源、bundle、TextAsset、解析错误和缓存状态;当前 `resource.index` 会返回资源 metadata,`parse-status` 会返回 TextUnit 索引和队列摘要,`parse-text-units` / `parse-errors` 会按当前 release 查询明细,`localized-status` 会校验 patch manifest。
|
||||
3. 支持 RPC/CLI 查询资源、bundle、TextAsset、解析错误和缓存状态;当前 `resource.index` 会返回资源 metadata,`parse-status` 会返回 TextUnit 索引和队列摘要,`parse-text-units` / `parse-errors` 会按当前 release 查询明细,`localized-status` 会区分 patch manifest contract 与 artifact integrity,`release.status/list/distribution/cleanup` 提供 Rust-owned 双 release 视图和安全运维操作。
|
||||
4. schema 迁移可重复执行;当前 SQLite 已有 `crc` 和 `metadata_json` 兼容迁移。
|
||||
|
||||
验收:
|
||||
@@ -156,23 +165,23 @@
|
||||
2. 解析缓存、资源变更集和 repository 数据能从同一 manifest fingerprint 追溯。
|
||||
3. CAS 对象跨版本复用,不重复存储相同文件。
|
||||
|
||||
### P5:Patch 发布前置解析
|
||||
### P5:Patch 发布
|
||||
|
||||
目标:让解析结果成为可生成汉化 patch 的输入。
|
||||
目标:让解析结果成为可生成、校验和回滚汉化 patch 的输入。
|
||||
|
||||
交付:
|
||||
|
||||
1. 已定义 `localized-patch-manifest.json`:目标官方版本、localized release、输出文件、hash、size、byte delta、TextAsset 操作和回滚信息。
|
||||
2. 已支持 UnityFS TextAsset raw bytes 替换的最小 patch 路径。
|
||||
3. MonoBehaviour/ScriptableObject 字段替换必须依赖 P2 字段级解析结果。
|
||||
4. Patch 产物写入配置化汉化发布根下的 `.staging/<id>`,校验通过后发布到 `versions/<id>` 并切换 `current`。
|
||||
5. 成功后发布状态从 `not_localized` 切到 `localized`;`localized.status` 要求 state、current symlink 和 patch manifest 同时匹配当前官方 release。
|
||||
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 字段级解析结果;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,并分别报告 schema/contract 与 artifact integrity,ZIP 外层文件和内层 UnityFS 也必须通过发布后重解析校验;current 仍存在但产物损坏时返回 `localized.degraded`,不自动修复。
|
||||
|
||||
验收:
|
||||
|
||||
1. Patch 失败不影响 `bat-resources/current`。
|
||||
2. 汉化 release 保留官方相对目录结构。
|
||||
3. `localized` 状态能证明原版和汉化两套资源都已发布,且 patch manifest 可验证。
|
||||
3. `localized` 状态能证明原版和汉化两套资源都已发布,且 generic/localized patch manifest 可验证。
|
||||
|
||||
---
|
||||
|
||||
@@ -195,12 +204,13 @@
|
||||
|
||||
---
|
||||
|
||||
## 7. 近期关闭路径
|
||||
## 7. 后续推进路径
|
||||
|
||||
优先顺序:
|
||||
|
||||
1. 完成 Addressables Windows/Android 当前版本 catalog 样本集合,关闭 G-007 当前阶段。
|
||||
2. 完成 TypeTree 字段 reader 和 MonoBehaviour/ScriptableObject 遍历,推进 G-005。
|
||||
3. 将 `crowdin-textunit-queue.json` 接入真实 Crowdin worker、翻译记忆和 Patch 构建。
|
||||
4. 将翻译任务状态接入 CAS/ResourceRepository 查询面,推进 G-011。
|
||||
5. 在通用 Binary/JSON/Text Patch 基础上继续扩展复杂 AssetBundle 重打包和发布流程统一,保留当前 UnityFS TextAsset patch 发布前置链路。
|
||||
1. 继续补充 Addressables Windows/Android 真实 catalog 样本和独立二进制格式诊断。
|
||||
2. 继续补充 TypeTree 字段 reader、MonoBehaviour/ScriptableObject 遍历和真实版本差异。
|
||||
3. 基于 `translation.worker.run` 继续扩展 TM/Glossary provenance 和真实资源发布样本。
|
||||
4. 扩展翻译任务结果在 CAS/ResourceRepository 查询面的索引。
|
||||
5. 在 generic Binary/JSON/Text Patch 基础上继续扩展复杂 AssetBundle 重打包,但只在
|
||||
新结构有真实 fixture 和完整重建验证时接入;不扩大当前 UnityFS V1 的宣称范围。
|
||||
|
||||
@@ -146,6 +146,12 @@
|
||||
17. 旧 launcher 包或 `resources.assets` 下载使用官方 launcher CDN 配置,primary CDN 失败后切换 official backup CDN;资源 patch host 不猜测非官方镜像。
|
||||
18. 记录最终文件大小、本次传输字节数、官方 hash 校验数和执行状态。
|
||||
19. 非官方 URL 直接拒绝。
|
||||
20. 下载调度默认并发数为 `8`,允许范围是 `1..=256`,由
|
||||
`--download-concurrency` / `BAT_DOWNLOAD_CONCURRENCY` 配置。worker 从共享
|
||||
plan 队列逐项领取任务,单个任务完成后立即领取下一个,不等待其他 worker
|
||||
的当前任务;完成结果在协调线程即时更新 manifest、hash 事件和进度计数。
|
||||
最终 `OfficialResourcePullReport.items` 仍按 `OfficialResourcePullPlan`
|
||||
顺序排列,避免并发完成顺序泄露到发布和 API 读侧。
|
||||
|
||||
路径映射时会做分段清理,并在写入前做输出目录安全校验、相对路径归属校验和现有路径组件 symlink 检查,避免把不安全路径写进输出目录或通过 symlink 跳出输出目录。
|
||||
|
||||
@@ -156,7 +162,7 @@
|
||||
### 3.5 导入到 CAS 和资源仓储
|
||||
|
||||
官方同步下载、校验并发布 release 后,可以通过 `--import-repository` 或
|
||||
`.env` 中 `BAT_IMPORT_REPOSITORY=1` 自动触发 CAS + `ResourceRepository`
|
||||
`config.toml` / 环境变量 `BAT_IMPORT_REPOSITORY=1` 自动触发 CAS + `ResourceRepository`
|
||||
导入:
|
||||
|
||||
1. 读取已发布 release 下的 `official-download-manifest.json`。
|
||||
@@ -171,8 +177,17 @@
|
||||
|
||||
这层的意义是把“下载到磁盘的文件”变成“可查询、可复用、可去重”的资源对象。
|
||||
`resource.index` RPC / CLI 只读查询现有 SQLite 索引;索引不存在时返回
|
||||
`available=false`,不会因为查询创建空库。G-011 剩余工作是翻译任务状态、
|
||||
CAS 诊断入口和更丰富查询。
|
||||
`available=false`,不会因为查询创建空库。发布后的 TextUnit 队列还会在当前
|
||||
release 根目录写入 `translation-tasks.sqlite`,由版本化 `schema_migrations`
|
||||
管理 V2 queued/running/failed/completed/skipped、provider run、lease、失败分类、
|
||||
重试计划和 TextUnit 级译文结果。跨 release 的 Translation Memory persistence schema V2
|
||||
独立存储在 `<output>/translation-memory.sqlite`,记录 raw source/hash、完整 context、
|
||||
candidate/trusted 和 release/TextUnit/provider/run provenance;`translation.tasks` 优先查询这份状态库,
|
||||
`translation.worker.run` 由 Rust worker 回写状态;`translation.task.update` 仍供外部 provider 流程回写状态;
|
||||
没有状态库的旧 release 才回退到 immutable JSON 队列。`bat doctor cas`
|
||||
已提供只读 CAS 根目录、对象目录、元数据库文件和对象统计诊断;`resource.index`
|
||||
已把 release、平台、bundle path 和常用数组 metadata 过滤下推到 SQLite。G-011
|
||||
剩余工作是更丰富的 TextUnit/TM 查询和通用 Patch 发布所需资源视图。
|
||||
|
||||
对应实现主要在:
|
||||
|
||||
@@ -206,7 +221,9 @@ CAS 诊断入口和更丰富查询。
|
||||
|
||||
集成边界:
|
||||
|
||||
1. 当前生产集成路径是 Rust `bat --watch` / `bat --daemon` 持久运行;Go `bat-api` 应优先通过 `internal/backendrpc` 调用 daemon RPC,one-shot/fallback 场景才运行 `bat --json` 并消费结构化 report。
|
||||
1. 当前生产集成路径是 Rust `bat --watch` / `bat --daemon` 持久运行;Go `bat-api`
|
||||
通过 `internal/backendrpc` 调用 daemon RPC,读取已发布 release 和状态,不运行
|
||||
另一套同步器。`bat --json` 只表示 Rust CLI 的机器输出形态。
|
||||
2. systemd、容器或上层 Go 进程只负责守护 `bat --watch` / `bat --daemon`,不直接接管下载器内部状态。
|
||||
3. `bat-ffi` 只允许作为可选无状态 C ABI 兼容层,用于 Manifest inspect 和 sync plan 这类一次性 JSON helper;它不是官方同步 daemon、下载器、资源锁、CAS handle 或主控制面的承载位置。
|
||||
|
||||
@@ -221,22 +238,27 @@ CAS 诊断入口和更丰富查询。
|
||||
7. 如果远端 snapshot 未变化但输出目录没有任何当前 plan 的本地资源,仍按首次运行处理并执行全量拉取。
|
||||
8. 远端无变化且本地已有资源时执行 download manifest audit,检查路径、size、BLAKE3 和 ZIP 结构。
|
||||
9. 远端变化、本地 audit 发现 repair_needed,首次空目录运行,或缺少 `current` 原子发布指针时,进入下载/发布流程。
|
||||
10. 下载先写入 `<output>/.staging/<id>`;若已有 active release,会先 seed staging 以复用已验证文件;若 version-state 中存在同一版本的失败 staging,则优先复用该 staging 并跳过 active seed,避免旧 active 覆盖已下载的新文件。
|
||||
11. 下载、manifest、本地 BLAKE3、ZIP 和官方 `.hash` 校验完成后写入新的 snapshot,并在 staging 中写入 `official-launcher-bootstrap.json`(若本轮启用 `--auto-discover`)。
|
||||
10. 下载先写入 `<output>/.staging/<id>`;若已有 active release,会先 seed staging 以复用已验证文件;若 version-state 中存在同一版本的失败 staging,则优先复用该 staging 并跳过 active seed,避免旧 active 覆盖已下载的新文件。新 staging 还会扫描已发布 release 的下载 manifest,按规范化 destination 查找候选并重新验证 size、BLAKE3 和 ZIP 结构;硬链接失败时回退到临时文件复制和原子 rename,历史 release 保持不可变。
|
||||
11. 下载、manifest、本地 BLAKE3、ZIP 和官方 `.hash` 校验完成后,在 staging 中写入独立的 `official-distribution-publication.json` 发布事实和新的 snapshot,并写入 `official-launcher-bootstrap.json`(若本轮启用 `--auto-discover`)。publication 文件关联 official release ID、完整 mapping identity、manifest content identity 和 entry count;缺少或不匹配时 release 不可作为 distribution-ready。
|
||||
12. 将 staging rename 为 `<output>/versions/<id>`,再原子替换 `<output>/current` symlink 指向该 versioned 目录。
|
||||
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`;up-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析。
|
||||
15. 若启用 `--import-repository`,已校验 release 会被导入 CAS + `ResourceRepository`,并可经 `resource.index` 查询。
|
||||
16. 官方同步报告默认给出 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布;UnityFS TextAsset patch 发布成功并通过 `localized-patch-manifest.json`、current symlink 和 release ID 校验后,`localized.status` 才返回 `localized`,表示原版和汉化两套资源都已发布。
|
||||
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 状态。历史 release 复用只允许不可变资源 payload/sidecar 硬链接;download manifest、snapshot、parse/textunit cache、queue、handoff、bootstrap、CAS reuse references 以及 `translation-tasks.sqlite`、WAL/SHM 都必须独立复制,不能共享可变 inode。
|
||||
15. 若启用 `--import-repository`,已校验 release 会被导入 CAS + `ResourceRepository`,并可经 `resource.index` 查询。历史 release 候选失效时,已有 CAS 对象会先经过完整性和元数据校验,再增加 release 引用并原子物化;当前 release 在 `official-cas-reuse-references.json` 中记录引用和首次生成后持久化的 `ownership_id`,staging/release 清理时按 `ownership_id + ordinal` 递减。旧无 identity 清单按 output-root scope、稳定 source mapping identity 和 generation counter 迁移;已有 basename ledger 的部分 cleanup 继续使用兼容 key,直到该 generation 完成,后续同名 generation 不复用该 key,失败则回退网络并保留诊断。
|
||||
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`,表示原版和汉化两套资源都已发布。`localized.status` 分开返回 `patch_manifest_contract_status` 与 `artifact_integrity_status`;state/current/identity 存在但文件被截断或手工修改时返回 `localized.degraded`,只读检查不回滚、不删除、不修复。`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 证据,供后续排障和自研客户端开发使用。
|
||||
|
||||
该入口不安装、不执行官方启动器,也不读取生产外的本地客户端目录。Rust 正式 binary `bat` 支持单次运行、`--watch` 常驻模式、`--daemon` 后台模式,以及 `status`、`stop`、`restart`、`reload`、`logs`、`refresh`、`verify`、`repair`、`doctor`、`clean-stable` 管理命令。`--daemon` 会在后台状态目录下创建 `bat.sock`,使用 Unix socket JSON-RPC 作为 live control plane;`bat.pid`、`bat-status.json` 和 `bat-daemon.log` 是快照、诊断和兼容 fallback;`bat-events.jsonl` 是带轮转的结构化 JSONL 事件日志;`bat-control.lock` 串行化控制命令,并在 stale/corrupt 时由下一次控制命令或 `clean-stable` 恢复。`bat-status.json` 和 `status` 子命令包含最后成功时间、下次检查时间、最后错误摘要、当前阶段和当前下载 URL 进度。PID、status、log 和控制锁文件创建时使用私有权限,读取和写入时不跟随 symlink。`status`、`stop`、`logs`、`reload`、默认形态的 `refresh` 和默认形态的 `repair` 优先走 RPC;`reload` 会唤醒或排队 watch 循环重新自动发现并强制刷新,默认 `repair` 会通过 `resource.repair` 入队本地 manifest 审计+修复任务,`restart` 才负责重启进程或替换启动参数;显式 `--proxy` / `--no-proxy` 会作为启动参数保存并在后台重启时复用。后台 daemon 管理某个资源目录时,前台 `run/watch/refresh/repair` 不允许直接写入同一目录;默认形态 `refresh` 会通过 RPC 触发后台刷新,默认形态 `repair` 会通过 RPC 入队任务。正常情况下默认每 1 小时执行一次检查;每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 会中断普通 sleep 并强制执行一次自动刷新,该轮注入 `force=true`。远端和本地一致时静默等待下次检查,不一致时自动下载或 repair。下载、发现或校验失败时不等待完整正常周期,默认 60 秒后重试;如果固定时间强制刷新失败,会保留 pending force 并按失败重试周期继续重试,可用 `--error-retry` 或 `--error-retry-seconds` 调整。默认官方原版资源输出目录是 `./bat-resources`,默认汉化产物目录是 `./bat-localized`,默认后台状态目录是 `/tmp/bat-pid`,三者分别通过 `--output`、`--localized-output` 和 `--state-dir` 配置;官方目录和汉化目录不能相同或互相嵌套。单次运行仍保留为核心幂等路径,systemd service、容器或 Go 进程可以只负责守护该常驻进程;cron/systemd timer 调单次模式只是可选集成方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。
|
||||
该入口不安装、不执行官方启动器,也不读取生产外的本地客户端目录。Rust 正式 binary `bat` 支持单次运行、`--watch` 常驻模式、`--daemon` 后台模式,以及 `status`、`stop`、`restart`、`reload`、`logs`、`refresh`、`verify`、`repair`、`doctor`、`clean-stable` 管理命令。`--daemon` 会在后台状态目录下创建 `bat.sock`,使用 Unix socket JSON-RPC 作为 live control plane;`bat.pid`、`bat-status.json` 和 `bat-daemon.log` 是快照、诊断和兼容 fallback;`bat-events.jsonl` 是带轮转的结构化 JSONL 事件日志;`bat-control.lock` 串行化控制命令,并在 stale/corrupt 时由下一次控制命令或 `clean-stable` 恢复。`bat-status.json` 和 `status` 子命令包含最后成功时间、下次检查时间、最后错误摘要、当前阶段和当前下载 URL 进度。PID、status、log 和控制锁文件创建时使用私有权限,读取和写入时不跟随 symlink。`status`、`stop`、`restart`、`logs`、`reload`、默认形态的 `refresh` 和默认形态的 `repair` 优先走 RPC;`reload` 会唤醒或排队 watch 循环重新自动发现并强制刷新,默认 `repair` 会通过 `resource.repair` 入队本地 manifest 审计+修复任务,live RPC `restart` 会启动 Rust lifecycle controller 并复用 CLI restart 路径替换进程;显式 `--proxy` / `--no-proxy` 会作为启动参数保存并在后台重启时复用。后台 daemon 管理某个资源目录时,前台 `run/watch/refresh/repair` 不允许直接写入同一目录;默认形态 `refresh` 会通过 RPC 触发后台刷新,默认形态 `repair` 会通过 RPC 入队任务。正常情况下默认每 1 小时执行一次检查;每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 会中断普通 sleep 并强制执行一次自动刷新,该轮注入 `force=true`。远端和本地一致时静默等待下次检查,不一致时自动下载或 repair。下载、发现或校验失败时不等待完整正常周期,默认 60 秒后重试;如果固定时间强制刷新失败,会保留 pending force 并按失败重试周期继续重试,可用 `--error-retry` 或 `--error-retry-seconds` 调整。默认官方原版资源输出目录是 `./bat-resources`,默认汉化产物目录是 `./bat-localized`,默认后台状态目录是 `/tmp/bat-pid`,三者分别通过 `--output`、`--localized-output` 和 `--state-dir` 配置;官方目录和汉化目录不能相同或互相嵌套。单次运行仍保留为核心幂等路径,systemd service、容器或 Go 进程可以只负责守护该常驻进程;cron/systemd timer 调单次模式只是可选集成方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。
|
||||
|
||||
对应实现主要在:
|
||||
|
||||
- `infrastructure/src/official_update.rs`
|
||||
- `infrastructure/src/bin/bat_official_sync.rs`
|
||||
- `infrastructure/src/bin/bat_official_sync.rs`(薄入口)
|
||||
- `infrastructure/src/bin/bat/app.rs`(控制面组合)
|
||||
- `infrastructure/src/bin/bat/report_output.rs`、`terminal_output.rs`(前台报告和终端输出)
|
||||
- `infrastructure/src/bin/bat/task_registry.rs`(任务注册表、持久化和 worker)
|
||||
- `infrastructure/src/bin/bat/readonly_query.rs`、`translation_query.rs`(只读查询)
|
||||
- `infrastructure/src/bin/bat/patch_commands.rs`(patch 命令)
|
||||
- `infrastructure/examples/official_update_check.rs`(历史/开发入口)
|
||||
|
||||
## 4. 官方 bootstrap 与用户流程
|
||||
@@ -269,10 +291,10 @@ Linux 生产路径:
|
||||
- pull plan 会同时包含 discovery URLs 和 content URLs
|
||||
- 全量样本下是 `2` 个 discovery URL + `5` 个内容 URL = `7` 个 URL
|
||||
- `OfficialUpdateService` 能持久化 v2 snapshot,并在远端 marker 内容变化时触发下载决策
|
||||
- `bat` 默认向 stderr 输出 `BlueArchiveToolkit` ASCII banner 和 progress log,stdout 默认输出人类可读摘要;progress log 覆盖代理决策、下载已完成计数、单文件开始/完成状态、下载中断失败分类和校验结果摘要;支持 `--proxy` / `--no-proxy` 控制 curl 传输代理,支持 `--json` 输出稳定 JSON,支持 `--no-progress` 关闭进度日志,支持 `--no-banner` 只关闭横幅,支持 `--watch --interval 1h --error-retry 60s` 常驻运行,支持 `--daemon` Unix socket JSON-RPC live control/backend(`daemon.status/logs/stop/reload/refresh/doctor`、`resource.sync/verify/repair/state/manifest/list/index`、`parse.status/text_units/errors`、`localized.status`、`catalog.*`、`task.*`);`restart` 与 `clean-stable` 仍由 CLI 侧按进程生命周期显式执行,非 dry-run 使用 `.official-sync.lock` 防止并发写资源目录,控制命令使用 `bat-control.lock` 防止并发状态修改,资源发布使用 `.staging`、`versions` 和 `current` 原子切换,daemon 写 `bat-events.jsonl` 结构化日志并在 `status` 中暴露下载进度、失败类型、HTTP 状态和调度状态
|
||||
- `bat` 默认向 stderr 输出 `BlueArchiveToolkit` ASCII banner 和 progress log,stdout 默认输出人类可读摘要;progress log 覆盖代理决策、下载已完成计数、单文件开始/完成状态、下载中断失败分类和校验结果摘要;支持 `--proxy` / `--no-proxy` 控制 curl 传输代理,支持 `--json` 输出稳定 JSON,支持 `--no-progress` 关闭进度日志,支持 `--no-banner` 只关闭横幅,支持 `--watch --interval 1h --error-retry 60s` 常驻运行,支持 `--daemon` Unix socket JSON-RPC live control/backend(`daemon.status/logs/stop/restart/reload/refresh/doctor`、`resource.sync/verify/repair/state/manifest/list/index`、`parse.status/text_units/errors`、`translation.tasks/handoff/task.update`、`localized.status`、`catalog.*`、`task.*`、文件级 `patch.apply` / `unityfs.patch_*`);`restart` 通过 Rust lifecycle controller 复用 CLI restart 路径,`clean-stable` 仍由 CLI 侧按进程生命周期显式执行,非 dry-run 使用 `.official-sync.lock` 防止并发写资源目录,控制命令使用 `bat-control.lock` 防止并发状态修改,资源发布使用 `.staging`、`versions` 和 `current` 原子切换,daemon 写 `bat-events.jsonl` 结构化日志并在 `status` 中暴露下载进度、失败类型、HTTP 状态和调度状态
|
||||
- curl 失败分类和重试策略已覆盖 404 不重试、5xx 重试耗尽后 quarantine、launcher primary CDN 失败后切换 official backup CDN
|
||||
- `official-version-state.json` 已覆盖当前完成版本、正在拉取版本、上一个可用版本和失败版本;同一 app version、bundle version 和 Addressables root 的失败只保留最新一条,重新拉取或成功发布后清理同版本失败记录,同版本失败 staging 会在路径安全且未发布时复用,`bat status` 会暴露版本状态摘要和最近历史失败原因
|
||||
- 资源导入链路已覆盖可配置 CAS 写入、`ResourceRepository` 索引、`metadata_json` release/平台/bundle/TextAsset/TextUnit 摘要,以及 TextAsset/Table/Media 分类;`resource.index` 可只读查询现有索引
|
||||
- 资源导入链路已覆盖可配置 CAS 写入、`ResourceRepository` 索引、`metadata_json` release/平台/bundle/TextAsset/TextUnit 摘要,以及 TextAsset/Table/Media 分类;`resource.index` 可只读查询现有索引,常用 metadata 过滤已下推到 SQLite,`bat doctor cas` 可只读诊断既有 CAS 目录和对象统计
|
||||
- 官方 release 发布后会生成 `official-resource-changes.json`、`crowdin-translation-handoff.json`、`official-parse-cache.json`、`official-textunit-index.json`、`official-textunit-tasks.json` 和 `crowdin-textunit-queue.json`,为后续增量解析和 Crowdin worker 预留稳定输入
|
||||
- 离线回归样本已覆盖当前 catalog、上一个版本 catalog、catalog 结构变化、403、404 和 seed hash mismatch
|
||||
- `OfficialUpdateService` 能读写 `official-bootstrap-cache.json`,并支持默认开启的 `audit_local` / `repair` CLI 行为
|
||||
@@ -321,21 +343,26 @@ JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边
|
||||
(版本化、`0600` 原子写,生命周期转换时落盘),daemon 重启后历史任务
|
||||
仍可经 `task.*` 查询,中断任务标记 `task_interrupted`(700005)。
|
||||
- 方法命名空间与实现状态、请求/响应示例见
|
||||
`docs/reference/rpc-backend-api.md`:`daemon.status/logs/stop/reload/refresh/doctor`、
|
||||
`docs/reference/rpc-backend-api.md`:`daemon.status/logs/stop/restart/reload/refresh/doctor`、
|
||||
`resource.state/sync/verify/repair/manifest/list/index`、`parse.status/text_units/errors`、
|
||||
`localized.status`、`catalog.*` 与 `task.status/list/cancel/logs` 已实现;
|
||||
`patch.*` / `unityfs.*` 待引擎;`task.create` 按设计暂不开放通用任务入口;
|
||||
`daemon.restart` / `daemon.clean-stable` 仍由 CLI 侧按进程生命周期显式执行。
|
||||
`translation.tasks/handoff/task.update/proofread`、`localized.status`、
|
||||
`release.status/list/distribution/cleanup`、`catalog.*` 与 `task.status/list/cancel/logs`
|
||||
已实现;文件级 `patch.apply` / `unityfs.patch_*`
|
||||
和受支持 localized publish/rollback 已实现,`archive_entry` 可验证时会重写
|
||||
外层 ZIP;通用发布级 patch 与复杂 UnityFS 语义编辑仍待后续;
|
||||
`task.create` 按设计暂不开放通用任务入口;
|
||||
`daemon.restart` 通过 Rust lifecycle controller 复用 CLI restart 路径;
|
||||
`daemon.clean-stable` 仍由 CLI 侧按进程生命周期显式执行。
|
||||
|
||||
### 7.2 Go 层职责边界
|
||||
|
||||
- Rust `bat` / daemon 是资源生产者和状态拥有者;Go `bat-api` 是资源读侧、
|
||||
bootstrap 和 HTTP 分发入口。二者之间的稳定边界是 `bat.sock` RPC 和
|
||||
`resource_root` 中已发布的只读文件。
|
||||
Rust 选择后返回的 `resource_root` 中已发布的只读文件。
|
||||
- Go 层负责:资源 bootstrap、资源内容分发(`cmd/bat-api`)、HTTP API 进程配置、
|
||||
以及通过 `internal/backendrpc` 作为 RPC client 调用本机 daemon(连接
|
||||
`bat.sock`,每行一个 JSON-RPC 请求/响应)。`cmd/bat` 仍是试验骨架,不是产品级用户 CLI。
|
||||
- **`bat-api`(资源分发,issue #19)**:
|
||||
- **`bat-api`(资源分发)**:
|
||||
- 提供 `/v1/bootstrap`,把 `bat` 的 RPC 健康、release 摘要、server-info URL、
|
||||
client-patch base 和改写后的 Addressables root 组织成启动前资源发现响应。
|
||||
- 提供 `/healthz` 作为 liveness + 最近一次 RPC refresh 诊断,提供 `/readyz`
|
||||
@@ -343,8 +370,16 @@ JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边
|
||||
- 只读提供 Rust `bat` 已发布 release 中的资源字节(官方 CDN host/path 形态)。
|
||||
- CDN path 支持 `GET` / `HEAD` / Range / 条件请求;ETag 优先使用 download
|
||||
manifest 中的 BLAKE3,响应包含 Last-Modified、Accept-Ranges 和长期缓存头。
|
||||
- 版本/清单发现优先走 RPC:先 `daemon.status`,再 `daemon.doctor`,再
|
||||
`catalog.status` / `resource.manifest`(可用 `--socket` 指定 socket 文件)。
|
||||
- 版本/清单发现优先走 RPC:先 `daemon.status`,再 `daemon.doctor`,再读取
|
||||
Rust 轻量 `release.attestation`,最后按 attested release/publication/mapping/manifest
|
||||
identity 和 verification generation 读取 `catalog.status` / `resource.manifest`(可用 `--socket` 指定 socket
|
||||
文件)。Go 不重新实现 release verifier;普通 current CDN 只有在 attestation
|
||||
fresh/ready、分页快照完整且本地只读路径检查都允许时才分发。
|
||||
- `/v1/releases`、`/v1/distribution` 和受保护的 `/admin/releases/status`、
|
||||
`/admin/releases` 只转发 `release.status/list/distribution` 的 Rust typed
|
||||
结果;localized 或历史分发不会绕过 Rust 完整性判断。
|
||||
- 受保护的 `/admin/control/release-cleanup` 只转发 Rust `release.cleanup`;
|
||||
先 dry-run 获取 `plan_id`,执行时由 Rust 重验证引用、路径和 current 保护。
|
||||
- 支持 `.env` / 环境变量配置监听端口、public base URL、RPC socket 和 RPC
|
||||
刷新周期,并预留 database/redis 键供后续 API 持久化;**不**负责资源自动拉取。
|
||||
- 可选改写 server-info 中的 `AddressablesCatalogUrlRoot` 指向自身;不伪装
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 官方资源 Release 布局与资源侧契约
|
||||
|
||||
- **更新时间**:2026-07-27
|
||||
- **更新时间**:2026-09-12
|
||||
- **用途**:冻结日服官方资源在本地发布根上的布局、URL 映射、seed 规则、`bat`/`bat-api` 关系,以及 `bat-api` 分发 path 的 1:1 对应关系。
|
||||
- **范围**:资源发现 / 清单 / 落盘 / 只读分发(**不是**完整游戏业务 API)。
|
||||
- **权威代码**:
|
||||
@@ -8,6 +8,7 @@
|
||||
- inventory 抽取:`adapters/src/official/inventory.rs`
|
||||
- 落盘与 manifest:`infrastructure/src/official_download.rs`(`destination_for_url`)
|
||||
- 发布布局:`infrastructure/src/official_update.rs`
|
||||
- 双 release 视图、分发选择与清理:`infrastructure/src/release_ops.rs`
|
||||
- 分发:`cmd/bat-api` + `internal/api`(见 `docs/reports/GO_STATUS.md`)
|
||||
|
||||
---
|
||||
@@ -17,7 +18,7 @@
|
||||
| 角色 | 组件 | 职责 |
|
||||
|---|---|---|
|
||||
| 同步 / 运维(近乎全自动) | Rust `bat` | auto-discover、拉取、校验、发布、watch/daemon、RPC 后端 |
|
||||
| 资源 bootstrap / 只读分发 | Go `bat-api` | 同环境经 `bat.sock` 发现已发布版本和 `resource_root`,提供 `/v1/bootstrap`、server-info 改写和官方 CDN path 字节 |
|
||||
| 资源 bootstrap / 只读分发 | Go `bat-api` | 同环境经 `bat.sock` 读取 Rust 选择的已验证版本和 `resource_root`,提供 `/v1/bootstrap`、server-info 改写、官方/localized CDN path 字节和 release 管理转发 |
|
||||
| 试验 CLI | Go `cmd/bat` → `bin/bat-go` | 非产品;禁止与 Rust `bat` 重名 |
|
||||
|
||||
**禁止**:把已安装客户端目录或 `/home/wanye/D/BlueArchive` 当作生产输入;真实全量样本优先服务器 release 或 `/tmp` 隔离目录。
|
||||
@@ -31,12 +32,14 @@
|
||||
current -> versions/<id> # 原子 symlink,生产读侧
|
||||
versions/<id>/ # 已发布 versioned release(= resource_root)
|
||||
official-download-manifest.json
|
||||
official-distribution-publication.json # 独立发布事实:release、mapping、manifest identity、entry count
|
||||
official-parse-cache.json # 校验后派生解析缓存,不是汉化产物
|
||||
official-textunit-index.json # TextUnit 明细与解析错误索引,不是汉化产物
|
||||
official-textunit-tasks.json # 翻译任务候选派生队列,不发 Crowdin 网络请求
|
||||
crowdin-textunit-queue.json # Crowdin worker 离线输入队列
|
||||
official-sync-snapshot.json # 常在 active root / current 下
|
||||
official-launcher-bootstrap.json # 官方 launcher 引导链版本化产物
|
||||
official-cas-reuse-references.json # 当前 release 获取的 CAS 引用和 ownership_id
|
||||
prod-clientpatch.bluearchiveyostar.com/
|
||||
<root_token>/
|
||||
TableBundles/
|
||||
@@ -74,7 +77,12 @@
|
||||
<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 审计输入/结果
|
||||
localized-distribution-manifest.json # 实际 localized bytes/hash 的轻量分发索引
|
||||
.staging/<id>/ # generic/translation patch 未发布写侧
|
||||
localized-version-state.json # localized current、官方 source release 和 workflow 状态
|
||||
.localized-release.lock # 跨进程单写者锁
|
||||
.localized-transaction.json # publish/rollback 崩溃恢复日志
|
||||
```
|
||||
|
||||
官方资源发布和汉化发布是两个独立状态:
|
||||
@@ -82,13 +90,74 @@
|
||||
- `not_localized`:官方原版资源已经完成下载、校验和发布,汉化资源尚未发布;这是官方同步完成后的默认状态。
|
||||
- `localized`:同一官方版本的原版资源和汉化资源都已发布,生产侧可以同时提供两套资源。
|
||||
|
||||
### 2.1 读侧 vs 写侧
|
||||
### 2.1 双 release 读写边界
|
||||
|
||||
Rust `bat` 的 `release.status` 是 official/localized 的统一重型只读视图,基于既有
|
||||
version state、current symlink、release manifest、文件系统和必要的 CAS/reference
|
||||
元数据计算,不建立第二个 release 数据库。`release.list` 返回两个 namespace 的当前
|
||||
与历史 release,包含稳定 ID、created/published、source official relation、生命周期、
|
||||
`rollback_available`、manifest contract、artifact/distribution integrity、
|
||||
`stale`/`damaged`/`referenced`/`unknown`、rollback previous 和诊断;缺少 generic manifest 的
|
||||
旧 localized release 保留为 `legacy`/`unknown`,不自动改写。
|
||||
|
||||
`release.distribution` 的默认 channel 是 `official`。只有当前或显式历史、路径归属安全、
|
||||
source relation 正确且 manifest/artifact integrity 通过的 release 才能被选择;staging、
|
||||
损坏、缺失、symlink/path escape 或未验证历史项不会回退到另一 channel。Rust 使用已发布
|
||||
manifest 做轻量选择,HTTP 热路径不重新执行完整 release audit;localized 必须额外满足
|
||||
`localized-distribution-manifest.json` 与 source official manifest 的 destination/URL
|
||||
集合及 deterministic source mapping identity 一致,并返回实际 localized bytes/hash。
|
||||
manifest 同时保存 localized mapping identity 和 destination index;返回的 `resource_root` 和 manifest entry
|
||||
由 Rust 决定,Go 只做 typed forwarding;传入 `destination` 时 Rust 会重新校验该文件的
|
||||
实际 bytes/BLAKE3。单条请求只比较发布时持久化的 identity、通过 destination index
|
||||
定位 entry,不重新遍历全量映射或资源文件。
|
||||
|
||||
localized publish/rollback 先取得 `.localized-release.lock`,并在 output root 下记录
|
||||
`.localized-transaction.json`。current、version-state 和 version 目录的切换按日志阶段
|
||||
推进;publish 只有最终 `verified` phase 才能 roll-forward,下一次写操作会先恢复或完成
|
||||
未决事务,避免跨进程并发写入和中断后留下半发布状态。`release.cleanup execute` 先取得
|
||||
同一 official `.official-sync.lock`,再按固定顺序取得 localized 锁并在持锁状态下重建
|
||||
计划;dry-run 不占用 official mutation lock。
|
||||
|
||||
`release.cleanup` 先生成 dry-run 计划和 `plan_id`,执行时重新计算并比对计划。current、
|
||||
rollback previous、active/in-progress、localized source official、state/manifest/CAS
|
||||
reference、无法确认 ownership 的对象均保留;只删除重新验证后仍为普通目录且确定无引用的
|
||||
历史 release。它不改变 current,不执行 rollback,也不负责自动 repair;staging 默认保留
|
||||
以避免删除未持久化任务。
|
||||
|
||||
### 2.2 读侧 vs 写侧
|
||||
|
||||
| 阶段 | 根目录 |
|
||||
|---|---|
|
||||
| 下载写入 | `<output>/.staging/<id>` |
|
||||
| 发布完成 | rename 到 `versions/<id>`,再切换 `current` |
|
||||
| 生产读取 / bat-api | RPC 给出的 `version.resource_root`;通常等价于 `current` 解析后的 versioned 目录 |
|
||||
| 生产读取 / bat-api | RPC 给出的已验证 `resource_root`;默认等价于 official `current` 解析后的 versioned 目录,localized 必须显式选择 |
|
||||
|
||||
每个 release 的 `official-download-manifest.json` 是历史复用的索引。新 staging
|
||||
按规范化 destination 查找候选,并重新验证 manifest 中的 size、BLAKE3 和 ZIP
|
||||
结构;URL、CDN 根或 release ID 变化本身不构成失效条件。复用文件先尝试硬链接,
|
||||
跨文件系统时复制到 staging 内的临时文件并原子 rename,旧 `versions/<id>` 目录
|
||||
保持不可变。
|
||||
|
||||
新 official release 在完整下载、manifest、文件和 ZIP 校验完成后,才会在 versioned
|
||||
目录中原子写入 `official-distribution-publication.json`。该文件独立记录
|
||||
`official_release_id`、完整 distribution mapping identity、manifest content identity
|
||||
(manifest 文件 BLAKE3)和 `entry_count`。普通 manifest 读写、release status 查询和
|
||||
`release.distribution` 不会重建或刷新它;如果 manifest 在发布后变化、publication
|
||||
文件缺失或两者 identity 不一致,该 release 的 `distribution_integrity_status` 不是
|
||||
`valid`,不能被 distribution 读侧选择。没有该文件的历史 release 仍可被状态/清理逻辑
|
||||
识别为 `legacy`,但不会被当作 distribution-ready。
|
||||
|
||||
从 CAS 物化资源时,`official-cas-reuse-references.json` 首次创建时生成并持久化
|
||||
`ownership_id`,记录每个获取的对象引用;文件带版本字段且允许重复 object ID。
|
||||
没有 `ownership_id` 的旧清单首次 cleanup 按 output-root scope、release ID、稳定 source
|
||||
mapping identity 和 generation counter 建立 persistent legacy generation identity;
|
||||
若已存在 basename ledger,则在该 generation 完成前继续使用 basename compatibility key,
|
||||
完成后同名新 generation 使用新的 ownership。`.cas-owner-scope` 是 output-root 私有状态,
|
||||
不会复制到另一个 release 或 staging。
|
||||
孤儿 staging 或显式 release 清理必须先按
|
||||
清单减少 CAS 引用,再删除目录;cleanup execute 与官方同步共用
|
||||
`.official-sync.lock`,localized cleanup 使用 `.localized-release.lock`。CAS 对象损坏、缺失或元数据不一致时只产生诊断,
|
||||
回退网络下载,不发布未经校验的文件。
|
||||
|
||||
---
|
||||
|
||||
@@ -124,7 +193,11 @@ GET {public-base-url}/prod-clientpatch.bluearchiveyostar.com/<root_token>/...
|
||||
≡ 磁盘 <resource_root>/prod-clientpatch.bluearchiveyostar.com/<root_token>/...
|
||||
```
|
||||
|
||||
默认仅服务 **download manifest 索引内且 Present + size 匹配** 的文件。
|
||||
默认仅服务 **official download manifest 索引内且 Present + size 匹配** 的文件。需要
|
||||
localized 或历史 release 时,调用 `release.distribution` 选择 Rust 已验证的
|
||||
`resource_root`,再由 `/v1/distribution` 或带 `channel`/`release_id` 的 CDN path
|
||||
转发;localized CDN 使用 Rust 返回的实际 bytes/hash 生成 ETag,并在显式请求时校验
|
||||
实际文件长度;Go 不在本地判断健康度,也不回退到 official。
|
||||
|
||||
### 3.3 launcher 资源引导兼容
|
||||
|
||||
@@ -183,8 +256,28 @@ GET {public-base-url}/prod-clientpatch.bluearchiveyostar.com/<root_token>/...
|
||||
| `bytes` | 文件大小 |
|
||||
| `blake3` | 本地 BLAKE3 hex |
|
||||
|
||||
manifest 还持久化以下 distribution 查询字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `distribution_mapping_identity` | string | 完整 URL、destination、size、BLAKE3 映射的确定性 identity |
|
||||
| `destination_index` | map destination → URL | 单 destination 分发的轻量定位索引 |
|
||||
|
||||
**权威清单**:拉取闭环写入的 manifest;`bat-api` / RPC `resource.manifest` 以此为应有集合,再以磁盘校验 Present。
|
||||
|
||||
`official-distribution-publication.json` 是发布事实而不是 manifest 派生缓存。单条
|
||||
distribution 查询只读取该文件、当前 manifest 的内容 identity 和
|
||||
`destination_index`,再校验目标文件的 size/BLAKE3;不会为了定位一个 destination
|
||||
重做完整 mapping canonicalization 或遍历其他资源。
|
||||
|
||||
`official-distribution-attestation.json` 是 Rust full local verification 的结果。发布时
|
||||
文件先写入 `.staging/<id>`,但其中的 `resource_root` 永远记录最终的
|
||||
`versions/<id>` canonical root;随后 staging 目录原子重命名并切换 `current`,不会因为
|
||||
重命名再次增加 verification generation。周期性 current 验证、显式 verify/repair 和新
|
||||
release 发布都会写入新的 generation;失败会写入 `ready=false`、`integrity_status=invalid`
|
||||
且 `verified_at=null` 的新结果。`max_age_seconds` 由 Rust watch 的验证周期和失败重试
|
||||
周期计算,缺失或为 0 的旧结果直接视为不可用,不使用固定兼容 fallback。
|
||||
|
||||
---
|
||||
|
||||
## 5. 发现与 seed URL 规则(L2)
|
||||
@@ -297,9 +390,13 @@ Addressables 改写后客户端拼接:
|
||||
|
||||
1. `daemon.status`
|
||||
2. `daemon.doctor`
|
||||
3. `catalog.status`(`version.resource_root`、`addressables_root`、app/bundle)
|
||||
4. `resource.manifest` 分页(url / destination / bytes / blake3)
|
||||
5. 在 `resource_root` 上 Lstat 校验 Present / size
|
||||
3. `release.attestation`,消费 Rust 当前 official 的 `ready`、release/publication/
|
||||
manifest identity、verification generation、freshness 和 integrity 事实
|
||||
4. `catalog.status`(`version.resource_root`、`addressables_root`、app/bundle)
|
||||
5. `resource.manifest` 分页(请求携带 release/publication/manifest identity;响应每页
|
||||
返回同一组 identity、generation、total、offset、limit)
|
||||
6. 在 `resource_root` 上 Lstat 校验 Present / size;该检查只验证 Go 读快照,
|
||||
不替代 Rust release verifier
|
||||
|
||||
**不读** `bat-status.json` / `bat-tasks.json` 作为常规路径。
|
||||
|
||||
@@ -307,18 +404,18 @@ Addressables 改写后客户端拼接:
|
||||
|
||||
---
|
||||
|
||||
## 9. issue #2 / #3 样本索引(R4/R5 预置)
|
||||
## 9. 真实资源样本索引
|
||||
|
||||
在服务器 release 上优先采集到 `/tmp` 隔离目录(**不入库大文件**):
|
||||
|
||||
| 用途 | 建议路径模式 |
|
||||
|---|---|
|
||||
| Addressables(#2) | `{PatchDir}/catalog_*.zip` 解压后的 JSON/bin + 旁路 `.hash` |
|
||||
| UnityFS(#3) | `FullPatch_*.zip` 内抽样 `.bundle`,或已解包 bundle |
|
||||
| Addressables | `{PatchDir}/catalog_*.zip` 解压后的 JSON/bin + 旁路 `.hash` |
|
||||
| UnityFS | `FullPatch_*.zip` 内抽样 `.bundle`,或已解包 bundle |
|
||||
| seed 加固(R2) | 各平台 `TableCatalog` / `BundlePackingInfo` / `MediaCatalog` 的 `.bytes`+`.hash` |
|
||||
|
||||
字段目标(#2,已有 `m_Crc` 部分):继续扩大 hash/size/CRC/依赖等可校验字段覆盖。
|
||||
结构目标(#3):header / block / directory / metadata / object table 引擎级解析。
|
||||
字段目标(已有 `m_Crc` 部分):继续扩大 hash/size/CRC/依赖等可校验字段覆盖。
|
||||
结构目标:header / block / directory / metadata / object table 引擎级解析。
|
||||
|
||||
---
|
||||
|
||||
@@ -348,7 +445,7 @@ bat.sock 或 state-dir: ...
|
||||
- `docs/architecture/official-resource-backend.md` — 拉取后端总览
|
||||
- `docs/reference/rpc-backend-api.md` — RPC 契约
|
||||
- `docs/guides/official-resource-test-pull.md` — 用户向运行说明
|
||||
- `docs/reports/CURRENT_GAPS.md` — G-009 / #2 / #3
|
||||
- `docs/reports/CURRENT_GAPS.md` — G-005 / G-007 / G-009
|
||||
|
||||
---
|
||||
|
||||
|
||||
+28
-29
@@ -1,6 +1,6 @@
|
||||
# 稳定工程基线指南
|
||||
|
||||
- **更新时间**:2026-07-26
|
||||
- **更新时间**:2026-09-04
|
||||
- **目标**:让工作区处于可继续开发核心功能的可信状态。
|
||||
|
||||
---
|
||||
@@ -13,7 +13,7 @@
|
||||
2. 根目录只保留入口文档和工程配置。
|
||||
3. 旧报告归档,且不再和当前状态混淆。
|
||||
4. Rust workspace 成员显式列出。
|
||||
5. Go 产品入口尚未完成时,Makefile 不把骨架包误报为完整产品。
|
||||
5. Go 正式入口为 `bat-api` 资源 bootstrap/分发服务;Makefile 不把实验性 CLI 骨架误报为完整产品。
|
||||
6. 当前缺口有集中清单和关闭顺序。
|
||||
7. 架构边界有 ADR 记录。
|
||||
8. 基础验证命令通过。
|
||||
@@ -23,42 +23,41 @@
|
||||
|
||||
## 2. 当前验证命令
|
||||
|
||||
必须通过:
|
||||
提交前的只读统一门禁必须通过:
|
||||
|
||||
```bash
|
||||
make test
|
||||
make check
|
||||
make lint
|
||||
make ci-check
|
||||
```
|
||||
|
||||
等价底层命令:
|
||||
该命令等价覆盖:
|
||||
|
||||
```bash
|
||||
cargo test --workspace
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
go test ./...
|
||||
go vet ./...
|
||||
cargo fmt --all -- --check
|
||||
cargo check --workspace --locked
|
||||
cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
cargo test --workspace --locked
|
||||
go test ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/...
|
||||
go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/...
|
||||
go build -o /tmp/bat-api ./cmd/bat-api
|
||||
make check-docs
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
1. 当前已有 `internal/backendrpc` fake socket 单测、`cmd/bat` 试验骨架和 `internal/ffi` 兼容包装;这些不代表 CLI/API 产品入口已完成。
|
||||
2. 后续新增 Go 产品 package,必须让 `go test ./...` 和 `go vet ./...` 纳入硬性验证。
|
||||
3. 当前 `golangci-lint` 可选;当 Go 代码进入主要开发阶段后,应纳入本地门禁。
|
||||
1. 默认 Go 测试只覆盖正式 `bat-api` 依赖的纯 Go 包:`internal/api` 和 `internal/backendrpc`;`make test-go-ffi` / `make test-go-all` 才会包含 FFI 和试验 CLI。
|
||||
2. `golangci-lint 2.12.2` 是 required gate;版本由 `scripts/ci-versions.sh` 固定,工具缺失或版本不匹配直接失败。
|
||||
3. `make format` / `make fmt` 会修改工作树;`make ci-check`、`make check`、`make test` 和 `make lint` 不应格式化源码。
|
||||
4. 官方同步相关修改必须额外运行 `cargo test -p bat-infrastructure --bin bat -- --nocapture`。
|
||||
|
||||
如果构建环境的默认 Go cache 不可写,可将 `GOCACHE` 指向工作区外的临时目录,例如
|
||||
`GOCACHE=/tmp/bat-go-cache`。
|
||||
|
||||
---
|
||||
|
||||
## 3. Git 基线
|
||||
|
||||
当前工作区原 `.git/` 是空目录,无法恢复原历史。本基线采用新初始化仓库,并以首次提交作为后续开发起点。
|
||||
|
||||
首次提交信息:
|
||||
|
||||
```text
|
||||
chore: establish development baseline
|
||||
```
|
||||
当前工作区以现有 Git 分支和提交为基线;原项目历史未恢复。提交前应确认工作区
|
||||
只包含本次有意修改,并核对文档、源码和测试状态。
|
||||
|
||||
提交前检查:
|
||||
|
||||
@@ -86,15 +85,15 @@ git check-ignore -v Cargo.lock CLAUDE.md AGENTS.md CONTRIBUTING.md
|
||||
|
||||
---
|
||||
|
||||
## 5. 下一阶段入口
|
||||
## 5. 当前开发入口
|
||||
|
||||
CAS V1 和 Rust 官方同步闭环完成后,下一阶段优先推进:
|
||||
当前开发优先推进:
|
||||
|
||||
1. 继续联调 Go `bat-api` 与 Rust daemon 的资源分发路径;Go 同步 CLI 不再作为产品目标。
|
||||
2. 按 `docs/guides/official-full-pull-smoke.md` 在隔离目录执行真实官方网络全量下载 smoke,并保留运行报告。
|
||||
3. 将 `crowdin-textunit-queue.json` 接入真实 Crowdin worker、翻译记忆和 Patch 构建。
|
||||
4. 扩展 ResourceRepository 查询面:翻译任务状态、CAS 诊断入口和更丰富 TextUnit 查询。
|
||||
5. 继续完善 AssetBundle 复杂对象解析、复杂对象重打包和 Patch 发布流程统一;通用 Binary/JSON/Text Patch 基础与 UnityFS TextAsset patch 发布前置链路已可用。
|
||||
1. 继续 AssetBundle 复杂对象解析、真实 fixture 和发布级重打包。
|
||||
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。
|
||||
|
||||
优先阅读:
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# bat-api 同机 live 联调
|
||||
|
||||
## 目的
|
||||
|
||||
该 runbook 验证生产拓扑的本地形态:Rust `bat` 与 Go `bat-api` 在同一主机上运行,二者通过同一个 `bat.sock` Unix socket 和同一个已发布资源文件系统协作。
|
||||
|
||||
测试使用仓库内完整 release fixture,并把所有 daemon、HTTP 服务、release 目录和报告写入一个新的 `/tmp` 隔离目录。它不访问官方网络,不读取现有客户端目录,也不写入开发机生产资源目录。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
make bat-api-local-live-smoke
|
||||
```
|
||||
|
||||
脚本会按需构建 `bat` 和 `bat-api`,然后在同一临时目录中:
|
||||
|
||||
1. 创建版本化 release、`official-version-state.json` 和 `current` symlink。
|
||||
2. 启动真实 Rust `bat --daemon`,验证 live `bat.sock` RPC。
|
||||
3. 启动 Go `bat-api`,通过 RPC 发现 `resource_root` 和 manifest;Go 不读取 daemon 状态文件。
|
||||
4. 验证 `/healthz`、`/readyz`、`/v1/bootstrap`、server-info 和 launcher resource bootstrap。
|
||||
5. 验证 CDN `GET`、`HEAD`、`Range`、ETag、Last-Modified、缓存头、未索引路径和编码 dot-segment 越界路径。
|
||||
6. 切换 `current` 到下一个已发布版本,确认 API 索引跟随 RPC 返回的版本变化。
|
||||
7. 清空已发布版本,确认旧索引不会继续分发,`/readyz` 返回 `503`。
|
||||
8. 停止并重启 Rust daemon,确认 RPC 断开时 API 返回未 ready,重连后恢复 ready。
|
||||
|
||||
成功时脚本输出 `LOCAL_BAT_API_LIVE_SMOKE_OK`,并打印类似以下报告路径:
|
||||
|
||||
```text
|
||||
/tmp/bat-api-local-live-<UTC timestamp>/report/SMOKE_REPORT.md
|
||||
```
|
||||
|
||||
报告目录不提交 Git;需要审阅时应保存该次命令输出和报告目录位置。
|
||||
|
||||
## 生产边界
|
||||
|
||||
- Rust `bat` 负责官方发现、下载、校验、发布、版本状态和 `bat.sock` RPC。
|
||||
- Go `bat-api` 只通过 RPC 发现已发布 `resource_root` 和 manifest,并提供 HTTP bootstrap/CDN 读服务。
|
||||
- 生产中两者必须使用同一主机、同一容器或同一共享文件系统;`bat.sock` 不应暴露到公网。
|
||||
- `--resource-root` / `BAT_API_RESOURCE_ROOT` 只用于 fixture 或应急只读诊断,不能替代生产 RPC 发现。
|
||||
- `make official-smoke` 是独立的官方网络全量拉取 runbook;本文件的本地 fixture smoke 不证明官方网络可达或官方全量资源下载成功。
|
||||
@@ -0,0 +1,431 @@
|
||||
# Rust bat 工作流命令
|
||||
|
||||
Rust `bat` 的工作流入口按三个一级命令组织:
|
||||
|
||||
- `res`:官方资源拉取、校验、修复和拉取计划。
|
||||
- `parse`:当前官方 release 的解析和 UnityFS 重打包。
|
||||
- `i18n`:离线翻译工作台、人工文本修改和汉化 release 发布。
|
||||
|
||||
`resource`、`resources`、`translation` 和 `translate` 仍作为长别名接受,但文档示例统一使用 `res` 和 `i18n`;例如 `resource status`、`resource schedule`、`translation tasks`、`translation handoff` 和 `translation status` 都会落到同一组已实现命令。
|
||||
|
||||
## 资源拉取
|
||||
|
||||
单次拉取:
|
||||
|
||||
```bash
|
||||
bat res pull --auto-discover --output /tmp/bat-resources
|
||||
```
|
||||
|
||||
同一进程内限定次数执行。第二轮及以后必须显式给出间隔:
|
||||
|
||||
```bash
|
||||
bat res pull --auto-discover \
|
||||
--run-count 3 \
|
||||
--interval 1h \
|
||||
--output /tmp/bat-resources
|
||||
```
|
||||
|
||||
无限周期执行使用 `--watch`:
|
||||
|
||||
```bash
|
||||
bat res pull --auto-discover --watch --interval 1h \
|
||||
--output /tmp/bat-resources
|
||||
```
|
||||
|
||||
资源下载默认使用 8 个独立 worker,允许范围为 `1..=256`。worker 完成当前 URL 后立即领取共享队列中的下一个任务,进度按完成顺序统计,最终报告仍按计划顺序输出。
|
||||
|
||||
## Release 查询与清理
|
||||
|
||||
双 release 运维由 Rust `bat` 通过 `bat.sock` 提供,不新增平行顶层 CLI:
|
||||
|
||||
```bash
|
||||
# 查看 official/localized 当前、历史、source relation 和完整性
|
||||
printf '{"jsonrpc":"2.0","id":1,"method":"release.status"}\n' \
|
||||
| socat - UNIX-CONNECT:/tmp/bat-state/bat.sock
|
||||
printf '{"jsonrpc":"2.0","id":2,"method":"release.list","params":{"channel":"localized"}}\n' \
|
||||
| socat - UNIX-CONNECT:/tmp/bat-state/bat.sock
|
||||
|
||||
# 选择已验证的 localized 当前 release,默认 channel 仍是 official
|
||||
printf '{"jsonrpc":"2.0","id":3,"method":"release.distribution","params":{"channel":"localized"}}\n' \
|
||||
| socat - UNIX-CONNECT:/tmp/bat-state/bat.sock
|
||||
```
|
||||
|
||||
`release.distribution` 只返回 Rust 已验证的当前或显式历史 release。localized 必须
|
||||
同时满足 source official、current、manifest identity、source/target hash/size 和
|
||||
UnityFS/ZIP 最终语义校验;staging、损坏、缺失或路径不安全的 release 不会跨 channel
|
||||
fallback。`localized.status` 的 `patch_manifest_contract_status` 与
|
||||
`artifact_integrity_status` 分开表示 schema 和产物健康度,产物损坏时为
|
||||
`localized.degraded`,检查不会自动修复。
|
||||
|
||||
清理必须先 dry-run,再使用同一 `plan_id` 执行:
|
||||
|
||||
```bash
|
||||
printf '{"jsonrpc":"2.0","id":4,"method":"release.cleanup","params":{"execute":false}}\n' \
|
||||
| socat - UNIX-CONNECT:/tmp/bat-state/bat.sock
|
||||
printf '{"jsonrpc":"2.0","id":5,"method":"release.cleanup","params":{"execute":true,"plan_id":"<plan-id>"}}\n' \
|
||||
| socat - UNIX-CONNECT:/tmp/bat-state/bat.sock
|
||||
```
|
||||
|
||||
Rust 会在执行前重算计划,保护 current、rollback previous、active/in-progress、
|
||||
localized source official、state/manifest/CAS/reference 和未知归属对象。cleanup 不改变
|
||||
current、不执行 rollback,也不删除 staging;回滚仍使用独立的 `localized.rollback`。
|
||||
|
||||
## 解析与重打包
|
||||
|
||||
解析当前已发布 release:
|
||||
|
||||
```bash
|
||||
bat parse run --output /tmp/bat-resources
|
||||
```
|
||||
|
||||
也可以显式指定隔离的已发布 release 根目录:
|
||||
|
||||
```bash
|
||||
bat parse run \
|
||||
--resource-root /tmp/bat-resources/versions/<release-id> \
|
||||
--force
|
||||
```
|
||||
|
||||
解析结果会刷新 `official-parse-cache.json`、`official-textunit-index.json` 和翻译队列。`--force` 忽略已有解析缓存,但仍要求输入 release 已通过官方下载 manifest 校验。
|
||||
|
||||
清理当前 release 的可再生解析缓存和离线翻译队列:
|
||||
|
||||
```bash
|
||||
bat parse clear-cache \
|
||||
--resource-root /tmp/bat-resources/versions/<release-id> \
|
||||
--force
|
||||
```
|
||||
|
||||
该命令不会删除 `translation-tasks.sqlite`;worker 状态必须通过任务接口单独维护。
|
||||
|
||||
批量 UnityFS 重打包使用 JSON spec。spec 的 `schema_version` 当前为 `1`,支持 `text_asset`、`string_field` 和受支持的语义 `field` 操作:
|
||||
|
||||
```bash
|
||||
bat parse repack --repack-spec /tmp/bat-repack.json
|
||||
```
|
||||
|
||||
重打包写入独立的 `target_bundle`,逐个操作后由底层 UnityFS patch 实现重建并校验,不允许 source 和 target 相同。重建会保留已识别的 block 压缩、alignment、目录和未修改对象内容;未知压缩模式或无法证明保真的结构会失败。
|
||||
|
||||
## 翻译工作台与发布
|
||||
|
||||
导出可人工编辑的工作台:
|
||||
|
||||
```bash
|
||||
bat i18n export \
|
||||
--output /tmp/bat-resources \
|
||||
--translation-file /tmp/bat-workbench.json
|
||||
```
|
||||
|
||||
修改一个条目:
|
||||
|
||||
```bash
|
||||
bat i18n set \
|
||||
--translation-file /tmp/bat-workbench.json \
|
||||
--translation-id <text-unit-id> \
|
||||
--translated-text '中文文本'
|
||||
```
|
||||
|
||||
也可以使用 `--translated-file` 读取 UTF-8 文本。
|
||||
如果译文触发 blocking Glossary QA,需先从 diagnose/任务结果取得当前
|
||||
`qa_identity`,并与 reviewer、reason、provenance 一起提交;系统不会根据 workbench
|
||||
中旧的 QA 自动补填:
|
||||
|
||||
```bash
|
||||
bat i18n set \
|
||||
--translation-file /tmp/bat-workbench.json \
|
||||
--translation-id <text-unit-id> \
|
||||
--translated-text '人工确认的译文' \
|
||||
--glossary-qa-identity <qa-identity> \
|
||||
--glossary-reviewer operator \
|
||||
--glossary-reason '人工确认术语偏离' \
|
||||
--glossary-provenance workbench
|
||||
```
|
||||
|
||||
需要复核单条内容时:
|
||||
|
||||
```bash
|
||||
bat i18n get \
|
||||
--translation-file /tmp/bat-workbench.json \
|
||||
--translation-id <text-unit-id> \
|
||||
--json
|
||||
```
|
||||
|
||||
需要把某条译文恢复为未审核状态时:
|
||||
|
||||
```bash
|
||||
bat i18n unset \
|
||||
--translation-file /tmp/bat-workbench.json \
|
||||
--translation-id <text-unit-id>
|
||||
```
|
||||
|
||||
这些工作台操作也可以写成 `bat i18n workbench set|get|unset|validate ...`,
|
||||
`--translation-file` 也可简写为 `--workbench`。
|
||||
工作台会保存 source text、release ID、TextUnit 目标和人工译文;发布前会重新读取当前
|
||||
TextUnit 索引,拒绝过期 release、source text 或 patch 目标。
|
||||
|
||||
发布前可只做工作台审计:
|
||||
|
||||
```bash
|
||||
bat i18n validate \
|
||||
--resource-root /tmp/bat-resources/versions/<release-id> \
|
||||
--translation-file /tmp/bat-workbench.json
|
||||
```
|
||||
|
||||
报告会区分未审核、原文未变化、可直接 `i18n publish` 的受支持 TextAsset/
|
||||
TypeTree string field 条目,以及需要先经过独立 repack 流程的 ZIP 内或其他不支持条目。
|
||||
|
||||
发布汉化 release:
|
||||
|
||||
```bash
|
||||
bat i18n publish \
|
||||
--output /tmp/bat-resources \
|
||||
--localized-output /tmp/bat-localized \
|
||||
--translation-file /tmp/bat-workbench.json
|
||||
```
|
||||
|
||||
发布接受当前实现支持的直接 TextAsset、TypeTree 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`。
|
||||
|
||||
运行一次 mock provider worker:
|
||||
|
||||
```bash
|
||||
bat i18n worker run \
|
||||
--output /tmp/bat-resources \
|
||||
--provider mock \
|
||||
--worker-concurrency 8
|
||||
```
|
||||
|
||||
`--provider` 支持 `mock` 和 `crowdin`。`mock` 可通过 `--translation-fixture`
|
||||
读取本地 JSON fixture;`crowdin` 从环境变量 `CROWDIN_PROJECT_ID`、
|
||||
`CROWDIN_LANGUAGE_ID`、`CROWDIN_API_TOKEN` 读取配置。worker 默认并发为 8,
|
||||
范围 `1..=256`;每个 worker 完成当前任务后立即从 SQLite 队列领取下一项,
|
||||
不会等待当前一批 worker 全部结束后再重新分配。
|
||||
|
||||
可用参数包括 `--worker-max-attempts`、`--worker-lease-seconds`、
|
||||
`--worker-retry-backoff` / `--worker-retry-backoff-seconds`、
|
||||
`--worker-max-tasks` 和 `--worker-id`。worker 支持 `--run-count` 与
|
||||
`--watch`,因此可以单次、限定次数或周期执行;`--run-count > 1` 时仍必须
|
||||
显式指定 `--interval`。
|
||||
|
||||
Glossary domain/feature contract V1 由 Rust `bat` 持有,并由 SQLite persistence schema V2
|
||||
承载,默认位于 `<output>/glossary.sqlite`;V2 正式吸收历史上的 `glossary_term_deletions`
|
||||
schema drift;也可以用 `--glossary-path`、
|
||||
`BAT_GLOSSARY_PATH` 或 `[translation.worker].glossary_path` 指定。worker 只把
|
||||
`approved` term 转成 provider-neutral constraints,并在 TM 复用、provider 返回
|
||||
和人工工作台/任务回写时执行相同的确定性 QA。冲突或不符合推荐/允许译法的结果会
|
||||
阻止自动完成;必须提交带当前 `qa_identity`、reviewer、reason 和 provenance 的显式
|
||||
override。
|
||||
|
||||
常用 Glossary 操作:
|
||||
|
||||
```bash
|
||||
bat i18n glossary summary --output /tmp/bat-resources
|
||||
bat i18n glossary query --glossary-source-text 'Sensei' --glossary-review-status approved
|
||||
bat i18n glossary diagnose --glossary-source-text 'Sensei' \
|
||||
--glossary-context-json '{"destination":"Table.bytes"}'
|
||||
```
|
||||
|
||||
外部 provider 或人工流程也可以用 `i18n task update` 回写当前 release 的任务状态:
|
||||
|
||||
```bash
|
||||
bat i18n task update \
|
||||
--state-dir /tmp/bat-state \
|
||||
--task-id textunit/v-current/TextAssets/Scenario.json \
|
||||
--task-status failed \
|
||||
--failure-reason "provider rejected payload" \
|
||||
--provider-run-id provider-run-1 \
|
||||
--json
|
||||
```
|
||||
|
||||
`--task-status` 支持 Rust contract 中的 `queued`、`running`、`failed`、
|
||||
`completed` 和 `skipped`;命令只更新当前 release 的
|
||||
`translation-tasks.sqlite`,不会创建任意翻译任务。
|
||||
任务查询和交接查询可以用 `bat i18n tasks` / `bat i18n handoff`;
|
||||
汉化发布状态可以用 `bat i18n status`。这些只读入口也可以写成
|
||||
`bat translation tasks|handoff|status`,其中 `translation` / `translate`
|
||||
是一级命令长别名。
|
||||
|
||||
需要把当前汉化 workflow 切到人工校对中时,可用:
|
||||
|
||||
```bash
|
||||
bat i18n proofread \
|
||||
--output /tmp/bat-resources \
|
||||
--localized-output /tmp/bat-localized
|
||||
```
|
||||
|
||||
该命令只改写 `localized-version-state.json` 中的工作流标记,不会改动已发布的
|
||||
汉化 release 指针;如果自动汉化已经发布,后续仍可继续正常发布汉化资源。
|
||||
|
||||
## 持久化调度
|
||||
|
||||
每个一级工作流都可以管理自己的 schedule。调度计划保存在 `--state-dir/bat-schedules.json`,计划记录包含动作、参数、下一次执行时间、周期、剩余次数、启用状态和最近错误。
|
||||
`parse schedule` 与 `res schedule` / `i18n schedule` 共用同一份计划库,
|
||||
`--id` / `--action` 分别是 `--schedule-id` / `--schedule-action` 的简写。
|
||||
|
||||
新增一个每天执行的资源拉取计划:
|
||||
|
||||
```bash
|
||||
bat res schedule add \
|
||||
--state-dir /tmp/bat-schedule \
|
||||
--schedule-id daily-pull \
|
||||
--schedule-action pull \
|
||||
--schedule-delay 1s \
|
||||
--schedule-every 24h \
|
||||
--schedule-arg --auto-discover \
|
||||
--schedule-arg --output \
|
||||
--schedule-arg /tmp/bat-resources
|
||||
```
|
||||
|
||||
计划操作:
|
||||
|
||||
```bash
|
||||
bat res schedule list --state-dir /tmp/bat-schedule
|
||||
bat res schedule update --state-dir /tmp/bat-schedule --schedule-id daily-pull --schedule-every 12h
|
||||
bat res schedule remove --state-dir /tmp/bat-schedule --schedule-id daily-pull
|
||||
bat res schedule run --state-dir /tmp/bat-schedule
|
||||
bat parse schedule list --state-dir /tmp/bat-schedule
|
||||
```
|
||||
|
||||
`parse schedule add` 默认动作是 `run`,`i18n schedule add` 默认动作也是 `run`;可以用 `--schedule-action repack` 或 `--schedule-action publish` 选择对应动作。`--schedule-count` 限定执行次数,省略表示周期无限执行;没有 `--schedule-every` 的计划执行一次后自动停用。
|
||||
|
||||
`res/parse/i18n schedule list` 默认只显示对应一级命令的计划;也可以用
|
||||
`--schedule-id`、`--schedule-enabled` 或 `--schedule-disabled` 过滤。计划删除和执行
|
||||
会校验一级命令作用域,避免误操作其他工作流。`schedule update` 可以用
|
||||
`--schedule-clear-every` 将周期计划改为单次计划;`schedule remove` 会删除计划。
|
||||
`schedule run --force` 会忽略到期时间立即执行指定计划,`--schedule-max-runs N`
|
||||
限制本轮最多执行 N 个到期计划。
|
||||
|
||||
## bat-api 调度与 dashboard 接口
|
||||
|
||||
内嵌 dashboard 由 `bat-api` 直接服务于 `GET /admin/dashboard/`。页面静态资产免
|
||||
token 读取,但资源、调度、任务、日志、解析和翻译控制都通过 `bat-api` 转发到
|
||||
Rust `bat.sock`,不维护第二份计划状态或翻译状态。Rust RPC 方法为:
|
||||
|
||||
- `schedule.list`
|
||||
- `schedule.add`
|
||||
- `schedule.update`
|
||||
- `schedule.remove`
|
||||
- `schedule.run`
|
||||
- `task.list`
|
||||
- `task.status`
|
||||
- `task.logs`
|
||||
- `task.cancel`
|
||||
- `daemon.logs`
|
||||
- `daemon.doctor`
|
||||
- `parse.status`
|
||||
- `parse.text_units`
|
||||
- `parse.errors`
|
||||
|
||||
`bat-api` 对应接口为 `GET /admin/schedules` 和
|
||||
`POST /admin/control/schedule-add|schedule-update|schedule-remove|schedule-run`,
|
||||
均要求配置 `BAT_API_AUTH_TOKEN` 并携带管理 token。列表接口支持 `id`、`group`、
|
||||
`enabled` query 过滤;请求字段沿用 Rust
|
||||
contract:`id`、`group`、`action`、`args`、`next_run_unix_seconds`、
|
||||
`delay_seconds`、`every_seconds`、`count`、`clear_args`、`clear_every`、
|
||||
`enabled`;`schedule.list` 额外接受 `id`、`group`、`enabled` 过滤,
|
||||
`schedule.run` 额外接受 `group`、`force` 和 `max_runs`。
|
||||
|
||||
任务和诊断接口同样要求管理 token:`GET /admin/diagnostics` 转发
|
||||
`daemon.doctor`,`GET /admin/logs?tail=200` 转发 `daemon.logs`,
|
||||
`GET /admin/tasks`、`GET /admin/tasks/status?task_id=...` 和
|
||||
`GET /admin/tasks/logs?task_id=...` 转发 `task.*` 查询。取消任务使用
|
||||
`POST /admin/control/task-cancel`,请求字段为 `task_id`。
|
||||
|
||||
解析查询接口为 `GET /admin/parse/status`、
|
||||
`GET /admin/parse/text-units` 和 `GET /admin/parse/errors`,均只读转发当前
|
||||
Rust release 的 `parse.*` 数据。`text-units` 与 `errors` 支持 `offset`、
|
||||
`limit`、`destination`、`path_pattern`、`archive_entry`、`path_id`、`class_id`、
|
||||
`field_path` 和 `format` query,`limit` 范围为 `1..=1000`。
|
||||
|
||||
翻译任务状态可由已鉴权的 dashboard 通过 `GET /admin/translation/tasks`
|
||||
查询,query 过滤项包括 `offset`、`limit`、`task_id`、`release_id`、
|
||||
`destination`、`path_pattern`、`archive_entry`、`status`、`worker_status`、
|
||||
`parse_status`、`format`、`has_reason` 和 `has_failure_reason`。完整 provider
|
||||
run 交接视图通过 `GET /admin/translation/handoff` 查询。两个查询接口都只转发
|
||||
Rust `translation.tasks` / `translation.handoff`,不在 Go 侧维护状态。
|
||||
|
||||
翻译任务状态也可由已鉴权的 dashboard 通过
|
||||
`POST /admin/control/translation-task-update` 回写,请求字段为
|
||||
`task_id`、`status`,以及可选的 `failure_reason`、`provider`、
|
||||
`provider_run_id` 和 `translation_results`;该接口只转发
|
||||
`translation.task.update`。人工校对流程提交译文时必须使用 `status=completed`,
|
||||
并为每个 `translation_results[]` 提供 `unit_id`、`source_text` 和
|
||||
`translated_text`,Rust 会用当前 `official-textunit-index.json` 校验 unit、
|
||||
source text、destination 和 archive entry 后再落库。blocking Glossary QA 还必须提交
|
||||
与当前 QA 完全相等的 `glossary_override.qa_identity`;旧或缺少 identity 的 override
|
||||
不会授权。
|
||||
|
||||
`POST /admin/control/translation-worker-run` 会触发 Rust 侧
|
||||
`translation.worker.run`,请求字段为 `provider`、`fixture_path`、
|
||||
`concurrency`、`max_attempts`、`lease_seconds`、`retry_backoff_seconds`、
|
||||
`max_tasks` 和 `worker_id`。bat-api 只做鉴权、JSON 解码和基础范围校验;
|
||||
任务状态、lease、重试和 provider 结果仍由 Rust 持久化。
|
||||
|
||||
`POST /admin/control/translation-proofread` 会把当前汉化 workflow 标记为人工校对中;
|
||||
该接口只转发 `translation.proofread`,不会改动已发布汉化 release 指针。
|
||||
|
||||
## localized patch 发布与回滚
|
||||
|
||||
`i18n publish` 会在独立的 `.staging/<localized-release-id>` 中复制当前官方
|
||||
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、发布时重新计算的
|
||||
Glossary QA/override 和 rollback 信息。若 TextUnit 带有 `archive_entry`,发布会在 staging 内验证 ZIP 条目路径,修改并重解析内层 UnityFS 后重写外层 ZIP;路径不安全、内层结构无效或重打包工具失败时不会发布不完整结果。可通过
|
||||
`--unzip <PATH>`、`--zip <PATH>` 或对应的 `BAT_UNZIP`、`BAT_ZIP` 配置工具路径。
|
||||
|
||||
使用人工编辑的工作台发布:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
已有 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`:
|
||||
|
||||
```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` 或 `patch_manifest`;rollback 可省略 release ID 以操作当前
|
||||
release。Go 只做鉴权、参数校验和转发,状态与产物仍由 Rust 持有。
|
||||
|
||||
## 边界
|
||||
|
||||
解析器新增类型覆盖和新的解析格式当前按路线图推进;新增覆盖仍需通过真实 fixture、回归测试和文档同步验收,不要只靠合成样本宣称能力。
|
||||
+82
-128
@@ -4,98 +4,79 @@
|
||||
|
||||
BlueArchive Toolkit 的部署文档分为当前可用模式和目标模式:
|
||||
|
||||
1. **本地开发模式**:代码在本地,连接本地或远程数据库。
|
||||
1. **本地开发模式**:当前 Rust `bat` 和 Go `bat-api` 不依赖 PostgreSQL/Redis;
|
||||
本地资源状态使用文件和 SQLite。
|
||||
2. **官方资源同步生产任务**:当前可用,运行 Rust `bat --watch` 或 RPC/daemon 模式。
|
||||
3. **bat-api 资源 bootstrap / 分发服务**:当前可用,和 Rust `bat` 在同一服务器/容器环境运行,经 `bat.sock` RPC 获取当前 `resource_root`。
|
||||
4. **完整单机/分布式部署**:尚未提供。完整游戏业务 API、数据库迁移和 Web 未实现前,不把它作为可执行部署方案。
|
||||
4. **可选数据库开发环境**:PostgreSQL/Redis 只服务于未来的 Go 服务层、完整 Web 协作后台和
|
||||
Provider 扩展,不是当前 `bat` / `bat-api` 的生产运行依赖;当前 Translation Memory
|
||||
persistence schema V2 使用 `<output>/translation-memory.sqlite`。
|
||||
5. **完整单机/分布式部署**:尚未提供。完整游戏业务 API、数据库迁移和 Web 未实现前,不把它作为可执行部署方案。
|
||||
|
||||
---
|
||||
|
||||
## 模式 1:本地开发 + 远程数据库
|
||||
## 模式 1:本地开发(当前推荐)
|
||||
|
||||
适用场景:本地开发,数据库部署在有公网 IP 的远程服务器
|
||||
|
||||
### 步骤
|
||||
|
||||
#### 1. 在远程服务器上部署数据库
|
||||
当前实现不要求启动 PostgreSQL 或 Redis。建议先运行 Rust/Go 自身的门禁:
|
||||
|
||||
```bash
|
||||
# SSH 登录到服务器
|
||||
ssh user@your.server.com
|
||||
|
||||
# 创建部署目录
|
||||
mkdir -p ~/bat/deployments
|
||||
cd ~/bat/deployments
|
||||
|
||||
# 上传配置文件(在本地执行)
|
||||
scp -r deployments/* user@your.server.com:~/bat/deployments/
|
||||
|
||||
# 配置环境变量
|
||||
cp .env.example .env
|
||||
nano .env # 设置强密码
|
||||
|
||||
# 启动数据库
|
||||
docker compose -f docker-compose.remote-db.yml up -d
|
||||
|
||||
# 查看状态
|
||||
docker compose -f docker-compose.remote-db.yml ps
|
||||
cargo check --workspace --locked
|
||||
make test-go-api
|
||||
make build-go-api
|
||||
make check-docs
|
||||
```
|
||||
|
||||
#### 2. 配置防火墙
|
||||
只有在开发未来 Go 服务层或目标数据库适配时,才需要启动可选的本地数据库:
|
||||
|
||||
```bash
|
||||
# 开放 PostgreSQL 端口
|
||||
sudo ufw allow 5432/tcp
|
||||
|
||||
# 开放 Redis 端口
|
||||
sudo ufw allow 6379/tcp
|
||||
|
||||
# 查看状态
|
||||
sudo ufw status
|
||||
docker compose -f deployments/docker-compose.dev.yml --profile local-db up -d
|
||||
```
|
||||
|
||||
#### 3. 本地连接配置
|
||||
本地数据库端口默认只绑定 `127.0.0.1`,不应改为 `0.0.0.0`。
|
||||
|
||||
在本地项目根目录创建 `.env`:
|
||||
---
|
||||
|
||||
## 模式 2:可选数据库开发环境(目标能力)
|
||||
|
||||
PostgreSQL 和 Redis 不是当前 `bat` / `bat-api` 的生产运行依赖。本模式只用于未来
|
||||
服务层、Web 协作视图或 Provider 扩展的开发验证,不能作为当前
|
||||
资源同步或资源分发的部署前置条件。
|
||||
|
||||
### 远程开发连接
|
||||
|
||||
远程开发默认使用私网地址、VPN 或 SSH tunnel。不要为开发方便把 PostgreSQL
|
||||
`5432` 或 Redis `6379` 暴露到公网;尤其不得把 Redis 公网暴露作为推荐方案。
|
||||
|
||||
在远程主机上启动可选数据库后,优先通过 SSH tunnel 连接:
|
||||
|
||||
```bash
|
||||
ssh -N \
|
||||
-L 15432:127.0.0.1:5432 \
|
||||
-L 16379:127.0.0.1:6379 \
|
||||
user@db-host
|
||||
```
|
||||
|
||||
本地开发进程只连接 tunnel 的回环端口:
|
||||
|
||||
```env
|
||||
DB_HOST=your.server.ip.address
|
||||
DB_PORT=5432
|
||||
DB_USER=bat_user
|
||||
DB_PASSWORD=your_secure_password
|
||||
DB_NAME=bluearchive_toolkit
|
||||
|
||||
REDIS_HOST=your.server.ip.address
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=your_redis_password
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=15432
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PORT=16379
|
||||
```
|
||||
|
||||
#### 4. 测试连接
|
||||
如果使用 VPN 或私网直连,应限制数据库服务仅监听明确的私网接口和允许的来源
|
||||
网段,并继续使用认证与 TLS。不要添加面向全网的 `5432` / `6379` 防火墙放行规则。
|
||||
|
||||
远程主机上的可选 Compose 服务:
|
||||
|
||||
```bash
|
||||
# 测试 PostgreSQL 连接
|
||||
psql -h your.server.ip.address -U bat_user -d bluearchive_toolkit
|
||||
|
||||
# 测试 Redis 连接
|
||||
redis-cli -h your.server.ip.address -p 6379 -a your_redis_password ping
|
||||
docker compose -f deployments/docker-compose.remote-db.yml up -d
|
||||
docker compose -f deployments/docker-compose.remote-db.yml ps
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 模式 2:本地数据库(开发)
|
||||
|
||||
适用场景:完全本地开发,不需要远程服务器
|
||||
|
||||
```bash
|
||||
# 启动本地数据库
|
||||
docker compose -f deployments/docker-compose.dev.yml --profile local-db up -d
|
||||
|
||||
# 配置 .env
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
```
|
||||
该 Compose 配置默认仅在远程主机回环地址发布端口,远程访问应通过 SSH tunnel、
|
||||
VPN 或受控私网,不通过公网端口直连。
|
||||
|
||||
---
|
||||
|
||||
@@ -253,7 +234,7 @@ sudo -u bat /opt/bluearchive-toolkit/bin/bat reload --state-dir /var/lib/bluearc
|
||||
sudo -u bat /opt/bluearchive-toolkit/bin/bat stop --state-dir /var/lib/bluearchive-toolkit/daemon-state
|
||||
```
|
||||
|
||||
`--daemon` 会在 `--state-dir` 下创建 `bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log`、`bat-events.jsonl` 和短生命周期的 `bat-control.lock`。`bat.sock` 是 Unix socket JSON-RPC 控制通道;`status`、`stop`、`logs`、`reload`、默认形态的 `refresh` 和默认形态的 `repair` 会优先连接 live daemon。PID、状态和日志文件保留为快照、诊断和 socket 不可用时的兼容路径;`bat-events.jsonl` 是带轮转的结构化 JSONL 事件日志;`bat-status.json` 会暴露最后成功时间、下次检查时间、最后错误摘要和当前下载进度;`bat-control.lock` 串行化控制命令,并能在 stale/corrupt 时由下一次控制命令或 `clean-stable` 恢复。`reload` 默认不会重启进程,而是让 watch 循环重新自动发现并强制刷新:空闲睡眠时立即唤醒,正在同步时排队到当前轮结束后执行;需要替换启动参数或 binary 时用 `restart`。
|
||||
`--daemon` 会在 `--state-dir` 下创建 `bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log`、`bat-events.jsonl` 和短生命周期的 `bat-control.lock`。`bat.sock` 是 Unix socket JSON-RPC 控制通道;`status`、`stop`、`restart`、`logs`、`reload`、默认形态的 `refresh` 和默认形态的 `repair` 会优先连接 live daemon。PID、状态和日志文件保留为快照、诊断和 socket 不可用时的兼容路径;`bat-events.jsonl` 是带轮转的结构化 JSONL 事件日志;`bat-status.json` 会暴露最后成功时间、下次检查时间、最后错误摘要和当前下载进度;`bat-control.lock` 串行化控制命令,并能在 stale/corrupt 时由下一次控制命令或 `clean-stable` 恢复。`restart` 会通过 Rust lifecycle controller 复用 CLI restart 路径替换后台进程;`reload` 默认不会重启进程,而是让 watch 循环重新自动发现并强制刷新:空闲睡眠时立即唤醒,正在同步时排队到当前轮结束后执行;需要替换启动参数或 binary 时用 `restart`。
|
||||
|
||||
不要同时运行 systemd `--watch` 和 standalone `--daemon` 指向同一个 `--output`。二者都会被资源锁和 live daemon 互斥保护,但生产运维上应保持单一 owner。
|
||||
|
||||
@@ -356,7 +337,7 @@ sudo -u bat tar -C /var/lib/bluearchive-toolkit/official \
|
||||
|
||||
## 模式 4:bat-api 资源 bootstrap / 分发服务
|
||||
|
||||
适用场景:真实 Rust `bat` 长期运行在远程服务器,并且同一服务器/容器环境内运行 Go `bat-api`,给客户端、补丁器或上层工具提供启动前资源入口和 CDN path 只读分发。
|
||||
适用场景:真实 Rust `bat` 长期运行在生产主机,并且同一主机/容器环境内运行 Go `bat-api`,给客户端、补丁器或上层工具提供启动前资源入口和 CDN path 只读分发。
|
||||
|
||||
核心约束:
|
||||
|
||||
@@ -364,7 +345,7 @@ sudo -u bat tar -C /var/lib/bluearchive-toolkit/official \
|
||||
2. 当前资源目录由 `bat.sock` RPC 返回的 `resource_root` 决定;生产不要在 `bat-api` 配置里写死 `BAT_API_RESOURCE_ROOT`。
|
||||
3. `BAT_API_RESOURCE_ROOT` 只用于本地 fixture、临时只读诊断或 RPC 不可用时的应急验证。
|
||||
4. `bat.sock` 只在服务器本机使用,不通过公网暴露;对外只发布 HTTP `bat-api`,生产建议放在反向代理和 TLS 后面。
|
||||
5. 本地开发环境不需要、也不应全量运行 `bat`;使用 Go 单测、fixture release 或远程服务器联调。
|
||||
5. 本地开发环境不需要官方全量下载;使用 Go 单测、fixture release 和 `make bat-api-local-live-smoke`。该 smoke 在本地 `/tmp` 隔离目录启动真实 Rust daemon,不连接远程服务器。
|
||||
|
||||
### 构建和安装 bat-api
|
||||
|
||||
@@ -388,7 +369,7 @@ sudo ln -sfn \
|
||||
|
||||
### bat 侧前置条件
|
||||
|
||||
`bat-api` 依赖 live RPC,而不是直接读取 daemon 状态文件。部署 `bat-api` 前,远程服务器上应已有 socket 形态的 Rust `bat`:
|
||||
`bat-api` 依赖 live RPC,而不是直接读取 daemon 状态文件。部署 `bat-api` 前,部署所在生产主机上应已有 socket 形态的 Rust `bat`:
|
||||
|
||||
```bash
|
||||
sudo -u bat /opt/bluearchive-toolkit/bin/bat \
|
||||
@@ -458,7 +439,7 @@ BAT_API_TRUST_PROXY_HEADERS=true
|
||||
|
||||
否则保持默认 `false`,`bat-api` 会按 TCP peer IP 做限流和日志归因。应用层访问日志只记录 path,不记录 query string,避免 query token 进入日志。动态 JSON 响应使用 `Cache-Control: no-store`;CDN 字节路径仍使用长期 immutable 缓存。
|
||||
|
||||
不要在生产 env 里设置 `BAT_API_RESOURCE_ROOT`。`bat-api` 会按 `BAT_API_REFRESH_INTERVAL` 周期通过 RPC 重新读取 `catalog.status` / `resource.manifest`,从而跟随 Rust `bat` 切换 `current -> versions/<id>`。
|
||||
不要在生产 env 里设置 `BAT_API_RESOURCE_ROOT`。`bat-api` 会按 `BAT_API_REFRESH_INTERVAL` 周期通过 RPC 读取当前 official `release.attestation`,再以同一 release/publication/mapping/manifest identity 和 verification generation 请求 `resource.manifest`,从而跟随 Rust `bat` 切换 `current -> versions/<id>`;attestation 过期、generation 变化或分页不一致时 fail closed。
|
||||
|
||||
### 健康检查
|
||||
|
||||
@@ -492,81 +473,54 @@ BAT_API_SKIP_ENV_FILE=1 go run ./cmd/bat-api \
|
||||
--refresh-interval 0
|
||||
```
|
||||
|
||||
这条本地命令只验证 HTTP 形态、server-info 改写、CDN path、Range/缓存语义和管理接口;真实全量 release 联调应在远程长期运行的 `bat` 环境里执行。
|
||||
这条本地命令只验证 HTTP 形态、server-info 改写、CDN path、Range/缓存语义和管理接口;同机 live 联调使用 `make bat-api-local-live-smoke`,真实官方网络下载则使用独立的 `make official-smoke`。
|
||||
|
||||
---
|
||||
|
||||
## 模式 5:完整生产环境部署
|
||||
|
||||
当前不可用。API Server、数据库迁移、Web 管理后台和发布编排尚未实现;不要按完整服务端产品部署本仓库。
|
||||
完整游戏业务生产环境当前不可用。`bat-api` 资源 bootstrap/分发服务和 Rust
|
||||
官方资源同步任务已经可以按模式 3/4 部署;数据库迁移、Web 管理后台、发布编排
|
||||
以及完整游戏业务 API 尚未实现,因此不要按完整服务端产品部署本仓库。
|
||||
|
||||
---
|
||||
|
||||
## 数据库备份
|
||||
## 可选数据库环境的备份与监控
|
||||
|
||||
### 手动备份
|
||||
以下内容只适用于未来服务层使用的可选 PostgreSQL/Redis 环境,不属于当前
|
||||
`bat` / `bat-api` 生产部署步骤。
|
||||
|
||||
### 备份
|
||||
|
||||
备份应在数据库主机或受控私网内执行,也可以通过 SSH 在远程主机上运行容器内工具:
|
||||
|
||||
```bash
|
||||
# PostgreSQL
|
||||
pg_dump -h your.server.com -U bat_user -d bluearchive_toolkit > backup.sql
|
||||
|
||||
# Redis
|
||||
redis-cli -h your.server.com -p 6379 -a password BGSAVE
|
||||
```
|
||||
|
||||
### 自动备份
|
||||
|
||||
启动备份服务:
|
||||
```bash
|
||||
ssh user@db-host \
|
||||
'docker exec bat-postgres pg_dump -U bat_user bluearchive_toolkit' \
|
||||
> backup.sql
|
||||
docker compose -f deployments/docker-compose.remote-db.yml --profile backup up -d
|
||||
```
|
||||
|
||||
备份文件位置:`deployments/backups/`
|
||||
Redis 备份使用数据库主机或容器内的受控备份工具。不要在脚本或文档中使用带公网
|
||||
主机名的 `redis-cli -h ... -p 6379` 连接,也不要把密码放进公开命令行参数或提交文件。
|
||||
|
||||
---
|
||||
|
||||
## 监控
|
||||
|
||||
### 查看日志
|
||||
### 监控
|
||||
|
||||
```bash
|
||||
# 数据库日志
|
||||
docker logs bat-postgres
|
||||
|
||||
# Redis 日志
|
||||
docker logs bat-redis
|
||||
ssh user@db-host 'docker compose -f deployments/docker-compose.remote-db.yml ps'
|
||||
ssh user@db-host 'docker logs bat-postgres'
|
||||
ssh user@db-host 'docker logs bat-redis'
|
||||
```
|
||||
|
||||
### 健康检查
|
||||
### 故障排查
|
||||
|
||||
```bash
|
||||
# 检查容器状态
|
||||
docker compose -f deployments/docker-compose.remote-db.yml ps
|
||||
当前 `bat` / `bat-api` 无需数据库连接;资源同步故障应先检查 `bat.sock`、发布目录、
|
||||
SQLite 索引和 Rust daemon 状态。未来服务层出现数据库连接问题时,按以下顺序检查:
|
||||
|
||||
# 检查 PostgreSQL
|
||||
docker exec bat-postgres pg_isready -U bat_user
|
||||
|
||||
# 检查 Redis
|
||||
docker exec bat-redis redis-cli ping
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 无法连接数据库
|
||||
|
||||
1. 检查防火墙是否开放端口
|
||||
2. 检查 `pg_hba.conf` 配置
|
||||
3. 检查密码是否正确
|
||||
4. 检查数据库是否启动
|
||||
|
||||
### 性能问题
|
||||
|
||||
1. 查看数据库连接数
|
||||
2. 检查慢查询日志
|
||||
3. 优化索引
|
||||
4. 调整数据库参数
|
||||
1. 私网、VPN 或 SSH tunnel 是否可用;
|
||||
2. 本地连接端口是否为 tunnel 映射或受控私网端口;
|
||||
3. 数据库认证、TLS 和允许网段配置;
|
||||
4. 数据库容器是否运行。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+84
-27
@@ -8,7 +8,7 @@
|
||||
|
||||
#### Go
|
||||
```bash
|
||||
# 安装 Go 1.22+
|
||||
# 安装 Go 1.26.4+
|
||||
# 参考:https://golang.org/doc/install
|
||||
|
||||
go version # 验证安装
|
||||
@@ -23,9 +23,11 @@ rustc --version # 验证安装
|
||||
cargo --version
|
||||
```
|
||||
|
||||
#### 自托管 Gitea runner
|
||||
#### 本地完整质量门禁
|
||||
|
||||
`.gitea/workflows/bat.yml` 使用 `runs-on: linux`,并且不依赖 `actions/checkout`、`dtolnay/rust-toolchain` 等外部 GitHub Action。runner 需要在执行环境中预装以下命令:
|
||||
项目以本地 `make ci-check` 作为唯一完整 required quality gate。开发过程中可运行 focused
|
||||
checks 以快速反馈,但提交前完整 gate 不得省略;仓库不依赖 Gitea、GitHub Actions 或其它
|
||||
远端 CI runner。执行环境需要预装以下命令:
|
||||
|
||||
```bash
|
||||
git --version
|
||||
@@ -33,9 +35,14 @@ rustc --version
|
||||
cargo --version
|
||||
rustfmt --version
|
||||
cargo clippy --version
|
||||
go version
|
||||
golangci-lint --version # 必须为 2.12.2
|
||||
```
|
||||
|
||||
该 workflow 会用 `GITHUB_SERVER_URL`、`GITHUB_REPOSITORY`、`GITHUB_REF` 和 `GITHUB_SHA` 手动 `git fetch` 当前提交,再执行 Rust workspace 的格式化、检查、构建、clippy 和测试。这样可以避免自托管 runner 在准备阶段通过代理克隆第三方 action 仓库。
|
||||
缺少上述命令、版本不匹配或 `golangci-lint` 不是 2.12.2 都会使 required gate 失败;
|
||||
`golangci-lint 2.12.2` 是 required gate,不是可选检查。`make ci-check` 会执行 Rust
|
||||
workspace 的只读格式检查、检查、release build、clippy 和测试,以及通过
|
||||
`make check-go-format` 执行的 Go 格式、测试、vet、构建、2.12.2 lint 和文档状态门禁。
|
||||
|
||||
#### Docker
|
||||
```bash
|
||||
@@ -65,14 +72,11 @@ git checkout -b feature/your-feature-name
|
||||
### 2. 开发
|
||||
|
||||
```bash
|
||||
# 实时编译检查
|
||||
make check
|
||||
# 运行只读 required 门禁(不会格式化或修改源码)
|
||||
make ci-check
|
||||
|
||||
# 运行测试
|
||||
make test
|
||||
|
||||
# 格式化代码
|
||||
make fmt
|
||||
# 需要格式化时才修改工作树
|
||||
make format
|
||||
```
|
||||
|
||||
开发约束:
|
||||
@@ -117,9 +121,9 @@ git push origin feature/your-feature-name
|
||||
|
||||
禁止使用 demo、临时实现、硬编码路径或只为当前测试通过的伪实现。确实未完成的能力应写入当前缺口文档,而不是用 `TODO` 或 `FIXME` 隐藏。
|
||||
|
||||
### 解析模块冻结
|
||||
### 解析模块状态
|
||||
|
||||
UnityFS / AssetBundle / Addressables / TypeTree 解析当前处于维护冻结。冻结期不得新增解析类型、扩大解析覆盖、开放新的写入型解析 RPC/CLI,或用合成 fixture 宣称新增能力。允许变更仅限编译、测试、clippy、真实运行回归、诊断和文档一致性修复。细则见 `docs/reports/PARSER_FREEZE.md`。
|
||||
UnityFS / AssetBundle / Addressables / TypeTree 解析当前按路线图继续推进。新增解析类型、扩大解析覆盖和写入型解析 RPC/CLI 仍需遵守现有接口边界、真实 fixture 和回归验收要求。
|
||||
|
||||
### Go
|
||||
- 遵循 [Effective Go](https://golang.org/doc/effective_go)
|
||||
@@ -142,26 +146,27 @@ UnityFS / AssetBundle / Addressables / TypeTree 解析当前处于维护冻结
|
||||
### 合并前通用门禁
|
||||
|
||||
```bash
|
||||
cargo fmt --check
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
make test-go-api
|
||||
make build-go-api
|
||||
go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/...
|
||||
make ci-check
|
||||
```
|
||||
|
||||
`make ci-check` 是只读门禁入口;`make format` / `make fmt` 才会修改源码。
|
||||
required 的 `golangci-lint 2.12.2` 由 `scripts/ci-versions.sh` 固定,缺失或版本不匹配
|
||||
都会失败,不会伪报全部门禁通过。
|
||||
|
||||
Go 边界与进度以 `docs/reports/GO_STATUS.md` 为准:
|
||||
|
||||
- **同步/运维命令行** = Rust `bat`(近乎全自动)
|
||||
- **资源 bootstrap/分发服务** = `cmd/bat-api`(`make build-go-api`)
|
||||
- **默认 Go 门禁** = `make test-go-api`(无 FFI)
|
||||
- **资源 bootstrap/分发服务与内嵌 dashboard** = `cmd/bat-api`(`make build-go-api`)
|
||||
- **默认 Go 门禁** = `make ci-check` 中的纯 Go API test/vet/build 和
|
||||
`golangci-lint 2.12.2`(无 FFI)
|
||||
- 试验 CLI 产物为 `bin/bat-go`(`make build-go-cli`),**禁止**与 Rust `bat` 重名
|
||||
- 修改 FFI 时再跑 `make test-go-ffi`
|
||||
|
||||
开发环境不能本地全量运行 Rust `bat` 时,`bat-api` 不需要真实生产资源目录。用 fixture 或 mock RPC 验证服务面;生产联调再连接远程服务器上同环境运行的 `bat.sock`:
|
||||
`bat-api` 与 Rust `bat` 的生产拓扑是同一主机、同一容器或同一共享文件系统。开发时优先使用隔离 fixture 和本地 `bat.sock` live smoke,不连接远程服务器,也不读取现有客户端目录:
|
||||
|
||||
```bash
|
||||
make test-go-api
|
||||
make bat-api-local-live-smoke
|
||||
BAT_API_SKIP_ENV_FILE=1 go run ./cmd/bat-api \
|
||||
--listen 127.0.0.1:18080 \
|
||||
--public-base-url http://127.0.0.1:18080 \
|
||||
@@ -169,6 +174,8 @@ BAT_API_SKIP_ENV_FILE=1 go run ./cmd/bat-api \
|
||||
--refresh-interval 0
|
||||
```
|
||||
|
||||
其中 `make bat-api-local-live-smoke` 会在 `/tmp` 中启动真实 Rust daemon 和 Go API,覆盖 release 切换、清单不完整、无 release、RPC 断开、server-info、CDN Range/缓存和路径越界;报告保留在该次 smoke 的临时目录。单独的 `--resource-root` 命令只验证 fixture HTTP 形态,不替代 live socket 联调。浏览器检查内嵌 dashboard 时打开 `http://127.0.0.1:18080/admin/dashboard/`,再在页面内填入管理 token。
|
||||
|
||||
生产默认路径仍是 `--socket` / `BAT_API_SOCKET`,资源根由 Rust `bat` RPC 返回;`--resource-root` 只用于上述 fixture 或应急只读诊断。
|
||||
|
||||
### 常用聚焦命令
|
||||
@@ -187,6 +194,8 @@ cargo clippy -p bat-core -p bat-adapters -p bat-infrastructure --all-targets --
|
||||
|
||||
`bat-ffi` 只是可选无状态 C ABI 兼容层。修改 FFI 时必须运行 `cargo test -p bat-ffi -- --nocapture`。Go 服务层默认经 `internal/backendrpc` 调 daemon;同步任务由 Rust `bat` 执行,不由 Go 试验 CLI 承担。
|
||||
|
||||
Rust `bat` 的资源拉取、解析、翻译工作流、重打包、汉化发布和持久化调度命令见 [`docs/guides/bat-workflows.md`](bat-workflows.md)。推荐使用 `res`、`parse`、`i18n` 三个一级命令。
|
||||
|
||||
### 集成测试
|
||||
|
||||
```bash
|
||||
@@ -217,12 +226,29 @@ cargo run -p bat-infrastructure --bin bat -- \
|
||||
`official-resource-changes.json`、`crowdin-translation-handoff.json`、
|
||||
`official-parse-cache.json` 和 `official-textunit-index.json`,随后从
|
||||
Added/Modified 资源、parse cache 与 TextUnit 明细索引派生
|
||||
`official-textunit-tasks.json` 和 `crowdin-textunit-queue.json`。本地已有旧完整
|
||||
`official-textunit-tasks.json`、`crowdin-textunit-queue.json`、
|
||||
`translation-tasks.sqlite` 和 `translation-handoff.json`。本地已有旧完整
|
||||
版本时,新版本发布后会先按 manifest destination 对比旧/新 release,只把新增和
|
||||
内容变更的资源写入解析与 Crowdin handoff;删除资源只记录差异,不进入翻译队列。
|
||||
up-to-date 轮询发现本地文件、解析缓存、TextUnit 明细索引和 TextUnit 队列未变时不会重复解析。
|
||||
Crowdin 队列当前只落本地文件,不发网络请求。
|
||||
|
||||
官方下载服务默认使用 8 个有界 worker,`--download-concurrency` /
|
||||
`BAT_DOWNLOAD_CONCURRENCY` 只接受 `1..=256`。worker 完成一个 URL 后立即从共享
|
||||
队列领取下一个任务;finished 进度按实际完成顺序即时上报,完成计数单调递增,
|
||||
最终 report 的资源列表仍按 pull plan 顺序。需要验证顺序模式时显式使用
|
||||
`--download-concurrency 1`。本文档中的真实资源命令仅是隔离 runbook;本地轻量
|
||||
验证应使用 fake-curl/fixture,不要在开发机执行真实下载或 smoke run。
|
||||
|
||||
新 release 会在网络下载前扫描已发布 release 的下载 manifest,按规范化
|
||||
destination 查找候选,并重新验证 size、BLAKE3 和 ZIP 结构。命中后优先硬链接,
|
||||
跨文件系统时回退为 staging 内临时文件复制和原子 rename;历史 release 保持不可变。
|
||||
历史候选不满足校验时才尝试既有 CAS 对象。CAS 复用会记录
|
||||
`official-cas-reuse-references.json`,清理 staging/release 时递减引用;损坏、缺失
|
||||
或元数据不一致会记录诊断并回退网络。报告和进度分别暴露
|
||||
`release_reused_count`、`cas_reused_count`、`reused_bytes`、
|
||||
`transferred_bytes` 以及 `release_reused` / `cas_reused` / `downloaded` 状态。
|
||||
|
||||
需要把已校验官方 release 导入 CAS + `ResourceRepository` 时,显式启用:
|
||||
|
||||
```bash
|
||||
@@ -233,23 +259,51 @@ cargo run -p bat-infrastructure --bin bat -- \
|
||||
--import-resource-db /tmp/bat-test-resources.sqlite
|
||||
```
|
||||
|
||||
对应 `.env` / 环境变量键为 `BAT_IMPORT_REPOSITORY`、
|
||||
对应 `config.toml` / 环境变量键为 `BAT_IMPORT_REPOSITORY`、
|
||||
`BAT_IMPORT_CAS_ROOT` 和 `BAT_IMPORT_RESOURCE_DB`。只读查询命令:
|
||||
|
||||
```bash
|
||||
cargo run -p bat-infrastructure --bin bat -- parse-status
|
||||
cargo run -p bat-infrastructure --bin bat -- parse-text-units --limit 50
|
||||
cargo run -p bat-infrastructure --bin bat -- parse-errors --limit 50
|
||||
cargo run -p bat-infrastructure --bin bat -- translation-tasks --task-status skipped_parse_failed --has-reason --limit 50
|
||||
cargo run -p bat-infrastructure --bin bat -- translation-tasks --worker-status failed --has-failure-reason --limit 50
|
||||
cargo run -p bat-infrastructure --bin bat -- translation-handoff
|
||||
cargo run -p bat-infrastructure --bin bat -- i18n worker run --provider mock --worker-concurrency 8 --worker-max-tasks 10
|
||||
cargo run -p bat-infrastructure --bin bat -- localized-status
|
||||
cargo run -p bat-infrastructure --bin bat -- resource-index --limit 50
|
||||
cargo run -p bat-infrastructure --bin bat -- resource-index --release-id <ID> --platform windows --archive-entry <PATH> --format json --limit 50
|
||||
```
|
||||
|
||||
历史 release/CAS 复用的隔离回归测试:
|
||||
|
||||
```bash
|
||||
cargo test -p bat-infrastructure official_download::tests::reuses_verified_historical_release_when_cdn_root_changes -- --nocapture
|
||||
cargo test -p bat-infrastructure official_download::tests::falls_back_to_cas_after_corrupt_historical_release -- --nocapture
|
||||
cargo test -p bat-infrastructure official_download::tests::corrupted_cas_falls_back_to_network_with_diagnostic -- --nocapture
|
||||
```
|
||||
|
||||
`parse-status` 会额外显示 TextUnit 明细索引和队列摘要;`parse-text-units` /
|
||||
`parse-errors` 可按 destination、archive entry、path id、class id、field path
|
||||
和 format 分页查询当前官方 release 的 TextUnit 明细与解析错误;
|
||||
`resource-index` 返回的资源 JSON 包含 release、平台、bundle path、TextAsset 和 TextUnit metadata;
|
||||
`translation-tasks` 可按 release、destination、archive entry、队列任务状态、provider
|
||||
worker 状态、parse status、TextUnit format、队列 reason 和 provider failure reason
|
||||
查询离线 TextUnit 翻译任务状态与跳过/失败原因;发布后的状态保存在当前 release
|
||||
根目录的 `translation-tasks.sqlite`,旧 release 没有状态库时回退到 JSON 队列;
|
||||
`i18n worker run` / `translation.worker.run` 会由 Rust provider worker 独立 claim
|
||||
下一项任务并落库 lease、失败分类、重试计划和 TextUnit 级译文结果,默认并发为 8,
|
||||
范围 `1..=256`;
|
||||
`translation-handoff` / `translation.handoff` 会动态合并版本化
|
||||
`translation-handoff.json` 与 SQLite 状态,返回 job、unit、provider run 的完整交接
|
||||
视图;
|
||||
`resource-index` 返回的资源 JSON 包含 release、平台、bundle path、TextAsset 和 TextUnit metadata,
|
||||
以及 Addressables entry 的 provider ID、bundle name、hash、size、CRC 和依赖关系;
|
||||
并可按 release、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 做资源级过滤;
|
||||
`localized-status` 只有在 `localized-version-state.json`、`current` symlink 和
|
||||
`localized-patch-manifest.json` 都匹配当前官方 release 时才返回 `localized`。
|
||||
`localized-patch-manifest.json` 都匹配当前官方 release 时才返回 `localized`;
|
||||
当 workflow 被 `translation.proofread` 标记为人工校对中时,会额外返回
|
||||
`translation_workflow_status=manual_proofreading` 与
|
||||
`translation_workflow_status_code=translation.manual_proofreading`,但不会遮蔽已发布的汉化 release。
|
||||
|
||||
文件级写入命令只处理显式输入/输出文件,不切换官方或汉化 release:
|
||||
|
||||
@@ -362,7 +416,10 @@ cargo fetch
|
||||
|
||||
### 3. FFI 兼容层问题
|
||||
|
||||
`bat-ffi` 不是主集成边界,只用于需要 C ABI 的兼容场景。未来 Go 产品入口集成优先运行 Rust `bat --json` 或调用 daemon RPC。
|
||||
`bat-ffi` 不是主集成边界,只用于需要 C ABI 的兼容场景。当前 Go 正式产品入口是
|
||||
`bat-api` 资源 bootstrap/分发服务,默认通过 `internal/backendrpc` 调用 daemon RPC;
|
||||
`cmd/bat` 仍是试验 CLI。新的 Go 集成优先使用 Rust `bat.sock` RPC 或稳定 SDK;
|
||||
`bat --json` 仅是 Rust CLI 的机器输出形态。
|
||||
|
||||
重新构建兼容库:
|
||||
```bash
|
||||
|
||||
@@ -184,6 +184,8 @@ cargo run -p bat-infrastructure --example official_pull_plan -- \
|
||||
- 存在 `.part` 临时文件时会尝试断点续传
|
||||
- 新下载先写 `.part`,成功并通过必要校验后再替换为最终文件;如果断点续传后的 `.zip` 结构校验失败,会删除 `.part` 并重新全量下载
|
||||
- 如果上一轮非 dry-run 已进入 staging 但未发布成功,下一轮会优先查找 `<output>/official-version-state.json` 中同一 app version、bundle version 和 Addressables root 的失败版本;只有对应 `<output>/.staging/<id>` 仍存在、路径安全且 `versions/<id>` 尚未发布时,才复用该 staging,并继续按 manifest 校验复用或重下单个 URL
|
||||
- 新 release 的 staging 在访问网络前会扫描已发布 release 的 `official-download-manifest.json`。候选必须同时满足 manifest 记录的 destination、size、BLAKE3 和适用的 ZIP 结构校验;URL、CDN 根和 release ID 的变化本身不会阻止复用。命中后优先用硬链接,跨文件系统时回退为临时文件复制并原子 rename,旧 release 不会被修改
|
||||
- 历史 release 候选失效时,如果配置的 CAS 根已有对应 BLAKE3 对象,会先通过 CAS 读取完整性和元数据,再增加当前 release 的引用并原子物化;当前 release 会写带持久化 `ownership_id` 的 `official-cas-reuse-references.json`,清理孤儿 staging 或显式清理 release 时按 ownership 和 ordinal 递减这些引用;旧无 identity 清单保留 legacy cleanup key。CAS 损坏、缺对象或元数据不一致会写入复用诊断并继续走网络下载,不会静默使用缓存
|
||||
- 把结果发布到 `--output/current`
|
||||
|
||||
## 5. 自动更新检查
|
||||
@@ -210,12 +212,13 @@ cargo run -p bat-infrastructure --example official_pull_plan -- \
|
||||
- `--dry-run`:只报告本次是否会下载,不写 snapshot;如果 cache miss,也不会写入新的 bootstrap cache。
|
||||
- `--dry-run --plan`:除更新判断外,还会解析 seed catalog 并打印完整下载 URL。
|
||||
- 真实更新会输出 `downloaded_count`、`resumed_count`、`skipped_count`、`transferred_bytes`、`official_seed_hash_verified_count`。
|
||||
- 复用统计还包括 `release_reused_count`、`cas_reused_count`、`reused_bytes` 和 `reuse_warnings`;单文件 progress 状态区分 `release_reused`、`cas_reused`、`downloaded`,`transferred_bytes` 不包含复用文件。
|
||||
- 校验报告分层输出 `official_seed_hash_verified_count`、`local_manifest_verified_count`、`addressables_marker_checked_count`、`unverified_marker_count`。
|
||||
- 下载阶段复用同一套本地清单、ZIP 结构校验和 `.part` 续传逻辑;没有清单或校验不匹配的文件会重新下载。
|
||||
- 非 dry-run 且启用 `--auto-discover` 时,成功发布的 release 会包含 `official-launcher-bootstrap.json`;up-to-date 轮询发现当前 release 缺少该文件时会补写。官方 launcher/server-info 已更新但 client-patch 资源尚未开放时,不切换 `current`,只在输出根写入 `official-launcher-bootstrap.pending.json` 作为维护期证据。
|
||||
- 校验和发布完成后会先对比上一完整 release 与当前 release 的 `official-download-manifest.json`,写出 `<output>/current/official-resource-changes.json` 和 `<output>/current/crowdin-translation-handoff.json`。同一 destination 只有 size 或 BLAKE3 改变才算 modified;仅 URL/CDN 根变化但内容一致不会触发解析/翻译候选。新增+变更资源进入解析和 Crowdin 翻译 handoff,删除资源只进入差异记录;当前不会直接调用 Crowdin API。
|
||||
- 随后会刷新 `<output>/current/official-parse-cache.json`。解析缓存从 `official-download-manifest.json` 的全部条目出发,处理直接 UnityFS bundle 和 zip 内 UnityFS 条目;catalog、hash、媒体等非 UnityFS 文件记录为不支持,不视为同步失败。新 release 会刷新解析缓存;远端和本地都 up-to-date 且已有有效解析缓存时只读取摘要,不重复解析。
|
||||
- 需要将已校验官方 release 导入 CAS + SQLite ResourceRepository 时,使用 `--import-repository` 或 `.env` 中 `BAT_IMPORT_REPOSITORY=1`;默认 CAS 为 `<output>/.cas`,默认索引为 `<output>/resources.sqlite`,可用 `--import-cas-root` / `BAT_IMPORT_CAS_ROOT` 和 `--import-resource-db` / `BAT_IMPORT_RESOURCE_DB` 覆盖。`resource.index` RPC 可查询现有索引,索引不存在时返回 `available=false`,不会创建空库。
|
||||
- 需要将已校验官方 release 导入 CAS + SQLite ResourceRepository 时,使用 `--import-repository` 或 `config.toml` / 环境变量 `BAT_IMPORT_REPOSITORY=1`;默认 CAS 为 `<output>/.cas`,默认索引为 `<output>/resources.sqlite`,可用 `--import-cas-root` / `BAT_IMPORT_CAS_ROOT` 和 `--import-resource-db` / `BAT_IMPORT_RESOURCE_DB` 覆盖。`resource.index` RPC 可查询现有索引,索引不存在时返回 `available=false`,不会创建空库;`bat doctor cas --output <output>` 或 `bat doctor cas --import-cas-root <path>` 可只读检查既有 CAS 根目录、对象目录、元数据库文件和对象统计。
|
||||
- 官方同步报告中的 `localized_release_status=not_localized` 表示原版资源已发布、汉化资源未发布,这是当前官方同步阶段的正常完成状态;后续 Patch 发布完成后才应切换为 `localized`,表示原版和汉化两套资源都已发布。
|
||||
|
||||
资源同步状态文件默认分布如下:
|
||||
@@ -225,7 +228,9 @@ cargo run -p bat-infrastructure --example official_pull_plan -- \
|
||||
- `<output>/official-launcher-bootstrap.pending.json`:官方 launcher/server-info 已前进但 client-patch seed marker 或必需 seed catalog 尚未开放时写入的待处理 bootstrap 证据;它不代表资源已发布,也不会改变 `current`。
|
||||
- `<output>/official-bootstrap-cache.json`:`--auto-discover` 的 `GameMainConfig` 解析缓存。launcher metadata 与 remote manifest 文件列表 digest 都未变时复用缓存;任一变化时才通过官方 HTTP 按 manifest 下载必要 `resources.assets` 或旧版 game zip 到临时目录解析。
|
||||
- `<output>/official-version-state.json`:资源发布根目录的持久版本状态,包含当前已完成版本、正在拉取版本、上一个可用版本和失败版本。
|
||||
- `<output>/current/official-download-manifest.json`:本地下载强校验清单,记录 URL、相对路径、size 和 BLAKE3。
|
||||
- `<output>/current/official-download-manifest.json`:本地下载强校验清单,记录 URL、相对路径、size、BLAKE3、deterministic distribution mapping identity 和 destination index。
|
||||
- `<output>/current/official-distribution-publication.json`:新 official release 完整校验后写入的独立发布事实,记录 official release ID、mapping identity、manifest content identity 和 entry count;它缺失或与 manifest 不一致时 release 不可分发。普通查询不会自动补写该文件。
|
||||
- `<output>/current/official-cas-reuse-references.json`:当前 release 获取的 CAS 引用清单,首次创建时包含持久化 `ownership_id`;每个复用项占一条记录,release 清理或孤儿 staging GC 时据此按 ownership/ordinal 递减引用。旧无 identity 清单使用 output-root 的 `.cas-owner-scope` 完成 generation-aware legacy cleanup;该文件不会被复制到新 release。
|
||||
- `<output>/current/official-resource-changes.json`:当前 release 相对上一完整 release 的资源差异,记录新增、变更、删除以及解析/翻译候选计数。
|
||||
- `<output>/current/crowdin-translation-handoff.json`:为后续 Crowdin worker 预留的本地队列,只包含新增+变更资源;它不是 Crowdin API 调用结果。
|
||||
- `<output>/current/official-parse-cache.json`:官方资源发布后的派生解析缓存,记录 bundle/zip 条目解析摘要和缓存复用情况;它不是汉化产物。
|
||||
@@ -277,7 +282,7 @@ cargo run -p bat-infrastructure --bin bat -- reload
|
||||
cargo run -p bat-infrastructure --bin bat -- stop
|
||||
```
|
||||
|
||||
`status`、`stop`、`logs`、`reload`、默认形态的 `refresh` 和默认形态的 `repair` 会优先连接 `bat.sock`,通过 Unix socket JSON-RPC 和 live daemon 通信;socket 不可用时,`status`、`stop` 会回退到 PID/状态文件兼容路径。`status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、版本状态摘要、最近历史失败版本和原因、文本日志路径、结构化日志路径和轮转日志路径;正在重新拉取同一版本时,对应旧失败不会作为当前历史失败摘要展示;人类输出不会把完整 `official-version-state.json` 内联打印成 JSON。控制命令会通过 `bat-control.lock` 做跨进程互斥,失效或损坏的控制锁会在下次控制命令或 `clean-stable` 时恢复。`restart` 会停止旧后台进程并按保存参数或显式参数重新启动;`reload` 在未显式传入同步参数时不会重启进程,而是唤醒或排队 watch 循环重新执行自动发现和强制刷新:空闲睡眠时立即执行,正在同步时等当前轮结束;如果显式传入 `--proxy` 或 `--no-proxy`,会按新代理配置重启后台进程。所有命令默认输出人类可读摘要,脚本集成时加 `--json`。
|
||||
`status`、`stop`、`restart`、`logs`、`reload`、默认形态的 `refresh` 和默认形态的 `repair` 会优先连接 `bat.sock`,通过 Unix socket JSON-RPC 和 live daemon 通信;socket 不可用时,`status`、`stop` 会回退到 PID/状态文件兼容路径。`status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、版本状态摘要、最近历史失败版本和原因、文本日志路径、结构化日志路径和轮转日志路径;正在重新拉取同一版本时,对应旧失败不会作为当前历史失败摘要展示;人类输出不会把完整 `official-version-state.json` 内联打印成 JSON。控制命令会通过 `bat-control.lock` 做跨进程互斥,失效或损坏的控制锁会在下次控制命令或 `clean-stable` 时恢复。`restart` 会通过 Rust lifecycle controller 复用 CLI restart 路径停止旧后台进程并按保存参数或显式参数重新启动;`reload` 在未显式传入同步参数时不会重启进程,而是唤醒或排队 watch 循环重新执行自动发现和强制刷新:空闲睡眠时立即执行,正在同步时等当前轮结束;如果显式传入 `--proxy` 或 `--no-proxy`,会按新代理配置重启后台进程。所有命令默认输出人类可读摘要,脚本集成时加 `--json`。
|
||||
|
||||
如果要把后台状态目录改到其他位置,使用 `--state-dir <目录>`:
|
||||
|
||||
@@ -320,9 +325,9 @@ cargo run -p bat-infrastructure --bin bat -- \
|
||||
--error-retry 60s
|
||||
```
|
||||
|
||||
默认平台是 `Windows,Android`,无需显式传 `--platforms`;只有要覆盖默认平台时才传。`--interval` 是正常检查周期,默认 `1h`;watch/daemon 模式还会在每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 强制执行一次自动刷新,该轮会注入 `force=true`,并且会中断普通 interval 的 sleep。`--error-retry` 是下载、发现或校验失败后的重试周期,默认 `60s`,也可以用 `--error-retry-seconds 60`。CLI 默认启动时向 stderr 打印 `BlueArchiveToolkit` ASCII banner,并把阶段进度日志写到 stderr,包括自动发现、proxy、server-info、marker、catalog、audit、download、snapshot 和 publish 阶段;download 阶段会输出已完成计数和单文件开始/完成状态,下载执行保持顺序处理,已完成计数保持单调不倒退,audit 阶段会输出官方 `.hash`、本地 BLAKE3、需修复项和 ZIP 结构校验结果摘要。daemon 还会写 `bat-events.jsonl` 结构化日志并按大小轮转。命令结果默认以人类可读摘要写到 stdout。需要纯机器输出时加 `--json --no-progress`,需要显式开启进度日志则用 `--progress`;只想关闭横幅但保留日志时可加 `--no-banner`。错误时 stderr 输出 JSON error,watch 模式下错误 JSON 的 `next_retry_seconds` 使用失败重试周期;如果未关闭 progress,错误 JSON 前可能已有 banner 和进度日志。普通错误 exit `1`,资源目录锁冲突 exit `75`,`verify` 或 `doctor` 发现问题也返回非 0。
|
||||
默认平台是 `Windows,Android`,无需显式传 `--platforms`;只有要覆盖默认平台时才传。`--interval` 是正常检查周期,默认 `1h`;watch/daemon 模式还会在每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 强制执行一次自动刷新,该轮会注入 `force=true`,并且会中断普通 interval 的 sleep。`--error-retry` 是下载、发现或校验失败后的重试周期,默认 `60s`,也可以用 `--error-retry-seconds 60`。CLI 默认启动时向 stderr 打印 `BlueArchiveToolkit` ASCII banner,并把阶段进度日志写到 stderr,包括自动发现、proxy、server-info、marker、catalog、audit、download、snapshot 和 publish 阶段;download 阶段会输出已完成计数和单文件开始/完成状态,worker 从共享队列独立领取任务并在完成后立即领取下一项,完成计数保持单调不倒退,最终 report 的 `items` 仍按 pull plan 顺序排列,audit 阶段会输出官方 `.hash`、本地 BLAKE3、需修复项和 ZIP 结构校验结果摘要。daemon 还会写 `bat-events.jsonl` 结构化日志并按大小轮转。命令结果默认以人类可读摘要写到 stdout。需要纯机器输出时加 `--json --no-progress`,需要显式开启进度日志则用 `--progress`;只想关闭横幅但保留日志时可加 `--no-banner`。错误时 stderr 输出 JSON error,watch 模式下错误 JSON 的 `next_retry_seconds` 使用失败重试周期;如果未关闭 progress,错误 JSON 前可能已有 banner 和进度日志。普通错误 exit `1`,资源目录锁冲突 exit `75`,`verify` 或 `doctor` 发现问题也返回非 0。
|
||||
|
||||
生产可以直接运行 `--watch`,也可以用 `--daemon` 后台运行,或者用 systemd service、容器或 Go 进程守护它。cron/systemd timer 仍可调用单次模式,但不再是 Rust 自动更新的唯一方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。生产官方资源目录应使用独立输出目录,不要指向现有客户端或人工维护的资源目录;上层读取原版资源时应读取 `--output/current`,不要读取 `.staging` 或 `versions` 中未切换的目录。汉化 Patch/导出应写入 `--localized-output`,并保留官方相对目录结构,不能写回 `--output/current`。发布状态分两档:`not_localized` 只发布原版资源、不发布汉化资源;`localized` 发布原版和汉化两套资源。非 dry-run 每轮会创建 `--output/.official-sync.lock`,防止并发写同一官方资源目录;live daemon 还会阻止前台写命令直接修改它正在管理的同一目录。
|
||||
生产可以直接运行 `--watch`,也可以用 `--daemon` 后台运行,或者用 systemd service、容器或 Go 进程守护它。cron/systemd timer 仍可调用单次模式,但不再是 Rust 自动更新的唯一方式。下载默认并发 8,可用 `--download-concurrency` / `BAT_DOWNLOAD_CONCURRENCY` 配置为 `1..=256`;worker 动态领取共享 plan,finished 进度即时按完成数统计,发布 report 仍按 plan 顺序。项目是否热更新、热重载或重启进程,由上层业务集成决定。生产官方资源目录应使用独立输出目录,不要指向现有客户端或人工维护的资源目录;上层读取原版资源时应读取 `--output/current`,不要读取 `.staging` 或 `versions` 中未切换的目录。汉化 Patch/导出应写入 `--localized-output`,并保留官方相对目录结构,不能写回 `--output/current`。发布状态分两档:`not_localized` 只发布原版资源、不发布汉化资源;`localized` 发布原版和汉化两套资源。非 dry-run 每轮会创建 `--output/.official-sync.lock`,防止并发写同一官方资源目录;`release.cleanup` execute 使用同一个锁并在锁内重新生成/校验 `plan_id`,localized cleanup 使用 `.localized-release.lock`;live daemon 还会阻止前台写命令直接修改它正在管理的同一目录。
|
||||
|
||||
需要只做探测时可以加 `--dry-run`。需要关闭本地 audit 或 repair 时可以显式使用 `--no-audit-local` 或 `--no-repair`,但生产同步默认应保持开启。
|
||||
|
||||
@@ -339,7 +344,7 @@ make official-smoke
|
||||
|
||||
默认输出在 `/tmp/bat-official-smoke-<UTC timestamp>/`,脚本会执行 dry-run plan、首次全量拉取、二次 `up_to_date`、本地文件破坏后的 `repair`、repair 后 `verify`,并检查 stderr progress log 中存在下载已完成计数、单文件进度和校验结果摘要。完整说明见 `docs/guides/official-full-pull-smoke.md`。
|
||||
|
||||
## 7. 例外输入
|
||||
## 7. 输入模式
|
||||
|
||||
可接受的 `server-info` 输入是:
|
||||
|
||||
@@ -356,7 +361,7 @@ make official-smoke
|
||||
|
||||
- `infrastructure/examples/official_launcher_bootstrap.rs`
|
||||
- `infrastructure/examples/official_pull_plan.rs`
|
||||
- `infrastructure/src/bin/bat_official_sync.rs`
|
||||
- `infrastructure/src/bin/bat_official_sync.rs`(薄入口;控制面实现位于同目录 `bat/`)
|
||||
- `infrastructure/examples/official_update_check.rs`(历史/开发入口;生产优先使用 `bat`)
|
||||
- `adapters/examples/yostar_jp_client_bootstrap.rs`
|
||||
- `adapters/examples/yostar_jp_discovery.rs`
|
||||
|
||||
@@ -83,13 +83,17 @@ contract 为准,不应绕过 daemon 状态文件或扩展 `bat-ffi` 作为主
|
||||
| `daemon.status` | 已实现 | `null` | 后台状态报告。 |
|
||||
| `daemon.logs` | 已实现 | `{ "tail": 200 }` | 日志尾部报告。 |
|
||||
| `daemon.stop` | 已实现 | `null` | accepted ack。 |
|
||||
| `daemon.restart` | 已实现 | `null` | accepted ack;启动 Rust lifecycle controller,并在响应后停止当前 daemon。 |
|
||||
| `daemon.reload` | 已实现 | `null` | accepted ack。 |
|
||||
| `daemon.refresh` | 已实现 | `{ "force": false }` | accepted ack。 |
|
||||
| `daemon.doctor` | 已实现 | `null` | 只读诊断报告。 |
|
||||
| `daemon.restart` | 保留 | `null` | live RPC 不执行;由 CLI 生命周期入口处理。 |
|
||||
| `daemon.clean-stable` | 保留 | `null` | live RPC 不执行;由 CLI 离线清理入口处理。 |
|
||||
|
||||
`bat.status`、`bat.stop`、`bat.reload`、`bat.refresh`、`bat.logs`、
|
||||
`daemon.restart` 不在 daemon 线程内手写第二套启动流程;它启动本机 Rust
|
||||
`bat restart --state-dir ...` lifecycle controller,由既有 CLI restart 路径复用
|
||||
保存的启动参数、代理凭据、PID/socket 替换和控制锁。
|
||||
|
||||
`bat.status`、`bat.stop`、`bat.restart`、`bat.reload`、`bat.refresh`、`bat.logs`、
|
||||
`bat.doctor`、`bat.clean-stable` 是兼容别名;新代码应使用 `daemon.*`。
|
||||
|
||||
### resource
|
||||
@@ -100,23 +104,47 @@ contract 为准,不应绕过 daemon 状态文件或扩展 `bat-ffi` 作为主
|
||||
| `resource.sync` | 已实现 | `{ "force": false }` | `{ "task_id": "...", "kind": "resource.sync" }`。 |
|
||||
| `resource.verify` | 已实现 | `null` | `{ "task_id": "...", "kind": "resource.verify" }`。 |
|
||||
| `resource.repair` | 已实现 | `null` | `{ "task_id": "...", "kind": "resource.repair" }`。 |
|
||||
| `resource.manifest` | 已实现 | `{ "offset": 0, "limit": 100 }` | 当前 download manifest 分页。 |
|
||||
| `resource.list` | 已实现 | `{ "offset": 0, "limit": 100 }` | `resource.manifest` 的兼容别名。 |
|
||||
| `resource.index` | 已实现 | `{ "offset": 0, "limit": 100, "type": "asset_bundle", "hash": "...", "path_pattern": "*" }` | 当前 `ResourceRepository` 分页/过滤查询。 |
|
||||
| `resource.manifest` | 已实现 | `{ "release_id": "...", "expected_publication_identity": "...", "expected_manifest_identity": "...", "expected_verification_generation": 7, "offset": 0, "limit": 100 }` | 绑定一个 Rust attested official generation 的 download manifest 分页;generation 为必需绑定条件,`0` 也不能省略或忽略。 |
|
||||
| `resource.list` | 已实现 | 同 `resource.manifest` | `resource.manifest` 的兼容别名。 |
|
||||
| `resource.index` | 已实现 | `{ "offset": 0, "limit": 100, "type": "asset_bundle", "hash": "...", "path_pattern": "*", "release_id": "...", "platform": "windows", "destination": "...", "archive_entry": "...", "parse_status": "parsed", "format": "json" }` | 当前 `ResourceRepository` 分页/过滤查询。 |
|
||||
|
||||
`resource.repair` 会开启本地 manifest audit + repair,不继承 `force`。
|
||||
`resource.manifest` / `resource.list` 查询当前已发布 release 的
|
||||
`official-download-manifest.json`;`resource.index` 查询可选导入产生的
|
||||
SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且
|
||||
`data.available=false`,不会隐式创建数据库。`limit` 范围是 `1..=1000`,
|
||||
非法参数返回 `BAT-ERR-700002`。
|
||||
`data.available=false`,不会隐式创建数据库。`resource.index` 可按
|
||||
`resource_type`/`type`、`hash`、`path_pattern`、`official_release_id`/`release_id`、
|
||||
`platform`、`destination`、`bundle_path`、`archive_entry`、`parse_status` 和
|
||||
`text_unit_format`/`format` 过滤;`path_id`、`class_id` 和 `field_path`
|
||||
属于 `parse.text_units` / `parse.errors` 的对象级查询。`limit` 范围是
|
||||
`1..=1000`,非法参数返回 `BAT-ERR-700002`。
|
||||
|
||||
`resource.manifest` 的请求必须携带由 `release.attestation` 返回的
|
||||
`release_id`、`expected_publication_identity`、`expected_manifest_identity` 和
|
||||
`expected_verification_generation`。
|
||||
每一页返回 `release_id`、`resource_root`、`manifest_version`、
|
||||
`publication_identity`、`mapping_identity`、`manifest_identity`、`generation`、
|
||||
`total_entries`、`offset`、`limit` 和 `entries`。Rust 在当前 release 切换或 identity
|
||||
不匹配、attestation 不可用或 generation 改变时拒绝请求;Go 会逐页验证 channel、
|
||||
这些 identity、generation、manifest version、页 offset/limit、total 和最终 entry
|
||||
count,任何一页不一致都会丢弃整个候选快照。
|
||||
|
||||
`resource.index` 的 `entries[]` 是 `Resource` JSON,除 `id`、`local_path`、
|
||||
`entry` 外会包含 `metadata`:`official_release_id`、`platform`、
|
||||
`bundle_path`、`archive_entries`、`parse_statuses`、`unity_versions`、
|
||||
`text_assets`、`text_unit_count`、`text_unit_formats` 和
|
||||
`text_unit_error_count` 等字段。旧索引库会通过 `metadata_json` 迁移列得到
|
||||
默认空 metadata。
|
||||
`text_unit_error_count` 等字段。`entry` 还会保留 Addressables 的
|
||||
`provider_id`、`bundle_name`、`hash`、`size`、`crc` 和 `dependencies`。
|
||||
旧索引库会通过 `metadata_json` 以及资源字段兼容迁移得到默认空值。
|
||||
|
||||
`resource.state`、`catalog.status`、`parse.status` 和 `localized.status`
|
||||
都会返回当前观察面的短状态 `status` 与稳定状态码 `status_code`。`status_code`
|
||||
使用命名空间格式,例如 `official.up_to_date`、`official.published`、
|
||||
`parse.completed`、`translation.queued_offline`、`localized.published` 和
|
||||
`distribution.ready`。这些状态码描述资源/解析/翻译 handoff/汉化/分发生命周期;
|
||||
失败原因仍使用 `BAT-ERR-*` 错误码,二者不混用。响应还会包含
|
||||
`status_phase`、`status_terminal` 和 `status_retryable`,供 `bat-api` 等读侧
|
||||
决定展示、重试或 readiness。
|
||||
|
||||
官方资源完整新版本发布后,Rust 侧会先比较上一完整 release 与当前 release
|
||||
的 download manifest,并在当前 release 根目录写出:
|
||||
@@ -132,11 +160,95 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且
|
||||
本地文件未变且索引有效时复用,不重复解析。
|
||||
- `official-textunit-tasks.json`:只由 added + modified 资源、parse cache 和
|
||||
TextUnit 明细索引派生,记录 TextUnit 任务、跳过原因和解析诊断。
|
||||
- `crowdin-textunit-queue.json`:只包含已产生 TextUnit 的离线任务,预留给后续
|
||||
Crowdin worker;当前不会发出网络请求。
|
||||
- `crowdin-textunit-queue.json`:只包含已产生 TextUnit 的离线任务;官方同步阶段
|
||||
不发出 provider 网络请求。
|
||||
- `translation-tasks.sqlite`:当前 release 的可变 worker 状态库,记录
|
||||
queued / running / failed / completed / skipped、attempt count、provider run
|
||||
ID、provider、TextUnit 级译文结果、lease、失败分类、可重试标记和
|
||||
next attempt;当前 schema version 为 V2,schema 由 `schema_migrations` 版本表
|
||||
管理,并通过只读 fingerprint、`BEGIN IMMEDIATE` 和显式 V1 → V2 migration
|
||||
保证 future/未知 schema fail closed。
|
||||
- `translation-handoff.json`:当前 release 的版本化 job/unit/provider run 交接
|
||||
快照;worker 更新后的实时状态仍以 `translation-tasks.sqlite` 为准。
|
||||
- `translation-memory.sqlite`:跨 release 的项目级 Translation Memory,不位于
|
||||
`versions/<id>`,也不与 `translation-tasks.sqlite` 共用;记录 raw source/hash、完整
|
||||
TextUnit context、candidate/trusted、translation 和 release/TextUnit/provider/run
|
||||
provenance;当前 schema version 为 V1,打开时先进行只读 fingerprint preflight,
|
||||
再在 writer transaction 内补齐 `schema_migrations` 版本记录。默认路径为
|
||||
`<output>/translation-memory.sqlite`,可由
|
||||
`BAT_TRANSLATION_MEMORY_PATH`、`[translation.worker].translation_memory_path` 或 CLI
|
||||
覆盖。
|
||||
- `glossary.sqlite`:跨 release 的项目级 Glossary,不位于 `versions/<id>`,也不与
|
||||
`translation-tasks.sqlite` 或 TM 共用;记录 term、alias、推荐/允许译法、scope、
|
||||
priority、review 状态、source provenance、完整 source/review history 和 deletion
|
||||
audit;当前 schema version 为 V2。V2 正式吸收历史上未升版本的
|
||||
`glossary_term_deletions` drift,打开时通过 fingerprint 和 writer transaction
|
||||
区分 V1-A/V1-B 并 fail closed;正式 schema evolution 不再通过 `ensure_column`
|
||||
隐式修复。
|
||||
默认路径为
|
||||
`<output>/glossary.sqlite`,可由 `BAT_GLOSSARY_PATH`、`[translation.worker].glossary_path`
|
||||
或 CLI 覆盖。
|
||||
|
||||
删除资源只进入 `official-resource-changes.json`,不进入 Crowdin handoff。
|
||||
|
||||
### release
|
||||
|
||||
| 方法 | 状态 | params | data |
|
||||
|---|---|---|---|
|
||||
| `release.attestation` | 已实现 | `null` | 当前 official 的轻量 health/publication proof:`available`、`ready`、`channel`、`release_id`、`resource_root`、`publication_identity`、`mapping_identity`、`manifest_identity`、`entry_count`、`integrity_status`、`status`、`status_code`、`verification_generation`、`verified_at`、`max_age_seconds` 和 diagnostics。只读取 current、publication anchor、manifest 元数据与 freshness,不扫描历史 release 或计算资源文件 BLAKE3。 |
|
||||
| `release.status` | 已实现 | `null` | official/localized current、source relation、match、历史 release 和 manifest/artifact/distribution integrity 的重型管理诊断,仍返回管理侧 `official_distribution_ready`;普通 current bat-api readiness 使用 `release.attestation`。 |
|
||||
| `release.list` | 已实现 | `{ "channel": "official" }` 或 `{ "channel": "localized" }`,可省略 | 对应 namespace 的历史 release 摘要,包含 stable ID、created/published、current pointer、`rollback_available`、lifecycle、`stale`/`damaged`/`referenced`/`unknown`、legacy 和诊断。 |
|
||||
| `release.distribution` | 已实现 | `{ "channel": "official", "release_id": "...", "destination": "...", "offset": 0, "limit": 1000 }`,均可省略 | Rust 只选择具有独立 `official-distribution-publication.json` 且 identity 与当前 manifest 一致的 verified `resource_root`;有 `destination` 时是 single-entry lookup,响应固定 `total=1, offset=0, limit=1, entries.length=1`,使用 published mapping identity/destination index,只校验该实际文件的 bytes/BLAKE3,不重新执行全量映射或资源 audit;无 `destination` 时保留管理查询分页语义。localized 还必须匹配 source official 的 published identity,并使用发布时生成的实际字节 metadata,不复用 official size/hash;默认 channel 为 official,选择失败返回 `available=false`,不跨 channel fallback。 |
|
||||
| `release.cleanup` | 已实现 | dry-run `{ "execute": false }`;执行 `{ "execute": true, "plan_id": "..." }` | cleanup plan、candidate/retain reasons、blocking references 和 removed paths;执行前会重新生成并比对 `plan_id`。 |
|
||||
|
||||
`release.status`、`release.list` 和 `release.distribution` 只读现有 official/localized
|
||||
state、current、manifest、文件系统和 CAS/reference 元数据,不创建第二套 release 状态。
|
||||
分发选择使用发布后的轻量 manifest 和文件 size/单文件 BLAKE3 校验,不在 HTTP 热路径
|
||||
重新执行完整 release audit;localized 还要求 distribution manifest 的 destination/URL
|
||||
集合与 source official manifest 一致,并使用发布时记录的实际 localized bytes/hash。
|
||||
publication 文件缺失、manifest content identity 变化或 source mapping identity 不一致时,
|
||||
即使单个目标文件本身完整,也返回 `available=false`;旧 release 可被列出并标记
|
||||
`legacy`,但不会被 distribution 读侧自动重建 publication metadata。默认官方分发行为不变。
|
||||
`release.cleanup` 只删除 Rust 能证明是普通目录且未被 current、rollback、staging、
|
||||
source、state、manifest、CAS 或未知 ownership 引用的历史项,不修改 current,也不承担
|
||||
rollback 或 repair。
|
||||
|
||||
### schedule
|
||||
|
||||
调度计划由 Rust `bat` 持有,状态文件为 daemon `state_dir` 下的
|
||||
`bat-schedules.json`。CLI、RPC 和 `bat-api` dashboard 都调用同一组原子
|
||||
读改写逻辑,不在 Go 侧复制计划状态。
|
||||
|
||||
| 方法 | 状态 | params | data |
|
||||
|---|---|---|---|
|
||||
| `schedule.list` | 已实现 | `null` 或 `{ "id": "...", "group": "res", "enabled": true }` | `{ "command": "schedule-list", "query": {...}, "schedules": [...] }`。 |
|
||||
| `schedule.add` | 已实现 | 调度 mutation | 新建 schedule report。 |
|
||||
| `schedule.update` | 已实现 | 调度 mutation,必须有 `id` | 更新后的 schedule report。 |
|
||||
| `schedule.remove` | 已实现 | `{ "id": "daily-pull" }` | 删除报告。 |
|
||||
| `schedule.run` | 已实现 | `{ "id": "daily-pull", "group": "res", "force": true, "max_runs": 1 }`,字段可省略 | 到期或强制执行报告;省略 `id` 执行指定 group 的到期计划。 |
|
||||
|
||||
调度 mutation 字段如下:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | string | 计划 ID;add 必填,update/remove 用于定位。 |
|
||||
| `group` | string | `res`、`parse` 或 `i18n`;对应一级工作流。 |
|
||||
| `action` | string | `res` 的 `pull/refresh/verify/repair`、`parse` 的 `run/repack/clear-cache`、`i18n` 的 `run/export/validate/publish`。 |
|
||||
| `args` | string[] | 目标工作流的 CLI 参数。 |
|
||||
| `next_run_unix_seconds` | uint64 | 指定下一次执行时间;不能和 `delay_seconds` 同时使用。 |
|
||||
| `delay_seconds` | uint64 | 从当前时间计算下一次执行时间。 |
|
||||
| `every_seconds` | uint64 | 周期秒数;必须大于 0。 |
|
||||
| `count` | uint64 | 最大执行次数;省略周期无限执行,非周期计划默认执行一次。 |
|
||||
| `clear_args` | bool | update 时清空工作流参数。 |
|
||||
| `clear_every` | bool | update 时清除周期并转为单次计划。 |
|
||||
| `enabled` | bool | 启用或停用计划。 |
|
||||
|
||||
`schedule.run.max_runs` 必须大于 0,用于限制一次轮询最多领取的到期计划数。
|
||||
|
||||
`count > 1` 必须和周期同时存在;`schedule.run` 的 `force=true` 只忽略
|
||||
到期时间,不会绕过 `enabled=false`。每次执行前先持久化下一次状态,执行后
|
||||
再持久化成功/失败和错误信息,避免进程中断后重复领取同一计划。
|
||||
|
||||
### parse
|
||||
|
||||
| 方法 | 状态 | params | data |
|
||||
@@ -144,6 +256,29 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且
|
||||
| `parse.status` | 已实现 | `null` | 当前官方 release 的解析缓存状态。 |
|
||||
| `parse.text_units` | 已实现 | `{ "offset": 0, "limit": 100, "destination": "*Table*", "archive_entry": "*.bytes", "path_id": 1, "class_id": 114, "field_path": "*Text*", "format": "json" }` | 当前官方 release 的 TextUnit 明细分页。 |
|
||||
| `parse.errors` | 已实现 | `{ "offset": 0, "limit": 100, "destination": "*Table*", "archive_entry": "*.bytes", "path_id": 1, "class_id": 114, "field_path": "*Text*", "format": "json" }` | 当前官方 release 的解析错误分页。 |
|
||||
| `translation.tasks` | 已实现 | `{ "offset": 0, "limit": 100, "task_id": "...", "release_id": "...", "destination": "...", "archive_entry": "...", "status": "skipped_parse_failed", "parse_status": "failed", "format": "json", "has_reason": true }` | 当前官方 release 的离线 TextUnit 翻译任务状态分页。 |
|
||||
| `translation.handoff` | 已实现 | `null` | 当前官方 release 的 job、unit、provider run 交接视图;动态合并队列和 SQLite worker 状态。 |
|
||||
| `translation.task.update` | 已实现 | `{ "task_id": "...", "status": "failed", "failure_reason": "...", "provider_run_id": "..." }` | 写入当前 release 的 provider worker 状态,返回可回查任务记录。 |
|
||||
| `translation.worker.run` | 已实现 | provider worker 参数 | 异步触发 Rust provider worker,返回 `{ "task_id": "...", "kind": "translation.worker.run", "worker": {...} }`。 |
|
||||
| `translation.proofread` | 已实现 | `null` | 将当前汉化 workflow 标记为人工校对中,返回工作流状态报告。 |
|
||||
| `translation.memory.summary` | 已实现 | 可选 `{ "translation_memory_path": "..." }` | 返回 TM persistence schema 版本、总记录数、candidate/trusted/rejected/superseded 状态计数和 trusted 冲突组计数。 |
|
||||
| `translation.memory.query` | 已实现 | `{ "source_text": "...", "source_context": {...}, "limit": 100 }` | 按 raw source 查询记录,返回 match kind、trust、translation 和 provenance;conflict 结果不可自动复用。 |
|
||||
| `translation.memory.confirm` | 已实现 | `{ "record_id": "...", "reviewer": "...", "reason": "...", "supersede_record_id": "..." }` | 显式确认 candidate 为 trusted;已有不同 current Trusted 时必须显式 supersede,worker 之后才可自动复用。 |
|
||||
| `translation.memory.conflicts` | 已实现 | 可选 `{ "translation_memory_path": "...", "limit": 100 }` | 只读列出 exact source/context 下存在多个 current Trusted 的冲突组。 |
|
||||
| `translation.memory.resolve_conflict` | 已实现 | `{ "winner_record_id": "...", "expected_trusted_record_ids": ["..."], "reviewer": "...", "reason": "..." }` | 使用稳定 record ID 原子解决历史 Trusted 冲突,保留 supersede 历史并写入 audit event。 |
|
||||
| `translation.glossary.summary` | 已实现 | 可选 `{ "glossary_path": "..." }` | 返回 Glossary schema 版本和 draft/approved/deprecated/rejected 计数;缺库只返回 `available=false`,不会创建空库。 |
|
||||
| `translation.glossary.query` | 已实现 | `{ "source_text": "...", "category": "...", "review_status": "approved", "limit": 100 }` | 查询 term、alias、scope、source provenance 和完整 source/review history。 |
|
||||
| `translation.glossary.diagnose` | 已实现 | `{ "source_text": "...", "context": {...} }` | 只对 approved term 生成 provider-neutral constraints,并返回冲突/覆盖诊断和 blocked 决策。 |
|
||||
| `translation.glossary.add` | 已实现 | Glossary term draft,包含 `term_id`、`source_term`、`recommended_translation`、`source` 等 | Rust 创建 draft/import term 并记录 source history。 |
|
||||
| `translation.glossary.update` | 已实现 | term draft + `reviewer`,可选 `reason` | Rust 替换 term definition,并记录 source/review history。 |
|
||||
| `translation.glossary.approve` | 已实现 | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | 将 term 明确置为 approved;只有 approved term 进入 worker/TM 自动流程。 |
|
||||
| `translation.glossary.deprecate` | 已实现 | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | 保留历史但停止自动应用。 |
|
||||
| `translation.glossary.delete` | 已实现 | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | 显式删除当前 term;需要 reviewer/reason,Rust 另保留删除审计快照,返回删除前快照。 |
|
||||
|
||||
TM 的自动复用规则是 raw source 完全相同、完整 context 完全相同且状态为 `trusted`;
|
||||
context 缺失/不一致、normalized source 仅辅助查询、candidate 或 provider 成功都不会
|
||||
自动复用或自动变成 trusted。TM 查询、confirm 和诊断由 Rust `bat` 持有,Go
|
||||
`bat-api` 不维护第二份 TM 状态。
|
||||
|
||||
`parse.status` 是只读查询;没有当前 release 或没有解析缓存时返回
|
||||
`ok=true` 且 `data.available=false`。解析缓存来自官方原版资源目录,不读取
|
||||
@@ -151,7 +286,9 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且
|
||||
`textunit_index_available=true`、`textunit_index_path` 和
|
||||
`textunit_index_summary`;存在 `official-textunit-tasks.json` 时,响应会包含
|
||||
`textunit_queue_available=true`、`textunit_task_queue_path` 和
|
||||
`textunit_task_summary`。
|
||||
`textunit_task_summary`。当 TextUnit 队列存在且有离线任务时,
|
||||
`translation_status_code=translation.queued_offline`;provider worker 完成任务后,
|
||||
同一查询面会返回已落库的 worker 状态和 TextUnit 级译文结果。
|
||||
|
||||
`parse.text_units` / `parse.errors` 是只读查询;没有当前 release 或没有
|
||||
`official-textunit-index.json` 时返回 `ok=true` 且 `data.available=false`。
|
||||
@@ -175,22 +312,106 @@ offset 和 error。TypeTree-covered managed reference 字段会进入结构化
|
||||
完整 managed reference registry 等暂不支持结构会进入解析错误,而不是静默降级为
|
||||
低保真文本。
|
||||
|
||||
`translation.tasks` 优先查询当前 release 的 `translation-tasks.sqlite`,旧 release
|
||||
没有该文件时回退到 `official-textunit-tasks.json`;用于查看离线 TextUnit
|
||||
翻译任务候选和 provider worker 状态。没有当前 release 或没有任务队列时返回
|
||||
`ok=true` 且 `data.available=false`。过滤参数包括 `task_id`、
|
||||
`official_release_id`/`release_id`、`destination`、`path_pattern`、
|
||||
`archive_entry`、`status`/`task_status`、`worker_status`、`parse_status`、
|
||||
`text_unit_format`/`format`、`has_reason` 和 `has_failure_reason`。
|
||||
`entries[]` 会包含 `official_release_id`、`destination`、`archive_entry`、
|
||||
`parse_status`、队列 `status`、`task_status`、`failure_reason`、`attempt_count`、
|
||||
`provider_run_id`、TextAsset/TextUnit 摘要和校验指纹。
|
||||
|
||||
`translation.task.update` 只更新当前 release 的 SQLite 状态库,不改写 immutable
|
||||
队列文件,也不主动访问 Crowdin。`status` 支持 `queued`、`running`、`failed`、
|
||||
`completed` 和 `skipped`;进入 `running` 会增加 attempt count,`completed` 会
|
||||
记录完成时间,`failed` 可写入 `failure_reason`。人工校对流程可以在
|
||||
`status=completed` 时额外提交 `provider`、`provider_run_id` 和
|
||||
`translation_results[]`,每个结果必须包含 `unit_id`、`source_text` 和
|
||||
`translated_text`;结果也可以提交完整的 `glossary_override`(`reviewer`、
|
||||
`reason`、`provenance`、`confirmed_unix_seconds` 和当前 blocking QA 的
|
||||
`qa_identity`),用于人工确认 Glossary blocking deviation。`qa_identity` 必须与
|
||||
Rust 重新计算的当前 QA 完全相等;缺失或过期的 override 不授权。Rust 会用当前
|
||||
`official-textunit-index.json` 校验 unit、
|
||||
source text、destination 和 archive entry 后再落库。因此 worker 或人工校对流程
|
||||
消费 handoff 后,bat-api 可通过 `translation.tasks` 查询单项任务,也可通过
|
||||
`translation.handoff` 获取完整 job/unit/provider run 状态。`translation.handoff`
|
||||
不会触发下载或 provider 网络请求;没有当前 release 或任务队列时返回
|
||||
`data.available=false`。
|
||||
|
||||
`translation.worker.run` 通过 daemon 任务队列异步启动 Rust provider worker。
|
||||
provider worker 会先同步当前 release 的 TextUnit 队列到
|
||||
`translation-tasks.sqlite`,回收过期 lease,然后由 `concurrency` 个独立 worker
|
||||
循环 claim 下一项任务;任一 worker 完成当前任务后会立即领取下一项,不等待
|
||||
其他 worker 完成本轮批次。默认并发为 8,范围 `1..=256`。
|
||||
|
||||
provider worker 参数:
|
||||
|
||||
| 字段 | 类型 | 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `provider` | string | `mock` | `mock` 或 `crowdin`。 |
|
||||
| `fixture_path` | string/null | `null` | mock provider fixture;别名为 `translation_fixture`、`provider_fixture`、`mock_fixture`、`fixture`。 |
|
||||
| `concurrency` | uint | `8` | 独立 worker 数,范围 `1..=256`;别名为 `worker_concurrency`、`translation_concurrency`。 |
|
||||
| `max_attempts` | uint | `3` | 单个任务最大 claim 次数,必须大于 0。 |
|
||||
| `lease_seconds` | uint | `300` | claim lease 秒数,必须大于 0。 |
|
||||
| `retry_backoff_seconds` | uint | `5` | 可重试 provider 失败的 next attempt 间隔,可为 0。 |
|
||||
| `max_tasks` | uint/null | `null` | 本轮最多 claim 的任务数,设置时必须大于 0。 |
|
||||
| `worker_id` | string | `bat-rpc-worker` | lease 诊断用 worker ID 前缀。 |
|
||||
| `translation_memory_path` | string/null | 按配置推导 | 覆盖 Rust worker 使用的项目级 TM 数据库路径;未指定时使用 worker 配置或 `<output>/translation-memory.sqlite`。 |
|
||||
| `glossary_path` | string/null | 按配置推导 | 覆盖 Rust worker 使用的项目级 Glossary 数据库路径;未指定时使用 worker 配置或 `<output>/glossary.sqlite`。存在 Glossary 但无法打开时 worker fail-closed,不自动绕过 QA。 |
|
||||
|
||||
数字字段必须是 JSON number;字符串数字、负数和越界值会返回
|
||||
`BAT-ERR-700002`。`mock` provider 在没有 fixture 时把 source text 写成可诊断的
|
||||
mock 译文;`crowdin` provider 从 `CROWDIN_PROJECT_ID`、`CROWDIN_LANGUAGE_ID`、
|
||||
`CROWDIN_API_TOKEN` 读取配置,可选 `CROWDIN_API_BASE_URL` 和 `BAT_CURL`。
|
||||
token 不会进入报告、任务记录或调试输出。
|
||||
|
||||
Glossary 只把 `approved` term 发送为 provider constraints。worker 会在 trusted TM
|
||||
复用前、provider 返回后、人工 `translation.task.update` 和 workbench publish 前执行
|
||||
同一套确定性 QA;冲突或未使用推荐/允许译法的结果不会自动完成或发布。允许但非推荐译法
|
||||
产生 warning;blocking deviation 必须在对应结果中提交 `glossary_override`,并包含
|
||||
`qa_identity`、`reviewer`、`reason`、`provenance` 和确认时间。Glossary 定义变化会
|
||||
只使受影响 QA 的旧 override 失效;无关术语变化不会改变该 QA identity。系统不会在
|
||||
译文生成后做静默字符串替换。
|
||||
|
||||
### localized
|
||||
|
||||
| 方法 | 状态 | params | data |
|
||||
|---|---|---|---|
|
||||
| `localized.status` | 已实现 | `null` | 汉化发布状态、当前官方 release 匹配关系和汉化输出目录。 |
|
||||
| `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 / `.env` 中的 `BAT_LOCALIZED_OUTPUT` 或
|
||||
`localized.status` 严格按 daemon / `config.toml` 或环境变量中的 `BAT_LOCALIZED_OUTPUT` 或
|
||||
`--localized-output` 查询汉化产物目录,不把 `./bat-resources` 与
|
||||
`./bat-localized` 混用。当前支持未汉化发布状态和已汉化发布状态的只读报告。
|
||||
返回 `localized` 的条件是:`localized-version-state.json` 的官方 release ID
|
||||
匹配当前官方 release,`current` symlink 指向汉化发布根下对应的
|
||||
`versions/<id>`,并且该版本目录中的
|
||||
`status` / `status_code` 使用生命周期短状态和稳定状态码,例如
|
||||
`pending` / `localized.pending`、`stale` / `localized.stale`、`published` /
|
||||
`localized.published`、`localized.degraded`;旧的 `localized` / `not_localized` 业务标签放在
|
||||
`localized_release_status`。`translation_workflow_status` / `translation_workflow_status_code`
|
||||
用于表示汉化工作流的人工校对状态,例如 `manual_proofreading` /
|
||||
`translation.manual_proofreading`。返回 `localized_release_status=localized` 的条件是:
|
||||
`localized-version-state.json` 的官方 release ID 匹配当前官方 release,
|
||||
`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_contract_status`、
|
||||
`patch_manifest_integrity_status`(兼容别名)、`artifact_integrity_status`、
|
||||
`artifact_integrity_verified`、`artifact_integrity_error` 和
|
||||
`artifact_integrity_diagnostics`、
|
||||
`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
|
||||
|
||||
@@ -202,6 +423,9 @@ offset 和 error。TypeTree-covered managed reference 字段会进入结构化
|
||||
| `catalog.refresh` | 已实现 | `{ "force": false }` | `{ "task_id": "...", "kind": "catalog.refresh" }`。 |
|
||||
|
||||
只读查询在没有可用版本时返回 `ok=true` 且 `data.available=false`。
|
||||
`catalog.status` 可用时会返回 `status_code=official.published`,并用
|
||||
`distribution_status_code=distribution.ready` 表示该官方 release 可被读侧分发;
|
||||
不可用时对应 `official.unavailable` / `distribution.blocked`。
|
||||
|
||||
### task
|
||||
|
||||
@@ -272,7 +496,13 @@ daemon 重启后仍处于 `queued` 或 `running` 的历史任务会被标记为
|
||||
`data` 会返回 source / patch 或 replacement / target 的 size 与 BLAKE3。`target_path`
|
||||
不能与输入文件相同。
|
||||
|
||||
仍关闭的范围:发布级 `patch build` / `patch rollback`、复杂 UnityFS 语义编辑、
|
||||
`localized.publish` 从当前官方 TextUnit/工作台生成受支持的 UnityFS patch
|
||||
operation;当 TextUnit 带有 `archive_entry` 时,发布 manifest 的 operation
|
||||
会记录该可选字段,Rust 发布器会在独立 staging 中校验、重建内层 UnityFS 并
|
||||
重写外层 ZIP。ZIP 路径、内层解析或重打包校验失败时整个发布失败,不会只发布
|
||||
部分结果。
|
||||
|
||||
仍关闭的范围:通用 manifest 驱动的发布级 `patch build` / `patch rollback`、复杂 UnityFS 语义编辑、
|
||||
`unityfs.inspect`、通用 manifest 驱动 release 切换。调用这些规划方法仍返回
|
||||
`BAT-ERR-700003`。
|
||||
|
||||
@@ -289,7 +519,9 @@ CLI 对应关系:
|
||||
|
||||
`bat-api` 应直接调用本 RPC contract,不通过 `exec` 调用 `bat` binary。
|
||||
`bat` binary 是人类 CLI 和进程生命周期工具;默认 `refresh` / `repair`
|
||||
在 daemon 可用时也会作为 RPC client 调用同一个 socket。
|
||||
在 daemon 可用时也会作为 RPC client 调用同一个 socket。`daemon.restart`
|
||||
会启动 Rust lifecycle controller 复用同一套 CLI restart 路径,Go 层仍不直接
|
||||
`exec` 或解析 `bat` stdout。
|
||||
|
||||
人类 CLI 的只读查询命令与 RPC 对应关系如下:
|
||||
|
||||
@@ -298,14 +530,92 @@ CLI 对应关系:
|
||||
| `bat parse-status` | `parse.status` |
|
||||
| `bat parse-text-units` | `parse.text_units` |
|
||||
| `bat parse-errors` | `parse.errors` |
|
||||
| `bat translation-tasks` | `translation.tasks` |
|
||||
| `bat translation-handoff` | `translation.handoff` |
|
||||
| `bat i18n task list` / `bat i18n task status` | `translation.tasks` |
|
||||
| `bat i18n task update` | `translation.task.update` |
|
||||
| `bat i18n worker run` | `translation.worker.run` |
|
||||
| `bat i18n proofread` | `translation.proofread` |
|
||||
| `bat i18n memory summary` / `bat i18n memory query` | `translation.memory.summary` / `translation.memory.query` |
|
||||
| `bat i18n memory confirm` / `bat i18n memory conflicts` | `translation.memory.confirm` / `translation.memory.conflicts` |
|
||||
| `bat i18n memory resolve-conflict` | `translation.memory.resolve_conflict` |
|
||||
| `bat i18n glossary summary` / `bat i18n glossary query` | `translation.glossary.summary` / `translation.glossary.query` |
|
||||
| `bat i18n glossary diagnose` | `translation.glossary.diagnose` |
|
||||
| `bat i18n glossary add/update` | `translation.glossary.add` / `translation.glossary.update` |
|
||||
| `bat i18n glossary approve/deprecate` | `translation.glossary.approve` / `translation.glossary.deprecate` |
|
||||
| `bat i18n glossary delete` | `translation.glossary.delete` |
|
||||
| `bat localized-status` | `localized.status` |
|
||||
| `bat resource-index` | `resource.index` |
|
||||
|
||||
`bat resource-index` 支持 `--offset`、`--limit`、`--resource-type`、`--hash`
|
||||
和 `--path-pattern`;`bat parse-text-units` / `bat parse-errors` 支持
|
||||
`--offset`、`--limit`、`--destination`、`--archive-entry`、`--path-id`、
|
||||
`--class-id`、`--field-path` 和 `--format`;这些过滤参数不适用于
|
||||
`parse-status` 或 `localized-status`。
|
||||
`bat translation-tasks` / `bat i18n tasks`、`bat translation-handoff` / `bat i18n handoff`、
|
||||
`bat localized-status` / `bat i18n status` 都对应同一 RPC;这里列出的是推荐命令形态。
|
||||
|
||||
`bat resource-index` 支持 `--offset`、`--limit`、`--resource-type`、`--hash`、
|
||||
`--path-pattern`、`--release-id`、`--platform`、`--destination`、
|
||||
`--bundle-path`、`--archive-entry`、`--parse-status` 和 `--format`;
|
||||
这些常用 metadata 过滤在 SQLite `ResourceRepository` 中下推执行。`bat doctor cas`
|
||||
是本地只读 CLI 诊断入口,不对应 live RPC 方法;它读取 CLI 指定的 CAS 根目录和
|
||||
元数据库路径,报告缺失或对象文件异常,且不会创建空库。
|
||||
`bat parse-text-units` / `bat parse-errors` 支持 `--offset`、`--limit`、
|
||||
`--destination`、`--path-pattern`、`--archive-entry`、`--path-id`、
|
||||
`--class-id`、`--field-path` 和 `--format`;`bat translation-tasks` 支持
|
||||
`--offset`、`--limit`、`--task-id`、`--release-id`、`--destination`、
|
||||
`--path-pattern`、`--archive-entry`、`--task-status`、`--worker-status`、
|
||||
`--parse-status`、`--format`、`--has-reason` 和 `--has-failure-reason`。这些过滤参数不适用于
|
||||
`parse-status`、`translation-handoff` 或 `localized-status`。
|
||||
|
||||
### Go 客户端表面
|
||||
|
||||
`internal/backendrpc.Client` 是 Unix socket JSON-RPC 传输客户端:
|
||||
|
||||
- `Call` 可发送本文档中的任意已记录方法,并负责 JSON-RPC transport、
|
||||
envelope 和 `ApiError` 解码;它不是 bat-api 的 HTTP 任意 RPC proxy。
|
||||
- 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.*`、`release.status/list/distribution/cleanup`、
|
||||
`localized.status`、`localized.publish`、`localized.rollback`、
|
||||
`translation.tasks`、`translation.handoff`、`translation.task.update`、
|
||||
`translation.worker.run`、`translation.proofread`、`translation.memory.summary`、
|
||||
`translation.memory.query`、`translation.memory.confirm`、`translation.memory.conflicts`、
|
||||
`translation.memory.resolve_conflict`、`translation.glossary.summary`、
|
||||
`translation.glossary.query`、`translation.glossary.diagnose`、`translation.glossary.add`、
|
||||
`translation.glossary.update`、`translation.glossary.approve`、`translation.glossary.deprecate`、
|
||||
`translation.glossary.delete`、
|
||||
`task.*` 和三个 `unityfs.patch_*` 方法。
|
||||
- `resource.index` 和 `patch.apply` 当前没有专用 typed helper;需要直接使用 `Call`,并仍须遵守
|
||||
本契约的参数和响应定义。
|
||||
|
||||
`internal/api` 对 bat-api 生产路径进一步收窄接口:
|
||||
|
||||
| Go 接口 | 允许调用的 RPC | 用途 |
|
||||
|---|---|---|
|
||||
| `Backend` | `daemon.status`、`daemon.doctor`、`resource.state`、`catalog.status`、`release.attestation`、绑定后的 `resource.manifest` | 启动发现、周期刷新和资源分发基础数据 |
|
||||
| `AttestationBackend` | `release.attestation` | current official 轻量 health/publication proof;不触发历史 release 扫描 |
|
||||
| `ReleaseStatusBackend` | `release.status` | 鉴权管理面的 official/localized 重型 release 诊断;Go 不重新实现 verifier |
|
||||
| `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 调度计划控制 |
|
||||
| `DaemonLogsBackend` | `daemon.logs` | 鉴权后的 daemon 日志尾部查询 |
|
||||
| `TaskBackend` | `task.list`、`task.status`、`task.logs`、`task.cancel` | 鉴权后的 daemon 任务查询和取消 |
|
||||
| `ParseBackend` | `parse.status`、`parse.text_units`、`parse.errors` | 鉴权后的当前 release 解析状态、TextUnit 和解析错误只读查询 |
|
||||
| `TranslationBackend` | `translation.tasks`、`translation.handoff`、`translation.task.update`、`translation.worker.run`、`translation.proofread` | 鉴权后的 dashboard 翻译任务查询、交接视图、状态回写、provider worker 触发与人工校对标记 |
|
||||
| `TranslationMemoryBackend` | `translation.memory.summary`、`translation.memory.query`、`translation.memory.confirm`、`translation.memory.conflicts`、`translation.memory.resolve_conflict` | 鉴权后的 TM 摘要、source/context 查询和 Trusted 冲突治理;Go 只转发,不持有 TM 状态 |
|
||||
| `GlossaryBackend` | `translation.glossary.summary/query/diagnose/add/update/approve/deprecate/delete` | 鉴权后的 Glossary 摘要、term/history 查询、确定性诊断和审核/删除 mutation;Go 只转发,不持有 Glossary 状态 |
|
||||
| `LocalizedBackend` | `localized.status`、`localized.publish`、`localized.rollback` | 鉴权后的汉化 release 状态、发布与显式回滚 |
|
||||
| `ReleaseBackend` | `release.status`、`release.list`、`release.distribution`、`release.cleanup` | 鉴权后的双 release 查询、验证分发选择和 dry-run/execute cleanup;Go 不持有 release 状态 |
|
||||
|
||||
`daemon.stop`、`daemon.clean-stable` 和任意通用 RPC 不属于 bat-api 管理控制面。
|
||||
Rust dispatch、Go transport 和 bat-api 接口的权威实现位置分别是
|
||||
`infrastructure/src/bin/bat/app.rs`、`internal/backendrpc/client.go` 和
|
||||
`internal/api/rpc_release.go`;修改方法、字段或 allowlist 时必须同步更新本文档。
|
||||
|
||||
Go mirror contract fixture 固化在 `internal/api/testdata/contract/`,覆盖
|
||||
`catalog.status` available/unavailable、`resource.manifest` page0、对应
|
||||
`official-sync-snapshot.json`、Translation Memory query/缺库 mirror 和 Glossary
|
||||
query/source-history mirror。
|
||||
这些 fixture/mirror 由 Rust 输出形状归一化而来,只用于
|
||||
schema / mirror 回归;live daemon socket 和完整 fixture release 切换由
|
||||
`make bat-api-local-live-smoke` 在同机 `/tmp` 隔离环境中验证。该 smoke 不替代
|
||||
`make official-smoke` 的官方网络全量下载验证。
|
||||
|
||||
禁止事项:
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
# bat-api / Rust bat Contract Fixture Handoff
|
||||
|
||||
更新时间:2026-07-28
|
||||
更新时间:2026-09-04
|
||||
|
||||
本文用于两个 Codex 窗口之间间接联调 `bat-api` 与 Rust `bat` 的跨语言 contract fixture。它只定义协作协议和验收标准,不包含已审核 fixture。
|
||||
本文用于两个 Codex 窗口之间间接联调 `bat-api` 与 Rust `bat` 的跨语言 contract fixture。
|
||||
仓库内归一化 fixture 已交付;本文保留生成、审核和后续扩展的协作协议。
|
||||
|
||||
2026-07-31 更新:已审核归一化 fixture 已落入
|
||||
`internal/api/testdata/contract/`,Go 侧通过
|
||||
`internal/api/contract_fixture_test.go` 固化 mirror struct 验证。本文件保留为
|
||||
后续重新生成或扩展 contract fixture 时的协作协议。
|
||||
|
||||
## 最小上下文包
|
||||
|
||||
@@ -11,7 +17,9 @@
|
||||
- 本次联调对象是 Rust `bat` RPC / snapshot JSON 与 Go `bat-api` mirror struct 的 contract fixture。
|
||||
- 联调不要求本地运行全量长期服务端 `bat`;允许 Rust 侧使用 fixture root 或临时目录走真实代码路径导出 JSON。
|
||||
- fixture 审核前只能放在 `/tmp/bat-contract-fixture/`,不能直接提交到仓库。
|
||||
- Go 侧已经实现 player-facing HTTP 鉴权、限流、访问日志、反代适配、OpenAPI 和 `/admin/` 预留;contract fixture 是剩余跨语言强契约工作。
|
||||
- Go 侧已经实现 player-facing HTTP 鉴权、限流、访问日志、反代适配、OpenAPI 和
|
||||
`/admin/` 控制入口;仓库内 contract fixture 和同机 live daemon socket / 完整
|
||||
fixture release 切换联调均已完成,命令为 `make bat-api-local-live-smoke`。
|
||||
- Go 侧当前相关代码入口:
|
||||
- `internal/api/rpc_release.go`
|
||||
- `internal/api/release_index.go`
|
||||
@@ -42,11 +50,13 @@
|
||||
catalog-status.unavailable.raw.json
|
||||
resource-manifest.page0.raw.json
|
||||
official-sync-snapshot.raw.json
|
||||
glossary-query.raw.json
|
||||
normalized/
|
||||
catalog-status.available.json
|
||||
catalog-status.unavailable.json
|
||||
resource-manifest.page0.json
|
||||
official-sync-snapshot.json
|
||||
glossary-query.json
|
||||
notes.md
|
||||
```
|
||||
|
||||
@@ -64,6 +74,9 @@ Rust 窗口请基于当前真实代码生成或导出以下 JSON:
|
||||
2. `catalog.status` available=false 响应。
|
||||
3. `resource.manifest` 第一页响应,至少包含 1 到 2 个 entries。
|
||||
4. 对应 release 的 `official-sync-snapshot.json`。
|
||||
5. Rust Glossary domain/feature contract V1、SQLite persistence schema V2 的
|
||||
`translation.glossary.query` 响应,至少包含 alias、approved
|
||||
review、source provenance 和 created/approved history。
|
||||
|
||||
输出应来自 Rust 代码路径,而不是手写 JSON。允许使用 fixture resource root 或临时目录,但不能依赖开发机真实资源目录。
|
||||
|
||||
@@ -90,14 +103,14 @@ Go 窗口读取归一化后的 JSON,验证:
|
||||
- `version.completed_unix_seconds`
|
||||
- `version.resource_root`
|
||||
- `launcher_metadata`
|
||||
- `game_main_config`
|
||||
- `game_main_config_bootstrap`
|
||||
2. `parseCatalogStatus` 对 `available=false` 返回不可用而不是错误。
|
||||
3. `resource.manifest` entry 字段能映射为 Go `ResourceManifestEntry`:
|
||||
- `url`
|
||||
- `destination`
|
||||
- `bytes`
|
||||
- `blake3`
|
||||
4. 本地 snapshot fixture 使用 `game_main_config_bootstrap`,RPC `catalog.status` 使用 `game_main_config`。
|
||||
4. 本地 snapshot fixture 与 RPC `catalog.status` 均使用 `game_main_config_bootstrap`。
|
||||
5. `bat-api` bootstrap 和 launcher bootstrap 不泄露归一化前的开发机路径。
|
||||
|
||||
Go 侧审核通过后的落地建议:
|
||||
@@ -118,8 +131,8 @@ Go 侧审核通过后的落地建议:
|
||||
- `launcher_metadata.game_lowest_version`
|
||||
- `launcher_metadata.game_start_exe_name`
|
||||
- `launcher_metadata.manifest_source`
|
||||
- `game_main_config.server_info_data_url`
|
||||
- `game_main_config.default_connection_group`
|
||||
- `game_main_config_bootstrap.server_info_data_url`
|
||||
- `game_main_config_bootstrap.default_connection_group`
|
||||
2. optional 字段为 null 或缺省:
|
||||
- Go mirror 不应崩溃。
|
||||
- HTTP response 中按当前 Go struct `omitempty` 策略输出。
|
||||
@@ -130,7 +143,7 @@ Go 侧审核通过后的落地建议:
|
||||
|
||||
- 归一化是否过度改变 Rust 真实输出。
|
||||
- fixture 是否意外绑定真实版本、日期、本机路径或私有部署路径。
|
||||
- `game_main_config` 与 `game_main_config_bootstrap` 的 RPC / snapshot 差异是否符合预期。
|
||||
- `game_main_config_bootstrap` 在 RPC / snapshot 中是否保持同一语义。
|
||||
- optional 字段覆盖是否足够。
|
||||
|
||||
## notes.md 模板
|
||||
@@ -191,6 +204,16 @@ contract fixture 工作只有在以下条件同时满足时才算完成:
|
||||
|
||||
## 当前状态
|
||||
|
||||
- Go `bat-api` 已具备消费 `launcher_metadata` / `game_main_config` 的 mirror struct。
|
||||
- Go `bat-api` 已具备 player-facing HTTP 控制面、OpenAPI 和管理面板预留。
|
||||
- contract fixture 尚未落仓库,等待 Rust 侧真实输出与用户审核。
|
||||
- Go `bat-api` 已具备消费 `launcher_metadata` / `game_main_config_bootstrap` 的 mirror struct。
|
||||
- Go `bat-api` 已具备 player-facing HTTP 控制面、OpenAPI 和管理控制白名单。
|
||||
- 已归一化的 Rust contract fixture 已落仓库:
|
||||
- `internal/api/testdata/contract/catalog-status.available.json`
|
||||
- `internal/api/testdata/contract/catalog-status.unavailable.json`
|
||||
- `internal/api/testdata/contract/resource-manifest.page0.json`
|
||||
- `internal/api/testdata/contract/official-sync-snapshot.json`
|
||||
- 原始交接产物仍位于 `/tmp/bat-contract-fixture/`;仓库内归一化 fixture 用于 schema/mirror 回归,
|
||||
同机 live socket 验证使用 `make bat-api-local-live-smoke`,不依赖该交接目录。
|
||||
- Go contract 测试读取仓库内归一化 fixture,不依赖 `/tmp/bat-contract-fixture/`、开发机资源目录或远端长期运行的 `bat`。
|
||||
- 真实长期 daemon 的生产部署仍需由部署环境持续运行;仓库已在本地隔离环境通过真实
|
||||
daemon socket 完成端到端调用、版本切换、无 release、RPC 断线和恢复验证。真实官方
|
||||
网络全量下载仍由 `make official-smoke` 独立负责。
|
||||
|
||||
+176
-527
@@ -1,562 +1,211 @@
|
||||
# 当前实现缺口清单
|
||||
|
||||
- **更新时间**:2026-07-24
|
||||
- **Go 进度权威**:`GO_STATUS.md`
|
||||
- **资源布局 / 逆向契约**:`../architecture/resource-release-layout.md`
|
||||
- **用途**:集中跟踪当前代码中的占位实现、设计缺口和下一步验收项。
|
||||
- **更新时间**:2026-09-13
|
||||
- **文档角色**:只记录尚未完成、仍需验证或仍需设计的工作,不重复维护完整实现状态。
|
||||
- **当前事实**:以源码、测试、稳定契约和 `CURRENT_STATUS.md` 为准。
|
||||
- **Go 进度**:`GO_STATUS.md`
|
||||
- **资源布局契约**:`../architecture/resource-release-layout.md`
|
||||
- **权威计划**:`../../PROJECT_PLAN.md`
|
||||
- **历史资料**:`docs/archive/` 和 `docs/reports/historical/` 只用于追溯。
|
||||
|
||||
---
|
||||
## 1. 当前工程缺口
|
||||
|
||||
## 1. 基线缺口
|
||||
### G-005:AssetBundle V1 已完成,完整兼容仍未完成
|
||||
|
||||
### G-001:Git 元数据不可用
|
||||
状态:**V1 已完成(仅限已验证结构),继续推进真实版本和复杂结构**
|
||||
|
||||
状态:**已关闭,采用新初始化基线**
|
||||
当前已具备 UnityFS 容器校验、directory 文件提取、serialized file
|
||||
object/type table/TypeTree 元数据、TextAsset、基础 MonoBehaviour 和
|
||||
ScriptableObject 字段读取、TextUnit 提取,以及受支持字段的文件级
|
||||
parse→modify→rebuild→reparse。重建会保留已识别的 block 压缩、alignment、
|
||||
directory 形态和未修改对象/字段,并明确拒绝未知压缩或无法证明保真的输入。
|
||||
|
||||
原现象:
|
||||
仍需完成:
|
||||
|
||||
- `.git/` 是空目录。
|
||||
- `git status` 报 `not a git repository`。
|
||||
- 用真实资源 fixture 覆盖更多 MonoBehaviour、ScriptableObject、Unity 版本差异、
|
||||
复杂容器和 managed reference registry/map entry 变体。
|
||||
- 为未知字段补充结构语义;不能把低保真猜测当作已支持格式。
|
||||
- 扩大真实 Unity 版本、复杂容器、未知字段和 managed-reference/map 变体覆盖;
|
||||
当前 V1 不等价于任意 AssetBundle 结构的通用重打包。
|
||||
|
||||
处理结果:
|
||||
现有证据:`crates/bat-assetbundle` 的单元/压缩/对齐/变长重建测试、隔离真实 UnityFS
|
||||
回归和 `bat-infrastructure` 的解析缓存、ZIP 内 bundle 发布测试。新增格式覆盖必须
|
||||
同时补真实 fixture、回归测试和文档。
|
||||
|
||||
- 已执行 `git init`。
|
||||
- 已将初始分支调整为 `main`。
|
||||
- 已配置当前路径为 Git safe directory。
|
||||
- `git status --short --branch` 已可用。
|
||||
- 本轮创建首次基线提交。
|
||||
### G-006:通用 Patch 的复杂格式和运维扩展仍未完成
|
||||
|
||||
限制:
|
||||
状态:**V1 已完成(当前支持类型),复杂格式和运维扩展继续推进**
|
||||
|
||||
- 原项目历史未恢复。
|
||||
- 后续历史从当前基线提交开始。
|
||||
`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;UnityFS 目标身份至少包含 archive entry、serialized file、path ID 和实际
|
||||
field path,同对象 sibling field 可并存,重复、父子、whole-object/field 结构重叠会拒绝;
|
||||
`localized.publish`、`i18n publish` 和 bat-api typed forwarding 均已接入。
|
||||
|
||||
验收:
|
||||
仍需完成:
|
||||
|
||||
- `git log --oneline -1` 能看到基线提交。
|
||||
- 任意复杂 AssetBundle 重打包和完整翻译文件集合构建;当前 localized publish 已支持
|
||||
可验证 ZIP 内 bundle 的外层 ZIP 重写,但不扩大 UnityFS 结构支持范围。
|
||||
- generic manifest 已冻结为当前支持类型的 V1;复杂 AssetBundle 结构仍需真实样本驱动,
|
||||
不在本项中扩展 Patch 格式。
|
||||
|
||||
### G-002:CAS 有两套实现边界
|
||||
所有发布产物必须先进入独立 staging,通过完整性校验后再原子发布;失败不得改变
|
||||
已发布的 `bat-resources/current` 或 `bat-localized/current`。
|
||||
|
||||
状态:**已关闭**
|
||||
### G-007:Addressables 完整兼容仍未完成
|
||||
|
||||
原现象:
|
||||
状态:**当前 JSON/compact 目标字段完成,独立二进制格式待后续**
|
||||
|
||||
- `crates/bat-cas-engine/src/storage.rs` 有文件系统存储。
|
||||
- `infrastructure/src/cas/filesystem.rs` 也实现了文件系统 CAS repository。
|
||||
当前 JSON/compact catalog 已覆盖 path、hash、size、address、dependencies、
|
||||
provider、bundle name、resource type 和 CRC,并有 fixture/golden 回归。
|
||||
|
||||
处理结果:
|
||||
仍需完成:
|
||||
|
||||
- `crates/bat-cas-engine` 新增 `repository` 组合层,成为 CAS 核心实现。
|
||||
- `infrastructure/src/cas/filesystem.rs` 已改为 `bat-core::CasRepository` 适配层。
|
||||
- infrastructure 不再直接写对象文件,不再维护自己的引用计数逻辑。
|
||||
- 更多 Windows/Android 真实 catalog 形态和失败诊断。
|
||||
- 独立二进制 catalog 入口;在未支持前必须明确拒绝,不得静默丢字段。
|
||||
|
||||
验收证据:
|
||||
### G-009:`bat-api` 仍是资源服务,不是完整官方游戏 API
|
||||
|
||||
- `bat-cas-engine::repository::FileSystemCasRepository`
|
||||
- `bat_infrastructure::FileSystemCasRepository`
|
||||
- `cargo test --workspace`
|
||||
状态:**资源 bootstrap/分发和管理控制面已可用,业务 API 未完成**
|
||||
|
||||
### G-003:CAS 引用计数和 GC 未实现
|
||||
当前 `cmd/bat-api` 通过 `bat.sock` 读取 Rust 已发布 release,提供 bootstrap、
|
||||
launcher 资源引导兼容、只读 CDN path、readiness、OpenAPI、鉴权管理入口和内嵌
|
||||
dashboard;翻译任务和 Rust-owned TM 的 summary/query/confirm 也通过 typed RPC
|
||||
转发。Rust `bat` 继续拥有资源发现、下载、校验、staging、发布、任务和长期状态。
|
||||
普通 current release 的 readiness、bootstrap、release summary 和 CDN 共用
|
||||
Rust `release.attestation` 是 current official 的轻量 health/publication proof,带
|
||||
release/publication/manifest identity、verification generation、freshness 和诊断;Go
|
||||
只建立绑定同一代际的 manifest 读快照,不复制 Rust verifier。`release.status` 仍保留
|
||||
为重型管理诊断。`make ci-check` 是只读统一门禁,required `golangci-lint 2.12.2`
|
||||
缺失或版本不匹配直接失败。
|
||||
|
||||
状态:**已关闭**
|
||||
仍需完成:
|
||||
|
||||
原现象:
|
||||
- 完整游戏业务 API、账号/登录/网关链和完整 launcher 安装包更新链。
|
||||
- 更丰富的 Resource/TextUnit/翻译记忆查询面。
|
||||
- 真实官方网络长期运行报告;运行使用 `make official-smoke`,产物留在隔离目录。
|
||||
|
||||
- `FileSystemCasRepository::add_reference` 返回固定 `1`。
|
||||
- `remove_reference` 返回固定 `0`。
|
||||
- `get_reference_count` 返回固定 `1`。
|
||||
- `gc` 返回固定 `0`。
|
||||
- `crates/bat-cas-engine/src/refcount.rs` 是占位。
|
||||
`bat-api` 不得复制 Rust 下载器、CAS、AssetBundle 解析、Patch 核心算法或同步状态机。
|
||||
|
||||
处理结果:
|
||||
### G-010:完整 Web 协作后台仍未完成
|
||||
|
||||
- `crates/bat-cas-engine/src/refcount.rs` 使用 SQLite 保存对象元数据和引用计数。
|
||||
- `store()` 会存储对象并增加引用计数。
|
||||
- `add_reference()`、`remove_reference()`、`get_reference_count()` 已持久化。
|
||||
- `gc()` 删除引用计数为 0 的对象和元数据。
|
||||
- `gc_candidates()` 提供 dry-run 能力。
|
||||
状态:**内嵌 dashboard MVP 已完成,完整后台未开始**
|
||||
|
||||
验收证据:
|
||||
当前页面可以调用已有资源、调度、任务、解析、翻译和 localized 控制接口。
|
||||
|
||||
- 引用计数增减有持久化测试。
|
||||
- GC 不删除仍被引用对象。
|
||||
- 并发引用更新测试通过。
|
||||
仍需完成:
|
||||
|
||||
---
|
||||
|
||||
## 2. 核心功能缺口
|
||||
|
||||
### G-004:CAS 写入不是生产级原子流程
|
||||
|
||||
状态:**已关闭**
|
||||
|
||||
原现象:
|
||||
|
||||
- 当前写入直接写目标路径。
|
||||
- 缺少临时文件、fsync、原子 rename、并发冲突处理。
|
||||
|
||||
处理结果:
|
||||
|
||||
- `FileSystemStorage::put()` 使用临时文件写入、文件 sync、原子 rename、目录 sync。
|
||||
- 读取对象时强制 Hash 校验。
|
||||
- 并发写入相同内容只保留一个对象,引用计数按调用次数递增。
|
||||
- 损坏对象读取返回 `HashMismatch`。
|
||||
|
||||
验收证据:
|
||||
|
||||
- 写入失败不会留下可见半成品对象。
|
||||
- 并发写入相同内容只产生一个对象。
|
||||
- 读取时 Hash 不匹配会返回明确错误。
|
||||
|
||||
### G-005:AssetBundle 引擎解析器仍未完成
|
||||
|
||||
状态:**部分完成**
|
||||
|
||||
冻结状态:自 2026-07-30 起,G-005 不再作为默认推进项。解析层只接受维护冻结规则允许的稳定性修复、诊断修复、真实回归修复和文档校正;新增 TypeTree 语义类型、扩大解析覆盖和新增写入型解析入口全部暂停。冻结细则见 `docs/reports/PARSER_FREEZE.md`。
|
||||
|
||||
现象:
|
||||
|
||||
- `crates/bat-assetbundle` 已接管 UnityFS 解析,提供 `UnityFsParser`、`UnityFsBundle`、header、block info、directory、压缩模式、block info at end、LZ4/LZMA block info 解压、数据 block 解压、directory 文件提取和边界诊断。
|
||||
- `crates/bat-assetbundle::serialized` 已提供 Unity serialized file header、type table、TypeTree node 元数据、object table、TextAsset bytes 和基础 TypeTree field reader。
|
||||
- `adapters/src/unity/unity_2021_3.rs` 已降为 Unity 版本选择薄层,复用 `bat-assetbundle`,不再维护第二套 UnityFS parser。
|
||||
- `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 size,enum `value__` backing field 会暴露为语义化 `{type_name, storage_type, value}`,`LayerMask` / `BitField` 的 `m_Bits` backing field 会暴露为语义化 `{type_name, storage_type, bits}`,managed-reference full typename 可拆为 assembly/namespace/class,常见 `m_ManagedReferences` / `RefIds` / `m_RefIds` / verbose type 字段命名、`managedReference*` / `serializedReference*` prefixed metadata、`SerializedReference` 节点 alias 和 `data` / `value` / `payload` / `object` / `managedReferencePayload` / `referencePayload` / `serializedReferencePayload` / `managedReferenceValue` / `referenceValue` / `serializedReferenceValue` / `managedReferenceObject` / `referenceObject` / `serializedReferenceObject` / `managedReferenceData` / `referenceData` / `serializedData` / `serializedReferenceData` payload 命名已有回归覆盖,多记录 registry 聚合已有单元回归,TextUnit 提取会跳过 registry 元数据字符串并把它们作为 payload 文本上下文,fallback 字段遍历也会跳过常见 managed-reference 元数据别名,并按 `RefIds[n]` 等记录前缀或子字段推导 metadata 写入 payload TextUnit context;当前可对 string、bool、integer、float raw bits、bytes、enum、bit_field、常见固定 Unity float/int/hash 值类型的 leaf/direct-child 形态、unknown fixed-size raw bytes 同长度替换、PPtr、object 字段组合和 TypeTree schema 支撑的 array/vector/List/HashSet/map 整体替换执行文件级 patch,map entry 的 `first/second` 与 `key/value` 字段命名已有 serialized 和 UnityFS 重建回归,ScriptableObject `key/value` map 解析、变长替换和 UnityFS 重建已有专门回归;真实版本差异、未见样本驱动的复杂 managed reference registry / map entry 变体、unknown 字段结构语义和发布级重打包入口仍未完成。
|
||||
|
||||
影响:
|
||||
|
||||
- 可以对 UnityFS 容器做结构校验、解包 directory 文件,并提取 serialized file 中的 TextAsset 原始 bytes。
|
||||
- 对日语汉化最关键的 TextAsset 索引、bytes、JSONL TextUnit、MonoBehaviour/ScriptableObject 基础字符串字段、array/vector/List/HashSet/map 字符串元素和 TypeTree-covered managed reference payload 提取已有入口;managed-reference 类型元数据作为上下文保留,不进入翻译文本队列,fallback registry 字段遍历也会过滤常见元数据别名,并按记录前缀或子字段保留可推导 metadata。文件级 UnityFS 重建可覆盖 TextAsset、TypeTree string 字段、managed-reference registry payload 字符串、基础语义字段、enum、bit_field、unknown fixed-size raw bytes、object 字段组合和 TypeTree schema 支撑的 array/vector/List/HashSet/map 整体替换,但还不能完成发布级复杂对象重打包。
|
||||
|
||||
当前验收证据:
|
||||
|
||||
- `crates/bat-assetbundle` 能解析结构化测试样本和隔离真实样本。
|
||||
- 支持 UnityFS header、blocks、directory、metadata 摘要、directory 文件提取。
|
||||
- 支持 Unity serialized file object table、TypeTree node 元数据和 TextAsset 提取的合成 fixture。
|
||||
- 错误包含偏移和字段上下文。
|
||||
|
||||
关闭前仍需:
|
||||
|
||||
- 冻结解除前不继续扩大 TypeTree 字段 reader 覆盖。当前 TypeTree-covered managed reference 字段与 registry 记录已可结构化解码,`m_ManagedReferences`、`RefIds` / `m_RefIds`、verbose type 字段、`managedReference*` / `serializedReference*` metadata、payload/value/object 家族、`managedReferenceData` / `referenceData` / `serializedData` / `serializedReferenceData` payload、多记录 registry 聚合、enum `value__` backing field、`LayerMask` / `BitField` 的 `m_Bits` backing field、固定 Unity float/int/hash 值类型 leaf/direct-child 形态、unknown fixed-size raw bytes 同长度替换、嵌套 vector `Array` 形态、`List<T>` / `HashSet<T>` 集合 alias、`first/second` 与 `key/value` map entry schema、空 array/vector/List/HashSet/map 扩容已有合成 fixture 覆盖;剩余真实版本差异、unknown 字段结构语义、更多 nested collection、managed reference registry / map entry 变体只记录为冻结后的工作。
|
||||
- 用真实 fixture 继续覆盖 MonoBehaviour、ScriptableObject 字段级遍历和字符串策略。
|
||||
- 输出可追溯文本定位:bundle path、archive entry、serialized file、path id、class id、field path、字段 offset/byte size。
|
||||
- 用真实资源 fixture 覆盖对象级解析、TextAsset 提取和字段级文本提取。
|
||||
- 将解析结果作为 Patch 输入;真正的重打包、Patch 生成和 `localized` 发布切换归 G-006/G-011D。
|
||||
|
||||
解析补全路线图:
|
||||
|
||||
- 见 `docs/architecture/assetbundle.md` 的 P2/P3/P5。
|
||||
|
||||
### G-006:Patch 引擎基础已落地,发布入口仍未完成
|
||||
|
||||
状态:**部分关闭**
|
||||
|
||||
已完成:
|
||||
|
||||
- `bat-patch::binary` 已提供确定性 Binary hunk diff/apply,应用前校验 source size/BLAKE3,应用后校验 target size/BLAKE3。
|
||||
- `bat-patch::json` 已提供 RFC 6902 JSON Patch apply,覆盖 add/remove/replace/move/copy/test 和 JSON Pointer escape。
|
||||
- `bat-patch::text` 已提供 UTF-8 Text Patch,按 source-relative byte range 替换,支持 expected 文本校验、UTF-8 边界校验、source/target BLAKE3 和 size 校验。
|
||||
- `bat-patch::manifest` 已定义通用 Patch manifest、文件级 patch kind、source/target BLAKE3、size、rollback 元数据和 manifest 文件完整性校验。
|
||||
- `LocalizedPatchManifest` 可转换为通用 `bat_patch::PatchManifest`,UnityFS TextAsset 发布链路和通用 Patch manifest 已有类型对齐点。
|
||||
- `infrastructure::patch_ops`、`patch.apply` RPC 和 `patch-apply` CLI 已开放文件级 Binary/JSON/Text patch apply;输入/输出为显式文件路径,输出原子写入并返回 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 文件写入;TextAsset、TypeTree string 字段、managed-reference registry `data` / `managedReferenceData` payload 字符串、基础语义字段、enum、bit_field、固定 Unity 值类型 leaf/direct-child 形态、unknown fixed-size raw bytes、object 字段组合和 TypeTree schema 支撑的 array/vector/List/HashSet/map 整体替换会在重建后重新解析校验。
|
||||
|
||||
影响:
|
||||
|
||||
- 通用 Binary/JSON/Text Patch crate 能力可作为后续发布流程输入。
|
||||
- 文件级写入入口可用于隔离测试和上层工具显式产物生成。
|
||||
- 发布级 `patch build` / `patch rollback`、复杂 AssetBundle 重打包和通用 manifest 在发布命令中的正式使用仍未完成。
|
||||
|
||||
验收:
|
||||
|
||||
- Binary patch 能完成 diff/apply 往返。
|
||||
- JSON patch 能应用 RFC 6902 patch。
|
||||
- Patch manifest 包含 hash、版本和回滚信息。
|
||||
- Patch 构建/应用必须写 staging,完整性校验通过后才能发布。
|
||||
- `patch.apply` / `patch-apply` 对显式文件执行 apply 时必须原子写目标文件,并返回 source/patch/target hash 与 size。
|
||||
- 失败时不得影响 `bat-resources/current` 或已发布 `bat-localized/current`。
|
||||
|
||||
### G-007:Addressables Catalog 解析不完整
|
||||
|
||||
状态:**部分关闭**
|
||||
|
||||
现象:
|
||||
|
||||
- `AddressablesCatalogDriver` 已能解析当前真实形态 JSON catalog fixture/golden。
|
||||
- 已输出 path、hash、size、resource_type、address、dependencies、metadata,并已提取 `m_Crc` 到 `crc` 字段。
|
||||
- compact catalog 解析已补充 hash/size/CRC 的非 0 回归、资源计数 metadata,以及 blob 解码失败时的明确错误;不再在 compact 字段损坏时静默退回低保真 `m_InternalIds`。
|
||||
- `bat-core` 已提供 `crc32_ieee` 和 `ResourceEntry::verify_downloaded_bytes`,SQLite `ResourceRepository` 已有 `crc` 列迁移。
|
||||
- 仍需覆盖更多官方 catalog 结构变体、provider/bundle name 持久化字段,以及真实 Windows/Android catalog 样本集合。
|
||||
|
||||
影响:
|
||||
|
||||
- 当前解析能力可以服务 Manifest inspect 和部分资源索引,但还不能宣称完整兼容所有 Unity Addressables/SBP catalog 形态。
|
||||
|
||||
验收:
|
||||
|
||||
- 能解析项目目标版本的真实 Catalog 样本集合。
|
||||
- 解析结果包含资源 key、provider、dependency、hash、size、path、CRC。
|
||||
- 对不支持的 catalog 结构返回明确错误,而不是静默丢字段。
|
||||
- 解析结果能反查 bundle 文件、依赖链和本地下载 manifest 条目。
|
||||
- Windows/Android 样本集合需要覆盖 JSON、compact JSON 和后续二进制 catalog 入口。
|
||||
|
||||
解析补全路线图:
|
||||
|
||||
- 见 `docs/architecture/assetbundle.md` 的 P1。
|
||||
|
||||
---
|
||||
|
||||
## 3. 应用层缺口
|
||||
|
||||
### G-008:Go 同步/运维 CLI 产品入口
|
||||
|
||||
状态:**已决策关闭(wontfix)**
|
||||
|
||||
决策(2026-07-24,见 `GO_STATUS.md`):
|
||||
|
||||
- **正式同步/运维命令行 = Rust `bat`**(近乎全自动:auto-discover + watch/daemon,无需持久手操维护)。
|
||||
- **不另做**产品级 Go 同步 CLI,避免与 Rust `bat` 双轨。
|
||||
- Go 试验入口 `cmd/bat` 可保留为 experimental,产物必须为 `bin/bat-go`,**禁止**再构建为 `bin/bat`。
|
||||
- Go 正式产品入口集中在 **`bat-api` 资源 bootstrap/分发服务** + `internal/backendrpc`(G-009)。
|
||||
|
||||
原验收(真实 doctor / Go sync 包装)**不再作为当前里程碑**。
|
||||
|
||||
### G-009:API Server(`bat-api`,资源 bootstrap/分发)部分完成
|
||||
|
||||
状态:**资源 bootstrap + CDN MVP 已落地;非完整官方游戏 API**
|
||||
|
||||
目标(对应 issue #19,**按资源面收窄**):
|
||||
|
||||
- `cmd/bat-api`:组织 Rust `bat` 已发布 release 的启动前资源入口,并只读分发官方 CDN host/path 形态资源。
|
||||
- **拉取归属 Rust `bat`**;`bat-api` 不做下载器。
|
||||
- 发现经 `bat.sock`:先 `daemon.status`,再 `daemon.doctor`,再 `catalog.status` / `resource.manifest`。
|
||||
- 生产与 Rust `bat` 同环境运行,资源根来自 RPC 返回的 `resource_root`;`--resource-root` 仅用于 fixture 或应急只读诊断。
|
||||
- `.env` 配置端口 / public base / RPC socket / RPC 刷新周期;预留 database/redis。
|
||||
- `/v1/bootstrap` 返回 RPC 健康、release 摘要、server-info URL、client-patch base 和改写后的 Addressables root。
|
||||
- `/v1/launcher/bootstrap` 和 `/api/launcher/...` 形状端点返回资源引导兼容信息,当前来源是 Rust `bat` 已发布 snapshot/RPC 中的 launcher metadata 与 GameMainConfig 摘要;Rust 侧已新增 release 内 `official-launcher-bootstrap.json` 版本化产物,后续 bat-api 字段统一和联调应以该产物加 RPC contract fixture 为准。
|
||||
- `/healthz` 暴露最近一次 RPC refresh 诊断;`/readyz` 在无可分发 release 时返回 `503`。
|
||||
- 玩家-facing HTTP 控制面必须支持 token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON `no-store` 和 `/v1/resources` 分页上限。
|
||||
- CDN path 支持 `GET` / `HEAD` / `Range`、ETag、Last-Modified、Accept-Ranges 和长期缓存头。
|
||||
- launcher 完整安装包更新链、账号、登录、网关、游戏业务 API 和鉴权全链 **非关闭条件**;USERGUIDE 已补基础章节,联调后补生产排障样例。
|
||||
|
||||
已完成:
|
||||
|
||||
- `cmd/bat-api`、`internal/api`、`/v1/bootstrap`、`/v1/launcher/bootstrap`、launcher 资源 metadata 兼容端点、HTTP token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON `no-store`、`/v1/resources` 分页上限、OpenAPI、`/admin/` 预留、RPC 周期刷新/诊断、`/readyz`、CDN Range/缓存头、fixture 单测、USERGUIDE 基础章节、systemd bat-api 模板、`make build-go-api` / `test-go-api`
|
||||
- 进度权威:`docs/reports/GO_STATUS.md`
|
||||
|
||||
验收(剩余):
|
||||
|
||||
- 与远程长期运行的 `bat` / 全量 release 联调(覆盖 bootstrap、server-info、CDN path;SSH 实勘可后置)
|
||||
- Rust snapshot schema 与 Go mirror struct 的跨语言 contract fixture(需要用户审核后落入 fixture)
|
||||
- refresh 中 manifest 磁盘校验的 mtime/size 增量缓存优化(真实全量 release 观测后决定)
|
||||
- 文档与 GO_STATUS 持续一致
|
||||
|
||||
排期:P2 主体可联调;持久化 API 层与完整 launcher/业务链另议。
|
||||
|
||||
### G-010:Web 管理后台尚未实现
|
||||
|
||||
现象:
|
||||
|
||||
- `web/` 只有目录结构。
|
||||
|
||||
影响:
|
||||
|
||||
- 翻译审核、术语管理、Dashboard 无 UI。
|
||||
|
||||
验收:
|
||||
|
||||
- 登录、权限、翻译审核、术语管理基础流程可用。
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据与翻译缺口
|
||||
- 独立登录、角色权限和协作式翻译审核。
|
||||
- Glossary/术语管理、批量审核、搜索和完整历史版本视图。
|
||||
- 构建型前端工程、浏览器 E2E 和完整错误态交互门禁。
|
||||
|
||||
### G-011:ResourceRepository 查询面仍不完整
|
||||
|
||||
状态:**部分关闭**
|
||||
|
||||
影响:
|
||||
|
||||
- `SqliteResourceRepository` 已存在,可按领域 repository 接口保存资源元数据。
|
||||
- `ResourceImportService` 已能把 manifest 中有数据的资源写入 CAS + `ResourceRepository`,AssetBundle 会记录 UnityFS 摘要,TextAsset/Table/Media 会分类索引。
|
||||
- 官方同步下载结果可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后自动导入 CAS + ResourceRepository;默认 CAS 为 `<output>/.cas`,默认 SQLite 索引为 `<output>/resources.sqlite`,也可通过 `--import-cas-root`、`--import-resource-db`、`BAT_IMPORT_CAS_ROOT`、`BAT_IMPORT_RESOURCE_DB` 覆盖。
|
||||
- `resource.index` RPC 已能按资源类型、hash、路径模式分页查询现有 SQLite 索引;数据库不存在时返回 `available=false`,不会因查询创建空库。
|
||||
- 官方 release 发布后会写出 `official-resource-changes.json` 和 `crowdin-translation-handoff.json`,新增+变更资源进入解析/翻译 handoff,删除资源只进入差异记录。
|
||||
- `Resource` metadata 已通过 SQLite `metadata_json` 兼容迁移保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式;`resource.index` 会返回这些 metadata。
|
||||
- 官方 release 发布后会持久化 `official-textunit-index.json`,记录单条 TextUnit 和解析错误;`parse.text_units` / `parse.errors` RPC 和 `parse-text-units` / `parse-errors` CLI 可按 destination、archive entry、path id、class id、field path 和 format 分页过滤。
|
||||
- 官方 release 发布后会从 Added/Modified 资源、parse cache 和 TextUnit 明细索引派生 `official-textunit-tasks.json` 和 `crowdin-textunit-queue.json`;删除资源不会进入队列。
|
||||
- 翻译任务状态和 Crowdin worker 失败原因查询仍需补齐。
|
||||
|
||||
验收:
|
||||
|
||||
- schema 和迁移可重复执行。
|
||||
- 可按版本、类型、hash、路径查询资源,且能明确区分索引缺失、版本缺失和空结果。
|
||||
- 官方同步后的资源可通过 CLI/RPC 查询并能追溯到 CAS 对象。
|
||||
- `official-parse-cache.json` 的 bundle、zip entry、TextAsset 和 TextUnit 摘要能进入 ResourceRepository 查询面。
|
||||
- `parse.status` 能报告 TextUnit 索引、TextUnit 队列路径与摘要。
|
||||
- `parse.text_units` / `parse.errors` 能分页查询当前 release 的 TextUnit 明细和解析错误。
|
||||
- Crowdin handoff 被后续翻译 worker 消费后,任务状态和失败原因能反查到对应官方 release 与资源 destination。
|
||||
|
||||
解析补全路线图:
|
||||
|
||||
- 见 `docs/architecture/assetbundle.md` 的 P4。
|
||||
|
||||
### G-011A:资源导入链路基础能力不足
|
||||
|
||||
状态:**已关闭**
|
||||
|
||||
历史现象:
|
||||
|
||||
- 资源导入链路只导入 AssetBundle。
|
||||
- 非 AssetBundle manifest 条目只会被跳过。
|
||||
- 导入报告只包含 UnityFS 基础摘要,不包含稳定分类统计。
|
||||
|
||||
处理结果:
|
||||
|
||||
- `ResourceImportService` 会把有数据的 manifest 条目导入 CAS 并写入 `ResourceRepository`。
|
||||
- AssetBundle 仍执行 UnityFS header/block/directory 摘要解析。
|
||||
- TextAsset、TableBundle、Media 会按资源类型分类,缺少数据时记录为 skipped,便于渐进导入。
|
||||
- `ResourceImportReport` 增加 `category_counts`,`ImportedResource` 增加 `resource_type`、`category` 和可选 UnityFS 摘要。
|
||||
|
||||
验收:
|
||||
|
||||
- `cargo test -p bat-infrastructure import::tests::`
|
||||
- `cargo test -p bat-infrastructure --test synthetic_phase2_import`
|
||||
|
||||
### G-011B:官方版本状态管理不明确
|
||||
|
||||
状态:**已关闭**
|
||||
|
||||
历史现象:
|
||||
|
||||
- 当前可用版本主要靠 `current` symlink 和 release 内 snapshot 推断。
|
||||
- 未显式保存“正在拉取版本”和“失败版本”。
|
||||
- 上一个可用版本需要从目录状态间接判断。
|
||||
|
||||
处理结果:
|
||||
|
||||
- 新增 `<output>/official-version-state.json`。
|
||||
- 开始下载后写入 `in_progress_version`。
|
||||
- 发布成功后写入 `current_completed_version` 和 `previous_available_version`。
|
||||
- 失败或中断后写入 `failed_versions` 并清空 in-progress。
|
||||
- `bat status` 会读取并展示版本状态摘要。
|
||||
|
||||
验收:
|
||||
|
||||
- `cargo test -p bat-infrastructure version_state`
|
||||
- `cargo test -p bat-infrastructure --test official_game_main_config_bootstrap`
|
||||
|
||||
### G-011D:汉化发布状态与 Patch 发布流程未完成
|
||||
|
||||
状态:**部分关闭**
|
||||
|
||||
当前已完成:
|
||||
|
||||
- 官方原版资源发布根为 `./bat-resources`,汉化产物发布根为 `./bat-localized`。
|
||||
- CLI 支持 `--localized-output` / `BAT_LOCALIZED_OUTPUT`,并拒绝官方目录和汉化目录相同或互相嵌套。
|
||||
- 官方同步报告新增 `localized_release_status=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。
|
||||
- `localized.status` RPC 会读取 `.env` / daemon 配置中的汉化输出目录,校验汉化状态是否匹配当前官方 release,且要求 patch manifest 存在并匹配 release,避免写死 `./bat-localized` 或误报手工状态。
|
||||
|
||||
仍未完成:
|
||||
|
||||
- 真实 Patch/翻译构建阶段尚未从 `crowdin-textunit-queue.json`、翻译记忆和 Crowdin 结果生成完整汉化文件集合。
|
||||
- 通用 Binary/JSON/Text Patch crate 基础和文件级 `patch.apply` / UnityFS 写入入口已经实现;复杂 AssetBundle 重打包、发布级 patch build/rollback 和与汉化发布流程的统一仍未完成。
|
||||
- 尚未实现原版资源与汉化资源双发布后的查询、分发和清理策略。
|
||||
|
||||
验收:
|
||||
|
||||
- 原版资源同步成功后保持 `not_localized`,不发布半成品汉化资源。
|
||||
- Patch 构建和校验成功后,汉化产物按官方相对路径写入配置化汉化发布根下的 `versions/<id>`。
|
||||
- 汉化发布必须原子切换配置化汉化发布根下的 `current`,失败时不影响已发布原版资源。
|
||||
- `localized` 状态能证明原版和汉化两套资源都可发布,并能被 CLI/RPC/API 查询;缺 patch manifest 或 release 不匹配时不得返回 `localized`。
|
||||
|
||||
### G-011C:真实 fixture 与回归样本不足
|
||||
|
||||
状态:**已关闭当前阶段**
|
||||
|
||||
历史现象:
|
||||
|
||||
- 已有 Addressables real-shape fixture/golden,但缺少按问题类型命名的当前/上一版本/结构变化样本。
|
||||
- 403/404 和 hash mismatch 主要依赖单测内联构造,不便于后续回归扩展。
|
||||
|
||||
处理结果:
|
||||
|
||||
- 新增 `adapters/tests/fixtures/addressables_regression/current_catalog.json`。
|
||||
- 新增 `adapters/tests/fixtures/addressables_regression/previous_catalog.json`。
|
||||
- 新增 `adapters/tests/fixtures/addressables_regression/structure_changed_catalog.json`。
|
||||
- 新增 `infrastructure/tests/fixtures/official_regression/http_403.json`。
|
||||
- 新增 `infrastructure/tests/fixtures/official_regression/http_404.json`。
|
||||
- 新增 `infrastructure/tests/fixtures/official_regression/hash_mismatch_catalog.json`。
|
||||
- 对应测试会解析这些 fixture,防止样本只存在但不参与验证。
|
||||
|
||||
验收:
|
||||
|
||||
- `cargo test -p bat-adapters --test addressables_regression`
|
||||
- `cargo test -p bat-infrastructure regression_fixture`
|
||||
|
||||
### G-012:Translation Memory 未实现
|
||||
|
||||
影响:
|
||||
|
||||
- 无法复用人工翻译和 AI 翻译历史。
|
||||
|
||||
验收:
|
||||
|
||||
- 精确匹配、模糊匹配、上下文匹配可用。
|
||||
- 记录 Provider、模型、审核状态和历史版本。
|
||||
|
||||
### G-013:Glossary 未实现
|
||||
|
||||
影响:
|
||||
|
||||
- 无法保证术语一致性。
|
||||
- AI 翻译无法强制遵守术语。
|
||||
|
||||
验收:
|
||||
|
||||
- 术语优先级高于 AI。
|
||||
- 支持别名、分类、冲突检测、审核。
|
||||
|
||||
### G-014:AI Provider 抽象未实现
|
||||
|
||||
影响:
|
||||
|
||||
- 无法接入 DeepL/OpenAI/Anthropic/Google/Azure。
|
||||
|
||||
验收:
|
||||
|
||||
- Provider 可替换。
|
||||
- 支持批处理、限流、重试、成本统计和质量检查。
|
||||
|
||||
---
|
||||
|
||||
## 5. 文档与发布缺口
|
||||
|
||||
### G-015:README 与当前真实状态不完全一致
|
||||
|
||||
状态:**已关闭**
|
||||
|
||||
现象:
|
||||
|
||||
- 旧 README 描述了最终架构,但部分功能尚未实现。
|
||||
|
||||
验收:
|
||||
|
||||
- README 明确区分已实现、开发中、规划中。
|
||||
|
||||
处理结果:
|
||||
|
||||
- README 已明确区分当前可用能力、未完成模块、官方同步运行命令和近期优先级。
|
||||
|
||||
### G-016:架构文档需要更新为当前路线图
|
||||
|
||||
状态:**已关闭当前阶段**
|
||||
|
||||
现象:
|
||||
|
||||
- 旧 `docs/architecture/README.md` 偏目标架构,容易让读者误以为 Go 同步器和 API/Web 已经可用。
|
||||
|
||||
验收:
|
||||
|
||||
- 增加 ADR 或架构决策记录。
|
||||
- 明确 Rust/Go/DB/Plugin 边界。
|
||||
|
||||
处理结果:
|
||||
|
||||
- 架构 README 已明确当前 Rust 官方同步入口、Go 计划边界和目标架构差异。
|
||||
- 官方资源后端说明由 `docs/architecture/official-resource-backend.md` 承载。
|
||||
- `bat-ffi` 已降级为可选无状态兼容层,主集成边界明确为 `bat --json` 进程边界或未来稳定 SDK。
|
||||
|
||||
### G-017:CI 未落地
|
||||
|
||||
状态:**已关闭(决策:不引入托管 CI)**
|
||||
|
||||
原现象:
|
||||
|
||||
- 仓库没有 GitHub Workflows 或等价托管 CI,质量门槛无自动远端执行。
|
||||
|
||||
处理结果:
|
||||
|
||||
- 明确决策:本项目不加入 GitHub Workflows,也不引入其他托管 CI。
|
||||
- 目前补充了自托管 Gitea linux-runner workflow(`.gitea/workflows/bat.yml`),仅用于 Rust workspace 的构建和测试,不改变“不引入托管 CI”的决策。
|
||||
- workflow 不使用外部 GitHub Action;它通过 runner 环境变量手动 `git fetch` 当前提交,并要求 runner 预装 `git`、Rust stable、rustfmt 和 clippy,避免准备阶段因第三方 action 仓库代理或网络限制失败。
|
||||
- 质量门禁由本地默认验证命令和自托管 workflow 共同承担:提交前执行 `cargo fmt` / `cargo clippy --workspace --all-targets -- -D warnings` / `cargo test --workspace`(见 `docs/guides/development.md` 与 `docs/guides/baseline.md`)。
|
||||
- 发布类检查(build、smoke)由 `Makefile` 与 `scripts/` 下的可重复脚本承担(如 `make official-smoke`)。
|
||||
|
||||
限制:
|
||||
|
||||
- 门禁执行依赖提交者本地自觉,无远端强制拦截;若未来出现多人协作或外部贡献需求,可重新评估本决策。
|
||||
|
||||
### G-018:真实官方网络全量下载 smoke test 已固化为可重复命令
|
||||
|
||||
状态:**已关闭(已固化可重复 smoke 命令;真实下载产物不纳入 Git)**
|
||||
|
||||
历史现象:
|
||||
|
||||
- 本地测试覆盖 mock、fixture、synthetic import 和 CLI 参数。
|
||||
- 曾缺少真实官方网络全量下载的固定 runbook 和可重复命令。
|
||||
|
||||
处理结果:
|
||||
|
||||
- 新增 `scripts/official-full-pull-smoke.sh`,默认在 `/tmp/bat-official-smoke-<UTC timestamp>/` 下创建隔离资源目录、状态目录和报告目录。
|
||||
- 新增 `make official-smoke` 统一入口。
|
||||
- 新增 `docs/guides/official-full-pull-smoke.md`,记录目标、命令、输出结构、环境变量、安全边界和成功判定。
|
||||
- smoke 流程覆盖 dry-run plan、首次全量拉取、二次 `up_to_date`、人工破坏 active release 文件后的 `repair`、repair 后 `verify`。
|
||||
- 脚本会检查二次 `up_to_date`、repair 完成、verify `healthy=true`,并检查首次拉取和 repair 的 stderr log 中存在下载已完成计数、单文件进度和校验结果日志。
|
||||
- 运行报告 `SMOKE_REPORT.md` 记录实际输出目录、active release、文件数量、release 大小和被破坏文件;大型官方资源文件保留在隔离输出目录,不纳入 Git。
|
||||
|
||||
验收:
|
||||
|
||||
- 使用 `scripts/official-full-pull-smoke.sh` 或 `make official-smoke`。
|
||||
- 默认输出目录必须是独立 `/tmp` 目录;非 `/tmp` 路径需要显式设置 `BAT_SMOKE_ALLOW_NON_TMP=1`,且输出目录必须为空。
|
||||
- 脚本退出码为 0 即表示 runbook 验收通过。
|
||||
- 真实网络执行需要外部网络和足够磁盘空间;本仓库只保存 runbook、脚本和测试,不保存官方大文件。
|
||||
|
||||
后续跟踪(非阻塞):
|
||||
|
||||
- 官方同步长期运行测试正在进行,运行报告将在后续提供。
|
||||
|
||||
### G-019:下载失败重试策略不够精细
|
||||
|
||||
状态:**已关闭**
|
||||
|
||||
历史现象:
|
||||
|
||||
- curl 失败只按固定次数重试,错误信息主要保留最后一次 stderr。
|
||||
- 403/404 和 5xx 没有不同处理。
|
||||
- 单个 URL 长期失败时缺少可查询的 quarantine 诊断状态。
|
||||
- 旧 launcher 包下载虽然有 primary/backup CDN 路径,但失败信息没有统一分类。
|
||||
|
||||
处理结果:
|
||||
|
||||
- 新增统一 curl 失败分类:`http_forbidden`、`http_not_found`、`http_client_error`、`http_too_many_requests`、`http_server_error`、`dns`、`connect`、`timeout`、`tls`、`interrupted`、`network` 等。
|
||||
- 403/404/普通 4xx 视为不可重试并提前停止;5xx、429、DNS、连接、超时、中断和网络类错误按尝试次数重试。
|
||||
- 单个资源 URL 最终失败会写入 `official-download-quarantine.json`,记录失败类型、HTTP 状态、是否可重试、尝试次数和最后错误。
|
||||
- 失败 URL 会发出 Failed progress,daemon status 和 `bat-events.jsonl` 暴露失败类型、HTTP 状态、重试属性和 quarantine 状态。
|
||||
- quarantine 会中断同步并阻止发布不完整 staging;下一轮成功下载或复用后清理对应 quarantine 条目。
|
||||
- 旧 launcher 包或 `resources.assets` 下载在 primary CDN 失败后会切换官方 backup CDN。
|
||||
|
||||
验收:
|
||||
|
||||
- HTTP 404 不重试,写入 quarantine,manifest 不写失败项。
|
||||
- HTTP 5xx 重试到上限后写入 quarantine。
|
||||
- launcher primary CDN 失败后会尝试官方 backup CDN。
|
||||
|
||||
---
|
||||
|
||||
## 6. 当前关闭顺序建议
|
||||
|
||||
1. issue #24:失败 staging 复用回归已补;核对残余场景。
|
||||
2. issue #1:RPC 主体、文件级 `patch.apply` / `unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field` 已落地;剩余发布级 patch build/rollback、复杂 UnityFS 语义编辑与设计边界确认。
|
||||
3. issue #17 及子 issue:已按 wontfix 关闭多线程下载(顺序下载 + 指数退避)。
|
||||
4. **G-008:已决策关闭**(同步 CLI = Rust `bat`;见 `GO_STATUS.md`)。
|
||||
5. **G-009 / issue #19**:资源 bootstrap/分发 MVP 已编码;优先服务器联调与索引实勘,非「从零实现」。
|
||||
6. issue #2 / G-007(P1):Addressables 可校验字段。
|
||||
7. issue #3 / G-005(P1):UnityFS 容器基础解析已落地;对象级引擎解析继续跟踪 G-005。
|
||||
8. G-011:翻译任务状态、CAS 诊断和 ResourceRepository 查询面扩展。
|
||||
9. G-012 / G-006:Crowdin/翻译系统、复杂 AssetBundle 重打包和 Patch 发布流程统一。
|
||||
10. G-011D:原版/汉化双发布后的查询、分发和清理策略。
|
||||
|
||||
Go 进度以 `docs/reports/GO_STATUS.md` 为准。G-018 / G-017 已关闭。
|
||||
状态:**部分完成**
|
||||
|
||||
当前已支持 CAS + SQLite 导入、资源类型/release/平台/path/parse status/TextUnit
|
||||
format 等资源级过滤,`parse.text_units` / `parse.errors` 和翻译任务查询也已可用。
|
||||
|
||||
仍需完成:
|
||||
|
||||
- 更丰富的 TextUnit、翻译记忆和 Patch 发布资源视图。
|
||||
- 从同一 manifest fingerprint 追溯资源、解析缓存、翻译任务和发布产物。
|
||||
- 更多 schema 迁移、权限、并发和损坏恢复场景验证。
|
||||
|
||||
### G-011D:双 release 查询、分发与安全清理
|
||||
|
||||
状态:**V1 已完成**
|
||||
|
||||
官方原版和 localized release 已分离,受支持 patch 可独立 staging、校验、发布和
|
||||
rollback。Rust `release.status` 提供统一 current/source/match、manifest contract、
|
||||
artifact/distribution integrity、历史 release、legacy/stale/damaged 摘要;
|
||||
`release.list` 查询两个 namespace,`release.distribution` 只允许当前或显式历史且已
|
||||
验证的 official/localized release,默认仍为 official;`release.cleanup` 提供 dry-run
|
||||
`plan_id` 和执行前重验证,只删除确定未被 current、rollback、staging、source、状态、
|
||||
manifest、CAS 或未知归属引用的普通目录。`localized.status` 还区分 schema/contract 与
|
||||
artifact integrity,损坏产物返回 degraded/corrupt,不自动回滚或删除。
|
||||
|
||||
rollback 与 cleanup 保持独立;缺少 generic manifest 的旧 localized release 仍可读,
|
||||
明确标记 `legacy`/`unknown`,不会被自动重写。
|
||||
|
||||
本轮 P1 一致性修复已完成:CAS repository 的 store/get/reference/GC 使用跨进程操作锁,
|
||||
release-local CAS 引用通过持久化 `ownership_id + ordinal` ledger 幂等释放;没有
|
||||
`ownership_id` 的旧清单按持久化 output-root scope、source mapping 和 generation identity
|
||||
迁移,已有 basename ledger 的部分 cleanup 保持 legacy compatibility key,完成后同名新
|
||||
generation 使用新的 ownership,不随机迁移已开始的 cleanup。官方历史复用只对不可变文件使用 hard link,`translation-tasks.sqlite` 及 WAL/SHM 始终独立复制;
|
||||
localized output 使用单写者锁和事务日志恢复 publish/rollback,publish 只有最终
|
||||
`verified` phase 才能 roll-forward,并在发布时写入实际 localized bytes/BLAKE3 的
|
||||
distribution manifest。official/localized distribution manifest 还持久化 deterministic
|
||||
source/localized mapping identity 和 destination index;`release.distribution(destination=...)`
|
||||
是单条 lookup,返回 exactly one entry,source identity mismatch 会阻断 localized
|
||||
distribution,完整 identity 校验只在 publish/status/audit 路径执行;分发读取保留 path
|
||||
ownership、symlink 和文件完整性检查;`release.cleanup execute` 与 official sync 共用
|
||||
`.official-sync.lock`。新 official release 还持久化独立的
|
||||
`official-distribution-publication.json`,把 release ID、完整 mapping identity、manifest
|
||||
content identity 和 entry count 绑定到发布事实;publication 缺失或 manifest 变化时
|
||||
official/localized distribution 均被阻断,普通查询不会自动重建。legacy release 仍可
|
||||
列出和清理,但不视为 distribution-ready。本轮已关闭两个 Release/CAS P2。其他 P2
|
||||
尚未由本轮处理:ResourceRepository
|
||||
更完整的查询/权限/损坏恢复、模糊 TM、bat.sock peer credential/perms、FFI 生命周期、
|
||||
资源大小/限额与更强的持久化 fsync 语义仍按后续专项推进。
|
||||
|
||||
### G-012:Translation Memory persistence schema V2 已实现,扩展能力仍缺失
|
||||
|
||||
Rust `bat` 已提供独立项目级 SQLite TM,当前 persistence schema version 为 V2;schema 打开遵守
|
||||
只读 preflight、fingerprint、transaction rollback 和 future/unknown fail-closed
|
||||
契约。它记录 raw source/hash、完整 context、release/TextUnit/provider/run provenance,
|
||||
区分 candidate/trusted,只有显式 confirm 或 conflict resolve 才能建立唯一 current
|
||||
trusted 记录;worker 只自动复用 raw source + 完整 context exact match 的 current
|
||||
trusted,并在复用前执行已批准 Glossary 的确定性 QA。Go `bat-api` 已提供鉴权的
|
||||
summary/query/conflicts 只读接口和 confirm/resolve_conflict 转发,但 Go 不持有 TM 状态。
|
||||
仍缺少模糊匹配和更丰富的导入导出历史能力。
|
||||
|
||||
### G-013:Glossary domain/feature contract V1、persistence schema V2 已实现,协作视图仍缺失
|
||||
|
||||
Rust `bat` 已提供独立项目级 `glossary.sqlite`,当前 schema version 为 V2;V2 正式
|
||||
吸收历史上未升版本的 `glossary_term_deletions` drift,并将历史 V1-A(无 deletion
|
||||
audit)和 V1-B(已有 deletion audit)分别纳入显式迁移。打开遵守只读 fingerprint
|
||||
preflight、writer transaction、rollback 和 future/unknown fail-closed 契约,不再
|
||||
使用隐式 `ensure_column` 修复结构。term/alias/recommended/
|
||||
allowed/category/priority、全局与 TextUnit scope、source history、approved review、
|
||||
冲突诊断、provider-neutral constraints 和确定性 QA 均由 Rust 持有。trusted TM 复用会
|
||||
先经过 Glossary QA;provider、TM、人工 task/workbench 结果都记录 QA,blocking
|
||||
deviation 必须显式提交与当前 QA 精确绑定的 `qa_identity` 及 reviewer/reason/provenance。
|
||||
localized publish 会把发布时重算的 QA 写入 manifest。`translation.glossary.*` 已通过
|
||||
`bat.sock` 暴露,Go 仅提供鉴权后的 typed forwarding。剩余缺口是完整 Web 术语协作视图
|
||||
和更丰富的导入/搜索能力。
|
||||
|
||||
### G-014:完整 Provider 扩展体系未实现
|
||||
|
||||
当前已有 mock/Crowdin provider worker、lease、重试和 TextUnit 结果落库;仍需建立
|
||||
可替换的 Provider 扩展体系,以及批处理、限流、成本统计和质量检查。
|
||||
|
||||
## 2. 已确定的架构边界
|
||||
|
||||
以下内容不是待实现的重复任务:
|
||||
|
||||
1. 正式资源同步和运维命令行是 Rust `bat`;不另做产品级 Go 同步 CLI。
|
||||
2. Rust `bat` / daemon 是资源生产者和状态拥有者;Go `bat-api` 只读取已发布资源,
|
||||
通过 `bat.sock` 提供 bootstrap、分发和受限管理入口。
|
||||
3. `bat-api` 是资源 bootstrap/分发服务,**不是完整官方游戏 API**。
|
||||
4. `bat-ffi` 只保留无状态兼容 helper,不承载 daemon、下载器、CAS handle 或主控制面。
|
||||
5. 官方原版 release 和 localized release 使用独立目录、staging、manifest、current
|
||||
和 rollback 生命周期。
|
||||
6. `daemon.clean-stable` 是 CLI 生命周期清理入口,不在 live RPC 内执行在线清理;
|
||||
`task.create` 也不作为通用 RPC 入口开放。
|
||||
7. `status` / `status_code` 描述生命周期,`BAT-ERR-*` 描述错误;两者不混用。
|
||||
|
||||
详细阶段报告仍保留在 `docs/reports/historical/`,不作为当前实现依据。
|
||||
|
||||
## 3. 后续推进顺序
|
||||
|
||||
1. 继续 G-005:更多真实 AssetBundle 样本、复杂字段解析、版本差异和任意结构重打包。
|
||||
2. 继续 G-006:复杂 AssetBundle 兼容和真实样本覆盖;G-011D 的双 release 运维 V1
|
||||
已完成,后续 retention scheduler 不属于本次闭环。
|
||||
3. 继续 G-011/G-012/G-013/G-014:资源查询、TM/Glossary 扩展和 Provider
|
||||
扩展体系。
|
||||
4. 在隔离环境执行 `make official-smoke`,补充真实网络长期运行报告。
|
||||
5. 最后推进完整 Web 协作后台和完整游戏业务 API。
|
||||
|
||||
+28
-31
@@ -1,8 +1,8 @@
|
||||
# Go 侧进度与边界(权威)
|
||||
|
||||
- **更新时间**:2026-07-27
|
||||
- **更新时间**:2026-09-12
|
||||
- **用途**:统一 Go module `bat-api` 的产品边界、既有约定和组件进度;其他文档与此冲突时以本文为准。
|
||||
- **关联**:issue #19 / G-009(资源 bootstrap/分发)、G-008(已决策关闭)、`docs/architecture/official-resource-backend.md` §7
|
||||
- **关联缺口**:G-009(资源 bootstrap/分发);相关契约见 `docs/architecture/official-resource-backend.md` §7 和 `docs/guides/bat-api-local-live-smoke.md`
|
||||
|
||||
---
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
| 名称 | 路径 / 产物 | 角色 | 是否产品入口 |
|
||||
|---|---|---|---|
|
||||
| **Rust `bat`** | `infrastructure` bin → 正式同步二进制 | 官方资源**自动**发现 / 拉取 / 校验 / 发布 / watch·daemon / 运维子命令 | **是(同步与运维命令行)** |
|
||||
| **Go `bat-api`** | `cmd/bat-api` → `bin/bat-api` | **资源 bootstrap + 分发 HTTP 服务**(官方 CDN path 形态)+ release 观察 API | **是(bootstrap/分发服务)** |
|
||||
| **Go `bat-api`** | `cmd/bat-api` → `bin/bat-api` | **资源 bootstrap + 分发 HTTP 服务**(官方 CDN path 形态)+ release 观察 API + 内嵌 dashboard | **是(bootstrap/分发服务与管理入口)** |
|
||||
| **Go 试验 CLI** | `cmd/bat` → `bin/bat-go`(不得再叫 `bin/bat`) | FFI 演示骨架 | **否** |
|
||||
|
||||
### 1.1 「同步命令行 = Rust `bat`」的含义
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
这**不是**说整个项目只有 Rust,也**不是**取消 Go 入口:
|
||||
|
||||
- Go 的正式产品入口是 **`bat-api` 服务进程**(给客户端/工具提供启动前资源 bootstrap、server-info 改写和已发布资源字节),不是再做一套同步 CLI。
|
||||
- Go 的正式产品入口是 **`bat-api` 服务进程**(给客户端/工具提供启动前资源 bootstrap、server-info 改写、已发布资源字节和内嵌管理 dashboard),不是再做一套同步 CLI。
|
||||
- Go `cmd/bat` 仅试验,禁止与 Rust `bat` 二进制重名。
|
||||
|
||||
### 1.2 `bat` 与 `bat-api` 的关系
|
||||
@@ -31,16 +31,16 @@
|
||||
|---|---|---|
|
||||
| 资源发现 | 读取官方 launcher/resource metadata,解析 `GameMainConfig`、server-info 和 Addressables root | 通过 `bat.sock` 读取已发布版本摘要,不重新探测官方 metadata |
|
||||
| 下载与发布 | 下载、校验、staging、原子发布 `current -> versions/<id>`,维护 manifest/snapshot/version-state | 不下载、不写 staging、不改 version-state;生产资源根来自 RPC 返回的 `resource_root` |
|
||||
| 启动前资源入口 | 暴露 `catalog.status` / `resource.manifest` 等 RPC 数据 | 提供 `/v1/bootstrap`、`/v1/launcher/bootstrap`、launcher 资源 metadata 兼容端点、`/v1/server-info` 和 CDN path,组织给客户端/补丁器使用 |
|
||||
| 长期状态 | watch/daemon、任务队列、日志、错误码、repair/sync/verify | 周期性经 RPC 刷新内存索引,只展示 ready、RPC 健康和 release;需要拉取/修复时由外部运维调用 `bat` 或 RPC 任务 |
|
||||
| 启动前资源入口 | 暴露 `catalog.status` / `resource.manifest` 等 RPC 数据 | 提供 `/v1/bootstrap`、`/v1/launcher/bootstrap`、launcher 资源 metadata 兼容端点、`/v1/server-info`、CDN path 和 `/admin/dashboard/`,组织给客户端/补丁器/维护者使用 |
|
||||
| 长期状态 | watch/daemon、任务队列、日志、错误码、repair/sync/verify | 周期性经 RPC 刷新内存索引;认证 Web 控制面仅白名单转发 reload/refresh/restart/sync/verify/repair/catalog-refresh、schedule/task/log/parse/translation/TM/localized 方法,不持有或写入同步状态 |
|
||||
|
||||
这条边界允许 `bat-api` 做资源 bootstrap 兼容,但不允许它复制 Rust 下载器或伪装完整游戏业务服务。
|
||||
|
||||
### 1.3 决策(已核验)
|
||||
|
||||
1. **G-008 决策关闭(wontfix)**:不另做产品级 Go 同步/运维 CLI。
|
||||
1. **Go 同步 CLI 边界已确定**:不另做产品级 Go 同步/运维 CLI,正式入口是 Rust `bat`。
|
||||
2. **G-009**:资源 bootstrap/分发 MVP 部分完成;非完整游戏业务 API。
|
||||
3. **USERGUIDE 的 bat-api 基础章节已补**;全量 release 联调后继续补充生产参数和排障样例。
|
||||
3. **USERGUIDE 的 bat-api 基础章节已补**;同机 live 联调 runbook 和内嵌 dashboard MVP 已补,真实官方网络下载仍由独立 smoke 负责。
|
||||
|
||||
---
|
||||
|
||||
@@ -54,28 +54,30 @@
|
||||
| B | **`bat-api` 只读分发**已发布 release,不实现下载器,不写 staging/version-state |
|
||||
| C | 仿真范围 = **资源拉取相关**(resource bootstrap + CDN path + 可选 server-info);**不是**完整游戏业务 API |
|
||||
| D | launcher 资源 metadata 可作为 bootstrap 输入/输出兼容;账号、登录、网关和鉴权全链 **非 G-009 关闭条件** |
|
||||
| E | USERGUIDE bat-api 基础章节已补;联调后补充实战样例 |
|
||||
| E | USERGUIDE bat-api 基础章节和同机 live smoke 实战样例已补 |
|
||||
|
||||
### 发现与数据
|
||||
|
||||
| ID | 约定 |
|
||||
|---|---|
|
||||
| F | 版本/清单经 **`bat.sock` JSON-RPC**(`--socket`);不读 daemon 内部状态文件 |
|
||||
| G | RPC 顺序:先 **`daemon.status`**,再 **`daemon.doctor`**,再 catalog/manifest |
|
||||
| G | RPC 顺序:先 **`daemon.status`**,再 **`daemon.doctor`**,再轻量 **`release.attestation`**,再 catalog/manifest;manifest 请求绑定 attested release/publication/mapping/manifest identity 和 verification generation |
|
||||
| H | 生产文件字节从 RPC 返回的 `resource_root` 读盘;`bat-api` 与 daemon 同服务器/同容器/共享文件系统部署;`--resource-root` 仅 fixture 或应急只读诊断 |
|
||||
| I | 真数据在**已全量拉取且长期运行 Rust `bat` 的远程服务器**;开发机不跑全量 `bat`,用 fixture、mock RPC 和 Go 门禁验证;远程联调等连接信息 |
|
||||
| J | 索引以 **manifest + 磁盘 Present/size** 为准 |
|
||||
| I | 生产中 Rust `bat` 与 `bat-api` 在同一主机/容器/共享文件系统;开发用 `/tmp` fixture 和真实本地 `bat.sock` smoke,不依赖远程连接 |
|
||||
| J | Go 索引以 **manifest + 磁盘 Present/size** 建立可读快照,但不将其当作 release integrity |
|
||||
| J2 | 普通 current 分发以 Rust `release.attestation` 的 `ready`、identity、freshness 和 integrity/status code 为准;`bat-api` 只读消费,不自行推导 verifier;`release.status` 保留为重型管理诊断 |
|
||||
|
||||
### 进程配置
|
||||
|
||||
| ID | 约定 |
|
||||
|---|---|
|
||||
| K | `.env` / 环境变量 / CLI:端口、public base、RPC socket、RPC 刷新周期;**预留** database/redis |
|
||||
| L | 管理面 / bootstrap:`/healthz`、`/readyz`、`/v1/bootstrap`、`/v1/release`、`/v1/resources`、`/openapi.yaml`、`/admin/` 预留 |
|
||||
| L | 管理面 / bootstrap:`/healthz`、`/readyz`、`/v1/bootstrap`、`/v1/release`、`/v1/resources`、`/openapi.yaml`、`/admin/` 控制入口 |
|
||||
| M | CDN:`GET/HEAD /prod-clientpatch.bluearchiveyostar.com/...`,支持 Range、ETag、Last-Modified、长期缓存头 |
|
||||
| N | server-info 可选;**只改 AddressablesCatalogUrlRoot** |
|
||||
| N2 | launcher 兼容仅限资源引导:`/v1/launcher/bootstrap` 与 `/api/launcher/...` 形状端点输出已发布 release、launcher metadata 和 GameMainConfig 摘要;不下载 launcher 包、不生成完整 PC package update manifest、不仿造登录/网关 |
|
||||
| N3 | 玩家-facing HTTP 控制面:可配置 token 鉴权、进程内限流、访问日志、反代 IP 适配、动态 JSON `no-store`、`/v1/resources` 分页上限 |
|
||||
| N4 | `/admin/control/{action}` 白名单控制面;`restart` 通过 Rust live RPC 启动 lifecycle controller,Go 不直接执行 `bat` binary |
|
||||
|
||||
### 工程
|
||||
|
||||
@@ -84,7 +86,7 @@
|
||||
| O | 权威文档与 `go list` 一致,禁止「API 完全没有」等过时句 |
|
||||
| P | 试验 CLI 产物 **`bin/bat-go`**,禁止 `bin/bat` |
|
||||
| Q | 空目录标明 reserved empty |
|
||||
| R | 默认门禁:`make test-go-api` + `make build-go-api`(无 FFI) |
|
||||
| R | 默认门禁:`make ci-check`;其中 Go 使用纯 API test/vet/build、required `golangci-lint 2.12.2`(无 FFI),缺失或版本不匹配失败 |
|
||||
|
||||
---
|
||||
|
||||
@@ -93,12 +95,12 @@
|
||||
| 组件 | 路径 | 状态 | 说明 |
|
||||
|---|---|---|---|
|
||||
| Module | `go.mod` → `bat-api` | 已用 | 服务层模块名 |
|
||||
| RPC client | `internal/backendrpc` | **完成** | typed JSON-RPC;fake transport 单测 |
|
||||
| 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理面预留 + `.env` |
|
||||
| RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helper;typed helper 覆盖 daemon 已实现控制/查询、`resource.state/sync/verify/repair/manifest/list`、`release.attestation/status/list/distribution/cleanup`、`catalog.*`、`parse.*`、`localized.status/publish/rollback`、`task.*`、`translation.tasks`、`translation.handoff`、`translation.task.update`、`translation.worker.run`、`translation.proofread`、`translation.memory.summary/query/confirm/conflicts/resolve_conflict`、`translation.glossary.summary/query/diagnose/add/update/approve/deprecate/delete` 和文件级 UnityFS patch 调用;`resource.manifest` typed params 固定 release/publication/manifest identity 和 verification generation;`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` + `/v1/releases` + `/v1/distribution` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理控制白名单 + release/localized/TM/Glossary admin forwarding + 内嵌 dashboard + `.env` |
|
||||
| 试验 CLI | `cmd/bat` | **试验** | doctor 固定 ok;manifest/sync 走 FFI |
|
||||
| FFI | `internal/ffi` | **可选** | 需 `build-ffi` |
|
||||
| 空骨架 | `api/`、`pkg/*`、部分 `internal/*` | **空** | 见各目录 README |
|
||||
| Web | `web/` | **空** | G-010 |
|
||||
| Web | `web/` | **内嵌 dashboard MVP** | 完整协作后台、登录/角色和术语管理仍属 G-010 剩余 |
|
||||
|
||||
`go list ./...` 当前包:
|
||||
|
||||
@@ -114,9 +116,7 @@
|
||||
|
||||
```bash
|
||||
# 默认(提交前 / CI 建议)
|
||||
make test-go-api
|
||||
make build-go-api
|
||||
go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/...
|
||||
make ci-check
|
||||
|
||||
# 可选:改 FFI 或试验 CLI 时
|
||||
make build-ffi
|
||||
@@ -126,26 +126,23 @@ make build-go-cli # 产出 bin/bat-go
|
||||
|
||||
---
|
||||
|
||||
## 5. 与缺口 / issue 对应
|
||||
## 5. 与缺口对应
|
||||
|
||||
| 项 | 状态 |
|
||||
|---|---|
|
||||
| G-008 Go 同步 CLI | **已决策关闭**(正式同步 CLI = Rust `bat`) |
|
||||
| G-009 bat-api 资源 bootstrap/分发 | **部分完成**(MVP+生产控制面);已含资源 bootstrap 关系面、launcher 资源 metadata 兼容、HTTP 鉴权/限流/日志/反代适配、RPC 周期刷新/诊断、readiness、OpenAPI、管理面预留和部署模板,后续远程服务器联调/可选持久化 |
|
||||
| issue #19 | 资源面 MVP 与 USERGUIDE 基础章节已编码;真机联调后继续补充实战样例;**未自动关 issue** |
|
||||
| G-010 Web | 未开始 |
|
||||
| Go 同步 CLI | **边界已确定**(正式同步 CLI = Rust `bat`) |
|
||||
| G-009 bat-api 资源 bootstrap/分发 | **资源面完成(非完整官方游戏 API)**;已含资源 bootstrap、launcher resource metadata 兼容、HTTP 鉴权/限流/日志/反代适配、RPC 周期刷新/诊断、readiness、OpenAPI、管理控制白名单、Rust-owned `schedule.*`、`task.*`、`parse.*`、`release.*` 双 release 查询/分发/cleanup 转发、翻译任务/TM 状态查询与显式确认代理、内嵌 dashboard、同机 live smoke 和部署模板;持久化仍另议 |
|
||||
| G-010 Web | 内嵌 dashboard MVP 已完成;完整协作后台、登录/角色、术语管理和构建型前端未开始 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 资源布局与逆向
|
||||
|
||||
- **Release / URL / 分发契约(权威)**:`docs/architecture/resource-release-layout.md`
|
||||
- 真机全量实勘、seed inventory diff、issue #2/#3 样本采集按该文档 §9–§10 执行
|
||||
- 真机全量实勘、seed inventory diff 和样本采集按该文档 §9–§10 执行
|
||||
|
||||
## 7. 后续(不在进度统一范围内)
|
||||
|
||||
1. 服务器 SSH 只读实勘(连接信息到位后)
|
||||
2. bat-api 与远程长期运行的 `bat` / 全量 release 联调(含 `/v1/bootstrap`、server-info 和 CDN path)
|
||||
3. 预留 database/redis 的接入时机另议
|
||||
4. USERGUIDE bat-api 联调排障样例(全量 release 验证后)
|
||||
5. launcher 完整安装包更新链 / 登录网关链(若需要,新 issue)
|
||||
1. 预留 database/redis 的接入时机另议
|
||||
2. 真实官方网络全量下载长期运行报告(使用 `make official-smoke`,与同机 live 联调独立)
|
||||
3. launcher 完整安装包更新链 / 登录网关链(如需推进,应另立范围明确的后续需求)
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
# 解析模块维护冻结
|
||||
# 解析模块维护冻结(历史记录)
|
||||
|
||||
状态:**生效中**
|
||||
**状态**:历史记录,已解除
|
||||
**生效时间**:2026-07-30
|
||||
**解除时间**:2026-09-04
|
||||
|
||||
生效时间:2026-07-30
|
||||
本文保留解析模块维护冻结期间的原始规则和例外说明。冻结已于
|
||||
2026-09-04 解除,以下内容不构成当前开发约束;当前解析开发以源码、测试、
|
||||
`CURRENT_STATUS.md` 和 `docs/architecture/assetbundle.md` 为准。
|
||||
|
||||
---
|
||||
|
||||
## 原冻结记录
|
||||
|
||||
原记录发布时状态:**生效中**
|
||||
|
||||
冻结目标:停止继续扩大 UnityFS / AssetBundle / Addressables / TypeTree 解析能力,把当前工作重心切换到运行稳定性、代码审核问题、文档一致性和发布链路可靠性。
|
||||
|
||||
@@ -27,6 +37,28 @@
|
||||
- 修正文档、CLI 帮助、RPC 参考和状态文件中与当前实现不一致的解析能力声明。
|
||||
- 改善错误信息、日志字段、状态记录和失败恢复,但不得改变解析输出契约,除非是修复错误契约且同步迁移说明。
|
||||
|
||||
## issue 43 的明确例外
|
||||
|
||||
本次 issue 43 经用户明确授权,允许新增 `bat` 的工作流编排入口:
|
||||
|
||||
- `parse run` 只刷新已有解析输出、TextUnit 索引和翻译队列;
|
||||
- `parse repack` 只调用已有 TextAsset、TypeTree string 和受支持语义字段 patch 实现;
|
||||
- `i18n` 工作台和 `publish` 只消费已有 TextUnit 输出,并发布独立汉化 release。
|
||||
|
||||
该例外不解冻解析器,不新增 UnityFS/AssetBundle/Addressables/TypeTree 解析类型、字段覆盖、catalog 结构或合成 fixture 能力。后续任何扩大解析覆盖的变更仍需单独解冻授权。
|
||||
|
||||
## issue 2/3 的开发例外
|
||||
|
||||
授权时间:2026-08-19
|
||||
|
||||
用户明确授权将 issue #2/#3 作为解析开发进展继续推进。本次例外允许:
|
||||
|
||||
- Addressables JSON/compact catalog 的 provider、bundle name、hash、size、CRC、资源类型和依赖关系字段补全,以及对应 SQLite/fixture 回归;
|
||||
- UnityFS 已有 header、block、directory、压缩和 alignment 能力的校验加固,以及隔离真实 bundle 回归;
|
||||
- 更新解析路线图、状态和 RPC/CLI 资源索引字段说明。
|
||||
|
||||
本次例外不包含发布级复杂对象重打包、完整 Unity 版本兼容承诺或新的汉化发布控制面;这些仍按后续 Patch/发布路线单独验收。
|
||||
|
||||
## 禁止变更
|
||||
|
||||
冻结期禁止以下解析相关变更:
|
||||
@@ -1,6 +1,8 @@
|
||||
# 历史报告归档说明
|
||||
|
||||
本目录只保存追溯资料,不代表当前项目状态。当前状态以根目录 `CURRENT_STATUS.md`、`PROJECT_PLAN.md`、`DOCS_INDEX.md` 和 `docs/reports/CURRENT_GAPS.md` 为准。
|
||||
本目录只保存追溯资料,不代表当前项目状态。当前实现以源码、测试、根目录
|
||||
`CURRENT_STATUS.md` 和对应专项状态文档为准;`PROJECT_PLAN.md` 只描述目标和路线图,
|
||||
`DOCS_INDEX.md` 只负责文档分类,`docs/reports/CURRENT_GAPS.md` 只记录当前缺口。
|
||||
|
||||
归档分类:
|
||||
|
||||
@@ -10,5 +12,6 @@
|
||||
- `quality/`:早期质量状态报告。
|
||||
- `build-logs/`:历史构建、测试和 Clippy 输出。
|
||||
- `nested-docs/`:从误嵌套 `docs/docs` 移出的历史报告。
|
||||
- `PARSER_FREEZE.md`:2026-07-30 生效、2026-09-04 解除的解析模块维护冻结记录。
|
||||
|
||||
新增运行产物、smoke 输出、质量扫描输出和本地分析报告不要放入本目录;这些文件应写入 `/tmp`、显式的隔离输出目录,或被 `.gitignore` 覆盖的本地生成报告目录。
|
||||
|
||||
@@ -70,12 +70,12 @@
|
||||
## 📚 重要文档索引
|
||||
|
||||
### 架构和设计
|
||||
- `docs/ARCHITECTURE_REVIEW.md` - 完整架构审查(1903行)
|
||||
- `docs/BLUE_ARCHIVE_TECHNICAL_ANALYSIS.md` - 技术分析
|
||||
- `docs/CODE_QUALITY_IMPROVEMENT.md` - 代码质量优化详情
|
||||
- `docs/archive/ARCHITECTURE_REVIEW.md` - 完整架构审查(1903行)
|
||||
- `docs/archive/BLUE_ARCHIVE_TECHNICAL_ANALYSIS.md` - 技术分析
|
||||
- `docs/reports/historical/nested-docs/CODE_QUALITY_IMPROVEMENT.md` - 代码质量优化详情
|
||||
|
||||
### 进度报告
|
||||
- `docs/PHASE_1_WEEK_1_COMPLETE.md` - Week 1 详细报告
|
||||
- `docs/reports/historical/nested-docs/PHASE_1_WEEK_1_COMPLETE.md` - Week 1 详细报告
|
||||
- `PHASE_1_WEEK_1_FINAL_REPORT.md` - Week 1 最终报告
|
||||
|
||||
### 代码质量
|
||||
|
||||
@@ -109,11 +109,11 @@ BlueArchiveToolkit/
|
||||
|
||||
## 📚 创建的文档
|
||||
|
||||
1. ✅ [ARCHITECTURE_REVIEW.md](./docs/ARCHITECTURE_REVIEW.md) - 完整架构审查(1903 行)
|
||||
2. ✅ [BLUE_ARCHIVE_TECHNICAL_ANALYSIS.md](./docs/BLUE_ARCHIVE_TECHNICAL_ANALYSIS.md) - 技术分析报告
|
||||
3. ✅ [PHASE_0.5_REPORT.md](./docs/PHASE_0.5_REPORT.md) - 深度验证报告
|
||||
4. ✅ [PHASE_1_WEEK_1_COMPLETE.md](./docs/PHASE_1_WEEK_1_COMPLETE.md) - Week 1 详细报告
|
||||
5. ✅ [WEEK_1_VERIFIED.md](./WEEK_1_VERIFIED.md) - 最终验证报告
|
||||
1. ✅ [ARCHITECTURE_REVIEW.md](../../../archive/ARCHITECTURE_REVIEW.md) - 完整架构审查(1903 行)
|
||||
2. ✅ [BLUE_ARCHIVE_TECHNICAL_ANALYSIS.md](../../../archive/BLUE_ARCHIVE_TECHNICAL_ANALYSIS.md) - 技术分析报告
|
||||
3. ✅ [PHASE_0.5_REPORT.md](../nested-docs/PHASE_0.5_REPORT.md) - 深度验证报告
|
||||
4. ✅ [PHASE_1_WEEK_1_COMPLETE.md](../nested-docs/PHASE_1_WEEK_1_COMPLETE.md) - Week 1 详细报告
|
||||
5. `WEEK_1_VERIFIED.md` - 原报告未纳入当前归档。
|
||||
|
||||
---
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,728 @@
|
||||
use super::report_output::print_json_value;
|
||||
use super::*;
|
||||
use bat_core::domain::{
|
||||
GlossaryReviewStatus, GlossarySourceKind, GlossarySourceRecord, GlossaryTermDraft,
|
||||
GlossaryTermSnapshot, TranslationMemoryContext,
|
||||
};
|
||||
use bat_infrastructure::{SqliteGlossaryRepository, GLOSSARY_SCHEMA_VERSION};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub(super) fn run_glossary_command(options: &CliOptions) -> anyhow::Result<()> {
|
||||
let method = glossary_method(options.command)?;
|
||||
if daemon_rpc_available(&options.state_dir)
|
||||
&& options.resource_root.is_none()
|
||||
&& !options.output_explicit
|
||||
{
|
||||
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
|
||||
let report = daemon_rpc_call(&options.state_dir, method, glossary_cli_params(options)?)?;
|
||||
print_json_value(options.output_format, &report)?;
|
||||
return Ok(());
|
||||
}
|
||||
let path = glossary_cli_path(options)?;
|
||||
let report =
|
||||
match options.command {
|
||||
CliCommand::GlossarySummary => build_glossary_summary_report(&path)?,
|
||||
CliCommand::GlossaryQuery => build_glossary_query_report(
|
||||
&path,
|
||||
options.glossary_source_text.as_deref(),
|
||||
options.glossary_category.as_deref(),
|
||||
options.glossary_review_status.as_deref(),
|
||||
options.query_limit,
|
||||
)?,
|
||||
CliCommand::GlossaryDiagnose => build_glossary_diagnose_report(
|
||||
&path,
|
||||
options.glossary_source_text.as_deref().unwrap_or_default(),
|
||||
parse_glossary_context(options.glossary_context_json.as_deref())?,
|
||||
)?,
|
||||
CliCommand::GlossaryAdd | CliCommand::GlossaryUpdate => {
|
||||
let draft = glossary_term_draft(options)?;
|
||||
let reviewer = options.glossary_reviewer.as_deref();
|
||||
build_glossary_mutation_report(
|
||||
&path,
|
||||
&draft,
|
||||
options.command == CliCommand::GlossaryUpdate,
|
||||
reviewer,
|
||||
options.glossary_reason.clone(),
|
||||
)?
|
||||
}
|
||||
CliCommand::GlossaryApprove | CliCommand::GlossaryDeprecate => {
|
||||
let term_id = options.glossary_term_id.as_deref().ok_or_else(|| {
|
||||
anyhow::anyhow!("Glossary review 必须指定 --glossary-term-id")
|
||||
})?;
|
||||
let reviewer = options.glossary_reviewer.as_deref().ok_or_else(|| {
|
||||
anyhow::anyhow!("Glossary review 必须指定 --glossary-reviewer")
|
||||
})?;
|
||||
let status = if options.command == CliCommand::GlossaryApprove {
|
||||
GlossaryReviewStatus::Approved
|
||||
} else {
|
||||
GlossaryReviewStatus::Deprecated
|
||||
};
|
||||
build_glossary_review_report(
|
||||
&path,
|
||||
term_id,
|
||||
status,
|
||||
reviewer,
|
||||
options.glossary_reason.clone(),
|
||||
)?
|
||||
}
|
||||
CliCommand::GlossaryDelete => {
|
||||
let term_id = options.glossary_term_id.as_deref().ok_or_else(|| {
|
||||
anyhow::anyhow!("Glossary delete 必须指定 --glossary-term-id")
|
||||
})?;
|
||||
let reviewer = options.glossary_reviewer.as_deref().ok_or_else(|| {
|
||||
anyhow::anyhow!("Glossary delete 必须指定 --glossary-reviewer")
|
||||
})?;
|
||||
let reason = options
|
||||
.glossary_reason
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Glossary delete 必须指定 --glossary-reason"))?;
|
||||
build_glossary_delete_report(&path, term_id, reviewer, reason)?
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
print_json_value(options.output_format, &report)
|
||||
}
|
||||
|
||||
fn glossary_method(command: CliCommand) -> anyhow::Result<&'static str> {
|
||||
Ok(match command {
|
||||
CliCommand::GlossarySummary => RPC_METHOD_GLOSSARY_SUMMARY,
|
||||
CliCommand::GlossaryQuery => RPC_METHOD_GLOSSARY_QUERY,
|
||||
CliCommand::GlossaryAdd => RPC_METHOD_GLOSSARY_ADD,
|
||||
CliCommand::GlossaryUpdate => RPC_METHOD_GLOSSARY_UPDATE,
|
||||
CliCommand::GlossaryApprove => RPC_METHOD_GLOSSARY_APPROVE,
|
||||
CliCommand::GlossaryDeprecate => RPC_METHOD_GLOSSARY_DEPRECATE,
|
||||
CliCommand::GlossaryDelete => RPC_METHOD_GLOSSARY_DELETE,
|
||||
CliCommand::GlossaryDiagnose => RPC_METHOD_GLOSSARY_DIAGNOSE,
|
||||
_ => return Err(anyhow::anyhow!("不是 Glossary 命令")),
|
||||
})
|
||||
}
|
||||
|
||||
fn glossary_cli_path(options: &CliOptions) -> anyhow::Result<std::path::PathBuf> {
|
||||
if let Some(path) = options.glossary_path.as_ref() {
|
||||
return lexical_absolute(path).map_err(anyhow::Error::msg);
|
||||
}
|
||||
let resource_root = options
|
||||
.resource_root
|
||||
.as_deref()
|
||||
.map(lexical_absolute)
|
||||
.transpose()
|
||||
.map_err(anyhow::Error::msg)?
|
||||
.unwrap_or(active_official_resource_root(&options.config.output_root)?);
|
||||
Ok(SqliteGlossaryRepository::repository_path(&resource_root))
|
||||
}
|
||||
|
||||
fn glossary_cli_params(options: &CliOptions) -> anyhow::Result<Option<serde_json::Value>> {
|
||||
let mut params = serde_json::Map::new();
|
||||
if let Some(path) = options.glossary_path.as_ref() {
|
||||
params.insert("glossary_path".to_string(), serde_json::json!(path));
|
||||
}
|
||||
match options.command {
|
||||
CliCommand::GlossarySummary => {}
|
||||
CliCommand::GlossaryQuery => {
|
||||
if let Some(source_text) = options.glossary_source_text.as_deref() {
|
||||
params.insert("source_text".to_string(), serde_json::json!(source_text));
|
||||
}
|
||||
if let Some(category) = options.glossary_category.as_deref() {
|
||||
params.insert("category".to_string(), serde_json::json!(category));
|
||||
}
|
||||
if let Some(status) = options.glossary_review_status.as_deref() {
|
||||
params.insert("review_status".to_string(), serde_json::json!(status));
|
||||
}
|
||||
params.insert("limit".to_string(), serde_json::json!(options.query_limit));
|
||||
}
|
||||
CliCommand::GlossaryDiagnose => {
|
||||
let source_text = options.glossary_source_text.as_deref().ok_or_else(|| {
|
||||
anyhow::anyhow!("Glossary diagnose 必须指定 --glossary-source-text")
|
||||
})?;
|
||||
params.insert("source_text".to_string(), serde_json::json!(source_text));
|
||||
params.insert(
|
||||
"context".to_string(),
|
||||
serde_json::json!(parse_glossary_context(
|
||||
options.glossary_context_json.as_deref()
|
||||
)?),
|
||||
);
|
||||
}
|
||||
CliCommand::GlossaryAdd | CliCommand::GlossaryUpdate => {
|
||||
let draft = glossary_term_draft(options)?;
|
||||
params.extend(
|
||||
serde_json::to_value(draft)?
|
||||
.as_object()
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
if options.command == CliCommand::GlossaryUpdate {
|
||||
params.insert(
|
||||
"reviewer".to_string(),
|
||||
serde_json::json!(options.glossary_reviewer.as_deref().unwrap_or_default()),
|
||||
);
|
||||
if let Some(reason) = options.glossary_reason.as_deref() {
|
||||
params.insert("reason".to_string(), serde_json::json!(reason));
|
||||
}
|
||||
}
|
||||
}
|
||||
CliCommand::GlossaryApprove | CliCommand::GlossaryDeprecate => {
|
||||
params.insert(
|
||||
"term_id".to_string(),
|
||||
serde_json::json!(options.glossary_term_id.as_deref().unwrap_or_default()),
|
||||
);
|
||||
params.insert(
|
||||
"reviewer".to_string(),
|
||||
serde_json::json!(options.glossary_reviewer.as_deref().unwrap_or_default()),
|
||||
);
|
||||
if let Some(reason) = options.glossary_reason.as_deref() {
|
||||
params.insert("reason".to_string(), serde_json::json!(reason));
|
||||
}
|
||||
}
|
||||
CliCommand::GlossaryDelete => {
|
||||
params.insert(
|
||||
"term_id".to_string(),
|
||||
serde_json::json!(options.glossary_term_id.as_deref().unwrap_or_default()),
|
||||
);
|
||||
params.insert(
|
||||
"reviewer".to_string(),
|
||||
serde_json::json!(options.glossary_reviewer.as_deref().unwrap_or_default()),
|
||||
);
|
||||
params.insert(
|
||||
"reason".to_string(),
|
||||
serde_json::json!(options.glossary_reason.as_deref().unwrap_or_default()),
|
||||
);
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
Ok(Some(serde_json::Value::Object(params)))
|
||||
}
|
||||
|
||||
fn glossary_term_draft(options: &CliOptions) -> anyhow::Result<GlossaryTermDraft> {
|
||||
let term_id = required_option(options.glossary_term_id.as_deref(), "--glossary-term-id")?;
|
||||
let source_term = required_option(
|
||||
options.glossary_source_term.as_deref(),
|
||||
"--glossary-source-term",
|
||||
)?;
|
||||
let recommended_translation = required_option(
|
||||
options.glossary_recommended_translation.as_deref(),
|
||||
"--glossary-recommended-translation",
|
||||
)?;
|
||||
let aliases = parse_string_array(
|
||||
options.glossary_aliases_json.as_deref(),
|
||||
"--glossary-aliases-json",
|
||||
)?;
|
||||
let allowed_translations = parse_string_array(
|
||||
options.glossary_allowed_translations_json.as_deref(),
|
||||
"--glossary-allowed-translations-json",
|
||||
)?;
|
||||
let scope = parse_glossary_context(options.glossary_scope_json.as_deref())?;
|
||||
let source_kind = options.glossary_source_kind.as_deref().unwrap_or("manual");
|
||||
let source_kind = GlossarySourceKind::parse(source_kind)
|
||||
.ok_or_else(|| anyhow::anyhow!("Glossary source kind 无效:{source_kind}"))?;
|
||||
let review_status = options.glossary_review_status.as_deref().unwrap_or("draft");
|
||||
let review_status = GlossaryReviewStatus::parse(review_status)
|
||||
.ok_or_else(|| anyhow::anyhow!("Glossary review status 无效:{review_status}"))?;
|
||||
let now = unix_seconds_now();
|
||||
Ok(GlossaryTermDraft {
|
||||
term_id,
|
||||
definition: GlossaryTermSnapshot {
|
||||
source_term,
|
||||
aliases,
|
||||
recommended_translation,
|
||||
allowed_translations,
|
||||
source_language: options.glossary_source_language.clone(),
|
||||
target_language: options.glossary_target_language.clone(),
|
||||
category: options.glossary_category.clone(),
|
||||
priority: options.glossary_priority,
|
||||
scope,
|
||||
},
|
||||
review_status,
|
||||
source: GlossarySourceRecord {
|
||||
source_kind,
|
||||
source_ref: options.glossary_source_ref.clone(),
|
||||
source_author: options.glossary_source_author.clone(),
|
||||
source_note: options.glossary_source_note.clone(),
|
||||
observed_unix_seconds: now,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn required_option(value: Option<&str>, label: &str) -> anyhow::Result<String> {
|
||||
value
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| anyhow::anyhow!("Glossary 必须指定 {label}"))
|
||||
}
|
||||
|
||||
fn parse_string_array(value: Option<&str>, label: &str) -> anyhow::Result<Vec<String>> {
|
||||
let Some(value) = value else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
serde_json::from_str(value)
|
||||
.map_err(|error| anyhow::anyhow!("{label} 必须是 JSON string array:{error}"))
|
||||
}
|
||||
|
||||
fn parse_glossary_context(value: Option<&str>) -> anyhow::Result<TranslationMemoryContext> {
|
||||
let Some(value) = value else {
|
||||
return Ok(BTreeMap::new());
|
||||
};
|
||||
serde_json::from_str(value)
|
||||
.map_err(|error| anyhow::anyhow!("Glossary context 必须是 JSON object:{error}"))
|
||||
}
|
||||
|
||||
pub(super) fn build_glossary_summary_report(
|
||||
path: &std::path::Path,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
if !sqlite_file_exists_no_symlink(path, "Glossary 数据库")? {
|
||||
return Ok(serde_json::json!({
|
||||
"available": false,
|
||||
"path": path,
|
||||
"reason": "database_missing",
|
||||
}));
|
||||
}
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
let summary = runtime.block_on(async {
|
||||
let repository = SqliteGlossaryRepository::open(path)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||
repository
|
||||
.summary()
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))
|
||||
})?;
|
||||
Ok(serde_json::json!({
|
||||
"available": true,
|
||||
"path": path,
|
||||
"schema_version": GLOSSARY_SCHEMA_VERSION,
|
||||
"summary": summary,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn build_glossary_query_report(
|
||||
path: &std::path::Path,
|
||||
source_text: Option<&str>,
|
||||
category: Option<&str>,
|
||||
review_status: Option<&str>,
|
||||
limit: usize,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let status = review_status
|
||||
.map(|value| {
|
||||
GlossaryReviewStatus::parse(value)
|
||||
.ok_or_else(|| anyhow::anyhow!("Glossary review_status 无效"))
|
||||
})
|
||||
.transpose()?;
|
||||
if !sqlite_file_exists_no_symlink(path, "Glossary 数据库")? {
|
||||
return Ok(serde_json::json!({
|
||||
"available": false,
|
||||
"path": path,
|
||||
"source_text": source_text,
|
||||
"terms": [],
|
||||
"reason": "database_missing",
|
||||
}));
|
||||
}
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
let terms = runtime.block_on(async {
|
||||
let repository = SqliteGlossaryRepository::open(path)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||
repository
|
||||
.query(source_text, category, status, limit)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))
|
||||
})?;
|
||||
Ok(serde_json::json!({
|
||||
"available": true,
|
||||
"path": path,
|
||||
"source_text": source_text,
|
||||
"terms": terms,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn build_glossary_diagnose_report(
|
||||
path: &std::path::Path,
|
||||
source_text: &str,
|
||||
context: TranslationMemoryContext,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
if source_text.trim().is_empty() {
|
||||
return Err(anyhow::anyhow!("Glossary diagnose 的 source_text 不能为空"));
|
||||
}
|
||||
if !sqlite_file_exists_no_symlink(path, "Glossary 数据库")? {
|
||||
return Ok(serde_json::json!({
|
||||
"available": false,
|
||||
"path": path,
|
||||
"source_text": source_text,
|
||||
"context": context,
|
||||
"evaluation": {
|
||||
"constraints": [],
|
||||
"diagnostics": [],
|
||||
"blocked": false
|
||||
},
|
||||
"reason": "database_missing",
|
||||
}));
|
||||
}
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
let evaluation = runtime.block_on(async {
|
||||
let repository = SqliteGlossaryRepository::open(path)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||
repository
|
||||
.diagnose(source_text, &context)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))
|
||||
})?;
|
||||
Ok(serde_json::json!({
|
||||
"available": true,
|
||||
"path": path,
|
||||
"source_text": source_text,
|
||||
"context": context,
|
||||
"evaluation": evaluation,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn build_glossary_mutation_report(
|
||||
path: &std::path::Path,
|
||||
draft: &GlossaryTermDraft,
|
||||
update: bool,
|
||||
reviewer: Option<&str>,
|
||||
reason: Option<String>,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
let term = runtime.block_on(async {
|
||||
let repository = SqliteGlossaryRepository::new(path)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||
let result = if update {
|
||||
repository
|
||||
.update(
|
||||
draft.clone(),
|
||||
reviewer.ok_or_else(|| anyhow::anyhow!("Glossary update 需要 reviewer"))?,
|
||||
reason,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
repository.add(draft.clone()).await
|
||||
};
|
||||
result.map_err(|error| anyhow::anyhow!("{error}"))
|
||||
})?;
|
||||
Ok(serde_json::json!({
|
||||
"available": true,
|
||||
"path": path,
|
||||
"schema_version": GLOSSARY_SCHEMA_VERSION,
|
||||
"term": term,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn build_glossary_review_report(
|
||||
path: &std::path::Path,
|
||||
term_id: &str,
|
||||
status: GlossaryReviewStatus,
|
||||
reviewer: &str,
|
||||
reason: Option<String>,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
let term = runtime.block_on(async {
|
||||
let repository = SqliteGlossaryRepository::open(path)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||
repository
|
||||
.review(term_id, status, reviewer, reason)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))
|
||||
})?;
|
||||
Ok(serde_json::json!({
|
||||
"available": true,
|
||||
"path": path,
|
||||
"term": term,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn build_glossary_delete_report(
|
||||
path: &std::path::Path,
|
||||
term_id: &str,
|
||||
reviewer: &str,
|
||||
reason: &str,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
let term = runtime.block_on(async {
|
||||
let repository = SqliteGlossaryRepository::open(path)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||
repository
|
||||
.delete(term_id, reviewer, reason)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))
|
||||
})?;
|
||||
Ok(serde_json::json!({
|
||||
"available": true,
|
||||
"path": path,
|
||||
"schema_version": GLOSSARY_SCHEMA_VERSION,
|
||||
"deleted": true,
|
||||
"term": term,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn glossary_rpc_envelope(
|
||||
request_id: String,
|
||||
result: Result<serde_json::Value, ApiError>,
|
||||
) -> RpcEnvelope {
|
||||
match result {
|
||||
Ok(data) => rpc_envelope_ok(request_id, "ok", data),
|
||||
Err(error) => rpc_envelope_error(request_id, error),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn glossary_summary_rpc_report(
|
||||
state_dir: &Path,
|
||||
output_root: &Path,
|
||||
default_path: Option<&Path>,
|
||||
params: Option<&serde_json::Value>,
|
||||
) -> Result<serde_json::Value, ApiError> {
|
||||
let path = glossary_rpc_path(
|
||||
state_dir,
|
||||
output_root,
|
||||
default_path,
|
||||
params,
|
||||
RPC_METHOD_GLOSSARY_SUMMARY,
|
||||
)?;
|
||||
build_glossary_summary_report(&path)
|
||||
.map_err(|error| glossary_internal_error(RPC_METHOD_GLOSSARY_SUMMARY, error))
|
||||
}
|
||||
|
||||
pub(super) fn glossary_query_rpc_report(
|
||||
state_dir: &Path,
|
||||
output_root: &Path,
|
||||
default_path: Option<&Path>,
|
||||
params: Option<&serde_json::Value>,
|
||||
) -> Result<serde_json::Value, ApiError> {
|
||||
let params = glossary_params(params, RPC_METHOD_GLOSSARY_QUERY)?;
|
||||
let source_text = glossary_string(¶ms, "source_text", RPC_METHOD_GLOSSARY_QUERY)?;
|
||||
let category = glossary_string(¶ms, "category", RPC_METHOD_GLOSSARY_QUERY)?;
|
||||
let review_status = glossary_string(¶ms, "review_status", RPC_METHOD_GLOSSARY_QUERY)?;
|
||||
let limit = glossary_limit(¶ms, RPC_METHOD_GLOSSARY_QUERY)?;
|
||||
let path = glossary_rpc_path(
|
||||
state_dir,
|
||||
output_root,
|
||||
default_path,
|
||||
Some(&serde_json::Value::Object(params.clone())),
|
||||
RPC_METHOD_GLOSSARY_QUERY,
|
||||
)?;
|
||||
build_glossary_query_report(&path, source_text, category, review_status, limit)
|
||||
.map_err(|error| glossary_internal_error(RPC_METHOD_GLOSSARY_QUERY, error))
|
||||
}
|
||||
|
||||
pub(super) fn glossary_diagnose_rpc_report(
|
||||
state_dir: &Path,
|
||||
output_root: &Path,
|
||||
default_path: Option<&Path>,
|
||||
params: Option<&serde_json::Value>,
|
||||
) -> Result<serde_json::Value, ApiError> {
|
||||
let params = glossary_params(params, RPC_METHOD_GLOSSARY_DIAGNOSE)?;
|
||||
let source_text = glossary_string(¶ms, "source_text", RPC_METHOD_GLOSSARY_DIAGNOSE)?
|
||||
.ok_or_else(|| glossary_invalid(RPC_METHOD_GLOSSARY_DIAGNOSE, "缺少 source_text"))?;
|
||||
let context = glossary_context(¶ms, RPC_METHOD_GLOSSARY_DIAGNOSE)?;
|
||||
let path = glossary_rpc_path(
|
||||
state_dir,
|
||||
output_root,
|
||||
default_path,
|
||||
Some(&serde_json::Value::Object(params.clone())),
|
||||
RPC_METHOD_GLOSSARY_DIAGNOSE,
|
||||
)?;
|
||||
build_glossary_diagnose_report(&path, source_text, context)
|
||||
.map_err(|error| glossary_internal_error(RPC_METHOD_GLOSSARY_DIAGNOSE, error))
|
||||
}
|
||||
|
||||
pub(super) fn glossary_mutation_rpc_report(
|
||||
state_dir: &Path,
|
||||
output_root: &Path,
|
||||
default_path: Option<&Path>,
|
||||
params: Option<&serde_json::Value>,
|
||||
update: bool,
|
||||
) -> Result<serde_json::Value, ApiError> {
|
||||
let method = if update {
|
||||
RPC_METHOD_GLOSSARY_UPDATE
|
||||
} else {
|
||||
RPC_METHOD_GLOSSARY_ADD
|
||||
};
|
||||
let params = glossary_params(params, method)?;
|
||||
let draft: GlossaryTermDraft =
|
||||
serde_json::from_value(serde_json::Value::Object(params.clone())).map_err(|error| {
|
||||
glossary_invalid(method, format!("Glossary term 参数无效:{error}"))
|
||||
})?;
|
||||
let reviewer = glossary_string(¶ms, "reviewer", method)?;
|
||||
if update && reviewer.is_none() {
|
||||
return Err(glossary_invalid(method, "update 缺少 reviewer"));
|
||||
}
|
||||
let reason = glossary_string(¶ms, "reason", method)?.map(str::to_string);
|
||||
let path = glossary_rpc_path(
|
||||
state_dir,
|
||||
output_root,
|
||||
default_path,
|
||||
Some(&serde_json::Value::Object(params.clone())),
|
||||
method,
|
||||
)?;
|
||||
build_glossary_mutation_report(&path, &draft, update, reviewer, reason)
|
||||
.map_err(|error| glossary_internal_error(method, error))
|
||||
}
|
||||
|
||||
pub(super) fn glossary_review_rpc_report(
|
||||
state_dir: &Path,
|
||||
output_root: &Path,
|
||||
default_path: Option<&Path>,
|
||||
params: Option<&serde_json::Value>,
|
||||
status: GlossaryReviewStatus,
|
||||
) -> Result<serde_json::Value, ApiError> {
|
||||
let method = if status == GlossaryReviewStatus::Approved {
|
||||
RPC_METHOD_GLOSSARY_APPROVE
|
||||
} else {
|
||||
RPC_METHOD_GLOSSARY_DEPRECATE
|
||||
};
|
||||
let params = glossary_params(params, method)?;
|
||||
let term_id = glossary_string(¶ms, "term_id", method)?
|
||||
.ok_or_else(|| glossary_invalid(method, "缺少 term_id"))?;
|
||||
let reviewer = glossary_string(¶ms, "reviewer", method)?
|
||||
.ok_or_else(|| glossary_invalid(method, "缺少 reviewer"))?;
|
||||
let reason = glossary_string(¶ms, "reason", method)?.map(str::to_string);
|
||||
let path = glossary_rpc_path(
|
||||
state_dir,
|
||||
output_root,
|
||||
default_path,
|
||||
Some(&serde_json::Value::Object(params.clone())),
|
||||
method,
|
||||
)?;
|
||||
build_glossary_review_report(&path, term_id, status, reviewer, reason)
|
||||
.map_err(|error| glossary_internal_error(method, error))
|
||||
}
|
||||
|
||||
pub(super) fn glossary_delete_rpc_report(
|
||||
state_dir: &Path,
|
||||
output_root: &Path,
|
||||
default_path: Option<&Path>,
|
||||
params: Option<&serde_json::Value>,
|
||||
) -> Result<serde_json::Value, ApiError> {
|
||||
let params = glossary_params(params, RPC_METHOD_GLOSSARY_DELETE)?;
|
||||
let term_id = glossary_string(¶ms, "term_id", RPC_METHOD_GLOSSARY_DELETE)?
|
||||
.ok_or_else(|| glossary_invalid(RPC_METHOD_GLOSSARY_DELETE, "缺少 term_id"))?;
|
||||
let reviewer = glossary_string(¶ms, "reviewer", RPC_METHOD_GLOSSARY_DELETE)?
|
||||
.ok_or_else(|| glossary_invalid(RPC_METHOD_GLOSSARY_DELETE, "缺少 reviewer"))?;
|
||||
let reason = glossary_string(¶ms, "reason", RPC_METHOD_GLOSSARY_DELETE)?
|
||||
.ok_or_else(|| glossary_invalid(RPC_METHOD_GLOSSARY_DELETE, "缺少 reason"))?;
|
||||
let path = glossary_rpc_path(
|
||||
state_dir,
|
||||
output_root,
|
||||
default_path,
|
||||
Some(&serde_json::Value::Object(params.clone())),
|
||||
RPC_METHOD_GLOSSARY_DELETE,
|
||||
)?;
|
||||
build_glossary_delete_report(&path, term_id, reviewer, reason)
|
||||
.map_err(|error| glossary_internal_error(RPC_METHOD_GLOSSARY_DELETE, error))
|
||||
}
|
||||
|
||||
fn glossary_rpc_path(
|
||||
state_dir: &Path,
|
||||
output_root: &Path,
|
||||
default_path: Option<&Path>,
|
||||
params: Option<&serde_json::Value>,
|
||||
method: &'static str,
|
||||
) -> Result<std::path::PathBuf, ApiError> {
|
||||
if let Some(path) = params
|
||||
.and_then(|value| value.get("glossary_path"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return lexical_absolute(Path::new(path))
|
||||
.map_err(|error| glossary_internal_error(method, anyhow::anyhow!(error)));
|
||||
}
|
||||
if let Some(path) = default_path {
|
||||
return lexical_absolute(path)
|
||||
.map_err(|error| glossary_internal_error(method, anyhow::anyhow!(error)));
|
||||
}
|
||||
let (_, version_state) = read_daemon_resource_state(state_dir)
|
||||
.map_err(|error| glossary_internal_error(method, error))?;
|
||||
if let Some(record) = version_state
|
||||
.as_ref()
|
||||
.and_then(|state| state.current_completed_version.as_ref())
|
||||
{
|
||||
return Ok(SqliteGlossaryRepository::repository_path(
|
||||
&record.resource_root,
|
||||
));
|
||||
}
|
||||
Ok(SqliteGlossaryRepository::repository_path(output_root))
|
||||
}
|
||||
|
||||
fn glossary_params(
|
||||
params: Option<&serde_json::Value>,
|
||||
method: &'static str,
|
||||
) -> Result<serde_json::Map<String, serde_json::Value>, ApiError> {
|
||||
match params {
|
||||
None | Some(serde_json::Value::Null) => Ok(serde_json::Map::new()),
|
||||
Some(serde_json::Value::Object(value)) => Ok(value.clone()),
|
||||
Some(_) => Err(glossary_invalid(method, "params 必须是 JSON object")),
|
||||
}
|
||||
}
|
||||
|
||||
fn glossary_string<'a>(
|
||||
params: &'a serde_json::Map<String, serde_json::Value>,
|
||||
key: &str,
|
||||
method: &'static str,
|
||||
) -> Result<Option<&'a str>, ApiError> {
|
||||
let Some(value) = params.get(key) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if value.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
value
|
||||
.as_str()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| glossary_invalid(method, format!("{key} 必须是非空字符串")))
|
||||
.map(Some)
|
||||
}
|
||||
|
||||
fn glossary_context(
|
||||
params: &serde_json::Map<String, serde_json::Value>,
|
||||
method: &'static str,
|
||||
) -> Result<TranslationMemoryContext, ApiError> {
|
||||
let Some(value) = params
|
||||
.get("context")
|
||||
.or_else(|| params.get("source_context"))
|
||||
else {
|
||||
return Ok(BTreeMap::new());
|
||||
};
|
||||
serde_json::from_value(value.clone()).map_err(|error| {
|
||||
glossary_invalid(method, format!("context 必须是 JSON string map:{error}"))
|
||||
})
|
||||
}
|
||||
|
||||
fn glossary_limit(
|
||||
params: &serde_json::Map<String, serde_json::Value>,
|
||||
method: &'static str,
|
||||
) -> Result<usize, ApiError> {
|
||||
let limit = params
|
||||
.get("limit")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.unwrap_or(100);
|
||||
let limit = usize::try_from(limit)
|
||||
.map_err(|error| glossary_invalid(method, format!("limit 无效:{error}")))?;
|
||||
if !(1..=1000).contains(&limit) {
|
||||
return Err(glossary_invalid(method, "limit 必须在 1..=1000 范围内"));
|
||||
}
|
||||
Ok(limit)
|
||||
}
|
||||
|
||||
fn glossary_invalid(method: &'static str, message: impl Into<String>) -> ApiError {
|
||||
ApiError::new(ErrorCode::RPC_INVALID_PARAMS, method, message.into())
|
||||
}
|
||||
|
||||
fn glossary_internal_error(method: &'static str, error: anyhow::Error) -> ApiError {
|
||||
ApiError::new(ErrorCode::INTERNAL, method, error.to_string())
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
use super::report_output::print_report;
|
||||
|
||||
pub(super) fn run_write_patch_command(options: &CliOptions) -> anyhow::Result<()> {
|
||||
match options.command {
|
||||
CliCommand::PatchApply => {
|
||||
let params = patch_apply_params_from_options(options)?;
|
||||
let report = apply_patch_file(¶ms)?;
|
||||
print_report(options.output_format, &report)
|
||||
}
|
||||
CliCommand::UnityFsPatchTextAsset => {
|
||||
let params = unityfs_text_asset_params_from_options(options)?;
|
||||
let report = apply_unityfs_text_asset_patch_file(¶ms)?;
|
||||
print_report(options.output_format, &report)
|
||||
}
|
||||
CliCommand::UnityFsPatchStringField => {
|
||||
let params = unityfs_string_field_params_from_options(options)?;
|
||||
let report = apply_unityfs_string_field_patch_file(¶ms)?;
|
||||
print_report(options.output_format, &report)
|
||||
}
|
||||
CliCommand::UnityFsPatchField => {
|
||||
let params = unityfs_field_params_from_options(options)?;
|
||||
let report = apply_unityfs_field_patch_file(¶ms)?;
|
||||
print_report(options.output_format, &report)
|
||||
}
|
||||
_ => Err(anyhow::anyhow!("不是写入 patch 命令")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn is_write_patch_command(command: CliCommand) -> bool {
|
||||
matches!(
|
||||
command,
|
||||
CliCommand::PatchApply
|
||||
| CliCommand::UnityFsPatchTextAsset
|
||||
| CliCommand::UnityFsPatchStringField
|
||||
| CliCommand::UnityFsPatchField
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn validate_write_patch_options(options: &CliOptions) -> anyhow::Result<()> {
|
||||
match options.command {
|
||||
CliCommand::PatchApply => {
|
||||
let _ = patch_apply_params_from_options(options)?;
|
||||
reject_unityfs_write_options(options, "patch-apply")?;
|
||||
}
|
||||
CliCommand::UnityFsPatchTextAsset => {
|
||||
let _ = unityfs_text_asset_params_from_options(options)?;
|
||||
reject_patch_apply_options(options, "unityfs-patch-text-asset")?;
|
||||
if options.unityfs_field_path.is_some()
|
||||
|| options.unityfs_replacement_text.is_some()
|
||||
|| options.unityfs_expected_value.is_some()
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"unityfs-patch-text-asset 不接受 --field-path、--string-field-path、--replacement-text 或 --expected-value"
|
||||
));
|
||||
}
|
||||
}
|
||||
CliCommand::UnityFsPatchStringField => {
|
||||
let _ = unityfs_string_field_params_from_options(options)?;
|
||||
reject_patch_apply_options(options, "unityfs-patch-string-field")?;
|
||||
if options.unityfs_expected_name.is_some() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"unityfs-patch-string-field 不接受 --expected-name"
|
||||
));
|
||||
}
|
||||
}
|
||||
CliCommand::UnityFsPatchField => {
|
||||
let _ = unityfs_field_params_from_options(options)?;
|
||||
reject_patch_apply_options(options, "unityfs-patch-field")?;
|
||||
if options.unityfs_expected_name.is_some()
|
||||
|| options.unityfs_replacement_text.is_some()
|
||||
|| options.unityfs_expected_value.is_some()
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"unityfs-patch-field 不接受 --expected-name、--replacement-text 或 --expected-value;请使用 --replacement-json / --expected-json"
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn patch_apply_params_from_options(options: &CliOptions) -> anyhow::Result<PatchApplyParams> {
|
||||
Ok(PatchApplyParams {
|
||||
kind: require_cli_option(options.patch_kind, "--patch-kind")?,
|
||||
source_path: require_cli_option(options.patch_source_path.clone(), "--source-file")?,
|
||||
patch_path: require_cli_option(options.patch_patch_path.clone(), "--patch-file")?,
|
||||
target_path: require_cli_option(options.patch_target_path.clone(), "--target-file")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn unityfs_text_asset_params_from_options(
|
||||
options: &CliOptions,
|
||||
) -> anyhow::Result<UnityFsTextAssetPatchParams> {
|
||||
Ok(UnityFsTextAssetPatchParams {
|
||||
bundle_path: require_cli_option(options.unityfs_bundle_path.clone(), "--bundle-file")?,
|
||||
serialized_file_path: require_cli_option(
|
||||
options.unityfs_serialized_file_path.clone(),
|
||||
"--serialized-file",
|
||||
)?,
|
||||
path_id: require_cli_option(options.unityfs_path_id, "--object-path-id")?,
|
||||
replacement_path: require_cli_option(
|
||||
options.unityfs_replacement_path.clone(),
|
||||
"--replacement-file",
|
||||
)?,
|
||||
target_path: require_cli_option(options.patch_target_path.clone(), "--target-file")?,
|
||||
expected_name: options.unityfs_expected_name.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn unityfs_string_field_params_from_options(
|
||||
options: &CliOptions,
|
||||
) -> anyhow::Result<UnityFsStringFieldPatchParams> {
|
||||
let has_replacement_text = options.unityfs_replacement_text.is_some();
|
||||
let has_replacement_path = options.unityfs_replacement_path.is_some();
|
||||
if has_replacement_text == has_replacement_path {
|
||||
return Err(anyhow::anyhow!(
|
||||
"unityfs-patch-string-field 必须且只能指定 --replacement-text 或 --replacement-file 其中一个"
|
||||
));
|
||||
}
|
||||
Ok(UnityFsStringFieldPatchParams {
|
||||
bundle_path: require_cli_option(options.unityfs_bundle_path.clone(), "--bundle-file")?,
|
||||
serialized_file_path: require_cli_option(
|
||||
options.unityfs_serialized_file_path.clone(),
|
||||
"--serialized-file",
|
||||
)?,
|
||||
path_id: require_cli_option(options.unityfs_path_id, "--object-path-id")?,
|
||||
field_path: require_cli_option(
|
||||
options.unityfs_field_path.clone(),
|
||||
"--field-path/--string-field-path",
|
||||
)?,
|
||||
replacement_text: options.unityfs_replacement_text.clone(),
|
||||
replacement_path: options.unityfs_replacement_path.clone(),
|
||||
target_path: require_cli_option(options.patch_target_path.clone(), "--target-file")?,
|
||||
expected_value: options.unityfs_expected_value.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn unityfs_field_params_from_options(
|
||||
options: &CliOptions,
|
||||
) -> anyhow::Result<UnityFsFieldPatchParams> {
|
||||
if options.unityfs_replacement_path.is_some() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"unityfs-patch-field 不接受 --replacement-file;请使用 --replacement-json"
|
||||
));
|
||||
}
|
||||
Ok(UnityFsFieldPatchParams {
|
||||
bundle_path: require_cli_option(options.unityfs_bundle_path.clone(), "--bundle-file")?,
|
||||
serialized_file_path: require_cli_option(
|
||||
options.unityfs_serialized_file_path.clone(),
|
||||
"--serialized-file",
|
||||
)?,
|
||||
path_id: require_cli_option(options.unityfs_path_id, "--object-path-id")?,
|
||||
field_path: require_cli_option(
|
||||
options.unityfs_field_path.clone(),
|
||||
"--field-path/--string-field-path",
|
||||
)?,
|
||||
replacement: require_cli_option(
|
||||
options.unityfs_replacement_value.clone(),
|
||||
"--replacement-json",
|
||||
)?,
|
||||
target_path: require_cli_option(options.patch_target_path.clone(), "--target-file")?,
|
||||
expected_value: options.unityfs_expected_semantic_value.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn require_cli_option<T>(value: Option<T>, name: &str) -> anyhow::Result<T> {
|
||||
value.ok_or_else(|| anyhow::anyhow!("缺少必要参数 {name}"))
|
||||
}
|
||||
|
||||
fn reject_patch_apply_options(options: &CliOptions, command: &str) -> anyhow::Result<()> {
|
||||
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"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reject_unityfs_write_options(options: &CliOptions, command: &str) -> anyhow::Result<()> {
|
||||
if options.unityfs_bundle_path.is_some()
|
||||
|| options.unityfs_serialized_file_path.is_some()
|
||||
|| options.unityfs_path_id.is_some()
|
||||
|| options.unityfs_field_path.is_some()
|
||||
|| options.unityfs_replacement_path.is_some()
|
||||
|| options.unityfs_replacement_text.is_some()
|
||||
|| options.unityfs_expected_name.is_some()
|
||||
|| 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-* 命令"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
use super::*;
|
||||
@@ -0,0 +1,272 @@
|
||||
use super::report_output::print_json_value;
|
||||
use super::*;
|
||||
|
||||
pub(super) fn run_readonly_query_command(options: &CliOptions) -> anyhow::Result<()> {
|
||||
run_readonly_query_command_with_rpc(options, daemon_rpc_available, daemon_rpc_call)
|
||||
}
|
||||
|
||||
pub(super) fn run_readonly_query_command_with_rpc(
|
||||
options: &CliOptions,
|
||||
rpc_available: impl Fn(&Path) -> bool,
|
||||
rpc_call: impl Fn(&Path, &str, Option<serde_json::Value>) -> anyhow::Result<serde_json::Value>,
|
||||
) -> anyhow::Result<()> {
|
||||
let method = readonly_query_rpc_method(options.command)
|
||||
.ok_or_else(|| anyhow::anyhow!("不是只读查询命令"))?;
|
||||
if rpc_available(&options.state_dir) && !readonly_query_requires_local_config(options) {
|
||||
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
|
||||
let report = rpc_call(
|
||||
&options.state_dir,
|
||||
method,
|
||||
readonly_query_rpc_params(options),
|
||||
)?;
|
||||
print_json_value(options.output_format, &report)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let report = build_readonly_query_report(options, method)?;
|
||||
print_json_value(options.output_format, &report)
|
||||
}
|
||||
|
||||
fn readonly_query_rpc_method(command: CliCommand) -> Option<&'static str> {
|
||||
match command {
|
||||
CliCommand::ParseStatus => Some(RPC_METHOD_PARSE_STATUS),
|
||||
CliCommand::ParseTextUnits => Some(RPC_METHOD_PARSE_TEXT_UNITS),
|
||||
CliCommand::ParseErrors => Some(RPC_METHOD_PARSE_ERRORS),
|
||||
CliCommand::TranslationTasks => Some(RPC_METHOD_TRANSLATION_TASKS),
|
||||
CliCommand::TranslationHandoff => Some(RPC_METHOD_TRANSLATION_HANDOFF),
|
||||
CliCommand::LocalizedStatus => Some(RPC_METHOD_LOCALIZED_STATUS),
|
||||
CliCommand::ResourceIndex => Some(RPC_METHOD_RESOURCE_INDEX),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn readonly_query_rpc_params(options: &CliOptions) -> Option<serde_json::Value> {
|
||||
let mut params = serde_json::Map::new();
|
||||
match options.command {
|
||||
CliCommand::ResourceIndex
|
||||
| CliCommand::ParseTextUnits
|
||||
| CliCommand::ParseErrors
|
||||
| CliCommand::TranslationTasks => {
|
||||
params.insert(
|
||||
"offset".to_string(),
|
||||
serde_json::json!(options.query_offset),
|
||||
);
|
||||
params.insert("limit".to_string(), serde_json::json!(options.query_limit));
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
match options.command {
|
||||
CliCommand::ResourceIndex => {
|
||||
if let Some(resource_type) = options.query_resource_type {
|
||||
params.insert(
|
||||
"resource_type".to_string(),
|
||||
serde_json::json!(resource_type_rpc_label(resource_type)),
|
||||
);
|
||||
}
|
||||
if let Some(hash) = options.query_hash.as_ref() {
|
||||
params.insert("hash".to_string(), serde_json::json!(hash));
|
||||
}
|
||||
if let Some(path_pattern) = options.query_path_pattern.as_ref() {
|
||||
params.insert("path_pattern".to_string(), serde_json::json!(path_pattern));
|
||||
}
|
||||
if let Some(release_id) = options.query_official_release_id.as_ref() {
|
||||
params.insert(
|
||||
"official_release_id".to_string(),
|
||||
serde_json::json!(release_id),
|
||||
);
|
||||
}
|
||||
if let Some(platform) = options.query_platform.as_ref() {
|
||||
params.insert("platform".to_string(), serde_json::json!(platform));
|
||||
}
|
||||
if let Some(destination) = options.query_destination.as_ref() {
|
||||
params.insert("destination".to_string(), serde_json::json!(destination));
|
||||
}
|
||||
if let Some(bundle_path) = options.query_bundle_path.as_ref() {
|
||||
params.insert("bundle_path".to_string(), serde_json::json!(bundle_path));
|
||||
}
|
||||
if let Some(archive_entry) = options.query_archive_entry.as_ref() {
|
||||
params.insert(
|
||||
"archive_entry".to_string(),
|
||||
serde_json::json!(archive_entry),
|
||||
);
|
||||
}
|
||||
if let Some(parse_status) = options.query_parse_status.as_ref() {
|
||||
params.insert("parse_status".to_string(), serde_json::json!(parse_status));
|
||||
}
|
||||
if let Some(format) = options.query_format.as_ref() {
|
||||
params.insert("text_unit_format".to_string(), serde_json::json!(format));
|
||||
}
|
||||
}
|
||||
CliCommand::ParseTextUnits | CliCommand::ParseErrors => {
|
||||
if let Some(destination) = options.query_destination.as_ref() {
|
||||
params.insert("destination".to_string(), serde_json::json!(destination));
|
||||
}
|
||||
if let Some(path_pattern) = options.query_path_pattern.as_ref() {
|
||||
params.insert("path_pattern".to_string(), serde_json::json!(path_pattern));
|
||||
}
|
||||
if let Some(archive_entry) = options.query_archive_entry.as_ref() {
|
||||
params.insert(
|
||||
"archive_entry".to_string(),
|
||||
serde_json::json!(archive_entry),
|
||||
);
|
||||
}
|
||||
if let Some(path_id) = options.query_path_id {
|
||||
params.insert("path_id".to_string(), serde_json::json!(path_id));
|
||||
}
|
||||
if let Some(class_id) = options.query_class_id {
|
||||
params.insert("class_id".to_string(), serde_json::json!(class_id));
|
||||
}
|
||||
if let Some(field_path) = options.query_field_path.as_ref() {
|
||||
params.insert("field_path".to_string(), serde_json::json!(field_path));
|
||||
}
|
||||
if let Some(format) = options.query_format.as_ref() {
|
||||
params.insert("format".to_string(), serde_json::json!(format));
|
||||
}
|
||||
}
|
||||
CliCommand::TranslationTasks => {
|
||||
if let Some(task_id) = options.query_task_id.as_ref() {
|
||||
params.insert("task_id".to_string(), serde_json::json!(task_id));
|
||||
}
|
||||
if let Some(release_id) = options.query_official_release_id.as_ref() {
|
||||
params.insert(
|
||||
"official_release_id".to_string(),
|
||||
serde_json::json!(release_id),
|
||||
);
|
||||
}
|
||||
if let Some(destination) = options.query_destination.as_ref() {
|
||||
params.insert("destination".to_string(), serde_json::json!(destination));
|
||||
}
|
||||
if let Some(path_pattern) = options.query_path_pattern.as_ref() {
|
||||
params.insert("path_pattern".to_string(), serde_json::json!(path_pattern));
|
||||
}
|
||||
if let Some(archive_entry) = options.query_archive_entry.as_ref() {
|
||||
params.insert(
|
||||
"archive_entry".to_string(),
|
||||
serde_json::json!(archive_entry),
|
||||
);
|
||||
}
|
||||
if let Some(status) = options.query_task_status.as_ref() {
|
||||
params.insert("status".to_string(), serde_json::json!(status));
|
||||
}
|
||||
if let Some(status) = options.query_worker_status.as_ref() {
|
||||
params.insert("worker_status".to_string(), serde_json::json!(status));
|
||||
}
|
||||
if let Some(parse_status) = options.query_parse_status.as_ref() {
|
||||
params.insert("parse_status".to_string(), serde_json::json!(parse_status));
|
||||
}
|
||||
if let Some(format) = options.query_format.as_ref() {
|
||||
params.insert("text_unit_format".to_string(), serde_json::json!(format));
|
||||
}
|
||||
if let Some(has_reason) = options.query_has_reason {
|
||||
params.insert("has_reason".to_string(), serde_json::json!(has_reason));
|
||||
}
|
||||
if let Some(has_failure_reason) = options.query_has_failure_reason {
|
||||
params.insert(
|
||||
"has_failure_reason".to_string(),
|
||||
serde_json::json!(has_failure_reason),
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Some(serde_json::Value::Object(params))
|
||||
}
|
||||
|
||||
fn readonly_query_requires_local_config(options: &CliOptions) -> bool {
|
||||
matches!(options.command, CliCommand::ResourceIndex)
|
||||
&& options.config.import_resource_repository_path.is_some()
|
||||
}
|
||||
|
||||
fn build_readonly_query_report(
|
||||
options: &CliOptions,
|
||||
method: &str,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
match method {
|
||||
RPC_METHOD_PARSE_STATUS => build_parse_status_report(&options.state_dir),
|
||||
RPC_METHOD_PARSE_TEXT_UNITS => build_parse_text_units_report(
|
||||
&options.state_dir,
|
||||
textunit_query_from_options(options),
|
||||
options.query_offset,
|
||||
options.query_limit,
|
||||
),
|
||||
RPC_METHOD_PARSE_ERRORS => build_parse_errors_report(
|
||||
&options.state_dir,
|
||||
textunit_query_from_options(options),
|
||||
options.query_offset,
|
||||
options.query_limit,
|
||||
),
|
||||
RPC_METHOD_TRANSLATION_TASKS => build_translation_tasks_report(
|
||||
&options.state_dir,
|
||||
translation_task_query_from_options(options),
|
||||
options.query_offset,
|
||||
options.query_limit,
|
||||
),
|
||||
RPC_METHOD_TRANSLATION_HANDOFF => build_translation_handoff_report(&options.state_dir),
|
||||
RPC_METHOD_LOCALIZED_STATUS => {
|
||||
build_localized_status_report(&options.state_dir, &options.config)
|
||||
}
|
||||
RPC_METHOD_RESOURCE_INDEX => build_resource_index_report(
|
||||
&options.state_dir,
|
||||
&options.config,
|
||||
resource_index_query_from_options(options),
|
||||
options.query_offset,
|
||||
options.query_limit,
|
||||
),
|
||||
_ => Err(anyhow::anyhow!("不支持的只读查询方法:{method}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn validate_readonly_query_options(options: &CliOptions) -> anyhow::Result<()> {
|
||||
let has_resource_index_only_filter = options.query_resource_type.is_some()
|
||||
|| options.query_hash.is_some()
|
||||
|| options.query_platform.is_some()
|
||||
|| options.query_bundle_path.is_some();
|
||||
let has_parse_object_filter = options.query_path_id.is_some()
|
||||
|| options.query_class_id.is_some()
|
||||
|| options.query_field_path.is_some();
|
||||
let has_translation_task_filter = options.query_task_id.is_some()
|
||||
|| options.query_task_status.is_some()
|
||||
|| options.query_worker_status.is_some()
|
||||
|| options.query_has_reason.is_some()
|
||||
|| options.query_has_failure_reason.is_some();
|
||||
|
||||
match options.command {
|
||||
CliCommand::ResourceIndex => {
|
||||
if has_parse_object_filter || has_translation_task_filter {
|
||||
return Err(anyhow::anyhow!(
|
||||
"--path-id/--class-id/--field-path 只适用于 parse-text-units 或 parse-errors;--task-id/--task-status/--worker-status/--has-reason/--has-failure-reason 只适用于 translation-tasks"
|
||||
));
|
||||
}
|
||||
}
|
||||
CliCommand::ParseTextUnits | CliCommand::ParseErrors => {
|
||||
if has_resource_index_only_filter
|
||||
|| has_translation_task_filter
|
||||
|| options.query_official_release_id.is_some()
|
||||
|| options.query_parse_status.is_some()
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"--resource-type/--hash/--release-id/--platform/--bundle-path/--parse-status 只适用于 resource-index 或 translation-tasks;--task-id/--task-status/--worker-status/--has-reason/--has-failure-reason 只适用于 translation-tasks"
|
||||
));
|
||||
}
|
||||
}
|
||||
CliCommand::TranslationTasks => {
|
||||
if has_resource_index_only_filter || has_parse_object_filter {
|
||||
return Err(anyhow::anyhow!(
|
||||
"--resource-type/--hash/--platform/--bundle-path 只适用于 resource-index;--path-id/--class-id/--field-path 只适用于 parse-text-units 或 parse-errors"
|
||||
));
|
||||
}
|
||||
}
|
||||
CliCommand::TranslationHandoff if options.query_option_explicit => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"translation-handoff 不接受查询过滤参数;请使用 translation-tasks 查询单项任务"
|
||||
));
|
||||
}
|
||||
CliCommand::ParseStatus | CliCommand::LocalizedStatus if options.query_option_explicit => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"查询过滤参数只适用于 resource-index、parse-text-units、parse-errors 或 translation-tasks"
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,863 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) trait HumanReport {
|
||||
fn print_human(&self) -> anyhow::Result<()>;
|
||||
}
|
||||
|
||||
pub(super) fn print_report<T>(format: OutputFormat, report: &T) -> anyhow::Result<()>
|
||||
where
|
||||
T: Serialize + HumanReport,
|
||||
{
|
||||
match format {
|
||||
OutputFormat::Json => println!("{}", serde_json::to_string_pretty(report)?),
|
||||
OutputFormat::Human => report.print_human()?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn print_json_value(
|
||||
format: OutputFormat,
|
||||
value: &serde_json::Value,
|
||||
) -> anyhow::Result<()> {
|
||||
match format {
|
||||
OutputFormat::Json => println!("{}", serde_json::to_string_pretty(value)?),
|
||||
OutputFormat::Human => print_human_json_value(value)?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl HumanReport for serde_json::Value {
|
||||
fn print_human(&self) -> anyhow::Result<()> {
|
||||
print_human_json_value(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl HumanReport for RepackReport {
|
||||
fn print_human(&self) -> anyhow::Result<()> {
|
||||
print_title("UnityFS 重打包");
|
||||
print_field("命令", self.command);
|
||||
print_field("状态", self.status);
|
||||
print_path_field("源 bundle", &self.source_bundle);
|
||||
print_path_field("目标 bundle", &self.target_bundle);
|
||||
print_field("操作数", self.operation_count);
|
||||
print_field("源字节", self.source_bytes);
|
||||
print_field("目标字节", self.target_bytes);
|
||||
print_field("源 BLAKE3", &self.source_blake3);
|
||||
print_field("目标 BLAKE3", &self.target_blake3);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl HumanReport for LocalizedPatchReport {
|
||||
fn print_human(&self) -> anyhow::Result<()> {
|
||||
print_title("汉化 release 发布");
|
||||
print_path_field("版本目录", &self.version_path);
|
||||
print_path_field("current", &self.current_path);
|
||||
print_path_field("状态文件", &self.state_path);
|
||||
print_path_field("patch manifest", &self.patch_manifest_path);
|
||||
print_field("变更文件数", self.files.len());
|
||||
print_field("TextAsset 操作数", self.manifest.text_asset_operation_count);
|
||||
print_field("校验文件数", self.integrity.verified_changed_file_count);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
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("汉化工作流状态");
|
||||
print_field("命令", self.command);
|
||||
print_field("状态", self.status);
|
||||
print_field("官方 release", &self.official_release_id);
|
||||
print_optional_field("汉化 release", self.current_release_id.as_deref());
|
||||
print_field("汉化发布状态", &self.localized_release_status);
|
||||
print_field("工作流状态", &self.translation_workflow_status);
|
||||
print_field("工作流状态码", self.translation_workflow_status_code);
|
||||
print_field("工作流标签", self.translation_workflow_label);
|
||||
print_field("允许发布", format_bool(self.publish_allowed));
|
||||
print_path_field("汉化输出目录", &self.localized_output_root);
|
||||
print_path_field("状态文件", &self.state_path);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl HumanReport for bat_infrastructure::TranslationWorkerReport {
|
||||
fn print_human(&self) -> anyhow::Result<()> {
|
||||
print_title("翻译 provider worker");
|
||||
print_field("命令", self.command);
|
||||
print_field("状态", self.status);
|
||||
print_field("官方 release", &self.official_release_id);
|
||||
print_field("provider", &self.provider);
|
||||
print_field("回收 lease", self.recovered_lease_count);
|
||||
print_field("领取任务", self.claimed_count);
|
||||
print_field("完成任务", self.completed_count);
|
||||
print_field("失败任务", self.failed_count);
|
||||
print_field("已安排重试", self.retry_scheduled_count);
|
||||
print_field("剩余任务", self.remaining_count);
|
||||
print_path_field("Translation Memory", &self.translation_memory_path);
|
||||
print_field("TM 可用", format_bool(self.translation_memory_available));
|
||||
print_field("TM 命中 TextUnit", self.translation_memory_hit_count);
|
||||
print_field("Provider TextUnit", self.provider_unit_count);
|
||||
print_path_field("Glossary", &self.glossary_path);
|
||||
print_field("Glossary 可用", format_bool(self.glossary_available));
|
||||
print_field("Glossary blocking TextUnit", self.glossary_blocked_count);
|
||||
for failure in &self.translation_memory_failures {
|
||||
println!(" - TM: {failure}");
|
||||
}
|
||||
for failure in &self.glossary_failures {
|
||||
println!(" - Glossary: {failure}");
|
||||
}
|
||||
for failure in &self.failures {
|
||||
println!(
|
||||
" - {} [{}] retryable={} {}",
|
||||
failure.task_id,
|
||||
failure.failure_class,
|
||||
format_bool(failure.retryable),
|
||||
failure.failure_reason
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn print_human_json_value(value: &serde_json::Value) -> anyhow::Result<()> {
|
||||
if value.get("running").is_some() && value.get("state_dir").is_some() {
|
||||
print_title("后台状态");
|
||||
print_json_field(value, "status", "状态");
|
||||
print_json_field(value, "message", "消息");
|
||||
print_json_field(value, "running", "运行中");
|
||||
print_json_field(value, "pid", "PID");
|
||||
print_json_field(value, "daemon_state", "后台状态");
|
||||
print_json_field(value, "rpc_available", "RPC 可用");
|
||||
print_json_field(value, "stale_pid_file", "失效 PID");
|
||||
print_json_field(value, "stale_socket", "失效 socket");
|
||||
print_json_field(value, "last_update_status", "上次同步");
|
||||
print_json_field(value, "last_success_unix_seconds", "最后成功时间");
|
||||
print_json_field(value, "last_error", "上次错误");
|
||||
print_json_field(value, "next_retry_seconds", "下次重试秒数");
|
||||
print_json_field(value, "next_check_unix_seconds", "下次检查时间");
|
||||
print_json_field(value, "current_stage", "当前阶段");
|
||||
print_json_field(value, "current_message", "当前消息");
|
||||
print_daemon_download_progress_json_summary(value.get("download_progress"));
|
||||
print_json_field(value, "version_state_path", "版本状态路径");
|
||||
print_daemon_version_state_json_summary(value.get("version_state"));
|
||||
print_json_field(value, "resource_output_root", "资源目录");
|
||||
print_json_field(value, "state_dir", "状态目录");
|
||||
print_json_field(value, "socket_path", "socket");
|
||||
print_json_field(value, "log_path", "日志");
|
||||
print_json_field(value, "structured_log_path", "结构化日志");
|
||||
print_json_field(value, "rotated_structured_log_paths", "轮转日志");
|
||||
return Ok(());
|
||||
}
|
||||
if value.get("command").and_then(serde_json::Value::as_str) == Some("logs") {
|
||||
print_title("后台日志");
|
||||
print_json_field(value, "status", "状态");
|
||||
print_json_field(value, "message", "消息");
|
||||
print_json_field(value, "log_path", "日志");
|
||||
print_json_field(value, "bytes", "字节");
|
||||
print_json_field(value, "total_lines", "总行数");
|
||||
print_json_field(value, "returned_lines", "返回行数");
|
||||
if let Some(content) = value.get("content").and_then(serde_json::Value::as_str) {
|
||||
if !content.is_empty() {
|
||||
println!();
|
||||
println!("{content}");
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if value.get("command").is_some() && value.get("status").is_some() {
|
||||
print_title("后台命令");
|
||||
print_json_field(value, "command", "命令");
|
||||
print_json_field(value, "status", "状态");
|
||||
print_json_field(value, "message", "消息");
|
||||
print_json_field(value, "force", "force");
|
||||
print_json_field(value, "state_dir", "状态目录");
|
||||
print_json_field(value, "socket_path", "socket");
|
||||
return Ok(());
|
||||
}
|
||||
println!("{}", serde_json::to_string_pretty(value)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_daemon_download_progress_json_summary(value: Option<&serde_json::Value>) {
|
||||
let Some(value) = value else {
|
||||
return;
|
||||
};
|
||||
if value.is_null() {
|
||||
return;
|
||||
}
|
||||
print_field(
|
||||
"下载进度",
|
||||
format_daemon_download_progress_json(value)
|
||||
.unwrap_or_else(|error| format!("无法解析:{error}")),
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn format_daemon_download_progress_json(
|
||||
value: &serde_json::Value,
|
||||
) -> Result<String, String> {
|
||||
let progress = serde_json::from_value::<DaemonDownloadProgress>(value.clone())
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(format_daemon_download_progress(&progress))
|
||||
}
|
||||
|
||||
fn print_daemon_version_state_json_summary(value: Option<&serde_json::Value>) {
|
||||
let Some(value) = value else {
|
||||
return;
|
||||
};
|
||||
if value.is_null() {
|
||||
return;
|
||||
}
|
||||
match serde_json::from_value::<OfficialVersionState>(value.clone()) {
|
||||
Ok(version_state) => print_daemon_version_state_summary(&version_state),
|
||||
Err(error) => print_field("版本状态", format!("无法解析:{error}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn print_daemon_version_state_summary(version_state: &OfficialVersionState) {
|
||||
print_optional_field(
|
||||
"当前完成版本",
|
||||
version_state
|
||||
.current_completed_version
|
||||
.as_ref()
|
||||
.map(|version| version.id.as_str()),
|
||||
);
|
||||
print_optional_field(
|
||||
"正在拉取版本",
|
||||
version_state
|
||||
.in_progress_version
|
||||
.as_ref()
|
||||
.map(|version| version.id.as_str()),
|
||||
);
|
||||
print_optional_field(
|
||||
"上一个可用版本",
|
||||
version_state
|
||||
.previous_available_version
|
||||
.as_ref()
|
||||
.map(|version| version.id.as_str()),
|
||||
);
|
||||
let historical_failures = visible_historical_failed_versions(version_state);
|
||||
print_field("历史失败版本数", historical_failures.len());
|
||||
if let Some(failed) = historical_failures.last() {
|
||||
print_field("最近历史失败版本", &failed.version.id);
|
||||
print_field("最近历史失败时间", failed.failed_unix_seconds);
|
||||
print_field("最近历史失败原因", &failed.error);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn visible_historical_failed_versions(
|
||||
version_state: &OfficialVersionState,
|
||||
) -> Vec<&OfficialFailedVersionRecord> {
|
||||
let in_progress = version_state.in_progress_version.as_ref();
|
||||
version_state
|
||||
.failed_versions
|
||||
.iter()
|
||||
.filter(|failed| {
|
||||
!in_progress.is_some_and(|version| version_matches_for_status(&failed.version, version))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn version_matches_for_status(left: &OfficialVersionRecord, right: &OfficialVersionRecord) -> bool {
|
||||
left.app_version == right.app_version
|
||||
&& left.bundle_version == right.bundle_version
|
||||
&& left.addressables_root == right.addressables_root
|
||||
}
|
||||
|
||||
fn print_json_field(value: &serde_json::Value, key: &str, label: &str) {
|
||||
let Some(value) = value.get(key) else {
|
||||
return;
|
||||
};
|
||||
if value.is_null() {
|
||||
return;
|
||||
}
|
||||
if let Some(value) = value.as_str() {
|
||||
print_field(label, value);
|
||||
} else {
|
||||
print_field(label, value);
|
||||
}
|
||||
}
|
||||
|
||||
fn print_title(title: &str) {
|
||||
println!("{title}");
|
||||
}
|
||||
|
||||
fn print_field(label: &str, value: impl std::fmt::Display) {
|
||||
println!(" {label:<18} {value}");
|
||||
}
|
||||
|
||||
fn print_optional_field<T>(label: &str, value: Option<T>)
|
||||
where
|
||||
T: std::fmt::Display,
|
||||
{
|
||||
if let Some(value) = value {
|
||||
print_field(label, value);
|
||||
}
|
||||
}
|
||||
|
||||
fn print_path_field(label: &str, value: &Path) {
|
||||
print_field(label, value.display());
|
||||
}
|
||||
|
||||
fn print_optional_path_field(label: &str, value: Option<&PathBuf>) {
|
||||
if let Some(value) = value {
|
||||
print_path_field(label, value);
|
||||
}
|
||||
}
|
||||
|
||||
fn print_list(label: &str, values: &[String], limit: usize) {
|
||||
if values.is_empty() {
|
||||
return;
|
||||
}
|
||||
println!(" {label}:");
|
||||
for value in values.iter().take(limit) {
|
||||
println!(" - {value}");
|
||||
}
|
||||
if values.len() > limit {
|
||||
println!(" ... 还有 {} 项", values.len() - limit);
|
||||
}
|
||||
}
|
||||
|
||||
fn format_daemon_download_progress(progress: &DaemonDownloadProgress) -> String {
|
||||
let status = progress.status.as_deref().unwrap_or("running");
|
||||
if let Some(hash) = progress.official_hash.as_ref() {
|
||||
return format!(
|
||||
"{}/{} official_hash algorithm={} expected={} actual={} data={} hash={}",
|
||||
progress.index,
|
||||
progress.total,
|
||||
hash.algorithm.as_str(),
|
||||
hash.expected,
|
||||
hash.actual,
|
||||
hash.data_url,
|
||||
hash.hash_url
|
||||
);
|
||||
}
|
||||
if status == "failed" {
|
||||
return format!(
|
||||
"{}/{} failed kind={} http={} retryable={} attempts={} quarantined={} {}",
|
||||
progress.index,
|
||||
progress.total,
|
||||
progress.failure_kind.as_deref().unwrap_or("unknown"),
|
||||
progress
|
||||
.failure_http_status
|
||||
.map(|status| status.to_string())
|
||||
.unwrap_or_else(|| "none".to_string()),
|
||||
progress
|
||||
.failure_retryable
|
||||
.map(|retryable| retryable.to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string()),
|
||||
progress
|
||||
.failure_attempts
|
||||
.map(|attempts| attempts.to_string())
|
||||
.unwrap_or_else(|| "0".to_string()),
|
||||
progress
|
||||
.quarantined
|
||||
.map(|quarantined| quarantined.to_string())
|
||||
.unwrap_or_else(|| "false".to_string()),
|
||||
progress.url
|
||||
);
|
||||
}
|
||||
if let Some(verification) = progress.verification.as_ref() {
|
||||
return format!(
|
||||
"{}/{} {} bytes={} blake3={} zip_checked={} zip_verified={} {}",
|
||||
progress.index,
|
||||
progress.total,
|
||||
status,
|
||||
verification.actual_bytes,
|
||||
verification.actual_blake3,
|
||||
verification.zip_checked,
|
||||
verification.zip_structure_verified,
|
||||
progress.url
|
||||
);
|
||||
}
|
||||
format!(
|
||||
"{}/{} {} {}",
|
||||
progress.index, progress.total, status, progress.url
|
||||
)
|
||||
}
|
||||
|
||||
fn print_verification_summary(summary: &OfficialVerificationSummary) {
|
||||
println!(" 校验摘要:");
|
||||
for line in verification_summary_lines(summary) {
|
||||
println!(" - {line}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn verification_summary_lines(summary: &OfficialVerificationSummary) -> Vec<String> {
|
||||
vec![
|
||||
format!(
|
||||
"官方 .hash 强校验: {} 对 ({})",
|
||||
summary.official_hash_verified_count, summary.official_hash_scope
|
||||
),
|
||||
format!(
|
||||
"本地 BLAKE3 复用校验: {} 项通过, {} 项需修复 ({})",
|
||||
summary.local_manifest_blake3_verified_count,
|
||||
summary.local_manifest_repair_needed_count,
|
||||
summary.local_manifest_blake3_scope
|
||||
),
|
||||
format!(
|
||||
"ZIP 结构校验: {} 个 ZIP 通过 ({})",
|
||||
summary.zip_structure_verified_count, summary.zip_structure_scope
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn format_bool(value: bool) -> &'static str {
|
||||
if value {
|
||||
"yes"
|
||||
} else {
|
||||
"no"
|
||||
}
|
||||
}
|
||||
|
||||
fn format_bytes(value: u64) -> String {
|
||||
const KIB: f64 = 1024.0;
|
||||
const MIB: f64 = 1024.0 * 1024.0;
|
||||
const GIB: f64 = 1024.0 * 1024.0 * 1024.0;
|
||||
let value_f = value as f64;
|
||||
if value_f >= GIB {
|
||||
format!("{value_f:.2} GiB", value_f = value_f / GIB)
|
||||
} else if value_f >= MIB {
|
||||
format!("{value_f:.2} MiB", value_f = value_f / MIB)
|
||||
} else if value_f >= KIB {
|
||||
format!("{value_f:.2} KiB", value_f = value_f / KIB)
|
||||
} else {
|
||||
format!("{value} B")
|
||||
}
|
||||
}
|
||||
|
||||
fn platform_label(platform: PatchPlatform) -> &'static str {
|
||||
match platform {
|
||||
PatchPlatform::Windows => "Windows",
|
||||
PatchPlatform::Android => "Android",
|
||||
}
|
||||
}
|
||||
|
||||
fn endpoint_kind_label_for_human(kind: YostarJpResourceEndpointKind) -> &'static str {
|
||||
match kind {
|
||||
YostarJpResourceEndpointKind::TableCatalog => "table_catalog",
|
||||
YostarJpResourceEndpointKind::TableCatalogHash => "table_catalog_hash",
|
||||
YostarJpResourceEndpointKind::AddressablesCatalog => "addressables_catalog",
|
||||
YostarJpResourceEndpointKind::AddressablesCatalogHash => "addressables_catalog_hash",
|
||||
YostarJpResourceEndpointKind::BundlePackingInfo => "bundle_packing_info",
|
||||
YostarJpResourceEndpointKind::BundlePackingInfoHash => "bundle_packing_info_hash",
|
||||
YostarJpResourceEndpointKind::MediaCatalog => "media_catalog",
|
||||
YostarJpResourceEndpointKind::MediaCatalogHash => "media_catalog_hash",
|
||||
}
|
||||
}
|
||||
|
||||
impl HumanReport for OfficialUpdateReport {
|
||||
fn print_human(&self) -> anyhow::Result<()> {
|
||||
print_title("官方资源同步");
|
||||
print_field("状态", self.update_status.as_str());
|
||||
print_field("状态码", self.status_code.as_str());
|
||||
print_field("应用版本", &self.app_version);
|
||||
print_optional_field("Bundle 版本", self.bundle_version.as_deref());
|
||||
print_field("连接组", &self.connection_group);
|
||||
print_field(
|
||||
"平台",
|
||||
self.platforms
|
||||
.iter()
|
||||
.map(|platform| platform_label(*platform))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
);
|
||||
print_field("需要下载", format_bool(self.should_download));
|
||||
print_field(
|
||||
"等待官方资源",
|
||||
format_bool(self.waiting_for_official_resources),
|
||||
);
|
||||
print_field("首次同步", format_bool(self.is_initial));
|
||||
print_field("强制刷新", format_bool(self.force));
|
||||
print_field("本地审计", format_bool(self.audit_local));
|
||||
print_field("自动修复", format_bool(self.repair));
|
||||
print_field("dry-run", format_bool(self.dry_run));
|
||||
print_path_field("官方资源目录", &self.output_root);
|
||||
print_path_field("汉化输出目录", &self.localized_output_root);
|
||||
print_field("汉化发布状态", self.localized_release_status.as_str());
|
||||
print_path_field("汉化 current", &self.localized_current_path);
|
||||
print_optional_path_field(
|
||||
"汉化 published",
|
||||
self.localized_published_version_path.as_ref(),
|
||||
);
|
||||
print_path_field("active release", &self.active_resource_root);
|
||||
print_path_field("current", &self.current_path);
|
||||
print_path_field("version state", &self.version_state_path);
|
||||
print_optional_path_field("staging", self.staging_path.as_ref());
|
||||
print_optional_path_field("published", self.published_version_path.as_ref());
|
||||
print_path_field("snapshot", &self.snapshot_path);
|
||||
print_path_field("manifest", &self.download_manifest);
|
||||
print_optional_path_field("资源变更集", self.resource_change_set_path.as_ref());
|
||||
print_optional_path_field("Crowdin handoff", self.crowdin_handoff_path.as_ref());
|
||||
print_optional_path_field("解析缓存", self.parse_cache_path.as_ref());
|
||||
print_optional_path_field("TextUnit 任务队列", self.textunit_task_queue_path.as_ref());
|
||||
print_optional_path_field(
|
||||
"Crowdin TextUnit 队列",
|
||||
self.crowdin_textunit_queue_path.as_ref(),
|
||||
);
|
||||
print_optional_path_field("写入 snapshot", self.snapshot_written.as_ref());
|
||||
print_optional_path_field(
|
||||
"启动器引导产物",
|
||||
self.launcher_bootstrap_artifact_path.as_ref(),
|
||||
);
|
||||
print_optional_path_field("bootstrap cache", self.bootstrap_cache_path.as_ref());
|
||||
print_optional_field("bootstrap 命中", self.bootstrap_cache_hit.map(format_bool));
|
||||
print_optional_field("计划 URL 数", self.download_url_count);
|
||||
print_optional_field("资源数", self.resource_count);
|
||||
print_field("已下载", self.downloaded_count);
|
||||
print_field("已续传", self.resumed_count);
|
||||
print_field("当前 manifest 复用", self.skipped_count);
|
||||
print_field("历史 release 复用", self.release_reused_count);
|
||||
print_field("CAS 复用", self.cas_reused_count);
|
||||
print_field("复用量", format_bytes(self.reused_bytes));
|
||||
print_field("传输量", format_bytes(self.transferred_bytes));
|
||||
print_field("复用诊断", self.reuse_warnings.len());
|
||||
for warning in &self.reuse_warnings {
|
||||
println!(
|
||||
" - 复用回退 [{}] {} {}",
|
||||
warning.source, warning.url, warning.message
|
||||
);
|
||||
}
|
||||
print_field("最终大小", format_bytes(self.final_bytes));
|
||||
print_field("本地校验通过", self.local_manifest_verified_count);
|
||||
print_field("需修复", self.local_manifest_repair_needed_count);
|
||||
print_field("官方 hash 校验", self.official_seed_hash_verified_count);
|
||||
print_verification_summary(&self.verification_summary);
|
||||
if let Some(summary) = self.resource_change_summary.as_ref() {
|
||||
print_field("新增资源", summary.added_count);
|
||||
print_field("变更资源", summary.modified_count);
|
||||
print_field("删除资源", summary.removed_count);
|
||||
print_field("解析候选", summary.parse_candidate_count);
|
||||
print_field("Crowdin 候选", summary.translation_candidate_count);
|
||||
}
|
||||
if let Some(summary) = self.parse_summary.as_ref() {
|
||||
print_field("解析缓存条目", summary.cache_entry_count);
|
||||
print_field("解析成功 bundle", summary.parsed_bundle_count);
|
||||
print_field("解析复用", summary.skipped_unchanged_count);
|
||||
print_field("解析不支持", summary.unsupported_count);
|
||||
print_field("解析失败", summary.failed_count);
|
||||
print_field("TextAsset", summary.text_asset_count);
|
||||
print_field("TextUnit", summary.text_unit_count);
|
||||
print_field("二进制 TextAsset", summary.skipped_binary_text_asset_count);
|
||||
print_field("TextUnit 诊断", summary.text_unit_error_count);
|
||||
}
|
||||
if let Some(summary) = self.textunit_task_summary.as_ref() {
|
||||
print_field("TextUnit 资源候选", summary.resource_candidate_count);
|
||||
print_field("TextUnit 解析条目", summary.parse_entry_count);
|
||||
print_field("TextUnit 任务", summary.queued_task_count);
|
||||
print_field("增量 TextUnit", summary.text_unit_count);
|
||||
print_field("TextUnit 无解析", summary.skipped_no_parse_entry_count);
|
||||
print_field("TextUnit 无文本", summary.skipped_no_text_unit_count);
|
||||
print_field("TextUnit 解析失败", summary.skipped_parse_failed_count);
|
||||
print_field("TextUnit 不支持", summary.skipped_unsupported_count);
|
||||
}
|
||||
print_field("catalog marker", self.addressables_marker_checked_count);
|
||||
if !self.unavailable_endpoints.is_empty() {
|
||||
let unavailable = self
|
||||
.unavailable_endpoints
|
||||
.iter()
|
||||
.map(|endpoint| {
|
||||
format!(
|
||||
"{}{} kind={} http={} {}",
|
||||
endpoint_kind_label_for_human(endpoint.kind),
|
||||
endpoint
|
||||
.platform
|
||||
.map(|platform| format!(" ({})", platform_label(platform)))
|
||||
.unwrap_or_default(),
|
||||
endpoint.error_kind,
|
||||
endpoint
|
||||
.http_status
|
||||
.map(|status| status.to_string())
|
||||
.unwrap_or_else(|| "none".to_string()),
|
||||
endpoint.url
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
print_list("不可用官方 endpoint", &unavailable, 8);
|
||||
}
|
||||
print_list("变更 endpoint", &self.changed_endpoint_urls, 8);
|
||||
print_list("计划 URL", &self.download_urls, 8);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> HumanReport for CommandReport<T>
|
||||
where
|
||||
T: Serialize + HumanReport,
|
||||
{
|
||||
fn print_human(&self) -> anyhow::Result<()> {
|
||||
print_title(self.message);
|
||||
print_field("命令", self.command);
|
||||
print_field("状态", self.status);
|
||||
self.data.print_human()
|
||||
}
|
||||
}
|
||||
|
||||
impl HumanReport for PatchApplyReport {
|
||||
fn print_human(&self) -> anyhow::Result<()> {
|
||||
print_title(self.message);
|
||||
print_field("命令", self.command);
|
||||
print_field("状态", self.status);
|
||||
print_field("Patch 类型", self.kind.as_str());
|
||||
print_path_field("源文件", &self.source_path);
|
||||
print_path_field("Patch 文件", &self.patch_path);
|
||||
print_path_field("目标文件", &self.target_path);
|
||||
print_field("源字节", self.source_size);
|
||||
print_field("Patch 字节", self.patch_size);
|
||||
print_field("目标字节", self.target_size);
|
||||
print_field("源 BLAKE3", &self.source_blake3);
|
||||
print_field("Patch BLAKE3", &self.patch_blake3);
|
||||
print_field("目标 BLAKE3", &self.target_blake3);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl HumanReport for UnityFsPatchReport {
|
||||
fn print_human(&self) -> anyhow::Result<()> {
|
||||
print_title(self.message);
|
||||
print_field("命令", self.command);
|
||||
print_field("状态", self.status);
|
||||
print_path_field("源 bundle", &self.bundle_path);
|
||||
print_field("Serialized 文件", &self.serialized_file_path);
|
||||
print_field("Path ID", self.path_id);
|
||||
print_optional_field("字段路径", self.field_path.as_deref());
|
||||
print_path_field("目标 bundle", &self.target_path);
|
||||
print_field("源字节", self.source_size);
|
||||
print_field("替换字节", self.replacement_size);
|
||||
print_field("目标字节", self.target_size);
|
||||
print_field("源 BLAKE3", &self.source_blake3);
|
||||
print_field("替换 BLAKE3", &self.replacement_blake3);
|
||||
print_field("目标 BLAKE3", &self.target_blake3);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl HumanReport for DaemonStartReport {
|
||||
fn print_human(&self) -> anyhow::Result<()> {
|
||||
print_title(self.message);
|
||||
print_field("状态", self.status);
|
||||
print_field("PID", self.pid);
|
||||
print_path_field("资源目录", &self.resource_output_root);
|
||||
print_path_field("汉化目录", &self.localized_output_root);
|
||||
print_path_field("状态目录", &self.state_dir);
|
||||
print_path_field("socket", &self.socket_path);
|
||||
print_path_field("日志", &self.log_path);
|
||||
print_path_field("结构化日志", &self.structured_log_path);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl HumanReport for DaemonStatusReport {
|
||||
fn print_human(&self) -> anyhow::Result<()> {
|
||||
print_title("后台状态");
|
||||
print_field("状态", self.status);
|
||||
print_field("消息", self.message);
|
||||
print_field("运行中", format_bool(self.running));
|
||||
print_optional_field("PID", self.pid);
|
||||
print_optional_field("后台状态", self.daemon_state.as_deref());
|
||||
print_field("RPC 可用", format_bool(self.rpc_available));
|
||||
print_field("失效 PID", format_bool(self.stale_pid_file));
|
||||
print_field("失效 socket", format_bool(self.stale_socket));
|
||||
print_optional_field("上次同步", self.last_update_status.as_deref());
|
||||
print_optional_field("最后成功时间", self.last_success_unix_seconds);
|
||||
print_optional_field("上次错误", self.last_error.as_deref());
|
||||
print_optional_field("下次重试秒数", self.next_retry_seconds);
|
||||
print_optional_field("下次检查时间", self.next_check_unix_seconds);
|
||||
print_optional_field("当前阶段", self.current_stage.as_deref());
|
||||
print_optional_field("当前消息", self.current_message.as_deref());
|
||||
if let Some(progress) = self.download_progress.as_ref() {
|
||||
print_field("下载进度", format_daemon_download_progress(progress));
|
||||
}
|
||||
if let Some(version_state) = self.version_state.as_ref() {
|
||||
print_daemon_version_state_summary(version_state);
|
||||
}
|
||||
print_optional_path_field("资源目录", self.resource_output_root.as_ref());
|
||||
print_optional_path_field("汉化目录", self.localized_output_root.as_ref());
|
||||
print_optional_path_field("版本状态", self.version_state_path.as_ref());
|
||||
print_path_field("状态目录", &self.state_dir);
|
||||
print_path_field("socket", &self.socket_path);
|
||||
print_optional_path_field("日志", self.log_path.as_ref());
|
||||
print_optional_path_field("结构化日志", self.structured_log_path.as_ref());
|
||||
let rotated = self
|
||||
.rotated_structured_log_paths
|
||||
.iter()
|
||||
.map(|path| path.display().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
print_list("轮转日志", &rotated, 5);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl HumanReport for DaemonStopReport {
|
||||
fn print_human(&self) -> anyhow::Result<()> {
|
||||
print_title(self.message);
|
||||
print_field("状态", self.status);
|
||||
print_field("已停止", format_bool(self.stopped));
|
||||
print_optional_field("PID", self.pid);
|
||||
print_path_field("状态目录", &self.state_dir);
|
||||
print_path_field("socket", &self.socket_path);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl HumanReport for DaemonControlReport {
|
||||
fn print_human(&self) -> anyhow::Result<()> {
|
||||
print_title(self.message);
|
||||
print_field("命令", self.command);
|
||||
print_field("状态", self.status);
|
||||
print_field("策略", self.strategy);
|
||||
print_optional_field("旧 PID", self.previous_pid);
|
||||
print_field("PID", self.pid);
|
||||
print_path_field("资源目录", &self.resource_output_root);
|
||||
print_path_field("状态目录", &self.state_dir);
|
||||
print_path_field("socket", &self.socket_path);
|
||||
print_path_field("日志", &self.log_path);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl HumanReport for VerifyCommandReport {
|
||||
fn print_human(&self) -> anyhow::Result<()> {
|
||||
print_title(self.message);
|
||||
print_field("命令", self.command);
|
||||
print_field("状态", self.status);
|
||||
print_field("健康", format_bool(self.healthy));
|
||||
print_field("远端状态", &self.remote_update_status);
|
||||
print_path_field("校验资源目录", &self.verified_resource_root);
|
||||
print_optional_field("计划 URL 数", self.planned_url_count);
|
||||
print_field("计划异常数", self.expected_plan_failure_count);
|
||||
print_field("本地 manifest 项", self.local_manifest_entry_count);
|
||||
print_field("本地校验通过", self.local_manifest_verified_count);
|
||||
print_field("本地失败数", self.local_manifest_failure_count);
|
||||
print_field("官方 hash 对", self.official_hash_pair_count);
|
||||
print_field("官方 hash 通过", self.official_hash_verified_count);
|
||||
print_verification_summary(&self.verification_summary);
|
||||
if !self.official_hash_errors.is_empty() {
|
||||
print_list("官方 hash 错误", &self.official_hash_errors, 8);
|
||||
}
|
||||
if !self.failures.is_empty() {
|
||||
println!(" 失败项:");
|
||||
for item in self.failures.iter().take(12) {
|
||||
println!(" - {} -> {}", item.status, item.destination.display());
|
||||
}
|
||||
if self.failures.len() > 12 {
|
||||
println!(" ... 还有 {} 项", self.failures.len() - 12);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl HumanReport for LogsReport {
|
||||
fn print_human(&self) -> anyhow::Result<()> {
|
||||
print_title(self.message);
|
||||
print_field("状态", self.status);
|
||||
print_path_field("日志", &self.log_path);
|
||||
print_field("存在", format_bool(self.exists));
|
||||
print_field("为空", format_bool(self.empty));
|
||||
print_field("字节", self.bytes);
|
||||
print_field("总行数", self.total_lines);
|
||||
print_field("返回行数", self.returned_lines);
|
||||
if !self.content.is_empty() {
|
||||
println!();
|
||||
println!("{}", self.content);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl HumanReport for DoctorReport {
|
||||
fn print_human(&self) -> anyhow::Result<()> {
|
||||
print_title(self.message);
|
||||
print_field("命令", self.command);
|
||||
print_field("状态", self.status);
|
||||
print_field("健康", format_bool(self.healthy));
|
||||
println!(" 检查:");
|
||||
for check in &self.checks {
|
||||
println!(
|
||||
" [{}] {} - {}",
|
||||
if check.ok { "OK" } else { "FAIL" },
|
||||
check.name,
|
||||
check.message
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl HumanReport for DoctorCasReport {
|
||||
fn print_human(&self) -> anyhow::Result<()> {
|
||||
print_title(self.message);
|
||||
print_field("命令", self.command);
|
||||
print_field("状态", self.status);
|
||||
print_field("健康", format_bool(self.healthy));
|
||||
print_path_field("CAS 目录", &self.cas_root);
|
||||
print_path_field("对象目录", &self.objects_dir);
|
||||
print_path_field("元数据库", &self.metadata_db_path);
|
||||
print_field("对象数", self.object_count);
|
||||
print_field("总字节", self.total_size);
|
||||
print_field("无效对象文件", self.invalid_object_count);
|
||||
println!(" 检查:");
|
||||
for check in &self.checks {
|
||||
println!(
|
||||
" [{}] {} - {}",
|
||||
if check.ok { "OK" } else { "FAIL" },
|
||||
check.name,
|
||||
check.message
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl HumanReport for CleanStableReport {
|
||||
fn print_human(&self) -> anyhow::Result<()> {
|
||||
print_title(self.message);
|
||||
print_field("命令", self.command);
|
||||
print_field("状态", self.status);
|
||||
print_path_field("资源目录", &self.output_root);
|
||||
print_path_field("状态目录", &self.state_dir);
|
||||
if !self.removed_paths.is_empty() {
|
||||
println!(" 已清理:");
|
||||
for path in &self.removed_paths {
|
||||
println!(" - {}", path.display());
|
||||
}
|
||||
}
|
||||
if !self.skipped_paths.is_empty() {
|
||||
println!(" 已跳过:");
|
||||
for path in &self.skipped_paths {
|
||||
println!(" - {}", path.display());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl HumanReport for DaemonRpcAck {
|
||||
fn print_human(&self) -> anyhow::Result<()> {
|
||||
print_title(self.message);
|
||||
print_field("命令", self.command);
|
||||
print_field("状态", self.status);
|
||||
print_optional_field("force", self.force.map(format_bool));
|
||||
print_path_field("状态目录", &self.state_dir);
|
||||
print_path_field("socket", &self.socket_path);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,777 @@
|
||||
use super::report_output::print_json_value;
|
||||
use super::*;
|
||||
|
||||
const SCHEDULES_FILE_NAME: &str = "bat-schedules.json";
|
||||
const SCHEDULE_LOCK_FILE_NAME: &str = "bat-schedule.lock";
|
||||
const SCHEDULES_SCHEMA_VERSION: u32 = 1;
|
||||
static SCHEDULE_FILE_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ScheduleFileLock {
|
||||
path: PathBuf,
|
||||
pid: u32,
|
||||
}
|
||||
|
||||
impl ScheduleFileLock {
|
||||
fn acquire(state_dir: &Path) -> anyhow::Result<Self> {
|
||||
validate_runtime_state_dir(state_dir).map_err(anyhow::Error::msg)?;
|
||||
fs::create_dir_all(state_dir)?;
|
||||
let path = state_dir.join(SCHEDULE_LOCK_FILE_NAME);
|
||||
let pid = std::process::id();
|
||||
for attempt in 0..=1 {
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
options.mode(PRIVATE_FILE_MODE);
|
||||
match options.open(&path) {
|
||||
Ok(mut file) => {
|
||||
file.write_all(pid.to_string().as_bytes())?;
|
||||
return Ok(Self { path, pid });
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
if attempt == 0 && remove_recoverable_pid_lock(&path)? {
|
||||
continue;
|
||||
}
|
||||
return Err(anyhow::anyhow!(
|
||||
"调度计划已被锁定:{};{}",
|
||||
path.display(),
|
||||
describe_pid_lock_owner(&path)?
|
||||
));
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"获取调度计划锁失败 {}:{error}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(anyhow::anyhow!("获取调度计划锁失败"))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ScheduleFileLock {
|
||||
fn drop(&mut self) {
|
||||
let expected = self.pid.to_string();
|
||||
if fs::symlink_metadata(&self.path)
|
||||
.map(|metadata| metadata.file_type().is_symlink())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if fs::read_to_string(&self.path)
|
||||
.map(|contents| contents.trim() == expected)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(super) struct ScheduleFile {
|
||||
pub(super) schema_version: u32,
|
||||
pub(super) schedules: Vec<ScheduleEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(super) struct ScheduleEntry {
|
||||
pub(super) id: String,
|
||||
pub(super) group: String,
|
||||
pub(super) action: String,
|
||||
pub(super) args: Vec<String>,
|
||||
pub(super) next_run_unix_seconds: u64,
|
||||
pub(super) interval_seconds: Option<u64>,
|
||||
pub(super) remaining_runs: Option<usize>,
|
||||
pub(super) enabled: bool,
|
||||
pub(super) created_unix_seconds: u64,
|
||||
pub(super) updated_unix_seconds: u64,
|
||||
pub(super) last_run_unix_seconds: Option<u64>,
|
||||
pub(super) last_status: Option<String>,
|
||||
pub(super) last_error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub(super) struct ScheduleMutationRequest {
|
||||
#[serde(default, alias = "schedule_id")]
|
||||
pub(super) id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(super) group: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(super) action: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(super) args: Vec<String>,
|
||||
#[serde(default, alias = "at_unix_seconds", alias = "schedule_at_unix")]
|
||||
pub(super) next_run_unix_seconds: Option<u64>,
|
||||
#[serde(default, alias = "schedule_delay_seconds")]
|
||||
pub(super) delay_seconds: Option<u64>,
|
||||
#[serde(default, alias = "schedule_every_seconds")]
|
||||
pub(super) every_seconds: Option<u64>,
|
||||
#[serde(default, alias = "schedule_count")]
|
||||
pub(super) count: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub(super) clear_args: bool,
|
||||
#[serde(default)]
|
||||
pub(super) clear_every: bool,
|
||||
#[serde(default)]
|
||||
pub(super) enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub(super) struct ScheduleListRequest {
|
||||
#[serde(default, alias = "schedule_id")]
|
||||
pub(super) id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(super) group: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(super) enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub(super) struct ScheduleRunRequest {
|
||||
#[serde(default, alias = "schedule_id")]
|
||||
pub(super) id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(super) group: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(super) force: bool,
|
||||
#[serde(default)]
|
||||
pub(super) max_runs: Option<usize>,
|
||||
}
|
||||
|
||||
pub(super) fn run_schedule_list(options: &CliOptions) -> anyhow::Result<()> {
|
||||
let request = ScheduleListRequest {
|
||||
id: options.schedule_id.clone(),
|
||||
group: options.schedule_group.clone(),
|
||||
enabled: options.schedule_enabled,
|
||||
};
|
||||
print_json_value(
|
||||
options.output_format,
|
||||
&schedule_list_report_with_request(&options.state_dir, request)?,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn run_schedule_add(options: &CliOptions) -> anyhow::Result<()> {
|
||||
validate_schedule_command_options(options, false)?;
|
||||
let request = schedule_request_from_options(options);
|
||||
print_json_value(
|
||||
options.output_format,
|
||||
&schedule_add_report(&options.state_dir, request)?,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn run_schedule_update(options: &CliOptions) -> anyhow::Result<()> {
|
||||
validate_schedule_command_options(options, true)?;
|
||||
let request = schedule_request_from_options(options);
|
||||
print_json_value(
|
||||
options.output_format,
|
||||
&schedule_update_report(&options.state_dir, request)?,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn run_schedule_remove(options: &CliOptions) -> anyhow::Result<()> {
|
||||
validate_schedule_command_options(options, true)?;
|
||||
let request = schedule_request_from_options(options);
|
||||
print_json_value(
|
||||
options.output_format,
|
||||
&schedule_remove_report(&options.state_dir, request)?,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn run_schedule_run(options: &CliOptions) -> anyhow::Result<()> {
|
||||
validate_schedule_command_options(options, true)?;
|
||||
loop {
|
||||
let request = ScheduleRunRequest {
|
||||
id: options.schedule_id.clone(),
|
||||
group: options.schedule_group.clone(),
|
||||
force: options.config.force,
|
||||
max_runs: options.schedule_max_runs,
|
||||
};
|
||||
print_json_value(
|
||||
options.output_format,
|
||||
&schedule_run_report(&options.state_dir, request)?,
|
||||
)?;
|
||||
if !options.watch {
|
||||
return Ok(());
|
||||
}
|
||||
thread::sleep(options.interval);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn schedule_list_report_with_request(
|
||||
state_dir: &Path,
|
||||
request: ScheduleListRequest,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let _guard = SCHEDULE_FILE_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poison| poison.into_inner());
|
||||
let _schedule_lock = ScheduleFileLock::acquire(state_dir)?;
|
||||
let file = read_schedule_file(state_dir)?;
|
||||
let group = request
|
||||
.group
|
||||
.as_deref()
|
||||
.map(normalize_schedule_group)
|
||||
.transpose()?;
|
||||
let schedules = file
|
||||
.schedules
|
||||
.iter()
|
||||
.filter(|entry| request.id.as_deref().is_none_or(|id| id == entry.id))
|
||||
.filter(|entry| group.as_deref().is_none_or(|group| group == entry.group))
|
||||
.filter(|entry| {
|
||||
request
|
||||
.enabled
|
||||
.is_none_or(|enabled| enabled == entry.enabled)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(serde_json::json!({
|
||||
"command": "schedule-list",
|
||||
"status": "ok",
|
||||
"state_file": schedule_file_path(state_dir),
|
||||
"query": request,
|
||||
"schedules": schedules,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn schedule_add_report(
|
||||
state_dir: &Path,
|
||||
request: ScheduleMutationRequest,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let _guard = SCHEDULE_FILE_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poison| poison.into_inner());
|
||||
let _schedule_lock = ScheduleFileLock::acquire(state_dir)?;
|
||||
let mut file = read_schedule_file(state_dir)?;
|
||||
let id = request
|
||||
.id
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("schedule add 必须指定 --schedule-id"))?;
|
||||
if file.schedules.iter().any(|entry| entry.id == id) {
|
||||
return Err(anyhow::anyhow!("schedule 已存在:{id}"));
|
||||
}
|
||||
let now = unix_seconds_now();
|
||||
let entry = build_schedule_entry(&request, now)?;
|
||||
file.schedules.push(entry.clone());
|
||||
write_schedule_file(state_dir, &file)?;
|
||||
Ok(schedule_result_value(
|
||||
state_dir,
|
||||
"schedule-add",
|
||||
"created",
|
||||
&entry,
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn schedule_update_report(
|
||||
state_dir: &Path,
|
||||
request: ScheduleMutationRequest,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let _guard = SCHEDULE_FILE_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poison| poison.into_inner());
|
||||
let _schedule_lock = ScheduleFileLock::acquire(state_dir)?;
|
||||
validate_schedule_mutation(&request, true)?;
|
||||
let id = request
|
||||
.id
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("schedule update 必须指定 --schedule-id"))?;
|
||||
let mut file = read_schedule_file(state_dir)?;
|
||||
let entry = file
|
||||
.schedules
|
||||
.iter_mut()
|
||||
.find(|entry| entry.id == id)
|
||||
.ok_or_else(|| anyhow::anyhow!("schedule 不存在:{id}"))?;
|
||||
if let Some(group) = request
|
||||
.group
|
||||
.as_deref()
|
||||
.map(normalize_schedule_group)
|
||||
.transpose()?
|
||||
{
|
||||
if group != entry.group {
|
||||
return Err(anyhow::anyhow!(
|
||||
"schedule {} 属于 {},不能从 {} 二级命令更新",
|
||||
id,
|
||||
entry.group,
|
||||
group
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(action) = request.action.as_deref() {
|
||||
validate_schedule_action(entry.group.as_str(), action)?;
|
||||
entry.action = action.to_string();
|
||||
}
|
||||
if let Some(at) = request.next_run_unix_seconds {
|
||||
entry.next_run_unix_seconds = at;
|
||||
}
|
||||
if let Some(delay) = request.delay_seconds {
|
||||
entry.next_run_unix_seconds = unix_seconds_now().saturating_add(delay);
|
||||
}
|
||||
if let Some(every) = request.every_seconds {
|
||||
entry.interval_seconds = Some(nonzero_seconds(
|
||||
Duration::from_secs(every),
|
||||
"--schedule-every",
|
||||
)?);
|
||||
}
|
||||
if request.clear_every {
|
||||
entry.interval_seconds = None;
|
||||
}
|
||||
if let Some(count) = request.count {
|
||||
entry.remaining_runs = Some(count);
|
||||
}
|
||||
if request.clear_args {
|
||||
entry.args.clear();
|
||||
}
|
||||
if !request.args.is_empty() {
|
||||
validate_schedule_args(&request.args)?;
|
||||
entry.args = request.args.clone();
|
||||
}
|
||||
if let Some(enabled) = request.enabled {
|
||||
entry.enabled = enabled;
|
||||
}
|
||||
if entry.interval_seconds.is_none() && request.clear_every && request.count.is_none() {
|
||||
entry.remaining_runs = Some(1);
|
||||
}
|
||||
validate_schedule_entry_shape(entry)?;
|
||||
entry.updated_unix_seconds = unix_seconds_now();
|
||||
let updated = entry.clone();
|
||||
write_schedule_file(state_dir, &file)?;
|
||||
Ok(schedule_result_value(
|
||||
state_dir,
|
||||
"schedule-update",
|
||||
"updated",
|
||||
&updated,
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn schedule_remove_report(
|
||||
state_dir: &Path,
|
||||
request: ScheduleMutationRequest,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let _guard = SCHEDULE_FILE_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poison| poison.into_inner());
|
||||
let _schedule_lock = ScheduleFileLock::acquire(state_dir)?;
|
||||
let id = request
|
||||
.id
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("schedule remove 必须指定 --schedule-id"))?;
|
||||
let mut file = read_schedule_file(state_dir)?;
|
||||
let group = request
|
||||
.group
|
||||
.as_deref()
|
||||
.map(normalize_schedule_group)
|
||||
.transpose()?;
|
||||
let index = file
|
||||
.schedules
|
||||
.iter()
|
||||
.position(|entry| entry.id == id)
|
||||
.ok_or_else(|| anyhow::anyhow!("schedule 不存在:{id}"))?;
|
||||
if let Some(group) = group {
|
||||
if file.schedules[index].group != group {
|
||||
return Err(anyhow::anyhow!(
|
||||
"schedule {} 属于 {},不能从 {} 二级命令删除",
|
||||
id,
|
||||
file.schedules[index].group,
|
||||
group
|
||||
));
|
||||
}
|
||||
}
|
||||
file.schedules.remove(index);
|
||||
write_schedule_file(state_dir, &file)?;
|
||||
Ok(serde_json::json!({
|
||||
"command": "schedule-remove",
|
||||
"status": "removed",
|
||||
"id": id,
|
||||
"state_file": schedule_file_path(state_dir),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn schedule_run_report(
|
||||
state_dir: &Path,
|
||||
request: ScheduleRunRequest,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let _guard = SCHEDULE_FILE_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poison| poison.into_inner());
|
||||
let _schedule_lock = ScheduleFileLock::acquire(state_dir)?;
|
||||
let now = unix_seconds_now();
|
||||
let selected_id = request.id.as_deref();
|
||||
let mut file = read_schedule_file(state_dir)?;
|
||||
let group = request
|
||||
.group
|
||||
.as_deref()
|
||||
.map(normalize_schedule_group)
|
||||
.transpose()?;
|
||||
if let (Some(id), Some(group)) = (selected_id, group.as_deref()) {
|
||||
if let Some(entry) = file.schedules.iter().find(|entry| entry.id == id) {
|
||||
if entry.group != group {
|
||||
return Err(anyhow::anyhow!(
|
||||
"schedule {} 属于 {},不能从 {} 二级命令执行",
|
||||
id,
|
||||
entry.group,
|
||||
group
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if request.max_runs == Some(0) {
|
||||
return Err(anyhow::anyhow!("max_runs 必须大于 0"));
|
||||
}
|
||||
let mut results = Vec::new();
|
||||
for index in 0..file.schedules.len() {
|
||||
if request.max_runs.is_some_and(|max| results.len() >= max) {
|
||||
break;
|
||||
}
|
||||
let due = {
|
||||
let entry = &file.schedules[index];
|
||||
entry.enabled
|
||||
&& (request.force || entry.next_run_unix_seconds <= now)
|
||||
&& selected_id.is_none_or(|id| id == entry.id)
|
||||
&& group.as_deref().is_none_or(|group| group == entry.group)
|
||||
};
|
||||
if !due {
|
||||
continue;
|
||||
}
|
||||
let entry = &mut file.schedules[index];
|
||||
let id = entry.id.clone();
|
||||
let command = schedule_child_command(entry, state_dir);
|
||||
let started = unix_seconds_now();
|
||||
if let Some(remaining) = entry.remaining_runs.as_mut() {
|
||||
*remaining = remaining.saturating_sub(1);
|
||||
}
|
||||
entry.last_run_unix_seconds = Some(started);
|
||||
entry.updated_unix_seconds = started;
|
||||
entry.enabled = entry.remaining_runs != Some(0);
|
||||
entry.next_run_unix_seconds = entry
|
||||
.interval_seconds
|
||||
.map(|seconds| started.saturating_add(seconds))
|
||||
.unwrap_or(started);
|
||||
write_schedule_file(state_dir, &file)?;
|
||||
|
||||
let status = Command::new(&command[0]).args(&command[1..]).status();
|
||||
let (status_label, error) = match status {
|
||||
Ok(status) if status.success() => ("completed".to_string(), None),
|
||||
Ok(status) => (
|
||||
"failed".to_string(),
|
||||
Some(format!("子命令退出码:{}", status.code().unwrap_or(-1))),
|
||||
),
|
||||
Err(error) => ("failed".to_string(), Some(error.to_string())),
|
||||
};
|
||||
let (next_run_unix_seconds, enabled) = {
|
||||
let entry = &mut file.schedules[index];
|
||||
entry.last_status = Some(status_label.clone());
|
||||
entry.last_error = error.clone();
|
||||
entry.updated_unix_seconds = unix_seconds_now();
|
||||
(entry.next_run_unix_seconds, entry.enabled)
|
||||
};
|
||||
write_schedule_file(state_dir, &file)?;
|
||||
results.push(serde_json::json!({
|
||||
"id": id,
|
||||
"command": command,
|
||||
"status": status_label,
|
||||
"error": error,
|
||||
"next_run_unix_seconds": next_run_unix_seconds,
|
||||
"enabled": enabled,
|
||||
}));
|
||||
}
|
||||
if selected_id.is_some() && results.is_empty() {
|
||||
let status = match file
|
||||
.schedules
|
||||
.iter()
|
||||
.find(|entry| Some(entry.id.as_str()) == selected_id)
|
||||
{
|
||||
None => "not_found",
|
||||
Some(entry) if !entry.enabled => "disabled",
|
||||
Some(_) => "not_due",
|
||||
};
|
||||
return Ok(serde_json::json!({
|
||||
"command": "schedule-run",
|
||||
"status": status,
|
||||
"now_unix_seconds": now,
|
||||
"executed": [],
|
||||
}));
|
||||
}
|
||||
Ok(serde_json::json!({
|
||||
"command": "schedule-run",
|
||||
"status": "completed",
|
||||
"now_unix_seconds": now,
|
||||
"executed": results,
|
||||
}))
|
||||
}
|
||||
|
||||
fn build_schedule_entry(
|
||||
request: &ScheduleMutationRequest,
|
||||
now: u64,
|
||||
) -> anyhow::Result<ScheduleEntry> {
|
||||
validate_schedule_mutation(request, false)?;
|
||||
let group = request
|
||||
.group
|
||||
.as_deref()
|
||||
.map(normalize_schedule_group)
|
||||
.transpose()?
|
||||
.ok_or_else(|| anyhow::anyhow!("schedule 命令缺少所属一级命令"))?;
|
||||
let action = request
|
||||
.action
|
||||
.as_deref()
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| default_schedule_action(&group).to_string());
|
||||
validate_schedule_action(&group, &action)?;
|
||||
validate_schedule_args(&request.args)?;
|
||||
let next_run = schedule_next_run(request, now)?;
|
||||
let interval_seconds = request
|
||||
.every_seconds
|
||||
.map(|value| nonzero_seconds(Duration::from_secs(value), "--schedule-every"))
|
||||
.transpose()?;
|
||||
let remaining_runs = request
|
||||
.count
|
||||
.or_else(|| interval_seconds.is_none().then_some(1));
|
||||
if interval_seconds.is_none() && remaining_runs.is_some_and(|count| count > 1) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"--schedule-count 大于 1 时必须指定 --schedule-every"
|
||||
));
|
||||
}
|
||||
Ok(ScheduleEntry {
|
||||
id: request
|
||||
.id
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow::anyhow!("schedule add 必须指定 --schedule-id"))?,
|
||||
group,
|
||||
action,
|
||||
args: request.args.clone(),
|
||||
next_run_unix_seconds: next_run,
|
||||
interval_seconds,
|
||||
remaining_runs,
|
||||
enabled: request.enabled.unwrap_or(true),
|
||||
created_unix_seconds: now,
|
||||
updated_unix_seconds: now,
|
||||
last_run_unix_seconds: None,
|
||||
last_status: None,
|
||||
last_error: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_schedule_mutation(
|
||||
request: &ScheduleMutationRequest,
|
||||
update: bool,
|
||||
) -> anyhow::Result<()> {
|
||||
if request.next_run_unix_seconds.is_some() && request.delay_seconds.is_some() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"next_run_unix_seconds 与 delay_seconds 只能指定一个"
|
||||
));
|
||||
}
|
||||
if request.every_seconds.is_some() && request.clear_every {
|
||||
return Err(anyhow::anyhow!("every_seconds 与 clear_every 只能指定一个"));
|
||||
}
|
||||
if request.count == Some(0) {
|
||||
return Err(anyhow::anyhow!("count 必须大于 0"));
|
||||
}
|
||||
if request.clear_args && !update {
|
||||
return Err(anyhow::anyhow!("clear_args 只适用于 schedule update"));
|
||||
}
|
||||
if request.clear_every && !update {
|
||||
return Err(anyhow::anyhow!("clear_every 只适用于 schedule update"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn schedule_next_run(request: &ScheduleMutationRequest, now: u64) -> anyhow::Result<u64> {
|
||||
match (request.next_run_unix_seconds, request.delay_seconds) {
|
||||
(Some(_), Some(_)) => Err(anyhow::anyhow!(
|
||||
"--schedule-at-unix 与 --schedule-delay 只能指定一个"
|
||||
)),
|
||||
(Some(at), None) => Ok(at),
|
||||
(None, Some(delay)) => Ok(now.saturating_add(delay)),
|
||||
(None, None) => Ok(now),
|
||||
}
|
||||
}
|
||||
|
||||
fn schedule_child_command(entry: &ScheduleEntry, state_dir: &Path) -> Vec<String> {
|
||||
let executable = env::current_exe().unwrap_or_else(|_| PathBuf::from("bat"));
|
||||
let mut command = vec![
|
||||
executable.to_string_lossy().into_owned(),
|
||||
entry.group.clone(),
|
||||
entry.action.clone(),
|
||||
];
|
||||
command.extend(entry.args.iter().cloned());
|
||||
if !entry.args.iter().any(|arg| arg == "--state-dir") {
|
||||
command.push("--state-dir".to_string());
|
||||
command.push(state_dir.to_string_lossy().into_owned());
|
||||
}
|
||||
command.push("--no-banner".to_string());
|
||||
command.push("--no-progress".to_string());
|
||||
command
|
||||
}
|
||||
|
||||
fn validate_schedule_command_options(
|
||||
options: &CliOptions,
|
||||
allow_empty: bool,
|
||||
) -> anyhow::Result<()> {
|
||||
if !allow_empty && options.schedule_group.is_none() {
|
||||
return Err(anyhow::anyhow!("schedule 命令缺少所属一级命令"));
|
||||
}
|
||||
if options.watch && !matches!(options.command, CliCommand::ScheduleRun) {
|
||||
return Err(anyhow::anyhow!("只有 schedule run 支持 --watch"));
|
||||
}
|
||||
if options.interval.is_zero() {
|
||||
return Err(anyhow::anyhow!("schedule 轮询间隔必须大于 0"));
|
||||
}
|
||||
if options.schedule_every.is_some_and(|value| value.is_zero()) {
|
||||
return Err(anyhow::anyhow!("--schedule-every 必须大于 0"));
|
||||
}
|
||||
if options.schedule_delay.is_some_and(|value| value.is_zero()) {
|
||||
return Err(anyhow::anyhow!("--schedule-delay 必须大于 0"));
|
||||
}
|
||||
if options.schedule_count == Some(0) {
|
||||
return Err(anyhow::anyhow!("--schedule-count 必须大于 0"));
|
||||
}
|
||||
if options.schedule_max_runs == Some(0) {
|
||||
return Err(anyhow::anyhow!("--schedule-max-runs 必须大于 0"));
|
||||
}
|
||||
if options.schedule_max_runs.is_some() && !matches!(options.command, CliCommand::ScheduleRun) {
|
||||
return Err(anyhow::anyhow!("--schedule-max-runs 只适用于 schedule run"));
|
||||
}
|
||||
if options.schedule_clear_args && !matches!(options.command, CliCommand::ScheduleUpdate) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"--schedule-clear-args 只适用于 schedule update"
|
||||
));
|
||||
}
|
||||
if options.schedule_clear_every && !matches!(options.command, CliCommand::ScheduleUpdate) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"--schedule-clear-every 只适用于 schedule update"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_schedule_action(group: &str, action: &str) -> anyhow::Result<()> {
|
||||
let valid = match group {
|
||||
"res" => matches!(action, "pull" | "refresh" | "verify" | "repair"),
|
||||
"parse" => matches!(action, "run" | "repack" | "clear-cache"),
|
||||
"i18n" => matches!(action, "run" | "export" | "validate" | "publish"),
|
||||
_ => false,
|
||||
};
|
||||
if valid {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow::anyhow!(
|
||||
"不支持的 schedule action:group={group}, action={action}"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_schedule_entry_shape(entry: &ScheduleEntry) -> anyhow::Result<()> {
|
||||
if entry.interval_seconds.is_none() && entry.remaining_runs.is_some_and(|count| count > 1) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"非周期 schedule 不能保留多次执行次数;请设置 --schedule-every"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn default_schedule_action(group: &str) -> &'static str {
|
||||
match group {
|
||||
"res" => "pull",
|
||||
"parse" => "run",
|
||||
"i18n" => "run",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_schedule_args(args: &[String]) -> anyhow::Result<()> {
|
||||
if let Some(arg) = args
|
||||
.iter()
|
||||
.find(|arg| arg.starts_with("--schedule-") || matches!(arg.as_str(), "--id" | "--action"))
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"schedule 子命令参数不能嵌套调度控制选项:{arg}"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn nonzero_seconds(value: Duration, flag: &str) -> anyhow::Result<u64> {
|
||||
let seconds = value.as_secs();
|
||||
if seconds == 0 {
|
||||
return Err(anyhow::anyhow!("{flag} 必须至少为 1s"));
|
||||
}
|
||||
Ok(seconds)
|
||||
}
|
||||
|
||||
fn schedule_request_from_options(options: &CliOptions) -> ScheduleMutationRequest {
|
||||
ScheduleMutationRequest {
|
||||
id: options.schedule_id.clone(),
|
||||
group: options.schedule_group.clone(),
|
||||
action: options.schedule_action.clone(),
|
||||
args: options.schedule_args.clone(),
|
||||
next_run_unix_seconds: options.schedule_at_unix,
|
||||
delay_seconds: options.schedule_delay.map(|value| value.as_secs()),
|
||||
every_seconds: options.schedule_every.map(|value| value.as_secs()),
|
||||
count: options.schedule_count,
|
||||
clear_args: options.schedule_clear_args,
|
||||
clear_every: options.schedule_clear_every,
|
||||
enabled: options.schedule_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_schedule_group(group: &str) -> anyhow::Result<String> {
|
||||
let normalized = match group {
|
||||
"res" | "resource" | "resources" => "res",
|
||||
"parse" => "parse",
|
||||
"i18n" | "tr" | "translation" | "translate" => "i18n",
|
||||
other => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"schedule 不支持的一级命令:{other}(支持 res、parse、i18n)"
|
||||
))
|
||||
}
|
||||
};
|
||||
Ok(normalized.to_string())
|
||||
}
|
||||
|
||||
fn schedule_result_value(
|
||||
state_dir: &Path,
|
||||
command: &'static str,
|
||||
status: &'static str,
|
||||
entry: &ScheduleEntry,
|
||||
) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"command": command,
|
||||
"status": status,
|
||||
"schedule": entry,
|
||||
"state_file": schedule_file_path(state_dir),
|
||||
})
|
||||
}
|
||||
|
||||
fn schedule_file_path(state_dir: &Path) -> PathBuf {
|
||||
state_dir.join(SCHEDULES_FILE_NAME)
|
||||
}
|
||||
|
||||
pub(super) fn read_schedule_file(state_dir: &Path) -> anyhow::Result<ScheduleFile> {
|
||||
let path = schedule_file_path(state_dir);
|
||||
let Some(bytes) = read_file_no_symlink(&path, "调度计划文件").map_err(anyhow::Error::msg)?
|
||||
else {
|
||||
return Ok(ScheduleFile {
|
||||
schema_version: SCHEDULES_SCHEMA_VERSION,
|
||||
schedules: Vec::new(),
|
||||
});
|
||||
};
|
||||
let file: ScheduleFile = serde_json::from_slice(&bytes)?;
|
||||
if file.schema_version != SCHEDULES_SCHEMA_VERSION {
|
||||
return Err(anyhow::anyhow!(
|
||||
"不支持的调度计划 schema:{},当前版本={}",
|
||||
file.schema_version,
|
||||
SCHEDULES_SCHEMA_VERSION
|
||||
));
|
||||
}
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
fn write_schedule_file(state_dir: &Path, file: &ScheduleFile) -> anyhow::Result<()> {
|
||||
validate_runtime_state_dir(state_dir).map_err(anyhow::Error::msg)?;
|
||||
let path = schedule_file_path(state_dir);
|
||||
let bytes = serde_json::to_vec_pretty(file)?;
|
||||
write_file_atomic(
|
||||
&path,
|
||||
&bytes,
|
||||
bat_infrastructure::STATE_FILE_MODE,
|
||||
"调度计划文件",
|
||||
)
|
||||
.map_err(anyhow::Error::msg)
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) const MAX_RETAINED_TASKS: usize = 64;
|
||||
/// 每个任务保留的进度日志行数上限。
|
||||
pub(super) const MAX_TASK_LOG_LINES: usize = 200;
|
||||
|
||||
/// 任务类型:覆盖资源同步、校验、修复、翻译 worker 与 catalog 更新检查。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum TaskKind {
|
||||
Sync,
|
||||
Verify,
|
||||
Repair,
|
||||
/// 翻译 provider worker 轮次。
|
||||
TranslationWorker,
|
||||
/// catalog 更新检查:只做发现 + 拉取计划(dry-run),不下载不审计。
|
||||
Refresh,
|
||||
}
|
||||
|
||||
impl TaskKind {
|
||||
pub(super) fn method(self) -> &'static str {
|
||||
match self {
|
||||
Self::Sync => RPC_METHOD_RESOURCE_SYNC,
|
||||
Self::Verify => RPC_METHOD_RESOURCE_VERIFY,
|
||||
Self::Repair => RPC_METHOD_RESOURCE_REPAIR,
|
||||
Self::TranslationWorker => RPC_METHOD_TRANSLATION_WORKER_RUN,
|
||||
Self::Refresh => RPC_METHOD_CATALOG_REFRESH,
|
||||
}
|
||||
}
|
||||
|
||||
/// 由 daemon 基准配置派生该任务的实际同步配置。
|
||||
pub(super) fn build_config(
|
||||
self,
|
||||
base: &OfficialUpdateConfig,
|
||||
force: bool,
|
||||
) -> OfficialUpdateConfig {
|
||||
let mut config = base.clone();
|
||||
match self {
|
||||
Self::Sync => {
|
||||
config.dry_run = false;
|
||||
config.force = config.force || force;
|
||||
}
|
||||
Self::Verify => {
|
||||
config.dry_run = true;
|
||||
config.plan = true;
|
||||
config.audit_local = true;
|
||||
config.repair = false;
|
||||
config.force = false;
|
||||
}
|
||||
Self::Repair => {
|
||||
config.dry_run = false;
|
||||
config.audit_local = true;
|
||||
config.repair = true;
|
||||
config.force = false;
|
||||
}
|
||||
Self::Refresh => {
|
||||
config.dry_run = true;
|
||||
config.plan = true;
|
||||
config.audit_local = false;
|
||||
config.repair = false;
|
||||
config.force = force;
|
||||
}
|
||||
Self::TranslationWorker => {
|
||||
config.dry_run = false;
|
||||
config.force = false;
|
||||
}
|
||||
}
|
||||
config
|
||||
}
|
||||
}
|
||||
|
||||
/// 请求取消任务的结果。
|
||||
pub(super) enum CancelOutcome {
|
||||
Requested,
|
||||
AlreadyFinished,
|
||||
NotFound,
|
||||
}
|
||||
|
||||
/// 单个任务的可轮询记录。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub(super) struct TaskRecord {
|
||||
pub(super) id: String,
|
||||
pub(super) kind: &'static str,
|
||||
/// `queued` | `running` | `succeeded` | `failed` | `cancelled`。
|
||||
pub(super) status: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(super) stage: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(super) message: Option<String>,
|
||||
pub(super) created_at: u64,
|
||||
pub(super) updated_at: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(super) started_at: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(super) finished_at: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(super) error: Option<ApiError>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(super) result: Option<serde_json::Value>,
|
||||
/// 取消标志,worker 的 should_cancel 检查它;不参与序列化。
|
||||
#[serde(skip)]
|
||||
pub(super) cancel: Arc<AtomicBool>,
|
||||
/// 进度日志(有界),经 task.logs 返回;不参与 task.status 序列化。
|
||||
#[serde(skip)]
|
||||
pub(super) log: Vec<String>,
|
||||
}
|
||||
|
||||
impl TaskRecord {
|
||||
pub(super) fn is_finished(&self) -> bool {
|
||||
matches!(self.status, "succeeded" | "failed" | "cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
struct TaskStore {
|
||||
tasks: HashMap<String, TaskRecord>,
|
||||
order: Vec<String>,
|
||||
seq: u64,
|
||||
/// 任务历史持久化文件路径;`None` 表示纯内存(测试等非 daemon 场景)。
|
||||
persist_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// daemon 任务历史持久化文件名(位于 state dir 内,`0600` 原子写)。
|
||||
pub(super) const TASKS_FILE_NAME: &str = "bat-tasks.json";
|
||||
/// 任务历史文件结构版本。
|
||||
pub(super) const TASKS_FILE_VERSION: u32 = 1;
|
||||
|
||||
/// 任务历史文件的持久化形态(版本化;daemon 重启后恢复任务历史用)。
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub(super) struct PersistedTaskFile {
|
||||
pub(super) version: u32,
|
||||
/// 任务 ID 序号计数器;恢复它避免 pid 复用时新任务与历史任务撞 ID。
|
||||
pub(super) seq: u64,
|
||||
pub(super) tasks: Vec<PersistedTaskRecord>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub(super) struct PersistedTaskRecord {
|
||||
id: String,
|
||||
kind: String,
|
||||
pub(super) status: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
stage: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
message: Option<String>,
|
||||
created_at: u64,
|
||||
updated_at: u64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
started_at: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
finished_at: Option<u64>,
|
||||
/// `ApiError` 的序列化形态(code/kind/domain/location/message/retryable)。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
error: Option<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
result: Option<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
log: Vec<String>,
|
||||
}
|
||||
|
||||
/// 把持久化的任务类型映射回静态字符串;未识别(如未来版本新增)返回 `None`。
|
||||
fn task_kind_static(kind: &str) -> Option<&'static str> {
|
||||
match kind {
|
||||
RPC_METHOD_RESOURCE_SYNC => Some(RPC_METHOD_RESOURCE_SYNC),
|
||||
RPC_METHOD_RESOURCE_VERIFY => Some(RPC_METHOD_RESOURCE_VERIFY),
|
||||
RPC_METHOD_RESOURCE_REPAIR => Some(RPC_METHOD_RESOURCE_REPAIR),
|
||||
RPC_METHOD_TRANSLATION_WORKER_RUN => Some(RPC_METHOD_TRANSLATION_WORKER_RUN),
|
||||
RPC_METHOD_CATALOG_REFRESH => Some(RPC_METHOD_CATALOG_REFRESH),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 把持久化的任务状态映射回静态字符串;未识别返回 `None`。
|
||||
fn task_status_static(status: &str) -> Option<&'static str> {
|
||||
match status {
|
||||
"queued" => Some("queued"),
|
||||
"running" => Some("running"),
|
||||
"succeeded" => Some("succeeded"),
|
||||
"failed" => Some("failed"),
|
||||
"cancelled" => Some("cancelled"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl PersistedTaskRecord {
|
||||
fn from_record(record: &TaskRecord) -> Self {
|
||||
Self {
|
||||
id: record.id.clone(),
|
||||
kind: record.kind.to_string(),
|
||||
status: record.status.to_string(),
|
||||
stage: record.stage.clone(),
|
||||
message: record.message.clone(),
|
||||
created_at: record.created_at,
|
||||
updated_at: record.updated_at,
|
||||
started_at: record.started_at,
|
||||
finished_at: record.finished_at,
|
||||
error: record
|
||||
.error
|
||||
.as_ref()
|
||||
.and_then(|error| serde_json::to_value(error).ok()),
|
||||
result: record.result.clone(),
|
||||
log: record.log.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 还原为内存任务记录;kind/status 未识别时返回 `None`(调用方计数跳过)。
|
||||
fn into_record(self) -> Option<TaskRecord> {
|
||||
let kind = task_kind_static(&self.kind)?;
|
||||
let status = task_status_static(&self.status)?;
|
||||
// 错误从序列化形态还原:code 经码表反查(未登记回退 internal),
|
||||
// location 固定为任务执行器(当前全部任务错误的唯一来源)。
|
||||
let error = self.error.as_ref().map(|value| {
|
||||
let code = value
|
||||
.get("code")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.and_then(ErrorCode::from_id)
|
||||
.unwrap_or(ErrorCode::INTERNAL);
|
||||
let message = value
|
||||
.get("message")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("<持久化错误信息缺失>")
|
||||
.to_string();
|
||||
ApiError::new(code, "task.executor", message)
|
||||
});
|
||||
Some(TaskRecord {
|
||||
id: self.id,
|
||||
kind,
|
||||
status,
|
||||
stage: self.stage,
|
||||
message: self.message,
|
||||
created_at: self.created_at,
|
||||
updated_at: self.updated_at,
|
||||
started_at: self.started_at,
|
||||
finished_at: self.finished_at,
|
||||
error,
|
||||
result: self.result,
|
||||
cancel: Arc::new(AtomicBool::new(false)),
|
||||
log: self.log,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取任务历史文件。文件缺失返回 `Ok(None)`;symlink、解析失败或版本不支持返回 `Err`。
|
||||
fn load_persisted_tasks(path: &Path) -> Result<Option<PersistedTaskFile>, String> {
|
||||
let Some(bytes) = read_file_no_symlink(path, "任务历史")? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let file: PersistedTaskFile = serde_json::from_slice(&bytes)
|
||||
.map_err(|error| format!("解析任务历史失败 {}:{error}", path.display()))?;
|
||||
if file.version != TASKS_FILE_VERSION {
|
||||
return Err(format!(
|
||||
"不支持的任务历史版本 {},文件 {}",
|
||||
file.version,
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
Ok(Some(file))
|
||||
}
|
||||
|
||||
/// 任务注册表句柄:包住内存存储,供 RPC handler 与 worker 共享。
|
||||
///
|
||||
/// 通过方法访问(而非直接摸内部 map),便于将来换成 Redis 等持久化后端。
|
||||
#[derive(Clone)]
|
||||
pub(super) struct TaskRegistry {
|
||||
inner: Arc<Mutex<TaskStore>>,
|
||||
}
|
||||
|
||||
impl TaskRegistry {
|
||||
/// 纯内存注册表(无持久化);生产 daemon 走 [`Self::with_persistence`]。
|
||||
#[cfg(test)]
|
||||
pub(super) fn new() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(TaskStore {
|
||||
tasks: HashMap::new(),
|
||||
order: Vec::new(),
|
||||
seq: 0,
|
||||
persist_path: None,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 state dir 恢复任务历史并启用持久化。
|
||||
///
|
||||
/// 中断时仍处于 queued/running 的任务标记为 `failed`(`TASK_INTERRUPTED`);
|
||||
/// 文件缺失按空历史处理;文件损坏或版本不支持时改名 `.corrupt` 留证并从
|
||||
/// 空历史开始。返回注册表与恢复摘要(供 daemon 日志记录)。
|
||||
pub(super) fn with_persistence(state_dir: &Path) -> (Self, String) {
|
||||
let path = state_dir.join(TASKS_FILE_NAME);
|
||||
let now = unix_seconds_now();
|
||||
let mut seq = 0;
|
||||
let mut tasks = HashMap::new();
|
||||
let mut order = Vec::new();
|
||||
let summary = match load_persisted_tasks(&path) {
|
||||
Ok(None) => "无历史任务文件,从空任务历史开始".to_string(),
|
||||
Ok(Some(file)) => {
|
||||
seq = file.seq;
|
||||
let total = file.tasks.len();
|
||||
let mut interrupted = 0usize;
|
||||
let mut skipped = 0usize;
|
||||
for persisted in file.tasks {
|
||||
let Some(mut record) = persisted.into_record() else {
|
||||
skipped += 1;
|
||||
continue;
|
||||
};
|
||||
if !record.is_finished() {
|
||||
interrupted += 1;
|
||||
record.status = "failed";
|
||||
record.finished_at = Some(now);
|
||||
record.updated_at = now;
|
||||
record.error = Some(ApiError::new(
|
||||
ErrorCode::TASK_INTERRUPTED,
|
||||
"task.executor",
|
||||
"daemon 停止/重启导致任务中断",
|
||||
));
|
||||
record
|
||||
.log
|
||||
.push("[daemon] 任务因 daemon 停止/重启而中断".to_string());
|
||||
}
|
||||
if tasks.insert(record.id.clone(), record.clone()).is_none() {
|
||||
order.push(record.id);
|
||||
} else {
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
format!("恢复任务历史 {total} 条(标记中断 {interrupted} 条,跳过无法识别 {skipped} 条)")
|
||||
}
|
||||
Err(error) => {
|
||||
// 保留损坏文件供诊断(改名而非覆盖),从空历史开始。
|
||||
let corrupt = path.with_extension("json.corrupt");
|
||||
if fs::rename(&path, &corrupt).is_ok() {
|
||||
format!(
|
||||
"任务历史不可用({error});原文件已改名保留为 {}",
|
||||
corrupt.display()
|
||||
)
|
||||
} else {
|
||||
format!("任务历史不可用({error});且无法改名保留原文件")
|
||||
}
|
||||
}
|
||||
};
|
||||
let registry = Self {
|
||||
inner: Arc::new(Mutex::new(TaskStore {
|
||||
tasks,
|
||||
order,
|
||||
seq,
|
||||
persist_path: Some(path),
|
||||
})),
|
||||
};
|
||||
// 把中断标记(或空历史)立即写回,保证文件与内存视图一致。
|
||||
registry.lock().persist();
|
||||
(registry, summary)
|
||||
}
|
||||
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, TaskStore> {
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap_or_else(|poison| poison.into_inner())
|
||||
}
|
||||
|
||||
/// 创建 queued 任务并返回 task_id。
|
||||
pub(super) fn create(&self, kind: TaskKind) -> String {
|
||||
let now = unix_seconds_now();
|
||||
let mut store = self.lock();
|
||||
store.seq += 1;
|
||||
let id = format!("task-{}-{}", std::process::id(), store.seq);
|
||||
let record = TaskRecord {
|
||||
id: id.clone(),
|
||||
kind: kind.method(),
|
||||
status: "queued",
|
||||
stage: None,
|
||||
message: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
started_at: None,
|
||||
finished_at: None,
|
||||
error: None,
|
||||
result: None,
|
||||
cancel: Arc::new(AtomicBool::new(false)),
|
||||
log: Vec::new(),
|
||||
};
|
||||
store.tasks.insert(id.clone(), record);
|
||||
store.order.push(id.clone());
|
||||
store.prune();
|
||||
store.persist();
|
||||
id
|
||||
}
|
||||
|
||||
pub(super) fn update<F: FnOnce(&mut TaskRecord)>(&self, id: &str, update: F) {
|
||||
let mut store = self.lock();
|
||||
let mut status_changed = false;
|
||||
if let Some(record) = store.tasks.get_mut(id) {
|
||||
let previous_status = record.status;
|
||||
update(record);
|
||||
record.updated_at = unix_seconds_now();
|
||||
status_changed = record.status != previous_status;
|
||||
}
|
||||
// 只在生命周期转换时落盘;stage/message/log 的高频进度更新以内存为准,
|
||||
// 随下一次转换一起写入(避免每个进度事件一次磁盘写)。
|
||||
if status_changed {
|
||||
store.persist();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn get(&self, id: &str) -> Option<TaskRecord> {
|
||||
self.lock().tasks.get(id).cloned()
|
||||
}
|
||||
|
||||
/// 返回任务的取消标志(与 worker 共享同一 Arc)。
|
||||
pub(super) fn cancel_flag(&self, id: &str) -> Option<Arc<AtomicBool>> {
|
||||
self.lock()
|
||||
.tasks
|
||||
.get(id)
|
||||
.map(|record| Arc::clone(&record.cancel))
|
||||
}
|
||||
|
||||
/// 追加一行进度日志,超出上限时丢弃最旧的。
|
||||
pub(super) fn append_log(&self, id: &str, line: String) {
|
||||
let mut store = self.lock();
|
||||
if let Some(record) = store.tasks.get_mut(id) {
|
||||
record.log.push(line);
|
||||
if record.log.len() > MAX_TASK_LOG_LINES {
|
||||
let overflow = record.log.len() - MAX_TASK_LOG_LINES;
|
||||
record.log.drain(0..overflow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回任务的进度日志。
|
||||
pub(super) fn logs(&self, id: &str) -> Option<Vec<String>> {
|
||||
self.lock().tasks.get(id).map(|record| record.log.clone())
|
||||
}
|
||||
|
||||
/// 请求取消任务:未结束的置取消标志,已结束的原样返回,不存在返回 NotFound。
|
||||
pub(super) fn request_cancel(&self, id: &str) -> CancelOutcome {
|
||||
let store = self.lock();
|
||||
match store.tasks.get(id) {
|
||||
None => CancelOutcome::NotFound,
|
||||
Some(record) if record.is_finished() => CancelOutcome::AlreadyFinished,
|
||||
Some(record) => {
|
||||
record.cancel.store(true, Ordering::Relaxed);
|
||||
CancelOutcome::Requested
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回全部任务,最新创建的在前。
|
||||
pub(super) fn list(&self) -> Vec<TaskRecord> {
|
||||
let store = self.lock();
|
||||
store
|
||||
.order
|
||||
.iter()
|
||||
.rev()
|
||||
.filter_map(|id| store.tasks.get(id).cloned())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl TaskStore {
|
||||
/// 把当前任务历史落盘(`0600` 原子写、不跟随 symlink)。
|
||||
///
|
||||
/// 持久化未启用时为 no-op;写失败只记 stderr(进 daemon 日志),
|
||||
/// 不让持久化故障拖垮任务执行本身。
|
||||
fn persist(&self) {
|
||||
let Some(path) = &self.persist_path else {
|
||||
return;
|
||||
};
|
||||
let file = PersistedTaskFile {
|
||||
version: TASKS_FILE_VERSION,
|
||||
seq: self.seq,
|
||||
tasks: self
|
||||
.order
|
||||
.iter()
|
||||
.filter_map(|id| self.tasks.get(id))
|
||||
.map(PersistedTaskRecord::from_record)
|
||||
.collect(),
|
||||
};
|
||||
match serde_json::to_vec_pretty(&file) {
|
||||
Ok(bytes) => {
|
||||
if let Err(error) = write_file_atomic(path, &bytes, PRIVATE_FILE_MODE, "任务历史")
|
||||
{
|
||||
super::terminal_output::print_daemon_error(format!(
|
||||
"任务历史落盘失败:{error}"
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
super::terminal_output::print_daemon_error(format!("任务历史序列化失败:{error}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 裁剪最旧的已结束任务,把内存占用控制在上限内;运行中/排队中的任务不裁剪。
|
||||
fn prune(&mut self) {
|
||||
while self.order.len() > MAX_RETAINED_TASKS {
|
||||
let Some(position) = self.order.iter().position(|id| {
|
||||
self.tasks
|
||||
.get(id)
|
||||
.map(TaskRecord::is_finished)
|
||||
.unwrap_or(true)
|
||||
}) else {
|
||||
break;
|
||||
};
|
||||
let id = self.order.remove(position);
|
||||
self.tasks.remove(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交给任务 worker 的作业(配置已按任务类型派生完毕)。
|
||||
pub(super) struct TaskJob {
|
||||
pub(super) id: String,
|
||||
pub(super) kind: TaskKind,
|
||||
pub(super) config: OfficialUpdateConfig,
|
||||
pub(super) translation_worker_config: Option<TranslationWorkerConfig>,
|
||||
/// 与任务记录共享的取消标志。
|
||||
pub(super) cancel: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
/// daemon 任务上下文:RPC handler 借它创建任务、入队和读取。
|
||||
#[derive(Clone)]
|
||||
pub(super) struct DaemonTaskContext {
|
||||
pub(super) registry: TaskRegistry,
|
||||
pub(super) queue: mpsc::Sender<TaskJob>,
|
||||
pub(super) base_config: OfficialUpdateConfig,
|
||||
/// daemon 中未显式传入参数的 translation worker 默认配置。
|
||||
pub(super) translation_worker_config: TranslationWorkerConfig,
|
||||
/// 串行化会读取或修改已发布资源状态的 daemon 操作。
|
||||
pub(super) sync_lock: Arc<Mutex<()>>,
|
||||
pub(super) restart_controller: DaemonRestartController,
|
||||
}
|
||||
|
||||
pub(super) type DaemonRestartController = fn(&Path) -> anyhow::Result<u32>;
|
||||
|
||||
/// 任务 worker:单线程 FIFO 消费任务队列,串行执行官方同步/校验。
|
||||
///
|
||||
/// 每个任务执行前获取进程内 `sync_lock`,与 watch 循环互斥(等待而非撞文件锁失败);
|
||||
/// 进度写入任务记录;`should_cancel` 接 daemon 停止标志,停机时中止在途任务。
|
||||
pub(super) fn run_task_worker(
|
||||
receiver: mpsc::Receiver<TaskJob>,
|
||||
registry: TaskRegistry,
|
||||
sync_lock: Arc<Mutex<()>>,
|
||||
control: DaemonControl,
|
||||
service: OfficialUpdateService,
|
||||
) {
|
||||
for job in receiver {
|
||||
registry.update(&job.id, |record| {
|
||||
record.status = "running";
|
||||
record.started_at = Some(unix_seconds_now());
|
||||
});
|
||||
|
||||
let cancel = Arc::clone(&job.cancel);
|
||||
let run_result = if job.kind == TaskKind::TranslationWorker {
|
||||
let worker_config = job
|
||||
.translation_worker_config
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("translation worker 任务缺少运行配置"));
|
||||
worker_config.and_then(|worker_config| {
|
||||
let _sync_guard = sync_lock
|
||||
.lock()
|
||||
.unwrap_or_else(|poison| poison.into_inner());
|
||||
registry.append_log(&job.id, "[translation-worker] 开始执行".to_string());
|
||||
registry.update(&job.id, |record| {
|
||||
record.stage = Some("translation-worker".to_string());
|
||||
record.message = Some("翻译 provider worker 正在执行".to_string());
|
||||
});
|
||||
let resource_root = active_official_resource_root(&job.config.output_root)?;
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
let cancel_check = Arc::clone(&cancel);
|
||||
runtime
|
||||
.block_on(
|
||||
bat_infrastructure::run_translation_worker_at_with_cancellation(
|
||||
&resource_root,
|
||||
worker_config,
|
||||
Arc::new(move || cancel_check.load(Ordering::Relaxed)),
|
||||
),
|
||||
)
|
||||
.and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from))
|
||||
})
|
||||
} else {
|
||||
let run_result = {
|
||||
let _sync_guard = sync_lock
|
||||
.lock()
|
||||
.unwrap_or_else(|poison| poison.into_inner());
|
||||
let progress_registry = registry.clone();
|
||||
let progress_id = job.id.clone();
|
||||
let cancel_check = Arc::clone(&cancel);
|
||||
let stop_control = Arc::clone(&control);
|
||||
service.run_with_progress_and_cancellation(
|
||||
&job.config,
|
||||
|event| {
|
||||
progress_registry.append_log(
|
||||
&progress_id,
|
||||
format!("[{}] {}", event.stage, event.message),
|
||||
);
|
||||
progress_registry.update(&progress_id, |record| {
|
||||
record.stage = Some(event.stage.to_string());
|
||||
record.message = Some(event.message.clone());
|
||||
});
|
||||
},
|
||||
|| {
|
||||
cancel_check.load(Ordering::Relaxed)
|
||||
|| daemon_control_stop_requested(Some(&stop_control))
|
||||
},
|
||||
)
|
||||
};
|
||||
let run_result = if job.kind == TaskKind::Verify {
|
||||
match run_result {
|
||||
Ok(report) => {
|
||||
bat_infrastructure::verify_and_record_official_distribution_attestation(
|
||||
&job.config,
|
||||
service.attestation_max_age_seconds(),
|
||||
)
|
||||
.map(|_| report)
|
||||
}
|
||||
Err(error) => {
|
||||
// If the verifier failed before returning its report
|
||||
// (for example, a malformed manifest), make a best
|
||||
// effort to revoke the previous ready generation.
|
||||
let _ =
|
||||
bat_infrastructure::verify_and_record_official_distribution_attestation(
|
||||
&job.config,
|
||||
service.attestation_max_age_seconds(),
|
||||
);
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
run_result
|
||||
};
|
||||
run_result
|
||||
.map(|report| serde_json::to_value(&report).map_err(anyhow::Error::from))
|
||||
.and_then(|result| result)
|
||||
};
|
||||
|
||||
match run_result {
|
||||
Ok(report) => registry.update(&job.id, |record| {
|
||||
record.status = "succeeded";
|
||||
record.finished_at = Some(unix_seconds_now());
|
||||
record.result = Some(report);
|
||||
}),
|
||||
Err(error) => {
|
||||
let cancelled =
|
||||
cancel.load(Ordering::Relaxed) || daemon_control_stop_requested(Some(&control));
|
||||
// 下载失败携带类型化 DownloadError(含准确网络域码);其余归 internal。
|
||||
let code = error
|
||||
.downcast_ref::<bat_infrastructure::DownloadError>()
|
||||
.map(bat_infrastructure::DownloadError::code)
|
||||
.unwrap_or(ErrorCode::INTERNAL);
|
||||
registry.update(&job.id, |record| {
|
||||
record.finished_at = Some(unix_seconds_now());
|
||||
if cancelled {
|
||||
record.status = "cancelled";
|
||||
record.error = Some(ApiError::new(
|
||||
ErrorCode::INTERNAL,
|
||||
"task.executor",
|
||||
"任务已取消",
|
||||
));
|
||||
} else {
|
||||
record.status = "failed";
|
||||
record.error =
|
||||
Some(ApiError::new(code, "task.executor", error.to_string()));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) const STARTUP_BANNER: &str = r#"
|
||||
=====================================================================================
|
||||
____ _ _ _ _ _____ _ _ _ _
|
||||
| __ )| |_ _ ___ / \ _ __ ___| |__ (_)_ _____|_ _|__ ___ | | | _(_) |_
|
||||
| _ \| | | | |/ _ \/ _ \ | '__/ __| '_ \| \ \ / / _ \ | |/ _ \ / _ \| | |/ / | __|
|
||||
| |_) | | |_| | __/ ___ \| | | (__| | | | |\ V / __/ | | (_) | (_) | | <| | |_
|
||||
|____/|_|\__,_|\__/_/ \_\_| \___|_| |_|_|\_/ \___/ |_|\___/ \___/|_|_|\_\_|\__|
|
||||
|
||||
BlueArchiveToolkit
|
||||
Official Resource Sync
|
||||
=====================================================================================
|
||||
"#;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ErrorReport {
|
||||
status: &'static str,
|
||||
exit_code: i32,
|
||||
error: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
next_retry_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
pub(super) fn print_fatal_error(error: String, exit_code: i32) -> ! {
|
||||
let payload = ErrorReport {
|
||||
status: "error",
|
||||
exit_code,
|
||||
error,
|
||||
next_retry_seconds: None,
|
||||
};
|
||||
eprintln!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&payload)
|
||||
.unwrap_or_else(|_| "{\"status\":\"error\"}".to_string())
|
||||
);
|
||||
std::process::exit(exit_code);
|
||||
}
|
||||
|
||||
pub(super) fn print_error_report(
|
||||
error: impl Into<String>,
|
||||
next_retry_seconds: Option<u64>,
|
||||
) -> anyhow::Result<()> {
|
||||
let payload = ErrorReport {
|
||||
status: "error",
|
||||
exit_code: EXIT_ERROR,
|
||||
error: error.into(),
|
||||
next_retry_seconds,
|
||||
};
|
||||
eprintln!("{}", serde_json::to_string_pretty(&payload)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn print_repeated_workflow_wait(
|
||||
command_name: &str,
|
||||
interval: Duration,
|
||||
completed_runs: usize,
|
||||
) {
|
||||
eprintln!(
|
||||
"{command_name} 下一轮将在 {} 后执行(已完成 {} 轮)",
|
||||
format_duration(interval),
|
||||
completed_runs
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn print_daemon_error(message: impl std::fmt::Display) {
|
||||
eprintln!("[daemon] {message}");
|
||||
}
|
||||
|
||||
pub(super) fn print_startup_banner() {
|
||||
eprintln!("{STARTUP_BANNER}");
|
||||
}
|
||||
|
||||
pub(super) fn print_config_template_created(path: &Path) {
|
||||
eprintln!(
|
||||
"已生成配置模板 {}(编辑 `config.toml`,`config.toml.example` 不会被程序自动读取)",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn print_config_template_warning(path: &Path, error: impl std::fmt::Display) {
|
||||
eprintln!(
|
||||
"警告:生成 config.toml.example 模板失败 {}:{error}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn print_deprecated_env_file_warning() {
|
||||
eprintln!("警告:BAT_SKIP_ENV_FILE 已废弃且不再影响启动,已忽略");
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct ProgressLogger {
|
||||
enabled: bool,
|
||||
started_at: Instant,
|
||||
structured: Option<RotatingStructuredLogger>,
|
||||
}
|
||||
|
||||
impl ProgressLogger {
|
||||
pub(super) fn new(enabled: bool) -> Self {
|
||||
Self {
|
||||
enabled,
|
||||
started_at: Instant::now(),
|
||||
structured: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn attach_structured_log(&mut self, path: PathBuf) {
|
||||
self.structured = Some(RotatingStructuredLogger::new(
|
||||
path,
|
||||
STRUCTURED_LOG_MAX_BYTES,
|
||||
STRUCTURED_LOG_ROTATE_KEEP,
|
||||
));
|
||||
}
|
||||
|
||||
pub(super) fn log(&mut self, event: OfficialUpdateProgress) {
|
||||
self.log_event(&event);
|
||||
}
|
||||
|
||||
pub(super) fn log_text(&mut self, stage: &str, message: impl AsRef<str>) {
|
||||
self.log_text_inner(stage, message.as_ref());
|
||||
}
|
||||
|
||||
fn log_event(&mut self, event: &OfficialUpdateProgress) {
|
||||
if self.enabled {
|
||||
print_progress_line(
|
||||
self.started_at.elapsed(),
|
||||
event.stage,
|
||||
event.message.as_str(),
|
||||
);
|
||||
}
|
||||
if let Some(structured) = self.structured.as_mut() {
|
||||
let _ = structured.write_event(self.started_at.elapsed(), event);
|
||||
}
|
||||
}
|
||||
|
||||
fn log_text_inner(&mut self, stage: &str, message: &str) {
|
||||
if !self.enabled {
|
||||
if let Some(structured) = self.structured.as_mut() {
|
||||
let event = OfficialUpdateProgress::new(stage_to_static(stage), message);
|
||||
let _ = structured.write_event(self.started_at.elapsed(), &event);
|
||||
}
|
||||
return;
|
||||
}
|
||||
print_progress_line(self.started_at.elapsed(), stage, message);
|
||||
if let Some(structured) = self.structured.as_mut() {
|
||||
let event = OfficialUpdateProgress::new(stage_to_static(stage), message);
|
||||
let _ = structured.write_event(self.started_at.elapsed(), &event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn print_progress_line(elapsed: Duration, stage: &str, message: &str) {
|
||||
eprintln!(
|
||||
"[+{} 信息] [{}] {}",
|
||||
format_duration(elapsed),
|
||||
localized_stage(stage),
|
||||
message
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct RotatingStructuredLogger {
|
||||
path: PathBuf,
|
||||
max_bytes: u64,
|
||||
keep: usize,
|
||||
}
|
||||
|
||||
impl RotatingStructuredLogger {
|
||||
pub(super) fn new(path: PathBuf, max_bytes: u64, keep: usize) -> Self {
|
||||
Self {
|
||||
path,
|
||||
max_bytes,
|
||||
keep,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn write_event(
|
||||
&mut self,
|
||||
elapsed: Duration,
|
||||
event: &OfficialUpdateProgress,
|
||||
) -> anyhow::Result<()> {
|
||||
let payload = serde_json::json!({
|
||||
"timestamp_unix_seconds": unix_seconds_now(),
|
||||
"elapsed_ms": elapsed.as_millis(),
|
||||
"level": "info",
|
||||
"stage": event.stage,
|
||||
"stage_label": localized_stage(event.stage),
|
||||
"status_code": event.status_code.as_str(),
|
||||
"status_phase": event.status_code.phase(),
|
||||
"message": event.message.as_str(),
|
||||
"download": event.download_index.map(|index| serde_json::json!({
|
||||
"index": index,
|
||||
"total": event.download_total.unwrap_or(index),
|
||||
"url": event.download_url.as_deref(),
|
||||
"status": event.download_status.as_deref(),
|
||||
"bytes": event.download_bytes,
|
||||
"transferred_bytes": event.download_transferred_bytes,
|
||||
"failure_kind": event.download_failure_kind.as_deref(),
|
||||
"failure_http_status": event.download_failure_http_status,
|
||||
"failure_retryable": event.download_failure_retryable,
|
||||
"failure_attempts": event.download_failure_attempts,
|
||||
"quarantined": event.download_quarantined,
|
||||
"verification": event.download_verification,
|
||||
"official_hash": event.official_hash_verification,
|
||||
})),
|
||||
});
|
||||
let mut line = serde_json::to_vec(&payload)?;
|
||||
line.push(b'\n');
|
||||
self.rotate_if_needed(line.len() as u64)?;
|
||||
let mut file = open_append_file(&self.path, PRIVATE_FILE_MODE, "结构化日志")
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
file.write_all(&line)?;
|
||||
file.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rotate_if_needed(&self, incoming_bytes: u64) -> anyhow::Result<()> {
|
||||
let current_len = match fs::symlink_metadata(&self.path) {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"结构化日志不能是 symlink:{}",
|
||||
self.path.display()
|
||||
))
|
||||
}
|
||||
Ok(metadata) if metadata.is_file() => metadata.len(),
|
||||
Ok(_) => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"结构化日志已存在但不是普通文件:{}",
|
||||
self.path.display()
|
||||
))
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => 0,
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
if current_len.saturating_add(incoming_bytes) <= self.max_bytes {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for index in (1..=self.keep).rev() {
|
||||
let from = if index == 1 {
|
||||
self.path.clone()
|
||||
} else {
|
||||
rotated_structured_log_path(&self.path, index - 1)
|
||||
};
|
||||
let to = rotated_structured_log_path(&self.path, index);
|
||||
if !path_exists_no_follow(&from)? {
|
||||
continue;
|
||||
}
|
||||
if path_exists_no_follow(&to)? {
|
||||
fs::remove_file(&to)?;
|
||||
}
|
||||
fs::rename(&from, &to)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn stage_to_static(stage: &str) -> &'static str {
|
||||
match stage {
|
||||
"start" => "start",
|
||||
"lock" => "lock",
|
||||
"bootstrap" => "bootstrap",
|
||||
"launcher" => "launcher",
|
||||
"bootstrap-cache" => "bootstrap-cache",
|
||||
"game-main-config" => "game-main-config",
|
||||
"metadata" => "metadata",
|
||||
"server-info" => "server-info",
|
||||
"discovery" => "discovery",
|
||||
"markers" => "markers",
|
||||
"marker" => "marker",
|
||||
"catalog" => "catalog",
|
||||
"local-state" => "local-state",
|
||||
"audit" => "audit",
|
||||
"decision" => "decision",
|
||||
"plan" => "plan",
|
||||
"download" => "download",
|
||||
"snapshot" => "snapshot",
|
||||
"publish" => "publish",
|
||||
"parse" => "parse",
|
||||
"finish" => "finish",
|
||||
"watch" => "watch",
|
||||
"daemon" => "daemon",
|
||||
"dry-run" => "dry-run",
|
||||
_ => "log",
|
||||
}
|
||||
}
|
||||
|
||||
fn localized_stage(stage: &str) -> &str {
|
||||
match stage {
|
||||
"start" => "启动",
|
||||
"lock" => "锁",
|
||||
"bootstrap" => "启动发现",
|
||||
"launcher" => "启动器",
|
||||
"bootstrap-cache" => "启动缓存",
|
||||
"game-main-config" => "游戏配置",
|
||||
"metadata" => "元数据",
|
||||
"server-info" => "服务器信息",
|
||||
"discovery" => "发现",
|
||||
"markers" => "标记",
|
||||
"marker" => "标记",
|
||||
"snapshot" => "快照",
|
||||
"decision" => "决策",
|
||||
"plan" => "计划",
|
||||
"catalog" => "目录",
|
||||
"inventory" => "清单",
|
||||
"local-state" => "本地状态",
|
||||
"audit" => "审计",
|
||||
"dry-run" => "试运行",
|
||||
"download" => "下载",
|
||||
"publish" => "发布",
|
||||
"parse" => "解析",
|
||||
"resource" => "资源",
|
||||
"finish" => "完成",
|
||||
"watch" => "常驻",
|
||||
"daemon" => "后台",
|
||||
_ => stage,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn print_usage(binary: &str) {
|
||||
let usage = format!(
|
||||
r#"BlueArchiveToolkit official resource sync
|
||||
|
||||
Usage:
|
||||
{binary} [OPTIONS]
|
||||
{binary} <COMMAND> [OPTIONS]
|
||||
|
||||
Commands:
|
||||
res pull Pull official resources once or repeatedly
|
||||
res schedule Manage resource pull schedules (CLI/RPC/dashboard)
|
||||
parse run Parse current official release
|
||||
parse clear-cache Clear regenerable parse and translation queue files
|
||||
parse repack Repack a UnityFS bundle from a JSON spec
|
||||
parse schedule Manage parse schedules
|
||||
i18n run Refresh offline translation work
|
||||
i18n export Export an editable translation workbench
|
||||
i18n set Update one translation workbench entry
|
||||
i18n get Show one translation workbench entry
|
||||
i18n unset Clear one translated workbench entry
|
||||
i18n validate Validate workbench against the current official release
|
||||
i18n proofread Mark localized workflow as manual proofreading
|
||||
i18n worker run Run translation provider worker once or repeatedly
|
||||
i18n tasks / i18n task list / i18n task status Query current offline TextUnit translation task status
|
||||
i18n handoff Query current translation handoff
|
||||
i18n status Show localized release status for current official release
|
||||
i18n task update Update one provider worker task status
|
||||
i18n glossary summary/query Show project Glossary terms and review counts
|
||||
i18n glossary add/update Add or replace one Glossary term definition
|
||||
i18n glossary approve/deprecate Review one Glossary term
|
||||
i18n glossary delete Remove one Glossary term with reviewer and reason
|
||||
i18n glossary diagnose Run deterministic Glossary QA for one TextUnit source
|
||||
i18n publish Publish a localized release from a workbench or worker results
|
||||
i18n rollback Roll back the current localized release
|
||||
i18n schedule Manage translation schedules
|
||||
refresh Run one update check, or ask a live daemon to refresh
|
||||
verify Verify remote plan, local manifest, and official seed hashes
|
||||
repair Redownload resources that fail local verification
|
||||
parse-status Show current official parse-cache status
|
||||
parse-text-units Query current official TextUnit detail index
|
||||
parse-errors Query current official parse/extraction diagnostics
|
||||
translation-tasks Query current offline TextUnit translation task status
|
||||
translation-handoff Query current translation job/unit/provider handoff
|
||||
localized-status Show localized release status for current official release
|
||||
localized-rollback Roll back the current localized release
|
||||
resource-index Query CAS + ResourceRepository index
|
||||
patch-apply Apply a Binary/JSON/Text patch file
|
||||
unityfs-patch-text-asset Patch one UnityFS TextAsset object
|
||||
unityfs-patch-string-field Patch one UnityFS TypeTree string field
|
||||
unityfs-patch-field Patch one UnityFS TypeTree field with semantic JSON
|
||||
status Show daemon state
|
||||
stop Stop daemon
|
||||
restart Restart daemon, reusing saved args unless explicit args are passed
|
||||
reload Ask daemon to rediscover metadata and force refresh
|
||||
logs Show daemon log tail
|
||||
doctor Run runtime diagnostics
|
||||
doctor cas Inspect local CAS storage
|
||||
clean-stable Remove .part/.tmp/stale lock, pid, and socket files
|
||||
|
||||
Examples:
|
||||
{binary} --auto-discover --dry-run
|
||||
{binary} --auto-discover --watch
|
||||
{binary} --auto-discover --daemon
|
||||
{binary} res pull --auto-discover --run-count 3 --interval 1h
|
||||
{binary} parse run --force --resource-root /tmp/bat-release
|
||||
{binary} parse schedule list --state-dir /tmp/bat-schedule
|
||||
{binary} i18n export --translation-file /tmp/bat-workbench.json
|
||||
{binary} i18n get --translation-file /tmp/bat-workbench.json --translation-id unit-1
|
||||
{binary} i18n unset --translation-file /tmp/bat-workbench.json --translation-id unit-1
|
||||
{binary} i18n proofread --json
|
||||
{binary} i18n worker run --provider mock --worker-concurrency 8 --run-count 2 --interval 30s
|
||||
{binary} i18n glossary diagnose --glossary-source-text Sensei --json
|
||||
{binary} i18n tasks --json
|
||||
{binary} i18n handoff --json
|
||||
{binary} i18n status --json
|
||||
{binary} i18n publish --translation-file /tmp/bat-workbench.json --force
|
||||
{binary} i18n publish --from-worker --localized-release-id release-manual-1
|
||||
{binary} i18n rollback --localized-release-id release-manual-1
|
||||
{binary} status
|
||||
{binary} refresh --force --json
|
||||
|
||||
Discovery:
|
||||
--auto-discover Discover app-version, server-info, connection-group
|
||||
--server-info-url <URL> Use an official server-info URL
|
||||
--server-info-file <NAME> Use an official server-info file name
|
||||
--server-info-path <PATH> Use a local server-info JSON file
|
||||
--app-version <VERSION> Override app version
|
||||
--connection-group <NAME> Override connection group
|
||||
--launcher-version <VERSION> Launcher metadata API version (default: 1.7.2)
|
||||
|
||||
Sync:
|
||||
--platforms <LIST> Platforms, e.g. Windows,Android
|
||||
--output <DIR> Official resource publish root (default: ./bat-resources)
|
||||
--localized-output <DIR> Localized output root (default: ./bat-localized)
|
||||
--import-repository Import verified release into CAS + ResourceRepository
|
||||
--no-import-repository Disable CAS + ResourceRepository import
|
||||
--import-cas-root <DIR> CAS root for official release imports
|
||||
--import-resource-db <PATH> SQLite ResourceRepository path
|
||||
--snapshot <PATH> Override snapshot path (default: <output>/current/official-sync-snapshot.json)
|
||||
--curl <PATH> curl executable (default: curl)
|
||||
--download-concurrency <N> Bounded parallel downloads (default: 8, range 1..=256)
|
||||
--proxy <URL|auto|none> curl proxy override (default: auto from env)
|
||||
--no-proxy Force direct curl connections
|
||||
--unzip <PATH> unzip executable (default: unzip)
|
||||
--zip <PATH> zip executable (default: zip)
|
||||
--dry-run Do not write sync state
|
||||
--plan Include planned URLs in dry-run
|
||||
--force Force download/refresh
|
||||
--audit-local | --no-audit-local Enable/disable local manifest audit
|
||||
--repair | --no-repair Enable/disable automatic repair
|
||||
--run-count <N> Run pull/parse/translate/publish N times
|
||||
--once Explicitly select one run
|
||||
--resource-root <DIR> Use an explicit published official release root
|
||||
--translation-file <PATH> / --workbench <PATH> Translation workbench JSON file
|
||||
--translation-id <ID> TextUnit ID for i18n set
|
||||
--translated-text <TEXT> Inline translation for i18n set
|
||||
--translated-file <PATH> UTF-8 translation file for i18n set
|
||||
--from-worker Build publish input from completed provider worker results
|
||||
--failure-reason <TEXT> Provider failure reason for i18n task update
|
||||
--provider-run-id <ID> Provider run ID for i18n task update
|
||||
--translation-provider <NAME> / --provider <NAME> Provider for i18n worker run (mock/crowdin)
|
||||
--translation-fixture <PATH> Mock/provider fixture for i18n worker run
|
||||
--glossary-path <PATH> Project Glossary SQLite path
|
||||
--glossary-term-id <ID> Glossary term ID for add/update/review/delete
|
||||
--glossary-source-term <TEXT> Source spelling for a Glossary term
|
||||
--glossary-recommended-translation <TEXT> Recommended target translation
|
||||
--glossary-source-text <TEXT> Source TextUnit text for Glossary query/diagnose
|
||||
--glossary-context-json <JSON> TextUnit context for Glossary diagnose
|
||||
--glossary-reviewer <ID> Reviewer for Glossary updates/reviews/delete
|
||||
--glossary-reason <TEXT> Reason for Glossary review/delete or override
|
||||
--glossary-provenance <TEXT> Provenance for an explicit Glossary override
|
||||
--glossary-qa-identity <ID> Current blocking Glossary QA identity for an override
|
||||
--worker-concurrency <N> Translation worker concurrency (default: 8, range 1..=256)
|
||||
--worker-max-attempts <N> Maximum claims per translation task
|
||||
--worker-lease-seconds <N> Lease seconds for one claimed task
|
||||
--worker-retry-backoff <DURATION> Retry backoff after retryable failure
|
||||
--worker-retry-backoff-seconds <N> Retry backoff seconds
|
||||
--worker-max-tasks <N> Maximum tasks claimed in one worker run
|
||||
--worker-id <ID> Worker ID prefix for lease diagnostics
|
||||
--localized-release-id <ID> Explicit localized publication ID
|
||||
--repack-spec <PATH> UnityFS batch repack JSON spec
|
||||
|
||||
Read-only queries:
|
||||
--offset <N> Query offset for resource-index/parse-text-units/parse-errors/translation-tasks
|
||||
--limit <N> Query limit for resource-index/parse-text-units/parse-errors/translation-tasks (1..=1000)
|
||||
--task-id <ID> Filter translation-tasks by stable task ID
|
||||
--resource-type <TYPE> asset_bundle, manifest, table_bundle, text_asset, media, other
|
||||
--hash <HASH> Filter resource-index by full CAS hash
|
||||
--path-pattern <GLOB> Filter resource-index or parse detail by path pattern
|
||||
--release-id <ID> Filter resource-index or translation-tasks by official release ID
|
||||
--platform <NAME> Filter resource-index by metadata platform
|
||||
--destination <PATH> Filter resource-index, parse detail, or translation-tasks by official destination
|
||||
--bundle-path <PATH> Filter resource-index by metadata bundle path
|
||||
--archive-entry <PATH> Filter resource-index, parse detail, or translation-tasks by ZIP/archive entry
|
||||
--task-status <STATUS> Filter translation-tasks by task status
|
||||
--worker-status <STATUS> Filter translation-tasks by provider worker status
|
||||
--parse-status <STATUS> Filter resource-index or translation-tasks by parse status
|
||||
--path-id <ID> Filter parse detail by Unity object path ID
|
||||
--class-id <ID> Filter parse detail by Unity class ID
|
||||
--field-path <PATH> Filter parse detail, or TypeTree field path after UnityFS field patch commands
|
||||
--format <NAME> Filter resource-index, parse text units, or translation-tasks by payload format
|
||||
--has-reason | --no-reason Filter translation-tasks by diagnostic reason presence
|
||||
--has-failure-reason | --no-failure-reason Filter translation-tasks by provider failure reason
|
||||
|
||||
Write patch:
|
||||
--patch-kind <binary|json|text> Patch type for patch-apply
|
||||
--source-file <PATH> Source file for patch-apply
|
||||
--patch-file <PATH> Patch JSON file for patch-apply
|
||||
--bundle-file <PATH> Source UnityFS bundle file
|
||||
--serialized-file <PATH> Serialized file path inside UnityFS
|
||||
--object-path-id <ID> Unity object path ID for UnityFS patch
|
||||
--string-field-path <PATH> Deprecated alias for UnityFS TypeTree field path
|
||||
--replacement-file <PATH> Replacement bytes or UTF-8 string file
|
||||
--replacement-text <TEXT> Inline replacement text for string-field patch
|
||||
--replacement-json <JSON> Semantic replacement, e.g. signed/enum/bit_field JSON
|
||||
--expected-name <NAME> Expected TextAsset name
|
||||
--expected-value <TEXT> Expected source string value
|
||||
--expected-json <JSON> Optional expected semantic source value
|
||||
--target-file <PATH> Target output file written atomically
|
||||
|
||||
Daemon:
|
||||
--watch Run in foreground loop
|
||||
--daemon Start detached watch process
|
||||
--state-dir <DIR> Daemon state dir (default: /tmp/bat-pid)
|
||||
--interval <DURATION> Normal check interval (default: 1h)
|
||||
--error-retry <DURATION> Retry interval after error (default: 60s)
|
||||
--quiet-up-to-date Suppress clean up-to-date reports
|
||||
--no-quiet-up-to-date Always print reports
|
||||
--tail <N> Log lines for logs command (default: 200)
|
||||
--schedule-id <ID> / --id <ID> Schedule identifier
|
||||
--schedule-action <ACTION> / --action <ACTION> Schedule action (pull/run/repack/publish)
|
||||
--schedule-at-unix <SECONDS> First execution time
|
||||
--schedule-delay <DURATION> Delay first execution from now
|
||||
--schedule-every <DURATION> Period between executions
|
||||
--schedule-count <N> Bounded execution count
|
||||
--schedule-max-runs <N> Maximum plans executed by one schedule run
|
||||
--schedule-arg <ARG> Argument passed to scheduled child command
|
||||
--schedule-clear-args Clear args during schedule update
|
||||
--schedule-clear-every Convert a periodic plan to one-shot
|
||||
--schedule-enabled/--schedule-disabled Enable/disable a schedule
|
||||
|
||||
Output:
|
||||
--human Human-readable output (default)
|
||||
--json Stable JSON output for scripts
|
||||
--progress | --no-progress Enable/disable stderr progress logs
|
||||
--banner | --no-banner Enable/disable startup banner
|
||||
-h, --help Show this help
|
||||
|
||||
Defaults:
|
||||
platforms: Windows,Android
|
||||
official resource output: ./bat-resources (current -> versions/<id>, .staging/<id>)
|
||||
localized output: ./bat-localized (separate patch/export target)
|
||||
daemon state: /tmp/bat-pid (bat.sock, bat.pid, bat-status.json, bat-daemon.log, bat-events.jsonl)
|
||||
forced refresh: {DAILY_FORCED_REFRESH_LABEL}
|
||||
"#,
|
||||
binary = binary,
|
||||
DAILY_FORCED_REFRESH_LABEL = DAILY_FORCED_REFRESH_LABEL,
|
||||
);
|
||||
eprint!("{usage}");
|
||||
}
|
||||
@@ -0,0 +1,821 @@
|
||||
use super::report_output::print_json_value;
|
||||
use super::*;
|
||||
use bat_core::domain::{validate_glossary_override, GlossaryOverride, TranslationMemoryContext};
|
||||
use bat_core::repositories::TranslationMemoryRepository;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TranslationTaskResultUpdateParam {
|
||||
unit_id: String,
|
||||
source_text: String,
|
||||
translated_text: String,
|
||||
#[serde(default)]
|
||||
glossary_override: Option<GlossaryOverride>,
|
||||
}
|
||||
|
||||
pub(super) fn build_translation_tasks_report(
|
||||
state_dir: &Path,
|
||||
query: OfficialTextUnitTaskQuery,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let (_, version_state) = read_daemon_resource_state(state_dir)?;
|
||||
let current = version_state
|
||||
.as_ref()
|
||||
.and_then(|state| state.current_completed_version.as_ref());
|
||||
let Some(record) = current else {
|
||||
return Ok(serde_json::json!({ "available": false }));
|
||||
};
|
||||
let task_queue_path = record
|
||||
.resource_root
|
||||
.join(bat_infrastructure::OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE);
|
||||
let task_repository_path =
|
||||
SqliteTranslationTaskRepository::repository_path(&record.resource_root);
|
||||
let Some(queue) = bat_infrastructure::read_textunit_task_queue_at(&record.resource_root)
|
||||
.map_err(anyhow::Error::msg)?
|
||||
else {
|
||||
return Ok(serde_json::json!({
|
||||
"available": false,
|
||||
"current_version_id": record.id,
|
||||
"resource_root": record.resource_root,
|
||||
"textunit_task_queue_path": task_queue_path,
|
||||
"task_repository_path": task_repository_path,
|
||||
"task_repository_available": false,
|
||||
}));
|
||||
};
|
||||
let task_repository_available =
|
||||
sqlite_file_exists_no_symlink(&task_repository_path, "翻译任务状态数据库")?;
|
||||
let (total_entries, entries) = if task_repository_available {
|
||||
query_translation_task_repository(&task_repository_path, &query, offset, limit)?
|
||||
} else {
|
||||
let mut queue_query = query.clone();
|
||||
queue_query.task_status = None;
|
||||
queue_query.has_failure_reason = None;
|
||||
let matches = bat_infrastructure::query_textunit_tasks(&queue, &queue_query);
|
||||
let persisted = matches
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.map(|task| {
|
||||
bat_infrastructure::PersistedTranslationTask::from_queued_task(
|
||||
task,
|
||||
queue.generated_unix_seconds,
|
||||
)
|
||||
})
|
||||
.filter(|task| {
|
||||
query
|
||||
.task_status
|
||||
.as_ref()
|
||||
.is_none_or(|status| task.task_status.as_str() == status)
|
||||
})
|
||||
.filter(|task| {
|
||||
query
|
||||
.has_failure_reason
|
||||
.is_none_or(|has_reason| task.failure_reason.is_some() == has_reason)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let total_entries = persisted.len();
|
||||
let entries = persisted
|
||||
.into_iter()
|
||||
.skip(offset)
|
||||
.take(limit)
|
||||
.collect::<Vec<_>>();
|
||||
(total_entries as u64, entries)
|
||||
};
|
||||
Ok(serde_json::json!({
|
||||
"available": true,
|
||||
"current_version_id": record.id,
|
||||
"resource_root": record.resource_root,
|
||||
"textunit_task_queue_path": task_queue_path,
|
||||
"task_repository_path": task_repository_path,
|
||||
"task_repository_available": task_repository_available,
|
||||
"task_repository_schema_version": bat_infrastructure::TRANSLATION_TASK_SCHEMA_VERSION,
|
||||
"summary": queue.summary,
|
||||
"total_entries": total_entries,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"query": translation_task_query_json(&query),
|
||||
"entries": entries,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn build_translation_handoff_report(
|
||||
state_dir: &Path,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let (_, version_state) = read_daemon_resource_state(state_dir)?;
|
||||
let current = version_state
|
||||
.as_ref()
|
||||
.and_then(|state| state.current_completed_version.as_ref());
|
||||
let Some(record) = current else {
|
||||
return Ok(serde_json::json!({ "available": false }));
|
||||
};
|
||||
let resource_root = &record.resource_root;
|
||||
let task_queue_path = resource_root.join(bat_infrastructure::OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE);
|
||||
let handoff_path = resource_root.join(bat_infrastructure::TRANSLATION_HANDOFF_FILE);
|
||||
let repository_path = SqliteTranslationTaskRepository::repository_path(resource_root);
|
||||
let Some(queue) = bat_infrastructure::read_textunit_task_queue_at(resource_root)
|
||||
.map_err(anyhow::Error::msg)?
|
||||
else {
|
||||
return Ok(serde_json::json!({
|
||||
"available": false,
|
||||
"current_version_id": record.id,
|
||||
"resource_root": resource_root,
|
||||
"textunit_task_queue_path": task_queue_path,
|
||||
"translation_handoff_path": handoff_path,
|
||||
"task_repository_path": repository_path,
|
||||
}));
|
||||
};
|
||||
let task_repository_available =
|
||||
sqlite_file_exists_no_symlink(&repository_path, "翻译任务状态数据库")?;
|
||||
let tasks = if task_repository_available {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
runtime.block_on(async {
|
||||
let repository = SqliteTranslationTaskRepository::open(&repository_path)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||
repository
|
||||
.list(&OfficialTextUnitTaskQuery::default())
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))
|
||||
})?
|
||||
} else {
|
||||
queue
|
||||
.tasks
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|task| {
|
||||
bat_infrastructure::PersistedTranslationTask::from_queued_task(
|
||||
task,
|
||||
queue.generated_unix_seconds,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let handoff = bat_infrastructure::build_translation_handoff(&queue, &tasks);
|
||||
let handoff_file_available = sqlite_file_exists_no_symlink(&handoff_path, "翻译 handoff")?;
|
||||
Ok(serde_json::json!({
|
||||
"available": true,
|
||||
"current_version_id": record.id,
|
||||
"resource_root": resource_root,
|
||||
"textunit_task_queue_path": task_queue_path,
|
||||
"translation_handoff_path": handoff_path,
|
||||
"translation_handoff_file_available": handoff_file_available,
|
||||
"task_repository_path": repository_path,
|
||||
"task_repository_available": task_repository_available,
|
||||
"task_repository_schema_version": bat_infrastructure::TRANSLATION_TASK_SCHEMA_VERSION,
|
||||
"handoff_schema_version": bat_infrastructure::TRANSLATION_HANDOFF_SCHEMA_VERSION,
|
||||
"handoff": handoff,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn update_translation_task_status_report(
|
||||
state_dir: &Path,
|
||||
params: Option<&serde_json::Value>,
|
||||
configured_glossary_path: Option<&Path>,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let task_id = rpc_string_param(params, "task_id")
|
||||
.ok_or_else(|| anyhow::anyhow!("translation.task.update 缺少 task_id"))?;
|
||||
let status_label = rpc_string_param(params, "status")
|
||||
.ok_or_else(|| anyhow::anyhow!("translation.task.update 缺少 status"))?;
|
||||
let status = TranslationTaskStatus::parse(status_label)
|
||||
.ok_or_else(|| anyhow::anyhow!("不支持的翻译任务 worker 状态:{status_label}"))?;
|
||||
let failure_reason = rpc_string_param(params, "failure_reason")
|
||||
.or_else(|| rpc_string_param(params, "reason"))
|
||||
.map(str::to_string);
|
||||
let provider_run_id = rpc_string_param(params, "provider_run_id").map(str::to_string);
|
||||
let provider = rpc_string_param(params, "provider").map(str::to_string);
|
||||
let result_params = translation_task_result_params(params)?;
|
||||
if !result_params.is_empty() && status != TranslationTaskStatus::Completed {
|
||||
return Err(anyhow::anyhow!(
|
||||
"translation_results 只能随 completed 状态写入"
|
||||
));
|
||||
}
|
||||
let (_, version_state) = read_daemon_resource_state(state_dir)?;
|
||||
let current = version_state
|
||||
.as_ref()
|
||||
.and_then(|state| state.current_completed_version.as_ref())
|
||||
.ok_or_else(|| anyhow::anyhow!("没有可更新翻译任务的当前官方 release"))?;
|
||||
let repository_path = SqliteTranslationTaskRepository::repository_path(¤t.resource_root);
|
||||
if !sqlite_file_exists_no_symlink(&repository_path, "翻译任务状态数据库")? {
|
||||
return Err(anyhow::anyhow!(
|
||||
"翻译任务状态数据库不存在:{}",
|
||||
repository_path.display()
|
||||
));
|
||||
}
|
||||
let textunit_index = if result_params.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
read_textunit_index_at(¤t.resource_root)
|
||||
.map_err(anyhow::Error::msg)?
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("当前 release 缺少 TextUnit 明细索引,无法校验人工校对结果")
|
||||
})?,
|
||||
)
|
||||
};
|
||||
let result_provider = provider.clone().unwrap_or_else(|| "manual".to_string());
|
||||
let result_timestamp = unix_seconds_now();
|
||||
let result_provider_run_id = provider_run_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("manual-{result_timestamp}"));
|
||||
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
let task = runtime.block_on(async {
|
||||
let repository = SqliteTranslationTaskRepository::open(&repository_path)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||
if let Some(index) = textunit_index.as_ref() {
|
||||
let current_task = repository
|
||||
.find(task_id)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||
let glossary_path = configured_glossary_path
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
bat_infrastructure::SqliteGlossaryRepository::repository_path(
|
||||
¤t.resource_root,
|
||||
)
|
||||
});
|
||||
let glossary = if std::fs::symlink_metadata(&glossary_path).is_ok() {
|
||||
Some(
|
||||
bat_infrastructure::SqliteGlossaryRepository::open(&glossary_path)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("打开 Glossary 数据库失败:{error}"))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let results = build_manual_translation_results(
|
||||
¤t_task,
|
||||
index,
|
||||
&result_params,
|
||||
&result_provider,
|
||||
&result_provider_run_id,
|
||||
result_timestamp,
|
||||
glossary.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
repository
|
||||
.update_status_with_results(
|
||||
task_id,
|
||||
status,
|
||||
failure_reason,
|
||||
Some(result_provider_run_id),
|
||||
Some(result_provider),
|
||||
Some(&results),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))
|
||||
} else {
|
||||
repository
|
||||
.update_status(task_id, status, failure_reason, provider_run_id)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))
|
||||
}
|
||||
})?;
|
||||
Ok(serde_json::json!({
|
||||
"available": true,
|
||||
"current_version_id": current.id,
|
||||
"task_repository_path": repository_path,
|
||||
"entry": task,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn textunit_query_json(query: &OfficialTextUnitQuery) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"destination": query.destination.clone(),
|
||||
"path_pattern": query.path_pattern.clone(),
|
||||
"archive_entry": query.archive_entry.clone(),
|
||||
"path_id": query.path_id,
|
||||
"class_id": query.class_id,
|
||||
"field_path": query.field_path.clone(),
|
||||
"format": query.format.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn translation_task_result_params(
|
||||
params: Option<&serde_json::Value>,
|
||||
) -> anyhow::Result<Vec<TranslationTaskResultUpdateParam>> {
|
||||
let Some(value) = params
|
||||
.and_then(|params| params.get("translation_results"))
|
||||
.or_else(|| params.and_then(|params| params.get("results")))
|
||||
else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
if value.is_null() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
serde_json::from_value(value.clone())
|
||||
.map_err(|error| anyhow::anyhow!("translation_results 必须是结果数组:{error}"))
|
||||
}
|
||||
|
||||
async fn build_manual_translation_results(
|
||||
task: &bat_infrastructure::PersistedTranslationTask,
|
||||
index: &bat_infrastructure::OfficialTextUnitIndex,
|
||||
params: &[TranslationTaskResultUpdateParam],
|
||||
provider: &str,
|
||||
provider_run_id: &str,
|
||||
translated_unix_seconds: u64,
|
||||
glossary: Option<&bat_infrastructure::SqliteGlossaryRepository>,
|
||||
) -> anyhow::Result<Vec<bat_infrastructure::TranslationTaskUnitResult>> {
|
||||
let index_by_id = index
|
||||
.units
|
||||
.iter()
|
||||
.map(|unit| (unit.id.as_str(), unit))
|
||||
.collect::<std::collections::BTreeMap<_, _>>();
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let mut results = Vec::with_capacity(params.len());
|
||||
for param in params {
|
||||
let unit_id = param.unit_id.trim();
|
||||
if unit_id.is_empty() {
|
||||
return Err(anyhow::anyhow!("translation_results[].unit_id 不能为空"));
|
||||
}
|
||||
if !seen.insert(unit_id.to_string()) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"translation_results 包含重复 TextUnit:{unit_id}"
|
||||
));
|
||||
}
|
||||
let unit = index_by_id
|
||||
.get(unit_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("translation_results 引用了未知 TextUnit:{unit_id}"))?;
|
||||
if unit.destination != task.task.destination
|
||||
|| unit.archive_entry != task.task.archive_entry
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"TextUnit {unit_id} 不属于翻译任务 {}",
|
||||
task.task.task_id
|
||||
));
|
||||
}
|
||||
if param.source_text != unit.source_text {
|
||||
return Err(anyhow::anyhow!(
|
||||
"TextUnit {unit_id} 的 source_text 与当前索引不一致"
|
||||
));
|
||||
}
|
||||
let glossary_qa = if let Some(glossary) = glossary {
|
||||
let context = bat_infrastructure::translation_memory_context(
|
||||
&unit.destination,
|
||||
unit.archive_entry.as_deref(),
|
||||
unit.serialized_file.as_deref(),
|
||||
unit.path_id,
|
||||
unit.class_id,
|
||||
unit.field_path.as_deref(),
|
||||
unit.format.as_deref(),
|
||||
unit.asset_name.as_deref(),
|
||||
unit.text_source_kind.as_deref(),
|
||||
&unit.context,
|
||||
);
|
||||
Some(
|
||||
glossary
|
||||
.diagnose(&unit.source_text, &context)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("Glossary QA 失败:{error}"))?
|
||||
.check_translation(¶m.translated_text),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(qa) = glossary_qa.as_ref() {
|
||||
if qa.status.is_blocked() {
|
||||
validate_glossary_override(qa, param.glossary_override.as_ref()).map_err(
|
||||
|error| {
|
||||
anyhow::anyhow!("TextUnit {} 的 glossary_override 无效:{error}", unit_id)
|
||||
},
|
||||
)?;
|
||||
} else if param.glossary_override.is_some() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"TextUnit {} 不能为非 blocking Glossary QA 指定 override",
|
||||
unit_id
|
||||
));
|
||||
}
|
||||
} else if param.glossary_override.is_some() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"TextUnit {} 不能为非 blocking Glossary QA 指定 override",
|
||||
unit_id
|
||||
));
|
||||
}
|
||||
results.push(bat_infrastructure::TranslationTaskUnitResult {
|
||||
unit_id: unit_id.to_string(),
|
||||
source_text: param.source_text.clone(),
|
||||
translated_text: param.translated_text.clone(),
|
||||
source_kind: bat_infrastructure::TranslationTaskResultSourceKind::Manual,
|
||||
translation_memory_record_id: None,
|
||||
provider: provider.to_string(),
|
||||
provider_run_id: provider_run_id.to_string(),
|
||||
translated_unix_seconds,
|
||||
glossary_qa,
|
||||
glossary_override: param.glossary_override.clone(),
|
||||
});
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
pub(super) fn translation_task_query_json(query: &OfficialTextUnitTaskQuery) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"task_id": query.task_id.clone(),
|
||||
"official_release_id": query.official_release_id.clone(),
|
||||
"destination": query.destination.clone(),
|
||||
"path_pattern": query.path_pattern.clone(),
|
||||
"archive_entry": query.archive_entry.clone(),
|
||||
"status": query.status.clone(),
|
||||
"task_status": query.task_status.clone(),
|
||||
"parse_status": query.parse_status.clone(),
|
||||
"text_unit_format": query.text_unit_format.clone(),
|
||||
"has_reason": query.has_reason,
|
||||
"has_failure_reason": query.has_failure_reason,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn run_translation_memory_command(options: &CliOptions) -> anyhow::Result<()> {
|
||||
let method = match options.command {
|
||||
CliCommand::TranslationMemorySummary => RPC_METHOD_TRANSLATION_MEMORY_SUMMARY,
|
||||
CliCommand::TranslationMemoryQuery => RPC_METHOD_TRANSLATION_MEMORY_QUERY,
|
||||
CliCommand::TranslationMemoryConfirm => RPC_METHOD_TRANSLATION_MEMORY_CONFIRM,
|
||||
CliCommand::TranslationMemoryConflicts => RPC_METHOD_TRANSLATION_MEMORY_CONFLICTS,
|
||||
CliCommand::TranslationMemoryResolveConflict => {
|
||||
RPC_METHOD_TRANSLATION_MEMORY_RESOLVE_CONFLICT
|
||||
}
|
||||
_ => return Err(anyhow::anyhow!("不是 Translation Memory 命令")),
|
||||
};
|
||||
if daemon_rpc_available(&options.state_dir)
|
||||
&& options.resource_root.is_none()
|
||||
&& !options.output_explicit
|
||||
{
|
||||
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
|
||||
let report = daemon_rpc_call(
|
||||
&options.state_dir,
|
||||
method,
|
||||
translation_memory_cli_params(options)?,
|
||||
)?;
|
||||
print_json_value(options.output_format, &report)?;
|
||||
return Ok(());
|
||||
}
|
||||
let path = translation_memory_cli_path(options)?;
|
||||
let report = match options.command {
|
||||
CliCommand::TranslationMemorySummary => build_translation_memory_summary_report(&path)?,
|
||||
CliCommand::TranslationMemoryQuery => {
|
||||
let source_text = options
|
||||
.translation_memory_source_text
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("TM query 必须指定 --tm-source-text"))?;
|
||||
let context = parse_translation_memory_context(
|
||||
options.translation_memory_context_json.as_deref(),
|
||||
)?;
|
||||
build_translation_memory_query_report(
|
||||
&path,
|
||||
source_text,
|
||||
&context,
|
||||
options.query_limit,
|
||||
)?
|
||||
}
|
||||
CliCommand::TranslationMemoryConfirm => {
|
||||
let record_id = options
|
||||
.translation_memory_record_id
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("TM confirm 必须指定 --tm-record-id"))?;
|
||||
let reviewer = options
|
||||
.translation_memory_reviewer
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("TM confirm 必须指定 --tm-reviewer"))?;
|
||||
build_translation_memory_confirm_report(
|
||||
&path,
|
||||
record_id,
|
||||
reviewer,
|
||||
options.translation_memory_reason.clone(),
|
||||
options.translation_memory_supersede_record_id.as_deref(),
|
||||
)?
|
||||
}
|
||||
CliCommand::TranslationMemoryConflicts => {
|
||||
build_translation_memory_conflicts_report(&path, options.query_limit)?
|
||||
}
|
||||
CliCommand::TranslationMemoryResolveConflict => {
|
||||
let winner = options
|
||||
.translation_memory_record_id
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("TM resolve-conflict 必须指定 winner record"))?;
|
||||
let expected = options
|
||||
.translation_memory_expected_trusted_record_ids_json
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("TM resolve-conflict 必须指定 expected set"))?;
|
||||
let expected = serde_json::from_str::<Vec<String>>(expected).map_err(|error| {
|
||||
anyhow::anyhow!("expected trusted record IDs 必须是 JSON array:{error}")
|
||||
})?;
|
||||
let reviewer = options
|
||||
.translation_memory_reviewer
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("TM resolve-conflict 必须指定 reviewer"))?;
|
||||
let reason = options
|
||||
.translation_memory_reason
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("TM resolve-conflict 必须指定 reason"))?;
|
||||
build_translation_memory_resolve_conflict_report(
|
||||
&path, winner, &expected, reviewer, reason,
|
||||
)?
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
print_json_value(options.output_format, &report)
|
||||
}
|
||||
|
||||
fn translation_memory_cli_path(options: &CliOptions) -> anyhow::Result<std::path::PathBuf> {
|
||||
if let Some(path) = options.translation_memory_path.as_ref() {
|
||||
return lexical_absolute(path).map_err(anyhow::Error::msg);
|
||||
}
|
||||
let resource_root = options
|
||||
.resource_root
|
||||
.as_deref()
|
||||
.map(lexical_absolute)
|
||||
.transpose()
|
||||
.map_err(anyhow::Error::msg)?
|
||||
.unwrap_or(active_official_resource_root(&options.config.output_root)?);
|
||||
Ok(bat_infrastructure::translation_memory_repository_path(
|
||||
&resource_root,
|
||||
))
|
||||
}
|
||||
|
||||
fn translation_memory_cli_params(
|
||||
options: &CliOptions,
|
||||
) -> anyhow::Result<Option<serde_json::Value>> {
|
||||
let mut params = serde_json::Map::new();
|
||||
if let Some(path) = options.translation_memory_path.as_ref() {
|
||||
params.insert(
|
||||
"translation_memory_path".to_string(),
|
||||
serde_json::json!(path),
|
||||
);
|
||||
}
|
||||
match options.command {
|
||||
CliCommand::TranslationMemorySummary => {}
|
||||
CliCommand::TranslationMemoryQuery => {
|
||||
let source_text = options
|
||||
.translation_memory_source_text
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("TM query 必须指定 --tm-source-text"))?;
|
||||
let context = parse_translation_memory_context(
|
||||
options.translation_memory_context_json.as_deref(),
|
||||
)?;
|
||||
params.insert("source_text".to_string(), serde_json::json!(source_text));
|
||||
params.insert("source_context".to_string(), serde_json::json!(context));
|
||||
params.insert("limit".to_string(), serde_json::json!(options.query_limit));
|
||||
}
|
||||
CliCommand::TranslationMemoryConfirm => {
|
||||
let record_id = options
|
||||
.translation_memory_record_id
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("TM confirm 必须指定 --tm-record-id"))?;
|
||||
let reviewer = options
|
||||
.translation_memory_reviewer
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("TM confirm 必须指定 --tm-reviewer"))?;
|
||||
params.insert("record_id".to_string(), serde_json::json!(record_id));
|
||||
params.insert("reviewer".to_string(), serde_json::json!(reviewer));
|
||||
if let Some(supersede_record_id) =
|
||||
options.translation_memory_supersede_record_id.as_deref()
|
||||
{
|
||||
params.insert(
|
||||
"supersede_record_id".to_string(),
|
||||
serde_json::json!(supersede_record_id),
|
||||
);
|
||||
}
|
||||
if let Some(reason) = options.translation_memory_reason.as_deref() {
|
||||
params.insert("reason".to_string(), serde_json::json!(reason));
|
||||
}
|
||||
}
|
||||
CliCommand::TranslationMemoryConflicts => {
|
||||
params.insert("limit".to_string(), serde_json::json!(options.query_limit));
|
||||
}
|
||||
CliCommand::TranslationMemoryResolveConflict => {
|
||||
let winner = options
|
||||
.translation_memory_record_id
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("TM resolve-conflict 必须指定 winner record"))?;
|
||||
let expected = options
|
||||
.translation_memory_expected_trusted_record_ids_json
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("TM resolve-conflict 必须指定 expected set"))?;
|
||||
let reviewer = options
|
||||
.translation_memory_reviewer
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("TM resolve-conflict 必须指定 reviewer"))?;
|
||||
let reason = options
|
||||
.translation_memory_reason
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("TM resolve-conflict 必须指定 reason"))?;
|
||||
let expected = serde_json::from_str::<Vec<String>>(expected).map_err(|error| {
|
||||
anyhow::anyhow!("expected trusted record IDs 必须是 JSON array:{error}")
|
||||
})?;
|
||||
params.insert("winner_record_id".to_string(), serde_json::json!(winner));
|
||||
params.insert(
|
||||
"expected_trusted_record_ids".to_string(),
|
||||
serde_json::json!(expected),
|
||||
);
|
||||
params.insert("reviewer".to_string(), serde_json::json!(reviewer));
|
||||
params.insert("reason".to_string(), serde_json::json!(reason));
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
Ok(Some(serde_json::Value::Object(params)))
|
||||
}
|
||||
|
||||
pub(super) fn build_translation_memory_summary_report(
|
||||
path: &std::path::Path,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
if !sqlite_file_exists_no_symlink(path, "Translation Memory 数据库")? {
|
||||
return Ok(serde_json::json!({
|
||||
"available": false,
|
||||
"path": path,
|
||||
"reason": "database_missing",
|
||||
}));
|
||||
}
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
let summary = runtime.block_on(async {
|
||||
let repository = bat_infrastructure::SqliteTranslationMemoryRepository::open(path)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||
repository
|
||||
.summary()
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))
|
||||
})?;
|
||||
Ok(serde_json::json!({
|
||||
"available": true,
|
||||
"path": path,
|
||||
"schema_version": summary.schema_version,
|
||||
"summary": summary,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn build_translation_memory_query_report(
|
||||
path: &std::path::Path,
|
||||
source_text: &str,
|
||||
source_context: &TranslationMemoryContext,
|
||||
limit: usize,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
if source_text.trim().is_empty() {
|
||||
return Err(anyhow::anyhow!("TM query 的 source_text 不能为空"));
|
||||
}
|
||||
if !(1..=1000).contains(&limit) {
|
||||
return Err(anyhow::anyhow!("TM query 的 limit 必须在 1..=1000 范围内"));
|
||||
}
|
||||
if !sqlite_file_exists_no_symlink(path, "Translation Memory 数据库")? {
|
||||
return Ok(serde_json::json!({
|
||||
"available": false,
|
||||
"path": path,
|
||||
"source_text": source_text,
|
||||
"source_context": source_context,
|
||||
"matches": [],
|
||||
"reason": "database_missing",
|
||||
}));
|
||||
}
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
let matches = runtime.block_on(async {
|
||||
let repository = bat_infrastructure::SqliteTranslationMemoryRepository::open(path)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||
repository
|
||||
.find_matches(source_text, source_context, limit)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))
|
||||
})?;
|
||||
Ok(serde_json::json!({
|
||||
"available": true,
|
||||
"path": path,
|
||||
"source_text": source_text,
|
||||
"source_context": source_context,
|
||||
"matches": matches,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn build_translation_memory_confirm_report(
|
||||
path: &std::path::Path,
|
||||
record_id: &str,
|
||||
reviewer: &str,
|
||||
reason: Option<String>,
|
||||
supersede_record_id: Option<&str>,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
if record_id.trim().is_empty() || reviewer.trim().is_empty() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"TM confirm 必须指定非空 record_id 和 reviewer"
|
||||
));
|
||||
}
|
||||
if !sqlite_file_exists_no_symlink(path, "Translation Memory 数据库")? {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Translation Memory 数据库不存在:{}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
let entry = runtime.block_on(async {
|
||||
let repository = bat_infrastructure::SqliteTranslationMemoryRepository::open(path)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||
repository
|
||||
.confirm_with_supersede(record_id, reviewer, reason, supersede_record_id)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))
|
||||
})?;
|
||||
Ok(serde_json::json!({
|
||||
"available": true,
|
||||
"path": path,
|
||||
"entry": entry,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn build_translation_memory_conflicts_report(
|
||||
path: &std::path::Path,
|
||||
limit: usize,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
if !(1..=1000).contains(&limit) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"TM conflicts 的 limit 必须在 1..=1000 范围内"
|
||||
));
|
||||
}
|
||||
if !sqlite_file_exists_no_symlink(path, "Translation Memory 数据库")? {
|
||||
return Ok(serde_json::json!({
|
||||
"available": false,
|
||||
"path": path,
|
||||
"conflicts": [],
|
||||
"reason": "database_missing",
|
||||
}));
|
||||
}
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
let conflicts = runtime.block_on(async {
|
||||
let repository = bat_infrastructure::SqliteTranslationMemoryRepository::open(path)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||
repository
|
||||
.list_conflicts(limit)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))
|
||||
})?;
|
||||
Ok(serde_json::json!({
|
||||
"available": true,
|
||||
"path": path,
|
||||
"conflicts": conflicts,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn build_translation_memory_resolve_conflict_report(
|
||||
path: &std::path::Path,
|
||||
winner_record_id: &str,
|
||||
expected_trusted_record_ids: &[String],
|
||||
reviewer: &str,
|
||||
reason: &str,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
if winner_record_id.trim().is_empty()
|
||||
|| reviewer.trim().is_empty()
|
||||
|| reason.trim().is_empty()
|
||||
|| expected_trusted_record_ids.is_empty()
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"TM resolve-conflict 必须指定 winner_record_id、expected_trusted_record_ids、reviewer 和 reason"
|
||||
));
|
||||
}
|
||||
if !sqlite_file_exists_no_symlink(path, "Translation Memory 数据库")? {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Translation Memory 数据库不存在:{}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
let entry = runtime.block_on(async {
|
||||
let repository = bat_infrastructure::SqliteTranslationMemoryRepository::open(path)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||
repository
|
||||
.resolve_conflict(
|
||||
winner_record_id,
|
||||
expected_trusted_record_ids,
|
||||
reviewer,
|
||||
reason,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))
|
||||
})?;
|
||||
Ok(serde_json::json!({
|
||||
"available": true,
|
||||
"path": path,
|
||||
"entry": entry,
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_translation_memory_context(
|
||||
value: Option<&str>,
|
||||
) -> anyhow::Result<TranslationMemoryContext> {
|
||||
let Some(value) = value else {
|
||||
return Ok(BTreeMap::new());
|
||||
};
|
||||
serde_json::from_str::<TranslationMemoryContext>(value)
|
||||
.map_err(|error| anyhow::anyhow!("--tm-context-json 必须是 JSON object:{error}"))
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
use super::report_output::{print_json_value, print_report};
|
||||
use super::*;
|
||||
|
||||
pub(super) fn run_parse_once(options: &CliOptions) -> anyhow::Result<()> {
|
||||
let (resource_root, release_id) = current_official_release(options)?;
|
||||
let parse_config =
|
||||
OfficialParseConfig::new(&resource_root, options.config.unzip_command.clone())
|
||||
.with_force(options.config.force);
|
||||
let parse_report = OfficialParseCacheService::new()
|
||||
.run(&parse_config)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let queue_report =
|
||||
write_official_textunit_queues(&resource_root).map_err(anyhow::Error::msg)?;
|
||||
let data = serde_json::json!({
|
||||
"official_release_id": release_id,
|
||||
"resource_root": resource_root,
|
||||
"forced": options.config.force,
|
||||
"parse": parse_report,
|
||||
"translation_queue": queue_report,
|
||||
});
|
||||
print_report(
|
||||
options.output_format,
|
||||
&CommandReport {
|
||||
command: "parse",
|
||||
status: "completed",
|
||||
message: "官方资源解析已执行",
|
||||
data,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn run_parse_clear_cache(options: &CliOptions) -> anyhow::Result<()> {
|
||||
let (resource_root, release_id) = current_official_release(options)?;
|
||||
let artifact_names = [
|
||||
OFFICIAL_PARSE_CACHE_FILE,
|
||||
OFFICIAL_TEXTUNIT_INDEX_FILE,
|
||||
OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE,
|
||||
CROWDIN_TEXTUNIT_QUEUE_FILE,
|
||||
];
|
||||
let mut removed = Vec::new();
|
||||
for name in artifact_names {
|
||||
let path = resource_root.join(name);
|
||||
if remove_regenerable_file(&path)? {
|
||||
removed.push(path);
|
||||
}
|
||||
}
|
||||
let data = serde_json::json!({
|
||||
"official_release_id": release_id,
|
||||
"resource_root": resource_root,
|
||||
"removed": removed,
|
||||
"translation_task_repository_preserved": true,
|
||||
});
|
||||
print_report(
|
||||
options.output_format,
|
||||
&CommandReport {
|
||||
command: "parse-clear-cache",
|
||||
status: "cleared",
|
||||
message: "当前官方 release 的可再生解析缓存和翻译队列已清理",
|
||||
data,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn run_translate_once(options: &CliOptions) -> anyhow::Result<()> {
|
||||
let (resource_root, release_id) = current_official_release(options)?;
|
||||
let queue = write_official_textunit_queues(&resource_root).map_err(anyhow::Error::msg)?;
|
||||
let exported = options
|
||||
.translation_file
|
||||
.as_ref()
|
||||
.map(|path| {
|
||||
export_translation_workbench(&resource_root, release_id.clone(), path).map(
|
||||
|workbench| {
|
||||
serde_json::json!({
|
||||
"path": path,
|
||||
"entry_count": workbench.entries.len(),
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
.transpose()?;
|
||||
let data = serde_json::json!({
|
||||
"official_release_id": release_id,
|
||||
"resource_root": resource_root,
|
||||
"queue": queue,
|
||||
"workbench": exported,
|
||||
"provider": "offline",
|
||||
"note": "当前 translate 只生成/刷新离线队列和可编辑工作台,不调用外部翻译 provider",
|
||||
});
|
||||
print_report(
|
||||
options.output_format,
|
||||
&CommandReport {
|
||||
command: "translate",
|
||||
status: "queued",
|
||||
message: "翻译离线队列已刷新",
|
||||
data,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn run_translation_validate(options: &CliOptions) -> anyhow::Result<()> {
|
||||
let path = options
|
||||
.translation_file
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("i18n validate 必须指定 --translation-file"))?;
|
||||
let (resource_root, release_id) = current_official_release(options)?;
|
||||
let workbench = read_translation_workbench(path)?;
|
||||
let validation = validate_translation_workbench_with_glossary_path(
|
||||
&resource_root,
|
||||
&release_id,
|
||||
&workbench,
|
||||
options.glossary_path.as_deref(),
|
||||
)?;
|
||||
let data = serde_json::json!({
|
||||
"official_release_id": release_id,
|
||||
"resource_root": resource_root,
|
||||
"translation_file": path,
|
||||
"validation": validation,
|
||||
});
|
||||
print_json_value(options.output_format, &data)
|
||||
}
|
||||
|
||||
pub(super) fn run_translation_set(options: &CliOptions) -> anyhow::Result<()> {
|
||||
let path = options
|
||||
.translation_file
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("translation-set 必须指定 --translation-file"))?;
|
||||
let text = match (&options.translation_text, &options.translation_text_file) {
|
||||
(Some(_), Some(_)) => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"--translated-text 与 --translated-file 只能指定一个"
|
||||
))
|
||||
}
|
||||
(Some(text), None) => text.clone(),
|
||||
(None, Some(path)) => String::from_utf8(
|
||||
read_file_no_symlink(path, "翻译文本文件")
|
||||
.map_err(anyhow::Error::msg)?
|
||||
.ok_or_else(|| anyhow::anyhow!("翻译文本文件不存在:{}", path.display()))?,
|
||||
)?,
|
||||
(None, None) => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"translation-set 必须指定 --translated-text 或 --translated-file"
|
||||
))
|
||||
}
|
||||
};
|
||||
let entry_id = options
|
||||
.translation_id
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("translation-set 必须指定 --translation-id"))?;
|
||||
let glossary_override = match (
|
||||
options.glossary_reviewer.as_deref(),
|
||||
options.glossary_reason.as_deref(),
|
||||
options.glossary_override_provenance.as_deref(),
|
||||
options.glossary_qa_identity.as_deref(),
|
||||
) {
|
||||
(None, None, None, None) => None,
|
||||
(Some(reviewer), Some(reason), Some(provenance), Some(qa_identity)) => Some(
|
||||
bat_core::domain::GlossaryOverride {
|
||||
qa_identity: qa_identity.to_string(),
|
||||
reviewer: reviewer.to_string(),
|
||||
reason: reason.to_string(),
|
||||
provenance: provenance.to_string(),
|
||||
confirmed_unix_seconds: unix_seconds_now(),
|
||||
},
|
||||
),
|
||||
_ => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Glossary override 必须同时指定 --glossary-qa-identity、--glossary-reviewer、--glossary-reason 和 --glossary-provenance"
|
||||
))
|
||||
}
|
||||
};
|
||||
let workbench = read_translation_workbench(path)?;
|
||||
let glossary_path = options.glossary_path.clone().unwrap_or_else(|| {
|
||||
bat_infrastructure::SqliteGlossaryRepository::repository_path(
|
||||
&workbench.official_resource_root,
|
||||
)
|
||||
});
|
||||
let entry = if std::fs::symlink_metadata(&glossary_path).is_ok() {
|
||||
let (resource_root, _) = current_official_release(options)?;
|
||||
set_translation_checked_with_glossary_path(
|
||||
&resource_root,
|
||||
path,
|
||||
entry_id,
|
||||
text,
|
||||
glossary_override,
|
||||
options.glossary_path.as_deref(),
|
||||
)?
|
||||
} else {
|
||||
if glossary_override.is_some() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"当前项目没有 Glossary 数据库,不能提交 Glossary override"
|
||||
));
|
||||
}
|
||||
set_translation(path, entry_id, text)?
|
||||
};
|
||||
let data = serde_json::json!({
|
||||
"translation_file": path,
|
||||
"entry": entry,
|
||||
});
|
||||
print_report(
|
||||
options.output_format,
|
||||
&CommandReport {
|
||||
command: "translation-set",
|
||||
status: "updated",
|
||||
message: "翻译工作台条目已更新",
|
||||
data,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn run_translation_get(options: &CliOptions) -> anyhow::Result<()> {
|
||||
let path = options
|
||||
.translation_file
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("i18n get 必须指定 --translation-file"))?;
|
||||
let entry_id = options
|
||||
.translation_id
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("i18n get 必须指定 --translation-id"))?;
|
||||
let entry = get_translation_entry(path, entry_id)?;
|
||||
let data = serde_json::json!({
|
||||
"translation_file": path,
|
||||
"entry": entry,
|
||||
});
|
||||
print_report(
|
||||
options.output_format,
|
||||
&CommandReport {
|
||||
command: "translation-get",
|
||||
status: "ok",
|
||||
message: "翻译工作台条目已读取",
|
||||
data,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn run_translation_unset(options: &CliOptions) -> anyhow::Result<()> {
|
||||
let path = options
|
||||
.translation_file
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("i18n unset 必须指定 --translation-file"))?;
|
||||
let entry_id = options
|
||||
.translation_id
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("i18n unset 必须指定 --translation-id"))?;
|
||||
let entry = unset_translation(path, entry_id)?;
|
||||
let data = serde_json::json!({
|
||||
"translation_file": path,
|
||||
"entry": entry,
|
||||
});
|
||||
print_report(
|
||||
options.output_format,
|
||||
&CommandReport {
|
||||
command: "translation-unset",
|
||||
status: "updated",
|
||||
message: "翻译工作台条目已恢复为未审核",
|
||||
data,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn run_translation_task_update(options: &CliOptions) -> anyhow::Result<()> {
|
||||
let task_id = options
|
||||
.query_task_id
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("i18n task update 必须指定 --task-id"))?;
|
||||
let status = options
|
||||
.query_task_status
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("i18n task update 必须指定 --task-status"))?;
|
||||
let mut params = serde_json::Map::from_iter([
|
||||
("task_id".to_string(), serde_json::json!(task_id)),
|
||||
("status".to_string(), serde_json::json!(status)),
|
||||
]);
|
||||
if let Some(reason) = options.translation_failure_reason.as_deref() {
|
||||
params.insert("failure_reason".to_string(), serde_json::json!(reason));
|
||||
}
|
||||
if let Some(provider_run_id) = options.translation_provider_run_id.as_deref() {
|
||||
params.insert(
|
||||
"provider_run_id".to_string(),
|
||||
serde_json::json!(provider_run_id),
|
||||
);
|
||||
}
|
||||
let report = update_translation_task_status_report(
|
||||
&options.state_dir,
|
||||
Some(&serde_json::Value::Object(params)),
|
||||
options.glossary_path.as_deref(),
|
||||
)?;
|
||||
print_json_value(options.output_format, &report)
|
||||
}
|
||||
|
||||
pub(super) fn run_translation_proofread(options: &CliOptions) -> anyhow::Result<()> {
|
||||
if daemon_rpc_available(&options.state_dir)
|
||||
&& options.resource_root.is_none()
|
||||
&& !options.output_explicit
|
||||
{
|
||||
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
|
||||
let report = daemon_rpc_call(&options.state_dir, RPC_METHOD_TRANSLATION_PROOFREAD, None)?;
|
||||
print_json_value(options.output_format, &report)?;
|
||||
return Ok(());
|
||||
}
|
||||
let (_, official_release_id) = current_official_release(options)?;
|
||||
let report = bat_infrastructure::mark_localized_manual_proofreading(
|
||||
&options.config.localized_output_root,
|
||||
&official_release_id,
|
||||
)?;
|
||||
print_report(options.output_format, &report)
|
||||
}
|
||||
|
||||
pub(super) fn run_translation_worker(options: &CliOptions) -> anyhow::Result<()> {
|
||||
let resource_root = options
|
||||
.resource_root
|
||||
.clone()
|
||||
.map(|path| lexical_absolute(&path).map_err(anyhow::Error::msg))
|
||||
.transpose()?
|
||||
.unwrap_or(active_official_resource_root(&options.config.output_root)?);
|
||||
let config = super::translation_worker_config_from_options(
|
||||
options,
|
||||
&format!("bat-worker-{}", std::process::id()),
|
||||
)?;
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
let report = runtime.block_on(bat_infrastructure::run_translation_worker_at(
|
||||
&resource_root,
|
||||
&config,
|
||||
))?;
|
||||
print_report(options.output_format, &report)
|
||||
}
|
||||
|
||||
pub(super) fn run_repack(options: &CliOptions) -> anyhow::Result<()> {
|
||||
let spec = options
|
||||
.repack_spec
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("repack 必须指定 --repack-spec"))?;
|
||||
let report = repack_bundle(spec)?;
|
||||
print_report(options.output_format, &report)
|
||||
}
|
||||
|
||||
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()
|
||||
.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={} 不一致;请重新导出",
|
||||
workbench.official_release_id,
|
||||
official_release_id
|
||||
));
|
||||
}
|
||||
let expected_root = lexical_absolute(&resource_root).map_err(anyhow::Error::msg)?;
|
||||
if workbench.official_resource_root != expected_root {
|
||||
return Err(anyhow::anyhow!(
|
||||
"翻译工作台资源根目录与当前 release 不一致;请重新导出"
|
||||
));
|
||||
}
|
||||
validate_translation_workbench_with_glossary_path(
|
||||
&resource_root,
|
||||
&official_release_id,
|
||||
&workbench,
|
||||
options.glossary_path.as_deref(),
|
||||
)?;
|
||||
let operations = localized_patch_operations_with_glossary_path(
|
||||
&resource_root,
|
||||
&workbench,
|
||||
options.glossary_path.as_deref(),
|
||||
)?;
|
||||
let localized_release_id = options.localized_release_id.clone().or_else(|| {
|
||||
options
|
||||
.config
|
||||
.force
|
||||
.then(|| format!("{}-manual-{}", official_release_id, unix_seconds_now()))
|
||||
});
|
||||
let mut 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_operations(operations)
|
||||
.with_force(options.config.force);
|
||||
if let Some(release_id) = localized_release_id {
|
||||
config = config.with_localized_release_id(release_id);
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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)?
|
||||
} else {
|
||||
state
|
||||
.as_ref()
|
||||
.and_then(|state| state.current_completed_version.as_ref())
|
||||
.map(|record| record.resource_root.clone())
|
||||
.unwrap_or(active_official_resource_root(&options.config.output_root)?)
|
||||
};
|
||||
let release_id = state
|
||||
.as_ref()
|
||||
.and_then(|state| state.current_completed_version.as_ref())
|
||||
.filter(|_| options.resource_root.is_none())
|
||||
.map(|record| record.id.clone())
|
||||
.or_else(|| {
|
||||
resource_root
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(str::to_string)
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("无法从当前官方资源根目录确定 release id"))?;
|
||||
if read_download_manifest_at(&resource_root)
|
||||
.map_err(anyhow::Error::msg)?
|
||||
.is_none()
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"当前官方 release 缺少官方下载 manifest:{}",
|
||||
resource_root.display()
|
||||
));
|
||||
}
|
||||
Ok((resource_root, release_id))
|
||||
}
|
||||
|
||||
fn remove_regenerable_file(path: &Path) -> anyhow::Result<bool> {
|
||||
let metadata = match fs::symlink_metadata(path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"拒绝删除符号链接形式的可再生文件:{}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
if !metadata.is_file() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"可再生缓存路径不是普通文件:{}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
fs::remove_file(path)?;
|
||||
Ok(true)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,30 @@ impl FileSystemCasRepository {
|
||||
self.engine().await.map(|_| ())
|
||||
}
|
||||
|
||||
/// Releases one release-owned CAS reference exactly once.
|
||||
pub async fn release_reference_once(
|
||||
&self,
|
||||
ownership_id: &str,
|
||||
ordinal: u64,
|
||||
id: &ObjectId,
|
||||
) -> bat_core::Result<bool> {
|
||||
let hash = Self::parse_object_id(id)?;
|
||||
self.engine()
|
||||
.await?
|
||||
.release_reference_once(ownership_id, ordinal, &hash)
|
||||
.await
|
||||
.map_err(Self::map_error)
|
||||
}
|
||||
|
||||
/// Returns whether the durable release ownership ledger has any row.
|
||||
pub async fn has_release_ownership(&self, ownership_id: &str) -> bat_core::Result<bool> {
|
||||
self.engine()
|
||||
.await?
|
||||
.has_release_ownership(ownership_id)
|
||||
.await
|
||||
.map_err(Self::map_error)
|
||||
}
|
||||
|
||||
async fn engine(&self) -> bat_core::Result<&engine_repository::FileSystemCasRepository> {
|
||||
self.inner
|
||||
.get_or_try_init(|| async {
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
//! Downloader backend and bounded scheduling contracts.
|
||||
//!
|
||||
//! The scheduler is deliberately independent from curl, manifests, and
|
||||
//! official URL rules. Those concerns belong to a backend and the caller,
|
||||
//! which keeps retry, proxy, and verification policy composable.
|
||||
|
||||
use std::sync::{mpsc, Arc, Mutex};
|
||||
|
||||
/// Lowest supported download concurrency.
|
||||
pub const MIN_DOWNLOAD_CONCURRENCY: usize = 1;
|
||||
/// Highest supported download concurrency.
|
||||
pub const MAX_DOWNLOAD_CONCURRENCY: usize = 256;
|
||||
/// Default official download concurrency.
|
||||
pub const DEFAULT_DOWNLOAD_CONCURRENCY: usize = 8;
|
||||
|
||||
/// A backend that executes one already-planned download task.
|
||||
pub trait DownloaderBackend<T>: Send + Sync {
|
||||
/// Successful result returned for one task.
|
||||
type Output: Send;
|
||||
/// Failure returned for one task.
|
||||
type Error: Send;
|
||||
|
||||
/// Executes one task. The scheduler owns ordering and concurrency only.
|
||||
fn download(&self, task: T) -> Result<Self::Output, Self::Error>;
|
||||
}
|
||||
|
||||
/// Results returned by a scheduler for a task type and backend.
|
||||
pub type DownloadResults<T, B> =
|
||||
Vec<Result<<B as DownloaderBackend<T>>::Output, <B as DownloaderBackend<T>>::Error>>;
|
||||
|
||||
/// A bounded worker scheduler.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct DownloadScheduler {
|
||||
max_concurrency: usize,
|
||||
}
|
||||
|
||||
impl DownloadScheduler {
|
||||
/// Creates a scheduler with the supported bounded range.
|
||||
///
|
||||
/// The official CLI validates input and reports out-of-range values.
|
||||
/// This lower-level constructor remains total for library callers and
|
||||
/// clamps values to the same safety bounds.
|
||||
pub fn new(max_concurrency: usize) -> Self {
|
||||
Self {
|
||||
max_concurrency: max_concurrency
|
||||
.clamp(MIN_DOWNLOAD_CONCURRENCY, MAX_DOWNLOAD_CONCURRENCY),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the configured upper bound.
|
||||
pub fn max_concurrency(self) -> usize {
|
||||
self.max_concurrency
|
||||
}
|
||||
|
||||
/// Executes tasks with a bounded number of workers.
|
||||
///
|
||||
/// Results are returned in input order even when workers finish out of
|
||||
/// order. A failed task does not cause additional tasks to be scheduled
|
||||
/// after it, because already-started bounded work must be joined cleanly;
|
||||
/// callers decide whether a failed result invalidates the whole release.
|
||||
pub fn execute<T, B>(self, backend: &B, tasks: Vec<T>) -> DownloadResults<T, B>
|
||||
where
|
||||
T: Send + 'static,
|
||||
B: DownloaderBackend<T>,
|
||||
{
|
||||
self.execute_with_observer(
|
||||
backend,
|
||||
tasks,
|
||||
|_, _| Ok::<(), std::convert::Infallible>(()),
|
||||
)
|
||||
.expect("infallible download observer cannot fail")
|
||||
}
|
||||
|
||||
/// Executes tasks and observes each result as soon as a worker returns it.
|
||||
///
|
||||
/// The observer runs on the coordinator thread, while worker threads
|
||||
/// immediately take another pending task after sending their result. An
|
||||
/// observer error stops further observation but still drains and joins all
|
||||
/// workers before returning, so no background transfer is left detached.
|
||||
pub fn execute_with_observer<T, B, F, E>(
|
||||
self,
|
||||
backend: &B,
|
||||
tasks: Vec<T>,
|
||||
mut observer: F,
|
||||
) -> Result<DownloadResults<T, B>, E>
|
||||
where
|
||||
T: Send + 'static,
|
||||
B: DownloaderBackend<T>,
|
||||
F: FnMut(usize, &Result<B::Output, B::Error>) -> Result<(), E>,
|
||||
{
|
||||
if tasks.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
if self.max_concurrency == 1 {
|
||||
let mut results = Vec::with_capacity(tasks.len());
|
||||
for (index, task) in tasks.into_iter().enumerate() {
|
||||
let result = backend.download(task);
|
||||
observer(index, &result)?;
|
||||
results.push(result);
|
||||
}
|
||||
return Ok(results);
|
||||
}
|
||||
|
||||
let total = tasks.len();
|
||||
let worker_count = self.max_concurrency.min(total);
|
||||
let pending = Arc::new(Mutex::new(tasks.into_iter().enumerate()));
|
||||
let (result_sender, result_receiver) = mpsc::channel();
|
||||
|
||||
std::thread::scope(|scope| {
|
||||
for _ in 0..worker_count {
|
||||
let pending = Arc::clone(&pending);
|
||||
let result_sender = result_sender.clone();
|
||||
scope.spawn(move || loop {
|
||||
let task = pending
|
||||
.lock()
|
||||
.expect("download scheduler task queue poisoned")
|
||||
.next();
|
||||
let Some((index, task)) = task else {
|
||||
break;
|
||||
};
|
||||
let result = backend.download(task);
|
||||
if result_sender.send((index, result)).is_err() {
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
drop(result_sender);
|
||||
|
||||
let mut results = std::iter::repeat_with(|| None)
|
||||
.take(total)
|
||||
.collect::<Vec<_>>();
|
||||
let mut observer_error = None;
|
||||
for (index, result) in result_receiver {
|
||||
if observer_error.is_none() {
|
||||
if let Err(error) = observer(index, &result) {
|
||||
observer_error = Some(error);
|
||||
}
|
||||
}
|
||||
results[index] = Some(result);
|
||||
}
|
||||
let results = results
|
||||
.into_iter()
|
||||
.map(|result| result.expect("download scheduler lost a task result"))
|
||||
.collect();
|
||||
match observer_error {
|
||||
Some(error) => Err(error),
|
||||
None => Ok(results),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
struct TestBackend {
|
||||
active: AtomicUsize,
|
||||
max_active: AtomicUsize,
|
||||
}
|
||||
|
||||
impl DownloaderBackend<usize> for TestBackend {
|
||||
type Output = usize;
|
||||
type Error = String;
|
||||
|
||||
fn download(&self, task: usize) -> Result<Self::Output, Self::Error> {
|
||||
let active = self.active.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
self.max_active.fetch_max(active, Ordering::SeqCst);
|
||||
thread::sleep(Duration::from_millis(2));
|
||||
self.active.fetch_sub(1, Ordering::SeqCst);
|
||||
Ok(task * 2)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scheduler_preserves_result_order_and_respects_bound() {
|
||||
let backend = TestBackend {
|
||||
active: AtomicUsize::new(0),
|
||||
max_active: AtomicUsize::new(0),
|
||||
};
|
||||
let results = DownloadScheduler::new(2).execute(&backend, (0..8).collect());
|
||||
|
||||
assert_eq!(
|
||||
results.into_iter().map(Result::unwrap).collect::<Vec<_>>(),
|
||||
(0..8).map(|value| value * 2).collect::<Vec<_>>()
|
||||
);
|
||||
assert!(backend.max_active.load(Ordering::SeqCst) <= 2);
|
||||
assert!(backend.max_active.load(Ordering::SeqCst) >= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_concurrency_is_conservative() {
|
||||
assert_eq!(
|
||||
DownloadScheduler::new(0).max_concurrency(),
|
||||
MIN_DOWNLOAD_CONCURRENCY
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scheduler_caps_untrusted_upper_bound() {
|
||||
assert_eq!(
|
||||
DownloadScheduler::new(usize::MAX).max_concurrency(),
|
||||
MAX_DOWNLOAD_CONCURRENCY
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observer_receives_completion_without_a_global_barrier() {
|
||||
struct UnevenBackend {
|
||||
active: AtomicUsize,
|
||||
task_two_started_while_task_zero_active: AtomicUsize,
|
||||
}
|
||||
|
||||
impl DownloaderBackend<usize> for UnevenBackend {
|
||||
type Output = usize;
|
||||
type Error = String;
|
||||
|
||||
fn download(&self, task: usize) -> Result<Self::Output, Self::Error> {
|
||||
if task == 0 {
|
||||
self.active.fetch_add(1, Ordering::SeqCst);
|
||||
let deadline = Instant::now() + Duration::from_millis(500);
|
||||
while self
|
||||
.task_two_started_while_task_zero_active
|
||||
.load(Ordering::SeqCst)
|
||||
== 0
|
||||
&& Instant::now() < deadline
|
||||
{
|
||||
thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
self.active.fetch_sub(1, Ordering::SeqCst);
|
||||
} else {
|
||||
if task == 1 {
|
||||
while self.active.load(Ordering::SeqCst) == 0 {
|
||||
thread::yield_now();
|
||||
}
|
||||
}
|
||||
if task == 2 && self.active.load(Ordering::SeqCst) > 0 {
|
||||
self.task_two_started_while_task_zero_active
|
||||
.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(if task == 1 { 1 } else { 5 }));
|
||||
}
|
||||
Ok(task)
|
||||
}
|
||||
}
|
||||
|
||||
let backend = UnevenBackend {
|
||||
active: AtomicUsize::new(0),
|
||||
task_two_started_while_task_zero_active: AtomicUsize::new(0),
|
||||
};
|
||||
let mut completed = Vec::new();
|
||||
let results = DownloadScheduler::new(2)
|
||||
.execute_with_observer(&backend, vec![0, 1, 2], |index, _| {
|
||||
completed.push(index);
|
||||
Ok::<(), ()>(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
results.into_iter().map(Result::unwrap).collect::<Vec<_>>(),
|
||||
vec![0, 1, 2]
|
||||
);
|
||||
assert_eq!(completed.len(), 3);
|
||||
assert!(completed[0] == 1, "短任务应在长任务之前回传:{completed:?}");
|
||||
assert_eq!(
|
||||
backend
|
||||
.task_two_started_while_task_zero_active
|
||||
.load(Ordering::SeqCst),
|
||||
1,
|
||||
"worker 完成 task 1 后应立即领取 task 2"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -560,6 +560,8 @@ mod tests {
|
||||
resource_type: ResourceType::AssetBundle,
|
||||
address: None,
|
||||
dependencies: Vec::new(),
|
||||
provider_id: None,
|
||||
bundle_name: None,
|
||||
crc: None,
|
||||
},
|
||||
ResourceEntry {
|
||||
@@ -569,6 +571,8 @@ mod tests {
|
||||
resource_type: ResourceType::Manifest,
|
||||
address: None,
|
||||
dependencies: Vec::new(),
|
||||
provider_id: None,
|
||||
bundle_name: None,
|
||||
crc: None,
|
||||
},
|
||||
ResourceEntry {
|
||||
@@ -578,6 +582,8 @@ mod tests {
|
||||
resource_type: ResourceType::TextAsset,
|
||||
address: Some("dialogue".to_string()),
|
||||
dependencies: Vec::new(),
|
||||
provider_id: None,
|
||||
bundle_name: None,
|
||||
crc: None,
|
||||
},
|
||||
ResourceEntry {
|
||||
@@ -587,6 +593,8 @@ mod tests {
|
||||
resource_type: ResourceType::TableBundle,
|
||||
address: None,
|
||||
dependencies: Vec::new(),
|
||||
provider_id: None,
|
||||
bundle_name: None,
|
||||
crc: None,
|
||||
},
|
||||
ResourceEntry {
|
||||
@@ -596,6 +604,8 @@ mod tests {
|
||||
resource_type: ResourceType::Media,
|
||||
address: None,
|
||||
dependencies: Vec::new(),
|
||||
provider_id: None,
|
||||
bundle_name: None,
|
||||
crc: None,
|
||||
},
|
||||
],
|
||||
@@ -627,6 +637,8 @@ mod tests {
|
||||
resource_type: ResourceType::TextAsset,
|
||||
address: None,
|
||||
dependencies: Vec::new(),
|
||||
provider_id: None,
|
||||
bundle_name: None,
|
||||
crc: None,
|
||||
}
|
||||
}
|
||||
@@ -648,6 +660,8 @@ mod tests {
|
||||
resource_type: ResourceType::AssetBundle,
|
||||
address: None,
|
||||
dependencies: Vec::new(),
|
||||
provider_id: None,
|
||||
bundle_name: None,
|
||||
crc: None,
|
||||
},
|
||||
]);
|
||||
@@ -801,6 +815,8 @@ mod tests {
|
||||
resource_type: ResourceType::AssetBundle,
|
||||
address: None,
|
||||
dependencies: Vec::new(),
|
||||
provider_id: None,
|
||||
bundle_name: None,
|
||||
crc: None,
|
||||
}]);
|
||||
|
||||
|
||||
+89
-15
@@ -12,6 +12,8 @@
|
||||
|
||||
pub mod cas;
|
||||
mod curl_transfer;
|
||||
pub mod downloader;
|
||||
pub mod glossary;
|
||||
pub mod import;
|
||||
pub mod localized_patch;
|
||||
pub mod official_changes;
|
||||
@@ -26,24 +28,47 @@ pub mod official_textunit_queue;
|
||||
pub mod official_update;
|
||||
pub mod patch_ops;
|
||||
pub mod path_security;
|
||||
pub mod release_flow;
|
||||
pub mod release_ops;
|
||||
pub mod resources;
|
||||
mod sqlite_migration;
|
||||
pub mod translation_memory;
|
||||
pub mod translation_tasks;
|
||||
pub mod translation_worker;
|
||||
pub mod translation_workflow;
|
||||
mod zip_validation;
|
||||
|
||||
pub use cas::FileSystemCasRepository;
|
||||
pub use curl_transfer::{
|
||||
redact_proxy_url, resolve_curl_proxy, CurlProxyConfig, CurlProxyMode, ResolvedCurlProxy,
|
||||
};
|
||||
pub use downloader::{
|
||||
DownloadResults, DownloadScheduler, DownloaderBackend, DEFAULT_DOWNLOAD_CONCURRENCY,
|
||||
MAX_DOWNLOAD_CONCURRENCY, MIN_DOWNLOAD_CONCURRENCY,
|
||||
};
|
||||
pub use glossary::{
|
||||
SqliteGlossaryRepository, GLOSSARY_REPOSITORY_FILE, GLOSSARY_SCHEMA_COMPONENT,
|
||||
GLOSSARY_SCHEMA_VERSION,
|
||||
};
|
||||
pub use import::{
|
||||
BundleSource, ImportedResource, ResourceImportCategory, ResourceImportReport,
|
||||
ResourceImportService,
|
||||
};
|
||||
pub use localized_patch::{
|
||||
read_localized_patch_manifest_at, read_localized_version_state, LocalizedPatchConfig,
|
||||
LocalizedPatchFile, LocalizedPatchIntegrity, LocalizedPatchManifest, LocalizedPatchOperation,
|
||||
inspect_localized_release_artifact, inspect_localized_release_artifact_at,
|
||||
mark_localized_manual_proofreading, read_localized_patch_manifest_at,
|
||||
read_localized_version_state, write_localized_version_state, LocalizedArtifactIntegrityReport,
|
||||
LocalizedDistributionEntry, LocalizedDistributionManifest, LocalizedFieldPatch,
|
||||
LocalizedPatchConfig, LocalizedPatchFile, LocalizedPatchInput, LocalizedPatchIntegrity,
|
||||
LocalizedPatchManifest, LocalizedPatchOperation, LocalizedPatchOperationMetadata,
|
||||
LocalizedPatchReport, LocalizedPatchRollbackInfo, LocalizedPatchService,
|
||||
LocalizedTextAssetPatch, LocalizedVersionState, LOCALIZED_CURRENT_LINK,
|
||||
LOCALIZED_PATCH_MANIFEST_FILE, LOCALIZED_PATCH_MANIFEST_VERSION, LOCALIZED_STAGING_DIR,
|
||||
LOCALIZED_VERSIONS_DIR, LOCALIZED_VERSION_STATE_FILE,
|
||||
LocalizedRollbackReport, LocalizedStringFieldPatch, LocalizedTextAssetPatch,
|
||||
LocalizedTranslationWorkflowReport, LocalizedVersionState, LOCALIZED_CURRENT_LINK,
|
||||
LOCALIZED_DISTRIBUTION_MANIFEST_FILE, 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,
|
||||
};
|
||||
pub use official_changes::{
|
||||
read_resource_change_set_at, write_crowdin_translation_handoff_at,
|
||||
@@ -56,12 +81,19 @@ pub use official_changes::{
|
||||
OFFICIAL_RESOURCE_CHANGES_VERSION,
|
||||
};
|
||||
pub use official_download::{
|
||||
read_download_manifest_at, DownloadError, OfficialDownloadManifest,
|
||||
official_distribution_mapping_identity, official_distribution_max_age_for_durations,
|
||||
official_distribution_max_age_seconds, read_cas_reuse_reference_manifest_at,
|
||||
read_download_manifest_at, release_cas_reuse_references, DownloadError,
|
||||
OfficialCasReuseReferenceManifest, OfficialDistributionAttestation, OfficialDownloadManifest,
|
||||
OfficialDownloadManifestEntry, OfficialLocalManifestAuditItem,
|
||||
OfficialLocalManifestAuditReport, OfficialLocalManifestAuditStatus,
|
||||
OfficialLocalVerificationReport, OfficialResourcePullItem, OfficialResourcePullProgress,
|
||||
OfficialLocalVerificationReport, OfficialResourceHashAlgorithm,
|
||||
OfficialResourceHashVerification, OfficialResourcePullItem, OfficialResourcePullProgress,
|
||||
OfficialResourcePullProgressKind, OfficialResourcePullReport, OfficialResourcePullService,
|
||||
OfficialResourcePullStatus,
|
||||
OfficialResourcePullStatus, OfficialResourceReuseWarning, OfficialResourceVerification,
|
||||
DEFAULT_OFFICIAL_ERROR_RETRY_SECONDS, DEFAULT_OFFICIAL_VERIFICATION_INTERVAL_SECONDS,
|
||||
OFFICIAL_CAS_REUSE_REFERENCES_FILE, OFFICIAL_DISTRIBUTION_ATTESTATION_FILE,
|
||||
OFFICIAL_DISTRIBUTION_ATTESTATION_VERSION, OFFICIAL_DISTRIBUTION_PUBLICATION_FILE,
|
||||
};
|
||||
pub use official_game_main_config::OfficialGameMainConfigBootstrapService;
|
||||
pub use official_launcher::{
|
||||
@@ -92,16 +124,17 @@ pub use official_sync::{
|
||||
default_official_platforms, OfficialSyncDecision, OfficialSyncPlan,
|
||||
};
|
||||
pub use official_textunit_queue::{
|
||||
read_textunit_task_queue_at, write_crowdin_textunit_queue_at, write_official_textunit_queues,
|
||||
write_textunit_task_queue_at, CrowdinTextUnitQueue, CrowdinTextUnitQueueItem,
|
||||
OfficialTextUnitQueueReport, OfficialTextUnitTask, OfficialTextUnitTaskQueue,
|
||||
OfficialTextUnitTaskStatus, OfficialTextUnitTaskSummary, CROWDIN_TEXTUNIT_QUEUE_FILE,
|
||||
CROWDIN_TEXTUNIT_QUEUE_VERSION, OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE,
|
||||
OFFICIAL_TEXTUNIT_TASK_QUEUE_VERSION,
|
||||
query_textunit_tasks, read_textunit_task_queue_at, write_crowdin_textunit_queue_at,
|
||||
write_official_textunit_queues, write_textunit_task_queue_at, CrowdinTextUnitQueue,
|
||||
CrowdinTextUnitQueueItem, OfficialTextUnitQueueReport, OfficialTextUnitTask,
|
||||
OfficialTextUnitTaskQuery, OfficialTextUnitTaskQueue, OfficialTextUnitTaskStatus,
|
||||
OfficialTextUnitTaskSummary, CROWDIN_TEXTUNIT_QUEUE_FILE, CROWDIN_TEXTUNIT_QUEUE_VERSION,
|
||||
OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE, OFFICIAL_TEXTUNIT_TASK_QUEUE_VERSION,
|
||||
};
|
||||
pub use official_update::{
|
||||
cached_game_main_config_for_metadata, diff_extended_snapshot, gc_orphan_staging,
|
||||
read_bootstrap_cache, read_snapshot, read_version_state, write_bootstrap_cache, write_snapshot,
|
||||
gc_orphan_staging_with_cas_root, read_bootstrap_cache, read_snapshot, read_version_state,
|
||||
verify_and_record_official_distribution_attestation, write_bootstrap_cache, write_snapshot,
|
||||
write_version_state, ExtendedSnapshotDelta, GameMainConfigSnapshot, LauncherMetadataSnapshot,
|
||||
LocalizedReleaseStatus, OfficialBootstrapCache, OfficialEndpointMarkerRole,
|
||||
OfficialEndpointMarkerSnapshot, OfficialFailedVersionRecord, OfficialServerInfoSource,
|
||||
@@ -120,7 +153,48 @@ pub use path_security::{
|
||||
open_append_file, read_file_no_symlink, set_file_mode, validate_output_root,
|
||||
validate_runtime_state_dir, write_file_atomic, PRIVATE_FILE_MODE, STATE_FILE_MODE,
|
||||
};
|
||||
pub use release_flow::ReleaseFlowStatusCode;
|
||||
pub use release_ops::{
|
||||
build_official_distribution_attestation, build_release_list, build_release_status,
|
||||
cleanup_releases, select_release_distribution, OfficialDistributionAttestationReport,
|
||||
ReleaseCleanupParams, ReleaseCleanupReport, ReleaseDistributionEntry, ReleaseDistributionPage,
|
||||
ReleaseDistributionParams, ReleaseListParams, ReleaseStatusReport, ReleaseSummary,
|
||||
};
|
||||
pub use resources::{InMemoryResourceRepository, SqliteResourceRepository};
|
||||
pub use translation_memory::{
|
||||
translation_memory_context, translation_memory_repository_path,
|
||||
SqliteTranslationMemoryRepository, TRANSLATION_MEMORY_REPOSITORY_FILE,
|
||||
TRANSLATION_MEMORY_SCHEMA_COMPONENT, TRANSLATION_MEMORY_SCHEMA_VERSION,
|
||||
};
|
||||
pub use translation_tasks::{
|
||||
build_translation_handoff, read_translation_handoff_at, sync_translation_task_repository_at,
|
||||
write_translation_handoff_at, PersistedTranslationTask, PersistedTranslationTaskState,
|
||||
ProviderRun, ProviderRunStatus, SqliteTranslationTaskRepository, TranslationHandoff,
|
||||
TranslationJob, TranslationJobStatus, TranslationTaskFailure, TranslationTaskResultSourceKind,
|
||||
TranslationTaskStatus, TranslationTaskSyncReport, TranslationTaskUnitResult, TranslationUnit,
|
||||
TranslationUnitStatus, TRANSLATION_HANDOFF_FILE, TRANSLATION_HANDOFF_SCHEMA_VERSION,
|
||||
TRANSLATION_TASK_REPOSITORY_FILE, TRANSLATION_TASK_SCHEMA_VERSION,
|
||||
};
|
||||
pub use translation_worker::{
|
||||
run_translation_worker_at, run_translation_worker_at_with_cancellation,
|
||||
run_translation_worker_with_provider, CrowdinProvider, MockTranslationProvider,
|
||||
TranslationProvider, TranslationProviderFailureClass, TranslationProviderKind,
|
||||
TranslationProviderRequest, TranslationProviderResponse, TranslationProviderUnit,
|
||||
TranslationProviderUnitResult, TranslationWorkerConfig, TranslationWorkerFailure,
|
||||
TranslationWorkerReport, DEFAULT_TRANSLATION_CONCURRENCY, DEFAULT_TRANSLATION_LEASE_SECONDS,
|
||||
DEFAULT_TRANSLATION_MAX_ATTEMPTS, DEFAULT_TRANSLATION_RETRY_BACKOFF,
|
||||
MAX_TRANSLATION_CONCURRENCY, MIN_TRANSLATION_CONCURRENCY, MOCK_TRANSLATION_FIXTURE_VERSION,
|
||||
};
|
||||
pub use translation_workflow::{
|
||||
completed_worker_translation_workbench, export_completed_worker_translation_workbench,
|
||||
export_translation_workbench, get_translation_entry, localized_patch_operations,
|
||||
localized_patch_operations_with_glossary_path, localized_text_asset_patches,
|
||||
read_translation_workbench, repack_bundle, set_translation, set_translation_checked,
|
||||
set_translation_checked_with_glossary_path, unset_translation, validate_translation_workbench,
|
||||
validate_translation_workbench_with_glossary_path, write_translation_workbench,
|
||||
RepackOperation, RepackReport, RepackSpec, TranslationWorkbench, TranslationWorkbenchEntry,
|
||||
TranslationWorkbenchValidationReport, REPACK_SPEC_VERSION, TRANSLATION_WORKBENCH_VERSION,
|
||||
};
|
||||
|
||||
/// Infrastructure 版本号
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+2817
-228
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,8 @@ pub struct OfficialParseConfig {
|
||||
pub resource_root: PathBuf,
|
||||
/// `unzip` executable used to inspect zip archives without extracting them.
|
||||
pub unzip_command: PathBuf,
|
||||
/// Ignore a matching previous cache and inspect every manifest candidate.
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
impl OfficialParseConfig {
|
||||
@@ -43,9 +45,16 @@ impl OfficialParseConfig {
|
||||
Self {
|
||||
resource_root: resource_root.into(),
|
||||
unzip_command: unzip_command.into(),
|
||||
force: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Enables or disables forced cache regeneration.
|
||||
pub fn with_force(mut self, force: bool) -> Self {
|
||||
self.force = force;
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns the parse-cache path for this resource root.
|
||||
pub fn cache_path(&self) -> PathBuf {
|
||||
self.resource_root.join(OFFICIAL_PARSE_CACHE_FILE)
|
||||
@@ -357,8 +366,16 @@ impl OfficialParseCacheService {
|
||||
config.resource_root.display()
|
||||
)
|
||||
})?;
|
||||
let previous_cache = read_parse_cache_at(&config.resource_root)?;
|
||||
let previous_textunit_index = read_textunit_index_at(&config.resource_root)?;
|
||||
let previous_cache = if config.force {
|
||||
None
|
||||
} else {
|
||||
read_parse_cache_at(&config.resource_root)?
|
||||
};
|
||||
let previous_textunit_index = if config.force {
|
||||
None
|
||||
} else {
|
||||
read_textunit_index_at(&config.resource_root)?
|
||||
};
|
||||
let mut summary = OfficialParseSummary {
|
||||
manifest_entry_count: manifest.entries.len(),
|
||||
..OfficialParseSummary::default()
|
||||
|
||||
@@ -211,6 +211,8 @@ impl<'a> OfficialReleaseImportService<'a> {
|
||||
resource_type,
|
||||
address: None,
|
||||
dependencies: Vec::new(),
|
||||
provider_id: None,
|
||||
bundle_name: None,
|
||||
crc: None,
|
||||
},
|
||||
metadata,
|
||||
|
||||
@@ -16,6 +16,7 @@ use crate::path_security::{
|
||||
ensure_path_within_root, ensure_safe_file_target, read_file_no_symlink, write_file_atomic,
|
||||
STATE_FILE_MODE,
|
||||
};
|
||||
use crate::sync_translation_task_repository_at;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -100,6 +101,33 @@ pub struct OfficialTextUnitTask {
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
/// Query filters for incremental TextUnit translation tasks.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct OfficialTextUnitTaskQuery {
|
||||
/// Filter by stable task ID.
|
||||
pub task_id: Option<String>,
|
||||
/// Filter by official release ID.
|
||||
pub official_release_id: Option<String>,
|
||||
/// Filter by resource destination.
|
||||
pub destination: Option<String>,
|
||||
/// Filter by destination glob pattern.
|
||||
pub path_pattern: Option<String>,
|
||||
/// Filter by ZIP/archive entry.
|
||||
pub archive_entry: Option<String>,
|
||||
/// Filter by task status, for example `queued_offline` or `skipped_parse_failed`.
|
||||
pub status: Option<String>,
|
||||
/// Filter by parse status, for example `parsed`, `failed`, or `skipped_unsupported`.
|
||||
pub parse_status: Option<String>,
|
||||
/// Filter by TextUnit format.
|
||||
pub text_unit_format: Option<String>,
|
||||
/// Filter tasks by whether a diagnostic reason is present.
|
||||
pub has_reason: Option<bool>,
|
||||
/// Filter tasks by whether a provider failure reason is present.
|
||||
pub has_failure_reason: Option<bool>,
|
||||
/// Filter by mutable provider-worker status.
|
||||
pub task_status: Option<String>,
|
||||
}
|
||||
|
||||
/// Aggregate counters for an incremental TextUnit task queue.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OfficialTextUnitTaskSummary {
|
||||
@@ -332,6 +360,16 @@ pub fn write_official_textunit_queues(
|
||||
write_textunit_task_queue_at(resource_root, &task_queue)?;
|
||||
|
||||
let task_queue_path = resource_root.join(OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE);
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|error| format!("构建官方 TextUnit 任务状态同步运行时失败:{error}"))?;
|
||||
runtime
|
||||
.block_on(sync_translation_task_repository_at(
|
||||
resource_root,
|
||||
&task_queue,
|
||||
))
|
||||
.map_err(|error| format!("同步官方 TextUnit 任务状态到 SQLite 失败:{error}"))?;
|
||||
let crowdin_queue =
|
||||
CrowdinTextUnitQueue::from_textunit_task_queue(task_queue_path.clone(), &task_queue);
|
||||
write_crowdin_textunit_queue_at(resource_root, &crowdin_queue)?;
|
||||
@@ -363,6 +401,18 @@ pub fn read_textunit_task_queue_at(
|
||||
Ok(Some(queue))
|
||||
}
|
||||
|
||||
/// Returns TextUnit translation tasks matching a query.
|
||||
pub fn query_textunit_tasks<'a>(
|
||||
queue: &'a OfficialTextUnitTaskQueue,
|
||||
query: &OfficialTextUnitTaskQuery,
|
||||
) -> Vec<&'a OfficialTextUnitTask> {
|
||||
queue
|
||||
.tasks
|
||||
.iter()
|
||||
.filter(|task| textunit_task_matches(task, query))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns whether the persisted TextUnit queue still matches current inputs.
|
||||
pub fn is_textunit_task_queue_current(
|
||||
resource_root: &Path,
|
||||
@@ -457,6 +507,100 @@ fn parse_entries_by_destination(
|
||||
by_destination
|
||||
}
|
||||
|
||||
pub(crate) fn textunit_task_matches(
|
||||
task: &OfficialTextUnitTask,
|
||||
query: &OfficialTextUnitTaskQuery,
|
||||
) -> bool {
|
||||
if query
|
||||
.task_id
|
||||
.as_ref()
|
||||
.is_some_and(|task_id| &task.task_id != task_id)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if query
|
||||
.official_release_id
|
||||
.as_ref()
|
||||
.is_some_and(|release_id| &task.official_release_id != release_id)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if query
|
||||
.destination
|
||||
.as_ref()
|
||||
.is_some_and(|destination| &task.destination != destination)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if query
|
||||
.path_pattern
|
||||
.as_ref()
|
||||
.is_some_and(|pattern| !glob_matches(pattern, &task.destination))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if query
|
||||
.archive_entry
|
||||
.as_ref()
|
||||
.is_some_and(|archive_entry| task.archive_entry.as_ref() != Some(archive_entry))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if query
|
||||
.status
|
||||
.as_ref()
|
||||
.is_some_and(|status| task.status.as_str() != status)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(status) = &query.parse_status {
|
||||
if task.parse_status.map(parse_status_label) != Some(status.as_str()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if query.text_unit_format.as_ref().is_some_and(|format| {
|
||||
!task
|
||||
.text_unit_formats
|
||||
.iter()
|
||||
.any(|task_format| task_format == format)
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
if query
|
||||
.has_reason
|
||||
.is_some_and(|has_reason| task.reason.is_some() != has_reason)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn parse_status_label(status: OfficialParseStatus) -> &'static str {
|
||||
match status {
|
||||
OfficialParseStatus::Parsed => "parsed",
|
||||
OfficialParseStatus::SkippedUnsupported => "skipped_unsupported",
|
||||
OfficialParseStatus::Failed => "failed",
|
||||
}
|
||||
}
|
||||
|
||||
fn glob_matches(pattern: &str, value: &str) -> bool {
|
||||
glob_matches_bytes(pattern.as_bytes(), value.as_bytes())
|
||||
}
|
||||
|
||||
fn glob_matches_bytes(pattern: &[u8], value: &[u8]) -> bool {
|
||||
match pattern.split_first() {
|
||||
None => value.is_empty(),
|
||||
Some((&b'*', rest)) => {
|
||||
glob_matches_bytes(rest, value)
|
||||
|| (!value.is_empty() && glob_matches_bytes(pattern, &value[1..]))
|
||||
}
|
||||
Some((&b'?', rest)) => !value.is_empty() && glob_matches_bytes(rest, &value[1..]),
|
||||
Some((&literal, rest)) => value
|
||||
.split_first()
|
||||
.is_some_and(|(&head, tail)| head == literal && glob_matches_bytes(rest, tail)),
|
||||
}
|
||||
}
|
||||
|
||||
fn skipped_no_parse_entry_task(
|
||||
change_set: &OfficialResourceChangeSet,
|
||||
change: &OfficialResourceChange,
|
||||
@@ -772,6 +916,61 @@ mod tests {
|
||||
assert_eq!(crowdin.items[0].destination, "Bundles/a.bundle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_textunit_tasks_filters_status_reason_and_format() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let queue = OfficialTextUnitTaskQueue::from_change_set_and_parse_cache(
|
||||
&change_set(temp.path()),
|
||||
&parse_cache(),
|
||||
);
|
||||
|
||||
let queued = query_textunit_tasks(
|
||||
&queue,
|
||||
&OfficialTextUnitTaskQuery {
|
||||
official_release_id: Some("release-new".to_string()),
|
||||
path_pattern: Some("Bundles/*.bundle".to_string()),
|
||||
status: Some("queued_offline".to_string()),
|
||||
parse_status: Some("parsed".to_string()),
|
||||
text_unit_format: Some("plain".to_string()),
|
||||
has_reason: Some(false),
|
||||
..OfficialTextUnitTaskQuery::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(queued.len(), 1);
|
||||
assert_eq!(queued[0].destination, "Bundles/a.bundle");
|
||||
assert_eq!(queued[0].reason, None);
|
||||
|
||||
let skipped_with_reason = query_textunit_tasks(
|
||||
&queue,
|
||||
&OfficialTextUnitTaskQuery {
|
||||
status: Some("skipped_unsupported".to_string()),
|
||||
has_reason: Some(true),
|
||||
..OfficialTextUnitTaskQuery::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(skipped_with_reason.len(), 1);
|
||||
assert_eq!(skipped_with_reason[0].destination, "Bundles/b.bundle");
|
||||
assert_eq!(
|
||||
skipped_with_reason[0].reason.as_deref(),
|
||||
Some("unsupported")
|
||||
);
|
||||
|
||||
let by_task_id = query_textunit_tasks(
|
||||
&queue,
|
||||
&OfficialTextUnitTaskQuery {
|
||||
task_id: Some("textunit/release-new/Bundles/c.bundle".to_string()),
|
||||
status: Some("skipped_no_parse_entry".to_string()),
|
||||
has_reason: Some(true),
|
||||
..OfficialTextUnitTaskQuery::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(by_task_id.len(), 1);
|
||||
assert_eq!(
|
||||
by_task_id[0].reason.as_deref(),
|
||||
Some("parse cache entry not found for changed resource")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_textunit_queues_persists_files() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,327 @@
|
||||
//! Stable status codes for the official-resource to localized-release flow.
|
||||
//!
|
||||
//! The codes describe observable lifecycle state. They are deliberately
|
||||
//! separate from `BAT-ERR-*`: an error code explains why an operation failed,
|
||||
//! while a flow status code explains what a caller can do next.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Stable status code shared by Rust reports and read-only RPC data.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ReleaseFlowStatusCode {
|
||||
/// No published official release is currently available.
|
||||
#[serde(rename = "official.unavailable")]
|
||||
OfficialUnavailable,
|
||||
/// The producer is discovering remote and local official-resource state.
|
||||
#[serde(rename = "official.checking")]
|
||||
OfficialChecking,
|
||||
/// The producer determined that a new or repaired official release is needed.
|
||||
#[serde(rename = "official.update_available")]
|
||||
OfficialUpdateAvailable,
|
||||
/// Official resources are being downloaded or reused into staging.
|
||||
#[serde(rename = "official.downloading")]
|
||||
OfficialDownloading,
|
||||
/// Downloaded official resources are being verified.
|
||||
#[serde(rename = "official.validating")]
|
||||
OfficialValidating,
|
||||
/// A verified official release is being staged or atomically published.
|
||||
#[serde(rename = "official.publishing")]
|
||||
OfficialPublishing,
|
||||
/// A verified official release has been published.
|
||||
#[serde(rename = "official.published")]
|
||||
OfficialPublished,
|
||||
/// The published official release already matches the observed remote state.
|
||||
#[serde(rename = "official.up_to_date")]
|
||||
OfficialUpToDate,
|
||||
/// Launcher/server-info has advanced before required CDN resources are readable.
|
||||
#[serde(rename = "official.waiting_for_resources")]
|
||||
OfficialWaitingForResources,
|
||||
/// Official-resource production failed before a publishable state was reached.
|
||||
#[serde(rename = "official.failed")]
|
||||
OfficialFailed,
|
||||
/// Parsing is blocked because there is no published official release.
|
||||
#[serde(rename = "parse.blocked_official")]
|
||||
ParseBlockedOfficial,
|
||||
/// A published official release exists but parse cache is not present yet.
|
||||
#[serde(rename = "parse.pending")]
|
||||
ParsePending,
|
||||
/// Parse cache or TextUnit index generation is running.
|
||||
#[serde(rename = "parse.running")]
|
||||
ParseRunning,
|
||||
/// Parse cache and TextUnit indexes are present without recorded parse failures.
|
||||
#[serde(rename = "parse.completed")]
|
||||
ParseCompleted,
|
||||
/// Parse cache exists but contains parser or extraction failures.
|
||||
#[serde(rename = "parse.completed_with_errors")]
|
||||
ParseCompletedWithErrors,
|
||||
/// Translation worker integration is not available for the observed release.
|
||||
#[serde(rename = "translation.unavailable")]
|
||||
TranslationUnavailable,
|
||||
/// Translation handoff files are being prepared from the official change set.
|
||||
#[serde(rename = "translation.handoff_preparing")]
|
||||
TranslationHandoffPreparing,
|
||||
/// Translation tasks have been queued to local offline handoff files.
|
||||
#[serde(rename = "translation.queued_offline")]
|
||||
TranslationQueuedOffline,
|
||||
/// Human proofreading is in progress for the observed localized release.
|
||||
#[serde(rename = "translation.manual_proofreading")]
|
||||
TranslationManualProofreading,
|
||||
/// Localized publication is blocked because there is no official release.
|
||||
#[serde(rename = "localized.blocked_official")]
|
||||
LocalizedBlockedOfficial,
|
||||
/// The current official release has no matching localized publication yet.
|
||||
#[serde(rename = "localized.pending")]
|
||||
LocalizedPending,
|
||||
/// A localized state exists but it does not match the current official release.
|
||||
#[serde(rename = "localized.stale")]
|
||||
LocalizedStale,
|
||||
/// A localized release is published and matches the current official release.
|
||||
#[serde(rename = "localized.published")]
|
||||
LocalizedPublished,
|
||||
/// A localized current release exists but its manifest or published bytes
|
||||
/// fail read-only integrity verification.
|
||||
#[serde(rename = "localized.degraded")]
|
||||
LocalizedDegraded,
|
||||
/// Distribution cannot serve a usable release for the observed channel.
|
||||
#[serde(rename = "distribution.blocked")]
|
||||
DistributionBlocked,
|
||||
/// Distribution can serve the published release.
|
||||
#[serde(rename = "distribution.ready")]
|
||||
DistributionReady,
|
||||
}
|
||||
|
||||
impl ReleaseFlowStatusCode {
|
||||
/// Returns the stable wire label.
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::OfficialUnavailable => "official.unavailable",
|
||||
Self::OfficialChecking => "official.checking",
|
||||
Self::OfficialUpdateAvailable => "official.update_available",
|
||||
Self::OfficialDownloading => "official.downloading",
|
||||
Self::OfficialValidating => "official.validating",
|
||||
Self::OfficialPublishing => "official.publishing",
|
||||
Self::OfficialPublished => "official.published",
|
||||
Self::OfficialUpToDate => "official.up_to_date",
|
||||
Self::OfficialWaitingForResources => "official.waiting_for_resources",
|
||||
Self::OfficialFailed => "official.failed",
|
||||
Self::ParseBlockedOfficial => "parse.blocked_official",
|
||||
Self::ParsePending => "parse.pending",
|
||||
Self::ParseRunning => "parse.running",
|
||||
Self::ParseCompleted => "parse.completed",
|
||||
Self::ParseCompletedWithErrors => "parse.completed_with_errors",
|
||||
Self::TranslationUnavailable => "translation.unavailable",
|
||||
Self::TranslationHandoffPreparing => "translation.handoff_preparing",
|
||||
Self::TranslationQueuedOffline => "translation.queued_offline",
|
||||
Self::TranslationManualProofreading => "translation.manual_proofreading",
|
||||
Self::LocalizedBlockedOfficial => "localized.blocked_official",
|
||||
Self::LocalizedPending => "localized.pending",
|
||||
Self::LocalizedStale => "localized.stale",
|
||||
Self::LocalizedPublished => "localized.published",
|
||||
Self::LocalizedDegraded => "localized.degraded",
|
||||
Self::DistributionBlocked => "distribution.blocked",
|
||||
Self::DistributionReady => "distribution.ready",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the short status value used alongside `status_code` in RPC
|
||||
/// payloads. This keeps existing human-facing labels independent from the
|
||||
/// namespaced wire code.
|
||||
pub const fn status(self) -> &'static str {
|
||||
match self {
|
||||
Self::OfficialUnavailable => "unavailable",
|
||||
Self::OfficialChecking => "checking",
|
||||
Self::OfficialUpdateAvailable => "update_available",
|
||||
Self::OfficialDownloading => "downloading",
|
||||
Self::OfficialValidating => "validating",
|
||||
Self::OfficialPublishing => "publishing",
|
||||
Self::OfficialPublished => "published",
|
||||
Self::OfficialUpToDate => "up_to_date",
|
||||
Self::OfficialWaitingForResources => "waiting_for_resources",
|
||||
Self::OfficialFailed => "failed",
|
||||
Self::ParseBlockedOfficial => "blocked_official",
|
||||
Self::ParsePending => "pending",
|
||||
Self::ParseRunning => "running",
|
||||
Self::ParseCompleted => "completed",
|
||||
Self::ParseCompletedWithErrors => "completed_with_errors",
|
||||
Self::TranslationUnavailable => "unavailable",
|
||||
Self::TranslationHandoffPreparing => "handoff_preparing",
|
||||
Self::TranslationQueuedOffline => "queued_offline",
|
||||
Self::TranslationManualProofreading => "manual_proofreading",
|
||||
Self::LocalizedBlockedOfficial => "blocked_official",
|
||||
Self::LocalizedPending => "pending",
|
||||
Self::LocalizedStale => "stale",
|
||||
Self::LocalizedPublished => "published",
|
||||
Self::LocalizedDegraded => "degraded",
|
||||
Self::DistributionBlocked => "blocked",
|
||||
Self::DistributionReady => "ready",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a status code read from a persisted daemon/RPC snapshot.
|
||||
fn parse_wire_code(code: &str) -> Option<Self> {
|
||||
Some(match code {
|
||||
"official.unavailable" => Self::OfficialUnavailable,
|
||||
"official.checking" => Self::OfficialChecking,
|
||||
"official.update_available" => Self::OfficialUpdateAvailable,
|
||||
"official.downloading" => Self::OfficialDownloading,
|
||||
"official.validating" => Self::OfficialValidating,
|
||||
"official.publishing" => Self::OfficialPublishing,
|
||||
"official.published" => Self::OfficialPublished,
|
||||
"official.up_to_date" => Self::OfficialUpToDate,
|
||||
"official.waiting_for_resources" => Self::OfficialWaitingForResources,
|
||||
"official.failed" => Self::OfficialFailed,
|
||||
"parse.blocked_official" => Self::ParseBlockedOfficial,
|
||||
"parse.pending" => Self::ParsePending,
|
||||
"parse.running" => Self::ParseRunning,
|
||||
"parse.completed" => Self::ParseCompleted,
|
||||
"parse.completed_with_errors" => Self::ParseCompletedWithErrors,
|
||||
"translation.unavailable" => Self::TranslationUnavailable,
|
||||
"translation.handoff_preparing" => Self::TranslationHandoffPreparing,
|
||||
"translation.queued_offline" => Self::TranslationQueuedOffline,
|
||||
"translation.manual_proofreading" => Self::TranslationManualProofreading,
|
||||
"localized.blocked_official" => Self::LocalizedBlockedOfficial,
|
||||
"localized.pending" => Self::LocalizedPending,
|
||||
"localized.stale" => Self::LocalizedStale,
|
||||
"localized.published" => Self::LocalizedPublished,
|
||||
"localized.degraded" => Self::LocalizedDegraded,
|
||||
"distribution.blocked" => Self::DistributionBlocked,
|
||||
"distribution.ready" => Self::DistributionReady,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the broad flow phase represented by this code.
|
||||
pub const fn phase(self) -> &'static str {
|
||||
match self {
|
||||
Self::OfficialUnavailable
|
||||
| Self::OfficialChecking
|
||||
| Self::OfficialUpdateAvailable
|
||||
| Self::OfficialDownloading
|
||||
| Self::OfficialValidating
|
||||
| Self::OfficialPublishing
|
||||
| Self::OfficialPublished
|
||||
| Self::OfficialUpToDate
|
||||
| Self::OfficialWaitingForResources
|
||||
| Self::OfficialFailed => "official_sync",
|
||||
Self::ParseBlockedOfficial
|
||||
| Self::ParsePending
|
||||
| Self::ParseRunning
|
||||
| Self::ParseCompleted
|
||||
| Self::ParseCompletedWithErrors => "parse",
|
||||
Self::TranslationUnavailable
|
||||
| Self::TranslationHandoffPreparing
|
||||
| Self::TranslationQueuedOffline
|
||||
| Self::TranslationManualProofreading => "translation",
|
||||
Self::LocalizedBlockedOfficial
|
||||
| Self::LocalizedPending
|
||||
| Self::LocalizedStale
|
||||
| Self::LocalizedPublished
|
||||
| Self::LocalizedDegraded => "localized_publish",
|
||||
Self::DistributionBlocked | Self::DistributionReady => "distribution",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the state is stable for the current observation.
|
||||
pub const fn terminal(self) -> bool {
|
||||
!matches!(
|
||||
self,
|
||||
Self::OfficialChecking
|
||||
| Self::OfficialDownloading
|
||||
| Self::OfficialValidating
|
||||
| Self::OfficialPublishing
|
||||
| Self::ParseRunning
|
||||
| Self::TranslationHandoffPreparing
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns whether the producer may retry the operation automatically.
|
||||
pub const fn retryable(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::OfficialWaitingForResources
|
||||
| Self::OfficialFailed
|
||||
| Self::ParsePending
|
||||
| Self::ParseCompletedWithErrors
|
||||
)
|
||||
}
|
||||
|
||||
/// Maps an existing official update result to the stable flow code.
|
||||
pub fn from_update_status(status: &str) -> Self {
|
||||
match status {
|
||||
"would_download" => Self::OfficialUpdateAvailable,
|
||||
"waiting_for_official_resources" => Self::OfficialWaitingForResources,
|
||||
"downloaded" => Self::OfficialPublished,
|
||||
"up_to_date" => Self::OfficialUpToDate,
|
||||
_ => Self::OfficialFailed,
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps an existing progress stage to the stable flow code.
|
||||
pub fn from_progress_stage(stage: &str) -> Self {
|
||||
match stage {
|
||||
"download" => Self::OfficialDownloading,
|
||||
"audit" | "snapshot" => Self::OfficialValidating,
|
||||
"publish" | "launcher-bootstrap" => Self::OfficialPublishing,
|
||||
"parse" => Self::ParseRunning,
|
||||
"changes" => Self::TranslationHandoffPreparing,
|
||||
"finish" => Self::OfficialPublished,
|
||||
_ => Self::OfficialChecking,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for ReleaseFlowStatusCode {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(code: &str) -> Result<Self, Self::Err> {
|
||||
Self::parse_wire_code(code).ok_or(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ReleaseFlowStatusCode;
|
||||
|
||||
#[test]
|
||||
fn status_codes_are_stable_and_namespaced() {
|
||||
let codes = [
|
||||
ReleaseFlowStatusCode::OfficialUnavailable,
|
||||
ReleaseFlowStatusCode::OfficialChecking,
|
||||
ReleaseFlowStatusCode::OfficialUpdateAvailable,
|
||||
ReleaseFlowStatusCode::OfficialDownloading,
|
||||
ReleaseFlowStatusCode::OfficialValidating,
|
||||
ReleaseFlowStatusCode::OfficialPublishing,
|
||||
ReleaseFlowStatusCode::OfficialPublished,
|
||||
ReleaseFlowStatusCode::OfficialUpToDate,
|
||||
ReleaseFlowStatusCode::OfficialWaitingForResources,
|
||||
ReleaseFlowStatusCode::OfficialFailed,
|
||||
ReleaseFlowStatusCode::ParseBlockedOfficial,
|
||||
ReleaseFlowStatusCode::ParsePending,
|
||||
ReleaseFlowStatusCode::ParseRunning,
|
||||
ReleaseFlowStatusCode::ParseCompleted,
|
||||
ReleaseFlowStatusCode::ParseCompletedWithErrors,
|
||||
ReleaseFlowStatusCode::TranslationUnavailable,
|
||||
ReleaseFlowStatusCode::TranslationHandoffPreparing,
|
||||
ReleaseFlowStatusCode::TranslationQueuedOffline,
|
||||
ReleaseFlowStatusCode::TranslationManualProofreading,
|
||||
ReleaseFlowStatusCode::LocalizedBlockedOfficial,
|
||||
ReleaseFlowStatusCode::LocalizedPending,
|
||||
ReleaseFlowStatusCode::LocalizedStale,
|
||||
ReleaseFlowStatusCode::LocalizedPublished,
|
||||
ReleaseFlowStatusCode::LocalizedDegraded,
|
||||
ReleaseFlowStatusCode::DistributionBlocked,
|
||||
ReleaseFlowStatusCode::DistributionReady,
|
||||
];
|
||||
let labels = codes.iter().map(|code| code.as_str()).collect::<Vec<_>>();
|
||||
let unique = labels.iter().collect::<std::collections::BTreeSet<_>>();
|
||||
assert_eq!(labels.len(), unique.len());
|
||||
assert!(labels.iter().all(|label| label.contains('.')));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_codes_round_trip_through_from_str() {
|
||||
let code = ReleaseFlowStatusCode::LocalizedPublished;
|
||||
assert_eq!(code.as_str().parse::<ReleaseFlowStatusCode>(), Ok(code));
|
||||
assert!("unknown.status".parse::<ReleaseFlowStatusCode>().is_err());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -129,6 +129,8 @@ impl SqliteResourceRepository {
|
||||
resource_type TEXT NOT NULL,
|
||||
local_path TEXT NOT NULL,
|
||||
address TEXT,
|
||||
provider_id TEXT,
|
||||
bundle_name TEXT,
|
||||
dependencies_json TEXT NOT NULL DEFAULT '[]',
|
||||
crc INTEGER,
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}'
|
||||
@@ -148,6 +150,8 @@ impl SqliteResourceRepository {
|
||||
"TEXT NOT NULL DEFAULT '{}'",
|
||||
)
|
||||
.await?;
|
||||
Self::ensure_column(&self.pool, "resources", "provider_id", "TEXT").await?;
|
||||
Self::ensure_column(&self.pool, "resources", "bundle_name", "TEXT").await?;
|
||||
|
||||
Self::execute_query(
|
||||
&self.pool,
|
||||
@@ -182,6 +186,39 @@ impl SqliteResourceRepository {
|
||||
)
|
||||
.await?;
|
||||
|
||||
Self::execute_query(
|
||||
&self.pool,
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE INDEX IF NOT EXISTS idx_resources_release_id
|
||||
ON resources(json_extract(metadata_json, '$.official_release_id'))
|
||||
"#,
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Self::execute_query(
|
||||
&self.pool,
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE INDEX IF NOT EXISTS idx_resources_platform
|
||||
ON resources(json_extract(metadata_json, '$.platform'))
|
||||
"#,
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Self::execute_query(
|
||||
&self.pool,
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE INDEX IF NOT EXISTS idx_resources_bundle_path
|
||||
ON resources(json_extract(metadata_json, '$.bundle_path'))
|
||||
"#,
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -280,6 +317,8 @@ impl SqliteResourceRepository {
|
||||
resource_type,
|
||||
local_path,
|
||||
address,
|
||||
provider_id,
|
||||
bundle_name,
|
||||
dependencies_json,
|
||||
crc,
|
||||
metadata_json,
|
||||
@@ -294,6 +333,8 @@ impl SqliteResourceRepository {
|
||||
resource_type: Self::resource_type_from_str(&resource_type)?,
|
||||
address,
|
||||
dependencies: Self::dependencies_from_json(&dependencies_json)?,
|
||||
provider_id,
|
||||
bundle_name,
|
||||
crc: crc.and_then(|value| u32::try_from(value).ok()),
|
||||
},
|
||||
metadata: Self::metadata_from_json(&metadata_json)?,
|
||||
@@ -326,6 +367,57 @@ impl SqliteResourceRepository {
|
||||
.push(" ESCAPE '\\'");
|
||||
}
|
||||
|
||||
if let Some(destination) = &query.destination {
|
||||
push_condition_prefix(builder, &mut has_where);
|
||||
builder.push("path = ");
|
||||
builder.push_bind(destination);
|
||||
}
|
||||
|
||||
if let Some(release_id) = &query.official_release_id {
|
||||
push_condition_prefix(builder, &mut has_where);
|
||||
builder.push("json_extract(metadata_json, '$.official_release_id') = ");
|
||||
builder.push_bind(release_id);
|
||||
}
|
||||
|
||||
if let Some(platform) = &query.platform {
|
||||
push_condition_prefix(builder, &mut has_where);
|
||||
builder.push("json_extract(metadata_json, '$.platform') = ");
|
||||
builder.push_bind(platform);
|
||||
}
|
||||
|
||||
if let Some(bundle_path) = &query.bundle_path {
|
||||
push_condition_prefix(builder, &mut has_where);
|
||||
builder.push("json_extract(metadata_json, '$.bundle_path') = ");
|
||||
builder.push_bind(bundle_path);
|
||||
}
|
||||
|
||||
if let Some(archive_entry) = &query.archive_entry {
|
||||
push_condition_prefix(builder, &mut has_where);
|
||||
builder.push(
|
||||
"EXISTS (SELECT 1 FROM json_each(metadata_json, '$.archive_entries') AS archive_entries WHERE archive_entries.value = ",
|
||||
);
|
||||
builder.push_bind(archive_entry);
|
||||
builder.push(")");
|
||||
}
|
||||
|
||||
if let Some(parse_status) = &query.parse_status {
|
||||
push_condition_prefix(builder, &mut has_where);
|
||||
builder.push(
|
||||
"EXISTS (SELECT 1 FROM json_each(metadata_json, '$.parse_statuses') AS parse_statuses WHERE parse_statuses.value = ",
|
||||
);
|
||||
builder.push_bind(parse_status);
|
||||
builder.push(")");
|
||||
}
|
||||
|
||||
if let Some(text_unit_format) = &query.text_unit_format {
|
||||
push_condition_prefix(builder, &mut has_where);
|
||||
builder.push(
|
||||
"EXISTS (SELECT 1 FROM json_each(metadata_json, '$.text_unit_formats') AS text_unit_formats WHERE text_unit_formats.value = ",
|
||||
);
|
||||
builder.push_bind(text_unit_format);
|
||||
builder.push(")");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -335,7 +427,7 @@ impl SqliteResourceRepository {
|
||||
limit: Option<usize>,
|
||||
) -> bat_core::Result<Vec<Resource>> {
|
||||
let mut builder = QueryBuilder::<Sqlite>::new(
|
||||
"SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json, crc, metadata_json FROM resources",
|
||||
"SELECT id, path, hash, size, resource_type, local_path, address, provider_id, bundle_name, dependencies_json, crc, metadata_json FROM resources",
|
||||
);
|
||||
Self::apply_filters(&mut builder, query)?;
|
||||
builder.push(" ORDER BY id");
|
||||
@@ -349,7 +441,14 @@ impl SqliteResourceRepository {
|
||||
.await
|
||||
.map_err(|error| bat_core::Error::Other(error.into()))?;
|
||||
|
||||
rows.into_iter().map(Self::resource_from_row).collect()
|
||||
let resources = rows
|
||||
.into_iter()
|
||||
.map(Self::resource_from_row)
|
||||
.collect::<bat_core::Result<Vec<_>>>()?;
|
||||
Ok(resources
|
||||
.into_iter()
|
||||
.filter(|resource| query_matches(query, resource))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn count_resources(&self, query: &ResourceQuery) -> bat_core::Result<u64> {
|
||||
@@ -375,9 +474,9 @@ impl ResourceRepository for SqliteResourceRepository {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO resources (
|
||||
id, path, hash, size, resource_type, local_path, address, dependencies_json, crc, metadata_json
|
||||
id, path, hash, size, resource_type, local_path, address, provider_id, bundle_name, dependencies_json, crc, metadata_json
|
||||
)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
path = excluded.path,
|
||||
hash = excluded.hash,
|
||||
@@ -385,6 +484,8 @@ impl ResourceRepository for SqliteResourceRepository {
|
||||
resource_type = excluded.resource_type,
|
||||
local_path = excluded.local_path,
|
||||
address = excluded.address,
|
||||
provider_id = excluded.provider_id,
|
||||
bundle_name = excluded.bundle_name,
|
||||
dependencies_json = excluded.dependencies_json,
|
||||
crc = excluded.crc,
|
||||
metadata_json = excluded.metadata_json
|
||||
@@ -397,6 +498,8 @@ impl ResourceRepository for SqliteResourceRepository {
|
||||
.bind(Self::resource_type_to_str(resource.entry.resource_type))
|
||||
.bind(resource.local_path.to_string_lossy().to_string())
|
||||
.bind(resource.entry.address.clone())
|
||||
.bind(resource.entry.provider_id.clone())
|
||||
.bind(resource.entry.bundle_name.clone())
|
||||
.bind(dependencies)
|
||||
.bind(resource.entry.crc.map(i64::from))
|
||||
.bind(metadata),
|
||||
@@ -409,7 +512,7 @@ impl ResourceRepository for SqliteResourceRepository {
|
||||
async fn find_by_id(&self, id: &str) -> bat_core::Result<Resource> {
|
||||
let row: Option<ResourceRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json, crc, metadata_json
|
||||
SELECT id, path, hash, size, resource_type, local_path, address, provider_id, bundle_name, dependencies_json, crc, metadata_json
|
||||
FROM resources
|
||||
WHERE id = ?1
|
||||
"#,
|
||||
@@ -427,7 +530,7 @@ impl ResourceRepository for SqliteResourceRepository {
|
||||
async fn find_by_hash(&self, hash: &str) -> bat_core::Result<Resource> {
|
||||
let row: Option<ResourceRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json, crc, metadata_json
|
||||
SELECT id, path, hash, size, resource_type, local_path, address, provider_id, bundle_name, dependencies_json, crc, metadata_json
|
||||
FROM resources
|
||||
WHERE hash = ?1
|
||||
ORDER BY id
|
||||
@@ -488,6 +591,8 @@ type ResourceRow = (
|
||||
String,
|
||||
String,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
String,
|
||||
Option<i64>,
|
||||
String,
|
||||
@@ -536,6 +641,63 @@ fn query_matches(query: &ResourceQuery, resource: &Resource) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(release_id) = &query.official_release_id {
|
||||
if resource.metadata.official_release_id.as_deref() != Some(release_id.as_str()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(platform) = &query.platform {
|
||||
if resource.metadata.platform.as_deref() != Some(platform.as_str()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(destination) = &query.destination {
|
||||
if resource.entry.path != *destination {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(bundle_path) = &query.bundle_path {
|
||||
if resource.metadata.bundle_path.as_deref() != Some(bundle_path.as_str()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(archive_entry) = &query.archive_entry {
|
||||
if !resource
|
||||
.metadata
|
||||
.archive_entries
|
||||
.iter()
|
||||
.any(|entry| entry == archive_entry)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(parse_status) = &query.parse_status {
|
||||
if !resource
|
||||
.metadata
|
||||
.parse_statuses
|
||||
.iter()
|
||||
.any(|status| status == parse_status)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(text_unit_format) = &query.text_unit_format {
|
||||
if !resource
|
||||
.metadata
|
||||
.text_unit_formats
|
||||
.iter()
|
||||
.any(|format| format == text_unit_format)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
@@ -578,6 +740,8 @@ mod tests {
|
||||
resource_type,
|
||||
address: None,
|
||||
dependencies: Vec::new(),
|
||||
provider_id: None,
|
||||
bundle_name: None,
|
||||
crc: None,
|
||||
},
|
||||
metadata: ResourceMetadata::default(),
|
||||
@@ -641,6 +805,7 @@ mod tests {
|
||||
resource_type: Some(ResourceType::AssetBundle),
|
||||
hash: Some("hash-a".to_string()),
|
||||
path_pattern: Some("synthetic-*.bundle".to_string()),
|
||||
..ResourceQuery::all()
|
||||
};
|
||||
|
||||
let results = repository.list(query).await.unwrap();
|
||||
@@ -653,6 +818,52 @@ mod tests {
|
||||
assert_eq!(repository.count(ResourceQuery::all()).await.unwrap(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_filters_by_release_parse_and_textunit_metadata() {
|
||||
let repository = InMemoryResourceRepository::new();
|
||||
let mut matching = resource(
|
||||
"resource/text-a",
|
||||
"TextAssets/a.json",
|
||||
"hash-a",
|
||||
ResourceType::TextAsset,
|
||||
);
|
||||
matching.metadata.official_release_id = Some("v-current".to_string());
|
||||
matching.metadata.platform = Some("windows".to_string());
|
||||
matching.metadata.bundle_path = Some("Bundles/story.bundle".to_string());
|
||||
matching.metadata.archive_entries = vec!["story/Scenario.json".to_string()];
|
||||
matching.metadata.parse_statuses = vec!["parsed".to_string()];
|
||||
matching.metadata.text_unit_formats = vec!["json".to_string()];
|
||||
repository.add(matching).await.unwrap();
|
||||
|
||||
let mut stale = resource(
|
||||
"resource/text-b",
|
||||
"TextAssets/b.json",
|
||||
"hash-b",
|
||||
ResourceType::TextAsset,
|
||||
);
|
||||
stale.metadata.official_release_id = Some("v-old".to_string());
|
||||
stale.metadata.platform = Some("android".to_string());
|
||||
stale.metadata.parse_statuses = vec!["failed".to_string()];
|
||||
stale.metadata.text_unit_formats = vec!["plain".to_string()];
|
||||
repository.add(stale).await.unwrap();
|
||||
|
||||
let query = ResourceQuery {
|
||||
official_release_id: Some("v-current".to_string()),
|
||||
platform: Some("windows".to_string()),
|
||||
destination: Some("TextAssets/a.json".to_string()),
|
||||
bundle_path: Some("Bundles/story.bundle".to_string()),
|
||||
archive_entry: Some("story/Scenario.json".to_string()),
|
||||
parse_status: Some("parsed".to_string()),
|
||||
text_unit_format: Some("json".to_string()),
|
||||
..ResourceQuery::all()
|
||||
};
|
||||
|
||||
let results = repository.list(query.clone()).await.unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].id, "resource/text-a");
|
||||
assert_eq!(repository.count(query).await.unwrap(), 1);
|
||||
}
|
||||
|
||||
async fn sqlite_repository() -> (tempfile::TempDir, SqliteResourceRepository) {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let repository = SqliteResourceRepository::new(temp_dir.path().join("resources.sqlite"))
|
||||
@@ -664,6 +875,20 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn sqlite_repository_persists_and_filters_resources() {
|
||||
let (_temp_dir, repository) = sqlite_repository().await;
|
||||
let indexes = sqlx::query_scalar::<_, String>(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'resources'",
|
||||
)
|
||||
.fetch_all(&repository.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(indexes
|
||||
.iter()
|
||||
.any(|name| name == "idx_resources_release_id"));
|
||||
assert!(indexes.iter().any(|name| name == "idx_resources_platform"));
|
||||
assert!(indexes
|
||||
.iter()
|
||||
.any(|name| name == "idx_resources_bundle_path"));
|
||||
|
||||
let mut resource = resource(
|
||||
"resource/sqlite-a",
|
||||
"assets/model.bundle",
|
||||
@@ -675,10 +900,18 @@ mod tests {
|
||||
.entry
|
||||
.dependencies
|
||||
.push("assets/shared.bundle".to_string());
|
||||
resource.entry.provider_id = Some(
|
||||
"UnityEngine.ResourceManagement.ResourceProviders.AssetBundleProvider".to_string(),
|
||||
);
|
||||
resource.entry.bundle_name = Some("assets/model.bundle".to_string());
|
||||
resource.metadata.official_release_id = Some("release-1".to_string());
|
||||
resource.metadata.platform = Some("windows".to_string());
|
||||
resource.metadata.bundle_path = Some("assets/model.bundle".to_string());
|
||||
resource.metadata.archive_entries = vec!["serialized/Scenario".to_string()];
|
||||
resource.metadata.parse_statuses = vec!["parsed".to_string()];
|
||||
resource.metadata.text_assets = vec!["Scenario".to_string()];
|
||||
resource.metadata.text_unit_count = 3;
|
||||
resource.metadata.text_unit_formats = vec!["json".to_string()];
|
||||
|
||||
repository.add(resource.clone()).await.unwrap();
|
||||
|
||||
@@ -688,13 +921,31 @@ mod tests {
|
||||
by_id.entry.dependencies,
|
||||
vec!["assets/shared.bundle".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
by_id.entry.provider_id.as_deref(),
|
||||
Some("UnityEngine.ResourceManagement.ResourceProviders.AssetBundleProvider")
|
||||
);
|
||||
assert_eq!(
|
||||
by_id.entry.bundle_name.as_deref(),
|
||||
Some("assets/model.bundle")
|
||||
);
|
||||
assert_eq!(
|
||||
by_id.metadata.official_release_id.as_deref(),
|
||||
Some("release-1")
|
||||
);
|
||||
assert_eq!(by_id.metadata.platform.as_deref(), Some("windows"));
|
||||
assert_eq!(
|
||||
by_id.metadata.bundle_path.as_deref(),
|
||||
Some("assets/model.bundle")
|
||||
);
|
||||
assert_eq!(
|
||||
by_id.metadata.archive_entries,
|
||||
vec!["serialized/Scenario".to_string()]
|
||||
);
|
||||
assert_eq!(by_id.metadata.parse_statuses, vec!["parsed".to_string()]);
|
||||
assert_eq!(by_id.metadata.text_assets, vec!["Scenario".to_string()]);
|
||||
assert_eq!(by_id.metadata.text_unit_count, 3);
|
||||
assert_eq!(by_id.metadata.text_unit_formats, vec!["json".to_string()]);
|
||||
assert_eq!(
|
||||
repository.find_by_hash("hash-sqlite-a").await.unwrap().id,
|
||||
resource.id
|
||||
@@ -709,6 +960,28 @@ mod tests {
|
||||
let count = repository.count(ResourceQuery::all()).await.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
|
||||
let query = ResourceQuery {
|
||||
official_release_id: Some("release-1".to_string()),
|
||||
platform: Some("windows".to_string()),
|
||||
destination: Some("assets/model.bundle".to_string()),
|
||||
bundle_path: Some("assets/model.bundle".to_string()),
|
||||
archive_entry: Some("serialized/Scenario".to_string()),
|
||||
parse_status: Some("parsed".to_string()),
|
||||
text_unit_format: Some("json".to_string()),
|
||||
..ResourceQuery::all()
|
||||
};
|
||||
let filtered = repository.list(query.clone()).await.unwrap();
|
||||
assert_eq!(filtered.len(), 1);
|
||||
assert_eq!(filtered[0].id, resource.id);
|
||||
assert_eq!(repository.count(query).await.unwrap(), 1);
|
||||
|
||||
let missing = ResourceQuery {
|
||||
official_release_id: Some("release-missing".to_string()),
|
||||
..ResourceQuery::all()
|
||||
};
|
||||
assert!(repository.list(missing.clone()).await.unwrap().is_empty());
|
||||
assert_eq!(repository.count(missing).await.unwrap(), 0);
|
||||
|
||||
repository.delete(&resource.id).await.unwrap();
|
||||
assert!(matches!(
|
||||
repository.find_by_id(&resource.id).await,
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
//! Shared, deliberately small SQLite schema-migration primitives.
|
||||
//!
|
||||
//! Component owners still define their own schema fingerprints and migration
|
||||
//! steps. This module only owns the read-only preflight, schema snapshot, and
|
||||
//! writer-lock mechanics shared by the long-lived SQLite stores.
|
||||
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
use sqlx::{Row, SqliteConnection, SqlitePool};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
pub const SCHEMA_MIGRATIONS_TABLE: &str = "schema_migrations";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SqliteColumn {
|
||||
pub data_type: String,
|
||||
pub not_null: bool,
|
||||
pub default_value: Option<String>,
|
||||
pub primary_key: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SqliteSchemaSnapshot {
|
||||
/// Non-internal SQLite objects, including tables, indexes, views, and
|
||||
/// triggers. Internal `sqlite_autoindex_*` objects are omitted.
|
||||
pub objects: BTreeSet<(String, String)>,
|
||||
pub tables: BTreeMap<String, BTreeMap<String, SqliteColumn>>,
|
||||
pub indexes: BTreeMap<String, BTreeMap<String, Vec<String>>>,
|
||||
pub component_version: Option<i64>,
|
||||
}
|
||||
|
||||
impl SqliteSchemaSnapshot {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.objects.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ExpectedColumn<'a> {
|
||||
pub name: &'a str,
|
||||
pub data_type: &'a str,
|
||||
pub not_null: bool,
|
||||
pub default_value: Option<&'a str>,
|
||||
pub primary_key: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ExpectedTable<'a> {
|
||||
pub name: &'a str,
|
||||
pub columns: &'a [ExpectedColumn<'a>],
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ExpectedIndex<'a> {
|
||||
pub table: &'a str,
|
||||
pub name: &'a str,
|
||||
pub columns: &'a [&'a str],
|
||||
}
|
||||
|
||||
/// Opens an existing database with SQLite's read-only flag and snapshots its
|
||||
/// schema before a writable connection can perform any mutation.
|
||||
pub async fn read_only_preflight(
|
||||
path: &Path,
|
||||
component: &str,
|
||||
) -> Result<SqliteSchemaSnapshot, sqlx::Error> {
|
||||
let has_wal_sidecar =
|
||||
sidecar_path(path, "-wal").exists() || sidecar_path(path, "-shm").exists();
|
||||
let options = SqliteConnectOptions::from_str(&format!("sqlite://{}", path.display()))?
|
||||
.read_only(true)
|
||||
.create_if_missing(false)
|
||||
// A cleanly closed WAL database has all committed pages in the main
|
||||
// file. Immutable read-only mode prevents SQLite from creating a new
|
||||
// `-shm` sidecar during future-schema rejection. Live WAL sidecars
|
||||
// must remain visible to the preflight reader.
|
||||
.immutable(!has_wal_sidecar)
|
||||
.busy_timeout(Duration::from_secs(30));
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(options)
|
||||
.await?;
|
||||
let snapshot = {
|
||||
let mut connection = pool.acquire().await?;
|
||||
snapshot_connection(&mut connection, component).await
|
||||
};
|
||||
pool.close().await;
|
||||
snapshot
|
||||
}
|
||||
|
||||
/// Connects a writable single-connection pool, retrying the SQLite-specific
|
||||
/// exclusive lock needed when a connection switches an existing database to
|
||||
/// WAL mode. SQLite's busy timeout cannot wait for that PRAGMA, so the retry
|
||||
/// belongs around connection establishment rather than only around writes.
|
||||
pub async fn connect_writable_pool(
|
||||
options: SqliteConnectOptions,
|
||||
) -> Result<SqlitePool, sqlx::Error> {
|
||||
const MAX_ATTEMPTS: usize = 32;
|
||||
|
||||
for attempt in 0..=MAX_ATTEMPTS {
|
||||
match SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(options.clone())
|
||||
.await
|
||||
{
|
||||
Ok(pool) => return Ok(pool),
|
||||
Err(error) if attempt < MAX_ATTEMPTS && is_sqlite_lock_error(&error) => {
|
||||
let delay_millis = (25 * (attempt as u64 + 1)).min(250);
|
||||
tokio::time::sleep(Duration::from_millis(delay_millis)).await;
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
unreachable!("SQLite connection retry loop always returns")
|
||||
}
|
||||
|
||||
fn is_sqlite_lock_error(error: &sqlx::Error) -> bool {
|
||||
error.to_string().contains("database is locked")
|
||||
}
|
||||
|
||||
/// Snapshots the schema using an already-open connection. The caller may use
|
||||
/// this both for read-only preflight and inside the migration transaction.
|
||||
pub async fn snapshot_connection(
|
||||
connection: &mut SqliteConnection,
|
||||
component: &str,
|
||||
) -> Result<SqliteSchemaSnapshot, sqlx::Error> {
|
||||
let object_rows = sqlx::query(
|
||||
"SELECT type, name FROM sqlite_master
|
||||
WHERE name NOT LIKE 'sqlite_%'
|
||||
ORDER BY type, name",
|
||||
)
|
||||
.fetch_all(&mut *connection)
|
||||
.await?;
|
||||
|
||||
let mut objects = BTreeSet::new();
|
||||
let mut table_names = BTreeSet::new();
|
||||
for row in object_rows {
|
||||
let object_type: String = row.try_get("type")?;
|
||||
let name: String = row.try_get("name")?;
|
||||
if object_type == "table" {
|
||||
table_names.insert(name.clone());
|
||||
}
|
||||
objects.insert((object_type, name));
|
||||
}
|
||||
|
||||
let mut tables = BTreeMap::new();
|
||||
let mut indexes = BTreeMap::new();
|
||||
for table in table_names {
|
||||
let quoted_table = quote_identifier(&table);
|
||||
let column_rows = sqlx::query(&format!("PRAGMA table_info({quoted_table})"))
|
||||
.fetch_all(&mut *connection)
|
||||
.await?;
|
||||
let mut columns = BTreeMap::new();
|
||||
for row in column_rows {
|
||||
let name: String = row.try_get("name")?;
|
||||
let data_type: String = row.try_get("type")?;
|
||||
let not_null: i64 = row.try_get("notnull")?;
|
||||
let default_value: Option<String> = row.try_get("dflt_value")?;
|
||||
let primary_key: i64 = row.try_get("pk")?;
|
||||
columns.insert(
|
||||
name,
|
||||
SqliteColumn {
|
||||
data_type,
|
||||
not_null: not_null != 0,
|
||||
default_value,
|
||||
primary_key: primary_key != 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
tables.insert(table.clone(), columns);
|
||||
|
||||
let index_rows = sqlx::query(&format!("PRAGMA index_list({quoted_table})"))
|
||||
.fetch_all(&mut *connection)
|
||||
.await?;
|
||||
let mut table_indexes = BTreeMap::new();
|
||||
for row in index_rows {
|
||||
let index_name: String = row.try_get("name")?;
|
||||
if index_name.starts_with("sqlite_autoindex_") {
|
||||
continue;
|
||||
}
|
||||
let quoted_index = quote_identifier(&index_name);
|
||||
let index_columns = sqlx::query(&format!("PRAGMA index_info({quoted_index})"))
|
||||
.fetch_all(&mut *connection)
|
||||
.await?;
|
||||
let mut columns = Vec::new();
|
||||
for index_column in index_columns {
|
||||
let sequence: i64 = index_column.try_get("seqno")?;
|
||||
let name: Option<String> = index_column.try_get("name")?;
|
||||
if sequence < 0 {
|
||||
continue;
|
||||
}
|
||||
let name = name.ok_or_else(|| {
|
||||
sqlx::Error::Protocol(format!(
|
||||
"SQLite index {index_name} has an unnamed column"
|
||||
))
|
||||
})?;
|
||||
columns.push((sequence, name));
|
||||
}
|
||||
columns.sort_by_key(|(sequence, _)| *sequence);
|
||||
table_indexes.insert(
|
||||
index_name,
|
||||
columns
|
||||
.into_iter()
|
||||
.map(|(_, name)| name)
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
if !table_indexes.is_empty() {
|
||||
indexes.insert(table, table_indexes);
|
||||
}
|
||||
}
|
||||
|
||||
let component_version = if tables.contains_key(SCHEMA_MIGRATIONS_TABLE) {
|
||||
sqlx::query_scalar("SELECT version FROM schema_migrations WHERE component = ?1")
|
||||
.bind(component)
|
||||
.fetch_optional(&mut *connection)
|
||||
.await?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(SqliteSchemaSnapshot {
|
||||
objects,
|
||||
tables,
|
||||
indexes,
|
||||
component_version,
|
||||
})
|
||||
}
|
||||
|
||||
/// Starts a real SQLite writer transaction. `BEGIN IMMEDIATE` serializes DDL
|
||||
/// migration writers instead of allowing two preflight results to race.
|
||||
pub async fn begin_immediate(
|
||||
pool: &SqlitePool,
|
||||
) -> Result<sqlx::Transaction<'static, sqlx::Sqlite>, sqlx::Error> {
|
||||
pool.begin_with("BEGIN IMMEDIATE").await
|
||||
}
|
||||
|
||||
pub async fn write_component_version(
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||
component: &str,
|
||||
version: u32,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"INSERT INTO schema_migrations(component, version) VALUES (?1, ?2)
|
||||
ON CONFLICT(component) DO UPDATE SET version = excluded.version",
|
||||
)
|
||||
.bind(component)
|
||||
.bind(i64::from(version))
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_schema_migrations_table(
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
component TEXT PRIMARY KEY NOT NULL,
|
||||
version INTEGER NOT NULL CHECK(version >= 1)
|
||||
)",
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn matches_fingerprint(
|
||||
snapshot: &SqliteSchemaSnapshot,
|
||||
expected_tables: &[ExpectedTable<'_>],
|
||||
expected_indexes: &[ExpectedIndex<'_>],
|
||||
) -> bool {
|
||||
let expected_table_names = expected_tables
|
||||
.iter()
|
||||
.map(|table| table.name)
|
||||
.collect::<BTreeSet<_>>();
|
||||
let actual_table_names: BTreeSet<&str> = snapshot.tables.keys().map(String::as_str).collect();
|
||||
if actual_table_names != expected_table_names {
|
||||
return false;
|
||||
}
|
||||
|
||||
let expected_object_names = expected_tables
|
||||
.iter()
|
||||
.map(|table| ("table", table.name))
|
||||
.chain(expected_indexes.iter().map(|index| ("index", index.name)))
|
||||
.collect::<BTreeSet<_>>();
|
||||
let actual_object_names = snapshot
|
||||
.objects
|
||||
.iter()
|
||||
.map(|(object_type, name)| (object_type.as_str(), name.as_str()))
|
||||
.collect::<BTreeSet<_>>();
|
||||
if actual_object_names != expected_object_names {
|
||||
return false;
|
||||
}
|
||||
|
||||
for table in expected_tables {
|
||||
let Some(actual_columns) = snapshot.tables.get(table.name) else {
|
||||
return false;
|
||||
};
|
||||
if actual_columns.len() != table.columns.len() {
|
||||
return false;
|
||||
}
|
||||
for expected in table.columns {
|
||||
let Some(actual) = actual_columns.get(expected.name) else {
|
||||
return false;
|
||||
};
|
||||
if actual.data_type.to_ascii_uppercase() != expected.data_type
|
||||
|| actual.not_null != expected.not_null
|
||||
|| actual.primary_key != expected.primary_key
|
||||
|| normalize_default(actual.default_value.as_deref())
|
||||
!= normalize_default(expected.default_value)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let expected_indexes = expected_indexes
|
||||
.iter()
|
||||
.map(|index| {
|
||||
(
|
||||
index.table.to_string(),
|
||||
index.name.to_string(),
|
||||
index
|
||||
.columns
|
||||
.iter()
|
||||
.map(|column| (*column).to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
})
|
||||
.collect::<BTreeSet<_>>();
|
||||
let actual_indexes = snapshot
|
||||
.indexes
|
||||
.iter()
|
||||
.flat_map(|(table, indexes)| {
|
||||
indexes
|
||||
.iter()
|
||||
.map(|(name, columns)| (table.clone(), name.clone(), columns.clone()))
|
||||
})
|
||||
.collect::<BTreeSet<_>>();
|
||||
actual_indexes == expected_indexes
|
||||
}
|
||||
|
||||
pub fn schema_migrations_table() -> ExpectedTable<'static> {
|
||||
ExpectedTable {
|
||||
name: SCHEMA_MIGRATIONS_TABLE,
|
||||
columns: &[
|
||||
ExpectedColumn {
|
||||
name: "component",
|
||||
data_type: "TEXT",
|
||||
not_null: true,
|
||||
default_value: None,
|
||||
primary_key: true,
|
||||
},
|
||||
ExpectedColumn {
|
||||
name: "version",
|
||||
data_type: "INTEGER",
|
||||
not_null: true,
|
||||
default_value: None,
|
||||
primary_key: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_default(value: Option<&str>) -> Option<String> {
|
||||
value.map(|value| value.trim().to_ascii_lowercase())
|
||||
}
|
||||
|
||||
fn quote_identifier(value: &str) -> String {
|
||||
format!("\"{}\"", value.replace('"', "\"\""))
|
||||
}
|
||||
|
||||
fn sidecar_path(path: &Path, suffix: &str) -> std::path::PathBuf {
|
||||
std::path::PathBuf::from(format!("{}{}", path.display(), suffix))
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user