diff --git a/api/main.py b/api/main.py index cf4ace22..31189984 100644 --- a/api/main.py +++ b/api/main.py @@ -3,7 +3,7 @@ from api.routes import users, keys, coupons, servers, tariffs, gifts, referrals, app = FastAPI( title="SoloBot API (preAlpha)", - version="0.1.2", + version="0.2.0", docs_url="/api/docs", redoc_url="/api/redoc", openapi_url="/api/openapi.json" diff --git a/api/routes/keys.py b/api/routes/keys.py index bd0166ff..98d4be8e 100644 --- a/api/routes/keys.py +++ b/api/routes/keys.py @@ -1,16 +1,16 @@ -from fastapi import Depends, HTTPException, Path, Body +from fastapi import Depends, HTTPException, Path, Body, status from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from datetime import datetime from database.models import Key, Admin, Tariff -from api.schemas.keys import KeyBase, KeyResponse, KeyUpdate +from api.schemas.keys import KeyBase, KeyResponse, KeyUpdate, KeyCreateRequest from api.routes.base_crud import generate_crud_router from api.depends import get_session, verify_admin_token from handlers.keys.key_utils import delete_key_from_cluster from logger import logger -from handlers.keys.key_utils import renew_key_in_cluster +from handlers.keys.key_utils import renew_key_in_cluster, create_key_on_cluster router = generate_crud_router( model=Key, @@ -119,3 +119,32 @@ async def edit_key_by_email( except Exception as e: logger.error(f"[API] Ошибка при обновлении ключа: {e}") raise HTTPException(status_code=500, detail="Ошибка при обновлении ключа") + + +@router.post("/create", response_model=dict, status_code=status.HTTP_201_CREATED) +async def create_key_api( + payload: KeyCreateRequest = Body(...), + session: AsyncSession = Depends(get_session), + admin: Admin = Depends(verify_admin_token), +): + logger.info(f"[API] Запрос на создание ключа: {payload.dict()}") + + try: + await create_key_on_cluster( + cluster_id=payload.cluster_id, + tg_id=payload.tg_id, + client_id=payload.client_id, + email=payload.email or f"{payload.tg_id}_key", + expiry_timestamp=payload.expiry_timestamp, + plan=payload.tariff_id, + session=session, + remnawave_link=payload.remnawave_link, + hwid_limit=payload.hwid_limit, + traffic_limit_bytes=payload.traffic_limit_bytes, + is_trial=payload.is_trial or False, + ) + return {"message": "Ключ успешно создан"} + + except Exception as e: + logger.error(f"[API] Ошибка при создании ключа: {e}") + raise HTTPException(status_code=500, detail="Ошибка при создании ключа") \ No newline at end of file diff --git a/api/schemas/coupons.py b/api/schemas/coupons.py index 98a1669a..b81fb08f 100644 --- a/api/schemas/coupons.py +++ b/api/schemas/coupons.py @@ -13,10 +13,12 @@ class CouponBase(BaseModel): @model_validator(mode="after") def check_exactly_one_of_amount_or_days(self) -> "CouponBase": - has_amount = getattr(self, "amount", None) is not None - has_days = getattr(self, "days", None) is not None + has_amount = self.amount not in (None, 0) + has_days = self.days is not None - if has_amount == has_days: + if has_amount and has_days: + raise ValueError("Coupon must have exactly one of: 'amount' or 'days'") + if not has_amount and not has_days: raise ValueError("Coupon must have exactly one of: 'amount' or 'days'") return self diff --git a/api/schemas/keys.py b/api/schemas/keys.py index fdc181d2..c4d821f9 100644 --- a/api/schemas/keys.py +++ b/api/schemas/keys.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional @@ -63,9 +63,15 @@ class KeyUpdate(BaseModel): class KeyCreateRequest(BaseModel): - tg_id: int - cluster_id: str - tariff_id: int - email: Optional[str] = None - alias: Optional[str] = None - remnawave_link: Optional[str] = None \ No newline at end of file + tg_id: int = Field(..., description="Telegram ID пользователя") + cluster_id: str = Field(..., description="Имя кластера или сервера") + tariff_id: int = Field(..., description="ID тарифа из базы данных") + client_id: str = Field(..., description="UUID клиента (уникальный)") + expiry_timestamp: int = Field(..., description="Срок окончания в миллисекундах") + + email: Optional[str] = Field(None, description="Условное имя подписки") + alias: Optional[str] = Field(None, description="пользовательское имя") + remnawave_link: Optional[str] = Field(None, description="Ссылка на подписку Remnawave") + hwid_limit: Optional[int] = Field(None, description="Ограничение по HWID") + traffic_limit_bytes: Optional[int] = Field(None, description="Ограничение трафика в байтах") + is_trial: Optional[bool] = Field(False, description="Флаг триального ключа") \ No newline at end of file diff --git a/handlers/admin/sender/sender_handler.py b/handlers/admin/sender/sender_handler.py index e90e2780..540ac1e2 100644 --- a/handlers/admin/sender/sender_handler.py +++ b/handlers/admin/sender/sender_handler.py @@ -6,6 +6,7 @@ from aiogram import F, Router from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from aiogram.types import CallbackQuery, Message, InlineKeyboardMarkup, InlineKeyboardButton +from aiogram.exceptions import TelegramBadRequest from sqlalchemy import distinct, func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -84,10 +85,16 @@ def parse_message_buttons(text: str) -> tuple[str, InlineKeyboardMarkup | None]: IsAdminFilter(), ) async def handle_sender(callback_query: CallbackQuery): - await callback_query.message.edit_text( - text="✍️ Выберите группу пользователей для рассылки:", - reply_markup=build_sender_kb(), - ) + try: + await callback_query.message.edit_text( + text="✍️ Выберите группу пользователей для рассылки:", + reply_markup=build_sender_kb(), + ) + except TelegramBadRequest as e: + if "message is not modified" in str(e): + logger.debug("[Sender] Сообщение не изменено, Telegram отклонил редактирование") + else: + raise @router.callback_query( diff --git a/handlers/admin/users/users_handler.py b/handlers/admin/users/users_handler.py index 7c9a9886..36fca181 100644 --- a/handlers/admin/users/users_handler.py +++ b/handlers/admin/users/users_handler.py @@ -1341,7 +1341,7 @@ async def change_expiry_time( tariff = result.first() if tariff: traffic_limit = int(tariff[0]) if tariff[0] is not None else 0 - device_limit = int(tariff[1]) if tariff[1] is not None else None + device_limit = int(tariff[1]) if tariff[1] is not None else 0 servers = await get_servers(session=session) diff --git a/handlers/coupons.py b/handlers/coupons.py index c260fa4f..9c8dcc8f 100644 --- a/handlers/coupons.py +++ b/handlers/coupons.py @@ -235,7 +235,7 @@ async def handle_key_extension( if key.tariff_id: tariff = await get_tariff_by_id(session, key.tariff_id) total_gb = int(tariff["traffic_limit"]) if tariff and tariff.get("traffic_limit") else 0 - device_limit = int(tariff["device_limit"]) if tariff and tariff.get("device_limit") else None + device_limit = int(tariff["device_limit"]) if tariff and tariff.get("device_limit") else 0 await renew_key_in_cluster( cluster_id=key.server_id, diff --git a/handlers/keys/key_mode/key_cluster_mode.py b/handlers/keys/key_mode/key_cluster_mode.py index 81a72635..8070d73b 100644 --- a/handlers/keys/key_mode/key_cluster_mode.py +++ b/handlers/keys/key_mode/key_cluster_mode.py @@ -79,7 +79,7 @@ async def key_cluster_mode( is_trial = data.get("is_trial", False) device_limit = 0 - traffic_limit_gb = None + traffic_limit_gb = 0 if is_trial: device_limit = TRIAL_CONFIG.get("hwid_limit", 0) diff --git a/handlers/keys/key_utils.py b/handlers/keys/key_utils.py index 4a0d726e..915e87f3 100644 --- a/handlers/keys/key_utils.py +++ b/handlers/keys/key_utils.py @@ -132,6 +132,7 @@ async def create_key_on_cluster( user_data["shortUuid"] = short_uuid if hwid_limit is not None: user_data["hwidDeviceLimit"] = hwid_limit + logger.info(f"[Key Creation] Данные для создания клиента в Remnawave: {user_data}") result = await remna.create_user(user_data) if not result: @@ -236,7 +237,7 @@ async def create_client_on_server( sub_id = unique_email total_gb_value = 0 - device_limit_value = None + device_limit_value = 0 if is_trial: total_gb_value = TRIAL_CONFIG.get("traffic_limit_gb", 0)