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>
389 lines
14 KiB
Python
389 lines
14 KiB
Python
import asyncio
|
|
import logging
|
|
from datetime import datetime, timedelta, time, timezone
|
|
from typing import Dict, List, Optional
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from aiogram import Bot
|
|
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.config import settings
|
|
from app.database.crud.contest import (
|
|
create_round,
|
|
get_active_round_by_template,
|
|
list_templates,
|
|
upsert_template,
|
|
)
|
|
from app.database.database import AsyncSessionLocal
|
|
from app.database.models import ContestTemplate, SubscriptionStatus, User
|
|
from app.services.contests.enums import GameType, PrizeType, RoundStatus
|
|
from app.services.contests.games import get_game_strategy
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Legacy aliases for backward compatibility
|
|
GAME_QUEST = GameType.QUEST_BUTTONS.value
|
|
GAME_LOCKS = GameType.LOCK_HACK.value
|
|
GAME_CIPHER = GameType.LETTER_CIPHER.value
|
|
GAME_SERVER = GameType.SERVER_LOTTERY.value
|
|
GAME_BLITZ = GameType.BLITZ_REACTION.value
|
|
GAME_EMOJI = GameType.EMOJI_GUESS.value
|
|
GAME_ANAGRAM = GameType.ANAGRAM.value
|
|
|
|
|
|
DEFAULT_TEMPLATES = [
|
|
{
|
|
"slug": GAME_QUEST,
|
|
"name": "Квест-кнопки",
|
|
"description": "Найди секретную кнопку 3×3",
|
|
"prize_type": "days",
|
|
"prize_value": "1",
|
|
"max_winners": 3,
|
|
"attempts_per_user": 1,
|
|
"times_per_day": 2,
|
|
"schedule_times": "10:00,18:00",
|
|
"payload": {"rows": 3, "cols": 3},
|
|
"is_enabled": False,
|
|
},
|
|
{
|
|
"slug": GAME_LOCKS,
|
|
"name": "Кнопочный взлом",
|
|
"description": "Найди взломанную кнопку среди 20 замков",
|
|
"prize_type": "days",
|
|
"prize_value": "5",
|
|
"max_winners": 1,
|
|
"attempts_per_user": 1,
|
|
"times_per_day": 2,
|
|
"schedule_times": "09:00,19:00",
|
|
"payload": {"buttons": 20},
|
|
"is_enabled": False,
|
|
},
|
|
{
|
|
"slug": GAME_CIPHER,
|
|
"name": "Шифр букв",
|
|
"description": "Расшифруй слово по номерам",
|
|
"prize_type": "days",
|
|
"prize_value": "1",
|
|
"max_winners": 1,
|
|
"attempts_per_user": 1,
|
|
"times_per_day": 2,
|
|
"schedule_times": "12:00,20:00",
|
|
"payload": {"words": ["VPN", "SERVER", "PROXY", "XRAY"]},
|
|
"is_enabled": False,
|
|
},
|
|
{
|
|
"slug": GAME_SERVER,
|
|
"name": "Сервер-лотерея",
|
|
"description": "Угадай доступный сервер",
|
|
"prize_type": "days",
|
|
"prize_value": "7",
|
|
"max_winners": 1,
|
|
"attempts_per_user": 1,
|
|
"times_per_day": 1,
|
|
"schedule_times": "15:00",
|
|
"payload": {"flags": ["🇸🇪","🇸🇬","🇺🇸","🇷🇺","🇩🇪","🇯🇵","🇧🇷","🇦🇺","🇨🇦","🇫🇷"]},
|
|
"is_enabled": False,
|
|
},
|
|
{
|
|
"slug": GAME_BLITZ,
|
|
"name": "Блиц-реакция",
|
|
"description": "Нажми кнопку за 10 секунд",
|
|
"prize_type": "days",
|
|
"prize_value": "1",
|
|
"max_winners": 1,
|
|
"attempts_per_user": 1,
|
|
"times_per_day": 2,
|
|
"schedule_times": "11:00,21:00",
|
|
"payload": {"timeout_seconds": 10},
|
|
"is_enabled": False,
|
|
},
|
|
{
|
|
"slug": GAME_EMOJI,
|
|
"name": "Угадай сервис по эмодзи",
|
|
"description": "Определи сервис по эмодзи",
|
|
"prize_type": "days",
|
|
"prize_value": "1",
|
|
"max_winners": 1,
|
|
"attempts_per_user": 1,
|
|
"times_per_day": 1,
|
|
"schedule_times": "13:00",
|
|
"payload": {"pairs": [{"question": "🔐📡🌐", "answer": "VPN"}]},
|
|
"is_enabled": False,
|
|
},
|
|
{
|
|
"slug": GAME_ANAGRAM,
|
|
"name": "Анаграмма дня",
|
|
"description": "Собери слово из букв",
|
|
"prize_type": "days",
|
|
"prize_value": "1",
|
|
"max_winners": 1,
|
|
"attempts_per_user": 1,
|
|
"times_per_day": 1,
|
|
"schedule_times": "17:00",
|
|
"payload": {"words": ["SERVER", "XRAY", "VPN"]},
|
|
"is_enabled": False,
|
|
},
|
|
]
|
|
|
|
|
|
class ContestRotationService:
|
|
def __init__(self) -> None:
|
|
self.bot: Optional[Bot] = None
|
|
self._task: Optional[asyncio.Task] = None
|
|
self._interval_seconds = 60
|
|
|
|
def is_running(self) -> bool:
|
|
return self._task is not None and not self._task.done()
|
|
|
|
def set_bot(self, bot: Bot) -> None:
|
|
self.bot = bot
|
|
|
|
async def start(self) -> None:
|
|
await self.stop()
|
|
|
|
if not settings.is_contests_enabled():
|
|
logger.info("Сервис игр отключён настройками")
|
|
return
|
|
|
|
await self._ensure_default_templates()
|
|
|
|
self._task = asyncio.create_task(self._loop())
|
|
logger.info("🎲 Сервис ротационных конкурсов запущен")
|
|
|
|
async def stop(self) -> None:
|
|
if self._task and not self._task.done():
|
|
self._task.cancel()
|
|
try:
|
|
await self._task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
self._task = None
|
|
|
|
async def _ensure_default_templates(self) -> None:
|
|
async with AsyncSessionLocal() as db:
|
|
for tpl in DEFAULT_TEMPLATES:
|
|
try:
|
|
await upsert_template(db, **tpl)
|
|
except Exception as exc:
|
|
logger.error("Не удалось создать шаблон %s: %s", tpl["slug"], exc)
|
|
|
|
async def _loop(self) -> None:
|
|
try:
|
|
while True:
|
|
try:
|
|
await self._tick()
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.error("Ошибка в ротации конкурсов: %s", exc)
|
|
await asyncio.sleep(self._interval_seconds)
|
|
except asyncio.CancelledError:
|
|
logger.info("Сервис ротации конкурсов остановлен")
|
|
raise
|
|
|
|
def _parse_times(self, times_str: Optional[str]) -> List[time]:
|
|
if not times_str:
|
|
return []
|
|
times: List[time] = []
|
|
for part in times_str.split(","):
|
|
part = part.strip()
|
|
if not part:
|
|
continue
|
|
try:
|
|
hh, mm = part.split(":")
|
|
times.append(time(int(hh), int(mm)))
|
|
except Exception:
|
|
continue
|
|
return times
|
|
|
|
async def _tick(self) -> None:
|
|
async with AsyncSessionLocal() as db:
|
|
templates = await list_templates(db)
|
|
# Get current time in configured timezone
|
|
tz = self._get_timezone()
|
|
now_utc = datetime.now(timezone.utc)
|
|
now_local = now_utc.astimezone(tz)
|
|
|
|
for tpl in templates:
|
|
times = self._parse_times(tpl.schedule_times) or []
|
|
for slot in times[: tpl.times_per_day]:
|
|
# Apply schedule time to local date
|
|
starts_at_local = now_local.replace(
|
|
hour=slot.hour, minute=slot.minute, second=0, microsecond=0
|
|
)
|
|
if starts_at_local > now_local:
|
|
starts_at_local -= timedelta(days=1)
|
|
ends_at_local = starts_at_local + timedelta(hours=tpl.cooldown_hours)
|
|
if not (starts_at_local <= now_local <= ends_at_local):
|
|
continue
|
|
|
|
exists = await get_active_round_by_template(db, tpl.id)
|
|
if exists:
|
|
continue
|
|
|
|
# Convert to UTC for storage
|
|
starts_at_utc = starts_at_local.astimezone(timezone.utc).replace(tzinfo=None)
|
|
ends_at_utc = ends_at_local.astimezone(timezone.utc).replace(tzinfo=None)
|
|
|
|
# Анонс перед созданием раунда
|
|
await self._announce_round_start(tpl, starts_at_local, ends_at_local)
|
|
payload = self._build_payload_for_template(tpl)
|
|
round_obj = await create_round(
|
|
db,
|
|
template=tpl,
|
|
starts_at=starts_at_utc,
|
|
ends_at=ends_at_utc,
|
|
payload=payload,
|
|
)
|
|
logger.info("Создан раунд %s для шаблона %s", round_obj.id, tpl.slug)
|
|
|
|
def _get_timezone(self) -> ZoneInfo:
|
|
tz_name = settings.TIMEZONE or "UTC"
|
|
try:
|
|
return ZoneInfo(tz_name)
|
|
except Exception:
|
|
logger.warning("Не удалось загрузить TZ %s, используем UTC", tz_name)
|
|
return ZoneInfo("UTC")
|
|
|
|
def _build_payload_for_template(self, tpl: ContestTemplate) -> Dict:
|
|
"""Build round-specific payload using game strategy."""
|
|
strategy = get_game_strategy(tpl.slug)
|
|
if strategy:
|
|
return strategy.build_payload(tpl.payload or {})
|
|
# Fallback for unknown game types
|
|
return tpl.payload or {}
|
|
|
|
async def _announce_round_start(
|
|
self,
|
|
tpl: ContestTemplate,
|
|
starts_at_local: datetime,
|
|
ends_at_local: datetime,
|
|
) -> None:
|
|
if not self.bot:
|
|
return
|
|
|
|
from app.localization.texts import get_texts
|
|
texts = get_texts("ru") # Default to ru for announcements
|
|
|
|
# Format prize display based on prize_type
|
|
prize_type = tpl.prize_type or PrizeType.DAYS.value
|
|
prize_value = tpl.prize_value or "1"
|
|
|
|
if prize_type == PrizeType.DAYS.value:
|
|
prize_display = f"{prize_value} {texts.t('DAYS', 'дн. подписки')}"
|
|
elif prize_type == PrizeType.BALANCE.value:
|
|
prize_display = f"{prize_value} коп."
|
|
elif prize_type == PrizeType.CUSTOM.value:
|
|
prize_display = prize_value
|
|
else:
|
|
prize_display = prize_value
|
|
|
|
text = (
|
|
f"🎲 {texts.t('CONTEST_START_ANNOUNCEMENT', 'Стартует игра')}: <b>{tpl.name}</b>\n"
|
|
f"{texts.t('CONTEST_PRIZE', 'Приз')}: {prize_display} • {texts.t('CONTEST_WINNERS', 'Победителей')}: {tpl.max_winners}\n"
|
|
f"{texts.t('CONTEST_ATTEMPTS', 'Попыток/польз')}: {tpl.attempts_per_user}\n\n"
|
|
f"{texts.t('CONTEST_ELIGIBILITY', 'Участвовать могут только с активной или триальной подпиской')}.\n"
|
|
f"💡 <b>{texts.t('REMINDER', 'Напоминание')}:</b> {texts.t('CONTEST_REMINDER_TEXT', 'Не забудьте участвовать в конкурсах для получения бонусов')}!"
|
|
)
|
|
|
|
await asyncio.gather(
|
|
self._send_channel_announce(text),
|
|
self._broadcast_to_users(text),
|
|
return_exceptions=True,
|
|
)
|
|
|
|
async def _send_channel_announce(self, text: str) -> None:
|
|
if not self.bot:
|
|
return
|
|
channel_id_raw = settings.CHANNEL_SUB_ID
|
|
if not channel_id_raw:
|
|
return
|
|
try:
|
|
channel_id = int(channel_id_raw)
|
|
except Exception:
|
|
channel_id = channel_id_raw
|
|
|
|
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
|
[InlineKeyboardButton(text="🎲 Играть", callback_data="contests_menu")]
|
|
])
|
|
|
|
try:
|
|
await self.bot.send_message(
|
|
chat_id=channel_id,
|
|
text=text,
|
|
disable_web_page_preview=True,
|
|
reply_markup=keyboard,
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.error("Не удалось отправить анонс в канал %s: %s", channel_id_raw, exc)
|
|
|
|
async def _broadcast_to_users(self, text: str) -> None:
|
|
"""Отправляет анонс всем пользователям с активной/триальной подпиской."""
|
|
if not self.bot:
|
|
return
|
|
|
|
try:
|
|
batch_size = 500
|
|
offset = 0
|
|
sent = failed = 0
|
|
|
|
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
|
[InlineKeyboardButton(text="🎲 Играть", callback_data="contests_menu")]
|
|
])
|
|
|
|
while True:
|
|
async with AsyncSessionLocal() as db:
|
|
users_batch = await self._load_users_batch(db, offset, batch_size)
|
|
if not users_batch:
|
|
break
|
|
offset += batch_size
|
|
|
|
tasks = []
|
|
semaphore = asyncio.Semaphore(15)
|
|
|
|
async def _send(u: User):
|
|
nonlocal sent, failed
|
|
async with semaphore:
|
|
try:
|
|
await self.bot.send_message(
|
|
chat_id=u.telegram_id,
|
|
text=text,
|
|
disable_web_page_preview=True,
|
|
reply_markup=keyboard,
|
|
)
|
|
sent += 1
|
|
except Exception:
|
|
failed += 1
|
|
await asyncio.sleep(0.02)
|
|
|
|
for user in users_batch:
|
|
tasks.append(asyncio.create_task(_send(user)))
|
|
|
|
await asyncio.gather(*tasks, return_exceptions=True)
|
|
|
|
logger.info("Анонс игр: отправлено=%s, ошибок=%s", sent, failed)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.error("Ошибка рассылки анонса игр пользователям: %s", exc)
|
|
|
|
async def _load_users_batch(self, db: AsyncSession, offset: int, limit: int) -> List[User]:
|
|
from app.database.crud.user import get_users_list
|
|
|
|
users = await get_users_list(
|
|
db,
|
|
offset=offset,
|
|
limit=limit,
|
|
status=None,
|
|
)
|
|
allowed: List[User] = []
|
|
for u in users:
|
|
sub = getattr(u, "subscription", None)
|
|
if not sub:
|
|
continue
|
|
if sub.status in {SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value}:
|
|
allowed.append(u)
|
|
return allowed
|
|
|
|
|
|
contest_rotation_service = ContestRotationService()
|