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
`f.zikai.wang` 的 Python Web 服务FastAPI提供主机监控、大文件上传、共享白板与
文件浏览:**HTTP整文件 + 分片/断点续传)**,并内置 **SFTP 服务器** 用于原始文件暂存
采用 Spring 风格分层架构(`controllers` -> `services` -> `dao`,外加 `models`
`schemas`),自带自动生成的 API 文档,全部运行在自包含的 `.venv` 中。
基于 FastAPI 的个人 Web 服务,提供**文件上传/浏览/下载、共享记事本(实时协作)、
主机监控、SFTP 暂存、反向隧道**。采用 Spring 风格分层架构,自带 API 文档
## 功能
## 功能一览
- `GET /api/system/status` - CPU、内存、各磁盘使用率via `psutil`)。
- `POST /api/files/upload` - **流式** multipart 上传(内存恒定,支持多 GB落盘时算 SHA-256。
- `GET /upload` - 拖拽上传页面:多文件、**分片4 MiB**、**断点续传**、sha256 去重。
- `POST /api/files/chunk-uploads/*` - 支撑 `/upload` 的分片上传 API建会话 / 查状态 / 传分片 / 完成)。
- `GET /api/files``GET /api/files/{id}``GET /api/files/{id}/download`
- **文件浏览页** `GET /files`Basic Auth同 docs列出/下载/**硬删除**已上传文件;删除后不再显示。
管理 API`GET /api/admin/files``GET /api/admin/files/{id}``GET /api/admin/files/{id}/download`
`DELETE /api/admin/files/{id}`(均 Basic Auth
- **共享白板** `GET /whiteboard/{id}`公开不存在则新建Canvas 实时协作 + **清空 / 复制链接**,兼容移动端。
实时同步走 `WS /ws/whiteboard/{id}`**心跳 3s连续 5 次丢失判失活并移除**)。
- **白板管理页** `GET /whiteboard-admin`Basic Auth同 docs查看创建时间/修改次数/上次修改时间/删除。
管理 API`GET /api/admin/whiteboards``DELETE /api/admin/whiteboards/{id}`(均 Basic Auth
- **反向隧道反代**`ALL /api/userPort/{userName}` -- 把请求经 SSH 反向隧道转发到该 user 的本机服务。
- **内置 SFTP/SSH 服务器**asyncssh支持 **密码 + 公钥** 鉴权,同时承载 SFTP 文件暂存与反向隧道。
- `/docs`Swagger UI`/redoc` - 交互式文档,自动列出所有 API。
- 元数据持久化在 **独立的 MySQL 数据库**`zikai_filesvc`)。
- `start.sh` / `stop.sh` 生命周期管理;`setup.sh` 一次性初始化。
| 模块 | 页面 / 接口 | 鉴权 |
|------|------------|------|
| 文件上传 | `POST /api/files/upload`(流式)/ `POST /api/files/chunk-uploads/*`(分片+断点续传) | 公开 |
| 上传页 | `GET /upload`(拖拽/多文件/分片/去重) | 公开 |
| 文件浏览 | `GET /files`(多选/批量下载删除/分页) | Basic Auth |
| 文件管理 API | `GET /api/admin/files``GET/DELETE /api/admin/files/{id}``GET /api/admin/files/{id}/download` | Basic Auth |
| 共享记事本 | `GET /wb/{id}`(公开,不存在则新建) | 公开 |
| 记事本实时同步 | `WS /ws/wb/{id}`(心跳 3s5 次失活移除) | 公开 |
| 记事本管理 | `GET /wb-admin`(查看/删除) | Basic Auth |
| 记事本管理 API | `GET /api/admin/wb``DELETE /api/admin/wb/{id}` | Basic Auth |
| 主机监控 | `GET /api/system/status`CPU/内存/磁盘HTML+JSON 内容协商) | 公开 |
| 反向隧道反代 | `ALL /api/userPort/{userName}`(经 SSH 隧道转发到 user 本地服务) | 公开 |
| SFTP/SSH | 端口 2022密码+公钥chroot 到上传目录,承载隧道转发) | SSH |
| API 文档 | `GET /docs`Swagger/ `GET /redoc` | Basic Auth |
## 架构Spring 风格分层)
## 项目结构
```
app/
├── controllers/ # FastAPI 路由 -- HTTP 边界(类似 @RestController
├── services/ # 业务逻辑SystemService, UploadService, ChunkUploadService,
# WhiteboardService, WhiteboardHub, SFTP 服务
├── dao/ # 数据访问对象 -- 唯一发出 SQL/ORM 的层
├── models/ # SQLAlchemy ORM 实体UploadedFile, UploadSession, Whiteboard, ...
├── schemas/ # pydantic DTO请求/响应校验)
├── views/ # 服务端渲染的 HTML 页面(系统状态、上传页)
├── static/ # 前端静态资源(文件浏览/白板/白板管理的 HTML+JS+CSS经 StaticFiles 挂载)
├── database.py # 引擎、Session、Base、get_db() 依赖
├── config.py # 从 config.yaml 加载的类型化 Settings
└── scripts/ # init_db.py -- 数据库初始化
server/
├── app/
│ ├── main.py # FastAPI 应用工厂、路由注册、生命周期reaper
├── config.py # 从 config.yaml 加载的类型化 Settingspydantic-settings
├── database.py # SQLAlchemy 引擎/Session/Base/get_db 依赖
│ ├── security.py # Basic Authrequire_docs_auth常量时间比较
│ ├── controllers/ # 路由层(@RestControllerfile/system/chunk/tunnel/whiteboard/admin
│ ├── services/ # 业务层UploadService/ChunkUploadService/SystemService/
│ │ # WhiteboardService/WhiteboardHub/TunnelService/sftp_server
├── dao/ # 数据访问层:唯一发 SQL 的层SQLAlchemy ORM 参数化)
│ ├── models/ # ORM 实体UploadedFile/UploadSession/Whiteboard/TunnelSession
│ ├── 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。
DB Session 由 FastAPI 的 `get_db` 依赖注入并向下传递。前端三套页面走「独立静态文件 +
StaticFiles 挂载」的前后端分离模式HTML 壳由具名路由返回(便于各自挂 Basic Auth
JS 调用同源 `/api/...`
**请求流程**`controller → service → dao → ORM model MySQL`
DB Session 由 `get_db` 依赖注入。前端页面走「StaticFiles 挂载 + 具名 HTML 路由」前后端分离JS 调同源 `/api/...`
## 快速开始
## 从零安装Ubuntu 22.04+
### 1. 安装系统依赖
```bash
cd /root/zikai
./setup.sh # 一次性venv、依赖、建库建账、SFTP 主机密钥
./start.sh # 启动 HTTP127.0.0.1:6867+ SFTP0.0.0.0:2022
./stop.sh # 停止两者
apt update
apt install -y python3-venv python3-pip mysql-server apache2 \
libssl-dev build-essential # build-essential 给 bcrypt/asyncssh 编译
```
`setup.sh` 可重复执行。它会创建 `.venv`、安装 `requirements.txt`、复制
`config.example.yaml``config.yaml`(若不存在)、通过本机 root socket 建一个
**全新的独立 MySQL 数据库与应用账户**,并生成 SFTP 主机密钥。
### 2. 获取代码
## 访问方式
```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 |
|------|-----|
| 状态页HTML | https://f.zikai.wang/api/system/status |
| 状态页JSON | https://f.zikai.wang/api/system/status?format=json`Accept: application/json` |
| API 文档Swagger | https://f.zikai.wang/docs **HTTP Basic Auth -- 见 config.yaml 的 `docs:` 段)** |
| API 文档ReDoc | https://f.zikai.wang/redoc同样鉴权 |
| 上传页(拖拽、分片、断点续传) | https://f.zikai.wang/upload |
| 文件浏览页(列出/下载/删除) | https://f.zikai.wang/files **Basic Auth同 docs** |
| 共享白板(实时协作) | https://f.zikai.wang/whiteboard/{id}(公开,`{id}``[a-zA-Z0-9_-]{1,64}`,不存在则新建) |
| 白板管理页 | https://f.zikai.wang/whiteboard-admin **Basic Auth同 docs** |
| 上传curl | `curl -F file=@big.iso https://f.zikai.wang/api/files/upload` |
| SFTP | `sftp -P 2022 uploader@f.zikai.wang` |
| API 文档 | https://f.zikai.wang/docsBasic Auth |
| 上传页 | https://f.zikai.wang/upload |
| 文件浏览 | https://f.zikai.wang/filesBasic Auth |
| 共享记事本 | https://f.zikai.wang/wb/{id}(公开,`{id}``[a-zA-Z0-9_-]{1,64}` |
| 记事本管理 | https://f.zikai.wang/wb-adminBasic Auth |
| 系统状态 | https://f.zikai.wang/api/system/statusHTML`?format=json` 切 JSON |
| 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 —— 浏览器会弹出登录框。用户名与
明文密码写在 `config.yaml``docs:` 段。`/health``/` 保持公开
Apache`/etc/apache2/sites-available/f.zikai.wang-le-ssl.conf`)把 `f.zikai.wang` 反代到
`127.0.0.1:6867``ProxyPreserveHost On`),因此服务只绑 loopback
> **大文件/慢速 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` 使用)。
- **日志**`logs/app.log`HTTP`logs/sftp.log`SFTPpidfile`app.pid``sftp.pid`
- **临时文件清理**:分片上传完成后立即删 `.work/<id>/`;被放弃会话(`pending` 超 5 分钟)由后台 reaper 每 60s 清理;`start.sh` 启动时兜底清残留
- **重新生成 DB 密码**`.venv/bin/python -m app.scripts.init_db`(保留现有:`KEEP_DB_PASSWORD=1`)。
- **多 worker 限制**:记事本 hub 是进程内存,多 uvicorn worker 下不互通,保持 `workers: 1`
- **数据库表**ORM 启动时自动建表(`init_db_schema``sql/schema.sql` 供参考/手动初始化

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
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 sqlalchemy.orm import Session
@@ -43,8 +43,8 @@ async def upload_file(
@router.get("", response_model=FileListResponse, summary="列出已上传的文件")
def list_files(
limit: int = 100,
offset: int = 0,
limit: int = Query(100, ge=1, le=10000),
offset: int = Query(0, ge=0),
service: UploadService = Depends(_service),
) -> FileListResponse:
total, items = service.list_files(limit=limit, offset=offset)

View File

@@ -1,10 +1,13 @@
"""白板接口REST访问/管理)+ WebSocket实时同步
路由:
GET /whiteboard/{board_id} 公开:访问白板,不存在则新建
WS /ws/whiteboard/{board_id} 公开:实时协作 + 心跳
GET /api/admin/whiteboards Basic Auth管理页列表
DELETE /api/admin/whiteboards/{id} Basic Auth删除白板
GET /api/wb/{board_id} 公开:访问记事本元数据,不存在则新建(前端 init 用)
WS /ws/wb/{board_id} 公开:实时协作 + 心跳
GET /api/admin/wb Basic Auth管理页列表
DELETE /api/admin/wb/{board_id} Basic Auth删除记事本
HTML 页面 /wb/{id} 与管理页 /wb-admin 由 main.py 直接返回静态文件,
不在此 controller 注册,避免与 REST 同路径冲突。
"""
from __future__ import annotations
@@ -29,17 +32,17 @@ router = APIRouter(tags=["whiteboard"])
def _service(db: Session = Depends(get_db)) -> WhiteboardService:
"""REST 路径的 service:注入 hub 以便删除时踢出连接"""
return WhiteboardService(WhiteboardDAO(db), hub=get_hub())
"""REST 路径的 service(纯 DB 操作)"""
return WhiteboardService(WhiteboardDAO(db))
# ---------------- 公开 REST ----------------
@router.get(
"/whiteboard/{board_id}",
"/api/wb/{board_id}",
response_model=WhiteboardOut,
summary="访问白板(不存在则新建)",
description="任何人凭 board_id 即可访问;不存在时自动创建空板并返回",
summary="访问记事本元数据(不存在则新建)",
description="前端打开 /wb/{id} 页面后调本接口拉取初始文本;不存在时自动创建空板。",
)
def get_whiteboard(board_id: str, service: WhiteboardService = Depends(_service)) -> WhiteboardOut:
return service.get_or_create(board_id)
@@ -48,10 +51,10 @@ def get_whiteboard(board_id: str, service: WhiteboardService = Depends(_service)
# ---------------- 管理 RESTBasic Auth ----------------
@router.get(
"/api/admin/whiteboards",
"/api/admin/wb",
response_model=WhiteboardListResponse,
summary="列出所有白板(需鉴权)",
description="白板管理页使用board_id / 创建时间 / 修改次数 / 上次修改时间。",
summary="列出所有记事本(需鉴权)",
description="记事本管理页使用board_id / 创建时间 / 编辑次数 / 上次修改时间。",
)
def list_whiteboards(
limit: int = Query(100, ge=1, le=500),
@@ -64,11 +67,11 @@ def list_whiteboards(
@router.delete(
"/api/admin/whiteboards/{board_id}",
summary="删除白板(需鉴权)",
"/api/admin/wb/{board_id}",
summary="删除记事本(需鉴权)",
description="删 DB 行,并关闭该 board 的所有在线 WebSocket 连接。",
)
def delete_whiteboard(
async def delete_whiteboard(
board_id: str,
service: WhiteboardService = Depends(_service),
_: str = Depends(require_docs_auth),
@@ -76,25 +79,27 @@ def delete_whiteboard(
ok = service.delete(board_id)
if not ok:
raise HTTPException(404, "白板不存在")
# 删除成功后踢出该 board 的所有在线连接close_board 是 async须在事件循环中调用
await get_hub().close_board(board_id)
return {"deleted": True}
# ---------------- WebSocket公开实时同步 + 心跳) ----------------
@router.websocket("/ws/whiteboard/{board_id}")
@router.websocket("/ws/wb/{board_id}")
async def whiteboard_ws(websocket: WebSocket, board_id: str) -> None:
"""白板实时协作端点。
"""白板实时协作端点(文本记事本)
协议JSON 文本帧):
client -> server:
{"type":"hello","client_id":"..."} 首帧(可选,未带则服务端生成)
{"type":"ping"} 心跳,服务端回 pong 并刷新计时
{"type":"stroke","stroke":{...}} 新增笔画,持久化并广播给他人
{"type":"edit","content":"..."} debounce 后发完整文本,持久化并广播给他人
{"type":"clear"} 清空,持久化并广播给所有人
server -> client:
{"type":"init","strokes":[...],"stroke_count":n}
{"type":"init","content":"...","version":n,"edit_count":m}
{"type":"pong"}
{"type":"stroke","stroke":{...},"client_id":"..."}
{"type":"update","content":"...","version":n,"client_id":"..."} 文本变更
{"type":"cleared","client_id":"..."}
{"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]
# 校验 board_id 并加载白板(不存在则新建)
from ..database import get_session_local
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:
await _safe_send(websocket, {"type": "error", "msg": exc.detail})
await _safe_close(websocket)
@@ -122,17 +126,27 @@ async def whiteboard_ws(websocket: WebSocket, board_id: str) -> None:
# 注册连接并下发 init
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, {
"type": "init",
"strokes": board.strokes,
"stroke_count": board.stroke_count,
"content": board.content,
"version": board.version,
"edit_count": board.edit_count,
})
# 主循环:收消息 -> 处理 -> 广播
# 单帧大小上限:与 content 限制对齐256KB 文本 + JSON 开销,留余量到 512KB
MAX_FRAME = 512 * 1024
try:
while True:
raw = await websocket.receive_text()
if len(raw) > MAX_FRAME:
await _safe_send(websocket, {"type": "error", "msg": "消息过大"})
continue
msg = _parse(raw)
if msg is None:
continue
@@ -143,22 +157,27 @@ async def whiteboard_ws(websocket: WebSocket, board_id: str) -> None:
continue
# 任何有效业务帧都视为活性证据
conn.touch()
if mtype == "stroke":
stroke = msg.get("stroke") or {}
if mtype == "edit":
content = msg.get("content")
if not isinstance(content, str):
await _safe_send(websocket, {"type": "error", "msg": "content 必须是字符串"})
continue
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:
await _safe_send(websocket, {"type": "error", "msg": exc.detail})
continue
# 广播给他人(发送者本地已,不回推)
# 广播给他人(发送者本地已更新,不回推)
await hub.broadcast(
board_id,
{"type": "stroke", "stroke": stroke, "client_id": client_id},
{"type": "update", "content": out.content, "version": out.version, "client_id": client_id},
exclude=conn,
)
elif mtype == "clear":
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:
await _safe_send(websocket, {"type": "error", "msg": exc.detail})
continue

View File

@@ -29,6 +29,11 @@ class UploadedFileDAO:
"""返回数据库中文件总条数。"""
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]:
stmt = (
select(UploadedFile)

View File

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

View File

@@ -8,10 +8,10 @@
GET /health -> 存活探针(公开)
GET /upload -> 上传页面(公开 HTML
GET /files -> 文件浏览页Basic Auth同 docs
GET /whiteboard/{id} -> 白板页面(公开,不存在则新建)
GET /whiteboard-admin -> 白板管理页Basic Auth同 docs
GET /wb/{id} -> 白板页面(公开,不存在则新建)
GET /wb-admin -> 白板管理页Basic Auth同 docs
GET /api/... -> 业务接口
WS /ws/whiteboard/{id} -> 白板实时同步(公开)
WS /ws/wb/{id} -> 白板实时同步(公开)
/static/... -> 前端静态资源JS/CSS
"""
@@ -155,48 +155,68 @@ def create_app() -> FastAPI:
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
# 受 Basic Auth 保护的文档接口
@app.get("/openapi.json")
@app.get("/openapi.json", tags=["docs"], summary="OpenAPI 文档(需鉴权)")
def protected_openapi(_: str = Depends(require_docs_auth)) -> JSONResponse:
return JSONResponse(app.openapi())
@app.get("/docs")
@app.get("/docs", tags=["docs"], summary="Swagger UI需鉴权")
def protected_docs(_: str = Depends(require_docs_auth)):
return get_swagger_ui_html(
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)):
return get_redoc_html(
openapi_url="/openapi.json", title="zikai docs", redoc_favicon_url=""
)
@app.get("/", response_class=PlainTextResponse)
@app.get("/", tags=["meta"], summary="版本号")
def root() -> PlainTextResponse:
return PlainTextResponse(f"zikai {app.version}\n")
@app.get("/health")
@app.get("/health", tags=["meta"], summary="存活探针")
def health() -> dict:
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:
"""拖拽 / 多文件 / 分片上传页面(公开,对齐 /api/files/upload"""
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:
"""文件浏览页Basic Auth同 docs列出/下载/删除已上传文件。"""
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:
"""白板管理页Basic Auth同 docs查看/删除白板。"""
return _serve_static_html("whiteboard_admin.html")
@app.get("/whiteboard/{board_id}", response_class=HTMLResponse)
@app.get(
"/wb/{board_id}",
response_class=HTMLResponse,
tags=["pages"],
summary="记事本页面",
description="公开访问的共享文本记事本,不存在则自动新建;实时协作走 WS /ws/wb/{id}",
)
def whiteboard_page(board_id: str) -> HTMLResponse:
"""白板页面(公开):访问即协作,不存在则前端拉取时自动新建。"""
return _serve_static_html("whiteboard.html")
return app

View File

@@ -1,15 +1,16 @@
"""共享白板实体。
"""共享白板实体(文本记事本)
一个白板由 board_id 唯一标识(用户可读的 url idstrokes 以 JSON 列保存全部笔画。
白板长期留存,进程重启后仍可恢复;实时协作由 WebSocket hub 在内存中维护在线连接,
笔画变更经 service 落库后由 hub 广播给同 board 的其它在线连接。
一个白板由 board_id 唯一标识(用户可读的 url idcontent 存完整文本,
version 是乐观锁版本号(每次编辑 +1白板长期留存,进程重启后仍可恢复;
实时协作由 WebSocket hub 在内存中维护在线连接,文本变更经 service 落库后
由 hub 广播给同 board 的其它在线连接。
"""
from __future__ import annotations
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 ..database import Base
@@ -21,10 +22,12 @@ class Whiteboard(Base):
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
# 用户可读的 url id[a-zA-Z0-9_-]{1,64}),全局唯一
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)
# 修改次数:每次新增笔画或清空 +1供管理页统计
stroke_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# 白板文本内容(记事本)
content: Mapped[str] = mapped_column(Text, nullable=False, default="")
# 乐观锁版本号:每次编辑 +1
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(
DateTime, server_default=func.now(), nullable=False
)
@@ -35,5 +38,5 @@ class Whiteboard(Base):
def __repr__(self) -> str: # pragma: no cover
return (
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 .tunnel import TunnelStatusResponse
from .whiteboard import (
Stroke,
StrokeOp,
WhiteboardListItem,
WhiteboardListResponse,
WhiteboardOut,
@@ -26,8 +24,6 @@ __all__ = [
"FileUploadResponse",
"MemoryUsage",
"SessionStatusResponse",
"Stroke",
"StrokeOp",
"SystemStatus",
"TunnelStatusResponse",
"UploadedFileOut",

View File

@@ -1,27 +1,19 @@
"""白板接口 DTO。"""
"""白板接口 DTO(文本记事本)"""
from __future__ import annotations
from datetime import datetime
from typing import Any
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):
"""白板完整内容GET /whiteboard/{id} 与 WS init 帧)。"""
"""白板完整内容GET /api/wb/{id} 与 WS init 帧)。"""
board_id: str
strokes: list[Any] = Field(default_factory=list, description="笔画数组")
stroke_count: int = Field(0, description="累计修改次数")
content: str = Field("", description="白板文本内容")
version: int = Field(0, description="乐观锁版本号,每次编辑 +1")
edit_count: int = Field(0, description="累计编辑次数")
created_at: datetime
updated_at: datetime
@@ -32,7 +24,7 @@ class WhiteboardListItem(BaseModel):
"""管理页列表项。"""
board_id: str
stroke_count: int
edit_count: int
created_at: datetime
updated_at: datetime
@@ -44,10 +36,3 @@ class WhiteboardListResponse(BaseModel):
total: int
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
import hashlib
import logging
import os
import time
import uuid
from collections.abc import Iterator
from datetime import datetime, timezone
@@ -21,6 +23,12 @@ from ..dao.uploaded_file_dao import UploadedFileDAO
from ..models.uploaded_file import UploadedFile
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]:
"""对一段字节块序列流式计算 (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]]:
total = self.dao.count()
rows = self.dao.list(limit=limit, offset=offset)
@@ -95,18 +169,17 @@ class UploadService:
def delete_file(self, file_id: int) -> bool:
"""硬删除:删磁盘文件 + 删 DB 行。磁盘缺失不阻断 DB 清理。返回是否命中。
供单删/批删复用,保证删除语义一致。
供单删/批删复用,保证删除语义一致。磁盘删除失败仅记日志,仍清 DB 行
(保证列表不再显示),避免磁盘文件泄漏却无任何记录。
"""
_, path = self.get_out_with_disk_path(file_id)
row = self.dao.get_by_id(file_id)
if row is None:
out, path = self.get_out_with_disk_path(file_id)
if out is None:
return False
if path is not None:
try:
Path(path).unlink(missing_ok=True)
except Exception:
# 即使磁盘删除失败也继续清 DB 行,保证列表不再显示
pass
except Exception as exc:
logger.warning("删除磁盘文件失败 file_id=%s path=%s: %s", file_id, path, exc)
self.dao.delete(file_id)
return True

View File

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

View File

@@ -1,14 +1,13 @@
"""白板服务CRUD + 笔画操作
"""白板服务(文本记事本)CRUD + 文本更新 + 清空
不持有 WebSocket 连接状态(那是 hub 的职责)删除白板时通过可选的 hub 回调
通知 hub 踢出该 board 的所有在线连接,由 controller 在装配时注入,避免 service -> hub
的硬依赖(保持低耦合
不持有 WebSocket 连接状态(那是 hub 的职责)删除白板时由 controller 层负责
通知 hub 踢出在线连接(因为 close_board 是 async需在事件循环中调用
service 只管 DB 层面的删除,保持低耦合。
"""
from __future__ import annotations
import re
from typing import TYPE_CHECKING, Any
from fastapi import HTTPException
@@ -16,20 +15,18 @@ from ..config import get_settings
from ..dao.whiteboard_dao import WhiteboardDAO
from ..schemas.whiteboard import WhiteboardListItem, WhiteboardOut
if TYPE_CHECKING: # 避免运行时循环导入
from .whiteboard_hub import WhiteboardHub
# board_id 合法字符集:字母数字下划线短横线
_BOARD_ID_RE = re.compile(r"^[a-zA-Z0-9_-]+$")
class WhiteboardService:
def __init__(self, dao: WhiteboardDAO, hub: "WhiteboardHub | None" = None) -> None:
def __init__(self, dao: WhiteboardDAO) -> None:
self.dao = dao
self.hub = hub
cfg = get_settings().whiteboard
self.max_board_id_length = cfg.max_board_id_length
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 字符)")
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:
@@ -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)
board = self.dao.append_strokes(board_id, [stroke])
self.validate_content(content)
board = self.dao.update_content(board_id, content)
if board is None:
raise HTTPException(404, "白板不存在")
return WhiteboardOut.model_validate(board)
def clear(self, board_id: str) -> WhiteboardOut:
"""清空白板stroke_count 仍自增以记录这次修改。"""
self.validate_board_id(board_id)
board = self.dao.replace_strokes(board_id, [])
if board is None:
raise HTTPException(404, "白板不存在")
return WhiteboardOut.model_validate(board)
"""清空白板内容置空edit_count 仍自增以记录这次修改。"""
return self.update_content(board_id, "")
def delete(self, board_id: str) -> bool:
"""删除白板;同时通知 hub 踢出该 board 的所有在线连接"""
"""删除白板 DB 行。踢出在线连接由 controller 层负责close_board 是 async"""
self.validate_board_id(board_id)
ok = 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
return self.dao.delete(board_id)

View File

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

View File

@@ -3,6 +3,12 @@
(function (global) {
"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) {
const node = document.createElement(tag);
if (attrs) {
@@ -11,7 +17,8 @@
else if (k === "dataset") Object.assign(node.dataset, v);
else if (k.startsWith("on") && typeof v === "function")
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) {
@@ -54,15 +61,15 @@
}
function fmtBytes(n) {
if (n == null) return "-";
const x = Number(n);
if (!isFinite(x)) return "-";
for (const unit of ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]) {
if (Math.abs(x) < 1024 || unit === "PiB")
return unit === "B" ? `${x} B` : `${x.toFixed(1)} ${unit}`;
n = x / 1024;
let x = Number(n);
if (n == null || !isFinite(x)) return "-";
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
let i = 0;
while (Math.abs(x) >= 1000 && i < units.length - 1) {
x /= 1000;
i++;
}
return `${n.toFixed(1)} PiB`;
return i === 0 ? `${Math.round(x)} ${units[i]}` : `${x.toFixed(1)} ${units[i]}`;
}
function fmtTime(s) {
@@ -76,8 +83,15 @@
async function api(path, opts) {
const res = await fetch(path, opts);
if (res.status === 401) {
// 触发浏览器 Basic Auth 弹窗(同源 reload 即可带上凭据)
// fetch 不会触发浏览器 Basic Auth 弹窗(只有导航/form 会)。
// 重载当前页:浏览器对页面导航的 401 会弹凭据框,凭据缓存后重试即可带上。
toast("需要登录");
if (location.href.indexOf("/api/") !== -1) {
// 纯 API 调用页(无页面壳),跳转到来源页触发鉴权
location.reload();
} else {
location.reload();
}
throw new Error("UNAUTHORIZED");
}
return res;

View File

@@ -1,4 +1,4 @@
/* 白板页专属样式:全屏画布、悬浮工具栏、移动端适配。 */
/* 记事本页专属样式:全屏 textarea、悬浮工具栏、移动端适配。 */
:root { --bar-h: 52px; }
body { overflow: hidden; background: var(--bg); }
.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-online { color: var(--success); font-size: 0.7em; }
.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-stage { position: relative; flex: 1; overflow: hidden; }
#canvas {
position: absolute; inset: 0; width: 100%; height: 100%;
display: block; touch-action: none; cursor: crosshair;
background:
linear-gradient(var(--border) 1px, transparent 1px) 0 0 / 24px 24px,
linear-gradient(90deg, var(--border) 1px, transparent 1px) 0 0 / 24px 24px,
var(--surface);
background-blend-mode: normal;
.wb-editor {
position: absolute; inset: 0;
width: 100%; height: 100%;
display: block; resize: none; border: none; outline: none;
padding: 1em 1.2em;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, "JetBrains Mono", monospace;
font-size: 14px; line-height: 1.6;
background: var(--surface); color: var(--text);
touch-action: manipulation;
}
.wb-editor::placeholder { color: var(--text-dim); }
.wb-status {
position: absolute; bottom: 0.8em; left: 50%; transform: translateX(-50%);
background: var(--surface); border: 1px solid var(--border);
@@ -44,7 +42,7 @@ body { overflow: hidden; background: var(--bg); }
@media (max-width: 640px) {
.wb-bar { padding: 0.4em 0.5em; gap: 0.4em; }
.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-title { display: none; }
.wb-editor { font-size: 15px; padding: 0.8em; }
}

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<meta name="theme-color" content="#1565c0">
<title>白板 - zikai</title>
<title>记事本 - zikai</title>
<link rel="stylesheet" href="/static/common.css">
<link rel="stylesheet" href="/static/whiteboard.css">
</head>
@@ -12,24 +12,18 @@
<div class="wb-app">
<header class="wb-bar">
<div class="wb-bar-left">
<span class="wb-title">白板</span>
<code class="wb-id mono" id="boardId" title="白板 ID"></code>
<span class="wb-title">记事本</span>
<code class="wb-id mono" id="boardId" title="记事本 ID"></code>
<span class="wb-online" id="online" title="在线人数"></span>
</div>
<div class="wb-bar-right">
<label class="wb-tool" title="笔画颜色">
<input type="color" id="color" value="#1565c0">
</label>
<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>
<button class="btn" id="copyLinkBtn" title="复制分享链接">复制链接</button>
<button class="btn" id="copyBtn" title="复制全部文本">复制文本</button>
<button class="btn danger" id="clearBtn" title="清空全部内容(所有人)">清空</button>
</div>
</header>
<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>
</main>
</div>

View File

@@ -1,153 +1,149 @@
/* 白板Canvas 绘画 + WebSocket 实时同步 + 心跳。
- 笔画以 {points:[[x,y],...], color, width} 表示,增量广播
- 心跳 3s 一次 ping服务端 15s 无心跳判失活会主动断连,前端据此重连
- 收到他人 stroke 增量重绘该笔;收到 cleared 清空本地画布
- 兼容鼠标 + 触摸:统一用 pointer eventstouch-action:none 防滚动缩放。 */
/* 记事本textarea + WebSocket 实时同步 + 心跳。
- 文本以整文本 debounce 400ms 后发服务端,服务端存为新版本并广播给其他端
- 收到他人 update 时用 diff 应用变更,保留本地光标位置(按相对偏移调整)
- 收到 cleared 清空本地 textarea
- 心跳 3s 一次 ping服务端 15s 无心跳判失活会主动断连,前端据此重连。 */
(function () {
"use strict";
const { el, toast, copyText } = window.ZK;
const { toast, copyText } = window.ZK;
// ---------- 从 URL 解析 board_id ----------
// 路径形如 /whiteboard/{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";
// 合法性兜底:前端非法字符直接回退到 default真正校验在服务端
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(boardId)) boardId = "default";
document.getElementById("boardId").textContent = boardId;
// ---------- DOM ----------
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const colorInput = document.getElementById("color");
const widthInput = document.getElementById("width");
const widthVal = document.getElementById("widthVal");
const editor = document.getElementById("editor");
const clearBtn = document.getElementById("clearBtn");
const copyBtn = document.getElementById("copyBtn");
const copyLinkBtn = document.getElementById("copyLinkBtn");
const statusEl = document.getElementById("status");
const onlineEl = document.getElementById("online");
// ---------- 状态 ----------
let strokes = []; // 已确认的笔画
let current = null; // 正在画的笔画(本地未提交)
let drawing = false;
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) {
clientId = "c_" + Math.random().toString(36).slice(2, 10);
localStorage.setItem("wb_cid", clientId);
sessionStorage.setItem("wb_cid", clientId);
}
let heartbeatTimer = null;
let reconnectTimer = null;
let connected = false;
let lastSentText = ""; // 上次发到服务端的文本(避免无变更时重复发)
let suppressInput = false; // 应用远端更新时抑制 input 事件,防回环
let debounceTimer = null;
// ---------- 画布尺寸 ----------
function resize() {
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth;
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);
// ---------- 本地编辑 -> debounce -> 发送 ----------
editor.addEventListener("input", () => {
if (suppressInput) return;
scheduleSend();
});
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", () => {
if (!connected) { flashStatus("未连接"); return; }
if (!confirm("确定清空白板?所有人的内容都会被清除。")) return;
if (!confirm("确定清空全部内容?所有人的内容都会被清除。")) return;
send({ type: "clear" });
});
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);
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 ----------
function wsUrl() {
const proto = location.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${location.host}/ws/whiteboard/${encodeURIComponent(boardId)}`;
return `${proto}//${location.host}/ws/wb/${encodeURIComponent(boardId)}`;
}
function connect() {
@@ -160,7 +156,7 @@
}
ws.onopen = () => {
connected = true;
setStatus("已连接", true);
setStatus("已连接", false);
onlineEl.classList.remove("off");
send({ type: "hello", client_id: clientId });
startHeartbeat();
@@ -175,23 +171,34 @@
try { msg = JSON.parse(raw); } catch { return; }
switch (msg.type) {
case "init":
strokes = Array.isArray(msg.strokes) ? msg.strokes : [];
redraw();
setStatus(`已同步 ${strokes.length}`, true);
// 连接建立时服务端下发当前文本。若本地有未发送编辑(断线期间输入的),
// 不直接覆盖而是把本地编辑作为最新版本发上去last-writer-wins
// 避免断线期间的编辑被静默丢弃。
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;
case "pong":
// 心跳回声,保持连接
break;
case "stroke":
if (msg.client_id === clientId) break; // 自己的,已本地画
strokes.push(msg.stroke);
drawStroke(msg.stroke);
case "update":
// 服务端 broadcast 已用 exclude=conn 排除发送者,收到即他人编辑,直接应用。
applyRemoteUpdate(msg.content || "");
flashStatus("对方有更新");
break;
case "cleared":
strokes = [];
current = null;
redraw();
flashStatus(msg.client_id === clientId ? "已清空" : "对方清空了白板");
suppressInput = true;
editor.value = "";
lastSentText = "";
suppressInput = false;
flashStatus(msg.client_id === clientId ? "已清空" : "对方清空了内容");
break;
case "error":
flashStatus(msg.msg || "错误", true);
@@ -242,28 +249,33 @@
}
// ---------- 启动 ----------
// 先 GET /whiteboard/{id} 确保白板存在(不存在则服务端新建),再连 WS
fetch(`/whiteboard/${encodeURIComponent(boardId)}`, { headers: { accept: "application/json" } })
.then((r) => r.ok ? r.json() : null)
// 先 GET /api/wb/{id} 确保白板存在(不存在则服务端新建),再连 WS
fetch(`/api/wb/${encodeURIComponent(boardId)}`, { headers: { accept: "application/json" } })
.then((r) => (r.ok ? r.json() : null))
.then((body) => {
if (body && Array.isArray(body.strokes)) {
strokes = body.strokes;
redraw();
if (body && typeof body.content === "string") {
editor.value = body.content;
lastSentText = body.content;
}
resize();
connect();
})
.catch(() => { resize(); connect(); });
.catch(() => { connect(); });
// 页面隐藏时不发心跳可被服务端判失活;重新可见时主动重连
// 页面可见性:重新可见时若已断连则主动重连;隐藏时 flush 未发送的编辑
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible" && (!ws || ws.readyState !== WebSocket.OPEN)) {
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
connect();
if (document.visibilityState === "visible") {
if (!ws || ws.readyState !== WebSocket.OPEN) {
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
connect();
}
} else {
flushSend();
}
});
// 离开前 flush 未发送编辑
window.addEventListener("beforeunload", () => {
flushSend();
stopHeartbeat();
if (reconnectTimer) clearTimeout(reconnectTimer);
try { ws && ws.close(); } catch {}

View File

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

View File

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

View File

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