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:
@@ -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}"),
|
||||
|
||||
Reference in New Issue
Block a user