Merge pull request #1491 from Fr1ngg/wugnye-bedolaga/expand-bot-api-for-system-logs

Expose system log through admin API
This commit is contained in:
Egor
2025-10-24 09:20:48 +03:00
committed by GitHub
4 changed files with 152 additions and 2 deletions

View File

@@ -72,7 +72,9 @@ OPENAPI_TAGS = [
},
{
"name": "logs",
"description": "Журналы мониторинга бота и действий модераторов поддержки.",
"description": (
"Журналы мониторинга бота, действий модераторов поддержки и системный лог-файл."
),
},
{
"name": "auth",

View File

@@ -1,11 +1,17 @@
"""Маршруты административного API для просмотра логов."""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from fastapi import APIRouter, Depends, Query, Security
from fastapi import APIRouter, Depends, HTTPException, Query, Security
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.ticket import TicketCRUD
from app.services.monitoring_service import monitoring_service
@@ -17,10 +23,123 @@ from ..schemas.logs import (
SupportAuditActionsResponse,
SupportAuditLogEntry,
SupportAuditLogListResponse,
SystemLogPreviewResponse,
)
router = APIRouter()
logger = logging.getLogger(__name__)
SYSTEM_LOG_PREVIEW_LIMIT_DEFAULT = 4000
SYSTEM_LOG_PREVIEW_LIMIT_MAX = 20000
def _resolve_system_log_path() -> Path:
path = Path(settings.LOG_FILE)
if not path.is_absolute():
path = Path.cwd() / path
return path
async def _read_system_log(path: Path) -> tuple[str, int, Optional[float]]:
def _read() -> tuple[str, int, float]:
content = path.read_text(encoding="utf-8", errors="ignore")
stats = path.stat()
return content, stats.st_size, stats.st_mtime
return await run_in_threadpool(_read)
def _format_timestamp(timestamp: Optional[float]) -> Optional[datetime]:
if timestamp is None:
return None
return datetime.fromtimestamp(timestamp, tz=timezone.utc)
@router.get("/system", response_model=SystemLogPreviewResponse)
async def get_system_log_preview(
_: Any = Security(require_api_token),
preview_limit: int = Query(
SYSTEM_LOG_PREVIEW_LIMIT_DEFAULT,
ge=500,
le=SYSTEM_LOG_PREVIEW_LIMIT_MAX,
description="Количество символов предпросмотра от конца файла",
),
) -> SystemLogPreviewResponse:
"""Получить предпросмотр системного лог-файла бота."""
log_path = _resolve_system_log_path()
if not log_path.exists() or not log_path.is_file():
return SystemLogPreviewResponse(
path=str(log_path),
exists=False,
updated_at=None,
size_bytes=0,
size_chars=0,
preview="",
preview_chars=0,
preview_truncated=False,
download_url="/logs/system/download",
)
try:
content, size_bytes, mtime = await _read_system_log(log_path)
except FileNotFoundError:
logger.warning("Лог-файл %s исчез во время чтения", log_path)
return SystemLogPreviewResponse(
path=str(log_path),
exists=False,
updated_at=None,
size_bytes=0,
size_chars=0,
preview="",
preview_chars=0,
preview_truncated=False,
download_url="/logs/system/download",
)
except Exception as error: # pragma: no cover - защита от неожиданных ошибок чтения
logger.error("Ошибка чтения лог-файла %s: %s", log_path, error)
raise HTTPException(status_code=500, detail="Не удалось прочитать лог-файл") from error
preview_text = content[-preview_limit:] if preview_limit > 0 else ""
truncated = len(content) > len(preview_text)
return SystemLogPreviewResponse(
path=str(log_path),
exists=True,
updated_at=_format_timestamp(mtime),
size_bytes=size_bytes,
size_chars=len(content),
preview=preview_text,
preview_chars=len(preview_text),
preview_truncated=truncated,
download_url="/logs/system/download",
)
@router.get("/system/download")
async def download_system_log(
_: Any = Security(require_api_token),
) -> FileResponse:
"""Скачать полный лог-файл бота."""
log_path = _resolve_system_log_path()
if not log_path.exists() or not log_path.is_file():
raise HTTPException(status_code=404, detail="Лог-файл не найден")
try:
return FileResponse(
log_path,
media_type="text/plain",
filename=log_path.name,
)
except Exception as error: # pragma: no cover - защита от неожиданных ошибок отдачи файла
logger.error("Ошибка отправки лог-файла %s: %s", log_path, error)
raise HTTPException(status_code=500, detail="Не удалось отправить лог-файл") from error
@router.get("/monitoring", response_model=MonitoringLogListResponse)
async def list_monitoring_logs(

View File

@@ -63,3 +63,28 @@ class SupportAuditActionsResponse(BaseModel):
"""Ответ со списком доступных действий аудита поддержки."""
items: List[str] = Field(default_factory=list)
class SystemLogPreviewResponse(BaseModel):
"""Ответ с превью системного лог-файла бота."""
path: str = Field(..., description="Абсолютный путь до лог-файла")
exists: bool = Field(..., description="Флаг наличия лог-файла")
updated_at: Optional[datetime] = Field(
default=None,
description="Дата и время последнего изменения лог-файла",
)
size_bytes: int = Field(..., ge=0, description="Размер лог-файла в байтах")
size_chars: int = Field(..., ge=0, description="Количество символов в лог-файле")
preview: str = Field(
default="",
description="Фрагмент содержимого лог-файла, возвращаемый для предпросмотра",
)
preview_chars: int = Field(..., ge=0, description="Размер предпросмотра в символах")
preview_truncated: bool = Field(
..., description="Флаг усечения предпросмотра относительно полного файла"
)
download_url: Optional[str] = Field(
default=None,
description="Относительный путь до endpoint для скачивания лог-файла",
)

View File

@@ -139,6 +139,8 @@ curl -X POST "http://127.0.0.1:8080/tokens" \
| `GET` | `/logs/monitoring/event-types` | Справочник доступных типов событий мониторинга.
| `GET` | `/logs/support` | Журнал действий модераторов поддержки (блокировки, закрытия тикетов).
| `GET` | `/logs/support/actions` | Справочник возможных действий в аудите поддержки.
| `GET` | `/logs/system` | Предпросмотр системного лог-файла бота с метаданными.
| `GET` | `/logs/system/download` | Скачивание полного лог-файла бота (`text/plain`).
> Раздел **promo-offers** в Swagger объединяет работу с персональными предложениями: выдачу скидок/бонусов пользователям, настройку
> текстов шаблонов и просмотр журнала операций (активации, автосписания, отключения просроченных акций).
@@ -151,6 +153,8 @@ curl -X POST "http://127.0.0.1:8080/tokens" \
- Получать справочник доступных типов событий (`GET /logs/monitoring/event-types`). Это удобно для построения фильтров во внешней админке.
- Отслеживать действия модераторов поддержки (`GET /logs/support`) с пагинацией и возможностью фильтровать по конкретному действию (`action`).
- Запрашивать список возможных действий для UI (`GET /logs/support/actions`).
- Просматривать системный лог-файл бота (`GET /logs/system`). Endpoint возвращает метаданные (путь, время изменения, размер в байтах/символах) и фрагмент конца файла, размер которого можно регулировать параметром `preview_limit` (от 500 до 20 000 символов).
- Скачивать полный системный лог в текстовом формате (`GET /logs/system/download`).
Все эндпоинты защищены токеном API и возвращают структуру с общим количеством записей, текущим `limit`/`offset` и массивом объектов. Это упрощает реализацию таблиц и постраничной навигации во внешних административных интерфейсах.