feat(backend): RBAC admin roles + runtime logs service

This commit is contained in:
Simon
2026-07-09 13:46:15 +08:00
parent 7ffc6c8e23
commit d480aa4c1d
12 changed files with 726 additions and 25 deletions
+220
View File
@@ -0,0 +1,220 @@
# =============================================================================
# 企微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"