Files
remnawave-bedolaga-telegram…/app/services/contests/attempt_service.py
T
Fringg 1f0fef114b refactor: complete structlog migration with contextvars, kwargs, and logging hardening
- Add ContextVarsMiddleware for automatic user_id/chat_id/username binding
  via structlog contextvars (aiogram) and http_method/http_path (FastAPI)
- Use bound_contextvars() context manager instead of clear_contextvars()
  to safely restore previous state instead of wiping all context
- Register ContextVarsMiddleware as outermost middleware (before GlobalError)
  so all error logs include user context
- Replace structlog.get_logger() with structlog.get_logger(__name__) across
  270 calls in 265 files for meaningful logger names
- Switch wrapper_class from BoundLogger to make_filtering_bound_logger()
  for pre-processor level filtering (performance optimization)
- Migrate 1411 %-style positional arg logger calls to structlog kwargs
  style across 161 files via AST script
- Migrate log_rotation_service.py from stdlib logging to structlog
- Add payment module prefixes to TelegramNotifierProcessor.IGNORED_LOGGER_PREFIXES
  and ExcludePaymentFilter.PAYMENT_MODULES to prevent payment data leaking
  to Telegram notifications and general log files
- Fix LoggingMiddleware: add from_user null-safety for channel posts,
  switch time.time() to time.monotonic() for duration measurement
- Remove duplicate logger assignments in purchase.py, config.py,
  inline.py, and admin/payments.py
2026-02-16 09:18:12 +03:00

323 lines
9.7 KiB
Python

"""Service for atomic contest attempt operations."""
from dataclasses import dataclass
import structlog
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 = structlog.get_logger(__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 , round , pick '', winner",
user_id=user_id,
round_obj_id=round_obj.id,
pick=pick,
is_winner=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 , round , answer '', winner",
user_id=user_id,
round_obj_id=round_obj.id,
text_answer=text_answer,
is_winner=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,
) -> ContestAttempt | None:
"""
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)
if 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()