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:
lix-2026
2026-04-17 23:36:24 +08:00
parent cfc3af8984
commit d8de820d93
40 changed files with 4668 additions and 4143 deletions
+8
View File
@@ -3362,6 +3362,12 @@ fn normalize_kernel_node_from_record(
if let Some(is_starred) = bool_field(value, "is_starred").or_else(|| bool_field(value, "isStarred")) {
extra.insert("isStarred".into(), json!(is_starred));
}
if let Some(sort_order) = record_field(value, "sort_order")
.and_then(|field| field.as_i64().or_else(|| field.as_f64().map(|number| number as i64)))
.or_else(|| record_field(value, "sortOrder").and_then(Value::as_i64))
{
extra.insert("sortOrder".into(), json!(sort_order));
}
let content = value
.get("content")
@@ -5712,7 +5718,9 @@ mod tests {
assert_eq!(result["projection"], json!("sidebar_tree"));
assert_eq!(result["rootNodeId"], json!("page_root"));
assert_eq!(result["items"][0]["nodeId"], json!("page_root"));
assert_eq!(result["items"][0]["position"], json!(0));
assert_eq!(result["items"][1]["parentNodeId"], json!("page_root"));
assert_eq!(result["items"][1]["position"], json!(1));
}
#[test]
+57 -3
View File
@@ -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>,
+10 -9
View File
@@ -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)
}
+4 -5
View File
@@ -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(),
+8 -9
View File
@@ -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(),
+4
View File
@@ -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
+173 -10
View File
@@ -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())),
}
}