From 11b19683cf7ae8a237acd677177623cfcaa618d4 Mon Sep 17 00:00:00 2001 From: Yuyi-Oak <1722157266@qq.com> Date: Fri, 17 Jul 2026 10:10:03 -0700 Subject: [PATCH] =?UTF-8?q?feat(daemon):=20=E5=AE=9E=E7=8E=B0=20catalog.*?= =?UTF-8?q?=20=E5=91=BD=E5=90=8D=E7=A9=BA=E9=97=B4=E4=B8=8E=20resource.man?= =?UTF-8?q?ifest=EF=BC=88issue=20#1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐 issue #1 方法分层中可立即实现的剩余命名空间: - catalog.status:当前已发布版本的 catalog 概览(读取其 snapshot: app/bundle 版本、connection group、addressables 根、端点与 marker 计数、launcher 元数据、GameMainConfig 摘要)。 - catalog.versions:版本历史(current / in_progress / previous / failed,来自 official-version-state.json)。 - catalog.diff:当前 snapshot 相对上一个可用版本的差异(复用 YostarJpSyncSnapshot::diff 与 diff_extended_snapshot;含 changed_endpoint_urls 与 previous_snapshot_missing 标志;无上一版本 时按首次观察处理 is_initial=true)。 - catalog.refresh:经任务执行器的 catalog 更新检查任务(dry-run + plan,不下载不审计;支持 force),返回 task_id 可轮询。 - resource.manifest:当前版本下载 manifest 的分页只读查询(offset 默认 0、limit 默认 100/上限 1000,BTreeMap 按 URL 有序保证分页稳定; 非法 limit → 700002)。infrastructure 公开 OfficialDownloadManifest(Entry) 与 read_download_manifest_at。 约定:只读查询在尚无已发布版本或文件缺失时返回 ok=true 且 data.available=false(正常状态而非错误,便于 Go 层直接分支)。 is_pending_rpc_method 缩减为 task.create / resource.repair / patch.* / unityfs.*(后两者待引擎;repair 待引擎独立修复模式)。 新增 read_daemon_resource_state 供 resource/catalog 只读查询共用。 测试:新增 10 个 dispatch 单测(含 fixture:状态文件 + 版本状态 + snapshot + manifest)与 read_download_manifest_at 单测;调整 pending 断言。真实 daemon 端到端验证(fixture 资源根 + 死代理快速失败): raw socket 依次断言 catalog.status/versions/diff、resource.manifest 分页与参数错误、catalog.refresh 入队与 task.status 轮询、 resource.repair→700003、未知方法→700001,全部通过,daemon.stop 干净 退出。全量 fmt / clippy --workspace --all-targets -D warnings / test --workspace 全绿。 已知后续(另行推进):任务配置校验失败(如缺少 app-version)尚未接入 输入域错误码(应为 100001,现落 900001)。 Co-Authored-By: Claude Fable 5 --- USERGUIDE.md | 21 +- infrastructure/src/bin/bat_official_sync.rs | 567 +++++++++++++++++++- infrastructure/src/lib.rs | 10 +- infrastructure/src/official_download.rs | 69 ++- 4 files changed, 636 insertions(+), 31 deletions(-) diff --git a/USERGUIDE.md b/USERGUIDE.md index 8379a50..f869596 100644 --- a/USERGUIDE.md +++ b/USERGUIDE.md @@ -262,16 +262,23 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon | `resource.state` | ✅ | 资源发布根 + 版本状态 + 上次同步结果 | | `resource.sync` | ✅ | 触发同步任务(`params.force`),返回 `task_id` | | `resource.verify` | ✅ | 触发校验任务(dry-run + audit),返回 `task_id` | +| `resource.manifest` | ✅ | 当前版本下载 manifest 分页查询(`params.offset` 默认 0、`params.limit` 默认 100/上限 1000) | +| `catalog.status` | ✅ | 当前已发布版本的 catalog 概览(app/bundle 版本、addressables 根、端点与 marker 计数、launcher 元数据) | +| `catalog.versions` | ✅ | 版本历史:current / in_progress / previous / failed | +| `catalog.diff` | ✅ | 当前 snapshot 相对上一个可用版本的差异(base_delta + extended_delta + 变更端点 URL) | +| `catalog.refresh` | ✅ | 触发 catalog 更新检查任务(dry-run 计划,不下载;`params.force`),返回 `task_id` | | `task.status` | ✅ | 查询任务(`params.task_id`) | | `task.list` | ✅ | 列出全部任务(最新在前) | | `task.cancel` | ✅ | 请求取消任务(`params.task_id`);协作式,在同步检查点生效 | | `task.logs` | ✅ | 返回任务的进度日志(`params.task_id`,有界) | -| `catalog.*` / `patch.*` / `unityfs.*` / `task.create` | ⏳ | 已规划,返回 `BAT-ERR-700003`(not implemented) | +| `resource.repair` / `patch.*` / `unityfs.*` / `task.create` | ⏳ | 已规划,返回 `BAT-ERR-700003`(not implemented);repair 待引擎支持独立修复模式,patch/unityfs 待引擎实现 | | 未知方法 | — | `BAT-ERR-700001`(unknown method) | +只读查询(`resource.state` / `resource.manifest` / `catalog.status` / `catalog.versions` / `catalog.diff`)在尚无已发布版本或对应文件不存在时返回 `ok: true` 且 `data.available: false`(正常状态而非错误,便于调用方直接分支)。 + ### 任务模型 -`resource.sync` / `resource.verify` 是**异步任务**:入队即返回 `{ "task_id": "task--", "kind": "resource.sync" }`(`status: "accepted"`),实际执行由后台任务 worker 串行完成,通过 `task.status` / `task.list` 轮询。任务记录: +`resource.sync` / `resource.verify` / `catalog.refresh` 是**异步任务**:入队即返回 `{ "task_id": "task--", "kind": "resource.sync" }`(`status: "accepted"`),实际执行由后台任务 worker 串行完成,通过 `task.status` / `task.list` 轮询。任务记录: ```json { "id": "task-1234-1", "kind": "resource.sync", @@ -295,4 +302,14 @@ printf '{"jsonrpc":"2.0","id":1,"method":"resource.sync","params":{"force":true} # 轮询任务 printf '{"jsonrpc":"2.0","id":2,"method":"task.status","params":{"task_id":"task-1234-1"}}\n' \ | socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock + +# 查询当前 catalog 概览与版本历史 +printf '{"jsonrpc":"2.0","id":3,"method":"catalog.status"}\n' \ + | socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock +printf '{"jsonrpc":"2.0","id":4,"method":"catalog.versions"}\n' \ + | socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock + +# 分页读取当前版本的下载 manifest +printf '{"jsonrpc":"2.0","id":5,"method":"resource.manifest","params":{"offset":0,"limit":50}}\n' \ + | socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock ``` diff --git a/infrastructure/src/bin/bat_official_sync.rs b/infrastructure/src/bin/bat_official_sync.rs index 0ed6cc0..c693a65 100644 --- a/infrastructure/src/bin/bat_official_sync.rs +++ b/infrastructure/src/bin/bat_official_sync.rs @@ -1,12 +1,14 @@ use bat_adapters::official::yostar_jp::PatchPlatform; use bat_core::{ApiError, ErrorCode}; use bat_infrastructure::{ - gc_orphan_staging, lexical_absolute, open_append_file, read_file_no_symlink, + changed_endpoint_urls, diff_extended_snapshot, gc_orphan_staging, lexical_absolute, + open_append_file, read_download_manifest_at, read_file_no_symlink, read_snapshot, read_version_state, redact_proxy_url, resolve_curl_proxy, validate_output_root, validate_runtime_state_dir, write_file_atomic, CurlProxyConfig, CurlProxyMode, - OfficialFailedVersionRecord, OfficialServerInfoSource, OfficialUpdateConfig, - OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateStatus, - OfficialVerificationSummary, OfficialVersionRecord, OfficialVersionState, PRIVATE_FILE_MODE, + OfficialEndpointMarkerRole, OfficialFailedVersionRecord, OfficialServerInfoSource, + OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, + OfficialUpdateSnapshot, OfficialUpdateStatus, OfficialVerificationSummary, + OfficialVersionRecord, OfficialVersionState, PRIVATE_FILE_MODE, }; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -638,6 +640,11 @@ const RPC_METHOD_LOGS: &str = "daemon.logs"; const RPC_METHOD_RESOURCE_STATE: &str = "resource.state"; const RPC_METHOD_RESOURCE_SYNC: &str = "resource.sync"; const RPC_METHOD_RESOURCE_VERIFY: &str = "resource.verify"; +const RPC_METHOD_RESOURCE_MANIFEST: &str = "resource.manifest"; +const RPC_METHOD_CATALOG_STATUS: &str = "catalog.status"; +const RPC_METHOD_CATALOG_VERSIONS: &str = "catalog.versions"; +const RPC_METHOD_CATALOG_DIFF: &str = "catalog.diff"; +const RPC_METHOD_CATALOG_REFRESH: &str = "catalog.refresh"; const RPC_METHOD_TASK_STATUS: &str = "task.status"; const RPC_METHOD_TASK_LIST: &str = "task.list"; const RPC_METHOD_TASK_CANCEL: &str = "task.cancel"; @@ -648,11 +655,13 @@ const MAX_RETAINED_TASKS: usize = 64; /// 每个任务保留的进度日志行数上限。 const MAX_TASK_LOG_LINES: usize = 200; -/// 任务类型:目前覆盖官方同步与校验。 +/// 任务类型:目前覆盖官方同步、校验与 catalog 更新检查。 #[derive(Debug, Clone, Copy)] enum TaskKind { Sync, Verify, + /// catalog 更新检查:只做发现 + 拉取计划(dry-run),不下载不审计。 + Refresh, } impl TaskKind { @@ -660,6 +669,7 @@ impl TaskKind { match self { Self::Sync => RPC_METHOD_RESOURCE_SYNC, Self::Verify => RPC_METHOD_RESOURCE_VERIFY, + Self::Refresh => RPC_METHOD_CATALOG_REFRESH, } } @@ -678,6 +688,13 @@ impl TaskKind { config.repair = false; config.force = false; } + Self::Refresh => { + config.dry_run = true; + config.plan = true; + config.audit_local = false; + config.repair = false; + config.force = force; + } } config } @@ -969,8 +986,11 @@ fn canonical_rpc_method(method: &str) -> &str { /// 判断方法是否属于已规划但尚未实现的命名空间/动作(返回 not_implemented 而非 unknown)。 fn is_pending_rpc_method(method: &str) -> bool { - matches!(method, "task.create") - || method.starts_with("catalog.") + // task.create:任务统一由 resource.sync / resource.verify / catalog.refresh + // 等语义方法创建,通用创建接口暂不开放。 + // resource.repair:引擎尚无独立修复模式(sync 自带审计+重下)。 + // patch.* / unityfs.*:被 bat-patch / bat-assetbundle 引擎阻塞。 + matches!(method, "task.create" | "resource.repair") || method.starts_with("patch.") || method.starts_with("unityfs.") } @@ -1603,6 +1623,45 @@ fn dispatch_rpc_method( RPC_METHOD_RESOURCE_VERIFY => { enqueue_task_envelope(tasks, TaskKind::Verify, false, request_id) } + RPC_METHOD_RESOURCE_MANIFEST => { + let (offset, limit) = match rpc_page_params(request.params.as_ref()) { + Ok(page) => page, + Err(error) => { + return rpc_envelope_error( + request_id, + ApiError::new( + ErrorCode::RPC_INVALID_PARAMS, + "resource.manifest", + error.to_string(), + ), + ) + } + }; + rpc_envelope_from_result( + request_id, + "resource.manifest", + build_resource_manifest_report(state_dir, offset, limit), + ) + } + RPC_METHOD_CATALOG_STATUS => rpc_envelope_from_result( + request_id, + "catalog.status", + build_catalog_status_report(state_dir), + ), + RPC_METHOD_CATALOG_VERSIONS => rpc_envelope_from_result( + request_id, + "catalog.versions", + build_catalog_versions_report(state_dir), + ), + RPC_METHOD_CATALOG_DIFF => rpc_envelope_from_result( + request_id, + "catalog.diff", + build_catalog_diff_report(state_dir), + ), + RPC_METHOD_CATALOG_REFRESH => { + let force = rpc_bool_param(request.params.as_ref(), "force").unwrap_or(false); + enqueue_task_envelope(tasks, TaskKind::Refresh, force, request_id) + } RPC_METHOD_TASK_STATUS => { let task_id = rpc_task_id_param(request); match tasks.registry.get(task_id) { @@ -1747,17 +1806,28 @@ fn rpc_ack_value( } /// 构建 `resource.state` 数据:资源发布根、版本状态、上次同步结果。 -fn build_resource_state_report(state_dir: &Path) -> anyhow::Result { +/// 读取 daemon 状态文件与资源根目录的版本状态(resource/catalog 只读查询共用)。 +fn read_daemon_resource_state( + state_dir: &Path, +) -> anyhow::Result<(Option, Option)> { let status_file = read_daemon_status_file(&daemon_status_path(state_dir))?; - let resource_output_root = status_file + let version_state = status_file .as_ref() - .map(|status| status.resource_output_root.clone()); - let version_state = resource_output_root - .as_ref() - .map(|root| root.join("official-version-state.json")) + .map(|status| { + status + .resource_output_root + .join("official-version-state.json") + }) .and_then(|path| read_version_state(&path).ok().flatten()); + Ok((status_file, version_state)) +} + +fn build_resource_state_report(state_dir: &Path) -> anyhow::Result { + let (status_file, version_state) = read_daemon_resource_state(state_dir)?; Ok(serde_json::json!({ - "resource_output_root": resource_output_root, + "resource_output_root": status_file + .as_ref() + .map(|status| status.resource_output_root.clone()), "version_state": version_state, "last_update_status": status_file .as_ref() @@ -1768,6 +1838,140 @@ fn build_resource_state_report(state_dir: &Path) -> anyhow::Result anyhow::Result { + let (_, version_state) = read_daemon_resource_state(state_dir)?; + let current = version_state + .as_ref() + .and_then(|state| state.current_completed_version.as_ref()); + let snapshot = current.and_then(|record| read_snapshot(&record.snapshot_path).ok().flatten()); + let (Some(record), Some(snapshot)) = (current, snapshot) else { + return Ok(serde_json::json!({ "available": false })); + }; + let official_seed_hash_marker_count = snapshot + .endpoint_markers + .iter() + .filter(|marker| marker.role == OfficialEndpointMarkerRole::OfficialSeedHash) + .count(); + Ok(serde_json::json!({ + "available": true, + "version": { + "id": record.id, + "completed_unix_seconds": record.completed_unix_seconds, + "resource_root": record.resource_root, + }, + "app_version": snapshot.app_version, + "bundle_version": snapshot.bundle_version, + "connection_group_name": snapshot.connection_group_name, + "addressables_root": snapshot.addressables_root, + "endpoint_count": snapshot.endpoints.len(), + "endpoint_marker_count": snapshot.endpoint_markers.len(), + "official_seed_hash_marker_count": official_seed_hash_marker_count, + "addressables_catalog_marker_count": snapshot.addressables_marker_checked_count(), + "launcher_metadata": snapshot.launcher_metadata, + "game_main_config": snapshot.game_main_config_bootstrap, + "snapshot_version": snapshot.snapshot_version, + })) +} + +/// `catalog.versions`:版本历史(当前/进行中/上一个可用/失败记录)。 +fn build_catalog_versions_report(state_dir: &Path) -> anyhow::Result { + let (status_file, version_state) = read_daemon_resource_state(state_dir)?; + let Some(state) = version_state else { + return Ok(serde_json::json!({ "available": false })); + }; + Ok(serde_json::json!({ + "available": true, + "resource_output_root": status_file.map(|status| status.resource_output_root), + "current": state.current_completed_version, + "in_progress": state.in_progress_version, + "previous": state.previous_available_version, + "failed": state.failed_versions, + "updated_unix_seconds": state.updated_unix_seconds, + })) +} + +/// `catalog.diff`:当前已发布 snapshot 相对上一个可用版本的差异。 +/// +/// 没有上一个版本(或其 snapshot 不可读,见 `previous_snapshot_missing`)时 +/// 按首次观察处理(`base_delta.is_initial = true`)。 +fn build_catalog_diff_report(state_dir: &Path) -> anyhow::Result { + let (_, version_state) = read_daemon_resource_state(state_dir)?; + let current_record = version_state + .as_ref() + .and_then(|state| state.current_completed_version.as_ref()); + let current_snapshot = + current_record.and_then(|record| read_snapshot(&record.snapshot_path).ok().flatten()); + let (Some(current_record), Some(current_snapshot)) = (current_record, current_snapshot) else { + return Ok(serde_json::json!({ "available": false })); + }; + let previous_record = version_state + .as_ref() + .and_then(|state| state.previous_available_version.as_ref()); + let previous_snapshot = + previous_record.and_then(|record| read_snapshot(&record.snapshot_path).ok().flatten()); + + let current_base = current_snapshot.base_snapshot(); + let previous_base = previous_snapshot + .as_ref() + .map(OfficialUpdateSnapshot::base_snapshot); + let base_delta = current_base.diff(previous_base.as_ref()); + let extended_delta = diff_extended_snapshot(¤t_snapshot, previous_snapshot.as_ref()); + let changed_urls = changed_endpoint_urls(&base_delta); + Ok(serde_json::json!({ + "available": true, + "current_version_id": current_record.id, + "previous_version_id": previous_record.map(|record| record.id.clone()), + "previous_snapshot_missing": previous_record.is_some() && previous_snapshot.is_none(), + "base_delta": base_delta, + "extended_delta": extended_delta, + "changed_endpoint_urls": changed_urls, + })) +} + +/// `resource.manifest`:当前版本下载 manifest 的分页查询。 +fn build_resource_manifest_report( + state_dir: &Path, + offset: usize, + limit: usize, +) -> anyhow::Result { + let (_, version_state) = read_daemon_resource_state(state_dir)?; + let current = version_state + .as_ref() + .and_then(|state| state.current_completed_version.as_ref()); + let Some(record) = current else { + return Ok(serde_json::json!({ "available": false })); + }; + let manifest = read_download_manifest_at(&record.resource_root).map_err(anyhow::Error::msg)?; + let Some(manifest) = manifest else { + return Ok(serde_json::json!({ + "available": false, + "resource_root": record.resource_root, + })); + }; + let total_entries = manifest.entries.len(); + // BTreeMap 按 URL 有序迭代,分页结果稳定。 + let entries: Vec<_> = manifest + .entries + .values() + .skip(offset) + .take(limit) + .cloned() + .collect(); + Ok(serde_json::json!({ + "available": true, + "resource_root": record.resource_root, + "manifest_version": manifest.version, + "total_entries": total_entries, + "offset": offset, + "limit": limit, + "entries": entries, + })) +} + #[cfg(unix)] fn write_json_rpc_response( stream: &mut UnixStream, @@ -1812,6 +2016,24 @@ fn rpc_bool_param(params: Option<&serde_json::Value>, key: &str) -> Option .and_then(serde_json::Value::as_bool) } +/// 分页参数:`offset` 默认 0;`limit` 默认 100,范围 1..=1000。 +fn rpc_page_params(params: Option<&serde_json::Value>) -> anyhow::Result<(usize, usize)> { + let offset = params + .and_then(|params| params.get("offset")) + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let limit = params + .and_then(|params| params.get("limit")) + .and_then(serde_json::Value::as_u64) + .unwrap_or(100); + let offset = usize::try_from(offset)?; + let limit = usize::try_from(limit)?; + if limit == 0 || limit > 1000 { + return Err(anyhow::anyhow!("limit 必须在 1..=1000 范围内")); + } + Ok((offset, limit)) +} + fn rpc_tail_param(params: Option<&serde_json::Value>, default: usize) -> anyhow::Result { let tail = params .and_then(|params| params.get("tail")) @@ -5356,13 +5578,17 @@ mod tests { #[test] fn is_pending_rpc_method_covers_planned_namespaces() { - assert!(is_pending_rpc_method("catalog.status")); assert!(is_pending_rpc_method("patch.apply")); assert!(is_pending_rpc_method("unityfs.inspect")); assert!(is_pending_rpc_method("task.create")); - // sync/verify 与 task.cancel/logs 已实现,不再是 pending。 + assert!(is_pending_rpc_method("resource.repair")); + // sync/verify、task.cancel/logs、catalog.* 与 resource.manifest 已实现, + // 不再是 pending。 assert!(!is_pending_rpc_method("resource.sync")); assert!(!is_pending_rpc_method("resource.verify")); + assert!(!is_pending_rpc_method("resource.manifest")); + assert!(!is_pending_rpc_method("catalog.status")); + assert!(!is_pending_rpc_method("catalog.refresh")); assert!(!is_pending_rpc_method("task.cancel")); assert!(!is_pending_rpc_method("task.logs")); assert!(!is_pending_rpc_method("daemon.status")); @@ -5434,7 +5660,7 @@ mod tests { let temp = tempfile::TempDir::new().unwrap(); let control = new_daemon_control(); let envelope = dispatch_rpc_method( - &rpc_request("catalog.status", None), + &rpc_request("patch.apply", None), temp.path(), &control, &test_task_context(), @@ -6276,4 +6502,311 @@ mod tests { command: vec!["bat".to_string(), "--daemon-child".to_string()], } } + + /// 写入 catalog 只读查询所需的完整 fixture:daemon 状态文件、版本目录内的 + /// snapshot、版本状态文件。返回当前版本目录路径。 + fn write_catalog_fixture( + state_dir: &Path, + output_root: &Path, + current_bundle: &str, + previous_bundle: Option<&str>, + ) -> PathBuf { + fs::create_dir_all(state_dir).unwrap(); + fs::create_dir_all(output_root).unwrap(); + write_daemon_status_file( + &daemon_status_path(state_dir), + &test_daemon_status_file(state_dir, output_root), + ) + .unwrap(); + + let make_version = |id: &str, bundle: &str| -> OfficialVersionRecord { + let version_dir = output_root.join("versions").join(id); + fs::create_dir_all(&version_dir).unwrap(); + let snapshot_path = version_dir.join("official-sync-snapshot.json"); + let snapshot = OfficialUpdateSnapshot { + snapshot_version: + bat_infrastructure::official_update::OFFICIAL_UPDATE_SNAPSHOT_VERSION, + connection_group_name: "Prod-Audit".to_string(), + app_version: "1.70.0".to_string(), + bundle_version: Some(bundle.to_string()), + addressables_root: "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture" + .to_string(), + endpoints: Vec::new(), + endpoint_markers: Vec::new(), + launcher_metadata: None, + game_main_config_bootstrap: None, + }; + bat_infrastructure::write_snapshot(&snapshot_path, &snapshot).unwrap(); + OfficialVersionRecord { + id: id.to_string(), + app_version: "1.70.0".to_string(), + bundle_version: Some(bundle.to_string()), + addressables_root: snapshot.addressables_root.clone(), + resource_root: version_dir.clone(), + snapshot_path, + staging_path: None, + version_path: Some(version_dir.clone()), + started_unix_seconds: None, + completed_unix_seconds: Some(unix_seconds_now()), + } + }; + + let current = make_version("v-current", current_bundle); + let current_dir = current.resource_root.clone(); + let state = OfficialVersionState { + current_completed_version: Some(current), + previous_available_version: previous_bundle + .map(|bundle| make_version("v-previous", bundle)), + ..OfficialVersionState::default() + }; + bat_infrastructure::write_version_state( + &output_root.join("official-version-state.json"), + &state, + ) + .unwrap(); + current_dir + } + + #[test] + fn dispatch_catalog_status_unavailable_without_state() { + let temp = tempfile::TempDir::new().unwrap(); + let envelope = dispatch_rpc_method( + &rpc_request("catalog.status", None), + temp.path(), + &new_daemon_control(), + &test_task_context(), + "req-cat-0".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["available"], false); + } + + #[test] + fn dispatch_catalog_status_reads_current_snapshot() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + let output_root = temp.path().join("output"); + write_catalog_fixture(&state_dir, &output_root, "bundle-b2", Some("bundle-b1")); + + let envelope = dispatch_rpc_method( + &rpc_request("catalog.status", None), + &state_dir, + &new_daemon_control(), + &test_task_context(), + "req-cat-1".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["available"], true); + assert_eq!(value["data"]["bundle_version"], "bundle-b2"); + assert_eq!(value["data"]["version"]["id"], "v-current"); + assert_eq!(value["data"]["endpoint_count"], 0); + assert_eq!(value["data"]["connection_group_name"], "Prod-Audit"); + } + + #[test] + fn dispatch_catalog_versions_lists_history() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + let output_root = temp.path().join("output"); + write_catalog_fixture(&state_dir, &output_root, "bundle-b2", Some("bundle-b1")); + + let envelope = dispatch_rpc_method( + &rpc_request("catalog.versions", None), + &state_dir, + &new_daemon_control(), + &test_task_context(), + "req-cat-2".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["available"], true); + assert_eq!(value["data"]["current"]["id"], "v-current"); + assert_eq!(value["data"]["previous"]["id"], "v-previous"); + assert_eq!(value["data"]["in_progress"], serde_json::Value::Null); + assert!(value["data"]["failed"].as_array().unwrap().is_empty()); + } + + #[test] + fn dispatch_catalog_diff_reports_bundle_change() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + let output_root = temp.path().join("output"); + write_catalog_fixture(&state_dir, &output_root, "bundle-b2", Some("bundle-b1")); + + let envelope = dispatch_rpc_method( + &rpc_request("catalog.diff", None), + &state_dir, + &new_daemon_control(), + &test_task_context(), + "req-cat-3".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["available"], true); + assert_eq!(value["data"]["current_version_id"], "v-current"); + assert_eq!(value["data"]["previous_version_id"], "v-previous"); + assert_eq!(value["data"]["previous_snapshot_missing"], false); + assert_eq!(value["data"]["base_delta"]["is_initial"], false); + assert_eq!(value["data"]["base_delta"]["bundle_version_changed"], true); + assert_eq!( + value["data"]["base_delta"]["addressables_root_changed"], + false + ); + } + + #[test] + fn dispatch_catalog_diff_initial_without_previous() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + let output_root = temp.path().join("output"); + write_catalog_fixture(&state_dir, &output_root, "bundle-b1", None); + + let envelope = dispatch_rpc_method( + &rpc_request("catalog.diff", None), + &state_dir, + &new_daemon_control(), + &test_task_context(), + "req-cat-4".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["available"], true); + assert_eq!( + value["data"]["previous_version_id"], + serde_json::Value::Null + ); + assert_eq!(value["data"]["base_delta"]["is_initial"], true); + } + + #[test] + fn dispatch_catalog_refresh_enqueues_task() { + let temp = tempfile::TempDir::new().unwrap(); + // 保留 rx 让 send 成功(不启动 worker,任务停留在 queued)。 + let (queue, rx) = mpsc::channel::(); + let tasks = DaemonTaskContext { + registry: TaskRegistry::new(), + queue, + base_config: OfficialUpdateConfig::default(), + }; + let envelope = dispatch_rpc_method( + &rpc_request("catalog.refresh", None), + temp.path(), + &new_daemon_control(), + &tasks, + "req-cat-5".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["status"], "accepted"); + assert_eq!(value["data"]["kind"], "catalog.refresh"); + let task_id = value["data"]["task_id"].as_str().unwrap(); + let record = tasks.registry.get(task_id).unwrap(); + assert_eq!(record.kind, "catalog.refresh"); + assert_eq!(record.status, "queued"); + + // 作业配置是 dry-run 计划检查,不下载不审计。 + let job = rx.try_recv().unwrap(); + assert_eq!(job.id, task_id); + assert!(job.config.dry_run); + assert!(job.config.plan); + assert!(!job.config.audit_local); + } + + #[test] + fn catalog_refresh_config_is_dry_run_plan_only() { + let base = OfficialUpdateConfig::default(); + let config = TaskKind::Refresh.build_config(&base, false); + assert!(config.dry_run); + assert!(config.plan); + assert!(!config.audit_local); + assert!(!config.repair); + assert!(!config.force); + let forced = TaskKind::Refresh.build_config(&base, true); + assert!(forced.force); + } + + #[test] + fn dispatch_resource_manifest_paginates() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + let output_root = temp.path().join("output"); + let current_dir = write_catalog_fixture(&state_dir, &output_root, "bundle-b1", None); + let manifest = serde_json::json!({ + "version": 1, + "entries": { + "https://prod-clientpatch.bluearchiveyostar.com/a": { + "url": "https://prod-clientpatch.bluearchiveyostar.com/a", + "destination": "a", + "bytes": 1, + "blake3": "aa", + }, + "https://prod-clientpatch.bluearchiveyostar.com/b": { + "url": "https://prod-clientpatch.bluearchiveyostar.com/b", + "destination": "b", + "bytes": 2, + "blake3": "bb", + }, + "https://prod-clientpatch.bluearchiveyostar.com/c": { + "url": "https://prod-clientpatch.bluearchiveyostar.com/c", + "destination": "c", + "bytes": 3, + "blake3": "cc", + }, + }, + }); + fs::write( + current_dir.join("official-download-manifest.json"), + serde_json::to_vec(&manifest).unwrap(), + ) + .unwrap(); + + let envelope = dispatch_rpc_method( + &rpc_request( + "resource.manifest", + Some(serde_json::json!({ "offset": 1, "limit": 2 })), + ), + &state_dir, + &new_daemon_control(), + &test_task_context(), + "req-man-1".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["available"], true); + assert_eq!(value["data"]["total_entries"], 3); + assert_eq!(value["data"]["offset"], 1); + let entries = value["data"]["entries"].as_array().unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0]["destination"], "b"); + assert_eq!(entries[1]["destination"], "c"); + + // 非法 limit → 参数错误。 + let envelope = dispatch_rpc_method( + &rpc_request("resource.manifest", Some(serde_json::json!({ "limit": 0 }))), + &state_dir, + &new_daemon_control(), + &test_task_context(), + "req-man-2".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], false); + assert_eq!(value["error"]["code"], "BAT-ERR-700002"); + } + + #[test] + fn dispatch_resource_repair_reports_not_implemented() { + let temp = tempfile::TempDir::new().unwrap(); + let envelope = dispatch_rpc_method( + &rpc_request("resource.repair", None), + temp.path(), + &new_daemon_control(), + &test_task_context(), + "req-rep-1".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], false); + assert_eq!(value["error"]["code"], "BAT-ERR-700003"); + } } diff --git a/infrastructure/src/lib.rs b/infrastructure/src/lib.rs index b0247f2..dbf5e57 100644 --- a/infrastructure/src/lib.rs +++ b/infrastructure/src/lib.rs @@ -32,10 +32,12 @@ pub use import::{ ResourceImportService, }; pub use official_download::{ - DownloadError, OfficialLocalManifestAuditItem, OfficialLocalManifestAuditReport, - OfficialLocalManifestAuditStatus, OfficialLocalVerificationReport, OfficialResourcePullItem, - OfficialResourcePullProgress, OfficialResourcePullProgressKind, OfficialResourcePullReport, - OfficialResourcePullService, OfficialResourcePullStatus, + read_download_manifest_at, DownloadError, OfficialDownloadManifest, + OfficialDownloadManifestEntry, OfficialLocalManifestAuditItem, + OfficialLocalManifestAuditReport, OfficialLocalManifestAuditStatus, + OfficialLocalVerificationReport, OfficialResourcePullItem, OfficialResourcePullProgress, + OfficialResourcePullProgressKind, OfficialResourcePullReport, OfficialResourcePullService, + OfficialResourcePullStatus, }; pub use official_game_main_config::OfficialGameMainConfigBootstrapService; pub use official_launcher::{ diff --git a/infrastructure/src/official_download.rs b/infrastructure/src/official_download.rs index a858c85..6fc0528 100644 --- a/infrastructure/src/official_download.rs +++ b/infrastructure/src/official_download.rs @@ -1500,12 +1500,15 @@ fn strong_seed_hash_kind( } } +/// 官方下载 manifest:按完整 URL 为键记录每个已下载资源的本地校验信息。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -struct OfficialDownloadManifest { +pub struct OfficialDownloadManifest { + /// Manifest 结构版本。 #[serde(default = "default_download_manifest_version")] - version: u32, + pub version: u32, + /// 按 URL 为键的资源条目。 #[serde(default)] - entries: BTreeMap, + pub entries: BTreeMap, } impl Default for OfficialDownloadManifest { @@ -1517,12 +1520,41 @@ impl Default for OfficialDownloadManifest { } } +/// 官方下载 manifest 中的单个资源条目。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -struct OfficialDownloadManifestEntry { - url: String, - destination: String, - bytes: u64, - blake3: String, +pub struct OfficialDownloadManifestEntry { + /// 官方资源 URL。 + pub url: String, + /// 相对资源根目录的落盘路径。 + pub destination: String, + /// 文件字节数。 + pub bytes: u64, + /// 文件 BLAKE3 摘要(hex)。 + pub blake3: String, +} + +/// 读取指定资源根目录下的官方下载 manifest(daemon RPC 只读查询用)。 +/// +/// 文件缺失返回 `Ok(None)`;符号链接、解析失败或版本不支持返回 `Err`。 +pub fn read_download_manifest_at( + resource_root: &Path, +) -> Result, String> { + let path = resource_root.join(DOWNLOAD_MANIFEST_FILE); + let Some(bytes) = read_file_no_symlink(&path, "下载 manifest")? else { + return Ok(None); + }; + + let manifest: OfficialDownloadManifest = serde_json::from_slice(&bytes) + .map_err(|error| format!("解析下载 manifest 失败 {}:{error}", path.display()))?; + if manifest.version != DOWNLOAD_MANIFEST_VERSION { + return Err(format!( + "不支持的下载 manifest 版本 {},文件 {}", + manifest.version, + path.display() + )); + } + + Ok(Some(manifest)) } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -1797,6 +1829,27 @@ mod tests { use std::fs; use tempfile::TempDir; + #[test] + fn read_download_manifest_at_handles_missing_and_bad_version() { + let temp = TempDir::new().unwrap(); + // 文件缺失:Ok(None)。 + assert!(read_download_manifest_at(temp.path()).unwrap().is_none()); + + // 正常 manifest:读取条目。 + let path = temp.path().join(DOWNLOAD_MANIFEST_FILE); + fs::write( + &path, + br#"{"version":1,"entries":{"https://a":{"url":"https://a","destination":"a","bytes":1,"blake3":"aa"}}}"#, + ) + .unwrap(); + let manifest = read_download_manifest_at(temp.path()).unwrap().unwrap(); + assert_eq!(manifest.entries.len(), 1); + + // 不支持的版本:Err。 + fs::write(&path, br#"{"version":999,"entries":{}}"#).unwrap(); + assert!(read_download_manifest_at(temp.path()).is_err()); + } + fn discovery_plan() -> YostarJpResourceDiscoveryPlan { YostarJpResourceDiscoveryPlan { connection_group_name: "Prod-Audit".to_string(),