Compare commits

...

10 Commits

Author SHA1 Message Date
zikai
09705a8843 feat: SFTP 上传的文件经磁盘扫描补录后可在文件浏览页展示
SFTP 上传的文件直接落盘到 upload_root(chroot 根),不经 HTTP 路径,无 DB 记录,
文件浏览页(读 uploaded_file 表)看不到。新增磁盘扫描机制:

- UploadService.scan_sftp_files(force=False):遍历 uploads/ 下所有文件(排除 .work/
  与 .part 残品),与 DB 已有 storage_path 比对,磁盘有 DB 无的补录(source="sftp",
  流式算 size + sha256)。
- 频率限制:模块级 _last_scan_time,两次扫描间隔 < 3s 则跳过返回 0(只读 DB 缓存),
  force=True 强制扫。
- file_admin_controller 的 list 接口返回前触发 scan_sftp_files(),文件浏览页打开/
  刷新即自动同步 SFTP 文件(受 3s 频率限制,不会每次翻页都扫磁盘)。
- UploadedFileDAO 加 list_storage_paths() 批量查已记录路径集合供比对。

SFTP 文件补录后可正常下载/删除(与 HTTP 上传文件一致)。
2026-07-22 01:25:41 +00:00
zikai
30a263ed50 fix: 代码审查修复 + 精简重写 README
后端修复:
- 白板删除踢人失效:delete_whiteboard 改 async def,删除后直接 await
  hub.close_board()。原实现用 asyncio.get_running_loop() 在同步 REST handler
  (threadpool)里调用必抛 RuntimeError 被 except 吞掉,close_board 从不执行。
  同时移除 service 的 hub 依赖(close_board 改由 controller 调用,service 只管 DB)。
- delete_file 去重复查询:原先 get_out_with_disk_path + get_by_id 查两次,
  合并为一次;磁盘 unlink 失败加 logger.warning(原静默吞掉致磁盘泄漏无记录)。
- get_hub 单例加 threading.Lock 双重检查(防 REST threadpool 与 WS 事件循环
  并发首访各建一个 hub)。
- file_controller 公开 /api/files list 加 Query(ge=1, le=10000) 约束(原无上限可 DoS)。

前端修复:
- applyRemoteUpdate 有未发送编辑时重发:合并远端更新后若本地有 pending 编辑
  (editor.value !== lastSentText)重新 scheduleSend,避免被 lastSentText 短路丢弃。
- init 不覆盖未发送编辑:断线重连后若本地有未发送内容,作为新版本发上去而非被 init 覆盖。
- applyRemoteUpdate 仅在编辑器已有焦点时恢复焦点,避免抢按钮焦点。
- api() 401 时 location.reload() 触发浏览器 Basic Auth 弹窗(原只 toast 卡死)。

README:
- 精简重写,补全 Ubuntu 从 0 安装、Apache 反代(含 WS)、配置项表格、防火墙说明。
2026-07-22 01:03:48 +00:00
zikai
4de2648178 fix: 记事本修改需手动刷新才同步(同浏览器多 tab clientId 共享导致 update 被跳过)
根因:clientId 存 localStorage,同浏览器多个 tab 共享同一 clientId。
前端 onMessage 的 update 分支用 `if (msg.client_id === clientId) break` 跳过
「自己的」更新,但服务端 exclude=conn 是按连接对象排除,非按 client_id。
于是 tab B 编辑后,tab A 收到的 update client_id 等于自己的 clientId,
被误判为「自己的」跳过 -> 不同步,只有手动刷新(重新 init)才看到内容。

修复:
- clientId 改用 sessionStorage(每 tab 独立),不再跨 tab 共享。
- 移除 update 分支的 client_id 跳过逻辑(服务端 exclude=conn 已排除发送者,
  前端跳过是多余且会误杀同 client_id 的其他 tab)。
2026-07-22 00:36:44 +00:00
zikai
1030801712 docs: 补 Apache WebSocket 反代配置说明(/ws/ 须 proxy_wstunnel 透传) 2026-07-21 15:12:15 +00:00
zikai
da7d2fb4b4 security: 白板 WS 加单帧大小限制与单 board 连接数上限
安全审计发现:
- WS receive_text 无应用层大小限制,恶意客户端可发超大帧(uvicorn 默认 16MB
  才拦截)占内存。加 512KB 单帧检查(与 content 256KB 限制对齐留余量)。
- 单 board 无连接数上限,恶意脚本可开海量连接耗尽资源。config 加
  max_connections_per_board(默认 50),hub.register 超限返回 False,
  controller 回 error 帧并关闭连接。

审计结论(无需修复):
- SQL:全部 SQLAlchemy ORM 参数化,无注入。
- 路径穿越:storage_path 服务端生成(uuid+basename(ext)),用户不可控分隔符。
- 鉴权:管理类操作均有 require_docs_auth,公开写接口符合设计。
- 下载 filename CRLF:Starlette FileResponse 用 quote() 编码,无响应拆分。
- XSS:前端 el() 用 createTextNode,textarea 纯文本,innerHTML 仅用于静态文案。
2026-07-21 15:09:13 +00:00
zikai
374c3d150f fix: docs 分组与 summary 统一 + 记事本页加「复制链接」按钮
docs 乱:
- main.py 页面路由(/upload /files /wb-admin /wb/{id} /docs /redoc / /health)
  加 tags(pages/docs/meta)+ summary/description,Swagger UI 按 tag 分组显示。
- whiteboard_controller 残留「白板/笔画/修改次数」改为「记事本/文本/编辑次数」。

复制链接:
- 记事本页加「复制链接」按钮,复制 {origin}/wb/{id} 分享链接(保留「复制文本」)。
2026-07-21 15:04:02 +00:00
zikai
655e039aad refactor: 白板从画笔画板改为文本记事本
原实现是 Canvas 画笔画板,与「文字白板/记事本」需求不符。重做为纯文本实时协作:
- model:strokes JSON -> content TEXT + version INT(乐观锁)+ edit_count;
  schema/dao/service 同步重构,append_stroke/replace_strokes -> update_content。
- WS 协议:stroke -> edit(发完整文本,debounce 400ms);init 下发 content/version。
  update 帧广播给他人,cleared 广播给所有人。
- 前端:canvas -> textarea;收到远端 update 用最长公共前后缀算变更区间,
  仅替换该区间并保留光标(区间前不动/后平移/内移末尾);清空/复制文本按钮。
- schema.sql 更新 whiteboard 表 DDL;DB 旧表 DROP 重建(开发环境)。
- 测试脚本与 README 同步更新。
2026-07-21 15:00:25 +00:00
zikai
7bacc33679 fix: fmtBytes 大文件显示成 PiB(累除逻辑错误)+ 改用 KB/MB/GB 单位
原实现循环里 n = x / 1024 每次都用原始 x 除一次,未累除,导致大文件
一路跌到 PiB 分支显示错误值。改为 while 循环每次 x /= 1000 累除,
单位改为 B/KB/MB/GB/TB/PB(1000 进制),B 显示整数其余保留 1 位小数。
2026-07-21 14:52:44 +00:00
zikai
7188c62d3a 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)。
2026-07-21 14:51:33 +00:00
zikai
3284269399 fix: 复选框初始状态错误(el 对布尔属性用 setAttribute 导致 checked:false 仍勾选)
common.js 的 el() 对 checked/disabled/hidden 等布尔属性调用 setAttribute(k, v),
而 setAttribute("checked", "false") 仍会令 checkbox 呈勾选态(属性存在即为真)。
改为维护 BOOL_PROPS 集合,布尔属性用 node[k] = !!v 赋值;同时 v === false 时跳过 setAttribute。
修复后文件浏览页复选框初始未选中时正确显示为未勾选。
2026-07-21 14:41:10 +00:00
22 changed files with 634 additions and 550 deletions

352
README.md
View File

