fix(logs): 日志归拢 data/logs/ 并为桌面版 desktop.log 增加大小切割

- 新增 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 例 (迁移/冷启动/不覆盖/不可写容错)
This commit is contained in:
shy3130
2026-10-23 22:00:51 +08:00
parent 14391914e3
commit 5d6ac5c736
6 changed files with 160 additions and 45 deletions
+2 -2
View File
@@ -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 越大占用越高。历史数据较少时(例如首次只同步了几十天)内存会明显低于上表。
### 跑起来后的第一次使用
+8 -25
View File
@@ -55,7 +55,7 @@ def _failed_html(reason: str | None = None) -> str:
</style></head><body><div class="box">
<h1>{title}</h1>
{body}
<p>反复失败时查看日志:data\\desktop.log</p>
<p>反复失败时查看日志:data\\logs\\desktop.log</p>
</div></body></html>"""
@@ -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)
+60
View File
@@ -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/<filename> 的轮转文件 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)
+5 -17
View File
@@ -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
+84
View File
@@ -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
+1 -1
View File
@@ -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
}