mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 04:06:44 +08:00
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:
+19
-2
@@ -262,16 +262,23 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon
|
|||||||
| `resource.state` | ✅ | 资源发布根 + 版本状态 + 上次同步结果 |
|
| `resource.state` | ✅ | 资源发布根 + 版本状态 + 上次同步结果 |
|
||||||
| `resource.sync` | ✅ | 触发同步任务(`params.force`),返回 `task_id` |
|
| `resource.sync` | ✅ | 触发同步任务(`params.force`),返回 `task_id` |
|
||||||
| `resource.verify` | ✅ | 触发校验任务(dry-run + audit),返回 `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.status` | ✅ | 查询任务(`params.task_id`) |
|
||||||
| `task.list` | ✅ | 列出全部任务(最新在前) |
|
| `task.list` | ✅ | 列出全部任务(最新在前) |
|
||||||
| `task.cancel` | ✅ | 请求取消任务(`params.task_id`);协作式,在同步检查点生效 |
|
| `task.cancel` | ✅ | 请求取消任务(`params.task_id`);协作式,在同步检查点生效 |
|
||||||
| `task.logs` | ✅ | 返回任务的进度日志(`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) |
|
| 未知方法 | — | `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-<pid>-<seq>", "kind": "resource.sync" }`(`status: "accepted"`),实际执行由后台任务 worker 串行完成,通过 `task.status` / `task.list` 轮询。任务记录:
|
`resource.sync` / `resource.verify` / `catalog.refresh` 是**异步任务**:入队即返回 `{ "task_id": "task-<pid>-<seq>", "kind": "resource.sync" }`(`status: "accepted"`),实际执行由后台任务 worker 串行完成,通过 `task.status` / `task.list` 轮询。任务记录:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "id": "task-1234-1", "kind": "resource.sync",
|
{ "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' \
|
printf '{"jsonrpc":"2.0","id":2,"method":"task.status","params":{"task_id":"task-1234-1"}}\n' \
|
||||||
| socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock
|
| 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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
use bat_adapters::official::yostar_jp::PatchPlatform;
|
use bat_adapters::official::yostar_jp::PatchPlatform;
|
||||||
use bat_core::{ApiError, ErrorCode};
|
use bat_core::{ApiError, ErrorCode};
|
||||||
use bat_infrastructure::{
|
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,
|
read_version_state, redact_proxy_url, resolve_curl_proxy, validate_output_root,
|
||||||
validate_runtime_state_dir, write_file_atomic, CurlProxyConfig, CurlProxyMode,
|
validate_runtime_state_dir, write_file_atomic, CurlProxyConfig, CurlProxyMode,
|
||||||
OfficialFailedVersionRecord, OfficialServerInfoSource, OfficialUpdateConfig,
|
OfficialEndpointMarkerRole, OfficialFailedVersionRecord, OfficialServerInfoSource,
|
||||||
OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateStatus,
|
OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService,
|
||||||
OfficialVerificationSummary, OfficialVersionRecord, OfficialVersionState, PRIVATE_FILE_MODE,
|
OfficialUpdateSnapshot, OfficialUpdateStatus, OfficialVerificationSummary,
|
||||||
|
OfficialVersionRecord, OfficialVersionState, PRIVATE_FILE_MODE,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
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_STATE: &str = "resource.state";
|
||||||
const RPC_METHOD_RESOURCE_SYNC: &str = "resource.sync";
|
const RPC_METHOD_RESOURCE_SYNC: &str = "resource.sync";
|
||||||
const RPC_METHOD_RESOURCE_VERIFY: &str = "resource.verify";
|
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_STATUS: &str = "task.status";
|
||||||
const RPC_METHOD_TASK_LIST: &str = "task.list";
|
const RPC_METHOD_TASK_LIST: &str = "task.list";
|
||||||
const RPC_METHOD_TASK_CANCEL: &str = "task.cancel";
|
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;
|
const MAX_TASK_LOG_LINES: usize = 200;
|
||||||
|
|
||||||
/// 任务类型:目前覆盖官方同步与校验。
|
/// 任务类型:目前覆盖官方同步、校验与 catalog 更新检查。
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
enum TaskKind {
|
enum TaskKind {
|
||||||
Sync,
|
Sync,
|
||||||
Verify,
|
Verify,
|
||||||
|
/// catalog 更新检查:只做发现 + 拉取计划(dry-run),不下载不审计。
|
||||||
|
Refresh,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TaskKind {
|
impl TaskKind {
|
||||||
@@ -660,6 +669,7 @@ impl TaskKind {
|
|||||||
match self {
|
match self {
|
||||||
Self::Sync => RPC_METHOD_RESOURCE_SYNC,
|
Self::Sync => RPC_METHOD_RESOURCE_SYNC,
|
||||||
Self::Verify => RPC_METHOD_RESOURCE_VERIFY,
|
Self::Verify => RPC_METHOD_RESOURCE_VERIFY,
|
||||||
|
Self::Refresh => RPC_METHOD_CATALOG_REFRESH,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -678,6 +688,13 @@ impl TaskKind {
|
|||||||
config.repair = false;
|
config.repair = false;
|
||||||
config.force = false;
|
config.force = false;
|
||||||
}
|
}
|
||||||
|
Self::Refresh => {
|
||||||
|
config.dry_run = true;
|
||||||
|
config.plan = true;
|
||||||
|
config.audit_local = false;
|
||||||
|
config.repair = false;
|
||||||
|
config.force = force;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
config
|
config
|
||||||
}
|
}
|
||||||
@@ -969,8 +986,11 @@ fn canonical_rpc_method(method: &str) -> &str {
|
|||||||
|
|
||||||
/// 判断方法是否属于已规划但尚未实现的命名空间/动作(返回 not_implemented 而非 unknown)。
|
/// 判断方法是否属于已规划但尚未实现的命名空间/动作(返回 not_implemented 而非 unknown)。
|
||||||
fn is_pending_rpc_method(method: &str) -> bool {
|
fn is_pending_rpc_method(method: &str) -> bool {
|
||||||
matches!(method, "task.create")
|
// task.create:任务统一由 resource.sync / resource.verify / catalog.refresh
|
||||||
|| method.starts_with("catalog.")
|
// 等语义方法创建,通用创建接口暂不开放。
|
||||||
|
// resource.repair:引擎尚无独立修复模式(sync 自带审计+重下)。
|
||||||
|
// patch.* / unityfs.*:被 bat-patch / bat-assetbundle 引擎阻塞。
|
||||||
|
matches!(method, "task.create" | "resource.repair")
|
||||||
|| method.starts_with("patch.")
|
|| method.starts_with("patch.")
|
||||||
|| method.starts_with("unityfs.")
|
|| method.starts_with("unityfs.")
|
||||||
}
|
}
|
||||||
@@ -1603,6 +1623,45 @@ fn dispatch_rpc_method(
|
|||||||
RPC_METHOD_RESOURCE_VERIFY => {
|
RPC_METHOD_RESOURCE_VERIFY => {
|
||||||
enqueue_task_envelope(tasks, TaskKind::Verify, false, request_id)
|
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 => {
|
RPC_METHOD_TASK_STATUS => {
|
||||||
let task_id = rpc_task_id_param(request);
|
let task_id = rpc_task_id_param(request);
|
||||||
match tasks.registry.get(task_id) {
|
match tasks.registry.get(task_id) {
|
||||||
@@ -1747,17 +1806,28 @@ fn rpc_ack_value(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 构建 `resource.state` 数据:资源发布根、版本状态、上次同步结果。
|
/// 构建 `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 status_file = read_daemon_status_file(&daemon_status_path(state_dir))?;
|
||||||
let resource_output_root = status_file
|
let version_state = status_file
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|status| status.resource_output_root.clone());
|
.map(|status| {
|
||||||
let version_state = resource_output_root
|
status
|
||||||
.as_ref()
|
.resource_output_root
|
||||||
.map(|root| root.join("official-version-state.json"))
|
.join("official-version-state.json")
|
||||||
|
})
|
||||||
.and_then(|path| read_version_state(&path).ok().flatten());
|
.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!({
|
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,
|
"version_state": version_state,
|
||||||
"last_update_status": status_file
|
"last_update_status": status_file
|
||||||
.as_ref()
|
.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(¤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<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)]
|
#[cfg(unix)]
|
||||||
fn write_json_rpc_response(
|
fn write_json_rpc_response(
|
||||||
stream: &mut UnixStream,
|
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)
|
.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> {
|
fn rpc_tail_param(params: Option<&serde_json::Value>, default: usize) -> anyhow::Result<usize> {
|
||||||
let tail = params
|
let tail = params
|
||||||
.and_then(|params| params.get("tail"))
|
.and_then(|params| params.get("tail"))
|
||||||
@@ -5356,13 +5578,17 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn is_pending_rpc_method_covers_planned_namespaces() {
|
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("patch.apply"));
|
||||||
assert!(is_pending_rpc_method("unityfs.inspect"));
|
assert!(is_pending_rpc_method("unityfs.inspect"));
|
||||||
assert!(is_pending_rpc_method("task.create"));
|
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.sync"));
|
||||||
assert!(!is_pending_rpc_method("resource.verify"));
|
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.cancel"));
|
||||||
assert!(!is_pending_rpc_method("task.logs"));
|
assert!(!is_pending_rpc_method("task.logs"));
|
||||||
assert!(!is_pending_rpc_method("daemon.status"));
|
assert!(!is_pending_rpc_method("daemon.status"));
|
||||||
@@ -5434,7 +5660,7 @@ mod tests {
|
|||||||
let temp = tempfile::TempDir::new().unwrap();
|
let temp = tempfile::TempDir::new().unwrap();
|
||||||
let control = new_daemon_control();
|
let control = new_daemon_control();
|
||||||
let envelope = dispatch_rpc_method(
|
let envelope = dispatch_rpc_method(
|
||||||
&rpc_request("catalog.status", None),
|
&rpc_request("patch.apply", None),
|
||||||
temp.path(),
|
temp.path(),
|
||||||
&control,
|
&control,
|
||||||
&test_task_context(),
|
&test_task_context(),
|
||||||
@@ -6276,4 +6502,311 @@ mod tests {
|
|||||||
command: vec!["bat".to_string(), "--daemon-child".to_string()],
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,10 +32,12 @@ pub use import::{
|
|||||||
ResourceImportService,
|
ResourceImportService,
|
||||||
};
|
};
|
||||||
pub use official_download::{
|
pub use official_download::{
|
||||||
DownloadError, OfficialLocalManifestAuditItem, OfficialLocalManifestAuditReport,
|
read_download_manifest_at, DownloadError, OfficialDownloadManifest,
|
||||||
OfficialLocalManifestAuditStatus, OfficialLocalVerificationReport, OfficialResourcePullItem,
|
OfficialDownloadManifestEntry, OfficialLocalManifestAuditItem,
|
||||||
OfficialResourcePullProgress, OfficialResourcePullProgressKind, OfficialResourcePullReport,
|
OfficialLocalManifestAuditReport, OfficialLocalManifestAuditStatus,
|
||||||
OfficialResourcePullService, OfficialResourcePullStatus,
|
OfficialLocalVerificationReport, OfficialResourcePullItem, OfficialResourcePullProgress,
|
||||||
|
OfficialResourcePullProgressKind, OfficialResourcePullReport, OfficialResourcePullService,
|
||||||
|
OfficialResourcePullStatus,
|
||||||
};
|
};
|
||||||
pub use official_game_main_config::OfficialGameMainConfigBootstrapService;
|
pub use official_game_main_config::OfficialGameMainConfigBootstrapService;
|
||||||
pub use official_launcher::{
|
pub use official_launcher::{
|
||||||
|
|||||||
@@ -1500,12 +1500,15 @@ fn strong_seed_hash_kind(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 官方下载 manifest:按完整 URL 为键记录每个已下载资源的本地校验信息。
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
struct OfficialDownloadManifest {
|
pub struct OfficialDownloadManifest {
|
||||||
|
/// Manifest 结构版本。
|
||||||
#[serde(default = "default_download_manifest_version")]
|
#[serde(default = "default_download_manifest_version")]
|
||||||
version: u32,
|
pub version: u32,
|
||||||
|
/// 按 URL 为键的资源条目。
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
entries: BTreeMap<String, OfficialDownloadManifestEntry>,
|
pub entries: BTreeMap<String, OfficialDownloadManifestEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for OfficialDownloadManifest {
|
impl Default for OfficialDownloadManifest {
|
||||||
@@ -1517,12 +1520,41 @@ impl Default for OfficialDownloadManifest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 官方下载 manifest 中的单个资源条目。
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
struct OfficialDownloadManifestEntry {
|
pub struct OfficialDownloadManifestEntry {
|
||||||
url: String,
|
/// 官方资源 URL。
|
||||||
destination: String,
|
pub url: String,
|
||||||
bytes: u64,
|
/// 相对资源根目录的落盘路径。
|
||||||
blake3: 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)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -1797,6 +1829,27 @@ mod tests {
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use tempfile::TempDir;
|
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 {
|
fn discovery_plan() -> YostarJpResourceDiscoveryPlan {
|
||||||
YostarJpResourceDiscoveryPlan {
|
YostarJpResourceDiscoveryPlan {
|
||||||
connection_group_name: "Prod-Audit".to_string(),
|
connection_group_name: "Prod-Audit".to_string(),
|
||||||
|
|||||||
Reference in New Issue
Block a user