feat: 接入 mnote web tree shell 与主页链路整理
- 增加 mnote-web tree/command 支持与前端 MnoteWebTreeShell 集成 - 调整 sidebar、documents、runtime config 与 dev/prod server 配套逻辑 - 补充 homepage/tree shell smoke 脚本并更新 harness 进度文件
This commit is contained in:
@@ -2,6 +2,7 @@ use crate::middleware::request_context::inject_request_context;
|
||||
use crate::routes::build_router;
|
||||
use axum::Router;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::sync::Arc;
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
@@ -15,6 +16,8 @@ pub struct AppConfig {
|
||||
pub convex_url: Option<String>,
|
||||
pub convex_admin_key: Option<String>,
|
||||
pub allow_dev_fixtures: bool,
|
||||
pub query_fixtures_json: Option<String>,
|
||||
pub mutation_fixtures_json: Option<String>,
|
||||
pub dev_user_id: String,
|
||||
pub dev_user_name: String,
|
||||
pub dev_user_email: String,
|
||||
@@ -34,23 +37,74 @@ impl AppConfig {
|
||||
convex_url: env::var("CONVEX_SELF_HOSTED_URL")
|
||||
.ok()
|
||||
.or_else(|| env::var("NEXT_PUBLIC_CONVEX_URL").ok())
|
||||
.or_else(|| read_env_or_dotenv("CONVEX_SELF_HOSTED_URL"))
|
||||
.or_else(|| read_env_or_dotenv("NEXT_PUBLIC_CONVEX_URL"))
|
||||
.map(|value| value.trim().trim_end_matches('/').to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
convex_admin_key: env::var("CONVEX_SELF_HOSTED_ADMIN_KEY")
|
||||
.ok()
|
||||
.or_else(|| read_env_or_dotenv("CONVEX_SELF_HOSTED_ADMIN_KEY"))
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
allow_dev_fixtures: env::var("MNOTE_WEB_ALLOW_DEV_FIXTURES")
|
||||
.ok()
|
||||
.map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES"))
|
||||
.unwrap_or(false),
|
||||
dev_user_id: env::var("DEV_USER_ID").unwrap_or_else(|_| "dev-user".into()),
|
||||
dev_user_name: env::var("DEV_USER_NAME").unwrap_or_else(|_| "开发用户".into()),
|
||||
dev_user_email: env::var("DEV_USER_EMAIL").unwrap_or_else(|_| "dev@mnote.local".into()),
|
||||
query_fixtures_json: env::var("MNOTE_WEB_QUERY_FIXTURES_JSON")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
mutation_fixtures_json: env::var("MNOTE_WEB_MUTATION_FIXTURES_JSON")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
dev_user_id: env::var("DEV_USER_ID")
|
||||
.ok()
|
||||
.or_else(|| read_env_or_dotenv("DEV_USER_ID"))
|
||||
.unwrap_or_else(|| "dev-user".into()),
|
||||
dev_user_name: env::var("DEV_USER_NAME")
|
||||
.ok()
|
||||
.or_else(|| read_env_or_dotenv("DEV_USER_NAME"))
|
||||
.unwrap_or_else(|| "开发用户".into()),
|
||||
dev_user_email: env::var("DEV_USER_EMAIL")
|
||||
.ok()
|
||||
.or_else(|| read_env_or_dotenv("DEV_USER_EMAIL"))
|
||||
.unwrap_or_else(|| "dev@mnote.local".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_env_or_dotenv(key: &str) -> Option<String> {
|
||||
if let Ok(value) = env::var(key) {
|
||||
let trimmed = value.trim().trim_matches('"').to_string();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../..")
|
||||
.join(".env.all");
|
||||
let content = fs::read_to_string(root).ok()?;
|
||||
for line in content.lines() {
|
||||
let line = line.trim_end_matches('\r');
|
||||
if line.starts_with('#') || line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some((k, v)) = line.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
if k.trim() != key {
|
||||
continue;
|
||||
}
|
||||
let trimmed = v.trim().trim_matches('"').to_string();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppState {
|
||||
config: Arc<AppConfig>,
|
||||
|
||||
@@ -73,13 +73,13 @@ fn workspace_query_payload(
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_bridge_query(
|
||||
async fn execute_bridge_query(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
workspace_id: &str,
|
||||
query: RuntimeQueryEnvelopeWire,
|
||||
) -> Result<Value, WebError> {
|
||||
execute_runtime_query_via_convex(config, context, Some(workspace_id), query)
|
||||
execute_runtime_query_via_convex(config, context, Some(workspace_id), query).await
|
||||
}
|
||||
|
||||
pub async fn workspace(
|
||||
@@ -95,7 +95,8 @@ pub async fn workspace(
|
||||
&context,
|
||||
&effective_workspace_id,
|
||||
workspace_query_payload(&effective_workspace_id, &query),
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
Ok(ok_response(&context, result))
|
||||
}
|
||||
|
||||
@@ -119,7 +120,8 @@ pub async fn request(
|
||||
"commandId": query.command_id,
|
||||
}),
|
||||
},
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
Ok(ok_response(&context, result))
|
||||
}
|
||||
|
||||
@@ -143,7 +145,8 @@ pub async fn trace(
|
||||
"commandId": query.command_id,
|
||||
}),
|
||||
},
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
Ok(ok_response(&context, result))
|
||||
}
|
||||
|
||||
@@ -155,10 +158,6 @@ mod tests {
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn app() -> axum::Router {
|
||||
std::env::set_var(
|
||||
"MNOTE_WEB_QUERY_FIXTURES_JSON",
|
||||
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"}}"#,
|
||||
);
|
||||
build_app(AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
@@ -168,6 +167,8 @@ mod tests {
|
||||
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(),
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
use crate::app::AppConfig;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::transport::convex::execute_convex_command_plan;
|
||||
use bridge_runtime::{
|
||||
execute_runtime_input, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeCommandEnvelopeWire,
|
||||
RuntimeCommandExecutionPlan, RuntimeExecutionPlan, RuntimeInput, RuntimeSourceWire,
|
||||
RuntimeTargetWire,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
pub fn runtime_context(
|
||||
context: &RequestContext,
|
||||
effective_workspace_id: Option<&str>,
|
||||
) -> RuntimeBridgeContextWire {
|
||||
RuntimeBridgeContextWire {
|
||||
deployment_id: context.workspace.deployment_id.clone(),
|
||||
project_id: context.workspace.project_id.clone(),
|
||||
workspace_id: effective_workspace_id
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| context.workspace.workspace_id.clone()),
|
||||
request_id: context.trace.request_id.clone(),
|
||||
trace_id: context.trace.trace_id.clone(),
|
||||
actor: RuntimeActorWire {
|
||||
actor_type: context.auth.actor_type.clone(),
|
||||
actor_id: context.auth.actor_id.clone(),
|
||||
session_id: context.auth.session_id.clone(),
|
||||
},
|
||||
source: RuntimeSourceWire {
|
||||
channel: context.source.channel.clone(),
|
||||
client: context.source.client.clone(),
|
||||
},
|
||||
tenant_id: context.workspace.tenant_id.clone(),
|
||||
auth_token: context.auth.authorization.clone(),
|
||||
idempotency_key: context.source.idempotency_key.clone(),
|
||||
validate_only: false,
|
||||
dry_run: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_command_plan(
|
||||
context: &RequestContext,
|
||||
effective_workspace_id: Option<&str>,
|
||||
command: RuntimeCommandEnvelopeWire,
|
||||
) -> Result<RuntimeCommandExecutionPlan, WebError> {
|
||||
let runtime_input = RuntimeInput::Command {
|
||||
context: runtime_context(context, effective_workspace_id),
|
||||
command,
|
||||
};
|
||||
let RuntimeExecutionPlan::Command(plan) = execute_runtime_input(runtime_input)
|
||||
.map_err(|error| WebError::bad_request(error.message).with_context(context))?
|
||||
else {
|
||||
return Err(WebError::internal("runtime command 未返回 command plan").with_context(context));
|
||||
};
|
||||
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
pub async fn execute_runtime_command_via_convex(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
effective_workspace_id: Option<&str>,
|
||||
command: RuntimeCommandEnvelopeWire,
|
||||
) -> Result<Value, WebError> {
|
||||
let plan = build_runtime_command_plan(context, effective_workspace_id, command)?;
|
||||
execute_convex_command_plan(config, context, &plan).await
|
||||
}
|
||||
|
||||
pub fn build_tree_target(
|
||||
workspace_id: &str,
|
||||
page_id: Option<&str>,
|
||||
block_id: Option<&str>,
|
||||
) -> RuntimeTargetWire {
|
||||
RuntimeTargetWire {
|
||||
workspace_id: Some(workspace_id.to_string()),
|
||||
page_id: page_id.map(ToOwned::to_owned),
|
||||
block_id: block_id.map(ToOwned::to_owned),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ensure_non_empty(value: &str, field: &'static str, context: &RequestContext) -> Result<String, WebError> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"tree_command_validation",
|
||||
format!("{field} 不能为空"),
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "tree_command_validate"));
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
pub fn read_optional_non_empty(value: Option<String>) -> Option<String> {
|
||||
value
|
||||
.map(|item| item.trim().to_string())
|
||||
.filter(|item| !item.is_empty())
|
||||
}
|
||||
|
||||
pub fn ensure_sort_order(sort_order: i64, context: &RequestContext) -> Result<i64, WebError> {
|
||||
if sort_order < 0 {
|
||||
return Err(WebError::bad_request_code(
|
||||
"tree_command_validation",
|
||||
"sortOrder 不能小于 0",
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "tree_command_validate"));
|
||||
}
|
||||
Ok(sort_order)
|
||||
}
|
||||
@@ -69,7 +69,8 @@ pub async fn next_sidebar(
|
||||
"workspaceId": effective_workspace_id,
|
||||
}),
|
||||
},
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
let projection = execute_runtime_query_against_data(
|
||||
&context,
|
||||
Some(&effective_workspace_id),
|
||||
@@ -115,10 +116,6 @@ mod tests {
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn app() -> axum::Router {
|
||||
std::env::set_var(
|
||||
"MNOTE_WEB_QUERY_FIXTURES_JSON",
|
||||
r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","workspaces":[],"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"}],"trashed_documents":[],"media_assets":[],"trashed_media_assets":[],"mindmap_assets":[],"trashed_mindmap_assets":[],"table_assets":[],"trashed_table_assets":[],"mindmap_docs":[],"mindmap_asset_children":{}},"bridgeLogs:listWorkspaceOverview":{"workspace_id":"ws_demo","command_logs":[],"domain_events":[],"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"}}"#,
|
||||
);
|
||||
build_app(AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
@@ -128,6 +125,8 @@ mod tests {
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: true,
|
||||
query_fixtures_json: Some(r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","workspaces":[],"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"}],"trashed_documents":[],"media_assets":[],"trashed_media_assets":[],"mindmap_assets":[],"trashed_mindmap_assets":[],"table_assets":[],"trashed_table_assets":[],"mindmap_docs":[],"mindmap_asset_children":{}},"bridgeLogs:listWorkspaceOverview":{"workspace_id":"ws_demo","command_logs":[],"domain_events":[],"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(),
|
||||
|
||||
@@ -52,7 +52,7 @@ fn sidebar_dataset_query(workspace_id: &str) -> RuntimeQueryEnvelopeWire {
|
||||
}
|
||||
}
|
||||
|
||||
fn load_sidebar_dataset(
|
||||
async fn load_sidebar_dataset(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
workspace_id: &str,
|
||||
@@ -63,6 +63,7 @@ fn load_sidebar_dataset(
|
||||
Some(workspace_id),
|
||||
sidebar_dataset_query(workspace_id),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn execute_kernel_query(
|
||||
@@ -94,7 +95,7 @@ pub async fn project_sidebar(
|
||||
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)?;
|
||||
let dataset = load_sidebar_dataset(state.config(), &context, &effective_workspace_id).await?;
|
||||
let result = execute_kernel_query(
|
||||
&context,
|
||||
&effective_workspace_id,
|
||||
@@ -124,7 +125,7 @@ pub async fn subtree(
|
||||
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)?;
|
||||
let dataset = load_sidebar_dataset(state.config(), &context, &effective_workspace_id).await?;
|
||||
let result = execute_kernel_query(
|
||||
&context,
|
||||
&effective_workspace_id,
|
||||
@@ -151,7 +152,7 @@ pub async fn edges(
|
||||
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)?;
|
||||
let dataset = load_sidebar_dataset(state.config(), &context, &effective_workspace_id).await?;
|
||||
let result = execute_kernel_query(
|
||||
&context,
|
||||
&effective_workspace_id,
|
||||
@@ -176,7 +177,7 @@ pub async fn graph(
|
||||
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)?;
|
||||
let dataset = load_sidebar_dataset(state.config(), &context, &effective_workspace_id).await?;
|
||||
let result = execute_kernel_query(
|
||||
&context,
|
||||
&effective_workspace_id,
|
||||
@@ -202,10 +203,6 @@ mod tests {
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn app() -> axum::Router {
|
||||
std::env::set_var(
|
||||
"MNOTE_WEB_QUERY_FIXTURES_JSON",
|
||||
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"}}"#,
|
||||
);
|
||||
build_app(AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
@@ -215,6 +212,8 @@ mod tests {
|
||||
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(),
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
mod bridge;
|
||||
mod command_support;
|
||||
mod compat;
|
||||
mod health;
|
||||
mod hermes;
|
||||
mod kernel;
|
||||
mod query_support;
|
||||
mod sse;
|
||||
mod tree;
|
||||
mod ws;
|
||||
|
||||
use crate::app::AppState;
|
||||
@@ -17,6 +19,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
|
||||
Router::new()
|
||||
.route("/health", get(health::health))
|
||||
.route("/tree", get(tree::tree_shell))
|
||||
.route(
|
||||
"/api/kernel/projections/sidebar",
|
||||
get(kernel::project_sidebar),
|
||||
@@ -24,6 +27,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/api/kernel/subtree", get(kernel::subtree))
|
||||
.route("/api/kernel/edges", get(kernel::edges))
|
||||
.route("/api/kernel/graph", get(kernel::graph))
|
||||
.route("/api/tree/commands", post(tree::tree_command))
|
||||
.route("/api/bridge/workspace", get(bridge::workspace))
|
||||
.route("/api/bridge/request", get(bridge::request))
|
||||
.route("/api/bridge/trace", get(bridge::trace))
|
||||
|
||||
@@ -96,14 +96,14 @@ pub fn build_runtime_query_plan(
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
pub fn fetch_query_data_via_convex(
|
||||
pub async fn fetch_query_data_via_convex(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
effective_workspace_id: Option<&str>,
|
||||
query: RuntimeQueryEnvelopeWire,
|
||||
) -> Result<Value, WebError> {
|
||||
let plan = build_runtime_query_plan(context, effective_workspace_id, query)?;
|
||||
execute_convex_query_plan(config, context, &plan)
|
||||
execute_convex_query_plan(config, context, &plan).await
|
||||
}
|
||||
|
||||
pub fn execute_runtime_query_against_data(
|
||||
@@ -120,12 +120,12 @@ pub fn execute_runtime_query_against_data(
|
||||
.map_err(|error| WebError::bad_request(error.message).with_context(context))
|
||||
}
|
||||
|
||||
pub fn execute_runtime_query_via_convex(
|
||||
pub async fn execute_runtime_query_via_convex(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
effective_workspace_id: Option<&str>,
|
||||
query: RuntimeQueryEnvelopeWire,
|
||||
) -> Result<Value, WebError> {
|
||||
let data = fetch_query_data_via_convex(config, context, effective_workspace_id, query.clone())?;
|
||||
let data = fetch_query_data_via_convex(config, context, effective_workspace_id, query.clone()).await?;
|
||||
execute_runtime_query_against_data(context, effective_workspace_id, query, data)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine;
|
||||
use bridge_runtime::RuntimeQueryExecutionPlan;
|
||||
use bridge_runtime::{RuntimeCommandExecutionPlan, RuntimeQueryExecutionPlan};
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::time::Duration;
|
||||
@@ -13,6 +13,7 @@ const HEADER_TRACE_ID: &str = "x-trace-id";
|
||||
const HEADER_WORKSPACE_ID: &str = "x-mnote-workspace-id";
|
||||
const HEADER_SOURCE_CHANNEL: &str = "x-mnote-source-channel";
|
||||
const HEADER_SOURCE_CLIENT: &str = "x-mnote-source-client";
|
||||
const HEADER_IDEMPOTENCY_KEY: &str = "x-idempotency-key";
|
||||
|
||||
fn read_env_or_dotenv(key: &str) -> Option<String> {
|
||||
if let Ok(value) = std::env::var(key) {
|
||||
@@ -70,10 +71,18 @@ fn build_authorization(config: &AppConfig, context: &RequestContext) -> Result<S
|
||||
.with_header("x-upstream-service", "convex")
|
||||
})?;
|
||||
|
||||
let effective_actor_id = if context.auth.actor_id.trim().is_empty()
|
||||
|| context.auth.actor_id == "anonymous"
|
||||
{
|
||||
config.dev_user_id.clone()
|
||||
} else {
|
||||
context.auth.actor_id.clone()
|
||||
};
|
||||
|
||||
let identity = json!({
|
||||
"subject": config.dev_user_id,
|
||||
"subject": effective_actor_id,
|
||||
"issuer": "https://mnote.local/dev-auth",
|
||||
"tokenIdentifier": format!("dev-user|{}", config.dev_user_id),
|
||||
"tokenIdentifier": format!("dev-user|{}", effective_actor_id),
|
||||
"name": config.dev_user_name,
|
||||
"email": config.dev_user_email,
|
||||
});
|
||||
@@ -113,7 +122,7 @@ fn load_query_fixture(
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let Ok(raw) = std::env::var("MNOTE_WEB_QUERY_FIXTURES_JSON") else {
|
||||
let Some(raw) = config.query_fixtures_json.as_deref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let trimmed = raw.trim();
|
||||
@@ -133,7 +142,36 @@ fn load_query_fixture(
|
||||
.cloned())
|
||||
}
|
||||
|
||||
pub fn execute_convex_query_plan(
|
||||
fn load_mutation_fixture(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
plan: &RuntimeCommandExecutionPlan,
|
||||
) -> Result<Option<Value>, WebError> {
|
||||
if !config.allow_dev_fixtures {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let Some(raw) = config.mutation_fixtures_json.as_deref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let fixtures: Value = serde_json::from_str(trimmed).map_err(|error| {
|
||||
WebError::internal(format!("MNOTE_WEB_MUTATION_FIXTURES_JSON 非法: {error}"))
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "fixture_parse")
|
||||
})?;
|
||||
|
||||
Ok(fixtures
|
||||
.as_object()
|
||||
.and_then(|map| map.get(plan.function_name.as_str()))
|
||||
.cloned())
|
||||
}
|
||||
|
||||
pub async fn execute_convex_query_plan(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
plan: &RuntimeQueryExecutionPlan,
|
||||
@@ -157,7 +195,7 @@ pub fn execute_convex_query_plan(
|
||||
"args": plan.args_json,
|
||||
});
|
||||
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(20))
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
@@ -186,7 +224,7 @@ pub fn execute_convex_query_plan(
|
||||
request = request.header(HEADER_WORKSPACE_ID, workspace_id);
|
||||
}
|
||||
|
||||
let response = request.send().map_err(|error| {
|
||||
let response = request.send().await.map_err(|error| {
|
||||
let base = if error.is_timeout() {
|
||||
WebError::gateway_timeout_code("convex_timeout", format!("Convex query 超时: {error}"))
|
||||
} else {
|
||||
@@ -201,7 +239,7 @@ pub fn execute_convex_query_plan(
|
||||
})?;
|
||||
|
||||
let status = response.status();
|
||||
let body: Value = response.json().map_err(|error| {
|
||||
let body: Value = response.json().await.map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"convex_bad_response",
|
||||
format!("Convex 响应解析失败: {error}"),
|
||||
@@ -249,10 +287,135 @@ pub fn execute_convex_query_plan(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn execute_sidebar_dataset_query(
|
||||
pub async fn execute_sidebar_dataset_query(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
plan: &RuntimeQueryExecutionPlan,
|
||||
) -> Result<Value, WebError> {
|
||||
execute_convex_query_plan(config, context, plan)
|
||||
execute_convex_query_plan(config, context, plan).await
|
||||
}
|
||||
|
||||
pub async fn execute_convex_command_plan(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
plan: &RuntimeCommandExecutionPlan,
|
||||
) -> Result<Value, WebError> {
|
||||
if plan.function_name.trim().is_empty() || plan.function_name.ends_with(":unknown") {
|
||||
return Err(WebError::bad_request_code(
|
||||
"transport_command_unsupported",
|
||||
format!("mnote-web transport 暂不支持 command: {}", plan.function_name),
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "plan_validation"));
|
||||
}
|
||||
|
||||
if let Some(fixture) = load_mutation_fixture(config, context, plan)? {
|
||||
return Ok(fixture);
|
||||
}
|
||||
|
||||
let payload = json!({
|
||||
"path": plan.function_name,
|
||||
"format": "convex_encoded_json",
|
||||
"args": [plan.args_json.clone()],
|
||||
});
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(20))
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("Convex HTTP 客户端创建失败: {error}"))
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "client_build")
|
||||
.with_header("x-upstream-service", "convex")
|
||||
})?;
|
||||
|
||||
let mut request = client
|
||||
.post(format!("{}/api/mutation", convex_url(config, context)?))
|
||||
.header("Authorization", build_authorization(config, context)?)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Convex-Client", "mnote-web")
|
||||
.header(HEADER_REQUEST_ID, &context.trace.request_id)
|
||||
.header(HEADER_TRACE_ID, &context.trace.trace_id)
|
||||
.header(HEADER_SOURCE_CHANNEL, context.source.channel.as_str())
|
||||
.header(HEADER_SOURCE_CLIENT, context.source.client.as_str())
|
||||
.json(&payload);
|
||||
|
||||
if let Some(workspace_id) = plan
|
||||
.workspace_id
|
||||
.as_deref()
|
||||
.or(context.workspace.workspace_id.as_deref())
|
||||
{
|
||||
request = request.header(HEADER_WORKSPACE_ID, workspace_id);
|
||||
}
|
||||
if let Some(idempotency_key) = plan
|
||||
.idempotency_key
|
||||
.as_deref()
|
||||
.or(context.source.idempotency_key.as_deref())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
request = request.header(HEADER_IDEMPOTENCY_KEY, idempotency_key);
|
||||
}
|
||||
|
||||
let response = request.send().await.map_err(|error| {
|
||||
let base = if error.is_timeout() {
|
||||
WebError::gateway_timeout_code("convex_timeout", format!("Convex mutation 超时: {error}"))
|
||||
} else {
|
||||
WebError::service_unavailable_code(
|
||||
"convex_unavailable",
|
||||
format!("Convex mutation 请求失败: {error}"),
|
||||
)
|
||||
};
|
||||
base.with_context(context)
|
||||
.with_header("x-error-phase", "mutation_send")
|
||||
.with_header("x-upstream-service", "convex")
|
||||
})?;
|
||||
|
||||
let status = response.status();
|
||||
let body: Value = response.json().await.map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"convex_bad_response",
|
||||
format!("Convex mutation 响应解析失败: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "mutation_decode")
|
||||
.with_header("x-upstream-service", "convex")
|
||||
.with_header("x-upstream-status", status.as_u16().to_string())
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
let message = body
|
||||
.get("errorMessage")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("Convex mutation 失败");
|
||||
return Err(
|
||||
WebError::bad_gateway_code("convex_upstream_error", message.to_string())
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "mutation_status")
|
||||
.with_header("x-upstream-service", "convex")
|
||||
.with_header("x-upstream-status", status.as_u16().to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
match body.get("status").and_then(Value::as_str) {
|
||||
Some("success") => Ok(body.get("value").cloned().unwrap_or(Value::Null)),
|
||||
Some("error") => Err(WebError::bad_gateway_code(
|
||||
"convex_upstream_error",
|
||||
body.get("errorMessage")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("Convex 返回 error")
|
||||
.to_string(),
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "mutation_payload")
|
||||
.with_header("x-upstream-service", "convex")
|
||||
.with_header("x-upstream-status", status.as_u16().to_string())),
|
||||
_ => Err(WebError::bad_gateway_code(
|
||||
"convex_bad_response",
|
||||
format!("未知 Convex 响应: {body}"),
|
||||
)
|
||||
.with_context(context)
|
||||
.with_header("x-error-phase", "mutation_payload")
|
||||
.with_header("x-upstream-service", "convex")
|
||||
.with_header("x-upstream-status", status.as_u16().to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user