From 5f316f85b35ed3f78733b00456e0720940e4f864 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 24 Oct 2025 09:06:08 +0300 Subject: [PATCH] feat: expose monitoring logs through web api --- app/database/crud/ticket.py | 34 ++++++-- app/services/monitoring_service.py | 29 +++++-- app/webapi/app.py | 6 ++ app/webapi/routes/__init__.py | 2 + app/webapi/routes/logs.py | 120 +++++++++++++++++++++++++++++ app/webapi/schemas/logs.py | 65 ++++++++++++++++ docs/web-admin-integration.md | 15 ++++ 7 files changed, 260 insertions(+), 11 deletions(-) create mode 100644 app/webapi/routes/logs.py create mode 100644 app/webapi/schemas/logs.py diff --git a/app/database/crud/ticket.py b/app/database/crud/ticket.py index 7dd3ef52..1bea7b11 100644 --- a/app/database/crud/ticket.py +++ b/app/database/crud/ticket.py @@ -333,18 +333,42 @@ class TicketCRUD: *, limit: int = 50, offset: int = 0, + action: Optional[str] = None, ) -> List[SupportAuditLog]: from sqlalchemy import select, desc - result = await db.execute( - select(SupportAuditLog).order_by(desc(SupportAuditLog.created_at)).offset(offset).limit(limit) - ) + + query = select(SupportAuditLog).order_by(desc(SupportAuditLog.created_at)) + + if action: + query = query.where(SupportAuditLog.action == action) + + result = await db.execute(query.offset(offset).limit(limit)) return result.scalars().all() @staticmethod - async def count_support_audit(db: AsyncSession) -> int: + async def count_support_audit(db: AsyncSession, action: Optional[str] = None) -> int: from sqlalchemy import select, func - result = await db.execute(select(func.count()).select_from(SupportAuditLog)) + + query = select(func.count()).select_from(SupportAuditLog) + + if action: + query = query.where(SupportAuditLog.action == action) + + result = await db.execute(query) return int(result.scalar() or 0) + + @staticmethod + async def list_support_audit_actions(db: AsyncSession) -> List[str]: + from sqlalchemy import select + + result = await db.execute( + select(SupportAuditLog.action) + .where(SupportAuditLog.action.isnot(None)) + .distinct() + .order_by(SupportAuditLog.action) + ) + + return [row[0] for row in result.fetchall()] @staticmethod async def get_open_tickets_count(db: AsyncSession) -> int: diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 15c65e61..857e4ea4 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -1759,26 +1759,43 @@ class MonitoringService: return [] async def get_monitoring_logs_count( - self, + self, db: AsyncSession, event_type: Optional[str] = None ) -> int: try: from sqlalchemy import select, func - + query = select(func.count(MonitoringLog.id)) - + if event_type: query = query.where(MonitoringLog.event_type == event_type) - + result = await db.execute(query) count = result.scalar() - + return count or 0 - + except Exception as e: logger.error(f"Ошибка получения количества логов: {e}") return 0 + + async def get_monitoring_event_types(self, db: AsyncSession) -> List[str]: + try: + from sqlalchemy import select + + result = await db.execute( + select(MonitoringLog.event_type) + .where(MonitoringLog.event_type.isnot(None)) + .distinct() + .order_by(MonitoringLog.event_type) + ) + + return [row[0] for row in result.fetchall() if row[0]] + + except Exception as e: + logger.error(f"Ошибка получения списка типов событий мониторинга: {e}") + return [] async def cleanup_old_logs(self, db: AsyncSession, days: int = 30) -> int: try: diff --git a/app/webapi/app.py b/app/webapi/app.py index a976cd91..cd2b6697 100644 --- a/app/webapi/app.py +++ b/app/webapi/app.py @@ -25,6 +25,7 @@ from .routes import ( tokens, transactions, users, + logs, ) @@ -69,6 +70,10 @@ OPENAPI_TAGS = [ "name": "promo-offers", "description": "Управление промо-предложениями, шаблонами и журналом событий.", }, + { + "name": "logs", + "description": "Журналы мониторинга бота и действий модераторов поддержки.", + }, { "name": "auth", "description": "Управление токенами доступа к административному API.", @@ -138,5 +143,6 @@ def create_web_api_app() -> FastAPI: app.include_router(tokens.router, prefix="/tokens", tags=["auth"]) app.include_router(remnawave.router, prefix="/remnawave", tags=["remnawave"]) app.include_router(miniapp.router, prefix="/miniapp", tags=["miniapp"]) + app.include_router(logs.router, prefix="/logs", tags=["logs"]) return app diff --git a/app/webapi/routes/__init__.py b/app/webapi/routes/__init__.py index f8fb6fdc..23541d12 100644 --- a/app/webapi/routes/__init__.py +++ b/app/webapi/routes/__init__.py @@ -13,6 +13,7 @@ from . import ( tokens, transactions, users, + logs, ) __all__ = [ @@ -30,4 +31,5 @@ __all__ = [ "tokens", "transactions", "users", + "logs", ] diff --git a/app/webapi/routes/logs.py b/app/webapi/routes/logs.py new file mode 100644 index 00000000..c172a1f0 --- /dev/null +++ b/app/webapi/routes/logs.py @@ -0,0 +1,120 @@ +"""Маршруты административного API для просмотра логов.""" +from __future__ import annotations + +from typing import Any, Optional + +from fastapi import APIRouter, Depends, Query, Security +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.crud.ticket import TicketCRUD +from app.services.monitoring_service import monitoring_service + +from ..dependencies import get_db_session, require_api_token +from ..schemas.logs import ( + MonitoringLogEntry, + MonitoringLogListResponse, + MonitoringLogTypeListResponse, + SupportAuditActionsResponse, + SupportAuditLogEntry, + SupportAuditLogListResponse, +) + +router = APIRouter() + + +@router.get("/monitoring", response_model=MonitoringLogListResponse) +async def list_monitoring_logs( + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), + limit: int = Query(50, ge=1, le=200, description="Количество записей на странице"), + offset: int = Query(0, ge=0, description="Смещение от начала списка"), + event_type: Optional[str] = Query( + default=None, + max_length=100, + description="Фильтр по типу события", + ), +) -> MonitoringLogListResponse: + """Получить список логов мониторинга с пагинацией.""" + + per_page = limit + page = (offset // per_page) + 1 + + raw_logs = await monitoring_service.get_monitoring_logs( + db, + event_type=event_type, + page=page, + per_page=per_page, + ) + total = await monitoring_service.get_monitoring_logs_count(db, event_type=event_type) + + return MonitoringLogListResponse( + total=total, + limit=limit, + offset=offset, + items=[MonitoringLogEntry(**entry) for entry in raw_logs], + ) + + +@router.get("/monitoring/event-types", response_model=MonitoringLogTypeListResponse) +async def list_monitoring_event_types( + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> MonitoringLogTypeListResponse: + """Получить список доступных типов событий мониторинга.""" + + event_types = await monitoring_service.get_monitoring_event_types(db) + return MonitoringLogTypeListResponse(items=event_types) + + +@router.get("/support", response_model=SupportAuditLogListResponse) +async def list_support_audit_logs( + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), + limit: int = Query(50, ge=1, le=200, description="Количество записей на странице"), + offset: int = Query(0, ge=0, description="Смещение от начала списка"), + action: Optional[str] = Query( + default=None, + max_length=50, + description="Фильтр по типу действия модератора", + ), +) -> SupportAuditLogListResponse: + """Получить список аудита действий модераторов поддержки.""" + + logs = await TicketCRUD.list_support_audit( + db, + limit=limit, + offset=offset, + action=action, + ) + total = await TicketCRUD.count_support_audit(db, action=action) + + return SupportAuditLogListResponse( + total=total, + limit=limit, + offset=offset, + items=[ + SupportAuditLogEntry( + id=log.id, + actor_user_id=log.actor_user_id, + actor_telegram_id=log.actor_telegram_id, + is_moderator=log.is_moderator, + action=log.action, + ticket_id=log.ticket_id, + target_user_id=log.target_user_id, + details=log.details, + created_at=log.created_at, + ) + for log in logs + ], + ) + + +@router.get("/support/actions", response_model=SupportAuditActionsResponse) +async def list_support_audit_actions( + _: Any = Security(require_api_token), + db: AsyncSession = Depends(get_db_session), +) -> SupportAuditActionsResponse: + """Получить список действий, доступных в аудите поддержки.""" + + actions = await TicketCRUD.list_support_audit_actions(db) + return SupportAuditActionsResponse(items=actions) diff --git a/app/webapi/schemas/logs.py b/app/webapi/schemas/logs.py new file mode 100644 index 00000000..8a42573a --- /dev/null +++ b/app/webapi/schemas/logs.py @@ -0,0 +1,65 @@ +"""Pydantic-схемы для работы с логами административного API.""" +from __future__ import annotations + +from datetime import datetime +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + + +class MonitoringLogEntry(BaseModel): + """Запись лога мониторинга.""" + + id: int + event_type: str = Field(..., description="Тип события мониторинга") + message: str = Field(..., description="Краткое описание события") + data: Optional[Dict[str, Any]] = Field( + default=None, + description="Дополнительные данные события", + ) + is_success: bool = Field(..., description="Флаг успешности выполнения операции") + created_at: datetime = Field(..., description="Дата и время создания записи") + + +class MonitoringLogListResponse(BaseModel): + """Ответ со списком логов мониторинга.""" + + total: int = Field(..., ge=0) + limit: int = Field(..., ge=1) + offset: int = Field(..., ge=0) + items: List[MonitoringLogEntry] + + +class MonitoringLogTypeListResponse(BaseModel): + """Ответ со списком доступных типов событий мониторинга.""" + + items: List[str] = Field(default_factory=list) + + +class SupportAuditLogEntry(BaseModel): + """Запись аудита модераторов поддержки.""" + + id: int + actor_user_id: Optional[int] + actor_telegram_id: int + is_moderator: bool + action: str + ticket_id: Optional[int] + target_user_id: Optional[int] + details: Optional[Dict[str, Any]] = None + created_at: datetime + + +class SupportAuditLogListResponse(BaseModel): + """Ответ со списком аудита поддержки.""" + + total: int = Field(..., ge=0) + limit: int = Field(..., ge=1) + offset: int = Field(..., ge=0) + items: List[SupportAuditLogEntry] + + +class SupportAuditActionsResponse(BaseModel): + """Ответ со списком доступных действий аудита поддержки.""" + + items: List[str] = Field(default_factory=list) diff --git a/docs/web-admin-integration.md b/docs/web-admin-integration.md index 26e2534b..353eb486 100644 --- a/docs/web-admin-integration.md +++ b/docs/web-admin-integration.md @@ -135,10 +135,25 @@ curl -X POST "http://127.0.0.1:8080/tokens" \ | `PATCH` | `/promo-offers/templates/{id}` | Обновить текст, кнопки и параметры шаблона. | `GET` | `/promo-offers/logs` | Журнал операций с промо-предложениями (активации, списания, выключения). | `GET` | `/tokens` | Управление токенами доступа. +| `GET` | `/logs/monitoring` | Логи мониторинга бота с пагинацией и фильтрами по типу события. +| `GET` | `/logs/monitoring/event-types` | Справочник доступных типов событий мониторинга. +| `GET` | `/logs/support` | Журнал действий модераторов поддержки (блокировки, закрытия тикетов). +| `GET` | `/logs/support/actions` | Справочник возможных действий в аудите поддержки. > Раздел **promo-offers** в Swagger объединяет работу с персональными предложениями: выдачу скидок/бонусов пользователям, настройку > текстов шаблонов и просмотр журнала операций (активации, автосписания, отключения просроченных акций). +### Логи бота + +В административном API появился раздел **logs**. Он позволяет: + +- Просматривать общие логи мониторинга (`GET /logs/monitoring`) с поддержкой пагинации (`limit`, `offset`) и фильтра по типу события (`event_type`). +- Получать справочник доступных типов событий (`GET /logs/monitoring/event-types`). Это удобно для построения фильтров во внешней админке. +- Отслеживать действия модераторов поддержки (`GET /logs/support`) с пагинацией и возможностью фильтровать по конкретному действию (`action`). +- Запрашивать список возможных действий для UI (`GET /logs/support/actions`). + +Все эндпоинты защищены токеном API и возвращают структуру с общим количеством записей, текущим `limit`/`offset` и массивом объектов. Это упрощает реализацию таблиц и постраничной навигации во внешних административных интерфейсах. + ### RemnaWave интеграция После включения веб-API в Swagger (`WEB_API_DOCS_ENABLED=true`) появится раздел **remnawave**. Он объединяет эндпоинты для управления панелью RemnaWave и синхронизации данных бота: