Files

190 lines
5.4 KiB
Python

"""
日志管理 API
提供日志文件的列表、查看、删除功能。
"""
import itertools
import logging
import os
from typing import List, Optional
from datetime import datetime
from pathlib import Path
logger = logging.getLogger(__name__)
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from ..config import settings
from ..models import AuthUserORM
from .dependencies import _get_current_user
router = APIRouter(prefix="/logs", tags=["logs"])
# 日志根目录
LOG_ROOT = Path(settings.PROJECT_ROOT) / "logs"
class LogFileInfo(BaseModel):
"""日志文件信息"""
name: str
path: str
size: int
modified_at: str
type: str # app, task, error
class LogContentResponse(BaseModel):
"""日志内容响应"""
file_name: str
total_lines: int
content: str
offset: int
limit: int
def _get_log_type(file_path: Path) -> str:
"""根据路径判断日志类型"""
path_str = str(file_path)
if "logs/app" in path_str or "logs\\app" in path_str:
return "app"
elif "logs/error" in path_str or "logs\\error" in path_str:
return "error"
elif "logs/tasks" in path_str or "logs\\tasks" in path_str:
return "task"
return "other"
def _is_safe_path(file_path: str) -> bool:
"""检查路径是否安全(防止路径遍历攻击)"""
try:
requested_path = (LOG_ROOT / file_path).resolve()
return requested_path.is_relative_to(LOG_ROOT)
except (ValueError, RuntimeError):
return False
@router.get("/list", response_model=List[LogFileInfo])
async def list_logs(
log_type: Optional[str] = Query(None, description="日志类型: app, task, error"),
current_user: AuthUserORM = Depends(_get_current_user)
):
"""
列出所有日志文件
- 支持按类型过滤
- 返回文件名、大小、修改时间
"""
if not LOG_ROOT.exists():
return []
log_files = []
# 遍历日志目录
for log_file in LOG_ROOT.rglob("*.log"):
if log_file.is_file() and log_file.name != ".gitkeep":
file_type = _get_log_type(log_file)
# 类型过滤
if log_type and file_type != log_type:
continue
# 获取相对路径
relative_path = log_file.relative_to(LOG_ROOT)
log_files.append(LogFileInfo(
name=log_file.name,
path=str(relative_path).replace("\\", "/"),
size=log_file.stat().st_size,
modified_at=datetime.fromtimestamp(log_file.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
type=file_type
))
# 按修改时间倒序排序
log_files.sort(key=lambda x: x.modified_at, reverse=True)
return log_files
@router.get("/content/{log_path:path}", response_model=LogContentResponse)
async def get_log_content(
log_path: str,
offset: int = Query(0, ge=0, description="起始行号"),
limit: int = Query(1000, ge=1, le=10000, description="读取行数"),
current_user: AuthUserORM = Depends(_get_current_user)
):
"""
读取日志内容
- 支持分页读取(大文件)
- offset: 起始行号(从 0 开始)
- limit: 读取行数(最多 10000 行)
"""
# 安全检查
if not _is_safe_path(log_path):
raise HTTPException(status_code=400, detail="非法的文件路径")
log_file = LOG_ROOT / log_path
if not log_file.exists():
raise HTTPException(status_code=404, detail="日志文件不存在")
if not log_file.is_file():
raise HTTPException(status_code=400, detail="不是有效的文件")
try:
with open(log_file, "r", encoding="utf-8", errors="replace") as f:
# Count total lines without loading all into memory
total_lines = sum(1 for _ in f)
with open(log_file, "r", encoding="utf-8", errors="replace") as f:
# Skip to offset using islice, then read 'limit' lines
content_lines = list(itertools.islice(f, offset, offset + limit))
return LogContentResponse(
file_name=log_file.name,
total_lines=total_lines,
content="".join(content_lines),
offset=offset,
limit=limit
)
except Exception as e:
logger.exception("读取日志失败: %s", log_file.name)
raise HTTPException(status_code=500, detail="读取日志失败,请查看后端日志")
@router.delete("/{log_path:path}")
async def delete_log(
log_path: str,
current_user: AuthUserORM = Depends(_get_current_user)
):
"""
删除日志文件
- 仅管理员可以删除
- 需要确认操作
"""
# 权限检查
if current_user.role != "admin":
raise HTTPException(status_code=403, detail="只有管理员可以删除日志")
# 安全检查
if not _is_safe_path(log_path):
raise HTTPException(status_code=400, detail="非法的文件路径")
log_file = LOG_ROOT / log_path
if not log_file.exists():
raise HTTPException(status_code=404, detail="日志文件不存在")
if not log_file.is_file():
raise HTTPException(status_code=400, detail="不是有效的文件")
try:
log_file.unlink()
return {"message": f"日志文件 {log_file.name} 已删除"}
except Exception as e:
logger.exception("删除日志失败: %s", log_file.name)
raise HTTPException(status_code=500, detail="删除日志失败,请查看后端日志")