use crate::document_buffer_store::BufferStore; use crate::editor_actor::EditorRuntimeActor; use crate::local_folder_watcher_registry::LocalFolderWatcherRegistry; use crate::middleware::request_context::inject_request_context; use crate::routes::build_router; use axum::extract::Request; use axum::middleware::Next; use axum::response::Response; use axum::Router; use control_plane::ControlPlaneStore; #[cfg(test)] use control_plane::SqliteControlPlaneStore; #[cfg(not(test))] use control_plane::{TursoControlPlaneConfig, TursoControlPlaneMode, TursoControlPlaneStore}; use std::env; use std::fs; use std::sync::Arc; #[cfg(not(test))] use std::time::Duration; use tower_http::compression::CompressionLayer; use tower_http::trace::TraceLayer; use tracing::{error, warn}; #[derive(Debug, Clone)] pub struct AppConfig { pub service_name: String, pub service_version: String, pub bind_addr: String, pub public_bind_addr: String, pub legacy_next_base_url: Option, pub enable_legacy_next_compat: bool, pub enable_debug_shell_routes: bool, pub enable_editor_actor: bool, pub enable_page_ai_pi_lab: bool, pub compat_next_base_path: String, pub convex_url: Option, pub convex_admin_key: Option, pub allow_dev_fixtures: bool, pub query_fixtures_json: Option, pub mutation_fixtures_json: Option, pub dev_user_id: String, pub dev_user_name: String, pub dev_user_email: String, /// "dev" | "prod" — 影响前端显示与 env 片段中的 base URL。 pub environment: String, } impl AppConfig { pub fn from_env() -> Self { Self { service_name: env::var("MNOTE_WEB_SERVICE_NAME").unwrap_or_else(|_| "mnote-web".into()), service_version: env::var("MNOTE_WEB_SERVICE_VERSION") .unwrap_or_else(|_| env!("CARGO_PKG_VERSION").into()), bind_addr: env::var("MNOTE_WEB_BIND") .or_else(|_| env::var("MNOTE_WEB_PUBLIC_BIND")) .unwrap_or_else(|_| "127.0.0.1:0".into()), public_bind_addr: env::var("MNOTE_WEB_PUBLIC_BIND") .unwrap_or_else(|_| "127.0.0.1:3000".into()), legacy_next_base_url: env::var("MNOTE_WEB_LEGACY_NEXT_BASE_URL") .ok() .map(|value| value.trim().trim_end_matches('/').to_string()) .filter(|value| !value.is_empty()), enable_legacy_next_compat: env_bool("MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT", false), enable_debug_shell_routes: env::var("MNOTE_WEB_ENABLE_DEBUG_SHELL_ROUTES") .ok() .map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES")) .unwrap_or(false), enable_editor_actor: env_bool("MNOTE_WEB_ENABLE_EDITOR_ACTOR", true), enable_page_ai_pi_lab: env_bool("MNOTE_PAGE_AI_PI_LAB", true), compat_next_base_path: env::var("MNOTE_WEB_COMPAT_NEXT_BASE_PATH") .unwrap_or_else(|_| "/api/compat/next".into()), convex_url: None, convex_admin_key: None, allow_dev_fixtures: env::var("MNOTE_WEB_ALLOW_DEV_FIXTURES") .ok() .map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES")) .unwrap_or(false), 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()), environment: detect_environment(), } } } /// 推断运行环境:显式 MNOTE_WEB_ENV > 端口匹配(3000=dev, 3003=prod)> 默认 "dev"。 fn detect_environment() -> String { if let Ok(v) = env::var("MNOTE_WEB_ENV") { let t = v.trim().to_lowercase(); if !t.is_empty() { return t; } } let port = env::var("MNOTE_WEB_BIND") .or_else(|_| env::var("MNOTE_WEB_PUBLIC_BIND")) .ok() .and_then(|addr| addr.rsplit(':').next()?.parse::().ok()); match port { Some(3003) => "prod".into(), _ => "dev".into(), } } fn env_bool(key: &str, default: bool) -> bool { env::var(key) .ok() .map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES")) .unwrap_or(default) } fn read_env_or_dotenv(key: &str) -> Option { 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 } #[cfg(not(test))] fn parse_turso_sync_interval_ms() -> Option { let value = env::var("MNOTE_TURSO_SYNC_INTERVAL_MS") .ok() .map(|v| v.trim().to_string()) .filter(|v| !v.is_empty())?; let ms: u64 = value .parse() .expect("MNOTE_TURSO_SYNC_INTERVAL_MS 必须为正整数(毫秒),例如 5000"); if ms == 0 { panic!("MNOTE_TURSO_SYNC_INTERVAL_MS 必须为正整数,当前值: {ms}"); } Some(Duration::from_millis(ms)) } use tokio::sync::broadcast; #[derive(Clone)] pub struct AppState { config: Arc, local_folder_watcher_registry: LocalFolderWatcherRegistry, pub editor_actor: EditorRuntimeActor, pub block_delta_tx: broadcast::Sender, pub stream_delta_tx: broadcast::Sender, pub buffer_store: BufferStore, control_plane: Arc, } impl AppState { pub fn new(config: AppConfig) -> Self { let (block_delta_tx, _) = broadcast::channel(256); let (stream_delta_tx, _) = broadcast::channel(256); let actor = EditorRuntimeActor::new(); actor.set_block_delta_tx(block_delta_tx.clone()); let buffer_store = BufferStore::new(); let control_plane = open_control_plane_store(); Self { config: Arc::new(config), local_folder_watcher_registry: LocalFolderWatcherRegistry::new( buffer_store.clone(), control_plane.clone(), ), editor_actor: actor, block_delta_tx, stream_delta_tx, buffer_store, control_plane, } } pub fn config(&self) -> &AppConfig { self.config.as_ref() } pub fn local_folder_watcher_registry(&self) -> &LocalFolderWatcherRegistry { &self.local_folder_watcher_registry } pub fn control_plane(&self) -> &dyn ControlPlaneStore { self.control_plane.as_ref() } } #[cfg(test)] pub(crate) fn open_control_plane_store() -> Arc { Arc::new(SqliteControlPlaneStore::in_memory().expect("初始化测试 control-plane")) } #[cfg(not(test))] pub(crate) fn open_control_plane_store() -> Arc { let backend = env::var("MNOTE_CONTROL_PLANE_BACKEND") .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .unwrap_or_else(|| "libsql-local".to_string()); match backend.as_str() { "libsql-local" | "turso-local" | "turso" => open_turso_local_control_plane_store(), "turso-remote" => open_turso_remote_control_plane_store(), "turso-local-replica" | "turso-remote-replica" => { open_turso_remote_replica_control_plane_store() } "turso-synced" => open_turso_synced_control_plane_store(), other => panic!( "不支持的控制面后端 {other},mnote-web 运行时只支持 libsql-local/turso-remote/turso-local-replica/turso-synced;SQLite 仅保留给 control-plane-admin 迁移/导出和测试" ), } } #[cfg(not(test))] fn open_turso_local_control_plane_store() -> Arc { let db_path = env::var("MNOTE_TURSO_LOCAL_PATH") .ok() .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| { "/mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db".to_string() }); if let Some(parent) = std::path::Path::new(&db_path).parent() { fs::create_dir_all(parent).expect("创建 libSQL local 控制面目录"); } Arc::new(TursoControlPlaneStore::open_local(&db_path).expect("初始化 libSQL local 控制面")) } #[cfg(not(test))] fn open_turso_remote_control_plane_store() -> Arc { let url = env::var("MNOTE_TURSO_DATABASE_URL") .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .expect("MNOTE_CONTROL_PLANE_BACKEND=turso-remote 需要 MNOTE_TURSO_DATABASE_URL"); let token = env::var("MNOTE_TURSO_AUTH_TOKEN") .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .expect("MNOTE_CONTROL_PLANE_BACKEND=turso-remote 需要 MNOTE_TURSO_AUTH_TOKEN"); Arc::new(TursoControlPlaneStore::open_remote(url, token).expect("初始化 Turso remote 控制面")) } #[cfg(not(test))] fn open_turso_remote_replica_control_plane_store() -> Arc { let replica_path = env::var("MNOTE_TURSO_LOCAL_REPLICA_PATH") .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .unwrap_or_else(|| { "/mnt/Data1T/Mnote_data/control-plane/control-plane-replica.db".to_string() }); if let Some(parent) = std::path::Path::new(&replica_path).parent() { fs::create_dir_all(parent).expect("创建 Turso local replica 控制面目录"); } let url = env::var("MNOTE_TURSO_DATABASE_URL") .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .expect("MNOTE_CONTROL_PLANE_BACKEND=turso-local-replica 需要 MNOTE_TURSO_DATABASE_URL"); let token = env::var("MNOTE_TURSO_AUTH_TOKEN") .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .expect("MNOTE_CONTROL_PLANE_BACKEND=turso-local-replica 需要 MNOTE_TURSO_AUTH_TOKEN"); Arc::new( TursoControlPlaneStore::open_remote_replica( replica_path, url, token, parse_turso_sync_interval_ms(), ) .expect("初始化 Turso local replica 控制面"), ) } #[cfg(not(test))] fn open_turso_synced_control_plane_store() -> Arc { let local_path = env::var("MNOTE_TURSO_SYNCED_PATH") .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .or_else(|| { env::var("MNOTE_TURSO_LOCAL_REPLICA_PATH") .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) }) .unwrap_or_else(|| { "/mnt/Data1T/Mnote_data/control-plane/control-plane-synced.db".to_string() }); if let Some(parent) = std::path::Path::new(&local_path).parent() { fs::create_dir_all(parent).expect("创建 Turso synced 控制面目录"); } let url = env::var("MNOTE_TURSO_DATABASE_URL") .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .expect("MNOTE_CONTROL_PLANE_BACKEND=turso-synced 需要 MNOTE_TURSO_DATABASE_URL"); let token = env::var("MNOTE_TURSO_AUTH_TOKEN") .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .expect("MNOTE_CONTROL_PLANE_BACKEND=turso-synced 需要 MNOTE_TURSO_AUTH_TOKEN"); Arc::new( TursoControlPlaneStore::open_with_config(TursoControlPlaneConfig { mode: TursoControlPlaneMode::Synced, path: Some(local_path.into()), remote_url: Some(url), auth_token: Some(token), sync_interval: parse_turso_sync_interval_ms(), }) .expect("初始化 Turso synced 控制面"), ) } pub fn build_app(state: AppState) -> Router { build_router(state) // Outer layers run first on request / last on response. Compress HTML/JSON // for large local-folder SSR shells (PageTree/FileTree). .layer(CompressionLayer::new()) .layer(TraceLayer::new_for_http().on_failure(())) .layer(axum::middleware::from_fn(log_failed_response)) .layer(axum::middleware::from_fn(inject_request_context)) } async fn log_failed_response(request: Request, next: Next) -> Response { let method = request.method().clone(); let uri = request.uri().clone(); let response = next.run(request).await; let status = response.status(); if status.is_server_error() { let error_code = response .headers() .get("x-error-code") .and_then(|value| value.to_str().ok()) .unwrap_or(""); if error_code == "convex_retired" { warn!( method = %method, uri = %uri, status = %status, error_code = %error_code, "退役 legacy cloud/Convex 兼容路径被请求" ); } else { error!( method = %method, uri = %uri, status = %status, error_code = %error_code, "mnote-web 请求返回服务端错误" ); } } response } #[cfg(test)] mod tests { use super::*; use control_plane::UpsertUserInput; fn test_config() -> AppConfig { 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: None, enable_legacy_next_compat: false, enable_debug_shell_routes: false, enable_editor_actor: true, enable_page_ai_pi_lab: true, compat_next_base_path: "/api/compat/next".into(), convex_url: None, convex_admin_key: None, allow_dev_fixtures: false, query_fixtures_json: None, mutation_fixtures_json: None, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), environment: "dev".into(), } } #[test] fn app_state_initializes_control_plane_store_for_tests() { let state = AppState::new(test_config()); state .control_plane() .upsert_user(UpsertUserInput { id: Some("shujuan".into()), email: Some("shujuan@163.com".into()), username: "shujuan".into(), display_name: "shujuan".into(), role: None, password_hash: None, }) .expect("upsert user"); let workspace = state .control_plane() .ensure_default_workspace("shujuan") .expect("default workspace"); assert_eq!(workspace.name, "shujuan 的空间"); let access = state .control_plane() .resolve_access("shujuan", &workspace.root_uri) .expect("owner access"); assert_eq!(access.permission, "write"); } }