""" Сервис колеса удачи (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()