fix: clarify todo-note route misses
This commit is contained in:
@@ -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 #198,CLI 侧 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-op,CLI 不再覆盖。
|
||||
改写后的响应同时带 `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 actions(addTodo / toggleTodoCompleted / deleteTodo),probe 写入 + 删除完整 lifecycle 通。
|
||||
|
||||
|
||||
@@ -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-op,CLI 不再覆盖。
|
||||
- 同样在 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
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user