From 5d6ac5c7365cc9a6e520e6fcf81738018e8a29d8 Mon Sep 17 00:00:00 2001 From: shy3130 Date: Fri, 23 Oct 2026 22:00:51 +0800 Subject: [PATCH] =?UTF-8?q?fix(logs):=20=E6=97=A5=E5=BF=97=E5=BD=92?= =?UTF-8?q?=E6=8B=A2=20data/logs/=20=E5=B9=B6=E4=B8=BA=E6=A1=8C=E9=9D=A2?= =?UTF-8?q?=E7=89=88=20desktop.log=20=E5=A2=9E=E5=8A=A0=E5=A4=A7=E5=B0=8F?= =?UTF-8?q?=E5=88=87=E5=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 app/logfile.py attach_rotating_log: 统一 data/logs/ 子目录 + RotatingFileHandler + 旧位置日志一次性迁移 (目标已存在/被占用则跳过, 失败仅 warning 不阻断启动) - desktop.py: desktop.log 由无限追加 FileHandler 改为 10MB×5 切割 (长期挂机不再无限增长); 失败页与错误提示的用户可见路径同步 data\logs\desktop.log - main.py: backend.log 改用共享函数, 参数不变 (10MB×3), 目录随迁 - README/dev.ps1 排障路径同步; 新增 test_logging_setup.py 4 例 (迁移/冷启动/不覆盖/不可写容错) --- README.md | 4 +- backend/app/desktop.py | 33 +++--------- backend/app/logfile.py | 60 +++++++++++++++++++++ backend/app/main.py | 22 ++------ backend/tests/test_logging_setup.py | 84 +++++++++++++++++++++++++++++ dev.ps1 | 2 +- 6 files changed, 160 insertions(+), 45 deletions(-) create mode 100644 backend/app/logfile.py create mode 100644 backend/tests/test_logging_setup.py diff --git a/README.md b/README.md index 63978118..b829db8c 100644 --- a/README.md +++ b/README.md @@ -495,7 +495,7 @@ Windows 启动器的可用参数: ``` - **停止请用 Ctrl-C**,不要点窗口右上角的 X —— 只有 Ctrl-C 会走到清理逻辑,结束整棵进程树。 -- 子进程输出重定向到 `data/logs/{backend,frontend}.{out,err}.log`,启动器本身只做有界 tail,内存占用恒定。排查问题优先看这几个文件,后端的 `data/backend.log` 仍会单独轮转。 +- 子进程输出重定向到 `data/logs/{backend,frontend}.{out,err}.log`,启动器本身只做有界 tail,内存占用恒定。排查问题优先看这几个文件,后端自身的 `data/logs/backend.log` 会单独按大小轮转 (10MB×3)。 - 启动前会检测同端口是否已有本项目后端:有则直接拒绝启动并打印 PID,避免多开叠加内存(每个实例常驻 1GB 以上)。换端口调试的实例不会被误杀。 ### 方式 E:桌面客户端(Windows / macOS) @@ -551,7 +551,7 @@ POLARS_MAX_THREADS=2 # 主要收益项: 用约 40% 的构建耗时换 500 Select-Object ProcessId, ParentProcessId, CommandLine ``` -- **确认预热规模**:`data/backend.log` 中的 `enriched 历史缓存: N rows` 表示本次预热实际载入的行数,N 越大占用越高。历史数据较少时(例如首次只同步了几十天)内存会明显低于上表。 +- **确认预热规模**:`data/logs/backend.log` 中的 `enriched 历史缓存: N rows` 表示本次预热实际载入的行数,N 越大占用越高。历史数据较少时(例如首次只同步了几十天)内存会明显低于上表。 ### 跑起来后的第一次使用 diff --git a/backend/app/desktop.py b/backend/app/desktop.py index a2a4006e..b654f06e 100644 --- a/backend/app/desktop.py +++ b/backend/app/desktop.py @@ -55,7 +55,7 @@ def _failed_html(reason: str | None = None) -> str:

