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: