From 7188c62d3ac22d2f04ed4723accdde3b74d620fd Mon Sep 17 00:00:00 2001 From: zikai Date: Tue, 21 Jul 2026 14:51:33 +0000 Subject: [PATCH] =?UTF-8?q?refactor:=20=E7=99=BD=E6=9D=BF=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E6=94=B9=20/wb=20=E5=89=8D=E7=BC=80=EF=BC=8C=E6=B6=88?= =?UTF-8?q?=E9=99=A4=20HTML=20=E4=B8=8E=20REST=20=E5=90=8C=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E5=86=B2=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:GET /whiteboard/{board_id} 同时被 REST(返回 JSON)与 main.py 的 HTML 页面 注册,FastAPI 按注册顺序匹配到 REST,导致浏览器访问拿到 JSON 而非前端页面。 改为按职责分命名空间,避免冲突: - HTML 页面:/wb/{id}(main.py)、/wb-admin(main.py,Basic Auth) - 公开 REST:GET /api/wb/{id}(前端 init 拉取初始笔画) - WS:/ws/wb/{id}(实时同步 + 心跳) - 管理 REST:GET /api/admin/wb、DELETE /api/admin/wb/{id}(Basic Auth) 前端 whiteboard.js / whiteboard_admin.js、测试脚本、README 路径同步更新。 旧 /whiteboard/* 路径不再注册(404)。 --- README.md | 22 +++++++++++----------- app/controllers/whiteboard_controller.py | 23 +++++++++++++---------- app/main.py | 10 +++++----- static/whiteboard.js | 12 ++++++------ static/whiteboard_admin.js | 10 +++++----- tests/manual_whiteboard_kick.py | 4 ++-- tests/manual_whiteboard_ws.py | 4 ++-- 7 files changed, 44 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 24dc6d2..1ea723b 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,10 @@ - **文件浏览页** `GET /files`(Basic Auth,同 docs):列出/下载/**硬删除**已上传文件;删除后不再显示。 管理 API:`GET /api/admin/files`、`GET /api/admin/files/{id}`、`GET /api/admin/files/{id}/download`、 `DELETE /api/admin/files/{id}`(均 Basic Auth)。 -- **共享白板** `GET /whiteboard/{id}`(公开,不存在则新建):Canvas 实时协作 + **清空 / 复制链接**,兼容移动端。 - 实时同步走 `WS /ws/whiteboard/{id}`(**心跳 3s,连续 5 次丢失判失活并移除**)。 -- **白板管理页** `GET /whiteboard-admin`(Basic Auth,同 docs):查看创建时间/修改次数/上次修改时间/删除。 - 管理 API:`GET /api/admin/whiteboards`、`DELETE /api/admin/whiteboards/{id}`(均 Basic Auth)。 +- **共享白板** `GET /wb/{id}`(公开,不存在则新建):Canvas 实时协作 + **清空 / 复制链接**,兼容移动端。 + 实时同步走 `WS /ws/wb/{id}`(**心跳 3s,连续 5 次丢失判失活并移除**)。 +- **白板管理页** `GET /wb-admin`(Basic Auth,同 docs):查看创建时间/修改次数/上次修改时间/删除。 + 管理 API:`GET /api/admin/wb`、`DELETE /api/admin/wb/{id}`(均 Basic Auth)。 - **反向隧道反代**:`ALL /api/userPort/{userName}` -- 把请求经 SSH 反向隧道转发到该 user 的本机服务。 - **内置 SFTP/SSH 服务器**(asyncssh),支持 **密码 + 公钥** 鉴权,同时承载 SFTP 文件暂存与反向隧道。 - `/docs`(Swagger UI)与 `/redoc` - 交互式文档,自动列出所有 API。 @@ -70,8 +70,8 @@ cd /root/zikai | API 文档(ReDoc) | https://f.zikai.wang/redoc(同样鉴权) | | 上传页(拖拽、分片、断点续传) | https://f.zikai.wang/upload | | 文件浏览页(列出/下载/删除) | https://f.zikai.wang/files **(Basic Auth,同 docs)** | -| 共享白板(实时协作) | https://f.zikai.wang/whiteboard/{id}(公开,`{id}` 为 `[a-zA-Z0-9_-]{1,64}`,不存在则新建) | -| 白板管理页 | https://f.zikai.wang/whiteboard-admin **(Basic Auth,同 docs)** | +| 共享白板(实时协作) | https://f.zikai.wang/wb/{id}(公开,`{id}` 为 `[a-zA-Z0-9_-]{1,64}`,不存在则新建) | +| 白板管理页 | https://f.zikai.wang/wb-admin **(Basic Auth,同 docs)** | | 上传(curl) | `curl -F file=@big.iso https://f.zikai.wang/api/files/upload` | | SFTP | `sftp -P 2022 uploader@f.zikai.wang` | @@ -170,13 +170,13 @@ SSH 服务器(2022)同时承载 SFTP 文件暂存与反向隧道。隧道 us ## 共享白板 -白板无鉴权,任何人凭 `/whiteboard/{id}` 即可访问并实时协作;`{id}` 须匹配 +白板无鉴权,任何人凭 `/wb/{id}` 即可访问并实时协作;`{id}` 须匹配 `[a-zA-Z0-9_-]{1,64}`,非法返回 400。访问不存在的 id 自动新建空板。白板长期留存 (存 MySQL `whiteboard` 表),进程重启后内容仍在。 ### 实时同步与心跳 -- 连接:`WS /ws/whiteboard/{id}`(公开)。JSON 文本帧协议: +- 连接:`WS /ws/wb/{id}`(公开)。JSON 文本帧协议: - client -> server:`{"type":"hello","client_id":"..."}`(首帧,可选)、 `{"type":"ping"}`(心跳)、`{"type":"stroke","stroke":{points,color,width}}`、`{"type":"clear"}` - server -> client:`{"type":"init","strokes":[...],"stroke_count":n}`、`{"type":"pong"}`、 @@ -195,10 +195,10 @@ SSH 服务器(2022)同时承载 SFTP 文件暂存与反向隧道。隧道 us ### 白板管理 -- `GET /whiteboard-admin`(Basic Auth,同 docs)渲染 `static/whiteboard_admin.html`。 +- `GET /wb-admin`(Basic Auth,同 docs)渲染 `static/whiteboard_admin.html`。 - 管理 API(均 Basic Auth): - - `GET /api/admin/whiteboards?limit=&offset=` -> `{total, items:[{board_id, stroke_count, created_at, updated_at}]}` - - `DELETE /api/admin/whiteboards/{id}` -> 删 DB 行 + 关闭该 board 所有在线 WS 连接。 + - `GET /api/admin/wb?limit=&offset=` -> `{total, items:[{board_id, stroke_count, created_at, updated_at}]}` + - `DELETE /api/admin/wb/{id}` -> 删 DB 行 + 关闭该 board 所有在线 WS 连接。 ## 临时文件清理 diff --git a/app/controllers/whiteboard_controller.py b/app/controllers/whiteboard_controller.py index be12315..25e8bc2 100644 --- a/app/controllers/whiteboard_controller.py +++ b/app/controllers/whiteboard_controller.py @@ -1,10 +1,13 @@ """白板接口:REST(访问/管理)+ WebSocket(实时同步)。 路由: - GET /whiteboard/{board_id} 公开:访问白板,不存在则新建 - WS /ws/whiteboard/{board_id} 公开:实时协作 + 心跳 - GET /api/admin/whiteboards Basic Auth:管理页列表 - DELETE /api/admin/whiteboards/{id} Basic Auth:删除白板 + GET /api/wb/{board_id} 公开:访问白板元数据,不存在则新建(前端 init 用) + WS /ws/wb/{board_id} 公开:实时协作 + 心跳 + GET /api/admin/wb Basic Auth:管理页列表 + DELETE /api/admin/wb/{board_id} Basic Auth:删除白板 + +HTML 页面 /wb/{id} 与管理页 /wb-admin 由 main.py 直接返回静态文件, +不在此 controller 注册,避免与 REST 同路径冲突。 """ from __future__ import annotations @@ -36,10 +39,10 @@ def _service(db: Session = Depends(get_db)) -> WhiteboardService: # ---------------- 公开 REST ---------------- @router.get( - "/whiteboard/{board_id}", + "/api/wb/{board_id}", response_model=WhiteboardOut, - summary="访问白板(不存在则新建)", - description="任何人凭 board_id 即可访问;不存在时自动创建空板并返回。", + summary="访问白板元数据(不存在则新建)", + description="前端打开 /wb/{id} 页面后调本接口拉取初始笔画;不存在时自动创建空板。", ) def get_whiteboard(board_id: str, service: WhiteboardService = Depends(_service)) -> WhiteboardOut: return service.get_or_create(board_id) @@ -48,7 +51,7 @@ def get_whiteboard(board_id: str, service: WhiteboardService = Depends(_service) # ---------------- 管理 REST(Basic Auth) ---------------- @router.get( - "/api/admin/whiteboards", + "/api/admin/wb", response_model=WhiteboardListResponse, summary="列出所有白板(需鉴权)", description="供白板管理页使用:board_id / 创建时间 / 修改次数 / 上次修改时间。", @@ -64,7 +67,7 @@ def list_whiteboards( @router.delete( - "/api/admin/whiteboards/{board_id}", + "/api/admin/wb/{board_id}", summary="删除白板(需鉴权)", description="删 DB 行,并关闭该 board 的所有在线 WebSocket 连接。", ) @@ -81,7 +84,7 @@ def delete_whiteboard( # ---------------- WebSocket(公开,实时同步 + 心跳) ---------------- -@router.websocket("/ws/whiteboard/{board_id}") +@router.websocket("/ws/wb/{board_id}") async def whiteboard_ws(websocket: WebSocket, board_id: str) -> None: """白板实时协作端点。 diff --git a/app/main.py b/app/main.py index fecf66b..7eb3573 100644 --- a/app/main.py +++ b/app/main.py @@ -8,10 +8,10 @@ GET /health -> 存活探针(公开) GET /upload -> 上传页面(公开 HTML) GET /files -> 文件浏览页(Basic Auth,同 docs) - GET /whiteboard/{id} -> 白板页面(公开,不存在则新建) - GET /whiteboard-admin -> 白板管理页(Basic Auth,同 docs) + GET /wb/{id} -> 白板页面(公开,不存在则新建) + GET /wb-admin -> 白板管理页(Basic Auth,同 docs) GET /api/... -> 业务接口 - WS /ws/whiteboard/{id} -> 白板实时同步(公开) + WS /ws/wb/{id} -> 白板实时同步(公开) /static/... -> 前端静态资源(JS/CSS) """ @@ -189,12 +189,12 @@ def create_app() -> FastAPI: """文件浏览页(Basic Auth,同 docs):列出/下载/删除已上传文件。""" return _serve_static_html("file_browser.html") - @app.get("/whiteboard-admin", response_class=HTMLResponse) + @app.get("/wb-admin", response_class=HTMLResponse) def whiteboard_admin_page(_: str = Depends(require_docs_auth)) -> HTMLResponse: """白板管理页(Basic Auth,同 docs):查看/删除白板。""" return _serve_static_html("whiteboard_admin.html") - @app.get("/whiteboard/{board_id}", response_class=HTMLResponse) + @app.get("/wb/{board_id}", response_class=HTMLResponse) def whiteboard_page(board_id: str) -> HTMLResponse: """白板页面(公开):访问即协作,不存在则前端拉取时自动新建。""" return _serve_static_html("whiteboard.html") diff --git a/static/whiteboard.js b/static/whiteboard.js index ced60d0..183f34e 100644 --- a/static/whiteboard.js +++ b/static/whiteboard.js @@ -8,8 +8,8 @@ const { el, toast, copyText } = window.ZK; // ---------- 从 URL 解析 board_id ---------- - // 路径形如 /whiteboard/{id};id 为 [a-zA-Z0-9_-]{1,64} - const m = location.pathname.match(/^\/whiteboard\/([^/]+)\/?$/); + // 路径形如 /wb/{id};id 为 [a-zA-Z0-9_-]{1,64} + const m = location.pathname.match(/^\/wb\/([^/]+)\/?$/); let boardId = m ? decodeURIComponent(m[1]) : "default"; // 合法性兜底:前端非法字符直接回退到 default,真正校验在服务端 if (!/^[a-zA-Z0-9_-]{1,64}$/.test(boardId)) boardId = "default"; @@ -139,7 +139,7 @@ }); copyBtn.addEventListener("click", async () => { - const url = `${location.origin}/whiteboard/${boardId}`; + const url = `${location.origin}/wb/${boardId}`; const ok = await copyText(url); toast(ok ? "链接已复制" : "复制失败"); }); @@ -147,7 +147,7 @@ // ---------- WebSocket ---------- function wsUrl() { const proto = location.protocol === "https:" ? "wss:" : "ws:"; - return `${proto}//${location.host}/ws/whiteboard/${encodeURIComponent(boardId)}`; + return `${proto}//${location.host}/ws/wb/${encodeURIComponent(boardId)}`; } function connect() { @@ -242,8 +242,8 @@ } // ---------- 启动 ---------- - // 先 GET /whiteboard/{id} 确保白板存在(不存在则服务端新建),再连 WS - fetch(`/whiteboard/${encodeURIComponent(boardId)}`, { headers: { accept: "application/json" } }) + // 先 GET /api/wb/{id} 确保白板存在(不存在则服务端新建),再连 WS + fetch(`/api/wb/${encodeURIComponent(boardId)}`, { headers: { accept: "application/json" } }) .then((r) => r.ok ? r.json() : null) .then((body) => { if (body && Array.isArray(body.strokes)) { diff --git a/static/whiteboard_admin.js b/static/whiteboard_admin.js index a28f9e4..d5cca7b 100644 --- a/static/whiteboard_admin.js +++ b/static/whiteboard_admin.js @@ -1,4 +1,4 @@ -/* 白板管理页:拉取 /api/admin/whiteboards、渲染表格、删除、新建并跳转。 */ +/* 白板管理页:拉取 /api/admin/wb、渲染表格、删除、新建并跳转。 */ (function () { "use strict"; const { el, toast, fmtTime, api } = window.ZK; @@ -11,14 +11,14 @@ newBtn.addEventListener("click", () => { // 生成一个随机 board_id 并打开(访问即创建) const id = "b_" + Math.random().toString(36).slice(2, 10); - window.open(`/whiteboard/${id}`, "_blank"); + window.open(`/wb/${id}`, "_blank"); }); async function load() { listEl.innerHTML = '
加载中…
'; countEl.textContent = ""; try { - const res = await api("/api/admin/whiteboards?limit=500&offset=0"); + const res = await api("/api/admin/wb?limit=500&offset=0"); if (!res.ok) throw new Error("HTTP " + res.status); const body = await res.json(); render(body.items || []); @@ -47,7 +47,7 @@ for (const b of items) { const row = el("tr", null, el("td", { class: "col-id" }, - el("a", { class: "bid link", href: `/whiteboard/${b.board_id}`, target: "_blank" }, b.board_id) + el("a", { class: "bid link", href: `/wb/${b.board_id}`, target: "_blank" }, b.board_id) ), el("td", { class: "col-mods mono" }, String(b.stroke_count ?? 0)), el("td", { class: "col-created muted" }, fmtTime(b.created_at)), @@ -67,7 +67,7 @@ async function remove(b, row) { if (!confirm(`确定删除白板「${b.board_id}」?\n所有在线协作者会被断开,内容不可恢复。`)) return; try { - const res = await api(`/api/admin/whiteboards/${encodeURIComponent(b.board_id)}`, { method: "DELETE" }); + const res = await api(`/api/admin/wb/${encodeURIComponent(b.board_id)}`, { method: "DELETE" }); if (res.status === 404) { toast("白板已不存在"); } else if (!res.ok) throw new Error("HTTP " + res.status); row.classList.add("row-removed"); diff --git a/tests/manual_whiteboard_kick.py b/tests/manual_whiteboard_kick.py index 56aae3a..62531ca 100644 --- a/tests/manual_whiteboard_kick.py +++ b/tests/manual_whiteboard_kick.py @@ -8,7 +8,7 @@ import urllib.request import websockets -BASE_WS = "ws://127.0.0.1:6890/ws/whiteboard" +BASE_WS = "ws://127.0.0.1:6867/ws/wb" BOARD = "kicktest" AUTH = "Basic YTo2NjUxMTMxNQ==" # a:66511315 @@ -22,7 +22,7 @@ async def main() -> None: # 通过 REST 删除白板(带 Basic Auth) req = urllib.request.Request( - f"http://127.0.0.1:6890/api/admin/whiteboards/{BOARD}", + f"http://127.0.0.1:6867/api/admin/wb/{BOARD}", method="DELETE", headers={"Authorization": AUTH}, ) diff --git a/tests/manual_whiteboard_ws.py b/tests/manual_whiteboard_ws.py index c3423d6..ef26532 100644 --- a/tests/manual_whiteboard_ws.py +++ b/tests/manual_whiteboard_ws.py @@ -16,7 +16,7 @@ import json import websockets -BASE_WS = "ws://127.0.0.1:6890/ws/whiteboard" +BASE_WS = "ws://127.0.0.1:6867/ws/wb" BOARD = "e2etest" @@ -80,7 +80,7 @@ async def main() -> None: # 验证 stroke_count 累计(之前 1 笔 + 1 次清空 = 2) import urllib.request - with urllib.request.urlopen(f"http://127.0.0.1:6890/whiteboard/{BOARD}") as r: + with urllib.request.urlopen(f"http://127.0.0.1:6867/api/wb/{BOARD}") as r: meta = json.load(r) print("stroke_count after ops:", meta["stroke_count"]) assert meta["stroke_count"] == 2, "1 笔 + 1 清空 = 2 次修改"