Files
new_lottery_bot/src/core/services.py
Andrew K. Choi 505d26f0e9
Some checks reported errors
continuous-integration/drone/push Build encountered an error
feat: Система автоматического подтверждения выигрышей с поддержкой множественных счетов
Основные изменения:

 Новые функции:
- Система регистрации пользователей с множественными счетами
- Автоматическое подтверждение выигрышей через 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
2025-11-16 14:01:30 +09:00

652 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, delete
from sqlalchemy.orm import selectinload
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
class UserService:
"""Сервис для работы с пользователями"""
@staticmethod
async def get_or_create_user(session: AsyncSession, telegram_id: int,
username: str = None, first_name: str = None,
last_name: str = None) -> User:
"""Получить или создать пользователя"""
# Пробуем найти существующего пользователя
result = await session.execute(
select(User).where(User.telegram_id == telegram_id)
)
user = result.scalar_one_or_none()
if user:
# Обновляем информацию о пользователе
user.username = username
user.first_name = first_name
user.last_name = last_name
await session.commit()
return user
# Создаем нового пользователя
user = User(
telegram_id=telegram_id,
username=username,
first_name=first_name,
last_name=last_name
)
session.add(user)
await session.commit()
await session.refresh(user)
return user
@staticmethod
async def get_user_by_telegram_id(session: AsyncSession, telegram_id: int) -> Optional[User]:
"""Получить пользователя по Telegram ID"""
result = await session.execute(
select(User).where(User.telegram_id == telegram_id)
)
return result.scalar_one_or_none()
@staticmethod
async def get_user_by_username(session: AsyncSession, username: str) -> Optional[User]:
"""Получить пользователя по username"""
result = await session.execute(select(User).where(User.username == username))
return result.scalar_one_or_none()
@staticmethod
async def get_all_users(session: AsyncSession, limit: int = None, offset: int = 0) -> List[User]:
"""Получить всех пользователей"""
query = select(User).order_by(User.created_at.desc())
if limit:
query = query.offset(offset).limit(limit)
result = await session.execute(query)
return result.scalars().all()
@staticmethod
async def search_users(session: AsyncSession, search_term: str, limit: int = 20) -> List[User]:
"""Поиск пользователей по имени или username"""
from sqlalchemy import or_, func
search_pattern = f"%{search_term.lower()}%"
result = await session.execute(
select(User).where(
or_(
func.lower(User.first_name).contains(search_pattern),
func.lower(User.last_name).contains(search_pattern) if User.last_name else False,
func.lower(User.username).contains(search_pattern) if User.username else False
)
).limit(limit)
)
return result.scalars().all()
@staticmethod
async def delete_user(session: AsyncSession, user_id: int) -> bool:
"""Удалить пользователя и все связанные данные"""
user = await session.get(User, user_id)
if not user:
return False
# Удаляем все участия
await session.execute(
delete(Participation).where(Participation.user_id == user_id)
)
# Удаляем все победы
await session.execute(
delete(Winner).where(Winner.user_id == user_id)
)
# Удаляем пользователя
await session.delete(user)
await session.commit()
return True
@staticmethod
async def set_admin(session: AsyncSession, telegram_id: int, is_admin: bool = True) -> bool:
"""Установить/снять права администратора"""
result = await session.execute(
update(User)
.where(User.telegram_id == telegram_id)
.values(is_admin=is_admin)
)
await session.commit()
return result.rowcount > 0
@staticmethod
async def set_account_number(session: AsyncSession, telegram_id: int, account_number: str) -> bool:
"""Установить номер клиентского счета пользователю"""
# Валидируем и форматируем номер
formatted_number = format_account_number(account_number)
if not formatted_number:
return False
# Проверяем уникальность номера
existing = await session.execute(
select(User).where(User.account_number == formatted_number)
)
if existing.scalar_one_or_none():
return False # Номер уже занят
# Обновляем пользователя
result = await session.execute(
update(User)
.where(User.telegram_id == telegram_id)
.values(account_number=formatted_number)
)
await session.commit()
return result.rowcount > 0
@staticmethod
async def get_user_by_account(session: AsyncSession, account_number: str) -> Optional[User]:
"""Получить пользователя по номеру счета"""
formatted_number = format_account_number(account_number)
if not formatted_number:
return None
result = await session.execute(
select(User).where(User.account_number == formatted_number)
)
return result.scalar_one_or_none()
@staticmethod
async def search_by_account(session: AsyncSession, account_pattern: str) -> List[User]:
"""Поиск пользователей по части номера счета"""
# Убираем все кроме цифр и дефисов
clean_pattern = ''.join(c for c in account_pattern if c.isdigit() or c == '-')
if not clean_pattern:
return []
result = await session.execute(
select(User).where(
User.account_number.like(f'%{clean_pattern}%')
).limit(20)
)
return result.scalars().all()
class LotteryService:
"""Сервис для работы с розыгрышами"""
@staticmethod
async def create_lottery(session: AsyncSession, title: str, description: str,
prizes: List[str], creator_id: int) -> Lottery:
"""Создать новый розыгрыш"""
lottery = Lottery(
title=title,
description=description,
prizes=prizes,
creator_id=creator_id
)
session.add(lottery)
await session.commit()
await session.refresh(lottery)
return lottery
@staticmethod
async def get_lottery(session: AsyncSession, lottery_id: int) -> Optional[Lottery]:
"""Получить розыгрыш по ID"""
result = await session.execute(
select(Lottery)
.options(selectinload(Lottery.participations).selectinload(Participation.user))
.where(Lottery.id == lottery_id)
)
return result.scalar_one_or_none()
@staticmethod
async def get_active_lotteries(session: AsyncSession, limit: Optional[int] = None) -> List[Lottery]:
"""Получить список активных розыгрышей"""
query = select(Lottery).where(
Lottery.is_active == True,
Lottery.is_completed == False
).order_by(Lottery.created_at.desc())
if limit:
query = query.limit(limit)
result = await session.execute(query)
return result.scalars().all()
@staticmethod
async def get_all_lotteries(session: AsyncSession, limit: Optional[int] = None) -> List[Lottery]:
"""Получить список всех розыгрышей"""
query = select(Lottery).order_by(Lottery.created_at.desc())
if limit:
query = query.limit(limit)
result = await session.execute(query)
return result.scalars().all()
@staticmethod
async def set_manual_winner(session: AsyncSession, lottery_id: int,
place: int, telegram_id: int) -> bool:
"""Установить ручного победителя для определенного места"""
# Получаем пользователя
user = await UserService.get_user_by_telegram_id(session, telegram_id)
if not user:
return False
# Получаем розыгрыш
lottery = await LotteryService.get_lottery(session, lottery_id)
if not lottery:
return False
# Обновляем ручных победителей
if not lottery.manual_winners:
lottery.manual_winners = {}
lottery.manual_winners[str(place)] = telegram_id
await session.commit()
return True
@staticmethod
async def conduct_draw(session: AsyncSession, lottery_id: int) -> Dict[int, Dict[str, Any]]:
"""Провести розыгрыш с учетом ручных победителей"""
lottery = await LotteryService.get_lottery(session, lottery_id)
if not lottery or lottery.is_completed:
return {}
# Получаем всех участников (включая тех, у кого нет 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 {}
# Определяем количество призовых мест
num_prizes = len(lottery.prizes) if lottery.prizes else 1
results = {}
remaining_participants = participants.copy()
manual_winners = lottery.manual_winners or {}
# Сначала обрабатываем ручных победителей
for place in range(1, num_prizes + 1):
place_str = str(place)
if place_str in manual_winners:
# Находим пользователя среди участников
manual_winner = None
for participant in remaining_participants:
if hasattr(participant, 'telegram_id') and participant.telegram_id == manual_winners[place_str]:
manual_winner = participant
break
if manual_winner:
results[place] = {
'user': manual_winner,
'prize': lottery.prizes[place - 1] if lottery.prizes and place <= len(lottery.prizes) else f"Приз {place} места",
'is_manual': True
}
remaining_participants.remove(manual_winner)
# Заполняем оставшиеся места случайными участниками
for place in range(1, num_prizes + 1):
if place not in results and remaining_participants:
winner = random.choice(remaining_participants)
results[place] = {
'user': winner,
'prize': lottery.prizes[place - 1] if lottery.prizes and place <= len(lottery.prizes) else f"Приз {place} места",
'is_manual': False
}
remaining_participants.remove(winner)
# Сохраняем победителей в базу данных
for place, winner_info in results.items():
user_obj = winner_info['user']
winner = Winner(
lottery_id=lottery_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']
)
session.add(winner)
# Обновляем статус розыгрыша
lottery.is_completed = True
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']
}
await session.commit()
return results
@staticmethod
async def get_winners(session: AsyncSession, lottery_id: int) -> List[Winner]:
"""Получить победителей розыгрыша"""
result = await session.execute(
select(Winner)
.options(selectinload(Winner.user))
.where(Winner.lottery_id == lottery_id)
.order_by(Winner.place)
)
return result.scalars().all()
@staticmethod
async def set_winner_display_type(session: AsyncSession, lottery_id: int, display_type: str) -> bool:
"""Установить тип отображения победителей для розыгрыша"""
from ..display.winner_display import validate_display_type
if not validate_display_type(display_type):
return False
result = await session.execute(
update(Lottery)
.where(Lottery.id == lottery_id)
.values(winner_display_type=display_type)
)
await session.commit()
return result.rowcount > 0
@staticmethod
async def set_lottery_active(session: AsyncSession, lottery_id: int, is_active: bool) -> bool:
"""Установить статус активности розыгрыша"""
result = await session.execute(
update(Lottery)
.where(Lottery.id == lottery_id)
.values(is_active=is_active)
)
await session.commit()
return result.rowcount > 0
@staticmethod
async def complete_lottery(session: AsyncSession, lottery_id: int) -> bool:
"""Завершить розыгрыш (сделать неактивным и завершенным)"""
from datetime import datetime
result = await session.execute(
update(Lottery)
.where(Lottery.id == lottery_id)
.values(is_active=False, is_completed=True, end_date=datetime.now())
)
await session.commit()
return result.rowcount > 0
@staticmethod
async def delete_lottery(session: AsyncSession, lottery_id: int) -> bool:
"""Удалить розыгрыш и все связанные данные"""
# Сначала удаляем все связанные данные
# Удаляем победителей
await session.execute(
delete(Winner).where(Winner.lottery_id == lottery_id)
)
# Удаляем участников
await session.execute(
delete(Participation).where(Participation.lottery_id == lottery_id)
)
# Удаляем сам розыгрыш
result = await session.execute(
delete(Lottery).where(Lottery.id == lottery_id)
)
await session.commit()
return result.rowcount > 0
class ParticipationService:
"""Сервис для работы с участием в розыгрышах"""
@staticmethod
async def add_participant(session: AsyncSession, lottery_id: int, user_id: int) -> bool:
"""Добавить участника в розыгрыш"""
# Проверяем, не участвует ли уже пользователь
existing = await session.execute(
select(Participation)
.where(Participation.lottery_id == lottery_id, Participation.user_id == user_id)
)
if existing.scalar_one_or_none():
return False
participation = Participation(lottery_id=lottery_id, user_id=user_id)
session.add(participation)
await session.commit()
return True
@staticmethod
async def remove_participant(session: AsyncSession, lottery_id: int, user_id: int) -> bool:
"""Удалить участника из розыгрыша"""
participation = await session.execute(
select(Participation)
.where(Participation.lottery_id == lottery_id, Participation.user_id == user_id)
)
participation = participation.scalar_one_or_none()
if not participation:
return False
await session.delete(participation)
await session.commit()
return True
@staticmethod
async def get_participants(session: AsyncSession, lottery_id: int, limit: Optional[int] = None, offset: int = 0) -> List[User]:
"""Получить участников розыгрыша"""
query = select(User).join(Participation).where(Participation.lottery_id == lottery_id)
if limit:
query = query.offset(offset).limit(limit)
result = await session.execute(query)
return list(result.scalars().all())
@staticmethod
async def get_user_participations(session: AsyncSession, user_id: int) -> List[Participation]:
"""Получить участие пользователя в розыгрышах"""
result = await session.execute(
select(Participation)
.options(selectinload(Participation.lottery))
.where(Participation.user_id == user_id)
.order_by(Participation.created_at.desc())
)
return list(result.scalars().all())
@staticmethod
async def get_participants_count(session: AsyncSession, lottery_id: int) -> int:
"""Получить количество участников в розыгрыше"""
result = await session.execute(
select(Participation)
.where(Participation.lottery_id == lottery_id)
)
return len(result.scalars().all())
@staticmethod
async def add_participants_bulk(session: AsyncSession, lottery_id: int, telegram_ids: List[int]) -> Dict[str, Any]:
"""Массовое добавление участников"""
results = {
"added": 0,
"skipped": 0,
"errors": [],
"details": []
}
for telegram_id in telegram_ids:
try:
# Проверяем, существует ли пользователь
user = await UserService.get_user_by_telegram_id(session, telegram_id)
if not user:
results["errors"].append(f"Пользователь {telegram_id} не найден")
continue
# Пробуем добавить
if await ParticipationService.add_participant(session, lottery_id, user.id):
results["added"] += 1
results["details"].append(f"Добавлен: {user.first_name} (@{user.username or 'no_username'})")
else:
results["skipped"] += 1
results["details"].append(f"Уже участвует: {user.first_name}")
except Exception as e:
results["errors"].append(f"Ошибка с {telegram_id}: {str(e)}")
return results
@staticmethod
async def remove_participants_bulk(session: AsyncSession, lottery_id: int, telegram_ids: List[int]) -> Dict[str, Any]:
"""Массовое удаление участников"""
results = {
"removed": 0,
"not_found": 0,
"errors": [],
"details": []
}
for telegram_id in telegram_ids:
try:
user = await UserService.get_user_by_telegram_id(session, telegram_id)
if not user:
results["not_found"] += 1
results["details"].append(f"Не найден: {telegram_id}")
continue
if await ParticipationService.remove_participant(session, lottery_id, user.id):
results["removed"] += 1
results["details"].append(f"Удален: {user.first_name}")
else:
results["not_found"] += 1
results["details"].append(f"Не участвовал: {user.first_name}")
except Exception as e:
results["errors"].append(f"Ошибка с {telegram_id}: {str(e)}")
return results
@staticmethod
async def add_participants_by_accounts_bulk(session: AsyncSession, lottery_id: int, account_numbers: List[str]) -> Dict[str, Any]:
"""Массовое добавление участников по номерам счетов"""
results = {
"added": 0,
"skipped": 0,
"errors": [],
"details": [],
"invalid_accounts": []
}
for account_number in account_numbers:
account_number = account_number.strip()
if not account_number:
continue
try:
# Валидируем и форматируем номер
formatted_account = format_account_number(account_number)
if not formatted_account:
results["invalid_accounts"].append(account_number)
results["errors"].append(f"Неверный формат: {account_number}")
continue
# Ищем пользователя по номеру счёта
user = await UserService.get_user_by_account(session, formatted_account)
if not user:
results["errors"].append(f"Пользователь с счётом {formatted_account} не найден")
continue
# Пробуем добавить в розыгрыш
if await ParticipationService.add_participant(session, lottery_id, user.id):
results["added"] += 1
results["details"].append(f"Добавлен: {user.first_name} ({formatted_account})")
else:
results["skipped"] += 1
results["details"].append(f"Уже участвует: {user.first_name} ({formatted_account})")
except Exception as e:
results["errors"].append(f"Ошибка с {account_number}: {str(e)}")
return results
@staticmethod
async def remove_participants_by_accounts_bulk(session: AsyncSession, lottery_id: int, account_numbers: List[str]) -> Dict[str, Any]:
"""Массовое удаление участников по номерам счетов"""
results = {
"removed": 0,
"not_found": 0,
"errors": [],
"details": [],
"invalid_accounts": []
}
for account_number in account_numbers:
account_number = account_number.strip()
if not account_number:
continue
try:
# Валидируем и форматируем номер
formatted_account = format_account_number(account_number)
if not formatted_account:
results["invalid_accounts"].append(account_number)
results["errors"].append(f"Неверный формат: {account_number}")
continue
# Ищем пользователя по номеру счёта
user = await UserService.get_user_by_account(session, formatted_account)
if not user:
results["not_found"] += 1
results["details"].append(f"Не найден: {formatted_account}")
continue
# Пробуем удалить из розыгрыша
if await ParticipationService.remove_participant(session, lottery_id, user.id):
results["removed"] += 1
results["details"].append(f"Удалён: {user.first_name} ({formatted_account})")
else:
results["not_found"] += 1
results["details"].append(f"Не участвовал: {user.first_name} ({formatted_account})")
except Exception as e:
results["errors"].append(f"Ошибка с {account_number}: {str(e)}")
return results
@staticmethod
async def get_participant_stats(session: AsyncSession, user_id: int) -> Dict[str, Any]:
"""Статистика участника"""
from sqlalchemy import func
# Количество участий
participations_count = await session.scalar(
select(func.count(Participation.id)).where(Participation.user_id == user_id)
)
# Количество побед
wins_count = await session.scalar(
select(func.count(Winner.id)).where(Winner.user_id == user_id)
)
# Последнее участие
last_participation = await session.execute(
select(Participation).where(Participation.user_id == user_id)
.order_by(Participation.created_at.desc()).limit(1)
)
last_participation = last_participation.scalar_one_or_none()
return {
"participations_count": participations_count,
"wins_count": wins_count,
"last_participation": last_participation.created_at if last_participation else None
}