Files
mnote/rust/crates/mnote-web/src/routes/resource_trash.rs
T

1578 lines
53 KiB
Rust

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<String>,
pub new_name: Option<String>,
pub target_document_id: Option<String>,
}
#[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<String>,
pub root_uri: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MindmapLocalQuery {
pub source_kind: Option<String>,
pub root_uri: Option<String>,
}
#[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<String>,
pub schema: Option<Value>,
pub snapshot: Option<Value>,
}
fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, HeaderMap, Json<Value>) {
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<String> {
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<Value> {
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<Value, WebError> {
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<String>, Option<String>) {
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<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<MediaBatchRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<MediaPurgeRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<WorkspaceTrashRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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<AppState>,
Extension(context): Extension<RequestContext>,
Path((doc_id, mindmap_id)): Path<(String, String)>,
Query(query): Query<MindmapLocalQuery>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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<AppState>,
Extension(context): Extension<RequestContext>,
Path((doc_id, mindmap_id)): Path<(String, String)>,
Query(query): Query<MindmapLocalQuery>,
Json(body): Json<MindmapTrashRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<WorkspaceTrashRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<TableTrashRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
table_action(
state,
context,
body.table_id,
"tables:restore",
"table_restore",
)
.await
}
pub async fn table_create(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<TableCreateRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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<AppState>,
Extension(context): Extension<RequestContext>,
Path(table_id): Path<String>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
table_action(state, context, table_id, "tables:remove", "table_delete").await
}
pub async fn table_purge(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<TableTrashRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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<Value>), 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<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<WorkspaceTrashRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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);
}
}