feat: доработки функционала бота

1. Подтверждение запуска розыгрыша:
   - Показ диалога с информацией об участниках и призах
   - Кнопки 'Да, провести' и 'Отмена'
   - Индикатор загрузки при проведении

2. Удаление сообщений администратором:
   - Команда /delete для удаления сообщений бота (ответ на сообщение)
   - Callback кнопка delete_message
   - Новый роутер message_admin_router

3. Определение владельцев счетов:
   - Извлечение номера клубной карты (последние 4 цифры)
   - Поиск владельца по club_card_number
   - Отображение владельца в списке обнаруженных счетов
   - Метод UserService.get_user_by_club_card()

4. Тестирование производительности:
   - Скрипт generate_test_accounts.py
   - Генерация файлов с 100, 500, 1000, 2000, 5000 счетов
   - Готовые тестовые файлы для проверки

5. Улучшения парсинга:
   - Обработка текста из кабинета с мусорными данными
   - Построчный парсинг с разбором по пробелам
   - Поддержка формата 'Viposnova  16-11-2025 22:19:36  17-24-66-42-38-31-53  0.00    2918'

6. Исправления багов:
   - AttributeError при отображении победителей без user_id
   - Безопасная обработка winner.user == None
This commit is contained in:
2025-11-17 10:42:41 +09:00
parent 65b550f8c8
commit 79eb66cf51
13 changed files with 8951 additions and 34 deletions

View File

