Merge branch 'refactor/cleanup-failfast' into main

清理死代码/提前失败/日志改进/高内聚低耦合,精简 README 并新增 docs/。
合并 controller/dao/service 日志改进与死代码清理。
This commit is contained in:
zikai
2026-07-28 04:28:07 +00:00
16 changed files with 229 additions and 192 deletions

193
README.md
View File

@@ -1,125 +1,47 @@
# zikai file service # zTools2 - 个人 Web 服务后端
基于 FastAPI 的个人 Web 服务,提供**文件上传/浏览/下载、共享记事本(实时协作)、 基于 FastAPI 的个人 Web 服务后端,提供文件上传/浏览/下载、共享记事本(实时协作)、主机监控、SFTP 暂存、反向隧道、PDF 转换。采用 Spring 风格分层架构controller -> service -> dao -> ORM model -> MySQL自带 API 文档。
主机监控、SFTP 暂存、反向隧道、PDF 转换**。采用 Spring 风格分层架构,自带 API 文档。
## 功能一览 所有入口统一挂在 `/api/` 前缀下,前端用同源相对路径调用,无需区分 dev/prod。前端子项目[timeTableFix](https://git.zikai.wang/zikai/timeTableFix)、[zPDF_package](https://git.zikai.wang/zikai/zPDF_package)、[zWhiteBoard](https://git.zikai.wang/zikai/zWhiteBoard))经 [zMainPage](https://git.zikai.wang/zikai/zMainPage) 构建期组件 import 集成,生产由 apache2 静态托管 + `/api` 反代到本服务。
| 模块 | 页面 / 接口 | 鉴权 |
|------|------------|------|
| 文件上传 | `POST /api/files/upload`(流式)/ `POST /api/files/chunk-uploads/*`(分片+断点续传) | 公开 |
| 上传页 | `GET /api/upload`(拖拽/多文件/分片/去重) | 公开 |
| 文件浏览 | `GET /api/files-page`(多选/批量下载删除/分页) | Basic Auth |
| 文件管理 API | `GET /api/admin/files``GET/DELETE /api/admin/files/{id}``GET /api/admin/files/{id}/download` | Basic Auth |
| 共享记事本 | `GET /api/wb-page/{id}`(公开,不存在则新建) | 公开 |
| 记事本实时同步 | `WS /api/ws/wb/{id}`(心跳 3s5 次失活移除) | 公开 |
| 记事本管理 | `GET /api/wb-admin`(查看/删除) | Basic Auth |
| 记事本管理 API | `GET /api/admin/wb``DELETE /api/admin/wb/{id}` | Basic Auth |
| PDF 转换 API | `POST /api/pdf/jobs``GET /api/pdf/jobs[/{id}]``GET /api/pdf/jobs/{id}/download``DELETE /api/pdf/jobs/{id}` | 公开cookie |
| PDF 转换管理 | `GET /api/pdf-admin`(全部任务,含已软删标记,硬删) | Basic Auth |
| PDF 转换管理 API | `GET /api/admin/pdf/jobs``DELETE /api/admin/pdf/jobs/{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 |
## 路由约定
**所有 zTools2 托管的入口(页面 / 静态资源 / 健康探针 / WebSocket / REST API统一挂在 `/api/` 前缀下**,只有元信息/文档例外(`/``/docs``/redoc``/openapi.json`)。这样反向代理与 vite dev proxy 都只需一条 `/api/` 规则即可把请求转给当前环境的 zTools2前端用**同源相对路径**(如 `/api/pdf/jobs``/api/health`)即可,与运行环境(本地 / 测试 / 生产)无关,无需区分 dev/prod 指向。
> PDF 转换的用户侧 UI 由 zMainPage 的 zPDF_package 组件提供(构建期 import非 iframezTools2 仅提供 `/api/pdf/jobs` 等 REST API 与 `/api/pdf-admin` 管理页。
页面类入口为避免与同名 REST API 冲突,统一加 `-page` 后缀:
| 类型 | 页面入口 | REST API同名不加后缀 |
|------|---------|------------------------|
| 文件浏览 | `GET /api/files-page` | `GET /api/files``/api/files/{id}` 等 |
| 记事本 | `GET /api/wb-page/{id}` | `GET /api/wb/{id}` |
其余入口:`/api/health`(探针)、`/api/static/*`JS/CSS`/api/ws/wb/{id}`WebSocket`/api/upload``/api/pdf-admin``/api/wb-admin`
## 项目结构 ## 项目结构
``` ```
server/ zTools2/
├── app/ ├── app/
│ ├── main.py # FastAPI 应用工厂、路由注册、生命周期reaper │ ├── main.py # FastAPI 应用工厂、路由注册、生命周期reaper
│ ├── config.py # 从 config.yaml 加载的类型化 Settingspydantic-settings │ ├── config.py # 从 config.yaml 加载的类型化 Settingspydantic-settings
│ ├── database.py # SQLAlchemy 引擎/Session/Base/get_db 依赖 │ ├── database.py # SQLAlchemy 引擎/Session/Base/get_db 依赖 + schema 校验
│ ├── security.py # Basic Authrequire_docs_auth常量时间比较 │ ├── security.py # Basic Authrequire_docs_auth常量时间比较
│ ├── controllers/ # 路由层@RestControllerfile/system/chunk/tunnel/whiteboard/pdf/admin │ ├── controllers/ # 路由层file/system/chunk/tunnel/whiteboard/pdf/admin
│ ├── services/ # 业务层UploadService/ChunkUploadService/SystemService/ │ ├── services/ # 业务层Upload/ChunkUpload/System/Whiteboard/Tunnel/Pdf/sftp
│ │ # WhiteboardService/WhiteboardHub/TunnelService/sftp_server
│ │ # PdfService转换编排+ pdf_converterepub->pdf 纯 Python
│ ├── dao/ # 数据访问层:唯一发 SQL 的层SQLAlchemy ORM 参数化) │ ├── dao/ # 数据访问层:唯一发 SQL 的层SQLAlchemy ORM 参数化)
│ ├── models/ # ORM 实体UploadedFile/UploadSession/Whiteboard/TunnelSession/PdfJob │ ├── models/ # ORM 实体UploadedFile/UploadSession/Whiteboard/TunnelSession/PdfJob
│ ├── schemas/ # pydantic 请求/响应 DTO │ ├── schemas/ # pydantic 请求/响应 DTO
│ ├── views/ # 服务端渲染 HTML系统状态页、上传页 │ ├── views/ # 服务端渲染 HTML系统状态页、上传页
│ ├── static/ # 前端静态资源common + file_browser + whiteboard + pdf + pdf_admin │ ├── static/ # 前端静态资源
│ └── scripts/init_db.py # 数据库初始化(建库建账、随机密码写回 config.yaml │ └── scripts/init_db.py # 数据库初始化(建库建账、随机密码写回 config.yaml
├── sql/schema.sql # 建表 DDL参考实际由 ORM 自动建表) ├── sql/schema.sql # 建表 DDL参考实际由 ORM 自动建表)
├── config.example.yaml # 配置模板(含注释) ├── config.example.yaml # 配置模板(含注释)
├── config.yaml # 实际配置git-ignored含密码 ├── config.yaml # 实际配置git-ignored含密码
├── requirements.txt ├── requirements.txt
├── setup.sh # 一次性初始化venv + 依赖 + 建库 + SFTP 密钥 ├── setup.sh # 一次性初始化venv + 依赖 + 建库 + SFTP 密钥
├── start.sh / stop.sh # 启停 HTTP6867+ SFTP2022 ├── start.sh / stop.sh # 启停 HTTP127.0.0.1:6867+ SFTP2022
└── logs/ # app.log / sftp.log └── deploy/ # systemd 持久化部署
``` ```
**请求流程**`controller service dao ORM model MySQL` **请求流程**`controller -> service -> dao -> ORM model -> MySQL`DB Session 由 `get_db` 依赖注入。
DB Session 由 `get_db` 依赖注入。前端页面走「StaticFiles 挂载 + 具名 HTML 路由」前后端分离JS 调同源 `/api/...`
## 从零安装Ubuntu 22.04+ ## 外部依赖
### 1. 安装系统依赖 | 项 | 说明 |
|----|------|
| Python | ≥ 3.11(用 `.venv` |
| MySQL | 8.x独立库 `zikai_filesvc`,由 `setup.sh` 建账) |
| Apache2 | 反向代理对外提供 HTTPS服务本身只绑 `127.0.0.1:6867` |
| 系统库 | `libpango/cairo`weasyprint PDF 转换)、`build-essential`bcrypt/asyncssh 编译) |
```bash ### Apache2 反向代理配置
apt update
apt install -y python3-venv python3-pip mysql-server apache2 \
libssl-dev build-essential # build-essential 给 bcrypt/asyncssh 编译
# PDF 转换依赖 weasyprint需 pango/cairo 系统库Ubuntu 通常已随桌面环境安装,缺则补装):
apt install -y libpango-1.0-0 libpangoft2-1.0-0 libcairo2 libgdk-pixbuf-2.0-0
```
### 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 服务只绑 `127.0.0.1:6867`,通过 Apache 对外提供 HTTPS。安装模块并配置 vhost
@@ -154,62 +76,49 @@ systemctl reload apache2
> **防火墙**:放开 443HTTPS与 2022SFTP。6867 不对外(仅 loopback > **防火墙**:放开 443HTTPS与 2022SFTP。6867 不对外(仅 loopback
> **大文件上传**Apache 全局 `Timeout 300`,慢链路建议走分片上传(`/api/upload`)或 SFTP。 > **大文件上传**Apache 全局 `Timeout 300`,慢链路建议走分片上传(`/api/upload`)或 SFTP。
## 配置说明 ## 从零安装Ubuntu 22.04+
所有运行时配置在 `config.yaml`git-ignored。完整 schema 见 `config.example.yaml` ### 1. 安装系统依赖
| 段 | 关键项 | 说明 | ```bash
|----|--------|------| apt update
| `server` | `host`/`port` | 绑定地址,保持 `127.0.0.1:6867`Apache 反代) | apt install -y python3-venv python3-pip mysql-server apache2 \
| `database` | `host`/`port`/`user`/`password`/`database` | MySQL 连接;密码由 `setup.sh` 自动生成写回 | libssl-dev build-essential # build-essential 给 bcrypt/asyncssh 编译
| `storage` | `upload_dir` | 文件存储根目录(默认 `uploads` | # PDF 转换依赖 weasyprint需 pango/cairo 系统库:
| | `chunk_bytes` | 流式上传分片大小(默认 1 MiB | apt install -y libpango-1.0-0 libpangoft2-1.0-0 libcairo2 libgdk-pixbuf-2.0-0
| | `chunk_session_dir` | 分片会话暂存目录(默认 `uploads/.work` | ```
| | `chunk_session_ttl_seconds` | 被放弃会话存活秒数(默认 300 |
| `docs` | `username`/`password` | `/docs``/api/files-page``/api/wb-admin``/api/pdf-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 |
## 访问入口 ### 2. 获取代码并初始化
| 入口 | URL | ```bash
|------|-----| git clone <repo> /root/zikai
| API 文档 | https://f.zikai.wang/docsBasic Auth | cd /root/zikai/zTools2
| 上传页 | https://f.zikai.wang/api/upload | ./setup.sh # 创建 .venv + 装依赖 + 复制 config.yaml + 建 MySQL 库账 + 生成 SFTP 密钥
| 文件浏览 | https://f.zikai.wang/api/files-pageBasic Auth | ```
| 共享记事本 | https://f.zikai.wang/api/wb-page/{id}(公开,`{id}``[a-zA-Z0-9_-]{1,64}` |
| 记事本管理 | https://f.zikai.wang/api/wb-adminBasic Auth |
| PDF 转换 | 经 zMainPage 的 PDF 页签zPDF_package 组件)调用 `/api/pdf/jobs` 等 REST API公开凭 cookie |
| 系统状态 | 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` |
## 运维 ### 3. 配置凭据
- **日志**`logs/app.log`HTTP`logs/sftp.log`SFTPpidfile`app.pid``sftp.pid` 编辑 `config.yaml`(详见 [`docs/configuration.md`](./docs/configuration.md)`docs.username/password`(管理页 Basic Auth`sftp.users[].password_hash`bcrypt、可选 `tunnel.users[]`
- **临时文件清理**:分片上传完成后立即删 `.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` 供参考/手动初始化。
## 持久化部署systemd ### 4. 启动
`./start.sh` 适合手动运维;生产推荐用 systemd 管理实现开机自启 + 崩溃自动重启: ```bash
./start.sh # 启动 HTTP(127.0.0.1:6867) + SFTP(0.0.0.0:2022)
./stop.sh # 停止
```
### 5. 持久化部署systemd
```bash ```bash
./deploy/install-systemd.sh # 安装并启动 ztools2 服务(开机自启) ./deploy/install-systemd.sh # 安装并启动 ztools2 服务(开机自启)
systemctl status ztools2 # 状态 systemctl status ztools2
journalctl -u ztools2 -f # 日志 journalctl -u ztools2 -f
sudo systemctl restart ztools2 # 重启(更新代码后)
``` ```
脚本自动检测 `.venv/bin/uvicorn` 或系统 `uvicorn`,用实际路径填充 `deploy/ztools2.service` 模板。卸载:`systemctl disable --now ztools2 && rm /etc/systemd/system/ztools2.service && systemctl daemon-reload` > 与 zMainPage 整体部署配合systemd 管后端,`zMainPage/deploy.sh --no-restart` 部署前端
> 与 zMainPage 整体部署配合systemd 管后端,`zMainPage/deploy.sh --no-restart` 部署前端。详见 zMainPage 的 [`docs/deployment.md`](https://git.zikai.wang/zikai/zMainPage/src/branch/main/docs/deployment.md)。 ## 了解更多
- [路由与访问入口](./docs/routes.md)
- [配置说明](./docs/configuration.md)
- [错误处理与日志约定](./docs/error-handling.md)

View File

@@ -39,7 +39,6 @@ def _resolve_cookie(request: Request, zk_pdf: str | None = Cookie(default=None))
cookie 缺失时把新值挂到 request.state供响应阶段 set_cookie。 cookie 缺失时把新值挂到 request.state供响应阶段 set_cookie。
""" """
cfg = get_settings().pdf
if zk_pdf and len(zk_pdf) == 32: if zk_pdf and len(zk_pdf) == 32:
return zk_pdf return zk_pdf
new = new_owner_cookie() new = new_owner_cookie()

View File

@@ -228,12 +228,12 @@ def _parse(raw: str) -> dict | None:
async def _safe_send(ws: WebSocket, msg: dict) -> None: async def _safe_send(ws: WebSocket, msg: dict) -> None:
try: try:
await ws.send_json(msg) await ws.send_json(msg)
except Exception: # pragma: no cover except Exception as exc: # pragma: no cover
pass logger.debug("发送 WS 消息失败: %s", exc)
async def _safe_close(ws: WebSocket) -> None: async def _safe_close(ws: WebSocket) -> None:
try: try:
await ws.close() await ws.close()
except Exception: # pragma: no cover except Exception as exc: # pragma: no cover
pass logger.debug("关闭 WS 失败: %s", exc)

View File

@@ -6,8 +6,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -33,6 +31,11 @@ class PdfJobDAO:
self.db.refresh(job) self.db.refresh(job)
return job return job
def delete(self, job: PdfJob) -> None:
"""硬删 PdfJob 行(管理视角真正删除,不可恢复)。"""
self.db.delete(job)
self.db.commit()
def list_for_user(self, owner_cookie: str, limit: int = 100, offset: int = 0) -> list[PdfJob]: def list_for_user(self, owner_cookie: str, limit: int = 100, offset: int = 0) -> list[PdfJob]:
"""用户视角:仅未软删的任务,按创建时间倒序。""" """用户视角:仅未软删的任务,按创建时间倒序。"""
stmt = ( stmt = (

View File

@@ -31,15 +31,6 @@ class TunnelSessionDAO:
) )
return self.db.scalars(stmt).first() return self.db.scalars(stmt).first()
def get_active_by_port(self, tunnel_port: int) -> TunnelSession | None:
stmt = (
select(TunnelSession)
.where(TunnelSession.tunnel_port == tunnel_port)
.where(TunnelSession.status == "active")
.limit(1)
)
return self.db.scalars(stmt).first()
def list_active(self) -> list[TunnelSession]: def list_active(self) -> list[TunnelSession]:
stmt = select(TunnelSession).where(TunnelSession.status == "active") stmt = select(TunnelSession).where(TunnelSession.status == "active")
return list(self.db.scalars(stmt).all()) return list(self.db.scalars(stmt).all())

View File

@@ -6,11 +6,16 @@ get_or_create 用于「访问即新建」语义(路由 GET /api/wb/{id} 不存
from __future__ import annotations from __future__ import annotations
import logging
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..models.whiteboard import Whiteboard from ..models.whiteboard import Whiteboard
logger = logging.getLogger("zikai.whiteboard")
class WhiteboardDAO: class WhiteboardDAO:
def __init__(self, db: Session) -> None: def __init__(self, db: Session) -> None:
@@ -27,14 +32,18 @@ class WhiteboardDAO:
return self.db.scalars(stmt).first() return self.db.scalars(stmt).first()
def get_or_create(self, board_id: str) -> Whiteboard: def get_or_create(self, board_id: str) -> Whiteboard:
"""存在则返回,否则新建空板。利用 unique 约束兜底并发首访。""" """存在则返回,否则新建空板。利用 unique 约束兜底并发首访。
仅 IntegrityError并发下另一事务已插入违反唯一约束才回滚重读
其他异常向上抛,避免掩盖 schema/连接等真实故障。
"""
board = self.get(board_id) board = self.get(board_id)
if board is not None: if board is not None:
return board return board
board = Whiteboard(board_id=board_id, content="", version=0, edit_count=0) board = Whiteboard(board_id=board_id, content="", version=0, edit_count=0)
try: try:
return self.create(board) return self.create(board)
except Exception: except IntegrityError:
# 并发下另一事务已插入:回滚后重新读 # 并发下另一事务已插入:回滚后重新读
self.db.rollback() self.db.rollback()
return self.get(board_id) # type: ignore[return-value] return self.get(board_id) # type: ignore[return-value]

View File

@@ -64,7 +64,29 @@ def get_db() -> Generator[Session, None, None]:
def init_db_schema() -> None: def init_db_schema() -> None:
"""按需建表(幂等)。先导入 models 以注册映射。""" """按需建表(幂等)并校验既有表列与模型一致fail-fast on schema drift
先导入 models 注册映射create_all 用 IF NOT EXISTS 仅补缺失的表;
随后对每张已存在的表检查模型声明的列是否齐全,缺列即抛 RuntimeError
避免运行期才以晦涩的 OperationalError 暴露 schema 漂移。
"""
from sqlalchemy import inspect
from . import models # noqa: F401 from . import models # noqa: F401
get_engine() engine = get_engine()
Base.metadata.create_all(bind=_engine) Base.metadata.create_all(bind=engine)
inspector = inspect(engine)
missing: list[str] = []
for table, mapper in Base.registry.mappers.items():
if not inspector.has_table(table):
continue
db_cols = {c["name"] for c in inspector.get_columns(table)}
for model_col in mapper.columns.keys():
if model_col not in db_cols:
missing.append(f"{table}.{model_col}")
if missing:
raise RuntimeError(
"数据库 schema 与模型不一致,缺少列: " + ", ".join(missing)
+ "。请执行 sql/schema.sql 或迁移脚本更新表结构。"
)

View File

@@ -11,7 +11,6 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import shutil
import tempfile import tempfile
import zipfile import zipfile
from pathlib import Path from pathlib import Path

View File

@@ -26,7 +26,7 @@ from ..config import get_settings
from ..dao.pdf_job_dao import PdfJobDAO from ..dao.pdf_job_dao import PdfJobDAO
from ..dao.uploaded_file_dao import UploadedFileDAO from ..dao.uploaded_file_dao import UploadedFileDAO
from ..models.pdf_job import PdfJob from ..models.pdf_job import PdfJob
from ..schemas.pdf import PdfJobOut, PdfJobListResponse, PdfSubmitResponse from ..schemas.pdf import PdfJobOut, PdfJobListResponse
from . import pdf_converter from . import pdf_converter
from .upload_service import UploadService from .upload_service import UploadService
@@ -172,7 +172,6 @@ class PdfService:
# 落产物 UploadedFile复用 commit_entity 的原子改名 + 入库) # 落产物 UploadedFile复用 commit_entity 的原子改名 + 入库)
from ..models.uploaded_file import UploadedFile from ..models.uploaded_file import UploadedFile
import hashlib
size = part_path.stat().st_size size = part_path.stat().st_size
sha256 = self._hash_file(part_path) sha256 = self._hash_file(part_path)
entity = UploadedFile( entity = UploadedFile(
@@ -277,8 +276,7 @@ class PdfService:
if job.output_file_id is not None: if job.output_file_id is not None:
self._safe_delete_file(job.output_file_id) self._safe_delete_file(job.output_file_id)
# 删 PdfJob 行 # 删 PdfJob 行
self.job_dao.db.delete(job) self.job_dao.delete(job)
self.job_dao.db.commit()
logger.info("管理员硬删 PDF 任务 job=%s", job_id) logger.info("管理员硬删 PDF 任务 job=%s", job_id)
return True return True
@@ -293,7 +291,6 @@ class PdfService:
except Exception as exc: # pragma: no cover except Exception as exc: # pragma: no cover
logger.warning("删除文件失败 file_id=%s path=%s: %s", file_id, path, exc) logger.warning("删除文件失败 file_id=%s path=%s: %s", file_id, path, exc)
try: try:
self.file_dao.db.delete(row) self.file_dao.delete(file_id)
self.file_dao.db.commit()
except Exception as exc: # pragma: no cover except Exception as exc: # pragma: no cover
logger.warning("删除文件 DB 行失败 file_id=%s: %s", file_id, exc) logger.warning("删除文件 DB 行失败 file_id=%s: %s", file_id, exc)

View File

@@ -27,8 +27,9 @@ class ZikaiSFTPServer(asyncssh.SFTPServer):
super().__init__(chan, chroot=str(upload_root).encode()) super().__init__(chan, chroot=str(upload_root).encode())
try: try:
self._username = chan.get_extra_info("username") or "unknown" self._username = chan.get_extra_info("username") or "unknown"
except Exception: # pragma: no cover except Exception as exc: # pragma: no cover
self._username = "unknown" self._username = "unknown"
logger.debug("读取 SFTP 会话用户名失败: %s", exc)
logger.info("SFTP 会话开始 user=%s chroot=%s", self._username, upload_root) logger.info("SFTP 会话开始 user=%s chroot=%s", self._username, upload_root)
def exit(self) -> None: def exit(self) -> None:
@@ -45,8 +46,8 @@ def _tunnel_dao():
def _close_tunnel_dao(dao) -> None: def _close_tunnel_dao(dao) -> None:
try: try:
dao.db.close() dao.db.close()
except Exception: # pragma: no cover except Exception as exc: # pragma: no cover
pass logger.debug("关闭隧道 DAO 会话失败: %s", exc)
class ZikaiSSHServer(asyncssh.SSHServer): class ZikaiSSHServer(asyncssh.SSHServer):
@@ -101,7 +102,8 @@ class ZikaiSSHServer(asyncssh.SSHServer):
try: try:
# asyncssh 命中返回 dict可能为空未命中返回 None # asyncssh 命中返回 dict可能为空未命中返回 None
result = self._authorized_keys.validate(key, client_host=addr, client_addr=addr) result = self._authorized_keys.validate(key, client_host=addr, client_addr=addr)
except Exception: except Exception as exc: # pragma: no cover
logger.warning("公钥校验异常 user=%s: %s", username, exc)
result = None result = None
ok = result is not None ok = result is not None
if ok: if ok:

View File

@@ -52,11 +52,6 @@ class TunnelService:
def get_active(self, user_name: str) -> TunnelSession | None: def get_active(self, user_name: str) -> TunnelSession | None:
return self.dao.get_active_by_user(user_name) return self.dao.get_active_by_user(user_name)
def is_port_allowed(self, user_name: str, tunnel_port: int) -> bool:
"""校验该 user 是否被允许绑定该隧道端口(防 user 乱绑端口)。"""
user = self.settings.find_user(user_name)
return user is not None and user.tunnel_port == tunnel_port
def reap_orphans(self) -> int: def reap_orphans(self) -> int:
"""兜底清理:关闭所有 active 会话(进程重启时 DB 里残留的孤儿记录)。 """兜底清理:关闭所有 active 会话(进程重启时 DB 里残留的孤儿记录)。

View File

@@ -92,8 +92,9 @@ class WhiteboardHub:
# 尽力关闭 websocket可能已关闭 # 尽力关闭 websocket可能已关闭
try: try:
await conn.websocket.close() await conn.websocket.close()
except Exception: # pragma: no cover except Exception as exc: # pragma: no cover
pass logger.debug("关闭 websocket 时出错 board=%s client=%s: %s",
conn.board_id, conn.client_id, exc)
logger.info("连接移除 board=%s client=%s(剩余 %d 人)", logger.info("连接移除 board=%s client=%s(剩余 %d 人)",
conn.board_id, conn.client_id, self.connection_count(conn.board_id)) conn.board_id, conn.client_id, self.connection_count(conn.board_id))
@@ -161,8 +162,9 @@ class WhiteboardHub:
for conn in conns: for conn in conns:
try: try:
await conn.websocket.close() await conn.websocket.close()
except Exception: # pragma: no cover except Exception as exc: # pragma: no cover
pass logger.debug("关闭 websocket 时出错 board=%s client=%s: %s",
conn.board_id, conn.client_id, exc)
logger.info("关闭白板 board=%s,踢出 %d 个连接", board_id, len(conns)) logger.info("关闭白板 board=%s,踢出 %d 个连接", board_id, len(conns))
@@ -179,10 +181,3 @@ def get_hub() -> WhiteboardHub:
if _hub is None: if _hub is None:
_hub = WhiteboardHub() _hub = WhiteboardHub()
return _hub return _hub
def reset_hub() -> None:
"""测试用:重置单例。"""
global _hub
with _hub_lock:
_hub = None

View File

@@ -6,8 +6,6 @@
from __future__ import annotations from __future__ import annotations
from html import escape
# 默认分片大小 4 MiB大于 Apache 300s 限制下单片可数秒传完,小到内存恒定。 # 默认分片大小 4 MiB大于 Apache 300s 限制下单片可数秒传完,小到内存恒定。
DEFAULT_CHUNK_SIZE = 4 * 1024 * 1024 DEFAULT_CHUNK_SIZE = 4 * 1024 * 1024
# 同一文件分片并发数 # 同一文件分片并发数

39
docs/configuration.md Normal file
View File

@@ -0,0 +1,39 @@
# 配置说明
所有运行时配置在 `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``/api/files-page``/api/wb-admin``/api/pdf-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 |
## 生成 bcrypt hashSFTP/隧道用户密码)
```bash
.venv/bin/python -c "import bcrypt;print(bcrypt.hashpw(b'yourpass',bcrypt.gensalt()).decode())"
```
把输出填入 `config.yaml` 对应 `password_hash` 字段。
## 重新生成 DB 密码
```bash
.venv/bin/python -m app.scripts.init_db # 重置密码(写回 config.yaml
KEEP_DB_PASSWORD=1 .venv/bin/python -m app.scripts.init_db # 保留现有密码,仅建库建账
```

29
docs/error-handling.md Normal file
View File

@@ -0,0 +1,29 @@
# 错误处理与日志约定
zTools2 采用 Spring 风格分层架构错误处理分三层DAO fail-fast抛异常Service 捕获后转换业务异常并记日志Controller 捕获后转 HTTP 状态码。
## DAO 层app/dao/
- **唯一发 SQL 的层**所有写操作create/update/delete均在该层 commitservice 不直接操作 session。
- `get_or_create`whiteboard_dao.py`IntegrityError`(并发下另一事务已插入违反唯一约束)才回滚重读;其他异常向上抛,避免掩盖 schema/连接等真实故障。
- `delete`pdf_job_dao.py / uploaded_file_dao.py硬删 DB 行并 commit不存在返回 False。
- schema 漂移检测:`init_db_schema`database.py建表后用 inspector 检查既有表的列是否与模型声明齐全,缺列即抛 `RuntimeError`fail-fast避免运行期才以晦涩的 `OperationalError` 暴露。
## Service 层app/services/
- **PdfService**`submit` 校验扩展名/大小,超限清理已落盘文件后抛 `HTTPException`;后台转换 `_convert_async` 捕获 `TimeoutError` / 通用异常,经 `_mark_failed` 落库 + `logger.warning``admin_delete` 磁盘删除失败仅 `logger.warning`,仍清 DB 行保证列表不再显示。
- **UploadService / ChunkUploadService**:流式落盘出错清理临时文件后 `raise`(向上传播);分片会话被放弃由后台 reaper 每 60s 清理。
- **WhiteboardHub**`disconnect` / `close_board` 关闭 websocket 出错 `logger.debug`(尽力关闭,可能已关闭);`broadcast` 单连接发送失败立即 disconnect不影响其他连接reaper 循环异常 `logger.warning` 后继续。
- **TunnelService**`register` / `close` / `reap_orphans` 均记 `logger.info`SSH 连接断开时清理会话失败 `logger.warning`
- **sftp_server**`validate_public_key` 校验异常 `logger.warning`auth 路径,避免静默失败);`_close_tunnel_dao` / 读会话用户名失败 `logger.debug`(尽力清理)。
## Controller 层app/controllers/
- **pdf_controller**`_resolve_cookie` 解析用户 cookie无则生成新值挂 request.state 供响应 set_cookie。
- **whiteboard_controller**`_safe_send` / `_safe_close` 发送/关闭 WS 失败 `logger.debug`best-effortWS 主循环异常 `logger.warning` 后正常关闭连接。
- 所有管理 API`/api/admin/*`)经 `require_docs_auth` Basic Auth 守卫(常量时间比较)。
## 日志位置
- `logs/app.log`HTTP`logs/sftp.log`SFTPpidfile`app.pid``sftp.pid`
- 日志器命名:`zikai.pdf` / `zikai.whiteboard` / `zikai.tunnel` / `sftp`,便于按模块过滤。

50
docs/routes.md Normal file
View File

@@ -0,0 +1,50 @@
# 路由与访问入口
## 路由约定
**所有 zTools2 托管的入口(页面 / 静态资源 / 健康探针 / WebSocket / REST API统一挂在 `/api/` 前缀下**,只有元信息/文档例外(`/``/docs``/redoc``/openapi.json`)。这样反向代理与 vite dev proxy 都只需一条 `/api/` 规则即可把请求转给当前环境的 zTools2前端用**同源相对路径**(如 `/api/pdf/jobs``/api/health`)即可,与运行环境(本地 / 测试 / 生产)无关,无需区分 dev/prod 指向。
> PDF 转换的用户侧 UI 由 zMainPage 的 zPDF_package 组件提供(构建期 import非 iframezTools2 仅提供 `/api/pdf/jobs` 等 REST API 与 `/api/pdf-admin` 管理页。
页面类入口为避免与同名 REST API 冲突,统一加 `-page` 后缀:
| 类型 | 页面入口 | REST API同名不加后缀 |
|------|---------|------------------------|
| 文件浏览 | `GET /api/files-page` | `GET /api/files``/api/files/{id}` 等 |
| 记事本 | `GET /api/wb-page/{id}` | `GET /api/wb/{id}` |
其余入口:`/api/health`(探针)、`/api/static/*`JS/CSS`/api/ws/wb/{id}`WebSocket`/api/upload``/api/pdf-admin``/api/wb-admin`
## 功能一览
| 模块 | 页面 / 接口 | 鉴权 |
|------|------------|------|
| 文件上传 | `POST /api/files/upload`(流式)/ `POST /api/files/chunk-uploads/*`(分片+断点续传) | 公开 |
| 上传页 | `GET /api/upload`(拖拽/多文件/分片/去重) | 公开 |
| 文件浏览 | `GET /api/files-page`(多选/批量下载删除/分页) | Basic Auth |
| 文件管理 API | `GET /api/admin/files``GET/DELETE /api/admin/files/{id}``GET /api/admin/files/{id}/download` | Basic Auth |
| 共享记事本 | `GET /api/wb-page/{id}`(公开,不存在则新建) | 公开 |
| 记事本实时同步 | `WS /api/ws/wb/{id}`(心跳 3s5 次失活移除) | 公开 |
| 记事本管理 | `GET /api/wb-admin`(查看/删除) | Basic Auth |
| 记事本管理 API | `GET /api/admin/wb``DELETE /api/admin/wb/{id}` | Basic Auth |
| PDF 转换 API | `POST /api/pdf/jobs``GET /api/pdf/jobs[/{id}]``GET /api/pdf/jobs/{id}/download``DELETE /api/pdf/jobs/{id}` | 公开cookie |
| PDF 转换管理 | `GET /api/pdf-admin`(全部任务,含已软删标记,硬删) | Basic Auth |
| PDF 转换管理 API | `GET /api/admin/pdf/jobs``DELETE /api/admin/pdf/jobs/{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 |
## 访问入口
| 入口 | URL |
|------|-----|
| API 文档 | https://f.zikai.wang/docsBasic Auth |
| 上传页 | https://f.zikai.wang/api/upload |
| 文件浏览 | https://f.zikai.wang/api/files-pageBasic Auth |
| 共享记事本 | https://f.zikai.wang/api/wb-page/{id}(公开,`{id}``[a-zA-Z0-9_-]{1,64}` |
| 记事本管理 | https://f.zikai.wang/api/wb-adminBasic Auth |
| PDF 转换 | 经 zMainPage 的 PDF 页签zPDF_package 组件)调用 `/api/pdf/jobs` 等 REST API公开凭 cookie |
| 系统状态 | 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` |