Files
remnawave-bedolaga-telegram…/app/database/crud/wheel.py
PEDZEO 0e24a5505c feat(subscription): добавлены новые функции для управления тарифами и трафиком
- Обновлены схемы и маршруты для поддержки покупки тарифов и управления трафиком.
- Реализована синхронизация тарифов и серверов из RemnaWave при запуске.
- Добавлены новые параметры в тарифы: server_traffic_limits и allow_traffic_topup.
- Обновлены настройки и логика для проверки доступности докупки трафика в зависимости от тарифа.
- Внедрены новые эндпоинты для работы с колесом удачи и обработка платежей через Stars.

Обновлён .env.example с новыми параметрами для режима продаж подписок.
2026-01-12 07:41:10 +03:00

439 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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,
}