@@ -147,6 +147,23 @@ class UserService:
formatted_number = format_account_number(account_number)
if not formatted_number:
return None
@staticmethod
async def get_user_by_club_card(session: AsyncSession, club_card_number: str) -> Optional[User]:
"""
Получить пользователя по номеру клубной карты
Args:
session: Сессия БД
club_card_number: Номер клубной карты (4 цифры)
Returns:
User или None если не найден
"""
result = await session.execute(
select(User).where(User.club_card_number == club_card_number)
)
return result.scalar_one_or_none()
result = await session.execute(
select(User).where(User.account_number == formatted_number)

View File

@@ -42,6 +42,7 @@ async def detect_account_input(message: Message, state: FSMContext):
"""
Обнаружение ввода счетов в сообщении
Активируется только для администраторов
Извлекает номер клубной карты и определяет владельца
"""
if not is_admin(message.from_user.id):
return
@@ -52,16 +53,82 @@ async def detect_account_input(message: Message, state: FSMContext):
if not accounts:
return # Счета не обнаружены, пропускаем
# Сохраняем счета в состоянии
await state.update_data(detected_accounts=accounts)
# Извлекаем номера клубных карт из последних 4 цифр каждого счета
from ..core.services import UserService
from ..core.registration_services import AccountService
# Формируем сообщение
accounts_text = "\n".join([f"{acc}" for acc in accounts])
async with async_session_maker() as session:
accounts_with_owners = []
for account in accounts:
# Извлекаем только номер счета (без карты если есть)
parts = account.split()
account_number = parts[-1] if parts else account
# Извлекаем последние 4 цифры (номер клубной карты)
digits_only = account_number.replace('-', '').replace(' ', '')
if len(digits_only) >= 4:
club_card = digits_only[-4:] # Последние 4 цифры
# Ищем пользователя по номеру клубной карты
user = await UserService.get_user_by_club_card(session, club_card)
if user:
owner_info = f"@{user.username}" if user.username else user.first_name
accounts_with_owners.append({
'account': account,
'club_card': club_card,
'owner': owner_info,
'user_id': user.id
})
else:
accounts_with_owners.append({
'account': account,
'club_card': club_card,
'owner': None,
'user_id': None
})
else:
# Счет неверного формата
accounts_with_owners.append({
'account': account,
'club_card': None,
'owner': None,
'user_id': None
})
# Сохраняем счета в состоянии
await state.update_data(
detected_accounts=accounts,
accounts_with_owners=accounts_with_owners
)
# Формируем сообщение с владельцами
accounts_text_parts = []
for item in accounts_with_owners:
account = item['account']
club_card = item['club_card']
owner = item['owner']
if owner:
line = f"{account}{owner} (карта: {club_card})"
elif club_card:
line = f"{account} (карта: {club_card}, владелец не найден)"
else:
line = f"{account} (неверный формат)"
accounts_text_parts.append(line)
accounts_text = "\n".join(accounts_text_parts)
count = len(accounts)
# Подсчёт найденных владельцев
owners_found = sum(1 for item in accounts_with_owners if item['owner'])
text = (
f"🔍 <b>Обнаружен ввод счет{'а' if count == 1 else 'ов'}</b>\n\n"
f"Найдено: <b>{count}</b>\n\n"
f"Найдено: <b>{count}</b>\n"
f"Владельцев определено: <b>{owners_found}</b>\n\n"
f"{accounts_text}\n\n"
f"Выберите действие:"
)

View File

@@ -409,7 +409,14 @@ async def show_lottery_detail(callback: CallbackQuery):
text += f"🏆 Результаты:\n"
for winner in winners:
manual_mark = " 👑" if winner.is_manual else ""
username = f"@{winner.user.username}" if winner.user.username else winner.user.first_name
# Безопасная обработка победителя - может быть без user_id
if winner.user:
username = f"@{winner.user.username}" if winner.user.username else winner.user.first_name
else:
# Победитель по номеру счета без связанного пользователя
username = f"Счет: {winner.account_number}"
text += f"{winner.place}. {username}{manual_mark}\n"
buttons = []
@@ -2586,8 +2593,8 @@ async def choose_lottery_for_draw(callback: CallbackQuery):
@admin_router.callback_query(F.data.startswith("admin_conduct_"))
async def conduct_lottery_draw(callback: CallbackQuery):
"""Проведение розыгрыша"""
async def conduct_lottery_draw_confirm(callback: CallbackQuery):
"""Запрос подтверждения проведения розыгрыша"""
if not is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
@@ -2611,6 +2618,61 @@ async def conduct_lottery_draw(callback: CallbackQuery):
await callback.answer("Нет участников для розыгрыша", show_alert=True)
return
# Подсчёт призов
prizes_count = len(lottery.prizes) if lottery.prizes else 0
# Формируем сообщение с подтверждением
text = f"⚠️ <b>Подтверждение проведения розыгрыша</b>\n\n"
text += f"🎲 <b>Розыгрыш:</b> {lottery.title}\n"
text += f"👥 <b>Участников:</b> {participants_count}\n"
text += f"🏆 <b>Призов:</b> {prizes_count}\n\n"
if lottery.prizes:
text += "<b>Призы:</b>\n"
for i, prize in enumerate(lottery.prizes, 1):
text += f"{i}. {prize}\n"
text += "\n"
text += "❗️ <b>Внимание:</b> После проведения розыгрыша результаты нельзя будет изменить!\n\n"
text += "Продолжить?"
buttons = [
[InlineKeyboardButton(text="✅ Да, провести розыгрыш", callback_data=f"admin_conduct_confirmed_{lottery_id}")],
[InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_lottery_{lottery_id}")]
]
await callback.message.edit_text(text, reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
@admin_router.callback_query(F.data.startswith("admin_conduct_confirmed_"))
async def conduct_lottery_draw(callback: CallbackQuery):
"""Проведение розыгрыша после подтверждения"""
if not is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
lottery_id = int(callback.data.split("_")[-1])
async with async_session_maker() as session:
lottery = await LotteryService.get_lottery(session, lottery_id)
if not lottery:
await callback.answer("Розыгрыш не найден", show_alert=True)
return
if lottery.is_completed:
await callback.answer("Розыгрыш уже завершён", show_alert=True)
return
participants_count = await ParticipationService.get_participants_count(session, lottery_id)
if participants_count == 0:
await callback.answer("Нет участников для розыгрыша", show_alert=True)
return
# Показываем индикатор загрузки
await callback.answer("⏳ Проводится розыгрыш...", show_alert=True)
# Проводим розыгрыш через сервис
winners_dict = await LotteryService.conduct_draw(session, lottery_id)

View File

@@ -0,0 +1,65 @@
"""
Хэндлеры для управления сообщениями администратором
"""
import logging
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery
from aiogram.filters import Command
from ..core.config import ADMIN_IDS
logger = logging.getLogger(__name__)
message_admin_router = Router(name="message_admin")
def is_admin(user_id: int) -> bool:
"""Проверка, является ли пользователь администратором"""
return user_id in ADMIN_IDS
@message_admin_router.message(Command("delete"))
async def delete_replied_message(message: Message):
"""
Удаление сообщения по команде /delete
Работает только если команда является ответом на сообщение бота
"""
if not is_admin(message.from_user.id):
await message.answer("❌ Недостаточно прав")
return
if not message.reply_to_message:
await message.answer("⚠️ Ответьте на сообщение бота командой /delete чтобы удалить его")
return
if message.reply_to_message.from_user.id != message.bot.id:
await message.answer("⚠️ Можно удалять только сообщения бота")
return
try:
# Удаляем сообщение бота
await message.reply_to_message.delete()
# Удаляем команду
await message.delete()
logger.info(f"Администратор {message.from_user.id} удалил сообщение {message.reply_to_message.message_id}")
except Exception as e:
logger.error(f"Ошибка при удалении сообщения: {e}")
await message.answer(f"Не удалось удалить сообщение: {str(e)}")
@message_admin_router.callback_query(F.data == "delete_message")
async def delete_message_callback(callback: CallbackQuery):
"""
Удаление сообщения по нажатию кнопки
"""
if not is_admin(callback.from_user.id):
await callback.answer("❌ Недостаточно прав", show_alert=True)
return
try:
await callback.message.delete()
await callback.answer("✅ Сообщение удалено")
logger.info(f"Администратор {callback.from_user.id} удалил сообщение {callback.message.message_id} кнопкой")
except Exception as e:
logger.error(f"Ошибка при удалении сообщения: {e}")
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)

View File

@@ -103,6 +103,9 @@ def parse_accounts_from_message(text: str) -> List[str]:
Поддерживает формат: "КАРТА СЧЕТ" (например "2521 11-22-33-44-55-66-77")
или просто "СЧЕТ" (например "11-22-33-44-55-66-77")
Также обрабатывает текст из кабинета с мусором:
"Viposnova 16-11-2025 22:19:36 17-24-66-42-38-31-53 0.00 2918"
Args:
text: Текст сообщения
@@ -114,33 +117,58 @@ def parse_accounts_from_message(text: str) -> List[str]:
accounts = []
# Паттерн 1: номер карты (4 цифры) + пробел + счет (7 пар цифр)
pattern_with_card = r'(\d{4})\s+(\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2})'
# Обработка построчно - для текста из кабинета с мусорными данными
lines = text.strip().split('\n')
# Находим все совпадения с картой и удаляем их из текста
text_copy = text
for match in re.finditer(pattern_with_card, text):
card = match.group(1)
account = match.group(2)
formatted = format_account_number(account)
if formatted:
full_account = f"{card} {formatted}"
if full_account not in accounts:
accounts.append(full_account)
# Удаляем это совпадение из копии текста, чтобы не найти повторно
text_copy = text_copy.replace(match.group(0), ' ' * len(match.group(0)))
for line in lines:
# Разбиваем строку по пробелам
parts = line.strip().split()
# Ищем в каждой части паттерны счетов
for i, part in enumerate(parts):
# Проверяем, является ли эта часть счетом (7 пар цифр через дефис)
if re.match(r'^\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}$', part):
formatted = format_account_number(part)
if formatted and formatted not in accounts:
# Проверяем предыдущую часть на номер карты (4 цифры)
if i > 0 and re.match(r'^\d{4}$', parts[i-1]):
card = parts[i-1]
full_account = f"{card} {formatted}"
if full_account not in accounts:
accounts.append(full_account)
else:
# Добавляем только счет
accounts.append(formatted)
# Паттерн 2: только счет (7 пар цифр) в оставшемся тексте
pattern_only_account = r'\b\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}\b'
matches_only = re.findall(pattern_only_account, text_copy)
for match in matches_only:
formatted = format_account_number(match)
if formatted and formatted not in accounts:
# Дополнительная проверка - этот счет не должен быть частью уже найденных "карта + счет"
is_duplicate = any(formatted in acc for acc in accounts)
if not is_duplicate:
accounts.append(formatted)
# Если построчная обработка ничего не нашла, используем старый метод
if not accounts:
# Паттерн 1: номер карты (4 цифры) + пробел + счет (7 пар цифр)
pattern_with_card = r'(\d{4})\s+(\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2})'
# Находим все совпадения с картой и удаляем их из текста
text_copy = text
for match in re.finditer(pattern_with_card, text):
card = match.group(1)
account = match.group(2)
formatted = format_account_number(account)
if formatted:
full_account = f"{card} {formatted}"
if full_account not in accounts:
accounts.append(full_account)
# Удаляем это совпадение из копии текста, чтобы не найти повторно
text_copy = text_copy.replace(match.group(0), ' ' * len(match.group(0)))
# Паттерн 2: только счет (7 пар цифр) в оставшемся тексте
pattern_only_account = r'\b\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}[-\s]?\d{2}\b'
matches_only = re.findall(pattern_only_account, text_copy)
for match in matches_only:
formatted = format_account_number(match)
if formatted and formatted not in accounts:
# Дополнительная проверка - этот счет не должен быть частью уже найденных "карта + счет"
is_duplicate = any(formatted in acc for acc in accounts)
if not is_duplicate:
accounts.append(formatted)
return accounts