refactor: 白板路由改 /wb 前缀,消除 HTML 与 REST 同路径冲突

问题: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)。
This commit is contained in:
zikai
2026-07-21 14:51:33 +00:00
parent 3284269399
commit 7188c62d3a
7 changed files with 44 additions and 41 deletions

View File

@@ -15,10 +15,10 @@
- **文件浏览页** `GET /files`Basic Auth同 docs列出/下载/**硬删除**已上传文件;删除后不再显示。 - **文件浏览页** `GET /files`Basic Auth同 docs列出/下载/**硬删除**已上传文件;删除后不再显示。
管理 API`GET /api/admin/files``GET /api/admin/files/{id}``GET /api/admin/files/{id}/download` 管理 API`GET /api/admin/files``GET /api/admin/files/{id}``GET /api/admin/files/{id}/download`
`DELETE /api/admin/files/{id}`(均 Basic Auth `DELETE /api/admin/files/{id}`(均 Basic Auth
- **共享白板** `GET /whiteboard/{id}`公开不存在则新建Canvas 实时协作 + **清空 / 复制链接**,兼容移动端。 - **共享白板** `GET /wb/{id}`公开不存在则新建Canvas 实时协作 + **清空 / 复制链接**,兼容移动端。
实时同步走 `WS /ws/whiteboard/{id}`**心跳 3s连续 5 次丢失判失活并移除**)。 实时同步走 `WS /ws/wb/{id}`**心跳 3s连续 5 次丢失判失活并移除**)。
- **白板管理页** `GET /whiteboard-admin`Basic Auth同 docs查看创建时间/修改次数/上次修改时间/删除。 - **白板管理页** `GET /wb-admin`Basic Auth同 docs查看创建时间/修改次数/上次修改时间/删除。
管理 API`GET /api/admin/whiteboards``DELETE /api/admin/whiteboards/{id}`(均 Basic Auth 管理 API`GET /api/admin/wb``DELETE /api/admin/wb/{id}`(均 Basic Auth
- **反向隧道反代**`ALL /api/userPort/{userName}` -- 把请求经 SSH 反向隧道转发到该 user 的本机服务。 - **反向隧道反代**`ALL /api/userPort/{userName}` -- 把请求经 SSH 反向隧道转发到该 user 的本机服务。
- **内置 SFTP/SSH 服务器**asyncssh支持 **密码 + 公钥** 鉴权,同时承载 SFTP 文件暂存与反向隧道。 - **内置 SFTP/SSH 服务器**asyncssh支持 **密码 + 公钥** 鉴权,同时承载 SFTP 文件暂存与反向隧道。
- `/docs`Swagger UI`/redoc` - 交互式文档,自动列出所有 API。 - `/docs`Swagger UI`/redoc` - 交互式文档,自动列出所有 API。
@@ -70,8 +70,8 @@ cd /root/zikai
| API 文档ReDoc | https://f.zikai.wang/redoc同样鉴权 | | API 文档ReDoc | https://f.zikai.wang/redoc同样鉴权 |
| 上传页(拖拽、分片、断点续传) | https://f.zikai.wang/upload | | 上传页(拖拽、分片、断点续传) | https://f.zikai.wang/upload |
| 文件浏览页(列出/下载/删除) | https://f.zikai.wang/files **Basic Auth同 docs** | | 文件浏览页(列出/下载/删除) | 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/wb/{id}(公开,`{id}``[a-zA-Z0-9_-]{1,64}`,不存在则新建) |
| 白板管理页 | https://f.zikai.wang/whiteboard-admin **Basic Auth同 docs** | | 白板管理页 | https://f.zikai.wang/wb-admin **Basic Auth同 docs** |
| 上传curl | `curl -F file=@big.iso https://f.zikai.wang/api/files/upload` | | 上传curl | `curl -F file=@big.iso https://f.zikai.wang/api/files/upload` |
| SFTP | `sftp -P 2022 uploader@f.zikai.wang` | | 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 自动新建空板。白板长期留存 `[a-zA-Z0-9_-]{1,64}`,非法返回 400。访问不存在的 id 自动新建空板。白板长期留存
(存 MySQL `whiteboard` 表),进程重启后内容仍在。 (存 MySQL `whiteboard` 表),进程重启后内容仍在。
### 实时同步与心跳 ### 实时同步与心跳
- 连接:`WS /ws/whiteboard/{id}`公开。JSON 文本帧协议: - 连接:`WS /ws/wb/{id}`公开。JSON 文本帧协议:
- client -> server`{"type":"hello","client_id":"..."}`(首帧,可选)、 - client -> server`{"type":"hello","client_id":"..."}`(首帧,可选)、
`{"type":"ping"}`(心跳)、`{"type":"stroke","stroke":{points,color,width}}``{"type":"clear"}` `{"type":"ping"}`(心跳)、`{"type":"stroke","stroke":{points,color,width}}``{"type":"clear"}`
- server -> client`{"type":"init","strokes":[...],"stroke_count":n}``{"type":"pong"}` - 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 - 管理 API均 Basic Auth
- `GET /api/admin/whiteboards?limit=&offset=` -> `{total, items:[{board_id, stroke_count, created_at, updated_at}]}` - `GET /api/admin/wb?limit=&offset=` -> `{total, items:[{board_id, stroke_count, created_at, updated_at}]}`
- `DELETE /api/admin/whiteboards/{id}` -> 删 DB 行 + 关闭该 board 所有在线 WS 连接。 - `DELETE /api/admin/wb/{id}` -> 删 DB 行 + 关闭该 board 所有在线 WS 连接。
## 临时文件清理 ## 临时文件清理

View File

@@ -1,10 +1,13 @@
"""白板接口REST访问/管理)+ WebSocket实时同步 """白板接口REST访问/管理)+ WebSocket实时同步
路由: 路由:
GET /whiteboard/{board_id} 公开:访问白板,不存在则新建 GET /api/wb/{board_id} 公开:访问白板元数据,不存在则新建(前端 init 用)
WS /ws/whiteboard/{board_id} 公开:实时协作 + 心跳 WS /ws/wb/{board_id} 公开:实时协作 + 心跳
GET /api/admin/whiteboards Basic Auth管理页列表 GET /api/admin/wb Basic Auth管理页列表
DELETE /api/admin/whiteboards/{id} Basic Auth删除白板 DELETE /api/admin/wb/{board_id} Basic Auth删除白板
HTML 页面 /wb/{id} 与管理页 /wb-admin 由 main.py 直接返回静态文件,
不在此 controller 注册,避免与 REST 同路径冲突。
""" """
from __future__ import annotations from __future__ import annotations
@@ -36,10 +39,10 @@ def _service(db: Session = Depends(get_db)) -> WhiteboardService:
# ---------------- 公开 REST ---------------- # ---------------- 公开 REST ----------------
@router.get( @router.get(
"/whiteboard/{board_id}", "/api/wb/{board_id}",
response_model=WhiteboardOut, response_model=WhiteboardOut,
summary="访问白板(不存在则新建)", summary="访问白板元数据(不存在则新建)",
description="任何人凭 board_id 即可访问;不存在时自动创建空板并返回", description="前端打开 /wb/{id} 页面后调本接口拉取初始笔画;不存在时自动创建空板。",
) )
def get_whiteboard(board_id: str, service: WhiteboardService = Depends(_service)) -> WhiteboardOut: def get_whiteboard(board_id: str, service: WhiteboardService = Depends(_service)) -> WhiteboardOut:
return service.get_or_create(board_id) return service.get_or_create(board_id)
@@ -48,7 +51,7 @@ def get_whiteboard(board_id: str, service: WhiteboardService = Depends(_service)
# ---------------- 管理 RESTBasic Auth ---------------- # ---------------- 管理 RESTBasic Auth ----------------
@router.get( @router.get(
"/api/admin/whiteboards", "/api/admin/wb",
response_model=WhiteboardListResponse, response_model=WhiteboardListResponse,
summary="列出所有白板(需鉴权)", summary="列出所有白板(需鉴权)",
description="供白板管理页使用board_id / 创建时间 / 修改次数 / 上次修改时间。", description="供白板管理页使用board_id / 创建时间 / 修改次数 / 上次修改时间。",
@@ -64,7 +67,7 @@ def list_whiteboards(
@router.delete( @router.delete(
"/api/admin/whiteboards/{board_id}", "/api/admin/wb/{board_id}",
summary="删除白板(需鉴权)", summary="删除白板(需鉴权)",
description="删 DB 行,并关闭该 board 的所有在线 WebSocket 连接。", description="删 DB 行,并关闭该 board 的所有在线 WebSocket 连接。",
) )
@@ -81,7 +84,7 @@ def delete_whiteboard(
# ---------------- WebSocket公开实时同步 + 心跳) ---------------- # ---------------- WebSocket公开实时同步 + 心跳) ----------------
@router.websocket("/ws/whiteboard/{board_id}") @router.websocket("/ws/wb/{board_id}")
async def whiteboard_ws(websocket: WebSocket, board_id: str) -> None: async def whiteboard_ws(websocket: WebSocket, board_id: str) -> None:
"""白板实时协作端点。 """白板实时协作端点。

View File

@@ -8,10 +8,10 @@
GET /health -> 存活探针(公开) GET /health -> 存活探针(公开)
GET /upload -> 上传页面(公开 HTML GET /upload -> 上传页面(公开 HTML
GET /files -> 文件浏览页Basic Auth同 docs GET /files -> 文件浏览页Basic Auth同 docs
GET /whiteboard/{id} -> 白板页面(公开,不存在则新建) GET /wb/{id} -> 白板页面(公开,不存在则新建)
GET /whiteboard-admin -> 白板管理页Basic Auth同 docs GET /wb-admin -> 白板管理页Basic Auth同 docs
GET /api/... -> 业务接口 GET /api/... -> 业务接口
WS /ws/whiteboard/{id} -> 白板实时同步(公开) WS /ws/wb/{id} -> 白板实时同步(公开)
/static/... -> 前端静态资源JS/CSS /static/... -> 前端静态资源JS/CSS
""" """
@@ -189,12 +189,12 @@ def create_app() -> FastAPI:
"""文件浏览页Basic Auth同 docs列出/下载/删除已上传文件。""" """文件浏览页Basic Auth同 docs列出/下载/删除已上传文件。"""
return _serve_static_html("file_browser.html") 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: def whiteboard_admin_page(_: str = Depends(require_docs_auth)) -> HTMLResponse:
"""白板管理页Basic Auth同 docs查看/删除白板。""" """白板管理页Basic Auth同 docs查看/删除白板。"""
return _serve_static_html("whiteboard_admin.html") 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: def whiteboard_page(board_id: str) -> HTMLResponse:
"""白板页面(公开):访问即协作,不存在则前端拉取时自动新建。""" """白板页面(公开):访问即协作,不存在则前端拉取时自动新建。"""
return _serve_static_html("whiteboard.html") return _serve_static_html("whiteboard.html")

View File

@@ -8,8 +8,8 @@
const { el, toast, copyText } = window.ZK; const { el, toast, copyText } = window.ZK;
// ---------- 从 URL 解析 board_id ---------- // ---------- 从 URL 解析 board_id ----------
// 路径形如 /whiteboard/{id}id 为 [a-zA-Z0-9_-]{1,64} // 路径形如 /wb/{id}id 为 [a-zA-Z0-9_-]{1,64}
const m = location.pathname.match(/^\/whiteboard\/([^/]+)\/?$/); const m = location.pathname.match(/^\/wb\/([^/]+)\/?$/);
let boardId = m ? decodeURIComponent(m[1]) : "default"; let boardId = m ? decodeURIComponent(m[1]) : "default";
// 合法性兜底:前端非法字符直接回退到 default真正校验在服务端 // 合法性兜底:前端非法字符直接回退到 default真正校验在服务端
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(boardId)) boardId = "default"; if (!/^[a-zA-Z0-9_-]{1,64}$/.test(boardId)) boardId = "default";
@@ -139,7 +139,7 @@
}); });
copyBtn.addEventListener("click", async () => { copyBtn.addEventListener("click", async () => {
const url = `${location.origin}/whiteboard/${boardId}`; const url = `${location.origin}/wb/${boardId}`;
const ok = await copyText(url); const ok = await copyText(url);
toast(ok ? "链接已复制" : "复制失败"); toast(ok ? "链接已复制" : "复制失败");
}); });
@@ -147,7 +147,7 @@
// ---------- WebSocket ---------- // ---------- WebSocket ----------
function wsUrl() { function wsUrl() {
const proto = location.protocol === "https:" ? "wss:" : "ws:"; 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() { function connect() {
@@ -242,8 +242,8 @@
} }
// ---------- 启动 ---------- // ---------- 启动 ----------
// 先 GET /whiteboard/{id} 确保白板存在(不存在则服务端新建),再连 WS // 先 GET /api/wb/{id} 确保白板存在(不存在则服务端新建),再连 WS
fetch(`/whiteboard/${encodeURIComponent(boardId)}`, { headers: { accept: "application/json" } }) fetch(`/api/wb/${encodeURIComponent(boardId)}`, { headers: { accept: "application/json" } })
.then((r) => r.ok ? r.json() : null) .then((r) => r.ok ? r.json() : null)
.then((body) => { .then((body) => {
if (body && Array.isArray(body.strokes)) { if (body && Array.isArray(body.strokes)) {

View File

@@ -1,4 +1,4 @@
/* 白板管理页:拉取 /api/admin/whiteboards、渲染表格、删除、新建并跳转。 */ /* 白板管理页:拉取 /api/admin/wb、渲染表格、删除、新建并跳转。 */
(function () { (function () {
"use strict"; "use strict";
const { el, toast, fmtTime, api } = window.ZK; const { el, toast, fmtTime, api } = window.ZK;
@@ -11,14 +11,14 @@
newBtn.addEventListener("click", () => { newBtn.addEventListener("click", () => {
// 生成一个随机 board_id 并打开(访问即创建) // 生成一个随机 board_id 并打开(访问即创建)
const id = "b_" + Math.random().toString(36).slice(2, 10); const id = "b_" + Math.random().toString(36).slice(2, 10);
window.open(`/whiteboard/${id}`, "_blank"); window.open(`/wb/${id}`, "_blank");
}); });
async function load() { async function load() {
listEl.innerHTML = '<div class="skel">加载中…</div>'; listEl.innerHTML = '<div class="skel">加载中…</div>';
countEl.textContent = ""; countEl.textContent = "";
try { 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); if (!res.ok) throw new Error("HTTP " + res.status);
const body = await res.json(); const body = await res.json();
render(body.items || []); render(body.items || []);
@@ -47,7 +47,7 @@
for (const b of items) { for (const b of items) {
const row = el("tr", null, const row = el("tr", null,
el("td", { class: "col-id" }, 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-mods mono" }, String(b.stroke_count ?? 0)),
el("td", { class: "col-created muted" }, fmtTime(b.created_at)), el("td", { class: "col-created muted" }, fmtTime(b.created_at)),
@@ -67,7 +67,7 @@
async function remove(b, row) { async function remove(b, row) {
if (!confirm(`确定删除白板「${b.board_id}」?\n所有在线协作者会被断开,内容不可恢复。`)) return; if (!confirm(`确定删除白板「${b.board_id}」?\n所有在线协作者会被断开,内容不可恢复。`)) return;
try { 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("白板已不存在"); } if (res.status === 404) { toast("白板已不存在"); }
else if (!res.ok) throw new Error("HTTP " + res.status); else if (!res.ok) throw new Error("HTTP " + res.status);
row.classList.add("row-removed"); row.classList.add("row-removed");

View File

@@ -8,7 +8,7 @@ import urllib.request
import websockets import websockets
BASE_WS = "ws://127.0.0.1:6890/ws/whiteboard" BASE_WS = "ws://127.0.0.1:6867/ws/wb"
BOARD = "kicktest" BOARD = "kicktest"
AUTH = "Basic YTo2NjUxMTMxNQ==" # a:66511315 AUTH = "Basic YTo2NjUxMTMxNQ==" # a:66511315
@@ -22,7 +22,7 @@ async def main() -> None:
# 通过 REST 删除白板(带 Basic Auth # 通过 REST 删除白板(带 Basic Auth
req = urllib.request.Request( 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", method="DELETE",
headers={"Authorization": AUTH}, headers={"Authorization": AUTH},
) )

View File

@@ -16,7 +16,7 @@ import json
import websockets import websockets
BASE_WS = "ws://127.0.0.1:6890/ws/whiteboard" BASE_WS = "ws://127.0.0.1:6867/ws/wb"
BOARD = "e2etest" BOARD = "e2etest"
@@ -80,7 +80,7 @@ async def main() -> None:
# 验证 stroke_count 累计(之前 1 笔 + 1 次清空 = 2 # 验证 stroke_count 累计(之前 1 笔 + 1 次清空 = 2
import urllib.request 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) meta = json.load(r)
print("stroke_count after ops:", meta["stroke_count"]) print("stroke_count after ops:", meta["stroke_count"])
assert meta["stroke_count"] == 2, "1 笔 + 1 清空 = 2 次修改" assert meta["stroke_count"] == 2, "1 笔 + 1 清空 = 2 次修改"