Some checks reported errors
continuous-integration/drone/push Build encountered an error
Основные изменения: ✨ Новые функции: - Система регистрации пользователей с множественными счетами - Автоматическое подтверждение выигрышей через inline-кнопки - Механизм переигровки для неподтвержденных выигрышей (24 часа) - Подтверждение на уровне счетов (каждый счет подтверждается отдельно) - Скрипт полной очистки базы данных 🔧 Технические улучшения: - Исправлена ошибка MissingGreenlet при lazy loading (добавлен joinedload/selectinload) - Добавлено поле claimed_at для отслеживания времени подтверждения - Пакетное добавление счетов с выбором розыгрыша - Проверка владения конкретным счетом при подтверждении 📚 Документация: - docs/AUTO_CONFIRM_SYSTEM.md - Полная документация системы подтверждения - docs/ACCOUNT_BASED_CONFIRMATION.md - Подтверждение на уровне счетов - docs/REGISTRATION_SYSTEM.md - Система регистрации - docs/ADMIN_COMMANDS.md - Команды администратора - docs/CLEAR_DATABASE.md - Очистка БД - docs/QUICK_GUIDE.md - Быстрое начало - docs/UPDATE_LOG.md - Журнал обновлений 🗄️ База данных: - Миграция 003: Таблицы accounts, winner_verifications - Миграция 004: Поле claimed_at в таблице winners - Скрипт scripts/clear_database.py для полной очистки 🎮 Новые команды: Админские: - /check_unclaimed <lottery_id> - Проверка неподтвержденных выигрышей - /redraw <lottery_id> - Повторный розыгрыш - /add_accounts - Пакетное добавление счетов - /list_accounts <telegram_id> - Список счетов пользователя Пользовательские: - /register - Регистрация с вводом данных - /my_account - Просмотр своих счетов - Callback confirm_win_{id} - Подтверждение выигрыша 🛠️ Makefile: - make clear-db - Очистка всех данных из БД (с подтверждением) 🔒 Безопасность: - Проверка владения счетом при подтверждении - Защита от подтверждения чужих счетов - Независимое подтверждение каждого выигрышного счета 📊 Логика работы: 1. Пользователь регистрируется и добавляет счета 2. Счета участвуют в розыгрыше 3. Победители получают уведомление с кнопкой подтверждения 4. Каждый счет подтверждается отдельно (24 часа на подтверждение) 5. Неподтвержденные выигрыши переигрываются через /redraw
586 lines
23 KiB
Python
586 lines
23 KiB
Python
"""Админские обработчики для управления счетами и верификации"""
|
||
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
|
||
|
||
|
||
router = Router()
|
||
|
||
|
||
class AddAccountStates(StatesGroup):
|
||
waiting_for_data = State()
|
||
choosing_lottery = State()
|
||
|
||
|
||
def is_admin(user_id: int) -> bool:
|
||
"""Проверка прав администратора"""
|
||
return user_id in ADMIN_IDS
|
||
|
||
|
||
@router.message(Command("add_account"))
|
||
async def add_account_command(message: Message, state: FSMContext):
|
||
"""
|
||
Добавить счет пользователю по клубной карте
|
||
Формат: /add_account <club_card> <account_number>
|
||
Или: /add_account (затем вводить данные построчно)
|
||
"""
|
||
if not is_admin(message.from_user.id):
|
||
await message.answer("❌ Недостаточно прав")
|
||
return
|
||
|
||
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"
|
||
"Отправьте данные в формате:\n"
|
||
"`клубная_карта номер_счета`\n\n"
|
||
"**Для одного счета:**\n"
|
||
"`2223 11-22-33-44-55-66-77`\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"Теперь вы можете участвовать в розыгрышах с этим счетом!"
|
||
)
|
||
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')
|
||
accounts_data = []
|
||
errors = []
|
||
|
||
for i, line in enumerate(lines, 1):
|
||
parts = line.strip().split()
|
||
if len(parts) != 2:
|
||
errors.append(f"Строка {i}: неверный формат (ожидается: клубная_карта номер_счета)")
|
||
continue
|
||
|
||
club_card, account_number = parts
|
||
|
||
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
|
||
})
|
||
|
||
# Отправляем уведомление владельцу
|
||
if owner:
|
||
try:
|
||
await message.bot.send_message(
|
||
owner.telegram_id,
|
||
f"✅ К вашему профилю добавлен счет:\n\n"
|
||
f"💳 {account_number}\n\n"
|
||
f"Теперь вы можете участвовать в розыгрышах!"
|
||
)
|
||
except:
|
||
pass
|
||
|
||
except ValueError as e:
|
||
errors.append(f"Строка {i} ({club_card} {account_number}): {str(e)}")
|
||
except Exception as e:
|
||
errors.append(f"Строка {i}: {str(e)}")
|
||
|
||
# Формируем отчет
|
||
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"))
|
||
async def remove_account_command(message: Message):
|
||
"""
|
||
Деактивировать счет
|
||
Формат: /remove_account <account_number>
|
||
"""
|
||
if not is_admin(message.from_user.id):
|
||
await message.answer("❌ Недостаточно прав")
|
||
return
|
||
|
||
parts = message.text.split()
|
||
if len(parts) != 2:
|
||
await message.answer(
|
||
"❌ Неверный формат команды\n\n"
|
||
"Используйте: /remove_account <account_number>"
|
||
)
|
||
return
|
||
|
||
account_number = parts[1]
|
||
|
||
try:
|
||
async with async_session_maker() as session:
|
||
success = await AccountService.deactivate_account(session, account_number)
|
||
|
||
if success:
|
||
await message.answer(f"✅ Счет {account_number} деактивирован")
|
||
else:
|
||
await message.answer(f"❌ Счет {account_number} не найден")
|
||
|
||
except Exception as e:
|
||
await message.answer(f"❌ Ошибка: {str(e)}")
|
||
|
||
|
||
@router.message(Command("verify_winner"))
|
||
async def verify_winner_command(message: Message):
|
||
"""
|
||
Подтвердить выигрыш по коду верификации
|
||
Формат: /verify_winner <verification_code> <lottery_id>
|
||
Пример: /verify_winner AB12CD34 1
|
||
"""
|
||
if not is_admin(message.from_user.id):
|
||
await message.answer("❌ Недостаточно прав")
|
||
return
|
||
|
||
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"))
|
||
async def winner_status_command(message: Message):
|
||
"""
|
||
Показать статус всех победителей розыгрыша
|
||
Формат: /winner_status <lottery_id>
|
||
"""
|
||
if not is_admin(message.from_user.id):
|
||
await message.answer("❌ Недостаточно прав")
|
||
return
|
||
|
||
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"))
|
||
async def user_info_command(message: Message):
|
||
"""
|
||
Показать информацию о пользователе
|
||
Формат: /user_info <club_card>
|
||
"""
|
||
if not is_admin(message.from_user.id):
|
||
await message.answer("❌ Недостаточно прав")
|
||
return
|
||
|
||
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)}")
|