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

- 补齐共享只读页面 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
@@ -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();