推进本地优先迁移与资源闭环

- 补齐共享只读页面 AI 浏览器 smoke,并回填当前优先级 checklist 的 P4/P5/P6 证据。

- 为 OnlyOffice 资源增加 /office/{documentId}/{assetId} 对象壳,固定 resource identity 与侧边栏打开路径。

- 扩展 Convex 导出脚本,支持 dry-run、manifest、冲突报告、索引刷新与 rollback,并补充 smoke。

- 补充资源 AI 工具合同与 Convex 导出 Web 入口设计。
This commit is contained in:
lix-2026
2026-05-19 11:39:04 +08:00
parent a1b28a8e38
commit 94be1c927b
11 changed files with 1097 additions and 73 deletions
@@ -4163,12 +4163,6 @@ fn local_agent_audit_should_skip(path: &FsPath) -> bool {
)
}
fn local_agent_audit_hash(text: &str) -> u64 {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
text.hash(&mut hasher);
hasher.finish()
}
fn local_agent_audit_snapshot_entry(
path: &FsPath,
) -> Result<LocalAgentAuditFileSnapshot, WebError> {
@@ -4185,18 +4179,22 @@ fn local_agent_audit_snapshot_entry(
.map(|duration| duration.as_millis())
.unwrap_or_default();
let size = metadata.len();
let content = fs::read_to_string(path).ok();
let hash = content
.as_deref()
.map(local_agent_audit_hash)
.unwrap_or_default();
let markdown_content = content.and_then(|content| {
if path.extension().and_then(|value| value.to_str()) == Some("md") {
Some(content)
} else {
None
}
});
let bytes = fs::read(path).map_err(|error| {
WebError::bad_request_code(
"local_ai_audit_snapshot_failed",
format!("无法读取本地文件快照内容: {error}"),
)
})?;
let hash = {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut hasher);
hasher.finish()
};
let markdown_content = if path.extension().and_then(|value| value.to_str()) == Some("md") {
String::from_utf8(bytes).ok()
} else {
None
};
Ok(LocalAgentAuditFileSnapshot {
size,
modified_ms,
@@ -5593,6 +5591,38 @@ mod tests {
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_agent_audit_snapshot_detects_resource_files() {
let root = std::env::temp_dir().join(format!(
"mnote-local-agent-audit-resources-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("maps")).expect("create maps dir");
std::fs::create_dir_all(root.join("office")).expect("create office dir");
std::fs::write(root.join("maps/a.mindmap.json"), r#"{"root":"old"}"#)
.expect("write mindmap");
std::fs::write(root.join("office/a.docx"), [0xff, 1, 2, 3]).expect("write office");
let root_uri = format!("file://{}", root.display());
let before = local_agent_audit_collect_snapshot(&root_uri).expect("before snapshot");
std::fs::write(root.join("maps/a.mindmap.json"), r#"{"root":"new"}"#)
.expect("modify mindmap");
std::fs::write(root.join("office/a.docx"), [0xfe, 1, 2, 3]).expect("modify office");
let after = local_agent_audit_collect_snapshot(&root_uri).expect("after snapshot");
let changed = local_agent_audit_change_files(&before, &after);
let files = changed.as_array().expect("changed files");
assert!(files.iter().any(|file| {
file["path"] == "maps/a.mindmap.json" && file["changeType"] == "modified"
}));
assert!(files
.iter()
.any(|file| { file["path"] == "office/a.docx" && file["changeType"] == "modified" }));
let _ = std::fs::remove_dir_all(&root);
}
fn app() -> axum::Router {
app_with_config(AppConfig {
service_name: "mnote-web".into(),
+4
View File
@@ -154,6 +154,10 @@ pub fn build_router(state: AppState) -> Router {
post(page_ai_workflow::block_edit_workflow),
)
.route("/onlyoffice", get(onlyoffice::page))
.route(
"/office/{document_id}/{asset_id}",
get(onlyoffice::object_shell),
)
.route("/onlyoffice-server/{*path}", any(onlyoffice::server_proxy))
.route("/cache/{*path}", any(onlyoffice::cache_proxy))
.route("/api/onlyoffice/sign", post(onlyoffice::sign))
@@ -257,6 +257,19 @@ fn json_string(value: &str) -> String {
serde_json::to_string(value).unwrap_or_else(|_| "\"\"".into())
}
fn encode_query_component(value: &str) -> String {
let mut encoded = String::new();
for byte in value.as_bytes() {
let ch = *byte as char;
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '~') {
encoded.push(ch);
} else {
encoded.push_str(&format!("%{byte:02X}"));
}
}
encoded
}
fn header_value(headers: &HeaderMap, name: &str) -> Option<String> {
headers
.get(name)
@@ -630,6 +643,67 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
Ok(Html(html).into_response())
}
pub async fn object_shell(
Path((document_id, asset_id)): Path<(String, String)>,
Query(query): Query<OnlyOfficePageQuery>,
) -> Result<Response, WebError> {
let document_id = document_id.trim();
let asset_id = asset_id.trim();
if document_id.is_empty() || asset_id.is_empty() {
return Err(WebError::bad_request_code(
"onlyoffice_resource_identity_required",
"缺少有效 documentId 或 assetId",
));
}
let file_name = query.file_name.unwrap_or_else(|| "附件".into());
let file_type = query.file_type.unwrap_or_else(|| "docx".into());
let mode = query.mode.unwrap_or_else(|| "edit".into());
let user_id = query.user_id.unwrap_or_default();
let file_url = query.file_url.unwrap_or_default();
let onlyoffice_url = {
let mut params = Vec::new();
params.push(("fileUrl", file_url.as_str()));
params.push(("fileName", file_name.as_str()));
params.push(("fileType", file_type.as_str()));
params.push(("assetId", asset_id));
params.push(("documentId", document_id));
params.push(("userId", user_id.as_str()));
params.push(("mode", mode.as_str()));
let query = params
.into_iter()
.map(|(key, value)| format!("{key}={}", encode_query_component(value)))
.collect::<Vec<_>>()
.join("&");
format!("/onlyoffice?{query}")
};
let object_identity = format!("resource:onlyoffice:{document_id}:{asset_id}");
let html = format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
<style>
html, body, .mnote-office-object-shell, iframe {{ width: 100%; height: 100%; margin: 0; border: 0; }}
body {{ overflow: hidden; background: #f7f7f5; }}
</style>
</head>
<body>
<main class="mnote-office-object-shell" data-mnote-object-editor="onlyoffice" data-mnote-object-identity="{object_identity}" data-document-id="{document_id}" data-asset-id="{asset_id}">
<iframe src="{onlyoffice_url}" title="{title}" allow="clipboard-read; clipboard-write; fullscreen"></iframe>
</main>
</body>
</html>"#,
title = escape_html(&file_name),
object_identity = escape_html(&object_identity),
document_id = escape_html(document_id),
asset_id = escape_html(asset_id),
onlyoffice_url = escape_html(&onlyoffice_url),
);
Ok(Html(html).into_response())
}
pub async fn sign(Json(payload): Json<OnlyOfficeSignPayload>) -> Result<Response, WebError> {
let config = payload.config.ok_or_else(|| {
WebError::bad_request_code("onlyoffice_sign_config_missing", "缺少 config")
@@ -1117,6 +1191,37 @@ mod tests {
assert!(key.starts_with("asset_1_"));
}
#[tokio::test]
async fn onlyoffice_object_shell_exposes_resource_identity() {
let response = object_shell(
Path(("doc_1".into(), "asset_docx".into())),
Query(OnlyOfficePageQuery {
file_url: Some("/api/media/sign?assetId=asset_docx".into()),
file_name: Some("方案.docx".into()),
file_type: Some("docx".into()),
asset_id: None,
document_id: None,
user_id: Some("user_1".into()),
mode: Some("edit".into()),
}),
)
.await
.expect("object shell");
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("html");
assert!(html.contains("data-mnote-object-editor=\"onlyoffice\""));
assert!(
html.contains("data-mnote-object-identity=\"resource:onlyoffice:doc_1:asset_docx\"")
);
assert!(html.contains("/onlyoffice?"));
assert!(html.contains("assetId=asset_docx"));
assert!(html.contains("documentId=doc_1"));
}
#[test]
fn onlyoffice_internal_candidates_keep_default_first_after_env() {
let candidates = onlyoffice_internal_candidates();
@@ -1179,6 +1179,22 @@ mod tests {
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 mindmap_trash_routes_delete_restore_purge_and_empty() {
let deleted = delete_json("/api/mindmap/doc_1/mind_1").await;
@@ -1210,6 +1226,25 @@ mod tests {
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(