1578 lines
53 KiB
Rust
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);
|
|
}
|
|
}
|