use crate::app::{AppConfig, AppState}; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::query_support::{ execute_runtime_query_via_legacy_cloud, resolve_effective_workspace_id, }; use axum::extract::{Extension, Query, State}; use axum::http::StatusCode; use axum::Json; use bridge_runtime::RuntimeQueryEnvelopeWire; use serde::Deserialize; use serde_json::{json, Value}; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BridgeWorkspaceQuery { pub workspace_id: Option, pub limit: Option, pub cursor: Option, pub command_status: Option, pub event_status: Option, pub target_page_id: Option, pub target_block_id: Option, pub aggregate_type: Option, pub aggregate_id: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BridgeRequestQuery { pub workspace_id: Option, pub request_id: String, pub command_id: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BridgeTraceQuery { pub workspace_id: Option, pub trace_id: String, pub command_id: 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, })), ) } /// Cap bridge overview page size to avoid unbounded fan-out / memory pressure. const BRIDGE_WORKSPACE_LIMIT_MAX: u32 = 200; const BRIDGE_WORKSPACE_LIMIT_DEFAULT: u32 = 50; fn clamped_bridge_limit(limit: Option) -> u32 { limit .unwrap_or(BRIDGE_WORKSPACE_LIMIT_DEFAULT) .clamp(1, BRIDGE_WORKSPACE_LIMIT_MAX) } fn workspace_query_payload( workspace_id: &str, query: &BridgeWorkspaceQuery, ) -> RuntimeQueryEnvelopeWire { RuntimeQueryEnvelopeWire { name: "bridge.workspace.overview".into(), payload: json!({ "workspaceId": workspace_id, "limit": clamped_bridge_limit(query.limit), "cursor": query.cursor, "commandStatus": query.command_status, "eventStatus": query.event_status, "targetPageId": query.target_page_id, "targetBlockId": query.target_block_id, "aggregateType": query.aggregate_type, "aggregateId": query.aggregate_id, }), } } async fn execute_bridge_query( config: &AppConfig, context: &RequestContext, workspace_id: &str, query: RuntimeQueryEnvelopeWire, ) -> Result { execute_runtime_query_via_legacy_cloud(config, context, Some(workspace_id), query).await } fn require_resolved_workspace_id( context: &RequestContext, query_workspace_id: Option<&str>, ) -> Result { resolve_effective_workspace_id(context, query_workspace_id, true)?.ok_or_else(|| { WebError::bad_request_code( "workspace_required", "缺少 workspaceId,请在 query 或请求头中提供有效工作区", ) .with_context(context) .with_header("x-error-phase", "workspace_resolve") }) } pub async fn workspace( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result<(StatusCode, Json), WebError> { let effective_workspace_id = require_resolved_workspace_id(&context, query.workspace_id.as_deref())?; let result = execute_bridge_query( state.config(), &context, &effective_workspace_id, workspace_query_payload(&effective_workspace_id, &query), ) .await?; Ok(ok_response(&context, result)) } pub async fn request( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result<(StatusCode, Json), WebError> { let effective_workspace_id = require_resolved_workspace_id(&context, query.workspace_id.as_deref())?; let result = execute_bridge_query( state.config(), &context, &effective_workspace_id, RuntimeQueryEnvelopeWire { name: "bridge.request.get".into(), payload: json!({ "workspaceId": effective_workspace_id, "requestId": query.request_id, "commandId": query.command_id, }), }, ) .await?; Ok(ok_response(&context, result)) } pub async fn trace( State(state): State, Extension(context): Extension, Query(query): Query, ) -> Result<(StatusCode, Json), WebError> { let effective_workspace_id = require_resolved_workspace_id(&context, query.workspace_id.as_deref())?; let result = execute_bridge_query( state.config(), &context, &effective_workspace_id, RuntimeQueryEnvelopeWire { name: "bridge.trace.get".into(), payload: json!({ "workspaceId": effective_workspace_id, "traceId": query.trace_id, "commandId": query.command_id, }), }, ) .await?; 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(), 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, enable_editor_actor: true, enable_page_ai_pi_lab: false, 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(), environment: "dev".into(), })) } #[tokio::test] async fn bridge_workspace_route_returns_runtime_result() { let response = app() .oneshot( Request::builder() .uri("/api/bridge/workspace?workspaceId=ws_demo") .body(Body::empty()) .expect("request"), ) .await .expect("response"); assert_eq!(response.status(), StatusCode::OK); } }