mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-03 10:40:25 +00:00
- Обновлены схемы и маршруты для поддержки покупки тарифов и управления трафиком. - Реализована синхронизация тарифов и серверов из RemnaWave при запуске. - Добавлены новые параметры в тарифы: server_traffic_limits и allow_traffic_topup. - Обновлены настройки и логика для проверки доступности докупки трафика в зависимости от тарифа. - Внедрены новые эндпоинты для работы с колесом удачи и обработка платежей через Stars. Обновлён .env.example с новыми параметрами для режима продаж подписок.
439 lines
14 KiB
Python
439 lines
14 KiB
Python
"""
|
||
CRUD операции для колеса удачи (Fortune Wheel).
|
||
"""
|
||
import logging
|
||
from datetime import datetime, timedelta
|
||
from typing import Optional, List, Dict, Any
|
||
|
||
from sqlalchemy import select, and_, func, desc
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy.orm import selectinload
|
||
|
||
from app.database.models import (
|
||
WheelConfig,
|
||
WheelPrize,
|
||
WheelSpin,
|
||
WheelPrizeType,
|
||
WheelSpinPaymentType,
|
||
User,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
# ==================== WHEEL CONFIG ====================
|
||
|
||
|
||
async def get_wheel_config(db: AsyncSession) -> Optional[WheelConfig]:
|
||
"""Получить текущую конфигурацию колеса (всегда id=1)."""
|
||
result = await db.execute(
|
||
select(WheelConfig)
|
||
.options(selectinload(WheelConfig.prizes))
|
||
.where(WheelConfig.id == 1)
|
||
)
|
||
return result.scalar_one_or_none()
|
||
|
||
|
||
async def get_or_create_wheel_config(db: AsyncSession) -> WheelConfig:
|
||
"""Получить или создать конфигурацию колеса."""
|
||
config = await get_wheel_config(db)
|
||
if config:
|
||
return config
|
||
|
||
# Создаем дефолтную конфигурацию
|
||
config = WheelConfig(
|
||
id=1,
|
||
is_enabled=False,
|
||
name="Колесо удачи",
|
||
spin_cost_stars=10,
|
||
spin_cost_days=1,
|
||
spin_cost_stars_enabled=True,
|
||
spin_cost_days_enabled=True,
|
||
rtp_percent=80,
|
||
daily_spin_limit=5,
|
||
min_subscription_days_for_day_payment=3,
|
||
promo_prefix="WHEEL",
|
||
promo_validity_days=7,
|
||
)
|
||
db.add(config)
|
||
await db.commit()
|
||
await db.refresh(config)
|
||
logger.info("🎡 Создана дефолтная конфигурация колеса удачи")
|
||
return config
|
||
|
||
|
||
async def update_wheel_config(
|
||
db: AsyncSession,
|
||
**kwargs
|
||
) -> WheelConfig:
|
||
"""Обновить конфигурацию колеса."""
|
||
config = await get_or_create_wheel_config(db)
|
||
|
||
for key, value in kwargs.items():
|
||
if hasattr(config, key) and value is not None:
|
||
setattr(config, key, value)
|
||
|
||
config.updated_at = datetime.utcnow()
|
||
await db.commit()
|
||
await db.refresh(config)
|
||
logger.info(f"🎡 Обновлена конфигурация колеса: {kwargs}")
|
||
return config
|
||
|
||
|
||
# ==================== WHEEL PRIZES ====================
|
||
|
||
|
||
async def get_wheel_prizes(
|
||
db: AsyncSession,
|
||
config_id: int = 1,
|
||
active_only: bool = True
|
||
) -> List[WheelPrize]:
|
||
"""Получить список призов колеса."""
|
||
query = select(WheelPrize).where(WheelPrize.config_id == config_id)
|
||
|
||
if active_only:
|
||
query = query.where(WheelPrize.is_active == True)
|
||
|
||
query = query.order_by(WheelPrize.sort_order)
|
||
|
||
result = await db.execute(query)
|
||
return list(result.scalars().all())
|
||
|
||
|
||
async def get_wheel_prize_by_id(db: AsyncSession, prize_id: int) -> Optional[WheelPrize]:
|
||
"""Получить приз по ID."""
|
||
result = await db.execute(
|
||
select(WheelPrize).where(WheelPrize.id == prize_id)
|
||
)
|
||
return result.scalar_one_or_none()
|
||
|
||
|
||
async def create_wheel_prize(
|
||
db: AsyncSession,
|
||
config_id: int,
|
||
prize_type: str,
|
||
prize_value: int,
|
||
display_name: str,
|
||
prize_value_kopeks: int,
|
||
emoji: str = "🎁",
|
||
color: str = "#3B82F6",
|
||
sort_order: int = 0,
|
||
manual_probability: Optional[float] = None,
|
||
is_active: bool = True,
|
||
promo_balance_bonus_kopeks: int = 0,
|
||
promo_subscription_days: int = 0,
|
||
promo_traffic_gb: int = 0,
|
||
) -> WheelPrize:
|
||
"""Создать новый приз на колесе."""
|
||
prize = WheelPrize(
|
||
config_id=config_id,
|
||
prize_type=prize_type,
|
||
prize_value=prize_value,
|
||
display_name=display_name,
|
||
prize_value_kopeks=prize_value_kopeks,
|
||
emoji=emoji,
|
||
color=color,
|
||
sort_order=sort_order,
|
||
manual_probability=manual_probability,
|
||
is_active=is_active,
|
||
promo_balance_bonus_kopeks=promo_balance_bonus_kopeks,
|
||
promo_subscription_days=promo_subscription_days,
|
||
promo_traffic_gb=promo_traffic_gb,
|
||
)
|
||
db.add(prize)
|
||
await db.commit()
|
||
await db.refresh(prize)
|
||
logger.info(f"🎁 Создан приз колеса: {display_name} ({prize_type})")
|
||
return prize
|
||
|
||
|
||
async def update_wheel_prize(
|
||
db: AsyncSession,
|
||
prize_id: int,
|
||
**kwargs
|
||
) -> Optional[WheelPrize]:
|
||
"""Обновить приз колеса."""
|
||
prize = await get_wheel_prize_by_id(db, prize_id)
|
||
if not prize:
|
||
return None
|
||
|
||
for key, value in kwargs.items():
|
||
if hasattr(prize, key) and value is not None:
|
||
setattr(prize, key, value)
|
||
|
||
prize.updated_at = datetime.utcnow()
|
||
await db.commit()
|
||
await db.refresh(prize)
|
||
logger.info(f"🎁 Обновлен приз колеса ID={prize_id}: {kwargs}")
|
||
return prize
|
||
|
||
|
||
async def delete_wheel_prize(db: AsyncSession, prize_id: int) -> bool:
|
||
"""Удалить приз колеса."""
|
||
prize = await get_wheel_prize_by_id(db, prize_id)
|
||
if not prize:
|
||
return False
|
||
|
||
await db.delete(prize)
|
||
await db.commit()
|
||
logger.info(f"🗑️ Удален приз колеса ID={prize_id}")
|
||
return True
|
||
|
||
|
||
async def reorder_wheel_prizes(
|
||
db: AsyncSession,
|
||
prize_ids: List[int]
|
||
) -> bool:
|
||
"""Переупорядочить призы колеса."""
|
||
for index, prize_id in enumerate(prize_ids):
|
||
prize = await get_wheel_prize_by_id(db, prize_id)
|
||
if prize:
|
||
prize.sort_order = index
|
||
|
||
await db.commit()
|
||
logger.info(f"🔄 Переупорядочены призы колеса: {prize_ids}")
|
||
return True
|
||
|
||
|
||
# ==================== WHEEL SPINS ====================
|
||
|
||
|
||
async def create_wheel_spin(
|
||
db: AsyncSession,
|
||
user_id: int,
|
||
prize_id: int,
|
||
payment_type: str,
|
||
payment_amount: int,
|
||
payment_value_kopeks: int,
|
||
prize_type: str,
|
||
prize_value: int,
|
||
prize_display_name: str,
|
||
prize_value_kopeks: int,
|
||
generated_promocode_id: Optional[int] = None,
|
||
is_applied: bool = False,
|
||
) -> WheelSpin:
|
||
"""Создать запись о спине колеса."""
|
||
spin = WheelSpin(
|
||
user_id=user_id,
|
||
prize_id=prize_id,
|
||
payment_type=payment_type,
|
||
payment_amount=payment_amount,
|
||
payment_value_kopeks=payment_value_kopeks,
|
||
prize_type=prize_type,
|
||
prize_value=prize_value,
|
||
prize_display_name=prize_display_name,
|
||
prize_value_kopeks=prize_value_kopeks,
|
||
generated_promocode_id=generated_promocode_id,
|
||
is_applied=is_applied,
|
||
applied_at=datetime.utcnow() if is_applied else None,
|
||
)
|
||
db.add(spin)
|
||
await db.commit()
|
||
await db.refresh(spin)
|
||
logger.info(f"🎰 Создан спин колеса: user_id={user_id}, prize='{prize_display_name}'")
|
||
return spin
|
||
|
||
|
||
async def mark_spin_applied(db: AsyncSession, spin_id: int) -> Optional[WheelSpin]:
|
||
"""Отметить спин как примененный."""
|
||
result = await db.execute(
|
||
select(WheelSpin).where(WheelSpin.id == spin_id)
|
||
)
|
||
spin = result.scalar_one_or_none()
|
||
if spin:
|
||
spin.is_applied = True
|
||
spin.applied_at = datetime.utcnow()
|
||
await db.commit()
|
||
await db.refresh(spin)
|
||
return spin
|
||
|
||
|
||
async def get_user_spins_today(db: AsyncSession, user_id: int) -> int:
|
||
"""Получить количество спинов пользователя за сегодня."""
|
||
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||
|
||
result = await db.execute(
|
||
select(func.count(WheelSpin.id))
|
||
.where(
|
||
and_(
|
||
WheelSpin.user_id == user_id,
|
||
WheelSpin.created_at >= today_start,
|
||
)
|
||
)
|
||
)
|
||
return result.scalar() or 0
|
||
|
||
|
||
async def get_user_spin_history(
|
||
db: AsyncSession,
|
||
user_id: int,
|
||
limit: int = 20,
|
||
offset: int = 0
|
||
) -> tuple[List[WheelSpin], int]:
|
||
"""Получить историю спинов пользователя."""
|
||
# Общее количество
|
||
count_result = await db.execute(
|
||
select(func.count(WheelSpin.id))
|
||
.where(WheelSpin.user_id == user_id)
|
||
)
|
||
total = count_result.scalar() or 0
|
||
|
||
# Спины с пагинацией (eager load prize relationship)
|
||
result = await db.execute(
|
||
select(WheelSpin)
|
||
.options(selectinload(WheelSpin.prize))
|
||
.where(WheelSpin.user_id == user_id)
|
||
.order_by(desc(WheelSpin.created_at))
|
||
.limit(limit)
|
||
.offset(offset)
|
||
)
|
||
spins = list(result.scalars().all())
|
||
|
||
return spins, total
|
||
|
||
|
||
async def get_all_spins(
|
||
db: AsyncSession,
|
||
user_id: Optional[int] = None,
|
||
date_from: Optional[datetime] = None,
|
||
date_to: Optional[datetime] = None,
|
||
limit: int = 50,
|
||
offset: int = 0
|
||
) -> tuple[List[WheelSpin], int]:
|
||
"""Получить все спины с фильтрами (для админки)."""
|
||
conditions = []
|
||
|
||
if user_id:
|
||
conditions.append(WheelSpin.user_id == user_id)
|
||
if date_from:
|
||
conditions.append(WheelSpin.created_at >= date_from)
|
||
if date_to:
|
||
conditions.append(WheelSpin.created_at <= date_to)
|
||
|
||
# Общее количество
|
||
count_query = select(func.count(WheelSpin.id))
|
||
if conditions:
|
||
count_query = count_query.where(and_(*conditions))
|
||
count_result = await db.execute(count_query)
|
||
total = count_result.scalar() or 0
|
||
|
||
# Спины с пагинацией
|
||
query = select(WheelSpin).options(selectinload(WheelSpin.user))
|
||
if conditions:
|
||
query = query.where(and_(*conditions))
|
||
query = query.order_by(desc(WheelSpin.created_at)).limit(limit).offset(offset)
|
||
|
||
result = await db.execute(query)
|
||
spins = list(result.scalars().all())
|
||
|
||
return spins, total
|
||
|
||
|
||
# ==================== STATISTICS ====================
|
||
|
||
|
||
async def get_wheel_statistics(
|
||
db: AsyncSession,
|
||
date_from: Optional[datetime] = None,
|
||
date_to: Optional[datetime] = None
|
||
) -> Dict[str, Any]:
|
||
"""Получить статистику колеса удачи."""
|
||
conditions = []
|
||
if date_from:
|
||
conditions.append(WheelSpin.created_at >= date_from)
|
||
if date_to:
|
||
conditions.append(WheelSpin.created_at <= date_to)
|
||
|
||
base_query = select(WheelSpin)
|
||
if conditions:
|
||
base_query = base_query.where(and_(*conditions))
|
||
|
||
# Общие метрики
|
||
result = await db.execute(
|
||
select(
|
||
func.count(WheelSpin.id).label("total_spins"),
|
||
func.coalesce(func.sum(WheelSpin.payment_value_kopeks), 0).label("total_revenue"),
|
||
func.coalesce(func.sum(WheelSpin.prize_value_kopeks), 0).label("total_payout"),
|
||
).where(and_(*conditions) if conditions else True)
|
||
)
|
||
row = result.one()
|
||
total_spins = row.total_spins or 0
|
||
total_revenue = row.total_revenue or 0
|
||
total_payout = row.total_payout or 0
|
||
|
||
# Фактический RTP
|
||
actual_rtp = (total_payout / total_revenue * 100) if total_revenue > 0 else 0
|
||
|
||
# Распределение по типу оплаты
|
||
payment_dist = await db.execute(
|
||
select(
|
||
WheelSpin.payment_type,
|
||
func.count(WheelSpin.id).label("count"),
|
||
func.sum(WheelSpin.payment_value_kopeks).label("total"),
|
||
)
|
||
.where(and_(*conditions) if conditions else True)
|
||
.group_by(WheelSpin.payment_type)
|
||
)
|
||
spins_by_payment_type = {
|
||
row.payment_type: {"count": row.count, "total_kopeks": row.total or 0}
|
||
for row in payment_dist
|
||
}
|
||
|
||
# Распределение призов
|
||
prizes_dist = await db.execute(
|
||
select(
|
||
WheelSpin.prize_type,
|
||
WheelSpin.prize_display_name,
|
||
func.count(WheelSpin.id).label("count"),
|
||
func.sum(WheelSpin.prize_value_kopeks).label("total"),
|
||
)
|
||
.where(and_(*conditions) if conditions else True)
|
||
.group_by(WheelSpin.prize_type, WheelSpin.prize_display_name)
|
||
)
|
||
prizes_distribution = [
|
||
{
|
||
"prize_type": row.prize_type,
|
||
"display_name": row.prize_display_name,
|
||
"count": row.count,
|
||
"total_kopeks": row.total or 0,
|
||
}
|
||
for row in prizes_dist
|
||
]
|
||
|
||
# Топ выигрышей
|
||
top_wins_result = await db.execute(
|
||
select(WheelSpin)
|
||
.options(selectinload(WheelSpin.user))
|
||
.where(and_(*conditions) if conditions else True)
|
||
.where(WheelSpin.prize_value_kopeks > 0)
|
||
.order_by(desc(WheelSpin.prize_value_kopeks))
|
||
.limit(10)
|
||
)
|
||
top_wins = [
|
||
{
|
||
"user_id": spin.user_id,
|
||
"username": spin.user.username if spin.user else None,
|
||
"prize_display_name": spin.prize_display_name,
|
||
"prize_value_kopeks": spin.prize_value_kopeks,
|
||
"created_at": spin.created_at.isoformat() if spin.created_at else None,
|
||
}
|
||
for spin in top_wins_result.scalars().all()
|
||
]
|
||
|
||
# Конфигурация для сравнения
|
||
config = await get_wheel_config(db)
|
||
configured_rtp = config.rtp_percent if config else 80
|
||
|
||
return {
|
||
"total_spins": total_spins,
|
||
"total_revenue_kopeks": total_revenue,
|
||
"total_payout_kopeks": total_payout,
|
||
"actual_rtp_percent": round(actual_rtp, 2),
|
||
"configured_rtp_percent": configured_rtp,
|
||
"spins_by_payment_type": spins_by_payment_type,
|
||
"prizes_distribution": prizes_distribution,
|
||
"top_wins": top_wins,
|
||
"period_from": date_from.isoformat() if date_from else None,
|
||
"period_to": date_to.isoformat() if date_to else None,
|
||
}
|