Compare commits

12 Commits

Author SHA1 Message Date
574d2094e5 Некоторые поправки что бы всё работало 2025-12-05 15:47:17 +03:00
f16cb3bc2e добавил подсчёт количества рефералов 2025-12-05 13:58:45 +03:00
root
eb9e00b27c Убрал мусор, добавил скрипт для тарифов 2025-12-01 16:03:50 +03:00
root
cc95ae1a6b Интеграция биллинг при покупке подписки 2025-11-29 22:19:13 +03:00
root
4d3f8b2ad3 Удалил пополнение баланса, так как его тут не должно быть, это ответственность billing 2025-11-29 22:18:24 +03:00
root
8348a9b44b Мелкие изменения в модели и дебаг для ошибки в получении транзакций 2025-11-29 20:00:12 +03:00
root
a8a31940d5 Интегрировал биллинг при оплате подписки 2025-11-29 15:12:53 +03:00
root
9ffa5ba0f7 Ошибка в ендпоинте получения последней подписки, исправил 2025-11-28 18:40:42 +03:00
root
61fadc5c0d Добавление сервиса для биллинг 2025-11-28 18:35:55 +03:00
root
6a701da4f7 Я случайно букву поставил в репозитории и убрал self из db_manager 2025-11-27 19:52:48 +03:00
root
a001694608 Встроил марзбан в бекенд, исправил бывшие проблемы с получением активной подписки 2025-11-26 18:04:22 +03:00
root
e975bf4774 Переделал модель БД под новую, переделал Репозиторий, переделал сервисы, убрал монгодб, изменил необходимые пакеты, марзбан я добавил, но не настроил. Весь старый бот вроде работает(только в рефералке не уверен) 2025-11-24 23:43:40 +03:00
33 changed files with 1285 additions and 1634 deletions

View File

@@ -1,6 +1,5 @@
#from .payment_routes import router as payment_router
from .user_routes import router
from .subscription_routes import router as subscription_router
from .support_routes import router as sup_router
# Экспорт всех маршрутов
__all__ = [ "router", "subscription_router","sup_router"]
__all__ = [ "router", "subscription_router"]

View File

