""" log/logger.py 统一日志配置: - 控制台输出(强制 UTF-8,修复 Windows 控制台中文乱码) - logs/app.log 全量日志(按大小轮转) - logs/error.log 仅 WARNING 及以上 - logs/upload.log 上传/解析/入库链路(routers.template、services.*) - 接管 uvicorn 的 access/error 日志,统一落盘 幂等:重复调用只配置一次。 """ from __future__ import annotations import logging import sys from logging.handlers import RotatingFileHandler from pathlib import Path _CONFIGURED = False _FORMAT = "%(asctime)s | %(levelname)-7s | %(name)s | %(message)s" _DATEFMT = "%Y-%m-%d %H:%M:%S" # 上传/解析/入库链路相关的 logger 前缀(额外汇总到 upload.log) _UPLOAD_PREFIXES = ( "routers.template", "services.file_parse_client", "services.section_extractor", "services.declaration_service", "services.llm_client", ) # 交由 root 统一处理的第三方/框架 logger _DELEGATED_LOGGERS = ("uvicorn", "uvicorn.error", "uvicorn.access") class _PrefixFilter(logging.Filter): def __init__(self, prefixes: tuple[str, ...]) -> None: super().__init__() self.prefixes = prefixes def filter(self, record: logging.LogRecord) -> bool: name = str(record.name or "") return any(name == p or name.startswith(p + ".") for p in self.prefixes) def _force_utf8_stream(stream): """让控制台以 UTF-8 输出,避免 Windows GBK 控制台中文乱码。""" reconfigure = getattr(stream, "reconfigure", None) if callable(reconfigure): try: reconfigure(encoding="utf-8", errors="replace") except (ValueError, OSError): pass return stream def configure_logging( *, log_dir: str | Path | None = None, level: str | int | None = None, to_console: bool | None = None, max_bytes: int | None = None, backup_count: int | None = None, ) -> Path: """配置全局日志。返回 app.log 路径。""" global _CONFIGURED # 延迟导入,避免与 config 形成循环依赖问题 from config import settings log_dir = log_dir if log_dir is not None else settings.LOG_DIR level = level if level is not None else settings.LOG_LEVEL to_console = to_console if to_console is not None else settings.LOG_TO_CONSOLE max_bytes = max_bytes if max_bytes is not None else settings.LOG_MAX_BYTES backup_count = backup_count if backup_count is not None else settings.LOG_BACKUP_COUNT if isinstance(level, str): level = getattr(logging, level.strip().upper(), logging.INFO) target_dir = Path(log_dir).resolve() target_dir.mkdir(parents=True, exist_ok=True) app_log_path = target_dir / "app.log" if _CONFIGURED: return app_log_path formatter = logging.Formatter(_FORMAT, datefmt=_DATEFMT) def _rotating(name: str, *, backups: int | None = None) -> RotatingFileHandler: h = RotatingFileHandler( target_dir / name, maxBytes=max_bytes, backupCount=backups if backups is not None else backup_count, encoding="utf-8", ) h.setFormatter(formatter) return h # 全量日志 app_handler = _rotating("app.log") app_handler.setLevel(level) # 错误日志(WARNING+) error_handler = _rotating("error.log") error_handler.setLevel(logging.WARNING) # 上传/解析链路日志 upload_handler = _rotating("upload.log", backups=max(backup_count, 10)) upload_handler.setLevel(level) upload_handler.addFilter(_PrefixFilter(_UPLOAD_PREFIXES)) handlers: list[logging.Handler] = [app_handler, error_handler, upload_handler] if to_console: console_handler = logging.StreamHandler(_force_utf8_stream(sys.stdout)) console_handler.setLevel(level) console_handler.setFormatter(formatter) handlers.append(console_handler) root = logging.getLogger() root.setLevel(level) root.handlers.clear() for h in handlers: root.addHandler(h) # 让 uvicorn 的日志走 root 统一落盘 for name in _DELEGATED_LOGGERS: lg = logging.getLogger(name) lg.handlers.clear() lg.propagate = True lg.setLevel(level) _CONFIGURED = True logging.getLogger(__name__).info("日志系统已初始化 | dir=%s | level=%s", target_dir, logging.getLevelName(level)) return app_log_path def get_logger(name: str) -> logging.Logger: return logging.getLogger(name)