feat(rpc): 补齐 issue #1 资源后端接口

This commit is contained in:
2026-07-24 15:55:37 +08:00
parent a729615a48
commit ecda08ed97
9 changed files with 249 additions and 70 deletions
+220 -48
View File
@@ -641,13 +641,18 @@ struct DaemonRpcAck {
// 规范方法名采用国际惯例的 `<namespace>.<action>`。`bat.*` 保留为向后兼容别名。
const RPC_METHOD_STATUS: &str = "daemon.status";
const RPC_METHOD_STOP: &str = "daemon.stop";
const RPC_METHOD_RESTART: &str = "daemon.restart";
const RPC_METHOD_RELOAD: &str = "daemon.reload";
const RPC_METHOD_REFRESH: &str = "daemon.refresh";
const RPC_METHOD_LOGS: &str = "daemon.logs";
const RPC_METHOD_DOCTOR: &str = "daemon.doctor";
const RPC_METHOD_CLEAN_STABLE: &str = "daemon.clean-stable";
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_REPAIR: &str = "resource.repair";
const RPC_METHOD_RESOURCE_MANIFEST: &str = "resource.manifest";
const RPC_METHOD_RESOURCE_LIST: &str = "resource.list";
const RPC_METHOD_CATALOG_STATUS: &str = "catalog.status";
const RPC_METHOD_CATALOG_VERSIONS: &str = "catalog.versions";
const RPC_METHOD_CATALOG_DIFF: &str = "catalog.diff";
@@ -667,6 +672,7 @@ const MAX_TASK_LOG_LINES: usize = 200;
enum TaskKind {
Sync,
Verify,
Repair,
/// catalog 更新检查:只做发现 + 拉取计划(dry-run),不下载不审计。
Refresh,
}
@@ -676,6 +682,7 @@ impl TaskKind {
match self {
Self::Sync => RPC_METHOD_RESOURCE_SYNC,
Self::Verify => RPC_METHOD_RESOURCE_VERIFY,
Self::Repair => RPC_METHOD_RESOURCE_REPAIR,
Self::Refresh => RPC_METHOD_CATALOG_REFRESH,
}
}
@@ -695,6 +702,12 @@ impl TaskKind {
config.repair = false;
config.force = false;
}
Self::Repair => {
config.dry_run = false;
config.audit_local = true;
config.repair = true;
config.force = false;
}
Self::Refresh => {
config.dry_run = true;
config.plan = true;
@@ -800,6 +813,7 @@ fn task_kind_static(kind: &str) -> Option<&'static str> {
match kind {
RPC_METHOD_RESOURCE_SYNC => Some(RPC_METHOD_RESOURCE_SYNC),
RPC_METHOD_RESOURCE_VERIFY => Some(RPC_METHOD_RESOURCE_VERIFY),
RPC_METHOD_RESOURCE_REPAIR => Some(RPC_METHOD_RESOURCE_REPAIR),
RPC_METHOD_CATALOG_REFRESH => Some(RPC_METHOD_CATALOG_REFRESH),
_ => None,
}
@@ -1233,21 +1247,28 @@ fn canonical_rpc_method(method: &str) -> &str {
match method {
"bat.status" => RPC_METHOD_STATUS,
"bat.stop" => RPC_METHOD_STOP,
"bat.restart" => RPC_METHOD_RESTART,
"bat.reload" => RPC_METHOD_RELOAD,
"bat.refresh" => RPC_METHOD_REFRESH,
"bat.logs" => RPC_METHOD_LOGS,
"bat.doctor" => RPC_METHOD_DOCTOR,
"bat.clean-stable" => RPC_METHOD_CLEAN_STABLE,
RPC_METHOD_RESOURCE_LIST => RPC_METHOD_RESOURCE_MANIFEST,
other => other,
}
}
/// 判断方法是否属于已规划但尚未实现的命名空间/动作(返回 not_implemented 而非 unknown)。
fn is_pending_rpc_method(method: &str) -> bool {
// task.create:任务统一由 resource.sync / resource.verify / catalog.refresh
// task.create:任务统一由 resource.sync / resource.verify / resource.repair / catalog.refresh
// 等语义方法创建,通用创建接口暂不开放。
// resource.repair:引擎尚无独立修复模式(sync 自带审计+重下)。
// daemon.restart / daemon.clean-stableCLI 侧按进程生命周期处理;
// live RPC 内不做自重启或在线清理。
// patch.* / unityfs.*:被 bat-patch / bat-assetbundle 引擎阻塞。
matches!(method, "task.create" | "resource.repair")
|| method.starts_with("patch.")
matches!(
method,
"task.create" | RPC_METHOD_RESTART | RPC_METHOD_CLEAN_STABLE
) || method.starts_with("patch.")
|| method.starts_with("unityfs.")
}
@@ -1831,6 +1852,12 @@ fn dispatch_rpc_method(
.and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)),
)
}
RPC_METHOD_DOCTOR => rpc_envelope_from_result(
request_id,
"daemon.doctor",
build_doctor_report(state_dir, &tasks.base_config)
.and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)),
),
RPC_METHOD_STOP => {
daemon_control_mark_stop_requested(control);
let _ = update_daemon_state_only(state_dir, "stopping");
@@ -1879,6 +1906,9 @@ fn dispatch_rpc_method(
RPC_METHOD_RESOURCE_VERIFY => {
enqueue_task_envelope(tasks, TaskKind::Verify, false, request_id)
}
RPC_METHOD_RESOURCE_REPAIR => {
enqueue_task_envelope(tasks, TaskKind::Repair, false, request_id)
}
RPC_METHOD_RESOURCE_MANIFEST => {
let (offset, limit) = match rpc_page_params(request.params.as_ref()) {
Ok(page) => page,
@@ -3006,19 +3036,27 @@ struct CommandReport<T> {
}
fn run_sync_command(options: &CliOptions, command_name: &'static str) -> anyhow::Result<()> {
if refresh_should_use_daemon_rpc(options, command_name)
&& daemon_rpc_available(&options.state_dir)
if let Some(rpc_method) = sync_command_rpc_method(options, command_name)
.filter(|_| daemon_rpc_available(&options.state_dir))
{
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
let report = daemon_rpc_call(
&options.state_dir,
RPC_METHOD_REFRESH,
Some(serde_json::json!({ "force": options.config.force })),
)?;
let params = if rpc_method == RPC_METHOD_REFRESH {
Some(serde_json::json!({ "force": options.config.force }))
} else {
None
};
let report = daemon_rpc_call(&options.state_dir, rpc_method, params)?;
print_json_value(options.output_format, &report)?;
return Ok(());
}
run_sync_command_foreground(options, command_name)
}
fn run_sync_command_foreground(
options: &CliOptions,
command_name: &'static str,
) -> anyhow::Result<()> {
assert_no_live_daemon_output_conflict(options, command_name)?;
let mut config = options.config.clone();
if command_name == "repair" {
@@ -3044,13 +3082,9 @@ fn run_sync_command(options: &CliOptions, command_name: &'static str) -> anyhow:
Ok(())
}
fn refresh_should_use_daemon_rpc(options: &CliOptions, command_name: &str) -> bool {
if command_name != "refresh" {
return false;
}
fn sync_command_rpc_method(options: &CliOptions, command_name: &str) -> Option<&'static str> {
let defaults = OfficialUpdateConfig::default();
options.command == CliCommand::Refresh
&& !options.watch
let default_daemon_shape = !options.watch
&& !options.daemon
&& !options.daemon_child
&& !options.output_explicit
@@ -3068,7 +3102,19 @@ fn refresh_should_use_daemon_rpc(options: &CliOptions, command_name: &str) -> bo
&& !options.config.dry_run
&& !options.config.plan
&& options.config.audit_local == defaults.audit_local
&& options.config.repair == defaults.repair
&& options.config.repair == defaults.repair;
if !default_daemon_shape {
return None;
}
match (options.command, command_name) {
(CliCommand::Refresh, "refresh") => Some(RPC_METHOD_REFRESH),
(CliCommand::Repair, "repair") if !options.config.force => Some(RPC_METHOD_RESOURCE_REPAIR),
_ => None,
}
}
fn refresh_should_use_daemon_rpc(options: &CliOptions, command_name: &str) -> bool {
sync_command_rpc_method(options, command_name) == Some(RPC_METHOD_REFRESH)
}
fn print_report<T>(format: OutputFormat, report: &T) -> anyhow::Result<()>
@@ -3806,30 +3852,29 @@ struct DoctorReport {
checks: Vec<DoctorCheck>,
}
fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> {
fn build_doctor_report(
state_dir: &Path,
config: &OfficialUpdateConfig,
) -> anyhow::Result<DoctorReport> {
let mut checks = vec![
path_check("state_dir", &options.state_dir, "后台状态目录可用"),
path_check(
"output_root",
&options.config.output_root,
"资源输出目录可用",
),
path_check("state_dir", state_dir, "后台状态目录可用"),
path_check("output_root", &config.output_root, "资源输出目录可用"),
safety_check(
"output_root_safety",
validate_output_root(&options.config.output_root),
validate_output_root(&config.output_root),
"资源输出目录安全边界通过",
),
safety_check(
"state_dir_safety",
validate_runtime_state_dir(&options.state_dir),
validate_runtime_state_dir(state_dir),
"后台状态目录安全边界通过",
),
command_check("curl", &options.config.curl_command),
proxy_check(&options.config.curl_proxy),
command_check("unzip", &options.config.unzip_command),
command_check("curl", &config.curl_command),
proxy_check(&config.curl_proxy),
command_check("unzip", &config.unzip_command),
];
let pid_path = daemon_pid_path(&options.state_dir);
let pid_path = daemon_pid_path(state_dir);
let daemon_running = read_pid_file(&pid_path)
.ok()
.flatten()
@@ -3857,9 +3902,9 @@ fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> {
}),
}
let socket_path = daemon_socket_path(&options.state_dir);
let socket_path = daemon_socket_path(state_dir);
let socket_exists = daemon_socket_path_exists(&socket_path).unwrap_or(false);
let socket_available = daemon_rpc_available(&options.state_dir);
let socket_available = daemon_rpc_available(state_dir);
checks.push(DoctorCheck {
name: "daemon_rpc",
ok: if daemon_running {
@@ -3884,7 +3929,7 @@ fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> {
},
});
let lock_path = options.config.lock_path();
let lock_path = config.lock_path();
checks.push(match classify_pid_lock_file(&lock_path)? {
PidLockState::Missing => DoctorCheck {
name: "resource_lock",
@@ -3917,7 +3962,7 @@ fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> {
},
});
let control_lock_path = daemon_control_lock_path(&options.state_dir);
let control_lock_path = daemon_control_lock_path(state_dir);
checks.push(match classify_pid_lock_file(&control_lock_path)? {
PidLockState::Missing => DoctorCheck {
name: "daemon_control_lock",
@@ -3950,7 +3995,7 @@ fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> {
},
});
let status_path = daemon_status_path(&options.state_dir);
let status_path = daemon_status_path(state_dir);
// ok 与 message 从同一次解析结果派生,避免“ok=false 却提示可解析”的自相矛盾。
let daemon_status_result = read_daemon_status_file(&status_path);
checks.push(DoctorCheck {
@@ -3964,7 +4009,7 @@ fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> {
});
let healthy = checks.iter().all(|check| check.ok);
let report = DoctorReport {
Ok(DoctorReport {
command: "doctor",
status: if healthy { "ok" } else { "issues_found" },
message: if healthy {
@@ -3974,7 +4019,12 @@ fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> {
},
healthy,
checks,
};
})
}
fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> {
let report = build_doctor_report(&options.state_dir, &options.config)?;
let healthy = report.healthy;
print_report(options.output_format, &report)?;
Ok(healthy)
}
@@ -5919,11 +5969,13 @@ mod tests {
"404",
));
});
let repair_id = registry.create(TaskKind::Repair);
registry.update(&repair_id, |record| record.status = "succeeded");
drop(registry);
// 重启:恢复历史;running 任务标记中断;错误码经持久化往返保留。
let (registry, summary) = TaskRegistry::with_persistence(state_dir);
assert!(summary.contains("恢复任务历史 3"), "{summary}");
assert!(summary.contains("恢复任务历史 4"), "{summary}");
assert!(summary.contains("标记中断 1 条"), "{summary}");
let finished = registry.get(&finished_id).unwrap();
assert_eq!(finished.status, "succeeded");
@@ -5944,16 +5996,19 @@ mod tests {
failed.error.as_ref().unwrap().code(),
ErrorCode::HTTP_NOT_FOUND
);
let repair = registry.get(&repair_id).unwrap();
assert_eq!(repair.kind, "resource.repair");
assert_eq!(repair.status, "succeeded");
// seq 持久化:重启后(本测试内 pid 相同)新任务不与历史撞 ID。
let new_id = registry.create(TaskKind::Sync);
assert!(
[&finished_id, &running_id, &failed_id]
[&finished_id, &running_id, &failed_id, &repair_id]
.iter()
.all(|id| **id != new_id),
"新任务 ID {new_id} 与历史撞号"
);
assert_eq!(registry.list().len(), 4);
assert_eq!(registry.list().len(), 5);
}
#[test]
@@ -6368,8 +6423,13 @@ mod tests {
#[test]
fn canonical_rpc_method_resolves_aliases() {
assert_eq!(canonical_rpc_method("bat.status"), RPC_METHOD_STATUS);
assert_eq!(canonical_rpc_method("bat.doctor"), RPC_METHOD_DOCTOR);
assert_eq!(canonical_rpc_method("bat.refresh"), RPC_METHOD_REFRESH);
assert_eq!(canonical_rpc_method("daemon.status"), RPC_METHOD_STATUS);
assert_eq!(
canonical_rpc_method("resource.list"),
RPC_METHOD_RESOURCE_MANIFEST
);
assert_eq!(canonical_rpc_method("resource.state"), "resource.state");
assert_eq!(canonical_rpc_method("unknown.method"), "unknown.method");
}
@@ -6379,17 +6439,20 @@ mod tests {
assert!(is_pending_rpc_method("patch.apply"));
assert!(is_pending_rpc_method("unityfs.inspect"));
assert!(is_pending_rpc_method("task.create"));
assert!(is_pending_rpc_method("resource.repair"));
// sync/verify、task.cancel/logs、catalog.* 与 resource.manifest 已实现,
// 不再是 pending
assert!(is_pending_rpc_method("daemon.restart"));
assert!(is_pending_rpc_method("daemon.clean-stable"));
// sync/verify/repair、task.cancel/logs、catalog.* 与 resource.manifest 已实现
assert!(!is_pending_rpc_method("resource.sync"));
assert!(!is_pending_rpc_method("resource.verify"));
assert!(!is_pending_rpc_method("resource.repair"));
assert!(!is_pending_rpc_method("resource.manifest"));
assert!(!is_pending_rpc_method("resource.list"));
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"));
assert!(!is_pending_rpc_method("daemon.doctor"));
}
#[test]
@@ -6502,6 +6565,36 @@ mod tests {
assert_eq!(value["error"]["code"], "BAT-ERR-700004");
}
#[test]
fn dispatch_daemon_doctor_returns_report() {
let temp = tempfile::TempDir::new().unwrap();
let state_dir = temp.path().join("state");
let output_root = temp.path().join("output");
let mut base_config = OfficialUpdateConfig::default();
base_config.output_root = output_root;
let (queue, _rx) = mpsc::channel::<TaskJob>();
let context = DaemonTaskContext {
registry: TaskRegistry::new(),
queue,
base_config,
};
let envelope = dispatch_rpc_method(
&rpc_request("daemon.doctor", None),
&state_dir,
&new_daemon_control(),
&context,
"req-doctor-1".to_string(),
);
let value = serde_json::to_value(&envelope).unwrap();
assert_eq!(value["ok"], true);
assert_eq!(value["status"], "ok");
assert_eq!(value["data"]["command"], "doctor");
assert!(value["data"]["healthy"].is_boolean());
let checks = value["data"]["checks"].as_array().unwrap();
assert!(checks.iter().any(|check| check["name"] == "daemon_rpc"));
}
#[test]
fn dispatch_resource_sync_enqueues_task() {
let temp = tempfile::TempDir::new().unwrap();
@@ -6554,6 +6647,48 @@ mod tests {
assert_eq!(status_value["data"]["status"], "queued");
}
#[test]
fn dispatch_resource_repair_enqueues_repair_task() {
let temp = tempfile::TempDir::new().unwrap();
let control = new_daemon_control();
// 保留 rx 让 send 成功(不启动 worker,任务停留在 queued)。
let (queue, rx) = mpsc::channel::<TaskJob>();
let mut base_config = OfficialUpdateConfig::default();
base_config.force = true;
base_config.dry_run = true;
base_config.audit_local = false;
base_config.repair = false;
let context = DaemonTaskContext {
registry: TaskRegistry::new(),
queue,
base_config,
};
let envelope = dispatch_rpc_method(
&rpc_request("resource.repair", None),
temp.path(),
&control,
&context,
"req-repair-1".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"], "resource.repair");
let task_id = value["data"]["task_id"].as_str().unwrap();
let record = context.registry.get(task_id).unwrap();
assert_eq!(record.kind, "resource.repair");
assert_eq!(record.status, "queued");
// repair 任务强制开启本地审计+修复,但不会继承 force/dry-run。
let job = rx.try_recv().unwrap();
assert_eq!(job.id, task_id);
assert!(!job.config.dry_run);
assert!(job.config.audit_local);
assert!(job.config.repair);
assert!(!job.config.force);
}
#[test]
fn task_registry_create_update_and_prune() {
let registry = TaskRegistry::new();
@@ -7092,21 +7227,41 @@ mod tests {
}
#[test]
fn refresh_rpc_selection_only_for_default_daemon_shape() {
fn sync_command_rpc_selection_only_for_default_daemon_shape() {
let options = parse(&["bat", "refresh"]).unwrap();
assert!(refresh_should_use_daemon_rpc(&options, "refresh"));
assert_eq!(
sync_command_rpc_method(&options, "refresh"),
Some(RPC_METHOD_REFRESH)
);
let options = parse(&["bat", "refresh", "--force"]).unwrap();
assert!(refresh_should_use_daemon_rpc(&options, "refresh"));
assert_eq!(
sync_command_rpc_method(&options, "refresh"),
Some(RPC_METHOD_REFRESH)
);
let options = parse(&["bat", "refresh", "--output", "/tmp/other"]).unwrap();
assert!(!refresh_should_use_daemon_rpc(&options, "refresh"));
assert_eq!(sync_command_rpc_method(&options, "refresh"), None);
let options = parse(&["bat", "refresh", "--server-info-file", "ProdNotice.json"]).unwrap();
assert!(!refresh_should_use_daemon_rpc(&options, "refresh"));
assert_eq!(sync_command_rpc_method(&options, "refresh"), None);
let options = parse(&["bat", "repair"]).unwrap();
assert!(!refresh_should_use_daemon_rpc(&options, "repair"));
assert_eq!(
sync_command_rpc_method(&options, "repair"),
Some(RPC_METHOD_RESOURCE_REPAIR)
);
let options = parse(&["bat", "repair", "--output", "/tmp/other"]).unwrap();
assert_eq!(sync_command_rpc_method(&options, "repair"), None);
let options = parse(&["bat", "repair", "--force"]).unwrap();
assert_eq!(sync_command_rpc_method(&options, "repair"), None);
}
#[test]
@@ -7591,17 +7746,34 @@ mod tests {
let value = serde_json::to_value(&envelope).unwrap();
assert_eq!(value["ok"], false);
assert_eq!(value["error"]["code"], "BAT-ERR-700002");
let envelope = dispatch_rpc_method(
&rpc_request(
"resource.list",
Some(serde_json::json!({ "offset": 2, "limit": 1 })),
),
&state_dir,
&new_daemon_control(),
&test_task_context(),
"req-man-3".to_string(),
);
let value = serde_json::to_value(&envelope).unwrap();
assert_eq!(value["ok"], true);
assert_eq!(value["data"]["available"], true);
let entries = value["data"]["entries"].as_array().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0]["destination"], "c");
}
#[test]
fn dispatch_resource_repair_reports_not_implemented() {
fn dispatch_daemon_clean_stable_reports_not_implemented() {
let temp = tempfile::TempDir::new().unwrap();
let envelope = dispatch_rpc_method(
&rpc_request("resource.repair", None),
&rpc_request("daemon.clean-stable", None),
temp.path(),
&new_daemon_control(),
&test_task_context(),
"req-rep-1".to_string(),
"req-clean-1".to_string(),
);
let value = serde_json::to_value(&envelope).unwrap();
assert_eq!(value["ok"], false);