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)、配置项表格、防火墙说明。
This commit is contained in:
zikai
2026-07-22 01:03:48 +00:00
parent 4de2648178
commit 30a263ed50
8 changed files with 222 additions and 254 deletions

366
README.md
View File

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

View File

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

View File

@@ -32,8 +32,8 @@ router = APIRouter(tags=["whiteboard"])
def _service(db: Session = Depends(get_db)) -> WhiteboardService: def _service(db: Session = Depends(get_db)) -> WhiteboardService:
"""REST 路径的 service:注入 hub 以便删除时踢出连接""" """REST 路径的 service(纯 DB 操作)"""
return WhiteboardService(WhiteboardDAO(db), hub=get_hub()) return WhiteboardService(WhiteboardDAO(db))
# ---------------- 公开 REST ---------------- # ---------------- 公开 REST ----------------
@@ -71,7 +71,7 @@ def list_whiteboards(
summary="删除记事本(需鉴权)", summary="删除记事本(需鉴权)",
description="删 DB 行,并关闭该 board 的所有在线 WebSocket 连接。", description="删 DB 行,并关闭该 board 的所有在线 WebSocket 连接。",
) )
def delete_whiteboard( async def delete_whiteboard(
board_id: str, board_id: str,
service: WhiteboardService = Depends(_service), service: WhiteboardService = Depends(_service),
_: str = Depends(require_docs_auth), _: str = Depends(require_docs_auth),
@@ -79,6 +79,8 @@ def delete_whiteboard(
ok = service.delete(board_id) ok = service.delete(board_id)
if not ok: if not ok:
raise HTTPException(404, "白板不存在") raise HTTPException(404, "白板不存在")
# 删除成功后踢出该 board 的所有在线连接close_board 是 async须在事件循环中调用
await get_hub().close_board(board_id)
return {"deleted": True} return {"deleted": True}
@@ -116,7 +118,7 @@ async def whiteboard_ws(websocket: WebSocket, board_id: str) -> None:
# 校验 board_id 并加载白板(不存在则新建) # 校验 board_id 并加载白板(不存在则新建)
try: try:
board = _with_db(lambda db: WhiteboardService(WhiteboardDAO(db), hub=hub).get_or_create(board_id)) board = _with_db(lambda db: WhiteboardService(WhiteboardDAO(db)).get_or_create(board_id))
except HTTPException as exc: except HTTPException as exc:
await _safe_send(websocket, {"type": "error", "msg": exc.detail}) await _safe_send(websocket, {"type": "error", "msg": exc.detail})
await _safe_close(websocket) await _safe_close(websocket)
@@ -162,7 +164,7 @@ async def whiteboard_ws(websocket: WebSocket, board_id: str) -> None:
continue continue
try: try:
out = _with_db( 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: except HTTPException as exc:
await _safe_send(websocket, {"type": "error", "msg": exc.detail}) 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": elif mtype == "clear":
try: try:
_with_db(lambda db: WhiteboardService(WhiteboardDAO(db), hub=hub).clear(board_id)) _with_db(lambda db: WhiteboardService(WhiteboardDAO(db)).clear(board_id))
except HTTPException as exc: except HTTPException as exc:
await _safe_send(websocket, {"type": "error", "msg": exc.detail}) await _safe_send(websocket, {"type": "error", "msg": exc.detail})
continue continue

View File

@@ -8,6 +8,7 @@ upload_root不再有 HTTP 登记接口。
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import logging
import os import os
import uuid import uuid
from collections.abc import Iterator from collections.abc import Iterator
@@ -21,6 +22,8 @@ from ..dao.uploaded_file_dao import UploadedFileDAO
from ..models.uploaded_file import UploadedFile from ..models.uploaded_file import UploadedFile
from ..schemas.file import FileUploadResponse, UploadedFileOut from ..schemas.file import FileUploadResponse, UploadedFileOut
logger = logging.getLogger("zikai.upload")
def hash_stream(chunks: Iterator[bytes]) -> tuple[int, str]: def hash_stream(chunks: Iterator[bytes]) -> tuple[int, str]:
"""对一段字节块序列流式计算 (size, sha256)。供多条上传路径复用。""" """对一段字节块序列流式计算 (size, sha256)。供多条上传路径复用。"""
@@ -95,18 +98,17 @@ class UploadService:
def delete_file(self, file_id: int) -> bool: def delete_file(self, file_id: int) -> bool:
"""硬删除:删磁盘文件 + 删 DB 行。磁盘缺失不阻断 DB 清理。返回是否命中。 """硬删除:删磁盘文件 + 删 DB 行。磁盘缺失不阻断 DB 清理。返回是否命中。
供单删/批删复用,保证删除语义一致。 供单删/批删复用,保证删除语义一致。磁盘删除失败仅记日志,仍清 DB 行
(保证列表不再显示),避免磁盘文件泄漏却无任何记录。
""" """
_, path = self.get_out_with_disk_path(file_id) out, path = self.get_out_with_disk_path(file_id)
row = self.dao.get_by_id(file_id) if out is None:
if row is None:
return False return False
if path is not None: if path is not None:
try: try:
Path(path).unlink(missing_ok=True) Path(path).unlink(missing_ok=True)
except Exception: except Exception as exc:
# 即使磁盘删除失败也继续清 DB 行,保证列表不再显示 logger.warning("删除磁盘文件失败 file_id=%s path=%s: %s", file_id, path, exc)
pass
self.dao.delete(file_id) self.dao.delete(file_id)
return True return True

View File

@@ -18,6 +18,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import threading
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -167,16 +168,21 @@ class WhiteboardHub:
# 进程内单例(由 main.py lifespan / controller 共享) # 进程内单例(由 main.py lifespan / controller 共享)
_hub: WhiteboardHub | None = None _hub: WhiteboardHub | None = None
_hub_lock = threading.Lock()
def get_hub() -> WhiteboardHub: def get_hub() -> WhiteboardHub:
"""获取/创建进程内单例 hub。线程安全lifespan 预热后通常不再进锁)。"""
global _hub global _hub
if _hub is None: if _hub is None:
_hub = WhiteboardHub() with _hub_lock:
if _hub is None:
_hub = WhiteboardHub()
return _hub return _hub
def reset_hub() -> None: def reset_hub() -> None:
"""测试用:重置单例。""" """测试用:重置单例。"""
global _hub global _hub
_hub = None with _hub_lock:
_hub = None

View File

@@ -1,14 +1,13 @@
"""白板服务文本记事本CRUD + 文本更新 + 清空。 """白板服务文本记事本CRUD + 文本更新 + 清空。
不持有 WebSocket 连接状态(那是 hub 的职责)删除白板时通过可选的 hub 回调 不持有 WebSocket 连接状态(那是 hub 的职责)删除白板时由 controller 层负责
通知 hub 踢出该 board 的所有在线连接,由 controller 在装配时注入,避免 service -> hub 通知 hub 踢出在线连接(因为 close_board 是 async需在事件循环中调用
的硬依赖(保持低耦合 service 只管 DB 层面的删除,保持低耦合。
""" """
from __future__ import annotations from __future__ import annotations
import re import re
from typing import TYPE_CHECKING
from fastapi import HTTPException from fastapi import HTTPException
@@ -16,17 +15,13 @@ from ..config import get_settings
from ..dao.whiteboard_dao import WhiteboardDAO from ..dao.whiteboard_dao import WhiteboardDAO
from ..schemas.whiteboard import WhiteboardListItem, WhiteboardOut from ..schemas.whiteboard import WhiteboardListItem, WhiteboardOut
if TYPE_CHECKING: # 避免运行时循环导入
from .whiteboard_hub import WhiteboardHub
# board_id 合法字符集:字母数字下划线短横线 # board_id 合法字符集:字母数字下划线短横线
_BOARD_ID_RE = re.compile(r"^[a-zA-Z0-9_-]+$") _BOARD_ID_RE = re.compile(r"^[a-zA-Z0-9_-]+$")
class WhiteboardService: class WhiteboardService:
def __init__(self, dao: WhiteboardDAO, hub: "WhiteboardHub | None" = None) -> None: def __init__(self, dao: WhiteboardDAO) -> None:
self.dao = dao self.dao = dao
self.hub = hub
cfg = get_settings().whiteboard cfg = get_settings().whiteboard
self.max_board_id_length = cfg.max_board_id_length self.max_board_id_length = cfg.max_board_id_length
self.list_limit = cfg.list_limit self.list_limit = cfg.list_limit
@@ -84,17 +79,6 @@ class WhiteboardService:
return self.update_content(board_id, "") return self.update_content(board_id, "")
def delete(self, board_id: str) -> bool: def delete(self, board_id: str) -> bool:
"""删除白板;同时通知 hub 踢出该 board 的所有在线连接""" """删除白板 DB 行。踢出在线连接由 controller 层负责close_board 是 async"""
self.validate_board_id(board_id) self.validate_board_id(board_id)
ok = self.dao.delete(board_id) return self.dao.delete(board_id)
if ok and self.hub is not None:
# hub.close_board 是 async但删除走 REST 同步路径;安排到事件循环里执行
import asyncio
try:
loop = asyncio.get_running_loop()
loop.create_task(self.hub.close_board(board_id))
except RuntimeError:
# 无运行中事件循环(如脚本调用):同步调用会报错,忽略即可
pass
return ok

View File

@@ -83,8 +83,15 @@
async function api(path, opts) { async function api(path, opts) {
const res = await fetch(path, opts); const res = await fetch(path, opts);
if (res.status === 401) { if (res.status === 401) {
// 触发浏览器 Basic Auth 弹窗(同源 reload 即可带上凭据) // fetch 不会触发浏览器 Basic Auth 弹窗(只有导航/form 会)。
// 重载当前页:浏览器对页面导航的 401 会弹凭据框,凭据缓存后重试即可带上。
toast("需要登录"); toast("需要登录");
if (location.href.indexOf("/api/") !== -1) {
// 纯 API 调用页(无页面壳),跳转到来源页触发鉴权
location.reload();
} else {
location.reload();
}
throw new Error("UNAUTHORIZED"); throw new Error("UNAUTHORIZED");
} }
return res; return res;

View File

@@ -88,6 +88,8 @@
// ---------- 应用远端更新(保留光标) ---------- // ---------- 应用远端更新(保留光标) ----------
// 策略:用最长公共前后缀算出变更区间,仅替换该区间,光标按相对位置调整。 // 策略:用最长公共前后缀算出变更区间,仅替换该区间,光标按相对位置调整。
// 若本地有未发送的编辑editor.value !== lastSentText合并后重新 scheduleSend
// 避免本地编辑被远端覆盖后因 lastSentText 短路而丢弃。
function applyRemoteUpdate(newText) { function applyRemoteUpdate(newText) {
const oldText = editor.value; const oldText = editor.value;
if (newText === oldText) return; if (newText === oldText) return;
@@ -108,6 +110,7 @@
suffixNew--; suffixNew--;
} }
const hadPending = editor.value !== lastSentText;
suppressInput = true; suppressInput = true;
// 用 setRangeText 替换 [prefix, suffixOld) 为 newText[prefix, suffixNew) // 用 setRangeText 替换 [prefix, suffixOld) 为 newText[prefix, suffixNew)
editor.setRangeText(newText.slice(prefix, suffixNew), prefix, suffixOld, "end"); editor.setRangeText(newText.slice(prefix, suffixNew), prefix, suffixOld, "end");
@@ -130,7 +133,11 @@
try { try {
editor.setSelectionRange(newStart, newEnd); editor.setSelectionRange(newStart, newEnd);
} catch {} } catch {}
editor.focus(); // 仅当编辑器当前有焦点时恢复焦点,避免抢其它控件焦点
if (document.activeElement === editor) editor.focus();
// 本地有未发送编辑被合并了,重新安排发送,避免被丢弃
if (hadPending) scheduleSend();
} }
// ---------- WebSocket ---------- // ---------- WebSocket ----------
@@ -164,11 +171,19 @@
try { msg = JSON.parse(raw); } catch { return; } try { msg = JSON.parse(raw); } catch { return; }
switch (msg.type) { switch (msg.type) {
case "init": case "init":
suppressInput = true; // 连接建立时服务端下发当前文本。若本地有未发送编辑(断线期间输入的),
editor.value = msg.content || ""; // 不直接覆盖而是把本地编辑作为最新版本发上去last-writer-wins
lastSentText = editor.value; // 避免断线期间的编辑被静默丢弃。
suppressInput = false; if (editor.value && editor.value !== lastSentText) {
editor.focus(); 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); setStatus("已同步", false);
break; break;
case "pong": case "pong":