From 90307a3243e6719ad32b0ed6c37eec7006ad72d4 Mon Sep 17 00:00:00 2001 From: Yuyi-Oak <1722157266@qq.com> Date: Wed, 15 Jul 2026 19:53:10 +0800 Subject: [PATCH] feat: track official versions and import resources Persist official version-state, extend CAS/ResourceRepository import classification, and add offline catalog and failure regression fixtures. Fixes #13 Fixes #12 Fixes #8 --- CHANGELOG.md | 3 + CURRENT_STATUS.md | 10 +- README.md | 6 +- adapters/src/manifest/addressables.rs | 60 ++- adapters/src/official/game_main_config.rs | 6 +- adapters/src/official/yostar_jp.rs | 4 +- adapters/src/unity/serialized_file.rs | 2 +- adapters/tests/addressables_regression.rs | 68 +++ .../current_catalog.json | 38 ++ .../previous_catalog.json | 9 + .../structure_changed_catalog.json | 24 + core/src/domain/resource.rs | 4 + .../architecture/official-resource-backend.md | 3 + docs/guides/official-resource-test-pull.md | 2 + docs/reports/CURRENT_GAPS.md | 72 ++- docs/reports/current-stage-prepush.md | 10 +- docs/reports/current-status-handoff.md | 11 +- .../examples/official_update_check.rs | 2 +- infrastructure/src/bin/bat_official_sync.rs | 169 +++--- infrastructure/src/import.rs | 263 ++++++++- infrastructure/src/lib.rs | 17 +- infrastructure/src/official_download.rs | 53 ++ infrastructure/src/official_update.rs | 502 +++++++++++++++++- infrastructure/src/resources.rs | 4 + .../hash_mismatch_catalog.json | 7 + .../official_regression/http_403.json | 7 + .../official_regression/http_404.json | 7 + .../tests/golden/synthetic_phase2_import.json | 5 + .../official_game_main_config_bootstrap.rs | 43 +- .../tests/synthetic_phase2_import.rs | 15 +- 30 files changed, 1269 insertions(+), 157 deletions(-) create mode 100644 adapters/tests/addressables_regression.rs create mode 100644 adapters/tests/fixtures/addressables_regression/current_catalog.json create mode 100644 adapters/tests/fixtures/addressables_regression/previous_catalog.json create mode 100644 adapters/tests/fixtures/addressables_regression/structure_changed_catalog.json create mode 100644 infrastructure/tests/fixtures/official_regression/hash_mismatch_catalog.json create mode 100644 infrastructure/tests/fixtures/official_regression/http_403.json create mode 100644 infrastructure/tests/fixtures/official_regression/http_404.json diff --git a/CHANGELOG.md b/CHANGELOG.md index ccdec98..e1198da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,9 @@ - 新增真实官方网络全量拉取 smoke:`scripts/official-full-pull-smoke.sh`、`make official-smoke` 和 `docs/guides/official-full-pull-smoke.md` - 新增官方资源下载校验:官方 URL 拒绝、`.part` 续传、重试、本地 size+BLAKE3、官方 seed `.hash` 校验 - 新增下载失败分类与 quarantine:403/404/普通 4xx 不重试,5xx/429/网络类错误重试,最终失败写入 `official-download-quarantine.json` 并阻止发布不完整资源;旧 launcher 包支持官方 primary/backup CDN 切换 +- 新增官方版本状态管理:`official-version-state.json` 持久化当前已完成版本、正在拉取版本、上一个可用版本和失败版本,并在 `bat status` 中暴露状态摘要 +- 优化资源导入链路:CAS 导入会写入 `ResourceRepository`,AssetBundle 记录 UnityFS 摘要,TextAsset/Table/Media 按资源类型分类并可被索引 +- 新增离线回归 fixture:当前 catalog、上一个版本 catalog、catalog 结构变化、403、404 和官方 seed hash mismatch 样本 - 新增 Addressables 当前真实形态 fixture/golden 测试 - 新增 SQLite Resource Repository 和粗粒度 FFI JSON 接口 - 补齐官方资源运行、架构、状态、缺口和交接文档 diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md index 002396d..70c7c8c 100644 --- a/CURRENT_STATUS.md +++ b/CURRENT_STATUS.md @@ -1,6 +1,6 @@ # BlueArchiveToolkit 当前工作区状态 -- **更新时间**:2026-07-14 +- **更新时间**:2026-07-15 - **状态来源**:本地工作区盘点、代码验证和最新提交 - **状态分支**:`experiment` - **最新已推送功能提交**:以当前 `git log --oneline -1` 为准 @@ -24,7 +24,8 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口: 8. 远端 snapshot 未变化但输出目录为空时,会按首次运行执行全量拉取;官方 seed `.hash` 校验失败时会清理对应 manifest 条目,避免失败产物被后续本地 audit 误判为可复用。 9. 默认资源目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`;资源目录是发布根目录,包含 `current` symlink、`versions/` 和 `.staging/`,非 dry-run 会先写 staging,校验完成后发布 versioned 目录并原子切换 `current`;后台状态目录包含 `bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log`、`bat-events.jsonl` 和短生命周期 `bat-control.lock`;非 dry-run 使用 `--output/.official-sync.lock` 防止并发写同一资源目录,live daemon 会阻止前台写命令直接修改它正在管理的同一目录。 10. 官方同步会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。 -11. `bat status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、结构化日志路径和轮转日志路径。 +11. `/official-version-state.json` 会明确保存当前已完成版本、正在拉取版本、上一个可用版本和失败版本;`bat status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、版本状态摘要、结构化日志路径和轮转日志路径。 +12. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,AssetBundle 导入会记录 UnityFS 摘要,TextAsset/Table/Media 会按类型分类;当前/上一个/结构变化 catalog、403/404、hash mismatch 均有离线回归 fixture。 仍需明确:这不是完整产品完成。Go CLI 最小入口、完整 AssetBundle 解析、Patch、翻译系统、API Server 和 Web 仍是后续工作;真实官方网络全量拉取 smoke 已固化为可重复脚本和 runbook,真实大文件产物与运行报告默认保存在 `/tmp` 隔离目录,不纳入 Git。 @@ -88,12 +89,13 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口: - Manifest driver trait、Addressables driver、注册表。 - Addressables JSON catalog 的 path、hash、size、address、dependencies、metadata 解析。 - 真实形态 Addressables fixture/golden 测试。 +- 当前 catalog、上一个版本 catalog、结构变化 catalog 的离线回归 fixture。 - 官方日服 `server-info`、URL 规则、平台 discovery 和 inventory 枚举。 待完成: -- Unity bundle parse/serialize 仍是后续阶段能力。 -- Addressables parser 仍需继续覆盖更多官方 catalog 结构变体和失败诊断。 +- Unity bundle serialize 仍是后续阶段能力。 +- Addressables parser 仍需继续覆盖二进制/压缩字段组合和更细失败诊断。 - 客户端发现、备份、应用补丁流程尚未连接真实实现。 ### `bat-cas-engine` diff --git a/README.md b/README.md index 4cd74e4..831c90e 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,10 @@ - `bat-core` 领域对象和仓储接口骨架。 - `bat-adapters` Unity、Manifest、Client 集成框架,以及当前真实形态 Addressables catalog 解析覆盖。 - `bat-cas-engine` CAS V1:原子写入、BLAKE3 校验、引用计数、GC、并发写入测试、损坏检测。 -- `bat-infrastructure` CAS 适配层、SQLite Resource Repository、官方资源 pull/update 服务。 +- `bat-infrastructure` CAS 适配层、SQLite Resource Repository、资源导入服务、官方资源 pull/update 服务。 - `bat`:官方资源自动发现、全量拉取、原子发布到 `current -> versions/`、本地 manifest audit/repair、`.part` 断点续传、403/404/5xx 分类重试、下载 quarantine 诊断、ZIP 结构校验、官方 seed `.hash` 校验、snapshot/cache、`--watch` 常驻更新、`--daemon` 后台运行,以及 Unix socket JSON-RPC 后台控制命令 `status/stop/restart/reload/refresh/logs/verify/repair/doctor/clean-stable`。 +- 官方同步会维护 `/official-version-state.json`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。 +- 资源导入链路可将 manifest 条目写入 CAS + `ResourceRepository`,AssetBundle 会记录 UnityFS 摘要,TextAsset/Table/Media 会按类型分类索引。 - `bat-ffi` 粗粒度 JSON 接口:Manifest inspect 和官方 sync plan。 - 文档路线图、当前状态、缺口清单、官方资源运行指南。 @@ -96,7 +98,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`。 +非 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`。 资源操作命令默认输出人类可读摘要,并在没有显式 metadata 参数时默认走官方自动发现。脚本或上层程序需要稳定结构化输出时加 `--json`: diff --git a/adapters/src/manifest/addressables.rs b/adapters/src/manifest/addressables.rs index 0c6e192..8596f6e 100644 --- a/adapters/src/manifest/addressables.rs +++ b/adapters/src/manifest/addressables.rs @@ -187,12 +187,32 @@ impl AddressablesCatalogDriver { } fn resource_type_for_path(path: &str) -> ResourceType { - if path.contains("TableBundles/") || path.ends_with(".bytes") { - ResourceType::TableBundle - } else if path.ends_with(".bundle") { - ResourceType::AssetBundle - } else if path.contains("catalog") { + let normalized = path.replace('\\', "/").to_ascii_lowercase(); + if normalized.contains("catalog") || normalized.ends_with(".hash") { ResourceType::Manifest + } else if normalized.contains("tablebundles/") { + ResourceType::TableBundle + } else if normalized.contains("mediaresources/") + || normalized.contains("mediaresources-") + || matches!( + normalized.rsplit('.').next(), + Some( + "mp3" | "mp4" | "ogg" | "wav" | "png" | "jpg" | "jpeg" | "webp" | "acb" | "awb" + ) + ) + { + ResourceType::Media + } else if normalized.ends_with(".bundle") { + ResourceType::AssetBundle + } else if normalized.contains("textassets/") + || matches!( + normalized.rsplit('.').next(), + Some("txt" | "csv" | "xml" | "yaml" | "yml") + ) + { + ResourceType::TextAsset + } else if normalized.ends_with(".bytes") { + ResourceType::TableBundle } else { ResourceType::Other } @@ -210,6 +230,16 @@ impl AddressablesCatalogDriver { return ResourceType::Manifest; } + if resource_type_name.is_some_and(|name| name.contains("TextAsset")) { + return ResourceType::TextAsset; + } + + if resource_type_name.is_some_and(|name| { + name.contains("AudioClip") || name.contains("VideoClip") || name.contains("Texture2D") + }) { + return ResourceType::Media; + } + Self::resource_type_for_path(path) } @@ -1017,4 +1047,24 @@ mod tests { ResourceType::TableBundle ); } + + #[tokio::test] + async fn test_parse_text_and_media_resource_types() { + let driver = AddressablesCatalogDriver::new(); + + let catalog_json = r#"{ + "m_LocatorId": "AddressablesMainContentCatalog", + "m_Entries": [ + {"internal_id": "TextAssets/dialogue.csv"}, + {"internal_id": "MediaResources-Windows/voice/academy.acb"}, + {"internal_id": "MediaResources-Windows/movie/opening.mp4"} + ] + }"#; + + let manifest = driver.parse(catalog_json.as_bytes()).await.unwrap(); + + assert_eq!(manifest.resources[0].resource_type, ResourceType::TextAsset); + assert_eq!(manifest.resources[1].resource_type, ResourceType::Media); + assert_eq!(manifest.resources[2].resource_type, ResourceType::Media); + } } diff --git a/adapters/src/official/game_main_config.rs b/adapters/src/official/game_main_config.rs index 571043a..39f914e 100644 --- a/adapters/src/official/game_main_config.rs +++ b/adapters/src/official/game_main_config.rs @@ -114,7 +114,7 @@ fn parse_obfuscated_game_main_config(bytes: &[u8]) -> Result Result { - if bytes.len() % 2 != 0 { + if !bytes.len().is_multiple_of(2) { return Err("Obfuscated GameMainConfig byte length is not UTF-16LE aligned".to_string()); } @@ -288,7 +288,7 @@ fn xor_bytes(bytes: &mut [u8], key: &[u8]) { } fn utf16le_to_string(bytes: &[u8]) -> Result { - if bytes.len() % 2 != 0 { + if !bytes.len().is_multiple_of(2) { return Err("GameMainConfig decrypted byte length is not UTF-16LE aligned".into()); } @@ -454,7 +454,7 @@ mod tests { fn encrypt_utf16le_xor(value: &str, key: &[u8]) -> Vec { let mut utf16 = utf16le_bytes(value); - xor_bytes(&mut utf16, &key); + xor_bytes(&mut utf16, key); utf16 } diff --git a/adapters/src/official/yostar_jp.rs b/adapters/src/official/yostar_jp.rs index 1f5011e..4acef9d 100644 --- a/adapters/src/official/yostar_jp.rs +++ b/adapters/src/official/yostar_jp.rs @@ -965,8 +965,8 @@ mod tests { }; let delta = current.diff(Some(&previous)); - assert!(delta.connection_group_changed == false); - assert!(delta.app_version_changed == false); + assert!(!delta.connection_group_changed); + assert!(!delta.app_version_changed); assert!(delta.bundle_version_changed); assert!(delta.addressables_root_changed); assert!(delta.root_token_changed); diff --git a/adapters/src/unity/serialized_file.rs b/adapters/src/unity/serialized_file.rs index 4546163..4e0f07d 100644 --- a/adapters/src/unity/serialized_file.rs +++ b/adapters/src/unity/serialized_file.rs @@ -78,7 +78,7 @@ impl UnitySerializedFile { )?); } - let big_id_enabled = if version >= 11 && version < 14 { + let big_id_enabled = if (11..14).contains(&version) { reader.read_i32("big_id_enabled")? } else { 0 diff --git a/adapters/tests/addressables_regression.rs b/adapters/tests/addressables_regression.rs new file mode 100644 index 0000000..cee2286 --- /dev/null +++ b/adapters/tests/addressables_regression.rs @@ -0,0 +1,68 @@ +use bat_adapters::manifest::ManifestDriverRegistry; +use bat_core::domain::ResourceType; + +#[tokio::test] +async fn parses_current_catalog_fixture_with_resource_categories() { + let manifest = ManifestDriverRegistry::with_defaults() + .parse(include_bytes!( + "fixtures/addressables_regression/current_catalog.json" + )) + .await + .unwrap(); + + assert_eq!(manifest.resources.len(), 4); + assert_eq!( + manifest.resources[0].resource_type, + ResourceType::TableBundle + ); + assert_eq!(manifest.resources[1].resource_type, ResourceType::Media); + assert_eq!(manifest.resources[2].resource_type, ResourceType::TextAsset); + assert_eq!( + manifest.resources[3].dependencies, + vec!["shared_dependencies.bundle".to_string()] + ); + assert_eq!( + manifest.metadata.cdn_prefixes, + vec!["https://fixture.invalid/current/".to_string()] + ); +} + +#[tokio::test] +async fn parses_previous_catalog_fixture_using_internal_ids() { + let manifest = ManifestDriverRegistry::with_defaults() + .parse(include_bytes!( + "fixtures/addressables_regression/previous_catalog.json" + )) + .await + .unwrap(); + + assert_eq!(manifest.resources.len(), 3); + assert_eq!( + manifest.resources[0].resource_type, + ResourceType::AssetBundle + ); + assert_eq!(manifest.resources[1].resource_type, ResourceType::Manifest); + assert_eq!( + manifest.resources[2].resource_type, + ResourceType::TableBundle + ); +} + +#[tokio::test] +async fn parses_catalog_structure_change_with_alias_fields() { + let manifest = ManifestDriverRegistry::with_defaults() + .parse(include_bytes!( + "fixtures/addressables_regression/structure_changed_catalog.json" + )) + .await + .unwrap(); + + assert_eq!(manifest.resources.len(), 2); + assert_eq!(manifest.resources[0].resource_type, ResourceType::Media); + assert_eq!( + manifest.resources[0].dependencies, + vec!["shared_assets_current.bundle".to_string()] + ); + assert_eq!(manifest.resources[1].resource_type, ResourceType::TextAsset); + assert_eq!(manifest.resources[1].address.as_deref(), Some("lesson")); +} diff --git a/adapters/tests/fixtures/addressables_regression/current_catalog.json b/adapters/tests/fixtures/addressables_regression/current_catalog.json new file mode 100644 index 0000000..d8898c2 --- /dev/null +++ b/adapters/tests/fixtures/addressables_regression/current_catalog.json @@ -0,0 +1,38 @@ +{ + "m_LocatorId": "AddressablesMainContentCatalog", + "m_InternalIdPrefixes": [ + "https://fixture.invalid/current/" + ], + "m_Entries": [ + { + "internal_id": "TableBundles/ExcelDB.db", + "hash": "current-table-hash", + "size": 4096, + "address": "ExcelDB", + "dependencies": [] + }, + { + "internal_id": "MediaResources-Windows/voice/title.acb", + "hash": "current-media-hash", + "size": 2048, + "address": "title", + "dependencies": [] + }, + { + "internal_id": "TextAssets/dialogue.csv", + "hash": "current-text-hash", + "size": 128, + "address": "dialogue", + "dependencies": [] + }, + { + "internal_id": "shared_assets_current.bundle", + "hash": "current-bundle-hash", + "size": 8192, + "address": "shared_assets_current", + "dependencies": [ + "shared_dependencies.bundle" + ] + } + ] +} diff --git a/adapters/tests/fixtures/addressables_regression/previous_catalog.json b/adapters/tests/fixtures/addressables_regression/previous_catalog.json new file mode 100644 index 0000000..5e39426 --- /dev/null +++ b/adapters/tests/fixtures/addressables_regression/previous_catalog.json @@ -0,0 +1,9 @@ +{ + "m_LocatorId": "AddressablesMainContentCatalog", + "m_InternalIdPrefixes": [], + "m_InternalIds": [ + "shared_assets_previous.bundle", + "catalog_previous.json", + "TableBundles/ExcelDB.db" + ] +} diff --git a/adapters/tests/fixtures/addressables_regression/structure_changed_catalog.json b/adapters/tests/fixtures/addressables_regression/structure_changed_catalog.json new file mode 100644 index 0000000..f531e88 --- /dev/null +++ b/adapters/tests/fixtures/addressables_regression/structure_changed_catalog.json @@ -0,0 +1,24 @@ +{ + "m_LocatorId": "AddressablesMainContentCatalog", + "m_InternalIdPrefixes": [], + "m_InternalIds": [ + "fallback.bundle" + ], + "m_Entries": [ + { + "InternalId": "MediaResources-Android/voice/title.awb", + "Hash": "changed-media-hash", + "Size": 65536, + "Address": "title-android", + "m_Dependencies": [ + "shared_assets_current.bundle" + ] + }, + { + "Path": "TextAssets/lesson.json", + "Hash": "changed-text-hash", + "Size": 512, + "Key": "lesson" + } + ] +} diff --git a/core/src/domain/resource.rs b/core/src/domain/resource.rs index def7584..41ef93e 100644 --- a/core/src/domain/resource.rs +++ b/core/src/domain/resource.rs @@ -11,6 +11,10 @@ pub enum ResourceType { Manifest, /// TableBundle TableBundle, + /// TextAsset + TextAsset, + /// Media resource + Media, /// 其他 Other, } diff --git a/docs/architecture/official-resource-backend.md b/docs/architecture/official-resource-backend.md index f366870..8ae0917 100644 --- a/docs/architecture/official-resource-backend.md +++ b/docs/architecture/official-resource-backend.md @@ -238,6 +238,9 @@ Linux 生产路径: - `OfficialUpdateService` 能持久化 v2 snapshot,并在远端 marker 内容变化时触发下载决策 - `bat` 默认向 stderr 输出 `BlueArchiveToolkit` ASCII banner 和 progress log,stdout 默认输出人类可读摘要;progress log 覆盖总体下载进度、单文件开始/完成状态、下载中断失败分类和校验结果摘要;支持 `--json` 输出稳定 JSON,支持 `--no-progress` 关闭进度日志,支持 `--no-banner` 只关闭横幅,支持 `--watch --interval 1h --error-retry 60s` 常驻运行,支持 `--daemon` Unix socket JSON-RPC 控制、`status`、`stop`、`restart`、`reload`、`logs`、`refresh --force`、`verify`、`repair`、`doctor`、`clean-stable`,非 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` 已覆盖当前完成版本、正在拉取版本、上一个可用版本和失败版本,`bat status` 会暴露版本状态摘要 +- 资源导入链路已覆盖 CAS 写入、`ResourceRepository` 索引、AssetBundle UnityFS 摘要,以及 TextAsset/Table/Media 分类 +- 离线回归样本已覆盖当前 catalog、上一个版本 catalog、catalog 结构变化、403、404 和 seed hash mismatch - `OfficialUpdateService` 能读写 `official-bootstrap-cache.json`,并支持默认开启的 `audit_local` / `repair` CLI 行为 - 下载层能在本地文件 size/BLAKE3/path、ZIP 结构或 manifest 不匹配时重新下载 - 官方 seed `.hash` mismatch 会导致下载失败,而不是降级为本地 BLAKE3 猜测 diff --git a/docs/guides/official-resource-test-pull.md b/docs/guides/official-resource-test-pull.md index a985055..c70eb14 100644 --- a/docs/guides/official-resource-test-pull.md +++ b/docs/guides/official-resource-test-pull.md @@ -161,6 +161,7 @@ cargo run -p bat-infrastructure --example official_pull_plan -- \ - 旧 launcher 包或 `resources.assets` 下载路径使用官方 launcher CDN 配置,primary CDN 失败后会切换官方 backup CDN;资源 patch host 当前只使用 server-info 返回的官方 client-patch host,不猜测非官方镜像。 - 远端和本地都一致:单次模式输出 `update_status=up_to_date`,watch 模式默认静默并等待下次检查。 - 有远端变化或本地 repair:生成 pull plan,下载完整官方资源到 staging,成功后更新 snapshot 并原子发布到 `current`。 +- 非 dry-run 会维护 `/official-version-state.json`:开始下载后写入 `in_progress_version`,发布成功后写入 `current_completed_version` 和 `previous_available_version`,失败或中断后写入 `failed_versions`。 - `--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`。 @@ -171,6 +172,7 @@ cargo run -p bat-infrastructure --example official_pull_plan -- \ - `/current/official-sync-snapshot.json`:上一次成功同步的 v2 snapshot,包含 app version、connection group、bundle version、addressables root、endpoint URL、官方 seed `.hash` 内容、Addressables `catalog_*.hash` marker、launcher metadata 摘要和 `GameMainConfig` 摘要。 - `/official-bootstrap-cache.json`:`--auto-discover` 的 `GameMainConfig` 解析缓存。launcher metadata 未变时复用缓存;metadata 变化时才通过官方 HTTP 按 manifest 下载必要 `resources.assets` 或旧版 game zip 到临时目录解析。 +- `/official-version-state.json`:资源发布根目录的持久版本状态,包含当前已完成版本、正在拉取版本、上一个可用版本和失败版本。 - `/current/official-download-manifest.json`:本地下载强校验清单,记录 URL、相对路径、size 和 BLAKE3。 - `/current/official-download-quarantine.json` 或当前 staging 下同名文件:下载最终失败的 URL 诊断记录,包含失败类型、HTTP 状态、是否可重试、尝试次数和最后错误。 diff --git a/docs/reports/CURRENT_GAPS.md b/docs/reports/CURRENT_GAPS.md index 0f17299..74c3007 100644 --- a/docs/reports/CURRENT_GAPS.md +++ b/docs/reports/CURRENT_GAPS.md @@ -228,7 +228,8 @@ 影响: - `SqliteResourceRepository` 已存在,可按领域 repository 接口保存资源元数据。 -- 官方同步下载结果尚未作为用户级流程自动写入 CAS + ResourceRepository。 +- `ResourceImportService` 已能把 manifest 中有数据的资源写入 CAS + `ResourceRepository`,AssetBundle 会记录 UnityFS 摘要,TextAsset/Table/Media 会分类索引。 +- 官方同步下载结果尚未作为用户级流程自动触发导入 CAS + ResourceRepository。 - 迁移、版本化 schema 和 CLI 查询入口仍需补齐。 验收: @@ -237,6 +238,75 @@ - 可按版本、类型、hash、路径查询资源。 - 官方同步后的资源可通过 CLI 查询并能追溯到 CAS 对象。 +### G-011A:资源导入链路基础能力不足 + +状态:**已关闭** + +历史现象: + +- 资源导入链路只导入 AssetBundle。 +- 非 AssetBundle manifest 条目只会被跳过。 +- 导入报告只包含 UnityFS 基础摘要,不包含稳定分类统计。 + +处理结果: + +- `ResourceImportService` 会把有数据的 manifest 条目导入 CAS 并写入 `ResourceRepository`。 +- AssetBundle 仍执行 UnityFS header/block/directory 摘要解析。 +- TextAsset、TableBundle、Media 会按资源类型分类,缺少数据时记录为 skipped,便于渐进导入。 +- `ResourceImportReport` 增加 `category_counts`,`ImportedResource` 增加 `resource_type`、`category` 和可选 UnityFS 摘要。 + +验收: + +- `cargo test -p bat-infrastructure import::tests::` +- `cargo test -p bat-infrastructure --test synthetic_phase2_import` + +### G-011B:官方版本状态管理不明确 + +状态:**已关闭** + +历史现象: + +- 当前可用版本主要靠 `current` symlink 和 release 内 snapshot 推断。 +- 未显式保存“正在拉取版本”和“失败版本”。 +- 上一个可用版本需要从目录状态间接判断。 + +处理结果: + +- 新增 `/official-version-state.json`。 +- 开始下载后写入 `in_progress_version`。 +- 发布成功后写入 `current_completed_version` 和 `previous_available_version`。 +- 失败或中断后写入 `failed_versions` 并清空 in-progress。 +- `bat status` 会读取并展示版本状态摘要。 + +验收: + +- `cargo test -p bat-infrastructure version_state` +- `cargo test -p bat-infrastructure --test official_game_main_config_bootstrap` + +### G-011C:真实 fixture 与回归样本不足 + +状态:**已关闭当前阶段** + +历史现象: + +- 已有 Addressables real-shape fixture/golden,但缺少按问题类型命名的当前/上一版本/结构变化样本。 +- 403/404 和 hash mismatch 主要依赖单测内联构造,不便于后续回归扩展。 + +处理结果: + +- 新增 `adapters/tests/fixtures/addressables_regression/current_catalog.json`。 +- 新增 `adapters/tests/fixtures/addressables_regression/previous_catalog.json`。 +- 新增 `adapters/tests/fixtures/addressables_regression/structure_changed_catalog.json`。 +- 新增 `infrastructure/tests/fixtures/official_regression/http_403.json`。 +- 新增 `infrastructure/tests/fixtures/official_regression/http_404.json`。 +- 新增 `infrastructure/tests/fixtures/official_regression/hash_mismatch_catalog.json`。 +- 对应测试会解析这些 fixture,防止样本只存在但不参与验证。 + +验收: + +- `cargo test -p bat-adapters --test addressables_regression` +- `cargo test -p bat-infrastructure regression_fixture` + ### G-012:Translation Memory 未实现 影响: diff --git a/docs/reports/current-stage-prepush.md b/docs/reports/current-stage-prepush.md index 1d77e81..d58076b 100644 --- a/docs/reports/current-stage-prepush.md +++ b/docs/reports/current-stage-prepush.md @@ -37,8 +37,8 @@ 仍未完成: - Go CLI 最小可用入口。 -- 官方同步下载结果自动导入 CAS + ResourceRepository 的用户级流程。 -- 完整 AssetBundle 解析。 +- 官方同步下载结果自动触发 CAS + ResourceRepository 导入的用户级流程。 +- AssetBundle 对象表、TypeTree 和 TextAsset 提取。 - Patch、翻译系统、API Server、Web。 ## 3. 当前核查结果 @@ -64,11 +64,11 @@ git status --short ## 4. 当前结论 -当前 Rust 官方资源同步链路已经具备可运行闭环,并已固化真实网络 smoke 的可重复命令,但仓库还不是完整产品。下一步不应继续只扩展 example,而应补齐 Go CLI 最小入口、CAS/ResourceRepository 编排和 AssetBundle 解析。 +当前 Rust 官方资源同步链路已经具备可运行闭环,并已固化真实网络 smoke 的可重复命令;版本状态、资源导入基础链路和离线回归 fixture 已补齐。但仓库还不是完整产品。下一步不应继续只扩展 example,而应补齐 Go CLI 最小入口、官方同步后自动导入编排和 AssetBundle 对象级解析。 ## 5. 下一步 1. 实现 Go CLI:`bat doctor`、`bat sync --help`、`bat official sync --help`。 2. 按 `docs/guides/official-full-pull-smoke.md` 在具备网络和磁盘窗口的环境中执行真实官方网络 smoke,输出目录必须隔离。 -3. 将官方同步下载结果接入 CAS + `SqliteResourceRepository`。 -4. 开始 AssetBundle UnityFS header/block/directory 解析。 +3. 将官方同步下载结果自动接入 CAS + `SqliteResourceRepository` 的用户级命令。 +4. 开始 AssetBundle 对象表、TypeTree 和 TextAsset 提取。 diff --git a/docs/reports/current-status-handoff.md b/docs/reports/current-status-handoff.md index e2e1b11..90b628e 100644 --- a/docs/reports/current-status-handoff.md +++ b/docs/reports/current-status-handoff.md @@ -1,6 +1,6 @@ # 当前进度交接 -- **更新时间**:2026-07-14 +- **更新时间**:2026-07-15 - **状态分支**:`experiment` - **最新功能提交**:以当前 `git log --oneline -1` 为准 - **用途**:给下一次对话快速恢复上下文,优先以当前工作区和已验证结果为准。 @@ -23,6 +23,9 @@ - `--watch` 是 Rust 内部常驻检查模式,正常检查默认 1 小时,失败重试默认 60 秒;外部 systemd/container 只负责守护进程。 - `bat` progress log 会输出总体下载进度、单文件进度和校验结果摘要。 - 真实官方网络 smoke 已固化为 `scripts/official-full-pull-smoke.sh`、`make official-smoke` 和 `docs/guides/official-full-pull-smoke.md`,默认写入 `/tmp` 隔离目录。 +- `official-version-state.json` 已持久化当前完成、正在拉取、上一个可用和失败版本,`bat status` 会显示版本状态摘要。 +- 资源导入链路已支持 CAS + `ResourceRepository` 写入,AssetBundle UnityFS 摘要,以及 TextAsset/Table/Media 分类索引。 +- 当前 catalog、上一个版本 catalog、结构变化 catalog、403、404、hash mismatch 已有离线回归 fixture。 ## 2. 现在不要误解的点 @@ -39,9 +42,9 @@ 1. Go CLI 最小入口:`bat doctor`、`bat sync --help`、`bat official sync --help`。 2. 按 smoke runbook 在具备网络和磁盘窗口的环境中执行真实官方网络 smoke,并保留隔离目录报告。 -3. 官方同步下载结果进入 CAS + `SqliteResourceRepository` 的用户级流程。 -4. Addressables parser 覆盖更多真实 catalog 结构。 -5. AssetBundle UnityFS header/block/directory 解析。 +3. 官方同步下载结果自动触发 CAS + `SqliteResourceRepository` 的用户级导入流程。 +4. Addressables parser 覆盖二进制/压缩字段组合和更细失败诊断。 +5. AssetBundle 对象表、TypeTree 和 TextAsset 提取链路继续推进。 6. Patch、翻译库、API Server、Web。 ## 4. 下一次对话最合适的起点 diff --git a/infrastructure/examples/official_update_check.rs b/infrastructure/examples/official_update_check.rs index 35974b9..df04862 100644 --- a/infrastructure/examples/official_update_check.rs +++ b/infrastructure/examples/official_update_check.rs @@ -379,7 +379,7 @@ fn main() -> anyhow::Result<()> { let pull_plan = pull_plan .as_ref() .ok_or_else(|| anyhow::anyhow!("download requested but no pull plan exists"))?; - let report = fetcher.pull(&pull_plan).map_err(anyhow::Error::msg)?; + let report = fetcher.pull(pull_plan).map_err(anyhow::Error::msg)?; let final_audit = fetcher .audit_local_manifest(pull_plan) .map_err(anyhow::Error::msg)?; diff --git a/infrastructure/src/bin/bat_official_sync.rs b/infrastructure/src/bin/bat_official_sync.rs index cee6763..a9b09ba 100644 --- a/infrastructure/src/bin/bat_official_sync.rs +++ b/infrastructure/src/bin/bat_official_sync.rs @@ -1,9 +1,9 @@ use bat_adapters::official::yostar_jp::PatchPlatform; use bat_infrastructure::{ - lexical_absolute, open_append_file, read_file_no_symlink, validate_output_root, - validate_runtime_state_dir, write_file_atomic, OfficialServerInfoSource, OfficialUpdateConfig, - OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateStatus, - OfficialVerificationSummary, PRIVATE_FILE_MODE, + lexical_absolute, open_append_file, read_file_no_symlink, read_version_state, + validate_output_root, validate_runtime_state_dir, write_file_atomic, OfficialServerInfoSource, + OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, + OfficialUpdateStatus, OfficialVerificationSummary, OfficialVersionState, PRIVATE_FILE_MODE, }; use serde::{Deserialize, Serialize}; use std::env; @@ -608,17 +608,7 @@ fn record_daemon_status( return; } - if let Err(error) = update_daemon_status( - state_dir, - update.state, - update.last_update_status, - update.last_error, - update.next_retry_seconds, - update.last_success_unix_seconds, - update.next_check_unix_seconds, - update.pending_scheduled_force, - update.next_forced_refresh_at, - ) { + if let Err(error) = update_daemon_status(state_dir, update) { logger.log_text("daemon", format!("更新后台状态失败:{error}")); } } @@ -731,10 +721,7 @@ fn classify_pid_lock_file(path: &Path) -> anyhow::Result { if metadata.file_type().is_symlink() || !metadata.is_file() { return Ok(PidLockState::Corrupt); } - let contents = match fs::read_to_string(path) { - Ok(contents) => contents, - Err(error) => return Err(error.into()), - }; + let contents = fs::read_to_string(path)?; let Some(pid) = parse_pid_value(&contents) else { return Ok(PidLockState::Corrupt); }; @@ -1322,6 +1309,8 @@ struct DaemonStatusReport { current_stage: Option, current_message: Option, download_progress: Option, + version_state_path: Option, + version_state: Option, pending_scheduled_force: Option, next_forced_refresh_unix_seconds: Option, command: Option>, @@ -1600,6 +1589,14 @@ fn build_daemon_status_report(state_dir: &Path) -> anyhow::Result anyhow::Result anyhow::Result<()> { print_json_field(value, "current_stage", "当前阶段"); print_json_field(value, "current_message", "当前消息"); print_json_field(value, "download_progress", "下载进度"); + print_json_field(value, "version_state_path", "版本状态路径"); + print_json_field(value, "version_state", "版本状态"); print_json_field(value, "resource_output_root", "资源目录"); print_json_field(value, "state_dir", "状态目录"); print_json_field(value, "socket_path", "socket"); @@ -2102,6 +2103,7 @@ impl HumanReport for OfficialUpdateReport { print_path_field("输出根目录", &self.output_root); print_path_field("active release", &self.active_resource_root); print_path_field("current", &self.current_path); + print_path_field("version state", &self.version_state_path); print_optional_path_field("staging", self.staging_path.as_ref()); print_optional_path_field("published", self.published_version_path.as_ref()); print_path_field("snapshot", &self.snapshot_path); @@ -2174,7 +2176,32 @@ impl HumanReport for DaemonStatusReport { if let Some(progress) = self.download_progress.as_ref() { print_field("下载进度", format_daemon_download_progress(progress)); } + if let Some(version_state) = self.version_state.as_ref() { + print_optional_field( + "当前完成版本", + version_state + .current_completed_version + .as_ref() + .map(|version| version.id.as_str()), + ); + print_optional_field( + "正在拉取版本", + version_state + .in_progress_version + .as_ref() + .map(|version| version.id.as_str()), + ); + print_optional_field( + "上一个可用版本", + version_state + .previous_available_version + .as_ref() + .map(|version| version.id.as_str()), + ); + print_field("失败版本数", version_state.failed_versions.len()); + } print_optional_path_field("资源目录", self.resource_output_root.as_ref()); + print_optional_path_field("版本状态", self.version_state_path.as_ref()); print_path_field("状态目录", &self.state_dir); print_path_field("socket", &self.socket_path); print_optional_path_field("日志", self.log_path.as_ref()); @@ -2499,29 +2526,26 @@ struct DoctorReport { } fn run_doctor_command(options: &CliOptions) -> anyhow::Result { - let mut checks = Vec::new(); - checks.push(path_check( - "state_dir", - &options.state_dir, - "后台状态目录可用", - )); - checks.push(path_check( - "output_root", - &options.config.output_root, - "资源输出目录可用", - )); - checks.push(safety_check( - "output_root_safety", - validate_output_root(&options.config.output_root), - "资源输出目录安全边界通过", - )); - checks.push(safety_check( - "state_dir_safety", - validate_runtime_state_dir(&options.state_dir), - "后台状态目录安全边界通过", - )); - checks.push(command_check("curl", &options.config.curl_command)); - checks.push(command_check("unzip", &options.config.unzip_command)); + let mut checks = vec![ + path_check("state_dir", &options.state_dir, "后台状态目录可用"), + path_check( + "output_root", + &options.config.output_root, + "资源输出目录可用", + ), + safety_check( + "output_root_safety", + validate_output_root(&options.config.output_root), + "资源输出目录安全边界通过", + ), + safety_check( + "state_dir_safety", + validate_runtime_state_dir(&options.state_dir), + "后台状态目录安全边界通过", + ), + command_check("curl", &options.config.curl_command), + command_check("unzip", &options.config.unzip_command), + ]; let pid_path = daemon_pid_path(&options.state_dir); let daemon_running = read_pid_file(&pid_path) @@ -2864,17 +2888,7 @@ fn write_daemon_status_file(path: &Path, status: &DaemonStatusFile) -> anyhow::R Ok(()) } -fn update_daemon_status( - state_dir: &Path, - state: &str, - last_update_status: Option, - last_error: Option, - next_retry_seconds: Option, - last_success_unix_seconds: Option, - next_check_unix_seconds: Option, - pending_scheduled_force: bool, - next_forced_refresh_at: SystemTime, -) -> anyhow::Result<()> { +fn update_daemon_status(state_dir: &Path, update: DaemonStatusUpdate<'_>) -> anyhow::Result<()> { let status_path = daemon_status_path(state_dir); let mut status = read_daemon_status_file(&status_path)?.unwrap_or_else(|| DaemonStatusFile { version: DAEMON_STATUS_VERSION, @@ -2899,22 +2913,23 @@ fn update_daemon_status( command: env::args().collect(), }); status.pid = std::process::id(); - status.state = state.to_string(); + status.state = update.state.to_string(); status.updated_unix_seconds = unix_seconds_now(); - status.last_update_status = last_update_status; - status.last_error = last_error; - status.next_retry_seconds = next_retry_seconds; - if last_success_unix_seconds.is_some() { - status.last_success_unix_seconds = last_success_unix_seconds; + status.last_update_status = update.last_update_status; + status.last_error = update.last_error; + status.next_retry_seconds = update.next_retry_seconds; + if update.last_success_unix_seconds.is_some() { + status.last_success_unix_seconds = update.last_success_unix_seconds; } - status.next_check_unix_seconds = next_check_unix_seconds; - if state != "running" { + status.next_check_unix_seconds = update.next_check_unix_seconds; + if update.state != "running" { status.current_stage = None; status.current_message = None; status.download_progress = None; } - status.pending_scheduled_force = pending_scheduled_force; - status.next_forced_refresh_unix_seconds = system_time_to_unix_seconds(next_forced_refresh_at); + status.pending_scheduled_force = update.pending_scheduled_force; + status.next_forced_refresh_unix_seconds = + system_time_to_unix_seconds(update.next_forced_refresh_at); write_daemon_status_file(&status_path, &status) } @@ -3130,11 +3145,11 @@ fn daemon_child_args(options: &CliOptions) -> Vec { fn format_duration_arg(duration: Duration) -> String { let millis = duration.as_millis(); - if millis % 3_600_000 == 0 { + if millis.is_multiple_of(3_600_000) { format!("{}h", millis / 3_600_000) - } else if millis % 60_000 == 0 { + } else if millis.is_multiple_of(60_000) { format!("{}m", millis / 60_000) - } else if millis % 1_000 == 0 { + } else if millis.is_multiple_of(1_000) { format!("{}s", millis / 1_000) } else { format!("{millis}ms") @@ -3825,7 +3840,7 @@ fn parse_args_from(raw_args: impl IntoIterator) -> anyhow::Result "verify 只验证本地资源,不能和 --force 同时使用" )); } - if options.config.repair == false && options.config.audit_local == false { + if !options.config.repair && !options.config.audit_local { return Err(anyhow::anyhow!( "verify 必须启用本地审计;不要同时传 --no-repair --no-audit-local" )); @@ -3854,7 +3869,7 @@ fn ensure_command_not_set(command: CliCommand, next: &str) -> anyhow::Result<()> } fn tools_are_non_default(config: &OfficialUpdateConfig) -> bool { - config.curl_command != PathBuf::from("curl") || config.unzip_command != PathBuf::from("unzip") + config.curl_command != Path::new("curl") || config.unzip_command != Path::new("unzip") } fn next_option_value( @@ -4503,14 +4518,16 @@ mod tests { let next_check = unix_seconds_after(Duration::from_secs(30)); update_daemon_status( &state_dir, - "sleeping", - Some("downloaded".to_string()), - None, - Some(30), - Some(123456), - Some(next_check), - false, - SystemTime::now() + Duration::from_secs(3600), + DaemonStatusUpdate { + state: "sleeping", + last_update_status: Some("downloaded".to_string()), + last_error: None, + next_retry_seconds: Some(30), + last_success_unix_seconds: Some(123456), + next_check_unix_seconds: Some(next_check), + pending_scheduled_force: false, + next_forced_refresh_at: SystemTime::now() + Duration::from_secs(3600), + }, ) .unwrap(); let report = build_daemon_status_report(&state_dir).unwrap(); diff --git a/infrastructure/src/import.rs b/infrastructure/src/import.rs index c774ab0..4d36673 100644 --- a/infrastructure/src/import.rs +++ b/infrastructure/src/import.rs @@ -4,6 +4,7 @@ use bat_adapters::manifest::GenericManifest; use bat_adapters::unity::{RawAssetBundle, UnityAdapterRegistry}; use bat_core::domain::{Resource, ResourceType}; use bat_core::repositories::{CasRepository, ResourceRepository}; +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; /// 待导入的 AssetBundle 数据。 @@ -32,12 +33,47 @@ pub struct ImportedResource { pub id: String, /// Manifest 中的资源路径。 pub source_path: String, + /// Manifest 或路径推断出的资源类型。 + pub resource_type: ResourceType, + /// 导入链路使用的稳定分类标签。 + pub category: ResourceImportCategory, /// CAS 返回的对象 ID。 pub object_id: String, /// 写入 CAS 的字节数。 pub bytes: u64, - /// UnityFS 解析摘要。 - pub unityfs: UnityFsImportSummary, + /// UnityFS 解析摘要,仅 AssetBundle 资源会填充。 + pub unityfs: Option, +} + +/// Stable resource category produced by the import pipeline. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum ResourceImportCategory { + /// UnityFS AssetBundle resource. + AssetBundle, + /// Unity TextAsset or text-like payload. + TextAsset, + /// Table bundle/database resource. + Table, + /// Media payload such as audio, video, texture, or media archive. + Media, + /// Manifest/catalog sidecar. + Manifest, + /// Resource that does not match a known category yet. + Other, +} + +impl ResourceImportCategory { + /// Returns a stable lowercase label for reports and golden tests. + pub fn as_str(self) -> &'static str { + match self { + Self::AssetBundle => "asset_bundle", + Self::TextAsset => "text_asset", + Self::Table => "table", + Self::Media => "media", + Self::Manifest => "manifest", + Self::Other => "other", + } + } } /// 导入时解析到的 UnityFS 基础结构摘要。 @@ -58,8 +94,10 @@ pub struct UnityFsImportSummary { pub struct ResourceImportReport { /// 成功导入的资源。 pub imported: Vec, - /// 被跳过的非 AssetBundle 资源路径。 + /// 被跳过的资源路径。 pub skipped: Vec, + /// 按稳定分类标签统计的成功导入数量。 + pub category_counts: BTreeMap, } /// 将解析后的资源清单写入 CAS 与资源仓储的应用服务。 @@ -88,10 +126,12 @@ impl<'a> ResourceImportService<'a> { } } - /// 导入 manifest 中的 AssetBundle 资源。 + /// 导入 manifest 中可用的资源数据。 /// - /// 非 AssetBundle 条目会记录到 `skipped`。AssetBundle 条目必须能在 - /// `bundles` 中按完整路径或文件名找到对应数据,否则返回错误。 + /// AssetBundle 条目必须能在 `bundles` 中按完整路径或文件名找到对应 + /// 数据并成功解析 UnityFS,否则返回错误。TextAsset/Table/Media 等 + /// 非 AssetBundle 条目在有数据时写入 CAS 和 `ResourceRepository`;缺少 + /// 数据时记录到 `skipped`,用于渐进式导入官方下载结果。 pub async fn import_manifest_bundles( &self, manifest: &GenericManifest, @@ -100,18 +140,23 @@ impl<'a> ResourceImportService<'a> { let mut report = ResourceImportReport::default(); for entry in &manifest.resources { - if entry.resource_type != ResourceType::AssetBundle { + let category = classify_import_category(entry.resource_type, &entry.path); + let Some(bundle) = find_bundle(bundles, &entry.path) else { + if entry.resource_type == ResourceType::AssetBundle { + return Err(bat_core::Error::InvalidArgument(format!( + "Missing bundle data for manifest resource: {}", + entry.path + ))); + } report.skipped.push(entry.path.clone()); continue; - } + }; - let bundle = find_bundle(bundles, &entry.path).ok_or_else(|| { - bat_core::Error::InvalidArgument(format!( - "Missing bundle data for manifest resource: {}", - entry.path - )) - })?; - let unityfs = self.parse_unityfs_summary(&entry.path, bundle).await?; + let unityfs = if entry.resource_type == ResourceType::AssetBundle { + Some(self.parse_unityfs_summary(&entry.path, bundle).await?) + } else { + None + }; let object_id = self.cas.store(&bundle.data).await?; let mut stored_entry = entry.clone(); @@ -128,10 +173,16 @@ impl<'a> ResourceImportService<'a> { report.imported.push(ImportedResource { id, source_path: entry.path.clone(), + resource_type: entry.resource_type, + category, object_id, bytes: bundle.data.len() as u64, unityfs, }); + *report + .category_counts + .entry(category.as_str().to_string()) + .or_insert(0) += 1; } Ok(report) @@ -196,6 +247,43 @@ fn file_name(path: &str) -> Option<&str> { Path::new(path).file_name()?.to_str() } +fn classify_import_category(resource_type: ResourceType, path: &str) -> ResourceImportCategory { + match resource_type { + ResourceType::AssetBundle => ResourceImportCategory::AssetBundle, + ResourceType::TextAsset => ResourceImportCategory::TextAsset, + ResourceType::TableBundle => ResourceImportCategory::Table, + ResourceType::Media => ResourceImportCategory::Media, + ResourceType::Manifest => ResourceImportCategory::Manifest, + ResourceType::Other => classify_import_category_from_path(path), + } +} + +fn classify_import_category_from_path(path: &str) -> ResourceImportCategory { + let normalized = path.replace('\\', "/").to_ascii_lowercase(); + if normalized.contains("tablebundles/") { + ResourceImportCategory::Table + } else if normalized.contains("mediaresources/") + || normalized.contains("mediaresources-") + || matches!( + normalized.rsplit('.').next(), + Some("mp3" | "mp4" | "ogg" | "wav" | "png" | "jpg" | "jpeg" | "webp" | "acb" | "awb") + ) + { + ResourceImportCategory::Media + } else if normalized.contains("textassets/") + || matches!( + normalized.rsplit('.').next(), + Some("txt" | "csv" | "xml" | "yaml" | "yml") + ) + { + ResourceImportCategory::TextAsset + } else if normalized.contains("catalog") || normalized.ends_with(".hash") { + ResourceImportCategory::Manifest + } else { + ResourceImportCategory::Other + } +} + #[cfg(test)] mod tests { use super::*; @@ -287,6 +375,30 @@ mod tests { address: None, dependencies: Vec::new(), }, + ResourceEntry { + path: "TextAssets/dialogue.csv".to_string(), + hash: "synthetic-text-hash".to_string(), + size: 1, + resource_type: ResourceType::TextAsset, + address: Some("dialogue".to_string()), + dependencies: Vec::new(), + }, + ResourceEntry { + path: "TableBundles/ExcelDB.db".to_string(), + hash: "synthetic-table-hash".to_string(), + size: 1, + resource_type: ResourceType::TableBundle, + address: None, + dependencies: Vec::new(), + }, + ResourceEntry { + path: "MediaResources-Windows/voice/title.acb".to_string(), + hash: "synthetic-media-hash".to_string(), + size: 1, + resource_type: ResourceType::Media, + address: None, + dependencies: Vec::new(), + }, ], metadata: ManifestMetadata { locator_id: None, @@ -306,32 +418,78 @@ mod tests { let report = service .import_manifest_bundles( &synthetic_manifest(), - &[BundleSource::new( - "minimal.bundle", - synthetic_minimal_unityfs_bundle(), - )], + &[ + BundleSource::new("minimal.bundle", synthetic_minimal_unityfs_bundle()), + BundleSource::new("dialogue.csv", b"id,text\n1,hello".to_vec()), + BundleSource::new("ExcelDB.db", b"table-fixture".to_vec()), + BundleSource::new("title.acb", b"media-fixture".to_vec()), + ], ) .await .unwrap(); - assert_eq!(report.imported.len(), 1); + assert_eq!(report.imported.len(), 4); assert_eq!(report.skipped, vec!["synthetic/catalog.json".to_string()]); assert!(cas.exists(&report.imported[0].object_id).await); - assert_eq!(report.imported[0].unityfs.unity_version, "2021.3.56f2"); - assert_eq!(report.imported[0].unityfs.block_count, 1); - assert_eq!(report.imported[0].unityfs.directory_count, 1); + assert_eq!(report.imported[0].resource_type, ResourceType::AssetBundle); assert_eq!( - report.imported[0].unityfs.directories, - vec!["SYNTHETIC-CAB".to_string()] + report.imported[0].category, + ResourceImportCategory::AssetBundle ); + let unityfs = report.imported[0].unityfs.as_ref().unwrap(); + assert_eq!(unityfs.unity_version, "2021.3.56f2"); + assert_eq!(unityfs.block_count, 1); + assert_eq!(unityfs.directory_count, 1); + assert_eq!(unityfs.directories, vec!["SYNTHETIC-CAB".to_string()]); + assert_eq!( + report.imported[1].category, + ResourceImportCategory::TextAsset + ); + assert_eq!(report.imported[2].category, ResourceImportCategory::Table); + assert_eq!(report.imported[3].category, ResourceImportCategory::Media); + assert_eq!(report.category_counts.get("asset_bundle"), Some(&1)); + assert_eq!(report.category_counts.get("text_asset"), Some(&1)); + assert_eq!(report.category_counts.get("table"), Some(&1)); + assert_eq!(report.category_counts.get("media"), Some(&1)); let indexed = resources.list(ResourceQuery::all()).await.unwrap(); - assert_eq!(indexed.len(), 1); - assert_eq!(indexed[0].entry.hash, report.imported[0].object_id); + assert_eq!(indexed.len(), 4); + let indexed_bundle = indexed + .iter() + .find(|resource| resource.entry.path == "synthetic/minimal.bundle") + .unwrap(); + assert_eq!(indexed_bundle.entry.hash, report.imported[0].object_id); assert_eq!( - indexed[0].entry.size, + indexed_bundle.entry.size, synthetic_minimal_unityfs_bundle().len() as u64 ); + assert_eq!( + indexed + .iter() + .find(|resource| resource.entry.path.ends_with("title.acb")) + .unwrap() + .entry + .resource_type, + ResourceType::Media + ); + assert_eq!( + indexed + .iter() + .find(|resource| resource.entry.path.ends_with("ExcelDB.db")) + .unwrap() + .entry + .resource_type, + ResourceType::TableBundle + ); + assert_eq!( + indexed + .iter() + .find(|resource| resource.entry.path.ends_with("dialogue.csv")) + .unwrap() + .entry + .resource_type, + ResourceType::TextAsset + ); } #[tokio::test] @@ -370,4 +528,53 @@ mod tests { assert!(matches!(error, bat_core::Error::InvalidArgument(_))); assert_eq!(resources.count(ResourceQuery::all()).await.unwrap(), 0); } + + #[tokio::test] + async fn indexes_non_bundle_resources_without_unityfs_summary() { + let temp_dir = TempDir::new().unwrap(); + let cas = FileSystemCasRepository::new(temp_dir.path().join("cas")); + let resources = InMemoryResourceRepository::new(); + let service = ResourceImportService::new(&cas, &resources); + let mut manifest = synthetic_manifest(); + manifest + .resources + .retain(|entry| entry.resource_type != ResourceType::AssetBundle); + + let report = service + .import_manifest_bundles( + &manifest, + &[ + BundleSource::new("dialogue.csv", b"id,text\n1,hello".to_vec()), + BundleSource::new("ExcelDB.db", b"table-fixture".to_vec()), + BundleSource::new("title.acb", b"media-fixture".to_vec()), + ], + ) + .await + .unwrap(); + + assert_eq!(report.imported.len(), 3); + assert!(report.imported.iter().all(|item| item.unityfs.is_none())); + assert_eq!(report.skipped, vec!["synthetic/catalog.json".to_string()]); + assert_eq!( + resources + .count(ResourceQuery::by_type(ResourceType::TextAsset)) + .await + .unwrap(), + 1 + ); + assert_eq!( + resources + .count(ResourceQuery::by_type(ResourceType::TableBundle)) + .await + .unwrap(), + 1 + ); + assert_eq!( + resources + .count(ResourceQuery::by_type(ResourceType::Media)) + .await + .unwrap(), + 1 + ); + } } diff --git a/infrastructure/src/lib.rs b/infrastructure/src/lib.rs index de28cb3..31d5416 100644 --- a/infrastructure/src/lib.rs +++ b/infrastructure/src/lib.rs @@ -24,7 +24,10 @@ pub mod resources; mod zip_validation; pub use cas::FileSystemCasRepository; -pub use import::{BundleSource, ImportedResource, ResourceImportReport, ResourceImportService}; +pub use import::{ + BundleSource, ImportedResource, ResourceImportCategory, ResourceImportReport, + ResourceImportService, +}; pub use official_download::{ OfficialLocalManifestAuditItem, OfficialLocalManifestAuditReport, OfficialLocalManifestAuditStatus, OfficialLocalVerificationReport, OfficialResourcePullItem, @@ -48,11 +51,13 @@ pub use official_sync::{ }; pub use official_update::{ cached_game_main_config_for_metadata, diff_extended_snapshot, read_bootstrap_cache, - read_snapshot, write_bootstrap_cache, write_snapshot, ExtendedSnapshotDelta, - GameMainConfigSnapshot, LauncherMetadataSnapshot, OfficialBootstrapCache, - OfficialEndpointMarkerRole, OfficialEndpointMarkerSnapshot, OfficialServerInfoSource, - OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, - OfficialUpdateSnapshot, OfficialUpdateStatus, OfficialVerificationSummary, ResolvedBootstrap, + read_snapshot, read_version_state, write_bootstrap_cache, write_snapshot, write_version_state, + ExtendedSnapshotDelta, GameMainConfigSnapshot, LauncherMetadataSnapshot, + OfficialBootstrapCache, OfficialEndpointMarkerRole, OfficialEndpointMarkerSnapshot, + OfficialFailedVersionRecord, OfficialServerInfoSource, OfficialUpdateConfig, + OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateSnapshot, + OfficialUpdateStatus, OfficialVerificationSummary, OfficialVersionRecord, OfficialVersionState, + ResolvedBootstrap, }; pub use path_security::{ ensure_path_within_root, ensure_safe_directory_path, ensure_safe_file_target, lexical_absolute, diff --git a/infrastructure/src/official_download.rs b/infrastructure/src/official_download.rs index 5a2fef7..7fa4028 100644 --- a/infrastructure/src/official_download.rs +++ b/infrastructure/src/official_download.rs @@ -2787,6 +2787,59 @@ exit 22 assert!(service.read_download_manifest().unwrap().entries.is_empty()); } + #[test] + fn official_http_failure_regression_fixtures_are_enforced() { + for fixture in [ + include_str!("../tests/fixtures/official_regression/http_403.json"), + include_str!("../tests/fixtures/official_regression/http_404.json"), + ] { + let fixture: serde_json::Value = serde_json::from_str(fixture).unwrap(); + let out_dir = TempDir::new().unwrap(); + let bin_dir = TempDir::new().unwrap(); + let curl_path = bin_dir.path().join("curl"); + let status = fixture["http_status"].as_u64().unwrap() as u16; + write_fake_http_error_curl(&curl_path, status); + + let service = + OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path) + .with_retry_attempts(3); + let url = fixture["url"].as_str().unwrap(); + let error = service.pull(&one_url_plan(url)).unwrap_err(); + let expected_kind = fixture["failure_kind"].as_str().unwrap(); + let expected_retryable = fixture["retryable"].as_bool().unwrap(); + let expected_attempts = fixture["expected_attempts"].as_u64().unwrap(); + + assert!(error.contains(expected_kind)); + assert!(error.contains(&format!("retryable={expected_retryable}"))); + assert_eq!( + fs::read_to_string(curl_path.with_extension("state")).unwrap(), + expected_attempts.to_string() + ); + let quarantine = service.read_download_quarantine().unwrap(); + let entry = quarantine.entries.get(url).unwrap(); + assert_eq!(entry.failure_kind.as_deref(), Some(expected_kind)); + assert_eq!(entry.retryable, Some(expected_retryable)); + assert_eq!(entry.attempts, expected_attempts as usize); + } + } + + #[test] + fn official_hash_mismatch_regression_fixture_is_enforced() { + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../tests/fixtures/official_regression/hash_mismatch_catalog.json" + )) + .unwrap(); + let error = verify_official_seed_catalog_hash( + fixture["data_url"].as_str().unwrap(), + fixture["data"].as_str().unwrap().as_bytes(), + fixture["hash_url"].as_str().unwrap(), + fixture["official_hash"].as_str().unwrap().as_bytes(), + ) + .unwrap_err(); + + assert!(error.contains(fixture["expected_error_contains"].as_str().unwrap())); + } + #[test] fn retries_http_5xx_before_quarantine() { let out_dir = TempDir::new().unwrap(); diff --git a/infrastructure/src/official_update.rs b/infrastructure/src/official_update.rs index 10d090c..ebd3db2 100644 --- a/infrastructure/src/official_update.rs +++ b/infrastructure/src/official_update.rs @@ -37,11 +37,14 @@ use std::time::{SystemTime, UNIX_EPOCH}; pub const OFFICIAL_UPDATE_SNAPSHOT_VERSION: u32 = 2; /// Current official bootstrap cache schema version. pub const OFFICIAL_BOOTSTRAP_CACHE_VERSION: u32 = 1; +/// Current official version-state schema version. +pub const OFFICIAL_VERSION_STATE_VERSION: u32 = 1; const OFFICIAL_CURRENT_LINK: &str = "current"; const OFFICIAL_VERSIONS_DIR: &str = "versions"; const OFFICIAL_STAGING_DIR: &str = ".staging"; const OFFICIAL_DOWNLOAD_MANIFEST_FILE: &str = "official-download-manifest.json"; const OFFICIAL_SYNC_SNAPSHOT_FILE: &str = "official-sync-snapshot.json"; +const OFFICIAL_VERSION_STATE_FILE: &str = "official-version-state.json"; /// Server-info input for an official update run. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -125,6 +128,11 @@ impl OfficialUpdateConfig { self.output_root.join("official-bootstrap-cache.json") } + /// Returns the persistent version-state path for this config. + pub fn version_state_path(&self) -> PathBuf { + self.output_root.join(OFFICIAL_VERSION_STATE_FILE) + } + /// Returns the lock path used for non-dry-run executions. pub fn lock_path(&self) -> PathBuf { self.output_root.join(".official-sync.lock") @@ -181,6 +189,82 @@ pub struct OfficialUpdateSnapshot { pub game_main_config_bootstrap: Option, } +/// Persistent version state for an official resource output root. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OfficialVersionState { + /// State schema version. + #[serde(default = "default_version_state_version")] + pub state_version: u32, + /// Last successfully published version. + #[serde(default)] + pub current_completed_version: Option, + /// Version currently being downloaded into staging. + #[serde(default)] + pub in_progress_version: Option, + /// Previously usable version before the latest successful publish. + #[serde(default)] + pub previous_available_version: Option, + /// Recent versions that failed after entering staging. + #[serde(default)] + pub failed_versions: Vec, + /// Last time this state file was updated. + pub updated_unix_seconds: u64, +} + +impl Default for OfficialVersionState { + fn default() -> Self { + Self { + state_version: OFFICIAL_VERSION_STATE_VERSION, + current_completed_version: None, + in_progress_version: None, + previous_available_version: None, + failed_versions: Vec::new(), + updated_unix_seconds: unix_seconds_now(), + } + } +} + +/// One version tracked by `official-version-state.json`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OfficialVersionRecord { + /// Stable publish ID under `versions/`. + pub id: String, + /// Selected app version. + pub app_version: String, + /// Selected bundle version, when present. + pub bundle_version: Option, + /// Selected Addressables root. + pub addressables_root: String, + /// Resource root for this version. For in-progress versions this is a + /// staging path; for completed versions it is a versioned path. + pub resource_root: PathBuf, + /// Snapshot path associated with the resource root. + pub snapshot_path: PathBuf, + /// Staging path, when the version is still being downloaded. + #[serde(default)] + pub staging_path: Option, + /// Versioned path, when known. + #[serde(default)] + pub version_path: Option, + /// Start time for a staged pull. + #[serde(default)] + pub started_unix_seconds: Option, + /// Completion time for a published version. + #[serde(default)] + pub completed_unix_seconds: Option, +} + +/// Failed version entry tracked after a staged pull or publish error. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OfficialFailedVersionRecord { + /// Version that failed. + pub version: OfficialVersionRecord, + /// Human-readable failure reason. + pub error: String, + /// Failure time. + pub failed_unix_seconds: u64, +} + impl OfficialUpdateSnapshot { /// Creates an update snapshot from a base sync snapshot and extended data. pub fn new( @@ -391,6 +475,8 @@ pub struct OfficialUpdateReport { pub active_resource_root: PathBuf, /// Atomic `current` pointer path. pub current_path: PathBuf, + /// Persistent version-state path. + pub version_state_path: PathBuf, /// Versioned release directory after a successful publish. pub published_version_path: Option, /// Staging directory used during download before publish. @@ -754,6 +840,10 @@ impl OfficialUpdateService { progress(OfficialUpdateProgress::new("lock", "资源目录状态锁已获取")); Some(lock) }; + let version_state_path = config.version_state_path(); + if !config.dry_run { + recover_interrupted_version_state(&version_state_path)?; + } check_shutdown_requested(&mut should_cancel)?; let publish_layout = OfficialPublishLayout::new(&config.output_root); @@ -1035,6 +1125,7 @@ impl OfficialUpdateService { output_root: config.output_root.clone(), active_resource_root: active_resource_root.clone(), current_path: publish_layout.current_path.clone(), + version_state_path: version_state_path.clone(), published_version_path: None, staging_path: None, snapshot_path: snapshot_path.clone(), @@ -1079,6 +1170,27 @@ impl OfficialUpdateService { "audit", verification_progress_message(&report.verification_summary), )); + if !config.dry_run { + let current_record = version_record_for_snapshot( + ¤t_update_snapshot, + VersionRecordInput { + id: version_id_from_path(&active_resource_root) + .unwrap_or_else(|| fallback_version_id(¤t_update_snapshot)), + resource_root: active_resource_root.clone(), + snapshot_path: snapshot_path.clone(), + staging_path: None, + version_path: Some(active_resource_root.clone()), + started_unix_seconds: None, + completed_unix_seconds: Some(unix_seconds_now()), + }, + ); + complete_version_state( + &version_state_path, + current_record, + &active_resource_root, + &snapshot_path, + )?; + } progress(OfficialUpdateProgress::new( "finish", "资源已是最新;无需下载", @@ -1129,11 +1241,22 @@ impl OfficialUpdateService { publish_layout .seed_staging_from_active(&active_resource_root, &publish_plan.staging_path) .map_err(anyhow::Error::msg)?; + let staging_snapshot_path = snapshot_path_for(config, &publish_plan.staging_path); + let in_progress_record = prepare_in_progress_version_state( + &version_state_path, + ¤t_update_snapshot, + &publish_plan.id, + &active_resource_root, + previous_snapshot.as_ref(), + &publish_plan.staging_path, + &staging_snapshot_path, + )?; + let mut version_state_guard = + VersionStateGuard::new(version_state_path.clone(), in_progress_record); let staging_fetcher = OfficialResourcePullService::with_curl_command( &publish_plan.staging_path, &config.curl_command, ); - let staging_snapshot_path = snapshot_path_for(config, &publish_plan.staging_path); report.staging_path = Some(publish_plan.staging_path.clone()); report.snapshot_path = staging_snapshot_path.clone(); report.download_manifest = staging_fetcher.download_manifest_path(); @@ -1150,7 +1273,10 @@ impl OfficialUpdateService { }, &mut should_cancel, ) - .map_err(anyhow::Error::msg)?; + .map_err(|error| anyhow::anyhow!(error)) + .inspect_err(|error| { + let _ = version_state_guard.fail(&error.to_string()); + })?; progress(OfficialUpdateProgress::new( "download", format!( @@ -1217,7 +1343,15 @@ impl OfficialUpdateService { report.local_manifest_verified_count = final_audit.verified_count(); report.local_manifest_repair_needed_count = final_audit.repair_needed_count(); report.verification_summary = final_verification_summary; - report.snapshot_written = Some(final_snapshot_path); + report.snapshot_written = Some(final_snapshot_path.clone()); + let completed_record = version_state_guard.record()?.clone(); + complete_version_state( + &version_state_path, + completed_record, + &published_version_path, + &final_snapshot_path, + )?; + version_state_guard.commit(); progress(OfficialUpdateProgress::new( "finish", @@ -1505,6 +1639,11 @@ fn validate_update_paths(config: &OfficialUpdateConfig) -> Result<(), String> { &config.bootstrap_cache_path(), "官方启动缓存", )?; + ensure_safe_file_target( + &config.output_root, + &config.version_state_path(), + "官方版本状态", + )?; ensure_safe_file_target(&config.output_root, &config.lock_path(), "官方同步锁")?; Ok(()) } @@ -1725,6 +1864,30 @@ pub fn write_snapshot(path: &Path, snapshot: &OfficialUpdateSnapshot) -> anyhow: Ok(()) } +/// Reads the persistent official version state. +pub fn read_version_state(path: &Path) -> anyhow::Result> { + let Some(data) = read_file_no_symlink(path, "官方版本状态").map_err(anyhow::Error::msg)? + else { + return Ok(None); + }; + let state: OfficialVersionState = serde_json::from_slice(&data)?; + if state.state_version != OFFICIAL_VERSION_STATE_VERSION { + return Err(anyhow::anyhow!( + "不支持的官方版本状态 schema:{},当前版本={}", + state.state_version, + OFFICIAL_VERSION_STATE_VERSION + )); + } + Ok(Some(state)) +} + +/// Writes the persistent official version state atomically. +pub fn write_version_state(path: &Path, state: &OfficialVersionState) -> anyhow::Result<()> { + let data = serde_json::to_vec_pretty(state)?; + write_file_atomic(path, &data, STATE_FILE_MODE, "官方版本状态").map_err(anyhow::Error::msg)?; + Ok(()) +} + /// Reads an official bootstrap cache from disk. pub fn read_bootstrap_cache(path: &Path) -> anyhow::Result> { let Some(data) = read_file_no_symlink(path, "官方启动缓存").map_err(anyhow::Error::msg)? @@ -1763,6 +1926,227 @@ fn default_bootstrap_cache_version() -> u32 { OFFICIAL_BOOTSTRAP_CACHE_VERSION } +fn default_version_state_version() -> u32 { + OFFICIAL_VERSION_STATE_VERSION +} + +#[derive(Debug)] +struct VersionRecordInput { + id: String, + resource_root: PathBuf, + snapshot_path: PathBuf, + staging_path: Option, + version_path: Option, + started_unix_seconds: Option, + completed_unix_seconds: Option, +} + +fn version_record_for_snapshot( + snapshot: &OfficialUpdateSnapshot, + input: VersionRecordInput, +) -> OfficialVersionRecord { + OfficialVersionRecord { + id: input.id, + app_version: snapshot.app_version.clone(), + bundle_version: snapshot.bundle_version.clone(), + addressables_root: snapshot.addressables_root.clone(), + resource_root: input.resource_root, + snapshot_path: input.snapshot_path, + staging_path: input.staging_path, + version_path: input.version_path, + started_unix_seconds: input.started_unix_seconds, + completed_unix_seconds: input.completed_unix_seconds, + } +} + +fn recover_interrupted_version_state(path: &Path) -> anyhow::Result<()> { + let Some(mut state) = read_version_state(path)? else { + return Ok(()); + }; + let Some(version) = state.in_progress_version.take() else { + return Ok(()); + }; + + state.failed_versions.push(OfficialFailedVersionRecord { + version, + error: "上一次官方资源同步在完成发布前中断".to_string(), + failed_unix_seconds: unix_seconds_now(), + }); + trim_failed_versions(&mut state.failed_versions); + state.updated_unix_seconds = unix_seconds_now(); + write_version_state(path, &state) +} + +fn prepare_in_progress_version_state( + path: &Path, + snapshot: &OfficialUpdateSnapshot, + publish_id: &str, + active_resource_root: &Path, + previous_snapshot: Option<&OfficialUpdateSnapshot>, + staging_path: &Path, + snapshot_path: &Path, +) -> anyhow::Result { + let mut state = read_version_state(path)?.unwrap_or_default(); + let fallback_current = previous_snapshot.map(|snapshot| { + version_record_for_snapshot( + snapshot, + VersionRecordInput { + id: version_id_from_path(active_resource_root) + .unwrap_or_else(|| fallback_version_id(snapshot)), + resource_root: active_resource_root.to_path_buf(), + snapshot_path: snapshot_path_for_state_path(snapshot, active_resource_root), + staging_path: None, + version_path: Some(active_resource_root.to_path_buf()), + started_unix_seconds: None, + completed_unix_seconds: Some(unix_seconds_now()), + }, + ) + }); + if state.current_completed_version.is_none() { + state.current_completed_version = fallback_current; + } + + let now = unix_seconds_now(); + let record = version_record_for_snapshot( + snapshot, + VersionRecordInput { + id: publish_id.to_string(), + resource_root: staging_path.to_path_buf(), + snapshot_path: snapshot_path.to_path_buf(), + staging_path: Some(staging_path.to_path_buf()), + version_path: None, + started_unix_seconds: Some(now), + completed_unix_seconds: None, + }, + ); + state.in_progress_version = Some(record.clone()); + state.updated_unix_seconds = now; + write_version_state(path, &state)?; + Ok(record) +} + +fn complete_version_state( + path: &Path, + record: OfficialVersionRecord, + published_path: &Path, + snapshot_path: &Path, +) -> anyhow::Result<()> { + let mut state = read_version_state(path)?.unwrap_or_default(); + let previous = state + .current_completed_version + .take() + .filter(|previous| previous.id != record.id); + if previous.is_some() { + state.previous_available_version = previous; + } + state.current_completed_version = Some(OfficialVersionRecord { + resource_root: published_path.to_path_buf(), + snapshot_path: snapshot_path.to_path_buf(), + staging_path: None, + version_path: Some(published_path.to_path_buf()), + completed_unix_seconds: Some(unix_seconds_now()), + ..record + }); + state.in_progress_version = None; + state.updated_unix_seconds = unix_seconds_now(); + write_version_state(path, &state) +} + +fn fail_version_state( + path: &Path, + record: OfficialVersionRecord, + error: &str, +) -> anyhow::Result<()> { + let mut state = read_version_state(path)?.unwrap_or_default(); + state.in_progress_version = None; + state.failed_versions.push(OfficialFailedVersionRecord { + version: record, + error: error.to_string(), + failed_unix_seconds: unix_seconds_now(), + }); + trim_failed_versions(&mut state.failed_versions); + state.updated_unix_seconds = unix_seconds_now(); + write_version_state(path, &state) +} + +struct VersionStateGuard { + path: PathBuf, + record: Option, +} + +impl VersionStateGuard { + fn new(path: PathBuf, record: OfficialVersionRecord) -> Self { + Self { + path, + record: Some(record), + } + } + + fn record(&self) -> anyhow::Result<&OfficialVersionRecord> { + self.record + .as_ref() + .ok_or_else(|| anyhow::anyhow!("官方版本状态事务已结束")) + } + + fn fail(&mut self, error: &str) -> anyhow::Result<()> { + let Some(record) = self.record.take() else { + return Ok(()); + }; + fail_version_state(&self.path, record, error) + } + + fn commit(&mut self) { + self.record = None; + } +} + +impl Drop for VersionStateGuard { + fn drop(&mut self) { + let Some(record) = self.record.take() else { + return; + }; + let _ = fail_version_state( + &self.path, + record, + "官方资源同步未完成;已将该版本记录为失败,保留当前可用版本", + ); + } +} + +fn trim_failed_versions(failed_versions: &mut Vec) { + const MAX_FAILED_VERSIONS: usize = 8; + if failed_versions.len() > MAX_FAILED_VERSIONS { + let drop_count = failed_versions.len() - MAX_FAILED_VERSIONS; + failed_versions.drain(0..drop_count); + } +} + +fn snapshot_path_for_state_path( + _snapshot: &OfficialUpdateSnapshot, + resource_root: &Path, +) -> PathBuf { + resource_root.join(OFFICIAL_SYNC_SNAPSHOT_FILE) +} + +fn version_id_from_path(path: &Path) -> Option { + path.file_name() + .and_then(|value| value.to_str()) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn fallback_version_id(snapshot: &OfficialUpdateSnapshot) -> String { + format!( + "{}-{}", + sanitize_publish_segment(&snapshot.app_version), + snapshot + .bundle_version + .as_deref() + .map(sanitize_publish_segment) + .unwrap_or_else(|| "no-bundle".to_string()) + ) +} + fn launcher_metadata_from_parts( launcher_version: &str, game_config: &YostarJpLauncherGameConfig, @@ -2268,6 +2652,118 @@ mod tests { assert_eq!(read_bootstrap_cache(&path).unwrap(), Some(cache)); } + #[test] + fn persists_version_state_json_round_trip() { + let temp = tempfile::TempDir::new().unwrap(); + let path = temp.path().join("official-version-state.json"); + let snapshot = OfficialUpdateSnapshot::new(fixture_base_snapshot(), Vec::new(), None); + let current = version_record_for_snapshot( + &snapshot, + VersionRecordInput { + id: "1.70.0-bundle-current".to_string(), + resource_root: temp.path().join("versions/current"), + snapshot_path: temp + .path() + .join("versions/current/official-sync-snapshot.json"), + staging_path: None, + version_path: Some(temp.path().join("versions/current")), + started_unix_seconds: None, + completed_unix_seconds: Some(10), + }, + ); + let failed = OfficialFailedVersionRecord { + version: current.clone(), + error: "fixture failure".to_string(), + failed_unix_seconds: 11, + }; + let state = OfficialVersionState { + current_completed_version: Some(current), + failed_versions: vec![failed], + updated_unix_seconds: 12, + ..OfficialVersionState::default() + }; + + write_version_state(&path, &state).unwrap(); + assert_eq!(read_version_state(&path).unwrap(), Some(state)); + } + + #[test] + fn version_state_tracks_in_progress_success_and_failure() { + let temp = tempfile::TempDir::new().unwrap(); + let path = temp.path().join("official-version-state.json"); + let active = temp.path().join("versions/previous"); + let staging = temp.path().join(".staging/current"); + let staging_snapshot = staging.join("official-sync-snapshot.json"); + let published = temp.path().join("versions/current"); + let previous_snapshot = OfficialUpdateSnapshot::new( + fixture_base_snapshot(), + vec![fixture_marker("previous")], + None, + ); + let current_snapshot = OfficialUpdateSnapshot::new( + fixture_base_snapshot(), + vec![fixture_marker("current")], + Some(&fixture_bootstrap()), + ); + + let in_progress = prepare_in_progress_version_state( + &path, + ¤t_snapshot, + "current", + &active, + Some(&previous_snapshot), + &staging, + &staging_snapshot, + ) + .unwrap(); + let state = read_version_state(&path).unwrap().unwrap(); + assert_eq!(state.in_progress_version.as_ref().unwrap().id, "current"); + assert_eq!( + state.current_completed_version.as_ref().unwrap().id, + "previous" + ); + + complete_version_state( + &path, + in_progress.clone(), + &published, + &published.join("official-sync-snapshot.json"), + ) + .unwrap(); + let state = read_version_state(&path).unwrap().unwrap(); + assert_eq!( + state.current_completed_version.as_ref().unwrap().id, + "current" + ); + assert!(state.in_progress_version.is_none()); + assert_eq!( + state.previous_available_version.as_ref().unwrap().id, + "previous" + ); + + let failed = prepare_in_progress_version_state( + &path, + ¤t_snapshot, + "broken", + &published, + state + .current_completed_version + .as_ref() + .map(|_| ¤t_snapshot), + &temp.path().join(".staging/broken"), + &temp + .path() + .join(".staging/broken/official-sync-snapshot.json"), + ) + .unwrap(); + fail_version_state(&path, failed, "download 403").unwrap(); + let state = read_version_state(&path).unwrap().unwrap(); + assert!(state.in_progress_version.is_none()); + assert_eq!(state.failed_versions.len(), 1); + assert_eq!(state.failed_versions[0].version.id, "broken"); + assert!(state.failed_versions[0].error.contains("403")); + } + #[test] fn update_rejects_dangerous_output_root_before_network_work() { let config = OfficialUpdateConfig { diff --git a/infrastructure/src/resources.rs b/infrastructure/src/resources.rs index c5d2a37..bf551ce 100644 --- a/infrastructure/src/resources.rs +++ b/infrastructure/src/resources.rs @@ -187,6 +187,8 @@ impl SqliteResourceRepository { ResourceType::AssetBundle => "AssetBundle", ResourceType::Manifest => "Manifest", ResourceType::TableBundle => "TableBundle", + ResourceType::TextAsset => "TextAsset", + ResourceType::Media => "Media", ResourceType::Other => "Other", } } @@ -196,6 +198,8 @@ impl SqliteResourceRepository { "AssetBundle" => Ok(ResourceType::AssetBundle), "Manifest" => Ok(ResourceType::Manifest), "TableBundle" => Ok(ResourceType::TableBundle), + "TextAsset" => Ok(ResourceType::TextAsset), + "Media" => Ok(ResourceType::Media), "Other" => Ok(ResourceType::Other), other => Err(bat_core::Error::Serialization(format!( "Unknown resource type: {}", diff --git a/infrastructure/tests/fixtures/official_regression/hash_mismatch_catalog.json b/infrastructure/tests/fixtures/official_regression/hash_mismatch_catalog.json new file mode 100644 index 0000000..5133abe --- /dev/null +++ b/infrastructure/tests/fixtures/official_regression/hash_mismatch_catalog.json @@ -0,0 +1,7 @@ +{ + "data_url": "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.bytes", + "hash_url": "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.hash", + "data": "ExcelDB.db", + "official_hash": "0", + "expected_error_contains": "官方 hash 校验失败" +} diff --git a/infrastructure/tests/fixtures/official_regression/http_403.json b/infrastructure/tests/fixtures/official_regression/http_403.json new file mode 100644 index 0000000..f475430 --- /dev/null +++ b/infrastructure/tests/fixtures/official_regression/http_403.json @@ -0,0 +1,7 @@ +{ + "url": "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/Forbidden.bytes", + "http_status": 403, + "failure_kind": "http_forbidden", + "retryable": false, + "expected_attempts": 1 +} diff --git a/infrastructure/tests/fixtures/official_regression/http_404.json b/infrastructure/tests/fixtures/official_regression/http_404.json new file mode 100644 index 0000000..40c4899 --- /dev/null +++ b/infrastructure/tests/fixtures/official_regression/http_404.json @@ -0,0 +1,7 @@ +{ + "url": "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/Missing.bytes", + "http_status": 404, + "failure_kind": "http_not_found", + "retryable": false, + "expected_attempts": 1 +} diff --git a/infrastructure/tests/golden/synthetic_phase2_import.json b/infrastructure/tests/golden/synthetic_phase2_import.json index 5244aa0..fcbe8ab 100644 --- a/infrastructure/tests/golden/synthetic_phase2_import.json +++ b/infrastructure/tests/golden/synthetic_phase2_import.json @@ -2,6 +2,8 @@ "imported": [ { "bytes": 143, + "category": "asset_bundle", + "resource_type": "AssetBundle", "source_path": "synthetic/minimal.bundle", "unityfs": { "block_count": 1, @@ -13,6 +15,9 @@ } } ], + "category_counts": { + "asset_bundle": 1 + }, "resource_count": 1, "skipped": [ "synthetic/catalog.json" diff --git a/infrastructure/tests/official_game_main_config_bootstrap.rs b/infrastructure/tests/official_game_main_config_bootstrap.rs index 927daff..cbee3dd 100644 --- a/infrastructure/tests/official_game_main_config_bootstrap.rs +++ b/infrastructure/tests/official_game_main_config_bootstrap.rs @@ -7,9 +7,9 @@ use bat_adapters::official::yostar_jp::{ YostarJpResourceDiscoveryPlan, YostarJpResourceEndpointKind, YostarJpServerInfo, }; use bat_infrastructure::{ - build_official_pull_plan_from_platform_inventory, OfficialGameMainConfigBootstrapService, - OfficialResourcePullService, OfficialUpdateConfig, OfficialUpdateProgress, - OfficialUpdateService, OfficialUpdateStatus, + build_official_pull_plan_from_platform_inventory, read_version_state, + OfficialGameMainConfigBootstrapService, OfficialResourcePullService, OfficialUpdateConfig, + OfficialUpdateProgress, OfficialUpdateService, OfficialUpdateStatus, }; use std::collections::HashMap; use std::fs; @@ -252,15 +252,26 @@ fn official_update_first_run_pulls_without_pre_audit() { .unwrap() .file_type() .is_symlink()); - assert_eq!( - config - .output_root - .join("official-download-manifest.json") - .exists(), - false - ); + assert!(!config + .output_root + .join("official-download-manifest.json") + .exists()); assert!(published.join("official-download-manifest.json").exists()); assert!(published.join("official-sync-snapshot.json").exists()); + let version_state = read_version_state(&report.version_state_path) + .unwrap() + .unwrap(); + assert_eq!( + version_state + .current_completed_version + .as_ref() + .unwrap() + .version_path + .as_deref(), + Some(published.as_path()) + ); + assert!(version_state.in_progress_version.is_none()); + assert!(version_state.failed_versions.is_empty()); } #[test] @@ -305,6 +316,18 @@ fn official_update_second_run_audits_existing_resources_before_reuse() { .unwrap() .file_type() .is_symlink()); + let version_state = read_version_state(&report.version_state_path) + .unwrap() + .unwrap(); + assert_eq!( + version_state + .current_completed_version + .as_ref() + .unwrap() + .resource_root, + report.active_resource_root + ); + assert!(version_state.in_progress_version.is_none()); } struct TestHarness { diff --git a/infrastructure/tests/synthetic_phase2_import.rs b/infrastructure/tests/synthetic_phase2_import.rs index 3445ba5..6d886bd 100644 --- a/infrastructure/tests/synthetic_phase2_import.rs +++ b/infrastructure/tests/synthetic_phase2_import.rs @@ -99,17 +99,20 @@ async fn imports_synthetic_addressables_catalog_bundle_against_golden() { "imported": report.imported.iter().map(|imported| { json!({ "bytes": imported.bytes, + "category": imported.category.as_str(), + "resource_type": format!("{:?}", imported.resource_type), "source_path": imported.source_path, - "unityfs": { - "block_count": imported.unityfs.block_count, - "directories": imported.unityfs.directories, - "directory_count": imported.unityfs.directory_count, - "unity_version": imported.unityfs.unity_version, - } + "unityfs": imported.unityfs.as_ref().map(|unityfs| json!({ + "block_count": unityfs.block_count, + "directories": unityfs.directories, + "directory_count": unityfs.directory_count, + "unity_version": unityfs.unity_version, + })), }) }).collect::>(), "resource_count": indexed.len(), "skipped": report.skipped, + "category_counts": report.category_counts, }); let expected: serde_json::Value = serde_json::from_str(include_str!("golden/synthetic_phase2_import.json")).unwrap();