diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md index 6f995d3..31dc8dc 100644 --- a/CURRENT_STATUS.md +++ b/CURRENT_STATUS.md @@ -1,6 +1,6 @@ # BlueArchiveToolkit 当前工作区状态 -- **更新时间**:2026-08-31 +- **更新时间**:2026-09-02 - **状态来源**:本地工作区盘点、代码验证和最新提交 - **状态分支**:`experiment` - **最新已推送功能提交**:以当前 `git log --oneline -1` 为准 @@ -20,15 +20,15 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口: 3. 默认平台为 `Windows + Android`。 4. 能生成官方全量 pull plan,执行真实下载,维护 release 内的 `official-download-manifest.json`。 5. 下载后使用本地 manifest 的 size + BLAKE3 校验复用文件;所有 `.zip` 在下载验收、复用、本地 audit/verify 时做 ZIP 结构校验;官方 seed `.hash` 使用标准 `xxHash32(seed=0)` 强校验(早期实现的非标准 avalanche 常量已修正)。 -6. 支持 `.part` 断点续传、失败后 clean retry、本地 manifest audit/repair、失败 staging 恢复复用、403/404/5xx 分类重试(重试带指数退避)、下载 quarantine 诊断,以及旧 launcher 包官方 primary/backup CDN 切换。启动器/server-info 先行更新但 client-patch seed marker 或必需 seed catalog 尚未开放时,会进入 `waiting_for_official_resources`,保留现有 `current`,不创建失败 staging,也不写入失败版本循环;启用 `--auto-discover` 的非 dry-run 会写入 `/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 会写入 `/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 ` 显式指定或 `--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`。 9. 远端 snapshot 未变化但输出目录为空时,会按首次运行执行全量拉取;官方 seed `.hash` 校验失败时会清理对应 manifest 条目,避免失败产物被后续本地 audit 误判为可复用。 -10. 默认官方原版资源目录是 `./bat-resources`,默认汉化产物目录是 `./bat-localized`,默认后台状态目录是 `/tmp/bat-pid`;官方资源目录是发布根目录,包含 `current` symlink、`versions/` 和 `.staging/`,非 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/` 和 `.staging/`,非 dry-run 会先写 staging,校验完成后发布 versioned 目录并原子切换 `current`;启用 `--auto-discover` 的 release 会包含 `official-launcher-bootstrap.json`,up-to-date 轮询会为旧 release 补写该产物;如果上一轮同一 app version、bundle version 和 Addressables root 的 staging 失败但目录仍安全存在,下一轮会复用该 staging 并按 manifest 逐文件校验/补下载;从 CAS 复用的 release 会在自身目录保存版本化 `official-cas-reuse-references.json`,孤儿 staging 清理或 release 清理时按清单递减 CAS 引用,避免 CAS GC 误删仍被 release 使用的对象;后台状态目录包含 `bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log`、`bat-events.jsonl`、任务历史 `bat-tasks.json` 和短生命周期 `bat-control.lock`;非 dry-run 使用 `--output/.official-sync.lock` 防止并发写同一资源目录,live daemon 会阻止前台写命令直接修改它正在管理的同一目录。 11. 官方同步会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。 12. `/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 路径为 `/.cas`、SQLite 索引为 `/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 任务历史持久化在 `/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` 首次启动会在二进制所在目录释放 `.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 任务历史持久化在 `/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。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`。 diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 4296441..79e8832 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -175,7 +175,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付 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 可见模型。 -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 后台运维命令返回。 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。 diff --git a/README.md b/README.md index 5e814b6..14505e7 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,12 @@ - `bat-adapters` Unity、Manifest、Client 集成框架,以及当前真实形态 Addressables catalog 解析覆盖,含 `m_Crc` 提取和 UnityFS 解包/TextAsset 提取基础校验。 - `bat-cas-engine` CAS V1:原子写入、BLAKE3 校验、引用计数、GC、并发写入测试、损坏检测。 - `bat-infrastructure` CAS 适配层、SQLite Resource Repository、资源导入服务、官方资源 pull/update 服务。 -- `bat`:官方资源自动发现、全量拉取、原子发布到 `current -> versions/`、本地 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/`、本地 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`、`localized.status`、`catalog.*`、`task.*`、`patch.apply`、`unityfs.patch_*`)。 - `internal/backendrpc`:Go 侧 typed Unix socket JSON-RPC client,是 `bat-api` 调用 Rust daemon 的默认路径。 - `cmd/bat-api`:资源 bootstrap + 分发 HTTP MVP(issue #19 / G-009);`/v1/bootstrap` 和 `/v1/launcher/bootstrap` 组织 `bat` 已发布 release 的启动前资源入口,launcher 形状兼容端点仅输出资源 metadata / GameMainConfig 引导,`/healthz` 暴露 RPC refresh 诊断,`/readyz` 做 release readiness,CDN path 支持 `GET`/`HEAD`/`Range`、ETag、Last-Modified 和缓存头;玩家-facing 控制面已具备 token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单、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`)。 - 官方同步会维护 `/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、bundle path、archive entry、parse status 和 TextUnit format 分页查询索引,常用 metadata 过滤会下推到 SQLite;`bat doctor cas` 可只读诊断既有 CAS 目录、对象数、对象字节数和元数据库文件状态。 +- 资源导入链路可配置为在官方 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 交接。 - `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/` 并切换 `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 流程完成。 @@ -106,7 +106,7 @@ cargo run -p bat-infrastructure --bin bat -- stop `bat` 会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。 -非 dry-run 同步不会把新文件直接写进生产可读目录。官方原版资源会先下载到 `/.staging/`,完成 manifest、BLAKE3、ZIP 和官方 `.hash` 校验后移动到 `/versions/`,再原子切换 `/current` symlink;生产读取方应只读取 `/current`。同步过程会更新 `/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` 摘要;官方资源端尚未开放时会写 `/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 同步不会把新文件直接写进生产可读目录。官方原版资源会先下载到 `/.staging/`,完成 manifest、BLAKE3、ZIP 和官方 `.hash` 校验后移动到 `/versions/`,再原子切换 `/current` symlink;生产读取方应只读取 `/current`。同步过程会更新 `/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` 摘要;官方资源端尚未开放时会写 `/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`: diff --git a/USERGUIDE.md b/USERGUIDE.md index 147c7de..b177de4 100644 --- a/USERGUIDE.md +++ b/USERGUIDE.md @@ -12,9 +12,9 @@ `bat` 是 Linux 上官方日服(Yostar JP)资源同步的正式入口。它可以: - `--auto-discover` 从官方 HTTP metadata 解析 `GameMainConfig`,自动获得 app-version、连接组和 server-info,不安装、不启动官方启动器;已发布 release 会保存 `official-launcher-bootstrap.json`。 -- 生成官方全量 pull plan、执行真实下载,维护 release 内的下载 manifest,并做 size + BLAKE3 复用校验、官方 seed `.hash`(标准 xxHash32(seed=0))强校验、ZIP 结构校验。 +- 生成官方全量 pull plan、执行真实下载,维护 release 内的下载 manifest,并做 size + BLAKE3 复用校验、已发布历史 release/CAS 复用、官方 seed `.hash`(标准 xxHash32(seed=0))强校验、ZIP 结构校验。 - 断点续传、失败分类重试、下载 quarantine、本地 manifest audit/repair。 -- 原子发布:先写 `.staging/`,校验通过后发布 `versions/` 并原子切换 `current` symlink。 +- 原子发布:先写 `.staging/`,优先复用已验证历史 release/CAS,校验通过后发布 `versions/` 并原子切换 `current` symlink;CAS 引用记录在 release 内的 `official-cas-reuse-references.json`。 - 常驻运行(`--watch`)或后台化(`--daemon`),通过 `bat.sock` Unix socket JSON-RPC 控制。 ### 运行形态 diff --git a/crates/bat-cas-engine/src/refcount.rs b/crates/bat-cas-engine/src/refcount.rs index 907ca24..b302eaf 100644 --- a/crates/bat-cas-engine/src/refcount.rs +++ b/crates/bat-cas-engine/src/refcount.rs @@ -2,11 +2,11 @@ use crate::error::{CasError, Result}; use crate::hash::Hash; -use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions, SqliteQueryResult}; +use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteQueryResult}; use sqlx::SqlitePool; use std::path::Path; use std::str::FromStr; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; /// CAS 对象元数据。 #[derive(Debug, Clone, PartialEq, Eq)] @@ -41,7 +41,9 @@ impl SqliteRefCounter { let options = SqliteConnectOptions::from_str(&format!("sqlite://{}", path.as_ref().display())) .map_err(|error| CasError::Database(error.to_string()))? - .create_if_missing(true); + .create_if_missing(true) + .journal_mode(SqliteJournalMode::Wal) + .busy_timeout(Duration::from_secs(30)); let pool = SqlitePoolOptions::new() .max_connections(1) diff --git a/docs/architecture/official-resource-backend.md b/docs/architecture/official-resource-backend.md index add8bc4..6fa1215 100644 --- a/docs/architecture/official-resource-backend.md +++ b/docs/architecture/official-resource-backend.md @@ -234,12 +234,12 @@ release 根目录写入 `translation-tasks.sqlite`,由版本化 `schema_migrat 7. 如果远端 snapshot 未变化但输出目录没有任何当前 plan 的本地资源,仍按首次运行处理并执行全量拉取。 8. 远端无变化且本地已有资源时执行 download manifest audit,检查路径、size、BLAKE3 和 ZIP 结构。 9. 远端变化、本地 audit 发现 repair_needed,首次空目录运行,或缺少 `current` 原子发布指针时,进入下载/发布流程。 -10. 下载先写入 `/.staging/`;若已有 active release,会先 seed staging 以复用已验证文件;若 version-state 中存在同一版本的失败 staging,则优先复用该 staging 并跳过 active seed,避免旧 active 覆盖已下载的新文件。 +10. 下载先写入 `/.staging/`;若已有 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`)。 12. 将 staging rename 为 `/versions/`,再原子替换 `/current` symlink 指向该 versioned 目录。 13. 发布完成后先对比上一完整 release 和当前 release 的 `official-download-manifest.json`,写出 `official-resource-changes.json` 和 `crowdin-translation-handoff.json`。同一 destination 只有 size 或 BLAKE3 变化才算 modified;新增+变更资源进入解析/翻译 handoff,删除资源只进入差异记录。当前只预留 Crowdin 本地 handoff,不发外部 API 请求。 14. 随后刷新 active release 下的 `official-parse-cache.json` 和 `official-textunit-index.json`,并从 Added/Modified 资源、parse cache 与 TextUnit 明细索引派生 `official-textunit-tasks.json`、`crowdin-textunit-queue.json` 和版本化的 `translation-tasks.sqlite`;up-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析,重新同步队列时保留已有 worker 状态。 -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` 中记录引用,staging/release 清理时递减,失败则回退网络并保留诊断。 16. 官方同步报告默认给出 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布;UnityFS TextAsset patch 发布成功并通过 `localized-patch-manifest.json`、current symlink 和 release ID 校验后,`localized.status` 才返回 `localized`,表示原版和汉化两套资源都已发布。`translation.proofread` 只会把 workflow 标记成 `manual_proofreading` / `translation.manual_proofreading`,不会回退已发布汉化 release 的发布状态。 维护期特殊分支:如果官方 launcher/server-info 已经指向新资源根,但 client-patch seed marker 或必需 seed catalog 仍返回 403/404 等未开放状态,`bat` 返回 `waiting_for_official_resources`,保留现有 `current`,不创建失败 staging;若本轮启用 `--auto-discover`,会在 `/official-launcher-bootstrap.pending.json` 写入待处理 launcher bootstrap 证据,供后续排障和自研客户端开发使用。 diff --git a/docs/architecture/resource-release-layout.md b/docs/architecture/resource-release-layout.md index 7885d41..3bf0ccf 100644 --- a/docs/architecture/resource-release-layout.md +++ b/docs/architecture/resource-release-layout.md @@ -37,6 +37,7 @@ crowdin-textunit-queue.json # Crowdin worker 离线输入队列 official-sync-snapshot.json # 常在 active root / current 下 official-launcher-bootstrap.json # 官方 launcher 引导链版本化产物 + official-cas-reuse-references.json # 当前 release 获取的 CAS 引用 prod-clientpatch.bluearchiveyostar.com/ / TableBundles/ @@ -90,6 +91,17 @@ | 发布完成 | rename 到 `versions/`,再切换 `current` | | 生产读取 / bat-api | RPC 给出的 `version.resource_root`;通常等价于 `current` 解析后的 versioned 目录 | +每个 release 的 `official-download-manifest.json` 是历史复用的索引。新 staging +按规范化 destination 查找候选,并重新验证 manifest 中的 size、BLAKE3 和 ZIP +结构;URL、CDN 根或 release ID 变化本身不构成失效条件。复用文件先尝试硬链接, +跨文件系统时复制到 staging 内的临时文件并原子 rename,旧 `versions/` 目录 +保持不可变。 + +从 CAS 物化资源时,`official-cas-reuse-references.json` 记录每个获取的对象引用, +文件带版本字段且允许重复 object ID。孤儿 staging 或显式 release 清理必须先按 +清单减少 CAS 引用,再删除目录;CAS 对象损坏、缺失或元数据不一致时只产生诊断, +回退网络下载,不发布未经校验的文件。 + --- ## 3. URL → 磁盘映射(核心不变量) diff --git a/docs/guides/development.md b/docs/guides/development.md index 6266dd4..89f3825 100644 --- a/docs/guides/development.md +++ b/docs/guides/development.md @@ -238,6 +238,15 @@ Crowdin 队列当前只落本地文件,不发网络请求。 `--download-concurrency 1`。本文档中的真实资源命令仅是隔离 runbook;本地轻量 验证应使用 fake-curl/fixture,不要在开发机执行真实下载或 smoke run。 +新 release 会在网络下载前扫描已发布 release 的下载 manifest,按规范化 +destination 查找候选,并重新验证 size、BLAKE3 和 ZIP 结构。命中后优先硬链接, +跨文件系统时回退为 staging 内临时文件复制和原子 rename;历史 release 保持不可变。 +历史候选不满足校验时才尝试既有 CAS 对象。CAS 复用会记录 +`official-cas-reuse-references.json`,清理 staging/release 时递减引用;损坏、缺失 +或元数据不一致会记录诊断并回退网络。报告和进度分别暴露 +`release_reused_count`、`cas_reused_count`、`reused_bytes`、 +`transferred_bytes` 以及 `release_reused` / `cas_reused` / `downloaded` 状态。 + 需要把已校验官方 release 导入 CAS + `ResourceRepository` 时,显式启用: ```bash @@ -264,6 +273,14 @@ cargo run -p bat-infrastructure --bin bat -- resource-index --limit 50 cargo run -p bat-infrastructure --bin bat -- resource-index --release-id --platform windows --archive-entry --format json --limit 50 ``` +issue 47 的隔离回归测试: + +```bash +cargo test -p bat-infrastructure official_download::tests::reuses_verified_historical_release_when_cdn_root_changes -- --nocapture +cargo test -p bat-infrastructure official_download::tests::falls_back_to_cas_after_corrupt_historical_release -- --nocapture +cargo test -p bat-infrastructure official_download::tests::corrupted_cas_falls_back_to_network_with_diagnostic -- --nocapture +``` + `parse-status` 会额外显示 TextUnit 明细索引和队列摘要;`parse-text-units` / `parse-errors` 可按 destination、archive entry、path id、class id、field path 和 format 分页查询当前官方 release 的 TextUnit 明细与解析错误; diff --git a/docs/guides/official-resource-test-pull.md b/docs/guides/official-resource-test-pull.md index 384f080..4b7af3a 100644 --- a/docs/guides/official-resource-test-pull.md +++ b/docs/guides/official-resource-test-pull.md @@ -184,6 +184,8 @@ cargo run -p bat-infrastructure --example official_pull_plan -- \ - 存在 `.part` 临时文件时会尝试断点续传 - 新下载先写 `.part`,成功并通过必要校验后再替换为最终文件;如果断点续传后的 `.zip` 结构校验失败,会删除 `.part` 并重新全量下载 - 如果上一轮非 dry-run 已进入 staging 但未发布成功,下一轮会优先查找 `/official-version-state.json` 中同一 app version、bundle version 和 Addressables root 的失败版本;只有对应 `/.staging/` 仍存在、路径安全且 `versions/` 尚未发布时,才复用该 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 会写 `official-cas-reuse-references.json`,清理孤儿 staging 或显式清理 release 时递减这些引用。CAS 损坏、缺对象或元数据不一致会写入复用诊断并继续走网络下载,不会静默使用缓存 - 把结果发布到 `--output/current` ## 5. 自动更新检查 @@ -210,6 +212,7 @@ cargo run -p bat-infrastructure --example official_pull_plan -- \ - `--dry-run`:只报告本次是否会下载,不写 snapshot;如果 cache miss,也不会写入新的 bootstrap cache。 - `--dry-run --plan`:除更新判断外,还会解析 seed catalog 并打印完整下载 URL。 - 真实更新会输出 `downloaded_count`、`resumed_count`、`skipped_count`、`transferred_bytes`、`official_seed_hash_verified_count`。 +- 复用统计还包括 `release_reused_count`、`cas_reused_count`、`reused_bytes` 和 `reuse_warnings`;单文件 progress 状态区分 `release_reused`、`cas_reused`、`downloaded`,`transferred_bytes` 不包含复用文件。 - 校验报告分层输出 `official_seed_hash_verified_count`、`local_manifest_verified_count`、`addressables_marker_checked_count`、`unverified_marker_count`。 - 下载阶段复用同一套本地清单、ZIP 结构校验和 `.part` 续传逻辑;没有清单或校验不匹配的文件会重新下载。 - 非 dry-run 且启用 `--auto-discover` 时,成功发布的 release 会包含 `official-launcher-bootstrap.json`;up-to-date 轮询发现当前 release 缺少该文件时会补写。官方 launcher/server-info 已更新但 client-patch 资源尚未开放时,不切换 `current`,只在输出根写入 `official-launcher-bootstrap.pending.json` 作为维护期证据。 @@ -226,6 +229,7 @@ cargo run -p bat-infrastructure --example official_pull_plan -- \ - `/official-bootstrap-cache.json`:`--auto-discover` 的 `GameMainConfig` 解析缓存。launcher metadata 与 remote manifest 文件列表 digest 都未变时复用缓存;任一变化时才通过官方 HTTP 按 manifest 下载必要 `resources.assets` 或旧版 game zip 到临时目录解析。 - `/official-version-state.json`:资源发布根目录的持久版本状态,包含当前已完成版本、正在拉取版本、上一个可用版本和失败版本。 - `/current/official-download-manifest.json`:本地下载强校验清单,记录 URL、相对路径、size 和 BLAKE3。 +- `/current/official-cas-reuse-references.json`:当前 release 获取的 CAS 引用清单;每个复用项占一条记录,release 清理或孤儿 staging GC 时据此递减引用。 - `/current/official-resource-changes.json`:当前 release 相对上一完整 release 的资源差异,记录新增、变更、删除以及解析/翻译候选计数。 - `/current/crowdin-translation-handoff.json`:为后续 Crowdin worker 预留的本地队列,只包含新增+变更资源;它不是 Crowdin API 调用结果。 - `/current/official-parse-cache.json`:官方资源发布后的派生解析缓存,记录 bundle/zip 条目解析摘要和缓存复用情况;它不是汉化产物。 @@ -320,7 +324,7 @@ cargo run -p bat-infrastructure --bin bat -- \ --error-retry 60s ``` -默认平台是 `Windows,Android`,无需显式传 `--platforms`;只有要覆盖默认平台时才传。`--interval` 是正常检查周期,默认 `1h`;watch/daemon 模式还会在每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 强制执行一次自动刷新,该轮会注入 `force=true`,并且会中断普通 interval 的 sleep。`--error-retry` 是下载、发现或校验失败后的重试周期,默认 `60s`,也可以用 `--error-retry-seconds 60`。CLI 默认启动时向 stderr 打印 `BlueArchiveToolkit` ASCII banner,并把阶段进度日志写到 stderr,包括自动发现、proxy、server-info、marker、catalog、audit、download、snapshot 和 publish 阶段;download 阶段会输出已完成计数和单文件开始/完成状态,下载执行保持顺序处理,已完成计数保持单调不倒退,audit 阶段会输出官方 `.hash`、本地 BLAKE3、需修复项和 ZIP 结构校验结果摘要。daemon 还会写 `bat-events.jsonl` 结构化日志并按大小轮转。命令结果默认以人类可读摘要写到 stdout。需要纯机器输出时加 `--json --no-progress`,需要显式开启进度日志则用 `--progress`;只想关闭横幅但保留日志时可加 `--no-banner`。错误时 stderr 输出 JSON error,watch 模式下错误 JSON 的 `next_retry_seconds` 使用失败重试周期;如果未关闭 progress,错误 JSON 前可能已有 banner 和进度日志。普通错误 exit `1`,资源目录锁冲突 exit `75`,`verify` 或 `doctor` 发现问题也返回非 0。 +默认平台是 `Windows,Android`,无需显式传 `--platforms`;只有要覆盖默认平台时才传。`--interval` 是正常检查周期,默认 `1h`;watch/daemon 模式还会在每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 强制执行一次自动刷新,该轮会注入 `force=true`,并且会中断普通 interval 的 sleep。`--error-retry` 是下载、发现或校验失败后的重试周期,默认 `60s`,也可以用 `--error-retry-seconds 60`。CLI 默认启动时向 stderr 打印 `BlueArchiveToolkit` ASCII banner,并把阶段进度日志写到 stderr,包括自动发现、proxy、server-info、marker、catalog、audit、download、snapshot 和 publish 阶段;download 阶段会输出已完成计数和单文件开始/完成状态,worker 从共享队列独立领取任务并在完成后立即领取下一项,完成计数保持单调不倒退,最终 report 的 `items` 仍按 pull plan 顺序排列,audit 阶段会输出官方 `.hash`、本地 BLAKE3、需修复项和 ZIP 结构校验结果摘要。daemon 还会写 `bat-events.jsonl` 结构化日志并按大小轮转。命令结果默认以人类可读摘要写到 stdout。需要纯机器输出时加 `--json --no-progress`,需要显式开启进度日志则用 `--progress`;只想关闭横幅但保留日志时可加 `--no-banner`。错误时 stderr 输出 JSON error,watch 模式下错误 JSON 的 `next_retry_seconds` 使用失败重试周期;如果未关闭 progress,错误 JSON 前可能已有 banner 和进度日志。普通错误 exit `1`,资源目录锁冲突 exit `75`,`verify` 或 `doctor` 发现问题也返回非 0。 生产可以直接运行 `--watch`,也可以用 `--daemon` 后台运行,或者用 systemd service、容器或 Go 进程守护它。cron/systemd timer 仍可调用单次模式,但不再是 Rust 自动更新的唯一方式。下载默认并发 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 还会阻止前台写命令直接修改它正在管理的同一目录。 diff --git a/docs/reports/CURRENT_GAPS.md b/docs/reports/CURRENT_GAPS.md index fe63a43..614fb77 100644 --- a/docs/reports/CURRENT_GAPS.md +++ b/docs/reports/CURRENT_GAPS.md @@ -301,6 +301,7 @@ issue 43 例外说明:新增的 `parse repack` 只编排已有 TextAsset、Typ - `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.tasks` / `translation.handoff` 已提供 Go typed helper 和 bat-api 鉴权查询端点;`translation.task.update` 已提供 queued/running/failed/completed/skipped 状态回写契约,并已通过 `bat i18n task update`、Go typed helper 和 bat-api 鉴权控制面暴露;`translation.worker.run` 已提供 Rust provider worker,支持 mock/Crowdin、独立 claim、lease、失败分类、重试和 TextUnit 级译文结果落库;`translation.proofread` 已提供人工校对状态标记契约。 +- issue #47 已补齐官方资源同步的历史 release 与 CAS 前置复用:按计划顺序生成结果、按共享队列动态领取任务、对复用源执行 size/BLAKE3/ZIP 校验,失败时保留诊断并回退网络;CAS release 引用写入版本化清单,staging GC 会先释放引用再删除目录。更丰富的 Resource 查询、翻译记忆和通用 Patch 发布仍属于后续缺口。 - `i18n get/set/unset` 可查看、修改或清空单条工作台译文,`i18n validate` 可在发布前校验工作台 release、source text、重复 patch 目标,并区分可直接发布的 TextAsset 与必须进入 `parse repack` 的条目;`i18n proofread` 可把当前汉化 workflow 标记为人工校对中,且不会遮蔽已发布汉化 release;`parse clear-cache` 只删除可再生解析/队列 JSON,保留 `translation-tasks.sqlite` 的 worker 状态。 - 翻译记忆和完整 localized repack 仍属于后续翻译系统工作,不在当前 provider worker 状态闭环范围内。 @@ -572,12 +573,13 @@ issue 43 例外说明:新增的 `parse repack` 只编排已有 TextAsset、Typ 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/分发和同机 live 联调已完成;非「从零实现」。后续真实官方网络长期运行、持久化和完整 launcher/业务链不属于本 issue 关闭条件。 -6. issue #2 / G-007(P1):Addressables 可校验字段。 -7. issue #3 / G-005(P1):UnityFS 容器基础解析已落地;对象级引擎解析继续跟踪 G-005。 -8. G-011:更丰富的 TextUnit 查询、翻译记忆查询和通用 Patch 发布所需资源视图。 -9. G-012 / G-006:Crowdin/翻译系统、复杂 AssetBundle 重打包和 Patch 发布流程统一。 -10. G-011D:原版/汉化双发布后的查询、分发和清理策略。 +4. **issue #47:官方资源历史 release/CAS 复用已完成**;后续只跟踪真实官方长期运行和更丰富的版本清理策略。 +5. **G-008:已决策关闭**(同步 CLI = Rust `bat`;见 `GO_STATUS.md`)。 +6. **G-009 / issue #19**:资源 bootstrap/分发和同机 live 联调已完成;非「从零实现」。后续真实官方网络长期运行、持久化和完整 launcher/业务链不属于本 issue 关闭条件。 +7. issue #2 / G-007(P1):Addressables 可校验字段。 +8. issue #3 / G-005(P1):UnityFS 容器基础解析已落地;对象级引擎解析继续跟踪 G-005。 +9. G-011:更丰富的 TextUnit 查询、翻译记忆查询和通用 Patch 发布所需资源视图。 +10. G-012 / G-006:Crowdin/翻译系统、复杂 AssetBundle 重打包和 Patch 发布流程统一。 +11. G-011D:原版/汉化双发布后的查询、分发和清理策略。 Go 进度以 `docs/reports/GO_STATUS.md` 为准。G-018 / G-017 已关闭。 diff --git a/infrastructure/src/bin/bat/app.rs b/infrastructure/src/bin/bat/app.rs index 42cac50..a98c933 100644 --- a/infrastructure/src/bin/bat/app.rs +++ b/infrastructure/src/bin/bat/app.rs @@ -9,8 +9,8 @@ use bat_infrastructure::{ apply_patch_file, apply_unityfs_field_patch_file, apply_unityfs_string_field_patch_file, apply_unityfs_text_asset_patch_file, changed_endpoint_urls, completed_worker_translation_workbench, diff_extended_snapshot, export_translation_workbench, - gc_orphan_staging, get_translation_entry, lexical_absolute, localized_patch_operations, - open_append_file, read_download_manifest_at, read_file_no_symlink, + gc_orphan_staging_with_cas_root, get_translation_entry, lexical_absolute, + localized_patch_operations, open_append_file, read_download_manifest_at, read_file_no_symlink, read_localized_patch_manifest_at, read_localized_version_state, read_parse_cache_at, read_snapshot, read_textunit_index_at, read_translation_workbench, read_version_state, redact_proxy_url, repack_bundle, resolve_curl_proxy, set_translation, unset_translation, @@ -5103,8 +5103,18 @@ impl HumanReport for OfficialUpdateReport { print_optional_field("资源数", self.resource_count); print_field("已下载", self.downloaded_count); print_field("已续传", self.resumed_count); - print_field("已跳过", self.skipped_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); @@ -6172,7 +6182,11 @@ fn run_clean_stable_command(options: &CliOptions) -> anyhow::Result<()> { // 后台已停止,清理未被版本状态引用的孤儿 staging 目录。 if let Some(state) = read_version_state(&options.config.version_state_path())? { - removed_paths.extend(gc_orphan_staging(&options.config.output_root, &state)?); + removed_paths.extend(gc_orphan_staging_with_cas_root( + &options.config.output_root, + &state, + &options.config.effective_import_cas_root(), + )?); } let report = CleanStableReport { diff --git a/infrastructure/src/lib.rs b/infrastructure/src/lib.rs index eacf06c..fbd61f4 100644 --- a/infrastructure/src/lib.rs +++ b/infrastructure/src/lib.rs @@ -70,13 +70,15 @@ pub use official_changes::{ OFFICIAL_RESOURCE_CHANGES_VERSION, }; pub use official_download::{ - read_download_manifest_at, DownloadError, OfficialDownloadManifest, + read_cas_reuse_reference_manifest_at, read_download_manifest_at, release_cas_reuse_references, + DownloadError, OfficialCasReuseReferenceManifest, OfficialDownloadManifest, OfficialDownloadManifestEntry, OfficialLocalManifestAuditItem, OfficialLocalManifestAuditReport, OfficialLocalManifestAuditStatus, OfficialLocalVerificationReport, OfficialResourceHashAlgorithm, OfficialResourceHashVerification, OfficialResourcePullItem, OfficialResourcePullProgress, OfficialResourcePullProgressKind, OfficialResourcePullReport, OfficialResourcePullService, - OfficialResourcePullStatus, OfficialResourceVerification, + OfficialResourcePullStatus, OfficialResourceReuseWarning, OfficialResourceVerification, + OFFICIAL_CAS_REUSE_REFERENCES_FILE, }; pub use official_game_main_config::OfficialGameMainConfigBootstrapService; pub use official_launcher::{ @@ -116,13 +118,14 @@ pub use official_textunit_queue::{ }; pub use official_update::{ cached_game_main_config_for_metadata, diff_extended_snapshot, gc_orphan_staging, - read_bootstrap_cache, read_snapshot, read_version_state, write_bootstrap_cache, write_snapshot, - write_version_state, ExtendedSnapshotDelta, GameMainConfigSnapshot, LauncherMetadataSnapshot, - LocalizedReleaseStatus, OfficialBootstrapCache, OfficialEndpointMarkerRole, - OfficialEndpointMarkerSnapshot, OfficialFailedVersionRecord, OfficialServerInfoSource, - OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, - OfficialUpdateSnapshot, OfficialUpdateStatus, OfficialVerificationSummary, - OfficialVersionRecord, OfficialVersionState, ResolvedBootstrap, + gc_orphan_staging_with_cas_root, read_bootstrap_cache, read_snapshot, read_version_state, + write_bootstrap_cache, write_snapshot, write_version_state, ExtendedSnapshotDelta, + GameMainConfigSnapshot, LauncherMetadataSnapshot, LocalizedReleaseStatus, + OfficialBootstrapCache, OfficialEndpointMarkerRole, OfficialEndpointMarkerSnapshot, + OfficialFailedVersionRecord, OfficialServerInfoSource, OfficialUpdateConfig, + OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateSnapshot, + OfficialUpdateStatus, OfficialVerificationSummary, OfficialVersionRecord, OfficialVersionState, + ResolvedBootstrap, }; pub use patch_ops::{ apply_patch_file, apply_unityfs_field_patch_file, apply_unityfs_string_field_patch_file, diff --git a/infrastructure/src/official_download.rs b/infrastructure/src/official_download.rs index 5c546eb..8d5b885 100644 --- a/infrastructure/src/official_download.rs +++ b/infrastructure/src/official_download.rs @@ -18,12 +18,14 @@ use bat_adapters::official::{ destination_under_root, DownloadUrlMapper, OfficialResourceBackend, SidecarHashStrategy, XxHash32DecimalSeedZero, YostarJpBackend, }; +use bat_core::repositories::CasRepository; use serde::{Deserialize, Serialize}; -use std::collections::{BTreeMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::fs::{self, File}; use std::io::Read; use std::path::{Path, PathBuf}; use std::process::Command; +use std::sync::{Arc, Mutex}; use std::time::{SystemTime, UNIX_EPOCH}; /// 官方资源下载错误:携带统一错误码,便于 CLI/RPC 归类。 @@ -66,15 +68,23 @@ impl std::error::Error for DownloadError {} const DOWNLOAD_MANIFEST_FILE: &str = "official-download-manifest.json"; const DOWNLOAD_QUARANTINE_FILE: &str = "official-download-quarantine.json"; +/// 记录一个已发布官方 release 获取的 CAS 引用。 +pub const OFFICIAL_CAS_REUSE_REFERENCES_FILE: &str = "official-cas-reuse-references.json"; +const OFFICIAL_CAS_REUSE_REFERENCES_VERSION: u32 = 1; const DOWNLOAD_MANIFEST_VERSION: u32 = 1; const DOWNLOAD_QUARANTINE_VERSION: u32 = 1; const DEFAULT_RETRY_ATTEMPTS: usize = 3; /// Outcome for one official resource pull item. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum OfficialResourcePullStatus { /// The destination already existed and passed local manifest validation. SkippedExisting, + /// The resource was materialized from a previously published release. + ReleaseReused, + /// The resource was materialized from a CAS object. + CasReused, /// A partial `.part` file was resumed. Resumed, /// The resource was downloaded from scratch. @@ -86,6 +96,8 @@ impl OfficialResourcePullStatus { pub fn as_str(self) -> &'static str { match self { Self::SkippedExisting => "skipped_existing", + Self::ReleaseReused => "release_reused", + Self::CasReused => "cas_reused", Self::Resumed => "resumed", Self::Downloaded => "downloaded", } @@ -95,9 +107,9 @@ impl OfficialResourcePullStatus { /// Progress event kind emitted while executing an official pull plan. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OfficialResourcePullProgressKind { - /// A URL is about to be checked or downloaded. + /// A URL is about to be checked or reused/downloaded. Started, - /// A URL finished as skipped, resumed, or downloaded. + /// A URL finished as skipped, reused, resumed, or downloaded. Finished, /// An official sidecar hash pair passed verification. Verification, @@ -296,7 +308,7 @@ pub struct OfficialResourceHashVerification { } /// One downloaded official resource. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct OfficialResourcePullItem { /// Original URL. pub url: String, @@ -308,15 +320,138 @@ pub struct OfficialResourcePullItem { pub transferred_bytes: u64, /// Pull outcome. pub status: OfficialResourcePullStatus, + /// CAS object referenced when this item was reused from CAS. + #[serde(default)] + pub cas_object_id: Option, } /// Download report for one executed pull plan. -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct OfficialResourcePullReport { /// Downloaded resources in pull-plan order, independent of completion order. pub items: Vec, /// Official hash sidecars successfully verified after download. pub verified_hashes: Vec, + /// Non-fatal reuse diagnostics that fell back to the next source or network. + pub reuse_warnings: Vec, +} + +/// Non-fatal diagnostic emitted when a historical release or CAS candidate +/// could not be reused and the pull fell back to another source. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OfficialResourceReuseWarning { + /// Current official URL being processed. + pub url: String, + /// Reuse source class, such as `historical_release` or `cas`. + pub source: String, + /// Human-readable diagnostic message. + pub message: String, +} + +/// Versioned CAS references acquired while materializing one official release. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OfficialCasReuseReferenceManifest { + /// 引用清单版本。 + pub version: u32, + /// 每个 CAS 引用一项。允许重复,因为每个拉取项分别拥有一个引用。 + pub object_ids: Vec, +} + +impl Default for OfficialCasReuseReferenceManifest { + fn default() -> Self { + Self { + version: OFFICIAL_CAS_REUSE_REFERENCES_VERSION, + object_ids: Vec::new(), + } + } +} + +/// Reads a release-local CAS reference manifest. +pub fn read_cas_reuse_reference_manifest_at( + release_root: &Path, +) -> Result, String> { + let path = release_root.join(OFFICIAL_CAS_REUSE_REFERENCES_FILE); + let Some(bytes) = read_file_no_symlink(&path, "官方 release CAS 引用清单")? else { + return Ok(None); + }; + let manifest: OfficialCasReuseReferenceManifest = + serde_json::from_slice(&bytes).map_err(|error| { + format!( + "解析官方 release CAS 引用清单失败 {}:{error}", + path.display() + ) + })?; + if manifest.version != OFFICIAL_CAS_REUSE_REFERENCES_VERSION { + return Err(format!( + "不支持的官方 release CAS 引用清单版本 {},文件 {}", + manifest.version, + path.display() + )); + } + Ok(Some(manifest)) +} + +/// Decrements and removes CAS references recorded for a release. +/// +/// The operation is resumable: after every successful decrement the remaining +/// object IDs are atomically written back to the release-local manifest. +pub fn release_cas_reuse_references(release_root: &Path, cas_root: &Path) -> Result { + let Some(mut manifest) = read_cas_reuse_reference_manifest_at(release_root)? else { + return Ok(0); + }; + let objects_root = cas_root.join("objects"); + let metadata_path = cas_root.join("metadata.sqlite"); + require_existing_directory(cas_root, "CAS 根目录")?; + require_existing_directory(&objects_root, "CAS 对象目录")?; + require_existing_file(cas_root, &metadata_path, "CAS 元数据库")?; + let mut released = 0usize; + while let Some(object_id) = manifest.object_ids.pop() { + let cas_root = cas_root.to_path_buf(); + let object_id_for_runtime = object_id.clone(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| format!("创建 CAS 引用清理 runtime 失败:{error}"))?; + runtime.block_on(async move { + let cas = crate::FileSystemCasRepository::new(cas_root); + cas.remove_reference(&object_id_for_runtime) + .await + .map_err(|error| format!("减少 CAS release 引用失败 object={object_id}:{error}")) + })?; + write_cas_reuse_reference_manifest(release_root, &manifest)?; + released += 1; + } + let path = release_root.join(OFFICIAL_CAS_REUSE_REFERENCES_FILE); + match fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(format!( + "官方 release CAS 引用清单不能是 symlink:{}", + path.display() + )) + } + Ok(_) => fs::remove_file(&path) + .map_err(|error| format!("删除空官方 release CAS 引用清单失败:{error}"))?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "检查官方 release CAS 引用清单失败 {}:{error}", + path.display() + )) + } + } + Ok(released) +} + +fn write_cas_reuse_reference_manifest( + release_root: &Path, + manifest: &OfficialCasReuseReferenceManifest, +) -> Result<(), String> { + let path = release_root.join(OFFICIAL_CAS_REUSE_REFERENCES_FILE); + ensure_path_within_root(release_root, &path)?; + ensure_safe_file_target(release_root, &path, "官方 release CAS 引用清单")?; + let bytes = serde_json::to_vec_pretty(manifest) + .map_err(|error| format!("序列化官方 release CAS 引用清单失败:{error}"))?; + write_file_atomic(&path, &bytes, STATE_FILE_MODE, "官方 release CAS 引用清单") } impl OfficialResourcePullReport { @@ -338,6 +473,38 @@ impl OfficialResourcePullReport { .count() } + /// Returns the number of resources reused from historical releases. + pub fn release_reused_count(&self) -> usize { + self.items + .iter() + .filter(|item| item.status == OfficialResourcePullStatus::ReleaseReused) + .count() + } + + /// Returns the number of resources reused from CAS. + pub fn cas_reused_count(&self) -> usize { + self.items + .iter() + .filter(|item| item.status == OfficialResourcePullStatus::CasReused) + .count() + } + + /// Returns the byte count satisfied without network transfer. + pub fn reused_bytes(&self) -> u64 { + self.items + .iter() + .filter(|item| { + matches!( + item.status, + OfficialResourcePullStatus::SkippedExisting + | OfficialResourcePullStatus::ReleaseReused + | OfficialResourcePullStatus::CasReused + ) + }) + .map(|item| item.bytes) + .sum() + } + /// Returns the number of resources resumed from `.part` files. pub fn resumed_count(&self) -> usize { self.items @@ -552,6 +719,9 @@ pub struct OfficialResourcePullService { curl_proxy: CurlProxyConfig, retry_attempts: usize, max_concurrency: usize, + release_reuse_roots: Vec, + cas_reuse_root: Option, + cas_reference_tracker: Arc>>, } impl OfficialResourcePullService { @@ -572,6 +742,9 @@ impl OfficialResourcePullService { curl_proxy: CurlProxyConfig::default(), retry_attempts: DEFAULT_RETRY_ATTEMPTS, max_concurrency: DEFAULT_DOWNLOAD_CONCURRENCY, + release_reuse_roots: Vec::new(), + cas_reuse_root: None, + cas_reference_tracker: Arc::new(Mutex::new(Vec::new())), } } @@ -598,6 +771,29 @@ impl OfficialResourcePullService { self } + /// Enables best-effort reuse from immutable published release directories. + pub fn with_release_reuse_root(mut self, release_root: impl Into) -> Self { + self.release_reuse_roots.push(release_root.into()); + self + } + + /// Enables best-effort reuse from several immutable release roots. + pub fn with_release_reuse_roots(mut self, release_roots: I) -> Self + where + I: IntoIterator, + P: Into, + { + self.release_reuse_roots + .extend(release_roots.into_iter().map(Into::into)); + self + } + + /// Enables best-effort reuse from an existing filesystem CAS root. + pub fn with_cas_reuse_root(mut self, cas_root: impl Into) -> Self { + self.cas_reuse_root = Some(cas_root.into()); + self + } + /// Returns the configured download concurrency. pub fn max_concurrency(&self) -> usize { self.max_concurrency @@ -618,6 +814,38 @@ impl OfficialResourcePullService { self.output_root.join(DOWNLOAD_QUARANTINE_FILE) } + fn track_cas_reference(&self, object_id: &str) { + if let Ok(mut references) = self.cas_reference_tracker.lock() { + references.push(object_id.to_string()); + } + } + + fn untrack_cas_reference(&self, object_id: &str) { + if let Ok(mut references) = self.cas_reference_tracker.lock() { + if let Some(index) = references.iter().position(|id| id == object_id) { + references.remove(index); + } + } + } + + fn persist_cas_reuse_references( + &self, + report: &OfficialResourcePullReport, + ) -> Result<(), String> { + let object_ids = report + .items + .iter() + .filter_map(|item| item.cas_object_id.clone()) + .collect::>(); + if object_ids.is_empty() { + return Ok(()); + } + let mut manifest = + read_cas_reuse_reference_manifest_at(&self.output_root)?.unwrap_or_default(); + manifest.object_ids.extend(object_ids); + write_cas_reuse_reference_manifest(&self.output_root, &manifest) + } + /// Removes resource files recorded by an older manifest but absent from /// the current pull plan. /// @@ -679,10 +907,15 @@ impl OfficialResourcePullService { mut should_cancel: impl FnMut() -> bool, ) -> Result { self.ensure_output_root_ready()?; + let cas_reference_guard = CasReferenceRollbackGuard::new( + self.cas_reuse_root.clone(), + Arc::clone(&self.cas_reference_tracker), + ); let official_hash_pairs = official_seed_hash_pairs(plan); let force_refresh_urls = official_hash_refresh_urls(&official_hash_pairs); let mut manifest = self.read_download_manifest()?; + let release_reuse_index = self.build_release_reuse_index()?; let urls = plan.all_urls()?; let total = urls.len(); @@ -693,6 +926,7 @@ impl OfficialResourcePullService { return Err("官方资源拉取已被停止请求中断".to_string().into()); } let mut planned: Vec = Vec::with_capacity(total); + let mut planned_destinations = HashSet::with_capacity(total); for url in urls { if !self.backend.is_official_url(&url) { return Err(DownloadError::new( @@ -701,6 +935,12 @@ impl OfficialResourcePullService { )); } let destination = self.destination_for_url(&url)?; + if !planned_destinations.insert(destination.clone()) { + return Err(DownloadError::new( + bat_core::ErrorCode::INVALID_ARGUMENT, + format!("官方资源拉取计划包含重复目标:{}", destination.display()), + )); + } if let Some(parent) = destination.parent() { ensure_safe_directory_path(parent, "下载目标目录")?; fs::create_dir_all(parent).map_err(|error| { @@ -716,22 +956,31 @@ impl OfficialResourcePullService { } else { self.validated_existing_file(&url, &destination, &manifest)? }; + let reuse_candidate = if existing.is_none() && !force_refresh { + self.reuse_candidate_for_url(&url, &destination, &release_reuse_index)? + } else { + None + }; planned.push(PlannedDownload { url, destination, existing, + reuse_candidate, }); } if self.max_concurrency > 1 { - return self.pull_planned_concurrently( + let report = self.pull_planned_concurrently( planned, manifest, official_hash_pairs, - total, + release_reuse_index.warnings, &mut progress, &mut should_cancel, - ); + )?; + self.persist_cas_reuse_references(&report)?; + cas_reference_guard.commit(); + return Ok(report); } // Phase B:按 plan 顺序处理每个 URL。下载或复用完成并写入 manifest 后, @@ -739,6 +988,15 @@ impl OfficialResourcePullService { let mut completed_count = 0usize; let mut items = Vec::with_capacity(planned.len()); let mut verified_hashes = Vec::new(); + let mut reuse_warnings = release_reuse_index + .warnings + .into_iter() + .map(|message| OfficialResourceReuseWarning { + url: String::new(), + source: "historical_release_index".to_string(), + message, + }) + .collect::>(); let mut verified_hash_urls = HashSet::::new(); let mut processed_urls = HashSet::::new(); @@ -756,8 +1014,9 @@ impl OfficialResourcePullService { self.clear_quarantine_entry(&item.url)?; existing.clone() } else { - match self.pull_one(&item.url, &item.destination) { + match self.pull_task(item.clone()) { Ok(mut pull_result) => { + reuse_warnings.append(&mut pull_result.reuse_warnings); let verification_result = self .clear_quarantine_entry(&item.url) .and_then(|_| { @@ -852,15 +1111,20 @@ impl OfficialResourcePullService { bytes: result.bytes, transferred_bytes: result.transferred_bytes, status: result.status, + cas_object_id: result.cas_object_id.clone(), }); } self.verify_all_official_hashes_are_complete(&official_hash_pairs, &verified_hash_urls)?; - Ok(OfficialResourcePullReport { + let report = OfficialResourcePullReport { items, verified_hashes, - }) + reuse_warnings, + }; + self.persist_cas_reuse_references(&report)?; + cas_reference_guard.commit(); + Ok(report) } fn pull_planned_concurrently( @@ -868,10 +1132,11 @@ impl OfficialResourcePullService { planned: Vec, mut manifest: OfficialDownloadManifest, official_hash_pairs: Vec, - total: usize, + mut reuse_warnings: Vec, progress: &mut impl FnMut(OfficialResourcePullProgress), should_cancel: &mut impl FnMut() -> bool, ) -> Result { + let total = planned.len(); if should_cancel() { return Err("官方资源拉取已被停止请求中断".to_string().into()); } @@ -888,6 +1153,14 @@ impl OfficialResourcePullService { let mut items_by_plan_index: Vec> = (0..total).map(|_| None).collect(); let mut verified_hashes = Vec::new(); + let mut reuse_warnings = reuse_warnings + .drain(..) + .map(|message| OfficialResourceReuseWarning { + url: String::new(), + source: "historical_release_index".to_string(), + message, + }) + .collect::>(); let mut verified_hash_urls = HashSet::::new(); let mut processed_urls = HashSet::::new(); @@ -903,6 +1176,7 @@ impl OfficialResourcePullService { let needs_manifest = item.existing.is_none(); let verification = match result { Ok(pull_result) => { + reuse_warnings.extend(pull_result.reuse_warnings.clone()); let verification_result = self .clear_quarantine_entry(&item.url) .and_then(|_| { @@ -1017,6 +1291,7 @@ impl OfficialResourcePullService { bytes: pull_result.bytes, transferred_bytes: pull_result.transferred_bytes, status: pull_result.status, + cas_object_id: pull_result.cas_object_id.clone(), }); Ok(()) }, @@ -1037,6 +1312,7 @@ impl OfficialResourcePullService { Ok(OfficialResourcePullReport { items, verified_hashes, + reuse_warnings, }) } @@ -1124,6 +1400,358 @@ impl OfficialResourcePullService { }) } + fn build_release_reuse_index(&self) -> Result { + let mut index = ReleaseReuseIndex::default(); + let mut roots = Vec::new(); + for configured_root in &self.release_reuse_roots { + let metadata = match fs::symlink_metadata(configured_root) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => { + index.warnings.push(format!( + "读取历史 release 根失败 {}:{error}", + configured_root.display() + )); + continue; + } + }; + if metadata.file_type().is_symlink() { + index.warnings.push(format!( + "历史 release 根不能是 symlink:{}", + configured_root.display() + )); + continue; + } + if !metadata.is_dir() { + index.warnings.push(format!( + "历史 release 根不是目录:{}", + configured_root.display() + )); + continue; + } + if let Err(error) = ensure_safe_directory_path(configured_root, "历史 release 根目录") + { + index.warnings.push(error); + continue; + } + + let manifest_path = configured_root.join(DOWNLOAD_MANIFEST_FILE); + if fs::symlink_metadata(&manifest_path) + .map(|metadata| !metadata.file_type().is_symlink() && metadata.is_file()) + .unwrap_or(false) + { + roots.push(configured_root.clone()); + continue; + } + + let entries = match fs::read_dir(configured_root) { + Ok(entries) => entries, + Err(error) => { + index.warnings.push(format!( + "读取历史 release 目录失败 {}:{error}", + configured_root.display() + )); + continue; + } + }; + let mut child_roots = entries + .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::>(); + child_roots.sort(); + child_roots.reverse(); + roots.extend(child_roots); + } + + for root in roots { + let manifest = match read_download_manifest_at(&root) { + Ok(Some(manifest)) => manifest, + Ok(None) => continue, + Err(error) => { + index.warnings.push(format!( + "忽略无效历史 release manifest {}:{error}", + root.display() + )); + continue; + } + }; + for entry in manifest.entries.values() { + if !is_valid_blake3_hex(&entry.blake3) { + index.warnings.push(format!( + "忽略没有可靠 BLAKE3 的历史条目:release={} destination={}", + root.display(), + entry.destination + )); + continue; + } + let source = root.join(Path::new(&entry.destination)); + if ensure_path_within_root(&root, &source).is_err() { + index.warnings.push(format!( + "忽略逃逸历史条目:release={} destination={}", + root.display(), + entry.destination + )); + continue; + } + let candidate = HistoricalReuseCandidate { + root: root.clone(), + source, + destination: entry.destination.clone(), + bytes: entry.bytes, + blake3: entry.blake3.clone(), + }; + index + .candidates + .entry(reuse_key(&entry.destination)) + .or_default() + .push(candidate); + } + } + Ok(index) + } + + fn reuse_candidate_for_url( + &self, + _url: &str, + destination: &Path, + index: &ReleaseReuseIndex, + ) -> Result, String> { + let relative_destination = self.relative_destination(destination)?; + Ok(index + .candidates + .get(&reuse_key(&relative_destination)) + .filter(|candidates| !candidates.is_empty()) + .cloned() + .map(|candidates| ReuseCandidate { candidates })) + } + + fn pull_task(&self, task: PlannedDownload) -> Result { + if let Some(existing) = task.existing { + return Ok(existing); + } + + let mut reuse_warnings = Vec::new(); + if let Some(candidate) = task.reuse_candidate.as_ref() { + let (reused, warnings) = + self.try_reuse_candidate(candidate, &task.url, &task.destination); + reuse_warnings.extend(warnings); + if let Some(mut result) = reused { + result.reuse_warnings = reuse_warnings; + return Ok(result); + } + } + + let mut result = self.pull_one(&task.url, &task.destination)?; + result.reuse_warnings = reuse_warnings; + Ok(result) + } + + fn try_reuse_candidate( + &self, + candidate: &ReuseCandidate, + url: &str, + destination: &Path, + ) -> (Option, Vec) { + let mut warnings = Vec::new(); + for historical in &candidate.candidates { + match self.materialize_historical_candidate(historical, url, destination) { + Ok(result) => return (Some(result), warnings), + Err(error) => warnings.push(OfficialResourceReuseWarning { + url: url.to_string(), + source: "historical_release".to_string(), + message: format!( + "release={} destination={}:{error}", + historical.root.display(), + historical.destination + ), + }), + } + } + + if self.cas_reuse_root.is_some() { + for historical in &candidate.candidates { + match self.materialize_cas_candidate(historical, url, destination) { + Ok(result) => return (Some(result), warnings), + Err(error) => warnings.push(OfficialResourceReuseWarning { + url: url.to_string(), + source: "cas".to_string(), + message: format!( + "object={} destination={}:{error}", + historical.blake3, historical.destination + ), + }), + } + } + } + + (None, warnings) + } + + fn materialize_historical_candidate( + &self, + candidate: &HistoricalReuseCandidate, + url: &str, + destination: &Path, + ) -> Result { + ensure_safe_directory_path(&candidate.root, "历史 release 根目录")?; + ensure_safe_file_target(&candidate.root, &candidate.source, "历史 release 复用源")?; + verify_file_attributes(&candidate.source, candidate.bytes, &candidate.blake3, url)?; + self.materialize_verified_path( + &candidate.source, + url, + destination, + ReuseMaterialization { + expected_bytes: candidate.bytes, + expected_blake3: candidate.blake3.clone(), + status: OfficialResourcePullStatus::ReleaseReused, + cas_object_id: None, + }, + ) + } + + fn materialize_cas_candidate( + &self, + candidate: &HistoricalReuseCandidate, + url: &str, + destination: &Path, + ) -> Result { + let cas_root = self + .cas_reuse_root + .as_ref() + .ok_or_else(|| "未配置 CAS 根目录".to_string())?; + let objects_root = cas_root.join("objects"); + let metadata_path = cas_root.join("metadata.sqlite"); + require_existing_directory(cas_root, "CAS 根目录")?; + require_existing_directory(&objects_root, "CAS 对象目录")?; + require_existing_file(cas_root, &metadata_path, "CAS 元数据库")?; + + let object_path = cas_object_path(cas_root, &candidate.blake3)?; + ensure_safe_file_target(&objects_root, &object_path, "CAS 对象")?; + let object_id = candidate.blake3.clone(); + let cas_root_for_runtime = cas_root.clone(); + let object_id_for_runtime = object_id.clone(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| format!("创建 CAS 校验 runtime 失败:{error}"))?; + runtime + .block_on(async move { + let cas = crate::FileSystemCasRepository::new(cas_root_for_runtime); + cas.get(&object_id_for_runtime) + .await + .map_err(|error| format!("CAS 对象完整性校验失败:{error}"))?; + cas.get_reference_count(&object_id_for_runtime) + .await + .map_err(|error| format!("CAS 对象元数据不一致:{error}"))?; + cas.add_reference(&object_id_for_runtime) + .await + .map_err(|error| format!("增加 CAS release 引用失败:{error}")) + }) + .map_err(|error| error.to_string())?; + self.track_cas_reference(&object_id); + + match self.materialize_verified_path( + &object_path, + url, + destination, + ReuseMaterialization { + expected_bytes: candidate.bytes, + expected_blake3: candidate.blake3.clone(), + status: OfficialResourcePullStatus::CasReused, + cas_object_id: Some(object_id.clone()), + }, + ) { + Ok(result) => Ok(result), + Err(error) => { + let cas_root_for_runtime = cas_root.clone(); + let object_id_for_runtime = object_id.clone(); + let rollback = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|runtime_error| { + format!("创建 CAS 回滚 runtime 失败:{runtime_error}") + })? + .block_on(async move { + let cas = crate::FileSystemCasRepository::new(cas_root_for_runtime); + cas.remove_reference(&object_id_for_runtime) + .await + .map_err(|rollback_error| rollback_error.to_string()) + }); + if let Err(rollback_error) = rollback { + return Err(format!("{error};回滚 CAS 引用失败:{rollback_error}")); + } + self.untrack_cas_reference(&object_id); + Err(error) + } + } + } + + fn materialize_verified_path( + &self, + source: &Path, + url: &str, + destination: &Path, + materialization: ReuseMaterialization, + ) -> Result { + ensure_safe_file_target(&self.output_root, destination, "复用目标文件")?; + if source == destination { + let verification = self.local_verification( + url, + destination, + Some(materialization.expected_bytes), + Some(materialization.expected_blake3), + )?; + return Ok(PullOneResult { + bytes: verification.actual_bytes, + transferred_bytes: 0, + status: materialization.status, + verification, + cas_object_id: materialization.cas_object_id, + reuse_warnings: Vec::new(), + }); + } + + let temporary = reuse_temporary_path(destination); + ensure_safe_file_target(&self.output_root, &temporary, "复用临时文件")?; + let result = (|| { + if let Err(hard_link_error) = fs::hard_link(source, &temporary) { + fs::copy(source, &temporary).map_err(|copy_error| { + format!("硬链接复用失败:{hard_link_error};跨文件系统复制也失败:{copy_error}") + })?; + } + let verification = self.local_verification( + url, + &temporary, + Some(materialization.expected_bytes), + Some(materialization.expected_blake3), + )?; + ensure_safe_file_target(&self.output_root, destination, "复用目标文件")?; + fs::rename(&temporary, destination).map_err(|error| { + format!( + "原子移动复用文件失败 {} -> {}:{error}", + temporary.display(), + destination.display() + ) + })?; + Ok(PullOneResult { + bytes: verification.actual_bytes, + transferred_bytes: 0, + status: materialization.status, + verification, + cas_object_id: materialization.cas_object_id, + reuse_warnings: Vec::new(), + }) + })(); + if result.is_err() { + let _ = fs::remove_file(&temporary); + } + result + } + /// Fetches an official URL into memory. /// /// This is intended for small discovery inputs such as `server-info`, @@ -1254,6 +1882,8 @@ impl OfficialResourcePullService { }, status, verification, + cas_object_id: None, + reuse_warnings: Vec::new(), }) } @@ -1586,6 +2216,8 @@ impl OfficialResourcePullService { transferred_bytes: 0, status: OfficialResourcePullStatus::SkippedExisting, verification, + cas_object_id: None, + reuse_warnings: Vec::new(), })) } @@ -2027,6 +2659,80 @@ fn default_download_quarantine_version() -> u32 { DOWNLOAD_QUARANTINE_VERSION } +#[derive(Debug, Clone, Default)] +struct ReleaseReuseIndex { + candidates: HashMap>, + warnings: Vec, +} + +#[derive(Debug, Clone)] +struct HistoricalReuseCandidate { + root: PathBuf, + source: PathBuf, + destination: String, + bytes: u64, + blake3: String, +} + +#[derive(Debug, Clone)] +struct ReuseCandidate { + candidates: Vec, +} + +struct CasReferenceRollbackGuard { + cas_root: Option, + tracker: Arc>>, + committed: bool, +} + +impl CasReferenceRollbackGuard { + fn new(cas_root: Option, tracker: Arc>>) -> Self { + Self { + cas_root, + tracker, + committed: false, + } + } + + fn commit(mut self) { + self.committed = true; + if let Ok(mut references) = self.tracker.lock() { + references.clear(); + } + } +} + +impl Drop for CasReferenceRollbackGuard { + fn drop(&mut self) { + if self.committed { + return; + } + let Some(cas_root) = self.cas_root.clone() else { + return; + }; + let object_ids = self + .tracker + .lock() + .map(|mut references| std::mem::take(&mut *references)) + .unwrap_or_default(); + if object_ids.is_empty() { + return; + } + let Ok(runtime) = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + else { + return; + }; + runtime.block_on(async move { + let cas = crate::FileSystemCasRepository::new(cas_root); + for object_id in object_ids { + let _ = cas.remove_reference(&object_id).await; + } + }); + } +} + /// Phase A 产出的单个下载计划项:URL、目标路径,以及若命中本地 manifest /// 校验则带上「已验证可跳过」的结果(`existing`)。 #[derive(Debug, Clone)] @@ -2034,6 +2740,7 @@ struct PlannedDownload { url: String, destination: PathBuf, existing: Option, + reuse_candidate: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -2042,6 +2749,16 @@ struct PullOneResult { transferred_bytes: u64, status: OfficialResourcePullStatus, verification: OfficialResourceVerification, + cas_object_id: Option, + reuse_warnings: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ReuseMaterialization { + expected_bytes: u64, + expected_blake3: String, + status: OfficialResourcePullStatus, + cas_object_id: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -2059,9 +2776,7 @@ impl DownloaderBackend for CurlDownloadBackend<'_> { type Error = PullOneError; fn download(&self, task: PlannedDownload) -> Result { - task.existing - .map(Ok) - .unwrap_or_else(|| self.service.pull_one(&task.url, &task.destination)) + self.service.pull_task(task) } } @@ -2131,6 +2846,103 @@ fn file_len_if_exists(path: &Path) -> Result, String> { } } +fn require_existing_directory(path: &Path, label: &str) -> Result<(), String> { + ensure_safe_directory_path(path, label)?; + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => Ok(()), + Ok(_) => Err(format!("{label} 不是可复用的目录:{}", path.display())), + Err(error) => Err(format!( + "{label} 不存在或不可读取 {}:{error}", + path.display() + )), + } +} + +fn require_existing_file(root: &Path, path: &Path, label: &str) -> Result<(), String> { + ensure_safe_file_target(root, path, label)?; + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => Ok(()), + Ok(_) => Err(format!("{label} 不是可复用的普通文件:{}", path.display())), + Err(error) => Err(format!( + "{label} 不存在或不可读取 {}:{error}", + path.display() + )), + } +} + +fn verify_file_attributes( + path: &Path, + expected_bytes: u64, + expected_blake3: &str, + url: &str, +) -> Result<(), String> { + let actual_bytes = + file_len_if_exists(path)?.ok_or_else(|| format!("复用源文件不存在:{}", path.display()))?; + if actual_bytes != expected_bytes { + return Err(format!( + "复用源 size 校验失败 {}:期望 {},实际 {}", + path.display(), + expected_bytes, + actual_bytes + )); + } + let actual_blake3 = blake3_file_hex(path)?; + if actual_blake3 != expected_blake3 { + return Err(format!( + "复用源 BLAKE3 校验失败 {}:期望 {},实际 {}", + path.display(), + expected_blake3, + actual_blake3 + )); + } + if url_or_path_has_zip_extension(url) || path_has_zip_extension(path) { + validate_zip_structure(path).map(|_| ())?; + } + Ok(()) +} + +fn is_valid_blake3_hex(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn reuse_key(destination: &str) -> String { + let normalized = destination.replace('\\', "/"); + let components = normalized + .split('/') + .filter(|component| !component.is_empty()) + .collect::>(); + if components.len() <= 2 { + return components.join("/"); + } + std::iter::once(components[0]) + .chain(components.into_iter().skip(2)) + .collect::>() + .join("/") +} + +fn cas_object_path(root: &Path, object_id: &str) -> Result { + if !is_valid_blake3_hex(object_id) { + return Err(format!("CAS 对象 ID 不是有效 BLAKE3:{object_id}")); + } + Ok(root + .join("objects") + .join(&object_id[..2]) + .join(&object_id[2..4]) + .join(object_id)) +} + +fn reuse_temporary_path(destination: &Path) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + let file_name = destination + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("resource"); + destination.with_file_name(format!(".{file_name}.reuse.{nonce}.tmp")) +} + fn partial_path_for(destination: &Path) -> PathBuf { let mut partial = destination.as_os_str().to_owned(); partial.push(".part"); @@ -3742,6 +4554,187 @@ exit 22 .any(|item| item.url.contains("Android_PatchPack"))); } + #[test] + fn reuses_verified_historical_release_when_cdn_root_changes() { + let temp = TempDir::new().unwrap(); + let out_dir = temp.path().join("out"); + let versions_dir = temp.path().join("versions"); + let old_root = versions_dir.join("release-old"); + let old_url = + "https://prod-clientpatch.bluearchiveyostar.com/r93_old/TableBundles/Reusable.bytes"; + let current_url = + "https://prod-clientpatch.bluearchiveyostar.com/r94_new/TableBundles/Reusable.bytes"; + let destination = + "prod-clientpatch.bluearchiveyostar.com/r93_old/TableBundles/Reusable.bytes"; + let bytes = b"verified historical resource"; + let source = old_root.join(destination); + fs::create_dir_all(source.parent().unwrap()).unwrap(); + fs::write(&source, bytes).unwrap(); + write_historical_manifest(&old_root, old_url, destination, bytes); + + let bin_dir = TempDir::new().unwrap(); + let curl_path = bin_dir.path().join("curl"); + write_fake_curl(&curl_path); + let service = OfficialResourcePullService::with_curl_command(&out_dir, &curl_path) + .with_release_reuse_root(&versions_dir); + + let report = service.pull(&one_url_plan(current_url)).unwrap(); + + assert_eq!(report.release_reused_count(), 1); + assert_eq!(report.cas_reused_count(), 0); + assert_eq!(report.downloaded_count(), 0); + assert_eq!(report.reused_bytes(), bytes.len() as u64); + assert_eq!( + fs::read(service.destination_for_url(current_url).unwrap()).unwrap(), + bytes + ); + assert_eq!( + report.items[0].status, + OfficialResourcePullStatus::ReleaseReused + ); + assert!(report.items[0].cas_object_id.is_none()); + assert!(report.reuse_warnings.is_empty()); + let manifest = service.read_download_manifest().unwrap(); + assert!(manifest.entries.contains_key(current_url)); + } + + #[test] + fn falls_back_to_cas_after_corrupt_historical_release() { + let temp = TempDir::new().unwrap(); + let out_dir = temp.path().join("out"); + let versions_dir = temp.path().join("versions"); + let old_root = versions_dir.join("release-old"); + let cas_root = temp.path().join("cas"); + let old_url = + "https://prod-clientpatch.bluearchiveyostar.com/r93_old/TableBundles/Reusable.bytes"; + let current_url = + "https://prod-clientpatch.bluearchiveyostar.com/r94_new/TableBundles/Reusable.bytes"; + let destination = + "prod-clientpatch.bluearchiveyostar.com/r93_old/TableBundles/Reusable.bytes"; + let bytes = b"cas-backed resource"; + let source = old_root.join(destination); + fs::create_dir_all(source.parent().unwrap()).unwrap(); + fs::write(&source, b"corrupt").unwrap(); + write_historical_manifest(&old_root, old_url, destination, bytes); + let object_id = store_cas_object(&cas_root, bytes); + + let bin_dir = TempDir::new().unwrap(); + let curl_path = bin_dir.path().join("curl"); + write_fake_curl(&curl_path); + let service = OfficialResourcePullService::with_curl_command(&out_dir, &curl_path) + .with_release_reuse_root(&versions_dir) + .with_cas_reuse_root(&cas_root); + + let report = service.pull(&one_url_plan(current_url)).unwrap(); + + assert_eq!(report.release_reused_count(), 0); + assert_eq!(report.cas_reused_count(), 1); + assert_eq!(report.downloaded_count(), 0); + assert_eq!( + report.items[0].cas_object_id.as_deref(), + Some(object_id.as_str()) + ); + assert_eq!( + fs::read(service.destination_for_url(current_url).unwrap()).unwrap(), + bytes + ); + assert!(report + .reuse_warnings + .iter() + .any(|warning| warning.source == "historical_release")); + let references = read_cas_reuse_reference_manifest_at(&out_dir) + .unwrap() + .unwrap(); + assert_eq!(references.object_ids, vec![object_id.clone()]); + assert_eq!(cas_reference_count(&cas_root, &object_id), 2); + + assert_eq!( + release_cas_reuse_references(&out_dir, &cas_root).unwrap(), + 1 + ); + assert_eq!(cas_reference_count(&cas_root, &object_id), 1); + assert!(read_cas_reuse_reference_manifest_at(&out_dir) + .unwrap() + .is_none()); + } + + #[test] + fn corrupted_cas_falls_back_to_network_with_diagnostic() { + let temp = TempDir::new().unwrap(); + let out_dir = temp.path().join("out"); + let versions_dir = temp.path().join("versions"); + let old_root = versions_dir.join("release-old"); + let cas_root = temp.path().join("cas"); + let old_url = + "https://prod-clientpatch.bluearchiveyostar.com/r93_old/TableBundles/Reusable.bytes"; + let current_url = + "https://prod-clientpatch.bluearchiveyostar.com/r94_new/TableBundles/Reusable.bytes"; + let destination = + "prod-clientpatch.bluearchiveyostar.com/r93_old/TableBundles/Reusable.bytes"; + let expected = b"expected cas resource"; + write_historical_manifest(&old_root, old_url, destination, expected); + let object_id = store_cas_object(&cas_root, expected); + fs::write(cas_object_path(&cas_root, &object_id).unwrap(), b"corrupt").unwrap(); + + let bin_dir = TempDir::new().unwrap(); + let curl_path = bin_dir.path().join("curl"); + write_fake_curl(&curl_path); + let service = OfficialResourcePullService::with_curl_command(&out_dir, &curl_path) + .with_release_reuse_root(&versions_dir) + .with_cas_reuse_root(&cas_root); + + let report = service.pull(&one_url_plan(current_url)).unwrap(); + + assert_eq!(report.downloaded_count(), 1); + assert_eq!(report.cas_reused_count(), 0); + assert_eq!( + fs::read(service.destination_for_url(current_url).unwrap()).unwrap(), + current_url.as_bytes() + ); + assert!(report + .reuse_warnings + .iter() + .any(|warning| warning.source == "cas" && warning.message.contains("完整性"))); + assert!(read_cas_reuse_reference_manifest_at(&out_dir) + .unwrap() + .is_none()); + } + + #[test] + fn missing_cas_root_does_not_create_storage_during_fallback() { + let temp = TempDir::new().unwrap(); + let out_dir = temp.path().join("out"); + let versions_dir = temp.path().join("versions"); + let old_root = versions_dir.join("release-old"); + let cas_root = temp.path().join("cas"); + let old_url = + "https://prod-clientpatch.bluearchiveyostar.com/r93_old/TableBundles/Reusable.bytes"; + let current_url = + "https://prod-clientpatch.bluearchiveyostar.com/r94_new/TableBundles/Reusable.bytes"; + let destination = + "prod-clientpatch.bluearchiveyostar.com/r93_old/TableBundles/Reusable.bytes"; + let expected = b"network fallback resource"; + let source = old_root.join(destination); + fs::create_dir_all(source.parent().unwrap()).unwrap(); + fs::write(&source, b"corrupt").unwrap(); + write_historical_manifest(&old_root, old_url, destination, expected); + + let bin_dir = TempDir::new().unwrap(); + let curl_path = bin_dir.path().join("curl"); + write_fake_curl(&curl_path); + let service = OfficialResourcePullService::with_curl_command(&out_dir, &curl_path) + .with_release_reuse_root(&versions_dir) + .with_cas_reuse_root(&cas_root); + + let report = service.pull(&one_url_plan(current_url)).unwrap(); + + assert_eq!(report.downloaded_count(), 1); + assert!(!cas_root.exists()); + assert!(report.reuse_warnings.iter().any(|warning| { + warning.source == "cas" && warning.message.contains("不存在或不可读取") + })); + } + #[test] fn fetches_official_seed_bytes_with_curl() { let out_dir = TempDir::new().unwrap(); @@ -3809,4 +4802,45 @@ exit 22 assert!(service.pull(&plan).is_err()); } + + fn write_historical_manifest(root: &Path, url: &str, destination: &str, bytes: &[u8]) { + fs::create_dir_all(root).unwrap(); + let mut manifest = OfficialDownloadManifest::default(); + manifest.entries.insert( + url.to_string(), + OfficialDownloadManifestEntry { + url: url.to_string(), + destination: destination.to_string(), + bytes: bytes.len() as u64, + blake3: blake3::hash(bytes).to_hex().to_string(), + }, + ); + fs::write( + root.join(DOWNLOAD_MANIFEST_FILE), + serde_json::to_vec(&manifest).unwrap(), + ) + .unwrap(); + } + + fn store_cas_object(root: &Path, bytes: &[u8]) -> String { + let cas = crate::FileSystemCasRepository::new(root); + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async { cas.store(bytes).await.unwrap() }) + } + + fn cas_reference_count(root: &Path, object_id: &str) -> u64 { + let cas = crate::FileSystemCasRepository::new(root); + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async { + cas.get_reference_count(&object_id.to_string()) + .await + .unwrap() + }) + } } diff --git a/infrastructure/src/official_update.rs b/infrastructure/src/official_update.rs index e8da844..96e90bc 100644 --- a/infrastructure/src/official_update.rs +++ b/infrastructure/src/official_update.rs @@ -34,12 +34,12 @@ use crate::translation_tasks::{ }; use crate::{ 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, OfficialResourceHashVerification, OfficialResourcePullPlan, OfficialResourcePullProgress, - OfficialResourcePullProgressKind, OfficialResourcePullService, OfficialResourceVerification, - YostarJpLauncherCdnConfig, YostarJpLauncherGameConfig, YostarJpLauncherManifestUrl, - YostarJpLauncherRemoteManifest, + OfficialResourcePullProgressKind, OfficialResourcePullService, OfficialResourceReuseWarning, + OfficialResourceVerification, YostarJpLauncherCdnConfig, YostarJpLauncherGameConfig, + YostarJpLauncherManifestUrl, YostarJpLauncherRemoteManifest, }; use crate::{ read_parse_cache_at, OfficialParseCacheService, OfficialParseConfig, OfficialParseSummary, @@ -827,6 +827,18 @@ pub struct OfficialUpdateReport { pub resumed_count: usize, /// Number of resources skipped using the local manifest. 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, /// Final local byte count in the pull report. pub final_bytes: u64, /// Bytes transferred in this run. @@ -1120,6 +1132,36 @@ impl OfficialPublishLayout { path_exists_no_follow(&self.root.join(OFFICIAL_DOWNLOAD_MANIFEST_FILE)) } + fn release_reuse_roots(&self, active_root: &Path) -> Result, 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::>(); + versions.sort(); + versions.reverse(); + roots.extend(versions); + roots.dedup(); + Ok(roots) + } + fn publish(&self, plan: &OfficialPublishPlan) -> Result { ensure_safe_directory_path(&self.versions_dir, "官方资源 versions 根目录")?; fs::create_dir_all(&self.versions_dir).map_err(|error| { @@ -1663,6 +1705,10 @@ impl OfficialUpdateService { downloaded_count: 0, resumed_count: 0, skipped_count: 0, + release_reused_count: 0, + cas_reused_count: 0, + reused_bytes: 0, + reuse_warnings: Vec::new(), final_bytes: 0, transferred_bytes: 0, official_seed_hash_verified_count: 0, @@ -1861,6 +1907,12 @@ impl OfficialUpdateService { &config.curl_command, ) .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); let pruned_stale_resource_count = staging_fetcher .prune_stale_manifest_entries(&pull_plan) @@ -1898,13 +1950,24 @@ impl OfficialUpdateService { progress(OfficialUpdateProgress::new( "download", format!( - "下载阶段完成:已下载={} 已续传={} 已复用={} 本轮传输字节={}", + "下载阶段完成:已下载={} 已续传={} 当前 manifest 复用={} 历史 release 复用={} CAS 复用={} 本轮传输字节={}", pull_report.downloaded_count(), pull_report.resumed_count(), pull_report.skipped_count(), + pull_report.release_reused_count(), + pull_report.cas_reused_count(), pull_report.transferred_bytes() ), )); + if !pull_report.reuse_warnings.is_empty() { + progress(OfficialUpdateProgress::new( + "download", + format!( + "复用候选诊断:{} 项候选未通过校验,已按顺序回退", + pull_report.reuse_warnings.len() + ), + )); + } progress(OfficialUpdateProgress::new( "audit", "执行最终本地 manifest 审计", @@ -1996,6 +2059,10 @@ impl OfficialUpdateService { report.downloaded_count = pull_report.downloaded_count(); report.resumed_count = pull_report.resumed_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.transferred_bytes = pull_report.transferred_bytes(); report.official_seed_hash_verified_count = pull_report.official_hash_verified_count(); @@ -2050,7 +2117,11 @@ impl OfficialUpdateService { // 清理未被最新版本状态引用的孤儿 staging 目录(GC 失败仅告警,不影响发布结果)。 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( "publish", format!("已清理 {} 个孤儿 staging 目录", removed.len()), @@ -2556,6 +2627,10 @@ fn waiting_for_official_resources_report( downloaded_count: 0, resumed_count: 0, skipped_count: 0, + release_reused_count: 0, + cas_reused_count: 0, + reused_bytes: 0, + reuse_warnings: Vec::new(), final_bytes: 0, transferred_bytes: 0, official_seed_hash_verified_count: 0, @@ -2874,6 +2949,8 @@ fn verification_progress_message(summary: &OfficialVerificationSummary) -> Strin fn localized_pull_status(status: crate::OfficialResourcePullStatus) -> &'static str { match status { crate::OfficialResourcePullStatus::SkippedExisting => "已复用", + crate::OfficialResourcePullStatus::ReleaseReused => "已复用历史 release", + crate::OfficialResourcePullStatus::CasReused => "已复用 CAS", crate::OfficialResourcePullStatus::Resumed => "已续传", crate::OfficialResourcePullStatus::Downloaded => "已下载", } @@ -3700,12 +3777,22 @@ pub fn gc_orphan_staging( output_root: &Path, state: &OfficialVersionState, ) -> anyhow::Result> { - 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> { + gc_orphan_staging_dirs(&output_root.join(OFFICIAL_STAGING_DIR), state, cas_root) } fn gc_orphan_staging_dirs( staging_dir: &Path, state: &OfficialVersionState, + cas_root: &Path, ) -> anyhow::Result> { let read_dir = match fs::read_dir(staging_dir) { Ok(read_dir) => read_dir, @@ -3740,6 +3827,7 @@ fn gc_orphan_staging_dirs( if referenced.contains(&name) { continue; } + release_cas_reuse_references(&path, cas_root).map_err(anyhow::Error::msg)?; fs::remove_dir_all(&path).map_err(|error| { anyhow::anyhow!("清理孤儿 staging 目录失败 {}:{error}", path.display()) })?;