mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-17 09:30:35 +00:00
Рефакторинг архитектуры ежедневных конкурсов: - Создан модуль app/services/contests/ с новой архитектурой: - enums.py: GameType, RoundStatus, PrizeType enum классы - games.py: паттерн Стратегия для 7 типов игр - attempt_service.py: ContestAttemptService для атомарных операций - Упрощён handlers/contests.py: - Удалены отдельные _render_* функции (заменены на стратегии) - Логика обработки попыток вынесена в ContestAttemptService - Уменьшено с 523 до 342 строк (-35%) - Обновлён contest_rotation_service.py: - Заменена if-elif цепочка на get_game_strategy().build_payload() - Используются enum классы вместо магических строк - Исправлен handlers/admin/daily_contests.py: - prize_days → prize_type/prize_value (соответствие модели БД) - Обновлены EDITABLE_FIELDS и отображение приза 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
317 lines
9.6 KiB
Python
317 lines
9.6 KiB
Python
"""Service for atomic contest attempt operations."""
|
||
|
||
import logging
|
||
from dataclasses import dataclass
|
||
from typing import Optional
|
||
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.config import settings
|
||
from app.database.crud.contest import create_attempt, get_attempt, update_attempt
|
||
from app.database.crud.subscription import extend_subscription, get_subscription_by_user_id
|
||
from app.database.crud.user import get_user_by_id
|
||
from app.database.models import ContestAttempt, ContestRound, ContestTemplate
|
||
from app.services.contests.enums import PrizeType
|
||
from app.services.contests.games import get_game_strategy
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
@dataclass
|
||
class AttemptResult:
|
||
"""Result of processing a contest attempt."""
|
||
|
||
success: bool
|
||
is_winner: bool
|
||
message: str
|
||
already_played: bool = False
|
||
round_finished: bool = False
|
||
|
||
|
||
class ContestAttemptService:
|
||
"""Service for processing contest attempts with atomic operations."""
|
||
|
||
async def process_button_attempt(
|
||
self,
|
||
db: AsyncSession,
|
||
round_obj: ContestRound,
|
||
user_id: int,
|
||
pick: str,
|
||
language: str,
|
||
) -> AttemptResult:
|
||
"""
|
||
Process a button-based game attempt atomically.
|
||
|
||
Args:
|
||
db: Database session
|
||
round_obj: Contest round
|
||
user_id: User ID
|
||
pick: User's pick (button callback data)
|
||
language: User's language
|
||
|
||
Returns:
|
||
AttemptResult with outcome details
|
||
"""
|
||
tpl = round_obj.template
|
||
if not tpl:
|
||
return AttemptResult(
|
||
success=False,
|
||
is_winner=False,
|
||
message="Конкурс не найден",
|
||
)
|
||
|
||
# Check if user already played
|
||
existing_attempt = await get_attempt(db, round_obj.id, user_id)
|
||
if existing_attempt:
|
||
return AttemptResult(
|
||
success=False,
|
||
is_winner=False,
|
||
message="У вас уже была попытка",
|
||
already_played=True,
|
||
)
|
||
|
||
# Get game strategy and check answer
|
||
strategy = get_game_strategy(tpl.slug)
|
||
if not strategy:
|
||
return AttemptResult(
|
||
success=False,
|
||
is_winner=False,
|
||
message="Тип игры не поддерживается",
|
||
)
|
||
|
||
check_result = strategy.check_answer(pick, round_obj.payload or {}, language)
|
||
is_winner = check_result.is_correct
|
||
|
||
# Atomic winner check with row lock
|
||
is_winner = await self._atomic_winner_check(db, round_obj.id, is_winner)
|
||
|
||
# Create attempt record
|
||
await create_attempt(
|
||
db,
|
||
round_id=round_obj.id,
|
||
user_id=user_id,
|
||
answer=str(pick),
|
||
is_winner=is_winner,
|
||
)
|
||
|
||
logger.info(
|
||
"Contest attempt: user %s, round %s, pick '%s', winner %s",
|
||
user_id, round_obj.id, pick, is_winner
|
||
)
|
||
|
||
if is_winner:
|
||
prize_msg = await self._award_prize(db, user_id, tpl, language)
|
||
return AttemptResult(
|
||
success=True,
|
||
is_winner=True,
|
||
message=f"🎉 Победа! {prize_msg}" if prize_msg else "🎉 Победа!",
|
||
)
|
||
|
||
return AttemptResult(
|
||
success=True,
|
||
is_winner=False,
|
||
message=check_result.response_text or "Неудача",
|
||
)
|
||
|
||
async def process_text_attempt(
|
||
self,
|
||
db: AsyncSession,
|
||
round_obj: ContestRound,
|
||
user_id: int,
|
||
text_answer: str,
|
||
language: str,
|
||
) -> AttemptResult:
|
||
"""
|
||
Process a text-input game attempt atomically.
|
||
|
||
Args:
|
||
db: Database session
|
||
round_obj: Contest round
|
||
user_id: User ID
|
||
text_answer: User's text answer
|
||
language: User's language
|
||
|
||
Returns:
|
||
AttemptResult with outcome details
|
||
"""
|
||
tpl = round_obj.template
|
||
if not tpl:
|
||
return AttemptResult(
|
||
success=False,
|
||
is_winner=False,
|
||
message="Конкурс не найден",
|
||
)
|
||
|
||
# For text games, attempt should already exist (created in render phase)
|
||
attempt = await get_attempt(db, round_obj.id, user_id)
|
||
if not attempt:
|
||
return AttemptResult(
|
||
success=False,
|
||
is_winner=False,
|
||
message="Сначала начните игру",
|
||
)
|
||
|
||
# Check if already answered
|
||
if attempt.answer is not None:
|
||
return AttemptResult(
|
||
success=False,
|
||
is_winner=False,
|
||
message="У вас уже была попытка",
|
||
already_played=True,
|
||
)
|
||
|
||
# Get game strategy and check answer
|
||
strategy = get_game_strategy(tpl.slug)
|
||
if not strategy:
|
||
return AttemptResult(
|
||
success=False,
|
||
is_winner=False,
|
||
message="Тип игры не поддерживается",
|
||
)
|
||
|
||
check_result = strategy.check_answer(text_answer, round_obj.payload or {}, language)
|
||
is_winner = check_result.is_correct
|
||
|
||
# Atomic winner check with row lock
|
||
is_winner = await self._atomic_winner_check(db, round_obj.id, is_winner)
|
||
|
||
# Update attempt with answer
|
||
await update_attempt(db, attempt, answer=text_answer.strip().upper(), is_winner=is_winner)
|
||
|
||
logger.info(
|
||
"Contest text attempt: user %s, round %s, answer '%s', winner %s",
|
||
user_id, round_obj.id, text_answer, is_winner
|
||
)
|
||
|
||
if is_winner:
|
||
prize_msg = await self._award_prize(db, user_id, tpl, language)
|
||
return AttemptResult(
|
||
success=True,
|
||
is_winner=True,
|
||
message=f"🎉 Победа! {prize_msg}" if prize_msg else "🎉 Победа!",
|
||
)
|
||
|
||
return AttemptResult(
|
||
success=True,
|
||
is_winner=False,
|
||
message=check_result.response_text or "Неверно, попробуй в следующем раунде",
|
||
)
|
||
|
||
async def create_pending_attempt(
|
||
self,
|
||
db: AsyncSession,
|
||
round_id: int,
|
||
user_id: int,
|
||
) -> Optional[ContestAttempt]:
|
||
"""
|
||
Create a pending attempt for text-input games.
|
||
This blocks re-entry while user is answering.
|
||
|
||
Args:
|
||
db: Database session
|
||
round_id: Round ID
|
||
user_id: User ID
|
||
|
||
Returns:
|
||
Created attempt or None if already exists
|
||
"""
|
||
existing = await get_attempt(db, round_id, user_id)
|
||
if existing:
|
||
return None
|
||
|
||
return await create_attempt(
|
||
db,
|
||
round_id=round_id,
|
||
user_id=user_id,
|
||
answer=None,
|
||
is_winner=False,
|
||
)
|
||
|
||
async def _atomic_winner_check(
|
||
self,
|
||
db: AsyncSession,
|
||
round_id: int,
|
||
is_winner: bool,
|
||
) -> bool:
|
||
"""
|
||
Atomically check and increment winner count.
|
||
Uses SELECT FOR UPDATE to prevent race conditions.
|
||
|
||
Args:
|
||
db: Database session
|
||
round_id: Round ID
|
||
is_winner: Whether user answered correctly
|
||
|
||
Returns:
|
||
True if user is a winner, False if max winners reached
|
||
"""
|
||
if not is_winner:
|
||
return False
|
||
|
||
stmt = select(ContestRound).where(ContestRound.id == round_id).with_for_update()
|
||
result = await db.execute(stmt)
|
||
round_obj = result.scalar_one()
|
||
|
||
if round_obj.winners_count >= round_obj.max_winners:
|
||
return False
|
||
|
||
round_obj.winners_count += 1
|
||
await db.commit()
|
||
return True
|
||
|
||
async def _award_prize(
|
||
self,
|
||
db: AsyncSession,
|
||
user_id: int,
|
||
template: ContestTemplate,
|
||
language: str,
|
||
) -> str:
|
||
"""
|
||
Award prize to winner.
|
||
|
||
Args:
|
||
db: Database session
|
||
user_id: Winner user ID
|
||
template: Contest template with prize info
|
||
language: User's language
|
||
|
||
Returns:
|
||
Prize notification message
|
||
"""
|
||
from app.localization.texts import get_texts
|
||
texts = get_texts(language)
|
||
|
||
prize_type = template.prize_type or PrizeType.DAYS.value
|
||
prize_value = template.prize_value or "1"
|
||
|
||
if prize_type == PrizeType.DAYS.value:
|
||
subscription = await get_subscription_by_user_id(db, user_id)
|
||
if not subscription:
|
||
return ""
|
||
days = int(prize_value) if prize_value.isdigit() else 1
|
||
await extend_subscription(db, subscription, days)
|
||
return texts.t("CONTEST_PRIZE_GRANTED", "Бонус {days} дней зачислен!").format(days=days)
|
||
|
||
elif prize_type == PrizeType.BALANCE.value:
|
||
user = await get_user_by_id(db, user_id)
|
||
if not user:
|
||
return ""
|
||
kopeks = int(prize_value) if prize_value.isdigit() else 0
|
||
if kopeks > 0:
|
||
user.balance_kopeks += kopeks
|
||
await db.commit()
|
||
return texts.t(
|
||
"CONTEST_BALANCE_GRANTED",
|
||
"Бонус {amount} зачислен!"
|
||
).format(amount=settings.format_price(kopeks))
|
||
|
||
elif prize_type == PrizeType.CUSTOM.value:
|
||
return f"🎁 {prize_value}"
|
||
|
||
return ""
|
||
|
||
|
||
# Singleton instance
|
||
contest_attempt_service = ContestAttemptService()
|