From d343a317eeb2d985a1a6a1f84ffdcb14f946105a Mon Sep 17 00:00:00 2001 From: gy9vin Date: Sat, 27 Dec 2025 19:02:28 +0300 Subject: [PATCH] =?UTF-8?q?feat(logging):=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC=D1=83?= =?UTF-8?q?=20=D1=80=D0=BE=D1=82=D0=B0=D1=86=D0=B8=D0=B8=20=D0=BB=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ежедневная ротация в 00:00 с архивацией в tar.gz - Разделение по уровням: info.log, warning.log, error.log - Отдельный payments.log для платежных операций - Отправка архивов в Telegram-канал бекапов - Автоочистка архивов старше 7 дней (настраивается) - Переключатель LOG_ROTATION_ENABLED (по умолчанию выключен) --- .env.example | 22 ++ app/config.py | 48 +++- app/services/log_rotation_service.py | 351 ++++++++++++++++++++++++++ app/services/payment/cloudpayments.py | 4 +- app/services/payment/common.py | 4 +- app/services/payment/cryptobot.py | 5 +- app/services/payment/heleket.py | 4 +- app/services/payment/mulenpay.py | 4 +- app/services/payment/pal24.py | 4 +- app/services/payment/platega.py | 4 +- app/services/payment/stars.py | 4 +- app/services/payment/tribute.py | 4 +- app/services/payment/wata.py | 4 +- app/services/payment/yookassa.py | 4 +- app/utils/log_handlers.py | 108 ++++++++ app/utils/payment_logger.py | 54 ++++ main.py | 120 ++++++++- 17 files changed, 704 insertions(+), 44 deletions(-) create mode 100644 app/services/log_rotation_service.py create mode 100644 app/utils/log_handlers.py create mode 100644 app/utils/payment_logger.py diff --git a/.env.example b/.env.example index 1cce3ab8..e66be086 100644 --- a/.env.example +++ b/.env.example @@ -541,6 +541,28 @@ VERSION_CHECK_INTERVAL_HOURS=1 LOG_LEVEL=INFO LOG_FILE=logs/bot.log +# === Ротация логов === +# Включить новую систему ротации (по умолчанию старое поведение) +LOG_ROTATION_ENABLED=false +# Время ротации (HH:MM) +LOG_ROTATION_TIME=00:00 +# Хранить архивы N дней +LOG_ROTATION_KEEP_DAYS=7 +# Сжимать архивы gzip +LOG_ROTATION_COMPRESS=true +# Отправлять архивы в Telegram-канал +LOG_ROTATION_SEND_TO_TELEGRAM=false +# Канал для логов (если не задан, используется BACKUP_SEND_CHAT_ID) +LOG_ROTATION_CHAT_ID= +# Топик в канале (если не задан, используется BACKUP_SEND_TOPIC_ID) +LOG_ROTATION_TOPIC_ID= +# Пути к лог-файлам (при LOG_ROTATION_ENABLED=true) +LOG_DIR=logs +LOG_INFO_FILE=info.log +LOG_WARNING_FILE=warning.log +LOG_ERROR_FILE=error.log +LOG_PAYMENTS_FILE=payments.log + # ===== РАЗРАБОТКА ===== DEBUG=false WEBHOOK_URL= diff --git a/app/config.py b/app/config.py index fab2409d..fdba7359 100644 --- a/app/config.py +++ b/app/config.py @@ -426,7 +426,23 @@ class Settings(BaseSettings): LOG_LEVEL: str = "INFO" LOG_FILE: str = "logs/bot.log" - + + # === Log Rotation Settings === + LOG_ROTATION_ENABLED: bool = False # По умолчанию старое поведение + LOG_ROTATION_TIME: str = "00:00" # Время ротации (HH:MM) + LOG_ROTATION_KEEP_DAYS: int = 7 # Хранить архивы N дней + LOG_ROTATION_COMPRESS: bool = True # Сжимать архивы gzip + LOG_ROTATION_SEND_TO_TELEGRAM: bool = False # Отправлять в канал + LOG_ROTATION_CHAT_ID: Optional[str] = None # Канал для логов (или BACKUP_SEND_CHAT_ID) + LOG_ROTATION_TOPIC_ID: Optional[int] = None # Топик в канале + + # Пути к лог-файлам (при LOG_ROTATION_ENABLED=true) + LOG_DIR: str = "logs" + LOG_INFO_FILE: str = "info.log" + LOG_WARNING_FILE: str = "warning.log" + LOG_ERROR_FILE: str = "error.log" + LOG_PAYMENTS_FILE: str = "payments.log" + DEBUG: bool = False WEBHOOK_URL: Optional[str] = None WEBHOOK_PATH: str = "/webhook" @@ -1630,6 +1646,36 @@ class Settings(BaseSettings): password = (self.BACKUP_ARCHIVE_PASSWORD or "").strip() return password if password else None + # === Log Rotation Methods === + + def is_log_rotation_enabled(self) -> bool: + """Проверить, включена ли новая система ротации логов.""" + return self.LOG_ROTATION_ENABLED + + def get_log_rotation_chat_id(self) -> Optional[int]: + """Получить ID канала для отправки логов. + + Если LOG_ROTATION_CHAT_ID не задан, использует BACKUP_SEND_CHAT_ID. + """ + chat_id = self.LOG_ROTATION_CHAT_ID or self.BACKUP_SEND_CHAT_ID + if not chat_id: + return None + + try: + return int(chat_id) + except (ValueError, TypeError): + return None + + def get_log_rotation_topic_id(self) -> Optional[int]: + """Получить ID топика для отправки логов. + + Если LOG_ROTATION_TOPIC_ID не задан, использует BACKUP_SEND_TOPIC_ID. + """ + topic_id = self.LOG_ROTATION_TOPIC_ID + if topic_id is not None: + return topic_id + return self.BACKUP_SEND_TOPIC_ID + def get_referral_settings(self) -> Dict: return { "program_enabled": self.is_referral_program_enabled(), diff --git a/app/services/log_rotation_service.py b/app/services/log_rotation_service.py new file mode 100644 index 00000000..30090408 --- /dev/null +++ b/app/services/log_rotation_service.py @@ -0,0 +1,351 @@ +"""Сервис ротации логов с отправкой в Telegram. + +Функционал: +- Ежедневная ротация в настроенное время (по умолчанию 00:00) +- Разделение по уровням: info.log, warning.log, error.log +- Отдельный лог платежей: payments.log +- Архивирование всех логов за день в один tar.gz +- Отправка архива в Telegram-канал +- Очистка архивов старше N дней +""" + +from __future__ import annotations + +import asyncio +import logging +import tarfile +from dataclasses import dataclass +from datetime import datetime, timedelta +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +from aiogram import Bot +from aiogram.types import FSInputFile + +from app.config import settings +from app.utils.timezone import get_local_timezone + +logger = logging.getLogger(__name__) + + +@dataclass +class LogRotationStatus: + """Статус сервиса ротации логов.""" + + enabled: bool + running: bool + rotation_time: str + keep_days: int + send_to_telegram: bool + next_rotation: Optional[str] + log_dir: str + archive_count: int + + +class LogRotationService: + """Сервис ежедневной ротации и архивации логов.""" + + def __init__(self, bot: Optional[Bot] = None): + self.bot = bot + self._rotation_task: Optional[asyncio.Task] = None + self._running = False + self._handlers: List[logging.Handler] = [] + + # Пути + self.log_dir = Path(settings.LOG_DIR).resolve() + self.current_dir = self.log_dir / "current" + self.archive_dir = self.log_dir / "archive" + + @property + def log_files(self) -> Dict[str, Path]: + """Пути к текущим лог-файлам.""" + return { + "bot": self.current_dir / "bot.log", + "info": self.current_dir / settings.LOG_INFO_FILE, + "warning": self.current_dir / settings.LOG_WARNING_FILE, + "error": self.current_dir / settings.LOG_ERROR_FILE, + "payments": self.current_dir / settings.LOG_PAYMENTS_FILE, + } + + def set_bot(self, bot: Bot) -> None: + """Установить экземпляр бота для отправки логов.""" + self.bot = bot + + def register_handlers(self, handlers: List[logging.Handler]) -> None: + """Зарегистрировать хэндлеры для управления при ротации.""" + self._handlers = handlers + + async def initialize(self) -> None: + """Создать необходимые директории.""" + self.current_dir.mkdir(parents=True, exist_ok=True) + self.archive_dir.mkdir(parents=True, exist_ok=True) + + async def start(self) -> None: + """Запустить сервис ротации.""" + if self._running: + return + + self._running = True + self._rotation_task = asyncio.create_task(self._rotation_loop()) + logger.info("Сервис ротации логов запущен") + + async def stop(self) -> None: + """Остановить сервис ротации.""" + self._running = False + if self._rotation_task and not self._rotation_task.done(): + self._rotation_task.cancel() + try: + await self._rotation_task + except asyncio.CancelledError: + pass + logger.info("Сервис ротации логов остановлен") + + def is_running(self) -> bool: + """Проверить, запущен ли сервис.""" + return self._running + + async def _rotation_loop(self) -> None: + """Основной цикл ожидания времени ротации.""" + while self._running: + next_rotation = self._calculate_next_rotation_time() + now = datetime.now(get_local_timezone()) + wait_seconds = (next_rotation - now).total_seconds() + + if wait_seconds > 0: + logger.info( + "Следующая ротация логов: %s (через %.1f часов)", + next_rotation.strftime("%Y-%m-%d %H:%M"), + wait_seconds / 3600, + ) + try: + await asyncio.sleep(wait_seconds) + except asyncio.CancelledError: + break + + if self._running: + await self.rotate_logs() + + def _calculate_next_rotation_time(self) -> datetime: + """Вычислить время следующей ротации.""" + now = datetime.now(get_local_timezone()) + + # Парсим время ротации + time_str = settings.LOG_ROTATION_TIME + try: + hours, minutes = map(int, time_str.split(":")) + except ValueError: + hours, minutes = 0, 0 + logger.warning( + "Некорректное LOG_ROTATION_TIME='%s', используем 00:00", time_str + ) + + next_rotation = now.replace(hour=hours, minute=minutes, second=0, microsecond=0) + + # Если время уже прошло сегодня, ротация завтра + if next_rotation <= now: + next_rotation += timedelta(days=1) + + return next_rotation + + async def rotate_logs(self) -> Tuple[bool, str]: + """Выполнить ротацию логов. + + Создаёт один архив logs_YYYY-MM-DD.tar.gz со всеми лог-файлами за день. + + Returns: + Tuple[bool, str]: (успех, сообщение) + """ + try: + logger.info("Начинаем ротацию логов...") + + # Дата для архива (вчера, т.к. логи были за предыдущие сутки) + yesterday = ( + datetime.now(get_local_timezone()) - timedelta(days=1) + ).strftime("%Y-%m-%d") + + # Сбрасываем буферы хэндлеров перед архивацией + for handler in self._handlers: + try: + handler.flush() + except Exception: + pass + + # Собираем файлы для архивации + files_to_archive: List[Tuple[Path, str]] = [] + for name, log_path in self.log_files.items(): + if log_path.exists() and log_path.stat().st_size > 0: + files_to_archive.append((log_path, f"{name}.log")) + + if not files_to_archive: + message = "Нет логов для архивации" + logger.info(message) + return True, message + + # Создаём один архив со всеми логами + archive_path = await self._create_archive(files_to_archive, yesterday) + + if archive_path: + # Очищаем текущие лог-файлы + for log_path, _ in files_to_archive: + log_path.write_text("") + + # Очистка старых архивов + await self._cleanup_old_archives() + + # Отправка в Telegram + if settings.LOG_ROTATION_SEND_TO_TELEGRAM and self.bot: + await self._send_logs_to_telegram(archive_path, yesterday) + + message = f"Ротация логов завершена. Архив: {archive_path.name}" + logger.info(message) + return True, message + else: + message = "Ошибка создания архива логов" + logger.error(message) + return False, message + + except Exception as error: + message = f"Ошибка ротации логов: {error}" + logger.error(message, exc_info=True) + return False, message + + async def _create_archive( + self, + files: List[Tuple[Path, str]], + date_str: str, + ) -> Optional[Path]: + """Создать архив со всеми логами за день. + + Args: + files: список (путь к файлу, имя в архиве) + date_str: дата в формате YYYY-MM-DD + + Returns: + Путь к созданному архиву или None при ошибке + """ + try: + if settings.LOG_ROTATION_COMPRESS: + archive_name = f"logs_{date_str}.tar.gz" + mode = "w:gz" + else: + archive_name = f"logs_{date_str}.tar" + mode = "w" + + archive_path = self.archive_dir / archive_name + + def _create_tar(): + with tarfile.open(archive_path, mode) as tar: + for file_path, arcname in files: + tar.add(file_path, arcname=arcname) + + await asyncio.to_thread(_create_tar) + logger.debug("Создан архив: %s", archive_path) + return archive_path + + except Exception as error: + logger.error("Ошибка создания архива: %s", error) + return None + + async def _cleanup_old_archives(self) -> None: + """Удалить архивы старше LOG_ROTATION_KEEP_DAYS.""" + keep_days = settings.LOG_ROTATION_KEEP_DAYS + cutoff_date = datetime.now(get_local_timezone()) - timedelta(days=keep_days) + + if not self.archive_dir.exists(): + return + + # Ищем файлы вида logs_YYYY-MM-DD.tar.gz или logs_YYYY-MM-DD.tar + for archive_file in self.archive_dir.iterdir(): + if not archive_file.is_file(): + continue + + # Извлекаем дату из имени файла logs_YYYY-MM-DD.tar.gz + name = archive_file.name + if not name.startswith("logs_"): + continue + + try: + # logs_2025-01-26.tar.gz -> 2025-01-26 + date_part = name.replace("logs_", "").replace(".tar.gz", "").replace(".tar", "") + file_date = datetime.strptime(date_part, "%Y-%m-%d") + file_date = file_date.replace(tzinfo=get_local_timezone()) + + if file_date < cutoff_date: + archive_file.unlink() + logger.info("Удален старый архив логов: %s", archive_file.name) + except ValueError: + # Пропускаем файлы с некорректным форматом имени + pass + + async def _send_logs_to_telegram( + self, + archive_path: Path, + date_str: str, + ) -> None: + """Отправить архив логов в Telegram.""" + chat_id = settings.get_log_rotation_chat_id() + if not chat_id: + logger.warning("LOG_ROTATION_CHAT_ID не задан, пропускаем отправку") + return + + topic_id = settings.get_log_rotation_topic_id() + + try: + file_size_kb = archive_path.stat().st_size / 1024 + caption = ( + f"Логи бота\n" + f"Дата: {date_str}\n" + f"Файл: {archive_path.name}\n" + f"Размер: {file_size_kb:.1f} KB" + ) + + send_kwargs = { + "chat_id": chat_id, + "document": FSInputFile(archive_path), + "caption": caption, + "parse_mode": "HTML", + } + + if topic_id: + send_kwargs["message_thread_id"] = topic_id + + await self.bot.send_document(**send_kwargs) + logger.info("Архив логов отправлен: %s", archive_path.name) + + except Exception as error: + logger.error("Ошибка отправки архива %s: %s", archive_path.name, error) + + # === Ручные операции === + + async def force_rotate(self) -> Tuple[bool, str]: + """Принудительная ротация (для админ-команды).""" + return await self.rotate_logs() + + def get_status(self) -> LogRotationStatus: + """Получить статус сервиса.""" + archive_count = 0 + if self.archive_dir.exists(): + # Считаем файлы logs_*.tar.gz или logs_*.tar + archive_count = len( + [f for f in self.archive_dir.iterdir() + if f.is_file() and f.name.startswith("logs_")] + ) + + next_rotation = None + if self._running: + next_rotation = self._calculate_next_rotation_time().isoformat() + + return LogRotationStatus( + enabled=settings.is_log_rotation_enabled(), + running=self._running, + rotation_time=settings.LOG_ROTATION_TIME, + keep_days=settings.LOG_ROTATION_KEEP_DAYS, + send_to_telegram=settings.LOG_ROTATION_SEND_TO_TELEGRAM, + next_rotation=next_rotation, + log_dir=str(self.log_dir), + archive_count=archive_count, + ) + + +# Глобальный экземпляр сервиса +log_rotation_service = LogRotationService() diff --git a/app/services/payment/cloudpayments.py b/app/services/payment/cloudpayments.py index c55201aa..2250a8a7 100644 --- a/app/services/payment/cloudpayments.py +++ b/app/services/payment/cloudpayments.py @@ -3,7 +3,6 @@ from __future__ import annotations import json -import logging from datetime import datetime from importlib import import_module from typing import Any, Dict, Optional @@ -18,8 +17,7 @@ from app.services.subscription_auto_purchase_service import ( ) from app.services.cloudpayments_service import CloudPaymentsAPIError, CloudPaymentsService from app.utils.user_utils import format_referrer_info - -logger = logging.getLogger(__name__) +from app.utils.payment_logger import payment_logger as logger class CloudPaymentsPaymentMixin: diff --git a/app/services/payment/common.py b/app/services/payment/common.py index 32823fb4..6be5f0b7 100644 --- a/app/services/payment/common.py +++ b/app/services/payment/common.py @@ -7,7 +7,6 @@ from __future__ import annotations -import logging from types import SimpleNamespace from typing import Any @@ -25,8 +24,7 @@ from app.services.subscription_checkout_service import ( ) from app.services.user_cart_service import user_cart_service from app.utils.miniapp_buttons import build_miniapp_or_callback_button - -logger = logging.getLogger(__name__) +from app.utils.payment_logger import payment_logger as logger class PaymentCommonMixin: diff --git a/app/services/payment/cryptobot.py b/app/services/payment/cryptobot.py index 17c00784..95855c56 100644 --- a/app/services/payment/cryptobot.py +++ b/app/services/payment/cryptobot.py @@ -1,7 +1,7 @@ """Mixin с логикой обработки платежей CryptoBot.""" from __future__ import annotations -import logging + import math from dataclasses import dataclass from datetime import datetime @@ -28,8 +28,7 @@ from app.services.subscription_renewal_service import ( ) from app.utils.currency_converter import currency_converter from app.utils.user_utils import format_referrer_info - -logger = logging.getLogger(__name__) +from app.utils.payment_logger import payment_logger as logger renewal_service = SubscriptionRenewalService() diff --git a/app/services/payment/heleket.py b/app/services/payment/heleket.py index 27d24a2b..79552d63 100644 --- a/app/services/payment/heleket.py +++ b/app/services/payment/heleket.py @@ -2,7 +2,6 @@ from __future__ import annotations -import logging import secrets import time from datetime import datetime, timezone, timedelta @@ -14,8 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.models import PaymentMethod, TransactionType from app.utils.user_utils import format_referrer_info - -logger = logging.getLogger(__name__) +from app.utils.payment_logger import payment_logger as logger class HeleketPaymentMixin: diff --git a/app/services/payment/mulenpay.py b/app/services/payment/mulenpay.py index f732c802..4faeeeac 100644 --- a/app/services/payment/mulenpay.py +++ b/app/services/payment/mulenpay.py @@ -2,7 +2,6 @@ from __future__ import annotations -import logging import uuid from importlib import import_module from typing import Any, Dict, Optional @@ -16,8 +15,7 @@ from app.services.subscription_auto_purchase_service import ( auto_purchase_saved_cart_after_topup, ) from app.utils.user_utils import format_referrer_info - -logger = logging.getLogger(__name__) +from app.utils.payment_logger import payment_logger as logger class MulenPayPaymentMixin: diff --git a/app/services/payment/pal24.py b/app/services/payment/pal24.py index 20ba7eff..b6f844d3 100644 --- a/app/services/payment/pal24.py +++ b/app/services/payment/pal24.py @@ -2,7 +2,6 @@ from __future__ import annotations -import logging from datetime import datetime from importlib import import_module import uuid @@ -18,8 +17,7 @@ from app.services.subscription_auto_purchase_service import ( auto_purchase_saved_cart_after_topup, ) from app.utils.user_utils import format_referrer_info - -logger = logging.getLogger(__name__) +from app.utils.payment_logger import payment_logger as logger class Pal24PaymentMixin: diff --git a/app/services/payment/platega.py b/app/services/payment/platega.py index 76985c36..7f575e50 100644 --- a/app/services/payment/platega.py +++ b/app/services/payment/platega.py @@ -2,7 +2,6 @@ from __future__ import annotations -import logging import uuid from datetime import datetime from importlib import import_module @@ -18,8 +17,7 @@ from app.services.subscription_auto_purchase_service import ( auto_purchase_saved_cart_after_topup, ) from app.utils.user_utils import format_referrer_info - -logger = logging.getLogger(__name__) +from app.utils.payment_logger import payment_logger as logger class PlategaPaymentMixin: diff --git a/app/services/payment/stars.py b/app/services/payment/stars.py index e50cd2ed..a5cff2e7 100644 --- a/app/services/payment/stars.py +++ b/app/services/payment/stars.py @@ -6,7 +6,6 @@ from __future__ import annotations -import logging from dataclasses import dataclass from datetime import datetime from decimal import Decimal, ROUND_FLOOR, ROUND_HALF_UP @@ -25,8 +24,7 @@ from app.services.subscription_auto_purchase_service import ( auto_purchase_saved_cart_after_topup, ) from app.utils.user_utils import format_referrer_info - -logger = logging.getLogger(__name__) +from app.utils.payment_logger import payment_logger as logger @dataclass(slots=True) diff --git a/app/services/payment/tribute.py b/app/services/payment/tribute.py index ec253169..1cb09d32 100644 --- a/app/services/payment/tribute.py +++ b/app/services/payment/tribute.py @@ -4,12 +4,10 @@ from __future__ import annotations import hashlib import hmac -import logging from typing import Dict from app.config import settings - -logger = logging.getLogger(__name__) +from app.utils.payment_logger import payment_logger as logger class TributePaymentMixin: diff --git a/app/services/payment/wata.py b/app/services/payment/wata.py index 3791d42e..35ff554b 100644 --- a/app/services/payment/wata.py +++ b/app/services/payment/wata.py @@ -2,7 +2,6 @@ from __future__ import annotations -import logging import uuid from datetime import datetime from importlib import import_module @@ -18,8 +17,7 @@ from app.services.subscription_auto_purchase_service import ( ) from app.services.wata_service import WataAPIError, WataService from app.utils.user_utils import format_referrer_info - -logger = logging.getLogger(__name__) +from app.utils.payment_logger import payment_logger as logger def _extract_transaction_id(payment: Any, remote_link: Optional[Dict[str, Any]] = None) -> Optional[str]: diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py index 66982e15..8e4b1586 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -6,7 +6,6 @@ from __future__ import annotations -import logging from datetime import datetime from decimal import Decimal, InvalidOperation from importlib import import_module @@ -21,8 +20,7 @@ from app.services.subscription_auto_purchase_service import ( auto_purchase_saved_cart_after_topup, ) from app.utils.user_utils import format_referrer_info - -logger = logging.getLogger(__name__) +from app.utils.payment_logger import payment_logger as logger if TYPE_CHECKING: from app.database.models import YooKassaPayment diff --git a/app/utils/log_handlers.py b/app/utils/log_handlers.py new file mode 100644 index 00000000..baee8d89 --- /dev/null +++ b/app/utils/log_handlers.py @@ -0,0 +1,108 @@ +"""Кастомные хэндлеры для системы логирования с ротацией. + +Модуль предоставляет: +- LevelFilterHandler: фильтрация логов по диапазону уровней +- PaymentLogFilter: перехват логов из платежных модулей +- ExcludePaymentFilter: исключение платежей из основных логов +""" + +from __future__ import annotations + +import logging +from typing import Optional + + +class LevelFilterHandler(logging.Handler): + """Хэндлер, фильтрующий логи по диапазону уровней. + + Используется для разделения логов: + - info.log: только INFO (min_level=INFO, max_level=INFO) + - warning.log: WARNING и выше (min_level=WARNING) + - error.log: ERROR и CRITICAL (min_level=ERROR) + + Args: + filename: Путь к файлу лога + min_level: Минимальный уровень логирования + max_level: Максимальный уровень (по умолчанию CRITICAL) + encoding: Кодировка файла + """ + + def __init__( + self, + filename: str, + min_level: int, + max_level: Optional[int] = None, + encoding: str = "utf-8", + ): + super().__init__(level=min_level) + self.min_level = min_level + self.max_level = max_level if max_level is not None else logging.CRITICAL + self._file_handler = logging.FileHandler(filename, encoding=encoding) + + def emit(self, record: logging.LogRecord) -> None: + """Записать лог только если уровень в заданном диапазоне.""" + if self.min_level <= record.levelno <= self.max_level: + self._file_handler.emit(record) + + def setFormatter(self, fmt: logging.Formatter) -> None: + """Установить форматтер для внутреннего хэндлера.""" + super().setFormatter(fmt) + self._file_handler.setFormatter(fmt) + + def close(self) -> None: + """Закрыть файловый хэндлер.""" + self._file_handler.close() + super().close() + + def flush(self) -> None: + """Сбросить буфер файлового хэндлера.""" + self._file_handler.flush() + + +class PaymentLogFilter(logging.Filter): + """Фильтр для логов платежей. + + Пропускает записи из модулей: + - app.services.payment.* + - app.payments (выделенный логгер) + - Связанные платежные сервисы + """ + + PAYMENT_MODULES = ( + "app.payments", + "app.services.payment", + "app.services.yookassa_service", + "app.services.tribute_service", + "app.services.mulenpay_service", + "app.services.cloudpayments_service", + "app.services.platega_service", + "app.services.pal24_service", + "app.services.wata_service", + "app.external.cryptobot", + "app.external.heleket", + "app.external.tribute", + "app.external.yookassa_webhook", + "app.external.pal24_webhook", + "app.external.wata_webhook", + "app.external.heleket_webhook", + ) + + def filter(self, record: logging.LogRecord) -> bool: + """Пропустить только записи из платежных модулей.""" + return any(record.name.startswith(module) for module in self.PAYMENT_MODULES) + + +class ExcludePaymentFilter(logging.Filter): + """Исключает платежные логи из основных файлов. + + Используется для bot.log, info.log, warning.log, error.log + чтобы платежные записи шли только в payments.log. + """ + + PAYMENT_MODULES = PaymentLogFilter.PAYMENT_MODULES + + def filter(self, record: logging.LogRecord) -> bool: + """Пропустить записи НЕ из платежных модулей.""" + return not any( + record.name.startswith(module) for module in self.PAYMENT_MODULES + ) diff --git a/app/utils/payment_logger.py b/app/utils/payment_logger.py new file mode 100644 index 00000000..ff742a96 --- /dev/null +++ b/app/utils/payment_logger.py @@ -0,0 +1,54 @@ +"""Специальный логгер для платежей. + +Выделенный логгер для всех платежных операций. +Записи идут в отдельный файл payments.log. + +Использование: + from app.utils.payment_logger import payment_logger + + payment_logger.info("Создан YooKassa платеж %s на %s", payment_id, amount) + payment_logger.error("Ошибка обработки webhook: %s", error) +""" + +from __future__ import annotations + +import logging +from typing import Optional + +# Выделенный логгер для всех платежных операций +payment_logger = logging.getLogger("app.payments") + + +def configure_payment_logger( + handler: logging.Handler, + formatter: Optional[logging.Formatter] = None, + level: int = logging.INFO, +) -> None: + """Настроить payment_logger с указанным хэндлером. + + Args: + handler: Хэндлер для записи логов (FileHandler, StreamHandler и т.д.) + formatter: Форматтер для логов (опционально) + level: Уровень логирования (по умолчанию INFO) + """ + payment_logger.setLevel(level) + + if formatter: + handler.setFormatter(formatter) + + payment_logger.addHandler(handler) + + # Предотвращаем дублирование в родительских логгерах + payment_logger.propagate = False + + +def get_payment_logger() -> logging.Logger: + """Получить экземпляр payment_logger. + + Альтернативный способ получения логгера для модулей, + которые предпочитают явный вызов функции. + + Returns: + Настроенный логгер платежей + """ + return payment_logger diff --git a/main.py b/main.py index 1e0e4cf4..7642aa47 100644 --- a/main.py +++ b/main.py @@ -37,6 +37,9 @@ from app.services.contest_rotation_service import contest_rotation_service from app.services.nalogo_queue_service import nalogo_queue_service from app.utils.startup_timeline import StartupTimeline from app.utils.timezone import TimezoneAwareFormatter +from app.utils.log_handlers import LevelFilterHandler, ExcludePaymentFilter +from app.utils.payment_logger import payment_logger, configure_payment_logger +from app.services.log_rotation_service import log_rotation_service class GracefulExit: @@ -55,17 +58,85 @@ async def main(): timezone_name=settings.TIMEZONE, ) - file_handler = logging.FileHandler(settings.LOG_FILE, encoding='utf-8') - file_handler.setFormatter(formatter) + log_handlers = [] - stream_handler = logging.StreamHandler(sys.stdout) - stream_handler.setFormatter(formatter) + # === Инициализация системы логирования === + if settings.is_log_rotation_enabled(): + # Новая система: разделение по уровням + отдельный лог платежей + await log_rotation_service.initialize() + + log_dir = log_rotation_service.current_dir + log_dir.mkdir(parents=True, exist_ok=True) + + # 1. Общий лог (bot.log) - все уровни, без платежей + bot_handler = logging.FileHandler(log_dir / "bot.log", encoding='utf-8') + bot_handler.setFormatter(formatter) + bot_handler.addFilter(ExcludePaymentFilter()) + log_handlers.append(bot_handler) + + # 2. INFO лог - только INFO уровень + info_handler = LevelFilterHandler( + str(log_dir / settings.LOG_INFO_FILE), + min_level=logging.INFO, + max_level=logging.INFO, + ) + info_handler.setFormatter(formatter) + info_handler.addFilter(ExcludePaymentFilter()) + log_handlers.append(info_handler) + + # 3. WARNING лог - WARNING и выше + warning_handler = LevelFilterHandler( + str(log_dir / settings.LOG_WARNING_FILE), + min_level=logging.WARNING, + ) + warning_handler.setFormatter(formatter) + warning_handler.addFilter(ExcludePaymentFilter()) + log_handlers.append(warning_handler) + + # 4. ERROR лог - только ERROR и CRITICAL + error_handler = LevelFilterHandler( + str(log_dir / settings.LOG_ERROR_FILE), + min_level=logging.ERROR, + ) + error_handler.setFormatter(formatter) + error_handler.addFilter(ExcludePaymentFilter()) + log_handlers.append(error_handler) + + # 5. Payment лог - отдельный файл для платежей + payment_handler = logging.FileHandler( + log_dir / settings.LOG_PAYMENTS_FILE, + encoding='utf-8', + ) + configure_payment_logger(payment_handler, formatter) + + # 6. Консольный вывод + stream_handler = logging.StreamHandler(sys.stdout) + stream_handler.setFormatter(formatter) + log_handlers.append(stream_handler) + + logging.basicConfig( + level=getattr(logging, settings.LOG_LEVEL), + handlers=log_handlers, + ) + + # Регистрируем хэндлеры для управления при ротации + log_rotation_service.register_handlers(log_handlers) + + else: + # Старое поведение: один файл лога + file_handler = logging.FileHandler(settings.LOG_FILE, encoding='utf-8') + file_handler.setFormatter(formatter) + log_handlers.append(file_handler) + + stream_handler = logging.StreamHandler(sys.stdout) + stream_handler.setFormatter(formatter) + log_handlers.append(stream_handler) + + logging.basicConfig( + level=getattr(logging, settings.LOG_LEVEL), + handlers=log_handlers, + ) - logging.basicConfig( - level=getattr(logging, settings.LOG_LEVEL), - handlers=[file_handler, stream_handler], - ) - # Установим более высокий уровень логирования для "мусорных" логов logging.getLogger("aiohttp.access").setLevel(logging.ERROR) logging.getLogger("aiohttp.client").setLevel(logging.WARNING) @@ -74,7 +145,7 @@ async def main(): logging.getLogger("aiogram").setLevel(logging.WARNING) logging.getLogger("uvicorn.access").setLevel(logging.ERROR) logging.getLogger("uvicorn.error").setLevel(logging.WARNING) - + logger = logging.getLogger(__name__) timeline = StartupTimeline(logger, "Bedolaga Remnawave Bot") timeline.log_banner( @@ -246,6 +317,28 @@ async def main(): stage.warning(f"Ошибка запуска ротации игр: {e}") logger.error(f"❌ Ошибка запуска ротации игр: {e}") + if settings.is_log_rotation_enabled(): + async with timeline.stage( + "Ротация логов", + "📋", + success_message="Сервис ротации логов готов", + ) as stage: + try: + log_rotation_service.set_bot(bot) + await log_rotation_service.start() + status = log_rotation_service.get_status() + stage.log(f"Время ротации: {status.rotation_time}") + stage.log(f"Хранение архивов: {status.keep_days} дней") + if status.send_to_telegram: + stage.log("Отправка в Telegram: включена") + if status.next_rotation: + from datetime import datetime + next_dt = datetime.fromisoformat(status.next_rotation) + stage.log(f"Следующая ротация: {next_dt.strftime('%d.%m.%Y %H:%M')}") + except Exception as e: + stage.warning(f"Ошибка запуска сервиса ротации логов: {e}") + logger.error(f"❌ Ошибка запуска сервиса ротации логов: {e}") + async with timeline.stage( "Автосинхронизация RemnaWave", "🔄", @@ -673,6 +766,13 @@ async def main(): except Exception as e: logger.error(f"Ошибка остановки ротации игр: {e}") + if settings.is_log_rotation_enabled(): + logger.info("ℹ️ Остановка сервиса ротации логов...") + try: + await log_rotation_service.stop() + except Exception as e: + logger.error(f"Ошибка остановки сервиса ротации логов: {e}") + logger.info("ℹ️ Остановка очереди чеков NaloGO...") try: await nalogo_queue_service.stop()