feat(subscription): добавлены новые функции для управления тарифами и трафиком

- Обновлены схемы и маршруты для поддержки покупки тарифов и управления трафиком.
- Реализована синхронизация тарифов и серверов из RemnaWave при запуске.
- Добавлены новые параметры в тарифы: server_traffic_limits и allow_traffic_topup.
- Обновлены настройки и логика для проверки доступности докупки трафика в зависимости от тарифа.
- Внедрены новые эндпоинты для работы с колесом удачи и обработка платежей через Stars.

Обновлён .env.example с новыми параметрами для режима продаж подписок.
This commit is contained in:
PEDZEO
2026-01-12 07:41:10 +03:00
parent 805e3847b9
commit 0e24a5505c
37 changed files with 5722 additions and 80 deletions
+11 -4
View File
@@ -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
+14
View File
@@ -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"]
+330
View File
@@ -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)}",
)
+301
View File
@@ -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=[],
)
+446
View File
@@ -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,
)
+387
View File
@@ -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,
)
+127 -1
View File
@@ -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(
+160 -9
View File
@@ -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)
+196
View File
@@ -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()
+329 -10
View File
@@ -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
+9 -2
View File
@@ -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)
+271
View File
@@ -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",
)
+119
View File
@@ -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
+8
View File
@@ -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")
+149
View File
@@ -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
+7
View File
@@ -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")
+259
View File
@@ -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
+56 -2
View File
@@ -1658,6 +1658,20 @@ 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():
@@ -1677,6 +1691,20 @@ class Settings(BaseSettings):
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():
@@ -2188,11 +2216,37 @@ _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()
if _DB_PERIOD_PRICES:
# Используем цены из БД
PERIOD_PRICES.update(_DB_PERIOD_PRICES)
else:
# Fallback на .env
PERIOD_PRICES.update(
{
days: getattr(settings, field_name, 0)
+169 -2
View File
@@ -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,22 +530,110 @@ async def get_random_trial_squad_uuid(
def _generate_display_name(original_name: str) -> str:
"""Генерирует отображаемое название сервера на основе оригинального имени."""
country_names = {
# Европа
'NL': '🇳🇱 Нидерланды',
'DE': '🇩🇪 Германия',
'US': '🇺🇸 США',
'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
@@ -550,10 +642,42 @@ def _generate_display_name(original_name: str) -> str:
def _extract_country_code(original_name: str) -> Optional[str]:
"""Извлекает код страны из оригинального названия."""
# Полный список кодов стран
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',
]
codes = ['NL', 'DE', 'US', 'FR', 'GB', 'IT', 'ES', 'CA', 'JP', 'SG', 'AU']
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
@@ -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:
+131
View File
@@ -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)
+438
View File
@@ -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,
}
+162
View File
@@ -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:
@@ -2250,3 +2285,130 @@ class CabinetRefreshToken(Base):
def __repr__(self) -> str:
status = "valid" if self.is_valid else ("revoked" if self.is_revoked else "expired")
return f"<CabinetRefreshToken id={self.id} user_id={self.user_id} status={status}>"
# ==================== FORTUNE WHEEL ====================
class WheelConfig(Base):
"""Глобальная конфигурация колеса удачи."""
__tablename__ = "wheel_configs"
id = Column(Integer, primary_key=True, index=True)
# Основные настройки
is_enabled = Column(Boolean, default=False, nullable=False)
name = Column(String(255), default="Колесо удачи", nullable=False)
# Стоимость спина
spin_cost_stars = Column(Integer, default=10, nullable=False) # Стоимость в Stars
spin_cost_days = Column(Integer, default=1, nullable=False) # Стоимость в днях подписки
spin_cost_stars_enabled = Column(Boolean, default=True, nullable=False)
spin_cost_days_enabled = Column(Boolean, default=True, nullable=False)
# RTP настройки (Return to Player) - процент возврата 0-100
rtp_percent = Column(Integer, default=80, nullable=False)
# Лимиты
daily_spin_limit = Column(Integer, default=5, nullable=False) # 0 = без лимита
min_subscription_days_for_day_payment = Column(Integer, default=3, nullable=False)
# Генерация промокодов
promo_prefix = Column(String(20), default="WHEEL", nullable=False)
promo_validity_days = Column(Integer, default=7, nullable=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
prizes = relationship("WheelPrize", back_populates="config", cascade="all, delete-orphan")
def __repr__(self) -> str:
return f"<WheelConfig id={self.id} enabled={self.is_enabled} rtp={self.rtp_percent}%>"
class WheelPrize(Base):
"""Приз на колесе удачи."""
__tablename__ = "wheel_prizes"
id = Column(Integer, primary_key=True, index=True)
config_id = Column(Integer, ForeignKey("wheel_configs.id", ondelete="CASCADE"), nullable=False)
# Тип и значение приза
prize_type = Column(String(50), nullable=False) # WheelPrizeType
prize_value = Column(Integer, default=0, nullable=False) # Дни/копейки/GB в зависимости от типа
# Отображение
display_name = Column(String(100), nullable=False)
emoji = Column(String(10), default="🎁", nullable=False)
color = Column(String(20), default="#3B82F6", nullable=False) # HEX цвет сектора
# Стоимость приза для расчета RTP (в копейках)
prize_value_kopeks = Column(Integer, default=0, nullable=False)
# Порядок и вероятность
sort_order = Column(Integer, default=0, nullable=False)
manual_probability = Column(Float, nullable=True) # Если задано - игнорирует RTP расчет (0.0-1.0)
is_active = Column(Boolean, default=True, nullable=False)
# Настройки генерируемого промокода (только для prize_type=promocode)
promo_balance_bonus_kopeks = Column(Integer, default=0)
promo_subscription_days = Column(Integer, default=0)
promo_traffic_gb = Column(Integer, default=0)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
config = relationship("WheelConfig", back_populates="prizes")
spins = relationship("WheelSpin", back_populates="prize")
def __repr__(self) -> str:
return f"<WheelPrize id={self.id} type={self.prize_type} name='{self.display_name}'>"
class WheelSpin(Base):
"""История спинов колеса удачи."""
__tablename__ = "wheel_spins"
__table_args__ = (
Index("ix_wheel_spins_user_created", "user_id", "created_at"),
)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
prize_id = Column(Integer, ForeignKey("wheel_prizes.id", ondelete="SET NULL"), nullable=True)
# Способ оплаты
payment_type = Column(String(50), nullable=False) # WheelSpinPaymentType
payment_amount = Column(Integer, nullable=False) # Stars или дни
payment_value_kopeks = Column(Integer, nullable=False) # Эквивалент в копейках для статистики
# Результат
prize_type = Column(String(50), nullable=False) # Копируем из WheelPrize на момент спина
prize_value = Column(Integer, nullable=False)
prize_display_name = Column(String(100), nullable=False)
prize_value_kopeks = Column(Integer, nullable=False) # Стоимость приза в копейках
# Сгенерированный промокод (если приз - промокод)
generated_promocode_id = Column(Integer, ForeignKey("promocodes.id"), nullable=True)
# Флаг успешного начисления
is_applied = Column(Boolean, default=False, nullable=False)
applied_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=func.now())
user = relationship("User", backref="wheel_spins")
prize = relationship("WheelPrize", back_populates="spins")
generated_promocode = relationship("PromoCode")
@property
def prize_value_rubles(self) -> float:
"""Стоимость приза в рублях."""
return self.prize_value_kopeks / 100
@property
def payment_value_rubles(self) -> float:
"""Стоимость оплаты в рублях."""
return self.payment_value_kopeks / 100
def __repr__(self) -> str:
return f"<WheelSpin id={self.id} user_id={self.user_id} prize='{self.prize_display_name}'>"
+318
View File
@@ -5332,6 +5332,305 @@ 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("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===")
@@ -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"))
+119 -1
View File
@@ -14,6 +14,112 @@ from app.localization.texts import get_texts
logger = logging.getLogger(__name__)
async def _handle_wheel_spin_payment(
message: types.Message,
db: AsyncSession,
user,
stars_amount: int,
payload: str,
texts,
):
"""Обработка Stars платежа для колеса удачи."""
from app.services.wheel_service import wheel_service
from app.database.crud.wheel import get_or_create_wheel_config, get_wheel_prizes
try:
config = await get_or_create_wheel_config(db)
if not config.is_enabled:
await message.answer(
"❌ Колесо удачи временно недоступно. Звезды будут возвращены.",
)
return False
# Выполняем спин напрямую (оплата уже прошла через Stars)
prizes = await get_or_create_wheel_config(db)
prizes = await get_wheel_prizes(db, config.id, active_only=True)
if not prizes:
await message.answer(
"❌ Призы не настроены. Обратитесь в поддержку.",
)
return False
# Рассчитываем стоимость в копейках для статистики
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(stars_amount)
payment_value_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP))
# Рассчитываем вероятности и выбираем приз
prizes_with_probs = wheel_service.calculate_prize_probabilities(config, prizes, payment_value_kopeks)
selected_prize = wheel_service._select_prize(prizes_with_probs)
# Применяем приз
generated_promocode = await wheel_service._apply_prize(db, user, selected_prize, config)
# Создаем запись спина
from app.database.crud.wheel import create_wheel_spin
from app.database.models import WheelSpinPaymentType
promocode_id = None
if generated_promocode:
result = await db.execute(
f"SELECT id FROM promocodes WHERE code = '{generated_promocode}'"
)
row = result.fetchone()
if row:
promocode_id = row[0]
logger.info(
f"🎰 Creating wheel spin: user.id={user.id}, user.telegram_id={user.telegram_id}, "
f"prize={selected_prize.display_name}"
)
spin = await create_wheel_spin(
db=db,
user_id=user.id,
prize_id=selected_prize.id,
payment_type=WheelSpinPaymentType.TELEGRAM_STARS.value,
payment_amount=stars_amount,
payment_value_kopeks=payment_value_kopeks,
prize_type=selected_prize.prize_type,
prize_value=selected_prize.prize_value,
prize_display_name=selected_prize.display_name,
prize_value_kopeks=selected_prize.prize_value_kopeks,
generated_promocode_id=promocode_id,
is_applied=True,
)
logger.info(f"🎰 Wheel spin created: spin.id={spin.id}, spin.user_id={spin.user_id}")
# Ensure all changes are committed (subscription days, traffic GB, etc.)
await db.commit()
# Отправляем результат
prize_message = wheel_service._get_prize_message(selected_prize, generated_promocode)
emoji = selected_prize.emoji or "🎁"
await message.answer(
f"🎰 <b>Колесо удачи!</b>\n\n"
f"{emoji} <b>{selected_prize.display_name}</b>\n\n"
f"{prize_message}\n\n"
f"⭐ Потрачено: {stars_amount} Stars",
parse_mode="HTML",
)
logger.info(
f"🎰 Wheel spin via Stars: user={user.id}, prize={selected_prize.display_name}, "
f"stars={stars_amount}"
)
return True
except Exception as e:
logger.error(f"Ошибка обработки wheel spin payment: {e}", exc_info=True)
await message.answer(
"❌ Произошла ошибка при обработке спина. Обратитесь в поддержку.",
)
return False
async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
texts = get_texts(DEFAULT_LANGUAGE)
@@ -22,7 +128,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}"
)
allowed_prefixes = ("balance_", "admin_stars_test_", "simple_sub_")
allowed_prefixes = ("balance_", "admin_stars_test_", "simple_sub_", "wheel_spin_")
if not query.invoice_payload or not query.invoice_payload.startswith(allowed_prefixes):
logger.warning(f"Невалидный payload: {query.invoice_payload}")
@@ -109,6 +215,18 @@ async def handle_successful_payment(
)
return
# Обработка оплаты спина колеса удачи
if payment.invoice_payload and payment.invoice_payload.startswith("wheel_spin_"):
await _handle_wheel_spin_payment(
message=message,
db=db,
user=user,
stars_amount=payment.total_amount,
payload=payment.invoice_payload,
texts=texts,
)
return
payment_service = PaymentService(message.bot)
state_data = await state.get_data()
+26 -4
View File
@@ -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,12 +107,17 @@ async def handle_add_traffic(
)
return
# В режиме тарифов докупка трафика недоступна
if settings.is_tariffs_mode():
# Проверяем настройку на уровне тарифа (если тариф задан)
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,
)
@@ -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
+234
View File
@@ -0,0 +1,234 @@
"""
Сервис для отправки уведомлений от ban системы пользователям
"""
import logging
from typing import Optional, Tuple
from datetime import datetime
from aiogram import Bot
from aiogram.exceptions import TelegramAPIError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database.models import User
from app.services.remnawave_service import remnawave_service
logger = logging.getLogger(__name__)
class BanNotificationService:
"""Сервис для отправки уведомлений о банах пользователям"""
def __init__(self):
self._bot: Optional[Bot] = None
def set_bot(self, bot: Bot):
"""Установить инстанс бота для отправки сообщений"""
self._bot = bot
async def _find_user_by_identifier(
self,
db: AsyncSession,
user_identifier: str
) -> Optional[User]:
"""
Найти пользователя по email или user_id из Remnawave Panel
Args:
db: Сессия БД
user_identifier: Email или user_id пользователя
Returns:
User или None если не найден
"""
# Сначала пытаемся получить telegram_id через remnawave_service
try:
telegram_id = await remnawave_service.get_telegram_id_by_email(user_identifier)
if telegram_id:
# Ищем пользователя по telegram_id
result = await db.execute(
select(User).where(User.telegram_id == telegram_id)
)
user = result.scalar_one_or_none()
if user:
return user
except Exception as e:
logger.warning(f"Не удалось получить telegram_id через remnawave: {e}")
# Если не нашли через remnawave, пытаемся искать по email в подписках
# (это может быть полезно если у пользователя есть подписка с таким email)
try:
# Импортируем здесь чтобы избежать циклических импортов
from app.database.models import Subscription
result = await db.execute(
select(User)
.join(Subscription)
.where(Subscription.email == user_identifier)
.limit(1)
)
user = result.scalar_one_or_none()
if user:
return user
except Exception as e:
logger.warning(f"Ошибка поиска пользователя по email в подписках: {e}")
return None
async def send_punishment_notification(
self,
db: AsyncSession,
user_identifier: str,
username: str,
ip_count: int,
limit: int,
ban_minutes: int
) -> Tuple[bool, str, Optional[int]]:
"""
Отправить уведомление о блокировке пользователю
Returns:
(success, message, telegram_id)
"""
if not self._bot:
return False, "Бот не инициализирован", None
# Находим пользователя
user = await self._find_user_by_identifier(db, user_identifier)
if not user:
logger.warning(f"Пользователь {user_identifier} не найден в базе данных")
return False, f"Пользователь не найден: {user_identifier}", None
# Формируем сообщение
message_text = (
"🚨 <b>Превышение лимита устройств</b>\n\n"
f"Ваш аккаунт временно заблокирован из-за превышения лимита подключенных устройств.\n\n"
f"📱 Обнаружено устройств: <b>{ip_count}</b>\n"
f"📊 Максимально разрешено: <b>{limit}</b>\n"
f"⏱ Блокировка на: <b>{ban_minutes} мин</b>\n\n"
f"ℹ️ Пожалуйста, отключите лишние устройства и подождите окончания блокировки.\n"
f"После разблокировки ваш доступ будет восстановлен автоматически."
)
# Отправляем сообщение
try:
await self._bot.send_message(
chat_id=user.telegram_id,
text=message_text,
parse_mode="HTML"
)
logger.info(
f"Уведомление о бане отправлено пользователю {username} "
f"(telegram_id: {user.telegram_id})"
)
return True, "Уведомление отправлено", user.telegram_id
except TelegramAPIError as e:
logger.error(
f"Ошибка отправки уведомления пользователю {username} "
f"(telegram_id: {user.telegram_id}): {e}"
)
return False, f"Ошибка Telegram API: {str(e)}", user.telegram_id
async def send_enabled_notification(
self,
db: AsyncSession,
user_identifier: str,
username: str
) -> Tuple[bool, str, Optional[int]]:
"""
Отправить уведомление о разблокировке пользователю
Returns:
(success, message, telegram_id)
"""
if not self._bot:
return False, "Бот не инициализирован", None
# Находим пользователя
user = await self._find_user_by_identifier(db, user_identifier)
if not user:
logger.warning(f"Пользователь {user_identifier} не найден в базе данных")
return False, f"Пользователь не найден: {user_identifier}", None
# Формируем сообщение
message_text = (
"✅ <b>Блокировка снята</b>\n\n"
f"Ваш аккаунт разблокирован!\n\n"
f"Теперь вы снова можете пользоваться VPN. "
f"Пожалуйста, следите за количеством подключенных устройств, "
f"чтобы избежать повторной блокировки."
)
# Отправляем сообщение
try:
await self._bot.send_message(
chat_id=user.telegram_id,
text=message_text,
parse_mode="HTML"
)
logger.info(
f"Уведомление о разбане отправлено пользователю {username} "
f"(telegram_id: {user.telegram_id})"
)
return True, "Уведомление отправлено", user.telegram_id
except TelegramAPIError as e:
logger.error(
f"Ошибка отправки уведомления пользователю {username} "
f"(telegram_id: {user.telegram_id}): {e}"
)
return False, f"Ошибка Telegram API: {str(e)}", user.telegram_id
async def send_warning_notification(
self,
db: AsyncSession,
user_identifier: str,
username: str,
warning_message: str
) -> Tuple[bool, str, Optional[int]]:
"""
Отправить предупреждение пользователю
Returns:
(success, message, telegram_id)
"""
if not self._bot:
return False, "Бот не инициализирован", None
# Находим пользователя
user = await self._find_user_by_identifier(db, user_identifier)
if not user:
logger.warning(f"Пользователь {user_identifier} не найден в базе данных")
return False, f"Пользователь не найден: {user_identifier}", None
# Формируем сообщение
message_text = (
"⚠️ <b>Предупреждение</b>\n\n"
f"{warning_message}"
)
# Отправляем сообщение
try:
await self._bot.send_message(
chat_id=user.telegram_id,
text=message_text,
parse_mode="HTML"
)
logger.info(
f"Предупреждение отправлено пользователю {username} "
f"(telegram_id: {user.telegram_id})"
)
return True, "Предупреждение отправлено", user.telegram_id
except TelegramAPIError as e:
logger.error(
f"Ошибка отправки предупреждения пользователю {username} "
f"(telegram_id: {user.telegram_id}): {e}"
)
return False, f"Ошибка Telegram API: {str(e)}", user.telegram_id
# Глобальный экземпляр сервиса
ban_notification_service = BanNotificationService()
+19 -1
View File
@@ -66,8 +66,9 @@ class MenuLayoutStatsService:
callback_data: Optional[str] = None,
button_type: Optional[str] = None,
button_text: Optional[str] = None,
) -> ButtonClickLog:
) -> Optional[ButtonClickLog]:
"""Записать клик по кнопке."""
try:
click_log = ButtonClickLog(
button_id=button_id,
user_id=user_id,
@@ -78,6 +79,23 @@ class MenuLayoutStatsService:
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(
+2 -1
View File
@@ -558,7 +558,8 @@ class ReportingService:
select(func.count(func.distinct(Subscription.user_id))).where(
or_(
Subscription.connected_squads.is_(None),
func.jsonb_array_length(cast(Subscription.connected_squads, JSONB)) == 0,
cast(Subscription.connected_squads, JSONB) == cast('[]', JSONB),
func.jsonb_typeof(cast(Subscription.connected_squads, JSONB)) != 'array',
)
)
)
@@ -336,9 +336,11 @@ class MiniAppSubscriptionPurchaseService:
currency = (getattr(user, "balance_currency", None) or "RUB").upper()
texts = get_texts(getattr(user, "language", None))
# Exclude trial-only servers from purchase options
available_servers = await get_available_server_squads(
db,
promo_group_id=getattr(user, "promo_group_id", None),
exclude_trial_only=True,
)
server_catalog: Dict[str, ServerSquad] = {server.squad_uuid: server for server in available_servers}
+19
View File
@@ -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,
+18
View File
@@ -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. "
+631
View File
@@ -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()
+13
View File
@@ -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():
+110
View File
@@ -0,0 +1,110 @@
"""
API эндпоинты для приема уведомлений от ban системы
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.webapi.dependencies import get_db_session, require_api_token
from app.webapi.schemas.ban_notifications import (
BanNotificationRequest,
BanNotificationResponse,
)
from app.services.ban_notification_service import ban_notification_service
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post(
"/send",
response_model=BanNotificationResponse,
summary="Отправить уведомление от ban системы",
description=(
"Эндпоинт для отправки уведомлений пользователям от системы мониторинга ban. "
"Поддерживает уведомления о блокировке, разблокировке и предупреждения."
),
)
async def send_ban_notification(
request: BanNotificationRequest,
db: AsyncSession = Depends(get_db_session),
_token=Depends(require_api_token),
) -> BanNotificationResponse:
"""
Отправить уведомление пользователю от ban системы
- **punishment**: Уведомление о блокировке за превышение лимита устройств
- **enabled**: Уведомление о снятии блокировки
- **warning**: Предупреждение пользователю
Требует API ключ в заголовке X-API-Key или Authorization: Bearer <token>
"""
logger.info(
f"Получен запрос на отправку уведомления типа '{request.notification_type}' "
f"для пользователя {request.username} ({request.user_identifier})"
)
try:
if request.notification_type == "punishment":
if request.ip_count is None or request.limit is None or request.ban_minutes is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Для типа 'punishment' требуются поля: ip_count, limit, ban_minutes"
)
success, message, telegram_id = await ban_notification_service.send_punishment_notification(
db=db,
user_identifier=request.user_identifier,
username=request.username,
ip_count=request.ip_count,
limit=request.limit,
ban_minutes=request.ban_minutes,
)
elif request.notification_type == "enabled":
success, message, telegram_id = await ban_notification_service.send_enabled_notification(
db=db,
user_identifier=request.user_identifier,
username=request.username,
)
elif request.notification_type == "warning":
if not request.warning_message:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Для типа 'warning' требуется поле: warning_message"
)
success, message, telegram_id = await ban_notification_service.send_warning_notification(
db=db,
user_identifier=request.user_identifier,
username=request.username,
warning_message=request.warning_message,
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Неизвестный тип уведомления: {request.notification_type}"
)
return BanNotificationResponse(
success=success,
message=message,
telegram_id=telegram_id,
sent=success
)
except HTTPException:
raise
except Exception as e:
logger.exception(f"Ошибка при отправке уведомления: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Внутренняя ошибка сервера: {str(e)}"
)
+6 -4
View File
@@ -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(
+57
View File
@@ -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
}
}
+30
View File
@@ -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