feat: Система автоматического подтверждения выигрышей с поддержкой множественных счетов
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
This commit is contained in:
2025-11-16 14:01:30 +09:00
parent 31c4c5382a
commit 505d26f0e9
21 changed files with 4217 additions and 68 deletions

View File

@@ -1,11 +1,12 @@
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, Text, JSON
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, Text, JSON, UniqueConstraint
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from .database import Base
import secrets
class User(Base):
"""Модель пользователя"""
"""Модель пользователя с регистрацией"""
__tablename__ = "users"
id = Column(Integer, primary_key=True)
@@ -13,16 +14,68 @@ class User(Base):
username = Column(String(255))
first_name = Column(String(255))
last_name = Column(String(255))
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
phone = Column(String(20), nullable=True) # Телефон для верификации
club_card_number = Column(String(50), unique=True, nullable=True, index=True) # Номер клубной карты
is_registered = Column(Boolean, default=False) # Прошел ли полную регистрацию
is_admin = Column(Boolean, default=False)
# Клиентский счет в формате: XX-XX-XX-XX-XX-XX-XX (7 пар цифр через дефис)
account_number = Column(String(20), unique=True, nullable=True, index=True)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
# Секретный код для верификации выигрыша (генерируется при регистрации)
verification_code = Column(String(10), unique=True, nullable=True)
# Связи
accounts = relationship("Account", back_populates="owner", cascade="all, delete-orphan")
participations = relationship("Participation", back_populates="user")
winners = relationship("Winner", back_populates="user")
def __repr__(self):
return f"<User(telegram_id={self.telegram_id}, username={self.username})>"
return f"<User(telegram_id={self.telegram_id}, club_card={self.club_card_number})>"
def generate_verification_code(self):
"""Генерирует уникальный код верификации"""
self.verification_code = secrets.token_hex(4).upper() # 8-символьный код
class Account(Base):
"""Модель счета клиента (может быть несколько у одного пользователя)"""
__tablename__ = "accounts"
id = Column(Integer, primary_key=True)
account_number = Column(String(20), unique=True, nullable=False, index=True) # XX-XX-XX-XX-XX-XX-XX
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
is_active = Column(Boolean, default=True) # Активен ли счет
# Связи
owner = relationship("User", back_populates="accounts")
participations = relationship("Participation", back_populates="account")
def __repr__(self):
return f"<Account(number={self.account_number}, owner_id={self.owner_id})>"
class WinnerVerification(Base):
"""Модель верификации победителя"""
__tablename__ = "winner_verifications"
id = Column(Integer, primary_key=True)
winner_id = Column(Integer, ForeignKey("winners.id"), nullable=False, unique=True)
verification_token = Column(String(32), unique=True, nullable=False) # Токен для подтверждения
is_verified = Column(Boolean, default=False)
verified_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
expires_at = Column(DateTime(timezone=True), nullable=False) # Срок действия токена
# Связи
winner = relationship("Winner", back_populates="verification")
def __repr__(self):
return f"<WinnerVerification(winner_id={self.winner_id}, verified={self.is_verified})>"
@staticmethod
def generate_token():
"""Генерирует уникальный токен верификации"""
return secrets.token_urlsafe(24)
class Lottery(Base):
@@ -60,14 +113,16 @@ class Participation(Base):
__tablename__ = "participations"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True) # Опционально
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
lottery_id = Column(Integer, ForeignKey("lotteries.id"), nullable=False)
account_number = Column(String(20), nullable=True, index=True) # Счет участника (XX-XX-XX-XX-XX-XX-XX)
account_id = Column(Integer, ForeignKey("accounts.id"), nullable=True) # Привязка к счету
account_number = Column(String(20), nullable=True, index=True) # Дублируем для быстрого доступа
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
# Связи
user = relationship("User", back_populates="participations")
lottery = relationship("Lottery", back_populates="participations")
account = relationship("Account", back_populates="participations")
def __repr__(self):
if self.account_number:
@@ -81,16 +136,22 @@ class Winner(Base):
id = Column(Integer, primary_key=True)
lottery_id = Column(Integer, ForeignKey("lotteries.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True) # Опционально
account_number = Column(String(20), nullable=True, index=True) # Счет победителя
place = Column(Integer, nullable=False) # Место (1, 2, 3...)
prize = Column(String(500)) # Описание приза
is_manual = Column(Boolean, default=False) # Был ли установлен вручную
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
account_number = Column(String(20), nullable=True, index=True)
place = Column(Integer, nullable=False)
prize = Column(String(500))
is_manual = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
# Статус подтверждения выигрыша
is_notified = Column(Boolean, default=False) # Отправлено ли уведомление
is_claimed = Column(Boolean, default=False) # Подтвердил ли победитель
claimed_at = Column(DateTime(timezone=True), nullable=True) # Время подтверждения
# Связи
user = relationship("User")
user = relationship("User", back_populates="winners")
lottery = relationship("Lottery")
verification = relationship("WinnerVerification", back_populates="winner", uselist=False)
def __repr__(self):
if self.account_number:

View File

@@ -0,0 +1,287 @@
"""Сервисы для регистрации, управления счетами и верификации"""
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from .models import User, Account, Winner, WinnerVerification
from typing import Optional, List
from datetime import datetime, timezone, timedelta
import secrets
class RegistrationService:
"""Сервис для регистрации пользователей"""
@staticmethod
async def register_user(
session: AsyncSession,
telegram_id: int,
club_card_number: str,
phone: Optional[str] = None
) -> User:
"""Зарегистрировать нового пользователя с клубной картой"""
# Проверяем, не занята ли клубная карта
existing = await session.execute(
select(User).where(User.club_card_number == club_card_number)
)
if existing.scalar_one_or_none():
raise ValueError(f"Клубная карта {club_card_number} уже зарегистрирована")
# Находим пользователя по telegram_id
result = await session.execute(
select(User).where(User.telegram_id == telegram_id)
)
user = result.scalar_one_or_none()
if not user:
raise ValueError("Пользователь не найден")
# Обновляем данные пользователя
user.club_card_number = club_card_number
user.phone = phone
user.is_registered = True
user.generate_verification_code()
await session.commit()
await session.refresh(user)
return user
@staticmethod
async def get_user_by_club_card(
session: AsyncSession,
club_card_number: str
) -> Optional[User]:
"""Найти пользователя по номеру клубной карты"""
result = await session.execute(
select(User).where(User.club_card_number == club_card_number)
)
return result.scalar_one_or_none()
@staticmethod
async def get_user_by_verification_code(
session: AsyncSession,
verification_code: str
) -> Optional[User]:
"""Найти пользователя по коду верификации"""
result = await session.execute(
select(User).where(User.verification_code == verification_code)
)
return result.scalar_one_or_none()
class AccountService:
"""Сервис для управления счетами пользователей"""
@staticmethod
async def create_account(
session: AsyncSession,
club_card_number: str,
account_number: str
) -> Account:
"""Создать новый счет для пользователя по номеру клубной карты"""
# Находим владельца по клубной карте
user_result = await session.execute(
select(User).where(User.club_card_number == club_card_number)
)
user = user_result.scalar_one_or_none()
if not user:
raise ValueError(f"Пользователь с клубной картой {club_card_number} не найден")
# Проверяем, не существует ли уже такой счет
existing = await session.execute(
select(Account).where(Account.account_number == account_number)
)
if existing.scalar_one_or_none():
raise ValueError(f"Счет {account_number} уже существует")
# Создаем счет
account = Account(
account_number=account_number,
owner_id=user.id,
is_active=True
)
session.add(account)
await session.commit()
await session.refresh(account)
return account
@staticmethod
async def get_account_owner(
session: AsyncSession,
account_number: str
) -> Optional[User]:
"""Найти владельца счета"""
result = await session.execute(
select(Account).where(
and_(
Account.account_number == account_number,
Account.is_active == True
)
)
)
account = result.scalar_one_or_none()
if not account:
return None
# Получаем владельца
owner_result = await session.execute(
select(User).where(User.id == account.owner_id)
)
return owner_result.scalar_one_or_none()
@staticmethod
async def get_user_accounts(
session: AsyncSession,
user_id: int
) -> List[Account]:
"""Получить все счета пользователя"""
result = await session.execute(
select(Account)
.where(Account.owner_id == user_id)
.order_by(Account.created_at.desc())
)
return result.scalars().all()
@staticmethod
async def deactivate_account(
session: AsyncSession,
account_number: str
) -> bool:
"""Деактивировать счет"""
result = await session.execute(
select(Account).where(Account.account_number == account_number)
)
account = result.scalar_one_or_none()
if not account:
return False
account.is_active = False
await session.commit()
return True
class WinnerNotificationService:
"""Сервис для уведомления победителей"""
@staticmethod
async def create_verification_token(
session: AsyncSession,
winner_id: int,
expires_hours: int = 24
) -> WinnerVerification:
"""Создать токен верификации для победителя"""
# Проверяем, нет ли уже токена
existing = await session.execute(
select(WinnerVerification).where(WinnerVerification.winner_id == winner_id)
)
verification = existing.scalar_one_or_none()
if verification:
# Обновляем существующий токен
verification.verification_token = WinnerVerification.generate_token()
verification.created_at = datetime.now(timezone.utc)
verification.expires_at = datetime.now(timezone.utc) + timedelta(hours=expires_hours)
verification.is_verified = False
verification.verified_at = None
else:
# Создаем новый
verification = WinnerVerification(
winner_id=winner_id,
verification_token=WinnerVerification.generate_token(),
created_at=datetime.now(timezone.utc),
expires_at=datetime.now(timezone.utc) + timedelta(hours=expires_hours)
)
session.add(verification)
await session.commit()
await session.refresh(verification)
return verification
@staticmethod
async def verify_winner(
session: AsyncSession,
verification_code: str,
lottery_id: int
) -> Optional[Winner]:
"""Подтвердить выигрыш по коду верификации пользователя"""
# Находим пользователя по коду
user_result = await session.execute(
select(User).where(User.verification_code == verification_code)
)
user = user_result.scalar_one_or_none()
if not user:
return None
# Находим выигрыш этого пользователя в данном розыгрыше
winner_result = await session.execute(
select(Winner).where(
and_(
Winner.user_id == user.id,
Winner.lottery_id == lottery_id,
Winner.is_claimed == False
)
)
)
winner = winner_result.scalar_one_or_none()
if not winner:
# Проверяем, может быть выигрыш по счету
# Получаем все счета пользователя
accounts_result = await session.execute(
select(Account).where(Account.owner_id == user.id)
)
accounts = accounts_result.scalars().all()
account_numbers = [acc.account_number for acc in accounts]
# Ищем выигрыш по любому из счетов
winner_result = await session.execute(
select(Winner).where(
and_(
Winner.account_number.in_(account_numbers),
Winner.lottery_id == lottery_id,
Winner.is_claimed == False
)
)
)
winner = winner_result.scalar_one_or_none()
if not winner:
return None
# Помечаем как подтвержденный
winner.is_claimed = True
# Обновляем верификацию если есть
verification_result = await session.execute(
select(WinnerVerification).where(WinnerVerification.winner_id == winner.id)
)
verification = verification_result.scalar_one_or_none()
if verification:
verification.is_verified = True
verification.verified_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(winner)
return winner
@staticmethod
async def get_unverified_winners(
session: AsyncSession,
lottery_id: int
) -> List[Winner]:
"""Получить список неподтвержденных победителей"""
result = await session.execute(
select(Winner).where(
and_(
Winner.lottery_id == lottery_id,
Winner.is_claimed == False
)
)
)
return result.scalars().all()

View File

@@ -1,7 +1,7 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, delete
from sqlalchemy.orm import selectinload
from .models import User, Lottery, Participation, Winner
from .models import User, Lottery, Participation, Winner, Account
from typing import List, Optional, Dict, Any
from ..utils.account_utils import validate_account_number, format_account_number
import random
@@ -251,8 +251,20 @@ class LotteryService:
if not lottery or lottery.is_completed:
return {}
# Получаем всех участников
participants = [p.user for p in lottery.participations]
# Получаем всех участников (включая тех, у кого нет user)
participants = []
for p in lottery.participations:
if p.user:
participants.append(p.user)
else:
# Создаем временный объект для участников без пользователя
# Храним только номер счета
participants.append(type('obj', (object,), {
'id': None,
'telegram_id': None,
'account_number': p.account_number
})())
if not participants:
return {}
@@ -270,7 +282,7 @@ class LotteryService:
# Находим пользователя среди участников
manual_winner = None
for participant in remaining_participants:
if participant.telegram_id == manual_winners[place_str]:
if hasattr(participant, 'telegram_id') and participant.telegram_id == manual_winners[place_str]:
manual_winner = participant
break
@@ -295,9 +307,11 @@ class LotteryService:
# Сохраняем победителей в базу данных
for place, winner_info in results.items():
user_obj = winner_info['user']
winner = Winner(
lottery_id=lottery_id,
user_id=winner_info['user'].id,
user_id=user_obj.id if hasattr(user_obj, 'id') and user_obj.id else None,
account_number=user_obj.account_number if hasattr(user_obj, 'account_number') else None,
place=place,
prize=winner_info['prize'],
is_manual=winner_info['is_manual']
@@ -306,15 +320,17 @@ class LotteryService:
# Обновляем статус розыгрыша
lottery.is_completed = True
lottery.draw_results = {
str(place): {
'user_id': info['user'].id,
'telegram_id': info['user'].telegram_id,
'username': info['user'].username,
lottery.draw_results = {}
for place, info in results.items():
user_obj = info['user']
lottery.draw_results[str(place)] = {
'user_id': user_obj.id if hasattr(user_obj, 'id') and user_obj.id else None,
'telegram_id': user_obj.telegram_id if hasattr(user_obj, 'telegram_id') else None,
'username': user_obj.username if hasattr(user_obj, 'username') else None,
'account_number': user_obj.account_number if hasattr(user_obj, 'account_number') else None,
'prize': info['prize'],
'is_manual': info['is_manual']
} for place, info in results.items()
}
}
await session.commit()
return results

View File

@@ -0,0 +1,585 @@
"""Админские обработчики для управления счетами и верификации"""
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)}")

View File

@@ -471,9 +471,13 @@ async def show_lottery_participants(callback: CallbackQuery):
for i, participation in enumerate(lottery.participations[:20], 1): # Показываем первых 20
user = participation.user
username = f"@{user.username}" if user.username else "Нет username"
text += f"{i}. {user.first_name} {user.last_name or ''}\n"
text += f" {username} | ID: {user.telegram_id}\n"
if user:
username = f"@{user.username}" if user.username else "Нет username"
text += f"{i}. {user.first_name} {user.last_name or ''}\n"
text += f" {username} | ID: {user.telegram_id}\n"
else:
# Если пользователя нет, показываем номер счета
text += f"{i}. Счет: {participation.account_number or 'Не указан'}\n"
text += f" Участвует с: {participation.created_at.strftime('%d.%m %H:%M')}\n\n"
if len(lottery.participations) > 20:
@@ -1683,11 +1687,13 @@ async def redirect_to_edit_lottery(callback: CallbackQuery, state: FSMContext):
parts = callback.data.split("_")
if len(parts) == 3: # admin_edit_123
lottery_id = int(parts[2])
# Подменяем callback_data для обработки существующим хэндлером
callback.data = f"admin_edit_lottery_select_{lottery_id}"
# Напрямую вызываем обработчик вместо подмены callback_data
await state.update_data(edit_lottery_id=lottery_id)
await choose_edit_field(callback, state)
else:
# Если формат другой, то это уже правильный callback
lottery_id = int(callback.data.split("_")[-1])
await state.update_data(edit_lottery_id=lottery_id)
await choose_edit_field(callback, state)
@@ -2027,8 +2033,8 @@ async def handle_set_winner_from_lottery(callback: CallbackQuery, state: FSMCont
lottery_id = int(callback.data.split("_")[-1])
# Перенаправляем на стандартный обработчик
callback.data = f"admin_choose_winner_lottery_{lottery_id}"
# Напрямую вызываем обработчик вместо подмены callback_data
await state.update_data(winner_lottery_id=lottery_id)
await choose_winner_place(callback, state)
@@ -2610,11 +2616,12 @@ async def conduct_lottery_draw(callback: CallbackQuery):
await callback.answer("Нет участников для розыгрыша", show_alert=True)
return
# Проводим розыгрыш
from ..display.conduct_draw import conduct_draw
winners = await conduct_draw(lottery_id)
# Проводим розыгрыш через сервис
winners_dict = await LotteryService.conduct_draw(session, lottery_id)
if winners:
if winners_dict:
# Получаем победителей из базы
winners = await LotteryService.get_winners(session, lottery_id)
text = f"🎉 Розыгрыш '{lottery.title}' завершён!\n\n"
text += "🏆 Победители:\n"
for winner in winners:

View File

@@ -0,0 +1,314 @@
"""Команды для повторного розыгрыша неподтвержденных выигрышей"""
from aiogram import Router, F
from aiogram.types import Message, InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.filters import Command
from sqlalchemy import select, and_
from datetime import datetime, timezone, timedelta
import random
from src.core.database import async_session_maker
from src.core.registration_services import AccountService, WinnerNotificationService
from src.core.services import LotteryService
from src.core.models import User, Winner
from src.core.config import ADMIN_IDS
router = Router()
def is_admin(user_id: int) -> bool:
"""Проверка прав администратора"""
return user_id in ADMIN_IDS
@router.message(Command("check_unclaimed"))
async def check_unclaimed_winners(message: Message):
"""
Проверить неподтвержденные выигрыши (более 24 часов)
Формат: /check_unclaimed <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"
"Используйте: /check_unclaimed <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:
from sqlalchemy.orm import selectinload
from src.core.models import Lottery
# Загружаем розыгрыш с участниками
lottery_result = await session.execute(
select(Lottery)
.options(selectinload(Lottery.participations))
.where(Lottery.id == lottery_id)
)
lottery = lottery_result.scalar_one_or_none()
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
# Находим неподтвержденные выигрыши старше 24 часов
now = datetime.now(timezone.utc)
unclaimed = []
for winner in winners:
if not winner.is_claimed and winner.is_notified:
# Проверяем, прошло ли 24 часа
time_passed = now - winner.created_at
if time_passed.total_seconds() > 24 * 3600: # 24 часа
unclaimed.append({
'winner': winner,
'hours_passed': int(time_passed.total_seconds() / 3600)
})
if not unclaimed:
await message.answer(
f"Все победители розыгрыша '{lottery.title}' подтвердили выигрыш\n"
f"или срок подтверждения еще не истек."
)
return
text = f"⚠️ **Неподтвержденные выигрыши в розыгрыше '{lottery.title}':**\n\n"
for item in unclaimed:
winner = item['winner']
hours = item['hours_passed']
text += f"🏆 {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"
text += f" ⏰ Прошло: {hours} часов\n\n"
text += f"\n📊 Всего неподтвержденных: {len(unclaimed)}\n\n"
text += f"Используйте /redraw {lottery_id} для повторного розыгрыша"
await message.answer(text, parse_mode="Markdown")
except Exception as e:
await message.answer(f"❌ Ошибка: {str(e)}")
@router.message(Command("redraw"))
async def redraw_lottery(message: Message):
"""
Переиграть розыгрыш для неподтвержденных выигрышей
Формат: /redraw <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"
"Используйте: /redraw <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:
from sqlalchemy.orm import selectinload
from src.core.models import Lottery
# Загружаем розыгрыш с участниками
lottery_result = await session.execute(
select(Lottery)
.options(selectinload(Lottery.participations))
.where(Lottery.id == lottery_id)
)
lottery = lottery_result.scalar_one_or_none()
if not lottery:
await message.answer(f"❌ Розыгрыш #{lottery_id} не найден")
return
winners = await LotteryService.get_winners(session, lottery_id)
# Находим неподтвержденные выигрыши старше 24 часов
now = datetime.now(timezone.utc)
unclaimed_winners = []
for winner in winners:
if not winner.is_claimed and winner.is_notified:
time_passed = now - winner.created_at
if time_passed.total_seconds() > 24 * 3600:
unclaimed_winners.append(winner)
if not unclaimed_winners:
await message.answer(
"✅ Нет неподтвержденных выигрышей старше 24 часов.\n"
"Повторный розыгрыш не требуется."
)
return
# Получаем всех участников, исключая текущих победителей
all_participants = []
current_winner_accounts = set()
for winner in winners:
if winner.account_number:
current_winner_accounts.add(winner.account_number)
for p in lottery.participations:
if p.account_number and p.account_number not in current_winner_accounts:
all_participants.append(p)
if not all_participants:
await message.answer(
"❌ Нет доступных участников для повторного розыгрыша.\n"
"Все участники уже являются победителями."
)
return
# Переигрываем каждое неподтвержденное место
redraw_results = []
for old_winner in unclaimed_winners:
if not all_participants:
break
# Выбираем нового победителя
new_participant = random.choice(all_participants)
all_participants.remove(new_participant)
# Удаляем старого победителя
await session.delete(old_winner)
# Создаем нового победителя
new_winner = Winner(
lottery_id=lottery_id,
user_id=None,
account_number=new_participant.account_number,
account_id=new_participant.account_id,
place=old_winner.place,
prize=old_winner.prize,
is_manual=False,
is_notified=False,
is_claimed=False
)
session.add(new_winner)
redraw_results.append({
'place': old_winner.place,
'prize': old_winner.prize,
'old_account': old_winner.account_number,
'new_account': new_participant.account_number
})
await session.commit()
# Отправляем уведомления новым победителям
for result in redraw_results:
# Находим нового победителя
new_winner_result = await session.execute(
select(Winner).where(
and_(
Winner.lottery_id == lottery_id,
Winner.place == result['place'],
Winner.account_number == result['new_account']
)
)
)
new_winner = new_winner_result.scalar_one_or_none()
if new_winner:
# Отправляем уведомление новому победителю
owner = await AccountService.get_account_owner(session, new_winner.account_number)
if owner and owner.telegram_id:
# Создаем токен верификации
await WinnerNotificationService.create_verification_token(
session,
new_winner.id
)
# Формируем сообщение
notification_message = (
f"🎉 Поздравляем! Ваш счет выиграл!\n\n"
f"🎯 Розыгрыш: {lottery.title}\n"
f"🏆 Место: {new_winner.place}\n"
f"🎁 Приз: {new_winner.prize}\n"
f"💳 Счет: {new_winner.account_number}\n\n"
f"⏰ **У вас есть 24 часа для подтверждения!**\n\n"
f"Нажмите кнопку ниже, чтобы подтвердить получение приза."
)
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text="✅ Подтвердить получение приза",
callback_data=f"confirm_win_{new_winner.id}"
)]
])
try:
await message.bot.send_message(
owner.telegram_id,
notification_message,
reply_markup=keyboard,
parse_mode="Markdown"
)
new_winner.is_notified = True
await session.commit()
except:
pass
# Формируем отчет для админа
text = f"🔄 **Повторный розыгрыш завершен!**\n\n"
text += f"🎯 Розыгрыш: {lottery.title}\n"
text += f"📊 Переиграно мест: {len(redraw_results)}\n\n"
for result in redraw_results:
text += f"🏆 {result['place']} место - {result['prize']}\n"
text += f" ❌ Было: {result['old_account']}\n"
text += f" ✅ Стало: {result['new_account']}\n\n"
text += "📨 Новым победителям отправлены уведомления"
await message.answer(text, parse_mode="Markdown")
except Exception as e:
await message.answer(f"❌ Ошибка: {str(e)}")

