# ============================================================================= # 企微IT智能服务台 — 运行期日志服务 # ============================================================================= # 说明:读取后端运行期日志目录(config.RUNTIME_LOG_DIR)下的 *.log 文件, # 逐行 json.loads(容错跳过非 JSON 行),按 级别 >= 阈值 / 时间区间 / # 关键字 过滤,按时间倒序分页返回结构化条目; # download 模式返回原始 JSON 行(供流式下载)。 # 目录不存在 / 不可读时返回空结果(不抛 500)。 # ============================================================================= import json import logging import os import re from datetime import datetime from typing import Any, AsyncGenerator, Dict, List, Optional from app.config import settings logger = logging.getLogger(__name__) # 日志级别数值映射(用于 >= 阈值 比较) _LEVEL_ORDER = { "DEBUG": 10, "INFO": 20, "WARNING": 30, "ERROR": 40, "CRITICAL": 50, } # 轮转备份命名形如 wecom-it-desk.log.1 / .2 ... _ROTATED_RE = re.compile(r"\.log\.\d+$") def _level_num(level: str) -> int: """将级别名转为数值(用于 >= 阈值比较)。""" return _LEVEL_ORDER.get((level or "INFO").upper(), 20) def _parse_ts(ts: Optional[str]) -> datetime: """解析 JSONFormatter 写入的时间戳(ISO8601,可能带 Z)。 解析失败时返回 datetime.min,使其排在最后(不影响过滤语义)。 """ if not ts: return datetime.min try: return datetime.fromisoformat(ts.replace("Z", "+00:00")) except (ValueError, TypeError): try: return datetime.strptime(ts, "%Y-%m-%dT%H:%M:%S.%f") except (ValueError, TypeError): return datetime.min def _list_log_files() -> List[str]: """列出运行期日志目录下所有 .log 文件(含轮转备份)。 目录不存在 / 不可读时返回空列表(不抛异常)。 """ log_dir = getattr(settings, "RUNTIME_LOG_DIR", None) or "/app/logs" if not log_dir or not os.path.isdir(log_dir): return [] files: List[str] = [] try: for name in os.listdir(log_dir): if name.endswith(".log") or _ROTATED_RE.search(name): full = os.path.join(log_dir, name) if os.path.isfile(full): files.append(full) except (OSError, PermissionError) as e: logger.warning(f"读取运行期日志目录失败: {log_dir}: {e}") return [] # 按文件名排序,保证读取顺序稳定(主日志优先于轮转备份) files.sort() return files def _iter_raw_lines() -> List[str]: """逐文件读取所有日志行(原始文本,已去除行尾换行)。""" lines: List[str] = [] for path in _list_log_files(): try: with open(path, "r", encoding="utf-8", errors="ignore") as f: for line in f: stripped = line.rstrip("\n").rstrip("\r") if stripped: lines.append(stripped) except (OSError, PermissionError) as e: logger.warning(f"读取运行期日志文件失败: {path}: {e}") continue return lines def _match( entry: Dict[str, Any], level_th: int, from_time: Optional[datetime], to_time: Optional[datetime], keyword: Optional[str], ) -> bool: """判断单条日志是否命中筛选条件。""" # 级别阈值:仅保留 >= 阈值的条目 if _level_num(entry.get("level", "INFO")) < level_th: return False # 时间区间(闭区间) ts = _parse_ts(entry.get("timestamp")) if from_time and ts < from_time: return False if to_time and ts > to_time: return False # 关键字:消息子串(大小写不敏感);为空则跳过该过滤 if keyword: msg = str(entry.get("message", "")) if keyword.lower() not in msg.lower(): return False return True async def query_runtime_logs( level: str = "INFO", from_time: Optional[datetime] = None, to_time: Optional[datetime] = None, keyword: Optional[str] = None, page: int = 1, page_size: int = 50, ) -> Dict[str, Any]: """查询运行期日志(分页 + 多条件筛选)。 读取 config.RUNTIME_LOG_DIR 下 *.log(含轮转备份),逐行 json.loads (容错跳过非 JSON 行),按 级别>=阈值 / 时间区间 / 关键字 过滤, 按时间倒序分页返回结构化条目。 目录不存在 / 不可读时返回空列表(不抛 500)。 Args: level: 级别阈值(DEBUG/INFO/WARNING/ERROR/CRITICAL),返回 >= 该级别 from_time: 起始时间(可选) to_time: 结束时间(可选) keyword: 消息关键字子串(可选,大小写不敏感) page: 页码,从 1 开始 page_size: 每页条数 Returns: Dict: {items, total, page, page_size} """ level_th = _level_num(level) entries: List[Dict[str, Any]] = [] for raw in _iter_raw_lines(): try: entry = json.loads(raw) except (json.JSONDecodeError, ValueError): # 容错:跳过非 JSON 行 continue if not isinstance(entry, dict): continue if _match(entry, level_th, from_time, to_time, keyword): entries.append(entry) # 按时间倒序(新 -> 旧) entries.sort(key=lambda e: _parse_ts(e.get("timestamp")), reverse=True) total = len(entries) page = max(1, page) page_size = max(1, page_size) start = (page - 1) * page_size page_items = entries[start:start + page_size] items = [] for e in page_items: items.append({ "timestamp": e.get("timestamp", ""), "level": e.get("level", ""), "logger": e.get("logger", ""), "message": e.get("message", ""), "module": e.get("module", ""), "function": e.get("function", ""), "line": e.get("line", ""), "request_id": e.get("request_id"), "user_id": e.get("user_id"), }) return { "items": items, "total": total, "page": page, "page_size": page_size, } async def iter_runtime_log_lines( level: str = "INFO", from_time: Optional[datetime] = None, to_time: Optional[datetime] = None, keyword: Optional[str] = None, ) -> AsyncGenerator[str, None]: """流式返回命中的原始 JSON 行(供下载)。 逐行读取并过滤,命中即 yield 原始行文本(带换行), 便于 StreamingResponse 直接流式输出为 .log 文件。 Args: level: 级别阈值 from_time: 起始时间(可选) to_time: 结束时间(可选) keyword: 消息关键字子串(可选) Yields: str: 命中的原始 JSON 日志行(含行尾换行) """ level_th = _level_num(level) for raw in _iter_raw_lines(): try: entry = json.loads(raw) except (json.JSONDecodeError, ValueError): continue if not isinstance(entry, dict): continue if _match(entry, level_th, from_time, to_time, keyword): yield raw + "\n"