Merge pull request #264 from pikasTech/fix/todo-note-route-not-found-198

修复 Todo Note 错路径 404 误导文案
This commit is contained in:
Lyon
2026-06-11 16:52:32 +08:00
committed by GitHub
4 changed files with 247 additions and 8 deletions
+3 -3
View File
@@ -170,7 +170,7 @@ bun scripts/cli.ts microservice proxy todo-note /api/instances/<id>/redo --metho
看到 `404 {"error":"Todo Note is running in backend-only mode"}` 时**第一反应**是路径错(用了不存在的 REST 端点被 catch-all 兜底),不是 mode 锁了写;详见 `docs/reference/microservices.md` 的 Todo Note 段。
**Todo Note 错路径结构化诊断(issue #198CLI 侧 interim 修**:上游 `gitee.com/Lyon1998/todo_note` 的 catch-all handler 当前把任何未注册路径都回 `404 {"ok":false,"error":"Todo Note is running in backend-only mode"}`,与 `TODO_NOTE_BACKEND_ONLY=1` 的真实语义(只关 Vite 前端)混淆。`microservice proxy todo-note` 看到这个特征时会把响应 body 改写成结构化诊断:
**Todo Note 错路径结构化诊断(issue #198)**:上游 `gitee.com/Lyon1998/todo_note` 的 catch-all handler 当前把任何未注册路径都回 `404 {"ok":false,"error":"Todo Note is running in backend-only mode"}`,与 `TODO_NOTE_BACKEND_ONLY=1` 的真实语义(只关 Vite 前端)混淆。backend-core 的 `/api/microservices/todo-note/proxy/...` 同源代理看到这个特征时会把响应 body 改写成结构化诊断;CLI 侧保留相同改写作为本地/旧 runtime 兜底
```json
{
@@ -192,7 +192,7 @@ bun scripts/cli.ts microservice proxy todo-note /api/instances/<id>/redo --metho
}
```
改写后的响应同时带 `bodyRewritten: true``rewriteReason` 和原始 `upstreamBody``{"ok":false,"error":"Todo Note is running in backend-only mode"}`),所以审计 / 排障仍能拿到上游真相;上游 PR 落地后这个改写会自动退化为 no-opCLI 不再覆盖
改写后的响应同时带 `bodyRewritten: true``rewriteReason` 和原始 `upstreamBody``{"ok":false,"error":"Todo Note is running in backend-only mode"}`),所以审计 / 排障仍能拿到上游真相;上游 PR 落地后这个特征 body 不再出现,backend-core proxy 和 CLI 兜底改写会自动退化为 no-op。
**`--check-path` 预检(同样针对 todo-note)**:在真正发请求前先校验 path/method 是否命中 CLI 侧 endpoint catalog,命中返回 `ok: true, matched: true, endpoint: {...}`,未命中返回与 rewrite 同源的结构化诊断且**不调用上游**。这能在 1 步内识别 typo、错端口、错 host 等引起的 catch-all 404。命令样例:
@@ -210,7 +210,7 @@ bun scripts/cli.ts microservice proxy todo-note /api/todos --method POST --check
bun scripts/cli.ts microservice proxy code-queue /api/foo --method GET --check-path
```
`--check-path` 与默认 proxy 行为是正交的:默认 proxy 仍然打 upstream 对误导 404 做 rewrite`--check-path` 完全跳过 upstream 调用,只走 CLI 侧 catalog。详见 `scripts/src/microservices.ts``TODO_NOTE_WRITABLE_ENDPOINTS` / `matchTodoNoteEndpoint` / `rewriteTodoNoteMisleadingRouteNotFound` / `runTodoNoteCheckPath`,以及 `scripts/src/e2e.ts``microservice:todo-note-route-diagnostic` 门禁。
`--check-path` 与默认 proxy 行为是正交的:默认 proxy 仍然打 upstream,并由 backend-core proxy 对误导 404 做 rewrite`--check-path` 完全跳过 upstream 调用,只走 CLI 侧 catalog。详见 `src/components/backend-core/src/microservice_proxy.rs` 的 Todo Note route diagnostic、`scripts/src/microservices.ts``TODO_NOTE_WRITABLE_ENDPOINTS` / `matchTodoNoteEndpoint` / `rewriteTodoNoteMisleadingRouteNotFound` / `runTodoNoteCheckPath`,以及 `scripts/src/e2e.ts``microservice:todo-note-route-diagnostic` 门禁。
**复杂 JSON body 优先 `--body-file <path>` 而非 `--body-json '<inline>'`**2026-06-01 摩擦改进验证)。`--body-json` 在 shell 里要把双引号 escape 成 `\"`,遇到 action 对象里有嵌套字段或中英文混排标题几乎必坏;`--body-file` 走文件直读,反引号、反斜杠、中文、嵌套 JSON 都安全,且支持 stdin(`--body-file -`)。推荐做法:先 `cat > /tmp/x.json <<'EOF' ... EOF`heredoc quoted 防 shell 展开),再 `bun scripts/cli.ts microservice proxy todo-note .../actions --method POST --body-file /tmp/x.json`。已交互验证 200 OK 全套 write actionsaddTodo / toggleTodoCompleted / deleteTodo),probe 写入 + 删除完整 lifecycle 通。
+3 -3
View File
@@ -61,9 +61,9 @@ Code Queue runner 也是分布式开发执行面。runner 镜像必须内置 `tr
- **写操作端点形态(2026-06-01 复盘 [#188](https://github.com/pikasTech/unidesk/issues/188) 固化)**Todo Note 不走 REST 集合(如 `/api/instances/:id/todos`),所有 task 写都走 **action 队列**模式 `POST /api/instances/:id/actions` + body `{action: {type, ...}}`。已注册 action type`addTodo` / `updateTodoTitle` / `toggleTodoCompleted` / `toggleTodoExpanded` / `setAllTodosExpanded` / `moveTodo` / `deleteTodo` / `renameInstance` / `setTodoReminder`。其他写端点:`POST /api/instances`body `{name}` 新建清单)、`DELETE /api/instances/:id``POST /api/instances/:id/undo``POST /api/instances/:id/redo`
- **`TODO_NOTE_BACKEND_ONLY=1` 真实语义**:仅关闭 Todo Note 自带 Vite 前端 SPA,不阻挡任何已注册 API 路由(包括所有 POST/DELETE 写)。`/api/health` 暴露的 `backendOnly` 字段是观察用,不是读路径开关。看到 `404 {"error":"Todo Note is running in backend-only mode"}` 时的第一反应是路径写错(用了不存在的 REST 端点被 catch-all 兜底),不是写被 mode 锁。CLI 写操作范式见 `docs/reference/cli.md``microservice proxy` 段。
- **Catch-all 误导文案 + CLI 改写 / 预检issue #198 interim**:上游 `gitee.com/Lyon1998/todo_note` 的 catch-all 把所有未注册路径都回 `404 {"ok":false,"error":"Todo Note is running in backend-only mode"}`,秘书/agent 看到这条第一反应容易误判成 "写被 mode 锁了"。UniDesk CLI 侧在 `microservice proxy todo-note` 检测到该特征 body 时会做就地改写,输出结构化诊断(`writableApiEndpoints``actionTypes``backendOnly: true``method``path``issueReference``upstreamBody` 完整保留原始误导文案用于审计),并把 `bodyRewritten: true` 显式返回;上游 catch-all 修复 PR 合并后改写自动退化为 no-opCLI 不再覆盖
- 同样在 CLI 侧提供 `--check-path` 预检:未命中时返回同源结构化诊断并**不调用 upstream**;命中时返回 `ok: true, matched: true, endpoint: {...}`。当前 `--check-path` 只支持 `service=todo-note`,对其他服务返回结构化 `unsupported` 错误。命令样例见 `docs/reference/cli.md``microservice proxy` 段。
- 验证门禁走 `scripts/src/e2e.ts``microservice:todo-note-route-diagnostic`(覆盖错路径 rewrite、命中 / 未命中 check-path、非 todo-note 服务的 unsupported)。该门禁是 CLI 侧 interim 自检,不替代上游 catch-all 修复 PR上游落地后由 `bun scripts/cli.ts server rebuild todo-note` 拉新 commit 镜像,并继续用 `microservice health todo-note` + `microservice proxy todo-note /api/instances/<id>/actions` 验证。
- **Catch-all 误导文案 + proxy 结构化诊断issue #198**:上游 `gitee.com/Lyon1998/todo_note` 的 catch-all 把所有未注册路径都回 `404 {"ok":false,"error":"Todo Note is running in backend-only mode"}`,秘书/agent 看到这条第一反应容易误判成 "写被 mode 锁了"。UniDesk backend-core 在 `/api/microservices/todo-note/proxy/...` 同源代理层检测到该特征 body 时会改写为结构化诊断(`error: "Todo Note route not found"``writableApiEndpoints``actionTypes``backendOnly: true``method``path``issueReference``upstreamBody` 完整保留原始误导文案用于审计),并把 `bodyRewritten: true` 显式返回;CLI 侧保留相同改写作为本地/旧 runtime 兜底。上游 catch-all 修复 PR 合并后,该特征 body 不再出现,proxy/CLI 改写自动退化为 no-op。
- CLI 侧提供 `--check-path` 预检:未命中时返回同源结构化诊断并**不调用 upstream**;命中时返回 `ok: true, matched: true, endpoint: {...}`。当前 `--check-path` 只支持 `service=todo-note`,对其他服务返回结构化 `unsupported` 错误。命令样例见 `docs/reference/cli.md``microservice proxy` 段。
- 验证门禁走 `scripts/src/e2e.ts``microservice:todo-note-route-diagnostic`(覆盖错路径 proxy/CLI rewrite、命中 / 未命中 check-path、非 todo-note 服务的 unsupported)。上游落地后由 `bun scripts/cli.ts server rebuild todo-note` 或版本化 artifact consumer 拉新 commit 镜像,并继续用 `microservice health todo-note` + `microservice proxy todo-note /api/instances/<id>/actions` 验证。
- UniDesk 前端:`用户服务 / Todo Note` React 页面负责展示清单列表、树形任务、筛选、提醒、拖放/上移下移、撤销/重做、字号控制和显式原始 JSON 按钮。
Todo Note 在 UniDesk 语境中按纯后端服务管理:不得继续公开 Todo Note 自身 Vite/Web 前端,也不得把 `4211` 映射为公网端口。浏览器只能通过 UniDesk frontend 的 `/api/microservices/todo-note/...` 同源代理访问 Todo Note 后端。标准 artifact consumer 路径为 `bun scripts/cli.ts deploy apply --env dev|prod --service todo-note`;由于 Todo Note 源码仍在外部 Gitee 仓库,D601 registry 中必须先已有 `127.0.0.1:5000/unidesk/todo-note:<commit>`。Compose 在 recreate 时注入 `UNIDESK_TODO_NOTE_DEPLOY_*`artifact consumer 的健康探针读取 `/api/health` 并合成 `deploy.commit``deploy.requestedCommit` 供强校验。
+5 -2
View File
@@ -1780,6 +1780,9 @@ function runTodoNoteRouteDiagnosticChecks(): { ok: boolean; detail: unknown } {
// 1) Bad path: CLI must rewrite the misleading 404 to the structured diagnostic.
const badPathRewrite = runTodoNoteCliProbe(["/api/instances/instance_probe_bad/todos", "--method", "POST", "--body-json", JSON.stringify({ title: "e2e-route-diagnostic" })]);
const badPathBody = (badPathRewrite.payload as { data?: { body?: Record<string, unknown> } } | null)?.data?.body ?? null;
const badPathData = (badPathRewrite.payload as { data?: Record<string, unknown> } | null)?.data ?? null;
const badPathBodyRewritten = badPathBody?.bodyRewritten === true || badPathData?.bodyRewritten === true;
const badPathUpstreamError = (badPathBody?.upstreamBody as { error?: string } | undefined)?.error ?? (badPathData?.upstreamBody as { error?: string } | undefined)?.error;
const rewriteOk = (
badPathRewrite.exitCode === 1
&& badPathBody !== null
@@ -1791,8 +1794,8 @@ function runTodoNoteRouteDiagnosticChecks(): { ok: boolean; detail: unknown } {
&& (badPathBody.writableApiEndpoints as Array<{ path?: string }>).some((endpoint) => endpoint.path === "/api/instances/:instanceId/actions")
&& Array.isArray(badPathBody.actionTypes)
&& (badPathBody.actionTypes as string[]).includes("addTodo")
&& (badPathRewrite.payload as { data?: { bodyRewritten?: boolean } } | null)?.data?.bodyRewritten === true
&& (badPathRewrite.payload as { data?: { upstreamBody?: { error?: string } } } | null)?.data?.upstreamBody?.error === "Todo Note is running in backend-only mode"
&& badPathBodyRewritten
&& badPathUpstreamError === "Todo Note is running in backend-only mode"
);
if (!rewriteOk) issues.push("bad-path-rewrite");
// 2) --check-path matched: POST /api/instances must succeed and return matched=true.
@@ -591,6 +591,162 @@ fn content_type_is_json(content_type: &str) -> bool {
content_type.to_ascii_lowercase().contains("json")
}
const TODO_NOTE_ACTION_TYPES: &[&str] = &[
"addTodo",
"updateTodoTitle",
"toggleTodoCompleted",
"toggleTodoExpanded",
"setAllTodosExpanded",
"moveTodo",
"deleteTodo",
"renameInstance",
"setTodoReminder",
];
const TODO_NOTE_WRITABLE_ENDPOINTS: &[(&str, &str, &str)] = &[
(
"POST",
"/api/instances",
"Create a new todo list; body {name}.",
),
(
"DELETE",
"/api/instances/:instanceId",
"Delete a todo list.",
),
(
"POST",
"/api/instances/:instanceId/actions",
"Apply a typed action; body {action: {type, ...}}. Use the action queue pattern, not REST collection paths like /api/instances/:id/todos.",
),
(
"POST",
"/api/instances/:instanceId/undo",
"Undo the last applied action.",
),
(
"POST",
"/api/instances/:instanceId/redo",
"Redo the last undone action.",
),
];
fn is_todo_note_misleading_route_not_found(
status: u16,
content_type: &str,
body_text: &str,
) -> Option<Value> {
if status != 404 || !content_type_is_json(content_type) {
return None;
}
let body = serde_json::from_str::<Value>(body_text).ok()?;
let record = body.as_object()?;
if record.get("ok").and_then(Value::as_bool) == Some(false)
&& record.get("error").and_then(Value::as_str)
== Some("Todo Note is running in backend-only mode")
{
Some(body)
} else {
None
}
}
fn todo_note_route_not_found_body(
method: &Method,
target_path: &str,
upstream_body: Value,
) -> Value {
json!({
"ok": false,
"error": "Todo Note route not found",
"backendOnly": true,
"method": method.as_str(),
"path": target_path,
"bodyRewritten": true,
"rewriteReason": "Todo Note catch-all 404 body was misleading; UniDesk proxy wrapped it with a structured route diagnostic. See docs/reference/cli.md and pikasTech/unidesk issue #198 for context.",
"writableApiEndpoints": TODO_NOTE_WRITABLE_ENDPOINTS.iter().map(|(method, path, hint)| json!({ "method": method, "path": path, "hint": hint })).collect::<Vec<Value>>(),
"actionTypes": TODO_NOTE_ACTION_TYPES,
"hint": "Write operations use the action queue pattern: POST /api/instances/:instanceId/actions with body {action: {type, ...}}. The requested path/method is not a registered Todo Note API route; TODO_NOTE_BACKEND_ONLY only disables the upstream Vite SPA and does not lock registered API writes.",
"issueReference": "pikasTech/unidesk#198",
"upstreamBody": upstream_body,
})
}
async fn rewrite_todo_note_misleading_route_not_found_response(
service: &MicroserviceConfig,
method: &Method,
target_path: &str,
response: Response,
) -> Response {
if service.id != "todo-note" {
return response;
}
let status = response.status().as_u16();
let headers = response.headers().clone();
let content_type = headers
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
if status != 404 || !content_type_is_json(&content_type) {
return response;
}
let bytes = to_bytes(response.into_body(), 16 * 1024 * 1024)
.await
.unwrap_or_default();
let body_text = String::from_utf8_lossy(&bytes).to_string();
let Some(upstream_body) =
is_todo_note_misleading_route_not_found(status, &content_type, &body_text)
else {
let mut rebuilt = response_with_body(status, &content_type, body_text);
for name in FORWARD_RESPONSE_HEADERS {
if let Some(value) = headers.get(*name).and_then(|value| value.to_str().ok()) {
add_header(&mut rebuilt, name, value);
}
}
if let Some(value) = headers
.get("x-unidesk-proxy-mode")
.and_then(|value| value.to_str().ok())
{
add_header(&mut rebuilt, "x-unidesk-proxy-mode", value);
}
if let Some(value) = headers
.get("x-unidesk-upstream-proxy-mode")
.and_then(|value| value.to_str().ok())
{
add_header(&mut rebuilt, "x-unidesk-upstream-proxy-mode", value);
}
return rebuilt;
};
let mut rewritten = json_response(
todo_note_route_not_found_body(method, target_path, upstream_body),
404,
);
for name in FORWARD_RESPONSE_HEADERS {
if let Some(value) = headers.get(*name).and_then(|value| value.to_str().ok()) {
add_header(&mut rewritten, name, value);
}
}
if let Some(value) = headers
.get("x-unidesk-proxy-mode")
.and_then(|value| value.to_str().ok())
{
add_header(&mut rewritten, "x-unidesk-proxy-mode", value);
}
if let Some(value) = headers
.get("x-unidesk-upstream-proxy-mode")
.and_then(|value| value.to_str().ok())
{
add_header(&mut rewritten, "x-unidesk-upstream-proxy-mode", value);
}
add_header(
&mut rewritten,
"x-unidesk-todo-note-route-diagnostic",
"true",
);
rewritten
}
fn apply_json_array_limits(body_text: String, content_type: &str, limits: &Value) -> String {
let Some(limits) = limits.as_object() else {
return body_text;
@@ -2391,6 +2547,13 @@ pub async fn microservice_route(
body_text,
)
.await;
let response = rewrite_todo_note_misleading_route_not_found_response(
&service,
&method,
&target_path,
response,
)
.await;
if (method == Method::GET || method == Method::HEAD)
&& is_microservice_transient_failure_response(&response)
{
@@ -2417,6 +2580,79 @@ pub async fn microservice_route(
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn todo_note_route_diagnostic_matches_only_misleading_404_body() {
let upstream = r#"{"ok":false,"error":"Todo Note is running in backend-only mode"}"#;
let matched = is_todo_note_misleading_route_not_found(
404,
"application/json; charset=utf-8",
upstream,
)
.expect("misleading body should match");
assert_eq!(
matched.get("error").and_then(Value::as_str),
Some("Todo Note is running in backend-only mode")
);
assert!(
is_todo_note_misleading_route_not_found(
404,
"application/json",
r#"{"ok":false,"error":"Cannot POST /api/todos"}"#,
)
.is_none()
);
assert!(
is_todo_note_misleading_route_not_found(200, "application/json", upstream).is_none()
);
}
#[test]
fn todo_note_route_diagnostic_describes_missing_post_path() {
let body = todo_note_route_not_found_body(
&Method::POST,
"/api/instances/instance_probe_bad/todos",
json!({ "ok": false, "error": "Todo Note is running in backend-only mode" }),
);
assert_eq!(
body.get("error").and_then(Value::as_str),
Some("Todo Note route not found")
);
assert_eq!(body.get("backendOnly").and_then(Value::as_bool), Some(true));
assert_eq!(body.get("method").and_then(Value::as_str), Some("POST"));
assert_eq!(
body.get("path").and_then(Value::as_str),
Some("/api/instances/instance_probe_bad/todos")
);
assert_eq!(
body.get("bodyRewritten").and_then(Value::as_bool),
Some(true)
);
assert!(
body.get("writableApiEndpoints")
.and_then(Value::as_array)
.is_some_and(|endpoints| endpoints.iter().any(|endpoint| endpoint
.get("path")
.and_then(Value::as_str)
== Some("/api/instances/:instanceId/actions")))
);
assert!(
body.get("actionTypes")
.and_then(Value::as_array)
.is_some_and(|actions| actions
.iter()
.any(|action| action.as_str() == Some("addTodo")))
);
assert_eq!(
body.pointer("/upstreamBody/error").and_then(Value::as_str),
Some("Todo Note is running in backend-only mode")
);
}
}
fn urlencoding_like(value: &str) -> String {
percent_encoding::utf8_percent_encode(value, percent_encoding::NON_ALPHANUMERIC).to_string()
}