mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-29 08:10:24 +00:00
- Add JWT authentication for cabinet users - Add Telegram WebApp authentication - Add subscription management endpoints - Add balance and transactions endpoints - Add referral system endpoints - Add tickets support for cabinet - Add webhooks and websocket for real-time updates - Add email verification service 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
481 lines
17 KiB
Python
481 lines
17 KiB
Python
from __future__ import annotations
|
||
|
||
from typing import Any, Optional
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
||
from sqlalchemy import func, or_, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy.orm import selectinload
|
||
|
||
from app.config import settings
|
||
from app.database.crud.promo_group import get_promo_group_by_id
|
||
from app.database.crud.subscription import (
|
||
create_paid_subscription,
|
||
create_trial_subscription,
|
||
deactivate_subscription,
|
||
get_subscription_by_user_id,
|
||
replace_subscription,
|
||
)
|
||
from app.database.crud.user import (
|
||
add_user_balance,
|
||
create_user,
|
||
get_user_by_id,
|
||
get_user_by_referral_code,
|
||
get_user_by_telegram_id,
|
||
update_user,
|
||
)
|
||
from app.database.models import PromoGroup, Subscription, User, UserStatus
|
||
from app.services.subscription_service import SubscriptionService
|
||
|
||
from ..dependencies import get_db_session, require_api_token
|
||
from ..schemas.users import (
|
||
BalanceUpdateRequest,
|
||
PromoGroupSummary,
|
||
SubscriptionSummary,
|
||
UserCreateRequest,
|
||
UserListResponse,
|
||
UserResponse,
|
||
UserSubscriptionCreateRequest,
|
||
UserUpdateRequest,
|
||
)
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
def _serialize_promo_group(group: Optional[PromoGroup]) -> Optional[PromoGroupSummary]:
|
||
if not group:
|
||
return None
|
||
return PromoGroupSummary(
|
||
id=group.id,
|
||
name=group.name,
|
||
server_discount_percent=group.server_discount_percent,
|
||
traffic_discount_percent=group.traffic_discount_percent,
|
||
device_discount_percent=group.device_discount_percent,
|
||
apply_discounts_to_addons=getattr(group, "apply_discounts_to_addons", True),
|
||
)
|
||
|
||
|
||
def _serialize_subscription(subscription: Optional[Subscription]) -> Optional[SubscriptionSummary]:
|
||
if not subscription:
|
||
return None
|
||
|
||
return SubscriptionSummary(
|
||
id=subscription.id,
|
||
status=subscription.status,
|
||
actual_status=subscription.actual_status,
|
||
is_trial=subscription.is_trial,
|
||
start_date=subscription.start_date,
|
||
end_date=subscription.end_date,
|
||
traffic_limit_gb=subscription.traffic_limit_gb,
|
||
traffic_used_gb=subscription.traffic_used_gb,
|
||
device_limit=subscription.device_limit,
|
||
modem_enabled=getattr(subscription, 'modem_enabled', False) or False,
|
||
autopay_enabled=subscription.autopay_enabled,
|
||
autopay_days_before=subscription.autopay_days_before,
|
||
subscription_url=subscription.subscription_url,
|
||
subscription_crypto_link=subscription.subscription_crypto_link,
|
||
connected_squads=list(subscription.connected_squads or []),
|
||
)
|
||
|
||
|
||
def _serialize_user(user: User) -> UserResponse:
|
||
subscription = getattr(user, "subscription", None)
|
||
promo_group = getattr(user, "promo_group", None)
|
||
|
||
return UserResponse(
|
||
id=user.id,
|
||
telegram_id=user.telegram_id,
|
||
username=user.username,
|
||
first_name=user.first_name,
|
||
last_name=user.last_name,
|
||
status=user.status,
|
||
language=user.language,
|
||
balance_kopeks=user.balance_kopeks,
|
||
balance_rubles=round(user.balance_kopeks / 100, 2),
|
||
referral_code=user.referral_code,
|
||
referred_by_id=user.referred_by_id,
|
||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||
has_made_first_topup=user.has_made_first_topup,
|
||
created_at=user.created_at,
|
||
updated_at=user.updated_at,
|
||
last_activity=user.last_activity,
|
||
promo_group=_serialize_promo_group(promo_group),
|
||
subscription=_serialize_subscription(subscription),
|
||
)
|
||
|
||
|
||
def _apply_search_filter(query, search: str):
|
||
search_lower = f"%{search.lower()}%"
|
||
conditions = [
|
||
func.lower(User.username).like(search_lower),
|
||
func.lower(User.first_name).like(search_lower),
|
||
func.lower(User.last_name).like(search_lower),
|
||
func.lower(User.referral_code).like(search_lower),
|
||
]
|
||
|
||
if search.isdigit():
|
||
conditions.append(User.telegram_id == int(search))
|
||
conditions.append(User.id == int(search))
|
||
|
||
return query.where(or_(*conditions))
|
||
|
||
|
||
@router.get("", response_model=UserListResponse)
|
||
async def list_users(
|
||
_: Any = Security(require_api_token),
|
||
db: AsyncSession = Depends(get_db_session),
|
||
limit: int = Query(50, ge=1, le=200),
|
||
offset: int = Query(0, ge=0),
|
||
status_filter: Optional[UserStatus] = Query(default=None, alias="status"),
|
||
promo_group_id: Optional[int] = Query(default=None),
|
||
search: Optional[str] = Query(default=None),
|
||
) -> UserListResponse:
|
||
base_query = (
|
||
select(User)
|
||
.options(
|
||
selectinload(User.subscription),
|
||
selectinload(User.promo_group),
|
||
)
|
||
)
|
||
|
||
if status_filter:
|
||
base_query = base_query.where(User.status == status_filter.value)
|
||
|
||
if promo_group_id:
|
||
base_query = base_query.where(User.promo_group_id == promo_group_id)
|
||
|
||
if search:
|
||
base_query = _apply_search_filter(base_query, search)
|
||
|
||
total_query = base_query.with_only_columns(func.count()).order_by(None)
|
||
total = await db.scalar(total_query) or 0
|
||
|
||
result = await db.execute(
|
||
base_query.order_by(User.created_at.desc()).offset(offset).limit(limit)
|
||
)
|
||
users = result.scalars().unique().all()
|
||
|
||
return UserListResponse(
|
||
items=[_serialize_user(user) for user in users],
|
||
total=int(total),
|
||
limit=limit,
|
||
offset=offset,
|
||
)
|
||
|
||
|
||
@router.get("/{user_id}", response_model=UserResponse)
|
||
async def get_user(
|
||
user_id: int,
|
||
_: Any = Security(require_api_token),
|
||
db: AsyncSession = Depends(get_db_session),
|
||
) -> UserResponse:
|
||
# First check if the provided ID is a telegram_id
|
||
user = await get_user_by_telegram_id(db, user_id)
|
||
if user:
|
||
return _serialize_user(user)
|
||
|
||
# If not found as telegram_id, check as internal user ID
|
||
user = await get_user_by_id(db, user_id)
|
||
if not user:
|
||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||
|
||
return _serialize_user(user)
|
||
|
||
|
||
@router.get("/by-telegram-id/{telegram_id}", response_model=UserResponse)
|
||
async def get_user_by_telegram_id_endpoint(
|
||
telegram_id: int,
|
||
_: Any = Security(require_api_token),
|
||
db: AsyncSession = Depends(get_db_session),
|
||
) -> UserResponse:
|
||
"""
|
||
Get user by Telegram ID
|
||
"""
|
||
user = await get_user_by_telegram_id(db, telegram_id)
|
||
if not user:
|
||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||
|
||
return _serialize_user(user)
|
||
|
||
|
||
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||
async def create_user_endpoint(
|
||
payload: UserCreateRequest,
|
||
_: Any = Security(require_api_token),
|
||
db: AsyncSession = Depends(get_db_session),
|
||
) -> UserResponse:
|
||
existing = await get_user_by_telegram_id(db, payload.telegram_id)
|
||
if existing:
|
||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "User with this telegram_id already exists")
|
||
|
||
user = await create_user(
|
||
db,
|
||
telegram_id=payload.telegram_id,
|
||
username=payload.username,
|
||
first_name=payload.first_name,
|
||
last_name=payload.last_name,
|
||
language=payload.language,
|
||
referred_by_id=payload.referred_by_id,
|
||
)
|
||
|
||
if payload.promo_group_id and payload.promo_group_id != user.promo_group_id:
|
||
promo_group = await get_promo_group_by_id(db, payload.promo_group_id)
|
||
if not promo_group:
|
||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Promo group not found")
|
||
user = await update_user(db, user, promo_group_id=promo_group.id)
|
||
|
||
user = await get_user_by_id(db, user.id)
|
||
return _serialize_user(user)
|
||
|
||
|
||
@router.patch("/{user_id}", response_model=UserResponse)
|
||
async def update_user_endpoint(
|
||
user_id: int,
|
||
payload: UserUpdateRequest,
|
||
_: Any = Security(require_api_token),
|
||
db: AsyncSession = Depends(get_db_session),
|
||
) -> UserResponse:
|
||
# First check if the provided ID is a telegram_id
|
||
user = await get_user_by_telegram_id(db, user_id)
|
||
if user:
|
||
found_user = user
|
||
else:
|
||
# If not found as telegram_id, check as internal user ID
|
||
found_user = await get_user_by_id(db, user_id)
|
||
|
||
if not found_user:
|
||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||
|
||
updates: dict[str, Any] = {}
|
||
|
||
if payload.username is not None:
|
||
updates["username"] = payload.username
|
||
if payload.first_name is not None:
|
||
updates["first_name"] = payload.first_name
|
||
if payload.last_name is not None:
|
||
updates["last_name"] = payload.last_name
|
||
if payload.language is not None:
|
||
updates["language"] = payload.language
|
||
if payload.has_had_paid_subscription is not None:
|
||
updates["has_had_paid_subscription"] = payload.has_had_paid_subscription
|
||
if payload.has_made_first_topup is not None:
|
||
updates["has_made_first_topup"] = payload.has_made_first_topup
|
||
|
||
if payload.status is not None:
|
||
try:
|
||
status_value = UserStatus(payload.status).value
|
||
except ValueError as error:
|
||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid status") from error
|
||
updates["status"] = status_value
|
||
|
||
if payload.promo_group_id is not None:
|
||
promo_group = await get_promo_group_by_id(db, payload.promo_group_id)
|
||
if not promo_group:
|
||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Promo group not found")
|
||
updates["promo_group_id"] = promo_group.id
|
||
|
||
if payload.referral_code is not None and payload.referral_code != found_user.referral_code:
|
||
existing_code_owner = await get_user_by_referral_code(db, payload.referral_code)
|
||
if existing_code_owner and existing_code_owner.id != found_user.id:
|
||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Referral code already in use")
|
||
updates["referral_code"] = payload.referral_code
|
||
|
||
if not updates:
|
||
return _serialize_user(found_user)
|
||
|
||
found_user = await update_user(db, found_user, **updates)
|
||
# Reload the user to ensure we have the latest data
|
||
if found_user.telegram_id == user_id:
|
||
found_user = await get_user_by_telegram_id(db, user_id)
|
||
else:
|
||
found_user = await get_user_by_id(db, found_user.id)
|
||
|
||
return _serialize_user(found_user)
|
||
|
||
|
||
@router.post("/{user_id}/balance", response_model=UserResponse)
|
||
async def update_balance(
|
||
user_id: int,
|
||
payload: BalanceUpdateRequest,
|
||
_: Any = Security(require_api_token),
|
||
db: AsyncSession = Depends(get_db_session),
|
||
) -> UserResponse:
|
||
if payload.amount_kopeks == 0:
|
||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Amount must be non-zero")
|
||
|
||
# First check if the provided ID is a telegram_id
|
||
user = await get_user_by_telegram_id(db, user_id)
|
||
if user:
|
||
found_user = user
|
||
else:
|
||
# If not found as telegram_id, check as internal user ID
|
||
found_user = await get_user_by_id(db, user_id)
|
||
|
||
if not found_user:
|
||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||
|
||
success = await add_user_balance(
|
||
db,
|
||
found_user,
|
||
amount_kopeks=payload.amount_kopeks,
|
||
description=payload.description or "Корректировка через веб-API",
|
||
create_transaction=payload.create_transaction,
|
||
)
|
||
|
||
if not success:
|
||
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to update balance")
|
||
|
||
# Reload the user to ensure we have the latest data
|
||
if found_user.telegram_id == user_id:
|
||
found_user = await get_user_by_telegram_id(db, user_id)
|
||
else:
|
||
found_user = await get_user_by_id(db, found_user.id)
|
||
|
||
return _serialize_user(found_user)
|
||
|
||
|
||
async def _get_user_by_id_or_telegram_id(db: AsyncSession, user_id: int) -> User:
|
||
"""Helper function to get user by ID or telegram_id"""
|
||
user = await get_user_by_telegram_id(db, user_id)
|
||
if user:
|
||
return user
|
||
|
||
user = await get_user_by_id(db, user_id)
|
||
if not user:
|
||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
|
||
return user
|
||
|
||
|
||
@router.post("/{user_id}/subscription", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||
async def create_user_subscription(
|
||
user_id: int,
|
||
payload: UserSubscriptionCreateRequest,
|
||
_: Any = Security(require_api_token),
|
||
db: AsyncSession = Depends(get_db_session),
|
||
) -> UserResponse:
|
||
"""
|
||
Создать или заменить подписку для пользователя.
|
||
Поддерживает создание как триальных, так и платных подписок.
|
||
"""
|
||
user = await _get_user_by_id_or_telegram_id(db, user_id)
|
||
|
||
existing = await get_subscription_by_user_id(db, user.id)
|
||
if existing and not payload.replace_existing:
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
"User already has a subscription. Use replace_existing=true to replace it"
|
||
)
|
||
|
||
forced_devices = None
|
||
if not settings.is_devices_selection_enabled():
|
||
forced_devices = settings.get_disabled_mode_device_limit()
|
||
|
||
if payload.is_trial:
|
||
trial_device_limit = payload.device_limit
|
||
if trial_device_limit is None:
|
||
trial_device_limit = forced_devices
|
||
duration_days = payload.duration_days or settings.TRIAL_DURATION_DAYS
|
||
traffic_limit_gb = payload.traffic_limit_gb or settings.TRIAL_TRAFFIC_LIMIT_GB
|
||
|
||
if existing:
|
||
# Сохраняем существующие сквады при замене
|
||
connected_squads = list(existing.connected_squads or [])
|
||
if payload.squad_uuid:
|
||
connected_squads = [payload.squad_uuid]
|
||
elif payload.connected_squads:
|
||
connected_squads = payload.connected_squads
|
||
|
||
subscription = await replace_subscription(
|
||
db,
|
||
existing,
|
||
duration_days=duration_days,
|
||
traffic_limit_gb=traffic_limit_gb,
|
||
device_limit=(
|
||
trial_device_limit
|
||
if trial_device_limit is not None
|
||
else settings.TRIAL_DEVICE_LIMIT
|
||
),
|
||
connected_squads=connected_squads,
|
||
is_trial=True,
|
||
update_server_counters=True,
|
||
)
|
||
else:
|
||
subscription = await create_trial_subscription(
|
||
db,
|
||
user_id=user.id,
|
||
duration_days=duration_days,
|
||
traffic_limit_gb=traffic_limit_gb,
|
||
device_limit=trial_device_limit,
|
||
squad_uuid=payload.squad_uuid,
|
||
)
|
||
else:
|
||
if payload.duration_days is None:
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
"duration_days is required for paid subscriptions"
|
||
)
|
||
device_limit = payload.device_limit
|
||
if device_limit is None:
|
||
if forced_devices is not None:
|
||
device_limit = forced_devices
|
||
else:
|
||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||
|
||
if existing:
|
||
subscription = await replace_subscription(
|
||
db,
|
||
existing,
|
||
duration_days=payload.duration_days,
|
||
traffic_limit_gb=payload.traffic_limit_gb or settings.DEFAULT_TRAFFIC_LIMIT_GB,
|
||
device_limit=device_limit,
|
||
connected_squads=payload.connected_squads or [],
|
||
is_trial=False,
|
||
update_server_counters=True,
|
||
)
|
||
else:
|
||
subscription = await create_paid_subscription(
|
||
db,
|
||
user_id=user.id,
|
||
duration_days=payload.duration_days,
|
||
traffic_limit_gb=payload.traffic_limit_gb or settings.DEFAULT_TRAFFIC_LIMIT_GB,
|
||
device_limit=device_limit,
|
||
connected_squads=payload.connected_squads or [],
|
||
update_server_counters=True,
|
||
)
|
||
|
||
# Создаем пользователя в RemnaWave для платных подписок
|
||
subscription_service = SubscriptionService()
|
||
await subscription_service.create_remnawave_user(db, subscription)
|
||
|
||
# Перезагружаем пользователя с подпиской
|
||
user = await get_user_by_id(db, user.id)
|
||
return _serialize_user(user)
|
||
|
||
|
||
@router.delete("/{user_id}/subscription", response_model=UserResponse)
|
||
async def delete_user_subscription(
|
||
user_id: int,
|
||
_: Any = Security(require_api_token),
|
||
db: AsyncSession = Depends(get_db_session),
|
||
) -> UserResponse:
|
||
"""
|
||
Деактивировать подписку пользователя.
|
||
Подписка не удаляется физически, а помечается как DISABLED.
|
||
"""
|
||
user = await _get_user_by_id_or_telegram_id(db, user_id)
|
||
|
||
subscription = await get_subscription_by_user_id(db, user.id)
|
||
if not subscription:
|
||
raise HTTPException(status.HTTP_404_NOT_FOUND, "User has no subscription")
|
||
|
||
await deactivate_subscription(db, subscription)
|
||
|
||
# Деактивируем пользователя в RemnaWave, если есть UUID
|
||
if user.remnawave_uuid:
|
||
subscription_service = SubscriptionService()
|
||
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
|
||
|
||
# Перезагружаем пользователя
|
||
user = await get_user_by_id(db, user.id)
|
||
return _serialize_user(user)
|