use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::command_support::{ execute_runtime_command_via_legacy_cloud_with_artifacts, runtime_context, }; use crate::routes::local_folder_source::{ ensure_local_workspace_access, execute_local_tree_command, }; use crate::transport::legacy_cloud_guard::{ execute_retired_mutation_by_name, execute_retired_query_by_name, persist_runtime_command_artifacts, }; use axum::extract::{Extension, Path, Query, State}; use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; use axum::Json; use bridge_runtime::{ build_runtime_command_artifact_plan, RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeCommandExecutionPlan, RuntimeSourceWire, RuntimeTargetWire, }; use serde::Deserialize; use serde_json::{json, Value}; use time::format_description::well_known::Rfc3339; use time::{Duration, OffsetDateTime}; const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; const HEADER_RESOURCE_TRASH_TRANSPORT: &str = "x-mnote-resource-trash-transport"; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MediaBatchRequest { pub action: String, #[serde(default)] pub asset_ids: Vec, pub new_name: Option, pub target_document_id: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MediaPurgeRequest { pub asset_id: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WorkspaceTrashRequest { pub workspace_id: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MindmapTrashRequest { pub action: String, pub source_kind: Option, pub root_uri: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MindmapLocalQuery { pub source_kind: Option, pub root_uri: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TableTrashRequest { pub table_id: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TableCreateRequest { pub document_id: String, pub title: Option, pub schema: Option, pub snapshot: Option, } fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, HeaderMap, Json) { let mut headers = HeaderMap::new(); stamp_headers(&mut headers); ( StatusCode::OK, headers, Json(json!({ "ok": true, "requestId": context.trace.request_id, "traceId": context.trace.trace_id, "owner": "mnote-web", "result": result, })), ) } fn stamp_headers(headers: &mut HeaderMap) { if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) { headers.insert(name, HeaderValue::from_static("mnote-web")); } if let Ok(name) = HeaderName::from_lowercase(HEADER_RESOURCE_TRASH_TRANSPORT.as_bytes()) { headers.insert(name, HeaderValue::from_static("resource-trash-api")); } } async fn current_user_id(state: &AppState, context: &RequestContext) -> String { let user_id = context.auth.actor_id.trim(); if !user_id.is_empty() && user_id != "anonymous" { return user_id.to_string(); } let has_auth_cookie = context .auth .cookie_header .as_deref() .map(|value| { value.contains("__convexAuthJWT=") || value.contains("mnote_web_convex_token=") }) .unwrap_or(false); if has_auth_cookie { if let Ok(user) = execute_retired_query_by_name( state.config(), context, "users:currentUser", json!({}), context.workspace.workspace_id.as_deref(), "resource_trash_current_user", ) .await { for key in ["_id", "id"] { if let Some(user_id) = user .get(key) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) { return user_id.to_string(); } } } } state.config().dev_user_id.clone() } fn require_id<'a>( context: &RequestContext, value: &'a str, field: &'static str, ) -> Result<&'a str, WebError> { let value = value.trim(); if value.is_empty() { return Err(WebError::bad_request_code( "resource_id_required", format!("缺少有效 {field}"), ) .with_context(context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")); } Ok(value) } fn force_expired_at() -> String { (OffsetDateTime::now_utc() + Duration::days(365 * 100)) .format(&Rfc3339) .unwrap_or_else(|_| "2126-01-01T00:00:00Z".into()) } fn now_iso_like() -> String { OffsetDateTime::now_utc() .format(&Rfc3339) .unwrap_or_else(|_| "1970-01-01T00:00:00Z".into()) } async fn record_media_empty_trash_artifacts( state: &AppState, context: &RequestContext, user_id: &str, workspace_id: &str, result: &Value, ) -> Result<(), WebError> { let stream_delta_hint = json!({ "family": "tree", "kind": "resync_required", "args": { "reason": "media_empty_trash", "workspaceId": workspace_id, "resourceKind": "media", }, }); let command = RuntimeCommandEnvelopeWire { name: "mediaAssets.emptyTrashByWorkspace".into(), command_id: format!("media_empty_trash_{}", context.trace.request_id), idempotency_key: context.source.idempotency_key.clone(), actor: RuntimeActorWire { actor_type: context.auth.actor_type.clone(), actor_id: user_id.to_string(), session_id: context.auth.session_id.clone(), }, source: RuntimeSourceWire { channel: context.source.channel.clone(), client: context.source.client.clone(), source_kind: None, root_uri: None, workspace_id: Some(workspace_id.to_string()), capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some(workspace_id.to_string()), page_id: None, block_id: None, }), payload: json!({ "workspaceId": workspace_id, "resourceKind": "media", }), preflight_data: None, reason: Some("mnote-web media empty trash".into()), refs: vec!["mnote-web-resource-trash".into()], dry_run: false, validate_only: false, }; let plan = RuntimeCommandExecutionPlan { command_name: command.name.clone(), command_id: command.command_id.clone(), function_name: command.name.clone(), workspace_id: Some(workspace_id.to_string()), request_id: context.trace.request_id.clone(), trace_id: context.trace.trace_id.clone(), actor_id: user_id.to_string(), idempotency_key: context.source.idempotency_key.clone(), source: json!({ "channel": context.source.channel.as_str(), "client": context.source.client.as_str(), "workspaceId": workspace_id, }), payload_json: command.payload.to_string(), args_json: json!({ "userId": user_id, "workspaceId": workspace_id, "streamDeltaHint": stream_delta_hint.clone(), "domainEventHint": { "family": "tree", "eventType": "tree.trash.media.emptied", }, "domainEventPlan": { "family": "tree", "schema": "mnote.tree.domain_event", "schemaVersion": 1, "eventType": "tree.trash.media.emptied", "streamDeltaHint": stream_delta_hint, }, }), }; let runtime_context = runtime_context(context, Some(workspace_id)); if let Some(artifacts) = build_runtime_command_artifact_plan( &runtime_context, &command, &plan, result, &now_iso_like(), ) { persist_runtime_command_artifacts(state.config(), context, &artifacts).await?; } Ok(()) } async fn record_resource_resync_artifacts( state: &AppState, context: &RequestContext, actor_id: &str, workspace_id: &str, document_id: Option<&str>, resource_id: Option<&str>, command_name: &str, event_type: &str, reason: &str, result: &Value, ) -> Result<(), WebError> { let stream_delta_hint = json!({ "family": "tree", "kind": "resync_required", "args": { "reason": reason, "workspaceId": workspace_id, "documentId": document_id, "blockId": resource_id, }, }); let command = RuntimeCommandEnvelopeWire { name: command_name.to_string(), command_id: format!("{}_{}", reason, context.trace.request_id), idempotency_key: context.source.idempotency_key.clone(), actor: RuntimeActorWire { actor_type: context.auth.actor_type.clone(), actor_id: actor_id.to_string(), session_id: context.auth.session_id.clone(), }, source: RuntimeSourceWire { channel: context.source.channel.clone(), client: context.source.client.clone(), source_kind: None, root_uri: None, workspace_id: Some(workspace_id.to_string()), capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: Some(workspace_id.to_string()), page_id: document_id.map(ToOwned::to_owned), block_id: resource_id.map(ToOwned::to_owned), }), payload: json!({ "workspaceId": workspace_id, "documentId": document_id, "resourceId": resource_id, }), preflight_data: None, reason: Some(format!("mnote-web resource resync {command_name}")), refs: vec!["mnote-web-resource-trash".into()], dry_run: false, validate_only: false, }; let plan = RuntimeCommandExecutionPlan { command_name: command.name.clone(), command_id: command.command_id.clone(), function_name: command_name.replace('.', ":"), workspace_id: Some(workspace_id.to_string()), request_id: context.trace.request_id.clone(), trace_id: context.trace.trace_id.clone(), actor_id: actor_id.to_string(), idempotency_key: context.source.idempotency_key.clone(), source: json!({ "channel": context.source.channel.as_str(), "client": context.source.client.as_str(), "workspaceId": workspace_id, }), payload_json: command.payload.to_string(), args_json: json!({ "workspaceId": workspace_id, "documentId": document_id, "resourceId": resource_id, "streamDeltaHint": stream_delta_hint.clone(), "domainEventHint": { "family": "tree", "eventType": event_type, }, "domainEventPlan": { "family": "tree", "schema": "mnote.tree.domain_event", "schemaVersion": 1, "eventType": event_type, "streamDeltaHint": stream_delta_hint, }, }), }; let runtime_context = runtime_context(context, Some(workspace_id)); if let Some(artifacts) = build_runtime_command_artifact_plan( &runtime_context, &command, &plan, result, &now_iso_like(), ) { persist_runtime_command_artifacts(state.config(), context, &artifacts).await?; } Ok(()) } async fn fetch_document_workspace_id( state: &AppState, context: &RequestContext, document_id: &str, ) -> Option { execute_retired_query_by_name( state.config(), context, "documents:getMeta", json!({ "id": document_id, "includeDeleted": true, }), context.workspace.workspace_id.as_deref(), "resource_trash_document_workspace", ) .await .ok() .and_then(|meta| { meta.get("workspace_id") .or_else(|| meta.get("workspaceId")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) }) } async fn fetch_table_meta( state: &AppState, context: &RequestContext, user_id: &str, table_id: &str, ) -> Option { execute_retired_query_by_name( state.config(), context, "tables:get", json!({ "userId": user_id, "tableId": table_id, }), context.workspace.workspace_id.as_deref(), "resource_trash_table_meta", ) .await .ok() .filter(|value| value.is_object()) } fn resource_command( context: &RequestContext, command_name: &str, command_id_suffix: &str, asset_id: &str, actor_id: &str, workspace_id: Option<&str>, document_id: Option<&str>, payload: Value, ) -> RuntimeCommandEnvelopeWire { RuntimeCommandEnvelopeWire { name: command_name.into(), command_id: format!("resource_{command_id_suffix}_{}", context.trace.request_id), idempotency_key: context.source.idempotency_key.clone(), actor: RuntimeActorWire { actor_type: context.auth.actor_type.clone(), actor_id: actor_id.to_string(), session_id: context.auth.session_id.clone(), }, source: RuntimeSourceWire { channel: context.source.channel.clone(), client: context.source.client.clone(), source_kind: None, root_uri: None, workspace_id: workspace_id.map(ToOwned::to_owned), capabilities: Vec::new(), }, target: Some(RuntimeTargetWire { workspace_id: workspace_id.map(ToOwned::to_owned), page_id: document_id.map(ToOwned::to_owned), block_id: Some(asset_id.to_string()), }), payload, preflight_data: None, reason: Some(format!("mnote-web resource compat {command_name}")), refs: vec![ "file-tree-resource-command".into(), "resource-trash-compat-alias".into(), ], dry_run: false, validate_only: false, } } fn annotate_resource_lifecycle_result( mut result: Value, canonical_command: &str, resource_kind: &str, ) -> Value { if let Value::Object(map) = &mut result { map.insert("canonicalCommand".into(), json!(canonical_command)); map.insert("resourceKind".into(), json!(resource_kind)); } result } async fn fetch_media_asset_meta( state: &AppState, context: &RequestContext, user_id: &str, asset_id: &str, ) -> Result { let assets = execute_retired_query_by_name( state.config(), context, "mediaAssets:listByIds", json!({ "userId": user_id, "ids": [asset_id], }), context.workspace.workspace_id.as_deref(), "media_asset_meta", ) .await?; Ok(assets .as_array() .and_then(|items| items.first()) .cloned() .unwrap_or_else(|| json!({}))) } fn read_asset_workspace_and_document(asset: &Value) -> (Option, Option) { let workspace_id = asset .get("workspace_id") .or_else(|| asset.get("workspaceId")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); let document_id = asset .get("document_id") .or_else(|| asset.get("documentId")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); (workspace_id, document_id) } pub async fn media_batch( State(state): State, Extension(context): Extension, Json(body): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let user_id = current_user_id(&state, &context).await; let action = body.action.trim(); if action != "restore" && action != "delete" && action != "rename" && action != "move" { return Err(WebError::bad_request_code( "media_batch_action_unsupported", "Rust resource trash 当前仅支持附件 delete / restore / rename / move", ) .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")); } if body.asset_ids.is_empty() { return Err( WebError::bad_request_code("asset_ids_required", "缺少 assetIds") .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"), ); } let mut updated = 0usize; for asset_id in body.asset_ids.iter().map(String::as_str) { let asset_id = require_id(&context, asset_id, "assetId")?; let asset = fetch_media_asset_meta(&state, &context, &user_id, asset_id).await?; let (workspace_id, document_id) = read_asset_workspace_and_document(&asset); let (command_name, payload) = if action == "restore" { ( "tree.resource.restore", json!({ "resourceKind": "file", "assetId": asset_id, }), ) } else if action == "rename" { let new_name = require_id( &context, body.new_name.as_deref().unwrap_or_default(), "newName", )?; ( "tree.resource.rename", json!({ "resourceKind": "file", "assetId": asset_id, "newName": new_name, }), ) } else if action == "move" { let target_document_id = require_id( &context, body.target_document_id.as_deref().unwrap_or_default(), "targetDocumentId", )?; ( "tree.resource.move", json!({ "resourceKind": "file", "assetId": asset_id, "fromDocumentId": document_id, "targetDocumentId": target_document_id, }), ) } else { ( "tree.resource.archive", json!({ "resourceKind": "file", "assetId": asset_id, }), ) }; let command = resource_command( &context, command_name, action, asset_id, &user_id, workspace_id.as_deref(), document_id.as_deref(), payload, ); if action == "rename" { let new_name = require_id( &context, body.new_name.as_deref().unwrap_or_default(), "newName", )?; execute_retired_mutation_by_name( state.config(), &context, "mediaAssets:patchById", json!({ "userId": user_id, "id": asset_id, "patch": { "file_name": new_name, }, }), workspace_id.as_deref(), None, "media_batch_rename_patch", ) .await?; } if action == "move" { let target_document_id = require_id( &context, body.target_document_id.as_deref().unwrap_or_default(), "targetDocumentId", )?; execute_retired_mutation_by_name( state.config(), &context, "mediaAssets:patchById", json!({ "userId": user_id, "id": asset_id, "patch": { "document_id": target_document_id, }, }), workspace_id.as_deref(), None, "media_batch_move_patch", ) .await?; } let command_result = execute_runtime_command_via_legacy_cloud_with_artifacts( &state, &context, workspace_id.as_deref(), command, ) .await; if action == "rename" || action == "move" { if let Err(error) = command_result { tracing::warn!( error = %error.message(), asset_id = %asset_id, action = %action, "tree.resource artifact command 失败,已保留兼容 patch 结果" ); } } else { command_result?; } updated += 1; } Ok(ok_response( &context, json!({ "action": action, "updated": updated, "restored": if action == "restore" { updated } else { 0 }, "deleted": if action == "delete" { updated } else { 0 }, "renamed": if action == "rename" { updated } else { 0 }, "moved": if action == "move" { updated } else { 0 }, "canonicalCommand": if action == "restore" { "tree.resource.restore" } else if action == "rename" { "tree.resource.rename" } else if action == "move" { "tree.resource.move" } else { "tree.resource.archive" }, }), )) } pub async fn media_purge( State(state): State, Extension(context): Extension, Json(body): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let user_id = current_user_id(&state, &context).await; let asset_id = require_id(&context, &body.asset_id, "assetId")?; let asset = fetch_media_asset_meta(&state, &context, &user_id, asset_id).await?; let (workspace_id, document_id) = read_asset_workspace_and_document(&asset); let command = resource_command( &context, "tree.resource.purge", "purge", asset_id, &user_id, workspace_id.as_deref(), document_id.as_deref(), json!({ "resourceKind": "file", "assetId": asset_id, }), ); let execution = execute_runtime_command_via_legacy_cloud_with_artifacts( &state, &context, workspace_id.as_deref(), command, ) .await?; Ok(ok_response(&context, execution.result)) } pub async fn media_empty_trash( State(state): State, Extension(context): Extension, Json(body): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let user_id = current_user_id(&state, &context).await; let workspace_id = require_id(&context, &body.workspace_id, "workspaceId")?; let result = execute_retired_mutation_by_name( state.config(), &context, "mediaAssets:emptyTrashByWorkspace", json!({ "userId": user_id, "workspaceId": workspace_id, "expiredDeletedAt": force_expired_at(), }), Some(workspace_id), None, "media_empty_trash", ) .await?; let _ = match record_media_empty_trash_artifacts(&state, &context, &user_id, workspace_id, &result) .await { Ok(()) => Ok(()), Err(error) => { let message = error.message().to_string(); tracing::warn!( error = %message, workspace_id = %workspace_id, "media empty trash tree artifacts 记录失败,主清空结果继续返回" ); Err(()) } }; let mut result = result; if let Value::Object(map) = &mut result { map.insert( "canonicalCommand".into(), json!("mediaAssets.emptyTrashByWorkspace"), ); map.insert("compatRoute".into(), json!("/api/media/empty-trash")); } Ok(ok_response(&context, result)) } pub async fn mindmap_delete( State(state): State, Extension(context): Extension, Path((doc_id, mindmap_id)): Path<(String, String)>, Query(query): Query, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let user_id = current_user_id(&state, &context).await; let doc_id = require_id(&context, &doc_id, "docId")?; let mindmap_id = require_id(&context, &mindmap_id, "mindmapId")?; if query.source_kind.as_deref() == Some("local_folder") { let root_uri = query .root_uri .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri") .with_context(&context) })?; ensure_local_workspace_access(&context, root_uri) .map_err(|error| error.with_context(&context))?; let execution = execute_local_tree_command(root_uri, "delete", &mindmap_id, None, None) .map_err(|error| error.with_context(&context))?; return Ok(ok_response( &context, annotate_resource_lifecycle_result(execution, "tree.resource.archive", "mindmap"), )); } let workspace_id = fetch_document_workspace_id(&state, &context, doc_id).await; let command = resource_command( &context, "tree.resource.archive", "mindmap_archive", mindmap_id, &user_id, workspace_id.as_deref(), Some(doc_id), json!({ "resourceKind": "mindmap", "documentId": doc_id, "mindmapId": mindmap_id, }), ); let execution = execute_runtime_command_via_legacy_cloud_with_artifacts( &state, &context, workspace_id.as_deref(), command, ) .await?; Ok(ok_response( &context, annotate_resource_lifecycle_result(execution.result, "tree.resource.archive", "mindmap"), )) } pub async fn mindmap_trash_action( State(state): State, Extension(context): Extension, Path((doc_id, mindmap_id)): Path<(String, String)>, Query(query): Query, Json(body): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let action = body.action.trim(); let command_name = match action { "restore" => "tree.resource.restore", "purge" => "tree.resource.purge", _ => { return Err(WebError::bad_request_code( "mindmap_trash_action_unsupported", "仅支持 restore 或 purge", ) .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")); } }; let user_id = current_user_id(&state, &context).await; let doc_id = require_id(&context, &doc_id, "docId")?; let mindmap_id = require_id(&context, &mindmap_id, "mindmapId")?; let local_source_kind = body .source_kind .as_deref() .or(query.source_kind.as_deref()) .map(str::trim); if local_source_kind == Some("local_folder") { let root_uri = query .root_uri .as_deref() .or(body.root_uri.as_deref()) .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri") .with_context(&context) })?; ensure_local_workspace_access(&context, root_uri) .map_err(|error| error.with_context(&context))?; let local_action = if action == "restore" { "restore" } else { "purge" }; let execution = execute_local_tree_command(root_uri, local_action, mindmap_id, None, None) .map_err(|error| error.with_context(&context))?; return Ok(ok_response( &context, annotate_resource_lifecycle_result(execution, command_name, "mindmap"), )); } let workspace_id = fetch_document_workspace_id(&state, &context, doc_id).await; let command = resource_command( &context, command_name, if action == "restore" { "mindmap_restore" } else { "mindmap_purge" }, mindmap_id, &user_id, workspace_id.as_deref(), Some(doc_id), json!({ "resourceKind": "mindmap", "documentId": doc_id, "mindmapId": mindmap_id, }), ); let execution = execute_runtime_command_via_legacy_cloud_with_artifacts( &state, &context, workspace_id.as_deref(), command, ) .await?; Ok(ok_response( &context, annotate_resource_lifecycle_result(execution.result, command_name, "mindmap"), )) } pub async fn mindmap_empty_trash( State(state): State, Extension(context): Extension, Json(body): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let workspace_id = require_id(&context, &body.workspace_id, "workspaceId")?; let result = execute_retired_mutation_by_name( state.config(), &context, "mindmaps:emptyTrashByWorkspace", json!({ "workspaceId": workspace_id, }), Some(workspace_id), None, "mindmap_empty_trash", ) .await?; let _ = record_resource_resync_artifacts( &state, &context, &context.auth.actor_id, workspace_id, None, None, "mindmaps.emptyTrashByWorkspace", "tree.trash.mindmap.emptied", "mindmap_empty_trash", &result, ) .await .map_err(|error| { tracing::warn!( error = %error.message(), workspace_id = %workspace_id, "mindmap empty trash tree artifacts 记录失败,主清空结果继续返回" ); }); Ok(ok_response(&context, result)) } pub async fn table_restore( State(state): State, Extension(context): Extension, Json(body): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { table_action( state, context, body.table_id, "tables:restore", "table_restore", ) .await } pub async fn table_create( State(state): State, Extension(context): Extension, Json(body): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let user_id = current_user_id(&state, &context).await; let document_id = require_id(&context, &body.document_id, "documentId")?; let workspace_id = fetch_document_workspace_id(&state, &context, document_id) .await .ok_or_else(|| { WebError::bad_request_code("workspace_id_required", "无法解析表格所属 workspace") .with_context(&context) .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") })?; let result = execute_retired_mutation_by_name( state.config(), &context, "tables:create", json!({ "userId": user_id, "workspaceId": workspace_id, "documentId": document_id, "title": body.title, "schema": body.schema.unwrap_or_else(|| json!({})), "snapshot": body.snapshot, }), Some(&workspace_id), None, "table_create", ) .await?; let table_id = result .get("id") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()); let _ = record_resource_resync_artifacts( &state, &context, &user_id, &workspace_id, Some(document_id), table_id, "tables.create", "tree.resource.table.created", "table_create", &result, ) .await .map_err(|error| { tracing::warn!( error = %error.message(), table_id = table_id.unwrap_or(""), "table create tree artifacts 记录失败,主创建结果继续返回" ); }); Ok(ok_response(&context, result)) } pub async fn table_delete( State(state): State, Extension(context): Extension, Path(table_id): Path, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { table_action(state, context, table_id, "tables:remove", "table_delete").await } pub async fn table_purge( State(state): State, Extension(context): Extension, Json(body): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { table_action(state, context, body.table_id, "tables:purge", "table_purge").await } async fn table_action( state: AppState, context: RequestContext, table_id: String, function_name: &'static str, error_phase: &'static str, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let user_id = current_user_id(&state, &context).await; let table_id = require_id(&context, &table_id, "tableId")?; let table_meta = fetch_table_meta(&state, &context, &user_id, table_id).await; let workspace_id = table_meta .as_ref() .and_then(|table| { table .get("workspace_id") .or_else(|| table.get("workspaceId")) }) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); let document_id = table_meta .as_ref() .and_then(|table| table.get("document_id").or_else(|| table.get("documentId"))) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); let (command_name, reason) = match function_name { "tables:restore" => ("tree.resource.restore", "table_restore"), "tables:purge" => ("tree.resource.purge", "table_purge"), _ => ("tree.resource.archive", "table_archive"), }; let command = resource_command( &context, command_name, reason, table_id, &user_id, workspace_id.as_deref(), document_id.as_deref(), json!({ "resourceKind": "table", "tableId": table_id, }), ); let execution = execute_runtime_command_via_legacy_cloud_with_artifacts( &state, &context, workspace_id.as_deref(), command, ) .await?; let _ = error_phase; Ok(ok_response( &context, annotate_resource_lifecycle_result(execution.result, command_name, "table"), )) } pub async fn table_empty_trash( State(state): State, Extension(context): Extension, Json(body): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { let user_id = current_user_id(&state, &context).await; let workspace_id = require_id(&context, &body.workspace_id, "workspaceId")?; let result = execute_retired_mutation_by_name( state.config(), &context, "tables:emptyTrashByWorkspace", json!({ "userId": user_id, "workspaceId": workspace_id, "expiredDeletedAt": force_expired_at(), }), Some(workspace_id), None, "table_empty_trash", ) .await?; let _ = record_resource_resync_artifacts( &state, &context, &user_id, workspace_id, None, None, "tables.emptyTrashByWorkspace", "tree.trash.table.emptied", "table_empty_trash", &result, ) .await .map_err(|error| { tracing::warn!( error = %error.message(), workspace_id = %workspace_id, "table empty trash tree artifacts 记录失败,主清空结果继续返回" ); }); Ok(ok_response(&context, result)) } #[cfg(test)] mod tests { use super::*; use crate::app::{build_app, AppConfig, AppState}; use axum::body::{to_bytes, Body}; use axum::http::Request; 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: false, enable_debug_shell_routes: false, enable_editor_actor: true, enable_page_ai_pi_lab: 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#"{ "documents:getMeta": {"id": "doc_1", "workspace_id": "ws_demo"}, "mediaAssets:listByIds": [{"id": "asset_1", "workspace_id": "ws_demo", "document_id": "doc_1"}], "tables:get": {"id": "table_1", "workspace_id": "ws_demo", "document_id": "doc_1"} }"# .into(), ), mutation_fixtures_json: Some( r#"{ "mediaAssets:patchById": {"ok": true}, "mediaAssets:purgeById": {"ok": true, "deleted": 1}, "mediaAssets:emptyTrashByWorkspace": {"ok": true, "deleted": 2}, "bridgeLogs:recordCommandLog": {"ok": true}, "bridgeLogs:recordDomainEvent": {"ok": true}, "mindmaps:softDelete": {"ok": true, "moved": 1, "deleted_at": "2026-05-15T00:00:00Z"}, "mindmaps:restore": {"ok": true, "updated_at": "2026-05-15T00:00:00Z"}, "mindmaps:purge": {"ok": true}, "mindmaps:emptyTrashByWorkspace": {"ok": true, "deletedCount": 3}, "tables:create": {"id": "table_1", "workspace_id": "ws_demo", "document_id": "doc_1", "title": "表格"}, "tables:remove": {"success": true, "deleted_at": "2026-05-15T00:00:00Z"}, "tables:restore": {"success": true}, "tables:purge": {"success": true}, "tables:emptyTrashByWorkspace": {"ok": true, "deleted": 4} }"# .into(), ), dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), })) } async fn post_json(uri: &str, body: Value) -> Value { let response = app() .oneshot( Request::builder() .method("POST") .uri(uri) .header("content-type", "application/json") .header("x-mnote-actor-id", "user_real") .header("x-mnote-actor-type", "user") .body(Body::from(body.to_string())) .expect("request"), ) .await .expect("response"); let status = response.status(); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body)); serde_json::from_slice(&body).expect("json") } async fn patch_json(uri: &str, body: Value) -> Value { let response = app() .oneshot( Request::builder() .method("PATCH") .uri(uri) .header("content-type", "application/json") .header("x-mnote-actor-id", "user_real") .header("x-mnote-actor-type", "user") .body(Body::from(body.to_string())) .expect("request"), ) .await .expect("response"); let status = response.status(); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body)); serde_json::from_slice(&body).expect("json") } async fn delete_json(uri: &str) -> Value { let response = app() .oneshot( Request::builder() .method("DELETE") .uri(uri) .header("x-mnote-actor-id", "user_real") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("response"); let status = response.status(); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body)); serde_json::from_slice(&body).expect("json") } #[tokio::test] async fn local_folder_mindmap_delete_moves_resource_file_to_trash() { let root = std::env::temp_dir().join(format!("mnote-local-mindmap-trash-{}", std::process::id())); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(root.join("Page")).expect("create page"); let markdown_path = root.join("Page").join("Page.md"); let original_markdown = "# Page\n\n[map](map.mindmap.json)\n"; std::fs::write(&markdown_path, original_markdown).expect("write md"); std::fs::write( root.join("Page").join("map.mindmap.json"), r#"{"data":{"uid":"root","text":"KMIND"},"children":[]}"#, ) .expect("write mindmap"); let root_uri = format!("file://{}", root.display()); crate::routes::local_folder_source::initialize_local_workspace_for_actor( "user_real", &root_uri, ) .expect("init workspace"); let response = app() .oneshot( Request::builder() .method("DELETE") .uri(format!( "/api/mindmap/local-md:Page~2FPage.md/local-file:Page%2Fmap.mindmap.json?sourceKind=local_folder&rootUri={root_uri}" )) .header("x-mnote-actor-id", "user_real") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("response"); let status = response.status(); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body)); assert_eq!( payload["result"]["canonicalCommand"], "tree.resource.archive" ); assert_eq!(payload["result"]["sourceKind"], "local_folder"); assert!(!root.join("Page").join("map.mindmap.json").exists()); assert!(root .join(".mnote") .join("trash") .join("map.mindmap.json") .exists()); assert_eq!( std::fs::read_to_string(&markdown_path).expect("read markdown after trash"), original_markdown, "mindmap archive 只移动资源文件,不应改写正文引用" ); let trash_index = std::fs::read_to_string(root.join(".mnote").join("trash-index.json")) .expect("trash index"); assert!(trash_index.contains("local-file:Page/map.mindmap.json")); let restored = app() .oneshot( Request::builder() .method("PATCH") .uri("/api/mindmap/local-md:Page~2FPage.md/local-file:Page%2Fmap.mindmap.json") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_real") .header("x-mnote-actor-type", "user") .body(Body::from( json!({ "action": "restore", "sourceKind": "local_folder", "rootUri": root_uri, }) .to_string(), )) .expect("request"), ) .await .expect("restore response"); let restore_status = restored.status(); let restore_body = to_bytes(restored.into_body(), usize::MAX) .await .expect("restore body"); let restored_payload: Value = serde_json::from_slice(&restore_body).expect("json"); assert_eq!( restore_status, StatusCode::OK, "{}", String::from_utf8_lossy(&restore_body) ); assert_eq!(restored_payload["result"]["sourceKind"], "local_folder"); assert_eq!( restored_payload["result"]["canonicalCommand"], "tree.resource.restore" ); assert!(root.join("Page").join("map.mindmap.json").exists()); assert_eq!( std::fs::read_to_string(&markdown_path).expect("read markdown after restore"), original_markdown, "mindmap restore 只恢复资源文件,不应改写正文引用" ); let delete_again = app() .oneshot( Request::builder() .method("DELETE") .uri(format!( "/api/mindmap/local-md:Page~2FPage.md/local-file:Page%2Fmap.mindmap.json?sourceKind=local_folder&rootUri={root_uri}" )) .header("x-mnote-actor-id", "user_real") .header("x-mnote-actor-type", "user") .body(Body::empty()) .expect("request"), ) .await .expect("delete again response"); assert_eq!(delete_again.status(), StatusCode::OK); let _ = to_bytes(delete_again.into_body(), usize::MAX).await; let purged = app() .oneshot( Request::builder() .method("PATCH") .uri("/api/mindmap/local-md:Page~2FPage.md/local-file:Page%2Fmap.mindmap.json") .header("content-type", "application/json") .header("x-mnote-actor-id", "user_real") .header("x-mnote-actor-type", "user") .body(Body::from( json!({ "action": "purge", "sourceKind": "local_folder", "rootUri": root_uri, }) .to_string(), )) .expect("request"), ) .await .expect("purge response"); let purge_status = purged.status(); let purge_body = to_bytes(purged.into_body(), usize::MAX) .await .expect("purge body"); let purged_payload: Value = serde_json::from_slice(&purge_body).expect("json"); assert_eq!( purge_status, StatusCode::OK, "{}", String::from_utf8_lossy(&purge_body) ); assert_eq!(purged_payload["result"]["sourceKind"], "local_folder"); assert_eq!( purged_payload["result"]["canonicalCommand"], "tree.resource.purge" ); assert!(!root .join(".mnote") .join("trash") .join("map.mindmap.json") .exists()); let _ = std::fs::remove_dir_all(&root); } #[tokio::test] async fn media_trash_routes_delete_restore_purge_and_empty() { let deleted = post_json( "/api/media/batch", json!({"action": "delete", "assetIds": ["asset_1"]}), ) .await; assert_eq!(deleted["result"]["deleted"], 1); let restored = post_json( "/api/media/batch", json!({"action": "restore", "assetIds": ["asset_1"]}), ) .await; assert_eq!(restored["result"]["restored"], 1); let purged = post_json("/api/media/purge", json!({"assetId": "asset_1"})).await; assert_eq!(purged["result"]["deleted"], 1); let emptied = post_json("/api/media/empty-trash", json!({"workspaceId": "ws_demo"})).await; assert_eq!(emptied["result"]["deleted"], 2); } #[tokio::test] async fn resource_rename_uses_resource_command_not_document_command() { let renamed = post_json( "/api/media/batch", json!({"action": "rename", "assetIds": ["asset_1"], "newName": "附件.docx"}), ) .await; assert_eq!(renamed["result"]["renamed"], 1); assert_eq!( renamed["result"]["canonicalCommand"], "tree.resource.rename" ); assert_ne!(renamed["result"]["canonicalCommand"], "tree.node.rename"); } #[tokio::test] async fn resource_move_uses_resource_command_not_document_command() { let moved = post_json( "/api/media/batch", json!({"action": "move", "assetIds": ["asset_1"], "targetDocumentId": "doc_2"}), ) .await; assert_eq!(moved["result"]["moved"], 1); assert_eq!(moved["result"]["canonicalCommand"], "tree.resource.move"); assert_ne!(moved["result"]["canonicalCommand"], "documents.move"); } #[tokio::test] async fn mindmap_trash_routes_delete_restore_purge_and_empty() { let deleted = delete_json("/api/mindmap/doc_1/mind_1").await; assert_eq!(deleted["result"]["moved"], 1); assert_eq!( deleted["result"]["canonicalCommand"], "tree.resource.archive" ); assert_eq!(deleted["result"]["resourceKind"], "mindmap"); let restored = patch_json("/api/mindmap/doc_1/mind_1", json!({"action": "restore"})).await; assert_eq!(restored["result"]["updated_at"], "2026-05-15T00:00:00Z"); assert_eq!( restored["result"]["canonicalCommand"], "tree.resource.restore" ); assert_eq!(restored["result"]["resourceKind"], "mindmap"); let purged = patch_json("/api/mindmap/doc_1/mind_1", json!({"action": "purge"})).await; assert_eq!(purged["result"]["ok"], true); assert_eq!(purged["result"]["canonicalCommand"], "tree.resource.purge"); assert_eq!(purged["result"]["resourceKind"], "mindmap"); let emptied = post_json( "/api/mindmap-trash/empty", json!({"workspaceId": "ws_demo"}), ) .await; assert_eq!(emptied["result"]["deletedCount"], 3); } #[tokio::test] async fn mindmap_delete_restore_keeps_markdown_reference_out_of_lifecycle_command() { let deleted = delete_json("/api/mindmap/doc_1/mind_1").await; assert_eq!( deleted["result"]["canonicalCommand"], "tree.resource.archive" ); assert_eq!(deleted["result"]["resourceKind"], "mindmap"); assert_eq!(deleted["result"]["documentId"], Value::Null); let restored = patch_json("/api/mindmap/doc_1/mind_1", json!({"action": "restore"})).await; assert_eq!( restored["result"]["canonicalCommand"], "tree.resource.restore" ); assert_eq!(restored["result"]["resourceKind"], "mindmap"); assert_eq!(restored["result"]["documentId"], Value::Null); } #[tokio::test] async fn table_trash_routes_delete_restore_purge_and_empty() { let created = post_json( "/api/tables/create", json!({ "documentId": "doc_1", "title": "表格", "schema": {}, "snapshot": null, }), ) .await; assert_eq!(created["result"]["id"], "table_1"); let deleted = delete_json("/api/tables/table_1").await; assert_eq!(deleted["result"]["success"], true); assert_eq!( deleted["result"]["canonicalCommand"], "tree.resource.archive" ); assert_eq!(deleted["result"]["resourceKind"], "table"); let restored = post_json("/api/tables/restore", json!({"tableId": "table_1"})).await; assert_eq!(restored["result"]["success"], true); assert_eq!( restored["result"]["canonicalCommand"], "tree.resource.restore" ); assert_eq!(restored["result"]["resourceKind"], "table"); let purged = post_json("/api/tables/purge", json!({"tableId": "table_1"})).await; assert_eq!(purged["result"]["success"], true); assert_eq!(purged["result"]["canonicalCommand"], "tree.resource.purge"); assert_eq!(purged["result"]["resourceKind"], "table"); let emptied = post_json("/api/tables/empty-trash", json!({"workspaceId": "ws_demo"})).await; assert_eq!(emptied["result"]["deleted"], 4); } }