Files
new_lottery_bot/src/handlers/admin_account_handlers.py
Andrew K. Choi dc402270a6
Some checks reported errors
continuous-integration/drone/push Build encountered an error
feat: улучшения массовой обработки счетов
- Добавлено уведомление о задержке при отправке >250 счетов
- Реализовано массовое удаление счетов через /remove_account
- Исправлен flood control с задержкой 500ms между сообщениями
- Callback.answer() перенесён в начало для предотвращения timeout
- Добавлена обработка TelegramRetryAfter с повторной попыткой
2025-11-18 16:36:30 +09:00

812 lines
34 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Админские обработчики для управления счетами и верификации"""
from aiogram import Router, F, Bot
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from sqlalchemy import select, and_
from src.core.database import async_session_maker
from src.core.registration_services import AccountService, WinnerNotificationService, RegistrationService
from src.core.services import UserService, LotteryService, ParticipationService
from src.core.models import User, Winner, Account, Participation
from src.core.config import ADMIN_IDS
from src.core.permissions import admin_only
router = Router()
class AddAccountStates(StatesGroup):
waiting_for_data = State()
choosing_lottery = State()
@router.message(Command("cancel"))
@admin_only
async def cancel_command(message: Message, state: FSMContext):
"""Отменить текущую операцию и сбросить состояние"""
await state.clear()
await message.answer("✅ Состояние сброшено. Все операции отменены.")
@router.message(Command("add_account"))
@admin_only
async def add_account_command(message: Message, state: FSMContext):
"""
Добавить счет пользователю по клубной карте
Формат: /add_account <club_card> <account_number>
Или: /add_account (затем вводить данные построчно)
"""
parts = message.text.split(maxsplit=2)
# Если данные указаны в команде
if len(parts) == 3:
club_card = parts[1]
account_number = parts[2]
await process_single_account(message, club_card, account_number, state)
else:
# Запрашиваем данные
await state.set_state(AddAccountStates.waiting_for_data)
await message.answer(
"💳 **Добавление счетов**\n\n"
"📋 **Формат 1 (однострочный):**\n"
"`карта счет`\n"
"Пример: `2223 11-22-33-44-55-66-77`\n\n"
"📋 **Формат 2 (многострочный из таблицы):**\n"
"Скопируйте столбцы со счетами и картами - система сама распознает\n\n"
"**Для нескольких счетов:**\n"
"`2223 11-22-33-44-55-66-77`\n"
"`2223 88-99-00-11-22-33-44`\n"
"`3334 12-34-56-78-90-12-34`\n\n"
"❌ Отправьте /cancel для отмены",
parse_mode="Markdown"
)
async def process_single_account(message: Message, club_card: str, account_number: str, state: FSMContext):
"""Обработка одного счета"""
try:
async with async_session_maker() as session:
# Создаем счет
account = await AccountService.create_account(
session,
club_card_number=club_card,
account_number=account_number
)
# Получаем владельца
owner = await AccountService.get_account_owner(session, account_number)
# Сохраняем данные счета в state для добавления в розыгрыш
await state.update_data(
accounts=[{
'club_card': club_card,
'account_number': account_number,
'account_id': account.id
}]
)
text = f"✅ Счет успешно добавлен!\n\n"
text += f"🎫 Клубная карта: {club_card}\n"
text += f"💳 Счет: {account_number}\n"
if owner:
text += f"👤 Владелец: {owner.first_name}\n\n"
# Отправляем уведомление владельцу с форматированием
try:
await message.bot.send_message(
owner.telegram_id,
f"К вашему профилю добавлен счет:\n\n"
f"💳 `{account_number}`\n\n"
f"Теперь вы можете участвовать в розыгрышах!",
parse_mode="Markdown"
)
text += "📨 Владельцу отправлено уведомление\n\n"
except Exception as e:
text += f"⚠️ Не удалось отправить уведомление: {str(e)}\n\n"
# Предлагаем добавить в розыгрыш
await show_lottery_selection(message, text, state)
except ValueError as e:
await message.answer(f"❌ Ошибка: {str(e)}")
await state.clear()
except Exception as e:
await message.answer(f"❌ Произошла ошибка: {str(e)}")
await state.clear()
@router.message(AddAccountStates.waiting_for_data)
async def process_accounts_data(message: Message, state: FSMContext):
"""Обработка данных счетов (один или несколько)"""
if message.text.strip().lower() == '/cancel':
await state.clear()
await message.answer("❌ Операция отменена")
return
lines = message.text.strip().split('\n')
# Ограничение: максимум 1000 счетов за раз
MAX_ACCOUNTS = 1000
if len(lines) > MAX_ACCOUNTS:
await message.answer(
f"⚠️ Слишком много счетов!\n\n"
f"Максимум за раз: {MAX_ACCOUNTS}\n"
f"Вы отправили: {len(lines)} строк\n\n"
f"Разделите данные на несколько частей."
)
await state.clear()
return
# Отправляем начальное уведомление
progress_msg = await message.answer(
f"⏳ Обработка {len(lines)} строк...\n"
f"Пожалуйста, подождите..."
)
accounts_data = []
errors = []
BATCH_SIZE = 100 # Обрабатываем по 100 счетов за раз
# Универсальный парсер: поддержка однострочного и многострочного формата
i = 0
while i < len(lines):
line = lines[i].strip()
# Пропускаем пустые строки и строки с названиями/датами
if not line or any(x in line.lower() for x in ['viposnova', '0.00', ':']):
i += 1
continue
# Проверяем, есть ли в строке пробел (однострочный формат: "карта счет")
if ' ' in line:
# Однострочный формат: разделяем по первому пробелу
parts = line.split(maxsplit=1)
if len(parts) == 2:
club_card, account_number = parts
else:
errors.append(f"Строка {i+1}: неверный формат")
i += 1
continue
else:
# Многострочный формат: текущая строка - счет, следующая - карта
account_number = line
i += 1
if i >= len(lines):
errors.append(f"Строка {i}: отсутствует номер карты после счета {account_number}")
break
club_card = lines[i].strip()
# Пропускаем, если следующая строка содержит мусор
if not club_card or any(x in club_card.lower() for x in ['viposnova', '0.00', ':']):
errors.append(f"Строка {i}: некорректный номер карты после счета {account_number}")
i += 1
continue
# Создаем счет
try:
async with async_session_maker() as session:
account = await AccountService.create_account(
session,
club_card_number=club_card,
account_number=account_number
)
owner = await AccountService.get_account_owner(session, account_number)
accounts_data.append({
'club_card': club_card,
'account_number': account_number,
'account_id': account.id,
'owner': owner,
'owner_id': owner.telegram_id if owner else None
})
# Обновляем progress каждые 50 счетов
if len(accounts_data) % 50 == 0:
try:
await progress_msg.edit_text(
f"⏳ Обработано: {len(accounts_data)} / ~{len(lines)}\n"
f"❌ Ошибок: {len(errors)}"
)
except:
pass # Игнорируем ошибки редактирования
except ValueError as e:
errors.append(f"Счет {account_number} (карта {club_card}): {str(e)}")
except Exception as e:
errors.append(f"Счет {account_number}: {str(e)}")
i += 1
# Удаляем progress сообщение
try:
await progress_msg.delete()
except:
pass
# Группируем счета по владельцам и отправляем групповые уведомления
if accounts_data:
from collections import defaultdict
accounts_by_owner = defaultdict(list)
for acc in accounts_data:
if acc['owner_id']:
accounts_by_owner[acc['owner_id']].append(acc['account_number'])
# Отправляем групповые уведомления
for owner_id, account_numbers in accounts_by_owner.items():
try:
if len(account_numbers) == 1:
# Одиночное уведомление
notification_text = (
"К вашему профилю добавлен счет:\n\n"
f"💳 `{account_numbers[0]}`\n\n"
"Теперь вы можете участвовать в розыгрышах!"
)
await message.bot.send_message(
owner_id,
notification_text,
parse_mode="Markdown"
)
elif len(account_numbers) <= 50:
# Групповое уведомление (до 50 счетов)
notification_text = (
f"К вашему профилю добавлено счетов: *{len(account_numbers)}*\n\n"
"💳 *Ваши счета:*\n"
)
for acc_num in account_numbers:
notification_text += f"• `{acc_num}`\n"
notification_text += "\nТеперь вы можете участвовать в розыгрышах!"
await message.bot.send_message(
owner_id,
notification_text,
parse_mode="Markdown"
)
else:
# Много счетов - показываем первые 10 и кнопку
notification_text = (
f"К вашему профилю добавлено счетов: *{len(account_numbers)}*\n\n"
"💳 *Первые 10 счетов:*\n"
)
for acc_num in account_numbers[:10]:
notification_text += f"• `{acc_num}`\n"
notification_text += f"\n_...и ещё {len(account_numbers) - 10} счетов_\n\n"
notification_text += "Теперь вы можете участвовать в розыгрышах!"
# Кнопка для просмотра всех счетов
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text="📋 Просмотреть все счета",
callback_data="view_my_accounts"
)]
])
await message.bot.send_message(
owner_id,
notification_text,
parse_mode="Markdown",
reply_markup=keyboard
)
except Exception as e:
pass # Игнорируем ошибки отправки уведомлений
# Формируем отчет
text = f"📊 **Результаты добавления счетов**\n\n"
if accounts_data:
text += f"✅ **Успешно добавлено: {len(accounts_data)}**\n\n"
for acc in accounts_data:
text += f"{acc['club_card']}{acc['account_number']}\n"
if acc['owner']:
text += f" 👤 {acc['owner'].first_name}\n"
text += "\n"
if errors:
text += f"❌ **Ошибки: {len(errors)}**\n\n"
for error in errors[:5]: # Показываем максимум 5 ошибок
text += f"{error}\n"
if len(errors) > 5:
text += f"\n... и еще {len(errors) - 5} ошибок\n"
if not accounts_data:
await message.answer(text)
await state.clear()
return
# Сохраняем данные и предлагаем добавить в розыгрыш
await state.update_data(accounts=accounts_data)
await show_lottery_selection(message, text, state)
async def show_lottery_selection(message: Message, prev_text: str, state: FSMContext):
"""Показать выбор розыгрыша для добавления счетов"""
async with async_session_maker() as session:
lotteries = await LotteryService.get_active_lotteries(session)
if not lotteries:
await message.answer(
prev_text + " Нет активных розыгрышей для добавления счетов"
)
await state.clear()
return
await state.set_state(AddAccountStates.choosing_lottery)
buttons = []
for lottery in lotteries[:10]: # Максимум 10 розыгрышей
buttons.append([
InlineKeyboardButton(
text=f"🎯 {lottery.title}",
callback_data=f"add_to_lottery_{lottery.id}"
)
])
buttons.append([
InlineKeyboardButton(text="❌ Пропустить", callback_data="skip_lottery_add")
])
await message.answer(
prev_text + " **Добавить счета в розыгрыш?**\n\n"
"Выберите розыгрыш из списка:",
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)
)
@router.callback_query(F.data.startswith("add_to_lottery_"))
async def add_accounts_to_lottery(callback: CallbackQuery, state: FSMContext):
"""Добавить счета в выбранный розыгрыш"""
lottery_id = int(callback.data.split("_")[-1])
data = await state.get_data()
accounts = data.get('accounts', [])
if not accounts:
await callback.answer("❌ Нет данных о счетах", show_alert=True)
await state.clear()
return
success_count = 0
errors = []
async with async_session_maker() as session:
lottery = await LotteryService.get_lottery(session, lottery_id)
if not lottery:
await callback.answer("❌ Розыгрыш не найден", show_alert=True)
await state.clear()
return
for acc in accounts:
try:
# Добавляем участие через account_id
# Проверяем, не участвует ли уже
existing = await session.execute(
select(Participation).where(
and_(
Participation.lottery_id == lottery_id,
Participation.account_id == acc['account_id']
)
)
)
if existing.scalar_one_or_none():
errors.append(f"{acc['account_number']}: уже участвует")
continue
# Создаем участие
participation = Participation(
lottery_id=lottery_id,
account_id=acc['account_id'],
account_number=acc['account_number']
)
session.add(participation)
success_count += 1
except Exception as e:
errors.append(f"{acc['account_number']}: {str(e)}")
await session.commit()
text = f"📊 **Добавление в розыгрыш '{lottery.title}'**\n\n"
if success_count:
text += f"✅ Добавлено счетов: {success_count}\n\n"
if errors:
text += f"⚠️ Ошибки: {len(errors)}\n"
for error in errors[:3]:
text += f"{error}\n"
await callback.message.edit_text(text)
await state.clear()
@router.callback_query(F.data == "skip_lottery_add")
async def skip_lottery_add(callback: CallbackQuery, state: FSMContext):
"""Пропустить добавление в розыгрыш"""
await callback.message.edit_text("✅ Счета добавлены без участия в розыгрышах")
await state.clear()
@router.message(Command("remove_account"))
@admin_only
async def remove_account_command(message: Message):
"""
Деактивировать счет(а)
Формат: /remove_account <account_number1> [account_number2] [account_number3] ...
Можно указать несколько счетов через пробел для массового удаления
"""
parts = message.text.split()
if len(parts) < 2:
await message.answer(
"❌ Неверный формат команды\n\n"
"Используйте: /remove_account <account_number1> [account_number2] ...\n\n"
"Примеры:\n"
"• /remove_account 12-34-56-78-90-12-34\n"
"• /remove_account 12-34-56-78-90-12-34 98-76-54-32-10-98-76"
)
return
account_numbers = parts[1:] # Все аргументы после команды
try:
results = {
'success': [],
'not_found': [],
'errors': []
}
async with async_session_maker() as session:
for account_number in account_numbers:
try:
success = await AccountService.deactivate_account(session, account_number)
if success:
results['success'].append(account_number)
else:
results['not_found'].append(account_number)
except Exception as e:
results['errors'].append((account_number, str(e)))
# Формируем отчёт
response_parts = []
if results['success']:
response_parts.append(
f"✅ *Деактивировано счетов: {len(results['success'])}*\n"
+ "\n".join(f"• `{acc}`" for acc in results['success'])
)
if results['not_found']:
response_parts.append(
f"❌ *Не найдено счетов: {len(results['not_found'])}*\n"
+ "\n".join(f"• `{acc}`" for acc in results['not_found'])
)
if results['errors']:
response_parts.append(
f"⚠️ *Ошибки при обработке: {len(results['errors'])}*\n"
+ "\n".join(f"• `{acc}`: {err}" for acc, err in results['errors'])
)
if not response_parts:
await message.answer("Не удалось обработать ни один счет")
else:
await message.answer("\n\n".join(response_parts), parse_mode="Markdown")
except Exception as e:
await message.answer(f"❌ Критическая ошибка: {str(e)}")
@router.message(Command("verify_winner"))
@admin_only
async def verify_winner_command(message: Message):
"""
Подтвердить выигрыш по коду верификации
Формат: /verify_winner <verification_code> <lottery_id>
Пример: /verify_winner AB12CD34 1
"""
parts = message.text.split()
if len(parts) != 3:
await message.answer(
"❌ Неверный формат команды\n\n"
"Используйте:\n"
"/verify_winner <verification_code> <lottery_id>\n\n"
"Пример:\n"
"/verify_winner AB12CD34 1"
)
return
verification_code = parts[1].upper()
try:
lottery_id = int(parts[2])
except ValueError:
await message.answer("❌ lottery_id должен быть числом")
return
try:
async with async_session_maker() as session:
# Проверяем существование розыгрыша
lottery = await LotteryService.get_lottery(session, lottery_id)
if not lottery:
await message.answer(f"❌ Розыгрыш #{lottery_id} не найден")
return
# Подтверждаем выигрыш
winner = await WinnerNotificationService.verify_winner(
session,
verification_code=verification_code,
lottery_id=lottery_id
)
if not winner:
await message.answer(
f"❌ Выигрыш не найден\n\n"
f"Возможные причины:\n"
f"• Неверный код верификации\n"
f"• Пользователь не является победителем в розыгрыше #{lottery_id}\n"
f"• Выигрыш уже был подтвержден"
)
return
# Получаем пользователя
user = await RegistrationService.get_user_by_verification_code(session, verification_code)
text = "✅ Выигрыш подтвержден!\n\n"
text += f"🎯 Розыгрыш: {lottery.title}\n"
text += f"🏆 Место: {winner.place}\n"
text += f"🎁 Приз: {winner.prize}\n\n"
if user:
text += f"👤 Победитель: {user.first_name}\n"
text += f"🎫 Клубная карта: {user.club_card_number}\n"
if user.phone:
text += f"📱 Телефон: {user.phone}\n"
# Отправляем уведомление победителю
try:
bot = message.bot
await bot.send_message(
user.telegram_id,
f"✅ Ваш выигрыш подтвержден!\n\n"
f"🎯 Розыгрыш: {lottery.title}\n"
f"🏆 Место: {winner.place}\n"
f"🎁 Приз: {winner.prize}\n\n"
f"Администратор свяжется с вами для получения приза."
)
text += "\n📨 Победителю отправлено уведомление"
except Exception as e:
text += f"\n⚠️ Не удалось отправить уведомление: {str(e)}"
if winner.account_number:
text += f"💳 Счет: {winner.account_number}\n"
await message.answer(text)
except Exception as e:
await message.answer(f"❌ Ошибка: {str(e)}")
@router.message(Command("winner_status"))
@admin_only
async def winner_status_command(message: Message):
"""
Показать статус всех победителей розыгрыша
Формат: /winner_status <lottery_id>
"""
parts = message.text.split()
if len(parts) != 2:
await message.answer(
"❌ Неверный формат команды\n\n"
"Используйте: /winner_status <lottery_id>"
)
return
try:
lottery_id = int(parts[1])
except ValueError:
await message.answer("❌ lottery_id должен быть числом")
return
try:
async with async_session_maker() as session:
lottery = await LotteryService.get_lottery(session, lottery_id)
if not lottery:
await message.answer(f"❌ Розыгрыш #{lottery_id} не найден")
return
winners = await LotteryService.get_winners(session, lottery_id)
if not winners:
await message.answer(f"В розыгрыше '{lottery.title}' пока нет победителей")
return
text = f"🏆 Победители розыгрыша '{lottery.title}':\n\n"
for winner in winners:
status_icon = "" if winner.is_claimed else ""
notified_icon = "📨" if winner.is_notified else "📭"
text += f"{status_icon} {winner.place} место - {winner.prize}\n"
# Получаем информацию о победителе
async with async_session_maker() as session:
if winner.user_id:
user_result = await session.execute(
select(User).where(User.id == winner.user_id)
)
user = user_result.scalar_one_or_none()
if user:
text += f" 👤 {user.first_name}"
if user.club_card_number:
text += f" (КК: {user.club_card_number})"
text += "\n"
if winner.account_number:
text += f" 💳 {winner.account_number}\n"
# Статус подтверждения
if winner.is_claimed:
text += f" ✅ Подтвержден\n"
else:
text += f" ⏳ Ожидает подтверждения\n"
text += "\n"
await message.answer(text)
except Exception as e:
await message.answer(f"❌ Ошибка: {str(e)}")
@router.message(Command("user_info"))
@admin_only
async def user_info_command(message: Message):
"""
Показать информацию о пользователе
Формат: /user_info <club_card>
"""
parts = message.text.split()
if len(parts) != 2:
await message.answer(
"❌ Неверный формат команды\n\n"
"Используйте: /user_info <club_card>"
)
return
club_card = parts[1]
try:
async with async_session_maker() as session:
user = await RegistrationService.get_user_by_club_card(session, club_card)
if not user:
await message.answer(f"❌ Пользователь с клубной картой {club_card} не найден")
return
# Получаем счета
accounts = await AccountService.get_user_accounts(session, user.id)
# Получаем выигрыши
winners_result = await session.execute(
select(Winner).where(Winner.user_id == user.id)
)
winners = winners_result.scalars().all()
text = f"👤 Информация о пользователе\n\n"
text += f"🎫 Клубная карта: {user.club_card_number}\n"
text += f"👤 Имя: {user.first_name}"
if user.last_name:
text += f" {user.last_name}"
text += "\n"
if user.username:
text += f"📱 Telegram: @{user.username}\n"
if user.phone:
text += f"📞 Телефон: {user.phone}\n"
text += f"🔑 Код верификации: {user.verification_code}\n"
text += f"📅 Зарегистрирован: {user.created_at.strftime('%d.%m.%Y')}\n\n"
# Счета
text += f"💳 Счета ({len(accounts)}):\n"
if accounts:
for acc in accounts:
status = "" if acc.is_active else ""
text += f" {status} {acc.account_number}\n"
else:
text += " Нет счетов\n"
# Выигрыши
text += f"\n🏆 Выигрыши ({len(winners)}):\n"
if winners:
for w in winners:
status = "" if w.is_claimed else ""
text += f" {status} {w.place} место - {w.prize}\n"
else:
text += " Нет выигрышей\n"
await message.answer(text)
except Exception as e:
await message.answer(f"❌ Ошибка: {str(e)}")
@router.callback_query(F.data == "view_my_accounts")
async def view_my_accounts_callback(callback: CallbackQuery):
"""Показать все счета пользователя"""
import asyncio
try:
async with async_session_maker() as session:
# Получаем пользователя
user_result = await session.execute(
select(User).where(User.telegram_id == callback.from_user.id)
)
user = user_result.scalar_one_or_none()
if not user:
await callback.answer("❌ Пользователь не найден", show_alert=True)
return
# Получаем все счета
accounts = await AccountService.get_user_accounts(session, user.id)
if not accounts:
await callback.answer("У вас нет счетов", show_alert=True)
return
# Отвечаем на callback сразу, чтобы не было timeout
await callback.answer("⏳ Загружаю ваши счета...")
# Если счетов много - предупреждаем о задержке
batches_count = (len(accounts) + 49) // 50 # Округление вверх
if batches_count > 5:
await callback.message.answer(
f"📊 Найдено счетов: *{len(accounts)}*\n"
f"📤 Отправка {batches_count} сообщений с задержкой (~{batches_count//2} сек)\n\n"
f"⏳ _Пожалуйста, подождите. Бот не завис._",
parse_mode="Markdown"
)
# Формируем сообщение с пагинацией (по 50 счетов на сообщение)
BATCH_SIZE = 50
for i in range(0, len(accounts), BATCH_SIZE):
batch = accounts[i:i+BATCH_SIZE]
text = f"💳 *Ваши счета ({i+1}-{min(i+BATCH_SIZE, len(accounts))} из {len(accounts)}):*\n\n"
for acc in batch:
status = "" if acc.is_active else ""
text += f"{status} `{acc.account_number}`\n"
try:
await callback.message.answer(text, parse_mode="Markdown")
# Задержка между сообщениями для избежания flood control
if i + BATCH_SIZE < len(accounts):
await asyncio.sleep(0.5) # 500ms между сообщениями
except Exception as send_error:
# Если flood control - ждём дольше
if "Flood control" in str(send_error) or "Too Many Requests" in str(send_error):
await asyncio.sleep(2)
await callback.message.answer(text, parse_mode="Markdown")
else:
raise
except Exception as e:
# Не используем callback.answer в except - может быть timeout
try:
await callback.message.answer(f"❌ Ошибка: {str(e)}")
except:
pass # Игнорируем если не получилось отправить