use crate::app::{AppConfig, AppState}; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::query_support::{ execute_runtime_query_against_data, fetch_query_data_via_convex, resolve_effective_workspace_id, }; use axum::extract::{Extension, Query, State}; use axum::http::StatusCode; use axum::Json; use bridge_runtime::RuntimeQueryEnvelopeWire; use core_protocol::{KernelGraphDirection, KernelNodeType, KernelProjectionKind}; use serde::Deserialize; use serde_json::{json, Value}; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct KernelProjectionQuery { pub workspace_id: Option, pub root_node_id: Option, pub depth: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct KernelSubtreeQuery { pub workspace_id: Option, pub root_node_id: String, pub depth: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct KernelEdgesQuery { pub workspace_id: Option, pub node_id: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct KernelGraphQuery { pub workspace_id: Option, pub start_node_id: String, pub max_depth: Option, } fn sidebar_dataset_query(workspace_id: &str) -> RuntimeQueryEnvelopeWire { RuntimeQueryEnvelopeWire { name: "sidebar.dataset.list".into(), payload: json!({ "workspaceId": workspace_id, }), } } async fn load_sidebar_dataset( config: &AppConfig, context: &RequestContext, workspace_id: &str, ) -> Result { fetch_query_data_via_convex( config, context, Some(workspace_id), sidebar_dataset_query(workspace_id), ) .await } fn execute_kernel_query( context: &RequestContext, workspace_id: &str, query: RuntimeQueryEnvelopeWire, dataset: Value, ) -> Result { execute_runtime_query_against_data(context, Some(workspace_id), query, dataset) } fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, Json) { ( StatusCode::OK, Json(json!({ "ok": true, "requestId": context.trace.request_id, "traceId": context.trace.trace_id, "result": result, })), ) } pub async fn project_sidebar( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result<(StatusCode, Json), WebError> { let effective_workspace_id = resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)? .expect("workspace_required 已确保存在"); let dataset = load_sidebar_dataset(state.config(), &context, &effective_workspace_id).await?; let result = execute_kernel_query( &context, &effective_workspace_id, RuntimeQueryEnvelopeWire { name: "kernel.project_view".into(), payload: json!({ "projection": KernelProjectionKind::SidebarTree, "workspaceId": effective_workspace_id, "rootNodeId": query.root_node_id, "depth": query.depth, "includeEdges": true, "includeContent": false, "nodeTypes": [KernelNodeType::Page], }), }, dataset, )?; Ok(ok_response(&context, result)) } pub async fn subtree( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result<(StatusCode, Json), WebError> { let effective_workspace_id = resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)? .expect("workspace_required 已确保存在"); let dataset = load_sidebar_dataset(state.config(), &context, &effective_workspace_id).await?; let result = execute_kernel_query( &context, &effective_workspace_id, RuntimeQueryEnvelopeWire { name: "kernel.subtree.get".into(), payload: json!({ "workspaceId": effective_workspace_id, "rootNodeId": query.root_node_id, "depth": query.depth, "includeEdges": true, "nodeTypes": [KernelNodeType::Page], }), }, dataset, )?; Ok(ok_response(&context, result)) } pub async fn edges( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result<(StatusCode, Json), WebError> { let effective_workspace_id = resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)? .expect("workspace_required 已确保存在"); let dataset = load_sidebar_dataset(state.config(), &context, &effective_workspace_id).await?; let result = execute_kernel_query( &context, &effective_workspace_id, RuntimeQueryEnvelopeWire { name: "kernel.edges.list".into(), payload: json!({ "workspaceId": effective_workspace_id, "nodeId": query.node_id, "direction": KernelGraphDirection::Both, }), }, dataset, )?; Ok(ok_response(&context, result)) } pub async fn graph( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result<(StatusCode, Json), WebError> { let effective_workspace_id = resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)? .expect("workspace_required 已确保存在"); let dataset = load_sidebar_dataset(state.config(), &context, &effective_workspace_id).await?; let result = execute_kernel_query( &context, &effective_workspace_id, RuntimeQueryEnvelopeWire { name: "kernel.graph.traverse".into(), payload: json!({ "workspaceId": effective_workspace_id, "startNodeId": query.start_node_id, "maxDepth": query.max_depth.unwrap_or(2), "edgeTypes": [], }), }, dataset, )?; Ok(ok_response(&context, result)) } #[cfg(test)] mod tests { use crate::app::{build_app, AppConfig, AppState}; use axum::body::Body; use axum::http::{Request, StatusCode}; use tower::util::ServiceExt; fn app() -> axum::Router { build_app(AppState::new(AppConfig { service_name: "mnote-web".into(), service_version: "0.1.0".into(), bind_addr: "127.0.0.1:0".into(), hermes_base_path: "/api/hermes".into(), compat_next_base_path: "/api/compat/next".into(), convex_url: None, convex_admin_key: None, allow_dev_fixtures: true, query_fixtures_json: Some(r#"{"sidebar:datasetList":{"documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}]},"bridgeLogs:listWorkspaceOverview":{"workspace_id":"ws_demo","command_logs":[{"command_id":"cmd_1","request_id":"req_1","status":"applied","created_at":"2026-04-16T00:00:00Z"}],"domain_events":[{"command_id":"cmd_1","status":"published","created_at":"2026-04-16T00:00:00Z"}],"next_cursor":null,"has_more":false,"filters":{"command_status":null,"event_status":null,"target_page_id":null,"target_block_id":null,"aggregate_type":null,"aggregate_id":null},"generated_at":"2026-04-16T00:00:00Z"}}"#.into()), mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), })) } #[tokio::test] async fn kernel_projection_route_returns_sidebar_projection() { let response = app() .oneshot( Request::builder() .uri("/api/kernel/projections/sidebar?workspaceId=ws_demo&rootNodeId=page_root") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); } }