diff --git a/app/webapi/app.py b/app/webapi/app.py index cd2b6697..79f23065 100644 --- a/app/webapi/app.py +++ b/app/webapi/app.py @@ -72,7 +72,9 @@ OPENAPI_TAGS = [ }, { "name": "logs", - "description": "Журналы мониторинга бота и действий модераторов поддержки.", + "description": ( + "Журналы мониторинга бота, действий модераторов поддержки и системный лог-файл." + ), }, { "name": "auth", diff --git a/app/webapi/routes/logs.py b/app/webapi/routes/logs.py index c172a1f0..81d07524 100644 --- a/app/webapi/routes/logs.py +++ b/app/webapi/routes/logs.py @@ -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( diff --git a/app/webapi/schemas/logs.py b/app/webapi/schemas/logs.py index 8a42573a..c21858a5 100644 --- a/app/webapi/schemas/logs.py +++ b/app/webapi/schemas/logs.py @@ -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 для скачивания лог-файла", + ) diff --git a/docs/web-admin-integration.md b/docs/web-admin-integration.md index 353eb486..9ad9048a 100644 --- a/docs/web-admin-integration.md +++ b/docs/web-admin-integration.md @@ -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` и массивом объектов. Это упрощает реализацию таблиц и постраничной навигации во внешних административных интерфейсах.