feat(tree): checkpoint resource lifecycle work

提交当前顶层 mnote Git 工作区,范围集中在 04-tree-domain 的 resource/trash/filetree 生命周期、mnote-web resource_trash 路由、Convex/Next 兼容接口、sidebar/file-tree 客户端适配、smoke 脚本与对应设计/bug 记录。

不包含被 ignore 的 design/05-editor-mainline/reference-code/leptos-tiptap 嵌套仓库改动。新增 smoke 的测试密码改为运行时读取 MNOTE_E2E_PASSWORD,避免提交明文 credential assignment。
This commit is contained in:
lix-2026
2026-05-16 07:38:45 +08:00
parent 274779c0d8
commit 384da4e44c
85 changed files with 12570 additions and 394 deletions
+104 -1
View File
@@ -1,16 +1,26 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::transport::convex::{execute_convex_mutation_by_name, execute_convex_query_by_name};
use crate::routes::command_support::{build_runtime_command_plan, runtime_context};
use crate::transport::convex::{
execute_convex_mutation_by_name, execute_convex_query_by_name,
persist_runtime_command_artifacts,
};
use axum::extract::{Multipart, Query, State};
use axum::http::{header, HeaderMap};
use axum::response::{IntoResponse, Response};
use axum::{Extension, Json};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use bridge_runtime::{
build_runtime_command_artifact_plan, RuntimeActorWire, RuntimeCommandEnvelopeWire,
RuntimeSourceWire, RuntimeTargetWire,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
static UPLOAD_COUNTER: AtomicU64 = AtomicU64::new(1);
@@ -110,6 +120,12 @@ fn new_asset_id() -> String {
)
}
fn now_iso_like() -> String {
OffsetDateTime::now_utc()
.format(&Rfc3339)
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".into())
}
fn asset_type(mime: &str) -> &'static str {
if mime.starts_with("image/") {
"image"
@@ -122,6 +138,73 @@ fn asset_type(mime: &str) -> &'static str {
}
}
async fn record_upload_artifacts(
state: &AppState,
context: &RequestContext,
user_id: &str,
workspace_id: &str,
document_id: &str,
asset_id: &str,
file: &UploadFile,
asset_kind: &str,
target_sub_path: Option<&str>,
created: &Value,
) -> Result<(), WebError> {
let command = RuntimeCommandEnvelopeWire {
name: "tree.resource.upload".into(),
command_id: format!("resource_upload_{}", 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: Some(document_id.to_string()),
block_id: Some(asset_id.to_string()),
}),
payload: json!({
"assetId": asset_id,
"workspaceId": workspace_id,
"targetDocumentId": document_id,
"targetSubPath": target_sub_path,
"fileName": file.name,
"fileSize": file.bytes.len(),
"mimeType": file.content_type,
"assetType": asset_kind,
}),
preflight_data: None,
reason: Some("mnote-web media upload tree.resource.upload".into()),
refs: vec!["file-tree-resource-upload".into()],
dry_run: false,
validate_only: false,
};
let runtime_context = runtime_context(context, Some(workspace_id));
let plan = build_runtime_command_plan(context, Some(workspace_id), command.clone())?;
let artifact_result = json!({
"items": [created.clone()],
});
if let Some(artifacts) = build_runtime_command_artifact_plan(
&runtime_context,
&command,
&plan,
&artifact_result,
&now_iso_like(),
) {
persist_runtime_command_artifacts(state.config(), context, &artifacts).await?;
}
Ok(())
}
async fn read_upload_multipart(
mut multipart: Multipart,
) -> Result<(UploadFile, String, String, Option<String>), WebError> {
@@ -367,6 +450,26 @@ pub async fn upload(
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if let Err(error) = record_upload_artifacts(
&state,
&context,
&user_id,
&workspace_id,
&document_id,
&asset_id,
&file,
&kind,
target_sub_path.as_deref(),
&created,
)
.await
{
tracing::warn!(
error = %error.message(),
asset_id = %asset_id,
"media upload tree.resource.upload artifacts 记录失败,主上传结果继续返回"
);
}
Ok(Json(json!({
"asset": created,
"mindmapUrl": format!("asset:{asset_id}"),