diff --git a/README.md b/README.md index d1886a8..59cf9e0 100644 --- a/README.md +++ b/README.md @@ -1,125 +1,47 @@ -# zikai file service +# zTools2 - 个人 Web 服务后端 -基于 FastAPI 的个人 Web 服务,提供**文件上传/浏览/下载、共享记事本(实时协作)、 -主机监控、SFTP 暂存、反向隧道、PDF 转换**。采用 Spring 风格分层架构,自带 API 文档。 +基于 FastAPI 的个人 Web 服务后端,提供文件上传/浏览/下载、共享记事本(实时协作)、主机监控、SFTP 暂存、反向隧道、PDF 转换。采用 Spring 风格分层架构(controller -> service -> dao -> ORM model -> MySQL),自带 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}`(心跳 3s,5 次失活移除) | 公开 | -| 记事本管理 | `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,非 iframe);zTools2 仅提供 `/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`。 +所有入口统一挂在 `/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` 反代到本服务。 ## 项目结构 ``` -server/ +zTools2/ ├── app/ │ ├── main.py # FastAPI 应用工厂、路由注册、生命周期(reaper) │ ├── config.py # 从 config.yaml 加载的类型化 Settings(pydantic-settings) -│ ├── database.py # SQLAlchemy 引擎/Session/Base/get_db 依赖 +│ ├── database.py # SQLAlchemy 引擎/Session/Base/get_db 依赖 + schema 校验 │ ├── security.py # Basic Auth(require_docs_auth,常量时间比较) -│ ├── controllers/ # 路由层(@RestController):file/system/chunk/tunnel/whiteboard/pdf/admin -│ ├── services/ # 业务层:UploadService/ChunkUploadService/SystemService/ -│ │ # WhiteboardService/WhiteboardHub/TunnelService/sftp_server -│ │ # PdfService(转换编排)+ pdf_converter(epub->pdf 纯 Python) +│ ├── controllers/ # 路由层:file/system/chunk/tunnel/whiteboard/pdf/admin +│ ├── services/ # 业务层:Upload/ChunkUpload/System/Whiteboard/Tunnel/Pdf/sftp │ ├── dao/ # 数据访问层:唯一发 SQL 的层(SQLAlchemy ORM 参数化) │ ├── models/ # ORM 实体:UploadedFile/UploadSession/Whiteboard/TunnelSession/PdfJob │ ├── schemas/ # pydantic 请求/响应 DTO │ ├── views/ # 服务端渲染 HTML(系统状态页、上传页) -│ ├── static/ # 前端静态资源(common + file_browser + whiteboard + pdf + pdf_admin) +│ ├── static/ # 前端静态资源 │ └── 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 +├── start.sh / stop.sh # 启停 HTTP(127.0.0.1:6867)+ SFTP(2022) +└── deploy/ # systemd 持久化部署 ``` -**请求流程**:`controller → service → dao → ORM model → MySQL`。 -DB Session 由 `get_db` 依赖注入。前端页面走「StaticFiles 挂载 + 具名 HTML 路由」前后端分离,JS 调同源 `/api/...`。 +**请求流程**:`controller -> service -> dao -> ORM model -> MySQL`。DB Session 由 `get_db` 依赖注入。 -## 从零安装(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 -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 /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 反向代理 +### Apache2 反向代理配置 服务只绑 `127.0.0.1:6867`,通过 Apache 对外提供 HTTPS。安装模块并配置 vhost: @@ -154,62 +76,49 @@ systemctl reload apache2 > **防火墙**:放开 443(HTTPS)与 2022(SFTP)。6867 不对外(仅 loopback)。 > **大文件上传**:Apache 全局 `Timeout 300`,慢链路建议走分片上传(`/api/upload`)或 SFTP。 -## 配置说明 +## 从零安装(Ubuntu 22.04+) -所有运行时配置在 `config.yaml`(git-ignored)。完整 schema 见 `config.example.yaml`。 +### 1. 安装系统依赖 -| 段 | 关键项 | 说明 | -|----|--------|------| -| `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) | +```bash +apt update +apt install -y python3-venv python3-pip mysql-server apache2 \ + libssl-dev build-essential # build-essential 给 bcrypt/asyncssh 编译 +# PDF 转换依赖 weasyprint,需 pango/cairo 系统库: +apt install -y libpango-1.0-0 libpangoft2-1.0-0 libcairo2 libgdk-pixbuf-2.0-0 +``` -## 访问入口 +### 2. 获取代码并初始化 -| 入口 | URL | -|------|-----| -| API 文档 | https://f.zikai.wang/docs(Basic Auth) | -| 上传页 | https://f.zikai.wang/api/upload | -| 文件浏览 | https://f.zikai.wang/api/files-page(Basic Auth) | -| 共享记事本 | https://f.zikai.wang/api/wb-page/{id}(公开,`{id}` 为 `[a-zA-Z0-9_-]{1,64}`) | -| 记事本管理 | https://f.zikai.wang/api/wb-admin(Basic Auth) | -| PDF 转换 | 经 zMainPage 的 PDF 页签(zPDF_package 组件)调用 `/api/pdf/jobs` 等 REST API(公开,凭 cookie) | -| 系统状态 | 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` | +```bash +git clone /root/zikai +cd /root/zikai/zTools2 +./setup.sh # 创建 .venv + 装依赖 + 复制 config.yaml + 建 MySQL 库账 + 生成 SFTP 密钥 +``` -## 运维 +### 3. 配置凭据 -- **日志**:`logs/app.log`(HTTP)、`logs/sftp.log`(SFTP);pidfile:`app.pid`、`sftp.pid`。 -- **临时文件清理**:分片上传完成后立即删 `.work//`;被放弃会话(`pending` 超 5 分钟)由后台 reaper 每 60s 清理;`start.sh` 启动时兜底清残留。 -- **重新生成 DB 密码**:`.venv/bin/python -m app.scripts.init_db`(保留现有:`KEEP_DB_PASSWORD=1`)。 -- **多 worker 限制**:记事本 hub 是进程内存,多 uvicorn worker 下不互通,保持 `workers: 1`。 -- **数据库表**:ORM 启动时自动建表(`init_db_schema`);`sql/schema.sql` 供参考/手动初始化。 +编辑 `config.yaml`(详见 [`docs/configuration.md`](./docs/configuration.md)):`docs.username/password`(管理页 Basic Auth)、`sftp.users[].password_hash`(bcrypt)、可选 `tunnel.users[]`。 -## 持久化部署(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 ./deploy/install-systemd.sh # 安装并启动 ztools2 服务(开机自启) -systemctl status ztools2 # 状态 -journalctl -u ztools2 -f # 日志 -sudo systemctl restart ztools2 # 重启(更新代码后) +systemctl status ztools2 +journalctl -u ztools2 -f ``` -脚本自动检测 `.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) diff --git a/app/controllers/pdf_controller.py b/app/controllers/pdf_controller.py index 94ce55a..3a07292 100644 --- a/app/controllers/pdf_controller.py +++ b/app/controllers/pdf_controller.py @@ -39,7 +39,6 @@ def _resolve_cookie(request: Request, zk_pdf: str | None = Cookie(default=None)) cookie 缺失时把新值挂到 request.state,供响应阶段 set_cookie。 """ - cfg = get_settings().pdf if zk_pdf and len(zk_pdf) == 32: return zk_pdf new = new_owner_cookie() diff --git a/app/controllers/whiteboard_controller.py b/app/controllers/whiteboard_controller.py index 55424f1..64b5191 100644 --- a/app/controllers/whiteboard_controller.py +++ b/app/controllers/whiteboard_controller.py @@ -228,12 +228,12 @@ def _parse(raw: str) -> dict | None: async def _safe_send(ws: WebSocket, msg: dict) -> None: try: await ws.send_json(msg) - except Exception: # pragma: no cover - pass + except Exception as exc: # pragma: no cover + logger.debug("发送 WS 消息失败: %s", exc) async def _safe_close(ws: WebSocket) -> None: try: await ws.close() - except Exception: # pragma: no cover - pass + except Exception as exc: # pragma: no cover + logger.debug("关闭 WS 失败: %s", exc) diff --git a/app/dao/pdf_job_dao.py b/app/dao/pdf_job_dao.py index 0ec55c7..8216afd 100644 --- a/app/dao/pdf_job_dao.py +++ b/app/dao/pdf_job_dao.py @@ -6,8 +6,6 @@ from __future__ import annotations -from datetime import datetime - from sqlalchemy import func, select from sqlalchemy.orm import Session @@ -33,6 +31,11 @@ class PdfJobDAO: self.db.refresh(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]: """用户视角:仅未软删的任务,按创建时间倒序。""" stmt = ( diff --git a/app/dao/tunnel_session_dao.py b/app/dao/tunnel_session_dao.py index 040c14e..db1818a 100644 --- a/app/dao/tunnel_session_dao.py +++ b/app/dao/tunnel_session_dao.py @@ -31,15 +31,6 @@ class TunnelSessionDAO: ) 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]: stmt = select(TunnelSession).where(TunnelSession.status == "active") return list(self.db.scalars(stmt).all()) diff --git a/app/dao/whiteboard_dao.py b/app/dao/whiteboard_dao.py index ad92cfd..0c688ba 100644 --- a/app/dao/whiteboard_dao.py +++ b/app/dao/whiteboard_dao.py @@ -6,11 +6,16 @@ get_or_create 用于「访问即新建」语义(路由 GET /api/wb/{id} 不存 from __future__ import annotations +import logging + from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from ..models.whiteboard import Whiteboard +logger = logging.getLogger("zikai.whiteboard") + class WhiteboardDAO: def __init__(self, db: Session) -> None: @@ -27,14 +32,18 @@ class WhiteboardDAO: return self.db.scalars(stmt).first() def get_or_create(self, board_id: str) -> Whiteboard: - """存在则返回,否则新建空板。利用 unique 约束兜底并发首访。""" + """存在则返回,否则新建空板。利用 unique 约束兜底并发首访。 + + 仅 IntegrityError(并发下另一事务已插入违反唯一约束)才回滚重读; + 其他异常向上抛,避免掩盖 schema/连接等真实故障。 + """ board = self.get(board_id) if board is not None: return board board = Whiteboard(board_id=board_id, content="", version=0, edit_count=0) try: return self.create(board) - except Exception: + except IntegrityError: # 并发下另一事务已插入:回滚后重新读 self.db.rollback() return self.get(board_id) # type: ignore[return-value] diff --git a/app/database.py b/app/database.py index da6df99..ef4d41b 100644 --- a/app/database.py +++ b/app/database.py @@ -64,7 +64,29 @@ def get_db() -> Generator[Session, None, 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 - get_engine() - Base.metadata.create_all(bind=_engine) + engine = get_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 或迁移脚本更新表结构。" + ) diff --git a/app/services/pdf_converter.py b/app/services/pdf_converter.py index ca0c5c6..c95427b 100644 --- a/app/services/pdf_converter.py +++ b/app/services/pdf_converter.py @@ -11,7 +11,6 @@ from __future__ import annotations import logging -import shutil import tempfile import zipfile from pathlib import Path diff --git a/app/services/pdf_service.py b/app/services/pdf_service.py index 0785e5e..d6a7a8e 100644 --- a/app/services/pdf_service.py +++ b/app/services/pdf_service.py @@ -26,7 +26,7 @@ from ..config import get_settings from ..dao.pdf_job_dao import PdfJobDAO from ..dao.uploaded_file_dao import UploadedFileDAO from ..models.pdf_job import PdfJob -from ..schemas.pdf import PdfJobOut, PdfJobListResponse, PdfSubmitResponse +from ..schemas.pdf import PdfJobOut, PdfJobListResponse from . import pdf_converter from .upload_service import UploadService @@ -172,7 +172,6 @@ class PdfService: # 落产物 UploadedFile(复用 commit_entity 的原子改名 + 入库) from ..models.uploaded_file import UploadedFile - import hashlib size = part_path.stat().st_size sha256 = self._hash_file(part_path) entity = UploadedFile( @@ -277,8 +276,7 @@ class PdfService: if job.output_file_id is not None: self._safe_delete_file(job.output_file_id) # 删 PdfJob 行 - self.job_dao.db.delete(job) - self.job_dao.db.commit() + self.job_dao.delete(job) logger.info("管理员硬删 PDF 任务 job=%s", job_id) return True @@ -293,7 +291,6 @@ class PdfService: except Exception as exc: # pragma: no cover logger.warning("删除文件失败 file_id=%s path=%s: %s", file_id, path, exc) try: - self.file_dao.db.delete(row) - self.file_dao.db.commit() + self.file_dao.delete(file_id) except Exception as exc: # pragma: no cover logger.warning("删除文件 DB 行失败 file_id=%s: %s", file_id, exc) diff --git a/app/services/sftp_server.py b/app/services/sftp_server.py index 7cb30a3..bcbb411 100644 --- a/app/services/sftp_server.py +++ b/app/services/sftp_server.py @@ -27,8 +27,9 @@ class ZikaiSFTPServer(asyncssh.SFTPServer): super().__init__(chan, chroot=str(upload_root).encode()) try: self._username = chan.get_extra_info("username") or "unknown" - except Exception: # pragma: no cover + except Exception as exc: # pragma: no cover self._username = "unknown" + logger.debug("读取 SFTP 会话用户名失败: %s", exc) logger.info("SFTP 会话开始 user=%s chroot=%s", self._username, upload_root) def exit(self) -> None: @@ -45,8 +46,8 @@ def _tunnel_dao(): def _close_tunnel_dao(dao) -> None: try: dao.db.close() - except Exception: # pragma: no cover - pass + except Exception as exc: # pragma: no cover + logger.debug("关闭隧道 DAO 会话失败: %s", exc) class ZikaiSSHServer(asyncssh.SSHServer): @@ -101,7 +102,8 @@ class ZikaiSSHServer(asyncssh.SSHServer): try: # asyncssh 命中返回 dict(可能为空),未命中返回 None 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 ok = result is not None if ok: diff --git a/app/services/tunnel_service.py b/app/services/tunnel_service.py index 69830c3..7b1819f 100644 --- a/app/services/tunnel_service.py +++ b/app/services/tunnel_service.py @@ -52,11 +52,6 @@ class TunnelService: def get_active(self, user_name: str) -> TunnelSession | None: 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: """兜底清理:关闭所有 active 会话(进程重启时 DB 里残留的孤儿记录)。 diff --git a/app/services/whiteboard_hub.py b/app/services/whiteboard_hub.py index e11b622..3a28f84 100644 --- a/app/services/whiteboard_hub.py +++ b/app/services/whiteboard_hub.py @@ -92,8 +92,9 @@ class WhiteboardHub: # 尽力关闭 websocket(可能已关闭) try: await conn.websocket.close() - except Exception: # pragma: no cover - pass + except Exception as exc: # pragma: no cover + logger.debug("关闭 websocket 时出错 board=%s client=%s: %s", + conn.board_id, conn.client_id, exc) logger.info("连接移除 board=%s client=%s(剩余 %d 人)", conn.board_id, conn.client_id, self.connection_count(conn.board_id)) @@ -161,8 +162,9 @@ class WhiteboardHub: for conn in conns: try: await conn.websocket.close() - except Exception: # pragma: no cover - pass + except Exception as exc: # pragma: no cover + logger.debug("关闭 websocket 时出错 board=%s client=%s: %s", + conn.board_id, conn.client_id, exc) logger.info("关闭白板 board=%s,踢出 %d 个连接", board_id, len(conns)) @@ -179,10 +181,3 @@ def get_hub() -> WhiteboardHub: if _hub is None: _hub = WhiteboardHub() return _hub - - -def reset_hub() -> None: - """测试用:重置单例。""" - global _hub - with _hub_lock: - _hub = None diff --git a/app/views/upload_html.py b/app/views/upload_html.py index 659adb9..d495341 100644 --- a/app/views/upload_html.py +++ b/app/views/upload_html.py @@ -6,8 +6,6 @@ from __future__ import annotations -from html import escape - # 默认分片大小 4 MiB:大于 Apache 300s 限制下单片可数秒传完,小到内存恒定。 DEFAULT_CHUNK_SIZE = 4 * 1024 * 1024 # 同一文件分片并发数 diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..7877134 --- /dev/null +++ b/docs/configuration.md @@ -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 hash(SFTP/隧道用户密码) + +```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 # 保留现有密码,仅建库建账 +``` diff --git a/docs/error-handling.md b/docs/error-handling.md new file mode 100644 index 0000000..718f67f --- /dev/null +++ b/docs/error-handling.md @@ -0,0 +1,29 @@ +# 错误处理与日志约定 + +zTools2 采用 Spring 风格分层架构,错误处理分三层:DAO fail-fast(抛异常),Service 捕获后转换业务异常并记日志,Controller 捕获后转 HTTP 状态码。 + +## DAO 层(app/dao/) + +- **唯一发 SQL 的层**:所有写操作(create/update/delete)均在该层 commit,service 不直接操作 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-effort);WS 主循环异常 `logger.warning` 后正常关闭连接。 +- 所有管理 API(`/api/admin/*`)经 `require_docs_auth` Basic Auth 守卫(常量时间比较)。 + +## 日志位置 + +- `logs/app.log`(HTTP)、`logs/sftp.log`(SFTP);pidfile:`app.pid`、`sftp.pid`。 +- 日志器命名:`zikai.pdf` / `zikai.whiteboard` / `zikai.tunnel` / `sftp`,便于按模块过滤。 diff --git a/docs/routes.md b/docs/routes.md new file mode 100644 index 0000000..f905dfa --- /dev/null +++ b/docs/routes.md @@ -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,非 iframe);zTools2 仅提供 `/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}`(心跳 3s,5 次失活移除) | 公开 | +| 记事本管理 | `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/docs(Basic Auth) | +| 上传页 | https://f.zikai.wang/api/upload | +| 文件浏览 | https://f.zikai.wang/api/files-page(Basic Auth) | +| 共享记事本 | https://f.zikai.wang/api/wb-page/{id}(公开,`{id}` 为 `[a-zA-Z0-9_-]{1,64}`) | +| 记事本管理 | https://f.zikai.wang/api/wb-admin(Basic Auth) | +| PDF 转换 | 经 zMainPage 的 PDF 页签(zPDF_package 组件)调用 `/api/pdf/jobs` 等 REST API(公开,凭 cookie) | +| 系统状态 | 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` |