feat(daemon): 实现 catalog.* 命名空间与 resource.manifest(issue #1)

补齐 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 10:10:03 -07:00
co-authored by Claude Fable 5
parent e9b7ce219f
commit 11b19683cf
4 changed files with 636 additions and 31 deletions
+550 -17
View File
@@ -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<serde_json::Value> {
/// 读取 daemon 状态文件与资源根目录的版本状态(resource/catalog 只读查询共用)。
fn read_daemon_resource_state(
state_dir: &Path,
) -> anyhow::Result<(Option<DaemonStatusFile>, Option<OfficialVersionState>)> {
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<serde_json::Value> {
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<serde_json::V
}))
}
/// `catalog.status`:当前已发布版本的 catalog 概览(读取其 snapshot)。
///
/// 没有已发布版本或 snapshot 不可读时返回 `available: false`(正常状态,
/// 不视为错误,便于 Go 层直接分支)。
fn build_catalog_status_report(state_dir: &Path) -> anyhow::Result<serde_json::Value> {
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<serde_json::Value> {
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<serde_json::Value> {
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(&current_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<serde_json::Value> {
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<bool>
.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<usize> {
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::<TaskJob>();
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");
}
}
+6 -4
View File
@@ -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::{
+61 -8
View File
@@ -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<String, OfficialDownloadManifestEntry>,
pub entries: BTreeMap<String, OfficialDownloadManifestEntry>,
}
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<Option<OfficialDownloadManifest>, 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(),