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

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

272 lines
9.1 KiB
Python

"""
API роуты колеса удачи для пользователей.
"""
import logging
import math
import time
import httpx
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from typing import Optional
from app.config import settings
from app.database.models import User
from app.database.crud.wheel import (
get_or_create_wheel_config,
get_wheel_prizes,
get_user_spins_today,
get_user_spin_history,
)
from app.services.wheel_service import wheel_service
from app.cabinet.dependencies import get_cabinet_db, get_current_cabinet_user
from app.cabinet.schemas.wheel import (
WheelConfigResponse,
WheelPrizeDisplay,
SpinAvailabilityResponse,
SpinRequest,
SpinResultResponse,
SpinHistoryResponse,
SpinHistoryItem,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/wheel", tags=["Fortune Wheel"])
@router.get("/config", response_model=WheelConfigResponse)
async def get_wheel_config(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Получить конфигурацию колеса удачи."""
config = await get_or_create_wheel_config(db)
prizes = await get_wheel_prizes(db, config.id, active_only=True)
spins_today = await get_user_spins_today(db, user.id)
# Проверяем доступность
availability = await wheel_service.check_availability(db, user)
prizes_display = [
WheelPrizeDisplay(
id=p.id,
display_name=p.display_name,
emoji=p.emoji,
color=p.color,
prize_type=p.prize_type,
)
for p in prizes
]
return WheelConfigResponse(
is_enabled=config.is_enabled,
name=config.name,
spin_cost_stars=config.spin_cost_stars if config.spin_cost_stars_enabled else None,
spin_cost_days=config.spin_cost_days if config.spin_cost_days_enabled else None,
spin_cost_stars_enabled=config.spin_cost_stars_enabled,
spin_cost_days_enabled=config.spin_cost_days_enabled,
prizes=prizes_display,
daily_limit=config.daily_spin_limit,
user_spins_today=spins_today,
can_spin=availability.can_spin,
can_spin_reason=availability.reason,
can_pay_stars=availability.can_pay_stars,
can_pay_days=availability.can_pay_days,
user_balance_kopeks=availability.user_balance_kopeks,
required_balance_kopeks=availability.required_balance_kopeks,
)
@router.get("/availability", response_model=SpinAvailabilityResponse)
async def check_spin_availability(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Проверить доступность спина."""
availability = await wheel_service.check_availability(db, user)
return SpinAvailabilityResponse(
can_spin=availability.can_spin,
reason=availability.reason,
spins_remaining_today=availability.spins_remaining_today,
can_pay_stars=availability.can_pay_stars,
can_pay_days=availability.can_pay_days,
min_subscription_days=availability.min_subscription_days,
user_subscription_days=availability.user_subscription_days,
user_balance_kopeks=availability.user_balance_kopeks,
required_balance_kopeks=availability.required_balance_kopeks,
)
@router.post("/spin", response_model=SpinResultResponse)
async def spin_wheel(
request: SpinRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Крутить колесо удачи."""
result = await wheel_service.spin(db, user, request.payment_type.value)
if not result.success:
# Возвращаем ошибку в теле ответа, а не HTTP exception
return SpinResultResponse(
success=False,
error=result.error,
message=result.message,
)
return SpinResultResponse(
success=True,
prize_id=result.prize_id,
prize_type=result.prize_type,
prize_value=result.prize_value,
prize_display_name=result.prize_display_name,
emoji=result.emoji,
color=result.color,
rotation_degrees=result.rotation_degrees,
message=result.message,
promocode=result.promocode,
)
@router.get("/history", response_model=SpinHistoryResponse)
async def get_spin_history(
page: int = 1,
per_page: int = 20,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Получить историю спинов пользователя."""
if page < 1:
page = 1
if per_page < 1 or per_page > 100:
per_page = 20
offset = (page - 1) * per_page
spins, total = await get_user_spin_history(db, user.id, limit=per_page, offset=offset)
items = []
for spin in spins:
# Получаем emoji и color из приза, если он есть
emoji = "🎁"
color = "#3B82F6"
if spin.prize:
emoji = spin.prize.emoji
color = spin.prize.color
items.append(SpinHistoryItem(
id=spin.id,
payment_type=spin.payment_type,
payment_amount=spin.payment_amount,
prize_type=spin.prize_type,
prize_value=spin.prize_value,
prize_display_name=spin.prize_display_name,
emoji=emoji,
color=color,
prize_value_kopeks=spin.prize_value_kopeks,
created_at=spin.created_at,
))
pages = math.ceil(total / per_page) if total > 0 else 1
return SpinHistoryResponse(
items=items,
total=total,
page=page,
per_page=per_page,
pages=pages,
)
class StarsInvoiceResponse(BaseModel):
"""Ответ с ссылкой на Stars invoice."""
invoice_url: str
stars_amount: int
@router.post("/stars-invoice", response_model=StarsInvoiceResponse)
async def create_stars_invoice(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Создать Telegram Stars invoice для оплаты спина колеса.
Используется в Telegram Mini App для прямой оплаты Stars.
"""
config = await get_or_create_wheel_config(db)
if not config.is_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Колесо удачи недоступно",
)
if not config.spin_cost_stars_enabled or not config.spin_cost_stars:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Оплата Stars не включена",
)
# Проверяем лимит спинов
spins_today = await get_user_spins_today(db, user.id)
if config.daily_spin_limit > 0 and spins_today >= config.daily_spin_limit:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Достигнут дневной лимит спинов",
)
# Проверяем наличие призов
prizes = await get_wheel_prizes(db, config.id, active_only=True)
if not prizes:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Призы не настроены",
)
stars_amount = config.spin_cost_stars
payload = f"wheel_spin_{user.id}_{int(time.time())}"
# Создаем invoice через Telegram Bot API
try:
bot_token = settings.BOT_TOKEN
api_url = f"https://api.telegram.org/bot{bot_token}/createInvoiceLink"
async with httpx.AsyncClient() as client:
response = await client.post(
api_url,
json={
"title": "Колесо удачи",
"description": f"Спин колеса удачи ({stars_amount} ⭐)",
"payload": payload,
"provider_token": "", # Пустой для Stars
"currency": "XTR",
"prices": [{"label": "Спин колеса", "amount": stars_amount}],
},
)
result = response.json()
if not result.get("ok"):
logger.error(f"Telegram API error: {result}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Ошибка создания инвойса",
)
invoice_url = result["result"]
logger.info(f"Created Stars invoice for wheel spin: user={user.id}, stars={stars_amount}")
return StarsInvoiceResponse(
invoice_url=invoice_url,
stars_amount=stars_amount,
)
except httpx.HTTPError as e:
logger.error(f"HTTP error creating invoice: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Ошибка соединения с Telegram",
)