mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 10:04:55 +08:00
Compare commits
33
Commits
550ee7fd9a
...
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
|
@@ -1,247 +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
|
|
||||||
|
|
||||||
go-api:
|
|
||||||
name: Build and test Go API
|
|
||||||
runs-on: linux
|
|
||||||
|
|
||||||
env:
|
|
||||||
GOCACHE: /tmp/bat-go-cache
|
|
||||||
|
|
||||||
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 Go tool version
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
source /var/lib/act_runner/env.sh
|
|
||||||
|
|
||||||
command -v go
|
|
||||||
go version
|
|
||||||
|
|
||||||
- name: Run Go API tests
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
source /var/lib/act_runner/env.sh
|
|
||||||
make test-go-api
|
|
||||||
|
|
||||||
- name: Run Go API vet
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
source /var/lib/act_runner/env.sh
|
|
||||||
go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/...
|
|
||||||
|
|
||||||
- name: Build Go API
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
source /var/lib/act_runner/env.sh
|
|
||||||
go build -o /tmp/bat-api ./cmd/bat-api
|
|
||||||
|
|
||||||
- name: Run documentation status gate
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
make check-docs
|
|
||||||
@@ -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. 默认工作于用户本地环境。不要把生产环境当作开发环境。
|
BlueArchiveToolkit 不以“最小修复”为工程目标。不要为了让单个 testcase 通过、暂时消除表面症状或缩小 diff,而留下已经能够确认的同根因问题。
|
||||||
2. 真实资源下载、smoke run 和手动验证必须写入隔离目录,例如 `/tmp` 或显式指定的测试目录。
|
|
||||||
3. 不要默认读取、修改或污染现有客户端目录、生产资源目录或 `/home/wanye/D/BlueArchive` 这类本地资源目录。
|
|
||||||
4. 不要要求安装官方启动器作为生产运行前提。可以分析启动器资源或官方公开数据,但生产链路必须能在 Linux 环境中独立运行。
|
|
||||||
5. 涉及官方资源时,优先使用官方 `.hash`、catalog、manifest 和可复现 fixture 做校验依据。
|
|
||||||
|
|
||||||
## 架构原则
|
处理问题时优先保证长期可维护性、可用性、安全性、明确契约、恢复能力和回归覆盖。进入一个工程边界后,应根据实际相关性检查正常路径、异常路径、并发、重试、恢复、兼容、持久化和资源限制,并把属于同一 root cause 或同一 contract 的问题完整收口。
|
||||||
|
|
||||||
1. 仓库采用 monorepo;模块必须边界清晰、高内聚、低耦合。
|
这不意味着无边界重构。不要为了架构形式、代码行数或“以后也许会用”扩大修改范围;与当前 contract 无关的问题应记录到 `TODO.md`,留给后续独立处理。
|
||||||
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` 掩盖未完成设计。确实无法完成时,应在当前缺口文档中说明边界、风险和后续工作。
|
|
||||||
|
|
||||||
## 当前冻结
|
跨模块问题必须沿真实状态所有权和调用链检查。例如 Rust 状态经 RPC 暴露给 Go,再由 HTTP 或 Web 消费时,不能只修改其中一层而让其他层继续保持矛盾语义。
|
||||||
|
|
||||||
1. UnityFS / AssetBundle / Addressables / TypeTree 解析模块当前处于维护冻结,细则见 `docs/reports/PARSER_FREEZE.md`。
|
持久化和状态机修改应考虑 schema/version、transaction、crash consistency、retry、recovery 与兼容读取;解析器、压缩包和其他外部输入应考虑 size/count/depth 等资源边界以及 malformed input 的确定性失败。
|
||||||
2. 冻结期不继续新增解析类型、字段族、catalog 结构覆盖、写入型解析 RPC/CLI 或合成 fixture 驱动的能力扩展。
|
|
||||||
3. 冻结期允许且优先处理编译、测试、clippy、真实运行回归、错误诊断、状态一致性、缓存复用和文档一致性问题。
|
|
||||||
4. 如果用户明确要求继续解析扩展,必须先指出冻结状态、说明风险,并获得明确解冻或例外授权。
|
|
||||||
|
|
||||||
## 开发流程
|
## 以什么为准
|
||||||
|
|
||||||
1. 动手前先读相关文档和代码,确认当前真实状态。
|
仓库里有不少历史文档,不能混着看。
|
||||||
2. 对跨模块、架构、数据格式或用户工作流有影响的改动,先给出设计判断或简短计划。
|
|
||||||
3. 实现后必须同步验证。验证范围要覆盖改动实际影响面,而不是只跑最窄的命令。
|
|
||||||
4. 涉及用户可见行为、运行方式、架构边界或缺口状态时,必须同步更新文档。
|
|
||||||
5. 保持改动范围和任务目标一致;不要顺手做无关重构或格式化 churn。
|
|
||||||
6. 如果需求、技术路线或设计存在明显风险,应直接指出并给出可执行替代方案。
|
|
||||||
7. 不确定的事实必须查证或询问;不要凭空调用不存在的接口、命令、路径或线上资源。
|
|
||||||
|
|
||||||
## 质量要求
|
判断**当前实现**时,优先参考:
|
||||||
|
|
||||||
1. 所有错误必须显式处理,并给出可诊断信息。
|
* 当前源码和测试;
|
||||||
2. 日志应结构化或至少足够定位阶段、路径、版本、URL、重试、校验和失败原因。
|
* `CURRENT_STATUS.md`;
|
||||||
3. 下载、写文件、状态切换和发布操作必须考虑原子性、断点续传、并发锁、失败恢复和清理策略。
|
* 对应模块的专项状态文档,例如 `docs/reports/GO_STATUS.md`;
|
||||||
4. 本地状态文件和索引必须有版本字段或兼容策略。
|
* 已冻结的 RPC、release、schema 等契约。
|
||||||
5. 新增 fixture、golden 或回归样本时,应说明它覆盖的真实风险。
|
|
||||||
6. 默认验证命令见 `docs/guides/development.md`;稳定工程基线见 `docs/guides/baseline.md`。
|
|
||||||
|
|
||||||
## 文档职责
|
`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. 代码、测试和权威文档是否仍然一致。
|
||||||
|
|||||||
+9
-7
@@ -13,7 +13,8 @@
|
|||||||
- 新增 `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` 资源 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/分发层的关系
|
- `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` 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` 增加 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` 增加 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/` 管理面板预留
|
- `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 的边界
|
- USERGUIDE 补充 `bat-api` 资源 bootstrap / 分发章节,说明与 Rust `bat` 的运行关系、接口、CDN 响应语义和不仿造业务 API 的边界
|
||||||
- 官方同步新增 `official-parse-cache.json`:校验发布后从下载 manifest 覆盖直接 UnityFS bundle、zip 内 UnityFS 条目和非候选资源记录;URL、相对路径、size 和 BLAKE3 未变化时跳过重复解析
|
- 官方同步新增 `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`
|
- 官方原版资源和汉化产物目录分离:`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)
|
- 官方下载失败重试之间加入指数退避(网络类失败 200ms→400ms→800ms…,上限 5s)
|
||||||
|
|
||||||
### 计划
|
### 计划
|
||||||
- [ ] `bat-api` 后续:全量 release 联调、refresh mtime/size 增量缓存、完整 launcher 安装包更新链(若需要,新 issue)、API 持久化层接入预留 database/redis 配置;Rust/Go snapshot contract fixture 已落仓库
|
- [ ] `bat-api` 后续:refresh mtime/size 增量缓存、完整 launcher 安装包更新链(若需要,新 issue)、API 持久化层接入预留 database/redis 配置;Rust/Go snapshot contract fixture 与同机 live smoke 已完成
|
||||||
- [ ] 官方同步结果接入 CAS + ResourceRepository 的用户级工作流
|
- [ ] 扩展 CAS + ResourceRepository 的用户级查询、翻译记忆和通用 Patch 发布资源视图
|
||||||
- [ ] 官方下载/导入路径接入 CRC/size 校验(复用 `verify_downloaded_bytes`)
|
- [ ] 官方下载/导入路径接入 CRC/size 校验(复用 `verify_downloaded_bytes`)
|
||||||
- [ ] 汉化 Patch 发布:维护 `localized-output/current`、`localized-version-state`、`localized` 状态切换和回滚
|
- [ ] 完成通用 manifest 驱动的 Patch build/rollback、复杂 AssetBundle 重打包和完整汉化文件集合发布
|
||||||
- [ ] 实现翻译系统
|
- [ ] 扩展 provider 编排、翻译记忆和人工协作工作流
|
||||||
- [ ] 完成发布级 Patch build/rollback、复杂 AssetBundle 重打包和汉化发布统一
|
- [ ] 完成 Web 协作后台的持久化、权限和长期任务能力
|
||||||
- [ ] 实现 Web 管理后台
|
|
||||||
|
|
||||||
## [0.2.0] - 2026-07-17
|
## [0.2.0] - 2026-07-17
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -37,9 +37,14 @@
|
|||||||
基础验证命令见 `docs/guides/development.md`。常用最低门禁:
|
基础验证命令见 `docs/guides/development.md`。常用最低门禁:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo fmt --check
|
cargo fmt --all -- --check
|
||||||
|
cargo check --workspace
|
||||||
cargo test --workspace
|
cargo test --workspace
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
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 相关改动还应运行:
|
如果改动只影响部分 crate,可以先跑更窄的测试,但合并前必须确保影响面被覆盖。官方资源同步、下载、daemon、status、verify 或 repair 相关改动还应运行:
|
||||||
|
|||||||
+113
-65
@@ -1,6 +1,6 @@
|
|||||||
# BlueArchiveToolkit 当前工作区状态
|
# BlueArchiveToolkit 当前工作区状态
|
||||||
|
|
||||||
- **更新时间**:2026-08-03
|
- **更新时间**:2026-09-13
|
||||||
- **状态来源**:本地工作区盘点、代码验证和最新提交
|
- **状态来源**:本地工作区盘点、代码验证和最新提交
|
||||||
- **状态分支**:`experiment`
|
- **状态分支**:`experiment`
|
||||||
- **最新已推送功能提交**:以当前 `git log --oneline -1` 为准
|
- **最新已推送功能提交**:以当前 `git log --oneline -1` 为准
|
||||||
@@ -20,22 +20,71 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
|
|||||||
3. 默认平台为 `Windows + Android`。
|
3. 默认平台为 `Windows + Android`。
|
||||||
4. 能生成官方全量 pull plan,执行真实下载,维护 release 内的 `official-download-manifest.json`。
|
4. 能生成官方全量 pull plan,执行真实下载,维护 release 内的 `official-download-manifest.json`。
|
||||||
5. 下载后使用本地 manifest 的 size + BLAKE3 校验复用文件;所有 `.zip` 在下载验收、复用、本地 audit/verify 时做 ZIP 结构校验;官方 seed `.hash` 使用标准 `xxHash32(seed=0)` 强校验(早期实现的非标准 avalanche 常量已修正)。
|
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` 作为维护期证据。下载默认使用 8 个独立 worker,范围为 `1..=256`;每个 worker 完成当前 URL 后立即从共享计划队列领取下一个任务,进度按实际完成顺序即时上报,最终 report 资源列表仍按计划顺序输出。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` 会清除。
|
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` 并同样按错误重试间隔探测;`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`。
|
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 误判为可复用。
|
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` 权限创建。
|
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 内联打印。
|
12. `<output>/official-version-state.json` 会明确保存当前已完成版本、正在拉取版本、上一个可用版本和失败版本;同一 app version、bundle version 和 Addressables root 的失败只保留最新一条,同一版本开始重新拉取或后续发布成功时会清理对应失败记录;`bat status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、版本状态摘要、最近历史失败版本和原因、结构化日志路径和轮转日志路径,人类可读输出不会把完整版本状态 JSON 内联打印。
|
||||||
13. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,官方同步可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后触发导入,默认 CAS 路径为 `<output>/.cas`、SQLite 索引为 `<output>/resources.sqlite`,也可通过 `--import-cas-root`、`--import-resource-db`、`BAT_IMPORT_CAS_ROOT`、`BAT_IMPORT_RESOURCE_DB` 覆盖;`resource.index` RPC/CLI 可按类型、hash、路径模式、官方 release ID、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 分页查询现有索引,数据库不存在时返回 `available=false` 且不会创建空库。`Resource` metadata 已通过 `metadata_json` 兼容迁移保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式;当前/上一个/结构变化 catalog、失败 staging 复用、403/404、hash mismatch、CRC、metadata 迁移与 UnityFS 边界校验均有离线回归 fixture 或单测覆盖。
|
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 翻译任务状态与跳过/失败原因;TextUnit 已包含 class id、field path、字段 offset/byte size 等可追溯定位。Crowdin 当前仅预留本地离线队列,不发网络请求;up-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析。官方同步报告默认 `localized_release_status=not_localized`,含义是原版资源已经发布、汉化资源未发布;UnityFS TextAsset patch 发布成功后会写 `localized-patch-manifest.json`,校验 hash/size/diff/rollback 后才允许 `localized.status` 返回 `status=published`、`status_code=localized.published` 和 `localized_release_status=localized`。`bat` 首次启动会在二进制所在目录释放 `.env` 配置模板(`0600`),之后每次启动自动加载(不覆盖已存在的环境变量),支持 `BAT_OUTPUT`/`BAT_LOCALIZED_OUTPUT`/`BAT_IMPORT_REPOSITORY`/`BAT_IMPORT_CAS_ROOT`/`BAT_IMPORT_RESOURCE_DB`/`BAT_STATE_DIR`/`BAT_AUTO_DISCOVER`/`BAT_WATCH`/`BAT_DAEMON`/`BAT_PROXY` 等键,实现编辑 `.env` 后无参启动;优先级为命令行参数 > 进程环境变量 > `.env` > 内置默认值,`BAT_SKIP_ENV_FILE=1` 可整体禁用;Redis 键为预留。daemon 任务历史持久化在 `<state-dir>/bat-tasks.json`(版本化、`0600` 原子写),重启后任务经 `task.*` 仍可查,中断任务标记 `task_interrupted`(`BAT-ERR-700005`)。
|
14. 非 dry-run 官方同步在校验完成并发布后,会先对比上一完整 release 与当前 release 的 `official-download-manifest.json`,在当前 release 下写入 `official-resource-changes.json` 和 `crowdin-translation-handoff.json`;同一 destination 只有 size 或 BLAKE3 改变才算 modified,仅 URL/CDN 根变化但内容相同不会触发解析/翻译候选。随后刷新 `official-parse-cache.json` 和 `official-textunit-index.json`,并从 Added/Modified 资源、parse cache 与 TextUnit 明细索引派生 `official-textunit-tasks.json` 和 `crowdin-textunit-queue.json`;删除资源只进入差异记录,不进入 TextUnit/Crowdin 队列。`parse.text_units` 和 `parse.errors` RPC/CLI 可按 destination、archive entry、path id、class id、field path 和 format 查询当前 release 的 TextUnit 明细与解析错误;`translation.tasks` RPC/CLI 可按 release、destination、archive entry、任务状态、parse status、TextUnit format 和 reason presence 查询离线 TextUnit 翻译任务状态与跳过/失败原因;`translation.task.update` 可回写 provider worker 状态,`translation.worker.run` 可触发 Rust provider worker 独立 claim/lease/retry 并落库 TextUnit 级译文结果,`translation.proofread` 可把汉化 workflow 标记为人工校对中;TextUnit 已包含 class id、field path、字段 offset/byte size 等可追溯定位。Crowdin provider 通过 `CROWDIN_*` 环境变量接入,mock provider 支持本地 fixture;up-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析。官方同步报告默认 `localized_release_status=not_localized`,含义是原版资源已经发布、汉化资源未发布;受支持的 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 发布成功后会写带 trace 的 `localized-patch-manifest.json`,校验 hash/size/diff/rollback 后才允许 `localized.status` 返回 `status=published`、`status_code=localized.published` 和 `localized_release_status=localized`,并可用 `localized.rollback` 显式恢复上一 release。`bat` 首次启动会在二进制所在目录释放 `config.toml.example` 配置模板(`0600`),`config.toml` 存在且 Unix 权限为 `0600` 或更严格时读取并使用它;`config.toml` 不存在时仅保留模板,不自动读取 example,运行时继续使用环境变量和内置默认值。优先级为命令行参数 > 进程环境变量 > `config.toml` > 内置默认值,`BAT_SKIP_ENV_FILE` 已废弃且不再影响启动;Redis 键为预留。daemon 任务历史持久化在 `<state-dir>/bat-tasks.json`(版本化、`0600` 原子写),重启后任务经 `task.*` 仍可查,中断任务标记 `task_interrupted`(`BAT-ERR-700005`)。官方同步报告还分别统计当前 manifest 复用、历史 release 复用、CAS 复用、网络传输字节和复用回退诊断,下载事件状态使用 `release_reused`、`cas_reused`、`downloaded` 等稳定值。
|
||||||
|
|
||||||
15. issue 43 已补齐 Rust `bat` 的 `res` / `parse` / `i18n` 工作流入口:支持资源拉取、解析刷新、可再生解析缓存清理、离线翻译工作台、人工文本查看/修改/清空、工作台发布前校验、有限 TextAsset 汉化发布、既有 patch 能力的批量重打包、单次/限定次数/周期执行和版本化 schedule CRUD。schedule 查询现在按一级工作流过滤,删除/执行会校验作用域,单轮执行可限制计划数;schedule CRUD 和翻译任务状态回写已通过 `bat.sock` 的 RPC 以及 `bat-api` 的鉴权管理接口暴露,dashboard 不维护第二套状态。该例外只编排已有解析和 patch 能力,不扩大解析器覆盖;完整 AssetBundle 重打包、真实 provider worker 和 Web 前端仍是后续工作。G-008(产品级 Go 同步 CLI)已决策关闭。真实官方网络全量拉取 smoke 已固化(G-018 已关闭);真实大文件与运行报告默认在 `/tmp` 隔离目录,不纳入 Git。Go 细节见 `docs/reports/GO_STATUS.md`。
|
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`;
|
当前翻译交接还包括 `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` 查询完整
|
`translation.tasks` 查询单项 worker 状态,`translation.handoff` 查询完整
|
||||||
job/unit/provider run 状态。当前下载实现使用默认 8 个独立 worker,完成后动态领取
|
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 顺序输出。
|
任务,最终资源报告按 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 不再复用。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -86,7 +135,7 @@ job/unit/provider run 状态。当前下载实现使用默认 8 个独立 worker
|
|||||||
待完成:
|
待完成:
|
||||||
|
|
||||||
- 领域服务模块仍为空。
|
- 领域服务模块仍为空。
|
||||||
- Glossary、Provider、Patch、Manifest 等后续仓储/服务接口需要补齐。
|
- Provider、Patch、Manifest 等后续仓储/服务接口需要补齐。
|
||||||
- 公共错误模型需要与 CLI/API 错误码统一。
|
- 公共错误模型需要与 CLI/API 错误码统一。
|
||||||
|
|
||||||
### `bat-adapters`
|
### `bat-adapters`
|
||||||
@@ -97,14 +146,14 @@ job/unit/provider run 状态。当前下载实现使用默认 8 个独立 worker
|
|||||||
|
|
||||||
- Unity adapter trait、注册表、Unity 2021.3 adapter 基础解析与校验。
|
- Unity adapter trait、注册表、Unity 2021.3 adapter 基础解析与校验。
|
||||||
- Manifest driver trait、Addressables driver、注册表。
|
- 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 测试。
|
- 真实形态 Addressables fixture/golden 测试。
|
||||||
- 当前 catalog、上一个版本 catalog、结构变化 catalog 的离线回归 fixture。
|
- 当前 catalog、上一个版本 catalog、结构变化 catalog 的离线回归 fixture。
|
||||||
- 官方日服 `server-info`、URL 规则、平台 discovery 和 inventory 枚举;`MediaCatalog.bytes` 使用官方相对路径生成媒体 URL,覆盖 `GameData/`、`Prologue/` 下的 zip/mp4/png/jpg/ogg/wav 等媒体资源,避免把叶子文件名误拼到媒体根目录。
|
- 官方日服 `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`,但仍需继续覆盖二进制/压缩字段组合和更细失败诊断。
|
- Addressables parser 已覆盖当前真实形态 fixture/golden 与 `m_Crc`,但仍需继续覆盖二进制/压缩字段组合和更细失败诊断。
|
||||||
- 客户端发现、备份、应用补丁流程尚未连接真实实现。
|
- 客户端发现、备份、应用补丁流程尚未连接真实实现。
|
||||||
|
|
||||||
@@ -143,18 +192,22 @@ job/unit/provider run 状态。当前下载实现使用默认 8 个独立 worker
|
|||||||
- `OfficialResourcePullService`:官方 URL 拒绝策略、目标路径映射、下载 manifest、下载 quarantine、`.part` 续传、curl 代理配置、403/404/5xx 分类重试、ZIP 结构校验、官方 seed `.hash` 校验、本地全量 verify。
|
- `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 恢复。
|
- `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`。
|
- `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/provider worker、翻译记忆和 Patch 构建;围绕 `translation.task.update` 完成真实 worker 集成与失败原因落库验证,并继续扩展 CAS 诊断查询面。
|
- 基于已接入的 `translation.worker.run` 继续扩展 TM/Glossary 和复杂 AssetBundle fixture;generic manifest V1 与双 release 运维 V1 已完成。
|
||||||
- 真实线上全量下载 smoke 已固化为 `scripts/official-full-pull-smoke.sh` 和 `make official-smoke`;实际运行报告由脚本写入隔离输出目录。
|
- 真实线上全量下载 smoke 已固化为 `scripts/official-full-pull-smoke.sh` 和 `make official-smoke`;实际运行报告由脚本写入隔离输出目录。
|
||||||
- 增加更多权限和极端文件系统场景测试。
|
- 增加更多权限和极端文件系统场景测试。
|
||||||
|
|
||||||
### `bat-assetbundle`
|
### `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 验收推进。
|
||||||
|
|
||||||
当前已有:
|
当前已有:
|
||||||
|
|
||||||
@@ -171,20 +224,20 @@ job/unit/provider run 状态。当前下载实现使用默认 8 个独立 worker
|
|||||||
待完成:
|
待完成:
|
||||||
|
|
||||||
- 真实 MonoBehaviour、ScriptableObject 版本差异、复杂容器结构调整、unknown 字段结构语义和未见样本驱动的完整 managed reference registry / map entry 变体覆盖;TypeTree-covered managed reference 字段与 registry 记录已可结构化解码,常见 full typename 可拆解为 assembly/namespace/class,不做低保真猜测。
|
- 真实 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 覆盖对象级解析和文本提取。
|
- 真实资源 fixture 覆盖对象级解析和文本提取。
|
||||||
- 详细补全顺序见 `docs/architecture/assetbundle.md`。
|
- 详细补全顺序见 `docs/architecture/assetbundle.md`。
|
||||||
|
|
||||||
### `bat-patch`
|
### `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 变体驱动字段修改后的语义重打包。
|
- 未见样本驱动的 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 语义编辑和写入型发布工作流仍未开放。
|
- `unityfs.inspect`、复杂 UnityFS 语义编辑和写入型发布工作流仍未开放。
|
||||||
|
|
||||||
### `bat-ffi`
|
### `bat-ffi`
|
||||||
@@ -202,7 +255,8 @@ job/unit/provider run 状态。当前下载实现使用默认 8 个独立 worker
|
|||||||
|
|
||||||
- `bat-ffi` 只暴露粗粒度、无状态、一次调用一次 JSON 输入输出的 C ABI helper。
|
- `bat-ffi` 只暴露粗粒度、无状态、一次调用一次 JSON 输入输出的 C ABI helper。
|
||||||
- 它不持有 downloader、daemon、CAS handle、资源目录锁或长生命周期状态。
|
- 它不持有 downloader、daemon、CAS handle、资源目录锁或长生命周期状态。
|
||||||
- 未来 Go 产品入口和生产运维默认应调用 `bat --json` 进程边界;未来稳定 SDK 也优先于 FFI。
|
- 新的 Go 集成和生产运维读侧默认应调用 `bat.sock` RPC;`bat --json` 仅是
|
||||||
|
Rust CLI 的机器输出形态,`bat-ffi` 仍是可选兼容层。
|
||||||
- FFI 仅用于需要嵌入 C ABI 的兼容场景,不能作为官方同步控制面或主集成边界。
|
- FFI 仅用于需要嵌入 C ABI 的兼容场景,不能作为官方同步控制面或主集成边界。
|
||||||
|
|
||||||
待完成:
|
待完成:
|
||||||
@@ -212,51 +266,50 @@ job/unit/provider run 状态。当前下载实现使用默认 8 个独立 worker
|
|||||||
|
|
||||||
### Go / API / Web
|
### Go / API / Web
|
||||||
|
|
||||||
状态:**边界已冻结;资源分发 MVP 已落地。权威细节见 `docs/reports/GO_STATUS.md`。**
|
状态:**边界已确定;资源分发 MVP 已落地。权威细节见 `docs/reports/GO_STATUS.md`。**
|
||||||
|
|
||||||
| 角色 | 所有者 | 状态 |
|
| 角色 | 所有者 | 状态 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 同步/运维命令行(近乎全自动) | Rust `bat` | 产品入口 |
|
| 同步/运维命令行(近乎全自动) | 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` | 完成 |
|
| daemon RPC client | `internal/backendrpc` | 完成 |
|
||||||
| 试验 CLI | `cmd/bat` → `bin/bat-go` | 非产品 |
|
| 试验 CLI | `cmd/bat` → `bin/bat-go` | 非产品 |
|
||||||
| FFI | `internal/ffi` | 可选 |
|
| FFI | `internal/ffi` | 可选 |
|
||||||
| 空目录 `api/` `pkg/` 等 | 占位 | 无实现 |
|
| 空目录 `api/` `pkg/` 等 | 占位 | 无实现 |
|
||||||
| Web | `web/` | 空(G-010) |
|
| Web | `web/` | 内嵌 dashboard MVP;完整协作后台仍未完成 |
|
||||||
|
|
||||||
默认 Go/docs 门禁:`make test-go-api`、`make build-go-api`、`make check-docs`(无 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. 已验证结果
|
## 4. 已验证结果
|
||||||
|
|
||||||
近期 Rust 侧复核已运行并通过:
|
以下命令已于 2026-09-04 在本地工作区执行并通过:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo fmt --check
|
cargo fmt --all -- --check
|
||||||
cargo test --offline --workspace --quiet
|
cargo check --workspace --locked
|
||||||
cargo clippy --offline --workspace --all-targets -- -D warnings
|
cargo test --workspace --locked
|
||||||
cargo test --offline -p bat-patch --quiet
|
cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
本次 bat-api 侧复核已运行并通过:
|
Go 与文档门禁:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
env 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 test-go-api
|
||||||
env GOCACHE=/tmp/bat-go-cache GOMODCACHE=/tmp/bat-go-modcache make build-go-api
|
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 go test ./...
|
||||||
|
GOCACHE=/tmp/bat-go-cache GOMODCACHE=/tmp/bat-go-modcache go vet ./...
|
||||||
make check-docs
|
make check-docs
|
||||||
```
|
```
|
||||||
|
|
||||||
未执行 / 后置:
|
当前仍未作为本地事实确认的项目包括:
|
||||||
|
|
||||||
- 真实官方全量 smoke 长期运行报告(G-018 命令已固化)。
|
- 真实官方全量 smoke 长期运行报告;命令已固化为 `make official-smoke`。
|
||||||
- `bat-api` 对远程长期运行 `bat` / 全量 release 的 SSH 联调(等连接信息)。
|
- `bat-api` 同机 live 联调:已由 `make bat-api-local-live-smoke` 在 `/tmp` 隔离目录完成;真实官方网络全量下载仍由 `make official-smoke` 独立跟踪。
|
||||||
- Web(G-010)。
|
- 完整 Web 协作后台。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -271,7 +324,7 @@ cargo run -p bat-infrastructure --bin bat -- \
|
|||||||
--watch
|
--watch
|
||||||
```
|
```
|
||||||
|
|
||||||
资源 HTTP bootstrap / 只读分发入口是 Go `cmd/bat-api`。生产拓扑下它与 Rust `bat` 同环境运行,经 `bat.sock` RPC 获取当前 `resource_root`,不在配置里写死资源目录;本地开发不能全量跑 `bat` 时用 fixture 和 Go 门禁验证。`internal/api/testdata/contract/` 已固化来自 Rust 输出并经归一化的 `catalog.status`、`resource.manifest` 和 `official-sync-snapshot.json` contract fixture,Go mirror 测试会防止字段名、null 语义和 `game_main_config_bootstrap` 再次漂移。`bat-api` 已补 launcher 资源引导兼容端点、玩家-facing HTTP 控制面和鉴权调度管理接口(token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单;`reload` / `refresh` / `restart` / `sync` / `verify` / `repair` / `catalog-refresh` 及 `schedule.*` 可经 Web 转发),响应只来自已发布 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。
|
||||||
|
|
||||||
生产要求:
|
生产要求:
|
||||||
|
|
||||||
@@ -285,34 +338,29 @@ cargo run -p bat-infrastructure --bin bat -- \
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 当前阻塞项
|
## 6. 当前开发基础与后续工作
|
||||||
|
|
||||||
GitHub issue 状态:#1 已关闭;#17 的历史决定不代表当前下载实现,现行默认并发为 8,范围 `1..=256`,每个独立 worker 完成后立即领取下一个任务,进度按完成事件即时统计并保持 report 计划顺序;子 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/restart/reload/refresh/doctor)、`resource.*`(state/sync/verify/repair/manifest/list/index)、`schedule.*`(list/add/update/remove/run)、`parse.*`(status/text_units/errors)、`translation.*`(tasks/handoff/task.update)、`localized.*`(status)、`catalog.*`(status/refresh/diff/versions)、`task.*`(status/list/cancel/logs)、文件级 `patch.apply` 与 `unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field` 已实现,长任务返回 `task_id` 可轮询(任务执行器单 worker FIFO,与 watch 循环互斥;任务历史持久化于 `<state-dir>/bat-tasks.json`,daemon 重启后仍可查,中断任务标记 `task_interrupted`);错误码已接入下载、launcher/metadata、server-info/marker 与配置校验路径。剩余:发布级 `patch build` / `patch rollback`、复杂 `unityfs.*` 语义编辑、`task.create`(按设计由语义方法创建)、`daemon.clean-stable`(由 CLI 侧按进程生命周期显式执行,live RPC 内不做在线清理)、Redis 任务后端(`.env` 已预留配置键,接入时机另议)。Go 层通过 RPC 调用 Rust backend,不走 FFI(FFI 降级说明见 `docs/architecture/official-resource-backend.md` §7)。
|
- 使用 `make official-smoke` 执行真实官方网络长期运行测试,并将报告留在隔离目录。
|
||||||
2. Go 侧:进度见 `docs/reports/GO_STATUS.md`。G-008 已关闭;`bat-api` 资源 bootstrap/分发 MVP 已落地,已含 `/v1/bootstrap`、`/v1/launcher/bootstrap`、launcher 资源 metadata 兼容、HTTP 鉴权/限流/日志/反代适配、动态 JSON no-store、OpenAPI、管理控制白名单、CDN Range/缓存头、RPC 周期刷新和 USERGUIDE 基础章节;仓库内 Rust/Go snapshot contract fixture 已落地,剩余为远程服务器全量 release 联调、refresh mtime/size 增量缓存和可选持久化。
|
|
||||||
3. 文本提取 / 翻译队列 / Patch 输入:`official-textunit-index.json`、`official-textunit-tasks.json` 与 `crowdin-textunit-queue.json` 已生成;TextUnit 明细、解析错误和离线翻译任务状态/失败原因已可查询,`translation.task.update` 已提供 Rust CLI、Go typed helper 和 bat-api dashboard 状态回写入口。剩余为真实 Crowdin/provider worker、翻译记忆和 Patch 构建。
|
|
||||||
4. Issue #3(P1):AssetBundle UnityFS 基础解析校验已具备离线和隔离真实样本覆盖;对象级解析继续跟踪 G-005。
|
|
||||||
5. Issue #2(P1):继续逆向 Addressables catalog,提取 bundle hash/size/CRC 等可校验字段。
|
|
||||||
6. 通用 Binary/JSON/Text Patch 基础已落地;复杂 AssetBundle 重打包和真实翻译系统仍应后置,UnityFS TextAsset patch 发布前置已具备回归测试。
|
|
||||||
|
|
||||||
非阻塞跟踪项:官方同步长期运行测试正在进行,运行报告将在后续提供。
|
后续工程顺序:
|
||||||
|
|
||||||
|
1. 继续复杂 AssetBundle:真实样本、复杂字段解析和发布级重打包。
|
||||||
|
2. 继续通用 Patch:真实样本驱动的复杂 AssetBundle 兼容;双 release 查询、分发、rollback 边界和安全清理 V1 已完成。
|
||||||
|
3. 继续资源查询和翻译基础设施:更丰富的查询和 Provider
|
||||||
|
扩展体系。
|
||||||
|
4. 在资源和翻译契约稳定后推进完整 Web 协作后台和完整游戏业务 API。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. 下一步建议
|
- **当前总体完成度**:不固定写单一百分比,以各模块状态、源码、测试和契约为准。
|
||||||
|
- **当前基线状态**:Rust `bat` 同步闭环可用;Go `bat-api` 资源 bootstrap/分发 MVP、
|
||||||
立即任务:
|
HTTP 控制面、launcher 资源引导兼容、RPC 周期刷新/诊断、readiness、内嵌 dashboard
|
||||||
|
和 `backendrpc` 可用;CAS 用户级导入、TextUnit 明细索引/查询、增量离线队列、
|
||||||
1. Issue #1 收尾:协议基础设施、最小方法集、`catalog.*`、`parse.*`、`localized.*`、`task.*`、`resource.repair`、`daemon.restart`、文件级 `patch.apply` / `unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field`、任务持久化、错误码模型与文档均已完成;剩余发布级 `patch build`/`rollback`、复杂 `unityfs.*` 语义编辑以及 `task.create`、`daemon.clean-stable` 的设计边界确认。
|
通用 Binary/JSON/Text Patch 基础、generic manifest 和受支持 localized patch 发布/rollback 可用;
|
||||||
2. `bat-api` 与远程长期运行的 `bat` / 全量 release 联调(含 `/v1/bootstrap`、`/v1/launcher/bootstrap`、server-info 和 CDN path;issue #19 剩余)。
|
复杂 AssetBundle 重打包、完整 Web 协作后台、模糊 TM 匹配和更高阶 release retention 未完成;双 release 运维 V1 已完成。
|
||||||
3. 跟进官方同步长期运行测试报告。
|
- **下一工程里程碑**:复杂 AssetBundle 解析和重打包,以及真实官方资源长期运行验证。
|
||||||
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/provider worker / 翻译记忆、复杂 AssetBundle 解析和重打包。
|
|
||||||
|
|||||||
Generated
+2
@@ -103,6 +103,7 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"blake3",
|
"blake3",
|
||||||
"hex",
|
"hex",
|
||||||
|
"libc",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
@@ -118,6 +119,7 @@ version = "1.0.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"blake3",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"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
|
||||||
+135
-95
@@ -1,123 +1,163 @@
|
|||||||
# BlueArchiveToolkit 文档索引
|
# BlueArchive Toolkit 文档分类索引
|
||||||
|
|
||||||
- **更新时间**:2026-08-03
|
- **更新时间**: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/guides/bat-workflows.md`:Rust `bat` 的 `res` / `parse` / `i18n` 工作流、调度计划和 `bat-api` 调度接口。
|
|
||||||
- `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。
|
- `docs/reference/rpc-backend-api.md`:Rust Resource Backend JSON-RPC 稳定 contract。
|
||||||
- `CHANGELOG.md`:版本变更记录。
|
- `api/openapi/bat-api.yaml`:`bat-api` HTTP OpenAPI 静态规范。
|
||||||
- `AGENTS.md`:AI agent 和自动化开发助手长期规则。
|
- `docs/api/README.md`:API 文档入口及规范索引。
|
||||||
- `CONTRIBUTING.md`:贡献者协作、提交和验证要求。
|
|
||||||
- `CLAUDE.md`:Claude Code 等旧工具的兼容入口。
|
|
||||||
|
|
||||||
---
|
契约文档涉及字段、状态码、错误码、release layout 或路径语义时,必须与源码测试和 `internal/api/testdata/contract/` 一起复核。
|
||||||
|
|
||||||
## 2. 架构与指南
|
|
||||||
|
|
||||||
- `docs/architecture/README.md`:总体架构设计。
|
### 3.4 Dashboard 设计参考
|
||||||
- `docs/api/README.md`:API 设计入口。
|
|
||||||
- `api/openapi/bat-api.yaml`:当前 `bat-api` 资源 bootstrap/分发 HTTP OpenAPI 静态规范。
|
|
||||||
- `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 核心接口和错误边界冻结。
|
|
||||||
|
|
||||||
后续建议新增:
|
- `DESIGN.md`:用户 Dashboard 与运营 Dashboard 的主要视觉参考和设计灵感来源,描述应延续的色彩关系、排版、空间、边框、层级、组件形态和交互气质。它不定义后端事实、权限或业务状态,也不要求复制参考来源的页面结构或品牌内容。
|
||||||
|
- Dashboard 的稳定产品职责、信息边界和设计执行规则见 `AGENTS.md` 的“Dashboard 开发与设计”。用户 Dashboard 与运营 Dashboard 共享基础视觉语言和组件体系,但拥有不同的信息架构、信息密度和权限边界。
|
||||||
|
- Dashboard 设计必须以当前真实 API/RPC contract 和数据结构为依据。若所需信息尚无后端 contract,应记录缺口,而不是在前端维护第二份业务状态或伪造指标。
|
||||||
|
|
||||||
- `docs/architecture/cas.md`:CAS 生产级设计。
|
发生冲突时遵循:`AGENTS.md` 与稳定产品/接口契约 > 当前明确任务需求 > `DESIGN.md` > Agent 自身设计偏好。
|
||||||
- `docs/architecture/translation.md`:翻译系统设计。
|
|
||||||
|
|
||||||
---
|
## 4. 用户、开发与运维指南
|
||||||
|
|
||||||
## 3. 分析资料
|
这些文件描述如何使用或验证已经存在的能力:
|
||||||
|
|
||||||
- `docs/assetbundle_analysis.json`:AssetBundle 分析资料。
|
- `docs/guides/development.md`:本地开发、测试、调试和代码质量流程。
|
||||||
- `docs/textassets_analysis.json`:TextAsset 分析资料。
|
- `docs/guides/deployment.md`:部署、systemd、Docker 和运维说明。
|
||||||
- `docs/archive/BLUE_ARCHIVE_TECHNICAL_ANALYSIS.md`:历史技术分析。
|
- `docs/guides/official-resource-test-pull.md`:官方资源拉取与自动更新运行指南。
|
||||||
- `docs/archive/ARCHITECTURE_REVIEW.md`:历史架构审查。
|
- `docs/guides/official-full-pull-smoke.md`:真实官方全量拉取 smoke runbook。
|
||||||
- `docs/archive/ARCHITECTURE_REVIEW_SUMMARY.md`:历史架构审查摘要。
|
- `docs/guides/bat-api-local-live-smoke.md`:Rust `bat` 与 Go `bat-api` 同机 live 联调。
|
||||||
- `docs/archive/READY_FOR_PHASE_1.md`:历史 Phase 1 准备文档。
|
- `docs/guides/bat-workflows.md`:`res`、`parse`、`i18n` 工作流和调度接口。
|
||||||
- `docs/archive/REFACTOR_CHECKLIST.md`:历史重构清单。
|
- `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/root/`:原根目录阶段报告。
|
||||||
- `docs/reports/historical/current-stage/`:已被 `CURRENT_STATUS.md` 和当前指南取代的阶段交接、推送前核查报告。
|
- `docs/reports/historical/current-stage/`:已被当前状态和指南取代的阶段交接报告。
|
||||||
- `docs/reports/historical/week2/`:Week 2 相关报告。
|
- `docs/reports/historical/week2/`:Week 2 报告和当时的构建/测试输出。
|
||||||
- `docs/reports/historical/week3/`:Week 3 相关报告。注意:这些报告中存在“完成”和“回滚”的冲突描述。
|
- `docs/reports/historical/week3/`:Week 3 报告;其中存在互相冲突的完成描述。
|
||||||
- `docs/reports/historical/build-logs/`:历史构建、测试、Clippy 输出。
|
- `docs/reports/historical/PARSER_FREEZE.md`:已解除的解析模块维护冻结历史记录,不构成当前开发约束。
|
||||||
|
- `docs/reports/historical/build-logs/`:历史构建、测试和 Clippy 输出。
|
||||||
- `docs/reports/historical/quality/`:历史质量报告。
|
- `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`
|
||||||
1. `CURRENT_STATUS.md`
|
3. `PROJECT_PLAN.md`
|
||||||
2. `PROJECT_PLAN.md`
|
4. `docs/reports/CURRENT_GAPS.md`
|
||||||
3. `docs/guides/official-resource-test-pull.md`
|
5. `docs/reports/GO_STATUS.md`
|
||||||
4. `docs/guides/official-full-pull-smoke.md`
|
|
||||||
5. `docs/guides/bat-workflows.md`
|
|
||||||
6. `docs/architecture/official-resource-backend.md`
|
6. `docs/architecture/official-resource-backend.md`
|
||||||
7. `docs/reference/rpc-backend-api.md`
|
7. `docs/architecture/resource-release-layout.md`
|
||||||
8. `docs/reports/CURRENT_GAPS.md`
|
8. `docs/reference/rpc-backend-api.md`
|
||||||
9. `docs/guides/baseline.md`
|
9. `docs/architecture/assetbundle.md`
|
||||||
10. `docs/architecture/README.md`
|
10. `docs/guides/official-resource-test-pull.md`
|
||||||
11. `docs/guides/development.md`
|
11. `docs/guides/bat-workflows.md`
|
||||||
12. `CONTRIBUTING.md`
|
12. `docs/guides/development.md`
|
||||||
13. `AGENTS.md`
|
13. `CONTRIBUTING.md`
|
||||||
|
14. `AGENTS.md`
|
||||||
|
|
||||||
---
|
### 8.2 AI / Agent 开发接管顺序
|
||||||
|
|
||||||
## 6. 状态摘要
|
Agent 进入仓库进行开发时优先按以下顺序建立上下文:
|
||||||
|
|
||||||
当前总体完成度不再固定写单一百分比,以 `CURRENT_STATUS.md` 和 `CURRENT_GAPS.md` 的模块状态为准。
|
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`:需要判断能力缺口或后续路线时再读取。
|
||||||
|
|
||||||
已完成:
|
涉及 Dashboard、Web UI、页面布局、视觉样式、组件或交互体验时,在设计或修改前额外必须阅读 `DESIGN.md`。
|
||||||
|
|
||||||
- Rust 领域模型和仓储接口骨架。
|
阅读顺序中的状态和契约结论必须回到当前源码、测试和实际命令验证;`TODO.md`、`CURRENT_GAPS.md` 和 `PROJECT_PLAN.md` 均不能把计划项提升为已实现事实;历史报告只用于解释演进过程。
|
||||||
- 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 引擎级解析。
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: help build build-ffi test clean check check-docs 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
|
.DEFAULT_GOAL := help
|
||||||
@@ -72,16 +72,16 @@ test-go-all: test-go-api test-go-ffi ## 全部 Go 测试
|
|||||||
bench: ## 运行性能基准测试
|
bench: ## 运行性能基准测试
|
||||||
@echo "$(BLUE)Running benchmarks...$(NC)"
|
@echo "$(BLUE)Running benchmarks...$(NC)"
|
||||||
cargo bench --workspace
|
cargo bench --workspace
|
||||||
@if [ -n "$$(go list ./... 2>/dev/null)" ]; then \
|
go test -bench=. -benchmem ./...
|
||||||
go test -bench=. -benchmem ./...; \
|
|
||||||
else \
|
|
||||||
echo "$(YELLOW)No Go packages yet, skipping Go benchmarks...$(NC)"; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
official-smoke: ## 运行真实官方全量拉取 smoke(默认写入 /tmp 隔离目录)
|
official-smoke: ## 运行真实官方全量拉取 smoke(默认写入 /tmp 隔离目录)
|
||||||
@echo "$(BLUE)Running official full pull smoke...$(NC)"
|
@echo "$(BLUE)Running official full pull smoke...$(NC)"
|
||||||
./scripts/official-full-pull-smoke.sh
|
./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
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# 代码质量
|
# 代码质量
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -94,11 +94,11 @@ check-rust: ## 检查 Rust 代码
|
|||||||
|
|
||||||
check-go: ## 检查 Go 代码
|
check-go: ## 检查 Go 代码
|
||||||
@echo "$(BLUE)Checking Go code...$(NC)"
|
@echo "$(BLUE)Checking Go code...$(NC)"
|
||||||
@if [ -n "$$(go list ./... 2>/dev/null)" ]; then \
|
go vet ./...
|
||||||
go vet ./...; \
|
|
||||||
else \
|
check-go-format: ## 检查 Go 格式(只读)
|
||||||
echo "$(YELLOW)No Go packages yet, skipping...$(NC)"; \
|
@echo "$(BLUE)Checking Go formatting...$(NC)"
|
||||||
fi
|
bash scripts/check-go-format.sh
|
||||||
|
|
||||||
check-docs: ## 检查权威状态文档与占位目录声明
|
check-docs: ## 检查权威状态文档与占位目录声明
|
||||||
@echo "$(BLUE)Checking documentation status claims...$(NC)"
|
@echo "$(BLUE)Checking documentation status claims...$(NC)"
|
||||||
@@ -106,32 +106,35 @@ check-docs: ## 检查权威状态文档与占位目录声明
|
|||||||
|
|
||||||
fmt: fmt-rust fmt-go ## 格式化所有代码
|
fmt: fmt-rust fmt-go ## 格式化所有代码
|
||||||
|
|
||||||
|
format: fmt ## 格式化所有代码(会修改工作树)
|
||||||
|
|
||||||
fmt-rust: ## 格式化 Rust 代码
|
fmt-rust: ## 格式化 Rust 代码
|
||||||
@echo "$(BLUE)Formatting Rust code...$(NC)"
|
@echo "$(BLUE)Formatting Rust code...$(NC)"
|
||||||
cargo fmt --all
|
cargo fmt --all
|
||||||
|
|
||||||
fmt-go: ## 格式化 Go 代码
|
fmt-go: ## 格式化 Go 代码
|
||||||
@echo "$(BLUE)Formatting Go code...$(NC)"
|
@echo "$(BLUE)Formatting Go code...$(NC)"
|
||||||
@if [ -n "$$(go list ./... 2>/dev/null)" ]; then \
|
go fmt ./...
|
||||||
go fmt ./...; \
|
|
||||||
else \
|
|
||||||
echo "$(YELLOW)No Go packages yet, skipping...$(NC)"; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
lint: lint-rust lint-go ## 运行所有 Linter
|
lint: lint-rust lint-go ## 运行所有 Linter
|
||||||
|
|
||||||
lint-rust: ## Rust Clippy 检查
|
lint-rust: ## Rust Clippy 检查
|
||||||
@echo "$(BLUE)Running Clippy...$(NC)"
|
@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)"
|
@echo "$(BLUE)Running golangci-lint...$(NC)"
|
||||||
@command -v golangci-lint >/dev/null 2>&1 || { echo "$(YELLOW)golangci-lint not installed, skipping...$(NC)"; exit 0; }
|
@. scripts/ci-versions.sh; \
|
||||||
@if [ -n "$$(go list ./... 2>/dev/null)" ]; then \
|
command -v golangci-lint >/dev/null 2>&1 || { \
|
||||||
golangci-lint run ./...; \
|
echo "$(YELLOW)required gate failed: golangci-lint $${GOLANGCI_LINT_VERSION} is not installed$(NC)"; \
|
||||||
else \
|
exit 1; \
|
||||||
echo "$(YELLOW)No Go packages yet, skipping...$(NC)"; \
|
}; \
|
||||||
fi
|
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 ./...
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# 清理
|
# 清理
|
||||||
@@ -197,5 +200,7 @@ docs: ## 生成文档
|
|||||||
# CI/CD
|
# CI/CD
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
ci: fmt lint test ## 运行 CI 检查(本地模拟)
|
ci-check: ## 运行只读 required CI 门禁(含固定版本 Go lint)
|
||||||
@echo "$(GREEN)✓ All CI checks passed!$(NC)"
|
@bash scripts/ci-check.sh
|
||||||
|
|
||||||
|
ci: ci-check ## 运行只读 CI 检查(兼容旧命令名)
|
||||||
|
|||||||
+60
-57
@@ -1,8 +1,8 @@
|
|||||||
# BlueArchiveToolkit 完整开发计划
|
# BlueArchiveToolkit 完整开发计划
|
||||||
|
|
||||||
- **项目名称**:BlueArchiveToolkit
|
- **项目名称**:BlueArchiveToolkit
|
||||||
- **文档版本**:2026-08-03 状态复核版
|
- **文档版本**:2026-09-04 状态复核版
|
||||||
- **权威状态**:以本文档和 `CURRENT_STATUS.md` 为准,旧阶段报告仅作历史参考。
|
- **文档角色**:长期目标、里程碑和路线图;当前实现以源码、测试、稳定契约和 `CURRENT_STATUS.md` 为准,旧阶段报告仅作历史参考。
|
||||||
- **最终目标**:构建一个可长期维护、可扩展、可审计的 Blue Archive 资源管理、文本提取、翻译和补丁平台。
|
- **最终目标**:构建一个可长期维护、可扩展、可审计的 Blue Archive 资源管理、文本提取、翻译和补丁平台。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -22,7 +22,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
|||||||
|
|
||||||
## 2. 当前真实状态
|
## 2. 当前真实状态
|
||||||
|
|
||||||
本节来自 2026-08-03 的工作区盘点、本地验证和最新功能提交。
|
本节来自 2026-09-04 的工作区盘点、本地验证和最新功能提交。
|
||||||
|
|
||||||
### 已具备
|
### 已具备
|
||||||
|
|
||||||
@@ -32,23 +32,23 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
|||||||
4. `bat-cas-engine` 已完成 CAS V1:原子写入、BLAKE3 Hash、SQLite 引用计数、GC、并发测试、损坏检测。
|
4. `bat-cas-engine` 已完成 CAS V1:原子写入、BLAKE3 Hash、SQLite 引用计数、GC、并发测试、损坏检测。
|
||||||
5. `bat-infrastructure` 已改为 CAS 仓储适配层,不再重复实现对象存储。
|
5. `bat-infrastructure` 已改为 CAS 仓储适配层,不再重复实现对象存储。
|
||||||
6. `bat-infrastructure` 已提供官方资源 pull/update 服务,正式入口是 Rust binary `bat`。
|
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/restart/reload/refresh/doctor`、`resource.sync/verify/repair/state/manifest/list/index`、`parse.status/text_units/errors`、`localized.status`、`catalog.*`、`task.*`);`daemon.restart` 通过 Rust lifecycle controller 复用 CLI 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。
|
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 明细索引和队列时只读取摘要,不重复解析。
|
10. 官方同步校验完成并发布新 release 后会生成 `official-resource-changes.json`、`crowdin-translation-handoff.json`、`official-parse-cache.json`、`official-textunit-index.json`、`official-textunit-tasks.json` 和 `crowdin-textunit-queue.json`,用 Added/Modified 资源驱动后续解析/翻译增量;up-to-date 轮询在已有有效缓存、TextUnit 明细索引和队列时只读取摘要,不重复解析。
|
||||||
11. issue 43 已补齐 Rust `bat` 的 `res` / `parse` / `i18n` 工作流:单次/限定次数/周期执行、版本化 schedule CRUD 与作用域过滤、解析缓存清理、翻译工作台校验、离线翻译工作台、人工文本查看/修改/清空、翻译任务状态回写、既有 patch 能力的批量重打包和独立汉化 release 发布;schedule CRUD 与 `translation.task.update` 已经通过 `bat.sock` 和 `bat-api` 管理接口暴露,dashboard 不维护第二套状态;该能力不扩大冻结期解析器覆盖。
|
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` 已合并。
|
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 仍未完成。
|
1. `bat-assetbundle` 已具备 UnityFS 解包、TextAsset/TypeTree 字段读取和 TextUnit 提取;对当前真实/合成回归覆盖的结构,UnityFS TextAsset、TypeTree string、managed-reference string 和语义字段已形成 parse→modify→rebuild→reparse 闭环,保留已识别压缩/对齐/目录形态并校验未修改对象/字段;所有真实版本差异、未知字段语义和任意复杂 AssetBundle 兼容仍未完成。
|
||||||
2. `bat-patch` 已具备确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、通用 Patch manifest、BLAKE3/size 完整性校验和 rollback 元数据;文件级 `patch.apply` RPC / `patch-apply` CLI 与 UnityFS TextAsset / TypeTree string / TypeTree 语义字段写入入口已开放,当前发布级可用的是 `bat-assetbundle` + `LocalizedPatchService` 的 UnityFS TextAsset patch 前置链路。
|
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`;资源分发 = `cmd/bat-api` MVP;`internal/backendrpc` 完成;`cmd/bat` 仅为试验(`bin/bat-go`)。完整游戏业务 API / Web / SDK 仍未完成。
|
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 兼容层。
|
4. Addressables parser 已覆盖当前真实形态 fixture/golden,但还不是完整 Unity Addressables/SBP catalog 兼容层。
|
||||||
5. 官方同步结果可配置为发布后自动导入 CAS + ResourceRepository,并通过 `resource.index` RPC/CLI 查询;Resource metadata 已保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 摘要,资源级查询已覆盖 release、平台、destination、archive entry、parse status 和 TextUnit format;单条 TextUnit 明细和解析错误已持久化到 `official-textunit-index.json`,可通过 `parse.text_units` / `parse.errors` 查询;离线 TextUnit 翻译任务状态和跳过/失败原因可通过 `translation.tasks` 查询,`translation.task.update` 已提供 worker 状态回写 contract,真实 provider worker 集成仍需继续推进。
|
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 manifest/apply/diff/rollback/完整性校验和 `localized.status` 严格校验;真实 Crowdin worker、翻译记忆到完整汉化文件集合的构建仍未完成。
|
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(G-018 已关闭);真实运行记录处于长期运行测试阶段,报告待后续提供。
|
7. 真实官方网络全量下载 smoke 已固化为可重复脚本和 runbook;真实运行记录处于长期运行测试阶段,报告待后续提供。
|
||||||
8. 完整 Web、数据库迁移、插件加载机制尚未实现;`bat-api` 资源 bootstrap/分发 OpenAPI 已通过 `/openapi.yaml` 提供,完整游戏业务 API 的 OpenAPI 仍未完成。
|
8. 内嵌 dashboard MVP 已实现;完整 Web 协作后台、数据库迁移、插件加载机制尚未实现;`bat-api` 资源 bootstrap/分发/OpenAPI/管理控制面已通过 `/openapi.yaml` 提供,完整游戏业务 API 的 OpenAPI 仍未完成。
|
||||||
9. 原 Git 历史未恢复;当前仓库以新初始化基线为准。
|
9. 原 Git 历史未恢复;当前仓库以新初始化基线为准。
|
||||||
|
|
||||||
### 已验证
|
### 已验证
|
||||||
@@ -73,8 +73,8 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
|||||||
|
|
||||||
### 3.2 技术决策
|
### 3.2 技术决策
|
||||||
|
|
||||||
1. **Rust**:保留为核心引擎语言,用于 CAS、AssetBundle、Patch、完整资源拉取和更新检查核心逻辑;`bat --json` 进程边界是当前主集成路径,FFI 仅作为可选兼容层。
|
1. **Rust**:保留为核心引擎语言,用于 CAS、AssetBundle、Patch、完整资源拉取和更新检查核心逻辑;`bat.sock` RPC 是 Go `bat-api` 的当前主集成边界,`bat --json` 是 Rust CLI 的机器输出形态,FFI 仅作为可选兼容层。
|
||||||
2. **Go**:用于最小稳定 CLI、服务编排、API Server、任务编排、Provider 集成;不强制要求 Rust 核心能力必须写成库供 Go 调用。
|
2. **Go**:当前用于 `bat-api` 资源 bootstrap/分发和 Rust RPC 管理入口;完整服务编排、API Server、任务编排和 Provider 集成仍是目标能力,不强制要求 Rust 核心能力必须写成库供 Go 调用。
|
||||||
3. **PostgreSQL**:作为服务端主数据库,承载翻译记忆库、术语库、任务、审核和用户权限。
|
3. **PostgreSQL**:作为服务端主数据库,承载翻译记忆库、术语库、任务、审核和用户权限。
|
||||||
4. **SQLite**:仅作为本地 CLI 可选元数据后端,必须通过仓储抽象隔离,不能绑定业务逻辑。
|
4. **SQLite**:仅作为本地 CLI 可选元数据后端,必须通过仓储抽象隔离,不能绑定业务逻辑。
|
||||||
5. **Redis**:用于服务端缓存、任务状态、限流和短期锁。
|
5. **Redis**:用于服务端缓存、任务状态、限流和短期锁。
|
||||||
@@ -88,7 +88,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
|||||||
2. 公共接口具备文档、错误语义和兼容性说明。
|
2. 公共接口具备文档、错误语义和兼容性说明。
|
||||||
3. 单元测试覆盖核心分支;跨模块能力补集成测试。
|
3. 单元测试覆盖核心分支;跨模块能力补集成测试。
|
||||||
4. `cargo fmt`、`cargo clippy --workspace --all-targets -- -D warnings`、`cargo test --workspace` 通过。
|
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` 检查和失败恢复建议。
|
6. 用户可见命令必须有 `doctor` 检查和失败恢复建议。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -169,21 +169,23 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
|||||||
|
|
||||||
**目标**:能够获取、解析和同步 Blue Archive 资源清单。
|
**目标**:能够获取、解析和同步 Blue Archive 资源清单。
|
||||||
|
|
||||||
**当前状态**:部分完成。Rust 官方日服资源同步链路已经具备正式 one-shot 和 `--watch` 常驻入口;`bat-api` 资源 bootstrap/分发入口已落地,但完整解析覆盖、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 结构变体。
|
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 可见模型。
|
2. 资源版本、区域、渠道、远端 URL、Hash、大小、依赖关系模型:**部分完成**。`Resource` 和官方 endpoint/snapshot 模型已扩展;Go CLI/API 可见模型仍需在稳定 contract 中继续收敛。
|
||||||
3. Rust 官方下载器:**已完成当前生产入口需要的核心能力**。包含官方 URL 校验、`.part` 续传、重试、本地 manifest size+BLAKE3 校验、官方 seed `.hash` 校验、repair,以及默认 8、范围 `1..=256` 的有界并发 scheduler;worker 动态领取任务,进度按完成数单调上报,report 保持 plan 顺序。
|
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 后台运维命令返回。
|
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`。
|
5. Go 入口边界:**已确定**。同步命令行 = Rust `bat`;资源分发 = `bat-api` MVP。详见 `docs/reports/GO_STATUS.md`。
|
||||||
6. 用户级 `sync`、`manifest inspect`、`cache status`:**未完成**。Rust `bat --json` 是当前稳定进程边界;`bat-ffi` 只提供可选兼容用的 Manifest inspect 和 sync plan JSON helper。
|
6. Go 用户级 `sync`、`manifest inspect`、`cache status`:**未完成**。Rust `bat`
|
||||||
7. 下载结果写入 CAS + ResourceRepository:**部分完成**。CAS 和 SQLite ResourceRepository 已存在,官方同步入口可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后导入;`resource.index` 可按资源级 release、平台、destination、archive entry、parse status 和 TextUnit format 查询现有索引和资源 metadata;`parse.text_units` / `parse.errors` 可查询当前 release 的 TextUnit 明细与解析错误;`translation.tasks` 可查询离线 TextUnit 翻译任务状态和跳过/失败原因。剩余工作是真实 provider worker 集成、CAS 诊断入口和面向大索引的查询优化。
|
是当前正式资源同步 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`。
|
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`,本地文件未变化且缓存/索引有效时跳过重复解析。
|
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 管理接口控制。
|
||||||
|
|
||||||
验收标准:
|
验收标准:
|
||||||
|
|
||||||
@@ -203,7 +205,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
|||||||
|
|
||||||
### Milestone 4:Unity AssetBundle 解析
|
### Milestone 4:Unity AssetBundle 解析
|
||||||
|
|
||||||
维护冻结细则见 [`docs/reports/PARSER_FREEZE.md`](docs/reports/PARSER_FREEZE.md);冻结期只接受稳定性、诊断、真实回归和文档一致性修复。
|
当前解析扩展按路线图和真实回归继续推进。
|
||||||
|
|
||||||
**目标**:建立可扩展 AssetBundle 解析框架,并首先支持文本相关资源。
|
**目标**:建立可扩展 AssetBundle 解析框架,并首先支持文本相关资源。
|
||||||
|
|
||||||
@@ -211,9 +213,9 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
|||||||
|
|
||||||
1. **解析缓存闭环**:官方同步发布后生成 `official-parse-cache.json`,覆盖 manifest 全部条目、直接 bundle、zip 内 bundle、非候选资源和解析失败诊断;未变化文件按 URL、相对路径、size 和 BLAKE3 复用解析结果。
|
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 和资源类型。
|
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 表示。
|
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 的文本定位。
|
6. **文本对象入口**:实现 TextAsset、MonoBehaviour、ScriptableObject 的可扩展提取入口,输出可追溯到 bundle、serialized file、path id 和 field path 的文本定位。
|
||||||
7. **工具与接口**:编写 `bundle inspect`、`bundle extract`、`text extract` 的最小稳定入口;CLI/RPC/API 使用解析器输出,不直接耦合解析内部结构。
|
7. **工具与接口**:编写 `bundle inspect`、`bundle extract`、`text extract` 的最小稳定入口;CLI/RPC/API 使用解析器输出,不直接耦合解析内部结构。
|
||||||
8. **汉化发布前置**:解析结果必须能作为 Patch 输入;Patch 发布阶段才写入 `--localized-output` / `BAT_LOCALIZED_OUTPUT` 配置的汉化输出目录并切换 `localized` 状态。
|
8. **汉化发布前置**:解析结果必须能作为 Patch 输入;Patch 发布阶段才写入 `--localized-output` / `BAT_LOCALIZED_OUTPUT` 配置的汉化输出目录并切换 `localized` 状态。
|
||||||
@@ -258,11 +260,10 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
|||||||
|
|
||||||
交付物:
|
交付物:
|
||||||
|
|
||||||
1. PostgreSQL schema:source_text、translation、translation_memory、glossary、review、history。
|
1. Translation Memory persistence schema V2 已使用项目级 SQLite schema:source raw/hash、translation、完整 context、candidate/trusted、provenance、supersede 关系和 audit event。
|
||||||
2. 实现精确匹配、模糊匹配、上下文匹配。
|
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. 实现术语优先级、别名、分类、冲突检测和审核状态。
|
3. 已实现显式 per-record confirm、supersede 和历史冲突 resolve;Glossary 已实现术语优先级、别名、分类、冲突检测和审核历史,批量审核与完整导入导出仍待实现。
|
||||||
4. 实现导入导出和版本历史。
|
4. 已实现 `bat i18n memory summary|query|confirm|conflicts|resolve-conflict` 与对应 Rust RPC。
|
||||||
5. 实现 `translate memory`、`glossary` CLI 子命令。
|
|
||||||
|
|
||||||
验收标准:
|
验收标准:
|
||||||
|
|
||||||
@@ -305,7 +306,8 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
|||||||
3. 实现客户端发现、路径校验、备份、应用、回滚。
|
3. 实现客户端发现、路径校验、备份、应用、回滚。
|
||||||
4. 实现 `patch build`、`patch apply`、`patch rollback`、`verify`。
|
4. 实现 `patch build`、`patch apply`、`patch rollback`、`verify`。
|
||||||
5. 实现 dry-run 和安全检查。
|
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 保持独立。
|
||||||
|
|
||||||
验收标准:
|
验收标准:
|
||||||
|
|
||||||
@@ -323,12 +325,14 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
|||||||
|
|
||||||
交付物:
|
交付物:
|
||||||
|
|
||||||
1. Go CLI 主入口和命令体系。
|
1. 正式 Rust `bat` CLI 和命令体系;Go 侧面向 `bat-api`、SDK 和服务集成发展。
|
||||||
2. 配置系统:项目级、用户级、环境变量、密钥管理。
|
2. 配置系统:项目级、用户级、环境变量、密钥管理。
|
||||||
3. Go SDK:Manifest、Sync、CAS、Extract、Translate、Patch。
|
3. Go SDK:Manifest、Sync、CAS、Extract、Translate、Patch。
|
||||||
4. REST API Server:认证、权限、统一错误码、OpenAPI。
|
4. REST API Server:认证、权限、统一错误码、OpenAPI。
|
||||||
5. 后台任务系统:同步、提取、翻译、补丁构建。
|
5. 后台任务系统:同步、提取、翻译、补丁构建。
|
||||||
|
|
||||||
|
当前边界:正式同步与运维 CLI 继续由 Rust `bat` 承担;Go `cmd/bat` 仅为试验入口,Go 产品化工作集中在 `bat-api`、SDK 和服务集成。
|
||||||
|
|
||||||
验收标准:
|
验收标准:
|
||||||
|
|
||||||
1. CLI 命令风格统一,支持 JSON 输出和人类可读输出。
|
1. CLI 命令风格统一,支持 JSON 输出和人类可读输出。
|
||||||
@@ -340,12 +344,12 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
|||||||
|
|
||||||
### Milestone 10:Web 管理后台
|
### Milestone 10:Web 管理后台
|
||||||
|
|
||||||
**目标**:为翻译协作和资源管理提供可用后台。
|
**目标**:在已落地的 `bat-api` 内嵌 dashboard MVP 之上,为翻译协作和资源管理提供完整后台。
|
||||||
|
|
||||||
交付物:
|
交付物:
|
||||||
|
|
||||||
1. 登录、权限、用户角色。
|
1. 登录、权限、用户角色。
|
||||||
2. Dashboard:同步状态、翻译进度、质量问题、队列状态。
|
2. Dashboard:同步状态、翻译进度、质量问题、队列状态;当前 MVP 已覆盖资源、调度、任务、日志、parse、翻译和 localized 控制。
|
||||||
3. 翻译审核:列表、详情、Diff、批量操作。
|
3. 翻译审核:列表、详情、Diff、批量操作。
|
||||||
4. 术语管理:搜索、冲突提示、审核。
|
4. 术语管理:搜索、冲突提示、审核。
|
||||||
5. 资源浏览:版本、资源、Bundle、文本定位。
|
5. 资源浏览:版本、资源、Bundle、文本定位。
|
||||||
@@ -366,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:本地开发、服务端部署。
|
2. Docker Compose:本地开发、服务端部署。
|
||||||
3. 数据备份与恢复文档。
|
3. 数据备份与恢复文档。
|
||||||
4. 用户文档、开发文档、故障排查文档。
|
4. 用户文档、开发文档、故障排查文档。
|
||||||
@@ -384,7 +388,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
|||||||
|
|
||||||
## 5. 推荐执行顺序
|
## 5. 推荐执行顺序
|
||||||
|
|
||||||
近期不要直接跳到 Web 或 AI Provider。项目当前的真实瓶颈是资源解析、增量变更集进入文本提取/翻译队列、`bat-api` 与全量 release 联调,以及真实端到端验证。
|
近期不要把内嵌 dashboard MVP 扩成完整协作后台或过早扩展 AI Provider。项目当前的真实瓶颈仍是完整 Web 术语协作视图、复杂 AssetBundle 重打包和真实官方资源长期运行验证。
|
||||||
|
|
||||||
建议顺序:
|
建议顺序:
|
||||||
|
|
||||||
@@ -392,20 +396,19 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
|||||||
2. 完成 Milestone 5,再开始翻译系统。
|
2. 完成 Milestone 5,再开始翻译系统。
|
||||||
3. 完成 Milestone 6 和 7,建立可审计翻译流程。
|
3. 完成 Milestone 6 和 7,建立可审计翻译流程。
|
||||||
4. 完成 Milestone 8,形成可交付补丁。
|
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 已关闭(顺序下载 + 指数退避)。
|
1. 继续 Addressables 结构变体与 UnityFS 复杂对象能力。
|
||||||
2. G-008 已决策关闭:同步/运维命令行 = 近乎全自动的 Rust `bat`。
|
2. 基于 `translation.worker.run` 继续补充复杂 AssetBundle 的 Patch 构建与发布验证。
|
||||||
3. G-009 / issue #19:`bat-api` 资源分发 MVP 已落地;优先服务器联调;拉取仍在 Rust `bat`。
|
3. 继续扩展资源库剩余查询面:更丰富的 TextUnit/TM 查询和通用 Patch 发布所需资源视图。
|
||||||
4. 继续 Addressables(issue #2)与 UnityFS(issue #3 / G-005)。
|
4. 在隔离环境执行真实官方网络长期运行 smoke,并保留运行报告。
|
||||||
5. 将 `crowdin-textunit-queue.json` 接入真实 Crowdin worker、翻译记忆和 Patch 构建。
|
|
||||||
6. 继续扩展 G-011 剩余查询面:真实 provider worker 集成与状态落库验证、`doctor cas` 诊断入口和面向大索引的查询优化。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -413,7 +416,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
|||||||
|
|
||||||
### SQLite 权限问题
|
### SQLite 权限问题
|
||||||
|
|
||||||
旧 Week 3 报告提到 SQLite 文件权限导致测试失败。处理策略:
|
本地 SQLite 元数据后端的权限和恢复风险需要通过显式测试覆盖。处理策略:
|
||||||
|
|
||||||
1. 本地元数据后端必须使用临时目录和明确权限测试。
|
1. 本地元数据后端必须使用临时目录和明确权限测试。
|
||||||
2. SQLite 只作为 adapter,不进入领域层。
|
2. SQLite 只作为 adapter,不进入领域层。
|
||||||
@@ -433,7 +436,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
|||||||
处理策略:
|
处理策略:
|
||||||
|
|
||||||
1. Rust 提供稳定引擎能力,并在当前阶段承担可生产运行的官方资源同步 CLI、watch 和 daemon。
|
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。
|
3. 跨边界优先进程或 SDK,FFI 只作为可选的粗粒度、无状态、安全、可测试兼容 API。
|
||||||
4. Rust 不需要被强制写成 Go 调用库;当前 `bat --watch` / `bat --daemon` 是允许长期运行的 Rust 生产任务。
|
4. Rust 不需要被强制写成 Go 调用库;当前 `bat --watch` / `bat --daemon` 是允许长期运行的 Rust 生产任务。
|
||||||
|
|
||||||
@@ -446,22 +449,22 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
|||||||
3. smoke test 只记录命令、状态和摘要,不把大体积官方资源纳入 Git。
|
3. smoke test 只记录命令、状态和摘要,不把大体积官方资源纳入 Git。
|
||||||
4. 下载成功后必须通过 `official-download-manifest.json` audit 和官方 seed `.hash` 校验报告确认。
|
4. 下载成功后必须通过 `official-download-manifest.json` audit 和官方 seed `.hash` 校验报告确认。
|
||||||
|
|
||||||
### 过早做 Web
|
### Web 范围控制
|
||||||
|
|
||||||
处理策略:
|
处理策略:
|
||||||
|
|
||||||
1. Web 依赖可用 API 和数据库,不应早于核心同步、提取、翻译模型。
|
1. 当前内嵌 dashboard 只编排已有 API,不维护第二套业务状态。
|
||||||
2. 先完成 CLI 和 API,再构建 Web。
|
2. 完整协作后台应在权限、翻译模型和持久化 API 明确后继续建设。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. 当前完成度评估
|
## 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 运行记录
|
||||||
- **下一项工程任务**:执行官方同步端到端 smoke,推进真实 Crowdin worker / 翻译记忆、复杂 AssetBundle 解析和 bat-api 全量 release 联调。
|
- **下一项工程任务**:推进 TM/Glossary 扩展、复杂 AssetBundle 解析,并持续执行官方资源长期运行 smoke。
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**BlueArchiveToolkit** 是一个面向长期维护的 Blue Archive 资源管理、解析、翻译和补丁工具套件。
|
**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-adapters` Unity、Manifest、Client 集成框架,以及当前真实形态 Addressables catalog 解析覆盖,含 `m_Crc` 提取和 UnityFS 解包/TextAsset 提取基础校验。
|
||||||
- `bat-cas-engine` CAS V1:原子写入、BLAKE3 校验、引用计数、GC、并发写入测试、损坏检测。
|
- `bat-cas-engine` CAS V1:原子写入、BLAKE3 校验、引用计数、GC、并发写入测试、损坏检测。
|
||||||
- `bat-infrastructure` CAS 适配层、SQLite Resource Repository、资源导入服务、官方资源 pull/update 服务。
|
- `bat-infrastructure` CAS 适配层、SQLite Resource Repository、资源导入服务、官方资源 pull/update 服务。
|
||||||
- `bat`:官方资源自动发现、全量拉取、原子发布到 `current -> versions/<id>`、本地 manifest audit/repair、`.part` 断点续传、403/404/5xx 分类重试、指数退避、默认并发 8(可配置 `1..=256`,report 按 plan 顺序、进度按完成数单调上报)、下载 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`、`localized.status`、`catalog.*`、`task.*`、`patch.apply`、`unityfs.patch_*`)。
|
- `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 的默认路径。
|
- `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,不负责自动拉取。
|
- `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)(G-008 已关闭:同步 CLI = Rust `bat`)。
|
- Go 边界权威说明:[`docs/reports/GO_STATUS.md`](docs/reports/GO_STATUS.md)(同步 CLI = Rust `bat`)。
|
||||||
- 官方同步会维护 `<output>/official-version-state.json`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。
|
- 官方同步会维护 `<output>/official-version-state.json`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。
|
||||||
- 资源导入链路可配置为在官方 release 发布后写入 CAS + `ResourceRepository`,资源 metadata 会记录 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式,TextAsset/Table/Media 会按类型分类索引;`resource.index` RPC/CLI 可按类型、hash、路径模式、官方 release ID、平台、destination、archive entry、parse status 和 TextUnit format 分页查询索引。
|
- 资源导入链路可配置为在官方 release 发布后写入 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 交接。
|
- 新 release 发布后会生成 `official-resource-changes.json`、`official-parse-cache.json`、`official-textunit-index.json`、`official-textunit-tasks.json`、`crowdin-translation-handoff.json`、`crowdin-textunit-queue.json`、`translation-tasks.sqlite` 和 `translation-handoff.json`;其中 TextUnit/Crowdin 队列只使用 Added/Modified 资源,不调用 Crowdin 网络 API,离线 TextUnit 翻译任务可通过 `translation.tasks` / `translation.handoff` RPC 或 CLI 查询状态、跳过/失败原因和 provider run 交接。
|
||||||
- `LocalizedPatchService` 已具备 UnityFS TextAsset patch 发布前置能力:在 `--localized-output` / `BAT_LOCALIZED_OUTPUT` 配置的独立汉化目录 staging 中复制官方 release、应用 TextAsset patch、写 `localized-patch-manifest.json`(hash、size、diff、rollback)、校验后发布到 `versions/<id>` 并切换 `current`。
|
- `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 扩展体系仍待实现。
|
||||||
- `bat-patch` 已具备通用 Patch 基础:确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、Patch manifest、BLAKE3/size 完整性校验和 rollback 元数据;文件级 `patch.apply` RPC / `patch-apply` CLI 与 UnityFS TextAsset / TypeTree string / TypeTree 语义字段写入入口已开放,TypeTree 语义字段支持基础标量、固定 Unity float/int/hash 值类型的 leaf/direct-child 形态、PPtr、managed-reference registry payload 字符串、object 字段组合、unknown fixed-size raw bytes 同长度替换和 TypeTree schema 支撑的 array/vector/map 整体替换;TextUnit 提取会把 managed-reference 类型信息保留为上下文而非翻译文本,汉化发布当前仍走 UnityFS TextAsset 前置链路。
|
- `LocalizedPatchService` 已具备受支持的 UnityFS localized patch 发布/回滚能力:在 `--localized-output` / `BAT_LOCALIZED_OUTPUT` 配置的独立汉化目录 staging 中复制官方 release、应用 TextAsset、TypeTree string field 或 managed-reference string field patch、写入带 TextUnit/provider/review/rollback trace 的 `localized-patch-manifest.json`,校验后发布到 `versions/<id>` 并切换 `current`,也可显式 rollback。
|
||||||
|
- `bat-patch` 已具备通用 Patch 基础:确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、Patch manifest、BLAKE3/size 完整性校验和 rollback 元数据;文件级 `patch.apply` RPC / `patch-apply` CLI 与 UnityFS TextAsset / TypeTree string / TypeTree 语义字段写入入口已开放,TypeTree 语义字段支持基础标量、固定 Unity float/int/hash 值类型的 leaf/direct-child 形态、PPtr、managed-reference registry payload 字符串、object 字段组合、unknown fixed-size raw bytes 同长度替换和 TypeTree schema 支撑的 array/vector/map 整体替换;TextUnit 提取会把 managed-reference 类型信息保留为上下文而非翻译文本,受支持 localized 发布通过独立 manifest/staging/current 流程完成。
|
||||||
- `bat-ffi` 可选无状态 C ABI 兼容层:仅保留 Manifest inspect 和官方 sync plan 的粗粒度 JSON helper,不作为 Go CLI 或生产同步的主集成边界。
|
- `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 对象级解析(UnityFS 解包、object table、TypeTree node 元数据、TextAsset bytes、MonoBehaviour/ScriptableObject 基础 TypeTree 字段级解析已起步;复杂字段覆盖、发布级重打包和 Patch 发布统一仍未完成)。
|
||||||
- 复杂 AssetBundle 重打包和真实翻译构建 worker;当前通用 Binary/JSON/Text Patch 基础已在 crate 层可用,发布链路仍只开放 UnityFS TextAsset patch 前置能力。
|
- 复杂 AssetBundle 重打包和完整翻译资产编排仍未完成;当前 generic manifest 已驱动已验证的 Binary/JSON/Text 与 UnityFS localized 操作,未知结构仍明确拒绝。
|
||||||
- Translation Memory、Glossary、AI Provider。
|
- Translation Memory、Glossary 和完整 Provider 扩展体系:Translation Memory persistence schema V2 与 Glossary domain/feature contract V1(SQLite persistence schema V2)已由 Rust `bat` 持有;仍未实现的是模糊匹配、完整 Provider 扩展体系和完整 Web 协作后台。
|
||||||
- SDK、Web 管理后台。
|
- SDK、完整 Web 协作后台。
|
||||||
|
|
||||||
详细状态见:
|
详细状态见:
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@
|
|||||||
- [当前缺口清单](docs/reports/CURRENT_GAPS.md)
|
- [当前缺口清单](docs/reports/CURRENT_GAPS.md)
|
||||||
- [官方资源拉取与自动更新指南](docs/guides/official-resource-test-pull.md)
|
- [官方资源拉取与自动更新指南](docs/guides/official-resource-test-pull.md)
|
||||||
- [官方全量拉取 Smoke Runbook](docs/guides/official-full-pull-smoke.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)
|
- [官方资源后端说明](docs/architecture/official-resource-backend.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -51,19 +53,20 @@
|
|||||||
前置要求:
|
前置要求:
|
||||||
|
|
||||||
- Rust 1.75+
|
- Rust 1.75+
|
||||||
- Go 1.22+
|
- Go 1.26.4+
|
||||||
- `curl`
|
- `curl`
|
||||||
- `unzip`,仅旧版 launcher manifest 指向整包 ZIP 且 `--auto-discover` 需要从 ZIP 解析 `GameMainConfig` 时使用;当前目录型 manifest 会直接下载 `resources.assets`
|
- `unzip`,仅旧版 launcher manifest 指向整包 ZIP 且 `--auto-discover` 需要从 ZIP 解析 `GameMainConfig` 时使用;当前目录型 manifest 会直接下载 `resources.assets`
|
||||||
|
|
||||||
运行当前通用验证:
|
运行当前通用验证:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo test --workspace
|
make ci-check
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
|
||||||
go test ./...
|
|
||||||
go vet ./...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`make ci-check` 是只读 required 门禁;`make format` / `make fmt` 才会格式化源码。
|
||||||
|
Go lint 是 required gate,使用 `scripts/ci-versions.sh` 固定的
|
||||||
|
`golangci-lint 2.12.2`;工具缺失或版本不匹配都会失败。
|
||||||
|
|
||||||
查看官方同步命令:
|
查看官方同步命令:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -101,11 +104,11 @@ cargo run -p bat-infrastructure --bin bat -- reload
|
|||||||
cargo run -p bat-infrastructure --bin bat -- stop
|
cargo run -p bat-infrastructure --bin bat -- stop
|
||||||
```
|
```
|
||||||
|
|
||||||
`status`、`stop`、`restart`、`logs`、`reload`、默认形态的 `refresh` 和默认形态的 `repair` 会优先连接 live RPC socket;socket 不可用时,状态和停止命令会回退到 PID/状态文件兼容路径。`restart` 会通过 Rust lifecycle controller 复用 CLI restart 路径替换后台进程;`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` 权限创建。
|
`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`:
|
资源操作命令默认输出人类可读摘要,并在没有显式 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。
|
该 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。
|
- Rust:CAS、官方资源同步核心、AssetBundle/Patch 引擎;当前生产同步入口是 `bat` binary。
|
||||||
- Go:当前正式入口是 `bat-api` 资源 bootstrap/分发服务和 `internal/backendrpc`;完整游戏业务 API、SDK、Provider 编排仍按路线图推进,`cmd/bat` 仅为试验 CLI。
|
- Go:当前正式入口是 `bat-api` 资源 bootstrap/分发服务、内嵌 dashboard 和 `internal/backendrpc`;完整游戏业务 API、SDK、Provider 编排仍按路线图推进,`cmd/bat` 仅为试验 CLI。
|
||||||
- `bat-ffi`:可选兼容层,只暴露无状态粗粒度 JSON C ABI,不承载 daemon、下载器、CAS handle 或主控制面。
|
- `bat-ffi`:可选兼容层,只暴露无状态粗粒度 JSON C ABI,不承载 daemon、下载器、CAS handle 或主控制面。
|
||||||
- PostgreSQL:计划中的服务端主数据库。
|
- PostgreSQL:计划中的服务端主数据库。
|
||||||
- Redis:计划中的缓存、队列状态、限流和短期锁。
|
- Redis:计划中的缓存、队列状态、限流和短期锁。
|
||||||
- Vue 3 + TypeScript:计划中的 Web 管理后台。
|
- Vue 3 + TypeScript:计划中的完整 Web 协作后台;当前已先提供无构建内嵌 dashboard。
|
||||||
- Docker / Docker Compose:数据库和后续服务部署配置。
|
- Docker / Docker Compose:数据库和后续服务部署配置。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -168,7 +171,7 @@ BlueArchiveToolkit/
|
|||||||
├── cmd/ # Go CLI 试验骨架与后续产品入口
|
├── cmd/ # Go CLI 试验骨架与后续产品入口
|
||||||
├── pkg/ # Go SDK 包,尚未实现
|
├── pkg/ # Go SDK 包,尚未实现
|
||||||
├── api/ # 预留 API 定义;bat-api OpenAPI 静态规范已提供,完整业务 API 尚未实现
|
├── api/ # 预留 API 定义;bat-api OpenAPI 静态规范已提供,完整业务 API 尚未实现
|
||||||
├── web/ # Web 管理后台,尚未实现
|
├── web/ # bat-api 内嵌 dashboard 静态资产;完整协作后台仍在后续阶段
|
||||||
├── deployments/ # Docker 和部署配置
|
├── deployments/ # Docker 和部署配置
|
||||||
├── docs/ # 文档、历史报告和分析资料
|
├── docs/ # 文档、历史报告和分析资料
|
||||||
├── Cargo.toml
|
├── Cargo.toml
|
||||||
@@ -182,13 +185,13 @@ BlueArchiveToolkit/
|
|||||||
|
|
||||||
近期优先级:
|
近期优先级:
|
||||||
|
|
||||||
1. 维护并联调 Go `bat-api` 资源 bootstrap/分发入口;`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 引擎级解析。
|
2. 补齐 AssetBundle UnityFS 引擎级解析。
|
||||||
3. 扩展 Addressables catalog 解析覆盖,继续用真实形态 fixture/golden 锁定行为。
|
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 在具备网络和磁盘窗口的环境中执行真实官方全量拉取,并保留本地报告。
|
5. 按 smoke runbook 在具备网络和磁盘窗口的环境中执行真实官方全量拉取,并保留本地报告。
|
||||||
|
|
||||||
不建议在 Go 产品入口、资源解析和文本提取基础能力完成前优先开发 Web UI。
|
当前已提供直接调用 bat-api 鉴权接口的内嵌 dashboard;完整 Web 协作后台仍应在 TM 扩展、权限模型和持久化 API 明确后推进。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+150
-14
@@ -12,9 +12,9 @@
|
|||||||
`bat` 是 Linux 上官方日服(Yostar JP)资源同步的正式入口。它可以:
|
`bat` 是 Linux 上官方日服(Yostar JP)资源同步的正式入口。它可以:
|
||||||
|
|
||||||
- `--auto-discover` 从官方 HTTP metadata 解析 `GameMainConfig`,自动获得 app-version、连接组和 server-info,不安装、不启动官方启动器;已发布 release 会保存 `official-launcher-bootstrap.json`。
|
- `--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。
|
- 断点续传、失败分类重试、下载 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 控制。
|
- 常驻运行(`--watch`)或后台化(`--daemon`),通过 `bat.sock` Unix socket JSON-RPC 控制。
|
||||||
|
|
||||||
### 运行形态
|
### 运行形态
|
||||||
@@ -49,10 +49,17 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon
|
|||||||
| `parse run` | 执行当前官方 release 的解析和 TextUnit 队列刷新 |
|
| `parse run` | 执行当前官方 release 的解析和 TextUnit 队列刷新 |
|
||||||
| `parse clear-cache` | 使用 `--force` 清理当前 release 的可再生解析缓存和翻译队列 |
|
| `parse clear-cache` | 使用 `--force` 清理当前 release 的可再生解析缓存和翻译队列 |
|
||||||
| `parse repack` | 根据 JSON spec 批量重打包 UnityFS bundle |
|
| `parse repack` | 根据 JSON spec 批量重打包 UnityFS bundle |
|
||||||
|
| `parse schedule` | 管理解析计划;与 `res schedule` / `i18n schedule` 共用同一计划状态 |
|
||||||
| `i18n run` / `i18n export` | 刷新离线翻译队列或导出可编辑翻译工作台 |
|
| `i18n run` / `i18n export` | 刷新离线翻译队列或导出可编辑翻译工作台 |
|
||||||
| `i18n set` / `i18n get` / `i18n unset` | 手动查看、修改或清空一个翻译工作台条目;也可通过 `i18n workbench ...` 访问 |
|
| `i18n set` / `i18n get` / `i18n unset` | 手动查看、修改或清空一个翻译工作台条目;也可通过 `i18n workbench ...` 或 `--workbench` 访问 |
|
||||||
| `i18n validate` | 发布前校验工作台 release、source text 和 patch 目标 |
|
| `i18n validate` | 发布前校验工作台 release、source text 和 patch 目标 |
|
||||||
| `i18n publish` | 校验工作台并发布独立汉化 release;`--force` 使用新的手动 release ID |
|
| `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` | 管理翻译和汉化发布计划 |
|
| `i18n schedule` | 管理翻译和汉化发布计划 |
|
||||||
| `refresh` | 执行一次更新检查;若有 live daemon,则通过 RPC 请求其刷新 |
|
| `refresh` | 执行一次更新检查;若有 live daemon,则通过 RPC 请求其刷新 |
|
||||||
| `verify` | 校验远端计划、本地 manifest 和官方 seed hash(dry-run + 审计当前 release) |
|
| `verify` | 校验远端计划、本地 manifest 和官方 seed hash(dry-run + 审计当前 release) |
|
||||||
@@ -66,11 +73,17 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon
|
|||||||
| `clean-stable` | 清理 `.part`/`.tmp`/失效锁、PID、socket(daemon 运行中会拒绝执行) |
|
| `clean-stable` | 清理 `.part`/`.tmp`/失效锁、PID、socket(daemon 运行中会拒绝执行) |
|
||||||
|
|
||||||
`status`/`stop`/`logs`/`reload` 和默认形态的 `refresh` 优先走 `bat.sock` JSON-RPC;socket 不可用时 `status`/`stop` 回退到 PID/状态文件兼容路径。
|
`status`/`stop`/`logs`/`reload` 和默认形态的 `refresh` 优先走 `bat.sock` JSON-RPC;socket 不可用时 `status`/`stop` 回退到 PID/状态文件兼容路径。
|
||||||
|
需要替换 daemon 启动参数时使用 `restart`,或给 `reload` 显式传入同步、输出、worker/TM 等启动参数;未显式传参的 `restart` 复用上次保存的启动命令。
|
||||||
|
|
||||||
Rust `bat` 工作流的完整命令、工作台字段、重打包 spec、调度计划和
|
Rust `bat` 工作流的完整命令、工作台字段、重打包 spec、调度计划和
|
||||||
`bat-api` 调度接口见 [`docs/guides/bat-workflows.md`](docs/guides/bat-workflows.md)。
|
`bat-api` 调度接口见 [`docs/guides/bat-workflows.md`](docs/guides/bat-workflows.md)。
|
||||||
一级命令推荐使用短名称 `res`、`parse`、`i18n`;`resource`、`resources`、
|
一级命令推荐使用短名称 `res`、`parse`、`i18n`;`resource`、`resources`、
|
||||||
`translation`、`translate` 仍是兼容别名。
|
`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 / 分发服务
|
### bat-api 资源 bootstrap / 分发服务
|
||||||
|
|
||||||
@@ -125,14 +138,31 @@ BAT_API_SKIP_ENV_FILE=1 go run ./cmd/bat-api \
|
|||||||
| `GET/HEAD /prod-clientpatch.bluearchiveyostar.com/...` | 官方 CDN path 形态资源字节 |
|
| `GET/HEAD /prod-clientpatch.bluearchiveyostar.com/...` | 官方 CDN path 形态资源字节 |
|
||||||
| `GET /openapi.yaml` | bat-api OpenAPI 文档 |
|
| `GET /openapi.yaml` | bat-api OpenAPI 文档 |
|
||||||
| `GET /admin/` | 管理控制入口与允许操作列表 |
|
| `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/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` 控制请求;见下文 |
|
| `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,也不仿造登录、账号、网关、鉴权或游戏业务协议。
|
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 鉴权、限流和访问日志:
|
生产面对玩家分发时,应启用 HTTP token 鉴权、限流和访问日志:
|
||||||
|
|
||||||
- `BAT_API_AUTH_TOKEN`:启用 `Authorization: Bearer <token>`、`X-BAT-Token` 或 query fallback 鉴权;token 推荐由 secret manager 或进程环境提供,不建议写入提交文件。`/admin/control/*` 和 `/admin/schedules` 需要此 token。
|
- `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_QUERY_PARAM`:query fallback 参数名,默认 `bat_token`;兼容不能写 header 的客户端,访问日志不会记录 query。
|
||||||
- `BAT_API_AUTH_EXEMPT_PATHS`:逗号分隔的免鉴权 path 或 slash-prefix,例如 `/healthz,/readyz`。
|
- `BAT_API_AUTH_EXEMPT_PATHS`:逗号分隔的免鉴权 path 或 slash-prefix,例如 `/healthz,/readyz`。
|
||||||
- `BAT_API_RATE_LIMIT_RPS` / `BAT_API_RATE_LIMIT_BURST`:按客户端 IP 的进程内 token bucket 限流;边缘反代/CDN 仍应配置独立限流。
|
- `BAT_API_RATE_LIMIT_RPS` / `BAT_API_RATE_LIMIT_BURST`:按客户端 IP 的进程内 token bucket 限流;边缘反代/CDN 仍应配置独立限流。
|
||||||
@@ -155,7 +185,19 @@ launcher 兼容端点只服务启动前资源发现。它们复用 Rust `bat` sn
|
|||||||
| `schedule-update` | `schedule.update` | 调度 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-remove` | `schedule.remove` | `{ "id": "..." }` | `202` + Rust schedule report |
|
||||||
| `schedule-run` | `schedule.run` | 可选 `{ "id": "...", "force": true }` | `202` + 执行报告 |
|
| `schedule-run` | `schedule.run` | 可选 `{ "id": "...", "force": true }` | `202` + 执行报告 |
|
||||||
| `translation-task-update` | `translation.task.update` | `{ "task_id": "...", "status": "...", "failure_reason": "...", "provider_run_id": "..." }` | `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 暴露。
|
`stop`、`clean-stable`、patch 和 UnityFS 写入命令不会经 HTTP 暴露。
|
||||||
|
|
||||||
@@ -207,6 +249,7 @@ curl -i -H 'Range: bytes=0-1023' \
|
|||||||
| `--proxy <URL\|auto\|none>` | curl 代理覆盖(默认 `auto`,从环境变量检测)。scheme 支持 http/https/socks4/socks4a/socks5/socks5h |
|
| `--proxy <URL\|auto\|none>` | curl 代理覆盖(默认 `auto`,从环境变量检测)。scheme 支持 http/https/socks4/socks4a/socks5/socks5h |
|
||||||
| `--no-proxy` | 强制直连 |
|
| `--no-proxy` | 强制直连 |
|
||||||
| `--unzip <PATH>` | unzip 可执行文件(默认 `unzip`) |
|
| `--unzip <PATH>` | unzip 可执行文件(默认 `unzip`) |
|
||||||
|
| `--zip <PATH>` | zip 可执行文件(默认 `zip`) |
|
||||||
| `--dry-run` | 不写同步状态 |
|
| `--dry-run` | 不写同步状态 |
|
||||||
| `--plan` | dry-run 时输出计划中的 URL |
|
| `--plan` | dry-run 时输出计划中的 URL |
|
||||||
| `--force` | 强制下载/刷新 |
|
| `--force` | 强制下载/刷新 |
|
||||||
@@ -243,16 +286,78 @@ curl -i -H 'Range: bytes=0-1023' \
|
|||||||
- 强制刷新:每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 各一次。
|
- 强制刷新:每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 各一次。
|
||||||
- 状态类文件默认 `0600` 权限,读写不跟随 symlink。
|
- 状态类文件默认 `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` > 内置默认值**。
|
由于 `config.toml` 可能包含代理凭据,Unix 下实际 `config.toml` 必须保持 `0600` 或更严格;权限过宽时程序会拒绝读取。
|
||||||
- 语法:每行 `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`。
|
- 优先级:**命令行参数 > 进程环境变量 > `config.toml` > 内置默认值**。
|
||||||
- `BAT_WATCH` / `BAT_DAEMON` 只对无子命令的 `bat` 生效(两者同时为 `1` 时 daemon 优先);命令行显式传入 `--watch` / `--daemon` / `--dry-run` 时 `.env` 的模式开关让位。`status` / `verify` 等子命令不受它们影响。
|
- `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_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 失效,无关术语变化不会使其失效。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -404,11 +509,17 @@ curl -i -H 'Range: bytes=0-1023' \
|
|||||||
| `resource.repair` | ✅ | 触发本地 manifest 审计 + 修复任务,返回 `task_id`;不继承 `force` |
|
| `resource.repair` | ✅ | 触发本地 manifest 审计 + 修复任务,返回 `task_id`;不继承 `force` |
|
||||||
| `resource.manifest` / `resource.list` | ✅ | 当前版本下载 manifest 分页查询(`params.offset` 默认 0、`params.limit` 默认 100/上限 1000) |
|
| `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 过滤 |
|
| `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.status` | ✅ | 查询当前 release 的解析缓存、TextUnit 索引和队列摘要 |
|
||||||
| `parse.text_units` / `parse.errors` | ✅ | 查询当前 release 的 TextUnit 明细和解析错误 |
|
| `parse.text_units` / `parse.errors` | ✅ | 查询当前 release 的 TextUnit 明细和解析错误 |
|
||||||
| `translation.tasks` | ✅ | 查询离线 TextUnit 翻译任务及 worker 状态 |
|
| `translation.tasks` | ✅ | 查询离线 TextUnit 翻译任务及 worker 状态 |
|
||||||
| `translation.handoff` | ✅ | 查询完整 job/unit/provider run 交接视图 |
|
| `translation.handoff` | ✅ | 查询完整 job/unit/provider run 交接视图 |
|
||||||
| `translation.task.update` | ✅ | 回写当前 release 的 provider worker 状态 |
|
| `translation.task.update` | ✅ | 回写当前 release 的 provider worker 状态 |
|
||||||
|
| `translation.worker.run` | ✅ | 触发 Rust provider worker,落库 TextUnit 译文结果、lease、失败分类和重试状态 |
|
||||||
|
| `translation.proofread` | ✅ | 将当前汉化 workflow 标记为人工校对中 |
|
||||||
| `localized.status` | ✅ | 查询汉化 release 与当前官方 release 的匹配状态 |
|
| `localized.status` | ✅ | 查询汉化 release 与当前官方 release 的匹配状态 |
|
||||||
| `catalog.status` | ✅ | 当前已发布版本的 catalog 概览(app/bundle 版本、addressables 根、端点与 marker 计数、launcher 元数据) |
|
| `catalog.status` | ✅ | 当前已发布版本的 catalog 概览(app/bundle 版本、addressables 根、端点与 marker 计数、launcher 元数据) |
|
||||||
| `catalog.versions` | ✅ | 版本历史:current / in_progress / previous / failed |
|
| `catalog.versions` | ✅ | 版本历史:current / in_progress / previous / failed |
|
||||||
@@ -424,10 +535,23 @@ curl -i -H 'Range: bytes=0-1023' \
|
|||||||
| 未知方法 | — | `BAT-ERR-700001`(unknown method) |
|
| 未知方法 | — | `BAT-ERR-700001`(unknown method) |
|
||||||
|
|
||||||
只读查询(`daemon.doctor` / `resource.state` / `resource.manifest` / `resource.list` /
|
只读查询(`daemon.doctor` / `resource.state` / `resource.manifest` / `resource.list` /
|
||||||
`resource.index` / `parse.*` / `translation.tasks` / `translation.handoff` /
|
`resource.index` / `release.status` / `release.list` / `release.distribution` / `parse.*` / `translation.tasks` / `translation.handoff` /
|
||||||
`localized.status` / `catalog.status` / `catalog.versions` / `catalog.diff`)在尚无已发布版本
|
`localized.status` / `catalog.status` / `catalog.versions` / `catalog.diff`)在尚无已发布版本
|
||||||
或对应文件不存在时返回 `ok: true` 且 `data.available: false`(正常状态而非错误,便于调用方直接分支)。
|
或对应文件不存在时返回 `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`。
|
||||||
|
|
||||||
### 任务模型
|
### 任务模型
|
||||||
|
|
||||||
`resource.sync` / `resource.verify` / `resource.repair` / `catalog.refresh` 是**异步任务**:入队即返回 `{ "task_id": "task-<pid>-<seq>", "kind": "resource.sync" }`(`status: "accepted"`),实际执行由后台任务 worker 串行完成,通过 `task.status` / `task.list` 轮询。任务记录:
|
`resource.sync` / `resource.verify` / `resource.repair` / `catalog.refresh` 是**异步任务**:入队即返回 `{ "task_id": "task-<pid>-<seq>", "kind": "resource.sync" }`(`status: "accepted"`),实际执行由后台任务 worker 串行完成,通过 `task.status` / `task.list` 轮询。任务记录:
|
||||||
@@ -468,4 +592,16 @@ printf '{"jsonrpc":"2.0","id":5,"method":"resource.manifest","params":{"offset":
|
|||||||
# 触发本地资源审计+修复任务
|
# 触发本地资源审计+修复任务
|
||||||
printf '{"jsonrpc":"2.0","id":6,"method":"resource.repair"}\n' \
|
printf '{"jsonrpc":"2.0","id":6,"method":"resource.repair"}\n' \
|
||||||
| socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock
|
| 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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -256,6 +256,22 @@ impl AddressablesCatalogDriver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let address = Self::string_field(value, &["address", "Address", "m_Address", "key", "Key"]);
|
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
|
let hash = value
|
||||||
.get("hash")
|
.get("hash")
|
||||||
.or_else(|| value.get("Hash"))
|
.or_else(|| value.get("Hash"))
|
||||||
@@ -264,28 +280,32 @@ impl AddressablesCatalogDriver {
|
|||||||
.map(ToOwned::to_owned)
|
.map(ToOwned::to_owned)
|
||||||
.unwrap_or_else(|| format!("addressable_{}", index));
|
.unwrap_or_else(|| format!("addressable_{}", index));
|
||||||
|
|
||||||
let size = value
|
let size = Self::u64_field(value, &["size", "Size", "m_Size"]).unwrap_or_default();
|
||||||
.get("size")
|
|
||||||
.or_else(|| value.get("Size"))
|
|
||||||
.or_else(|| value.get("m_Size"))
|
|
||||||
.and_then(|value| value.as_u64())
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let dependencies = Self::dependencies_from_entry(value);
|
let dependencies = Self::dependencies_from_entry(value);
|
||||||
let crc = value
|
let crc = Self::u32_field(value, &["crc", "Crc", "m_Crc"]);
|
||||||
.get("crc")
|
let resource_type_name = Self::type_name_field(
|
||||||
.or_else(|| value.get("Crc"))
|
value,
|
||||||
.or_else(|| value.get("m_Crc"))
|
&[
|
||||||
.and_then(|value| value.as_u64())
|
"resource_type",
|
||||||
.and_then(|value| u32::try_from(value).ok());
|
"resourceType",
|
||||||
|
"ResourceType",
|
||||||
|
"m_ResourceType",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
Some(ResourceEntry {
|
Some(ResourceEntry {
|
||||||
path: path.to_string(),
|
path: path.to_string(),
|
||||||
hash,
|
hash,
|
||||||
size,
|
size,
|
||||||
resource_type: Self::resource_type_for_path(path),
|
resource_type: Self::resource_type_for_compact_entry(
|
||||||
|
path,
|
||||||
|
resource_type_name.as_deref(),
|
||||||
|
),
|
||||||
address,
|
address,
|
||||||
dependencies,
|
dependencies,
|
||||||
|
provider_id,
|
||||||
|
bundle_name,
|
||||||
crc,
|
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> {
|
fn string_array_field(value: &Value, fields: &[&str]) -> Vec<String> {
|
||||||
for field in fields {
|
for field in fields {
|
||||||
if let Some(array) = value.get(field).and_then(|value| value.as_array()) {
|
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),
|
resource_type: Self::resource_type_for_path(path),
|
||||||
address: None,
|
address: None,
|
||||||
dependencies: Vec::new(),
|
dependencies: Vec::new(),
|
||||||
|
provider_id: None,
|
||||||
|
bundle_name: None,
|
||||||
crc: None,
|
crc: None,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -421,6 +479,8 @@ impl AddressablesCatalogDriver {
|
|||||||
resource_type,
|
resource_type,
|
||||||
address: None,
|
address: None,
|
||||||
dependencies: Vec::new(),
|
dependencies: Vec::new(),
|
||||||
|
provider_id: None,
|
||||||
|
bundle_name: None,
|
||||||
crc: None,
|
crc: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -479,8 +539,9 @@ impl AddressablesCatalogDriver {
|
|||||||
internal_ids.len()
|
internal_ids.len()
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
provider_ids
|
let provider_id = provider_ids
|
||||||
.get(record.provider_index as usize)
|
.get(record.provider_index as usize)
|
||||||
|
.cloned()
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
format!(
|
format!(
|
||||||
"compact entry {index} provider_index {} out of range {}",
|
"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 path = Self::normalize_internal_id(&internal_id_prefixes, internal_id);
|
||||||
let extra = Self::extra_data_at(&extra_data, record.data_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 resource_type_name = Self::resource_type_name(json, record.resource_type_index)?;
|
||||||
let dependencies = Self::compact_dependencies(record, &entry_records, &buckets, &keys);
|
let dependencies = Self::compact_dependencies(record, &entry_records, &buckets, &keys);
|
||||||
let hash = extra
|
let hash = extra
|
||||||
.hash
|
.hash
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.or_else(|| extra.bundle_name.filter(|value| !value.is_empty()))
|
|
||||||
.unwrap_or_else(|| format!("addressable_{}", index));
|
.unwrap_or_else(|| format!("addressable_{}", index));
|
||||||
|
|
||||||
resources.push(ResourceEntry {
|
resources.push(ResourceEntry {
|
||||||
@@ -522,6 +582,8 @@ impl AddressablesCatalogDriver {
|
|||||||
Some(primary_key)
|
Some(primary_key)
|
||||||
},
|
},
|
||||||
dependencies,
|
dependencies,
|
||||||
|
provider_id: Some(provider_id),
|
||||||
|
bundle_name: extra.bundle_name,
|
||||||
crc: extra.crc,
|
crc: extra.crc,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -632,25 +694,34 @@ impl AddressablesCatalogDriver {
|
|||||||
.collect()
|
.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 {
|
if data_index < 0 {
|
||||||
return AddressablesExtraData::default();
|
return Ok(AddressablesExtraData::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some((object, _)) = Self::read_serialized_object(extra_data, data_index as usize)
|
let Some((object, _)) = Self::read_serialized_object(extra_data, data_index as usize)
|
||||||
else {
|
else {
|
||||||
return AddressablesExtraData::default();
|
return Err(format!(
|
||||||
|
"compact entry extra data index {} is not decodable",
|
||||||
|
data_index
|
||||||
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
let AddressablesObject::JsonObject { json, .. } = object else {
|
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 {
|
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
|
hash: json
|
||||||
.get("m_Hash")
|
.get("m_Hash")
|
||||||
.and_then(|value| value.as_str())
|
.and_then(|value| value.as_str())
|
||||||
@@ -659,26 +730,31 @@ impl AddressablesCatalogDriver {
|
|||||||
.get("m_BundleName")
|
.get("m_BundleName")
|
||||||
.and_then(|value| value.as_str())
|
.and_then(|value| value.as_str())
|
||||||
.map(ToOwned::to_owned),
|
.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 校验,忠实保留原值。
|
// m_Crc 是 bundle 的 IEEE CRC-32;0 表示不做 CRC 校验,忠实保留原值。
|
||||||
crc: json
|
crc: Self::u32_field(&json, &["m_Crc", "crc", "Crc"]),
|
||||||
.get("m_Crc")
|
})
|
||||||
.and_then(|value| value.as_u64())
|
|
||||||
.and_then(|value| u32::try_from(value).ok()),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
if index < 0 {
|
||||||
return None;
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
json.get("m_resourceTypes")?
|
let resource_types = json
|
||||||
.as_array()?
|
.get("m_resourceTypes")
|
||||||
.get(index as usize)?
|
.and_then(|value| value.as_array())
|
||||||
.get("m_ClassName")?
|
.ok_or_else(|| "compact catalog missing m_resourceTypes array".to_string())?;
|
||||||
.as_str()
|
let value = resource_types.get(index as usize).ok_or_else(|| {
|
||||||
.map(ToOwned::to_owned)
|
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 {
|
fn normalize_internal_id(prefixes: &[String], internal_id: &str) -> String {
|
||||||
@@ -869,6 +945,25 @@ impl AddressablesCatalogDriver {
|
|||||||
fallback_hash_count.to_string(),
|
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",
|
"hash": "synthetic-entry-hash",
|
||||||
"size": 119,
|
"size": 119,
|
||||||
"crc": 3735928559,
|
"crc": 3735928559,
|
||||||
|
"provider_id": "synthetic-provider",
|
||||||
|
"bundle_name": "synthetic-bundle",
|
||||||
"address": "Character_001",
|
"address": "Character_001",
|
||||||
"dependencies": ["synthetic/shared.bundle"]
|
"dependencies": ["synthetic/shared.bundle"]
|
||||||
},
|
},
|
||||||
@@ -1163,6 +1260,14 @@ mod tests {
|
|||||||
assert_eq!(manifest.resources[0].path, "synthetic/minimal.bundle");
|
assert_eq!(manifest.resources[0].path, "synthetic/minimal.bundle");
|
||||||
assert_eq!(manifest.resources[0].hash, "synthetic-entry-hash");
|
assert_eq!(manifest.resources[0].hash, "synthetic-entry-hash");
|
||||||
assert_eq!(manifest.resources[0].size, 119);
|
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。
|
// m_Crc(此处 0xDEADBEEF)应被提取;缺该字段的条目为 None。
|
||||||
assert_eq!(manifest.resources[0].crc, Some(0xDEAD_BEEF));
|
assert_eq!(manifest.resources[0].crc, Some(0xDEAD_BEEF));
|
||||||
assert_eq!(manifest.resources[1].crc, None);
|
assert_eq!(manifest.resources[1].crc, None);
|
||||||
@@ -1207,6 +1312,14 @@ mod tests {
|
|||||||
manifest.metadata.extra.get("dependency_count"),
|
manifest.metadata.extra.get("dependency_count"),
|
||||||
Some(&"1".to_string())
|
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]
|
#[tokio::test]
|
||||||
@@ -1243,6 +1356,14 @@ mod tests {
|
|||||||
assert_eq!(resource.hash, "hash-compact");
|
assert_eq!(resource.hash, "hash-compact");
|
||||||
assert_eq!(resource.size, 42);
|
assert_eq!(resource.size, 42);
|
||||||
assert_eq!(resource.crc, Some(0x1234_5678));
|
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.resource_type, ResourceType::AssetBundle);
|
||||||
assert_eq!(resource.address.as_deref(), Some("synthetic.bundle"));
|
assert_eq!(resource.address.as_deref(), Some("synthetic.bundle"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1261,6 +1382,14 @@ mod tests {
|
|||||||
manifest.metadata.extra.get("declared_crc_count"),
|
manifest.metadata.extra.get("declared_crc_count"),
|
||||||
Some(&"1".to_string())
|
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]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -21,6 +21,19 @@ async fn parses_current_catalog_fixture_with_resource_categories() {
|
|||||||
manifest.resources[3].dependencies,
|
manifest.resources[3].dependencies,
|
||||||
vec!["shared_dependencies.bundle".to_string()]
|
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!(
|
assert_eq!(
|
||||||
manifest.metadata.cdn_prefixes,
|
manifest.metadata.cdn_prefixes,
|
||||||
vec!["https://fixture.invalid/current/".to_string()]
|
vec!["https://fixture.invalid/current/".to_string()]
|
||||||
@@ -63,6 +76,18 @@ async fn parses_catalog_structure_change_with_alias_fields() {
|
|||||||
manifest.resources[0].dependencies,
|
manifest.resources[0].dependencies,
|
||||||
vec!["shared_assets_current.bundle".to_string()]
|
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].resource_type, ResourceType::TextAsset);
|
||||||
assert_eq!(manifest.resources[1].address.as_deref(), Some("lesson"));
|
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",
|
"internal_id": "TableBundles/ExcelDB.db",
|
||||||
"hash": "current-table-hash",
|
"hash": "current-table-hash",
|
||||||
"size": 4096,
|
"size": 4096,
|
||||||
|
"provider_id": "provider-table",
|
||||||
|
"bundle_name": "table-bundle",
|
||||||
|
"crc": "305419896",
|
||||||
"address": "ExcelDB",
|
"address": "ExcelDB",
|
||||||
"dependencies": []
|
"dependencies": []
|
||||||
},
|
},
|
||||||
@@ -15,6 +18,8 @@
|
|||||||
"internal_id": "MediaResources-Windows/voice/title.acb",
|
"internal_id": "MediaResources-Windows/voice/title.acb",
|
||||||
"hash": "current-media-hash",
|
"hash": "current-media-hash",
|
||||||
"size": 2048,
|
"size": 2048,
|
||||||
|
"m_ProviderId": "provider-media",
|
||||||
|
"m_BundleName": "media-bundle",
|
||||||
"address": "title",
|
"address": "title",
|
||||||
"dependencies": []
|
"dependencies": []
|
||||||
},
|
},
|
||||||
@@ -22,6 +27,8 @@
|
|||||||
"internal_id": "TextAssets/dialogue.csv",
|
"internal_id": "TextAssets/dialogue.csv",
|
||||||
"hash": "current-text-hash",
|
"hash": "current-text-hash",
|
||||||
"size": 128,
|
"size": 128,
|
||||||
|
"Provider": "provider-text",
|
||||||
|
"BundleName": "text-bundle",
|
||||||
"address": "dialogue",
|
"address": "dialogue",
|
||||||
"dependencies": []
|
"dependencies": []
|
||||||
},
|
},
|
||||||
@@ -29,6 +36,8 @@
|
|||||||
"internal_id": "shared_assets_current.bundle",
|
"internal_id": "shared_assets_current.bundle",
|
||||||
"hash": "current-bundle-hash",
|
"hash": "current-bundle-hash",
|
||||||
"size": 8192,
|
"size": 8192,
|
||||||
|
"provider": "provider-bundle",
|
||||||
|
"bundleName": "shared-bundle",
|
||||||
"address": "shared_assets_current",
|
"address": "shared_assets_current",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"shared_dependencies.bundle"
|
"shared_dependencies.bundle"
|
||||||
|
|||||||
+4
@@ -9,6 +9,8 @@
|
|||||||
"InternalId": "MediaResources-Android/voice/title.awb",
|
"InternalId": "MediaResources-Android/voice/title.awb",
|
||||||
"Hash": "changed-media-hash",
|
"Hash": "changed-media-hash",
|
||||||
"Size": 65536,
|
"Size": 65536,
|
||||||
|
"ProviderId": "provider-android",
|
||||||
|
"BundleName": "title-android-bundle",
|
||||||
"Address": "title-android",
|
"Address": "title-android",
|
||||||
"m_Dependencies": [
|
"m_Dependencies": [
|
||||||
"shared_assets_current.bundle"
|
"shared_assets_current.bundle"
|
||||||
@@ -18,6 +20,8 @@
|
|||||||
"Path": "TextAssets/lesson.json",
|
"Path": "TextAssets/lesson.json",
|
||||||
"Hash": "changed-text-hash",
|
"Hash": "changed-text-hash",
|
||||||
"Size": 512,
|
"Size": 512,
|
||||||
|
"provider_id": "provider-text",
|
||||||
|
"bundle_name": "lesson-bundle",
|
||||||
"Key": "lesson"
|
"Key": "lesson"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
"dependencies": [
|
"dependencies": [
|
||||||
"shared_assets_all_123.bundle"
|
"shared_assets_all_123.bundle"
|
||||||
],
|
],
|
||||||
|
"provider_id": "UnityEngine.ResourceManagement.ResourceProviders.AssetBundleProvider",
|
||||||
|
"bundle_name": "bundle-main",
|
||||||
"crc": 0
|
"crc": 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -22,6 +24,8 @@
|
|||||||
"resource_type": "AssetBundle",
|
"resource_type": "AssetBundle",
|
||||||
"address": "academy-_mxload-prefabs-2025-08-26_assets_all_1581352935.bundle",
|
"address": "academy-_mxload-prefabs-2025-08-26_assets_all_1581352935.bundle",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
|
"provider_id": "UnityEngine.ResourceManagement.ResourceProviders.AssetBundleProvider",
|
||||||
|
"bundle_name": "bundle-second",
|
||||||
"crc": 0
|
"crc": 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -31,16 +35,20 @@
|
|||||||
"resource_type": "AssetBundle",
|
"resource_type": "AssetBundle",
|
||||||
"address": "shared_assets_all_123.bundle",
|
"address": "shared_assets_all_123.bundle",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
|
"provider_id": "UnityEngine.ResourceManagement.ResourceProviders.AssetBundleProvider",
|
||||||
|
"bundle_name": "bundle-shared",
|
||||||
"crc": 0
|
"crc": 0
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"asset_bundle_count": "3",
|
"asset_bundle_count": "3",
|
||||||
"declared_size_count": "3",
|
"declared_size_count": "3",
|
||||||
|
"bundle_name_count": "3",
|
||||||
"dependency_count": "1",
|
"dependency_count": "1",
|
||||||
"internal_id_count": "3",
|
"internal_id_count": "3",
|
||||||
"resource_count": "3",
|
"resource_count": "3",
|
||||||
"resource_type_count": "1",
|
"resource_type_count": "1",
|
||||||
|
"provider_id_count": "3",
|
||||||
"key_object_count": "4",
|
"key_object_count": "4",
|
||||||
"bucket_record_count": "4",
|
"bucket_record_count": "4",
|
||||||
"entry_record_count": "3",
|
"entry_record_count": "3",
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ async fn parses_real_shape_addressables_catalog_against_golden() {
|
|||||||
"resource_type": format!("{:?}", resource.resource_type),
|
"resource_type": format!("{:?}", resource.resource_type),
|
||||||
"address": resource.address,
|
"address": resource.address,
|
||||||
"dependencies": resource.dependencies,
|
"dependencies": resource.dependencies,
|
||||||
|
"provider_id": resource.provider_id,
|
||||||
|
"bundle_name": resource.bundle_name,
|
||||||
"crc": resource.crc,
|
"crc": resource.crc,
|
||||||
})
|
})
|
||||||
}).collect::<Vec<_>>(),
|
}).collect::<Vec<_>>(),
|
||||||
|
|||||||
@@ -5,5 +5,6 @@ HTTP surface. The running service also exposes the same contract at
|
|||||||
`GET /openapi.yaml`.
|
`GET /openapi.yaml`.
|
||||||
|
|
||||||
This contract covers resource bootstrap, launcher resource compatibility,
|
This contract covers resource bootstrap, launcher resource compatibility,
|
||||||
server-info rewrite, CDN-shaped resource bytes, auth schemes, and the reserved
|
server-info rewrite, CDN-shaped resource bytes, auth schemes, and the admin
|
||||||
admin panel entry. It does not describe a full game business API.
|
panel's Rust-forwarded translation/TM management routes. It does not describe
|
||||||
|
a full game business API.
|
||||||
|
|||||||
+722
-9
@@ -20,25 +20,25 @@ paths:
|
|||||||
summary: Release readiness
|
summary: Release readiness
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"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":
|
"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:
|
/v1/bootstrap:
|
||||||
get:
|
get:
|
||||||
summary: Startup resource bootstrap
|
summary: Startup resource bootstrap
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Resource bootstrap response.
|
description: Resource bootstrap response with the same distribution health used by readiness and current CDN serving.
|
||||||
"503":
|
"503":
|
||||||
description: Release is not ready.
|
description: The current release is not distributable.
|
||||||
/v1/launcher/bootstrap:
|
/v1/launcher/bootstrap:
|
||||||
get:
|
get:
|
||||||
summary: Launcher-shaped resource bootstrap
|
summary: Launcher-shaped resource bootstrap
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Launcher bootstrap response.
|
description: Launcher bootstrap response with the current release distribution health.
|
||||||
"503":
|
"503":
|
||||||
description: Release is not ready.
|
description: The current release is not distributable.
|
||||||
/api/launcher/game/config:
|
/api/launcher/game/config:
|
||||||
get:
|
get:
|
||||||
summary: Resource-only launcher game config compatibility
|
summary: Resource-only launcher game config compatibility
|
||||||
@@ -71,7 +71,58 @@ paths:
|
|||||||
summary: Current release summary
|
summary: Current release summary
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"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:
|
/v1/resources:
|
||||||
get:
|
get:
|
||||||
summary: Paginated resource manifest entries
|
summary: Paginated resource manifest entries
|
||||||
@@ -102,22 +153,509 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: OpenAPI YAML.
|
description: OpenAPI YAML.
|
||||||
|
/admin/dashboard/:
|
||||||
|
get:
|
||||||
|
summary: Embedded bat-api dashboard
|
||||||
|
security: []
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Static dashboard HTML.
|
||||||
/admin/:
|
/admin/:
|
||||||
get:
|
get:
|
||||||
summary: Admin control entry
|
summary: Admin control entry
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Admin links and allowlisted control actions.
|
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}:
|
/admin/control/{action}:
|
||||||
post:
|
post:
|
||||||
summary: Forward an allowlisted control action to Rust bat
|
summary: Forward an allowlisted control or schedule action to Rust bat
|
||||||
parameters:
|
parameters:
|
||||||
- name: action
|
- name: action
|
||||||
in: path
|
in: path
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh]
|
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:
|
requestBody:
|
||||||
required: false
|
required: false
|
||||||
content:
|
content:
|
||||||
@@ -128,6 +666,181 @@ paths:
|
|||||||
properties:
|
properties:
|
||||||
force:
|
force:
|
||||||
type: boolean
|
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:
|
responses:
|
||||||
"202":
|
"202":
|
||||||
description: Rust bat accepted the control request.
|
description: Rust bat accepted the control request.
|
||||||
|
|||||||
@@ -9,7 +9,3 @@ func InspectManifest(rawJSON string) (string, error) {
|
|||||||
func BuildSyncPlan(currentJSON, previousJSON string) (string, error) {
|
func BuildSyncPlan(currentJSON, previousJSON string) (string, error) {
|
||||||
return ffi.BuildSyncPlan(currentJSON, previousJSON)
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintln(os.Stdout, result)
|
_, err = fmt.Fprintln(os.Stdout, result)
|
||||||
return nil
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ serde.workspace = true
|
|||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
blake3.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true, features = ["test-util", "macros"] }
|
tokio = { workspace = true, features = ["test-util", "macros"] }
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ impl GameClient {
|
|||||||
if !is_real_directory(&self.install_path)? || has_symlink_component(&self.install_path)? {
|
if !is_real_directory(&self.install_path)? || has_symlink_component(&self.install_path)? {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
Ok(client_layout_is_present(&self.install_path)?)
|
client_layout_is_present(&self.install_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取 StreamingAssets 目录路径
|
/// 获取 StreamingAssets 目录路径
|
||||||
@@ -154,6 +154,35 @@ 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -244,32 +273,3 @@ mod tests {
|
|||||||
.unwrap());
|
.unwrap());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,11 +2,19 @@
|
|||||||
|
|
||||||
pub mod game_client;
|
pub mod game_client;
|
||||||
pub mod game_version;
|
pub mod game_version;
|
||||||
|
pub mod glossary;
|
||||||
pub mod resource;
|
pub mod resource;
|
||||||
pub mod translation;
|
pub mod translation;
|
||||||
|
pub mod translation_memory;
|
||||||
|
|
||||||
pub use game_client::{ClientStatus, GameClient, GameRegion};
|
pub use game_client::{ClientStatus, GameClient, GameRegion};
|
||||||
pub use game_version::{GameVersion, UnityVersion};
|
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::{
|
pub use resource::{
|
||||||
crc32_ieee, IntegrityMismatch, Resource, ResourceEntry, ResourceMetadata, ResourceType,
|
crc32_ieee, IntegrityMismatch, Resource, ResourceEntry, ResourceMetadata, ResourceType,
|
||||||
};
|
};
|
||||||
@@ -14,3 +22,9 @@ pub use translation::{
|
|||||||
ExtractedText, SourceText, TextContext, TextMetadata, TextSource, TranslatedText,
|
ExtractedText, SourceText, TextContext, TextMetadata, TextSource, TranslatedText,
|
||||||
TranslationStatus,
|
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 address: Option<String>,
|
||||||
/// 该资源依赖的其他资源标识
|
/// 该资源依赖的其他资源标识
|
||||||
pub dependencies: Vec<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`)。
|
/// Addressables bundle 的 CRC32(catalog 中的 `m_Crc`)。
|
||||||
///
|
///
|
||||||
/// `None` 表示 catalog 未提供该字段;Unity 用 `0` 表示「不做 CRC 校验」,
|
/// `None` 表示 catalog 未提供该字段;Unity 用 `0` 表示「不做 CRC 校验」,
|
||||||
@@ -202,6 +212,8 @@ mod tests {
|
|||||||
resource_type: ResourceType::AssetBundle,
|
resource_type: ResourceType::AssetBundle,
|
||||||
address: None,
|
address: None,
|
||||||
dependencies: Vec::new(),
|
dependencies: Vec::new(),
|
||||||
|
provider_id: None,
|
||||||
|
bundle_name: None,
|
||||||
crc,
|
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 cas_repository;
|
||||||
|
pub mod glossary_repository;
|
||||||
pub mod resource_repository;
|
pub mod resource_repository;
|
||||||
|
pub mod translation_memory_repository;
|
||||||
pub mod translation_repository;
|
pub mod translation_repository;
|
||||||
|
|
||||||
pub use cas_repository::CasRepository;
|
pub use cas_repository::CasRepository;
|
||||||
|
pub use glossary_repository::GlossaryRepository;
|
||||||
pub use resource_repository::ResourceRepository;
|
pub use resource_repository::ResourceRepository;
|
||||||
|
pub use translation_memory_repository::TranslationMemoryRepository;
|
||||||
pub use translation_repository::TranslationRepository;
|
pub use translation_repository::TranslationRepository;
|
||||||
|
|||||||
@@ -192,10 +192,10 @@ impl ResourceQuery {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 是否包含需要读取完整 `Resource` 后才能判断的条件。
|
/// 是否包含通用仓储需要读取完整 `Resource` 后才能判断的条件。
|
||||||
///
|
///
|
||||||
/// 基础索引可先用类型、hash、路径模式和 destination 缩小范围;这些条件
|
/// 具体后端可以把这些 metadata 条件下推到自身索引;内存实现仍用完整
|
||||||
/// 需要再按 metadata 过滤,确保 `list()` 与 `count()` 的语义一致。
|
/// `Resource` 过滤来保持 `list()` 与 `count()` 的语义一致。
|
||||||
pub fn requires_resource_scan(&self) -> bool {
|
pub fn requires_resource_scan(&self) -> bool {
|
||||||
self.official_release_id.is_some()
|
self.official_release_id.is_some()
|
||||||
|| self.platform.is_some()
|
|| self.platform.is_some()
|
||||||
|
|||||||
@@ -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 error::{AssetBundleError, Result};
|
||||||
pub use parser::{compression_from_flags, Parser, UnityFsParser};
|
pub use parser::{compression_from_flags, Parser, UnityFsParser};
|
||||||
pub use patch::{
|
pub use patch::{
|
||||||
patch_unityfs_field, patch_unityfs_string_field, patch_unityfs_text_asset, FieldPatch,
|
patch_unityfs_field, patch_unityfs_string_field, patch_unityfs_text_asset,
|
||||||
StringFieldPatch, TextAssetPatch,
|
rebuild_unityfs_bundle, FieldPatch, StringFieldPatch, TextAssetPatch,
|
||||||
};
|
};
|
||||||
pub use serialized::{
|
pub use serialized::{
|
||||||
UnityManagedReferenceMetadata, UnityManagedReferenceRecord, UnitySerializedField,
|
UnityManagedReferenceMetadata, UnityManagedReferenceRecord, UnitySerializedField,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use crate::types::{
|
|||||||
ParsedAssetBundle, RawAssetBundle, UnityFsBlockInfo, UnityFsBundle, UnityFsCompression,
|
ParsedAssetBundle, RawAssetBundle, UnityFsBlockInfo, UnityFsBundle, UnityFsCompression,
|
||||||
UnityFsDirectoryInfo, UnityFsFile, UnityFsHeader, UnitySerializedParseError,
|
UnityFsDirectoryInfo, UnityFsFile, UnityFsHeader, UnitySerializedParseError,
|
||||||
};
|
};
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
|
|
||||||
const UNITYFS_COMPRESSION_MASK: u32 = 0x3f;
|
const UNITYFS_COMPRESSION_MASK: u32 = 0x3f;
|
||||||
@@ -113,9 +114,9 @@ fn parse_unityfs(data: &[u8]) -> Result<UnityFsBundle> {
|
|||||||
flags: reader.read_u32("flags")?,
|
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!(
|
return Err(AssetBundleError::Parse(format!(
|
||||||
"UnityFS total_size {} exceeds file size {}",
|
"UnityFS total_size {} does not match file size {}",
|
||||||
header.total_size,
|
header.total_size,
|
||||||
data.len()
|
data.len()
|
||||||
)));
|
)));
|
||||||
@@ -173,6 +174,7 @@ fn parse_unityfs(data: &[u8]) -> Result<UnityFsBundle> {
|
|||||||
compressed_data_size,
|
compressed_data_size,
|
||||||
uncompressed_data_size,
|
uncompressed_data_size,
|
||||||
raw_data: data.to_vec(),
|
raw_data: data.to_vec(),
|
||||||
|
uncompressed_data,
|
||||||
files,
|
files,
|
||||||
serialized_files,
|
serialized_files,
|
||||||
text_assets,
|
text_assets,
|
||||||
@@ -446,8 +448,14 @@ fn parse_blocks_info(
|
|||||||
"invalid UnityFS block count: {block_count}"
|
"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 {
|
for _ in 0..block_count {
|
||||||
let uncompressed_size = reader.read_u32("block_uncompressed_size")?;
|
let uncompressed_size = reader.read_u32("block_uncompressed_size")?;
|
||||||
let compressed_size = reader.read_u32("block_compressed_size")?;
|
let compressed_size = reader.read_u32("block_compressed_size")?;
|
||||||
@@ -466,8 +474,14 @@ fn parse_blocks_info(
|
|||||||
"invalid UnityFS directory count: {directory_count}"
|
"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 {
|
for _ in 0..directory_count {
|
||||||
directories.push(UnityFsDirectoryInfo {
|
directories.push(UnityFsDirectoryInfo {
|
||||||
offset: reader.read_u64("directory_offset")?,
|
offset: reader.read_u64("directory_offset")?,
|
||||||
@@ -546,7 +560,15 @@ fn validate_directory_bounds(
|
|||||||
data_region_size: u64,
|
data_region_size: u64,
|
||||||
directories: &[UnityFsDirectoryInfo],
|
directories: &[UnityFsDirectoryInfo],
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
let mut paths = HashSet::with_capacity(directories.len());
|
||||||
for (index, directory) in directories.iter().enumerate() {
|
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
|
let end = directory
|
||||||
.offset
|
.offset
|
||||||
.checked_add(directory.size)
|
.checked_add(directory.size)
|
||||||
@@ -567,6 +589,45 @@ fn validate_directory_bounds(
|
|||||||
Ok(())
|
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> {
|
struct UnityFsReader<'a> {
|
||||||
data: &'a [u8],
|
data: &'a [u8],
|
||||||
offset: usize,
|
offset: usize,
|
||||||
@@ -972,6 +1033,21 @@ mod tests {
|
|||||||
assert_eq!(parsed.files[0].data, payload);
|
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]
|
#[test]
|
||||||
fn extracts_data_block_after_block_info_alignment_padding() {
|
fn extracts_data_block_after_block_info_alignment_padding() {
|
||||||
let parser = UnityFsParser::new();
|
let parser = UnityFsParser::new();
|
||||||
@@ -1002,6 +1078,62 @@ mod tests {
|
|||||||
assert!(error.contains("uncompressed data region size 4"), "{error}");
|
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]
|
#[test]
|
||||||
fn rejects_truncated_header_with_field_context() {
|
fn rejects_truncated_header_with_field_context() {
|
||||||
let parser = UnityFsParser::new();
|
let parser = UnityFsParser::new();
|
||||||
@@ -1023,4 +1155,8 @@ mod tests {
|
|||||||
"{error}"
|
"{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::{
|
use crate::serialized::{
|
||||||
UnitySerializedField, UnitySerializedReplacementValue, UnitySerializedValue,
|
UnitySerializedField, UnitySerializedReplacementValue, UnitySerializedValue,
|
||||||
};
|
};
|
||||||
use crate::types::UnityFsBundle;
|
use crate::types::{UnityFsBundle, UnityFsCompression};
|
||||||
use md5::{Digest, Md5};
|
use md5::{Digest, Md5};
|
||||||
|
|
||||||
/// One TextAsset replacement inside a serialized UnityFS directory file.
|
/// One TextAsset replacement inside a serialized UnityFS directory file.
|
||||||
@@ -105,9 +105,9 @@ impl FieldPatch {
|
|||||||
|
|
||||||
/// Patches one TextAsset and rebuilds the UnityFS container.
|
/// Patches one TextAsset and rebuilds the UnityFS container.
|
||||||
///
|
///
|
||||||
/// The rebuilt bundle uses a single uncompressed data block. This keeps the
|
/// The rebuilt bundle retains the parsed block count, compression modes,
|
||||||
/// patch path deterministic and avoids relying on a compressor-specific
|
/// alignment flags and directory metadata while recalculating all variable
|
||||||
/// implementation while preserving all directory file paths and metadata.
|
/// offsets and sizes.
|
||||||
pub fn patch_unityfs_text_asset(data: &[u8], patch: &TextAssetPatch) -> Result<Vec<u8>> {
|
pub fn patch_unityfs_text_asset(data: &[u8], patch: &TextAssetPatch) -> Result<Vec<u8>> {
|
||||||
let parser = UnityFsParser::new();
|
let parser = UnityFsParser::new();
|
||||||
let mut bundle = parser.parse_bytes(data)?;
|
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 rebuilt = rebuild_unityfs(&bundle)?;
|
||||||
let verified = parser.parse_bytes(&rebuilt)?;
|
let verified = parser.parse_bytes(&rebuilt)?;
|
||||||
let asset = verified
|
verify_rebuild_preserves_unmodified_content(
|
||||||
.text_assets
|
&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()
|
.iter()
|
||||||
.find(|asset| asset.path_id == patch.path_id)
|
.find(|asset| asset.path_id == patch.path_id)
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
@@ -197,6 +214,13 @@ pub fn patch_unityfs_string_field(data: &[u8], patch: &StringFieldPatch) -> Resu
|
|||||||
|
|
||||||
let rebuilt = rebuild_unityfs(&bundle)?;
|
let rebuilt = rebuild_unityfs(&bundle)?;
|
||||||
let verified = parser.parse_bytes(&rebuilt)?;
|
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
|
let serialized = verified
|
||||||
.serialized_files
|
.serialized_files
|
||||||
.iter()
|
.iter()
|
||||||
@@ -259,6 +283,13 @@ pub fn patch_unityfs_field(data: &[u8], patch: &FieldPatch) -> Result<Vec<u8>> {
|
|||||||
|
|
||||||
let rebuilt = rebuild_unityfs(&bundle)?;
|
let rebuilt = rebuild_unityfs(&bundle)?;
|
||||||
let verified = parser.parse_bytes(&rebuilt)?;
|
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
|
let serialized = verified
|
||||||
.serialized_files
|
.serialized_files
|
||||||
.iter()
|
.iter()
|
||||||
@@ -285,7 +316,12 @@ pub fn patch_unityfs_field(data: &[u8], patch: &FieldPatch) -> Result<Vec<u8>> {
|
|||||||
Ok(rebuilt)
|
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() {
|
if bundle.files.len() != bundle.directories.len() {
|
||||||
return Err(AssetBundleError::Parse(format!(
|
return Err(AssetBundleError::Parse(format!(
|
||||||
"UnityFS file/directory count mismatch: files={}, directories={}",
|
"UnityFS file/directory count mismatch: files={}, directories={}",
|
||||||
@@ -293,24 +329,123 @@ fn rebuild_unityfs(bundle: &UnityFsBundle) -> Result<Vec<u8>> {
|
|||||||
bundle.directories.len()
|
bundle.directories.len()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
let block_uncompressed_size = bundle
|
||||||
let mut uncompressed_data = Vec::new();
|
.blocks
|
||||||
let mut directory_offsets = Vec::with_capacity(bundle.files.len());
|
.iter()
|
||||||
for file in &bundle.files {
|
.try_fold(0u64, |total, block| {
|
||||||
let offset = u64::try_from(uncompressed_data.len())
|
total.checked_add(u64::from(block.uncompressed_size))
|
||||||
.map_err(|_| AssetBundleError::Parse("UnityFS data offset overflow".to_string()))?;
|
})
|
||||||
directory_offsets.push(offset);
|
.ok_or_else(|| AssetBundleError::Parse("UnityFS block size overflow".to_string()))?;
|
||||||
uncompressed_data.extend_from_slice(&file.data);
|
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 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 data_size = u32::try_from(uncompressed_data.len()).map_err(|_| {
|
|
||||||
AssetBundleError::Parse("UnityFS rebuilt data exceeds u32 size".to_string())
|
|
||||||
})?;
|
|
||||||
let mut blocks_info_body = Vec::new();
|
let mut blocks_info_body = Vec::new();
|
||||||
push_i32_be(&mut blocks_info_body, 1);
|
push_i32_be(
|
||||||
push_u32_be(&mut blocks_info_body, data_size);
|
&mut blocks_info_body,
|
||||||
push_u32_be(&mut blocks_info_body, data_size);
|
i32::try_from(bundle.blocks.len())
|
||||||
push_u16_be(&mut blocks_info_body, 0);
|
.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(
|
push_i32_be(
|
||||||
&mut blocks_info_body,
|
&mut blocks_info_body,
|
||||||
i32::try_from(bundle.files.len()).map_err(|_| {
|
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);
|
push_c_string(&mut blocks_info_body, &file.path);
|
||||||
}
|
}
|
||||||
let digest = Md5::digest(&blocks_info_body);
|
let digest = Md5::digest(&blocks_info_body);
|
||||||
let mut blocks_info = Vec::with_capacity(16 + blocks_info_body.len());
|
let mut blocks_info_uncompressed = Vec::with_capacity(16 + blocks_info_body.len());
|
||||||
blocks_info.extend_from_slice(&digest);
|
blocks_info_uncompressed.extend_from_slice(&digest);
|
||||||
blocks_info.extend_from_slice(&blocks_info_body);
|
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();
|
let mut output = Vec::new();
|
||||||
push_c_string(&mut output, "UnityFS");
|
push_c_string(&mut output, "UnityFS");
|
||||||
@@ -342,27 +482,395 @@ fn rebuild_unityfs(bundle: &UnityFsBundle) -> Result<Vec<u8>> {
|
|||||||
push_u32_be(
|
push_u32_be(
|
||||||
&mut output,
|
&mut output,
|
||||||
u32::try_from(blocks_info.len()).map_err(|_| {
|
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(
|
push_u32_be(
|
||||||
&mut output,
|
&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())
|
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 {
|
if bundle.header.format_version >= 7 {
|
||||||
align_vec(&mut output, 16);
|
align_vec(&mut output, 16);
|
||||||
}
|
}
|
||||||
output.extend_from_slice(&blocks_info);
|
if block_info_at_end(bundle.header.flags) {
|
||||||
output.extend_from_slice(&uncompressed_data);
|
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);
|
||||||
|
} 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())
|
let total_size = u64::try_from(output.len())
|
||||||
.map_err(|_| AssetBundleError::Parse("UnityFS rebuilt size overflow".to_string()))?;
|
.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());
|
output[total_size_offset..total_size_offset + 8].copy_from_slice(&total_size.to_be_bytes());
|
||||||
Ok(output)
|
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>(
|
fn find_field_value<'a>(
|
||||||
fields: &'a [UnitySerializedField],
|
fields: &'a [UnitySerializedField],
|
||||||
field_path: &str,
|
field_path: &str,
|
||||||
@@ -489,6 +997,99 @@ mod tests {
|
|||||||
data
|
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) {
|
fn push_i16_le(data: &mut Vec<u8>, value: i16) {
|
||||||
data.extend_from_slice(&value.to_le_bytes());
|
data.extend_from_slice(&value.to_le_bytes());
|
||||||
}
|
}
|
||||||
@@ -549,6 +1150,66 @@ mod tests {
|
|||||||
file
|
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> {
|
fn synthetic_serialized_monobehaviour() -> Vec<u8> {
|
||||||
let mut object_data = Vec::new();
|
let mut object_data = Vec::new();
|
||||||
push_u32_le(&mut object_data, 5);
|
push_u32_le(&mut object_data, 5);
|
||||||
@@ -1391,6 +2052,179 @@ mod tests {
|
|||||||
assert_eq!(parsed.unity_version, reparsed.unity_version);
|
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]
|
#[test]
|
||||||
fn patches_text_asset_and_verifies_reparsed_payload() {
|
fn patches_text_asset_and_verifies_reparsed_payload() {
|
||||||
let original_text = "こんにちは".as_bytes();
|
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(
|
fn rewrite_object_payload(
|
||||||
&self,
|
&self,
|
||||||
target_index: usize,
|
target_index: usize,
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ pub struct UnityFsBundle {
|
|||||||
pub uncompressed_data_size: u64,
|
pub uncompressed_data_size: u64,
|
||||||
/// Original bytes retained for future extraction/serialization.
|
/// Original bytes retained for future extraction/serialization.
|
||||||
pub raw_data: Vec<u8>,
|
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.
|
/// Files extracted from the UnityFS uncompressed data region.
|
||||||
pub files: Vec<UnityFsFile>,
|
pub files: Vec<UnityFsFile>,
|
||||||
/// Serialized files parsed from UnityFS directory files.
|
/// 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
|
serde_json.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
|
libc = "0.2"
|
||||||
|
|
||||||
# 文件系统操作
|
# 文件系统操作
|
||||||
tokio = { workspace = true, features = ["fs", "io-util"] }
|
tokio = { workspace = true, features = ["fs", "io-util"] }
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
use crate::error::{CasError, Result};
|
use crate::error::{CasError, Result};
|
||||||
use crate::hash::Hash;
|
use crate::hash::Hash;
|
||||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions, SqliteQueryResult};
|
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteQueryResult};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
/// CAS 对象元数据。
|
/// CAS 对象元数据。
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -41,7 +41,9 @@ impl SqliteRefCounter {
|
|||||||
let options =
|
let options =
|
||||||
SqliteConnectOptions::from_str(&format!("sqlite://{}", path.as_ref().display()))
|
SqliteConnectOptions::from_str(&format!("sqlite://{}", path.as_ref().display()))
|
||||||
.map_err(|error| CasError::Database(error.to_string()))?
|
.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()
|
let pool = SqlitePoolOptions::new()
|
||||||
.max_connections(1)
|
.max_connections(1)
|
||||||
@@ -82,6 +84,22 @@ impl SqliteRefCounter {
|
|||||||
)
|
)
|
||||||
.await?;
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +299,107 @@ impl SqliteRefCounter {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(result.rows_affected() > 0)
|
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -4,7 +4,25 @@ use crate::error::{CasError, Result};
|
|||||||
use crate::hash::{compute_hash, Hash};
|
use crate::hash::{compute_hash, Hash};
|
||||||
use crate::refcount::SqliteRefCounter;
|
use crate::refcount::SqliteRefCounter;
|
||||||
use crate::storage::{FileSystemStorage, Storage, StorageStats};
|
use crate::storage::{FileSystemStorage, Storage, StorageStats};
|
||||||
|
use std::fs::OpenOptions;
|
||||||
use std::path::Path;
|
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。
|
/// 文件系统 CAS repository。
|
||||||
///
|
///
|
||||||
@@ -34,8 +52,16 @@ impl FileSystemCasRepository {
|
|||||||
&self.storage
|
&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> {
|
pub async fn store(&self, data: &[u8]) -> Result<Hash> {
|
||||||
|
let _lock = self.acquire_operation_lock().await?;
|
||||||
let hash = compute_hash(data);
|
let hash = compute_hash(data);
|
||||||
let existed = self.storage.exists(&hash).await?;
|
let existed = self.storage.exists(&hash).await?;
|
||||||
let stored_hash = self.storage.put(data).await?;
|
let stored_hash = self.storage.put(data).await?;
|
||||||
@@ -70,17 +96,20 @@ impl FileSystemCasRepository {
|
|||||||
|
|
||||||
/// 读取对象并验证 Hash。
|
/// 读取对象并验证 Hash。
|
||||||
pub async fn get(&self, hash: &Hash) -> Result<Vec<u8>> {
|
pub async fn get(&self, hash: &Hash) -> Result<Vec<u8>> {
|
||||||
|
let _lock = self.acquire_operation_lock().await?;
|
||||||
let data = self.storage.get(hash).await?;
|
let data = self.storage.get(hash).await?;
|
||||||
Ok(data)
|
Ok(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 检查对象是否存在。
|
/// 检查对象是否存在。
|
||||||
pub async fn exists(&self, hash: &Hash) -> Result<bool> {
|
pub async fn exists(&self, hash: &Hash) -> Result<bool> {
|
||||||
|
let _lock = self.acquire_operation_lock().await?;
|
||||||
self.storage.exists(hash).await
|
self.storage.exists(hash).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 增加引用计数。
|
/// 增加引用计数。
|
||||||
pub async fn add_reference(&self, hash: &Hash) -> Result<u64> {
|
pub async fn add_reference(&self, hash: &Hash) -> Result<u64> {
|
||||||
|
let _lock = self.acquire_operation_lock().await?;
|
||||||
if !self.storage.exists(hash).await? {
|
if !self.storage.exists(hash).await? {
|
||||||
return Err(CasError::ObjectNotFound(hash.to_string()));
|
return Err(CasError::ObjectNotFound(hash.to_string()));
|
||||||
}
|
}
|
||||||
@@ -94,22 +123,26 @@ impl FileSystemCasRepository {
|
|||||||
|
|
||||||
/// 减少引用计数。
|
/// 减少引用计数。
|
||||||
pub async fn remove_reference(&self, hash: &Hash) -> Result<u64> {
|
pub async fn remove_reference(&self, hash: &Hash) -> Result<u64> {
|
||||||
|
let _lock = self.acquire_operation_lock().await?;
|
||||||
self.ref_counter.remove_reference(hash).await
|
self.ref_counter.remove_reference(hash).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取引用计数。
|
/// 获取引用计数。
|
||||||
pub async fn get_reference_count(&self, hash: &Hash) -> Result<u64> {
|
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
|
self.ref_counter.get_reference_count(hash).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 返回当前 GC 候选对象。
|
/// 返回当前 GC 候选对象。
|
||||||
pub async fn gc_candidates(&self) -> Result<Vec<Hash>> {
|
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 的对象。
|
/// 删除引用计数为 0 的对象。
|
||||||
pub async fn gc(&self) -> Result<u64> {
|
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;
|
let mut deleted = 0u64;
|
||||||
|
|
||||||
for hash in candidates {
|
for hash in candidates {
|
||||||
@@ -130,10 +163,52 @@ impl FileSystemCasRepository {
|
|||||||
Ok(deleted)
|
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> {
|
pub async fn stats(&self) -> Result<StorageStats> {
|
||||||
|
let _lock = self.acquire_operation_lock().await?;
|
||||||
self.storage.stats().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)]
|
#[cfg(test)]
|
||||||
@@ -228,6 +303,53 @@ mod tests {
|
|||||||
assert_eq!(repo.get_reference_count(&hash).await.unwrap(), 1);
|
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]
|
#[tokio::test]
|
||||||
async fn corrupted_object_is_detected_through_repository() {
|
async fn corrupted_object_is_detected_through_repository() {
|
||||||
let (_temp_dir, repo) = temp_repo().await;
|
let (_temp_dir, repo) = temp_repo().await;
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ pub mod text;
|
|||||||
|
|
||||||
pub use error::{PatchError, Result};
|
pub use error::{PatchError, Result};
|
||||||
pub use manifest::{
|
pub use manifest::{
|
||||||
PatchIntegrity, PatchKind, PatchManifest, PatchManifestFile, PatchRollback,
|
build_patch_manifest, validate_patch_manifest, verify_patch_file_bytes, PatchIntegrity,
|
||||||
PATCH_MANIFEST_VERSION,
|
PatchKind, PatchManifest, PatchManifestBuildFile, PatchManifestFile, PatchManifestOperation,
|
||||||
|
PatchManifestOperationPayload, PatchManifestProvenance, PatchRollback, PATCH_MANIFEST_VERSION,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Patch 引擎版本号
|
/// Patch 引擎版本号
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
//! Patch manifest, integrity and rollback primitives.
|
//! Patch manifest, integrity and rollback primitives.
|
||||||
|
|
||||||
use crate::PatchError;
|
use crate::{binary::BinaryPatch, json::JsonPatchOperation, text::TextPatch, PatchError};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Component, Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
|
||||||
@@ -52,10 +53,30 @@ pub struct PatchManifestFile {
|
|||||||
pub source_size: u64,
|
pub source_size: u64,
|
||||||
/// Expected target byte length.
|
/// Expected target byte length.
|
||||||
pub target_size: u64,
|
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.
|
/// 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")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum PatchKind {
|
pub enum PatchKind {
|
||||||
/// Deterministic binary hunk patch.
|
/// Deterministic binary hunk patch.
|
||||||
@@ -65,7 +86,185 @@ pub enum PatchKind {
|
|||||||
/// UTF-8 text patch.
|
/// UTF-8 text patch.
|
||||||
Text,
|
Text,
|
||||||
/// UnityFS TextAsset replacement patch.
|
/// UnityFS TextAsset replacement patch.
|
||||||
|
#[serde(rename = "unityfs_text_asset")]
|
||||||
UnityFsTextAsset,
|
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.
|
/// Rollback metadata owned by higher-level publication code.
|
||||||
@@ -94,12 +293,7 @@ pub fn verify_patch_manifest_files(
|
|||||||
target_root: &Path,
|
target_root: &Path,
|
||||||
manifest: &PatchManifest,
|
manifest: &PatchManifest,
|
||||||
) -> crate::Result<PatchIntegrity> {
|
) -> crate::Result<PatchIntegrity> {
|
||||||
if manifest.version != PATCH_MANIFEST_VERSION {
|
validate_patch_manifest(manifest)?;
|
||||||
return Err(PatchError::ApplyFailed(format!(
|
|
||||||
"unsupported patch manifest version {}",
|
|
||||||
manifest.version
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut integrity = PatchIntegrity {
|
let mut integrity = PatchIntegrity {
|
||||||
file_count: 0,
|
file_count: 0,
|
||||||
@@ -119,6 +313,403 @@ pub fn verify_patch_manifest_files(
|
|||||||
Ok(integrity)
|
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.
|
/// Verifies one manifest file entry against source and target bytes.
|
||||||
pub fn verify_patch_file_bytes(
|
pub fn verify_patch_file_bytes(
|
||||||
source: &[u8],
|
source: &[u8],
|
||||||
@@ -151,7 +742,14 @@ pub fn verify_patch_file_bytes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_manifest_path(root: &Path, relative: &Path) -> crate::Result<PathBuf> {
|
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!(
|
return Err(PatchError::ApplyFailed(format!(
|
||||||
"patch manifest path must be relative: {}",
|
"patch manifest path must be relative: {}",
|
||||||
relative.display()
|
relative.display()
|
||||||
@@ -171,6 +769,26 @@ fn resolve_manifest_path(root: &Path, relative: &Path) -> crate::Result<PathBuf>
|
|||||||
Ok(root.join(relative))
|
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>> {
|
fn read_manifest_file(path: &Path, label: &str) -> crate::Result<Vec<u8>> {
|
||||||
fs::read(path).map_err(|error| {
|
fs::read(path).map_err(|error| {
|
||||||
PatchError::ApplyFailed(format!(
|
PatchError::ApplyFailed(format!(
|
||||||
@@ -238,6 +856,269 @@ mod tests {
|
|||||||
assert!(matches!(error, PatchError::ApplyFailed(_)));
|
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 {
|
fn manifest_for(path: &str, source: &[u8], target: &[u8]) -> PatchManifest {
|
||||||
PatchManifest {
|
PatchManifest {
|
||||||
version: PATCH_MANIFEST_VERSION,
|
version: PATCH_MANIFEST_VERSION,
|
||||||
@@ -251,6 +1132,7 @@ mod tests {
|
|||||||
target_blake3: blake3_hex(target),
|
target_blake3: blake3_hex(target),
|
||||||
source_size: source.len() as u64,
|
source_size: source.len() as u64,
|
||||||
target_size: target.len() as u64,
|
target_size: target.len() as u64,
|
||||||
|
operations: Vec::new(),
|
||||||
}],
|
}],
|
||||||
rollback: PatchRollback {
|
rollback: PatchRollback {
|
||||||
previous_current_target: None,
|
previous_current_target: None,
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ DB_MODE=remote
|
|||||||
|
|
||||||
# PostgreSQL 配置
|
# PostgreSQL 配置
|
||||||
# 本地模式:使用 localhost:5432
|
# 本地模式:使用 localhost:5432
|
||||||
# 远程模式:填写远程服务器的公网 IP 和端口
|
# 远程模式:优先使用私网/VPN;SSH tunnel 时填写本地转发地址和端口
|
||||||
DB_HOST=your.remote.server.com # 远程服务器地址(或 localhost 用于本地)
|
DB_HOST=127.0.0.1 # 本地或 SSH tunnel 地址
|
||||||
DB_PORT=5432
|
DB_PORT=15432
|
||||||
DB_USER=bat_user
|
DB_USER=bat_user
|
||||||
DB_PASSWORD=your_secure_password_here
|
DB_PASSWORD=your_secure_password_here
|
||||||
DB_NAME=bluearchive_toolkit
|
DB_NAME=bluearchive_toolkit
|
||||||
@@ -32,9 +32,9 @@ DB_SSL_MODE=prefer
|
|||||||
|
|
||||||
# Redis 配置
|
# Redis 配置
|
||||||
# 本地模式:使用 localhost:6379
|
# 本地模式:使用 localhost:6379
|
||||||
# 远程模式:填写远程服务器的公网 IP 和端口
|
# 远程模式:优先使用私网/VPN;SSH tunnel 时填写本地转发地址和端口
|
||||||
REDIS_HOST=your.remote.server.com # 远程服务器地址(或 localhost 用于本地)
|
REDIS_HOST=127.0.0.1 # 本地或 SSH tunnel 地址
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=16379
|
||||||
REDIS_PASSWORD=your_redis_password_here
|
REDIS_PASSWORD=your_redis_password_here
|
||||||
REDIS_DB=0
|
REDIS_DB=0
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ services:
|
|||||||
POSTGRES_PASSWORD: bat_dev_password
|
POSTGRES_PASSWORD: bat_dev_password
|
||||||
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C"
|
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C"
|
||||||
ports:
|
ports:
|
||||||
- "0.0.0.0:5432:5432"
|
- "127.0.0.1:5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
- ./postgres-init:/docker-entrypoint-initdb.d
|
- ./postgres-init:/docker-entrypoint-initdb.d
|
||||||
@@ -41,7 +41,7 @@ services:
|
|||||||
profiles: ["local-db"] # 只有指定 --profile local-db 才启动
|
profiles: ["local-db"] # 只有指定 --profile local-db 才启动
|
||||||
command: redis-server /usr/local/etc/redis/redis.conf
|
command: redis-server /usr/local/etc/redis/redis.conf
|
||||||
ports:
|
ports:
|
||||||
- "0.0.0.0:6379:6379"
|
- "127.0.0.1:6379:6379"
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
- ./redis.conf:/usr/local/etc/redis/redis.conf
|
- ./redis.conf:/usr/local/etc/redis/redis.conf
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
# 1. 将此文件和相关配置上传到远程服务器
|
# 1. 将此文件和相关配置上传到远程服务器
|
||||||
# 2. 复制 .env.example 为 .env 并配置密码
|
# 2. 复制 .env.example 为 .env 并配置密码
|
||||||
# 3. 运行:docker compose -f docker-compose.remote-db.yml up -d
|
# 3. 运行:docker compose -f docker-compose.remote-db.yml up -d
|
||||||
# 4. 确保防火墙开放 5432 和 6379 端口
|
# 4. 默认仅绑定宿主机回环地址;远程开发使用私网、VPN 或 SSH tunnel
|
||||||
|
|
||||||
version: '3.9'
|
version: '3.9'
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ services:
|
|||||||
POSTGRES_PASSWORD: ${REMOTE_DB_POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: ${REMOTE_DB_POSTGRES_PASSWORD}
|
||||||
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C"
|
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C"
|
||||||
ports:
|
ports:
|
||||||
- "0.0.0.0:5432:5432" # 监听所有网络接口
|
- "127.0.0.1:5432:5432" # 不直接暴露到公网
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
- ./postgres-init:/docker-entrypoint-initdb.d
|
- ./postgres-init:/docker-entrypoint-initdb.d
|
||||||
@@ -44,7 +44,7 @@ services:
|
|||||||
container_name: bat-redis
|
container_name: bat-redis
|
||||||
command: redis-server /usr/local/etc/redis/redis.conf
|
command: redis-server /usr/local/etc/redis/redis.conf
|
||||||
ports:
|
ports:
|
||||||
- "0.0.0.0:6379:6379" # 监听所有网络接口
|
- "127.0.0.1:6379:6379" # 不直接暴露到公网
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
- ./redis-remote.conf:/usr/local/etc/redis/redis.conf
|
- ./redis-remote.conf:/usr/local/etc/redis/redis.conf
|
||||||
|
|||||||
+4
-1
@@ -18,7 +18,10 @@ Go `cmd/bat-api` 是资源 bootstrap、已发布资源分发和鉴权控制服
|
|||||||
- `/v1/bootstrap`、`/v1/launcher/bootstrap`、`/v1/release`、`/v1/resources`
|
- `/v1/bootstrap`、`/v1/launcher/bootstrap`、`/v1/release`、`/v1/resources`
|
||||||
- `/v1/server-info` 和 CDN 形状资源路径
|
- `/v1/server-info` 和 CDN 形状资源路径
|
||||||
- `/api/launcher/game/config` 兼容端点
|
- `/api/launcher/game/config` 兼容端点
|
||||||
- `/admin/` 与白名单 `/admin/control/{action}`
|
- `/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`
|
- `/openapi.yaml`
|
||||||
|
|
||||||
HTTP 路由的 OpenAPI 文本由 `internal/api/openapi.go` 提供,运行中的服务也可
|
HTTP 路由的 OpenAPI 文本由 `internal/api/openapi.go` 提供,运行中的服务也可
|
||||||
|
|||||||
+83
-38
@@ -4,12 +4,14 @@
|
|||||||
|
|
||||||
BlueArchive Toolkit 采用 **Monorepo + 多语言混合** 架构,旨在构建一个可持续维护十年以上的工业级开源项目。
|
BlueArchive Toolkit 采用 **Monorepo + 多语言混合** 架构,旨在构建一个可持续维护十年以上的工业级开源项目。
|
||||||
|
|
||||||
当前文档描述目标架构和已经落地的关键边界。它不是部署手册;当前可部署能力包括 Rust 官方资源同步任务和 Go `cmd/bat-api` 资源 bootstrap/分发服务。完整游戏业务 API、Web、Provider 编排和 SDK 仍未完成,实际实现状态以根目录 `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`;控制面组合与实现位于 `infrastructure/src/bin/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/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/task_registry.rs`:任务注册表、任务持久化、取消和 daemon worker。
|
||||||
- `infrastructure/src/bin/bat/readonly_query.rs`:parse/resource/translation/localized 只读查询及 RPC 选择。
|
- `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/translation_query.rs`:翻译任务与 handoff 查询、worker 状态更新。
|
||||||
@@ -23,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/0002-cas-v1-design-boundary.md`:CAS V1 设计边界。
|
||||||
- `adr/0003-cas-core-interface-and-error-boundary.md`:CAS 核心接口与错误边界冻结。
|
- `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`
|
||||||
|
的资源控制面边界。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -39,16 +43,30 @@ BlueArchive Toolkit 采用 **Monorepo + 多语言混合** 架构,旨在构建
|
|||||||
|
|
||||||
### 2. 语言选型
|
### 2. 语言选型
|
||||||
|
|
||||||
| 模块 | 语言 | 理由 |
|
| 模块 | 语言 | 当前定位 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| CLI、API Server、服务编排 | Go | 并发模型优秀、部署简单、生态成熟 |
|
| 官方资源同步与运维 CLI、同步核心 | Rust | **当前实现**;`bat` 负责生产资源和长期状态 |
|
||||||
| 官方资源同步核心、AssetBundle 解析、Patch 引擎、CAS 引擎 | Rust | 零成本抽象、内存安全、性能和二进制处理更可靠 |
|
| 资源 bootstrap、只读分发和 Rust 管理入口 | Go | **当前实现**;`cmd/bat-api` 通过 `bat.sock` RPC 工作 |
|
||||||
| Web 管理后台 | Vue 3 + TypeScript | 渐进式、类型安全、生态完善 |
|
| AssetBundle 解析、Patch 引擎、CAS 引擎 | Rust | **当前已有基础,复杂覆盖仍按路线图推进** |
|
||||||
|
| 完整 API、服务编排和 Provider | Go | **目标设计,尚未完整实现** |
|
||||||
|
| 完整 Web 协作后台 | Vue 3 + TypeScript | **目标设计**;当前只有内嵌 dashboard MVP |
|
||||||
|
|
||||||
### 3. 数据流设计
|
### 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)
|
Web UI 缓存层 (Redis)
|
||||||
```
|
```
|
||||||
@@ -97,7 +115,7 @@ cas/
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2. 官方资源同步器 (Rust 当前实现,Go 后续编排)
|
### 2. 官方资源同步器 (Rust 当前实现,Go 侧读取)
|
||||||
|
|
||||||
**职责**:从官方日服 HTTP metadata 自动发现资源入口,下载 Windows + Android 官方资源,增量检查,完整性校验,保持本地状态。
|
**职责**:从官方日服 HTTP metadata 自动发现资源入口,下载 Windows + Android 官方资源,增量检查,完整性校验,保持本地状态。
|
||||||
|
|
||||||
@@ -131,20 +149,25 @@ current symlink → official-sync-snapshot.json + official-download-manifest.jso
|
|||||||
- 本地文件损坏时 repair。
|
- 本地文件损坏时 repair。
|
||||||
- 官方 seed `.hash` 强校验;Addressables `catalog_*.hash` 作为变更 marker。
|
- 官方 seed `.hash` 强校验;Addressables `catalog_*.hash` 作为变更 marker。
|
||||||
|
|
||||||
**后续 Go 职责**:
|
**Go 当前职责**:
|
||||||
|
|
||||||
- 提供最小稳定 CLI。
|
- `bat-api` 通过 `bat.sock` RPC 读取 Rust 已发布 release、manifest、snapshot 和状态。
|
||||||
- 默认通过 `bat --json` 进程边界包装 Rust 同步入口,并转发结构化 report。
|
- 提供资源 bootstrap、server-info 改写、只读 CDN path、readiness、OpenAPI 和白名单管理转发;
|
||||||
- `bat-ffi` 仅作为可选无状态 C ABI 兼容层,不承载官方同步 daemon、下载器或 CAS handle。
|
翻译任务与 TM 管理接口只通过 Rust RPC 代理,不在 Go 侧持有状态。
|
||||||
- 编排 API Server、任务队列、Provider 和用户配置。
|
- 不运行另一套同步器,不直接管理官方下载、staging、version-state、CAS 或解析状态。
|
||||||
|
|
||||||
|
完整 API、服务编排、Provider 和用户配置属于目标扩展,不能从本节推断为当前已实现。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3. AssetBundle 解析器 (Rust)
|
### 3. AssetBundle 解析器 (Rust,当前基础与目标扩展)
|
||||||
|
|
||||||
**职责**:解析 Unity AssetBundle,提取资源
|
**职责**:解析 Unity AssetBundle,提取资源
|
||||||
|
|
||||||
**插件化架构**:
|
以下插件注册和动态加载是目标扩展;当前实现以 `crates/bat-assetbundle`、
|
||||||
|
`bat-adapters` 和真实 fixture 覆盖为准。
|
||||||
|
|
||||||
|
**目标插件化架构**:
|
||||||
```rust
|
```rust
|
||||||
pub trait AssetParser {
|
pub trait AssetParser {
|
||||||
fn name(&self) -> &str;
|
fn name(&self) -> &str;
|
||||||
@@ -170,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 审核队列
|
PostgreSQL 审核队列
|
||||||
```
|
```
|
||||||
@@ -188,7 +219,11 @@ type TranslationProvider interface {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**实现**:
|
**当前实现**:
|
||||||
|
- Rust `bat` 的 mock provider worker
|
||||||
|
- Rust `bat` 的 Crowdin provider worker
|
||||||
|
|
||||||
|
**目标 Provider**:
|
||||||
- DeepL Provider
|
- DeepL Provider
|
||||||
- OpenAI Provider
|
- OpenAI Provider
|
||||||
- Anthropic Provider
|
- Anthropic Provider
|
||||||
@@ -196,18 +231,19 @@ type TranslationProvider interface {
|
|||||||
- Azure Translator Provider
|
- Azure Translator Provider
|
||||||
|
|
||||||
**翻译记忆库**:
|
**翻译记忆库**:
|
||||||
- 精确匹配:100% 匹配直接使用
|
- 当前规则:raw source 完全相同、完整 context 完全相同且只有一条 current Trusted 时自动复用。
|
||||||
- 模糊匹配:使用相似度算法(Levenshtein Distance)
|
- 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 类型**:
|
**支持的 Patch 类型**:
|
||||||
1. **Binary Patch**:使用 bsdiff 算法
|
1. **Binary Patch**:确定性 Binary hunk diff/apply(当前实现)
|
||||||
2. **JSON Patch**:RFC 6902 标准
|
2. **JSON Patch**:RFC 6902 标准
|
||||||
3. **Text Patch**:基于 diff 算法
|
3. **Text Patch**:基于 diff 算法
|
||||||
|
|
||||||
@@ -230,7 +266,10 @@ patch/
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 6. API Server (Go)
|
### 6. API Server (Go,目标设计)
|
||||||
|
|
||||||
|
当前可用的 Go HTTP 服务是 `cmd/bat-api` 的资源 bootstrap、只读分发和 Rust 管理
|
||||||
|
入口,不是下列完整游戏业务 API。
|
||||||
|
|
||||||
**框架**:Gin 或 Echo
|
**框架**:Gin 或 Echo
|
||||||
|
|
||||||
@@ -257,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
|
- Vue 3 + Composition API
|
||||||
@@ -270,13 +312,16 @@ HTTP Request → Middleware (Auth, CORS, Logger) → Handler → Service → Rep
|
|||||||
**模块**:
|
**模块**:
|
||||||
- Dashboard(统计概览)
|
- Dashboard(统计概览)
|
||||||
- 翻译审核(Translation Review)
|
- 翻译审核(Translation Review)
|
||||||
- 术语管理(Glossary Manager)
|
- Web 术语管理(Glossary Manager)
|
||||||
- 资源浏览(Asset Browser)
|
- 资源浏览(Asset Browser)
|
||||||
- 用户管理(User Management)
|
- 用户管理(User Management)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 数据库设计
|
## 数据库设计(目标设计)
|
||||||
|
|
||||||
|
当前 Rust 资源链路使用 SQLite 维护本地 CAS、ResourceRepository 和翻译任务状态;
|
||||||
|
PostgreSQL/Redis 业务服务端方案尚未完整落地。
|
||||||
|
|
||||||
### PostgreSQL Schema
|
### PostgreSQL Schema
|
||||||
|
|
||||||
@@ -320,7 +365,11 @@ CREATE TABLE resource_versions (
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 部署架构
|
## 部署架构(目标设计)
|
||||||
|
|
||||||
|
当前可部署形态是 Rust `bat` 官方资源同步任务和同机/共享文件系统的 Go
|
||||||
|
`bat-api` 资源 bootstrap/分发服务。以下多实例 API、PostgreSQL 主从和 Redis
|
||||||
|
集群属于目标部署形态。
|
||||||
|
|
||||||
### 本地开发模式
|
### 本地开发模式
|
||||||
|
|
||||||
@@ -348,7 +397,7 @@ API Server (多实例)
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 安全设计
|
## 安全设计(目标设计)
|
||||||
|
|
||||||
1. **认证**:JWT Token
|
1. **认证**:JWT Token
|
||||||
2. **授权**:RBAC (Role-Based Access Control)
|
2. **授权**:RBAC (Role-Based Access Control)
|
||||||
@@ -359,7 +408,7 @@ API Server (多实例)
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 性能优化
|
## 性能优化(目标设计)
|
||||||
|
|
||||||
1. **缓存策略**:
|
1. **缓存策略**:
|
||||||
- Redis 缓存热点数据
|
- Redis 缓存热点数据
|
||||||
@@ -378,7 +427,7 @@ API Server (多实例)
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 监控与日志
|
## 监控与日志(目标设计)
|
||||||
|
|
||||||
- **日志**:结构化日志(JSON 格式)
|
- **日志**:结构化日志(JSON 格式)
|
||||||
- **指标**:Prometheus + Grafana
|
- **指标**:Prometheus + Grafana
|
||||||
@@ -399,10 +448,6 @@ API Server (多实例)
|
|||||||
更多详细设计文档:
|
更多详细设计文档:
|
||||||
|
|
||||||
- [官方资源后端说明](./official-resource-backend.md)
|
- [官方资源后端说明](./official-resource-backend.md)
|
||||||
|
- [资源 release 布局与分发契约](./resource-release-layout.md)
|
||||||
|
- [AssetBundle 解析与发布路线图](./assetbundle.md)
|
||||||
- [API 设计](../api/README.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 0001: Rust 引擎与 Go 应用层边界
|
||||||
|
|
||||||
**状态**:已接受
|
**状态**:已接受(历史决策;资源同步职责已由 ADR 0004 取代)
|
||||||
**日期**:2026-06-28
|
**日期**:2026-06-28
|
||||||
**关联计划**:`../../../PROJECT_PLAN.md`
|
**关联计划**:`../../../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。项目天然包含二进制解析、文件完整性、网络同步、任务编排、数据库、用户界面等不同类型的问题。
|
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,9 +1,9 @@
|
|||||||
# AssetBundle 与资源解析路线图
|
# AssetBundle 与资源解析路线图
|
||||||
|
|
||||||
- **更新时间**:2026-07-26
|
- **更新时间**:2026-09-12
|
||||||
- **适用范围**:Rust 解析引擎、官方同步后的解析缓存、CAS/ResourceRepository 接入、后续文本提取和 Patch 发布。
|
- **适用范围**: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。
|
- **权威关联**:`PROJECT_PLAN.md` Milestone 3/4/5/8,`docs/reports/CURRENT_GAPS.md` G-005/G-007/G-011/G-011D。
|
||||||
- **维护冻结**:解析扩展遵循 [`docs/reports/PARSER_FREEZE.md`](../reports/PARSER_FREEZE.md),冻结期只接受稳定性、诊断、真实回归和文档一致性修复。
|
- **开发状态**:解析扩展当前按路线图和真实回归继续推进。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -28,11 +28,11 @@
|
|||||||
| 层级 | 输入 | 输出 | 当前状态 |
|
| 层级 | 输入 | 输出 | 当前状态 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 官方 seed manifest | `TableCatalog.bytes`、`BundlePackingInfo.bytes`、`MediaCatalog.bytes` | 完整下载 URL、相对路径、hash 校验边界 | 已用于下载计划,仍需沉淀更多结构化字段 |
|
| 官方 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 当前样本已覆盖,仍需更多真实结构变体 |
|
| 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、边界校验 |
|
| 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 |
|
| 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 提取和字符串提取已落地,真实结构覆盖继续扩大 |
|
| 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 时会在外层重写后重新读取、重解析并校验定位字段/替换值 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -40,23 +40,26 @@
|
|||||||
|
|
||||||
`crates/bat-assetbundle` 已经承担解析核心:
|
`crates/bat-assetbundle` 已经承担解析核心:
|
||||||
|
|
||||||
1. `UnityFsParser` 解析 UnityFS header、block info、directory。
|
1. `UnityFsParser` 解析 UnityFS header、block info、directory,并校验声明总大小与实际文件大小。
|
||||||
2. 支持 LZ4/LZMA block info 和数据 block 解压。
|
2. 支持 LZ4/LZMA block info 和数据 block 解压。
|
||||||
3. 支持 block info at end 和官方样本中出现的 block data alignment。
|
3. 支持 block info at end 和官方样本中出现的 block data alignment。
|
||||||
4. 能从 UnityFS directory 提取文件 bytes。
|
4. 能从 UnityFS directory 提取文件 bytes,并拒绝越界、重复或不安全路径。
|
||||||
5. `serialized` 模块能读取 Unity serialized file header、type table、TypeTree node 元数据、object table。
|
5. 对 block/directory 计数先按剩余字节做有界检查,避免损坏输入触发超大内存分配。
|
||||||
6. 能提取 TextAsset 的 name 和原始 bytes。
|
6. `serialized` 模块能读取 Unity serialized file header、type table、TypeTree node 元数据、object table。
|
||||||
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 诊断。
|
7. 能提取 TextAsset 的 name 和原始 bytes。
|
||||||
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。
|
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. `ResourceImportService` 能把 AssetBundle 摘要、TextAsset/Table/Media 分类和 TextUnit 摘要写入导入报告。
|
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. 官方同步后 `OfficialParseCacheService` 能从 `official-download-manifest.json` 遍历所有资源,解析直接 bundle 和 zip 内条目,非候选资源记录为 unsupported,并缓存 TextUnit 数量/格式/诊断摘要,同时写出 `official-textunit-index.json` 供 `parse.text_units` / `parse.errors` 查询。
|
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 字段结构语义和版本差异冻结后再推进。
|
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 结构变体仍需真实样本驱动补齐。
|
2. Addressables 当前目标 JSON/compact 字段链已补齐;未识别的独立二进制格式仍返回明确错误,不静默降级。
|
||||||
3. 官方 release 已可配置导入 CAS + ResourceRepository,并可通过 `resource.index` 查询现有资源索引;Resource metadata 已记录 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 摘要。
|
3. 官方 release 已可配置导入 CAS + ResourceRepository,并可通过 `resource.index` 查询现有资源索引;Resource metadata 已记录 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 摘要。
|
||||||
4. 不能完成复杂对象字段重打包,也不能从真实 Crowdin 结果自动生成完整汉化文件集合。
|
4. 尚未覆盖所有真实 Unity 版本、未知字段语义和任意复杂 AssetBundle 结构,也不能从真实 Crowdin 结果自动生成完整汉化文件集合;当前仅对已有真实/合成回归覆盖的结构宣称支持。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -98,6 +101,11 @@
|
|||||||
4. 将 Windows/Android catalog 样本拆成可复现 fixture,不把大文件纳入 Git。
|
4. 将 Windows/Android catalog 样本拆成可复现 fixture,不把大文件纳入 Git。
|
||||||
5. 对未知结构返回明确错误或保真 raw metadata,不静默丢字段。
|
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 样本集合解析通过。
|
1. 当前目标版本 Windows/Android catalog 样本集合解析通过。
|
||||||
@@ -148,7 +156,7 @@
|
|||||||
|
|
||||||
1. 官方同步完成后可配置触发导入 CAS + ResourceRepository(已具备 `--import-repository` / `BAT_IMPORT_REPOSITORY=1`)。
|
1. 官方同步完成后可配置触发导入 CAS + ResourceRepository(已具备 `--import-repository` / `BAT_IMPORT_REPOSITORY=1`)。
|
||||||
2. ResourceRepository 已保存官方 manifest 资源的类型、路径、hash、size 和 metadata;metadata 包含 release、平台、bundle path、parse status、TextAsset 名称、TextUnit 数量/格式。
|
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` 兼容迁移。
|
4. schema 迁移可重复执行;当前 SQLite 已有 `crc` 和 `metadata_json` 兼容迁移。
|
||||||
|
|
||||||
验收:
|
验收:
|
||||||
@@ -157,23 +165,23 @@
|
|||||||
2. 解析缓存、资源变更集和 repository 数据能从同一 manifest fingerprint 追溯。
|
2. 解析缓存、资源变更集和 repository 数据能从同一 manifest fingerprint 追溯。
|
||||||
3. CAS 对象跨版本复用,不重复存储相同文件。
|
3. CAS 对象跨版本复用,不重复存储相同文件。
|
||||||
|
|
||||||
### P5:Patch 发布前置解析
|
### P5:Patch 发布
|
||||||
|
|
||||||
目标:让解析结果成为可生成汉化 patch 的输入。
|
目标:让解析结果成为可生成、校验和回滚汉化 patch 的输入。
|
||||||
|
|
||||||
交付:
|
交付:
|
||||||
|
|
||||||
1. 已定义 `localized-patch-manifest.json`:目标官方版本、localized release、输出文件、hash、size、byte delta、TextAsset 操作和回滚信息。
|
1. 已定义并接入 generic `PatchManifest`:目标官方版本、localized release、输出文件、hash、size、按序 operation、算法载荷、UnityFS 定位、TextUnit/provider/review trace 和回滚信息。
|
||||||
2. 已支持 UnityFS TextAsset raw bytes 替换的最小 patch 路径。
|
2. 已支持 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 的 localized patch 操作;普通 ZIP 条目在 `archive_entry` 可验证、内层可重解析时会解包、重建并重写外层 ZIP,路径穿越、symlink、混合直接/ZIP patch 和无效内层 bundle 明确失败。
|
||||||
3. MonoBehaviour/ScriptableObject 字段替换必须依赖 P2 字段级解析结果。
|
3. MonoBehaviour/ScriptableObject 字段替换必须依赖 P2 字段级解析结果;generic manifest 不把 UnityFS 定位信息扁平化。
|
||||||
4. Patch 产物写入配置化汉化发布根下的 `.staging/<id>`,校验通过后发布到 `versions/<id>` 并切换 `current`。
|
4. Patch 产物写入配置化汉化发布根下的 `.staging/<id>`,校验通过后发布到 `versions/<id>` 并切换 `current`;rollback 按 manifest 恢复上一 release。
|
||||||
5. 成功后发布状态从 `not_localized` 切到 `localized`;`localized.status` 要求 state、current symlink 和 patch 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`。
|
1. Patch 失败不影响 `bat-resources/current`。
|
||||||
2. 汉化 release 保留官方相对目录结构。
|
2. 汉化 release 保留官方相对目录结构。
|
||||||
3. `localized` 状态能证明原版和汉化两套资源都已发布,且 patch manifest 可验证。
|
3. `localized` 状态能证明原版和汉化两套资源都已发布,且 generic/localized patch manifest 可验证。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -196,12 +204,13 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. 近期关闭路径
|
## 7. 后续推进路径
|
||||||
|
|
||||||
优先顺序:
|
优先顺序:
|
||||||
|
|
||||||
1. 完成 Addressables Windows/Android 当前版本 catalog 样本集合,关闭 G-007 当前阶段。
|
1. 继续补充 Addressables Windows/Android 真实 catalog 样本和独立二进制格式诊断。
|
||||||
2. 完成 TypeTree 字段 reader 和 MonoBehaviour/ScriptableObject 遍历,推进 G-005。
|
2. 继续补充 TypeTree 字段 reader、MonoBehaviour/ScriptableObject 遍历和真实版本差异。
|
||||||
3. 将 `crowdin-textunit-queue.json` 接入真实 Crowdin worker、翻译记忆和 Patch 构建。
|
3. 基于 `translation.worker.run` 继续扩展 TM/Glossary provenance 和真实资源发布样本。
|
||||||
4. 将翻译任务状态接入 CAS/ResourceRepository 查询面,推进 G-011。
|
4. 扩展翻译任务结果在 CAS/ResourceRepository 查询面的索引。
|
||||||
5. 在通用 Binary/JSON/Text Patch 基础上继续扩展复杂 AssetBundle 重打包和发布流程统一,保留当前 UnityFS TextAsset patch 发布前置链路。
|
5. 在 generic Binary/JSON/Text Patch 基础上继续扩展复杂 AssetBundle 重打包,但只在
|
||||||
|
新结构有真实 fixture 和完整重建验证时接入;不扩大当前 UnityFS V1 的宣称范围。
|
||||||
|
|||||||
@@ -162,7 +162,7 @@
|
|||||||
### 3.5 导入到 CAS 和资源仓储
|
### 3.5 导入到 CAS 和资源仓储
|
||||||
|
|
||||||
官方同步下载、校验并发布 release 后,可以通过 `--import-repository` 或
|
官方同步下载、校验并发布 release 后,可以通过 `--import-repository` 或
|
||||||
`.env` 中 `BAT_IMPORT_REPOSITORY=1` 自动触发 CAS + `ResourceRepository`
|
`config.toml` / 环境变量 `BAT_IMPORT_REPOSITORY=1` 自动触发 CAS + `ResourceRepository`
|
||||||
导入:
|
导入:
|
||||||
|
|
||||||
1. 读取已发布 release 下的 `official-download-manifest.json`。
|
1. 读取已发布 release 下的 `official-download-manifest.json`。
|
||||||
@@ -179,10 +179,15 @@
|
|||||||
`resource.index` RPC / CLI 只读查询现有 SQLite 索引;索引不存在时返回
|
`resource.index` RPC / CLI 只读查询现有 SQLite 索引;索引不存在时返回
|
||||||
`available=false`,不会因为查询创建空库。发布后的 TextUnit 队列还会在当前
|
`available=false`,不会因为查询创建空库。发布后的 TextUnit 队列还会在当前
|
||||||
release 根目录写入 `translation-tasks.sqlite`,由版本化 `schema_migrations`
|
release 根目录写入 `translation-tasks.sqlite`,由版本化 `schema_migrations`
|
||||||
管理 queued/running/failed/completed/skipped 和 failure reason。`translation.tasks`
|
管理 V2 queued/running/failed/completed/skipped、provider run、lease、失败分类、
|
||||||
优先查询这份状态库,`translation.task.update` 供 provider worker 回写状态;
|
重试计划和 TextUnit 级译文结果。跨 release 的 Translation Memory persistence schema V2
|
||||||
没有状态库的旧 release 才回退到 immutable JSON 队列。G-011 剩余工作是 CAS
|
独立存储在 `<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 发布所需资源视图。
|
||||||
|
|
||||||
对应实现主要在:
|
对应实现主要在:
|
||||||
|
|
||||||
@@ -216,7 +221,9 @@ release 根目录写入 `translation-tasks.sqlite`,由版本化 `schema_migrat
|
|||||||
|
|
||||||
集成边界:
|
集成边界:
|
||||||
|
|
||||||
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`,不直接接管下载器内部状态。
|
2. systemd、容器或上层 Go 进程只负责守护 `bat --watch` / `bat --daemon`,不直接接管下载器内部状态。
|
||||||
3. `bat-ffi` 只允许作为可选无状态 C ABI 兼容层,用于 Manifest inspect 和 sync plan 这类一次性 JSON helper;它不是官方同步 daemon、下载器、资源锁、CAS handle 或主控制面的承载位置。
|
3. `bat-ffi` 只允许作为可选无状态 C ABI 兼容层,用于 Manifest inspect 和 sync plan 这类一次性 JSON helper;它不是官方同步 daemon、下载器、资源锁、CAS handle 或主控制面的承载位置。
|
||||||
|
|
||||||
@@ -231,13 +238,13 @@ release 根目录写入 `translation-tasks.sqlite`,由版本化 `schema_migrat
|
|||||||
7. 如果远端 snapshot 未变化但输出目录没有任何当前 plan 的本地资源,仍按首次运行处理并执行全量拉取。
|
7. 如果远端 snapshot 未变化但输出目录没有任何当前 plan 的本地资源,仍按首次运行处理并执行全量拉取。
|
||||||
8. 远端无变化且本地已有资源时执行 download manifest audit,检查路径、size、BLAKE3 和 ZIP 结构。
|
8. 远端无变化且本地已有资源时执行 download manifest audit,检查路径、size、BLAKE3 和 ZIP 结构。
|
||||||
9. 远端变化、本地 audit 发现 repair_needed,首次空目录运行,或缺少 `current` 原子发布指针时,进入下载/发布流程。
|
9. 远端变化、本地 audit 发现 repair_needed,首次空目录运行,或缺少 `current` 原子发布指针时,进入下载/发布流程。
|
||||||
10. 下载先写入 `<output>/.staging/<id>`;若已有 active release,会先 seed staging 以复用已验证文件;若 version-state 中存在同一版本的失败 staging,则优先复用该 staging 并跳过 active seed,避免旧 active 覆盖已下载的新文件。
|
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` 校验完成后写入新的 snapshot,并在 staging 中写入 `official-launcher-bootstrap.json`(若本轮启用 `--auto-discover`)。
|
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 目录。
|
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 请求。
|
13. 发布完成后先对比上一完整 release 和当前 release 的 `official-download-manifest.json`,写出 `official-resource-changes.json` 和 `crowdin-translation-handoff.json`。同一 destination 只有 size 或 BLAKE3 变化才算 modified;新增+变更资源进入解析/翻译 handoff,删除资源只进入差异记录。当前只预留 Crowdin 本地 handoff,不发外部 API 请求。
|
||||||
14. 随后刷新 active release 下的 `official-parse-cache.json` 和 `official-textunit-index.json`,并从 Added/Modified 资源、parse cache 与 TextUnit 明细索引派生 `official-textunit-tasks.json`、`crowdin-textunit-queue.json` 和版本化的 `translation-tasks.sqlite`;up-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析,重新同步队列时保留已有 worker 状态。
|
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` 查询。
|
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`,表示原版资源已发布、汉化资源未发布;UnityFS TextAsset patch 发布成功并通过 `localized-patch-manifest.json`、current symlink 和 release ID 校验后,`localized.status` 才返回 `localized`,表示原版和汉化两套资源都已发布。
|
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 证据,供后续排障和自研客户端开发使用。
|
维护期特殊分支:如果官方 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 证据,供后续排障和自研客户端开发使用。
|
||||||
|
|
||||||
@@ -248,6 +255,7 @@ release 根目录写入 `translation-tasks.sqlite`,由版本化 `schema_migrat
|
|||||||
- `infrastructure/src/official_update.rs`
|
- `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/app.rs`(控制面组合)
|
||||||
|
- `infrastructure/src/bin/bat/report_output.rs`、`terminal_output.rs`(前台报告和终端输出)
|
||||||
- `infrastructure/src/bin/bat/task_registry.rs`(任务注册表、持久化和 worker)
|
- `infrastructure/src/bin/bat/task_registry.rs`(任务注册表、持久化和 worker)
|
||||||
- `infrastructure/src/bin/bat/readonly_query.rs`、`translation_query.rs`(只读查询)
|
- `infrastructure/src/bin/bat/readonly_query.rs`、`translation_query.rs`(只读查询)
|
||||||
- `infrastructure/src/bin/bat/patch_commands.rs`(patch 命令)
|
- `infrastructure/src/bin/bat/patch_commands.rs`(patch 命令)
|
||||||
@@ -286,7 +294,7 @@ Linux 生产路径:
|
|||||||
- `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 状态和调度状态
|
- `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
|
- curl 失败分类和重试策略已覆盖 404 不重试、5xx 重试耗尽后 quarantine、launcher primary CDN 失败后切换 official backup CDN
|
||||||
- `official-version-state.json` 已覆盖当前完成版本、正在拉取版本、上一个可用版本和失败版本;同一 app version、bundle version 和 Addressables root 的失败只保留最新一条,重新拉取或成功发布后清理同版本失败记录,同版本失败 staging 会在路径安全且未发布时复用,`bat status` 会暴露版本状态摘要和最近历史失败原因
|
- `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 预留稳定输入
|
- 官方 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
|
- 离线回归样本已覆盖当前 catalog、上一个版本 catalog、catalog 结构变化、403、404 和 seed hash mismatch
|
||||||
- `OfficialUpdateService` 能读写 `official-bootstrap-cache.json`,并支持默认开启的 `audit_local` / `repair` CLI 行为
|
- `OfficialUpdateService` 能读写 `official-bootstrap-cache.json`,并支持默认开启的 `audit_local` / `repair` CLI 行为
|
||||||
@@ -337,9 +345,11 @@ JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边
|
|||||||
- 方法命名空间与实现状态、请求/响应示例见
|
- 方法命名空间与实现状态、请求/响应示例见
|
||||||
`docs/reference/rpc-backend-api.md`:`daemon.status/logs/stop/restart/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`、
|
`resource.state/sync/verify/repair/manifest/list/index`、`parse.status/text_units/errors`、
|
||||||
`translation.tasks/handoff/task.update`、`localized.status`、`catalog.*` 与
|
`translation.tasks/handoff/task.update/proofread`、`localized.status`、
|
||||||
`task.status/list/cancel/logs` 已实现;文件级 `patch.apply` / `unityfs.patch_*`
|
`release.status/list/distribution/cleanup`、`catalog.*` 与 `task.status/list/cancel/logs`
|
||||||
已实现,发布级 patch 与复杂 UnityFS 语义编辑待引擎;
|
已实现;文件级 `patch.apply` / `unityfs.patch_*`
|
||||||
|
和受支持 localized publish/rollback 已实现,`archive_entry` 可验证时会重写
|
||||||
|
外层 ZIP;通用发布级 patch 与复杂 UnityFS 语义编辑仍待后续;
|
||||||
`task.create` 按设计暂不开放通用任务入口;
|
`task.create` 按设计暂不开放通用任务入口;
|
||||||
`daemon.restart` 通过 Rust lifecycle controller 复用 CLI restart 路径;
|
`daemon.restart` 通过 Rust lifecycle controller 复用 CLI restart 路径;
|
||||||
`daemon.clean-stable` 仍由 CLI 侧按进程生命周期显式执行。
|
`daemon.clean-stable` 仍由 CLI 侧按进程生命周期显式执行。
|
||||||
@@ -348,11 +358,11 @@ JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边
|
|||||||
|
|
||||||
- Rust `bat` / daemon 是资源生产者和状态拥有者;Go `bat-api` 是资源读侧、
|
- Rust `bat` / daemon 是资源生产者和状态拥有者;Go `bat-api` 是资源读侧、
|
||||||
bootstrap 和 HTTP 分发入口。二者之间的稳定边界是 `bat.sock` RPC 和
|
bootstrap 和 HTTP 分发入口。二者之间的稳定边界是 `bat.sock` RPC 和
|
||||||
`resource_root` 中已发布的只读文件。
|
Rust 选择后返回的 `resource_root` 中已发布的只读文件。
|
||||||
- Go 层负责:资源 bootstrap、资源内容分发(`cmd/bat-api`)、HTTP API 进程配置、
|
- Go 层负责:资源 bootstrap、资源内容分发(`cmd/bat-api`)、HTTP API 进程配置、
|
||||||
以及通过 `internal/backendrpc` 作为 RPC client 调用本机 daemon(连接
|
以及通过 `internal/backendrpc` 作为 RPC client 调用本机 daemon(连接
|
||||||
`bat.sock`,每行一个 JSON-RPC 请求/响应)。`cmd/bat` 仍是试验骨架,不是产品级用户 CLI。
|
`bat.sock`,每行一个 JSON-RPC 请求/响应)。`cmd/bat` 仍是试验骨架,不是产品级用户 CLI。
|
||||||
- **`bat-api`(资源分发,issue #19)**:
|
- **`bat-api`(资源分发)**:
|
||||||
- 提供 `/v1/bootstrap`,把 `bat` 的 RPC 健康、release 摘要、server-info URL、
|
- 提供 `/v1/bootstrap`,把 `bat` 的 RPC 健康、release 摘要、server-info URL、
|
||||||
client-patch base 和改写后的 Addressables root 组织成启动前资源发现响应。
|
client-patch base 和改写后的 Addressables root 组织成启动前资源发现响应。
|
||||||
- 提供 `/healthz` 作为 liveness + 最近一次 RPC refresh 诊断,提供 `/readyz`
|
- 提供 `/healthz` 作为 liveness + 最近一次 RPC refresh 诊断,提供 `/readyz`
|
||||||
@@ -360,8 +370,16 @@ JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边
|
|||||||
- 只读提供 Rust `bat` 已发布 release 中的资源字节(官方 CDN host/path 形态)。
|
- 只读提供 Rust `bat` 已发布 release 中的资源字节(官方 CDN host/path 形态)。
|
||||||
- CDN path 支持 `GET` / `HEAD` / Range / 条件请求;ETag 优先使用 download
|
- CDN path 支持 `GET` / `HEAD` / Range / 条件请求;ETag 优先使用 download
|
||||||
manifest 中的 BLAKE3,响应包含 Last-Modified、Accept-Ranges 和长期缓存头。
|
manifest 中的 BLAKE3,响应包含 Last-Modified、Accept-Ranges 和长期缓存头。
|
||||||
- 版本/清单发现优先走 RPC:先 `daemon.status`,再 `daemon.doctor`,再
|
- 版本/清单发现优先走 RPC:先 `daemon.status`,再 `daemon.doctor`,再读取
|
||||||
`catalog.status` / `resource.manifest`(可用 `--socket` 指定 socket 文件)。
|
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
|
- 支持 `.env` / 环境变量配置监听端口、public base URL、RPC socket 和 RPC
|
||||||
刷新周期,并预留 database/redis 键供后续 API 持久化;**不**负责资源自动拉取。
|
刷新周期,并预留 database/redis 键供后续 API 持久化;**不**负责资源自动拉取。
|
||||||
- 可选改写 server-info 中的 `AddressablesCatalogUrlRoot` 指向自身;不伪装
|
- 可选改写 server-info 中的 `AddressablesCatalogUrlRoot` 指向自身;不伪装
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 官方资源 Release 布局与资源侧契约
|
# 官方资源 Release 布局与资源侧契约
|
||||||
|
|
||||||
- **更新时间**:2026-07-27
|
- **更新时间**:2026-09-12
|
||||||
- **用途**:冻结日服官方资源在本地发布根上的布局、URL 映射、seed 规则、`bat`/`bat-api` 关系,以及 `bat-api` 分发 path 的 1:1 对应关系。
|
- **用途**:冻结日服官方资源在本地发布根上的布局、URL 映射、seed 规则、`bat`/`bat-api` 关系,以及 `bat-api` 分发 path 的 1:1 对应关系。
|
||||||
- **范围**:资源发现 / 清单 / 落盘 / 只读分发(**不是**完整游戏业务 API)。
|
- **范围**:资源发现 / 清单 / 落盘 / 只读分发(**不是**完整游戏业务 API)。
|
||||||
- **权威代码**:
|
- **权威代码**:
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
- inventory 抽取:`adapters/src/official/inventory.rs`
|
- inventory 抽取:`adapters/src/official/inventory.rs`
|
||||||
- 落盘与 manifest:`infrastructure/src/official_download.rs`(`destination_for_url`)
|
- 落盘与 manifest:`infrastructure/src/official_download.rs`(`destination_for_url`)
|
||||||
- 发布布局:`infrastructure/src/official_update.rs`
|
- 发布布局:`infrastructure/src/official_update.rs`
|
||||||
|
- 双 release 视图、分发选择与清理:`infrastructure/src/release_ops.rs`
|
||||||
- 分发:`cmd/bat-api` + `internal/api`(见 `docs/reports/GO_STATUS.md`)
|
- 分发:`cmd/bat-api` + `internal/api`(见 `docs/reports/GO_STATUS.md`)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -17,7 +18,7 @@
|
|||||||
| 角色 | 组件 | 职责 |
|
| 角色 | 组件 | 职责 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 同步 / 运维(近乎全自动) | Rust `bat` | auto-discover、拉取、校验、发布、watch/daemon、RPC 后端 |
|
| 同步 / 运维(近乎全自动) | 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` 重名 |
|
| 试验 CLI | Go `cmd/bat` → `bin/bat-go` | 非产品;禁止与 Rust `bat` 重名 |
|
||||||
|
|
||||||
**禁止**:把已安装客户端目录或 `/home/wanye/D/BlueArchive` 当作生产输入;真实全量样本优先服务器 release 或 `/tmp` 隔离目录。
|
**禁止**:把已安装客户端目录或 `/home/wanye/D/BlueArchive` 当作生产输入;真实全量样本优先服务器 release 或 `/tmp` 隔离目录。
|
||||||
@@ -31,12 +32,14 @@
|
|||||||
current -> versions/<id> # 原子 symlink,生产读侧
|
current -> versions/<id> # 原子 symlink,生产读侧
|
||||||
versions/<id>/ # 已发布 versioned release(= resource_root)
|
versions/<id>/ # 已发布 versioned release(= resource_root)
|
||||||
official-download-manifest.json
|
official-download-manifest.json
|
||||||
|
official-distribution-publication.json # 独立发布事实:release、mapping、manifest identity、entry count
|
||||||
official-parse-cache.json # 校验后派生解析缓存,不是汉化产物
|
official-parse-cache.json # 校验后派生解析缓存,不是汉化产物
|
||||||
official-textunit-index.json # TextUnit 明细与解析错误索引,不是汉化产物
|
official-textunit-index.json # TextUnit 明细与解析错误索引,不是汉化产物
|
||||||
official-textunit-tasks.json # 翻译任务候选派生队列,不发 Crowdin 网络请求
|
official-textunit-tasks.json # 翻译任务候选派生队列,不发 Crowdin 网络请求
|
||||||
crowdin-textunit-queue.json # Crowdin worker 离线输入队列
|
crowdin-textunit-queue.json # Crowdin worker 离线输入队列
|
||||||
official-sync-snapshot.json # 常在 active root / current 下
|
official-sync-snapshot.json # 常在 active root / current 下
|
||||||
official-launcher-bootstrap.json # 官方 launcher 引导链版本化产物
|
official-launcher-bootstrap.json # 官方 launcher 引导链版本化产物
|
||||||
|
official-cas-reuse-references.json # 当前 release 获取的 CAS 引用和 ownership_id
|
||||||
prod-clientpatch.bluearchiveyostar.com/
|
prod-clientpatch.bluearchiveyostar.com/
|
||||||
<root_token>/
|
<root_token>/
|
||||||
TableBundles/
|
TableBundles/
|
||||||
@@ -74,7 +77,12 @@
|
|||||||
<localized-output>/ # 汉化产物发布根(--localized-output / BAT_LOCALIZED_OUTPUT)
|
<localized-output>/ # 汉化产物发布根(--localized-output / BAT_LOCALIZED_OUTPUT)
|
||||||
current -> versions/<id> # 已汉化后才切换;未汉化状态不发布
|
current -> versions/<id> # 已汉化后才切换;未汉化状态不发布
|
||||||
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`:官方原版资源已经完成下载、校验和发布,汉化资源尚未发布;这是官方同步完成后的默认状态。
|
- `not_localized`:官方原版资源已经完成下载、校验和发布,汉化资源尚未发布;这是官方同步完成后的默认状态。
|
||||||
- `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>` |
|
| 下载写入 | `<output>/.staging/<id>` |
|
||||||
| 发布完成 | rename 到 `versions/<id>`,再切换 `current` |
|
| 发布完成 | 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>/...
|
≡ 磁盘 <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 资源引导兼容
|
### 3.3 launcher 资源引导兼容
|
||||||
|
|
||||||
@@ -183,8 +256,28 @@ GET {public-base-url}/prod-clientpatch.bluearchiveyostar.com/<root_token>/...
|
|||||||
| `bytes` | 文件大小 |
|
| `bytes` | 文件大小 |
|
||||||
| `blake3` | 本地 BLAKE3 hex |
|
| `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。
|
**权威清单**:拉取闭环写入的 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)
|
## 5. 发现与 seed URL 规则(L2)
|
||||||
@@ -297,9 +390,13 @@ Addressables 改写后客户端拼接:
|
|||||||
|
|
||||||
1. `daemon.status`
|
1. `daemon.status`
|
||||||
2. `daemon.doctor`
|
2. `daemon.doctor`
|
||||||
3. `catalog.status`(`version.resource_root`、`addressables_root`、app/bundle)
|
3. `release.attestation`,消费 Rust 当前 official 的 `ready`、release/publication/
|
||||||
4. `resource.manifest` 分页(url / destination / bytes / blake3)
|
manifest identity、verification generation、freshness 和 integrity 事实
|
||||||
5. 在 `resource_root` 上 Lstat 校验 Present / size
|
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` 作为常规路径。
|
**不读** `bat-status.json` / `bat-tasks.json` 作为常规路径。
|
||||||
|
|
||||||
@@ -307,18 +404,18 @@ Addressables 改写后客户端拼接:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. issue #2 / #3 样本索引(R4/R5 预置)
|
## 9. 真实资源样本索引
|
||||||
|
|
||||||
在服务器 release 上优先采集到 `/tmp` 隔离目录(**不入库大文件**):
|
在服务器 release 上优先采集到 `/tmp` 隔离目录(**不入库大文件**):
|
||||||
|
|
||||||
| 用途 | 建议路径模式 |
|
| 用途 | 建议路径模式 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Addressables(#2) | `{PatchDir}/catalog_*.zip` 解压后的 JSON/bin + 旁路 `.hash` |
|
| Addressables | `{PatchDir}/catalog_*.zip` 解压后的 JSON/bin + 旁路 `.hash` |
|
||||||
| UnityFS(#3) | `FullPatch_*.zip` 内抽样 `.bundle`,或已解包 bundle |
|
| UnityFS | `FullPatch_*.zip` 内抽样 `.bundle`,或已解包 bundle |
|
||||||
| seed 加固(R2) | 各平台 `TableCatalog` / `BundlePackingInfo` / `MediaCatalog` 的 `.bytes`+`.hash` |
|
| seed 加固(R2) | 各平台 `TableCatalog` / `BundlePackingInfo` / `MediaCatalog` 的 `.bytes`+`.hash` |
|
||||||
|
|
||||||
字段目标(#2,已有 `m_Crc` 部分):继续扩大 hash/size/CRC/依赖等可校验字段覆盖。
|
字段目标(已有 `m_Crc` 部分):继续扩大 hash/size/CRC/依赖等可校验字段覆盖。
|
||||||
结构目标(#3):header / block / directory / metadata / object table 引擎级解析。
|
结构目标:header / block / directory / metadata / object table 引擎级解析。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -348,7 +445,7 @@ bat.sock 或 state-dir: ...
|
|||||||
- `docs/architecture/official-resource-backend.md` — 拉取后端总览
|
- `docs/architecture/official-resource-backend.md` — 拉取后端总览
|
||||||
- `docs/reference/rpc-backend-api.md` — RPC 契约
|
- `docs/reference/rpc-backend-api.md` — RPC 契约
|
||||||
- `docs/guides/official-resource-test-pull.md` — 用户向运行说明
|
- `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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+27
-28
@@ -1,6 +1,6 @@
|
|||||||
# 稳定工程基线指南
|
# 稳定工程基线指南
|
||||||
|
|
||||||
- **更新时间**:2026-08-03
|
- **更新时间**:2026-09-04
|
||||||
- **目标**:让工作区处于可继续开发核心功能的可信状态。
|
- **目标**:让工作区处于可继续开发核心功能的可信状态。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -23,42 +23,41 @@
|
|||||||
|
|
||||||
## 2. 当前验证命令
|
## 2. 当前验证命令
|
||||||
|
|
||||||
必须通过:
|
提交前的只读统一门禁必须通过:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make test
|
make ci-check
|
||||||
make check
|
|
||||||
make lint
|
|
||||||
```
|
```
|
||||||
|
|
||||||
等价底层命令:
|
该命令等价覆盖:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo test --workspace
|
cargo fmt --all -- --check
|
||||||
cargo check --workspace
|
cargo check --workspace --locked
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||||
go test ./...
|
cargo test --workspace --locked
|
||||||
go vet ./...
|
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 产品入口已完成。
|
1. 默认 Go 测试只覆盖正式 `bat-api` 依赖的纯 Go 包:`internal/api` 和 `internal/backendrpc`;`make test-go-ffi` / `make test-go-all` 才会包含 FFI 和试验 CLI。
|
||||||
2. 后续新增 Go 产品 package,必须让 `go test ./...` 和 `go vet ./...` 纳入硬性验证。
|
2. `golangci-lint 2.12.2` 是 required gate;版本由 `scripts/ci-versions.sh` 固定,工具缺失或版本不匹配直接失败。
|
||||||
3. 当前 `golangci-lint` 可选;当 Go 代码进入主要开发阶段后,应纳入本地门禁。
|
3. `make format` / `make fmt` 会修改工作树;`make ci-check`、`make check`、`make test` 和 `make lint` 不应格式化源码。
|
||||||
4. 官方同步相关修改必须额外运行 `cargo test -p bat-infrastructure --bin bat -- --nocapture`。
|
4. 官方同步相关修改必须额外运行 `cargo test -p bat-infrastructure --bin bat -- --nocapture`。
|
||||||
|
|
||||||
|
如果构建环境的默认 Go cache 不可写,可将 `GOCACHE` 指向工作区外的临时目录,例如
|
||||||
|
`GOCACHE=/tmp/bat-go-cache`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Git 基线
|
## 3. Git 基线
|
||||||
|
|
||||||
当前工作区原 `.git/` 是空目录,无法恢复原历史。本基线采用新初始化仓库,并以首次提交作为后续开发起点。
|
当前工作区以现有 Git 分支和提交为基线;原项目历史未恢复。提交前应确认工作区
|
||||||
|
只包含本次有意修改,并核对文档、源码和测试状态。
|
||||||
首次提交信息:
|
|
||||||
|
|
||||||
```text
|
|
||||||
chore: establish development baseline
|
|
||||||
```
|
|
||||||
|
|
||||||
提交前检查:
|
提交前检查:
|
||||||
|
|
||||||
@@ -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 不再作为产品目标。
|
1. 继续 AssetBundle 复杂对象解析、真实 fixture 和发布级重打包。
|
||||||
2. 按 `docs/guides/official-full-pull-smoke.md` 在隔离目录执行真实官方网络全量下载 smoke,并保留运行报告。
|
2. 基于 `translation.worker.run` 继续扩展 TM/Glossary,并补充复杂 AssetBundle 的真实 fixture 与发布验证。
|
||||||
3. 将 `crowdin-textunit-queue.json` 接入真实 Crowdin worker、翻译记忆和 Patch 构建。
|
3. 扩展 ResourceRepository 查询面:更丰富的 TextUnit/TM 查询和 generic manifest 发布所需资源视图。
|
||||||
4. 扩展 ResourceRepository 查询面:真实 provider worker 集成与状态落库验证、CAS 诊断入口和更丰富 TextUnit 查询。
|
4. 按 `docs/guides/official-full-pull-smoke.md` 在隔离目录执行真实官方网络全量下载 smoke,并保留运行报告。
|
||||||
5. 继续完善 AssetBundle 复杂对象解析、复杂对象重打包和 Patch 发布流程统一;通用 Binary/JSON/Text Patch 基础与 UnityFS TextAsset patch 发布前置链路已可用。
|
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 不证明官方网络可达或官方全量资源下载成功。
|
||||||
+230
-14
@@ -6,7 +6,7 @@ Rust `bat` 的工作流入口按三个一级命令组织:
|
|||||||
- `parse`:当前官方 release 的解析和 UnityFS 重打包。
|
- `parse`:当前官方 release 的解析和 UnityFS 重打包。
|
||||||
- `i18n`:离线翻译工作台、人工文本修改和汉化 release 发布。
|
- `i18n`:离线翻译工作台、人工文本修改和汉化 release 发布。
|
||||||
|
|
||||||
`resource`、`resources`、`translation` 和 `translate` 仍作为长别名接受,但文档示例统一使用 `res` 和 `i18n`。
|
`resource`、`resources`、`translation` 和 `translate` 仍作为长别名接受,但文档示例统一使用 `res` 和 `i18n`;例如 `resource status`、`resource schedule`、`translation tasks`、`translation handoff` 和 `translation status` 都会落到同一组已实现命令。
|
||||||
|
|
||||||
## 资源拉取
|
## 资源拉取
|
||||||
|
|
||||||
@@ -34,6 +34,42 @@ bat res pull --auto-discover --watch --interval 1h \
|
|||||||
|
|
||||||
资源下载默认使用 8 个独立 worker,允许范围为 `1..=256`。worker 完成当前 URL 后立即领取共享队列中的下一个任务,进度按完成顺序统计,最终报告仍按计划顺序输出。
|
资源下载默认使用 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:
|
解析当前已发布 release:
|
||||||
@@ -68,7 +104,7 @@ bat parse clear-cache \
|
|||||||
bat parse repack --repack-spec /tmp/bat-repack.json
|
bat parse repack --repack-spec /tmp/bat-repack.json
|
||||||
```
|
```
|
||||||
|
|
||||||
重打包写入独立的 `target_bundle`,逐个操作后由底层 UnityFS patch 实现重建并校验,不允许 source 和 target 相同。
|
重打包写入独立的 `target_bundle`,逐个操作后由底层 UnityFS patch 实现重建并校验,不允许 source 和 target 相同。重建会保留已识别的 block 压缩、alignment、目录和未修改对象内容;未知压缩模式或无法证明保真的结构会失败。
|
||||||
|
|
||||||
## 翻译工作台与发布
|
## 翻译工作台与发布
|
||||||
|
|
||||||
@@ -89,7 +125,23 @@ bat i18n set \
|
|||||||
--translated-text '中文文本'
|
--translated-text '中文文本'
|
||||||
```
|
```
|
||||||
|
|
||||||
也可以使用 `--translated-file` 读取 UTF-8 文本。需要复核单条内容时:
|
也可以使用 `--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
|
```bash
|
||||||
bat i18n get \
|
bat i18n get \
|
||||||
@@ -106,7 +158,8 @@ bat i18n unset \
|
|||||||
--translation-id <text-unit-id>
|
--translation-id <text-unit-id>
|
||||||
```
|
```
|
||||||
|
|
||||||
这些工作台操作也可以写成 `bat i18n workbench set|get|unset|validate ...`。
|
这些工作台操作也可以写成 `bat i18n workbench set|get|unset|validate ...`,
|
||||||
|
`--translation-file` 也可简写为 `--workbench`。
|
||||||
工作台会保存 source text、release ID、TextUnit 目标和人工译文;发布前会重新读取当前
|
工作台会保存 source text、release ID、TextUnit 目标和人工译文;发布前会重新读取当前
|
||||||
TextUnit 索引,拒绝过期 release、source text 或 patch 目标。
|
TextUnit 索引,拒绝过期 release、source text 或 patch 目标。
|
||||||
|
|
||||||
@@ -118,8 +171,8 @@ bat i18n validate \
|
|||||||
--translation-file /tmp/bat-workbench.json
|
--translation-file /tmp/bat-workbench.json
|
||||||
```
|
```
|
||||||
|
|
||||||
报告会区分未审核、原文未变化、可直接 `i18n publish` 的 TextAsset,以及必须使用
|
报告会区分未审核、原文未变化、可直接 `i18n publish` 的受支持 TextAsset/
|
||||||
`parse repack` 的 TypeTree/嵌套 archive 条目。
|
TypeTree string field 条目,以及需要先经过独立 repack 流程的 ZIP 内或其他不支持条目。
|
||||||
|
|
||||||
发布汉化 release:
|
发布汉化 release:
|
||||||
|
|
||||||
@@ -130,11 +183,55 @@ bat i18n publish \
|
|||||||
--translation-file /tmp/bat-workbench.json
|
--translation-file /tmp/bat-workbench.json
|
||||||
```
|
```
|
||||||
|
|
||||||
发布只接受当前实现支持的直接 TextAsset 条目;TypeTree 字段和 zip 内 bundle 使用 `parse repack` 的 spec 单独处理。`--force` 不覆盖已有目录,而是生成独立的 `<official-release>-manual-<unix-seconds>` 汉化 release ID;也可以用 `--localized-release-id` 显式指定新 ID。因此强制发布仍保留旧 release 和 rollback 信息。
|
发布接受当前实现支持的直接 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。
|
当前 `i18n run` 是离线工作流:刷新 TextUnit 队列,并可用 `--translation-file` 导出工作台;真实 provider 由单独的 worker 命令消费 `translation-tasks.sqlite`。
|
||||||
|
|
||||||
provider worker 消费 handoff 后,可以用 `i18n task update` 回写当前 release 的任务状态:
|
运行一次 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
|
```bash
|
||||||
bat i18n task update \
|
bat i18n task update \
|
||||||
@@ -149,10 +246,27 @@ bat i18n task update \
|
|||||||
`--task-status` 支持 Rust contract 中的 `queued`、`running`、`failed`、
|
`--task-status` 支持 Rust contract 中的 `queued`、`running`、`failed`、
|
||||||
`completed` 和 `skipped`;命令只更新当前 release 的
|
`completed` 和 `skipped`;命令只更新当前 release 的
|
||||||
`translation-tasks.sqlite`,不会创建任意翻译任务。
|
`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`,计划记录包含动作、参数、下一次执行时间、周期、剩余次数、启用状态和最近错误。
|
每个一级工作流都可以管理自己的 schedule。调度计划保存在 `--state-dir/bat-schedules.json`,计划记录包含动作、参数、下一次执行时间、周期、剩余次数、启用状态和最近错误。
|
||||||
|
`parse schedule` 与 `res schedule` / `i18n schedule` 共用同一份计划库,
|
||||||
|
`--id` / `--action` 分别是 `--schedule-id` / `--schedule-action` 的简写。
|
||||||
|
|
||||||
新增一个每天执行的资源拉取计划:
|
新增一个每天执行的资源拉取计划:
|
||||||
|
|
||||||
@@ -175,6 +289,7 @@ 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 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 remove --state-dir /tmp/bat-schedule --schedule-id daily-pull
|
||||||
bat res schedule run --state-dir /tmp/bat-schedule
|
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` 的计划执行一次后自动停用。
|
`parse schedule add` 默认动作是 `run`,`i18n schedule add` 默认动作也是 `run`;可以用 `--schedule-action repack` 或 `--schedule-action publish` 选择对应动作。`--schedule-count` 限定执行次数,省略表示周期无限执行;没有 `--schedule-every` 的计划执行一次后自动停用。
|
||||||
@@ -186,15 +301,26 @@ bat res schedule run --state-dir /tmp/bat-schedule
|
|||||||
`schedule run --force` 会忽略到期时间立即执行指定计划,`--schedule-max-runs N`
|
`schedule run --force` 会忽略到期时间立即执行指定计划,`--schedule-max-runs N`
|
||||||
限制本轮最多执行 N 个到期计划。
|
限制本轮最多执行 N 个到期计划。
|
||||||
|
|
||||||
## bat-api 调度接口
|
## bat-api 调度与 dashboard 接口
|
||||||
|
|
||||||
dashboard 通过 `bat-api` 转发到 Rust `bat.sock`,不维护第二份计划状态。Rust RPC 方法为:
|
内嵌 dashboard 由 `bat-api` 直接服务于 `GET /admin/dashboard/`。页面静态资产免
|
||||||
|
token 读取,但资源、调度、任务、日志、解析和翻译控制都通过 `bat-api` 转发到
|
||||||
|
Rust `bat.sock`,不维护第二份计划状态或翻译状态。Rust RPC 方法为:
|
||||||
|
|
||||||
- `schedule.list`
|
- `schedule.list`
|
||||||
- `schedule.add`
|
- `schedule.add`
|
||||||
- `schedule.update`
|
- `schedule.update`
|
||||||
- `schedule.remove`
|
- `schedule.remove`
|
||||||
- `schedule.run`
|
- `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` 和
|
`bat-api` 对应接口为 `GET /admin/schedules` 和
|
||||||
`POST /admin/control/schedule-add|schedule-update|schedule-remove|schedule-run`,
|
`POST /admin/control/schedule-add|schedule-update|schedule-remove|schedule-run`,
|
||||||
@@ -205,11 +331,101 @@ contract:`id`、`group`、`action`、`args`、`next_run_unix_seconds`、
|
|||||||
`enabled`;`schedule.list` 额外接受 `id`、`group`、`enabled` 过滤,
|
`enabled`;`schedule.list` 额外接受 `id`、`group`、`enabled` 过滤,
|
||||||
`schedule.run` 额外接受 `group`、`force` 和 `max_runs`。
|
`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 通过
|
翻译任务状态也可由已鉴权的 dashboard 通过
|
||||||
`POST /admin/control/translation-task-update` 回写,请求字段为
|
`POST /admin/control/translation-task-update` 回写,请求字段为
|
||||||
`task_id`、`status`,以及可选的 `failure_reason`、`provider_run_id`;
|
`task_id`、`status`,以及可选的 `failure_reason`、`provider`、
|
||||||
该接口只转发 `translation.task.update`。
|
`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 持有。
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
|
|
||||||
解析器新增类型覆盖和新的解析格式仍受 `docs/reports/PARSER_FREEZE.md` 约束。本次 issue 43 的例外只开放已有解析输出的手动编排、缓存刷新、工作台编辑、既有 patch 实现的重打包和独立汉化发布,不扩展 UnityFS/AssetBundle/Addressables/TypeTree 的解析类型覆盖。
|
解析器新增类型覆盖和新的解析格式当前按路线图推进;新增覆盖仍需通过真实 fixture、回归测试和文档同步验收,不要只靠合成样本宣称能力。
|
||||||
|
|||||||
+78
-126
@@ -4,98 +4,79 @@
|
|||||||
|
|
||||||
BlueArchive Toolkit 的部署文档分为当前可用模式和目标模式:
|
BlueArchive Toolkit 的部署文档分为当前可用模式和目标模式:
|
||||||
|
|
||||||
1. **本地开发模式**:代码在本地,连接本地或远程数据库。
|
1. **本地开发模式**:当前 Rust `bat` 和 Go `bat-api` 不依赖 PostgreSQL/Redis;
|
||||||
|
本地资源状态使用文件和 SQLite。
|
||||||
2. **官方资源同步生产任务**:当前可用,运行 Rust `bat --watch` 或 RPC/daemon 模式。
|
2. **官方资源同步生产任务**:当前可用,运行 Rust `bat --watch` 或 RPC/daemon 模式。
|
||||||
3. **bat-api 资源 bootstrap / 分发服务**:当前可用,和 Rust `bat` 在同一服务器/容器环境运行,经 `bat.sock` RPC 获取当前 `resource_root`。
|
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 的远程服务器
|
当前实现不要求启动 PostgreSQL 或 Redis。建议先运行 Rust/Go 自身的门禁:
|
||||||
|
|
||||||
### 步骤
|
|
||||||
|
|
||||||
#### 1. 在远程服务器上部署数据库
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# SSH 登录到服务器
|
cargo check --workspace --locked
|
||||||
ssh user@your.server.com
|
make test-go-api
|
||||||
|
make build-go-api
|
||||||
# 创建部署目录
|
make check-docs
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 2. 配置防火墙
|
只有在开发未来 Go 服务层或目标数据库适配时,才需要启动可选的本地数据库:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 开放 PostgreSQL 端口
|
docker compose -f deployments/docker-compose.dev.yml --profile local-db up -d
|
||||||
sudo ufw allow 5432/tcp
|
|
||||||
|
|
||||||
# 开放 Redis 端口
|
|
||||||
sudo ufw allow 6379/tcp
|
|
||||||
|
|
||||||
# 查看状态
|
|
||||||
sudo ufw status
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 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
|
```env
|
||||||
DB_HOST=your.server.ip.address
|
DB_HOST=127.0.0.1
|
||||||
DB_PORT=5432
|
DB_PORT=15432
|
||||||
DB_USER=bat_user
|
REDIS_HOST=127.0.0.1
|
||||||
DB_PASSWORD=your_secure_password
|
REDIS_PORT=16379
|
||||||
DB_NAME=bluearchive_toolkit
|
|
||||||
|
|
||||||
REDIS_HOST=your.server.ip.address
|
|
||||||
REDIS_PORT=6379
|
|
||||||
REDIS_PASSWORD=your_redis_password
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 4. 测试连接
|
如果使用 VPN 或私网直连,应限制数据库服务仅监听明确的私网接口和允许的来源
|
||||||
|
网段,并继续使用认证与 TLS。不要添加面向全网的 `5432` / `6379` 防火墙放行规则。
|
||||||
|
|
||||||
|
远程主机上的可选 Compose 服务:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 测试 PostgreSQL 连接
|
docker compose -f deployments/docker-compose.remote-db.yml up -d
|
||||||
psql -h your.server.ip.address -U bat_user -d bluearchive_toolkit
|
docker compose -f deployments/docker-compose.remote-db.yml ps
|
||||||
|
|
||||||
# 测试 Redis 连接
|
|
||||||
redis-cli -h your.server.ip.address -p 6379 -a your_redis_password ping
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
该 Compose 配置默认仅在远程主机回环地址发布端口,远程访问应通过 SSH tunnel、
|
||||||
|
VPN 或受控私网,不通过公网端口直连。
|
||||||
## 模式 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
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -356,7 +337,7 @@ sudo -u bat tar -C /var/lib/bluearchive-toolkit/official \
|
|||||||
|
|
||||||
## 模式 4:bat-api 资源 bootstrap / 分发服务
|
## 模式 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`。
|
2. 当前资源目录由 `bat.sock` RPC 返回的 `resource_root` 决定;生产不要在 `bat-api` 配置里写死 `BAT_API_RESOURCE_ROOT`。
|
||||||
3. `BAT_API_RESOURCE_ROOT` 只用于本地 fixture、临时只读诊断或 RPC 不可用时的应急验证。
|
3. `BAT_API_RESOURCE_ROOT` 只用于本地 fixture、临时只读诊断或 RPC 不可用时的应急验证。
|
||||||
4. `bat.sock` 只在服务器本机使用,不通过公网暴露;对外只发布 HTTP `bat-api`,生产建议放在反向代理和 TLS 后面。
|
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
|
### 构建和安装 bat-api
|
||||||
|
|
||||||
@@ -388,7 +369,7 @@ sudo ln -sfn \
|
|||||||
|
|
||||||
### bat 侧前置条件
|
### bat 侧前置条件
|
||||||
|
|
||||||
`bat-api` 依赖 live RPC,而不是直接读取 daemon 状态文件。部署 `bat-api` 前,远程服务器上应已有 socket 形态的 Rust `bat`:
|
`bat-api` 依赖 live RPC,而不是直接读取 daemon 状态文件。部署 `bat-api` 前,部署所在生产主机上应已有 socket 形态的 Rust `bat`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo -u bat /opt/bluearchive-toolkit/bin/bat \
|
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 缓存。
|
否则保持默认 `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,7 +473,7 @@ BAT_API_SKIP_ENV_FILE=1 go run ./cmd/bat-api \
|
|||||||
--refresh-interval 0
|
--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`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -504,71 +485,42 @@ BAT_API_SKIP_ENV_FILE=1 go run ./cmd/bat-api \
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 数据库备份
|
## 可选数据库环境的备份与监控
|
||||||
|
|
||||||
### 手动备份
|
以下内容只适用于未来服务层使用的可选 PostgreSQL/Redis 环境,不属于当前
|
||||||
|
`bat` / `bat-api` 生产部署步骤。
|
||||||
|
|
||||||
|
### 备份
|
||||||
|
|
||||||
|
备份应在数据库主机或受控私网内执行,也可以通过 SSH 在远程主机上运行容器内工具:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# PostgreSQL
|
ssh user@db-host \
|
||||||
pg_dump -h your.server.com -U bat_user -d bluearchive_toolkit > backup.sql
|
'docker exec bat-postgres pg_dump -U bat_user bluearchive_toolkit' \
|
||||||
|
> backup.sql
|
||||||
# Redis
|
|
||||||
redis-cli -h your.server.com -p 6379 -a password BGSAVE
|
|
||||||
```
|
|
||||||
|
|
||||||
### 自动备份
|
|
||||||
|
|
||||||
启动备份服务:
|
|
||||||
```bash
|
|
||||||
docker compose -f deployments/docker-compose.remote-db.yml --profile backup up -d
|
docker compose -f deployments/docker-compose.remote-db.yml --profile backup up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
备份文件位置:`deployments/backups/`
|
Redis 备份使用数据库主机或容器内的受控备份工具。不要在脚本或文档中使用带公网
|
||||||
|
主机名的 `redis-cli -h ... -p 6379` 连接,也不要把密码放进公开命令行参数或提交文件。
|
||||||
|
|
||||||
---
|
### 监控
|
||||||
|
|
||||||
## 监控
|
|
||||||
|
|
||||||
### 查看日志
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 数据库日志
|
ssh user@db-host 'docker compose -f deployments/docker-compose.remote-db.yml ps'
|
||||||
docker logs bat-postgres
|
ssh user@db-host 'docker logs bat-postgres'
|
||||||
|
ssh user@db-host 'docker logs bat-redis'
|
||||||
# Redis 日志
|
|
||||||
docker logs bat-redis
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 健康检查
|
### 故障排查
|
||||||
|
|
||||||
```bash
|
当前 `bat` / `bat-api` 无需数据库连接;资源同步故障应先检查 `bat.sock`、发布目录、
|
||||||
# 检查容器状态
|
SQLite 索引和 Rust daemon 状态。未来服务层出现数据库连接问题时,按以下顺序检查:
|
||||||
docker compose -f deployments/docker-compose.remote-db.yml ps
|
|
||||||
|
|
||||||
# 检查 PostgreSQL
|
1. 私网、VPN 或 SSH tunnel 是否可用;
|
||||||
docker exec bat-postgres pg_isready -U bat_user
|
2. 本地连接端口是否为 tunnel 映射或受控私网端口;
|
||||||
|
3. 数据库认证、TLS 和允许网段配置;
|
||||||
# 检查 Redis
|
4. 数据库容器是否运行。
|
||||||
docker exec bat-redis redis-cli ping
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 故障排查
|
|
||||||
|
|
||||||
### 无法连接数据库
|
|
||||||
|
|
||||||
1. 检查防火墙是否开放端口
|
|
||||||
2. 检查 `pg_hba.conf` 配置
|
|
||||||
3. 检查密码是否正确
|
|
||||||
4. 检查数据库是否启动
|
|
||||||
|
|
||||||
### 性能问题
|
|
||||||
|
|
||||||
1. 查看数据库连接数
|
|
||||||
2. 检查慢查询日志
|
|
||||||
3. 优化索引
|
|
||||||
4. 调整数据库参数
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+58
-27
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
#### Go
|
#### Go
|
||||||
```bash
|
```bash
|
||||||
# 安装 Go 1.22+
|
# 安装 Go 1.26.4+
|
||||||
# 参考:https://golang.org/doc/install
|
# 参考:https://golang.org/doc/install
|
||||||
|
|
||||||
go version # 验证安装
|
go version # 验证安装
|
||||||
@@ -23,9 +23,11 @@ rustc --version # 验证安装
|
|||||||
cargo --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
|
```bash
|
||||||
git --version
|
git --version
|
||||||
@@ -34,9 +36,13 @@ cargo --version
|
|||||||
rustfmt --version
|
rustfmt --version
|
||||||
cargo clippy --version
|
cargo clippy --version
|
||||||
go version
|
go version
|
||||||
|
golangci-lint --version # 必须为 2.12.2
|
||||||
```
|
```
|
||||||
|
|
||||||
该 workflow 会用 `GITHUB_SERVER_URL`、`GITHUB_REPOSITORY`、`GITHUB_REF` 和 `GITHUB_SHA` 手动 `git fetch` 当前提交,再执行 Rust workspace 的格式化、检查、构建、clippy 和测试,以及 Go API 门禁和文档状态门禁。这样可以避免自托管 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
|
#### Docker
|
||||||
```bash
|
```bash
|
||||||
@@ -66,14 +72,11 @@ git checkout -b feature/your-feature-name
|
|||||||
### 2. 开发
|
### 2. 开发
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 实时编译检查
|
# 运行只读 required 门禁(不会格式化或修改源码)
|
||||||
make check
|
make ci-check
|
||||||
|
|
||||||
# 运行测试
|
# 需要格式化时才修改工作树
|
||||||
make test
|
make format
|
||||||
|
|
||||||
# 格式化代码
|
|
||||||
make fmt
|
|
||||||
```
|
```
|
||||||
|
|
||||||
开发约束:
|
开发约束:
|
||||||
@@ -118,9 +121,9 @@ git push origin feature/your-feature-name
|
|||||||
|
|
||||||
禁止使用 demo、临时实现、硬编码路径或只为当前测试通过的伪实现。确实未完成的能力应写入当前缺口文档,而不是用 `TODO` 或 `FIXME` 隐藏。
|
禁止使用 demo、临时实现、硬编码路径或只为当前测试通过的伪实现。确实未完成的能力应写入当前缺口文档,而不是用 `TODO` 或 `FIXME` 隐藏。
|
||||||
|
|
||||||
### 解析模块冻结
|
### 解析模块状态
|
||||||
|
|
||||||
UnityFS / AssetBundle / Addressables / TypeTree 解析当前处于维护冻结。冻结期不得新增解析类型、扩大解析覆盖、开放新的写入型解析 RPC/CLI,或用合成 fixture 宣称新增能力。允许变更仅限编译、测试、clippy、真实运行回归、诊断和文档一致性修复。细则见 `docs/reports/PARSER_FREEZE.md`。
|
UnityFS / AssetBundle / Addressables / TypeTree 解析当前按路线图继续推进。新增解析类型、扩大解析覆盖和写入型解析 RPC/CLI 仍需遵守现有接口边界、真实 fixture 和回归验收要求。
|
||||||
|
|
||||||
### Go
|
### Go
|
||||||
- 遵循 [Effective Go](https://golang.org/doc/effective_go)
|
- 遵循 [Effective Go](https://golang.org/doc/effective_go)
|
||||||
@@ -143,27 +146,27 @@ UnityFS / AssetBundle / Addressables / TypeTree 解析当前处于维护冻结
|
|||||||
### 合并前通用门禁
|
### 合并前通用门禁
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo fmt --check
|
make ci-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 check-docs
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`make ci-check` 是只读门禁入口;`make format` / `make fmt` 才会修改源码。
|
||||||
|
required 的 `golangci-lint 2.12.2` 由 `scripts/ci-versions.sh` 固定,缺失或版本不匹配
|
||||||
|
都会失败,不会伪报全部门禁通过。
|
||||||
|
|
||||||
Go 边界与进度以 `docs/reports/GO_STATUS.md` 为准:
|
Go 边界与进度以 `docs/reports/GO_STATUS.md` 为准:
|
||||||
|
|
||||||
- **同步/运维命令行** = Rust `bat`(近乎全自动)
|
- **同步/运维命令行** = Rust `bat`(近乎全自动)
|
||||||
- **资源 bootstrap/分发服务** = `cmd/bat-api`(`make build-go-api`)
|
- **资源 bootstrap/分发服务与内嵌 dashboard** = `cmd/bat-api`(`make build-go-api`)
|
||||||
- **默认 Go 门禁** = `make test-go-api`(无 FFI)
|
- **默认 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` 重名
|
- 试验 CLI 产物为 `bin/bat-go`(`make build-go-cli`),**禁止**与 Rust `bat` 重名
|
||||||
- 修改 FFI 时再跑 `make test-go-ffi`
|
- 修改 FFI 时再跑 `make test-go-ffi`
|
||||||
|
|
||||||
开发环境不能本地全量运行 Rust `bat` 时,`bat-api` 不需要真实生产资源目录。用 fixture 或 mock RPC 验证服务面;生产联调再连接远程服务器上同环境运行的 `bat.sock`:
|
`bat-api` 与 Rust `bat` 的生产拓扑是同一主机、同一容器或同一共享文件系统。开发时优先使用隔离 fixture 和本地 `bat.sock` live smoke,不连接远程服务器,也不读取现有客户端目录:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make test-go-api
|
make test-go-api
|
||||||
|
make bat-api-local-live-smoke
|
||||||
BAT_API_SKIP_ENV_FILE=1 go run ./cmd/bat-api \
|
BAT_API_SKIP_ENV_FILE=1 go run ./cmd/bat-api \
|
||||||
--listen 127.0.0.1:18080 \
|
--listen 127.0.0.1:18080 \
|
||||||
--public-base-url http://127.0.0.1:18080 \
|
--public-base-url http://127.0.0.1:18080 \
|
||||||
@@ -171,6 +174,8 @@ BAT_API_SKIP_ENV_FILE=1 go run ./cmd/bat-api \
|
|||||||
--refresh-interval 0
|
--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 或应急只读诊断。
|
生产默认路径仍是 `--socket` / `BAT_API_SOCKET`,资源根由 Rust `bat` RPC 返回;`--resource-root` 只用于上述 fixture 或应急只读诊断。
|
||||||
|
|
||||||
### 常用聚焦命令
|
### 常用聚焦命令
|
||||||
@@ -189,7 +194,7 @@ 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 承担。
|
`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` 三个一级命令;该工作流当前对应 issue `#43`。
|
Rust `bat` 的资源拉取、解析、翻译工作流、重打包、汉化发布和持久化调度命令见 [`docs/guides/bat-workflows.md`](bat-workflows.md)。推荐使用 `res`、`parse`、`i18n` 三个一级命令。
|
||||||
|
|
||||||
### 集成测试
|
### 集成测试
|
||||||
|
|
||||||
@@ -235,6 +240,15 @@ Crowdin 队列当前只落本地文件,不发网络请求。
|
|||||||
`--download-concurrency 1`。本文档中的真实资源命令仅是隔离 runbook;本地轻量
|
`--download-concurrency 1`。本文档中的真实资源命令仅是隔离 runbook;本地轻量
|
||||||
验证应使用 fake-curl/fixture,不要在开发机执行真实下载或 smoke run。
|
验证应使用 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` 时,显式启用:
|
需要把已校验官方 release 导入 CAS + `ResourceRepository` 时,显式启用:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -245,7 +259,7 @@ cargo run -p bat-infrastructure --bin bat -- \
|
|||||||
--import-resource-db /tmp/bat-test-resources.sqlite
|
--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`。只读查询命令:
|
`BAT_IMPORT_CAS_ROOT` 和 `BAT_IMPORT_RESOURCE_DB`。只读查询命令:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -255,11 +269,20 @@ 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 --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-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 -- 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 -- localized-status
|
||||||
cargo run -p bat-infrastructure --bin bat -- resource-index --limit 50
|
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
|
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-status` 会额外显示 TextUnit 明细索引和队列摘要;`parse-text-units` /
|
||||||
`parse-errors` 可按 destination、archive entry、path id、class id、field path
|
`parse-errors` 可按 destination、archive entry、path id、class id、field path
|
||||||
和 format 分页查询当前官方 release 的 TextUnit 明细与解析错误;
|
和 format 分页查询当前官方 release 的 TextUnit 明细与解析错误;
|
||||||
@@ -267,13 +290,20 @@ cargo run -p bat-infrastructure --bin bat -- resource-index --release-id <ID> --
|
|||||||
worker 状态、parse status、TextUnit format、队列 reason 和 provider failure reason
|
worker 状态、parse status、TextUnit format、队列 reason 和 provider failure reason
|
||||||
查询离线 TextUnit 翻译任务状态与跳过/失败原因;发布后的状态保存在当前 release
|
查询离线 TextUnit 翻译任务状态与跳过/失败原因;发布后的状态保存在当前 release
|
||||||
根目录的 `translation-tasks.sqlite`,旧 release 没有状态库时回退到 JSON 队列;
|
根目录的 `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` / `translation.handoff` 会动态合并版本化
|
||||||
`translation-handoff.json` 与 SQLite 状态,返回 job、unit、provider run 的完整交接
|
`translation-handoff.json` 与 SQLite 状态,返回 job、unit、provider run 的完整交接
|
||||||
视图;
|
视图;
|
||||||
`resource-index` 返回的资源 JSON 包含 release、平台、bundle path、TextAsset 和 TextUnit metadata,
|
`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 做资源级过滤;
|
并可按 release、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 做资源级过滤;
|
||||||
`localized-status` 只有在 `localized-version-state.json`、`current` symlink 和
|
`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:
|
文件级写入命令只处理显式输入/输出文件,不切换官方或汉化 release:
|
||||||
|
|
||||||
@@ -388,7 +418,8 @@ cargo fetch
|
|||||||
|
|
||||||
`bat-ffi` 不是主集成边界,只用于需要 C ABI 的兼容场景。当前 Go 正式产品入口是
|
`bat-ffi` 不是主集成边界,只用于需要 C ABI 的兼容场景。当前 Go 正式产品入口是
|
||||||
`bat-api` 资源 bootstrap/分发服务,默认通过 `internal/backendrpc` 调用 daemon RPC;
|
`bat-api` 资源 bootstrap/分发服务,默认通过 `internal/backendrpc` 调用 daemon RPC;
|
||||||
`cmd/bat` 仍是试验 CLI。未来新增 Go 集成仍优先使用 Rust `bat --json` 进程边界或稳定 RPC/SDK。
|
`cmd/bat` 仍是试验 CLI。新的 Go 集成优先使用 Rust `bat.sock` RPC 或稳定 SDK;
|
||||||
|
`bat --json` 仅是 Rust CLI 的机器输出形态。
|
||||||
|
|
||||||
重新构建兼容库:
|
重新构建兼容库:
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -184,6 +184,8 @@ cargo run -p bat-infrastructure --example official_pull_plan -- \
|
|||||||
- 存在 `.part` 临时文件时会尝试断点续传
|
- 存在 `.part` 临时文件时会尝试断点续传
|
||||||
- 新下载先写 `.part`,成功并通过必要校验后再替换为最终文件;如果断点续传后的 `.zip` 结构校验失败,会删除 `.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
|
- 如果上一轮非 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`
|
- 把结果发布到 `--output/current`
|
||||||
|
|
||||||
## 5. 自动更新检查
|
## 5. 自动更新检查
|
||||||
@@ -210,12 +212,13 @@ cargo run -p bat-infrastructure --example official_pull_plan -- \
|
|||||||
- `--dry-run`:只报告本次是否会下载,不写 snapshot;如果 cache miss,也不会写入新的 bootstrap cache。
|
- `--dry-run`:只报告本次是否会下载,不写 snapshot;如果 cache miss,也不会写入新的 bootstrap cache。
|
||||||
- `--dry-run --plan`:除更新判断外,还会解析 seed catalog 并打印完整下载 URL。
|
- `--dry-run --plan`:除更新判断外,还会解析 seed catalog 并打印完整下载 URL。
|
||||||
- 真实更新会输出 `downloaded_count`、`resumed_count`、`skipped_count`、`transferred_bytes`、`official_seed_hash_verified_count`。
|
- 真实更新会输出 `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`。
|
- 校验报告分层输出 `official_seed_hash_verified_count`、`local_manifest_verified_count`、`addressables_marker_checked_count`、`unverified_marker_count`。
|
||||||
- 下载阶段复用同一套本地清单、ZIP 结构校验和 `.part` 续传逻辑;没有清单或校验不匹配的文件会重新下载。
|
- 下载阶段复用同一套本地清单、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` 作为维护期证据。
|
- 非 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。
|
- 校验和发布完成后会先对比上一完整 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 且已有有效解析缓存时只读取摘要,不重复解析。
|
- 随后会刷新 `<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`,表示原版和汉化两套资源都已发布。
|
- 官方同步报告中的 `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-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-bootstrap-cache.json`:`--auto-discover` 的 `GameMainConfig` 解析缓存。launcher metadata 与 remote manifest 文件列表 digest 都未变时复用缓存;任一变化时才通过官方 HTTP 按 manifest 下载必要 `resources.assets` 或旧版 game zip 到临时目录解析。
|
||||||
- `<output>/official-version-state.json`:资源发布根目录的持久版本状态,包含当前已完成版本、正在拉取版本、上一个可用版本和失败版本。
|
- `<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/official-resource-changes.json`:当前 release 相对上一完整 release 的资源差异,记录新增、变更、删除以及解析/翻译候选计数。
|
||||||
- `<output>/current/crowdin-translation-handoff.json`:为后续 Crowdin worker 预留的本地队列,只包含新增+变更资源;它不是 Crowdin API 调用结果。
|
- `<output>/current/crowdin-translation-handoff.json`:为后续 Crowdin worker 预留的本地队列,只包含新增+变更资源;它不是 Crowdin API 调用结果。
|
||||||
- `<output>/current/official-parse-cache.json`:官方资源发布后的派生解析缓存,记录 bundle/zip 条目解析摘要和缓存复用情况;它不是汉化产物。
|
- `<output>/current/official-parse-cache.json`:官方资源发布后的派生解析缓存,记录 bundle/zip 条目解析摘要和缓存复用情况;它不是汉化产物。
|
||||||
@@ -320,9 +325,9 @@ cargo run -p bat-infrastructure --bin bat -- \
|
|||||||
--error-retry 60s
|
--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 自动更新的唯一方式。下载默认并发 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`,防止并发写同一官方资源目录;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`,但生产同步默认应保持开启。
|
需要只做探测时可以加 `--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`。
|
默认输出在 `/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` 输入是:
|
可接受的 `server-info` 输入是:
|
||||||
|
|
||||||
|
|||||||
@@ -104,8 +104,8 @@ contract 为准,不应绕过 daemon 状态文件或扩展 `bat-ffi` 作为主
|
|||||||
| `resource.sync` | 已实现 | `{ "force": false }` | `{ "task_id": "...", "kind": "resource.sync" }`。 |
|
| `resource.sync` | 已实现 | `{ "force": false }` | `{ "task_id": "...", "kind": "resource.sync" }`。 |
|
||||||
| `resource.verify` | 已实现 | `null` | `{ "task_id": "...", "kind": "resource.verify" }`。 |
|
| `resource.verify` | 已实现 | `null` | `{ "task_id": "...", "kind": "resource.verify" }`。 |
|
||||||
| `resource.repair` | 已实现 | `null` | `{ "task_id": "...", "kind": "resource.repair" }`。 |
|
| `resource.repair` | 已实现 | `null` | `{ "task_id": "...", "kind": "resource.repair" }`。 |
|
||||||
| `resource.manifest` | 已实现 | `{ "offset": 0, "limit": 100 }` | 当前 download manifest 分页。 |
|
| `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` | 已实现 | `{ "offset": 0, "limit": 100 }` | `resource.manifest` 的兼容别名。 |
|
| `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.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.repair` 会开启本地 manifest audit + repair,不继承 `force`。
|
||||||
@@ -119,12 +119,23 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且
|
|||||||
属于 `parse.text_units` / `parse.errors` 的对象级查询。`limit` 范围是
|
属于 `parse.text_units` / `parse.errors` 的对象级查询。`limit` 范围是
|
||||||
`1..=1000`,非法参数返回 `BAT-ERR-700002`。
|
`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`、
|
`resource.index` 的 `entries[]` 是 `Resource` JSON,除 `id`、`local_path`、
|
||||||
`entry` 外会包含 `metadata`:`official_release_id`、`platform`、
|
`entry` 外会包含 `metadata`:`official_release_id`、`platform`、
|
||||||
`bundle_path`、`archive_entries`、`parse_statuses`、`unity_versions`、
|
`bundle_path`、`archive_entries`、`parse_statuses`、`unity_versions`、
|
||||||
`text_assets`、`text_unit_count`、`text_unit_formats` 和
|
`text_assets`、`text_unit_count`、`text_unit_formats` 和
|
||||||
`text_unit_error_count` 等字段。旧索引库会通过 `metadata_json` 迁移列得到
|
`text_unit_error_count` 等字段。`entry` 还会保留 Addressables 的
|
||||||
默认空 metadata。
|
`provider_id`、`bundle_name`、`hash`、`size`、`crc` 和 `dependencies`。
|
||||||
|
旧索引库会通过 `metadata_json` 以及资源字段兼容迁移得到默认空值。
|
||||||
|
|
||||||
`resource.state`、`catalog.status`、`parse.status` 和 `localized.status`
|
`resource.state`、`catalog.status`、`parse.status` 和 `localized.status`
|
||||||
都会返回当前观察面的短状态 `status` 与稳定状态码 `status_code`。`status_code`
|
都会返回当前观察面的短状态 `status` 与稳定状态码 `status_code`。`status_code`
|
||||||
@@ -149,16 +160,59 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且
|
|||||||
本地文件未变且索引有效时复用,不重复解析。
|
本地文件未变且索引有效时复用,不重复解析。
|
||||||
- `official-textunit-tasks.json`:只由 added + modified 资源、parse cache 和
|
- `official-textunit-tasks.json`:只由 added + modified 资源、parse cache 和
|
||||||
TextUnit 明细索引派生,记录 TextUnit 任务、跳过原因和解析诊断。
|
TextUnit 明细索引派生,记录 TextUnit 任务、跳过原因和解析诊断。
|
||||||
- `crowdin-textunit-queue.json`:只包含已产生 TextUnit 的离线任务,预留给后续
|
- `crowdin-textunit-queue.json`:只包含已产生 TextUnit 的离线任务;官方同步阶段
|
||||||
Crowdin worker;当前不会发出网络请求。
|
不发出 provider 网络请求。
|
||||||
- `translation-tasks.sqlite`:当前 release 的可变 worker 状态库,记录
|
- `translation-tasks.sqlite`:当前 release 的可变 worker 状态库,记录
|
||||||
queued / running / failed / completed / skipped、attempt count、provider run
|
queued / running / failed / completed / skipped、attempt count、provider run
|
||||||
ID 和 failure reason;schema 由 `schema_migrations` 版本表管理。
|
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 交接
|
- `translation-handoff.json`:当前 release 的版本化 job/unit/provider run 交接
|
||||||
快照;worker 更新后的实时状态仍以 `translation-tasks.sqlite` 为准。
|
快照;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。
|
删除资源只进入 `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
|
### schedule
|
||||||
|
|
||||||
调度计划由 Rust `bat` 持有,状态文件为 daemon `state_dir` 下的
|
调度计划由 Rust `bat` 持有,状态文件为 daemon `state_dir` 下的
|
||||||
@@ -205,6 +259,26 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且
|
|||||||
| `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.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.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.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 或没有解析缓存时返回
|
`parse.status` 是只读查询;没有当前 release 或没有解析缓存时返回
|
||||||
`ok=true` 且 `data.available=false`。解析缓存来自官方原版资源目录,不读取
|
`ok=true` 且 `data.available=false`。解析缓存来自官方原版资源目录,不读取
|
||||||
@@ -213,8 +287,8 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且
|
|||||||
`textunit_index_summary`;存在 `official-textunit-tasks.json` 时,响应会包含
|
`textunit_index_summary`;存在 `official-textunit-tasks.json` 时,响应会包含
|
||||||
`textunit_queue_available=true`、`textunit_task_queue_path` 和
|
`textunit_queue_available=true`、`textunit_task_queue_path` 和
|
||||||
`textunit_task_summary`。当 TextUnit 队列存在且有离线任务时,
|
`textunit_task_summary`。当 TextUnit 队列存在且有离线任务时,
|
||||||
`translation_status_code=translation.queued_offline`;真实 Crowdin worker
|
`translation_status_code=translation.queued_offline`;provider worker 完成任务后,
|
||||||
尚未接入时不会返回翻译完成状态。
|
同一查询面会返回已落库的 worker 状态和 TextUnit 级译文结果。
|
||||||
|
|
||||||
`parse.text_units` / `parse.errors` 是只读查询;没有当前 release 或没有
|
`parse.text_units` / `parse.errors` 是只读查询;没有当前 release 或没有
|
||||||
`official-textunit-index.json` 时返回 `ok=true` 且 `data.available=false`。
|
`official-textunit-index.json` 时返回 `ok=true` 且 `data.available=false`。
|
||||||
@@ -252,31 +326,92 @@ offset 和 error。TypeTree-covered managed reference 字段会进入结构化
|
|||||||
`translation.task.update` 只更新当前 release 的 SQLite 状态库,不改写 immutable
|
`translation.task.update` 只更新当前 release 的 SQLite 状态库,不改写 immutable
|
||||||
队列文件,也不主动访问 Crowdin。`status` 支持 `queued`、`running`、`failed`、
|
队列文件,也不主动访问 Crowdin。`status` 支持 `queued`、`running`、`failed`、
|
||||||
`completed` 和 `skipped`;进入 `running` 会增加 attempt count,`completed` 会
|
`completed` 和 `skipped`;进入 `running` 会增加 attempt count,`completed` 会
|
||||||
记录完成时间,`failed` 可写入 `failure_reason`。因此 worker 消费 handoff 后,
|
记录完成时间,`failed` 可写入 `failure_reason`。人工校对流程可以在
|
||||||
bat-api 可通过 `translation.tasks` 查询单项任务,也可通过
|
`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`
|
`translation.handoff` 获取完整 job/unit/provider run 状态。`translation.handoff`
|
||||||
不会触发下载或 provider 网络请求;没有当前 release 或任务队列时返回
|
不会触发下载或 provider 网络请求;没有当前 release 或任务队列时返回
|
||||||
`data.available=false`。
|
`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
|
### localized
|
||||||
|
|
||||||
| 方法 | 状态 | params | data |
|
| 方法 | 状态 | params | data |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `localized.status` | 已实现 | `null` | 汉化发布状态、当前官方 release 匹配关系和汉化输出目录。 |
|
| `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` 与
|
`--localized-output` 查询汉化产物目录,不把 `./bat-resources` 与
|
||||||
`./bat-localized` 混用。当前支持未汉化发布状态和已汉化发布状态的只读报告。
|
`./bat-localized` 混用。当前支持未汉化发布状态和已汉化发布状态的只读报告。
|
||||||
`status` / `status_code` 使用生命周期短状态和稳定状态码,例如
|
`status` / `status_code` 使用生命周期短状态和稳定状态码,例如
|
||||||
`pending` / `localized.pending`、`stale` / `localized.stale`、`published` /
|
`pending` / `localized.pending`、`stale` / `localized.stale`、`published` /
|
||||||
`localized.published`;旧的 `localized` / `not_localized` 业务标签放在
|
`localized.published`、`localized.degraded`;旧的 `localized` / `not_localized` 业务标签放在
|
||||||
`localized_release_status`。返回 `localized_release_status=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,
|
`localized-version-state.json` 的官方 release ID 匹配当前官方 release,
|
||||||
`current` symlink 指向汉化发布根下对应的 `versions/<id>`,并且该版本目录中的
|
`current` symlink 指向汉化发布根下对应的 `versions/<id>`,并且该版本目录中的
|
||||||
`localized-patch-manifest.json` 存在且 release ID 匹配。响应会返回
|
`localized-patch-manifest.json` 存在且 release ID 匹配。响应会返回
|
||||||
`patch_manifest_path`、`patch_manifest_available`、
|
`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`。
|
`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
|
### catalog
|
||||||
|
|
||||||
@@ -361,7 +496,13 @@ daemon 重启后仍处于 `queued` 或 `running` 的历史任务会被标记为
|
|||||||
`data` 会返回 source / patch 或 replacement / target 的 size 与 BLAKE3。`target_path`
|
`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 切换。调用这些规划方法仍返回
|
`unityfs.inspect`、通用 manifest 驱动 release 切换。调用这些规划方法仍返回
|
||||||
`BAT-ERR-700003`。
|
`BAT-ERR-700003`。
|
||||||
|
|
||||||
@@ -391,13 +532,30 @@ CLI 对应关系:
|
|||||||
| `bat parse-errors` | `parse.errors` |
|
| `bat parse-errors` | `parse.errors` |
|
||||||
| `bat translation-tasks` | `translation.tasks` |
|
| `bat translation-tasks` | `translation.tasks` |
|
||||||
| `bat translation-handoff` | `translation.handoff` |
|
| `bat translation-handoff` | `translation.handoff` |
|
||||||
|
| `bat i18n task list` / `bat i18n task status` | `translation.tasks` |
|
||||||
| `bat i18n task update` | `translation.task.update` |
|
| `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 localized-status` | `localized.status` |
|
||||||
| `bat resource-index` | `resource.index` |
|
| `bat resource-index` | `resource.index` |
|
||||||
|
|
||||||
|
`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`、
|
`bat resource-index` 支持 `--offset`、`--limit`、`--resource-type`、`--hash`、
|
||||||
`--path-pattern`、`--release-id`、`--platform`、`--destination`、
|
`--path-pattern`、`--release-id`、`--platform`、`--destination`、
|
||||||
`--bundle-path`、`--archive-entry`、`--parse-status` 和 `--format`;
|
`--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`、
|
`bat parse-text-units` / `bat parse-errors` 支持 `--offset`、`--limit`、
|
||||||
`--destination`、`--path-pattern`、`--archive-entry`、`--path-id`、
|
`--destination`、`--path-pattern`、`--archive-entry`、`--path-id`、
|
||||||
`--class-id`、`--field-path` 和 `--format`;`bat translation-tasks` 支持
|
`--class-id`、`--field-path` 和 `--format`;`bat translation-tasks` 支持
|
||||||
@@ -414,21 +572,36 @@ CLI 对应关系:
|
|||||||
envelope 和 `ApiError` 解码;它不是 bat-api 的 HTTP 任意 RPC proxy。
|
envelope 和 `ApiError` 解码;它不是 bat-api 的 HTTP 任意 RPC proxy。
|
||||||
- typed helper 已覆盖 daemon 已实现方法(`status/logs/stop/restart/reload/refresh/doctor`)、
|
- typed helper 已覆盖 daemon 已实现方法(`status/logs/stop/restart/reload/refresh/doctor`)、
|
||||||
`resource.state/sync/verify/repair/manifest/list`、`schedule.list/add/update/remove/run`、
|
`resource.state/sync/verify/repair/manifest/list`、`schedule.list/add/update/remove/run`、
|
||||||
`catalog.*`、`parse.*`、
|
`catalog.*`、`parse.*`、`release.status/list/distribution/cleanup`、
|
||||||
`localized.status`、`translation.task.update`、`task.*` 和三个
|
`localized.status`、`localized.publish`、`localized.rollback`、
|
||||||
`unityfs.patch_*` 方法。
|
`translation.tasks`、`translation.handoff`、`translation.task.update`、
|
||||||
- `resource.index`、`translation.tasks`、`translation.handoff` 和
|
`translation.worker.run`、`translation.proofread`、`translation.memory.summary`、
|
||||||
`patch.apply` 当前没有专用 typed helper;需要直接使用 `Call`,并仍须遵守
|
`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 生产路径进一步收窄接口:
|
`internal/api` 对 bat-api 生产路径进一步收窄接口:
|
||||||
|
|
||||||
| Go 接口 | 允许调用的 RPC | 用途 |
|
| Go 接口 | 允许调用的 RPC | 用途 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `Backend` | `daemon.status`、`daemon.doctor`、`resource.state`、`catalog.status`、`resource.manifest` | 启动发现、周期刷新和资源分发 |
|
| `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` | 鉴权后的管理控制白名单 |
|
| `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 调度计划控制 |
|
| `ScheduleBackend` | `schedule.list`、`schedule.add`、`schedule.update`、`schedule.remove`、`schedule.run` | 鉴权后的 dashboard 调度计划控制 |
|
||||||
| `TranslationBackend` | `translation.task.update` | 鉴权后的 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 管理控制面。
|
`daemon.stop`、`daemon.clean-stable` 和任意通用 RPC 不属于 bat-api 管理控制面。
|
||||||
Rust dispatch、Go transport 和 bat-api 接口的权威实现位置分别是
|
Rust dispatch、Go transport 和 bat-api 接口的权威实现位置分别是
|
||||||
@@ -436,10 +609,13 @@ Rust dispatch、Go transport 和 bat-api 接口的权威实现位置分别是
|
|||||||
`internal/api/rpc_release.go`;修改方法、字段或 allowlist 时必须同步更新本文档。
|
`internal/api/rpc_release.go`;修改方法、字段或 allowlist 时必须同步更新本文档。
|
||||||
|
|
||||||
Go mirror contract fixture 固化在 `internal/api/testdata/contract/`,覆盖
|
Go mirror contract fixture 固化在 `internal/api/testdata/contract/`,覆盖
|
||||||
`catalog.status` available/unavailable、`resource.manifest` page0 和对应
|
`catalog.status` available/unavailable、`resource.manifest` page0、对应
|
||||||
`official-sync-snapshot.json`。这些 fixture 由 Rust 输出归一化而来,只用于
|
`official-sync-snapshot.json`、Translation Memory query/缺库 mirror 和 Glossary
|
||||||
schema / mirror 回归;live daemon socket 和完整 release 切换仍需在允许 smoke 的
|
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,6 +1,6 @@
|
|||||||
# bat-api / Rust bat Contract Fixture Handoff
|
# bat-api / Rust bat Contract Fixture Handoff
|
||||||
|
|
||||||
更新时间:2026-08-03
|
更新时间:2026-09-04
|
||||||
|
|
||||||
本文用于两个 Codex 窗口之间间接联调 `bat-api` 与 Rust `bat` 的跨语言 contract fixture。
|
本文用于两个 Codex 窗口之间间接联调 `bat-api` 与 Rust `bat` 的跨语言 contract fixture。
|
||||||
仓库内归一化 fixture 已交付;本文保留生成、审核和后续扩展的协作协议。
|
仓库内归一化 fixture 已交付;本文保留生成、审核和后续扩展的协作协议。
|
||||||
@@ -18,8 +18,8 @@
|
|||||||
- 联调不要求本地运行全量长期服务端 `bat`;允许 Rust 侧使用 fixture root 或临时目录走真实代码路径导出 JSON。
|
- 联调不要求本地运行全量长期服务端 `bat`;允许 Rust 侧使用 fixture root 或临时目录走真实代码路径导出 JSON。
|
||||||
- fixture 审核前只能放在 `/tmp/bat-contract-fixture/`,不能直接提交到仓库。
|
- fixture 审核前只能放在 `/tmp/bat-contract-fixture/`,不能直接提交到仓库。
|
||||||
- Go 侧已经实现 player-facing HTTP 鉴权、限流、访问日志、反代适配、OpenAPI 和
|
- Go 侧已经实现 player-facing HTTP 鉴权、限流、访问日志、反代适配、OpenAPI 和
|
||||||
`/admin/` 控制入口;仓库内 contract fixture 已完成,剩余是 live daemon socket
|
`/admin/` 控制入口;仓库内 contract fixture 和同机 live daemon socket / 完整
|
||||||
和完整 release 切换联调。
|
fixture release 切换联调均已完成,命令为 `make bat-api-local-live-smoke`。
|
||||||
- Go 侧当前相关代码入口:
|
- Go 侧当前相关代码入口:
|
||||||
- `internal/api/rpc_release.go`
|
- `internal/api/rpc_release.go`
|
||||||
- `internal/api/release_index.go`
|
- `internal/api/release_index.go`
|
||||||
@@ -50,11 +50,13 @@
|
|||||||
catalog-status.unavailable.raw.json
|
catalog-status.unavailable.raw.json
|
||||||
resource-manifest.page0.raw.json
|
resource-manifest.page0.raw.json
|
||||||
official-sync-snapshot.raw.json
|
official-sync-snapshot.raw.json
|
||||||
|
glossary-query.raw.json
|
||||||
normalized/
|
normalized/
|
||||||
catalog-status.available.json
|
catalog-status.available.json
|
||||||
catalog-status.unavailable.json
|
catalog-status.unavailable.json
|
||||||
resource-manifest.page0.json
|
resource-manifest.page0.json
|
||||||
official-sync-snapshot.json
|
official-sync-snapshot.json
|
||||||
|
glossary-query.json
|
||||||
notes.md
|
notes.md
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -72,6 +74,9 @@ Rust 窗口请基于当前真实代码生成或导出以下 JSON:
|
|||||||
2. `catalog.status` available=false 响应。
|
2. `catalog.status` available=false 响应。
|
||||||
3. `resource.manifest` 第一页响应,至少包含 1 到 2 个 entries。
|
3. `resource.manifest` 第一页响应,至少包含 1 到 2 个 entries。
|
||||||
4. 对应 release 的 `official-sync-snapshot.json`。
|
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 或临时目录,但不能依赖开发机真实资源目录。
|
输出应来自 Rust 代码路径,而不是手写 JSON。允许使用 fixture resource root 或临时目录,但不能依赖开发机真实资源目录。
|
||||||
|
|
||||||
@@ -206,6 +211,9 @@ contract fixture 工作只有在以下条件同时满足时才算完成:
|
|||||||
- `internal/api/testdata/contract/catalog-status.unavailable.json`
|
- `internal/api/testdata/contract/catalog-status.unavailable.json`
|
||||||
- `internal/api/testdata/contract/resource-manifest.page0.json`
|
- `internal/api/testdata/contract/resource-manifest.page0.json`
|
||||||
- `internal/api/testdata/contract/official-sync-snapshot.json`
|
- `internal/api/testdata/contract/official-sync-snapshot.json`
|
||||||
- 原始交接产物仍位于 `/tmp/bat-contract-fixture/`;当前受本地 sandbox 限制,live socket daemon 无法启动,原始 JSON 通过临时 Rust 测试调用同一 dispatch/report 代码路径生成。
|
- 原始交接产物仍位于 `/tmp/bat-contract-fixture/`;仓库内归一化 fixture 用于 schema/mirror 回归,
|
||||||
|
同机 live socket 验证使用 `make bat-api-local-live-smoke`,不依赖该交接目录。
|
||||||
- Go contract 测试读取仓库内归一化 fixture,不依赖 `/tmp/bat-contract-fixture/`、开发机资源目录或远端长期运行的 `bat`。
|
- Go contract 测试读取仓库内归一化 fixture,不依赖 `/tmp/bat-contract-fixture/`、开发机资源目录或远端长期运行的 `bat`。
|
||||||
- 仍未覆盖真实长期 daemon socket 的端到端调用和完整发布切换;该项需要在允许 live daemon / smoke 的隔离环境中单独验证。
|
- 真实长期 daemon 的生产部署仍需由部署环境持续运行;仓库已在本地隔离环境通过真实
|
||||||
|
daemon socket 完成端到端调用、版本切换、无 release、RPC 断线和恢复验证。真实官方
|
||||||
|
网络全量下载仍由 `make official-smoke` 独立负责。
|
||||||
|
|||||||
+175
-539
@@ -1,575 +1,211 @@
|
|||||||
# 当前实现缺口清单
|
# 当前实现缺口清单
|
||||||
|
|
||||||
- **更新时间**:2026-08-03
|
- **更新时间**:2026-09-13
|
||||||
- **Go 进度权威**:`GO_STATUS.md`
|
- **文档角色**:只记录尚未完成、仍需验证或仍需设计的工作,不重复维护完整实现状态。
|
||||||
- **资源布局 / 逆向契约**:`../architecture/resource-release-layout.md`
|
- **当前事实**:以源码、测试、稳定契约和 `CURRENT_STATUS.md` 为准。
|
||||||
- **用途**:集中跟踪当前代码中的占位实现、设计缺口和下一步验收项。
|
- **Go 进度**:`GO_STATUS.md`
|
||||||
|
- **资源布局契约**:`../architecture/resource-release-layout.md`
|
||||||
- **权威计划**:`../../PROJECT_PLAN.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/` 是空目录。
|
- 用真实资源 fixture 覆盖更多 MonoBehaviour、ScriptableObject、Unity 版本差异、
|
||||||
- `git status` 报 `not a git repository`。
|
复杂容器和 managed reference registry/map entry 变体。
|
||||||
|
- 为未知字段补充结构语义;不能把低保真猜测当作已支持格式。
|
||||||
|
- 扩大真实 Unity 版本、复杂容器、未知字段和 managed-reference/map 变体覆盖;
|
||||||
|
当前 V1 不等价于任意 AssetBundle 结构的通用重打包。
|
||||||
|
|
||||||
处理结果:
|
现有证据:`crates/bat-assetbundle` 的单元/压缩/对齐/变长重建测试、隔离真实 UnityFS
|
||||||
|
回归和 `bat-infrastructure` 的解析缓存、ZIP 内 bundle 发布测试。新增格式覆盖必须
|
||||||
|
同时补真实 fixture、回归测试和文档。
|
||||||
|
|
||||||
- 已执行 `git init`。
|
### G-006:通用 Patch 的复杂格式和运维扩展仍未完成
|
||||||
- 已将初始分支调整为 `main`。
|
|
||||||
- 已配置当前路径为 Git safe directory。
|
|
||||||
- `git status --short --branch` 已可用。
|
|
||||||
- 本轮创建首次基线提交。
|
|
||||||
|
|
||||||
限制:
|
状态:**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` 有文件系统存储。
|
当前 JSON/compact catalog 已覆盖 path、hash、size、address、dependencies、
|
||||||
- `infrastructure/src/cas/filesystem.rs` 也实现了文件系统 CAS repository。
|
provider、bundle name、resource type 和 CRC,并有 fixture/golden 回归。
|
||||||
|
|
||||||
处理结果:
|
仍需完成:
|
||||||
|
|
||||||
- `crates/bat-cas-engine` 新增 `repository` 组合层,成为 CAS 核心实现。
|
- 更多 Windows/Android 真实 catalog 形态和失败诊断。
|
||||||
- `infrastructure/src/cas/filesystem.rs` 已改为 `bat-core::CasRepository` 适配层。
|
- 独立二进制 catalog 入口;在未支持前必须明确拒绝,不得静默丢字段。
|
||||||
- infrastructure 不再直接写对象文件,不再维护自己的引用计数逻辑。
|
|
||||||
|
|
||||||
验收证据:
|
### G-009:`bat-api` 仍是资源服务,不是完整官方游戏 API
|
||||||
|
|
||||||
- `bat-cas-engine::repository::FileSystemCasRepository`
|
状态:**资源 bootstrap/分发和管理控制面已可用,业务 API 未完成**
|
||||||
- `bat_infrastructure::FileSystemCasRepository`
|
|
||||||
- `cargo test --workspace`
|
|
||||||
|
|
||||||
### 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`。
|
`bat-api` 不得复制 Rust 下载器、CAS、AssetBundle 解析、Patch 核心算法或同步状态机。
|
||||||
- `remove_reference` 返回固定 `0`。
|
|
||||||
- `get_reference_count` 返回固定 `1`。
|
|
||||||
- `gc` 返回固定 `0`。
|
|
||||||
- `crates/bat-cas-engine/src/refcount.rs` 是占位。
|
|
||||||
|
|
||||||
处理结果:
|
### G-010:完整 Web 协作后台仍未完成
|
||||||
|
|
||||||
- `crates/bat-cas-engine/src/refcount.rs` 使用 SQLite 保存对象元数据和引用计数。
|
状态:**内嵌 dashboard MVP 已完成,完整后台未开始**
|
||||||
- `store()` 会存储对象并增加引用计数。
|
|
||||||
- `add_reference()`、`remove_reference()`、`get_reference_count()` 已持久化。
|
|
||||||
- `gc()` 删除引用计数为 0 的对象和元数据。
|
|
||||||
- `gc_candidates()` 提供 dry-run 能力。
|
|
||||||
|
|
||||||
验收证据:
|
当前页面可以调用已有资源、调度、任务、解析、翻译和 localized 控制接口。
|
||||||
|
|
||||||
- 引用计数增减有持久化测试。
|
仍需完成:
|
||||||
- GC 不删除仍被引用对象。
|
|
||||||
- 并发引用更新测试通过。
|
|
||||||
|
|
||||||
---
|
- 独立登录、角色权限和协作式翻译审核。
|
||||||
|
- Glossary/术语管理、批量审核、搜索和完整历史版本视图。
|
||||||
|
- 构建型前端工程、浏览器 E2E 和完整错误态交互门禁。
|
||||||
|
|
||||||
## 2. 核心功能缺口
|
### G-011:ResourceRepository 查询面仍不完整
|
||||||
|
|
||||||
### 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`。
|
当前已支持 CAS + SQLite 导入、资源类型/release/平台/path/parse status/TextUnit
|
||||||
|
format 等资源级过滤,`parse.text_units` / `parse.errors` 和翻译任务查询也已可用。
|
||||||
现象:
|
|
||||||
|
仍需完成:
|
||||||
- `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。
|
- 更丰富的 TextUnit、翻译记忆和 Patch 发布资源视图。
|
||||||
- `adapters/src/unity/unity_2021_3.rs` 已降为 Unity 版本选择薄层,复用 `bat-assetbundle`,不再维护第二套 UnityFS parser。
|
- 从同一 manifest fingerprint 追溯资源、解析缓存、翻译任务和发布产物。
|
||||||
- `ResourceImportService` 的 UnityFS 摘要已经能暴露解包文件数、serialized file 数、TextAsset 名称、TextUnit 数量/格式和字段诊断。
|
- 更多 schema 迁移、权限、并发和损坏恢复场景验证。
|
||||||
- `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 字段结构语义和发布级重打包入口仍未完成。
|
|
||||||
|
### G-011D:双 release 查询、分发与安全清理
|
||||||
issue 43 例外说明:新增的 `parse repack` 只编排已有 TextAsset、TypeTree string 和受支持语义 field patch 实现;它不新增解析器类型、字段族或 catalog 覆盖。人工翻译工作台和有限 TextAsset 发布也只消费已有 TextUnit 输出。
|
|
||||||
|
状态:**V1 已完成**
|
||||||
影响:
|
|
||||||
|
官方原版和 localized release 已分离,受支持 patch 可独立 staging、校验、发布和
|
||||||
- 可以对 UnityFS 容器做结构校验、解包 directory 文件,并提取 serialized file 中的 TextAsset 原始 bytes。
|
rollback。Rust `release.status` 提供统一 current/source/match、manifest contract、
|
||||||
- 对日语汉化最关键的 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 整体替换,但还不能完成发布级复杂对象重打包。
|
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、状态、
|
||||||
- `crates/bat-assetbundle` 能解析结构化测试样本和隔离真实样本。
|
manifest、CAS 或未知归属引用的普通目录。`localized.status` 还区分 schema/contract 与
|
||||||
- 支持 UnityFS header、blocks、directory、metadata 摘要、directory 文件提取。
|
artifact integrity,损坏产物返回 degraded/corrupt,不自动回滚或删除。
|
||||||
- 支持 Unity serialized file object table、TypeTree node 元数据和 TextAsset 提取的合成 fixture。
|
|
||||||
- 错误包含偏移和字段上下文。
|
rollback 与 cleanup 保持独立;缺少 generic manifest 的旧 localized release 仍可读,
|
||||||
|
明确标记 `legacy`/`unknown`,不会被自动重写。
|
||||||
关闭前仍需:
|
|
||||||
|
本轮 P1 一致性修复已完成:CAS repository 的 store/get/reference/GC 使用跨进程操作锁,
|
||||||
- 冻结解除前不继续扩大 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 变体只记录为冻结后的工作。
|
release-local CAS 引用通过持久化 `ownership_id + ordinal` ledger 幂等释放;没有
|
||||||
- 用真实 fixture 继续覆盖 MonoBehaviour、ScriptableObject 字段级遍历和字符串策略。
|
`ownership_id` 的旧清单按持久化 output-root scope、source mapping 和 generation identity
|
||||||
- 输出可追溯文本定位:bundle path、archive entry、serialized file、path id、class id、field path、字段 offset/byte size。
|
迁移,已有 basename ledger 的部分 cleanup 保持 legacy compatibility key,完成后同名新
|
||||||
- 用真实资源 fixture 覆盖对象级解析、TextAsset 提取和字段级文本提取。
|
generation 使用新的 ownership,不随机迁移已开始的 cleanup。官方历史复用只对不可变文件使用 hard link,`translation-tasks.sqlite` 及 WAL/SHM 始终独立复制;
|
||||||
- 将解析结果作为 Patch 输入;真正的重打包、Patch 生成和 `localized` 发布切换归 G-006/G-011D。
|
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=...)`
|
||||||
- 见 `docs/architecture/assetbundle.md` 的 P2/P3/P5。
|
是单条 lookup,返回 exactly one entry,source identity mismatch 会阻断 localized
|
||||||
|
distribution,完整 identity 校验只在 publish/status/audit 路径执行;分发读取保留 path
|
||||||
### G-006:Patch 引擎基础已落地,发布入口仍未完成
|
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
|
||||||
- `bat-patch::binary` 已提供确定性 Binary hunk diff/apply,应用前校验 source size/BLAKE3,应用后校验 target size/BLAKE3。
|
尚未由本轮处理:ResourceRepository
|
||||||
- `bat-patch::json` 已提供 RFC 6902 JSON Patch apply,覆盖 add/remove/replace/move/copy/test 和 JSON Pointer escape。
|
更完整的查询/权限/损坏恢复、模糊 TM、bat.sock peer credential/perms、FFI 生命周期、
|
||||||
- `bat-patch::text` 已提供 UTF-8 Text Patch,按 source-relative byte range 替换,支持 expected 文本校验、UTF-8 边界校验、source/target BLAKE3 和 size 校验。
|
资源大小/限额与更强的持久化 fsync 语义仍按后续专项推进。
|
||||||
- `bat-patch::manifest` 已定义通用 Patch manifest、文件级 patch kind、source/target BLAKE3、size、rollback 元数据和 manifest 文件完整性校验。
|
|
||||||
- `LocalizedPatchManifest` 可转换为通用 `bat_patch::PatchManifest`,UnityFS TextAsset 发布链路和通用 Patch manifest 已有类型对齐点。
|
### G-012:Translation Memory persistence schema V2 已实现,扩展能力仍缺失
|
||||||
- `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 整体替换会在重建后重新解析校验。
|
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
|
||||||
- 通用 Binary/JSON/Text Patch crate 能力可作为后续发布流程输入。
|
trusted 记录;worker 只自动复用 raw source + 完整 context exact match 的 current
|
||||||
- 文件级写入入口可用于隔离测试和上层工具显式产物生成。
|
trusted,并在复用前执行已批准 Glossary 的确定性 QA。Go `bat-api` 已提供鉴权的
|
||||||
- 发布级通用 `patch build` / `patch rollback`、复杂 AssetBundle 重打包和完整翻译文件集合的正式使用仍未完成;issue 43 的 `parse repack`、`parse clear-cache`、`i18n validate` 与 `i18n publish` 仅覆盖已有 patch 实现支持的安全子集。
|
summary/query/conflicts 只读接口和 confirm/resolve_conflict 转发,但 Go 不持有 TM 状态。
|
||||||
|
仍缺少模糊匹配和更丰富的导入导出历史能力。
|
||||||
验收:
|
|
||||||
|
### G-013:Glossary domain/feature contract V1、persistence schema V2 已实现,协作视图仍缺失
|
||||||
- Binary patch 能完成 diff/apply 往返。
|
|
||||||
- JSON patch 能应用 RFC 6902 patch。
|
Rust `bat` 已提供独立项目级 `glossary.sqlite`,当前 schema version 为 V2;V2 正式
|
||||||
- Patch manifest 包含 hash、版本和回滚信息。
|
吸收历史上未升版本的 `glossary_term_deletions` drift,并将历史 V1-A(无 deletion
|
||||||
- Patch 构建/应用必须写 staging,完整性校验通过后才能发布。
|
audit)和 V1-B(已有 deletion audit)分别纳入显式迁移。打开遵守只读 fingerprint
|
||||||
- `patch.apply` / `patch-apply` 对显式文件执行 apply 时必须原子写目标文件,并返回 source/patch/target hash 与 size。
|
preflight、writer transaction、rollback 和 future/unknown fail-closed 契约,不再
|
||||||
- 失败时不得影响 `bat-resources/current` 或已发布 `bat-localized/current`。
|
使用隐式 `ensure_column` 修复结构。term/alias/recommended/
|
||||||
|
allowed/category/priority、全局与 TextUnit scope、source history、approved review、
|
||||||
### G-007:Addressables Catalog 解析不完整
|
冲突诊断、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 术语协作视图
|
||||||
|
和更丰富的导入/搜索能力。
|
||||||
- `AddressablesCatalogDriver` 已能解析当前真实形态 JSON catalog fixture/golden。
|
|
||||||
- 已输出 path、hash、size、resource_type、address、dependencies、metadata,并已提取 `m_Crc` 到 `crc` 字段。
|
### G-014:完整 Provider 扩展体系未实现
|
||||||
- compact catalog 解析已补充 hash/size/CRC 的非 0 回归、资源计数 metadata,以及 blob 解码失败时的明确错误;不再在 compact 字段损坏时静默退回低保真 `m_InternalIds`。
|
|
||||||
- `bat-core` 已提供 `crc32_ieee` 和 `ResourceEntry::verify_downloaded_bytes`,SQLite `ResourceRepository` 已有 `crc` 列迁移。
|
当前已有 mock/Crowdin provider worker、lease、重试和 TextUnit 结果落库;仍需建立
|
||||||
- 仍需覆盖更多官方 catalog 结构变体、provider/bundle name 持久化字段,以及真实 Windows/Android catalog 样本集合。
|
可替换的 Provider 扩展体系,以及批处理、限流、成本统计和质量检查。
|
||||||
|
|
||||||
影响:
|
## 2. 已确定的架构边界
|
||||||
|
|
||||||
- 当前解析能力可以服务 Manifest inspect 和部分资源索引,但还不能宣称完整兼容所有 Unity Addressables/SBP catalog 形态。
|
以下内容不是待实现的重复任务:
|
||||||
|
|
||||||
验收:
|
1. 正式资源同步和运维命令行是 Rust `bat`;不另做产品级 Go 同步 CLI。
|
||||||
|
2. Rust `bat` / daemon 是资源生产者和状态拥有者;Go `bat-api` 只读取已发布资源,
|
||||||
- 能解析项目目标版本的真实 Catalog 样本集合。
|
通过 `bat.sock` 提供 bootstrap、分发和受限管理入口。
|
||||||
- 解析结果包含资源 key、provider、dependency、hash、size、path、CRC。
|
3. `bat-api` 是资源 bootstrap/分发服务,**不是完整官方游戏 API**。
|
||||||
- 对不支持的 catalog 结构返回明确错误,而不是静默丢字段。
|
4. `bat-ffi` 只保留无状态兼容 helper,不承载 daemon、下载器、CAS handle 或主控制面。
|
||||||
- 解析结果能反查 bundle 文件、依赖链和本地下载 manifest 条目。
|
5. 官方原版 release 和 localized release 使用独立目录、staging、manifest、current
|
||||||
- Windows/Android 样本集合需要覆盖 JSON、compact JSON 和后续二进制 catalog 入口。
|
和 rollback 生命周期。
|
||||||
|
6. `daemon.clean-stable` 是 CLI 生命周期清理入口,不在 live RPC 内执行在线清理;
|
||||||
解析补全路线图:
|
`task.create` 也不作为通用 RPC 入口开放。
|
||||||
|
7. `status` / `status_code` 描述生命周期,`BAT-ERR-*` 描述错误;两者不混用。
|
||||||
- 见 `docs/architecture/assetbundle.md` 的 P1。
|
|
||||||
|
详细阶段报告仍保留在 `docs/reports/historical/`,不作为当前实现依据。
|
||||||
---
|
|
||||||
|
## 3. 后续推进顺序
|
||||||
## 3. 应用层缺口
|
|
||||||
|
1. 继续 G-005:更多真实 AssetBundle 样本、复杂字段解析、版本差异和任意结构重打包。
|
||||||
### G-008:Go 同步/运维 CLI 产品入口
|
2. 继续 G-006:复杂 AssetBundle 兼容和真实样本覆盖;G-011D 的双 release 运维 V1
|
||||||
|
已完成,后续 retention scheduler 不属于本次闭环。
|
||||||
状态:**已决策关闭(wontfix)**
|
3. 继续 G-011/G-012/G-013/G-014:资源查询、TM/Glossary 扩展和 Provider
|
||||||
|
扩展体系。
|
||||||
决策(2026-07-24,见 `GO_STATUS.md`):
|
4. 在隔离环境执行 `make official-smoke`,补充真实网络长期运行报告。
|
||||||
|
5. 最后推进完整 Web 协作后台和完整游戏业务 API。
|
||||||
- **正式同步/运维命令行 = 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 RPC 的 `resource.state`、`catalog.status`、`parse.status` 和 `localized.status` 会返回短状态 `status` 与稳定生命周期状态码 `status_code`;`backendrpc` 已提供对应 client 能力,当前 `bat-api` 发现流程只消费 `resource.state`、`catalog.status`、`resource.manifest`,parse/localized 不由 HTTP surface 暴露;错误原因仍以 `BAT-ERR-*` 为准。
|
|
||||||
- `bat-api` 可通过受限 Web 控制面转发 `reload` / `refresh` / `restart` / `sync` / `verify` / `repair` / `catalog-refresh`,这些控制动作均走 Rust live RPC;`parse.*`、`localized.status` 和文件级 `unityfs.patch_*` 属于 `backendrpc` 能力,但当前不由 HTTP surface 暴露;`daemon.clean-stable` 等危险或离线生命周期命令不经 Web 转发。
|
|
||||||
- 生产与 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`
|
|
||||||
- Rust snapshot schema 与 Go mirror struct 的仓库内 contract fixture 已完成,文件位于
|
|
||||||
`internal/api/testdata/contract/`;剩余是 live daemon socket 和完整 release 切换联调。
|
|
||||||
|
|
||||||
验收(剩余):
|
|
||||||
|
|
||||||
- 与远程长期运行的 `bat` / 全量 release 联调(覆盖 bootstrap、server-info、CDN path;SSH 实勘可后置)
|
|
||||||
- refresh 中 manifest 磁盘校验的 mtime/size 增量缓存优化(真实全量 release 观测后决定)
|
|
||||||
- 文档与 GO_STATUS 持续一致
|
|
||||||
|
|
||||||
排期:P2 主体可联调;持久化 API 层与完整 launcher/业务链另议。
|
|
||||||
|
|
||||||
### G-010:Web 管理后台尚未实现
|
|
||||||
|
|
||||||
现象:
|
|
||||||
|
|
||||||
- `web/` 只有目录结构。
|
|
||||||
|
|
||||||
影响:
|
|
||||||
|
|
||||||
- 翻译审核、术语管理、Dashboard 无 UI。
|
|
||||||
|
|
||||||
验收:
|
|
||||||
|
|
||||||
- 登录、权限、翻译审核、术语管理基础流程可用。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 数据与翻译缺口
|
|
||||||
|
|
||||||
### G-011:Resource Repository 查询面仍不完整
|
|
||||||
|
|
||||||
状态:**部分关闭**
|
|
||||||
|
|
||||||
影响:
|
|
||||||
|
|
||||||
- `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/CLI 已能按资源类型、hash、路径模式、官方 release ID、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 分页查询现有 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` 和 `translation-tasks.sqlite`;删除资源不会进入队列。
|
|
||||||
- 官方 release 发布后还会写入版本化 `translation-handoff.json`;`translation.handoff` RPC/CLI 动态合并该快照与 SQLite worker 状态,暴露 job、unit、provider run、attempt 和 failure reason。
|
|
||||||
- `translation-tasks.sqlite` 由 `schema_migrations` 管理 durable task state、attempt count、provider run ID 和 failure reason;重复同步会保留已有 worker 状态。
|
|
||||||
- `translation.tasks` RPC/CLI 优先查询 `translation-tasks.sqlite`,旧 release 没有状态库时回退到 `official-textunit-tasks.json`;返回队列 `status`、worker `task_status`、failure reason 和时间/尝试次数。
|
|
||||||
- `translation.task.update` 已提供 queued/running/failed/completed/skipped 状态回写契约,并已通过 `bat i18n task update`、Go typed helper 和 bat-api 鉴权控制面暴露;provider worker 可在消费 handoff 后按 task_id 更新并由同一查询接口反查。
|
|
||||||
- `i18n get/set/unset` 可查看、修改或清空单条工作台译文,`i18n validate` 可在发布前校验工作台 release、source text、重复 patch 目标,并区分可直接发布的 TextAsset 与必须进入 `parse repack` 的条目;`parse clear-cache` 只删除可再生解析/队列 JSON,保留 `translation-tasks.sqlite` 的 worker 状态。
|
|
||||||
- 真实 Crowdin 网络 worker、翻译记忆和完整 localized repack 仍属于后续翻译系统工作,不在当前 Rust 离线状态仓储范围内。
|
|
||||||
|
|
||||||
验收:
|
|
||||||
|
|
||||||
- schema 和迁移可重复执行。
|
|
||||||
- 可按版本、平台、类型、hash、路径、destination、archive entry、parse status 和 TextUnit format 查询资源,且能明确区分索引缺失、版本缺失和空结果。
|
|
||||||
- 官方同步后的资源可通过 CLI/RPC 查询并能追溯到 CAS 对象。
|
|
||||||
- `official-parse-cache.json` 的 bundle、zip entry、TextAsset 和 TextUnit 摘要能进入 ResourceRepository 查询面。
|
|
||||||
- `parse.status` 能报告 TextUnit 索引、TextUnit 队列路径与摘要。
|
|
||||||
- `parse.text_units` / `parse.errors` 能分页查询当前 release 的 TextUnit 明细和解析错误。
|
|
||||||
- 离线 TextUnit 翻译任务状态和跳过/失败 reason 可反查到对应官方 release、资源 destination 和 archive entry。
|
|
||||||
- Crowdin handoff 被后续翻译 worker 消费后,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` 或误报手工状态;其中 `status` / `status_code` 返回生命周期状态,`localized_release_status` 保留 `localized` / `not_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 构建/测试、Go API 门禁和文档状态门禁,不改变“不引入托管 CI”的决策。
|
|
||||||
- workflow 不使用外部 GitHub Action;它通过 runner 环境变量手动 `git fetch` 当前提交,并要求 runner 预装 `git`、Rust stable、rustfmt、clippy 和 Go,避免准备阶段因第三方 action 仓库代理或网络限制失败。
|
|
||||||
- 质量门禁由本地默认验证命令和自托管 workflow 共同承担:提交前执行 `cargo fmt` / `cargo clippy --workspace --all-targets -- -D warnings` / `cargo test --workspace`、Go API 门禁和 `make check-docs`(见 `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 #33/#35 已补有界 downloader
|
|
||||||
scheduler 和默认并发 8,范围 `1..=256`,worker 完成后立即领取下一个任务,
|
|
||||||
进度即时按完成数上报,最终 report 保持 plan 顺序。
|
|
||||||
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 已关闭。
|
|
||||||
|
|||||||
+26
-32
@@ -1,8 +1,8 @@
|
|||||||
# Go 侧进度与边界(权威)
|
# Go 侧进度与边界(权威)
|
||||||
|
|
||||||
- **更新时间**:2026-08-03
|
- **更新时间**:2026-09-12
|
||||||
- **用途**:统一 Go module `bat-api` 的产品边界、既有约定和组件进度;其他文档与此冲突时以本文为准。
|
- **用途**:统一 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 / 运维子命令 | **是(同步与运维命令行)** |
|
| **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 演示骨架 | **否** |
|
| **Go 试验 CLI** | `cmd/bat` → `bin/bat-go`(不得再叫 `bin/bat`) | FFI 演示骨架 | **否** |
|
||||||
|
|
||||||
### 1.1 「同步命令行 = Rust `bat`」的含义
|
### 1.1 「同步命令行 = Rust `bat`」的含义
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
这**不是**说整个项目只有 Rust,也**不是**取消 Go 入口:
|
这**不是**说整个项目只有 Rust,也**不是**取消 Go 入口:
|
||||||
|
|
||||||
- Go 的正式产品入口是 **`bat-api` 服务进程**(给客户端/工具提供启动前资源 bootstrap、server-info 改写和已发布资源字节),不是再做一套同步 CLI。
|
- Go 的正式产品入口是 **`bat-api` 服务进程**(给客户端/工具提供启动前资源 bootstrap、server-info 改写、已发布资源字节和内嵌管理 dashboard),不是再做一套同步 CLI。
|
||||||
- Go `cmd/bat` 仅试验,禁止与 Rust `bat` 二进制重名。
|
- Go `cmd/bat` 仅试验,禁止与 Rust `bat` 二进制重名。
|
||||||
|
|
||||||
### 1.2 `bat` 与 `bat-api` 的关系
|
### 1.2 `bat` 与 `bat-api` 的关系
|
||||||
@@ -31,16 +31,16 @@
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 资源发现 | 读取官方 launcher/resource metadata,解析 `GameMainConfig`、server-info 和 Addressables root | 通过 `bat.sock` 读取已发布版本摘要,不重新探测官方 metadata |
|
| 资源发现 | 读取官方 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` |
|
| 下载与发布 | 下载、校验、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,组织给客户端/补丁器使用 |
|
| 启动前资源入口 | 暴露 `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,不持有或写入同步状态 |
|
| 长期状态 | 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 下载器或伪装完整游戏业务服务。
|
这条边界允许 `bat-api` 做资源 bootstrap 兼容,但不允许它复制 Rust 下载器或伪装完整游戏业务服务。
|
||||||
|
|
||||||
### 1.3 决策(已核验)
|
### 1.3 决策(已核验)
|
||||||
|
|
||||||
1. **G-008 决策关闭(wontfix)**:不另做产品级 Go 同步/运维 CLI。
|
1. **Go 同步 CLI 边界已确定**:不另做产品级 Go 同步/运维 CLI,正式入口是 Rust `bat`。
|
||||||
2. **G-009**:资源 bootstrap/分发 MVP 部分完成;非完整游戏业务 API。
|
2. **G-009**:资源 bootstrap/分发 MVP 部分完成;非完整游戏业务 API。
|
||||||
3. **USERGUIDE 的 bat-api 基础章节已补**;全量 release 联调后继续补充生产参数和排障样例。
|
3. **USERGUIDE 的 bat-api 基础章节已补**;同机 live 联调 runbook 和内嵌 dashboard MVP 已补,真实官方网络下载仍由独立 smoke 负责。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -54,18 +54,18 @@
|
|||||||
| B | **`bat-api` 只读分发**已发布 release,不实现下载器,不写 staging/version-state |
|
| B | **`bat-api` 只读分发**已发布 release,不实现下载器,不写 staging/version-state |
|
||||||
| C | 仿真范围 = **资源拉取相关**(resource bootstrap + CDN path + 可选 server-info);**不是**完整游戏业务 API |
|
| C | 仿真范围 = **资源拉取相关**(resource bootstrap + CDN path + 可选 server-info);**不是**完整游戏业务 API |
|
||||||
| D | launcher 资源 metadata 可作为 bootstrap 输入/输出兼容;账号、登录、网关和鉴权全链 **非 G-009 关闭条件** |
|
| D | launcher 资源 metadata 可作为 bootstrap 输入/输出兼容;账号、登录、网关和鉴权全链 **非 G-009 关闭条件** |
|
||||||
| E | USERGUIDE bat-api 基础章节已补;联调后补充实战样例 |
|
| E | USERGUIDE bat-api 基础章节和同机 live smoke 实战样例已补 |
|
||||||
|
|
||||||
### 发现与数据
|
### 发现与数据
|
||||||
|
|
||||||
| ID | 约定 |
|
| ID | 约定 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| F | 版本/清单经 **`bat.sock` JSON-RPC**(`--socket`);不读 daemon 内部状态文件 |
|
| 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 或应急只读诊断 |
|
| H | 生产文件字节从 RPC 返回的 `resource_root` 读盘;`bat-api` 与 daemon 同服务器/同容器/共享文件系统部署;`--resource-root` 仅 fixture 或应急只读诊断 |
|
||||||
| I | 真数据在**已全量拉取且长期运行 Rust `bat` 的远程服务器**;开发机不跑全量 `bat`,用 fixture、mock RPC 和 Go 门禁验证;远程联调等连接信息 |
|
| I | 生产中 Rust `bat` 与 `bat-api` 在同一主机/容器/共享文件系统;开发用 `/tmp` fixture 和真实本地 `bat.sock` smoke,不依赖远程连接 |
|
||||||
| J | 索引以 **manifest + 磁盘 Present/size** 为准 |
|
| J | Go 索引以 **manifest + 磁盘 Present/size** 建立可读快照,但不将其当作 release integrity |
|
||||||
| J2 | RPC 状态以 Rust 返回的 `status` / `status_code` 为准;`bat-api` 只读消费,不自行推导同步状态 |
|
| J2 | 普通 current 分发以 Rust `release.attestation` 的 `ready`、identity、freshness 和 integrity/status code 为准;`bat-api` 只读消费,不自行推导 verifier;`release.status` 保留为重型管理诊断 |
|
||||||
|
|
||||||
### 进程配置
|
### 进程配置
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
| O | 权威文档与 `go list` 一致,禁止「API 完全没有」等过时句 |
|
| O | 权威文档与 `go list` 一致,禁止「API 完全没有」等过时句 |
|
||||||
| P | 试验 CLI 产物 **`bin/bat-go`**,禁止 `bin/bat` |
|
| P | 试验 CLI 产物 **`bin/bat-go`**,禁止 `bin/bat` |
|
||||||
| Q | 空目录标明 reserved empty |
|
| Q | 空目录标明 reserved empty |
|
||||||
| R | 默认门禁:`make test-go-api` + `make build-go-api` + `make check-docs`(无 FFI) |
|
| R | 默认门禁:`make ci-check`;其中 Go 使用纯 API test/vet/build、required `golangci-lint 2.12.2`(无 FFI),缺失或版本不匹配失败 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -95,12 +95,12 @@
|
|||||||
| 组件 | 路径 | 状态 | 说明 |
|
| 组件 | 路径 | 状态 | 说明 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Module | `go.mod` → `bat-api` | 已用 | 服务层模块名 |
|
| Module | `go.mod` → `bat-api` | 已用 | 服务层模块名 |
|
||||||
| RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helper;typed helper 覆盖 daemon 已实现控制/查询、`resource.state/sync/verify/repair/manifest/list`、`catalog.*`、`parse.*`、`localized.status`、`task.*` 和文件级 UnityFS patch 调用;`resource.index`、`translation.*`、`patch.apply` 仍通过通用 `Call` 走同一 contract;fake transport 单测,配合 `internal/api/testdata/contract/` 固化 Rust 输出 mirror |
|
| RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helper;typed helper 覆盖 daemon 已实现控制/查询、`resource.state/sync/verify/repair/manifest/list`、`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` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理控制白名单 + `.env` |
|
| 资源 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 |
|
| 试验 CLI | `cmd/bat` | **试验** | doctor 固定 ok;manifest/sync 走 FFI |
|
||||||
| FFI | `internal/ffi` | **可选** | 需 `build-ffi` |
|
| FFI | `internal/ffi` | **可选** | 需 `build-ffi` |
|
||||||
| 空骨架 | `api/`、`pkg/*`、部分 `internal/*` | **空** | 见各目录 README |
|
| 空骨架 | `api/`、`pkg/*`、部分 `internal/*` | **空** | 见各目录 README |
|
||||||
| Web | `web/` | **空** | G-010 |
|
| Web | `web/` | **内嵌 dashboard MVP** | 完整协作后台、登录/角色和术语管理仍属 G-010 剩余 |
|
||||||
|
|
||||||
`go list ./...` 当前包:
|
`go list ./...` 当前包:
|
||||||
|
|
||||||
@@ -116,10 +116,7 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 默认(提交前 / CI 建议)
|
# 默认(提交前 / CI 建议)
|
||||||
make test-go-api
|
make ci-check
|
||||||
make build-go-api
|
|
||||||
go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/...
|
|
||||||
make check-docs
|
|
||||||
|
|
||||||
# 可选:改 FFI 或试验 CLI 时
|
# 可选:改 FFI 或试验 CLI 时
|
||||||
make build-ffi
|
make build-ffi
|
||||||
@@ -129,26 +126,23 @@ make build-go-cli # 产出 bin/bat-go
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 与缺口 / issue 对应
|
## 5. 与缺口对应
|
||||||
|
|
||||||
| 项 | 状态 |
|
| 项 | 状态 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| G-008 Go 同步 CLI | **已决策关闭**(正式同步 CLI = Rust `bat`) |
|
| Go 同步 CLI | **边界已确定**(正式同步 CLI = Rust `bat`) |
|
||||||
| G-009 bat-api 资源 bootstrap/分发 | **部分完成**(MVP+生产控制面);已含资源 bootstrap 关系面、launcher 资源 metadata 兼容、HTTP 鉴权/限流/日志/反代适配、RPC 周期刷新/诊断、readiness、OpenAPI、管理控制白名单、Rust-owned `schedule.*` dashboard 代理、`translation.task.update` 状态回写代理和部署模板,后续远程服务器联调/可选持久化 |
|
| 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 和部署模板;持久化仍另议 |
|
||||||
| issue #19 | 资源面 MVP 与 USERGUIDE 基础章节已编码;真机联调后继续补充实战样例;**未自动关 issue** |
|
| G-010 Web | 内嵌 dashboard MVP 已完成;完整协作后台、登录/角色、术语管理和构建型前端未开始 |
|
||||||
| G-010 Web | 未开始 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 资源布局与逆向
|
## 6. 资源布局与逆向
|
||||||
|
|
||||||
- **Release / URL / 分发契约(权威)**:`docs/architecture/resource-release-layout.md`
|
- **Release / URL / 分发契约(权威)**:`docs/architecture/resource-release-layout.md`
|
||||||
- 真机全量实勘、seed inventory diff、issue #2/#3 样本采集按该文档 §9–§10 执行
|
- 真机全量实勘、seed inventory diff 和样本采集按该文档 §9–§10 执行
|
||||||
|
|
||||||
## 7. 后续(不在进度统一范围内)
|
## 7. 后续(不在进度统一范围内)
|
||||||
|
|
||||||
1. 服务器 SSH 只读实勘(连接信息到位后)
|
1. 预留 database/redis 的接入时机另议
|
||||||
2. bat-api 与远程长期运行的 `bat` / 全量 release 联调(含 `/v1/bootstrap`、server-info 和 CDN path)
|
2. 真实官方网络全量下载长期运行报告(使用 `make official-smoke`,与同机 live 联调独立)
|
||||||
3. 预留 database/redis 的接入时机另议
|
3. launcher 完整安装包更新链 / 登录网关链(如需推进,应另立范围明确的后续需求)
|
||||||
4. USERGUIDE bat-api 联调排障样例(全量 release 验证后)
|
|
||||||
5. launcher 完整安装包更新链 / 登录网关链(若需要,新 issue)
|
|
||||||
|
|||||||
@@ -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 解析能力,把当前工作重心切换到运行稳定性、代码审核问题、文档一致性和发布链路可靠性。
|
冻结目标:停止继续扩大 UnityFS / AssetBundle / Addressables / TypeTree 解析能力,把当前工作重心切换到运行稳定性、代码审核问题、文档一致性和发布链路可靠性。
|
||||||
|
|
||||||
@@ -37,6 +47,18 @@
|
|||||||
|
|
||||||
该例外不解冻解析器,不新增 UnityFS/AssetBundle/Addressables/TypeTree 解析类型、字段覆盖、catalog 结构或合成 fixture 能力。后续任何扩大解析覆盖的变更仍需单独解冻授权。
|
该例外不解冻解析器,不新增 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/`:早期质量状态报告。
|
- `quality/`:早期质量状态报告。
|
||||||
- `build-logs/`:历史构建、测试和 Clippy 输出。
|
- `build-logs/`:历史构建、测试和 Clippy 输出。
|
||||||
- `nested-docs/`:从误嵌套 `docs/docs` 移出的历史报告。
|
- `nested-docs/`:从误嵌套 `docs/docs` 移出的历史报告。
|
||||||
|
- `PARSER_FREEZE.md`:2026-07-30 生效、2026-09-04 解除的解析模块维护冻结记录。
|
||||||
|
|
||||||
新增运行产物、smoke 输出、质量扫描输出和本地分析报告不要放入本目录;这些文件应写入 `/tmp`、显式的隔离输出目录,或被 `.gitignore` 覆盖的本地生成报告目录。
|
新增运行产物、smoke 输出、质量扫描输出和本地分析报告不要放入本目录;这些文件应写入 `/tmp`、显式的隔离输出目录,或被 `.gitignore` 覆盖的本地生成报告目录。
|
||||||
|
|||||||
@@ -70,12 +70,12 @@
|
|||||||
## 📚 重要文档索引
|
## 📚 重要文档索引
|
||||||
|
|
||||||
### 架构和设计
|
### 架构和设计
|
||||||
- `docs/ARCHITECTURE_REVIEW.md` - 完整架构审查(1903行)
|
- `docs/archive/ARCHITECTURE_REVIEW.md` - 完整架构审查(1903行)
|
||||||
- `docs/BLUE_ARCHIVE_TECHNICAL_ANALYSIS.md` - 技术分析
|
- `docs/archive/BLUE_ARCHIVE_TECHNICAL_ANALYSIS.md` - 技术分析
|
||||||
- `docs/CODE_QUALITY_IMPROVEMENT.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 最终报告
|
- `PHASE_1_WEEK_1_FINAL_REPORT.md` - Week 1 最终报告
|
||||||
|
|
||||||
### 代码质量
|
### 代码质量
|
||||||
|
|||||||
@@ -109,11 +109,11 @@ BlueArchiveToolkit/
|
|||||||
|
|
||||||
## 📚 创建的文档
|
## 📚 创建的文档
|
||||||
|
|
||||||
1. ✅ [ARCHITECTURE_REVIEW.md](./docs/ARCHITECTURE_REVIEW.md) - 完整架构审查(1903 行)
|
1. ✅ [ARCHITECTURE_REVIEW.md](../../../archive/ARCHITECTURE_REVIEW.md) - 完整架构审查(1903 行)
|
||||||
2. ✅ [BLUE_ARCHIVE_TECHNICAL_ANALYSIS.md](./docs/BLUE_ARCHIVE_TECHNICAL_ANALYSIS.md) - 技术分析报告
|
2. ✅ [BLUE_ARCHIVE_TECHNICAL_ANALYSIS.md](../../../archive/BLUE_ARCHIVE_TECHNICAL_ANALYSIS.md) - 技术分析报告
|
||||||
3. ✅ [PHASE_0.5_REPORT.md](./docs/PHASE_0.5_REPORT.md) - 深度验证报告
|
3. ✅ [PHASE_0.5_REPORT.md](../nested-docs/PHASE_0.5_REPORT.md) - 深度验证报告
|
||||||
4. ✅ [PHASE_1_WEEK_1_COMPLETE.md](./docs/PHASE_1_WEEK_1_COMPLETE.md) - Week 1 详细报告
|
4. ✅ [PHASE_1_WEEK_1_COMPLETE.md](../nested-docs/PHASE_1_WEEK_1_COMPLETE.md) - Week 1 详细报告
|
||||||
5. ✅ [WEEK_1_VERIFIED.md](./WEEK_1_VERIFIED.md) - 最终验证报告
|
5. `WEEK_1_VERIFIED.md` - 原报告未纳入当前归档。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+2914
-1503
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())
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use super::report_output::print_report;
|
||||||
|
|
||||||
pub(super) fn run_write_patch_command(options: &CliOptions) -> anyhow::Result<()> {
|
pub(super) fn run_write_patch_command(options: &CliOptions) -> anyhow::Result<()> {
|
||||||
match options.command {
|
match options.command {
|
||||||
CliCommand::PatchApply => {
|
CliCommand::PatchApply => {
|
||||||
@@ -170,6 +172,7 @@ fn reject_patch_apply_options(options: &CliOptions, command: &str) -> anyhow::Re
|
|||||||
if options.patch_kind.is_some()
|
if options.patch_kind.is_some()
|
||||||
|| options.patch_source_path.is_some()
|
|| options.patch_source_path.is_some()
|
||||||
|| options.patch_patch_path.is_some()
|
|| options.patch_patch_path.is_some()
|
||||||
|
|| options.patch_manifest.is_some()
|
||||||
{
|
{
|
||||||
return Err(anyhow::anyhow!(
|
return Err(anyhow::anyhow!(
|
||||||
"{command} 不接受 --patch-kind、--source-file 或 --patch-file"
|
"{command} 不接受 --patch-kind、--source-file 或 --patch-file"
|
||||||
@@ -189,6 +192,7 @@ fn reject_unityfs_write_options(options: &CliOptions, command: &str) -> anyhow::
|
|||||||
|| options.unityfs_expected_value.is_some()
|
|| options.unityfs_expected_value.is_some()
|
||||||
|| options.unityfs_replacement_value.is_some()
|
|| options.unityfs_replacement_value.is_some()
|
||||||
|| options.unityfs_expected_semantic_value.is_some()
|
|| options.unityfs_expected_semantic_value.is_some()
|
||||||
|
|| options.patch_manifest.is_some()
|
||||||
{
|
{
|
||||||
return Err(anyhow::anyhow!(
|
return Err(anyhow::anyhow!(
|
||||||
"{command} 不接受 UnityFS 写入参数;请改用 unityfs-patch-* 命令"
|
"{command} 不接受 UnityFS 写入参数;请改用 unityfs-patch-* 命令"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use super::report_output::print_json_value;
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
pub(super) fn run_readonly_query_command(options: &CliOptions) -> anyhow::Result<()> {
|
pub(super) fn run_readonly_query_command(options: &CliOptions) -> anyhow::Result<()> {
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use super::report_output::print_json_value;
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
const SCHEDULES_FILE_NAME: &str = "bat-schedules.json";
|
const SCHEDULES_FILE_NAME: &str = "bat-schedules.json";
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ pub(super) const MAX_RETAINED_TASKS: usize = 64;
|
|||||||
/// 每个任务保留的进度日志行数上限。
|
/// 每个任务保留的进度日志行数上限。
|
||||||
pub(super) const MAX_TASK_LOG_LINES: usize = 200;
|
pub(super) const MAX_TASK_LOG_LINES: usize = 200;
|
||||||
|
|
||||||
/// 任务类型:目前覆盖官方同步、校验与 catalog 更新检查。
|
/// 任务类型:覆盖资源同步、校验、修复、翻译 worker 与 catalog 更新检查。
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub(super) enum TaskKind {
|
pub(super) enum TaskKind {
|
||||||
Sync,
|
Sync,
|
||||||
Verify,
|
Verify,
|
||||||
Repair,
|
Repair,
|
||||||
|
/// 翻译 provider worker 轮次。
|
||||||
|
TranslationWorker,
|
||||||
/// catalog 更新检查:只做发现 + 拉取计划(dry-run),不下载不审计。
|
/// catalog 更新检查:只做发现 + 拉取计划(dry-run),不下载不审计。
|
||||||
Refresh,
|
Refresh,
|
||||||
}
|
}
|
||||||
@@ -20,6 +22,7 @@ impl TaskKind {
|
|||||||
Self::Sync => RPC_METHOD_RESOURCE_SYNC,
|
Self::Sync => RPC_METHOD_RESOURCE_SYNC,
|
||||||
Self::Verify => RPC_METHOD_RESOURCE_VERIFY,
|
Self::Verify => RPC_METHOD_RESOURCE_VERIFY,
|
||||||
Self::Repair => RPC_METHOD_RESOURCE_REPAIR,
|
Self::Repair => RPC_METHOD_RESOURCE_REPAIR,
|
||||||
|
Self::TranslationWorker => RPC_METHOD_TRANSLATION_WORKER_RUN,
|
||||||
Self::Refresh => RPC_METHOD_CATALOG_REFRESH,
|
Self::Refresh => RPC_METHOD_CATALOG_REFRESH,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,6 +59,10 @@ impl TaskKind {
|
|||||||
config.repair = false;
|
config.repair = false;
|
||||||
config.force = force;
|
config.force = force;
|
||||||
}
|
}
|
||||||
|
Self::TranslationWorker => {
|
||||||
|
config.dry_run = false;
|
||||||
|
config.force = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
config
|
config
|
||||||
}
|
}
|
||||||
@@ -155,6 +162,7 @@ fn task_kind_static(kind: &str) -> Option<&'static str> {
|
|||||||
RPC_METHOD_RESOURCE_SYNC => Some(RPC_METHOD_RESOURCE_SYNC),
|
RPC_METHOD_RESOURCE_SYNC => Some(RPC_METHOD_RESOURCE_SYNC),
|
||||||
RPC_METHOD_RESOURCE_VERIFY => Some(RPC_METHOD_RESOURCE_VERIFY),
|
RPC_METHOD_RESOURCE_VERIFY => Some(RPC_METHOD_RESOURCE_VERIFY),
|
||||||
RPC_METHOD_RESOURCE_REPAIR => Some(RPC_METHOD_RESOURCE_REPAIR),
|
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),
|
RPC_METHOD_CATALOG_REFRESH => Some(RPC_METHOD_CATALOG_REFRESH),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
@@ -467,10 +475,14 @@ impl TaskStore {
|
|||||||
Ok(bytes) => {
|
Ok(bytes) => {
|
||||||
if let Err(error) = write_file_atomic(path, &bytes, PRIVATE_FILE_MODE, "任务历史")
|
if let Err(error) = write_file_atomic(path, &bytes, PRIVATE_FILE_MODE, "任务历史")
|
||||||
{
|
{
|
||||||
eprintln!("[daemon] 任务历史落盘失败:{error}");
|
super::terminal_output::print_daemon_error(format!(
|
||||||
|
"任务历史落盘失败:{error}"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(error) => eprintln!("[daemon] 任务历史序列化失败:{error}"),
|
Err(error) => {
|
||||||
|
super::terminal_output::print_daemon_error(format!("任务历史序列化失败:{error}"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,7 +506,9 @@ impl TaskStore {
|
|||||||
/// 提交给任务 worker 的作业(配置已按任务类型派生完毕)。
|
/// 提交给任务 worker 的作业(配置已按任务类型派生完毕)。
|
||||||
pub(super) struct TaskJob {
|
pub(super) struct TaskJob {
|
||||||
pub(super) id: String,
|
pub(super) id: String,
|
||||||
|
pub(super) kind: TaskKind,
|
||||||
pub(super) config: OfficialUpdateConfig,
|
pub(super) config: OfficialUpdateConfig,
|
||||||
|
pub(super) translation_worker_config: Option<TranslationWorkerConfig>,
|
||||||
/// 与任务记录共享的取消标志。
|
/// 与任务记录共享的取消标志。
|
||||||
pub(super) cancel: Arc<AtomicBool>,
|
pub(super) cancel: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
@@ -505,6 +519,10 @@ pub(super) struct DaemonTaskContext {
|
|||||||
pub(super) registry: TaskRegistry,
|
pub(super) registry: TaskRegistry,
|
||||||
pub(super) queue: mpsc::Sender<TaskJob>,
|
pub(super) queue: mpsc::Sender<TaskJob>,
|
||||||
pub(super) base_config: OfficialUpdateConfig,
|
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) restart_controller: DaemonRestartController,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,8 +537,8 @@ pub(super) fn run_task_worker(
|
|||||||
registry: TaskRegistry,
|
registry: TaskRegistry,
|
||||||
sync_lock: Arc<Mutex<()>>,
|
sync_lock: Arc<Mutex<()>>,
|
||||||
control: DaemonControl,
|
control: DaemonControl,
|
||||||
|
service: OfficialUpdateService,
|
||||||
) {
|
) {
|
||||||
let service = OfficialUpdateService::new();
|
|
||||||
for job in receiver {
|
for job in receiver {
|
||||||
registry.update(&job.id, |record| {
|
registry.update(&job.id, |record| {
|
||||||
record.status = "running";
|
record.status = "running";
|
||||||
@@ -528,39 +546,100 @@ pub(super) fn run_task_worker(
|
|||||||
});
|
});
|
||||||
|
|
||||||
let cancel = Arc::clone(&job.cancel);
|
let cancel = Arc::clone(&job.cancel);
|
||||||
let run_result = {
|
let run_result = if job.kind == TaskKind::TranslationWorker {
|
||||||
let _sync_guard = sync_lock
|
let worker_config = job
|
||||||
.lock()
|
.translation_worker_config
|
||||||
.unwrap_or_else(|poison| poison.into_inner());
|
.as_ref()
|
||||||
let progress_registry = registry.clone();
|
.ok_or_else(|| anyhow::anyhow!("translation worker 任务缺少运行配置"));
|
||||||
let progress_id = job.id.clone();
|
worker_config.and_then(|worker_config| {
|
||||||
let cancel_check = Arc::clone(&cancel);
|
let _sync_guard = sync_lock
|
||||||
let stop_control = Arc::clone(&control);
|
.lock()
|
||||||
service.run_with_progress_and_cancellation(
|
.unwrap_or_else(|poison| poison.into_inner());
|
||||||
&job.config,
|
registry.append_log(&job.id, "[translation-worker] 开始执行".to_string());
|
||||||
|event| {
|
registry.update(&job.id, |record| {
|
||||||
progress_registry
|
record.stage = Some("translation-worker".to_string());
|
||||||
.append_log(&progress_id, format!("[{}] {}", event.stage, event.message));
|
record.message = Some("翻译 provider worker 正在执行".to_string());
|
||||||
progress_registry.update(&progress_id, |record| {
|
});
|
||||||
record.stage = Some(event.stage.to_string());
|
let resource_root = active_official_resource_root(&job.config.output_root)?;
|
||||||
record.message = Some(event.message.clone());
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
});
|
.enable_all()
|
||||||
},
|
.build()?;
|
||||||
|| {
|
let cancel_check = Arc::clone(&cancel);
|
||||||
cancel_check.load(Ordering::Relaxed)
|
runtime
|
||||||
|| daemon_control_stop_requested(Some(&stop_control))
|
.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 {
|
match run_result {
|
||||||
Ok(report) => registry.update(&job.id, |record| {
|
Ok(report) => registry.update(&job.id, |record| {
|
||||||
record.status = "succeeded";
|
record.status = "succeeded";
|
||||||
record.finished_at = Some(unix_seconds_now());
|
record.finished_at = Some(unix_seconds_now());
|
||||||
record.result = serde_json::to_value(&report).ok();
|
record.result = Some(report);
|
||||||
}),
|
}),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
let cancelled = cancel.load(Ordering::Relaxed);
|
let cancelled =
|
||||||
|
cancel.load(Ordering::Relaxed) || daemon_control_stop_requested(Some(&control));
|
||||||
// 下载失败携带类型化 DownloadError(含准确网络域码);其余归 internal。
|
// 下载失败携带类型化 DownloadError(含准确网络域码);其余归 internal。
|
||||||
let code = error
|
let code = error
|
||||||
.downcast_ref::<bat_infrastructure::DownloadError>()
|
.downcast_ref::<bat_infrastructure::DownloadError>()
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
@@ -1,4 +1,17 @@
|
|||||||
|
use super::report_output::print_json_value;
|
||||||
use super::*;
|
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(
|
pub(super) fn build_translation_tasks_report(
|
||||||
state_dir: &Path,
|
state_dir: &Path,
|
||||||
@@ -159,6 +172,7 @@ pub(super) fn build_translation_handoff_report(
|
|||||||
pub(super) fn update_translation_task_status_report(
|
pub(super) fn update_translation_task_status_report(
|
||||||
state_dir: &Path,
|
state_dir: &Path,
|
||||||
params: Option<&serde_json::Value>,
|
params: Option<&serde_json::Value>,
|
||||||
|
configured_glossary_path: Option<&Path>,
|
||||||
) -> anyhow::Result<serde_json::Value> {
|
) -> anyhow::Result<serde_json::Value> {
|
||||||
let task_id = rpc_string_param(params, "task_id")
|
let task_id = rpc_string_param(params, "task_id")
|
||||||
.ok_or_else(|| anyhow::anyhow!("translation.task.update 缺少 task_id"))?;
|
.ok_or_else(|| anyhow::anyhow!("translation.task.update 缺少 task_id"))?;
|
||||||
@@ -170,6 +184,13 @@ pub(super) fn update_translation_task_status_report(
|
|||||||
.or_else(|| rpc_string_param(params, "reason"))
|
.or_else(|| rpc_string_param(params, "reason"))
|
||||||
.map(str::to_string);
|
.map(str::to_string);
|
||||||
let provider_run_id = rpc_string_param(params, "provider_run_id").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 (_, version_state) = read_daemon_resource_state(state_dir)?;
|
||||||
let current = version_state
|
let current = version_state
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -182,6 +203,22 @@ pub(super) fn update_translation_task_status_report(
|
|||||||
repository_path.display()
|
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()
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
.enable_all()
|
.enable_all()
|
||||||
@@ -190,10 +227,54 @@ pub(super) fn update_translation_task_status_report(
|
|||||||
let repository = SqliteTranslationTaskRepository::open(&repository_path)
|
let repository = SqliteTranslationTaskRepository::open(&repository_path)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||||
repository
|
if let Some(index) = textunit_index.as_ref() {
|
||||||
.update_status(task_id, status, failure_reason, provider_run_id)
|
let current_task = repository
|
||||||
.await
|
.find(task_id)
|
||||||
.map_err(|error| anyhow::anyhow!("{error}"))
|
.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!({
|
Ok(serde_json::json!({
|
||||||
"available": true,
|
"available": true,
|
||||||
@@ -215,6 +296,122 @@ pub(super) fn textunit_query_json(query: &OfficialTextUnitQuery) -> serde_json::
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
pub(super) fn translation_task_query_json(query: &OfficialTextUnitTaskQuery) -> serde_json::Value {
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"task_id": query.task_id.clone(),
|
"task_id": query.task_id.clone(),
|
||||||
@@ -230,3 +427,395 @@ pub(super) fn translation_task_query_json(query: &OfficialTextUnitTaskQuery) ->
|
|||||||
"has_failure_reason": query.has_failure_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}"))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use super::report_output::{print_json_value, print_report};
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
pub(super) fn run_parse_once(options: &CliOptions) -> anyhow::Result<()> {
|
pub(super) fn run_parse_once(options: &CliOptions) -> anyhow::Result<()> {
|
||||||
@@ -103,7 +104,12 @@ pub(super) fn run_translation_validate(options: &CliOptions) -> anyhow::Result<(
|
|||||||
.ok_or_else(|| anyhow::anyhow!("i18n validate 必须指定 --translation-file"))?;
|
.ok_or_else(|| anyhow::anyhow!("i18n validate 必须指定 --translation-file"))?;
|
||||||
let (resource_root, release_id) = current_official_release(options)?;
|
let (resource_root, release_id) = current_official_release(options)?;
|
||||||
let workbench = read_translation_workbench(path)?;
|
let workbench = read_translation_workbench(path)?;
|
||||||
let validation = validate_translation_workbench(&resource_root, &release_id, &workbench)?;
|
let validation = validate_translation_workbench_with_glossary_path(
|
||||||
|
&resource_root,
|
||||||
|
&release_id,
|
||||||
|
&workbench,
|
||||||
|
options.glossary_path.as_deref(),
|
||||||
|
)?;
|
||||||
let data = serde_json::json!({
|
let data = serde_json::json!({
|
||||||
"official_release_id": release_id,
|
"official_release_id": release_id,
|
||||||
"resource_root": resource_root,
|
"resource_root": resource_root,
|
||||||
@@ -140,7 +146,52 @@ pub(super) fn run_translation_set(options: &CliOptions) -> anyhow::Result<()> {
|
|||||||
.translation_id
|
.translation_id
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.ok_or_else(|| anyhow::anyhow!("translation-set 必须指定 --translation-id"))?;
|
.ok_or_else(|| anyhow::anyhow!("translation-set 必须指定 --translation-id"))?;
|
||||||
let entry = set_translation(path, entry_id, text)?;
|
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!({
|
let data = serde_json::json!({
|
||||||
"translation_file": path,
|
"translation_file": path,
|
||||||
"entry": entry,
|
"entry": entry,
|
||||||
@@ -231,10 +282,50 @@ pub(super) fn run_translation_task_update(options: &CliOptions) -> anyhow::Resul
|
|||||||
let report = update_translation_task_status_report(
|
let report = update_translation_task_status_report(
|
||||||
&options.state_dir,
|
&options.state_dir,
|
||||||
Some(&serde_json::Value::Object(params)),
|
Some(&serde_json::Value::Object(params)),
|
||||||
|
options.glossary_path.as_deref(),
|
||||||
)?;
|
)?;
|
||||||
print_json_value(options.output_format, &report)
|
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<()> {
|
pub(super) fn run_repack(options: &CliOptions) -> anyhow::Result<()> {
|
||||||
let spec = options
|
let spec = options
|
||||||
.repack_spec
|
.repack_spec
|
||||||
@@ -244,13 +335,68 @@ pub(super) fn run_repack(options: &CliOptions) -> anyhow::Result<()> {
|
|||||||
print_report(options.output_format, &report)
|
print_report(options.output_format, &report)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn run_publish_localized(options: &CliOptions) -> anyhow::Result<()> {
|
pub(super) fn publish_localized_report(
|
||||||
let translation_file = options
|
options: &CliOptions,
|
||||||
.translation_file
|
) -> anyhow::Result<LocalizedPatchReport> {
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("publish-localized 必须指定 --translation-file"))?;
|
|
||||||
let (resource_root, official_release_id) = current_official_release(options)?;
|
let (resource_root, official_release_id) = current_official_release(options)?;
|
||||||
let workbench = read_translation_workbench(translation_file)?;
|
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 {
|
if workbench.official_release_id != official_release_id {
|
||||||
return Err(anyhow::anyhow!(
|
return Err(anyhow::anyhow!(
|
||||||
"翻译工作台 release={} 与当前官方 release={} 不一致;请重新导出",
|
"翻译工作台 release={} 与当前官方 release={} 不一致;请重新导出",
|
||||||
@@ -264,7 +410,17 @@ pub(super) fn run_publish_localized(options: &CliOptions) -> anyhow::Result<()>
|
|||||||
"翻译工作台资源根目录与当前 release 不一致;请重新导出"
|
"翻译工作台资源根目录与当前 release 不一致;请重新导出"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let patches = localized_text_asset_patches(&resource_root, &workbench)?;
|
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(|| {
|
let localized_release_id = options.localized_release_id.clone().or_else(|| {
|
||||||
options
|
options
|
||||||
.config
|
.config
|
||||||
@@ -275,17 +431,40 @@ pub(super) fn run_publish_localized(options: &CliOptions) -> anyhow::Result<()>
|
|||||||
resource_root,
|
resource_root,
|
||||||
options.config.localized_output_root.clone(),
|
options.config.localized_output_root.clone(),
|
||||||
official_release_id,
|
official_release_id,
|
||||||
patches,
|
Vec::new(),
|
||||||
)
|
)
|
||||||
|
.with_archive_commands(
|
||||||
|
options.config.unzip_command.clone(),
|
||||||
|
options.config.zip_command.clone(),
|
||||||
|
)
|
||||||
|
.with_operations(operations)
|
||||||
.with_force(options.config.force);
|
.with_force(options.config.force);
|
||||||
if let Some(release_id) = localized_release_id {
|
if let Some(release_id) = localized_release_id {
|
||||||
config = config.with_localized_release_id(release_id);
|
config = config.with_localized_release_id(release_id);
|
||||||
}
|
}
|
||||||
let report = LocalizedPatchService::new().publish(&config)?;
|
LocalizedPatchService::new().publish(&config)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn run_publish_localized(options: &CliOptions) -> anyhow::Result<()> {
|
||||||
|
let report = publish_localized_report(options)?;
|
||||||
print_report(options.output_format, &report)
|
print_report(options.output_format, &report)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn current_official_release(options: &CliOptions) -> anyhow::Result<(PathBuf, String)> {
|
pub(super) fn localized_rollback_report(
|
||||||
|
options: &CliOptions,
|
||||||
|
) -> anyhow::Result<LocalizedRollbackReport> {
|
||||||
|
LocalizedPatchService::new().rollback(
|
||||||
|
&options.config.localized_output_root,
|
||||||
|
options.localized_release_id.as_deref(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn run_localized_rollback(options: &CliOptions) -> anyhow::Result<()> {
|
||||||
|
let report = localized_rollback_report(options)?;
|
||||||
|
print_report(options.output_format, &report)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn current_official_release(options: &CliOptions) -> anyhow::Result<(PathBuf, String)> {
|
||||||
let state = read_version_state(&options.config.version_state_path())?;
|
let state = read_version_state(&options.config.version_state_path())?;
|
||||||
let resource_root = if let Some(resource_root) = options.resource_root.clone() {
|
let resource_root = if let Some(resource_root) = options.resource_root.clone() {
|
||||||
lexical_absolute(&resource_root).map_err(anyhow::Error::msg)?
|
lexical_absolute(&resource_root).map_err(anyhow::Error::msg)?
|
||||||
|
|||||||
@@ -30,6 +30,30 @@ impl FileSystemCasRepository {
|
|||||||
self.engine().await.map(|_| ())
|
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> {
|
async fn engine(&self) -> bat_core::Result<&engine_repository::FileSystemCasRepository> {
|
||||||
self.inner
|
self.inner
|
||||||
.get_or_try_init(|| async {
|
.get_or_try_init(|| async {
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ pub trait DownloaderBackend<T>: Send + Sync {
|
|||||||
fn download(&self, task: T) -> Result<Self::Output, Self::Error>;
|
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.
|
/// A bounded worker scheduler.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct DownloadScheduler {
|
pub struct DownloadScheduler {
|
||||||
@@ -54,7 +58,7 @@ impl DownloadScheduler {
|
|||||||
/// order. A failed task does not cause additional tasks to be scheduled
|
/// order. A failed task does not cause additional tasks to be scheduled
|
||||||
/// after it, because already-started bounded work must be joined cleanly;
|
/// after it, because already-started bounded work must be joined cleanly;
|
||||||
/// callers decide whether a failed result invalidates the whole release.
|
/// callers decide whether a failed result invalidates the whole release.
|
||||||
pub fn execute<T, B>(self, backend: &B, tasks: Vec<T>) -> Vec<Result<B::Output, B::Error>>
|
pub fn execute<T, B>(self, backend: &B, tasks: Vec<T>) -> DownloadResults<T, B>
|
||||||
where
|
where
|
||||||
T: Send + 'static,
|
T: Send + 'static,
|
||||||
B: DownloaderBackend<T>,
|
B: DownloaderBackend<T>,
|
||||||
@@ -78,7 +82,7 @@ impl DownloadScheduler {
|
|||||||
backend: &B,
|
backend: &B,
|
||||||
tasks: Vec<T>,
|
tasks: Vec<T>,
|
||||||
mut observer: F,
|
mut observer: F,
|
||||||
) -> Result<Vec<Result<B::Output, B::Error>>, E>
|
) -> Result<DownloadResults<T, B>, E>
|
||||||
where
|
where
|
||||||
T: Send + 'static,
|
T: Send + 'static,
|
||||||
B: DownloaderBackend<T>,
|
B: DownloaderBackend<T>,
|
||||||
@@ -152,7 +156,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
struct TestBackend {
|
struct TestBackend {
|
||||||
active: AtomicUsize,
|
active: AtomicUsize,
|
||||||
@@ -218,7 +222,15 @@ mod tests {
|
|||||||
fn download(&self, task: usize) -> Result<Self::Output, Self::Error> {
|
fn download(&self, task: usize) -> Result<Self::Output, Self::Error> {
|
||||||
if task == 0 {
|
if task == 0 {
|
||||||
self.active.fetch_add(1, Ordering::SeqCst);
|
self.active.fetch_add(1, Ordering::SeqCst);
|
||||||
thread::sleep(Duration::from_millis(20));
|
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);
|
self.active.fetch_sub(1, Ordering::SeqCst);
|
||||||
} else {
|
} else {
|
||||||
if task == 1 {
|
if task == 1 {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -560,6 +560,8 @@ mod tests {
|
|||||||
resource_type: ResourceType::AssetBundle,
|
resource_type: ResourceType::AssetBundle,
|
||||||
address: None,
|
address: None,
|
||||||
dependencies: Vec::new(),
|
dependencies: Vec::new(),
|
||||||
|
provider_id: None,
|
||||||
|
bundle_name: None,
|
||||||
crc: None,
|
crc: None,
|
||||||
},
|
},
|
||||||
ResourceEntry {
|
ResourceEntry {
|
||||||
@@ -569,6 +571,8 @@ mod tests {
|
|||||||
resource_type: ResourceType::Manifest,
|
resource_type: ResourceType::Manifest,
|
||||||
address: None,
|
address: None,
|
||||||
dependencies: Vec::new(),
|
dependencies: Vec::new(),
|
||||||
|
provider_id: None,
|
||||||
|
bundle_name: None,
|
||||||
crc: None,
|
crc: None,
|
||||||
},
|
},
|
||||||
ResourceEntry {
|
ResourceEntry {
|
||||||
@@ -578,6 +582,8 @@ mod tests {
|
|||||||
resource_type: ResourceType::TextAsset,
|
resource_type: ResourceType::TextAsset,
|
||||||
address: Some("dialogue".to_string()),
|
address: Some("dialogue".to_string()),
|
||||||
dependencies: Vec::new(),
|
dependencies: Vec::new(),
|
||||||
|
provider_id: None,
|
||||||
|
bundle_name: None,
|
||||||
crc: None,
|
crc: None,
|
||||||
},
|
},
|
||||||
ResourceEntry {
|
ResourceEntry {
|
||||||
@@ -587,6 +593,8 @@ mod tests {
|
|||||||
resource_type: ResourceType::TableBundle,
|
resource_type: ResourceType::TableBundle,
|
||||||
address: None,
|
address: None,
|
||||||
dependencies: Vec::new(),
|
dependencies: Vec::new(),
|
||||||
|
provider_id: None,
|
||||||
|
bundle_name: None,
|
||||||
crc: None,
|
crc: None,
|
||||||
},
|
},
|
||||||
ResourceEntry {
|
ResourceEntry {
|
||||||
@@ -596,6 +604,8 @@ mod tests {
|
|||||||
resource_type: ResourceType::Media,
|
resource_type: ResourceType::Media,
|
||||||
address: None,
|
address: None,
|
||||||
dependencies: Vec::new(),
|
dependencies: Vec::new(),
|
||||||
|
provider_id: None,
|
||||||
|
bundle_name: None,
|
||||||
crc: None,
|
crc: None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -627,6 +637,8 @@ mod tests {
|
|||||||
resource_type: ResourceType::TextAsset,
|
resource_type: ResourceType::TextAsset,
|
||||||
address: None,
|
address: None,
|
||||||
dependencies: Vec::new(),
|
dependencies: Vec::new(),
|
||||||
|
provider_id: None,
|
||||||
|
bundle_name: None,
|
||||||
crc: None,
|
crc: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -648,6 +660,8 @@ mod tests {
|
|||||||
resource_type: ResourceType::AssetBundle,
|
resource_type: ResourceType::AssetBundle,
|
||||||
address: None,
|
address: None,
|
||||||
dependencies: Vec::new(),
|
dependencies: Vec::new(),
|
||||||
|
provider_id: None,
|
||||||
|
bundle_name: None,
|
||||||
crc: None,
|
crc: None,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -801,6 +815,8 @@ mod tests {
|
|||||||
resource_type: ResourceType::AssetBundle,
|
resource_type: ResourceType::AssetBundle,
|
||||||
address: None,
|
address: None,
|
||||||
dependencies: Vec::new(),
|
dependencies: Vec::new(),
|
||||||
|
provider_id: None,
|
||||||
|
bundle_name: None,
|
||||||
crc: None,
|
crc: None,
|
||||||
}]);
|
}]);
|
||||||
|
|
||||||
|
|||||||
+68
-19
@@ -13,6 +13,7 @@
|
|||||||
pub mod cas;
|
pub mod cas;
|
||||||
mod curl_transfer;
|
mod curl_transfer;
|
||||||
pub mod downloader;
|
pub mod downloader;
|
||||||
|
pub mod glossary;
|
||||||
pub mod import;
|
pub mod import;
|
||||||
pub mod localized_patch;
|
pub mod localized_patch;
|
||||||
pub mod official_changes;
|
pub mod official_changes;
|
||||||
@@ -28,8 +29,12 @@ pub mod official_update;
|
|||||||
pub mod patch_ops;
|
pub mod patch_ops;
|
||||||
pub mod path_security;
|
pub mod path_security;
|
||||||
pub mod release_flow;
|
pub mod release_flow;
|
||||||
|
pub mod release_ops;
|
||||||
pub mod resources;
|
pub mod resources;
|
||||||
|
mod sqlite_migration;
|
||||||
|
pub mod translation_memory;
|
||||||
pub mod translation_tasks;
|
pub mod translation_tasks;
|
||||||
|
pub mod translation_worker;
|
||||||
pub mod translation_workflow;
|
pub mod translation_workflow;
|
||||||
mod zip_validation;
|
mod zip_validation;
|
||||||
|
|
||||||
@@ -38,20 +43,32 @@ pub use curl_transfer::{
|
|||||||
redact_proxy_url, resolve_curl_proxy, CurlProxyConfig, CurlProxyMode, ResolvedCurlProxy,
|
redact_proxy_url, resolve_curl_proxy, CurlProxyConfig, CurlProxyMode, ResolvedCurlProxy,
|
||||||
};
|
};
|
||||||
pub use downloader::{
|
pub use downloader::{
|
||||||
DownloadScheduler, DownloaderBackend, DEFAULT_DOWNLOAD_CONCURRENCY, MAX_DOWNLOAD_CONCURRENCY,
|
DownloadResults, DownloadScheduler, DownloaderBackend, DEFAULT_DOWNLOAD_CONCURRENCY,
|
||||||
MIN_DOWNLOAD_CONCURRENCY,
|
MAX_DOWNLOAD_CONCURRENCY, MIN_DOWNLOAD_CONCURRENCY,
|
||||||
|
};
|
||||||
|
pub use glossary::{
|
||||||
|
SqliteGlossaryRepository, GLOSSARY_REPOSITORY_FILE, GLOSSARY_SCHEMA_COMPONENT,
|
||||||
|
GLOSSARY_SCHEMA_VERSION,
|
||||||
};
|
};
|
||||||
pub use import::{
|
pub use import::{
|
||||||
BundleSource, ImportedResource, ResourceImportCategory, ResourceImportReport,
|
BundleSource, ImportedResource, ResourceImportCategory, ResourceImportReport,
|
||||||
ResourceImportService,
|
ResourceImportService,
|
||||||
};
|
};
|
||||||
pub use localized_patch::{
|
pub use localized_patch::{
|
||||||
read_localized_patch_manifest_at, read_localized_version_state, LocalizedPatchConfig,
|
inspect_localized_release_artifact, inspect_localized_release_artifact_at,
|
||||||
LocalizedPatchFile, LocalizedPatchIntegrity, LocalizedPatchManifest, LocalizedPatchOperation,
|
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,
|
LocalizedPatchReport, LocalizedPatchRollbackInfo, LocalizedPatchService,
|
||||||
LocalizedTextAssetPatch, LocalizedVersionState, LOCALIZED_CURRENT_LINK,
|
LocalizedRollbackReport, LocalizedStringFieldPatch, LocalizedTextAssetPatch,
|
||||||
LOCALIZED_PATCH_MANIFEST_FILE, LOCALIZED_PATCH_MANIFEST_VERSION, LOCALIZED_STAGING_DIR,
|
LocalizedTranslationWorkflowReport, LocalizedVersionState, LOCALIZED_CURRENT_LINK,
|
||||||
LOCALIZED_VERSIONS_DIR, LOCALIZED_VERSION_STATE_FILE,
|
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::{
|
pub use official_changes::{
|
||||||
read_resource_change_set_at, write_crowdin_translation_handoff_at,
|
read_resource_change_set_at, write_crowdin_translation_handoff_at,
|
||||||
@@ -64,13 +81,19 @@ pub use official_changes::{
|
|||||||
OFFICIAL_RESOURCE_CHANGES_VERSION,
|
OFFICIAL_RESOURCE_CHANGES_VERSION,
|
||||||
};
|
};
|
||||||
pub use official_download::{
|
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,
|
OfficialDownloadManifestEntry, OfficialLocalManifestAuditItem,
|
||||||
OfficialLocalManifestAuditReport, OfficialLocalManifestAuditStatus,
|
OfficialLocalManifestAuditReport, OfficialLocalManifestAuditStatus,
|
||||||
OfficialLocalVerificationReport, OfficialResourceHashAlgorithm,
|
OfficialLocalVerificationReport, OfficialResourceHashAlgorithm,
|
||||||
OfficialResourceHashVerification, OfficialResourcePullItem, OfficialResourcePullProgress,
|
OfficialResourceHashVerification, OfficialResourcePullItem, OfficialResourcePullProgress,
|
||||||
OfficialResourcePullProgressKind, OfficialResourcePullReport, OfficialResourcePullService,
|
OfficialResourcePullProgressKind, OfficialResourcePullReport, OfficialResourcePullService,
|
||||||
OfficialResourcePullStatus, OfficialResourceVerification,
|
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_game_main_config::OfficialGameMainConfigBootstrapService;
|
||||||
pub use official_launcher::{
|
pub use official_launcher::{
|
||||||
@@ -110,7 +133,8 @@ pub use official_textunit_queue::{
|
|||||||
};
|
};
|
||||||
pub use official_update::{
|
pub use official_update::{
|
||||||
cached_game_main_config_for_metadata, diff_extended_snapshot, gc_orphan_staging,
|
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,
|
write_version_state, ExtendedSnapshotDelta, GameMainConfigSnapshot, LauncherMetadataSnapshot,
|
||||||
LocalizedReleaseStatus, OfficialBootstrapCache, OfficialEndpointMarkerRole,
|
LocalizedReleaseStatus, OfficialBootstrapCache, OfficialEndpointMarkerRole,
|
||||||
OfficialEndpointMarkerSnapshot, OfficialFailedVersionRecord, OfficialServerInfoSource,
|
OfficialEndpointMarkerSnapshot, OfficialFailedVersionRecord, OfficialServerInfoSource,
|
||||||
@@ -130,20 +154,45 @@ pub use path_security::{
|
|||||||
validate_runtime_state_dir, write_file_atomic, PRIVATE_FILE_MODE, STATE_FILE_MODE,
|
validate_runtime_state_dir, write_file_atomic, PRIVATE_FILE_MODE, STATE_FILE_MODE,
|
||||||
};
|
};
|
||||||
pub use release_flow::ReleaseFlowStatusCode;
|
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 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::{
|
pub use translation_tasks::{
|
||||||
build_translation_handoff, read_translation_handoff_at, sync_translation_task_repository_at,
|
build_translation_handoff, read_translation_handoff_at, sync_translation_task_repository_at,
|
||||||
write_translation_handoff_at, PersistedTranslationTask, ProviderRun, ProviderRunStatus,
|
write_translation_handoff_at, PersistedTranslationTask, PersistedTranslationTaskState,
|
||||||
SqliteTranslationTaskRepository, TranslationHandoff, TranslationJob, TranslationJobStatus,
|
ProviderRun, ProviderRunStatus, SqliteTranslationTaskRepository, TranslationHandoff,
|
||||||
TranslationTaskStatus, TranslationTaskSyncReport, TranslationUnit, TranslationUnitStatus,
|
TranslationJob, TranslationJobStatus, TranslationTaskFailure, TranslationTaskResultSourceKind,
|
||||||
TRANSLATION_HANDOFF_FILE, TRANSLATION_HANDOFF_SCHEMA_VERSION, TRANSLATION_TASK_REPOSITORY_FILE,
|
TranslationTaskStatus, TranslationTaskSyncReport, TranslationTaskUnitResult, TranslationUnit,
|
||||||
TRANSLATION_TASK_SCHEMA_VERSION,
|
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::{
|
pub use translation_workflow::{
|
||||||
export_translation_workbench, get_translation_entry, localized_text_asset_patches,
|
completed_worker_translation_workbench, export_completed_worker_translation_workbench,
|
||||||
read_translation_workbench, repack_bundle, set_translation, unset_translation,
|
export_translation_workbench, get_translation_entry, localized_patch_operations,
|
||||||
validate_translation_workbench, write_translation_workbench, RepackOperation, RepackReport,
|
localized_patch_operations_with_glossary_path, localized_text_asset_patches,
|
||||||
RepackSpec, TranslationWorkbench, TranslationWorkbenchEntry,
|
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,
|
TranslationWorkbenchValidationReport, REPACK_SPEC_VERSION, TRANSLATION_WORKBENCH_VERSION,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -211,6 +211,8 @@ impl<'a> OfficialReleaseImportService<'a> {
|
|||||||
resource_type,
|
resource_type,
|
||||||
address: None,
|
address: None,
|
||||||
dependencies: Vec::new(),
|
dependencies: Vec::new(),
|
||||||
|
provider_id: None,
|
||||||
|
bundle_name: None,
|
||||||
crc: None,
|
crc: None,
|
||||||
},
|
},
|
||||||
metadata,
|
metadata,
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ use crate::official_changes::{
|
|||||||
write_official_resource_change_handoff, OfficialResourceChangeHandoffReport,
|
write_official_resource_change_handoff, OfficialResourceChangeHandoffReport,
|
||||||
OfficialResourceChangeSummary,
|
OfficialResourceChangeSummary,
|
||||||
};
|
};
|
||||||
|
use crate::official_download::{
|
||||||
|
official_distribution_max_age_for_durations, write_official_distribution_attestation_at,
|
||||||
|
write_official_distribution_publication_anchor_at, OfficialDistributionAttestation,
|
||||||
|
OFFICIAL_CAS_REUSE_REFERENCES_FILE, OFFICIAL_DISTRIBUTION_PUBLICATION_FILE,
|
||||||
|
};
|
||||||
use crate::official_game_main_config::{
|
use crate::official_game_main_config::{
|
||||||
resolve_game_main_config_source, OfficialGameMainConfigSelectedSource,
|
resolve_game_main_config_source, OfficialGameMainConfigSelectedSource,
|
||||||
OfficialGameMainConfigSourceKind,
|
OfficialGameMainConfigSourceKind,
|
||||||
@@ -34,12 +39,12 @@ use crate::translation_tasks::{
|
|||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
build_official_pull_plan_for_platform_inventory, build_official_sync_plan,
|
build_official_pull_plan_for_platform_inventory, build_official_sync_plan,
|
||||||
changed_endpoint_urls, default_official_platforms, DownloadError,
|
changed_endpoint_urls, default_official_platforms, release_cas_reuse_references, DownloadError,
|
||||||
OfficialGameMainConfigBootstrapService, OfficialLauncherBootstrapService,
|
OfficialGameMainConfigBootstrapService, OfficialLauncherBootstrapService,
|
||||||
OfficialResourceHashVerification, OfficialResourcePullPlan, OfficialResourcePullProgress,
|
OfficialResourceHashVerification, OfficialResourcePullPlan, OfficialResourcePullProgress,
|
||||||
OfficialResourcePullProgressKind, OfficialResourcePullService, OfficialResourceVerification,
|
OfficialResourcePullProgressKind, OfficialResourcePullService, OfficialResourceReuseWarning,
|
||||||
YostarJpLauncherCdnConfig, YostarJpLauncherGameConfig, YostarJpLauncherManifestUrl,
|
OfficialResourceVerification, YostarJpLauncherCdnConfig, YostarJpLauncherGameConfig,
|
||||||
YostarJpLauncherRemoteManifest,
|
YostarJpLauncherManifestUrl, YostarJpLauncherRemoteManifest,
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
read_parse_cache_at, OfficialParseCacheService, OfficialParseConfig, OfficialParseSummary,
|
read_parse_cache_at, OfficialParseCacheService, OfficialParseConfig, OfficialParseSummary,
|
||||||
@@ -126,6 +131,8 @@ pub struct OfficialUpdateConfig {
|
|||||||
pub download_concurrency: usize,
|
pub download_concurrency: usize,
|
||||||
/// Unzip command used when a metadata change requires GameMainConfig parsing.
|
/// Unzip command used when a metadata change requires GameMainConfig parsing.
|
||||||
pub unzip_command: PathBuf,
|
pub unzip_command: PathBuf,
|
||||||
|
/// Zip command used when publishing localized bundles nested in ZIP archives.
|
||||||
|
pub zip_command: PathBuf,
|
||||||
/// Dry run reports decisions and optional plan URLs without writing sync state.
|
/// Dry run reports decisions and optional plan URLs without writing sync state.
|
||||||
pub dry_run: bool,
|
pub dry_run: bool,
|
||||||
/// Include full download URLs when dry-running.
|
/// Include full download URLs when dry-running.
|
||||||
@@ -162,6 +169,7 @@ impl Default for OfficialUpdateConfig {
|
|||||||
curl_proxy: CurlProxyConfig::default(),
|
curl_proxy: CurlProxyConfig::default(),
|
||||||
download_concurrency: DEFAULT_DOWNLOAD_CONCURRENCY,
|
download_concurrency: DEFAULT_DOWNLOAD_CONCURRENCY,
|
||||||
unzip_command: PathBuf::from("unzip"),
|
unzip_command: PathBuf::from("unzip"),
|
||||||
|
zip_command: PathBuf::from("zip"),
|
||||||
dry_run: false,
|
dry_run: false,
|
||||||
plan: false,
|
plan: false,
|
||||||
force: false,
|
force: false,
|
||||||
@@ -827,6 +835,18 @@ pub struct OfficialUpdateReport {
|
|||||||
pub resumed_count: usize,
|
pub resumed_count: usize,
|
||||||
/// Number of resources skipped using the local manifest.
|
/// Number of resources skipped using the local manifest.
|
||||||
pub skipped_count: usize,
|
pub skipped_count: usize,
|
||||||
|
/// Number of resources reused from immutable historical releases.
|
||||||
|
#[serde(default)]
|
||||||
|
pub release_reused_count: usize,
|
||||||
|
/// Number of resources reused from CAS.
|
||||||
|
#[serde(default)]
|
||||||
|
pub cas_reused_count: usize,
|
||||||
|
/// Bytes materialized without network transfer.
|
||||||
|
#[serde(default)]
|
||||||
|
pub reused_bytes: u64,
|
||||||
|
/// Diagnostics recorded while invalid reuse candidates fell back.
|
||||||
|
#[serde(default)]
|
||||||
|
pub reuse_warnings: Vec<OfficialResourceReuseWarning>,
|
||||||
/// Final local byte count in the pull report.
|
/// Final local byte count in the pull report.
|
||||||
pub final_bytes: u64,
|
pub final_bytes: u64,
|
||||||
/// Bytes transferred in this run.
|
/// Bytes transferred in this run.
|
||||||
@@ -1113,13 +1133,56 @@ impl OfficialPublishLayout {
|
|||||||
if !path_exists_no_follow(active_root)? {
|
if !path_exists_no_follow(active_root)? {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
copy_tree_no_symlink(active_root, staging_root, active_root == self.root)
|
copy_tree_no_symlink(active_root, staging_root, active_root == self.root)?;
|
||||||
|
if active_root != self.root {
|
||||||
|
let cas_references = staging_root.join(OFFICIAL_CAS_REUSE_REFERENCES_FILE);
|
||||||
|
if path_exists_no_follow(&cas_references)? {
|
||||||
|
ensure_safe_file_target(staging_root, &cas_references, "staging CAS 引用清单")?;
|
||||||
|
fs::remove_file(&cas_references).map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"清理 active release CAS ownership 清单失败 {}:{error}",
|
||||||
|
cas_references.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn legacy_manifest_exists(&self) -> Result<bool, String> {
|
fn legacy_manifest_exists(&self) -> Result<bool, String> {
|
||||||
path_exists_no_follow(&self.root.join(OFFICIAL_DOWNLOAD_MANIFEST_FILE))
|
path_exists_no_follow(&self.root.join(OFFICIAL_DOWNLOAD_MANIFEST_FILE))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn release_reuse_roots(&self, active_root: &Path) -> Result<Vec<PathBuf>, String> {
|
||||||
|
let mut roots = Vec::new();
|
||||||
|
if path_exists_no_follow(active_root)? {
|
||||||
|
roots.push(active_root.to_path_buf());
|
||||||
|
}
|
||||||
|
if !path_exists_no_follow(&self.versions_dir)? {
|
||||||
|
return Ok(roots);
|
||||||
|
}
|
||||||
|
ensure_safe_directory_path(&self.versions_dir, "官方资源历史 release 根目录")?;
|
||||||
|
let mut versions = fs::read_dir(&self.versions_dir)
|
||||||
|
.map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"读取官方资源历史 release 根目录失败 {}:{error}",
|
||||||
|
self.versions_dir.display()
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
|
||||||
|
.filter(|path| {
|
||||||
|
fs::symlink_metadata(path)
|
||||||
|
.map(|metadata| metadata.is_dir() && !metadata.file_type().is_symlink())
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
versions.sort();
|
||||||
|
versions.reverse();
|
||||||
|
roots.extend(versions);
|
||||||
|
roots.dedup();
|
||||||
|
Ok(roots)
|
||||||
|
}
|
||||||
|
|
||||||
fn publish(&self, plan: &OfficialPublishPlan) -> Result<PathBuf, String> {
|
fn publish(&self, plan: &OfficialPublishPlan) -> Result<PathBuf, String> {
|
||||||
ensure_safe_directory_path(&self.versions_dir, "官方资源 versions 根目录")?;
|
ensure_safe_directory_path(&self.versions_dir, "官方资源 versions 根目录")?;
|
||||||
fs::create_dir_all(&self.versions_dir).map_err(|error| {
|
fs::create_dir_all(&self.versions_dir).map_err(|error| {
|
||||||
@@ -1150,13 +1213,48 @@ impl OfficialPublishLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Official update runner.
|
/// Official update runner.
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct OfficialUpdateService;
|
pub struct OfficialUpdateService {
|
||||||
|
attestation_max_age_seconds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for OfficialUpdateService {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl OfficialUpdateService {
|
impl OfficialUpdateService {
|
||||||
/// Creates an official update runner.
|
/// Creates an official update runner.
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self
|
Self {
|
||||||
|
attestation_max_age_seconds: official_distribution_max_age_for_durations(
|
||||||
|
std::time::Duration::from_secs(
|
||||||
|
crate::official_download::DEFAULT_OFFICIAL_VERIFICATION_INTERVAL_SECONDS,
|
||||||
|
),
|
||||||
|
std::time::Duration::from_secs(
|
||||||
|
crate::official_download::DEFAULT_OFFICIAL_ERROR_RETRY_SECONDS,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates an update runner using the daemon's actual watch cadence.
|
||||||
|
pub fn with_verification_cadence(
|
||||||
|
verification_interval: std::time::Duration,
|
||||||
|
error_retry: std::time::Duration,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
attestation_max_age_seconds: official_distribution_max_age_for_durations(
|
||||||
|
verification_interval,
|
||||||
|
error_retry,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the freshness window persisted with each attestation.
|
||||||
|
pub fn attestation_max_age_seconds(&self) -> u64 {
|
||||||
|
self.attestation_max_age_seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Executes one official update run.
|
/// Executes one official update run.
|
||||||
@@ -1544,11 +1642,24 @@ impl OfficialUpdateService {
|
|||||||
fetcher.download_manifest_path().display()
|
fetcher.download_manifest_path().display()
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
Some(
|
match fetcher.audit_local_manifest(&pull_plan) {
|
||||||
fetcher
|
Ok(audit) => Some(audit),
|
||||||
.audit_local_manifest(&pull_plan)
|
Err(error) => {
|
||||||
.map_err(anyhow::Error::msg)?,
|
if !config.dry_run && has_current_pointer {
|
||||||
)
|
let release_id = version_id_from_path(&active_resource_root)
|
||||||
|
.unwrap_or_else(|| fallback_version_id(¤t_update_snapshot));
|
||||||
|
let _ = write_official_distribution_attestation_at(
|
||||||
|
&active_resource_root,
|
||||||
|
&active_resource_root,
|
||||||
|
&release_id,
|
||||||
|
"invalid",
|
||||||
|
self.attestation_max_age_seconds,
|
||||||
|
vec![format!("本地 manifest 审计失败:{error}")],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Err(anyhow::Error::msg(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if config.audit_local {
|
} else if config.audit_local {
|
||||||
progress(OfficialUpdateProgress::new(
|
progress(OfficialUpdateProgress::new(
|
||||||
"audit",
|
"audit",
|
||||||
@@ -1610,6 +1721,31 @@ impl OfficialUpdateService {
|
|||||||
check_shutdown_requested(&mut should_cancel)?;
|
check_shutdown_requested(&mut should_cancel)?;
|
||||||
let active_release_id = version_id_from_path(&active_resource_root)
|
let active_release_id = version_id_from_path(&active_resource_root)
|
||||||
.unwrap_or_else(|| fallback_version_id(¤t_update_snapshot));
|
.unwrap_or_else(|| fallback_version_id(¤t_update_snapshot));
|
||||||
|
let mut local_attestation_invalidated = false;
|
||||||
|
if !config.dry_run
|
||||||
|
&& has_current_pointer
|
||||||
|
&& local_audit.as_ref().is_some_and(|audit| !audit.is_clean())
|
||||||
|
{
|
||||||
|
let diagnostics = local_audit
|
||||||
|
.as_ref()
|
||||||
|
.map(|audit| {
|
||||||
|
vec![format!(
|
||||||
|
"本地 manifest 审计失败:{} 项需要修复",
|
||||||
|
audit.repair_needed_count()
|
||||||
|
)]
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
write_official_distribution_attestation_at(
|
||||||
|
&active_resource_root,
|
||||||
|
&active_resource_root,
|
||||||
|
&active_release_id,
|
||||||
|
"invalid",
|
||||||
|
self.attestation_max_age_seconds,
|
||||||
|
diagnostics,
|
||||||
|
)
|
||||||
|
.map_err(anyhow::Error::msg)?;
|
||||||
|
local_attestation_invalidated = true;
|
||||||
|
}
|
||||||
let localized_info = localized_release_info_for(config, Some(active_release_id.as_str()));
|
let localized_info = localized_release_info_for(config, Some(active_release_id.as_str()));
|
||||||
let mut report = OfficialUpdateReport {
|
let mut report = OfficialUpdateReport {
|
||||||
update_status: if should_download {
|
update_status: if should_download {
|
||||||
@@ -1663,6 +1799,10 @@ impl OfficialUpdateService {
|
|||||||
downloaded_count: 0,
|
downloaded_count: 0,
|
||||||
resumed_count: 0,
|
resumed_count: 0,
|
||||||
skipped_count: 0,
|
skipped_count: 0,
|
||||||
|
release_reused_count: 0,
|
||||||
|
cas_reused_count: 0,
|
||||||
|
reused_bytes: 0,
|
||||||
|
reuse_warnings: Vec::new(),
|
||||||
final_bytes: 0,
|
final_bytes: 0,
|
||||||
transferred_bytes: 0,
|
transferred_bytes: 0,
|
||||||
official_seed_hash_verified_count: 0,
|
official_seed_hash_verified_count: 0,
|
||||||
@@ -1719,6 +1859,32 @@ impl OfficialUpdateService {
|
|||||||
&active_resource_root,
|
&active_resource_root,
|
||||||
&snapshot_path,
|
&snapshot_path,
|
||||||
)?;
|
)?;
|
||||||
|
if !local_attestation_invalidated {
|
||||||
|
if let Some(audit) = local_audit.as_ref() {
|
||||||
|
let integrity_status = if audit.is_clean() {
|
||||||
|
"verified"
|
||||||
|
} else {
|
||||||
|
"invalid"
|
||||||
|
};
|
||||||
|
let diagnostics = if audit.is_clean() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
vec![format!(
|
||||||
|
"本地 manifest 审计失败:{} 项需要修复",
|
||||||
|
audit.repair_needed_count()
|
||||||
|
)]
|
||||||
|
};
|
||||||
|
write_official_distribution_attestation_at(
|
||||||
|
&active_resource_root,
|
||||||
|
&active_resource_root,
|
||||||
|
&active_release_id,
|
||||||
|
integrity_status,
|
||||||
|
self.attestation_max_age_seconds,
|
||||||
|
diagnostics,
|
||||||
|
)
|
||||||
|
.map_err(anyhow::Error::msg)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
let active_launcher_bootstrap_path =
|
let active_launcher_bootstrap_path =
|
||||||
active_resource_root.join(OFFICIAL_LAUNCHER_BOOTSTRAP_FILE);
|
active_resource_root.join(OFFICIAL_LAUNCHER_BOOTSTRAP_FILE);
|
||||||
if bootstrap.is_some()
|
if bootstrap.is_some()
|
||||||
@@ -1861,6 +2027,12 @@ impl OfficialUpdateService {
|
|||||||
&config.curl_command,
|
&config.curl_command,
|
||||||
)
|
)
|
||||||
.with_proxy_config(config.curl_proxy.clone())
|
.with_proxy_config(config.curl_proxy.clone())
|
||||||
|
.with_release_reuse_roots(
|
||||||
|
publish_layout
|
||||||
|
.release_reuse_roots(&active_resource_root)
|
||||||
|
.map_err(anyhow::Error::msg)?,
|
||||||
|
)
|
||||||
|
.with_cas_reuse_root(config.effective_import_cas_root())
|
||||||
.with_max_concurrency(config.download_concurrency);
|
.with_max_concurrency(config.download_concurrency);
|
||||||
let pruned_stale_resource_count = staging_fetcher
|
let pruned_stale_resource_count = staging_fetcher
|
||||||
.prune_stale_manifest_entries(&pull_plan)
|
.prune_stale_manifest_entries(&pull_plan)
|
||||||
@@ -1898,13 +2070,24 @@ impl OfficialUpdateService {
|
|||||||
progress(OfficialUpdateProgress::new(
|
progress(OfficialUpdateProgress::new(
|
||||||
"download",
|
"download",
|
||||||
format!(
|
format!(
|
||||||
"下载阶段完成:已下载={} 已续传={} 已复用={} 本轮传输字节={}",
|
"下载阶段完成:已下载={} 已续传={} 当前 manifest 复用={} 历史 release 复用={} CAS 复用={} 本轮传输字节={}",
|
||||||
pull_report.downloaded_count(),
|
pull_report.downloaded_count(),
|
||||||
pull_report.resumed_count(),
|
pull_report.resumed_count(),
|
||||||
pull_report.skipped_count(),
|
pull_report.skipped_count(),
|
||||||
|
pull_report.release_reused_count(),
|
||||||
|
pull_report.cas_reused_count(),
|
||||||
pull_report.transferred_bytes()
|
pull_report.transferred_bytes()
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
|
if !pull_report.reuse_warnings.is_empty() {
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"download",
|
||||||
|
format!(
|
||||||
|
"复用候选诊断:{} 项候选未通过校验,已按顺序回退",
|
||||||
|
pull_report.reuse_warnings.len()
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
progress(OfficialUpdateProgress::new(
|
progress(OfficialUpdateProgress::new(
|
||||||
"audit",
|
"audit",
|
||||||
"执行最终本地 manifest 审计",
|
"执行最终本地 manifest 审计",
|
||||||
@@ -1923,6 +2106,24 @@ impl OfficialUpdateService {
|
|||||||
"audit",
|
"audit",
|
||||||
verification_progress_message(&final_verification_summary),
|
verification_progress_message(&final_verification_summary),
|
||||||
));
|
));
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"publish",
|
||||||
|
"写入官方 distribution publication anchor",
|
||||||
|
));
|
||||||
|
write_official_distribution_publication_anchor_at(
|
||||||
|
&publish_plan.staging_path,
|
||||||
|
&publish_plan.id,
|
||||||
|
)
|
||||||
|
.map_err(anyhow::Error::msg)?;
|
||||||
|
write_official_distribution_attestation_at(
|
||||||
|
&publish_plan.staging_path,
|
||||||
|
&publish_plan.version_path,
|
||||||
|
&publish_plan.id,
|
||||||
|
"verified",
|
||||||
|
self.attestation_max_age_seconds,
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
.map_err(anyhow::Error::msg)?;
|
||||||
progress(OfficialUpdateProgress::new(
|
progress(OfficialUpdateProgress::new(
|
||||||
"snapshot",
|
"snapshot",
|
||||||
format!("写入快照 {}", staging_snapshot_path.display()),
|
format!("写入快照 {}", staging_snapshot_path.display()),
|
||||||
@@ -1996,6 +2197,10 @@ impl OfficialUpdateService {
|
|||||||
report.downloaded_count = pull_report.downloaded_count();
|
report.downloaded_count = pull_report.downloaded_count();
|
||||||
report.resumed_count = pull_report.resumed_count();
|
report.resumed_count = pull_report.resumed_count();
|
||||||
report.skipped_count = pull_report.skipped_count();
|
report.skipped_count = pull_report.skipped_count();
|
||||||
|
report.release_reused_count = pull_report.release_reused_count();
|
||||||
|
report.cas_reused_count = pull_report.cas_reused_count();
|
||||||
|
report.reused_bytes = pull_report.reused_bytes();
|
||||||
|
report.reuse_warnings = pull_report.reuse_warnings.clone();
|
||||||
report.final_bytes = pull_report.total_bytes();
|
report.final_bytes = pull_report.total_bytes();
|
||||||
report.transferred_bytes = pull_report.transferred_bytes();
|
report.transferred_bytes = pull_report.transferred_bytes();
|
||||||
report.official_seed_hash_verified_count = pull_report.official_hash_verified_count();
|
report.official_seed_hash_verified_count = pull_report.official_hash_verified_count();
|
||||||
@@ -2050,7 +2255,11 @@ impl OfficialUpdateService {
|
|||||||
|
|
||||||
// 清理未被最新版本状态引用的孤儿 staging 目录(GC 失败仅告警,不影响发布结果)。
|
// 清理未被最新版本状态引用的孤儿 staging 目录(GC 失败仅告警,不影响发布结果)。
|
||||||
match read_version_state(&version_state_path) {
|
match read_version_state(&version_state_path) {
|
||||||
Ok(Some(state)) => match gc_orphan_staging(&config.output_root, &state) {
|
Ok(Some(state)) => match gc_orphan_staging_with_cas_root(
|
||||||
|
&config.output_root,
|
||||||
|
&state,
|
||||||
|
&config.effective_import_cas_root(),
|
||||||
|
) {
|
||||||
Ok(removed) if !removed.is_empty() => progress(OfficialUpdateProgress::new(
|
Ok(removed) if !removed.is_empty() => progress(OfficialUpdateProgress::new(
|
||||||
"publish",
|
"publish",
|
||||||
format!("已清理 {} 个孤儿 staging 目录", removed.len()),
|
format!("已清理 {} 个孤儿 staging 目录", removed.len()),
|
||||||
@@ -2080,6 +2289,77 @@ impl OfficialUpdateService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Runs the explicit full local verification and records its result for the
|
||||||
|
/// lightweight current-distribution health RPC.
|
||||||
|
///
|
||||||
|
/// This is intentionally called only by the explicit verify task/command. The
|
||||||
|
/// high-frequency health path reads the resulting attestation and never hashes
|
||||||
|
/// resource artifacts.
|
||||||
|
pub fn verify_and_record_official_distribution_attestation(
|
||||||
|
config: &OfficialUpdateConfig,
|
||||||
|
max_age_seconds: u64,
|
||||||
|
) -> anyhow::Result<OfficialDistributionAttestation> {
|
||||||
|
let version_state = read_version_state(&config.version_state_path())?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("官方版本状态不存在,无法记录 distribution attestation"))?;
|
||||||
|
let record = version_state
|
||||||
|
.current_completed_version
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("没有当前已发布官方 release,无法记录 distribution attestation")
|
||||||
|
})?;
|
||||||
|
let resource_root = OfficialPublishLayout::new(&config.output_root)
|
||||||
|
.active_resource_root()
|
||||||
|
.map_err(anyhow::Error::msg)?;
|
||||||
|
if resource_root != record.resource_root {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"current resource root 与版本状态不一致:current={} state={}",
|
||||||
|
resource_root.display(),
|
||||||
|
record.resource_root.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let verification_result =
|
||||||
|
OfficialResourcePullService::with_curl_command(&resource_root, &config.curl_command)
|
||||||
|
.with_proxy_config(config.curl_proxy.clone())
|
||||||
|
.verify_local_download_manifest();
|
||||||
|
let verification = match verification_result {
|
||||||
|
Ok(verification) => verification,
|
||||||
|
Err(error) => {
|
||||||
|
write_official_distribution_attestation_at(
|
||||||
|
&resource_root,
|
||||||
|
&resource_root,
|
||||||
|
&record.id,
|
||||||
|
"invalid",
|
||||||
|
max_age_seconds,
|
||||||
|
vec![format!("本地 manifest 验证失败:{error}")],
|
||||||
|
)
|
||||||
|
.map_err(anyhow::Error::msg)?;
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"本地 manifest 验证失败,已立即使当前 distribution attestation 失效:{error}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let diagnostics = verification
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.filter(|item| !item.status.is_verified())
|
||||||
|
.map(|item| format!("{}: {}", item.destination.display(), item.status.as_str()))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let integrity_status = if verification.is_clean() {
|
||||||
|
"verified"
|
||||||
|
} else {
|
||||||
|
"invalid"
|
||||||
|
};
|
||||||
|
write_official_distribution_attestation_at(
|
||||||
|
&resource_root,
|
||||||
|
&resource_root,
|
||||||
|
&record.id,
|
||||||
|
integrity_status,
|
||||||
|
max_age_seconds,
|
||||||
|
diagnostics,
|
||||||
|
)
|
||||||
|
.map_err(anyhow::Error::msg)
|
||||||
|
}
|
||||||
|
|
||||||
fn run_post_sync_resource_handoff(
|
fn run_post_sync_resource_handoff(
|
||||||
previous_resource_root: Option<&Path>,
|
previous_resource_root: Option<&Path>,
|
||||||
current_resource_root: &Path,
|
current_resource_root: &Path,
|
||||||
@@ -2556,6 +2836,10 @@ fn waiting_for_official_resources_report(
|
|||||||
downloaded_count: 0,
|
downloaded_count: 0,
|
||||||
resumed_count: 0,
|
resumed_count: 0,
|
||||||
skipped_count: 0,
|
skipped_count: 0,
|
||||||
|
release_reused_count: 0,
|
||||||
|
cas_reused_count: 0,
|
||||||
|
reused_bytes: 0,
|
||||||
|
reuse_warnings: Vec::new(),
|
||||||
final_bytes: 0,
|
final_bytes: 0,
|
||||||
transferred_bytes: 0,
|
transferred_bytes: 0,
|
||||||
official_seed_hash_verified_count: 0,
|
official_seed_hash_verified_count: 0,
|
||||||
@@ -2874,6 +3158,8 @@ fn verification_progress_message(summary: &OfficialVerificationSummary) -> Strin
|
|||||||
fn localized_pull_status(status: crate::OfficialResourcePullStatus) -> &'static str {
|
fn localized_pull_status(status: crate::OfficialResourcePullStatus) -> &'static str {
|
||||||
match status {
|
match status {
|
||||||
crate::OfficialResourcePullStatus::SkippedExisting => "已复用",
|
crate::OfficialResourcePullStatus::SkippedExisting => "已复用",
|
||||||
|
crate::OfficialResourcePullStatus::ReleaseReused => "已复用历史 release",
|
||||||
|
crate::OfficialResourcePullStatus::CasReused => "已复用 CAS",
|
||||||
crate::OfficialResourcePullStatus::Resumed => "已续传",
|
crate::OfficialResourcePullStatus::Resumed => "已续传",
|
||||||
crate::OfficialResourcePullStatus::Downloaded => "已下载",
|
crate::OfficialResourcePullStatus::Downloaded => "已下载",
|
||||||
}
|
}
|
||||||
@@ -3115,6 +3401,8 @@ fn copy_tree_no_symlink(
|
|||||||
| OFFICIAL_VERSIONS_DIR
|
| OFFICIAL_VERSIONS_DIR
|
||||||
| OFFICIAL_CURRENT_LINK
|
| OFFICIAL_CURRENT_LINK
|
||||||
| ".official-sync.lock"
|
| ".official-sync.lock"
|
||||||
|
| ".cas-owner-scope"
|
||||||
|
| OFFICIAL_DISTRIBUTION_PUBLICATION_FILE
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -3144,7 +3432,15 @@ fn copy_tree_no_symlink(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Err(_error) = fs::hard_link(&source_path, &destination_path) {
|
if is_release_local_mutable_state(&source_path) {
|
||||||
|
fs::copy(&source_path, &destination_path).map_err(|copy_error| {
|
||||||
|
format!(
|
||||||
|
"复制官方 release mutable state 失败 {} -> {}:{copy_error}",
|
||||||
|
source_path.display(),
|
||||||
|
destination_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
} else if let Err(_error) = fs::hard_link(&source_path, &destination_path) {
|
||||||
fs::copy(&source_path, &destination_path).map_err(|copy_error| {
|
fs::copy(&source_path, &destination_path).map_err(|copy_error| {
|
||||||
format!(
|
format!(
|
||||||
"复制官方资源到 staging 失败 {} -> {}:{copy_error}",
|
"复制官方资源到 staging 失败 {} -> {}:{copy_error}",
|
||||||
@@ -3163,6 +3459,29 @@ fn copy_tree_no_symlink(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_release_local_mutable_state(path: &Path) -> bool {
|
||||||
|
matches!(
|
||||||
|
path.file_name().and_then(|name| name.to_str()),
|
||||||
|
Some(
|
||||||
|
OFFICIAL_DOWNLOAD_MANIFEST_FILE
|
||||||
|
| OFFICIAL_SYNC_SNAPSHOT_FILE
|
||||||
|
| "official-parse-cache.json"
|
||||||
|
| "official-textunit-index.json"
|
||||||
|
| "official-textunit-tasks.json"
|
||||||
|
| "crowdin-textunit-queue.json"
|
||||||
|
| "official-resource-changes.json"
|
||||||
|
| "crowdin-translation-handoff.json"
|
||||||
|
| "translation-tasks.sqlite"
|
||||||
|
| "translation-tasks.sqlite-wal"
|
||||||
|
| "translation-tasks.sqlite-shm"
|
||||||
|
| "translation-handoff.json"
|
||||||
|
| OFFICIAL_LAUNCHER_BOOTSTRAP_FILE
|
||||||
|
| OFFICIAL_LAUNCHER_BOOTSTRAP_PENDING_FILE
|
||||||
|
| "official-cas-reuse-references.json"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
fn switch_current_symlink(
|
fn switch_current_symlink(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
@@ -3700,12 +4019,22 @@ pub fn gc_orphan_staging(
|
|||||||
output_root: &Path,
|
output_root: &Path,
|
||||||
state: &OfficialVersionState,
|
state: &OfficialVersionState,
|
||||||
) -> anyhow::Result<Vec<PathBuf>> {
|
) -> anyhow::Result<Vec<PathBuf>> {
|
||||||
gc_orphan_staging_dirs(&output_root.join(OFFICIAL_STAGING_DIR), state)
|
gc_orphan_staging_with_cas_root(output_root, state, &output_root.join(".cas"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cleans orphan staging directories and releases their recorded CAS refs.
|
||||||
|
pub fn gc_orphan_staging_with_cas_root(
|
||||||
|
output_root: &Path,
|
||||||
|
state: &OfficialVersionState,
|
||||||
|
cas_root: &Path,
|
||||||
|
) -> anyhow::Result<Vec<PathBuf>> {
|
||||||
|
gc_orphan_staging_dirs(&output_root.join(OFFICIAL_STAGING_DIR), state, cas_root)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn gc_orphan_staging_dirs(
|
fn gc_orphan_staging_dirs(
|
||||||
staging_dir: &Path,
|
staging_dir: &Path,
|
||||||
state: &OfficialVersionState,
|
state: &OfficialVersionState,
|
||||||
|
cas_root: &Path,
|
||||||
) -> anyhow::Result<Vec<PathBuf>> {
|
) -> anyhow::Result<Vec<PathBuf>> {
|
||||||
let read_dir = match fs::read_dir(staging_dir) {
|
let read_dir = match fs::read_dir(staging_dir) {
|
||||||
Ok(read_dir) => read_dir,
|
Ok(read_dir) => read_dir,
|
||||||
@@ -3740,6 +4069,7 @@ fn gc_orphan_staging_dirs(
|
|||||||
if referenced.contains(&name) {
|
if referenced.contains(&name) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
release_cas_reuse_references(&path, cas_root).map_err(anyhow::Error::msg)?;
|
||||||
fs::remove_dir_all(&path).map_err(|error| {
|
fs::remove_dir_all(&path).map_err(|error| {
|
||||||
anyhow::anyhow!("清理孤儿 staging 目录失败 {}:{error}", path.display())
|
anyhow::anyhow!("清理孤儿 staging 目录失败 {}:{error}", path.display())
|
||||||
})?;
|
})?;
|
||||||
@@ -4187,21 +4517,22 @@ fn required_platform(endpoint: &YostarJpResourceEndpoint) -> anyhow::Result<Patc
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct OfficialUpdateLock {
|
pub(crate) struct OfficialUpdateLock {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OfficialUpdateLock {
|
impl OfficialUpdateLock {
|
||||||
fn acquire(config: &OfficialUpdateConfig) -> anyhow::Result<Self> {
|
fn acquire(config: &OfficialUpdateConfig) -> anyhow::Result<Self> {
|
||||||
validate_output_root(&config.output_root).map_err(anyhow::Error::msg)?;
|
Self::acquire_output_root(&config.output_root)
|
||||||
ensure_safe_directory_path(&config.output_root, "资源输出目录")
|
}
|
||||||
.map_err(anyhow::Error::msg)?;
|
|
||||||
fs::create_dir_all(&config.output_root)?;
|
pub(crate) fn acquire_output_root(output_root: &Path) -> anyhow::Result<Self> {
|
||||||
ensure_safe_directory_path(&config.output_root, "资源输出目录")
|
validate_output_root(output_root).map_err(anyhow::Error::msg)?;
|
||||||
.map_err(anyhow::Error::msg)?;
|
ensure_safe_directory_path(output_root, "资源输出目录").map_err(anyhow::Error::msg)?;
|
||||||
let path = config.lock_path();
|
fs::create_dir_all(output_root)?;
|
||||||
ensure_safe_file_target(&config.output_root, &path, "官方同步锁")
|
ensure_safe_directory_path(output_root, "资源输出目录").map_err(anyhow::Error::msg)?;
|
||||||
.map_err(anyhow::Error::msg)?;
|
let path = output_root.join(".official-sync.lock");
|
||||||
|
ensure_safe_file_target(output_root, &path, "官方同步锁").map_err(anyhow::Error::msg)?;
|
||||||
for attempt in 0..=1 {
|
for attempt in 0..=1 {
|
||||||
let mut options = OpenOptions::new();
|
let mut options = OpenOptions::new();
|
||||||
options.write(true).create_new(true);
|
options.write(true).create_new(true);
|
||||||
@@ -4239,6 +4570,12 @@ impl OfficialUpdateLock {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn acquire_official_output_lock(
|
||||||
|
output_root: &Path,
|
||||||
|
) -> anyhow::Result<OfficialUpdateLock> {
|
||||||
|
OfficialUpdateLock::acquire_output_root(output_root)
|
||||||
|
}
|
||||||
|
|
||||||
impl Drop for OfficialUpdateLock {
|
impl Drop for OfficialUpdateLock {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
let expected = std::process::id().to_string();
|
let expected = std::process::id().to_string();
|
||||||
@@ -4324,6 +4661,60 @@ fn process_exists(_pid: u32) -> bool {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn staging_copy_does_not_hard_link_mutable_translation_state() {
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
|
||||||
|
let temp = tempfile::TempDir::new().unwrap();
|
||||||
|
let source = temp.path().join("old");
|
||||||
|
let destination = temp.path().join("new");
|
||||||
|
fs::create_dir_all(&source).unwrap();
|
||||||
|
let mutable_files = [
|
||||||
|
OFFICIAL_DOWNLOAD_MANIFEST_FILE,
|
||||||
|
OFFICIAL_SYNC_SNAPSHOT_FILE,
|
||||||
|
"official-parse-cache.json",
|
||||||
|
"official-textunit-index.json",
|
||||||
|
"official-textunit-tasks.json",
|
||||||
|
"crowdin-textunit-queue.json",
|
||||||
|
"official-resource-changes.json",
|
||||||
|
"crowdin-translation-handoff.json",
|
||||||
|
"translation-tasks.sqlite",
|
||||||
|
"translation-tasks.sqlite-wal",
|
||||||
|
"translation-tasks.sqlite-shm",
|
||||||
|
"translation-handoff.json",
|
||||||
|
OFFICIAL_LAUNCHER_BOOTSTRAP_FILE,
|
||||||
|
OFFICIAL_LAUNCHER_BOOTSTRAP_PENDING_FILE,
|
||||||
|
"official-cas-reuse-references.json",
|
||||||
|
];
|
||||||
|
for (index, name) in mutable_files.iter().enumerate() {
|
||||||
|
fs::write(source.join(name), format!("old-{index}")).unwrap();
|
||||||
|
}
|
||||||
|
fs::write(source.join("immutable.bundle"), b"payload").unwrap();
|
||||||
|
|
||||||
|
copy_tree_no_symlink(&source, &destination, false).unwrap();
|
||||||
|
|
||||||
|
for (index, name) in mutable_files.iter().enumerate() {
|
||||||
|
assert_ne!(
|
||||||
|
fs::metadata(source.join(name)).unwrap().ino(),
|
||||||
|
fs::metadata(destination.join(name)).unwrap().ino(),
|
||||||
|
"mutable state unexpectedly hard-linked: {name}"
|
||||||
|
);
|
||||||
|
fs::write(destination.join(name), format!("new-{index}")).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(source.join(name)).unwrap(),
|
||||||
|
format!("old-{index}").as_bytes(),
|
||||||
|
"historical mutable state changed: {name}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
fs::metadata(source.join("immutable.bundle")).unwrap().ino(),
|
||||||
|
fs::metadata(destination.join("immutable.bundle"))
|
||||||
|
.unwrap()
|
||||||
|
.ino()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn missing_app_version_carries_input_error_code() {
|
fn missing_app_version_carries_input_error_code() {
|
||||||
// 未启用 auto-discover 且未传 app-version:配置校验失败应携带
|
// 未启用 auto-discover 且未传 app-version:配置校验失败应携带
|
||||||
@@ -4684,6 +5075,227 @@ mod tests {
|
|||||||
assert_eq!(read_version_state(&path).unwrap(), Some(state));
|
assert_eq!(read_version_state(&path).unwrap(), Some(state));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn publication_attestation_keeps_canonical_root_across_staging_rename() {
|
||||||
|
use crate::official_download::{
|
||||||
|
official_distribution_max_age_seconds, write_official_distribution_attestation_at,
|
||||||
|
write_official_distribution_publication_anchor_at, OfficialDownloadManifest,
|
||||||
|
OfficialDownloadManifestEntry,
|
||||||
|
};
|
||||||
|
|
||||||
|
let temp = tempfile::TempDir::new().unwrap();
|
||||||
|
let root = temp.path().join("official");
|
||||||
|
let layout = OfficialPublishLayout::new(&root);
|
||||||
|
let staging = layout.staging_dir.join("release-a");
|
||||||
|
let version = layout.versions_dir.join("release-a");
|
||||||
|
fs::create_dir_all(&staging).unwrap();
|
||||||
|
fs::create_dir_all(&layout.versions_dir).unwrap();
|
||||||
|
|
||||||
|
let payload = b"official";
|
||||||
|
fs::write(staging.join("data.bin"), payload).unwrap();
|
||||||
|
let url = "https://example.invalid/data.bin".to_string();
|
||||||
|
let mut manifest = OfficialDownloadManifest {
|
||||||
|
entries: [(
|
||||||
|
url.clone(),
|
||||||
|
OfficialDownloadManifestEntry {
|
||||||
|
url: url.clone(),
|
||||||
|
destination: "data.bin".to_string(),
|
||||||
|
bytes: payload.len() as u64,
|
||||||
|
blake3: blake3::hash(payload).to_hex().to_string(),
|
||||||
|
},
|
||||||
|
)]
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
|
..OfficialDownloadManifest::default()
|
||||||
|
};
|
||||||
|
manifest.destination_index = [("data.bin".to_string(), url)].into_iter().collect();
|
||||||
|
manifest.distribution_mapping_identity =
|
||||||
|
Some(crate::official_download::official_distribution_mapping_identity(&manifest));
|
||||||
|
fs::write(
|
||||||
|
staging.join(OFFICIAL_DOWNLOAD_MANIFEST_FILE),
|
||||||
|
serde_json::to_vec(&manifest).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
write_official_distribution_publication_anchor_at(&staging, "release-a").unwrap();
|
||||||
|
let max_age = official_distribution_max_age_seconds(3600, 60);
|
||||||
|
let before_publish = write_official_distribution_attestation_at(
|
||||||
|
&staging,
|
||||||
|
&version,
|
||||||
|
"release-a",
|
||||||
|
"verified",
|
||||||
|
max_age,
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(before_publish.resource_root, version);
|
||||||
|
assert_eq!(before_publish.verification_generation, 1);
|
||||||
|
|
||||||
|
let plan = OfficialPublishPlan {
|
||||||
|
id: "release-a".to_string(),
|
||||||
|
staging_path: staging,
|
||||||
|
version_path: version.clone(),
|
||||||
|
reuse_existing_staging: false,
|
||||||
|
};
|
||||||
|
let published = layout.publish(&plan).unwrap();
|
||||||
|
assert_eq!(published, version);
|
||||||
|
let after_rename =
|
||||||
|
crate::official_download::read_official_distribution_attestation_at(&version)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(after_rename.resource_root, version);
|
||||||
|
assert_eq!(
|
||||||
|
after_rename.verification_generation,
|
||||||
|
before_publish.verification_generation
|
||||||
|
);
|
||||||
|
|
||||||
|
let snapshot_path = version.join(OFFICIAL_SYNC_SNAPSHOT_FILE);
|
||||||
|
let snapshot = OfficialUpdateSnapshot::new(fixture_base_snapshot(), Vec::new(), None);
|
||||||
|
write_snapshot(&snapshot_path, &snapshot).unwrap();
|
||||||
|
let state = OfficialVersionState {
|
||||||
|
current_completed_version: Some(OfficialVersionRecord {
|
||||||
|
id: "release-a".to_string(),
|
||||||
|
app_version: snapshot.app_version.clone(),
|
||||||
|
bundle_version: snapshot.bundle_version.clone(),
|
||||||
|
addressables_root: snapshot.addressables_root.clone(),
|
||||||
|
resource_root: version.clone(),
|
||||||
|
snapshot_path,
|
||||||
|
staging_path: None,
|
||||||
|
version_path: Some(version.clone()),
|
||||||
|
started_unix_seconds: Some(1),
|
||||||
|
completed_unix_seconds: Some(2),
|
||||||
|
}),
|
||||||
|
..OfficialVersionState::default()
|
||||||
|
};
|
||||||
|
write_version_state(&root.join(OFFICIAL_VERSION_STATE_FILE), &state).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_link(root.join(OFFICIAL_CURRENT_LINK)).unwrap(),
|
||||||
|
Path::new(OFFICIAL_VERSIONS_DIR).join("release-a")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state
|
||||||
|
.current_completed_version
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.resource_root,
|
||||||
|
after_rename.resource_root
|
||||||
|
);
|
||||||
|
let report = crate::release_ops::build_official_distribution_attestation(&root).unwrap();
|
||||||
|
assert!(report.available);
|
||||||
|
assert!(report.ready);
|
||||||
|
assert_eq!(report.release_id, "release-a");
|
||||||
|
assert_eq!(report.resource_root, version.display().to_string());
|
||||||
|
assert_eq!(report.verification_generation, 1);
|
||||||
|
|
||||||
|
// Keep this assertion explicit: current is the only pointer used by
|
||||||
|
// the read path, and the attestation never stores the staging path.
|
||||||
|
assert!(!after_rename.resource_root.starts_with(layout.staging_dir));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_local_verification_invalidates_previous_ready_generation() {
|
||||||
|
use crate::official_download::{
|
||||||
|
official_distribution_max_age_seconds, write_official_distribution_attestation_at,
|
||||||
|
write_official_distribution_publication_anchor_at, OfficialDownloadManifest,
|
||||||
|
OfficialDownloadManifestEntry,
|
||||||
|
};
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
|
let temp = tempfile::TempDir::new().unwrap();
|
||||||
|
let root = temp.path().join("official");
|
||||||
|
let version = root.join(OFFICIAL_VERSIONS_DIR).join("release-a");
|
||||||
|
fs::create_dir_all(&version).unwrap();
|
||||||
|
let payload = b"official";
|
||||||
|
let url = "https://example.invalid/data.bin".to_string();
|
||||||
|
let mut manifest = OfficialDownloadManifest {
|
||||||
|
entries: [(
|
||||||
|
url.clone(),
|
||||||
|
OfficialDownloadManifestEntry {
|
||||||
|
url,
|
||||||
|
destination: "data.bin".to_string(),
|
||||||
|
bytes: payload.len() as u64,
|
||||||
|
blake3: blake3::hash(payload).to_hex().to_string(),
|
||||||
|
},
|
||||||
|
)]
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
|
..OfficialDownloadManifest::default()
|
||||||
|
};
|
||||||
|
manifest.destination_index = [(
|
||||||
|
"data.bin".to_string(),
|
||||||
|
"https://example.invalid/data.bin".to_string(),
|
||||||
|
)]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
manifest.distribution_mapping_identity =
|
||||||
|
Some(crate::official_download::official_distribution_mapping_identity(&manifest));
|
||||||
|
fs::write(
|
||||||
|
version.join(OFFICIAL_DOWNLOAD_MANIFEST_FILE),
|
||||||
|
serde_json::to_vec(&manifest).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(version.join("data.bin"), payload).unwrap();
|
||||||
|
write_official_distribution_publication_anchor_at(&version, "release-a").unwrap();
|
||||||
|
let max_age = official_distribution_max_age_seconds(3600, 60);
|
||||||
|
let initial = write_official_distribution_attestation_at(
|
||||||
|
&version,
|
||||||
|
&version,
|
||||||
|
"release-a",
|
||||||
|
"verified",
|
||||||
|
max_age,
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
symlink(
|
||||||
|
Path::new(OFFICIAL_VERSIONS_DIR).join("release-a"),
|
||||||
|
root.join(OFFICIAL_CURRENT_LINK),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let snapshot_path = version.join(OFFICIAL_SYNC_SNAPSHOT_FILE);
|
||||||
|
write_snapshot(
|
||||||
|
&snapshot_path,
|
||||||
|
&OfficialUpdateSnapshot::new(fixture_base_snapshot(), Vec::new(), None),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
write_version_state(
|
||||||
|
&root.join(OFFICIAL_VERSION_STATE_FILE),
|
||||||
|
&OfficialVersionState {
|
||||||
|
current_completed_version: Some(OfficialVersionRecord {
|
||||||
|
id: "release-a".to_string(),
|
||||||
|
app_version: "app".to_string(),
|
||||||
|
bundle_version: None,
|
||||||
|
addressables_root: "root".to_string(),
|
||||||
|
resource_root: version.clone(),
|
||||||
|
snapshot_path,
|
||||||
|
staging_path: None,
|
||||||
|
version_path: Some(version.clone()),
|
||||||
|
started_unix_seconds: Some(1),
|
||||||
|
completed_unix_seconds: Some(2),
|
||||||
|
}),
|
||||||
|
..OfficialVersionState::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
fs::write(version.join(OFFICIAL_DOWNLOAD_MANIFEST_FILE), b"{malformed").unwrap();
|
||||||
|
let config = OfficialUpdateConfig {
|
||||||
|
output_root: root,
|
||||||
|
..OfficialUpdateConfig::default()
|
||||||
|
};
|
||||||
|
assert!(verify_and_record_official_distribution_attestation(&config, max_age).is_err());
|
||||||
|
let invalid = crate::official_download::read_official_distribution_attestation_at(&version)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
invalid.verification_generation,
|
||||||
|
initial.verification_generation + 1
|
||||||
|
);
|
||||||
|
assert_eq!(invalid.integrity_status, "invalid");
|
||||||
|
assert!(!invalid.ready);
|
||||||
|
assert!(invalid.verified_at.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn version_state_tracks_in_progress_success_and_failure() {
|
fn version_state_tracks_in_progress_success_and_failure() {
|
||||||
let temp = tempfile::TempDir::new().unwrap();
|
let temp = tempfile::TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ pub enum ReleaseFlowStatusCode {
|
|||||||
/// Translation tasks have been queued to local offline handoff files.
|
/// Translation tasks have been queued to local offline handoff files.
|
||||||
#[serde(rename = "translation.queued_offline")]
|
#[serde(rename = "translation.queued_offline")]
|
||||||
TranslationQueuedOffline,
|
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.
|
/// Localized publication is blocked because there is no official release.
|
||||||
#[serde(rename = "localized.blocked_official")]
|
#[serde(rename = "localized.blocked_official")]
|
||||||
LocalizedBlockedOfficial,
|
LocalizedBlockedOfficial,
|
||||||
@@ -75,6 +78,10 @@ pub enum ReleaseFlowStatusCode {
|
|||||||
/// A localized release is published and matches the current official release.
|
/// A localized release is published and matches the current official release.
|
||||||
#[serde(rename = "localized.published")]
|
#[serde(rename = "localized.published")]
|
||||||
LocalizedPublished,
|
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.
|
/// Distribution cannot serve a usable release for the observed channel.
|
||||||
#[serde(rename = "distribution.blocked")]
|
#[serde(rename = "distribution.blocked")]
|
||||||
DistributionBlocked,
|
DistributionBlocked,
|
||||||
@@ -105,10 +112,12 @@ impl ReleaseFlowStatusCode {
|
|||||||
Self::TranslationUnavailable => "translation.unavailable",
|
Self::TranslationUnavailable => "translation.unavailable",
|
||||||
Self::TranslationHandoffPreparing => "translation.handoff_preparing",
|
Self::TranslationHandoffPreparing => "translation.handoff_preparing",
|
||||||
Self::TranslationQueuedOffline => "translation.queued_offline",
|
Self::TranslationQueuedOffline => "translation.queued_offline",
|
||||||
|
Self::TranslationManualProofreading => "translation.manual_proofreading",
|
||||||
Self::LocalizedBlockedOfficial => "localized.blocked_official",
|
Self::LocalizedBlockedOfficial => "localized.blocked_official",
|
||||||
Self::LocalizedPending => "localized.pending",
|
Self::LocalizedPending => "localized.pending",
|
||||||
Self::LocalizedStale => "localized.stale",
|
Self::LocalizedStale => "localized.stale",
|
||||||
Self::LocalizedPublished => "localized.published",
|
Self::LocalizedPublished => "localized.published",
|
||||||
|
Self::LocalizedDegraded => "localized.degraded",
|
||||||
Self::DistributionBlocked => "distribution.blocked",
|
Self::DistributionBlocked => "distribution.blocked",
|
||||||
Self::DistributionReady => "distribution.ready",
|
Self::DistributionReady => "distribution.ready",
|
||||||
}
|
}
|
||||||
@@ -137,10 +146,12 @@ impl ReleaseFlowStatusCode {
|
|||||||
Self::TranslationUnavailable => "unavailable",
|
Self::TranslationUnavailable => "unavailable",
|
||||||
Self::TranslationHandoffPreparing => "handoff_preparing",
|
Self::TranslationHandoffPreparing => "handoff_preparing",
|
||||||
Self::TranslationQueuedOffline => "queued_offline",
|
Self::TranslationQueuedOffline => "queued_offline",
|
||||||
|
Self::TranslationManualProofreading => "manual_proofreading",
|
||||||
Self::LocalizedBlockedOfficial => "blocked_official",
|
Self::LocalizedBlockedOfficial => "blocked_official",
|
||||||
Self::LocalizedPending => "pending",
|
Self::LocalizedPending => "pending",
|
||||||
Self::LocalizedStale => "stale",
|
Self::LocalizedStale => "stale",
|
||||||
Self::LocalizedPublished => "published",
|
Self::LocalizedPublished => "published",
|
||||||
|
Self::LocalizedDegraded => "degraded",
|
||||||
Self::DistributionBlocked => "blocked",
|
Self::DistributionBlocked => "blocked",
|
||||||
Self::DistributionReady => "ready",
|
Self::DistributionReady => "ready",
|
||||||
}
|
}
|
||||||
@@ -167,10 +178,12 @@ impl ReleaseFlowStatusCode {
|
|||||||
"translation.unavailable" => Self::TranslationUnavailable,
|
"translation.unavailable" => Self::TranslationUnavailable,
|
||||||
"translation.handoff_preparing" => Self::TranslationHandoffPreparing,
|
"translation.handoff_preparing" => Self::TranslationHandoffPreparing,
|
||||||
"translation.queued_offline" => Self::TranslationQueuedOffline,
|
"translation.queued_offline" => Self::TranslationQueuedOffline,
|
||||||
|
"translation.manual_proofreading" => Self::TranslationManualProofreading,
|
||||||
"localized.blocked_official" => Self::LocalizedBlockedOfficial,
|
"localized.blocked_official" => Self::LocalizedBlockedOfficial,
|
||||||
"localized.pending" => Self::LocalizedPending,
|
"localized.pending" => Self::LocalizedPending,
|
||||||
"localized.stale" => Self::LocalizedStale,
|
"localized.stale" => Self::LocalizedStale,
|
||||||
"localized.published" => Self::LocalizedPublished,
|
"localized.published" => Self::LocalizedPublished,
|
||||||
|
"localized.degraded" => Self::LocalizedDegraded,
|
||||||
"distribution.blocked" => Self::DistributionBlocked,
|
"distribution.blocked" => Self::DistributionBlocked,
|
||||||
"distribution.ready" => Self::DistributionReady,
|
"distribution.ready" => Self::DistributionReady,
|
||||||
_ => return None,
|
_ => return None,
|
||||||
@@ -197,11 +210,13 @@ impl ReleaseFlowStatusCode {
|
|||||||
| Self::ParseCompletedWithErrors => "parse",
|
| Self::ParseCompletedWithErrors => "parse",
|
||||||
Self::TranslationUnavailable
|
Self::TranslationUnavailable
|
||||||
| Self::TranslationHandoffPreparing
|
| Self::TranslationHandoffPreparing
|
||||||
| Self::TranslationQueuedOffline => "translation",
|
| Self::TranslationQueuedOffline
|
||||||
|
| Self::TranslationManualProofreading => "translation",
|
||||||
Self::LocalizedBlockedOfficial
|
Self::LocalizedBlockedOfficial
|
||||||
| Self::LocalizedPending
|
| Self::LocalizedPending
|
||||||
| Self::LocalizedStale
|
| Self::LocalizedStale
|
||||||
| Self::LocalizedPublished => "localized_publish",
|
| Self::LocalizedPublished
|
||||||
|
| Self::LocalizedDegraded => "localized_publish",
|
||||||
Self::DistributionBlocked | Self::DistributionReady => "distribution",
|
Self::DistributionBlocked | Self::DistributionReady => "distribution",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -288,10 +303,12 @@ mod tests {
|
|||||||
ReleaseFlowStatusCode::TranslationUnavailable,
|
ReleaseFlowStatusCode::TranslationUnavailable,
|
||||||
ReleaseFlowStatusCode::TranslationHandoffPreparing,
|
ReleaseFlowStatusCode::TranslationHandoffPreparing,
|
||||||
ReleaseFlowStatusCode::TranslationQueuedOffline,
|
ReleaseFlowStatusCode::TranslationQueuedOffline,
|
||||||
|
ReleaseFlowStatusCode::TranslationManualProofreading,
|
||||||
ReleaseFlowStatusCode::LocalizedBlockedOfficial,
|
ReleaseFlowStatusCode::LocalizedBlockedOfficial,
|
||||||
ReleaseFlowStatusCode::LocalizedPending,
|
ReleaseFlowStatusCode::LocalizedPending,
|
||||||
ReleaseFlowStatusCode::LocalizedStale,
|
ReleaseFlowStatusCode::LocalizedStale,
|
||||||
ReleaseFlowStatusCode::LocalizedPublished,
|
ReleaseFlowStatusCode::LocalizedPublished,
|
||||||
|
ReleaseFlowStatusCode::LocalizedDegraded,
|
||||||
ReleaseFlowStatusCode::DistributionBlocked,
|
ReleaseFlowStatusCode::DistributionBlocked,
|
||||||
ReleaseFlowStatusCode::DistributionReady,
|
ReleaseFlowStatusCode::DistributionReady,
|
||||||
];
|
];
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -129,6 +129,8 @@ impl SqliteResourceRepository {
|
|||||||
resource_type TEXT NOT NULL,
|
resource_type TEXT NOT NULL,
|
||||||
local_path TEXT NOT NULL,
|
local_path TEXT NOT NULL,
|
||||||
address TEXT,
|
address TEXT,
|
||||||
|
provider_id TEXT,
|
||||||
|
bundle_name TEXT,
|
||||||
dependencies_json TEXT NOT NULL DEFAULT '[]',
|
dependencies_json TEXT NOT NULL DEFAULT '[]',
|
||||||
crc INTEGER,
|
crc INTEGER,
|
||||||
metadata_json TEXT NOT NULL DEFAULT '{}'
|
metadata_json TEXT NOT NULL DEFAULT '{}'
|
||||||
@@ -148,6 +150,8 @@ impl SqliteResourceRepository {
|
|||||||
"TEXT NOT NULL DEFAULT '{}'",
|
"TEXT NOT NULL DEFAULT '{}'",
|
||||||
)
|
)
|
||||||
.await?;
|
.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::execute_query(
|
||||||
&self.pool,
|
&self.pool,
|
||||||
@@ -182,6 +186,39 @@ impl SqliteResourceRepository {
|
|||||||
)
|
)
|
||||||
.await?;
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,6 +317,8 @@ impl SqliteResourceRepository {
|
|||||||
resource_type,
|
resource_type,
|
||||||
local_path,
|
local_path,
|
||||||
address,
|
address,
|
||||||
|
provider_id,
|
||||||
|
bundle_name,
|
||||||
dependencies_json,
|
dependencies_json,
|
||||||
crc,
|
crc,
|
||||||
metadata_json,
|
metadata_json,
|
||||||
@@ -294,6 +333,8 @@ impl SqliteResourceRepository {
|
|||||||
resource_type: Self::resource_type_from_str(&resource_type)?,
|
resource_type: Self::resource_type_from_str(&resource_type)?,
|
||||||
address,
|
address,
|
||||||
dependencies: Self::dependencies_from_json(&dependencies_json)?,
|
dependencies: Self::dependencies_from_json(&dependencies_json)?,
|
||||||
|
provider_id,
|
||||||
|
bundle_name,
|
||||||
crc: crc.and_then(|value| u32::try_from(value).ok()),
|
crc: crc.and_then(|value| u32::try_from(value).ok()),
|
||||||
},
|
},
|
||||||
metadata: Self::metadata_from_json(&metadata_json)?,
|
metadata: Self::metadata_from_json(&metadata_json)?,
|
||||||
@@ -332,6 +373,51 @@ impl SqliteResourceRepository {
|
|||||||
builder.push_bind(destination);
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,7 +427,7 @@ impl SqliteResourceRepository {
|
|||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
) -> bat_core::Result<Vec<Resource>> {
|
) -> bat_core::Result<Vec<Resource>> {
|
||||||
let mut builder = QueryBuilder::<Sqlite>::new(
|
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)?;
|
Self::apply_filters(&mut builder, query)?;
|
||||||
builder.push(" ORDER BY id");
|
builder.push(" ORDER BY id");
|
||||||
@@ -366,10 +452,6 @@ impl SqliteResourceRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn count_resources(&self, query: &ResourceQuery) -> bat_core::Result<u64> {
|
async fn count_resources(&self, query: &ResourceQuery) -> bat_core::Result<u64> {
|
||||||
if query.requires_resource_scan() {
|
|
||||||
return Ok(self.fetch_resources(query, None).await?.len() as u64);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut builder = QueryBuilder::<Sqlite>::new("SELECT COUNT(*) FROM resources");
|
let mut builder = QueryBuilder::<Sqlite>::new("SELECT COUNT(*) FROM resources");
|
||||||
Self::apply_filters(&mut builder, query)?;
|
Self::apply_filters(&mut builder, query)?;
|
||||||
|
|
||||||
@@ -392,9 +474,9 @@ impl ResourceRepository for SqliteResourceRepository {
|
|||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO resources (
|
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
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
path = excluded.path,
|
path = excluded.path,
|
||||||
hash = excluded.hash,
|
hash = excluded.hash,
|
||||||
@@ -402,6 +484,8 @@ impl ResourceRepository for SqliteResourceRepository {
|
|||||||
resource_type = excluded.resource_type,
|
resource_type = excluded.resource_type,
|
||||||
local_path = excluded.local_path,
|
local_path = excluded.local_path,
|
||||||
address = excluded.address,
|
address = excluded.address,
|
||||||
|
provider_id = excluded.provider_id,
|
||||||
|
bundle_name = excluded.bundle_name,
|
||||||
dependencies_json = excluded.dependencies_json,
|
dependencies_json = excluded.dependencies_json,
|
||||||
crc = excluded.crc,
|
crc = excluded.crc,
|
||||||
metadata_json = excluded.metadata_json
|
metadata_json = excluded.metadata_json
|
||||||
@@ -414,6 +498,8 @@ impl ResourceRepository for SqliteResourceRepository {
|
|||||||
.bind(Self::resource_type_to_str(resource.entry.resource_type))
|
.bind(Self::resource_type_to_str(resource.entry.resource_type))
|
||||||
.bind(resource.local_path.to_string_lossy().to_string())
|
.bind(resource.local_path.to_string_lossy().to_string())
|
||||||
.bind(resource.entry.address.clone())
|
.bind(resource.entry.address.clone())
|
||||||
|
.bind(resource.entry.provider_id.clone())
|
||||||
|
.bind(resource.entry.bundle_name.clone())
|
||||||
.bind(dependencies)
|
.bind(dependencies)
|
||||||
.bind(resource.entry.crc.map(i64::from))
|
.bind(resource.entry.crc.map(i64::from))
|
||||||
.bind(metadata),
|
.bind(metadata),
|
||||||
@@ -426,7 +512,7 @@ impl ResourceRepository for SqliteResourceRepository {
|
|||||||
async fn find_by_id(&self, id: &str) -> bat_core::Result<Resource> {
|
async fn find_by_id(&self, id: &str) -> bat_core::Result<Resource> {
|
||||||
let row: Option<ResourceRow> = sqlx::query_as(
|
let row: Option<ResourceRow> = sqlx::query_as(
|
||||||
r#"
|
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
|
FROM resources
|
||||||
WHERE id = ?1
|
WHERE id = ?1
|
||||||
"#,
|
"#,
|
||||||
@@ -444,7 +530,7 @@ impl ResourceRepository for SqliteResourceRepository {
|
|||||||
async fn find_by_hash(&self, hash: &str) -> bat_core::Result<Resource> {
|
async fn find_by_hash(&self, hash: &str) -> bat_core::Result<Resource> {
|
||||||
let row: Option<ResourceRow> = sqlx::query_as(
|
let row: Option<ResourceRow> = sqlx::query_as(
|
||||||
r#"
|
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
|
FROM resources
|
||||||
WHERE hash = ?1
|
WHERE hash = ?1
|
||||||
ORDER BY id
|
ORDER BY id
|
||||||
@@ -505,6 +591,8 @@ type ResourceRow = (
|
|||||||
String,
|
String,
|
||||||
String,
|
String,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
String,
|
String,
|
||||||
Option<i64>,
|
Option<i64>,
|
||||||
String,
|
String,
|
||||||
@@ -652,6 +740,8 @@ mod tests {
|
|||||||
resource_type,
|
resource_type,
|
||||||
address: None,
|
address: None,
|
||||||
dependencies: Vec::new(),
|
dependencies: Vec::new(),
|
||||||
|
provider_id: None,
|
||||||
|
bundle_name: None,
|
||||||
crc: None,
|
crc: None,
|
||||||
},
|
},
|
||||||
metadata: ResourceMetadata::default(),
|
metadata: ResourceMetadata::default(),
|
||||||
@@ -785,6 +875,20 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn sqlite_repository_persists_and_filters_resources() {
|
async fn sqlite_repository_persists_and_filters_resources() {
|
||||||
let (_temp_dir, repository) = sqlite_repository().await;
|
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(
|
let mut resource = resource(
|
||||||
"resource/sqlite-a",
|
"resource/sqlite-a",
|
||||||
"assets/model.bundle",
|
"assets/model.bundle",
|
||||||
@@ -796,6 +900,10 @@ mod tests {
|
|||||||
.entry
|
.entry
|
||||||
.dependencies
|
.dependencies
|
||||||
.push("assets/shared.bundle".to_string());
|
.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.official_release_id = Some("release-1".to_string());
|
||||||
resource.metadata.platform = Some("windows".to_string());
|
resource.metadata.platform = Some("windows".to_string());
|
||||||
resource.metadata.bundle_path = Some("assets/model.bundle".to_string());
|
resource.metadata.bundle_path = Some("assets/model.bundle".to_string());
|
||||||
@@ -813,6 +921,14 @@ mod tests {
|
|||||||
by_id.entry.dependencies,
|
by_id.entry.dependencies,
|
||||||
vec!["assets/shared.bundle".to_string()]
|
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!(
|
assert_eq!(
|
||||||
by_id.metadata.official_release_id.as_deref(),
|
by_id.metadata.official_release_id.as_deref(),
|
||||||
Some("release-1")
|
Some("release-1")
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+1649
-194
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
+1172
-7
File diff suppressed because it is too large
Load Diff
+1360
-16
File diff suppressed because it is too large
Load Diff
+49
-16
@@ -14,11 +14,6 @@ func (s *Server) serveCDN(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
idx := s.index()
|
|
||||||
if idx == nil || idx.ResourceRoot == "" {
|
|
||||||
http.Error(w, "resource root not ready", http.StatusServiceUnavailable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, rel, err := SplitCDNPath(r.URL.Path)
|
_, rel, err := SplitCDNPath(r.URL.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
@@ -27,15 +22,51 @@ func (s *Server) serveCDN(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
var entry ResourceEntry
|
var entry ResourceEntry
|
||||||
var hasEntry bool
|
var hasEntry bool
|
||||||
if s.cfg.RequireIndexed {
|
resourceRoot := ""
|
||||||
entry, hasEntry = idx.Lookup(rel)
|
explicitRelease := false
|
||||||
if !hasEntry || !entry.Present || !entry.SizeMatch {
|
channel := r.URL.Query().Get("channel")
|
||||||
http.NotFound(w, r)
|
releaseID := r.URL.Query().Get("release_id")
|
||||||
|
if channel != "" || releaseID != "" {
|
||||||
|
explicitRelease = true
|
||||||
|
channel, releaseID, selectorErr := releaseSelector(r)
|
||||||
|
if selectorErr != nil {
|
||||||
|
http.Error(w, selectorErr.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
selected, selectErr := s.loadReleaseDistribution(r, channel, releaseID, rel)
|
||||||
|
if selectErr != nil {
|
||||||
|
http.Error(w, selectErr.Error(), http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if selected == nil || !selected.Available || selected.ResourceRoot == "" {
|
||||||
|
http.Error(w, "selected release is not distributable", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resourceRoot = selected.ResourceRoot
|
||||||
|
entry, hasEntry = releaseDistributionEntry(selected, rel)
|
||||||
|
} else {
|
||||||
|
idx := s.index()
|
||||||
|
if idx == nil || idx.ResourceRoot == "" {
|
||||||
|
http.Error(w, "resource root not ready", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
summary := idx.Summary()
|
||||||
|
if !summary.Ready {
|
||||||
|
// The cached health fact covers the whole current release. A
|
||||||
|
// locally present target is not enough to serve it as a healthy
|
||||||
|
// immutable artifact.
|
||||||
|
http.Error(w, "current release is not distributable", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resourceRoot = idx.ResourceRoot
|
||||||
|
entry, hasEntry = idx.Lookup(rel)
|
||||||
|
}
|
||||||
|
if !hasEntry || !entry.Present || !entry.SizeMatch {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
abs, err := ResolveUnderRoot(idx.ResourceRoot, rel, true)
|
abs, err := ResolveUnderRoot(resourceRoot, rel, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
@@ -45,11 +76,13 @@ func (s *Server) serveCDN(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.cfg.RequireIndexed && s.cfg.VerifySize {
|
if explicitRelease && hasEntry && uint64(info.Size()) != entry.Bytes {
|
||||||
if hasEntry && entry.Bytes > 0 && uint64(info.Size()) != entry.Bytes {
|
http.Error(w, "size mismatch with release index", http.StatusConflict)
|
||||||
http.Error(w, "size mismatch with release index", http.StatusConflict)
|
return
|
||||||
return
|
}
|
||||||
}
|
if !explicitRelease && entry.Bytes > 0 && uint64(info.Size()) != entry.Bytes {
|
||||||
|
http.Error(w, "size mismatch with release index", http.StatusConflict)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
file, err := os.Open(abs)
|
file, err := os.Open(abs)
|
||||||
@@ -57,7 +90,7 @@ func (s *Server) serveCDN(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer func() { _ = file.Close() }()
|
||||||
|
|
||||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
w.Header().Set("ETag", cdnETag(entry, hasEntry, info))
|
w.Header().Set("ETag", cdnETag(entry, hasEntry, info))
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user