feat(logging): добавить систему ротации логов
- Ежедневная ротация в 00:00 с архивацией в tar.gz - Разделение по уровням: info.log, warning.log, error.log - Отдельный payments.log для платежных операций - Отправка архивов в Telegram-канал бекапов - Автоочистка архивов старше 7 дней (настраивается) - Переключатель LOG_ROTATION_ENABLED (по умолчанию выключен)
This commit is contained in:
@@ -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=
|
||||
|
||||
+47
-1
@@ -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(),
|
||||
|
||||
@@ -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"<b>Логи бота</b>\n"
|
||||
f"Дата: {date_str}\n"
|
||||
f"Файл: <code>{archive_path.name}</code>\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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user