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