@@ -1,213 +1,179 @@
# zikai file service # zikai file service
`f.zikai.wang` 的 Python Web 服务FastAPI提供主机监控、大文件上传、共享白板与 基于 FastAPI 的个人 Web 服务,提供**文件上传/浏览/下载、共享记事本(实时协作)、
文件浏览:**HTTP整文件 + 分片/断点续传)**,并内置 **SFTP 服务器** 用于原始文件暂存 主机监控、SFTP 暂存、反向隧道**。采用 Spring 风格分层架构,自带 API 文档
采用 Spring 风格分层架构(`controllers` -> `services` -> `dao`,外加 `models`
`schemas`),自带自动生成的 API 文档,全部运行在自包含的 `.venv` 中。
## 功能 ## 功能一览
- `GET /api/system/status` - CPU、内存、各磁盘使用率via `psutil`)。 | 模块 | 页面 / 接口 | 鉴权 |
- `POST /api/files/upload` - **流式** multipart 上传(内存恒定,支持多 GB落盘时算 SHA-256。 |------|------------|------|
- `GET /upload` - 拖拽上传页面:多文件、**分片4 MiB**、**断点续传**、sha256 去重。 | 文件上传 | `POST /api/files/upload`(流式)/ `POST /api/files/chunk-uploads/*`(分片+断点续传) | 公开 |
- `POST /api/files/chunk-uploads/*` - 支撑 `/upload` 的分片上传 API建会话 / 查状态 / 传分片 / 完成)。 | 上传页 | `GET /upload`(拖拽/多文件/分片/去重) | 公开 |
- `GET /api/files``GET /api/files/{id}``GET /api/files/{id}/download` | 文件浏览 | `GET /files`(多选/批量下载删除/分页) | Basic Auth |
- **文件浏览页** `GET /files`Basic Auth同 docs列出/下载/**硬删除**已上传文件;删除后不再显示。 | 文件管理 API | `GET /api/admin/files``GET/DELETE /api/admin/files/{id}``GET /api/admin/files/{id}/download` | Basic Auth |
管理 API`GET /api/admin/files``GET /api/admin/files/{id}``GET /api/admin/files/{id}/download` | 共享记事本 | `GET /wb/{id}`(公开,不存在则新建) | 公开 |
`DELETE /api/admin/files/{id}`(均 Basic Auth | 记事本实时同步 | `WS /ws/wb/{id}`(心跳 3s5 次失活移除) | 公开 |
- **共享白板** `GET /whiteboard/{id}`公开不存在则新建Canvas 实时协作 + **清空 / 复制链接**,兼容移动端。 | 记事本管理 | `GET /wb-admin`(查看/删除) | Basic Auth |
实时同步走 `WS /ws/whiteboard/{id}`**心跳 3s连续 5 次丢失判失活并移除**)。 | 记事本管理 API | `GET /api/admin/wb``DELETE /api/admin/wb/{id}` | Basic Auth |
- **白板管理页** `GET /whiteboard-admin`Basic Auth同 docs查看创建时间/修改次数/上次修改时间/删除。 | 主机监控 | `GET /api/system/status`CPU/内存/磁盘HTML+JSON 内容协商) | 公开 |
管理 API`GET /api/admin/whiteboards``DELETE /api/admin/whiteboards/{id}`(均 Basic Auth | 反向隧道反代 | `ALL /api/userPort/{userName}`(经 SSH 隧道转发到 user 本地服务) | 公开 |
- **反向隧道反代**`ALL /api/userPort/{userName}` -- 把请求经 SSH 反向隧道转发到该 user 的本机服务。 | SFTP/SSH | 端口 2022密码+公钥chroot 到上传目录,承载隧道转发) | SSH |
- **内置 SFTP/SSH 服务器**asyncssh支持 **密码 + 公钥** 鉴权,同时承载 SFTP 文件暂存与反向隧道。 | API 文档 | `GET /docs`Swagger/ `GET /redoc` | Basic Auth |
- `/docs`Swagger UI`/redoc` - 交互式文档,自动列出所有 API。
- 元数据持久化在 **独立的 MySQL 数据库**`zikai_filesvc`)。
- `start.sh` / `stop.sh` 生命周期管理;`setup.sh` 一次性初始化。
## 架构Spring 风格分层) ## 项目结构
``` ```
app/ server/
├── controllers/ # FastAPI 路由 -- HTTP 边界(类似 @RestController ├── app/
├── services/ # 业务逻辑SystemService, UploadService, ChunkUploadService, │ ├── main.py # FastAPI 应用工厂、路由注册、生命周期reaper
# WhiteboardService, WhiteboardHub, SFTP 服务 ├── config.py # 从 config.yaml 加载的类型化 Settingspydantic-settings
├── dao/ # 数据访问对象 -- 唯一发出 SQL/ORM 的层 ├── database.py # SQLAlchemy 引擎/Session/Base/get_db 依赖
├── models/ # SQLAlchemy ORM 实体UploadedFile, UploadSession, Whiteboard, ... │ ├── security.py # Basic Authrequire_docs_auth常量时间比较
├── schemas/ # pydantic DTO请求/响应校验) │ ├── controllers/ # 路由层(@RestControllerfile/system/chunk/tunnel/whiteboard/admin
├── views/ # 服务端渲染的 HTML 页面(系统状态、上传页) │ ├── services/ # 业务层UploadService/ChunkUploadService/SystemService/
├── static/ # 前端静态资源(文件浏览/白板/白板管理的 HTML+JS+CSS经 StaticFiles 挂载) │ │ # WhiteboardService/WhiteboardHub/TunnelService/sftp_server
├── database.py # 引擎、Session、Base、get_db() 依赖 ├── dao/ # 数据访问层:唯一发 SQL 的层SQLAlchemy ORM 参数化)
├── config.py # 从 config.yaml 加载的类型化 Settings │ ├── models/ # ORM 实体UploadedFile/UploadSession/Whiteboard/TunnelSession
└── scripts/ # init_db.py -- 数据库初始化 │ ├── schemas/ # pydantic 请求/响应 DTO
│ ├── views/ # 服务端渲染 HTML系统状态页、上传页
│ ├── static/ # 前端静态资源common + file_browser + whiteboard + whiteboard_admin
│ └── scripts/init_db.py # 数据库初始化(建库建账、随机密码写回 config.yaml
├── sql/schema.sql # 建表 DDL参考实际由 ORM 自动建表)
├── config.example.yaml # 配置模板(含注释)
├── config.yaml # 实际配置git-ignored含密码
├── requirements.txt
├── setup.sh # 一次性初始化venv + 依赖 + 建库 + SFTP 密钥
├── start.sh / stop.sh # 启停 HTTP6867+ SFTP2022
└── logs/ # app.log / sftp.log
``` ```
请求流程**controller** -> **service** -> **dao** -> **ORM model** -> MySQL。 **请求流程**`controller → service → dao → ORM model MySQL`
DB Session 由 FastAPI 的 `get_db` 依赖注入并向下传递。前端三套页面走「独立静态文件 + DB Session 由 `get_db` 依赖注入。前端页面走「StaticFiles 挂载 + 具名 HTML 路由」前后端分离JS 调同源 `/api/...`
StaticFiles 挂载」的前后端分离模式HTML 壳由具名路由返回(便于各自挂 Basic Auth
JS 调用同源 `/api/...`
## 快速开始 ## 从零安装Ubuntu 22.04+
### 1. 安装系统依赖
```bash ```bash
cd /root/zikai apt update
./setup.sh # 一次性venv、依赖、建库建账、SFTP 主机密钥 apt install -y python3-venv python3-pip mysql-server apache2 \
./start.sh # 启动 HTTP127.0.0.1:6867+ SFTP0.0.0.0:2022 libssl-dev build-essential # build-essential 给 bcrypt/asyncssh 编译
./stop.sh # 停止两者
``` ```
`setup.sh` 可重复执行。它会创建 `.venv`、安装 `requirements.txt`、复制 ### 2. 获取代码
`config.example.yaml``config.yaml`(若不存在)、通过本机 root socket 建一个
**全新的独立 MySQL 数据库与应用账户**,并生成 SFTP 主机密钥。
## 访问方式 ```bash
git clone <repo> /root/zikai
cd /root/zikai/server
```
### 3. 初始化venv + 依赖 + 建库 + SFTP 密钥)
```bash
./setup.sh
```
`setup.sh` 会:
- 创建 `.venv` 并安装 `requirements.txt`
- 复制 `config.example.yaml → config.yaml`(若不存在)
- 通过本机 root socket 建独立 MySQL 库 `zikai_filesvc` + 应用账户,随机密码写回 `config.yaml`
- 生成 SFTP 主机密钥(`keys/ssh_host_*`
### 4. 配置凭据
编辑 `config.yaml`(见下方[配置说明](#配置说明)
- `docs.username` / `docs.password`:管理页与 API 文档的 Basic Auth 凭据
- `sftp.users[].password_hash`SFTP 用户bcrypt生成方式见下
- `tunnel.users[]`:反向隧道用户(可选)
```bash
# 生成 bcrypt hash
.venv/bin/python -c "import bcrypt;print(bcrypt.hashpw(b'yourpass',bcrypt.gensalt()).decode())"
```
### 5. 启动
```bash
./start.sh # 启动 HTTP(127.0.0.1:6867) + SFTP(0.0.0.0:2022)
./stop.sh # 停止
```
### 6. 配置 Apache 反向代理
服务只绑 `127.0.0.1:6867`,通过 Apache 对外提供 HTTPS。安装模块并配置 vhost
```bash
a2enmod ssl proxy proxy_http proxy_wstunnel rewrite headers
```
创建 `/etc/apache2/sites-available/f.zikai.wang.conf`(关键部分):
```apache
<VirtualHost *:443>
ServerName f.zikai.wang
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/f.zikai.wang/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/f.zikai.wang/privkey.pem
ProxyPreserveHost On
ProxyPass /fdata !
# WebSocket 反代:/ws/ 必须在通用 / 规则之前,用 proxy_wstunnel 透传
ProxyPass /ws/ ws://127.0.0.1:6867/ws/
ProxyPassReverse /ws/ ws://127.0.0.1:6867/ws/
ProxyPass / http://127.0.0.1:6867/
ProxyPassReverse / http://127.0.0.1:6867/
ProxyTimeout 300
</VirtualHost>
```
```bash
a2ensite f.zikai.wang
systemctl reload apache2
```
> **防火墙**:放开 443HTTPS与 2022SFTP。6867 不对外(仅 loopback
> **大文件上传**Apache 全局 `Timeout 300`,慢链路建议走分片上传(`/upload`)或 SFTP。
## 配置说明
所有运行时配置在 `config.yaml`git-ignored。完整 schema 见 `config.example.yaml`
| 段 | 关键项 | 说明 |
|----|--------|------|
| `server` | `host`/`port` | 绑定地址,保持 `127.0.0.1:6867`Apache 反代) |
| `database` | `host`/`port`/`user`/`password`/`database` | MySQL 连接;密码由 `setup.sh` 自动生成写回 |
| `storage` | `upload_dir` | 文件存储根目录(默认 `uploads` |
| | `chunk_bytes` | 流式上传分片大小(默认 1 MiB |
| | `chunk_session_dir` | 分片会话暂存目录(默认 `uploads/.work` |
| | `chunk_session_ttl_seconds` | 被放弃会话存活秒数(默认 300 |
| `docs` | `username`/`password` | `/docs``/files``/wb-admin``/api/admin/*` 的 Basic Auth明文常量时间比较 |
| `sftp` | `enabled`/`host`/`port` | SFTP 服务,默认 `0.0.0.0:2022` |
| | `users[].username`/`password_hash` | SFTP 用户bcrypt |
| | `host_key_path`/`authorized_keys_path` | 主机密钥与公钥白名单路径 |
| `tunnel` | `enabled`/`users[]` | 反向隧道:`username`/`password_hash`/`tunnel_port`/`local_port` |
| `whiteboard` | `heartbeat_interval_seconds` | 心跳间隔(默认 3s |
| | `heartbeat_miss_threshold` | 失活阈值(默认 5 次 = 15s |
| | `max_board_id_length` | board_id 长度上限(默认 64 |
| | `max_connections_per_board` | 单白板并发连接上限(默认 50 |
| | `list_limit` | 管理页单次列表上限(默认 100 |
## 访问入口
| 入口 | URL | | 入口 | URL |
|------|-----| |------|-----|
| 状态页HTML | https://f.zikai.wang/api/system/status | | API 文档 | https://f.zikai.wang/docsBasic Auth |
| 状态页JSON | https://f.zikai.wang/api/system/status?format=json`Accept: application/json` | | 上传页 | https://f.zikai.wang/upload |
| API 文档Swagger | https://f.zikai.wang/docs **HTTP Basic Auth -- 见 config.yaml 的 `docs:` 段)** | | 文件浏览 | https://f.zikai.wang/filesBasic Auth |
| API 文档ReDoc | https://f.zikai.wang/redoc同样鉴权 | | 共享记事本 | https://f.zikai.wang/wb/{id}(公开,`{id}``[a-zA-Z0-9_-]{1,64}` |
| 上传页(拖拽、分片、断点续传) | https://f.zikai.wang/upload | | 记事本管理 | https://f.zikai.wang/wb-adminBasic Auth |
| 文件浏览页(列出/下载/删除) | https://f.zikai.wang/files **Basic Auth同 docs** | | 系统状态 | https://f.zikai.wang/api/system/statusHTML`?format=json` 切 JSON |
| 共享白板(实时协作) | https://f.zikai.wang/whiteboard/{id}(公开,`{id}``[a-zA-Z0-9_-]{1,64}`,不存在则新建) | | curl 上传 | `curl -F file=@big.iso https://f.zikai.wang/api/files/upload` |
| 白板管理页 | https://f.zikai.wang/whiteboard-admin **Basic Auth同 docs** | | SFTP | `sftp -P 2022 uploader@f.zikai.wang` |
| 上传curl | `curl -F file=@big.iso https://f.zikai.wang/api/files/upload` |
| SFTP | `sftp -P 2022 uploader@f.zikai.wang` |
`/api/system/status` 做内容协商:浏览器(`Accept: text/html`)拿到带进度条的可读页面; ## 运维
API 客户端拿到 JSON。可用 `?format=html``?format=json` 强制指定。
`/docs``/redoc``/openapi.json` 需要 HTTP Basic Auth —— 浏览器会弹出登录框。用户名与 - **日志**`logs/app.log`HTTP`logs/sftp.log`SFTPpidfile`app.pid``sftp.pid`
明文密码写在 `config.yaml``docs:` 段。`/health``/` 保持公开 - **临时文件清理**:分片上传完成后立即删 `.work/<id>/`;被放弃会话(`pending` 超 5 分钟)由后台 reaper 每 60s 清理;`start.sh` 启动时兜底清残留
- **重新生成 DB 密码**`.venv/bin/python -m app.scripts.init_db`(保留现有:`KEEP_DB_PASSWORD=1`)。
Apache`/etc/apache2/sites-available/f.zikai.wang-le-ssl.conf`)把 `f.zikai.wang` 反代到 - **多 worker 限制**:记事本 hub 是进程内存,多 uvicorn worker 下不互通,保持 `workers: 1`
`127.0.0.1:6867``ProxyPreserveHost On`),因此服务只绑 loopback - **数据库表**ORM 启动时自动建表(`init_db_schema``sql/schema.sql` 供参考/手动初始化
> **大文件/慢速 HTTP 上传:** Apache 代理段继承全局 `Timeout 300`。多 GB 慢链路传输建议走
> **分片上传**`/upload` 页面或 `/api/files/chunk-uploads`,单片 4 MiB 在超时内可传完)或
> **SFTP**(完全绕过 HTTP 代理)。要提高 HTTP 上限可在 Apache vhost 加 `ProxyTimeout`/`Timeout`。
## 配置
所有运行时配置都在 **`config.yaml`**git-ignored。完整 schema 见 `config.example.yaml`
关键配置项:
- `server` — 绑定 host/port保持 `127.0.0.1:6867` 以对齐 Apache
- `database` — host/port/user/password/database。密码由 `setup.sh`/`init_db.py` 自动生成并写回。
- `storage.upload_dir``storage.chunk_bytes`(默认 1 MiB 流式分片)、`storage.chunk_session_dir`
(分片会话暂存目录)、`storage.chunk_session_ttl_seconds`(被放弃会话的存活秒数,默认 300
- `sftp` — enabled、host/port、host key + authorized_keys 路径、`users`
- `whiteboard` - `heartbeat_interval_seconds`(默认 3`heartbeat_miss_threshold`(默认 5`max_board_id_length`(默认 64`list_limit`(默认 100
### 设置 /docs 管理密码
直接编辑 `config.yaml`,无需哈希:
```yaml
docs:
enabled: true
username: admin
password: "your-plaintext-password"
realm: "zikai docs"
```
然后 `./stop.sh && ./start.sh`。该文件 root 持有且仅在本机;比较使用常量时间
`secrets.compare_digest`)。
### 设置 SFTP 凭据
**密码鉴权** —— 生成 bcrypt hash 写入 `config.yaml`
```bash
.venv/bin/python -c "import bcrypt;print(bcrypt.hashpw(b'yourpass',bcrypt.gensalt()).decode())"
# 输出粘贴到 sftp.users[].password_hash然后 ./stop.sh && ./start.sh
```
**公钥鉴权** —— 把每个客户端的公钥OpenSSH 格式)追加到 `keys/authorized_keys`(每行一个)。
`sftp.users[]` 中的用户随后可用任一方式登录。
### 重新生成数据库密码
```bash
.venv/bin/python -m app.scripts.init_db # 生成新随机密码
KEEP_DB_PASSWORD=1 .venv/bin/python -m app.scripts.init_db # 保留现有密码
```
## SFTP 说明
- SFTP 服务无法穿透 Apache 的 HTTP 代理,因此直接绑 `0.0.0.0:2022`。**请在防火墙放开
2022 端口** 供外部客户端FileZilla/WinSCP/scp连接。
- 会话 chroot 到上传根目录(`uploads/`),与 HTTP 共用存储。
-`sftp.users` 中列出的用户可连接;只允许 SFTP无 shell/exec
- SFTP 服务器作为文件暂存通道;不再提供 HTTP 登记接口。
## 反向隧道
SSH 服务器2022同时承载 SFTP 文件暂存与反向隧道。隧道 user 在 `config.yaml`
`tunnel.users[]` 独立配置(与 `sftp.users[]` 分开):
- user 端跑 `user/tunnel.py`,连 2022 请求 remote port forward 绑 `tunnel_port`
- `ZikaiSSHServer.server_requested` 校验该 user 是否允许绑该端口,记一条 `tunnel_session`
到 DBuser IP、local_port、tunnel_port、起止时间
- `GET /api/userPort/{userName}` 查 DB 该 user 活跃隧道的端口,反代到 `127.0.0.1:tunnel_port`
(经隧道回指 user 本地服务)。无活跃隧道返回 502。
- user 断开时 `connection_lost` 回调关闭 DB 会话(记 `ended_at`);另有启动 reaper 兜底
清理进程异常重启后的孤儿记录。
配置示例见 `config.example.yaml``tunnel:` 段。生成 bcrypt hash 的方式同 SFTP。
## 文件浏览页
- `GET /files`Basic Auth同 docs渲染 `static/file_browser.html`JS 调同源管理 API。
- 管理 API均 Basic Auth
- `GET /api/admin/files?limit=&offset=` -> `{total, items:[UploadedFileOut]}`
- `GET /api/admin/files/{id}` -> `UploadedFileOut`
- `GET /api/admin/files/{id}/download` -> 文件流(磁盘缺失返回 410
- `DELETE /api/admin/files/{id}` -> 硬删除:删 DB 行 + 删磁盘文件(`unlink missing_ok`)。
- **删除后不再显示**:列表每次进入或删除后重新 fetch前端不缓存DB 行已删,列表自然不含。
- 公开 `/api/files` 系列user.py 依赖的查重/查询/下载)保留不变。
## 共享白板
白板无鉴权,任何人凭 `/whiteboard/{id}` 即可访问并实时协作;`{id}` 须匹配
`[a-zA-Z0-9_-]{1,64}`,非法返回 400。访问不存在的 id 自动新建空板。白板长期留存
(存 MySQL `whiteboard` 表),进程重启后内容仍在。
### 实时同步与心跳
- 连接:`WS /ws/whiteboard/{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"}`
`{"type":"stroke","stroke":{...},"client_id":"..."}`(广播给他人,不含发送者)、
`{"type":"cleared","client_id":"..."}`(广播给所有人)、`{"type":"error","msg":"..."}`
- **心跳**:客户端每 `whiteboard.heartbeat_interval_seconds`(默认 3s发一次 `ping`,服务端回 `pong`
并刷新计时。后台 reaper 每秒扫描,连续 `heartbeat_miss_threshold`(默认 5次未收到心跳
(即 15s判失活**关闭该连接并从 hub 移除**。
- **内存安全**`WhiteboardHub` 维护 `{board_id: set[Connection]}`
- `disconnect` 幂等,空 set 从 dict 删除(防 board 键无限增长);
- WS 主循环 `try/finally` 必调 `disconnect`,异常/断连均清理;
- `broadcast` 对单连接发送失败立即 `disconnect`,不影响其他连接;
- 删除白板时 `close_board` 关闭并清理该 board 的全部连接。
- **多 worker 限制**hub 是进程内存,多 uvicorn worker 下不同进程的连接不互通。生产部署需
保持 `server.workers: 1`,或后续接 Redis pub/sub 跨进程广播。
### 白板管理
- `GET /whiteboard-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 连接。
## 临时文件清理
- `complete` 成功(含去重命中)后,会话目录 `uploads/.work/<upload_id>/` 立即删除。
- 被放弃的上传(`pending` 状态且超过 `chunk_session_ttl_seconds` 无活动,默认 5 分钟)由
**后台 reaper** 清理:每 60 秒扫一次,删 `.work/<upload_id>/` 目录 + DB 会话行。
- `start.sh` 启动时仍会兜底清掉残留的 `.work/``*.part`(进程异常退出时的半成品)。
## 日志与 pidfile
- HTTP 日志 → `logs/app.log`SFTP 日志 → `logs/sftp.log`
- pidfile`app.pid``sftp.pid``stop.sh` 使用)。

View File

@@ -102,6 +102,8 @@ class WhiteboardConfig(BaseModel):
heartbeat_miss_threshold: int = 5 heartbeat_miss_threshold: int = 5
# board_id 合法字符集与长度上限,防路径/注入 # board_id 合法字符集与长度上限,防路径/注入
max_board_id_length: int = 64 max_board_id_length: int = 64
# 单 board 并发连接上限,防资源耗尽(同 board 同时在线人数)
max_connections_per_board: int = 50
# 列表/管理页分页默认值 # 列表/管理页分页默认值
list_limit: int = 100 list_limit: int = 100

View File

@@ -43,7 +43,8 @@ class BatchDeleteResult(BaseModel):
"", "",
response_model=FileListResponse, response_model=FileListResponse,
summary="列出已上传文件(需鉴权)", summary="列出已上传文件(需鉴权)",
description="供文件浏览页使用;字段同公开 /api/files但要求 docs Basic Auth。支持分页。", description="供文件浏览页使用;字段同公开 /api/files但要求 docs Basic Auth。支持分页。"
"返回前会触发磁盘扫描(频率限制 3s把 SFTP 上传但未入库的文件补录进来。",
) )
def list_files( def list_files(
limit: int = Query(100, ge=1, le=10000, description="每页条数1-10000"), limit: int = Query(100, ge=1, le=10000, description="每页条数1-10000"),
@@ -51,6 +52,7 @@ def list_files(
service: UploadService = Depends(_service), service: UploadService = Depends(_service),
_: str = Depends(require_docs_auth), _: str = Depends(require_docs_auth),
) -> FileListResponse: ) -> FileListResponse:
service.scan_sftp_files() # 频率限制内置3s 内重复只返回 DB 缓存
total, items = service.list_files(limit=limit, offset=offset) total, items = service.list_files(limit=limit, offset=offset)
return FileListResponse(total=total, items=items) return FileListResponse(total=total, items=items)

View File

@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -43,8 +43,8 @@ async def upload_file(
@router.get("", response_model=FileListResponse, summary="列出已上传的文件") @router.get("", response_model=FileListResponse, summary="列出已上传的文件")
def list_files( def list_files(
limit: int = 100, limit: int = Query(100, ge=1, le=10000),
offset: int = 0, offset: int = Query(0, ge=0),
service: UploadService = Depends(_service), service: UploadService = Depends(_service),
) -> FileListResponse: ) -> FileListResponse:
total, items = service.list_files(limit=limit, offset=offset) total, items = service.list_files(limit=limit, offset=offset)

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
@@ -29,17 +32,17 @@ router = APIRouter(tags=["whiteboard"])
def _service(db: Session = Depends(get_db)) -> WhiteboardService: def _service(db: Session = Depends(get_db)) -> WhiteboardService:
"""REST 路径的 service:注入 hub 以便删除时踢出连接""" """REST 路径的 service(纯 DB 操作)"""
return WhiteboardService(WhiteboardDAO(db), hub=get_hub()) return WhiteboardService(WhiteboardDAO(db))
# ---------------- 公开 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,10 +51,10 @@ 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 / 创建时间 / 编辑次数 / 上次修改时间。",
) )
def list_whiteboards( def list_whiteboards(
limit: int = Query(100, ge=1, le=500), limit: int = Query(100, ge=1, le=500),
@@ -64,11 +67,11 @@ 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 连接。",
) )
def delete_whiteboard( async def delete_whiteboard(
board_id: str, board_id: str,
service: WhiteboardService = Depends(_service), service: WhiteboardService = Depends(_service),
_: str = Depends(require_docs_auth), _: str = Depends(require_docs_auth),
@@ -76,25 +79,27 @@ def delete_whiteboard(
ok = service.delete(board_id) ok = service.delete(board_id)
if not ok: if not ok:
raise HTTPException(404, "白板不存在") raise HTTPException(404, "白板不存在")
# 删除成功后踢出该 board 的所有在线连接close_board 是 async须在事件循环中调用
await get_hub().close_board(board_id)
return {"deleted": True} return {"deleted": True}
# ---------------- 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:
"""白板实时协作端点。 """白板实时协作端点(文本记事本)
协议JSON 文本帧): 协议JSON 文本帧):
client -> server: client -> server:
{"type":"hello","client_id":"..."} 首帧(可选,未带则服务端生成) {"type":"hello","client_id":"..."} 首帧(可选,未带则服务端生成)
{"type":"ping"} 心跳,服务端回 pong 并刷新计时 {"type":"ping"} 心跳,服务端回 pong 并刷新计时
{"type":"stroke","stroke":{...}} 新增笔画,持久化并广播给他人 {"type":"edit","content":"..."} debounce 后发完整文本,持久化并广播给他人
{"type":"clear"} 清空,持久化并广播给所有人 {"type":"clear"} 清空,持久化并广播给所有人
server -> client: server -> client:
{"type":"init","strokes":[...],"stroke_count":n} {"type":"init","content":"...","version":n,"edit_count":m}
{"type":"pong"} {"type":"pong"}
{"type":"stroke","stroke":{...},"client_id":"..."} {"type":"update","content":"...","version":n,"client_id":"..."} 文本变更
{"type":"cleared","client_id":"..."} {"type":"cleared","client_id":"..."}
{"type":"error","msg":"..."} {"type":"error","msg":"..."}
""" """
@@ -112,9 +117,8 @@ async def whiteboard_ws(websocket: WebSocket, board_id: str) -> None:
client_id = _extract_client_id(first) or uuid.uuid4().hex[:12] client_id = _extract_client_id(first) or uuid.uuid4().hex[:12]
# 校验 board_id 并加载白板(不存在则新建) # 校验 board_id 并加载白板(不存在则新建)
from ..database import get_session_local
try: try:
board = _with_db(lambda db: WhiteboardService(WhiteboardDAO(db), hub=hub).get_or_create(board_id)) board = _with_db(lambda db: WhiteboardService(WhiteboardDAO(db)).get_or_create(board_id))
except HTTPException as exc: except HTTPException as exc:
await _safe_send(websocket, {"type": "error", "msg": exc.detail}) await _safe_send(websocket, {"type": "error", "msg": exc.detail})
await _safe_close(websocket) await _safe_close(websocket)
@@ -122,17 +126,27 @@ async def whiteboard_ws(websocket: WebSocket, board_id: str) -> None:
# 注册连接并下发 init # 注册连接并下发 init
conn = Connection(websocket=websocket, board_id=board_id, client_id=client_id) conn = Connection(websocket=websocket, board_id=board_id, client_id=client_id)
await hub.register(conn) ok = await hub.register(conn)
if not ok:
await _safe_send(websocket, {"type": "error", "msg": "该记事本在线人数已满"})
await _safe_close(websocket)
return
await _safe_send(websocket, { await _safe_send(websocket, {
"type": "init", "type": "init",
"strokes": board.strokes, "content": board.content,
"stroke_count": board.stroke_count, "version": board.version,
"edit_count": board.edit_count,
}) })
# 主循环:收消息 -> 处理 -> 广播 # 主循环:收消息 -> 处理 -> 广播
# 单帧大小上限:与 content 限制对齐256KB 文本 + JSON 开销,留余量到 512KB
MAX_FRAME = 512 * 1024
try: try:
while True: while True:
raw = await websocket.receive_text() raw = await websocket.receive_text()
if len(raw) > MAX_FRAME:
await _safe_send(websocket, {"type": "error", "msg": "消息过大"})
continue
msg = _parse(raw) msg = _parse(raw)
if msg is None: if msg is None:
continue continue
@@ -143,22 +157,27 @@ async def whiteboard_ws(websocket: WebSocket, board_id: str) -> None:
continue continue
# 任何有效业务帧都视为活性证据 # 任何有效业务帧都视为活性证据
conn.touch() conn.touch()
if mtype == "stroke": if mtype == "edit":
stroke = msg.get("stroke") or {} content = msg.get("content")
if not isinstance(content, str):
await _safe_send(websocket, {"type": "error", "msg": "content 必须是字符串"})
continue
try: try:
_with_db(lambda db: WhiteboardService(WhiteboardDAO(db), hub=hub).append_stroke(board_id, stroke)) out = _with_db(
lambda db: WhiteboardService(WhiteboardDAO(db)).update_content(board_id, content)
)
except HTTPException as exc: except HTTPException as exc:
await _safe_send(websocket, {"type": "error", "msg": exc.detail}) await _safe_send(websocket, {"type": "error", "msg": exc.detail})
continue continue
# 广播给他人(发送者本地已,不回推) # 广播给他人(发送者本地已更新,不回推)
await hub.broadcast( await hub.broadcast(
board_id, board_id,
{"type": "stroke", "stroke": stroke, "client_id": client_id}, {"type": "update", "content": out.content, "version": out.version, "client_id": client_id},
exclude=conn, exclude=conn,
) )
elif mtype == "clear": elif mtype == "clear":
try: try:
_with_db(lambda db: WhiteboardService(WhiteboardDAO(db), hub=hub).clear(board_id)) _with_db(lambda db: WhiteboardService(WhiteboardDAO(db)).clear(board_id))
except HTTPException as exc: except HTTPException as exc:
await _safe_send(websocket, {"type": "error", "msg": exc.detail}) await _safe_send(websocket, {"type": "error", "msg": exc.detail})
continue continue

View File

@@ -29,6 +29,11 @@ class UploadedFileDAO:
"""返回数据库中文件总条数。""" """返回数据库中文件总条数。"""
return self.db.scalar(select(func.count()).select_from(UploadedFile)) or 0 return self.db.scalar(select(func.count()).select_from(UploadedFile)) or 0
def list_storage_paths(self) -> set[str]:
"""返回所有已记录的 storage_path 集合(供磁盘扫描比对,识别未入库文件)。"""
stmt = select(UploadedFile.storage_path)
return {row for row in self.db.scalars(stmt).all()}
def list(self, limit: int = 100, offset: int = 0) -> list[UploadedFile]: def list(self, limit: int = 100, offset: int = 0) -> list[UploadedFile]:
stmt = ( stmt = (
select(UploadedFile) select(UploadedFile)

View File

@@ -1,13 +1,11 @@
"""Whiteboard 的 DAO。 """Whiteboard 的 DAO(文本记事本)
所有写操作均在该层 commitservice 不直接操作 session。 所有写操作均在该层 commitservice 不直接操作 session。
get_or_create 用于「访问即新建」语义(路由 GET /whiteboard/{id} 不存在则建)。 get_or_create 用于「访问即新建」语义(路由 GET /api/wb/{id} 不存在则建)。
""" """
from __future__ import annotations from __future__ import annotations
from typing import Any
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -33,7 +31,7 @@ class WhiteboardDAO:
board = self.get(board_id) board = self.get(board_id)
if board is not None: if board is not None:
return board return board
board = Whiteboard(board_id=board_id, strokes=[], stroke_count=0) board = Whiteboard(board_id=board_id, content="", version=0, edit_count=0)
try: try:
return self.create(board) return self.create(board)
except Exception: except Exception:
@@ -41,24 +39,14 @@ class WhiteboardDAO:
self.db.rollback() self.db.rollback()
return self.get(board_id) # type: ignore[return-value] return self.get(board_id) # type: ignore[return-value]
def append_strokes(self, board_id: str, new_strokes: list[Any]) -> Whiteboard | None: def update_content(self, board_id: str, content: str) -> Whiteboard | None:
"""把新笔画追加到 strokes 数组尾部stroke_count 自增""" """整体替换文本内容version +1、edit_count +1"""
board = self.get(board_id) board = self.get(board_id)
if board is None: if board is None:
return None return None
board.strokes = [*board.strokes, *new_strokes] board.content = content
board.stroke_count = (board.stroke_count or 0) + len(new_strokes) board.version = (board.version or 0) + 1
self.db.commit() board.edit_count = (board.edit_count or 0) + 1
self.db.refresh(board)
return board
def replace_strokes(self, board_id: str, strokes: list[Any]) -> Whiteboard | None:
"""整体替换 strokes清空时传 []stroke_count 自增 1。"""
board = self.get(board_id)
if board is None:
return None
board.strokes = list(strokes)
board.stroke_count = (board.stroke_count or 0) + 1
self.db.commit() self.db.commit()
self.db.refresh(board) self.db.refresh(board)
return board return board

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
""" """
@@ -155,48 +155,68 @@ def create_app() -> FastAPI:
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static") app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
# 受 Basic Auth 保护的文档接口 # 受 Basic Auth 保护的文档接口
@app.get("/openapi.json") @app.get("/openapi.json", tags=["docs"], summary="OpenAPI 文档(需鉴权)")
def protected_openapi(_: str = Depends(require_docs_auth)) -> JSONResponse: def protected_openapi(_: str = Depends(require_docs_auth)) -> JSONResponse:
return JSONResponse(app.openapi()) return JSONResponse(app.openapi())
@app.get("/docs") @app.get("/docs", tags=["docs"], summary="Swagger UI需鉴权")
def protected_docs(_: str = Depends(require_docs_auth)): def protected_docs(_: str = Depends(require_docs_auth)):
return get_swagger_ui_html( return get_swagger_ui_html(
openapi_url="/openapi.json", title="zikai docs", swagger_favicon_url="" openapi_url="/openapi.json", title="zikai docs", swagger_favicon_url=""
) )
@app.get("/redoc") @app.get("/redoc", tags=["docs"], summary="ReDoc需鉴权")
def protected_redoc(_: str = Depends(require_docs_auth)): def protected_redoc(_: str = Depends(require_docs_auth)):
return get_redoc_html( return get_redoc_html(
openapi_url="/openapi.json", title="zikai docs", redoc_favicon_url="" openapi_url="/openapi.json", title="zikai docs", redoc_favicon_url=""
) )
@app.get("/", response_class=PlainTextResponse) @app.get("/", tags=["meta"], summary="版本号")
def root() -> PlainTextResponse: def root() -> PlainTextResponse:
return PlainTextResponse(f"zikai {app.version}\n") return PlainTextResponse(f"zikai {app.version}\n")
@app.get("/health") @app.get("/health", tags=["meta"], summary="存活探针")
def health() -> dict: def health() -> dict:
return {"status": "ok"} return {"status": "ok"}
@app.get("/upload", response_class=HTMLResponse) @app.get(
"/upload",
response_class=HTMLResponse,
tags=["pages"],
summary="上传页面",
description="拖拽 / 多文件 / 分片4 MiB / 断点续传上传页面(公开)。",
)
def upload_page() -> HTMLResponse: def upload_page() -> HTMLResponse:
"""拖拽 / 多文件 / 分片上传页面(公开,对齐 /api/files/upload"""
return HTMLResponse(render_upload_html()) return HTMLResponse(render_upload_html())
@app.get("/files", response_class=HTMLResponse) @app.get(
"/files",
response_class=HTMLResponse,
tags=["pages"],
summary="文件浏览页(需鉴权)",
description="列出 / 下载 / 删除已上传文件支持多选、批量下载删除与分页。Basic Auth 同 docs。",
)
def files_page(_: str = Depends(require_docs_auth)) -> HTMLResponse: def files_page(_: str = Depends(require_docs_auth)) -> HTMLResponse:
"""文件浏览页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,
tags=["pages"],
summary="记事本管理页(需鉴权)",
description="查看所有记事本的创建时间 / 编辑次数 / 上次修改时间并可删除。Basic Auth 同 docs。",
)
def whiteboard_admin_page(_: str = Depends(require_docs_auth)) -> HTMLResponse: def whiteboard_admin_page(_: str = Depends(require_docs_auth)) -> HTMLResponse:
"""白板管理页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,
tags=["pages"],
summary="记事本页面",
description="公开访问的共享文本记事本,不存在则自动新建;实时协作走 WS /ws/wb/{id}",
)
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")
return app return app

View File

@@ -1,15 +1,16 @@
"""共享白板实体。 """共享白板实体(文本记事本)
一个白板由 board_id 唯一标识(用户可读的 url idstrokes 以 JSON 列保存全部笔画。 一个白板由 board_id 唯一标识(用户可读的 url idcontent 存完整文本,
白板长期留存,进程重启后仍可恢复;实时协作由 WebSocket hub 在内存中维护在线连接, version 是乐观锁版本号(每次编辑 +1白板长期留存,进程重启后仍可恢复;
笔画变更经 service 落库后由 hub 广播给同 board 的其它在线连接。 实时协作由 WebSocket hub 在内存中维护在线连接,文本变更经 service 落库后
由 hub 广播给同 board 的其它在线连接。
""" """
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from sqlalchemy import BigInteger, DateTime, Integer, JSON, String, func from sqlalchemy import BigInteger, DateTime, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from ..database import Base from ..database import Base
@@ -21,10 +22,12 @@ class Whiteboard(Base):
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
# 用户可读的 url id[a-zA-Z0-9_-]{1,64}),全局唯一 # 用户可读的 url id[a-zA-Z0-9_-]{1,64}),全局唯一
board_id: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True) board_id: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
# 笔画数组 [{points:[[x,y],...], color, width}, ...] # 白板文本内容(记事本)
strokes: Mapped[list] = mapped_column(JSON, nullable=False, default=list) content: Mapped[str] = mapped_column(Text, nullable=False, default="")
# 修改次数:每次新增笔画或清空 +1供管理页统计 # 乐观锁版本号:每次编辑 +1
stroke_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) version: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# 编辑次数(累计修改次数,含清空),供管理页统计
edit_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), nullable=False DateTime, server_default=func.now(), nullable=False
) )
@@ -35,5 +38,5 @@ class Whiteboard(Base):
def __repr__(self) -> str: # pragma: no cover def __repr__(self) -> str: # pragma: no cover
return ( return (
f"<Whiteboard board_id={self.board_id!r} " f"<Whiteboard board_id={self.board_id!r} "
f"strokes={len(self.strokes)} mods={self.stroke_count}>" f"len={len(self.content)} v={self.version} mods={self.edit_count}>"
) )

View File

@@ -10,8 +10,6 @@ from .file import FileListResponse, FileUploadResponse, UploadedFileOut
from .system import DiskUsage, MemoryUsage, SystemStatus from .system import DiskUsage, MemoryUsage, SystemStatus
from .tunnel import TunnelStatusResponse from .tunnel import TunnelStatusResponse
from .whiteboard import ( from .whiteboard import (
Stroke,
StrokeOp,
WhiteboardListItem, WhiteboardListItem,
WhiteboardListResponse, WhiteboardListResponse,
WhiteboardOut, WhiteboardOut,
@@ -26,8 +24,6 @@ __all__ = [
"FileUploadResponse", "FileUploadResponse",
"MemoryUsage", "MemoryUsage",
"SessionStatusResponse", "SessionStatusResponse",
"Stroke",
"StrokeOp",
"SystemStatus", "SystemStatus",
"TunnelStatusResponse", "TunnelStatusResponse",
"UploadedFileOut", "UploadedFileOut",

View File

@@ -1,27 +1,19 @@
"""白板接口 DTO。""" """白板接口 DTO(文本记事本)"""
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
class Stroke(BaseModel):
"""一条笔画:点序列 + 样式。结构宽松Any以兼容前端扩展字段。"""
points: list[list[float]] = Field(default_factory=list, description="[[x,y],...]")
color: str = Field(default="#1565c0", description="笔画颜色")
width: float = Field(default=3, description="笔画宽度")
class WhiteboardOut(BaseModel): class WhiteboardOut(BaseModel):
"""白板完整内容GET /whiteboard/{id} 与 WS init 帧)。""" """白板完整内容GET /api/wb/{id} 与 WS init 帧)。"""
board_id: str board_id: str
strokes: list[Any] = Field(default_factory=list, description="笔画数组") content: str = Field("", description="白板文本内容")
stroke_count: int = Field(0, description="累计修改次数") version: int = Field(0, description="乐观锁版本号,每次编辑 +1")
edit_count: int = Field(0, description="累计编辑次数")
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -32,7 +24,7 @@ class WhiteboardListItem(BaseModel):
"""管理页列表项。""" """管理页列表项。"""
board_id: str board_id: str
stroke_count: int edit_count: int
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -44,10 +36,3 @@ class WhiteboardListResponse(BaseModel):
total: int total: int
items: list[WhiteboardListItem] items: list[WhiteboardListItem]
class StrokeOp(BaseModel):
"""WS 笔画操作type=stroke 时携带)。"""
type: str = Field(..., description="add / clear")
stroke: dict[str, Any] | None = Field(None, description="type=add 时携带的笔画对象")

View File

@@ -8,7 +8,9 @@ upload_root不再有 HTTP 登记接口。
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import logging
import os import os
import time
import uuid import uuid
from collections.abc import Iterator from collections.abc import Iterator
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -21,6 +23,12 @@ from ..dao.uploaded_file_dao import UploadedFileDAO
from ..models.uploaded_file import UploadedFile from ..models.uploaded_file import UploadedFile
from ..schemas.file import FileUploadResponse, UploadedFileOut from ..schemas.file import FileUploadResponse, UploadedFileOut
logger = logging.getLogger("zikai.upload")
# 磁盘扫描频率限制:两次扫描至少间隔此秒数,否则跳过(只返回 DB 缓存)。
_SCAN_MIN_INTERVAL = 3.0
_last_scan_time: float = 0.0
def hash_stream(chunks: Iterator[bytes]) -> tuple[int, str]: def hash_stream(chunks: Iterator[bytes]) -> tuple[int, str]:
"""对一段字节块序列流式计算 (size, sha256)。供多条上传路径复用。""" """对一段字节块序列流式计算 (size, sha256)。供多条上传路径复用。"""
@@ -66,6 +74,72 @@ class UploadService:
# ---------------- 查询 ---------------- # ---------------- 查询 ----------------
def scan_sftp_files(self, force: bool = False) -> int:
"""扫描 uploads/ 目录,把磁盘上有但 DB 未记录的文件补录入库。
SFTP 上传的文件直接落盘到 upload_rootchroot 根),不经过 HTTP 路径,
因此没有 DB 记录。本方法遍历磁盘文件,与 DB 已有 storage_path 比对,
为缺失项创建记录source="sftp",计算 size + sha256
频率限制:两次扫描间隔 < _SCAN_MIN_INTERVAL3s则跳过force=True 强制扫。
返回本次新补录的条数(跳过时返回 0
"""
global _last_scan_time
now = time.monotonic()
if not force and (now - _last_scan_time) < _SCAN_MIN_INTERVAL:
return 0
_last_scan_time = now
known = self.dao.list_storage_paths()
new_count = 0
# 排除分片会话暂存目录与 .part 残品
skip_dirs = {".work"}
for abs_path in self.upload_root.rglob("*"):
if not abs_path.is_file():
continue
if abs_path.suffix == ".part":
continue
# 跳过 .work 目录下的任何文件
rel = abs_path.relative_to(self.upload_root)
if rel.parts and rel.parts[0] in skip_dirs:
continue
storage_path = str(rel).replace("\\", "/")
if storage_path in known:
continue
# 磁盘有、DB 无:补录
try:
size = abs_path.stat().st_size
sha256 = self._hash_file(abs_path)
except Exception as exc:
logger.warning("扫描文件失败 path=%s: %s", abs_path, exc)
continue
entity = UploadedFile(
storage_path=storage_path,
original_filename=abs_path.name,
content_type="",
size_bytes=size,
sha256=sha256,
source="sftp",
uploaded_by="sftp",
)
try:
self.dao.create(entity)
new_count += 1
known.add(storage_path)
except Exception as exc:
logger.warning("补录 SFTP 文件失败 path=%s: %s", storage_path, exc)
if new_count:
logger.info("SFTP 文件扫描补录 %d", new_count)
return new_count
def _hash_file(self, path: Path) -> str:
"""流式计算文件 sha256避免大文件一次性读入内存"""
h = hashlib.sha256()
with path.open("rb") as f:
while chunk := f.read(self.chunk_bytes):
h.update(chunk)
return h.hexdigest()
def list_files(self, limit: int = 100, offset: int = 0) -> tuple[int, list[UploadedFileOut]]: def list_files(self, limit: int = 100, offset: int = 0) -> tuple[int, list[UploadedFileOut]]:
total = self.dao.count() total = self.dao.count()
rows = self.dao.list(limit=limit, offset=offset) rows = self.dao.list(limit=limit, offset=offset)
@@ -95,18 +169,17 @@ class UploadService:
def delete_file(self, file_id: int) -> bool: def delete_file(self, file_id: int) -> bool:
"""硬删除:删磁盘文件 + 删 DB 行。磁盘缺失不阻断 DB 清理。返回是否命中。 """硬删除:删磁盘文件 + 删 DB 行。磁盘缺失不阻断 DB 清理。返回是否命中。
供单删/批删复用,保证删除语义一致。 供单删/批删复用,保证删除语义一致。磁盘删除失败仅记日志,仍清 DB 行
(保证列表不再显示),避免磁盘文件泄漏却无任何记录。
""" """
_, path = self.get_out_with_disk_path(file_id) out, path = self.get_out_with_disk_path(file_id)
row = self.dao.get_by_id(file_id) if out is None:
if row is None:
return False return False
if path is not None: if path is not None:
try: try:
Path(path).unlink(missing_ok=True) Path(path).unlink(missing_ok=True)
except Exception: except Exception as exc:
# 即使磁盘删除失败也继续清 DB 行,保证列表不再显示 logger.warning("删除磁盘文件失败 file_id=%s path=%s: %s", file_id, path, exc)
pass
self.dao.delete(file_id) self.dao.delete(file_id)
return True return True

View File

@@ -18,6 +18,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import threading
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -58,19 +59,26 @@ class WhiteboardHub:
self.heartbeat_interval = cfg.heartbeat_interval_seconds self.heartbeat_interval = cfg.heartbeat_interval_seconds
self.heartbeat_miss_threshold = cfg.heartbeat_miss_threshold self.heartbeat_miss_threshold = cfg.heartbeat_miss_threshold
self.timeout_seconds = self.heartbeat_interval * self.heartbeat_miss_threshold self.timeout_seconds = self.heartbeat_interval * self.heartbeat_miss_threshold
self.max_connections_per_board = cfg.max_connections_per_board
# {board_id: set[Connection]} # {board_id: set[Connection]}
self._boards: dict[str, set[Connection]] = {} self._boards: dict[str, set[Connection]] = {}
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
# ---------------- 连接生命周期 ---------------- # ---------------- 连接生命周期 ----------------
async def register(self, conn: Connection) -> None: async def register(self, conn: Connection) -> bool:
"""把已 accept 的连接加入 board 集合WebSocket accept 由 controller 负责)。""" """把已 accept 的连接加入 board 集合
返回 False 表示该 board 连接数已达上限(调用方应关闭连接)。
"""
async with self._lock: async with self._lock:
conns = self._boards.setdefault(conn.board_id, set()) conns = self._boards.setdefault(conn.board_id, set())
if len(conns) >= self.max_connections_per_board:
return False
conns.add(conn) conns.add(conn)
logger.info("连接接入 board=%s client=%s(当前 %d 人)", logger.info("连接接入 board=%s client=%s(当前 %d 人)",
conn.board_id, conn.client_id, self.connection_count(conn.board_id)) conn.board_id, conn.client_id, self.connection_count(conn.board_id))
return True
async def disconnect(self, conn: Connection) -> None: async def disconnect(self, conn: Connection) -> None:
"""幂等移除连接;空 set 从 dict 删除以防内存泄漏。""" """幂等移除连接;空 set 从 dict 删除以防内存泄漏。"""
@@ -160,16 +168,21 @@ class WhiteboardHub:
# 进程内单例(由 main.py lifespan / controller 共享) # 进程内单例(由 main.py lifespan / controller 共享)
_hub: WhiteboardHub | None = None _hub: WhiteboardHub | None = None
_hub_lock = threading.Lock()
def get_hub() -> WhiteboardHub: def get_hub() -> WhiteboardHub:
"""获取/创建进程内单例 hub。线程安全lifespan 预热后通常不再进锁)。"""
global _hub global _hub
if _hub is None: if _hub is None:
_hub = WhiteboardHub() with _hub_lock:
if _hub is None:
_hub = WhiteboardHub()
return _hub return _hub
def reset_hub() -> None: def reset_hub() -> None:
"""测试用:重置单例。""" """测试用:重置单例。"""
global _hub global _hub
_hub = None with _hub_lock:
_hub = None

View File

@@ -1,14 +1,13 @@
"""白板服务CRUD + 笔画操作 """白板服务(文本记事本)CRUD + 文本更新 + 清空
不持有 WebSocket 连接状态(那是 hub 的职责)删除白板时通过可选的 hub 回调 不持有 WebSocket 连接状态(那是 hub 的职责)删除白板时由 controller 层负责
通知 hub 踢出该 board 的所有在线连接,由 controller 在装配时注入,避免 service -> hub 通知 hub 踢出在线连接(因为 close_board 是 async需在事件循环中调用
的硬依赖(保持低耦合 service 只管 DB 层面的删除,保持低耦合。
""" """
from __future__ import annotations from __future__ import annotations
import re import re
from typing import TYPE_CHECKING, Any
from fastapi import HTTPException from fastapi import HTTPException
@@ -16,20 +15,18 @@ from ..config import get_settings
from ..dao.whiteboard_dao import WhiteboardDAO from ..dao.whiteboard_dao import WhiteboardDAO
from ..schemas.whiteboard import WhiteboardListItem, WhiteboardOut from ..schemas.whiteboard import WhiteboardListItem, WhiteboardOut
if TYPE_CHECKING: # 避免运行时循环导入
from .whiteboard_hub import WhiteboardHub
# board_id 合法字符集:字母数字下划线短横线 # board_id 合法字符集:字母数字下划线短横线
_BOARD_ID_RE = re.compile(r"^[a-zA-Z0-9_-]+$") _BOARD_ID_RE = re.compile(r"^[a-zA-Z0-9_-]+$")
class WhiteboardService: class WhiteboardService:
def __init__(self, dao: WhiteboardDAO, hub: "WhiteboardHub | None" = None) -> None: def __init__(self, dao: WhiteboardDAO) -> None:
self.dao = dao self.dao = dao
self.hub = hub
cfg = get_settings().whiteboard cfg = get_settings().whiteboard
self.max_board_id_length = cfg.max_board_id_length self.max_board_id_length = cfg.max_board_id_length
self.list_limit = cfg.list_limit self.list_limit = cfg.list_limit
# 文本内容长度上限(防滥用)
self.max_content_length = 256 * 1024
# ---------------- 校验 ---------------- # ---------------- 校验 ----------------
@@ -42,6 +39,15 @@ class WhiteboardService:
): ):
raise HTTPException(400, "board_id 非法仅允许字母数字下划线短横线1-64 字符)") raise HTTPException(400, "board_id 非法仅允许字母数字下划线短横线1-64 字符)")
def validate_content(self, content: str) -> None:
if not isinstance(content, str):
raise HTTPException(400, "content 必须是字符串")
if len(content) > self.max_content_length:
raise HTTPException(
413,
f"文本过长({len(content)} > {self.max_content_length}),请缩减内容",
)
# ---------------- 读 ---------------- # ---------------- 读 ----------------
def get_or_create(self, board_id: str) -> WhiteboardOut: def get_or_create(self, board_id: str) -> WhiteboardOut:
@@ -59,34 +65,20 @@ class WhiteboardService:
# ---------------- 写 ---------------- # ---------------- 写 ----------------
def append_stroke(self, board_id: str, stroke: dict[str, Any]) -> WhiteboardOut: def update_content(self, board_id: str, content: str) -> WhiteboardOut:
"""追加一条笔画并返回最新状态""" """整体替换文本内容(客户端 debounce 后发完整文本)"""
self.validate_board_id(board_id) self.validate_board_id(board_id)
board = self.dao.append_strokes(board_id, [stroke]) self.validate_content(content)
board = self.dao.update_content(board_id, content)
if board is None: if board is None:
raise HTTPException(404, "白板不存在") raise HTTPException(404, "白板不存在")
return WhiteboardOut.model_validate(board) return WhiteboardOut.model_validate(board)
def clear(self, board_id: str) -> WhiteboardOut: def clear(self, board_id: str) -> WhiteboardOut:
"""清空白板stroke_count 仍自增以记录这次修改。""" """清空白板内容置空edit_count 仍自增以记录这次修改。"""
self.validate_board_id(board_id) return self.update_content(board_id, "")
board = self.dao.replace_strokes(board_id, [])
if board is None:
raise HTTPException(404, "白板不存在")
return WhiteboardOut.model_validate(board)
def delete(self, board_id: str) -> bool: def delete(self, board_id: str) -> bool:
"""删除白板;同时通知 hub 踢出该 board 的所有在线连接""" """删除白板 DB 行。踢出在线连接由 controller 层负责close_board 是 async"""
self.validate_board_id(board_id) self.validate_board_id(board_id)
ok = self.dao.delete(board_id) return self.dao.delete(board_id)
if ok and self.hub is not None:
# hub.close_board 是 async但删除走 REST 同步路径;安排到事件循环里执行
import asyncio
try:
loop = asyncio.get_running_loop()
loop.create_task(self.hub.close_board(board_id))
except RuntimeError:
# 无运行中事件循环(如脚本调用):同步调用会报错,忽略即可
pass
return ok

View File

@@ -53,13 +53,15 @@ CREATE TABLE IF NOT EXISTS `tunnel_session` (
KEY `idx_status` (`status`) KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 共享白板表。由 app/services/whiteboard_service.py 使用。 -- 共享白板表(文本记事本)。由 app/services/whiteboard_service.py 使用。
-- 白板长期留存strokes 以 JSON 保存全部笔画;实时同步由 WebSocket hub 在内存维护 -- content 存完整文本version 是乐观锁版本号(每次编辑 +1edit_count 累计编辑次数
-- 白板长期留存,实时同步由 WebSocket hub 在内存维护在线连接。
CREATE TABLE IF NOT EXISTS `whiteboard` ( CREATE TABLE IF NOT EXISTS `whiteboard` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`board_id` VARCHAR(64) NOT NULL COMMENT '用户可读的 url id[a-zA-Z0-9_-]{1,64}', `board_id` VARCHAR(64) NOT NULL COMMENT '用户可读的 url id[a-zA-Z0-9_-]{1,64}',
`strokes` JSON NOT NULL COMMENT '笔画数组 [{points,color,width}, ...]', `content` MEDIUMTEXT NOT NULL COMMENT '白板文本内容',
`stroke_count` INT NOT NULL DEFAULT 0 COMMENT '累计修改次数(新增笔画/清空各 +1', `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号,每次编辑 +1',
`edit_count` INT NOT NULL DEFAULT 0 COMMENT '累计编辑次数(含清空)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),

View File

@@ -3,6 +3,12 @@
(function (global) { (function (global) {
"use strict"; "use strict";
// 布尔属性:用属性赋值而非 setAttributesetAttribute("checked","false") 仍会勾选)
const BOOL_PROPS = new Set([
"checked", "disabled", "readonly", "selected", "hidden", "multiple", "open",
"autofocus", "required", "async", "defer", "controls", "autoplay", "loop", "muted",
]);
function el(tag, attrs, ...children) { function el(tag, attrs, ...children) {
const node = document.createElement(tag); const node = document.createElement(tag);
if (attrs) { if (attrs) {
@@ -11,7 +17,8 @@
else if (k === "dataset") Object.assign(node.dataset, v); else if (k === "dataset") Object.assign(node.dataset, v);
else if (k.startsWith("on") && typeof v === "function") else if (k.startsWith("on") && typeof v === "function")
node.addEventListener(k.slice(2).toLowerCase(), v); node.addEventListener(k.slice(2).toLowerCase(), v);
else if (v !== null && v !== undefined) node.setAttribute(k, v); else if (BOOL_PROPS.has(k)) node[k] = !!v;
else if (v !== null && v !== undefined && v !== false) node.setAttribute(k, v);
} }
} }
for (const c of children) { for (const c of children) {
@@ -54,15 +61,15 @@
} }
function fmtBytes(n) { function fmtBytes(n) {
if (n == null) return "-"; let x = Number(n);
const x = Number(n); if (n == null || !isFinite(x)) return "-";
if (!isFinite(x)) return "-"; const units = ["B", "KB", "MB", "GB", "TB", "PB"];
for (const unit of ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]) { let i = 0;
if (Math.abs(x) < 1024 || unit === "PiB") while (Math.abs(x) >= 1000 && i < units.length - 1) {
return unit === "B" ? `${x} B` : `${x.toFixed(1)} ${unit}`; x /= 1000;
n = x / 1024; i++;
} }
return `${n.toFixed(1)} PiB`; return i === 0 ? `${Math.round(x)} ${units[i]}` : `${x.toFixed(1)} ${units[i]}`;
} }
function fmtTime(s) { function fmtTime(s) {
@@ -76,8 +83,15 @@
async function api(path, opts) { async function api(path, opts) {
const res = await fetch(path, opts); const res = await fetch(path, opts);
if (res.status === 401) { if (res.status === 401) {
// 触发浏览器 Basic Auth 弹窗(同源 reload 即可带上凭据) // fetch 不会触发浏览器 Basic Auth 弹窗(只有导航/form 会)。
// 重载当前页:浏览器对页面导航的 401 会弹凭据框,凭据缓存后重试即可带上。
toast("需要登录"); toast("需要登录");
if (location.href.indexOf("/api/") !== -1) {
// 纯 API 调用页(无页面壳),跳转到来源页触发鉴权
location.reload();
} else {
location.reload();
}
throw new Error("UNAUTHORIZED"); throw new Error("UNAUTHORIZED");
} }
return res; return res;

View File

@@ -1,4 +1,4 @@
/* 白板页专属样式:全屏画布、悬浮工具栏、移动端适配。 */ /* 记事本页专属样式:全屏 textarea、悬浮工具栏、移动端适配。 */
:root { --bar-h: 52px; } :root { --bar-h: 52px; }
body { overflow: hidden; background: var(--bg); } body { overflow: hidden; background: var(--bg); }
.wb-app { display: flex; flex-direction: column; height: 100vh; height: 100dvh; } .wb-app { display: flex; flex-direction: column; height: 100vh; height: 100dvh; }
@@ -14,22 +14,20 @@ body { overflow: hidden; background: var(--bg); }
.wb-id { background: var(--surface-2); padding: 0.15em 0.5em; border-radius: 6px; font-size: 0.82em; color: var(--text-dim); } .wb-id { background: var(--surface-2); padding: 0.15em 0.5em; border-radius: 6px; font-size: 0.82em; color: var(--text-dim); }
.wb-online { color: var(--success); font-size: 0.7em; } .wb-online { color: var(--success); font-size: 0.7em; }
.wb-online.off { color: var(--text-dim); } .wb-online.off { color: var(--text-dim); }
.wb-tool { display: inline-flex; align-items: center; gap: 0.3em; font-size: 0.85em; color: var(--text-dim); }
.wb-tool input[type="color"] { width: 28px; height: 28px; padding: 0; border: 1px solid var(--border); border-radius: 6px; background: transparent; cursor: pointer; }
.wb-tool input[type="range"] { width: 80px; accent-color: var(--primary); }
.wb-width-val { width: 1.4em; text-align: center; }
.wb-bar .btn { padding: 0.4em 0.9em; font-size: 0.86em; } .wb-bar .btn { padding: 0.4em 0.9em; font-size: 0.86em; }
.wb-stage { position: relative; flex: 1; overflow: hidden; } .wb-stage { position: relative; flex: 1; overflow: hidden; }
#canvas { .wb-editor {
position: absolute; inset: 0; width: 100%; height: 100%; position: absolute; inset: 0;
display: block; touch-action: none; cursor: crosshair; width: 100%; height: 100%;
background: display: block; resize: none; border: none; outline: none;
linear-gradient(var(--border) 1px, transparent 1px) 0 0 / 24px 24px, padding: 1em 1.2em;
linear-gradient(90deg, var(--border) 1px, transparent 1px) 0 0 / 24px 24px, font-family: ui-monospace, "SF Mono", Menlo, Consolas, "JetBrains Mono", monospace;
var(--surface); font-size: 14px; line-height: 1.6;
background-blend-mode: normal; background: var(--surface); color: var(--text);
touch-action: manipulation;
} }
.wb-editor::placeholder { color: var(--text-dim); }
.wb-status { .wb-status {
position: absolute; bottom: 0.8em; left: 50%; transform: translateX(-50%); position: absolute; bottom: 0.8em; left: 50%; transform: translateX(-50%);
background: var(--surface); border: 1px solid var(--border); background: var(--surface); border: 1px solid var(--border);
@@ -44,7 +42,7 @@ body { overflow: hidden; background: var(--bg); }
@media (max-width: 640px) { @media (max-width: 640px) {
.wb-bar { padding: 0.4em 0.5em; gap: 0.4em; } .wb-bar { padding: 0.4em 0.5em; gap: 0.4em; }
.wb-id { max-width: 8em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .wb-id { max-width: 8em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wb-tool input[type="range"] { width: 56px; }
.wb-bar .btn { padding: 0.45em 0.7em; } .wb-bar .btn { padding: 0.45em 0.7em; }
.wb-title { display: none; } .wb-title { display: none; }
.wb-editor { font-size: 15px; padding: 0.8em; }
} }

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<meta name="theme-color" content="#1565c0"> <meta name="theme-color" content="#1565c0">
<title>白板 - zikai</title> <title>记事本 - zikai</title>
<link rel="stylesheet" href="/static/common.css"> <link rel="stylesheet" href="/static/common.css">
<link rel="stylesheet" href="/static/whiteboard.css"> <link rel="stylesheet" href="/static/whiteboard.css">
</head> </head>
@@ -12,24 +12,18 @@
<div class="wb-app"> <div class="wb-app">
<header class="wb-bar"> <header class="wb-bar">
<div class="wb-bar-left"> <div class="wb-bar-left">
<span class="wb-title">白板</span> <span class="wb-title">记事本</span>
<code class="wb-id mono" id="boardId" title="白板 ID"></code> <code class="wb-id mono" id="boardId" title="记事本 ID"></code>
<span class="wb-online" id="online" title="在线人数"></span> <span class="wb-online" id="online" title="在线人数"></span>
</div> </div>
<div class="wb-bar-right"> <div class="wb-bar-right">
<label class="wb-tool" title="笔画颜色"> <button class="btn" id="copyLinkBtn" title="复制分享链接">复制链接</button>
<input type="color" id="color" value="#1565c0"> <button class="btn" id="copyBtn" title="复制全部文本">复制文本</button>
</label> <button class="btn danger" id="clearBtn" title="清空全部内容(所有人)">清空</button>
<label class="wb-tool" title="笔画粗细">
<input type="range" id="width" min="1" max="24" value="3">
<span class="wb-width-val mono" id="widthVal">3</span>
</label>
<button class="btn" id="copyBtn" title="复制分享链接">复制链接</button>
<button class="btn danger" id="clearBtn" title="清空白板(所有人)">清空</button>
</div> </div>
</header> </header>
<main class="wb-stage"> <main class="wb-stage">
<canvas id="canvas"></canvas> <textarea id="editor" class="wb-editor" placeholder="在此输入文本,所有人会实时看到你的编辑…" spellcheck="false" autocomplete="off"></textarea>
<div class="wb-status" id="status">连接中…</div> <div class="wb-status" id="status">连接中…</div>
</main> </main>
</div> </div>

View File

@@ -1,153 +1,149 @@
/* 白板Canvas 绘画 + WebSocket 实时同步 + 心跳。 /* 记事本textarea + WebSocket 实时同步 + 心跳。
- 笔画以 {points:[[x,y],...], color, width} 表示,增量广播 - 文本以整文本 debounce 400ms 后发服务端,服务端存为新版本并广播给其他端
- 心跳 3s 一次 ping服务端 15s 无心跳判失活会主动断连,前端据此重连 - 收到他人 update 时用 diff 应用变更,保留本地光标位置(按相对偏移调整)
- 收到他人 stroke 增量重绘该笔;收到 cleared 清空本地画布 - 收到 cleared 清空本地 textarea
- 兼容鼠标 + 触摸:统一用 pointer eventstouch-action:none 防滚动缩放。 */ - 心跳 3s 一次 ping服务端 15s 无心跳判失活会主动断连,前端据此重连。 */
(function () { (function () {
"use strict"; "use strict";
const { el, toast, copyText } = window.ZK; const { toast, copyText } = window.ZK;
// ---------- 从 URL 解析 board_id ---------- // ---------- 从 URL 解析 board_id ----------
// 路径形如 /whiteboard/{id}id 为 [a-zA-Z0-9_-]{1,64} const m = location.pathname.match(/^\/wb\/([^/]+)\/?$/);
const m = location.pathname.match(/^\/whiteboard\/([^/]+)\/?$/);
let boardId = m ? decodeURIComponent(m[1]) : "default"; let boardId = m ? decodeURIComponent(m[1]) : "default";
// 合法性兜底:前端非法字符直接回退到 default真正校验在服务端
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(boardId)) boardId = "default"; if (!/^[a-zA-Z0-9_-]{1,64}$/.test(boardId)) boardId = "default";
document.getElementById("boardId").textContent = boardId; document.getElementById("boardId").textContent = boardId;
// ---------- DOM ---------- // ---------- DOM ----------
const canvas = document.getElementById("canvas"); const editor = document.getElementById("editor");
const ctx = canvas.getContext("2d");
const colorInput = document.getElementById("color");
const widthInput = document.getElementById("width");
const widthVal = document.getElementById("widthVal");
const clearBtn = document.getElementById("clearBtn"); const clearBtn = document.getElementById("clearBtn");
const copyBtn = document.getElementById("copyBtn"); const copyBtn = document.getElementById("copyBtn");
const copyLinkBtn = document.getElementById("copyLinkBtn");
const statusEl = document.getElementById("status"); const statusEl = document.getElementById("status");
const onlineEl = document.getElementById("online"); const onlineEl = document.getElementById("online");
// ---------- 状态 ---------- // ---------- 状态 ----------
let strokes = []; // 已确认的笔画
let current = null; // 正在画的笔画(本地未提交)
let drawing = false;
let ws = null; let ws = null;
let clientId = localStorage.getItem("wb_cid") || ""; // 每个 tab 独立的 client_id用 sessionStorage 而非 localStorage避免同浏览器
// 多 tab 共享 id 导致收到的 update 被误判为「自己的」而跳过不同步)。
// 服务端已用 exclude=conn 排除发送者,前端不再用 client_id 跳过 update。
let clientId = sessionStorage.getItem("wb_cid") || "";
if (!clientId) { if (!clientId) {
clientId = "c_" + Math.random().toString(36).slice(2, 10); clientId = "c_" + Math.random().toString(36).slice(2, 10);
localStorage.setItem("wb_cid", clientId); sessionStorage.setItem("wb_cid", clientId);
} }
let heartbeatTimer = null; let heartbeatTimer = null;
let reconnectTimer = null; let reconnectTimer = null;
let connected = false; let connected = false;
let lastSentText = ""; // 上次发到服务端的文本(避免无变更时重复发)
let suppressInput = false; // 应用远端更新时抑制 input 事件,防回环
let debounceTimer = null;
// ---------- 画布尺寸 ---------- // ---------- 本地编辑 -> debounce -> 发送 ----------
function resize() { editor.addEventListener("input", () => {
const dpr = window.devicePixelRatio || 1; if (suppressInput) return;
const w = canvas.clientWidth; scheduleSend();
const h = canvas.clientHeight;
canvas.width = Math.max(1, Math.floor(w * dpr));
canvas.height = Math.max(1, Math.floor(h * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
redraw();
}
window.addEventListener("resize", resize);
// ---------- 绘制 ----------
function drawStroke(s) {
if (!s || !s.points || s.points.length < 1) return;
ctx.strokeStyle = s.color || "#1565c0";
ctx.lineWidth = Number(s.width) || 3;
ctx.lineCap = "round";
ctx.lineJoin = "round";
const pts = s.points;
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
if (pts.length === 1) {
// 单点:画一个小圆点
ctx.arc(pts[0][0], pts[0][1], (ctx.lineWidth || 3) / 2, 0, Math.PI * 2);
ctx.fillStyle = ctx.strokeStyle;
ctx.fill();
return;
}
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]);
ctx.stroke();
}
function redraw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const s of strokes) drawStroke(s);
if (current) drawStroke(current);
}
// ---------- 指针事件 ----------
function pos(e) {
const r = canvas.getBoundingClientRect();
return [e.clientX - r.left, e.clientY - r.top];
}
canvas.addEventListener("pointerdown", (e) => {
if (!connected) { flashStatus("未连接,正在重连…"); return; }
e.preventDefault();
canvas.setPointerCapture(e.pointerId);
drawing = true;
current = { points: [pos(e)], color: colorInput.value, width: Number(widthInput.value) };
drawStroke(current);
});
canvas.addEventListener("pointermove", (e) => {
if (!drawing) return;
e.preventDefault();
const p = pos(e);
const last = current.points[current.points.length - 1];
// 跳过过近的点,减少数据量
if (Math.hypot(p[0] - last[0], p[1] - last[1]) < 1.5) return;
current.points.push(p);
// 增量画最后一段
ctx.strokeStyle = current.color;
ctx.lineWidth = current.width;
ctx.lineCap = "round"; ctx.lineJoin = "round";
ctx.beginPath();
ctx.moveTo(last[0], last[1]);
ctx.lineTo(p[0], p[1]);
ctx.stroke();
});
function endStroke(e) {
if (!drawing) return;
drawing = false;
if (e && e.pointerId !== undefined) {
try { canvas.releasePointerCapture(e.pointerId); } catch {}
}
if (current && current.points.length) {
strokes.push(current);
send({ type: "stroke", stroke: current });
}
current = null;
}
canvas.addEventListener("pointerup", endStroke);
canvas.addEventListener("pointercancel", endStroke);
canvas.addEventListener("pointerleave", (e) => {
// 仅在抬起时结束离开但按住不放不结束pointer capture 已处理)
if (!drawing) return;
if (e.buttons === 0) endStroke(e);
}); });
widthInput.addEventListener("input", () => (widthVal.textContent = widthInput.value)); function scheduleSend() {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
debounceTimer = null;
const text = editor.value;
if (text === lastSentText) return;
lastSentText = text;
send({ type: "edit", content: text });
}, 400);
}
function flushSend() {
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
const text = editor.value;
if (text !== lastSentText) {
lastSentText = text;
send({ type: "edit", content: text });
}
}
}
clearBtn.addEventListener("click", () => { clearBtn.addEventListener("click", () => {
if (!connected) { flashStatus("未连接"); return; } if (!connected) { flashStatus("未连接"); return; }
if (!confirm("确定清空白板?所有人的内容都会被清除。")) return; if (!confirm("确定清空全部内容?所有人的内容都会被清除。")) return;
send({ type: "clear" }); send({ type: "clear" });
}); });
copyBtn.addEventListener("click", async () => { copyBtn.addEventListener("click", async () => {
const url = `${location.origin}/whiteboard/${boardId}`; const text = editor.value;
if (!text) { toast("内容为空"); return; }
const ok = await copyText(text);
toast(ok ? "已复制全部文本" : "复制失败");
});
copyLinkBtn.addEventListener("click", async () => {
const url = `${location.origin}/wb/${boardId}`;
const ok = await copyText(url); const ok = await copyText(url);
toast(ok ? "链接已复制" : "复制失败"); toast(ok ? "链接已复制" : "复制失败");
}); });
// ---------- 应用远端更新(保留光标) ----------
// 策略:用最长公共前后缀算出变更区间,仅替换该区间,光标按相对位置调整。
// 若本地有未发送的编辑editor.value !== lastSentText合并后重新 scheduleSend
// 避免本地编辑被远端覆盖后因 lastSentText 短路而丢弃。
function applyRemoteUpdate(newText) {
const oldText = editor.value;
if (newText === oldText) return;
const selStart = editor.selectionStart;
const selEnd = editor.selectionEnd;
// 算公共前缀
let prefix = 0;
const minLen = Math.min(oldText.length, newText.length);
while (prefix < minLen && oldText[prefix] === newText[prefix]) prefix++;
// 算公共后缀(不能与前缀重叠)
let suffixOld = oldText.length;
let suffixNew = newText.length;
while (suffixOld > prefix && suffixNew > prefix && oldText[suffixOld - 1] === newText[suffixNew - 1]) {
suffixOld--;
suffixNew--;
}
const hadPending = editor.value !== lastSentText;
suppressInput = true;
// 用 setRangeText 替换 [prefix, suffixOld) 为 newText[prefix, suffixNew)
editor.setRangeText(newText.slice(prefix, suffixNew), prefix, suffixOld, "end");
suppressInput = false;
lastSentText = editor.value;
// 调整光标:若光标在变更区间之前,不动;在之后,平移差值;在区间内,移到区间末尾
const delta = (suffixNew - prefix) - (suffixOld - prefix);
let newStart = selStart, newEnd = selEnd;
if (selStart <= prefix) {
// 光标在变更前,不变
} else if (selStart >= suffixOld) {
// 光标在变更后,平移
newStart = selStart + delta;
newEnd = selEnd + delta;
} else {
// 光标在变更区间内,移到区间末尾
newStart = newEnd = suffixNew;
}
try {
editor.setSelectionRange(newStart, newEnd);
} catch {}
// 仅当编辑器当前有焦点时恢复焦点,避免抢其它控件焦点
if (document.activeElement === editor) editor.focus();
// 本地有未发送编辑被合并了,重新安排发送,避免被丢弃
if (hadPending) scheduleSend();
}
// ---------- 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() {
@@ -160,7 +156,7 @@
} }
ws.onopen = () => { ws.onopen = () => {
connected = true; connected = true;
setStatus("已连接", true); setStatus("已连接", false);
onlineEl.classList.remove("off"); onlineEl.classList.remove("off");
send({ type: "hello", client_id: clientId }); send({ type: "hello", client_id: clientId });
startHeartbeat(); startHeartbeat();
@@ -175,23 +171,34 @@
try { msg = JSON.parse(raw); } catch { return; } try { msg = JSON.parse(raw); } catch { return; }
switch (msg.type) { switch (msg.type) {
case "init": case "init":
strokes = Array.isArray(msg.strokes) ? msg.strokes : []; // 连接建立时服务端下发当前文本。若本地有未发送编辑(断线期间输入的),
redraw(); // 不直接覆盖而是把本地编辑作为最新版本发上去last-writer-wins
setStatus(`已同步 ${strokes.length}`, true); // 避免断线期间的编辑被静默丢弃。
if (editor.value && editor.value !== lastSentText) {
lastSentText = editor.value;
send({ type: "edit", content: editor.value });
} else {
suppressInput = true;
editor.value = msg.content || "";
lastSentText = editor.value;
suppressInput = false;
editor.focus();
}
setStatus("已同步", false);
break; break;
case "pong": case "pong":
// 心跳回声,保持连接
break; break;
case "stroke": case "update":
if (msg.client_id === clientId) break; // 自己的,已本地画 // 服务端 broadcast 已用 exclude=conn 排除发送者,收到即他人编辑,直接应用。
strokes.push(msg.stroke); applyRemoteUpdate(msg.content || "");
drawStroke(msg.stroke); flashStatus("对方有更新");
break; break;
case "cleared": case "cleared":
strokes = []; suppressInput = true;
current = null; editor.value = "";
redraw(); lastSentText = "";
flashStatus(msg.client_id === clientId ? "已清空" : "对方清空了白板"); suppressInput = false;
flashStatus(msg.client_id === clientId ? "已清空" : "对方清空了内容");
break; break;
case "error": case "error":
flashStatus(msg.msg || "错误", true); flashStatus(msg.msg || "错误", true);
@@ -242,28 +249,33 @@
} }
// ---------- 启动 ---------- // ---------- 启动 ----------
// 先 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 && typeof body.content === "string") {
strokes = body.strokes; editor.value = body.content;
redraw(); lastSentText = body.content;
} }
resize();
connect(); connect();
}) })
.catch(() => { resize(); connect(); }); .catch(() => { connect(); });
// 页面隐藏时不发心跳可被服务端判失活;重新可见时主动重连 // 页面可见性:重新可见时若已断连则主动重连;隐藏时 flush 未发送的编辑
document.addEventListener("visibilitychange", () => { document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible" && (!ws || ws.readyState !== WebSocket.OPEN)) { if (document.visibilityState === "visible") {
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } if (!ws || ws.readyState !== WebSocket.OPEN) {
connect(); if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
connect();
}
} else {
flushSend();
} }
}); });
// 离开前 flush 未发送编辑
window.addEventListener("beforeunload", () => { window.addEventListener("beforeunload", () => {
flushSend();
stopHeartbeat(); stopHeartbeat();
if (reconnectTimer) clearTimeout(reconnectTimer); if (reconnectTimer) clearTimeout(reconnectTimer);
try { ws && ws.close(); } catch {} try { ws && ws.close(); } catch {}

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,9 +47,9 @@
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.edit_count ?? 0)),
el("td", { class: "col-created muted" }, fmtTime(b.created_at)), el("td", { class: "col-created muted" }, fmtTime(b.created_at)),
el("td", { class: "col-updated muted" }, fmtTime(b.updated_at)), el("td", { class: "col-updated muted" }, fmtTime(b.updated_at)),
el("td", { class: "col-act" }, el("td", { class: "col-act" },
@@ -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

@@ -1,22 +1,24 @@
"""白板 WebSocket 端到端烟测:两客户端实时同步 + 心跳 + 清空。 """白板 WebSocket 端到端烟测(文本记事本):两客户端实时同步 + 心跳 + 清空。
验证: 验证:
1. 客户端 A 连入 -> 收到 init 1. 客户端 A 连入 -> 收到 init(含 content/version
2. 客户端 B 连入 -> 收到 init 2. 客户端 B 连入 -> 收到 init
3. A 画一笔 -> B 收到 stroke 广播A 不收自己的) 3. A 编辑文本 -> B 收到 updateA 不收自己的)
4. 心跳 ping -> pong 4. 心跳 ping -> pong
5. A 清空 -> A、B 都收到 cleared 5. A 清空 -> A、B 都收到 cleared
6. 停发心跳的连接会被服务端 reaper 移除15s这里只验证 ping/pong 即可reaper 已在 hub 单测覆盖) 6. 持久化:重连后 init 应返回清空后的内容
7. edit_count 累计
""" """
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json import json
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 = "e2etest" BOARD = "e2etest"
@@ -31,7 +33,6 @@ async def recv_msg(ws, timeout=2.0) -> dict | None:
async def main() -> None: async def main() -> None:
async with websockets.connect(f"{BASE_WS}/{BOARD}") as a, \ async with websockets.connect(f"{BASE_WS}/{BOARD}") as a, \
websockets.connect(f"{BASE_WS}/{BOARD}") as b: websockets.connect(f"{BASE_WS}/{BOARD}") as b:
# hello
await a.send(json.dumps({"type": "hello", "client_id": "A"})) await a.send(json.dumps({"type": "hello", "client_id": "A"}))
await b.send(json.dumps({"type": "hello", "client_id": "B"})) await b.send(json.dumps({"type": "hello", "client_id": "B"}))
@@ -42,18 +43,18 @@ async def main() -> None:
assert init_a and init_a["type"] == "init" assert init_a and init_a["type"] == "init"
assert init_b and init_b["type"] == "init" assert init_b and init_b["type"] == "init"
# A 画一笔 # A 编辑文本
stroke = {"points": [[10, 10], [20, 20]], "color": "#1565c0", "width": 3} text = "Hello, this is a shared note.\nSecond line."
await a.send(json.dumps({"type": "stroke", "stroke": stroke})) await a.send(json.dumps({"type": "edit", "content": text}))
# A 不应收到自己的(排除发送者) # A 不应收到自己的 update
echo = await recv_msg(a, timeout=1.0) echo = await recv_msg(a, timeout=1.0)
print("A self-echo (expect None):", echo) print("A self-echo (expect None):", echo)
assert echo is None, "发送者不应收到自己的 stroke" assert echo is None, "发送者不应收到自己的 update"
# B 应收到 # B 应收到 update
got = await recv_msg(b) got = await recv_msg(b)
print("B recv:", got.get("type") if got else None, "client_id=", got.get("client_id") if got else None) print("B recv:", got.get("type") if got else None, "content=", repr(got.get("content")) if got else None)
assert got and got["type"] == "stroke" and got["client_id"] == "A" assert got and got["type"] == "update" and got["client_id"] == "A"
assert got["stroke"] == stroke assert got["content"] == text
# 心跳 # 心跳
await a.send(json.dumps({"type": "ping"})) await a.send(json.dumps({"type": "ping"}))
@@ -70,20 +71,19 @@ async def main() -> None:
assert cleared_b and cleared_b["type"] == "cleared" and cleared_b["client_id"] == "B" assert cleared_b and cleared_b["type"] == "cleared" and cleared_b["client_id"] == "B"
assert cleared_a and cleared_a["type"] == "cleared" and cleared_a["client_id"] == "B" assert cleared_a and cleared_a["type"] == "cleared" and cleared_a["client_id"] == "B"
# 验证持久化:重连后 init 应 strokes 为空(已清空) # 验证持久化:重连后 init 应 content 为空(已清空)
async with websockets.connect(f"{BASE_WS}/{BOARD}") as c: async with websockets.connect(f"{BASE_WS}/{BOARD}") as c:
await c.send(json.dumps({"type": "hello", "client_id": "C"})) await c.send(json.dumps({"type": "hello", "client_id": "C"}))
init_c = await recv_msg(c) init_c = await recv_msg(c)
print("C init after clear, strokes=", init_c.get("strokes") if init_c else None) print("C init after clear, content=", repr(init_c.get("content")) if init_c else None)
assert init_c and init_c["type"] == "init" assert init_c and init_c["type"] == "init"
assert init_c["strokes"] == [], "清空后重连应得到空 strokes" assert init_c["content"] == "", "清空后重连应得到空 content"
# 验证 stroke_count 累计(之前 1 笔 + 1 次清空 = 2 # 验证 edit_count 累计(1 次编辑 + 1 次清空 = 2
import urllib.request with urllib.request.urlopen(f"http://127.0.0.1:6867/api/wb/{BOARD}") as r:
with urllib.request.urlopen(f"http://127.0.0.1:6890/whiteboard/{BOARD}") as r:
meta = json.load(r) meta = json.load(r)
print("stroke_count after ops:", meta["stroke_count"]) print("edit_count after ops:", meta["edit_count"])
assert meta["stroke_count"] == 2, "1 + 1 清空 = 2 次修改" assert meta["edit_count"] == 2, "1 编辑 + 1 清空 = 2 次"
print("\nWS 端到端全部通过 ✅") print("\nWS 端到端全部通过 ✅")