From 0e24a5505cd26453bcea0dec92369dc152861ac2 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Mon, 12 Jan 2026 07:41:10 +0300 Subject: [PATCH] =?UTF-8?q?feat(subscription):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B5?= =?UTF-8?q?=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=B8=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D1=82=D0=B0=D1=80=D0=B8=D1=84=D0=B0=D0=BC=D0=B8=20?= =?UTF-8?q?=D0=B8=20=D1=82=D1=80=D0=B0=D1=84=D0=B8=D0=BA=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Обновлены схемы и маршруты для поддержки покупки тарифов и управления трафиком. - Реализована синхронизация тарифов и серверов из RemnaWave при запуске. - Добавлены новые параметры в тарифы: server_traffic_limits и allow_traffic_topup. - Обновлены настройки и логика для проверки доступности докупки трафика в зависимости от тарифа. - Внедрены новые эндпоинты для работы с колесом удачи и обработка платежей через Stars. Обновлён .env.example с новыми параметрами для режима продаж подписок. --- .env.example | 15 +- app/cabinet/routes/__init__.py | 14 + app/cabinet/routes/admin_servers.py | 330 +++++++++ app/cabinet/routes/admin_stats.py | 301 +++++++++ app/cabinet/routes/admin_tariffs.py | 446 +++++++++++++ app/cabinet/routes/admin_wheel.py | 387 +++++++++++ app/cabinet/routes/balance.py | 128 +++- app/cabinet/routes/branding.py | 169 ++++- app/cabinet/routes/media.py | 196 ++++++ app/cabinet/routes/subscription.py | 339 +++++++++- app/cabinet/routes/tickets.py | 11 +- app/cabinet/routes/wheel.py | 271 ++++++++ app/cabinet/schemas/servers.py | 119 ++++ app/cabinet/schemas/subscription.py | 8 + app/cabinet/schemas/tariffs.py | 149 +++++ app/cabinet/schemas/tickets.py | 7 + app/cabinet/schemas/wheel.py | 259 +++++++ app/config.py | 86 ++- app/database/crud/server_squad.py | 183 ++++- app/database/crud/tariff.py | 131 ++++ app/database/crud/wheel.py | 438 ++++++++++++ app/database/models.py | 164 ++++- app/database/universal_migration.py | 320 ++++++++- app/handlers/stars_payments.py | 120 +++- app/handlers/subscription/traffic.py | 44 +- app/services/ban_notification_service.py | 234 +++++++ app/services/menu_layout/stats_service.py | 40 +- app/services/reporting_service.py | 3 +- app/services/subscription_purchase_service.py | 2 + app/services/subscription_service.py | 19 + app/services/system_settings_service.py | 18 + app/services/wheel_service.py | 631 ++++++++++++++++++ app/webapi/app.py | 13 + app/webapi/routes/ban_notifications.py | 110 +++ app/webapi/routes/miniapp.py | 10 +- app/webapi/schemas/ban_notifications.py | 57 ++ main.py | 30 + 37 files changed, 5722 insertions(+), 80 deletions(-) create mode 100644 app/cabinet/routes/admin_servers.py create mode 100644 app/cabinet/routes/admin_stats.py create mode 100644 app/cabinet/routes/admin_tariffs.py create mode 100644 app/cabinet/routes/admin_wheel.py create mode 100644 app/cabinet/routes/media.py create mode 100644 app/cabinet/routes/wheel.py create mode 100644 app/cabinet/schemas/servers.py create mode 100644 app/cabinet/schemas/tariffs.py create mode 100644 app/cabinet/schemas/wheel.py create mode 100644 app/database/crud/wheel.py create mode 100644 app/services/ban_notification_service.py create mode 100644 app/services/wheel_service.py create mode 100644 app/webapi/routes/ban_notifications.py create mode 100644 app/webapi/schemas/ban_notifications.py diff --git a/.env.example b/.env.example index 51233971..7171a0aa 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/cabinet/routes/__init__.py b/app/cabinet/routes/__init__.py index 612976c1..6f99721a 100644 --- a/app/cabinet/routes/__init__.py +++ b/app/cabinet/routes/__init__.py @@ -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"] diff --git a/app/cabinet/routes/admin_servers.py b/app/cabinet/routes/admin_servers.py new file mode 100644 index 00000000..642eedf2 --- /dev/null +++ b/app/cabinet/routes/admin_servers.py @@ -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)}", + ) diff --git a/app/cabinet/routes/admin_stats.py b/app/cabinet/routes/admin_stats.py new file mode 100644 index 00000000..07b12700 --- /dev/null +++ b/app/cabinet/routes/admin_stats.py @@ -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=[], + ) diff --git a/app/cabinet/routes/admin_tariffs.py b/app/cabinet/routes/admin_tariffs.py new file mode 100644 index 00000000..e625eef7 --- /dev/null +++ b/app/cabinet/routes/admin_tariffs.py @@ -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, + ) diff --git a/app/cabinet/routes/admin_wheel.py b/app/cabinet/routes/admin_wheel.py new file mode 100644 index 00000000..ff3770d4 --- /dev/null +++ b/app/cabinet/routes/admin_wheel.py @@ -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, + ) diff --git a/app/cabinet/routes/balance.py b/app/cabinet/routes/balance.py index ff0558ad..43ef9ddc 100644 --- a/app/cabinet/routes/balance.py +++ b/app/cabinet/routes/balance.py @@ -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( diff --git a/app/cabinet/routes/branding.py b/app/cabinet/routes/branding.py index 5c363e39..18983668 100644 --- a/app/cabinet/routes/branding.py +++ b/app/cabinet/routes/branding.py @@ -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) diff --git a/app/cabinet/routes/media.py b/app/cabinet/routes/media.py new file mode 100644 index 00000000..ec9ac2c9 --- /dev/null +++ b/app/cabinet/routes/media.py @@ -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() diff --git a/app/cabinet/routes/subscription.py b/app/cabinet/routes/subscription.py index b941986c..736f58af 100644 --- a/app/cabinet/routes/subscription.py +++ b/app/cabinet/routes/subscription.py @@ -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 diff --git a/app/cabinet/routes/tickets.py b/app/cabinet/routes/tickets.py index 2b2ebe86..9547b8cf 100644 --- a/app/cabinet/routes/tickets.py +++ b/app/cabinet/routes/tickets.py @@ -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) diff --git a/app/cabinet/routes/wheel.py b/app/cabinet/routes/wheel.py new file mode 100644 index 00000000..7d9320c7 --- /dev/null +++ b/app/cabinet/routes/wheel.py @@ -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", + ) diff --git a/app/cabinet/schemas/servers.py b/app/cabinet/schemas/servers.py new file mode 100644 index 00000000..19819fb4 --- /dev/null +++ b/app/cabinet/schemas/servers.py @@ -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 diff --git a/app/cabinet/schemas/subscription.py b/app/cabinet/schemas/subscription.py index 4ede8761..7ccfcfb8 100644 --- a/app/cabinet/schemas/subscription.py +++ b/app/cabinet/schemas/subscription.py @@ -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") diff --git a/app/cabinet/schemas/tariffs.py b/app/cabinet/schemas/tariffs.py new file mode 100644 index 00000000..ba46eef9 --- /dev/null +++ b/app/cabinet/schemas/tariffs.py @@ -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 diff --git a/app/cabinet/schemas/tickets.py b/app/cabinet/schemas/tickets.py index a0388935..90ede395 100644 --- a/app/cabinet/schemas/tickets.py +++ b/app/cabinet/schemas/tickets.py @@ -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") diff --git a/app/cabinet/schemas/wheel.py b/app/cabinet/schemas/wheel.py new file mode 100644 index 00000000..612da3e5 --- /dev/null +++ b/app/cabinet/schemas/wheel.py @@ -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 diff --git a/app/config.py b/app/config.py index 58ff2239..0eab96b9 100644 --- a/app/config.py +++ b/app/config.py @@ -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] = {} diff --git a/app/database/crud/server_squad.py b/app/database/crud/server_squad.py index c08a8676..2cbd5dc7 100644 --- a/app/database/crud/server_squad.py +++ b/app/database/crud/server_squad.py @@ -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: diff --git a/app/database/crud/tariff.py b/app/database/crud/tariff.py index 2ade37cc..d19ed375 100644 --- a/app/database/crud/tariff.py +++ b/app/database/crud/tariff.py @@ -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) diff --git a/app/database/crud/wheel.py b/app/database/crud/wheel.py new file mode 100644 index 00000000..30f7cbdf --- /dev/null +++ b/app/database/crud/wheel.py @@ -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, + } diff --git a/app/database/models.py b/app/database/models.py index b7850486..cf17e6b2 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -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"" \ No newline at end of file + return f"" + + +# ==================== 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"" + + +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"" + + +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"" \ No newline at end of file diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 92c9904e..29e8d48e 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -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")) diff --git a/app/handlers/stars_payments.py b/app/handlers/stars_payments.py index 48356450..0c87ca27 100644 --- a/app/handlers/stars_payments.py +++ b/app/handlers/stars_payments.py @@ -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"🎰 Колесо удачи!\n\n" + f"{emoji} {selected_prize.display_name}\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() diff --git a/app/handlers/subscription/traffic.py b/app/handlers/subscription/traffic.py index 1c1a8de5..a2fdae2b 100644 --- a/app/handlers/subscription/traffic.py +++ b/app/handlers/subscription/traffic.py @@ -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 diff --git a/app/services/ban_notification_service.py b/app/services/ban_notification_service.py new file mode 100644 index 00000000..c3c8f8e9 --- /dev/null +++ b/app/services/ban_notification_service.py @@ -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 = ( + "🚨 Превышение лимита устройств\n\n" + f"Ваш аккаунт временно заблокирован из-за превышения лимита подключенных устройств.\n\n" + f"📱 Обнаружено устройств: {ip_count}\n" + f"📊 Максимально разрешено: {limit}\n" + f"⏱ Блокировка на: {ban_minutes} мин\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 = ( + "✅ Блокировка снята\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 = ( + "⚠️ Предупреждение\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() diff --git a/app/services/menu_layout/stats_service.py b/app/services/menu_layout/stats_service.py index f85a5006..7ca23b79 100644 --- a/app/services/menu_layout/stats_service.py +++ b/app/services/menu_layout/stats_service.py @@ -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( diff --git a/app/services/reporting_service.py b/app/services/reporting_service.py index cf6376ae..80afd083 100644 --- a/app/services/reporting_service.py +++ b/app/services/reporting_service.py @@ -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', ) ) ) diff --git a/app/services/subscription_purchase_service.py b/app/services/subscription_purchase_service.py index 5a9dcbc3..00987a2c 100644 --- a/app/services/subscription_purchase_service.py +++ b/app/services/subscription_purchase_service.py @@ -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} diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py index 990e963d..c4a509a6 100644 --- a/app/services/subscription_service.py +++ b/app/services/subscription_service.py @@ -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, diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index d7b8b787..fee51d2c 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -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. " diff --git a/app/services/wheel_service.py b/app/services/wheel_service.py new file mode 100644 index 00000000..b37c6ef6 --- /dev/null +++ b/app/services/wheel_service.py @@ -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() diff --git a/app/webapi/app.py b/app/webapi/app.py index 7da19989..d1eba6e5 100644 --- a/app/webapi/app.py +++ b/app/webapi/app.py @@ -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(): diff --git a/app/webapi/routes/ban_notifications.py b/app/webapi/routes/ban_notifications.py new file mode 100644 index 00000000..69cb08f0 --- /dev/null +++ b/app/webapi/routes/ban_notifications.py @@ -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 + """ + 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)}" + ) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 2f6acade..ceecedaa 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -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( diff --git a/app/webapi/schemas/ban_notifications.py b/app/webapi/schemas/ban_notifications.py new file mode 100644 index 00000000..1e3800de --- /dev/null +++ b/app/webapi/schemas/ban_notifications.py @@ -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 + } + } diff --git a/main.py b/main.py index 0d622271..ed529493 100644 --- a/main.py +++ b/main.py @@ -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