mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 05:34:55 +08:00
feat(resource): 增加 CAS 诊断与索引过滤优化
补充 doctor cas 只读诊断、校验 CAS 对象分片布局,并将常用 ResourceRepository metadata 查询下推到 SQLite。 Refs G-011
This commit is contained in:
+2
-2
@@ -27,7 +27,7 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
|
||||
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 会阻止前台写命令直接修改它正在管理的同一目录。
|
||||
11. 官方同步会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。
|
||||
12. `<output>/official-version-state.json` 会明确保存当前已完成版本、正在拉取版本、上一个可用版本和失败版本;同一 app version、bundle version 和 Addressables root 的失败只保留最新一条,同一版本开始重新拉取或后续发布成功时会清理对应失败记录;`bat status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、版本状态摘要、最近历史失败版本和原因、结构化日志路径和轮转日志路径,人类可读输出不会把完整版本状态 JSON 内联打印。
|
||||
13. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,官方同步可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后触发导入,默认 CAS 路径为 `<output>/.cas`、SQLite 索引为 `<output>/resources.sqlite`,也可通过 `--import-cas-root`、`--import-resource-db`、`BAT_IMPORT_CAS_ROOT`、`BAT_IMPORT_RESOURCE_DB` 覆盖;`resource.index` RPC/CLI 可按类型、hash、路径模式、官方 release ID、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 分页查询现有索引,数据库不存在时返回 `available=false` 且不会创建空库。`Resource` metadata 已通过 `metadata_json` 兼容迁移保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式;当前/上一个/结构变化 catalog、失败 staging 复用、403/404、hash mismatch、CRC、metadata 迁移与 UnityFS 边界校验均有离线回归 fixture 或单测覆盖。
|
||||
13. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,官方同步可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后触发导入,默认 CAS 路径为 `<output>/.cas`、SQLite 索引为 `<output>/resources.sqlite`,也可通过 `--import-cas-root`、`--import-resource-db`、`BAT_IMPORT_CAS_ROOT`、`BAT_IMPORT_RESOURCE_DB` 覆盖;`resource.index` RPC/CLI 可按类型、hash、路径模式、官方 release ID、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 分页查询现有索引,release、平台、bundle path 和常用数组 metadata 过滤已下推到 SQLite,数据库不存在时返回 `available=false` 且不会创建空库;`bat doctor cas` 可只读检查既有 CAS 根目录、对象目录、元数据库文件和对象统计,不会因诊断创建空库。`Resource` metadata 已通过 `metadata_json` 兼容迁移保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式;当前/上一个/结构变化 catalog、失败 staging 复用、403/404、hash mismatch、CRC、metadata 迁移与 UnityFS 边界校验均有离线回归 fixture 或单测覆盖。
|
||||
14. 非 dry-run 官方同步在校验完成并发布后,会先对比上一完整 release 与当前 release 的 `official-download-manifest.json`,在当前 release 下写入 `official-resource-changes.json` 和 `crowdin-translation-handoff.json`;同一 destination 只有 size 或 BLAKE3 改变才算 modified,仅 URL/CDN 根变化但内容相同不会触发解析/翻译候选。随后刷新 `official-parse-cache.json` 和 `official-textunit-index.json`,并从 Added/Modified 资源、parse cache 与 TextUnit 明细索引派生 `official-textunit-tasks.json` 和 `crowdin-textunit-queue.json`;删除资源只进入差异记录,不进入 TextUnit/Crowdin 队列。`parse.text_units` 和 `parse.errors` RPC/CLI 可按 destination、archive entry、path id、class id、field path 和 format 查询当前 release 的 TextUnit 明细与解析错误;`translation.tasks` RPC/CLI 可按 release、destination、archive entry、任务状态、parse status、TextUnit format 和 reason presence 查询离线 TextUnit 翻译任务状态与跳过/失败原因;`translation.task.update` 可回写 provider worker 状态,`translation.worker.run` 可触发 Rust provider worker 独立 claim/lease/retry 并落库 TextUnit 级译文结果,`translation.proofread` 可把汉化 workflow 标记为人工校对中;TextUnit 已包含 class id、field path、字段 offset/byte size 等可追溯定位。Crowdin provider 通过 `CROWDIN_*` 环境变量接入,mock provider 支持本地 fixture;up-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析。官方同步报告默认 `localized_release_status=not_localized`,含义是原版资源已经发布、汉化资源未发布;受支持的 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 发布成功后会写带 trace 的 `localized-patch-manifest.json`,校验 hash/size/diff/rollback 后才允许 `localized.status` 返回 `status=published`、`status_code=localized.published` 和 `localized_release_status=localized`,并可用 `localized.rollback` 显式恢复上一 release。`bat` 首次启动会在二进制所在目录释放 `.env` 配置模板(`0600`),之后每次启动自动加载(不覆盖已存在的环境变量),支持 `BAT_OUTPUT`/`BAT_LOCALIZED_OUTPUT`/`BAT_IMPORT_REPOSITORY`/`BAT_IMPORT_CAS_ROOT`/`BAT_IMPORT_RESOURCE_DB`/`BAT_STATE_DIR`/`BAT_AUTO_DISCOVER`/`BAT_WATCH`/`BAT_DAEMON`/`BAT_PROXY` 和 `BAT_TRANSLATION_*` 等键,实现编辑 `.env` 后无参启动;优先级为命令行参数 > 进程环境变量 > `.env` > 内置默认值,`BAT_SKIP_ENV_FILE=1` 可整体禁用;Redis 键为预留。daemon 任务历史持久化在 `<state-dir>/bat-tasks.json`(版本化、`0600` 原子写),重启后任务经 `task.*` 仍可查,中断任务标记 `task_interrupted`(`BAT-ERR-700005`)。
|
||||
|
||||
15. issue 43 已补齐 Rust `bat` 的 `res` / `parse` / `i18n` 工作流入口:支持资源拉取、解析刷新、可再生解析缓存清理、离线翻译工作台、人工文本查看/修改/清空、工作台发布前校验、有限 TextAsset 汉化发布、人工校对状态标记、既有 patch 能力的批量重打包、单次/限定次数/周期执行和版本化 schedule CRUD。issue 44 已接入 `translation.worker.run` provider worker:默认并发 8、范围 `1..=256`,每个 worker 独立 claim 下一项任务并落库 lease、失败分类、重试计划和 TextUnit 译文结果。schedule 查询现在按一级工作流过滤,删除/执行会校验作用域,单轮执行可限制计划数;schedule CRUD、翻译任务查询/交接视图、翻译任务状态回写、provider worker 触发和 `translation.proofread` 状态标记已通过 `bat.sock` 的 RPC 以及 `bat-api` 的鉴权管理接口暴露,dashboard 不维护第二套状态。issue 46 已提供 `bat-api` 内嵌 dashboard MVP,静态资产由 Go embed 暴露在 `/admin/dashboard/`,页面直接调用已有鉴权接口控制资源、调度、翻译、任务、日志、parse TextUnit 查询和 localized 发布/回滚。该例外只编排已有解析和 patch 能力,不扩大解析器覆盖;完整 AssetBundle 重打包和完整 Web 协作后台仍是后续工作。G-008(产品级 Go 同步 CLI)已决策关闭。真实官方网络全量拉取 smoke 已固化(G-018 已关闭);真实大文件与运行报告默认在 `/tmp` 隔离目录,不纳入 Git。Go 细节见 `docs/reports/GO_STATUS.md`。
|
||||
@@ -146,7 +146,7 @@ job/unit/provider run 状态。当前下载实现使用默认 8 个独立 worker
|
||||
|
||||
待完成:
|
||||
|
||||
- 基于已接入的 `translation.worker.run` 继续推进翻译记忆、完整 Patch 构建/rollback;继续扩展 CAS 诊断查询面。
|
||||
- 基于已接入的 `translation.worker.run` 继续推进翻译记忆、完整 Patch 构建/rollback;继续扩展更丰富的 TextUnit 查询和通用 Patch 发布资源视图。
|
||||
- 真实线上全量下载 smoke 已固化为 `scripts/official-full-pull-smoke.sh` 和 `make official-smoke`;实际运行报告由脚本写入隔离输出目录。
|
||||
- 增加更多权限和极端文件系统场景测试。
|
||||
|
||||
|
||||
+2
-2
@@ -179,7 +179,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
4. Rust 自动更新入口:**已完成当前生产入口**。`bat` 支持 snapshot、marker diff、bootstrap cache、one-shot、`--watch`、`--daemon`、默认 1 小时间隔、北京时间固定强制刷新,以及 Unix socket JSON-RPC 后台运维命令返回。
|
||||
5. Go 入口边界:**已冻结**。同步命令行 = Rust `bat`(G-008 关闭);资源分发 = `bat-api` MVP(G-009 部分完成)。详见 `docs/reports/GO_STATUS.md`。
|
||||
6. 用户级 `sync`、`manifest inspect`、`cache status`:**未完成**。Rust `bat --json` 是当前稳定进程边界;`bat-ffi` 只提供可选兼容用的 Manifest inspect 和 sync plan JSON helper。
|
||||
7. 下载结果写入 CAS + ResourceRepository:**部分完成**。CAS 和 SQLite ResourceRepository 已存在,官方同步入口可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后导入;`resource.index` 可按资源级 release、平台、destination、archive entry、parse status 和 TextUnit format 查询现有索引和资源 metadata;`parse.text_units` / `parse.errors` 可查询当前 release 的 TextUnit 明细与解析错误;`translation.tasks` 可查询离线 TextUnit 翻译任务状态、跳过/失败原因和 worker 结果。剩余工作是 CAS 诊断入口和面向大索引的查询优化。
|
||||
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` 可查询离线 TextUnit 翻译任务状态、跳过/失败原因和 worker 结果。剩余工作是更丰富的 TextUnit 查询、翻译记忆查询和通用 Patch 发布所需资源视图。
|
||||
8. Linux 生产同步不依赖已安装官方启动器:**已完成当前 Rust 入口**。`--auto-discover` 只使用官方 HTTP metadata 和临时目录解析 `GameMainConfig`。
|
||||
9. 真实官方网络全量下载 smoke test:**命令已固化(G-018 已关闭)**。`scripts/official-full-pull-smoke.sh` / `make official-smoke` 已固化 dry-run、首次下载、二次 up-to-date 和本地损坏 repair 的可重复流程;真实运行处于长期运行测试阶段,报告待后续提供。
|
||||
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`,本地文件未变化且缓存/索引有效时跳过重复解析。
|
||||
@@ -405,7 +405,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
3. G-009 / issue #19:`bat-api` 资源分发和同机 `bat.sock` live 联调已完成;拉取仍在 Rust `bat`。
|
||||
4. 继续 Addressables 结构变体与 UnityFS 复杂对象能力(issue #3 / G-005 的后续阶段)。
|
||||
5. 基于 `translation.worker.run` 继续推进翻译记忆和 Patch 构建。
|
||||
6. 继续扩展 G-011 剩余查询面:`doctor cas` 诊断入口和面向大索引的查询优化。
|
||||
6. 继续扩展 G-011 剩余查询面:更丰富的 TextUnit 查询、翻译记忆查询和通用 Patch 发布所需资源视图。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
- `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、管理控制白名单、task/log/parse/translation admin 查询控制入口和无构建内嵌 dashboard;`.env` 配置端口/RPC socket/刷新周期;生产资源根和长期状态来自 RPC,不负责自动拉取。
|
||||
- Go 边界权威说明:[`docs/reports/GO_STATUS.md`](docs/reports/GO_STATUS.md)(G-008 已关闭:同步 CLI = Rust `bat`)。
|
||||
- 官方同步会维护 `<output>/official-version-state.json`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。
|
||||
- 资源导入链路可配置为在官方 release 发布后写入 CAS + `ResourceRepository`,资源 metadata 会记录 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式,TextAsset/Table/Media 会按类型分类索引;`resource.index` RPC/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;`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 交接。
|
||||
- `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 流程完成。
|
||||
|
||||
@@ -192,10 +192,10 @@ impl ResourceQuery {
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否包含需要读取完整 `Resource` 后才能判断的条件。
|
||||
/// 是否包含通用仓储需要读取完整 `Resource` 后才能判断的条件。
|
||||
///
|
||||
/// 基础索引可先用类型、hash、路径模式和 destination 缩小范围;这些条件
|
||||
/// 需要再按 metadata 过滤,确保 `list()` 与 `count()` 的语义一致。
|
||||
/// 具体后端可以把这些 metadata 条件下推到自身索引;内存实现仍用完整
|
||||
/// `Resource` 过滤来保持 `list()` 与 `count()` 的语义一致。
|
||||
pub fn requires_resource_scan(&self) -> bool {
|
||||
self.official_release_id.is_some()
|
||||
|| self.platform.is_some()
|
||||
|
||||
@@ -182,8 +182,10 @@ release 根目录写入 `translation-tasks.sqlite`,由版本化 `schema_migrat
|
||||
管理 queued/running/failed/completed/skipped、provider run、lease、失败分类、
|
||||
重试计划和 TextUnit 级译文结果。`translation.tasks` 优先查询这份状态库,
|
||||
`translation.worker.run` 由 Rust worker 回写状态;`translation.task.update` 仍供外部 provider 流程回写状态;
|
||||
没有状态库的旧 release 才回退到 immutable JSON 队列。G-011 剩余工作是 CAS
|
||||
诊断入口和面向大索引的查询优化。
|
||||
没有状态库的旧 release 才回退到 immutable JSON 队列。`bat doctor cas`
|
||||
已提供只读 CAS 根目录、对象目录、元数据库文件和对象统计诊断;`resource.index`
|
||||
已把 release、平台、bundle path 和常用数组 metadata 过滤下推到 SQLite。G-011
|
||||
剩余工作是更丰富的 TextUnit 查询、翻译记忆查询和通用 Patch 发布所需资源视图。
|
||||
|
||||
对应实现主要在:
|
||||
|
||||
@@ -287,7 +289,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 状态和调度状态
|
||||
- curl 失败分类和重试策略已覆盖 404 不重试、5xx 重试耗尽后 quarantine、launcher primary CDN 失败后切换 official backup CDN
|
||||
- `official-version-state.json` 已覆盖当前完成版本、正在拉取版本、上一个可用版本和失败版本;同一 app version、bundle version 和 Addressables root 的失败只保留最新一条,重新拉取或成功发布后清理同版本失败记录,同版本失败 staging 会在路径安全且未发布时复用,`bat status` 会暴露版本状态摘要和最近历史失败原因
|
||||
- 资源导入链路已覆盖可配置 CAS 写入、`ResourceRepository` 索引、`metadata_json` release/平台/bundle/TextAsset/TextUnit 摘要,以及 TextAsset/Table/Media 分类;`resource.index` 可只读查询现有索引
|
||||
- 资源导入链路已覆盖可配置 CAS 写入、`ResourceRepository` 索引、`metadata_json` release/平台/bundle/TextAsset/TextUnit 摘要,以及 TextAsset/Table/Media 分类;`resource.index` 可只读查询现有索引,常用 metadata 过滤已下推到 SQLite,`bat doctor cas` 可只读诊断既有 CAS 目录和对象统计
|
||||
- 官方 release 发布后会生成 `official-resource-changes.json`、`crowdin-translation-handoff.json`、`official-parse-cache.json`、`official-textunit-index.json`、`official-textunit-tasks.json` 和 `crowdin-textunit-queue.json`,为后续增量解析和 Crowdin worker 预留稳定输入
|
||||
- 离线回归样本已覆盖当前 catalog、上一个版本 catalog、catalog 结构变化、403、404 和 seed hash mismatch
|
||||
- `OfficialUpdateService` 能读写 `official-bootstrap-cache.json`,并支持默认开启的 `audit_local` / `repair` CLI 行为
|
||||
|
||||
@@ -93,7 +93,7 @@ CAS V1 和 Rust 官方同步闭环完成后,下一阶段优先推进:
|
||||
1. 继续联调 Go `bat-api` 与 Rust daemon 的资源分发路径;Go 同步 CLI 不再作为产品目标。
|
||||
2. 按 `docs/guides/official-full-pull-smoke.md` 在隔离目录执行真实官方网络全量下载 smoke,并保留运行报告。
|
||||
3. 基于 `translation.worker.run` 推进翻译记忆和通用 manifest Patch 构建。
|
||||
4. 扩展 ResourceRepository 查询面:CAS 诊断入口、面向大索引的查询优化和更丰富 TextUnit 查询。
|
||||
4. 扩展 ResourceRepository 查询面:更丰富的 TextUnit 查询、翻译记忆查询和通用 Patch 发布所需资源视图。
|
||||
5. 继续完善 AssetBundle 复杂对象解析、复杂对象重打包和通用 Patch 发布流程统一;通用 Binary/JSON/Text Patch 基础与受支持 localized patch 发布/rollback 链路已可用。
|
||||
|
||||
优先阅读:
|
||||
|
||||
@@ -215,7 +215,7 @@ cargo run -p bat-infrastructure --example official_pull_plan -- \
|
||||
- 非 dry-run 且启用 `--auto-discover` 时,成功发布的 release 会包含 `official-launcher-bootstrap.json`;up-to-date 轮询发现当前 release 缺少该文件时会补写。官方 launcher/server-info 已更新但 client-patch 资源尚未开放时,不切换 `current`,只在输出根写入 `official-launcher-bootstrap.pending.json` 作为维护期证据。
|
||||
- 校验和发布完成后会先对比上一完整 release 与当前 release 的 `official-download-manifest.json`,写出 `<output>/current/official-resource-changes.json` 和 `<output>/current/crowdin-translation-handoff.json`。同一 destination 只有 size 或 BLAKE3 改变才算 modified;仅 URL/CDN 根变化但内容一致不会触发解析/翻译候选。新增+变更资源进入解析和 Crowdin 翻译 handoff,删除资源只进入差异记录;当前不会直接调用 Crowdin API。
|
||||
- 随后会刷新 `<output>/current/official-parse-cache.json`。解析缓存从 `official-download-manifest.json` 的全部条目出发,处理直接 UnityFS bundle 和 zip 内 UnityFS 条目;catalog、hash、媒体等非 UnityFS 文件记录为不支持,不视为同步失败。新 release 会刷新解析缓存;远端和本地都 up-to-date 且已有有效解析缓存时只读取摘要,不重复解析。
|
||||
- 需要将已校验官方 release 导入 CAS + SQLite ResourceRepository 时,使用 `--import-repository` 或 `.env` 中 `BAT_IMPORT_REPOSITORY=1`;默认 CAS 为 `<output>/.cas`,默认索引为 `<output>/resources.sqlite`,可用 `--import-cas-root` / `BAT_IMPORT_CAS_ROOT` 和 `--import-resource-db` / `BAT_IMPORT_RESOURCE_DB` 覆盖。`resource.index` RPC 可查询现有索引,索引不存在时返回 `available=false`,不会创建空库。
|
||||
- 需要将已校验官方 release 导入 CAS + SQLite ResourceRepository 时,使用 `--import-repository` 或 `.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`,不会创建空库;`bat doctor cas --output <output>` 或 `bat doctor cas --import-cas-root <path>` 可只读检查既有 CAS 根目录、对象目录、元数据库文件和对象统计。
|
||||
- 官方同步报告中的 `localized_release_status=not_localized` 表示原版资源已发布、汉化资源未发布,这是当前官方同步阶段的正常完成状态;后续 Patch 发布完成后才应切换为 `localized`,表示原版和汉化两套资源都已发布。
|
||||
|
||||
资源同步状态文件默认分布如下:
|
||||
|
||||
@@ -441,6 +441,9 @@ CLI 对应关系:
|
||||
`bat resource-index` 支持 `--offset`、`--limit`、`--resource-type`、`--hash`、
|
||||
`--path-pattern`、`--release-id`、`--platform`、`--destination`、
|
||||
`--bundle-path`、`--archive-entry`、`--parse-status` 和 `--format`;
|
||||
这些常用 metadata 过滤在 SQLite `ResourceRepository` 中下推执行。`bat doctor cas`
|
||||
是本地只读 CLI 诊断入口,不对应 live RPC 方法;它读取 CLI 指定的 CAS 根目录和
|
||||
元数据库路径,报告缺失或对象文件异常,且不会创建空库。
|
||||
`bat parse-text-units` / `bat parse-errors` 支持 `--offset`、`--limit`、
|
||||
`--destination`、`--path-pattern`、`--archive-entry`、`--path-id`、
|
||||
`--class-id`、`--field-path` 和 `--format`;`bat translation-tasks` 支持
|
||||
|
||||
@@ -291,7 +291,8 @@ issue 43 例外说明:新增的 `parse repack` 只编排已有 TextAsset、Typ
|
||||
- `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`,不会因查询创建空库。
|
||||
- `resource.index` RPC/CLI 已能按资源类型、hash、路径模式、官方 release ID、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 分页查询现有 SQLite 索引;release、平台、bundle path 和常用数组 metadata 过滤已下推到 SQLite;数据库不存在时返回 `available=false`,不会因查询创建空库。
|
||||
- `bat doctor cas` 可按 `<output>/.cas` 或 `--import-cas-root` 只读诊断既有 CAS 根目录、对象目录、元数据库文件和对象统计,缺失路径会报告为不健康,不会创建空库。
|
||||
- 官方 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 分页过滤。
|
||||
@@ -575,7 +576,7 @@ issue 43 例外说明:新增的 `parse repack` 只编排已有 TextAsset、Typ
|
||||
5. **G-009 / issue #19**:资源 bootstrap/分发和同机 live 联调已完成;非「从零实现」。后续真实官方网络长期运行、持久化和完整 launcher/业务链不属于本 issue 关闭条件。
|
||||
6. issue #2 / G-007(P1):Addressables 可校验字段。
|
||||
7. issue #3 / G-005(P1):UnityFS 容器基础解析已落地;对象级引擎解析继续跟踪 G-005。
|
||||
8. G-011:翻译任务状态、CAS 诊断和 ResourceRepository 查询面扩展。
|
||||
8. G-011:更丰富的 TextUnit 查询、翻译记忆查询和通用 Patch 发布所需资源视图。
|
||||
9. G-012 / G-006:Crowdin/翻译系统、复杂 AssetBundle 重打包和 Patch 发布流程统一。
|
||||
10. G-011D:原版/汉化双发布后的查询、分发和清理策略。
|
||||
|
||||
|
||||
@@ -328,6 +328,10 @@ fn run() -> anyhow::Result<i32> {
|
||||
let healthy = run_doctor_command(&options)?;
|
||||
Ok(if healthy { 0 } else { EXIT_ERROR })
|
||||
}
|
||||
CliCommand::DoctorCas => {
|
||||
let healthy = run_doctor_cas_command(&options)?;
|
||||
Ok(if healthy { 0 } else { EXIT_ERROR })
|
||||
}
|
||||
CliCommand::CleanStable => {
|
||||
run_clean_stable_command(&options)?;
|
||||
Ok(0)
|
||||
@@ -608,6 +612,7 @@ enum CliCommand {
|
||||
UnityFsPatchStringField,
|
||||
UnityFsPatchField,
|
||||
Doctor,
|
||||
DoctorCas,
|
||||
Logs,
|
||||
CleanStable,
|
||||
}
|
||||
@@ -5380,6 +5385,31 @@ impl HumanReport for DoctorReport {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -5593,6 +5623,28 @@ struct DoctorReport {
|
||||
checks: Vec<DoctorCheck>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct DoctorCasReport {
|
||||
command: &'static str,
|
||||
status: &'static str,
|
||||
message: &'static str,
|
||||
healthy: bool,
|
||||
cas_root: PathBuf,
|
||||
objects_dir: PathBuf,
|
||||
metadata_db_path: PathBuf,
|
||||
object_count: u64,
|
||||
total_size: u64,
|
||||
invalid_object_count: u64,
|
||||
checks: Vec<DoctorCheck>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct CasObjectScanSummary {
|
||||
object_count: u64,
|
||||
total_size: u64,
|
||||
invalid_object_count: u64,
|
||||
}
|
||||
|
||||
fn build_doctor_report(
|
||||
state_dir: &Path,
|
||||
config: &OfficialUpdateConfig,
|
||||
@@ -5773,6 +5825,77 @@ fn build_doctor_report(
|
||||
})
|
||||
}
|
||||
|
||||
fn build_doctor_cas_report(config: &OfficialUpdateConfig) -> anyhow::Result<DoctorCasReport> {
|
||||
let cas_root = config
|
||||
.import_cas_root
|
||||
.clone()
|
||||
.unwrap_or_else(|| config.output_root.join(".cas"));
|
||||
let objects_dir = cas_root.join("objects");
|
||||
let metadata_db_path = cas_root.join("metadata.sqlite");
|
||||
let mut checks = vec![
|
||||
required_dir_check("cas_root", &cas_root, "CAS 根目录可用"),
|
||||
required_dir_check("cas_objects_dir", &objects_dir, "CAS 对象目录可用"),
|
||||
required_file_check("cas_metadata_db", &metadata_db_path, "CAS 元数据库文件可用"),
|
||||
];
|
||||
|
||||
let (object_count, total_size, invalid_object_count) = if objects_dir.is_dir() {
|
||||
match scan_cas_object_directory(&objects_dir) {
|
||||
Ok(summary) => {
|
||||
let ok = summary.invalid_object_count == 0;
|
||||
checks.push(DoctorCheck {
|
||||
name: "cas_object_scan",
|
||||
ok,
|
||||
message: if ok {
|
||||
format!(
|
||||
"对象扫描完成:{} 个对象,{} 字节",
|
||||
summary.object_count, summary.total_size
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"对象扫描发现 {} 个无效文件:{} 个对象,{} 字节",
|
||||
summary.invalid_object_count, summary.object_count, summary.total_size
|
||||
)
|
||||
},
|
||||
});
|
||||
(
|
||||
summary.object_count,
|
||||
summary.total_size,
|
||||
summary.invalid_object_count,
|
||||
)
|
||||
}
|
||||
Err(error) => {
|
||||
checks.push(DoctorCheck {
|
||||
name: "cas_object_scan",
|
||||
ok: false,
|
||||
message: format!("扫描 CAS 对象失败:{error}"),
|
||||
});
|
||||
(0, 0, 0)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(0, 0, 0)
|
||||
};
|
||||
|
||||
let healthy = checks.iter().all(|check| check.ok);
|
||||
Ok(DoctorCasReport {
|
||||
command: "doctor cas",
|
||||
status: if healthy { "ok" } else { "issues_found" },
|
||||
message: if healthy {
|
||||
"CAS 诊断通过"
|
||||
} else {
|
||||
"CAS 诊断发现问题"
|
||||
},
|
||||
healthy,
|
||||
cas_root,
|
||||
objects_dir,
|
||||
metadata_db_path,
|
||||
object_count,
|
||||
total_size,
|
||||
invalid_object_count,
|
||||
checks,
|
||||
})
|
||||
}
|
||||
|
||||
fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> {
|
||||
let report = build_doctor_report(&options.state_dir, &options.config)?;
|
||||
let healthy = report.healthy;
|
||||
@@ -5780,6 +5903,13 @@ fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> {
|
||||
Ok(healthy)
|
||||
}
|
||||
|
||||
fn run_doctor_cas_command(options: &CliOptions) -> anyhow::Result<bool> {
|
||||
let report = build_doctor_cas_report(&options.config)?;
|
||||
let healthy = report.healthy;
|
||||
print_report(options.output_format, &report)?;
|
||||
Ok(healthy)
|
||||
}
|
||||
|
||||
fn path_check(name: &'static str, path: &Path, ready_message: &str) -> DoctorCheck {
|
||||
let (ok, message) = if path.is_dir() {
|
||||
(true, format!("{ready_message}:{}", path.display()))
|
||||
@@ -5802,6 +5932,116 @@ fn path_check(name: &'static str, path: &Path, ready_message: &str) -> DoctorChe
|
||||
DoctorCheck { name, ok, message }
|
||||
}
|
||||
|
||||
fn required_dir_check(name: &'static str, path: &Path, ready_message: &str) -> DoctorCheck {
|
||||
if path.is_dir() {
|
||||
DoctorCheck {
|
||||
name,
|
||||
ok: true,
|
||||
message: format!("{ready_message}:{}", path.display()),
|
||||
}
|
||||
} else if path.exists() {
|
||||
DoctorCheck {
|
||||
name,
|
||||
ok: false,
|
||||
message: format!("路径存在但不是目录:{}", path.display()),
|
||||
}
|
||||
} else {
|
||||
DoctorCheck {
|
||||
name,
|
||||
ok: false,
|
||||
message: format!("目录不存在:{}", path.display()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn required_file_check(name: &'static str, path: &Path, ready_message: &str) -> DoctorCheck {
|
||||
if path.is_file() {
|
||||
DoctorCheck {
|
||||
name,
|
||||
ok: true,
|
||||
message: format!("{ready_message}:{}", path.display()),
|
||||
}
|
||||
} else if path.exists() {
|
||||
DoctorCheck {
|
||||
name,
|
||||
ok: false,
|
||||
message: format!("路径存在但不是文件:{}", path.display()),
|
||||
}
|
||||
} else {
|
||||
DoctorCheck {
|
||||
name,
|
||||
ok: false,
|
||||
message: format!("文件不存在:{}", path.display()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn scan_cas_object_directory(objects_dir: &Path) -> anyhow::Result<CasObjectScanSummary> {
|
||||
let mut summary = CasObjectScanSummary::default();
|
||||
scan_cas_object_directory_recursive(objects_dir, objects_dir, &mut summary)?;
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
fn scan_cas_object_directory_recursive(
|
||||
objects_dir: &Path,
|
||||
path: &Path,
|
||||
summary: &mut CasObjectScanSummary,
|
||||
) -> anyhow::Result<()> {
|
||||
for entry in fs::read_dir(path)? {
|
||||
let entry = entry?;
|
||||
let file_type = entry.file_type()?;
|
||||
let entry_path = entry.path();
|
||||
if file_type.is_dir() {
|
||||
scan_cas_object_directory_recursive(objects_dir, &entry_path, summary)?;
|
||||
continue;
|
||||
}
|
||||
if !file_type.is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let metadata = entry.metadata()?;
|
||||
let file_name = entry.file_name();
|
||||
let file_name = file_name.to_str().unwrap_or_default();
|
||||
if is_cas_object_path(objects_dir, &entry_path, file_name) {
|
||||
summary.object_count += 1;
|
||||
summary.total_size += metadata.len();
|
||||
} else {
|
||||
summary.invalid_object_count += 1;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_cas_object_path(objects_dir: &Path, object_path: &Path, file_name: &str) -> bool {
|
||||
if !is_cas_object_file_name(file_name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let relative_path = match object_path.strip_prefix(objects_dir) {
|
||||
Ok(path) => path,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let mut components = relative_path.components();
|
||||
let Some(std::path::Component::Normal(prefix1)) = components.next() else {
|
||||
return false;
|
||||
};
|
||||
let Some(std::path::Component::Normal(prefix2)) = components.next() else {
|
||||
return false;
|
||||
};
|
||||
let Some(std::path::Component::Normal(name)) = components.next() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
components.next().is_none()
|
||||
&& prefix1.to_str() == Some(&file_name[0..2])
|
||||
&& prefix2.to_str() == Some(&file_name[2..4])
|
||||
&& name.to_str() == Some(file_name)
|
||||
}
|
||||
|
||||
fn is_cas_object_file_name(file_name: &str) -> bool {
|
||||
file_name.len() == 64 && file_name.chars().all(|ch| ch.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn safety_check(name: &'static str, result: Result<(), String>, ok_message: &str) -> DoctorCheck {
|
||||
match result {
|
||||
Ok(()) => DoctorCheck {
|
||||
@@ -7101,7 +7341,7 @@ fn parse_args_with_env(
|
||||
raw_args: impl IntoIterator<Item = String>,
|
||||
env_lookup: impl Fn(&str) -> Option<String>,
|
||||
) -> anyhow::Result<CliOptions> {
|
||||
let mut args = raw_args.into_iter();
|
||||
let mut args = raw_args.into_iter().peekable();
|
||||
let binary = args.next().unwrap_or_else(|| "bat".to_string());
|
||||
let mut options = CliOptions::default();
|
||||
// `BAT_*` 环境变量(含 .env 加载的)先作为默认值写入,不标记 explicit;
|
||||
@@ -7196,7 +7436,12 @@ fn parse_args_with_env(
|
||||
}
|
||||
"doctor" => {
|
||||
ensure_command_not_set(options.command, "doctor")?;
|
||||
options.command = CliCommand::Doctor;
|
||||
if args.peek().map(|value| value.as_str()) == Some("cas") {
|
||||
args.next();
|
||||
options.command = CliCommand::DoctorCas;
|
||||
} else {
|
||||
options.command = CliCommand::Doctor;
|
||||
}
|
||||
}
|
||||
"logs" => {
|
||||
ensure_command_not_set(options.command, "logs")?;
|
||||
@@ -7845,7 +8090,25 @@ fn parse_args_with_env(
|
||||
options.progress = false;
|
||||
options.banner = false;
|
||||
}
|
||||
CliCommand::Doctor | CliCommand::CleanStable => {
|
||||
CliCommand::Doctor => {
|
||||
if options.sync_option_explicit {
|
||||
return Err(anyhow::anyhow!(
|
||||
"doctor 只接受 --output、--state-dir、--curl、--proxy 和 --unzip 等诊断参数"
|
||||
));
|
||||
}
|
||||
options.progress = false;
|
||||
options.banner = false;
|
||||
}
|
||||
CliCommand::DoctorCas => {
|
||||
if doctor_cas_has_disallowed_sync_options(&options) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"doctor cas 只接受 --output、--import-repository、--import-cas-root、--import-resource-db、--state-dir、--curl、--proxy 和 --unzip 等诊断参数"
|
||||
));
|
||||
}
|
||||
options.progress = false;
|
||||
options.banner = false;
|
||||
}
|
||||
CliCommand::CleanStable => {
|
||||
if options.sync_option_explicit {
|
||||
return Err(anyhow::anyhow!(
|
||||
"doctor/clean-stable 只接受 --output、--state-dir、--curl、--proxy 和 --unzip 等诊断参数"
|
||||
@@ -8282,6 +8545,19 @@ fn parse_args_with_env(
|
||||
Ok(options)
|
||||
}
|
||||
|
||||
fn doctor_cas_has_disallowed_sync_options(options: &CliOptions) -> bool {
|
||||
let mut allowed = options.env_baseline_config.clone();
|
||||
allowed.output_root = options.config.output_root.clone();
|
||||
allowed.curl_command = options.config.curl_command.clone();
|
||||
allowed.curl_proxy = options.config.curl_proxy.clone();
|
||||
allowed.unzip_command = options.config.unzip_command.clone();
|
||||
allowed.import_repository = options.config.import_repository;
|
||||
allowed.import_cas_root = options.config.import_cas_root.clone();
|
||||
allowed.import_resource_repository_path =
|
||||
options.config.import_resource_repository_path.clone();
|
||||
options.config != allowed
|
||||
}
|
||||
|
||||
fn parse_resource_command(
|
||||
args: &mut impl Iterator<Item = String>,
|
||||
options: &mut CliOptions,
|
||||
@@ -8623,6 +8899,7 @@ fn print_usage(binary: &str) {
|
||||
eprintln!(" reload Ask daemon to rediscover metadata and force refresh");
|
||||
eprintln!(" logs Show daemon log tail");
|
||||
eprintln!(" doctor Run runtime diagnostics");
|
||||
eprintln!(" doctor cas Inspect local CAS storage");
|
||||
eprintln!(" clean-stable Remove .part/.tmp/stale lock, pid, and socket files");
|
||||
eprintln!();
|
||||
eprintln!("Examples:");
|
||||
|
||||
@@ -1440,6 +1440,22 @@ fn parses_management_and_resource_commands() {
|
||||
let options = parse(&["bat", "resource", "status"]).unwrap();
|
||||
assert_eq!(options.command, CliCommand::ResourceIndex);
|
||||
|
||||
let doctor_cas = parse(&["bat", "doctor", "cas", "--output", "/tmp/bat-resources"]).unwrap();
|
||||
assert_eq!(doctor_cas.command, CliCommand::DoctorCas);
|
||||
assert_eq!(
|
||||
doctor_cas.config.output_root,
|
||||
PathBuf::from("/tmp/bat-resources")
|
||||
);
|
||||
assert!(parse(&[
|
||||
"bat",
|
||||
"doctor",
|
||||
"cas",
|
||||
"--output",
|
||||
"/tmp/bat-resources",
|
||||
"--force"
|
||||
])
|
||||
.is_err());
|
||||
|
||||
let index = parse(&[
|
||||
"bat",
|
||||
"resource-index",
|
||||
@@ -1601,6 +1617,64 @@ fn parses_management_and_resource_commands() {
|
||||
.contains("--resource-type/--hash/--platform/--bundle-path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doctor_cas_report_counts_objects_read_only() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let output_root = temp.path().join("output");
|
||||
let cas_root = output_root.join(".cas");
|
||||
let objects_dir = cas_root.join("objects/01/23");
|
||||
fs::create_dir_all(&objects_dir).unwrap();
|
||||
let object_path =
|
||||
objects_dir.join("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef");
|
||||
fs::write(&object_path, b"payload").unwrap();
|
||||
fs::write(cas_root.join("metadata.sqlite"), b"metadata").unwrap();
|
||||
|
||||
let report = build_doctor_cas_report(&OfficialUpdateConfig {
|
||||
output_root,
|
||||
..OfficialUpdateConfig::default()
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(report.command, "doctor cas");
|
||||
assert!(report.healthy);
|
||||
assert_eq!(report.object_count, 1);
|
||||
assert_eq!(report.total_size, 7);
|
||||
assert_eq!(report.invalid_object_count, 0);
|
||||
assert!(report
|
||||
.checks
|
||||
.iter()
|
||||
.any(|check| check.name == "cas_object_scan" && check.ok));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doctor_cas_report_flags_invalid_object_layout() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let output_root = temp.path().join("output");
|
||||
let cas_root = output_root.join(".cas");
|
||||
let objects_dir = cas_root.join("objects/ab/cd");
|
||||
fs::create_dir_all(&objects_dir).unwrap();
|
||||
fs::write(cas_root.join("metadata.sqlite"), b"metadata").unwrap();
|
||||
fs::write(
|
||||
objects_dir.join("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"),
|
||||
b"payload",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let report = build_doctor_cas_report(&OfficialUpdateConfig {
|
||||
output_root,
|
||||
..OfficialUpdateConfig::default()
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(!report.healthy);
|
||||
assert_eq!(report.object_count, 0);
|
||||
assert_eq!(report.invalid_object_count, 1);
|
||||
assert!(report
|
||||
.checks
|
||||
.iter()
|
||||
.any(|check| check.name == "cas_object_scan" && !check.ok));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_write_patch_commands_without_output_confusion() {
|
||||
let patch = parse(&[
|
||||
|
||||
@@ -186,6 +186,39 @@ impl SqliteResourceRepository {
|
||||
)
|
||||
.await?;
|
||||
|
||||
Self::execute_query(
|
||||
&self.pool,
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE INDEX IF NOT EXISTS idx_resources_release_id
|
||||
ON resources(json_extract(metadata_json, '$.official_release_id'))
|
||||
"#,
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Self::execute_query(
|
||||
&self.pool,
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE INDEX IF NOT EXISTS idx_resources_platform
|
||||
ON resources(json_extract(metadata_json, '$.platform'))
|
||||
"#,
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Self::execute_query(
|
||||
&self.pool,
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE INDEX IF NOT EXISTS idx_resources_bundle_path
|
||||
ON resources(json_extract(metadata_json, '$.bundle_path'))
|
||||
"#,
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -340,6 +373,51 @@ impl SqliteResourceRepository {
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -374,10 +452,6 @@ impl SqliteResourceRepository {
|
||||
}
|
||||
|
||||
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");
|
||||
Self::apply_filters(&mut builder, query)?;
|
||||
|
||||
@@ -801,6 +875,20 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn sqlite_repository_persists_and_filters_resources() {
|
||||
let (_temp_dir, repository) = sqlite_repository().await;
|
||||
let indexes = sqlx::query_scalar::<_, String>(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'resources'",
|
||||
)
|
||||
.fetch_all(&repository.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(indexes
|
||||
.iter()
|
||||
.any(|name| name == "idx_resources_release_id"));
|
||||
assert!(indexes.iter().any(|name| name == "idx_resources_platform"));
|
||||
assert!(indexes
|
||||
.iter()
|
||||
.any(|name| name == "idx_resources_bundle_path"));
|
||||
|
||||
let mut resource = resource(
|
||||
"resource/sqlite-a",
|
||||
"assets/model.bundle",
|
||||
|
||||
Reference in New Issue
Block a user