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
+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(),