Implement user-specific question numbering and update database schema. Added triggers for automatic question numbering and adjustments upon deletion. Enhanced CRUD operations to manage user_question_number effectively.
This commit is contained in:
9
services/__init__.py
Normal file
9
services/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
Сервисы для работы с данными и утилитами
|
||||
"""
|
||||
|
||||
# Импорты для обратной совместимости
|
||||
from .infrastructure.database import DatabaseService
|
||||
from .utils import generate_referral_link, format_question_info
|
||||
|
||||
__all__ = ['DatabaseService', 'generate_referral_link', 'format_question_info']
|
||||
7
services/auth/__init__.py
Normal file
7
services/auth/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Модуль авторизации и разрешений
|
||||
"""
|
||||
|
||||
from .auth_new import AuthService
|
||||
|
||||
__all__ = ['AuthService']
|
||||
146
services/auth/auth_new.py
Normal file
146
services/auth/auth_new.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""
|
||||
Новый сервис авторизации с использованием системы разрешений
|
||||
Соблюдает принцип открытости/закрытости (OCP)
|
||||
"""
|
||||
from typing import Optional
|
||||
from services.infrastructure.database import DatabaseService
|
||||
from services.permissions import get_permission_checker
|
||||
from services.infrastructure.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""
|
||||
Сервис авторизации, использующий систему разрешений.
|
||||
Соблюдает принцип открытости/закрытости (OCP).
|
||||
"""
|
||||
|
||||
def __init__(self, database: DatabaseService, config_provider):
|
||||
self.database = database
|
||||
self.config = config_provider
|
||||
|
||||
def is_admin(self, user_id: int) -> bool:
|
||||
"""
|
||||
Проверка, является ли пользователь администратором
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя в Telegram
|
||||
|
||||
Returns:
|
||||
True если пользователь администратор, False иначе
|
||||
"""
|
||||
return user_id in self.config.ADMINS
|
||||
|
||||
async def is_superuser(self, user_id: int) -> bool:
|
||||
"""
|
||||
Проверка, является ли пользователь суперпользователем
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя в Telegram
|
||||
|
||||
Returns:
|
||||
True если пользователь суперпользователь, False иначе
|
||||
"""
|
||||
try:
|
||||
user = await self.database.get_user(user_id)
|
||||
return user.is_superuser if user else False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def get_user_role(self, user_id: int) -> str:
|
||||
"""
|
||||
Получение роли пользователя
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя в Telegram
|
||||
|
||||
Returns:
|
||||
Роль пользователя: 'admin', 'superuser' или 'user'
|
||||
"""
|
||||
if self.is_admin(user_id):
|
||||
return 'admin'
|
||||
elif await self.is_superuser(user_id):
|
||||
return 'superuser'
|
||||
else:
|
||||
return 'user'
|
||||
|
||||
async def has_permission(self, user_id: int, permission: str) -> bool:
|
||||
"""
|
||||
Проверка наличия разрешения у пользователя через систему разрешений
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя в Telegram
|
||||
permission: Название разрешения
|
||||
|
||||
Returns:
|
||||
True если у пользователя есть разрешение, False иначе
|
||||
"""
|
||||
checker = get_permission_checker()
|
||||
if not checker:
|
||||
logger.error("Проверщик разрешений не инициализирован")
|
||||
return False
|
||||
|
||||
return await checker.has_permission(user_id, permission)
|
||||
|
||||
async def has_any_permission(self, user_id: int, permissions: list[str]) -> bool:
|
||||
"""
|
||||
Проверка наличия хотя бы одного из разрешений у пользователя
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя в Telegram
|
||||
permissions: Список названий разрешений
|
||||
|
||||
Returns:
|
||||
True если у пользователя есть хотя бы одно разрешение, False иначе
|
||||
"""
|
||||
checker = get_permission_checker()
|
||||
if not checker:
|
||||
logger.error("Проверщик разрешений не инициализирован")
|
||||
return False
|
||||
|
||||
return await checker.has_any_permission(user_id, permissions)
|
||||
|
||||
async def has_all_permissions(self, user_id: int, permissions: list[str]) -> bool:
|
||||
"""
|
||||
Проверка наличия всех разрешений у пользователя
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя в Telegram
|
||||
permissions: Список названий разрешений
|
||||
|
||||
Returns:
|
||||
True если у пользователя есть все разрешения, False иначе
|
||||
"""
|
||||
checker = get_permission_checker()
|
||||
if not checker:
|
||||
logger.error("Проверщик разрешений не инициализирован")
|
||||
return False
|
||||
|
||||
return await checker.has_all_permissions(user_id, permissions)
|
||||
|
||||
async def get_user_permissions(self, user_id: int) -> list[str]:
|
||||
"""
|
||||
Получение списка всех разрешений пользователя
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя в Telegram
|
||||
|
||||
Returns:
|
||||
Список названий разрешений пользователя
|
||||
"""
|
||||
checker = get_permission_checker()
|
||||
if not checker:
|
||||
logger.error("Проверщик разрешений не инициализирован")
|
||||
return []
|
||||
|
||||
# Получаем все доступные разрешения
|
||||
registry = checker.registry
|
||||
all_permissions = registry.get_all()
|
||||
|
||||
user_permissions = []
|
||||
for permission_name in all_permissions.keys():
|
||||
if await checker.has_permission(user_id, permission_name):
|
||||
user_permissions.append(permission_name)
|
||||
|
||||
return user_permissions
|
||||
10
services/business/__init__.py
Normal file
10
services/business/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
Бизнес-логика сервисов
|
||||
"""
|
||||
|
||||
from .user_service import UserService
|
||||
from .question_service import QuestionService
|
||||
from .message_service import MessageService
|
||||
from .pagination_service import PaginationService
|
||||
|
||||
__all__ = ['UserService', 'QuestionService', 'MessageService', 'PaginationService']
|
||||
237
services/business/message_service.py
Normal file
237
services/business/message_service.py
Normal file
@@ -0,0 +1,237 @@
|
||||
"""
|
||||
Сервис для отправки сообщений
|
||||
"""
|
||||
from typing import Optional, Union
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, ReplyKeyboardMarkup
|
||||
from aiogram import Bot
|
||||
|
||||
from services.infrastructure.logger import get_logger
|
||||
from services.rate_limiting.rate_limit_service import RateLimitService
|
||||
from services.infrastructure.logging_decorators import log_function_call, log_business_event
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class MessageService:
|
||||
"""Сервис для отправки сообщений"""
|
||||
|
||||
def __init__(self, rate_limit_service: Optional[RateLimitService] = None):
|
||||
self.rate_limit_service = rate_limit_service
|
||||
|
||||
@log_business_event("send_message", log_params=True, log_result=True)
|
||||
async def send_message(
|
||||
self,
|
||||
target: Union[Message, CallbackQuery, Bot],
|
||||
text: str,
|
||||
reply_markup: Optional[Union[InlineKeyboardMarkup, ReplyKeyboardMarkup]] = None,
|
||||
parse_mode: str = "HTML"
|
||||
) -> bool:
|
||||
"""
|
||||
Отправка сообщения
|
||||
|
||||
Args:
|
||||
target: Целевой объект (Message, CallbackQuery или Bot)
|
||||
text: Текст сообщения
|
||||
reply_markup: Клавиатура
|
||||
parse_mode: Режим парсинга
|
||||
|
||||
Returns:
|
||||
True если успешно отправлено
|
||||
"""
|
||||
try:
|
||||
logger.info(f"📤 Отправка сообщения с клавиатурой: {type(reply_markup).__name__ if reply_markup else 'None'}")
|
||||
|
||||
if isinstance(target, Message):
|
||||
await target.answer(text, reply_markup=reply_markup, parse_mode=parse_mode)
|
||||
elif isinstance(target, CallbackQuery):
|
||||
await target.message.answer(text, reply_markup=reply_markup, parse_mode=parse_mode)
|
||||
elif isinstance(target, Bot):
|
||||
# Для Bot нужен chat_id, который должен быть передан отдельно
|
||||
logger.error("Для Bot нужен chat_id")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке сообщения: {e}")
|
||||
return False
|
||||
|
||||
@log_business_event("edit_message", log_params=True, log_result=True)
|
||||
async def edit_message(
|
||||
self,
|
||||
target: Union[Message, CallbackQuery],
|
||||
text: str,
|
||||
reply_markup: Optional[InlineKeyboardMarkup] = None,
|
||||
parse_mode: str = "HTML"
|
||||
) -> bool:
|
||||
"""
|
||||
Редактирование сообщения
|
||||
|
||||
Args:
|
||||
target: Целевой объект (Message или CallbackQuery)
|
||||
text: Новый текст сообщения
|
||||
reply_markup: Новая клавиатура
|
||||
parse_mode: Режим парсинга
|
||||
|
||||
Returns:
|
||||
True если успешно отредактировано
|
||||
"""
|
||||
try:
|
||||
if isinstance(target, Message):
|
||||
await target.edit_text(text, reply_markup=reply_markup, parse_mode=parse_mode)
|
||||
elif isinstance(target, CallbackQuery):
|
||||
await target.message.edit_text(text, reply_markup=reply_markup, parse_mode=parse_mode)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при редактировании сообщения: {e}")
|
||||
return False
|
||||
|
||||
@log_function_call(log_params=True, log_result=True)
|
||||
async def send_callback_answer(
|
||||
self,
|
||||
callback: CallbackQuery,
|
||||
text: Optional[str] = None,
|
||||
show_alert: bool = False
|
||||
) -> bool:
|
||||
"""
|
||||
Отправка ответа на callback query
|
||||
|
||||
Args:
|
||||
callback: CallbackQuery объект
|
||||
text: Текст ответа
|
||||
show_alert: Показывать ли alert
|
||||
|
||||
Returns:
|
||||
True если успешно отправлено
|
||||
"""
|
||||
try:
|
||||
await callback.answer(text, show_alert=show_alert)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке callback answer: {e}")
|
||||
return False
|
||||
|
||||
@log_business_event("send_bot_message", log_params=True, log_result=True)
|
||||
async def send_bot_message(
|
||||
self,
|
||||
bot: Bot,
|
||||
chat_id: int,
|
||||
text: str,
|
||||
reply_markup: Optional[Union[InlineKeyboardMarkup, ReplyKeyboardMarkup]] = None,
|
||||
parse_mode: str = "HTML"
|
||||
) -> bool:
|
||||
"""
|
||||
Отправка сообщения через бота с rate limiting
|
||||
|
||||
Args:
|
||||
bot: Экземпляр бота
|
||||
chat_id: ID чата
|
||||
text: Текст сообщения
|
||||
reply_markup: Клавиатура
|
||||
parse_mode: Режим парсинга
|
||||
|
||||
Returns:
|
||||
True если успешно отправлено
|
||||
"""
|
||||
try:
|
||||
if self.rate_limit_service:
|
||||
# Используем rate limiting
|
||||
await self.rate_limit_service.send_with_rate_limit(
|
||||
bot.send_message,
|
||||
chat_id,
|
||||
text=text,
|
||||
reply_markup=reply_markup,
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
else:
|
||||
# Отправляем без rate limiting
|
||||
await bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
reply_markup=reply_markup,
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке сообщения через бота в чат {chat_id}: {e}")
|
||||
return False
|
||||
|
||||
@log_business_event("send_notification", log_params=True, log_result=True)
|
||||
async def send_notification(
|
||||
self,
|
||||
bot: Bot,
|
||||
user_id: int,
|
||||
title: str,
|
||||
message: str,
|
||||
reply_markup: Optional[InlineKeyboardMarkup] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Отправка уведомления пользователю с rate limiting
|
||||
|
||||
Args:
|
||||
bot: Экземпляр бота
|
||||
user_id: ID пользователя
|
||||
title: Заголовок уведомления
|
||||
message: Текст уведомления
|
||||
reply_markup: Клавиатура
|
||||
|
||||
Returns:
|
||||
True если успешно отправлено
|
||||
"""
|
||||
try:
|
||||
notification_text = f"🔔 <b>{title}</b>\n\n{message}"
|
||||
|
||||
if self.rate_limit_service:
|
||||
# Используем rate limiting
|
||||
await self.rate_limit_service.send_with_rate_limit(
|
||||
bot.send_message,
|
||||
user_id,
|
||||
text=notification_text,
|
||||
reply_markup=reply_markup,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
# Отправляем без rate limiting
|
||||
await bot.send_message(
|
||||
chat_id=user_id,
|
||||
text=notification_text,
|
||||
reply_markup=reply_markup,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
logger.info(f"Уведомление отправлено пользователю {user_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке уведомления пользователю {user_id}: {e}")
|
||||
return False
|
||||
|
||||
@log_business_event("send_error_message", log_params=True, log_result=True)
|
||||
async def send_error_message(
|
||||
self,
|
||||
target: Union[Message, CallbackQuery],
|
||||
error_text: str = "❌ Произошла ошибка. Попробуйте позже."
|
||||
) -> bool:
|
||||
"""
|
||||
Отправка сообщения об ошибке
|
||||
|
||||
Args:
|
||||
target: Целевой объект
|
||||
error_text: Текст ошибки
|
||||
|
||||
Returns:
|
||||
True если успешно отправлено
|
||||
"""
|
||||
try:
|
||||
if isinstance(target, CallbackQuery):
|
||||
await target.answer(error_text, show_alert=True)
|
||||
else:
|
||||
await self.send_message(target, error_text)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке сообщения об ошибке: {e}")
|
||||
return False
|
||||
185
services/business/pagination_service.py
Normal file
185
services/business/pagination_service.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
Сервис для работы с пагинацией
|
||||
"""
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
|
||||
from config.constants import DEFAULT_PAGE_SIZE, MIN_PAGE_NUMBER
|
||||
from services.infrastructure.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class PaginationService:
|
||||
"""Сервис для работы с пагинацией"""
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
async def calculate_pagination_from_db(
|
||||
self,
|
||||
total_count: int,
|
||||
page: int,
|
||||
per_page: int = DEFAULT_PAGE_SIZE
|
||||
) -> Tuple[int, int, int, int]:
|
||||
"""
|
||||
Расчет пагинации на основе общего количества записей в БД
|
||||
|
||||
Args:
|
||||
total_count: Общее количество записей в БД
|
||||
page: Номер страницы (начиная с 0)
|
||||
per_page: Количество элементов на странице
|
||||
|
||||
Returns:
|
||||
Кортеж (общее_количество, текущая_страница, общее_количество_страниц, offset)
|
||||
"""
|
||||
try:
|
||||
total_pages = (total_count + per_page - 1) // per_page # Округление вверх
|
||||
|
||||
# Проверяем корректность номера страницы
|
||||
if page < MIN_PAGE_NUMBER:
|
||||
page = MIN_PAGE_NUMBER
|
||||
elif page >= total_pages and total_pages > 0:
|
||||
page = total_pages - 1
|
||||
|
||||
# Вычисляем offset для БД
|
||||
offset = page * per_page
|
||||
|
||||
return total_count, page, total_pages, offset
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при расчете пагинации из БД: {e}")
|
||||
return MIN_PAGE_NUMBER, MIN_PAGE_NUMBER, MIN_PAGE_NUMBER, MIN_PAGE_NUMBER
|
||||
|
||||
def calculate_pagination(
|
||||
self,
|
||||
items: List[Any],
|
||||
page: int,
|
||||
per_page: int = DEFAULT_PAGE_SIZE
|
||||
) -> Tuple[List[Any], int, int, int]:
|
||||
"""
|
||||
Расчет пагинации для списка элементов
|
||||
|
||||
Args:
|
||||
items: Список элементов
|
||||
page: Номер страницы (начиная с 0)
|
||||
per_page: Количество элементов на странице
|
||||
|
||||
Returns:
|
||||
Кортеж (элементы_страницы, общее_количество, текущая_страница, общее_количество_страниц)
|
||||
"""
|
||||
try:
|
||||
total_items = len(items)
|
||||
total_pages = (total_items + per_page - 1) // per_page # Округление вверх
|
||||
|
||||
# Проверяем корректность номера страницы
|
||||
if page < MIN_PAGE_NUMBER:
|
||||
page = MIN_PAGE_NUMBER
|
||||
elif page >= total_pages and total_pages > 0:
|
||||
page = total_pages - 1
|
||||
|
||||
# Вычисляем диапазон элементов для текущей страницы
|
||||
start_idx = page * per_page
|
||||
end_idx = min(start_idx + per_page, total_items)
|
||||
page_items = items[start_idx:end_idx]
|
||||
|
||||
return page_items, total_items, page, total_pages
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при расчете пагинации: {e}")
|
||||
return [], MIN_PAGE_NUMBER, MIN_PAGE_NUMBER, MIN_PAGE_NUMBER
|
||||
|
||||
def format_pagination_info(
|
||||
self,
|
||||
current_page: int,
|
||||
total_pages: int,
|
||||
start_idx: int,
|
||||
end_idx: int,
|
||||
total_items: int
|
||||
) -> str:
|
||||
"""
|
||||
Форматирование информации о пагинации
|
||||
|
||||
Args:
|
||||
current_page: Текущая страница
|
||||
total_pages: Общее количество страниц
|
||||
start_idx: Начальный индекс
|
||||
end_idx: Конечный индекс
|
||||
total_items: Общее количество элементов
|
||||
|
||||
Returns:
|
||||
Отформатированная строка с информацией о пагинации
|
||||
"""
|
||||
try:
|
||||
info = f"📊 Показано {start_idx + 1}-{end_idx} из {total_items}\n"
|
||||
info += f"📄 Страница {current_page + 1} из {total_pages}\n\n"
|
||||
|
||||
return info
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при форматировании информации о пагинации: {e}")
|
||||
return ""
|
||||
|
||||
def get_pagination_buttons(
|
||||
self,
|
||||
current_page: int,
|
||||
total_pages: int,
|
||||
callback_prefix: str,
|
||||
additional_buttons: Optional[List[Tuple[str, str]]] = None
|
||||
) -> List[Tuple[str, str]]:
|
||||
"""
|
||||
Получение кнопок пагинации
|
||||
|
||||
Args:
|
||||
current_page: Текущая страница
|
||||
total_pages: Общее количество страниц
|
||||
callback_prefix: Префикс для callback_data
|
||||
additional_buttons: Дополнительные кнопки
|
||||
|
||||
Returns:
|
||||
Список кортежей (текст_кнопки, callback_data)
|
||||
"""
|
||||
try:
|
||||
buttons = []
|
||||
|
||||
# Кнопка "Предыдущая"
|
||||
if current_page > MIN_PAGE_NUMBER:
|
||||
buttons.append(("⬅️", f"{callback_prefix}_page_{current_page - 1}"))
|
||||
|
||||
# Кнопка "Следующая"
|
||||
if current_page < total_pages - 1:
|
||||
buttons.append(("➡️", f"{callback_prefix}_page_{current_page + 1}"))
|
||||
|
||||
# Дополнительные кнопки
|
||||
if additional_buttons:
|
||||
buttons.extend(additional_buttons)
|
||||
|
||||
return buttons
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании кнопок пагинации: {e}")
|
||||
return []
|
||||
|
||||
def validate_page_number(self, page: int, total_pages: int) -> int:
|
||||
"""
|
||||
Валидация номера страницы
|
||||
|
||||
Args:
|
||||
page: Номер страницы
|
||||
total_pages: Общее количество страниц
|
||||
|
||||
Returns:
|
||||
Валидный номер страницы
|
||||
"""
|
||||
try:
|
||||
if page < 0:
|
||||
return 0
|
||||
elif page >= total_pages and total_pages > 0:
|
||||
return total_pages - 1
|
||||
else:
|
||||
return page
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при валидации номера страницы: {e}")
|
||||
return MIN_PAGE_NUMBER
|
||||
280
services/business/question_service.py
Normal file
280
services/business/question_service.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""
|
||||
Сервис для управления вопросами
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Tuple
|
||||
from aiogram import Bot
|
||||
|
||||
from models.question import Question, QuestionStatus
|
||||
from services.infrastructure.database import DatabaseService
|
||||
from services.utils import UtilsService
|
||||
from services.infrastructure.logger import get_logger
|
||||
from services.infrastructure.metrics import get_metrics_service
|
||||
from services.infrastructure.logging_decorators import log_function_call, log_business_event
|
||||
from services.infrastructure.logging_utils import log_question_created, log_question_answered
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class QuestionService:
|
||||
"""Сервис для управления вопросами"""
|
||||
|
||||
def __init__(self, database: DatabaseService, utils: UtilsService):
|
||||
self.database = database
|
||||
self.utils = utils
|
||||
self.metrics = get_metrics_service()
|
||||
|
||||
@log_business_event("create_question", log_params=True, log_result=True)
|
||||
async def create_question(self, from_user_id: int, to_user_id: int, message_text: str) -> Question:
|
||||
"""
|
||||
Создание нового вопроса
|
||||
|
||||
Args:
|
||||
from_user_id: ID автора вопроса
|
||||
to_user_id: ID получателя вопроса
|
||||
message_text: Текст вопроса
|
||||
|
||||
Returns:
|
||||
Созданный объект вопроса
|
||||
"""
|
||||
try:
|
||||
question = Question(
|
||||
from_user_id=from_user_id,
|
||||
to_user_id=to_user_id,
|
||||
message_text=message_text.strip(),
|
||||
status=QuestionStatus.PENDING,
|
||||
created_at=datetime.now(),
|
||||
is_anonymous=True
|
||||
)
|
||||
|
||||
question = await self.database.create_question(question)
|
||||
self.metrics.increment_questions("created")
|
||||
return question
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании вопроса от {from_user_id} к {to_user_id}: {e}")
|
||||
raise
|
||||
|
||||
@log_function_call(log_params=True, log_result=True)
|
||||
async def get_question(self, question_id: int) -> Optional[Question]:
|
||||
"""
|
||||
Получение вопроса по ID
|
||||
|
||||
Args:
|
||||
question_id: ID вопроса
|
||||
|
||||
Returns:
|
||||
Объект вопроса или None
|
||||
"""
|
||||
try:
|
||||
return await self.database.get_question(question_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении вопроса {question_id}: {e}")
|
||||
return None
|
||||
|
||||
@log_function_call(log_params=True, log_result=True)
|
||||
async def get_user_questions(self, user_id: int, limit: int = 50, offset: int = 0) -> List[Question]:
|
||||
"""
|
||||
Получение вопросов пользователя
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
limit: Лимит вопросов
|
||||
offset: Смещение
|
||||
|
||||
Returns:
|
||||
Список вопросов
|
||||
"""
|
||||
try:
|
||||
return await self.database.get_user_questions(user_id, limit=limit, offset=offset)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении вопросов пользователя {user_id}: {e}")
|
||||
return []
|
||||
|
||||
@log_business_event("answer_question", log_params=True, log_result=True)
|
||||
async def answer_question(self, question_id: int, answer_text: str) -> Optional[Question]:
|
||||
"""
|
||||
Ответ на вопрос
|
||||
|
||||
Args:
|
||||
question_id: ID вопроса
|
||||
answer_text: Текст ответа
|
||||
|
||||
Returns:
|
||||
Обновленный объект вопроса или None
|
||||
"""
|
||||
try:
|
||||
question = await self.database.get_question(question_id)
|
||||
if not question:
|
||||
return None
|
||||
|
||||
question.mark_as_answered(answer_text.strip())
|
||||
question.answered_at = datetime.now()
|
||||
|
||||
question = await self.database.update_question(question)
|
||||
self.metrics.increment_answers("sent")
|
||||
return question
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при ответе на вопрос {question_id}: {e}")
|
||||
return None
|
||||
|
||||
@log_business_event("reject_question", log_params=True, log_result=True)
|
||||
async def reject_question(self, question_id: int) -> Optional[Question]:
|
||||
"""
|
||||
Отклонение вопроса
|
||||
|
||||
Args:
|
||||
question_id: ID вопроса
|
||||
|
||||
Returns:
|
||||
Обновленный объект вопроса или None
|
||||
"""
|
||||
try:
|
||||
question = await self.database.get_question(question_id)
|
||||
if not question:
|
||||
return None
|
||||
|
||||
question.mark_as_rejected()
|
||||
question.answered_at = datetime.now()
|
||||
|
||||
question = await self.database.update_question(question)
|
||||
self.metrics.increment_questions("rejected")
|
||||
return question
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отклонении вопроса {question_id}: {e}")
|
||||
return None
|
||||
|
||||
@log_business_event("delete_question", log_params=True, log_result=True)
|
||||
async def delete_question(self, question_id: int) -> Optional[Question]:
|
||||
"""
|
||||
Удаление вопроса
|
||||
|
||||
Args:
|
||||
question_id: ID вопроса
|
||||
|
||||
Returns:
|
||||
Обновленный объект вопроса или None
|
||||
"""
|
||||
try:
|
||||
question = await self.database.get_question(question_id)
|
||||
if not question:
|
||||
return None
|
||||
|
||||
question.mark_as_deleted()
|
||||
question.answered_at = datetime.now()
|
||||
|
||||
question = await self.database.update_question(question)
|
||||
self.metrics.increment_questions("deleted")
|
||||
return question
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении вопроса {question_id}: {e}")
|
||||
return None
|
||||
|
||||
@log_business_event("edit_answer", log_params=True, log_result=True)
|
||||
async def edit_answer(self, question_id: int, new_answer_text: str) -> Optional[Question]:
|
||||
"""
|
||||
Редактирование ответа на вопрос
|
||||
|
||||
Args:
|
||||
question_id: ID вопроса
|
||||
new_answer_text: Новый текст ответа
|
||||
|
||||
Returns:
|
||||
Обновленный объект вопроса или None
|
||||
"""
|
||||
try:
|
||||
question = await self.database.get_question(question_id)
|
||||
if not question:
|
||||
return None
|
||||
|
||||
question.answer_text = new_answer_text.strip()
|
||||
question.answered_at = datetime.now()
|
||||
|
||||
question = await self.database.update_question(question)
|
||||
self.metrics.increment_answers("edited")
|
||||
return question
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при редактировании ответа на вопрос {question_id}: {e}")
|
||||
return None
|
||||
|
||||
@log_function_call(log_params=True, log_result=True)
|
||||
def validate_question_text(self, text: str, max_length: int = 1000) -> Tuple[bool, str]:
|
||||
"""
|
||||
Валидация текста вопроса
|
||||
|
||||
Args:
|
||||
text: Текст вопроса
|
||||
max_length: Максимальная длина
|
||||
|
||||
Returns:
|
||||
Кортеж (валидность, сообщение об ошибке)
|
||||
"""
|
||||
return self.utils.is_valid_question_text(text, max_length)
|
||||
|
||||
@log_function_call(log_params=True, log_result=True)
|
||||
def validate_answer_text(self, text: str, max_length: int = 2000) -> Tuple[bool, str]:
|
||||
"""
|
||||
Валидация текста ответа
|
||||
|
||||
Args:
|
||||
text: Текст ответа
|
||||
max_length: Максимальная длина
|
||||
|
||||
Returns:
|
||||
Кортеж (валидность, сообщение об ошибке)
|
||||
"""
|
||||
return self.utils.is_valid_answer_text(text, max_length)
|
||||
|
||||
@log_function_call(log_params=True, log_result=True)
|
||||
async def send_answer_to_author(self, bot: Bot, question: Question, answer_text: str) -> bool:
|
||||
"""
|
||||
Отправка ответа автору вопроса
|
||||
|
||||
Args:
|
||||
bot: Экземпляр бота
|
||||
question: Объект вопроса
|
||||
answer_text: Текст ответа
|
||||
|
||||
Returns:
|
||||
True если успешно отправлено
|
||||
"""
|
||||
try:
|
||||
await self.utils.send_answer_to_author(bot, question, answer_text)
|
||||
self.metrics.increment_answers("delivered")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке ответа автору вопроса {question.id}: {e}")
|
||||
self.metrics.increment_answers("delivery_failed")
|
||||
return False
|
||||
|
||||
@log_function_call(log_params=True, log_result=True)
|
||||
def format_question_info(self, question: Question, show_answer: bool = False) -> str:
|
||||
"""
|
||||
Форматирование информации о вопросе
|
||||
|
||||
Args:
|
||||
question: Объект вопроса
|
||||
show_answer: Показывать ли ответ
|
||||
|
||||
Returns:
|
||||
Отформатированная строка
|
||||
"""
|
||||
return self.utils.format_question_info(question, show_answer)
|
||||
|
||||
@log_function_call(log_params=True, log_result=True)
|
||||
def get_question_preview(self, question: Question, max_length: int = 50) -> str:
|
||||
"""
|
||||
Получение превью вопроса
|
||||
|
||||
Args:
|
||||
question: Объект вопроса
|
||||
max_length: Максимальная длина превью
|
||||
|
||||
Returns:
|
||||
Превью вопроса
|
||||
"""
|
||||
return question.get_question_preview(max_length)
|
||||
208
services/business/user_service.py
Normal file
208
services/business/user_service.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""
|
||||
Сервис для управления пользователями
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, Tuple
|
||||
from aiogram.types import User as TelegramUser
|
||||
|
||||
from models.user import User
|
||||
from services.infrastructure.database import DatabaseService
|
||||
from services.utils import UtilsService
|
||||
from services.infrastructure.logger import get_logger
|
||||
from services.infrastructure.metrics import get_metrics_service
|
||||
from services.infrastructure.logging_decorators import log_function_call, log_business_event
|
||||
from services.infrastructure.logging_utils import log_user_created, log_user_blocked
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class UserService:
|
||||
"""Сервис для управления пользователями"""
|
||||
|
||||
def __init__(self, database: DatabaseService, utils: UtilsService):
|
||||
self.database = database
|
||||
self.utils = utils
|
||||
self.metrics = get_metrics_service()
|
||||
|
||||
@log_business_event("create_or_update_user", log_params=True, log_result=True)
|
||||
async def create_or_update_user(self, telegram_user: TelegramUser, chat_id: int) -> User:
|
||||
"""
|
||||
Создание или обновление пользователя
|
||||
|
||||
Args:
|
||||
telegram_user: Объект пользователя из Telegram
|
||||
chat_id: ID чата
|
||||
|
||||
Returns:
|
||||
Объект пользователя
|
||||
"""
|
||||
try:
|
||||
# Проверяем, существует ли пользователь
|
||||
existing_user = await self.database.get_user(telegram_user.id)
|
||||
|
||||
if existing_user:
|
||||
# Обновляем существующего пользователя
|
||||
logger.info(f"👤 Обновление существующего пользователя {telegram_user.id}")
|
||||
self.metrics.increment_users("updated")
|
||||
return await self._update_existing_user(existing_user, telegram_user, chat_id)
|
||||
else:
|
||||
# Создаем нового пользователя
|
||||
logger.info(f"👤 Создание нового пользователя {telegram_user.id}")
|
||||
self.metrics.increment_users("created")
|
||||
return await self._create_new_user(telegram_user, chat_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании/обновлении пользователя {telegram_user.id}: {e}")
|
||||
raise
|
||||
|
||||
@log_function_call(log_params=True, log_result=True)
|
||||
async def _update_existing_user(self, existing_user: User, telegram_user: TelegramUser, chat_id: int) -> User:
|
||||
"""Обновление существующего пользователя"""
|
||||
existing_user.username = telegram_user.username
|
||||
existing_user.first_name = telegram_user.first_name or "Пользователь"
|
||||
existing_user.last_name = telegram_user.last_name
|
||||
existing_user.chat_id = chat_id
|
||||
existing_user.update_timestamp()
|
||||
|
||||
return await self.database.update_user(existing_user)
|
||||
|
||||
@log_function_call(log_params=True, log_result=True)
|
||||
async def _create_new_user(self, telegram_user: TelegramUser, chat_id: int) -> User:
|
||||
"""Создание нового пользователя"""
|
||||
user = User(
|
||||
telegram_id=telegram_user.id,
|
||||
username=telegram_user.username,
|
||||
first_name=telegram_user.first_name or "Пользователь",
|
||||
last_name=telegram_user.last_name,
|
||||
chat_id=chat_id,
|
||||
profile_link=self.utils.generate_anonymous_id(),
|
||||
is_active=True,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
)
|
||||
|
||||
return await self.database.create_user(user)
|
||||
|
||||
@log_function_call(log_params=True, log_result=True)
|
||||
async def get_user_by_profile_link(self, profile_link: str) -> Optional[User]:
|
||||
"""
|
||||
Получение пользователя по ссылке профиля
|
||||
|
||||
Args:
|
||||
profile_link: Ссылка профиля
|
||||
|
||||
Returns:
|
||||
Объект пользователя или None
|
||||
"""
|
||||
try:
|
||||
return await self.database.get_user_by_profile_link(profile_link)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении пользователя по ссылке {profile_link}: {e}")
|
||||
return None
|
||||
|
||||
@log_function_call(log_params=True, log_result=True)
|
||||
async def get_user_by_telegram_id(self, telegram_id: int) -> Optional[User]:
|
||||
"""
|
||||
Получение пользователя по Telegram ID
|
||||
|
||||
Args:
|
||||
telegram_id: ID пользователя в Telegram
|
||||
|
||||
Returns:
|
||||
Объект пользователя или None
|
||||
"""
|
||||
try:
|
||||
return await self.database.get_user(telegram_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении пользователя {telegram_id}: {e}")
|
||||
return None
|
||||
|
||||
@log_function_call(log_params=True, log_result=True)
|
||||
def generate_referral_link(self, bot_username: str, user: User) -> str:
|
||||
"""
|
||||
Генерация реферальной ссылки для пользователя
|
||||
|
||||
Args:
|
||||
bot_username: Имя бота
|
||||
user: Объект пользователя
|
||||
|
||||
Returns:
|
||||
Реферальная ссылка
|
||||
"""
|
||||
return self.utils.generate_referral_link(bot_username, user.profile_link)
|
||||
|
||||
@log_function_call(log_params=True, log_result=True)
|
||||
async def is_user_blocked(self, blocker_id: int, blocked_id: int) -> bool:
|
||||
"""
|
||||
Проверка, заблокирован ли пользователь
|
||||
|
||||
Args:
|
||||
blocker_id: ID пользователя, который блокирует
|
||||
blocked_id: ID пользователя, которого блокируют
|
||||
|
||||
Returns:
|
||||
True если заблокирован, False иначе
|
||||
"""
|
||||
try:
|
||||
return await self.database.is_user_blocked(blocker_id, blocked_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при проверке блокировки {blocker_id} -> {blocked_id}: {e}")
|
||||
return False
|
||||
|
||||
@log_business_event("block_user", log_params=True, log_result=True)
|
||||
async def block_user(self, blocker_id: int, blocked_id: int) -> bool:
|
||||
"""
|
||||
Блокировка пользователя
|
||||
|
||||
Args:
|
||||
blocker_id: ID пользователя, который блокирует
|
||||
blocked_id: ID пользователя, которого блокируют
|
||||
|
||||
Returns:
|
||||
True если успешно заблокирован
|
||||
"""
|
||||
try:
|
||||
await self.database.block_user(blocker_id, blocked_id)
|
||||
logger.info(f"Пользователь {blocked_id} заблокирован пользователем {blocker_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при блокировке пользователя {blocked_id}: {e}")
|
||||
return False
|
||||
|
||||
@log_business_event("unblock_user", log_params=True, log_result=True)
|
||||
async def unblock_user(self, blocker_id: int, blocked_id: int) -> bool:
|
||||
"""
|
||||
Разблокировка пользователя
|
||||
|
||||
Args:
|
||||
blocker_id: ID пользователя, который разблокирует
|
||||
blocked_id: ID пользователя, которого разблокируют
|
||||
|
||||
Returns:
|
||||
True если успешно разблокирован
|
||||
"""
|
||||
try:
|
||||
result = await self.database.unblock_user(blocker_id, blocked_id)
|
||||
if result:
|
||||
logger.info(f"Пользователь {blocked_id} разблокирован пользователем {blocker_id}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при разблокировке пользователя {blocked_id}: {e}")
|
||||
return False
|
||||
|
||||
@log_function_call(log_params=True, log_result=True)
|
||||
async def get_blocked_users(self, user_id: int) -> list:
|
||||
"""
|
||||
Получение списка заблокированных пользователей
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя
|
||||
|
||||
Returns:
|
||||
Список ID заблокированных пользователей
|
||||
"""
|
||||
try:
|
||||
return await self.database.user_blocks.get_blocked_users(user_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении заблокированных пользователей для {user_id}: {e}")
|
||||
return []
|
||||
31
services/infrastructure/__init__.py
Normal file
31
services/infrastructure/__init__.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
Инфраструктурные сервисы
|
||||
"""
|
||||
|
||||
from .database import DatabaseService
|
||||
from .logger import get_logger, setup_logging
|
||||
from .metrics import MetricsService, get_metrics_service
|
||||
from .pid_manager import PIDManager, get_pid_manager, cleanup_pid_file
|
||||
from .logging_decorators import (
|
||||
log_function_call, log_business_event, log_fsm_transition,
|
||||
log_handler, log_service, log_business, log_fsm,
|
||||
log_quiet, log_middleware, log_utility
|
||||
)
|
||||
from .logging_utils import (
|
||||
LoggingContext, get_logging_context,
|
||||
log_user_action, log_business_operation, log_fsm_event, log_performance,
|
||||
log_question_created, log_question_answered, log_user_created, log_user_blocked
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'DatabaseService',
|
||||
'get_logger', 'setup_logging',
|
||||
'MetricsService', 'get_metrics_service',
|
||||
'PIDManager', 'get_pid_manager', 'cleanup_pid_file',
|
||||
'log_function_call', 'log_business_event', 'log_fsm_transition',
|
||||
'log_handler', 'log_service', 'log_business', 'log_fsm',
|
||||
'log_quiet', 'log_middleware', 'log_utility',
|
||||
'LoggingContext', 'get_logging_context',
|
||||
'log_user_action', 'log_business_operation', 'log_fsm_event', 'log_performance',
|
||||
'log_question_created', 'log_question_answered', 'log_user_created', 'log_user_blocked'
|
||||
]
|
||||
255
services/infrastructure/database.py
Normal file
255
services/infrastructure/database.py
Normal file
@@ -0,0 +1,255 @@
|
||||
"""
|
||||
Сервис для работы с базой данных SQLite
|
||||
"""
|
||||
import aiosqlite
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict, Any, Tuple
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from models.user import User
|
||||
from models.question import Question, QuestionStatus
|
||||
from models.user_block import UserBlock
|
||||
from models.user_settings import UserSettings
|
||||
from database.crud import UserCRUD, QuestionCRUD, UserBlockCRUD, UserSettingsCRUD
|
||||
from .logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class DatabaseService:
|
||||
"""Сервис для работы с базой данных"""
|
||||
|
||||
def __init__(self, db_path: str):
|
||||
self.db_path = db_path
|
||||
# Инициализируем CRUD операции
|
||||
self.users = UserCRUD(db_path)
|
||||
self.questions = QuestionCRUD(db_path)
|
||||
self.user_blocks = UserBlockCRUD(db_path)
|
||||
self.user_settings = UserSettingsCRUD(db_path)
|
||||
|
||||
async def init(self):
|
||||
"""Инициализация базы данных и создание таблиц"""
|
||||
logger.info(f"💾 Инициализация базы данных: {self.db_path}")
|
||||
# Создаем директорию для базы данных если её нет
|
||||
db_path = Path(self.db_path)
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
async with self.get_connection() as conn:
|
||||
await self._create_tables(conn)
|
||||
logger.info("✅ База данных инициализирована")
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_connection(self):
|
||||
"""Контекстный менеджер для подключения к БД с использованием пула"""
|
||||
from database.crud import get_connection_pool
|
||||
pool = get_connection_pool(self.db_path)
|
||||
conn = await pool.get_connection()
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
await pool.return_connection(conn)
|
||||
|
||||
async def _create_tables(self, conn: aiosqlite.Connection):
|
||||
"""Создание таблиц в базе данных"""
|
||||
# Проверяем, существуют ли уже таблицы
|
||||
cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='users';")
|
||||
if await cursor.fetchone():
|
||||
logger.info("📋 Таблицы уже существуют, пропускаем создание")
|
||||
return
|
||||
|
||||
# Читаем схему из файла
|
||||
schema_path = Path(__file__).parent.parent / "database" / "schema.sql"
|
||||
|
||||
if schema_path.exists():
|
||||
logger.info("📄 Создание таблиц из схемы")
|
||||
with open(schema_path, 'r', encoding='utf-8') as f:
|
||||
schema_sql = f.read()
|
||||
|
||||
# Выполняем SQL схему
|
||||
await conn.executescript(schema_sql)
|
||||
await conn.commit()
|
||||
logger.info("✅ Таблицы созданы из схемы")
|
||||
else:
|
||||
logger.warning("⚠️ Файл схемы не найден, создаем таблицы вручную")
|
||||
await self._create_tables_manual(conn)
|
||||
|
||||
async def _create_tables_manual(self, conn: aiosqlite.Connection):
|
||||
"""Создание таблиц вручную если схема не найдена"""
|
||||
# Простая схема для совместимости
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
telegram_id INTEGER UNIQUE NOT NULL,
|
||||
username TEXT,
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT,
|
||||
chat_id INTEGER NOT NULL,
|
||||
profile_link TEXT UNIQUE NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
banned_until DATETIME,
|
||||
ban_reason TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS questions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
from_user_id INTEGER,
|
||||
to_user_id INTEGER NOT NULL,
|
||||
message_text TEXT NOT NULL,
|
||||
answer_text TEXT,
|
||||
is_anonymous BOOLEAN DEFAULT TRUE,
|
||||
message_id INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
answered_at DATETIME,
|
||||
is_read BOOLEAN DEFAULT FALSE,
|
||||
status TEXT DEFAULT 'pending'
|
||||
)
|
||||
""")
|
||||
|
||||
await conn.commit()
|
||||
|
||||
# Обертки для CRUD операций (для совместимости)
|
||||
|
||||
# Пользователи
|
||||
async def create_user(self, user: User) -> User:
|
||||
"""Создание нового пользователя"""
|
||||
return await self.users.create(user)
|
||||
|
||||
async def create_users_batch(self, users: List[User]) -> List[User]:
|
||||
"""Создание нескольких пользователей за одну транзакцию (batch операция)"""
|
||||
return await self.users.create_batch(users)
|
||||
|
||||
async def get_user(self, telegram_id: int) -> Optional[User]:
|
||||
"""Получение пользователя по Telegram ID"""
|
||||
return await self.users.get_by_telegram_id(telegram_id)
|
||||
|
||||
async def get_user_by_profile_link(self, profile_link: str) -> Optional[User]:
|
||||
"""Получение пользователя по ссылке профиля"""
|
||||
return await self.users.get_by_profile_link(profile_link)
|
||||
|
||||
async def update_user(self, user: User) -> User:
|
||||
"""Обновление пользователя"""
|
||||
return await self.users.update(user)
|
||||
|
||||
async def get_all_users(self, limit: int = 100, offset: int = 0) -> List[User]:
|
||||
"""Получение всех пользователей"""
|
||||
return await self.users.get_all(limit, offset)
|
||||
|
||||
async def get_all_users_cursor(self, last_id: int, last_created_at: str,
|
||||
limit: int, direction: str = "desc") -> List[User]:
|
||||
"""Получение пользователей с cursor-based пагинацией"""
|
||||
return await self.users.get_all_users_cursor(last_id, last_created_at, limit, direction)
|
||||
|
||||
async def get_all_users_asc(self, limit: int = 100, offset: int = 0) -> List[User]:
|
||||
"""Получение всех пользователей в порядке возрастания"""
|
||||
return await self.users.get_all_users_asc(limit, offset)
|
||||
|
||||
async def get_users_stats(self) -> Dict[str, Any]:
|
||||
"""Получение статистики пользователей"""
|
||||
return await self.users.get_stats()
|
||||
|
||||
# Вопросы
|
||||
async def create_question(self, question: Question) -> Question:
|
||||
"""Создание нового вопроса"""
|
||||
return await self.questions.create(question)
|
||||
|
||||
async def create_questions_batch(self, questions: List[Question]) -> List[Question]:
|
||||
"""Создание нескольких вопросов за одну транзакцию (batch операция)"""
|
||||
return await self.questions.create_batch(questions)
|
||||
|
||||
async def get_question(self, question_id: int) -> Optional[Question]:
|
||||
"""Получение вопроса по ID"""
|
||||
return await self.questions.get_by_id(question_id)
|
||||
|
||||
async def get_user_questions(self, user_id: int, status: Optional[QuestionStatus] = None,
|
||||
limit: int = 50, offset: int = 0) -> List[Question]:
|
||||
"""Получение вопросов пользователя"""
|
||||
return await self.questions.get_by_to_user(user_id, status, limit, offset)
|
||||
|
||||
async def get_user_questions_with_authors(self, user_id: int, status: Optional[QuestionStatus] = None,
|
||||
limit: int = 50, offset: int = 0) -> List[Tuple[Question, Optional[User]]]:
|
||||
"""Получение вопросов пользователя с информацией об авторах (оптимизированный запрос)"""
|
||||
return await self.questions.get_by_to_user_with_authors(user_id, status, limit, offset)
|
||||
|
||||
async def get_user_questions_cursor(self, user_id: int, last_id: int, last_created_at: str,
|
||||
limit: int, direction: str = "desc") -> List[Question]:
|
||||
"""Получение вопросов пользователя с cursor-based пагинацией"""
|
||||
return await self.questions.get_by_to_user_cursor(user_id, last_id, last_created_at, limit, direction)
|
||||
|
||||
async def get_user_questions_asc(self, user_id: int, status: Optional[QuestionStatus] = None,
|
||||
limit: int = 50, offset: int = 0) -> List[Question]:
|
||||
"""Получение вопросов пользователя в порядке возрастания"""
|
||||
return await self.questions.get_by_to_user_asc(user_id, status, limit, offset)
|
||||
|
||||
async def update_question(self, question: Question) -> Question:
|
||||
"""Обновление вопроса"""
|
||||
return await self.questions.update(question)
|
||||
|
||||
async def get_questions_stats(self) -> Dict[str, Any]:
|
||||
"""Получение статистики вопросов"""
|
||||
return await self.questions.get_stats()
|
||||
|
||||
async def get_unread_questions_count(self, user_id: int) -> int:
|
||||
"""Получение количества непрочитанных вопросов"""
|
||||
return await self.questions.get_unread_count(user_id)
|
||||
|
||||
async def get_user_questions_count(self, user_id: int, status: Optional[QuestionStatus] = None) -> int:
|
||||
"""Получение общего количества вопросов пользователя"""
|
||||
return await self.questions.get_count_by_to_user(user_id, status)
|
||||
|
||||
# Блокировки
|
||||
async def block_user(self, blocker_id: int, blocked_id: int) -> UserBlock:
|
||||
"""Блокировка пользователя"""
|
||||
user_block = UserBlock(
|
||||
blocker_id=blocker_id,
|
||||
blocked_id=blocked_id,
|
||||
created_at=datetime.now()
|
||||
)
|
||||
return await self.user_blocks.create(user_block)
|
||||
|
||||
async def unblock_user(self, blocker_id: int, blocked_id: int) -> bool:
|
||||
"""Разблокировка пользователя"""
|
||||
return await self.user_blocks.delete(blocker_id, blocked_id)
|
||||
|
||||
async def is_user_blocked(self, blocker_id: int, blocked_id: int) -> bool:
|
||||
"""Проверка, заблокирован ли пользователь"""
|
||||
return await self.user_blocks.is_blocked(blocker_id, blocked_id)
|
||||
|
||||
# Настройки
|
||||
async def get_user_settings(self, user_id: int) -> Optional[UserSettings]:
|
||||
"""Получение настроек пользователя"""
|
||||
return await self.user_settings.get_by_user_id(user_id)
|
||||
|
||||
async def get_user_by_id(self, user_id: int) -> Optional[User]:
|
||||
"""Получение пользователя по ID (для получения информации об авторах вопросов)"""
|
||||
return await self.users.get_by_telegram_id(user_id)
|
||||
|
||||
async def update_user_settings(self, settings: UserSettings) -> UserSettings:
|
||||
"""Обновление настроек пользователя"""
|
||||
return await self.user_settings.update(settings)
|
||||
|
||||
async def create_user_settings(self, settings: UserSettings) -> UserSettings:
|
||||
"""Создание настроек пользователя"""
|
||||
return await self.user_settings.create(settings)
|
||||
|
||||
async def check_connection(self):
|
||||
"""Проверка соединения с базой данных"""
|
||||
try:
|
||||
async with self.get_connection() as conn:
|
||||
# Выполняем простой запрос для проверки соединения
|
||||
cursor = await conn.execute("SELECT 1")
|
||||
await cursor.fetchone()
|
||||
logger.debug("Database connection check successful")
|
||||
except Exception as e:
|
||||
logger.error(f"Database connection check failed: {e}")
|
||||
raise
|
||||
|
||||
async def close(self):
|
||||
"""Закрытие соединения с БД"""
|
||||
from database.crud import get_connection_pool
|
||||
pool = get_connection_pool(self.db_path)
|
||||
await pool.close_all()
|
||||
350
services/infrastructure/http_server.py
Normal file
350
services/infrastructure/http_server.py
Normal file
@@ -0,0 +1,350 @@
|
||||
"""
|
||||
HTTP сервер для эндпоинтов метрик и health check
|
||||
"""
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from aiohttp import ClientSession, web
|
||||
from aiohttp.web import Request, Response
|
||||
from loguru import logger
|
||||
|
||||
from config.constants import DEFAULT_HTTP_HOST, DEFAULT_HTTP_PORT, APP_VERSION, HTTP_STATUS_OK, HTTP_STATUS_SERVICE_UNAVAILABLE, HTTP_STATUS_INTERNAL_SERVER_ERROR
|
||||
from dependencies import get_database_service
|
||||
from .metrics import get_metrics_service
|
||||
|
||||
|
||||
class HTTPServer:
|
||||
"""HTTP сервер для метрик и health check"""
|
||||
|
||||
def __init__(self, host: str = DEFAULT_HTTP_HOST, port: int = DEFAULT_HTTP_PORT):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.app = web.Application()
|
||||
self.metrics_service = get_metrics_service()
|
||||
self.database_service = get_database_service()
|
||||
self.start_time = time.time()
|
||||
self._setup_routes()
|
||||
|
||||
def _setup_routes(self):
|
||||
"""Настройка маршрутов"""
|
||||
self.app.router.add_get('/metrics', self.metrics_handler)
|
||||
self.app.router.add_get('/health', self.health_handler)
|
||||
self.app.router.add_get('/ready', self.ready_handler)
|
||||
self.app.router.add_get('/status', self.status_handler)
|
||||
self.app.router.add_get('/', self.root_handler)
|
||||
|
||||
async def metrics_handler(self, request: Request) -> Response:
|
||||
"""Обработчик эндпоинта /metrics"""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# Получаем метрики
|
||||
metrics_data = self.metrics_service.get_metrics()
|
||||
content_type = self.metrics_service.get_content_type()
|
||||
|
||||
# Записываем метрику HTTP запроса
|
||||
duration = time.time() - start_time
|
||||
self.metrics_service.record_http_request_duration("GET", "/metrics", duration)
|
||||
self.metrics_service.increment_http_requests("GET", "/metrics", HTTP_STATUS_OK)
|
||||
|
||||
return Response(
|
||||
text=metrics_data,
|
||||
content_type=content_type,
|
||||
status=HTTP_STATUS_OK
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in metrics handler: {e}")
|
||||
duration = time.time() - start_time
|
||||
self.metrics_service.record_http_request_duration("GET", "/metrics", duration)
|
||||
self.metrics_service.increment_http_requests("GET", "/metrics", HTTP_STATUS_INTERNAL_SERVER_ERROR)
|
||||
self.metrics_service.increment_errors(type(e).__name__, "metrics_handler")
|
||||
|
||||
return Response(
|
||||
text="Internal Server Error",
|
||||
status=HTTP_STATUS_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
async def health_handler(self, request: Request) -> Response:
|
||||
"""Обработчик эндпоинта /health"""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# Проверяем состояние сервисов
|
||||
health_status = {
|
||||
"status": "healthy",
|
||||
"timestamp": time.time(),
|
||||
"uptime": time.time() - self.start_time,
|
||||
"version": APP_VERSION,
|
||||
"services": {}
|
||||
}
|
||||
|
||||
# Проверяем базу данных
|
||||
try:
|
||||
await self.database_service.check_connection()
|
||||
health_status["services"]["database"] = "healthy"
|
||||
except Exception as e:
|
||||
health_status["services"]["database"] = f"unhealthy: {str(e)}"
|
||||
health_status["status"] = "unhealthy"
|
||||
|
||||
# Проверяем метрики
|
||||
try:
|
||||
self.metrics_service.get_metrics()
|
||||
health_status["services"]["metrics"] = "healthy"
|
||||
except Exception as e:
|
||||
health_status["services"]["metrics"] = f"unhealthy: {str(e)}"
|
||||
health_status["status"] = "unhealthy"
|
||||
|
||||
# Определяем HTTP статус
|
||||
http_status = HTTP_STATUS_OK if health_status["status"] == "healthy" else HTTP_STATUS_SERVICE_UNAVAILABLE
|
||||
|
||||
# Записываем метрику HTTP запроса
|
||||
duration = time.time() - start_time
|
||||
self.metrics_service.record_http_request_duration("GET", "/health", duration)
|
||||
self.metrics_service.increment_http_requests("GET", "/health", http_status)
|
||||
|
||||
return Response(
|
||||
json=health_status,
|
||||
status=http_status
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in health handler: {e}")
|
||||
duration = time.time() - start_time
|
||||
self.metrics_service.record_http_request_duration("GET", "/health", duration)
|
||||
self.metrics_service.increment_http_requests("GET", "/health", 500)
|
||||
self.metrics_service.increment_errors(type(e).__name__, "health_handler")
|
||||
|
||||
return Response(
|
||||
json={"status": "error", "message": str(e)},
|
||||
status=HTTP_STATUS_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
async def ready_handler(self, request: Request) -> Response:
|
||||
"""Обработчик эндпоинта /ready (readiness probe)"""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# Проверяем готовность сервисов
|
||||
ready_status = {
|
||||
"status": "ready",
|
||||
"timestamp": time.time(),
|
||||
"services": {}
|
||||
}
|
||||
|
||||
# Проверяем базу данных
|
||||
try:
|
||||
await self.database_service.check_connection()
|
||||
ready_status["services"]["database"] = "ready"
|
||||
except Exception as e:
|
||||
ready_status["services"]["database"] = f"not_ready: {str(e)}"
|
||||
ready_status["status"] = "not_ready"
|
||||
|
||||
# Определяем HTTP статус
|
||||
http_status = HTTP_STATUS_OK if ready_status["status"] == "ready" else HTTP_STATUS_SERVICE_UNAVAILABLE
|
||||
|
||||
# Записываем метрику HTTP запроса
|
||||
duration = time.time() - start_time
|
||||
self.metrics_service.record_http_request_duration("GET", "/ready", duration)
|
||||
self.metrics_service.increment_http_requests("GET", "/ready", http_status)
|
||||
|
||||
return Response(
|
||||
json=ready_status,
|
||||
status=http_status
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in ready handler: {e}")
|
||||
duration = time.time() - start_time
|
||||
self.metrics_service.record_http_request_duration("GET", "/ready", duration)
|
||||
self.metrics_service.increment_http_requests("GET", "/ready", 500)
|
||||
self.metrics_service.increment_errors(type(e).__name__, "ready_handler")
|
||||
|
||||
return Response(
|
||||
json={"status": "error", "message": str(e)},
|
||||
status=HTTP_STATUS_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
async def status_handler(self, request: Request) -> Response:
|
||||
"""Handle /status endpoint for process status information."""
|
||||
try:
|
||||
import os
|
||||
import time
|
||||
import psutil
|
||||
|
||||
# Получаем PID текущего процесса
|
||||
current_pid = os.getpid()
|
||||
|
||||
try:
|
||||
# Получаем информацию о процессе
|
||||
process = psutil.Process(current_pid)
|
||||
create_time = process.create_time()
|
||||
uptime_seconds = time.time() - create_time
|
||||
|
||||
# Логируем для диагностики
|
||||
import datetime
|
||||
create_time_str = datetime.datetime.fromtimestamp(create_time).strftime('%Y-%m-%d %H:%M:%S')
|
||||
current_time_str = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
logger.info(f"Process PID {current_pid}: created at {create_time_str}, current time {current_time_str}, uptime {uptime_seconds:.1f}s")
|
||||
|
||||
# Форматируем uptime
|
||||
if uptime_seconds < 60:
|
||||
uptime_str = f"{int(uptime_seconds)}с"
|
||||
elif uptime_seconds < 3600:
|
||||
minutes = int(uptime_seconds // 60)
|
||||
uptime_str = f"{minutes}м"
|
||||
elif uptime_seconds < 86400:
|
||||
hours = int(uptime_seconds // 3600)
|
||||
minutes = int((uptime_seconds % 3600) // 60)
|
||||
uptime_str = f"{hours}ч {minutes}м"
|
||||
else:
|
||||
days = int(uptime_seconds // 86400)
|
||||
hours = int((uptime_seconds % 86400) // 3600)
|
||||
uptime_str = f"{days}д {hours}ч"
|
||||
|
||||
# Проверяем, что процесс активен
|
||||
if process.is_running():
|
||||
status = "running"
|
||||
else:
|
||||
status = "stopped"
|
||||
|
||||
# Формируем ответ
|
||||
response_data = {
|
||||
"status": status,
|
||||
"pid": current_pid,
|
||||
"uptime": uptime_str,
|
||||
"memory_usage_mb": round(process.memory_info().rss / 1024 / 1024, 2),
|
||||
"cpu_percent": process.cpu_percent(),
|
||||
"timestamp": time.time()
|
||||
}
|
||||
|
||||
import json
|
||||
return Response(
|
||||
text=json.dumps(response_data, ensure_ascii=False),
|
||||
content_type='application/json',
|
||||
status=200
|
||||
)
|
||||
|
||||
except psutil.NoSuchProcess:
|
||||
# Процесс не найден
|
||||
response_data = {
|
||||
"status": "not_found",
|
||||
"error": "Process not found",
|
||||
"timestamp": time.time()
|
||||
}
|
||||
|
||||
import json
|
||||
return Response(
|
||||
text=json.dumps(response_data, ensure_ascii=False),
|
||||
content_type='application/json',
|
||||
status=404
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Status check failed: {e}")
|
||||
import json
|
||||
response_data = {
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
"timestamp": time.time()
|
||||
}
|
||||
|
||||
return Response(
|
||||
text=json.dumps(response_data, ensure_ascii=False),
|
||||
content_type='application/json',
|
||||
status=500
|
||||
)
|
||||
|
||||
async def root_handler(self, request: Request) -> Response:
|
||||
"""Обработчик корневого эндпоинта"""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
info = {
|
||||
"service": "AnonBot",
|
||||
"version": APP_VERSION,
|
||||
"endpoints": {
|
||||
"metrics": "/metrics",
|
||||
"health": "/health",
|
||||
"ready": "/ready",
|
||||
"status": "/status"
|
||||
},
|
||||
"uptime": time.time() - self.start_time
|
||||
}
|
||||
|
||||
# Записываем метрику HTTP запроса
|
||||
duration = time.time() - start_time
|
||||
self.metrics_service.record_http_request_duration("GET", "/", duration)
|
||||
self.metrics_service.increment_http_requests("GET", "/", 200)
|
||||
|
||||
return Response(
|
||||
json=info,
|
||||
status=HTTP_STATUS_OK
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in root handler: {e}")
|
||||
duration = time.time() - start_time
|
||||
self.metrics_service.record_http_request_duration("GET", "/", duration)
|
||||
self.metrics_service.increment_http_requests("GET", "/", 500)
|
||||
self.metrics_service.increment_errors(type(e).__name__, "root_handler")
|
||||
|
||||
return Response(
|
||||
json={"error": str(e)},
|
||||
status=HTTP_STATUS_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
async def start(self):
|
||||
"""Запуск HTTP сервера"""
|
||||
try:
|
||||
runner = web.AppRunner(self.app)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, self.host, self.port)
|
||||
await site.start()
|
||||
|
||||
logger.info(f"HTTP server started on {self.host}:{self.port}")
|
||||
logger.info(f"Metrics endpoint: http://{self.host}:{self.port}/metrics")
|
||||
logger.info(f"Health endpoint: http://{self.host}:{self.port}/health")
|
||||
logger.info(f"Ready endpoint: http://{self.host}:{self.port}/ready")
|
||||
logger.info(f"Status endpoint: http://{self.host}:{self.port}/status")
|
||||
|
||||
return runner
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start HTTP server: {e}")
|
||||
self.metrics_service.increment_errors(type(e).__name__, "http_server")
|
||||
raise
|
||||
|
||||
async def stop(self, runner: web.AppRunner):
|
||||
"""Остановка HTTP сервера"""
|
||||
try:
|
||||
await runner.cleanup()
|
||||
logger.info("HTTP server stopped")
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping HTTP server: {e}")
|
||||
self.metrics_service.increment_errors(type(e).__name__, "http_server")
|
||||
|
||||
|
||||
# Глобальный экземпляр HTTP сервера
|
||||
_http_server: Optional[HTTPServer] = None
|
||||
|
||||
|
||||
def get_http_server(host: str = DEFAULT_HTTP_HOST, port: int = DEFAULT_HTTP_PORT) -> HTTPServer:
|
||||
"""Получить экземпляр HTTP сервера"""
|
||||
global _http_server
|
||||
if _http_server is None:
|
||||
_http_server = HTTPServer(host, port)
|
||||
return _http_server
|
||||
|
||||
|
||||
async def start_http_server(host: str = DEFAULT_HTTP_HOST, port: int = DEFAULT_HTTP_PORT) -> web.AppRunner:
|
||||
"""Запустить HTTP сервер"""
|
||||
server = get_http_server(host, port)
|
||||
return await server.start()
|
||||
|
||||
|
||||
async def stop_http_server(runner: web.AppRunner):
|
||||
"""Остановить HTTP сервер"""
|
||||
server = get_http_server()
|
||||
await server.stop(runner)
|
||||
83
services/infrastructure/logger.py
Normal file
83
services/infrastructure/logger.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
Настройка системы логирования с использованием loguru
|
||||
"""
|
||||
import sys
|
||||
from loguru import logger
|
||||
from config import config
|
||||
|
||||
|
||||
def setup_logging():
|
||||
"""Настройка системы логирования"""
|
||||
# Удаляем стандартный обработчик loguru
|
||||
logger.remove()
|
||||
|
||||
# Настраиваем логирование в stderr для Docker
|
||||
log_level = "DEBUG" if config.DEBUG else "INFO"
|
||||
|
||||
# Основной обработчик для stderr (для Docker)
|
||||
logger.add(
|
||||
sys.stderr,
|
||||
level=log_level,
|
||||
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
|
||||
colorize=True,
|
||||
backtrace=True,
|
||||
diagnose=True
|
||||
)
|
||||
|
||||
# Дополнительный обработчик для файла (опционально)
|
||||
if config.DEBUG:
|
||||
logger.add(
|
||||
"logs/bot.log",
|
||||
level="DEBUG",
|
||||
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}",
|
||||
rotation="10 MB",
|
||||
retention="7 days",
|
||||
compression="zip",
|
||||
backtrace=True,
|
||||
diagnose=True
|
||||
)
|
||||
|
||||
# Настраиваем логирование для внешних библиотек
|
||||
import logging
|
||||
|
||||
# Отключаем логирование aiogram по умолчанию
|
||||
logging.getLogger("aiogram").setLevel(logging.WARNING)
|
||||
logging.getLogger("aiohttp").setLevel(logging.WARNING)
|
||||
logging.getLogger("aiosqlite").setLevel(logging.WARNING)
|
||||
|
||||
# Перенаправляем стандартное логирование в loguru
|
||||
class InterceptHandler(logging.Handler):
|
||||
def emit(self, record):
|
||||
# Получаем соответствующий уровень loguru
|
||||
try:
|
||||
level = logger.level(record.levelname).name
|
||||
except ValueError:
|
||||
level = record.levelno
|
||||
|
||||
# Находим caller из логов
|
||||
frame, depth = logging.currentframe(), 2
|
||||
while frame.f_code.co_filename == logging.__file__:
|
||||
frame = frame.f_back
|
||||
depth += 1
|
||||
|
||||
logger.opt(depth=depth, exception=record.exc_info).log(
|
||||
level, record.getMessage()
|
||||
)
|
||||
|
||||
# Подключаем перехватчик к корневому логгеру
|
||||
logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True)
|
||||
|
||||
logger.info("🔧 Система логирования loguru настроена")
|
||||
logger.info(f"📊 Уровень логирования: {log_level}")
|
||||
logger.info(f"🐳 Логи выводятся в stderr для Docker")
|
||||
|
||||
|
||||
def get_logger(name: str = None):
|
||||
"""Получить логгер для модуля"""
|
||||
if name:
|
||||
return logger.bind(name=name)
|
||||
return logger
|
||||
|
||||
|
||||
# Инициализируем логирование при импорте
|
||||
setup_logging()
|
||||
274
services/infrastructure/logging_decorators.py
Normal file
274
services/infrastructure/logging_decorators.py
Normal file
@@ -0,0 +1,274 @@
|
||||
"""
|
||||
Декораторы для автоматического логирования функций
|
||||
"""
|
||||
import asyncio
|
||||
import inspect
|
||||
from functools import wraps
|
||||
from typing import Callable, Any, Optional, Dict, Union
|
||||
from aiogram.types import Message, CallbackQuery
|
||||
|
||||
from services.infrastructure.logger import get_logger
|
||||
|
||||
|
||||
def log_function_call(
|
||||
function_name: Optional[str] = None,
|
||||
log_params: bool = True,
|
||||
log_result: bool = False,
|
||||
log_level: str = "info",
|
||||
quiet: bool = False
|
||||
):
|
||||
"""
|
||||
Декоратор для автоматического логирования входа/выхода из функций
|
||||
|
||||
Args:
|
||||
function_name: Кастомное имя функции для логов (по умолчанию берется из func.__name__)
|
||||
log_params: Логировать ли параметры вызова
|
||||
log_result: Логировать ли результат выполнения
|
||||
log_level: Уровень логирования ('info', 'debug', 'warning')
|
||||
quiet: Тихое логирование (только ошибки)
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
async def async_wrapper(*args, **kwargs):
|
||||
logger = get_logger(func.__module__)
|
||||
name = function_name or func.__name__
|
||||
|
||||
# Формируем контекстную информацию
|
||||
context_info = _build_context_info(args, kwargs, log_params)
|
||||
|
||||
# Логируем вход в функцию (только если не тихий режим)
|
||||
if not quiet:
|
||||
log_method = getattr(logger, log_level)
|
||||
log_method(f"🚀 Начало выполнения {name}{context_info}")
|
||||
|
||||
try:
|
||||
result = await func(*args, **kwargs)
|
||||
|
||||
# Логируем успешное завершение (только если не тихий режим)
|
||||
if not quiet:
|
||||
result_info = ""
|
||||
if log_result and result is not None:
|
||||
result_info = f" | Результат: {_format_result(result)}"
|
||||
|
||||
log_method(f"✅ Успешное завершение {name}{result_info}")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
# Логируем ошибку (всегда, даже в тихом режиме)
|
||||
logger.error(f"❌ Ошибка в {name}: {e}")
|
||||
raise
|
||||
|
||||
@wraps(func)
|
||||
def sync_wrapper(*args, **kwargs):
|
||||
logger = get_logger(func.__module__)
|
||||
name = function_name or func.__name__
|
||||
|
||||
# Формируем контекстную информацию
|
||||
context_info = _build_context_info(args, kwargs, log_params)
|
||||
|
||||
# Логируем вход в функцию (только если не тихий режим)
|
||||
if not quiet:
|
||||
log_method = getattr(logger, log_level)
|
||||
log_method(f"🚀 Начало выполнения {name}{context_info}")
|
||||
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
|
||||
# Логируем успешное завершение (только если не тихий режим)
|
||||
if not quiet:
|
||||
result_info = ""
|
||||
if log_result and result is not None:
|
||||
result_info = f" | Результат: {_format_result(result)}"
|
||||
|
||||
log_method(f"✅ Успешное завершение {name}{result_info}")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
# Логируем ошибку (всегда, даже в тихом режиме)
|
||||
logger.error(f"❌ Ошибка в {name}: {e}")
|
||||
raise
|
||||
|
||||
# Возвращаем правильный wrapper в зависимости от типа функции
|
||||
return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def log_business_event(
|
||||
event_name: str,
|
||||
log_params: bool = True,
|
||||
log_result: bool = True
|
||||
):
|
||||
"""
|
||||
Декоратор для логирования бизнес-событий
|
||||
|
||||
Args:
|
||||
event_name: Название бизнес-события
|
||||
log_params: Логировать ли параметры
|
||||
log_result: Логировать ли результат
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
async def async_wrapper(*args, **kwargs):
|
||||
logger = get_logger(func.__module__)
|
||||
|
||||
# Формируем контекстную информацию
|
||||
context_info = _build_context_info(args, kwargs, log_params)
|
||||
|
||||
# Логируем бизнес-событие
|
||||
logger.info(f"📊 Бизнес-событие: {event_name}{context_info}")
|
||||
|
||||
try:
|
||||
result = await func(*args, **kwargs)
|
||||
|
||||
# Логируем результат бизнес-события
|
||||
if log_result and result is not None:
|
||||
result_info = _format_result(result)
|
||||
logger.info(f"📈 Результат {event_name}: {result_info}")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"💥 Ошибка в бизнес-событии {event_name}: {e}")
|
||||
raise
|
||||
|
||||
@wraps(func)
|
||||
def sync_wrapper(*args, **kwargs):
|
||||
logger = get_logger(func.__module__)
|
||||
|
||||
# Формируем контекстную информацию
|
||||
context_info = _build_context_info(args, kwargs, log_params)
|
||||
|
||||
# Логируем бизнес-событие
|
||||
logger.info(f"📊 Бизнес-событие: {event_name}{context_info}")
|
||||
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
|
||||
# Логируем результат бизнес-события
|
||||
if log_result and result is not None:
|
||||
result_info = _format_result(result)
|
||||
logger.info(f"📈 Результат {event_name}: {result_info}")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"💥 Ошибка в бизнес-событии {event_name}: {e}")
|
||||
raise
|
||||
|
||||
return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def log_fsm_transition(
|
||||
from_state: Optional[str] = None,
|
||||
to_state: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
Декоратор для логирования переходов FSM состояний
|
||||
|
||||
Args:
|
||||
from_state: Исходное состояние (если None, будет определено автоматически)
|
||||
to_state: Целевое состояние (если None, будет определено автоматически)
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
async def async_wrapper(*args, **kwargs):
|
||||
logger = get_logger(func.__module__)
|
||||
|
||||
# Извлекаем FSM context из аргументов
|
||||
fsm_context = None
|
||||
for arg in args:
|
||||
if hasattr(arg, 'get_state') and hasattr(arg, 'set_state'):
|
||||
fsm_context = arg
|
||||
break
|
||||
|
||||
# Логируем переход состояния
|
||||
if fsm_context:
|
||||
current_state = await fsm_context.get_state()
|
||||
logger.info(f"🔄 FSM переход: {current_state} -> {to_state or 'новое состояние'}")
|
||||
|
||||
try:
|
||||
result = await func(*args, **kwargs)
|
||||
|
||||
# Логируем успешный переход
|
||||
if fsm_context:
|
||||
new_state = await fsm_context.get_state()
|
||||
logger.info(f"✅ FSM переход завершен: {from_state or 'предыдущее состояние'} -> {new_state}")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка в FSM переходе: {e}")
|
||||
raise
|
||||
|
||||
return async_wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def _build_context_info(args: tuple, kwargs: dict, log_params: bool) -> str:
|
||||
"""Построение контекстной информации для логов"""
|
||||
if not log_params:
|
||||
return ""
|
||||
|
||||
context_parts = []
|
||||
|
||||
# Извлекаем информацию о пользователе из аргументов
|
||||
user_id = None
|
||||
for arg in args:
|
||||
if isinstance(arg, (Message, CallbackQuery)):
|
||||
user_id = arg.from_user.id
|
||||
context_parts.append(f"user_id={user_id}")
|
||||
break
|
||||
elif hasattr(arg, 'from_user_id'):
|
||||
user_id = arg.from_user_id
|
||||
context_parts.append(f"from_user_id={user_id}")
|
||||
elif hasattr(arg, 'to_user_id'):
|
||||
context_parts.append(f"to_user_id={arg.to_user_id}")
|
||||
elif hasattr(arg, 'id') and isinstance(arg.id, int):
|
||||
context_parts.append(f"id={arg.id}")
|
||||
|
||||
# Добавляем важные параметры из kwargs
|
||||
important_params = ['question_id', 'user_id', 'page', 'limit', 'status']
|
||||
for param in important_params:
|
||||
if param in kwargs and kwargs[param] is not None:
|
||||
context_parts.append(f"{param}={kwargs[param]}")
|
||||
|
||||
return f" | {', '.join(context_parts)}" if context_parts else ""
|
||||
|
||||
|
||||
def _format_result(result: Any) -> str:
|
||||
"""Форматирование результата для логов"""
|
||||
if result is None:
|
||||
return "None"
|
||||
|
||||
if isinstance(result, (str, int, float, bool)):
|
||||
return str(result)
|
||||
|
||||
if hasattr(result, 'id'):
|
||||
return f"id={result.id}"
|
||||
|
||||
if isinstance(result, (list, tuple)):
|
||||
return f"count={len(result)}"
|
||||
|
||||
if isinstance(result, dict):
|
||||
return f"keys={list(result.keys())}"
|
||||
|
||||
return str(type(result).__name__)
|
||||
|
||||
|
||||
# Удобные алиасы для часто используемых декораторов
|
||||
log_handler = log_function_call
|
||||
log_service = log_function_call
|
||||
log_business = log_business_event
|
||||
log_fsm = log_fsm_transition
|
||||
|
||||
# Тихие декораторы для middleware и служебных функций
|
||||
log_quiet = lambda **kwargs: log_function_call(quiet=True, **kwargs)
|
||||
log_middleware = lambda **kwargs: log_function_call(quiet=True, log_level="debug", **kwargs)
|
||||
|
||||
# Декоратор для служебных функций (только ошибки)
|
||||
def log_utility(func: Callable) -> Callable:
|
||||
"""Декоратор для служебных функций - логирует только ошибки"""
|
||||
return log_function_call(quiet=True, log_params=False, log_result=False)(func)
|
||||
227
services/infrastructure/logging_utils.py
Normal file
227
services/infrastructure/logging_utils.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
Утилиты для контекстного логирования
|
||||
"""
|
||||
from typing import Any, Optional, Dict, Union
|
||||
from aiogram.types import Message, CallbackQuery, User
|
||||
|
||||
from services.infrastructure.logger import get_logger
|
||||
|
||||
|
||||
class LoggingContext:
|
||||
"""Контекст для логирования с дополнительной информацией"""
|
||||
|
||||
def __init__(self, module_name: str):
|
||||
self.logger = get_logger(module_name)
|
||||
self.context_data = {}
|
||||
|
||||
def add_context(self, key: str, value: Any) -> 'LoggingContext':
|
||||
"""Добавить данные в контекст"""
|
||||
self.context_data[key] = value
|
||||
return self
|
||||
|
||||
def log_info(self, message: str, **kwargs):
|
||||
"""Логирование с контекстом"""
|
||||
context_str = self._format_context()
|
||||
full_message = f"{message}{context_str}"
|
||||
self.logger.info(full_message, **kwargs)
|
||||
|
||||
def log_warning(self, message: str, **kwargs):
|
||||
"""Логирование предупреждения с контекстом"""
|
||||
context_str = self._format_context()
|
||||
full_message = f"{message}{context_str}"
|
||||
self.logger.warning(full_message, **kwargs)
|
||||
|
||||
def log_error(self, message: str, **kwargs):
|
||||
"""Логирование ошибки с контекстом"""
|
||||
context_str = self._format_context()
|
||||
full_message = f"{message}{context_str}"
|
||||
self.logger.error(full_message, **kwargs)
|
||||
|
||||
def _format_context(self) -> str:
|
||||
"""Форматирование контекстных данных"""
|
||||
if not self.context_data:
|
||||
return ""
|
||||
|
||||
context_parts = [f"{k}={v}" for k, v in self.context_data.items()]
|
||||
return f" | {', '.join(context_parts)}"
|
||||
|
||||
|
||||
def get_logging_context(module_name: str) -> LoggingContext:
|
||||
"""Получить контекст логирования для модуля"""
|
||||
return LoggingContext(module_name)
|
||||
|
||||
|
||||
def log_user_action(
|
||||
logger,
|
||||
action: str,
|
||||
user: Union[User, Message, CallbackQuery, int],
|
||||
additional_info: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
"""
|
||||
Логирование действий пользователя
|
||||
|
||||
Args:
|
||||
logger: Логгер
|
||||
action: Действие пользователя
|
||||
user: Объект пользователя, сообщение, callback или user_id
|
||||
additional_info: Дополнительная информация
|
||||
"""
|
||||
user_id = _extract_user_id(user)
|
||||
user_info = _extract_user_info(user)
|
||||
|
||||
context_parts = [f"user_id={user_id}"]
|
||||
if user_info:
|
||||
context_parts.append(f"user_info={user_info}")
|
||||
|
||||
if additional_info:
|
||||
for key, value in additional_info.items():
|
||||
context_parts.append(f"{key}={value}")
|
||||
|
||||
context_str = f" | {', '.join(context_parts)}" if context_parts else ""
|
||||
logger.info(f"👤 {action}{context_str}")
|
||||
|
||||
|
||||
def log_business_operation(
|
||||
logger,
|
||||
operation: str,
|
||||
entity_type: str,
|
||||
entity_id: Optional[Union[int, str]] = None,
|
||||
additional_info: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
"""
|
||||
Логирование бизнес-операций
|
||||
|
||||
Args:
|
||||
logger: Логгер
|
||||
operation: Операция (create, update, delete, etc.)
|
||||
entity_type: Тип сущности (question, user, etc.)
|
||||
entity_id: ID сущности
|
||||
additional_info: Дополнительная информация
|
||||
"""
|
||||
context_parts = [f"operation={operation}", f"entity_type={entity_type}"]
|
||||
|
||||
if entity_id is not None:
|
||||
context_parts.append(f"entity_id={entity_id}")
|
||||
|
||||
if additional_info:
|
||||
for key, value in additional_info.items():
|
||||
context_parts.append(f"{key}={value}")
|
||||
|
||||
context_str = f" | {', '.join(context_parts)}"
|
||||
logger.info(f"📊 Бизнес-операция: {operation} {entity_type}{context_str}")
|
||||
|
||||
|
||||
def log_fsm_event(
|
||||
logger,
|
||||
event: str,
|
||||
state: Optional[str] = None,
|
||||
user_id: Optional[int] = None,
|
||||
additional_info: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
"""
|
||||
Логирование FSM событий
|
||||
|
||||
Args:
|
||||
logger: Логгер
|
||||
event: Событие FSM
|
||||
state: Текущее состояние
|
||||
user_id: ID пользователя
|
||||
additional_info: Дополнительная информация
|
||||
"""
|
||||
context_parts = [f"event={event}"]
|
||||
|
||||
if state:
|
||||
context_parts.append(f"state={state}")
|
||||
|
||||
if user_id:
|
||||
context_parts.append(f"user_id={user_id}")
|
||||
|
||||
if additional_info:
|
||||
for key, value in additional_info.items():
|
||||
context_parts.append(f"{key}={value}")
|
||||
|
||||
context_str = f" | {', '.join(context_parts)}"
|
||||
logger.info(f"🔄 FSM: {event}{context_str}")
|
||||
|
||||
|
||||
def log_performance(
|
||||
logger,
|
||||
operation: str,
|
||||
duration: float,
|
||||
additional_info: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
"""
|
||||
Логирование производительности
|
||||
|
||||
Args:
|
||||
logger: Логгер
|
||||
operation: Операция
|
||||
duration: Время выполнения в секундах
|
||||
additional_info: Дополнительная информация
|
||||
"""
|
||||
context_parts = [f"duration={duration:.3f}s"]
|
||||
|
||||
if additional_info:
|
||||
for key, value in additional_info.items():
|
||||
context_parts.append(f"{key}={value}")
|
||||
|
||||
context_str = f" | {', '.join(context_parts)}"
|
||||
logger.info(f"⏱️ Производительность: {operation}{context_str}")
|
||||
|
||||
|
||||
def _extract_user_id(user: Union[User, Message, CallbackQuery, int]) -> int:
|
||||
"""Извлечение user_id из различных объектов"""
|
||||
if isinstance(user, int):
|
||||
return user
|
||||
elif isinstance(user, User):
|
||||
return user.id
|
||||
elif isinstance(user, (Message, CallbackQuery)):
|
||||
return user.from_user.id
|
||||
else:
|
||||
return 0
|
||||
|
||||
|
||||
def _extract_user_info(user: Union[User, Message, CallbackQuery, int]) -> Optional[str]:
|
||||
"""Извлечение информации о пользователе"""
|
||||
if isinstance(user, int):
|
||||
return None
|
||||
elif isinstance(user, User):
|
||||
return f"{user.first_name or ''} {user.last_name or ''}".strip() or user.username or "Unknown"
|
||||
elif isinstance(user, (Message, CallbackQuery)):
|
||||
user_obj = user.from_user
|
||||
return f"{user_obj.first_name or ''} {user_obj.last_name or ''}".strip() or user_obj.username or "Unknown"
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
# Удобные функции для быстрого логирования
|
||||
def log_question_created(logger, question_id: int, from_user_id: int, to_user_id: int):
|
||||
"""Логирование создания вопроса"""
|
||||
log_business_operation(
|
||||
logger, "create", "question", question_id,
|
||||
{"from_user_id": from_user_id, "to_user_id": to_user_id}
|
||||
)
|
||||
|
||||
|
||||
def log_question_answered(logger, question_id: int, user_id: int):
|
||||
"""Логирование ответа на вопрос"""
|
||||
log_business_operation(
|
||||
logger, "answer", "question", question_id,
|
||||
{"user_id": user_id}
|
||||
)
|
||||
|
||||
|
||||
def log_user_created(logger, user_id: int, username: Optional[str] = None):
|
||||
"""Логирование создания пользователя"""
|
||||
additional_info = {"username": username} if username else None
|
||||
log_business_operation(
|
||||
logger, "create", "user", user_id, additional_info
|
||||
)
|
||||
|
||||
|
||||
def log_user_blocked(logger, user_id: int, reason: Optional[str] = None):
|
||||
"""Логирование блокировки пользователя"""
|
||||
additional_info = {"reason": reason} if reason else None
|
||||
log_business_operation(
|
||||
logger, "block", "user", user_id, additional_info
|
||||
)
|
||||
351
services/infrastructure/metrics.py
Normal file
351
services/infrastructure/metrics.py
Normal file
@@ -0,0 +1,351 @@
|
||||
"""
|
||||
Сервис для работы с Prometheus метриками
|
||||
"""
|
||||
import time
|
||||
import inspect
|
||||
from typing import Optional, Callable
|
||||
from prometheus_client import Counter, Histogram, Gauge, Info, generate_latest, CONTENT_TYPE_LATEST
|
||||
from loguru import logger
|
||||
|
||||
|
||||
|
||||
|
||||
class MetricsService:
|
||||
"""Сервис для управления Prometheus метриками"""
|
||||
|
||||
def __init__(self):
|
||||
self._init_metrics()
|
||||
|
||||
def _init_metrics(self):
|
||||
"""Инициализация метрик"""
|
||||
|
||||
# Информация о боте
|
||||
self.bot_info = Info('anon_bot_info', 'Information about the AnonBot')
|
||||
self.bot_info.info({
|
||||
'version': '1.0.0',
|
||||
'service': 'anon-bot'
|
||||
})
|
||||
|
||||
# Счетчики сообщений
|
||||
self.messages_total = Counter(
|
||||
'anon_bot_messages_total',
|
||||
'Total number of messages processed',
|
||||
['message_type', 'status']
|
||||
)
|
||||
|
||||
# Счетчики вопросов
|
||||
self.questions_total = Counter(
|
||||
'anon_bot_questions_total',
|
||||
'Total number of questions received',
|
||||
['status']
|
||||
)
|
||||
|
||||
# Счетчики ответов
|
||||
self.answers_total = Counter(
|
||||
'anon_bot_answers_total',
|
||||
'Total number of answers sent',
|
||||
['status']
|
||||
)
|
||||
|
||||
# Счетчики пользователей
|
||||
self.users_total = Counter(
|
||||
'anon_bot_users_total',
|
||||
'Total number of users',
|
||||
['action']
|
||||
)
|
||||
|
||||
# Время обработки сообщений
|
||||
self.message_processing_time = Histogram(
|
||||
'anon_bot_message_processing_seconds',
|
||||
'Time spent processing messages',
|
||||
['message_type'],
|
||||
buckets=[0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0]
|
||||
)
|
||||
|
||||
# Время обработки вопросов
|
||||
self.question_processing_time = Histogram(
|
||||
'anon_bot_question_processing_seconds',
|
||||
'Time spent processing questions',
|
||||
buckets=[0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0]
|
||||
)
|
||||
|
||||
# Время обработки ответов
|
||||
self.answer_processing_time = Histogram(
|
||||
'anon_bot_answer_processing_seconds',
|
||||
'Time spent processing answers',
|
||||
buckets=[0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0]
|
||||
)
|
||||
|
||||
# Активные пользователи
|
||||
self.active_users = Gauge(
|
||||
'anon_bot_active_users',
|
||||
'Number of active users'
|
||||
)
|
||||
|
||||
# Активные вопросы
|
||||
self.active_questions = Gauge(
|
||||
'anon_bot_active_questions',
|
||||
'Number of active questions'
|
||||
)
|
||||
|
||||
# Ошибки
|
||||
self.errors_total = Counter(
|
||||
'anon_bot_errors_total',
|
||||
'Total number of errors',
|
||||
['error_type', 'component']
|
||||
)
|
||||
|
||||
# HTTP запросы к эндпоинтам
|
||||
self.http_requests_total = Counter(
|
||||
'anon_bot_http_requests_total',
|
||||
'Total number of HTTP requests',
|
||||
['method', 'endpoint', 'status_code']
|
||||
)
|
||||
|
||||
# Метрики производительности БД
|
||||
self.db_queries_total = Counter(
|
||||
'anon_bot_db_queries_total',
|
||||
'Total number of database queries',
|
||||
['operation', 'table', 'status']
|
||||
)
|
||||
|
||||
self.db_query_duration = Histogram(
|
||||
'anon_bot_db_query_duration_seconds',
|
||||
'Database query duration',
|
||||
['operation', 'table'],
|
||||
buckets=[0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0]
|
||||
)
|
||||
|
||||
self.db_connections_active = Gauge(
|
||||
'anon_bot_db_connections_active',
|
||||
'Number of active database connections'
|
||||
)
|
||||
|
||||
self.db_connections_total = Counter(
|
||||
'anon_bot_db_connections_total',
|
||||
'Total number of database connections',
|
||||
['status']
|
||||
)
|
||||
|
||||
# Метрики пагинации
|
||||
self.pagination_requests_total = Counter(
|
||||
'anon_bot_pagination_requests_total',
|
||||
'Total number of pagination requests',
|
||||
['entity_type', 'method']
|
||||
)
|
||||
|
||||
self.pagination_duration = Histogram(
|
||||
'anon_bot_pagination_duration_seconds',
|
||||
'Pagination operation duration',
|
||||
['entity_type', 'method'],
|
||||
buckets=[0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0]
|
||||
)
|
||||
|
||||
self.pagination_errors_total = Counter(
|
||||
'anon_bot_pagination_errors_total',
|
||||
'Total number of pagination errors',
|
||||
['entity_type', 'error_type']
|
||||
)
|
||||
|
||||
# Метрики batch операций
|
||||
self.batch_operations_total = Counter(
|
||||
'anon_bot_batch_operations_total',
|
||||
'Total number of batch operations',
|
||||
['operation', 'table', 'status']
|
||||
)
|
||||
|
||||
self.batch_operation_duration = Histogram(
|
||||
'anon_bot_batch_operation_duration_seconds',
|
||||
'Batch operation duration',
|
||||
['operation', 'table'],
|
||||
buckets=[0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0]
|
||||
)
|
||||
|
||||
self.batch_operation_size = Histogram(
|
||||
'anon_bot_batch_operation_size',
|
||||
'Batch operation size (number of items)',
|
||||
['operation', 'table'],
|
||||
buckets=[1, 5, 10, 25, 50, 100, 250, 500, 1000]
|
||||
)
|
||||
|
||||
# Время ответа HTTP эндпоинтов
|
||||
self.http_request_duration = Histogram(
|
||||
'anon_bot_http_request_duration_seconds',
|
||||
'HTTP request duration',
|
||||
['method', 'endpoint'],
|
||||
buckets=[0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0]
|
||||
)
|
||||
|
||||
logger.info("Prometheus metrics initialized")
|
||||
|
||||
def increment_messages(self, message_type: str, status: str = "success"):
|
||||
"""Увеличить счетчик сообщений"""
|
||||
self.messages_total.labels(message_type=message_type, status=status).inc()
|
||||
|
||||
def increment_questions(self, status: str = "received"):
|
||||
"""Увеличить счетчик вопросов"""
|
||||
self.questions_total.labels(status=status).inc()
|
||||
|
||||
def increment_answers(self, status: str = "sent"):
|
||||
"""Увеличить счетчик ответов"""
|
||||
self.answers_total.labels(status=status).inc()
|
||||
|
||||
def increment_users(self, action: str):
|
||||
"""Увеличить счетчик пользователей"""
|
||||
self.users_total.labels(action=action).inc()
|
||||
|
||||
def increment_errors(self, error_type: str, component: str):
|
||||
"""Увеличить счетчик ошибок"""
|
||||
self.errors_total.labels(error_type=error_type, component=component).inc()
|
||||
|
||||
def increment_http_requests(self, method: str, endpoint: str, status_code: int):
|
||||
"""Увеличить счетчик HTTP запросов"""
|
||||
self.http_requests_total.labels(
|
||||
method=method,
|
||||
endpoint=endpoint,
|
||||
status_code=status_code
|
||||
).inc()
|
||||
|
||||
def set_active_users(self, count: int):
|
||||
"""Установить количество активных пользователей"""
|
||||
self.active_users.set(count)
|
||||
|
||||
def set_active_questions(self, count: int):
|
||||
"""Установить количество активных вопросов"""
|
||||
self.active_questions.set(count)
|
||||
|
||||
def record_message_processing_time(self, message_type: str, duration: float):
|
||||
"""Записать время обработки сообщения"""
|
||||
self.message_processing_time.labels(message_type=message_type).observe(duration)
|
||||
|
||||
def record_question_processing_time(self, duration: float):
|
||||
"""Записать время обработки вопроса"""
|
||||
self.question_processing_time.observe(duration)
|
||||
|
||||
def record_answer_processing_time(self, duration: float):
|
||||
"""Записать время обработки ответа"""
|
||||
self.answer_processing_time.observe(duration)
|
||||
|
||||
def record_http_request_duration(self, method: str, endpoint: str, duration: float):
|
||||
"""Записать время обработки HTTP запроса"""
|
||||
self.http_request_duration.labels(method=method, endpoint=endpoint).observe(duration)
|
||||
|
||||
# Методы для метрик БД
|
||||
def record_db_query(self, operation: str, table: str, status: str, duration: float):
|
||||
"""Записать метрики запроса к БД"""
|
||||
self.db_queries_total.labels(operation=operation, table=table, status=status).inc()
|
||||
self.db_query_duration.labels(operation=operation, table=table).observe(duration)
|
||||
|
||||
def record_db_connection(self, status: str):
|
||||
"""Записать метрики подключения к БД"""
|
||||
self.db_connections_total.labels(status=status).inc()
|
||||
if status == "opened":
|
||||
self.db_connections_active.inc()
|
||||
elif status == "closed":
|
||||
self.db_connections_active.dec()
|
||||
|
||||
def record_pagination_time(self, entity_type: str, duration: float, method: str = "cursor"):
|
||||
"""Записать время пагинации"""
|
||||
self.pagination_requests_total.labels(entity_type=entity_type, method=method).inc()
|
||||
self.pagination_duration.labels(entity_type=entity_type, method=method).observe(duration)
|
||||
|
||||
def increment_pagination_requests(self, entity_type: str, method: str = "cursor"):
|
||||
"""Увеличить счетчик запросов пагинации"""
|
||||
self.pagination_requests_total.labels(entity_type=entity_type, method=method).inc()
|
||||
|
||||
def increment_pagination_errors(self, entity_type: str, error_type: str = "unknown"):
|
||||
"""Увеличить счетчик ошибок пагинации"""
|
||||
self.pagination_errors_total.labels(entity_type=entity_type, error_type=error_type).inc()
|
||||
|
||||
def record_batch_operation(self, operation: str, table: str, status: str, duration: float, size: int):
|
||||
"""Записать метрики batch операции"""
|
||||
self.batch_operations_total.labels(operation=operation, table=table, status=status).inc()
|
||||
self.batch_operation_duration.labels(operation=operation, table=table).observe(duration)
|
||||
self.batch_operation_size.labels(operation=operation, table=table).observe(size)
|
||||
|
||||
def get_metrics(self) -> str:
|
||||
"""Получить метрики в формате Prometheus"""
|
||||
return generate_latest()
|
||||
|
||||
def get_content_type(self) -> str:
|
||||
"""Получить Content-Type для метрик"""
|
||||
return CONTENT_TYPE_LATEST
|
||||
|
||||
|
||||
# Глобальный экземпляр сервиса метрик
|
||||
metrics_service = MetricsService()
|
||||
|
||||
|
||||
def get_metrics_service() -> MetricsService:
|
||||
"""Получить экземпляр сервиса метрик"""
|
||||
return metrics_service
|
||||
|
||||
|
||||
# Декораторы для автоматического сбора метрик
|
||||
def track_message_processing(message_type: str):
|
||||
"""Декоратор для отслеживания обработки сообщений"""
|
||||
def decorator(func):
|
||||
async def wrapper(*args, **kwargs):
|
||||
# Убираем dispatcher, если он есть, так как он не нужен
|
||||
kwargs.pop('dispatcher', None)
|
||||
|
||||
start_time = time.time()
|
||||
try:
|
||||
result = await func(*args, **kwargs)
|
||||
metrics_service.increment_messages(message_type, "success")
|
||||
return result
|
||||
except Exception as e:
|
||||
metrics_service.increment_messages(message_type, "error")
|
||||
metrics_service.increment_errors(type(e).__name__, "message_processing")
|
||||
raise
|
||||
finally:
|
||||
duration = time.time() - start_time
|
||||
metrics_service.record_message_processing_time(message_type, duration)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def track_question_processing():
|
||||
"""Декоратор для отслеживания обработки вопросов"""
|
||||
def decorator(func):
|
||||
async def wrapper(*args, **kwargs):
|
||||
# Убираем dispatcher, если он есть, так как он не нужен
|
||||
kwargs.pop('dispatcher', None)
|
||||
|
||||
start_time = time.time()
|
||||
try:
|
||||
result = await func(*args, **kwargs)
|
||||
metrics_service.increment_questions("processed")
|
||||
return result
|
||||
except Exception as e:
|
||||
metrics_service.increment_questions("error")
|
||||
metrics_service.increment_errors(type(e).__name__, "question_processing")
|
||||
raise
|
||||
finally:
|
||||
duration = time.time() - start_time
|
||||
metrics_service.record_question_processing_time(duration)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def track_answer_processing():
|
||||
"""Декоратор для отслеживания обработки ответов"""
|
||||
def decorator(func):
|
||||
async def wrapper(*args, **kwargs):
|
||||
# Убираем dispatcher, если он есть, так как он не нужен
|
||||
kwargs.pop('dispatcher', None)
|
||||
|
||||
start_time = time.time()
|
||||
try:
|
||||
result = await func(*args, **kwargs)
|
||||
metrics_service.increment_answers("sent")
|
||||
return result
|
||||
except Exception as e:
|
||||
metrics_service.increment_answers("error")
|
||||
metrics_service.increment_errors(type(e).__name__, "answer_processing")
|
||||
raise
|
||||
finally:
|
||||
duration = time.time() - start_time
|
||||
metrics_service.record_answer_processing_time(duration)
|
||||
return wrapper
|
||||
return decorator
|
||||
117
services/infrastructure/pid_manager.py
Normal file
117
services/infrastructure/pid_manager.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
PID менеджер для управления PID файлом процесса
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class PIDManager:
|
||||
"""Менеджер для управления PID файлом процесса"""
|
||||
|
||||
def __init__(self, service_name: str = "anon_bot", pid_dir: str = "/tmp"):
|
||||
self.service_name = service_name
|
||||
self.pid_dir = Path(pid_dir)
|
||||
self.pid_file_path = self.pid_dir / f"{service_name}.pid"
|
||||
self.pid: Optional[int] = None
|
||||
|
||||
def create_pid_file(self) -> bool:
|
||||
"""Создать PID файл"""
|
||||
try:
|
||||
# Создаем директорию для PID файлов, если она не существует
|
||||
self.pid_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Проверяем, не запущен ли уже процесс
|
||||
if self.pid_file_path.exists():
|
||||
try:
|
||||
with open(self.pid_file_path, 'r') as f:
|
||||
existing_pid = int(f.read().strip())
|
||||
|
||||
# Проверяем, жив ли процесс с этим PID
|
||||
if self._is_process_running(existing_pid):
|
||||
logger.error(f"Процесс {self.service_name} уже запущен с PID {existing_pid}")
|
||||
return False
|
||||
else:
|
||||
logger.warning(f"Найден устаревший PID файл для {existing_pid}, удаляем его")
|
||||
self.pid_file_path.unlink()
|
||||
|
||||
except (ValueError, OSError) as e:
|
||||
logger.warning(f"Не удалось прочитать существующий PID файл: {e}, удаляем его")
|
||||
self.pid_file_path.unlink()
|
||||
|
||||
# Получаем PID текущего процесса
|
||||
self.pid = os.getpid()
|
||||
|
||||
# Создаем PID файл
|
||||
with open(self.pid_file_path, 'w') as f:
|
||||
f.write(str(self.pid))
|
||||
|
||||
logger.info(f"PID файл создан: {self.pid_file_path} (PID: {self.pid})")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось создать PID файл: {e}")
|
||||
return False
|
||||
|
||||
def cleanup_pid_file(self) -> None:
|
||||
"""Очистить PID файл"""
|
||||
try:
|
||||
if self.pid_file_path.exists():
|
||||
# Проверяем, что PID файл принадлежит нашему процессу
|
||||
with open(self.pid_file_path, 'r') as f:
|
||||
file_pid = int(f.read().strip())
|
||||
|
||||
if file_pid == self.pid:
|
||||
self.pid_file_path.unlink()
|
||||
logger.info(f"PID файл удален: {self.pid_file_path}")
|
||||
else:
|
||||
logger.warning(f"PID файл содержит другой PID ({file_pid}), не удаляем")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении PID файла: {e}")
|
||||
|
||||
def get_pid(self) -> Optional[int]:
|
||||
"""Получить PID процесса"""
|
||||
return self.pid
|
||||
|
||||
def get_pid_file_path(self) -> Path:
|
||||
"""Получить путь к PID файлу"""
|
||||
return self.pid_file_path
|
||||
|
||||
|
||||
def _is_process_running(self, pid: int) -> bool:
|
||||
"""Проверить, запущен ли процесс с указанным PID"""
|
||||
try:
|
||||
# В Unix-системах отправляем сигнал 0 для проверки существования процесса
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except (OSError, ProcessLookupError):
|
||||
return False
|
||||
|
||||
|
||||
|
||||
# Глобальный экземпляр PID менеджера
|
||||
_pid_manager: Optional[PIDManager] = None
|
||||
|
||||
|
||||
def get_pid_manager(service_name: str = "anon_bot", pid_dir: str = "/tmp") -> PIDManager:
|
||||
"""Получить экземпляр PID менеджера"""
|
||||
global _pid_manager
|
||||
if _pid_manager is None:
|
||||
_pid_manager = PIDManager(service_name, pid_dir)
|
||||
return _pid_manager
|
||||
|
||||
|
||||
def create_pid_file(service_name: str = "anon_bot", pid_dir: str = "/tmp") -> bool:
|
||||
"""Создать PID файл"""
|
||||
pid_manager = get_pid_manager(service_name, pid_dir)
|
||||
return pid_manager.create_pid_file()
|
||||
|
||||
|
||||
def cleanup_pid_file() -> None:
|
||||
"""Очистить PID файл"""
|
||||
if _pid_manager:
|
||||
_pid_manager.cleanup_pid_file()
|
||||
20
services/permissions/__init__.py
Normal file
20
services/permissions/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
Система разрешений для AnonBot
|
||||
Соблюдает принцип открытости/закрытости (OCP)
|
||||
"""
|
||||
|
||||
from .base import Permission, PermissionChecker, PermissionRegistry
|
||||
from .decorators import require_permission, require_admin, require_superuser
|
||||
from .registry import get_permission_registry, get_permission_checker, init_permission_checker
|
||||
|
||||
__all__ = [
|
||||
'Permission',
|
||||
'PermissionChecker',
|
||||
'PermissionRegistry',
|
||||
'require_permission',
|
||||
'require_admin',
|
||||
'require_superuser',
|
||||
'get_permission_registry',
|
||||
'get_permission_checker',
|
||||
'init_permission_checker'
|
||||
]
|
||||
165
services/permissions/base.py
Normal file
165
services/permissions/base.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
Базовые классы для системы разрешений
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, Type, Optional, Any
|
||||
from services.infrastructure.database import DatabaseService
|
||||
from services.infrastructure.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class Permission(ABC):
|
||||
"""
|
||||
Абстрактный базовый класс для всех разрешений.
|
||||
Соблюдает принцип открытости/закрытости (OCP).
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, description: str = ""):
|
||||
self.name = name
|
||||
self.description = description
|
||||
|
||||
@abstractmethod
|
||||
async def check(self, user_id: int, database: DatabaseService, config: Any) -> bool:
|
||||
"""
|
||||
Проверка разрешения для пользователя
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя в Telegram
|
||||
database: Сервис базы данных
|
||||
config: Конфигурация приложения
|
||||
|
||||
Returns:
|
||||
True если у пользователя есть разрешение, False иначе
|
||||
"""
|
||||
pass
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Permission({self.name})"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Permission(name='{self.name}', description='{self.description}')"
|
||||
|
||||
|
||||
class PermissionRegistry:
|
||||
"""
|
||||
Реестр разрешений. Позволяет регистрировать и получать разрешения.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._permissions: Dict[str, Permission] = {}
|
||||
|
||||
def register(self, permission: Permission) -> None:
|
||||
"""
|
||||
Регистрация разрешения
|
||||
|
||||
Args:
|
||||
permission: Разрешение для регистрации
|
||||
"""
|
||||
if permission.name in self._permissions:
|
||||
logger.warning(f"Разрешение '{permission.name}' уже зарегистрировано. Перезаписываем.")
|
||||
|
||||
self._permissions[permission.name] = permission
|
||||
logger.debug(f"Зарегистрировано разрешение: {permission}")
|
||||
|
||||
def get(self, name: str) -> Optional[Permission]:
|
||||
"""
|
||||
Получение разрешения по имени
|
||||
|
||||
Args:
|
||||
name: Имя разрешения
|
||||
|
||||
Returns:
|
||||
Разрешение или None если не найдено
|
||||
"""
|
||||
return self._permissions.get(name)
|
||||
|
||||
def get_all(self) -> Dict[str, Permission]:
|
||||
"""
|
||||
Получение всех зарегистрированных разрешений
|
||||
|
||||
Returns:
|
||||
Словарь всех разрешений
|
||||
"""
|
||||
return self._permissions.copy()
|
||||
|
||||
def is_registered(self, name: str) -> bool:
|
||||
"""
|
||||
Проверка, зарегистрировано ли разрешение
|
||||
|
||||
Args:
|
||||
name: Имя разрешения
|
||||
|
||||
Returns:
|
||||
True если разрешение зарегистрировано, False иначе
|
||||
"""
|
||||
return name in self._permissions
|
||||
|
||||
|
||||
class PermissionChecker:
|
||||
"""
|
||||
Сервис для проверки разрешений пользователей.
|
||||
Использует реестр разрешений для получения логики проверки.
|
||||
"""
|
||||
|
||||
def __init__(self, registry: PermissionRegistry, database: DatabaseService, config: Any):
|
||||
self.registry = registry
|
||||
self.database = database
|
||||
self.config = config
|
||||
|
||||
async def has_permission(self, user_id: int, permission_name: str) -> bool:
|
||||
"""
|
||||
Проверка наличия разрешения у пользователя
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя в Telegram
|
||||
permission_name: Имя разрешения
|
||||
|
||||
Returns:
|
||||
True если у пользователя есть разрешение, False иначе
|
||||
"""
|
||||
try:
|
||||
permission = self.registry.get(permission_name)
|
||||
if not permission:
|
||||
logger.warning(f"Разрешение '{permission_name}' не найдено в реестре")
|
||||
return False
|
||||
|
||||
result = await permission.check(user_id, self.database, self.config)
|
||||
logger.debug(f"Проверка разрешения '{permission_name}' для пользователя {user_id}: {result}")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при проверке разрешения '{permission_name}' для пользователя {user_id}: {e}")
|
||||
return False
|
||||
|
||||
async def has_any_permission(self, user_id: int, permission_names: list[str]) -> bool:
|
||||
"""
|
||||
Проверка наличия хотя бы одного из разрешений у пользователя
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя в Telegram
|
||||
permission_names: Список имен разрешений
|
||||
|
||||
Returns:
|
||||
True если у пользователя есть хотя бы одно разрешение, False иначе
|
||||
"""
|
||||
for permission_name in permission_names:
|
||||
if await self.has_permission(user_id, permission_name):
|
||||
return True
|
||||
return False
|
||||
|
||||
async def has_all_permissions(self, user_id: int, permission_names: list[str]) -> bool:
|
||||
"""
|
||||
Проверка наличия всех разрешений у пользователя
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя в Telegram
|
||||
permission_names: Список имен разрешений
|
||||
|
||||
Returns:
|
||||
True если у пользователя есть все разрешения, False иначе
|
||||
"""
|
||||
for permission_name in permission_names:
|
||||
if not await self.has_permission(user_id, permission_name):
|
||||
return False
|
||||
return True
|
||||
141
services/permissions/decorators.py
Normal file
141
services/permissions/decorators.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
Декораторы для проверки разрешений
|
||||
"""
|
||||
from functools import wraps
|
||||
from typing import Callable, Any, Union
|
||||
from aiogram.types import Message, CallbackQuery
|
||||
from services.infrastructure.logger import get_logger
|
||||
from .registry import get_permission_checker
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def require_permission(permission_name: str, error_message: str = "❌ У вас нет прав для выполнения этой команды."):
|
||||
"""
|
||||
Декоратор для проверки разрешения пользователя
|
||||
|
||||
Args:
|
||||
permission_name: Имя разрешения для проверки
|
||||
error_message: Сообщение об ошибке при отсутствии разрешения
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
# Извлекаем объект события (Message или CallbackQuery)
|
||||
event = None
|
||||
for arg in args:
|
||||
if isinstance(arg, (Message, CallbackQuery)):
|
||||
event = arg
|
||||
break
|
||||
|
||||
if not event:
|
||||
logger.error("Не удалось найти объект события для проверки разрешения")
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
# Получаем проверщик разрешений
|
||||
checker = get_permission_checker()
|
||||
if not checker:
|
||||
logger.error("Проверщик разрешений не инициализирован")
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
# Проверяем разрешение
|
||||
user_id = event.from_user.id
|
||||
has_permission = await checker.has_permission(user_id, permission_name)
|
||||
|
||||
if not has_permission:
|
||||
if isinstance(event, Message):
|
||||
await event.answer(error_message)
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer(error_message, show_alert=True)
|
||||
return
|
||||
|
||||
# Выполняем оригинальную функцию
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def require_admin(error_message: str = "❌ У вас нет прав администратора."):
|
||||
"""
|
||||
Декоратор для проверки прав администратора
|
||||
|
||||
Args:
|
||||
error_message: Сообщение об ошибке при отсутствии прав администратора
|
||||
"""
|
||||
return require_permission("admin", error_message)
|
||||
|
||||
|
||||
def require_superuser(error_message: str = "❌ У вас нет прав суперпользователя."):
|
||||
"""
|
||||
Декоратор для проверки прав суперпользователя
|
||||
|
||||
Args:
|
||||
error_message: Сообщение об ошибке при отсутствии прав суперпользователя
|
||||
"""
|
||||
return require_permission("superuser", error_message)
|
||||
|
||||
|
||||
def require_admin_or_superuser(error_message: str = "❌ У вас нет прав для выполнения этой команды."):
|
||||
"""
|
||||
Декоратор для проверки прав администратора или суперпользователя
|
||||
|
||||
Args:
|
||||
error_message: Сообщение об ошибке при отсутствии прав
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
# Извлекаем объект события (Message или CallbackQuery)
|
||||
event = None
|
||||
for arg in args:
|
||||
if isinstance(arg, (Message, CallbackQuery)):
|
||||
event = arg
|
||||
break
|
||||
|
||||
if not event:
|
||||
logger.error("Не удалось найти объект события для проверки разрешения")
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
# Получаем проверщик разрешений
|
||||
checker = get_permission_checker()
|
||||
if not checker:
|
||||
logger.error("Проверщик разрешений не инициализирован")
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
# Проверяем права администратора или суперпользователя
|
||||
user_id = event.from_user.id
|
||||
has_permission = await checker.has_any_permission(user_id, ["admin", "superuser"])
|
||||
|
||||
if not has_permission:
|
||||
if isinstance(event, Message):
|
||||
await event.answer(error_message)
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer(error_message, show_alert=True)
|
||||
return
|
||||
|
||||
# Выполняем оригинальную функцию
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def require_active_user(error_message: str = "❌ Ваш аккаунт неактивен."):
|
||||
"""
|
||||
Декоратор для проверки активности пользователя
|
||||
|
||||
Args:
|
||||
error_message: Сообщение об ошибке при неактивном аккаунте
|
||||
"""
|
||||
return require_permission("view_questions", error_message)
|
||||
|
||||
|
||||
def require_unbanned_user(error_message: str = "❌ Ваш аккаунт заблокирован."):
|
||||
"""
|
||||
Декоратор для проверки, что пользователь не забанен
|
||||
|
||||
Args:
|
||||
error_message: Сообщение об ошибке при заблокированном аккаунте
|
||||
"""
|
||||
return require_permission("ask_questions", error_message)
|
||||
55
services/permissions/init_permissions.py
Normal file
55
services/permissions/init_permissions.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
Инициализация системы разрешений
|
||||
Автоматически регистрирует все доступные разрешения
|
||||
"""
|
||||
from .registry import get_permission_registry, register_permission
|
||||
from .permissions import (
|
||||
AdminPermission,
|
||||
SuperuserPermission,
|
||||
ViewStatsPermission,
|
||||
AdminPanelPermission,
|
||||
ManageUsersPermission,
|
||||
BroadcastPermission,
|
||||
SuperuserOnlyPermission,
|
||||
ViewQuestionsPermission,
|
||||
AskQuestionsPermission,
|
||||
AnswerQuestionsPermission
|
||||
)
|
||||
from services.infrastructure.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def init_all_permissions():
|
||||
"""
|
||||
Инициализация всех разрешений в системе
|
||||
"""
|
||||
logger.info("Начинаем инициализацию системы разрешений...")
|
||||
|
||||
# Список всех разрешений для регистрации
|
||||
permissions = [
|
||||
AdminPermission(),
|
||||
SuperuserPermission(),
|
||||
ViewStatsPermission(),
|
||||
AdminPanelPermission(),
|
||||
ManageUsersPermission(),
|
||||
BroadcastPermission(),
|
||||
SuperuserOnlyPermission(),
|
||||
ViewQuestionsPermission(),
|
||||
AskQuestionsPermission(),
|
||||
AnswerQuestionsPermission(),
|
||||
]
|
||||
|
||||
# Регистрируем все разрешения
|
||||
for permission in permissions:
|
||||
register_permission(permission)
|
||||
logger.debug(f"Зарегистрировано разрешение: {permission.name}")
|
||||
|
||||
registry = get_permission_registry()
|
||||
total_permissions = len(registry.get_all())
|
||||
|
||||
logger.info(f"✅ Система разрешений инициализирована. Зарегистрировано {total_permissions} разрешений")
|
||||
|
||||
return registry
|
||||
|
||||
|
||||
196
services/permissions/permissions.py
Normal file
196
services/permissions/permissions.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""
|
||||
Конкретные реализации разрешений
|
||||
Каждое разрешение - отдельный класс, что позволяет легко добавлять новые без изменения существующего кода
|
||||
"""
|
||||
from .base import Permission
|
||||
from services.infrastructure.database import DatabaseService
|
||||
from services.infrastructure.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AdminPermission(Permission):
|
||||
"""Разрешение для администраторов"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
name="admin",
|
||||
description="Права администратора"
|
||||
)
|
||||
|
||||
async def check(self, user_id: int, database: DatabaseService, config) -> bool:
|
||||
"""Проверка, является ли пользователь администратором"""
|
||||
return user_id in config.ADMINS
|
||||
|
||||
|
||||
class SuperuserPermission(Permission):
|
||||
"""Разрешение для суперпользователей"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
name="superuser",
|
||||
description="Права суперпользователя"
|
||||
)
|
||||
|
||||
async def check(self, user_id: int, database: DatabaseService, config) -> bool:
|
||||
"""Проверка, является ли пользователь суперпользователем"""
|
||||
try:
|
||||
user = await database.get_user(user_id)
|
||||
return user.is_superuser if user else False
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при проверке суперпользователя {user_id}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class ViewStatsPermission(Permission):
|
||||
"""Разрешение на просмотр статистики"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
name="view_stats",
|
||||
description="Просмотр статистики"
|
||||
)
|
||||
|
||||
async def check(self, user_id: int, database: DatabaseService, config) -> bool:
|
||||
"""Проверка права на просмотр статистики"""
|
||||
# Администраторы и суперпользователи могут просматривать статистику
|
||||
admin_permission = AdminPermission()
|
||||
superuser_permission = SuperuserPermission()
|
||||
|
||||
is_admin = await admin_permission.check(user_id, database, config)
|
||||
is_superuser = await superuser_permission.check(user_id, database, config)
|
||||
|
||||
return is_admin or is_superuser
|
||||
|
||||
|
||||
class AdminPanelPermission(Permission):
|
||||
"""Разрешение на доступ к админ панели"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
name="admin_panel",
|
||||
description="Доступ к админ панели"
|
||||
)
|
||||
|
||||
async def check(self, user_id: int, database: DatabaseService, config) -> bool:
|
||||
"""Проверка права на доступ к админ панели"""
|
||||
# Администраторы и суперпользователи могут использовать админ панель
|
||||
admin_permission = AdminPermission()
|
||||
superuser_permission = SuperuserPermission()
|
||||
|
||||
is_admin = await admin_permission.check(user_id, database, config)
|
||||
is_superuser = await superuser_permission.check(user_id, database, config)
|
||||
|
||||
return is_admin or is_superuser
|
||||
|
||||
|
||||
class ManageUsersPermission(Permission):
|
||||
"""Разрешение на управление пользователями"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
name="manage_users",
|
||||
description="Управление пользователями"
|
||||
)
|
||||
|
||||
async def check(self, user_id: int, database: DatabaseService, config) -> bool:
|
||||
"""Проверка права на управление пользователями"""
|
||||
# Администраторы и суперпользователи могут управлять пользователями
|
||||
admin_permission = AdminPermission()
|
||||
superuser_permission = SuperuserPermission()
|
||||
|
||||
is_admin = await admin_permission.check(user_id, database, config)
|
||||
is_superuser = await superuser_permission.check(user_id, database, config)
|
||||
|
||||
return is_admin or is_superuser
|
||||
|
||||
|
||||
class BroadcastPermission(Permission):
|
||||
"""Разрешение на рассылку сообщений"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
name="broadcast",
|
||||
description="Рассылка сообщений"
|
||||
)
|
||||
|
||||
async def check(self, user_id: int, database: DatabaseService, config) -> bool:
|
||||
"""Проверка права на рассылку"""
|
||||
# Только администраторы могут делать рассылку
|
||||
admin_permission = AdminPermission()
|
||||
return await admin_permission.check(user_id, database, config)
|
||||
|
||||
|
||||
class SuperuserOnlyPermission(Permission):
|
||||
"""Разрешение только для суперпользователей"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
name="superuser_only",
|
||||
description="Только для суперпользователей"
|
||||
)
|
||||
|
||||
async def check(self, user_id: int, database: DatabaseService, config) -> bool:
|
||||
"""Проверка права только для суперпользователей"""
|
||||
superuser_permission = SuperuserPermission()
|
||||
return await superuser_permission.check(user_id, database, config)
|
||||
|
||||
|
||||
class ViewQuestionsPermission(Permission):
|
||||
"""Разрешение на просмотр вопросов"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
name="view_questions",
|
||||
description="Просмотр вопросов"
|
||||
)
|
||||
|
||||
async def check(self, user_id: int, database: DatabaseService, config) -> bool:
|
||||
"""Проверка права на просмотр вопросов"""
|
||||
# Все активные пользователи могут просматривать вопросы
|
||||
try:
|
||||
user = await database.get_user(user_id)
|
||||
return user.is_active if user else False
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при проверке активности пользователя {user_id}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class AskQuestionsPermission(Permission):
|
||||
"""Разрешение на задавание вопросов"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
name="ask_questions",
|
||||
description="Задавание вопросов"
|
||||
)
|
||||
|
||||
async def check(self, user_id: int, database: DatabaseService, config) -> bool:
|
||||
"""Проверка права на задавание вопросов"""
|
||||
# Все активные пользователи могут задавать вопросы
|
||||
try:
|
||||
user = await database.get_user(user_id)
|
||||
return user.is_active and not user.is_banned if user else False
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при проверке права задавать вопросы для пользователя {user_id}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class AnswerQuestionsPermission(Permission):
|
||||
"""Разрешение на ответы на вопросы"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
name="answer_questions",
|
||||
description="Ответы на вопросы"
|
||||
)
|
||||
|
||||
async def check(self, user_id: int, database: DatabaseService, config) -> bool:
|
||||
"""Проверка права на ответы на вопросы"""
|
||||
# Все активные пользователи могут отвечать на вопросы
|
||||
try:
|
||||
user = await database.get_user(user_id)
|
||||
return user.is_active and not user.is_banned if user else False
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при проверке права отвечать на вопросы для пользователя {user_id}: {e}")
|
||||
return False
|
||||
66
services/permissions/registry.py
Normal file
66
services/permissions/registry.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
Глобальный реестр разрешений и фабричные функции
|
||||
"""
|
||||
from typing import Optional
|
||||
from .base import PermissionRegistry, PermissionChecker, Permission
|
||||
from services.infrastructure.database import DatabaseService
|
||||
from services.infrastructure.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Глобальный реестр разрешений
|
||||
_permission_registry: Optional[PermissionRegistry] = None
|
||||
_permission_checker: Optional[PermissionChecker] = None
|
||||
|
||||
|
||||
def get_permission_registry() -> PermissionRegistry:
|
||||
"""
|
||||
Получение глобального реестра разрешений
|
||||
|
||||
Returns:
|
||||
Глобальный экземпляр реестра разрешений
|
||||
"""
|
||||
global _permission_registry
|
||||
if _permission_registry is None:
|
||||
_permission_registry = PermissionRegistry()
|
||||
logger.info("Создан глобальный реестр разрешений")
|
||||
return _permission_registry
|
||||
|
||||
|
||||
def get_permission_checker() -> Optional[PermissionChecker]:
|
||||
"""
|
||||
Получение глобального проверщика разрешений
|
||||
|
||||
Returns:
|
||||
Глобальный экземпляр проверщика разрешений или None если не инициализирован
|
||||
"""
|
||||
return _permission_checker
|
||||
|
||||
|
||||
def init_permission_checker(database: DatabaseService, config) -> PermissionChecker:
|
||||
"""
|
||||
Инициализация глобального проверщика разрешений
|
||||
|
||||
Args:
|
||||
database: Сервис базы данных
|
||||
config: Конфигурация приложения
|
||||
|
||||
Returns:
|
||||
Инициализированный проверщик разрешений
|
||||
"""
|
||||
global _permission_checker
|
||||
registry = get_permission_registry()
|
||||
_permission_checker = PermissionChecker(registry, database, config)
|
||||
logger.info("Инициализирован глобальный проверщик разрешений")
|
||||
return _permission_checker
|
||||
|
||||
|
||||
def register_permission(permission: Permission) -> None:
|
||||
"""
|
||||
Регистрация разрешения в глобальном реестре
|
||||
|
||||
Args:
|
||||
permission: Разрешение для регистрации
|
||||
"""
|
||||
registry = get_permission_registry()
|
||||
registry.register(permission)
|
||||
13
services/rate_limiting/__init__.py
Normal file
13
services/rate_limiting/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
Rate limiting сервисы
|
||||
"""
|
||||
|
||||
from .rate_limit_config import RateLimitSettings, get_rate_limit_config, get_adaptive_config
|
||||
from .rate_limiter import RateLimitConfig, send_with_rate_limit, telegram_rate_limiter
|
||||
from .rate_limit_service import RateLimitService
|
||||
|
||||
__all__ = [
|
||||
'RateLimitSettings', 'get_rate_limit_config', 'get_adaptive_config',
|
||||
'RateLimitConfig', 'send_with_rate_limit', 'telegram_rate_limiter',
|
||||
'RateLimitService'
|
||||
]
|
||||
150
services/rate_limiting/rate_limit_config.py
Normal file
150
services/rate_limiting/rate_limit_config.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
Конфигурация для rate limiting в AnonBot
|
||||
"""
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Загружаем переменные окружения
|
||||
load_dotenv()
|
||||
|
||||
|
||||
@dataclass
|
||||
class RateLimitSettings:
|
||||
"""Настройки rate limiting для разных типов сообщений"""
|
||||
|
||||
# Основные настройки
|
||||
messages_per_second: float = float(os.getenv('RATE_LIMIT_MESSAGES_PER_SECOND', '0.5')) # Максимум 0.5 сообщений в секунду на чат
|
||||
burst_limit: int = int(os.getenv('RATE_LIMIT_BURST_LIMIT', '2')) # Максимум 2 сообщения подряд
|
||||
retry_after_multiplier: float = float(os.getenv('RATE_LIMIT_RETRY_MULTIPLIER', '1.5')) # Множитель для увеличения задержки при retry
|
||||
max_retry_delay: float = float(os.getenv('RATE_LIMIT_MAX_RETRY_DELAY', '30.0')) # Максимальная задержка между попытками
|
||||
max_retries: int = int(os.getenv('RATE_LIMIT_MAX_RETRIES', '3')) # Максимальное количество повторных попыток
|
||||
|
||||
# Специальные настройки для разных типов сообщений
|
||||
voice_message_delay: float = float(os.getenv('RATE_LIMIT_VOICE_DELAY', '2.0')) # Дополнительная задержка для голосовых сообщений
|
||||
media_message_delay: float = float(os.getenv('RATE_LIMIT_MEDIA_DELAY', '1.5')) # Дополнительная задержка для медиа сообщений
|
||||
text_message_delay: float = float(os.getenv('RATE_LIMIT_TEXT_DELAY', '1.0')) # Дополнительная задержка для текстовых сообщений
|
||||
|
||||
# Настройки для разных типов чатов
|
||||
private_chat_multiplier: float = float(os.getenv('RATE_LIMIT_PRIVATE_MULTIPLIER', '1.0')) # Множитель для приватных чатов
|
||||
group_chat_multiplier: float = float(os.getenv('RATE_LIMIT_GROUP_MULTIPLIER', '0.8')) # Множитель для групповых чатов
|
||||
channel_multiplier: float = float(os.getenv('RATE_LIMIT_CHANNEL_MULTIPLIER', '0.6')) # Множитель для каналов
|
||||
|
||||
# Глобальные ограничения
|
||||
global_messages_per_second: float = float(os.getenv('RATE_LIMIT_GLOBAL_MESSAGES_PER_SECOND', '10.0')) # Максимум 10 сообщений в секунду глобально
|
||||
global_burst_limit: int = int(os.getenv('RATE_LIMIT_GLOBAL_BURST_LIMIT', '20')) # Максимум 20 сообщений подряд глобально
|
||||
|
||||
|
||||
# Конфигурации для разных сценариев использования
|
||||
# Основаны на официальных лимитах Telegram Bot API:
|
||||
# - 1 сообщение в секунду в личных чатах
|
||||
# - 20 сообщений в минуту в групповых чатах (0.33 в секунду)
|
||||
# - 30 запросов в секунду глобально
|
||||
|
||||
DEVELOPMENT_CONFIG = RateLimitSettings(
|
||||
messages_per_second=0.8, # Более мягкие ограничения для разработки (80% от лимита)
|
||||
burst_limit=3, # До 3 сообщений подряд
|
||||
retry_after_multiplier=1.2,
|
||||
max_retry_delay=15.0,
|
||||
max_retries=2,
|
||||
voice_message_delay=1.5,
|
||||
media_message_delay=1.2,
|
||||
text_message_delay=1.0
|
||||
)
|
||||
|
||||
PRODUCTION_CONFIG = RateLimitSettings(
|
||||
messages_per_second=0.5, # Консервативные ограничения (50% от лимита)
|
||||
burst_limit=2, # До 2 сообщений подряд
|
||||
retry_after_multiplier=1.5,
|
||||
max_retry_delay=30.0,
|
||||
max_retries=3,
|
||||
voice_message_delay=2.5, # Дополнительная задержка для голосовых
|
||||
media_message_delay=2.0, # Дополнительная задержка для медиа
|
||||
text_message_delay=1.5, # Дополнительная задержка для текста
|
||||
global_messages_per_second=20.0, # 20 из 30 доступных запросов в секунду
|
||||
global_burst_limit=15 # До 15 сообщений подряд глобально
|
||||
)
|
||||
|
||||
STRICT_CONFIG = RateLimitSettings(
|
||||
messages_per_second=0.3, # Очень консервативные ограничения (30% от лимита)
|
||||
burst_limit=1, # Только 1 сообщение подряд
|
||||
retry_after_multiplier=2.0,
|
||||
max_retry_delay=60.0,
|
||||
max_retries=5,
|
||||
voice_message_delay=3.0,
|
||||
media_message_delay=2.5,
|
||||
text_message_delay=2.0,
|
||||
global_messages_per_second=10.0, # 10 из 30 доступных запросов в секунду
|
||||
global_burst_limit=8 # До 8 сообщений подряд глобально
|
||||
)
|
||||
|
||||
|
||||
def get_rate_limit_config(environment: str = None) -> RateLimitSettings:
|
||||
"""
|
||||
Получает конфигурацию rate limiting в зависимости от окружения
|
||||
|
||||
Args:
|
||||
environment: Окружение ('development', 'production', 'strict')
|
||||
Если не указано, берется из переменной окружения RATE_LIMIT_ENV
|
||||
|
||||
Returns:
|
||||
RateLimitSettings: Конфигурация для указанного окружения
|
||||
"""
|
||||
if environment is None:
|
||||
environment = os.getenv('RATE_LIMIT_ENV', 'production')
|
||||
|
||||
configs = {
|
||||
"development": DEVELOPMENT_CONFIG,
|
||||
"production": PRODUCTION_CONFIG,
|
||||
"strict": STRICT_CONFIG
|
||||
}
|
||||
|
||||
return configs.get(environment, PRODUCTION_CONFIG)
|
||||
|
||||
|
||||
def get_adaptive_config(
|
||||
current_error_rate: float,
|
||||
base_config: Optional[RateLimitSettings] = None
|
||||
) -> RateLimitSettings:
|
||||
"""
|
||||
Получает адаптивную конфигурацию на основе текущего уровня ошибок
|
||||
|
||||
Args:
|
||||
current_error_rate: Текущий уровень ошибок (0.0 - 1.0)
|
||||
base_config: Базовая конфигурация
|
||||
|
||||
Returns:
|
||||
RateLimitSettings: Адаптированная конфигурация
|
||||
"""
|
||||
if base_config is None:
|
||||
base_config = PRODUCTION_CONFIG
|
||||
|
||||
# Если уровень ошибок высокий, ужесточаем ограничения
|
||||
if current_error_rate > 0.1: # Более 10% ошибок
|
||||
return RateLimitSettings(
|
||||
messages_per_second=base_config.messages_per_second * 0.5,
|
||||
burst_limit=max(1, base_config.burst_limit - 1),
|
||||
retry_after_multiplier=base_config.retry_after_multiplier * 1.5,
|
||||
max_retry_delay=base_config.max_retry_delay * 1.5,
|
||||
max_retries=base_config.max_retries + 1,
|
||||
voice_message_delay=base_config.voice_message_delay * 1.5,
|
||||
media_message_delay=base_config.media_message_delay * 1.3,
|
||||
text_message_delay=base_config.text_message_delay * 1.2
|
||||
)
|
||||
|
||||
# Если уровень ошибок низкий, можно немного ослабить ограничения
|
||||
elif current_error_rate < 0.01: # Менее 1% ошибок
|
||||
return RateLimitSettings(
|
||||
messages_per_second=base_config.messages_per_second * 1.2,
|
||||
burst_limit=base_config.burst_limit + 1,
|
||||
retry_after_multiplier=base_config.retry_after_multiplier * 0.9,
|
||||
max_retry_delay=base_config.max_retry_delay * 0.8,
|
||||
max_retries=max(1, base_config.max_retries - 1),
|
||||
voice_message_delay=base_config.voice_message_delay * 0.8,
|
||||
media_message_delay=base_config.media_message_delay * 0.9,
|
||||
text_message_delay=base_config.text_message_delay * 0.9
|
||||
)
|
||||
|
||||
# Возвращаем базовую конфигурацию
|
||||
return base_config
|
||||
142
services/rate_limiting/rate_limit_service.py
Normal file
142
services/rate_limiting/rate_limit_service.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
Сервис для управления rate limiting в AnonBot
|
||||
"""
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
from aiogram.exceptions import TelegramAPIError, TelegramRetryAfter
|
||||
|
||||
from config.constants import MIN_REQUESTS_FOR_ADAPTATION, HIGH_ERROR_RATE_THRESHOLD, LOW_ERROR_RATE_THRESHOLD
|
||||
from services.infrastructure.logger import get_logger
|
||||
from .rate_limit_config import RateLimitSettings, get_adaptive_config, get_rate_limit_config
|
||||
from .rate_limiter import send_with_rate_limit, telegram_rate_limiter
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class RateLimitService:
|
||||
"""Сервис для управления rate limiting"""
|
||||
|
||||
def __init__(self):
|
||||
self.rate_limiter = telegram_rate_limiter
|
||||
self.config = get_rate_limit_config()
|
||||
self.stats = {
|
||||
'total_requests': 0,
|
||||
'successful_requests': 0,
|
||||
'failed_requests': 0,
|
||||
'retry_after_errors': 0,
|
||||
'other_errors': 0,
|
||||
'total_wait_time': 0.0
|
||||
}
|
||||
|
||||
async def send_with_rate_limit(
|
||||
self,
|
||||
send_func: Callable,
|
||||
chat_id: int,
|
||||
*args,
|
||||
**kwargs
|
||||
) -> Any:
|
||||
"""
|
||||
Отправляет сообщение с соблюдением rate limit
|
||||
|
||||
Args:
|
||||
send_func: Функция отправки (например, bot.send_message)
|
||||
chat_id: ID чата
|
||||
*args, **kwargs: Аргументы для функции отправки
|
||||
|
||||
Returns:
|
||||
Результат выполнения функции отправки
|
||||
"""
|
||||
self.stats['total_requests'] += 1
|
||||
logger.info(f"Обработка rate limit запроса для чата {chat_id}")
|
||||
|
||||
try:
|
||||
result, wait_time = await self.rate_limiter.send_with_rate_limit(send_func, chat_id, *args, **kwargs)
|
||||
self.stats['successful_requests'] += 1
|
||||
self.stats['total_wait_time'] += wait_time
|
||||
logger.info(f"Rate limited сообщение успешно отправлено в чат {chat_id}, время ожидания: {wait_time:.2f}с")
|
||||
return result
|
||||
|
||||
except TelegramRetryAfter as e:
|
||||
self.stats['failed_requests'] += 1
|
||||
self.stats['retry_after_errors'] += 1
|
||||
logger.warning(f"Превышен rate limit для чата {chat_id}: {e}")
|
||||
raise
|
||||
|
||||
except TelegramAPIError as e:
|
||||
self.stats['failed_requests'] += 1
|
||||
self.stats['other_errors'] += 1
|
||||
logger.error(f"Ошибка Telegram API для чата {chat_id}: {e}")
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
self.stats['failed_requests'] += 1
|
||||
self.stats['other_errors'] += 1
|
||||
logger.error(f"Неожиданная ошибка в rate limit сервисе для чата {chat_id}: {e}")
|
||||
raise
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""Получает статистику rate limiting"""
|
||||
total = self.stats['total_requests']
|
||||
if total == 0:
|
||||
return {
|
||||
'total_requests': 0,
|
||||
'successful_requests': 0,
|
||||
'failed_requests': 0,
|
||||
'success_rate': 0.0,
|
||||
'error_rate': 0.0,
|
||||
'retry_after_errors': 0,
|
||||
'other_errors': 0,
|
||||
'retry_after_rate': 0.0,
|
||||
'other_error_rate': 0.0,
|
||||
'average_wait_time': 0.0
|
||||
}
|
||||
|
||||
return {
|
||||
'total_requests': total,
|
||||
'successful_requests': self.stats['successful_requests'],
|
||||
'failed_requests': self.stats['failed_requests'],
|
||||
'success_rate': self.stats['successful_requests'] / total,
|
||||
'error_rate': self.stats['failed_requests'] / total,
|
||||
'retry_after_errors': self.stats['retry_after_errors'],
|
||||
'other_errors': self.stats['other_errors'],
|
||||
'retry_after_rate': self.stats['retry_after_errors'] / total,
|
||||
'other_error_rate': self.stats['other_errors'] / total,
|
||||
'average_wait_time': self.stats['total_wait_time'] / total if total > 0 else 0.0
|
||||
}
|
||||
|
||||
def reset_stats(self):
|
||||
"""Сбрасывает статистику"""
|
||||
self.stats = {
|
||||
'total_requests': 0,
|
||||
'successful_requests': 0,
|
||||
'failed_requests': 0,
|
||||
'retry_after_errors': 0,
|
||||
'other_errors': 0,
|
||||
'total_wait_time': 0.0
|
||||
}
|
||||
logger.info("Статистика rate limit сброшена")
|
||||
|
||||
def update_config(self, new_config: RateLimitSettings):
|
||||
"""Обновляет конфигурацию rate limiting"""
|
||||
self.config = new_config
|
||||
logger.info(f"Конфигурация rate limit обновлена: {new_config}")
|
||||
|
||||
def get_adaptive_config(self) -> RateLimitSettings:
|
||||
"""Получает адаптивную конфигурацию на основе текущей статистики"""
|
||||
error_rate = self.stats['failed_requests'] / max(1, self.stats['total_requests'])
|
||||
return get_adaptive_config(error_rate, self.config)
|
||||
|
||||
def should_adapt_config(self) -> bool:
|
||||
"""Определяет, нужно ли адаптировать конфигурацию"""
|
||||
if self.stats['total_requests'] < MIN_REQUESTS_FOR_ADAPTATION: # Недостаточно данных
|
||||
return False
|
||||
|
||||
error_rate = self.stats['failed_requests'] / self.stats['total_requests']
|
||||
return error_rate > HIGH_ERROR_RATE_THRESHOLD or error_rate < LOW_ERROR_RATE_THRESHOLD # Высокий или низкий уровень ошибок
|
||||
|
||||
async def adapt_config_if_needed(self):
|
||||
"""Адаптирует конфигурацию если необходимо"""
|
||||
if self.should_adapt_config():
|
||||
new_config = self.get_adaptive_config()
|
||||
self.update_config(new_config)
|
||||
logger.info("Конфигурация rate limit адаптирована на основе текущей производительности")
|
||||
230
services/rate_limiting/rate_limiter.py
Normal file
230
services/rate_limiting/rate_limiter.py
Normal file
@@ -0,0 +1,230 @@
|
||||
"""
|
||||
Rate limiter для предотвращения Flood control ошибок в Telegram Bot API
|
||||
"""
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Dict, Optional, Any, Callable
|
||||
from dataclasses import dataclass
|
||||
from aiogram.exceptions import TelegramRetryAfter, TelegramAPIError
|
||||
|
||||
from services.infrastructure.logger import get_logger
|
||||
from .rate_limit_config import RateLimitSettings, get_rate_limit_config
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RateLimitConfig:
|
||||
"""Конфигурация для rate limiting"""
|
||||
messages_per_second: float = 0.5 # Максимум 0.5 сообщений в секунду на чат
|
||||
burst_limit: int = 3 # Максимум 3 сообщения подряд
|
||||
retry_after_multiplier: float = 1.2 # Множитель для увеличения задержки при retry
|
||||
max_retry_delay: float = 60.0 # Максимальная задержка между попытками
|
||||
|
||||
|
||||
class ChatRateLimiter:
|
||||
"""Rate limiter для конкретного чата"""
|
||||
|
||||
def __init__(self, config: RateLimitConfig):
|
||||
self.config = config
|
||||
self.last_send_time = 0.0
|
||||
self.burst_count = 0
|
||||
self.burst_reset_time = 0.0
|
||||
self.retry_delay = 1.0
|
||||
|
||||
async def wait_if_needed(self) -> None:
|
||||
"""Ждет если необходимо для соблюдения rate limit"""
|
||||
current_time = time.time()
|
||||
|
||||
# Сбрасываем счетчик burst если прошло достаточно времени
|
||||
if current_time >= self.burst_reset_time:
|
||||
self.burst_count = 0
|
||||
self.burst_reset_time = current_time + 1.0
|
||||
|
||||
# Проверяем burst limit
|
||||
if self.burst_count >= self.config.burst_limit:
|
||||
wait_time = self.burst_reset_time - current_time
|
||||
if wait_time > 0:
|
||||
logger.info(f"Достигнут лимит burst, ожидание {wait_time:.2f}с")
|
||||
await asyncio.sleep(wait_time)
|
||||
current_time = time.time()
|
||||
self.burst_count = 0
|
||||
self.burst_reset_time = current_time + 1.0
|
||||
|
||||
# Проверяем минимальный интервал между сообщениями
|
||||
time_since_last = current_time - self.last_send_time
|
||||
min_interval = 1.0 / self.config.messages_per_second
|
||||
|
||||
if time_since_last < min_interval:
|
||||
wait_time = min_interval - time_since_last
|
||||
logger.debug(f"Rate limiting: waiting {wait_time:.2f}s")
|
||||
await asyncio.sleep(wait_time)
|
||||
|
||||
# Обновляем время последней отправки
|
||||
self.last_send_time = time.time()
|
||||
self.burst_count += 1
|
||||
|
||||
|
||||
class GlobalRateLimiter:
|
||||
"""Глобальный rate limiter для всех чатов"""
|
||||
|
||||
def __init__(self, config: RateLimitConfig):
|
||||
self.config = config
|
||||
self.chat_limiters: Dict[int, ChatRateLimiter] = {}
|
||||
self.global_last_send = 0.0
|
||||
self.global_min_interval = 0.1 # Минимум 100ms между любыми сообщениями
|
||||
|
||||
def get_chat_limiter(self, chat_id: int) -> ChatRateLimiter:
|
||||
"""Получает rate limiter для конкретного чата"""
|
||||
if chat_id not in self.chat_limiters:
|
||||
self.chat_limiters[chat_id] = ChatRateLimiter(self.config)
|
||||
return self.chat_limiters[chat_id]
|
||||
|
||||
async def wait_if_needed(self, chat_id: int) -> None:
|
||||
"""Ждет если необходимо для соблюдения глобального и чат-специфичного rate limit"""
|
||||
current_time = time.time()
|
||||
|
||||
# Глобальный rate limit
|
||||
time_since_global = current_time - self.global_last_send
|
||||
if time_since_global < self.global_min_interval:
|
||||
wait_time = self.global_min_interval - time_since_global
|
||||
logger.info(f"Применен глобальный rate limit для чата {chat_id}, ожидание {wait_time:.2f}с")
|
||||
await asyncio.sleep(wait_time)
|
||||
current_time = time.time()
|
||||
|
||||
# Чат-специфичный rate limit
|
||||
chat_limiter = self.get_chat_limiter(chat_id)
|
||||
await chat_limiter.wait_if_needed()
|
||||
|
||||
self.global_last_send = time.time()
|
||||
|
||||
|
||||
class RetryHandler:
|
||||
"""Обработчик повторных попыток с экспоненциальной задержкой"""
|
||||
|
||||
def __init__(self, config: RateLimitConfig):
|
||||
self.config = config
|
||||
|
||||
async def execute_with_retry(
|
||||
self,
|
||||
func: Callable,
|
||||
chat_id: int,
|
||||
*args,
|
||||
max_retries: int = 3,
|
||||
**kwargs
|
||||
) -> tuple[Any, float]:
|
||||
"""Выполняет функцию с повторными попытками при ошибках"""
|
||||
retry_count = 0
|
||||
current_delay = self.config.retry_after_multiplier
|
||||
total_wait_time = 0.0
|
||||
|
||||
while retry_count <= max_retries:
|
||||
try:
|
||||
result = await func(*args, **kwargs)
|
||||
# Записываем успешный запрос
|
||||
logger.debug(f"Rate limit запрос успешен для чата {chat_id}")
|
||||
return result, total_wait_time
|
||||
|
||||
except TelegramRetryAfter as e:
|
||||
retry_count += 1
|
||||
if retry_count > max_retries:
|
||||
logger.error(f"Превышено максимальное количество попыток для RetryAfter: {e}")
|
||||
raise
|
||||
|
||||
# Используем время ожидания от Telegram или наше увеличенное
|
||||
wait_time = max(e.retry_after, current_delay)
|
||||
wait_time = min(wait_time, self.config.max_retry_delay)
|
||||
total_wait_time += wait_time
|
||||
|
||||
logger.info(f"RetryAfter ошибка для чата {chat_id}, ожидание {wait_time:.2f}с (попытка {retry_count}/{max_retries})")
|
||||
await asyncio.sleep(wait_time)
|
||||
current_delay *= self.config.retry_after_multiplier
|
||||
|
||||
except TelegramAPIError as e:
|
||||
retry_count += 1
|
||||
if retry_count > max_retries:
|
||||
logger.error(f"Превышено максимальное количество попыток для TelegramAPIError: {e}")
|
||||
raise
|
||||
|
||||
wait_time = min(current_delay, self.config.max_retry_delay)
|
||||
total_wait_time += wait_time
|
||||
logger.info(f"TelegramAPIError для чата {chat_id}, ожидание {wait_time:.2f}с (попытка {retry_count}/{max_retries}): {e}")
|
||||
await asyncio.sleep(wait_time)
|
||||
current_delay *= self.config.retry_after_multiplier
|
||||
|
||||
except Exception as e:
|
||||
# Для других ошибок не делаем retry
|
||||
logger.error(f"Ошибка без повторных попыток: {e}")
|
||||
raise
|
||||
|
||||
|
||||
class TelegramRateLimiter:
|
||||
"""Основной класс для rate limiting в Telegram боте"""
|
||||
|
||||
def __init__(self, config: Optional[RateLimitConfig] = None):
|
||||
self.config = config or RateLimitConfig()
|
||||
self.global_limiter = GlobalRateLimiter(self.config)
|
||||
self.retry_handler = RetryHandler(self.config)
|
||||
|
||||
async def send_with_rate_limit(
|
||||
self,
|
||||
send_func: Callable,
|
||||
chat_id: int,
|
||||
*args,
|
||||
**kwargs
|
||||
) -> tuple[Any, float]:
|
||||
"""Отправляет сообщение с соблюдением rate limit и retry логики"""
|
||||
|
||||
async def _send():
|
||||
await self.global_limiter.wait_if_needed(chat_id)
|
||||
# Добавляем chat_id в kwargs для функции отправки
|
||||
send_kwargs = kwargs.copy()
|
||||
send_kwargs['chat_id'] = chat_id
|
||||
return await send_func(*args, **send_kwargs)
|
||||
|
||||
return await self.retry_handler.execute_with_retry(_send, chat_id)
|
||||
|
||||
async def execute_with_rate_limit(
|
||||
self,
|
||||
handler_func: Callable,
|
||||
chat_id: int
|
||||
) -> tuple[Any, float]:
|
||||
"""Выполняет обработчик с соблюдением rate limit (без добавления chat_id в kwargs)"""
|
||||
|
||||
async def _execute():
|
||||
await self.global_limiter.wait_if_needed(chat_id)
|
||||
return await handler_func()
|
||||
|
||||
return await self.retry_handler.execute_with_retry(_execute, chat_id)
|
||||
|
||||
|
||||
def _create_rate_limit_config(settings: RateLimitSettings) -> RateLimitConfig:
|
||||
"""Создает RateLimitConfig из RateLimitSettings"""
|
||||
return RateLimitConfig(
|
||||
messages_per_second=settings.messages_per_second,
|
||||
burst_limit=settings.burst_limit,
|
||||
retry_after_multiplier=settings.retry_after_multiplier,
|
||||
max_retry_delay=settings.max_retry_delay
|
||||
)
|
||||
|
||||
|
||||
# Получаем конфигурацию из настроек
|
||||
_rate_limit_settings = get_rate_limit_config()
|
||||
_default_config = _create_rate_limit_config(_rate_limit_settings)
|
||||
|
||||
telegram_rate_limiter = TelegramRateLimiter(_default_config)
|
||||
|
||||
|
||||
async def send_with_rate_limit(send_func: Callable, chat_id: int, *args, **kwargs) -> tuple[Any, float]:
|
||||
"""
|
||||
Удобная функция для отправки сообщений с rate limiting
|
||||
|
||||
Args:
|
||||
send_func: Функция отправки (например, bot.send_message)
|
||||
chat_id: ID чата
|
||||
*args, **kwargs: Аргументы для функции отправки
|
||||
|
||||
Returns:
|
||||
Кортеж (результат выполнения функции отправки, общее время ожидания)
|
||||
"""
|
||||
return await telegram_rate_limiter.send_with_rate_limit(send_func, chat_id, *args, **kwargs)
|
||||
402
services/utils.py
Normal file
402
services/utils.py
Normal file
@@ -0,0 +1,402 @@
|
||||
"""
|
||||
Сервис утилит для бота
|
||||
"""
|
||||
import asyncio
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from config.constants import ANONYMOUS_TOKEN_LENGTH, DEFAULT_QUESTION_PREVIEW_LENGTH, DEFAULT_TEXT_TRUNCATE_LENGTH, MIN_QUESTION_LENGTH, MIN_ANSWER_LENGTH, SEPARATOR_LENGTH
|
||||
from models.question import Question
|
||||
from models.user import User
|
||||
from services.infrastructure.database import DatabaseService
|
||||
from services.infrastructure.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class UtilsService:
|
||||
"""Сервис утилит для форматирования и валидации"""
|
||||
|
||||
def __init__(self, database: DatabaseService):
|
||||
self.database = database
|
||||
|
||||
def generate_referral_link(self, bot_username: str, user_id: int) -> str:
|
||||
"""
|
||||
Генерация уникальной реферальной ссылки для пользователя
|
||||
|
||||
Args:
|
||||
bot_username: Имя бота (без @)
|
||||
user_id: ID пользователя
|
||||
|
||||
Returns:
|
||||
Ссылка формата: t.me/bot_username?start=ref_{user_id}
|
||||
"""
|
||||
return f"https://t.me/{bot_username}?start=ref_{user_id}"
|
||||
|
||||
def generate_anonymous_id(self) -> str:
|
||||
"""
|
||||
Генерация анонимного ID для отправителя вопроса
|
||||
|
||||
Returns:
|
||||
Случайная строка для идентификации анонимного пользователя
|
||||
"""
|
||||
return secrets.token_hex(ANONYMOUS_TOKEN_LENGTH)
|
||||
|
||||
def format_user_info(self, user: User, show_stats: bool = False) -> str:
|
||||
"""
|
||||
Форматирование информации о пользователе
|
||||
|
||||
Args:
|
||||
user: Объект пользователя
|
||||
show_stats: Показывать ли статистику
|
||||
|
||||
Returns:
|
||||
Отформатированная строка с информацией о пользователе
|
||||
"""
|
||||
info = f"👤 <b>{user.display_name}</b>\n"
|
||||
|
||||
if user.full_name and user.username:
|
||||
info += f"📝 {user.full_name}\n"
|
||||
|
||||
if hasattr(user, 'is_premium') and user.is_premium:
|
||||
info += "⭐ Premium пользователь\n"
|
||||
|
||||
if hasattr(user, 'language_code') and user.language_code:
|
||||
info += f"🌐 Язык: {user.language_code.upper()}\n"
|
||||
|
||||
if user.created_at:
|
||||
info += f"📅 Регистрация: {user.created_at.strftime('%d.%m.%Y %H:%M')}\n"
|
||||
|
||||
if user.is_active:
|
||||
info += "✅ Активен\n"
|
||||
else:
|
||||
info += "❌ Неактивен\n"
|
||||
|
||||
if show_stats:
|
||||
# Здесь можно добавить статистику пользователя
|
||||
pass
|
||||
|
||||
return info
|
||||
|
||||
def format_user_display_name(self, user: User) -> str:
|
||||
"""
|
||||
Форматирование отображаемого имени пользователя для суперпользователей
|
||||
|
||||
Args:
|
||||
user: Объект пользователя
|
||||
|
||||
Returns:
|
||||
Строка в формате: {@username} {first_name} {last_name}
|
||||
"""
|
||||
parts = []
|
||||
|
||||
# Добавляем username если есть
|
||||
if user.username:
|
||||
parts.append(f"@{user.username}")
|
||||
|
||||
# Добавляем имя
|
||||
if user.first_name:
|
||||
parts.append(user.first_name)
|
||||
|
||||
# Добавляем фамилию если есть
|
||||
if user.last_name:
|
||||
parts.append(user.last_name)
|
||||
|
||||
return " ".join(parts) if parts else "Неизвестный пользователь"
|
||||
|
||||
def format_question_info(self, question: Question, show_answer: bool = False) -> str:
|
||||
"""
|
||||
Форматирование информации о вопросе
|
||||
|
||||
Args:
|
||||
question: Объект вопроса
|
||||
show_answer: Показывать ли ответ
|
||||
|
||||
Returns:
|
||||
Отформатированная строка с информацией о вопросе
|
||||
"""
|
||||
# Используем user_question_number для отображения, если он есть
|
||||
display_number = question.user_question_number if question.user_question_number is not None else question.id
|
||||
info = f"❓ <b>Вопрос #{display_number}</b>\n\n"
|
||||
|
||||
if question.is_anonymous:
|
||||
info += "👤 <i>Анонимный вопрос</i>\n"
|
||||
else:
|
||||
info += f"👤 От: {question.from_user_id}\n"
|
||||
|
||||
info += f"📝 <b>Вопрос:</b>\n{question.message_text}\n\n"
|
||||
|
||||
if question.created_at:
|
||||
info += f"📅 {question.created_at.strftime('%d.%m.%Y %H:%M')}\n"
|
||||
|
||||
# Статус вопроса
|
||||
status_emoji = {
|
||||
'pending': '⏳',
|
||||
'answered': '✅',
|
||||
'rejected': '❌',
|
||||
'deleted': '🗑️'
|
||||
}
|
||||
|
||||
status_text = {
|
||||
'pending': 'Ожидает ответа',
|
||||
'answered': 'Отвечен',
|
||||
'rejected': 'Отклонен',
|
||||
'deleted': 'Удален'
|
||||
}
|
||||
|
||||
info += f"{status_emoji.get(question.status.value, '❓')} {status_text.get(question.status.value, 'Неизвестно')}\n"
|
||||
|
||||
if show_answer and question.answer_text:
|
||||
info += f"\n💬 <b>Ответ:</b>\n{question.answer_text}\n"
|
||||
|
||||
if question.answered_at:
|
||||
info += f"\n📅 Ответ дан: {question.answered_at.strftime('%d.%m.%Y %H:%M')}"
|
||||
|
||||
return info
|
||||
|
||||
def format_questions_list(self, questions: list, show_answers: bool = False) -> str:
|
||||
"""
|
||||
Форматирование списка вопросов
|
||||
|
||||
Args:
|
||||
questions: Список вопросов
|
||||
show_answers: Показывать ли ответы
|
||||
|
||||
Returns:
|
||||
Отформатированная строка со списком вопросов
|
||||
"""
|
||||
if not questions:
|
||||
return "📭 Вопросов пока нет"
|
||||
|
||||
info = f"📋 <b>Список вопросов ({len(questions)}):</b>\n\n"
|
||||
|
||||
for i, question in enumerate(questions, 1):
|
||||
info += f"{i}. {self.format_question_info(question, show_answers)}\n"
|
||||
if i < len(questions):
|
||||
info += "─" * SEPARATOR_LENGTH + "\n\n"
|
||||
|
||||
return info
|
||||
|
||||
def format_stats(self, stats: dict) -> str:
|
||||
"""
|
||||
Форматирование статистики
|
||||
|
||||
Args:
|
||||
stats: Словарь со статистикой
|
||||
|
||||
Returns:
|
||||
Отформатированная строка со статистикой
|
||||
"""
|
||||
info = "📊 <b>Статистика бота:</b>\n\n"
|
||||
|
||||
# Статистика пользователей
|
||||
if 'total_users' in stats:
|
||||
info += "👥 <b>Пользователи:</b>\n"
|
||||
info += f"• Всего: {stats.get('total_users', 0)}\n"
|
||||
info += f"• Активных за неделю: {stats.get('active_week', 0)}\n"
|
||||
info += f"• Активных сегодня: {stats.get('active_today', 0)}\n\n"
|
||||
|
||||
# Статистика вопросов
|
||||
if 'total_questions' in stats:
|
||||
info += "❓ <b>Вопросы:</b>\n"
|
||||
info += f"• Всего: {stats.get('total_questions', 0)}\n"
|
||||
info += f"• Ожидают ответа: {stats.get('pending_questions', 0)}\n"
|
||||
info += f"• Отвечено: {stats.get('answered_questions', 0)}\n"
|
||||
info += f"• За сегодня: {stats.get('questions_today', 0)}\n"
|
||||
info += f"• За неделю: {stats.get('questions_week', 0)}\n\n"
|
||||
|
||||
# Дополнительная статистика
|
||||
if 'total_questions_received' in stats:
|
||||
info += "📈 <b>Активность:</b>\n"
|
||||
info += f"• Получено вопросов: {stats.get('total_questions_received', 0)}\n"
|
||||
info += f"• Отвечено вопросов: {stats.get('total_questions_answered', 0)}\n"
|
||||
|
||||
return info
|
||||
|
||||
def escape_html(self, text: str) -> str:
|
||||
"""
|
||||
Экранирование HTML символов
|
||||
|
||||
Args:
|
||||
text: Исходный текст
|
||||
|
||||
Returns:
|
||||
Экранированный текст
|
||||
"""
|
||||
return (text
|
||||
.replace('&', '&')
|
||||
.replace('<', '<')
|
||||
.replace('>', '>')
|
||||
.replace('"', '"')
|
||||
.replace("'", '''))
|
||||
|
||||
|
||||
def is_valid_question_text(self, text: str, max_length: int = 1000) -> Tuple[bool, str]:
|
||||
"""
|
||||
Проверка валидности текста вопроса
|
||||
|
||||
Args:
|
||||
text: Текст вопроса
|
||||
max_length: Максимальная длина
|
||||
|
||||
Returns:
|
||||
Кортеж (валидность, сообщение об ошибке)
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return False, "Вопрос не может быть пустым"
|
||||
|
||||
if len(text.strip()) < MIN_QUESTION_LENGTH:
|
||||
return False, f"Вопрос должен содержать минимум {MIN_QUESTION_LENGTH} символов"
|
||||
|
||||
if len(text) > max_length:
|
||||
return False, f"Вопрос слишком длинный (максимум {max_length} символов)"
|
||||
|
||||
return True, ""
|
||||
|
||||
def is_valid_answer_text(self, text: str, max_length: int = 2000) -> Tuple[bool, str]:
|
||||
"""
|
||||
Проверка валидности текста ответа
|
||||
|
||||
Args:
|
||||
text: Текст ответа
|
||||
max_length: Максимальная длина
|
||||
|
||||
Returns:
|
||||
Кортеж (валидность, сообщение об ошибке)
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return False, "Ответ не может быть пустым"
|
||||
|
||||
if len(text.strip()) < MIN_ANSWER_LENGTH:
|
||||
return False, f"Ответ должен содержать минимум {MIN_ANSWER_LENGTH} символов"
|
||||
|
||||
if len(text) > max_length:
|
||||
return False, f"Ответ слишком длинный (максимум {max_length} символов)"
|
||||
|
||||
return True, ""
|
||||
|
||||
async def send_answer_to_author(self, bot, question: Question, answer_text: str):
|
||||
"""
|
||||
Отправляет ответ автору вопроса
|
||||
|
||||
Args:
|
||||
bot: Экземпляр бота
|
||||
question: Объект вопроса
|
||||
answer_text: Текст ответа
|
||||
"""
|
||||
try:
|
||||
logger.info(f"send_answer_to_author вызвана для вопроса {question.id}, from_user_id: {question.from_user_id}")
|
||||
|
||||
# Проверяем, есть ли ID автора (для анонимных вопросов может быть None)
|
||||
if not question.from_user_id:
|
||||
logger.warning(f"Нельзя отправить ответ автору вопроса {question.id}: from_user_id = None (анонимный вопрос)")
|
||||
return
|
||||
|
||||
# Формируем сообщение для автора
|
||||
message_text = f"💬 <b>Получен ответ на ваш вопрос!</b>\n\n"
|
||||
message_text += f"❓ <b>Ваш вопрос:</b>\n{question.message_text}\n\n"
|
||||
message_text += f"✅ <b>Ответ:</b>\n{answer_text}\n\n"
|
||||
|
||||
# Обрабатываем дату ответа (асинхронно)
|
||||
if question.answered_at:
|
||||
if isinstance(question.answered_at, str):
|
||||
# Если дата пришла как строка, конвертируем в datetime
|
||||
try:
|
||||
# Используем asyncio для неблокирующего парсинга
|
||||
loop = asyncio.get_event_loop()
|
||||
answered_at = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: datetime.fromisoformat(question.answered_at.replace('Z', '+00:00'))
|
||||
)
|
||||
date_str = answered_at.strftime('%d.%m.%Y %H:%M')
|
||||
except:
|
||||
date_str = str(question.answered_at)
|
||||
else:
|
||||
date_str = question.answered_at.strftime('%d.%m.%Y %H:%M')
|
||||
else:
|
||||
date_str = "Неизвестно"
|
||||
|
||||
message_text += f"📅 <b>Дата ответа:</b> {date_str}"
|
||||
|
||||
# Отправляем сообщение автору
|
||||
logger.info(f"Попытка отправить сообщение пользователю {question.from_user_id}")
|
||||
await bot.send_message(
|
||||
chat_id=question.from_user_id,
|
||||
text=message_text,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
logger.info(f"✅ Ответ успешно отправлен автору вопроса {question.id} (пользователь {question.from_user_id})")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке ответа автору вопроса {question.id}: {e}")
|
||||
# Не поднимаем исключение, чтобы не прерывать основной процесс
|
||||
|
||||
|
||||
# Функции для обратной совместимости
|
||||
def generate_referral_link(bot_username: str, user_id: int) -> str:
|
||||
"""Генерация уникальной реферальной ссылки для пользователя"""
|
||||
return f"https://t.me/{bot_username}?start=ref_{user_id}"
|
||||
|
||||
|
||||
def generate_anonymous_id() -> str:
|
||||
"""Генерация анонимного ID для отправителя вопроса"""
|
||||
return secrets.token_hex(ANONYMOUS_TOKEN_LENGTH)
|
||||
|
||||
|
||||
def format_user_info(user: User, show_stats: bool = False) -> str:
|
||||
"""Форматирование информации о пользователе"""
|
||||
utils = UtilsService(None) # Временное решение для обратной совместимости
|
||||
return utils.format_user_info(user, show_stats)
|
||||
|
||||
|
||||
def format_user_display_name(user: User) -> str:
|
||||
"""Форматирование отображаемого имени пользователя для суперпользователей"""
|
||||
utils = UtilsService(None) # Временное решение для обратной совместимости
|
||||
return utils.format_user_display_name(user)
|
||||
|
||||
|
||||
def format_question_info(question: Question, show_answer: bool = False) -> str:
|
||||
"""Форматирование информации о вопросе"""
|
||||
utils = UtilsService(None) # Временное решение для обратной совместимости
|
||||
return utils.format_question_info(question, show_answer)
|
||||
|
||||
|
||||
|
||||
|
||||
def format_stats(stats: dict) -> str:
|
||||
"""Форматирование статистики"""
|
||||
utils = UtilsService(None) # Временное решение для обратной совместимости
|
||||
return utils.format_stats(stats)
|
||||
|
||||
|
||||
def escape_html(text: str) -> str:
|
||||
"""Экранирование HTML символов"""
|
||||
return (text
|
||||
.replace('&', '&')
|
||||
.replace('<', '<')
|
||||
.replace('>', '>')
|
||||
.replace('"', '"')
|
||||
.replace("'", '''))
|
||||
|
||||
|
||||
|
||||
|
||||
def is_valid_question_text(text: str, max_length: int = 1000) -> Tuple[bool, str]:
|
||||
"""Проверка валидности текста вопроса"""
|
||||
utils = UtilsService(None) # Временное решение для обратной совместимости
|
||||
return utils.is_valid_question_text(text, max_length)
|
||||
|
||||
|
||||
def is_valid_answer_text(text: str, max_length: int = 2000) -> Tuple[bool, str]:
|
||||
"""Проверка валидности текста ответа"""
|
||||
utils = UtilsService(None) # Временное решение для обратной совместимости
|
||||
return utils.is_valid_answer_text(text, max_length)
|
||||
|
||||
|
||||
async def send_answer_to_author(bot, question: Question, answer_text: str):
|
||||
"""Отправляет ответ автору вопроса"""
|
||||
utils = UtilsService(None) # Временное решение для обратной совместимости
|
||||
await utils.send_answer_to_author(bot, question, answer_text)
|
||||
6
services/validation/__init__.py
Normal file
6
services/validation/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Модуль валидации входных данных
|
||||
"""
|
||||
from .input_validator import InputValidator, ValidationResult
|
||||
|
||||
__all__ = ['InputValidator', 'ValidationResult']
|
||||
359
services/validation/input_validator.py
Normal file
359
services/validation/input_validator.py
Normal file
@@ -0,0 +1,359 @@
|
||||
"""
|
||||
Централизованный валидатор входных данных для AnonBot
|
||||
"""
|
||||
import re
|
||||
import html
|
||||
from typing import Optional, Tuple, List
|
||||
from dataclasses import dataclass
|
||||
|
||||
from services.infrastructure.logger import get_logger
|
||||
from config.constants import MIN_QUESTION_LENGTH, MIN_ANSWER_LENGTH
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
"""Результат валидации"""
|
||||
is_valid: bool
|
||||
error_message: str = ""
|
||||
sanitized_value: Optional[str] = None
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return self.is_valid
|
||||
|
||||
|
||||
class InputValidator:
|
||||
"""Централизованный валидатор входных данных"""
|
||||
|
||||
# Константы для валидации
|
||||
MIN_TELEGRAM_ID = 1
|
||||
MAX_TELEGRAM_ID = 2**63 - 1
|
||||
MIN_USERNAME_LENGTH = 1
|
||||
MAX_USERNAME_LENGTH = 32
|
||||
MAX_CALLBACK_DATA_LENGTH = 64
|
||||
MAX_TEXT_LENGTH = 4000 # Telegram limit
|
||||
MAX_HTML_ENTITIES = 100 # Защита от HTML-спама
|
||||
|
||||
# Регулярные выражения
|
||||
USERNAME_PATTERN = re.compile(r'^[a-zA-Z0-9_]{1,32}$')
|
||||
DEEP_LINK_PATTERN = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
|
||||
CALLBACK_DATA_PATTERN = re.compile(r'^[a-zA-Z0-9_:-]{1,64}$')
|
||||
|
||||
# Опасные HTML теги и атрибуты
|
||||
DANGEROUS_TAGS = {'script', 'iframe', 'object', 'embed', 'form', 'input', 'button'}
|
||||
DANGEROUS_ATTRIBUTES = {'onclick', 'onload', 'onerror', 'onmouseover', 'href', 'src'}
|
||||
|
||||
def __init__(self):
|
||||
logger.info("🔍 InputValidator инициализирован")
|
||||
|
||||
def validate_telegram_id(self, user_id: int) -> ValidationResult:
|
||||
"""
|
||||
Валидация Telegram ID пользователя
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя Telegram
|
||||
|
||||
Returns:
|
||||
ValidationResult с результатом валидации
|
||||
"""
|
||||
try:
|
||||
if not isinstance(user_id, int):
|
||||
return ValidationResult(
|
||||
False,
|
||||
f"Telegram ID должен быть числом, получен: {type(user_id).__name__}"
|
||||
)
|
||||
|
||||
if user_id < self.MIN_TELEGRAM_ID:
|
||||
return ValidationResult(
|
||||
False,
|
||||
f"Telegram ID должен быть больше {self.MIN_TELEGRAM_ID}"
|
||||
)
|
||||
|
||||
if user_id > self.MAX_TELEGRAM_ID:
|
||||
return ValidationResult(
|
||||
False,
|
||||
f"Telegram ID должен быть меньше {self.MAX_TELEGRAM_ID}"
|
||||
)
|
||||
|
||||
logger.debug(f"✅ Telegram ID {user_id} прошел валидацию")
|
||||
return ValidationResult(True, sanitized_value=str(user_id))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка валидации Telegram ID {user_id}: {e}")
|
||||
return ValidationResult(False, f"Ошибка валидации Telegram ID: {str(e)}")
|
||||
|
||||
def validate_username(self, username: str) -> ValidationResult:
|
||||
"""
|
||||
Валидация username пользователя
|
||||
|
||||
Args:
|
||||
username: Username пользователя (без @)
|
||||
|
||||
Returns:
|
||||
ValidationResult с результатом валидации
|
||||
"""
|
||||
try:
|
||||
if not username:
|
||||
return ValidationResult(True, sanitized_value="") # Username может быть пустым
|
||||
|
||||
# Убираем @ если есть
|
||||
username = username.lstrip('@')
|
||||
|
||||
if len(username) < self.MIN_USERNAME_LENGTH:
|
||||
return ValidationResult(
|
||||
False,
|
||||
f"Username должен содержать минимум {self.MIN_USERNAME_LENGTH} символ"
|
||||
)
|
||||
|
||||
if len(username) > self.MAX_USERNAME_LENGTH:
|
||||
return ValidationResult(
|
||||
False,
|
||||
f"Username не должен превышать {self.MAX_USERNAME_LENGTH} символов"
|
||||
)
|
||||
|
||||
if not self.USERNAME_PATTERN.match(username):
|
||||
return ValidationResult(
|
||||
False,
|
||||
"Username может содержать только латинские буквы, цифры и подчеркивания"
|
||||
)
|
||||
|
||||
logger.debug(f"✅ Username '{username}' прошел валидацию")
|
||||
return ValidationResult(True, sanitized_value=username)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка валидации username '{username}': {e}")
|
||||
return ValidationResult(False, f"Ошибка валидации username: {str(e)}")
|
||||
|
||||
def validate_text_content(
|
||||
self,
|
||||
text: str,
|
||||
min_length: int = 1,
|
||||
max_length: int = MAX_TEXT_LENGTH,
|
||||
content_type: str = "текст"
|
||||
) -> ValidationResult:
|
||||
"""
|
||||
Валидация текстового контента
|
||||
|
||||
Args:
|
||||
text: Текст для валидации
|
||||
min_length: Минимальная длина
|
||||
max_length: Максимальная длина
|
||||
content_type: Тип контента для сообщений об ошибках
|
||||
|
||||
Returns:
|
||||
ValidationResult с результатом валидации
|
||||
"""
|
||||
try:
|
||||
if not text:
|
||||
return ValidationResult(
|
||||
False,
|
||||
f"{content_type.capitalize()} не может быть пустым"
|
||||
)
|
||||
|
||||
# Проверяем длину до санитизации
|
||||
if len(text) > max_length:
|
||||
return ValidationResult(
|
||||
False,
|
||||
f"{content_type.capitalize()} слишком длинный (максимум {max_length} символов)"
|
||||
)
|
||||
|
||||
# Санитизируем HTML
|
||||
sanitized_text = self.sanitize_html(text)
|
||||
|
||||
# Проверяем длину после санитизации
|
||||
if len(sanitized_text.strip()) < min_length:
|
||||
return ValidationResult(
|
||||
False,
|
||||
f"{content_type.capitalize()} должен содержать минимум {min_length} символов"
|
||||
)
|
||||
|
||||
# Проверяем на спам (повторяющиеся символы)
|
||||
if self._is_spam_text(sanitized_text):
|
||||
return ValidationResult(
|
||||
False,
|
||||
f"{content_type.capitalize()} содержит слишком много повторяющихся символов"
|
||||
)
|
||||
|
||||
logger.debug(f"✅ {content_type} прошел валидацию (длина: {len(sanitized_text)})")
|
||||
return ValidationResult(True, sanitized_value=sanitized_text)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка валидации {content_type}: {e}")
|
||||
return ValidationResult(False, f"Ошибка валидации {content_type}: {str(e)}")
|
||||
|
||||
def validate_question_text(self, text: str, max_length: int = 1000) -> ValidationResult:
|
||||
"""
|
||||
Валидация текста вопроса
|
||||
|
||||
Args:
|
||||
text: Текст вопроса
|
||||
max_length: Максимальная длина вопроса
|
||||
|
||||
Returns:
|
||||
ValidationResult с результатом валидации
|
||||
"""
|
||||
return self.validate_text_content(
|
||||
text,
|
||||
min_length=MIN_QUESTION_LENGTH,
|
||||
max_length=max_length,
|
||||
content_type="вопрос"
|
||||
)
|
||||
|
||||
def validate_answer_text(self, text: str, max_length: int = 2000) -> ValidationResult:
|
||||
"""
|
||||
Валидация текста ответа
|
||||
|
||||
Args:
|
||||
text: Текст ответа
|
||||
max_length: Максимальная длина ответа
|
||||
|
||||
Returns:
|
||||
ValidationResult с результатом валидации
|
||||
"""
|
||||
return self.validate_text_content(
|
||||
text,
|
||||
min_length=MIN_ANSWER_LENGTH,
|
||||
max_length=max_length,
|
||||
content_type="ответ"
|
||||
)
|
||||
|
||||
def validate_callback_data(self, data: str) -> ValidationResult:
|
||||
"""
|
||||
Валидация callback data
|
||||
|
||||
Args:
|
||||
data: Callback data для валидации
|
||||
|
||||
Returns:
|
||||
ValidationResult с результатом валидации
|
||||
"""
|
||||
try:
|
||||
if not data:
|
||||
return ValidationResult(False, "Callback data не может быть пустым")
|
||||
|
||||
if len(data) > self.MAX_CALLBACK_DATA_LENGTH:
|
||||
return ValidationResult(
|
||||
False,
|
||||
f"Callback data не должен превышать {self.MAX_CALLBACK_DATA_LENGTH} символов"
|
||||
)
|
||||
|
||||
if not self.CALLBACK_DATA_PATTERN.match(data):
|
||||
return ValidationResult(
|
||||
False,
|
||||
"Callback data содержит недопустимые символы"
|
||||
)
|
||||
|
||||
logger.debug(f"✅ Callback data '{data}' прошел валидацию")
|
||||
return ValidationResult(True, sanitized_value=data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка валидации callback data '{data}': {e}")
|
||||
return ValidationResult(False, f"Ошибка валидации callback data: {str(e)}")
|
||||
|
||||
def validate_deep_link(self, link: str) -> ValidationResult:
|
||||
"""
|
||||
Валидация deep link
|
||||
|
||||
Args:
|
||||
link: Deep link для валидации
|
||||
|
||||
Returns:
|
||||
ValidationResult с результатом валидации
|
||||
"""
|
||||
try:
|
||||
if not link:
|
||||
return ValidationResult(False, "Deep link не может быть пустым")
|
||||
|
||||
if len(link) > 64: # Telegram deep link limit
|
||||
return ValidationResult(
|
||||
False,
|
||||
"Deep link не должен превышать 64 символа"
|
||||
)
|
||||
|
||||
if not self.DEEP_LINK_PATTERN.match(link):
|
||||
return ValidationResult(
|
||||
False,
|
||||
"Deep link содержит недопустимые символы"
|
||||
)
|
||||
|
||||
logger.debug(f"✅ Deep link '{link}' прошел валидацию")
|
||||
return ValidationResult(True, sanitized_value=link)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка валидации deep link '{link}': {e}")
|
||||
return ValidationResult(False, f"Ошибка валидации deep link: {str(e)}")
|
||||
|
||||
|
||||
def sanitize_html(self, text: str) -> str:
|
||||
"""
|
||||
Санитизация HTML в тексте
|
||||
|
||||
Args:
|
||||
text: Текст для санитизации
|
||||
|
||||
Returns:
|
||||
Санитизированный текст
|
||||
"""
|
||||
try:
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
# Экранируем HTML сущности
|
||||
sanitized = html.escape(text, quote=True)
|
||||
|
||||
# Проверяем количество HTML сущностей (защита от спама)
|
||||
html_entities_count = len(re.findall(r'&[a-zA-Z0-9#]+;', sanitized))
|
||||
if html_entities_count > self.MAX_HTML_ENTITIES:
|
||||
logger.warning(f"⚠️ Обнаружено много HTML сущностей в тексте: {html_entities_count}")
|
||||
# Убираем лишние HTML сущности
|
||||
sanitized = re.sub(r'&[a-zA-Z0-9#]+;', '', sanitized)
|
||||
|
||||
return sanitized.strip()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка санитизации HTML: {e}")
|
||||
return text.strip() # Возвращаем исходный текст в случае ошибки
|
||||
|
||||
def _is_spam_text(self, text: str) -> bool:
|
||||
"""
|
||||
Проверка текста на спам (повторяющиеся символы)
|
||||
|
||||
Args:
|
||||
text: Текст для проверки
|
||||
|
||||
Returns:
|
||||
True если текст похож на спам
|
||||
"""
|
||||
try:
|
||||
if len(text) < 10:
|
||||
return False
|
||||
|
||||
# Проверяем повторяющиеся символы
|
||||
char_counts = {}
|
||||
for char in text:
|
||||
char_counts[char] = char_counts.get(char, 0) + 1
|
||||
|
||||
# Если какой-то символ повторяется более 50% от длины текста
|
||||
max_count = max(char_counts.values())
|
||||
if max_count > len(text) * 0.5:
|
||||
return True
|
||||
|
||||
# Проверяем повторяющиеся слова
|
||||
words = text.split()
|
||||
if len(words) > 3:
|
||||
word_counts = {}
|
||||
for word in words:
|
||||
word_counts[word] = word_counts.get(word, 0) + 1
|
||||
|
||||
max_word_count = max(word_counts.values())
|
||||
if max_word_count > len(words) * 0.6:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка проверки на спам: {e}")
|
||||
return False
|
||||
|
||||
|
||||
Reference in New Issue
Block a user