推进本地优先迁移与资源闭环
- 补齐共享只读页面 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:
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user