View File

@@ -0,0 +1,150 @@
"""Обработчики для регистрации пользователей"""
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.filters import Command, StateFilter
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from src.core.database import async_session_maker
from src.core.registration_services import RegistrationService, AccountService
from src.core.services import UserService
router = Router()
class RegistrationStates(StatesGroup):
"""Состояния для процесса регистрации"""
waiting_for_club_card = State()
waiting_for_phone = State()
@router.callback_query(F.data == "start_registration")
async def start_registration(callback: CallbackQuery, state: FSMContext):
"""Начать процесс регистрации"""
text = (
"📝 Регистрация в системе\n\n"
"Для участия в розыгрышах необходимо зарегистрироваться.\n\n"
"Введите номер вашей клубной карты:"
)
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="❌ Отмена", callback_data="back_to_main")]
])
)
await state.set_state(RegistrationStates.waiting_for_club_card)
@router.message(StateFilter(RegistrationStates.waiting_for_club_card))
async def process_club_card(message: Message, state: FSMContext):
"""Обработка номера клубной карты"""
club_card_number = message.text.strip()
# Проверяем, не занята ли карта
async with async_session_maker() as session:
existing_user = await RegistrationService.get_user_by_club_card(session, club_card_number)
if existing_user:
await message.answer(
f"❌ Клубная карта {club_card_number} уже зарегистрирована.\n\n"
"Если это ваша карта, обратитесь к администратору."
)
await state.clear()
return
await state.update_data(club_card_number=club_card_number)
await message.answer(
"📱 Теперь введите ваш номер телефона\n"
"(или отправьте '-' чтобы пропустить):"
)
await state.set_state(RegistrationStates.waiting_for_phone)
@router.message(StateFilter(RegistrationStates.waiting_for_phone))
async def process_phone(message: Message, state: FSMContext):
"""Обработка номера телефона"""
phone = None if message.text.strip() == "-" else message.text.strip()
data = await state.get_data()
club_card_number = data['club_card_number']
try:
async with async_session_maker() as session:
user = await RegistrationService.register_user(
session,
telegram_id=message.from_user.id,
club_card_number=club_card_number,
phone=phone
)
text = (
"✅ Регистрация завершена!\n\n"
f"🎫 Клубная карта: {user.club_card_number}\n"
f"🔑 Ваш код верификации: **{user.verification_code}**\n\n"
"⚠️ Сохраните этот код! Он понадобится для подтверждения выигрыша.\n\n"
"Теперь вы можете участвовать в розыгрышах!"
)
await message.answer(text, parse_mode="Markdown")
await state.clear()
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(Command("my_code"))
async def show_verification_code(message: Message):
"""Показать код верификации пользователя"""
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
if not user or not user.is_registered:
await message.answer(
"❌ Вы не зарегистрированы в системе.\n\n"
"Для регистрации отправьте /start и выберите 'Регистрация'"
)
return
text = (
"🔑 Ваш код верификации:\n\n"
f"**{user.verification_code}**\n\n"
"Этот код используется для подтверждения выигрыша.\n"
"Сообщите его администратору при получении приза."
)
await message.answer(text, parse_mode="Markdown")
@router.message(Command("my_accounts"))
async def show_user_accounts(message: Message):
"""Показать счета пользователя"""
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
if not user or not user.is_registered:
await message.answer("❌ Вы не зарегистрированы в системе")
return
accounts = await AccountService.get_user_accounts(session, user.id)
if not accounts:
await message.answer(
"У вас пока нет привязанных счетов.\n\n"
"Счета добавляются администратором."
)
return
text = f"💳 Ваши счета (Клубная карта: {user.club_card_number}):\n\n"
for i, account in enumerate(accounts, 1):
status = "" if account.is_active else ""
text += f"{i}. {status} {account.account_number}\n"
await message.answer(text)