use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::query_support::resolve_effective_workspace_id; use crate::routes::snapshot_support::{ execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query, ProjectionSnapshotSpec, }; use axum::extract::{Extension, Query, State}; use axum::http::StatusCode; use axum::Json; use bridge_runtime::RuntimeQueryEnvelopeWire; use core_protocol::{KernelGraphDirection, 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, pub query: Option, pub max_results: 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 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, })), ) } async fn project_projection( State(state): State, Extension(context): Extension, Query(query): Query, projection: KernelProjectionKind, ) -> Result<(StatusCode, Json), WebError> { let effective_workspace_id = resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)? .expect("workspace_required 已确保存在"); let snapshot = load_projection_snapshot( state.config(), &context, &ProjectionSnapshotSpec { workspace_id: &effective_workspace_id, root_node_id: query.root_node_id.as_deref(), depth: query.depth, query: query.query.as_deref(), max_results: query.max_results, projection, }, ) .await?; Ok(ok_response(&context, snapshot.projection)) } pub async fn project_tree_sidebar( state: State, context: Extension, query: Query, ) -> Result<(StatusCode, Json), WebError> { project_projection(state, context, query, KernelProjectionKind::SidebarTree).await } pub async fn project_tree_page( state: State, context: Extension, query: Query, ) -> Result<(StatusCode, Json), WebError> { project_projection(state, context, query, KernelProjectionKind::PageTree).await } pub async fn project_tree_file( state: State, context: Extension, query: Query, ) -> Result<(StatusCode, Json), WebError> { project_projection(state, context, query, KernelProjectionKind::FileTree).await } pub async fn project_sidebar( state: State, context: Extension, query: Query, ) -> Result<(StatusCode, Json), WebError> { project_tree_sidebar(state, context, query).await } 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, subtree_query(&effective_workspace_id, &query.root_node_id, query.depth), 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::{to_bytes, Body}; use axum::http::{Request, StatusCode}; use serde_json::Value; 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(), public_bind_addr: "127.0.0.1:3000".into(), legacy_next_base_url: Some("http://127.0.0.1:3100".into()), enable_legacy_next_compat: true, enable_debug_shell_routes: false, 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"}],"media_assets":[{"id":"asset_file_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"封面.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_child_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"节点图片.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_pdf_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"说明书.pdf","mime_type":"application/pdf","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_book_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"小说.epub","mime_type":"application/epub+zip","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_assets":[{"id":"mind_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"mindmap","file_name":"头脑风暴.json","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"table_assets":[{"id":"table_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"luckysheet","file_name":"预算.luckysheet","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_asset_children":{"mind_1":["asset_child_1"]}},"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); } #[tokio::test] async fn tree_projection_contract_route_keeps_shared_fields() { 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); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); let first = &payload["result"]["items"][0]; assert_eq!(first["projectionKind"], "sidebar_tree"); assert_eq!(first["resourceMeta"]["resourceKind"], "document"); assert_eq!(first["iconHint"], "page"); } #[tokio::test] async fn tree_projection_routes_return_sidebar_page_and_file_projection_kinds() { let cases = [ ( "/api/tree/projections/sidebar?workspaceId=ws_demo&rootNodeId=page_root", "sidebar_tree", ), ( "/api/tree/projections/page?workspaceId=ws_demo&rootNodeId=page_root", "page_tree", ), ( "/api/tree/projections/file?workspaceId=ws_demo&rootNodeId=page_root", "file_tree", ), ]; for (uri, expected_projection_kind) in cases { let response = app() .oneshot( Request::builder() .uri(uri) .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["result"]["projection"], expected_projection_kind); assert_eq!( payload["result"]["items"][0]["projectionKind"], expected_projection_kind ); } } #[tokio::test] async fn tree_projection_routes_keep_ok_response_shape() { let response = app() .oneshot( Request::builder() .uri("/api/tree/projections/page?workspaceId=ws_demo&rootNodeId=page_root") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["ok"], true); assert_eq!(payload["result"]["projection"], "page_tree"); assert_eq!(payload["result"]["rootNodeId"], "page_root"); } #[tokio::test] async fn file_tree_projection_includes_index_and_asset_rows() { let response = app() .oneshot( Request::builder() .uri("/api/tree/projections/file?workspaceId=ws_demo&rootNodeId=page_root") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); let items = payload["result"]["items"].as_array().expect("items"); let row_kinds = items .iter() .filter_map(|item| item["rowKind"].as_str()) .collect::>(); let resource_kinds = items .iter() .filter_map(|item| item["resourceMeta"]["resourceKind"].as_str()) .collect::>(); assert!(row_kinds.contains(&"document")); assert!(row_kinds.contains(&"index")); assert!(row_kinds.contains(&"asset")); assert!(row_kinds.contains(&"asset_folder")); assert!(resource_kinds.contains(&"document")); assert!(resource_kinds.contains(&"index")); assert!(resource_kinds.contains(&"asset")); assert!(resource_kinds.contains(&"mindmap")); assert!(resource_kinds.contains(&"table")); assert_eq!(items[0]["nodeId"], "page_root"); assert_eq!(items[1]["nodeId"], "index:page_root"); } #[tokio::test] async fn file_tree_projection_resource_variants_keep_icon_hint_and_capabilities() { let response = app() .oneshot( Request::builder() .uri("/api/tree/projections/file?workspaceId=ws_demo&rootNodeId=page_root") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); let item_by_row_id = payload["result"]["items"] .as_array() .expect("items") .iter() .filter_map(|item| item["rowId"].as_str().map(|row_id| (row_id, item))) .collect::>(); assert_eq!(item_by_row_id["asset:asset_pdf_1"]["iconHint"], "pdf"); assert_eq!(item_by_row_id["asset:asset_book_1"]["iconHint"], "book"); assert_eq!(item_by_row_id["asset:table_1"]["iconHint"], "table"); assert_eq!(item_by_row_id["asset-folder:mind_1"]["iconHint"], "mindmap"); assert_eq!( item_by_row_id["asset-folder:mind_1"]["resourceMeta"]["resourceKind"], "mindmap" ); assert!(item_by_row_id["asset-folder:mind_1"]["capabilities"] .as_array() .expect("capabilities") .iter() .any(|value| value == "expand")); } #[tokio::test] async fn file_tree_projection_query_returns_matches_and_ancestors() { let response = app() .oneshot( Request::builder() .uri("/api/tree/projections/file?workspaceId=ws_demo&rootNodeId=page_root&query=%E9%A2%84%E7%AE%97") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); let items = payload["result"]["items"].as_array().expect("items"); let row_ids = items .iter() .filter_map(|item| item["rowId"].as_str()) .collect::>(); assert_eq!(row_ids, vec!["doc:page_root", "asset:table_1"]); assert_eq!( payload["result"]["meta"]["search"]["indexingVisibility"]["schema"], "mnote.file_tree.indexing_visibility" ); assert_eq!( payload["result"]["meta"]["search"]["indexingVisibility"]["status"], "visible" ); assert_eq!( payload["result"]["meta"]["search"]["indexingVisibility"]["visibleResourceKinds"] .as_array() .expect("visible resource kinds") .iter() .any(|value| value == "asset"), true ); assert_eq!(items[0]["expandedByDefault"], true); assert_eq!(items[1]["resourceMeta"]["resourceKind"], "table"); let edges = payload["result"]["edges"].as_array().expect("edges"); assert_eq!(edges.len(), 1); assert_eq!(edges[0]["fromNodeId"], "page_root"); assert_eq!(edges[0]["toNodeId"], "asset:table_1"); } #[tokio::test] async fn file_tree_projection_query_honors_max_results_and_keeps_ancestors() { let response = app() .oneshot( Request::builder() .uri("/api/tree/projections/file?workspaceId=ws_demo&rootNodeId=page_root&query=png&maxResults=1") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); let items = payload["result"]["items"].as_array().expect("items"); let row_ids = items .iter() .filter_map(|item| item["rowId"].as_str()) .collect::>(); assert_eq!(row_ids, vec!["doc:page_root", "asset:asset_file_1"]); assert_eq!(items[0]["expandedByDefault"], true); assert_eq!(items[1]["resourceMeta"]["assetKind"], "image"); } }