原实现是 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 同步更新。
218 lines
13 KiB
Markdown
218 lines
13 KiB
Markdown
# zikai file service
|
||
|
||
`f.zikai.wang` 的 Python Web 服务(FastAPI),提供主机监控、大文件上传、共享白板与
|
||
文件浏览:**HTTP(整文件 + 分片/断点续传)**,并内置 **SFTP 服务器** 用于原始文件暂存。
|
||
采用 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/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` 一次性初始化。
|
||
|
||
## 架构(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 -- 数据库初始化
|
||
```
|
||
|
||
请求流程:**controller** -> **service** -> **dao** -> **ORM model** -> MySQL。
|
||
DB Session 由 FastAPI 的 `get_db` 依赖注入并向下传递。前端三套页面走「独立静态文件 +
|
||
StaticFiles 挂载」的前后端分离模式,HTML 壳由具名路由返回(便于各自挂 Basic Auth),
|
||
JS 调用同源 `/api/...`。
|
||
|
||
## 快速开始
|
||
|
||
```bash
|
||
cd /root/zikai
|
||
./setup.sh # 一次性:venv、依赖、建库建账、SFTP 主机密钥
|
||
./start.sh # 启动 HTTP(127.0.0.1:6867)+ SFTP(0.0.0.0:2022)
|
||
./stop.sh # 停止两者
|
||
```
|
||
|
||
`setup.sh` 可重复执行。它会创建 `.venv`、安装 `requirements.txt`、复制
|
||
`config.example.yaml` → `config.yaml`(若不存在)、通过本机 root socket 建一个
|
||
**全新的独立 MySQL 数据库与应用账户**,并生成 SFTP 主机密钥。
|
||
|
||
## 访问方式
|
||
|
||
| 入口 | 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/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`
|
||
到 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` 使用)。
|