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:
366
README.md
366
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 <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
|
||||
```
|
||||
|
||||
> **防火墙**:放开 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/<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`(SFTP);pidfile:`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` 供参考/手动初始化。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user