chore: 收口 review 执行清单与 runtime 验证
- 补齐 design/10-review 执行清单、验收标准与相关设计治理记录 - 迁移已完成的 tree、mindmap、runtime fallback、AI kernel 等设计和缺陷条目 - 推进 Rust Web runtime、tree/sidebar、page aggregate、mindmap 与 OnlyOffice 路由侧验证支撑 - 增加 task177-task180 smoke/audit 脚本及前端相关测试覆盖
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
use crate::app::AppState;
|
||||
use crate::app::AppConfig;
|
||||
use crate::error::WebError;
|
||||
use adapter_onlyoffice::{prepare_proxy_request, sign_config, OnlyOfficeProxyPreparationInput};
|
||||
use axum::body::{Body, Bytes};
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::{header, HeaderMap, Method, Request, Uri};
|
||||
use axum::http::{header, HeaderMap, HeaderValue, Method, Request, Uri};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde::Deserialize;
|
||||
@@ -715,6 +716,8 @@ pub async fn proxy(
|
||||
}
|
||||
|
||||
pub async fn callback(
|
||||
State(state): State<AppState>,
|
||||
uri: Uri,
|
||||
Query(query): Query<OnlyOfficeCallbackQuery>,
|
||||
Json(body): Json<Value>,
|
||||
) -> Response {
|
||||
@@ -728,10 +731,41 @@ pub async fn callback(
|
||||
status,
|
||||
"OnlyOffice callback received by mnote-web"
|
||||
);
|
||||
Json(json!({ "error": 0 })).into_response()
|
||||
match proxy_legacy_onlyoffice_json(
|
||||
state.config(),
|
||||
"/api/onlyoffice/callback",
|
||||
uri.query(),
|
||||
Some(body),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(error) if error.status() == axum::http::StatusCode::NOT_IMPLEMENTED => (
|
||||
axum::http::StatusCode::NOT_IMPLEMENTED,
|
||||
Json(json!({
|
||||
"error": 1,
|
||||
"degraded": true,
|
||||
"code": "onlyoffice_legacy_writeback_unavailable",
|
||||
"message": error.message(),
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(error) => (
|
||||
axum::http::StatusCode::BAD_GATEWAY,
|
||||
Json(json!({
|
||||
"error": 1,
|
||||
"degraded": true,
|
||||
"code": "onlyoffice_legacy_writeback_failed",
|
||||
"message": error.message(),
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn forcesave(
|
||||
State(state): State<AppState>,
|
||||
uri: Uri,
|
||||
Query(query): Query<OnlyOfficeForcesaveQuery>,
|
||||
) -> Result<Response, WebError> {
|
||||
let asset_id = query
|
||||
@@ -750,13 +784,85 @@ pub async fn forcesave(
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code("onlyoffice_forcesave_key_missing", "缺少 key")
|
||||
})?;
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"via": "mnote-web-rust-noop",
|
||||
"assetId": asset_id,
|
||||
"key": key,
|
||||
}))
|
||||
.into_response())
|
||||
let response = proxy_legacy_onlyoffice_json(
|
||||
state.config(),
|
||||
"/api/onlyoffice/forcesave",
|
||||
uri.query(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if error.status() == axum::http::StatusCode::NOT_IMPLEMENTED {
|
||||
return WebError::new(
|
||||
axum::http::StatusCode::NOT_IMPLEMENTED,
|
||||
"onlyoffice_legacy_writeback_unavailable",
|
||||
format!("OnlyOffice forcesave 未配置 legacy Next 写回链: assetId={asset_id}, key={key}"),
|
||||
);
|
||||
}
|
||||
error
|
||||
})?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn legacy_onlyoffice_writeback_base(config: &AppConfig) -> Option<String> {
|
||||
config
|
||||
.legacy_next_base_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| value.trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
async fn proxy_legacy_onlyoffice_json(
|
||||
config: &AppConfig,
|
||||
path: &str,
|
||||
query: Option<&str>,
|
||||
body: Option<Value>,
|
||||
) -> Result<Response, WebError> {
|
||||
let base = legacy_onlyoffice_writeback_base(config).ok_or_else(|| {
|
||||
WebError::new(
|
||||
axum::http::StatusCode::NOT_IMPLEMENTED,
|
||||
"onlyoffice_legacy_writeback_unavailable",
|
||||
"OnlyOffice Rust route 暂未直接写回,且未配置 legacy Next 写回链",
|
||||
)
|
||||
})?;
|
||||
let target = append_path_and_query(&base, path, query);
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("OnlyOffice legacy proxy HTTP 客户端创建失败: {error}"))
|
||||
})?;
|
||||
let mut request = client
|
||||
.post(target)
|
||||
.header(header::CONTENT_TYPE, "application/json");
|
||||
if let Some(body) = body {
|
||||
request = request.json(&body);
|
||||
} else {
|
||||
request = request.body("{}");
|
||||
}
|
||||
let upstream = request.send().await.map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"onlyoffice_legacy_writeback_failed",
|
||||
format!("OnlyOffice legacy 写回请求失败: {error}"),
|
||||
)
|
||||
})?;
|
||||
let status = upstream.status();
|
||||
let content_type = upstream
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| HeaderValue::from_static("application/json"));
|
||||
let bytes = upstream.bytes().await.map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"onlyoffice_legacy_writeback_failed",
|
||||
format!("OnlyOffice legacy 写回响应读取失败: {error}"),
|
||||
)
|
||||
})?;
|
||||
let mut response = Response::new(Body::from(bytes));
|
||||
*response.status_mut() = status;
|
||||
response.headers_mut().insert(header::CONTENT_TYPE, content_type);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn append_path_and_query(base: &str, path: &str, query: Option<&str>) -> String {
|
||||
@@ -987,6 +1093,13 @@ fn js_hash_abs(input: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::{AppConfig, AppState};
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
#[test]
|
||||
fn stable_doc_key_uses_onlyoffice_safe_characters() {
|
||||
@@ -1003,4 +1116,166 @@ mod tests {
|
||||
let candidates = onlyoffice_internal_candidates();
|
||||
assert!(candidates.contains(&DEFAULT_ONLYOFFICE_INTERNAL_URL.to_string()));
|
||||
}
|
||||
|
||||
fn test_state(legacy_next_base_url: Option<String>) -> AppState {
|
||||
AppState::new(AppConfig {
|
||||
service_name: "mnote-web".into(),
|
||||
service_version: "0.1.0".into(),
|
||||
bind_addr: "127.0.0.1:0".into(),
|
||||
public_bind_addr: "127.0.0.1:3000".into(),
|
||||
legacy_next_base_url,
|
||||
enable_legacy_next_compat: true,
|
||||
enable_debug_shell_routes: false,
|
||||
hermes_base_path: "/api/hermes".into(),
|
||||
compat_next_base_path: "/api/compat/next".into(),
|
||||
convex_url: None,
|
||||
convex_admin_key: None,
|
||||
allow_dev_fixtures: false,
|
||||
query_fixtures_json: None,
|
||||
mutation_fixtures_json: None,
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn spawn_legacy_json_server(
|
||||
response_body: &'static str,
|
||||
) -> (String, oneshot::Receiver<String>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind");
|
||||
let addr = listener.local_addr().expect("addr");
|
||||
let (tx, rx) = oneshot::channel();
|
||||
tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.expect("accept");
|
||||
let mut buffer = vec![0_u8; 8192];
|
||||
let read = stream.read(&mut buffer).await.expect("read");
|
||||
let request = String::from_utf8_lossy(&buffer[..read]).to_string();
|
||||
let _ = tx.send(request);
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
|
||||
response_body.len(),
|
||||
response_body
|
||||
);
|
||||
stream.write_all(response.as_bytes()).await.expect("write");
|
||||
});
|
||||
(format!("http://{addr}"), rx)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn onlyoffice_callback_without_legacy_next_fails_explicitly() {
|
||||
let response = callback(
|
||||
State(test_state(None)),
|
||||
"/api/onlyoffice/callback?assetId=asset_1"
|
||||
.parse::<Uri>()
|
||||
.expect("uri"),
|
||||
Query(OnlyOfficeCallbackQuery {
|
||||
asset_id: Some("asset_1".into()),
|
||||
user_id: None,
|
||||
}),
|
||||
Json(json!({
|
||||
"status": 2,
|
||||
"url": "http://127.0.0.1:8082/cache/files/out.docx"
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["error"], 1);
|
||||
assert_eq!(payload["degraded"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn onlyoffice_callback_proxies_to_legacy_next_writeback() {
|
||||
let (base_url, captured) = spawn_legacy_json_server(r#"{"error":0}"#).await;
|
||||
let response = callback(
|
||||
State(test_state(Some(base_url))),
|
||||
"/api/onlyoffice/callback?assetId=asset_1&userId=user_1"
|
||||
.parse::<Uri>()
|
||||
.expect("uri"),
|
||||
Query(OnlyOfficeCallbackQuery {
|
||||
asset_id: Some("asset_1".into()),
|
||||
user_id: Some("user_1".into()),
|
||||
}),
|
||||
Json(json!({
|
||||
"status": 2,
|
||||
"key": "doc_key",
|
||||
"url": "http://127.0.0.1:8082/cache/files/out.docx"
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
let request = captured.await.expect("captured");
|
||||
|
||||
assert_eq!(payload["error"], 0);
|
||||
assert!(request.starts_with(
|
||||
"POST /api/onlyoffice/callback?assetId=asset_1&userId=user_1 HTTP/1.1"
|
||||
));
|
||||
assert!(request.contains(r#""status":2"#));
|
||||
assert!(request.contains(r#""key":"doc_key""#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn onlyoffice_forcesave_without_legacy_next_is_not_noop_success() {
|
||||
let response = forcesave(
|
||||
State(test_state(None)),
|
||||
"/api/onlyoffice/forcesave?assetId=asset_1&key=doc_key"
|
||||
.parse::<Uri>()
|
||||
.expect("uri"),
|
||||
Query(OnlyOfficeForcesaveQuery {
|
||||
asset_id: Some("asset_1".into()),
|
||||
key: Some("doc_key".into()),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.map(IntoResponse::into_response)
|
||||
.unwrap_or_else(IntoResponse::into_response);
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["ok"], false);
|
||||
assert_eq!(payload["code"], "onlyoffice_legacy_writeback_unavailable");
|
||||
assert_ne!(payload["via"], "mnote-web-rust-noop");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn onlyoffice_forcesave_proxies_to_legacy_next_writeback() {
|
||||
let (base_url, captured) =
|
||||
spawn_legacy_json_server(r#"{"ok":true,"via":"forcesave","result":{"error":0}}"#)
|
||||
.await;
|
||||
let response = forcesave(
|
||||
State(test_state(Some(base_url))),
|
||||
"/api/onlyoffice/forcesave?assetId=asset_1&key=doc_key"
|
||||
.parse::<Uri>()
|
||||
.expect("uri"),
|
||||
Query(OnlyOfficeForcesaveQuery {
|
||||
asset_id: Some("asset_1".into()),
|
||||
key: Some("doc_key".into()),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
let request = captured.await.expect("captured");
|
||||
|
||||
assert_eq!(payload["ok"], true);
|
||||
assert_eq!(payload["via"], "forcesave");
|
||||
assert!(request.starts_with(
|
||||
"POST /api/onlyoffice/forcesave?assetId=asset_1&key=doc_key HTTP/1.1"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user