feat(subscription): добавлены новые функции для управления тарифами и трафиком
- Обновлены схемы и маршруты для поддержки покупки тарифов и управления трафиком. - Реализована синхронизация тарифов и серверов из RemnaWave при запуске. - Добавлены новые параметры в тарифы: server_traffic_limits и allow_traffic_topup. - Обновлены настройки и логика для проверки доступности докупки трафика в зависимости от тарифа. - Внедрены новые эндпоинты для работы с колесом удачи и обработка платежей через Stars. Обновлён .env.example с новыми параметрами для режима продаж подписок.
This commit is contained in:
+11
-4
@@ -159,10 +159,17 @@ REMNAWAVE_AUTO_SYNC_TIMES=03:00
|
||||
# ========= ПОДПИСКИ =========
|
||||
|
||||
# ===== РЕЖИМ ПРОДАЖ =====
|
||||
# Режим продаж подписок:
|
||||
# "classic" - классический режим (выбор серверов, трафика, устройств, периода отдельно)
|
||||
# "tariffs" - режим тарифов (готовые пакеты с фиксированными параметрами)
|
||||
SALES_MODE=classic
|
||||
# Режим продаж подписок (можно переключить в кабинете: Настройки → Подписки):
|
||||
# "classic" - классический режим:
|
||||
# - Пользователь выбирает период, серверы, трафик, устройства отдельно
|
||||
# - Цены периодов берутся из PERIOD_PRICES ниже
|
||||
# - Подходит для гибкой настройки под каждого пользователя
|
||||
# "tariffs" - режим тарифов:
|
||||
# - Пользователь выбирает готовый тариф (Premium, Basic и т.д.)
|
||||
# - Тарифы создаются в кабинете: Админ → Тарифы
|
||||
# - Каждый тариф имеет свои серверы, трафик, устройства и цены за периоды
|
||||
# - Подходит для продажи готовых пакетов услуг
|
||||
SALES_MODE=tariffs
|
||||
|
||||
# ===== ТРИАЛ ПОДПИСКА =====
|
||||
TRIAL_DURATION_DAYS=3
|
||||
|
||||
@@ -17,6 +17,12 @@ from .promo import router as promo_router
|
||||
from .notifications import router as notifications_router
|
||||
from .info import router as info_router
|
||||
from .branding import router as branding_router
|
||||
from .wheel import router as wheel_router
|
||||
from .admin_wheel import router as admin_wheel_router
|
||||
from .admin_tariffs import router as admin_tariffs_router
|
||||
from .admin_servers import router as admin_servers_router
|
||||
from .admin_stats import router as admin_stats_router
|
||||
from .media import router as media_router
|
||||
|
||||
# Main cabinet router
|
||||
router = APIRouter(prefix="/cabinet", tags=["Cabinet"])
|
||||
@@ -34,10 +40,18 @@ router.include_router(promo_router)
|
||||
router.include_router(notifications_router)
|
||||
router.include_router(info_router)
|
||||
router.include_router(branding_router)
|
||||
router.include_router(media_router)
|
||||
|
||||
# Wheel routes
|
||||
router.include_router(wheel_router)
|
||||
|
||||
# Admin routes
|
||||
router.include_router(admin_tickets_router)
|
||||
router.include_router(admin_settings_router)
|
||||
router.include_router(admin_apps_router)
|
||||
router.include_router(admin_wheel_router)
|
||||
router.include_router(admin_tariffs_router)
|
||||
router.include_router(admin_servers_router)
|
||||
router.include_router(admin_stats_router)
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
"""Admin routes for managing servers in cabinet."""
|
||||
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, String
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import User, ServerSquad, Subscription, Tariff, PromoGroup
|
||||
from app.database.crud.server_squad import (
|
||||
get_all_server_squads,
|
||||
get_server_squad_by_id,
|
||||
update_server_squad,
|
||||
update_server_squad_promo_groups,
|
||||
sync_with_remnawave,
|
||||
count_active_users_for_squad,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.servers import (
|
||||
ServerListResponse,
|
||||
ServerListItem,
|
||||
ServerDetailResponse,
|
||||
ServerUpdateRequest,
|
||||
ServerToggleResponse,
|
||||
ServerTrialToggleResponse,
|
||||
ServerStatsResponse,
|
||||
ServerSyncResponse,
|
||||
PromoGroupInfo,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/servers", tags=["Cabinet Admin Servers"])
|
||||
|
||||
|
||||
async def _get_server_promo_groups(db: AsyncSession, server: ServerSquad) -> List[PromoGroupInfo]:
|
||||
"""Get promo group info for server."""
|
||||
result = await db.execute(select(PromoGroup).order_by(PromoGroup.name))
|
||||
all_groups = result.scalars().all()
|
||||
|
||||
selected_ids = {pg.id for pg in server.allowed_promo_groups} if server.allowed_promo_groups else set()
|
||||
|
||||
return [
|
||||
PromoGroupInfo(
|
||||
id=pg.id,
|
||||
name=pg.name,
|
||||
is_selected=pg.id in selected_ids,
|
||||
)
|
||||
for pg in all_groups
|
||||
]
|
||||
|
||||
|
||||
async def _get_tariffs_using_server(db: AsyncSession, squad_uuid: str) -> List[str]:
|
||||
"""Get list of tariff names using this server."""
|
||||
# Get all tariffs and filter in Python since JSON array queries are DB-specific
|
||||
result = await db.execute(select(Tariff.name, Tariff.allowed_squads))
|
||||
tariff_names = []
|
||||
for name, allowed_squads in result.fetchall():
|
||||
if allowed_squads and squad_uuid in allowed_squads:
|
||||
tariff_names.append(name)
|
||||
return tariff_names
|
||||
|
||||
|
||||
@router.get("", response_model=ServerListResponse)
|
||||
async def list_servers(
|
||||
include_unavailable: bool = True,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of all servers."""
|
||||
servers, total = await get_all_server_squads(
|
||||
db,
|
||||
available_only=not include_unavailable,
|
||||
)
|
||||
|
||||
items = []
|
||||
for server in servers:
|
||||
items.append(ServerListItem(
|
||||
id=server.id,
|
||||
squad_uuid=server.squad_uuid,
|
||||
display_name=server.display_name,
|
||||
original_name=server.original_name,
|
||||
country_code=server.country_code,
|
||||
is_available=server.is_available,
|
||||
is_trial_eligible=server.is_trial_eligible,
|
||||
price_kopeks=server.price_kopeks,
|
||||
price_rubles=server.price_kopeks / 100,
|
||||
max_users=server.max_users,
|
||||
current_users=server.current_users or 0,
|
||||
sort_order=server.sort_order,
|
||||
is_full=server.is_full,
|
||||
availability_status=server.availability_status,
|
||||
created_at=server.created_at,
|
||||
))
|
||||
|
||||
return ServerListResponse(servers=items, total=total)
|
||||
|
||||
|
||||
@router.get("/{server_id}", response_model=ServerDetailResponse)
|
||||
async def get_server(
|
||||
server_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get detailed server info."""
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if not server:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Server not found",
|
||||
)
|
||||
|
||||
promo_groups = await _get_server_promo_groups(db, server)
|
||||
tariffs_using = await _get_tariffs_using_server(db, server.squad_uuid)
|
||||
active_subs = await count_active_users_for_squad(db, server.squad_uuid)
|
||||
|
||||
return ServerDetailResponse(
|
||||
id=server.id,
|
||||
squad_uuid=server.squad_uuid,
|
||||
display_name=server.display_name,
|
||||
original_name=server.original_name,
|
||||
country_code=server.country_code,
|
||||
description=server.description,
|
||||
is_available=server.is_available,
|
||||
is_trial_eligible=server.is_trial_eligible,
|
||||
price_kopeks=server.price_kopeks,
|
||||
price_rubles=server.price_kopeks / 100,
|
||||
max_users=server.max_users,
|
||||
current_users=server.current_users or 0,
|
||||
sort_order=server.sort_order,
|
||||
is_full=server.is_full,
|
||||
availability_status=server.availability_status,
|
||||
promo_groups=promo_groups,
|
||||
active_subscriptions=active_subs,
|
||||
tariffs_using=tariffs_using,
|
||||
created_at=server.created_at,
|
||||
updated_at=server.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{server_id}", response_model=ServerDetailResponse)
|
||||
async def update_existing_server(
|
||||
server_id: int,
|
||||
request: ServerUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update an existing server."""
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if not server:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Server not found",
|
||||
)
|
||||
|
||||
# Build updates dict
|
||||
updates = {}
|
||||
if request.display_name is not None:
|
||||
updates["display_name"] = request.display_name
|
||||
if request.description is not None:
|
||||
updates["description"] = request.description
|
||||
if request.country_code is not None:
|
||||
updates["country_code"] = request.country_code
|
||||
if request.is_available is not None:
|
||||
updates["is_available"] = request.is_available
|
||||
if request.is_trial_eligible is not None:
|
||||
updates["is_trial_eligible"] = request.is_trial_eligible
|
||||
if request.price_kopeks is not None:
|
||||
updates["price_kopeks"] = request.price_kopeks
|
||||
if request.max_users is not None:
|
||||
updates["max_users"] = request.max_users if request.max_users > 0 else None
|
||||
if request.sort_order is not None:
|
||||
updates["sort_order"] = request.sort_order
|
||||
|
||||
if updates:
|
||||
await update_server_squad(db, server_id, **updates)
|
||||
|
||||
# Update promo groups separately
|
||||
if request.promo_group_ids is not None:
|
||||
await update_server_squad_promo_groups(db, server_id, request.promo_group_ids)
|
||||
|
||||
logger.info(f"Admin {admin.id} updated server {server_id}")
|
||||
|
||||
return await get_server(server_id, admin, db)
|
||||
|
||||
|
||||
@router.post("/{server_id}/toggle", response_model=ServerToggleResponse)
|
||||
async def toggle_server(
|
||||
server_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Toggle server availability."""
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if not server:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Server not found",
|
||||
)
|
||||
|
||||
new_status = not server.is_available
|
||||
await update_server_squad(db, server_id, is_available=new_status)
|
||||
|
||||
status_text = "enabled" if new_status else "disabled"
|
||||
logger.info(f"Admin {admin.id} {status_text} server {server_id}")
|
||||
|
||||
return ServerToggleResponse(
|
||||
id=server_id,
|
||||
is_available=new_status,
|
||||
message=f"Server {status_text}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{server_id}/trial", response_model=ServerTrialToggleResponse)
|
||||
async def toggle_server_trial(
|
||||
server_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Toggle server trial eligibility."""
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if not server:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Server not found",
|
||||
)
|
||||
|
||||
new_status = not server.is_trial_eligible
|
||||
await update_server_squad(db, server_id, is_trial_eligible=new_status)
|
||||
|
||||
status_text = "enabled for trial" if new_status else "disabled for trial"
|
||||
logger.info(f"Admin {admin.id} {status_text} server {server_id}")
|
||||
|
||||
return ServerTrialToggleResponse(
|
||||
id=server_id,
|
||||
is_trial_eligible=new_status,
|
||||
message=f"Server {status_text}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{server_id}/stats", response_model=ServerStatsResponse)
|
||||
async def get_server_stats(
|
||||
server_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get server statistics."""
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if not server:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Server not found",
|
||||
)
|
||||
|
||||
active_subs = await count_active_users_for_squad(db, server.squad_uuid)
|
||||
|
||||
# Count trial subscriptions on this server
|
||||
# Use LIKE query for JSON array since .contains() is DB-specific
|
||||
trial_result = await db.execute(
|
||||
select(func.count(Subscription.id))
|
||||
.where(
|
||||
Subscription.is_trial == True,
|
||||
Subscription.status == "active",
|
||||
func.cast(Subscription.connected_squads, String).like(f'%"{server.squad_uuid}"%'),
|
||||
)
|
||||
)
|
||||
trial_count = trial_result.scalar() or 0
|
||||
|
||||
usage_percent = None
|
||||
if server.max_users and server.max_users > 0:
|
||||
usage_percent = round((server.current_users or 0) / server.max_users * 100, 1)
|
||||
|
||||
return ServerStatsResponse(
|
||||
id=server_id,
|
||||
display_name=server.display_name,
|
||||
squad_uuid=server.squad_uuid,
|
||||
current_users=server.current_users or 0,
|
||||
max_users=server.max_users,
|
||||
active_subscriptions=active_subs,
|
||||
trial_subscriptions=trial_count,
|
||||
usage_percent=usage_percent,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sync", response_model=ServerSyncResponse)
|
||||
async def sync_servers(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Sync servers with RemnaWave."""
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
if not subscription_service.is_configured:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="RemnaWave is not configured",
|
||||
)
|
||||
|
||||
# Get squads from RemnaWave
|
||||
squads = await subscription_service.get_remnawave_squads()
|
||||
if squads is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to fetch squads from RemnaWave",
|
||||
)
|
||||
|
||||
# Sync with database
|
||||
created, updated, removed = await sync_with_remnawave(db, squads)
|
||||
|
||||
logger.info(f"Admin {admin.id} synced servers: +{created} ~{updated} -{removed}")
|
||||
|
||||
return ServerSyncResponse(
|
||||
created=created,
|
||||
updated=updated,
|
||||
removed=removed,
|
||||
message=f"Synced: {created} created, {updated} updated, {removed} removed",
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to sync servers: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Sync failed: {str(e)}",
|
||||
)
|
||||
@@ -0,0 +1,301 @@
|
||||
"""Admin routes for statistics dashboard in cabinet."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.database.crud.subscription import get_subscriptions_statistics
|
||||
from app.database.crud.transaction import get_transactions_statistics, get_revenue_by_period
|
||||
from app.database.crud.server_squad import get_server_statistics
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.config import settings
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from app.database.models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/stats", tags=["Cabinet Admin Stats"])
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
class NodeStatus(BaseModel):
|
||||
"""Node status info."""
|
||||
uuid: str
|
||||
name: str
|
||||
address: str
|
||||
is_connected: bool
|
||||
is_disabled: bool
|
||||
users_online: int
|
||||
traffic_used_bytes: Optional[int] = None
|
||||
uptime: Optional[str] = None
|
||||
|
||||
|
||||
class NodesOverview(BaseModel):
|
||||
"""Overview of all nodes."""
|
||||
total: int
|
||||
online: int
|
||||
offline: int
|
||||
disabled: int
|
||||
total_users_online: int
|
||||
nodes: List[NodeStatus]
|
||||
|
||||
|
||||
class RevenueData(BaseModel):
|
||||
"""Revenue data point."""
|
||||
date: str
|
||||
amount_kopeks: int
|
||||
amount_rubles: float
|
||||
|
||||
|
||||
class SubscriptionStats(BaseModel):
|
||||
"""Subscription statistics."""
|
||||
total: int
|
||||
active: int
|
||||
trial: int
|
||||
paid: int
|
||||
expired: int
|
||||
purchased_today: int
|
||||
purchased_week: int
|
||||
purchased_month: int
|
||||
trial_to_paid_conversion: float
|
||||
|
||||
|
||||
class FinancialStats(BaseModel):
|
||||
"""Financial statistics."""
|
||||
income_today_kopeks: int
|
||||
income_today_rubles: float
|
||||
income_month_kopeks: int
|
||||
income_month_rubles: float
|
||||
income_total_kopeks: int
|
||||
income_total_rubles: float
|
||||
subscription_income_kopeks: int
|
||||
subscription_income_rubles: float
|
||||
|
||||
|
||||
class ServerStats(BaseModel):
|
||||
"""Server statistics."""
|
||||
total_servers: int
|
||||
available_servers: int
|
||||
servers_with_connections: int
|
||||
total_revenue_kopeks: int
|
||||
total_revenue_rubles: float
|
||||
|
||||
|
||||
class DashboardStats(BaseModel):
|
||||
"""Complete dashboard statistics."""
|
||||
nodes: NodesOverview
|
||||
subscriptions: SubscriptionStats
|
||||
financial: FinancialStats
|
||||
servers: ServerStats
|
||||
revenue_chart: List[RevenueData]
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
@router.get("/dashboard", response_model=DashboardStats)
|
||||
async def get_dashboard_stats(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get complete dashboard statistics for admin panel."""
|
||||
try:
|
||||
# Get nodes status from RemnaWave
|
||||
nodes_data = await _get_nodes_overview()
|
||||
|
||||
# Get subscription statistics
|
||||
sub_stats = await get_subscriptions_statistics(db)
|
||||
|
||||
# Get financial statistics
|
||||
now = datetime.utcnow()
|
||||
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
trans_stats = await get_transactions_statistics(db, month_start, now)
|
||||
|
||||
# Get revenue chart data (last 30 days)
|
||||
revenue_data = await get_revenue_by_period(db, days=30)
|
||||
|
||||
# Get server statistics
|
||||
server_stats = await get_server_statistics(db)
|
||||
|
||||
# Build response
|
||||
return DashboardStats(
|
||||
nodes=nodes_data,
|
||||
subscriptions=SubscriptionStats(
|
||||
total=sub_stats.get("total_subscriptions", 0),
|
||||
active=sub_stats.get("active_subscriptions", 0),
|
||||
trial=sub_stats.get("trial_subscriptions", 0),
|
||||
paid=sub_stats.get("paid_subscriptions", 0),
|
||||
expired=sub_stats.get("total_subscriptions", 0) - sub_stats.get("active_subscriptions", 0),
|
||||
purchased_today=sub_stats.get("purchased_today", 0),
|
||||
purchased_week=sub_stats.get("purchased_week", 0),
|
||||
purchased_month=sub_stats.get("purchased_month", 0),
|
||||
trial_to_paid_conversion=sub_stats.get("trial_to_paid_conversion", 0.0),
|
||||
),
|
||||
financial=FinancialStats(
|
||||
income_today_kopeks=trans_stats.get("today", {}).get("income_kopeks", 0),
|
||||
income_today_rubles=trans_stats.get("today", {}).get("income_kopeks", 0) / 100,
|
||||
income_month_kopeks=trans_stats.get("totals", {}).get("income_kopeks", 0),
|
||||
income_month_rubles=trans_stats.get("totals", {}).get("income_kopeks", 0) / 100,
|
||||
income_total_kopeks=trans_stats.get("totals", {}).get("income_kopeks", 0),
|
||||
income_total_rubles=trans_stats.get("totals", {}).get("income_kopeks", 0) / 100,
|
||||
subscription_income_kopeks=trans_stats.get("totals", {}).get("subscription_income_kopeks", 0),
|
||||
subscription_income_rubles=trans_stats.get("totals", {}).get("subscription_income_kopeks", 0) / 100,
|
||||
),
|
||||
servers=ServerStats(
|
||||
total_servers=server_stats.get("total_servers", 0),
|
||||
available_servers=server_stats.get("available_servers", 0),
|
||||
servers_with_connections=server_stats.get("servers_with_connections", 0),
|
||||
total_revenue_kopeks=server_stats.get("total_revenue_kopeks", 0),
|
||||
total_revenue_rubles=server_stats.get("total_revenue_rubles", 0.0),
|
||||
),
|
||||
revenue_chart=[
|
||||
RevenueData(
|
||||
date=item.get("date", "").isoformat() if hasattr(item.get("date", ""), "isoformat") else str(item.get("date", "")),
|
||||
amount_kopeks=item.get("amount_kopeks", 0),
|
||||
amount_rubles=item.get("amount_kopeks", 0) / 100,
|
||||
)
|
||||
for item in revenue_data
|
||||
],
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get dashboard stats: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to load dashboard statistics",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/nodes", response_model=NodesOverview)
|
||||
async def get_nodes_status(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get status of all nodes."""
|
||||
try:
|
||||
return await _get_nodes_overview()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get nodes status: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to load nodes status",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/nodes/{node_uuid}/restart")
|
||||
async def restart_node(
|
||||
node_uuid: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Restart a node."""
|
||||
try:
|
||||
service = RemnaWaveService()
|
||||
success = await service.manage_node(node_uuid, "restart")
|
||||
|
||||
if success:
|
||||
logger.info(f"Admin {admin.id} restarted node {node_uuid}")
|
||||
return {"success": True, "message": "Node restart initiated"}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Failed to restart node",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to restart node {node_uuid}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to restart node",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/nodes/{node_uuid}/toggle")
|
||||
async def toggle_node(
|
||||
node_uuid: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Enable or disable a node."""
|
||||
try:
|
||||
service = RemnaWaveService()
|
||||
nodes = await service.get_all_nodes()
|
||||
|
||||
node = next((n for n in nodes if n.get("uuid") == node_uuid), None)
|
||||
if not node:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Node not found",
|
||||
)
|
||||
|
||||
is_disabled = node.get("is_disabled", False)
|
||||
action = "enable" if is_disabled else "disable"
|
||||
success = await service.manage_node(node_uuid, action)
|
||||
|
||||
if success:
|
||||
logger.info(f"Admin {admin.id} {action}d node {node_uuid}")
|
||||
return {"success": True, "message": f"Node {action}d", "is_disabled": not is_disabled}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Failed to {action} node",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to toggle node {node_uuid}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to toggle node",
|
||||
)
|
||||
|
||||
|
||||
async def _get_nodes_overview() -> NodesOverview:
|
||||
"""Get overview of all nodes."""
|
||||
try:
|
||||
service = RemnaWaveService()
|
||||
nodes = await service.get_all_nodes()
|
||||
|
||||
total = len(nodes)
|
||||
online = sum(1 for n in nodes if n.get("is_connected") and not n.get("is_disabled"))
|
||||
disabled = sum(1 for n in nodes if n.get("is_disabled"))
|
||||
offline = total - online - disabled
|
||||
total_users_online = sum(n.get("users_online", 0) or 0 for n in nodes)
|
||||
|
||||
node_statuses = [
|
||||
NodeStatus(
|
||||
uuid=n.get("uuid", ""),
|
||||
name=n.get("name", "Unknown"),
|
||||
address=n.get("address", ""),
|
||||
is_connected=n.get("is_connected", False),
|
||||
is_disabled=n.get("is_disabled", False),
|
||||
users_online=n.get("users_online", 0) or 0,
|
||||
traffic_used_bytes=n.get("traffic_used_bytes"),
|
||||
uptime=n.get("uptime"),
|
||||
)
|
||||
for n in nodes
|
||||
]
|
||||
|
||||
return NodesOverview(
|
||||
total=total,
|
||||
online=online,
|
||||
offline=offline,
|
||||
disabled=disabled,
|
||||
total_users_online=total_users_online,
|
||||
nodes=node_statuses,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get nodes from RemnaWave: {e}")
|
||||
# Return empty data if RemnaWave is unavailable
|
||||
return NodesOverview(
|
||||
total=0,
|
||||
online=0,
|
||||
offline=0,
|
||||
disabled=0,
|
||||
total_users_online=0,
|
||||
nodes=[],
|
||||
)
|
||||
@@ -0,0 +1,446 @@
|
||||
"""Admin routes for managing tariffs in cabinet."""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from app.database.models import User, Tariff, Subscription, ServerSquad, PromoGroup
|
||||
from app.database.crud.tariff import (
|
||||
get_all_tariffs,
|
||||
get_tariff_by_id,
|
||||
create_tariff,
|
||||
update_tariff,
|
||||
delete_tariff,
|
||||
get_tariff_subscriptions_count,
|
||||
set_tariff_promo_groups,
|
||||
load_period_prices_from_db,
|
||||
)
|
||||
from app.database.crud.server_squad import get_all_server_squads
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.tariffs import (
|
||||
TariffListResponse,
|
||||
TariffListItem,
|
||||
TariffDetailResponse,
|
||||
TariffCreateRequest,
|
||||
TariffUpdateRequest,
|
||||
TariffToggleResponse,
|
||||
TariffTrialResponse,
|
||||
TariffStatsResponse,
|
||||
PeriodPrice,
|
||||
ServerInfo,
|
||||
PromoGroupInfo,
|
||||
ServerTrafficLimit,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/tariffs", tags=["Cabinet Admin Tariffs"])
|
||||
|
||||
|
||||
async def _get_tariff_servers(
|
||||
db: AsyncSession,
|
||||
allowed_squads: List[str],
|
||||
server_traffic_limits: dict = None
|
||||
) -> List[ServerInfo]:
|
||||
"""Get server info for tariff."""
|
||||
servers, _ = await get_all_server_squads(db, available_only=False)
|
||||
limits = server_traffic_limits or {}
|
||||
result = []
|
||||
for server in servers:
|
||||
# Получаем индивидуальный лимит трафика для сервера
|
||||
server_limit = None
|
||||
if server.squad_uuid in limits:
|
||||
limit_data = limits[server.squad_uuid]
|
||||
if isinstance(limit_data, dict) and 'traffic_limit_gb' in limit_data:
|
||||
server_limit = limit_data['traffic_limit_gb']
|
||||
elif isinstance(limit_data, int):
|
||||
server_limit = limit_data
|
||||
|
||||
result.append(ServerInfo(
|
||||
id=server.id,
|
||||
squad_uuid=server.squad_uuid,
|
||||
display_name=server.display_name,
|
||||
country_code=server.country_code,
|
||||
is_selected=server.squad_uuid in allowed_squads,
|
||||
traffic_limit_gb=server_limit,
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
async def _get_tariff_promo_groups(db: AsyncSession, tariff: Tariff) -> List[PromoGroupInfo]:
|
||||
"""Get promo group info for tariff."""
|
||||
result = await db.execute(select(PromoGroup).order_by(PromoGroup.name))
|
||||
all_groups = result.scalars().all()
|
||||
|
||||
selected_ids = {pg.id for pg in tariff.allowed_promo_groups} if tariff.allowed_promo_groups else set()
|
||||
|
||||
return [
|
||||
PromoGroupInfo(
|
||||
id=pg.id,
|
||||
name=pg.name,
|
||||
is_selected=pg.id in selected_ids,
|
||||
)
|
||||
for pg in all_groups
|
||||
]
|
||||
|
||||
|
||||
def _period_prices_to_list(period_prices: dict) -> List[PeriodPrice]:
|
||||
"""Convert period_prices dict to list."""
|
||||
if not period_prices:
|
||||
return []
|
||||
return [
|
||||
PeriodPrice(days=int(days), price_kopeks=price)
|
||||
for days, price in sorted(period_prices.items(), key=lambda x: int(x[0]))
|
||||
]
|
||||
|
||||
|
||||
def _period_prices_to_dict(period_prices: List[PeriodPrice]) -> dict:
|
||||
"""Convert period_prices list to dict."""
|
||||
return {str(pp.days): pp.price_kopeks for pp in period_prices}
|
||||
|
||||
|
||||
@router.get("", response_model=TariffListResponse)
|
||||
async def list_tariffs(
|
||||
include_inactive: bool = True,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of all tariffs."""
|
||||
tariffs = await get_all_tariffs(db, include_inactive=include_inactive)
|
||||
|
||||
items = []
|
||||
for tariff in tariffs:
|
||||
subs_count = await get_tariff_subscriptions_count(db, tariff.id)
|
||||
items.append(TariffListItem(
|
||||
id=tariff.id,
|
||||
name=tariff.name,
|
||||
description=tariff.description,
|
||||
is_active=tariff.is_active,
|
||||
is_trial_available=tariff.is_trial_available,
|
||||
allow_traffic_topup=tariff.allow_traffic_topup,
|
||||
traffic_limit_gb=tariff.traffic_limit_gb,
|
||||
device_limit=tariff.device_limit,
|
||||
tier_level=tariff.tier_level,
|
||||
display_order=tariff.display_order,
|
||||
servers_count=len(tariff.allowed_squads or []),
|
||||
subscriptions_count=subs_count,
|
||||
created_at=tariff.created_at,
|
||||
))
|
||||
|
||||
return TariffListResponse(tariffs=items, total=len(items))
|
||||
|
||||
|
||||
@router.get("/available-servers", response_model=List[ServerInfo])
|
||||
async def get_available_servers(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of all servers for tariff selection."""
|
||||
servers, _ = await get_all_server_squads(db, available_only=False)
|
||||
return [
|
||||
ServerInfo(
|
||||
id=server.id,
|
||||
squad_uuid=server.squad_uuid,
|
||||
display_name=server.display_name,
|
||||
country_code=server.country_code,
|
||||
is_selected=False,
|
||||
)
|
||||
for server in servers
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{tariff_id}", response_model=TariffDetailResponse)
|
||||
async def get_tariff(
|
||||
tariff_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get detailed tariff info."""
|
||||
tariff = await get_tariff_by_id(db, tariff_id)
|
||||
if not tariff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tariff not found",
|
||||
)
|
||||
|
||||
allowed_squads = tariff.allowed_squads or []
|
||||
server_traffic_limits = tariff.server_traffic_limits or {}
|
||||
servers = await _get_tariff_servers(db, allowed_squads, server_traffic_limits)
|
||||
promo_groups = await _get_tariff_promo_groups(db, tariff)
|
||||
subs_count = await get_tariff_subscriptions_count(db, tariff.id)
|
||||
|
||||
# Преобразуем server_traffic_limits в формат для схемы
|
||||
server_limits_response = {}
|
||||
for uuid, limit_data in server_traffic_limits.items():
|
||||
if isinstance(limit_data, dict):
|
||||
server_limits_response[uuid] = ServerTrafficLimit(**limit_data)
|
||||
elif isinstance(limit_data, int):
|
||||
server_limits_response[uuid] = ServerTrafficLimit(traffic_limit_gb=limit_data)
|
||||
|
||||
return TariffDetailResponse(
|
||||
id=tariff.id,
|
||||
name=tariff.name,
|
||||
description=tariff.description,
|
||||
is_active=tariff.is_active,
|
||||
is_trial_available=tariff.is_trial_available,
|
||||
allow_traffic_topup=tariff.allow_traffic_topup,
|
||||
traffic_limit_gb=tariff.traffic_limit_gb,
|
||||
device_limit=tariff.device_limit,
|
||||
device_price_kopeks=tariff.device_price_kopeks,
|
||||
tier_level=tariff.tier_level,
|
||||
display_order=tariff.display_order,
|
||||
period_prices=_period_prices_to_list(tariff.period_prices),
|
||||
allowed_squads=allowed_squads,
|
||||
server_traffic_limits=server_limits_response,
|
||||
servers=servers,
|
||||
promo_groups=promo_groups,
|
||||
subscriptions_count=subs_count,
|
||||
created_at=tariff.created_at,
|
||||
updated_at=tariff.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=TariffDetailResponse)
|
||||
async def create_new_tariff(
|
||||
request: TariffCreateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Create a new tariff."""
|
||||
period_prices_dict = _period_prices_to_dict(request.period_prices)
|
||||
|
||||
# Преобразуем ServerTrafficLimit в dict для хранения
|
||||
server_limits_dict = {
|
||||
uuid: limit.model_dump() for uuid, limit in request.server_traffic_limits.items()
|
||||
} if request.server_traffic_limits else {}
|
||||
|
||||
tariff = await create_tariff(
|
||||
db=db,
|
||||
name=request.name,
|
||||
description=request.description,
|
||||
is_active=request.is_active,
|
||||
allow_traffic_topup=request.allow_traffic_topup,
|
||||
traffic_limit_gb=request.traffic_limit_gb,
|
||||
device_limit=request.device_limit,
|
||||
device_price_kopeks=request.device_price_kopeks,
|
||||
tier_level=request.tier_level,
|
||||
period_prices=period_prices_dict,
|
||||
allowed_squads=request.allowed_squads,
|
||||
server_traffic_limits=server_limits_dict,
|
||||
promo_group_ids=request.promo_group_ids if request.promo_group_ids else None,
|
||||
)
|
||||
|
||||
logger.info(f"Admin {admin.id} created tariff {tariff.id}: {tariff.name}")
|
||||
|
||||
# Перезагружаем периоды из БД для синхронизации с ботом
|
||||
await load_period_prices_from_db(db)
|
||||
|
||||
# Return full detail
|
||||
return await get_tariff(tariff.id, admin, db)
|
||||
|
||||
|
||||
@router.put("/{tariff_id}", response_model=TariffDetailResponse)
|
||||
async def update_existing_tariff(
|
||||
tariff_id: int,
|
||||
request: TariffUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update an existing tariff."""
|
||||
tariff = await get_tariff_by_id(db, tariff_id)
|
||||
if not tariff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tariff not found",
|
||||
)
|
||||
|
||||
# Build updates dict
|
||||
updates = {}
|
||||
if request.name is not None:
|
||||
updates["name"] = request.name
|
||||
if request.description is not None:
|
||||
updates["description"] = request.description
|
||||
if request.is_active is not None:
|
||||
updates["is_active"] = request.is_active
|
||||
if request.allow_traffic_topup is not None:
|
||||
updates["allow_traffic_topup"] = request.allow_traffic_topup
|
||||
if request.traffic_limit_gb is not None:
|
||||
updates["traffic_limit_gb"] = request.traffic_limit_gb
|
||||
if request.device_limit is not None:
|
||||
updates["device_limit"] = request.device_limit
|
||||
if request.device_price_kopeks is not None:
|
||||
updates["device_price_kopeks"] = request.device_price_kopeks
|
||||
if request.tier_level is not None:
|
||||
updates["tier_level"] = request.tier_level
|
||||
if request.display_order is not None:
|
||||
updates["display_order"] = request.display_order
|
||||
if request.period_prices is not None:
|
||||
updates["period_prices"] = _period_prices_to_dict(request.period_prices)
|
||||
if request.allowed_squads is not None:
|
||||
updates["allowed_squads"] = request.allowed_squads
|
||||
if request.server_traffic_limits is not None:
|
||||
# Преобразуем ServerTrafficLimit в dict для хранения
|
||||
updates["server_traffic_limits"] = {
|
||||
uuid: limit.model_dump() for uuid, limit in request.server_traffic_limits.items()
|
||||
}
|
||||
|
||||
if updates:
|
||||
await update_tariff(db, tariff, **updates)
|
||||
|
||||
# Update promo groups separately
|
||||
if request.promo_group_ids is not None:
|
||||
await set_tariff_promo_groups(db, tariff_id, request.promo_group_ids)
|
||||
|
||||
logger.info(f"Admin {admin.id} updated tariff {tariff_id}")
|
||||
|
||||
# Перезагружаем периоды из БД для синхронизации с ботом
|
||||
await load_period_prices_from_db(db)
|
||||
|
||||
return await get_tariff(tariff_id, admin, db)
|
||||
|
||||
|
||||
@router.delete("/{tariff_id}")
|
||||
async def delete_existing_tariff(
|
||||
tariff_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Delete a tariff."""
|
||||
tariff = await get_tariff_by_id(db, tariff_id)
|
||||
if not tariff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tariff not found",
|
||||
)
|
||||
|
||||
# Check if tariff has subscriptions
|
||||
subs_count = await get_tariff_subscriptions_count(db, tariff_id)
|
||||
if subs_count > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Cannot delete tariff with {subs_count} active subscriptions",
|
||||
)
|
||||
|
||||
await delete_tariff(db, tariff)
|
||||
logger.info(f"Admin {admin.id} deleted tariff {tariff_id}: {tariff.name}")
|
||||
|
||||
# Перезагружаем периоды из БД для синхронизации с ботом
|
||||
await load_period_prices_from_db(db)
|
||||
|
||||
return {"message": "Tariff deleted successfully"}
|
||||
|
||||
|
||||
@router.post("/{tariff_id}/toggle", response_model=TariffToggleResponse)
|
||||
async def toggle_tariff(
|
||||
tariff_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Toggle tariff active status."""
|
||||
tariff = await get_tariff_by_id(db, tariff_id)
|
||||
if not tariff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tariff not found",
|
||||
)
|
||||
|
||||
new_status = not tariff.is_active
|
||||
await update_tariff(db, tariff, is_active=new_status)
|
||||
|
||||
status_text = "activated" if new_status else "deactivated"
|
||||
logger.info(f"Admin {admin.id} {status_text} tariff {tariff_id}")
|
||||
|
||||
# Перезагружаем периоды из БД для синхронизации с ботом
|
||||
await load_period_prices_from_db(db)
|
||||
|
||||
return TariffToggleResponse(
|
||||
id=tariff_id,
|
||||
is_active=new_status,
|
||||
message=f"Tariff {status_text}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{tariff_id}/trial", response_model=TariffTrialResponse)
|
||||
async def toggle_trial_tariff(
|
||||
tariff_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Toggle tariff trial availability."""
|
||||
tariff = await get_tariff_by_id(db, tariff_id)
|
||||
if not tariff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tariff not found",
|
||||
)
|
||||
|
||||
new_status = not tariff.is_trial_available
|
||||
await update_tariff(db, tariff, is_trial_available=new_status)
|
||||
|
||||
status_text = "set as trial" if new_status else "removed from trial"
|
||||
logger.info(f"Admin {admin.id} {status_text} tariff {tariff_id}")
|
||||
|
||||
return TariffTrialResponse(
|
||||
id=tariff_id,
|
||||
is_trial_available=new_status,
|
||||
message=f"Tariff {status_text}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{tariff_id}/stats", response_model=TariffStatsResponse)
|
||||
async def get_tariff_stats(
|
||||
tariff_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get tariff statistics."""
|
||||
tariff = await get_tariff_by_id(db, tariff_id)
|
||||
if not tariff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tariff not found",
|
||||
)
|
||||
|
||||
# Count subscriptions
|
||||
total_result = await db.execute(
|
||||
select(func.count(Subscription.id))
|
||||
.where(Subscription.tariff_id == tariff_id)
|
||||
)
|
||||
total_count = total_result.scalar() or 0
|
||||
|
||||
# Count active subscriptions
|
||||
active_result = await db.execute(
|
||||
select(func.count(Subscription.id))
|
||||
.where(
|
||||
Subscription.tariff_id == tariff_id,
|
||||
Subscription.status == "active",
|
||||
)
|
||||
)
|
||||
active_count = active_result.scalar() or 0
|
||||
|
||||
# Count trial subscriptions
|
||||
trial_result = await db.execute(
|
||||
select(func.count(Subscription.id))
|
||||
.where(
|
||||
Subscription.tariff_id == tariff_id,
|
||||
Subscription.is_trial == True,
|
||||
)
|
||||
)
|
||||
trial_count = trial_result.scalar() or 0
|
||||
|
||||
# TODO: Calculate revenue from transactions
|
||||
revenue_kopeks = 0
|
||||
|
||||
return TariffStatsResponse(
|
||||
id=tariff_id,
|
||||
name=tariff.name,
|
||||
subscriptions_count=total_count,
|
||||
active_subscriptions=active_count,
|
||||
trial_subscriptions=trial_count,
|
||||
revenue_kopeks=revenue_kopeks,
|
||||
revenue_rubles=revenue_kopeks / 100,
|
||||
)
|
||||
@@ -0,0 +1,387 @@
|
||||
"""
|
||||
API роуты колеса удачи для администраторов.
|
||||
"""
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.database.crud.wheel import (
|
||||
get_or_create_wheel_config,
|
||||
update_wheel_config,
|
||||
get_wheel_prizes,
|
||||
get_wheel_prize_by_id,
|
||||
create_wheel_prize,
|
||||
update_wheel_prize,
|
||||
delete_wheel_prize,
|
||||
reorder_wheel_prizes,
|
||||
get_all_spins,
|
||||
get_wheel_statistics,
|
||||
)
|
||||
from app.services.wheel_service import wheel_service
|
||||
from app.cabinet.dependencies import get_cabinet_db, get_current_admin_user
|
||||
from app.cabinet.schemas.wheel import (
|
||||
AdminWheelConfigResponse,
|
||||
WheelPrizeAdminResponse,
|
||||
UpdateWheelConfigRequest,
|
||||
CreatePrizeRequest,
|
||||
UpdatePrizeRequest,
|
||||
ReorderPrizesRequest,
|
||||
AdminSpinsResponse,
|
||||
AdminSpinItem,
|
||||
WheelStatisticsResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/wheel", tags=["Admin Fortune Wheel"])
|
||||
|
||||
|
||||
@router.get("/config", response_model=AdminWheelConfigResponse)
|
||||
async def get_admin_wheel_config(
|
||||
admin: User = Depends(get_current_admin_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=False)
|
||||
|
||||
prizes_response = [
|
||||
WheelPrizeAdminResponse(
|
||||
id=p.id,
|
||||
config_id=p.config_id,
|
||||
prize_type=p.prize_type,
|
||||
prize_value=p.prize_value,
|
||||
display_name=p.display_name,
|
||||
emoji=p.emoji,
|
||||
color=p.color,
|
||||
prize_value_kopeks=p.prize_value_kopeks,
|
||||
sort_order=p.sort_order,
|
||||
manual_probability=p.manual_probability,
|
||||
is_active=p.is_active,
|
||||
promo_balance_bonus_kopeks=p.promo_balance_bonus_kopeks or 0,
|
||||
promo_subscription_days=p.promo_subscription_days or 0,
|
||||
promo_traffic_gb=p.promo_traffic_gb or 0,
|
||||
created_at=p.created_at,
|
||||
updated_at=p.updated_at,
|
||||
)
|
||||
for p in prizes
|
||||
]
|
||||
|
||||
return AdminWheelConfigResponse(
|
||||
id=config.id,
|
||||
is_enabled=config.is_enabled,
|
||||
name=config.name,
|
||||
spin_cost_stars=config.spin_cost_stars,
|
||||
spin_cost_days=config.spin_cost_days,
|
||||
spin_cost_stars_enabled=config.spin_cost_stars_enabled,
|
||||
spin_cost_days_enabled=config.spin_cost_days_enabled,
|
||||
rtp_percent=config.rtp_percent,
|
||||
daily_spin_limit=config.daily_spin_limit,
|
||||
min_subscription_days_for_day_payment=config.min_subscription_days_for_day_payment,
|
||||
promo_prefix=config.promo_prefix,
|
||||
promo_validity_days=config.promo_validity_days,
|
||||
prizes=prizes_response,
|
||||
created_at=config.created_at,
|
||||
updated_at=config.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/config", response_model=AdminWheelConfigResponse)
|
||||
async def update_admin_wheel_config(
|
||||
request: UpdateWheelConfigRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Обновить конфигурацию колеса."""
|
||||
update_data = request.model_dump(exclude_unset=True)
|
||||
|
||||
if not update_data:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No fields to update",
|
||||
)
|
||||
|
||||
config = await update_wheel_config(db, **update_data)
|
||||
|
||||
logger.info(f"🎡 Admin {admin.telegram_id} updated wheel config: {update_data}")
|
||||
|
||||
# Возвращаем полную конфигурацию
|
||||
prizes = await get_wheel_prizes(db, config.id, active_only=False)
|
||||
|
||||
prizes_response = [
|
||||
WheelPrizeAdminResponse(
|
||||
id=p.id,
|
||||
config_id=p.config_id,
|
||||
prize_type=p.prize_type,
|
||||
prize_value=p.prize_value,
|
||||
display_name=p.display_name,
|
||||
emoji=p.emoji,
|
||||
color=p.color,
|
||||
prize_value_kopeks=p.prize_value_kopeks,
|
||||
sort_order=p.sort_order,
|
||||
manual_probability=p.manual_probability,
|
||||
is_active=p.is_active,
|
||||
promo_balance_bonus_kopeks=p.promo_balance_bonus_kopeks or 0,
|
||||
promo_subscription_days=p.promo_subscription_days or 0,
|
||||
promo_traffic_gb=p.promo_traffic_gb or 0,
|
||||
created_at=p.created_at,
|
||||
updated_at=p.updated_at,
|
||||
)
|
||||
for p in prizes
|
||||
]
|
||||
|
||||
return AdminWheelConfigResponse(
|
||||
id=config.id,
|
||||
is_enabled=config.is_enabled,
|
||||
name=config.name,
|
||||
spin_cost_stars=config.spin_cost_stars,
|
||||
spin_cost_days=config.spin_cost_days,
|
||||
spin_cost_stars_enabled=config.spin_cost_stars_enabled,
|
||||
spin_cost_days_enabled=config.spin_cost_days_enabled,
|
||||
rtp_percent=config.rtp_percent,
|
||||
daily_spin_limit=config.daily_spin_limit,
|
||||
min_subscription_days_for_day_payment=config.min_subscription_days_for_day_payment,
|
||||
promo_prefix=config.promo_prefix,
|
||||
promo_validity_days=config.promo_validity_days,
|
||||
prizes=prizes_response,
|
||||
created_at=config.created_at,
|
||||
updated_at=config.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/prizes", response_model=List[WheelPrizeAdminResponse])
|
||||
async def get_prizes(
|
||||
admin: User = Depends(get_current_admin_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=False)
|
||||
|
||||
return [
|
||||
WheelPrizeAdminResponse(
|
||||
id=p.id,
|
||||
config_id=p.config_id,
|
||||
prize_type=p.prize_type,
|
||||
prize_value=p.prize_value,
|
||||
display_name=p.display_name,
|
||||
emoji=p.emoji,
|
||||
color=p.color,
|
||||
prize_value_kopeks=p.prize_value_kopeks,
|
||||
sort_order=p.sort_order,
|
||||
manual_probability=p.manual_probability,
|
||||
is_active=p.is_active,
|
||||
promo_balance_bonus_kopeks=p.promo_balance_bonus_kopeks or 0,
|
||||
promo_subscription_days=p.promo_subscription_days or 0,
|
||||
promo_traffic_gb=p.promo_traffic_gb or 0,
|
||||
created_at=p.created_at,
|
||||
updated_at=p.updated_at,
|
||||
)
|
||||
for p in prizes
|
||||
]
|
||||
|
||||
|
||||
@router.post("/prizes", response_model=WheelPrizeAdminResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_prize(
|
||||
request: CreatePrizeRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Создать новый приз."""
|
||||
config = await get_or_create_wheel_config(db)
|
||||
|
||||
prize = await create_wheel_prize(
|
||||
db=db,
|
||||
config_id=config.id,
|
||||
prize_type=request.prize_type.value,
|
||||
prize_value=request.prize_value,
|
||||
display_name=request.display_name,
|
||||
prize_value_kopeks=request.prize_value_kopeks,
|
||||
emoji=request.emoji,
|
||||
color=request.color,
|
||||
sort_order=request.sort_order,
|
||||
manual_probability=request.manual_probability,
|
||||
is_active=request.is_active,
|
||||
promo_balance_bonus_kopeks=request.promo_balance_bonus_kopeks,
|
||||
promo_subscription_days=request.promo_subscription_days,
|
||||
promo_traffic_gb=request.promo_traffic_gb,
|
||||
)
|
||||
|
||||
logger.info(f"🎁 Admin {admin.telegram_id} created prize: {prize.display_name}")
|
||||
|
||||
return WheelPrizeAdminResponse(
|
||||
id=prize.id,
|
||||
config_id=prize.config_id,
|
||||
prize_type=prize.prize_type,
|
||||
prize_value=prize.prize_value,
|
||||
display_name=prize.display_name,
|
||||
emoji=prize.emoji,
|
||||
color=prize.color,
|
||||
prize_value_kopeks=prize.prize_value_kopeks,
|
||||
sort_order=prize.sort_order,
|
||||
manual_probability=prize.manual_probability,
|
||||
is_active=prize.is_active,
|
||||
promo_balance_bonus_kopeks=prize.promo_balance_bonus_kopeks or 0,
|
||||
promo_subscription_days=prize.promo_subscription_days or 0,
|
||||
promo_traffic_gb=prize.promo_traffic_gb or 0,
|
||||
created_at=prize.created_at,
|
||||
updated_at=prize.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/prizes/{prize_id}", response_model=WheelPrizeAdminResponse)
|
||||
async def update_prize(
|
||||
prize_id: int,
|
||||
request: UpdatePrizeRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Обновить приз."""
|
||||
update_data = request.model_dump(exclude_unset=True)
|
||||
|
||||
# Конвертируем enum в строку если есть
|
||||
if 'prize_type' in update_data and update_data['prize_type']:
|
||||
update_data['prize_type'] = update_data['prize_type'].value
|
||||
|
||||
if not update_data:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No fields to update",
|
||||
)
|
||||
|
||||
prize = await update_wheel_prize(db, prize_id, **update_data)
|
||||
|
||||
if not prize:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Prize not found",
|
||||
)
|
||||
|
||||
logger.info(f"🎁 Admin {admin.telegram_id} updated prize {prize_id}: {update_data}")
|
||||
|
||||
return WheelPrizeAdminResponse(
|
||||
id=prize.id,
|
||||
config_id=prize.config_id,
|
||||
prize_type=prize.prize_type,
|
||||
prize_value=prize.prize_value,
|
||||
display_name=prize.display_name,
|
||||
emoji=prize.emoji,
|
||||
color=prize.color,
|
||||
prize_value_kopeks=prize.prize_value_kopeks,
|
||||
sort_order=prize.sort_order,
|
||||
manual_probability=prize.manual_probability,
|
||||
is_active=prize.is_active,
|
||||
promo_balance_bonus_kopeks=prize.promo_balance_bonus_kopeks or 0,
|
||||
promo_subscription_days=prize.promo_subscription_days or 0,
|
||||
promo_traffic_gb=prize.promo_traffic_gb or 0,
|
||||
created_at=prize.created_at,
|
||||
updated_at=prize.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/prizes/{prize_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_prize_endpoint(
|
||||
prize_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Удалить приз."""
|
||||
success = await delete_wheel_prize(db, prize_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Prize not found",
|
||||
)
|
||||
|
||||
logger.info(f"🗑️ Admin {admin.telegram_id} deleted prize {prize_id}")
|
||||
|
||||
|
||||
@router.post("/prizes/reorder", status_code=status.HTTP_200_OK)
|
||||
async def reorder_prizes(
|
||||
request: ReorderPrizesRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Переупорядочить призы."""
|
||||
await reorder_wheel_prizes(db, request.prize_ids)
|
||||
logger.info(f"🔄 Admin {admin.telegram_id} reordered prizes: {request.prize_ids}")
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/statistics", response_model=WheelStatisticsResponse)
|
||||
async def get_statistics(
|
||||
date_from: Optional[datetime] = Query(None),
|
||||
date_to: Optional[datetime] = Query(None),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Получить статистику колеса."""
|
||||
stats = await wheel_service.get_statistics(db, date_from, date_to)
|
||||
|
||||
return WheelStatisticsResponse(
|
||||
total_spins=stats["total_spins"],
|
||||
total_revenue_kopeks=stats["total_revenue_kopeks"],
|
||||
total_payout_kopeks=stats["total_payout_kopeks"],
|
||||
actual_rtp_percent=stats["actual_rtp_percent"],
|
||||
configured_rtp_percent=stats["configured_rtp_percent"],
|
||||
spins_by_payment_type=stats["spins_by_payment_type"],
|
||||
prizes_distribution=stats["prizes_distribution"],
|
||||
top_wins=stats["top_wins"],
|
||||
period_from=stats["period_from"],
|
||||
period_to=stats["period_to"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/spins", response_model=AdminSpinsResponse)
|
||||
async def get_all_spins_endpoint(
|
||||
user_id: Optional[int] = Query(None),
|
||||
date_from: Optional[datetime] = Query(None),
|
||||
date_to: Optional[datetime] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(50, ge=1, le=100),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Получить все спины с фильтрами."""
|
||||
offset = (page - 1) * per_page
|
||||
spins, total = await get_all_spins(
|
||||
db,
|
||||
user_id=user_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
limit=per_page,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
items = [
|
||||
AdminSpinItem(
|
||||
id=spin.id,
|
||||
user_id=spin.user_id,
|
||||
username=spin.user.username if spin.user else None,
|
||||
payment_type=spin.payment_type,
|
||||
payment_amount=spin.payment_amount,
|
||||
payment_value_kopeks=spin.payment_value_kopeks,
|
||||
prize_type=spin.prize_type,
|
||||
prize_value=spin.prize_value,
|
||||
prize_display_name=spin.prize_display_name,
|
||||
prize_value_kopeks=spin.prize_value_kopeks,
|
||||
is_applied=spin.is_applied,
|
||||
created_at=spin.created_at,
|
||||
)
|
||||
for spin in spins
|
||||
]
|
||||
|
||||
pages = math.ceil(total / per_page) if total > 0 else 1
|
||||
|
||||
return AdminSpinsResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
pages=pages,
|
||||
)
|
||||
@@ -269,7 +269,13 @@ async def create_topup(
|
||||
payload=f"cabinet_topup_{user.id}_{request.amount_kopeks}",
|
||||
)
|
||||
if result:
|
||||
payment_url = result.get("pay_url") or result.get("bot_invoice_url")
|
||||
# Priority: web_app for desktop/browser, mini_app for mobile, bot as fallback
|
||||
payment_url = (
|
||||
result.get("web_app_invoice_url")
|
||||
or result.get("mini_app_invoice_url")
|
||||
or result.get("bot_invoice_url")
|
||||
or result.get("pay_url")
|
||||
)
|
||||
payment_id = str(result.get("invoice_id"))
|
||||
else:
|
||||
raise HTTPException(
|
||||
@@ -334,6 +340,126 @@ async def create_topup(
|
||||
detail="Failed to create Platega payment",
|
||||
)
|
||||
|
||||
elif request.payment_method == "heleket":
|
||||
if not settings.is_heleket_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Heleket payment method is unavailable",
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_heleket_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
)
|
||||
|
||||
if result and result.get("payment_url"):
|
||||
payment_url = result.get("payment_url")
|
||||
payment_id = str(result.get("local_payment_id") or result.get("uuid") or "pending")
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create Heleket payment",
|
||||
)
|
||||
|
||||
elif request.payment_method == "mulenpay":
|
||||
if not settings.is_mulenpay_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="MulenPay payment method is unavailable",
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_mulenpay_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
)
|
||||
|
||||
if result and result.get("payment_url"):
|
||||
payment_url = result.get("payment_url")
|
||||
payment_id = str(result.get("local_payment_id") or result.get("mulen_payment_id") or "pending")
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create MulenPay payment",
|
||||
)
|
||||
|
||||
elif request.payment_method == "pal24":
|
||||
if not settings.is_pal24_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="PAL24 payment method is unavailable",
|
||||
)
|
||||
|
||||
# Use payment_option to select card or sbp (default: sbp)
|
||||
option = (request.payment_option or "").strip().lower()
|
||||
if option not in {"card", "sbp"}:
|
||||
option = "sbp"
|
||||
provider_method = "card" if option == "card" else "sbp"
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_pal24_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
payment_method=provider_method,
|
||||
)
|
||||
|
||||
if result:
|
||||
# Select appropriate URL based on payment option
|
||||
preferred_urls = []
|
||||
if option == "sbp":
|
||||
preferred_urls.append(result.get("sbp_url") or result.get("transfer_url"))
|
||||
elif option == "card":
|
||||
preferred_urls.append(result.get("card_url"))
|
||||
preferred_urls.extend([
|
||||
result.get("link_url"),
|
||||
result.get("link_page_url"),
|
||||
result.get("payment_url"),
|
||||
result.get("transfer_url"),
|
||||
])
|
||||
payment_url = next((url for url in preferred_urls if url), None)
|
||||
payment_id = str(result.get("local_payment_id") or result.get("bill_id") or "pending")
|
||||
|
||||
if not payment_url:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create PAL24 payment",
|
||||
)
|
||||
|
||||
elif request.payment_method == "wata":
|
||||
if not settings.is_wata_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Wata payment method is unavailable",
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_wata_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
)
|
||||
|
||||
if result and result.get("payment_url"):
|
||||
payment_url = result.get("payment_url")
|
||||
payment_id = str(result.get("local_payment_id") or result.get("payment_link_id") or "pending")
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create Wata payment",
|
||||
)
|
||||
|
||||
else:
|
||||
# For other payment methods, redirect to bot
|
||||
raise HTTPException(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Branding routes for cabinet - logo and project name management."""
|
||||
"""Branding routes for cabinet - logo, project name, and theme colors management."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -23,15 +23,16 @@ router = APIRouter(prefix="/branding", tags=["Branding"])
|
||||
|
||||
# Directory for storing branding assets
|
||||
BRANDING_DIR = Path("data/branding")
|
||||
LOGO_FILENAME = "logo.png"
|
||||
LOGO_EXTENSIONS = [".png", ".jpg", ".jpeg", ".webp", ".svg"]
|
||||
|
||||
# Settings keys
|
||||
BRANDING_NAME_KEY = "CABINET_BRANDING_NAME"
|
||||
BRANDING_LOGO_KEY = "CABINET_BRANDING_LOGO" # Stores "custom" or "default"
|
||||
THEME_COLORS_KEY = "CABINET_THEME_COLORS" # Stores JSON with theme colors
|
||||
|
||||
# Allowed image types
|
||||
ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/jpg", "image/webp", "image/svg+xml"}
|
||||
MAX_FILE_SIZE = 2 * 1024 * 1024 # 2MB
|
||||
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB for larger logos
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
@@ -49,6 +50,55 @@ class BrandingNameUpdate(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class ThemeColorsResponse(BaseModel):
|
||||
"""Theme colors settings."""
|
||||
accent: str = "#3b82f6"
|
||||
darkBackground: str = "#0a0f1a"
|
||||
darkSurface: str = "#0f172a"
|
||||
darkText: str = "#f1f5f9"
|
||||
darkTextSecondary: str = "#94a3b8"
|
||||
lightBackground: str = "#F7E7CE"
|
||||
lightSurface: str = "#FEF9F0"
|
||||
lightText: str = "#1F1A12"
|
||||
lightTextSecondary: str = "#7D6B48"
|
||||
success: str = "#22c55e"
|
||||
warning: str = "#f59e0b"
|
||||
error: str = "#ef4444"
|
||||
|
||||
|
||||
class ThemeColorsUpdate(BaseModel):
|
||||
"""Request to update theme colors (partial update allowed)."""
|
||||
accent: Optional[str] = None
|
||||
darkBackground: Optional[str] = None
|
||||
darkSurface: Optional[str] = None
|
||||
darkText: Optional[str] = None
|
||||
darkTextSecondary: Optional[str] = None
|
||||
lightBackground: Optional[str] = None
|
||||
lightSurface: Optional[str] = None
|
||||
lightText: Optional[str] = None
|
||||
lightTextSecondary: Optional[str] = None
|
||||
success: Optional[str] = None
|
||||
warning: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
# Default theme colors
|
||||
DEFAULT_THEME_COLORS = {
|
||||
"accent": "#3b82f6",
|
||||
"darkBackground": "#0a0f1a",
|
||||
"darkSurface": "#0f172a",
|
||||
"darkText": "#f1f5f9",
|
||||
"darkTextSecondary": "#94a3b8",
|
||||
"lightBackground": "#F7E7CE",
|
||||
"lightSurface": "#FEF9F0",
|
||||
"lightText": "#1F1A12",
|
||||
"lightTextSecondary": "#7D6B48",
|
||||
"success": "#22c55e",
|
||||
"warning": "#f59e0b",
|
||||
"error": "#ef4444",
|
||||
}
|
||||
|
||||
|
||||
# ============ Helper Functions ============
|
||||
|
||||
def ensure_branding_dir():
|
||||
@@ -81,14 +131,23 @@ async def set_setting_value(db: AsyncSession, key: str, value: str):
|
||||
await db.commit()
|
||||
|
||||
|
||||
def get_logo_path() -> Path:
|
||||
"""Get the path to the custom logo file."""
|
||||
return BRANDING_DIR / LOGO_FILENAME
|
||||
def get_logo_path() -> Optional[Path]:
|
||||
"""Get the path to the custom logo file (any supported format)."""
|
||||
if not BRANDING_DIR.exists():
|
||||
return None
|
||||
|
||||
# Search for logo file with any supported extension
|
||||
for ext in LOGO_EXTENSIONS:
|
||||
logo_path = BRANDING_DIR / f"logo{ext}"
|
||||
if logo_path.exists():
|
||||
return logo_path
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def has_custom_logo() -> bool:
|
||||
"""Check if a custom logo exists."""
|
||||
return get_logo_path().exists()
|
||||
return get_logo_path() is not None
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
@@ -129,7 +188,7 @@ async def get_logo():
|
||||
"""
|
||||
logo_path = get_logo_path()
|
||||
|
||||
if not logo_path.exists():
|
||||
if logo_path is None or not logo_path.exists():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No custom logo set"
|
||||
@@ -279,3 +338,95 @@ async def delete_logo(
|
||||
logo_letter=logo_letter,
|
||||
has_custom_logo=False,
|
||||
)
|
||||
|
||||
|
||||
# ============ Theme Colors Routes ============
|
||||
|
||||
def validate_hex_color(color: str) -> bool:
|
||||
"""Validate hex color format."""
|
||||
if not color or not isinstance(color, str):
|
||||
return False
|
||||
if not color.startswith("#"):
|
||||
return False
|
||||
hex_part = color[1:]
|
||||
if len(hex_part) not in (3, 6):
|
||||
return False
|
||||
try:
|
||||
int(hex_part, 16)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
@router.get("/colors", response_model=ThemeColorsResponse)
|
||||
async def get_theme_colors(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
Get current theme colors.
|
||||
This is a public endpoint - no authentication required.
|
||||
"""
|
||||
colors_json = await get_setting_value(db, THEME_COLORS_KEY)
|
||||
|
||||
if colors_json:
|
||||
try:
|
||||
colors = json.loads(colors_json)
|
||||
# Merge with defaults to ensure all fields exist
|
||||
merged = {**DEFAULT_THEME_COLORS, **colors}
|
||||
return ThemeColorsResponse(**merged)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
return ThemeColorsResponse(**DEFAULT_THEME_COLORS)
|
||||
|
||||
|
||||
@router.patch("/colors", response_model=ThemeColorsResponse)
|
||||
async def update_theme_colors(
|
||||
payload: ThemeColorsUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update theme colors. Admin only. Partial update supported."""
|
||||
# Get current colors
|
||||
colors_json = await get_setting_value(db, THEME_COLORS_KEY)
|
||||
current_colors = DEFAULT_THEME_COLORS.copy()
|
||||
|
||||
if colors_json:
|
||||
try:
|
||||
current_colors.update(json.loads(colors_json))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Update with new values (only non-None fields)
|
||||
update_data = payload.model_dump(exclude_none=True)
|
||||
|
||||
# Validate hex colors
|
||||
for key, value in update_data.items():
|
||||
if not validate_hex_color(value):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid hex color for {key}: {value}"
|
||||
)
|
||||
|
||||
current_colors.update(update_data)
|
||||
|
||||
# Save to database
|
||||
await set_setting_value(db, THEME_COLORS_KEY, json.dumps(current_colors))
|
||||
|
||||
logger.info(f"Admin {admin.telegram_id} updated theme colors: {list(update_data.keys())}")
|
||||
|
||||
return ThemeColorsResponse(**current_colors)
|
||||
|
||||
|
||||
@router.post("/colors/reset", response_model=ThemeColorsResponse)
|
||||
async def reset_theme_colors(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reset theme colors to defaults. Admin only."""
|
||||
# Save default colors
|
||||
await set_setting_value(db, THEME_COLORS_KEY, json.dumps(DEFAULT_THEME_COLORS))
|
||||
|
||||
logger.info(f"Admin {admin.telegram_id} reset theme colors to defaults")
|
||||
|
||||
return ThemeColorsResponse(**DEFAULT_THEME_COLORS)
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Media upload/download routes for cabinet tickets."""
|
||||
|
||||
import logging
|
||||
import mimetypes
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.types import BufferedInputFile
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/media", tags=["Cabinet Media"])
|
||||
|
||||
ALLOWED_MEDIA_TYPES = {"photo", "video", "document"}
|
||||
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
|
||||
|
||||
|
||||
class MediaUploadResponse(BaseModel):
|
||||
"""Response after successful media upload."""
|
||||
media_type: str
|
||||
file_id: str
|
||||
file_unique_id: Optional[str] = None
|
||||
media_url: str
|
||||
|
||||
|
||||
def _resolve_target_chat_id() -> int:
|
||||
"""Get chat ID for uploading files (notification channel or first admin)."""
|
||||
chat_id = settings.get_admin_notifications_chat_id()
|
||||
if chat_id is not None:
|
||||
return chat_id
|
||||
|
||||
admin_ids = settings.get_admin_ids()
|
||||
if admin_ids:
|
||||
return admin_ids[0]
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="No chat configured for file uploads",
|
||||
)
|
||||
|
||||
|
||||
def _build_media_url(request: Request, file_id: str) -> str:
|
||||
"""Build URL for downloading media."""
|
||||
return str(request.url_for("cabinet_download_media", file_id=file_id))
|
||||
|
||||
|
||||
@router.post("/upload", response_model=MediaUploadResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def upload_media(
|
||||
request: Request,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
file: UploadFile = File(...),
|
||||
media_type: str = Form("photo", description="File type: photo, video, or document"),
|
||||
):
|
||||
"""
|
||||
Upload media file for use in ticket messages.
|
||||
Returns file_id that can be used when creating ticket or adding message.
|
||||
"""
|
||||
media_type_normalized = (media_type or "").strip().lower()
|
||||
if media_type_normalized not in ALLOWED_MEDIA_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported media type. Allowed: {', '.join(ALLOWED_MEDIA_TYPES)}",
|
||||
)
|
||||
|
||||
# Read and validate file
|
||||
file_bytes = await file.read()
|
||||
if not file_bytes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="File is empty",
|
||||
)
|
||||
|
||||
if len(file_bytes) > MAX_FILE_SIZE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File too large. Maximum size: {MAX_FILE_SIZE // 1024 // 1024}MB",
|
||||
)
|
||||
|
||||
# Validate content type for photos
|
||||
if media_type_normalized == "photo":
|
||||
allowed_image_types = {"image/jpeg", "image/png", "image/gif", "image/webp"}
|
||||
if file.content_type and file.content_type not in allowed_image_types:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid image type. Allowed: JPEG, PNG, GIF, WebP",
|
||||
)
|
||||
|
||||
target_chat_id = _resolve_target_chat_id()
|
||||
upload = BufferedInputFile(file_bytes, filename=file.filename or "upload")
|
||||
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
|
||||
try:
|
||||
if media_type_normalized == "photo":
|
||||
message = await bot.send_photo(
|
||||
chat_id=target_chat_id,
|
||||
photo=upload,
|
||||
)
|
||||
media = message.photo[-1]
|
||||
elif media_type_normalized == "video":
|
||||
message = await bot.send_video(
|
||||
chat_id=target_chat_id,
|
||||
video=upload,
|
||||
)
|
||||
media = message.video
|
||||
else:
|
||||
message = await bot.send_document(
|
||||
chat_id=target_chat_id,
|
||||
document=upload,
|
||||
)
|
||||
media = message.document
|
||||
|
||||
media_url = _build_media_url(request, media.file_id)
|
||||
|
||||
logger.info(f"User {user.telegram_id} uploaded {media_type_normalized}: {media.file_id}")
|
||||
|
||||
return MediaUploadResponse(
|
||||
media_type=media_type_normalized,
|
||||
file_id=media.file_id,
|
||||
file_unique_id=getattr(media, "file_unique_id", None),
|
||||
media_url=media_url,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.error(f"Failed to upload media for user {user.telegram_id}: {error}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to upload media",
|
||||
) from error
|
||||
finally:
|
||||
await bot.session.close()
|
||||
|
||||
|
||||
@router.get("/{file_id}", name="cabinet_download_media")
|
||||
async def download_media(
|
||||
file_id: str,
|
||||
) -> Response:
|
||||
"""
|
||||
Download media file by file_id.
|
||||
Used to display images/documents in ticket messages.
|
||||
"""
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
|
||||
try:
|
||||
file = await bot.get_file(file_id)
|
||||
if not file.file_path:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Media file not found",
|
||||
)
|
||||
|
||||
buffer = await bot.download_file(file.file_path)
|
||||
|
||||
if hasattr(buffer, "seek"):
|
||||
buffer.seek(0)
|
||||
|
||||
content = buffer.read() if hasattr(buffer, "read") else bytes(buffer)
|
||||
filename = file.file_path.split("/")[-1]
|
||||
|
||||
media_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
||||
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=media_type,
|
||||
headers={
|
||||
"Content-Disposition": f"inline; filename={filename}",
|
||||
"Cache-Control": "public, max-age=86400", # Cache for 24 hours
|
||||
},
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.error(f"Failed to download media {file_id}: {error}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to download media",
|
||||
) from error
|
||||
finally:
|
||||
await bot.session.close()
|
||||
@@ -9,10 +9,20 @@ from typing import List, Optional, Dict, Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User, Subscription, ServerSquad
|
||||
from app.database.crud.subscription import create_trial_subscription, get_subscription_by_user_id
|
||||
from app.database.models import User, Subscription, ServerSquad, Tariff, TransactionType
|
||||
from app.database.crud.subscription import (
|
||||
create_trial_subscription,
|
||||
get_subscription_by_user_id,
|
||||
create_paid_subscription,
|
||||
extend_subscription,
|
||||
)
|
||||
from app.database.crud.tariff import get_tariffs_for_user, get_tariff_by_id
|
||||
from app.database.crud.server_squad import get_server_squad_by_uuid
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from sqlalchemy import select
|
||||
from app.config import settings, PERIOD_PRICES
|
||||
from app.utils.pricing_utils import format_period_description
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.subscription_purchase_service import (
|
||||
MiniAppSubscriptionPurchaseService,
|
||||
@@ -33,6 +43,7 @@ from ..schemas.subscription import (
|
||||
TrialInfoResponse,
|
||||
PurchaseSelectionRequest,
|
||||
PurchasePreviewRequest,
|
||||
TariffPurchaseRequest,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -247,8 +258,24 @@ async def renew_subscription(
|
||||
|
||||
|
||||
@router.get("/traffic-packages", response_model=List[TrafficPackageResponse])
|
||||
async def get_traffic_packages():
|
||||
async def get_traffic_packages(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get available traffic packages."""
|
||||
# Проверяем глобальную настройку
|
||||
if not settings.is_traffic_topup_enabled():
|
||||
return []
|
||||
|
||||
# Проверяем настройку тарифа пользователя
|
||||
from app.database.crud.user import get_user_by_id
|
||||
fresh_user = await get_user_by_id(db, user.id)
|
||||
if fresh_user and fresh_user.subscription and fresh_user.subscription.tariff_id:
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
tariff = await get_tariff_by_id(db, fresh_user.subscription.tariff_id)
|
||||
if tariff and not tariff.allow_traffic_topup:
|
||||
return []
|
||||
|
||||
packages = settings.get_traffic_packages()
|
||||
result = []
|
||||
|
||||
@@ -273,6 +300,13 @@ async def purchase_traffic(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Purchase additional traffic."""
|
||||
# Проверяем глобальную настройку
|
||||
if not settings.is_traffic_topup_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Traffic top-up feature is disabled",
|
||||
)
|
||||
|
||||
await db.refresh(user, ["subscription"])
|
||||
|
||||
if not user.subscription:
|
||||
@@ -281,6 +315,16 @@ async def purchase_traffic(
|
||||
detail="No subscription found",
|
||||
)
|
||||
|
||||
# Проверяем настройку тарифа
|
||||
if user.subscription.tariff_id:
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
tariff = await get_tariff_by_id(db, user.subscription.tariff_id)
|
||||
if tariff and not tariff.allow_traffic_topup:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Traffic top-up is not available for your tariff",
|
||||
)
|
||||
|
||||
# Find matching package
|
||||
packages = settings.get_traffic_packages()
|
||||
matching_pkg = next(
|
||||
@@ -502,13 +546,39 @@ async def activate_trial(
|
||||
user.balance_kopeks -= price_kopeks
|
||||
logger.info(f"User {user.id} paid {price_kopeks} kopeks for trial activation")
|
||||
|
||||
# Get trial parameters from tariff if configured (same logic as bot handler)
|
||||
trial_duration = settings.TRIAL_DURATION_DAYS
|
||||
trial_traffic_limit = settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||
trial_device_limit = settings.TRIAL_DEVICE_LIMIT
|
||||
trial_squads = []
|
||||
tariff_id_for_trial = None
|
||||
|
||||
trial_tariff_id = settings.get_trial_tariff_id()
|
||||
if trial_tariff_id:
|
||||
try:
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
trial_tariff = await get_tariff_by_id(db, trial_tariff_id)
|
||||
if trial_tariff:
|
||||
trial_traffic_limit = trial_tariff.traffic_limit_gb
|
||||
trial_device_limit = trial_tariff.device_limit
|
||||
trial_squads = trial_tariff.allowed_squads or []
|
||||
tariff_id_for_trial = trial_tariff.id
|
||||
tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None)
|
||||
if tariff_trial_days:
|
||||
trial_duration = tariff_trial_days
|
||||
logger.info(f"Using trial tariff {trial_tariff.name} (ID: {trial_tariff.id}) with squads: {trial_squads}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting trial tariff: {e}")
|
||||
|
||||
# Create trial subscription
|
||||
subscription = await create_trial_subscription(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
duration_days=settings.TRIAL_DURATION_DAYS,
|
||||
traffic_limit_gb=settings.TRIAL_TRAFFIC_LIMIT_GB,
|
||||
device_limit=settings.TRIAL_DEVICE_LIMIT,
|
||||
duration_days=trial_duration,
|
||||
traffic_limit_gb=trial_traffic_limit,
|
||||
device_limit=trial_device_limit,
|
||||
connected_squads=trial_squads if trial_squads else None,
|
||||
tariff_id=tariff_id_for_trial,
|
||||
)
|
||||
|
||||
logger.info(f"Trial subscription activated for user {user.id}")
|
||||
@@ -522,6 +592,24 @@ async def activate_trial(
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create RemnaWave user for trial: {e}")
|
||||
|
||||
# Send admin notification about trial activation
|
||||
try:
|
||||
from aiogram import Bot
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
charged_amount = settings.TRIAL_ACTIVATION_PRICE if requires_payment else None
|
||||
await notification_service.send_trial_activation_notification(
|
||||
db, user, subscription, charged_amount_kopeks=charged_amount
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send trial activation notification: {e}")
|
||||
|
||||
return _subscription_to_response(subscription)
|
||||
|
||||
|
||||
@@ -530,6 +618,64 @@ async def activate_trial(
|
||||
purchase_service = MiniAppSubscriptionPurchaseService()
|
||||
|
||||
|
||||
async def _build_tariff_response(
|
||||
db: AsyncSession,
|
||||
tariff: Tariff,
|
||||
current_tariff_id: Optional[int] = None,
|
||||
language: str = "ru",
|
||||
) -> Dict[str, Any]:
|
||||
"""Build tariff model for API response."""
|
||||
servers = []
|
||||
servers_count = 0
|
||||
|
||||
if tariff.allowed_squads:
|
||||
servers_count = len(tariff.allowed_squads)
|
||||
for squad_uuid in tariff.allowed_squads[:5]: # Limit for preview
|
||||
server = await get_server_squad_by_uuid(db, squad_uuid)
|
||||
if server:
|
||||
servers.append({
|
||||
"uuid": squad_uuid,
|
||||
"name": server.display_name or squad_uuid[:8],
|
||||
})
|
||||
|
||||
periods = []
|
||||
if tariff.period_prices:
|
||||
for period_str, price_kopeks in sorted(tariff.period_prices.items(), key=lambda x: int(x[0])):
|
||||
if int(price_kopeks) <= 0:
|
||||
continue # Skip disabled periods
|
||||
period_days = int(period_str)
|
||||
months = max(1, period_days // 30)
|
||||
per_month = price_kopeks // months if months > 0 else price_kopeks
|
||||
|
||||
periods.append({
|
||||
"days": period_days,
|
||||
"months": months,
|
||||
"label": format_period_description(period_days, language),
|
||||
"price_kopeks": price_kopeks,
|
||||
"price_label": settings.format_price(price_kopeks),
|
||||
"price_per_month_kopeks": per_month,
|
||||
"price_per_month_label": settings.format_price(per_month),
|
||||
})
|
||||
|
||||
traffic_label = "♾️ Безлимит" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ"
|
||||
|
||||
return {
|
||||
"id": tariff.id,
|
||||
"name": tariff.name,
|
||||
"description": tariff.description,
|
||||
"tier_level": tariff.tier_level,
|
||||
"traffic_limit_gb": tariff.traffic_limit_gb,
|
||||
"traffic_limit_label": traffic_label,
|
||||
"is_unlimited_traffic": tariff.traffic_limit_gb == 0,
|
||||
"device_limit": tariff.device_limit,
|
||||
"servers_count": servers_count,
|
||||
"servers": servers,
|
||||
"periods": periods,
|
||||
"is_current": current_tariff_id == tariff.id if current_tariff_id else False,
|
||||
"is_available": tariff.is_active,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/purchase-options")
|
||||
async def get_purchase_options(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
@@ -537,8 +683,37 @@ async def get_purchase_options(
|
||||
) -> Dict[str, Any]:
|
||||
"""Get all subscription purchase options (periods, servers, traffic, devices)."""
|
||||
try:
|
||||
sales_mode = settings.get_sales_mode()
|
||||
|
||||
# Tariffs mode - return list of tariffs
|
||||
if settings.is_tariffs_mode():
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
promo_group_id = promo_group.id if promo_group else None
|
||||
tariffs = await get_tariffs_for_user(db, promo_group_id)
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
current_tariff_id = subscription.tariff_id if subscription else None
|
||||
language = getattr(user, "language", "ru") or "ru"
|
||||
|
||||
tariff_responses = []
|
||||
for tariff in tariffs:
|
||||
tariff_data = await _build_tariff_response(db, tariff, current_tariff_id, language)
|
||||
tariff_responses.append(tariff_data)
|
||||
|
||||
return {
|
||||
"sales_mode": "tariffs",
|
||||
"tariffs": tariff_responses,
|
||||
"current_tariff_id": current_tariff_id,
|
||||
"balance_kopeks": user.balance_kopeks,
|
||||
"balance_label": settings.format_price(user.balance_kopeks),
|
||||
}
|
||||
|
||||
# Classic mode - return periods
|
||||
context = await purchase_service.build_options(db, user)
|
||||
return context.payload
|
||||
payload = context.payload
|
||||
payload["sales_mode"] = "classic"
|
||||
return payload
|
||||
|
||||
except PurchaseValidationError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -640,6 +815,145 @@ async def submit_purchase(
|
||||
)
|
||||
|
||||
|
||||
# ============ Tariff Purchase (for tariffs mode) ============
|
||||
|
||||
@router.post("/purchase-tariff")
|
||||
async def purchase_tariff(
|
||||
request: TariffPurchaseRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> Dict[str, Any]:
|
||||
"""Purchase a tariff (for tariffs mode)."""
|
||||
try:
|
||||
# Check tariffs mode
|
||||
if not settings.is_tariffs_mode():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Tariffs mode is not enabled",
|
||||
)
|
||||
|
||||
# Get tariff
|
||||
tariff = await get_tariff_by_id(db, request.tariff_id)
|
||||
if not tariff or not tariff.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Tariff not found or inactive",
|
||||
)
|
||||
|
||||
# Check tariff availability for user's promo group
|
||||
promo_group = getattr(user, "promo_group", None)
|
||||
promo_group_id = promo_group.id if promo_group else None
|
||||
if not tariff.is_available_for_promo_group(promo_group_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="This tariff is not available for your promo group",
|
||||
)
|
||||
|
||||
# Get price for period
|
||||
price_kopeks = tariff.get_price_for_period(request.period_days)
|
||||
if price_kopeks is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid period for this tariff",
|
||||
)
|
||||
|
||||
# Check balance
|
||||
if user.balance_kopeks < price_kopeks:
|
||||
missing = price_kopeks - user.balance_kopeks
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail={
|
||||
"code": "insufficient_funds",
|
||||
"message": f"Недостаточно средств. Не хватает {settings.format_price(missing)}",
|
||||
"missing_amount": missing,
|
||||
},
|
||||
)
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
|
||||
# Charge balance
|
||||
description = f"Покупка тарифа '{tariff.name}' на {request.period_days} дней"
|
||||
success = await subtract_user_balance(db, user, price_kopeks, description)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="Failed to charge balance",
|
||||
)
|
||||
|
||||
# Create transaction
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=price_kopeks,
|
||||
description=description,
|
||||
)
|
||||
|
||||
if subscription:
|
||||
# Extend/change tariff
|
||||
subscription = await extend_subscription(
|
||||
db=db,
|
||||
subscription=subscription,
|
||||
days=request.period_days,
|
||||
tariff_id=tariff.id,
|
||||
traffic_limit_gb=tariff.traffic_limit_gb,
|
||||
device_limit=tariff.device_limit,
|
||||
connected_squads=tariff.allowed_squads or [],
|
||||
)
|
||||
else:
|
||||
# Create new subscription
|
||||
subscription = await create_paid_subscription(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
days=request.period_days,
|
||||
traffic_limit_gb=tariff.traffic_limit_gb,
|
||||
device_limit=tariff.device_limit,
|
||||
connected_squads=tariff.allowed_squads or [],
|
||||
tariff_id=tariff.id,
|
||||
)
|
||||
|
||||
# Sync with RemnaWave
|
||||
service = SubscriptionService()
|
||||
await service.update_remnawave_user(db, subscription)
|
||||
|
||||
# Save cart for auto-renewal
|
||||
try:
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
cart_data = {
|
||||
"cart_mode": "extend",
|
||||
"subscription_id": subscription.id,
|
||||
"period_days": request.period_days,
|
||||
"total_price": price_kopeks,
|
||||
"tariff_id": tariff.id,
|
||||
"description": f"Продление тарифа {tariff.name} на {request.period_days} дней",
|
||||
}
|
||||
await user_cart_service.save_user_cart(user.id, cart_data)
|
||||
logger.info(f"Tariff cart saved for auto-renewal (cabinet) user {user.telegram_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving tariff cart (cabinet): {e}")
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Тариф '{tariff.name}' успешно активирован",
|
||||
"subscription": _subscription_to_response(subscription),
|
||||
"tariff_id": tariff.id,
|
||||
"tariff_name": tariff.name,
|
||||
"balance_kopeks": user.balance_kopeks,
|
||||
"balance_label": settings.format_price(user.balance_kopeks),
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to purchase tariff for user {user.id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to process tariff purchase",
|
||||
)
|
||||
|
||||
|
||||
# ============ App Config for Connection ============
|
||||
|
||||
def _load_app_config() -> Dict[str, Any]:
|
||||
@@ -689,7 +1003,10 @@ async def get_available_countries(
|
||||
await db.refresh(user, ["subscription"])
|
||||
|
||||
promo_group_id = user.promo_group_id
|
||||
available_servers = await get_available_server_squads(db, promo_group_id=promo_group_id)
|
||||
# Exclude trial-only servers from available servers for purchase
|
||||
available_servers = await get_available_server_squads(
|
||||
db, promo_group_id=promo_group_id, exclude_trial_only=True
|
||||
)
|
||||
|
||||
connected_squads = []
|
||||
if user.subscription:
|
||||
@@ -705,7 +1022,6 @@ async def get_available_countries(
|
||||
"price_rubles": server.price_kopeks / 100,
|
||||
"is_available": server.is_available and not server.is_full,
|
||||
"is_connected": server.squad_uuid in connected_squads,
|
||||
"is_trial_eligible": server.is_trial_eligible,
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -753,7 +1069,10 @@ async def update_countries(
|
||||
current_countries = user.subscription.connected_squads or []
|
||||
promo_group_id = user.promo_group_id
|
||||
|
||||
available_servers = await get_available_server_squads(db, promo_group_id=promo_group_id)
|
||||
# Exclude trial-only servers from available servers for purchase
|
||||
available_servers = await get_available_server_squads(
|
||||
db, promo_group_id=promo_group_id, exclude_trial_only=True
|
||||
)
|
||||
allowed_country_ids = {server.squad_uuid for server in available_servers}
|
||||
|
||||
# Validate selected countries
|
||||
|
||||
@@ -36,6 +36,7 @@ def _message_to_response(message: TicketMessage) -> TicketMessageResponse:
|
||||
is_from_admin=message.is_from_admin,
|
||||
has_media=bool(message.media_file_id),
|
||||
media_type=message.media_type,
|
||||
media_file_id=message.media_file_id,
|
||||
media_caption=message.media_caption,
|
||||
created_at=message.created_at,
|
||||
)
|
||||
@@ -143,12 +144,15 @@ async def create_ticket(
|
||||
db.add(ticket)
|
||||
await db.flush()
|
||||
|
||||
# Create initial message
|
||||
# Create initial message with optional media
|
||||
message = TicketMessage(
|
||||
ticket_id=ticket.id,
|
||||
user_id=user.id,
|
||||
message_text=request.message,
|
||||
is_from_admin=False,
|
||||
media_type=request.media_type,
|
||||
media_file_id=request.media_file_id,
|
||||
media_caption=request.media_caption,
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(message)
|
||||
@@ -243,12 +247,15 @@ async def add_ticket_message(
|
||||
detail="Replies to this ticket are blocked",
|
||||
)
|
||||
|
||||
# Create message
|
||||
# Create message with optional media
|
||||
message = TicketMessage(
|
||||
ticket_id=ticket.id,
|
||||
user_id=user.id,
|
||||
message_text=request.message,
|
||||
is_from_admin=False,
|
||||
media_type=request.media_type,
|
||||
media_file_id=request.media_file_id,
|
||||
media_caption=request.media_caption,
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(message)
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
"""
|
||||
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",
|
||||
)
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Schemas for server management in cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PromoGroupInfo(BaseModel):
|
||||
"""Promo group info for server."""
|
||||
id: int
|
||||
name: str
|
||||
is_selected: bool = False
|
||||
|
||||
|
||||
class ServerListItem(BaseModel):
|
||||
"""Server item for list view."""
|
||||
id: int
|
||||
squad_uuid: str
|
||||
display_name: str
|
||||
original_name: Optional[str] = None
|
||||
country_code: Optional[str] = None
|
||||
is_available: bool
|
||||
is_trial_eligible: bool
|
||||
price_kopeks: int
|
||||
price_rubles: float
|
||||
max_users: Optional[int] = None
|
||||
current_users: int
|
||||
sort_order: int
|
||||
is_full: bool
|
||||
availability_status: str
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ServerListResponse(BaseModel):
|
||||
"""Response with list of servers."""
|
||||
servers: List[ServerListItem]
|
||||
total: int
|
||||
|
||||
|
||||
class ServerDetailResponse(BaseModel):
|
||||
"""Detailed server response."""
|
||||
id: int
|
||||
squad_uuid: str
|
||||
display_name: str
|
||||
original_name: Optional[str] = None
|
||||
country_code: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
is_available: bool
|
||||
is_trial_eligible: bool
|
||||
price_kopeks: int
|
||||
price_rubles: float
|
||||
max_users: Optional[int] = None
|
||||
current_users: int
|
||||
sort_order: int
|
||||
is_full: bool
|
||||
availability_status: str
|
||||
promo_groups: List[PromoGroupInfo]
|
||||
active_subscriptions: int
|
||||
tariffs_using: List[str] # Names of tariffs using this server
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ServerUpdateRequest(BaseModel):
|
||||
"""Request to update a server."""
|
||||
display_name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
country_code: Optional[str] = Field(None, max_length=5)
|
||||
is_available: Optional[bool] = None
|
||||
is_trial_eligible: Optional[bool] = None
|
||||
price_kopeks: Optional[int] = Field(None, ge=0)
|
||||
max_users: Optional[int] = Field(None, ge=0)
|
||||
sort_order: Optional[int] = Field(None, ge=0)
|
||||
promo_group_ids: Optional[List[int]] = None
|
||||
|
||||
|
||||
class ServerToggleResponse(BaseModel):
|
||||
"""Response after toggling server."""
|
||||
id: int
|
||||
is_available: bool
|
||||
message: str
|
||||
|
||||
|
||||
class ServerTrialToggleResponse(BaseModel):
|
||||
"""Response after toggling trial eligibility."""
|
||||
id: int
|
||||
is_trial_eligible: bool
|
||||
message: str
|
||||
|
||||
|
||||
class ServerStatsResponse(BaseModel):
|
||||
"""Server statistics."""
|
||||
id: int
|
||||
display_name: str
|
||||
squad_uuid: str
|
||||
current_users: int
|
||||
max_users: Optional[int]
|
||||
active_subscriptions: int
|
||||
trial_subscriptions: int
|
||||
usage_percent: Optional[float] = None
|
||||
|
||||
|
||||
class ServerSyncResponse(BaseModel):
|
||||
"""Response after syncing with RemnaWave."""
|
||||
created: int
|
||||
updated: int
|
||||
removed: int
|
||||
message: str
|
||||
|
||||
|
||||
class ServerSyncRequest(BaseModel):
|
||||
"""Request to sync servers."""
|
||||
force: bool = False # Force sync even if recently synced
|
||||
@@ -103,3 +103,11 @@ class PurchaseSelectionRequest(BaseModel):
|
||||
class PurchasePreviewRequest(BaseModel):
|
||||
"""Request to preview purchase pricing."""
|
||||
selection: PurchaseSelectionRequest
|
||||
|
||||
|
||||
# ============ Tariff Purchase Schemas ============
|
||||
|
||||
class TariffPurchaseRequest(BaseModel):
|
||||
"""Request to purchase a tariff."""
|
||||
tariff_id: int = Field(..., description="Tariff ID to purchase")
|
||||
period_days: int = Field(..., description="Period in days")
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Schemas for tariff management in cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PeriodPrice(BaseModel):
|
||||
"""Price for a specific period."""
|
||||
days: int = Field(..., ge=1, description="Period in days")
|
||||
price_kopeks: int = Field(..., ge=0, description="Price in kopeks")
|
||||
price_rubles: Optional[float] = None
|
||||
|
||||
def __init__(self, **data):
|
||||
super().__init__(**data)
|
||||
if self.price_rubles is None:
|
||||
self.price_rubles = self.price_kopeks / 100
|
||||
|
||||
|
||||
class ServerTrafficLimit(BaseModel):
|
||||
"""Traffic limit for a specific server."""
|
||||
traffic_limit_gb: int = Field(0, ge=0, description="0 = use default tariff limit")
|
||||
|
||||
|
||||
class ServerInfo(BaseModel):
|
||||
"""Server info for tariff."""
|
||||
id: int
|
||||
squad_uuid: str
|
||||
display_name: str
|
||||
country_code: Optional[str] = None
|
||||
is_selected: bool = False
|
||||
traffic_limit_gb: Optional[int] = None # Индивидуальный лимит для сервера
|
||||
|
||||
|
||||
class PromoGroupInfo(BaseModel):
|
||||
"""Promo group info for tariff."""
|
||||
id: int
|
||||
name: str
|
||||
is_selected: bool = False
|
||||
|
||||
|
||||
class TariffListItem(BaseModel):
|
||||
"""Tariff item for list view."""
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
is_active: bool
|
||||
is_trial_available: bool
|
||||
allow_traffic_topup: bool = True
|
||||
traffic_limit_gb: int
|
||||
device_limit: int
|
||||
tier_level: int
|
||||
display_order: int
|
||||
servers_count: int
|
||||
subscriptions_count: int
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TariffListResponse(BaseModel):
|
||||
"""Response with list of tariffs."""
|
||||
tariffs: List[TariffListItem]
|
||||
total: int
|
||||
|
||||
|
||||
class TariffDetailResponse(BaseModel):
|
||||
"""Detailed tariff response."""
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
is_active: bool
|
||||
is_trial_available: bool
|
||||
allow_traffic_topup: bool = True
|
||||
traffic_limit_gb: int
|
||||
device_limit: int
|
||||
device_price_kopeks: Optional[int] = None
|
||||
tier_level: int
|
||||
display_order: int
|
||||
period_prices: List[PeriodPrice]
|
||||
allowed_squads: List[str] # UUIDs
|
||||
server_traffic_limits: Dict[str, ServerTrafficLimit] = Field(default_factory=dict) # {uuid: {traffic_limit_gb}}
|
||||
servers: List[ServerInfo]
|
||||
promo_groups: List[PromoGroupInfo]
|
||||
subscriptions_count: int
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TariffCreateRequest(BaseModel):
|
||||
"""Request to create a tariff."""
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
is_active: bool = True
|
||||
allow_traffic_topup: bool = True
|
||||
traffic_limit_gb: int = Field(0, ge=0, description="0 = unlimited")
|
||||
device_limit: int = Field(1, ge=1)
|
||||
device_price_kopeks: Optional[int] = Field(None, ge=0)
|
||||
tier_level: int = Field(1, ge=1, le=10)
|
||||
period_prices: List[PeriodPrice] = Field(default_factory=list)
|
||||
allowed_squads: List[str] = Field(default_factory=list, description="Server UUIDs")
|
||||
server_traffic_limits: Dict[str, ServerTrafficLimit] = Field(default_factory=dict, description="Per-server traffic limits")
|
||||
promo_group_ids: List[int] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TariffUpdateRequest(BaseModel):
|
||||
"""Request to update a tariff."""
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
allow_traffic_topup: Optional[bool] = None
|
||||
traffic_limit_gb: Optional[int] = Field(None, ge=0)
|
||||
device_limit: Optional[int] = Field(None, ge=1)
|
||||
device_price_kopeks: Optional[int] = Field(None, ge=0)
|
||||
tier_level: Optional[int] = Field(None, ge=1, le=10)
|
||||
display_order: Optional[int] = Field(None, ge=0)
|
||||
period_prices: Optional[List[PeriodPrice]] = None
|
||||
allowed_squads: Optional[List[str]] = None
|
||||
server_traffic_limits: Optional[Dict[str, ServerTrafficLimit]] = None
|
||||
promo_group_ids: Optional[List[int]] = None
|
||||
|
||||
|
||||
class TariffToggleResponse(BaseModel):
|
||||
"""Response after toggling tariff."""
|
||||
id: int
|
||||
is_active: bool
|
||||
message: str
|
||||
|
||||
|
||||
class TariffTrialResponse(BaseModel):
|
||||
"""Response after setting trial tariff."""
|
||||
id: int
|
||||
is_trial_available: bool
|
||||
message: str
|
||||
|
||||
|
||||
class TariffStatsResponse(BaseModel):
|
||||
"""Tariff statistics."""
|
||||
id: int
|
||||
name: str
|
||||
subscriptions_count: int
|
||||
active_subscriptions: int
|
||||
trial_subscriptions: int
|
||||
revenue_kopeks: int
|
||||
revenue_rubles: float
|
||||
@@ -12,6 +12,7 @@ class TicketMessageResponse(BaseModel):
|
||||
is_from_admin: bool
|
||||
has_media: bool = False
|
||||
media_type: Optional[str] = None
|
||||
media_file_id: Optional[str] = None
|
||||
media_caption: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
@@ -64,8 +65,14 @@ class TicketCreateRequest(BaseModel):
|
||||
"""Request to create a new ticket."""
|
||||
title: str = Field(..., min_length=3, max_length=255, description="Ticket title")
|
||||
message: str = Field(..., min_length=10, max_length=4000, description="Initial message")
|
||||
media_type: Optional[str] = Field(None, description="Media type: photo, video, document")
|
||||
media_file_id: Optional[str] = Field(None, description="Telegram file_id of uploaded media")
|
||||
media_caption: Optional[str] = Field(None, max_length=1000, description="Media caption")
|
||||
|
||||
|
||||
class TicketMessageCreateRequest(BaseModel):
|
||||
"""Request to add message to ticket."""
|
||||
message: str = Field(..., min_length=1, max_length=4000, description="Message text")
|
||||
media_type: Optional[str] = Field(None, description="Media type: photo, video, document")
|
||||
media_file_id: Optional[str] = Field(None, description="Telegram file_id of uploaded media")
|
||||
media_caption: Optional[str] = Field(None, max_length=1000, description="Media caption")
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
"""Схемы для колеса удачи (Fortune Wheel)."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel, Field
|
||||
from enum import Enum
|
||||
|
||||
|
||||
# ==================== ENUMS ====================
|
||||
|
||||
|
||||
class WheelPaymentType(str, Enum):
|
||||
"""Способы оплаты спина."""
|
||||
TELEGRAM_STARS = "telegram_stars"
|
||||
SUBSCRIPTION_DAYS = "subscription_days"
|
||||
|
||||
|
||||
class WheelPrizeType(str, Enum):
|
||||
"""Типы призов."""
|
||||
SUBSCRIPTION_DAYS = "subscription_days"
|
||||
BALANCE_BONUS = "balance_bonus"
|
||||
TRAFFIC_GB = "traffic_gb"
|
||||
PROMOCODE = "promocode"
|
||||
NOTHING = "nothing"
|
||||
|
||||
|
||||
# ==================== USER SCHEMAS ====================
|
||||
|
||||
|
||||
class WheelPrizeDisplay(BaseModel):
|
||||
"""Отображение приза для пользователя."""
|
||||
id: int
|
||||
display_name: str
|
||||
emoji: str
|
||||
color: str
|
||||
prize_type: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class WheelConfigResponse(BaseModel):
|
||||
"""Конфигурация колеса для пользователя."""
|
||||
is_enabled: bool
|
||||
name: str
|
||||
spin_cost_stars: Optional[int] = None
|
||||
spin_cost_days: Optional[int] = None
|
||||
spin_cost_stars_enabled: bool
|
||||
spin_cost_days_enabled: bool
|
||||
prizes: List[WheelPrizeDisplay]
|
||||
daily_limit: int
|
||||
user_spins_today: int
|
||||
can_spin: bool
|
||||
can_spin_reason: Optional[str] = None
|
||||
can_pay_stars: bool = False
|
||||
can_pay_days: bool = False
|
||||
user_balance_kopeks: int = 0
|
||||
required_balance_kopeks: int = 0
|
||||
|
||||
|
||||
class SpinAvailabilityResponse(BaseModel):
|
||||
"""Доступность спина."""
|
||||
can_spin: bool
|
||||
reason: Optional[str] = None
|
||||
spins_remaining_today: int
|
||||
can_pay_stars: bool
|
||||
can_pay_days: bool
|
||||
min_subscription_days: int
|
||||
user_subscription_days: int
|
||||
user_balance_kopeks: int = 0
|
||||
required_balance_kopeks: int = 0
|
||||
|
||||
|
||||
class SpinRequest(BaseModel):
|
||||
"""Запрос на спин."""
|
||||
payment_type: WheelPaymentType
|
||||
|
||||
|
||||
class SpinResultResponse(BaseModel):
|
||||
"""Результат спина."""
|
||||
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
|
||||
|
||||
|
||||
class SpinHistoryItem(BaseModel):
|
||||
"""Элемент истории спинов."""
|
||||
id: int
|
||||
payment_type: str
|
||||
payment_amount: int
|
||||
prize_type: str
|
||||
prize_value: int
|
||||
prize_display_name: str
|
||||
emoji: str = "🎁"
|
||||
color: str = "#3B82F6"
|
||||
prize_value_kopeks: int
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class SpinHistoryResponse(BaseModel):
|
||||
"""История спинов с пагинацией."""
|
||||
items: List[SpinHistoryItem]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
pages: int
|
||||
|
||||
|
||||
# ==================== ADMIN SCHEMAS ====================
|
||||
|
||||
|
||||
class WheelPrizeAdminResponse(BaseModel):
|
||||
"""Полная информация о призе для админа."""
|
||||
id: int
|
||||
config_id: int
|
||||
prize_type: str
|
||||
prize_value: int
|
||||
display_name: str
|
||||
emoji: str
|
||||
color: str
|
||||
prize_value_kopeks: int
|
||||
sort_order: int
|
||||
manual_probability: Optional[float] = None
|
||||
is_active: bool
|
||||
promo_balance_bonus_kopeks: int = 0
|
||||
promo_subscription_days: int = 0
|
||||
promo_traffic_gb: int = 0
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AdminWheelConfigResponse(BaseModel):
|
||||
"""Полная конфигурация колеса для админа."""
|
||||
id: int
|
||||
is_enabled: bool
|
||||
name: str
|
||||
spin_cost_stars: int
|
||||
spin_cost_days: int
|
||||
spin_cost_stars_enabled: bool
|
||||
spin_cost_days_enabled: bool
|
||||
rtp_percent: int
|
||||
daily_spin_limit: int
|
||||
min_subscription_days_for_day_payment: int
|
||||
promo_prefix: str
|
||||
promo_validity_days: int
|
||||
prizes: List[WheelPrizeAdminResponse]
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UpdateWheelConfigRequest(BaseModel):
|
||||
"""Запрос на обновление конфига колеса."""
|
||||
is_enabled: Optional[bool] = None
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
spin_cost_stars: Optional[int] = Field(None, ge=1, le=1000)
|
||||
spin_cost_days: Optional[int] = Field(None, ge=1, le=30)
|
||||
spin_cost_stars_enabled: Optional[bool] = None
|
||||
spin_cost_days_enabled: Optional[bool] = None
|
||||
rtp_percent: Optional[int] = Field(None, ge=0, le=100)
|
||||
daily_spin_limit: Optional[int] = Field(None, ge=0, le=100)
|
||||
min_subscription_days_for_day_payment: Optional[int] = Field(None, ge=1, le=30)
|
||||
promo_prefix: Optional[str] = Field(None, min_length=1, max_length=20)
|
||||
promo_validity_days: Optional[int] = Field(None, ge=1, le=365)
|
||||
|
||||
|
||||
class CreatePrizeRequest(BaseModel):
|
||||
"""Запрос на создание приза."""
|
||||
prize_type: WheelPrizeType
|
||||
prize_value: int = Field(..., ge=0)
|
||||
display_name: str = Field(..., min_length=1, max_length=100)
|
||||
emoji: str = Field(default="🎁", max_length=10)
|
||||
color: str = Field(default="#3B82F6", pattern=r'^#[0-9A-Fa-f]{6}$')
|
||||
prize_value_kopeks: int = Field(..., ge=0)
|
||||
sort_order: int = Field(default=0, ge=0)
|
||||
manual_probability: Optional[float] = Field(None, ge=0, le=1)
|
||||
is_active: bool = True
|
||||
promo_balance_bonus_kopeks: int = Field(default=0, ge=0)
|
||||
promo_subscription_days: int = Field(default=0, ge=0)
|
||||
promo_traffic_gb: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class UpdatePrizeRequest(BaseModel):
|
||||
"""Запрос на обновление приза."""
|
||||
prize_type: Optional[WheelPrizeType] = None
|
||||
prize_value: Optional[int] = Field(None, ge=0)
|
||||
display_name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
emoji: Optional[str] = Field(None, max_length=10)
|
||||
color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')
|
||||
prize_value_kopeks: Optional[int] = Field(None, ge=0)
|
||||
sort_order: Optional[int] = Field(None, ge=0)
|
||||
manual_probability: Optional[float] = Field(None, ge=0, le=1)
|
||||
is_active: Optional[bool] = None
|
||||
promo_balance_bonus_kopeks: Optional[int] = Field(None, ge=0)
|
||||
promo_subscription_days: Optional[int] = Field(None, ge=0)
|
||||
promo_traffic_gb: Optional[int] = Field(None, ge=0)
|
||||
|
||||
|
||||
class ReorderPrizesRequest(BaseModel):
|
||||
"""Запрос на переупорядочивание призов."""
|
||||
prize_ids: List[int]
|
||||
|
||||
|
||||
class AdminSpinItem(BaseModel):
|
||||
"""Спин для админки."""
|
||||
id: int
|
||||
user_id: int
|
||||
username: Optional[str] = None
|
||||
payment_type: str
|
||||
payment_amount: int
|
||||
payment_value_kopeks: int
|
||||
prize_type: str
|
||||
prize_value: int
|
||||
prize_display_name: str
|
||||
prize_value_kopeks: int
|
||||
is_applied: bool
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AdminSpinsResponse(BaseModel):
|
||||
"""Список спинов для админки с пагинацией."""
|
||||
items: List[AdminSpinItem]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
pages: int
|
||||
|
||||
|
||||
class WheelStatisticsResponse(BaseModel):
|
||||
"""Статистика колеса."""
|
||||
total_spins: int
|
||||
total_revenue_kopeks: int
|
||||
total_payout_kopeks: int
|
||||
actual_rtp_percent: float
|
||||
configured_rtp_percent: int
|
||||
spins_by_payment_type: dict
|
||||
prizes_distribution: List[dict]
|
||||
top_wins: List[dict]
|
||||
period_from: Optional[str] = None
|
||||
period_to: Optional[str] = None
|
||||
+70
-16
@@ -1658,11 +1658,25 @@ class Settings(BaseSettings):
|
||||
return self.MAINTENANCE_MONITORING_ENABLED
|
||||
|
||||
def get_available_subscription_periods(self) -> List[int]:
|
||||
"""
|
||||
Возвращает доступные периоды подписки.
|
||||
Приоритет: БД (через PERIOD_PRICES) > .env
|
||||
"""
|
||||
from app.config import PERIOD_PRICES, get_db_period_prices
|
||||
|
||||
# Если есть данные из БД - используем их
|
||||
db_prices = get_db_period_prices()
|
||||
if db_prices:
|
||||
# Возвращаем только периоды с ценой > 0
|
||||
periods = sorted([days for days, price in db_prices.items() if price > 0])
|
||||
return periods if periods else [30, 90, 180]
|
||||
|
||||
# Fallback на .env
|
||||
try:
|
||||
periods_str = self.AVAILABLE_SUBSCRIPTION_PERIODS
|
||||
if not periods_str.strip():
|
||||
return [30, 90, 180]
|
||||
|
||||
return [30, 90, 180]
|
||||
|
||||
periods = []
|
||||
for period_str in periods_str.split(','):
|
||||
period_str = period_str.strip()
|
||||
@@ -1670,18 +1684,32 @@ class Settings(BaseSettings):
|
||||
period = int(period_str)
|
||||
if hasattr(self, f'PRICE_{period}_DAYS'):
|
||||
periods.append(period)
|
||||
|
||||
|
||||
return periods if periods else [30, 90, 180]
|
||||
|
||||
|
||||
except (ValueError, AttributeError):
|
||||
return [30, 90, 180]
|
||||
|
||||
def get_available_renewal_periods(self) -> List[int]:
|
||||
"""
|
||||
Возвращает доступные периоды продления.
|
||||
Приоритет: БД (через PERIOD_PRICES) > .env
|
||||
"""
|
||||
from app.config import get_db_period_prices
|
||||
|
||||
# Если есть данные из БД - используем их
|
||||
db_prices = get_db_period_prices()
|
||||
if db_prices:
|
||||
# Возвращаем только периоды с ценой > 0
|
||||
periods = sorted([days for days, price in db_prices.items() if price > 0])
|
||||
return periods if periods else [30, 90, 180]
|
||||
|
||||
# Fallback на .env
|
||||
try:
|
||||
periods_str = self.AVAILABLE_RENEWAL_PERIODS
|
||||
if not periods_str.strip():
|
||||
return [30, 90, 180]
|
||||
|
||||
return [30, 90, 180]
|
||||
|
||||
periods = []
|
||||
for period_str in periods_str.split(','):
|
||||
period_str = period_str.strip()
|
||||
@@ -1689,9 +1717,9 @@ class Settings(BaseSettings):
|
||||
period = int(period_str)
|
||||
if hasattr(self, f'PRICE_{period}_DAYS'):
|
||||
periods.append(period)
|
||||
|
||||
|
||||
return periods if periods else [30, 90, 180]
|
||||
|
||||
|
||||
except (ValueError, AttributeError):
|
||||
return [30, 90, 180]
|
||||
|
||||
@@ -2188,17 +2216,43 @@ _PERIOD_PRICE_FIELDS: Dict[int, str] = {
|
||||
360: "PRICE_360_DAYS",
|
||||
}
|
||||
|
||||
# Хранилище периодов/цен из БД (приоритет над .env)
|
||||
_DB_PERIOD_PRICES: Optional[Dict[int, int]] = None
|
||||
|
||||
|
||||
def set_period_prices_from_db(period_prices: Dict[int, int]) -> None:
|
||||
"""
|
||||
Устанавливает периоды/цены из БД.
|
||||
Вызывается после синхронизации тарифов при запуске бота.
|
||||
"""
|
||||
global _DB_PERIOD_PRICES
|
||||
_DB_PERIOD_PRICES = period_prices.copy() if period_prices else None
|
||||
refresh_period_prices()
|
||||
|
||||
|
||||
def get_db_period_prices() -> Optional[Dict[int, int]]:
|
||||
"""Возвращает периоды/цены из БД если они загружены."""
|
||||
return _DB_PERIOD_PRICES
|
||||
|
||||
|
||||
def refresh_period_prices() -> None:
|
||||
"""Rebuild cached period price mapping using the latest settings."""
|
||||
|
||||
"""
|
||||
Rebuild cached period price mapping.
|
||||
Приоритет: БД > .env
|
||||
"""
|
||||
PERIOD_PRICES.clear()
|
||||
PERIOD_PRICES.update(
|
||||
{
|
||||
days: getattr(settings, field_name, 0)
|
||||
for days, field_name in _PERIOD_PRICE_FIELDS.items()
|
||||
}
|
||||
)
|
||||
|
||||
if _DB_PERIOD_PRICES:
|
||||
# Используем цены из БД
|
||||
PERIOD_PRICES.update(_DB_PERIOD_PRICES)
|
||||
else:
|
||||
# Fallback на .env
|
||||
PERIOD_PRICES.update(
|
||||
{
|
||||
days: getattr(settings, field_name, 0)
|
||||
for days, field_name in _PERIOD_PRICE_FIELDS.items()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
PERIOD_PRICES: Dict[int, int] = {}
|
||||
|
||||
@@ -151,6 +151,7 @@ async def get_all_server_squads(
|
||||
async def get_available_server_squads(
|
||||
db: AsyncSession,
|
||||
promo_group_id: Optional[int] = None,
|
||||
exclude_trial_only: bool = False,
|
||||
) -> List[ServerSquad]:
|
||||
|
||||
query = (
|
||||
@@ -160,6 +161,9 @@ async def get_available_server_squads(
|
||||
.order_by(ServerSquad.sort_order, ServerSquad.display_name)
|
||||
)
|
||||
|
||||
if exclude_trial_only:
|
||||
query = query.where(ServerSquad.is_trial_eligible.is_(False))
|
||||
|
||||
if promo_group_id is not None:
|
||||
query = query.join(ServerSquad.allowed_promo_groups).where(
|
||||
PromoGroup.id == promo_group_id
|
||||
@@ -526,38 +530,158 @@ async def get_random_trial_squad_uuid(
|
||||
|
||||
|
||||
def _generate_display_name(original_name: str) -> str:
|
||||
"""Генерирует отображаемое название сервера на основе оригинального имени."""
|
||||
|
||||
country_names = {
|
||||
# Европа
|
||||
'NL': '🇳🇱 Нидерланды',
|
||||
'DE': '🇩🇪 Германия',
|
||||
'US': '🇺🇸 США',
|
||||
'DE': '🇩🇪 Германия',
|
||||
'FR': '🇫🇷 Франция',
|
||||
'GB': '🇬🇧 Великобритания',
|
||||
'UK': '🇬🇧 Великобритания',
|
||||
'IT': '🇮🇹 Италия',
|
||||
'ES': '🇪🇸 Испания',
|
||||
'PT': '🇵🇹 Португалия',
|
||||
'PL': '🇵🇱 Польша',
|
||||
'CZ': '🇨🇿 Чехия',
|
||||
'AT': '🇦🇹 Австрия',
|
||||
'CH': '🇨🇭 Швейцария',
|
||||
'SE': '🇸🇪 Швеция',
|
||||
'NO': '🇳🇴 Норвегия',
|
||||
'FI': '🇫🇮 Финляндия',
|
||||
'DK': '🇩🇰 Дания',
|
||||
'BE': '🇧🇪 Бельгия',
|
||||
'IE': '🇮🇪 Ирландия',
|
||||
'RO': '🇷🇴 Румыния',
|
||||
'BG': '🇧🇬 Болгария',
|
||||
'HU': '🇭🇺 Венгрия',
|
||||
'GR': '🇬🇷 Греция',
|
||||
'LV': '🇱🇻 Латвия',
|
||||
'LT': '🇱🇹 Литва',
|
||||
'EE': '🇪🇪 Эстония',
|
||||
'SK': '🇸🇰 Словакия',
|
||||
'SI': '🇸🇮 Словения',
|
||||
'HR': '🇭🇷 Хорватия',
|
||||
'RS': '🇷🇸 Сербия',
|
||||
'UA': '🇺🇦 Украина',
|
||||
'MD': '🇲🇩 Молдова',
|
||||
'BY': '🇧🇾 Беларусь',
|
||||
'LU': '🇱🇺 Люксембург',
|
||||
|
||||
# СНГ и Азия
|
||||
'RU': '🇷🇺 Россия',
|
||||
'KZ': '🇰🇿 Казахстан',
|
||||
'UZ': '🇺🇿 Узбекистан',
|
||||
'GE': '🇬🇪 Грузия',
|
||||
'AM': '🇦🇲 Армения',
|
||||
'AZ': '🇦🇿 Азербайджан',
|
||||
|
||||
# Америка
|
||||
'US': '🇺🇸 США',
|
||||
'CA': '🇨🇦 Канада',
|
||||
'MX': '🇲🇽 Мексика',
|
||||
'BR': '🇧🇷 Бразилия',
|
||||
'AR': '🇦🇷 Аргентина',
|
||||
'CL': '🇨🇱 Чили',
|
||||
'CO': '🇨🇴 Колумбия',
|
||||
|
||||
# Азия
|
||||
'JP': '🇯🇵 Япония',
|
||||
'KR': '🇰🇷 Южная Корея',
|
||||
'CN': '🇨🇳 Китай',
|
||||
'HK': '🇭🇰 Гонконг',
|
||||
'TW': '🇹🇼 Тайвань',
|
||||
'SG': '🇸🇬 Сингапур',
|
||||
'TH': '🇹🇭 Таиланд',
|
||||
'VN': '🇻🇳 Вьетнам',
|
||||
'MY': '🇲🇾 Малайзия',
|
||||
'ID': '🇮🇩 Индонезия',
|
||||
'PH': '🇵🇭 Филиппины',
|
||||
'IN': '🇮🇳 Индия',
|
||||
'PK': '🇵🇰 Пакистан',
|
||||
|
||||
# Ближний Восток
|
||||
'IL': '🇮🇱 Израиль',
|
||||
'TR': '🇹🇷 Турция',
|
||||
'AE': '🇦🇪 ОАЭ',
|
||||
'SA': '🇸🇦 Саудовская Аравия',
|
||||
'QA': '🇶🇦 Катар',
|
||||
'BH': '🇧🇭 Бахрейн',
|
||||
'KW': '🇰🇼 Кувейт',
|
||||
|
||||
# Океания
|
||||
'AU': '🇦🇺 Австралия',
|
||||
'NZ': '🇳🇿 Новая Зеландия',
|
||||
|
||||
# Африка
|
||||
'ZA': '🇿🇦 ЮАР',
|
||||
'EG': '🇪🇬 Египет',
|
||||
'NG': '🇳🇬 Нигерия',
|
||||
'KE': '🇰🇪 Кения',
|
||||
}
|
||||
|
||||
|
||||
name_upper = original_name.upper()
|
||||
|
||||
# Сначала ищем код как отдельный элемент (через - или _)
|
||||
for code, display_name in country_names.items():
|
||||
if f'-{code}' in name_upper or f'_{code}' in name_upper:
|
||||
return display_name
|
||||
if name_upper.startswith(code + '-') or name_upper.startswith(code + '_'):
|
||||
return display_name
|
||||
if name_upper.endswith('-' + code) or name_upper.endswith('_' + code):
|
||||
return display_name
|
||||
if name_upper == code:
|
||||
return display_name
|
||||
|
||||
# Потом ищем просто вхождение кода
|
||||
for code, display_name in country_names.items():
|
||||
if code in name_upper:
|
||||
return display_name
|
||||
|
||||
|
||||
return f"🌍 {original_name}"
|
||||
|
||||
|
||||
def _extract_country_code(original_name: str) -> Optional[str]:
|
||||
|
||||
codes = ['NL', 'DE', 'US', 'FR', 'GB', 'IT', 'ES', 'CA', 'JP', 'SG', 'AU']
|
||||
"""Извлекает код страны из оригинального названия."""
|
||||
|
||||
# Полный список кодов стран
|
||||
codes = [
|
||||
# Европа
|
||||
'NL', 'DE', 'FR', 'GB', 'UK', 'IT', 'ES', 'PT', 'PL', 'CZ', 'AT', 'CH',
|
||||
'SE', 'NO', 'FI', 'DK', 'BE', 'IE', 'RO', 'BG', 'HU', 'GR', 'LV', 'LT',
|
||||
'EE', 'SK', 'SI', 'HR', 'RS', 'UA', 'MD', 'BY', 'LU',
|
||||
# СНГ
|
||||
'RU', 'KZ', 'UZ', 'GE', 'AM', 'AZ',
|
||||
# Америка
|
||||
'US', 'CA', 'MX', 'BR', 'AR', 'CL', 'CO',
|
||||
# Азия
|
||||
'JP', 'KR', 'CN', 'HK', 'TW', 'SG', 'TH', 'VN', 'MY', 'ID', 'PH', 'IN', 'PK',
|
||||
# Ближний Восток
|
||||
'IL', 'TR', 'AE', 'SA', 'QA', 'BH', 'KW',
|
||||
# Океания
|
||||
'AU', 'NZ',
|
||||
# Африка
|
||||
'ZA', 'EG', 'NG', 'KE',
|
||||
]
|
||||
|
||||
name_upper = original_name.upper()
|
||||
|
||||
|
||||
# Сначала ищем код как отдельный элемент
|
||||
for code in codes:
|
||||
if f'-{code}' in name_upper or f'_{code}' in name_upper:
|
||||
return code
|
||||
if name_upper.startswith(code + '-') or name_upper.startswith(code + '_'):
|
||||
return code
|
||||
if name_upper.endswith('-' + code) or name_upper.endswith('_' + code):
|
||||
return code
|
||||
if name_upper == code:
|
||||
return code
|
||||
|
||||
# Потом просто ищем вхождение
|
||||
for code in codes:
|
||||
if code in name_upper:
|
||||
return code
|
||||
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -681,6 +805,49 @@ async def get_server_ids_by_uuids(
|
||||
return [row[0] for row in result.fetchall()]
|
||||
|
||||
|
||||
async def ensure_servers_synced(db: AsyncSession) -> None:
|
||||
"""
|
||||
Проверяет и синхронизирует серверы при запуске.
|
||||
Если серверов нет в БД, загружает их из RemnaWave.
|
||||
Вызывается при старте бота.
|
||||
"""
|
||||
try:
|
||||
# Проверяем есть ли серверы в БД
|
||||
result = await db.execute(select(func.count(ServerSquad.id)))
|
||||
server_count = result.scalar() or 0
|
||||
|
||||
if server_count > 0:
|
||||
logger.info(f"✅ В базе уже есть {server_count} серверов, пропускаем синхронизацию")
|
||||
return
|
||||
|
||||
logger.info("🔄 Серверов в БД нет, начинаем синхронизацию с RemnaWave...")
|
||||
|
||||
# Импортируем сервис здесь чтобы избежать циклических импортов
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
if not subscription_service.is_configured:
|
||||
logger.warning("⚠️ RemnaWave не настроен, серверы не синхронизированы")
|
||||
return
|
||||
|
||||
# Получаем скводы из RemnaWave
|
||||
squads = await subscription_service.get_remnawave_squads()
|
||||
if squads is None:
|
||||
logger.error("❌ Не удалось получить список серверов из RemnaWave")
|
||||
return
|
||||
|
||||
if not squads:
|
||||
logger.warning("⚠️ RemnaWave вернул пустой список серверов")
|
||||
return
|
||||
|
||||
# Синхронизируем
|
||||
created, updated, removed = await sync_with_remnawave(db, squads)
|
||||
logger.info(f"✅ Серверы синхронизированы: +{created} ~{updated} -{removed}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка синхронизации серверов: {e}")
|
||||
|
||||
|
||||
async def sync_server_user_counts(db: AsyncSession) -> int:
|
||||
|
||||
try:
|
||||
|
||||
@@ -162,10 +162,13 @@ async def create_tariff(
|
||||
is_active: bool = True,
|
||||
traffic_limit_gb: int = 100,
|
||||
device_limit: int = 1,
|
||||
device_price_kopeks: Optional[int] = None,
|
||||
allowed_squads: Optional[List[str]] = None,
|
||||
server_traffic_limits: Optional[Dict[str, dict]] = None,
|
||||
period_prices: Optional[Dict[int, int]] = None,
|
||||
tier_level: int = 1,
|
||||
is_trial_available: bool = False,
|
||||
allow_traffic_topup: bool = True,
|
||||
promo_group_ids: Optional[List[int]] = None,
|
||||
) -> Tariff:
|
||||
"""Создает новый тариф."""
|
||||
@@ -178,10 +181,13 @@ async def create_tariff(
|
||||
is_active=is_active,
|
||||
traffic_limit_gb=max(0, traffic_limit_gb),
|
||||
device_limit=max(1, device_limit),
|
||||
device_price_kopeks=device_price_kopeks,
|
||||
allowed_squads=allowed_squads or [],
|
||||
server_traffic_limits=server_traffic_limits or {},
|
||||
period_prices=normalized_prices,
|
||||
tier_level=max(1, tier_level),
|
||||
is_trial_available=is_trial_available,
|
||||
allow_traffic_topup=allow_traffic_topup,
|
||||
)
|
||||
|
||||
db.add(tariff)
|
||||
@@ -223,9 +229,11 @@ async def update_tariff(
|
||||
device_limit: Optional[int] = None,
|
||||
device_price_kopeks: Optional[int] = ..., # ... = не передан, None = сбросить
|
||||
allowed_squads: Optional[List[str]] = None,
|
||||
server_traffic_limits: Optional[Dict[str, dict]] = None,
|
||||
period_prices: Optional[Dict[int, int]] = None,
|
||||
tier_level: Optional[int] = None,
|
||||
is_trial_available: Optional[bool] = None,
|
||||
allow_traffic_topup: Optional[bool] = None,
|
||||
promo_group_ids: Optional[List[int]] = None,
|
||||
) -> Tariff:
|
||||
"""Обновляет существующий тариф."""
|
||||
@@ -246,6 +254,10 @@ async def update_tariff(
|
||||
tariff.device_price_kopeks = device_price_kopeks
|
||||
if allowed_squads is not None:
|
||||
tariff.allowed_squads = allowed_squads
|
||||
if server_traffic_limits is not None:
|
||||
tariff.server_traffic_limits = server_traffic_limits
|
||||
if allow_traffic_topup is not None:
|
||||
tariff.allow_traffic_topup = allow_traffic_topup
|
||||
if period_prices is not None:
|
||||
tariff.period_prices = _normalize_period_prices(period_prices)
|
||||
if tier_level is not None:
|
||||
@@ -399,3 +411,122 @@ async def reorder_tariffs(
|
||||
await db.commit()
|
||||
|
||||
logger.info("Изменен порядок тарифов: %s", tariff_order)
|
||||
|
||||
|
||||
async def sync_default_tariff_from_config(db: AsyncSession) -> Optional[Tariff]:
|
||||
"""
|
||||
Синхронизирует дефолтный тариф из конфига (.env) в БД.
|
||||
Создаёт тариф "Стандартный" если в БД нет тарифов.
|
||||
Обновляет цены существующего тарифа если он есть.
|
||||
|
||||
Returns:
|
||||
Tariff или None если не требуется синхронизация
|
||||
"""
|
||||
from app.config import settings, PERIOD_PRICES
|
||||
|
||||
# Проверяем есть ли тарифы в БД
|
||||
result = await db.execute(select(func.count(Tariff.id)))
|
||||
tariff_count = result.scalar() or 0
|
||||
|
||||
# Собираем цены из конфига
|
||||
period_prices = {}
|
||||
for period, price in PERIOD_PRICES.items():
|
||||
if price > 0:
|
||||
period_prices[str(period)] = price
|
||||
|
||||
if not period_prices:
|
||||
logger.warning("Нет цен в конфиге для создания дефолтного тарифа")
|
||||
return None
|
||||
|
||||
# Ищем тариф с именем "Стандартный" или первый тариф
|
||||
result = await db.execute(
|
||||
select(Tariff).where(Tariff.name == "Стандартный").limit(1)
|
||||
)
|
||||
existing_tariff = result.scalar_one_or_none()
|
||||
|
||||
if existing_tariff:
|
||||
# Обновляем цены существующего тарифа
|
||||
existing_tariff.period_prices = period_prices
|
||||
existing_tariff.traffic_limit_gb = settings.DEFAULT_TRAFFIC_LIMIT_GB
|
||||
existing_tariff.device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
await db.commit()
|
||||
await db.refresh(existing_tariff)
|
||||
logger.info("Обновлён дефолтный тариф 'Стандартный' из конфига")
|
||||
return existing_tariff
|
||||
|
||||
if tariff_count == 0:
|
||||
# Создаём новый дефолтный тариф
|
||||
new_tariff = Tariff(
|
||||
name="Стандартный",
|
||||
description="Базовый тарифный план",
|
||||
is_active=True,
|
||||
is_trial_available=True,
|
||||
traffic_limit_gb=settings.DEFAULT_TRAFFIC_LIMIT_GB,
|
||||
device_limit=settings.DEFAULT_DEVICE_LIMIT,
|
||||
tier_level=1,
|
||||
display_order=0,
|
||||
period_prices=period_prices,
|
||||
allowed_squads=[], # Все серверы по умолчанию
|
||||
server_traffic_limits={},
|
||||
)
|
||||
db.add(new_tariff)
|
||||
await db.commit()
|
||||
await db.refresh(new_tariff)
|
||||
logger.info("Создан дефолтный тариф 'Стандартный' из конфига: %s", period_prices)
|
||||
return new_tariff
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def load_period_prices_from_db(db: AsyncSession) -> None:
|
||||
"""
|
||||
Загружает периоды/цены из тарифа "Стандартный" в PERIOD_PRICES.
|
||||
Это позволяет боту использовать цены из кабинета вместо .env.
|
||||
"""
|
||||
from app.config import set_period_prices_from_db
|
||||
|
||||
try:
|
||||
# Ищем тариф "Стандартный" или первый активный тариф
|
||||
result = await db.execute(
|
||||
select(Tariff)
|
||||
.where(Tariff.is_active.is_(True))
|
||||
.order_by(Tariff.display_order, Tariff.id)
|
||||
.limit(1)
|
||||
)
|
||||
tariff = result.scalar_one_or_none()
|
||||
|
||||
if tariff and tariff.period_prices:
|
||||
# Преобразуем строковые ключи в int
|
||||
period_prices = {
|
||||
int(days): int(price)
|
||||
for days, price in tariff.period_prices.items()
|
||||
if int(price) > 0
|
||||
}
|
||||
|
||||
if period_prices:
|
||||
set_period_prices_from_db(period_prices)
|
||||
logger.info(
|
||||
"Загружены периоды из тарифа '%s': %s",
|
||||
tariff.name,
|
||||
{f"{d}д": f"{p//100}₽" for d, p in period_prices.items()}
|
||||
)
|
||||
else:
|
||||
logger.warning("Тариф '%s' не имеет активных периодов", tariff.name)
|
||||
else:
|
||||
logger.info("Активные тарифы не найдены, используются цены из .env")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Ошибка загрузки периодов из БД: %s", e)
|
||||
|
||||
|
||||
async def ensure_tariffs_synced(db: AsyncSession) -> None:
|
||||
"""
|
||||
Проверяет и синхронизирует тарифы при запуске.
|
||||
Вызывается при старте бота.
|
||||
"""
|
||||
try:
|
||||
await sync_default_tariff_from_config(db)
|
||||
# Загружаем периоды из БД в PERIOD_PRICES
|
||||
await load_period_prices_from_db(db)
|
||||
except Exception as e:
|
||||
logger.error("Ошибка синхронизации тарифов: %s", e)
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
"""
|
||||
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,
|
||||
}
|
||||
+163
-1
@@ -120,6 +120,21 @@ class MainMenuButtonVisibility(Enum):
|
||||
ADMINS = "admins"
|
||||
SUBSCRIBERS = "subscribers"
|
||||
|
||||
|
||||
class WheelPrizeType(Enum):
|
||||
"""Типы призов на колесе удачи."""
|
||||
SUBSCRIPTION_DAYS = "subscription_days"
|
||||
BALANCE_BONUS = "balance_bonus"
|
||||
TRAFFIC_GB = "traffic_gb"
|
||||
PROMOCODE = "promocode"
|
||||
NOTHING = "nothing"
|
||||
|
||||
|
||||
class WheelSpinPaymentType(Enum):
|
||||
"""Способы оплаты спина колеса."""
|
||||
TELEGRAM_STARS = "telegram_stars"
|
||||
SUBSCRIPTION_DAYS = "subscription_days"
|
||||
|
||||
class YooKassaPayment(Base):
|
||||
__tablename__ = "yookassa_payments"
|
||||
|
||||
@@ -753,6 +768,10 @@ class Tariff(Base):
|
||||
# Сквады (серверы) доступные в тарифе
|
||||
allowed_squads = Column(JSON, default=list) # список UUID сквадов
|
||||
|
||||
# Лимиты трафика по серверам (JSON: {"uuid": {"traffic_limit_gb": 100}, ...})
|
||||
# Если сервер не указан - используется общий traffic_limit_gb
|
||||
server_traffic_limits = Column(JSON, default=dict)
|
||||
|
||||
# Цены на периоды в копейках (JSON: {"14": 30000, "30": 50000, "90": 120000, ...})
|
||||
period_prices = Column(JSON, nullable=False, default=dict)
|
||||
|
||||
@@ -761,6 +780,7 @@ class Tariff(Base):
|
||||
|
||||
# Дополнительные настройки
|
||||
is_trial_available = Column(Boolean, default=False, nullable=False) # Можно ли взять триал на этом тарифе
|
||||
allow_traffic_topup = Column(Boolean, default=True, nullable=False) # Разрешена ли докупка трафика для этого тарифа
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
@@ -797,6 +817,21 @@ class Tariff(Base):
|
||||
return price_kopeks / 100
|
||||
return None
|
||||
|
||||
def get_traffic_limit_for_server(self, squad_uuid: str) -> int:
|
||||
"""Возвращает лимит трафика для конкретного сервера.
|
||||
|
||||
Если для сервера настроен отдельный лимит - возвращает его,
|
||||
иначе возвращает общий traffic_limit_gb тарифа.
|
||||
"""
|
||||
limits = self.server_traffic_limits or {}
|
||||
if squad_uuid in limits:
|
||||
server_limit = limits[squad_uuid]
|
||||
if isinstance(server_limit, dict) and 'traffic_limit_gb' in server_limit:
|
||||
return server_limit['traffic_limit_gb']
|
||||
elif isinstance(server_limit, int):
|
||||
return server_limit
|
||||
return self.traffic_limit_gb
|
||||
|
||||
def is_available_for_promo_group(self, promo_group_id: Optional[int]) -> bool:
|
||||
"""Проверяет, доступен ли тариф для указанной промогруппы."""
|
||||
if not self.allowed_promo_groups:
|
||||
@@ -2249,4 +2284,131 @@ class CabinetRefreshToken(Base):
|
||||
|
||||
def __repr__(self) -> str:
|
||||
status = "valid" if self.is_valid else ("revoked" if self.is_revoked else "expired")
|
||||
return f"<CabinetRefreshToken id={self.id} user_id={self.user_id} status={status}>"
|
||||
return f"<CabinetRefreshToken id={self.id} user_id={self.user_id} status={status}>"
|
||||
|
||||
|
||||
# ==================== FORTUNE WHEEL ====================
|
||||
|
||||
|
||||
class WheelConfig(Base):
|
||||
"""Глобальная конфигурация колеса удачи."""
|
||||
__tablename__ = "wheel_configs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# Основные настройки
|
||||
is_enabled = Column(Boolean, default=False, nullable=False)
|
||||
name = Column(String(255), default="Колесо удачи", nullable=False)
|
||||
|
||||
# Стоимость спина
|
||||
spin_cost_stars = Column(Integer, default=10, nullable=False) # Стоимость в Stars
|
||||
spin_cost_days = Column(Integer, default=1, nullable=False) # Стоимость в днях подписки
|
||||
spin_cost_stars_enabled = Column(Boolean, default=True, nullable=False)
|
||||
spin_cost_days_enabled = Column(Boolean, default=True, nullable=False)
|
||||
|
||||
# RTP настройки (Return to Player) - процент возврата 0-100
|
||||
rtp_percent = Column(Integer, default=80, nullable=False)
|
||||
|
||||
# Лимиты
|
||||
daily_spin_limit = Column(Integer, default=5, nullable=False) # 0 = без лимита
|
||||
min_subscription_days_for_day_payment = Column(Integer, default=3, nullable=False)
|
||||
|
||||
# Генерация промокодов
|
||||
promo_prefix = Column(String(20), default="WHEEL", nullable=False)
|
||||
promo_validity_days = Column(Integer, default=7, nullable=False)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
prizes = relationship("WheelPrize", back_populates="config", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<WheelConfig id={self.id} enabled={self.is_enabled} rtp={self.rtp_percent}%>"
|
||||
|
||||
|
||||
class WheelPrize(Base):
|
||||
"""Приз на колесе удачи."""
|
||||
__tablename__ = "wheel_prizes"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
config_id = Column(Integer, ForeignKey("wheel_configs.id", ondelete="CASCADE"), nullable=False)
|
||||
|
||||
# Тип и значение приза
|
||||
prize_type = Column(String(50), nullable=False) # WheelPrizeType
|
||||
prize_value = Column(Integer, default=0, nullable=False) # Дни/копейки/GB в зависимости от типа
|
||||
|
||||
# Отображение
|
||||
display_name = Column(String(100), nullable=False)
|
||||
emoji = Column(String(10), default="🎁", nullable=False)
|
||||
color = Column(String(20), default="#3B82F6", nullable=False) # HEX цвет сектора
|
||||
|
||||
# Стоимость приза для расчета RTP (в копейках)
|
||||
prize_value_kopeks = Column(Integer, default=0, nullable=False)
|
||||
|
||||
# Порядок и вероятность
|
||||
sort_order = Column(Integer, default=0, nullable=False)
|
||||
manual_probability = Column(Float, nullable=True) # Если задано - игнорирует RTP расчет (0.0-1.0)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
|
||||
# Настройки генерируемого промокода (только для prize_type=promocode)
|
||||
promo_balance_bonus_kopeks = Column(Integer, default=0)
|
||||
promo_subscription_days = Column(Integer, default=0)
|
||||
promo_traffic_gb = Column(Integer, default=0)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
config = relationship("WheelConfig", back_populates="prizes")
|
||||
spins = relationship("WheelSpin", back_populates="prize")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<WheelPrize id={self.id} type={self.prize_type} name='{self.display_name}'>"
|
||||
|
||||
|
||||
class WheelSpin(Base):
|
||||
"""История спинов колеса удачи."""
|
||||
__tablename__ = "wheel_spins"
|
||||
__table_args__ = (
|
||||
Index("ix_wheel_spins_user_created", "user_id", "created_at"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
prize_id = Column(Integer, ForeignKey("wheel_prizes.id", ondelete="SET NULL"), nullable=True)
|
||||
|
||||
# Способ оплаты
|
||||
payment_type = Column(String(50), nullable=False) # WheelSpinPaymentType
|
||||
payment_amount = Column(Integer, nullable=False) # Stars или дни
|
||||
payment_value_kopeks = Column(Integer, nullable=False) # Эквивалент в копейках для статистики
|
||||
|
||||
# Результат
|
||||
prize_type = Column(String(50), nullable=False) # Копируем из WheelPrize на момент спина
|
||||
prize_value = Column(Integer, nullable=False)
|
||||
prize_display_name = Column(String(100), nullable=False)
|
||||
prize_value_kopeks = Column(Integer, nullable=False) # Стоимость приза в копейках
|
||||
|
||||
# Сгенерированный промокод (если приз - промокод)
|
||||
generated_promocode_id = Column(Integer, ForeignKey("promocodes.id"), nullable=True)
|
||||
|
||||
# Флаг успешного начисления
|
||||
is_applied = Column(Boolean, default=False, nullable=False)
|
||||
applied_at = Column(DateTime, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
user = relationship("User", backref="wheel_spins")
|
||||
prize = relationship("WheelPrize", back_populates="spins")
|
||||
generated_promocode = relationship("PromoCode")
|
||||
|
||||
@property
|
||||
def prize_value_rubles(self) -> float:
|
||||
"""Стоимость приза в рублях."""
|
||||
return self.prize_value_kopeks / 100
|
||||
|
||||
@property
|
||||
def payment_value_rubles(self) -> float:
|
||||
"""Стоимость оплаты в рублях."""
|
||||
return self.payment_value_kopeks / 100
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<WheelSpin id={self.id} user_id={self.user_id} prize='{self.prize_display_name}'>"
|
||||
@@ -5332,9 +5332,308 @@ async def add_tariff_device_price_column() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def add_tariff_server_traffic_limits_column() -> bool:
|
||||
"""Добавляет колонку server_traffic_limits в таблицу tariffs."""
|
||||
try:
|
||||
if await check_column_exists('tariffs', 'server_traffic_limits'):
|
||||
logger.info("ℹ️ Колонка server_traffic_limits уже существует в tariffs")
|
||||
return True
|
||||
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE tariffs ADD COLUMN server_traffic_limits TEXT DEFAULT '{}'"
|
||||
))
|
||||
elif db_type == 'postgresql':
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE tariffs ADD COLUMN server_traffic_limits JSONB DEFAULT '{}'"
|
||||
))
|
||||
else: # MySQL
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE tariffs ADD COLUMN server_traffic_limits JSON DEFAULT NULL"
|
||||
))
|
||||
|
||||
logger.info("✅ Колонка server_traffic_limits добавлена в tariffs")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка добавления колонки server_traffic_limits: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def add_tariff_allow_traffic_topup_column() -> bool:
|
||||
"""Добавляет колонку allow_traffic_topup в таблицу tariffs."""
|
||||
try:
|
||||
if await check_column_exists('tariffs', 'allow_traffic_topup'):
|
||||
logger.info("ℹ️ Колонка allow_traffic_topup уже существует в tariffs")
|
||||
return True
|
||||
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE tariffs ADD COLUMN allow_traffic_topup INTEGER NOT NULL DEFAULT 1"
|
||||
))
|
||||
elif db_type == 'postgresql':
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE tariffs ADD COLUMN allow_traffic_topup BOOLEAN NOT NULL DEFAULT TRUE"
|
||||
))
|
||||
else: # MySQL
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE tariffs ADD COLUMN allow_traffic_topup BOOLEAN NOT NULL DEFAULT TRUE"
|
||||
))
|
||||
|
||||
logger.info("✅ Колонка allow_traffic_topup добавлена в tariffs")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка добавления колонки allow_traffic_topup: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_wheel_tables() -> bool:
|
||||
"""Создаёт таблицы для колеса удачи: wheel_config, wheel_prizes, wheel_spins."""
|
||||
try:
|
||||
db_type = await get_database_type()
|
||||
|
||||
# Создание wheel_config
|
||||
if not await check_table_exists('wheel_config'):
|
||||
async with engine.begin() as conn:
|
||||
if db_type == 'sqlite':
|
||||
create_config_sql = """
|
||||
CREATE TABLE wheel_config (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT 0,
|
||||
name VARCHAR(255) NOT NULL DEFAULT 'Колесо удачи',
|
||||
spin_cost_stars INTEGER NOT NULL DEFAULT 50,
|
||||
spin_cost_days INTEGER NOT NULL DEFAULT 3,
|
||||
spin_cost_stars_enabled BOOLEAN NOT NULL DEFAULT 1,
|
||||
spin_cost_days_enabled BOOLEAN NOT NULL DEFAULT 1,
|
||||
rtp_percent REAL NOT NULL DEFAULT 85.0,
|
||||
daily_spin_limit INTEGER NOT NULL DEFAULT 5,
|
||||
min_subscription_days_for_day_payment INTEGER NOT NULL DEFAULT 7,
|
||||
promo_prefix VARCHAR(50) NOT NULL DEFAULT 'WHEEL',
|
||||
promo_validity_days INTEGER NOT NULL DEFAULT 30,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
elif db_type == 'postgresql':
|
||||
create_config_sql = """
|
||||
CREATE TABLE wheel_config (
|
||||
id SERIAL PRIMARY KEY,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
name VARCHAR(255) NOT NULL DEFAULT 'Колесо удачи',
|
||||
spin_cost_stars INTEGER NOT NULL DEFAULT 50,
|
||||
spin_cost_days INTEGER NOT NULL DEFAULT 3,
|
||||
spin_cost_stars_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
spin_cost_days_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
rtp_percent REAL NOT NULL DEFAULT 85.0,
|
||||
daily_spin_limit INTEGER NOT NULL DEFAULT 5,
|
||||
min_subscription_days_for_day_payment INTEGER NOT NULL DEFAULT 7,
|
||||
promo_prefix VARCHAR(50) NOT NULL DEFAULT 'WHEEL',
|
||||
promo_validity_days INTEGER NOT NULL DEFAULT 30,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
else: # mysql
|
||||
create_config_sql = """
|
||||
CREATE TABLE wheel_config (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
name VARCHAR(255) NOT NULL DEFAULT 'Колесо удачи',
|
||||
spin_cost_stars INT NOT NULL DEFAULT 50,
|
||||
spin_cost_days INT NOT NULL DEFAULT 3,
|
||||
spin_cost_stars_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
spin_cost_days_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
rtp_percent FLOAT NOT NULL DEFAULT 85.0,
|
||||
daily_spin_limit INT NOT NULL DEFAULT 5,
|
||||
min_subscription_days_for_day_payment INT NOT NULL DEFAULT 7,
|
||||
promo_prefix VARCHAR(50) NOT NULL DEFAULT 'WHEEL',
|
||||
promo_validity_days INT NOT NULL DEFAULT 30,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
await conn.execute(text(create_config_sql))
|
||||
logger.info("✅ Таблица wheel_config создана")
|
||||
else:
|
||||
logger.debug("ℹ️ Таблица wheel_config уже существует")
|
||||
|
||||
# Создание wheel_prizes
|
||||
if not await check_table_exists('wheel_prizes'):
|
||||
async with engine.begin() as conn:
|
||||
if db_type == 'sqlite':
|
||||
create_prizes_sql = """
|
||||
CREATE TABLE wheel_prizes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
config_id INTEGER NOT NULL,
|
||||
prize_type VARCHAR(50) NOT NULL,
|
||||
prize_value INTEGER NOT NULL DEFAULT 0,
|
||||
display_name VARCHAR(255) NOT NULL,
|
||||
emoji VARCHAR(10) NOT NULL DEFAULT '🎁',
|
||||
color VARCHAR(20) NOT NULL DEFAULT '#3B82F6',
|
||||
prize_value_kopeks INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
manual_probability REAL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||
promo_balance_bonus_kopeks INTEGER NOT NULL DEFAULT 0,
|
||||
promo_subscription_days INTEGER NOT NULL DEFAULT 0,
|
||||
promo_traffic_gb INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (config_id) REFERENCES wheel_config(id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
elif db_type == 'postgresql':
|
||||
create_prizes_sql = """
|
||||
CREATE TABLE wheel_prizes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
config_id INTEGER NOT NULL REFERENCES wheel_config(id) ON DELETE CASCADE,
|
||||
prize_type VARCHAR(50) NOT NULL,
|
||||
prize_value INTEGER NOT NULL DEFAULT 0,
|
||||
display_name VARCHAR(255) NOT NULL,
|
||||
emoji VARCHAR(10) NOT NULL DEFAULT '🎁',
|
||||
color VARCHAR(20) NOT NULL DEFAULT '#3B82F6',
|
||||
prize_value_kopeks INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
manual_probability REAL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
promo_balance_bonus_kopeks INTEGER NOT NULL DEFAULT 0,
|
||||
promo_subscription_days INTEGER NOT NULL DEFAULT 0,
|
||||
promo_traffic_gb INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
else: # mysql
|
||||
create_prizes_sql = """
|
||||
CREATE TABLE wheel_prizes (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
config_id INT NOT NULL,
|
||||
prize_type VARCHAR(50) NOT NULL,
|
||||
prize_value INT NOT NULL DEFAULT 0,
|
||||
display_name VARCHAR(255) NOT NULL,
|
||||
emoji VARCHAR(10) NOT NULL DEFAULT '🎁',
|
||||
color VARCHAR(20) NOT NULL DEFAULT '#3B82F6',
|
||||
prize_value_kopeks INT NOT NULL DEFAULT 0,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
manual_probability FLOAT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
promo_balance_bonus_kopeks INT NOT NULL DEFAULT 0,
|
||||
promo_subscription_days INT NOT NULL DEFAULT 0,
|
||||
promo_traffic_gb INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (config_id) REFERENCES wheel_config(id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
await conn.execute(text(create_prizes_sql))
|
||||
# Индексы
|
||||
try:
|
||||
await conn.execute(text(
|
||||
"CREATE INDEX idx_wheel_prizes_config_id ON wheel_prizes(config_id)"
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("✅ Таблица wheel_prizes создана")
|
||||
else:
|
||||
logger.debug("ℹ️ Таблица wheel_prizes уже существует")
|
||||
|
||||
# Создание wheel_spins
|
||||
if not await check_table_exists('wheel_spins'):
|
||||
async with engine.begin() as conn:
|
||||
if db_type == 'sqlite':
|
||||
create_spins_sql = """
|
||||
CREATE TABLE wheel_spins (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
config_id INTEGER NOT NULL,
|
||||
prize_id INTEGER,
|
||||
payment_type VARCHAR(50) NOT NULL,
|
||||
payment_amount INTEGER NOT NULL,
|
||||
payment_value_kopeks INTEGER NOT NULL DEFAULT 0,
|
||||
prize_type VARCHAR(50) NOT NULL,
|
||||
prize_value INTEGER NOT NULL DEFAULT 0,
|
||||
prize_value_kopeks INTEGER NOT NULL DEFAULT 0,
|
||||
promocode_id INTEGER,
|
||||
is_applied BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (config_id) REFERENCES wheel_config(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (prize_id) REFERENCES wheel_prizes(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (promocode_id) REFERENCES promocodes(id) ON DELETE SET NULL
|
||||
)
|
||||
"""
|
||||
elif db_type == 'postgresql':
|
||||
create_spins_sql = """
|
||||
CREATE TABLE wheel_spins (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
config_id INTEGER NOT NULL REFERENCES wheel_config(id) ON DELETE CASCADE,
|
||||
prize_id INTEGER REFERENCES wheel_prizes(id) ON DELETE SET NULL,
|
||||
payment_type VARCHAR(50) NOT NULL,
|
||||
payment_amount INTEGER NOT NULL,
|
||||
payment_value_kopeks INTEGER NOT NULL DEFAULT 0,
|
||||
prize_type VARCHAR(50) NOT NULL,
|
||||
prize_value INTEGER NOT NULL DEFAULT 0,
|
||||
prize_value_kopeks INTEGER NOT NULL DEFAULT 0,
|
||||
promocode_id INTEGER REFERENCES promocodes(id) ON DELETE SET NULL,
|
||||
is_applied BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
else: # mysql
|
||||
create_spins_sql = """
|
||||
CREATE TABLE wheel_spins (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
config_id INT NOT NULL,
|
||||
prize_id INT,
|
||||
payment_type VARCHAR(50) NOT NULL,
|
||||
payment_amount INT NOT NULL,
|
||||
payment_value_kopeks INT NOT NULL DEFAULT 0,
|
||||
prize_type VARCHAR(50) NOT NULL,
|
||||
prize_value INT NOT NULL DEFAULT 0,
|
||||
prize_value_kopeks INT NOT NULL DEFAULT 0,
|
||||
promocode_id INT,
|
||||
is_applied BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (config_id) REFERENCES wheel_config(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (prize_id) REFERENCES wheel_prizes(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (promocode_id) REFERENCES promocodes(id) ON DELETE SET NULL
|
||||
)
|
||||
"""
|
||||
await conn.execute(text(create_spins_sql))
|
||||
# Индексы
|
||||
try:
|
||||
await conn.execute(text(
|
||||
"CREATE INDEX idx_wheel_spins_user_id ON wheel_spins(user_id)"
|
||||
))
|
||||
await conn.execute(text(
|
||||
"CREATE INDEX idx_wheel_spins_created_at ON wheel_spins(created_at)"
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("✅ Таблица wheel_spins создана")
|
||||
else:
|
||||
logger.debug("ℹ️ Таблица wheel_spins уже существует")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка создания таблиц колеса удачи: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def run_universal_migration():
|
||||
logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===")
|
||||
|
||||
|
||||
try:
|
||||
db_type = await get_database_type()
|
||||
logger.info(f"Тип базы данных: {db_type}")
|
||||
@@ -5834,6 +6133,18 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с колонкой device_price_kopeks в tariffs")
|
||||
|
||||
server_traffic_limits_ready = await add_tariff_server_traffic_limits_column()
|
||||
if server_traffic_limits_ready:
|
||||
logger.info("✅ Колонка server_traffic_limits в tariffs готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с колонкой server_traffic_limits в tariffs")
|
||||
|
||||
allow_traffic_topup_ready = await add_tariff_allow_traffic_topup_column()
|
||||
if allow_traffic_topup_ready:
|
||||
logger.info("✅ Колонка allow_traffic_topup в tariffs готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с колонкой allow_traffic_topup в tariffs")
|
||||
|
||||
logger.info("=== ОБНОВЛЕНИЕ ВНЕШНИХ КЛЮЧЕЙ ===")
|
||||
fk_updated = await fix_foreign_keys_for_user_deletion()
|
||||
if fk_updated:
|
||||
@@ -5869,6 +6180,13 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей withdrawal_requests")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦ КОЛЕСА УДАЧИ ===")
|
||||
wheel_tables_ready = await create_wheel_tables()
|
||||
if wheel_tables_ready:
|
||||
logger.info("✅ Таблицы колеса удачи готовы")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицами колеса удачи")
|
||||
|
||||
async with engine.begin() as conn:
|
||||
total_subs = await conn.execute(text("SELECT COUNT(*) FROM subscriptions"))
|
||||
unique_users = await conn.execute(text("SELECT COUNT(DISTINCT user_id) FROM subscriptions"))
|
||||
|
||||
@@ -14,6 +14,112 @@ from app.localization.texts import get_texts
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _handle_wheel_spin_payment(
|
||||
message: types.Message,
|
||||
db: AsyncSession,
|
||||
user,
|
||||
stars_amount: int,
|
||||
payload: str,
|
||||
texts,
|
||||
):
|
||||
"""Обработка Stars платежа для колеса удачи."""
|
||||
from app.services.wheel_service import wheel_service
|
||||
from app.database.crud.wheel import get_or_create_wheel_config, get_wheel_prizes
|
||||
|
||||
try:
|
||||
config = await get_or_create_wheel_config(db)
|
||||
|
||||
if not config.is_enabled:
|
||||
await message.answer(
|
||||
"❌ Колесо удачи временно недоступно. Звезды будут возвращены.",
|
||||
)
|
||||
return False
|
||||
|
||||
# Выполняем спин напрямую (оплата уже прошла через Stars)
|
||||
prizes = await get_or_create_wheel_config(db)
|
||||
prizes = await get_wheel_prizes(db, config.id, active_only=True)
|
||||
|
||||
if not prizes:
|
||||
await message.answer(
|
||||
"❌ Призы не настроены. Обратитесь в поддержку.",
|
||||
)
|
||||
return False
|
||||
|
||||
# Рассчитываем стоимость в копейках для статистики
|
||||
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(stars_amount)
|
||||
payment_value_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP))
|
||||
|
||||
# Рассчитываем вероятности и выбираем приз
|
||||
prizes_with_probs = wheel_service.calculate_prize_probabilities(config, prizes, payment_value_kopeks)
|
||||
selected_prize = wheel_service._select_prize(prizes_with_probs)
|
||||
|
||||
# Применяем приз
|
||||
generated_promocode = await wheel_service._apply_prize(db, user, selected_prize, config)
|
||||
|
||||
# Создаем запись спина
|
||||
from app.database.crud.wheel import create_wheel_spin
|
||||
from app.database.models import WheelSpinPaymentType
|
||||
|
||||
promocode_id = None
|
||||
if generated_promocode:
|
||||
result = await db.execute(
|
||||
f"SELECT id FROM promocodes WHERE code = '{generated_promocode}'"
|
||||
)
|
||||
row = result.fetchone()
|
||||
if row:
|
||||
promocode_id = row[0]
|
||||
|
||||
logger.info(
|
||||
f"🎰 Creating wheel spin: user.id={user.id}, user.telegram_id={user.telegram_id}, "
|
||||
f"prize={selected_prize.display_name}"
|
||||
)
|
||||
|
||||
spin = await create_wheel_spin(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
prize_id=selected_prize.id,
|
||||
payment_type=WheelSpinPaymentType.TELEGRAM_STARS.value,
|
||||
payment_amount=stars_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,
|
||||
)
|
||||
|
||||
logger.info(f"🎰 Wheel spin created: spin.id={spin.id}, spin.user_id={spin.user_id}")
|
||||
|
||||
# Ensure all changes are committed (subscription days, traffic GB, etc.)
|
||||
await db.commit()
|
||||
|
||||
# Отправляем результат
|
||||
prize_message = wheel_service._get_prize_message(selected_prize, generated_promocode)
|
||||
|
||||
emoji = selected_prize.emoji or "🎁"
|
||||
await message.answer(
|
||||
f"🎰 <b>Колесо удачи!</b>\n\n"
|
||||
f"{emoji} <b>{selected_prize.display_name}</b>\n\n"
|
||||
f"{prize_message}\n\n"
|
||||
f"⭐ Потрачено: {stars_amount} Stars",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"🎰 Wheel spin via Stars: user={user.id}, prize={selected_prize.display_name}, "
|
||||
f"stars={stars_amount}"
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки wheel spin payment: {e}", exc_info=True)
|
||||
await message.answer(
|
||||
"❌ Произошла ошибка при обработке спина. Обратитесь в поддержку.",
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
||||
texts = get_texts(DEFAULT_LANGUAGE)
|
||||
|
||||
@@ -22,7 +128,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
||||
f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}"
|
||||
)
|
||||
|
||||
allowed_prefixes = ("balance_", "admin_stars_test_", "simple_sub_")
|
||||
allowed_prefixes = ("balance_", "admin_stars_test_", "simple_sub_", "wheel_spin_")
|
||||
|
||||
if not query.invoice_payload or not query.invoice_payload.startswith(allowed_prefixes):
|
||||
logger.warning(f"Невалидный payload: {query.invoice_payload}")
|
||||
@@ -109,6 +215,18 @@ async def handle_successful_payment(
|
||||
)
|
||||
return
|
||||
|
||||
# Обработка оплаты спина колеса удачи
|
||||
if payment.invoice_payload and payment.invoice_payload.startswith("wheel_spin_"):
|
||||
await _handle_wheel_spin_payment(
|
||||
message=message,
|
||||
db=db,
|
||||
user=user,
|
||||
stars_amount=payment.total_amount,
|
||||
payload=payment.invoice_payload,
|
||||
texts=texts,
|
||||
)
|
||||
return
|
||||
|
||||
payment_service = PaymentService(message.bot)
|
||||
|
||||
state_data = await state.get_data()
|
||||
|
||||
@@ -96,7 +96,7 @@ async def handle_add_traffic(
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверяем, включена ли функция докупки трафика
|
||||
# Проверяем глобальную настройку
|
||||
if not settings.is_traffic_topup_enabled():
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
@@ -107,16 +107,21 @@ async def handle_add_traffic(
|
||||
)
|
||||
return
|
||||
|
||||
# В режиме тарифов докупка трафика недоступна
|
||||
if settings.is_tariffs_mode():
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"TARIFF_TRAFFIC_TOPUP_DISABLED",
|
||||
"⚠️ В режиме тарифов докупка трафика недоступна",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
# Проверяем настройку на уровне тарифа (если тариф задан)
|
||||
subscription = db_user.subscription
|
||||
if subscription and subscription.tariff_id:
|
||||
# Загружаем тариф с проверкой allow_traffic_topup
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if tariff and not tariff.allow_traffic_topup:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"TARIFF_TRAFFIC_TOPUP_DISABLED",
|
||||
"⚠️ Для вашего тарифа докупка трафика недоступна",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if settings.is_traffic_topup_blocked():
|
||||
await callback.answer(
|
||||
@@ -476,6 +481,15 @@ async def add_traffic(
|
||||
await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
|
||||
return
|
||||
|
||||
# Проверяем настройку тарифа
|
||||
subscription = db_user.subscription
|
||||
if subscription and subscription.tariff_id:
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if tariff and not tariff.allow_traffic_topup:
|
||||
await callback.answer("⚠️ Для вашего тарифа докупка трафика недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
traffic_gb = int(callback.data.split('_')[2])
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
@@ -631,6 +645,14 @@ async def handle_switch_traffic(
|
||||
await callback.answer("⚠️ Эта функция доступна только для платных подписок", show_alert=True)
|
||||
return
|
||||
|
||||
# Проверяем настройку тарифа
|
||||
if subscription.tariff_id:
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if tariff and not tariff.allow_traffic_topup:
|
||||
await callback.answer("⚠️ Для вашего тарифа переключение трафика недоступно", show_alert=True)
|
||||
return
|
||||
|
||||
current_traffic = subscription.traffic_limit_gb
|
||||
# Вычисляем базовый трафик (без докупленного) для корректного расчёта цен
|
||||
purchased_traffic = getattr(subscription, 'purchased_traffic_gb', 0) or 0
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
"""
|
||||
Сервис для отправки уведомлений от ban системы пользователям
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional, Tuple
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramAPIError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database.models import User
|
||||
from app.services.remnawave_service import remnawave_service
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BanNotificationService:
|
||||
"""Сервис для отправки уведомлений о банах пользователям"""
|
||||
|
||||
def __init__(self):
|
||||
self._bot: Optional[Bot] = None
|
||||
|
||||
def set_bot(self, bot: Bot):
|
||||
"""Установить инстанс бота для отправки сообщений"""
|
||||
self._bot = bot
|
||||
|
||||
async def _find_user_by_identifier(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_identifier: str
|
||||
) -> Optional[User]:
|
||||
"""
|
||||
Найти пользователя по email или user_id из Remnawave Panel
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
user_identifier: Email или user_id пользователя
|
||||
|
||||
Returns:
|
||||
User или None если не найден
|
||||
"""
|
||||
# Сначала пытаемся получить telegram_id через remnawave_service
|
||||
try:
|
||||
telegram_id = await remnawave_service.get_telegram_id_by_email(user_identifier)
|
||||
if telegram_id:
|
||||
# Ищем пользователя по telegram_id
|
||||
result = await db.execute(
|
||||
select(User).where(User.telegram_id == telegram_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if user:
|
||||
return user
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось получить telegram_id через remnawave: {e}")
|
||||
|
||||
# Если не нашли через remnawave, пытаемся искать по email в подписках
|
||||
# (это может быть полезно если у пользователя есть подписка с таким email)
|
||||
try:
|
||||
# Импортируем здесь чтобы избежать циклических импортов
|
||||
from app.database.models import Subscription
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.join(Subscription)
|
||||
.where(Subscription.email == user_identifier)
|
||||
.limit(1)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if user:
|
||||
return user
|
||||
except Exception as e:
|
||||
logger.warning(f"Ошибка поиска пользователя по email в подписках: {e}")
|
||||
|
||||
return None
|
||||
|
||||
async def send_punishment_notification(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_identifier: str,
|
||||
username: str,
|
||||
ip_count: int,
|
||||
limit: int,
|
||||
ban_minutes: int
|
||||
) -> Tuple[bool, str, Optional[int]]:
|
||||
"""
|
||||
Отправить уведомление о блокировке пользователю
|
||||
|
||||
Returns:
|
||||
(success, message, telegram_id)
|
||||
"""
|
||||
if not self._bot:
|
||||
return False, "Бот не инициализирован", None
|
||||
|
||||
# Находим пользователя
|
||||
user = await self._find_user_by_identifier(db, user_identifier)
|
||||
if not user:
|
||||
logger.warning(f"Пользователь {user_identifier} не найден в базе данных")
|
||||
return False, f"Пользователь не найден: {user_identifier}", None
|
||||
|
||||
# Формируем сообщение
|
||||
message_text = (
|
||||
"🚨 <b>Превышение лимита устройств</b>\n\n"
|
||||
f"Ваш аккаунт временно заблокирован из-за превышения лимита подключенных устройств.\n\n"
|
||||
f"📱 Обнаружено устройств: <b>{ip_count}</b>\n"
|
||||
f"📊 Максимально разрешено: <b>{limit}</b>\n"
|
||||
f"⏱ Блокировка на: <b>{ban_minutes} мин</b>\n\n"
|
||||
f"ℹ️ Пожалуйста, отключите лишние устройства и подождите окончания блокировки.\n"
|
||||
f"После разблокировки ваш доступ будет восстановлен автоматически."
|
||||
)
|
||||
|
||||
# Отправляем сообщение
|
||||
try:
|
||||
await self._bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message_text,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
logger.info(
|
||||
f"Уведомление о бане отправлено пользователю {username} "
|
||||
f"(telegram_id: {user.telegram_id})"
|
||||
)
|
||||
return True, "Уведомление отправлено", user.telegram_id
|
||||
|
||||
except TelegramAPIError as e:
|
||||
logger.error(
|
||||
f"Ошибка отправки уведомления пользователю {username} "
|
||||
f"(telegram_id: {user.telegram_id}): {e}"
|
||||
)
|
||||
return False, f"Ошибка Telegram API: {str(e)}", user.telegram_id
|
||||
|
||||
async def send_enabled_notification(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_identifier: str,
|
||||
username: str
|
||||
) -> Tuple[bool, str, Optional[int]]:
|
||||
"""
|
||||
Отправить уведомление о разблокировке пользователю
|
||||
|
||||
Returns:
|
||||
(success, message, telegram_id)
|
||||
"""
|
||||
if not self._bot:
|
||||
return False, "Бот не инициализирован", None
|
||||
|
||||
# Находим пользователя
|
||||
user = await self._find_user_by_identifier(db, user_identifier)
|
||||
if not user:
|
||||
logger.warning(f"Пользователь {user_identifier} не найден в базе данных")
|
||||
return False, f"Пользователь не найден: {user_identifier}", None
|
||||
|
||||
# Формируем сообщение
|
||||
message_text = (
|
||||
"✅ <b>Блокировка снята</b>\n\n"
|
||||
f"Ваш аккаунт разблокирован!\n\n"
|
||||
f"Теперь вы снова можете пользоваться VPN. "
|
||||
f"Пожалуйста, следите за количеством подключенных устройств, "
|
||||
f"чтобы избежать повторной блокировки."
|
||||
)
|
||||
|
||||
# Отправляем сообщение
|
||||
try:
|
||||
await self._bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message_text,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
logger.info(
|
||||
f"Уведомление о разбане отправлено пользователю {username} "
|
||||
f"(telegram_id: {user.telegram_id})"
|
||||
)
|
||||
return True, "Уведомление отправлено", user.telegram_id
|
||||
|
||||
except TelegramAPIError as e:
|
||||
logger.error(
|
||||
f"Ошибка отправки уведомления пользователю {username} "
|
||||
f"(telegram_id: {user.telegram_id}): {e}"
|
||||
)
|
||||
return False, f"Ошибка Telegram API: {str(e)}", user.telegram_id
|
||||
|
||||
async def send_warning_notification(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_identifier: str,
|
||||
username: str,
|
||||
warning_message: str
|
||||
) -> Tuple[bool, str, Optional[int]]:
|
||||
"""
|
||||
Отправить предупреждение пользователю
|
||||
|
||||
Returns:
|
||||
(success, message, telegram_id)
|
||||
"""
|
||||
if not self._bot:
|
||||
return False, "Бот не инициализирован", None
|
||||
|
||||
# Находим пользователя
|
||||
user = await self._find_user_by_identifier(db, user_identifier)
|
||||
if not user:
|
||||
logger.warning(f"Пользователь {user_identifier} не найден в базе данных")
|
||||
return False, f"Пользователь не найден: {user_identifier}", None
|
||||
|
||||
# Формируем сообщение
|
||||
message_text = (
|
||||
"⚠️ <b>Предупреждение</b>\n\n"
|
||||
f"{warning_message}"
|
||||
)
|
||||
|
||||
# Отправляем сообщение
|
||||
try:
|
||||
await self._bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message_text,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
logger.info(
|
||||
f"Предупреждение отправлено пользователю {username} "
|
||||
f"(telegram_id: {user.telegram_id})"
|
||||
)
|
||||
return True, "Предупреждение отправлено", user.telegram_id
|
||||
|
||||
except TelegramAPIError as e:
|
||||
logger.error(
|
||||
f"Ошибка отправки предупреждения пользователю {username} "
|
||||
f"(telegram_id: {user.telegram_id}): {e}"
|
||||
)
|
||||
return False, f"Ошибка Telegram API: {str(e)}", user.telegram_id
|
||||
|
||||
|
||||
# Глобальный экземпляр сервиса
|
||||
ban_notification_service = BanNotificationService()
|
||||
@@ -66,18 +66,36 @@ class MenuLayoutStatsService:
|
||||
callback_data: Optional[str] = None,
|
||||
button_type: Optional[str] = None,
|
||||
button_text: Optional[str] = None,
|
||||
) -> ButtonClickLog:
|
||||
) -> Optional[ButtonClickLog]:
|
||||
"""Записать клик по кнопке."""
|
||||
click_log = ButtonClickLog(
|
||||
button_id=button_id,
|
||||
user_id=user_id,
|
||||
callback_data=callback_data,
|
||||
button_type=button_type,
|
||||
button_text=button_text,
|
||||
)
|
||||
db.add(click_log)
|
||||
await db.commit()
|
||||
return click_log
|
||||
try:
|
||||
click_log = ButtonClickLog(
|
||||
button_id=button_id,
|
||||
user_id=user_id,
|
||||
callback_data=callback_data,
|
||||
button_type=button_type,
|
||||
button_text=button_text,
|
||||
)
|
||||
db.add(click_log)
|
||||
await db.commit()
|
||||
return click_log
|
||||
except Exception:
|
||||
# If user doesn't exist (foreign key violation), try without user_id
|
||||
await db.rollback()
|
||||
try:
|
||||
click_log = ButtonClickLog(
|
||||
button_id=button_id,
|
||||
user_id=None, # Log without user reference
|
||||
callback_data=callback_data,
|
||||
button_type=button_type,
|
||||
button_text=button_text,
|
||||
)
|
||||
db.add(click_log)
|
||||
await db.commit()
|
||||
return click_log
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def get_button_stats(
|
||||
|
||||
@@ -558,7 +558,8 @@ class ReportingService:
|
||||
select(func.count(func.distinct(Subscription.user_id))).where(
|
||||
or_(
|
||||
Subscription.connected_squads.is_(None),
|
||||
func.jsonb_array_length(cast(Subscription.connected_squads, JSONB)) == 0,
|
||||
cast(Subscription.connected_squads, JSONB) == cast('[]', JSONB),
|
||||
func.jsonb_typeof(cast(Subscription.connected_squads, JSONB)) != 'array',
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -336,9 +336,11 @@ class MiniAppSubscriptionPurchaseService:
|
||||
currency = (getattr(user, "balance_currency", None) or "RUB").upper()
|
||||
texts = get_texts(getattr(user, "language", None))
|
||||
|
||||
# Exclude trial-only servers from purchase options
|
||||
available_servers = await get_available_server_squads(
|
||||
db,
|
||||
promo_group_id=getattr(user, "promo_group_id", None),
|
||||
exclude_trial_only=True,
|
||||
)
|
||||
server_catalog: Dict[str, ServerSquad] = {server.squad_uuid: server for server in available_servers}
|
||||
|
||||
|
||||
@@ -413,6 +413,25 @@ class SubscriptionService:
|
||||
logger.error(f"Ошибка включения RemnaWave пользователя: {e}")
|
||||
return False
|
||||
|
||||
async def get_remnawave_squads(self) -> Optional[List[dict]]:
|
||||
"""Получить список internal squads из RemnaWave."""
|
||||
try:
|
||||
async with self.get_api_client() as api:
|
||||
squads = await api.get_internal_squads()
|
||||
# Преобразуем в формат для sync_with_remnawave
|
||||
result = []
|
||||
for squad in squads:
|
||||
result.append({
|
||||
'uuid': squad.uuid,
|
||||
'name': squad.name,
|
||||
})
|
||||
logger.info(f"✅ Получено {len(result)} серверов из RemnaWave")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения серверов из RemnaWave: {e}")
|
||||
return None
|
||||
|
||||
async def revoke_subscription(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
|
||||
@@ -214,6 +214,7 @@ class BotConfigurationService:
|
||||
"DEVICES_SELECTION_ENABLED": "SUBSCRIPTIONS_CORE",
|
||||
"DEVICES_SELECTION_DISABLED_AMOUNT": "SUBSCRIPTIONS_CORE",
|
||||
"BASE_SUBSCRIPTION_PRICE": "SUBSCRIPTIONS_CORE",
|
||||
"SALES_MODE": "SUBSCRIPTIONS_CORE",
|
||||
"DEFAULT_TRAFFIC_RESET_STRATEGY": "TRAFFIC",
|
||||
"RESET_TRAFFIC_ON_PAYMENT": "TRAFFIC",
|
||||
"TRAFFIC_SELECTION_MODE": "TRAFFIC",
|
||||
@@ -382,6 +383,10 @@ class BotConfigurationService:
|
||||
ChoiceOption("default", "📋 Полное меню"),
|
||||
ChoiceOption("text", "📝 Текстовое меню"),
|
||||
],
|
||||
"SALES_MODE": [
|
||||
ChoiceOption("classic", "📋 Классический (периоды из .env)"),
|
||||
ChoiceOption("tariffs", "📦 Тарифы (из кабинета)"),
|
||||
],
|
||||
"SERVER_STATUS_MODE": [
|
||||
ChoiceOption("disabled", "🚫 Отключено"),
|
||||
ChoiceOption("external_link", "🌐 Внешняя ссылка"),
|
||||
@@ -440,6 +445,19 @@ class BotConfigurationService:
|
||||
}
|
||||
|
||||
SETTING_HINTS: Dict[str, Dict[str, str]] = {
|
||||
"SALES_MODE": {
|
||||
"description": (
|
||||
"Режим продажи подписок. "
|
||||
"«Классический» — выбор периода из .env (PRICE_14_DAYS и т.д.). "
|
||||
"«Тарифы» — готовые тарифные планы из кабинета с серверами и лимитами."
|
||||
),
|
||||
"format": "Выберите один из доступных режимов.",
|
||||
"example": "tariffs",
|
||||
"warning": (
|
||||
"При смене режима логика покупки подписки полностью меняется. "
|
||||
"В режиме «Тарифы» пользователи выбирают готовый тарифный план."
|
||||
),
|
||||
},
|
||||
"YOOKASSA_ENABLED": {
|
||||
"description": (
|
||||
"Включает оплату через YooKassa. "
|
||||
|
||||
@@ -0,0 +1,631 @@
|
||||
"""
|
||||
Сервис колеса удачи (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
|
||||
period_prices = settings.PERIOD_PRICES if hasattr(settings, 'PERIOD_PRICES') else {30: 19900}
|
||||
daily_price = period_prices.get(30, 19900) / 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()
|
||||
@@ -10,6 +10,7 @@ from .middleware import RequestLoggingMiddleware
|
||||
from .routes import (
|
||||
broadcasts,
|
||||
backups,
|
||||
ban_notifications,
|
||||
campaigns,
|
||||
config,
|
||||
health,
|
||||
@@ -165,6 +166,13 @@ OPENAPI_TAGS = [
|
||||
"настройка показа при /start."
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "ban-notifications",
|
||||
"description": (
|
||||
"Эндпоинты для приема уведомлений от системы мониторинга ban (Banhammer). "
|
||||
"Позволяет отправлять уведомления пользователям о блокировке и разблокировке."
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -258,6 +266,11 @@ def create_web_api_app() -> FastAPI:
|
||||
)
|
||||
app.include_router(webhooks.router, prefix="/webhooks", tags=["webhooks"])
|
||||
app.include_router(websocket.router, tags=["websocket"])
|
||||
app.include_router(
|
||||
ban_notifications.router,
|
||||
prefix="/ban-notifications",
|
||||
tags=["ban-notifications"],
|
||||
)
|
||||
|
||||
# Cabinet (Personal Account) routes
|
||||
if settings.is_cabinet_enabled():
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
API эндпоинты для приема уведомлений от ban системы
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.webapi.dependencies import get_db_session, require_api_token
|
||||
from app.webapi.schemas.ban_notifications import (
|
||||
BanNotificationRequest,
|
||||
BanNotificationResponse,
|
||||
)
|
||||
from app.services.ban_notification_service import ban_notification_service
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/send",
|
||||
response_model=BanNotificationResponse,
|
||||
summary="Отправить уведомление от ban системы",
|
||||
description=(
|
||||
"Эндпоинт для отправки уведомлений пользователям от системы мониторинга ban. "
|
||||
"Поддерживает уведомления о блокировке, разблокировке и предупреждения."
|
||||
),
|
||||
)
|
||||
async def send_ban_notification(
|
||||
request: BanNotificationRequest,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
_token=Depends(require_api_token),
|
||||
) -> BanNotificationResponse:
|
||||
"""
|
||||
Отправить уведомление пользователю от ban системы
|
||||
|
||||
- **punishment**: Уведомление о блокировке за превышение лимита устройств
|
||||
- **enabled**: Уведомление о снятии блокировки
|
||||
- **warning**: Предупреждение пользователю
|
||||
|
||||
Требует API ключ в заголовке X-API-Key или Authorization: Bearer <token>
|
||||
"""
|
||||
logger.info(
|
||||
f"Получен запрос на отправку уведомления типа '{request.notification_type}' "
|
||||
f"для пользователя {request.username} ({request.user_identifier})"
|
||||
)
|
||||
|
||||
try:
|
||||
if request.notification_type == "punishment":
|
||||
if request.ip_count is None or request.limit is None or request.ban_minutes is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Для типа 'punishment' требуются поля: ip_count, limit, ban_minutes"
|
||||
)
|
||||
|
||||
success, message, telegram_id = await ban_notification_service.send_punishment_notification(
|
||||
db=db,
|
||||
user_identifier=request.user_identifier,
|
||||
username=request.username,
|
||||
ip_count=request.ip_count,
|
||||
limit=request.limit,
|
||||
ban_minutes=request.ban_minutes,
|
||||
)
|
||||
|
||||
elif request.notification_type == "enabled":
|
||||
success, message, telegram_id = await ban_notification_service.send_enabled_notification(
|
||||
db=db,
|
||||
user_identifier=request.user_identifier,
|
||||
username=request.username,
|
||||
)
|
||||
|
||||
elif request.notification_type == "warning":
|
||||
if not request.warning_message:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Для типа 'warning' требуется поле: warning_message"
|
||||
)
|
||||
|
||||
success, message, telegram_id = await ban_notification_service.send_warning_notification(
|
||||
db=db,
|
||||
user_identifier=request.user_identifier,
|
||||
username=request.username,
|
||||
warning_message=request.warning_message,
|
||||
)
|
||||
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Неизвестный тип уведомления: {request.notification_type}"
|
||||
)
|
||||
|
||||
return BanNotificationResponse(
|
||||
success=success,
|
||||
message=message,
|
||||
telegram_id=telegram_id,
|
||||
sent=success
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"Ошибка при отправке уведомления: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Внутренняя ошибка сервера: {str(e)}"
|
||||
)
|
||||
@@ -1297,10 +1297,11 @@ async def create_payment_link(
|
||||
if not result:
|
||||
raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment")
|
||||
|
||||
# Priority: web_app for desktop/browser, mini_app for mobile, bot as fallback
|
||||
payment_url = (
|
||||
result.get("bot_invoice_url")
|
||||
result.get("web_app_invoice_url")
|
||||
or result.get("mini_app_invoice_url")
|
||||
or result.get("web_app_invoice_url")
|
||||
or result.get("bot_invoice_url")
|
||||
)
|
||||
if not payment_url:
|
||||
raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to obtain payment url")
|
||||
@@ -5257,10 +5258,11 @@ async def submit_subscription_renewal_endpoint(
|
||||
detail={"code": "payment_creation_failed", "message": "Failed to create payment"},
|
||||
)
|
||||
|
||||
# Priority: web_app for desktop/browser, mini_app for mobile, bot as fallback
|
||||
payment_url = (
|
||||
result.get("mini_app_invoice_url")
|
||||
result.get("web_app_invoice_url")
|
||||
or result.get("mini_app_invoice_url")
|
||||
or result.get("bot_invoice_url")
|
||||
or result.get("web_app_invoice_url")
|
||||
)
|
||||
if not payment_url:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class BanNotificationRequest(BaseModel):
|
||||
"""Запрос на отправку уведомления о бане пользователю"""
|
||||
|
||||
notification_type: Literal["punishment", "enabled", "warning"] = Field(
|
||||
description="Тип уведомления: punishment (бан), enabled (разбан), warning (предупреждение)"
|
||||
)
|
||||
user_identifier: str = Field(
|
||||
description="Email или user_id пользователя из Remnawave Panel"
|
||||
)
|
||||
username: str = Field(
|
||||
description="Имя пользователя для отображения"
|
||||
)
|
||||
|
||||
# Данные для punishment
|
||||
ip_count: Optional[int] = Field(None, description="Количество устройств")
|
||||
limit: Optional[int] = Field(None, description="Лимит устройств")
|
||||
ban_minutes: Optional[int] = Field(None, description="Длительность бана в минутах")
|
||||
|
||||
# Данные для warning
|
||||
warning_message: Optional[str] = Field(None, description="Текст предупреждения")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"notification_type": "punishment",
|
||||
"user_identifier": "user@example.com",
|
||||
"username": "john_doe",
|
||||
"ip_count": 5,
|
||||
"limit": 3,
|
||||
"ban_minutes": 30
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class BanNotificationResponse(BaseModel):
|
||||
"""Ответ на запрос отправки уведомления"""
|
||||
|
||||
success: bool = Field(description="Успешно ли отправлено уведомление")
|
||||
message: str = Field(description="Сообщение о результате")
|
||||
telegram_id: Optional[int] = Field(None, description="Telegram ID получателя")
|
||||
sent: bool = Field(False, description="Было ли фактически отправлено сообщение")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"success": True,
|
||||
"message": "Уведомление отправлено",
|
||||
"telegram_id": 123456789,
|
||||
"sent": True
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ from app.utils.timezone import TimezoneAwareFormatter
|
||||
from app.utils.log_handlers import LevelFilterHandler, ExcludePaymentFilter
|
||||
from app.utils.payment_logger import payment_logger, configure_payment_logger
|
||||
from app.services.log_rotation_service import log_rotation_service
|
||||
from app.services.ban_notification_service import ban_notification_service
|
||||
|
||||
|
||||
class GracefulExit:
|
||||
@@ -219,6 +220,34 @@ async def main():
|
||||
"SKIP_MIGRATION=true",
|
||||
)
|
||||
|
||||
async with timeline.stage(
|
||||
"Синхронизация тарифов из конфига",
|
||||
"💰",
|
||||
success_message="Тарифы синхронизированы",
|
||||
) as stage:
|
||||
try:
|
||||
from app.database.crud.tariff import ensure_tariffs_synced
|
||||
from app.database.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as db:
|
||||
await ensure_tariffs_synced(db)
|
||||
except Exception as error:
|
||||
stage.warning(f"Не удалось синхронизировать тарифы: {error}")
|
||||
logger.error(f"❌ Не удалось синхронизировать тарифы: {error}")
|
||||
|
||||
async with timeline.stage(
|
||||
"Синхронизация серверов из RemnaWave",
|
||||
"🖥️",
|
||||
success_message="Серверы синхронизированы",
|
||||
) as stage:
|
||||
try:
|
||||
from app.database.crud.server_squad import ensure_servers_synced
|
||||
from app.database.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as db:
|
||||
await ensure_servers_synced(db)
|
||||
except Exception as error:
|
||||
stage.warning(f"Не удалось синхронизировать серверы: {error}")
|
||||
logger.error(f"❌ Не удалось синхронизировать серверы: {error}")
|
||||
|
||||
async with timeline.stage(
|
||||
"Загрузка конфигурации из БД",
|
||||
"⚙️",
|
||||
@@ -239,6 +268,7 @@ async def main():
|
||||
monitoring_service.bot = bot
|
||||
maintenance_service.set_bot(bot)
|
||||
broadcast_service.set_bot(bot)
|
||||
ban_notification_service.set_bot(bot)
|
||||
traffic_monitoring_scheduler.set_bot(bot)
|
||||
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
Reference in New Issue
Block a user