86dd18fbe7
Рефакторинг архитектуры ежедневных конкурсов: - Создан модуль 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()
|