From 30a263ed5058ac14951e5f6c98623747b305ac35 Mon Sep 17 00:00:00 2001 From: zikai Date: Wed, 22 Jul 2026 01:03:48 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BB=A3=E7=A0=81=E5=AE=A1=E6=9F=A5?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20+=20=E7=B2=BE=E7=AE=80=E9=87=8D=E5=86=99?= =?UTF-8?q?=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端修复: - 白板删除踢人失效: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)、配置项表格、防火墙说明。 --- README.md | 366 ++++++++++------------- app/controllers/file_controller.py | 6 +- app/controllers/whiteboard_controller.py | 14 +- app/services/upload_service.py | 16 +- app/services/whiteboard_hub.py | 10 +- app/services/whiteboard_service.py | 28 +- static/common.js | 9 +- static/whiteboard.js | 27 +- 8 files changed, 222 insertions(+), 254 deletions(-) diff --git a/README.md b/README.md index 2e7fe9e..c58d5e6 100644 --- a/README.md +++ b/README.md @@ -1,227 +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 /wb/{id}`(公开,不存在则新建):纯文本实时协作 + **清空 / 复制文本**,兼容移动端。 - 实时同步走 `WS /ws/wb/{id}`(**心跳 3s,连续 5 次丢失判失活并移除**)。 -- **白板管理页** `GET /wb-admin`(Basic Auth,同 docs):查看创建时间/编辑次数/上次修改时间/删除。 - 管理 API:`GET /api/admin/wb`、`DELETE /api/admin/wb/{id}`(均 Basic Auth)。 -- **反向隧道反代**:`ALL /api/userPort/{userName}` -- 把请求经 SSH 反向隧道转发到该 user 的本机服务。 -- **内置 SFTP/SSH 服务器**(asyncssh),支持 **密码 + 公钥** 鉴权,同时承载 SFTP 文件暂存与反向隧道。 -- `/docs`(Swagger UI)与 `/redoc` - 交互式文档,自动列出所有 API。 -- 元数据持久化在 **独立的 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}`(心跳 3s,5 次失活移除) | 公开 | +| 记事本管理 | `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 加载的类型化 Settings(pydantic-settings) +│ ├── database.py # SQLAlchemy 引擎/Session/Base/get_db 依赖 +│ ├── security.py # Basic Auth(require_docs_auth,常量时间比较) +│ ├── controllers/ # 路由层(@RestController):file/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 # 启停 HTTP(6867)+ SFTP(2022) +└── 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 # 启动 HTTP(127.0.0.1:6867)+ SFTP(0.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 /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 + + 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 + +``` + +```bash +a2ensite f.zikai.wang +systemctl reload apache2 +``` + +> **防火墙**:放开 443(HTTPS)与 2022(SFTP)。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/wb/{id}(公开,`{id}` 为 `[a-zA-Z0-9_-]{1,64}`,不存在则新建) | -| 白板管理页 | https://f.zikai.wang/wb-admin **(Basic Auth,同 docs)** | -| 上传(curl) | `curl -F file=@big.iso https://f.zikai.wang/api/files/upload` | -| SFTP | `sftp -P 2022 uploader@f.zikai.wang` | +| API 文档 | https://f.zikai.wang/docs(Basic Auth) | +| 上传页 | https://f.zikai.wang/upload | +| 文件浏览 | https://f.zikai.wang/files(Basic Auth) | +| 共享记事本 | https://f.zikai.wang/wb/{id}(公开,`{id}` 为 `[a-zA-Z0-9_-]{1,64}`) | +| 记事本管理 | https://f.zikai.wang/wb-admin(Basic Auth) | +| 系统状态 | https://f.zikai.wang/api/system/status(HTML,`?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。 - -> **WebSocket 反代**:记事本的实时同步走 `WS /ws/wb/{id}`,经 Apache 反代时必须用 -> `proxy_wstunnel` 模块单独透传 `/ws/` 路径,否则升级请求被当普通 GET 返回 404、前端反复 -> 「连接已关闭,重连中」。vhost 须在通用 `/` 规则**之前**加: -> ```apache -> ProxyPass /ws/ ws://127.0.0.1:6867/ws/ -> ProxyPassReverse /ws/ ws://127.0.0.1:6867/ws/ -> ``` -> (外层 HTTPS 由 Apache 终结,Apache 到 uvicorn 之间是明文 `ws://`。) -> 需启用 `proxy_wstunnel` 模块:`a2enmod proxy_wstunnel && systemctl reload apache2`。 - -> **大文件/慢速 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` - 到 DB(user 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 依赖的查重/查询/下载)保留不变。 - -## 共享记事本(白板) - -白板无鉴权,任何人凭 `/wb/{id}` 即可访问并实时协作;`{id}` 须匹配 -`[a-zA-Z0-9_-]{1,64}`,非法返回 400。访问不存在的 id 自动新建空板。白板长期留存 -(存 MySQL `whiteboard` 表,`content` TEXT 列),进程重启后内容仍在。 - -### 实时同步与心跳 - -- 连接:`WS /ws/wb/{id}`(公开)。JSON 文本帧协议: - - client -> server:`{"type":"hello","client_id":"..."}`(首帧,可选)、 - `{"type":"ping"}`(心跳)、`{"type":"edit","content":"..."}`(debounce 后发完整文本)、 - `{"type":"clear"}` - - server -> client:`{"type":"init","content":"...","version":n,"edit_count":m}`、 - `{"type":"pong"}`、`{"type":"update","content":"...","version":n,"client_id":"..."}`(广播给他人,不含发送者)、 - `{"type":"cleared","client_id":"..."}`(广播给所有人)、`{"type":"error","msg":"..."}` -- **同步策略**:客户端本地编辑后 debounce 400ms 发完整文本,服务端存为新版本(version+1) - 并广播给同 board 的其他在线连接。其他端用最长公共前后缀算出变更区间,仅替换该区间并 - 保留本地光标位置(在变更区间前不动,在后平移,在区间内移到末尾)。 -- **心跳**:客户端每 `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 /wb-admin`(Basic Auth,同 docs)渲染 `static/whiteboard_admin.html`。 -- 管理 API(均 Basic Auth): - - `GET /api/admin/wb?limit=&offset=` -> `{total, items:[{board_id, edit_count, created_at, updated_at}]}` - - `DELETE /api/admin/wb/{id}` -> 删 DB 行 + 关闭该 board 所有在线 WS 连接。 - -## 临时文件清理 - -- `complete` 成功(含去重命中)后,会话目录 `uploads/.work//` 立即删除。 -- 被放弃的上传(`pending` 状态且超过 `chunk_session_ttl_seconds` 无活动,默认 5 分钟)由 - **后台 reaper** 清理:每 60 秒扫一次,删 `.work//` 目录 + 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`(SFTP);pidfile:`app.pid`、`sftp.pid`。 +- **临时文件清理**:分片上传完成后立即删 `.work//`;被放弃会话(`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` 供参考/手动初始化。 diff --git a/app/controllers/file_controller.py b/app/controllers/file_controller.py index 4b4ed70..8740726 100644 --- a/app/controllers/file_controller.py +++ b/app/controllers/file_controller.py @@ -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) diff --git a/app/controllers/whiteboard_controller.py b/app/controllers/whiteboard_controller.py index 9be4490..ec890ec 100644 --- a/app/controllers/whiteboard_controller.py +++ b/app/controllers/whiteboard_controller.py @@ -32,8 +32,8 @@ 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 ---------------- @@ -71,7 +71,7 @@ def list_whiteboards( summary="删除记事本(需鉴权)", description="删 DB 行,并关闭该 board 的所有在线 WebSocket 连接。", ) -def delete_whiteboard( +async def delete_whiteboard( board_id: str, service: WhiteboardService = Depends(_service), _: str = Depends(require_docs_auth), @@ -79,6 +79,8 @@ 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} @@ -116,7 +118,7 @@ async def whiteboard_ws(websocket: WebSocket, board_id: str) -> None: # 校验 board_id 并加载白板(不存在则新建) 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) @@ -162,7 +164,7 @@ async def whiteboard_ws(websocket: WebSocket, board_id: str) -> None: continue try: out = _with_db( - lambda db: WhiteboardService(WhiteboardDAO(db), hub=hub).update_content(board_id, content) + lambda db: WhiteboardService(WhiteboardDAO(db)).update_content(board_id, content) ) except HTTPException as exc: await _safe_send(websocket, {"type": "error", "msg": exc.detail}) @@ -175,7 +177,7 @@ async def whiteboard_ws(websocket: WebSocket, board_id: str) -> None: ) 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 diff --git a/app/services/upload_service.py b/app/services/upload_service.py index 0fb067c..b405ae6 100644 --- a/app/services/upload_service.py +++ b/app/services/upload_service.py @@ -8,6 +8,7 @@ upload_root),不再有 HTTP 登记接口。 from __future__ import annotations import hashlib +import logging import os import uuid from collections.abc import Iterator @@ -21,6 +22,8 @@ from ..dao.uploaded_file_dao import UploadedFileDAO from ..models.uploaded_file import UploadedFile from ..schemas.file import FileUploadResponse, UploadedFileOut +logger = logging.getLogger("zikai.upload") + def hash_stream(chunks: Iterator[bytes]) -> tuple[int, str]: """对一段字节块序列流式计算 (size, sha256)。供多条上传路径复用。""" @@ -95,18 +98,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 diff --git a/app/services/whiteboard_hub.py b/app/services/whiteboard_hub.py index 1010c28..e11b622 100644 --- a/app/services/whiteboard_hub.py +++ b/app/services/whiteboard_hub.py @@ -18,6 +18,7 @@ from __future__ import annotations import asyncio import logging +import threading import time from dataclasses import dataclass, field @@ -167,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 diff --git a/app/services/whiteboard_service.py b/app/services/whiteboard_service.py index 65bfde1..c18342d 100644 --- a/app/services/whiteboard_service.py +++ b/app/services/whiteboard_service.py @@ -1,14 +1,13 @@ """白板服务(文本记事本):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 from fastapi import HTTPException @@ -16,17 +15,13 @@ 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 @@ -84,17 +79,6 @@ class WhiteboardService: 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) diff --git a/static/common.js b/static/common.js index ac8c3c8..75b75a7 100644 --- a/static/common.js +++ b/static/common.js @@ -83,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; diff --git a/static/whiteboard.js b/static/whiteboard.js index a48cf63..ea97e64 100644 --- a/static/whiteboard.js +++ b/static/whiteboard.js @@ -88,6 +88,8 @@ // ---------- 应用远端更新(保留光标) ---------- // 策略:用最长公共前后缀算出变更区间,仅替换该区间,光标按相对位置调整。 + // 若本地有未发送的编辑(editor.value !== lastSentText),合并后重新 scheduleSend, + // 避免本地编辑被远端覆盖后因 lastSentText 短路而丢弃。 function applyRemoteUpdate(newText) { const oldText = editor.value; if (newText === oldText) return; @@ -108,6 +110,7 @@ suffixNew--; } + const hadPending = editor.value !== lastSentText; suppressInput = true; // 用 setRangeText 替换 [prefix, suffixOld) 为 newText[prefix, suffixNew) editor.setRangeText(newText.slice(prefix, suffixNew), prefix, suffixOld, "end"); @@ -130,7 +133,11 @@ try { editor.setSelectionRange(newStart, newEnd); } catch {} - editor.focus(); + // 仅当编辑器当前有焦点时恢复焦点,避免抢其它控件焦点 + if (document.activeElement === editor) editor.focus(); + + // 本地有未发送编辑被合并了,重新安排发送,避免被丢弃 + if (hadPending) scheduleSend(); } // ---------- WebSocket ---------- @@ -164,11 +171,19 @@ try { msg = JSON.parse(raw); } catch { return; } switch (msg.type) { case "init": - suppressInput = true; - editor.value = msg.content || ""; - lastSentText = editor.value; - suppressInput = false; - editor.focus(); + // 连接建立时服务端下发当前文本。若本地有未发送编辑(断线期间输入的), + // 不直接覆盖,而是把本地编辑作为最新版本发上去(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":