mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-20 11:50:27 +00:00
633 lines
25 KiB
Python
633 lines
25 KiB
Python
"""
|
||
Сервис колеса удачи (Fortune Wheel) с RTP алгоритмом.
|
||
"""
|
||
import logging
|
||
import random
|
||
import secrets
|
||
from dataclasses import dataclass
|
||
from datetime import datetime, timedelta
|
||
from decimal import Decimal, ROUND_HALF_UP
|
||
from typing import Optional, List, Tuple, Dict, Any
|
||
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.config import settings
|
||
from app.database.models import (
|
||
User,
|
||
Subscription,
|
||
WheelConfig,
|
||
WheelPrize,
|
||
WheelSpin,
|
||
WheelPrizeType,
|
||
WheelSpinPaymentType,
|
||
PromoCode,
|
||
PromoCodeType,
|
||
)
|
||
from app.database.crud.wheel import (
|
||
get_or_create_wheel_config,
|
||
get_wheel_prizes,
|
||
get_user_spins_today,
|
||
create_wheel_spin,
|
||
mark_spin_applied,
|
||
get_wheel_statistics,
|
||
)
|
||
from app.database.crud.user import add_user_balance
|
||
from app.database.crud.subscription import get_subscription_by_user_id
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
@dataclass
|
||
class SpinResult:
|
||
"""Результат спина колеса."""
|
||
success: bool
|
||
prize_id: Optional[int] = None
|
||
prize_type: Optional[str] = None
|
||
prize_value: int = 0
|
||
prize_display_name: str = ""
|
||
emoji: str = "🎁"
|
||
color: str = "#3B82F6"
|
||
rotation_degrees: float = 0.0
|
||
message: str = ""
|
||
promocode: Optional[str] = None
|
||
error: Optional[str] = None
|
||
|
||
|
||
@dataclass
|
||
class SpinAvailability:
|
||
"""Доступность спина для пользователя."""
|
||
can_spin: bool
|
||
reason: Optional[str] = None
|
||
spins_remaining_today: int = 0
|
||
can_pay_stars: bool = False
|
||
can_pay_days: bool = False
|
||
min_subscription_days: int = 0
|
||
user_subscription_days: int = 0
|
||
user_balance_kopeks: int = 0
|
||
required_balance_kopeks: int = 0
|
||
|
||
|
||
class FortuneWheelService:
|
||
"""Сервис колеса удачи с RTP механикой."""
|
||
|
||
def __init__(self):
|
||
pass
|
||
|
||
async def check_availability(
|
||
self,
|
||
db: AsyncSession,
|
||
user: User
|
||
) -> SpinAvailability:
|
||
"""Проверить доступность спина для пользователя."""
|
||
config = await get_or_create_wheel_config(db)
|
||
|
||
# Колесо выключено
|
||
if not config.is_enabled:
|
||
return SpinAvailability(
|
||
can_spin=False,
|
||
reason="wheel_disabled",
|
||
)
|
||
|
||
# Проверяем лимит спинов
|
||
spins_today = await get_user_spins_today(db, user.id)
|
||
spins_remaining = config.daily_spin_limit - spins_today if config.daily_spin_limit > 0 else 999
|
||
|
||
if config.daily_spin_limit > 0 and spins_today >= config.daily_spin_limit:
|
||
return SpinAvailability(
|
||
can_spin=False,
|
||
reason="daily_limit_reached",
|
||
spins_remaining_today=0,
|
||
)
|
||
|
||
# Проверяем доступные способы оплаты
|
||
can_pay_stars = False
|
||
can_pay_days = False
|
||
user_subscription_days = 0
|
||
required_balance_kopeks = 0
|
||
|
||
# Проверяем оплату Stars (конвертируется в рубли из баланса)
|
||
if config.spin_cost_stars_enabled and config.spin_cost_stars > 0:
|
||
stars_rate = Decimal(str(settings.get_stars_rate()))
|
||
rubles = Decimal(config.spin_cost_stars) * stars_rate
|
||
required_balance_kopeks = int(rubles * 100)
|
||
# Проверяем достаточно ли средств на балансе
|
||
if user.balance_kopeks >= required_balance_kopeks:
|
||
can_pay_stars = True
|
||
|
||
if config.spin_cost_days_enabled:
|
||
subscription = await get_subscription_by_user_id(db, user.id)
|
||
if subscription and subscription.is_active:
|
||
user_subscription_days = subscription.days_left
|
||
# Нужно оставить минимум min_subscription_days_for_day_payment дней после оплаты
|
||
if user_subscription_days >= config.min_subscription_days_for_day_payment + config.spin_cost_days:
|
||
can_pay_days = True
|
||
|
||
if not can_pay_stars and not can_pay_days:
|
||
# Определяем причину
|
||
reason = "no_payment_method_available"
|
||
if config.spin_cost_stars_enabled and user.balance_kopeks < required_balance_kopeks:
|
||
reason = "insufficient_balance"
|
||
|
||
return SpinAvailability(
|
||
can_spin=False,
|
||
reason=reason,
|
||
spins_remaining_today=spins_remaining,
|
||
can_pay_stars=can_pay_stars,
|
||
can_pay_days=can_pay_days,
|
||
min_subscription_days=config.min_subscription_days_for_day_payment,
|
||
user_subscription_days=user_subscription_days,
|
||
user_balance_kopeks=user.balance_kopeks,
|
||
required_balance_kopeks=required_balance_kopeks,
|
||
)
|
||
|
||
# Проверяем наличие призов
|
||
prizes = await get_wheel_prizes(db, config.id, active_only=True)
|
||
if not prizes:
|
||
return SpinAvailability(
|
||
can_spin=False,
|
||
reason="no_prizes_configured",
|
||
)
|
||
|
||
return SpinAvailability(
|
||
can_spin=True,
|
||
spins_remaining_today=spins_remaining,
|
||
can_pay_stars=can_pay_stars,
|
||
can_pay_days=can_pay_days,
|
||
min_subscription_days=config.min_subscription_days_for_day_payment,
|
||
user_subscription_days=user_subscription_days,
|
||
user_balance_kopeks=user.balance_kopeks,
|
||
required_balance_kopeks=required_balance_kopeks,
|
||
)
|
||
|
||
def calculate_prize_probabilities(
|
||
self,
|
||
config: WheelConfig,
|
||
prizes: List[WheelPrize],
|
||
spin_cost_kopeks: int
|
||
) -> List[Tuple[WheelPrize, float]]:
|
||
"""
|
||
Рассчитать вероятности выпадения призов на основе RTP.
|
||
|
||
Алгоритм:
|
||
1. Целевая средняя выплата = spin_cost * (RTP / 100)
|
||
2. Для призов с manual_probability - используем его напрямую
|
||
3. Для остальных - рассчитываем веса обратно пропорционально стоимости приза
|
||
4. "Nothing" сектор балансирует систему
|
||
"""
|
||
if not prizes:
|
||
return []
|
||
|
||
target_payout = spin_cost_kopeks * (config.rtp_percent / 100)
|
||
|
||
# Разделяем призы с ручной вероятностью и автоматической
|
||
manual_prizes = []
|
||
auto_prizes = []
|
||
manual_prob_sum = 0.0
|
||
|
||
for prize in prizes:
|
||
if prize.manual_probability is not None and prize.manual_probability > 0:
|
||
manual_prizes.append((prize, prize.manual_probability))
|
||
manual_prob_sum += prize.manual_probability
|
||
else:
|
||
auto_prizes.append(prize)
|
||
|
||
# Оставшаяся вероятность для авто-призов
|
||
remaining_prob = max(0, 1.0 - manual_prob_sum)
|
||
|
||
if not auto_prizes or remaining_prob <= 0:
|
||
# Только ручные призы, нормализуем их
|
||
if manual_prizes:
|
||
total = sum(p[1] for p in manual_prizes)
|
||
return [(p[0], p[1] / total) for p in manual_prizes]
|
||
return []
|
||
|
||
# Рассчитываем веса для авто-призов
|
||
# Вес обратно пропорционален стоимости приза (более дорогие выпадают реже)
|
||
weights = []
|
||
for prize in auto_prizes:
|
||
if prize.prize_value_kopeks > 0:
|
||
# Чем дороже приз, тем меньше вес
|
||
weight = target_payout / prize.prize_value_kopeks
|
||
else:
|
||
# "Nothing" или нулевой приз - даем базовый вес
|
||
weight = 1.0
|
||
weights.append((prize, max(weight, 0.01))) # Минимальный вес 1%
|
||
|
||
# Нормализуем веса авто-призов до remaining_prob
|
||
total_weight = sum(w[1] for w in weights)
|
||
auto_probabilities = [
|
||
(prize, (weight / total_weight) * remaining_prob)
|
||
for prize, weight in weights
|
||
]
|
||
|
||
# Объединяем
|
||
result = manual_prizes + auto_probabilities
|
||
|
||
# Финальная нормализация (на случай погрешностей)
|
||
total = sum(p[1] for p in result)
|
||
if total > 0:
|
||
result = [(p[0], p[1] / total) for p in result]
|
||
|
||
return result
|
||
|
||
def _select_prize(
|
||
self,
|
||
prizes_with_probabilities: List[Tuple[WheelPrize, float]]
|
||
) -> WheelPrize:
|
||
"""Выбрать приз на основе вероятностей."""
|
||
if not prizes_with_probabilities:
|
||
raise ValueError("No prizes to select from")
|
||
|
||
rand = random.random()
|
||
cumulative = 0.0
|
||
|
||
for prize, probability in prizes_with_probabilities:
|
||
cumulative += probability
|
||
if rand <= cumulative:
|
||
return prize
|
||
|
||
# Fallback на последний приз
|
||
return prizes_with_probabilities[-1][0]
|
||
|
||
def _calculate_rotation(
|
||
self,
|
||
prizes: List[WheelPrize],
|
||
selected_prize: WheelPrize
|
||
) -> float:
|
||
"""
|
||
Рассчитать угол поворота колеса для анимации.
|
||
Возвращает градусы для CSS transform.
|
||
"""
|
||
if not prizes:
|
||
return 0.0
|
||
|
||
# Находим индекс выбранного приза
|
||
prize_index = next(
|
||
(i for i, p in enumerate(prizes) if p.id == selected_prize.id),
|
||
0
|
||
)
|
||
|
||
# Угол одного сектора
|
||
sector_angle = 360 / len(prizes)
|
||
|
||
# Базовый угол до центра сектора (от 12 часов по часовой)
|
||
base_angle = prize_index * sector_angle + sector_angle / 2
|
||
|
||
# Добавляем случайное смещение внутри сектора (не по краям)
|
||
offset = random.uniform(-sector_angle * 0.3, sector_angle * 0.3)
|
||
|
||
# Угол остановки (стрелка сверху, поэтому инвертируем)
|
||
stop_angle = 360 - base_angle + offset
|
||
|
||
# Добавляем несколько полных оборотов для эффекта
|
||
full_rotations = random.randint(5, 8) * 360
|
||
|
||
return full_rotations + stop_angle
|
||
|
||
async def _process_stars_payment(
|
||
self,
|
||
db: AsyncSession,
|
||
user: User,
|
||
config: WheelConfig
|
||
) -> int:
|
||
"""
|
||
Обработать оплату Stars (списание эквивалента с баланса).
|
||
Возвращает стоимость в копейках.
|
||
"""
|
||
# Конвертируем Stars в рубли
|
||
stars_rate = Decimal(str(settings.get_stars_rate()))
|
||
rubles = Decimal(config.spin_cost_stars) * stars_rate
|
||
kopeks = int(rubles * 100)
|
||
|
||
if user.balance_kopeks < kopeks:
|
||
raise ValueError("Недостаточно средств на балансе")
|
||
|
||
# Списываем с баланса
|
||
user.balance_kopeks -= kopeks
|
||
logger.info(f"💫 Списано {kopeks/100:.2f}₽ ({config.spin_cost_stars}⭐) с баланса user_id={user.id}")
|
||
|
||
return kopeks
|
||
|
||
async def _process_days_payment(
|
||
self,
|
||
db: AsyncSession,
|
||
user: User,
|
||
config: WheelConfig
|
||
) -> int:
|
||
"""
|
||
Обработать оплату днями подписки.
|
||
Возвращает эквивалент в копейках.
|
||
"""
|
||
subscription = await get_subscription_by_user_id(db, user.id)
|
||
|
||
if not subscription or not subscription.is_active:
|
||
raise ValueError("Нет активной подписки")
|
||
|
||
if subscription.days_left < config.min_subscription_days_for_day_payment + config.spin_cost_days:
|
||
raise ValueError("Недостаточно дней подписки")
|
||
|
||
# Уменьшаем end_date
|
||
subscription.end_date -= timedelta(days=config.spin_cost_days)
|
||
subscription.updated_at = datetime.utcnow()
|
||
|
||
# Оцениваем стоимость в копейках (для статистики)
|
||
# Берем цену 30-дневного периода и делим на 30
|
||
from app.config import PERIOD_PRICES
|
||
price_30_days = PERIOD_PRICES.get(30, settings.PRICE_30_DAYS) or 19900
|
||
daily_price = price_30_days / 30
|
||
kopeks = int(daily_price * config.spin_cost_days)
|
||
|
||
logger.info(f"📅 Списано {config.spin_cost_days} дней подписки у user_id={user.id}")
|
||
|
||
return kopeks
|
||
|
||
async def _apply_prize(
|
||
self,
|
||
db: AsyncSession,
|
||
user: User,
|
||
prize: WheelPrize,
|
||
config: WheelConfig
|
||
) -> Optional[str]:
|
||
"""
|
||
Применить приз к пользователю.
|
||
Возвращает промокод (если приз - промокод), иначе None.
|
||
"""
|
||
prize_type = prize.prize_type
|
||
|
||
if prize_type == WheelPrizeType.NOTHING.value:
|
||
logger.info(f"🎰 Пустой приз для user_id={user.id}")
|
||
return None
|
||
|
||
if prize_type == WheelPrizeType.BALANCE_BONUS.value:
|
||
# Пополнение баланса
|
||
await add_user_balance(
|
||
db, user, prize.prize_value,
|
||
description=f"Выигрыш в колесе удачи: {prize.prize_value/100:.2f}₽",
|
||
create_transaction=True,
|
||
)
|
||
logger.info(f"💰 Начислено {prize.prize_value/100:.2f}₽ на баланс user_id={user.id}")
|
||
return None
|
||
|
||
if prize_type == WheelPrizeType.SUBSCRIPTION_DAYS.value:
|
||
# Дни подписки
|
||
subscription = await get_subscription_by_user_id(db, user.id)
|
||
if subscription:
|
||
subscription.end_date += timedelta(days=prize.prize_value)
|
||
subscription.updated_at = datetime.utcnow()
|
||
logger.info(f"📅 Начислено {prize.prize_value} дней подписки user_id={user.id}")
|
||
else:
|
||
# Если нет подписки - начисляем на баланс эквивалент
|
||
await add_user_balance(
|
||
db, user, prize.prize_value_kopeks,
|
||
description=f"Выигрыш в колесе удачи: {prize.prize_value} дней (на баланс)",
|
||
create_transaction=True,
|
||
)
|
||
logger.info(f"💰 Дни конвертированы в баланс для user_id={user.id}")
|
||
return None
|
||
|
||
if prize_type == WheelPrizeType.TRAFFIC_GB.value:
|
||
# Бонусный трафик
|
||
subscription = await get_subscription_by_user_id(db, user.id)
|
||
if subscription and subscription.traffic_limit_gb > 0:
|
||
subscription.traffic_limit_gb += prize.prize_value
|
||
subscription.updated_at = datetime.utcnow()
|
||
logger.info(f"📊 Начислено {prize.prize_value}GB трафика user_id={user.id}")
|
||
else:
|
||
# Если безлимит или нет подписки - на баланс
|
||
await add_user_balance(
|
||
db, user, prize.prize_value_kopeks,
|
||
description=f"Выигрыш в колесе удачи: {prize.prize_value}GB (на баланс)",
|
||
create_transaction=True,
|
||
)
|
||
return None
|
||
|
||
if prize_type == WheelPrizeType.PROMOCODE.value:
|
||
# Генерация промокода
|
||
promocode = await self._generate_prize_promocode(db, user, prize, config)
|
||
logger.info(f"🎟️ Сгенерирован промокод {promocode.code} для user_id={user.id}")
|
||
return promocode.code
|
||
|
||
return None
|
||
|
||
async def _generate_prize_promocode(
|
||
self,
|
||
db: AsyncSession,
|
||
user: User,
|
||
prize: WheelPrize,
|
||
config: WheelConfig
|
||
) -> PromoCode:
|
||
"""Сгенерировать уникальный промокод для приза."""
|
||
# Генерируем уникальный код
|
||
code = f"{config.promo_prefix}{secrets.token_hex(4).upper()}"
|
||
|
||
# Определяем тип промокода
|
||
if prize.promo_subscription_days > 0:
|
||
promo_type = PromoCodeType.SUBSCRIPTION_DAYS.value
|
||
else:
|
||
promo_type = PromoCodeType.BALANCE.value
|
||
|
||
promocode = PromoCode(
|
||
code=code,
|
||
type=promo_type,
|
||
balance_bonus_kopeks=prize.promo_balance_bonus_kopeks,
|
||
subscription_days=prize.promo_subscription_days,
|
||
max_uses=1,
|
||
valid_until=datetime.utcnow() + timedelta(days=config.promo_validity_days),
|
||
is_active=True,
|
||
created_by=user.id,
|
||
)
|
||
|
||
db.add(promocode)
|
||
await db.flush()
|
||
|
||
return promocode
|
||
|
||
async def spin(
|
||
self,
|
||
db: AsyncSession,
|
||
user: User,
|
||
payment_type: str
|
||
) -> SpinResult:
|
||
"""
|
||
Выполнить спин колеса.
|
||
|
||
Шаги:
|
||
1. Проверить доступность
|
||
2. Обработать оплату
|
||
3. Рассчитать вероятности и выбрать приз
|
||
4. Применить приз
|
||
5. Создать запись WheelSpin
|
||
6. Вернуть результат
|
||
"""
|
||
try:
|
||
# 1. Проверяем доступность
|
||
availability = await self.check_availability(db, user)
|
||
if not availability.can_spin:
|
||
return SpinResult(
|
||
success=False,
|
||
error=availability.reason,
|
||
message=self._get_error_message(availability.reason),
|
||
)
|
||
|
||
config = await get_or_create_wheel_config(db)
|
||
prizes = await get_wheel_prizes(db, config.id, active_only=True)
|
||
|
||
if not prizes:
|
||
return SpinResult(
|
||
success=False,
|
||
error="no_prizes",
|
||
message="Призы не настроены",
|
||
)
|
||
|
||
# 2. Обрабатываем оплату
|
||
if payment_type == WheelSpinPaymentType.TELEGRAM_STARS.value:
|
||
if not availability.can_pay_stars:
|
||
return SpinResult(
|
||
success=False,
|
||
error="cannot_pay_stars",
|
||
message="Оплата Stars недоступна",
|
||
)
|
||
payment_amount = config.spin_cost_stars
|
||
payment_value_kopeks = await self._process_stars_payment(db, user, config)
|
||
elif payment_type == WheelSpinPaymentType.SUBSCRIPTION_DAYS.value:
|
||
if not availability.can_pay_days:
|
||
return SpinResult(
|
||
success=False,
|
||
error="cannot_pay_days",
|
||
message="Оплата днями подписки недоступна",
|
||
)
|
||
payment_amount = config.spin_cost_days
|
||
payment_value_kopeks = await self._process_days_payment(db, user, config)
|
||
else:
|
||
return SpinResult(
|
||
success=False,
|
||
error="invalid_payment_type",
|
||
message="Неверный способ оплаты",
|
||
)
|
||
|
||
# 3. Рассчитываем вероятности и выбираем приз
|
||
prizes_with_probs = self.calculate_prize_probabilities(config, prizes, payment_value_kopeks)
|
||
selected_prize = self._select_prize(prizes_with_probs)
|
||
|
||
# 4. Рассчитываем угол для анимации
|
||
rotation = self._calculate_rotation(prizes, selected_prize)
|
||
|
||
# 5. Применяем приз
|
||
generated_promocode = await self._apply_prize(db, user, selected_prize, config)
|
||
promocode_id = None
|
||
if generated_promocode:
|
||
# Получаем ID промокода
|
||
result = await db.execute(
|
||
f"SELECT id FROM promocodes WHERE code = '{generated_promocode}'"
|
||
)
|
||
row = result.fetchone()
|
||
if row:
|
||
promocode_id = row[0]
|
||
|
||
# 6. Создаем запись спина
|
||
spin = await create_wheel_spin(
|
||
db=db,
|
||
user_id=user.id,
|
||
prize_id=selected_prize.id,
|
||
payment_type=payment_type,
|
||
payment_amount=payment_amount,
|
||
payment_value_kopeks=payment_value_kopeks,
|
||
prize_type=selected_prize.prize_type,
|
||
prize_value=selected_prize.prize_value,
|
||
prize_display_name=selected_prize.display_name,
|
||
prize_value_kopeks=selected_prize.prize_value_kopeks,
|
||
generated_promocode_id=promocode_id,
|
||
is_applied=True,
|
||
)
|
||
|
||
await db.commit()
|
||
|
||
# 7. Формируем результат
|
||
message = self._get_prize_message(selected_prize, generated_promocode)
|
||
|
||
return SpinResult(
|
||
success=True,
|
||
prize_id=selected_prize.id,
|
||
prize_type=selected_prize.prize_type,
|
||
prize_value=selected_prize.prize_value,
|
||
prize_display_name=selected_prize.display_name,
|
||
emoji=selected_prize.emoji,
|
||
color=selected_prize.color,
|
||
rotation_degrees=rotation,
|
||
message=message,
|
||
promocode=generated_promocode,
|
||
)
|
||
|
||
except ValueError as e:
|
||
await db.rollback()
|
||
return SpinResult(
|
||
success=False,
|
||
error="payment_error",
|
||
message=str(e),
|
||
)
|
||
except Exception as e:
|
||
await db.rollback()
|
||
logger.exception(f"Ошибка спина колеса для user_id={user.id}: {e}")
|
||
return SpinResult(
|
||
success=False,
|
||
error="internal_error",
|
||
message="Произошла ошибка, попробуйте позже",
|
||
)
|
||
|
||
def _get_error_message(self, reason: Optional[str]) -> str:
|
||
"""Получить человекочитаемое сообщение об ошибке."""
|
||
messages = {
|
||
"wheel_disabled": "Колесо удачи временно недоступно",
|
||
"daily_limit_reached": "Вы достигли лимита спинов на сегодня",
|
||
"no_payment_method_available": "Нет доступных способов оплаты",
|
||
"no_prizes_configured": "Призы еще не настроены",
|
||
"insufficient_balance": "Недостаточно средств на балансе. Пополните баланс для оплаты спина.",
|
||
}
|
||
return messages.get(reason, "Произошла ошибка")
|
||
|
||
def _get_prize_message(self, prize: WheelPrize, promocode: Optional[str]) -> str:
|
||
"""Сформировать сообщение о выигрыше."""
|
||
prize_type = prize.prize_type
|
||
|
||
if prize_type == WheelPrizeType.NOTHING.value:
|
||
return "К сожалению, в этот раз не повезло. Попробуйте еще!"
|
||
|
||
if prize_type == WheelPrizeType.BALANCE_BONUS.value:
|
||
return f"Поздравляем! Вы выиграли {prize.prize_value/100:.0f}₽ на баланс!"
|
||
|
||
if prize_type == WheelPrizeType.SUBSCRIPTION_DAYS.value:
|
||
days_word = self._pluralize_days(prize.prize_value)
|
||
return f"Поздравляем! Вы выиграли {prize.prize_value} {days_word} подписки!"
|
||
|
||
if prize_type == WheelPrizeType.TRAFFIC_GB.value:
|
||
return f"Поздравляем! Вы выиграли {prize.prize_value}GB трафика!"
|
||
|
||
if prize_type == WheelPrizeType.PROMOCODE.value:
|
||
return f"Поздравляем! Ваш промокод: {promocode}"
|
||
|
||
return "Поздравляем с выигрышем!"
|
||
|
||
def _pluralize_days(self, n: int) -> str:
|
||
"""Склонение слова 'день'."""
|
||
if 11 <= n % 100 <= 19:
|
||
return "дней"
|
||
elif n % 10 == 1:
|
||
return "день"
|
||
elif 2 <= n % 10 <= 4:
|
||
return "дня"
|
||
else:
|
||
return "дней"
|
||
|
||
async def get_statistics(
|
||
self,
|
||
db: AsyncSession,
|
||
date_from: Optional[datetime] = None,
|
||
date_to: Optional[datetime] = None
|
||
) -> Dict[str, Any]:
|
||
"""Получить статистику колеса."""
|
||
return await get_wheel_statistics(db, date_from, date_to)
|
||
|
||
|
||
# Глобальный экземпляр сервиса
|
||
wheel_service = FortuneWheelService()
|