mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 02:16:13 +00:00
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:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user