@@ -1,65 +0,0 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from yookassa import Payment
from yookassa.domain.notification import WebhookNotification
from app.services.db_manager import DatabaseManager
router = APIRouter()
class CreatePaymentRequest(BaseModel):
telegram_id: str
amount: float
class PaymentResponse(BaseModel):
payment_url: str
payment_id: str
@router.post("/payment/create", response_model=PaymentResponse)
async def create_payment(request: CreatePaymentRequest):
"""
Создаёт платёж через ЮKassa и возвращает ссылку для оплаты.
"""
try:
# Создание платежа через API ЮKassa
payment = Payment.create({
"amount": {
"value": f"{request.amount:.2f}",
"currency": "RUB"
},
"confirmation": {
"type": "redirect", # Тип подтверждения (redirect или embedded)
"return_url": "https://your-app.com/success" # URL возврата
},
"description": f"Пополнение баланса для пользователя {request.telegram_id}"
})
# Возвращаем ссылку для оплаты и ID платежа
return PaymentResponse(
payment_url=payment.confirmation.confirmation_url,
payment_id=payment.id
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка создания платежа: {str(e)}")
@router.post("/payment/notification")
async def payment_notification(notification: dict, database_manager: DatabaseManager):
"""
Обрабатывает уведомления от ЮKassa.
"""
try:
# Парсим уведомление
webhook = WebhookNotification(notification)
payment_object = webhook.object
# Проверяем статус платежа
if payment_object["status"] == "succeeded":
# Обновляем баланс пользователя
telegram_id = int(payment_object["description"].split()[-1])
amount = float(payment_object["amount"]["value"])
await database_manager.update_balance(telegram_id, amount)
return {"status": "ok"}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка обработки уведомления: {str(e)}")

View File

@@ -1,7 +1,7 @@
from typing import List
from typing import List, Optional
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from app.services.db_manager import DatabaseManager
from app.services import DatabaseManager
from instance.configdb import get_database_manager
from uuid import UUID
import logging
@@ -14,16 +14,18 @@ router = APIRouter()
class BuySubscriptionRequest(BaseModel):
telegram_id: str
plan_id: str
telegram_id: int
plan_name: str
class SubscriptionResponse(BaseModel):
id: str
plan: str
vpn_server_id: str
expiry_date: str
user_id: int
plan_name: str
vpn_server_id: Optional[str]
status: str
start_date: str
end_date: str
created_at: str
updated_at: str
# Эндпоинт для покупки подписки
@router.post("/subscription/buy", response_model=dict)
@@ -35,52 +37,82 @@ async def buy_subscription(
Покупка подписки.
"""
try:
result = await database_manager.buy_sub(request_data.telegram_id, request_data.plan_id)
logger.info(f"Получен запрос на покупку подписки: {request_data.dict()}")
result = await database_manager.buy_sub(request_data.telegram_id, request_data.plan_name)
logger.info(f"Результат buy_sub: {result}")
if result == "ERROR":
raise HTTPException(status_code=500, detail="ERROR")
raise HTTPException(status_code=500, detail="Internal server error")
elif result == "INSUFFICIENT_FUNDS":
raise HTTPException(status_code=400, detail="INSUFFICIENT_FUNDS")
elif result == "TARIFF_NOT_FOUND":
raise HTTPException(status_code=400, detail="TARIFF_NOT_FOUND")
elif result == "ACTIVE_SUBSCRIPTION_EXISTS":
raise HTTPException(status_code=400, detail="ACTIVE_SUBSCRIPTION_EXISTS")
result = await database_manager.generate_uri(request_data.telegram_id)
return {"message": result}
elif result == "USER_NOT_FOUND":
raise HTTPException(status_code=404, detail="USER_NOT_FOUND")
elif result == "SUBSCRIPTION_CREATION_FAILED":
raise HTTPException(status_code=500, detail="Failed to create subscription")
elif result == "PAYMENT_FAILED_AFTER_SUBSCRIPTION":
raise HTTPException(status_code=402, detail="SUBSCRIPTION_CREATED_BUT_PAYMENT_FAILED")
elif result == "SUBSCRIPTION_CREATED_BUT_PAYMENT_FAILED":
raise HTTPException(status_code=402, detail="SUBSCRIPTION_CREATED_BUT_PAYMENT_FAILED")
# Если успешно, генерируем URI
if isinstance(result, dict) and result.get('status') == 'OK':
uri_result = await database_manager.generate_uri(request_data.telegram_id)
logger.info(f"Результат генерации URI: {uri_result}")
return {
"status": "success",
"subscription_id": result.get('subscription_id'),
"uri": uri_result[0] if uri_result and isinstance(uri_result, list) else uri_result
}
else:
return {"status": "success", "message": "Subscription created"}
except HTTPException as http_exc:
# Пропускаем HTTPException, чтобы FastAPI обработал его корректно
logger.error(f"HTTPException в buy_subscription: {http_exc.detail}")
raise http_exc
except Exception as e:
# Обрабатываем остальные исключения
logger.error(f"Неожиданная ошибка в buy_subscription: {str(e)}")
raise HTTPException(status_code=500, detail=f"Unexpected error: {str(e)}")
# Эндпоинт для получения последней подписки
@router.get("/subscription/{user_id}/last", response_model=SubscriptionResponse)
async def last_subscription(user_id: UUID, database_manager: DatabaseManager = Depends(get_database_manager)):
@router.get("/subscription/{telegram_id}/last", response_model=SubscriptionResponse)
async def last_subscription(telegram_id: int, database_manager: DatabaseManager = Depends(get_database_manager)):
"""
Возвращает последнюю подписку пользователя.
"""
logger.info(f"Получение последней подписки для пользователя: {user_id}")
logger.info(f"Получение последней подписки для пользователя: {telegram_id}")
try:
subscriptions = await database_manager.get_last_subscriptions(user_id=user_id, limit=1)
subscription = await database_manager.get_last_subscriptions(telegram_id=telegram_id)
if not subscriptions:
logger.warning(f"Подписки для пользователя {user_id} не найдены")
if not subscription :
logger.warning(f"Подписки для пользователя {telegram_id} не найдены")
raise HTTPException(status_code=404, detail="No subscriptions found")
sub = subscriptions[0]
plan = await database_manager.get_plan_by_id(subscription.plan_id)
if not plan:
logger.warning(f"Тариф для пользователя {telegram_id} не найдены")
raise HTTPException(status_code=404, detail="No plan found")
return {
"id": sub.id,
"plan": sub.plan,
"vpn_server_id": sub.vpn_server_id,
"expiry_date": sub.expiry_date.isoformat(),
"created_at": sub.created_at.isoformat(),
"updated_at": sub.updated_at.isoformat(),
"id": str(subscription.id),
"user_id": subscription.user_id,
"plan_name": plan.name,
"vpn_server_id": subscription.vpn_server_id,
"status": subscription.status.value,
"start_date": subscription.start_date.isoformat(),
"end_date": subscription.end_date.isoformat(),
"created_at": subscription.created_at.isoformat(),
}
except SQLAlchemyError as e:
logger.error(f"Ошибка базы данных при получении подписки для пользователя {user_id}: {e}")
logger.error(f"Ошибка базы данных при получении подписки для пользователя {telegram_id}: {e}")
raise HTTPException(status_code=500, detail="Database error")
except HTTPException as e:
# Пропускаем HTTPException, чтобы FastAPI обработал её автоматически
@@ -89,44 +121,57 @@ async def last_subscription(user_id: UUID, database_manager: DatabaseManager = D
logger.error(f"Неожиданная ошибка: {e}")
raise HTTPException(status_code=500, detail="Internal Server Error")
@router.get("/subscriptions/{user_id}", response_model=List[SubscriptionResponse])
async def get_subscriptions(user_id: UUID, database_manager: DatabaseManager = Depends(get_database_manager)):
@router.get("/subscriptions/{telegram_id}", response_model=List[SubscriptionResponse])
async def get_subscriptions(telegram_id: int, database_manager: DatabaseManager = Depends(get_database_manager)):
"""
Возвращает список подписок пользователя.
"""
logger.info(f"Получение подписок для пользователя: {user_id}")
logger.info(f"Получение подписок для пользователя: {telegram_id}")
try:
# Получаем подписки без ограничений или с указанным лимитом
subscriptions = await database_manager.last_subscriptions(user_id=str(user_id))
subscription = await database_manager.get_last_subscriptions(telegram_id=telegram_id)
if not subscriptions:
logger.warning(f"Подписки для пользователя {user_id} не найдены")
if not subscription:
logger.warning(f"Подписки для пользователя {telegram_id} не найдены")
raise HTTPException(status_code=404, detail="No subscriptions found")
plan = await database_manager.get_plan_by_id(subscription.plan_id)
if not plan:
logger.warning(f"Тариф для подписки {subscription.id} не найден")
plan_name = "Unknown"
else:
plan_name = plan.name
# Формируем список подписок для ответа
return [
{
"id": sub.id,
"plan": sub.plan,
"vpn_server_id": sub.vpn_server_id,
"expiry_date": sub.expiry_date.isoformat(),
"created_at": sub.created_at.isoformat(),
"updated_at": sub.updated_at.isoformat(),
}
for sub in subscriptions
]
# return [
# {
# "id": sub.id,
# "plan": sub.plan,
# "vpn_server_id": sub.vpn_server_id,
# "expiry_date": sub.expiry_date.isoformat(),
# "created_at": sub.created_at.isoformat(),
# "updated_at": sub.updated_at.isoformat(),
# }
# for sub in subscription
# ]
return [{
"id": str(subscription.id), # Конвертируем UUID в строку
"user_id": subscription.user_id,
"plan_name": plan_name,
"vpn_server_id": subscription.vpn_server_id,
"end_date": subscription.end_date.isoformat(),
"status": subscription.status.value, # Извлекаем значение enum
"start_date": subscription.start_date.isoformat(),
"created_at": subscription.created_at.isoformat()
}]
except SQLAlchemyError as e:
logger.error(f"Ошибка базы данных при получении подписок для пользователя {user_id}: {e}")
logger.error(f"Ошибка базы данных при получении подписок для пользователя {telegram_id}: {e}")
raise HTTPException(status_code=500, detail="Database error")
except Exception as e:
logger.error(f"Неожиданная ошибка: {e}")
raise HTTPException(status_code=500, detail=str(e))
except HTTPException as e:
# Пропускаем HTTPException, чтобы FastAPI обработал её автоматически
raise e
@router.get("/uri", response_model=dict)
async def get_uri(telegram_id: str, database_manager: DatabaseManager = Depends(get_database_manager)):
async def get_uri(telegram_id: int, database_manager: DatabaseManager = Depends(get_database_manager)):
"""
Возвращает список подписок пользователя.
"""
@@ -137,10 +182,10 @@ async def get_uri(telegram_id: str, database_manager: DatabaseManager = Depends(
if uri == "SUB_ERROR":
raise HTTPException(status_code=404, detail="SUB_ERROR")
if not uri:
logger.warning(f"Не удалось сгенерировать URI для пользователя с telegram_id {telegram_id}")
logger.warning(f"Не удалось сгенерировать URI для пользователя с telegram_id {telegram_id}, данные -> {uri}")
raise HTTPException(status_code=404, detail="URI not found")
return {"detail": uri}
return {"detail": uri[0]}
except HTTPException as e:
# Пропускаем HTTPException, чтобы FastAPI обработал её автоматически

View File

@@ -1,249 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.exc import SQLAlchemyError
from app.services.db_manager import DatabaseManager
from instance.configdb import get_database_manager
from uuid import UUID
from pydantic import BaseModel
from fastapi import Response, status
from enum import Enum
from typing import List, Literal, Optional
from datetime import datetime
import logging
logger = logging.getLogger(__name__)
class UpdateTicketStatusRequest(BaseModel):
new_status: str
class TicketStatus(str, Enum):
OPEN = "open"
PENDING = "pending"
CLOSED = "closed"
class CreateTicketRequest(BaseModel):
subject: str
message: str
class TicketResponse(BaseModel):
id: int
user_id: UUID
subject: str
message: str
status: TicketStatus
created_at: datetime
updated_at: datetime
class TicketMessageRequest(BaseModel):
message: str
class TicketMessageResponse(BaseModel):
ticket_id: int
sender: str
message: str
created_at: datetime
router = APIRouter()
def handle_exception(e: Exception, message: str):
logger.error(f"{message}: {e}")
raise HTTPException(status_code=500, detail=f"{message}: {str(e)}")
@router.post("/support/tickets/{ticket_id}/messages", summary="Добавить сообщение")
async def add_message(
ticket_id: int,
request: TicketMessageRequest,
sender: Literal["user", "support"], # "user" или "support"
database_manager: DatabaseManager = Depends(get_database_manager)
):
"""
Добавляет сообщение в тикет.
Args:
ticket_id (int): ID тикета.
request (TicketMessageRequest): Данные сообщения.
sender (str): Отправитель ("user" или "support").
database_manager (DatabaseManager): Управление базой данных.
Returns:
TicketMessageResponse: Данные созданного сообщения.
"""
try:
message = await database_manager.add_message_to_ticket(ticket_id=ticket_id, sender=sender, message=request.message)
if not message:
raise HTTPException(status_code=404, detail="Тикет не найден или ошибка добавления сообщения")
if message != "OK":
raise HTTPException(status_code=500, detail="Ошибка добавления сообщения")
return Response(status_code=status.HTTP_200_OK)
except Exception as e:
handle_exception(e,"Ошибка добавления сообщения")
@router.get("/support/tickets/{ticket_id}/messages", response_model=List[TicketMessageResponse], summary="Получить сообщения")
async def get_messages(
ticket_id: int,
database_manager: DatabaseManager = Depends(get_database_manager)
):
"""
Возвращает список сообщений в тикете.
Args:
ticket_id (int): ID тикета, для которого нужно получить сообщения.
database_manager (DatabaseManager): Менеджер базы данных.
Returns:
List[TicketMessageResponse]: Список сообщений, связанных с тикетом.
Raises:
HTTPException: 404, если сообщения для тикета не найдены.
HTTPException: 500, если произошла ошибка на сервере.
"""
try:
messages = await database_manager.get_ticket_messages(ticket_id=ticket_id)
ticket_info = await database_manager.get_ticket(ticket_id)
logger.info(messages)
result_messages = []
if messages:
for message in messages:
correct_response = TicketMessageResponse(
ticket_id=ticket_id,
sender=message.sender,
message=message.message,
created_at=message.created_at
)
result_messages.append(correct_response)
result_messages.insert(0,
TicketMessageResponse(
ticket_id=ticket_id,
sender="user",
message=ticket_info['message'],
created_at=ticket_info["created_at"]
)
)
return result_messages
except Exception as e:
handle_exception(e,"Ошибка получения сообщения")
@router.get("/support/ticket/{ticket_id}", response_model=TicketResponse, summary="Получить информацию о тикете")
async def get_ticket(
ticket_id: int,
database_manager: DatabaseManager = Depends(get_database_manager)
):
"""
Возвращает информацию о тикете.
Args:
ticket_id (int): ID тикета, информацию котрого хочет получить пользователь.
database_manager (DatabaseManager): Менеджер базы данных.
Returns:
TicketResponse: Информация о тикете.
Raises:
HTTPException: 404, если тикет не найден.
HTTPException: 500, если произошла ошибка на сервере.
"""
try:
# Получаем данные о тикете
ticket = await database_manager.get_ticket(ticket_id=ticket_id)
if not ticket:
raise HTTPException(status_code=404, detail="Тикет не найден")
# Возвращаем данные через Pydantic-модель
return TicketResponse(**ticket)
except SQLAlchemyError as e:
handle_exception(e, "Ошибка получения тикета")
except Exception as e:
handle_exception(e, "Неизвестная ошибка при получении тикета")
@router.post("/support/tickets", response_model=TicketResponse, summary="Создать тикет")
async def create_ticket(
request: CreateTicketRequest,
user_id: UUID,
database_manager: DatabaseManager = Depends(get_database_manager)
):
"""
Создаёт новый тикет для пользователя.
Args:
request (CreateTicketRequest): Данные для создания тикета (тема и сообщение).
user_id (UUID): Идентификатор пользователя, создающего тикет.
database_manager (DatabaseManager): Менеджер базы данных.
Returns:
TicketResponse: Данные созданного тикета.
Raises:
HTTPException: 500, если произошла ошибка при создании тикета.
"""
try:
ticket = await database_manager.create_ticket(
user_id=user_id,
subject=request.subject,
message=request.message
)
return ticket
except Exception as e:
handle_exception(e,"Ошибка содания тикета")
@router.get("/support/tickets", response_model=List[TicketResponse], summary="Получить список тикетов")
async def list_tickets(
user_id: UUID,
database_manager: DatabaseManager = Depends(get_database_manager)
):
"""
Возвращает список тикетов пользователя.
Args:
user_id (UUID): Идентификатор пользователя, чьи тикеты нужно получить.
database_manager (DatabaseManager): Менеджер базы данных.
Returns:
List[TicketResponse]: Список тикетов пользователя.
Raises:
HTTPException: 404, если тикеты не найдены.
HTTPException: 500, если произошла ошибка на сервере.
"""
try:
tickets = await database_manager.get_active_tickets(user_id=user_id)
if not tickets:
raise HTTPException(status_code=404, detail="Тикеты не найдены")
return tickets
except Exception as e:
handle_exception(e,"Ошибка получения тикетов")
@router.patch("/support/ticket/{ticket_id}/status", summary="Обновить статус тикета")
async def update_ticket_status(
ticket_id: int,
request: UpdateTicketStatusRequest,
database_manager: DatabaseManager = Depends(get_database_manager)
):
"""
Обновляет статус тикета.
Args:
ticket_id (int): ID тикета.
request (UpdateTicketStatusRequest): Запрос с новым статусом.
database_manager (DatabaseManager): Менеджер базы данных.
Returns:
dict: Подтверждение обновления статуса.
Raises:
HTTPException: Если тикет не найден или произошла ошибка.
"""
try:
result = await database_manager.update_ticket_status(ticket_id, request.new_status)
if result != "OK":
return "ERROR"
return "OK"
except ValueError as e:
logger.error(f"Тикет с ID {ticket_id} не найден: {e}")
raise HTTPException(status_code=404, detail="Тикет не найден.")
except Exception as e:
logger.error(f"Ошибка обновления статуса тикета {ticket_id}: {e}")
raise HTTPException(status_code=500, detail="Не удалось обновить статус тикета.")

View File

@@ -1,7 +1,8 @@
from datetime import datetime
import sys
from fastapi import APIRouter, Depends, HTTPException
from fastapi.exceptions import HTTPException
from app.services.db_manager import DatabaseManager
from app.services import DatabaseManager
from sqlalchemy.exc import SQLAlchemyError
from instance.configdb import get_database_manager
from pydantic import BaseModel
@@ -20,20 +21,19 @@ router = APIRouter()
# Модели запросов и ответов
class CreateUserRequest(BaseModel):
telegram_id: str
referrer_id: Optional[str] = None
telegram_id: int
invited_by: Optional[int] = None
class UserResponse(BaseModel):
id: UUID
telegram_id: str
telegram_id: int
username: Optional[str]
balance: float
referrer_id: Optional[str]
invited_by: Optional[int] = None
created_at: str
updated_at: str
class AddReferal(BaseModel):
new_user_id: str
invited_id: int
@router.post("/user/create", response_model=UserResponse, summary="Создать пользователя")
async def create_user(
@@ -44,16 +44,15 @@ async def create_user(
Создание пользователя через Telegram ID.
"""
try:
user = await db_manager.create_user(request.telegram_id,request.referrer_id)
if user == "ERROR":
user = await db_manager.create_user(request.telegram_id,request.invited_by)
if user == None:
raise HTTPException(status_code=500, detail="Failed to create user")
return UserResponse(
id=user.id,
telegram_id=user.telegram_id,
username=user.username,
balance=user.balance,
referrer_id=user.referrer_id if user.referrer_id is not None else None,
invited_by=user.invited_by if user.invited_by is not None else None,
created_at=user.created_at.isoformat(),
updated_at=user.updated_at.isoformat()
)
@@ -64,7 +63,7 @@ async def create_user(
@router.get("/user/{telegram_id}", response_model=UserResponse, summary="Получить информацию о пользователе")
async def get_user(
telegram_id: str,
telegram_id: int,
db_manager: DatabaseManager = Depends(get_database_manager)
):
"""
@@ -77,13 +76,12 @@ async def get_user(
logger.warning(f"Пользователь с telegram_id {telegram_id} не найден.")
raise HTTPException(status_code=404, detail="User not found")
print(f"Пользователь найден: ID={user.id}, Username={user.username}")
print(f"Пользователь найден: ID={user.telegram_id}, Username={user.username}")
user_response = UserResponse(
id=user.id,
telegram_id=user.telegram_id,
username=user.username,
balance=user.balance,
referrer_id=user.referrer_id if user.referrer_id is not None else None,
invited_by=user.invited_by if user.invited_by is not None else None,
created_at=user.created_at.isoformat(),
updated_at=user.updated_at.isoformat()
)
@@ -101,83 +99,69 @@ async def get_user(
logger.exception(f"Неожиданная ошибка при получении пользователя с telegram_id {telegram_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/user/{telegram_id}/balance/{amount}", summary="Обновить баланс")
async def update_balance(
telegram_id: str,
amount: float,
db_manager: DatabaseManager = Depends(get_database_manager)
):
"""
Обновляет баланс пользователя.
"""
logger.info(f"Получен запрос на обновление баланса: telegram_id={telegram_id}, amount={amount}")
try:
result = await db_manager.update_balance(telegram_id, amount)
if result == "ERROR":
logger.error(f"Ошибка обновления баланса для пользователя {telegram_id}")
raise HTTPException(status_code=500, detail="Failed to update balance")
logger.info(f"Баланс пользователя {telegram_id} успешно обновлен на {amount}")
return {"message": "Balance updated successfully"}
except HTTPException as http_ex:
logger.warning(f"HTTP ошибка: {http_ex.detail}")
raise http_ex
except SQLAlchemyError as db_ex:
logger.error(f"Ошибка базы данных при обновлении баланса пользователя {telegram_id}: {db_ex}")
raise HTTPException(status_code=500, detail="Database error")
except Exception as e:
logger.exception(f"Неожиданная ошибка при обновлении баланса пользователя {telegram_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/user/{user_id}/transactions", summary="Последние транзакции пользователя")
@router.get("/user/{telegram_id}/transactions", summary="Последние транзакции пользователя")
async def last_transactions(
user_id: UUID,
telegram_id: int,
db_manager: DatabaseManager = Depends(get_database_manager)
):
"""
Возвращает список последних транзакций пользователя.
"""
logger.info(f"Получен запрос на транзакции для пользователя: {user_id}")
logger.info(f"Получен запрос на транзакции для пользователя: {telegram_id}")
try:
logger.debug(f"Вызов метода get_transaction с user_id={user_id}")
transactions = await db_manager.get_transaction(user_id)
logger.debug(f"Вызов метода get_transaction с user_id={telegram_id}")
transactions = await db_manager.get_transaction(telegram_id)
if transactions == "ERROR":
logger.error(f"Ошибка при получении транзакций для пользователя: {user_id}")
logger.error(f"Ошибка при получении транзакций для пользователя: {telegram_id}")
raise HTTPException(status_code=500, detail="Failed to fetch transactions")
if transactions == None:
response = []
logger.info(f"Формирование ответа для пользователя {telegram_id}: {response}")
return response
logger.debug(f"Транзакции для {user_id}: {transactions}")
response = [
{
logger.debug(f"Транзакции для {telegram_id}: {transactions}")
response = []
for tx in transactions:
# Проверяем, что транзакция существует и имеет created_at
if tx is None:
continue
if tx.status.value == "pending":
continue
# Обрабатываем created_at (может быть None)
created_at_str = None
if tx.created_at:
created_at_str = tx.created_at.isoformat()
else:
created_at_str = datetime.utcnow().isoformat() # или любое значение по умолчанию
response.append({
"id": tx.id,
"amount": tx.amount,
"created_at": tx.created_at.isoformat(),
"transaction_type": tx.transaction_type,
} for tx in transactions
]
logger.info(f"Формирование ответа для пользователя {user_id}: {response}")
"amount": float(tx.amount) if tx.amount else 0.0,
"created_at": created_at_str,
"type": tx.type.value if hasattr(tx.type, 'value') else str(tx.type),
})
logger.info(f"Формирование ответа для пользователя {telegram_id}: {response}")
return response
except HTTPException as http_ex:
logger.warning(f"HTTP ошибка для {user_id}: {http_ex.detail}")
logger.warning(f"HTTP ошибка для {telegram_id}: {http_ex.detail}")
raise http_ex
except SQLAlchemyError as db_ex:
logger.error(f"Ошибка базы данных для {user_id}: {db_ex}")
logger.error(f"Ошибка базы данных для {telegram_id}: {db_ex}")
raise HTTPException(status_code=500, detail="Database error")
except Exception as e:
logger.exception(f"Неожиданная ошибка для {user_id}: {e}")
logger.exception(f"Неожиданная ошибка для {telegram_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/user/{referrer_id}/add_referral", summary="Обновить баланс")
async def add_referal(
referrer_id: str,
referrer_id: int,
request: AddReferal,
db_manager: DatabaseManager = Depends(get_database_manager)
):
@@ -186,12 +170,12 @@ async def add_referal(
"""
logger.info(f"Получен запрос на добавление реферала: telegram_id={referrer_id}")
try:
result = await db_manager.add_referal(referrer_id)
result = await db_manager.add_referal(referrer_id,request.invited_id)
if result == "ERROR":
logger.error(f"Ошибка добавления реферала для {referrer_id} c айди {request.new_user_id}")
logger.error(f"Ошибка добавления реферала для {referrer_id} c айди {request.invited_id}")
raise HTTPException(status_code=500, detail="Failed to update balance")
logger.info(f"Добавлен реферал для {referrer_id} c айди {request.new_user_id}")
logger.info(f"Добавлен реферал для {referrer_id} c айди {request.invited_id}")
return {"message": "Balance updated successfully"}
except HTTPException as http_ex:
logger.warning(f"HTTP ошибка: {http_ex.detail}")
@@ -202,3 +186,26 @@ async def add_referal(
except Exception as e:
logger.exception(f"Неожиданная ошибка при добавлении рефералу {referrer_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/user/{telegram_id}/referrals", summary="Получить колво рефералов")
async def get_referral_count(
telegram_id: int,
db_manager: DatabaseManager = Depends(get_database_manager)
):
try:
result = await db_manager.get_referrals_count(telegram_id)
if result == "ERROR":
logger.error(f"Ошибка получения рефералов для пользователя: {telegram_id}")
raise HTTPException(status_code=500, detail="Failed to get referrals")
logger.info(f"Количество приглашённых {result}")
return {"invited_count": result}
except HTTPException as http_ex:
logger.warning(f"HTTP ошибка: {http_ex.detail}")
raise http_ex
except SQLAlchemyError as db_ex:
logger.error(f"Ошибка базы данных при получении рефералов пользователя {telegram_id}: {db_ex}")
raise HTTPException(status_code=500, detail="Database error")
except Exception as e:
logger.exception(f"Неожиданная ошибка при получении рефералов {telegram_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))

4
app/services/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
from .db_manager import DatabaseManager
#from .marzban import MarzbanService
__all__ = ['DatabaseManager']

View File

@@ -0,0 +1,80 @@
import aiohttp
import logging
from typing import Dict, Any, Optional
from enum import Enum
class BillingErrorCode(Enum):
INSUFFICIENT_FUNDS = "INSUFFICIENT_FUNDS"
USER_NOT_FOUND = "USER_NOT_FOUND"
PAYMENT_FAILED = "PAYMENT_FAILED"
SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE"
class BillingAdapter:
def __init__(self, base_url: str):
self.base_url = base_url
self.logger = logging.getLogger(__name__)
self.session = None
async def get_session(self):
if self.session is None:
self.session = aiohttp.ClientSession()
return self.session
async def withdraw_funds(self, user_id: int, amount: float, description: str = "") -> Dict[str, Any]:
"""
Списание средств через биллинг-сервис
"""
try:
session = await self.get_session()
payload = {
"user_id": user_id,
"amount": amount,
"description": description or f"Payment for subscription"
}
self.logger.info(f"Withdrawing {amount} from user {user_id}")
async with session.post(f"{self.base_url}/billing/payments/withdraw", json=payload) as response:
if response.status == 200:
result = await response.json()
if result.get("success"):
return {"status": "success"}
else:
error = result.get("error", "WITHDRAWAL_FAILED")
self.logger.error(f"Withdrawal failed: {error}")
return {"status": "error", "code": error}
else:
self.logger.error(f"Billing service error: {response.status}")
return {"status": "error", "code": "SERVICE_UNAVAILABLE"}
except aiohttp.ClientError as e:
self.logger.error(f"Billing service connection error: {str(e)}")
return {"status": "error", "code": "SERVICE_UNAVAILABLE"}
except Exception as e:
self.logger.error(f"Unexpected error in withdraw_funds: {str(e)}")
return {"status": "error", "code": "PAYMENT_FAILED"}
async def get_balance(self, user_id: int) -> Dict[str, Any]:
"""
Получение баланса пользователя
"""
try:
session = await self.get_session()
async with session.get(f"{self.base_url}/billing/balance/{user_id}") as response:
if response.status == 200:
result = await response.json()
return {"status": "success", "balance": result.get("balance", 0)}
elif response.status == 404:
return {"status": "error", "code": "USER_NOT_FOUND"}
else:
return {"status": "error", "code": "SERVICE_UNAVAILABLE"}
except Exception as e:
self.logger.error(f"Error getting balance: {str(e)}")
return {"status": "error", "code": "SERVICE_UNAVAILABLE"}
async def close(self):
if self.session:
await self.session.close()

View File

@@ -1,12 +1,12 @@
from decimal import Decimal
import json
from instance.model import User, Subscription, Transaction, SupportTicket, TicketMessage, TicketStatus
from .xui_rep import PanelInteraction
from instance.model import User, Subscription, Transaction
from app.services.billing_service import BillingAdapter
from app.services.marzban import MarzbanService, MarzbanUser
from .postgres_rep import PostgresRepository
from .mongo_rep import MongoDBRepository
from instance.model import Transaction
from instance.model import Transaction,TransactionType, Plan
from dateutil.relativedelta import relativedelta
from datetime import datetime
from datetime import datetime, timezone
import random
import string
from typing import Optional
@@ -15,171 +15,207 @@ from uuid import UUID
class DatabaseManager:
def __init__(self, session_generator):
def __init__(self, session_generator,marzban_username,marzban_password,marzban_url,billing_base_url):
"""
Инициализация с асинхронным генератором сессий (например, get_postgres_session).
"""
self.logger = logging.getLogger(__name__)
self.mongo_repo = MongoDBRepository()
self.postgres_repo = PostgresRepository(session_generator, self.logger)
async def get_active_tickets(self, user_id: UUID):
"""
Получает активные подписки пользователя
"""
return await self.postgres_repo.list_active_tickets(user_id)
self.marzban_service = MarzbanService(marzban_url,marzban_username,marzban_password)
self.billing_adapter = BillingAdapter(billing_base_url)
async def create_user(self, telegram_id: str, referrer_id: Optional[str]= None):
async def create_user(self, telegram_id: int, invented_by: Optional[int]= None):
"""
Создаёт пользователя.
"""
try:
username = self.generate_string(6)
return await self.postgres_repo.create_user(telegram_id, username, referrer_id)
return await self.postgres_repo.create_user(telegram_id, username, invented_by)
except Exception as e:
self.logger.error(f"Ошибка при создании пользователя:{e}")
async def get_user_by_telegram_id(self, telegram_id: str):
async def get_user_by_telegram_id(self, telegram_id: int):
"""
Возвращает пользователя по Telegram ID.
"""
return await self.postgres_repo.get_user_by_telegram_id(telegram_id)
async def add_transaction(self, user_id: UUID, amount: float):
async def add_transaction(self, telegram_id: int, amount: float):
"""
Добавляет транзакцию.
"""
tran = Transaction(
user_id=user_id,
user_id=telegram_id,
amount=Decimal(amount),
transaction_type="default"
type=TransactionType.DEPOSIT
)
return await self.postgres_repo.add_record(tran)
async def add_referal(self,referrer_id: str, new_user_id: str):
async def add_referal(self,referrer_id: int, new_user_telegram_id: int):
"""
Добавление рефералу пользователей
"""
return await self.postgres_repo.add_referal(referrer_id,new_user_id)
async def get_transaction(self, user_id: UUID, limit: int = 10):
return await self.postgres_repo.add_referral(referrer_id,new_user_telegram_id)
async def get_transaction(self, telegram_id: int, limit: int = 10):
"""
Возвращает транзакции.
"""
return await self.postgres_repo.get_last_transactions(user_id, limit)
return await self.postgres_repo.get_last_transactions(telegram_id, limit)
async def update_balance(self, telegram_id: str, amount: float):
async def get_referrals_count(self,telegram_id: int) -> int:
"""
Обновляет баланс пользователя и добавляет транзакцию.
Docstring for get_referrals_count
:param self: Description
:param telegram_id: Description
:type telegram_id: int
:return: Description
:rtype: int
"""
self.logger.info(f"Попытка обновления баланса: telegram_id={telegram_id}, amount={amount}")
user = await self.get_user_by_telegram_id(telegram_id)
if not user:
self.logger.warning(f"Пользователь с Telegram ID {telegram_id} не найден.")
return "ERROR"
return await self.postgres_repo.get_referrals_count(telegram_id)
updated = await self.postgres_repo.update_balance(user, amount)
if not updated:
self.logger.error(f"Не удалось обновить баланс пользователя {telegram_id}")
return "ERROR"
# async def update_balance(self, telegram_id: int, amount: float):
# """
# Обновляет баланс пользователя и добавляет транзакцию.
# """
# self.logger.info(f"Попытка обновления баланса: telegram_id={telegram_id}, amount={amount}")
# user = await self.get_user_by_telegram_id(telegram_id)
# if not user:
# self.logger.warning(f"Пользователь с Telegram ID {telegram_id} не найден.")
# return "ERROR"
self.logger.info(f"Баланс пользователя {telegram_id} обновлен на {amount}, добавление транзакции")
await self.add_transaction(user.id, amount)
return "OK"
# updated = await self.postgres_repo.update_balance(user, amount)
# if not updated:
# self.logger.error(f"Не удалось обновить баланс пользователя {telegram_id}")
# return "ERROR"
# self.logger.info(f"Баланс пользователя {telegram_id} обновлен на {amount}, добавление транзакции")
# await self.add_transaction(user.telegram_id, amount)
# return "OK"
async def get_active_subscription(self, telegram_id: str):
async def get_active_subscription(self, telegram_id: int):
"""
Проверяет наличие активной подписки.
"""
return await self.postgres_repo.get_active_subscription(telegram_id)
try:
async def get_last_subscriptions(self, user_id: UUID, limit: int ):
return await self.postgres_repo.get_active_subscription(telegram_id)
except Exception as e:
self.logger.error(f"Неожиданная ошибка в get_active_subscription: {str(e)}")
return "ERROR"
async def get_plan_by_id(self, plan_id):
"""
Ищет по названию плана.
"""
try:
return await self.postgres_repo.get_plan_by_id(plan_id)
except Exception as e:
self.logger.error(f"Неожиданная ошибка в get_plan_by_name: {str(e)}")
return None
async def get_last_subscriptions(self, telegram_id: int, limit: int = 1):
"""
Возвращает список последних подписок.
"""
return await self.postgres_repo.get_last_subscription_by_user_id(user_id, limit)
return await self.postgres_repo.get_last_subscription_by_user_id(telegram_id)
async def buy_sub(self, telegram_id: str, plan_id: str):
async def buy_sub(self, telegram_id: int, plan_name: str):
"""
Покупает подписку.
Покупка подписки: сначала создаем подписку, потом списываем деньги
"""
active_subscription = await self.get_active_subscription(telegram_id)
self.logger.info(f"{active_subscription}")
if active_subscription:
self.logger.error(f"Пользователь {telegram_id} уже имеет активную подписку.")
return "ACTIVE_SUBSCRIPTION_EXISTS"
try:
self.logger.info(f"Покупка подписки: user={telegram_id}, plan={plan_name}")
result = await self._initialize_user_and_plan(telegram_id, plan_id)
if isinstance(result, str):
return result # Возвращает "ERROR", "TARIFF_NOT_FOUND" или "INSUFFICIENT_FUNDS"
# 1. Проверка активной подписки
if await self.get_active_subscription(telegram_id):
return "ACTIVE_SUBSCRIPTION_EXISTS"
user, plan = result
await self.postgres_repo.update_balance(user,-plan['price'])
new_subscription, server = await self._create_subscription_and_add_client(user, plan)
# 2. Получаем план
plan = await self.postgres_repo.get_subscription_plan(plan_name)
if not plan:
return "TARIFF_NOT_FOUND"
if not new_subscription:
# 3. Проверяем пользователя
user = await self.get_user_by_telegram_id(telegram_id)
if not user:
return "USER_NOT_FOUND"
# 4. Проверяем баланс (только для информации)
balance_result = await self.billing_adapter.get_balance(telegram_id)
if balance_result["status"] == "error":
return "BILLING_SERVICE_ERROR"
if balance_result["balance"] < plan.price:
return "INSUFFICIENT_FUNDS"
# 5. СОЗДАЕМ ПОДПИСКУ (самое важное - сначала!)
new_subscription = await self._create_subscription_and_add_client(user, plan)
if not new_subscription:
return "SUBSCRIPTION_CREATION_FAILED"
# 6. ТОЛЬКО ПОСЛЕ УСПЕШНОГО СОЗДАНИЯ ПОДПИСКИ - списываем деньги
withdraw_result = await self.billing_adapter.withdraw_funds(
telegram_id,
float(plan.price),
f"Оплата подписки {plan_name}"
)
if withdraw_result["status"] == "error":
await self.postgres_repo.delete_subscription(new_subscription.id)
self.logger.error(f"Payment failed but subscription created: {new_subscription.id}")
return "PAYMENT_FAILED_AFTER_SUBSCRIPTION"
# 7. ВСЕ УСПЕШНО
self.logger.info(f"Подписка успешно создана и оплачена: {new_subscription.id}")
return {"status": "OK", "subscription_id": str(new_subscription.id)}
except Exception as e:
self.logger.error(f"Ошибка в buy_sub: {str(e)}")
return "ERROR"
self.logger.info(f"Подписка успешно оформлена для пользователя {telegram_id}.")
return "OK"
async def _create_subscription_and_add_client(self, user: User, plan: Plan):
"""Создаёт подписку и добавляет клиента на сервер."""
try:
self.logger.info(f"Создание подписки для user_id={user.telegram_id}, plan={plan.name}")
async def _initialize_user_and_plan(self, telegram_id, plan_id):
"""
Инициализирует пользователя и план подписки.
"""
user = await self.get_user_by_telegram_id(telegram_id)
if not user:
self.logger.error(f"Пользователь с Telegram ID {telegram_id} не найден.")
return "ERROR"
# Проверяем типы объектов
self.logger.info(f"Тип user: {type(user)}, тип plan: {type(plan)}")
plan = await self.mongo_repo.get_subscription_plan(plan_id)
if not plan:
self.logger.error(f"Тарифный план {plan_id} не найден.")
return "TARIFF_NOT_FOUND"
expiry_date = datetime.utcnow() + relativedelta(days=plan.duration_days)
cost = int(plan["price"])
if user.balance < cost:
self.logger.error(f"Недостаточно средств у пользователя {telegram_id} для покупки плана {plan_id}.")
return "INSUFFICIENT_FUNDS"
new_subscription = Subscription(
user_id=user.telegram_id,
vpn_server_id="BASE SERVER NEED TO UPDATE",
plan_id=plan.id,
end_date=expiry_date,
start_date=datetime.utcnow()
)
return user, plan
self.logger.info(f"Создан объект подписки: {new_subscription}")
async def _create_subscription_and_add_client(self, user, plan):
"""
Создаёт подписку и добавляет клиента на сервер.
"""
expiry_date = datetime.utcnow() + relativedelta(months=plan["duration_months"])
server = await self.mongo_repo.get_server_with_least_clients()
if not server:
self.logger.error("Нет доступных серверов для подписки.")
return None, None
response = await self.marzban_service.create_user(user, new_subscription)
self.logger.info(f"Ответ от Marzban: {response}")
new_subscription = Subscription(
user_id=user.id,
vpn_server_id=str(server["server"]["name"]),
plan=plan["name"],
expiry_date=expiry_date,
)
if response == "USER_ALREADY_EXISTS":
response = await self.marzban_service.get_user_status(user)
result = await self.marzban_service.update_user(user, new_subscription)
panel = PanelInteraction(
base_url=f"https://{server['server']['ip']}:{server['server']['port']}/{server['server']['secretKey']}",
login_data={"username": server["server"]["login"], "password": server["server"]["password"]},
logger=self.logger,
certificate=server["server"]["certificate"]["data"],
)
# if not isinstance(response,MarzbanUser) or not isinstance(result,MarzbanUser):
# self.logger.error(f"Ошибка при добавлении клиента: {response}, {result}")
# return None
response = await panel.add_client(
inbound_id=1,
expiry_date=expiry_date.isoformat(),
email=user.username,
)
if response != "OK":
self.logger.error(f"Ошибка при добавлении клиента: {response}")
return None, None
await self.postgres_repo.add_record(new_subscription)
await self.postgres_repo.add_record(new_subscription)
self.logger.info(f"Подписка сохранена в БД с ID: {new_subscription.id}")
return new_subscription
return new_subscription, server
except Exception as e:
self.logger.error(f"Неожиданная ошибка в _create_subscription_and_add_client: {str(e)}")
import traceback
self.logger.error(f"Трассировка: {traceback.format_exc()}")
return None
async def generate_uri(self, telegram_id: str):
async def generate_uri(self, telegram_id: int):
"""
Генерация URI для пользователя.
@@ -187,121 +223,23 @@ class DatabaseManager:
:return: Строка URI или None в случае ошибки.
"""
try:
# Извлечение данных
subscription = await self.postgres_repo.get_active_subscription(telegram_id)
if not subscription:
self.logger.error(f"Подписки для пользователя {telegram_id} не найдены.")
return "SUB_ERROR"
user = await self.get_user_by_telegram_id(telegram_id)
if user == False or user == None:
self.logger.error(f"Ошибка при получении клиента: user = {user}")
return "ERROR"
server = await self.mongo_repo.get_server(subscription.vpn_server_id)
if not server:
self.logger.error(f"Сервер с ID {subscription.vpn_server_id} не найден в MongoDB.")
return None
result = await self.marzban_service.get_config_links(user)
if result == None:
self.logger.error(f"Ошибка при получении ссылки клиента: result = {user}")
return "ERROR"
user = await self.postgres_repo.get_user_by_telegram_id(telegram_id)
if not user:
self.logger.error(f"Пользователь с telegram_id {telegram_id} не найден.")
return None
self.logger.info(f"Итог generate_uri: result = {result}")
email = user.username # Используем email из данных пользователя
panel = PanelInteraction(
base_url=f"https://{server['server']['ip']}:{server['server']['port']}/{server['server']['secretKey']}",
login_data={"username": server["server"]["login"], "password": server["server"]["password"]},
logger=self.logger,
certificate=server["server"]["certificate"]["data"],
)
inbound_info = await panel.get_inbound_info(inbound_id=1) # Используем фиксированный ID
if not inbound_info:
self.logger.error(f"Не удалось получить информацию об инбаунде для ID {subscription.vpn_server_id}.")
return None
# Логируем полученные данные
self.logger.info(f"Inbound Info: {inbound_info}")
# Разбор JSON-строк
try:
stream_settings = json.loads(inbound_info["obj"]["streamSettings"])
except KeyError as e:
self.logger.error(f"Ключ 'streamSettings' отсутствует: {e}")
return None
except json.JSONDecodeError as e:
self.logger.error(f"Ошибка разбора JSON для 'streamSettings': {e}")
return None
settings = json.loads(inbound_info["obj"]["settings"]) # Разбираем JSON
# Находим клиента по email
client = next((c for c in settings["clients"] if c["email"] == email), None)
if not client:
self.logger.error(f"Клиент с email {email} не найден среди клиентов.")
return None
server_info = server["server"]
# Преобразование данных в формат URI
uri = (
f"vless://{client['id']}@{server_info['ip']}:443?"
f"type={stream_settings['network']}&security={stream_settings['security']}"
f"&pbk={stream_settings['realitySettings']['settings']['publicKey']}"
f"&fp={stream_settings['realitySettings']['settings']['fingerprint']}"
f"&sni={stream_settings['realitySettings']['serverNames'][0]}"
f"&sid={stream_settings['realitySettings']['shortIds'][0]}"
f"&spx=%2F&flow={client['flow']}"
f"#{inbound_info['obj']['remark']}-{client['email']}"
)
self.logger.info(f"Сформирован URI для пользователя {telegram_id}: {uri}")
return uri
return result
except Exception as e:
self.logger.error(f"Ошибка при генерации URI для пользователя {telegram_id}: {e}")
return None
async def get_ticket(self,ticket_id: int):
"""
Ищет тикет по айди
"""
return await self.postgres_repo.get_ticket(ticket_id)
async def create_ticket(self, user_id: UUID, subject: str, message: str):
"""
Создаёт тикет
"""
ticket = SupportTicket(user_id=user_id,subject=subject,message=message)
return await self.postgres_repo.add_record(ticket)
async def add_message_to_ticket(self,ticket_id : int,sender: str,message: str):
"""
Добавляет сообщения к тикету
"""
message = TicketMessage(ticket_id=ticket_id, sender=sender, message=message)
result = await self.postgres_repo.add_record(message)
if result == None:
self.logger.error(f"Неожиданная ошибка в generate_uri: {str(e)}")
return "ERROR"
return "OK"
async def get_ticket_messages(self,ticket_id: int):
"""
Получает сообщения тикета
"""
return await self.postgres_repo.get_ticket_messages(ticket_id)
async def update_ticket_status(self, ticket_id: int, new_status: str):
"""
Обновляет статус тикета.
Args:
ticket_id (int): ID тикета, статус которого нужно обновить.
new_status (str): Новый статус тикета.
Returns:
dict: Словарь с ID тикета и обновлённым статусом.
Raises:
ValueError: Если тикет не найден.
"""
return await self.postgres_repo.set_new_status(ticket_id,new_status)
@staticmethod
def generate_string(length):
@@ -309,3 +247,12 @@ class DatabaseManager:
Генерирует случайную строку заданной длины.
"""
return ''.join(random.choices(string.ascii_lowercase + string.digits, k=length))
@staticmethod
def _is_subscription_expired(expire_timestamp: int) -> bool:
"""Проверяет, истекла ли подписка"""
current_time = datetime.now(timezone.utc)
expire_time = datetime.fromtimestamp(expire_timestamp, tz=timezone.utc)
return expire_time < current_time

288
app/services/marzban.py Normal file
View File

@@ -0,0 +1,288 @@
from typing import Any, Dict, Optional, Literal
import aiohttp
import requests
from datetime import date, datetime, time, timezone
import logging
from instance import User, Subscription
class UserAlreadyExistsError(Exception):
"""Пользователь уже существует в системе"""
pass
class MarzbanUser:
"""Модель пользователя Marzban"""
def __init__(self, data: Dict[str, Any]):
self.username = data.get('username')
self.status = data.get('status')
self.expire = data.get('expire')
self.data_limit = data.get('data_limit')
self.data_limit_reset_strategy = data.get('data_limit_reset_strategy')
self.used_traffic = data.get('used_traffic')
self.lifetime_used_traffic = data.get('lifetime_used_traffic')
self.subscription_url = data.get('subscription_url')
self.online_at = data.get('online_at')
self.created_at = data.get('created_at')
self.proxies = data.get('proxies', {})
self.inbounds = data.get('inbounds', {})
self.note = data.get('note')
class UserStatus:
"""Статус пользователя"""
def __init__(self, data: Dict[str, Any]):
self.used_traffic = data.get('used_traffic', 0)
self.lifetime_used_traffic = data.get('lifetime_used_traffic', 0)
self.online_at = data.get('online_at')
self.status = data.get('status')
self.expire = data.get('expire')
self.data_limit = data.get('data_limit')
class MarzbanService:
def __init__(self, baseURL: str, username: str, password: str) -> None:
self.base_url = baseURL.rstrip('/')
self.token = self._get_token(username, password)
self.headers = {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json"
}
self._session: Optional[aiohttp.ClientSession] = None
def _get_token(self, username: str, password: str) -> str:
"""Получение токена авторизации"""
try:
response = requests.post(
f"{self.base_url}/api/admin/token",
data={'username': username, 'password': password}
)
response.raise_for_status()
return response.json()['access_token']
except requests.RequestException as e:
logging.error(f"Failed to get token: {e}")
raise Exception(f"Authentication failed: {e}")
async def _get_session(self) -> aiohttp.ClientSession:
"""Ленивое создание сессии"""
if self._session is None or self._session.closed:
timeout = aiohttp.ClientTimeout(total=30)
self._session = aiohttp.ClientSession(timeout=timeout)
return self._session
async def _make_request(self, endpoint: str, method: Literal["get", "post", "put", "delete"],
data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Улучшенный метод для запросов"""
url = f"{self.base_url}{endpoint}"
try:
session = await self._get_session()
async with session.request(
method=method.upper(),
url=url,
headers=self.headers,
json=data
) as response:
response_data = await response.json() if response.content_length else {}
if response.status == 409:
raise UserAlreadyExistsError(f"User already exists: {response_data}")
if response.status not in (200, 201):
raise Exception(f"HTTP {response.status}: {response_data}")
return response_data
except UserAlreadyExistsError:
raise # Пробрасываем наверх
except aiohttp.ClientError as e:
logging.error(f"Network error during {method.upper()} to {url}: {e}")
raise
except Exception as e:
logging.error(f"Unexpected error during request to {url}: {e}")
raise
async def create_user(self, user: User, subscription: Subscription) -> str | MarzbanUser:
"""Создает нового пользователя в Marzban"""
logging.info(f"Конец подписки пользователя {user.telegram_id} {subscription.end_date}")
if subscription.end_date:
if isinstance(subscription.end_date, datetime):
if subscription.end_date.tzinfo is None:
end_date = subscription.end_date.replace(tzinfo=timezone.utc)
else:
end_date = subscription.end_date
expire_timestamp = int(end_date.timestamp())
elif isinstance(subscription.end_date, date):
end_datetime = datetime.combine(
subscription.end_date,
time(23, 59, 59),
tzinfo=timezone.utc
)
expire_timestamp = int(end_datetime.timestamp())
else:
expire_timestamp = 0
else:
expire_timestamp = 0
data = {
"username": user.username,
"status": "active",
"expire": expire_timestamp,
"data_limit": 100 * 1073741824, # Конвертируем GB в bytes
"data_limit_reset_strategy": "no_reset",
"proxies": {
"trojan": {}
},
"inbounds": {
"trojan": ["TROJAN WS NOTLS"]
},
"note": f"Telegram: {user.telegram_id}",
"on_hold_timeout": None,
"on_hold_expire_duration": 0,
"next_plan": {
"add_remaining_traffic": False,
"data_limit": 0,
"expire": 0,
"fire_on_either": True
}
}
try:
response_data = await self._make_request("/api/user", "post", data)
marzban_user = MarzbanUser(response_data)
logging.info(f"Пользователь {user.username} успешно создан в Marzban")
return marzban_user
except UserAlreadyExistsError:
logging.warning(f"Пользователь {user.telegram_id} уже существует в Marzban")
return "USER_ALREADY_EXISTS"
except Exception as e:
logging.error(f"Failed to create user {user.username}: {e}")
raise Exception(f"Failed to create user: {e}")
async def update_user(self, user: User, subscription: Subscription) -> MarzbanUser:
"""Обновляет существующего пользователя"""
username = user.username
if subscription.end_date:
if isinstance(subscription.end_date, datetime):
# Если это datetime, преобразуем в timestamp
if subscription.end_date.tzinfo is None:
end_date = subscription.end_date.replace(tzinfo=timezone.utc)
else:
end_date = subscription.end_date
expire_timestamp = int(end_date.timestamp())
elif isinstance(subscription.end_date, date):
# Если это date, создаем datetime на конец дня и преобразуем в timestamp
end_datetime = datetime.combine(
subscription.end_date,
time(23, 59, 59),
tzinfo=timezone.utc
)
expire_timestamp = int(end_datetime.timestamp())
else:
expire_timestamp = 0
else:
expire_timestamp = 0
data = {
"status": "active",
"expire": expire_timestamp
}
try:
response_data = await self._make_request(f"/api/user/{username}", "put", data)
marzban_user = MarzbanUser(response_data)
logging.info(f"User {username} updated successfully")
return marzban_user
except Exception as e:
logging.error(f"Failed to update user {username}: {e}")
raise Exception(f"Failed to update user: {e}")
async def disable_user(self, user: User) -> bool:
"""Отключает пользователя"""
username = user.username
data = {
"status": "disabled"
}
try:
await self._make_request(f"/api/user/{username}", "put", data)
logging.info(f"User {username} disabled successfully")
return True
except Exception as e:
logging.error(f"Failed to disable user {username}: {e}")
return False
async def enable_user(self, user: User) -> bool:
"""Включает пользователя"""
username = user.username
data = {
"status": "active"
}
try:
await self._make_request(f"/api/user/{username}", "put", data)
logging.info(f"User {username} enabled successfully")
return True
except Exception as e:
logging.error(f"Failed to enable user {username}: {e}")
return False
async def delete_user(self, user: User) -> bool:
"""Полностью удаляет пользователя из Marzban"""
try:
await self._make_request(f"/api/user/{user.username}", "delete")
logging.info(f"User {user.username} deleted successfully")
return True
except Exception as e:
logging.error(f"Failed to delete user {user.username}: {e}")
return False
async def get_user_status(self, user: User) -> UserStatus:
"""Получает текущий статус пользователя"""
try:
response_data = await self._make_request(f"/api/user/{user.username}", "get")
return UserStatus(response_data)
except Exception as e:
logging.error(f"Failed to get status for user {user.username}: {e}")
raise Exception(f"Failed to get user status: {e}")
async def get_subscription_url(self, user: User) -> str | None:
"""Возвращает готовую subscription_url для подключения"""
try:
response_data = await self._make_request(f"/api/user/{user.username}", "get")
return response_data.get('subscription_url', '')
except Exception as e:
logging.error(f"Failed to get subscription URL for user {user.username}: {e}")
return None
async def get_config_links(self, user: User) -> str:
"""Возвращает конфигурации для подключения"""
username = user.username
try:
response_data = await self._make_request(f"/api/user/{username}", "get")
return response_data.get('links', '')
except Exception as e:
logging.error(f"Failed to get configurations URL's for user {username}: {e}")
return ""
async def check_marzban_health(self) -> bool:
"""Проверяет доступность Marzban API"""
try:
await self._make_request("/api/admin", "get")
return True
except Exception as e:
logging.error(f"Marzban health check failed: {e}")
return False
async def close(self):
"""Закрытие сессии"""
if self._session and not self._session.closed:
await self._session.close()
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.close()

View File

@@ -1,158 +0,0 @@
import os
from motor.motor_asyncio import AsyncIOMotorClient
from pymongo.errors import DuplicateKeyError, NetworkTimeout
import logging
class MongoDBRepository:
def __init__(self):
# Настройки MongoDB из переменных окружения
mongo_uri = os.getenv("MONGO_URL")
database_name = os.getenv("DB_NAME")
server_collection = os.getenv("SERVER_COLLECTION", "servers")
plan_collection = os.getenv("PLAN_COLLECTION", "plans")
# Подключение к базе данных и коллекциям
self.client = AsyncIOMotorClient(mongo_uri)
self.db = self.client[database_name]
self.collection = self.db[server_collection] # Коллекция серверов
self.plans_collection = self.db[plan_collection] # Коллекция планов
self.logger = logging.getLogger(__name__)
async def add_subscription_plan(self, plan_data):
"""Добавляет новый тарифный план в коллекцию."""
try:
result = await self.plans_collection.insert_one(plan_data)
self.logger.debug(f"Тарифный план добавлен с ID: {result.inserted_id}")
return result.inserted_id
except DuplicateKeyError:
self.logger.error("Дублирующий ключ.")
except NetworkTimeout:
self.logger.error("Сетевой таймаут.")
async def get_subscription_plan(self, plan_name):
"""Получает тарифный план по его имени."""
try:
plan = await self.plans_collection.find_one({"name": plan_name})
if plan:
self.logger.debug(f"Найден тарифный план: {plan}")
else:
self.logger.error(f"Тарифный план {plan_name} не найден.")
return plan
except DuplicateKeyError:
self.logger.error("Дублирующий ключ.")
except NetworkTimeout:
self.logger.error("Сетевой таймаут.")
async def add_server(self, server_data):
"""Добавляет новый VPN сервер в коллекцию."""
try:
result = await self.collection.insert_one(server_data)
self.logger.debug(f"VPN сервер добавлен с ID: {result.inserted_id}")
return result.inserted_id
except DuplicateKeyError:
self.logger.error("Дублирующий ключ.")
except NetworkTimeout:
self.logger.error("Сетевой таймаут.")
async def get_server(self, server_name: str):
"""Получает сервер VPN по его ID."""
try:
server = await self.collection.find_one({"server.name": server_name})
if server:
self.logger.debug(f"Найден VPN сервер: {server}")
else:
self.logger.debug(f"VPN сервер с ID {server_name} не найден.")
return server
except DuplicateKeyError:
self.logger.error("Дублирующий ключ.")
except NetworkTimeout:
self.logger.error("Сетевой таймаут.")
async def get_server_with_least_clients(self):
"""Возвращает сервер с наименьшим количеством подключенных клиентов."""
try:
pipeline = [
{
"$addFields": {
"current_clients": {"$size": {"$ifNull": ["$clients", []]}}
}
},
{
"$sort": {"current_clients": 1}
},
{
"$limit": 1
}
]
result = await self.collection.aggregate(pipeline).to_list(length=1)
if result:
server = result[0]
self.logger.debug(f"Найден сервер с наименьшим количеством клиентов: {server}")
return server
else:
self.logger.debug("Не найдено серверов.")
return None
except DuplicateKeyError:
self.logger.error("Дублирующий ключ.")
except NetworkTimeout:
self.logger.error("Сетевой таймаут.")
async def update_server(self, server_name, update_data):
"""Обновляет данные VPN сервера."""
try:
result = await self.collection.update_one({"server_name": server_name}, {"$set": update_data})
if result.matched_count > 0:
self.logger.debug(f"VPN сервер с ID {server_name} обновлен.")
else:
self.logger.debug(f"VPN сервер с ID {server_name} не найден.")
return result.matched_count > 0
except DuplicateKeyError:
self.logger.error("Дублирующий ключ.")
except NetworkTimeout:
self.logger.error("Сетевой таймаут.")
async def delete_server(self, server_name):
"""Удаляет VPN сервер по его ID."""
try:
result = await self.collection.delete_one({"name": server_name})
if result.deleted_count > 0:
self.logger.debug(f"VPN сервер с ID {server_name} удален.")
else:
self.logger.debug(f"VPN сервер с ID {server_name} не найден.")
return result.deleted_count > 0
except DuplicateKeyError:
self.logger.error("Дублирующий ключ.")
except NetworkTimeout:
self.logger.error("Сетевой таймаут.")
async def list_servers(self):
"""Возвращает список всех VPN серверов."""
try:
servers = await self.collection.find().to_list(length=1000) # Получить до 1000 серверов (можно настроить)
self.logger.debug(f"Найдено {len(servers)} VPN серверов.")
return servers
except DuplicateKeyError:
self.logger.error("Дублирующий ключ.")
except NetworkTimeout:
self.logger.error("Сетевой таймаут.")
async def __aenter__(self):
"""
Метод вызывается при входе в блок with.
"""
self.logger.debug("Контекстный менеджер: подключение открыто.")
return self
async def __aexit__(self, exc_type, exc_value, traceback):
"""
Метод вызывается при выходе из блока with.
"""
await self.close_connection()
if exc_type:
self.logger.error(f"Контекстный менеджер завершён с ошибкой: {exc_value}")
else:
self.logger.debug("Контекстный менеджер: подключение закрыто.")

View File

@@ -1,11 +1,12 @@
from datetime import datetime
from typing import Optional
from uuid import UUID
from sqlalchemy.future import select
from sqlalchemy.exc import SQLAlchemyError
from decimal import Decimal
from sqlalchemy import asc, desc, update
from sqlalchemy.orm import joinedload
from instance.model import TicketMessage, User, Subscription, Transaction,SupportTicket
from instance.model import Referral, User, Subscription, Transaction, Plan
class PostgresRepository:
@@ -13,13 +14,13 @@ class PostgresRepository:
self.session_generator = session_generator
self.logger = logger
async def create_user(self, telegram_id: str, username: str, referrer_id: str):
async def create_user(self, telegram_id: int, username: str, invited_by: Optional[int]= None):
"""
Создаёт нового пользователя в PostgreSQL.
"""
async for session in self.session_generator():
try:
new_user = User(telegram_id=telegram_id, username=username, referrer_id=referrer_id)
new_user = User(telegram_id=telegram_id, username=username, invited_by=invited_by)
session.add(new_user)
await session.commit()
return new_user
@@ -28,7 +29,7 @@ class PostgresRepository:
await session.rollback()
return None
async def get_active_subscription(self, telegram_id: str):
async def get_active_subscription(self, telegram_id: int):
"""
Проверяет наличие активной подписки у пользователя.
"""
@@ -36,17 +37,23 @@ class PostgresRepository:
try:
result = await session.execute(
select(Subscription)
.join(User, Subscription.user_id == User.id)
.where(User.telegram_id == telegram_id, Subscription.expiry_date > datetime.utcnow())
.join(User, Subscription.user_id == User.telegram_id)
.where(User.telegram_id == telegram_id, Subscription.end_date > datetime.utcnow())
)
result= result.scalars().first()
self.logger.info(f"Пользователь с id {telegram_id}, проверен и имеет {result}")
return result
subscription = result.scalars().first()
if subscription:
# Отделяем объект от сессии
session.expunge(subscription)
self.logger.info(f"Пользователь с id {telegram_id}, проверен и имеет подписку ID: {subscription.id}")
else:
self.logger.info(f"Пользователь с id {telegram_id}, проверен и имеет None")
return subscription
except SQLAlchemyError as e:
self.logger.error(f"Ошибка проверки активной подписки для пользователя {telegram_id}: {e}")
return None
async def get_user_by_telegram_id(self, telegram_id: str):
async def get_user_by_telegram_id(self, telegram_id: int):
"""
Возвращает пользователя по Telegram ID.
"""
@@ -61,32 +68,32 @@ class PostgresRepository:
return False
async def update_balance(self, user: User, amount: float):
"""
Обновляет баланс пользователя.
# async def update_balance(self, user: User, amount: float):
# """
# Обновляет баланс пользователя.
:param user: Объект пользователя.
:param amount: Сумма для добавления/вычитания.
:return: True, если успешно, иначе False.
"""
self.logger.info(f"Обновление баланса пользователя: id={user.id}, current_balance={user.balance}, amount={amount}")
async for session in self.session_generator():
try:
user = await session.get(User, user.id) # Загружаем пользователя в той же сессии
if not user:
self.logger.warning(f"Пользователь с ID {user.id} не найден.")
return False
# Приведение amount к Decimal
user.balance += Decimal(amount)
await session.commit()
self.logger.info(f"Баланс пользователя id={user.id} успешно обновлен: new_balance={user.balance}")
return True
except SQLAlchemyError as e:
self.logger.error(f"Ошибка при обновлении баланса пользователя id={user.id}: {e}")
await session.rollback()
return False
# :param user: Объект пользователя.
# :param amount: Сумма для добавления/вычитания.
# :return: True, если успешно, иначе False.
# """
# self.logger.info(f"Обновление баланса пользователя: id={user.telegram_id}, current_balance={user.balance}, amount={amount}")
# async for session in self.session_generator():
# try:
# user = await session.get(User, user.telegram_id) # Загружаем пользователя в той же сессии
# if not user:
# self.logger.warning(f"Пользователь с ID {user.telegram_id} не найден.")
# return False
# # Приведение amount к Decimal
# user.balance += Decimal(amount)
# await session.commit()
# self.logger.info(f"Баланс пользователя id={user.telegram_id} успешно обновлен: new_balance={user.balance}")
# return True
# except SQLAlchemyError as e:
# self.logger.error(f"Ошибка при обновлении баланса пользователя id={user.telegram_id}: {e}")
# await session.rollback()
# return False
async def get_last_transactions(self, user_id: UUID, limit: int = 10):
async def get_last_transactions(self, user_telegram_id: int, limit: int = 10):
"""
Возвращает последние транзакции пользователя.
"""
@@ -94,16 +101,16 @@ class PostgresRepository:
try:
result = await session.execute(
select(Transaction)
.where(Transaction.user_id == user_id)
.where(Transaction.user_id == user_telegram_id)
.order_by(desc(Transaction.created_at))
.limit(limit)
)
return result.scalars().all()
except SQLAlchemyError as e:
self.logger.error(f"Ошибка получения транзакций пользователя {user_id}: {e}")
self.logger.error(f"Ошибка получения транзакций пользователя {user_telegram_id}: {e}")
return None
async def get_last_subscription_by_user_id(self, user_id: UUID, limit: int = 1):
async def get_last_subscription_by_user_id(self, user_telegram_id: int):
"""
Извлекает последнюю подписку пользователя на основании user_id.
@@ -114,16 +121,53 @@ class PostgresRepository:
try:
result = await session.execute(
select(Subscription)
.where(Subscription.user_id == user_id)
.where(Subscription.user_id == user_telegram_id)
.order_by(desc(Subscription.created_at))
.limit(limit)
.options(joinedload(Subscription.plan))
.limit(1)
)
subscriptions = list(result.scalars())
self.logger.info(f"Найдены такие подписки: {subscriptions}")
return subscriptions
subscription = result.scalars().first()
self.logger.info(f"Найдены такие подписки: {subscription}")
if subscription:
session.expunge(subscription)
self.logger.info(f"Найдена подписка ID: {subscription.id} для пользователя {user_telegram_id}")
return subscription
else:
return None
except SQLAlchemyError as e:
self.logger.error(f"Ошибка при получении подписки для пользователя {user_id}: {e}")
self.logger.error(f"Ошибка при получении подписки для пользователя {user_telegram_id}: {e}")
return None
async def delete_subscription(self, subscription_id: UUID) -> bool:
"""
Удаляет подписку по её ID.
:param subscription_id: UUID подписки для удаления
:return: True если удалено успешно, False в случае ошибки
"""
async for session in self.session_generator():
try:
result = await session.execute(
select(Subscription).where(Subscription.id == subscription_id)
)
subscription = result.scalars().first()
if not subscription:
self.logger.warning(f"Подписка с ID {subscription_id} не найдена")
return False
await session.delete(subscription)
await session.commit()
self.logger.info(f"Подписка с ID {subscription_id} успешно удалена")
return True
except SQLAlchemyError as e:
self.logger.error(f"Ошибка при удалении подписки {subscription_id}: {e}")
await session.rollback()
return False
async def add_record(self, record):
"""
@@ -140,119 +184,118 @@ class PostgresRepository:
except SQLAlchemyError as e:
self.logger.error(f"Ошибка при добавлении записи: {record}: {e}")
await session.rollback()
return None
raise Exception
async def list_active_tickets(self, user_id: UUID):
async def add_referral(self, referrer_id: int, referral_id: int):
"""
Добавление реферальной связи между пользователями.
"""
async for session in self.session_generator():
try:
tickets = await session.execute(
select(SupportTicket)
# Проверить, существует ли уже такая реферальная связь
existing_referral = await session.execute(
select(Referral)
.where(
SupportTicket.user_id == user_id,
SupportTicket.status.in_([status.upper() for status in ["pending", "open"]])
)
(Referral.inviter_id == referrer_id) &
(Referral.invited_id == referral_id)
)
)
result = list(tickets.scalars().all())
self.logger.info(f"Получены активные тикеты: {result}")
return result
except SQLAlchemyError as e:
self.logger.error(f"Произошла ошибка при поиске активных тикетов: {e}")
return None
existing_referral = existing_referral.scalars().first()
async def get_ticket(self, ticket_id):
async for session in self.session_generator():
try:
ticket = await session.execute(
select(SupportTicket)
.where(SupportTicket.id == ticket_id)
)
result = ticket.scalars().first()
self.logger.info(f"Получен тикет {ticket_id}.")
if result:
serialized_result = {
"id": result.id,
"user_id": result.user_id,
"subject": result.subject,
"message": result.message,
"status": result.status,
"created_at": result.created_at.isoformat(),
"updated_at": result.updated_at.isoformat(),
}
return serialized_result
except SQLAlchemyError as e:
self.logger.error(f"Произошла ошибка при поиске тикета {ticket_id}.")
return None
if existing_referral:
raise ValueError("Referral relationship already exists")
async def get_ticket_messages(self, ticket_id: int):
async for session in self.session_generator():
try:
# Выполняем запрос для получения сообщений, сортированных по дате
result = await session.execute(
select(TicketMessage)
.where(TicketMessage.ticket_id == ticket_id)
.order_by(asc(TicketMessage.created_at))
)
messages = result.scalars().all()
self.logger.info(f"Получены сообщения для тикета {ticket_id}, {messages}")
self.logger.info(messages)
return messages
except SQLAlchemyError as e:
self.logger.error(f"Ошибка при получении сообщений для тикета {ticket_id}: {e}")
return []
async def set_new_status(self,ticket_id: int, new_status: str):
async for session in self.session_generator():
try:
# Выполняем обновление тикета
result = await session.execute(
update(SupportTicket)
.where(SupportTicket.id == ticket_id)
.values(status=new_status)
.execution_options(synchronize_session="fetch")
)
if result.rowcount == 0:
raise ValueError(f"Тикет с ID {ticket_id} не найден.")
await session.commit()
self.logger.info(f"Статус тикета {ticket_id} обновлён на '{new_status}'.")
return "OK"
except SQLAlchemyError as e:
self.logger.error(f"Ошибка обновления статуса тикета {ticket_id}: {e}")
await session.rollback()
return "ERROR"
async def add_referal(self,referrer_id: str, referral_id:str):
"""
Добавление рефералу пользователей
"""
async for session in self.session_generator():
try:
# Проверить, что пользователи существуют
referrer = await session.execute(
select(User)
.where(User.id == referrer_id)
.options(joinedload(User.referrals)) # Загрузка связанных объектов
select(User).where(User.telegram_id == referrer_id)
)
referrer = referrer.scalars().first()
if not referrer:
raise ValueError("Referrer not found")
# Проверить, существует ли уже такой реферал
existing_referrals = [ref.id for ref in referrer.referrals]
if referrer_id in existing_referrals:
raise ValueError("Referral already exists")
# Найти реферала
referral = await session.execute(
select(User).where(User.id == referral_id)
referral_user = await session.execute(
select(User).where(User.telegram_id == referral_id)
)
referral = referral.scalars().first()
referral_user = referral_user.scalars().first()
if not referral:
if not referral_user:
raise ValueError("Referral user not found")
# Добавить реферала в список
referrer.referrals.append(referral)
await session.commit()
except Exception as e:
self.logger(f"Ошибка при добавлении рефералу пользователей")
# Проверить, что пользователь не приглашает сам себя
if referrer_id == referral_id:
raise ValueError("User cannot refer themselves")
# Создать новую реферальную связь
new_referral = Referral(
inviter_id=referrer_id,
invited_id=referral_id
)
session.add(new_referral)
await session.commit()
self.logger.info(f"Реферальная связь создана: {referrer_id} -> {referral_id}")
except Exception as e:
await session.rollback()
self.logger.error(f"Ошибка при добавлении реферальной связи: {str(e)}")
raise
async def get_subscription_plan(self, plan_name:str) -> Plan | None:
"""
Поиск плана для подписки
:param plan_name: Объект записи.
:return: Запись или None в случае ошибки.
"""
async for session in self.session_generator():
try:
result = await session.execute(
select(Plan)
.where(Plan.name == plan_name)
)
return result.scalar_one_or_none()
except SQLAlchemyError as e:
self.logger.error(f"Ошибка при поиске плана: {plan_name}: {e}")
await session.rollback()
return None
async def get_plan_by_id(self, plan_id: int) -> Plan | None:
"""
Поиск плана для подписки
:param plan_name: Объект записи.
:return: Запись или None в случае ошибки.
"""
async for session in self.session_generator():
try:
result = await session.execute(
select(Plan)
.where(Plan.id == plan_id)
)
return result.scalar_one_or_none()
except SQLAlchemyError as e:
self.logger.error(f"Ошибка при поиске плана: {plan_id}: {e}")
await session.rollback()
return None
async def get_referrals_count(self, user_telegram_id: int) -> int:
"""
Получить количество рефералов пользователя.
:param user_telegram_id: Telegram ID пользователя-пригласителя
:return: Количество рефералов
"""
async for session in self.session_generator():
try:
result = await session.execute(
select(Referral)
.where(Referral.inviter_id == user_telegram_id)
)
referrals = result.scalars().all()
return len(referrals)
except SQLAlchemyError as e:
self.logger.error(f"Ошибка при получении количества рефералов для пользователя {user_telegram_id}: {e}")
return 0

View File

@@ -1,232 +0,0 @@
import aiohttp
import uuid
import json
import base64
import ssl
def generate_uuid():
return str(uuid.uuid4())
class PanelInteraction:
def __init__(self, base_url, login_data, logger, certificate=None, is_encoded=True):
self.base_url = base_url
self.login_data = login_data
self.logger = logger
self.ssl_context = self._create_ssl_context(certificate, is_encoded)
self.session_id = None
self.headers = None
def _create_ssl_context(self, certificate, is_encoded):
if not certificate:
raise ValueError("Certificate is required.")
try:
ssl_context = ssl.create_default_context()
if is_encoded:
certificate = base64.b64decode(certificate).decode()
ssl_context.load_verify_locations(cadata=certificate)
return ssl_context
except Exception as e:
self.logger.error(f"Error creating SSL context: {e}")
raise ValueError("Invalid certificate format.") from e
async def _ensure_logged_in(self):
if not self.session_id:
try:
self.session_id = await self.login()
if self.session_id:
self.headers = {
'Accept': 'application/json',
'Cookie': f'3x-ui={self.session_id}',
'Content-Type': 'application/json'
}
else:
self.logger.error("Login failed: Unable to retrieve session ID.")
raise ValueError("Login failed: No session ID.")
except Exception as e:
self.logger.exception("Unexpected error during login.")
raise
async def login(self):
login_url = f"{self.base_url}/login"
self.logger.info(f"Attempting to login at: {login_url}")
async with aiohttp.ClientSession() as session:
try:
async with session.post(
login_url, data=self.login_data, ssl=self.ssl_context, timeout=10
) as response:
if response.status == 200:
session_id = response.cookies.get("3x-ui")
if session_id:
return session_id.value
else:
self.logger.error("Login failed: No session ID received.")
else:
error_details = await response.text()
self.logger.error(f"Login failed with status {response.status}: {error_details}")
except aiohttp.ClientError as e:
self.logger.exception(f"Login request failed: {e}")
raise
async def get_inbound_info(self, inbound_id: int = 1):
"""
Fetch inbound information by ID.
:param inbound_id: ID of the inbound.
:return: JSON response or None.
"""
await self._ensure_logged_in()
url = f"{self.base_url}/panel/api/inbounds/get/{inbound_id}"
async with aiohttp.ClientSession() as session:
try:
async with session.get(
url, headers=self.headers, ssl=self.ssl_context, timeout=10
) as response:
response_text = await response.text() # Получаем текст ответа
self.logger.info(f"Inbound Info (raw): {response_text}") # Логируем сырой текст
if response.status == 200:
return await response.json()
else:
self.logger.error(f"Failed to get inbound info: {response.status}")
return None
except aiohttp.ClientError as e:
self.logger.error(f"Get inbound info request failed: {e}")
return None
async def get_client_traffic(self, email):
"""
Fetch traffic information for a specific client.
:param email: Client's email.
:return: JSON response or None.
"""
await self._ensure_logged_in()
url = f"{self.base_url}/panel/api/inbounds/getClientTraffics/{email}"
async with aiohttp.ClientSession() as session:
try:
async with session.get(
url, headers=self.headers, ssl=self.ssl_context, timeout=10
) as response:
if response.status == 200:
return await response.json()
else:
self.logger.error(f"Failed to get client traffic: {response.status}")
return None
except aiohttp.ClientError as e:
self.logger.error(f"Get client traffic request failed: {e}")
return None
async def update_client_expiry(self, client_uuid, new_expiry_time, client_email):
"""
Update the expiry date of a specific client.
:param client_uuid: UUID of the client.
:param new_expiry_time: New expiry date in ISO format.
:param client_email: Client's email.
:return: None.
"""
await self._ensure_logged_in()
url = f"{self.base_url}/panel/api/inbounds/updateClient"
update_data = {
"id": 1,
"settings": {
"clients": [
{
"id": client_uuid,
"alterId": 0,
"email": client_email,
"limitIp": 2,
"totalGB": 0,
"expiryTime": new_expiry_time,
"enable": True,
"tgId": "",
"subId": ""
}
]
}
}
async with aiohttp.ClientSession() as session:
try:
async with session.post(
url, headers=self.headers, json=update_data, ssl=self.ssl_context
) as response:
if response.status == 200:
self.logger.info("Client expiry updated successfully.")
else:
self.logger.error(f"Failed to update client expiry: {response.status}")
except aiohttp.ClientError as e:
self.logger.error(f"Update client expiry request failed: {e}")
async def add_client(self, inbound_id, expiry_date, email):
"""
Add a new client to an inbound.
:param inbound_id: ID of the inbound.
:param expiry_date: Expiry date in ISO format.
:param email: Client's email.
:return: JSON response or None.
"""
await self._ensure_logged_in()
url = f"{self.base_url}/panel/api/inbounds/addClient"
client_info = {
"id": generate_uuid(),
"flow": "xtls-rprx-vision",
"email": email,
"limitIp": 2,
"totalGB": 0,
"expiryTime": expiry_date,
"enable": True,
"tgId": "",
"subId": "",
"reset": 0
}
settings = json.dumps({"clients": [client_info]}) # Преобразуем объект в JSON-строку
payload = {
"id": int(inbound_id), # Преобразуем inbound_id в число
"settings": settings # Передаем settings как JSON-строку
}
async with aiohttp.ClientSession() as session:
try:
async with session.post(
url, headers=self.headers, json=payload, ssl=self.ssl_context
) as response:
response_json = await response.json()
if response.status == 200 and response_json.get('success'):
self.logger.info(f"Клиент успешно добавлен: {response_json}")
return "OK"
else:
error_msg = response_json.get('msg', 'Причина не указана')
self.logger.error(f"Не удалось добавить клиента: {error_msg}")
return None
except aiohttp.ClientError as e:
self.logger.error(f"Add client request failed: {e}")
return None
async def delete_depleted_clients(self, inbound_id=None):
"""
Удалить исчерпанных клиентов.
:param inbound_id: ID входящего соединения (inbound), если None, удаляет для всех.
:return: Ответ сервера или None в случае ошибки.
"""
await self._ensure_logged_in()
url = f"{self.base_url}/panel/api/inbounds/delDepletedClients/{inbound_id or ''}".rstrip('/')
async with aiohttp.ClientSession() as session:
try:
async with session.post(url, headers=self.headers, ssl=self.ssl_context, timeout=10) as response:
if response.status == 200:
self.logger.info(f"Depleted clients deleted successfully for inbound_id: {inbound_id}")
return await response.json()
else:
error_details = await response.text()
self.logger.error(f"Failed to delete depleted clients: {response.status} - {error_details}")
return None
except aiohttp.ClientError as e:
self.logger.error(f"Delete depleted clients request failed: {e}")
return None

9
instance/__init__.py Normal file
View File

@@ -0,0 +1,9 @@
from .model import User,Transaction,Subscription,SubscriptionStatus,Referral,Plan,TransactionType
from .config import setup_logging
from .configdb import get_postgres_session,init_postgresql,close_connections
__all__ = ['User','Transaction',
'SubscriptionStatus','Subscription',
'Referral','Plan','get_postgres_session',
'setup_logging','init_postgresql',
'close_connections','TransactionType']

View File

@@ -19,7 +19,7 @@ def setup_logging():
logging.basicConfig(
level=logging.INFO,
handlers=[console_handler],
force=True # Перезаписать существующие настройки
force=True
)
# Установка уровня для конкретных логгеров

View File

@@ -1,25 +1,24 @@
import os
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from motor.motor_asyncio import AsyncIOMotorClient
from app.services.db_manager import DatabaseManager
from .model import Base
try:
# Настройки PostgreSQL из переменных окружения
POSTGRES_DSN = os.getenv("POSTGRES_URL")
POSTGRES_DSN = os.getenv("POSTGRES_URL")
BASE_URL_MARZBAN = os.getenv("BASE_URL_MARZBAN")
USERNAME_MARZBA = os.getenv('USERNAME_MARZBAN')
PASSWORD_MARZBAN = os.getenv('PASSWORD_MARZBAN')
BILLING_URL = os.getenv('BILLING_URL')
# Создание движка для PostgreSQL
postgres_engine = create_async_engine(POSTGRES_DSN, echo=False)
if POSTGRES_DSN is None or BASE_URL_MARZBAN is None or USERNAME_MARZBA is None or PASSWORD_MARZBAN is None or BILLING_URL is None:
raise Exception
postgres_engine = create_async_engine(POSTGRES_DSN, echo=False)
except Exception as e:
print("Ошибки при инициализации сессии постгреса")
AsyncSessionLocal = sessionmaker(bind=postgres_engine, class_=AsyncSession, expire_on_commit=False)
# Настройки MongoDB из переменных окружения
MONGO_URI = os.getenv("MONGO_URL")
DATABASE_NAME = os.getenv("DB_NAME")
# Создание клиента MongoDB
mongo_client = AsyncIOMotorClient(MONGO_URI)
mongo_db = mongo_client[DATABASE_NAME]
# Инициализация PostgreSQL
async def init_postgresql():
"""
@@ -32,18 +31,6 @@ async def init_postgresql():
except Exception as e:
print(f"Failed to connect to PostgreSQL: {e}")
# Инициализация MongoDB
async def init_mongodb():
"""
Проверка подключения к MongoDB.
"""
try:
# Проверяем подключение к MongoDB
await mongo_client.admin.command("ping")
print("MongoDB connected.")
except Exception as e:
print(f"Failed to connect to MongoDB: {e}")
# Получение сессии PostgreSQL
async def get_postgres_session():
"""
@@ -61,12 +48,9 @@ async def close_connections():
await postgres_engine.dispose()
print("PostgreSQL connection closed.")
# Закрытие MongoDB
mongo_client.close()
print("MongoDB connection closed.")
def get_database_manager() -> DatabaseManager:
"""
Функция-зависимость для получения экземпляра DatabaseManager.
"""
return DatabaseManager(get_postgres_session)
return DatabaseManager(get_postgres_session, USERNAME_MARZBA,PASSWORD_MARZBAN,BASE_URL_MARZBAN,BILLING_URL)

View File

@@ -1,94 +1,100 @@
from sqlalchemy import Column, String, Numeric, DateTime, Boolean, ForeignKey, Integer, Enum, Text
from sqlalchemy import Column, String, Numeric, DateTime, ForeignKey, Integer, Enum, Text, BigInteger
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import declarative_base, relationship, sessionmaker
from sqlalchemy.orm import declarative_base, relationship
from datetime import datetime
from enum import Enum as PyEnum
import uuid
Base = declarative_base()
def generate_uuid():
return str(uuid.uuid4())
class SubscriptionStatus(PyEnum):
ACTIVE = "active"
EXPIRED = "expired"
CANCELLED = "cancelled"
class TicketStatus(PyEnum):
OPEN = "open"
class TransactionStatus(PyEnum):
PENDING = "pending"
CLOSED = "closed"
SUCCESS = "success"
FAILED = "failed"
class TransactionType(PyEnum):
DEPOSIT = "deposit"
WITHDRAWAL = "withdrawal"
PAYMENT = "payment"
"""Пользователи"""
# Пользователи
class User(Base):
__tablename__ = 'users'
id = Column(UUID(as_uuid=True), primary_key=True, default=generate_uuid)
telegram_id = Column(String, unique=True, nullable=False) # telegram_id как уникальный идентификатор
username = Column(String)
telegram_id = Column(BigInteger, primary_key=True)
username = Column(String(255))
balance = Column(Numeric(10, 2), default=0.0)
referrer_id = Column(String, ForeignKey('users.telegram_id'), nullable=True) # Ссылка на telegram_id
ref_code = Column(String(7), unique=True) # Реферальный код пользователя
invited_by = Column(BigInteger, ForeignKey('users.telegram_id'), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
referrals = relationship("User", backref="referrer", remote_side=[telegram_id]) # Ссылка на telegram_id
# Relationships
subscriptions = relationship("Subscription", back_populates="user")
transactions = relationship("Transaction", back_populates="user")
admins = relationship("Administrators", back_populates="user")
sent_referrals = relationship("Referral",
foreign_keys="Referral.inviter_id",
back_populates="inviter")
received_referrals = relationship("Referral",
foreign_keys="Referral.invited_id",
back_populates="invited")
# Реферальные связи
class Referral(Base):
__tablename__ = 'referrals'
"""Подписки"""
id = Column(Integer, primary_key=True)
inviter_id = Column(BigInteger, ForeignKey('users.telegram_id'), nullable=False)
invited_id = Column(BigInteger, ForeignKey('users.telegram_id'), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
inviter = relationship("User", foreign_keys=[inviter_id], back_populates="sent_referrals")
invited = relationship("User", foreign_keys=[invited_id], back_populates="received_referrals")
# Тарифные планы
class Plan(Base):
__tablename__ = 'plans'
id = Column(Integer, primary_key=True)
name = Column(String(100), nullable=False)
price = Column(Numeric(10, 2), nullable=False)
duration_days = Column(Integer, nullable=False)
description = Column(Text)
subscriptions = relationship("Subscription", back_populates="plan")
# Подписки
class Subscription(Base):
__tablename__ = 'subscriptions'
id = Column(String, primary_key=True, default=generate_uuid)
user_id = Column(UUID(as_uuid=True), ForeignKey('users.id'))
vpn_server_id = Column(String)
plan = Column(String)
expiry_date = Column(DateTime)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(BigInteger, ForeignKey('users.telegram_id'), nullable=False)
plan_id = Column(Integer, ForeignKey('plans.id'), nullable=False)
vpn_server_id = Column(String) # ID сервера в Marzban
status = Column(Enum(SubscriptionStatus), default=SubscriptionStatus.ACTIVE)
start_date = Column(DateTime, nullable=False)
end_date = Column(DateTime, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user = relationship("User", back_populates="subscriptions")
plan = relationship("Plan", back_populates="subscriptions")
"""Транзакции"""
# Транзакции
class Transaction(Base):
__tablename__ = 'transactions'
id = Column(String, primary_key=True, default=generate_uuid)
user_id = Column(UUID(as_uuid=True), ForeignKey('users.id'))
amount = Column(Numeric(10, 2))
transaction_type = Column(String)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(BigInteger, ForeignKey('users.telegram_id'), nullable=False)
amount = Column(Numeric(10, 2), nullable=False)
status = Column(Enum(TransactionStatus), default=TransactionStatus.PENDING)
type = Column(Enum(TransactionType), nullable=False)
payment_provider = Column(String(100))
payment_id = Column(String, unique=True) # ID платежа в внешней системе
created_at = Column(DateTime, default=datetime.utcnow)
user = relationship("User", back_populates="transactions")
"""Тикет"""
class SupportTicket(Base):
__tablename__ = "support_tickets"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
subject = Column(String, nullable=False)
message = Column(String, nullable=False)
status = Column(Enum(TicketStatus), default=TicketStatus.OPEN, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
"""Сообщения из тикетов"""
class TicketMessage(Base):
__tablename__ = "ticket_messages"
id = Column(Integer, primary_key=True, index=True)
ticket_id = Column(Integer, ForeignKey("support_tickets.id"), nullable=False) # ID тикета
sender = Column(String, nullable=False) # "user" или "support"
message = Column(Text, nullable=False) # Текст сообщения
created_at = Column(DateTime, default=datetime.utcnow, nullable=False) #
"""Администраторы"""
class Administrators(Base):
__tablename__ = 'admins'
id = Column(String, primary_key=True, default=generate_uuid)
user_id = Column(UUID(as_uuid=True), ForeignKey('users.id'))
user = relationship("User", back_populates="admins")

78
main.py
View File

@@ -1,71 +1,23 @@
import os
import sys
from fastapi import FastAPI
from instance.config import setup_logging
from instance import setup_logging
import logging
setup_logging()
# logging.basicConfig(
# level=logging.INFO,
# format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
# handlers=[logging.StreamHandler(sys.stdout)],
# force=True
# )
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[logging.StreamHandler(sys.stdout)],
force=True
)
from instance.configdb import init_postgresql, init_mongodb, close_connections
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from app.routes import router, subscription_router, sup_router
from app.services.db_manager import DatabaseManager
from instance.configdb import get_postgres_session
from app.services.mongo_rep import MongoDBRepository
from app.services.xui_rep import PanelInteraction
from instance import init_postgresql, close_connections
from app.routes import router, subscription_router
logger = logging.getLogger(__name__)
app = FastAPI()
database_manager = DatabaseManager(session_generator=get_postgres_session)
mongo_repo = MongoDBRepository()
async def delete_depleted_clients_task():
"""
Удаляет исчерпанных клиентов на всех серверах из MongoDB.
"""
try:
# Получаем список серверов из MongoDB
servers = await mongo_repo.list_servers()
if not servers:
logger.warning("Список серверов пуст. Задача пропущена.")
return
# Проходим по каждому серверу и вызываем delete_depleted_clients
for server in servers:
base_url = server.get("base_url")
login_data = server.get("login_data")
certificate = server.get("certificate")
if not base_url or not login_data:
logger.error(f"Пропуск сервера из-за отсутствия данных: {server}")
continue
# Создаём экземпляр PanelInteraction
panel = PanelInteraction(
base_url=base_url,
login_data=login_data,
logger=logger,
certificate=certificate
)
# Выполняем удаление исчерпанных клиентов
response = await panel.delete_depleted_clients()
if response:
logger.info(f"Удаление клиентов завершено успешно для сервера: {base_url}")
else:
logger.warning(f"Не удалось удалить клиентов для сервера: {base_url}")
except Exception as e:
logger.error(f"Ошибка при выполнении задачи delete_depleted_clients: {e}")
scheduler = AsyncIOScheduler()
@app.on_event("startup")
async def startup():
@@ -73,15 +25,9 @@ async def startup():
Инициализация подключения к базам данных.
"""
try:
scheduler.add_job(delete_depleted_clients_task, CronTrigger(hour=23, minute=59))
scheduler.start()
logger.info("Инициализация PostgreSQL...")
await init_postgresql()
logger.info("PostgreSQL успешно инициализирован.")
logger.info("Инициализация MongoDB...")
await init_mongodb()
logger.info("MongoDB успешно инициализирован.")
except Exception as e:
logger.error(f"Ошибка при инициализации баз данных: {e}")
raise RuntimeError("Не удалось инициализировать базы данных")
@@ -101,7 +47,7 @@ async def shutdown():
app.include_router(router, prefix="/api")
#app.include_router(payment_router, prefix="/api")
app.include_router(subscription_router, prefix="/api")
app.include_router(sup_router, prefix="/api")
@app.get("/")
def read_root():
return {"message": "FastAPI приложение работает!"}

View File

@@ -7,6 +7,8 @@ APScheduler==3.11.0
asyncpg==0.30.0
attrs==24.3.0
blinker==1.9.0
certifi==2025.11.12
charset-normalizer==3.4.4
click==8.1.7
dnspython==2.7.0
fastapi==0.115.6
@@ -24,12 +26,14 @@ pydantic==2.10.4
pydantic_core==2.27.2
pymongo==4.9.2
python-dateutil==2.9.0.post0
requests==2.32.5
six==1.17.0
sniffio==1.3.1
SQLAlchemy==2.0.36
starlette==0.41.3
typing_extensions==4.12.2
tzlocal==5.2
urllib3==2.5.0
uvicorn==0.34.0
Werkzeug==3.1.3
yarl==1.18.3

View File

View File

@@ -1,128 +0,0 @@
import argparse
from datetime import datetime
import json
import base64
from pymongo import MongoClient
def connect_to_mongo(uri, db_name):
"""Подключение к MongoDB."""
client = MongoClient(uri)
db = client[db_name]
return db
def load_raw_json(json_path):
"""Загружает сырые JSON-данные из файла."""
with open(json_path, "r", encoding="utf-8") as f:
return json.loads(f.read())
def encode_file(file_path):
"""Читает файл и кодирует его в Base64."""
with open(file_path, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
def transform_data(raw_data):
"""Преобразует исходные сырые данные в целевую структуру."""
try:
settings = json.loads(raw_data["obj"]["settings"])
stream_settings = json.loads(raw_data["obj"]["streamSettings"])
sniffing_settings = json.loads(raw_data["obj"]["sniffing"])
transformed = {
"server": {
"name": raw_data["obj"].get("remark", "Unknown"),
"ip": "45.82.255.110", # Замените на актуальные данные
"port": "2053",
"secretKey": "Hd8OsqN5Jh", # Замените на актуальные данные
"login": "nc1450nP", # Замените на актуальные данные
"password": "KmajQOuf" # Замените на актуальные данные
},
"clients": [
{
"email": client["email"],
"inboundId": raw_data["obj"].get("id"),
"id": client["id"],
"flow": client.get("flow", ""),
"limits": {
"ipLimit": client.get("limitIp", 0),
"reset": client.get("reset", 0),
"totalGB": client.get("totalGB", 0)
},
"subscriptions": {
"subId": client.get("subId", ""),
"tgId": client.get("tgId", "")
}
} for client in settings["clients"]
],
"connection": {
"destination": stream_settings["realitySettings"].get("dest", ""),
"serverNames": stream_settings["realitySettings"].get("serverNames", []),
"security": stream_settings.get("security", ""),
"publicKey": stream_settings["realitySettings"]["settings"].get("publicKey", ""),
"fingerprint": stream_settings["realitySettings"]["settings"].get("fingerprint", ""),
"shortIds": stream_settings["realitySettings"].get("shortIds", []),
"tcpSettings": {
"acceptProxyProtocol": stream_settings["tcpSettings"].get("acceptProxyProtocol", False),
"headerType": stream_settings["tcpSettings"]["header"].get("type", "none")
},
"sniffing": {
"enabled": sniffing_settings.get("enabled", False),
"destOverride": sniffing_settings.get("destOverride", [])
}
}
}
return transformed
except KeyError as e:
raise ValueError(f"Ошибка преобразования данных: отсутствует ключ {e}")
def insert_certificate(data, cert_path, cert_location):
"""Добавляет сертификат в указанное место внутри структуры JSON."""
# Читаем и кодируем сертификат
certificate_data = encode_file(cert_path)
# Разбиваем путь на вложенные ключи
keys = cert_location.split(".")
target = data
for key in keys[:-1]:
if key not in target:
target[key] = {} # Создаем вложенные ключи, если их нет
target = target[key]
target[keys[-1]] = {
"data": certificate_data,
"uploaded_at": datetime.utcnow()
}
def insert_data(db, collection_name, data):
"""Вставляет данные в указанную коллекцию MongoDB."""
collection = db[collection_name]
collection.insert_one(data)
print(f"Данные успешно вставлены в коллекцию '{collection_name}'.")
def main():
parser = argparse.ArgumentParser(description="Insert raw JSON data into MongoDB with certificate")
parser.add_argument("--mongo-uri", default="mongodb://root:itOj4CE2miKR@mongodb:27017", help="MongoDB URI")
parser.add_argument("--db-name", default="MongoDBSub&Ser", help="MongoDB database name")
parser.add_argument("--collection", default="servers", help="Collection name")
parser.add_argument("--json-path", required=True, help="Path to the JSON file with raw data")
parser.add_argument("--cert-path", help="Path to the certificate file (.crt)")
parser.add_argument("--cert-location", default='server.certificate', help="Path inside JSON structure to store certificate (e.g., 'server.certificate')")
args = parser.parse_args()
# Подключение к MongoDB
db = connect_to_mongo(args.mongo_uri, args.db_name)
# Загрузка сырых данных из JSON-файла
raw_data = load_raw_json(args.json_path)
# Преобразование данных в нужную структуру
transformed_data = transform_data(raw_data)
# Вставка сертификата в структуру данных (если путь к сертификату указан)
if args.cert_path:
insert_certificate(transformed_data, args.cert_path, args.cert_location)
# Вставка данных в MongoDB
insert_data(db, args.collection, transformed_data)
if __name__ == "__main__":
main()

View File

@@ -1,74 +0,0 @@
import argparse
from datetime import datetime
import json
import glob
from pymongo import MongoClient
def connect_to_mongo(uri, db_name):
"""Подключение к MongoDB."""
client = MongoClient(uri)
db = client[db_name]
return db
def load_all_json_from_folder(folder_path):
"""Загружает все JSON-файлы из указанной папки."""
all_data = []
for file_path in glob.glob(f"{folder_path}/*.json"):
try:
with open(file_path, "r", encoding="utf-8") as f:
data = json.load(f)
all_data.append(data)
except Exception as e:
print(f"Ошибка при чтении файла {file_path}: {e}")
return all_data
def fetch_all_documents(mongo_uri, db_name, collection_name):
"""Выводит все элементы из указанной коллекции MongoDB."""
try:
client = MongoClient(mongo_uri)
db = client[db_name]
collection = db[collection_name]
documents = collection.find()
print(f"Содержимое коллекции '{collection_name}':")
for doc in documents:
print(doc)
except Exception as e:
print(f"Ошибка при получении данных: {e}")
finally:
client.close()
def insert_data(db, collection_name, data):
"""Вставляет данные в указанную коллекцию MongoDB."""
collection = db[collection_name]
for i in data:
collection.insert_one(i)
print(f"Данные '{i}'")
print(f"Данные успешно вставлены в коллекцию '{collection_name}'.")
def main():
parser = argparse.ArgumentParser(description="Insert JSON data into MongoDB with certificate")
parser.add_argument("--mongo-uri",default="mongodb://root:itOj4CE2miKR@mongodb:27017" ,required=True, help="MongoDB URI")
parser.add_argument("--db-name",default="MongoDBSub&Ser" ,required=True, help="MongoDB database name")
parser.add_argument("--collection",default="plans", required=True, help="Collection name")
parser.add_argument("--json-path", required=True, help="Path to the JSON file with data")
args = parser.parse_args()
db = connect_to_mongo(args.mongo_uri, args.db_name)
data = load_all_json_from_folder(args.json_path)
insert_data(db, args.collection, data)
fetch_all_documents(args.mongo_uri, args.db_name,args.collection)
if __name__ == "__main__":
main()

270
tests/add_plans.py Normal file
View File

@@ -0,0 +1,270 @@
#!/usr/bin/env python3
"""
Автономный скрипт для инициализации тарифных планов в PostgreSQL.
Использует данные подключения из docker-compose.yml
"""
import asyncio
import argparse
import sys
from typing import List, Dict
from sqlalchemy import Column, Integer, String, Numeric, Text, delete, insert
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import declarative_base
from decimal import Decimal
Base = declarative_base()
class Plan(Base):
"""Модель тарифного плана"""
__tablename__ = 'plans'
id = Column(Integer, primary_key=True)
name = Column(String(100), nullable=False)
price = Column(Numeric(10, 2), nullable=False)
duration_days = Column(Integer, nullable=False)
description = Column(Text, nullable=True)
# Данные из вашего docker-compose.yml
DEFAULT_CONFIG = {
'host': 'localhost',
'port': 5432,
'database': 'postgresql',
'user': 'AH3J9GSPBYOP',
'password': 'uPS9?y~mcu2',
'url': 'postgresql+asyncpg://AH3J9GSPBYOP:uPS9?y~mcu2@localhost:5432/postgresql'
}
PLANS_DATA = [
{'name': 'Lark_Standart_1', 'price': Decimal('200.00'), 'duration_days': 30},
{'name': 'Lark_Pro_1', 'price': Decimal('400.00'), 'duration_days': 30},
{'name': 'Lark_Family_1', 'price': Decimal('700.00'), 'duration_days': 30},
{'name': 'Lark_Standart_6', 'price': Decimal('1200.00'), 'duration_days': 180},
{'name': 'Lark_Standart_12', 'price': Decimal('2400.00'), 'duration_days': 360},
{'name': 'Lark_Pro_6', 'price': Decimal('2000.00'), 'duration_days': 180},
{'name': 'Lark_Pro_12', 'price': Decimal('4800.00'), 'duration_days': 360},
{'name': 'Lark_Family_6', 'price': Decimal('4200.00'), 'duration_days': 180},
{'name': 'Lark_Family_12', 'price': Decimal('8400.00'), 'duration_days': 360},
]
def print_banner():
"""Печатает баннер скрипта"""
print("=" * 60)
print("🚀 ИНИЦИАЛИЗАЦИЯ ТАРИФНЫХ ПЛАНОВ В БАЗЕ ДАННЫХ")
print("=" * 60)
print()
def create_db_url(config: dict) -> str:
"""Создает URL для подключения к базе данных"""
if config.get('url'):
return config['url']
return f"postgresql+asyncpg://{config['user']}:{config['password']}@{config['host']}:{config['port']}/{config['database']}"
async def check_connection(engine) -> bool:
"""Проверяет подключение к базе данных"""
try:
async with engine.connect() as conn:
result = await conn.execute("SELECT version()")
version = result.scalar()
print(f"✅ Подключено к PostgreSQL: {version.split(',')[0]}")
return True
except Exception as e:
print(f"❌ Ошибка подключения к базе данных: {e}")
return False
async def get_existing_plans(session) -> List:
"""Получает существующие тарифные планы"""
result = await session.execute(
"SELECT id, name, price, duration_days FROM plans ORDER BY price"
)
return result.fetchall()
async def clear_table(session, table_name: str = 'plans') -> bool:
"""Очищает указанную таблицу"""
try:
await session.execute(delete(Plan))
await session.commit()
print(f"✅ Таблица '{table_name}' очищена")
return True
except Exception as e:
print(f"❌ Ошибка при очистке таблицы: {e}")
await session.rollback()
return False
async def add_plans_to_db(session, plans_data: List[Dict]) -> int:
"""Добавляет тарифные планы в базу данных"""
try:
added_count = 0
for plan in plans_data:
await session.execute(
insert(Plan).values(**plan)
)
added_count += 1
await session.commit()
return added_count
except Exception as e:
await session.rollback()
raise e
async def print_plans_table(plans: List) -> None:
"""Выводит таблицу с тарифными планами"""
if not plans:
print("📭 Таблица 'plans' пуста")
return
print(f"\n📊 Текущие тарифные планы ({len(plans)} шт.):")
print("-" * 70)
print(f"{'ID':<5} {'Название':<25} {'Цена (руб.)':<15} {'Дней':<10}")
print("-" * 70)
for plan in plans:
print(f"{plan[0]:<5} {plan[1]:<25} {plan[2]:<15} {plan[3]:<10}")
print("-" * 70)
# Подсчет статистики
total_price = sum(float(p[2]) for p in plans)
avg_price = total_price / len(plans) if plans else 0
print(f"💰 Общая сумма всех тарифов: {total_price:.2f} руб.")
print(f"📈 Средняя цена тарифа: {avg_price:.2f} руб.")
print(f"📅 Всего предложений: {len(plans)}")
async def main(config: dict, clear_existing: bool = True, dry_run: bool = False):
"""Основная функция скрипта"""
print_banner()
# Создаем URL для подключения
db_url = create_db_url(config)
print(f"📡 Параметры подключения:")
print(f" Хост: {config['host']}:{config['port']}")
print(f" База данных: {config['database']}")
print(f" Пользователь: {config['user']}")
print(f" {'🚨 РЕЖИМ ТЕСТА (dry-run)' if dry_run else ''}")
print()
try:
# Подключаемся к базе данных
print("🔄 Подключение к базе данных...")
engine = create_async_engine(db_url, echo=False)
# Проверяем подключение
if not await check_connection(engine):
print("\nНе удалось подключиться к базе данных")
return False
# Создаем фабрику сессий
AsyncSessionLocal = async_sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False
)
async with AsyncSessionLocal() as session:
# Получаем текущие тарифы
print("\n🔍 Проверяем существующие тарифы...")
existing_plans = await get_existing_plans(session)
if existing_plans:
await print_plans_table(existing_plans)
if clear_existing and not dry_run:
print("\n⚠️ ВНИМАНИЕ: Будут удалены все существующие тарифы!")
confirm = input("Продолжить? (y/N): ")
if confirm.lower() != 'y':
print("❌ Операция отменена пользователем")
return False
# Очищаем таблицу
await clear_table(session)
elif dry_run:
print("\n⚠️ DRY-RUN: Существующие тарифы НЕ будут удалены")
else:
print("📭 Таблица 'plans' пуста, создаем новые тарифы...")
# Добавляем новые тарифы
if not dry_run:
print(f"\n Добавляем {len(PLANS_DATA)} тарифных планов...")
added_count = await add_plans_to_db(session, PLANS_DATA)
print(f"✅ Успешно добавлено {added_count} тарифов")
else:
print(f"\n⚠️ DRY-RUN: Планируется добавить {len(PLANS_DATA)} тарифов:")
for i, plan in enumerate(PLANS_DATA, 1):
print(f" {i}. {plan['name']} - {plan['price']} руб. ({plan['duration_days']} дней)")
# Показываем финальный результат
print("\n🎯 ФИНАЛЬНЫЙ РЕЗУЛЬТАТ:")
final_plans = await get_existing_plans(session)
await print_plans_table(final_plans)
await engine.dispose()
print("\n✅ Скрипт успешно выполнен!")
return True
except Exception as e:
print(f"\n❌ Критическая ошибка: {e}")
import traceback
traceback.print_exc()
return False
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description='Инициализация тарифных планов в базе данных',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Примеры использования:
%(prog)s # Использует настройки по умолчанию
%(prog)s --no-clear # Не очищать существующие тарифы
%(prog)s --dry-run # Только показать что будет сделано
%(prog)s --host 192.168.1.100 # Указать другой хост
%(prog)s --url "postgresql://..." # Указать полный URL
"""
)
parser.add_argument('--host', help='Хост базы данных', default=DEFAULT_CONFIG['host'])
parser.add_argument('--port', type=int, help='Порт базы данных', default=DEFAULT_CONFIG['port'])
parser.add_argument('--database', help='Имя базы данных', default=DEFAULT_CONFIG['database'])
parser.add_argument('--user', help='Имя пользователя', default=DEFAULT_CONFIG['user'])
parser.add_argument('--password', help='Пароль', default=DEFAULT_CONFIG['password'])
parser.add_argument('--url', help='Полный URL подключения (игнорирует остальные параметры)')
parser.add_argument('--no-clear', action='store_true', help='Не очищать существующие тарифы')
parser.add_argument('--dry-run', action='store_true', help='Только показать что будет сделано')
args = parser.parse_args()
# Формируем конфигурацию
config = DEFAULT_CONFIG.copy()
if args.url:
config['url'] = args.url
else:
config.update({
'host': args.host,
'port': args.port,
'database': args.database,
'user': args.user,
'password': args.password,
'url': None # Будет сгенерирован автоматически
})
# Запускаем скрипт
success = asyncio.run(main(
config=config,
clear_existing=not args.no_clear,
dry_run=args.dry_run
))
sys.exit(0 if success else 1)

View File

@@ -1,26 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIEYzCCA0ugAwIBAgIUEni/Go2t3/FXT7CGBMSnrlDzTKwwDQYJKoZIhvcNAQEL
BQAwgcAxCzAJBgNVBAYTAlVBMRswGQYDVQQIDBJSZXB1YmxpYyBvZiBDcmltZWEx
EzARBgNVBAcMClNpbWZlcm9wb2wxFzAVBgNVBAoMDkxhcmsgQ28gU3lzdGVtMSUw
IwYDVQQLDBxMYXJrIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MRgwFgYDVQQDDA9M
YXJrIFRydXN0ZWQgQ0ExJTAjBgkqhkiG9w0BCQEWFmxhcmtjb3N5c3RlbUBwcm90
b24ubWUwHhcNMjQxMjI3MTQ1NzQ2WhcNMzQxMjI1MTQ1NzQ2WjCBwDELMAkGA1UE
BhMCVUExGzAZBgNVBAgMElJlcHVibGljIG9mIENyaW1lYTETMBEGA1UEBwwKU2lt
ZmVyb3BvbDEXMBUGA1UECgwOTGFyayBDbyBTeXN0ZW0xJTAjBgNVBAsMHExhcmsg
Q2VydGlmaWNhdGlvbiBBdXRob3JpdHkxGDAWBgNVBAMMD0xhcmsgVHJ1c3RlZCBD
QTElMCMGCSqGSIb3DQEJARYWbGFya2Nvc3lzdGVtQHByb3Rvbi5tZTCCASIwDQYJ
KoZIhvcNAQEBBQADggEPADCCAQoCggEBAOzb2ibfe4Arrf5O3d15kObBJQkxcSGi
fzrtYj68/y0ZyNV3BTvp+gCdlmo+WqOrdgD4LCOod0585S2MLCxjvVIcuA+DIq6z
gxZvf6V1FRKjHO3s18HhUX6nl8LYe6bOveqHAiDf9TZ+8grJXYpGD2tybAofXkL5
8dmn5Jh10DTV2EBHwutET2hoBqSorop/Ro/zawYPOlMZuGXP4Txs/erUmNCzGm+b
AYw6qjBm+o9RG2AWzKVBI06/kFKA5vq7ATcEs2U5bdINy/U1u2vc1R08YuvTpPCh
2Q0uBn49T+WhiF9CpAYBoMj51Am22NqKWsc617ZFkl1OO3mWd4+mgocCAwEAAaNT
MFEwHQYDVR0OBBYEFAXCcmOWdaInuJLeY/5CRfdzb49+MB8GA1UdIwQYMBaAFAXC
cmOWdaInuJLeY/5CRfdzb49+MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEL
BQADggEBADoyGv2Gdem/zrHCEy43WlXo9uFqKCX/Z/Rlr+V9OmRodZx83j2B0xIB
QdjuEP/EaxEtuGL98TIln7u8PX/FKApbZAk9zxrn0JNQ6bAVpsLBK1i+w3aw2XlN
p6qmFoc66Z8B1OUiGHrWczw0cV4rr8XoGwD5KS/jXYyuT+JTFBdsYXmUXqwqcwHY
N4qXRKh8FtqTgvjb/TpETMr7bnEHkn0vUwMwKwRe4TB1VwFIAaJeh7DPnrchy5xQ
EpS2DIQoO+ZoOaQYIkFT/8c7zpN79fy5uuVfW4XL8OS7sbZkzsl2YJDtO5zCEDNx
CJeEKQYXpCXRi+n3RvsIedshrnmqZcg=
-----END CERTIFICATE-----

View File

@@ -1 +0,0 @@
{"success":true,"msg":"","obj":{"id":1,"up":301130896430,"down":4057274949955,"total":0,"remark":"vlv","enable":true,"expiryTime":0,"clientStats":null,"listen":"","port":443,"protocol":"vless","settings":"{\n \"clients\": [\n {\n \"email\": \"j8oajwd3\",\n \"enable\": true,\n \"expiryTime\": 0,\n \"flow\": \"xtls-rprx-vision\",\n \"id\": \"a31d71a4-6afd-4f36-96f6-860691b52873\",\n \"limitIp\": 2,\n \"reset\": 0,\n \"subId\": \"ox2awiqwduryuqnz\",\n \"tgId\": 1342351277,\n \"totalGB\": 0\n },\n {\n \"email\": \"cvvbqpm2\",\n \"enable\": true,\n \"expiryTime\": 0,\n \"flow\": \"xtls-rprx-vision\",\n \"id\": \"b6882942-d69d-4d5e-be9a-168ac89b20b6\",\n \"limitIp\": 1,\n \"reset\": 0,\n \"subId\": \"jk289x00uf7vbr9x\",\n \"tgId\": 123144325,\n \"totalGB\": 0\n },\n {\n \"email\": \"k15vx82w\",\n \"enable\": true,\n \"expiryTime\": 0,\n \"flow\": \"\",\n \"id\": \"3c88e5bb-53ba-443d-9d68-a09f5037030c\",\n \"limitIp\": 0,\n \"reset\": 0,\n \"subId\": \"5ffz56ofveepep1t\",\n \"tgId\": 5364765066,\n \"totalGB\": 0\n },\n {\n \"email\": \"gm5x10tr\",\n \"enable\": true,\n \"expiryTime\": 0,\n \"flow\": \"xtls-rprx-vision\",\n \"id\": \"c0b9ff6c-4c48-4d75-8ca0-c13a2686fa5d\",\n \"limitIp\": 0,\n \"reset\": 0,\n \"subId\": \"132pioffqi6gwhw6\",\n \"tgId\": \"\",\n \"totalGB\": 0\n }\n ],\n \"decryption\": \"none\",\n \"fallbacks\": []\n}","streamSettings":"{\n \"network\": \"tcp\",\n \"security\": \"reality\",\n \"externalProxy\": [],\n \"realitySettings\": {\n \"show\": false,\n \"xver\": 0,\n \"dest\": \"google.com:443\",\n \"serverNames\": [\n \"google.com\",\n \"www.google.com\"\n ],\n \"privateKey\": \"gKsDFmRn0vyLMUdYdk0ZU_LVyrQh7zMl4r-9s0nNFCk\",\n \"minClient\": \"\",\n \"maxClient\": \"\",\n \"maxTimediff\": 0,\n \"shortIds\": [\n \"edfaf8ab\"\n ],\n \"settings\": {\n \"publicKey\": \"Bha0eW7nfRc69CdZyF9HlmGVvtAeOJKammhwf4WShTU\",\n \"fingerprint\": \"random\",\n \"serverName\": \"\",\n \"spiderX\": \"/\"\n }\n },\n \"tcpSettings\": {\n \"acceptProxyProtocol\": false,\n \"header\": {\n \"type\": \"none\"\n }\n }\n}","tag":"inbound-443","sniffing":"{\n \"enabled\": true,\n \"destOverride\": [\n \"http\",\n \"tls\",\n \"quic\",\n \"fakedns\"\n ],\n \"metadataOnly\": false,\n \"routeOnly\": false\n}"}}

View File

@@ -1,8 +0,0 @@
{
"name": "Lark_Standart_1",
"normalName": "Lark Standart",
"type": "standart",
"duration_months": 1,
"ip_limit": 1,
"price": 200
}

View File

@@ -1,8 +0,0 @@
{
"name": "Lark_Standart_6",
"normalName": "Lark Standart",
"type": "standart",
"duration_months": 6,
"ip_limit": 1,
"price": 1000
}

View File

@@ -1,8 +0,0 @@
{
"name": "Lark_Standart_12",
"normalName": "Lark Standart",
"type": "standart",
"duration_months": 12,
"ip_limit": 1,
"price": 2000
}

View File

@@ -1,8 +0,0 @@
{
"name": "Lark_Pro_1",
"normalName": "Lark Pro",
"type": "pro",
"duration_months": 1,
"ip_limit": 5,
"price": 600
}

View File

@@ -1,8 +0,0 @@
{
"name": "Lark_Pro_6",
"normalName": "Lark Pro",
"type": "pro",
"duration_months": 6,
"ip_limit": 5,
"price": 3000
}

View File

@@ -1,8 +0,0 @@
{
"name": "Lark_Pro_12",
"normalName": "Lark Pro",
"type": "pro",
"duration_months": 12,
"ip_limit": 5,
"price": 5000
}

View File

View File