{title}

{body} -

反复失败时查看日志:data\\desktop.log

+

反复失败时查看日志:data\\logs\\desktop.log

""" @@ -178,34 +178,17 @@ def _guard_streams() -> None: def _setup_logging() -> None: - """配置日志落盘到 data/desktop.log。 + """配置日志落盘到 data/logs/desktop.log (10MB×5 切割)。 背景: spec 里 console=False (桌面应用不弹黑窗), 导致 logging 默认输出的 stderr 被吞掉 —— 启动期任何异常用户都看不到, 表现为「双击一闪退出、查无日志」。 - 这里追加一个 FileHandler, 让日志同时落到 data_dir/desktop.log, 事后可查。 - - 时序注意: data_dir 在 import app.config 时路径已可用, 但目录此刻可能不存在 - (frozen 首次运行), 必须先 mkdir, 否则 FileHandler 打开文件会抛 FileNotFoundError。 - 不用第二次 basicConfig (它「首次调用才生效」), 改用 addHandler 追加。 + 这里经共享的 attach_rotating_log 追加轮转文件 handler, 事后可查; 桌面版 + 长期挂机, 不切割的追加日志会无限增长 (用户反馈的磁盘隐患)。 + 旧版的 data/desktop.log 由 attach_rotating_log 一次性迁入 data/logs/。 """ - try: - from app.config import settings + from app.logfile import attach_rotating_log - log_dir = settings.data_dir - log_dir.mkdir(parents=True, exist_ok=True) - handler = logging.FileHandler( - log_dir / "desktop.log", - mode="a", # 追加, 保留历史 (排查时往往需要对比多次启动) - encoding="utf-8", - errors="replace", # 容错: 对齐 __init__.py 的 stderr 重配, 避免中文/emoji 触发 UnicodeEncodeError - ) - handler.setFormatter( - logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s") - ) - logging.getLogger().addHandler(handler) - except Exception as e: # noqa: BLE001 - # 日志落盘失败不阻断启动 (开发模式 data_dir 可能不可写) - logger.warning("日志文件初始化失败, 仅输出到 stderr: %s", e) + attach_rotating_log("desktop.log", max_mb=10, backup_count=5) def _show_crash(title: str, text: str) -> None: @@ -447,7 +430,7 @@ def main() -> int: # ready 由后端线程 finally 置位 → 线程已退出时立刻返回, 不等满超时 if not _wait_for_server(port, timeout=60.0, done_event=ready): reason = ( - "后端进程已提前退出,详情见 data\\desktop.log" + "后端进程已提前退出,详情见 data\\logs\\desktop.log" if ready.is_set() else None ) logger.error("后端未就绪 (reason=%s), 桌面版退出", reason) diff --git a/backend/app/logfile.py b/backend/app/logfile.py new file mode 100644 index 00000000..ae377c60 --- /dev/null +++ b/backend/app/logfile.py @@ -0,0 +1,60 @@ +"""文件日志统一落盘: data/logs/ 子目录 + 大小切割 + 旧位置一次性迁移。 + +main.py (backend.log, 源码/服务器模式) 与 desktop.py:_setup_logging +(desktop.log, 桌面版) 共用。历史上两个文件直接写在 data/ 根下与业务数据 +混放, 且桌面版用普通 FileHandler 追加、不切割 —— 长期挂机的 desktop.log +会无限增长 (用户反馈的磁盘隐患)。 +""" +import logging +import os +from logging.handlers import RotatingFileHandler + +logger = logging.getLogger(__name__) + +# 迁移旧位置时最多回看的轮转编号 (RotatingFileHandler 现存产物 .1/.2/...) +_LEGACY_ROTATIONS = 5 + + +def _migrate_legacy(log_dir, filename: str) -> None: + """把 data/ 根下的旧日志 (含轮转副本 .1..N) 尽力搬进 logs/ 子目录。 + + 目标已存在或搬迁失败 (Windows 下被占用等) 一律跳过: 旧文件留在原地 + 无害, 新日志在 logs/ 重新开始, 绝不为搬文件冒启动风险。 + """ + for name in [filename] + [f"{filename}.{i}" for i in range(1, _LEGACY_ROTATIONS + 1)]: + src = log_dir.parent / name + if not src.is_file(): + continue + dst = log_dir / name + if dst.exists(): + continue + try: + os.replace(src, dst) + except OSError: + pass + + +def attach_rotating_log(filename: str, *, max_mb: int, backup_count: int) -> None: + """给 root logger 追加一个写 data/logs/ 的轮转文件 handler。 + + 任何失败只 warning 不抛 —— 日志落盘永远不阻断启动 + (对齐 main.py / desktop.py 两处调用点原有的容错语义)。 + """ + try: + from app.config import settings + + log_dir = settings.data_dir / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + _migrate_legacy(log_dir, filename) + handler = RotatingFileHandler( + log_dir / filename, + maxBytes=max_mb * 1024 * 1024, + backupCount=backup_count, + mode="a", encoding="utf-8", errors="replace", + ) + handler.setFormatter( + logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s") + ) + logging.getLogger().addHandler(handler) + except Exception as e: # noqa: BLE001 + logger.warning("文件日志初始化失败, 仅输出到终端: %s", e) diff --git a/backend/app/main.py b/backend/app/main.py index 1ef54036..1517fb33 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -71,25 +71,13 @@ logger = logging.getLogger(__name__) # 追加文件日志: uvicorn (含 --reload 开发模式) 默认只有 StreamHandler, 同步/管道等 # 运行时日志仅出现在 dev 终端, 关掉或滚屏后即丢失, 排查「同步后日志没落」时无处可查。 -# 落盘到 data/backend.log 与桌面版 (desktop.py:_setup_logging → desktop.log) 行为对齐, -# 事后可查。桌面版 (frozen) 已由 desktop.py 写 desktop.log, 此处跳过避免重复落盘。 -# RotatingFileHandler 防止长期运行/频繁 reload 导致文件无限增长。 +# 落盘到 data/logs/backend.log 与桌面版 (desktop.py:_setup_logging → logs/desktop.log) +# 行为对齐, 事后可查。桌面版 (frozen) 已由 desktop.py 写 desktop.log, 此处跳过避免 +# 重复落盘; 大小切割与旧位置迁移见 app/logfile.py。 if not getattr(sys, "frozen", False): - try: - from logging.handlers import RotatingFileHandler + from app.logfile import attach_rotating_log - _log_path = settings.data_dir / "backend.log" - _log_path.parent.mkdir(parents=True, exist_ok=True) - _file_handler = RotatingFileHandler( - _log_path, maxBytes=10 * 1024 * 1024, backupCount=3, - mode="a", encoding="utf-8", errors="replace", - ) - _file_handler.setFormatter( - logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s") - ) - logging.getLogger().addHandler(_file_handler) - except Exception as _e: # noqa: BLE001 - logger.warning("文件日志初始化失败, 仅输出到终端: %s", _e) + attach_rotating_log("backend.log", max_mb=10, backup_count=3) @asynccontextmanager diff --git a/backend/tests/test_logging_setup.py b/backend/tests/test_logging_setup.py new file mode 100644 index 00000000..f3b9e8a1 --- /dev/null +++ b/backend/tests/test_logging_setup.py @@ -0,0 +1,84 @@ +"""app/logfile.py: 日志归拢 data/logs/ + 大小切割 + 旧位置一次性迁移。""" +from __future__ import annotations + +import logging +from logging.handlers import RotatingFileHandler + +import pytest + +from app.logfile import attach_rotating_log +from app.config import settings + + +@pytest.fixture() +def data_dir(tmp_path, monkeypatch): + monkeypatch.setattr(settings, "data_dir", tmp_path, raising=False) + return tmp_path + + +@pytest.fixture() +def no_leaked_handlers(): + """测试后摘掉本用例新挂的 root handler, 并 close 释放文件句柄。 + + RotatingFileHandler 构造即打开日志文件, 不 close 的话 Windows 上 + tmp_path 清理会因文件占用失败, 且 handler 会泄漏到后续用例。 + """ + before = {id(h) for h in logging.getLogger().handlers} + yield + for handler in list(logging.getLogger().handlers): + if id(handler) not in before: + logging.getLogger().removeHandler(handler) + handler.close() + + +def test_attaches_rotating_handler_and_migrates_legacy(data_dir, no_leaked_handlers): + # 旧位置: data 根下的 backend.log 与轮转副本 .1 + (data_dir / "backend.log").write_text("old-main", encoding="utf-8") + (data_dir / "backend.log.1").write_text("old-rot", encoding="utf-8") + + attach_rotating_log("backend.log", max_mb=10, backup_count=3) + + # 旧文件被搬进 logs/, 内容不丢 + assert (data_dir / "logs" / "backend.log").read_text(encoding="utf-8") == "old-main" + assert (data_dir / "logs" / "backend.log.1").read_text(encoding="utf-8") == "old-rot" + assert not (data_dir / "backend.log").exists() + assert not (data_dir / "backend.log.1").exists() + # handler 为轮转式, 参数符合调用 + handlers = [h for h in logging.getLogger().handlers if isinstance(h, RotatingFileHandler)] + assert handlers + added = handlers[-1] + assert added.maxBytes == 10 * 1024 * 1024 + assert added.backupCount == 3 + + +def test_no_legacy_files_starts_fresh(data_dir, no_leaked_handlers): + attach_rotating_log("desktop.log", max_mb=10, backup_count=5) + # RotatingFileHandler 构造即以追加模式打开目标文件 + assert (data_dir / "logs" / "desktop.log").exists() + assert not (data_dir / "desktop.log").exists() + + +def test_existing_target_not_overwritten(data_dir, no_leaked_handlers): + # logs/ 里已有同名文件 (如上一轮迁移过) → 旧位置的文件原地保留, 不覆盖 + (data_dir / "logs").mkdir() + (data_dir / "logs" / "backend.log").write_text("new", encoding="utf-8") + (data_dir / "backend.log").write_text("old", encoding="utf-8") + + attach_rotating_log("backend.log", max_mb=10, backup_count=3) + + assert (data_dir / "logs" / "backend.log").read_text(encoding="utf-8") == "new" + assert (data_dir / "backend.log").read_text(encoding="utf-8") == "old" + + +def test_unwritable_data_dir_only_warns(tmp_path, monkeypatch, caplog, no_leaked_handlers): + # data_dir 指向一个普通文件 → mkdir 失败 → 只 warning, 不抛异常、不加 handler + blocker = tmp_path / "not-a-dir" + blocker.write_text("", encoding="utf-8") + monkeypatch.setattr(settings, "data_dir", blocker, raising=False) + + before_count = len(logging.getLogger().handlers) + with caplog.at_level(logging.WARNING): + attach_rotating_log("backend.log", max_mb=10, backup_count=3) + + assert "文件日志初始化失败" in caplog.text + assert len(logging.getLogger().handlers) == before_count diff --git a/dev.ps1 b/dev.ps1 index 1cbb2008..0918b83b 100644 --- a/dev.ps1 +++ b/dev.ps1 @@ -242,7 +242,7 @@ $BackendErrLog = Join-Path $LogDir 'backend.err.log' $FrontendOutLog = Join-Path $LogDir 'frontend.out.log' $FrontendErrLog = Join-Path $LogDir 'frontend.err.log' -# 每次启动重置本会话日志 (后端自身的 data/backend.log 已有轮转, 这里只放转发副本) +# 每次启动重置本会话日志 (后端自身的 data\logs\backend.log 已有轮转, 这里只放转发副本) foreach ($f in @($BackendOutLog, $BackendErrLog, $FrontendOutLog, $FrontendErrLog)) { Remove-Item $f -Force -ErrorAction SilentlyContinue }